diff --git a/.github/actions/frozen-snapshots-append-only/action.yml b/.github/actions/frozen-snapshots-append-only/action.yml index d0304f6..2b21b81 100644 --- a/.github/actions/frozen-snapshots-append-only/action.yml +++ b/.github/actions/frozen-snapshots-append-only/action.yml @@ -8,8 +8,18 @@ runs: shell: bash run: | set -euo pipefail + # path: ref runs the check from THIS composite's own checkout, so the + # check version always matches the action version (same pattern as + # no-submodules) regardless of any RAINIX_SHA the caller pins. + RAINIX_STATIC="path:$(cd "$GITHUB_ACTION_PATH/../../.." && pwd)#rainix-static" + # Where snapshots live is asked of the binary rather than spelled out + # again (rainlanguage/rainix#313): the same value backs the subcommand's + # own default --root below, so this probe cannot drift out of step with + # what the check then scans and silently skip every repo. Under `set -e` + # a failed lookup fails the step instead of probing "". + GENERATED_DIR="$(nix run "$RAINIX_STATIC" -- generated-dir)" # Nothing to enforce if the repo has no per-tag snapshots. - if ! ls src/generated/*/*.pointers.sol >/dev/null 2>&1; then + if ! ls "$GENERATED_DIR"/*/*.pointers.sol >/dev/null 2>&1; then echo "snapshots-append-only: no per-tag snapshots; skip" exit 0 fi @@ -22,8 +32,7 @@ runs: git fetch --no-tags --unshallow origin fi git fetch --no-tags origin "$BASE_REF" - # path: ref runs the check from THIS composite's own checkout, so the - # check version always matches the action version (same pattern as - # no-submodules) regardless of any RAINIX_SHA the caller pins. - nix run "path:$(cd "$GITHUB_ACTION_PATH/../../.." && pwd)#rainix-static" -- \ + # No --root: the subcommand defaults it to the same canonical value the + # probe above read, so there is nothing here to keep in sync by hand. + nix run "$RAINIX_STATIC" -- \ snapshots-append-only --base "origin/$BASE_REF" diff --git a/.github/workflows/rainix-copy-artifacts.yaml b/.github/workflows/rainix-copy-artifacts.yaml index 1157a67..7339f6e 100644 --- a/.github/workflows/rainix-copy-artifacts.yaml +++ b/.github/workflows/rainix-copy-artifacts.yaml @@ -33,10 +33,18 @@ jobs: # 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. + # + # Where those sources live is asked of `rainix-static generated-dir` — the + # one value the frozen-snapshot check and the soldeer content gate read + # too (rainlanguage/rainix#313). A literal here would keep matching nothing + # once that directory moves, silently retiring this guard; `set -e` makes a + # failed lookup fail the step rather than skip it on an empty answer. - name: Regenerate 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." + set -euo pipefail + GENERATED_DIR="$(nix develop github:rainlanguage/rainix/${{ env.RAINIX_SHA }}#sol-shell -c rainix-static generated-dir)" + if [ -d "$GENERATED_DIR" ] && [ ! -f script/Build.sol ]; then + echo "::error::$GENERATED_DIR/ 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 diff --git a/flake.nix b/flake.nix index c390c0b..694da0c 100644 --- a/flake.nix +++ b/flake.nix @@ -448,6 +448,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/action/generated-dir.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/main.rs b/rainix-static/src/main.rs index af1910b..b8c4b70 100644 --- a/rainix-static/src/main.rs +++ b/rainix-static/src/main.rs @@ -34,9 +34,13 @@ // Nothing is stripped: a shell script reads the bytes on disk. Which // files are prompts and what they may weigh is per-repo, so both are an // input, and a glob matching nothing is an error rather than a pass. +// generated-dir +// print the one directory generated Solidity sources live in. Every +// mechanism that needs that path reads it from here rather than spelling +// it again (rainlanguage/rainix#313). // snapshots-append-only [--base ] [--root ] // fail if the branch modifies or deletes an existing per-tag deploy-pin -// snapshot under // (default root src/generated, base +// snapshot under // (default root `generated-dir`, base // origin/main). Snapshots are frozen once on the base branch; a release // ADDS a new , never edits an existing one. Needs the base ref // fetched with history (fetch-depth: 0 + `git fetch origin `). @@ -63,6 +67,16 @@ mod soldeer_gate; use std::path::Path; +/// THE directory generated Solidity sources live in, org-wide. +/// +/// One concept, one value: the copy-artifacts currency guard, the frozen +/// deploy-pin snapshot check and the soldeer content gate all resolve the path +/// through this constant (the two workflow-side ones via the `generated-dir` +/// subcommand), instead of each spelling it out (rainlanguage/rainix#313). +/// Restated, a literal keeps matching nothing once the real directory moves, and +/// every check built on it goes quietly inert rather than red. +pub(crate) const GENERATED_DIR: &str = "src/generated"; + /// Print a GitHub Actions error annotation and exit nonzero. Shared by every /// subcommand, so it lives at the crate root (`crate::fail`). pub(crate) fn fail(msg: &str) -> ! { @@ -156,9 +170,10 @@ fn main() { .unwrap_or_else(|| fail("soldeer-gate: --package required")); soldeer_gate::run(&pkg, flag(&args, "--github-output").as_deref()); } + "generated-dir" => println!("{GENERATED_DIR}"), "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()); + let root = flag(&args, "--root").unwrap_or_else(|| GENERATED_DIR.to_string()); match frozen_snapshots::check(&base, &root) { Err(e) => fail(&e), Ok(offenders) if offenders.is_empty() => println!("snapshots-append-only: clean"), @@ -197,7 +212,8 @@ fn main() { eprintln!( "rainix-static: unknown subcommand {other:?} \ (available: no-submodules, agent-context-cap, prompt-cap, \ - snapshots-append-only, soldeer-gate, rpc-preflight)" + generated-dir, 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 57c708e..8d03ae3 100644 --- a/rainix-static/src/soldeer_gate.rs +++ b/rainix-static/src/soldeer_gate.rs @@ -43,15 +43,17 @@ fn blank_foundry_version(content: &[u8]) -> Vec { out.into_bytes() } -/// Normalized content hash of a package's files. Excludes everything under -/// `src/generated/` (per-release snapshots + generated aliasing libs — derived -/// from source, and a fresh `/` dir appears every release, so hashing it -/// would flag "changed" on every merge). Blanks foundry.toml's version line. -/// Then hashes each remaining file as `name \0 content`, in byte-sorted name -/// order, through one SHA-256 — so identical source yields an identical digest -/// regardless of zip entry order. +/// Normalized content hash of a package's files. Excludes everything under the +/// canonical generated dir (per-release snapshots + generated aliasing libs — +/// derived from source, and a fresh `/` dir appears every release, so +/// hashing it would flag "changed" on every merge); the path comes from +/// `crate::GENERATED_DIR`, never restated here. Blanks foundry.toml's version +/// line. Then hashes each remaining file as `name \0 content`, in byte-sorted +/// name order, through one SHA-256 — so identical source yields an identical +/// digest regardless of zip entry order. fn norm_hash(entries: &mut Vec) -> String { - entries.retain(|(name, _)| !name.starts_with("src/generated/")); + let generated = format!("{}/", crate::GENERATED_DIR); + entries.retain(|(name, _)| !name.starts_with(&generated)); for (name, content) in entries.iter_mut() { if name == "foundry.toml" { *content = blank_foundry_version(content); @@ -340,20 +342,40 @@ mod tests { assert_eq!(norm_hash(&mut a), norm_hash(&mut b)); } + /// The excluded prefix is the ONE canonical generated dir, not a restatement + /// of it (rainlanguage/rainix#313) — so moving the constant moves the gate's + /// exclusion with it, instead of leaving the gate to see every regeneration + /// as a content change and republish forever. #[test] - fn norm_hash_excludes_generated() { + fn norm_hash_excludes_the_canonical_generated_dir() { let base = ("src/A.sol".to_string(), b"contract A {}".to_vec()); let mut without = vec![base.clone()]; let mut with_gen = vec![ base, ( - "src/generated/0.1.0/A.pointers.sol".to_string(), + format!("{}/0.1.0/A.pointers.sol", crate::GENERATED_DIR), b"address constant X = 1;".to_vec(), ), ]; assert_eq!(norm_hash(&mut without), norm_hash(&mut with_gen)); } + /// Only that dir is excluded: a sibling whose name merely starts with it is + /// hand-written source and must still count as content. + #[test] + fn norm_hash_excludes_only_that_dir() { + let base = ("src/A.sol".to_string(), b"contract A {}".to_vec()); + let mut without = vec![base.clone()]; + let mut with_sibling = vec![ + base, + ( + format!("{}Legacy/A.sol", crate::GENERATED_DIR), + b"contract Legacy {}".to_vec(), + ), + ]; + assert_ne!(norm_hash(&mut without), norm_hash(&mut with_sibling)); + } + #[test] fn norm_hash_detects_source_change() { let mut a = vec![("src/A.sol".to_string(), b"contract A {}".to_vec())]; diff --git a/test/bats/action/generated-dir.test.bats b/test/bats/action/generated-dir.test.bats new file mode 100644 index 0000000..a3a8b17 --- /dev/null +++ b/test/bats/action/generated-dir.test.bats @@ -0,0 +1,176 @@ +# Where generated Solidity sources live is ONE value, and every rainix mechanism +# that needs it reads that value from `rainix-static generated-dir` rather than +# spelling `src/generated` again (rainlanguage/rainix#313). Restating it is the +# hazard: the restatement keeps matching nothing after the canonical path moves, +# so the copy-artifacts currency guard, the frozen-snapshot check and the +# autopublish content gate all go quietly inert instead of red. +# +# Each mechanism here is therefore driven with a NON-default directory reported +# by the binary, and asserted to follow it — a hand-written `src/generated` fails +# these tests in both directions. + +setup() { + repo_root="$BATS_TEST_DIRNAME/../../.." + # Deliberately neither `src/generated` nor a prefix/suffix of it. + stub_dir="gen/out" + work="$(mktemp -d)" +} + +teardown() { + rm -rf "$work" +} + +# Run a workflow/action bash body with `nix` stubbed: the `generated-dir` call +# answers $STUB_GENERATED_DIR (or fails, when $STUB_GENERATED_DIR_FAILS is set), +# every other invocation echoes its argv so what would have run is assertable +# without building anything. `git` is stubbed the same way, so the scripts' fetch +# plumbing is inert in a bare temp dir. +run_stubbed() { + ACTION_SCRIPT="$1" \ + STUB_GENERATED_DIR="$stub_dir" \ + STUB_GENERATED_DIR_FAILS="${STUB_GENERATED_DIR_FAILS:-}" \ + GITHUB_ACTION_PATH="$repo_root/.github/actions/frozen-snapshots-append-only" \ + bash -c ' + nix() { + last="" + for a in "$@"; do last="$a"; done + if [ "$last" = "generated-dir" ]; then + if [ -n "$STUB_GENERATED_DIR_FAILS" ]; then + echo "nix stub: generated-dir unavailable" >&2 + return 1 + fi + printf "%s\n" "$STUB_GENERATED_DIR" + return 0 + fi + printf "nix" + printf " <%s>" "$@" + printf "\n" + } + git() { + printf "git" + printf " <%s>" "$@" + printf "\n" + } + export -f nix git + bash -c "$ACTION_SCRIPT" + ' +} + +frozen_action() { + run_stubbed "$(yq -r '.runs.steps[0].run' \ + "$repo_root/.github/actions/frozen-snapshots-append-only/action.yml")" +} + +copy_artifacts_regen() { + workflow="$repo_root/.github/workflows/rainix-copy-artifacts.yaml" + sha="$(yq -r '.env.RAINIX_SHA' "$workflow")" + # GitHub resolves ${{ … }} before bash ever sees the body; left in, bash reads + # it as a bad substitution and the step dies before reaching what is asserted. + run_stubbed "$(yq -r '.jobs.copy-artifacts.steps[] + | select(.name == "Regenerate generated sources") + | .run' "$workflow" | sed "s|\${{ env.RAINIX_SHA }}|$sha|g")" +} + +# The canonical value itself. + +@test "generated-dir prints the one directory generated sources live in" { + run rainix-static generated-dir + + [ "$status" -eq 0 ] + [ "$output" = "src/generated" ] +} + +# Mechanism 1: rainix-copy-artifacts' currency guard. + +@test "copy-artifacts hard-fails on generated sources with no script/Build.sol" { + cd "$work" + mkdir -p "$stub_dir" + + run copy_artifacts_regen + + [ "$status" -eq 1 ] + [[ "$output" == *"::error::"* ]] + [[ "$output" == *"script/Build.sol"* ]] +} + +@test "copy-artifacts does not guard a directory the binary does not name" { + cd "$work" + mkdir -p src/generated + + run copy_artifacts_regen + + [ "$status" -eq 0 ] + [[ "$output" != *"::error::"* ]] +} + +@test "copy-artifacts fails loudly when the canonical directory cannot be read" { + cd "$work" + mkdir -p "$stub_dir" + STUB_GENERATED_DIR_FAILS=1 + + run copy_artifacts_regen + + # Never exit 0 with the guard silently skipped because the lookup broke. + [ "$status" -ne 0 ] +} + +# Mechanism 2: the frozen-snapshots-append-only action's presence probe. + +@test "frozen-snapshots checks per-tag snapshots under the directory the binary names" { + cd "$work" + mkdir -p "$stub_dir/0_1_4" + touch "$stub_dir/0_1_4/CloneFactory.pointers.sol" + + run frozen_action + + [ "$status" -eq 0 ] + [[ "$output" != *"no per-tag snapshots; skip"* ]] + [[ "$output" == *""* ]] +} + +@test "frozen-snapshots skips snapshots outside the directory the binary names" { + cd "$work" + mkdir -p src/generated/0_1_4 + touch src/generated/0_1_4/CloneFactory.pointers.sol + + run frozen_action + + [ "$status" -eq 0 ] + [[ "$output" == *"no per-tag snapshots; skip"* ]] + [[ "$output" != *""* ]] +} + +@test "frozen-snapshots fails loudly when the canonical directory cannot be read" { + cd "$work" + mkdir -p "$stub_dir/0_1_4" + touch "$stub_dir/0_1_4/CloneFactory.pointers.sol" + STUB_GENERATED_DIR_FAILS=1 + + run frozen_action + + [ "$status" -ne 0 ] + [[ "$output" != *"no per-tag snapshots; skip"* ]] +} + +# Mechanism 2b: the binary's own default root for that check is the same value, +# so the action never has to pass --root to keep the two in step. + +@test "snapshots-append-only defaults its root to the canonical directory" { + dir="$(rainix-static generated-dir)" + cd "$work" + git init -q -b main . + git config user.email rainix@example.com + git config user.name rainix + mkdir -p "$dir/0_1_4" + printf 'address constant A = address(1);\n' >"$dir/0_1_4/X.pointers.sol" + git add -A + git commit -qm base + git checkout -qb branch + printf 'address constant A = address(2);\n' >"$dir/0_1_4/X.pointers.sol" + git commit -qam edit + + run rainix-static snapshots-append-only --base main + + [ "$status" -eq 1 ] + [[ "$output" == *"modified frozen snapshot $dir/0_1_4/X.pointers.sol"* ]] +}