release #872
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 | |
| # Cut a release by pushing a tag, e.g. | |
| # git tag v0.1.0 && git push origin v0.1.0 | |
| # | |
| # This builds the `stella` binary for every target in the matrix, packages a | |
| # `stella-<version>-<target>.tar.gz` (binary + LICENSE/NOTICE/LICENSING + | |
| # README), and — on a tag push — publishes a GitHub Release with all tarballs | |
| # and a SHA256SUMS. | |
| # | |
| # This is a hand-rolled matrix workflow: it does NOT require cargo-dist to be | |
| # installed on the runner. A future maintainer can migrate to cargo-dist using | |
| # the `[workspace.metadata.dist]` section already declared in Cargo.toml. | |
| on: | |
| push: | |
| tags: | |
| - "v*" | |
| workflow_dispatch: | |
| concurrency: | |
| group: release-${{ github.ref }} | |
| cancel-in-progress: false | |
| env: | |
| CARGO_TERM_COLOR: always | |
| permissions: | |
| contents: read | |
| jobs: | |
| build: | |
| name: build ${{ matrix.target }} | |
| runs-on: ${{ matrix.os }} | |
| # Release builds use full LTO + codegen-units=1 (see [profile.release] in | |
| # Cargo.toml) and compile bundled C/C++ deps (rusqlite, tree-sitter), so | |
| # give each build room on a cold cache. | |
| timeout-minutes: 90 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - target: aarch64-apple-darwin | |
| os: macos-14 | |
| # Cross-compiled from the Apple Silicon runner: Apple's clang/ld is a | |
| # universal toolchain, so `rustup target add x86_64-apple-darwin` | |
| # (via the `targets:` input below) builds the x86_64 slice — bundled | |
| # C deps included — without needing a (now-retired) Intel runner. | |
| - target: x86_64-apple-darwin | |
| os: macos-14 | |
| - target: x86_64-unknown-linux-gnu | |
| os: ubuntu-latest | |
| # Built natively on GitHub's arm64 runner (free for public repos). | |
| # Cross-compiling this target via `cross` fails: its default docker | |
| # image ships g++ 5, far too old for the bundled C/C++ this workspace | |
| # compiles. (The dep that first hit this, libduckdb-sys, is no longer | |
| # in the graph — `rg '^name = "libduckdb-sys"' Cargo.lock` finds | |
| # nothing — but rusqlite's bundled SQLite and the tree-sitter grammars | |
| # keep the constraint alive.) The native runner uses the same modern | |
| # toolchain that already builds them on x86_64 ubuntu-latest. | |
| - target: aarch64-unknown-linux-gnu | |
| os: ubuntu-24.04-arm | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| - name: Install Rust stable | |
| uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable | |
| with: | |
| targets: ${{ matrix.target }} | |
| - uses: Swatinem/rust-cache@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2 | |
| with: | |
| key: ${{ matrix.target }} | |
| # rust-toolchain.toml pins the repo to a concrete toolchain (e.g. 1.97.0), | |
| # and the build runs on *that*, not `stable`. dtolnay's `targets:` above | |
| # installs the target for `stable`, so a genuine cross-target (x86_64 on | |
| # the arm64 macOS runner) fails with E0463 "can't find crate for `core`". | |
| # Add the target to the pinned toolchain — this runs in the checkout, so | |
| # rustup honors rust-toolchain.toml (auto-installing the pin on first use). | |
| - name: Add the cross target to the pinned toolchain | |
| run: rustup target add ${{ matrix.target }} | |
| # The tagged tree carries its own version since #786 — auto-tag.yml | |
| # stamps the manifests in the release commit the tag points at, so this | |
| # step only VERIFIES the manifest matches the tag instead of rewriting | |
| # it. A mismatch means the tag predates #786 (or auto-tag regressed); | |
| # failing loud beats shipping a binary that reports the wrong version. | |
| - name: Verify the tagged tree carries its own version | |
| if: ${{ github.ref_type == 'tag' }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| version="${GITHUB_REF_NAME#v}" | |
| grep -m1 '^version = ' Cargo.toml | grep -q "\"${version}\"" \ | |
| || { echo "::error::Cargo.toml workspace version does not match tag ${GITHUB_REF_NAME} — the tag was not cut by the current auto-tag.yml (see #786)."; exit 1; } | |
| # Every target builds natively: the two Apple targets share one universal | |
| # Apple toolchain, and aarch64-linux builds on ubuntu-24.04-arm. `cross` | |
| # was tried and abandoned (see the matrix comments above) and is installed | |
| # by no step here. | |
| # | |
| # The build goes through scripts/repro-build.sh rather than calling cargo | |
| # inline, and that indirection is the whole of #910: the script is the one | |
| # place that remaps $CARGO_HOME and the rustup sysroot out of the emitted | |
| # paths (a release binary used to carry 553 absolute builder paths, so its | |
| # SHA-256 described the builder's home directory rather than the source), | |
| # asserts that the rust-toolchain.toml pin is what actually ran, exports | |
| # SOURCE_DATE_EPOCH for the packaging step, and writes the per-target | |
| # binary checksum. `--locked` is preserved from #786: the tagged tree's | |
| # lockfile is synced to its own version, same as install.sh's from-source | |
| # build. scripts/check-repro-wiring.sh fails if this step ever goes back | |
| # to a bare `cargo build`. | |
| - name: Build stella (reproducible) | |
| shell: bash | |
| run: ./scripts/repro-build.sh --locked "${{ matrix.target }}" | |
| - name: Package tarball | |
| id: package | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| if [ "${GITHUB_REF_TYPE}" = "tag" ]; then | |
| version="${GITHUB_REF_NAME#v}" | |
| else | |
| # workflow_dispatch (no tag): fall back to the workspace version. | |
| version="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n1)" | |
| fi | |
| target="${{ matrix.target }}" | |
| stem="stella-${version}-${target}" | |
| bin="target/${target}/release/stella" | |
| mkdir -p "dist/${stem}" | |
| cp "${bin}" "dist/${stem}/stella" | |
| # AGPL §4/§5: the license text ships with every distributed copy. | |
| cp LICENSE NOTICE LICENSING.md README.md "dist/${stem}/" | |
| # Not `tar -czf`: tar records mtimes, uid/gid and traversal order and | |
| # gzip stamps its header with the current time, so two runners that | |
| # produce a byte-identical *binary* still produce different | |
| # *tarballs* — and SHA256SUMS, which is what install.sh and the | |
| # Homebrew formula check, would diverge anyway. The macOS runners | |
| # have bsdtar and the Linux ones GNU tar, whose determinism flags do | |
| # not overlap, hence a script rather than flags. | |
| SOURCE_DATE_EPOCH="$(git log -1 --pretty=%ct)" | |
| export SOURCE_DATE_EPOCH | |
| ./scripts/package-tarball.py "dist/${stem}" "${stem}.tar.gz" | |
| # The bare-binary checksum, written by repro-build.sh in | |
| # `sha256sum` format with the artifact stem as its name column, so | |
| # the four of them concatenate into SHA256SUMS.bin below. This is the | |
| # number an independent rebuilder compares against — the tarball hash | |
| # cannot serve that purpose, since rebuilding gives them a binary and | |
| # not our archive of it. | |
| cp "target/${target}/release/stella.sha256" "${stem}.sha256" | |
| cat "${stem}.sha256" | |
| echo "asset=${stem}.tar.gz" >> "$GITHUB_OUTPUT" | |
| echo "sums=${stem}.sha256" >> "$GITHUB_OUTPUT" | |
| # Uploaded twice, because one unlucky DNS lookup used to throw away a | |
| # finished release. v0.9.273 compiled for seven minutes, packaged its | |
| # tarball, and then died here on `Failed to CreateArtifact: Unable to | |
| # make request: ENOTFOUND` — an Actions endpoint the runner could not | |
| # resolve. Every downstream job needs all four artifacts, so that one | |
| # step took the whole tag with it (#5629). | |
| # | |
| # `continue-on-error` on the first step is what lets a second one run; | |
| # the retry itself is a plain step, so a real upload failure still fails | |
| # the job. No retry action is added: a third-party action would owe | |
| # `action-pins` a pinned SHA and the licence allowlist a review, to buy | |
| # one `if:` condition. | |
| - name: Upload build artifact | |
| id: upload | |
| continue-on-error: true | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 | |
| with: | |
| name: stella-${{ matrix.target }} | |
| path: | | |
| ${{ steps.package.outputs.asset }} | |
| ${{ steps.package.outputs.sums }} | |
| if-no-files-found: error | |
| retention-days: 7 | |
| # `overwrite` covers the narrower failure where the first attempt got far | |
| # enough to reserve the name before it broke; without it the retry would | |
| # fail on a name conflict of its own making. | |
| - name: Upload build artifact (retry) | |
| if: steps.upload.outcome == 'failure' | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 | |
| with: | |
| name: stella-${{ matrix.target }} | |
| path: | | |
| ${{ steps.package.outputs.asset }} | |
| ${{ steps.package.outputs.sums }} | |
| if-no-files-found: error | |
| retention-days: 7 | |
| overwrite: true | |
| # Rebuild one target on a second runner and refuse to publish if the bytes | |
| # differ (#910, acceptance clause). This is the check that keeps | |
| # reproducibility fixed; scripts/check-repro-wiring.sh only keeps the plumbing | |
| # in place. | |
| # | |
| # THE VARIATION IS THE TEST. Two GitHub-hosted runners of the same image share | |
| # $HOME=/home/runner and $CARGO_HOME=/home/runner/.cargo, so the obvious | |
| # reading of "build twice on different runners and diff the SHA" would have | |
| # passed green on the *unfixed* code and proved nothing — a permanently-green | |
| # gate is worse than no gate. So this arm deliberately differs from the build | |
| # job in every host-shaped input that used to leak into the binary: | |
| # | |
| # CARGO_HOME different -> exercises the $CARGO_HOME remap (506 of the | |
| # 553 absolute paths a release binary used to carry) | |
| # RUSTUP_HOME different -> exercises the sysroot remap (the other 47) | |
| # rust-src installed -> the subtle one. rustc emits std's paths as | |
| # the virtual /rustc/<hash>/… but translates them back to the | |
| # real sysroot path when rust-src is present, so the same | |
| # source on the same pinned toolchain produces different | |
| # bytes depending on whether the builder happens to have the | |
| # component. The build job does not install it; this one does. | |
| # checkout path different -> nothing should depend on the workspace root | |
| # TMPDIR different -> nor on where the bundled C deps compile | |
| # no rust-cache -> nothing is restored from the first arm | |
| # | |
| # One linux target, not four: this doubles a full-LTO build (budgeted at 90 | |
| # minutes) and every target shares the same remapping. Runs on the tag push | |
| # that would publish the artifact, where a divergence blocks the release, and | |
| # on workflow_dispatch for checking between releases. Deliberately NOT on | |
| # pull_request — an hour of LTO per PR is not a cost this repo should carry | |
| # for a property that can only change in release.yml or repro-build.sh, both | |
| # of which the toolchain-free wiring guard already watches on every PR. | |
| # | |
| # `needs: build` costs a release one extra build's wall time, because this arm | |
| # cannot start until the first arm's checksum artifact exists. That is the | |
| # deliberate trade: running the two in parallel and comparing inside the | |
| # `release` job would be faster, but then no job owns the verdict and a green | |
| # "reproducible build" check would mean only that a build finished. | |
| verify-reproducible: | |
| name: reproducible build (independent rebuild) | |
| needs: build | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 90 | |
| env: | |
| REPRO_TARGET: x86_64-unknown-linux-gnu | |
| steps: | |
| # Before the toolchain step, not after: rustup installs toolchains into | |
| # RUSTUP_HOME and shims into CARGO_HOME at the moment it runs, so setting | |
| # these later would leave the arm building out of the runner's default | |
| # directories and silently make this job a copy of the first one. | |
| - name: Move cargo, rustup and TMPDIR somewhere the first arm never used | |
| run: | | |
| set -euo pipefail | |
| mkdir -p "${RUNNER_TEMP}/arm-b/tmp" | |
| { | |
| echo "CARGO_HOME=${RUNNER_TEMP}/arm-b/cargo" | |
| echo "RUSTUP_HOME=${RUNNER_TEMP}/arm-b/rustup" | |
| echo "TMPDIR=${RUNNER_TEMP}/arm-b/tmp" | |
| } >> "$GITHUB_ENV" | |
| echo "${RUNNER_TEMP}/arm-b/cargo/bin" >> "$GITHUB_PATH" | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| with: | |
| path: arm-b | |
| - name: Install Rust stable | |
| uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable | |
| with: | |
| targets: x86_64-unknown-linux-gnu | |
| # Inside the checkout, so rustup honors rust-toolchain.toml and installs | |
| # the pin (not `stable`). rust-src is the deliberate difference described | |
| # above; repro-build.sh refuses to run on anything but the pin, which is | |
| # also how #910 point 3 ("confirm the release job uses the pinned | |
| # toolchain") stops being a comment and becomes a check. | |
| - name: Install the pinned toolchain WITH rust-src | |
| working-directory: arm-b | |
| run: | | |
| set -euo pipefail | |
| rustup component add rust-src | |
| rustc -vV | |
| - name: Rebuild | |
| working-directory: arm-b | |
| run: ./scripts/repro-build.sh --locked "${REPRO_TARGET}" | |
| - name: Download the first arm's checksum | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 | |
| with: | |
| name: stella-x86_64-unknown-linux-gnu | |
| path: arm-a | |
| - name: Compare | |
| run: | | |
| set -euo pipefail | |
| a="$(cat arm-a/*.sha256)" | |
| b="$(cat "arm-b/target/${REPRO_TARGET}/release/stella.sha256")" | |
| echo "arm A (build job): ${a}" | |
| echo "arm B (this job): ${b}" | |
| if [ "$a" != "$b" ]; then | |
| echo "::error::release build is NOT reproducible — two runners produced different bytes for ${REPRO_TARGET}." | |
| echo "Arm B differs from arm A only in CARGO_HOME, RUSTUP_HOME, TMPDIR, the checkout path and the presence of rust-src." >&2 | |
| echo "Something the build reads from the host is still reaching the binary. Compare with:" >&2 | |
| echo " strings <binary> | grep -E '^/(home|Users|tmp)/'" >&2 | |
| exit 1 | |
| fi | |
| echo "reproducible: both arms produced ${b%% *}" | |
| # Execute the artifact this release would publish (#1626). Nothing else in | |
| # this pipeline ever did: `verify-reproducible` above proves the build is | |
| # deterministic, which a binary that segfaults on startup satisfies | |
| # perfectly, and `ci.yml` triggers on pull_request and pushes to main — not | |
| # on tags. A release could therefore go green, reproducible, signed, | |
| # attested and out to the Homebrew tap without a single process having run | |
| # it. The first execution of a published stella artifact by anyone was by | |
| # hand, in an issue. | |
| # | |
| # `needs: build` and downloading its *uploaded* artifact rather than reading | |
| # a path in the build tree: a build-tree binary would not prove the tarball | |
| # unpacks, keeps its mode bits, or contains the binary whose checksum ships | |
| # in SHA256SUMS.bin. This tests what a user receives. | |
| # | |
| # Two of the four targets, because those are the two a GitHub runner can | |
| # execute natively — an aarch64-linux or x86_64-darwin artifact would need | |
| # emulation, and a smoke gate that lies about which bytes it ran is worse | |
| # than one that admits a gap. `fail-fast: false` so a macOS-only break is | |
| # reported as macOS-only. | |
| # | |
| # The assertions live in scripts/smoke-artifact.sh, not inline here: that | |
| # puts them under `make shellcheck` and gives them a witness suite | |
| # (scripts/test-smoke-artifact.sh) that drives every failure branch against | |
| # a synthetic broken artifact. An inline `run:` block is the shape that | |
| # cannot be tested until a release is already going out. | |
| smoke: | |
| name: smoke ${{ matrix.target }} | |
| needs: build | |
| runs-on: ${{ matrix.os }} | |
| timeout-minutes: 15 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - target: aarch64-apple-darwin | |
| os: macos-14 | |
| - target: x86_64-unknown-linux-gnu | |
| os: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| - name: Download the artifact this release would publish | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 | |
| with: | |
| name: stella-${{ matrix.target }} | |
| path: published | |
| - name: Unpack it, verify it against its published checksum, and run it | |
| run: ./scripts/smoke-artifact.sh published | |
| release: | |
| name: publish GitHub Release | |
| # `smoke` gates this rather than the `homebrew` job downstream: a release | |
| # that cannot run is not one to publish tarballs for either, and gating | |
| # here covers the tap transitively. | |
| needs: [build, verify-reproducible, smoke] | |
| runs-on: ubuntu-latest | |
| if: startsWith(github.ref, 'refs/tags/') | |
| permissions: | |
| contents: write | |
| # Provenance attestation (#649). `id-token: write` mints the short-lived | |
| # OIDC token Sigstore signs against; `attestations: write` stores the | |
| # resulting bundle on the repo. No signing key is involved, so there is | |
| # no key to rotate or leak — which is what made this landable. | |
| id-token: write | |
| attestations: write | |
| steps: | |
| # Full history + tags so the notes step can diff against the prior tag. | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| with: | |
| fetch-depth: 0 | |
| fetch-tags: true | |
| - name: Download all build artifacts | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 | |
| with: | |
| path: artifacts | |
| pattern: stella-* | |
| merge-multiple: true | |
| # Two sums files, because they answer two different questions. | |
| # | |
| # SHA256SUMS hashes the tarballs. It is what install.sh and the | |
| # Homebrew formula check: "the thing I downloaded is the | |
| # thing that was published". | |
| # SHA256SUMS.bin hashes the bare binaries, one line per target, as | |
| # written by scripts/repro-build.sh. It is what an | |
| # independent rebuilder checks: rebuilding the tag gives | |
| # them a binary, not our archive of it, so without this | |
| # file the reproducibility work is unverifiable from | |
| # outside no matter how reproducible the build became | |
| # (#910 point 5). RELEASING.md documents the recipe. | |
| - name: Generate SHA256SUMS | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| cd artifacts | |
| sha256sum stella-*.tar.gz > SHA256SUMS | |
| # Sorted by the stem in the name column so the file's own bytes do | |
| # not depend on which target's job finished first. | |
| cat stella-*.sha256 | sort -k2 > SHA256SUMS.bin | |
| echo "----- SHA256SUMS -----" | |
| cat SHA256SUMS | |
| echo "----- SHA256SUMS.bin (bare binaries) -----" | |
| cat SHA256SUMS.bin | |
| # Attest the tarballs *and* SHA256SUMS. Attesting the sums file is the | |
| # point: install.sh trusts it to vouch for the tarball, but it was | |
| # fetched over the same channel from the same release, so anything able | |
| # to replace one could replace the other. A provenance bundle is bound to | |
| # this workflow at this commit and cannot be reissued by whoever holds | |
| # the release. | |
| # | |
| # Pinned by SHA because that is the invariant #648 established; adding a | |
| # floating ref here would be the first thing to break its CI guard. | |
| - name: Attest build provenance | |
| uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 | |
| with: | |
| subject-path: | | |
| artifacts/stella-*.tar.gz | |
| artifacts/SHA256SUMS | |
| artifacts/SHA256SUMS.bin | |
| # Draft the release notes from the diff since the previous tag using the | |
| # AI Gateway. Falls back to a plain commit list if the key is unset or the | |
| # call fails, so a release is never blocked on note generation. | |
| - name: Generate release notes | |
| shell: bash | |
| env: | |
| AI_GATEWAY_API_KEY: ${{ secrets.AI_GATEWAY_API_KEY }} | |
| RELEASE_NOTES_MODEL: ${{ vars.RELEASE_NOTES_MODEL || 'anthropic/claude-sonnet-5' }} | |
| run: | | |
| set -euo pipefail | |
| tag="${GITHUB_REF_NAME}" | |
| version="${tag#v}" | |
| prev="$(git tag -l 'v*' --sort=-v:refname | grep -vx "${tag}" | head -n1 || true)" | |
| echo "notes range: ${prev:-<start>}..${tag}" | |
| commits="$(git log --no-merges --pretty='- %s' ${prev:+${prev}..HEAD} | head -n 300)" | |
| { | |
| echo "## Commits"; echo "$commits"; | |
| echo; echo "## Files changed"; | |
| git diff --stat ${prev:+${prev}..HEAD} | tail -n 80; | |
| } > ctx.txt | |
| git diff ${prev:+${prev}..HEAD} 2>/dev/null | head -c 120000 > diff.txt || true | |
| notes="" | |
| if [ -n "${AI_GATEWAY_API_KEY:-}" ]; then | |
| prompt="$(printf 'Write the GitHub release notes for the "stella" coding-agent CLI %s, in clean Markdown. Start with a one-line summary, then grouped bullet sections (Features, Fixes, Performance, Docs, Internal) — omit any empty section. Be concrete and user-facing; no preamble, no headings above the summary. Base it strictly on the commits and diff below.\n\n%s\n\n## Diff (truncated)\n```diff\n%s\n```\n' "$version" "$(cat ctx.txt)" "$(cat diff.txt)")" | |
| payload="$(jq -n --arg m "$RELEASE_NOTES_MODEL" --arg c "$prompt" '{model:$m,messages:[{role:"user",content:$c}],temperature:0.2}')" | |
| resp="$(curl -sS --max-time 150 \ | |
| -H "Authorization: Bearer ${AI_GATEWAY_API_KEY}" \ | |
| -H "Content-Type: application/json" \ | |
| -d "$payload" \ | |
| https://ai-gateway.vercel.sh/v1/chat/completions || true)" | |
| notes="$(printf '%s' "$resp" | jq -r '.choices[0].message.content // empty' 2>/dev/null || true)" | |
| [ -z "$notes" ] && echo "::warning::AI note generation returned nothing; using the commit list." | |
| else | |
| echo "::warning::AI_GATEWAY_API_KEY not available; using the commit list." | |
| fi | |
| [ -z "$notes" ] && notes="$(printf '## What changed\n\n%s' "$commits")" | |
| { | |
| printf '%s\n\n' "$notes" | |
| [ -n "$prev" ] && printf '**Full changelog**: https://github.com/%s/compare/%s...%s\n' "$GITHUB_REPOSITORY" "$prev" "$tag" | |
| } > notes.md | |
| echo "----- notes.md -----"; cat notes.md | |
| # A draft release is not attached to its tag, so `GET /releases/tags/{tag}` | |
| # answers 404 for one and the publish step below creates a second release | |
| # instead of finishing the first. That is how v0.9.254 and v0.9.264 ended | |
| # up as drafts nothing would ever touch again: the step created the | |
| # release, uploaded its assets, then hit a GitHub 5xx before publishing, | |
| # and a re-run would have left the half-built draft sitting beside a new | |
| # one (#5629). Deleting it first is what makes a re-run finish the job. | |
| # | |
| # Only a draft is ever deleted. A published release for this tag is a | |
| # release users can already download, and this step must never touch it. | |
| - name: Delete a stale draft release for this tag | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| tag="${GITHUB_REF_NAME}" | |
| # `--slurp` cannot be combined with `--jq` (gh rejects that), so the | |
| # filter runs in a plain jq after the fetch. | |
| ids="$(CLICOLOR_FORCE=0 NO_COLOR=1 gh api --paginate --slurp \ | |
| 'repos/{owner}/{repo}/releases?per_page=100' \ | |
| | jq -r --arg tag "$tag" '.[][] | select(.draft == true and .tag_name == $tag) | .id')" | |
| if [ -z "$ids" ]; then | |
| echo "no draft release for ${tag}; nothing to clear." | |
| exit 0 | |
| fi | |
| for id in $ids; do | |
| echo "::notice::deleting stale draft release ${id} for ${tag} so this run can publish." | |
| gh api -X DELETE "repos/{owner}/{repo}/releases/${id}" | |
| done | |
| - name: Create / update GitHub Release | |
| uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3 | |
| with: | |
| files: | | |
| artifacts/stella-*.tar.gz | |
| artifacts/SHA256SUMS | |
| artifacts/SHA256SUMS.bin | |
| fail_on_unmatched_files: true | |
| body_path: notes.md | |
| # Renders .github/homebrew/stella.rb.tmpl with the real version + per-target | |
| # SHA-256 sums and commits it as Formula/stella.rb to the tap repo | |
| # (macanderson/homebrew-tap). Runs only after the GitHub Release exists, so | |
| # the formula's release-asset URLs resolve. Auth: HOMEBREW_TAP_DEPLOY_KEY | |
| # (an SSH deploy key with write access to the tap repo — scoped to exactly | |
| # that one repo, unlike a PAT), with HOMEBREW_TAP_TOKEN (https) honored as a | |
| # fallback. Skips gracefully when neither is set so a release is never | |
| # blocked on the tap being wired. | |
| homebrew: | |
| name: publish Homebrew formula | |
| needs: release | |
| runs-on: ubuntu-latest | |
| if: startsWith(github.ref, 'refs/tags/') | |
| steps: | |
| - name: Checkout (for the formula template) | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| - name: Download all build artifacts | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 | |
| with: | |
| path: artifacts | |
| pattern: stella-* | |
| merge-multiple: true | |
| - name: Render formula and push to tap | |
| env: | |
| HOMEBREW_TAP_DEPLOY_KEY: ${{ secrets.HOMEBREW_TAP_DEPLOY_KEY }} | |
| HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| if [ -z "${HOMEBREW_TAP_DEPLOY_KEY:-}" ] && [ -z "${HOMEBREW_TAP_TOKEN:-}" ]; then | |
| echo "::warning::neither HOMEBREW_TAP_DEPLOY_KEY nor HOMEBREW_TAP_TOKEN is set — skipping Homebrew tap publish." | |
| echo "One-time setup (create the tap repo + add the credential) is documented in RELEASING.md." | |
| exit 0 | |
| fi | |
| version="${GITHUB_REF_NAME#v}" | |
| if [ -n "${HOMEBREW_TAP_DEPLOY_KEY:-}" ]; then | |
| # SSH with the tap-scoped deploy key. The key file lives outside | |
| # the workspace and is chmod 600, as ssh requires. | |
| keyfile="${RUNNER_TEMP}/tap_deploy_key" | |
| printf '%s\n' "${HOMEBREW_TAP_DEPLOY_KEY}" > "${keyfile}" | |
| chmod 600 "${keyfile}" | |
| export GIT_SSH_COMMAND="ssh -i ${keyfile} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new" | |
| git clone --depth 1 "git@github.com:macanderson/homebrew-tap.git" tap | |
| else | |
| git clone --depth 1 \ | |
| "https://x-access-token:${HOMEBREW_TAP_TOKEN}@github.com/macanderson/homebrew-tap.git" tap | |
| fi | |
| # The tap is cloned before anything is rendered so this job can read | |
| # the version it currently serves and refuse to go backwards. | |
| # | |
| # Re-running release.yml on an old tag checks out the workflow AS OF | |
| # THAT TAG and renders the formula for that version unconditionally, | |
| # so retrying a months-old failed release would hand every `brew | |
| # upgrade` an older stella than the one it has (#5629). The formula | |
| # is the one artifact here that is a pointer rather than an addition: | |
| # a stale GitHub Release is harmless beside the newer ones, and a | |
| # stale Formula/stella.rb is what everybody installs. | |
| tap_version="" | |
| if [ -f tap/Formula/stella.rb ]; then | |
| tap_version="$(sed -n 's/^[[:space:]]*version "\([^"]*\)".*/\1/p' tap/Formula/stella.rb | head -n1)" | |
| fi | |
| if [ -n "${tap_version}" ] && [ "${tap_version}" != "${version}" ]; then | |
| older="$(printf '%s\n%s\n' "${version}" "${tap_version}" | sort -V | head -n1)" | |
| if [ "${older}" = "${version}" ]; then | |
| echo "::notice::the tap serves ${tap_version}; not rendering the older ${version} over it." | |
| exit 0 | |
| fi | |
| fi | |
| # SHA-256 of a given target's release tarball (same bytes uploaded to | |
| # the GitHub Release in the `release` job). | |
| sha_for() { | |
| local target="$1" | |
| local f="artifacts/stella-${version}-${target}.tar.gz" | |
| if [ ! -f "$f" ]; then | |
| echo "::error::expected release asset not found: ${f}" >&2 | |
| exit 1 | |
| fi | |
| sha256sum "$f" | awk '{ print $1 }' | |
| } | |
| sha_aarch64_darwin="$(sha_for aarch64-apple-darwin)" | |
| sha_x86_64_darwin="$(sha_for x86_64-apple-darwin)" | |
| sha_aarch64_linux="$(sha_for aarch64-unknown-linux-gnu)" | |
| sha_x86_64_linux="$(sha_for x86_64-unknown-linux-gnu)" | |
| mkdir -p rendered | |
| sed \ | |
| -e "s/@VERSION@/${version}/g" \ | |
| -e "s/@SHA_AARCH64_DARWIN@/${sha_aarch64_darwin}/g" \ | |
| -e "s/@SHA_X86_64_DARWIN@/${sha_x86_64_darwin}/g" \ | |
| -e "s/@SHA_AARCH64_LINUX@/${sha_aarch64_linux}/g" \ | |
| -e "s/@SHA_X86_64_LINUX@/${sha_x86_64_linux}/g" \ | |
| .github/homebrew/stella.rb.tmpl > rendered/stella.rb | |
| echo "----- rendered Formula/stella.rb -----" | |
| cat rendered/stella.rb | |
| mkdir -p tap/Formula | |
| cp rendered/stella.rb tap/Formula/stella.rb | |
| cd tap | |
| git config user.name "github-actions[bot]" | |
| git config user.email "41898282+github-actions[bot]@users.noreply.github.com" | |
| git add Formula/stella.rb | |
| if git diff --cached --quiet; then | |
| echo "Formula already up to date for ${version}; nothing to commit." | |
| exit 0 | |
| fi | |
| git commit -m "stella ${version}" | |
| git push origin HEAD |