diff --git a/.github/actions/codegen-declaration/action.yml b/.github/actions/codegen-declaration/action.yml new file mode 100644 index 0000000..0437ac8 --- /dev/null +++ b/.github/actions/codegen-declaration/action.yml @@ -0,0 +1,15 @@ +name: codegen-declaration +description: >- + Fails `rainix-copy-artifacts` when a codegen hook declared a generated path it then did not write. Re-running the generators and diffing proves the committed CONTENT is current, but a generator that has STOPPED emitting a file writes nothing, so the already-correct committed copy is left alone, nothing differs and the job is green over a dead emitter (rainlanguage/rain.factory.deploy#35). Only the generator knows which paths it owns rather than deliberately leaves frozen, so it declares them on stdout — `rainix-codegen owns ` and `rainix-codegen wrote ` — and the workflow tees that into the log this reads. A repo whose hooks declare nothing is a no-op. +runs: + using: composite + steps: + - name: Check the codegen declaration + 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 + # mutation-ledger) regardless of any RAINIX_SHA the caller pins. + nix run "path:$(cd "$GITHUB_ACTION_PATH/../../.." && pwd)#rainix-static" -- \ + codegen-declaration --log "$RUNNER_TEMP/rainix-codegen.log" diff --git a/.github/workflows/rainix-copy-artifacts.yaml b/.github/workflows/rainix-copy-artifacts.yaml index a15aca2..195b387 100644 --- a/.github/workflows/rainix-copy-artifacts.yaml +++ b/.github/workflows/rainix-copy-artifacts.yaml @@ -26,33 +26,48 @@ jobs: # 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. + # + # Each hook's stdout is teed into one log: a hook may declare there which + # generated paths it owns and which it wrote, which is the half of the + # currency check the diff cannot do (codegen-declaration, below). + # `pipefail` is set in every teed step — bash otherwise reports `tee`'s + # exit status, so a failing generator would pass. - 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 + run: | + set -euo pipefail + nix develop github:rainlanguage/rainix/${{ env.RAINIX_SHA }}#sol-shell -c ./script/build-meta.sh | tee -a "$RUNNER_TEMP/rainix-codegen.log" # 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 run: | + set -euo pipefail 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 + nix develop github:rainlanguage/rainix/${{ env.RAINIX_SHA }}#sol-shell -c forge script ./script/Build.sol | tee -a "$RUNNER_TEMP/rainix-codegen.log" 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 + run: | + set -euo pipefail + nix develop github:rainlanguage/rainix/${{ env.RAINIX_SHA }}#sol-shell -c forge script ./script/CopyArtifacts.sol --ffi | tee -a "$RUNNER_TEMP/rainix-codegen.log" # 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 + run: | + set -euo pipefail + ./script/build.sh | tee -a "$RUNNER_TEMP/rainix-codegen.log" + - name: Assert every declared generated path was written + uses: rainlanguage/rainix/.github/actions/codegen-declaration@main - name: Format (so generated artifacts match committed style) run: nix develop github:rainlanguage/rainix/${{ env.RAINIX_SHA }}#sol-shell -c forge fmt - name: Assert committed artifacts match freshly built diff --git a/README.md b/README.md index dbf3358..f2aca97 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,24 @@ single job it runs whichever of these the repo has: then `forge fmt` and the `git diff` assert. +The diff answers "is the committed **content** current". It cannot answer "is +anything still generating it": a generator that has stopped emitting a file +writes nothing, the already-correct committed copy is left alone, and the job is +green over a dead emitter (rainlanguage/rain.factory.deploy#35). Only the +generator knows which paths it owns, as against a `src/generated//` +snapshot deliberately frozen forever — so the generator says so, on stdout: + +``` +rainix-codegen owns src/lib/LibReleasedSuites.sol +rainix-codegen wrote src/lib/LibReleasedSuites.sol +``` + +The job tees every hook's stdout into one log and fails, naming the path, on +anything declared `owns` that no hook then `wrote`. Nothing is declared by hand +and no repo maintains a list: the same code that computes where to write emits +these lines. A repo whose hooks print neither keeps exactly today's behaviour — +green, with a note that a dead emitter there is still invisible. + ```yaml name: copy-artifacts on: [push] diff --git a/flake.nix b/flake.nix index d52a31f..a7cf508 100644 --- a/flake.nix +++ b/flake.nix @@ -474,6 +474,7 @@ bats test/bats/action/prompt-cap.test.bats bats test/bats/action/frozen-snapshots-append-only.test.bats bats test/bats/action/mutation-ledger.test.bats + bats test/bats/action/codegen-declaration.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 @@ -481,6 +482,7 @@ bats test/bats/task/no-custom-natspec.test.bats bats test/bats/workflow/rainix-sol-static.test.bats bats test/bats/workflow/rainix-rs-static.test.bats + bats test/bats/workflow/rainix-copy-artifacts.test.bats bats test/bats/workflow/test-yml.test.bats ''; additionalBuildInputs = [ pkgs.bats ] ++ sol-build-inputs ++ node-build-inputs; diff --git a/rainix-static/src/codegen_declaration.rs b/rainix-static/src/codegen_declaration.rs new file mode 100644 index 0000000..5500efa --- /dev/null +++ b/rainix-static/src/codegen_declaration.rs @@ -0,0 +1,200 @@ +use std::collections::BTreeSet; +use std::path::Path; + +/// Line prefix a codegen hook prints to declare a path. `forge script` indents +/// `console.log` output under `== Logs ==`, so lines are matched trimmed. +pub(crate) const SENTINEL: &str = "rainix-codegen "; + +#[derive(Default, PartialEq, Eq, Debug)] +pub(crate) struct Declaration { + pub(crate) owns: BTreeSet, + pub(crate) wrote: BTreeSet, + pub(crate) malformed: Vec, +} + +pub(crate) fn parse(log: &str) -> Declaration { + let mut out = Declaration::default(); + for line in log.lines() { + let Some(rest) = line.trim().strip_prefix(SENTINEL) else { + continue; + }; + match rest.split_once(char::is_whitespace) { + Some(("owns", path)) if !path.trim().is_empty() => { + out.owns.insert(path.trim().to_string()); + } + Some(("wrote", path)) if !path.trim().is_empty() => { + out.wrote.insert(path.trim().to_string()); + } + // Fail-closed: a verb this check does not know is a declaration it + // is silently not making, which is the defect one level up. + _ => out.malformed.push(line.trim().to_string()), + } + } + out +} + +pub(crate) fn offences( + owns: &BTreeSet, + wrote: &BTreeSet, + present: &BTreeSet, +) -> Vec { + let mut out = Vec::new(); + for path in owns { + match (wrote.contains(path), present.contains(path)) { + (false, true) => out.push(format!( + "the codegen hooks declare {path} generated, but nothing wrote it on this run. \ + The committed copy is left exactly as it was, so regenerating and diffing \ + passes without ever checking it — its emitter is dead. Restore the emitter, or \ + stop declaring the path if it is genuinely no longer generated." + )), + (false, false) => out.push(format!( + "the codegen hooks declare {path} generated, but nothing wrote it and no such \ + file exists after the run." + )), + (true, false) => out.push(format!( + "a codegen hook reported writing {path}, but no such file exists after the run." + )), + (true, true) => {} + } + } + out +} + +pub(crate) fn run(root: &Path, log: &Path) { + let text = match std::fs::read_to_string(log) { + Ok(text) => text, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(e) => crate::fail(&format!( + "codegen-declaration: failed to read {}: {e}", + log.display() + )), + }; + let declaration = parse(&text); + + if !declaration.malformed.is_empty() { + for line in &declaration.malformed { + eprintln!("::error::codegen-declaration: unreadable declaration: {line}"); + } + std::process::exit(1); + } + + if declaration.owns.is_empty() { + println!( + "codegen-declaration: clean — this repo's codegen declares no generated paths, so \ + nothing here can tell a live emitter from one that has died" + ); + return; + } + + let present: BTreeSet = declaration + .owns + .union(&declaration.wrote) + .filter(|path| root.join(path).exists()) + .cloned() + .collect(); + let offences = offences(&declaration.owns, &declaration.wrote, &present); + + if !offences.is_empty() { + for line in &offences { + eprintln!("::error::codegen-declaration: {line}"); + } + std::process::exit(1); + } + + println!( + "codegen-declaration: clean — {} declared generated paths, each written this run", + declaration.owns.len() + ); + for path in declaration.wrote.difference(&declaration.owns) { + println!("codegen-declaration: note — {path} was written but not declared, so nothing will notice if its emitter dies"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn set(paths: &[&str]) -> BTreeSet { + paths.iter().map(|p| p.to_string()).collect() + } + + #[test] + fn forge_indented_lines_parse_into_both_sets() { + let log = "== Logs ==\n rainix-codegen owns src/a.sol\n rainix-codegen wrote src/a.sol\n"; + let d = parse(log); + assert_eq!(d.owns, set(&["src/a.sol"])); + assert_eq!(d.wrote, set(&["src/a.sol"])); + assert!(d.malformed.is_empty()); + } + + #[test] + fn a_line_that_merely_mentions_the_sentinel_is_not_a_declaration() { + let d = parse("error: expected `rainix-codegen owns src/a.sol`\n"); + assert_eq!(d, Declaration::default()); + } + + #[test] + fn repeated_declarations_of_one_path_are_one_path() { + let d = parse("rainix-codegen wrote src/a.sol\nrainix-codegen wrote src/a.sol\n"); + assert_eq!(d.wrote, set(&["src/a.sol"])); + } + + #[test] + fn an_unknown_verb_is_malformed_rather_than_ignored() { + let d = parse("rainix-codegen skipped src/a.sol\n"); + assert_eq!(d.malformed, vec!["rainix-codegen skipped src/a.sol"]); + } + + #[test] + fn a_verb_with_no_path_is_malformed() { + let d = parse("rainix-codegen owns\nrainix-codegen wrote \n"); + assert_eq!(d.malformed.len(), 2); + assert!(d.owns.is_empty()); + assert!(d.wrote.is_empty()); + } + + #[test] + fn a_declared_path_nothing_wrote_is_an_offence_though_it_is_on_disk() { + let owns = set(&["src/lib/LibReleasedSuites.sol", "src/generated/A.sol"]); + let wrote = set(&["src/generated/A.sol"]); + let off = offences(&owns, &wrote, &owns); + assert_eq!(off.len(), 1); + assert!(off[0].contains("src/lib/LibReleasedSuites.sol")); + assert!(off[0].contains("emitter is dead")); + } + + #[test] + fn a_declared_path_that_is_not_on_disk_says_so_instead() { + let owns = set(&["src/generated/Gone.sol"]); + let off = offences(&owns, &BTreeSet::new(), &BTreeSet::new()); + assert_eq!(off.len(), 1); + assert!(off[0].contains("no such file exists")); + } + + #[test] + fn a_path_reported_written_that_is_not_on_disk_is_an_offence() { + let all = set(&["src/a.sol"]); + let off = offences(&all, &all, &BTreeSet::new()); + assert_eq!(off.len(), 1); + assert!(off[0].contains("reported writing")); + } + + #[test] + fn every_declared_path_written_is_clean() { + let all = set(&["src/a.sol", "src/b.sol"]); + assert!(offences(&all, &all, &all).is_empty()); + } + + #[test] + fn a_written_but_undeclared_path_is_not_an_offence() { + let owns = set(&["src/a.sol"]); + let wrote = set(&["src/a.sol", "soldeer.lock"]); + assert!(offences(&owns, &wrote, &wrote).is_empty()); + } + + #[test] + fn declaring_nothing_is_not_an_offence() { + let wrote = set(&["src/a.sol"]); + assert!(offences(&BTreeSet::new(), &wrote, &wrote).is_empty()); + } +} diff --git a/rainix-static/src/main.rs b/rainix-static/src/main.rs index 8a520da..6413118 100644 --- a/rainix-static/src/main.rs +++ b/rainix-static/src/main.rs @@ -34,6 +34,14 @@ // 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. +// codegen-declaration --log [--root ] +// fail if a codegen hook declared a generated path it then did not +// write. Re-running the generators and diffing proves the committed +// CONTENT is current but is blind to a generator that has STOPPED +// emitting a file, and only the generator knows which paths it owns +// rather than deliberately leaves frozen. Hooks declare on stdout as +// `rainix-codegen owns ` and `rainix-codegen wrote `, which +// the workflow tees into . A repo declaring nothing passes. // 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 @@ -96,6 +104,7 @@ mod agent_context_cap; mod ci_gate; +mod codegen_declaration; mod context_bytes; mod frozen_snapshots; mod mutation_ledger; @@ -212,6 +221,12 @@ fn main() { .unwrap_or_else(|| fail("soldeer-gate: --package required")); soldeer_gate::run(&pkg, flag(&args, "--github-output").as_deref()); } + "codegen-declaration" => { + let root = flag(&args, "--root").unwrap_or_else(|| ".".to_string()); + let log = flag(&args, "--log") + .unwrap_or_else(|| fail("codegen-declaration: --log required")); + codegen_declaration::run(Path::new(&root), Path::new(&log)); + } "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()); @@ -272,8 +287,8 @@ fn main() { eprintln!( "rainix-static: unknown subcommand {other:?} \ (available: no-submodules, agent-context-cap, prompt-cap, \ - snapshots-append-only, mutation-ledger, ci-gate, soldeer-gate, \ - rpc-preflight, release-guard)" + codegen-declaration, snapshots-append-only, mutation-ledger, \ + ci-gate, soldeer-gate, rpc-preflight, release-guard)" ); std::process::exit(2); } diff --git a/test/bats/action/codegen-declaration.test.bats b/test/bats/action/codegen-declaration.test.bats new file mode 100644 index 0000000..a1fb7be --- /dev/null +++ b/test/bats/action/codegen-declaration.test.bats @@ -0,0 +1,142 @@ +setup() { + repo_root="$BATS_TEST_DIRNAME/../../.." + action="$repo_root/.github/actions/codegen-declaration/action.yml" + action_script="$(yq -r '.runs.steps[0].run' "$action")" + work="$(mktemp -d)" + log="$work/codegen.log" +} + +teardown() { + rm -rf "$work" +} + +run_action() { + RUNNER_TEMP="$1" \ + GITHUB_ACTION_PATH="$repo_root/.github/actions/codegen-declaration" \ + ACTION_SCRIPT="$action_script" \ + bash -c ' + nix() { + printf "nix" + printf " <%s>" "$@" + printf "\n" + } + export -f nix + bash -c "$ACTION_SCRIPT" + ' +} + +@test "the action checks the log the workflow tees the hooks into" { + run run_action /tmp/runner-temp + + [ "$status" -eq 0 ] + [[ "$output" == *" <--log> " ]] +} + +# Staged, not committed: git ls-files and git diff read the index. +mk_consumer() { + git -C "$work" init -q + mkdir -p "$work/src/generated" "$work/src/lib" + printf 'GENERATED SNAPSHOT\n' >"$work/src/generated/Thing.sol" + printf 'GENERATED AGGREGATE\n' >"$work/src/lib/LibReleasedSuites.sol" + git -C "$work" add -A +} + +# The issue's control/mutant pair as a generator: ownership is computed from the +# repo's contract list, so removing the CALL that emits a path leaves the `owns` +# line and drops the write and the `wrote` line. Output is indented the way +# `forge script` indents console.log under `== Logs ==`. +# +# $1, when given, is the path whose emitter was removed. +generate() { + local dead="${1:-}" path + echo "== Logs ==" + for path in src/generated/Thing.sol src/lib/LibReleasedSuites.sol; do + printf ' rainix-codegen owns %s\n' "$path" + if [ "$path" != "$dead" ]; then + printf '%s\n' "$(cat "$work/$path")" >"$work/$path" + printf ' rainix-codegen wrote %s\n' "$path" + fi + done + echo "Script ran successfully." +} + +@test "a live generator rewriting identical bytes is clean, and so is git diff" { + mk_consumer + generate >"$log" + + run rainix-static codegen-declaration --root "$work" --log "$log" + [ "$status" -eq 0 ] + [[ "$output" == *"clean — 2 declared generated paths"* ]] + + run git -C "$work" diff --exit-code + [ "$status" -eq 0 ] +} + +@test "a generator that stopped emitting a file fails, though git diff is clean" { + mk_consumer + generate src/lib/LibReleasedSuites.sol >"$log" + + run rainix-static codegen-declaration --root "$work" --log "$log" + [ "$status" -eq 1 ] + [[ "$output" == *"src/lib/LibReleasedSuites.sol"* ]] + [[ "$output" == *"emitter is dead"* ]] + [[ "$output" != *"declare src/generated/Thing.sol generated, but nothing wrote"* ]] + + run git -C "$work" diff --exit-code + [ "$status" -eq 0 ] +} + +@test "a declared path that is not on disk at all is named as such" { + mk_consumer + rm "$work/src/lib/LibReleasedSuites.sol" + generate src/lib/LibReleasedSuites.sol >"$log" + + run rainix-static codegen-declaration --root "$work" --log "$log" + [ "$status" -eq 1 ] + [[ "$output" == *"no such file exists"* ]] +} + +@test "a hook that reports writing a path that never appeared fails" { + mk_consumer + printf 'rainix-codegen owns src/lib/Absent.sol\nrainix-codegen wrote src/lib/Absent.sol\n' >"$log" + + run rainix-static codegen-declaration --root "$work" --log "$log" + [ "$status" -eq 1 ] + [[ "$output" == *"reported writing src/lib/Absent.sol"* ]] +} + +@test "a repo whose hooks declare nothing passes, and is told it is unprotected" { + mk_consumer + printf 'Script ran successfully.\n' >"$log" + + run rainix-static codegen-declaration --root "$work" --log "$log" + [ "$status" -eq 0 ] + [[ "$output" == *"declares no generated paths"* ]] +} + +@test "a repo that ran no codegen hook at all passes" { + mk_consumer + + run rainix-static codegen-declaration --root "$work" --log "$work/never-written.log" + [ "$status" -eq 0 ] + [[ "$output" == *"declares no generated paths"* ]] +} + +@test "a path written but not declared is a note, not a failure" { + mk_consumer + printf 'rainix-codegen owns src/generated/Thing.sol\nrainix-codegen wrote src/generated/Thing.sol\nrainix-codegen wrote soldeer.lock\n' >"$log" + printf 'x\n' >"$work/soldeer.lock" + + run rainix-static codegen-declaration --root "$work" --log "$log" + [ "$status" -eq 0 ] + [[ "$output" == *"note — soldeer.lock was written but not declared"* ]] +} + +@test "a verb this check does not know fails rather than being ignored" { + mk_consumer + printf 'rainix-codegen skipped src/generated/Thing.sol\n' >"$log" + + run rainix-static codegen-declaration --root "$work" --log "$log" + [ "$status" -eq 1 ] + [[ "$output" == *"unreadable declaration"* ]] +} diff --git a/test/bats/workflow/rainix-copy-artifacts.test.bats b/test/bats/workflow/rainix-copy-artifacts.test.bats new file mode 100644 index 0000000..1e8ef64 --- /dev/null +++ b/test/bats/workflow/rainix-copy-artifacts.test.bats @@ -0,0 +1,76 @@ +# Nothing in this repo executes rainix-copy-artifacts.yaml — it is +# `workflow_call` only, so its only runners are the consumer repos. +# +# The declaration check's verdict is covered by the Rust unit tests and +# test/bats/action/codegen-declaration.test.bats; what is asserted here is the +# wiring it cannot see: that the hooks' stdout actually reaches the log the +# action reads, and that teeing did not swallow a hook's exit status. + +setup() { + repo_root="$BATS_TEST_DIRNAME/../../.." + workflow="$repo_root/.github/workflows/rainix-copy-artifacts.yaml" + action="$repo_root/.github/actions/codegen-declaration/action.yml" + runs="$(yq -r '.jobs.copy-artifacts.steps[] | select(.run) | .run' "$workflow")" + uses="$(yq -r '.jobs.copy-artifacts.steps[] | select(.uses) | .uses' "$workflow")" + sha="$(yq -r '.env.RAINIX_SHA' "$workflow")" + # The one path the two files have to agree on. + log_path="$(yq -r '.runs.steps[0].run' "$action" | grep -o '\--log "[^"]*"' | sed 's/--log "//; s/"$//')" + teed="$(yq -r '.jobs.copy-artifacts.steps[] | select(.run) | select(.run | contains("tee")) | .run' "$workflow")" +} + +@test "the codegen declaration is checked, at the ref the check ships from" { + echo "$uses" | grep -q '^rainlanguage/rainix/.github/actions/codegen-declaration@main$' +} + +@test "the action reads a log under the runner temp dir, never the working tree" { + [ -n "$log_path" ] + # The literal the action script carries, not an expansion of it. + # shellcheck disable=SC2016 + [[ "$log_path" == '$RUNNER_TEMP/'* ]] +} + +@test "every codegen hook tees into the log the action reads" { + local teed_steps occurrences + teed_steps="$(yq -r '[.jobs.copy-artifacts.steps[] | select(.run) | select(.run | contains("tee -a"))] | length' "$workflow")" + [ "$teed_steps" -eq 4 ] + occurrences="$(grep -cF "tee -a \"$log_path\"" "$workflow")" + [ "$occurrences" -eq 4 ] +} + +@test "every codegen hook reaches the log — none is left undeclarable" { + local hook + for hook in script/build-meta.sh script/Build.sol script/CopyArtifacts.sol script/build.sh; do + echo "$teed" | grep -qF "$hook" + done +} + +# Without pipefail bash reports tee's status, so a failing generator would pass +# the step it just failed. +@test "every teed step sets pipefail" { + local n_teed n_pipefail + n_teed="$(yq -r '[.jobs.copy-artifacts.steps[] | select(.run) | select(.run | contains("tee -a"))] | length' "$workflow")" + n_pipefail="$(yq -r '[.jobs.copy-artifacts.steps[] | select(.run) | select(.run | contains("tee -a")) | select(.run | contains("set -euo pipefail"))] | length' "$workflow")" + [ "$n_teed" -gt 0 ] + [ "$n_pipefail" -eq "$n_teed" ] +} + +@test "the declaration is checked after the last codegen hook and before the diff" { + local last_hook check diff + last_hook="$(yq -r '[.jobs.copy-artifacts.steps | to_entries[] | select(.value.run) | select(.value.run | contains("tee -a")) | .key] | max' "$workflow")" + check="$(yq -r '[.jobs.copy-artifacts.steps | to_entries[] | select(.value.uses) | select(.value.uses | contains("codegen-declaration")) | .key] | .[0]' "$workflow")" + diff="$(yq -r '[.jobs.copy-artifacts.steps | to_entries[] | select(.value.run) | select(.value.run | contains("git diff --exit-code")) | .key] | .[0]' "$workflow")" + [ "$last_hook" -lt "$check" ] + [ "$check" -lt "$diff" ] +} + +@test "every rainix-copy-artifacts run step resolves rainix through the pinned sha" { + [ -n "$sha" ] + [ "$sha" != "null" ] + local unpinned + unpinned="$(echo "$runs" | grep 'github:rainlanguage/rainix' | grep -v 'env.RAINIX_SHA' || true)" + if [ -n "$unpinned" ]; then + echo "FAIL: unpinned rainix refs in rainix-copy-artifacts.yaml:" >&2 + echo "$unpinned" >&2 + return 1 + fi +}