Pin the documented install to v1.48.0 #14
Workflow file for this run
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
| name: release | |
| # Publication is a separate, stricter act from tagging. A tag can already exist by the time | |
| # this workflow starts -- release-manager (UNITY) creates and pushes it the same way it always | |
| # has -- but nothing here treats a pushed tag as permission to publish. Every job re-reads the | |
| # remote's own record for the exact commit the tag points to, never the machine this workflow | |
| # happens to run on and never a branch reference that could have moved since. | |
| # | |
| # Trigger is a tag push, not `main`. `main` is a moving reference by definition -- a second | |
| # push during this run would change what it points to -- and Phase 2's "no workflow step | |
| # publishes from a moving branch reference" rules that out as the publish source. A tag pointing | |
| # at one fixed commit does not have that failure mode, provided nothing here ever force-moves | |
| # one, which is why the publish job re-derives and re-checks the tag's target commit instead of | |
| # trusting a value cached from an earlier job. | |
| on: | |
| push: | |
| tags: | |
| - 'v*.*.*' | |
| workflow_dispatch: | |
| inputs: | |
| tag: | |
| description: 'Existing tag to (re-)verify and publish, e.g. v1.46.0. Defaults to the tag that triggered this run.' | |
| required: false | |
| type: string | |
| concurrency: | |
| group: release-${{ github.event.inputs.tag || github.ref_name }} | |
| cancel-in-progress: false | |
| permissions: | |
| contents: read | |
| env: | |
| REQUIRED_CHECKS: verify install-linux install-macos install-alpine | |
| # Four job names in .github/workflows/verify.yml, testing three unique platforms | |
| # (ubuntu-latest, macos-latest, alpine:latest -- `verify` and `install-linux` both run on | |
| # ubuntu-latest). README.md and check 26 of .claude/verify.sh assert three documented | |
| # platforms; this list asserts four required *jobs*. Both counts are correct at once because | |
| # they are counting different things. See the release report for the discrepancy in full -- | |
| # do not "fix" this list to length 3 to make it match the platform count, and do not add a | |
| # fourth platform to satisfy an arithmetic coincidence; the repo does not run one. | |
| jobs: | |
| # --- resolve: pin everything to one commit before any other job trusts it ----------------- | |
| resolve: | |
| runs-on: ubuntu-latest | |
| outputs: | |
| tag: ${{ steps.pin.outputs.tag }} | |
| sha: ${{ steps.pin.outputs.sha }} | |
| # 'green' | 'failed' | 'undecided'. cleanup-on-failed-gate deletes the tag, so it needs to | |
| # tell a check that answered no from a check that has not answered. Empty when this job | |
| # failed before the gate step ran, which cleanup treats as a failure -- the conservative | |
| # direction for every refusal that is not specifically "not yet". | |
| gate: ${{ steps.gate.outputs.verdict }} | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| fetch-depth: 0 | |
| - name: Pin the candidate tag to the exact commit it names right now | |
| id: pin | |
| run: | | |
| set -euo pipefail | |
| TAG="${{ github.event.inputs.tag || github.ref_name }}" | |
| case "$TAG" in | |
| v[0-9]*.[0-9]*.[0-9]*) ;; | |
| *) echo "refusing: '$TAG' is not a vMAJOR.MINOR.PATCH tag"; exit 1 ;; | |
| esac | |
| git rev-parse "refs/tags/$TAG" >/dev/null 2>&1 \ | |
| || { echo "refusing: tag $TAG does not exist on the remote this checkout fetched"; exit 1; } | |
| # ^{commit} dereferences an annotated tag object to the commit it points at. This | |
| # repo's tags are annotated (release-manager's own process creates them that way) -- | |
| # trusting github.sha for a tag-push event would work today but ties correctness to | |
| # GitHub's event-payload behavior for annotated vs lightweight tags, which this | |
| # workflow has no reason to depend on when `git rev-parse` answers the same question | |
| # directly against the ref it is about to publish. | |
| SHA=$(git rev-parse "refs/tags/$TAG^{commit}") | |
| echo "tag=$TAG" >> "$GITHUB_OUTPUT" | |
| echo "sha=$SHA" >> "$GITHUB_OUTPUT" | |
| echo "resolved $TAG -> $SHA" | |
| - name: The candidate commit is reachable from main, not an orphan or a rewritten history | |
| run: | | |
| set -euo pipefail | |
| git merge-base --is-ancestor "${{ steps.pin.outputs.sha }}" origin/main \ | |
| || { echo "refusing: ${{ steps.pin.outputs.sha }} is not an ancestor of origin/main"; exit 1; } | |
| echo "ok ${{ steps.pin.outputs.sha }} is on origin/main" | |
| - name: Every required job succeeded for this exact commit | |
| id: gate | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| # A tag push and the push of its own commit trigger verify.yml and this workflow within | |
| # seconds of each other, so on the ordinary path the required checks are still queued | |
| # when this runs. Wait for them rather than refusing, because the refusal is not free: | |
| # it used to delete the tag, and even now it costs a manual re-dispatch. 25 minutes | |
| # covers this repo's slowest lane (verify's falsifiability sweep) with margin. | |
| # Derived from the workflow it waits for, not chosen. The last eight completed | |
| # `verify` runs took 27, 27, 28, 36, 52, 52, 59 and 70 minutes -- the falsifiability | |
| # suite dominates and grows with every check added. 1500s is 25 minutes, which is | |
| # BELOW ALL EIGHT: this budget had never once been long enough, so every release | |
| # timed out undecided and needed a manual re-dispatch. Measured on v1.47.0, which | |
| # sat at "1 required check still undecided" for the entire 1500s and then gave up | |
| # while the run it was waiting for was 20 minutes from finishing. | |
| # | |
| # 5400s is 90 minutes, comfortably above the 70-minute worst case, and well inside | |
| # GitHub's 360-minute job ceiling. The cost is a runner minute per minute spent | |
| # waiting; that is the price of not publishing over an unread verdict, and it is | |
| # only paid when a release is actually in flight. Re-derive this from the numbers | |
| # above if the suite grows again rather than nudging it. | |
| REQUIRE_CHECKS_WAIT_SECONDS: "5400" | |
| run: | | |
| # When origin first had this tag, as closely as this workflow can observe it. On the | |
| # ordinary path -- a tag push -- this run was CREATED BY that push, so its created_at | |
| # is the moment the tag became fetchable, to within a second. That is exactly the | |
| # boundary the gate needs: bin/doctor's "declared release is fetchable" and check 24 | |
| # are both required to be red before it and are able to be green after it, so a | |
| # conclusion recorded earlier is a verdict about a repository that did not contain | |
| # this release. | |
| # | |
| # Not the tag object's own tagger date, which is when someone typed `git tag` on their | |
| # laptop and can precede the push by any amount. Not a lightweight tag's -- it has | |
| # none. The push is the event that changes what a stranger can fetch, and this run is | |
| # the push's own receipt. | |
| # | |
| # On a manual workflow_dispatch there was no push, so created_at is "now" and every | |
| # earlier conclusion reads as stale. That direction is safe: stale means undecided, | |
| # undecided means publication is withheld and the tag is left alone. A re-dispatch | |
| # declining to delete a tag is a nuisance; deleting the right tag over the wrong | |
| # verdict is what this is here to stop. | |
| CANDIDATE_CREATED_AT=$(gh api "repos/${{ github.repository }}/actions/runs/${GITHUB_RUN_ID}" --jq '.created_at' 2>/dev/null || true) | |
| export CANDIDATE_CREATED_AT | |
| echo "candidate tag observed on origin at ${CANDIDATE_CREATED_AT:-<unknown -- staleness disabled, every conclusion counts>}" | |
| set +e | |
| bash .github/scripts/require-checks-green.sh \ | |
| "${{ github.repository }}" "${{ steps.pin.outputs.sha }}" ${{ env.REQUIRED_CHECKS }} | |
| rc=$? | |
| set -e | |
| case "$rc" in | |
| 0) echo "verdict=green" >> "$GITHUB_OUTPUT" ;; | |
| 2) echo "verdict=undecided" >> "$GITHUB_OUTPUT" ;; | |
| *) echo "verdict=failed" >> "$GITHUB_OUTPUT" ;; | |
| esac | |
| exit "$rc" | |
| - name: tests/bin-scripts.sh runs clean against the candidate commit | |
| run: ./tests/bin-scripts.sh | |
| # --- container-matrix: the published-artifact lane, run before anything is published ------- | |
| # The tag from `resolve` already exists on the remote (that is what triggered this workflow), | |
| # so it is a resolvable ref container-matrix.sh can clone -- but nothing has created a GitHub | |
| # Release yet, and this job runs before the one that does. A tag existing is not "published" | |
| # in the sense Phase 2 means it: there are no release notes, it is not marked Latest, and | |
| # nothing a stranger's install script resolves to it until the publish job below runs. | |
| container-matrix: | |
| needs: resolve | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 45 | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| ref: ${{ needs.resolve.outputs.sha }} | |
| - name: Published-artifact install, three foreign container images | |
| env: | |
| VSTACK_REF: ${{ needs.resolve.outputs.tag }} | |
| run: bash ./tests/container-matrix.sh | |
| # --- cleanup: a failed required job does not get to leave a tag behind --------------------- | |
| # "A failed required job cannot produce a tag or GitHub release" is a claim about the state | |
| # this workflow leaves behind, not just about which step it refuses to run. release-manager | |
| # pushes the tag before this workflow can evaluate anything -- that ordering is unavoidable, | |
| # since a tag push is what a workflow trigger reads -- so the only way to make the claim true | |
| # end-to-end is for a failure here to remove what a failure produced. This deletes only the | |
| # exact tag this run resolved, seconds after this same run pushed nobody else's history and | |
| # nothing this run's own SHA-pin did not already record. | |
| cleanup-on-failed-gate: | |
| needs: [resolve, container-matrix] | |
| # 'undecided' is carved out and nothing else is. A gate that has not been answered has not | |
| # decided against this tag, and deleting on it produced a deadlock on 2026-08-27: verify | |
| # cannot go green until the tag is on origin (bin/doctor's "declared release is fetchable" | |
| # reads the README pin), and the tag could not survive long enough for verify to finish. | |
| # Every other refusal -- a decided failure, a bad tag name, a commit that is not an ancestor | |
| # of main, a gate step that never ran -- still deletes, so "a failed required job cannot | |
| # produce a tag" holds exactly as before. | |
| # Deliberately BROADER than the delete rule. This condition only decides whether the job | |
| # gets a runner; .github/scripts/should-delete-candidate-tag.sh decides whether anything is | |
| # deleted, and check 51 asserts this expression carries no part of that rule. An `if:` | |
| # expression cannot be executed by a test, so the one destructive step in this workflow had | |
| # the one decision nobody could exercise -- and it was wrong in production on 2026-08-27. | |
| # Broad here is safe: a job that runs and then declines to delete costs a runner minute. A | |
| # condition narrower than the script would silently skip deletions the rule requires. | |
| if: always() && (needs.resolve.result != 'success' || needs.container-matrix.result != 'success') | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| - name: Has anything actually decided against this tag? | |
| id: decide | |
| run: | | |
| set +e | |
| bash .github/scripts/should-delete-candidate-tag.sh \ | |
| "${{ needs.resolve.result }}" \ | |
| "${{ needs.resolve.outputs.gate }}" \ | |
| "${{ needs.container-matrix.result }}" | |
| rc=$? | |
| set -e | |
| case "$rc" in | |
| 0) echo "delete=yes" >> "$GITHUB_OUTPUT" ;; | |
| 10) echo "delete=no" >> "$GITHUB_OUTPUT" ;; | |
| *) echo "should-delete-candidate-tag.sh exited $rc, which is neither verdict; refusing to guess"; exit 1 ;; | |
| esac | |
| - name: Delete the candidate tag; it did not earn publication | |
| if: steps.decide.outputs.delete == 'yes' | |
| run: | | |
| set -euo pipefail | |
| TAG="${{ github.event.inputs.tag || github.ref_name }}" | |
| if git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then | |
| git push origin ":refs/tags/$TAG" | |
| echo "deleted refs/tags/$TAG -- required checks did not pass for the commit it named" | |
| else | |
| echo "refs/tags/$TAG already gone" | |
| fi | |
| # --- publish: the one step in this workflow that is irreversible --------------------------- | |
| publish: | |
| needs: [resolve, container-matrix] | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| fetch-depth: 0 | |
| - name: The tag still points where resolve found it | |
| run: | | |
| set -euo pipefail | |
| NOW=$(git rev-parse "refs/tags/${{ needs.resolve.outputs.tag }}^{commit}") | |
| if [ "$NOW" != "${{ needs.resolve.outputs.sha }}" ]; then | |
| echo "refusing: refs/tags/${{ needs.resolve.outputs.tag }} moved from ${{ needs.resolve.outputs.sha }} to $NOW between resolve and publish" | |
| exit 1 | |
| fi | |
| echo "ok refs/tags/${{ needs.resolve.outputs.tag }} still names ${{ needs.resolve.outputs.sha }}" | |
| - name: Every required job is still green immediately before publication | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| bash .github/scripts/require-checks-green.sh \ | |
| "${{ github.repository }}" "${{ needs.resolve.outputs.sha }}" ${{ env.REQUIRED_CHECKS }} | |
| - name: Release notes from CHANGELOG.md, not invented here | |
| run: | | |
| set -euo pipefail | |
| VER="${{ needs.resolve.outputs.tag }}" | |
| VER="${VER#v}" | |
| awk -v ver="$VER" ' | |
| /^## / { if (found) exit; if ($0 ~ "^## \\[?" ver "([^0-9.]|$)") { found=1; print; next } } | |
| found { print } | |
| ' CHANGELOG.md > /tmp/release-notes.md | |
| if [ ! -s /tmp/release-notes.md ]; then | |
| echo "no CHANGELOG.md section found for $VER; falling back to --generate-notes" >&2 | |
| echo "__GENERATE__" > /tmp/release-notes.md | |
| fi | |
| cat /tmp/release-notes.md | |
| - name: gh release create | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| set -euo pipefail | |
| TAG="${{ needs.resolve.outputs.tag }}" | |
| if [ "$(cat /tmp/release-notes.md)" = "__GENERATE__" ]; then | |
| gh release create "$TAG" --verify-tag --title "$TAG" --generate-notes | |
| else | |
| gh release create "$TAG" --verify-tag --title "$TAG" --notes-file /tmp/release-notes.md | |
| fi | |
| - name: Read the release back; a created object gh cannot fetch is not published | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: gh release view "${{ needs.resolve.outputs.tag }}" --json url,tagName,isDraft |