fix(tests): prove local cancellation reaps owned tree (#11891) #7099
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Continuous release pipeline for Homeboy. | |
| # | |
| # Triggers on push to main (and manual dispatch). Checks for releasable | |
| # conventional commits since the last tag. If found: | |
| # 1. Quality gate (audit, lint, test) | |
| # 2. Version bump + changelog generation from conventional commits | |
| # 3. Cross-platform binary builds via cargo-dist | |
| # 4. Publish GitHub Release assets and the Homebrew formula | |
| # | |
| # No human input needed — version is computed from commit types: | |
| # fix: → patch, feat: → minor, BREAKING CHANGE → major | |
| # chore:/ci:/docs:/test: → no release | |
| name: Release | |
| permissions: | |
| actions: write | |
| contents: write | |
| issues: write | |
| pull-requests: write | |
| on: | |
| push: | |
| branches: [main] | |
| workflow_dispatch: | |
| inputs: | |
| dry-run: | |
| description: 'Preview the release without making changes' | |
| type: boolean | |
| default: false | |
| release_tag: | |
| description: 'Existing tag to publish/recover without preparing a new release' | |
| type: string | |
| default: '' | |
| release_blocking_commands: | |
| description: 'Comma-separated quality commands that may block release preparation' | |
| type: string | |
| default: 'review lint,review test' | |
| env: | |
| RELEASE_BLOCKING_COMMANDS: ${{ inputs.release_blocking_commands || 'review lint,review test' }} | |
| HOMEBOY_NO_UPDATE_CHECK: '1' | |
| RELEASE_MIN_FREE_KB: '5242880' | |
| # Must match homeboy-action's `release-branch` input default. The check job | |
| # re-attaches HEAD to this branch so the action's branch guard can resolve it. | |
| RELEASE_BRANCH: main | |
| # ── Concurrency: never queue a push release behind another one (#11749) ── | |
| # | |
| # This used to be `release-${{ inputs.release_tag || github.ref }}`, so every | |
| # push run on main shared ONE group, on the theory quoted below: "the queued run | |
| # starts after the first finishes and its check job exits in seconds because | |
| # HEAD is already tagged — zero wasted work." | |
| # | |
| # The queued run never started. GitHub keeps at most ONE pending run per | |
| # concurrency group and CANCELS the previously pending one when a newer run is | |
| # queued — `cancel-in-progress: false` only protects runs that are already | |
| # in progress, not runs that are waiting. At ~65 merges/day the pending slot is | |
| # displaced long before a ~35-minute release finishes, so runs were displaced, | |
| # not deferred. Measured on 2026-08-06: 27 of the last 30 Release runs ended | |
| # `cancelled` with `total_count: 0` jobs — they never dispatched a single job, | |
| # so not one of their guards, including `verify-published`, could fire. | |
| # | |
| # A push run now gets a group of its OWN. Nothing is ever queued behind another | |
| # release, so nothing can displace it. Serialization was never what made this | |
| # correct anyway: the `check` job's supersession test (HEAD vs the branch tip) | |
| # and `homeboy release`'s tag idempotency (`release-already-at-head`) are what | |
| # stop two runs preparing the same version, and both cost seconds rather than a | |
| # 35-minute queue slot. Superseded runs now skip the dry run outright, so the | |
| # merge path is not slowed and the wasted work is genuinely near zero. | |
| # | |
| # A recovery dispatch still serializes per TAG: two runs repairing the same | |
| # release must never race on its assets. | |
| # | |
| # The tradeoff, stated rather than hidden: two pushes landing close enough that | |
| # both observe themselves as the branch tip can now both reach `prepare` and | |
| # compute the same next version. That collision resolves at the tag push, where | |
| # the loser fails loudly — a red run plus a failed-SHA marker — and, if it dies | |
| # holding a prepared tag, `detect-stranded-release.sh` picks the tag up on a | |
| # later push. Both are machinery this repo already has. It is a strictly better | |
| # failure than the one being fixed: a colliding run cannot publish anything, | |
| # because publication is now gated on the complete declared asset set below, | |
| # whereas the displacement this replaces was shipping `latest` releases with no | |
| # Linux binary at all. | |
| concurrency: | |
| group: release-${{ inputs.release_tag || github.run_id }} | |
| cancel-in-progress: false | |
| jobs: | |
| # ── Step 1: Check for releasable commits ── | |
| # Fast exit if nothing to release (e.g. chore-only commits). | |
| check: | |
| name: Check for releasable commits | |
| runs-on: ubuntu-latest | |
| outputs: | |
| should-release: ${{ steps.check.outputs.should-release }} | |
| bump-type: ${{ steps.check.outputs['release-bump-type'] }} | |
| recovery-release: ${{ steps.check.outputs.recovery-release }} | |
| recovery-attempts: ${{ steps.check.outputs.recovery-attempts }} | |
| release-version: ${{ steps.check.outputs['release-version'] }} | |
| release-tag: ${{ steps.check.outputs['release-tag'] }} | |
| verified-release-branch: ${{ steps.attach.outputs['release-branch'] }} | |
| verified-release-sha: ${{ steps.attach.outputs['release-sha'] }} | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| ref: ${{ inputs.release_tag || github.sha }} | |
| fetch-depth: 0 | |
| # `actions/checkout` with `ref: <sha>` leaves HEAD DETACHED, so | |
| # `git rev-parse --abbrev-ref HEAD` — which is how homeboy-action's | |
| # release wrapper identifies the current branch — returns the literal | |
| # string "HEAD". Its guard then reads that as "not on main", exits 0 | |
| # before `homeboy release` ever runs, and writes no release-version. | |
| # The decide step below used to read that emptiness as "no releasable | |
| # commits", so the pipeline reported success while skipping every job | |
| # for 131 commits (#10703). | |
| # | |
| # Re-attaching HEAD is the honest fix. `--head` (via the action's | |
| # `release-head` input) is NOT — that flag means "finish an | |
| # already-versioned, already-tagged HEAD" and skips the version and | |
| # changelog computation that is the entire purpose of the dry run. | |
| # | |
| # Attach ONLY when the checked-out commit genuinely IS the release | |
| # branch tip, which is the property the guard is actually there to | |
| # enforce. If a commit landed between trigger and checkout, or the | |
| # workflow was dispatched at an arbitrary SHA, HEAD stays detached, | |
| # the guard fires, and the decide step turns that into a hard failure | |
| # rather than a silent green. | |
| - name: Attach HEAD to the release branch | |
| id: attach | |
| if: inputs.release_tag == '' | |
| run: | | |
| HEAD_SHA="$(git rev-parse HEAD)" | |
| BRANCH_SHA="$(git rev-parse -q --verify "refs/remotes/origin/${RELEASE_BRANCH}" || true)" | |
| if [ -z "${BRANCH_SHA}" ]; then | |
| echo "::warning::No refs/remotes/origin/${RELEASE_BRANCH} in this checkout - leaving HEAD detached" | |
| exit 0 | |
| fi | |
| # A newer commit reached the branch tip between this push and this | |
| # checkout. That is not an unknown: we measured it right here, and it | |
| # names the exact commit whose own run owns the release. Record it so | |
| # the decide step can skip on evidence instead of failing on mystery. | |
| # | |
| # Superseding is expected at this repo's merge rate, and no work is | |
| # lost: `homeboy release` computes its range from the last tag, so the | |
| # newest tip's run covers this commit too. | |
| if [ "${HEAD_SHA}" != "${BRANCH_SHA}" ]; then | |
| echo "superseded=true" >> "$GITHUB_OUTPUT" | |
| echo "branch-tip=${BRANCH_SHA}" >> "$GITHUB_OUTPUT" | |
| echo "::notice::HEAD ${HEAD_SHA:0:8} is not the ${RELEASE_BRANCH} tip ${BRANCH_SHA:0:8} - superseded, the newer tip's run owns this release" | |
| exit 0 | |
| fi | |
| git checkout -q -B "${RELEASE_BRANCH}" "${HEAD_SHA}" | |
| git branch --quiet --set-upstream-to "origin/${RELEASE_BRANCH}" "${RELEASE_BRANCH}" 2>/dev/null || true | |
| echo "release-branch=${RELEASE_BRANCH}" >> "$GITHUB_OUTPUT" | |
| echo "release-sha=${HEAD_SHA}" >> "$GITHUB_OUTPUT" | |
| echo "::notice::HEAD attached to ${RELEASE_BRANCH} at ${HEAD_SHA:0:8}" | |
| - name: Restore failed release marker | |
| id: failure-cache | |
| if: inputs.release_tag == '' | |
| uses: actions/cache/restore@v5 | |
| with: | |
| path: ${{ runner.temp }}/homeboy-release-last-failed | |
| key: release-last-failed-${{ github.ref_name }}-${{ github.sha }} | |
| restore-keys: | | |
| release-last-failed-${{ github.ref_name }}- | |
| # Recovery attempts are counted per TAG, not per SHA. The SHA marker | |
| # above only suppresses retrying the same HEAD; a stranded tag outlives | |
| # every HEAD, so its retry budget has to travel with the tag. | |
| - name: Restore release recovery attempts | |
| id: recovery-attempts-cache | |
| if: inputs.release_tag == '' | |
| uses: actions/cache/restore@v5 | |
| with: | |
| path: ${{ runner.temp }}/homeboy-release-recovery-attempts | |
| key: release-recovery-attempts-${{ github.ref_name }}-${{ github.run_id }} | |
| restore-keys: | | |
| release-recovery-attempts-${{ github.ref_name }}- | |
| - name: Check failed release marker | |
| id: failed-release | |
| if: inputs.release_tag == '' | |
| run: | | |
| HEAD_SHA="$(git rev-parse HEAD)" | |
| FAILURE_MARKER="${RUNNER_TEMP}/homeboy-release-last-failed" | |
| if [ -f "${FAILURE_MARKER}" ]; then | |
| LAST_FAILED="$(tr -d '[:space:]' < "${FAILURE_MARKER}")" | |
| if [ "${HEAD_SHA}" = "${LAST_FAILED}" ]; then | |
| echo "::notice::HEAD ${HEAD_SHA:0:8} matches last failed release attempt — skipping until new commits" | |
| echo "blocked=true" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| fi | |
| echo "blocked=false" >> "$GITHUB_OUTPUT" | |
| # ── HEAD-independent stranded-release detection (issue #10441) ── | |
| # The `bump-type=recovery` contract below only fires while HEAD is still | |
| # sitting on the prepared tag. main moves within minutes, so a tag whose | |
| # publish step failed was never revisited: v0.320.0 stranded 188 commits | |
| # for three hours while the pipeline was holding the exact repair | |
| # command. This step asks git and the GitHub API what is unpublished | |
| # instead of asking what HEAD is, so it fires regardless of how far main | |
| # has moved. See .github/detect-stranded-release.sh for the provenance, | |
| # in-flight, ordering, and retry-budget rules. | |
| - name: Detect stranded prepared release | |
| id: stranded | |
| if: inputs.release_tag == '' | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| RECOVERY_ATTEMPTS_FILE: ${{ runner.temp }}/homeboy-release-recovery-attempts | |
| run: | | |
| # Fail-safe: this detector sits in front of every release. A bug in it | |
| # must degrade to "no recovery", never to "no releases". The script | |
| # writes all four outputs in one final block, so a crash before that | |
| # leaves them unset and this fallback supplies the inert defaults. | |
| if ! bash .github/detect-stranded-release.sh; then | |
| echo "::warning::Stranded-release detection failed; continuing without recovery" | |
| { | |
| echo "stranded-tag=" | |
| echo "stranded-version=" | |
| echo "stranded-attempts=0" | |
| echo "hold-reason=" | |
| } >> "$GITHUB_OUTPUT" | |
| fi | |
| # `steps.attach.outputs.superseded != 'true'` is what makes per-run | |
| # concurrency cheap (#11749). The attach step above already PROVED this | |
| # commit is behind the branch tip, and the decide step below turns that | |
| # proof into `should-release=false` on its own — so running the dry run | |
| # anyway burns ~12 minutes of runner time to re-derive an answer already | |
| # in hand. Skipping it is what makes the design comment on `concurrency` | |
| # ("zero wasted work") true rather than aspirational. | |
| # | |
| # Skipping leaves `release-check.outcome == 'skipped'` and an empty | |
| # `skipped-reason`, which the decide step's supersession branch already | |
| # accepts (`wrong-branch|''`). It cannot reach the #10685 hard failure, | |
| # which requires `outcome == 'success'`. | |
| - name: Dry-run release check | |
| id: release-check | |
| if: inputs.release_tag == '' && steps.attach.outputs.superseded != 'true' && steps.failed-release.outputs.blocked != 'true' && steps.stranded.outputs['stranded-tag'] == '' && steps.stranded.outputs['hold-reason'] == '' | |
| uses: Extra-Chill/homeboy-action@v2 | |
| with: | |
| source: '.' | |
| commands: release | |
| expected-commands: review audit,review lint,review test | |
| args: --skip-checks=audit,lint,test | |
| release-dry-run: 'true' | |
| - name: Validate existing release tag | |
| id: recovery | |
| if: inputs.release_tag != '' | |
| run: | | |
| TAG="${{ inputs.release_tag }}" | |
| if ! git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then | |
| echo "::error::Release tag ${TAG} does not exist" | |
| exit 1 | |
| fi | |
| if [[ ! "${TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then | |
| echo "::error::Release tag ${TAG} must look like v1.2.3" | |
| exit 1 | |
| fi | |
| VERSION="${TAG#v}" | |
| echo "release-version=${VERSION}" >> "$GITHUB_OUTPUT" | |
| echo "release-tag=${TAG}" >> "$GITHUB_OUTPUT" | |
| echo "release-bump-type=recovery" >> "$GITHUB_OUTPUT" | |
| echo "::notice::Recovering publish pipeline for existing tag ${TAG}" | |
| - name: Decide whether to release | |
| id: check | |
| run: | | |
| HEAD_SHA="$(git rev-parse HEAD)" | |
| RECOVERY_TAG="${{ steps.recovery.outputs['release-tag'] }}" | |
| STRANDED_TAG="${{ steps.stranded.outputs['stranded-tag'] }}" | |
| STRANDED_VERSION="${{ steps.stranded.outputs['stranded-version'] }}" | |
| STRANDED_ATTEMPTS="${{ steps.stranded.outputs['stranded-attempts'] }}" | |
| HOLD_REASON="${{ steps.stranded.outputs['hold-reason'] }}" | |
| RELEASE_VERSION="${{ steps.recovery.outputs['release-version'] || steps.release-check.outputs['release-version'] }}" | |
| RELEASE_TAG="${{ steps.recovery.outputs['release-tag'] || steps.release-check.outputs['release-tag'] }}" | |
| BUMP_TYPE="${{ steps.recovery.outputs['release-bump-type'] || steps.release-check.outputs['release-bump-type'] }}" | |
| DRY_RUN_OUTCOME="${{ steps.release-check.outcome }}" | |
| SKIPPED_REASON="${{ steps.release-check.outputs['skipped-reason'] }}" | |
| SUPERSEDED="${{ steps.attach.outputs.superseded }}" | |
| BRANCH_TIP="${{ steps.attach.outputs['branch-tip'] }}" | |
| echo "recovery-attempts=${STRANDED_ATTEMPTS:-0}" >> "$GITHUB_OUTPUT" | |
| # A superseded push is a MEASURED negative, and the only one this job | |
| # measures itself rather than reading back from the dry run. | |
| # | |
| # The attach step compared this commit against the branch tip and | |
| # found a newer one, so `wrong-branch` here is the branch guard | |
| # working correctly, not the #10703 laundering bug. Failing it turns | |
| # every merge that is overtaken by the next merge into a red run. | |
| # | |
| # This deliberately does NOT widen the #10685 hole: the reason is | |
| # only accepted when THIS job proved the supersession and recorded | |
| # the winning tip. If HEAD really is the tip, `superseded` is empty, | |
| # `wrong-branch` stays unmeasured, and the hard failure below still | |
| # fires — which is exactly the #10703 regression signal. | |
| if [ "${SUPERSEDED}" = "true" ] && [ -z "${RELEASE_VERSION}" ]; then | |
| case "${SKIPPED_REASON}" in | |
| wrong-branch|'') | |
| echo "should-release=false" >> "$GITHUB_OUTPUT" | |
| echo "::notice::Superseded: ${HEAD_SHA:0:8} is behind the ${RELEASE_BRANCH} tip ${BRANCH_TIP:0:8}, whose own run owns this release. Skipping (skipped-reason='${SKIPPED_REASON:-<empty>}')." | |
| exit 0 | |
| ;; | |
| esac | |
| fi | |
| # #10685: absence of evidence must never be evidence of success. | |
| # An empty release-version has two very different causes, and this | |
| # step used to collapse both into "No releasable commits": | |
| # | |
| # MEASURED - `homeboy release --dry-run` evaluated the commit | |
| # range and declined. Core emits exactly three such | |
| # reasons from planning_policy.rs. | |
| # UNMEASURED - the action's wrapper bailed before invoking core, | |
| # so nothing was ever evaluated. `wrong-branch` is | |
| # the one that silently ate 131 commits (#10703); | |
| # `release-output-missing` is the other. | |
| # | |
| # Only a measured negative may render "nothing to release". Anything | |
| # else is UNKNOWN and fails the job loudly, because a release | |
| # pipeline that cannot tell whether it has work to do must not | |
| # report success while skipping every downstream job. | |
| if [ "${DRY_RUN_OUTCOME}" = "success" ] && [ -z "${RELEASE_VERSION}" ]; then | |
| case "${SKIPPED_REASON}" in | |
| no-releasable-commits|release-already-at-head|major-requires-flag) ;; | |
| *) | |
| echo "::error::Release check could not determine whether there is anything to release at ${HEAD_SHA:0:8}. The dry run produced no release-version and no measured skip reason (skipped-reason='${SKIPPED_REASON:-<empty>}'). This is an UNKNOWN result, not 'nothing to release', so the pipeline is failing instead of skipping every job and reporting success. If skipped-reason is 'wrong-branch', HEAD was detached and the branch guard fired before any commit range was evaluated — see the 'Attach HEAD to the release branch' step above and issue #10703." | |
| exit 1 | |
| ;; | |
| esac | |
| fi | |
| if [ -n "${RECOVERY_TAG}" ]; then | |
| # Explicit workflow_dispatch recovery always wins. | |
| echo "should-release=true" >> "$GITHUB_OUTPUT" | |
| echo "release-bump-type=${BUMP_TYPE}" >> "$GITHUB_OUTPUT" | |
| echo "release-version=${RELEASE_VERSION}" >> "$GITHUB_OUTPUT" | |
| echo "release-tag=${RECOVERY_TAG}" >> "$GITHUB_OUTPUT" | |
| echo "recovery-release=true" >> "$GITHUB_OUTPUT" | |
| echo "::notice::Release recovery will publish ${RECOVERY_TAG}" | |
| elif [ -n "${STRANDED_TAG}" ]; then | |
| # Recovery PREEMPTS a fresh release, and preempts the failed-SHA | |
| # marker too. The marker is keyed to github.sha and only means | |
| # "do not re-prepare this HEAD"; finishing an already-prepared tag | |
| # is a different, HEAD-independent action. Preemption is the whole | |
| # point: a fresh release prepared on top of a stranded tag buries | |
| # it under a newer published release, where the contiguous-window | |
| # scan can never find it again — permanently orphaning a version | |
| # whose artifacts were already built and paid for. | |
| echo "should-release=true" >> "$GITHUB_OUTPUT" | |
| echo "release-bump-type=recovery" >> "$GITHUB_OUTPUT" | |
| echo "release-version=${STRANDED_VERSION}" >> "$GITHUB_OUTPUT" | |
| echo "release-tag=${STRANDED_TAG}" >> "$GITHUB_OUTPUT" | |
| echo "recovery-release=true" >> "$GITHUB_OUTPUT" | |
| echo "::notice::Self-recovery: publishing stranded prepared tag ${STRANDED_TAG} instead of preparing a fresh release" | |
| elif [ -n "${HOLD_REASON}" ]; then | |
| echo "should-release=false" >> "$GITHUB_OUTPUT" | |
| echo "::notice::Holding release — ${HOLD_REASON}" | |
| elif [ "${{ steps.failed-release.outputs.blocked }}" = "true" ]; then | |
| echo "should-release=false" >> "$GITHUB_OUTPUT" | |
| elif [ -z "${RELEASE_VERSION}" ]; then | |
| # Reached only after the measurement check above accepted the | |
| # skip reason, so this really is a measured negative. Report the | |
| # reason by name — "no releasable commits" is a lie when core | |
| # actually said `major-requires-flag` or `release-already-at-head`. | |
| echo "should-release=false" >> "$GITHUB_OUTPUT" | |
| echo "::notice::Not releasing at HEAD ${HEAD_SHA:0:8} — ${SKIPPED_REASON:-no-releasable-commits}" | |
| else | |
| echo "should-release=true" >> "$GITHUB_OUTPUT" | |
| echo "release-bump-type=${BUMP_TYPE}" >> "$GITHUB_OUTPUT" | |
| echo "release-version=${RELEASE_VERSION}" >> "$GITHUB_OUTPUT" | |
| echo "release-tag=${RELEASE_TAG}" >> "$GITHUB_OUTPUT" | |
| if [ "${BUMP_TYPE}" = "recovery" ]; then | |
| echo "recovery-release=true" >> "$GITHUB_OUTPUT" | |
| echo "::notice::Recovered prepared release tag ${RELEASE_TAG}; bypassing quality gates and publishing artifacts" | |
| else | |
| echo "recovery-release=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| echo "::notice::Release dry-run predicts v${RELEASE_VERSION} (${BUMP_TYPE})" | |
| fi | |
| # ── Step 2: Build once ── | |
| # Compile homeboy from source once and share the binary with all | |
| # quality gate jobs. Eliminates 3× redundant cargo builds. | |
| gate-build: | |
| name: Build | |
| needs: check | |
| if: needs.check.outputs.should-release == 'true' | |
| # This binary is executed by the ubuntu-22.04 publication jobs. Build on | |
| # the oldest consumer runtime so recovery finalizers cannot require a newer GLIBC. | |
| runs-on: ubuntu-22.04 | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| # Recovery checks out an older release tag downstream, but the | |
| # finalizer must include the current recovery contract from main. | |
| ref: ${{ github.sha }} | |
| - name: Install Rust toolchain | |
| uses: dtolnay/rust-toolchain@1.95.0 | |
| - name: Cache cargo | |
| uses: actions/cache@v5 | |
| with: | |
| path: | | |
| ~/.cargo/registry | |
| ~/.cargo/git | |
| target | |
| key: ${{ runner.os }}-cargo-release-gate-${{ hashFiles('Cargo.lock') }} | |
| restore-keys: ${{ runner.os }}-cargo-release-gate- | |
| - name: Build homeboy | |
| run: cargo build --release | |
| - name: Upload binary | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: homeboy-binary | |
| path: target/release/homeboy | |
| retention-days: 1 | |
| # ── Step 3: Quality checks (parallel read-only checks + advisory repair) ── | |
| # Release preparation is gated by release-quality-policy, not by raw job | |
| # failures. Commands opt in to release blocking through RELEASE_BLOCKING_COMMANDS | |
| # (default: lint,test). Audit still runs and files issues, but stale/full-tree | |
| # audit debt does not block releases unless audit is explicitly listed. | |
| gate-audit: | |
| name: Audit | |
| needs: | |
| - check | |
| - gate-build | |
| if: needs.check.outputs.should-release == 'true' && needs.check.outputs.recovery-release != 'true' | |
| runs-on: ubuntu-latest | |
| outputs: | |
| audit-result: ${{ steps.audit.outcome }} | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| ref: ${{ inputs.release_tag || github.sha }} | |
| fetch-depth: 0 | |
| - name: Download homeboy binary | |
| uses: actions/download-artifact@v7 | |
| with: | |
| name: homeboy-binary | |
| path: .homeboy-bin | |
| - name: Verify homeboy binary present | |
| run: | | |
| if [ ! -f .homeboy-bin/homeboy ]; then | |
| echo "::error::Build artifact missing from upstream Build job: .homeboy-bin/homeboy was not produced/uploaded by gate-build. This is a CI artifact-handoff problem, not a code finding in this change. Re-run the failed Build job or investigate the homeboy-binary upload step." >&2 | |
| exit 1 | |
| fi | |
| chmod +x .homeboy-bin/homeboy | |
| - name: Generate GitHub App token | |
| id: app-token | |
| uses: actions/create-github-app-token@v3 | |
| continue-on-error: true | |
| with: | |
| client-id: ${{ secrets.HOMEBOY_APP_ID }} | |
| private-key: ${{ secrets.HOMEBOY_APP_PRIVATE_KEY }} | |
| - name: Run advisory audit | |
| id: audit | |
| uses: Extra-Chill/homeboy-action@v2 | |
| continue-on-error: true | |
| with: | |
| binary-path: .homeboy-bin/homeboy | |
| commands: review audit | |
| expected-commands: review audit,review lint,review test | |
| args: ${{ github.event.before && format('--profile=pr --changed-since {0}', github.event.before) || '--profile=pr' }} | |
| app-token: ${{ steps.app-token.outputs.token || '' }} | |
| gate-lint: | |
| name: Lint | |
| needs: | |
| - check | |
| - gate-build | |
| if: needs.check.outputs.should-release == 'true' && needs.check.outputs.recovery-release != 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| ref: ${{ inputs.release_tag || github.sha }} | |
| fetch-depth: 0 | |
| - name: Download homeboy binary | |
| uses: actions/download-artifact@v7 | |
| with: | |
| name: homeboy-binary | |
| path: .homeboy-bin | |
| - name: Verify homeboy binary present | |
| run: | | |
| if [ ! -f .homeboy-bin/homeboy ]; then | |
| echo "::error::Build artifact missing from upstream Build job: .homeboy-bin/homeboy was not produced/uploaded by gate-build. This is a CI artifact-handoff problem, not a lint finding in this change. Re-run the failed Build job or investigate the homeboy-binary upload step." >&2 | |
| exit 1 | |
| fi | |
| chmod +x .homeboy-bin/homeboy | |
| - name: Generate GitHub App token | |
| id: app-token | |
| uses: actions/create-github-app-token@v3 | |
| continue-on-error: true | |
| with: | |
| client-id: ${{ secrets.HOMEBOY_APP_ID }} | |
| private-key: ${{ secrets.HOMEBOY_APP_PRIVATE_KEY }} | |
| - uses: Extra-Chill/homeboy-action@v2 | |
| with: | |
| binary-path: .homeboy-bin/homeboy | |
| commands: review lint | |
| expected-commands: review audit,review lint,review test | |
| args: ${{ github.event.before && format('--changed-since {0}', github.event.before) || '' }} | |
| app-token: ${{ steps.app-token.outputs.token || '' }} | |
| gate-test: | |
| name: Test | |
| needs: | |
| - check | |
| - gate-build | |
| if: needs.check.outputs.should-release == 'true' && needs.check.outputs.recovery-release != 'true' | |
| runs-on: ubuntu-latest | |
| # Match the repository-scoped Test budget in ci.yml. The inner Homeboy | |
| # timeout must remain below the action's outer process-group backstop. | |
| env: | |
| HOMEBOY_TEST_TIMEOUT_SECONDS: '2700' | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| ref: ${{ inputs.release_tag || github.sha }} | |
| fetch-depth: 0 | |
| - name: Download homeboy binary | |
| uses: actions/download-artifact@v7 | |
| with: | |
| name: homeboy-binary | |
| path: .homeboy-bin | |
| - name: Verify homeboy binary present | |
| run: | | |
| if [ ! -f .homeboy-bin/homeboy ]; then | |
| echo "::error::Build artifact missing from upstream Build job: .homeboy-bin/homeboy was not produced/uploaded by gate-build. This is a CI artifact-handoff problem, not a test finding in this change. Re-run the failed Build job or investigate the homeboy-binary upload step." >&2 | |
| exit 1 | |
| fi | |
| chmod +x .homeboy-bin/homeboy | |
| - name: Generate GitHub App token | |
| id: app-token | |
| uses: actions/create-github-app-token@v3 | |
| continue-on-error: true | |
| with: | |
| client-id: ${{ secrets.HOMEBOY_APP_ID }} | |
| private-key: ${{ secrets.HOMEBOY_APP_PRIVATE_KEY }} | |
| - uses: Extra-Chill/homeboy-action@v2 | |
| env: | |
| RELEASE_BLOCKING_COMMANDS: ${{ env.RELEASE_BLOCKING_COMMANDS }} | |
| with: | |
| binary-path: .homeboy-bin/homeboy | |
| commands: review test | |
| expected-commands: review audit,review lint,review test | |
| args: ${{ github.event.before && format('--skip-lint --changed-since {0}', github.event.before) || '--skip-lint' }} | |
| execution-timeout-seconds: '3000' | |
| app-token: ${{ steps.app-token.outputs.token || '' }} | |
| # ── Step 3b: Release-blocking quality policy ── | |
| # Release preparation is gated by release-quality-policy, not by raw job | |
| # failures or generic source auto-refactor. Generic release auto-refactor / | |
| # autofix was removed (#8046): the release pipeline no longer mutates source, | |
| # opens autofix branches, or creates autofix PRs. Audit baseline consumption | |
| # (--changed-since against homeboy.json baselines.audit.known_fingerprints) | |
| # and automated categorized issue filing (app-token) are preserved in the | |
| # read-only gate jobs. Extension-declared generated-drift maintenance is | |
| # preserved as a narrow allowlisted transaction in core | |
| # (changes_are_only_drift / drift_file_paths), not as a release-workflow | |
| # source-mutation step. | |
| release-quality-policy: | |
| name: Release Quality Policy | |
| needs: | |
| - check | |
| - gate-build | |
| - gate-audit | |
| - gate-lint | |
| - gate-test | |
| if: ${{ always() && needs.check.outputs.should-release == 'true' && needs.check.outputs.recovery-release != 'true' && needs.gate-build.result == 'success' }} | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout workflow event commit | |
| uses: actions/checkout@v6 | |
| with: | |
| ref: ${{ github.sha }} | |
| - name: Enforce release-blocking commands | |
| env: | |
| BLOCKING_COMMANDS: ${{ env.RELEASE_BLOCKING_COMMANDS }} | |
| AUDIT_RESULT: ${{ needs.gate-audit.outputs.audit-result || needs.gate-audit.result }} | |
| LINT_RESULT: ${{ needs.gate-lint.result }} | |
| TEST_RESULT: ${{ needs.gate-test.result }} | |
| run: | | |
| bash .github/release-quality-policy.sh | |
| # ── Step 4: Version bump + changelog + tag ── | |
| prepare: | |
| name: Prepare Release | |
| needs: | |
| - check | |
| - gate-build | |
| - release-quality-policy | |
| if: ${{ always() && needs.check.outputs.should-release == 'true' && needs.gate-build.result == 'success' && (needs.check.outputs.recovery-release == 'true' || inputs.release_tag != '' || needs.release-quality-policy.result == 'success') }} | |
| runs-on: ubuntu-latest | |
| outputs: | |
| release-version: ${{ steps.outputs.outputs['release-version'] }} | |
| release-tag: ${{ steps.outputs.outputs['release-tag'] }} | |
| prepared: ${{ steps.outputs.outputs.prepared }} | |
| released: ${{ steps.outputs.outputs.released }} | |
| recovery-release: ${{ steps.outputs.outputs.recovery-release }} | |
| steps: | |
| - name: Generate GitHub App token | |
| id: app-token | |
| uses: actions/create-github-app-token@v3 | |
| continue-on-error: true | |
| with: | |
| client-id: ${{ secrets.HOMEBOY_APP_ID }} | |
| private-key: ${{ secrets.HOMEBOY_APP_PRIVATE_KEY }} | |
| # A push-triggered self-recovery has no `inputs.release_tag`, and main has | |
| # normally moved well past the stranded tag by now. Check out the tag | |
| # being recovered so every job in the publish chain agrees on the tree. | |
| - uses: actions/checkout@v6 | |
| with: | |
| ref: ${{ inputs.release_tag || (needs.check.outputs.recovery-release == 'true' && needs.check.outputs['release-tag']) || github.sha }} | |
| fetch-depth: 0 | |
| persist-credentials: true | |
| token: ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }} | |
| # The check job proved that this immutable push SHA was the release | |
| # branch tip before selecting a fresh release. Re-establish precisely | |
| # that verified identity here; never relabel an arbitrary detached | |
| # checkout as a release branch. | |
| - name: Attach verified release branch | |
| if: inputs.release_tag == '' && needs.check.outputs.recovery-release != 'true' | |
| env: | |
| VERIFIED_RELEASE_BRANCH: ${{ needs.check.outputs['verified-release-branch'] }} | |
| VERIFIED_RELEASE_SHA: ${{ needs.check.outputs['verified-release-sha'] }} | |
| run: | | |
| set -euo pipefail | |
| HEAD_SHA="$(git rev-parse HEAD)" | |
| if [ "${VERIFIED_RELEASE_BRANCH}" != "${RELEASE_BRANCH}" ] || [ -z "${VERIFIED_RELEASE_SHA}" ] || [ "${VERIFIED_RELEASE_SHA}" != "${HEAD_SHA}" ] || [ "${HEAD_SHA}" != "${GITHUB_SHA}" ]; then | |
| echo "::error::Prepare release branch identity is not the check-verified ${RELEASE_BRANCH}@${GITHUB_SHA}. Refusing to attach detached HEAD ${HEAD_SHA}." | |
| exit 1 | |
| fi | |
| git checkout -q -B "${VERIFIED_RELEASE_BRANCH}" "${VERIFIED_RELEASE_SHA}" | |
| git branch --quiet --set-upstream-to "origin/${VERIFIED_RELEASE_BRANCH}" "${VERIFIED_RELEASE_BRANCH}" 2>/dev/null || true | |
| echo "::notice::Prepare attached the check-verified ${VERIFIED_RELEASE_BRANCH} at ${VERIFIED_RELEASE_SHA:0:8}" | |
| - name: Preflight release runner disk | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| report_disk() { | |
| echo "::group::Release runner disk usage" | |
| df -h . "$RUNNER_TEMP" "$HOME" || true | |
| echo "::endgroup::" | |
| } | |
| available_kb() { | |
| df -Pk . | awk 'NR==2 {print $4}' | |
| } | |
| report_disk | |
| before_kb="$(available_kb)" | |
| if [ "$before_kb" -lt "$RELEASE_MIN_FREE_KB" ]; then | |
| echo "::warning::Release runner has ${before_kb} KiB free before prepare; cleaning reconstructable release artifacts and caches" | |
| rm -rf target/distrib target/package .homeboy-bin artifacts "$HOME/.cache/cargo-dist" "$HOME/.cache/sccache" | |
| fi | |
| report_disk | |
| after_kb="$(available_kb)" | |
| if [ "$after_kb" -lt "$RELEASE_MIN_FREE_KB" ]; then | |
| echo "::error::Release runner has ${after_kb} KiB free after cleanup; refusing prepare before the runner exhausts disk while writing diagnostics" | |
| exit 1 | |
| fi | |
| # Rust toolchain needed so run-release.sh can regenerate Cargo.lock | |
| # after bumping Cargo.toml version | |
| - name: Install Rust toolchain | |
| if: inputs.release_tag == '' && needs.check.outputs.recovery-release != 'true' | |
| uses: dtolnay/rust-toolchain@1.95.0 | |
| - name: Preflight release workspace build | |
| if: inputs.release_tag == '' && needs.check.outputs.recovery-release != 'true' | |
| run: cargo build --workspace --locked | |
| # Compile every crate's TEST target too. `cargo build --workspace` above | |
| # only builds lib/bin targets, so a test file that references a module | |
| # moved during a crate extraction (e.g. a donor's orphaned `super::foo` | |
| # after `foo` was extracted) compiles fine here yet breaks the test gate. | |
| # `check --tests` is codegen-free and cheap; it fails the release closed | |
| # before shipping a workspace whose tests don't build. | |
| - name: Preflight workspace test-target compilation | |
| if: inputs.release_tag == '' && needs.check.outputs.recovery-release != 'true' | |
| run: cargo check --workspace --tests --locked | |
| - uses: Extra-Chill/homeboy-action@v2 | |
| id: release | |
| if: inputs.release_tag == '' && needs.check.outputs.recovery-release != 'true' | |
| with: | |
| source: '.' | |
| commands: release | |
| expected-commands: review audit,review lint,review test | |
| args: --skip-checks=audit,lint,test | |
| release-dry-run: ${{ inputs.dry-run || 'false' }} | |
| release-branch: ${{ needs.check.outputs['verified-release-branch'] }} | |
| release-skip-publish: 'true' | |
| release-skip-github-release: 'true' | |
| app-token: ${{ steps.app-token.outputs.token || '' }} | |
| - name: Mark prepared release for downstream publish | |
| id: prepared | |
| if: inputs.release_tag == '' && needs.check.outputs.recovery-release != 'true' && steps.release.outputs['release-tag'] != '' | |
| run: | | |
| echo "prepared=true" >> "$GITHUB_OUTPUT" | |
| echo "::notice::Prepared ${{ steps.release.outputs['release-tag'] }}; downstream jobs will publish it in this run" | |
| - name: Use existing release tag | |
| id: recovery | |
| if: inputs.release_tag != '' || needs.check.outputs.recovery-release == 'true' | |
| run: | | |
| TAG="${{ inputs.release_tag || needs.check.outputs['release-tag'] }}" | |
| VERSION="${{ needs.check.outputs['release-version'] }}" | |
| if [ -z "${VERSION}" ]; then | |
| VERSION="${TAG#v}" | |
| fi | |
| echo "release-version=${VERSION}" >> "$GITHUB_OUTPUT" | |
| echo "release-tag=${TAG}" >> "$GITHUB_OUTPUT" | |
| echo "prepared=true" >> "$GITHUB_OUTPUT" | |
| echo "released=true" >> "$GITHUB_OUTPUT" | |
| echo "::notice::Skipping release preparation; downstream jobs will publish existing tag ${TAG}" | |
| - name: Resolve release outputs | |
| id: outputs | |
| run: | | |
| RELEASE_VERSION="${{ steps.recovery.outputs['release-version'] || steps.release.outputs['release-version'] }}" | |
| RELEASE_TAG="${{ steps.recovery.outputs['release-tag'] || steps.release.outputs['release-tag'] }}" | |
| PREPARED="${{ steps.recovery.outputs.prepared || steps.prepared.outputs.prepared }}" | |
| RELEASED="${{ steps.recovery.outputs.released || steps.release.outputs.released }}" | |
| RECOVERY_RELEASE="${{ needs.check.outputs.recovery-release }}" | |
| if [ -z "${RELEASE_VERSION}" ] || [ -z "${RELEASE_TAG}" ] || [ "${PREPARED}" != "true" ]; then | |
| echo "::error::Release check selected work, but Prepare Release produced an incomplete handoff (release-version='${RELEASE_VERSION:-<empty>}', release-tag='${RELEASE_TAG:-<empty>}', prepared='${PREPARED:-<empty>}'). Refusing to skip artifact and publication jobs." | |
| exit 1 | |
| fi | |
| echo "release-version=${RELEASE_VERSION}" >> "$GITHUB_OUTPUT" | |
| echo "release-tag=${RELEASE_TAG}" >> "$GITHUB_OUTPUT" | |
| echo "prepared=${PREPARED}" >> "$GITHUB_OUTPUT" | |
| echo "released=${RELEASED}" >> "$GITHUB_OUTPUT" | |
| echo "recovery-release=${RECOVERY_RELEASE}" >> "$GITHUB_OUTPUT" | |
| # ── Step 4: Build cross-platform binaries ── | |
| plan: | |
| name: Plan Build Matrix | |
| needs: prepare | |
| # Recovery releases (HEAD already tagged, GitHub Release missing) reach | |
| # `prepare` through its `always()` gate while the quality-policy job is | |
| # skipped. Without `always()` here, GitHub propagates that skipped | |
| # ancestor down the `needs` chain and skips `plan` too — so the tag lands | |
| # with no GitHub Release. Gate on prepare's result + outputs explicitly, | |
| # mirroring the `host` job, so both fresh and recovery paths publish. | |
| if: ${{ always() && needs.prepare.result == 'success' && needs.prepare.outputs.prepared == 'true' && needs.prepare.outputs['release-tag'] != '' }} | |
| runs-on: ubuntu-22.04 | |
| outputs: | |
| val: ${{ steps.plan.outputs.manifest }} | |
| tag-flag: ${{ format('--tag={0}', needs.prepare.outputs['release-tag']) }} | |
| expected-assets: ${{ steps.plan.outputs.expected-assets }} | |
| draft-complete: ${{ steps.draft-probe.outputs.draft-complete }} | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| ref: ${{ needs.prepare.outputs['release-tag'] }} | |
| persist-credentials: false | |
| submodules: recursive | |
| - name: Install dist | |
| shell: bash | |
| run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.31.0/cargo-dist-installer.sh | sh" | |
| - name: Cache dist | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: cargo-dist-cache | |
| path: ~/.cargo/bin/dist | |
| - id: plan | |
| run: | | |
| if ! grep -Fqx '[package.metadata.dist]' Cargo.toml; then | |
| printf '\n[package.metadata.dist]\ndist = true\n' >> Cargo.toml | |
| fi | |
| if [ "${{ needs.prepare.outputs.recovery-release }}" = "true" ]; then | |
| DIST_ALLOW_DIRTY="--allow-dirty" | |
| else | |
| DIST_ALLOW_DIRTY="" | |
| fi | |
| dist host --steps=create --tag=${{ needs.prepare.outputs['release-tag'] }} --output-format=json ${DIST_ALLOW_DIRTY} > plan-dist-manifest.json | |
| echo "dist ran successfully" | |
| cat plan-dist-manifest.json | |
| echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT" | |
| PLANNED_ARTIFACTS="$(jq -c '[.releases[].artifacts[]? | split("/") | last] | unique' plan-dist-manifest.json)" | |
| if [ "$(jq 'length' <<< "${PLANNED_ARTIFACTS}")" -eq 0 ]; then | |
| echo "::error::cargo-dist planned no release assets" | |
| exit 1 | |
| fi | |
| # cargo-dist uploads its own `dist-manifest.json` to every GitHub | |
| # Release but never lists it in `.releases[].artifacts[]` (issue | |
| # #10547). Draft adoption validates the remote inventory *exactly*, | |
| # so a planned-artifacts-only expectation is permanently one asset | |
| # short and no stranded draft can ever be adopted. `expected-assets` | |
| # must therefore describe cargo-dist's complete published inventory, | |
| # not just its planned payloads. | |
| EXPECTED_ASSETS="$(jq -c '. + ["dist-manifest.json"] | unique' <<< "${PLANNED_ARTIFACTS}")" | |
| echo "planned-artifacts=${PLANNED_ARTIFACTS}" >> "$GITHUB_OUTPUT" | |
| echo "expected-assets=${EXPECTED_ASSETS}" >> "$GITHUB_OUTPUT" | |
| # ── Recovery fast path: is the draft already complete? (issue #10519) ── | |
| # Recovery rebuilds every platform (~40 minutes) before it can adopt a | |
| # draft that frequently already holds every authoritative asset — the | |
| # stranded v0.321.1 draft has carried all 13 since 2026-07-27. Probe the | |
| # remote inventory so the publish chain can skip straight to adoption. | |
| # | |
| # This probe deliberately runs AFTER `dist host --steps=create`. Whether | |
| # `--steps=create` disturbs an existing draft's assets is undocumented, | |
| # and gating the build matrix on a pre-create observation could skip the | |
| # very rebuild that restores assets create had just cleared. Observing the | |
| # post-create state makes the fast path fail-safe by construction: if | |
| # create emptied the draft, the probe sees an incomplete inventory and the | |
| # full rebuild runs exactly as it does today. | |
| # | |
| # This is an optimisation gate only, and is deliberately weaker than the | |
| # authority it defers to: `validate_draft_adoption` still re-verifies | |
| # every asset's name, size, upload state and SHA-256 digest against the | |
| # published checksum sidecars in the `host` finalizer, and `verify-published` | |
| # independently re-checks the inventory afterwards. Neither is skipped. | |
| - name: Probe existing draft for a complete asset inventory | |
| id: draft-probe | |
| if: needs.prepare.outputs.recovery-release == 'true' | |
| env: | |
| RELEASE_TAG: ${{ needs.prepare.outputs['release-tag'] }} | |
| EXPECTED_ASSETS: ${{ steps.plan.outputs.expected-assets }} | |
| run: | | |
| # A probe that cannot read the inventory must degrade to "rebuild | |
| # everything", never to "fail the release". GitHub's default shell is | |
| # `bash -e`, so errexit has to be turned off explicitly here — `set | |
| # -uo pipefail` alone would leave it on and let a transient jq or gh | |
| # failure block the release outright. | |
| set +e | |
| set -uo pipefail | |
| COMPLETE=false | |
| REASON='' | |
| if ! gh release view "${RELEASE_TAG}" --json isDraft,assets > draft.json 2> draft.err; then | |
| REASON="the inventory for ${RELEASE_TAG} could not be read" | |
| sed 's/^/gh: /' draft.err >&2 || true | |
| elif [ "$(jq -r '.isDraft' draft.json)" != "true" ]; then | |
| REASON="${RELEASE_TAG} is not an unpublished draft" | |
| else | |
| WANTED="$(jq -Sc 'unique' <<< "${EXPECTED_ASSETS}")" | |
| USABLE="$(jq -Sc '[.assets[] | select(.state == "uploaded" and .size > 0) | .name] | unique' draft.json)" | |
| TOTAL="$(jq -r '.assets | length' draft.json)" | |
| WANTED_COUNT="$(jq -r 'length' <<< "${WANTED}")" | |
| # Exact inventory: every expected asset usable, and nothing else | |
| # present. `TOTAL` catches duplicates and strays that `unique` | |
| # would otherwise collapse away. | |
| if [ "${USABLE}" = "${WANTED}" ] && [ "${TOTAL}" = "${WANTED_COUNT}" ]; then | |
| COMPLETE=true | |
| else | |
| REASON="the inventory differs (expected ${WANTED_COUNT}: ${WANTED}; usable: ${USABLE}; total on release: ${TOTAL})" | |
| fi | |
| fi | |
| echo "draft-complete=${COMPLETE}" >> "$GITHUB_OUTPUT" | |
| if [ "${COMPLETE}" = "true" ]; then | |
| echo "::notice::${RELEASE_TAG} already holds every expected asset; skipping the cross-platform rebuild and re-upload, and going straight to draft adoption (#10519)" | |
| { | |
| echo "### Release recovery fast path" | |
| echo "" | |
| echo "\`${RELEASE_TAG}\` already holds every expected asset, so the cross-platform rebuild and re-upload are skipped." | |
| echo "The \`host\` finalizer still re-verifies name, size, upload state and SHA-256 digest before publishing." | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| else | |
| echo "::notice::Rebuilding every release artifact for ${RELEASE_TAG}: ${REASON}" | |
| fi | |
| - name: Upload dist-manifest.json | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: artifacts-plan-dist-manifest | |
| path: plan-dist-manifest.json | |
| build-local-artifacts: | |
| name: build (${{ join(matrix.targets, ', ') }}) | |
| needs: | |
| - prepare | |
| - plan | |
| if: ${{ always() && needs.prepare.result == 'success' && needs.prepare.outputs.prepared == 'true' && needs.prepare.outputs['release-tag'] != '' && needs.plan.result == 'success' && needs.plan.outputs.draft-complete != 'true' && fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null }} | |
| strategy: | |
| fail-fast: false | |
| matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }} | |
| runs-on: ${{ matrix.runner }} | |
| container: ${{ matrix.container && matrix.container.image || null }} | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json | |
| steps: | |
| - name: enable windows longpaths | |
| run: | | |
| git config --global core.longpaths true | |
| - uses: actions/checkout@v6 | |
| with: | |
| ref: ${{ needs.prepare.outputs['release-tag'] }} | |
| persist-credentials: false | |
| submodules: recursive | |
| - name: Install Rust non-interactively if not already installed | |
| if: ${{ matrix.container }} | |
| run: | | |
| if ! command -v cargo > /dev/null 2>&1; then | |
| curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y | |
| echo "$HOME/.cargo/bin" >> $GITHUB_PATH | |
| fi | |
| - name: Install dist | |
| run: ${{ matrix.install_dist.run }} | |
| - name: Fetch local artifacts | |
| uses: actions/download-artifact@v7 | |
| with: | |
| pattern: artifacts-* | |
| path: target/distrib/ | |
| merge-multiple: true | |
| - name: Install dependencies | |
| run: | | |
| ${{ matrix.packages_install }} | |
| - name: Build artifacts | |
| shell: bash | |
| run: | | |
| if ! grep -Fqx '[package.metadata.dist]' Cargo.toml; then | |
| printf '\n[package.metadata.dist]\ndist = true\n' >> Cargo.toml | |
| fi | |
| if [ "${{ needs.prepare.outputs.recovery-release }}" = "true" ]; then | |
| DIST_ALLOW_DIRTY="--allow-dirty" | |
| else | |
| DIST_ALLOW_DIRTY="" | |
| fi | |
| dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} ${DIST_ALLOW_DIRTY} > dist-manifest.json | |
| echo "dist ran successfully" | |
| - id: cargo-dist | |
| name: Post-build | |
| shell: bash | |
| run: | | |
| echo "paths<<EOF" >> "$GITHUB_OUTPUT" | |
| dist print-upload-files-from-manifest --manifest dist-manifest.json >> "$GITHUB_OUTPUT" | |
| echo "EOF" >> "$GITHUB_OUTPUT" | |
| cp dist-manifest.json "$BUILD_MANIFEST_NAME" | |
| - name: Upload artifacts | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: artifacts-build-local-${{ join(matrix.targets, '_') }} | |
| path: | | |
| ${{ steps.cargo-dist.outputs.paths }} | |
| ${{ env.BUILD_MANIFEST_NAME }} | |
| build-global-artifacts: | |
| needs: | |
| - prepare | |
| - plan | |
| - build-local-artifacts | |
| if: ${{ always() && needs.prepare.result == 'success' && needs.prepare.outputs.prepared == 'true' && needs.prepare.outputs['release-tag'] != '' && needs.plan.result == 'success' && needs.plan.outputs.draft-complete != 'true' && (fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include == null || needs.build-local-artifacts.result == 'success') }} | |
| runs-on: ubuntu-22.04 | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| ref: ${{ needs.prepare.outputs['release-tag'] }} | |
| persist-credentials: false | |
| submodules: recursive | |
| - name: Preflight release publisher disk | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| report_disk() { | |
| echo "::group::Release publisher disk usage" | |
| df -h . "$RUNNER_TEMP" "$HOME" || true | |
| echo "::endgroup::" | |
| } | |
| available_kb() { | |
| df -Pk . | awk 'NR==2 {print $4}' | |
| } | |
| report_disk | |
| before_kb="$(available_kb)" | |
| if [ "$before_kb" -lt "$RELEASE_MIN_FREE_KB" ]; then | |
| echo "::warning::Release publisher has ${before_kb} KiB free before publish; cleaning reconstructable release artifacts and caches" | |
| rm -rf target/distrib target/package .homeboy-bin artifacts "$HOME/.cache/cargo-dist" "$HOME/.cache/sccache" | |
| fi | |
| report_disk | |
| after_kb="$(available_kb)" | |
| if [ "$after_kb" -lt "$RELEASE_MIN_FREE_KB" ]; then | |
| echo "::error::Release publisher has ${after_kb} KiB free after cleanup; refusing publish before the runner exhausts disk while writing diagnostics" | |
| exit 1 | |
| fi | |
| - name: Install cached dist | |
| uses: actions/download-artifact@v7 | |
| with: | |
| name: cargo-dist-cache | |
| path: ~/.cargo/bin/ | |
| - run: chmod +x ~/.cargo/bin/dist | |
| - name: Fetch local artifacts | |
| uses: actions/download-artifact@v7 | |
| with: | |
| pattern: artifacts-* | |
| path: target/distrib/ | |
| merge-multiple: true | |
| - id: cargo-dist | |
| shell: bash | |
| run: | | |
| if ! grep -Fqx '[package.metadata.dist]' Cargo.toml; then | |
| printf '\n[package.metadata.dist]\ndist = true\n' >> Cargo.toml | |
| fi | |
| if [ "${{ needs.prepare.outputs.recovery-release }}" = "true" ]; then | |
| DIST_ALLOW_DIRTY="--allow-dirty" | |
| else | |
| DIST_ALLOW_DIRTY="" | |
| fi | |
| dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" ${DIST_ALLOW_DIRTY} > dist-manifest.json | |
| echo "dist ran successfully" | |
| echo "paths<<EOF" >> "$GITHUB_OUTPUT" | |
| jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT" | |
| echo "EOF" >> "$GITHUB_OUTPUT" | |
| cp dist-manifest.json "$BUILD_MANIFEST_NAME" | |
| - name: Upload artifacts | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: artifacts-build-global | |
| path: | | |
| ${{ steps.cargo-dist.outputs.paths }} | |
| ${{ env.BUILD_MANIFEST_NAME }} | |
| # ── Step 5: Publish ── | |
| host: | |
| name: Create GitHub Release | |
| needs: | |
| - prepare | |
| - plan | |
| - build-local-artifacts | |
| - build-global-artifacts | |
| # The draft-complete fast path (#10519) skips the artifact matrix entirely, | |
| # so `host` must not require builds that were deliberately never run. It | |
| # still runs: the adoption finalizer below is what publishes the draft. | |
| if: ${{ always() && needs.prepare.result == 'success' && needs.prepare.outputs.prepared == 'true' && needs.prepare.outputs['release-tag'] != '' && needs.plan.result == 'success' && (needs.plan.outputs.draft-complete == 'true' || (needs.build-global-artifacts.result == 'success' && (fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include == null || needs.build-local-artifacts.result == 'success'))) }} | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} | |
| runs-on: ubuntu-22.04 | |
| outputs: | |
| val: ${{ steps.host.outputs.manifest }} | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| ref: ${{ needs.prepare.outputs['release-tag'] }} | |
| fetch-depth: 0 | |
| persist-credentials: false | |
| submodules: recursive | |
| # Everything from here to the adoption manifest exists to rebuild and | |
| # re-upload artifacts. When the draft already holds every expected asset | |
| # (#10519) there is nothing to upload, so the whole cargo-dist upload | |
| # phase is skipped and this job goes straight to verified adoption. | |
| - name: Install cached dist | |
| if: needs.plan.outputs.draft-complete != 'true' | |
| uses: actions/download-artifact@v7 | |
| with: | |
| name: cargo-dist-cache | |
| path: ~/.cargo/bin/ | |
| - if: needs.plan.outputs.draft-complete != 'true' | |
| run: chmod +x ~/.cargo/bin/dist | |
| - name: Fetch artifacts | |
| if: needs.plan.outputs.draft-complete != 'true' | |
| uses: actions/download-artifact@v7 | |
| with: | |
| pattern: artifacts-* | |
| path: target/distrib/ | |
| merge-multiple: true | |
| - id: host | |
| if: needs.plan.outputs.draft-complete != 'true' | |
| shell: bash | |
| run: | | |
| if ! grep -Fqx '[package.metadata.dist]' Cargo.toml; then | |
| printf '\n[package.metadata.dist]\ndist = true\n' >> Cargo.toml | |
| fi | |
| if [ "${{ needs.prepare.outputs.recovery-release }}" = "true" ]; then | |
| DIST_ALLOW_DIRTY="--allow-dirty" | |
| else | |
| DIST_ALLOW_DIRTY="" | |
| fi | |
| dist host --tag=${{ needs.prepare.outputs['release-tag'] }} --steps=upload --output-format=json ${DIST_ALLOW_DIRTY} > dist-manifest.json | |
| echo "artifacts uploaded successfully" | |
| cat dist-manifest.json | |
| echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT" | |
| - name: Restore recovery cargo-dist overlay | |
| if: needs.prepare.outputs.recovery-release == 'true' && needs.plan.outputs.draft-complete != 'true' | |
| run: git restore --source=HEAD -- Cargo.toml | |
| - name: Upload dist-manifest.json | |
| if: needs.plan.outputs.draft-complete != 'true' | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: artifacts-dist-manifest | |
| path: dist-manifest.json | |
| - name: Download GitHub Artifacts | |
| if: needs.plan.outputs.draft-complete != 'true' | |
| uses: actions/download-artifact@v7 | |
| with: | |
| pattern: artifacts-* | |
| path: artifacts | |
| merge-multiple: true | |
| - name: Cleanup | |
| if: needs.plan.outputs.draft-complete != 'true' | |
| run: | | |
| rm -f artifacts/*-dist-manifest.json | |
| - name: Download current Homeboy finalizer | |
| uses: actions/download-artifact@v7 | |
| with: | |
| name: homeboy-binary | |
| path: .homeboy-bin | |
| - name: Create fresh artifact source-authority manifest | |
| if: needs.prepare.outputs.recovery-release != 'true' && needs.plan.outputs.draft-complete != 'true' | |
| env: | |
| RELEASE_TAG: ${{ needs.prepare.outputs['release-tag'] }} | |
| RELEASE_VERSION: ${{ needs.prepare.outputs['release-version'] }} | |
| run: | | |
| set -euo pipefail | |
| chmod +x .homeboy-bin/homeboy | |
| .homeboy-bin/homeboy release artifact-source-authority homeboy \ | |
| --dir artifacts \ | |
| --tag "${RELEASE_TAG}" \ | |
| --version "${RELEASE_VERSION}" \ | |
| --commit "$(git rev-parse "${RELEASE_TAG}^{commit}")" | |
| - name: Preserve existing published asset bytes | |
| if: needs.prepare.outputs.recovery-release == 'true' && needs.plan.outputs.draft-complete != 'true' | |
| env: | |
| RELEASE_TAG: ${{ needs.prepare.outputs['release-tag'] }} | |
| EXPECTED_ASSETS: ${{ needs.plan.outputs.expected-assets }} | |
| run: | | |
| set -euo pipefail | |
| if ! gh release view "${RELEASE_TAG}" --json isDraft,assets > existing-release.json; then | |
| exit 0 | |
| fi | |
| if [ "$(jq -r '.isDraft' existing-release.json)" != "false" ]; then | |
| exit 0 | |
| fi | |
| mkdir -p existing-assets | |
| gh release download "${RELEASE_TAG}" --dir existing-assets | |
| while IFS= read -r name; do | |
| path="existing-assets/${name}" | |
| if [ ! -f "${path}" ]; then | |
| continue | |
| fi | |
| test -s "${path}" | |
| remote_digest="$(jq -r --arg name "${name}" '.assets[] | select(.name == $name) | .digest // empty' existing-release.json)" | |
| if [[ "${remote_digest}" != sha256:* ]]; then | |
| echo "::error::GitHub metadata did not provide a SHA-256 digest for existing asset ${name}" | |
| exit 1 | |
| fi | |
| actual_digest="sha256:$(sha256sum "${path}" | cut -d' ' -f1)" | |
| if [ "${actual_digest}" != "${remote_digest}" ]; then | |
| echo "::error::Downloaded ${name} digest ${actual_digest} does not match GitHub metadata ${remote_digest}" | |
| exit 1 | |
| fi | |
| cp "${path}" "artifacts/${name}" | |
| done < <(jq -r '.[]' <<< "${EXPECTED_ASSETS}") | |
| - name: Create authoritative recovery manifest | |
| if: needs.prepare.outputs.recovery-release == 'true' && needs.plan.outputs.draft-complete != 'true' | |
| env: | |
| RELEASE_TAG: ${{ needs.prepare.outputs['release-tag'] }} | |
| RELEASE_VERSION: ${{ needs.prepare.outputs['release-version'] }} | |
| EXPECTED_ASSETS: ${{ needs.plan.outputs.expected-assets }} | |
| run: | | |
| set -euo pipefail | |
| COMMIT="$(git rev-parse "${RELEASE_TAG}^{}")" | |
| jq -n \ | |
| --arg schema homeboy.package-recovery \ | |
| --argjson schema_version 1 \ | |
| --arg component_id homeboy \ | |
| --arg tag "${RELEASE_TAG}" \ | |
| --arg version "${RELEASE_VERSION}" \ | |
| --arg commit "${COMMIT}" \ | |
| --argjson expected_assets "${EXPECTED_ASSETS}" \ | |
| '{schema: $schema, schema_version: $schema_version, component_id: $component_id, tag: $tag, version: $version, commit: $commit, artifacts: [$expected_assets[] | {path: .}]}' > artifacts/manifest.json | |
| - name: Create remote draft adoption manifest | |
| if: needs.prepare.outputs.recovery-release == 'true' && needs.plan.outputs.draft-complete == 'true' | |
| env: | |
| RELEASE_TAG: ${{ needs.prepare.outputs['release-tag'] }} | |
| RELEASE_VERSION: ${{ needs.prepare.outputs['release-version'] }} | |
| EXPECTED_ASSETS: ${{ needs.plan.outputs.expected-assets }} | |
| CONTROL_SHA: ${{ github.sha }} | |
| run: | | |
| set -euo pipefail | |
| mkdir -p draft-adoption | |
| COMMIT="$(git rev-parse "${RELEASE_TAG}^{}")" | |
| # ── Control-binary lineage (issue #10519) ── | |
| # This checkout is the RELEASE TARGET (the tag). The binary that will | |
| # publish it was built from CONTROL_SHA. Recovery is meant to run code | |
| # NEWER than the tag it repairs — that is how a publisher fix merged | |
| # after the tag reaches the stranded release at all. The inverse is | |
| # not safe: a control binary from a tree that never contained the tag | |
| # would apply a release contract this tag was never planned under. | |
| # Record the relationship so the publisher can enforce that boundary. | |
| # | |
| # Every unresolvable path emits `null`, never `false`: an ambiguous | |
| # answer must degrade to "unverified", not brick an already-stranded | |
| # release. Only a definitive "not an ancestor" blocks recovery. | |
| CONTAINS_TARGET=null | |
| if ! git cat-file -e "${CONTROL_SHA}^{commit}" 2>/dev/null; then | |
| git fetch --no-tags --depth=1 origin "${CONTROL_SHA}" 2>/dev/null || true | |
| fi | |
| if git cat-file -e "${CONTROL_SHA}^{commit}" 2>/dev/null; then | |
| set +e | |
| git merge-base --is-ancestor "${COMMIT}" "${CONTROL_SHA}" 2>/dev/null | |
| ANCESTRY_STATUS=$? | |
| set -e | |
| case "${ANCESTRY_STATUS}" in | |
| 0) CONTAINS_TARGET=true ;; | |
| 1) CONTAINS_TARGET=false ;; | |
| *) echo "::warning::git merge-base --is-ancestor exited ${ANCESTRY_STATUS}; recovery control lineage reported as unverified." ;; | |
| esac | |
| else | |
| echo "::warning::Control commit ${CONTROL_SHA} is not present in this checkout; recovery control lineage reported as unverified." | |
| fi | |
| jq -n \ | |
| --arg schema homeboy.draft-adoption \ | |
| --argjson schema_version 1 \ | |
| --arg component_id homeboy \ | |
| --arg tag "${RELEASE_TAG}" \ | |
| --arg version "${RELEASE_VERSION}" \ | |
| --arg commit "${COMMIT}" \ | |
| --argjson expected_assets "${EXPECTED_ASSETS}" \ | |
| --arg control_commit "${CONTROL_SHA}" \ | |
| --argjson contains_target "${CONTAINS_TARGET}" \ | |
| '{schema: $schema, schema_version: $schema_version, component_id: $component_id, tag: $tag, version: $version, commit: $commit, expected_assets: $expected_assets, control: {commit: $control_commit, contains_target: $contains_target}}' > draft-adoption/manifest.json | |
| echo "::notice::Recovery control lineage: control ${CONTROL_SHA} vs target ${COMMIT} (contains_target=${CONTAINS_TARGET})" | |
| # Recovery has two independent provenances that must never be conflated | |
| # (issue #10519): the *control binary* that performs publication, and the | |
| # *release target* whose tree and bytes are immutable. Record both | |
| # explicitly so a run's evidence proves which code repaired which tag. | |
| - name: Verify current Homeboy finalizer | |
| if: needs.prepare.outputs.recovery-release == 'true' | |
| env: | |
| RELEASE_TAG: ${{ needs.prepare.outputs['release-tag'] }} | |
| CONTROL_SHA: ${{ github.sha }} | |
| ADOPTION_SOURCE: ${{ needs.prepare.outputs.recovery-release == 'true' && needs.plan.outputs.draft-complete == 'true' && 'draft-adoption' || 'artifacts' }} | |
| ARTIFACT_ORIGIN: ${{ needs.plan.outputs.draft-complete == 'true' && 'pre-existing draft assets (rebuild skipped)' || 'rebuilt and re-uploaded by this run' }} | |
| run: | | |
| set -euo pipefail | |
| chmod +x .homeboy-bin/homeboy | |
| # Never let provenance reporting become a new way for recovery to | |
| # fail closed; the finalizer step below is the real execution gate. | |
| CONTROL_IDENTITY="$(.homeboy-bin/homeboy --version 2>/dev/null || echo 'unreadable')" | |
| TARGET_SHA="$(git rev-parse "${RELEASE_TAG}^{}")" | |
| { | |
| echo "### Release recovery provenance" | |
| echo "" | |
| echo "| role | value |" | |
| echo "| --- | --- |" | |
| echo "| control binary | \`${CONTROL_IDENTITY}\` |" | |
| echo "| control commit | \`${CONTROL_SHA}\` |" | |
| echo "| release target tag | \`${RELEASE_TAG}\` |" | |
| echo "| release target commit | \`${TARGET_SHA}\` |" | |
| echo "| artifact provenance | \`${ADOPTION_SOURCE}\` |" | |
| echo "| artifact bytes | \`${ARTIFACT_ORIGIN}\` |" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| echo "::notice::Recovery control binary ${CONTROL_IDENTITY} built from ${CONTROL_SHA} is repairing ${RELEASE_TAG} at ${TARGET_SHA} using ${ADOPTION_SOURCE} provenance" | |
| if [ "${CONTROL_SHA}" = "${TARGET_SHA}" ]; then | |
| # Not fatal: a tag pushed moments ago is legitimately still HEAD. | |
| # It does mean a publisher fix merged after the tag cannot be | |
| # bootstrapped by this run — the exact trap #10519 describes. | |
| echo "::warning::Recovery control binary was built from the release target commit ${TARGET_SHA}; a publisher fix merged after ${RELEASE_TAG} cannot be bootstrapped by this run (#10519). Re-dispatch from an updated default branch if recovery keeps failing in the publisher." | |
| fi | |
| # ── Publication gate: the declared asset set is the contract (#11749) ── | |
| # | |
| # The step below is what turns the draft into a published release. Every | |
| # asset check that existed before this one ran AFTER it — `verify-published` | |
| # detects an incomplete release and re-drafts it, which is compensation, | |
| # not prevention. Compensation requires the compensating job to run, and | |
| # the runs that produce incomplete releases are exactly the runs that do | |
| # not get that far: v0.323.1 published 2 of 14 assets and stayed live, | |
| # v0.333.0 published 7 of 13 with no Linux binary at all. | |
| # | |
| # So publication is now conditional on the complete declared asset set, | |
| # checked immediately before it happens. If the inventory cannot satisfy | |
| # `dist-workspace.toml`, this job fails and the release stays a draft — | |
| # a failed run is strictly better than a published release that 404s for | |
| # the platforms it dropped, because a draft is recoverable and a bad | |
| # `latest` is what pinned this controller 453 commits behind. | |
| # | |
| # `REQUIRE_ANNOUNCE_ASSETS: false` because cargo-dist attaches | |
| # `dist-manifest.json` when it announces, which has not happened yet at | |
| # this point. `verify-published` still re-checks the full inventory, | |
| # `dist-manifest.json` included, once the release is live. | |
| - name: Gate publication on the declared asset set | |
| env: | |
| RELEASE_TAG: ${{ needs.prepare.outputs['release-tag'] }} | |
| EXPECTED_ASSETS: ${{ needs.plan.outputs.expected-assets }} | |
| REQUIRE_ANNOUNCE_ASSETS: 'false' | |
| run: | | |
| bash .github/release-asset-completeness.sh | |
| - name: Finish Homeboy release pipeline at tag | |
| uses: Extra-Chill/homeboy-action@v2 | |
| with: | |
| source: '.' | |
| component: homeboy | |
| commands: release | |
| binary-path: ${{ needs.prepare.outputs.recovery-release == 'true' && '.homeboy-bin/homeboy' || '' }} | |
| release-head: 'true' | |
| release-from-artifacts: ${{ needs.prepare.outputs.recovery-release == 'true' && needs.plan.outputs.draft-complete == 'true' && 'draft-adoption' || 'artifacts' }} | |
| release-skip-publish: 'true' | |
| # ── Step 5b: Fail loudly if a prepared release did not publish ── | |
| # Skipped jobs do not fail a run, so a broken publish chain (e.g. `plan` | |
| # or `host` skipped) would otherwise land a tag with no GitHub Release | |
| # while the overall run still reports success — exactly the regression | |
| # fixed in #8567. This guard turns that silent gap into a red run: once a | |
| # release is actually prepared (`prepared == 'true'`), `host` MUST succeed. | |
| verify-published: | |
| name: Verify GitHub Release published | |
| needs: | |
| - prepare | |
| - plan | |
| - host | |
| if: ${{ always() && needs.prepare.outputs.prepared == 'true' && needs.prepare.outputs['release-tag'] != '' }} | |
| runs-on: ubuntu-latest | |
| permissions: | |
| # `write` so an incomplete release can be returned to draft. Detecting a | |
| # broken published release and leaving it published is not a guard (#8687). | |
| contents: write | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| steps: | |
| - name: Verify planned release assets | |
| env: | |
| RELEASE_TAG: ${{ needs.prepare.outputs['release-tag'] }} | |
| HOST_RESULT: ${{ needs.host.result }} | |
| EXPECTED_ASSETS: ${{ needs.plan.outputs.expected-assets }} | |
| run: | | |
| if [ "${HOST_RESULT}" != "success" ]; then | |
| echo "::error::Release ${RELEASE_TAG} was prepared and tagged but the Create GitHub Release job did not succeed (result: ${HOST_RESULT}). The tag exists with no GitHub Release. Investigate the plan/build/host publish chain; recover with a workflow_dispatch run using release_tag=${RELEASE_TAG}." | |
| exit 1 | |
| fi | |
| # `releases/tags/{tag}` resolves published releases only; a draft 404s | |
| # there. Distinguish the two so "never published" cannot be reported as | |
| # a malformed API response. | |
| if ! gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_TAG}" > release.json 2>/dev/null; then | |
| echo "::error::Release ${RELEASE_TAG} is not published. The tag exists but the GitHub Release is still a draft or absent, so consumers pinning this version get nothing. Recover with a workflow_dispatch run using release_tag=${RELEASE_TAG}." | |
| exit 1 | |
| fi | |
| missing=() | |
| invalid=() | |
| while IFS= read -r asset; do | |
| matches="$(jq -c --arg name "${asset}" '[.assets[] | select(.name == $name)]' release.json)" | |
| if [ "${matches}" = "[]" ]; then | |
| missing+=("${asset}") | |
| elif ! jq -e --arg name "${asset}" '.assets[] | select(.name == $name and .state == "uploaded" and .size > 0)' release.json >/dev/null; then | |
| invalid+=("${asset}: $(jq -c --arg name "${asset}" '[.assets[] | select(.name == $name) | {state, size}]' release.json)") | |
| fi | |
| done < <(jq -r '.[]' <<< "${EXPECTED_ASSETS}") | |
| if [ "${#missing[@]}" -gt 0 ] || [ "${#invalid[@]}" -gt 0 ]; then | |
| [ "${#missing[@]}" -eq 0 ] || echo "::error::Release ${RELEASE_TAG} is missing planned assets: ${missing[*]}" | |
| [ "${#invalid[@]}" -eq 0 ] || echo "::error::Release ${RELEASE_TAG} has planned assets that are not uploaded or are zero bytes: ${invalid[*]}" | |
| # Return it to draft. A published release that is missing planned | |
| # assets serves 404s for the platforms it dropped, and can become | |
| # `latest` if the next release also fails. Failing the run while | |
| # leaving it published detects the problem without containing it -- | |
| # v0.323.1 shipped 2 of 14 assets and stayed live exactly that way. | |
| # | |
| # This is reversible and non-destructive: the tag, the release body | |
| # and every uploaded asset are retained. Only the draft flag flips, | |
| # which is what makes the release unreachable to consumers until a | |
| # recovery run completes it. | |
| if jq -e '.draft == false' release.json >/dev/null; then | |
| if gh release edit "${RELEASE_TAG}" --draft=true --repo "${GITHUB_REPOSITORY}"; then | |
| echo "::error::Release ${RELEASE_TAG} was returned to DRAFT because it was published without every planned asset. Its tag and uploaded assets are retained. Complete it with a workflow_dispatch run using release_tag=${RELEASE_TAG}, which republishes once the asset set verifies." | |
| else | |
| echo "::error::Release ${RELEASE_TAG} is published and incomplete, and could not be returned to draft. Un-publish it by hand before any consumer resolves it: gh release edit ${RELEASE_TAG} --draft=true --repo ${GITHUB_REPOSITORY}" | |
| fi | |
| fi | |
| exit 1 | |
| fi | |
| echo "::notice::Release ${RELEASE_TAG} contains every planned asset." | |
| announce: | |
| needs: | |
| - plan | |
| - host | |
| if: ${{ always() && needs.host.result == 'success' }} | |
| runs-on: ubuntu-22.04 | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| persist-credentials: false | |
| submodules: recursive | |
| record-failure: | |
| name: Record failed release SHA | |
| needs: | |
| - check | |
| - gate-build | |
| - gate-audit | |
| - gate-lint | |
| - gate-test | |
| - release-quality-policy | |
| - prepare | |
| - plan | |
| - build-local-artifacts | |
| - build-global-artifacts | |
| - host | |
| - verify-published | |
| - announce | |
| if: ${{ always() && github.event_name == 'push' && needs.check.outputs.should-release == 'true' && contains(toJson(needs), '"result":"failure"') }} | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| # The SHA marker means "do not re-PREPARE this HEAD". A recovery run | |
| # never prepares anything — it finishes an already-prepared tag — so | |
| # recording HEAD there would wrongly suppress the fresh release this | |
| # HEAD still deserves. | |
| - name: Save failed SHA | |
| if: needs.check.outputs.recovery-release != 'true' | |
| run: git rev-parse HEAD > "${RUNNER_TEMP}/homeboy-release-last-failed" | |
| - name: Cache failed SHA | |
| if: needs.check.outputs.recovery-release != 'true' | |
| uses: actions/cache/save@v5 | |
| with: | |
| path: ${{ runner.temp }}/homeboy-release-last-failed | |
| key: release-last-failed-${{ github.ref_name }}-${{ github.sha }} | |
| # A failed recovery is charged to the TAG, so a stranded tag that cannot | |
| # be published gets a bounded retry budget instead of re-firing on every | |
| # push forever. `detect-stranded-release.sh` refuses a tag once it hits | |
| # MAX_RECOVERY_ATTEMPTS and falls through to a normal fresh release. | |
| - name: Restore recovery attempts | |
| if: needs.check.outputs.recovery-release == 'true' && needs.check.outputs['release-tag'] != '' | |
| uses: actions/cache/restore@v5 | |
| with: | |
| path: ${{ runner.temp }}/homeboy-release-recovery-attempts | |
| key: release-recovery-attempts-${{ github.ref_name }}-${{ github.run_id }} | |
| restore-keys: | | |
| release-recovery-attempts-${{ github.ref_name }}- | |
| - name: Record failed recovery attempt | |
| if: needs.check.outputs.recovery-release == 'true' && needs.check.outputs['release-tag'] != '' | |
| env: | |
| RELEASE_TAG: ${{ needs.check.outputs['release-tag'] }} | |
| run: | | |
| set -euo pipefail | |
| MARKER="${RUNNER_TEMP}/homeboy-release-recovery-attempts" | |
| touch "${MARKER}" | |
| NEXT="$(awk -v tag="${RELEASE_TAG}" '$1 == tag { count = $2 } END { printf "%d", count + 1 }' "${MARKER}")" | |
| awk -v tag="${RELEASE_TAG}" '$1 != tag' "${MARKER}" > "${MARKER}.next" | |
| printf '%s %s\n' "${RELEASE_TAG}" "${NEXT}" >> "${MARKER}.next" | |
| mv "${MARKER}.next" "${MARKER}" | |
| echo "::warning::Automatic recovery of ${RELEASE_TAG} failed (attempt ${NEXT})" | |
| cat "${MARKER}" | |
| - name: Cache recovery attempts | |
| if: needs.check.outputs.recovery-release == 'true' && needs.check.outputs['release-tag'] != '' | |
| uses: actions/cache/save@v5 | |
| with: | |
| path: ${{ runner.temp }}/homeboy-release-recovery-attempts | |
| key: release-recovery-attempts-${{ github.ref_name }}-${{ github.run_id }} | |
| clear-failure: | |
| name: Clear failed release SHA cache | |
| needs: | |
| - check | |
| - gate-build | |
| - gate-audit | |
| - gate-lint | |
| - gate-test | |
| - release-quality-policy | |
| - prepare | |
| - plan | |
| - build-local-artifacts | |
| - build-global-artifacts | |
| - host | |
| - verify-published | |
| - announce | |
| if: ${{ always() && github.event_name == 'push' && needs.check.outputs.should-release == 'true' && !contains(toJson(needs), '"result":"failure"') && !contains(toJson(needs), '"result":"cancelled"') }} | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Clear failed SHA cache | |
| if: needs.check.outputs.recovery-release != 'true' | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| gh cache list --json id,key --jq '.[] | select(.key | startswith("release-last-failed-${{ github.ref_name }}-")) | .id' | while read -r id; do | |
| gh cache delete "$id" 2>/dev/null || true | |
| done | |
| # Only a SUCCESSFUL recovery clears the retry budget. Clearing it after a | |
| # successful *fresh* release would reset the counter of a tag that keeps | |
| # failing, turning the bounded retry into a slow infinite loop. | |
| - name: Clear recovery attempts cache | |
| if: needs.check.outputs.recovery-release == 'true' | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| gh cache list --json id,key --jq '.[] | select(.key | startswith("release-recovery-attempts-${{ github.ref_name }}-")) | .id' | while read -r id; do | |
| gh cache delete "$id" 2>/dev/null || true | |
| done |