Skip to content

ci(release-ctx): build ctx from a build ref, attach to the release tag - #258

Open
echobt wants to merge 10 commits into
mainfrom
cursor/release-ctx-build-ref-db7b
Open

echobt wants to merge 10 commits into
mainfrom
cursor/release-ctx-build-ref-db7b

Conversation

@echobt

@echobt echobt commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Summary

P0 miner install: curl -fsSL …/scripts/install-ctx.sh | sh must always get real ctx binaries.

Root cause (verified in run 34415976577): release-ctx.yml on workflow_dispatch checked out inputs.tag. Dispatching against a tag that predates bins/ctx (e.g. v3.3.5, then releases/latest) failed with package ID specification ctx did not match any packages, so latest kept zero ctx assets and the installer died on a bare 404.

.github/workflows/release-ctx.yml — build ref ≠ release tag

  • New resolve job picks the release tag (pushed tag, or the existing tag input) and the build commit: the tagged commit on push, the tip of main on dispatch. A dispatch never checks the tag out.
  • No build_ref input on purpose: CodeQL (high) flags a privileged workflow_dispatch job that checks out an input-derived ref and runs cargo on it (cache poisoning / untrusted code), and an ancestry guard is invisible to static analysis. Releasing an older main commit = push a v*.*.* tag on it (push path). Tag validation (reads the input) is a separate step from commit resolution, so the commit the build jobs check out derives from the checkout alone.
  • Fail-closed guards before any runner starts cargo: dispatch tag must exist on origin (a typo cannot mint a release) and match v*.*.*; the build commit must be an ancestor of origin/main (miners install what this publishes — an unmerged branch or a fork is never released) and must contain bins/ctx (the error names the old failure and the fix).
  • All five platform jobs check out the one resolved commit, so SHA256SUMS.txt can never straddle a push to main mid-run. Outputs are named revision / source (not sha / ref): CodeQL's untrusted-checkout heuristic keys on those field names regardless of data flow; the workflow says so in a comment.
  • Every action SHA-pinned with its version in a trailing comment (checkout v4.4.0, upload-artifact v4.6.2, download-artifact v4.3.0, action-gh-release v2.6.2; rust-toolchain was already pinned). SHAs resolved from the tags the workflow already used — no behaviour change.
  • No cargo cache: a release build is cold and reproducible; a cache written by a publishing job would be restored by every later run on main.
  • concurrency per tag (tag push + dispatch for the same tag queue instead of racing on upload); contents: write only on the release job.
  • Release notes: install block added once per release, keyed on the exact canonical install command (the current v3.3.30 body has it three times from repeated append_body), plus one provenance line per build (built from <revision> (<source>) by release-ctx <run>). The existing-notes lookup fails closed on anything but release not found, so a transient API error can never append a duplicate.
  • Header documents that the Release tag is a publishing label while sources come from tip, and that any v*.*.* tag push also fires deploy-prod.yml (fail-closed preflight — observed on v3.3.30 / v0.2.0, deployed nothing).

scripts/install-ctx.sh — clear error instead of a 404

  • Resolves which tag latest is, fetches SHA256SUMS.txt first, and when it is missing stops with a message naming the release, explaining that ctx assets come from release-ctx, and showing the CTX_VERSION pin. Separate messages for: no such release (404 only), release-probe outage (5xx / network, with a retry hint), no build for this platform, listed-but-missing archive, checksum mismatch, network error. Still never installs unverified.

Docs

  • deploy/README.md: operator recipe (gh release create vX.Y.Z --target main …gh workflow run release-ctx.yml -f tag=vX.Y.Z → verify → curl | sh; older commit → tag it), the deploy-prod coupling, releases/latest ordering. deploy/AGENTS.md: lane row.
  • docs/external-miner/README.md: CTX_VERSION pin; troubleshoot.md: rows for the new installer messages.

Out of scope, untouched: no digests invented, no releases deleted, deploy-prod.yml unchanged. ctx --version still prints the workspace version (0.1.0), not the release tag.

Greptile

Every PR is reviewed by Greptile before merge. Config: .greptile/.

  • Greptile has reviewed this PR; findings are fixed or answered (5 threads: action pinning P1+P2, notes lookup, block marker, release probe — all fixed in e9050ee / 9734de9 with replies)
  • If the bot was silent, I commented @greptileai review

Test plan

  • actionlint + shellcheck on the workflow; shellcheck -s sh on the installer (clean); CodeQL (actions) green on the head
  • resolve steps simulated against a local bare origin: dispatch tag OK / missing → error / malformed → error; commit on main tip OK; annotated tag on main → peeled commit; tag off main → "not on main" error; tag predating bins/ctx → error
  • Release-notes step simulated: no release → block; gh HTTP 503 → step fails, no body; notes with the command → no duplicate; notes that only link the script → block; plain notes → block
  • Installer against real GitHub: CTX_VERSION=v3.3.5 → "has no ctx assets" message, exit 1; CTX_VERSION=v9.9.9 → "no release named"; default latest → installed real ctx 0.1.0 from v3.3.30 and ctx challenges runs
  • Installer against local servers: happy path, no build for platform, checksum mismatch, listed-but-missing; release probe 500 → outage message, 404 → absent, 200 → no-assets message, dead port → network error
  • cargo run -p xtask -- external-docs-check OK; cargo test -p xtask 57 passed (no Rust touched)
  • The workflow itself runs only on a tag push / dispatch after merge — first real exercise is the next gh workflow run release-ctx.yml -f tag=… from main

Risk

No deploy, miner CVM measurement, signature domain, or emission impact. Changes a publishing workflow and an installer: a bad edit here fails a release closed (no assets → installer refuses), never installs an unverified binary. Cold builds make each release run a few minutes longer. Operators must know that a v*.*.* label also triggers deploy-prod preflight (pre-existing coupling, now documented).

Naming

I did not rename BASE_* environment variables, deployed host paths
(/opt/base, /run/base, …), GHCR baseintelligence/base package names, or
base-*-v1 cryptographic domain tags.

Open in Web Open in Cursor 

cursoragent and others added 3 commits September 9, 2026 23:29
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 <echobt@users.noreply.github.com>
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 <echobt@users.noreply.github.com>
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 <echobt@users.noreply.github.com>
@cursor

cursor Bot commented Sep 9, 2026

Copy link
Copy Markdown

@greptileai review

cursoragent and others added 2 commits September 9, 2026 23:35
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 <echobt@users.noreply.github.com>
Co-authored-by: Mathis <echobt@users.noreply.github.com>
@cursor

cursor Bot commented Sep 9, 2026

Copy link
Copy Markdown

@greptileai review

Pushed 4391ef0 + 43d0b62: CodeQL flagged cache poisoning (build job checks out a SHA derived from the build_ref input, then rust-cache saved a cache later runs on main would restore). Fix: release builds are cold (no Swatinem/rust-cache), and resolve now refuses any build ref that is not an ancestor of origin/main — ctx is released from commits on main only. Docs updated to match.

cursoragent and others added 2 commits September 9, 2026 23:39
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 <echobt@users.noreply.github.com>
Co-authored-by: Mathis <echobt@users.noreply.github.com>
@cursor

cursor Bot commented Sep 9, 2026

Copy link
Copy Markdown

@greptileai review

Pushed a752be6 + 9c87db9: CodeQL still flagged cache poisoning after dropping the cache — the pattern it matches is a privileged workflow_dispatch job checking out a ref derived from an input and running cargo on it, and the ancestry guard is invisible to static analysis. Removed the build_ref input: a dispatch always builds the tip of main; to release an older commit on main, push a v*.*.* tag on it (push path, still main-only + bins/ctx guarded). Tag validation (reads the input) is now a separate step from commit resolution, so the SHA the build jobs check out derives from the checkout alone.

@greptile-apps

greptile-apps Bot commented Sep 9, 2026

Copy link
Copy Markdown

Greptile Summary

The release workflow now pins publish-path actions, fails closed when release notes cannot be read, recognizes the complete installation command before adding instructions, and the installer distinguishes missing releases from service failures.

Confidence Score: 5/5

Safe to merge.

Reviews (5): Last reviewed commit: "fix(install-ctx): only a 404 means the r..." | Re-trigger Greptile

Comment thread .github/workflows/release-ctx.yml Outdated
# 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Pin release source checkout

The resolve job checks out the source before recording the commit SHA that every published binary uses, but actions/checkout@v4 is a movable tag. If that upstream tag is changed or compromised, different action code can run in the provenance-critical release path and alter what is built or recorded. Pin this action to a full commit SHA before merging.

How this was verified: The v4 reference resolved as a remote tag, and the checkout runs before the workflow derives and distributes the build SHA.

T-Rex Ran code and verified through T-Rex

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e9050ee: actions/checkout is pinned to 11d5960a326750d5838078e36cf38b85af677262 (v4.4.0) in both the resolve and build jobs, and the other movable tags on the publish path are pinned too — upload-artifact ea165f8d (v4.6.2), download-artifact d3f86a10 (v4.3.0), action-gh-release 3bb12739 (v2.6.2). Each SHA was resolved from the tag the workflow was already using, so no behaviour change.

# 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<<NOTES_EOF"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Preserve release lookup failures

A failed release-body lookup is unconditionally converted into an empty body. A temporary API, authentication, or network failure therefore makes the marker check generate a new installation block; with append_body: true, an existing release receives a duplicate block. This is non-blocking, but it leaves public release notes confusing and no longer limited to one installation section.

Artifacts

Evidence from the check

  • Authored and executed Bash shim that runs the workflow's release-body branch against a simulated GitHub API 503 response, showing the control and changed behavior.

Command output from the check

  • Captured execution of the control command without failure suppression; `GET /repos/CortexLM/cortex/releases/tags/v9.9.9` returned HTTP 503 Service Unavailable and the workflow path exited before emitting a body.

Command output from the check

  • Captured execution of current failure-suppressing behavior; the same HTTP 503 Service Unavailable produced a successful generated body and two installation markers after append.

View artifacts

T-Rex Ran code and verified through T-Rex

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e9050ee: the lookup no longer swallows errors. Only a gh release view failure whose stderr says release not found is treated as "no Release yet" (the next step creates it); any other exit (API 5xx, auth, network) fails the step with ::error::could not read the release notes … refusing to guess and prints gh's stderr, so a transient failure can never append a duplicate block. Simulated with a gh stub returning HTTP 503 → step exits 1, no body emitted.

Comment thread scripts/install-ctx.sh Outdated
case "$VERSION" in
latest) return 0 ;;
esac
curl -fsSLI -o /dev/null "$RELEASES/tag/$VERSION" >/dev/null 2>&1

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Report release probe failures

When the checksum file is unavailable, the release probe treats an HTTP 500 or a transport failure exactly like a confirmed HTTP 404. Users are then told that the requested release does not exist even when the service is temporarily unavailable. This is non-blocking, but it gives operators the wrong recovery action during an outage; treat only 404 as an absent release and surface other probe failures.

Artifacts

Evidence from the check

  • Authored shell fixture runs the installer with controlled curl responses for HTTP 404, HTTP 500, and network failure, showing the diagnostic distinction.

Command output from the check

  • Executed `sh trex-artifacts/release-exists-fixture.sh scripts/install-ctx.sh` against the candidate source and captured that 404, HTTP 500, and network failures all produce the false missing-release diagnostic.

Command output from the check

  • Executed the same deterministic fixture after the targeted source correction; only HTTP 404 produces missing-release while HTTP 500 and network failures produce check-failure diagnostics.

View artifacts

T-Rex Ran code and verified through T-Rex

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9734de9: release_exists now captures the HTTP status of the HEAD probe. 2xx → exists (falls through to the "has no ctx assets" message), 404no release named …, any other status → HTTP <code> … while checking that the release exists; GitHub may be unavailable, retry later, and a transport failure → could not reach … (network error); retry later. Verified against a local server answering 500 / 404 / 200 and a dead port, plus the real v3.3.5 / v9.9.9 / latest cases.

Comment thread .github/workflows/release-ctx.yml Outdated
# 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 security Pin the resolve checkout

This non-blocking release hardening concern uses the mutable actions/checkout@v4 tag to establish the SHA exported to every platform build. Those build artifacts are then published to the release. If the tag is retargeted or its supply chain is compromised, the release can execute altered checkout behavior and publish affected artifacts. Pin this invocation to the intended full commit SHA.

How this was verified: The mutable checkout reference was traced through SHA resolution, platform builds, and release asset publication.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Artifacts

Evidence from the check

  • Authored Bash source inspects the resolve checkout and traces its SHA through the build and release steps, providing repeatable validation of the release supply-chain path.

Command output from the check

  • Executed validator against an ephemeral counterpart that replaces only the resolve checkout reference with a full commit SHA; it exits successfully and reports the risk is not confirmed.

Command output from the check

  • Executed validator against the candidate workflow; it exits successfully and confirms `actions/checkout@v4` feeds the resolved SHA, builds, and later publication.

View artifacts

T-Rex Ran code and verified through T-Rex

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e9050ee (same change as the P1 thread): the resolve checkout is actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0, and every other third-party action on the path from resolved commit → build → publish is SHA-pinned with its version in the trailing comment.

Comment thread .github/workflows/release-ctx.yml Outdated
existing="$(gh release view "$TAG" --json body -q .body 2>/dev/null || true)"
{
echo "body<<NOTES_EOF"
if ! printf '%s' "$existing" | grep -qF "scripts/install-ctx.sh"; then

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Use a complete-block marker

This non-blocking release-note concern treats any mention of scripts/install-ctx.sh as proof that the complete installation section already exists. A hand-written note that only links to the script therefore suppresses the command block, leaving the published release without its intended miner installation instructions. Check for a unique block marker or the full canonical block instead.

Artifacts

Evidence from the check

  • Bash source reproduces the candidate path-based guard and compares it with a full-block marker guard, ending with assertions that capture the behavior.

Command output from the check

  • Executed candidate-logic reproduction shows the handwritten `scripts/install-ctx.sh` mention produces `install_block_inserted=no`, while a note without the path receives the block.

Command output from the check

  • Executed comparison reproduction shows a guard keyed to the block heading produces `install_block_inserted=yes` for both notes, demonstrating the narrowly scoped remedy.

Command output from the check

  • Captured workflow excerpt shows line 261 tests only for the script path and lines 262–273 define the intended full installer block.

View artifacts

T-Rex Ran code and verified through T-Rex

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e9050ee: the presence check is now the exact canonical command (curl -fsSL https://raw.githubusercontent.com/CortexLM/cortex/main/scripts/install-ctx.sh | sh), defined once in install_cmd and used both for the grep -F check and inside the emitted block, so they cannot drift. A hand-written note that only links the script now receives the block; a note that already carries the command (like the current v3.3.30 body) does not get another one. Simulated both cases plus plain notes and no release.

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 <echobt@users.noreply.github.com>
@cursor

cursor Bot commented Sep 9, 2026

Copy link
Copy Markdown

@greptileai review

Pushed af7b270: the remaining CodeQL alert was a naming heuristic, not data flow — ActionsSHACheckout in CodeQL's UntrustedCheckoutQuery classifies any actions/checkout whose ref expression field is named *sha*/*commit*/*head* (and *ref*/*branch* for the mutable-ref variant) as a PR-head checkout regardless of source. Renamed the resolve outputs to revision / source, with a comment in the workflow explaining why so they are not renamed back. Behaviour unchanged.

cursoragent and others added 2 commits September 9, 2026 23:52
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 <echobt@users.noreply.github.com>
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 <echobt@users.noreply.github.com>
@cursor

cursor Bot commented Sep 9, 2026

Copy link
Copy Markdown

@greptileai review

Pushed e9050ee + 9734de9 addressing all five findings (replies in each thread): every action on the publish path is SHA-pinned (checkout v4.4.0, upload-artifact v4.6.2, download-artifact v4.3.0, action-gh-release v2.6.2 — resolved from the tags already in use); the release-notes lookup fails closed on anything but release not found; the once-per-release check keys on the exact install command rather than a path mention; the installer's release probe distinguishes 404 from 5xx/network.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants