Skip to content

ci(sol): build the tree .soldeerignore publishes, not the repo tree - #316

Open
thedavidmeister wants to merge 5 commits into
mainfrom
2026-08-16-issue-76-package-build
Open

ci(sol): build the tree .soldeerignore publishes, not the repo tree#316
thedavidmeister wants to merge 5 commits into
mainfrom
2026-08-16-issue-76-package-build

Conversation

@thedavidmeister

@thedavidmeister thedavidmeister commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

The CI half of rainlanguage/rain.sol.codegen#76. The repo half — removing the
dangling import that finding is about — is
rainlanguage/rain.sol.codegen#129.

These two must land together. The consumer PR carries Closes #76, so
merging it alone closes the issue and silently drops the gate that keeps the
finding from coming back.

Why this is in rainix and not in the consumer repo

.soldeerignore is a second, hand-maintained definition of what a library is,
disjoint from the source graph forge build walks. The repo tree is always
complete, so a source the filter drops — or a shipped file whose import the
filter drops — resolves in every check that runs against the repo and fails only
in a consumer's forge build after soldeer install. Nothing in the org checks
the two definitions agree.

That hazard is a property of publishing to soldeer, not of rain.sol.codegen.
Every soldeer-publishing sol repo in the org has a .soldeerignore and the same
blind spot, and every one of them already gets its whole sol CI from
rainix-sol.yamlrain.sol.codegen/.github/workflows/ is three files with no
steps in them at all. Putting the job in the consumer would be the first inline
CI in that repo, would protect exactly one repo, and would have to be copied by
hand into the next fifteen. So: rainix-sol-static.yaml, on every push, no
consumer wiring.

The logic is a rainix-static subcommand rather than inline bash, per CLAUDE.md.
It is also not optional here: sol-shell ships no unzip, so unpacking the
package has to happen in-process, and rainix-static already carries the zip
crate for soldeer-gate.

Why the step is a composite and not a run:

env.RAINIX_SHA pins the sol-shell to an older main commit
(53e96a7d), and the rainix-static inside it is whatever that commit built —
no-submodules and soldeer-gate only. A run: … #sol-shell -c rainix-static soldeer-package-build therefore exits 2 with unknown subcommand on every
consumer, measured, not reasoned:

##### A. as staged: rainix-static from the RAINIX_SHA-pinned sol-shell
rainix-static: unknown subcommand "soldeer-package-build" (available: no-submodules, soldeer-gate)
EXIT_A=2

.github/actions/no-submodules already answers this, and its comment says why:
run the check from the action's own checkout via a path: ref so its version
tracks the action version "regardless of any RAINIX_SHA the caller pins". This
step does the same, with one addition — forge still comes from the pinned
sol-shell, which the job has already realised for its other steps, so the
package is built on the same toolchain as the repo tree rather than a second
one. A path: ref makes no api.github.com HEAD lookup, so the unpinned-flake-ref
ban in CLAUDE.md is respected. The workflow and the composite land in the same
commit range on main, so a consumer pinning the reusable @main never sees
one without the other.

What rainix-static soldeer-package-build does

  1. Reads [package] name / version from foundry.toml. A repo that does not
    declare both publishes nothing and is skipped — so this is a no-op for every
    non-publishing consumer and needs no input.
  2. forge soldeer push <name>~<version> --dry-run — the same artifact
    soldeer-gate already hashes, i.e. exactly what would be uploaded.
  3. Unpacks it into a scratch project. Entry paths are rejected, not normalized,
    if they are absolute or contain ...
  4. Copies in foundry.toml, remappings.txt and soldeer.lock only when the
    package does not ship them
    .soldeerignore usually excludes all three
    and a consumer supplies them. A file the package ships is left alone.
  5. forge soldeer install when the scratch foundry.toml opens a
    [dependencies] table, in either the [dependencies] or
    [dependencies.<name>] form.
  6. forge build. Non-zero exit fails the job and leaves the unpacked tree on
    disk, named in the error, for inspection. A clean run removes it.

The published tree keeps its own directory names, so the copied foundry.toml's
src / script / test paths apply unchanged and forge build compiles every
.sol in the package rather than only what something imports.

Shared with soldeer-gate

read_zip, run_cmd and the zip-in-a-directory helpers are now pub(crate)
and take the directory as an argument instead of assuming the cwd;
is_version_line / read_local_version generalise to is_key_line /
read_local_field so name reads the same way version already did.
soldeer_gate's behaviour is unchanged and its tests are untouched.

QA

  • Discriminating tests: 5 bats tests over the built binary and 5 unit tests.
    The subcommand is new, so "fails on base" is the real consumer, not a stub:
    built at this branch and run against unmodified rain.sol.codegen main
    (935c725) it exits 1 on a genuinely broken published package, and exits 0
    once that repo's import is fixed. Output for both below. Bats 1 and 2 also
    failed for the wrong reason on their first run (new Helper() in a pure
    function) — the fixture was fixed, not the assertion.
  • Mutations applied: 8 mutants, all killed — full matrix below, with the
    baseline row and the per-mutant killing test named. R6 survived the first
    pass and a discriminating test was added for it; that history is in the matrix.
  • Oracle: what a consumer receives, derived independently of this code —
    .soldeerignore says /test does not publish, so a shipped script/Build.sol
    importing ../test/concrete/Helper.sol must be unresolvable after
    soldeer install, and must resolve once the imported file moves under src/.
    The real-world confirmation is rain-sol-codegen~0.1.13, a published package
    that is broken in exactly this way today, green on every other check.
  • Category check: rain.sol.codegen#76 asks for two halves — (1) compile the
    package as published, in CI, on every push; (2) remove the current
    inconsistency. This PR is (1); rain.sol.codegen#129 is (2). Both covered, and
    they land together.

Failing first, on a real consumer. Built at this branch, run against an
unmodified rain.sol.codegen main (935c725) — its script/Build.sol ships
and imports ../test/concrete/CodeGennable.sol, which .soldeerignore excludes:

Unable to resolve imports:
      "../test/concrete/CodeGennable.sol" in "…-scratch/script/Build.sol"
Compiling 24 files with Solc 0.8.25
Error: Compiler run failed:
Error (6275): Source "test/concrete/CodeGennable.sol" not found: File not found.
 --> script/Build.sol:8:1:
::error::rain-sol-codegen~0.1.13 does not build as published — …
EXIT=1

forge test, forge fmt --check and slither . are all green on that same
tree. This is the only check that sees it.

Passing after the consumer-side fix, same command, same scratch dir:

Compiling 25 files with Solc 0.8.25
Compiler run successful!
soldeer-package-build: clean — rain-sol-codegen~0.1.13 builds as published (10 Solidity files)
EXIT=0

The wiring, run as CI will run it. Both forms invoked against a real
consumer through the real pin (53e96a7d), the sha bound to a shell variable
exactly as the composite binds it to env.RAINIX_SHA. Tails only:

##### A. as staged: rainix-static from the RAINIX_SHA-pinned sol-shell
rainix-static: unknown subcommand "soldeer-package-build" …
EXIT_A=2
##### B. composite form: forge from the pin, the check from this checkout
soldeer-package-build: clean — rain-sol-codegen~0.1.13 builds as published (10 Solidity files)
EXIT_B=0
##### C. composite form against unmodified consumer main — must still be red
::error::rain-sol-codegen~0.1.13 does not build as published — …
EXIT_C=1

A is what a run: step would have done to every consumer's static job. C shows
the composite form still discriminates.

Suites. cargo testtest result: ok. 113 passed; 0 failed.
cargo clippy --all-targets -- -D warnings → clean.
bats test/bats/task/soldeer-package-build.test.bats → 5/5, hermetic (the
fixture package declares no dependencies and pins solc to this shell's
solc-0.8.25, so no network):

1..5
ok 1 a published file importing an excluded path fails the build
ok 2 the same package builds once the imported file publishes too
ok 3 the scratch tree carries the build config the package does not ship
ok 4 a repo that publishes no package is skipped rather than built
ok 5 a half-declared package is skipped rather than built

Mutation matrix. Every mutant applied alone to the committed tree, both
oracles re-run, mutant reverted, tree confirmed clean at the end. The baseline
row proves the harness runs and that a green reading is reachable — a mutated
build that failed to compile would show up as an error line, not as a kill.

mutant cargo test bats verdict
(baseline, no mutant) 113 passed, 0 failed 1..5, 5 ok
R1 safe_entry_path accepts .. escaping_entry_paths_are_rejected FAILED killed
R2 scaffold_missing overwrites files the package ships scaffolding_takes_only_what_the_package_omits FAILED killed
R3 declares_dependencies stops matching [dependencies.x] dependencies_table_detection FAILED killed
R4 write_entry counts .md instead of .sol a_solidity_entry_is_counted_and_written_with_its_parents FAILED not ok 2 killed
R5 a failed forge build is ignored 113 passed (blind) not ok 1, not ok 3 killed
R6 a missing version is defaulted, not skipped 113 passed (blind) not ok 5 killed
R7 SCAFFOLD_FILES drops foundry.toml 113 passed (blind) not ok 1, not ok 2, not ok 3 killed
R8 a missing name is defaulted, not skipped 113 passed (blind) not ok 5 killed

R6 survived the first pass: the only skip test declared neither [package]
field, so a build that accepted either half alone was invisible. Test 5 (a half-declared package is skipped rather than built) was added for it, and R8
was then written for the symmetric half to prove test 5 covers the property and
not just the one mutant. Both die on it.

R5, R6, R7 and R8 are all blind to cargo test — they live in run(), which
spawns forge, so the bats layer over the built binary is the only oracle that
can see them. That is what the bats file is for.

Blast radius on landing

rainix-sol-static.yaml is pinned @main by every sol consumer, so this gate
goes live everywhere on merge. Reading foundry.toml from every non-archived
repo in rainlanguage, cyclofinance and S01-Issuer: 30 declare
[package] and will actually build
(rain.deploy, raindex,
rain.math.float, rain.metadata, st0x.deploy, …); 14 declare none and are
a no-op.

Any of those 30 whose published tree is already broken reds on its next push.
That is the check working — each such red is a package that does not compile
for a consumer today — but it is a real landing cost. Only rain.sol.codegen
was measured (red before rain.sol.codegen#129, green after); the other 29 were
not swept, so the count of new reds is unknown. Sweeping them before merge is a
job on its own and was not in scope here; say the word and it can be run.

Found, not touched

rainix-autopublish.yaml:198 runs rainix-static soldeer-gate through the
RAINIX_SHA-pinned sol-shell, the same shape this PR just moved away from. It
is not broken — soldeer-gate exists at the pin — but consumers run the gate
logic frozen at 53e96a7d, so a change to it on main reaches nobody until the
pin is bumped, silently and with no red. Same class as what broke here; left
alone as out of scope.

pre-commit run --all-files at this repo's root reports
rustfmt ... Failed with cargo metadata … could not find Cargo.toml. It is
not this branch: the same command on unmodified main (7f223b4, clean
worktree) fails identically. The rustfmt-conditional hook guards on
[ -f Cargo.toml ] || [ -f */Cargo.toml ], which is true here because
rainix-static/Cargo.toml exists, then runs cargo-fmt fmt in the cwd — the
root, which has no manifest and no workspace. Nothing red today: this repo's own
CI runs everything under working-directory: test/fixture (which has a
Cargo.toml) and uses the rainix-rs-static task, not the hook bundle, while
the pre-commit run --all-files step is in the reusable consumers call, and
consumers have a root manifest. Reported rather than fixed — out of scope for
this PR.

What this does not do

It does not check that everything a consumer should get is in the package —
only that what is in the package builds. A source excluded by
.soldeerignore and imported by nothing shipped is still silent. Catching that
needs a positive statement of what the library is, which is a different change.

thedavidmeister and others added 2 commits August 16, 2026 18:50
`.soldeerignore` is a second, hand-maintained definition of what a library
is, disjoint from the source graph `forge build` walks in the repo. The repo
tree is always complete, so a source the filter drops — or a shipped file
whose import the filter drops — resolves in every check rainix runs today and
only fails in a consumer's build after `soldeer install`.

Add `rainix-static soldeer-package-build`: unpack what `forge soldeer push
--dry-run` would upload into a scratch project, scaffold the build config,
remappings and lockfile a consumer supplies, resolve declared dependencies,
and `forge build` it. Wire it into `rainix-sol-static.yaml` ahead of slither.
A repo whose foundry.toml declares no `[package]` name and version publishes
nothing and is skipped.

`soldeer_gate`'s zip and foundry.toml helpers are generalised from the cwd and
the `version` key to an arbitrary directory and key, and shared; behaviour is
unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The skip case only had a foundry.toml declaring neither `name` nor `version`,
so a build that accepted either half alone passed the suite. Assert both
half-declared shapes skip.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@thedavidmeister thedavidmeister self-assigned this Aug 16, 2026
@coderabbitai

coderabbitai Bot commented Aug 16, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@thedavidmeister, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 10 minutes

Limit details: You’ve used all 1 included review currently available under your plan.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 4a8834d1-6eb4-4309-aa8a-0a78967dec62

📥 Commits

Reviewing files that changed from the base of the PR and between 7f223b4 and 44f8594.

📒 Files selected for processing (7)
  • .github/actions/soldeer-package-build/action.yml
  • .github/workflows/rainix-sol-static.yaml
  • flake.nix
  • rainix-static/src/main.rs
  • rainix-static/src/soldeer_gate.rs
  • rainix-static/src/soldeer_package_build.rs
  • test/bats/task/soldeer-package-build.test.bats

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

thedavidmeister and others added 3 commits August 16, 2026 19:07
The step resolved `rainix-static` out of the sol-shell pinned by
`env.RAINIX_SHA`, which is an older main commit whose binary has no
`soldeer-package-build` — measured against the real pin
(`53e96a7d`): `unknown subcommand "soldeer-package-build" … (available:
no-submodules, soldeer-gate)`, exit 2. Every consumer's static job would have
gone red on the first push after this landed.

Move the step into a composite, the way `no-submodules` already resolves its
check: `forge` still comes from the pinned sol-shell, so the package builds on
the same toolchain as the rest of the job, while the check itself runs from the
composite's own checkout through a `path:` ref, which tracks the action version
and makes no api.github.com HEAD lookup.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Any repo can call this composite, so its input is untrusted text. Bind it to an
env var the way `rainix-autopublish.yaml` already binds `soldeer-package`,
instead of interpolating it into the shell command.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`slither .` failed inside `nix-quick-install-action`, fetching
`nix-2.24.12-x86_64-linux.tar.zstd`: `tar: Child returned status 1 / Error is
not recoverable`, 200ms in, before any repo code ran. The same step succeeded
in the twelve sibling jobs of that run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant