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..a602425 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 ] @@ -448,6 +451,7 @@ 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/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/rainix-static/src/codegen_fixed_point.rs b/rainix-static/src/codegen_fixed_point.rs new file mode 100644 index 0000000..214cc72 --- /dev/null +++ b/rainix-static/src/codegen_fixed_point.rs @@ -0,0 +1,409 @@ +//! `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 { + // 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"]) +} + +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 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(); + // 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}"); + // 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); + } +} 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); } 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..338c2f6 --- /dev/null +++ b/test/bats/workflow/copy-artifacts-fixed-point.test.bats @@ -0,0 +1,244 @@ +# End-to-end cover for the fixed-point step of rainix-copy-artifacts, driven +# from the step's own `run:` text so the wiring between the workflow input, the +# pinned flake refs and `rainix-static codegen-fixed-point` is exercised rather +# than assumed. The loop's own semantics are unit-tested in Rust. +# +# `$n` and `${{ … }}` inside single quotes are deliberate and must NOT expand +# here: the first belongs to the codegen script this file writes, the second to +# GitHub's own expansion of the step. +# shellcheck disable=SC2016 + +setup() { + repo_root="$BATS_TEST_DIRNAME/../../.." + workflow="$repo_root/.github/workflows/rainix-copy-artifacts.yaml" + step="$(yq -r '.jobs["copy-artifacts"].steps[] + | select(.name == "Regenerate committed artifacts to a fixed point") + | .run' "$workflow")" + # Read from the workflow rather than restated, so the bound these tests + # exercise is the one 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 the step 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 the step 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) + [ "$2" = "github:rainlanguage/rainix/$RAINIX_TEST_SHA#rainix-static" ] || + { echo "unexpected nix run flake ref: $2" >&2; exit 90; } + [ "$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: expressions expanded, then the shipped text. +run_step() { + local script + script="$(printf '%s\n' "$step" | + sed -e "s|\${{ env.RAINIX_SHA }}|$RAINIX_TEST_SHA|g" \ + -e "s|\${{ inputs.max-codegen-passes }}|${1:-$default_passes}|g")" + # 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 "$script" in + *'${{'*) + echo "unexpanded expression left in the step: $script" + return 90 + ;; + esac + (cd "$consumer" && bash -c "$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 flake ref in the step is pinned to the workflow's sha" { + [[ "$step" != *'github:rainlanguage/rainix#'* ]] + [[ "$step" != *'github:rainlanguage/rainix/main'* ]] + # One per command the step runs: build-meta.sh, Build.sol, build, copy, fmt, + # and the rainix-static that loops them. + [ "$(grep -cF 'github:rainlanguage/rainix/${{ env.RAINIX_SHA }}' <<<"$step")" -eq 6 ] +}