Skip to content

test(cook): use real paths for finalization fixtures (#11893) #7100

test(cook): use real paths for finalization fixtures (#11893)

test(cook): use real paths for finalization fixtures (#11893) #7100

Workflow file for this run

# 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