From 3a7f7da98155548025381433640cb0d2d8f6a8d7 Mon Sep 17 00:00:00 2001 From: David Meister Date: Thu, 13 Aug 2026 11:22:48 +0000 Subject: [PATCH 1/5] rainix-static: own the deploy-repo release freeze (cut-release) --- .github/actions/cut-release/action.yaml | 31 ++ .github/workflows/rainix-tag-release.yaml | 35 +- rainix-static/src/cut_release.rs | 476 ++++++++++++++++++++++ rainix-static/src/main.rs | 27 +- rainix-static/src/soldeer_gate.rs | 4 +- 5 files changed, 563 insertions(+), 10 deletions(-) create mode 100644 .github/actions/cut-release/action.yaml create mode 100644 rainix-static/src/cut_release.rs diff --git a/.github/actions/cut-release/action.yaml b/.github/actions/cut-release/action.yaml new file mode 100644 index 0000000..ad48d81 --- /dev/null +++ b/.github/actions/cut-release/action.yaml @@ -0,0 +1,31 @@ +name: cut-release +description: >- + Cuts a deploy repo's release snapshot: regenerates the rolling src/generated/candidate/ pins from the current source, formats, and only then freezes a byte-identical copy as src/generated// for the foundry.toml [package].version. The ordering is the reason this is a tool and not a consumer script — freezing before regenerating writes a stale candidate into a dir the frozen-snapshots-append-only gate protects forever while the regeneration moves candidate on, so the release publishes one address and permanently records another, and nothing downstream compares the two. Refuses a version that is not strict X.Y.Z (its tag dir would be invisible to that gate — an orphan snapshot nothing protects), a missing or empty candidate, and any tag dir that already exists. +inputs: + generate-cmd: + description: >- + Command that regenerates src/generated/candidate/ from the current source. Empty (the default) uses the org convention, `forge script ./script/BuildPointers.sol`. This is the ONLY repo-specific part of the freeze; everything else — the version read, the guards, the format, the copy and the byte-identity assertion — is the same everywhere and lives in rainix-static. It runs strictly BEFORE the freeze, so the inverted order cannot be expressed through it. + required: false + default: '' +runs: + using: composite + steps: + - name: Cut the release snapshot + shell: bash + env: + # Via env, not interpolated into the script body: a `with:` value spliced + # into `run:` text is a template-injection surface, and the binary reads + # it as a single argv element either way. + RAINIX_GENERATE_CMD: ${{ inputs.generate-cmd }} + run: | + set -euo pipefail + # Single source of truth: the Rust rainix-static binary (its unit tests + # run inside the nix build). The path: flake ref runs it — and the forge + # it drives, from the same sol-shell — out of this composite's own + # checkout, so the freeze logic always matches the action version + # regardless of any RAINIX_SHA the caller pins, and a new subcommand is + # usable the moment the action lands rather than after a sha bump. A + # path: ref also makes no api.github.com call, so it cannot hit the 429 + # that pinning exists to avoid. + nix develop "path:$(cd "$GITHUB_ACTION_PATH/../../.." && pwd)#sol-shell" -c \ + rainix-static cut-release --generate-cmd "$RAINIX_GENERATE_CMD" diff --git a/.github/workflows/rainix-tag-release.yaml b/.github/workflows/rainix-tag-release.yaml index 7406607..d46c2c8 100644 --- a/.github/workflows/rainix-tag-release.yaml +++ b/.github/workflows/rainix-tag-release.yaml @@ -54,11 +54,18 @@ on: required: false type: string default: sol-v + pointers-generate-cmd: + description: >- + Command that regenerates the rolling `src/generated/candidate/` pins from the current source, e.g. `forge script ./script/BuildPointers.sol` — which is also the default when this is empty, so a repo following the convention passes nothing. This is the ONLY repo-specific part of a release freeze: the version read, the guards, the format, the copy into `src/generated//` and the byte-identity assertion are identical everywhere and live in `rainix-static cut-release`. It is deliberately a GENERATE command, not a freeze script — it runs strictly before the freeze, so the freeze-then-regenerate order (which permanently records an address the release does not publish) cannot be expressed. + required: false + type: string + default: '' snapshot-generate-cmd: description: >- - Command that regenerates the deploy-pin snapshot from the (deterministic) bytecode into src/generated//, DEPLOY_TAG and any pointer libs, then formats. Run after [package].version is set to the release version, so the generated tag matches it. e.g. `forge script ./script/BuildPointers.sol && forge fmt`. - required: true + DEPRECATED / no-op. Freezing the release snapshot is no longer a consumer-supplied shell string — it is `rainix-static cut-release`, which owns the regenerate -> format -> freeze order rather than documenting it, because the inverted order silently freezes a stale `candidate` into an append-only dir while regeneration moves `candidate` on, publishing one address and permanently recording another. Retained only so existing callers that still pass it do not error; it is ignored and will be removed. Consumers should drop it, and pass `pointers-generate-cmd` instead if their pointer script is not at the conventional path. + required: false type: string + default: '' test-cmd: description: >- Pre-publish verification gate. Run against the regenerated snapshot; for a deploy repo this is the fork suite that reads the live chain and asserts it matches the fresh pins, so a release that snapshots addresses the chain does not actually carry fails loud BEFORE publishing. Default `forge test`. @@ -196,11 +203,25 @@ jobs: echo "::error::foundry.toml has no [package] version line to set to ${VERSION}" >&2 exit 1 } - - name: Regenerate the deploy-pin snapshot - # Deterministic: the pins are computed from bytecode (address = f(bytecode) - # under CREATE2), so this needs no chain access and produces the exact - # src/generated// the release publishes and commits. - run: nix develop github:rainlanguage/rainix/${{ env.RAINIX_SHA }}#sol-shell -c bash -c '${{ inputs.snapshot-generate-cmd }}' + - name: Cut the release snapshot + # Regenerate the rolling candidate pins, format, then freeze a + # byte-identical copy as src/generated//. 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 ORDER is the reason this is a rainix tool rather than a consumer + # script (rainlanguage/rainix#301). Freezing before regenerating writes a + # drifted candidate into an append-only dir while the regeneration moves + # candidate on, so the release publishes one address and permanently + # records another — invisible downstream, because the repo's + # self-consistency test checks the REGENERATED candidate against source and + # nothing compares a numbered dir to candidate. The consumer supplies only + # the generate command, which runs strictly first, so the inversion has + # nowhere to live. + uses: rainlanguage/rainix/.github/actions/cut-release@main + with: + generate-cmd: ${{ inputs.pointers-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. diff --git a/rainix-static/src/cut_release.rs b/rainix-static/src/cut_release.rs new file mode 100644 index 0000000..094b57c --- /dev/null +++ b/rainix-static/src/cut_release.rs @@ -0,0 +1,476 @@ +//! `cut-release` — freeze a deploy repo's rolling `candidate` snapshot as the +//! numbered release snapshot the pushed tag names. +//! +//! Model: `/candidate/` is the rolling snapshot of what the current source +//! compiles to (rewritten in full by every pointer-generation run, and aliased by +//! the consumer-facing pin lib, so it is what a release actually publishes). A +//! numbered snapshot (`0_1_5/`, …) is a FROZEN copy of `candidate` taken at the +//! instant a tag releases it — it never changes again, which +//! `snapshots-append-only` enforces. +//! +//! The ordering is the whole point of this living in a tool. Regenerating AFTER +//! the copy is silently wrong whenever the committed `candidate` has drifted from +//! source: the copy freezes the stale bytes into a dir the append-only gate then +//! protects forever, while the regeneration moves `candidate` on to the real +//! ones — so the release publishes one address and permanently records another. +//! Nothing downstream catches it; a self-consistency test checks the +//! *regenerated* candidate against source, and no test compares a numbered dir to +//! `candidate`. So the consumer supplies only the pointer-generation command and +//! this module owns the sequence: **generate -> `forge fmt` -> freeze -> verify**. +//! There is no input that runs after the copy, so the inverted order cannot be +//! expressed. + +use crate::frozen_snapshots::is_tag; +use crate::soldeer_gate::read_local_version; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// Pointer-generation command when the caller supplies none. Every deploy repo on +/// the rolling-candidate model generates its pins with exactly this invocation, +/// so it is the convention rather than a guess; a repo that names its script +/// differently passes `--generate-cmd`. +pub(crate) const DEFAULT_GENERATE_CMD: &str = "forge script ./script/BuildPointers.sol"; + +/// The rolling snapshot dir. Never a version number: the append-only gate's tag +/// filter is all-numeric, so `candidate` is invisible to it and free to roll. +const CANDIDATE: &str = "candidate"; + +/// What this release will freeze, resolved from the repo BEFORE anything runs. +struct Plan { + /// `/candidate` — the rolling snapshot to freeze. + candidate: PathBuf, + /// `/` — the numbered dir to freeze it into. + frozen: PathBuf, + /// The version with dots as underscores, e.g. `0.1.5` -> `0_1_5`. + tag: String, +} + +/// Resolve the release from `foundry.toml` and check every precondition, before +/// the generator runs, so a misconfigured release fails without side effects. +fn plan(repo: &Path, root: &str) -> Result { + let version = read_local_version(repo).ok_or_else(|| { + format!( + "cut-release: {} has no [package].version — the release version is read from there \ + (rainix-tag-release writes it from the pushed tag)", + repo.join("foundry.toml").display() + ) + })?; + let tag = version.replace('.', "_"); + // Strict X.Y.Z, tested against the append-only gate's OWN predicate rather + // than a restatement of it: a version like `0.1.7-rc1` yields `0_1_7-rc1`, + // which that gate ignores forever — an orphan snapshot nothing protects. + // Refuse rather than cut one. + if !is_tag(&tag) { + return Err(format!( + "cut-release: version {version:?} is not strict X.Y.Z — refusing to cut \ + {root}/{tag}, a snapshot dir the append-only gate would ignore forever" + )); + } + let candidate = repo.join(root).join(CANDIDATE); + if !candidate.is_dir() { + return Err(format!( + "cut-release: {} is missing — this repo is not on the rolling-candidate model, \ + so there is nothing to freeze", + candidate.display() + )); + } + let frozen = repo.join(root).join(&tag); + if frozen.exists() { + return Err(format!( + "cut-release: {} already exists — refusing to overwrite a frozen release snapshot \ + (snapshots are append-only; release a new version instead)", + frozen.display() + )); + } + Ok(Plan { + candidate, + frozen, + tag, + }) +} + +/// Every file under `root`, keyed by its path relative to `root`. Directories +/// carry no content of their own, so an empty one is not represented — git does +/// not track one either. +fn read_tree(root: &Path) -> Result>, String> { + let mut out = BTreeMap::new(); + let mut stack = vec![root.to_path_buf()]; + while let Some(dir) = stack.pop() { + let entries = std::fs::read_dir(&dir) + .map_err(|e| format!("cut-release: read dir {}: {e}", dir.display()))?; + for entry in entries { + let entry = + entry.map_err(|e| format!("cut-release: read dir {}: {e}", dir.display()))?; + let path = entry.path(); + let kind = entry + .file_type() + .map_err(|e| format!("cut-release: stat {}: {e}", path.display()))?; + if kind.is_dir() { + stack.push(path); + } else { + let rel = path + .strip_prefix(root) + .map_err(|e| { + format!( + "cut-release: {} is not under {}: {e}", + path.display(), + root.display() + ) + })? + .to_path_buf(); + let bytes = std::fs::read(&path) + .map_err(|e| format!("cut-release: read {}: {e}", path.display()))?; + out.insert(rel, bytes); + } + } + } + Ok(out) +} + +/// Write a tree at `root`, which must NOT already exist. `create_dir` rather than +/// `create_dir_all` is the append-only guard at the point of writing: a frozen +/// snapshot is never written over, whatever raced or generated it. +fn write_tree(root: &Path, tree: &BTreeMap>) -> Result<(), String> { + std::fs::create_dir(root) + .map_err(|e| format!("cut-release: create {}: {e}", root.display()))?; + for (rel, bytes) in tree { + let dst = root.join(rel); + if let Some(parent) = dst.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("cut-release: create {}: {e}", parent.display()))?; + } + std::fs::write(&dst, bytes) + .map_err(|e| format!("cut-release: write {}: {e}", dst.display()))?; + } + Ok(()) +} + +/// Regenerate, then freeze. `generate` is the caller's regeneration step +/// (pointer-generation command + `forge fmt` in production, a fake in tests); it +/// is taken as an argument so the sequence itself is testable without forge. +/// +/// The order below is the invariant this tool exists to hold: the numbered dir +/// must record what the release actually publishes, and what it publishes is +/// `candidate` — the pin lib aliases it — so the copy has to come from a +/// `candidate` already known to match the current source. +fn cut( + repo: &Path, + root: &str, + generate: &mut dyn FnMut() -> Result<(), String>, +) -> Result { + let plan = plan(repo, root)?; + + // 1. REGENERATE (and format) FIRST — never after the copy. + generate()?; + + // 2. The generator regenerates `candidate` and nothing else. One that froze a + // numbered dir itself (a leftover consumer cut-release script) is the + // inverted order sneaking back in through the command input; refuse it. + if plan.frozen.exists() { + return Err(format!( + "cut-release: the pointer-generation command created {} itself — it must only \ + regenerate {}; freezing the numbered snapshot is this tool's job, and doing it \ + before regeneration is what records an address the release does not publish", + plan.frozen.display(), + plan.candidate.display() + )); + } + if !plan.candidate.is_dir() { + return Err(format!( + "cut-release: the pointer-generation command left {} missing — nothing to freeze", + plan.candidate.display() + )); + } + let tree = read_tree(&plan.candidate)?; + if tree.is_empty() { + return Err(format!( + "cut-release: {} holds no files after regeneration — nothing to freeze", + plan.candidate.display() + )); + } + + // 3. FREEZE, from the just-regenerated candidate. + write_tree(&plan.frozen, &tree)?; + + // 4. The point of the ordering above, asserted rather than assumed: the + // frozen record and the published pin are the same bytes. Both sides are + // re-read from disk (the equivalent of `diff -r`), so this also catches the + // copy having disturbed `candidate` itself. + if read_tree(&plan.frozen)? != read_tree(&plan.candidate)? { + return Err(format!( + "cut-release: {} does not match {} after the copy", + plan.frozen.display(), + plan.candidate.display() + )); + } + Ok(plan.tag) +} + +/// Run a command through bash, failing loud on a nonzero exit. `-euo pipefail` so +/// a consumer command written as `a; b` cannot hide a's failure behind b's +/// success. +fn sh(cmd: &str) -> Result<(), String> { + let status = Command::new("bash") + .args(["-euo", "pipefail", "-c", cmd]) + .status() + .map_err(|e| format!("cut-release: failed to spawn {cmd:?}: {e}"))?; + if !status.success() { + return Err(format!("cut-release: {cmd:?} exited with {status}")); + } + Ok(()) +} + +/// Cut the release in the current directory: run `generate_cmd`, format, then +/// freeze `/candidate` into `/`. Returns the frozen tag. +pub(crate) fn run(root: &str, generate_cmd: &str) -> Result { + cut(Path::new("."), root, &mut || { + sh(generate_cmd)?; + // Formats BEFORE the copy, so the frozen dir is byte-identical to + // `candidate` rather than to its pre-format form. + sh("forge fmt") + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + + static N: AtomicUsize = AtomicUsize::new(0); + + fn tmp_dir() -> PathBuf { + let d = std::env::temp_dir().join(format!( + "rainix-static-cut-release-test-{}-{}", + std::process::id(), + N.fetch_add(1, Ordering::SeqCst) + )); + std::fs::create_dir_all(&d).unwrap(); + d + } + + const ROOT: &str = "src/generated"; + + /// A deploy repo on the candidate model: a version, and a rolling candidate + /// snapshot holding one pointer file. + fn repo(version: &str) -> PathBuf { + let d = tmp_dir(); + std::fs::write( + d.join("foundry.toml"), + format!("[package]\nname = \"x-deploy\"\nversion = \"{version}\"\n"), + ) + .unwrap(); + std::fs::create_dir_all(d.join(ROOT).join(CANDIDATE)).unwrap(); + std::fs::write( + d.join(ROOT).join(CANDIDATE).join("X.pointers.sol"), + b"address constant DEPLOYED_ADDRESS = address(0xAAA);\n", + ) + .unwrap(); + d + } + + fn read(p: &Path) -> String { + String::from_utf8(std::fs::read(p).unwrap()).unwrap() + } + + /// A generator that records that it ran and does nothing else. + fn counting(count: &mut usize) -> impl FnMut() -> Result<(), String> + '_ { + move || { + *count += 1; + Ok(()) + } + } + + #[test] + fn freezes_candidate_into_the_numbered_dir() { + let d = repo("0.1.5"); + let mut ran = 0; + let tag = cut(&d, ROOT, &mut counting(&mut ran)).unwrap(); + assert_eq!(tag, "0_1_5"); + assert_eq!(ran, 1); + // The frozen dir is a byte-identical copy, and the gate recognises it. + assert!(is_tag(&tag)); + assert_eq!( + read(&d.join(ROOT).join("0_1_5/X.pointers.sol")), + read(&d.join(ROOT).join("candidate/X.pointers.sol")), + ); + // …and candidate itself is untouched by the freeze. + assert!(d.join(ROOT).join(CANDIDATE).is_dir()); + } + + #[test] + fn freezes_nested_files_too() { + let d = repo("1.2.3"); + std::fs::create_dir_all(d.join(ROOT).join("candidate/sub")).unwrap(); + std::fs::write(d.join(ROOT).join("candidate/sub/Y.pointers.sol"), b"y\n").unwrap(); + cut(&d, ROOT, &mut counting(&mut 0)).unwrap(); + assert_eq!(read(&d.join(ROOT).join("1_2_3/sub/Y.pointers.sol")), "y\n"); + } + + /// The ordering this tool exists to enforce: the frozen dir records what the + /// generator produced, not what was committed before it ran. + #[test] + fn regenerates_before_freezing() { + let d = repo("0.2.0"); + let candidate = d.join(ROOT).join(CANDIDATE); + let mut generate = || { + // A drifted candidate being brought back in line with source. + std::fs::write( + candidate.join("X.pointers.sol"), + b"address constant DEPLOYED_ADDRESS = address(0xBBB);\n", + ) + .unwrap(); + std::fs::write(candidate.join("New.pointers.sol"), b"new\n").unwrap(); + Ok(()) + }; + cut(&d, ROOT, &mut generate).unwrap(); + let frozen = d.join(ROOT).join("0_2_0"); + // Freezing first would have recorded 0xAAA — the address the release does + // not publish — and would not hold the new file at all. + assert_eq!( + read(&frozen.join("X.pointers.sol")), + "address constant DEPLOYED_ADDRESS = address(0xBBB);\n" + ); + assert_eq!(read(&frozen.join("New.pointers.sol")), "new\n"); + } + + /// The same ordering seen from the generator's side: nothing is frozen yet + /// while it runs. + #[test] + fn nothing_is_frozen_while_the_generator_runs() { + let d = repo("0.3.1"); + let frozen = d.join(ROOT).join("0_3_1"); + let mut existed_during_generate = true; + { + let frozen = frozen.clone(); + let mut generate = || { + existed_during_generate = frozen.exists(); + Ok(()) + }; + cut(&d, ROOT, &mut generate).unwrap(); + } + assert!(!existed_during_generate); + assert!(frozen.is_dir()); + } + + #[test] + fn refuses_when_the_generator_freezes_the_numbered_dir_itself() { + let d = repo("0.4.0"); + let root_dir = d.join(ROOT); + let mut generate = || { + std::fs::create_dir_all(root_dir.join("0_4_0")).unwrap(); + std::fs::write(root_dir.join("0_4_0/X.pointers.sol"), b"stale\n").unwrap(); + Ok(()) + }; + let err = cut(&d, ROOT, &mut generate).unwrap_err(); + assert!(err.contains("created"), "{err}"); + // The tool never wrote over it, and never blessed it as the release. + assert_eq!(read(&d.join(ROOT).join("0_4_0/X.pointers.sol")), "stale\n"); + } + + #[test] + fn a_failing_generator_freezes_nothing() { + let d = repo("0.5.0"); + let mut generate = || Err("boom".to_string()); + assert_eq!(cut(&d, ROOT, &mut generate).unwrap_err(), "boom"); + assert!(!d.join(ROOT).join("0_5_0").exists()); + } + + #[test] + fn rejects_versions_the_append_only_gate_would_ignore() { + // Each of these would freeze an orphan dir no gate protects. + for version in ["0.1.7-rc1", "0.1", "1.2.3.4", "0.1.5+build", "v0.1.5", ""] { + let d = repo(version); + let mut ran = 0; + let err = cut(&d, ROOT, &mut counting(&mut ran)).unwrap_err(); + assert!(err.contains("strict X.Y.Z"), "{version}: {err}"); + // The guard runs before any work: nothing regenerated, nothing frozen. + assert_eq!(ran, 0, "{version}"); + let dirs: Vec<_> = std::fs::read_dir(d.join(ROOT)) + .unwrap() + .map(|e| e.unwrap().file_name()) + .collect(); + assert_eq!(dirs, vec![std::ffi::OsString::from(CANDIDATE)], "{version}"); + } + } + + #[test] + fn accepts_every_strict_version() { + for (version, tag) in [ + ("0.1.5", "0_1_5"), + ("1.0.0", "1_0_0"), + ("12.0.255", "12_0_255"), + ("0.1.10", "0_1_10"), + ] { + let d = repo(version); + assert_eq!(cut(&d, ROOT, &mut counting(&mut 0)).unwrap(), tag); + assert!(d.join(ROOT).join(tag).is_dir()); + } + } + + #[test] + fn refuses_without_a_candidate() { + let d = repo("0.1.5"); + std::fs::remove_dir_all(d.join(ROOT).join(CANDIDATE)).unwrap(); + let mut ran = 0; + let err = cut(&d, ROOT, &mut counting(&mut ran)).unwrap_err(); + assert!(err.contains("rolling-candidate model"), "{err}"); + assert_eq!(ran, 0); + assert!(!d.join(ROOT).join("0_1_5").exists()); + } + + #[test] + fn refuses_an_empty_candidate() { + let d = repo("0.1.5"); + std::fs::remove_file(d.join(ROOT).join("candidate/X.pointers.sol")).unwrap(); + let err = cut(&d, ROOT, &mut counting(&mut 0)).unwrap_err(); + assert!(err.contains("nothing to freeze"), "{err}"); + assert!(!d.join(ROOT).join("0_1_5").exists()); + } + + #[test] + fn refuses_to_overwrite_a_frozen_snapshot() { + let d = repo("0.1.5"); + std::fs::create_dir_all(d.join(ROOT).join("0_1_5")).unwrap(); + std::fs::write(d.join(ROOT).join("0_1_5/X.pointers.sol"), b"frozen\n").unwrap(); + let mut ran = 0; + let err = cut(&d, ROOT, &mut counting(&mut ran)).unwrap_err(); + assert!(err.contains("already exists"), "{err}"); + assert_eq!(ran, 0); + // Untouched: the frozen bytes downstream consumers pin are still there. + assert_eq!(read(&d.join(ROOT).join("0_1_5/X.pointers.sol")), "frozen\n"); + } + + #[test] + fn refuses_without_a_package_version() { + let d = repo("0.1.5"); + std::fs::write(d.join("foundry.toml"), "[package]\nname = \"x-deploy\"\n").unwrap(); + let err = cut(&d, ROOT, &mut counting(&mut 0)).unwrap_err(); + assert!(err.contains("[package].version"), "{err}"); + } + + #[test] + fn write_tree_refuses_an_existing_dir() { + let d = tmp_dir(); + let dst = d.join("0_1_5"); + std::fs::create_dir(&dst).unwrap(); + let mut tree = BTreeMap::new(); + tree.insert(PathBuf::from("X.sol"), b"x".to_vec()); + assert!(write_tree(&dst, &tree).is_err()); + assert!(!dst.join("X.sol").exists()); + } + + #[test] + fn read_tree_is_relative_and_recursive() { + let d = tmp_dir(); + std::fs::create_dir_all(d.join("a/b")).unwrap(); + std::fs::write(d.join("top"), b"1").unwrap(); + std::fs::write(d.join("a/mid"), b"2").unwrap(); + std::fs::write(d.join("a/b/deep"), b"3").unwrap(); + let tree = read_tree(&d).unwrap(); + assert_eq!(tree.len(), 3); + assert_eq!(tree[Path::new("top")], b"1"); + assert_eq!(tree[Path::new("a/mid")], b"2"); + assert_eq!(tree[Path::new("a/b/deep")], b"3"); + } +} diff --git a/rainix-static/src/main.rs b/rainix-static/src/main.rs index 45bab90..202ad9a 100644 --- a/rainix-static/src/main.rs +++ b/rainix-static/src/main.rs @@ -3,7 +3,8 @@ // inline bash or Python in a workflow. Per the "tooling is Rust" rule (CLAUDE.md), // logic — hashing, JSON parsing, version math, content gates — lives here as one // testable binary; workflows only orchestrate it. Each subcommand is its own -// module: static checks in `no_submodules`, CI release tooling in `soldeer_gate`. +// module: static checks in `no_submodules`, CI release tooling in `soldeer_gate` +// and `cut_release`. // // Static checks print their offenders and exit nonzero on failure (": clean" // otherwise). Tooling subcommands print machine outputs (key=value lines) to the @@ -24,6 +25,14 @@ // `forge soldeer push --dry-run` would upload against the latest published // revision, and emit changed / version / next. Runs inside sol-shell, so // `forge` and `curl` are on PATH. +// cut-release [--root ] [--generate-cmd ] +// Deploy-repo release freeze: regenerate /candidate/ with the pointer +// generation command (default `forge script ./script/BuildPointers.sol`), +// `forge fmt`, then copy it to / for the foundry.toml +// [package].version (default root src/generated). Regenerate-then-freeze is +// enforced here so no caller can invert it — the reverse order permanently +// records an address the release does not publish. Runs inside sol-shell, +// so `forge` is on PATH. // rpc-preflight [--root ] [--github-env ] [--samples N] // [--timeout N] [--no-archive] // Pick a working fork RPC endpoint per network and export it as @@ -32,6 +41,7 @@ // from the RAINIX_RPC_SECRET_ / RAINIX_RPC_VARS_ env vars merged // with hardcoded public archive defaults. Never prints a candidate URL. +mod cut_release; mod frozen_snapshots; mod no_submodules; mod rpc_preflight; @@ -93,6 +103,18 @@ fn main() { .unwrap_or_else(|| fail("soldeer-gate: --package required")); soldeer_gate::run(&pkg, flag(&args, "--github-output").as_deref()); } + "cut-release" => { + let root = flag(&args, "--root").unwrap_or_else(|| "src/generated".to_string()); + // An empty --generate-cmd is an unset workflow input, not a request to + // run nothing: fall back to the convention. + let cmd = flag(&args, "--generate-cmd") + .filter(|c| !c.trim().is_empty()) + .unwrap_or_else(|| cut_release::DEFAULT_GENERATE_CMD.to_string()); + match cut_release::run(&root, &cmd) { + Err(e) => fail(&e), + Ok(tag) => println!("cut-release: froze {root}/candidate -> {root}/{tag}"), + } + } "snapshots-append-only" => { let base = flag(&args, "--base").unwrap_or_else(|| "origin/main".to_string()); let root = flag(&args, "--root").unwrap_or_else(|| "src/generated".to_string()); @@ -133,7 +155,8 @@ fn main() { other => { eprintln!( "rainix-static: unknown subcommand {other:?} \ - (available: no-submodules, snapshots-append-only, soldeer-gate, rpc-preflight)" + (available: no-submodules, snapshots-append-only, soldeer-gate, cut-release, \ + rpc-preflight)" ); std::process::exit(2); } diff --git a/rainix-static/src/soldeer_gate.rs b/rainix-static/src/soldeer_gate.rs index 57c708e..df879c2 100644 --- a/rainix-static/src/soldeer_gate.rs +++ b/rainix-static/src/soldeer_gate.rs @@ -137,7 +137,9 @@ fn parse_registry(json: &str) -> (Option, Option) { /// First `[package].version` value in foundry.toml (the in-dev, unpublished /// version). Reads the value between the first pair of quotes on that line. -fn read_local_version(dir: &Path) -> Option { +/// Shared with `cut-release`, which reads the same line as the release version: +/// one parser, so the two lifecycles can never disagree about what that line is. +pub(crate) fn read_local_version(dir: &Path) -> Option { let content = std::fs::read_to_string(dir.join("foundry.toml")).ok()?; for line in content.lines() { if is_version_line(line) { From a22442652f0e2107c66ce9f9299b26531ca1e849 Mon Sep 17 00:00:00 2001 From: David Meister Date: Thu, 13 Aug 2026 11:25:00 +0000 Subject: [PATCH 2/5] cut-release: cover the generate command's failure modes --- rainix-static/src/cut_release.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/rainix-static/src/cut_release.rs b/rainix-static/src/cut_release.rs index 094b57c..d0f8214 100644 --- a/rainix-static/src/cut_release.rs +++ b/rainix-static/src/cut_release.rs @@ -473,4 +473,20 @@ mod tests { assert_eq!(tree[Path::new("a/mid")], b"2"); assert_eq!(tree[Path::new("a/b/deep")], b"3"); } + + /// The consumer's generate command is arbitrary shell, so a failure anywhere + /// in it has to abort the cut — a release that freezes what a half-failed + /// generator left behind is the same silent-corruption class this tool exists + /// to close. + #[test] + fn a_command_that_fails_anywhere_fails_the_cut() { + assert!(sh("true").is_ok()); + assert!(sh("false").is_err()); + // -e: an early failure is not hidden by a later success. + assert!(sh("false; true").is_err()); + // -o pipefail: nor by a successful tail of a pipe. + assert!(sh("false | true").is_err()); + // -u: an unset variable (a typo'd path) is an error, not an empty string. + assert!(sh("echo \"${RAINIX_CUT_RELEASE_UNSET_PROBE}\"").is_err()); + } } From de60aaa75141f88549ffc0455ccf24c56de9c3e7 Mon Sep 17 00:00:00 2001 From: David Meister Date: Thu, 13 Aug 2026 11:27:55 +0000 Subject: [PATCH 3/5] cut-release action: match the repo's action.yml naming --- .github/actions/cut-release/{action.yaml => action.yml} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/actions/cut-release/{action.yaml => action.yml} (100%) diff --git a/.github/actions/cut-release/action.yaml b/.github/actions/cut-release/action.yml similarity index 100% rename from .github/actions/cut-release/action.yaml rename to .github/actions/cut-release/action.yml From 5bb2cb348aae99da25c6dc5927a3ae0a7e2a5f7d Mon Sep 17 00:00:00 2001 From: David Meister Date: Thu, 13 Aug 2026 11:53:43 +0000 Subject: [PATCH 4/5] tag-release: delete snapshot-generate-cmd rather than deprecate it --- .github/workflows/rainix-tag-release.yaml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.github/workflows/rainix-tag-release.yaml b/.github/workflows/rainix-tag-release.yaml index d46c2c8..f2b651e 100644 --- a/.github/workflows/rainix-tag-release.yaml +++ b/.github/workflows/rainix-tag-release.yaml @@ -60,12 +60,6 @@ on: required: false type: string default: '' - snapshot-generate-cmd: - description: >- - DEPRECATED / no-op. Freezing the release snapshot is no longer a consumer-supplied shell string — it is `rainix-static cut-release`, which owns the regenerate -> format -> freeze order rather than documenting it, because the inverted order silently freezes a stale `candidate` into an append-only dir while regeneration moves `candidate` on, publishing one address and permanently recording another. Retained only so existing callers that still pass it do not error; it is ignored and will be removed. Consumers should drop it, and pass `pointers-generate-cmd` instead if their pointer script is not at the conventional path. - required: false - type: string - default: '' test-cmd: description: >- Pre-publish verification gate. Run against the regenerated snapshot; for a deploy repo this is the fork suite that reads the live chain and asserts it matches the fresh pins, so a release that snapshots addresses the chain does not actually carry fails loud BEFORE publishing. Default `forge test`. From 5db168818cd2254ce5830e7c73121e8f511fdeb9 Mon Sep 17 00:00:00 2001 From: David Meister Date: Tue, 18 Aug 2026 18:28:13 +0000 Subject: [PATCH 5/5] Cut the release with the repo's own cutRelease(), not a rainix copy of it rainix-tag-release runs `snapshot-generate-cmd`, which regenerates the rolling src/generated/candidate/ pins and freezes src/generated// from them in one call. The input keeps its name, is no longer required, and defaults to `forge script ./script/Build.sol --sig "cutRelease()" && forge fmt`. The freeze, its ordering and its guards live in rain.deploy's LibRainDeploySnapshot.freeze, reached through BuildScript.cutRelease(). The rainix-static `cut-release` subcommand and the `cut-release` composite action were a second implementation of the same thing; both are removed, leaving rainix-static byte-identical to main. The command value travels via env rather than being interpolated into the `run:` body, and runs under `set -euo pipefail`. Co-Authored-By: Claude Opus 5 (1M context) --- .github/actions/cut-release/action.yml | 31 -- .github/workflows/rainix-tag-release.yaml | 41 +- rainix-static/src/cut_release.rs | 492 ---------------------- rainix-static/src/main.rs | 26 +- rainix-static/src/soldeer_gate.rs | 4 +- 5 files changed, 23 insertions(+), 571 deletions(-) delete mode 100644 .github/actions/cut-release/action.yml delete mode 100644 rainix-static/src/cut_release.rs diff --git a/.github/actions/cut-release/action.yml b/.github/actions/cut-release/action.yml deleted file mode 100644 index ad48d81..0000000 --- a/.github/actions/cut-release/action.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: cut-release -description: >- - Cuts a deploy repo's release snapshot: regenerates the rolling src/generated/candidate/ pins from the current source, formats, and only then freezes a byte-identical copy as src/generated// for the foundry.toml [package].version. The ordering is the reason this is a tool and not a consumer script — freezing before regenerating writes a stale candidate into a dir the frozen-snapshots-append-only gate protects forever while the regeneration moves candidate on, so the release publishes one address and permanently records another, and nothing downstream compares the two. Refuses a version that is not strict X.Y.Z (its tag dir would be invisible to that gate — an orphan snapshot nothing protects), a missing or empty candidate, and any tag dir that already exists. -inputs: - generate-cmd: - description: >- - Command that regenerates src/generated/candidate/ from the current source. Empty (the default) uses the org convention, `forge script ./script/BuildPointers.sol`. This is the ONLY repo-specific part of the freeze; everything else — the version read, the guards, the format, the copy and the byte-identity assertion — is the same everywhere and lives in rainix-static. It runs strictly BEFORE the freeze, so the inverted order cannot be expressed through it. - required: false - default: '' -runs: - using: composite - steps: - - name: Cut the release snapshot - shell: bash - env: - # Via env, not interpolated into the script body: a `with:` value spliced - # into `run:` text is a template-injection surface, and the binary reads - # it as a single argv element either way. - RAINIX_GENERATE_CMD: ${{ inputs.generate-cmd }} - run: | - set -euo pipefail - # Single source of truth: the Rust rainix-static binary (its unit tests - # run inside the nix build). The path: flake ref runs it — and the forge - # it drives, from the same sol-shell — out of this composite's own - # checkout, so the freeze logic always matches the action version - # regardless of any RAINIX_SHA the caller pins, and a new subcommand is - # usable the moment the action lands rather than after a sha bump. A - # path: ref also makes no api.github.com call, so it cannot hit the 429 - # that pinning exists to avoid. - nix develop "path:$(cd "$GITHUB_ACTION_PATH/../../.." && pwd)#sol-shell" -c \ - rainix-static cut-release --generate-cmd "$RAINIX_GENERATE_CMD" diff --git a/.github/workflows/rainix-tag-release.yaml b/.github/workflows/rainix-tag-release.yaml index f2b651e..a3a9375 100644 --- a/.github/workflows/rainix-tag-release.yaml +++ b/.github/workflows/rainix-tag-release.yaml @@ -54,12 +54,12 @@ on: required: false type: string default: sol-v - pointers-generate-cmd: + snapshot-generate-cmd: description: >- - Command that regenerates the rolling `src/generated/candidate/` pins from the current source, e.g. `forge script ./script/BuildPointers.sol` — which is also the default when this is empty, so a repo following the convention passes nothing. This is the ONLY repo-specific part of a release freeze: the version read, the guards, the format, the copy into `src/generated//` and the byte-identity assertion are identical everywhere and live in `rainix-static cut-release`. It is deliberately a GENERATE command, not a freeze script — it runs strictly before the freeze, so the freeze-then-regenerate order (which permanently records an address the release does not publish) cannot be expressed. + Command that cuts the release: it regenerates the rolling `src/generated/candidate/` pins from the current source and freezes a copy of them as `src/generated//`, in that order and in one call, then formats. `BuildScript.cutRelease()` is that call, so the default is the whole of it for a deploy repo whose `script/Build.sol` extends `BuildScript` and such a repo passes nothing. Run after `[package].version` is set to the release version, so the tag the freeze derives from it is the one being released. required: false type: string - default: '' + default: 'forge script ./script/Build.sol --sig "cutRelease()" && forge fmt' test-cmd: description: >- Pre-publish verification gate. Run against the regenerated snapshot; for a deploy repo this is the fork suite that reads the live chain and asserts it matches the fresh pins, so a release that snapshots addresses the chain does not actually carry fails loud BEFORE publishing. Default `forge test`. @@ -198,24 +198,23 @@ jobs: exit 1 } - name: Cut the release snapshot - # Regenerate the rolling candidate pins, format, then freeze a - # byte-identical copy as src/generated//. 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 ORDER is the reason this is a rainix tool rather than a consumer - # script (rainlanguage/rainix#301). Freezing before regenerating writes a - # drifted candidate into an append-only dir while the regeneration moves - # candidate on, so the release publishes one address and permanently - # records another — invisible downstream, because the repo's - # self-consistency test checks the REGENERATED candidate against source and - # nothing compares a numbered dir to candidate. The consumer supplies only - # the generate command, which runs strictly first, so the inversion has - # nowhere to live. - uses: rainlanguage/rainix/.github/actions/cut-release@main - with: - generate-cmd: ${{ inputs.pointers-generate-cmd }} + # 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(). + 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: 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. diff --git a/rainix-static/src/cut_release.rs b/rainix-static/src/cut_release.rs deleted file mode 100644 index d0f8214..0000000 --- a/rainix-static/src/cut_release.rs +++ /dev/null @@ -1,492 +0,0 @@ -//! `cut-release` — freeze a deploy repo's rolling `candidate` snapshot as the -//! numbered release snapshot the pushed tag names. -//! -//! Model: `/candidate/` is the rolling snapshot of what the current source -//! compiles to (rewritten in full by every pointer-generation run, and aliased by -//! the consumer-facing pin lib, so it is what a release actually publishes). A -//! numbered snapshot (`0_1_5/`, …) is a FROZEN copy of `candidate` taken at the -//! instant a tag releases it — it never changes again, which -//! `snapshots-append-only` enforces. -//! -//! The ordering is the whole point of this living in a tool. Regenerating AFTER -//! the copy is silently wrong whenever the committed `candidate` has drifted from -//! source: the copy freezes the stale bytes into a dir the append-only gate then -//! protects forever, while the regeneration moves `candidate` on to the real -//! ones — so the release publishes one address and permanently records another. -//! Nothing downstream catches it; a self-consistency test checks the -//! *regenerated* candidate against source, and no test compares a numbered dir to -//! `candidate`. So the consumer supplies only the pointer-generation command and -//! this module owns the sequence: **generate -> `forge fmt` -> freeze -> verify**. -//! There is no input that runs after the copy, so the inverted order cannot be -//! expressed. - -use crate::frozen_snapshots::is_tag; -use crate::soldeer_gate::read_local_version; -use std::collections::BTreeMap; -use std::path::{Path, PathBuf}; -use std::process::Command; - -/// Pointer-generation command when the caller supplies none. Every deploy repo on -/// the rolling-candidate model generates its pins with exactly this invocation, -/// so it is the convention rather than a guess; a repo that names its script -/// differently passes `--generate-cmd`. -pub(crate) const DEFAULT_GENERATE_CMD: &str = "forge script ./script/BuildPointers.sol"; - -/// The rolling snapshot dir. Never a version number: the append-only gate's tag -/// filter is all-numeric, so `candidate` is invisible to it and free to roll. -const CANDIDATE: &str = "candidate"; - -/// What this release will freeze, resolved from the repo BEFORE anything runs. -struct Plan { - /// `/candidate` — the rolling snapshot to freeze. - candidate: PathBuf, - /// `/` — the numbered dir to freeze it into. - frozen: PathBuf, - /// The version with dots as underscores, e.g. `0.1.5` -> `0_1_5`. - tag: String, -} - -/// Resolve the release from `foundry.toml` and check every precondition, before -/// the generator runs, so a misconfigured release fails without side effects. -fn plan(repo: &Path, root: &str) -> Result { - let version = read_local_version(repo).ok_or_else(|| { - format!( - "cut-release: {} has no [package].version — the release version is read from there \ - (rainix-tag-release writes it from the pushed tag)", - repo.join("foundry.toml").display() - ) - })?; - let tag = version.replace('.', "_"); - // Strict X.Y.Z, tested against the append-only gate's OWN predicate rather - // than a restatement of it: a version like `0.1.7-rc1` yields `0_1_7-rc1`, - // which that gate ignores forever — an orphan snapshot nothing protects. - // Refuse rather than cut one. - if !is_tag(&tag) { - return Err(format!( - "cut-release: version {version:?} is not strict X.Y.Z — refusing to cut \ - {root}/{tag}, a snapshot dir the append-only gate would ignore forever" - )); - } - let candidate = repo.join(root).join(CANDIDATE); - if !candidate.is_dir() { - return Err(format!( - "cut-release: {} is missing — this repo is not on the rolling-candidate model, \ - so there is nothing to freeze", - candidate.display() - )); - } - let frozen = repo.join(root).join(&tag); - if frozen.exists() { - return Err(format!( - "cut-release: {} already exists — refusing to overwrite a frozen release snapshot \ - (snapshots are append-only; release a new version instead)", - frozen.display() - )); - } - Ok(Plan { - candidate, - frozen, - tag, - }) -} - -/// Every file under `root`, keyed by its path relative to `root`. Directories -/// carry no content of their own, so an empty one is not represented — git does -/// not track one either. -fn read_tree(root: &Path) -> Result>, String> { - let mut out = BTreeMap::new(); - let mut stack = vec![root.to_path_buf()]; - while let Some(dir) = stack.pop() { - let entries = std::fs::read_dir(&dir) - .map_err(|e| format!("cut-release: read dir {}: {e}", dir.display()))?; - for entry in entries { - let entry = - entry.map_err(|e| format!("cut-release: read dir {}: {e}", dir.display()))?; - let path = entry.path(); - let kind = entry - .file_type() - .map_err(|e| format!("cut-release: stat {}: {e}", path.display()))?; - if kind.is_dir() { - stack.push(path); - } else { - let rel = path - .strip_prefix(root) - .map_err(|e| { - format!( - "cut-release: {} is not under {}: {e}", - path.display(), - root.display() - ) - })? - .to_path_buf(); - let bytes = std::fs::read(&path) - .map_err(|e| format!("cut-release: read {}: {e}", path.display()))?; - out.insert(rel, bytes); - } - } - } - Ok(out) -} - -/// Write a tree at `root`, which must NOT already exist. `create_dir` rather than -/// `create_dir_all` is the append-only guard at the point of writing: a frozen -/// snapshot is never written over, whatever raced or generated it. -fn write_tree(root: &Path, tree: &BTreeMap>) -> Result<(), String> { - std::fs::create_dir(root) - .map_err(|e| format!("cut-release: create {}: {e}", root.display()))?; - for (rel, bytes) in tree { - let dst = root.join(rel); - if let Some(parent) = dst.parent() { - std::fs::create_dir_all(parent) - .map_err(|e| format!("cut-release: create {}: {e}", parent.display()))?; - } - std::fs::write(&dst, bytes) - .map_err(|e| format!("cut-release: write {}: {e}", dst.display()))?; - } - Ok(()) -} - -/// Regenerate, then freeze. `generate` is the caller's regeneration step -/// (pointer-generation command + `forge fmt` in production, a fake in tests); it -/// is taken as an argument so the sequence itself is testable without forge. -/// -/// The order below is the invariant this tool exists to hold: the numbered dir -/// must record what the release actually publishes, and what it publishes is -/// `candidate` — the pin lib aliases it — so the copy has to come from a -/// `candidate` already known to match the current source. -fn cut( - repo: &Path, - root: &str, - generate: &mut dyn FnMut() -> Result<(), String>, -) -> Result { - let plan = plan(repo, root)?; - - // 1. REGENERATE (and format) FIRST — never after the copy. - generate()?; - - // 2. The generator regenerates `candidate` and nothing else. One that froze a - // numbered dir itself (a leftover consumer cut-release script) is the - // inverted order sneaking back in through the command input; refuse it. - if plan.frozen.exists() { - return Err(format!( - "cut-release: the pointer-generation command created {} itself — it must only \ - regenerate {}; freezing the numbered snapshot is this tool's job, and doing it \ - before regeneration is what records an address the release does not publish", - plan.frozen.display(), - plan.candidate.display() - )); - } - if !plan.candidate.is_dir() { - return Err(format!( - "cut-release: the pointer-generation command left {} missing — nothing to freeze", - plan.candidate.display() - )); - } - let tree = read_tree(&plan.candidate)?; - if tree.is_empty() { - return Err(format!( - "cut-release: {} holds no files after regeneration — nothing to freeze", - plan.candidate.display() - )); - } - - // 3. FREEZE, from the just-regenerated candidate. - write_tree(&plan.frozen, &tree)?; - - // 4. The point of the ordering above, asserted rather than assumed: the - // frozen record and the published pin are the same bytes. Both sides are - // re-read from disk (the equivalent of `diff -r`), so this also catches the - // copy having disturbed `candidate` itself. - if read_tree(&plan.frozen)? != read_tree(&plan.candidate)? { - return Err(format!( - "cut-release: {} does not match {} after the copy", - plan.frozen.display(), - plan.candidate.display() - )); - } - Ok(plan.tag) -} - -/// Run a command through bash, failing loud on a nonzero exit. `-euo pipefail` so -/// a consumer command written as `a; b` cannot hide a's failure behind b's -/// success. -fn sh(cmd: &str) -> Result<(), String> { - let status = Command::new("bash") - .args(["-euo", "pipefail", "-c", cmd]) - .status() - .map_err(|e| format!("cut-release: failed to spawn {cmd:?}: {e}"))?; - if !status.success() { - return Err(format!("cut-release: {cmd:?} exited with {status}")); - } - Ok(()) -} - -/// Cut the release in the current directory: run `generate_cmd`, format, then -/// freeze `/candidate` into `/`. Returns the frozen tag. -pub(crate) fn run(root: &str, generate_cmd: &str) -> Result { - cut(Path::new("."), root, &mut || { - sh(generate_cmd)?; - // Formats BEFORE the copy, so the frozen dir is byte-identical to - // `candidate` rather than to its pre-format form. - sh("forge fmt") - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::sync::atomic::{AtomicUsize, Ordering}; - - static N: AtomicUsize = AtomicUsize::new(0); - - fn tmp_dir() -> PathBuf { - let d = std::env::temp_dir().join(format!( - "rainix-static-cut-release-test-{}-{}", - std::process::id(), - N.fetch_add(1, Ordering::SeqCst) - )); - std::fs::create_dir_all(&d).unwrap(); - d - } - - const ROOT: &str = "src/generated"; - - /// A deploy repo on the candidate model: a version, and a rolling candidate - /// snapshot holding one pointer file. - fn repo(version: &str) -> PathBuf { - let d = tmp_dir(); - std::fs::write( - d.join("foundry.toml"), - format!("[package]\nname = \"x-deploy\"\nversion = \"{version}\"\n"), - ) - .unwrap(); - std::fs::create_dir_all(d.join(ROOT).join(CANDIDATE)).unwrap(); - std::fs::write( - d.join(ROOT).join(CANDIDATE).join("X.pointers.sol"), - b"address constant DEPLOYED_ADDRESS = address(0xAAA);\n", - ) - .unwrap(); - d - } - - fn read(p: &Path) -> String { - String::from_utf8(std::fs::read(p).unwrap()).unwrap() - } - - /// A generator that records that it ran and does nothing else. - fn counting(count: &mut usize) -> impl FnMut() -> Result<(), String> + '_ { - move || { - *count += 1; - Ok(()) - } - } - - #[test] - fn freezes_candidate_into_the_numbered_dir() { - let d = repo("0.1.5"); - let mut ran = 0; - let tag = cut(&d, ROOT, &mut counting(&mut ran)).unwrap(); - assert_eq!(tag, "0_1_5"); - assert_eq!(ran, 1); - // The frozen dir is a byte-identical copy, and the gate recognises it. - assert!(is_tag(&tag)); - assert_eq!( - read(&d.join(ROOT).join("0_1_5/X.pointers.sol")), - read(&d.join(ROOT).join("candidate/X.pointers.sol")), - ); - // …and candidate itself is untouched by the freeze. - assert!(d.join(ROOT).join(CANDIDATE).is_dir()); - } - - #[test] - fn freezes_nested_files_too() { - let d = repo("1.2.3"); - std::fs::create_dir_all(d.join(ROOT).join("candidate/sub")).unwrap(); - std::fs::write(d.join(ROOT).join("candidate/sub/Y.pointers.sol"), b"y\n").unwrap(); - cut(&d, ROOT, &mut counting(&mut 0)).unwrap(); - assert_eq!(read(&d.join(ROOT).join("1_2_3/sub/Y.pointers.sol")), "y\n"); - } - - /// The ordering this tool exists to enforce: the frozen dir records what the - /// generator produced, not what was committed before it ran. - #[test] - fn regenerates_before_freezing() { - let d = repo("0.2.0"); - let candidate = d.join(ROOT).join(CANDIDATE); - let mut generate = || { - // A drifted candidate being brought back in line with source. - std::fs::write( - candidate.join("X.pointers.sol"), - b"address constant DEPLOYED_ADDRESS = address(0xBBB);\n", - ) - .unwrap(); - std::fs::write(candidate.join("New.pointers.sol"), b"new\n").unwrap(); - Ok(()) - }; - cut(&d, ROOT, &mut generate).unwrap(); - let frozen = d.join(ROOT).join("0_2_0"); - // Freezing first would have recorded 0xAAA — the address the release does - // not publish — and would not hold the new file at all. - assert_eq!( - read(&frozen.join("X.pointers.sol")), - "address constant DEPLOYED_ADDRESS = address(0xBBB);\n" - ); - assert_eq!(read(&frozen.join("New.pointers.sol")), "new\n"); - } - - /// The same ordering seen from the generator's side: nothing is frozen yet - /// while it runs. - #[test] - fn nothing_is_frozen_while_the_generator_runs() { - let d = repo("0.3.1"); - let frozen = d.join(ROOT).join("0_3_1"); - let mut existed_during_generate = true; - { - let frozen = frozen.clone(); - let mut generate = || { - existed_during_generate = frozen.exists(); - Ok(()) - }; - cut(&d, ROOT, &mut generate).unwrap(); - } - assert!(!existed_during_generate); - assert!(frozen.is_dir()); - } - - #[test] - fn refuses_when_the_generator_freezes_the_numbered_dir_itself() { - let d = repo("0.4.0"); - let root_dir = d.join(ROOT); - let mut generate = || { - std::fs::create_dir_all(root_dir.join("0_4_0")).unwrap(); - std::fs::write(root_dir.join("0_4_0/X.pointers.sol"), b"stale\n").unwrap(); - Ok(()) - }; - let err = cut(&d, ROOT, &mut generate).unwrap_err(); - assert!(err.contains("created"), "{err}"); - // The tool never wrote over it, and never blessed it as the release. - assert_eq!(read(&d.join(ROOT).join("0_4_0/X.pointers.sol")), "stale\n"); - } - - #[test] - fn a_failing_generator_freezes_nothing() { - let d = repo("0.5.0"); - let mut generate = || Err("boom".to_string()); - assert_eq!(cut(&d, ROOT, &mut generate).unwrap_err(), "boom"); - assert!(!d.join(ROOT).join("0_5_0").exists()); - } - - #[test] - fn rejects_versions_the_append_only_gate_would_ignore() { - // Each of these would freeze an orphan dir no gate protects. - for version in ["0.1.7-rc1", "0.1", "1.2.3.4", "0.1.5+build", "v0.1.5", ""] { - let d = repo(version); - let mut ran = 0; - let err = cut(&d, ROOT, &mut counting(&mut ran)).unwrap_err(); - assert!(err.contains("strict X.Y.Z"), "{version}: {err}"); - // The guard runs before any work: nothing regenerated, nothing frozen. - assert_eq!(ran, 0, "{version}"); - let dirs: Vec<_> = std::fs::read_dir(d.join(ROOT)) - .unwrap() - .map(|e| e.unwrap().file_name()) - .collect(); - assert_eq!(dirs, vec![std::ffi::OsString::from(CANDIDATE)], "{version}"); - } - } - - #[test] - fn accepts_every_strict_version() { - for (version, tag) in [ - ("0.1.5", "0_1_5"), - ("1.0.0", "1_0_0"), - ("12.0.255", "12_0_255"), - ("0.1.10", "0_1_10"), - ] { - let d = repo(version); - assert_eq!(cut(&d, ROOT, &mut counting(&mut 0)).unwrap(), tag); - assert!(d.join(ROOT).join(tag).is_dir()); - } - } - - #[test] - fn refuses_without_a_candidate() { - let d = repo("0.1.5"); - std::fs::remove_dir_all(d.join(ROOT).join(CANDIDATE)).unwrap(); - let mut ran = 0; - let err = cut(&d, ROOT, &mut counting(&mut ran)).unwrap_err(); - assert!(err.contains("rolling-candidate model"), "{err}"); - assert_eq!(ran, 0); - assert!(!d.join(ROOT).join("0_1_5").exists()); - } - - #[test] - fn refuses_an_empty_candidate() { - let d = repo("0.1.5"); - std::fs::remove_file(d.join(ROOT).join("candidate/X.pointers.sol")).unwrap(); - let err = cut(&d, ROOT, &mut counting(&mut 0)).unwrap_err(); - assert!(err.contains("nothing to freeze"), "{err}"); - assert!(!d.join(ROOT).join("0_1_5").exists()); - } - - #[test] - fn refuses_to_overwrite_a_frozen_snapshot() { - let d = repo("0.1.5"); - std::fs::create_dir_all(d.join(ROOT).join("0_1_5")).unwrap(); - std::fs::write(d.join(ROOT).join("0_1_5/X.pointers.sol"), b"frozen\n").unwrap(); - let mut ran = 0; - let err = cut(&d, ROOT, &mut counting(&mut ran)).unwrap_err(); - assert!(err.contains("already exists"), "{err}"); - assert_eq!(ran, 0); - // Untouched: the frozen bytes downstream consumers pin are still there. - assert_eq!(read(&d.join(ROOT).join("0_1_5/X.pointers.sol")), "frozen\n"); - } - - #[test] - fn refuses_without_a_package_version() { - let d = repo("0.1.5"); - std::fs::write(d.join("foundry.toml"), "[package]\nname = \"x-deploy\"\n").unwrap(); - let err = cut(&d, ROOT, &mut counting(&mut 0)).unwrap_err(); - assert!(err.contains("[package].version"), "{err}"); - } - - #[test] - fn write_tree_refuses_an_existing_dir() { - let d = tmp_dir(); - let dst = d.join("0_1_5"); - std::fs::create_dir(&dst).unwrap(); - let mut tree = BTreeMap::new(); - tree.insert(PathBuf::from("X.sol"), b"x".to_vec()); - assert!(write_tree(&dst, &tree).is_err()); - assert!(!dst.join("X.sol").exists()); - } - - #[test] - fn read_tree_is_relative_and_recursive() { - let d = tmp_dir(); - std::fs::create_dir_all(d.join("a/b")).unwrap(); - std::fs::write(d.join("top"), b"1").unwrap(); - std::fs::write(d.join("a/mid"), b"2").unwrap(); - std::fs::write(d.join("a/b/deep"), b"3").unwrap(); - let tree = read_tree(&d).unwrap(); - assert_eq!(tree.len(), 3); - assert_eq!(tree[Path::new("top")], b"1"); - assert_eq!(tree[Path::new("a/mid")], b"2"); - assert_eq!(tree[Path::new("a/b/deep")], b"3"); - } - - /// The consumer's generate command is arbitrary shell, so a failure anywhere - /// in it has to abort the cut — a release that freezes what a half-failed - /// generator left behind is the same silent-corruption class this tool exists - /// to close. - #[test] - fn a_command_that_fails_anywhere_fails_the_cut() { - assert!(sh("true").is_ok()); - assert!(sh("false").is_err()); - // -e: an early failure is not hidden by a later success. - assert!(sh("false; true").is_err()); - // -o pipefail: nor by a successful tail of a pipe. - assert!(sh("false | true").is_err()); - // -u: an unset variable (a typo'd path) is an error, not an empty string. - assert!(sh("echo \"${RAINIX_CUT_RELEASE_UNSET_PROBE}\"").is_err()); - } -} diff --git a/rainix-static/src/main.rs b/rainix-static/src/main.rs index 1013dbc..af1910b 100644 --- a/rainix-static/src/main.rs +++ b/rainix-static/src/main.rs @@ -3,8 +3,7 @@ // inline bash or Python in a workflow. Per the "tooling is Rust" rule (CLAUDE.md), // logic — hashing, JSON parsing, version math, content gates — lives here as one // testable binary; workflows only orchestrate it. Each subcommand is its own -// module: static checks in `no_submodules`, CI release tooling in `soldeer_gate` -// and `cut_release`. +// module: static checks in `no_submodules`, CI release tooling in `soldeer_gate`. // // Static checks print their offenders and exit nonzero on failure (": clean" // otherwise). Tooling subcommands print machine outputs (key=value lines) to the @@ -46,14 +45,6 @@ // `forge soldeer push --dry-run` would upload against the latest published // revision, and emit changed / version / next. Runs inside sol-shell, so // `forge` and `curl` are on PATH. -// cut-release [--root ] [--generate-cmd ] -// Deploy-repo release freeze: regenerate /candidate/ with the pointer -// generation command (default `forge script ./script/BuildPointers.sol`), -// `forge fmt`, then copy it to / for the foundry.toml -// [package].version (default root src/generated). Regenerate-then-freeze is -// enforced here so no caller can invert it — the reverse order permanently -// records an address the release does not publish. Runs inside sol-shell, -// so `forge` is on PATH. // rpc-preflight [--root ] [--github-env ] [--samples N] // [--timeout N] [--no-archive] // Pick a working fork RPC endpoint per network and export it as @@ -64,7 +55,6 @@ mod agent_context_cap; mod context_bytes; -mod cut_release; mod frozen_snapshots; mod no_submodules; mod prompt_cap; @@ -166,18 +156,6 @@ fn main() { .unwrap_or_else(|| fail("soldeer-gate: --package required")); soldeer_gate::run(&pkg, flag(&args, "--github-output").as_deref()); } - "cut-release" => { - let root = flag(&args, "--root").unwrap_or_else(|| "src/generated".to_string()); - // An empty --generate-cmd is an unset workflow input, not a request to - // run nothing: fall back to the convention. - let cmd = flag(&args, "--generate-cmd") - .filter(|c| !c.trim().is_empty()) - .unwrap_or_else(|| cut_release::DEFAULT_GENERATE_CMD.to_string()); - match cut_release::run(&root, &cmd) { - Err(e) => fail(&e), - Ok(tag) => println!("cut-release: froze {root}/candidate -> {root}/{tag}"), - } - } "snapshots-append-only" => { let base = flag(&args, "--base").unwrap_or_else(|| "origin/main".to_string()); let root = flag(&args, "--root").unwrap_or_else(|| "src/generated".to_string()); @@ -219,7 +197,7 @@ fn main() { eprintln!( "rainix-static: unknown subcommand {other:?} \ (available: no-submodules, agent-context-cap, prompt-cap, \ - snapshots-append-only, soldeer-gate, cut-release, rpc-preflight)" + snapshots-append-only, soldeer-gate, rpc-preflight)" ); std::process::exit(2); } diff --git a/rainix-static/src/soldeer_gate.rs b/rainix-static/src/soldeer_gate.rs index df879c2..57c708e 100644 --- a/rainix-static/src/soldeer_gate.rs +++ b/rainix-static/src/soldeer_gate.rs @@ -137,9 +137,7 @@ fn parse_registry(json: &str) -> (Option, Option) { /// First `[package].version` value in foundry.toml (the in-dev, unpublished /// version). Reads the value between the first pair of quotes on that line. -/// Shared with `cut-release`, which reads the same line as the release version: -/// one parser, so the two lifecycles can never disagree about what that line is. -pub(crate) fn read_local_version(dir: &Path) -> Option { +fn read_local_version(dir: &Path) -> Option { let content = std::fs::read_to_string(dir.join("foundry.toml")).ok()?; for line in content.lines() { if is_version_line(line) {