From d053f18c5466fc6c69f03843bc1b998e14e2d2b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 18:51:18 +0000 Subject: [PATCH 1/4] ci: run codegen to a fixed point, with a bound, instead of once Generated sources are inputs to their own generation, so one pass of the regeneration pipeline applies the generation function rather than reaching its fixed point. Run once, a tree several passes behind and a generation cycle that never settles both reach the currency check as "stale". `rainix-static codegen-fixed-point` loops the pipeline until the working tree stops changing, observing the tree as a git tree object built in a scratch index so .gitignore applies, untracked output counts, and the repo's own index is left for the currency check. `max-codegen-passes` bounds it; exhausting the bound fails with its own error. Closes https://github.com/rainlanguage/rainix/issues/314 Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/rainix-copy-artifacts.yaml | 80 ++-- README.md | 37 +- flake.nix | 15 +- rainix-static/src/codegen_fixed_point.rs | 391 +++++++++++++++++++ rainix-static/src/main.rs | 38 +- 5 files changed, 524 insertions(+), 37 deletions(-) create mode 100644 rainix-static/src/codegen_fixed_point.rs diff --git a/.github/workflows/rainix-copy-artifacts.yaml b/.github/workflows/rainix-copy-artifacts.yaml index 1157a67..cf4a857 100644 --- a/.github/workflows/rainix-copy-artifacts.yaml +++ b/.github/workflows/rainix-copy-artifacts.yaml @@ -1,6 +1,16 @@ name: rainix-copy-artifacts on: workflow_call: + inputs: + # One pass propagates one level of the generated-source dependency chain, + # so this bounds that depth rather than counting retries. Raising it is + # only ever correct for a repo whose generated sources genuinely nest + # deeper than the default. + max-codegen-passes: + description: Passes of the regeneration pipeline allowed before the repo is declared non-converging. + type: number + default: 5 + required: false env: RAINIX_SHA: 53e96a7d0a97d7c7c75c3b2412521324776fdac6 jobs: @@ -21,40 +31,60 @@ jobs: - name: Install soldeer dependencies if: hashFiles('soldeer.lock') != '' run: nix develop github:rainlanguage/rainix/${{ env.RAINIX_SHA }}#sol-shell -c forge soldeer install - # Currency-check every committed generated artifact by re-running each - # consumer-provided codegen step. The final git diff fails if any - # committed file has drifted from its source. The build-meta.sh hook is - # consumer-supplied because rain meta build's invocation (input/output - # filenames, meta type) varies per repo. - - name: Regenerate meta artifacts - if: hashFiles('script/build-meta.sh') != '' - run: nix develop github:rainlanguage/rainix/${{ env.RAINIX_SHA }}#sol-shell -c ./script/build-meta.sh # Committed generated sources must be regenerable here, or the currency # check below passes without checking anything. The codegen script is # `script/Build.sol`, matched exactly: a repo that renames or drops it # goes red rather than skipping regeneration and reporting green. - - name: Regenerate generated sources + - name: Require a codegen script for committed generated sources run: | if [ -d src/generated ] && [ ! -f script/Build.sol ]; then echo "::error::src/generated/ is committed but script/Build.sol was not found, so the committed sources cannot be currency checked here. The codegen script must be script/Build.sol." exit 1 fi - if [ -f script/Build.sol ]; then - nix develop github:rainlanguage/rainix/${{ env.RAINIX_SHA }}#sol-shell -c forge script ./script/Build.sol - fi - - name: Build Solidity - run: nix develop github:rainlanguage/rainix/${{ env.RAINIX_SHA }}#sol-shell -c forge build - - name: Copy forge artifacts into committed location - if: hashFiles('script/CopyArtifacts.sol') != '' - run: nix develop github:rainlanguage/rainix/${{ env.RAINIX_SHA }}#sol-shell -c forge script ./script/CopyArtifacts.sol --ffi - # Catch-all post-forge regen hook: consumer-supplied. Runs outside any - # nix devshell so the script picks shells per command (subgraph-shell, - # sol-shell, etc.) for whatever derived artifacts it emits. - - name: Regenerate derived artifacts - if: hashFiles('script/build.sh') != '' - run: ./script/build.sh - - name: Format (so generated artifacts match committed style) - run: nix develop github:rainlanguage/rainix/${{ env.RAINIX_SHA }}#sol-shell -c forge fmt + # Currency-check every committed generated artifact by re-running each + # consumer-provided codegen step. The final git diff fails if any + # committed file has drifted from its source. The build-meta.sh hook is + # consumer-supplied because rain meta build's invocation (input/output + # filenames, meta type) varies per repo; build.sh is the catch-all + # post-forge hook, and is the one command here that is NOT wrapped in a + # devshell, so it can pick shells per command (subgraph-shell, sol-shell, + # etc.) for whatever derived artifacts it emits. + # + # The pipeline is looped rather than run once because its output is part + # of its own input: a pointer table is imported by the contract whose + # codehash that same table records, so one pass is one application of the + # generation function and not its fixed point. Run once, a tree that is + # several passes behind and a generation cycle that will never settle both + # reach the currency check as "stale", and its advice — regenerate and + # commit — silently only fixes the first. `codegen-fixed-point` re-runs the + # pipeline until the working tree stops changing and fails with its own + # error once `max-codegen-passes` is spent, so the two are told apart by + # the machine instead of by a developer running the loop by hand until + # they guess it is never going to settle. It observes the tree in a + # scratch index, leaving the repo's own index to the check below. + # + # A repo already at its fixed point costs exactly one pass, the same as no + # loop at all; the second pass is only ever paid by a build that is + # already going red. + - name: Regenerate committed artifacts to a fixed point + run: | + nix run github:rainlanguage/rainix/${{ env.RAINIX_SHA }}#rainix-static -- codegen-fixed-point --max-passes ${{ inputs.max-codegen-passes }} --run ' + set -eu + if [ -f script/build-meta.sh ]; then + nix develop github:rainlanguage/rainix/${{ env.RAINIX_SHA }}#sol-shell -c ./script/build-meta.sh + fi + if [ -f script/Build.sol ]; then + nix develop github:rainlanguage/rainix/${{ env.RAINIX_SHA }}#sol-shell -c forge script ./script/Build.sol + fi + nix develop github:rainlanguage/rainix/${{ env.RAINIX_SHA }}#sol-shell -c forge build + if [ -f script/CopyArtifacts.sol ]; then + nix develop github:rainlanguage/rainix/${{ env.RAINIX_SHA }}#sol-shell -c forge script ./script/CopyArtifacts.sol --ffi + fi + if [ -f script/build.sh ]; then + ./script/build.sh + fi + nix develop github:rainlanguage/rainix/${{ env.RAINIX_SHA }}#sol-shell -c forge fmt + ' - name: Assert committed artifacts match freshly built run: | if ! git diff --exit-code; then diff --git a/README.md b/README.md index c0fe74e..219b198 100644 --- a/README.md +++ b/README.md @@ -147,10 +147,12 @@ Solidity artifacts from source and asserts `git diff --exit-code` — failing th PR if a maintainer changed source without committing the regenerated files. In a single job it runs whichever of these the repo has: -- `./script/BuildPointers.sol` → `src/generated/*.pointers.sol` +- `./script/build-meta.sh` → committed rain meta artifacts +- `./script/Build.sol` → `src/generated/*.sol` - `forge build` + `./script/CopyArtifacts.sol --ffi` → committed ABI JSON +- `./script/build.sh` → any other derived artifact -then `forge fmt` and the `git diff` assert. +then `forge fmt`, and the `git diff` assert once that pipeline has settled. ```yaml name: copy-artifacts @@ -162,9 +164,34 @@ jobs: ``` This replaces the former `rainix-build-pointers` reusable — a pointer-only repo -just omits `CopyArtifacts.sol` (the copy step is skipped via `hashFiles`). -Always runs through rainix's `sol-shell` (slim), regardless of the consumer's -default devShell. `secrets: inherit` carries `CACHIX_AUTH_TOKEN`. +just omits `CopyArtifacts.sol` (the copy step is skipped when the file is +absent). Always runs through rainix's `sol-shell` (slim), regardless of the +consumer's default devShell. `secrets: inherit` carries `CACHIX_AUTH_TOKEN`. + +##### The pipeline runs to a fixed point, not once + +Generated sources are inputs to their own generation: a pointer table is +imported by the contract whose codehash that same table records, so one pass of +the pipeline applies the generation function rather than reaching its fixed +point. The job therefore repeats the whole pipeline until the working tree stops +changing, and a repo already at its fixed point pays exactly one pass. + +`max-codegen-passes` (default `5`) bounds that. Exhausting it fails the job with +its own error — a generation cycle that does not settle, distinct from committed +artifacts that were merely not regenerated, which is what the currency check +reports. Committing whichever pass happened to diff clean is the trap the bound +exists to prevent: it records a `BYTECODE_HASH` for a contract compiled against +a different pass of the same file. Raise the bound only for a repo whose +generated sources genuinely nest deeper than five levels: + +```yaml +jobs: + copy-artifacts: + uses: rainlanguage/rainix/.github/workflows/rainix-copy-artifacts.yaml@main + secrets: inherit + with: + max-codegen-passes: 8 +``` #### rainix-rs-static diff --git a/flake.nix b/flake.nix index c390c0b..6ffe222 100644 --- a/flake.nix +++ b/flake.nix @@ -207,16 +207,19 @@ cargoLock.lockFile = ./rainix-static/Cargo.lock; nativeCheckInputs = [ pkgs.git ]; nativeBuildInputs = [ pkgs.makeWrapper ]; - # The subcommands shell out to git (`ls-files`, `diff`) and curl - # (`rpc-preflight`'s probes, `soldeer-gate`'s fetches). The composite - # actions invoke this binary with `nix run`, i.e. OUTSIDE any devshell, - # so ambient PATH is whatever the runner image happens to ship. Wrap it - # with the pinned tools and a CA bundle so the checks are hermetic and - # cannot fail on a host with no curl, no git, or no root certs. + # The subcommands shell out to git (`ls-files`, `diff`, + # `codegen-fixed-point`'s tree observations), curl (`rpc-preflight`'s + # probes, `soldeer-gate`'s fetches) and bash (the regeneration pipeline + # `codegen-fixed-point` loops over). The composite actions invoke this + # binary with `nix run`, i.e. OUTSIDE any devshell, so ambient PATH is + # whatever the runner image happens to ship. Wrap it with the pinned + # tools and a CA bundle so the checks are hermetic and cannot fail on a + # host with no curl, no git, no bash, or no root certs. postInstall = '' wrapProgram $out/bin/rainix-static \ --prefix PATH : ${ pkgs.lib.makeBinPath [ + pkgs.bash pkgs.curl pkgs.git ] diff --git a/rainix-static/src/codegen_fixed_point.rs b/rainix-static/src/codegen_fixed_point.rs new file mode 100644 index 0000000..92c6006 --- /dev/null +++ b/rainix-static/src/codegen_fixed_point.rs @@ -0,0 +1,391 @@ +//! `codegen-fixed-point` — run a repo's regeneration pipeline until the working +//! tree stops changing, under a bound. +//! +//! Generated Solidity feeds back into its own inputs: a pointer table is +//! imported by the contract whose codehash that same table records, so one pass +//! of the pipeline is not a fixed point — it is one application of a function +//! whose output is part of its next input. A pipeline run once and then diffed +//! reports "stale" for three different states, and a developer told to +//! regenerate can only distinguish them by hand: a tree that is one pass behind, +//! a tree that is several passes behind, and a pipeline that will never settle. +//! +//! Iterating here collapses the first two into a pass and separates the third +//! out with its own error. The bound is what makes non-convergence reportable at +//! all — an unbounded loop on an oscillating pipeline is a hung job, which is +//! the same non-diagnosis as the single pass, paid for in runner minutes. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// Why the loop stopped. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum Outcome { + /// The pipeline ran `passes` times and the last one changed nothing. The + /// working tree holds the fixed point. Whether it MATCHES what is committed + /// is a separate question, answered by the caller's currency check: a repo + /// already at its fixed point converges in 1, a repo whose committed + /// artifacts are one or more passes behind converges in more than 1 and + /// leaves a tree that differs from `HEAD`. + Converged { passes: u32 }, + /// The tree was still moving when the bound was spent, so no fixed point was + /// observed and none can be reported to exist. + NotConverged { passes: u32 }, +} + +/// Run `command` (via `bash -c`, in `root`) until two consecutive observations +/// of the working tree agree, or until `max_passes` is spent. +/// +/// The first comparison is against the tree as it was BEFORE any pass, so an +/// already-current repo costs exactly one pass — the same pipeline cost it paid +/// when the pipeline ran once with no loop around it. +/// +/// Pollution already present in the checkout is in that first observation, so it +/// is not mistaken for something a pass emitted. +pub(crate) fn run(root: &Path, max_passes: u32, command: &str) -> Result { + if max_passes == 0 { + return Err("--max-passes must be at least 1".to_string()); + } + let index = index_path(root)?; + let result = iterate(root, max_passes, command, &index); + let _ = std::fs::remove_file(&index); + result +} + +fn iterate(root: &Path, max_passes: u32, command: &str, index: &Path) -> Result { + let mut previous = snapshot(root, index)?; + for pass in 1..=max_passes { + run_pipeline(root, command, pass, max_passes)?; + let current = snapshot(root, index)?; + if current == previous { + return Ok(Outcome::Converged { passes: pass }); + } + previous = current; + } + Ok(Outcome::NotConverged { passes: max_passes }) +} + +/// One pass of the consumer's pipeline. A pass that fails is not a +/// non-convergence: the pipeline itself is broken and its own error is the one +/// worth reporting, so it stops the loop rather than burning the bound. +fn run_pipeline(root: &Path, command: &str, pass: u32, max_passes: u32) -> Result<(), String> { + println!("codegen-fixed-point: pass {pass} of at most {max_passes}"); + let status = Command::new("bash") + .arg("-c") + .arg(command) + .current_dir(root) + .status() + .map_err(|e| format!("failed to run the regeneration command: {e}"))?; + if status.success() { + Ok(()) + } else { + Err(format!( + "the regeneration command failed on pass {pass} ({status})" + )) + } +} + +/// Content hash of the whole working tree, as git's own tree object id. +/// +/// Built in a scratch index so the repo's real index is never written: the +/// currency check that runs after this one stages the tree itself, and a loop +/// that had already staged everything would leave it nothing to find. +/// +/// Reading through git rather than walking the filesystem is what makes +/// `.gitignore` apply for free, so `out/`, `cache/` and `dependencies/` — which +/// every pass rewrites and no repo commits — do not read as a tree that never +/// settles. It also makes the observation content-addressed, so a pass that +/// rewrites a file with the same bytes is correctly seen as no change, and one +/// that oscillates between two contents of the same path is correctly seen as a +/// change. +fn snapshot(root: &Path, index: &Path) -> Result { + // A stale scratch index would carry entries for files a later pass deleted. + let _ = std::fs::remove_file(index); + git(root, index, &["add", "--all"])?; + git(root, index, &["write-tree"]) +} + +fn git(root: &Path, index: &Path, args: &[&str]) -> Result { + let out = Command::new("git") + .arg("-C") + .arg(root) + .args(args) + .env("GIT_INDEX_FILE", index) + .output() + .map_err(|e| format!("failed to run git {}: {e}", args.join(" ")))?; + if !out.status.success() { + return Err(format!( + "git {} failed: {}", + args.join(" "), + String::from_utf8_lossy(&out.stderr).trim() + )); + } + Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) +} + +/// Absolute path for the scratch index, inside the repo's own git dir so it +/// shares the repo's filesystem and is invisible to every path the checks read. +fn index_path(root: &Path) -> Result { + let out = Command::new("git") + .arg("-C") + .arg(root) + .args(["rev-parse", "--absolute-git-dir"]) + .output() + .map_err(|e| format!("failed to run git rev-parse: {e}"))?; + if !out.status.success() { + return Err(format!( + "{} is not a git repository: {}", + root.display(), + String::from_utf8_lossy(&out.stderr).trim() + )); + } + Ok(Path::new(String::from_utf8_lossy(&out.stdout).trim()) + .join("rainix-codegen-fixed-point.index")) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + + static N: AtomicUsize = AtomicUsize::new(0); + + /// A consumer checkout: a git repo with one committed generated artifact and + /// a `.gitignore` covering the directories forge writes to. + struct Fixture { + dir: PathBuf, + repo: PathBuf, + } + + impl Fixture { + fn new() -> Self { + let dir = std::env::temp_dir().join(format!( + "rainix-static-fixedpoint-test-{}-{}", + std::process::id(), + N.fetch_add(1, Ordering::SeqCst) + )); + let repo = dir.join("repo"); + std::fs::create_dir_all(&repo).unwrap(); + for args in [ + vec!["init", "-q", "-b", "main"], + vec!["config", "user.email", "rainix@example.com"], + vec!["config", "user.name", "rainix"], + ] { + assert!(Command::new("git") + .arg("-C") + .arg(&repo) + .args(&args) + .status() + .unwrap() + .success()); + } + std::fs::write(repo.join(".gitignore"), "out/\ncache/\n").unwrap(); + std::fs::create_dir_all(repo.join("src/generated")).unwrap(); + std::fs::write(repo.join("src/generated/A.sol"), "pass 0\n").unwrap(); + assert!(Command::new("git") + .arg("-C") + .arg(&repo) + .args(["add", "--all"]) + .status() + .unwrap() + .success()); + assert!(Command::new("git") + .arg("-C") + .arg(&repo) + .args(["commit", "-qm", "committed artifacts"]) + .status() + .unwrap() + .success()); + Fixture { dir, repo } + } + + /// Path OUTSIDE the repo holding the pass counter, so counting does not + /// itself perturb the tree the loop is observing. + fn counter(&self) -> PathBuf { + self.dir.join("passes") + } + + fn passes_run(&self) -> u32 { + std::fs::read_to_string(self.counter()) + .map(|s| s.trim().len() as u32) + .unwrap_or(0) + } + + /// A pipeline that bumps the out-of-tree counter, then writes whatever + /// `body` computes into the committed artifact. `$n` is the pass number. + fn pipeline(&self, body: &str) -> String { + format!( + "printf x >> {counter}; n=$(wc -c < {counter} | tr -d ' '); {body}", + counter = self.counter().display(), + ) + } + + fn artifact(&self) -> String { + std::fs::read_to_string(self.repo.join("src/generated/A.sol")).unwrap() + } + } + + impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.dir); + } + } + + #[test] + fn a_repo_already_at_its_fixed_point_costs_one_pass() { + let f = Fixture::new(); + let cmd = f.pipeline("printf 'pass 0\\n' > src/generated/A.sol"); + + assert_eq!(run(&f.repo, 5, &cmd), Ok(Outcome::Converged { passes: 1 })); + assert_eq!( + f.passes_run(), + 1, + "an unchanged repo must not pay a second pass" + ); + } + + #[test] + fn a_stale_repo_converges_and_leaves_the_regenerated_tree() { + let f = Fixture::new(); + let cmd = f.pipeline("printf 'regenerated\\n' > src/generated/A.sol"); + + assert_eq!(run(&f.repo, 5, &cmd), Ok(Outcome::Converged { passes: 2 })); + assert_eq!(f.artifact(), "regenerated\n"); + } + + #[test] + fn generation_that_settles_only_after_several_passes_still_converges() { + let f = Fixture::new(); + // Each pass copies the previous pass's number, so the artifact chases + // the counter and settles once the counter stops being read fresh — + // here, a value that stops moving at pass 3. + let cmd = f.pipeline( + "if [ \"$n\" -lt 3 ]; then printf 'pass %s\\n' \"$n\" > src/generated/A.sol; fi", + ); + + assert_eq!(run(&f.repo, 5, &cmd), Ok(Outcome::Converged { passes: 3 })); + } + + #[test] + fn oscillating_generation_is_reported_as_not_converged() { + let f = Fixture::new(); + let cmd = f.pipeline("printf 'pass %s\\n' \"$((n % 2))\" > src/generated/A.sol"); + + assert_eq!( + run(&f.repo, 5, &cmd), + Ok(Outcome::NotConverged { passes: 5 }) + ); + assert_eq!( + f.passes_run(), + 5, + "the bound is what stops it, so it is spent in full" + ); + } + + #[test] + fn the_bound_is_the_bound() { + let f = Fixture::new(); + let cmd = f.pipeline( + "if [ \"$n\" -lt 3 ]; then printf 'pass %s\\n' \"$n\" > src/generated/A.sol; fi", + ); + + // The same pipeline that converges in 3 is not converged in 2. + assert_eq!( + run(&f.repo, 2, &cmd), + Ok(Outcome::NotConverged { passes: 2 }) + ); + } + + #[test] + fn a_file_no_pass_has_committed_yet_counts_as_a_change() { + let f = Fixture::new(); + // Nothing tracked ever changes, so `git diff` sees nothing at all here. + let cmd = f.pipeline("printf 'renamed\\n' > src/generated/B.sol"); + + assert_eq!(run(&f.repo, 5, &cmd), Ok(Outcome::Converged { passes: 2 })); + } + + #[test] + fn gitignored_build_output_does_not_look_like_a_moving_tree() { + let f = Fixture::new(); + // Rewriting out/ every pass is what forge does; it must not read as a + // pipeline that never settles. + let cmd = f.pipeline( + "mkdir -p out cache; printf '%s' \"$n\" > out/A.json; printf '%s' \"$n\" > cache/x", + ); + + assert_eq!(run(&f.repo, 5, &cmd), Ok(Outcome::Converged { passes: 1 })); + } + + #[test] + fn the_repos_own_index_is_left_for_the_currency_check_to_stage() { + let f = Fixture::new(); + let cmd = f.pipeline("printf 'regenerated\\n' > src/generated/A.sol"); + + assert_eq!(run(&f.repo, 5, &cmd), Ok(Outcome::Converged { passes: 2 })); + + let status = Command::new("git") + .arg("-C") + .arg(&f.repo) + .args(["status", "--porcelain"]) + .output() + .unwrap(); + // Trimmed, an unstaged modification's " M path" is "M path"; a staged + // one would read "M path" instead. + assert_eq!( + String::from_utf8_lossy(&status.stdout).trim(), + "M src/generated/A.sol", + "the regenerated file must still be unstaged" + ); + } + + #[test] + fn a_scratch_index_is_not_left_behind() { + let f = Fixture::new(); + let cmd = f.pipeline("printf 'regenerated\\n' > src/generated/A.sol"); + + run(&f.repo, 5, &cmd).unwrap(); + + assert!(!f + .repo + .join(".git/rainix-codegen-fixed-point.index") + .exists()); + } + + #[test] + fn a_failing_pipeline_stops_the_loop_and_reports_itself() { + let f = Fixture::new(); + let cmd = f.pipeline("printf 'regenerated\\n' > src/generated/A.sol; exit 3"); + + let err = run(&f.repo, 5, &cmd).unwrap_err(); + + assert!(err.contains("regeneration command failed"), "{err}"); + assert!(err.contains("pass 1"), "{err}"); + assert_eq!(f.passes_run(), 1, "a broken pipeline must not be retried"); + } + + #[test] + fn a_bound_of_zero_is_rejected_rather_than_passing_without_running() { + let f = Fixture::new(); + let cmd = f.pipeline("printf 'regenerated\\n' > src/generated/A.sol"); + + let err = run(&f.repo, 0, &cmd).unwrap_err(); + + assert!(err.contains("at least 1"), "{err}"); + assert_eq!(f.passes_run(), 0); + } + + #[test] + fn a_directory_that_is_not_a_repo_is_an_error_not_a_pass() { + let dir = std::env::temp_dir().join(format!( + "rainix-static-fixedpoint-norepo-{}-{}", + std::process::id(), + N.fetch_add(1, Ordering::SeqCst) + )); + std::fs::create_dir_all(&dir).unwrap(); + + let err = run(&dir, 5, "true").unwrap_err(); + + assert!(err.contains("not a git repository"), "{err}"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/rainix-static/src/main.rs b/rainix-static/src/main.rs index af1910b..8a58e65 100644 --- a/rainix-static/src/main.rs +++ b/rainix-static/src/main.rs @@ -45,6 +45,16 @@ // `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. +// codegen-fixed-point --run [--max-passes N] [--root ] +// Run a repo's regeneration pipeline until the working tree stops +// changing, or fail once N passes are spent. Generated sources feed back +// into their own inputs, so one pass is one application of the generation +// function rather than its fixed point, and a tree that is several passes +// behind is indistinguishable from one that will never settle unless the +// loop and its bound are in the machine. The tree is observed as a git +// tree object built in a scratch index, so .gitignore applies, untracked +// output counts, and the repo's own index is left untouched for the +// currency check that follows. // rpc-preflight [--root ] [--github-env ] [--samples N] // [--timeout N] [--no-archive] // Pick a working fork RPC endpoint per network and export it as @@ -54,6 +64,7 @@ // with hardcoded public archive defaults. Never prints a candidate URL. mod agent_context_cap; +mod codegen_fixed_point; mod context_bytes; mod frozen_snapshots; mod no_submodules; @@ -170,6 +181,30 @@ fn main() { } } } + "codegen-fixed-point" => { + let root = flag(&args, "--root").unwrap_or_else(|| ".".to_string()); + let command = flag(&args, "--run") + .unwrap_or_else(|| fail("codegen-fixed-point: --run required")); + let max_passes = num(&args, "--max-passes", 5); + match codegen_fixed_point::run(Path::new(&root), max_passes, &command) { + Err(e) => fail(&format!("codegen-fixed-point: {e}")), + Ok(codegen_fixed_point::Outcome::Converged { passes }) => { + println!("codegen-fixed-point: fixed point reached after {passes} pass(es)") + } + // Distinct from the currency check that follows: that one says + // the committed tree is behind, which is fixed by regenerating + // and committing. This one says regenerating will not help, + // because the generation never settles. + Ok(codegen_fixed_point::Outcome::NotConverged { passes }) => fail(&format!( + "Codegen did not reach a fixed point in {passes} passes. Generated sources \ + feed back into their own inputs, so this is a generation cycle that does \ + not settle rather than artifacts that were not regenerated — committing \ + any one pass leaves a tree whose recorded codehashes describe a different \ + pass. Fix the cycle, or raise --max-passes if this repo genuinely needs \ + more than {passes}." + )), + } + } "rpc-preflight" => { let root = flag(&args, "--root").unwrap_or_else(|| ".".to_string()); // There is no stdout fallback on purpose: the selected URL may be @@ -197,7 +232,8 @@ 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, \ + codegen-fixed-point)" ); std::process::exit(2); } From 035a13f7cbdf0bc2c6c4fc57c02eabaf005eb0e7 Mon Sep 17 00:00:00 2001 From: David Meister Date: Sun, 16 Aug 2026 19:02:38 +0000 Subject: [PATCH 2/4] test: drop an inert index reset, and name the root in the not-a-repo error The scratch-index `remove_file` before `git add --all` could not change any observation: `--all` already updates entries whose content moved and drops entries whose file is gone, so the tree it writes describes the working tree as it is now either way. A mutant deleting the line survived the whole suite, which is the proof it was inert rather than untested, so it goes rather than staying behind a mutant no test can justify. The not-a-repo error now asserts the root path is in the message. git's own "not a git repository" names no path, so without this an operator who pointed the loop at the wrong directory learns only that some directory was wrong; deleting the wrapping error left the assertion satisfied by git's message alone. Co-Authored-By: Claude Opus 5 (1M context) --- rainix-static/src/codegen_fixed_point.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/rainix-static/src/codegen_fixed_point.rs b/rainix-static/src/codegen_fixed_point.rs index 92c6006..ed79472 100644 --- a/rainix-static/src/codegen_fixed_point.rs +++ b/rainix-static/src/codegen_fixed_point.rs @@ -98,8 +98,10 @@ fn run_pipeline(root: &Path, command: &str, pass: u32, max_passes: u32) -> Resul /// that oscillates between two contents of the same path is correctly seen as a /// change. fn snapshot(root: &Path, index: &Path) -> Result { - // A stale scratch index would carry entries for files a later pass deleted. - let _ = std::fs::remove_file(index); + // The scratch index carries over between observations, which `git add --all` + // is defined to reconcile: it updates entries whose content moved and drops + // entries whose file is gone, so the tree it writes describes the working + // tree as it is now and not the union of every pass so far. git(root, index, &["add", "--all"])?; git(root, index, &["write-tree"]) } @@ -386,6 +388,9 @@ mod tests { let err = run(&dir, 5, "true").unwrap_err(); assert!(err.contains("not a git repository"), "{err}"); + // git's own message names no path, so an operator who pointed the loop + // at the wrong directory learns which one only if this says so. + assert!(err.contains(&dir.display().to_string()), "{err}"); let _ = std::fs::remove_dir_all(&dir); } } From b5a880ed2a946ecf38cbf4bbbfa33adc8e68c24b Mon Sep 17 00:00:00 2001 From: David Meister Date: Sun, 16 Aug 2026 19:15:07 +0000 Subject: [PATCH 3/4] ci: resolve the loop binary from the action's own checkout, and cover the wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The step invoked `nix run github:rainlanguage/rainix/$RAINIX_SHA#rainix-static`, and `codegen-fixed-point` is added by this same branch. A pinned flake ref can only ever name a commit that predates it, so from the moment this merged until a follow-up bumped the pin, every consumer's copy-artifacts job would have failed with "unknown subcommand" — the rainix-static half of `80e9432`'s soldeer-gate window, reopened. rainix already answers this. Five of the six rainix-static entry points are composite actions resolving the binary with a `path:` flake ref out of `$GITHUB_ACTION_PATH`, and rpc-preflight's comment says why: "the check version always matches the action version regardless of any RAINIX_SHA the caller pins". codegen-fixed-point needs no devshell — that is what the wrapper's bash/git/curl PATH is for — so nothing kept it from the same shape. The action and the workflow calling it land in one commit, so `@main` resolves both at once and the window is zero rather than merely short. The bound is now required on the action rather than defaulted, leaving exactly one published default: the workflow input the README documents. Two bats suites, registered in default-shell-test alongside the existing action tests. The workflow suite reads the step's pipeline and the composite's script out of the shipped YAML, stubs only `nix` and `forge` as files on PATH, and runs the REAL rainix-static against fixture consumer checkouts, so what is covered is the wiring rather than a restatement of it. The action suite pins the argv the composite builds, including that a multi-line pipeline survives as one argument and that the action never evaluates what it is handed. Co-Authored-By: Claude Opus 5 (1M context) --- .../actions/codegen-fixed-point/action.yml | 37 +++ .github/workflows/rainix-copy-artifacts.yaml | 12 +- flake.nix | 2 + .../bats/action/codegen-fixed-point.test.bats | 118 ++++++++ .../copy-artifacts-fixed-point.test.bats | 272 ++++++++++++++++++ 5 files changed, 438 insertions(+), 3 deletions(-) create mode 100644 .github/actions/codegen-fixed-point/action.yml create mode 100644 test/bats/action/codegen-fixed-point.test.bats create mode 100644 test/bats/workflow/copy-artifacts-fixed-point.test.bats diff --git a/.github/actions/codegen-fixed-point/action.yml b/.github/actions/codegen-fixed-point/action.yml new file mode 100644 index 0000000..67c73b1 --- /dev/null +++ b/.github/actions/codegen-fixed-point/action.yml @@ -0,0 +1,37 @@ +name: codegen-fixed-point +description: >- + Runs a repo's regeneration pipeline until its working tree stops changing, under a bound, so a generation cycle that never settles reports itself instead of arriving at the currency check wearing the same face as artifacts someone simply forgot to regenerate. Generated Solidity is an input to its own generation — a pointer table is imported by the contract whose codehash that same table records — so one pass applies the generation function rather than reaching its fixed point, and a tree one pass behind, a tree several passes behind, and a pipeline that will never converge are three states a single pass cannot tell apart. The bound is what makes the third reportable at all: an unbounded loop on an oscillating pipeline is a hung job, which is the same non-diagnosis as the single pass, paid for in runner minutes. The tree is observed as a git tree object written through a scratch index, so .gitignore applies for free (out/, cache/ and dependencies/ are rewritten every pass and committed by nobody), untracked output still counts as a change, comparison is by content rather than path, and the repo's own index is left unstaged for whatever currency check follows. A repo already at its fixed point costs exactly one pass — the same pipeline cost it paid with no loop around it. +inputs: + run: + description: >- + The regeneration pipeline, as a shell script run by bash from the repo root once per pass. Every command it needs must be self-contained, including any `nix develop` wrapping: this runs outside every devshell, which is what lets one pipeline pick a different shell per command. A pass that exits non-zero stops the loop and reports itself rather than burning the bound, because a broken pipeline is not a non-converging one. + required: true + max-passes: + description: >- + Passes allowed before the repo is declared non-converging. One pass propagates one level of the generated-source dependency chain, so this bounds that nesting depth rather than counting retries; raising it is only ever correct for a repo whose generated sources genuinely nest deeper. Required rather than defaulted on purpose: the caller already publishes a default to ITS callers, and a second default here would be a second number to keep in step with the documentation naming it. + required: true +runs: + using: composite + steps: + - name: Regenerate committed artifacts to a fixed point + shell: bash + env: + # Via env, not interpolated into the script text: the pipeline is + # multi-line caller input and pasting it into the shell would make any + # caller of the reusable workflow an author of this script. + RAINIX_CODEGEN_RUN: ${{ inputs.run }} + RAINIX_CODEGEN_MAX_PASSES: ${{ inputs.max-passes }} + 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 from this + # composite's own checkout, so the check version always matches the + # action version regardless of any RAINIX_SHA the caller pins. That is + # load-bearing here rather than merely tidy: a `github:…/$RAINIX_SHA` + # ref would resolve to a flake that predates this subcommand, so the + # step would fail with "unknown subcommand" from the moment it merged + # until a follow-up bumped the pin. + nix run "path:$(cd "$GITHUB_ACTION_PATH/../../.." && pwd)#rainix-static" -- \ + codegen-fixed-point \ + --max-passes "$RAINIX_CODEGEN_MAX_PASSES" \ + --run "$RAINIX_CODEGEN_RUN" diff --git a/.github/workflows/rainix-copy-artifacts.yaml b/.github/workflows/rainix-copy-artifacts.yaml index cf4a857..5345e1b 100644 --- a/.github/workflows/rainix-copy-artifacts.yaml +++ b/.github/workflows/rainix-copy-artifacts.yaml @@ -66,9 +66,16 @@ jobs: # A repo already at its fixed point costs exactly one pass, the same as no # loop at all; the second pass is only ever paid by a build that is # already going red. + # + # Through the composite rather than `nix run github:…/$RAINIX_SHA`, for the + # reason the composite spells out: it resolves the binary from its own + # checkout, so the subcommand exists as soon as this workflow calls it. A + # pinned-flake ref could only name a commit that predates the subcommand. - name: Regenerate committed artifacts to a fixed point - run: | - nix run github:rainlanguage/rainix/${{ env.RAINIX_SHA }}#rainix-static -- codegen-fixed-point --max-passes ${{ inputs.max-codegen-passes }} --run ' + uses: rainlanguage/rainix/.github/actions/codegen-fixed-point@main + with: + max-passes: ${{ inputs.max-codegen-passes }} + run: | set -eu if [ -f script/build-meta.sh ]; then nix develop github:rainlanguage/rainix/${{ env.RAINIX_SHA }}#sol-shell -c ./script/build-meta.sh @@ -84,7 +91,6 @@ jobs: ./script/build.sh fi nix develop github:rainlanguage/rainix/${{ env.RAINIX_SHA }}#sol-shell -c forge fmt - ' - name: Assert committed artifacts match freshly built run: | if ! git diff --exit-code; then diff --git a/flake.nix b/flake.nix index 6ffe222..3559bc0 100644 --- a/flake.nix +++ b/flake.nix @@ -451,6 +451,8 @@ bats test/bats/devshell/default/prettier-bundle.test.bats bats test/bats/action/rpc-preflight.test.bats bats test/bats/action/prompt-cap.test.bats + bats test/bats/action/codegen-fixed-point.test.bats + bats test/bats/workflow/copy-artifacts-fixed-point.test.bats bats test/bats/task/skip-simulation.test.bats bats test/bats/task/subgraph-build.test.bats bats test/bats/task/subgraph-deploy-version.test.bats diff --git a/test/bats/action/codegen-fixed-point.test.bats b/test/bats/action/codegen-fixed-point.test.bats new file mode 100644 index 0000000..d1bbb42 --- /dev/null +++ b/test/bats/action/codegen-fixed-point.test.bats @@ -0,0 +1,118 @@ +# Single quoting is load-bearing throughout this file: the injection test hands +# the action a pipeline whose `$(…)` must arrive unexpanded, which is the whole +# property under test. SC2016 asks whether that is a mistake; here it is the +# point. +# shellcheck disable=SC2016 + +setup() { + repo_root="$BATS_TEST_DIRNAME/../../.." + action="$repo_root/.github/actions/codegen-fixed-point/action.yml" + action_script="$(yq -r '.runs.steps[0].run' "$action")" + workflow="$repo_root/.github/workflows/rainix-copy-artifacts.yaml" +} + +# Runs the composite's own script with `nix` stubbed to echo its argv, so what +# is asserted is the command line the action builds rather than a reimplementation +# of it. The loop itself is covered by the Rust unit tests; what can only break +# here is the wiring between the two. +run_codegen_action() { + local max_passes="$1" + local pipeline="$2" + + RAINIX_CODEGEN_MAX_PASSES="$max_passes" \ + RAINIX_CODEGEN_RUN="$pipeline" \ + GITHUB_ACTION_PATH="$repo_root/.github/actions/codegen-fixed-point" \ + ACTION_SCRIPT="$action_script" \ + bash -c ' + nix() { + printf "nix" + printf " <%s>" "$@" + printf "\n" + } + export -f nix + bash -c "$ACTION_SCRIPT" + ' +} + +@test "the subcommand and both flags reach the binary" { + run run_codegen_action 5 'forge fmt' + + [ "$status" -eq 0 ] + [[ "$output" == *""* ]] + [[ "$output" == *"<--max-passes> <5>"* ]] + [[ "$output" == *"<--run> "* ]] +} + +@test "the bound is the caller's, not a number baked into the action" { + run run_codegen_action 9 'forge fmt' + + [ "$status" -eq 0 ] + [[ "$output" == *"<--max-passes> <9>"* ]] + [[ "$output" != *"<--max-passes> <5>"* ]] +} + +# The binary hands the string to `bash -c` whole. Word-splitting it here would +# turn a multi-command pipeline into a subcommand plus a pile of stray argv. +@test "a multi-line pipeline arrives as exactly one argument" { + run run_codegen_action 5 'set -eu +forge build +forge fmt' + + [ "$status" -eq 0 ] + [[ "$output" == *"<--run> "* ]] + # One `<...>` opens after --run and the line ends at its close: nothing + # spilled into further arguments. + [[ "$output" == *"forge fmt>" ]] +} + +# The pipeline is caller input reaching a reusable workflow from arbitrary +# repos. The action must pass it along, never evaluate it. +@test "the action never executes the pipeline it is handed" { + canary="$BATS_TEST_TMPDIR/canary" + + run run_codegen_action 5 "\$(touch '$canary') \`touch '$canary.tick'\`" + + [ "$status" -eq 0 ] + [ ! -e "$canary" ] + [ ! -e "$canary.tick" ] + [[ "$output" == *'<--run> <$(touch'* ]] +} + +# The action deliberately carries no default, so the bound has exactly one +# published default — the reusable workflow input the README documents. A +# default reappearing here is a second number to keep in step. +@test "the bound is defaulted in exactly one place" { + run yq -e '.inputs["max-passes"] | has("default")' "$action" + [ "$status" -ne 0 ] + + run yq -r '.inputs["max-passes"].required' "$action" + [ "$output" = "true" ] + + run yq -r '.on.workflow_call.inputs["max-codegen-passes"].default' "$workflow" + [ "$output" = "5" ] +} + +# `github:…/$RAINIX_SHA` cannot name a commit that contains a subcommand the +# same PR adds, so the step would fail with "unknown subcommand" from the moment +# it merged until a follow-up bumped the pin. The path: ref resolves the binary +# from this composite's own checkout, which is what removes that window. +# +# Asserted against the argv the stub records rather than the script text, so it +# is the ref actually passed to nix that is pinned here — and the `../../..` +# arithmetic has to land on the repo root for it to hold. +@test "the binary is resolved from the action's own checkout, not a pinned flake" { + run run_codegen_action 5 'forge fmt' + + [ "$status" -eq 0 ] + [[ "$output" == "nix <-->"* ]] + [[ "$output" != *"github:"* ]] +} + +@test "the reusable workflow calls the action rather than the binary directly" { + run yq -r '.jobs.copy-artifacts.steps[] | select(.uses | test("codegen-fixed-point")) | .uses' "$workflow" + + [ "$status" -eq 0 ] + [ "$output" = "rainlanguage/rainix/.github/actions/codegen-fixed-point@main" ] +} diff --git a/test/bats/workflow/copy-artifacts-fixed-point.test.bats b/test/bats/workflow/copy-artifacts-fixed-point.test.bats new file mode 100644 index 0000000..3fbea33 --- /dev/null +++ b/test/bats/workflow/copy-artifacts-fixed-point.test.bats @@ -0,0 +1,272 @@ +# Single quoting is load-bearing throughout this file: it carries GitHub Actions +# expressions (`${{ … }}`) that must stay unexpanded to be compared against the +# shipped YAML, and pipeline bodies whose `$n` belongs to the shell under test +# rather than to bats. SC2016 asks whether that is a mistake; here it is the +# point. +# shellcheck disable=SC2016 + +setup() { + repo_root="$BATS_TEST_DIRNAME/../../.." + workflow="$repo_root/.github/workflows/rainix-copy-artifacts.yaml" + action="$repo_root/.github/actions/codegen-fixed-point/action.yml" + + # The step is a `uses:`, so what ships is two halves: the pipeline the + # workflow hands over, and the composite script that runs it. Both are read + # from the files rather than restated, so a change to either is a change to + # what these tests exercise. + pipeline="$(yq -r '.jobs["copy-artifacts"].steps[] + | select(.uses // "" | test("codegen-fixed-point")) + | .with.run' "$workflow")" + passes_expr="$(yq -r '.jobs["copy-artifacts"].steps[] + | select(.uses // "" | test("codegen-fixed-point")) + | .with["max-passes"]' "$workflow")" + action_script="$(yq -r '.runs.steps[0].run' "$action")" + # The bound a consumer gets when they pass no `with:` at all. + default_passes="$(yq -r '.["on"].workflow_call.inputs["max-codegen-passes"].default' "$workflow")" + + work="$(mktemp -d)" + consumer="$work/repo" + export RAINIX_TEST_SHA=0000000000000000000000000000000000000000 + export RAINIX_TEST_LOG="$work/commands" + export RAINIX_TEST_CODEGEN="$work/codegen.sh" + : >"$RAINIX_TEST_LOG" + : >"$work/passes" + + # `nix` and `forge` are the only two binaries this reaches for. Stubbing them + # as files on PATH — not shell functions — is what lets the real + # `rainix-static` reach them: it runs the pipeline in a bash it spawns itself. + mkdir -p "$work/bin" + cat >"$work/bin/nix" <<'STUB' +#!/usr/bin/env bash +# Anything but the two entry points this is written against is an error, never +# a silent pass: a renamed devshell, a dropped `--`, or an unpinned ref must not +# still look like a working step. +case "$1" in + run) + # The composite resolves the binary from its OWN checkout. A pinned + # `github:` ref here could only ever name a commit predating the subcommand. + case "$2" in + path:*'#rainix-static') ;; + *) echo "unexpected nix run flake ref: $2" >&2; exit 90 ;; + esac + [ "$3" = "--" ] || { echo "nix run needs -- before the subcommand" >&2; exit 90; } + shift 3 + exec rainix-static "$@" + ;; + develop) + [ "$2" = "github:rainlanguage/rainix/$RAINIX_TEST_SHA#sol-shell" ] || + { echo "unexpected nix develop flake ref: $2" >&2; exit 90; } + [ "$3" = "-c" ] || { echo "nix develop needs -c before the command" >&2; exit 90; } + shift 3 + exec "$@" + ;; + *) echo "unexpected nix subcommand: $1" >&2; exit 90 ;; +esac +STUB + cat >"$work/bin/forge" <<'STUB' +#!/usr/bin/env bash +printf 'forge %s\n' "$*" >>"$RAINIX_TEST_LOG" +# Only the codegen script writes anything; build/copy/fmt are recorded so a +# test can see the whole pipeline repeat, not just its first command. +if [ "$1 $2" = "script ./script/Build.sol" ]; then + exec "$RAINIX_TEST_CODEGEN" +fi +STUB + chmod +x "$work/bin/nix" "$work/bin/forge" + PATH="$work/bin:$PATH" + + # A consumer checkout with every optional hook present, so the default + # scenario exercises the whole pipeline. + export GIT_CONFIG_NOSYSTEM=1 + export HOME="$work" + mkdir -p "$consumer/src/generated" "$consumer/script" + printf 'out/\ncache/\ndependencies/\n' >"$consumer/.gitignore" + printf 'pass 0\n' >"$consumer/src/generated/A.sol" + printf '// codegen\n' >"$consumer/script/Build.sol" + printf '// copy\n' >"$consumer/script/CopyArtifacts.sol" + for hook in build-meta.sh build.sh; do + printf '#!/usr/bin/env bash\nprintf "hook %s\\n" >>"$RAINIX_TEST_LOG"\n' "$hook" \ + >"$consumer/script/$hook" + chmod +x "$consumer/script/$hook" + done + git -C "$consumer" init -q -b main . + git -C "$consumer" config user.email rainix@example.com + git -C "$consumer" config user.name rainix + git -C "$consumer" add --all + git -C "$consumer" commit -qm 'committed artifacts' +} + +teardown() { + rm -rf "$work" +} + +# What a pass regenerates. `$n` is the pass number, counted OUTSIDE the checkout +# so counting cannot itself look like a tree that keeps moving. +codegen() { + cat >"$RAINIX_TEST_CODEGEN" <>"$work/passes" +n=\$(wc -c <"$work/passes" | tr -d ' ') +$1 +EOF + chmod +x "$RAINIX_TEST_CODEGEN" +} + +passes_run() { + wc -c <"$work/passes" | tr -d ' ' +} + +log_count() { + grep -cFx -- "$1" "$RAINIX_TEST_LOG" || true +} + +# The step as GitHub runs it: the workflow's expressions expanded, the resulting +# pipeline and bound handed to the composite through the same env keys the +# composite declares, and the composite's own script executed. +run_step() { + local expanded max_passes + expanded="$(printf '%s\n' "$pipeline" | + sed -e "s|\${{ env.RAINIX_SHA }}|$RAINIX_TEST_SHA|g")" + max_passes="$(printf '%s' "${1:-$default_passes}")" + # A renamed input or env key would otherwise leave an expression in the text + # and fail as a bash syntax error that looks nothing like its cause. + case "$expanded$passes_expr" in + *'${{ env.'*) + echo "unexpanded env expression left in the pipeline: $expanded" + return 90 + ;; + esac + # The bound the workflow forwards must be its own input, not a literal. + [ "$passes_expr" = '${{ inputs.max-codegen-passes }}' ] || { + echo "the step does not forward the workflow input as the bound: $passes_expr" + return 90 + } + ( + cd "$consumer" && + env GITHUB_ACTION_PATH="$repo_root/.github/actions/codegen-fixed-point" \ + RAINIX_CODEGEN_RUN="$expanded" \ + RAINIX_CODEGEN_MAX_PASSES="$max_passes" \ + ACTION_SCRIPT="$action_script" \ + bash -c 'bash -c "$ACTION_SCRIPT"' + ) +} + +@test "a repo already at its fixed point passes, having run the pipeline once" { + codegen "printf 'pass 0\n' > src/generated/A.sol" + + run run_step + + [ "$status" -eq 0 ] + [ "$(passes_run)" -eq 1 ] + [[ "$output" == *"fixed point reached after 1 pass"* ]] +} + +@test "generation that settles on a later pass passes, leaving the settled tree" { + codegen 'if [ "$n" -lt 3 ]; then printf "pass %s\n" "$n" > src/generated/A.sol; fi' + + run run_step + + [ "$status" -eq 0 ] + [ "$(passes_run)" -eq 3 ] + [ "$(cat "$consumer/src/generated/A.sol")" = "pass 2" ] +} + +@test "every command of the pipeline is inside the loop, not just the codegen" { + codegen 'if [ "$n" -lt 3 ]; then printf "pass %s\n" "$n" > src/generated/A.sol; fi' + + run run_step + + [ "$status" -eq 0 ] + [ "$(log_count 'hook build-meta.sh')" -eq 3 ] + [ "$(log_count 'forge script ./script/Build.sol')" -eq 3 ] + [ "$(log_count 'forge build')" -eq 3 ] + [ "$(log_count 'forge script ./script/CopyArtifacts.sol --ffi')" -eq 3 ] + [ "$(log_count 'hook build.sh')" -eq 3 ] + [ "$(log_count 'forge fmt')" -eq 3 ] +} + +@test "generation that never settles fails as non-convergence, not as staleness" { + codegen 'printf "pass %s\n" "$((n % 2))" > src/generated/A.sol' + + run run_step + + [ "$status" -eq 1 ] + [ "$(passes_run)" -eq "$default_passes" ] + [[ "$output" == *"::error::"* ]] + [[ "$output" == *"did not reach a fixed point in $default_passes passes"* ]] + [[ "$output" == *"cycle that does not settle"* ]] +} + +@test "the workflow input is the bound, not a value baked into the binary" { + codegen 'if [ "$n" -lt 3 ]; then printf "pass %s\n" "$n" > src/generated/A.sol; fi' + + run run_step 2 + + [ "$status" -eq 1 ] + [ "$(passes_run)" -eq 2 ] + [[ "$output" == *"did not reach a fixed point in 2 passes"* ]] +} + +@test "the default bound is 5" { + [ "$default_passes" -eq 5 ] +} + +@test "a pipeline command that fails stops the loop and is not retried" { + codegen "printf 'regenerated\n' > src/generated/A.sol; exit 3" + + run run_step + + [ "$status" -eq 1 ] + [ "$(passes_run)" -eq 1 ] + [[ "$output" == *"regeneration command failed"* ]] +} + +@test "the repo index is left untouched for the currency check that follows" { + codegen "printf 'regenerated\n' > src/generated/A.sol" + + run run_step + + [ "$status" -eq 0 ] + # Unstaged, " M path", is what the currency check reads. Staged, "M path", + # is a tree the check would report as clean while it is anything but. + [ "$(git -C "$consumer" status --porcelain)" = " M src/generated/A.sol" ] +} + +@test "gitignored build output does not read as a tree that never settles" { + codegen 'mkdir -p out cache dependencies + printf "%s" "$n" > out/A.json + printf "%s" "$n" > cache/x + printf "%s" "$n" > dependencies/dep.sol' + + run run_step + + [ "$status" -eq 0 ] + [ "$(passes_run)" -eq 1 ] +} + +@test "optional consumer hooks that are absent are skipped, not invoked" { + git -C "$consumer" rm -q script/build-meta.sh script/build.sh script/CopyArtifacts.sol + git -C "$consumer" commit -qm 'pointer-only consumer' + codegen "printf 'pass 0\n' > src/generated/A.sol" + + run run_step + + [ "$status" -eq 0 ] + [ "$(log_count 'hook build-meta.sh')" -eq 0 ] + [ "$(log_count 'hook build.sh')" -eq 0 ] + [ "$(log_count 'forge script ./script/CopyArtifacts.sol --ffi')" -eq 0 ] + [ "$(log_count 'forge build')" -eq 1 ] +} + +@test "every devshell the pipeline enters is pinned to the workflow's sha" { + [[ "$pipeline" != *'github:rainlanguage/rainix#'* ]] + [[ "$pipeline" != *'github:rainlanguage/rainix/main'* ]] + # One per command the pipeline wraps in a devshell: build-meta.sh, Build.sol, + # build, copy, fmt. build.sh is deliberately NOT wrapped — it picks its own + # shell per command — and the looping binary comes from the composite instead. + [ "$(grep -cF 'github:rainlanguage/rainix/${{ env.RAINIX_SHA }}' <<<"$pipeline")" -eq 5 ] + # Comments stripped: the composite explains the pinned ref it does NOT use, + # and prose naming the rejected form must not read as the form being used. + [[ "$(grep -v '^[[:space:]]*#' <<<"$action_script")" != *'github:'* ]] +} From 3dfac9d3537b7600097289b150001f9631b85a79 Mon Sep 17 00:00:00 2001 From: David Meister Date: Sun, 16 Aug 2026 19:21:56 +0000 Subject: [PATCH 4/4] test: a pass that only deletes a file is a change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dropping the per-pass scratch-index reset rested on `git add --all` reconciling removals as well as content. That claim had no test: every case asserted so far adds or rewrites a path, and an observation that only ever accumulated paths would pass all of them while calling a deleting pass converged on pass 1 — and hand the currency check a tree it never watched settle. The mutant that rules it out is `add --no-all .`, which stages content but not deletions. Folded in from the parallel branch at 2026-08-16-issue-314, which found this gap independently. Co-Authored-By: Claude Opus 5 (1M context) --- rainix-static/src/codegen_fixed_point.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/rainix-static/src/codegen_fixed_point.rs b/rainix-static/src/codegen_fixed_point.rs index ed79472..214cc72 100644 --- a/rainix-static/src/codegen_fixed_point.rs +++ b/rainix-static/src/codegen_fixed_point.rs @@ -306,6 +306,19 @@ mod tests { assert_eq!(run(&f.repo, 5, &cmd), Ok(Outcome::Converged { passes: 2 })); } + #[test] + fn a_pass_that_deletes_a_file_counts_as_a_change() { + let f = Fixture::new(); + // The scratch index is reused across observations, so a deletion is only + // seen because `git add --all` is left to reconcile removals too. An + // observation that only ever accumulated paths would call this converged + // on pass 1 and hand the currency check a tree it never watched settle. + let cmd = f.pipeline("rm -f src/generated/A.sol"); + + assert_eq!(run(&f.repo, 5, &cmd), Ok(Outcome::Converged { passes: 2 })); + assert!(!f.repo.join("src/generated/A.sol").exists()); + } + #[test] fn gitignored_build_output_does_not_look_like_a_moving_tree() { let f = Fixture::new();