Skip to content

ci: run codegen to a fixed point, with a bound, instead of once - #320

Closed
thedavidmeister wants to merge 2 commits into
mainfrom
2026-08-16-issue-314
Closed

ci: run codegen to a fixed point, with a bound, instead of once#320
thedavidmeister wants to merge 2 commits into
mainfrom
2026-08-16-issue-314

Conversation

@thedavidmeister

Copy link
Copy Markdown
Contributor

Closes #314

Problem

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 regeneration
pipeline applies the generation function rather than reaching its fixed point.
rainix-copy-artifacts ran the pipeline exactly once and diffed, so a tree several
passes behind and a generation cycle that never settles both arrived at the currency
check as the same thing: "stale, regenerate and commit". Followed literally once, that
message produces a still-stale tree and a still-red run, and the tempting resolution —
commit whichever pass happened to diff clean — is exactly the inconsistent state where
BYTECODE_HASH names a contract compiled against a different pass of the same file.

Change

rainix-static codegen-fixed-point repeats the whole pipeline until the working tree
stops changing. max-codegen-passes (default 5) bounds it; exhausting the bound fails
with its own error, distinct from the currency check's. A repo already at its fixed point
pays exactly one pass.

The tree is observed as a git tree object written through a scratch index, not a
git diff. That compares content rather than paths, respects .gitignore, counts
untracked output, and leaves the repo's own index alone for the currency check that
follows. The scratch index is reused across observations rather than deleted per pass,
because git add --all is defined to reconcile removals as well as content — the tree it
writes describes the working tree as it is now, not the union of every pass so far.

Deviation from the issue's sketch

The issue proposes inline bash: git add -A then [ -z "$(git status --porcelain)" ].
That never converges — git status --porcelain reports staged changes against HEAD, so
once git add -A has staged anything the emptiness test can no longer come back true. It
also stages the consumer's tree, which is the input the currency check downstream needs
left alone. Hence the Rust binary, which CLAUDE.md already binds real work to.

QA

  • Discriminating tests: the 11-test bats suite test/bats/workflow/copy-artifacts-fixed-point.test.bats, plus Rust codegen_fixed_point::tests::{a_pass_that_deletes_a_file_counts_as_a_change, the_repos_own_index_is_left_for_the_currency_check_to_stage, gitignored_build_output_does_not_look_like_a_moving_tree, a_directory_that_is_not_a_repo_is_an_error_not_a_pass} — each fails on base (verified by git checkout origin/main -- .github/workflows/rainix-copy-artifacts.yaml and re-running: all 11 bats report not ok, the step the suite reads with yq not existing on origin/main; the Rust tests fail on base because the codegen_fixed_point module does not exist there at all). Harness honesty was checked first by stubbing the binary under test to exit 77: 9 of 11 bats fail, and the two that pass (6 and 11) are the pure workflow-text assertions that never invoke it — so the suite is really executing the binary, not asserting against a shell that silently no-ops.

  • Mutations applied: every mutant below was introduced, run, and reverted by me; each was asserted to actually change the file first, and each run was asserted to have executed a non-zero number of tests, so "survived" cannot be a no-op or an empty filter.

    Mutation Killed by
    strip the codegen-fixed-point wrapper → single-shot pipeline (the issue's exact defect) bats 1, 2, 3, 4, 5, 7, 11
    --max-passes ${{ inputs.max-codegen-passes }}--max-passes 5 bats 5
    forge fmt hoisted out of the --run body bats 3, 4, 5, 7
    .../${{ env.RAINIX_SHA }}#sol-shell -c forge build.../main#sol-shell bats 1, 2, 3, 4, 5, 8, 9, 10, 11
    if [ -f script/build.sh ]if true bats 10
    yaml max-codegen-passes.default: 59 bats 6
    git() drops .env("GIT_INDEX_FILE", index) → loop uses the real index the_repos_own_index_is_left_for_the_currency_check_to_stage; bats 8
    git add --allgit add --all --force gitignored_build_output_does_not_look_like_a_moving_tree
    git add --allgit add --no-all . a_pass_that_deletes_a_file_counts_as_a_change (only this one)
    not-a-repo error drops root.display() a_directory_that_is_not_a_repo_is_an_error_not_a_pass
    re-insert let _ = std::fs::remove_file(index); in snapshot() SURVIVED — deliberately

    The last row is not a coverage gap. That line surviving is the proof it was dead: with
    the scratch index reused, --all already reconciles removals, so deleting the index
    did nothing. It is dropped by this PR rather than covered by a test. The --no-all .
    row is why the deletion test exists — it is the regression that silently converges on
    pass 1 and hands the currency check a tree it never watched settle, and nothing else
    in the suite catches it. git add --all --ignore-removal and bare git add --no-all
    were tried and rejected as invalid mutants: git errors or no-ops, killing tests for
    the wrong reason.

  • Oracle: the invariant is rain.sol.codegen's README.md:18-21 and this issue — pointers feed back into the contracts whose codehash the same file records, so generation must repeat to a fixed point — derived independently of this implementation. The bats suite reads its expected values out of the workflow with yq (the step's own run: text, and max-codegen-passes.default) rather than restating them, so the bound under test is the one a consumer gets when they pass no with: at all; test 6 then pins that read value to 5. Tree equality is git's own tree-object id, not a hand-rolled directory walk, and pass counting is done outside the checkout so counting cannot itself look like a tree that keeps moving.

  • Category check: the issue asks for (A) the loop in the machine, (B) with a bound, (C) a non-converging build that says so rather than blaming the developer, and (D) a currency check comparing the committed tree against the converged output rather than one iteration. Covered A (bats 1-3), B (bats 5, 6), C (bats 4 asserts ::error:: and the "cycle that does not settle" wording, distinct from the staleness message), D (bats 8 pins that the loop leaves the index for the currency check). The issue's yaml sketch is deliberately not followed; that deviation and its reason are stated above.

Also run: cargo test 121 passed / 0 failed; cargo clippy --all-targets --all-features -- -D warnings -D clippy::all and cargo fmt --all -- --check clean. Pre-existing and confirmed pre-existing by running them against a clean origin/main @ 7f223b4 rather than assumed: three prettier-bundle failures in default-shell-test, and pre-commit run --all-files failing rustfmt (its glob matches */Cargo.toml then runs cargo-fmt from the repo root, where there is none).

Interaction with open PRs

🤖 Generated with Claude Code

claude and others added 2 commits August 16, 2026 18:58
Generated sources are inputs to their own generation, so one pass of the
regeneration pipeline applies the generation function rather than reaching its
fixed point. Run once, a tree several passes behind and a generation cycle that
never settles both reach the currency check as "stale".

`rainix-static codegen-fixed-point` loops the pipeline until the working tree
stops changing, observing the tree as a git tree object built in a scratch index
so .gitignore applies, untracked output counts, and the repo's own index is left
for the currency check. `max-codegen-passes` bounds it; exhausting the bound
fails with its own error.

Closes #314

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The loop's own semantics were unit-tested, but nothing exercised the wiring
between the workflow input, the pinned flake refs and `rainix-static
codegen-fixed-point`. The new bats suite drives the step's own `run:` text,
read out of the workflow with `yq`, against a consumer checkout with `nix` and
`forge` stubbed as files on PATH so the real binary reaches them. A single-shot
pipeline, a hardcoded bound, `forge fmt` hoisted out of the loop, an unpinned
flake ref, a loop that leaves the tree staged, a skipped optional hook and a
changed yaml default are each killed by it.

Observing the tree reused the scratch index rather than deleting it per pass.
`git add --all` is defined to reconcile removals as well as content, so the
tree it writes describes the working tree as it is now; deleting the index each
pass was doing the same work at the cost of the guarantee being restated. A
pass that only deletes a file now has a test, because that is the case a
`git add --no-all .` regression silently converges on pass 1.

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: 3 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: 6c7c64fa-5836-4d5f-b4ba-33b98f13c2ed

📥 Commits

Reviewing files that changed from the base of the PR and between 7f223b4 and 23f4e7f.

📒 Files selected for processing (6)
  • .github/workflows/rainix-copy-artifacts.yaml
  • README.md
  • flake.nix
  • rainix-static/src/codegen_fixed_point.rs
  • rainix-static/src/main.rs
  • test/bats/workflow/copy-artifacts-fixed-point.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

Copy link
Copy Markdown
Contributor Author

Closing in favour of #319, which closes #314 and subsumes this. Verified against both trees rather than taken on the claim:

This branch has a fatal defect that #319 fixes. The step runs

nix run github:rainlanguage/rainix/${{ env.RAINIX_SHA }}#rainix-static -- codegen-fixed-point ...

with RAINIX_SHA: 53e96a7. At that commit rainix-static exposes only no-submodules and soldeer-gategit ls-tree -r 53e96a7 has no codegen_fixed_point source at all. A pinned ref can only ever resolve to a commit predating the subcommand its own branch adds, so on merge every consumer's copy-artifacts job fails with an unknown subcommand. #319 resolves the binary through a path: flake ref from $GITHUB_ACTION_PATH, matching the other rainix-static entry points.

My 11th test actively enshrined that defect: "every flake ref in the step is pinned to the workflow's sha" asserts the broken invariant, and the suite structurally cannot catch it — the nix stub checks the ref string and then execs a locally built binary, so the pinned commit's contents are never consulted. #319 rewords it to "every devshell the pipeline enters is pinned to the workflow's sha", which is the correct narrower claim: the devshells pin, the loop binary comes from the action's own checkout.

The one thing here that #319 lacks on the remote at b5a880e is the Rust test a_pass_that_deletes_a_file_counts_as_a_change. I measured it as the only test in either suite that kills git add --allgit add --no-all .; that mutant otherwise converges on pass 1 and hands the currency check a tree it never watched settle. It is reportedly folded in as 3dfac9d but that commit is not pushed — worth confirming it lands before merge.

The mutation matrix in the body above was measured on this branch and mostly transfers; the row for the unpinned-flake-ref mutant does not, since #319 deliberately changes that invariant.

Branch 2026-08-16-issue-314 is left in place, not deleted.

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.

rainix-copy-artifacts runs codegen exactly once, so a non-converging build is indistinguishable from a forgotten regeneration

2 participants