From a1ef2ae97e9f4617c75ae4a87e756f1a452c1009 Mon Sep 17 00:00:00 2001 From: David Meister Date: Thu, 20 Aug 2026 09:45:52 +0000 Subject: [PATCH 1/4] feat(rainix-tag-release): push-free deploy release with a fail-closed publish guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deploy repos ran rainix-tag-release, which committed the frozen snapshot + version bump back to main — rejected (GH006) on a branch-protected main. Make it push-free (rainlanguage/rainix#338, Option A): the snapshot + version bump land on main via a reviewed PR (append-only + fork-suite checks run there in rainix-sol), a human sol-v tag on the merged commit triggers publish, and the workflow is read-only verify + publish + GitHub release. Because the workflow no longer cuts the snapshot, a new fail-closed publish guard (rainix-static release-guard) refuses to publish a tag whose commit did not actually carry the freshly-cut snapshot: foundry.toml version == tag version; src/generated// present in the tagged commit; and re-running the generator changes nothing (deterministic). Co-Authored-By: Claude Fable 5 --- .github/workflows/rainix-tag-release.yaml | 194 ++++++++------- rainix-static/src/main.rs | 18 +- rainix-static/src/release_guard.rs | 273 ++++++++++++++++++++++ 3 files changed, 381 insertions(+), 104 deletions(-) create mode 100644 rainix-static/src/release_guard.rs diff --git a/.github/workflows/rainix-tag-release.yaml b/.github/workflows/rainix-tag-release.yaml index 5fd52f7..62b22e7 100644 --- a/.github/workflows/rainix-tag-release.yaml +++ b/.github/workflows/rainix-tag-release.yaml @@ -1,7 +1,7 @@ name: rainix-tag-release -# Tag-triggered release for DEPLOY repos (verify + publish + snapshot; the deploy -# itself stays manual — see below). The counterpart to rainix-autopublish's -# merge-driven publish for LIBRARY repos. +# Tag-triggered release for DEPLOY repos (verify + publish; the deploy itself and +# the snapshot cut both happen BEFORE this workflow — see below). The counterpart +# to rainix-autopublish's merge-driven publish for LIBRARY repos. # # The two lifecycles are mutually exclusive and a repo is strictly one or the # other: @@ -17,23 +17,37 @@ name: rainix-tag-release # * A DEPLOY repo (this workflow) records deployed addresses. Its # src/generated// snapshot pins the address + codehash of what it # deployed, frozen so consumers can rely on them (enforced by the -# frozen-snapshots-append-only gate). [package].version is the LAST released -# version, and moves ONLY at release time, in lockstep with the snapshot it -# describes. +# frozen-snapshots-append-only gate at PR time). [package].version is the +# LAST released version, and moves ONLY at release time, in lockstep with the +# snapshot it describes. # -# Running the merge-driven lifecycle on a deploy repo is the bug this exists -# to remove: autopublish advances the published version on every content -# merge, while the frozen DEPLOY_TAG only advances at deploy time, so the -# repo's release identity (version-vs-DEPLOY_TAG, e.g. `testDeployTag`) -# breaks on main between every merge and the next deploy — trained to be -# ignored, which is how a real regression rides through. +# The release is PUSH-FREE (rainlanguage/rainix#338). Deploy repo mains are +# branch-protected, and rainix-autopublish was already made push-free in +# #335/#336; a deploy release must work the same way. So this workflow never +# commits and never pushes to any branch — a push to a protected main is rejected +# (GH006) and would fail the release after publishing. # -# Here nothing moves on merge: a PR lands source only, main stays at the last -# release (its live contracts still match its pins), and a human TAG is the sole -# release trigger. The tag names the version; this workflow regenerates the -# snapshot for it, verifies the live chain against the fresh pins, publishes to -# Soldeer, and commits the new (append-only) snapshot back to main so the daily -# drift sweep always has the current release's pins to check. +# The release flow, in order: +# 1. Deploy on-chain (the repo's own human-driven rainix-manual-sol-artifacts +# dispatch — NOT this workflow; see below). +# 2. Open a PR that regenerates + commits the frozen src/generated// +# snapshot and bumps [package].version. That PR's normal CI runs the +# frozen-snapshots-append-only gate (rainix-sol-static) and the fork suite +# that asserts the live chain matches the pins (rainix-sol-test), so the +# deploy pins consumers trust are reviewed and verified BEFORE they publish. +# 3. A human merges the PR (normal protected-merge) and pushes a +# `sol-v` tag on the merged commit. +# 4. This workflow runs on that tag: it re-derives the version from the tag, +# re-runs the snapshot generator to prove the tagged commit's snapshot is a +# fresh deterministic regeneration (the fail-closed publish guard below), +# re-attests the live chain matches the pins, then publishes to Soldeer and +# creates the GitHub release. main already carries the snapshot (from the +# merged PR), so the daily drift sweep and the repo's own snapshot tests keep +# reading src/generated// from main unchanged. +# +# Because the snapshot reaches main via the reviewed PR and never via this +# workflow, the workflow has nothing to commit — it is read-only publish + tag + +# GitHub-release. # # The on-chain DEPLOY is deliberately NOT part of this workflow. A deploy is # flaky by nature — per-network, funds-dependent, and at the mercy of RPC @@ -44,6 +58,11 @@ name: rainix-tag-release # broadcast; it ATTESTS that the broadcast already landed: the `verify` step (the # repo's fork suite) checks prod exists at the freshly regenerated pins, and a # release whose pins don't resolve on-chain fails there and publishes nothing. +# +# The publish guard is the point of the push-free change: the workflow no longer +# cuts the snapshot, so a human could tag a commit whose snapshot was never +# regenerated (mistag, stale PR, hand-edit) and it would publish silently. The +# guard fails closed against that — see the `Publish guard` step. on: workflow_call: inputs: @@ -70,14 +89,17 @@ on: type: string default: forge test main-branch: - description: The branch the release snapshot is committed back to. + description: >- + The release branch the tagged commit must already be merged into (the guard's tag-on-main ancestry check). Nothing is committed back to it — the snapshot reaches main via the reviewed PR, not this workflow. required: false type: string default: main secrets: + # The release is push-free: this workflow makes no commit and no branch + # push, so PUBLISH_PRIVATE_KEY (deploy key), CI_GIT_EMAIL and CI_GIT_USER + # are no longer used here. They stay declared (required: false) so callers + # that pass them by name — rather than `secrets: inherit` — do not break. PUBLISH_PRIVATE_KEY: - # A deploy key whose push events (unlike GITHUB_TOKEN pushes) trigger the - # downstream git-clean / rainix-sol workflows on the commit-back to main. required: false CI_GIT_EMAIL: required: false @@ -137,31 +159,27 @@ jobs: release: needs: guard runs-on: ubuntu-latest - # contents: write for the commit-back to main + the gh-release composite. No - # id-token: nothing here uses OIDC (Soldeer uses SOLDEER_API_TOKEN, the release - # uses GITHUB_TOKEN). + # contents: write for the gh-release composite only. No branch push here (the + # release is push-free), and no id-token: nothing uses OIDC (Soldeer uses + # SOLDEER_API_TOKEN, the release uses GITHUB_TOKEN). permissions: contents: write steps: - # Deploy-key checkout so the commit-back to main can push and trigger - # downstream workflows. The pinned checkout / cache-nix / nix-install / - # Cachix SHAs all live once in the composites; the nix preamble then runs - # with checkout:'false'. + # Plain (GITHUB_TOKEN) checkout of the tagged commit: the release makes no + # branch push, so it needs no deploy key. The pinned checkout / cache-nix / + # nix-install / Cachix SHAs all live once in the composites; the nix preamble + # then runs with checkout:'false'. - uses: rainlanguage/rainix/.github/actions/checkout@main - with: - ssh-key: ${{ secrets.PUBLISH_PRIVATE_KEY }} - uses: rainlanguage/rainix/.github/actions/nix-cachix-setup@main with: cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }} checkout: 'false' - - name: Git config - run: | - git config --global user.email "${{ secrets.CI_GIT_EMAIL || 'github-actions[bot]@users.noreply.github.com' }}" - git config --global user.name "${{ secrets.CI_GIT_USER || 'github-actions[bot]' }}" - # Entering the devShell writes a generated .pre-commit-config.yaml into - # the tree; hide it via the local exclude so it never dirties the release - # commit (repo-agnostic, no consumer needs to .gitignore it). - echo ".pre-commit-config.yaml" >> .git/info/exclude + - name: Hide devShell leftovers from the determinism check + # Entering the devShell writes a generated .pre-commit-config.yaml into the + # tree; hide it via the local exclude so it never counts as a change in the + # publish guard's `git status` determinism check (repo-agnostic, no consumer + # needs to .gitignore it). + run: echo ".pre-commit-config.yaml" >> .git/info/exclude - name: Install soldeer dependencies if: ${{ hashFiles('soldeer.lock') != '' }} run: nix develop github:rainlanguage/rainix/${{ env.RAINIX_SHA }}#sol-shell -c forge soldeer install @@ -178,38 +196,27 @@ jobs: *) echo "::error::tag '$TAG' does not start with tag-prefix '$TAG_PREFIX'" >&2; exit 1 ;; esac # Require MAJOR.MINOR.PATCH. Beyond rejecting junk tags (sol-vfoo), this - # guarantees VERSION carries no characters special to the sed below - # (`&`, `\`, `/`), so it cannot corrupt foundry.toml. + # guarantees VERSION is `[0-9.]+` — safe to interpolate into the + # snapshot-dir path (dots→underscores) below and free of shell/path + # metacharacters. if ! printf '%s' "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then echo "::error::version '$VERSION' from tag '$TAG' is not MAJOR.MINOR.PATCH" >&2 exit 1 fi echo "VERSION=$VERSION" >> "$GITHUB_ENV" echo "Release version: $VERSION" - - name: Set foundry.toml version to the release version - # The tag names the version; set it BEFORE regenerating so the snapshot's - # DEPLOY_TAG bakes in the same value (a version-vs-DEPLOY_TAG identity test - # then holds by construction). Targets the first `version =` line, which is - # [package].version. - run: | - set -euo pipefail - sed -i -E "0,/^version[[:space:]]*=.*/s//version = \"${VERSION}\"/" foundry.toml - # Fail loud if the substitution matched nothing (no [package].version line - # to move) rather than silently releasing an unchanged version. - grep -qxE "version = \"${VERSION}\"" foundry.toml || { - echo "::error::foundry.toml has no [package] version line to set to ${VERSION}" >&2 - exit 1 - } - - name: Cut the release snapshot - # One call regenerates the rolling src/generated/candidate/ pins and - # freezes a copy of them as src/generated//, so there is no point - # between the two at which the record and the pins the release publishes - # can disagree. Deterministic: the pins are computed from bytecode - # (address = f(bytecode) under CREATE2), so this needs no chain access and - # produces the exact snapshot the release publishes and commits. The - # ordering, the version read, the append-only refusal and the guards - # around them all live in that call — LibRainDeploySnapshot.freeze, via - # BuildScript.cutRelease(). + - name: Regenerate the release snapshot (read-only determinism check) + # Option A lands the frozen src/generated// snapshot on main via a + # reviewed PR; this workflow does NOT cut it. To prove the tagged commit + # carries a snapshot that IS a fresh deterministic regeneration — not stale, + # hand-edited, or never cut — regenerate it here and let the publish guard + # below require a clean tree. The generator freezes a NEW / dir and + # may refuse to overwrite an existing one (e.g. cut-release.sh), so remove + # the committed dir first; git still holds it, and the guard checks presence + # against HEAD and diffs the regenerated tree against HEAD. Nothing is + # committed or pushed — the runner tree is thrown away. Deterministic from + # bytecode (address = f(bytecode) under CREATE2), so no chain access here; + # the on-chain attestation is a later step. env: # Via env, not interpolated into the script body: a workflow input # spliced into `run:` text is a template-injection surface. The @@ -217,29 +224,24 @@ jobs: SNAPSHOT_GENERATE_CMD: ${{ inputs.snapshot-generate-cmd }} # `set -euo pipefail` so a command written as `a; b` cannot hide a's # failure behind b's success. - run: nix develop github:rainlanguage/rainix/${{ env.RAINIX_SHA }}#sol-shell -c bash -c "set -euo pipefail; $SNAPSHOT_GENERATE_CMD" - - name: Commit the release snapshot - # Commit BEFORE the append-only gate and Soldeer push so both operate on a - # clean, inspectable tree. The commit is what lands on main below. run: | set -euo pipefail - # Stage the release: the foundry.toml version bump AND the regenerated - # snapshot (tracked edits + the new src/generated//). Do NOT - # `git checkout` first — that would revert the version bump and any - # regenerated tracked file, leaving a commit that misses them and breaks - # the version/snapshot lockstep. The one devShell leftover - # (.pre-commit-config.yaml) is already hidden via .git/info/exclude in the - # Git config step, so `git add -A` will not stage it. - git add -A - if git diff --cached --quiet; then - echo "::error::snapshot regeneration produced no changes for ${VERSION}; nothing to release" >&2 - exit 1 - fi - git commit --no-verify -m "Package Release: soldeer ${{ inputs.soldeer-package }} ${VERSION}" - - name: Enforce append-only snapshots - # The release must only ADD src/generated//; a frozen snapshot for - # an already-released tag must never change (consumers pin its constants). - uses: rainlanguage/rainix/.github/actions/frozen-snapshots-append-only@main + # VERSION is validated strict X.Y.Z above, so DIR is [0-9_]+. + DIR="${VERSION//./_}" + rm -rf "src/generated/${DIR}" + nix develop github:rainlanguage/rainix/${{ env.RAINIX_SHA }}#sol-shell -c bash -c "set -euo pipefail; $SNAPSHOT_GENERATE_CMD" + - name: Publish guard (fail-closed) + # The point of the push-free change: refuse to publish a tag whose commit + # did not actually cut this release's snapshot. All three must hold or the + # release fails loud and publishes NOTHING — foundry.toml [package].version + # == the tag version; src/generated// present in the tagged commit; + # and the regeneration above changed nothing (git status clean → the + # committed snapshot is a deterministic regeneration, not stale/hand-edited). + # Logic is Rust (rainix-static release-guard), unit-tested and + # mutation-covered; the append-only gate ran at PR time (rainix-sol-static). + # VERSION comes from $GITHUB_ENV, never a ${{ }} splice, so it cannot inject + # into the command. + run: nix develop github:rainlanguage/rainix/${{ env.RAINIX_SHA }}#sol-shell -c rainix-static release-guard --version "$VERSION" # The fork suite below rolls back to the freshly regenerated pins, so it # needs archive endpoints. Candidate URLs travel as env, never as `with:` # inputs — GitHub renders a composite's resolved inputs into the log and a @@ -275,28 +277,14 @@ jobs: # cannot do. run: nix develop github:rainlanguage/rainix/${{ env.RAINIX_SHA }}#sol-shell -c bash -c '${{ inputs.test-cmd }}' - name: Publish to Soldeer - # Pushes the working tree (== the release commit's tree, snapshot present) - # under the exact version the tag names. + # Pushes the working tree — the tagged commit's tree, whose snapshot the + # publish guard just verified is present and deterministic — under the exact + # version the tag names. No branch is written: main already carries this + # snapshot from the reviewed PR that cut it. env: SOLDEER_API_TOKEN: ${{ secrets.SOLDEER_API_TOKEN }} SOLDEER_PACKAGE: ${{ inputs.soldeer-package }} run: nix develop github:rainlanguage/rainix/${{ env.RAINIX_SHA }}#sol-shell -c forge soldeer push "$SOLDEER_PACKAGE~$VERSION" - - name: Commit the snapshot back to main - # main carries the CURRENT release's pins so the daily drift sweep has live - # constants to check. The tag was made on the main tip; rebase in any - # concurrent move first, then push the release commit to main. src/generated - # is append-only (a new / dir), so a concurrent release is the only - # thing that could conflict, and it fails loud rather than silently. - env: - MAIN: ${{ inputs.main-branch }} - run: | - set -euo pipefail - git fetch --no-tags origin "$MAIN" - if ! git rebase "origin/$MAIN"; then - echo "::error::release commit does not rebase cleanly onto origin/$MAIN (concurrent release?); resolve manually" >&2 - exit 1 - fi - git push origin "HEAD:$MAIN" - name: GitHub Release uses: rainlanguage/rainix/.github/actions/gh-release@main with: diff --git a/rainix-static/src/main.rs b/rainix-static/src/main.rs index a03ef20..613135f 100644 --- a/rainix-static/src/main.rs +++ b/rainix-static/src/main.rs @@ -57,12 +57,21 @@ // fails over instead of reddening every suite in the org. Candidates come // from the RAINIX_RPC_SECRET_ / RAINIX_RPC_VARS_ env vars merged // with hardcoded public archive defaults. Never prints a candidate URL. +// release-guard --version [--root ] [--foundry ] +// Fail-closed publish guard for rainix-tag-release's push-free deploy flow: +// refuse to publish a tag whose commit did not actually cut this release's +// snapshot. Verifies foundry.toml's [package].version equals the tag +// version, that // (dots→underscores) exists in the tagged +// commit, and — after the caller re-runs the snapshot generator on the tree +// — that `git status` is empty (the committed snapshot is a deterministic +// regeneration, not stale/hand-edited). Runs where git is on PATH. mod agent_context_cap; mod context_bytes; mod frozen_snapshots; mod no_submodules; mod prompt_cap; +mod release_guard; mod rpc_preflight; mod soldeer_gate; @@ -175,6 +184,13 @@ fn main() { } } } + "release-guard" => { + let version = flag(&args, "--version") + .unwrap_or_else(|| fail("release-guard: --version required")); + let root = flag(&args, "--root").unwrap_or_else(|| "src/generated".to_string()); + let foundry = flag(&args, "--foundry").unwrap_or_else(|| "foundry.toml".to_string()); + release_guard::run(&version, &root, &foundry); + } "rpc-preflight" => { let root = flag(&args, "--root").unwrap_or_else(|| ".".to_string()); // There is no stdout fallback on purpose: the selected URL may be @@ -202,7 +218,7 @@ fn main() { eprintln!( "rainix-static: unknown subcommand {other:?} \ (available: no-submodules, agent-context-cap, prompt-cap, \ - snapshots-append-only, soldeer-gate, rpc-preflight)" + snapshots-append-only, soldeer-gate, rpc-preflight, release-guard)" ); std::process::exit(2); } diff --git a/rainix-static/src/release_guard.rs b/rainix-static/src/release_guard.rs new file mode 100644 index 0000000..ee089b4 --- /dev/null +++ b/rainix-static/src/release_guard.rs @@ -0,0 +1,273 @@ +//! `release-guard` — fail-closed publish guard for `rainix-tag-release`. +//! +//! Under the push-free deploy-release flow (rainlanguage/rainix#338) the +//! workflow no longer cuts the snapshot itself: a reviewed PR lands the frozen +//! `src/generated//` snapshot and the `[package].version` bump on main, +//! and a human `sol-v` tag on that merged commit triggers publish. So +//! nothing in the workflow guarantees the tagged commit actually carries a +//! freshly-cut snapshot for this version — a mistag, a stale PR, or a hand-edited +//! snapshot would otherwise publish silently. This guard closes that hole: run +//! immediately before `forge soldeer push`, it fails loud (publishing NOTHING) +//! unless all three hold on the tagged commit: +//! +//! 1. Version identity — `foundry.toml`'s first `version = "…"` (the +//! `[package].version` the old flow used to write from the tag) equals the +//! tag's version. The bump happened in a PR the workflow did not control, so +//! the identity the old flow held "by construction" is now VERIFIED. +//! 2. Snapshot present — `src/generated//` exists in the tagged commit +//! (`` with `.` → `_`, matching the frozen-snapshot dir convention). +//! A missing dir means this version was never cut/frozen. +//! 3. Snapshot is a deterministic regeneration — the caller re-runs the repo's +//! `snapshot-generate-cmd` on the tagged tree (having first removed the +//! target dir, because a generator may refuse to overwrite a frozen dir), +//! then this guard requires `git status --porcelain` to be empty: any change +//! means the committed snapshot is stale, hand-edited, or was never +//! regenerated for this commit. Deterministic-from-bytecode, so no chain +//! access is needed; the on-chain attestation is a separate step. +//! +//! The version/dir checks read the tagged commit via git (`HEAD`), independent of +//! the working-tree regeneration; the determinism check reads the working tree +//! after regeneration. The decisions live in the small pure functions below so +//! they are unit-tested and mutation-covered; `run` only wires git and the +//! filesystem to them. + +use crate::fail; +use std::process::Command; + +/// True iff `v` is a strict `MAJOR.MINOR.PATCH` where each part is one or more +/// ASCII digits. Anything else (pre-release suffixes, extra components, empty +/// parts, non-digits) is rejected — the guard must not derive a snapshot dir +/// name from a version the frozen-snapshot append-only gate would ignore. +pub(crate) fn is_semver(v: &str) -> bool { + let parts: Vec<&str> = v.split('.').collect(); + parts.len() == 3 + && parts + .iter() + .all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit())) +} + +/// The frozen-snapshot directory name for a version: `.` → `_`, matching the +/// `is_tag` convention in `frozen_snapshots` (e.g. `0.1.5` → `0_1_5`). +pub(crate) fn version_dir(version: &str) -> String { + version.replace('.', "_") +} + +/// The value of `foundry.toml`'s first `version = "…"` line — the +/// `[package].version` the old tag-release flow wrote from the tag (deploy +/// repos put `[package]` first, so the first `version =` is it, the same line +/// that flow's `sed` targeted and `cut-release.sh`'s `grep -m1` reads). Matches +/// an optional-whitespace `version =` at the start of a line and returns the +/// text inside the first double-quoted string on it. `None` when no such line +/// exists (no version to verify against the tag). +pub(crate) fn foundry_version(content: &str) -> Option { + for line in content.lines() { + let t = line.trim_start(); + let Some(rest) = t.strip_prefix("version") else { + continue; + }; + // `version` must be followed by `=` (optionally after whitespace), not + // be a prefix of another key like `versionx`. + let rest = rest.trim_start(); + let Some(rest) = rest.strip_prefix('=') else { + continue; + }; + // First double-quoted string on the line is the value. + let after_open = rest.find('"')? + 1; + let close = rest[after_open..].find('"')? + after_open; + return Some(rest[after_open..close].to_string()); + } + None +} + +/// True iff `git ls-tree HEAD -- ` listed anything, i.e. the directory +/// exists in the tagged commit. Empty output means it does not. +pub(crate) fn dir_present(ls_tree_output: &str) -> bool { + !ls_tree_output.trim().is_empty() +} + +/// Working-tree changes reported by `git status --porcelain` as offender lines. +/// Any non-blank line is a change (modified, added, deleted, or untracked); an +/// empty result means the tree is clean. `.git/info/exclude` already hides the +/// devShell's generated `.pre-commit-config.yaml`, so it never appears here. +pub(crate) fn dirty_offenders(porcelain: &str) -> Vec { + porcelain + .lines() + .filter(|l| !l.trim().is_empty()) + .map(str::to_string) + .collect() +} + +/// Run a git command and return its stdout; fail loud (with stderr) on spawn +/// error or nonzero exit. +fn git_stdout(args: &[&str]) -> String { + let out = Command::new("git") + .args(args) + .output() + .unwrap_or_else(|e| fail(&format!("git {}: failed to spawn: {e}", args.join(" ")))); + if !out.status.success() { + fail(&format!( + "git {}: {} ({})", + args.join(" "), + String::from_utf8_lossy(&out.stderr).trim(), + out.status + )); + } + String::from_utf8_lossy(&out.stdout).into_owned() +} + +/// The publish guard. `version` is the tag's version, `root` the snapshot root +/// (default `src/generated`), `foundry` the manifest path (default +/// `foundry.toml`). Fails loud and exits nonzero on the first violated +/// invariant; prints `release-guard: clean …` and returns when all hold. +pub(crate) fn run(version: &str, root: &str, foundry: &str) { + // A non-semver version has no valid frozen-snapshot dir name; refuse before + // deriving one. + if !is_semver(version) { + fail(&format!( + "release-guard: tag version {version:?} is not MAJOR.MINOR.PATCH" + )); + } + + // 1. Version identity: foundry.toml's [package].version == the tag version. + let content = std::fs::read_to_string(foundry) + .unwrap_or_else(|e| fail(&format!("release-guard: cannot read {foundry}: {e}"))); + match foundry_version(&content) { + None => fail(&format!( + "release-guard: {foundry} has no `version = \"…\"` line to check against tag \ + version {version}" + )), + Some(v) if v != version => fail(&format!( + "release-guard: {foundry} version {v:?} does not match tag version {version:?} — \ + the release commit's version bump and the pushed tag must agree" + )), + Some(_) => {} + } + + // 2. Snapshot present in the tagged commit (read via HEAD, not the + // regenerated working tree, so a dir the generator just recreated cannot + // hide a commit that never carried it). + let dir = version_dir(version); + let path = format!("{root}/{dir}"); + let listed = git_stdout(&["ls-tree", "HEAD", "--", &path]); + if !dir_present(&listed) { + fail(&format!( + "release-guard: {path}/ is not present in the tagged commit — this version was \ + never cut/frozen; land the snapshot in a PR before tagging" + )); + } + + // 3. Deterministic regeneration: the caller already re-ran the generator on + // the tagged tree; any working-tree change means the committed snapshot is + // not what a fresh regeneration produces (stale, hand-edited, or never cut + // for this commit). + let porcelain = git_stdout(&["status", "--porcelain", "--untracked-files=all"]); + let offenders = dirty_offenders(&porcelain); + if !offenders.is_empty() { + eprintln!( + "::error::release-guard: re-running the snapshot generator changed the tree — the \ + committed snapshot for {version} is stale, hand-edited, or was never regenerated for \ + this commit. Regenerate it in a PR and re-tag the merged commit. Offending paths:" + ); + for o in offenders { + eprintln!(" {o}"); + } + std::process::exit(1); + } + + println!("release-guard: clean — {foundry} version, {path}/ present, snapshot deterministic"); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn semver_accepts_major_minor_patch() { + assert!(is_semver("0.1.5")); + assert!(is_semver("12.0.255")); + assert!(is_semver("0.0.0")); + } + + #[test] + fn semver_rejects_non_x_y_z() { + assert!(!is_semver("0.1")); // two parts + assert!(!is_semver("0.1.5.6")); // four parts + assert!(!is_semver("0.1.")); // empty trailing + assert!(!is_semver(".1.5")); // empty leading + assert!(!is_semver("0.1.5-rc1")); // pre-release suffix + assert!(!is_semver("v0.1.5")); // non-digit + assert!(!is_semver("0.1.x")); // non-digit + assert!(!is_semver("")); // empty + } + + #[test] + fn version_dir_dots_to_underscores() { + assert_eq!(version_dir("0.1.5"), "0_1_5"); + assert_eq!(version_dir("12.0.255"), "12_0_255"); + } + + #[test] + fn foundry_version_reads_first_version_line() { + let toml = "[package]\nname = \"rain-factory-deploy\"\nversion = \"0.1.5\"\n\n\ + [profile.default]\nsrc = 'src'\n"; + assert_eq!(foundry_version(toml).as_deref(), Some("0.1.5")); + } + + #[test] + fn foundry_version_tolerates_whitespace_variants() { + assert_eq!( + foundry_version("version=\"1.2.3\"").as_deref(), + Some("1.2.3") + ); + assert_eq!( + foundry_version(" version = \"1.2.3\" ").as_deref(), + Some("1.2.3") + ); + } + + #[test] + fn foundry_version_takes_the_first_when_several() { + // Only the first `version =` (the [package] line, first in deploy + // repos) is the release version; later ones must not shadow it. + let toml = "[package]\nversion = \"0.1.5\"\n\n[other]\nversion = \"9.9.9\"\n"; + assert_eq!(foundry_version(toml).as_deref(), Some("0.1.5")); + } + + #[test] + fn foundry_version_ignores_version_prefixed_keys_and_values() { + // `versionx` is a different key; a `version` inside a value is not a + // version line at column start. + let toml = "versionx = \"9.9.9\"\nname = \"version = 1.0.0\"\nversion = \"0.2.0\"\n"; + assert_eq!(foundry_version(toml).as_deref(), Some("0.2.0")); + } + + #[test] + fn foundry_version_none_when_absent() { + assert_eq!(foundry_version("[profile.default]\nsrc = 'src'\n"), None); + assert_eq!(foundry_version(""), None); + } + + #[test] + fn dir_present_true_only_on_nonempty_listing() { + assert!(dir_present("040000 tree abc123\tsrc/generated/0_1_5\n")); + assert!(!dir_present("")); + assert!(!dir_present(" \n \n")); + } + + #[test] + fn dirty_offenders_empty_tree_is_clean() { + assert!(dirty_offenders("").is_empty()); + assert!(dirty_offenders("\n\n \n").is_empty()); + } + + #[test] + fn dirty_offenders_lists_every_change() { + // A modified frozen snapshot and an untracked new file both count. + let porcelain = " M src/generated/0_1_5/CloneFactory.pointers.sol\n\ + ?? src/generated/0_1_5/Extra.pointers.sol\n"; + let off = dirty_offenders(porcelain); + assert_eq!(off.len(), 2); + assert!(off[0].contains("0_1_5/CloneFactory.pointers.sol")); + assert!(off[1].contains("0_1_5/Extra.pointers.sol")); + } +} From e46b10d404027e88f59b1bf166bd7b9f00528caf Mon Sep 17 00:00:00 2001 From: David Meister Date: Thu, 20 Aug 2026 09:54:23 +0000 Subject: [PATCH 2/4] ci: bump RAINIX_SHA to a1ef2ae (release-guard subcommand) So the pinned sol-shell carries the new rainix-static release-guard subcommand that rainix-tag-release calls. Co-Authored-By: Claude Fable 5 --- .github/workflows/rainix-autopublish.yaml | 2 +- .github/workflows/rainix-copy-artifacts.yaml | 2 +- .github/workflows/rainix-manual-sol-artifacts.yaml | 2 +- .github/workflows/rainix-manual-sol-verify.yaml | 2 +- .github/workflows/rainix-rs-static.yaml | 2 +- .github/workflows/rainix-rs-test.yaml | 2 +- .github/workflows/rainix-rs-wasm-test.yaml | 2 +- .github/workflows/rainix-rs-wasm.yaml | 2 +- .github/workflows/rainix-sol-legal.yaml | 2 +- .github/workflows/rainix-sol-static.yaml | 2 +- .github/workflows/rainix-sol-test.yaml | 2 +- .github/workflows/rainix-subgraph-test.yaml | 2 +- .github/workflows/rainix-tag-release.yaml | 2 +- 13 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/rainix-autopublish.yaml b/.github/workflows/rainix-autopublish.yaml index 7dee672..2d0ce00 100644 --- a/.github/workflows/rainix-autopublish.yaml +++ b/.github/workflows/rainix-autopublish.yaml @@ -49,7 +49,7 @@ on: SOLDEER_API_TOKEN: required: false env: - RAINIX_SHA: 5c00627bfaa83920c87596310be4c3f7b345b23d + RAINIX_SHA: a1ef2ae97e9f4617c75ae4a87e756f1a452c1009 jobs: release: if: ${{ !startsWith(github.event.head_commit.message, 'Package Release') }} diff --git a/.github/workflows/rainix-copy-artifacts.yaml b/.github/workflows/rainix-copy-artifacts.yaml index c3f3faa..ab67ed9 100644 --- a/.github/workflows/rainix-copy-artifacts.yaml +++ b/.github/workflows/rainix-copy-artifacts.yaml @@ -2,7 +2,7 @@ name: rainix-copy-artifacts on: workflow_call: env: - RAINIX_SHA: 5c00627bfaa83920c87596310be4c3f7b345b23d + RAINIX_SHA: a1ef2ae97e9f4617c75ae4a87e756f1a452c1009 jobs: copy-artifacts: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-manual-sol-artifacts.yaml b/.github/workflows/rainix-manual-sol-artifacts.yaml index 4973509..f37eafe 100644 --- a/.github/workflows/rainix-manual-sol-artifacts.yaml +++ b/.github/workflows/rainix-manual-sol-artifacts.yaml @@ -83,7 +83,7 @@ on: CI_DEPLOY_FLARE_ETHERSCAN_API_KEY: required: false env: - RAINIX_SHA: 5c00627bfaa83920c87596310be4c3f7b345b23d + RAINIX_SHA: a1ef2ae97e9f4617c75ae4a87e756f1a452c1009 jobs: deploy: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-manual-sol-verify.yaml b/.github/workflows/rainix-manual-sol-verify.yaml index 06f1509..31bdc33 100644 --- a/.github/workflows/rainix-manual-sol-verify.yaml +++ b/.github/workflows/rainix-manual-sol-verify.yaml @@ -65,7 +65,7 @@ on: CI_DEPLOY_FLARE_ETHERSCAN_API_KEY: required: false env: - RAINIX_SHA: 5c00627bfaa83920c87596310be4c3f7b345b23d + RAINIX_SHA: a1ef2ae97e9f4617c75ae4a87e756f1a452c1009 jobs: verify: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-rs-static.yaml b/.github/workflows/rainix-rs-static.yaml index 2fdbfc5..0380556 100644 --- a/.github/workflows/rainix-rs-static.yaml +++ b/.github/workflows/rainix-rs-static.yaml @@ -2,7 +2,7 @@ name: rainix-rs-static on: workflow_call: env: - RAINIX_SHA: 5c00627bfaa83920c87596310be4c3f7b345b23d + RAINIX_SHA: a1ef2ae97e9f4617c75ae4a87e756f1a452c1009 jobs: rs-static: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-rs-test.yaml b/.github/workflows/rainix-rs-test.yaml index 4346ba4..5b4bbb4 100644 --- a/.github/workflows/rainix-rs-test.yaml +++ b/.github/workflows/rainix-rs-test.yaml @@ -2,7 +2,7 @@ name: rainix-rs-test on: workflow_call: env: - RAINIX_SHA: 5c00627bfaa83920c87596310be4c3f7b345b23d + RAINIX_SHA: a1ef2ae97e9f4617c75ae4a87e756f1a452c1009 jobs: rs-test: strategy: diff --git a/.github/workflows/rainix-rs-wasm-test.yaml b/.github/workflows/rainix-rs-wasm-test.yaml index da4b640..f349208 100644 --- a/.github/workflows/rainix-rs-wasm-test.yaml +++ b/.github/workflows/rainix-rs-wasm-test.yaml @@ -2,7 +2,7 @@ name: rainix-rs-wasm-test on: workflow_call: env: - RAINIX_SHA: 5c00627bfaa83920c87596310be4c3f7b345b23d + RAINIX_SHA: a1ef2ae97e9f4617c75ae4a87e756f1a452c1009 jobs: rs-wasm-test: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-rs-wasm.yaml b/.github/workflows/rainix-rs-wasm.yaml index 12240a1..0e7da83 100644 --- a/.github/workflows/rainix-rs-wasm.yaml +++ b/.github/workflows/rainix-rs-wasm.yaml @@ -2,7 +2,7 @@ name: rainix-rs-wasm on: workflow_call: env: - RAINIX_SHA: 5c00627bfaa83920c87596310be4c3f7b345b23d + RAINIX_SHA: a1ef2ae97e9f4617c75ae4a87e756f1a452c1009 jobs: rs-wasm: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-sol-legal.yaml b/.github/workflows/rainix-sol-legal.yaml index 01af785..8a4ed97 100644 --- a/.github/workflows/rainix-sol-legal.yaml +++ b/.github/workflows/rainix-sol-legal.yaml @@ -2,7 +2,7 @@ name: rainix-sol-legal on: workflow_call: env: - RAINIX_SHA: 5c00627bfaa83920c87596310be4c3f7b345b23d + RAINIX_SHA: a1ef2ae97e9f4617c75ae4a87e756f1a452c1009 jobs: legal: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-sol-static.yaml b/.github/workflows/rainix-sol-static.yaml index 5076c4a..3c87260 100644 --- a/.github/workflows/rainix-sol-static.yaml +++ b/.github/workflows/rainix-sol-static.yaml @@ -2,7 +2,7 @@ name: rainix-sol-static on: workflow_call: env: - RAINIX_SHA: 5c00627bfaa83920c87596310be4c3f7b345b23d + RAINIX_SHA: a1ef2ae97e9f4617c75ae4a87e756f1a452c1009 jobs: static: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-sol-test.yaml b/.github/workflows/rainix-sol-test.yaml index abc112e..85fc610 100644 --- a/.github/workflows/rainix-sol-test.yaml +++ b/.github/workflows/rainix-sol-test.yaml @@ -23,7 +23,7 @@ on: RPC_URL_POLYGON_FORK: required: false env: - RAINIX_SHA: 5c00627bfaa83920c87596310be4c3f7b345b23d + RAINIX_SHA: a1ef2ae97e9f4617c75ae4a87e756f1a452c1009 jobs: test: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-subgraph-test.yaml b/.github/workflows/rainix-subgraph-test.yaml index 503e6d1..0a9a397 100644 --- a/.github/workflows/rainix-subgraph-test.yaml +++ b/.github/workflows/rainix-subgraph-test.yaml @@ -2,7 +2,7 @@ name: rainix-subgraph-test on: workflow_call: env: - RAINIX_SHA: 5c00627bfaa83920c87596310be4c3f7b345b23d + RAINIX_SHA: a1ef2ae97e9f4617c75ae4a87e756f1a452c1009 jobs: subgraph-test: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-tag-release.yaml b/.github/workflows/rainix-tag-release.yaml index 62b22e7..1bc6640 100644 --- a/.github/workflows/rainix-tag-release.yaml +++ b/.github/workflows/rainix-tag-release.yaml @@ -126,7 +126,7 @@ on: RPC_URL_POLYGON_FORK: required: false env: - RAINIX_SHA: 5c00627bfaa83920c87596310be4c3f7b345b23d + RAINIX_SHA: a1ef2ae97e9f4617c75ae4a87e756f1a452c1009 jobs: # The release tag must point at a commit already merged to the release branch. # `on: push: tags` fires for ANY tag, including one cut from an unmerged branch; From 864816f68b114f7596f8e1f6f00315ef48783072 Mon Sep 17 00:00:00 2001 From: David Meister Date: Thu, 20 Aug 2026 10:05:27 +0000 Subject: [PATCH 3/4] fix(release-guard): run guard last + parse the [package] table specifically (CodeRabbit) - Move the regenerate + publish-guard steps to after the fork suite and immediately before Publish to Soldeer, so the determinism check covers any tracked file test-cmd may touch and nothing runs between the guard and the push. - foundry_version now tracks the active TOML table and reads the version only from [package]/[external.package], so a version key in an earlier table cannot be mistaken for the release version. Adds discriminating tests. Co-Authored-By: Claude Fable 5 --- .github/workflows/rainix-tag-release.yaml | 87 ++++++++++++----------- rainix-static/src/release_guard.rs | 71 +++++++++++++----- 2 files changed, 100 insertions(+), 58 deletions(-) diff --git a/.github/workflows/rainix-tag-release.yaml b/.github/workflows/rainix-tag-release.yaml index 1bc6640..c2ac0f8 100644 --- a/.github/workflows/rainix-tag-release.yaml +++ b/.github/workflows/rainix-tag-release.yaml @@ -205,47 +205,9 @@ jobs: fi echo "VERSION=$VERSION" >> "$GITHUB_ENV" echo "Release version: $VERSION" - - name: Regenerate the release snapshot (read-only determinism check) - # Option A lands the frozen src/generated// snapshot on main via a - # reviewed PR; this workflow does NOT cut it. To prove the tagged commit - # carries a snapshot that IS a fresh deterministic regeneration — not stale, - # hand-edited, or never cut — regenerate it here and let the publish guard - # below require a clean tree. The generator freezes a NEW / dir and - # may refuse to overwrite an existing one (e.g. cut-release.sh), so remove - # the committed dir first; git still holds it, and the guard checks presence - # against HEAD and diffs the regenerated tree against HEAD. Nothing is - # committed or pushed — the runner tree is thrown away. Deterministic from - # bytecode (address = f(bytecode) under CREATE2), so no chain access here; - # the on-chain attestation is a later step. - env: - # Via env, not interpolated into the script body: a workflow input - # spliced into `run:` text is a template-injection surface. The - # expansion is one word, which bash then reads as the script. - SNAPSHOT_GENERATE_CMD: ${{ inputs.snapshot-generate-cmd }} - # `set -euo pipefail` so a command written as `a; b` cannot hide a's - # failure behind b's success. - run: | - set -euo pipefail - # VERSION is validated strict X.Y.Z above, so DIR is [0-9_]+. - DIR="${VERSION//./_}" - rm -rf "src/generated/${DIR}" - nix develop github:rainlanguage/rainix/${{ env.RAINIX_SHA }}#sol-shell -c bash -c "set -euo pipefail; $SNAPSHOT_GENERATE_CMD" - - name: Publish guard (fail-closed) - # The point of the push-free change: refuse to publish a tag whose commit - # did not actually cut this release's snapshot. All three must hold or the - # release fails loud and publishes NOTHING — foundry.toml [package].version - # == the tag version; src/generated// present in the tagged commit; - # and the regeneration above changed nothing (git status clean → the - # committed snapshot is a deterministic regeneration, not stale/hand-edited). - # Logic is Rust (rainix-static release-guard), unit-tested and - # mutation-covered; the append-only gate ran at PR time (rainix-sol-static). - # VERSION comes from $GITHUB_ENV, never a ${{ }} splice, so it cannot inject - # into the command. - run: nix develop github:rainlanguage/rainix/${{ env.RAINIX_SHA }}#sol-shell -c rainix-static release-guard --version "$VERSION" - # The fork suite below rolls back to the freshly regenerated pins, so it - # needs archive endpoints. Candidate URLs travel as env, never as `with:` - # inputs — GitHub renders a composite's resolved inputs into the log and a - # keyed URL embeds its API key. + # The fork suite below needs archive endpoints. Candidate URLs travel as env, + # never as `with:` inputs — GitHub renders a composite's resolved inputs into + # the log and a keyed URL embeds its API key. - name: RPC preflight uses: rainlanguage/rainix/.github/actions/rpc-preflight@main env: @@ -276,6 +238,49 @@ jobs: # candidate that is healthy right now, which a static one-URL mapping # cannot do. run: nix develop github:rainlanguage/rainix/${{ env.RAINIX_SHA }}#sol-shell -c bash -c '${{ inputs.test-cmd }}' + - name: Regenerate the release snapshot (read-only determinism check) + # Option A lands the frozen src/generated// snapshot on main via a + # reviewed PR; this workflow does NOT cut it. To prove the tagged commit + # carries a snapshot that IS a fresh deterministic regeneration — not stale, + # hand-edited, or never cut — regenerate it here and let the publish guard + # below require a clean tree. The generator freezes a NEW / dir and + # may refuse to overwrite an existing one (e.g. cut-release.sh), so remove + # the committed dir first; git still holds it, and the guard checks presence + # against HEAD and diffs the regenerated tree against HEAD. Nothing is + # committed or pushed — the runner tree is thrown away. Deterministic from + # bytecode (address = f(bytecode) under CREATE2), so no chain access here; + # the on-chain attestation is the fork suite above. + # + # This runs AFTER the fork suite (test-cmd), immediately before publish, so + # the guard's clean-tree check covers any tracked file the suite may have + # touched too — nothing runs between the guard and `forge soldeer push`, so + # what publishes is exactly what the guard verified. + env: + # Via env, not interpolated into the script body: a workflow input + # spliced into `run:` text is a template-injection surface. The + # expansion is one word, which bash then reads as the script. + SNAPSHOT_GENERATE_CMD: ${{ inputs.snapshot-generate-cmd }} + # `set -euo pipefail` so a command written as `a; b` cannot hide a's + # failure behind b's success. + run: | + set -euo pipefail + # VERSION is validated strict X.Y.Z above, so DIR is [0-9_]+. + DIR="${VERSION//./_}" + rm -rf "src/generated/${DIR}" + nix develop github:rainlanguage/rainix/${{ env.RAINIX_SHA }}#sol-shell -c bash -c "set -euo pipefail; $SNAPSHOT_GENERATE_CMD" + - name: Publish guard (fail-closed) + # The point of the push-free change: refuse to publish a tag whose commit + # did not actually cut this release's snapshot. Runs immediately before + # publish so what it verifies is what publishes. All three must hold or the + # release fails loud and publishes NOTHING — foundry.toml [package].version + # == the tag version; src/generated// present in the tagged commit; + # and the regeneration above changed nothing (git status clean → the + # committed snapshot is a deterministic regeneration, not stale/hand-edited, + # and the fork suite left no tracked file dirty). Logic is Rust (rainix-static + # release-guard), unit-tested and mutation-covered; the append-only gate ran + # at PR time (rainix-sol-static). VERSION comes from $GITHUB_ENV, never a + # ${{ }} splice, so it cannot inject into the command. + run: nix develop github:rainlanguage/rainix/${{ env.RAINIX_SHA }}#sol-shell -c rainix-static release-guard --version "$VERSION" - name: Publish to Soldeer # Pushes the working tree — the tagged commit's tree, whose snapshot the # publish guard just verified is present and deterministic — under the exact diff --git a/rainix-static/src/release_guard.rs b/rainix-static/src/release_guard.rs index ee089b4..f6f8939 100644 --- a/rainix-static/src/release_guard.rs +++ b/rainix-static/src/release_guard.rs @@ -52,16 +52,29 @@ pub(crate) fn version_dir(version: &str) -> String { version.replace('.', "_") } -/// The value of `foundry.toml`'s first `version = "…"` line — the -/// `[package].version` the old tag-release flow wrote from the tag (deploy -/// repos put `[package]` first, so the first `version =` is it, the same line -/// that flow's `sed` targeted and `cut-release.sh`'s `grep -m1` reads). Matches -/// an optional-whitespace `version =` at the start of a line and returns the -/// text inside the first double-quoted string on it. `None` when no such line -/// exists (no version to verify against the tag). +/// The `version = "…"` value inside `foundry.toml`'s package-metadata table — +/// `[package]` (legacy) or `[external.package]` (current deploy-repo form), the +/// table the release version lives in. Tracks the active TOML table header and +/// returns the version ONLY when inside that table, so a `version =` in some +/// other table earlier in the file (e.g. a tool section) can never be mistaken +/// for the release version. `None` when the package table has no `version =` +/// line (nothing to verify against the tag). A bare-string TOML header check is +/// enough here: foundry.toml is machine-shaped and these two headers sit at +/// column 0; the guard fails closed (no version found) on anything exotic. pub(crate) fn foundry_version(content: &str) -> Option { + let mut in_package = false; for line in content.lines() { - let t = line.trim_start(); + let t = line.trim(); + // A table header switches the active section. Only `[package]` / + // `[external.package]` are the release-version table; any other header + // (including `[package.metadata.*]` subtables) leaves it. + if t.starts_with('[') && t.ends_with(']') { + in_package = t == "[package]" || t == "[external.package]"; + continue; + } + if !in_package { + continue; + } let Some(rest) = t.strip_prefix("version") else { continue; }; @@ -216,34 +229,58 @@ mod tests { #[test] fn foundry_version_tolerates_whitespace_variants() { assert_eq!( - foundry_version("version=\"1.2.3\"").as_deref(), + foundry_version("[package]\nversion=\"1.2.3\"").as_deref(), Some("1.2.3") ); assert_eq!( - foundry_version(" version = \"1.2.3\" ").as_deref(), + foundry_version("[package]\n version = \"1.2.3\" ").as_deref(), Some("1.2.3") ); } #[test] - fn foundry_version_takes_the_first_when_several() { - // Only the first `version =` (the [package] line, first in deploy - // repos) is the release version; later ones must not shadow it. + fn foundry_version_reads_external_package_table() { + // Current deploy-repo form: `[external.package]` with a comment block + // between the header and the version line. + let toml = "[external.package]\nname = \"rain-extrospection-deploy\"\n\ + # version of the LAST publish\nversion = \"0.1.0\"\n"; + assert_eq!(foundry_version(toml).as_deref(), Some("0.1.0")); + } + + #[test] + fn foundry_version_ignores_version_in_an_earlier_table() { + // A `version` in a table BEFORE the package table must NOT shadow the + // real release version: otherwise the guard could match a foreign + // version to the tag while [package].version differs, and publish a + // package whose manifest version does not match the tag. + let toml = "[tool.whatever]\nversion = \"9.9.9\"\n\n\ + [package]\nname = \"pkg\"\nversion = \"0.1.5\"\n"; + assert_eq!(foundry_version(toml).as_deref(), Some("0.1.5")); + } + + #[test] + fn foundry_version_ignores_version_in_a_later_table() { + // Symmetric: a `version` in a table AFTER [package] must not be read + // either — only the package table's own version counts. let toml = "[package]\nversion = \"0.1.5\"\n\n[other]\nversion = \"9.9.9\"\n"; assert_eq!(foundry_version(toml).as_deref(), Some("0.1.5")); } #[test] fn foundry_version_ignores_version_prefixed_keys_and_values() { - // `versionx` is a different key; a `version` inside a value is not a - // version line at column start. - let toml = "versionx = \"9.9.9\"\nname = \"version = 1.0.0\"\nversion = \"0.2.0\"\n"; + // Inside [package]: `versionx` is a different key; a `version` inside a + // value is not a version line at column start. + let toml = "[package]\nversionx = \"9.9.9\"\nname = \"version = 1.0.0\"\n\ + version = \"0.2.0\"\n"; assert_eq!(foundry_version(toml).as_deref(), Some("0.2.0")); } #[test] - fn foundry_version_none_when_absent() { + fn foundry_version_none_when_no_package_table() { + // No package table at all, and a version line outside one, are both + // "no release version" — the guard fails closed. assert_eq!(foundry_version("[profile.default]\nsrc = 'src'\n"), None); + assert_eq!(foundry_version("version = \"1.2.3\"\n"), None); assert_eq!(foundry_version(""), None); } From 17dc9c5bc62becbe0828ff71754076d8fd5da2c8 Mon Sep 17 00:00:00 2001 From: David Meister Date: Thu, 20 Aug 2026 10:05:27 +0000 Subject: [PATCH 4/4] ci: bump RAINIX_SHA to 864816f68b114f7596f8e1f6f00315ef48783072 (guard fixes) Co-Authored-By: Claude Fable 5 --- .github/workflows/rainix-autopublish.yaml | 2 +- .github/workflows/rainix-copy-artifacts.yaml | 2 +- .github/workflows/rainix-manual-sol-artifacts.yaml | 2 +- .github/workflows/rainix-manual-sol-verify.yaml | 2 +- .github/workflows/rainix-rs-static.yaml | 2 +- .github/workflows/rainix-rs-test.yaml | 2 +- .github/workflows/rainix-rs-wasm-test.yaml | 2 +- .github/workflows/rainix-rs-wasm.yaml | 2 +- .github/workflows/rainix-sol-legal.yaml | 2 +- .github/workflows/rainix-sol-static.yaml | 2 +- .github/workflows/rainix-sol-test.yaml | 2 +- .github/workflows/rainix-subgraph-test.yaml | 2 +- .github/workflows/rainix-tag-release.yaml | 2 +- 13 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/rainix-autopublish.yaml b/.github/workflows/rainix-autopublish.yaml index 2d0ce00..821a217 100644 --- a/.github/workflows/rainix-autopublish.yaml +++ b/.github/workflows/rainix-autopublish.yaml @@ -49,7 +49,7 @@ on: SOLDEER_API_TOKEN: required: false env: - RAINIX_SHA: a1ef2ae97e9f4617c75ae4a87e756f1a452c1009 + RAINIX_SHA: 864816f68b114f7596f8e1f6f00315ef48783072 jobs: release: if: ${{ !startsWith(github.event.head_commit.message, 'Package Release') }} diff --git a/.github/workflows/rainix-copy-artifacts.yaml b/.github/workflows/rainix-copy-artifacts.yaml index ab67ed9..7a29810 100644 --- a/.github/workflows/rainix-copy-artifacts.yaml +++ b/.github/workflows/rainix-copy-artifacts.yaml @@ -2,7 +2,7 @@ name: rainix-copy-artifacts on: workflow_call: env: - RAINIX_SHA: a1ef2ae97e9f4617c75ae4a87e756f1a452c1009 + RAINIX_SHA: 864816f68b114f7596f8e1f6f00315ef48783072 jobs: copy-artifacts: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-manual-sol-artifacts.yaml b/.github/workflows/rainix-manual-sol-artifacts.yaml index f37eafe..eae49d8 100644 --- a/.github/workflows/rainix-manual-sol-artifacts.yaml +++ b/.github/workflows/rainix-manual-sol-artifacts.yaml @@ -83,7 +83,7 @@ on: CI_DEPLOY_FLARE_ETHERSCAN_API_KEY: required: false env: - RAINIX_SHA: a1ef2ae97e9f4617c75ae4a87e756f1a452c1009 + RAINIX_SHA: 864816f68b114f7596f8e1f6f00315ef48783072 jobs: deploy: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-manual-sol-verify.yaml b/.github/workflows/rainix-manual-sol-verify.yaml index 31bdc33..80f9fe6 100644 --- a/.github/workflows/rainix-manual-sol-verify.yaml +++ b/.github/workflows/rainix-manual-sol-verify.yaml @@ -65,7 +65,7 @@ on: CI_DEPLOY_FLARE_ETHERSCAN_API_KEY: required: false env: - RAINIX_SHA: a1ef2ae97e9f4617c75ae4a87e756f1a452c1009 + RAINIX_SHA: 864816f68b114f7596f8e1f6f00315ef48783072 jobs: verify: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-rs-static.yaml b/.github/workflows/rainix-rs-static.yaml index 0380556..46efb68 100644 --- a/.github/workflows/rainix-rs-static.yaml +++ b/.github/workflows/rainix-rs-static.yaml @@ -2,7 +2,7 @@ name: rainix-rs-static on: workflow_call: env: - RAINIX_SHA: a1ef2ae97e9f4617c75ae4a87e756f1a452c1009 + RAINIX_SHA: 864816f68b114f7596f8e1f6f00315ef48783072 jobs: rs-static: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-rs-test.yaml b/.github/workflows/rainix-rs-test.yaml index 5b4bbb4..e9d0b49 100644 --- a/.github/workflows/rainix-rs-test.yaml +++ b/.github/workflows/rainix-rs-test.yaml @@ -2,7 +2,7 @@ name: rainix-rs-test on: workflow_call: env: - RAINIX_SHA: a1ef2ae97e9f4617c75ae4a87e756f1a452c1009 + RAINIX_SHA: 864816f68b114f7596f8e1f6f00315ef48783072 jobs: rs-test: strategy: diff --git a/.github/workflows/rainix-rs-wasm-test.yaml b/.github/workflows/rainix-rs-wasm-test.yaml index f349208..9cc2a9b 100644 --- a/.github/workflows/rainix-rs-wasm-test.yaml +++ b/.github/workflows/rainix-rs-wasm-test.yaml @@ -2,7 +2,7 @@ name: rainix-rs-wasm-test on: workflow_call: env: - RAINIX_SHA: a1ef2ae97e9f4617c75ae4a87e756f1a452c1009 + RAINIX_SHA: 864816f68b114f7596f8e1f6f00315ef48783072 jobs: rs-wasm-test: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-rs-wasm.yaml b/.github/workflows/rainix-rs-wasm.yaml index 0e7da83..f4f6f35 100644 --- a/.github/workflows/rainix-rs-wasm.yaml +++ b/.github/workflows/rainix-rs-wasm.yaml @@ -2,7 +2,7 @@ name: rainix-rs-wasm on: workflow_call: env: - RAINIX_SHA: a1ef2ae97e9f4617c75ae4a87e756f1a452c1009 + RAINIX_SHA: 864816f68b114f7596f8e1f6f00315ef48783072 jobs: rs-wasm: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-sol-legal.yaml b/.github/workflows/rainix-sol-legal.yaml index 8a4ed97..a641279 100644 --- a/.github/workflows/rainix-sol-legal.yaml +++ b/.github/workflows/rainix-sol-legal.yaml @@ -2,7 +2,7 @@ name: rainix-sol-legal on: workflow_call: env: - RAINIX_SHA: a1ef2ae97e9f4617c75ae4a87e756f1a452c1009 + RAINIX_SHA: 864816f68b114f7596f8e1f6f00315ef48783072 jobs: legal: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-sol-static.yaml b/.github/workflows/rainix-sol-static.yaml index 3c87260..1dd4a3f 100644 --- a/.github/workflows/rainix-sol-static.yaml +++ b/.github/workflows/rainix-sol-static.yaml @@ -2,7 +2,7 @@ name: rainix-sol-static on: workflow_call: env: - RAINIX_SHA: a1ef2ae97e9f4617c75ae4a87e756f1a452c1009 + RAINIX_SHA: 864816f68b114f7596f8e1f6f00315ef48783072 jobs: static: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-sol-test.yaml b/.github/workflows/rainix-sol-test.yaml index 85fc610..ca26d19 100644 --- a/.github/workflows/rainix-sol-test.yaml +++ b/.github/workflows/rainix-sol-test.yaml @@ -23,7 +23,7 @@ on: RPC_URL_POLYGON_FORK: required: false env: - RAINIX_SHA: a1ef2ae97e9f4617c75ae4a87e756f1a452c1009 + RAINIX_SHA: 864816f68b114f7596f8e1f6f00315ef48783072 jobs: test: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-subgraph-test.yaml b/.github/workflows/rainix-subgraph-test.yaml index 0a9a397..9fbbef8 100644 --- a/.github/workflows/rainix-subgraph-test.yaml +++ b/.github/workflows/rainix-subgraph-test.yaml @@ -2,7 +2,7 @@ name: rainix-subgraph-test on: workflow_call: env: - RAINIX_SHA: a1ef2ae97e9f4617c75ae4a87e756f1a452c1009 + RAINIX_SHA: 864816f68b114f7596f8e1f6f00315ef48783072 jobs: subgraph-test: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-tag-release.yaml b/.github/workflows/rainix-tag-release.yaml index c2ac0f8..629778a 100644 --- a/.github/workflows/rainix-tag-release.yaml +++ b/.github/workflows/rainix-tag-release.yaml @@ -126,7 +126,7 @@ on: RPC_URL_POLYGON_FORK: required: false env: - RAINIX_SHA: a1ef2ae97e9f4617c75ae4a87e756f1a452c1009 + RAINIX_SHA: 864816f68b114f7596f8e1f6f00315ef48783072 jobs: # The release tag must point at a commit already merged to the release branch. # `on: push: tags` fires for ANY tag, including one cut from an unmerged branch;