From 38799d0e7a9bfe9e525b77208b148cc1bcf6b0bb Mon Sep 17 00:00:00 2001 From: David Spencer <1526975+DecisionNerd@users.noreply.github.com> Date: Fri, 7 Aug 2026 00:07:01 -0600 Subject: [PATCH 1/4] build(bazel): cut over CI Gate to Bazel and retire PR Cargo sticky disks Make Bazel //:ci_rust_tests authoritative under the unchanged CI Gate name, retire the Cargo rust-test job and PR sticky target/ mounts, and document a one-release-cycle Cargo diagnostic/rollback path after #5 perf evidence. Co-authored-by: Cursor --- .github/workflows/README.md | 41 +++++---- .github/workflows/test.yml | 97 ++++----------------- BUILD.bazel | 17 +++- docs/development/bazel-bootstrap.md | 8 +- docs/development/bazel-migration-cutover.md | 87 ++++++++++++++++++ docs/development/bazel-migration-ledger.md | 11 +-- docs/development/bazel-migration-parity.md | 27 +++--- docs/development/bazel-migration-perf.md | 4 +- docs/engineering/TESTING.md | 6 +- scripts/ci/classify-changes.sh | 7 ++ scripts/ci/test-ci-storage-policy.py | 33 ++++++- scripts/ci/test-classify-changes.sh | 14 +-- 12 files changed, 214 insertions(+), 138 deletions(-) create mode 100644 docs/development/bazel-migration-cutover.md diff --git a/.github/workflows/README.md b/.github/workflows/README.md index de7009d83..ff49663d4 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -14,15 +14,14 @@ Wall-clock targets and the dual-track table live in `publish.yaml` consumes a retained Binding RC candidate (no rebuild-on-write) after a GitHub Release / release identity exists for that SHA. -Linux jobs run on the pinned `blacksmith-4vcpu-ubuntu-2404` image. Native PR -jobs use Blacksmith sticky disks for job-isolated Cargo `target/` directories, -while registry and pnpm dependencies use the colocated cache through upstream -`actions/cache@v6` and `actions/setup-node@v6`. Cargo-lock changes create fresh -sticky disks; inactive disks expire under Blacksmith's retention policy. -The M1 host-native release load matrix also mounts a sticky `target/` so maturin, Cargo, -and napi share one build volume instead of a second root-disk tree. -Binding RC is expected to use the same Blacksmith-first sticky + colocated-cache -model (see storage policy tests); put `target/` on sticky disks, not in +Linux jobs run on the pinned `blacksmith-4vcpu-ubuntu-2404` image. After the +Bazel CI Gate cutover (#4), Test Suite no longer mounts job-isolated Cargo +`target/` sticky disks; authoritative Rust compile/test is Bazel under +`Bazel Bootstrap` (`//:ci_rust_tests`). Registry and pnpm dependencies still use +the colocated cache through upstream `actions/cache@v6` and `actions/setup-node`. +Binding RC and the M1 host-native release load matrix retain sticky `target/` +volumes so maturin, Cargo, and napi share one build volume for packaging lanes +(see storage policy tests); put `target/` on sticky disks there, not in `actions/cache` blobs. ### Blacksmith-first CI storage policy @@ -51,10 +50,9 @@ ${{ github.repository }}-binding-rc-linux-rust--${{ hashFiles('Cargo. ${{ github.repository }}-release_candidate-rust--${{ hashFiles('Cargo.lock') }}-release-target-v1 ``` -PR sticky keys stay job-isolated: -`${{ github.repository }}-${{ github.job }}-${{ hashFiles('Cargo.lock') }}-target-v1`. -macOS/Windows RC cells use larger Blacksmith runners + colocated registry cache; -use sticky disks there only when the platform supports them. +PR Test Suite sticky keys are retired after #4. macOS/Windows RC cells use +larger Blacksmith runners + colocated registry cache; use sticky disks there +only when the platform supports them. ## Pull-request contract @@ -64,12 +62,11 @@ use sticky disks there only when the platform supports them. ADR 0014 domain-dependency directions, and license compliance. - Documentation and packaging-metadata-only changes do not compile Rust or native bindings. -- Rust changes run formatting and Clippy in one clean job and workspace tests - in another. The workspace test already contains the Rust BDD target, so CI - does not compile it twice. The same Rust classification also runs the Windows - `graphforge-storage` `project_generation` lock unit tests on - `blacksmith-4vcpu-windows-2025` (Linux workspace tests cannot execute those - `#[cfg(windows)]` cases). +- Rust changes run Cargo formatting/Clippy (`Rust Quality`) and authoritative + Bazel tests (`Bazel Bootstrap` → `//:ci_rust_tests`, including API BDD). The + same Rust classification also runs the Windows `graphforge-storage` + `project_generation` lock unit tests on `blacksmith-4vcpu-windows-2025` + (Linux Bazel CI cannot execute those `#[cfg(windows)]` cases). - Python, Gherkin, public binding, Pulumi static-validation, and Terraform static-validation gates run only when their owned surfaces change. Shared GraphForge configuration and infrastructure contract fixtures run both IaC @@ -92,8 +89,10 @@ use sticky disks there only when the platform supports them. ### `test.yml` — Test Suite Runs the change classifier, repository policy, and only the applicable Rust, -Python, Gherkin, native binding, Pulumi, or Terraform jobs. Pull-request native -acceptance is Linux-only and uses Cargo's `dev` profile. +Python, Gherkin, native binding, Pulumi, Terraform, or Bazel jobs. Pull-request +native binding acceptance is Linux-only and uses Cargo's `dev` profile for +maturin/napi assembly. Authoritative Rust compile/test is Bazel +(`//:ci_rust_tests`) under `Bazel Bootstrap`. When Rust surfaces change, `Windows graphforge-storage Locks` runs `cargo test -p graphforge-storage project_generation::tests:: --lib` on `blacksmith-4vcpu-windows-2025` so the `#[cfg(windows)]` project-root lock unit diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f9941367d..e35922fcd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -437,12 +437,7 @@ jobs: toolchain: "1.96.0" components: clippy, rustfmt - - name: Mount Cargo build disk - uses: useblacksmith/stickydisk@v1 - with: - key: ${{ github.repository }}-${{ github.job }}-${{ hashFiles('Cargo.lock') }}-target-v1 - path: target - + # No Cargo sticky disk after #4 cutover; fmt/clippy remain Cargo diagnostics. - name: Cache Cargo registry uses: actions/cache@v6 with: @@ -457,43 +452,6 @@ jobs: - name: Run Clippy run: cargo clippy --workspace -- -D warnings - rust-test: - name: Rust Tests - runs-on: blacksmith-4vcpu-ubuntu-2404 - needs: changes - if: needs.changes.outputs.rust == 'true' - timeout-minutes: 30 - env: - CARGO_INCREMENTAL: 0 - CARGO_PROFILE_TEST_DEBUG: 0 - BDD_TIMING_DIR: target/bdd-timings - BDD_RUNNER_LABEL: blacksmith-4vcpu-ubuntu-2404 - steps: - - name: Checkout code - uses: actions/checkout@v7 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@master - with: - toolchain: "1.96.0" - - - name: Mount Cargo build disk - uses: useblacksmith/stickydisk@v1 - with: - key: ${{ github.repository }}-${{ github.job }}-${{ hashFiles('Cargo.lock') }}-target-v1 - path: target - - - name: Cache Cargo registry - uses: actions/cache@v6 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - key: ${{ runner.os }}-cargo-registry-v1-${{ hashFiles('Cargo.lock') }} - - - name: Run workspace tests once - run: cargo test --workspace --no-fail-fast - python-binding: name: Python Binding runs-on: blacksmith-4vcpu-ubuntu-2404 @@ -527,12 +485,8 @@ jobs: - name: Install workspace dependencies for cross-package publication tests run: pnpm install --frozen-lockfile - - name: Mount Cargo build disk - uses: useblacksmith/stickydisk@v1 - with: - key: ${{ github.repository }}-${{ github.job }}-${{ hashFiles('Cargo.lock') }}-target-v1 - path: target - + # PR binding assembly still uses maturin for acceptance suites; sticky + # Cargo target/ disks are retired after #4 (Binding RC retains sticky). - name: Cache Cargo build uses: actions/cache@v6 with: @@ -677,12 +631,8 @@ jobs: with: toolchain: "1.96.0" - - name: Mount Cargo build disk - uses: useblacksmith/stickydisk@v1 - with: - key: ${{ github.repository }}-${{ github.job }}-${{ hashFiles('Cargo.lock') }}-target-v1 - path: target - + # PR binding assembly still uses napi for acceptance suites; sticky + # Cargo target/ disks are retired after #4 (Binding RC retains sticky). - name: Cache Cargo build uses: actions/cache@v6 with: @@ -791,20 +741,6 @@ jobs: cache: pnpm cache-dependency-path: pnpm-lock.yaml - - name: Mount Cargo build disk - uses: useblacksmith/stickydisk@v1 - with: - key: ${{ github.repository }}-${{ github.job }}-${{ hashFiles('Cargo.lock') }}-target-v1 - path: target - - - name: Cache Cargo registry - uses: actions/cache@v6 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - key: ${{ runner.os }}-cargo-registry-v1-${{ hashFiles('Cargo.lock') }} - - name: Download same-SHA Python wheel uses: actions/download-artifact@v8 with: @@ -836,7 +772,7 @@ jobs: --output "${{ runner.temp }}/concurrency-short-evidence" # #[cfg(windows)] project-root lock unit tests in graphforge-storage do not run on - # Linux rust-test. Host them here — not on Binding RC python-windows (#2700). + # Linux Bazel CI. Host them here — not on Binding RC python-windows (#2700). windows-graphforge-storage-locks: name: Windows graphforge-storage Locks runs-on: blacksmith-4vcpu-windows-2025 @@ -870,13 +806,14 @@ jobs: --no-fail-fast bazel-bootstrap: + # Job display name kept for continuity; this is the authoritative Rust + # compile/test path under CI Gate after #4 cutover. name: Bazel Bootstrap runs-on: blacksmith-4vcpu-ubuntu-2404 needs: changes if: needs.changes.outputs.bazel == 'true' - # Pair collection (when evidence is incomplete) re-builds the representative - # surface across distinct output bases; allow a long but finite window. - timeout-minutes: 360 + # Authoritative //:ci_rust_tests + diagnostic parity + cache observation. + timeout-minutes: 120 steps: - name: Checkout code uses: actions/checkout@v7 @@ -906,15 +843,16 @@ jobs: # Do not set --remote_cache; Blacksmith injects repository cache. python3 scripts/ci/bazel-cache-perf.py --mode policy python3 scripts/ci/test-bazel-cache-perf.py - python3 scripts/ci/bazel-cache-perf.py --mode evaluate --allow-pending \ + # Strict evaluate: #5 evidence is complete (no --allow-pending). + python3 scripts/ci/bazel-cache-perf.py --mode evaluate \ --evidence docs/development/bazel-migration-evidence/perf-sample.json - - name: Bazel smoke + first-party libs/tests/CLI/resources + - name: Authoritative Bazel Rust tests + first-party libs/CLI/resources run: | set -euo pipefail # Do not set --remote_cache; Blacksmith injects repository cache. mkdir -p dist - bazelisk test //:bazel_test_graph_smoke 2>&1 | tee dist/bazel-test-graph-smoke.log + bazelisk test //:ci_rust_tests 2>&1 | tee dist/bazel-ci-rust-tests.log bazelisk build \ //:bazel_smoke \ //:first_party_libs \ @@ -932,9 +870,10 @@ jobs: 2>&1 | tee dist/bazel-binding-build.log python3 scripts/ci/test-assemble-bazel-binding-packages.py - - name: Same-SHA Cargo/Bazel dual-build parity + - name: Same-SHA Cargo/Bazel dual-build parity (diagnostic, one release cycle) run: | - # Dual-build remains under CI Gate; Bazel is not sole authority yet (#4). + # Cargo is no longer authoritative under CI Gate (#4). Keep same-SHA + # parity as a diagnostic for one release cycle; see cutover rollback doc. # Do not set --remote_cache; Blacksmith injects repository cache. mkdir -p dist python3 scripts/ci/cargo-bazel-parity-check.py \ @@ -1089,7 +1028,6 @@ jobs: - pulumi-static - terraform-static - rust-lint - - rust-test - python-binding - node-binding - concurrency-matrix @@ -1110,7 +1048,6 @@ jobs: "${{ needs.pulumi-static.result }}" "${{ needs.terraform-static.result }}" "${{ needs.rust-lint.result }}" - "${{ needs.rust-test.result }}" "${{ needs.python-binding.result }}" "${{ needs.node-binding.result }}" "${{ needs.concurrency-matrix.result }}" diff --git a/BUILD.bazel b/BUILD.bazel index 5e04f73bf..f48e23603 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -216,8 +216,8 @@ build_test( ], ) -# Representative #8 surface for Blacksmith Bazel Bootstrap (excludes full TCK BDD -# wall-time; BDD remains in //:bdd_tests for explicit/full runs). +# Representative #8 surface for Blacksmith cache/perf measurement (#5). +# Excludes full API BDD wall-time; BDD remains in //:bdd_tests / //:ci_rust_tests. test_suite( name = "bazel_test_graph_smoke", tests = [ @@ -237,6 +237,19 @@ test_suite( ], ) +# Authoritative PR Rust compile/test graph under CI Gate after #4 cutover. +# Replaces the retired Cargo `rust-test` workspace job. +test_suite( + name = "ci_rust_tests", + tests = [ + ":unit_tests", + ":integration_tests", + ":snapshot_tests", + ":cli_tests", + ":bdd_tests", + ], +) + # Host-native release artifact aggregate (#6). Cross-OS Binding RC surfaces are # modeled under //platforms:* and tools/bazel/release/release_platforms.json. build_test( diff --git a/docs/development/bazel-bootstrap.md b/docs/development/bazel-bootstrap.md index 435fde13b..a766f5307 100644 --- a/docs/development/bazel-bootstrap.md +++ b/docs/development/bazel-bootstrap.md @@ -158,7 +158,7 @@ CARGO_BAZEL_REPIN=1 bazelisk build --repo_env=CARGO_BAZEL_REPIN=1 //:first_party ## Next -1. Complete [#5](https://github.com/CurateLabs/graphforge/issues/5) after org-admin - enablement + ≥10 paired cold/warm runs (strict `evaluate` without - `--allow-pending`). -2. Then [#4](https://github.com/CurateLabs/graphforge/issues/4) — `CI Gate` cutover. +1. [#5](https://github.com/CurateLabs/graphforge/issues/5) cache/perf evidence — + see [bazel-migration-perf.md](bazel-migration-perf.md). +2. [#4](https://github.com/CurateLabs/graphforge/issues/4) — `CI Gate` cutover; + see [bazel-migration-cutover.md](bazel-migration-cutover.md). diff --git a/docs/development/bazel-migration-cutover.md b/docs/development/bazel-migration-cutover.md new file mode 100644 index 000000000..9b9286122 --- /dev/null +++ b/docs/development/bazel-migration-cutover.md @@ -0,0 +1,87 @@ +# Bazel migration CI Gate cutover (#4) + +Implements sequence step 9 of [#1](https://github.com/CurateLabs/graphforge/issues/1) +via child issue [#4](https://github.com/CurateLabs/graphforge/issues/4). + +Companion artifacts: + +- Orchestration: [bazel-migration-orchestration.md](bazel-migration-orchestration.md) +- Parity (#6): [bazel-migration-parity.md](bazel-migration-parity.md) +- Cache/perf (#5): [bazel-migration-perf.md](bazel-migration-perf.md) +- Ledger: [bazel-migration-ledger.md](bazel-migration-ledger.md) + +## Cutover contract + +| Piece | After #4 | +| --- | --- | +| Required check name | Exactly **`CI Gate`** (unchanged) | +| Authoritative Rust compile/test | `Bazel Bootstrap` → `bazelisk test //:ci_rust_tests` (+ libs/CLI/resources/bindings builds) | +| Retired | Cargo `rust-test` workspace job; PR job-isolated Cargo `target/` sticky disks | +| Retained Cargo diagnostics | `Rust Quality` (fmt/clippy); Windows `graphforge-storage` lock unit tests; PR maturin/napi binding assembly (no sticky); Binding RC / fuzz / M1 sticky packaging lanes | +| Path-classified skips | Remain neutral via `require-gates.sh` (`success` or `skipped`) | +| Dual-build parity | Diagnostic under `Bazel Bootstrap` for **one release cycle** | + +Do **not** set `--remote_cache` in-repo. Blacksmith injects repository Bazel caching. + +## What changed in CI + +1. Classifier: any `rust=true` change also enables `bazel=true`, so the authoritative + Bazel job always runs for Rust surfaces. +2. `rust-test` (`cargo test --workspace`) removed from `.github/workflows/test.yml` + and from `CI Gate` `needs`. +3. All five PR sticky mounts + (`${{ github.repository }}-${{ github.job }}-${{ hashFiles('Cargo.lock') }}-target-v1`) + removed from Test Suite. +4. `Bazel Bootstrap` runs `//:ci_rust_tests` (unit + integration + snapshot + CLI + API BDD) + as the required Rust test graph. +5. Same-SHA Cargo/Bazel parity remains as a **diagnostic** step for one release cycle. + +## Cargo diagnostic / rollback (one release cycle) + +Use this if Bazel CI misbehaves and maintainers need Cargo as a temporary +authoritative path. Keep Cargo manifests and local `cargo` tooling regardless. + +### Local diagnostic (no workflow change) + +```bash +cargo fmt --all -- --check +cargo clippy --workspace -- -D warnings +cargo test --workspace --no-fail-fast + +python3 scripts/ci/cargo-bazel-parity-check.py \ + --mode all \ + --write-evidence dist/cargo-bazel-parity-evidence.json +``` + +### Restore Cargo `rust-test` under CI Gate (rollback) + +1. Restore the `rust-test` job from git history prior to the #4 cutover commit + (search `.github/workflows/test.yml` for `name: Rust Tests`). +2. Re-add `rust-test` to `ci-gate` `needs` and to `scripts/ci/require-gates.sh` args. +3. Optionally re-mount PR sticky disks for `rust-test` / `rust-lint` only + (update `EXPECTED_STICKY_KEYS` / `EXPECTED_DEPENDENCY_KEYS` in + `scripts/ci/test-ci-storage-policy.py` in the same change). +4. Keep required check name **`CI Gate`**. Do not invent a second required context. +5. Prefer fixing Bazel root causes; treat this rollback as temporary for one + release cycle after cutover, then remove again once Bazel is healthy. + +### Binding RC / publish sticky disks + +Not retired by #4. Linux Binding RC and M1 release-load sticky `target/` volumes +remain for packaging/publish-track lanes (`RT-maturin-assemble` / `RT-napi-assemble` +handoff). Fuzz retains its sticky disk as a justified retained tool. + +## Acceptance mapping + +| #4 / #1 AC | Evidence | +| --- | --- | +| Bazel authoritative under `CI Gate` | `Bazel Bootstrap` runs `//:ci_rust_tests`; `rust-test` absent from Test Suite / gate | +| Branch protection still requires exactly `CI Gate` | Job display name unchanged; no second required context | +| Cargo sticky disks retired without weakening gates | PR sticky keys gone; Binding RC/fuzz/M1 retained; storage-policy tests updated | +| Documented Cargo rollback one release cycle | This document | +| Path-classified skips remain neutral | `require-gates.sh` still accepts `skipped` | + +## Next + +[#3](https://github.com/CurateLabs/graphforge/issues/3) — docs/observability/#1 +close-readiness evidence map. diff --git a/docs/development/bazel-migration-ledger.md b/docs/development/bazel-migration-ledger.md index c12476726..1d4537ed5 100644 --- a/docs/development/bazel-migration-ledger.md +++ b/docs/development/bazel-migration-ledger.md @@ -174,15 +174,16 @@ Frozen scan of `.github/workflows/`, `scripts/`, and `Makefile` for `cargo`, **27** files. Representative required path is `CI Gate` via `.github/workflows/test.yml` on Blacksmith runners. -### Sticky Cargo `target/` disks (retire only after #4 evidence) +### Sticky Cargo `target/` disks (#4 cutover) -Workflows using `useblacksmith/stickydisk` at freeze: +After [#4](https://github.com/CurateLabs/graphforge/issues/4), Test Suite +(`.github/workflows/test.yml`) no longer mounts PR job-isolated Cargo sticky +disks. Retained sticky workflows (packaging / retained tools): - `.github/workflows/binding-release-candidate.yml` -- `.github/workflows/test.yml` - `.github/workflows/m1-release-certification.yml` - `.github/workflows/fuzz.yml` -Primary `test.yml` sticky key pattern: +Retired PR sticky key pattern (do not reintroduce without rollback docs): `${{ github.repository }}-${{ github.job }}-${{ hashFiles('Cargo.lock') }}-target-v1` → `target/`. ### Sites by file @@ -195,7 +196,7 @@ Primary `test.yml` sticky key pattern: | `.github/workflows/fuzz.yml` | 6 | cargo-fuzz (retained-tool candidate) | | `.github/workflows/m1-release-certification.yml` | 4 | Release load certification | | `.github/workflows/non-cypher-surface-gate.yml` | 4 | Non-Cypher surface gate | -| `.github/workflows/test.yml` | 6 | Required CI Gate compile/test/bindings | +| `.github/workflows/test.yml` | 6 | Required CI Gate (Bazel authority + Cargo lint/bindings; #4 cutover) | | `.github/workflows/visualization-limits-stress.yml` | 1 | Visualization stress | | `Makefile` | 17 | Developer/CI mirrors | | `scripts/ci/clean-env-verify.py` | 1 | Build/test/package command site | diff --git a/docs/development/bazel-migration-parity.md b/docs/development/bazel-migration-parity.md index ad1e22c50..803841d9c 100644 --- a/docs/development/bazel-migration-parity.md +++ b/docs/development/bazel-migration-parity.md @@ -20,23 +20,26 @@ Bootstrap: [bazel-bootstrap.md](bazel-bootstrap.md). | Representative suite | `tools/bazel/parity/parity_suite.json` / `//:parity_suite` | | Host release bins | `//:release_bins` (CLI + all 11 API examples) | | Packaging tags | `assemble_bazel_binding_packages.py --wheel-tag` / `--platform-tag` | -| CI | `Bazel Bootstrap` dual-build steps; required check remains `CI Gate` | +| CI | `Bazel Bootstrap` (authoritative after #4) + diagnostic dual-build parity; required check remains `CI Gate` | ## Acceptance mapping | #6 / #1 AC theme | Evidence | | --- | --- | -| Every mapped test/public contract same pass/fail on Cargo and Bazel at one SHA | `cargo-bazel-parity-check.py --mode all` writes `dist/cargo-bazel-parity-evidence.json`; Cargo `rust-test` + Bazel Bootstrap remain dual-build under `CI Gate` | +| Every mapped test/public contract same pass/fail on Cargo and Bazel at one SHA | `cargo-bazel-parity-check.py --mode all` writes `dist/cargo-bazel-parity-evidence.json`; after #4, Bazel `//:ci_rust_tests` is authoritative and parity remains diagnostic for one release cycle | | Linux/macOS/Windows + Node cross-target release evidence | Platform inventory covers Binding RC contract + `napi.targets` (incl. `aarch64-unknown-linux-gnu`); host `//:release_bins` + binding smokes build under Bazel | | Unmapped target / unjustified exception fails ledger | `bazel-migration-ledger-check.py` rejects `unmapped` rows and `stub` exceptions | ## Dual-build contract -- Cargo remains required for ordinary CI compilation/tests through #4 cutover. -- Bazel Bootstrap runs drift, ledger, release-platform inventory, smoke tests, - release bins, binding packaging, and the dual-build parity suite. -- Required check name stays **`CI Gate`**. Do not make Bazel the sole path yet. -- Do **not** set `--remote_cache` (Blacksmith injects cache; enablement is #5). +- After [#4](https://github.com/CurateLabs/graphforge/issues/4) cutover, Bazel + `//:ci_rust_tests` is authoritative under `CI Gate`. Cargo `rust-test` is + retired; see [bazel-migration-cutover.md](bazel-migration-cutover.md). +- Bazel Bootstrap runs drift, ledger, release-platform inventory, authoritative + Rust tests, release bins, binding packaging, and diagnostic dual-build parity + for one release cycle. +- Required check name stays **`CI Gate`**. +- Do **not** set `--remote_cache` (Blacksmith injects cache). ## Local commands @@ -69,8 +72,8 @@ bazelisk test //:parity_suite //:bazel_test_graph_smoke ## Next -1. [#5](https://github.com/CurateLabs/graphforge/issues/5) — see - [bazel-migration-perf.md](bazel-migration-perf.md) (org-admin Bazel Build - Caching still required for remote hits / close). -2. [#4](https://github.com/CurateLabs/graphforge/issues/4) — only after #5 is - honestly closable. +1. [#4](https://github.com/CurateLabs/graphforge/issues/4) cutover — see + [bazel-migration-cutover.md](bazel-migration-cutover.md) (landed when #5 + performance gates are complete). +2. [#3](https://github.com/CurateLabs/graphforge/issues/3) — docs/observability + close-readiness. diff --git a/docs/development/bazel-migration-perf.md b/docs/development/bazel-migration-perf.md index 8d946aa5e..73b54f545 100644 --- a/docs/development/bazel-migration-perf.md +++ b/docs/development/bazel-migration-perf.md @@ -151,5 +151,5 @@ Close [#5](https://github.com/CurateLabs/graphforge/issues/5) only when: 3. Cache-unavailable cold correctness and affected-input isolation are proven. 4. Exact SHA + Blacksmith dashboard links are in closure notes. -Do **not** start [#4](https://github.com/CurateLabs/graphforge/issues/4) until #5 is -honestly closable. +[#5](https://github.com/CurateLabs/graphforge/issues/5) is closed with complete +evidence. Cutover: [bazel-migration-cutover.md](bazel-migration-cutover.md) / [#4](https://github.com/CurateLabs/graphforge/issues/4). diff --git a/docs/engineering/TESTING.md b/docs/engineering/TESTING.md index 20340d120..5f711cbcc 100644 --- a/docs/engineering/TESTING.md +++ b/docs/engineering/TESTING.md @@ -112,10 +112,12 @@ artifacts are publication evidence — see `AGENTS.md` § Issue close. lanes are fine; failed or cancelled applicable jobs are not. - PR native binding acceptance is **Linux-only** and uses Cargo’s `dev` profile. That is fast feedback, not multi-OS certification. -- When Rust surfaces change, Test Suite also runs `Windows graphforge-storage Locks` +- When Rust surfaces change, Test Suite runs authoritative Bazel tests + (`Bazel Bootstrap` → `//:ci_rust_tests`) plus Cargo fmt/clippy, and also runs + `Windows graphforge-storage Locks` (`cargo test -p graphforge-storage project_generation::tests:: --lib` on `blacksmith-4vcpu-windows-2025`) for the `#[cfg(windows)]` project-root lock - unit tests that Linux `rust-test` cannot execute. + unit tests that Linux Bazel CI cannot execute. - Repository policy always validates workflow syntax, the classifier, domain dependency directions, license compliance, and the ledgers that back later release gates (without running those heavy matrices on every PR). diff --git a/scripts/ci/classify-changes.sh b/scripts/ci/classify-changes.sh index c9ba6708d..fdd8122d8 100755 --- a/scripts/ci/classify-changes.sh +++ b/scripts/ci/classify-changes.sh @@ -118,6 +118,7 @@ while IFS= read -r -d '' path; do docs/development/bazel-migration-ledger.md | \ docs/development/bazel-migration-perf.md | \ docs/development/bazel-migration-baseline.md | \ + docs/development/bazel-migration-cutover.md | \ docs/development/bazel-migration-evidence/* | \ docs/development/bazel-migration-evidence/**/* | \ scripts/ci/cargo-bazel-drift-check.py | \ @@ -244,4 +245,10 @@ while IFS= read -r -d '' path; do esac done <"$changed_files" +# After #4 cutover, Bazel is the authoritative Rust compile/test path under +# CI Gate. Any Rust-classified change must also enable the Bazel job. +if [[ "$rust" == "true" ]]; then + bazel=true +fi + emit diff --git a/scripts/ci/test-ci-storage-policy.py b/scripts/ci/test-ci-storage-policy.py index 50ce40f4a..730e1fe1a 100644 --- a/scripts/ci/test-ci-storage-policy.py +++ b/scripts/ci/test-ci-storage-policy.py @@ -24,8 +24,9 @@ partitions (1-day transfer vs 30-day publication groups). Expected Binding RC Linux sticky keys use repository + lane + rustc + -Cargo.lock hash + ``release-target-v1``. PR sticky keys stay job-isolated with -``${{ github.job }}`` and ``target-v1``. +Cargo.lock hash + ``release-target-v1``. After #4 cutover, Test Suite no longer +mounts PR job-isolated Cargo ``target/`` sticky disks; Binding RC / fuzz / M1 +release-load retain sticky for packaging and retained-tool lanes. This module inventories workflow storage steps and fails closed on drift. """ @@ -83,14 +84,17 @@ ) EXPECTED_DEPENDENCY_KEYS = Counter( { - "${{ runner.os }}-cargo-registry-v1-${{ hashFiles('Cargo.lock') }}": 10, + # test.yml: policy + rust-lint + python/node binding + windows locks (5); + # Binding RC: 3. PR Cargo sticky disks retired after #4 cutover. + "${{ runner.os }}-cargo-registry-v1-${{ hashFiles('Cargo.lock') }}": 8, "${{ runner.os }}-snap-ego-facebook-v1": 1, "${{ runner.os }}-fuzz-${{ hashFiles('fuzz/Cargo.toml', '**/Cargo.lock') }}": 1, } ) EXPECTED_STICKY_KEYS = Counter( { - "${{ github.repository }}-${{ github.job }}-${{ hashFiles('Cargo.lock') }}-target-v1": 5, + # PR job-isolated Cargo target/ sticky disks retired after #4. + # Binding RC / fuzz / M1 release-load retain sticky for packaging lanes. ( "${{ github.repository }}-binding-rc-linux-rust-1.96.0-" "${{ hashFiles('Cargo.lock') }}-release-target-v1" @@ -402,9 +406,30 @@ def validate_test_suite_trigger(text: str) -> None: ) +def validate_ci_gate_cutover(text: str) -> None: + """#4: Bazel authority under CI Gate; Cargo rust-test + PR sticky retired.""" + assert " rust-test:" not in text, ( + "Cargo rust-test job must stay retired after CI Gate cutover (#4)" + ) + assert "name: Rust Tests" not in text, ( + "Cargo Rust Tests job display name must stay retired after cutover (#4)" + ) + assert "useblacksmith/stickydisk@v1" not in text, ( + "Test Suite must not mount Cargo sticky disks after cutover (#4)" + ) + assert "name: CI Gate" in text, "required check context must remain CI Gate" + assert "bazelisk test //:ci_rust_tests" in text, ( + "authoritative Bazel Rust test graph //:ci_rust_tests missing from Test Suite" + ) + assert "needs.rust-test.result" not in text, ( + "CI Gate must not aggregate the retired rust-test job" + ) + + def main() -> None: texts = {path: path.read_text(encoding="utf-8") for path in sorted(WORKFLOWS.glob("*.y*ml"))} validate_test_suite_trigger(texts[WORKFLOWS / "test.yml"]) + validate_ci_gate_cutover(texts[WORKFLOWS / "test.yml"]) artifact_uploads: list[str] = [] artifact_downloads: list[str] = [] diff --git a/scripts/ci/test-classify-changes.sh b/scripts/ci/test-classify-changes.sh index 9294f3f00..e35aa29e8 100755 --- a/scripts/ci/test-classify-changes.sh +++ b/scripts/ci/test-classify-changes.sh @@ -41,20 +41,21 @@ assert_classification() { } none=$'rust=false\npython=false\ngherkin=false\nbindings=false\nagent_skills=false\npulumi=false\nterraform=false\nbazel=false' -rust_only=$'rust=true\npython=false\ngherkin=false\nbindings=false\nagent_skills=false\npulumi=false\nterraform=false\nbazel=false' +# After #4 cutover, rust classification always enables Bazel authority. +rust_only=$'rust=true\npython=false\ngherkin=false\nbindings=false\nagent_skills=false\npulumi=false\nterraform=false\nbazel=true' python_only=$'rust=false\npython=true\ngherkin=false\nbindings=false\nagent_skills=false\npulumi=false\nterraform=false\nbazel=false' -gherkin_rust=$'rust=true\npython=false\ngherkin=true\nbindings=false\nagent_skills=false\npulumi=false\nterraform=false\nbazel=false' -binding_rust=$'rust=true\npython=false\ngherkin=false\nbindings=true\nagent_skills=false\npulumi=false\nterraform=false\nbazel=false' +gherkin_rust=$'rust=true\npython=false\ngherkin=true\nbindings=false\nagent_skills=false\npulumi=false\nterraform=false\nbazel=true' +binding_rust=$'rust=true\npython=false\ngherkin=false\nbindings=true\nagent_skills=false\npulumi=false\nterraform=false\nbazel=true' binding_python=$'rust=false\npython=true\ngherkin=false\nbindings=true\nagent_skills=false\npulumi=false\nterraform=false\nbazel=false' -binding_rust_python=$'rust=true\npython=true\ngherkin=false\nbindings=true\nagent_skills=false\npulumi=false\nterraform=false\nbazel=false' +binding_rust_python=$'rust=true\npython=true\ngherkin=false\nbindings=true\nagent_skills=false\npulumi=false\nterraform=false\nbazel=true' binding_only=$'rust=false\npython=false\ngherkin=false\nbindings=true\nagent_skills=false\npulumi=false\nterraform=false\nbazel=false' binding_agent_skills=$'rust=false\npython=false\ngherkin=false\nbindings=true\nagent_skills=true\npulumi=false\nterraform=false\nbazel=false' agent_skills_only=$'rust=false\npython=false\ngherkin=false\nbindings=false\nagent_skills=true\npulumi=false\nterraform=false\nbazel=false' pulumi_only=$'rust=false\npython=false\ngherkin=false\nbindings=false\nagent_skills=false\npulumi=true\nterraform=false\nbazel=false' terraform_only=$'rust=false\npython=false\ngherkin=false\nbindings=false\nagent_skills=false\npulumi=false\nterraform=true\nbazel=false' binding_iac=$'rust=false\npython=false\ngherkin=false\nbindings=true\nagent_skills=false\npulumi=true\nterraform=true\nbazel=false' -rust_binding_iac=$'rust=true\npython=false\ngherkin=false\nbindings=true\nagent_skills=false\npulumi=true\nterraform=true\nbazel=false' -rust_iac=$'rust=true\npython=false\ngherkin=false\nbindings=false\nagent_skills=false\npulumi=true\nterraform=true\nbazel=false' +rust_binding_iac=$'rust=true\npython=false\ngherkin=false\nbindings=true\nagent_skills=false\npulumi=true\nterraform=true\nbazel=true' +rust_iac=$'rust=true\npython=false\ngherkin=false\nbindings=false\nagent_skills=false\npulumi=true\nterraform=true\nbazel=true' all=$'rust=true\npython=true\ngherkin=true\nbindings=true\nagent_skills=true\npulumi=true\nterraform=true\nbazel=true' bazel_only=$'rust=false\npython=false\ngherkin=false\nbindings=false\nagent_skills=false\npulumi=false\nterraform=false\nbazel=true' rust_bindings_bazel=$'rust=true\npython=false\ngherkin=false\nbindings=true\nagent_skills=false\npulumi=false\nterraform=false\nbazel=true' @@ -129,6 +130,7 @@ assert_classification "$bazel_only" scripts/ci/bazel-migration-ledger-check.py b assert_classification "$bazel_only" docs/development/bazel-migration-parity.md bazel-parity-doc assert_classification "$bazel_only" scripts/ci/bazel-cache-perf.py bazel-cache-perf-harness assert_classification "$bazel_only" docs/development/bazel-migration-perf.md bazel-cache-perf-doc +assert_classification "$bazel_only" docs/development/bazel-migration-cutover.md bazel-cutover-doc assert_classification "$bazel_only" \ docs/development/bazel-migration-evidence/perf-sample.json bazel-cache-perf-evidence assert_classification "$none" "docs/a file with spaces.md" docs-only From 2d041fd5da3bf2a6db6bb81d4037d5e6378bf187 Mon Sep 17 00:00:00 2001 From: David Spencer <1526975+DecisionNerd@users.noreply.github.com> Date: Fri, 7 Aug 2026 00:11:29 -0600 Subject: [PATCH 2/4] fix(bazel): declare #[path] sources for bdd_timing and fixed_hop_limit Hermetic Bazel compile needs the path-included modules in srcs so //:ci_rust_tests can build the API integration suite after CI Gate cutover. Co-authored-by: Cursor --- crates/graphforge-api/BUILD.bazel | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/graphforge-api/BUILD.bazel b/crates/graphforge-api/BUILD.bazel index b661e2047..8f1d50b8e 100644 --- a/crates/graphforge-api/BUILD.bazel +++ b/crates/graphforge-api/BUILD.bazel @@ -62,7 +62,12 @@ _API_TEST_DATA = [ gf_rust_integration_test( name = "bdd_timing", - srcs = ["tests/bdd_timing.rs"], + # #[path = "bdd/..."] modules must be declared as srcs for hermetic compile. + srcs = [ + "tests/bdd_timing.rs", + "tests/bdd/fixture.rs", + "tests/bdd/timing.rs", + ], crate = ":graphforge_api", data = _API_TEST_DATA, size = "large", @@ -146,7 +151,11 @@ gf_rust_integration_test( gf_rust_integration_test( name = "fixed_hop_limit", - srcs = ["tests/fixed_hop_limit.rs"], + # #[path = "support/project_fixture.rs"] must be declared as srcs. + srcs = [ + "tests/fixed_hop_limit.rs", + "tests/support/project_fixture.rs", + ], crate = ":graphforge_api", data = _API_TEST_DATA, size = "large", From 559b3dc35252700e2e130a8545b5744aba0dde31 Mon Sep 17 00:00:00 2001 From: David Spencer <1526975+DecisionNerd@users.noreply.github.com> Date: Fri, 7 Aug 2026 00:21:43 -0600 Subject: [PATCH 3/4] test(ci): parse Test Suite jobs for Bazel cutover contract Strengthen #4 cutover checks so CI Gate must depend on the job that runs //:ci_rust_tests, and retirement of rust-test / PR sticky disks is job-scoped. Co-authored-by: Cursor --- scripts/ci/test-ci-storage-policy.py | 123 ++++++++++++++++++++++++--- 1 file changed, 113 insertions(+), 10 deletions(-) diff --git a/scripts/ci/test-ci-storage-policy.py b/scripts/ci/test-ci-storage-policy.py index 730e1fe1a..e9ede0d8a 100644 --- a/scripts/ci/test-ci-storage-policy.py +++ b/scripts/ci/test-ci-storage-policy.py @@ -406,23 +406,126 @@ def validate_test_suite_trigger(text: str) -> None: ) +def workflow_jobs(text: str) -> dict[str, str]: + """Split a workflow into top-level job_id -> job body (after the job key line).""" + lines = text.splitlines() + try: + jobs_index = next( + index for index, line in enumerate(lines) if line.rstrip() == "jobs:" + ) + except StopIteration as exc: + raise AssertionError("workflow is missing a top-level jobs: mapping") from exc + jobs: dict[str, str] = {} + current: str | None = None + body: list[str] = [] + for line in lines[jobs_index + 1 :]: + if not line.strip() or line.lstrip().startswith("#"): + if current is not None: + body.append(line) + continue + indent = len(line) - len(line.lstrip()) + if indent == 2 and line.rstrip().endswith(":") and not line.lstrip().startswith("- "): + if current is not None: + jobs[current] = "\n".join(body) + current = line.strip()[:-1] + body = [] + continue + if current is None: + continue + if indent < 2: + break + body.append(line) + if current is not None: + jobs[current] = "\n".join(body) + assert jobs, "workflow jobs: mapping is empty" + return jobs + + +def job_display_name(job_body: str) -> str | None: + for line in job_body.splitlines(): + stripped = line.strip() + if stripped.startswith("name:"): + return stripped.split(":", 1)[1].strip().strip("'\"") + return None + + +def job_needs(job_body: str) -> set[str]: + lines = job_body.splitlines() + needed: set[str] = set() + for index, line in enumerate(lines): + stripped = line.strip() + if not stripped.startswith("needs:"): + continue + value = stripped.split(":", 1)[1].strip() + if value.startswith("[") and value.endswith("]"): + inner = value[1:-1] + needed.update(part.strip().strip("'\"") for part in inner.split(",") if part.strip()) + break + if value and value not in {"|", ">"}: + needed.add(value.strip("'\"")) + break + indent = len(line) - len(line.lstrip()) + for follow in lines[index + 1 :]: + if not follow.strip(): + continue + follow_indent = len(follow) - len(follow.lstrip()) + if follow_indent <= indent: + break + item = follow.strip() + if item.startswith("- "): + needed.add(item[2:].strip().strip("'\"")) + elif item.startswith("[") and item.endswith("]"): + inner = item[1:-1] + needed.update( + part.strip().strip("'\"") for part in inner.split(",") if part.strip() + ) + break + return {item for item in needed if item} + + +def job_runs_command(job_body: str, needle: str) -> bool: + return needle in job_body + + def validate_ci_gate_cutover(text: str) -> None: """#4: Bazel authority under CI Gate; Cargo rust-test + PR sticky retired.""" - assert " rust-test:" not in text, ( + jobs = workflow_jobs(text) + assert "rust-test" not in jobs, ( "Cargo rust-test job must stay retired after CI Gate cutover (#4)" ) - assert "name: Rust Tests" not in text, ( - "Cargo Rust Tests job display name must stay retired after cutover (#4)" + for job_id, body in jobs.items(): + assert job_display_name(body) != "Rust Tests", ( + f"job {job_id!r} must not restore retired Cargo Rust Tests display name" + ) + sticky, _ = sticky_contracts(body) + assert not sticky, f"Test Suite job {job_id!r} must not mount Cargo sticky disks (#4)" + + authoritative = [ + job_id + for job_id, body in jobs.items() + if job_runs_command(body, "bazelisk test //:ci_rust_tests") + ] + assert len(authoritative) == 1, ( + "exactly one Test Suite job must run authoritative bazelisk test //:ci_rust_tests" ) - assert "useblacksmith/stickydisk@v1" not in text, ( - "Test Suite must not mount Cargo sticky disks after cutover (#4)" + auth_job = authoritative[0] + + gate_jobs = [ + job_id for job_id, body in jobs.items() if job_display_name(body) == "CI Gate" + ] + assert len(gate_jobs) == 1, "required check context must remain exactly one CI Gate job" + gate_id = gate_jobs[0] + gate_body = jobs[gate_id] + needed = job_needs(gate_body) + assert auth_job in needed, ( + f"CI Gate must depend on authoritative Bazel job {auth_job!r} (needs={sorted(needed)})" ) - assert "name: CI Gate" in text, "required check context must remain CI Gate" - assert "bazelisk test //:ci_rust_tests" in text, ( - "authoritative Bazel Rust test graph //:ci_rust_tests missing from Test Suite" + assert "rust-test" not in needed, "CI Gate must not aggregate the retired rust-test job" + assert f"needs.{auth_job}.result" in gate_body, ( + f"CI Gate must require {auth_job}.result via require-gates.sh" ) - assert "needs.rust-test.result" not in text, ( - "CI Gate must not aggregate the retired rust-test job" + assert "needs.rust-test.result" not in gate_body, ( + "CI Gate must not reference needs.rust-test.result" ) From 225394769f700e58e9217328cd37d79b53070e6d Mon Sep 17 00:00:00 2001 From: David Spencer <1526975+DecisionNerd@users.noreply.github.com> Date: Fri, 7 Aug 2026 00:23:40 -0600 Subject: [PATCH 4/4] style: ruff-format cutover job parser in storage policy tests Co-authored-by: Cursor --- scripts/ci/test-ci-storage-policy.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/scripts/ci/test-ci-storage-policy.py b/scripts/ci/test-ci-storage-policy.py index e9ede0d8a..269e692d6 100644 --- a/scripts/ci/test-ci-storage-policy.py +++ b/scripts/ci/test-ci-storage-policy.py @@ -410,9 +410,7 @@ def workflow_jobs(text: str) -> dict[str, str]: """Split a workflow into top-level job_id -> job body (after the job key line).""" lines = text.splitlines() try: - jobs_index = next( - index for index, line in enumerate(lines) if line.rstrip() == "jobs:" - ) + jobs_index = next(index for index, line in enumerate(lines) if line.rstrip() == "jobs:") except StopIteration as exc: raise AssertionError("workflow is missing a top-level jobs: mapping") from exc jobs: dict[str, str] = {} @@ -510,9 +508,7 @@ def validate_ci_gate_cutover(text: str) -> None: ) auth_job = authoritative[0] - gate_jobs = [ - job_id for job_id, body in jobs.items() if job_display_name(body) == "CI Gate" - ] + gate_jobs = [job_id for job_id, body in jobs.items() if job_display_name(body) == "CI Gate"] assert len(gate_jobs) == 1, "required check context must remain exactly one CI Gate job" gate_id = gate_jobs[0] gate_body = jobs[gate_id]