Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions .github/actions/frozen-snapshots-append-only/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
12 changes: 10 additions & 2 deletions .github/workflows/rainix-copy-artifacts.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 19 additions & 3 deletions rainix-static/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ref>] [--root <dir>]
// fail if the branch modifies or deletes an existing per-tag deploy-pin
// snapshot under <root>/<tag>/ (default root src/generated, base
// snapshot under <root>/<tag>/ (default root `generated-dir`, base
// origin/main). Snapshots are frozen once on the base branch; a release
// ADDS a new <tag>, never edits an existing one. Needs the base ref
// fetched with history (fetch-depth: 0 + `git fetch origin <base>`).
Expand All @@ -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) -> ! {
Expand Down Expand Up @@ -156,9 +170,10 @@ fn main() {
.unwrap_or_else(|| fail("soldeer-gate: --package <name> 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"),
Expand Down Expand Up @@ -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);
}
Expand Down
42 changes: 32 additions & 10 deletions rainix-static/src/soldeer_gate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,17 @@ fn blank_foundry_version(content: &[u8]) -> Vec<u8> {
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 `<tag>/` 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 `<tag>/` 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<Entry>) -> 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);
Expand Down Expand Up @@ -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())];
Expand Down
176 changes: 176 additions & 0 deletions test/bats/action/generated-dir.test.bats
Original file line number Diff line number Diff line change
@@ -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" == *"<snapshots-append-only>"* ]]
}

@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" != *"<snapshots-append-only>"* ]]
}

@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"* ]]
}
Loading