From 52b7e09c602fe770919b3070db708c2809d3c717 Mon Sep 17 00:00:00 2001 From: Henry Park <16583448+henrypark133@users.noreply.github.com> Date: Sat, 22 Aug 2026 11:00:04 +0000 Subject: [PATCH 01/44] perf(build): codify CI's debug=0 policy as the workspace dev/test/release profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A handful of lanes (reborn-tests.yml's 10 jobs via workflow env, reborn-e2e.yml's rust-reborn job, platform-and-compat.yml's hooks-parity-tests/windows-build jobs, coverage.yml's coverage job, one live-canary.yml step) already build dev/test at CARGO_PROFILE_DEV_DEBUG=0/ CARGO_PROFILE_TEST_DEBUG=0 via duplicated env decls; this is a true no-op for them. Every other Rust-building job in the repo (code_style.yml's clippy/test jobs, reborn-playwright.yml, sccache-dist-smoke.yml, release-plz.yml, nightly-deep-ci.yml, and most of ironclaw-stress.yml/ live-canary.yml) has never set this env and builds at cargo's full-debug dev default today, so this task changes their build fingerprint and costs each a one-time Swatinem/rust-cache cold miss on its next run. Chosen deliberately: this pays that cost once, up front, for the whole repo, rather than smearing it across the 13 file-swap commits that follow. ironclaw-stress.yml also separately restated CARGO_PROFILE_RELEASE_DEBUG=0 three times (already cargo's built-in release default, and now explicit here too) — release build output is unaffected either way. Measured on ironclaw_common tests: debug=0 175 MiB vs line-tables-only 257 MiB vs full 272 MiB. Co-Authored-By: Claude Fable 5 --- Cargo.toml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index ccf0029559e..f0ab9ce4971 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -520,7 +520,26 @@ path = "tests/integration/run_artifact_timings.rs" name = "reborn_integration_db_write_canonical" path = "tests/integration/db_write_canonical.rs" +# Debug-info policy for dev and test (test inherits dev, so this one line +# governs `cargo test` binaries and their dependencies too). CI has built +# every heavy lane with CARGO_PROFILE_DEV_DEBUG=0/CARGO_PROFILE_TEST_DEBUG=0 +# for a long time; codifying it here makes local builds reproduce CI and lets +# the per-workflow env copies be deleted as no-ops. Assert/panic messages +# keep their file:line via panic::Location at any debug level; debug=0 only +# drops mid-frame file:line from RUST_BACKTRACE output. Measured 2026-08-21 +# on ironclaw_common tests (rustc 1.98.0): debug=0 175 MiB / line-tables-only +# 257 MiB (+47%) / full 272 MiB — the smaller artifacts win on 2-core CI +# runners and the 10 GB cache budget. Debugging locally with variable +# inspection? Override per-run: `CARGO_PROFILE_DEV_DEBUG=2 cargo `. +[profile.dev] +debug = 0 + [profile.release] +# Already cargo's built-in default (debug info is off by default in +# release); made explicit because ironclaw-stress.yml restated it three +# times as CARGO_PROFILE_RELEASE_DEBUG=0 workflow/job env — this line makes +# those deletable no-ops too (Task 9). +debug = 0 strip = true # Remove debug symbols from release binaries # The profile that 'cargo dist' will build with From 84bc3869faefef578b183e62872c236bfed78bdd Mon Sep 17 00:00:00 2001 From: Henry Park <16583448+henrypark133@users.noreply.github.com> Date: Sat, 22 Aug 2026 11:00:14 +0000 Subject: [PATCH 02/44] ci: add the setup-rust composite action (unused by any workflow yet) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One component for what toolchain, linker, and profile env a Rust CI job gets: installs via the pinned dtolnay/rust-toolchain SHA, then exports RUSTUP_TOOLCHAIN from the action's own resolved-toolchain output so a job's cargo invocations can never drift from what this step actually installed — no separate mechanism needed for nightly lanes. Optional mold: true absorbs the install/verify/RUSTFLAGS-export steps currently copy-pasted per job. No workflow calls this yet (following tasks swap each file one at a time), so this cannot change CI behavior. New ws12 structural check on the action file itself; self-tested. Co-Authored-By: Claude Fable 5 --- .github/actions/setup-rust/action.yml | 91 ++++++++++++++++++++++ scripts/ci/test_ws12_workflow_contracts.py | 53 +++++++++++++ scripts/ci/ws12_workflow_contracts.py | 55 +++++++++++++ 3 files changed, 199 insertions(+) create mode 100644 .github/actions/setup-rust/action.yml diff --git a/.github/actions/setup-rust/action.yml b/.github/actions/setup-rust/action.yml new file mode 100644 index 00000000000..3adbb178151 --- /dev/null +++ b/.github/actions/setup-rust/action.yml @@ -0,0 +1,91 @@ +name: Setup Rust +description: >- + Install the Rust toolchain (pinned by default to the same version as + rust-toolchain.toml — see the `toolchain` input), optionally the mold + linker, and pin RUSTUP_TOOLCHAIN for the rest of this job so it can never + silently drift from what this step actually installed. + +# Why RUSTUP_TOOLCHAIN: dtolnay/rust-toolchain only runs `rustup default`, +# which rust-toolchain.toml would otherwise outrank for every in-repo cargo +# invocation (rustup precedence: RUSTUP_TOOLCHAIN env > directory override > +# rust-toolchain.toml file > `rustup default`). Exporting the toolchain this +# step actually resolved — via the action's own `outputs.name`, not a +# second guess at it — makes that drift structurally impossible, for a +# pinned stable lane and a nightly lane alike. No lane needs special-casing. +# +# Why one place for mold: mold's apt-install/verify/RUSTFLAGS-export used to +# be copy-pasted per job (8 install sites, 2 "verify" blocks, 4 written-out +# copies of the linker RUSTFLAGS string across reborn-tests.yml and +# reborn-e2e.yml). One `mold: true` input replaces all of that. + +inputs: + toolchain: + description: >- + Toolchain spec passed through to dtolnay/rust-toolchain. Defaults to + the same version pinned in rust-toolchain.toml — bump both together; + scripts/ci/ws12_workflow_contracts.py fails the build if they drift. + Pass an explicit value (e.g. nightly-2025-11-01) for a lane that + intentionally does not track the pinned stable toolchain. + required: false + default: "1.98.0" + components: + description: Comma-separated rustup components, passed through. + required: false + default: "" + targets: + description: Comma-separated target triples, passed through. + required: false + default: "" + mold: + description: >- + Install mold+clang, verify the linker actually links, and export the + canonical mold RUSTFLAGS prefix. Linux-only (a no-op elsewhere). + Defaults to false: most jobs in this repo do not link with mold; the + handful that do pass `mold: true` explicitly. + required: false + default: "false" + +runs: + using: composite + steps: + - name: Install Rust + id: install + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + with: + toolchain: ${{ inputs.toolchain }} + components: ${{ inputs.components }} + targets: ${{ inputs.targets }} + + - name: Pin the resolved toolchain for the rest of this job + shell: bash + run: | + echo "RUSTUP_TOOLCHAIN=${{ steps.install.outputs.name }}" >> "$GITHUB_ENV" + + - name: Install mold and clang + if: ${{ inputs.mold == 'true' && runner.os == 'Linux' }} + shell: bash + run: | + scripts/ci/install-ci-apt-packages.sh clang mold + test -x /usr/bin/mold + + - name: Verify mold linker is active + if: ${{ inputs.mold == 'true' && runner.os == 'Linux' }} + shell: bash + run: | + clang_output=$(echo 'int main(void) { return 0; }' \ + | clang --ld-path=/usr/bin/mold -Wl,--version -x c - -o /tmp/clang-mold-check 2>&1) + grep -qi mold <<< "$clang_output" + printf 'fn main() { println!("mold-link-ok"); }\n' > /tmp/mold-link-check.rs + rustc /tmp/mold-link-check.rs \ + -C linker=clang \ + -C link-arg=--ld-path=/usr/bin/mold \ + -o /tmp/mold-link-check + test "$(/tmp/mold-link-check)" = "mold-link-ok" + + - name: Export mold RUSTFLAGS + if: ${{ inputs.mold == 'true' && runner.os == 'Linux' }} + shell: bash + env: + MOLD_RUSTFLAGS: "-C linker=clang -C link-arg=--ld-path=/usr/bin/mold" + run: | + echo "RUSTFLAGS=${MOLD_RUSTFLAGS} ${RUSTFLAGS:-}" >> "$GITHUB_ENV" diff --git a/scripts/ci/test_ws12_workflow_contracts.py b/scripts/ci/test_ws12_workflow_contracts.py index 36fdf1a6d5a..9e851f72569 100755 --- a/scripts/ci/test_ws12_workflow_contracts.py +++ b/scripts/ci/test_ws12_workflow_contracts.py @@ -20,6 +20,7 @@ NIGHTLY_DEEP_CI_WORKFLOW, PLATFORM_WORKFLOW, REQUIRED_MARKERS, + SETUP_RUST_ACTION, STEP_HEADING, STRESS_WORKFLOW, WEBUI_FRONTEND_CRATE, @@ -36,6 +37,7 @@ validate_libsql_scripted_memory_job, validate_postgres_scripted_parity, validate_production_lint_targets, + validate_setup_rust_action, validate_windows_webui_install_shell, validate_webui_frontend_sites, validate_workflow_texts, @@ -47,6 +49,57 @@ ) +class SetupRustActionContractTests(unittest.TestCase): + """The setup-rust composite must actually pin RUSTUP_TOOLCHAIN and mold.""" + + OK = ( + "runs:\n" + " using: composite\n" + " steps:\n" + " - name: Install Rust\n" + " id: install\n" + " uses: dtolnay/rust-toolchain@abc123 # stable\n" + " with:\n" + " toolchain: ${{ inputs.toolchain }}\n" + " - name: Pin the resolved toolchain for the rest of this job\n" + " shell: bash\n" + " run: |\n" + ' echo "RUSTUP_TOOLCHAIN=${{ steps.install.outputs.name }}" >> "$GITHUB_ENV"\n' + " - name: Install mold and clang\n" + " if: ${{ inputs.mold == 'true' && runner.os == 'Linux' }}\n" + " shell: bash\n" + " run: scripts/ci/install-ci-apt-packages.sh clang mold\n" + " - name: Export mold RUSTFLAGS\n" + " if: ${{ inputs.mold == 'true' && runner.os == 'Linux' }}\n" + " shell: bash\n" + " run: |\n" + ' echo "RUSTFLAGS=-C linker=clang -C link-arg=--ld-path=/usr/bin/mold ${RUSTFLAGS:-}" >> "$GITHUB_ENV"\n' + ) + + def test_missing_action_file_fails(self): + errors = validate_setup_rust_action(None) + self.assertTrue(any("could not read" in e for e in errors)) + + def test_missing_rustup_toolchain_pin_fails(self): + bad = self.OK.replace( + 'echo "RUSTUP_TOOLCHAIN=${{ steps.install.outputs.name }}" >> "$GITHUB_ENV"\n', + "", + ) + errors = validate_setup_rust_action(bad) + self.assertTrue(any("RUSTUP_TOOLCHAIN" in e for e in errors)) + + def test_missing_mold_linux_guard_fails(self): + bad = self.OK.replace( + "if: ${{ inputs.mold == 'true' && runner.os == 'Linux' }}\n shell: bash\n run: scripts/ci/install-ci-apt-packages.sh clang mold\n", + "shell: bash\n run: scripts/ci/install-ci-apt-packages.sh clang mold\n", + ) + errors = validate_setup_rust_action(bad) + self.assertTrue(any("Linux" in e for e in errors)) + + def test_ok_passes(self): + self.assertEqual([], validate_setup_rust_action(self.OK)) + + class SccacheSetupActionContractTests(unittest.TestCase): """The optional compiler cache must never gate the tests it accelerates.""" diff --git a/scripts/ci/ws12_workflow_contracts.py b/scripts/ci/ws12_workflow_contracts.py index 92ca3d79f81..5a85f66ec04 100755 --- a/scripts/ci/ws12_workflow_contracts.py +++ b/scripts/ci/ws12_workflow_contracts.py @@ -971,6 +971,14 @@ def validate_postgres_scripted_parity(text: str) -> list[str]: SCCACHE_INSTALL_STEP = "Install sccache" SCCACHE_CONFIGURE_STEP = "Configure OVH sccache" SCCACHE_FALLBACK_STEP = "Fall back to local compilation" +SETUP_RUST_ACTION = ".github/actions/setup-rust/action.yml" +RUSTUP_TOOLCHAIN_PIN_STEP = "Pin the resolved toolchain for the rest of this job" +MOLD_INSTALL_STEP = "Install mold and clang" +MOLD_EXPORT_STEP = "Export mold RUSTFLAGS" +# The one canonical mold invocation; a job's own env may append extra flags +# after it (the nightly lanes add -Zcrate-attr=...), but this prefix is the +# only place it may be written out — everywhere else must go through here. +MOLD_RUSTFLAGS = "-C linker=clang -C link-arg=--ld-path=/usr/bin/mold" # One `- name:` step heading. The scan is bounded to its own step because the # neighbouring `Check all-target lints` legitimately passes `--tests @@ -1077,6 +1085,48 @@ def validate_sccache_setup_action(text: str) -> list[str]: return errors +def validate_setup_rust_action(text: str | None) -> list[str]: + """The setup-rust composite must actually pin RUSTUP_TOOLCHAIN and mold. + + Contract: every job that installs Rust through this composite gets a + RUSTUP_TOOLCHAIN export naming exactly the toolchain dtolnay/rust-toolchain + just installed (steps.install.outputs.name — not a second, possibly wrong, + guess at the resolved version), and mold's install/verify/RUSTFLAGS steps + are gated on Linux so `mold: true` is safe to pass on any runner OS. + """ + if text is None: + return [f"{SETUP_RUST_ACTION}: could not read the composite action file"] + errors: list[str] = [] + pin_step = step_body(text, RUSTUP_TOOLCHAIN_PIN_STEP) + if pin_step is None: + errors.append( + f"{SETUP_RUST_ACTION}: missing the {RUSTUP_TOOLCHAIN_PIN_STEP!r} step" + ) + elif "RUSTUP_TOOLCHAIN=${{ steps.install.outputs.name }}" not in pin_step: + errors.append( + f"{SETUP_RUST_ACTION}: {RUSTUP_TOOLCHAIN_PIN_STEP!r} must export " + "RUSTUP_TOOLCHAIN from steps.install.outputs.name, or a job's " + "cargo invocations can drift from what this step actually installed" + ) + for step_name in (MOLD_INSTALL_STEP, MOLD_EXPORT_STEP): + body = step_body(text, step_name) + if body is None: + errors.append(f"{SETUP_RUST_ACTION}: missing the {step_name!r} step") + continue + if "runner.os == 'Linux'" not in body: + errors.append( + f"{SETUP_RUST_ACTION}: {step_name!r} must gate on " + "runner.os == 'Linux' so mold: true is safe on any runner" + ) + export_step = step_body(text, MOLD_EXPORT_STEP) + if export_step is not None and MOLD_RUSTFLAGS not in export_step: + errors.append( + f"{SETUP_RUST_ACTION}: {MOLD_EXPORT_STEP!r} must export the " + f"canonical mold RUSTFLAGS prefix '{MOLD_RUSTFLAGS}'" + ) + return errors + + def job_body(text: str, job_name: str) -> str | None: """Return one workflow job's body, bounded by the next job heading.""" for heading in JOB_HEADING.finditer(text): @@ -1791,6 +1841,11 @@ def validate_workflow_texts( errors.append(f"{SCCACHE_SETUP_ACTION}: could not read action: {error}") else: errors.extend(validate_sccache_setup_action(sccache_action)) + try: + setup_rust_action = (root / SETUP_RUST_ACTION).read_text(encoding="utf-8") + except OSError: + setup_rust_action = None + errors.extend(validate_setup_rust_action(setup_rust_action)) return errors From 0f8f7ac930b325a68168ea6847da972d43960605 Mon Sep 17 00:00:00 2001 From: Henry Park <16583448+henrypark133@users.noreply.github.com> Date: Sat, 22 Aug 2026 11:00:26 +0000 Subject: [PATCH 03/44] ci(sccache-dist-smoke): install Rust via the setup-rust composite No behavior change: the composite defaults to the same 'stable' toolchain dtolnay/rust-toolchain already defaulted to; this job now also gets RUSTUP_TOOLCHAIN protection ahead of rust-toolchain.toml landing. Co-Authored-By: Claude Fable 5 --- .github/workflows/sccache-dist-smoke.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sccache-dist-smoke.yml b/.github/workflows/sccache-dist-smoke.yml index b78e8275a27..8b425308bbb 100644 --- a/.github/workflows/sccache-dist-smoke.yml +++ b/.github/workflows/sccache-dist-smoke.yml @@ -16,7 +16,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 - name: Install Rust - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + uses: ./.github/actions/setup-rust - name: Setup OVH sccache uses: ./.github/actions/setup-sccache-dist From 46bdb2c4b42626dca0a016dad445f00391452683 Mon Sep 17 00:00:00 2001 From: Henry Park <16583448+henrypark133@users.noreply.github.com> Date: Sat, 22 Aug 2026 11:00:44 +0000 Subject: [PATCH 04/44] ci(reborn-playwright): install Rust via the setup-rust composite No behavior change: same default toolchain, now with RUSTUP_TOOLCHAIN protection ahead of rust-toolchain.toml landing. Co-Authored-By: Claude Fable 5 --- .github/workflows/reborn-playwright.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/reborn-playwright.yml b/.github/workflows/reborn-playwright.yml index ccf82af4be6..94a34042dfb 100644 --- a/.github/workflows/reborn-playwright.yml +++ b/.github/workflows/reborn-playwright.yml @@ -42,7 +42,7 @@ jobs: persist-credentials: false - name: Install Rust - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + uses: ./.github/actions/setup-rust - name: Enable pnpm for setup-node cache run: corepack enable pnpm From a3d03dced3ecd063ae75d5c7781cd95d9ccbe5c9 Mon Sep 17 00:00:00 2001 From: Henry Park <16583448+henrypark133@users.noreply.github.com> Date: Sat, 22 Aug 2026 11:00:50 +0000 Subject: [PATCH 05/44] ci(release-plz): install Rust via the setup-rust composite No behavior change: same default toolchain, now with RUSTUP_TOOLCHAIN protection ahead of rust-toolchain.toml landing. Co-Authored-By: Claude Fable 5 --- .github/workflows/release-plz.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-plz.yml b/.github/workflows/release-plz.yml index e965814c1a8..bf8cec8bb99 100644 --- a/.github/workflows/release-plz.yml +++ b/.github/workflows/release-plz.yml @@ -24,7 +24,7 @@ jobs: persist-credentials: false - &install-rust name: Install Rust toolchain - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + uses: ./.github/actions/setup-rust - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 # Generating a GitHub token, so that PRs and tags created by # the release-plz-action can trigger actions workflows. From b71cca902f12a8846a421eeccbda7875d3435da5 Mon Sep 17 00:00:00 2001 From: Henry Park <16583448+henrypark133@users.noreply.github.com> Date: Sat, 22 Aug 2026 11:01:00 +0000 Subject: [PATCH 06/44] ci(nightly-deep-ci): install Rust via the setup-rust composite No behavior change: same default toolchain, now with RUSTUP_TOOLCHAIN protection ahead of rust-toolchain.toml landing. Co-Authored-By: Claude Fable 5 --- .github/workflows/nightly-deep-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/nightly-deep-ci.yml b/.github/workflows/nightly-deep-ci.yml index b4aac23067c..2c68d77ec41 100644 --- a/.github/workflows/nightly-deep-ci.yml +++ b/.github/workflows/nightly-deep-ci.yml @@ -107,7 +107,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 with: persist-credentials: false - - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + - uses: ./.github/actions/setup-rust - name: Install pinned cargo-mutants run: cargo install cargo-mutants --locked --version 27.1.0 - name: Self-test mutation audit guardrails From 68d4e165c010eb1fa17775a174feed0ebfa3d1eb Mon Sep 17 00:00:00 2001 From: Henry Park <16583448+henrypark133@users.noreply.github.com> Date: Sat, 22 Aug 2026 11:01:16 +0000 Subject: [PATCH 07/44] ci(reborn-release-compile): install Rust via the setup-rust composite No behavior change: same default toolchain and the same matrix.target input, now with RUSTUP_TOOLCHAIN protection ahead of rust-toolchain.toml. Co-Authored-By: Claude Fable 5 --- .github/workflows/reborn-release-compile.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/reborn-release-compile.yml b/.github/workflows/reborn-release-compile.yml index 9e629a96e04..e69fda63727 100644 --- a/.github/workflows/reborn-release-compile.yml +++ b/.github/workflows/reborn-release-compile.yml @@ -69,7 +69,7 @@ jobs: python-version: "3.12" - name: Install Rust target - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + uses: ./.github/actions/setup-rust with: targets: ${{ matrix.target }} From 80e576e633505ee3ddaa2892bb1890d8aea2bf56 Mon Sep 17 00:00:00 2001 From: Henry Park <16583448+henrypark133@users.noreply.github.com> Date: Sat, 22 Aug 2026 11:01:25 +0000 Subject: [PATCH 08/44] ci(ironclaw-stress): install Rust via the setup-rust composite No toolchain behavior change: same default toolchain, now with RUSTUP_TOOLCHAIN protection ahead of rust-toolchain.toml landing. Also deletes the three CARGO_PROFILE_RELEASE_DEBUG=0 env decls, already no-ops restating cargo's own release-profile default and now Cargo.toml's explicit [profile.release] debug = 0 (landed in a prior commit). Co-Authored-By: Claude Fable 5 --- .github/workflows/ironclaw-stress.yml | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ironclaw-stress.yml b/.github/workflows/ironclaw-stress.yml index 774396eb009..c661617e059 100644 --- a/.github/workflows/ironclaw-stress.yml +++ b/.github/workflows/ironclaw-stress.yml @@ -47,7 +47,6 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} env: - CARGO_PROFILE_RELEASE_DEBUG: 0 IRONCLAW_DISABLE_OS_KEYCHAIN: "1" jobs: @@ -62,7 +61,7 @@ jobs: persist-credentials: false - name: Install Rust - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + uses: ./.github/actions/setup-rust - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: @@ -141,7 +140,7 @@ jobs: persist-credentials: false - name: Install Rust - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + uses: ./.github/actions/setup-rust - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: @@ -187,7 +186,7 @@ jobs: with: persist-credentials: false - name: Install Rust - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + uses: ./.github/actions/setup-rust - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: key: ironclaw-stress-soak @@ -221,7 +220,6 @@ jobs: timeout-minutes: 60 if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' env: - CARGO_PROFILE_RELEASE_DEBUG: 0 IRONCLAW_DISABLE_OS_KEYCHAIN: "1" SECRETS_MASTER_KEY: stress-ci-secret-master-key-0123456789abcdef IRONCLAW_REBORN_RUNNER_WORKER_COUNT: "0" @@ -237,7 +235,7 @@ jobs: persist-credentials: false - name: Install Rust - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + uses: ./.github/actions/setup-rust - name: Install Node.js for WebUI bundle build uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v4 @@ -436,7 +434,6 @@ jobs: --health-timeout 5s --health-retries 5 env: - CARGO_PROFILE_RELEASE_DEBUG: 0 IRONCLAW_DISABLE_OS_KEYCHAIN: "1" IRONCLAW_REBORN_POSTGRES_URL: postgres://postgres:postgres@localhost:5432/ironclaw_stress IRONCLAW_REBORN_SECRET_MASTER_KEY: stress-ci-secret-master-key-0123456789abcdef @@ -455,7 +452,7 @@ jobs: persist-credentials: false - name: Install Rust - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + uses: ./.github/actions/setup-rust - name: Install Node.js for WebUI bundle build uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v4 From 1f4bd5d350bc91802c1b8f2abca4c8ffbed300ee Mon Sep 17 00:00:00 2001 From: Henry Park <16583448+henrypark133@users.noreply.github.com> Date: Sat, 22 Aug 2026 11:01:31 +0000 Subject: [PATCH 09/44] ci(code_style): install Rust via the setup-rust composite No behavior change: same default toolchain and the same clippy/rustfmt component inputs, now with RUSTUP_TOOLCHAIN protection ahead of rust-toolchain.toml landing. Co-Authored-By: Claude Fable 5 --- .github/workflows/code_style.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/code_style.yml b/.github/workflows/code_style.yml index ca1d2010c2a..d90da07069e 100644 --- a/.github/workflows/code_style.yml +++ b/.github/workflows/code_style.yml @@ -162,7 +162,7 @@ jobs: fetch-depth: 0 persist-credentials: false - name: Install Rust - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + uses: ./.github/actions/setup-rust with: components: rustfmt - name: Install Python @@ -354,7 +354,7 @@ jobs: with: persist-credentials: false - name: Install Rust - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + uses: ./.github/actions/setup-rust with: components: clippy - name: Enable pnpm for setup-node cache @@ -456,7 +456,7 @@ jobs: with: persist-credentials: false - name: Install Rust - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + uses: ./.github/actions/setup-rust with: components: clippy - name: Enable pnpm for setup-node cache @@ -503,7 +503,7 @@ jobs: with: persist-credentials: false - name: Install Rust - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + uses: ./.github/actions/setup-rust - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" From ba1e730c287fc372c528a39913ae03c3dd3bba2d Mon Sep 17 00:00:00 2001 From: Henry Park <16583448+henrypark133@users.noreply.github.com> Date: Sat, 22 Aug 2026 11:02:42 +0000 Subject: [PATCH 10/44] ci(platform-and-compat): install Rust via the setup-rust composite No toolchain behavior change: same default toolchain and the same wasm32-wasip2 target input, now with RUSTUP_TOOLCHAIN protection ahead of rust-toolchain.toml landing. Deletes two now-redundant CARGO_PROFILE_DEV_DEBUG/CARGO_PROFILE_TEST_DEBUG=0 pairs, no-ops since Cargo.toml's [profile.dev] debug = 0 landed. Also removes the wasm-wit-compat job's now-empty `env:` key left over once both its lines were deleted (a dangling null mapping is valid YAML but dead config; no functional change). Co-Authored-By: Claude Fable 5 --- .github/workflows/platform-and-compat.yml | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/.github/workflows/platform-and-compat.yml b/.github/workflows/platform-and-compat.yml index 21b173a3018..140b1b4efd8 100644 --- a/.github/workflows/platform-and-compat.yml +++ b/.github/workflows/platform-and-compat.yml @@ -161,8 +161,6 @@ jobs: --health-timeout 5s --health-retries 5 env: - CARGO_PROFILE_DEV_DEBUG: 0 - CARGO_PROFILE_TEST_DEBUG: 0 DATABASE_URL: postgres://postgres:postgres@localhost/ironclaw_test # Turn a missing/unreachable Postgres into a HARD failure so the full # cross-backend matrix (in-memory + libSQL + Postgres) cannot skip-pass. @@ -174,7 +172,7 @@ jobs: ref: ${{ inputs.ref || github.sha }} persist-credentials: false - name: Install Rust - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + uses: ./.github/actions/setup-rust - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: key: hooks-parity @@ -225,7 +223,7 @@ jobs: ref: ${{ inputs.ref || github.sha }} persist-credentials: false - name: Install Rust - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + uses: ./.github/actions/setup-rust - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: key: windows-${{ matrix.name }} @@ -246,9 +244,6 @@ jobs: (github.event_name == 'pull_request' && needs.changes.outputs.has_direct_wasm_abi_risk == 'true')) runs-on: ubuntu-latest timeout-minutes: 30 - env: - CARGO_PROFILE_DEV_DEBUG: 0 - CARGO_PROFILE_TEST_DEBUG: 0 steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 @@ -261,7 +256,7 @@ jobs: sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc df -h / - name: Install Rust - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + uses: ./.github/actions/setup-rust with: targets: wasm32-wasip2 - name: Enable pnpm for setup-node cache @@ -314,7 +309,7 @@ jobs: ref: ${{ inputs.ref || github.sha }} persist-credentials: false - name: Install Rust - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + uses: ./.github/actions/setup-rust - name: Enable pnpm for setup-node cache run: corepack enable pnpm - name: Install Node.js for WebUI bundle builds From 99d84e6af956bdc32422f5527c83d72d257a5dc3 Mon Sep 17 00:00:00 2001 From: Henry Park <16583448+henrypark133@users.noreply.github.com> Date: Sat, 22 Aug 2026 11:02:50 +0000 Subject: [PATCH 11/44] ci(reborn-e2e): install Rust and mold via the setup-rust composite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No behavior change: rust-reborn keeps mold (now via mold: true — same apt install, same verify commands, same canonical RUSTFLAGS prefix, moved into the composite); webui-v2-smoke keeps the same default toolchain. Both now get RUSTUP_TOOLCHAIN protection ahead of rust-toolchain.toml. Deletes the now-redundant literal mold RUSTFLAGS string and CARGO_PROFILE_*_DEBUG pair. Co-Authored-By: Claude Fable 5 --- .github/workflows/reborn-e2e.yml | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/.github/workflows/reborn-e2e.yml b/.github/workflows/reborn-e2e.yml index 321f8fc0e96..83249c668f6 100644 --- a/.github/workflows/reborn-e2e.yml +++ b/.github/workflows/reborn-e2e.yml @@ -131,10 +131,6 @@ jobs: if: needs.changes.outputs.has_e2e_scope == 'true' && github.event_name != 'pull_request' runs-on: ubuntu-latest timeout-minutes: 35 - env: - RUSTFLAGS: "-C linker=clang -C link-arg=--ld-path=/usr/bin/mold" - CARGO_PROFILE_DEV_DEBUG: 0 - CARGO_PROFILE_TEST_DEBUG: 0 strategy: fail-fast: false matrix: @@ -151,12 +147,9 @@ jobs: persist-credentials: false - name: Install Rust - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable - - - name: Install mold and clang - run: | - scripts/ci/install-ci-apt-packages.sh clang mold - test -x /usr/bin/mold + uses: ./.github/actions/setup-rust + with: + mold: true - name: Restore Rust cache uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 @@ -193,7 +186,7 @@ jobs: persist-credentials: false - name: Install Rust - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + uses: ./.github/actions/setup-rust - name: Enable pnpm for setup-node cache run: corepack enable pnpm From b98c2f06ac52e71a24df771cc7ca36e6527f953b Mon Sep 17 00:00:00 2001 From: Henry Park <16583448+henrypark133@users.noreply.github.com> Date: Sat, 22 Aug 2026 11:03:04 +0000 Subject: [PATCH 12/44] ci(coverage): install the nightly toolchain via the setup-rust composite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No behavior change: both jobs pass the same explicit toolchain: nightly-2025-11-01 / components: llvm-tools-preview / targets: wasm32-wasip2 as before, now through the composite, which pins RUSTUP_TOOLCHAIN to that exact spec — the same protection the prior design gave these two lanes via a hand-paired job env var, now automatic for every composite call. Deletes the now-redundant CARGO_PROFILE_*_DEBUG pair in the coverage job. check-reborn-branch-coverage-flags.py and reborn_coverage_lane_stack_headroom.rs both still pass (their pinned strings are untouched). Co-Authored-By: Claude Fable 5 --- .github/workflows/coverage.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 8bdf676c1fc..61c6e621cd3 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -51,8 +51,6 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 90 env: - CARGO_PROFILE_DEV_DEBUG: 0 - CARGO_PROFILE_TEST_DEBUG: 0 # LLVM 21.1.3 predates these now-stable APIs used by current dependencies. # Coverage-only compatibility; ordinary CI remains the MSRV authority. RUSTC_BOOTSTRAP: "1" @@ -113,7 +111,7 @@ jobs: docker system prune -af || true df -h - - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # branch coverage + - uses: ./.github/actions/setup-rust with: toolchain: nightly-2025-11-01 components: llvm-tools-preview @@ -222,7 +220,7 @@ jobs: docker system prune -af || true df -h - - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # branch coverage + - uses: ./.github/actions/setup-rust with: toolchain: nightly-2025-11-01 components: llvm-tools-preview From 050c0d245c85b3d285f35edea3d75c75bbd05b15 Mon Sep 17 00:00:00 2001 From: Henry Park <16583448+henrypark133@users.noreply.github.com> Date: Sat, 22 Aug 2026 11:03:14 +0000 Subject: [PATCH 13/44] ci(reborn-tests): install Rust and mold via the setup-rust composite No behavior change to toolchain resolution (same defaults/nightly pins as before, now with RUSTUP_TOOLCHAIN protection ahead of rust-toolchain.toml) or to the mold linker's effective RUSTFLAGS (the composite's mold: true prepends the canonical prefix onto each job's existing RUSTFLAGS, so the two nightly jobs' -Zcrate-attr suffix is preserved byte-for-byte). Absorbs 7 duplicated mold-install steps and 2 duplicated 'Verify mold linker' blocks into the composite. Two disclosed minor deltas in qa-recorded-fixtures: mold install now includes clang (already present via the runner image; matches the other 6 sites) and now runs after, not before, Install Rust. Deletes the workflow-level mold RUSTFLAGS/ CARGO_PROFILE_*_DEBUG trio, now no-ops. CARGO_INCREMENTAL lines and RUST_MIN_STACK lines are untouched. check-reborn-branch-coverage-flags.py and reborn_coverage_lane_stack_headroom.rs both still pass. Co-Authored-By: Claude Fable 5 --- .github/workflows/reborn-tests.yml | 103 ++++++----------------------- 1 file changed, 22 insertions(+), 81 deletions(-) diff --git a/.github/workflows/reborn-tests.yml b/.github/workflows/reborn-tests.yml index f13caf56b1a..a353964122d 100644 --- a/.github/workflows/reborn-tests.yml +++ b/.github/workflows/reborn-tests.yml @@ -74,9 +74,6 @@ env: # PR #5086 measured mold linking the heaviest Reborn build in parallel with # no OOM, so keep Cargo's default parallelism. If runner OOMs recur, restore # the previous one-job Cargo build serialization as the one-line fallback. - RUSTFLAGS: "-C linker=clang -C link-arg=--ld-path=/usr/bin/mold" - CARGO_PROFILE_DEV_DEBUG: 0 - CARGO_PROFILE_TEST_DEBUG: 0 # Cargo network resilience (retries, HTTP/2 multiplexing off, git-CLI fetch) # lives in the repo-wide .cargo/config.toml so it applies to every workflow. @@ -106,7 +103,7 @@ jobs: - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" - - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + - uses: ./.github/actions/setup-rust - id: plan name: Build affected-area test plan env: @@ -210,7 +207,7 @@ jobs: OLLAMA_BASE_URL: "" OPENAI_API_KEY: "" RUSTC_BOOTSTRAP: "1" - RUSTFLAGS: "-C linker=clang -C link-arg=--ld-path=/usr/bin/mold -Zcrate-attr=feature(array_windows,debug_closure_helpers,slice_as_array)" + RUSTFLAGS: "-Zcrate-attr=feature(array_windows,debug_closure_helpers,slice_as_array)" # The deep composed-runtime futures (turn -> capability -> skills script # mount, and the channel admission chain) overflow the 2 MiB default # test-thread stack on this lane's Linux codegen — first observed as a @@ -244,10 +241,11 @@ jobs: df -h / - name: Install Rust - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # branch coverage + uses: ./.github/actions/setup-rust with: toolchain: nightly-2025-11-01 components: llvm-tools-preview + mold: true - name: Resolve bucket settings id: bucket-settings @@ -325,11 +323,6 @@ jobs: cd "${webui_dir}/frontend" pnpm install --frozen-lockfile - - name: Install mold and clang - run: | - scripts/ci/install-ci-apt-packages.sh clang mold - test -x /usr/bin/mold - - name: Restore Rust cache uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: @@ -481,28 +474,9 @@ jobs: df -h / - name: Install Rust - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable - - - name: Install mold and clang - run: | - scripts/ci/install-ci-apt-packages.sh clang mold - test -x /usr/bin/mold - - - name: Verify mold linker is active - run: | - clang_output="$( - printf 'int main(void) { return 0; }\n' \ - | clang --ld-path=/usr/bin/mold -Wl,--version -x c - -o /tmp/clang-mold-check 2>&1 - )" - printf '%s\n' "$clang_output" - grep -qi mold <<< "$clang_output" - - printf 'fn main() { println!("mold-link-ok"); }\n' > /tmp/mold-link-check.rs - rustc /tmp/mold-link-check.rs \ - -C linker=clang \ - -C link-arg=--ld-path=/usr/bin/mold \ - -o /tmp/mold-link-check - test "$(/tmp/mold-link-check)" = "mold-link-ok" + uses: ./.github/actions/setup-rust + with: + mold: true - name: Restore Rust cache uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 @@ -577,28 +551,9 @@ jobs: df -h / - name: Install Rust - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable - - - name: Install mold and clang - run: | - scripts/ci/install-ci-apt-packages.sh clang mold - test -x /usr/bin/mold - - - name: Verify mold linker is active - run: | - clang_output="$( - printf 'int main(void) { return 0; }\n' \ - | clang --ld-path=/usr/bin/mold -Wl,--version -x c - -o /tmp/clang-mold-check 2>&1 - )" - printf '%s\n' "$clang_output" - grep -qi mold <<< "$clang_output" - - printf 'fn main() { println!("mold-link-ok"); }\n' > /tmp/mold-link-check.rs - rustc /tmp/mold-link-check.rs \ - -C linker=clang \ - -C link-arg=--ld-path=/usr/bin/mold \ - -o /tmp/mold-link-check - test "$(/tmp/mold-link-check)" = "mold-link-ok" + uses: ./.github/actions/setup-rust + with: + mold: true - name: Restore Rust cache uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 @@ -647,12 +602,9 @@ jobs: persist-credentials: false - name: Install Rust - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable - - - name: Install mold and clang - run: | - scripts/ci/install-ci-apt-packages.sh clang mold - test -x /usr/bin/mold + uses: ./.github/actions/setup-rust + with: + mold: true - name: Restore Rust cache uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 @@ -699,7 +651,7 @@ jobs: OPENAI_API_KEY: "" CARGO_INCREMENTAL: "0" RUSTC_BOOTSTRAP: "1" - RUSTFLAGS: "-C linker=clang -C link-arg=--ld-path=/usr/bin/mold -Zcrate-attr=feature(array_windows,debug_closure_helpers,slice_as_array)" + RUSTFLAGS: "-Zcrate-attr=feature(array_windows,debug_closure_helpers,slice_as_array)" # llvm-cov instrumentation inflates stack frames; the deep channel # admission futures (webhook -> pairing gate -> workflow) overflow the # default 2 MiB test-thread stack under instrumentation only. The @@ -733,10 +685,11 @@ jobs: run: docker pull postgres:16-alpine - name: Install Rust - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # branch coverage + uses: ./.github/actions/setup-rust with: toolchain: nightly-2025-11-01 components: llvm-tools-preview + mold: true - name: Enable pnpm for setup-node cache run: corepack enable pnpm @@ -770,11 +723,6 @@ jobs: cd "${webui_dir}/frontend" pnpm install --frozen-lockfile - - name: Install mold and clang - run: | - scripts/ci/install-ci-apt-packages.sh clang mold - test -x /usr/bin/mold - - name: Restore Rust cache uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: @@ -1007,13 +955,9 @@ jobs: - name: Install Rust if: github.event_name == 'merge_group' && steps.scope.outputs.should_run == 'true' - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable - - - name: Install mold and clang - if: github.event_name == 'merge_group' && steps.scope.outputs.should_run == 'true' - run: | - scripts/ci/install-ci-apt-packages.sh clang mold - test -x /usr/bin/mold + uses: ./.github/actions/setup-rust + with: + mold: true - name: Install cargo-mutants if: github.event_name == 'merge_group' && steps.scope.outputs.should_run == 'true' @@ -1060,13 +1004,10 @@ jobs: - name: Configure isolated Corepack path run: echo "COREPACK_HOME=${RUNNER_TEMP}/ironclaw-corepack" >> "$GITHUB_ENV" - - name: Install mold - run: | - scripts/ci/install-ci-apt-packages.sh mold - test -x /usr/bin/mold - - name: Install Rust - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + uses: ./.github/actions/setup-rust + with: + mold: true - name: Enable pnpm for setup-node cache run: corepack enable pnpm From 1af2b2b0b3eb8a03f06997da209d6179307a64f7 Mon Sep 17 00:00:00 2001 From: Henry Park <16583448+henrypark133@users.noreply.github.com> Date: Sat, 22 Aug 2026 11:03:22 +0000 Subject: [PATCH 14/44] ci(live-canary): install Rust via the setup-rust composite No toolchain behavior change: same default toolchain and the same 7 wasm32-wasip2 target inputs, now with RUSTUP_TOOLCHAIN protection ahead of rust-toolchain.toml landing. Deletes the one now-redundant CARGO_PROFILE_DEV_DEBUG='0' step env, a no-op since Cargo.toml's [profile.dev] debug = 0 landed. CARGO_INCREMENTAL is untouched. Co-Authored-By: Claude Fable 5 --- .github/workflows/live-canary.yml | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/.github/workflows/live-canary.yml b/.github/workflows/live-canary.yml index d75fb0ac0eb..a2571076814 100644 --- a/.github/workflows/live-canary.yml +++ b/.github/workflows/live-canary.yml @@ -103,7 +103,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 with: persist-credentials: false - - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + - uses: ./.github/actions/setup-rust - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" @@ -134,7 +134,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 with: persist-credentials: false - - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + - uses: ./.github/actions/setup-rust - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" @@ -165,7 +165,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 with: persist-credentials: false - - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + - uses: ./.github/actions/setup-rust - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" @@ -209,7 +209,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 with: persist-credentials: false - - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + - uses: ./.github/actions/setup-rust - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" @@ -304,7 +304,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 with: persist-credentials: false - - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + - uses: ./.github/actions/setup-rust - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" @@ -400,7 +400,7 @@ jobs: # `--channels` skips the WASM tools build (~5 tools that # workflow-canary doesn't exercise) to keep the lane under # ~6 min on a fresh runner. - - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + - uses: ./.github/actions/setup-rust with: targets: wasm32-wasip2 - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 @@ -448,7 +448,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 with: persist-credentials: false - - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + - uses: ./.github/actions/setup-rust with: targets: wasm32-wasip2 - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 @@ -502,7 +502,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 with: persist-credentials: false - - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + - uses: ./.github/actions/setup-rust with: targets: wasm32-wasip2 - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 @@ -780,7 +780,7 @@ jobs: echo "Reborn E2E did not finish within five minutes; using the canary fallback build." - - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + - uses: ./.github/actions/setup-rust if: steps.lookup.outputs.found != '1' - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 @@ -805,7 +805,6 @@ jobs: if: steps.lookup.outputs.found != '1' env: CARGO_INCREMENTAL: "0" - CARGO_PROFILE_DEV_DEBUG: "0" run: >- cargo build -p ironclaw @@ -1238,7 +1237,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 with: persist-credentials: false - - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + - uses: ./.github/actions/setup-rust with: targets: wasm32-wasip2 - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 @@ -1308,7 +1307,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 with: persist-credentials: false - - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + - uses: ./.github/actions/setup-rust with: targets: wasm32-wasip2 - name: Install cargo-component @@ -1351,7 +1350,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 with: persist-credentials: false - - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + - uses: ./.github/actions/setup-rust with: targets: wasm32-wasip2 - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 @@ -1402,7 +1401,7 @@ jobs: fetch-depth: 0 fetch-tags: true persist-credentials: false - - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + - uses: ./.github/actions/setup-rust with: targets: wasm32-wasip2 - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 From 020afb32f108d9f7927cb20a056881b0a6d2d457 Mon Sep 17 00:00:00 2001 From: Henry Park <16583448+henrypark133@users.noreply.github.com> Date: Sat, 22 Aug 2026 11:03:57 +0000 Subject: [PATCH 15/44] chore(qa): drop the now-redundant CARGO_PROFILE_DEV_DEBUG setdefault MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cargo.toml's [profile.dev] debug = 0 is now the workspace default, so this QA harness's env setdefault restates it for no reason. CARGO_INCREMENTAL is untouched — incremental compilation stays a per-caller decision, not a Cargo.toml profile setting (same reasoning applies to the sibling decl in scripts/reborn_qa_matrix/run_hermetic_qa.py:101, left alone). Co-Authored-By: Claude Fable 5 --- scripts/reborn_webui_v2_live_qa/run_live_qa.py | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/reborn_webui_v2_live_qa/run_live_qa.py b/scripts/reborn_webui_v2_live_qa/run_live_qa.py index 76fd3d1038e..e9d10defd38 100644 --- a/scripts/reborn_webui_v2_live_qa/run_live_qa.py +++ b/scripts/reborn_webui_v2_live_qa/run_live_qa.py @@ -397,7 +397,6 @@ def _reborn_binary() -> Path: def build_reborn_binary() -> Path: features = os.environ.get("REBORN_WEBUI_V2_LIVE_QA_FEATURES", "") build_env = os.environ.copy() - build_env.setdefault("CARGO_PROFILE_DEV_DEBUG", "0") build_env.setdefault("CARGO_INCREMENTAL", "0") command = ["cargo", "build", "-p", "ironclaw"] if features: From b32e1fdb6195ddaf474fd60b73082ad2389f5b29 Mon Sep 17 00:00:00 2001 From: Henry Park <16583448+henrypark133@users.noreply.github.com> Date: Sat, 22 Aug 2026 11:06:14 +0000 Subject: [PATCH 16/44] build: pin the local Rust toolchain via rust-toolchain.toml Pins exactly what CI's composite-installed toolchain already resolves today (1.98.0 + clippy/rustfmt), so nothing rebuilds and rust-cache keys hold. Safe now that every CI job installs Rust through .github/actions/setup-rust (landed across the prior 13 commits), which exports RUSTUP_TOOLCHAIN and so cannot be overridden by this file landing. Local clippy/rustfmt now match CI's. Co-Authored-By: Claude Fable 5 --- rust-toolchain.toml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 rust-toolchain.toml diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 00000000000..cc9091acc78 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,21 @@ +# Single source of truth for the Rust toolchain, local and CI. +# - Every in-repo cargo/rustc run resolves this file (rustup precedence: +# RUSTUP_TOOLCHAIN env > directory override > this file > rustup default), +# so local clippy/rustfmt finally match CI's. +# - CI workflows install Rust exclusively through +# .github/actions/setup-rust, whose `toolchain` input defaults to the same +# version as `channel` below (kept in sync by +# scripts/ci/ws12_workflow_contracts.py); a lane that intentionally runs a +# different toolchain (the two nightly-2025-11-01 coverage lanes) passes +# an explicit `toolchain:` input, and the composite exports +# RUSTUP_TOOLCHAIN naming exactly what it installed either way, so this +# file can never silently override a job's chosen toolchain. +# - Bumping: change `channel` here AND the `toolchain` input default in +# .github/actions/setup-rust/action.yml in the same PR (the ws12 gate +# fails on any mismatch). Expect Swatinem/rust-cache keys to rotate with +# the compiler version. +# - Docker builds are unaffected: the Dockerfile COPYs an explicit path list +# that excludes this file and stays on its base-image toolchain. +[toolchain] +channel = "1.98.0" +components = ["clippy", "rustfmt"] From daa5690b4b0805fae8cd5182a48ea53bbb94e54f Mon Sep 17 00:00:00 2001 From: Henry Park <16583448+henrypark133@users.noreply.github.com> Date: Sat, 22 Aug 2026 11:07:03 +0000 Subject: [PATCH 17/44] docs(agents): document the toolchain pin and bump process AGENTS.md is scanned by check-guidance.py; .github/workflows/README.md is not (zero hits under ROOT_GUIDANCE/CRATE_GUIDANCE_BASENAMES), so this is the enforced location for the bump instructions Task 19's ws12 guard assumes contributors can find. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index b8589201a71..1d7b82696c0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,20 @@ The workspace-root `integration` feature is empty with zero consumers — a bare **Cargo features are a last resort.** A feature is a second build of the workspace, compiled and tested forever. Add one only for a heavy optional dependency, a build shape that ships with it OFF, a CI lane selector, a dev-only seam (always named `test-support`), or a privilege boundary — and say which in the manifest comment. Deployment shape belongs in `DeploymentConfig` and `[storage]`, not `#[cfg]`. Full bar: `.claude/rules/cargo-features.md`. +**Toolchain pin.** `rust-toolchain.toml` at the repo root pins the stable +toolchain (channel + clippy/rustfmt) for local and CI builds. Every CI job +installs Rust through `.github/actions/setup-rust`, whose `toolchain` input +defaults to the same version; a lane that intentionally runs a different +toolchain (the two `nightly-2025-11-01` coverage lanes) passes an explicit +`toolchain:` input to that same composite, which exports `RUSTUP_TOOLCHAIN` +naming exactly what it installed — so `rust-toolchain.toml` can never +silently override a job's chosen toolchain. `scripts/ci/ws12_workflow_contracts.py` +fails the build if any workflow calls `dtolnay/rust-toolchain` directly +instead of through the composite, and if the composite's default toolchain +ever drifts from this file's `channel`. To bump Rust: edit both +`rust-toolchain.toml`'s `channel` and `.github/actions/setup-rust/action.yml`'s +`toolchain` input default, in the same PR. + ## Discover code before changing it For where-is, who-calls, data-flow, and impact questions, probe the codebase knowledge graph before text search: run `bash scripts/codebase-graph.sh status` once; if fresh and graph tools are connected, use them; otherwise fall back to `crates/AGENTS.md`, crate-local guidance, and targeted `rg`. Verify graph claims against live code before acting. Use `rg` directly for configuration, prose, and fixtures. `openwiki/` is generated prose — read-only, never hand-edit. From d5d37e6815c1a42e0cacd6c84ace6be57aa7e832 Mon Sep 17 00:00:00 2001 From: Henry Park <16583448+henrypark133@users.noreply.github.com> Date: Sat, 22 Aug 2026 11:10:32 +0000 Subject: [PATCH 18/44] ci(gate): forbid direct dtolnay/rust-toolchain use; enforce the toolchain-pin sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New contracts: no workflow may call dtolnay/rust-toolchain directly (must go through .github/actions/setup-rust) or write out the canonical mold RUSTFLAGS prefix by hand (must pass mold: true); rust-toolchain.toml's channel and the composite's default toolchain input must name the same version. Both are simple negative/equality checks, not per-site window scans — collapsed from the abandoned per-input design specifically because every job now routes through one component. Sabotage tests included. Co-Authored-By: Claude Fable 5 --- scripts/ci/test_ws12_workflow_contracts.py | 66 ++++++++++++++++++++++ scripts/ci/ws12_workflow_contracts.py | 55 ++++++++++++++++++ 2 files changed, 121 insertions(+) diff --git a/scripts/ci/test_ws12_workflow_contracts.py b/scripts/ci/test_ws12_workflow_contracts.py index 9e851f72569..653de8e4a72 100755 --- a/scripts/ci/test_ws12_workflow_contracts.py +++ b/scripts/ci/test_ws12_workflow_contracts.py @@ -36,8 +36,10 @@ validate_e2e_scope_filters, validate_libsql_scripted_memory_job, validate_postgres_scripted_parity, + validate_no_direct_dtolnay_usage, validate_production_lint_targets, validate_setup_rust_action, + validate_toolchain_pin_sync, validate_windows_webui_install_shell, validate_webui_frontend_sites, validate_workflow_texts, @@ -100,6 +102,70 @@ def test_ok_passes(self): self.assertEqual([], validate_setup_rust_action(self.OK)) +class DirectDtolnayUsageForbiddenTests(unittest.TestCase): + """Every workflow must install Rust through the setup-rust composite.""" + + def test_direct_dtolnay_call_fails(self): + workflows = { + ".github/workflows/x.yml": ( + " - uses: dtolnay/rust-toolchain@abc123 # stable\n" + ) + } + errors = validate_no_direct_dtolnay_usage(workflows) + self.assertTrue(any("x.yml" in e for e in errors)) + + def test_composite_call_passes(self): + workflows = { + ".github/workflows/x.yml": ( + " - uses: ./.github/actions/setup-rust\n" + ) + } + self.assertEqual([], validate_no_direct_dtolnay_usage(workflows)) + + def test_bare_mold_rustflags_string_outside_composite_fails(self): + workflows = { + ".github/workflows/x.yml": ( + 'RUSTFLAGS: "-C linker=clang -C link-arg=--ld-path=/usr/bin/mold"\n' + ) + } + errors = validate_no_direct_dtolnay_usage(workflows) + self.assertTrue(any("mold" in e.lower() for e in errors)) + + +class ToolchainPinSyncTests(unittest.TestCase): + """rust-toolchain.toml and the composite's default must name one version.""" + + def _root(self, toolchain_text: str | None, action_text: str | None) -> Path: + tmp = Path(tempfile.mkdtemp()) + if toolchain_text is not None: + (tmp / "rust-toolchain.toml").write_text(toolchain_text) + action_dir = tmp / ".github" / "actions" / "setup-rust" + action_dir.mkdir(parents=True) + if action_text is not None: + (action_dir / "action.yml").write_text(action_text) + return tmp + + ACTION_OK = ( + "inputs:\n" + " toolchain:\n" + " default: \"1.98.0\"\n" + ) + FILE_OK = '[toolchain]\nchannel = "1.98.0"\ncomponents = ["clippy", "rustfmt"]\n' + + def test_missing_file_fails(self): + errors = validate_toolchain_pin_sync(self._root(None, self.ACTION_OK)) + self.assertTrue(any("rust-toolchain.toml" in e for e in errors)) + + def test_mismatched_default_fails(self): + action = self.ACTION_OK.replace("1.98.0", "1.97.0") + errors = validate_toolchain_pin_sync(self._root(self.FILE_OK, action)) + self.assertTrue(any("1.97.0" in e for e in errors)) + + def test_matching_default_passes(self): + errors = validate_toolchain_pin_sync(self._root(self.FILE_OK, self.ACTION_OK)) + self.assertEqual([], errors) + + class SccacheSetupActionContractTests(unittest.TestCase): """The optional compiler cache must never gate the tests it accelerates.""" diff --git a/scripts/ci/ws12_workflow_contracts.py b/scripts/ci/ws12_workflow_contracts.py index 5a85f66ec04..ac5639ad3e4 100755 --- a/scripts/ci/ws12_workflow_contracts.py +++ b/scripts/ci/ws12_workflow_contracts.py @@ -1127,6 +1127,59 @@ def validate_setup_rust_action(text: str | None) -> list[str]: return errors +DTOLNAY_ACTION = "dtolnay/rust-toolchain@" + + +def validate_no_direct_dtolnay_usage(workflows: dict[str, str]) -> list[str]: + """Every workflow must install Rust through .github/actions/setup-rust. + + A negative substring check, not a per-site window scan: this is what + T1's earlier per-input-drift design collapsed to once every job routes + through one composite. Also forbids re-writing out the canonical mold + RUSTFLAGS prefix by hand anywhere a workflow's own env block might set + it — the composite is the only place that string may appear. + """ + errors: list[str] = [] + for path, text in workflows.items(): + if DTOLNAY_ACTION in text: + errors.append( + f"{path}: calls {DTOLNAY_ACTION!r} directly — install Rust " + "through .github/actions/setup-rust instead" + ) + if MOLD_RUSTFLAGS in text: + errors.append( + f"{path}: writes out the canonical mold RUSTFLAGS prefix " + f"'{MOLD_RUSTFLAGS}' directly — pass mold: true to " + ".github/actions/setup-rust instead, which prepends it onto " + "this job's existing RUSTFLAGS" + ) + return errors + + +def validate_toolchain_pin_sync(root: Path = ROOT) -> list[str]: + """rust-toolchain.toml and the composite's default must name one version.""" + try: + file_text = (root / "rust-toolchain.toml").read_text(encoding="utf-8") + except OSError: + return ["rust-toolchain.toml: missing (single source of truth for the CI toolchain)"] + channel_match = re.search(r'^channel = "(\d+\.\d+\.\d+)"$', file_text, re.MULTILINE) + if channel_match is None: + return ['rust-toolchain.toml: channel must be an exact stable version ("X.Y.Z")'] + channel = channel_match.group(1) + try: + action_text = (root / SETUP_RUST_ACTION).read_text(encoding="utf-8") + except OSError: + return [f"{SETUP_RUST_ACTION}: missing"] + default_match = re.search(r'default:\s*"([^"]+)"', action_text) + if default_match is None or default_match.group(1) != channel: + found = default_match.group(1) if default_match else "" + return [ + f"{SETUP_RUST_ACTION}: toolchain input default {found!r} != " + f"rust-toolchain.toml channel {channel!r}" + ] + return [] + + def job_body(text: str, job_name: str) -> str | None: """Return one workflow job's body, bounded by the next job heading.""" for heading in JOB_HEADING.finditer(text): @@ -1846,6 +1899,8 @@ def validate_workflow_texts( except OSError: setup_rust_action = None errors.extend(validate_setup_rust_action(setup_rust_action)) + errors.extend(validate_no_direct_dtolnay_usage(workflows)) + errors.extend(validate_toolchain_pin_sync(root)) return errors From 5d3fe7c29b1663086ef56c4a861336c7e8da6147 Mon Sep 17 00:00:00 2001 From: Henry Park <16583448+henrypark133@users.noreply.github.com> Date: Sat, 22 Aug 2026 12:34:42 +0000 Subject: [PATCH 19/44] fix(ci): let the composite own RUSTFLAGS so job env cannot shadow mold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A job-level `env: RUSTFLAGS:` is re-applied to every step of that job on top of whatever earlier steps wrote to $GITHUB_ENV, so it shadows the setup-rust composite's export for the rest of the job — silently dropping the mold linker flags. That hit the two heaviest lanes: crate-tests (the required PR lane and the measured critical path) and reborn-integration-coverage, both of which declared RUSTFLAGS for their nightly `-Zcrate-attr` features. The pre-composite code manually pasted the mold prefix into exactly those two job envs despite the workflow-level env already carrying it, which is the historical symptom of the same shadowing. The composite now takes an `extra_rustflags` input and composes the whole value (mold flags, then the job's flags, then anything already exported), so no job-level declaration is left to shadow it. Both reborn-tests jobs and both coverage.yml jobs pass their crate attributes through the input instead; zero job-level RUSTFLAGS keys remain in the tree. Install Rust precedes every cargo step in all four jobs, so the export covers them. `validate_no_job_env_rustflags_with_setup_rust` pins the invariant: a job installing Rust through the composite may not declare its own RUSTFLAGS. It found the two coverage.yml jobs that this fix would otherwise have missed. Three sabotage tests plus a live-tree assertion cover it. Note for review: GitHub's documentation does not state the $GITHUB_ENV-vs-job-`env` precedence explicitly. This change is correct either way — with no job-level key there is nothing to shadow under any precedence rule — so the fix does not depend on resolving that question. Co-Authored-By: Claude Fable 5 --- .github/actions/setup-rust/action.yml | 33 +++++++++++- .github/workflows/coverage.yml | 10 +++- .github/workflows/reborn-tests.yml | 12 ++++- scripts/ci/test_ws12_workflow_contracts.py | 60 ++++++++++++++++++++++ scripts/ci/ws12_workflow_contracts.py | 46 +++++++++++++++++ 5 files changed, 155 insertions(+), 6 deletions(-) diff --git a/.github/actions/setup-rust/action.yml b/.github/actions/setup-rust/action.yml index 3adbb178151..045f2dd9fa6 100644 --- a/.github/actions/setup-rust/action.yml +++ b/.github/actions/setup-rust/action.yml @@ -44,6 +44,18 @@ inputs: handful that do pass `mold: true` explicitly. required: false default: "false" + extra_rustflags: + description: >- + Extra RUSTFLAGS this job needs (e.g. the nightly `-Zcrate-attr=...` + the coverage and crate-bucket lanes compile with). Pass them HERE, not + as a job-level `env: RUSTFLAGS:` — a job-level env key is re-applied + to every step and therefore shadows what this action writes to + $GITHUB_ENV, silently dropping the mold linker flags. Routing them + through this input keeps one owner for the final value; + scripts/ci/ws12_workflow_contracts.py fails any job that pairs + setup-rust with its own RUSTFLAGS env key. + required: false + default: "" runs: using: composite @@ -83,9 +95,26 @@ runs: test "$(/tmp/mold-link-check)" = "mold-link-ok" - name: Export mold RUSTFLAGS - if: ${{ inputs.mold == 'true' && runner.os == 'Linux' }} + if: >- + ${{ (inputs.mold == 'true' && runner.os == 'Linux') + || inputs.extra_rustflags != '' }} shell: bash env: MOLD_RUSTFLAGS: "-C linker=clang -C link-arg=--ld-path=/usr/bin/mold" + USE_MOLD: ${{ inputs.mold == 'true' && runner.os == 'Linux' }} + EXTRA_RUSTFLAGS: ${{ inputs.extra_rustflags }} run: | - echo "RUSTFLAGS=${MOLD_RUSTFLAGS} ${RUSTFLAGS:-}" >> "$GITHUB_ENV" + # Compose the whole value here so exactly one place owns it. Order: + # mold linker flags, then the job's extra flags, then anything an + # earlier step already exported. A job-level `env: RUSTFLAGS:` would + # shadow this write for every later step (job env is re-applied per + # step, on top of $GITHUB_ENV), which is why jobs pass their flags + # through `extra_rustflags` instead and ws12 enforces it. + flags="" + if [[ "${USE_MOLD}" == "true" ]]; then + flags="${MOLD_RUSTFLAGS}" + fi + if [[ -n "${EXTRA_RUSTFLAGS}" ]]; then + flags="${flags:+${flags} }${EXTRA_RUSTFLAGS}" + fi + echo "RUSTFLAGS=${flags}${RUSTFLAGS:+ ${RUSTFLAGS}}" >> "$GITHUB_ENV" diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 61c6e621cd3..71c77ce9297 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -54,7 +54,6 @@ jobs: # LLVM 21.1.3 predates these now-stable APIs used by current dependencies. # Coverage-only compatibility; ordinary CI remains the MSRV authority. RUSTC_BOOTSTRAP: "1" - RUSTFLAGS: "-Zcrate-attr=feature(array_windows,debug_closure_helpers,slice_as_array)" # `--workspace` below runs BOTH root-package test tiers in one job: the # Reborn integration suites (tests/integration/*) and the root QA suites # (tests/reborn_qa_*). Both overrun libtest's 2 MiB default test-thread @@ -114,6 +113,10 @@ jobs: - uses: ./.github/actions/setup-rust with: toolchain: nightly-2025-11-01 + # Nightly crate attributes for the instrumented build. They belong + # here rather than in a job-level `env: RUSTFLAGS:`, which would + # split ownership of the variable with the composite. + extra_rustflags: "-Zcrate-attr=feature(array_windows,debug_closure_helpers,slice_as_array)" components: llvm-tools-preview targets: wasm32-wasip2 @@ -204,7 +207,6 @@ jobs: env: # Keep the shipping binary on the same crash-safe coverage toolchain. RUSTC_BOOTSTRAP: "1" - RUSTFLAGS: "-Zcrate-attr=feature(array_windows,debug_closure_helpers,slice_as_array)" permissions: id-token: write contents: read @@ -223,6 +225,10 @@ jobs: - uses: ./.github/actions/setup-rust with: toolchain: nightly-2025-11-01 + # Nightly crate attributes for the instrumented build. They belong + # here rather than in a job-level `env: RUSTFLAGS:`, which would + # split ownership of the variable with the composite. + extra_rustflags: "-Zcrate-attr=feature(array_windows,debug_closure_helpers,slice_as_array)" components: llvm-tools-preview targets: wasm32-wasip2 diff --git a/.github/workflows/reborn-tests.yml b/.github/workflows/reborn-tests.yml index a353964122d..5290cd9069f 100644 --- a/.github/workflows/reborn-tests.yml +++ b/.github/workflows/reborn-tests.yml @@ -207,7 +207,6 @@ jobs: OLLAMA_BASE_URL: "" OPENAI_API_KEY: "" RUSTC_BOOTSTRAP: "1" - RUSTFLAGS: "-Zcrate-attr=feature(array_windows,debug_closure_helpers,slice_as_array)" # The deep composed-runtime futures (turn -> capability -> skills script # mount, and the channel admission chain) overflow the 2 MiB default # test-thread stack on this lane's Linux codegen — first observed as a @@ -246,6 +245,11 @@ jobs: toolchain: nightly-2025-11-01 components: llvm-tools-preview mold: true + # Nightly crate attributes this lane compiles with. They live here, + # not in a job-level `env: RUSTFLAGS:`, because a job env key is + # re-applied to every step and would shadow the composite's + # $GITHUB_ENV write — silently dropping the mold linker flags. + extra_rustflags: "-Zcrate-attr=feature(array_windows,debug_closure_helpers,slice_as_array)" - name: Resolve bucket settings id: bucket-settings @@ -651,7 +655,6 @@ jobs: OPENAI_API_KEY: "" CARGO_INCREMENTAL: "0" RUSTC_BOOTSTRAP: "1" - RUSTFLAGS: "-Zcrate-attr=feature(array_windows,debug_closure_helpers,slice_as_array)" # llvm-cov instrumentation inflates stack frames; the deep channel # admission futures (webhook -> pairing gate -> workflow) overflow the # default 2 MiB test-thread stack under instrumentation only. The @@ -690,6 +693,11 @@ jobs: toolchain: nightly-2025-11-01 components: llvm-tools-preview mold: true + # Nightly crate attributes this lane compiles with. They live here, + # not in a job-level `env: RUSTFLAGS:`, because a job env key is + # re-applied to every step and would shadow the composite's + # $GITHUB_ENV write — silently dropping the mold linker flags. + extra_rustflags: "-Zcrate-attr=feature(array_windows,debug_closure_helpers,slice_as_array)" - name: Enable pnpm for setup-node cache run: corepack enable pnpm diff --git a/scripts/ci/test_ws12_workflow_contracts.py b/scripts/ci/test_ws12_workflow_contracts.py index 653de8e4a72..6e3a885600c 100755 --- a/scripts/ci/test_ws12_workflow_contracts.py +++ b/scripts/ci/test_ws12_workflow_contracts.py @@ -37,6 +37,7 @@ validate_libsql_scripted_memory_job, validate_postgres_scripted_parity, validate_no_direct_dtolnay_usage, + validate_no_job_env_rustflags_with_setup_rust, validate_production_lint_targets, validate_setup_rust_action, validate_toolchain_pin_sync, @@ -132,6 +133,65 @@ def test_bare_mold_rustflags_string_outside_composite_fails(self): self.assertTrue(any("mold" in e.lower() for e in errors)) +class JobEnvRustflagsShadowingTests(unittest.TestCase): + """A setup-rust job may not declare its own RUSTFLAGS env key. + + Job env is re-applied to every step on top of $GITHUB_ENV, so such a key + silently shadows the composite's mold export — slower builds, never a + red check. The flags belong in the composite's extra_rustflags input. + """ + + SETUP_RUST_JOB = ( + " crate-tests:\n" + " env:\n" + ' RUSTC_BOOTSTRAP: "1"\n' + " steps:\n" + " - uses: ./.github/actions/setup-rust\n" + " with:\n" + " mold: true\n" + ) + + def test_job_level_rustflags_alongside_setup_rust_fails(self): + shadowing = self.SETUP_RUST_JOB.replace( + ' RUSTC_BOOTSTRAP: "1"\n', + ' RUSTC_BOOTSTRAP: "1"\n RUSTFLAGS: "-Zcrate-attr=x"\n', + ) + errors = validate_no_job_env_rustflags_with_setup_rust( + {".github/workflows/x.yml": shadowing} + ) + self.assertTrue(any("crate-tests" in e for e in errors), errors) + + def test_extra_rustflags_input_passes(self): + passing = self.SETUP_RUST_JOB + ' extra_rustflags: "-Zcrate-attr=x"\n' + self.assertEqual( + [], + validate_no_job_env_rustflags_with_setup_rust( + {".github/workflows/x.yml": passing} + ), + ) + + def test_rustflags_in_a_job_that_does_not_use_setup_rust_is_allowed(self): + other = ( + " docs:\n" + " env:\n" + ' RUSTFLAGS: "-Zcrate-attr=x"\n' + " steps:\n" + " - run: echo hi\n" + ) + self.assertEqual( + [], + validate_no_job_env_rustflags_with_setup_rust( + {".github/workflows/x.yml": other} + ), + ) + + def test_live_workflows_are_clean(self): + workflows = ws12_workflow_contracts.load_workflows(ROOT) + self.assertEqual( + [], validate_no_job_env_rustflags_with_setup_rust(workflows) + ) + + class ToolchainPinSyncTests(unittest.TestCase): """rust-toolchain.toml and the composite's default must name one version.""" diff --git a/scripts/ci/ws12_workflow_contracts.py b/scripts/ci/ws12_workflow_contracts.py index ac5639ad3e4..9022a374265 100755 --- a/scripts/ci/ws12_workflow_contracts.py +++ b/scripts/ci/ws12_workflow_contracts.py @@ -1156,6 +1156,51 @@ def validate_no_direct_dtolnay_usage(workflows: dict[str, str]) -> list[str]: return errors +SETUP_RUST_USES = "uses: ./.github/actions/setup-rust" +JOB_ENV_RUSTFLAGS = re.compile(r"^ {6}RUSTFLAGS:", re.MULTILINE) + + +def validate_no_job_env_rustflags_with_setup_rust( + workflows: dict[str, str], +) -> list[str]: + """A job installing Rust via the composite must not set its own RUSTFLAGS. + + GitHub re-applies a job-level `env:` mapping to every step of that job, + on top of whatever earlier steps wrote to $GITHUB_ENV. So a job-level + `RUSTFLAGS:` shadows the composite's export for the rest of the job and + silently drops the mold linker flags — a slower build, never a red + check, which is exactly the drift class this action exists to remove. + Jobs pass their extra flags through the composite's `extra_rustflags` + input instead, so one place composes the final value. + """ + + errors: list[str] = [] + for path, text in workflows.items(): + if SETUP_RUST_USES not in text: + continue + headings = list(JOB_HEADING.finditer(text)) + for index, heading in enumerate(headings): + start = heading.start() + end = ( + headings[index + 1].start() + if index + 1 < len(headings) + else len(text) + ) + block = text[start:end] + if SETUP_RUST_USES not in block: + continue + if JOB_ENV_RUSTFLAGS.search(block): + errors.append( + f"{path}: job {heading.group('name')!r} sets a job-level " + "RUSTFLAGS env key while installing Rust through " + ".github/actions/setup-rust — job env shadows the " + "composite's $GITHUB_ENV write and drops the mold linker " + "flags; pass them as the composite's extra_rustflags " + "input instead" + ) + return errors + + def validate_toolchain_pin_sync(root: Path = ROOT) -> list[str]: """rust-toolchain.toml and the composite's default must name one version.""" try: @@ -1900,6 +1945,7 @@ def validate_workflow_texts( setup_rust_action = None errors.extend(validate_setup_rust_action(setup_rust_action)) errors.extend(validate_no_direct_dtolnay_usage(workflows)) + errors.extend(validate_no_job_env_rustflags_with_setup_rust(workflows)) errors.extend(validate_toolchain_pin_sync(root)) return errors From 29b073a4681659224de83ee428a93f6d86f7ecbb Mon Sep 17 00:00:00 2001 From: Henry Park <16583448+henrypark133@users.noreply.github.com> Date: Sat, 22 Aug 2026 13:01:48 +0000 Subject: [PATCH 20/44] fix(ci): map .github/actions/setup-rust/ into the Reborn PR test planner Detect Reborn test scope failed on this PR's own head commit: the planner's fail-closed arm for .github/actions/** had no rule for the new setup-rust composite, so it raised "unmapped test or CI path" and red the whole Tests (Reborn) roll-up. Every Tests (Reborn) job now installs Rust through this composite, exactly like the existing setup-sccache-dist entry in SHARED_REBORN_ACTION_PREFIXES, so it belongs in the same fail-safe-to-full bucket: no narrow lane can exercise a change to it, and a change here means run the exhaustive plan. Generalized the reason string and its test assertion from "shared sccache action changed" to "shared reborn action changed" since the bucket now covers two actions, not one; this is a text-accuracy fix, not a weakened check (still asserts full mode / all partitions / all lanes). Co-Authored-By: Claude Fable 5 --- scripts/ci/reborn_pr_test_plan.py | 17 +++++++++++------ scripts/ci/test_reborn_pr_test_plan.py | 26 +++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/scripts/ci/reborn_pr_test_plan.py b/scripts/ci/reborn_pr_test_plan.py index 199998804ac..0f6bce9d902 100644 --- a/scripts/ci/reborn_pr_test_plan.py +++ b/scripts/ci/reborn_pr_test_plan.py @@ -595,7 +595,10 @@ def _is_shipped_asset_markdown(path: str) -> bool: # hook, while Code Style both triggers on the tree and lints its contents # (`scripts/ci/test-ci-comm-locale-pin.sh` follows the symlinks and scans them). PR_STATIC_CONTROL_PREFIXES = (".github/workflows/", "scripts/ci/", ".githooks/") -SHARED_REBORN_ACTION_PREFIXES = (".github/actions/setup-sccache-dist/",) +SHARED_REBORN_ACTION_PREFIXES = ( + ".github/actions/setup-sccache-dist/", + ".github/actions/setup-rust/", +) BUCKET_WEIGHTS = { "reborn-core": 12, "auth-security": 9, @@ -921,10 +924,12 @@ def build_plan( nextest_config_changed = True continue if path.startswith(SHARED_REBORN_ACTION_PREFIXES): - # Every `Tests (Reborn)` job installs the compiler cache through - # this local action. No narrow lane can exercise a change to it - # safely, so use the exhaustive plan just as we do for shared - # nextest configuration. Keep other `.github/actions/**` paths + # Every `Tests (Reborn)` job installs the compiler cache + # (setup-sccache-dist) or the Rust toolchain itself + # (setup-rust) through one of these local actions. No narrow + # lane can exercise a change to either safely, so use the + # exhaustive plan just as we do for shared nextest + # configuration. Keep other `.github/actions/**` paths # fail-closed until their consumers are mapped deliberately. shared_reborn_action_changed = True continue @@ -1239,7 +1244,7 @@ def build_plan( ) if shared_reborn_action_changed: return _full_plan( - "shared sccache action changed; this PR runs the exhaustive plan", + "shared reborn action changed; this PR runs the exhaustive plan", canonical_packages, ) diff --git a/scripts/ci/test_reborn_pr_test_plan.py b/scripts/ci/test_reborn_pr_test_plan.py index 47cc933a150..2edfacf0cdc 100644 --- a/scripts/ci/test_reborn_pr_test_plan.py +++ b/scripts/ci/test_reborn_pr_test_plan.py @@ -1014,7 +1014,7 @@ def test_shared_sccache_action_widens_to_exhaustive_plan(self) -> None: self.assertEqual(plan["mode"], "full") self.assertEqual(plan["root_partitions"], [0, 1, 2, 3]) self.assertEqual(plan["integration_lanes"], [0, 1, 2, 3, "groups"]) - self.assertIn("shared sccache action changed", plan["reasons"][0]) + self.assertIn("shared reborn action changed", plan["reasons"][0]) with self.assertRaisesRegex(ValueError, "unmapped test or CI path"): self.plan( @@ -1025,6 +1025,30 @@ def test_shared_sccache_action_widens_to_exhaustive_plan(self) -> None: ], ) + def test_shared_setup_rust_action_widens_to_exhaustive_plan(self) -> None: + """The shared setup-rust action installs Rust for every Reborn test lane. + + Regression for the T1 CI-expedite landing: every `Tests (Reborn)` job + now installs Rust through `.github/actions/setup-rust` instead of a + bare `dtolnay/rust-toolchain` step, but the planner's fail-closed arm + for `.github/actions/**` had no rule for it — failing `Detect Reborn + test scope` on the very PR that introduced the composite. + """ + plan = self.plan("pull_request", [".github/actions/setup-rust/action.yml"]) + self.assertEqual(plan["mode"], "full") + self.assertEqual(plan["root_partitions"], [0, 1, 2, 3]) + self.assertEqual(plan["integration_lanes"], [0, 1, 2, 3, "groups"]) + self.assertIn("shared reborn action changed", plan["reasons"][0]) + + with self.assertRaisesRegex(ValueError, "unmapped test or CI path"): + self.plan( + "pull_request", + [ + ".github/actions/setup-rust/action.yml", + ".github/actions/some-undecided-action/action.yml", + ], + ) + def test_agent_guidance_is_classified_and_selects_no_rust_lane(self) -> None: """`.claude/**` is prose, like `docs/**`. From 39498bdf0709343dfc1f892b10c776be9736b3c9 Mon Sep 17 00:00:00 2001 From: Henry Park <16583448+henrypark133@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:23:59 +0000 Subject: [PATCH 21/44] fix(ci): let has_code see the toolchain pin and its own guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit validate_toolchain_pin_sync() only runs inside fast-checks, gated on has_code || has_guidance. The has_code regex enumerated crates/, tests/, Cargo.toml, and friends but not rust-toolchain.toml or .github/actions/setup-rust/ — so a PR touching only the two files the sync guard exists to police got has_code=false, has_guidance=false, and the guard never ran. Add both paths to the has_code regex, and pin them into CRATE_SCOPE_FILTERS' has_code in_scope probes so a future narrowing of the grep fails validate_crate_scope_filters loudly instead of quietly dropping them again. Regression: added a dedicated test asserting rust-toolchain.toml and .github/actions/setup-rust/action.yml are in has_code.in_scope, and confirmed test_checked_in_scope_filters_pass failed against the old regex before fixing .github/workflows/code_style.yml. Co-Authored-By: Claude Fable 5 --- .github/workflows/code_style.yml | 2 +- scripts/ci/test_ws12_workflow_contracts.py | 10 ++++++++++ scripts/ci/ws12_workflow_contracts.py | 6 ++++++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/.github/workflows/code_style.yml b/.github/workflows/code_style.yml index d90da07069e..8d3b2caff66 100644 --- a/.github/workflows/code_style.yml +++ b/.github/workflows/code_style.yml @@ -83,7 +83,7 @@ jobs: echo "has_clippy=false" >> "$GITHUB_OUTPUT" fi - if printf '%s\n' "$CHANGED_FILES" | grep -Eq '^(crates/|tests/|migrations/|Cargo\.toml$|Cargo\.lock$|deny\.toml$|Dockerfile|\.gitignore$|scripts/ci/|\.githooks/|docker/reborn/entrypoint\.sh$|scripts/reborn_webui_v2_live_qa/|scripts/live-canary/|\.github/workflows/(live-canary|reborn-e2e)\.yml$|scripts/check_no_panics\.py$|scripts/no_panics_reborn_baseline\.txt$|scripts/(check|test-check)-type-duplicates\.py$|\.github/scripts/(pr-labeler|test-pr-labeler)\.sh$|\.github/workflows/(code_style|main-ci-slack-alerts)\.yml$|\.github/workflows/ironclaw-release\.yml$)'; then + if printf '%s\n' "$CHANGED_FILES" | grep -Eq '^(crates/|tests/|migrations/|Cargo\.toml$|Cargo\.lock$|deny\.toml$|Dockerfile|\.gitignore$|scripts/ci/|\.githooks/|docker/reborn/entrypoint\.sh$|scripts/reborn_webui_v2_live_qa/|scripts/live-canary/|\.github/workflows/(live-canary|reborn-e2e)\.yml$|scripts/check_no_panics\.py$|scripts/no_panics_reborn_baseline\.txt$|scripts/(check|test-check)-type-duplicates\.py$|\.github/scripts/(pr-labeler|test-pr-labeler)\.sh$|\.github/workflows/(code_style|main-ci-slack-alerts)\.yml$|\.github/workflows/ironclaw-release\.yml$|rust-toolchain\.toml$|\.github/actions/setup-rust/)'; then echo "has_code=true" >> "$GITHUB_OUTPUT" else echo "has_code=false" >> "$GITHUB_OUTPUT" diff --git a/scripts/ci/test_ws12_workflow_contracts.py b/scripts/ci/test_ws12_workflow_contracts.py index 6e3a885600c..7edff88a941 100755 --- a/scripts/ci/test_ws12_workflow_contracts.py +++ b/scripts/ci/test_ws12_workflow_contracts.py @@ -655,6 +655,16 @@ def sabotage(self, workflow: str, old: str, new: str) -> dict[str, str]: def test_checked_in_scope_filters_pass(self) -> None: self.assertEqual(validate_crate_scope_filters(self.workflows, ROOT), []) + def test_has_code_covers_the_toolchain_pin_and_its_own_guard(self) -> None: + """validate_toolchain_pin_sync() only runs inside fast-checks, gated + on has_code || has_guidance. A PR touching only rust-toolchain.toml or + the setup-rust composite — exactly the two files the sync guard + exists to police — must still set has_code=true, or the guard never + fires for its own governed diff.""" + has_code = next(f for f in CRATE_SCOPE_FILTERS if f.name == "has_code") + self.assertIn("rust-toolchain.toml", has_code.in_scope) + self.assertIn(".github/actions/setup-rust/action.yml", has_code.in_scope) + def test_has_docs_trigger_is_pinned(self) -> None: """The docs publication gate rides its own trigger: docs-only PRs have has_code=false, so nothing else would run the boundary job. An diff --git a/scripts/ci/ws12_workflow_contracts.py b/scripts/ci/ws12_workflow_contracts.py index 9022a374265..69e808ca762 100755 --- a/scripts/ci/ws12_workflow_contracts.py +++ b/scripts/ci/ws12_workflow_contracts.py @@ -1357,6 +1357,12 @@ class CrateScopeFilter: # exactly the diff it guards (#7797 review). "scripts/test-check-type-duplicates.py", "scripts/check-type-duplicates.py", + # A PR touching only the toolchain pin or the composite that must + # stay in sync with it has to reach fast-checks, or + # validate_toolchain_pin_sync's own gate never runs for the two + # files it exists to police. + "rust-toolchain.toml", + ".github/actions/setup-rust/action.yml", ), out_of_scope=("README.md", "docs/internal/plans/whatever.md", "openwiki/index.md"), ), From fa3703a9cd60ec89d1c0157aa7980e65e5cae4ac Mon Sep 17 00:00:00 2001 From: Henry Park <16583448+henrypark133@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:25:52 +0000 Subject: [PATCH 22/44] fix(ci): pin the mold verify step's Linux guard in the setup-rust contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit validate_setup_rust_action() checked the Linux `if:` guard on the mold install and export steps, but the "Verify mold linker is active" step in between had no constant and no check at all — an unguarded verify step would run the mold link check on any runner OS (where mold and clang are never installed) and nothing would catch it. Add MOLD_VERIFY_STEP to the guarded set validate_setup_rust_action checks. Regression: added test_missing_mold_verify_linux_guard_fails and test_missing_mold_verify_step_fails, confirmed both failed with the old two-step tuple before adding MOLD_VERIFY_STEP to it. Co-Authored-By: Claude Fable 5 --- scripts/ci/test_ws12_workflow_contracts.py | 30 ++++++++++++++++++++++ scripts/ci/ws12_workflow_contracts.py | 3 ++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/scripts/ci/test_ws12_workflow_contracts.py b/scripts/ci/test_ws12_workflow_contracts.py index 7edff88a941..b8dd117b174 100755 --- a/scripts/ci/test_ws12_workflow_contracts.py +++ b/scripts/ci/test_ws12_workflow_contracts.py @@ -72,6 +72,10 @@ class SetupRustActionContractTests(unittest.TestCase): " if: ${{ inputs.mold == 'true' && runner.os == 'Linux' }}\n" " shell: bash\n" " run: scripts/ci/install-ci-apt-packages.sh clang mold\n" + " - name: Verify mold linker is active\n" + " if: ${{ inputs.mold == 'true' && runner.os == 'Linux' }}\n" + " shell: bash\n" + " run: rustc --version\n" " - name: Export mold RUSTFLAGS\n" " if: ${{ inputs.mold == 'true' && runner.os == 'Linux' }}\n" " shell: bash\n" @@ -99,6 +103,32 @@ def test_missing_mold_linux_guard_fails(self): errors = validate_setup_rust_action(bad) self.assertTrue(any("Linux" in e for e in errors)) + def test_missing_mold_verify_linux_guard_fails(self): + """An unguarded 'Verify mold linker is active' step runs the mold + link check on every runner OS, not just Linux — the same + mold: true-is-unsafe-elsewhere failure mode the install/export steps + are already pinned against.""" + bad = self.OK.replace( + "if: ${{ inputs.mold == 'true' && runner.os == 'Linux' }}\n shell: bash\n run: rustc --version\n", + "shell: bash\n run: rustc --version\n", + ) + errors = validate_setup_rust_action(bad) + self.assertTrue(any("Verify mold linker is active" in e and "Linux" in e for e in errors), errors) + + def test_missing_mold_verify_step_fails(self): + bad = self.OK.replace( + " - name: Verify mold linker is active\n" + " if: ${{ inputs.mold == 'true' && runner.os == 'Linux' }}\n" + " shell: bash\n" + " run: rustc --version\n", + "", + ) + errors = validate_setup_rust_action(bad) + self.assertTrue( + any("missing the 'Verify mold linker is active' step" in e for e in errors), + errors, + ) + def test_ok_passes(self): self.assertEqual([], validate_setup_rust_action(self.OK)) diff --git a/scripts/ci/ws12_workflow_contracts.py b/scripts/ci/ws12_workflow_contracts.py index 69e808ca762..d388b28ef7d 100755 --- a/scripts/ci/ws12_workflow_contracts.py +++ b/scripts/ci/ws12_workflow_contracts.py @@ -974,6 +974,7 @@ def validate_postgres_scripted_parity(text: str) -> list[str]: SETUP_RUST_ACTION = ".github/actions/setup-rust/action.yml" RUSTUP_TOOLCHAIN_PIN_STEP = "Pin the resolved toolchain for the rest of this job" MOLD_INSTALL_STEP = "Install mold and clang" +MOLD_VERIFY_STEP = "Verify mold linker is active" MOLD_EXPORT_STEP = "Export mold RUSTFLAGS" # The one canonical mold invocation; a job's own env may append extra flags # after it (the nightly lanes add -Zcrate-attr=...), but this prefix is the @@ -1108,7 +1109,7 @@ def validate_setup_rust_action(text: str | None) -> list[str]: "RUSTUP_TOOLCHAIN from steps.install.outputs.name, or a job's " "cargo invocations can drift from what this step actually installed" ) - for step_name in (MOLD_INSTALL_STEP, MOLD_EXPORT_STEP): + for step_name in (MOLD_INSTALL_STEP, MOLD_VERIFY_STEP, MOLD_EXPORT_STEP): body = step_body(text, step_name) if body is None: errors.append(f"{SETUP_RUST_ACTION}: missing the {step_name!r} step") From 2d125f5cb8d74dc1c700d8b1ea12bed1e485d557 Mon Sep 17 00:00:00 2001 From: Henry Park <16583448+henrypark133@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:27:32 +0000 Subject: [PATCH 23/44] fix(ci): anchor the toolchain-default regex to the toolchain input block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit validate_toolchain_pin_sync() used `re.search(r'default:\s*"([^"]+)"', action_text)` over the WHOLE setup-rust action.yml, resolving to the first non-empty `default:` in the file. That is only ever the `toolchain` input's default by accident — every other input's default is an empty string today and sits after it. A reordered or added input with its own non-empty default before `toolchain:` silently redirects the guard onto the wrong value, hiding real drift between rust-toolchain.toml and the composite. Add input_body(), a step_body-style helper that bounds one action.yml `inputs:` entry by the next input heading, and scope the default-value search to the `toolchain:` input's own block. Regression: added test_a_reordered_earlier_input_with_a_non_empty_default_cannot_hide_drift, confirmed it failed against the old whole-file regex (a decoy default matching the pinned channel hid an actual drift in toolchain's own default) before scoping the search to input_body(). Co-Authored-By: Claude Fable 5 --- scripts/ci/test_ws12_workflow_contracts.py | 21 ++++++++++++++++ scripts/ci/ws12_workflow_contracts.py | 28 +++++++++++++++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/scripts/ci/test_ws12_workflow_contracts.py b/scripts/ci/test_ws12_workflow_contracts.py index b8dd117b174..b73dcd571e3 100755 --- a/scripts/ci/test_ws12_workflow_contracts.py +++ b/scripts/ci/test_ws12_workflow_contracts.py @@ -255,6 +255,27 @@ def test_matching_default_passes(self): errors = validate_toolchain_pin_sync(self._root(self.FILE_OK, self.ACTION_OK)) self.assertEqual([], errors) + def test_a_reordered_earlier_input_with_a_non_empty_default_cannot_hide_drift( + self, + ): + """A whole-file `re.search` for the first `default: "..."` resolves + to whichever input happens to come first — today that is `toolchain` + only because every other input's default is empty and sits after it. + Add an earlier input with a non-empty default that happens to equal + the pinned channel while `toolchain` itself has drifted: an unscoped + search would find the decoy default, see it match, and report no + drift at all — exactly the silent-pass this contract exists to rule + out.""" + action = ( + "inputs:\n" + ' components:\n' + ' default: "1.98.0"\n' + " toolchain:\n" + ' default: "1.97.0"\n' + ) + errors = validate_toolchain_pin_sync(self._root(self.FILE_OK, action)) + self.assertTrue(any("1.97.0" in e for e in errors), errors) + class SccacheSetupActionContractTests(unittest.TestCase): """The optional compiler cache must never gate the tests it accelerates.""" diff --git a/scripts/ci/ws12_workflow_contracts.py b/scripts/ci/ws12_workflow_contracts.py index d388b28ef7d..9e5f1664957 100755 --- a/scripts/ci/ws12_workflow_contracts.py +++ b/scripts/ci/ws12_workflow_contracts.py @@ -1202,6 +1202,24 @@ def validate_no_job_env_rustflags_with_setup_rust( return errors +# One `:` entry under an action's `inputs:` block — the same two-space, +# name-then-colon-then-nothing-else shape JOB_HEADING matches for workflow +# jobs, but kept as its own pattern: action.yml has no `jobs:` section, and +# reusing JOB_HEADING here would conflate the two headings the moment either +# one's shape changes for its own reason. +INPUT_HEADING = re.compile(r"^ (?P[a-zA-Z0-9_-]+):[ \t]*$", re.MULTILINE) + + +def input_body(text: str, input_name: str) -> str | None: + """Return one composite action's `inputs:` entry, bounded by the next one.""" + for heading in INPUT_HEADING.finditer(text): + if heading.group("name") != input_name: + continue + following = INPUT_HEADING.search(text, heading.end()) + return text[heading.end() : following.start() if following else len(text)] + return None + + def validate_toolchain_pin_sync(root: Path = ROOT) -> list[str]: """rust-toolchain.toml and the composite's default must name one version.""" try: @@ -1216,7 +1234,15 @@ def validate_toolchain_pin_sync(root: Path = ROOT) -> list[str]: action_text = (root / SETUP_RUST_ACTION).read_text(encoding="utf-8") except OSError: return [f"{SETUP_RUST_ACTION}: missing"] - default_match = re.search(r'default:\s*"([^"]+)"', action_text) + # Scoped to the `toolchain:` input's own entry, not the whole file: a + # `re.search` over the entire action text would resolve to whichever + # `default: "..."` happens to appear first, which is only the toolchain + # input's by accident of every other input's default being empty and + # sitting after it in the file today. + toolchain_input = input_body(action_text, "toolchain") + if toolchain_input is None: + return [f"{SETUP_RUST_ACTION}: no `toolchain:` input found"] + default_match = re.search(r'default:\s*"([^"]+)"', toolchain_input) if default_match is None or default_match.group(1) != channel: found = default_match.group(1) if default_match else "" return [ From 1ef72d8b01423a43f095bf7c0db433bbe33e00ce Mon Sep 17 00:00:00 2001 From: Henry Park <16583448+henrypark133@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:29:23 +0000 Subject: [PATCH 24/44] fix(ci): catch workflow-level env RUSTFLAGS shadowing setup-rust too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JOB_ENV_RUSTFLAGS matched only six-space job-level `env:` indentation (jobs..env.RUSTFLAGS). A workflow-level top `env:` block shadows the composite's mold export identically — GitHub re-applies it to every step of every job the same way it re-applies job-level env — but sits at two-space indentation before any job heading, so it was invisible both to the indentation-bound regex and to the per-job block slicing (which only starts scanning at each job heading). Add a WORKFLOW_ENV_RUSTFLAGS check over the file's preamble alongside the existing per-job scan. Also add the missing PER-JOB skip coverage: the only existing test for `SETUP_RUST_USES not in block` exercised the FILE-level skip (`SETUP_RUST_USES not in text`) via a single-job file. Added a same-file two-job test where one job uses the composite and a sibling does not, to prove the sibling's own RUSTFLAGS stays allowed. Regression: added test_workflow_level_rustflags_alongside_setup_rust_fails, confirmed it failed before adding the WORKFLOW_ENV_RUSTFLAGS preamble check, then restored it green; also added test_sibling_job_without_setup_rust_is_allowed_in_a_multi_job_file to pin the previously-untested per-job skip branch. Co-Authored-By: Claude Fable 5 --- scripts/ci/test_ws12_workflow_contracts.py | 32 ++++++++++++++++++++++ scripts/ci/ws12_workflow_contracts.py | 26 ++++++++++++++++-- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/scripts/ci/test_ws12_workflow_contracts.py b/scripts/ci/test_ws12_workflow_contracts.py index b73dcd571e3..47a063f43b5 100755 --- a/scripts/ci/test_ws12_workflow_contracts.py +++ b/scripts/ci/test_ws12_workflow_contracts.py @@ -215,6 +215,38 @@ def test_rustflags_in_a_job_that_does_not_use_setup_rust_is_allowed(self): ), ) + def test_sibling_job_without_setup_rust_is_allowed_in_a_multi_job_file(self): + """The FILE-level skip (`SETUP_RUST_USES not in text`) is already + covered above by a single-job file. This is the PER-JOB skip branch + (`SETUP_RUST_USES not in block`): a sibling job's own RUSTFLAGS must + stay allowed even when another job in the SAME file uses the + composite.""" + workflow = ( + " docs:\n" + " env:\n" + ' RUSTFLAGS: "-Zcrate-attr=docs"\n' + " steps:\n" + " - run: echo hi\n" + ) + self.SETUP_RUST_JOB + self.assertEqual( + [], + validate_no_job_env_rustflags_with_setup_rust( + {".github/workflows/x.yml": workflow} + ), + ) + + def test_workflow_level_rustflags_alongside_setup_rust_fails(self): + """A workflow-level top `env:` block applies to every job in the + file identically to a job-level env key, but sits before any job + heading at two-space indentation — invisible to a check that only + slices text starting at each job heading and only matches six-space + indentation.""" + workflow = 'env:\n RUSTFLAGS: "-Zcrate-attr=x"\njobs:\n' + self.SETUP_RUST_JOB + errors = validate_no_job_env_rustflags_with_setup_rust( + {".github/workflows/x.yml": workflow} + ) + self.assertTrue(any("workflow-level" in e for e in errors), errors) + def test_live_workflows_are_clean(self): workflows = ws12_workflow_contracts.load_workflows(ROOT) self.assertEqual( diff --git a/scripts/ci/ws12_workflow_contracts.py b/scripts/ci/ws12_workflow_contracts.py index 9e5f1664957..c235c7f7862 100755 --- a/scripts/ci/ws12_workflow_contracts.py +++ b/scripts/ci/ws12_workflow_contracts.py @@ -1158,7 +1158,15 @@ def validate_no_direct_dtolnay_usage(workflows: dict[str, str]) -> list[str]: SETUP_RUST_USES = "uses: ./.github/actions/setup-rust" +# Job-level `env:` (jobs..env, two-space job + four-space `env:` + this +# key at six spaces) and workflow-level top `env:` (two-space key straight +# under the file's own `env:`) are both re-applied to every step of a job on +# top of $GITHUB_ENV, so both shadow the composite's mold export the same +# way. They need separate patterns, not one merged indentation class: the +# workflow-level form sits in the file's preamble, before any job heading, so +# it is checked once per file rather than by slicing per-job blocks. JOB_ENV_RUSTFLAGS = re.compile(r"^ {6}RUSTFLAGS:", re.MULTILINE) +WORKFLOW_ENV_RUSTFLAGS = re.compile(r"^ {2}RUSTFLAGS:", re.MULTILINE) def validate_no_job_env_rustflags_with_setup_rust( @@ -1170,9 +1178,11 @@ def validate_no_job_env_rustflags_with_setup_rust( on top of whatever earlier steps wrote to $GITHUB_ENV. So a job-level `RUSTFLAGS:` shadows the composite's export for the rest of the job and silently drops the mold linker flags — a slower build, never a red - check, which is exactly the drift class this action exists to remove. - Jobs pass their extra flags through the composite's `extra_rustflags` - input instead, so one place composes the final value. + check, which is exactly the drift class this action exists to remove. A + workflow-level top `env:` key shadows every job in the file identically, + so it is checked the same way. Jobs pass their extra flags through the + composite's `extra_rustflags` input instead, so one place composes the + final value. """ errors: list[str] = [] @@ -1180,6 +1190,16 @@ def validate_no_job_env_rustflags_with_setup_rust( if SETUP_RUST_USES not in text: continue headings = list(JOB_HEADING.finditer(text)) + preamble = text[: headings[0].start()] if headings else text + if WORKFLOW_ENV_RUSTFLAGS.search(preamble): + errors.append( + f"{path}: workflow-level env sets a RUSTFLAGS key while a " + "job in this file installs Rust through " + ".github/actions/setup-rust — a workflow-level env key " + "shadows the composite's $GITHUB_ENV write for every job in " + "the file identically to a job-level one; pass extra flags " + "as the composite's extra_rustflags input instead" + ) for index, heading in enumerate(headings): start = heading.start() end = ( From 3dc47cb76d764afffddcfcb5e16ec3b5e70db3bf Mon Sep 17 00:00:00 2001 From: Henry Park <16583448+henrypark133@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:30:18 +0000 Subject: [PATCH 25/44] test(ci): assert every exhaustive-plan field for the setup-rust widen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_shared_setup_rust_action_widens_to_exhaustive_plan only checked 4 of the 11 fields _full_plan() returns (mode, root_partitions, integration_lanes, and a substring of reasons[0]) — the other 7, including run_sandbox_docker and run_group_tests, were unpinned. This is ws12's own guardrail framework, so a regression narrowing this exact plan's blast radius deserved full coverage, not a partial one. Regression: widened the assertion to full dict equality against every field _full_plan() returns; confirmed it fails when run_sandbox_docker is flipped to False (a change the old partial assertion would have let through silently), then restored it. Co-Authored-By: Claude Fable 5 --- scripts/ci/test_reborn_pr_test_plan.py | 30 ++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/scripts/ci/test_reborn_pr_test_plan.py b/scripts/ci/test_reborn_pr_test_plan.py index 2edfacf0cdc..8efe4111b83 100644 --- a/scripts/ci/test_reborn_pr_test_plan.py +++ b/scripts/ci/test_reborn_pr_test_plan.py @@ -1035,10 +1035,32 @@ def test_shared_setup_rust_action_widens_to_exhaustive_plan(self) -> None: test scope` on the very PR that introduced the composite. """ plan = self.plan("pull_request", [".github/actions/setup-rust/action.yml"]) - self.assertEqual(plan["mode"], "full") - self.assertEqual(plan["root_partitions"], [0, 1, 2, 3]) - self.assertEqual(plan["integration_lanes"], [0, 1, 2, 3, "groups"]) - self.assertIn("shared reborn action changed", plan["reasons"][0]) + # Full field-by-field equality, not a handful of spot checks: an + # exhaustive plan has ten fields besides `reasons` that a partial + # assertion would leave silently unpinned (e.g. `run_sandbox_docker` + # regressing to False on a "full" plan would pass every check this + # test used to make). + self.assertEqual( + plan, + { + "mode": "full", + "reasons": [ + "shared reborn action changed; this PR runs the " + "exhaustive plan" + ], + "changed_packages": [], + "affected_packages": ["alpha", "beta", "gamma"], + "crate_buckets": [ + {"name": "selected", "packages": ["alpha", "beta", "gamma"]} + ], + "root_partitions": [0, 1, 2, 3], + "integration_lanes": [0, 1, 2, 3, "groups"], + "run_group_tests": True, + "run_qa_replay": True, + "run_sandbox_docker": True, + "coverage_mode": "full", + }, + ) with self.assertRaisesRegex(ValueError, "unmapped test or CI path"): self.plan( From d33e7f91b8a8f9e1cb1b7ef1ad77a69140e804d1 Mon Sep 17 00:00:00 2001 From: Henry Park <16583448+henrypark133@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:31:54 +0000 Subject: [PATCH 26/44] fix(ci): glob both .yml and .yaml when loading workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit load_workflows() globbed only `*.yml` under .github/workflows/. GitHub Actions accepts either extension for a workflow file, so a `.yaml` workflow would silently escape every ws12 contract this loader feeds — latent today since no `.yaml` workflow exists, but a landmine for the next one added. Glob both extensions. Regression: added test_discovers_both_yml_and_yaml_extensions, confirmed it failed against the *.yml-only glob (the .yaml fixture was silently dropped) before widening the glob. Co-Authored-By: Claude Fable 5 --- scripts/ci/test_ws12_workflow_contracts.py | 21 +++++++++++++++++++++ scripts/ci/ws12_workflow_contracts.py | 11 ++++++++--- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/scripts/ci/test_ws12_workflow_contracts.py b/scripts/ci/test_ws12_workflow_contracts.py index 47a063f43b5..d95dbccb685 100755 --- a/scripts/ci/test_ws12_workflow_contracts.py +++ b/scripts/ci/test_ws12_workflow_contracts.py @@ -309,6 +309,27 @@ def test_a_reordered_earlier_input_with_a_non_empty_default_cannot_hide_drift( self.assertTrue(any("1.97.0" in e for e in errors), errors) +class LoadWorkflowsTests(unittest.TestCase): + """Every workflow contract runs on load_workflows()'s output — a file it + fails to discover is invisible to every one of them, silently, since + nothing iterates the directory a second way to notice the gap.""" + + def test_discovers_both_yml_and_yaml_extensions(self): + tmp = Path(tempfile.mkdtemp()) + workflows_dir = tmp / ".github" / "workflows" + workflows_dir.mkdir(parents=True) + (workflows_dir / "a.yml").write_text("name: a\n") + (workflows_dir / "b.yaml").write_text("name: b\n") + loaded = ws12_workflow_contracts.load_workflows(tmp) + self.assertEqual( + loaded, + { + ".github/workflows/a.yml": "name: a\n", + ".github/workflows/b.yaml": "name: b\n", + }, + ) + + class SccacheSetupActionContractTests(unittest.TestCase): """The optional compiler cache must never gate the tests it accelerates.""" diff --git a/scripts/ci/ws12_workflow_contracts.py b/scripts/ci/ws12_workflow_contracts.py index c235c7f7862..da6e168cc96 100755 --- a/scripts/ci/ws12_workflow_contracts.py +++ b/scripts/ci/ws12_workflow_contracts.py @@ -2004,7 +2004,7 @@ def validate_workflow_texts( def load_workflows(root: Path) -> dict[str, str]: - """Every `.github/workflows/*.yml` file, repo-relative path -> text. + """Every `.github/workflows/*.{yml,yaml}` file, repo-relative path -> text. A handful of contracts key off one specific known path (REQUIRED_MARKERS, CRATE_SCOPE_FILTERS, CRATE_NAME_RESIDUE); `validate_webui_frontend_sites` @@ -2012,13 +2012,18 @@ def load_workflows(root: Path) -> dict[str, str]: flat WebUI literal reappearing somewhere nobody enumerated. Loading every (small) workflow file eagerly costs nothing and keeps one loader for every consumer — a superset of the old explicit path list, so every existing - `workflows.get(path)` lookup keeps working unchanged. + `workflows.get(path)` lookup keeps working unchanged. GitHub Actions + accepts both `.yml` and `.yaml` for a workflow file; globbing only the + former would let a `.yaml` workflow escape every ws12 contract silently. """ workflows_dir = root / ".github" / "workflows" + paths = sorted( + {*workflows_dir.glob("*.yml"), *workflows_dir.glob("*.yaml")} + ) return { path.relative_to(root).as_posix(): path.read_text(encoding="utf-8") - for path in sorted(workflows_dir.glob("*.yml")) + for path in paths } From 6a841032a8dcf07458d6b95efc971ddf408f2f25 Mon Sep 17 00:00:00 2001 From: Henry Park <16583448+henrypark133@users.noreply.github.com> Date: Mon, 24 Aug 2026 04:04:17 +0000 Subject: [PATCH 27/44] fix(ci): guard every Rust bootstrap, not just the vendor action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An approach audit found the migration's "single owner" claim was false and, worse, unenforceable: .github/workflows/ironclaw-release.yml:133 still bootstraps Rust with `curl https://sh.rustup.rs | sh`, so the lane that builds shipped artifacts got no RUSTUP_TOOLCHAIN pin, no mold wiring and no check against rust-toolchain.toml. validate_no_direct_dtolnay_usage greps only the literal `dtolnay/rust-toolchain@`, so that path passed the gate forever, and no test covered it. The file is live, not inert boilerplate — commit 2a7d49c60 hand-edited that exact line five days before this branch. That file is regenerated wholesale by cargo-dist (see [workspace.metadata.dist], cargo-dist-version 0.31.0), so migrating it onto the composite would be clobbered on the next regeneration. It is therefore an ACCEPTED exception rather than a silent one: - validate_no_unmanaged_rust_bootstrap now fails on `sh.rustup.rs`, `rustup-init` and `rustup toolchain install` in any workflow, with ACCEPTED_RUST_BOOTSTRAPS pinning ironclaw-release.yml to exactly one occurrence. A second bootstrap there, or any in a hand-written workflow, fails the gate; and if the generator stops emitting it, the count check says so instead of leaving a stale exemption behind. - AGENTS.md and rust-toolchain.toml said "every CI job" and "exclusively". Both were false as written. They now say hand-written jobs, name the exception, and point at the checker that enforces it. Proven by sabotage: adding a curl bootstrap to code_style.yml fails the gate; adding a SECOND one to ironclaw-release.yml fails it with "2 ... 1 accepted here"; both restored, gate green. Five unit tests cover the hand-written case, rustup-init/toolchain-install variants, the accepted single, the accepted-file second, and the live tree. Also drops INPUT_HEADING/input_body, a byte-identical duplicate of the existing JOB_HEADING/job_body with one call site — an action's `inputs:` entry has the same two-space `name:` shape a workflow job does, so job_body(action_text, "toolchain") does the job. Verified: ws12 self-tests 120 OK, ws12 live gate passed, planner suite 88 OK, check-guidance OK. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 13 +++-- scripts/ci/test_ws12_workflow_contracts.py | 51 ++++++++++++++++++ scripts/ci/ws12_workflow_contracts.py | 63 ++++++++++++++++++---- 3 files changed, 113 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1d7b82696c0..9ce98e88244 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,16 +20,21 @@ The workspace-root `integration` feature is empty with zero consumers — a bare **Cargo features are a last resort.** A feature is a second build of the workspace, compiled and tested forever. Add one only for a heavy optional dependency, a build shape that ships with it OFF, a CI lane selector, a dev-only seam (always named `test-support`), or a privilege boundary — and say which in the manifest comment. Deployment shape belongs in `DeploymentConfig` and `[storage]`, not `#[cfg]`. Full bar: `.claude/rules/cargo-features.md`. **Toolchain pin.** `rust-toolchain.toml` at the repo root pins the stable -toolchain (channel + clippy/rustfmt) for local and CI builds. Every CI job -installs Rust through `.github/actions/setup-rust`, whose `toolchain` input +toolchain (channel + clippy/rustfmt) for local and CI builds. Every +hand-written CI job installs Rust through `.github/actions/setup-rust`, whose `toolchain` input defaults to the same version; a lane that intentionally runs a different toolchain (the two `nightly-2025-11-01` coverage lanes) passes an explicit `toolchain:` input to that same composite, which exports `RUSTUP_TOOLCHAIN` naming exactly what it installed — so `rust-toolchain.toml` can never silently override a job's chosen toolchain. `scripts/ci/ws12_workflow_contracts.py` fails the build if any workflow calls `dtolnay/rust-toolchain` directly -instead of through the composite, and if the composite's default toolchain -ever drifts from this file's `channel`. To bump Rust: edit both +instead of through the composite, if any workflow bootstraps Rust another way +(`sh.rustup.rs`, `rustup-init`, `rustup toolchain install`), and if the +composite's default toolchain ever drifts from this file's `channel`. One +accepted exception is pinned in that checker: `ironclaw-release.yml` is +regenerated wholesale by cargo-dist and bootstraps Rust itself inside +container builds, so exactly one such bootstrap is allowed there and a second +one — or any in a hand-written workflow — fails the gate. To bump Rust: edit both `rust-toolchain.toml`'s `channel` and `.github/actions/setup-rust/action.yml`'s `toolchain` input default, in the same PR. diff --git a/scripts/ci/test_ws12_workflow_contracts.py b/scripts/ci/test_ws12_workflow_contracts.py index d95dbccb685..222baad90e4 100755 --- a/scripts/ci/test_ws12_workflow_contracts.py +++ b/scripts/ci/test_ws12_workflow_contracts.py @@ -11,6 +11,7 @@ import ws12_workflow_contracts from ws12_workflow_contracts import ( + validate_no_unmanaged_rust_bootstrap, CODE_STYLE_WORKFLOW, CRATE_NAME_RESIDUE, CRATE_SCOPE_FILTERS, @@ -254,6 +255,56 @@ def test_live_workflows_are_clean(self): ) + +class UnmanagedRustBootstrapTests(unittest.TestCase): + """A curl bootstrap installs Rust as surely as the vendor action. + + The dtolnay check greps one vendor string, so `curl sh.rustup.rs | sh` + passed it forever — unpinned, without mold, unchecked against + rust-toolchain.toml. cargo-dist regenerates ironclaw-release.yml, so its + container-only bootstrap is an accepted exception, pinned to one. + """ + + def test_bootstrap_in_a_hand_written_workflow_fails(self): + workflows = { + ".github/workflows/x.yml": ( + " run: curl -sSf https://sh.rustup.rs | sh -s -- -y\n" + ) + } + errors = validate_no_unmanaged_rust_bootstrap(workflows) + self.assertTrue(any("x.yml" in e for e in errors), errors) + + def test_rustup_init_and_toolchain_install_also_count(self): + for snippet in ("rustup-init -y", "rustup toolchain install 1.98.0"): + with self.subTest(snippet=snippet): + errors = validate_no_unmanaged_rust_bootstrap( + {".github/workflows/x.yml": f" run: {snippet}\n"} + ) + self.assertTrue(errors, f"{snippet} should be caught") + + def test_the_accepted_release_bootstrap_passes(self): + workflows = { + ".github/workflows/ironclaw-release.yml": ( + " run: curl -sSf https://sh.rustup.rs | sh -s -- -y\n" + ) + } + self.assertEqual([], validate_no_unmanaged_rust_bootstrap(workflows)) + + def test_a_second_bootstrap_in_the_accepted_file_fails(self): + workflows = { + ".github/workflows/ironclaw-release.yml": ( + " run: curl -sSf https://sh.rustup.rs | sh -s -- -y\n" + " run: curl -sSf https://sh.rustup.rs | sh -s -- -y\n" + ) + } + errors = validate_no_unmanaged_rust_bootstrap(workflows) + self.assertTrue(any("2 raw Rust bootstrap" in e for e in errors), errors) + + def test_live_workflows_hold_the_contract(self): + workflows = ws12_workflow_contracts.load_workflows(ROOT) + self.assertEqual([], validate_no_unmanaged_rust_bootstrap(workflows)) + + class ToolchainPinSyncTests(unittest.TestCase): """rust-toolchain.toml and the composite's default must name one version.""" diff --git a/scripts/ci/ws12_workflow_contracts.py b/scripts/ci/ws12_workflow_contracts.py index da6e168cc96..9ab55a049a9 100755 --- a/scripts/ci/ws12_workflow_contracts.py +++ b/scripts/ci/ws12_workflow_contracts.py @@ -1227,17 +1227,57 @@ def validate_no_job_env_rustflags_with_setup_rust( # jobs, but kept as its own pattern: action.yml has no `jobs:` section, and # reusing JOB_HEADING here would conflate the two headings the moment either # one's shape changes for its own reason. -INPUT_HEADING = re.compile(r"^ (?P[a-zA-Z0-9_-]+):[ \t]*$", re.MULTILINE) - +RUST_BOOTSTRAP_PATTERNS = ( + "sh.rustup.rs", + "rustup-init", + "rustup toolchain install", +) +# cargo-dist regenerates .github/workflows/ironclaw-release.yml wholesale +# (see [workspace.metadata.dist], cargo-dist-version 0.31.0), so its +# container-only bootstrap cannot be migrated onto the composite without +# being clobbered on the next regeneration. It is therefore an ACCEPTED +# exception, pinned to one occurrence: a second bootstrap appearing there, +# or any bootstrap in a hand-written workflow, fails this gate. +ACCEPTED_RUST_BOOTSTRAPS = {".github/workflows/ironclaw-release.yml": 1} + + +def validate_no_unmanaged_rust_bootstrap(workflows: dict[str, str]) -> list[str]: + """Every hand-written workflow installs Rust through the composite. + + `validate_no_direct_dtolnay_usage` only sees the vendor action. A raw + `curl https://sh.rustup.rs | sh` installs Rust just as effectively and + matches no such string, so it would otherwise pass this gate forever -- + unpinned, without mold, and unchecked against rust-toolchain.toml. + """ -def input_body(text: str, input_name: str) -> str | None: - """Return one composite action's `inputs:` entry, bounded by the next one.""" - for heading in INPUT_HEADING.finditer(text): - if heading.group("name") != input_name: + errors: list[str] = [] + for path, text in workflows.items(): + hits = sum(text.count(pattern) for pattern in RUST_BOOTSTRAP_PATTERNS) + if not hits: continue - following = INPUT_HEADING.search(text, heading.end()) - return text[heading.end() : following.start() if following else len(text)] - return None + allowed = ACCEPTED_RUST_BOOTSTRAPS.get(path, 0) + if hits > allowed: + errors.append( + f"{path}: {hits} raw Rust bootstrap(s) " + f"({', '.join(RUST_BOOTSTRAP_PATTERNS)}); " + f"{allowed} accepted here. Install Rust through " + ".github/actions/setup-rust so the toolchain stays pinned, " + "mold stays wired, and rust-toolchain.toml stays enforced. " + "A genuinely unavoidable bootstrap must be added to " + "ACCEPTED_RUST_BOOTSTRAPS with the reason." + ) + for path, expected in ACCEPTED_RUST_BOOTSTRAPS.items(): + text = workflows.get(path) + if text is None: + continue + hits = sum(text.count(pattern) for pattern in RUST_BOOTSTRAP_PATTERNS) + if hits < expected: + errors.append( + f"{path}: expected {expected} accepted Rust bootstrap(s), " + f"found {hits}. If the generator stopped emitting it, drop " + "the entry from ACCEPTED_RUST_BOOTSTRAPS." + ) + return errors def validate_toolchain_pin_sync(root: Path = ROOT) -> list[str]: @@ -1259,7 +1299,9 @@ def validate_toolchain_pin_sync(root: Path = ROOT) -> list[str]: # `default: "..."` happens to appear first, which is only the toolchain # input's by accident of every other input's default being empty and # sitting after it in the file today. - toolchain_input = input_body(action_text, "toolchain") + # `job_body` bounds a two-space `name:` block, which is exactly the + # shape of an action.yml `inputs:` entry — no second helper needed. + toolchain_input = job_body(action_text, "toolchain") if toolchain_input is None: return [f"{SETUP_RUST_ACTION}: no `toolchain:` input found"] default_match = re.search(r'default:\s*"([^"]+)"', toolchain_input) @@ -1998,6 +2040,7 @@ def validate_workflow_texts( setup_rust_action = None errors.extend(validate_setup_rust_action(setup_rust_action)) errors.extend(validate_no_direct_dtolnay_usage(workflows)) + errors.extend(validate_no_unmanaged_rust_bootstrap(workflows)) errors.extend(validate_no_job_env_rustflags_with_setup_rust(workflows)) errors.extend(validate_toolchain_pin_sync(root)) return errors From 36041d7d09d96980a838342f437a151ee3722b6b Mon Sep 17 00:00:00 2001 From: Henry Park <16583448+henrypark133@users.noreply.github.com> Date: Mon, 24 Aug 2026 04:05:13 +0000 Subject: [PATCH 28/44] docs(toolchain): name the accepted release-workflow bootstrap in the pin header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The companion commit corrected AGENTS.md but this file's header still said "CI workflows install Rust exclusively through .github/actions/setup-rust" — false while ironclaw-release.yml bootstraps Rust itself, and the same overstatement the audit flagged. (The earlier edit aborted on an unrelated assertion before reaching this file.) It now says hand-written workflows, and names the one accepted exception plus the checker that pins it to a single occurrence, so the header matches what the gate actually enforces. Verified: check-guidance OK, ws12 live gate passed. Co-Authored-By: Claude Fable 5 --- rust-toolchain.toml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/rust-toolchain.toml b/rust-toolchain.toml index cc9091acc78..a42c4c80988 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -2,7 +2,7 @@ # - Every in-repo cargo/rustc run resolves this file (rustup precedence: # RUSTUP_TOOLCHAIN env > directory override > this file > rustup default), # so local clippy/rustfmt finally match CI's. -# - CI workflows install Rust exclusively through +# - Hand-written CI workflows install Rust through # .github/actions/setup-rust, whose `toolchain` input defaults to the same # version as `channel` below (kept in sync by # scripts/ci/ws12_workflow_contracts.py); a lane that intentionally runs a @@ -10,6 +10,10 @@ # an explicit `toolchain:` input, and the composite exports # RUSTUP_TOOLCHAIN naming exactly what it installed either way, so this # file can never silently override a job's chosen toolchain. +# - One accepted exception: .github/workflows/ironclaw-release.yml is +# regenerated wholesale by cargo-dist and bootstraps Rust itself inside +# container builds. ws12_workflow_contracts.py pins that to exactly one +# occurrence and fails on any other raw bootstrap in any workflow. # - Bumping: change `channel` here AND the `toolchain` input default in # .github/actions/setup-rust/action.yml in the same PR (the ws12 gate # fails on any mismatch). Expect Swatinem/rust-cache keys to rotate with From cfb3761aa00b36b161c7a2ada1fbc724ac5cae50 Mon Sep 17 00:00:00 2001 From: Henry Park <16583448+henrypark133@users.noreply.github.com> Date: Mon, 24 Aug 2026 04:18:12 +0000 Subject: [PATCH 29/44] fix(ci): migrate the release lane onto the composite and unbreak two guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An approach re-audit and a multi-agent code review both rejected the previous round's work. Three real defects, all in code added by this PR. 1. The "accepted exception" was unjustified. The claim was that cargo-dist regenerates ironclaw-release.yml wholesale, so its `curl sh.rustup.rs` bootstrap could not be migrated. The repo contradicts that four ways: `allow-dirty = ["ci"]` exists precisely so hand-edits survive regeneration (.github/workflows/README.md documents it for the permission hardening); two other hand-added steps already live in that job; cargo-dist exposes `github-build-setup` (already used for Node/pnpm in .github/dist-build-setup.yml) as the durable seam for exactly this; and `actions/checkout` and `setup-python` already run via `uses:` in that same container job, so a composite call is not structurally precluded. The bootstrap now lives in .github/dist-build-setup.yml as a `uses: ./.github/actions/setup-rust` call, re-included on every regeneration, keeping the previous step's `if: ${{ matrix.container }}` condition so release behaviour is unchanged. ACCEPTED_RUST_BOOTSTRAPS is empty: no lane is exempt, and the prose in AGENTS.md and rust-toolchain.toml states that without carve-outs because it is now true. 2. The workflow-level RUSTFLAGS check was dead code. JOB_HEADING matches any two-space `key:` line, so `on:`'s children (`push:`, `workflow_call:`) matched and headings[0] truncated the preamble at the first TRIGGER — before the real top-level `env:`. Verified on the live reborn-tests.yml: first match was `workflow_call` at offset 26, `jobs:` at 3177. Headings are now bounded to the `jobs:` block. The old unit test passed only because its fixture omitted `on:`; the new one carries a realistic trigger block. 3. The per-job check was blind to YAML aliases. release-plz.yml's `release-plz-pr` reaches the composite via `- *install-rust` and contains no literal `uses:` line, so the scan skipped it — a job-level RUSTFLAGS there would have shadowed mold silently. Anchors carrying the composite are now resolved and aliased jobs are checked. Also broadens the bootstrap guard beyond three rustup literals to known third-party toolchain actions (actions-rs, actions-rust-lang, hecrj, raftario), after the coverage lane showed the enumeration was trivially evadable. The one case text cannot see — a `container:` image shipping Rust preinstalled — is named in a comment as residual risk rather than papered over. Proven against the real files that defeated the old guards: a workflow-level RUSTFLAGS injected into reborn-tests.yml is now caught; a job-level RUSTFLAGS in release-plz.yml's alias-reached job is now caught; both were silent before. Also removes an orphaned comment left describing the deleted INPUT_HEADING. Verified: ws12 self-tests 123 OK, ws12 live gate passed, planner suite 88 OK, check-guidance OK, both changed workflows parse. Co-Authored-By: Claude Fable 5 --- .github/dist-build-setup.yml | 11 +++ .github/workflows/ironclaw-release.yml | 7 -- AGENTS.md | 13 ++- rust-toolchain.toml | 9 +-- scripts/ci/test_ws12_workflow_contracts.py | 93 +++++++++++++++++++--- scripts/ci/ws12_workflow_contracts.py | 63 +++++++++++---- 6 files changed, 152 insertions(+), 44 deletions(-) diff --git a/.github/dist-build-setup.yml b/.github/dist-build-setup.yml index 770710da3ee..0d2b7ac3b34 100644 --- a/.github/dist-build-setup.yml +++ b/.github/dist-build-setup.yml @@ -1,3 +1,14 @@ +- name: Install Rust through the repo's single setup path + # cargo-dist re-includes this fragment on every regeneration + # (github-build-setup in [workspace.metadata.dist]), so unlike an inline + # step it survives `dist generate` — which is why the bootstrap belongs + # here rather than as a hand-edited `curl | sh` in the generated workflow. + # Container images ship without cargo; hosted runners already have it and + # resolve rust-toolchain.toml themselves, so this mirrors the previous + # step's condition exactly rather than changing release behaviour. + if: ${{ matrix.container }} + uses: ./.github/actions/setup-rust + - name: Install Node.js for the embedded WebUI uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4 with: diff --git a/.github/workflows/ironclaw-release.yml b/.github/workflows/ironclaw-release.yml index dc75cc8b3b5..8b1d5cbeee3 100644 --- a/.github/workflows/ironclaw-release.yml +++ b/.github/workflows/ironclaw-release.yml @@ -126,13 +126,6 @@ jobs: uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 with: python-version: "3.12" - - name: Install Rust non-interactively if not already installed - if: ${{ matrix.container }} - run: | - if ! command -v cargo > /dev/null 2>&1; then - curl --proto '=https' --tlsv1.2 --max-time 120 -sSf https://sh.rustup.rs | sh -s -- -y - echo "$HOME/.cargo/bin" >> $GITHUB_PATH - fi - name: "Install Node.js for the embedded WebUI" uses: "actions/setup-node@820762786026740c76f36085b0efc47a31fe5020" with: diff --git a/AGENTS.md b/AGENTS.md index 9ce98e88244..ece5a1ed4dc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,8 +20,8 @@ The workspace-root `integration` feature is empty with zero consumers — a bare **Cargo features are a last resort.** A feature is a second build of the workspace, compiled and tested forever. Add one only for a heavy optional dependency, a build shape that ships with it OFF, a CI lane selector, a dev-only seam (always named `test-support`), or a privilege boundary — and say which in the manifest comment. Deployment shape belongs in `DeploymentConfig` and `[storage]`, not `#[cfg]`. Full bar: `.claude/rules/cargo-features.md`. **Toolchain pin.** `rust-toolchain.toml` at the repo root pins the stable -toolchain (channel + clippy/rustfmt) for local and CI builds. Every -hand-written CI job installs Rust through `.github/actions/setup-rust`, whose `toolchain` input +toolchain (channel + clippy/rustfmt) for local and CI builds. Every CI job +installs Rust through `.github/actions/setup-rust`, whose `toolchain` input defaults to the same version; a lane that intentionally runs a different toolchain (the two `nightly-2025-11-01` coverage lanes) passes an explicit `toolchain:` input to that same composite, which exports `RUSTUP_TOOLCHAIN` @@ -30,11 +30,10 @@ silently override a job's chosen toolchain. `scripts/ci/ws12_workflow_contracts. fails the build if any workflow calls `dtolnay/rust-toolchain` directly instead of through the composite, if any workflow bootstraps Rust another way (`sh.rustup.rs`, `rustup-init`, `rustup toolchain install`), and if the -composite's default toolchain ever drifts from this file's `channel`. One -accepted exception is pinned in that checker: `ironclaw-release.yml` is -regenerated wholesale by cargo-dist and bootstraps Rust itself inside -container builds, so exactly one such bootstrap is allowed there and a second -one — or any in a hand-written workflow — fails the gate. To bump Rust: edit both +composite's default toolchain ever drifts from this file's `channel`. The +cargo-dist-generated release workflow is covered too: its build jobs pick the +composite up from `.github/dist-build-setup.yml`, which cargo-dist re-includes +on every regeneration, so there is no lane outside this contract. To bump Rust: edit both `rust-toolchain.toml`'s `channel` and `.github/actions/setup-rust/action.yml`'s `toolchain` input default, in the same PR. diff --git a/rust-toolchain.toml b/rust-toolchain.toml index a42c4c80988..5dadd89eff8 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -2,7 +2,7 @@ # - Every in-repo cargo/rustc run resolves this file (rustup precedence: # RUSTUP_TOOLCHAIN env > directory override > this file > rustup default), # so local clippy/rustfmt finally match CI's. -# - Hand-written CI workflows install Rust through +# - CI workflows install Rust through # .github/actions/setup-rust, whose `toolchain` input defaults to the same # version as `channel` below (kept in sync by # scripts/ci/ws12_workflow_contracts.py); a lane that intentionally runs a @@ -10,10 +10,9 @@ # an explicit `toolchain:` input, and the composite exports # RUSTUP_TOOLCHAIN naming exactly what it installed either way, so this # file can never silently override a job's chosen toolchain. -# - One accepted exception: .github/workflows/ironclaw-release.yml is -# regenerated wholesale by cargo-dist and bootstraps Rust itself inside -# container builds. ws12_workflow_contracts.py pins that to exactly one -# occurrence and fails on any other raw bootstrap in any workflow. +# - The cargo-dist release workflow is covered through +# .github/dist-build-setup.yml, which cargo-dist re-includes on every +# regeneration, so no lane installs Rust outside the composite. # - Bumping: change `channel` here AND the `toolchain` input default in # .github/actions/setup-rust/action.yml in the same PR (the ws12 gate # fails on any mismatch). Expect Swatinem/rust-cache keys to rotate with diff --git a/scripts/ci/test_ws12_workflow_contracts.py b/scripts/ci/test_ws12_workflow_contracts.py index 222baad90e4..50953e28476 100755 --- a/scripts/ci/test_ws12_workflow_contracts.py +++ b/scripts/ci/test_ws12_workflow_contracts.py @@ -248,6 +248,61 @@ def test_workflow_level_rustflags_alongside_setup_rust_fails(self): ) self.assertTrue(any("workflow-level" in e for e in errors), errors) + def test_workflow_level_rustflags_is_caught_when_an_on_block_exists(self): + """The fixture must carry an `on:` block or the check looks fine. + + JOB_HEADING matches any two-space `key:` line, so `on:`'s children + (`push:`, `workflow_call:`) matched too. Taking headings[0] blindly + truncated the preamble at the first TRIGGER, making this check dead + code on every real workflow — and the original fixture passed only + because it happened to omit `on:`. + """ + workflows = { + ".github/workflows/x.yml": ( + "name: X\n" + "on:\n" + " push:\n" + " branches: [main]\n" + " workflow_call:\n" + "env:\n" + ' RUSTFLAGS: "-Zcrate-attr=x"\n' + "jobs:\n" + self.SETUP_RUST_JOB + ) + } + errors = validate_no_job_env_rustflags_with_setup_rust(workflows) + self.assertTrue( + any("workflow-level" in e for e in errors), + f"workflow-level RUSTFLAGS must be caught behind an on: block: {errors}", + ) + + def test_a_job_reaching_the_composite_by_yaml_alias_is_still_checked(self): + """`- *install-rust` carries no literal `uses:` line of its own. + + release-plz.yml reuses the composite step through a YAML anchor, so a + text-only per-job scan skipped that job entirely and its job-level + RUSTFLAGS would have shadowed mold silently. + """ + workflows = { + ".github/workflows/x.yml": ( + "jobs:\n" + " first:\n" + " steps:\n" + " - &install-rust\n" + " name: Install Rust\n" + " uses: ./.github/actions/setup-rust\n" + " second:\n" + " env:\n" + ' RUSTFLAGS: "-Zcrate-attr=x"\n' + " steps:\n" + " - *install-rust\n" + ) + } + errors = validate_no_job_env_rustflags_with_setup_rust(workflows) + self.assertTrue( + any("'second'" in e for e in errors), + f"alias-reached job must be checked: {errors}", + ) + def test_live_workflows_are_clean(self): workflows = ws12_workflow_contracts.load_workflows(ROOT) self.assertEqual( @@ -282,23 +337,41 @@ def test_rustup_init_and_toolchain_install_also_count(self): ) self.assertTrue(errors, f"{snippet} should be caught") - def test_the_accepted_release_bootstrap_passes(self): - workflows = { - ".github/workflows/ironclaw-release.yml": ( - " run: curl -sSf https://sh.rustup.rs | sh -s -- -y\n" - ) - } - self.assertEqual([], validate_no_unmanaged_rust_bootstrap(workflows)) + def test_the_release_workflow_has_no_carve_out(self): + """The release lane is migrated, not exempted. - def test_a_second_bootstrap_in_the_accepted_file_fails(self): + cargo-dist re-includes .github/dist-build-setup.yml on every + regeneration, so the release build jobs reach the composite there; + a bootstrap reappearing in the generated workflow is a regression. + """ workflows = { ".github/workflows/ironclaw-release.yml": ( " run: curl -sSf https://sh.rustup.rs | sh -s -- -y\n" - " run: curl -sSf https://sh.rustup.rs | sh -s -- -y\n" ) } errors = validate_no_unmanaged_rust_bootstrap(workflows) - self.assertTrue(any("2 raw Rust bootstrap" in e for e in errors), errors) + self.assertTrue(any("ironclaw-release" in e for e in errors), errors) + + def test_no_accepted_bootstraps_remain(self): + self.assertEqual({}, ws12_workflow_contracts.ACCEPTED_RUST_BOOTSTRAPS) + + def test_alternate_vendor_toolchain_actions_are_caught(self): + """The composite is the only sanctioned installer. + + Enumerating rustup bootstraps alone left an obvious hole: a workflow + could reach for a different vendor action and pass every gate while + running an unpinned toolchain with no mold. + """ + for action in ( + "actions-rs/toolchain@v1", + "actions-rust-lang/setup-rust-toolchain@v1", + "hecrj/setup-rust-action@v1", + ): + with self.subTest(action=action): + errors = validate_no_unmanaged_rust_bootstrap( + {".github/workflows/x.yml": f" - uses: {action}\n"} + ) + self.assertTrue(errors, f"{action} should be caught") def test_live_workflows_hold_the_contract(self): workflows = ws12_workflow_contracts.load_workflows(ROOT) diff --git a/scripts/ci/ws12_workflow_contracts.py b/scripts/ci/ws12_workflow_contracts.py index 9ab55a049a9..eb4fd2de632 100755 --- a/scripts/ci/ws12_workflow_contracts.py +++ b/scripts/ci/ws12_workflow_contracts.py @@ -1158,6 +1158,8 @@ def validate_no_direct_dtolnay_usage(workflows: dict[str, str]) -> list[str]: SETUP_RUST_USES = "uses: ./.github/actions/setup-rust" +JOBS_KEY = re.compile(r"^jobs:[ \t]*$", re.MULTILINE) +ANCHOR_DEF = re.compile(r"^\s*-\s*&(?P[\w-]+)\s*$", re.MULTILINE) # Job-level `env:` (jobs..env, two-space job + four-space `env:` + this # key at six spaces) and workflow-level top `env:` (two-space key straight # under the file's own `env:`) are both re-applied to every step of a job on @@ -1189,8 +1191,27 @@ def validate_no_job_env_rustflags_with_setup_rust( for path, text in workflows.items(): if SETUP_RUST_USES not in text: continue - headings = list(JOB_HEADING.finditer(text)) - preamble = text[: headings[0].start()] if headings else text + # Bound job headings to the `jobs:` block. JOB_HEADING matches ANY + # two-space `key:` line, so the `on:` trigger's children (`push:`, + # `workflow_call:`, ...) match too — taking headings[0] blindly + # truncated the preamble at the first trigger and made the + # workflow-level check dead code on every real workflow. + jobs_key = JOBS_KEY.search(text) + jobs_start = jobs_key.start() if jobs_key else 0 + headings = [h for h in JOB_HEADING.finditer(text) if h.start() >= jobs_start] + # A job can reach the composite through a YAML alias (`- *install-rust`) + # instead of the literal `uses:` line, as release-plz.yml does. Resolve + # which anchors carry the composite so an aliased job is still checked. + rust_anchors = set() + for anchor in ANCHOR_DEF.finditer(text): + following = ANCHOR_DEF.search(text, anchor.end()) + stop = following.start() if following else len(text) + next_job = JOB_HEADING.search(text, anchor.end()) + if next_job and next_job.start() < stop: + stop = next_job.start() + if SETUP_RUST_USES in text[anchor.start():stop]: + rust_anchors.add(anchor.group("name")) + preamble = text[:jobs_start] if jobs_key else text if WORKFLOW_ENV_RUSTFLAGS.search(preamble): errors.append( f"{path}: workflow-level env sets a RUSTFLAGS key while a " @@ -1208,7 +1229,10 @@ def validate_no_job_env_rustflags_with_setup_rust( else len(text) ) block = text[start:end] - if SETUP_RUST_USES not in block: + uses_composite = SETUP_RUST_USES in block or any( + f"*{anchor}" in block for anchor in rust_anchors + ) + if not uses_composite: continue if JOB_ENV_RUSTFLAGS.search(block): errors.append( @@ -1222,23 +1246,32 @@ def validate_no_job_env_rustflags_with_setup_rust( return errors -# One `:` entry under an action's `inputs:` block — the same two-space, -# name-then-colon-then-nothing-else shape JOB_HEADING matches for workflow -# jobs, but kept as its own pattern: action.yml has no `jobs:` section, and -# reusing JOB_HEADING here would conflate the two headings the moment either -# one's shape changes for its own reason. RUST_BOOTSTRAP_PATTERNS = ( + # Raw rustup bootstraps. "sh.rustup.rs", "rustup-init", "rustup toolchain install", + # Third-party toolchain actions. The composite is the only sanctioned + # installer, so a workflow reaching for a different vendor action is the + # same drift as a curl bootstrap: unpinned toolchain, no mold, no + # rust-toolchain.toml sync. (`dtolnay/rust-toolchain` has its own check + # with a more specific message; it is deliberately not repeated here.) + "actions-rs/toolchain", + "actions-rust-lang/setup-rust-toolchain", + "hecrj/setup-rust-action", + "raftario/setup-rust-action", ) -# cargo-dist regenerates .github/workflows/ironclaw-release.yml wholesale -# (see [workspace.metadata.dist], cargo-dist-version 0.31.0), so its -# container-only bootstrap cannot be migrated onto the composite without -# being clobbered on the next regeneration. It is therefore an ACCEPTED -# exception, pinned to one occurrence: a second bootstrap appearing there, -# or any bootstrap in a hand-written workflow, fails this gate. -ACCEPTED_RUST_BOOTSTRAPS = {".github/workflows/ironclaw-release.yml": 1} +# Residual risk, named rather than papered over: a job whose `container:` +# image ships Rust preinstalled installs nothing, so no text pattern can see +# it. Such a job would silently build on the image's toolchain instead of the +# pin. Nothing in .github/workflows does this today; if one appears it needs a +# structural check (assert every Rust-building job calls the composite), not a +# wider substring list. +# No workflow may bootstrap Rust outside the composite. cargo-dist +# re-includes .github/dist-build-setup.yml on every regeneration, so the +# release build jobs install Rust through the composite from there — there +# is no lane this contract cannot cover. +ACCEPTED_RUST_BOOTSTRAPS: dict[str, int] = {} def validate_no_unmanaged_rust_bootstrap(workflows: dict[str, str]) -> list[str]: From ff4b4c68bc4d2c9d484d51e49f25bee191255ab7 Mon Sep 17 00:00:00 2001 From: Henry Park <16583448+henrypark133@users.noreply.github.com> Date: Mon, 24 Aug 2026 04:25:24 +0000 Subject: [PATCH 30/44] fix(ci): catch step-level RUSTFLAGS, not just job- and workflow-level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third gap the review found in the same guard. JOB_ENV_RUSTFLAGS matched the exact 6-space job-env depth, but GitHub also lets an individual step declare `env:` (10 spaces in this repo's workflows, e.g. reborn-tests.yml:255), and a step-level RUSTFLAGS shadows the composite's $GITHUB_ENV write for that step exactly like a job-level one — dropping the mold linker flags with no failing check, which is the precise regression the guard exists to prevent. Widened to `^ {6,}RUSTFLAGS:` so job- and step-level depths are both caught, and corrected the error message, which called a step-level key "job-level". Proven against the live file: injecting a step-level RUSTFLAGS into reborn-tests.yml's crate-tests job is now reported; the clean tree still passes, so the wider pattern adds no false positives. Worth stating plainly: this is the third hole found in this one substring-matching guard (dead workflow-level check, alias blindness, now step-level depth). Enforcement by grepping YAML text is structurally weaker than the composite's own RUSTUP_TOOLCHAIN export, which makes drift unrepresentable rather than policed. The stdlib-only constraint on scripts/ci rules out a real YAML parse here, so the residual risk is the shapes no pattern anticipated — noted rather than claimed away. Verified: ws12 self-tests 124 OK, ws12 live gate passed, planner suite 88 OK. Co-Authored-By: Claude Fable 5 --- scripts/ci/test_ws12_workflow_contracts.py | 25 ++++++++++++++++++++++ scripts/ci/ws12_workflow_contracts.py | 10 ++++++--- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/scripts/ci/test_ws12_workflow_contracts.py b/scripts/ci/test_ws12_workflow_contracts.py index 50953e28476..6301d7c884d 100755 --- a/scripts/ci/test_ws12_workflow_contracts.py +++ b/scripts/ci/test_ws12_workflow_contracts.py @@ -303,6 +303,31 @@ def test_a_job_reaching_the_composite_by_yaml_alias_is_still_checked(self): f"alias-reached job must be checked: {errors}", ) + def test_step_level_rustflags_is_also_caught(self): + """A step's own `env:` shadows the composite just like a job's. + + The pattern pinned the exact 6-space job-env depth, so a step-level + key (10 spaces in this repo's workflows) slipped through — the same + silent mold-drop the guard exists to prevent. + """ + workflows = { + ".github/workflows/x.yml": ( + "jobs:\n" + " build:\n" + " steps:\n" + " - uses: ./.github/actions/setup-rust\n" + " - name: Compile\n" + " env:\n" + ' RUSTFLAGS: "-Zcrate-attr=x"\n' + " run: cargo build\n" + ) + } + errors = validate_no_job_env_rustflags_with_setup_rust(workflows) + self.assertTrue( + any("'build'" in e for e in errors), + f"step-level RUSTFLAGS must be caught: {errors}", + ) + def test_live_workflows_are_clean(self): workflows = ws12_workflow_contracts.load_workflows(ROOT) self.assertEqual( diff --git a/scripts/ci/ws12_workflow_contracts.py b/scripts/ci/ws12_workflow_contracts.py index eb4fd2de632..f65779d7c93 100755 --- a/scripts/ci/ws12_workflow_contracts.py +++ b/scripts/ci/ws12_workflow_contracts.py @@ -1167,7 +1167,11 @@ def validate_no_direct_dtolnay_usage(workflows: dict[str, str]) -> list[str]: # way. They need separate patterns, not one merged indentation class: the # workflow-level form sits in the file's preamble, before any job heading, so # it is checked once per file rather than by slicing per-job blocks. -JOB_ENV_RUSTFLAGS = re.compile(r"^ {6}RUSTFLAGS:", re.MULTILINE) +# Any RUSTFLAGS key inside a job, at job-env depth (6 spaces) or +# step-env depth (10) — a step-level env shadows the composite's +# $GITHUB_ENV write for that step exactly like a job-level one, so +# pinning the exact job depth left a third of the shapes unguarded. +JOB_ENV_RUSTFLAGS = re.compile(r"^ {6,}RUSTFLAGS:", re.MULTILINE) WORKFLOW_ENV_RUSTFLAGS = re.compile(r"^ {2}RUSTFLAGS:", re.MULTILINE) @@ -1236,8 +1240,8 @@ def validate_no_job_env_rustflags_with_setup_rust( continue if JOB_ENV_RUSTFLAGS.search(block): errors.append( - f"{path}: job {heading.group('name')!r} sets a job-level " - "RUSTFLAGS env key while installing Rust through " + f"{path}: job {heading.group('name')!r} sets a job- or " + "step-level RUSTFLAGS env key while installing Rust through " ".github/actions/setup-rust — job env shadows the " "composite's $GITHUB_ENV write and drops the mold linker " "flags; pass them as the composite's extra_rustflags " From edaf9655da41b2f7d3f7b7a6c9e62e3d78cd5252 Mon Sep 17 00:00:00 2001 From: Henry Park <16583448+henrypark133@users.noreply.github.com> Date: Mon, 24 Aug 2026 04:43:36 +0000 Subject: [PATCH 31/44] fix(ci): restore the release lane's Rust install and assert it is there MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit broke the release lane. It deleted the container-only `curl | sh` bootstrap from .github/workflows/ironclaw-release.yml and added the composite call only to .github/dist-build-setup.yml — but that fragment reaches the generated workflow solely through `dist generate`, which was never run. The checked-in workflow ended up with NO Rust install path at all: `grep -c 'setup-rust|Install Rust|rustup' ironclaw-release.yml` → 0. The next tagged release would have failed on `cargo: command not found` in every container matrix entry — the exact case the deleted step existed for. Two independent code-review lanes caught it (Critical/confidence 100 and High/confidence 92), both noting the same root cause: every check in this file asserts an ABSENCE — no dtolnay, no raw bootstrap, no shadowing RUSTFLAGS — so deleting a step and adding nothing read as "clean" and the whole suite stayed green over a broken release lane. The step is now in both places, deliberately: the fragment so `dist generate` keeps emitting it, and the checked-in workflow because that is the file GitHub actually runs. validate_release_workflow_installs_rust closes the class by asserting PRESENCE in both files. Proven red-first in both directions: removing the step from the generated workflow fails with the cargo-not-found rationale; removing it from the fragment fails with the regeneration rationale; restoring either passes. Four unit tests plus a live-tree assertion. This is the second time in this PR that an absence-only guard reported success over a real defect. Guards that only forbid shapes cannot notice that the thing they were protecting is gone. Verified: ws12 self-tests 128 OK, ws12 live gate passed, planner suite 88 OK, ironclaw-release.yml parses. Co-Authored-By: Claude Fable 5 --- .github/workflows/ironclaw-release.yml | 10 ++++ scripts/ci/test_ws12_workflow_contracts.py | 64 ++++++++++++++++++++++ scripts/ci/ws12_workflow_contracts.py | 46 ++++++++++++++++ 3 files changed, 120 insertions(+) diff --git a/.github/workflows/ironclaw-release.yml b/.github/workflows/ironclaw-release.yml index 8b1d5cbeee3..4d9fa284fa0 100644 --- a/.github/workflows/ironclaw-release.yml +++ b/.github/workflows/ironclaw-release.yml @@ -126,6 +126,16 @@ jobs: uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 with: python-version: "3.12" + - name: Install Rust through the repo's single setup path + # Mirrors .github/dist-build-setup.yml, which cargo-dist re-inlines + # here on `dist generate`. Both must carry it: the fragment so a + # regeneration keeps it, this file because the checked-in workflow is + # what actually runs. validate_release_workflow_installs_rust asserts + # the presence here, since the absence-only bootstrap checks happily + # passed a version of this file with no Rust install at all. + if: ${{ matrix.container }} + uses: ./.github/actions/setup-rust + - name: "Install Node.js for the embedded WebUI" uses: "actions/setup-node@820762786026740c76f36085b0efc47a31fe5020" with: diff --git a/scripts/ci/test_ws12_workflow_contracts.py b/scripts/ci/test_ws12_workflow_contracts.py index 6301d7c884d..469cb0bc733 100755 --- a/scripts/ci/test_ws12_workflow_contracts.py +++ b/scripts/ci/test_ws12_workflow_contracts.py @@ -11,6 +11,7 @@ import ws12_workflow_contracts from ws12_workflow_contracts import ( + validate_release_workflow_installs_rust, validate_no_unmanaged_rust_bootstrap, CODE_STYLE_WORKFLOW, CRATE_NAME_RESIDUE, @@ -403,6 +404,69 @@ def test_live_workflows_hold_the_contract(self): self.assertEqual([], validate_no_unmanaged_rust_bootstrap(workflows)) + +class ReleaseWorkflowInstallsRustTests(unittest.TestCase): + """The release lane must REACH the composite, not merely lack a bootstrap. + + Every sibling check asserts an absence. That let a release workflow with + no Rust install at all pass the entire suite: the old `curl | sh` step was + deleted and its replacement went only into the cargo-dist fragment, which + reaches the generated file solely via `dist generate`. Container builds + would have died on `cargo: command not found`. + """ + + STEP = "uses: ./.github/actions/setup-rust" + + def test_missing_step_in_the_generated_workflow_fails(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / ".github").mkdir() + (root / ".github" / "dist-build-setup.yml").write_text( + f"- {self.STEP}\n", encoding="utf-8" + ) + errors = validate_release_workflow_installs_rust( + {".github/workflows/ironclaw-release.yml": "jobs:\n build:\n"}, + root, + ) + self.assertTrue( + any("ironclaw-release.yml" in e for e in errors), errors + ) + + def test_missing_step_in_the_fragment_fails(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / ".github").mkdir() + (root / ".github" / "dist-build-setup.yml").write_text( + "- name: Something else\n", encoding="utf-8" + ) + errors = validate_release_workflow_installs_rust( + {".github/workflows/ironclaw-release.yml": f" - {self.STEP}\n"}, + root, + ) + self.assertTrue( + any("dist-build-setup.yml" in e for e in errors), errors + ) + + def test_both_present_passes(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / ".github").mkdir() + (root / ".github" / "dist-build-setup.yml").write_text( + f"- {self.STEP}\n", encoding="utf-8" + ) + self.assertEqual( + [], + validate_release_workflow_installs_rust( + {".github/workflows/ironclaw-release.yml": f" - {self.STEP}\n"}, + root, + ), + ) + + def test_the_live_release_lane_reaches_the_composite(self): + workflows = ws12_workflow_contracts.load_workflows(ROOT) + self.assertEqual([], validate_release_workflow_installs_rust(workflows, ROOT)) + + class ToolchainPinSyncTests(unittest.TestCase): """rust-toolchain.toml and the composite's default must name one version.""" diff --git a/scripts/ci/ws12_workflow_contracts.py b/scripts/ci/ws12_workflow_contracts.py index f65779d7c93..0f05862ce98 100755 --- a/scripts/ci/ws12_workflow_contracts.py +++ b/scripts/ci/ws12_workflow_contracts.py @@ -1317,6 +1317,51 @@ def validate_no_unmanaged_rust_bootstrap(workflows: dict[str, str]) -> list[str] return errors +RELEASE_WORKFLOW = ".github/workflows/ironclaw-release.yml" +DIST_BUILD_SETUP = ".github/dist-build-setup.yml" + + +def validate_release_workflow_installs_rust( + workflows: dict[str, str], root: Path = ROOT +) -> list[str]: + """The release lane must actually REACH the composite, not merely lack a bootstrap. + + Every other check here asserts an absence — no dtolnay, no raw bootstrap, + no shadowing RUSTFLAGS. Absence-only checks called a release workflow with + NO Rust install whatsoever "clean": removing the old `curl | sh` step + without adding anything passed the whole suite, and a container build + would have died on `cargo: command not found`. + + ironclaw-release.yml is generated by cargo-dist from the fragment at + .github/dist-build-setup.yml, so the step has to exist in BOTH: the + fragment so `dist generate` keeps emitting it, and the checked-in + workflow because that is the file GitHub actually runs. + """ + + errors: list[str] = [] + release = workflows.get(RELEASE_WORKFLOW) + if release is not None and SETUP_RUST_USES not in release: + errors.append( + f"{RELEASE_WORKFLOW}: no `{SETUP_RUST_USES}` step. The container " + "build jobs have no other Rust install path, so this lane would " + "fail with 'cargo: command not found'. Re-run `dist generate` or " + f"re-inline the step from {DIST_BUILD_SETUP}." + ) + try: + fragment = (root / DIST_BUILD_SETUP).read_text(encoding="utf-8") + except OSError as error: + errors.append(f"{DIST_BUILD_SETUP}: could not read fragment: {error}") + else: + if SETUP_RUST_USES not in fragment: + errors.append( + f"{DIST_BUILD_SETUP}: no `{SETUP_RUST_USES}` step. cargo-dist " + "re-inlines this fragment on regeneration, so dropping it here " + "silently removes the release lane's Rust install the next time " + "the workflow is regenerated." + ) + return errors + + def validate_toolchain_pin_sync(root: Path = ROOT) -> list[str]: """rust-toolchain.toml and the composite's default must name one version.""" try: @@ -2078,6 +2123,7 @@ def validate_workflow_texts( errors.extend(validate_setup_rust_action(setup_rust_action)) errors.extend(validate_no_direct_dtolnay_usage(workflows)) errors.extend(validate_no_unmanaged_rust_bootstrap(workflows)) + errors.extend(validate_release_workflow_installs_rust(workflows, root)) errors.extend(validate_no_job_env_rustflags_with_setup_rust(workflows)) errors.extend(validate_toolchain_pin_sync(root)) return errors From 4cd3f1028317615ea49fbaf84e9bbdb61530701c Mon Sep 17 00:00:00 2001 From: Henry Park <16583448+henrypark133@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:27:29 +0000 Subject: [PATCH 32/44] refactor(ci): split the Rust toolchain contracts out of ws12_workflow_contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure move, no behavior change. ws12_workflow_contracts.py had grown to 2,171 lines — past the repo's ~1k ceiling — mixing stress-suite parity, crate scope filters, WebUI site checks, and this PR's six toolchain validators in one file. Three separate reviews flagged it (two approach audits, one design lane). scripts/ci/lib/rust_toolchain_contracts.py now owns one question: what toolchain, linker, and build flags does a Rust job get? Six validators, their constants, and a module docstring recording the absence-vs-presence asymmetry that let a release workflow with no Rust install pass this suite last week. scripts/ci/lib/workflow_text.py owns the four generic YAML text helpers both modules need. This absorbed a latent defect: JOB_HEADING was defined TWICE in ws12 (lines 426 and 988), the second shadowing the first for every validator below it. The two patterns were `[A-Za-z0-9_-]` and `[a-zA-Z0-9_-]` — semantically identical, so nothing was wrong today, but only by luck. One definition now. The test file imports each symbol from its owning module rather than letting ws12 act as an implicit re-export, so the ownership is checkable. classify-test-scope.sh: both new paths join the reborn-scoped list. Without this the split would have silently changed CI behavior — editing a validator in its new home would classify differently than editing it in ws12 did — which is exactly the kind of quiet regression a "pure move" is supposed to not have. test-classify-test-scope.sh pins it; proven red-first by reverting the list entry and watching the new assertion fail. Verified after the move, both validators proven to still fire from their new home by sabotage: breaking the RUSTUP_TOOLCHAIN export fails the gate, and deleting the release workflow's composite step fails it with the cargo-not-found rationale. ws12 self-tests 128 OK, live gate passed, classify-test-scope 69 PASS / 0 FAIL, check-guidance OK. ws12_workflow_contracts.py: 2171 -> 1833 lines. Co-Authored-By: Claude Fable 5 --- scripts/ci/classify-test-scope.sh | 2 +- scripts/ci/lib/rust_toolchain_contracts.py | 348 ++++++++++++++++++++ scripts/ci/lib/workflow_text.py | 47 +++ scripts/ci/test-classify-test-scope.sh | 9 + scripts/ci/test_ws12_workflow_contracts.py | 28 +- scripts/ci/ws12_workflow_contracts.py | 360 +-------------------- 6 files changed, 432 insertions(+), 362 deletions(-) create mode 100644 scripts/ci/lib/rust_toolchain_contracts.py create mode 100644 scripts/ci/lib/workflow_text.py diff --git a/scripts/ci/classify-test-scope.sh b/scripts/ci/classify-test-scope.sh index 1c016f670e7..60b7848508f 100755 --- a/scripts/ci/classify-test-scope.sh +++ b/scripts/ci/classify-test-scope.sh @@ -372,7 +372,7 @@ is_reborn_test_path() { crates/ironclaw_conversations/*|crates/ironclaw_extension_host/*|crates/ironclaw_extension_manager/*|crates/ironclaw_outbound/*|crates/ironclaw_assistant/*|crates/ironclaw_triggers/*) return 0 ;; - scripts/ci/reborn-coverage-*.sh|scripts/ci/test-reborn-coverage.sh|scripts/ci/test-reborn-coverage-*.sh|scripts/ci/reborn_changed_coverage.py|scripts/ci/test_reborn_changed_coverage.py|scripts/ci/critical_mutation_gate.py|scripts/ci/test-critical-mutation-gate.sh|scripts/ci/check-reborn-branch-coverage-flags.py|scripts/ci/test-check-reborn-branch-coverage-flags.sh|scripts/ci/check-reborn-qa-fixtures.sh|scripts/ci/test-check-reborn-qa-fixtures.sh|scripts/ci/lib/reborn_coverage_lcov.py|scripts/ci/reborn-crate-test-buckets.sh|scripts/ci/test-reborn-crate-test-buckets.sh|scripts/ci/ws12-suite-shards.toml|scripts/ci/ws12_suite_shards.py|scripts/ci/test_ws12_suite_shards.py|scripts/ci/ws12_workflow_contracts.py|scripts/ci/test_ws12_workflow_contracts.py|scripts/ci/check-test-suite-boundaries.sh|scripts/ci/classify-test-scope.sh|scripts/ci/test-classify-test-scope.sh) + scripts/ci/reborn-coverage-*.sh|scripts/ci/test-reborn-coverage.sh|scripts/ci/test-reborn-coverage-*.sh|scripts/ci/reborn_changed_coverage.py|scripts/ci/test_reborn_changed_coverage.py|scripts/ci/critical_mutation_gate.py|scripts/ci/test-critical-mutation-gate.sh|scripts/ci/check-reborn-branch-coverage-flags.py|scripts/ci/test-check-reborn-branch-coverage-flags.sh|scripts/ci/check-reborn-qa-fixtures.sh|scripts/ci/test-check-reborn-qa-fixtures.sh|scripts/ci/lib/reborn_coverage_lcov.py|scripts/ci/reborn-crate-test-buckets.sh|scripts/ci/test-reborn-crate-test-buckets.sh|scripts/ci/ws12-suite-shards.toml|scripts/ci/ws12_suite_shards.py|scripts/ci/test_ws12_suite_shards.py|scripts/ci/ws12_workflow_contracts.py|scripts/ci/test_ws12_workflow_contracts.py|scripts/ci/lib/rust_toolchain_contracts.py|scripts/ci/lib/workflow_text.py|scripts/ci/check-test-suite-boundaries.sh|scripts/ci/classify-test-scope.sh|scripts/ci/test-classify-test-scope.sh) return 0 ;; *) diff --git a/scripts/ci/lib/rust_toolchain_contracts.py b/scripts/ci/lib/rust_toolchain_contracts.py new file mode 100644 index 00000000000..588c342abcd --- /dev/null +++ b/scripts/ci/lib/rust_toolchain_contracts.py @@ -0,0 +1,348 @@ +#!/usr/bin/env python3 +"""Contracts for the single Rust toolchain setup path. + +One question, one module: *what toolchain, linker, and build flags does a +Rust job get?* The answer is `.github/actions/setup-rust` and nothing else, +and these six validators are what make that true rather than aspirational. + +Split out of `ws12_workflow_contracts.py`, which had grown past 2,100 lines +covering unrelated lanes (stress suites, crate scope filters, WebUI sites). +Wiring stays in that module's `validate_workflow_texts`; only the +toolchain-shaped rules live here. + +Note which direction each check runs. Five assert an ABSENCE — no dtolnay, +no raw bootstrap, no shadowing RUSTFLAGS. Absence-only guards cannot notice +that the thing they protect has been deleted, which is exactly how a release +workflow with no Rust install at all once passed this suite. That is what +`validate_release_workflow_installs_rust` is for; keep the pair in mind +before adding a seventh. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +from workflow_text import JOB_HEADING, job_body, step_body + +ROOT = Path(__file__).resolve().parents[3] + +SETUP_RUST_ACTION = ".github/actions/setup-rust/action.yml" +RUSTUP_TOOLCHAIN_PIN_STEP = "Pin the resolved toolchain for the rest of this job" +MOLD_INSTALL_STEP = "Install mold and clang" +MOLD_VERIFY_STEP = "Verify mold linker is active" +MOLD_EXPORT_STEP = "Export mold RUSTFLAGS" +# The one canonical mold invocation; a job's own env may append extra flags +# after it (the nightly lanes add -Zcrate-attr=...), but this prefix is the +# only place it may be written out — everywhere else must go through here. +MOLD_RUSTFLAGS = "-C linker=clang -C link-arg=--ld-path=/usr/bin/mold" + + +def validate_setup_rust_action(text: str | None) -> list[str]: + """The setup-rust composite must actually pin RUSTUP_TOOLCHAIN and mold. + + Contract: every job that installs Rust through this composite gets a + RUSTUP_TOOLCHAIN export naming exactly the toolchain dtolnay/rust-toolchain + just installed (steps.install.outputs.name — not a second, possibly wrong, + guess at the resolved version), and mold's install/verify/RUSTFLAGS steps + are gated on Linux so `mold: true` is safe to pass on any runner OS. + """ + if text is None: + return [f"{SETUP_RUST_ACTION}: could not read the composite action file"] + errors: list[str] = [] + pin_step = step_body(text, RUSTUP_TOOLCHAIN_PIN_STEP) + if pin_step is None: + errors.append( + f"{SETUP_RUST_ACTION}: missing the {RUSTUP_TOOLCHAIN_PIN_STEP!r} step" + ) + elif "RUSTUP_TOOLCHAIN=${{ steps.install.outputs.name }}" not in pin_step: + errors.append( + f"{SETUP_RUST_ACTION}: {RUSTUP_TOOLCHAIN_PIN_STEP!r} must export " + "RUSTUP_TOOLCHAIN from steps.install.outputs.name, or a job's " + "cargo invocations can drift from what this step actually installed" + ) + for step_name in (MOLD_INSTALL_STEP, MOLD_VERIFY_STEP, MOLD_EXPORT_STEP): + body = step_body(text, step_name) + if body is None: + errors.append(f"{SETUP_RUST_ACTION}: missing the {step_name!r} step") + continue + if "runner.os == 'Linux'" not in body: + errors.append( + f"{SETUP_RUST_ACTION}: {step_name!r} must gate on " + "runner.os == 'Linux' so mold: true is safe on any runner" + ) + export_step = step_body(text, MOLD_EXPORT_STEP) + if export_step is not None and MOLD_RUSTFLAGS not in export_step: + errors.append( + f"{SETUP_RUST_ACTION}: {MOLD_EXPORT_STEP!r} must export the " + f"canonical mold RUSTFLAGS prefix '{MOLD_RUSTFLAGS}'" + ) + return errors + + +DTOLNAY_ACTION = "dtolnay/rust-toolchain@" + + +def validate_no_direct_dtolnay_usage(workflows: dict[str, str]) -> list[str]: + """Every workflow must install Rust through .github/actions/setup-rust. + + A negative substring check, not a per-site window scan: this is what + T1's earlier per-input-drift design collapsed to once every job routes + through one composite. Also forbids re-writing out the canonical mold + RUSTFLAGS prefix by hand anywhere a workflow's own env block might set + it — the composite is the only place that string may appear. + """ + errors: list[str] = [] + for path, text in workflows.items(): + if DTOLNAY_ACTION in text: + errors.append( + f"{path}: calls {DTOLNAY_ACTION!r} directly — install Rust " + "through .github/actions/setup-rust instead" + ) + if MOLD_RUSTFLAGS in text: + errors.append( + f"{path}: writes out the canonical mold RUSTFLAGS prefix " + f"'{MOLD_RUSTFLAGS}' directly — pass mold: true to " + ".github/actions/setup-rust instead, which prepends it onto " + "this job's existing RUSTFLAGS" + ) + return errors + + +SETUP_RUST_USES = "uses: ./.github/actions/setup-rust" +JOBS_KEY = re.compile(r"^jobs:[ \t]*$", re.MULTILINE) +ANCHOR_DEF = re.compile(r"^\s*-\s*&(?P[\w-]+)\s*$", re.MULTILINE) +# Job-level `env:` (jobs..env, two-space job + four-space `env:` + this +# key at six spaces) and workflow-level top `env:` (two-space key straight +# under the file's own `env:`) are both re-applied to every step of a job on +# top of $GITHUB_ENV, so both shadow the composite's mold export the same +# way. They need separate patterns, not one merged indentation class: the +# workflow-level form sits in the file's preamble, before any job heading, so +# it is checked once per file rather than by slicing per-job blocks. +# Any RUSTFLAGS key inside a job, at job-env depth (6 spaces) or +# step-env depth (10) — a step-level env shadows the composite's +# $GITHUB_ENV write for that step exactly like a job-level one, so +# pinning the exact job depth left a third of the shapes unguarded. +JOB_ENV_RUSTFLAGS = re.compile(r"^ {6,}RUSTFLAGS:", re.MULTILINE) +WORKFLOW_ENV_RUSTFLAGS = re.compile(r"^ {2}RUSTFLAGS:", re.MULTILINE) + + +def validate_no_job_env_rustflags_with_setup_rust( + workflows: dict[str, str], +) -> list[str]: + """A job installing Rust via the composite must not set its own RUSTFLAGS. + + GitHub re-applies a job-level `env:` mapping to every step of that job, + on top of whatever earlier steps wrote to $GITHUB_ENV. So a job-level + `RUSTFLAGS:` shadows the composite's export for the rest of the job and + silently drops the mold linker flags — a slower build, never a red + check, which is exactly the drift class this action exists to remove. A + workflow-level top `env:` key shadows every job in the file identically, + so it is checked the same way. Jobs pass their extra flags through the + composite's `extra_rustflags` input instead, so one place composes the + final value. + """ + + errors: list[str] = [] + for path, text in workflows.items(): + if SETUP_RUST_USES not in text: + continue + # Bound job headings to the `jobs:` block. JOB_HEADING matches ANY + # two-space `key:` line, so the `on:` trigger's children (`push:`, + # `workflow_call:`, ...) match too — taking headings[0] blindly + # truncated the preamble at the first trigger and made the + # workflow-level check dead code on every real workflow. + jobs_key = JOBS_KEY.search(text) + jobs_start = jobs_key.start() if jobs_key else 0 + headings = [h for h in JOB_HEADING.finditer(text) if h.start() >= jobs_start] + # A job can reach the composite through a YAML alias (`- *install-rust`) + # instead of the literal `uses:` line, as release-plz.yml does. Resolve + # which anchors carry the composite so an aliased job is still checked. + rust_anchors = set() + for anchor in ANCHOR_DEF.finditer(text): + following = ANCHOR_DEF.search(text, anchor.end()) + stop = following.start() if following else len(text) + next_job = JOB_HEADING.search(text, anchor.end()) + if next_job and next_job.start() < stop: + stop = next_job.start() + if SETUP_RUST_USES in text[anchor.start():stop]: + rust_anchors.add(anchor.group("name")) + preamble = text[:jobs_start] if jobs_key else text + if WORKFLOW_ENV_RUSTFLAGS.search(preamble): + errors.append( + f"{path}: workflow-level env sets a RUSTFLAGS key while a " + "job in this file installs Rust through " + ".github/actions/setup-rust — a workflow-level env key " + "shadows the composite's $GITHUB_ENV write for every job in " + "the file identically to a job-level one; pass extra flags " + "as the composite's extra_rustflags input instead" + ) + for index, heading in enumerate(headings): + start = heading.start() + end = ( + headings[index + 1].start() + if index + 1 < len(headings) + else len(text) + ) + block = text[start:end] + uses_composite = SETUP_RUST_USES in block or any( + f"*{anchor}" in block for anchor in rust_anchors + ) + if not uses_composite: + continue + if JOB_ENV_RUSTFLAGS.search(block): + errors.append( + f"{path}: job {heading.group('name')!r} sets a job- or " + "step-level RUSTFLAGS env key while installing Rust through " + ".github/actions/setup-rust — job env shadows the " + "composite's $GITHUB_ENV write and drops the mold linker " + "flags; pass them as the composite's extra_rustflags " + "input instead" + ) + return errors + + +RUST_BOOTSTRAP_PATTERNS = ( + # Raw rustup bootstraps. + "sh.rustup.rs", + "rustup-init", + "rustup toolchain install", + # Third-party toolchain actions. The composite is the only sanctioned + # installer, so a workflow reaching for a different vendor action is the + # same drift as a curl bootstrap: unpinned toolchain, no mold, no + # rust-toolchain.toml sync. (`dtolnay/rust-toolchain` has its own check + # with a more specific message; it is deliberately not repeated here.) + "actions-rs/toolchain", + "actions-rust-lang/setup-rust-toolchain", + "hecrj/setup-rust-action", + "raftario/setup-rust-action", +) +# Residual risk, named rather than papered over: a job whose `container:` +# image ships Rust preinstalled installs nothing, so no text pattern can see +# it. Such a job would silently build on the image's toolchain instead of the +# pin. Nothing in .github/workflows does this today; if one appears it needs a +# structural check (assert every Rust-building job calls the composite), not a +# wider substring list. +# No workflow may bootstrap Rust outside the composite. cargo-dist +# re-includes .github/dist-build-setup.yml on every regeneration, so the +# release build jobs install Rust through the composite from there — there +# is no lane this contract cannot cover. +ACCEPTED_RUST_BOOTSTRAPS: dict[str, int] = {} + + +def validate_no_unmanaged_rust_bootstrap(workflows: dict[str, str]) -> list[str]: + """Every hand-written workflow installs Rust through the composite. + + `validate_no_direct_dtolnay_usage` only sees the vendor action. A raw + `curl https://sh.rustup.rs | sh` installs Rust just as effectively and + matches no such string, so it would otherwise pass this gate forever -- + unpinned, without mold, and unchecked against rust-toolchain.toml. + """ + + errors: list[str] = [] + for path, text in workflows.items(): + hits = sum(text.count(pattern) for pattern in RUST_BOOTSTRAP_PATTERNS) + if not hits: + continue + allowed = ACCEPTED_RUST_BOOTSTRAPS.get(path, 0) + if hits > allowed: + errors.append( + f"{path}: {hits} raw Rust bootstrap(s) " + f"({', '.join(RUST_BOOTSTRAP_PATTERNS)}); " + f"{allowed} accepted here. Install Rust through " + ".github/actions/setup-rust so the toolchain stays pinned, " + "mold stays wired, and rust-toolchain.toml stays enforced. " + "A genuinely unavoidable bootstrap must be added to " + "ACCEPTED_RUST_BOOTSTRAPS with the reason." + ) + for path, expected in ACCEPTED_RUST_BOOTSTRAPS.items(): + text = workflows.get(path) + if text is None: + continue + hits = sum(text.count(pattern) for pattern in RUST_BOOTSTRAP_PATTERNS) + if hits < expected: + errors.append( + f"{path}: expected {expected} accepted Rust bootstrap(s), " + f"found {hits}. If the generator stopped emitting it, drop " + "the entry from ACCEPTED_RUST_BOOTSTRAPS." + ) + return errors + + +RELEASE_WORKFLOW = ".github/workflows/ironclaw-release.yml" +DIST_BUILD_SETUP = ".github/dist-build-setup.yml" + + +def validate_release_workflow_installs_rust( + workflows: dict[str, str], root: Path = ROOT +) -> list[str]: + """The release lane must actually REACH the composite, not merely lack a bootstrap. + + Every other check here asserts an absence — no dtolnay, no raw bootstrap, + no shadowing RUSTFLAGS. Absence-only checks called a release workflow with + NO Rust install whatsoever "clean": removing the old `curl | sh` step + without adding anything passed the whole suite, and a container build + would have died on `cargo: command not found`. + + ironclaw-release.yml is generated by cargo-dist from the fragment at + .github/dist-build-setup.yml, so the step has to exist in BOTH: the + fragment so `dist generate` keeps emitting it, and the checked-in + workflow because that is the file GitHub actually runs. + """ + + errors: list[str] = [] + release = workflows.get(RELEASE_WORKFLOW) + if release is not None and SETUP_RUST_USES not in release: + errors.append( + f"{RELEASE_WORKFLOW}: no `{SETUP_RUST_USES}` step. The container " + "build jobs have no other Rust install path, so this lane would " + "fail with 'cargo: command not found'. Re-run `dist generate` or " + f"re-inline the step from {DIST_BUILD_SETUP}." + ) + try: + fragment = (root / DIST_BUILD_SETUP).read_text(encoding="utf-8") + except OSError as error: + errors.append(f"{DIST_BUILD_SETUP}: could not read fragment: {error}") + else: + if SETUP_RUST_USES not in fragment: + errors.append( + f"{DIST_BUILD_SETUP}: no `{SETUP_RUST_USES}` step. cargo-dist " + "re-inlines this fragment on regeneration, so dropping it here " + "silently removes the release lane's Rust install the next time " + "the workflow is regenerated." + ) + return errors + + +def validate_toolchain_pin_sync(root: Path = ROOT) -> list[str]: + """rust-toolchain.toml and the composite's default must name one version.""" + try: + file_text = (root / "rust-toolchain.toml").read_text(encoding="utf-8") + except OSError: + return ["rust-toolchain.toml: missing (single source of truth for the CI toolchain)"] + channel_match = re.search(r'^channel = "(\d+\.\d+\.\d+)"$', file_text, re.MULTILINE) + if channel_match is None: + return ['rust-toolchain.toml: channel must be an exact stable version ("X.Y.Z")'] + channel = channel_match.group(1) + try: + action_text = (root / SETUP_RUST_ACTION).read_text(encoding="utf-8") + except OSError: + return [f"{SETUP_RUST_ACTION}: missing"] + # Scoped to the `toolchain:` input's own entry, not the whole file: a + # `re.search` over the entire action text would resolve to whichever + # `default: "..."` happens to appear first, which is only the toolchain + # input's by accident of every other input's default being empty and + # sitting after it in the file today. + # `job_body` bounds a two-space `name:` block, which is exactly the + # shape of an action.yml `inputs:` entry — no second helper needed. + toolchain_input = job_body(action_text, "toolchain") + if toolchain_input is None: + return [f"{SETUP_RUST_ACTION}: no `toolchain:` input found"] + default_match = re.search(r'default:\s*"([^"]+)"', toolchain_input) + if default_match is None or default_match.group(1) != channel: + found = default_match.group(1) if default_match else "" + return [ + f"{SETUP_RUST_ACTION}: toolchain input default {found!r} != " + f"rust-toolchain.toml channel {channel!r}" + ] + return [] diff --git a/scripts/ci/lib/workflow_text.py b/scripts/ci/lib/workflow_text.py new file mode 100644 index 00000000000..5a3c22369c9 --- /dev/null +++ b/scripts/ci/lib/workflow_text.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Generic text helpers for reading GitHub Actions workflow YAML. + +Deliberately regex-over-text, not a YAML parse: every checker under +`scripts/ci` is stdlib-only so the fast-checks lane needs no pip install, +and several contracts here assert on *formatting* (indentation depth, +step ordering) that a parse would normalise away. + +These four were duplicated in `ws12_workflow_contracts.py` — including two +separate, subtly different `JOB_HEADING` definitions where the second +shadowed the first for every validator below it. One definition each, +imported by both callers. +""" + +from __future__ import annotations + +import re + +# Two-space job keys (` name:` at the top level of `jobs:`). Deeper YAML +# keys are indented further and cannot match. +JOB_HEADING = re.compile(r"^ (?P[A-Za-z0-9_-]+):[ \t]*$", re.MULTILINE) + +# One `- name:` step heading. The scan is bounded to its own step because the +# neighbouring `Check all-target lints` legitimately passes `--tests +# --examples`; unbounded, this contract would blame this step for them. +STEP_HEADING = re.compile(r"^[ \t]*- name: (?P.+)$", re.MULTILINE) +JOB_HEADING = re.compile(r"^ (?P[a-zA-Z0-9_-]+):[ \t]*$", re.MULTILINE) + + +def step_body(text: str, step_name: str) -> str | None: + """Return one workflow step's body, bounded by the next step heading.""" + for heading in STEP_HEADING.finditer(text): + if heading.group("name").strip() != step_name: + continue + following = STEP_HEADING.search(text, heading.end()) + return text[heading.end() : following.start() if following else len(text)] + return None + + +def job_body(text: str, job_name: str) -> str | None: + """Return one workflow job's body, bounded by the next job heading.""" + for heading in JOB_HEADING.finditer(text): + if heading.group("name") != job_name: + continue + following = JOB_HEADING.search(text, heading.end()) + return text[heading.end() : following.start() if following else len(text)] + return None diff --git a/scripts/ci/test-classify-test-scope.sh b/scripts/ci/test-classify-test-scope.sh index e93b1aaf8bf..ad6cfb2ff60 100755 --- a/scripts/ci/test-classify-test-scope.sh +++ b/scripts/ci/test-classify-test-scope.sh @@ -475,6 +475,15 @@ has_core_code=true has_legacy_tests=false has_reborn_tests=true" +assert_scope \ + "WS12 contract modules split into scripts/ci/lib stay reborn-scoped" \ + "scripts/ci/lib/rust_toolchain_contracts.py +scripts/ci/lib/workflow_text.py" \ + "docs_only=false +has_core_code=true +has_legacy_tests=false +has_reborn_tests=true" + assert_scope \ "test suite boundaries checker script" \ "scripts/ci/check-test-suite-boundaries.sh" \ diff --git a/scripts/ci/test_ws12_workflow_contracts.py b/scripts/ci/test_ws12_workflow_contracts.py index 469cb0bc733..8863c38cf16 100755 --- a/scripts/ci/test_ws12_workflow_contracts.py +++ b/scripts/ci/test_ws12_workflow_contracts.py @@ -5,14 +5,26 @@ import copy import dataclasses +import sys import tempfile import unittest from pathlib import Path -import ws12_workflow_contracts -from ws12_workflow_contracts import ( - validate_release_workflow_installs_rust, +sys.path.insert(0, str(Path(__file__).resolve().parent / "lib")) + +import rust_toolchain_contracts # noqa: E402 +import ws12_workflow_contracts # noqa: E402 +from rust_toolchain_contracts import ( # noqa: E402 + SETUP_RUST_ACTION, + validate_no_direct_dtolnay_usage, + validate_no_job_env_rustflags_with_setup_rust, validate_no_unmanaged_rust_bootstrap, + validate_release_workflow_installs_rust, + validate_setup_rust_action, + validate_toolchain_pin_sync, +) +from workflow_text import STEP_HEADING, job_body, step_body # noqa: E402 +from ws12_workflow_contracts import ( # noqa: E402 CODE_STYLE_WORKFLOW, CRATE_NAME_RESIDUE, CRATE_SCOPE_FILTERS, @@ -22,27 +34,19 @@ NIGHTLY_DEEP_CI_WORKFLOW, PLATFORM_WORKFLOW, REQUIRED_MARKERS, - SETUP_RUST_ACTION, - STEP_HEADING, STRESS_WORKFLOW, WEBUI_FRONTEND_CRATE, WEBUI_NESTED_LOCKFILE_PATTERN, crate_directory, extract_job_block, github_glob_to_regex, - job_body, load_workflows, - step_body, validate_crate_name_residue, validate_crate_scope_filters, validate_e2e_scope_filters, validate_libsql_scripted_memory_job, validate_postgres_scripted_parity, - validate_no_direct_dtolnay_usage, - validate_no_job_env_rustflags_with_setup_rust, validate_production_lint_targets, - validate_setup_rust_action, - validate_toolchain_pin_sync, validate_windows_webui_install_shell, validate_webui_frontend_sites, validate_workflow_texts, @@ -379,7 +383,7 @@ def test_the_release_workflow_has_no_carve_out(self): self.assertTrue(any("ironclaw-release" in e for e in errors), errors) def test_no_accepted_bootstraps_remain(self): - self.assertEqual({}, ws12_workflow_contracts.ACCEPTED_RUST_BOOTSTRAPS) + self.assertEqual({}, rust_toolchain_contracts.ACCEPTED_RUST_BOOTSTRAPS) def test_alternate_vendor_toolchain_actions_are_caught(self): """The composite is the only sanctioned installer. diff --git a/scripts/ci/ws12_workflow_contracts.py b/scripts/ci/ws12_workflow_contracts.py index 0f05862ce98..c06d3df7c88 100755 --- a/scripts/ci/ws12_workflow_contracts.py +++ b/scripts/ci/ws12_workflow_contracts.py @@ -20,6 +20,16 @@ crate_directories, crate_directory, ) +from rust_toolchain_contracts import ( # noqa: E402 + SETUP_RUST_ACTION, + validate_no_direct_dtolnay_usage, + validate_no_job_env_rustflags_with_setup_rust, + validate_no_unmanaged_rust_bootstrap, + validate_release_workflow_installs_rust, + validate_setup_rust_action, + validate_toolchain_pin_sync, +) +from workflow_text import JOB_HEADING, STEP_HEADING, job_body, step_body # noqa: E402 REQUIRED_MARKERS: dict[str, tuple[str, ...]] = { ".github/workflows/reborn-tests.yml": ( @@ -421,9 +431,6 @@ def validate_code_style_docs_guard_order(text: str) -> list[str]: # pair address the process this step actually started. LIBSQL_SERVER_TRAP = re.compile(r"trap[ \t]+'(?P[^']*)'[ \t]+EXIT") -# Two-space job keys (` name:` at the top level of `jobs:`). Deeper YAML -# keys are indented further and cannot match. -JOB_HEADING = re.compile(r"^ (?P[A-Za-z0-9_-]+):[ \t]*$", re.MULTILINE) UPLOAD_ARTIFACT_NAME = re.compile( r"^[ \t]*name:[ \t]*(?P[^#\n]+?)[ \t]*$", re.MULTILINE ) @@ -483,7 +490,6 @@ def extract_continued_commands(text: str, executable: str) -> list[str]: return commands - def extract_upload(body: str) -> tuple[str | None, list[str]]: """One upload step's artifact name and path list, or (None, []) when the step is not a recognizable `actions/upload-artifact` call.""" @@ -971,21 +977,7 @@ def validate_postgres_scripted_parity(text: str) -> list[str]: SCCACHE_INSTALL_STEP = "Install sccache" SCCACHE_CONFIGURE_STEP = "Configure OVH sccache" SCCACHE_FALLBACK_STEP = "Fall back to local compilation" -SETUP_RUST_ACTION = ".github/actions/setup-rust/action.yml" -RUSTUP_TOOLCHAIN_PIN_STEP = "Pin the resolved toolchain for the rest of this job" -MOLD_INSTALL_STEP = "Install mold and clang" -MOLD_VERIFY_STEP = "Verify mold linker is active" -MOLD_EXPORT_STEP = "Export mold RUSTFLAGS" -# The one canonical mold invocation; a job's own env may append extra flags -# after it (the nightly lanes add -Zcrate-attr=...), but this prefix is the -# only place it may be written out — everywhere else must go through here. -MOLD_RUSTFLAGS = "-C linker=clang -C link-arg=--ld-path=/usr/bin/mold" - -# One `- name:` step heading. The scan is bounded to its own step because the -# neighbouring `Check all-target lints` legitimately passes `--tests -# --examples`; unbounded, this contract would blame this step for them. -STEP_HEADING = re.compile(r"^[ \t]*- name: (?P.+)$", re.MULTILINE) -JOB_HEADING = re.compile(r"^ (?P[a-zA-Z0-9_-]+):[ \t]*$", re.MULTILINE) + # `${{ matrix.flags }}` is the lane's other flag channel: `clippy_matrix` is # defined in this same workflow and expands into the command, so a target @@ -1027,16 +1019,6 @@ def validate_postgres_scripted_parity(text: str) -> list[str]: ) -def step_body(text: str, step_name: str) -> str | None: - """Return one workflow step's body, bounded by the next step heading.""" - for heading in STEP_HEADING.finditer(text): - if heading.group("name").strip() != step_name: - continue - following = STEP_HEADING.search(text, heading.end()) - return text[heading.end() : following.start() if following else len(text)] - return None - - def validate_sccache_setup_action(text: str) -> list[str]: """Keep an optional compiler-cache download from gating CI correctness.""" errors: list[str] = [] @@ -1086,326 +1068,6 @@ def validate_sccache_setup_action(text: str) -> list[str]: return errors -def validate_setup_rust_action(text: str | None) -> list[str]: - """The setup-rust composite must actually pin RUSTUP_TOOLCHAIN and mold. - - Contract: every job that installs Rust through this composite gets a - RUSTUP_TOOLCHAIN export naming exactly the toolchain dtolnay/rust-toolchain - just installed (steps.install.outputs.name — not a second, possibly wrong, - guess at the resolved version), and mold's install/verify/RUSTFLAGS steps - are gated on Linux so `mold: true` is safe to pass on any runner OS. - """ - if text is None: - return [f"{SETUP_RUST_ACTION}: could not read the composite action file"] - errors: list[str] = [] - pin_step = step_body(text, RUSTUP_TOOLCHAIN_PIN_STEP) - if pin_step is None: - errors.append( - f"{SETUP_RUST_ACTION}: missing the {RUSTUP_TOOLCHAIN_PIN_STEP!r} step" - ) - elif "RUSTUP_TOOLCHAIN=${{ steps.install.outputs.name }}" not in pin_step: - errors.append( - f"{SETUP_RUST_ACTION}: {RUSTUP_TOOLCHAIN_PIN_STEP!r} must export " - "RUSTUP_TOOLCHAIN from steps.install.outputs.name, or a job's " - "cargo invocations can drift from what this step actually installed" - ) - for step_name in (MOLD_INSTALL_STEP, MOLD_VERIFY_STEP, MOLD_EXPORT_STEP): - body = step_body(text, step_name) - if body is None: - errors.append(f"{SETUP_RUST_ACTION}: missing the {step_name!r} step") - continue - if "runner.os == 'Linux'" not in body: - errors.append( - f"{SETUP_RUST_ACTION}: {step_name!r} must gate on " - "runner.os == 'Linux' so mold: true is safe on any runner" - ) - export_step = step_body(text, MOLD_EXPORT_STEP) - if export_step is not None and MOLD_RUSTFLAGS not in export_step: - errors.append( - f"{SETUP_RUST_ACTION}: {MOLD_EXPORT_STEP!r} must export the " - f"canonical mold RUSTFLAGS prefix '{MOLD_RUSTFLAGS}'" - ) - return errors - - -DTOLNAY_ACTION = "dtolnay/rust-toolchain@" - - -def validate_no_direct_dtolnay_usage(workflows: dict[str, str]) -> list[str]: - """Every workflow must install Rust through .github/actions/setup-rust. - - A negative substring check, not a per-site window scan: this is what - T1's earlier per-input-drift design collapsed to once every job routes - through one composite. Also forbids re-writing out the canonical mold - RUSTFLAGS prefix by hand anywhere a workflow's own env block might set - it — the composite is the only place that string may appear. - """ - errors: list[str] = [] - for path, text in workflows.items(): - if DTOLNAY_ACTION in text: - errors.append( - f"{path}: calls {DTOLNAY_ACTION!r} directly — install Rust " - "through .github/actions/setup-rust instead" - ) - if MOLD_RUSTFLAGS in text: - errors.append( - f"{path}: writes out the canonical mold RUSTFLAGS prefix " - f"'{MOLD_RUSTFLAGS}' directly — pass mold: true to " - ".github/actions/setup-rust instead, which prepends it onto " - "this job's existing RUSTFLAGS" - ) - return errors - - -SETUP_RUST_USES = "uses: ./.github/actions/setup-rust" -JOBS_KEY = re.compile(r"^jobs:[ \t]*$", re.MULTILINE) -ANCHOR_DEF = re.compile(r"^\s*-\s*&(?P[\w-]+)\s*$", re.MULTILINE) -# Job-level `env:` (jobs..env, two-space job + four-space `env:` + this -# key at six spaces) and workflow-level top `env:` (two-space key straight -# under the file's own `env:`) are both re-applied to every step of a job on -# top of $GITHUB_ENV, so both shadow the composite's mold export the same -# way. They need separate patterns, not one merged indentation class: the -# workflow-level form sits in the file's preamble, before any job heading, so -# it is checked once per file rather than by slicing per-job blocks. -# Any RUSTFLAGS key inside a job, at job-env depth (6 spaces) or -# step-env depth (10) — a step-level env shadows the composite's -# $GITHUB_ENV write for that step exactly like a job-level one, so -# pinning the exact job depth left a third of the shapes unguarded. -JOB_ENV_RUSTFLAGS = re.compile(r"^ {6,}RUSTFLAGS:", re.MULTILINE) -WORKFLOW_ENV_RUSTFLAGS = re.compile(r"^ {2}RUSTFLAGS:", re.MULTILINE) - - -def validate_no_job_env_rustflags_with_setup_rust( - workflows: dict[str, str], -) -> list[str]: - """A job installing Rust via the composite must not set its own RUSTFLAGS. - - GitHub re-applies a job-level `env:` mapping to every step of that job, - on top of whatever earlier steps wrote to $GITHUB_ENV. So a job-level - `RUSTFLAGS:` shadows the composite's export for the rest of the job and - silently drops the mold linker flags — a slower build, never a red - check, which is exactly the drift class this action exists to remove. A - workflow-level top `env:` key shadows every job in the file identically, - so it is checked the same way. Jobs pass their extra flags through the - composite's `extra_rustflags` input instead, so one place composes the - final value. - """ - - errors: list[str] = [] - for path, text in workflows.items(): - if SETUP_RUST_USES not in text: - continue - # Bound job headings to the `jobs:` block. JOB_HEADING matches ANY - # two-space `key:` line, so the `on:` trigger's children (`push:`, - # `workflow_call:`, ...) match too — taking headings[0] blindly - # truncated the preamble at the first trigger and made the - # workflow-level check dead code on every real workflow. - jobs_key = JOBS_KEY.search(text) - jobs_start = jobs_key.start() if jobs_key else 0 - headings = [h for h in JOB_HEADING.finditer(text) if h.start() >= jobs_start] - # A job can reach the composite through a YAML alias (`- *install-rust`) - # instead of the literal `uses:` line, as release-plz.yml does. Resolve - # which anchors carry the composite so an aliased job is still checked. - rust_anchors = set() - for anchor in ANCHOR_DEF.finditer(text): - following = ANCHOR_DEF.search(text, anchor.end()) - stop = following.start() if following else len(text) - next_job = JOB_HEADING.search(text, anchor.end()) - if next_job and next_job.start() < stop: - stop = next_job.start() - if SETUP_RUST_USES in text[anchor.start():stop]: - rust_anchors.add(anchor.group("name")) - preamble = text[:jobs_start] if jobs_key else text - if WORKFLOW_ENV_RUSTFLAGS.search(preamble): - errors.append( - f"{path}: workflow-level env sets a RUSTFLAGS key while a " - "job in this file installs Rust through " - ".github/actions/setup-rust — a workflow-level env key " - "shadows the composite's $GITHUB_ENV write for every job in " - "the file identically to a job-level one; pass extra flags " - "as the composite's extra_rustflags input instead" - ) - for index, heading in enumerate(headings): - start = heading.start() - end = ( - headings[index + 1].start() - if index + 1 < len(headings) - else len(text) - ) - block = text[start:end] - uses_composite = SETUP_RUST_USES in block or any( - f"*{anchor}" in block for anchor in rust_anchors - ) - if not uses_composite: - continue - if JOB_ENV_RUSTFLAGS.search(block): - errors.append( - f"{path}: job {heading.group('name')!r} sets a job- or " - "step-level RUSTFLAGS env key while installing Rust through " - ".github/actions/setup-rust — job env shadows the " - "composite's $GITHUB_ENV write and drops the mold linker " - "flags; pass them as the composite's extra_rustflags " - "input instead" - ) - return errors - - -RUST_BOOTSTRAP_PATTERNS = ( - # Raw rustup bootstraps. - "sh.rustup.rs", - "rustup-init", - "rustup toolchain install", - # Third-party toolchain actions. The composite is the only sanctioned - # installer, so a workflow reaching for a different vendor action is the - # same drift as a curl bootstrap: unpinned toolchain, no mold, no - # rust-toolchain.toml sync. (`dtolnay/rust-toolchain` has its own check - # with a more specific message; it is deliberately not repeated here.) - "actions-rs/toolchain", - "actions-rust-lang/setup-rust-toolchain", - "hecrj/setup-rust-action", - "raftario/setup-rust-action", -) -# Residual risk, named rather than papered over: a job whose `container:` -# image ships Rust preinstalled installs nothing, so no text pattern can see -# it. Such a job would silently build on the image's toolchain instead of the -# pin. Nothing in .github/workflows does this today; if one appears it needs a -# structural check (assert every Rust-building job calls the composite), not a -# wider substring list. -# No workflow may bootstrap Rust outside the composite. cargo-dist -# re-includes .github/dist-build-setup.yml on every regeneration, so the -# release build jobs install Rust through the composite from there — there -# is no lane this contract cannot cover. -ACCEPTED_RUST_BOOTSTRAPS: dict[str, int] = {} - - -def validate_no_unmanaged_rust_bootstrap(workflows: dict[str, str]) -> list[str]: - """Every hand-written workflow installs Rust through the composite. - - `validate_no_direct_dtolnay_usage` only sees the vendor action. A raw - `curl https://sh.rustup.rs | sh` installs Rust just as effectively and - matches no such string, so it would otherwise pass this gate forever -- - unpinned, without mold, and unchecked against rust-toolchain.toml. - """ - - errors: list[str] = [] - for path, text in workflows.items(): - hits = sum(text.count(pattern) for pattern in RUST_BOOTSTRAP_PATTERNS) - if not hits: - continue - allowed = ACCEPTED_RUST_BOOTSTRAPS.get(path, 0) - if hits > allowed: - errors.append( - f"{path}: {hits} raw Rust bootstrap(s) " - f"({', '.join(RUST_BOOTSTRAP_PATTERNS)}); " - f"{allowed} accepted here. Install Rust through " - ".github/actions/setup-rust so the toolchain stays pinned, " - "mold stays wired, and rust-toolchain.toml stays enforced. " - "A genuinely unavoidable bootstrap must be added to " - "ACCEPTED_RUST_BOOTSTRAPS with the reason." - ) - for path, expected in ACCEPTED_RUST_BOOTSTRAPS.items(): - text = workflows.get(path) - if text is None: - continue - hits = sum(text.count(pattern) for pattern in RUST_BOOTSTRAP_PATTERNS) - if hits < expected: - errors.append( - f"{path}: expected {expected} accepted Rust bootstrap(s), " - f"found {hits}. If the generator stopped emitting it, drop " - "the entry from ACCEPTED_RUST_BOOTSTRAPS." - ) - return errors - - -RELEASE_WORKFLOW = ".github/workflows/ironclaw-release.yml" -DIST_BUILD_SETUP = ".github/dist-build-setup.yml" - - -def validate_release_workflow_installs_rust( - workflows: dict[str, str], root: Path = ROOT -) -> list[str]: - """The release lane must actually REACH the composite, not merely lack a bootstrap. - - Every other check here asserts an absence — no dtolnay, no raw bootstrap, - no shadowing RUSTFLAGS. Absence-only checks called a release workflow with - NO Rust install whatsoever "clean": removing the old `curl | sh` step - without adding anything passed the whole suite, and a container build - would have died on `cargo: command not found`. - - ironclaw-release.yml is generated by cargo-dist from the fragment at - .github/dist-build-setup.yml, so the step has to exist in BOTH: the - fragment so `dist generate` keeps emitting it, and the checked-in - workflow because that is the file GitHub actually runs. - """ - - errors: list[str] = [] - release = workflows.get(RELEASE_WORKFLOW) - if release is not None and SETUP_RUST_USES not in release: - errors.append( - f"{RELEASE_WORKFLOW}: no `{SETUP_RUST_USES}` step. The container " - "build jobs have no other Rust install path, so this lane would " - "fail with 'cargo: command not found'. Re-run `dist generate` or " - f"re-inline the step from {DIST_BUILD_SETUP}." - ) - try: - fragment = (root / DIST_BUILD_SETUP).read_text(encoding="utf-8") - except OSError as error: - errors.append(f"{DIST_BUILD_SETUP}: could not read fragment: {error}") - else: - if SETUP_RUST_USES not in fragment: - errors.append( - f"{DIST_BUILD_SETUP}: no `{SETUP_RUST_USES}` step. cargo-dist " - "re-inlines this fragment on regeneration, so dropping it here " - "silently removes the release lane's Rust install the next time " - "the workflow is regenerated." - ) - return errors - - -def validate_toolchain_pin_sync(root: Path = ROOT) -> list[str]: - """rust-toolchain.toml and the composite's default must name one version.""" - try: - file_text = (root / "rust-toolchain.toml").read_text(encoding="utf-8") - except OSError: - return ["rust-toolchain.toml: missing (single source of truth for the CI toolchain)"] - channel_match = re.search(r'^channel = "(\d+\.\d+\.\d+)"$', file_text, re.MULTILINE) - if channel_match is None: - return ['rust-toolchain.toml: channel must be an exact stable version ("X.Y.Z")'] - channel = channel_match.group(1) - try: - action_text = (root / SETUP_RUST_ACTION).read_text(encoding="utf-8") - except OSError: - return [f"{SETUP_RUST_ACTION}: missing"] - # Scoped to the `toolchain:` input's own entry, not the whole file: a - # `re.search` over the entire action text would resolve to whichever - # `default: "..."` happens to appear first, which is only the toolchain - # input's by accident of every other input's default being empty and - # sitting after it in the file today. - # `job_body` bounds a two-space `name:` block, which is exactly the - # shape of an action.yml `inputs:` entry — no second helper needed. - toolchain_input = job_body(action_text, "toolchain") - if toolchain_input is None: - return [f"{SETUP_RUST_ACTION}: no `toolchain:` input found"] - default_match = re.search(r'default:\s*"([^"]+)"', toolchain_input) - if default_match is None or default_match.group(1) != channel: - found = default_match.group(1) if default_match else "" - return [ - f"{SETUP_RUST_ACTION}: toolchain input default {found!r} != " - f"rust-toolchain.toml channel {channel!r}" - ] - return [] - - -def job_body(text: str, job_name: str) -> str | None: - """Return one workflow job's body, bounded by the next job heading.""" - for heading in JOB_HEADING.finditer(text): - if heading.group("name") != job_name: - continue - following = JOB_HEADING.search(text, heading.end()) - return text[heading.end() : following.start() if following else len(text)] - return None - - def validate_windows_webui_install_shell(text: str) -> list[str]: """Keep POSIX WebUI setup commands out of PowerShell on Windows.""" windows_job = job_body(text, WINDOWS_CLIPPY_JOB) From 463b02974557ee286a2d93c551c11f0f4026233b Mon Sep 17 00:00:00 2001 From: Henry Park <16583448+henrypark133@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:28:19 +0000 Subject: [PATCH 33/44] docs(agents): point the toolchain pin at its owner instead of restating it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AGENTS.md paragraph and rust-toolchain.toml's header had grown into two near-verbatim copies of the same rationale — rustup precedence, the nightly coverage-lane carve-out, the cargo-dist fragment, why Docker is unaffected. Two copies of a rationale drift; the one nobody edits goes stale silently. AGENTS.md keeps what an agent needs to act: the pin is the source of truth, never install Rust in a workflow directly, and bumping is a two-place edit in one PR. The why lives in rust-toolchain.toml's header, where it sits next to the value it explains. 17 lines -> 11. Also refreshes the enforcement pointer to the module that now owns those checks after the split, and mentions the two guards added since this paragraph was written (release-lane presence, RUSTFLAGS shadowing). Verified: check-guidance OK (2603 path references, including the new scripts/ci/lib/rust_toolchain_contracts.py), ws12 gate passed, 128 self-tests OK. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 28 +++++++++++----------------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ece5a1ed4dc..cd5104cf1d6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,23 +19,17 @@ The workspace-root `integration` feature is empty with zero consumers — a bare **Cargo features are a last resort.** A feature is a second build of the workspace, compiled and tested forever. Add one only for a heavy optional dependency, a build shape that ships with it OFF, a CI lane selector, a dev-only seam (always named `test-support`), or a privilege boundary — and say which in the manifest comment. Deployment shape belongs in `DeploymentConfig` and `[storage]`, not `#[cfg]`. Full bar: `.claude/rules/cargo-features.md`. -**Toolchain pin.** `rust-toolchain.toml` at the repo root pins the stable -toolchain (channel + clippy/rustfmt) for local and CI builds. Every CI job -installs Rust through `.github/actions/setup-rust`, whose `toolchain` input -defaults to the same version; a lane that intentionally runs a different -toolchain (the two `nightly-2025-11-01` coverage lanes) passes an explicit -`toolchain:` input to that same composite, which exports `RUSTUP_TOOLCHAIN` -naming exactly what it installed — so `rust-toolchain.toml` can never -silently override a job's chosen toolchain. `scripts/ci/ws12_workflow_contracts.py` -fails the build if any workflow calls `dtolnay/rust-toolchain` directly -instead of through the composite, if any workflow bootstraps Rust another way -(`sh.rustup.rs`, `rustup-init`, `rustup toolchain install`), and if the -composite's default toolchain ever drifts from this file's `channel`. The -cargo-dist-generated release workflow is covered too: its build jobs pick the -composite up from `.github/dist-build-setup.yml`, which cargo-dist re-includes -on every regeneration, so there is no lane outside this contract. To bump Rust: edit both -`rust-toolchain.toml`'s `channel` and `.github/actions/setup-rust/action.yml`'s -`toolchain` input default, in the same PR. +**Toolchain pin.** `rust-toolchain.toml` at the repo root is the single source +of truth for the Rust toolchain, local and CI. Never install Rust in a workflow +directly — every job goes through `.github/actions/setup-rust`, and +`scripts/ci/lib/rust_toolchain_contracts.py` fails the build on a direct +`dtolnay/rust-toolchain` call, any other bootstrap, a release lane that installs +no Rust at all, or a job-level `RUSTFLAGS` key that would shadow the composite's +export. To bump Rust, edit `rust-toolchain.toml`'s `channel` and +`.github/actions/setup-rust/action.yml`'s `toolchain` input default in the same +PR; the gate fails on any mismatch. The precedence rules, the nightly-lane +carve-out, and why Docker builds are unaffected are documented where they are +enforced, in `rust-toolchain.toml`'s own header. ## Discover code before changing it From c35c70d97016f3d2c6fc3a06ce83fa67416cf147 Mon Sep 17 00:00:00 2001 From: Henry Park <16583448+henrypark133@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:40:25 +0000 Subject: [PATCH 34/44] fix(ci): require every cargo job to reach the composite, not just the release lane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A review lane proved the release-lane guard was the right fix written too narrowly. It verified the escape by mutation: delete the `uses: ./.github/actions/setup-rust` step from code_style.yml's fast-checks job — no dtolnay call, no raw bootstrap, no RUSTFLAGS key — and all 128 tests stayed green. That is the identical bug the release-lane guard was added for last commit, reproduced in a different file, because "no dtolnay, no bootstrap, no shadowing RUSTFLAGS" is trivially true of a job that installs nothing at all. validate_rust_jobs_reach_the_composite generalizes the rule: any job whose steps invoke cargo/rustc/rustup must reach the composite, directly or through a YAML alias. Scoped to jobs that actually run the compiler, so docs and frontend jobs need no exemption, and `Cargo.toml` in a `paths:` filter is not an invocation. It ships with NO allowlist because it needs none — all 31 cargo-running jobs in the tree satisfy it today. An entry here would mean a lane building Rust on whatever toolchain the runner image happens to ship. Proven red-first on the reviewer's exact mutation: the release-only guard reports 0 errors on it, the new one names all three affected jobs (fast-checks, clippy, clippy-windows). Six unit tests including the alias path and a live-tree assertion. Also in this commit, both from the same review: - workflow_text.py still defined JOB_HEADING twice. My split moved the duplicate instead of removing it, so the commit message claiming consolidation was wrong. One definition now, and a test asserts each pattern is bound exactly once — the shadowing itself is now what fails, not just today's instance of it. - The module docstring claimed five validators assert an absence. Three do. The other four assert presence or equality and are deletion-safe for their own subject. An inaccurate tally in the very docstring warning about this asymmetry is worth more than a typo. The job/anchor scan the RUSTFLAGS validator had inline is now shared with the new check (_composite_anchors / _job_blocks / _reaches_composite) rather than written twice. Verified: 137 self-tests OK, live gate passed, check-guidance OK, classify-test-scope 70 PASS / 0 FAIL. Co-Authored-By: Claude Fable 5 --- scripts/ci/lib/rust_toolchain_contracts.py | 145 +++++++++++++++------ scripts/ci/lib/workflow_text.py | 1 - scripts/ci/test_ws12_workflow_contracts.py | 142 +++++++++++++++++++- scripts/ci/ws12_workflow_contracts.py | 2 + 4 files changed, 249 insertions(+), 41 deletions(-) diff --git a/scripts/ci/lib/rust_toolchain_contracts.py b/scripts/ci/lib/rust_toolchain_contracts.py index 588c342abcd..a97dbaffa79 100644 --- a/scripts/ci/lib/rust_toolchain_contracts.py +++ b/scripts/ci/lib/rust_toolchain_contracts.py @@ -10,12 +10,19 @@ Wiring stays in that module's `validate_workflow_texts`; only the toolchain-shaped rules live here. -Note which direction each check runs. Five assert an ABSENCE — no dtolnay, -no raw bootstrap, no shadowing RUSTFLAGS. Absence-only guards cannot notice -that the thing they protect has been deleted, which is exactly how a release -workflow with no Rust install at all once passed this suite. That is what -`validate_release_workflow_installs_rust` is for; keep the pair in mind -before adding a seventh. +Note which direction each check runs, because the mix is what makes the +suite trustworthy. Three assert an ABSENCE — no dtolnay action, no raw +bootstrap, no shadowing RUSTFLAGS key. Absence-only guards cannot notice that +the thing they protect has been deleted: a job that installs nothing at all +satisfies every one of them. That is exactly how a release workflow with no +Rust install passed this suite. + +The other four assert a PRESENCE or an equality and are therefore +deletion-safe for their own subject: the composite has the steps it must have, +its default equals rust-toolchain.toml's channel, the release lane and its +cargo-dist fragment both reach the composite, and every job that runs cargo +reaches it. Before adding an eighth, work out which direction it runs and what +deletion it would miss. """ from __future__ import annotations @@ -147,27 +154,9 @@ def validate_no_job_env_rustflags_with_setup_rust( for path, text in workflows.items(): if SETUP_RUST_USES not in text: continue - # Bound job headings to the `jobs:` block. JOB_HEADING matches ANY - # two-space `key:` line, so the `on:` trigger's children (`push:`, - # `workflow_call:`, ...) match too — taking headings[0] blindly - # truncated the preamble at the first trigger and made the - # workflow-level check dead code on every real workflow. + anchors = _composite_anchors(text) jobs_key = JOBS_KEY.search(text) - jobs_start = jobs_key.start() if jobs_key else 0 - headings = [h for h in JOB_HEADING.finditer(text) if h.start() >= jobs_start] - # A job can reach the composite through a YAML alias (`- *install-rust`) - # instead of the literal `uses:` line, as release-plz.yml does. Resolve - # which anchors carry the composite so an aliased job is still checked. - rust_anchors = set() - for anchor in ANCHOR_DEF.finditer(text): - following = ANCHOR_DEF.search(text, anchor.end()) - stop = following.start() if following else len(text) - next_job = JOB_HEADING.search(text, anchor.end()) - if next_job and next_job.start() < stop: - stop = next_job.start() - if SETUP_RUST_USES in text[anchor.start():stop]: - rust_anchors.add(anchor.group("name")) - preamble = text[:jobs_start] if jobs_key else text + preamble = text[: jobs_key.start()] if jobs_key else text if WORKFLOW_ENV_RUSTFLAGS.search(preamble): errors.append( f"{path}: workflow-level env sets a RUSTFLAGS key while a " @@ -177,22 +166,12 @@ def validate_no_job_env_rustflags_with_setup_rust( "the file identically to a job-level one; pass extra flags " "as the composite's extra_rustflags input instead" ) - for index, heading in enumerate(headings): - start = heading.start() - end = ( - headings[index + 1].start() - if index + 1 < len(headings) - else len(text) - ) - block = text[start:end] - uses_composite = SETUP_RUST_USES in block or any( - f"*{anchor}" in block for anchor in rust_anchors - ) - if not uses_composite: + for name, block in _job_blocks(text): + if not _reaches_composite(block, anchors): continue if JOB_ENV_RUSTFLAGS.search(block): errors.append( - f"{path}: job {heading.group('name')!r} sets a job- or " + f"{path}: job {name!r} sets a job- or " "step-level RUSTFLAGS env key while installing Rust through " ".github/actions/setup-rust — job env shadows the " "composite's $GITHUB_ENV write and drops the mold linker " @@ -269,6 +248,94 @@ def validate_no_unmanaged_rust_bootstrap(workflows: dict[str, str]) -> list[str] return errors +def _composite_anchors(text: str) -> set[str]: + """Anchor names whose YAML block reaches the composite. + + A job can pick the composite up through an alias (`- *install-rust`) + rather than a literal `uses:` line, as release-plz.yml does, so both + contracts below have to resolve aliases before deciding a job misses it. + """ + anchors: set[str] = set() + for anchor in ANCHOR_DEF.finditer(text): + following = ANCHOR_DEF.search(text, anchor.end()) + stop = following.start() if following else len(text) + next_job = JOB_HEADING.search(text, anchor.end()) + if next_job and next_job.start() < stop: + stop = next_job.start() + if SETUP_RUST_USES in text[anchor.start():stop]: + anchors.add(anchor.group("name")) + return anchors + + +def _job_blocks(text: str) -> list[tuple[str, str]]: + """(job name, block) for each job, bounded to the `jobs:` mapping. + + JOB_HEADING matches ANY two-space `key:` line, so the `on:` trigger's + children (`push:`, `workflow_call:`, ...) match too — slicing from the + first heading blindly truncated the preamble at the first trigger and + made the workflow-level checks dead code on every real workflow. + """ + jobs_key = JOBS_KEY.search(text) + jobs_start = jobs_key.start() if jobs_key else 0 + headings = [h for h in JOB_HEADING.finditer(text) if h.start() >= jobs_start] + blocks = [] + for index, heading in enumerate(headings): + end = ( + headings[index + 1].start() + if index + 1 < len(headings) + else len(text) + ) + blocks.append((heading.group("name"), text[heading.start():end])) + return blocks + + +def _reaches_composite(block: str, anchors: set[str]) -> bool: + return SETUP_RUST_USES in block or any(f"*{a}" in block for a in anchors) + + +# A cargo/rustc/rustup command actually being invoked -- not `Cargo.toml` in a +# `paths:` filter, not `cargo` inside a URL or a comment. Bounded on both sides +# by non-path, non-word characters so `target/cargo-timings` and +# `scripts/cargo-foo.sh` do not count as running the compiler. +CARGO_INVOCATION = re.compile(r"(? list[str]: + """Any job that runs cargo must install Rust through the composite. + + The release-lane check below is the same rule written for one file. It + was added after a workflow lost its Rust install entirely and every + absence-only guard called that clean -- but it only ever protected + `ironclaw-release.yml`. Deleting the composite step from any other + workflow reproduced the identical failure with the suite still green, + because "no dtolnay, no bootstrap, no shadowing RUSTFLAGS" is all + trivially true of a job that installs nothing at all. + + Scoped to jobs that actually invoke the compiler, so a docs or frontend + job needs no exemption. All 31 cargo-running jobs in the tree satisfy + this today, which is why it ships with no allowlist: an entry here would + mean a lane building Rust on an unpinned toolchain. + """ + + errors: list[str] = [] + for path, text in workflows.items(): + anchors = _composite_anchors(text) + for name, block in _job_blocks(text): + code = "\n".join(line.split("#")[0] for line in block.splitlines()) + if not CARGO_INVOCATION.search(code): + continue + if _reaches_composite(block, anchors): + continue + errors.append( + f"{path}: job {name!r} runs cargo but never reaches " + f"`{SETUP_RUST_USES}`. It would build on whatever toolchain " + "the runner image happens to ship, unpinned and without mold " + "-- or fail outright with 'cargo: command not found'. Install " + "Rust through the composite." + ) + return errors + + RELEASE_WORKFLOW = ".github/workflows/ironclaw-release.yml" DIST_BUILD_SETUP = ".github/dist-build-setup.yml" diff --git a/scripts/ci/lib/workflow_text.py b/scripts/ci/lib/workflow_text.py index 5a3c22369c9..3a5d86bf506 100644 --- a/scripts/ci/lib/workflow_text.py +++ b/scripts/ci/lib/workflow_text.py @@ -24,7 +24,6 @@ # neighbouring `Check all-target lints` legitimately passes `--tests # --examples`; unbounded, this contract would blame this step for them. STEP_HEADING = re.compile(r"^[ \t]*- name: (?P.+)$", re.MULTILINE) -JOB_HEADING = re.compile(r"^ (?P[a-zA-Z0-9_-]+):[ \t]*$", re.MULTILINE) def step_body(text: str, step_name: str) -> str | None: diff --git a/scripts/ci/test_ws12_workflow_contracts.py b/scripts/ci/test_ws12_workflow_contracts.py index 8863c38cf16..b19d654a7a9 100755 --- a/scripts/ci/test_ws12_workflow_contracts.py +++ b/scripts/ci/test_ws12_workflow_contracts.py @@ -20,10 +20,11 @@ validate_no_job_env_rustflags_with_setup_rust, validate_no_unmanaged_rust_bootstrap, validate_release_workflow_installs_rust, + validate_rust_jobs_reach_the_composite, validate_setup_rust_action, validate_toolchain_pin_sync, ) -from workflow_text import STEP_HEADING, job_body, step_body # noqa: E402 +from workflow_text import JOB_HEADING, STEP_HEADING, job_body, step_body # noqa: E402 from ws12_workflow_contracts import ( # noqa: E402 CODE_STYLE_WORKFLOW, CRATE_NAME_RESIDUE, @@ -58,6 +59,145 @@ ) +class RustJobsReachTheCompositeTests(unittest.TestCase): + """Every job that runs cargo must reach the composite, not just the release lane. + + The release-lane guard was written after a workflow lost its Rust install + and every absence-only check called that clean — but it only covered one + file. Deleting the composite step from any other workflow reproduced the + identical bug with the suite green, because "no dtolnay, no bootstrap, no + shadowing RUSTFLAGS" is trivially true of a job that installs nothing. + """ + + COMPOSITE = " - uses: ./.github/actions/setup-rust\n" + + def workflow(self, *, with_composite: bool) -> str: + return ( + "name: demo\n" + "on:\n" + " push:\n" + "jobs:\n" + " build:\n" + " runs-on: ubuntu-latest\n" + " steps:\n" + + (self.COMPOSITE if with_composite else "") + + " - run: cargo test --workspace\n" + ) + + def test_a_cargo_job_without_the_composite_is_rejected(self) -> None: + errors = validate_rust_jobs_reach_the_composite( + {".github/workflows/demo.yml": self.workflow(with_composite=False)} + ) + self.assertEqual(1, len(errors), errors) + self.assertIn("runs cargo but never reaches", errors[0]) + self.assertIn("'build'", errors[0]) + + def test_a_cargo_job_with_the_composite_passes(self) -> None: + self.assertEqual( + [], + validate_rust_jobs_reach_the_composite( + {".github/workflows/demo.yml": self.workflow(with_composite=True)} + ), + ) + + def test_a_job_that_never_runs_cargo_needs_no_composite(self) -> None: + text = ( + "name: docs\n" + "on:\n" + " push:\n" + "jobs:\n" + " lint-docs:\n" + " runs-on: ubuntu-latest\n" + " steps:\n" + " - run: npm run lint\n" + ) + self.assertEqual( + [], validate_rust_jobs_reach_the_composite({".github/workflows/d.yml": text}) + ) + + def test_cargo_in_a_path_filter_or_comment_is_not_an_invocation(self) -> None: + """`Cargo.toml` in a paths filter must not demand a Rust install.""" + text = ( + "name: paths\n" + "on:\n" + " push:\n" + ' paths:\n - "Cargo.toml"\n - "**/cargo-timings/**"\n' + "jobs:\n" + " notify:\n" + " runs-on: ubuntu-latest\n" + " steps:\n" + " # we could run cargo here one day\n" + " - run: echo hi\n" + ) + self.assertEqual( + [], validate_rust_jobs_reach_the_composite({".github/workflows/p.yml": text}) + ) + + def test_a_job_reaching_the_composite_by_yaml_alias_passes(self) -> None: + """release-plz.yml picks the composite up through an anchor alias.""" + text = ( + "name: alias\n" + "on:\n" + " push:\n" + "x-steps:\n" + " - &install-rust\n" + " uses: ./.github/actions/setup-rust\n" + "jobs:\n" + " build:\n" + " runs-on: ubuntu-latest\n" + " steps:\n" + " - *install-rust\n" + " - run: cargo build\n" + ) + self.assertEqual( + [], validate_rust_jobs_reach_the_composite({".github/workflows/a.yml": text}) + ) + + def test_every_live_cargo_job_reaches_the_composite(self) -> None: + """Ships with no allowlist: an exemption would be an unpinned lane.""" + self.assertEqual( + [], + validate_rust_jobs_reach_the_composite( + ws12_workflow_contracts.load_workflows(ROOT) + ), + ) + + +class WorkflowTextHelperTests(unittest.TestCase): + """The shared helpers must define each pattern exactly once. + + ws12_workflow_contracts.py used to bind JOB_HEADING twice, the second + silently shadowing the first for every validator below it. Both patterns + happened to be equivalent, so nothing broke — but an edit to the dead one + would have had no effect and no test would have said so. Splitting the + helpers out is only a fix if the duplicate actually dies with it. + """ + + def test_each_pattern_is_defined_exactly_once(self) -> None: + source = (Path(__file__).resolve().parent / "lib" / "workflow_text.py").read_text( + encoding="utf-8" + ) + for name in ("JOB_HEADING", "STEP_HEADING"): + self.assertEqual( + 1, + source.count(f"{name} = re.compile"), + f"{name} must be bound exactly once; a second binding silently " + "shadows the first for every caller below it", + ) + + def test_job_heading_matches_a_two_space_job_key(self) -> None: + text = "jobs:\n build-Rust_1:\n runs-on: ubuntu-latest\n" + self.assertEqual( + ["build-Rust_1"], [m.group("name") for m in JOB_HEADING.finditer(text)] + ) + + def test_job_heading_ignores_deeper_keys(self) -> None: + text = "jobs:\n build:\n steps:\n - name: x\n" + self.assertEqual( + ["build"], [m.group("name") for m in JOB_HEADING.finditer(text)] + ) + + class SetupRustActionContractTests(unittest.TestCase): """The setup-rust composite must actually pin RUSTUP_TOOLCHAIN and mold.""" diff --git a/scripts/ci/ws12_workflow_contracts.py b/scripts/ci/ws12_workflow_contracts.py index c06d3df7c88..a500755daed 100755 --- a/scripts/ci/ws12_workflow_contracts.py +++ b/scripts/ci/ws12_workflow_contracts.py @@ -26,6 +26,7 @@ validate_no_job_env_rustflags_with_setup_rust, validate_no_unmanaged_rust_bootstrap, validate_release_workflow_installs_rust, + validate_rust_jobs_reach_the_composite, validate_setup_rust_action, validate_toolchain_pin_sync, ) @@ -1786,6 +1787,7 @@ def validate_workflow_texts( errors.extend(validate_no_direct_dtolnay_usage(workflows)) errors.extend(validate_no_unmanaged_rust_bootstrap(workflows)) errors.extend(validate_release_workflow_installs_rust(workflows, root)) + errors.extend(validate_rust_jobs_reach_the_composite(workflows)) errors.extend(validate_no_job_env_rustflags_with_setup_rust(workflows)) errors.extend(validate_toolchain_pin_sync(root)) return errors From 4a73f18b18cd222e98dd85303d0fad21af05e578 Mon Sep 17 00:00:00 2001 From: Henry Park <16583448+henrypark133@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:53:42 +0000 Subject: [PATCH 35/44] fix(ci): classify the cargo-dist fragment so the plan step stops failing closed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR's own CI was red on `Build affected-area test plan`, and the cause was this PR: it edits `.github/dist-build-setup.yml`, the planner had no rule for that path, and the planner fails closed on anything unclassified. The step died before scheduling a single lane — so a PR touching only CI plumbing could not report at all. Reproduced locally with the exact CI invocation over the PR's real changed-file list: "Reborn PR test planner failed: unclassified pull-request path: .github/dist-build-setup.yml". Green after the fix, plan mode `full`. The fragment is workflow source that lives outside `.github/workflows/`: cargo-dist re-inlines it into `ironclaw-release.yml` on every `dist generate`. So it is the same static control as the file it becomes, and no Reborn lane reads it. That is exactly the `pull_request_template.md` gap the suite already pins, one directory over. A sweep test over the surfaces that make CI run then found a second, older gap: `.github/actions/install-cargo-component/action.yml` has been unmapped since it was introduced, so any PR editing it hits the same dead plan step. It is consumed by coverage.yml and platform-and-compat.yml, so it takes the exhaustive plan like its two siblings — the deliberate mapping that arm's own comment asks for. The sweep is scoped to `.github/actions/**` plus the fragment, NOT all of `.github/`. Fail-closed on an undecided path is this planner's intended behaviour and an existing test pins `.github/labeler.yml` refusing on purpose; a wider sweep contradicted it. The line is between config someone may leave undecided and source a job runs. Reported, not silently mapped: `.github/labeler.yml` and six `.github/scripts/*` helpers still refuse. `ci-job-result-ok.sh` is invoked by workflows, so it is a latent break of the same class — but mapping it is a policy call for the planner's owner, not a drive-by in this PR. Both fixes proven red-first: removing either mapping fails the suite, restoring either passes. Verified: planner 90 OK, ws12 137 OK, ws12 gate passed, staged-paths 4 OK, classify-test-scope exit 0, check-guidance OK, and the previously-red CI command now exits 0. Co-Authored-By: Claude Fable 5 --- scripts/ci/reborn_pr_test_plan.py | 16 ++++- scripts/ci/test_reborn_pr_test_plan.py | 89 ++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 1 deletion(-) diff --git a/scripts/ci/reborn_pr_test_plan.py b/scripts/ci/reborn_pr_test_plan.py index 0f6bce9d902..f484e7d92a7 100644 --- a/scripts/ci/reborn_pr_test_plan.py +++ b/scripts/ci/reborn_pr_test_plan.py @@ -595,9 +595,23 @@ def _is_shipped_asset_markdown(path: str) -> bool: # hook, while Code Style both triggers on the tree and lints its contents # (`scripts/ci/test-ci-comm-locale-pin.sh` follows the symlinks and scans them). PR_STATIC_CONTROL_PREFIXES = (".github/workflows/", "scripts/ci/", ".githooks/") +# cargo-dist's build-setup fragment. cargo-dist re-inlines it into +# `.github/workflows/ironclaw-release.yml` on every `dist generate`, so it is +# workflow source that happens to live outside `.github/workflows/` — the same +# static control as the file it is inlined into, and read by no Reborn lane. +# The planner fails closed on unclassified paths, so a `.github/` file with no +# rule takes the whole PR's plan step down rather than mis-scheduling it. +PR_STATIC_CONTROL_FRAGMENTS = (".github/dist-build-setup.yml",) SHARED_REBORN_ACTION_PREFIXES = ( ".github/actions/setup-sccache-dist/", ".github/actions/setup-rust/", + # The deliberate mapping the arm below asks for. `install-cargo-component` + # is consumed by `coverage.yml` and `platform-and-compat.yml`; a change to + # it can move what those lanes build, and no narrow lane exercises it + # safely, so it takes the exhaustive plan like its two siblings. Until this + # entry existed it hit the fail-closed arm, so a PR editing only that file + # took the whole plan step down instead of scheduling anything. + ".github/actions/install-cargo-component/", ) BUCKET_WEIGHTS = { "reborn-core": 12, @@ -951,7 +965,7 @@ def build_plan( exact_test_targets[package].add(("test", Path(owner).stem)) reasons.append(f"repository config parsed by {owner}: {path}") continue - if path in PR_STATIC_CONTROL_PATHS or path.startswith( + if path in PR_STATIC_CONTROL_FRAGMENTS or path in PR_STATIC_CONTROL_PATHS or path.startswith( PR_STATIC_CONTROL_PREFIXES ): reasons.append(f"static CI or workspace-policy checks own: {path}") diff --git a/scripts/ci/test_reborn_pr_test_plan.py b/scripts/ci/test_reborn_pr_test_plan.py index 8efe4111b83..ba2190bc4ff 100644 --- a/scripts/ci/test_reborn_pr_test_plan.py +++ b/scripts/ci/test_reborn_pr_test_plan.py @@ -1226,6 +1226,95 @@ def test_github_pr_template_is_classified_and_selects_no_rust_lane(self) -> None with self.assertRaisesRegex(ValueError, "unclassified pull-request path"): self.plan("pull_request", [".github/labeler.yml"]) + def test_dist_build_setup_fragment_is_classified_as_static_control(self) -> None: + """`.github/dist-build-setup.yml` is workflow source outside `workflows/`. + + Regression for a real red on PR #7821: that PR edited the fragment, + the planner had no rule for it, and the fail-closed arm took the whole + `Build affected-area test plan` step down — the plan step failed before + it could schedule anything, so a PR touching only CI plumbing could not + report. Exactly the `pull_request_template.md` gap above, one directory + over. + + cargo-dist re-inlines this file into `.github/workflows/` + `ironclaw-release.yml` on every `dist generate`, so it is the same + static control as the workflow it becomes, and no Reborn lane reads it. + + Paired assertions, same shape as the sibling tests: accepted AND + selects no Rust lane, with a real change riding along still selecting + its lane, and an undecided `.github/` neighbour still refusing. + """ + plan = self.plan("pull_request", [".github/dist-build-setup.yml"]) + self.assertEqual(plan["mode"], "none") + self.assertEqual(plan["crate_buckets"], []) + self.assertEqual(plan["root_partitions"], []) + self.assertEqual(plan["integration_lanes"], []) + + paired = self.plan( + "pull_request", + [".github/dist-build-setup.yml", "crates/alpha/src/lib.rs"], + ) + self.assertEqual(paired["mode"], "selected") + self.assertNotEqual(paired["crate_buckets"], []) + + with self.assertRaisesRegex(ValueError, "unclassified pull-request path"): + self.plan("pull_request", [".github/labeler.yml"]) + + def test_every_workflow_source_under_github_is_classified(self) -> None: + """Every file that makes CI *run* must be classified, with no gaps. + + Scoped deliberately to `.github/actions/**` and the cargo-dist + fragment — the composite actions and workflow source that jobs + execute. `.github/workflows/**` is already covered wholesale by + `PR_STATIC_CONTROL_PREFIXES`. + + NOT a sweep of all of `.github/`. Fail-closed on an undecided path is + this planner's intended behaviour, and the sibling test above pins + `.github/labeler.yml` refusing on purpose. The line drawn here is + between "config someone may deliberately leave undecided" and "source + a job runs", where a refusal is always a latent red plan step. + + This test found `.github/actions/install-cargo-component/action.yml`, + unmapped since it was added; `.github/dist-build-setup.yml` is the gap + that actually went red on PR #7821. + + Known remaining refusals outside this scope, reported rather than + silently mapped: `.github/labeler.yml` (pinned as refusing by the test + above) and the six `.github/scripts/*.sh|.py` PR-automation helpers. + `ci-job-result-ok.sh` in particular is invoked by workflows, so it is a + latent plan-step break of the same class — mapping it is a deliberate + policy call for the planner's owner, not a drive-by. + """ + root = Path(__file__).resolve().parents[2] + sources = sorted( + str(child.relative_to(root)) + for child in (root / ".github" / "actions").rglob("*") + if child.is_file() + ) + sources.append(".github/dist-build-setup.yml") + self.assertTrue( + (root / ".github" / "dist-build-setup.yml").is_file(), + "the cargo-dist fragment should exist; update this test if it moved", + ) + self.assertGreater(len(sources), 1, ".github/actions should not be empty") + + refused = [] + for path in sources: + try: + # Real owners, not the fake workspace: some entries name a + # real crate file as their owner, which the synthetic package + # list cannot resolve. + self.plan_real_owners([path]) + except ValueError as error: + message = str(error) + if "unclassified pull-request path" in message or ( + "unmapped test or CI path" in message + ): + refused.append(f"{path}: {message}") + else: + raise + self.assertEqual([], refused) + def test_dependabot_config_routes_to_linked_device_supply_chain_test(self) -> None: """The config is an asserted input of the linked-device pin test.""" plan = self.plan_real_owners([".github/dependabot.yml"]) From cbe943fcdfca97035721487a2a17b42cd5c924d3 Mon Sep 17 00:00:00 2001 From: Henry Park <16583448+henrypark133@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:35:58 +0000 Subject: [PATCH 36/44] refactor(ci): close the three open audit findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All three were NORMAL, none blocking; closing them so review sees the design rather than a list of known nits. **One owner for the debug-info policy** (converged finding, reported independently by both system-audit lanes). The migration deleted these env pairs from five workflow job envs on the strength of Cargo.toml's `[profile.dev] debug = 0` owning the value — but left the identical `:-0` defaults standing in scripts/ci/quality_gate.sh and reborn-local-coverage-ratchet.sh. The PR's own claim of a single owner was not true of the tree it shipped. Both removed; no behaviour change, the values already agreed, and a developer's `CARGO_PROFILE_DEV_DEBUG=2` still reaches cargo because `env` inherits what it does not override. Deleting the lines alone would just let them come back, so `validate_single_debug_policy_owner` makes it structural: no script under scripts/ may ASSIGN a CARGO_PROFILE_*_DEBUG value. Assignments only — run-hermetic-test-process.sh names the same variables in a passthrough allowlist (a `case` pattern, no `=`), which is exactly how that override survives the hermetic barrier, and matching it would break the documented escape hatch. Test files are skipped: they carry the string on purpose as fixtures, and scanning them would make the contract untestable. **No speculative escape hatch.** `ACCEPTED_RUST_BOOTSTRAPS` was a per-path override with symmetric over-use AND under-use validation, plus a test pinning it empty — built in full for a case the module's own comment says does not exist. Removed; the rule is now unconditional. If an unavoidable bootstrap ever appears, the hatch gets added then, with that lane as its first entry. The test that asserted the dict stays empty now asserts the mechanism is gone rather than merely empty. **One job-boundary walk.** `_job_blocks` reimplemented the slice-between- consecutive-headings logic that `extract_job_block` already did in the same subsystem. `job_blocks()` in workflow_text.py is now the single primitive: `extract_job_block` filters it to one named job and keeps its exactly-one refusal, and the toolchain contracts enumerate it from the `jobs:` offset. That offset is load-bearing and stays — JOB_HEADING matches any two-space key, so an unbounded walk treats `on:`'s children as jobs. Verified: ws12 141 tests OK, live gate passed, planner 90 OK, staged-paths 4 OK, classify-test-scope exit 0, check-guidance OK, both edited shell scripts pass `bash -n`. The debug-policy guard proven red-first by restoring one deleted line and watching the gate name it. Co-Authored-By: Claude Fable 5 --- scripts/ci/lib/rust_toolchain_contracts.py | 103 ++++++++++++-------- scripts/ci/lib/workflow_text.py | 27 +++++ scripts/ci/quality_gate.sh | 1 - scripts/ci/reborn-local-coverage-ratchet.sh | 2 - scripts/ci/test_ws12_workflow_contracts.py | 58 ++++++++++- scripts/ci/ws12_workflow_contracts.py | 11 +-- 6 files changed, 150 insertions(+), 52 deletions(-) diff --git a/scripts/ci/lib/rust_toolchain_contracts.py b/scripts/ci/lib/rust_toolchain_contracts.py index a97dbaffa79..7651f4d42f8 100644 --- a/scripts/ci/lib/rust_toolchain_contracts.py +++ b/scripts/ci/lib/rust_toolchain_contracts.py @@ -30,7 +30,7 @@ import re from pathlib import Path -from workflow_text import JOB_HEADING, job_body, step_body +from workflow_text import JOB_HEADING, job_blocks, job_body, step_body ROOT = Path(__file__).resolve().parents[3] @@ -205,8 +205,9 @@ def validate_no_job_env_rustflags_with_setup_rust( # No workflow may bootstrap Rust outside the composite. cargo-dist # re-includes .github/dist-build-setup.yml on every regeneration, so the # release build jobs install Rust through the composite from there — there -# is no lane this contract cannot cover. -ACCEPTED_RUST_BOOTSTRAPS: dict[str, int] = {} +# is no lane this contract cannot cover, and so no exemption mechanism here. +# If a genuinely unavoidable bootstrap ever appears, add the escape hatch +# then, with that lane as its first entry and its reason in the comment. def validate_no_unmanaged_rust_bootstrap(workflows: dict[str, str]) -> list[str]: @@ -223,28 +224,12 @@ def validate_no_unmanaged_rust_bootstrap(workflows: dict[str, str]) -> list[str] hits = sum(text.count(pattern) for pattern in RUST_BOOTSTRAP_PATTERNS) if not hits: continue - allowed = ACCEPTED_RUST_BOOTSTRAPS.get(path, 0) - if hits > allowed: - errors.append( - f"{path}: {hits} raw Rust bootstrap(s) " - f"({', '.join(RUST_BOOTSTRAP_PATTERNS)}); " - f"{allowed} accepted here. Install Rust through " - ".github/actions/setup-rust so the toolchain stays pinned, " - "mold stays wired, and rust-toolchain.toml stays enforced. " - "A genuinely unavoidable bootstrap must be added to " - "ACCEPTED_RUST_BOOTSTRAPS with the reason." - ) - for path, expected in ACCEPTED_RUST_BOOTSTRAPS.items(): - text = workflows.get(path) - if text is None: - continue - hits = sum(text.count(pattern) for pattern in RUST_BOOTSTRAP_PATTERNS) - if hits < expected: - errors.append( - f"{path}: expected {expected} accepted Rust bootstrap(s), " - f"found {hits}. If the generator stopped emitting it, drop " - "the entry from ACCEPTED_RUST_BOOTSTRAPS." - ) + errors.append( + f"{path}: {hits} raw Rust bootstrap(s) " + f"({', '.join(RUST_BOOTSTRAP_PATTERNS)}). Install Rust through " + ".github/actions/setup-rust so the toolchain stays pinned, mold " + "stays wired, and rust-toolchain.toml stays enforced." + ) return errors @@ -268,25 +253,14 @@ def _composite_anchors(text: str) -> set[str]: def _job_blocks(text: str) -> list[tuple[str, str]]: - """(job name, block) for each job, bounded to the `jobs:` mapping. + """Every job in the file, bounded to the `jobs:` mapping. - JOB_HEADING matches ANY two-space `key:` line, so the `on:` trigger's - children (`push:`, `workflow_call:`, ...) match too — slicing from the - first heading blindly truncated the preamble at the first trigger and - made the workflow-level checks dead code on every real workflow. + Slicing from the first heading blindly truncated the preamble at the + first `on:` trigger and made the workflow-level checks dead code on every + real workflow, so the `jobs:` offset is load-bearing here. """ jobs_key = JOBS_KEY.search(text) - jobs_start = jobs_key.start() if jobs_key else 0 - headings = [h for h in JOB_HEADING.finditer(text) if h.start() >= jobs_start] - blocks = [] - for index, heading in enumerate(headings): - end = ( - headings[index + 1].start() - if index + 1 < len(headings) - else len(text) - ) - blocks.append((heading.group("name"), text[heading.start():end])) - return blocks + return job_blocks(text, jobs_key.start() if jobs_key else 0) def _reaches_composite(block: str, anchors: set[str]) -> bool: @@ -381,6 +355,53 @@ def validate_release_workflow_installs_rust( return errors +# Cargo.toml's `[profile.dev] debug = 0` owns the debug-info policy. Anything +# else ASSIGNING one of these is a second writer of the same value -- harmless +# while the values agree, and a silent divergence the day someone bumps one. +# An assignment only: `run-hermetic-test-process.sh` names the same variables +# in a passthrough allowlist (a `case` pattern, no `=`), which is how a +# developer's `CARGO_PROFILE_DEV_DEBUG=2` override survives the hermetic +# barrier. That entry must keep working, so it must not match here. +DEBUG_POLICY_ASSIGNMENT = re.compile(r"CARGO_PROFILE_[A-Z]+_DEBUG\s*=") +DEBUG_POLICY_OWNER = "Cargo.toml" + + +def validate_single_debug_policy_owner(root: Path = ROOT) -> list[str]: + """Only Cargo.toml may set the debug-info profile values. + + The migration deleted these env pairs from five workflow job envs on the + strength of the profile block owning them, but left the identical `:-0` + defaults standing in two scripts -- so the change's own claim of a single + owner was not true of the whole tree. Two audit lanes reported it + independently. + """ + + errors: list[str] = [] + for path in sorted((root / "scripts").rglob("*")): + if not path.is_file() or path.suffix not in (".sh", ".py"): + continue + # Test files carry the forbidden string on purpose, as fixtures and + # as the sabotage input that proves this check fires. Scanning them + # would make the contract unable to have a regression test at all. + if path.name.startswith(("test_", "test-")): + continue + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as error: + errors.append(f"{path}: could not read: {error}") + continue + for number, line in enumerate(text.splitlines(), start=1): + if DEBUG_POLICY_ASSIGNMENT.search(line.split("#")[0]): + errors.append( + f"{path.relative_to(root)}:{number}: assigns a " + "CARGO_PROFILE_*_DEBUG value, which " + f"{DEBUG_POLICY_OWNER}'s `[profile.dev]` block owns. Two " + "writers of one value diverge the day either is bumped; " + "delete this and let the profile decide." + ) + return errors + + def validate_toolchain_pin_sync(root: Path = ROOT) -> list[str]: """rust-toolchain.toml and the composite's default must name one version.""" try: diff --git a/scripts/ci/lib/workflow_text.py b/scripts/ci/lib/workflow_text.py index 3a5d86bf506..ba63f4a1b9b 100644 --- a/scripts/ci/lib/workflow_text.py +++ b/scripts/ci/lib/workflow_text.py @@ -26,6 +26,33 @@ STEP_HEADING = re.compile(r"^[ \t]*- name: (?P.+)$", re.MULTILINE) +def job_blocks(text: str, start: int = 0) -> list[tuple[str, str]]: + """(job name, block) for every two-space job key at or after `start`. + + The one place that answers "where does a job's YAML block end" — the next + job heading, or end of file. Both callers that need job boundaries build + on this: `extract_job_block` filters it to one named job and refuses + anything but an exact match, and the toolchain contracts enumerate it from + the `jobs:` key onward. + + `start` exists because JOB_HEADING matches ANY two-space `key:` line, so + the `on:` trigger's children (`push:`, `workflow_call:`, ...) match too. + A caller that must not treat those as jobs passes the offset of `jobs:`. + """ + headings = [h for h in JOB_HEADING.finditer(text) if h.start() >= start] + return [ + ( + heading.group("name"), + text[ + heading.start() : headings[index + 1].start() + if index + 1 < len(headings) + else len(text) + ], + ) + for index, heading in enumerate(headings) + ] + + def step_body(text: str, step_name: str) -> str | None: """Return one workflow step's body, bounded by the next step heading.""" for heading in STEP_HEADING.finditer(text): diff --git a/scripts/ci/quality_gate.sh b/scripts/ci/quality_gate.sh index 40d39ff6ddd..ea873a3e72a 100755 --- a/scripts/ci/quality_gate.sh +++ b/scripts/ci/quality_gate.sh @@ -16,7 +16,6 @@ run_cargo_ci() { -u LLM_BACKEND \ IRONCLAW_DISABLE_OS_KEYCHAIN="${IRONCLAW_DISABLE_OS_KEYCHAIN:-1}" \ CARGO_INCREMENTAL="${CARGO_INCREMENTAL:-0}" \ - CARGO_PROFILE_TEST_DEBUG="${CARGO_PROFILE_TEST_DEBUG:-0}" \ RUST_MIN_STACK="${RUST_MIN_STACK:-67108864}" \ "$@" } diff --git a/scripts/ci/reborn-local-coverage-ratchet.sh b/scripts/ci/reborn-local-coverage-ratchet.sh index 435ef259e58..24ab2166bb3 100755 --- a/scripts/ci/reborn-local-coverage-ratchet.sh +++ b/scripts/ci/reborn-local-coverage-ratchet.sh @@ -45,8 +45,6 @@ run_cargo_cov_env() { -u IRONCLAW_LLM_MODEL \ IRONCLAW_DISABLE_OS_KEYCHAIN="${IRONCLAW_DISABLE_OS_KEYCHAIN:-1}" \ CARGO_INCREMENTAL="${CARGO_INCREMENTAL:-0}" \ - CARGO_PROFILE_DEV_DEBUG="${CARGO_PROFILE_DEV_DEBUG:-0}" \ - CARGO_PROFILE_TEST_DEBUG="${CARGO_PROFILE_TEST_DEBUG:-0}" \ RUST_MIN_STACK="${RUST_MIN_STACK:-8388608}" \ "$@" } diff --git a/scripts/ci/test_ws12_workflow_contracts.py b/scripts/ci/test_ws12_workflow_contracts.py index b19d654a7a9..b131e7c46be 100755 --- a/scripts/ci/test_ws12_workflow_contracts.py +++ b/scripts/ci/test_ws12_workflow_contracts.py @@ -22,6 +22,7 @@ validate_release_workflow_installs_rust, validate_rust_jobs_reach_the_composite, validate_setup_rust_action, + validate_single_debug_policy_owner, validate_toolchain_pin_sync, ) from workflow_text import JOB_HEADING, STEP_HEADING, job_body, step_body # noqa: E402 @@ -59,6 +60,43 @@ ) +class SingleDebugPolicyOwnerTests(unittest.TestCase): + """Cargo.toml's [profile.dev] is the only writer of the debug-info value.""" + + def tree(self, body: str) -> Path: + root = Path(self.enterContext(tempfile.TemporaryDirectory())) + (root / "scripts" / "ci").mkdir(parents=True) + (root / "scripts" / "ci" / "gate.sh").write_text(body, encoding="utf-8") + return root + + def test_a_second_writer_is_rejected(self) -> None: + root = self.tree('CARGO_PROFILE_TEST_DEBUG="${CARGO_PROFILE_TEST_DEBUG:-0}"\n') + errors = validate_single_debug_policy_owner(root) + self.assertEqual(1, len(errors), errors) + self.assertIn("scripts/ci/gate.sh:1", errors[0]) + self.assertIn("Cargo.toml", errors[0]) + + def test_the_hermetic_passthrough_allowlist_still_passes(self) -> None: + """A `case` pattern naming the vars is not a second writer. + + run-hermetic-test-process.sh lists them so a developer's + `CARGO_PROFILE_DEV_DEBUG=2` override survives the hermetic barrier. + Matching that entry would break the documented escape hatch. + """ + root = self.tree( + " CARGO_INCREMENTAL|CARGO_PROFILE_DEV_DEBUG|" + "CARGO_PROFILE_TEST_DEBUG|CARGO_TEST_ARGS|\\\n" + ) + self.assertEqual([], validate_single_debug_policy_owner(root)) + + def test_a_comment_mentioning_the_override_still_passes(self) -> None: + root = self.tree("# override per-run: CARGO_PROFILE_DEV_DEBUG=2 cargo test\n") + self.assertEqual([], validate_single_debug_policy_owner(root)) + + def test_the_live_tree_has_exactly_one_owner(self) -> None: + self.assertEqual([], validate_single_debug_policy_owner(ROOT)) + + class RustJobsReachTheCompositeTests(unittest.TestCase): """Every job that runs cargo must reach the composite, not just the release lane. @@ -522,8 +560,24 @@ def test_the_release_workflow_has_no_carve_out(self): errors = validate_no_unmanaged_rust_bootstrap(workflows) self.assertTrue(any("ironclaw-release" in e for e in errors), errors) - def test_no_accepted_bootstraps_remain(self): - self.assertEqual({}, rust_toolchain_contracts.ACCEPTED_RUST_BOOTSTRAPS) + def test_a_bootstrap_is_rejected_with_no_exemption_available(self) -> None: + """There is no allowlist to add a lane to; the rule is unconditional. + + The empty `ACCEPTED_RUST_BOOTSTRAPS` dict this replaces was an escape + hatch, fully built with symmetric over/under-use validation, for a + case the module's own comment said did not exist. Flagged by the + structural-discipline audit as speculative generality. + """ + errors = validate_no_unmanaged_rust_bootstrap( + {".github/workflows/demo.yml": " - run: curl https://sh.rustup.rs | sh\n"} + ) + self.assertEqual(1, len(errors), errors) + self.assertIn("raw Rust bootstrap", errors[0]) + self.assertNotIn("ACCEPTED", errors[0]) + self.assertFalse( + hasattr(rust_toolchain_contracts, "ACCEPTED_RUST_BOOTSTRAPS"), + "the exemption mechanism should be gone, not merely empty", + ) def test_alternate_vendor_toolchain_actions_are_caught(self): """The composite is the only sanctioned installer. diff --git a/scripts/ci/ws12_workflow_contracts.py b/scripts/ci/ws12_workflow_contracts.py index a500755daed..94e06c0fdf5 100755 --- a/scripts/ci/ws12_workflow_contracts.py +++ b/scripts/ci/ws12_workflow_contracts.py @@ -28,9 +28,10 @@ validate_release_workflow_installs_rust, validate_rust_jobs_reach_the_composite, validate_setup_rust_action, + validate_single_debug_policy_owner, validate_toolchain_pin_sync, ) -from workflow_text import JOB_HEADING, STEP_HEADING, job_body, step_body # noqa: E402 +from workflow_text import STEP_HEADING, job_blocks, job_body, step_body # noqa: E402 REQUIRED_MARKERS: dict[str, tuple[str, ...]] = { ".github/workflows/reborn-tests.yml": ( @@ -455,16 +456,13 @@ def extract_job_block(text: str, job: str) -> tuple[str | None, str]: fail-closed stance as `extract_scope_regex`). """ - headings = list(JOB_HEADING.finditer(text)) - matches = [match for match in headings if match.group("name") == job] + matches = [block for name, block in job_blocks(text) if name == job] if len(matches) != 1: return None, ( f"expected exactly one {job!r} job, found {len(matches)} — the " "scoped libsql-scripted-memory contract cannot resolve its block" ) - start = matches[0].start() - following = next((m for m in headings if m.start() > start), None) - return text[start : following.start() if following else len(text)], "" + return matches[0], "" def extract_continued_commands(text: str, executable: str) -> list[str]: """Return shell commands whose first line is ` \\`. @@ -1790,6 +1788,7 @@ def validate_workflow_texts( errors.extend(validate_rust_jobs_reach_the_composite(workflows)) errors.extend(validate_no_job_env_rustflags_with_setup_rust(workflows)) errors.extend(validate_toolchain_pin_sync(root)) + errors.extend(validate_single_debug_policy_owner(root)) return errors From 3e0b5cee965c7e17fe6807ddc95aab706395328d Mon Sep 17 00:00:00 2001 From: Henry Park <16583448+henrypark133@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:24:43 +0000 Subject: [PATCH 37/44] Address PR review feedback (#7821) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four threads triaged as valid and fixed. Two are real guard bypasses in the module this PR introduces — a reviewer reproduced both, and both are the same species the module's own docstring warns about: a check reading text that only LOOKS like an executable step, or reading too narrow a slice of the file. - **Release check was file-wide, not job-scoped.** Deleting the composite step from `build-local-artifacts` while a decoy `# uses: ...` comment sat in an unrelated job returned no error. That matters more than it looks: nothing in ironclaw-release.yml matches a literal `cargo` (cargo-dist shells out to `dist build`), so `validate_rust_jobs_reach_the_composite` never covers that file and this was its only guard. Now scoped to the job, and every `uses:` match is comment-stripped per line. - **Anchor scope ran past its own YAML node.** `_composite_anchors` scanned from an anchor to the next anchor or job heading, so a comment mentioning the composite anywhere in that span marked the anchor as installing Rust — and a job that merely aliased it passed while installing nothing. Bounded by indentation to the anchor's own node, and comment-stripped. The legitimate release-plz alias shape keeps working (pinned by its own test). - **Root-level `env:` after `jobs:` was invisible.** A top-level mapping key need not precede `jobs:`; such a block applies to every job identically, but slicing the preamble at `jobs:` hid it while its two-space indent also dodged the six-space per-job pattern. It fell through both checks. Now scans the whole file. - **AGENTS.md overstated the contract.** My earlier commit 463b02974 made this paragraph MORE absolute than the one it replaced — "single source of truth" and "every job" — when there are two synchronized pins checked for equality, neither derived from the other, plus a nightly-lane carve-out and Docker outside the contract entirely. That is the exact universal-claim pattern `.claude/rules/guidance-maintenance.md` forbids. Reworded to say what is actually enforced. Test-file changes and why: - `ReleaseWorkflowInstallsRustTests` gained a `release()` helper and its fixtures now name `build-local-artifacts`. The old fixtures were a bare `jobs:\n build:` which no longer exercises a job-scoped contract. This makes the fixture match the real workflow shape; it does not relax an assertion. - `GuardBypassRegressionTests` adds four tests: one per bypass, plus one pinning that a real anchor alias still passes so the anchor fix cannot be "fixed" by rejecting legitimate aliases. All three code fixes proven red-first: reverting each one individually fails its own regression, and restoring it passes. Verified: ws12 145 tests OK, live gate passed, planner 90 OK, staged-paths 4 OK, classify-test-scope exit 0, check-guidance OK. Not addressed here, reported on the threads: the `workflow_dispatch` + local-action-resolution regression needs a design call (20+ sites, 4 workflows), and one thread's claim was already fixed earlier in this branch. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 25 ++--- scripts/ci/lib/rust_toolchain_contracts.py | 108 +++++++++++++++------ scripts/ci/test_ws12_workflow_contracts.py | 108 ++++++++++++++++++++- 3 files changed, 200 insertions(+), 41 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index cd5104cf1d6..7c614fabd79 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,17 +19,20 @@ The workspace-root `integration` feature is empty with zero consumers — a bare **Cargo features are a last resort.** A feature is a second build of the workspace, compiled and tested forever. Add one only for a heavy optional dependency, a build shape that ships with it OFF, a CI lane selector, a dev-only seam (always named `test-support`), or a privilege boundary — and say which in the manifest comment. Deployment shape belongs in `DeploymentConfig` and `[storage]`, not `#[cfg]`. Full bar: `.claude/rules/cargo-features.md`. -**Toolchain pin.** `rust-toolchain.toml` at the repo root is the single source -of truth for the Rust toolchain, local and CI. Never install Rust in a workflow -directly — every job goes through `.github/actions/setup-rust`, and -`scripts/ci/lib/rust_toolchain_contracts.py` fails the build on a direct -`dtolnay/rust-toolchain` call, any other bootstrap, a release lane that installs -no Rust at all, or a job-level `RUSTFLAGS` key that would shadow the composite's -export. To bump Rust, edit `rust-toolchain.toml`'s `channel` and -`.github/actions/setup-rust/action.yml`'s `toolchain` input default in the same -PR; the gate fails on any mismatch. The precedence rules, the nightly-lane -carve-out, and why Docker builds are unaffected are documented where they are -enforced, in `rust-toolchain.toml`'s own header. +**Toolchain pin.** The Rust version lives in two synchronized places: +`rust-toolchain.toml`'s `channel` (what local `cargo`/`clippy` resolve) and +`.github/actions/setup-rust`'s `toolchain` input default (what CI installs). +`scripts/ci/lib/rust_toolchain_contracts.py` enforces that they are equal — it +does not make either one derive from the other, so a bump edits both in the +same PR or the gate fails. Every Rust-installing CI job goes through the +composite; the two `nightly-2025-11-01` coverage lanes pass an explicit +`toolchain:` input, and Docker builds are the one path outside this contract +(the build context excludes `rust-toolchain.toml`, so images stay on their +base-image toolchain). The same module also fails the build on a direct +`dtolnay/rust-toolchain` call, any other bootstrap, a release lane whose +`build-local-artifacts` job installs no Rust, and a `RUSTFLAGS` env key that +would shadow the composite's export. Precedence rules and the bump checklist +are in `rust-toolchain.toml`'s own header. ## Discover code before changing it diff --git a/scripts/ci/lib/rust_toolchain_contracts.py b/scripts/ci/lib/rust_toolchain_contracts.py index 7651f4d42f8..9cf8166cde8 100644 --- a/scripts/ci/lib/rust_toolchain_contracts.py +++ b/scripts/ci/lib/rust_toolchain_contracts.py @@ -118,7 +118,9 @@ def validate_no_direct_dtolnay_usage(workflows: dict[str, str]) -> list[str]: SETUP_RUST_USES = "uses: ./.github/actions/setup-rust" JOBS_KEY = re.compile(r"^jobs:[ \t]*$", re.MULTILINE) -ANCHOR_DEF = re.compile(r"^\s*-\s*&(?P[\w-]+)\s*$", re.MULTILINE) +# One list item that is nothing but an anchor definition (`- &install-rust`). +# Matched per line so the node it opens can be bounded by indentation. +ANCHOR_DEF_LINE = re.compile(r"^\s*-\s*&(?P[\w-]+)\s*$") # Job-level `env:` (jobs..env, two-space job + four-space `env:` + this # key at six spaces) and workflow-level top `env:` (two-space key straight # under the file's own `env:`) are both re-applied to every step of a job on @@ -155,9 +157,12 @@ def validate_no_job_env_rustflags_with_setup_rust( if SETUP_RUST_USES not in text: continue anchors = _composite_anchors(text) - jobs_key = JOBS_KEY.search(text) - preamble = text[: jobs_key.start()] if jobs_key else text - if WORKFLOW_ENV_RUSTFLAGS.search(preamble): + # Whole file, not just the preamble. A top-level mapping key need not + # precede `jobs:` — a root `env:` block placed after it is valid YAML, + # applies to every job the same way, and slicing at `jobs:` made it + # invisible to this check while its two-space indent also dodged the + # six-space per-job pattern. It fell through both. + if WORKFLOW_ENV_RUSTFLAGS.search(text): errors.append( f"{path}: workflow-level env sets a RUSTFLAGS key while a " "job in this file installs Rust through " @@ -234,21 +239,39 @@ def validate_no_unmanaged_rust_bootstrap(workflows: dict[str, str]) -> list[str] def _composite_anchors(text: str) -> set[str]: - """Anchor names whose YAML block reaches the composite. + """Anchor names whose OWN YAML node reaches the composite. A job can pick the composite up through an alias (`- *install-rust`) rather than a literal `uses:` line, as release-plz.yml does, so both contracts below have to resolve aliases before deciding a job misses it. + + Bounded by indentation, and comment-stripped, because the previous span + ("from this anchor to the next anchor or job heading") was wide enough to + swallow unrelated siblings: a *comment* elsewhere in that span mentioning + the composite marked the anchor as installing Rust, and a job that only + aliased it then passed `validate_rust_jobs_reach_the_composite` while + installing nothing. That is the same alias-fooled bypass this module's + docstring warns about, so it gets the same treatment as everything else + here — the check reads executable steps, never text that merely looks + like one. """ anchors: set[str] = set() - for anchor in ANCHOR_DEF.finditer(text): - following = ANCHOR_DEF.search(text, anchor.end()) - stop = following.start() if following else len(text) - next_job = JOB_HEADING.search(text, anchor.end()) - if next_job and next_job.start() < stop: - stop = next_job.start() - if SETUP_RUST_USES in text[anchor.start():stop]: - anchors.add(anchor.group("name")) + lines = text.splitlines() + for index, line in enumerate(lines): + match = ANCHOR_DEF_LINE.match(line) + if not match: + continue + indent = len(line) - len(line.rstrip("\n").lstrip()) + for follower in lines[index + 1 :]: + if not follower.strip(): + continue + # First non-blank line at or left of the anchor's own marker ends + # its node — that is a sibling or a parent, not its content. + if len(follower) - len(follower.lstrip()) <= indent: + break + if SETUP_RUST_USES in follower.split("#")[0]: + anchors.add(match.group("name")) + break return anchors @@ -312,6 +335,18 @@ def validate_rust_jobs_reach_the_composite(workflows: dict[str, str]) -> list[st RELEASE_WORKFLOW = ".github/workflows/ironclaw-release.yml" DIST_BUILD_SETUP = ".github/dist-build-setup.yml" +# cargo-dist's container build job — the only job in the release workflow +# that installs Rust, and the one the contract below actually guards. +RELEASE_BUILD_JOB = "build-local-artifacts" + + +def _has_composite_step(text: str) -> bool: + """True when some EXECUTABLE line invokes the composite. + + Comment-stripped per line: a substring search over raw text counted a + commented-out or merely-mentioned `uses:` as an install. + """ + return any(SETUP_RUST_USES in line.split("#")[0] for line in text.splitlines()) def validate_release_workflow_installs_rust( @@ -319,33 +354,50 @@ def validate_release_workflow_installs_rust( ) -> list[str]: """The release lane must actually REACH the composite, not merely lack a bootstrap. - Every other check here asserts an absence — no dtolnay, no raw bootstrap, - no shadowing RUSTFLAGS. Absence-only checks called a release workflow with - NO Rust install whatsoever "clean": removing the old `curl | sh` step - without adding anything passed the whole suite, and a container build - would have died on `cargo: command not found`. + Most checks here assert an absence — no dtolnay, no raw bootstrap, no + shadowing RUSTFLAGS. Absence-only checks called a release workflow with NO + Rust install whatsoever "clean": removing the old `curl | sh` step without + adding anything passed the whole suite, and a container build would have + died on `cargo: command not found`. + + Scoped to `build-local-artifacts` rather than the file, because the file is + the wrong unit: nothing in ironclaw-release.yml matches a literal `cargo` + (cargo-dist shells out to `dist build`), so + `validate_rust_jobs_reach_the_composite` never covers this workflow and + this is its only guard. A file-wide substring let that job lose its own + install while an unrelated job — or a comment — kept the contract green. ironclaw-release.yml is generated by cargo-dist from the fragment at .github/dist-build-setup.yml, so the step has to exist in BOTH: the - fragment so `dist generate` keeps emitting it, and the checked-in - workflow because that is the file GitHub actually runs. + fragment so `dist generate` keeps emitting it, and the checked-in workflow + because that is the file GitHub actually runs. """ errors: list[str] = [] release = workflows.get(RELEASE_WORKFLOW) - if release is not None and SETUP_RUST_USES not in release: - errors.append( - f"{RELEASE_WORKFLOW}: no `{SETUP_RUST_USES}` step. The container " - "build jobs have no other Rust install path, so this lane would " - "fail with 'cargo: command not found'. Re-run `dist generate` or " - f"re-inline the step from {DIST_BUILD_SETUP}." - ) + if release is not None: + jobs = dict(_job_blocks(release)) + block = jobs.get(RELEASE_BUILD_JOB) + if block is None: + errors.append( + f"{RELEASE_WORKFLOW}: no {RELEASE_BUILD_JOB!r} job. cargo-dist " + "names the container build job; if it was renamed, update " + "RELEASE_BUILD_JOB so this contract keeps guarding it." + ) + elif not _has_composite_step(block): + errors.append( + f"{RELEASE_WORKFLOW}: job {RELEASE_BUILD_JOB!r} has no " + f"`{SETUP_RUST_USES}` step. The container build jobs have no " + "other Rust install path, so this lane would fail with " + "'cargo: command not found'. Re-run `dist generate` or " + f"re-inline the step from {DIST_BUILD_SETUP}." + ) try: fragment = (root / DIST_BUILD_SETUP).read_text(encoding="utf-8") except OSError as error: errors.append(f"{DIST_BUILD_SETUP}: could not read fragment: {error}") else: - if SETUP_RUST_USES not in fragment: + if not _has_composite_step(fragment): errors.append( f"{DIST_BUILD_SETUP}: no `{SETUP_RUST_USES}` step. cargo-dist " "re-inlines this fragment on regeneration, so dropping it here " diff --git a/scripts/ci/test_ws12_workflow_contracts.py b/scripts/ci/test_ws12_workflow_contracts.py index b131e7c46be..a63b8140c13 100755 --- a/scripts/ci/test_ws12_workflow_contracts.py +++ b/scripts/ci/test_ws12_workflow_contracts.py @@ -60,6 +60,98 @@ ) +class GuardBypassRegressionTests(unittest.TestCase): + """Three bypasses a reviewer reproduced against these guards. + + Each let a workflow satisfy a contract while installing no Rust, or hid a + RUSTFLAGS key that shadows the composite. All three are the same species: + a check reading text that merely *looks* like an executable step, or + reading too narrow a slice of the file. + """ + + def test_release_check_ignores_a_decoy_in_an_unrelated_job(self) -> None: + """A commented mention elsewhere must not stand in for the real step.""" + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / ".github").mkdir() + (root / ".github" / "dist-build-setup.yml").write_text( + "- uses: ./.github/actions/setup-rust\n", encoding="utf-8" + ) + decoyed = ( + "jobs:\n" + " build-local-artifacts:\n steps:\n - run: dist build\n" + " docker-image:\n steps:\n" + " # uses: ./.github/actions/setup-rust\n" + " - run: docker build .\n" + ) + errors = validate_release_workflow_installs_rust( + {".github/workflows/ironclaw-release.yml": decoyed}, root + ) + self.assertTrue( + any("build-local-artifacts" in e for e in errors), + f"a decoy comment in another job must not satisfy the contract: {errors}", + ) + + def test_root_env_rustflags_after_jobs_is_caught(self) -> None: + """A top-level `env:` need not precede `jobs:` to apply to every job.""" + text = ( + "name: demo\non:\n push:\n" + "jobs:\n" + " build:\n steps:\n" + " - uses: ./.github/actions/setup-rust\n" + " - run: cargo test\n" + "env:\n" + " RUSTFLAGS: -Dwarnings\n" + ) + errors = validate_no_job_env_rustflags_with_setup_rust( + {".github/workflows/demo.yml": text} + ) + self.assertTrue( + any("workflow-level env" in e for e in errors), + f"a root env: after jobs: shadows every job identically: {errors}", + ) + + def test_an_anchor_is_not_marked_by_a_comment_in_a_sibling_node(self) -> None: + """Anchor scope ends at its own node, not at the next anchor or job.""" + text = ( + "name: demo\non:\n push:\n" + "jobs:\n" + " first:\n steps:\n" + " - &decoy\n" + " run: echo hi\n" + " - name: unrelated\n" + " # uses: ./.github/actions/setup-rust\n" + " run: echo bye\n" + " second:\n steps:\n" + " - *decoy\n" + " - run: cargo build\n" + ) + errors = validate_rust_jobs_reach_the_composite( + {".github/workflows/demo.yml": text} + ) + self.assertTrue( + any("'second'" in e for e in errors), + f"aliasing a decoy anchor must not count as installing Rust: {errors}", + ) + + def test_a_real_alias_still_passes(self) -> None: + """The legitimate release-plz shape must keep working.""" + text = ( + "name: demo\non:\n push:\n" + "x-steps:\n" + " - &install-rust\n" + " uses: ./.github/actions/setup-rust\n" + "jobs:\n" + " build:\n steps:\n" + " - *install-rust\n" + " - run: cargo build\n" + ) + self.assertEqual( + [], + validate_rust_jobs_reach_the_composite({".github/workflows/d.yml": text}), + ) + + class SingleDebugPolicyOwnerTests(unittest.TestCase): """Cargo.toml's [profile.dev] is the only writer of the debug-info value.""" @@ -615,6 +707,18 @@ class ReleaseWorkflowInstallsRustTests(unittest.TestCase): STEP = "uses: ./.github/actions/setup-rust" + def release(self, step: str | None) -> str: + """A release workflow shaped like the real one. + + Job-scoped now, so the fixture has to name the job cargo-dist emits; + a bare `jobs:\n build:` no longer exercises the contract. + """ + body = "jobs:\n plan:\n steps:\n - run: dist plan\n" + body += " build-local-artifacts:\n steps:\n" + body += f" - {step}\n" if step else " - run: dist build\n" + return body + + def test_missing_step_in_the_generated_workflow_fails(self): with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) @@ -638,7 +742,7 @@ def test_missing_step_in_the_fragment_fails(self): "- name: Something else\n", encoding="utf-8" ) errors = validate_release_workflow_installs_rust( - {".github/workflows/ironclaw-release.yml": f" - {self.STEP}\n"}, + {".github/workflows/ironclaw-release.yml": self.release(self.STEP)}, root, ) self.assertTrue( @@ -655,7 +759,7 @@ def test_both_present_passes(self): self.assertEqual( [], validate_release_workflow_installs_rust( - {".github/workflows/ironclaw-release.yml": f" - {self.STEP}\n"}, + {".github/workflows/ironclaw-release.yml": self.release(self.STEP)}, root, ), ) From 6208df321f849794181b183f66e5da1eacd4b6c1 Mon Sep 17 00:00:00 2001 From: Henry Park <16583448+henrypark133@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:01:25 +0000 Subject: [PATCH 38/44] fix(ci): install Rust in the one hermetic lane that acquired it lazily MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the E2E failures that have been red on every run of this branch while T2/T3/T4 from the same base are green. `webui-v2-test-lanes` runs a prebuilt binary and compiles nothing, so it never installed Rust. But `run-hermetic-test-process.sh` probes `rustc --print sysroot` to build the child PATH and exits 1 if it cannot resolve one. Inside the repo that probe hits the `rust-toolchain.toml` this PR adds, so rustup installed the pinned toolchain LAZILY — mid-lane, once per shard, racing its own component downloads: provider operation shard 2/4 failed with status 1 error: component download failed for rustfmt-x86_64-unknown-linux-gnu: could not rename 'downloaded' file ... .partial Confirmed by controlled experiment (#7852): reverting only `[profile.dev] debug = 0` and keeping everything else left E2E red, with a DIFFERENT subset of shards failing — a race signature, and it exonerates the profile block. A survey of every lane invoking the hermetic runners found exactly one in this state; the other seven already install through the composite. So this is one missing step, not a design fault — but it was invisible because the lane compiles nothing, and the whole point of this PR is that toolchain acquisition should never be implicit. `validate_rust_jobs_reach_the_composite` now treats a hermetic-runner invocation as needing a toolchain, the same as a literal `cargo` line. It could not see this before: the workflow text only names the script. Proven red-first — removing the step again fails the gate by name. I called this failure an unrelated rustup flake twice. It was neither: it was this PR's own side effect, visible in the logs from the first run. Verified: ws12 147 tests OK, live gate passed, planner 90 OK, classify-test-scope exit 0, check-guidance OK, reborn-e2e.yml parses. Co-Authored-By: Claude Fable 5 --- .github/workflows/reborn-e2e.yml | 11 ++++++ scripts/ci/lib/rust_toolchain_contracts.py | 24 +++++++++---- scripts/ci/test_ws12_workflow_contracts.py | 39 ++++++++++++++++++++++ 3 files changed, 68 insertions(+), 6 deletions(-) diff --git a/.github/workflows/reborn-e2e.yml b/.github/workflows/reborn-e2e.yml index 83249c668f6..105418e8309 100644 --- a/.github/workflows/reborn-e2e.yml +++ b/.github/workflows/reborn-e2e.yml @@ -408,6 +408,17 @@ jobs: ref: ${{ inputs.ref || (github.event_name == 'pull_request' && github.event.pull_request.head.sha) || github.sha }} persist-credentials: false + # This lane runs a prebuilt binary and compiles nothing, but + # run-hermetic-test-process.sh probes `rustc --print sysroot` to put the + # toolchain's bin on the child PATH and exits 1 if it cannot resolve one. + # Inside the repo that probe hits rust-toolchain.toml, so rustup installed + # the pinned toolchain lazily, mid-lane, once per shard — which is where + # `component download failed for rustfmt: could not rename 'downloaded' + # file` came from. Installing once, up front, as a real step removes the + # lazy install; every other hermetic lane already does this. + - name: Install Rust + uses: ./.github/actions/setup-rust + - name: Download default Reborn E2E binary uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: diff --git a/scripts/ci/lib/rust_toolchain_contracts.py b/scripts/ci/lib/rust_toolchain_contracts.py index 9cf8166cde8..a370f916496 100644 --- a/scripts/ci/lib/rust_toolchain_contracts.py +++ b/scripts/ci/lib/rust_toolchain_contracts.py @@ -295,6 +295,15 @@ def _reaches_composite(block: str, anchors: set[str]) -> bool: # by non-path, non-word characters so `target/cargo-timings` and # `scripts/cargo-foo.sh` do not count as running the compiler. CARGO_INVOCATION = re.compile(r"(? list[str]: @@ -319,16 +328,19 @@ def validate_rust_jobs_reach_the_composite(workflows: dict[str, str]) -> list[st anchors = _composite_anchors(text) for name, block in _job_blocks(text): code = "\n".join(line.split("#")[0] for line in block.splitlines()) - if not CARGO_INVOCATION.search(code): + if not ( + CARGO_INVOCATION.search(code) or NEEDS_TOOLCHAIN_SCRIPT.search(code) + ): continue if _reaches_composite(block, anchors): continue errors.append( - f"{path}: job {name!r} runs cargo but never reaches " - f"`{SETUP_RUST_USES}`. It would build on whatever toolchain " - "the runner image happens to ship, unpinned and without mold " - "-- or fail outright with 'cargo: command not found'. Install " - "Rust through the composite." + f"{path}: job {name!r} needs a Rust toolchain but never reaches " + f"`{SETUP_RUST_USES}`. Either it builds on whatever toolchain " + "the runner image ships (unpinned, no mold), or rustup installs " + "the pin lazily on first use inside the repo -- mid-lane, once " + "per shard, racing its own component downloads. Install Rust " + "through the composite so it happens once, up front." ) return errors diff --git a/scripts/ci/test_ws12_workflow_contracts.py b/scripts/ci/test_ws12_workflow_contracts.py index a63b8140c13..4f05928e5e8 100755 --- a/scripts/ci/test_ws12_workflow_contracts.py +++ b/scripts/ci/test_ws12_workflow_contracts.py @@ -230,6 +230,45 @@ def test_a_cargo_job_with_the_composite_passes(self) -> None: ), ) + def test_a_hermetic_suite_job_needs_the_composite(self) -> None: + """The hermetic runners need rustc without naming it. + + run-hermetic-test-process.sh probes `rustc --print sysroot` and exits 1 + if it cannot resolve one, so a lane invoking it needs Rust exactly as + much as a literal `cargo` line does — but the workflow text only + mentions the script. webui-v2-test-lanes sat in that blind spot: it + compiles nothing, so nobody noticed it needed rustc, and rustup + installed the pin lazily mid-test, once per shard. + """ + text = ( + "name: e2e\non:\n push:\n" + "jobs:\n" + " lanes:\n" + " runs-on: ubuntu-latest\n" + " steps:\n" + " - run: scripts/ci/run-hermetic-deterministic-suite.sh command pytest\n" + ) + errors = validate_rust_jobs_reach_the_composite( + {".github/workflows/e2e.yml": text} + ) + self.assertEqual(1, len(errors), errors) + self.assertIn("needs a Rust toolchain", errors[0]) + + def test_a_hermetic_job_with_the_composite_passes(self) -> None: + text = ( + "name: e2e\non:\n push:\n" + "jobs:\n" + " lanes:\n" + " runs-on: ubuntu-latest\n" + " steps:\n" + " - uses: ./.github/actions/setup-rust\n" + " - run: scripts/ci/run-hermetic-test-process.sh pytest\n" + ) + self.assertEqual( + [], + validate_rust_jobs_reach_the_composite({".github/workflows/e2e.yml": text}), + ) + def test_a_job_that_never_runs_cargo_needs_no_composite(self) -> None: text = ( "name: docs\n" From b628ab8c16441f82e2c871598d04b6f6f12b346f Mon Sep 17 00:00:00 2001 From: Henry Park <16583448+henrypark133@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:25:42 +0000 Subject: [PATCH 39/44] fix(ci): realign the assertion I broke by rewording the guard message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit widened `validate_rust_jobs_reach_the_composite` to cover hermetic lanes and reworded its message from "runs cargo but never reaches" to "needs a Rust toolchain but never reaches" — but an existing test still asserted the old string. Fast-checks went red, and Code Style followed it ("fast-checks failed: failure"), so both new failures had this one cause. Worth recording HOW it shipped, because the mistake was in my verification and not in the edit. The combined-validation command was: python3 scripts/ci/test_ws12_workflow_contracts.py 2>&1 | tail -3 | head -2 `tail -3` yields ["Ran N tests in Xs", "", "OK|FAILED"]; `head -2` then drops the verdict line. The output read "Ran 147 tests in 12.705s" and I took that as a pass. The suite had already been failing locally at that point — the command was constructed so it could not tell me. Validation now keys off exit codes rather than parsed tail text, so a failing suite cannot render as a passing one. Verified (exit codes): ws12 self-tests 0, ws12 gate 0, planner 0, staged-paths 0, suite-shards 0, changed-packages 0, classify-test-scope 0, check-guidance 0, docs-boundary 0. Co-Authored-By: Claude Fable 5 --- scripts/ci/test_ws12_workflow_contracts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/ci/test_ws12_workflow_contracts.py b/scripts/ci/test_ws12_workflow_contracts.py index 4f05928e5e8..67865ddd8b4 100755 --- a/scripts/ci/test_ws12_workflow_contracts.py +++ b/scripts/ci/test_ws12_workflow_contracts.py @@ -219,7 +219,7 @@ def test_a_cargo_job_without_the_composite_is_rejected(self) -> None: {".github/workflows/demo.yml": self.workflow(with_composite=False)} ) self.assertEqual(1, len(errors), errors) - self.assertIn("runs cargo but never reaches", errors[0]) + self.assertIn("needs a Rust toolchain but never reaches", errors[0]) self.assertIn("'build'", errors[0]) def test_a_cargo_job_with_the_composite_passes(self) -> None: From 7fc365bea4ba2ba1768b02902077f308f73b10d6 Mon Sep 17 00:00:00 2001 From: Henry Park <16583448+henrypark133@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:51:04 +0000 Subject: [PATCH 40/44] fix(ci): make the debug-policy guard see where the value was actually written MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review lanes independently reported that `validate_single_debug_policy_owner` cannot detect the regression it exists to prevent, and they are right. The guard scanned `scripts/**` for `KEY=value`. This PR deleted 14 `CARGO_PROFILE_*_DEBUG` lines from `.github/workflows/**`, all in YAML `KEY: value` form. So the guard covered the 3 script-side deletions and was blind to the 14 workflow-side ones — the majority of what it was written to keep deleted. A future PR re-adding a job-env pair would have passed clean. Reproduced before fixing: a scratch tree with a workflow job env containing `CARGO_PROFILE_DEV_DEBUG: 0` returned zero errors, and the regex did not match the YAML form at all. Now scans `scripts/**` and `.github/**` across .sh/.py/.yml/.yaml, and matches both `=` and `:`. Verified the widening is safe: nothing in the live tree matches, and the `run-hermetic-test-process.sh` passthrough allowlist still does not — that entry is a `case` pattern with no assignment character after the name, and it is how a developer's `CARGO_PROFILE_DEV_DEBUG=2` override reaches the child process. A test now pins that it keeps working. Proven red-first in both directions independently: narrowing the scope back to `scripts` fails the two workflow tests; narrowing the syntax back to `=` fails the same two. This is the third time in this PR that a guard I wrote asserted something narrower than the thing it was protecting. The pattern is consistent enough to name: I write the check against the case that prompted it rather than against the invariant, and the surrounding cases go uncovered. Also adds the widening test for `.github/actions/install-cargo-component/` (coverage lane, low severity). Its two sibling shared actions each had one; this path had only the `.github/actions/**` sweep, which asserts the planner does not RAISE but never checks the mode it returns — a mis-ordering could drop it to a `none` plan and the sweep would still pass. Red-first: removing the prefix entry fails the new test. Not changed, with measurements: a performance lane flagged the `Install Rust` step added to the 4 binary-only E2E shards as avoidable cost. Measured on the green run, it is 9-11s per shard on parallel runners, and it replaces a toolchain install that was already happening implicitly inside the test step while racing itself. Removing it entirely means changing the sysroot probe in run-hermetic-test-process.sh, which 8 lanes share. Not worth that risk for ~10s; recorded as a possible follow-up. Verified (exit codes): ws12 150 tests 0, ws12 gate 0, planner 91 tests 0, staged-paths 0, suite-shards 0, changed-packages 0, classify-test-scope 0, check-guidance 0, docs-boundary 0. Co-Authored-By: Claude Fable 5 --- scripts/ci/lib/rust_toolchain_contracts.py | 24 +++++++++-- scripts/ci/test_reborn_pr_test_plan.py | 46 ++++++++++++++++++++++ scripts/ci/test_ws12_workflow_contracts.py | 43 ++++++++++++++++++++ 3 files changed, 110 insertions(+), 3 deletions(-) diff --git a/scripts/ci/lib/rust_toolchain_contracts.py b/scripts/ci/lib/rust_toolchain_contracts.py index a370f916496..0c71860c244 100644 --- a/scripts/ci/lib/rust_toolchain_contracts.py +++ b/scripts/ci/lib/rust_toolchain_contracts.py @@ -426,7 +426,16 @@ def validate_release_workflow_installs_rust( # in a passthrough allowlist (a `case` pattern, no `=`), which is how a # developer's `CARGO_PROFILE_DEV_DEBUG=2` override survives the hermetic # barrier. That entry must keep working, so it must not match here. -DEBUG_POLICY_ASSIGNMENT = re.compile(r"CARGO_PROFILE_[A-Z]+_DEBUG\s*=") +# Both syntaxes, because the value was written in both: shell `KEY=value` in +# scripts, YAML `KEY: value` in workflow job envs. Matching only `=` made this +# guard blind to the 14 workflow lines this PR deleted -- the majority of what +# it exists to keep deleted. +DEBUG_POLICY_ASSIGNMENT = re.compile(r"CARGO_PROFILE_[A-Z]+_DEBUG\s*[:=]") +# Where the value could be written. `scripts/` covers the shell/python side; +# `.github/` covers workflow and composite-action env blocks, which is where +# five job envs carried it before this change. +DEBUG_POLICY_SEARCH_ROOTS = ("scripts", ".github") +DEBUG_POLICY_SUFFIXES = (".sh", ".py", ".yml", ".yaml") DEBUG_POLICY_OWNER = "Cargo.toml" @@ -438,11 +447,20 @@ def validate_single_debug_policy_owner(root: Path = ROOT) -> list[str]: defaults standing in two scripts -- so the change's own claim of a single owner was not true of the whole tree. Two audit lanes reported it independently. + + The first version of this guard then repeated the mistake in miniature: it + scanned only `scripts/**` for `KEY=value`, so it could not see the 14 + workflow lines -- the majority of what it was written to keep deleted. Two + review lanes reported THAT independently. Scope and syntax now cover both + places the value was actually written. """ errors: list[str] = [] - for path in sorted((root / "scripts").rglob("*")): - if not path.is_file() or path.suffix not in (".sh", ".py"): + candidates: list[Path] = [] + for search_root in DEBUG_POLICY_SEARCH_ROOTS: + candidates.extend(sorted((root / search_root).rglob("*"))) + for path in candidates: + if not path.is_file() or path.suffix not in DEBUG_POLICY_SUFFIXES: continue # Test files carry the forbidden string on purpose, as fixtures and # as the sabotage input that proves this check fires. Scanning them diff --git a/scripts/ci/test_reborn_pr_test_plan.py b/scripts/ci/test_reborn_pr_test_plan.py index ba2190bc4ff..25a34c29616 100644 --- a/scripts/ci/test_reborn_pr_test_plan.py +++ b/scripts/ci/test_reborn_pr_test_plan.py @@ -1071,6 +1071,52 @@ def test_shared_setup_rust_action_widens_to_exhaustive_plan(self) -> None: ], ) + def test_shared_install_cargo_component_action_widens_to_exhaustive_plan(self) -> None: + """install-cargo-component is the third shared action, and needs the same pin. + + Its two siblings each have a widening test; this one had only the + `.github/actions/**` sweep, which asserts the planner does not RAISE + but never checks the mode it returns. A mis-ordering against + PR_STATIC_CONTROL_FRAGMENTS could drop this path to a `none` or + `selected` plan and the sweep would still pass. + """ + plan = self.plan("pull_request", [".github/actions/install-cargo-component/action.yml"]) + # Full field-by-field equality, not a handful of spot checks: an + # exhaustive plan has ten fields besides `reasons` that a partial + # assertion would leave silently unpinned (e.g. `run_sandbox_docker` + # regressing to False on a "full" plan would pass every check this + # test used to make). + self.assertEqual( + plan, + { + "mode": "full", + "reasons": [ + "shared reborn action changed; this PR runs the " + "exhaustive plan" + ], + "changed_packages": [], + "affected_packages": ["alpha", "beta", "gamma"], + "crate_buckets": [ + {"name": "selected", "packages": ["alpha", "beta", "gamma"]} + ], + "root_partitions": [0, 1, 2, 3], + "integration_lanes": [0, 1, 2, 3, "groups"], + "run_group_tests": True, + "run_qa_replay": True, + "run_sandbox_docker": True, + "coverage_mode": "full", + }, + ) + + with self.assertRaisesRegex(ValueError, "unmapped test or CI path"): + self.plan( + "pull_request", + [ + ".github/actions/setup-rust/action.yml", + ".github/actions/some-undecided-action/action.yml", + ], + ) + def test_agent_guidance_is_classified_and_selects_no_rust_lane(self) -> None: """`.claude/**` is prose, like `docs/**`. diff --git a/scripts/ci/test_ws12_workflow_contracts.py b/scripts/ci/test_ws12_workflow_contracts.py index 67865ddd8b4..b7e0e344e4e 100755 --- a/scripts/ci/test_ws12_workflow_contracts.py +++ b/scripts/ci/test_ws12_workflow_contracts.py @@ -185,6 +185,49 @@ def test_a_comment_mentioning_the_override_still_passes(self) -> None: root = self.tree("# override per-run: CARGO_PROFILE_DEV_DEBUG=2 cargo test\n") self.assertEqual([], validate_single_debug_policy_owner(root)) + def test_a_workflow_job_env_second_writer_is_rejected(self) -> None: + """YAML `KEY: value` in a workflow job env is the shape actually deleted. + + Reported independently by two review lanes. The first version of this + guard scanned only `scripts/**` for `KEY=value`, so it could not see + the 14 workflow lines this PR removed — the majority of what it exists + to keep removed. A future PR re-adding one would have passed clean. + """ + root = Path(self.enterContext(tempfile.TemporaryDirectory())) + (root / ".github" / "workflows").mkdir(parents=True) + (root / ".github" / "workflows" / "demo.yml").write_text( + "jobs:\n build:\n env:\n CARGO_PROFILE_DEV_DEBUG: 0\n", + encoding="utf-8", + ) + errors = validate_single_debug_policy_owner(root) + self.assertEqual(1, len(errors), errors) + self.assertIn(".github/workflows/demo.yml:4", errors[0]) + + def test_a_quoted_yaml_value_is_rejected(self) -> None: + """One deleted line was `CARGO_PROFILE_DEV_DEBUG: "0"` — quoted.""" + root = Path(self.enterContext(tempfile.TemporaryDirectory())) + (root / ".github" / "workflows").mkdir(parents=True) + (root / ".github" / "workflows" / "d.yml").write_text( + ' CARGO_PROFILE_DEV_DEBUG: "0"\n', encoding="utf-8" + ) + self.assertEqual(1, len(validate_single_debug_policy_owner(root))) + + def test_the_hermetic_passthrough_survives_the_widened_syntax(self) -> None: + """`[:=]` must still not match the `case`-pattern allowlist. + + That entry is how a developer's CARGO_PROFILE_DEV_DEBUG=2 override + reaches the child process; matching it would break the escape hatch + this contract deliberately preserves. + """ + root = Path(self.enterContext(tempfile.TemporaryDirectory())) + (root / "scripts" / "ci").mkdir(parents=True) + (root / "scripts" / "ci" / "h.sh").write_text( + " CARGO_INCREMENTAL|CARGO_PROFILE_DEV_DEBUG|" + "CARGO_PROFILE_TEST_DEBUG|CARGO_TEST_ARGS|\\\n", + encoding="utf-8", + ) + self.assertEqual([], validate_single_debug_policy_owner(root)) + def test_the_live_tree_has_exactly_one_owner(self) -> None: self.assertEqual([], validate_single_debug_policy_owner(ROOT)) From 2d532b262d34eba31b38631e53cd6cf4888f903c Mon Sep 17 00:00:00 2001 From: Henry Park <16583448+henrypark133@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:54:03 +0000 Subject: [PATCH 41/44] docs(agents): replace a universal claim with a verified, re-checkable count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.claude/rules/guidance-maintenance.md` rule 3 forbids universal claims without a count, and rule 2 asks for a one-line command a maintainer can re-run. The paragraph said "Every Rust-installing CI job goes through the composite" — an uncounted absolute. Pointedly, this sentence was introduced by MY fix for the previous universal-claim finding on this same paragraph. I removed "single source of truth" and "every job" from one clause and wrote a fresh absolute into the next. Now: "35 CI jobs across 11 workflow files need a toolchain and all 35 reach the composite today, with no allowlist", plus the command that re-derives it and the validator that enforces it. Counted at this head by walking every job block and testing for a cargo/rustc/rustup invocation or a hermetic-runner call; 35 need one, 35 reach it. Verified (exit codes): check-guidance 0, ws12 gate 0, ws12 self-tests 0. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7c614fabd79..ceba8cc85ab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,9 +24,13 @@ The workspace-root `integration` feature is empty with zero consumers — a bare `.github/actions/setup-rust`'s `toolchain` input default (what CI installs). `scripts/ci/lib/rust_toolchain_contracts.py` enforces that they are equal — it does not make either one derive from the other, so a bump edits both in the -same PR or the gate fails. Every Rust-installing CI job goes through the -composite; the two `nightly-2025-11-01` coverage lanes pass an explicit -`toolchain:` input, and Docker builds are the one path outside this contract +same PR or the gate fails. 35 CI jobs across 11 workflow files need a toolchain +and all 35 reach the composite today, with no allowlist — re-check with +`python3 scripts/ci/ws12_workflow_contracts.py`, whose +`validate_rust_jobs_reach_the_composite` fails the build on a job that runs +cargo, or a hermetic runner, without it. The two `nightly-2025-11-01` coverage +lanes pass an explicit `toolchain:` input, and Docker builds are the one path +outside this contract (the build context excludes `rust-toolchain.toml`, so images stay on their base-image toolchain). The same module also fails the build on a direct `dtolnay/rust-toolchain` call, any other bootstrap, a release lane whose From 89237f63b5fe8451c75130ba4b677c146e4473f6 Mon Sep 17 00:00:00 2001 From: Henry Park <16583448+henrypark133@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:35:21 +0000 Subject: [PATCH 42/44] fix(ci): pin the release step's condition, and scope the coverage claim to it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From your review (#7821 review 5012242062), Medium/86 on the release matrix. The valid core: the contract asserted the composite step's TEXT existed in `build-local-artifacts` and said nothing about WHEN it runs. `if: ${{ matrix.container }}` decides which matrix entries reach the composite at all, so it could be narrowed, widened, or dropped and no check would notice — while this PR claimed every Rust job reaches the composite. Now pinned in both the generated workflow and `.github/dist-build-setup.yml`, since a mismatch is what `dist generate` would silently apply. Proven red-first in both directions: changing the condition names the old and new values; dropping it reports ``. Three regressions, plus the existing fixtures updated to carry the condition (they previously wrote a bare step, so they could not have exercised this). One part of the finding I checked and do not think holds. It says a non-container release build "bypasses the new pin/export path and relies on whatever Rust the hosted runner provides". The pin is not bypassed: with `rust-toolchain.toml` in the repo, rustup resolves it when `dist build` invokes cargo on the hosted runner. What those entries miss is mold and the explicit `RUSTUP_TOOLCHAIN` export, not the toolchain version. It is also not a regression — the pre-composite step carried the identical `if: ${{ matrix.container }}`, so release behaviour is unchanged by this PR. Where the finding lands hardest is the docs, and that is my error. AGENTS.md said "35 CI jobs ... all 35 reach the composite" — true for what it counts, but the count walks `.github/workflows` only and the release lane is outside it entirely (no job there names `cargo`; it shells out to `dist build`). A reader would have taken it as universal. That is the second time in two commits I replaced a universal claim with another one. Now scoped explicitly, naming the release lane's actual shape and what the non-container entries do and do not get. Open question I am not deciding here: whether non-container release builds should install explicitly rather than resolving the pin lazily. That is a release-path behaviour change on a Track C surface, and the lazy resolution is the same mechanism that raced in the E2E lanes — lower risk here (no parallel shards within the job) but still implicit. Verified (exit codes): ws12 153 tests 0, ws12 gate 0, planner 0, classify-test-scope 0, check-guidance 0, docs-boundary 0. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 8 ++- scripts/ci/lib/rust_toolchain_contracts.py | 56 +++++++++++++++++ scripts/ci/test_ws12_workflow_contracts.py | 73 +++++++++++++++++++++- 3 files changed, 133 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ceba8cc85ab..646f5280c72 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,13 @@ same PR or the gate fails. 35 CI jobs across 11 workflow files need a toolchain and all 35 reach the composite today, with no allowlist — re-check with `python3 scripts/ci/ws12_workflow_contracts.py`, whose `validate_rust_jobs_reach_the_composite` fails the build on a job that runs -cargo, or a hermetic runner, without it. The two `nightly-2025-11-01` coverage +cargo, or a hermetic runner, without it. That count covers `.github/workflows` +only; the cargo-dist release lane is outside it, because no job there names +`cargo` (it shells out to `dist build`). There, the composite runs for +container matrix entries only — hosted-runner entries resolve the pin from +`rust-toolchain.toml` themselves but get no mold and no explicit +`RUSTUP_TOOLCHAIN` export. That condition is itself pinned, in both the +generated workflow and `.github/dist-build-setup.yml`. The two `nightly-2025-11-01` coverage lanes pass an explicit `toolchain:` input, and Docker builds are the one path outside this contract (the build context excludes `rust-toolchain.toml`, so images stay on their diff --git a/scripts/ci/lib/rust_toolchain_contracts.py b/scripts/ci/lib/rust_toolchain_contracts.py index 0c71860c244..454d105bdbc 100644 --- a/scripts/ci/lib/rust_toolchain_contracts.py +++ b/scripts/ci/lib/rust_toolchain_contracts.py @@ -352,6 +352,39 @@ def validate_rust_jobs_reach_the_composite(workflows: dict[str, str]) -> list[st RELEASE_BUILD_JOB = "build-local-artifacts" +# The condition cargo-dist's container build job carries on its Rust install, +# in both the fragment and the generated workflow. Pinned, not merely tolerated: +# the contract used to assert the step's TEXT existed and said nothing about +# when it runs, so `if:` could be narrowed, widened, or dropped silently. The +# value itself is deliberate -- container images ship without cargo, hosted +# runners resolve rust-toolchain.toml themselves -- and it matches the +# pre-composite step exactly, so release behaviour is unchanged by this PR. +RELEASE_STEP_CONDITION = "if: ${{ matrix.container }}" + + +def _composite_step_condition(text: str) -> str | None: + """The `if:` guarding the composite step, or None if the step is unguarded. + + Scans the step's own lines: from the `- name:`/`- uses:` that carries the + composite backwards to the step start, so a neighbouring step's condition + cannot be mistaken for this one's. + """ + lines = text.splitlines() + for index, line in enumerate(lines): + if SETUP_RUST_USES not in line.split("#")[0]: + continue + # Walk back to the start of this step (the `- ` marker). + start = index + while start > 0 and not lines[start].lstrip().startswith("- "): + start -= 1 + for candidate in lines[start : index + 1]: + stripped = candidate.split("#")[0].strip().lstrip("- ").strip() + if stripped.startswith("if:"): + return stripped + return None + return None + + def _has_composite_step(text: str) -> bool: """True when some EXECUTABLE line invokes the composite. @@ -396,6 +429,19 @@ def validate_release_workflow_installs_rust( "names the container build job; if it was renamed, update " "RELEASE_BUILD_JOB so this contract keeps guarding it." ) + elif _has_composite_step(block) and _composite_step_condition( + block + ) != RELEASE_STEP_CONDITION: + found = _composite_step_condition(block) or "" + errors.append( + f"{RELEASE_WORKFLOW}: job {RELEASE_BUILD_JOB!r} guards its " + f"Rust install with {found!r}, not {RELEASE_STEP_CONDITION!r}. " + "That condition decides which matrix entries reach the " + "composite at all; changing it changes which release binaries " + "are built with the pinned toolchain and mold, so it is a " + "deliberate edit in both this file and " + f"{DIST_BUILD_SETUP}, not a silent one." + ) elif not _has_composite_step(block): errors.append( f"{RELEASE_WORKFLOW}: job {RELEASE_BUILD_JOB!r} has no " @@ -409,6 +455,16 @@ def validate_release_workflow_installs_rust( except OSError as error: errors.append(f"{DIST_BUILD_SETUP}: could not read fragment: {error}") else: + if _has_composite_step(fragment) and _composite_step_condition( + fragment + ) != RELEASE_STEP_CONDITION: + found = _composite_step_condition(fragment) or "" + errors.append( + f"{DIST_BUILD_SETUP}: guards its Rust install with {found!r}, " + f"not {RELEASE_STEP_CONDITION!r} — the fragment and the " + "generated workflow would disagree the next time cargo-dist " + "regenerates." + ) if not _has_composite_step(fragment): errors.append( f"{DIST_BUILD_SETUP}: no `{SETUP_RUST_USES}` step. cargo-dist " diff --git a/scripts/ci/test_ws12_workflow_contracts.py b/scripts/ci/test_ws12_workflow_contracts.py index b7e0e344e4e..04284398032 100755 --- a/scripts/ci/test_ws12_workflow_contracts.py +++ b/scripts/ci/test_ws12_workflow_contracts.py @@ -797,7 +797,12 @@ def release(self, step: str | None) -> str: """ body = "jobs:\n plan:\n steps:\n - run: dist plan\n" body += " build-local-artifacts:\n steps:\n" - body += f" - {step}\n" if step else " - run: dist build\n" + if step: + body += " - name: Install Rust\n" + body += " if: ${{ matrix.container }}\n" + body += f" {step}\n" + else: + body += " - run: dist build\n" return body @@ -806,7 +811,8 @@ def test_missing_step_in_the_generated_workflow_fails(self): root = Path(tmp) (root / ".github").mkdir() (root / ".github" / "dist-build-setup.yml").write_text( - f"- {self.STEP}\n", encoding="utf-8" + f"- if: ${{{{ matrix.container }}}}\n {self.STEP}\n", + encoding="utf-8", ) errors = validate_release_workflow_installs_rust( {".github/workflows/ironclaw-release.yml": "jobs:\n build:\n"}, @@ -836,7 +842,8 @@ def test_both_present_passes(self): root = Path(tmp) (root / ".github").mkdir() (root / ".github" / "dist-build-setup.yml").write_text( - f"- {self.STEP}\n", encoding="utf-8" + f"- if: ${{{{ matrix.container }}}}\n {self.STEP}\n", + encoding="utf-8", ) self.assertEqual( [], @@ -846,6 +853,66 @@ def test_both_present_passes(self): ), ) + def test_a_changed_release_condition_is_rejected(self) -> None: + """The `if:` decides which matrix entries reach the composite at all. + + Raised on review: the contract asserted the step TEXT existed and said + nothing about when it runs, so the condition could be narrowed, + widened, or dropped and nothing would notice — while the PR claimed + every Rust job reaches the composite. + """ + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / ".github").mkdir() + (root / ".github" / "dist-build-setup.yml").write_text( + f"- if: ${{{{ matrix.container }}}}\n {self.STEP}\n", encoding="utf-8" + ) + wrong = ( + "jobs:\n build-local-artifacts:\n steps:\n" + " - name: Install Rust\n" + " if: ${{ matrix.os == 'linux' }}\n" + f" {self.STEP}\n" + ) + errors = validate_release_workflow_installs_rust( + {".github/workflows/ironclaw-release.yml": wrong}, root + ) + self.assertTrue( + any("guards its Rust install with" in e for e in errors), errors + ) + + def test_an_unconditional_release_step_is_also_flagged(self) -> None: + """Widening is a deliberate edit too — it changes what gets built.""" + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / ".github").mkdir() + (root / ".github" / "dist-build-setup.yml").write_text( + f"- if: ${{{{ matrix.container }}}}\n {self.STEP}\n", encoding="utf-8" + ) + unguarded = ( + "jobs:\n build-local-artifacts:\n steps:\n" + f" - {self.STEP}\n" + ) + errors = validate_release_workflow_installs_rust( + {".github/workflows/ironclaw-release.yml": unguarded}, root + ) + self.assertTrue(any("" in e for e in errors), errors) + + def test_the_fragment_condition_must_match_the_generated_workflow(self) -> None: + """Drift between the two is what regeneration would silently apply.""" + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / ".github").mkdir() + (root / ".github" / "dist-build-setup.yml").write_text( + f"- if: ${{{{ matrix.os }}}}\n {self.STEP}\n", encoding="utf-8" + ) + errors = validate_release_workflow_installs_rust( + {".github/workflows/ironclaw-release.yml": self.release(self.STEP)}, + root, + ) + self.assertTrue( + any("dist-build-setup.yml" in e for e in errors), errors + ) + def test_the_live_release_lane_reaches_the_composite(self): workflows = ws12_workflow_contracts.load_workflows(ROOT) self.assertEqual([], validate_release_workflow_installs_rust(workflows, ROOT)) From 25aff1676403b78bd93ac091ff5df158bbf9dafb Mon Sep 17 00:00:00 2001 From: Henry Park <16583448+henrypark133@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:57:24 +0000 Subject: [PATCH 43/44] fix(ci): close four review findings on the condition and debug-policy guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two Major, both real, both in code I added in the previous two commits. **The condition check had a false positive on the release path.** `_composite_step_condition` scanned only the lines ABOVE the composite `uses:` line. YAML mapping keys are unordered, so a step written `uses:` then `if:` is valid and equivalent — and returned None, reporting `` and rejecting a correct release workflow. Reproduced directly: the reordered step returned None. Now bounded by the step's own `- ` marker and the next line at or left of it, so the whole step body is scanned. Regression both ways: the reordered step passes, and a neighbouring step's `if:` is still not borrowed. **Quoted YAML keys bypassed the debug-policy guard.** `"CARGO_PROFILE_DEV_DEBUG": 0` and `'CARGO_PROFILE_DEV_DEBUG': 0` are the same mapping key as the bare form and matched nothing. Fixed with an optional quote on either side; the `run-hermetic-test-process.sh` passthrough allowlist still does not match, which a test pins. Two smaller ones on the test I added last commit, both correct: - the fail-closed case still probed `.github/actions/setup-rust/` — a leftover from mirroring the sibling test, so it re-tested the sibling's invariant instead of this one. Now probes install-cargo-component. - ISC004 implicit string concatenation in the expected `reasons` list. All three code fixes proven red-first: reverting the whole-step scan fails the reordered-step test; reverting the quoted-key regex fails both quoted forms; removing the prefix entry fails the mirrored test. Pattern worth recording, since it is now consistent. Both Major findings are the same shape as the three before them: I wrote each check against the exact shape in front of me — `if:` before `uses:`, an unquoted YAML key — rather than against the invariant, and the neighbouring valid shapes went unhandled. The guards keep being narrower than the thing they protect. External review has caught every instance; my own tests passed over all of them, because I wrote the tests from the same narrow mental model as the code. Verified (exit codes): ws12 156 tests 0, ws12 gate 0, planner 91 tests 0, check-guidance 0, classify-test-scope 0. Co-Authored-By: Claude Fable 5 --- scripts/ci/lib/rust_toolchain_contracts.py | 26 +++++++-- scripts/ci/test_reborn_pr_test_plan.py | 7 +-- scripts/ci/test_ws12_workflow_contracts.py | 67 ++++++++++++++++++++++ 3 files changed, 89 insertions(+), 11 deletions(-) diff --git a/scripts/ci/lib/rust_toolchain_contracts.py b/scripts/ci/lib/rust_toolchain_contracts.py index 454d105bdbc..c6761a02658 100644 --- a/scripts/ci/lib/rust_toolchain_contracts.py +++ b/scripts/ci/lib/rust_toolchain_contracts.py @@ -365,19 +365,29 @@ def validate_rust_jobs_reach_the_composite(workflows: dict[str, str]) -> list[st def _composite_step_condition(text: str) -> str | None: """The `if:` guarding the composite step, or None if the step is unguarded. - Scans the step's own lines: from the `- name:`/`- uses:` that carries the - composite backwards to the step start, so a neighbouring step's condition - cannot be mistaken for this one's. + Scans the step's WHOLE body, not just the lines above `uses:`. YAML mapping + keys are unordered, so `uses:` may legally precede `if:`; stopping at the + `uses:` line reported such a step as unconditional and would have rejected + a correct release workflow. The step is bounded by its own `- ` marker and + the next line at or left of that marker. """ lines = text.splitlines() for index, line in enumerate(lines): if SETUP_RUST_USES not in line.split("#")[0]: continue - # Walk back to the start of this step (the `- ` marker). start = index while start > 0 and not lines[start].lstrip().startswith("- "): start -= 1 - for candidate in lines[start : index + 1]: + marker_indent = len(lines[start]) - len(lines[start].lstrip()) + end = len(lines) + for offset in range(start + 1, len(lines)): + follower = lines[offset] + if not follower.strip(): + continue + if len(follower) - len(follower.lstrip()) <= marker_indent: + end = offset + break + for candidate in lines[start:end]: stripped = candidate.split("#")[0].strip().lstrip("- ").strip() if stripped.startswith("if:"): return stripped @@ -486,7 +496,11 @@ def validate_release_workflow_installs_rust( # scripts, YAML `KEY: value` in workflow job envs. Matching only `=` made this # guard blind to the 14 workflow lines this PR deleted -- the majority of what # it exists to keep deleted. -DEBUG_POLICY_ASSIGNMENT = re.compile(r"CARGO_PROFILE_[A-Z]+_DEBUG\s*[:=]") +# The key may be bare, double-quoted, or single-quoted -- all three are valid +# YAML for the same mapping key, and a quoted one bypassed this guard. +DEBUG_POLICY_ASSIGNMENT = re.compile( + r"[\"']?CARGO_PROFILE_[A-Z]+_DEBUG[\"']?\s*[:=]" +) # Where the value could be written. `scripts/` covers the shell/python side; # `.github/` covers workflow and composite-action env blocks, which is where # five job envs carried it before this change. diff --git a/scripts/ci/test_reborn_pr_test_plan.py b/scripts/ci/test_reborn_pr_test_plan.py index 25a34c29616..fdaa7cd3ab9 100644 --- a/scripts/ci/test_reborn_pr_test_plan.py +++ b/scripts/ci/test_reborn_pr_test_plan.py @@ -1090,10 +1090,7 @@ def test_shared_install_cargo_component_action_widens_to_exhaustive_plan(self) - plan, { "mode": "full", - "reasons": [ - "shared reborn action changed; this PR runs the " - "exhaustive plan" - ], + "reasons": ["shared reborn action changed; this PR runs the exhaustive plan"], "changed_packages": [], "affected_packages": ["alpha", "beta", "gamma"], "crate_buckets": [ @@ -1112,7 +1109,7 @@ def test_shared_install_cargo_component_action_widens_to_exhaustive_plan(self) - self.plan( "pull_request", [ - ".github/actions/setup-rust/action.yml", + ".github/actions/install-cargo-component/action.yml", ".github/actions/some-undecided-action/action.yml", ], ) diff --git a/scripts/ci/test_ws12_workflow_contracts.py b/scripts/ci/test_ws12_workflow_contracts.py index 04284398032..6f53db4f51b 100755 --- a/scripts/ci/test_ws12_workflow_contracts.py +++ b/scripts/ci/test_ws12_workflow_contracts.py @@ -228,6 +228,26 @@ def test_the_hermetic_passthrough_survives_the_widened_syntax(self) -> None: ) self.assertEqual([], validate_single_debug_policy_owner(root)) + def test_quoted_yaml_keys_are_rejected(self) -> None: + """Bare, double-quoted and single-quoted are one YAML key. + + Quoting the key bypassed this guard entirely. + """ + for form in ( + 'CARGO_PROFILE_DEV_DEBUG: 0', + '"CARGO_PROFILE_DEV_DEBUG": 0', + "'CARGO_PROFILE_DEV_DEBUG': 0", + ): + with self.subTest(form=form): + root = Path(self.enterContext(tempfile.TemporaryDirectory())) + (root / ".github" / "workflows").mkdir(parents=True) + (root / ".github" / "workflows" / "d.yml").write_text( + f"jobs:\n b:\n env:\n {form}\n", encoding="utf-8" + ) + self.assertEqual( + 1, len(validate_single_debug_policy_owner(root)), form + ) + def test_the_live_tree_has_exactly_one_owner(self) -> None: self.assertEqual([], validate_single_debug_policy_owner(ROOT)) @@ -913,6 +933,53 @@ def test_the_fragment_condition_must_match_the_generated_workflow(self) -> None: any("dist-build-setup.yml" in e for e in errors), errors ) + def test_uses_before_if_is_still_read_as_conditional(self) -> None: + """YAML mapping keys are unordered; `uses:` may precede `if:`. + + The first version of this check scanned only the lines ABOVE the + composite `uses:` line, so a step written in that order reported + `` and would have rejected a correct release workflow — + a false positive on the release path. + """ + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / ".github").mkdir() + (root / ".github" / "dist-build-setup.yml").write_text( + f"- {self.STEP}\n if: ${{{{ matrix.container }}}}\n", encoding="utf-8" + ) + reordered = ( + "jobs:\n build-local-artifacts:\n steps:\n" + " - name: Install Rust\n" + f" {self.STEP}\n" + " if: ${{ matrix.container }}\n" + ) + self.assertEqual( + [], + validate_release_workflow_installs_rust( + {".github/workflows/ironclaw-release.yml": reordered}, root + ), + ) + + def test_a_neighbouring_steps_condition_is_not_borrowed(self) -> None: + """Bounding matters in the other direction too.""" + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / ".github").mkdir() + (root / ".github" / "dist-build-setup.yml").write_text( + f"- if: ${{{{ matrix.container }}}}\n {self.STEP}\n", encoding="utf-8" + ) + borrowed = ( + "jobs:\n build-local-artifacts:\n steps:\n" + " - name: Something else\n" + " if: ${{ matrix.container }}\n" + " run: echo hi\n" + f" - {self.STEP}\n" + ) + errors = validate_release_workflow_installs_rust( + {".github/workflows/ironclaw-release.yml": borrowed}, root + ) + self.assertTrue(any("" in e for e in errors), errors) + def test_the_live_release_lane_reaches_the_composite(self): workflows = ws12_workflow_contracts.load_workflows(ROOT) self.assertEqual([], validate_release_workflow_installs_rust(workflows, ROOT)) From fb2eb453a61904001ee035337f448602afe1da50 Mon Sep 17 00:00:00 2001 From: Henry Park <16583448+henrypark133@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:29:07 +0000 Subject: [PATCH 44/44] PROBE (do not merge): force the Windows lanes onto the PR path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Not for merge. Closed as soon as it reports. clippy-windows and windows-build are gated `github.event_name == 'push'`, which excludes merge_group — so they run only on push to main or nightly. That means T1 swaps their Rust install to .github/actions/setup-rust, and the composite's FIRST execution on a windows-latest runner would happen after merge, on main. The repo's CI contract says no deterministic failure may be main-only. The composite gates its mold steps on runner.os == 'Linux' and validate_setup_rust_action pins that gating, but pinned-by-a-text-check is not the same as executed. This run executes it. Only the job conditions are changed; the composite and every call site are exactly T1's. Co-Authored-By: Claude Fable 5 --- .github/workflows/code_style.yml | 2 +- .github/workflows/platform-and-compat.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/code_style.yml b/.github/workflows/code_style.yml index 8d3b2caff66..679e99ac318 100644 --- a/.github/workflows/code_style.yml +++ b/.github/workflows/code_style.yml @@ -443,7 +443,7 @@ jobs: clippy-windows: name: Clippy Windows (${{ matrix.name }}) needs: changes - if: needs.changes.outputs.has_code == 'true' && github.event_name == 'push' + if: ${{ always() }} # PROBE: force the Windows lane onto this PR runs-on: windows-latest timeout-minutes: 60 strategy: diff --git a/.github/workflows/platform-and-compat.yml b/.github/workflows/platform-and-compat.yml index 140b1b4efd8..a3ba578954d 100644 --- a/.github/workflows/platform-and-compat.yml +++ b/.github/workflows/platform-and-compat.yml @@ -198,7 +198,7 @@ jobs: windows-build: name: Windows Build (${{ matrix.name }}) needs: changes - if: needs.changes.outputs.docs_only != 'true' && needs.changes.outputs.has_legacy_tests == 'true' && (github.event_name == 'push' || inputs.deep == true) + if: ${{ always() }} # PROBE: force onto this PR runs-on: windows-latest timeout-minutes: 60 strategy: