diff --git a/.github/workflows/promote-develop-to-main-on-tag.yml b/.github/workflows/promote-develop-to-main-on-tag.yml new file mode 100644 index 0000000..916a2a1 --- /dev/null +++ b/.github/workflows/promote-develop-to-main-on-tag.yml @@ -0,0 +1,144 @@ +name: promote-develop-to-main-on-tag + +# ============================================================================ +# REUSABLE WORKFLOW A — the tag -> CI -> auto-PR -> auto-merge piece of the +# release model. Called by a leaf repo's thin `release.yml` on a `v*` tag, +# BEFORE reusable B (pypi-publish-and-github-release-on-tag.yml) publishes. +# DRAFT PROPOSAL — not yet adopted by any leaf repo. +# +# WHY A REUSABLE (not just documented triggers) — the DECISION, stated: +# The leaf caller composes A then B with a single `needs:` edge +# (publish needs promote). For `needs` to gate B on A's completion they +# must be sibling JOBS in ONE caller workflow, which means BOTH have to be +# reusables the caller `uses:`. The promote+auto-merge logic is also +# identical across all 68 repos, so it is worth deduplicating exactly once +# here rather than re-inlining a `gh pr create && gh pr merge` block per +# repo. Hence: A is a reusable, mirroring B. +# +# WHAT IT DOES (operator model steps 1-2): +# 1. ci — run full CI on the TAGGED commit (calls the org pytest-matrix +# reusable). Nothing is promoted unless CI is green on the exact +# commit being released. +# 2. promote — open a develop->main PR at that same commit and auto-merge it +# with a MERGE commit (NEVER squash). Merge-not-squash keeps the +# tag's sha an ancestor of main, which is the precondition +# reusable B's containment guard verifies. If a squash slips +# through anyway, B fails RED — this is the belt, B is the +# braces. +# +# ACTOR FEEDBACK (A->B => B->A): each job writes a repo+stage verdict into the +# run summary the tag pusher (github.actor) sees; FLEET-HOOK markers show where +# a scitex-todo card / DM plugs in. +# +# RUNNER: `runs-on-json` selects the runner for the promote job (hosted callers +# pass '"ubuntu-latest"'; sif callers pass the self-hosted label array). NOTE: +# the CI job delegates to the org pytest-matrix reusable, which is self-hosted +# by construction — see RELEASE-MODEL.md "human decisions" for the hosted-only +# CI caveat. +# ============================================================================ + +on: + workflow_call: + inputs: + runs-on-json: + description: >- + JSON for the promote job's runs-on. hosted callers pass + '"ubuntu-latest"'; sif callers pass + '["self-hosted","Linux","X64","scitex-ci"]'. Consumed via fromJSON. + required: false + type: string + default: '["self-hosted","Linux","X64","scitex-ci"]' + develop-branch: + description: 'Source branch the tag is cut on.' + required: false + type: string + default: 'develop' + main-branch: + description: 'Promotion target branch.' + required: false + type: string + default: 'main' + +jobs: + # -------------------------------------------------------------------------- + # CI on the tagged commit — org-standard pytest-matrix. Reusable `uses:` + # must be a literal (no expressions), so the CI workflow is fixed here. + # -------------------------------------------------------------------------- + ci: + uses: scitex-ai/.github/.github/workflows/pytest-matrix.yml@main + secrets: inherit + + # -------------------------------------------------------------------------- + # PROMOTE — open develop->main PR at the tagged commit and auto-merge it + # with a MERGE commit. Only runs if `ci` succeeded. + # -------------------------------------------------------------------------- + promote: + needs: ci + runs-on: ${{ fromJSON(inputs.runs-on-json) }} + timeout-minutes: 20 + permissions: + contents: write + pull-requests: write + steps: + - name: Resolve version from the triggering tag + id: ver + run: | + set -euo pipefail + v="${GITHUB_REF#refs/tags/}" + echo "version=$v" >> "$GITHUB_OUTPUT" + + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Open + auto-merge develop -> main (MERGE commit, never squash) + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + DEV: ${{ inputs.develop-branch }} + MAIN: ${{ inputs.main-branch }} + TAG: ${{ steps.ver.outputs.version }} + run: | + set -euo pipefail + git fetch --no-tags --prune origin "$DEV" "$MAIN" + + # Already contained? (develop already merged, or re-run) -> nothing to do. + if git merge-base --is-ancestor "origin/$DEV" "origin/$MAIN"; then + echo "origin/$DEV is already contained in origin/$MAIN — no promotion PR needed." + echo "### release A / promote — \`SKIP\` ($REPO @ $TAG): $DEV already in $MAIN" >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + + # Reuse an open PR if one already exists, else create it. + num="$(gh pr list --repo "$REPO" --base "$MAIN" --head "$DEV" --state open --json number --jq '.[0].number // empty')" + if [ -z "${num:-}" ]; then + num="$(gh pr create --repo "$REPO" --base "$MAIN" --head "$DEV" \ + --title "release: promote $DEV -> $MAIN ($TAG)" \ + --body "Automated promotion for release **$TAG** (reusable A). Merged with a MERGE commit so the tag's commit stays an ancestor of $MAIN — reusable B asserts exactly that before publishing." \ + | sed -nE 's#.*/pull/([0-9]+).*#\1#p')" + fi + test -n "${num:-}" || { echo "::error::could not resolve a develop->main PR number for $TAG"; exit 1; } + echo "promotion PR: #$num" + + # MERGE commit (NOT --squash, NOT --rebase): preserves the tag sha in + # $MAIN history so B's containment guard passes. --admin bypasses any + # required-review gate for this automated same-commit promotion. + gh pr merge "$num" --repo "$REPO" --merge --admin + + # Verify the promotion actually contained the tag commit — loud-fail early. + git fetch --no-tags --prune origin "$MAIN" + TAG_SHA="$(git rev-list -n1 "refs/tags/$TAG")" + if git merge-base --is-ancestor "$TAG_SHA" "origin/$MAIN"; then + echo "### release A / promote — \`MERGED\` ($REPO @ $TAG): #$num, tag contained in $MAIN" >> "$GITHUB_STEP_SUMMARY" + else + echo "::error::promotion merged PR #$num but tag $TAG ($TAG_SHA) is NOT in $MAIN — a squash/rebase merge policy likely rewrote the sha. Reconfigure the repo to allow merge commits for this PR. @${{ github.actor }}" + echo "### release A / promote — \`BAD-MERGE\` ($REPO @ $TAG): tag not in $MAIN after merge" >> "$GITHUB_STEP_SUMMARY" + # FLEET-HOOK: DM github.actor — promotion succeeded but broke containment. + exit 1 + fi + + - name: Actor feedback — promote verdict + if: always() + run: | + echo "- release A actor: @${{ github.actor }} · status: ${{ job.status }}" >> "$GITHUB_STEP_SUMMARY" + # FLEET-HOOK: report CI+promote result to github.actor here. diff --git a/.github/workflows/pypi-publish-and-github-release-on-tag.yml b/.github/workflows/pypi-publish-and-github-release-on-tag.yml new file mode 100644 index 0000000..8e2c1c9 --- /dev/null +++ b/.github/workflows/pypi-publish-and-github-release-on-tag.yml @@ -0,0 +1,276 @@ +name: pypi-publish-and-github-release-on-tag + +# ============================================================================ +# REUSABLE WORKFLOW B — build + CONTAINMENT-GUARD + publish + GitHub Release, +# from a version tag. Called (never triggered directly) by a leaf repo's thin +# `release.yml` caller AFTER the tag's commit has landed on main (see reusable +# A: promote-develop-to-main-on-tag.yml). DRAFT PROPOSAL — not yet adopted by +# any leaf repo. +# +# WHY THIS EXISTS +# 68 repos each inline their own copy of this pipeline in two real variants +# (hosted-OIDC and self-hosted-SIF). There is no reusable to delegate to, so +# the copies cannot be deduplicated. This is that reusable. It parametrizes +# BOTH backends behind one `publish-backend` input. +# +# THE RELEASE MODEL (operator-confirmed 2026-07-23) — see RELEASE-MODEL.md. +# A release is cut by pushing `vX.Y.Z` on develop. Reusable A runs CI on the +# tagged commit and, if green, opens+auto-merges a develop->main PR (same +# commit, MERGE not squash). Once main contains the tag's commit, this +# reusable builds, ASSERTS THE TAG IS CONTAINED IN main (loud-fail guard), +# publishes to PyPI, and cuts the GitHub Release — all FROM THE TAG. +# +# THE CONTAINMENT GUARD (operator's loud-fail principle) — the whole point. +# A squash merge does not fail; it silently mints a NEW sha, so the tag's +# commit leaves main's history and setuptools-scm miscomputes the version. +# We do NOT fight that with repo settings. At publish time we assert +# `git merge-base --is-ancestor origin/main`. Not contained => +# ABORT publish, fail RED. Works under squash OR fast-forward. +# +# ACTOR FEEDBACK (operator's A->B => B->A principle). +# Every job writes a one-line PASS/FAIL verdict — repo + stage + tag — +# into $GITHUB_STEP_SUMMARY, and a final always()-job posts a consolidated +# verdict to the run summary that github.actor (the tag pusher) sees on the +# failing run. FLEET-HOOK markers show exactly where a scitex-todo card / DM +# would plug in for out-of-band notification. +# +# TWO BACKENDS (input `publish-backend`): +# hosted — GitHub-hosted ubuntu runner + PyPI Trusted Publisher via the +# pypa/gh-action-pypi-publish Docker action (OIDC). setup-python. +# sif — self-hosted Spartan pool; build/publish INSIDE the reused +# scitex-ci SIF via .github/ci/*.sh (the bare node has no Python +# and no Docker, so the pypa action cannot run there — publish is +# MANUAL OIDC: GitHub JWT -> PyPI mint-token -> twine). +# The caller selects the backend AND the matching runner via `runs-on-json` +# (hosted callers pass '"ubuntu-latest"'; sif callers pass the self-hosted +# label array). No-hosted-runners repos MUST use backend=sif — never let a +# sif repo land on a hosted runner (respects the no-hosted-runners guard). +# ============================================================================ + +on: + workflow_call: + inputs: + publish-backend: + description: 'Publish backend: "hosted" (ubuntu + pypa action) or "sif" (self-hosted Spartan SIF, manual OIDC).' + required: true + type: string + runs-on-json: + description: >- + JSON for runs-on. hosted callers pass '"ubuntu-latest"'; + sif callers pass '["self-hosted","Linux","X64","scitex-ci"]'. + Consumed via fromJSON so a bare quoted string or an array both work. + required: false + type: string + default: '["self-hosted","Linux","X64","scitex-ci"]' + python-version: + description: 'Python used to build the dist (and the SIF venv tag for the sif backend).' + required: false + type: string + default: '3.12' + pypi-project: + description: 'PyPI project name — display only, for the deployment environment URL.' + required: false + type: string + default: '' + secrets: + # Both backends publish via OIDC Trusted Publishing, so NO API-token + # secret is required by default. This optional secret is the documented + # fallback for a repo that publishes with a classic PyPI API token + # instead of a trusted publisher (DECISION-FOR-REVIEW in the PR body). + PYPI_API_TOKEN: + required: false + +jobs: + # -------------------------------------------------------------------------- + # BUILD — wheel + sdist from the tag. Hosted: setup-python + `python -m + # build`. Sif: build INSIDE the reused SIF via .github/ci/build-in-sif.sh. + # -------------------------------------------------------------------------- + build: + runs-on: ${{ fromJSON(inputs.runs-on-json) }} + timeout-minutes: 30 + outputs: + version: ${{ steps.ver.outputs.version }} + steps: + - name: Resolve version from the triggering tag + id: ver + run: | + set -euo pipefail + v="${GITHUB_REF#refs/tags/}" + echo "version=$v" >> "$GITHUB_OUTPUT" + echo "resolved release tag: $v" + + - uses: actions/checkout@v4 + with: + ref: ${{ github.ref }} + fetch-depth: 0 # full history + tags => setuptools-scm computes the tagged version + + # ---- hosted build ---- + - name: (hosted) Set up Python + if: inputs.publish-backend == 'hosted' + uses: actions/setup-python@v5 + with: + python-version: ${{ inputs.python-version }} + - name: (hosted) Build sdist + wheel + if: inputs.publish-backend == 'hosted' + run: | + set -euo pipefail + python -m pip install --upgrade pip build + python -m build + test -n "$(ls -A dist 2>/dev/null)" || { echo "::error::dist/ is empty after build"; exit 1; } + + # ---- sif build (self-hosted; no setup-python on the bare Spartan node) ---- + - name: (sif) Build wheel + sdist in the reused CI SIF (apptainer exec) + if: inputs.publish-backend == 'sif' + run: bash .github/ci/exec-in-sif.sh build-in-sif.sh ${{ inputs.python-version }} + + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + - name: Actor feedback — build verdict + if: always() + run: | + verdict="${{ job.status }}" + echo "### release B / build — \`$verdict\` (${{ github.repository }} @ ${{ steps.ver.outputs.version }})" >> "$GITHUB_STEP_SUMMARY" + echo "- actor: @${{ github.actor }} · backend: ${{ inputs.publish-backend }}" >> "$GITHUB_STEP_SUMMARY" + # FLEET-HOOK: on failure, emit a scitex-todo card / DM to github.actor here. + + # -------------------------------------------------------------------------- + # PUBLISH — containment guard THEN publish. The guard is the load-bearing + # step: it makes the silent squash-merge failure LOUD. + # -------------------------------------------------------------------------- + publish: + needs: build + runs-on: ${{ fromJSON(inputs.runs-on-json) }} + timeout-minutes: 30 + environment: + name: pypi + url: ${{ inputs.pypi-project != '' && format('https://pypi.org/p/{0}', inputs.pypi-project) || 'https://pypi.org/' }} + permissions: + id-token: write # OIDC: populates ACTIONS_ID_TOKEN_REQUEST_{TOKEN,URL} for both backends + contents: read + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.ref }} + fetch-depth: 0 + + # ---- THE CONTAINMENT GUARD (operator loud-fail; works under squash OR ff) ---- + - name: Containment guard — the tag's commit MUST be contained in origin/main + run: | + set -euo pipefail + TAG="${GITHUB_REF#refs/tags/}" + git fetch --no-tags --prune origin main + TAG_SHA="$(git rev-list -n1 "refs/tags/${TAG}")" + echo "tag ${TAG} -> ${TAG_SHA}" + if git merge-base --is-ancestor "${TAG_SHA}" origin/main; then + echo "OK: ${TAG} (${TAG_SHA}) is an ancestor of origin/main — safe to publish." + echo "### release B / containment guard — \`PASS\` (${{ github.repository }} @ ${TAG})" >> "$GITHUB_STEP_SUMMARY" + else + echo "::error::CONTAINMENT GUARD FAILED: tag ${TAG} commit ${TAG_SHA} is NOT an ancestor of origin/main. A squash merge likely rewrote the sha, so this tag's commit left main's history and setuptools-scm would miscompute the version. ABORTING publish. Re-promote develop->main with a MERGE (not squash) commit, then re-run." + echo "### release B / containment guard — \`ABORT\` (${{ github.repository }} @ ${TAG})" >> "$GITHUB_STEP_SUMMARY" + echo "- @${{ github.actor }}: tag commit not contained in main — publish aborted (loud-fail)." >> "$GITHUB_STEP_SUMMARY" + # FLEET-HOOK: this is the highest-value notification — DM github.actor. + exit 1 + fi + + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist + + # ---- hosted publish: pypa Docker action (OIDC trusted publisher) ---- + - name: (hosted) Publish to PyPI (OIDC trusted publisher) + if: inputs.publish-backend == 'hosted' + uses: pypa/gh-action-pypi-publish@release/v1 + + # ---- sif publish: manual OIDC inside the SIF (no Docker on Spartan) ---- + - name: (sif) Publish to PyPI via manual OIDC trusted publishing in the SIF + if: inputs.publish-backend == 'sif' + run: bash .github/ci/exec-in-sif.sh publish-in-sif.sh ${{ inputs.python-version }} + + - name: Actor feedback — publish verdict + if: always() + run: | + verdict="${{ job.status }}" + echo "### release B / publish — \`$verdict\` (${{ github.repository }} @ ${{ needs.build.outputs.version }})" >> "$GITHUB_STEP_SUMMARY" + echo "- actor: @${{ github.actor }} · backend: ${{ inputs.publish-backend }}" >> "$GITHUB_STEP_SUMMARY" + # FLEET-HOOK: report publish success/abort to github.actor here. + + # -------------------------------------------------------------------------- + # RELEASE — GitHub Release from the tag. softprops action (a JS action) runs + # on BOTH hosted and self-hosted (the bare Spartan node has no `gh` CLI, so + # `gh release create` is not portable across backends). + # -------------------------------------------------------------------------- + release: + needs: [build, publish] + runs-on: ${{ fromJSON(inputs.runs-on-json) }} + timeout-minutes: 15 + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.build.outputs.version }} + fetch-depth: 0 + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist + - name: Extract release notes from CHANGELOG.md + run: | + set -euo pipefail + VERSION="${{ needs.build.outputs.version }}" + VERSION="${VERSION#v}" + if [ -f CHANGELOG.md ]; then + awk -v v="$VERSION" ' + $0 ~ "^## \\[" v "\\]" {p=1; next} + p && /^## \[/ {exit} + p + ' CHANGELOG.md > release-notes.md || true + fi + if [ ! -s release-notes.md ]; then + echo "Release ${VERSION}. See CHANGELOG.md for details." > release-notes.md + fi + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ needs.build.outputs.version }} + name: ${{ needs.build.outputs.version }} + body_path: release-notes.md + files: dist/* + fail_on_unmatched_files: true + - name: Actor feedback — release verdict + if: always() + run: | + verdict="${{ job.status }}" + echo "### release B / github-release — \`$verdict\` (${{ github.repository }} @ ${{ needs.build.outputs.version }})" >> "$GITHUB_STEP_SUMMARY" + echo "- actor: @${{ github.actor }}" >> "$GITHUB_STEP_SUMMARY" + + # -------------------------------------------------------------------------- + # ACTOR FEEDBACK ROLL-UP — one consolidated verdict the tag pusher reads on + # the run. Fails RED if any upstream job failed, so github.actor's run- + # failure notification carries the whole-pipeline verdict. + # -------------------------------------------------------------------------- + report: + needs: [build, publish, release] + if: always() + runs-on: ${{ fromJSON(inputs.runs-on-json) }} + steps: + - name: Consolidated verdict to github.actor + run: | + set -euo pipefail + B="${{ needs.build.result }}"; P="${{ needs.publish.result }}"; R="${{ needs.release.result }}" + { + echo "## Release pipeline verdict — ${{ github.repository }} @ ${{ needs.build.outputs.version }}" + echo "- pusher: @${{ github.actor }}" + echo "- build: \`$B\` · publish: \`$P\` · release: \`$R\`" + } >> "$GITHUB_STEP_SUMMARY" + # FLEET-HOOK: single consolidated scitex-todo card / DM to @${{ github.actor }} here. + if [ "$B" = "success" ] && [ "$P" = "success" ] && [ "$R" = "success" ]; then + echo "release OK" + else + echo "::error::release pipeline FAILED for ${{ github.repository }} @ ${{ needs.build.outputs.version }} (build=$B publish=$P release=$R) — see the failing stage above, @${{ github.actor }}." + exit 1 + fi diff --git a/RELEASE-MODEL.md b/RELEASE-MODEL.md new file mode 100644 index 0000000..01cdb9d --- /dev/null +++ b/RELEASE-MODEL.md @@ -0,0 +1,171 @@ +# SciTeX release model (DRAFT proposal — for operator review, not adopted) + +This document + the two reusable workflows it describes are a **draft +proposal**. Nothing here is wired into any leaf repo yet. The goal is to +replace the 68 inlined, drifting copies of +`pypi-publish-and-github-release-on-tag.yml` with **one** org reusable, and to +implement the operator-confirmed release model (TG 2026-07-23) exactly. + +## The flow + +A release is cut by **pushing a version tag on `develop`**: + +``` +git tag vX.Y.Z && git push origin vX.Y.Z +``` + +Everything else is automatic. The leaf repo's thin `release.yml` (see +`workflow-templates/release.yml`) composes two org reusables: + +``` + tag v* on develop + │ + ▼ + ┌─────────────────────────────────────────────────────────────┐ + │ Reusable A — promote-develop-to-main-on-tag.yml │ + │ ci : run full CI on the TAGGED commit (pytest-matrix)│ + │ promote : if green, open develop→main PR at the same │ + │ commit and auto-merge it with a MERGE commit │ + │ (never squash) → main now contains the tag sha │ + └─────────────────────────────────────────────────────────────┘ + │ (caller: publish `needs: promote`) + ▼ + ┌─────────────────────────────────────────────────────────────┐ + │ Reusable B — pypi-publish-and-github-release-on-tag.yml │ + │ build : wheel + sdist from the tag │ + │ publish : CONTAINMENT GUARD, then PyPI (hosted | sif) │ + │ release : GitHub Release from the tag │ + │ report : consolidated verdict → github.actor │ + └─────────────────────────────────────────────────────────────┘ +``` + +Because the caller wires `publish: {needs: promote}`, **B never runs until A +has landed the tag's commit on `main`**. Both are reusables (not a single +mega-workflow) so the caller can express that one `needs:` edge — that is the +reason A is a reusable and not just documented triggers. + +## The containment guard (the load-bearing part) + +A **squash merge does not fail** — it silently creates a *new* SHA, so the +tag's commit leaves `main`'s history and `setuptools-scm` miscomputes the +version. We do **not** fight this with repo settings (they can be changed, and +a wrong setting fails silently). Instead, at publish time in B we assert the +tag's commit is contained in `main`: + +```bash +TAG_SHA="$(git rev-list -n1 "refs/tags/${TAG}")" +git fetch --no-tags --prune origin main +git merge-base --is-ancestor "${TAG_SHA}" origin/main \ + || { echo "::error::CONTAINMENT GUARD FAILED — tag not in main"; exit 1; } +``` + +Not contained → **ABORT publish, fail RED.** This turns the silent squash +failure into a loud one and works under squash **or** fast-forward. Reusable A +also merges with `--merge` (never `--squash`) and re-checks containment right +after merging, so the guard in B is the backstop, not the only line of +defence. + +## The feedback loop (A→B ⇒ B→A) + +Each stage reports its result back to `github.actor` (the tag pusher): + +- Every job appends a `repo + stage + tag + verdict` line to + `$GITHUB_STEP_SUMMARY`. +- B's final `report` job emits a **consolidated verdict** and **fails red** if + any stage failed, so the actor's GitHub run-failure notification carries the + whole-pipeline outcome (which repo, which stage). +- Every failure path is marked `# FLEET-HOOK:` in the YAML — that is exactly + where an out-of-band **scitex-todo card / DM to `github.actor`** plugs in + once the fleet channel is chosen. The draft deliberately uses only + GitHub-native surfaces (job summary + a failing status) so it is adoptable + today without the fleet dependency. + +## The two publish backends + +The single reusable B parametrizes **both** real variants behind +`publish-backend`: + +| backend | runner (`runs-on-json`) | build | publish | +|----------|------------------------------------------------|-------------------------------|----------------------------------------------------------------| +| `hosted` | `'"ubuntu-latest"'` | `setup-python` + `python -m build` | `pypa/gh-action-pypi-publish` (Docker action, OIDC trusted publisher) | +| `sif` | `'["self-hosted","Linux","X64","scitex-ci"]'` | `.github/ci/exec-in-sif.sh build-in-sif.sh` | `.github/ci/exec-in-sif.sh publish-in-sif.sh` (manual OIDC: GitHub JWT → PyPI mint-token → twine, inside the SIF) | + +The caller selects the backend **and** the matching runner. A `sif` repo must +never be given a hosted `runs-on-json` — that is the `no-hosted-runners-guard` +posture (the Spartan nodes have no Docker and no bare-node Python; the SIF path +is self-hosted by construction). `runs-on-json` is consumed via `fromJSON`, so +a bare quoted string (`'"ubuntu-latest"'`) and a label array both work in the +same field. + +**Secrets (decision-for-review):** both backends publish via **OIDC Trusted +Publishing**, so **no API-token secret is required** by default. B declares one +*optional* `PYPI_API_TOKEN` secret as the documented fallback for any repo that +still publishes with a classic token instead of a trusted publisher. The +caller passes `secrets: inherit`. + +## How a leaf repo adopts it (the thin caller) + +Copy `workflow-templates/release.yml` to `.github/workflows/release.yml`, delete +the repo's old inlined `pypi-publish-and-github-release-on-tag.yml`, and set the +three marked values (`publish-backend`, `runs-on-json` on both jobs, +`pypi-project`). A `sif` repo also keeps its `.github/ci/*.sh` scripts (the +reusable calls them in the caller's checkout). + +### Worked example — scitex-dev (the SIF case) + +`scitex-dev/.github/workflows/release.yml`: + +```yaml +name: release +on: + push: + tags: ['v*'] +jobs: + promote: + uses: scitex-ai/.github/.github/workflows/promote-develop-to-main-on-tag.yml@main + with: + runs-on-json: '["self-hosted","Linux","X64","scitex-ci"]' + secrets: inherit + publish: + needs: promote + uses: scitex-ai/.github/.github/workflows/pypi-publish-and-github-release-on-tag.yml@main + with: + publish-backend: sif + runs-on-json: '["self-hosted","Linux","X64","scitex-ci"]' + pypi-project: scitex-dev + secrets: inherit +``` + +scitex-dev already ships `.github/ci/build-in-sif.sh`, `exec-in-sif.sh`, +`run-in-sif.sh`, and `publish-in-sif.sh`, so no other change is needed. The +PyPI Trusted Publisher on `pypi.org/p/scitex-dev` must list the **new** +workflow filename — see the human-decision list. + +A hosted repo's caller is identical except `publish-backend: hosted` and +`runs-on-json: '"ubuntu-latest"'` on both jobs. + +## What still needs a human decision + +- **PyPI Trusted Publisher filename.** Trusted publishing is keyed on the + *workflow filename*. Moving publish into a reusable means the actual runner + filename the caller invokes is still the leaf repo's `release.yml`, but the + OIDC subject differs when a reusable does the upload — each package's PyPI + trusted-publisher config must be re-checked / updated before first real + release (per-package, ~68 entries). This is the single biggest gate. +- **Hosted-only CI runner.** Reusable A runs CI via the org `pytest-matrix.yml` + reusable, which is self-hosted by construction (`uses:` can't be an + expression, so the CI ref is fixed). Repos that must run CI on hosted runners + need either a hosted `pytest-matrix` variant or a `runs-on` input added to + the shared `pytest-matrix.yml`. Decide before onboarding any hosted-only repo. +- **`--admin` auto-merge.** A promotes with `gh pr merge --merge --admin` to + bypass required-review on the automated same-commit promotion. Confirm every + target repo's branch protection permits admin merge with a merge commit (not + squash-only), or the guard will (correctly) abort the release. +- **The 5 CALLER+extra repos.** A handful of repos append extra steps to their + current inlined release (e.g. docs/artifact uploads, Zenodo DOI, extra smoke + gates). Those extras must become either **inputs** on B or **companion jobs** + in the leaf caller — enumerate them and decide input-vs-companion per repo + before migrating them off their inlined copies. +- **Rollout order.** Pilot on one SIF repo (scitex-dev) and one hosted repo + before the org-wide sweep; keep each leaf repo's old inlined workflow until + its first reusable-driven release is verified green. diff --git a/workflow-templates/release.properties.json b/workflow-templates/release.properties.json new file mode 100644 index 0000000..70e5170 --- /dev/null +++ b/workflow-templates/release.properties.json @@ -0,0 +1,7 @@ +{ + "name": "SciTeX release (tag -> promote -> publish)", + "description": "Tag-driven release: CI on the tag, auto-merge develop->main (merge commit), then containment-guarded PyPI publish + GitHub Release. Composes the org reusables A + B. Set publish-backend (hosted|sif) and pypi-project.", + "iconName": "package", + "categories": ["Deployment", "Python"], + "filePatterns": ["pyproject.toml$"] +} diff --git a/workflow-templates/release.yml b/workflow-templates/release.yml new file mode 100644 index 0000000..b93b48b --- /dev/null +++ b/workflow-templates/release.yml @@ -0,0 +1,35 @@ +name: release + +# THE THIN CALLER — copy this into a leaf repo as `.github/workflows/release.yml` +# and adjust the 3 marked values. It composes the two org reusables: +# A (promote-develop-to-main-on-tag) — CI on the tag, then auto-merge +# develop->main with a MERGE commit. +# B (pypi-publish-and-github-release-on-tag) — build + containment guard + +# publish + GitHub Release, from the tag. +# `publish` depends on `promote`, so B only runs after A has landed the tag's +# commit on main. See scitex-ai/.github/RELEASE-MODEL.md. +# +# BACKEND: this template is the SELF-HOSTED SIF default (the dominant, no- +# hosted-runners posture). For a hosted-OIDC repo, swap the two ADJUST lines: +# publish-backend: hosted +# runs-on-json: '"ubuntu-latest"' (both jobs) + +on: + push: + tags: ['v*'] + +jobs: + promote: + uses: scitex-ai/.github/.github/workflows/promote-develop-to-main-on-tag.yml@main + with: + runs-on-json: '["self-hosted","Linux","X64","scitex-ci"]' # ADJUST for hosted: '"ubuntu-latest"' + secrets: inherit + + publish: + needs: promote + uses: scitex-ai/.github/.github/workflows/pypi-publish-and-github-release-on-tag.yml@main + with: + publish-backend: sif # ADJUST: hosted | sif + runs-on-json: '["self-hosted","Linux","X64","scitex-ci"]' # ADJUST for hosted: '"ubuntu-latest"' + pypi-project: REPLACE-with-pypi-project-name # ADJUST: e.g. scitex-dev + secrets: inherit