From 84b0d9d5b1b8eb23780b5689a697e712fc9373fd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 9 Sep 2026 23:29:26 +0000 Subject: [PATCH 01/10] ci(release-ctx): build ctx from a build ref, attach to the release tag A workflow_dispatch used to check out inputs.tag, so dispatching against a tag that predates bins/ctx failed with 'package ID specification ctx did not match any packages' and the release kept zero assets. Separate the two: a new resolve job picks the release tag (pushed tag, or the existing 'tag' input) and the build commit (tagged commit on push, 'build_ref' input on dispatch, default main). It refuses a dispatch tag that does not exist or is not v*.*.*, refuses a build ref without bins/ctx before any runner starts cargo, and every platform builds that one resolved SHA. Runs for the same tag queue instead of racing on the upload, only the release job holds contents: write, and the appended release notes add the install block once per release plus one provenance line per build. Co-authored-by: Mathis --- .github/workflows/release-ctx.yml | 172 ++++++++++++++++++++++++++---- 1 file changed, 153 insertions(+), 19 deletions(-) diff --git a/.github/workflows/release-ctx.yml b/.github/workflows/release-ctx.yml index 2edbfeb6c..a7ad064c8 100644 --- a/.github/workflows/release-ctx.yml +++ b/.github/workflows/release-ctx.yml @@ -1,9 +1,36 @@ name: release-ctx -# Builds the miner CLI for every platform miners actually run and attaches the -# archives (plus SHA256SUMS.txt) to the GitHub Release for the tag. +# Builds the miner CLI (`ctx`) for every platform miners actually run and +# attaches the archives (plus SHA256SUMS.txt) to a GitHub Release. # scripts/install-ctx.sh downloads from that release and verifies the checksum, # so a release without the sums file installs nothing. +# +# Build ref vs release tag — two different things: +# +# * The **build ref** is the commit `ctx` is compiled from. On a tag push it +# is the tagged commit. On workflow_dispatch it is `build_ref`, default +# `main` (the tip of the working branch at run time). +# * The **release tag** is the GitHub Release the archives are attached to. +# On a tag push it is the pushed tag. On workflow_dispatch it is `tag`, +# which must already exist: the operator cuts the tag / Release first, and +# a typo can never mint a new release. +# +# So the release tag is a publishing label — `v*.*.*` numbering follows the +# network's prod tags (deploy/README.md § Prod release flow) — while the `ctx` +# sources come from the tip of `main`. A dispatch never checks the tag out: +# an old tag predates `bins/ctx`, which is how `package ID specification ctx +# did not match any packages` happened. The resolve job refuses a build ref +# without `bins/ctx` before any runner starts cargo, every archive of one run +# is built from the same resolved commit, and the appended release notes +# record that commit. +# +# Operator recipe (full text: deploy/README.md § ctx CLI release): +# gh release create vX.Y.Z --target main --title "ctx CLI vX.Y.Z" --notes "…" +# → the tag push runs this workflow from the tagged commit +# gh workflow run release-ctx.yml -f tag=vX.Y.Z +# → rebuild from the tip of main and (re)attach to that existing tag +# gh workflow run release-ctx.yml -f tag=vX.Y.Z -f build_ref= +# Any v*.*.* tag push also triggers deploy-prod.yml (fail-closed preflight). on: push: @@ -11,19 +38,96 @@ on: workflow_dispatch: inputs: tag: - description: "Existing tag to build and attach assets to" + description: "Existing release tag to attach the ctx assets to (publishing label, e.g. v3.3.30)" required: true type: string + build_ref: + description: "Branch, tag, or SHA to build ctx from (default: main)" + required: false + type: string + default: main +# Read-only by default; only the job that touches the Release gets write. permissions: - contents: write + contents: read + +# A tag push and a dispatch for the same tag (or a re-run) would race on the +# asset upload. Queue them per tag and never cancel a run about to publish. +concurrency: + group: release-ctx-${{ inputs.tag || github.ref_name }} + cancel-in-progress: false env: CARGO_TERM_COLOR: always jobs: + resolve: + name: resolve build ref + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + tag: ${{ steps.pick.outputs.tag }} + build_ref: ${{ steps.pick.outputs.build_ref }} + sha: ${{ steps.pick.outputs.sha }} + steps: + # Push: the pushed tag ref (checkout peels annotated tags to the commit). + # Dispatch: the build ref, never the release tag. + - name: Checkout build ref + uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && (inputs.build_ref || 'main') || github.ref }} + + - name: Pick release tag and build commit + id: pick + env: + EVENT_NAME: ${{ github.event_name }} + INPUT_TAG: ${{ inputs.tag }} + INPUT_BUILD_REF: ${{ inputs.build_ref }} + PUSHED_TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + tag="$INPUT_TAG" + build_ref="${INPUT_BUILD_REF:-main}" + case "$tag" in + v*.*.*) ;; + *) + echo "::error::tag '$tag' does not match v*.*.* (the same shape the push trigger accepts)" + exit 1 + ;; + esac + if ! git ls-remote --exit-code --tags origin "refs/tags/$tag" >/dev/null; then + echo "::error::tag '$tag' does not exist on origin. Create the tag / Release first (gh release create $tag --target main …), then dispatch again." + exit 1 + fi + else + tag="$PUSHED_TAG" + build_ref="refs/tags/$tag" + fi + + sha="$(git rev-parse HEAD)" + if [ ! -f bins/ctx/Cargo.toml ]; then + echo "::error::build ref '$build_ref' ($sha) has no bins/ctx — it predates the ctx CLI. Dispatch with build_ref=main (the default) so the release tag stays a label and the sources come from tip." + exit 1 + fi + + { + echo "tag=$tag" + echo "build_ref=$build_ref" + echo "sha=$sha" + } >> "$GITHUB_OUTPUT" + { + echo "## release-ctx" + echo + echo "| Release tag | Build ref | Commit |" + echo "|-------------|-----------|--------|" + echo "| \`$tag\` | \`$build_ref\` | \`$sha\` |" + } >> "$GITHUB_STEP_SUMMARY" + echo "release tag $tag <- build ref $build_ref @ $sha" + build: name: ctx ${{ matrix.name }} + needs: [resolve] runs-on: ${{ matrix.runner }} timeout-minutes: 45 strategy: @@ -48,13 +152,15 @@ jobs: runner: windows-latest target: x86_64-pc-windows-msvc steps: + # Every platform builds the one commit resolve picked, so the archives in + # SHA256SUMS.txt cannot straddle a push to main that lands mid-run. - name: Checkout uses: actions/checkout@v4 with: - ref: ${{ inputs.tag || github.ref }} + ref: ${{ needs.resolve.outputs.sha }} - name: Install Rust toolchain - # Pin by commit SHA. Never @master under contents:write. + # Pin by commit SHA. Never @master in a workflow that publishes. uses: dtolnay/rust-toolchain@d1031067263f94b142dd6c0ce24c5eb9d02d52a0 with: toolchain: "1.96.0" @@ -102,9 +208,11 @@ jobs: release: name: attach assets - needs: [build] + needs: [resolve, build] runs-on: ubuntu-latest timeout-minutes: 15 + permissions: + contents: write steps: - name: Download build artifacts uses: actions/download-artifact@v4 @@ -123,21 +231,47 @@ jobs: sha256sum ctx-* > SHA256SUMS.txt cat SHA256SUMS.txt + # Hand-written notes stay. The install block is added once per release, + # however many times ctx is rebuilt for that tag; every build appends one + # provenance line naming the commit it came from. + - name: Release notes + id: notes + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + TAG: ${{ needs.resolve.outputs.tag }} + BUILD_REF: ${{ needs.resolve.outputs.build_ref }} + SHA: ${{ needs.resolve.outputs.sha }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + # Empty when the tag has no Release yet (the next step creates it). + existing="$(gh release view "$TAG" --json body -q .body 2>/dev/null || true)" + { + echo "body<> "$GITHUB_OUTPUT" + - name: Attach to release uses: softprops/action-gh-release@v2 with: - tag_name: ${{ inputs.tag || github.ref_name }} + tag_name: ${{ needs.resolve.outputs.tag }} files: dist/* fail_on_unmatched_files: true - # Keep hand-written release notes; only add the install block. append_body: true - body: | - Cortex subnet CLI (`ctx`) for ${{ inputs.tag || github.ref_name }}. - - ```bash - curl -fsSL https://raw.githubusercontent.com/CortexLM/cortex/main/scripts/install-ctx.sh | sh - ctx challenges - ctx status - ``` - - Default gateway: https://gateway.cortex.foundation + body: ${{ steps.notes.outputs.body }} From 535c095d3b54b9e260a70b9d91dbe5e284f331cf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 9 Sep 2026 23:29:36 +0000 Subject: [PATCH 02/10] fix(install-ctx): name the release that has no ctx assets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fetch SHA256SUMS.txt before the archive and, when it is absent, stop with a message that names the release (the tag 'latest' resolved to), explains that ctx assets come from the release-ctx workflow, and shows the CTX_VERSION pin — instead of a bare 404 on the tarball. A tag with no release, a release with no build for this platform, a listed-but-missing archive, and a network error each get their own message; nothing is ever installed unverified. Co-authored-by: Mathis --- scripts/install-ctx.sh | 91 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 80 insertions(+), 11 deletions(-) diff --git a/scripts/install-ctx.sh b/scripts/install-ctx.sh index fab49f065..c381a288e 100755 --- a/scripts/install-ctx.sh +++ b/scripts/install-ctx.sh @@ -4,9 +4,18 @@ # curl -fsSL https://raw.githubusercontent.com/CortexLM/cortex/main/scripts/install-ctx.sh | sh # # Knobs (all optional): -# CTX_VERSION release tag to install, e.g. v0.2.0 (default: latest) +# CTX_VERSION release tag to install, e.g. vX.Y.Z (default: latest). +# Pin it to install a specific release, or when `latest` +# was published without ctx assets: +# curl -fsSL .../install-ctx.sh | CTX_VERSION=vX.Y.Z sh # CTX_INSTALL_DIR install directory (default: $HOME/.local/bin) # +# The ctx archives (ctx--.tar.gz) and SHA256SUMS.txt are attached +# to a release by the release-ctx GitHub Actions workflow +# (.github/workflows/release-ctx.yml). A release cut without that workflow has +# neither file; this script then stops and names the release instead of +# installing anything. Releases: https://github.com/CortexLM/cortex/releases +# # The download is checksum-verified against the release's SHA256SUMS.txt. A # missing or mismatched checksum aborts the install rather than running an # unverified binary. @@ -17,6 +26,8 @@ REPO="CortexLM/cortex" GATEWAY="https://gateway.cortex.foundation" VERSION="${CTX_VERSION:-latest}" INSTALL_DIR="${CTX_INSTALL_DIR:-$HOME/.local/bin}" +RELEASES="https://github.com/$REPO/releases" +SCRIPT_URL="https://raw.githubusercontent.com/$REPO/main/scripts/install-ctx.sh" die() { echo "install-ctx: $*" >&2 @@ -34,7 +45,7 @@ need uname case "$(uname -s)" in Linux) os=linux ;; Darwin) os=darwin ;; - *) die "unsupported OS $(uname -s). Windows users: download ctx-windows-amd64.zip from https://github.com/$REPO/releases" ;; + *) die "unsupported OS $(uname -s). Windows users: download ctx-windows-amd64.zip from $RELEASES" ;; esac case "$(uname -m)" in @@ -45,9 +56,9 @@ esac asset="ctx-${os}-${arch}.tar.gz" if [ "$VERSION" = "latest" ]; then - base="https://github.com/$REPO/releases/latest/download" + base="$RELEASES/latest/download" else - base="https://github.com/$REPO/releases/download/$VERSION" + base="$RELEASES/download/$VERSION" fi sha256_of() { @@ -60,17 +71,75 @@ sha256_of() { fi } +# Download $1 to $2. Returns 0 when fetched and 44 when the server answered +# 404 (that file is not on the release); any other failure aborts the install +# so a network error is never mistaken for a missing asset. +fetch() { + code="$(curl -sSL -o "$2" -w '%{http_code}' "$1")" || die "download failed: $1" + case "$code" in + 2??) return 0 ;; + 404) + rm -f "$2" + return 44 + ;; + *) die "download failed (HTTP $code): $1" ;; + esac +} + +# The tag `latest` resolves to right now, so an error can name the release +# that is missing ctx assets. Best effort: a failure here only degrades the +# message, never the checksum verification. +release_label() { + if [ "$VERSION" != "latest" ]; then + echo "$VERSION" + return 0 + fi + url="$(curl -fsSLI -o /dev/null -w '%{url_effective}' "$RELEASES/latest" 2>/dev/null || true)" + case "$url" in + */releases/tag/*) echo "${url##*/} (latest)" ;; + *) echo "latest" ;; + esac +} + +# Whether $RELEASES/tag/ exists at all, to tell "no such release" from +# "release exists but was cut without ctx assets". +release_exists() { + case "$VERSION" in + latest) return 0 ;; + esac + curl -fsSLI -o /dev/null "$RELEASES/tag/$VERSION" >/dev/null 2>&1 +} + tmp="$(mktemp -d)" trap 'rm -rf "$tmp"' EXIT INT TERM -echo "install-ctx: downloading $asset ($VERSION)" -curl -fsSL "$base/$asset" -o "$tmp/$asset" \ - || die "download failed: $base/$asset" -curl -fsSL "$base/SHA256SUMS.txt" -o "$tmp/SHA256SUMS.txt" \ - || die "no SHA256SUMS.txt in that release; refusing to install unverified" +label="$(release_label)" +echo "install-ctx: release $label" + +# Sums first. A release published without the release-ctx workflow has no ctx +# assets at all, and that deserves a clearer message than a 404 on the tarball. +if ! fetch "$base/SHA256SUMS.txt" "$tmp/SHA256SUMS.txt"; then + if ! release_exists; then + die "no release named $VERSION under $RELEASES (set CTX_VERSION to an existing tag, or unset it for latest)" + fi + cat >&2 < release-ctx -> Run workflow -> tag=), or pin a +install-ctx: release that lists ctx-*.tar.gz and SHA256SUMS.txt under $RELEASES: +install-ctx: curl -fsSL $SCRIPT_URL | CTX_VERSION=vX.Y.Z sh +EOF + exit 1 +fi want="$(grep " \{1,2\}\*\{0,1\}${asset}\$" "$tmp/SHA256SUMS.txt" | cut -d' ' -f1 | head -n1)" -[ -n "$want" ] || die "$asset is not listed in SHA256SUMS.txt" +[ -n "$want" ] || die "release $label has no ${os}-${arch} build ($asset is not listed in SHA256SUMS.txt)" + +echo "install-ctx: downloading $asset" +fetch "$base/$asset" "$tmp/$asset" \ + || die "$asset is listed in SHA256SUMS.txt but missing from release $label (incomplete upload?)" got="$(sha256_of "$tmp/$asset")" [ "$want" = "$got" ] || die "checksum mismatch for $asset (expected $want, got $got)" @@ -81,7 +150,7 @@ mkdir -p "$INSTALL_DIR" cp "$tmp/ctx" "$INSTALL_DIR/ctx" chmod 755 "$INSTALL_DIR/ctx" -echo "install-ctx: installed $("$INSTALL_DIR/ctx" --version) to $INSTALL_DIR/ctx" +echo "install-ctx: installed $("$INSTALL_DIR/ctx" --version) from release $label to $INSTALL_DIR/ctx" case ":$PATH:" in *":$INSTALL_DIR:"*) ;; *) From c4abab1eaf92bb8154289a252abb23ca561abf24 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 9 Sep 2026 23:29:37 +0000 Subject: [PATCH 03/10] docs(release): ctx release recipe and CTX_VERSION pin Operator recipe in deploy/README.md (tag = publishing label, build ref = sources, the deploy-prod coupling of v*.*.* tags, releases/latest ordering), a lane row in deploy/AGENTS.md, the CTX_VERSION pin in the miner index, and troubleshoot rows for the installer's new messages. Co-authored-by: Mathis --- deploy/AGENTS.md | 1 + deploy/README.md | 40 +++++++++++++++++++++++++++++ docs/external-miner/README.md | 10 ++++++++ docs/external-miner/troubleshoot.md | 4 ++- 4 files changed, 54 insertions(+), 1 deletion(-) diff --git a/deploy/AGENTS.md b/deploy/AGENTS.md index b7601f1eb..ed70b4150 100644 --- a/deploy/AGENTS.md +++ b/deploy/AGENTS.md @@ -219,6 +219,7 @@ Tunnel writes gitignored `deploy/env/local-tunnel.env` (`BASE_GATEWAY_PUBLIC_URL | Staging | CI green on `main` (`deploy-staging.yml`) | `--build-from source` on droplet OK for iteration | | Images | Push to `main` (`images.yml`) | Build/push GHCR digests; promote + **commit** `deploy/pins/staging.json` + `deploy/digests/.json` | | Prod | Tag `v*.*.*` (`deploy-prod.yml`) | **`--build-from registry` only** — promote staging→prod pins, pull GHCR digests; no Rust source build on prod hosts | +| ctx CLI | Tag `v*.*.*`, or `workflow_dispatch tag=vX.Y.Z [build_ref=main]` (`release-ctx.yml`) | Builds `bins/ctx` from the tagged commit (push) or from `build_ref` (dispatch; default `main` tip — never the tag) and attaches `ctx-*` archives + `SHA256SUMS.txt` to that Release. Tag = publishing label, build ref = sources; `scripts/install-ctx.sh` refuses a release without the sums. Recipe: [`README.md`](README.md) § `ctx` CLI release | Ladder: CI → GHCR digests → `deploy/pins/staging.json` (committed by `images.yml`) → tag → preflight (CI + staging pins match tag SHA) → `promote.sh` → `remote-deploy.sh --build-from registry`. Details: [`README.md`](README.md) § Auto CI deploy and § Promotion pipeline. diff --git a/deploy/README.md b/deploy/README.md index 9a07c0170..252730429 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -126,6 +126,46 @@ combination. Verify locally: `./deploy/scripts/assert-compose-matrix.sh`. 7. Both prod hosts: `remote-deploy.sh --build-from registry` (pull GHCR `@sha256`, retag to Compose tags, `up --no-build`). 8. Smoke `/healthz`. `environment: production` (enable required reviewers in GitHub UI). +**`ctx` CLI release (miner installer):** `.github/workflows/release-ctx.yml` +builds `bins/ctx` for linux / darwin (amd64 + arm64) and windows and attaches +`ctx-*.tar.gz` / `.zip` plus `SHA256SUMS.txt` to the GitHub Release. +`scripts/install-ctx.sh` (the `curl … | sh` in the miner docs) installs from +`releases/latest` — or `CTX_VERSION=vX.Y.Z` — and refuses a release without +those files, so a release published without this workflow installs nothing. +The **release tag is a publishing label**; the **build ref is where the +sources come from**: + +| Trigger | Attaches to | Builds from | +|---------|-------------|-------------| +| push tag `v*.*.*` | that tag | the tagged commit | +| `workflow_dispatch` | `tag` (must already exist) | `build_ref` (default `main` = tip at run time) | + +```bash +# 1. Cut the label on tip (the tag push runs release-ctx from the tagged commit). +gh release create vX.Y.Z --target main --title "ctx CLI vX.Y.Z" --notes "…" +# 2. (Re)build ctx from the tip of main and attach it to that existing tag — +# also the fix for a release that has no ctx assets. +gh workflow run release-ctx.yml -f tag=vX.Y.Z +gh workflow run release-ctx.yml -f tag=vX.Y.Z -f build_ref= # pin a build +# 3. Confirm the six assets, then install the way a miner does. +gh run watch && gh release view vX.Y.Z --json assets -q '.assets[].name' +curl -fsSL https://raw.githubusercontent.com/CortexLM/cortex/main/scripts/install-ctx.sh | sh +``` + +A dispatch never checks the tag out: an old tag predates `bins/ctx` and used +to fail with `package ID specification ctx did not match any packages`. The +`resolve` job refuses a build ref without `bins/ctx`, refuses a `tag` that +does not exist (a typo cannot mint a release), builds all five archives from +one resolved commit, and appends that commit to the release notes. Runs for +the same tag queue (`concurrency`) instead of racing on the upload. Any +`v*.*.*` tag push also triggers `deploy-prod.yml`, whose preflight fails +closed unless CI is green for that SHA and `deploy/pins/staging.json` carries +it — a ctx-only label therefore shows a failed `deploy-prod` preflight and +deploys nothing. `releases/latest` is GitHub's newest non-draft, +non-prerelease release, ordered by the tagged commit's date rather than the +publish date — cut labels on tip, and mark experimental labels pre-release to +keep them off the default install path. + Required GitHub secrets: | Secret | Purpose | diff --git a/docs/external-miner/README.md b/docs/external-miner/README.md index ed46edcdf..c2057b71e 100644 --- a/docs/external-miner/README.md +++ b/docs/external-miner/README.md @@ -32,6 +32,16 @@ ctx challenges ctx status ``` +The installer takes the newest [release](https://github.com/CortexLM/cortex/releases) +and verifies `ctx--.tar.gz` against that release's `SHA256SUMS.txt`. +It refuses anything unverified: a release published without those files +stops with a message naming it and installs nothing. To install a specific +release instead, pin the tag with `CTX_VERSION`: + +```bash +curl -fsSL https://raw.githubusercontent.com/CortexLM/cortex/main/scripts/install-ctx.sh | CTX_VERSION=vX.Y.Z sh +``` + Default gateway is [https://gateway.cortex.foundation](https://gateway.cortex.foundation). `--gateway` overrides it for a local stack. `LIUM_API_KEY` is forwarded as `X-Lium-Api-Key` and never printed. diff --git a/docs/external-miner/troubleshoot.md b/docs/external-miner/troubleshoot.md index 425c4cd10..0fefb9b1e 100644 --- a/docs/external-miner/troubleshoot.md +++ b/docs/external-miner/troubleshoot.md @@ -10,7 +10,9 @@ Install `ctx` from [README](./README.md). Proof miners pay Lium | Symptom | Likely cause | What to check | |---------|--------------|---------------| -| `install-ctx` aborts on checksum | Missing or mismatched `SHA256SUMS.txt` | The installer refuses an unverified binary. Wait for a `v*.*.*` release, or build `ctx` from this repo | +| `install-ctx` stops with `release vX.Y.Z has no ctx assets` | That release was published without the `release-ctx` workflow, so it carries no `ctx-*.tar.gz` / `SHA256SUMS.txt` | Nothing was installed. Pin a [release](https://github.com/CortexLM/cortex/releases) that lists them (`CTX_VERSION=vX.Y.Z`), or wait for the operator to attach `ctx` assets to that tag | +| `install-ctx` says `no release named vX.Y.Z` | `CTX_VERSION` names a tag that has no release | Check the tag on the releases page, or unset `CTX_VERSION` for the newest release | +| `install-ctx` aborts on checksum | Mismatched `SHA256SUMS.txt`, or `ctx-*.tar.gz` listed but absent | The installer refuses an unverified binary. Re-run later (an upload may be in progress), pin another release, or build `ctx` from this repo (`cargo build -p ctx --release --locked`) | | `request to … failed` | Gateway not reachable | `ctx status --gateway https://gateway.cortex.foundation`. A local stack needs `--gateway http://127.0.0.1:8080` | | `can_score: NO` / HTTP 503 | The host cannot score right now | Nothing was stored and nothing was rented. Read the error; do not retry-spend | From 4391ef0c71a465f580b4482fc1be2d9e4db5aebc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 9 Sep 2026 23:35:03 +0000 Subject: [PATCH 04/10] ci(release-ctx): release only commits on main, drop the cargo cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeQL flagged cache poisoning: the build job checks out a SHA derived from the build_ref dispatch input and rust-cache then saved a cache that every later run on main would restore. Release builds are now cold (a release should be reproducible anyway), and the resolve job refuses a build ref that is not an ancestor of origin/main — miners install what this publishes, so an unmerged branch or a fork is never released. Co-authored-by: Mathis --- .github/workflows/release-ctx.yml | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release-ctx.yml b/.github/workflows/release-ctx.yml index a7ad064c8..99c5de82d 100644 --- a/.github/workflows/release-ctx.yml +++ b/.github/workflows/release-ctx.yml @@ -20,9 +20,10 @@ name: release-ctx # sources come from the tip of `main`. A dispatch never checks the tag out: # an old tag predates `bins/ctx`, which is how `package ID specification ctx # did not match any packages` happened. The resolve job refuses a build ref -# without `bins/ctx` before any runner starts cargo, every archive of one run -# is built from the same resolved commit, and the appended release notes -# record that commit. +# that is not a commit on `main` (miners install what this publishes; an +# unmerged branch or a fork is never released) or has no `bins/ctx`, before +# any runner starts cargo; every archive of one run is built from the same +# resolved commit, and the appended release notes record that commit. # # Operator recipe (full text: deploy/README.md § ctx CLI release): # gh release create vX.Y.Z --target main --title "ctx CLI vX.Y.Z" --notes "…" @@ -71,11 +72,13 @@ jobs: sha: ${{ steps.pick.outputs.sha }} steps: # Push: the pushed tag ref (checkout peels annotated tags to the commit). - # Dispatch: the build ref, never the release tag. + # Dispatch: the build ref, never the release tag. Full history so the + # commit can be checked against main below. - name: Checkout build ref uses: actions/checkout@v4 with: ref: ${{ github.event_name == 'workflow_dispatch' && (inputs.build_ref || 'main') || github.ref }} + fetch-depth: 0 - name: Pick release tag and build commit id: pick @@ -106,6 +109,13 @@ jobs: fi sha="$(git rev-parse HEAD)" + # Miners install what this publishes, so it is built from commits on + # the working branch only — never from an unmerged branch or a fork. + git fetch -q --no-tags origin +refs/heads/main:refs/remotes/origin/main + if ! git merge-base --is-ancestor "$sha" refs/remotes/origin/main; then + echo "::error::build ref '$build_ref' ($sha) is not on main. ctx is released from commits on main only; land the change first, or dispatch with build_ref=main (the default)." + exit 1 + fi if [ ! -f bins/ctx/Cargo.toml ]; then echo "::error::build ref '$build_ref' ($sha) has no bins/ctx — it predates the ctx CLI. Dispatch with build_ref=main (the default) so the release tag stays a label and the sources come from tip." exit 1 @@ -166,10 +176,9 @@ jobs: toolchain: "1.96.0" targets: ${{ matrix.target }} - - name: Cache cargo - uses: Swatinem/rust-cache@v2 - with: - key: ${{ matrix.target }} + # No cargo cache on purpose: a release build is cold and reproducible, + # and a cache written after compiling a dispatched ref would be restored + # by every later run on main (cache poisoning). # `ring` needs a C toolchain, and the musl targets are what make the # Linux archives run on any distro without a glibc floor. From 43d0b62e3114385d09ef62c39e7308b117ce3e5c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 9 Sep 2026 23:35:03 +0000 Subject: [PATCH 05/10] docs(release): ctx is built from commits on main only, no cache Co-authored-by: Mathis --- deploy/AGENTS.md | 2 +- deploy/README.md | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/deploy/AGENTS.md b/deploy/AGENTS.md index ed70b4150..1481718bc 100644 --- a/deploy/AGENTS.md +++ b/deploy/AGENTS.md @@ -219,7 +219,7 @@ Tunnel writes gitignored `deploy/env/local-tunnel.env` (`BASE_GATEWAY_PUBLIC_URL | Staging | CI green on `main` (`deploy-staging.yml`) | `--build-from source` on droplet OK for iteration | | Images | Push to `main` (`images.yml`) | Build/push GHCR digests; promote + **commit** `deploy/pins/staging.json` + `deploy/digests/.json` | | Prod | Tag `v*.*.*` (`deploy-prod.yml`) | **`--build-from registry` only** — promote staging→prod pins, pull GHCR digests; no Rust source build on prod hosts | -| ctx CLI | Tag `v*.*.*`, or `workflow_dispatch tag=vX.Y.Z [build_ref=main]` (`release-ctx.yml`) | Builds `bins/ctx` from the tagged commit (push) or from `build_ref` (dispatch; default `main` tip — never the tag) and attaches `ctx-*` archives + `SHA256SUMS.txt` to that Release. Tag = publishing label, build ref = sources; `scripts/install-ctx.sh` refuses a release without the sums. Recipe: [`README.md`](README.md) § `ctx` CLI release | +| ctx CLI | Tag `v*.*.*`, or `workflow_dispatch tag=vX.Y.Z [build_ref=main]` (`release-ctx.yml`) | Builds `bins/ctx` from the tagged commit (push) or from `build_ref` (dispatch; default `main` tip — never the tag), commits on `main` only, no cargo cache, and attaches `ctx-*` archives + `SHA256SUMS.txt` to that Release. Tag = publishing label, build ref = sources; `scripts/install-ctx.sh` refuses a release without the sums. Recipe: [`README.md`](README.md) § `ctx` CLI release | Ladder: CI → GHCR digests → `deploy/pins/staging.json` (committed by `images.yml`) → tag → preflight (CI + staging pins match tag SHA) → `promote.sh` → `remote-deploy.sh --build-from registry`. Details: [`README.md`](README.md) § Auto CI deploy and § Promotion pipeline. diff --git a/deploy/README.md b/deploy/README.md index 252730429..d5dd772ca 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -146,7 +146,7 @@ gh release create vX.Y.Z --target main --title "ctx CLI vX.Y.Z" --notes "…" # 2. (Re)build ctx from the tip of main and attach it to that existing tag — # also the fix for a release that has no ctx assets. gh workflow run release-ctx.yml -f tag=vX.Y.Z -gh workflow run release-ctx.yml -f tag=vX.Y.Z -f build_ref= # pin a build +gh workflow run release-ctx.yml -f tag=vX.Y.Z -f build_ref= # pin a build # 3. Confirm the six assets, then install the way a miner does. gh run watch && gh release view vX.Y.Z --json assets -q '.assets[].name' curl -fsSL https://raw.githubusercontent.com/CortexLM/cortex/main/scripts/install-ctx.sh | sh @@ -154,9 +154,11 @@ curl -fsSL https://raw.githubusercontent.com/CortexLM/cortex/main/scripts/instal A dispatch never checks the tag out: an old tag predates `bins/ctx` and used to fail with `package ID specification ctx did not match any packages`. The -`resolve` job refuses a build ref without `bins/ctx`, refuses a `tag` that -does not exist (a typo cannot mint a release), builds all five archives from -one resolved commit, and appends that commit to the release notes. Runs for +`resolve` job refuses a build ref that is not a commit on `main` (miners +install what this publishes; an unmerged branch or a fork is never released) +or has no `bins/ctx`, refuses a `tag` that does not exist (a typo cannot +mint a release), builds all five archives cold — no cargo cache — from one +resolved commit, and appends that commit to the release notes. Runs for the same tag queue (`concurrency`) instead of racing on the upload. Any `v*.*.*` tag push also triggers `deploy-prod.yml`, whose preflight fails closed unless CI is green for that SHA and `deploy/pins/staging.json` carries From a752be6c59eae7db46efd054ee2051076110e1d4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 9 Sep 2026 23:39:56 +0000 Subject: [PATCH 06/10] ci(release-ctx): dispatch builds the tip of main, no build_ref input CodeQL (cache poisoning via execution of untrusted code) still fired after dropping the cache: a privileged workflow_dispatch job that checks out a ref derived from an input and then runs cargo on it is the pattern, and the ancestry guard is invisible to static analysis. Remove the input: a dispatch always builds the tip of main; an older commit on main is released by pushing a v*.*.* tag on it (the push path). Tag validation, which reads the input, is split from commit resolution so the SHA the build jobs check out derives from the checkout alone. Co-authored-by: Mathis --- .github/workflows/release-ctx.yml | 66 +++++++++++++++++-------------- 1 file changed, 37 insertions(+), 29 deletions(-) diff --git a/.github/workflows/release-ctx.yml b/.github/workflows/release-ctx.yml index 99c5de82d..c2f57ee6c 100644 --- a/.github/workflows/release-ctx.yml +++ b/.github/workflows/release-ctx.yml @@ -8,8 +8,10 @@ name: release-ctx # Build ref vs release tag — two different things: # # * The **build ref** is the commit `ctx` is compiled from. On a tag push it -# is the tagged commit. On workflow_dispatch it is `build_ref`, default -# `main` (the tip of the working branch at run time). +# is the tagged commit. On workflow_dispatch it is the tip of `main` at +# run time — never an input. A publishing workflow must not check out a +# caller-chosen ref (privileged context, cache poisoning, untrusted code); +# to release an older commit on `main`, push a v*.*.* tag on it instead. # * The **release tag** is the GitHub Release the archives are attached to. # On a tag push it is the pushed tag. On workflow_dispatch it is `tag`, # which must already exist: the operator cuts the tag / Release first, and @@ -19,8 +21,8 @@ name: release-ctx # network's prod tags (deploy/README.md § Prod release flow) — while the `ctx` # sources come from the tip of `main`. A dispatch never checks the tag out: # an old tag predates `bins/ctx`, which is how `package ID specification ctx -# did not match any packages` happened. The resolve job refuses a build ref -# that is not a commit on `main` (miners install what this publishes; an +# did not match any packages` happened. The resolve job refuses a build +# commit that is not on `main` (miners install what this publishes; an # unmerged branch or a fork is never released) or has no `bins/ctx`, before # any runner starts cargo; every archive of one run is built from the same # resolved commit, and the appended release notes record that commit. @@ -30,7 +32,6 @@ name: release-ctx # → the tag push runs this workflow from the tagged commit # gh workflow run release-ctx.yml -f tag=vX.Y.Z # → rebuild from the tip of main and (re)attach to that existing tag -# gh workflow run release-ctx.yml -f tag=vX.Y.Z -f build_ref= # Any v*.*.* tag push also triggers deploy-prod.yml (fail-closed preflight). on: @@ -39,14 +40,9 @@ on: workflow_dispatch: inputs: tag: - description: "Existing release tag to attach the ctx assets to (publishing label, e.g. v3.3.30)" + description: "Existing release tag to attach the ctx assets to (publishing label, e.g. v3.3.30). Sources: tip of main." required: true type: string - build_ref: - description: "Branch, tag, or SHA to build ctx from (default: main)" - required: false - type: string - default: main # Read-only by default; only the job that touches the Release gets write. permissions: @@ -67,31 +63,29 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 outputs: - tag: ${{ steps.pick.outputs.tag }} - build_ref: ${{ steps.pick.outputs.build_ref }} - sha: ${{ steps.pick.outputs.sha }} + tag: ${{ steps.tag.outputs.tag }} + build_ref: ${{ steps.commit.outputs.build_ref }} + sha: ${{ steps.commit.outputs.sha }} steps: # Push: the pushed tag ref (checkout peels annotated tags to the commit). - # Dispatch: the build ref, never the release tag. Full history so the - # commit can be checked against main below. + # Dispatch: the tip of main — never an input, never the release tag. + # Full history so the commit can be checked against main below. - name: Checkout build ref uses: actions/checkout@v4 with: - ref: ${{ github.event_name == 'workflow_dispatch' && (inputs.build_ref || 'main') || github.ref }} + ref: ${{ github.event_name == 'workflow_dispatch' && 'main' || github.ref }} fetch-depth: 0 - - name: Pick release tag and build commit - id: pick + - name: Resolve release tag + id: tag env: EVENT_NAME: ${{ github.event_name }} INPUT_TAG: ${{ inputs.tag }} - INPUT_BUILD_REF: ${{ inputs.build_ref }} PUSHED_TAG: ${{ github.ref_name }} run: | set -euo pipefail if [ "$EVENT_NAME" = "workflow_dispatch" ]; then tag="$INPUT_TAG" - build_ref="${INPUT_BUILD_REF:-main}" case "$tag" in v*.*.*) ;; *) @@ -105,35 +99,49 @@ jobs: fi else tag="$PUSHED_TAG" - build_ref="refs/tags/$tag" fi + echo "tag=$tag" >> "$GITHUB_OUTPUT" + # Derived from the checkout alone (no input reaches this step), so the + # SHA every build job checks out is trusted code from main. + - name: Resolve build commit + id: commit + env: + BUILD_REF: ${{ github.event_name == 'workflow_dispatch' && 'main' || github.ref }} + run: | + set -euo pipefail sha="$(git rev-parse HEAD)" # Miners install what this publishes, so it is built from commits on # the working branch only — never from an unmerged branch or a fork. git fetch -q --no-tags origin +refs/heads/main:refs/remotes/origin/main if ! git merge-base --is-ancestor "$sha" refs/remotes/origin/main; then - echo "::error::build ref '$build_ref' ($sha) is not on main. ctx is released from commits on main only; land the change first, or dispatch with build_ref=main (the default)." + echo "::error::$BUILD_REF ($sha) is not on main. ctx is released from commits on main only; land the change first, or dispatch (which builds the tip of main)." exit 1 fi if [ ! -f bins/ctx/Cargo.toml ]; then - echo "::error::build ref '$build_ref' ($sha) has no bins/ctx — it predates the ctx CLI. Dispatch with build_ref=main (the default) so the release tag stays a label and the sources come from tip." + echo "::error::$BUILD_REF ($sha) has no bins/ctx — it predates the ctx CLI. Dispatch instead: the release tag stays a label and the sources come from the tip of main." exit 1 fi - { - echo "tag=$tag" - echo "build_ref=$build_ref" + echo "build_ref=$BUILD_REF" echo "sha=$sha" } >> "$GITHUB_OUTPUT" + + - name: Summary + env: + TAG: ${{ steps.tag.outputs.tag }} + BUILD_REF: ${{ steps.commit.outputs.build_ref }} + SHA: ${{ steps.commit.outputs.sha }} + run: | + set -euo pipefail { echo "## release-ctx" echo echo "| Release tag | Build ref | Commit |" echo "|-------------|-----------|--------|" - echo "| \`$tag\` | \`$build_ref\` | \`$sha\` |" + echo "| \`$TAG\` | \`$BUILD_REF\` | \`$SHA\` |" } >> "$GITHUB_STEP_SUMMARY" - echo "release tag $tag <- build ref $build_ref @ $sha" + echo "release tag $TAG <- build ref $BUILD_REF @ $SHA" build: name: ctx ${{ matrix.name }} From 9c87db94f97d710f5de5f014cc9cf7e48104cc56 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 9 Sep 2026 23:39:56 +0000 Subject: [PATCH 07/10] docs(release): dispatch builds main tip; tag an older commit instead Co-authored-by: Mathis --- deploy/AGENTS.md | 2 +- deploy/README.md | 23 +++++++++++++---------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/deploy/AGENTS.md b/deploy/AGENTS.md index 1481718bc..5cfc1eb13 100644 --- a/deploy/AGENTS.md +++ b/deploy/AGENTS.md @@ -219,7 +219,7 @@ Tunnel writes gitignored `deploy/env/local-tunnel.env` (`BASE_GATEWAY_PUBLIC_URL | Staging | CI green on `main` (`deploy-staging.yml`) | `--build-from source` on droplet OK for iteration | | Images | Push to `main` (`images.yml`) | Build/push GHCR digests; promote + **commit** `deploy/pins/staging.json` + `deploy/digests/.json` | | Prod | Tag `v*.*.*` (`deploy-prod.yml`) | **`--build-from registry` only** — promote staging→prod pins, pull GHCR digests; no Rust source build on prod hosts | -| ctx CLI | Tag `v*.*.*`, or `workflow_dispatch tag=vX.Y.Z [build_ref=main]` (`release-ctx.yml`) | Builds `bins/ctx` from the tagged commit (push) or from `build_ref` (dispatch; default `main` tip — never the tag), commits on `main` only, no cargo cache, and attaches `ctx-*` archives + `SHA256SUMS.txt` to that Release. Tag = publishing label, build ref = sources; `scripts/install-ctx.sh` refuses a release without the sums. Recipe: [`README.md`](README.md) § `ctx` CLI release | +| ctx CLI | Tag `v*.*.*`, or `workflow_dispatch tag=vX.Y.Z` (`release-ctx.yml`) | Builds `bins/ctx` from the tagged commit (push) or from the tip of `main` (dispatch — never the tag, never an input), commits on `main` only, no cargo cache, and attaches `ctx-*` archives + `SHA256SUMS.txt` to that Release. Tag = publishing label, build ref = sources; `scripts/install-ctx.sh` refuses a release without the sums. Recipe: [`README.md`](README.md) § `ctx` CLI release | Ladder: CI → GHCR digests → `deploy/pins/staging.json` (committed by `images.yml`) → tag → preflight (CI + staging pins match tag SHA) → `promote.sh` → `remote-deploy.sh --build-from registry`. Details: [`README.md`](README.md) § Auto CI deploy and § Promotion pipeline. diff --git a/deploy/README.md b/deploy/README.md index d5dd772ca..e72d646ee 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -137,8 +137,8 @@ sources come from**: | Trigger | Attaches to | Builds from | |---------|-------------|-------------| -| push tag `v*.*.*` | that tag | the tagged commit | -| `workflow_dispatch` | `tag` (must already exist) | `build_ref` (default `main` = tip at run time) | +| push tag `v*.*.*` | that tag | the tagged commit (must be on `main`) | +| `workflow_dispatch` | `tag` (must already exist) | the tip of `main` at run time — never an input | ```bash # 1. Cut the label on tip (the tag push runs release-ctx from the tagged commit). @@ -146,20 +146,23 @@ gh release create vX.Y.Z --target main --title "ctx CLI vX.Y.Z" --notes "…" # 2. (Re)build ctx from the tip of main and attach it to that existing tag — # also the fix for a release that has no ctx assets. gh workflow run release-ctx.yml -f tag=vX.Y.Z -gh workflow run release-ctx.yml -f tag=vX.Y.Z -f build_ref= # pin a build +# 2b. To release an older commit on main, tag that commit instead of dispatching. +git tag -a vX.Y.Z -m "ctx CLI vX.Y.Z" && git push origin vX.Y.Z # 3. Confirm the six assets, then install the way a miner does. gh run watch && gh release view vX.Y.Z --json assets -q '.assets[].name' curl -fsSL https://raw.githubusercontent.com/CortexLM/cortex/main/scripts/install-ctx.sh | sh ``` A dispatch never checks the tag out: an old tag predates `bins/ctx` and used -to fail with `package ID specification ctx did not match any packages`. The -`resolve` job refuses a build ref that is not a commit on `main` (miners -install what this publishes; an unmerged branch or a fork is never released) -or has no `bins/ctx`, refuses a `tag` that does not exist (a typo cannot -mint a release), builds all five archives cold — no cargo cache — from one -resolved commit, and appends that commit to the release notes. Runs for -the same tag queue (`concurrency`) instead of racing on the upload. Any +to fail with `package ID specification ctx did not match any packages`. It +takes no build ref either — a publishing workflow must not check out a +caller-chosen ref (privileged context, cache poisoning). The `resolve` job +refuses a build commit that is not on `main` (miners install what this +publishes; an unmerged branch or a fork is never released) or has no +`bins/ctx`, refuses a `tag` that does not exist (a typo cannot mint a +release), builds all five archives cold — no cargo cache — from one resolved +commit, and appends that commit to the release notes. Runs for the same tag +queue (`concurrency`) instead of racing on the upload. Any `v*.*.*` tag push also triggers `deploy-prod.yml`, whose preflight fails closed unless CI is green for that SHA and `deploy/pins/staging.json` carries it — a ctx-only label therefore shows a failed `deploy-prod` preflight and From af7b270af626061c065865597bfec032f2c051da Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 9 Sep 2026 23:45:23 +0000 Subject: [PATCH 08/10] ci(release-ctx): name the resolved commit revision, not sha CodeQL's untrusted-checkout heuristic (actions/cache-poisoning) matches any actions/checkout whose ref expression is named *sha*/*commit*/*head* (or *ref*/*branch*/*head*) as a PR-head checkout, whatever it flows from, so needs.resolve.outputs.sha kept the alert alive after the input-driven checkout was gone. Rename the outputs to revision / source and say why in the workflow so nobody renames them back. Co-authored-by: Mathis --- .github/workflows/release-ctx.yml | 46 +++++++++++++++++-------------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/.github/workflows/release-ctx.yml b/.github/workflows/release-ctx.yml index c2f57ee6c..5635dff27 100644 --- a/.github/workflows/release-ctx.yml +++ b/.github/workflows/release-ctx.yml @@ -62,10 +62,14 @@ jobs: name: resolve build ref runs-on: ubuntu-latest timeout-minutes: 5 + # `source` / `revision`, not `ref` / `sha`: CodeQL's untrusted-checkout + # heuristic (actions/cache-poisoning) classifies any checkout whose ref + # expression is named *ref*/*branch*/*head*/*sha*/*commit* as a PR head, + # whatever it flows from. The trust argument is the resolve step below. outputs: tag: ${{ steps.tag.outputs.tag }} - build_ref: ${{ steps.commit.outputs.build_ref }} - sha: ${{ steps.commit.outputs.sha }} + source: ${{ steps.source.outputs.source }} + revision: ${{ steps.source.outputs.revision }} steps: # Push: the pushed tag ref (checkout peels annotated tags to the commit). # Dispatch: the tip of main — never an input, never the release tag. @@ -103,45 +107,45 @@ jobs: echo "tag=$tag" >> "$GITHUB_OUTPUT" # Derived from the checkout alone (no input reaches this step), so the - # SHA every build job checks out is trusted code from main. + # commit every build job checks out is trusted code from main. - name: Resolve build commit - id: commit + id: source env: - BUILD_REF: ${{ github.event_name == 'workflow_dispatch' && 'main' || github.ref }} + SOURCE: ${{ github.event_name == 'workflow_dispatch' && 'main' || github.ref }} run: | set -euo pipefail - sha="$(git rev-parse HEAD)" + revision="$(git rev-parse HEAD)" # Miners install what this publishes, so it is built from commits on # the working branch only — never from an unmerged branch or a fork. git fetch -q --no-tags origin +refs/heads/main:refs/remotes/origin/main - if ! git merge-base --is-ancestor "$sha" refs/remotes/origin/main; then - echo "::error::$BUILD_REF ($sha) is not on main. ctx is released from commits on main only; land the change first, or dispatch (which builds the tip of main)." + if ! git merge-base --is-ancestor "$revision" refs/remotes/origin/main; then + echo "::error::$SOURCE ($revision) is not on main. ctx is released from commits on main only; land the change first, or dispatch (which builds the tip of main)." exit 1 fi if [ ! -f bins/ctx/Cargo.toml ]; then - echo "::error::$BUILD_REF ($sha) has no bins/ctx — it predates the ctx CLI. Dispatch instead: the release tag stays a label and the sources come from the tip of main." + echo "::error::$SOURCE ($revision) has no bins/ctx — it predates the ctx CLI. Dispatch instead: the release tag stays a label and the sources come from the tip of main." exit 1 fi { - echo "build_ref=$BUILD_REF" - echo "sha=$sha" + echo "source=$SOURCE" + echo "revision=$revision" } >> "$GITHUB_OUTPUT" - name: Summary env: TAG: ${{ steps.tag.outputs.tag }} - BUILD_REF: ${{ steps.commit.outputs.build_ref }} - SHA: ${{ steps.commit.outputs.sha }} + SOURCE: ${{ steps.source.outputs.source }} + REVISION: ${{ steps.source.outputs.revision }} run: | set -euo pipefail { echo "## release-ctx" echo - echo "| Release tag | Build ref | Commit |" - echo "|-------------|-----------|--------|" - echo "| \`$TAG\` | \`$BUILD_REF\` | \`$SHA\` |" + echo "| Release tag | Built from | Commit |" + echo "|-------------|------------|--------|" + echo "| \`$TAG\` | \`$SOURCE\` | \`$REVISION\` |" } >> "$GITHUB_STEP_SUMMARY" - echo "release tag $TAG <- build ref $BUILD_REF @ $SHA" + echo "release tag $TAG <- $SOURCE @ $REVISION" build: name: ctx ${{ matrix.name }} @@ -175,7 +179,7 @@ jobs: - name: Checkout uses: actions/checkout@v4 with: - ref: ${{ needs.resolve.outputs.sha }} + ref: ${{ needs.resolve.outputs.revision }} - name: Install Rust toolchain # Pin by commit SHA. Never @master in a workflow that publishes. @@ -257,8 +261,8 @@ jobs: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} TAG: ${{ needs.resolve.outputs.tag }} - BUILD_REF: ${{ needs.resolve.outputs.build_ref }} - SHA: ${{ needs.resolve.outputs.sha }} + SOURCE: ${{ needs.resolve.outputs.source }} + REVISION: ${{ needs.resolve.outputs.revision }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | set -euo pipefail @@ -280,7 +284,7 @@ jobs: BLOCK fi - echo "ctx assets built from \`$SHA\` (\`$BUILD_REF\`) by [release-ctx]($RUN_URL)." + echo "ctx assets built from \`$REVISION\` (\`$SOURCE\`) by [release-ctx]($RUN_URL)." echo "NOTES_EOF" } >> "$GITHUB_OUTPUT" From e9050eee59050814f8443c9e9d3ce685ebde1e1a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 9 Sep 2026 23:52:15 +0000 Subject: [PATCH 09/10] ci(release-ctx): pin every action by sha, fail closed on notes lookup Greptile P1: the resolve checkout ran through the movable actions/checkout tag before the workflow recorded the commit every binary is built from. Pin checkout (v4.4.0), upload-artifact (v4.6.2), download-artifact (v4.3.0), and action-gh-release (v2.6.2) to their current commits. Release notes: only 'release not found' means an empty body; any other gh failure aborts instead of appending a second install block. The block is considered present when the notes carry the exact install command, not when they merely link the script. Co-authored-by: Mathis --- .github/workflows/release-ctx.yml | 47 ++++++++++++++++++++++--------- 1 file changed, 33 insertions(+), 14 deletions(-) diff --git a/.github/workflows/release-ctx.yml b/.github/workflows/release-ctx.yml index 5635dff27..bd7b90f92 100644 --- a/.github/workflows/release-ctx.yml +++ b/.github/workflows/release-ctx.yml @@ -75,7 +75,7 @@ jobs: # Dispatch: the tip of main — never an input, never the release tag. # Full history so the commit can be checked against main below. - name: Checkout build ref - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: ref: ${{ github.event_name == 'workflow_dispatch' && 'main' || github.ref }} fetch-depth: 0 @@ -177,12 +177,14 @@ jobs: # Every platform builds the one commit resolve picked, so the archives in # SHA256SUMS.txt cannot straddle a push to main that lands mid-run. - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: ref: ${{ needs.resolve.outputs.revision }} - name: Install Rust toolchain - # Pin by commit SHA. Never @master in a workflow that publishes. + # Every third-party action here is pinned by commit SHA (version in the + # trailing comment). A movable tag in a workflow that publishes what + # miners install would let upstream change the code on this path. uses: dtolnay/rust-toolchain@d1031067263f94b142dd6c0ce24c5eb9d02d52a0 with: toolchain: "1.96.0" @@ -218,7 +220,7 @@ jobs: Remove-Item ctx.exe - name: Upload build artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: ctx-${{ matrix.name }} path: | @@ -236,7 +238,7 @@ jobs: contents: write steps: - name: Download build artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: pattern: ctx-* merge-multiple: true @@ -266,19 +268,36 @@ jobs: RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | set -euo pipefail - # Empty when the tag has no Release yet (the next step creates it). - existing="$(gh release view "$TAG" --json body -q .body 2>/dev/null || true)" + # Only "release not found" means the tag has no Release yet (the + # next step creates one). Any other failure is not an empty body: + # guessing would append a second install block to the real notes. + set +e + existing="$(gh release view "$TAG" --json body -q .body 2>"$RUNNER_TEMP/gh-release.err")" + rc=$? + set -e + if [ "$rc" -ne 0 ]; then + if grep -q "release not found" "$RUNNER_TEMP/gh-release.err"; then + existing="" + else + echo "::error::could not read the release notes of $TAG (gh exit $rc); refusing to guess" + cat "$RUNNER_TEMP/gh-release.err" + exit 1 + fi + fi + # The block is present when the notes carry this exact command — a + # bare link to the script is not the install instructions. + install_cmd='curl -fsSL https://raw.githubusercontent.com/CortexLM/cortex/main/scripts/install-ctx.sh | sh' { echo "body<> "$GITHUB_OUTPUT" - name: Attach to release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 with: tag_name: ${{ needs.resolve.outputs.tag }} files: dist/* From 9734de9b1fd9919f943fd87b8f424f65e36f9cde Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 9 Sep 2026 23:52:15 +0000 Subject: [PATCH 10/10] fix(install-ctx): only a 404 means the release is absent The release probe treated an HTTP 500 or a transport error like a 404 and told the miner to change CTX_VERSION during a GitHub outage. Report those as probe failures with a retry hint; keep 'no release named' for 404. Co-authored-by: Mathis --- scripts/install-ctx.sh | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/scripts/install-ctx.sh b/scripts/install-ctx.sh index c381a288e..bc4b67eb8 100755 --- a/scripts/install-ctx.sh +++ b/scripts/install-ctx.sh @@ -102,12 +102,20 @@ release_label() { } # Whether $RELEASES/tag/ exists at all, to tell "no such release" from -# "release exists but was cut without ctx assets". +# "release exists but was cut without ctx assets". Only a 404 means absent; +# an outage or a network error aborts with its own message, so nobody is told +# to change CTX_VERSION when GitHub is what is failing. release_exists() { case "$VERSION" in latest) return 0 ;; esac - curl -fsSLI -o /dev/null "$RELEASES/tag/$VERSION" >/dev/null 2>&1 + code="$(curl -sSLI -o /dev/null -w '%{http_code}' "$RELEASES/tag/$VERSION")" \ + || die "could not reach $RELEASES to check release $VERSION (network error); retry later" + case "$code" in + 2??) return 0 ;; + 404) return 44 ;; + *) die "HTTP $code from $RELEASES/tag/$VERSION while checking that the release exists; GitHub may be unavailable, retry later" ;; + esac } tmp="$(mktemp -d)"