diff --git a/.ci/predev.yaml b/.ci/predev.yaml index 966fa5be98..077c7b39c8 100644 --- a/.ci/predev.yaml +++ b/.ci/predev.yaml @@ -1,7 +1,9 @@ -# What predev must pass to move into dev: busbar-release's `dev` rung (crates/busbar-release-cli/ -# ladder.json: gates inherit, oracle, conformance; verdict absolute), the 1.6.0 done-oracle, and the -# Q128 architecture audit (`cargo xtask q128 audit`). ModernCI names a branch file for the branch -# being moved, so the move predev -> dev reads this file. Shadow while busbar-release still decides. +# What predev must pass to move into dev: the `dev` rung (gates, oracle, conformance), the 1.6.0 +# done-oracle, and the Q128 architecture audit (`cargo xtask q128 audit`). ModernCI names a branch +# file for the branch being moved, so the move predev -> dev reads this file, and ModernCI alone +# judges it: promote.yml, the busbar-release hop that judged it before, is deleted (OWNER +# 2026-10-12, "ModernCI only"; docs/design/BUSBAR-1.6.0.md Part 6, "BUSBAR-RELEASE RETIRED FROM +# BUSBAR"). Its green landing is DEV-GREEN. to: dev on_green: merge # The owner's approval on the pull request holds the merge too; q128-audit gates the move on the @@ -24,6 +26,9 @@ checks: cargo xtask gate --all --format=tsv baseline: target rules: ["qa/*.toml"] + # The ceiling ratchet reads the merge-base (XTASK_CEILING_BASE), as in .ci/default.yaml: only a + # backend with the repo's history may run this check. + requires: [git-history] oracle: # busbar's own check tool, the pinned busbar-oracle (the backend's busbar-oracle tool sets # BUSBAR_ORACLE_RUST_BIN): the judge is the one dev's oracle-rust.pin names, run only when its diff --git a/.github/required-status-checks.md b/.github/required-status-checks.md index ffea47b689..530e300a00 100644 --- a/.github/required-status-checks.md +++ b/.github/required-status-checks.md @@ -5,22 +5,18 @@ Branch protection lives in repository rulesets. No script writes it. | Branch | Ruleset | Required | | --- | --- | --- | | `predev` | 24945926 | ModernCI's `ci` check. ModernCI judges the PR (`.ci/default.yaml`) and lands it. | -| `dev` | 24340358 | `preflight` and `hop`, both from `.github/workflows/promote.yml` (the predev to dev promotion). | +| `dev` | 24340358 | ModernCI's `ci` check on the predev to dev move (`.ci/predev.yaml`). The ruleset still names `preflight` and `hop`, the jobs of the deleted `promote.yml`: the owner replaces them with `ci`. | | `qa`, `main` | 24334334 | Owner-only; no status check stands in for the owner. | -Use the exact **check-run name** (the job's `name:`), not the workflow name. In `promote.yml`, `hop` -is the job id with no `name:`, so its context reads `hop`. - -* `preflight`: formatting, `Cargo.lock` current, the generated C header current, on a free runner. -* `hop`: the pinned busbar-release engine plans the hop and admits the candidate (every registered - `cargo xtask gate`, plus the oracle and conformance where the rung asks for them). +Use the exact **check name** the judge posts. ModernCI posts one check, `ci`, per pull request head. ## Reusable workflows (plugin repos call these) `plugin-ci.yml`, `plugin-consumer-verify.yml`, `plugin-repin.yml` and `plugin-release.yml` are `workflow_call` only, called by the plugin repos' rendered callers (busbar-release `template/`, `busbar-release plugin sync`). Their status bubbles up into the **caller's** job, so require the -caller's job in the plugin repo, never these directly. +caller's job in the plugin repo, never these directly. They stay until each plugin repo is +registered with ModernCI and judged by its own branch files. ## Branch hygiene diff --git a/.github/workflows/promote.yml b/.github/workflows/promote.yml deleted file mode 100644 index aa1c9b0ae5..0000000000 --- a/.github/workflows/promote.yml +++ /dev/null @@ -1,474 +0,0 @@ -name: Promote -# This workflow serves the predev->dev promotion pull request and the dev->qa->main ladder above it. The -# hop is GetBusbar/busbar-release's `busbar-release ci hop|verdict|publish|fallback` at RELEASE_REF -# (private, so no `uses:`); these jobs only check out both repos and run it. No job here runs BOARD -# (`ci train promote` needs the train's write token, and the job that runs the candidate's code holds -# none): a PR lands by merge. -# Pull requests into predev are not judged here: ModernCI judges them (required check `ci`) and lands -# them with its own merge train, so predev has no merge queue and this workflow runs for no pull request -# into predev or into a lane branch (a stacked lane PR), nor for `merge_group`. The `merge_group` handling in the jobs below -# is left in place, dormant: it is job logic, and the concurrency and env lines the preflight holds to busbar-release's compiled copy read it. -on: { pull_request: { branches: [dev, qa, main] } } -# R5 (busbar-release gate.rs, OWNER 2026-10-08): no run is ever cancelled in progress. A pull request -# has at most one run running and one pending, and each newer push replaces the pending one, so the -# pending run is always the newest head. A merge-queue run is its own group (a cancelled queue run -# re-runs whole, owner money 2026-10-03). The preflight holds this line to the one compiled into -# busbar-release: any drift is RED. -concurrency: { group: "promote-${{ github.event.pull_request.number || github.event.merge_group.head_ref }}", cancel-in-progress: false } -permissions: { contents: read } -# THE ENGINE PIN IS NOT IN THIS FILE (OWNER 2026-10-10): the org variables ENGINE_REF (a full -# busbar-release commit) and ENGINE_SHA256 (the sha256 of that commit's dev-bin ci asset), set by -# hand with `busbar-release engine pin `, so an engine update never waits in predev's merge -# queue. The preflight reads them ONCE and hands them to every later job as outputs: one run is one -# engine even if the variables move mid-run. Unset or malformed is a hard FAIL (ENGINE-PIN), never -# an older pin; the bytes run only when their sha256 is ENGINE_SHA256 (workflow-rules R16). -env: - # The engine contract (busbar-release contract.rs): 2 = the hop holds only the read-only R2 keys and `cache-publish` writes. An engine refuses a contract it does not support, and the auto-pin moves only to engines that support this number. - BUSBAR_RELEASE_CONTRACT: "2" - SHA: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }} - TO: ${{ github.base_ref || github.event.merge_group.base_ref }} - BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }} -jobs: - preflight: - if: github.event_name == 'merge_group' || github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-latest - timeout-minutes: 15 - # THE RUN GATE reads GitHub (open pull requests, the merge queue, this head's promote runs) with - # this job's token. This job runs the candidate's `cargo xtask`, so the token only READS - # (busbar-release 941028a): the gate's writes (ORPHANS, R7 REPEAT) are recorded in the act - # artifact before any candidate code runs, and `preflight-act` below makes them. - # OLD-ENGINE SHIM (remove once ENGINE_REF is at or past busbar-release 941028a): an older engine's - # R6 field `jobs.preflight.permissions (actions: read)` reads this line's text, comment included; - # this job's token stays contents read only. - permissions: { contents: read } # actions: read is public on this repo; the writes are preflight-act's - # RUNNERS: the runner gateway's table (vars.RUNNERS), from which the preflight sizes `runner=`. - env: { RELEASE_REF: "${{ vars.ENGINE_REF }}", ENGINE_SHA256: "${{ vars.ENGINE_SHA256 }}", RUNNERS: "${{ vars.RUNNERS }}" } - outputs: { engine_ref: "${{ steps.engine.outputs.engine_ref }}", engine_sha256: "${{ steps.engine.outputs.engine_sha256 }}", runner: "${{ steps.pre.outputs.runner }}", soak: "${{ steps.pre.outputs.soak }}", one_runner: "${{ steps.pre.outputs.one_runner }}", needs_release_artifact: "${{ steps.pre.outputs.needs_release_artifact }}", run: "${{ steps.pre.outputs.run }}", reason: "${{ steps.pre.outputs.reason }}" } - steps: - # Full history: the pre-flight reads the pull request's diff against BASE_SHA to choose one runner or the matrix. - - { uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1, with: { ref: "${{ env.SHA }}", path: busbar, fetch-depth: 0 } } # v7 - # THE ENGINE, downloaded, never compiled: CI's --no-default-features binary of RELEASE_REF from - # busbar-release's dev-bin release (dev-bin.yml), run only when its sha256 is ENGINE_SHA256. - # First the pin's shape: a full 40-hex commit and a 64-hex digest, or FAIL. - - name: Engine (busbar-release at RELEASE_REF, sha256-pinned) - id: engine - env: { GH_TOKEN: "${{ secrets.TURNSTILE_DISPATCH_TOKEN }}" } - run: | - set -euo pipefail - [[ "$RELEASE_REF" =~ ^[0-9a-f]{40}$ ]] || { echo "::error title=ENGINE-PIN FAIL::vars.ENGINE_REF is unset or not a full 40-hex busbar-release commit (busbar-release engine pin )"; exit 1; } - [[ "$ENGINE_SHA256" =~ ^[0-9a-f]{64}$ ]] || { echo "::error title=ENGINE-PIN FAIL::vars.ENGINE_SHA256 is unset or not a 64-hex sha256 (busbar-release engine pin )"; exit 1; } - api=https://api.github.com/repos/GetBusbar/busbar-release/releases - name="busbar-release-ci-linux-x86_64-$RELEASE_REF" - id=$(curl -fsSL -H "Authorization: Bearer $GH_TOKEN" "$api/tags/dev-bin" | jq -r --arg n "$name" '.assets[] | select(.name == $n) | .id') - [ -n "$id" ] || { echo "::error::dev-bin has no $name"; exit 1; } - curl -fsSL -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/octet-stream" -o "$RUNNER_TEMP/busbar-release" "$api/assets/$id" - echo "$ENGINE_SHA256 $RUNNER_TEMP/busbar-release" | sha256sum -c - - chmod +x "$RUNNER_TEMP/busbar-release" - { echo "engine_ref=$RELEASE_REF"; echo "engine_sha256=$ENGINE_SHA256"; } >> "$GITHUB_OUTPUT" - - { uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1, with: { repository: GetBusbar/busbar-release, ref: "${{ env.RELEASE_REF }}", token: "${{ secrets.TURNSTILE_DISPATCH_TOKEN }}", path: busbar-release, persist-credentials: false } } # v7 - # The pre-flight is busbar-release's Rust CLI (preflight.json is the one list): it sizes the hop - # (runner= to $GITHUB_OUTPUT), chooses how a pull request into predev runs (one_runner=: ONE runner - # when the crates its diff affects are estimated within 20 min, else the shard matrix; OWNER-LOCKED - # 2026-10-06), whether this run reads the release artifact (needs_release_artifact=: the rung runs - # the oracle or conformance; predev runs neither), and runs every pre-flight check against the - # candidate. Built with no default features, as the CLI documents for this job. FIRST it is THE - # RUN GATE (OWNER 2026-10-08): run=1|0 and reason= from facts only (R1 STACK, R2 queued, R3 - # superseded, R4 duplicate head, R5/R6 this file's wiring); run=0 means nothing in this run - # builds, and a RED rule fails here. The gate's writes are recorded (`--act-out act/act.json`, - # before any candidate code runs) for preflight-act; this job makes none. - # OLD-ENGINE SHIM (remove once ENGINE_REF is at or past 941028a): an engine without --act-out - # runs without it. - - id: pre - working-directory: busbar-release - env: { GITHUB_TOKEN: "${{ github.token }}", RUNNERS: "${{ vars.RUNNERS }}" } - run: | - act=() - if "$RUNNER_TEMP/busbar-release" ci preflight --help | grep -q -- --act-out; then act=(--act-out "$GITHUB_WORKSPACE/act/act.json"); fi - "$RUNNER_TEMP/busbar-release" ci preflight "${act[@]}" - - { if: always(), uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a, with: { name: "preflight-act-${{ env.SHA }}", path: "act/", retention-days: 1, if-no-files-found: ignore, overwrite: true } } # v7.0.1 - # THE RUN GATE'S WRITES (busbar-release 941028a), in a job that runs NO candidate code: it checks - # out nothing, downloads the engine (sha256-pinned) and the preflight's act artifact, and runs - # `ci preflight-act`, which re-reads GitHub itself: ORPHANS cancels the merge-group runs a queue - # rebuild left running (actions: write), and R7 REPEAT turns off the auto-merge of a PR whose head - # already failed the queue as its own red (pull-requests: write), only the PR both the artifact - # and the re-read name. Merge groups only: a pull request has nothing to act on. It runs whatever - # the preflight concluded (R7 is a RED preflight), and never decides the verdict. - preflight-act: - needs: preflight - if: ${{ always() && github.event_name == 'merge_group' && needs.preflight.outputs.engine_ref != '' }} - runs-on: ubuntu-latest - timeout-minutes: 10 - permissions: { contents: read, actions: write, pull-requests: write } - env: { RELEASE_REF: "${{ needs.preflight.outputs.engine_ref }}", ENGINE_SHA256: "${{ needs.preflight.outputs.engine_sha256 }}" } - steps: - # THE ENGINE, downloaded, never compiled: CI's --no-default-features binary of RELEASE_REF from - # busbar-release's dev-bin release (dev-bin.yml), run only when its sha256 is ENGINE_SHA256. - # First the pin's shape: a full 40-hex commit and a 64-hex digest, or FAIL. - - name: Engine (busbar-release at RELEASE_REF, sha256-pinned) - env: { GH_TOKEN: "${{ secrets.TURNSTILE_DISPATCH_TOKEN }}" } - run: | - set -euo pipefail - [[ "$RELEASE_REF" =~ ^[0-9a-f]{40}$ ]] || { echo "::error title=ENGINE-PIN FAIL::vars.ENGINE_REF is unset or not a full 40-hex busbar-release commit (busbar-release engine pin )"; exit 1; } - [[ "$ENGINE_SHA256" =~ ^[0-9a-f]{64}$ ]] || { echo "::error title=ENGINE-PIN FAIL::vars.ENGINE_SHA256 is unset or not a 64-hex sha256 (busbar-release engine pin )"; exit 1; } - api=https://api.github.com/repos/GetBusbar/busbar-release/releases - name="busbar-release-ci-linux-x86_64-$RELEASE_REF" - id=$(curl -fsSL -H "Authorization: Bearer $GH_TOKEN" "$api/tags/dev-bin" | jq -r --arg n "$name" '.assets[] | select(.name == $n) | .id') - [ -n "$id" ] || { echo "::error::dev-bin has no $name"; exit 1; } - curl -fsSL -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/octet-stream" -o "$RUNNER_TEMP/busbar-release" "$api/assets/$id" - echo "$ENGINE_SHA256 $RUNNER_TEMP/busbar-release" | sha256sum -c - - chmod +x "$RUNNER_TEMP/busbar-release" - - { continue-on-error: true, uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c, with: { name: "preflight-act-${{ env.SHA }}", path: act } } # v8.0.1 - # OLD-ENGINE SHIM (remove once ENGINE_REF is at or past 941028a): an engine without the verb - # made these writes in the preflight itself. - - name: The run gate's writes (ORPHANS, R7 REPEAT) - env: { GITHUB_TOKEN: "${{ github.token }}" } - run: | - if ! "$RUNNER_TEMP/busbar-release" ci --help | grep -q preflight-act; then echo "engine $RELEASE_REF has no preflight-act: nothing to act on here"; exit 0; fi - "$RUNNER_TEMP/busbar-release" ci preflight-act --act "$GITHUB_WORKSPACE/act/act.json" - # The release artifact the oracle records and the slsa-verifier conformance rig judges. It runs in - # every merge group (the fold requires it: a check on the release build), and in every run whose - # rung reads the artifact (`needs_release_artifact=true`: the rung runs the oracle or conformance, - # i.e. the predev->dev pull request and up). dev moves by pull request now, not by the ladder - # fast-forwarding a merge group's sha (ARCHITECT 2026-10-02, superseded), so the run that judges a - # promotion builds the artifact of exactly the commit it promotes: its shards download - # `slsa-artifact-` from THIS run, which a merge-group-only build never produced (#740 failed - # every run at that download). workflow-rules R18 holds this wiring. Its SLSA provenance is minted - # from `base64-subjects` by the pinned slsa-github-generator once the owner rules on R14 (the - # generator must be referenced by tag); until then the suite is not-run, a standing red here. - slsa-build: - if: needs.preflight.outputs.run == '1' && (github.event_name == 'merge_group' || needs.preflight.outputs.needs_release_artifact == 'true') - needs: preflight - permissions: { contents: read } - runs-on: ${{ needs.preflight.outputs.runner }} - timeout-minutes: 90 - outputs: { base64-subjects: "${{ steps.digest.outputs.base64-subjects }}" } - steps: - - { uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1, with: { ref: "${{ env.SHA }}", path: busbar, persist-credentials: false } } # v7 - - name: Build the release artifact - working-directory: busbar - run: | - cargo build --locked --release -p busbar --bin busbar - mkdir -p ../slsa && cp target/release/busbar ../slsa/busbar - - id: digest - name: Its sha256 (the provenance subject) - working-directory: slsa - run: | - sha256sum busbar > busbar.sha256 - echo "base64-subjects=$(base64 -w0 busbar.sha256)" >> "$GITHUB_OUTPUT" - - { uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a, with: { name: "slsa-artifact-${{ env.SHA }}", path: "slsa/", retention-days: 7, if-no-files-found: error } } # v7.0.1 - # THE QA SOAK (OWNER 2026-10-10; busbar #734): the soak stays, as a 1-hour stability gate on each - # qa promotion. `busbar-release soak ` (profile.rs's full profile: 3600 s, 50 rps, - # 16 concurrent) soaks THIS candidate's release binary on the runner the preflight chose, wherever - # the ladder's entry asks for it (`soak=true`: qa). The hop folds this job's result like - # slsa-build's (`ci turnstile merge --needs soak=...`): a failed soak DENIES the promotion. CI=false - # selects the full hour (the CI context would pick the two-minute smoke). It runs the candidate, so - # it holds no write token. The candidate binary is this run's slsa-build artifact (a rung with the - # soak reads the artifact, so slsa-build runs); a run without it builds it here. The engine is the - # downloaded ci binary (built with `--features soak`, dev-bin.yml), sha256-pinned like every other - # job's. - soak: - needs: [preflight, slsa-build] - if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.preflight.outputs.run == '1' && needs.preflight.outputs.soak == 'true' }} - permissions: { contents: read } - runs-on: ${{ needs.preflight.outputs.runner }} - timeout-minutes: 180 - env: { RELEASE_REF: "${{ needs.preflight.outputs.engine_ref }}", ENGINE_SHA256: "${{ needs.preflight.outputs.engine_sha256 }}" } - steps: - - { uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1, with: { ref: "${{ env.SHA }}", path: busbar, persist-credentials: false } } # v7 - # THE ENGINE, downloaded, never compiled: CI's --no-default-features binary of RELEASE_REF from - # busbar-release's dev-bin release (dev-bin.yml), run only when its sha256 is ENGINE_SHA256. - # First the pin's shape: a full 40-hex commit and a 64-hex digest, or FAIL. - - name: Engine (busbar-release at RELEASE_REF, sha256-pinned) - env: { GH_TOKEN: "${{ secrets.TURNSTILE_DISPATCH_TOKEN }}" } - run: | - set -euo pipefail - [[ "$RELEASE_REF" =~ ^[0-9a-f]{40}$ ]] || { echo "::error title=ENGINE-PIN FAIL::vars.ENGINE_REF is unset or not a full 40-hex busbar-release commit (busbar-release engine pin )"; exit 1; } - [[ "$ENGINE_SHA256" =~ ^[0-9a-f]{64}$ ]] || { echo "::error title=ENGINE-PIN FAIL::vars.ENGINE_SHA256 is unset or not a 64-hex sha256 (busbar-release engine pin )"; exit 1; } - api=https://api.github.com/repos/GetBusbar/busbar-release/releases - name="busbar-release-ci-linux-x86_64-$RELEASE_REF" - id=$(curl -fsSL -H "Authorization: Bearer $GH_TOKEN" "$api/tags/dev-bin" | jq -r --arg n "$name" '.assets[] | select(.name == $n) | .id') - [ -n "$id" ] || { echo "::error::dev-bin has no $name"; exit 1; } - curl -fsSL -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/octet-stream" -o "$RUNNER_TEMP/busbar-release" "$api/assets/$id" - echo "$ENGINE_SHA256 $RUNNER_TEMP/busbar-release" | sha256sum -c - - chmod +x "$RUNNER_TEMP/busbar-release" - - { uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1, with: { repository: GetBusbar/busbar-release, ref: "${{ env.RELEASE_REF }}", token: "${{ secrets.TURNSTILE_DISPATCH_TOKEN }}", path: busbar-release, persist-credentials: false } } # v7 - - if: needs.slsa-build.result == 'success' - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: { name: "slsa-artifact-${{ env.SHA }}", path: slsa } - - if: needs.slsa-build.result != 'success' - working-directory: busbar - run: | - cargo build --locked --release -p busbar --bin busbar - mkdir -p ../slsa && cp target/release/busbar ../slsa/busbar - - name: Soak the candidate (the full profile) - working-directory: busbar-release - env: { CI: "false", GH_TOKEN: "${{ github.token }}", TMPDIR: "${{ runner.temp }}" } - run: | - chmod +x "$GITHUB_WORKSPACE/slsa/busbar" - "$RUNNER_TEMP/busbar-release" soak "$GITHUB_WORKSPACE/slsa/busbar" - - { if: always(), uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a, with: { name: "soak-${{ env.SHA }}", path: "${{ runner.temp }}/busbar-soak-*/", retention-days: 30, if-no-files-found: warn } } # v7.0.1 - # The merge group's hop, and the predev->dev pull request's, is SHARDED (ARCHITECT 2026-10-02): four - # runners each judge one part (`--shard K/4`: test:workspace's hash partition K, plus the unsplit steps - # dealt to K; the gates in shard 1, the oracle and conformance in shard 4). Each builds and runs the - # candidate's code, so it holds NO write token (contents and actions read only, no persisted - # credentials); its report leaves as an artifact. It starts beside slsa-build, not after it (B3-P, - # 2026-10-08), and no shard waits for it: the fold requires slsa-build's result (`needs:`, - # `--needs`, `--must-run`), and nothing a shard runs reads the release artifact (the slsa-verifier - # leg is not-run until `conformance record` is handed `--slsa-artifact`). The predev->dev shards - # sat ~7 min each in `await-job` for a download nobody read (2026-10-11). Every shard runs to - # the bottom (FULLY SEEN, busbar-release #152). A pull request into predev runs the shards only when the - # pre-flight chose the matrix (one_runner=false: every crate affected, or over 20 min on one runner); - # otherwise its hop is ONE runner, in the `hop` job below. - hop-shard: - name: "hop shard ${{ matrix.shard }}" - strategy: { fail-fast: false, matrix: { shard: [1, 2, 3, 4] } } - needs: [preflight] - if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.preflight.outputs.run == '1' && (github.event_name == 'merge_group' || github.base_ref == 'dev' || needs.preflight.outputs.one_runner != 'true') }} - permissions: { contents: read, actions: read } - runs-on: ${{ needs.preflight.outputs.runner }} - timeout-minutes: 330 - env: { RELEASE_REF: "${{ needs.preflight.outputs.engine_ref }}", ENGINE_SHA256: "${{ needs.preflight.outputs.engine_sha256 }}", RUNNERS: "${{ vars.RUNNERS }}" } - steps: - - { uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1, with: { ref: "${{ env.SHA }}", path: busbar, fetch-depth: 0, persist-credentials: false } } # v7 - # THE ENGINE, downloaded, never compiled: CI's --no-default-features binary of RELEASE_REF from - # busbar-release's dev-bin release (dev-bin.yml), run only when its sha256 is ENGINE_SHA256. - # First the pin's shape: a full 40-hex commit and a 64-hex digest, or FAIL. - - name: Engine (busbar-release at RELEASE_REF, sha256-pinned) - env: { GH_TOKEN: "${{ secrets.TURNSTILE_DISPATCH_TOKEN }}" } - run: | - set -euo pipefail - [[ "$RELEASE_REF" =~ ^[0-9a-f]{40}$ ]] || { echo "::error title=ENGINE-PIN FAIL::vars.ENGINE_REF is unset or not a full 40-hex busbar-release commit (busbar-release engine pin )"; exit 1; } - [[ "$ENGINE_SHA256" =~ ^[0-9a-f]{64}$ ]] || { echo "::error title=ENGINE-PIN FAIL::vars.ENGINE_SHA256 is unset or not a 64-hex sha256 (busbar-release engine pin )"; exit 1; } - api=https://api.github.com/repos/GetBusbar/busbar-release/releases - name="busbar-release-ci-linux-x86_64-$RELEASE_REF" - id=$(curl -fsSL -H "Authorization: Bearer $GH_TOKEN" "$api/tags/dev-bin" | jq -r --arg n "$name" '.assets[] | select(.name == $n) | .id') - [ -n "$id" ] || { echo "::error::dev-bin has no $name"; exit 1; } - curl -fsSL -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/octet-stream" -o "$RUNNER_TEMP/busbar-release" "$api/assets/$id" - echo "$ENGINE_SHA256 $RUNNER_TEMP/busbar-release" | sha256sum -c - - chmod +x "$RUNNER_TEMP/busbar-release" - # THE JUDGE, pinned in busbar (OWNER 2026-10-10): the busbar-oracle testing/shadow-oracle/oracle-rust.pin - # names, read at the BASE (a candidate never picks its own judge), downloaded from busbar-release's - # dev-bin release and run only when its sha256 is the pin's sha256_linux_x86_64. The engine pin - # never moves it. Only the last shard runs the oracle; `ci hop` and bin/oracle both take the binary - # from BUSBAR_ORACLE_RUST_BIN, so nothing builds a judge. - - name: Oracle (pinned at the base, sha256-checked) - if: matrix.shard == 4 - env: { GH_TOKEN: "${{ secrets.TURNSTILE_DISPATCH_TOKEN }}" } - run: | - set -euo pipefail - pin="$(git -C busbar show "$BASE_SHA:testing/shadow-oracle/oracle-rust.pin" 2>/dev/null || true)" - # A base from before the pinned binary (no sha256 line): the oracle is built as before. - grep -q '^sha256_linux_x86_64=' <<<"$pin" || { echo "::notice::oracle-rust.pin at $BASE_SHA pins no binary: bin/oracle builds the judge"; exit 0; } - ref="$(printf '%s\n' "$pin" | sed -n 's/^release_ref=//p')" - ORACLE_SHA256="$(printf '%s\n' "$pin" | sed -n 's/^sha256_linux_x86_64=//p')" - [[ "$ref" =~ ^[0-9a-f]{40}$ && "$ORACLE_SHA256" =~ ^[0-9a-f]{64}$ ]] || { echo "::error title=ORACLE-PIN FAIL::oracle-rust.pin at $BASE_SHA needs a 40-hex release_ref and a 64-hex sha256_linux_x86_64"; exit 1; } - api=https://api.github.com/repos/GetBusbar/busbar-release/releases - name="busbar-oracle-linux-x86_64-$ref" - id=$(curl -fsSL -H "Authorization: Bearer $GH_TOKEN" "$api/tags/dev-bin" | jq -r --arg n "$name" '.assets[] | select(.name == $n) | .id') - [ -n "$id" ] || { echo "::error title=ORACLE-PIN FAIL::dev-bin has no $name"; exit 1; } - curl -fsSL -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/octet-stream" -o "$RUNNER_TEMP/busbar-oracle" "$api/assets/$id" - echo "$ORACLE_SHA256 $RUNNER_TEMP/busbar-oracle" | sha256sum -c - - chmod +x "$RUNNER_TEMP/busbar-oracle" - echo "BUSBAR_ORACLE_RUST_BIN=$RUNNER_TEMP/busbar-oracle" >> "$GITHUB_ENV" - - { uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1, with: { repository: GetBusbar/busbar-release, ref: "${{ env.RELEASE_REF }}", token: "${{ secrets.TURNSTILE_DISPATCH_TOKEN }}", path: busbar-release, persist-credentials: false } } # v7 - # The conformance rigs `conformance record` runs in the full plan need node 22 and pnpm 10 (the mcp - # suite's official runner, and rolldown's native binding built under it); the Latchkey large image - # carries node 20, so mcp read not-run (CONFORMANCE-RIGS, 2026-10-02). Only the last shard of a - # rung that runs conformance or the oracle (needs_release_artifact) records them; the others - # skip the ~15 s install. - - { if: "matrix.shard == 4 && needs.preflight.outputs.needs_release_artifact == 'true'", uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413, with: { version: 10 } } # v6.1.0 - - { if: "matrix.shard == 4 && needs.preflight.outputs.needs_release_artifact == 'true'", uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020, with: { node-version: 22 } } # v7.0.0 - # No debuginfo in the dev/test builds: build_test's all-features, feature-matrix and test builds plus - # the oracle's release build filled the runner's ~41 GiB free disk with debuginfo (ENOSPC, no log). - # Base and candidate build alike, so the no-worse-than-base verdict is unchanged. - # GITHUB_TOKEN (contents read) is for `ci hop`'s R3 re-check alone (is this merge group still a - # current queue entry's head?), which runs before any candidate code; the judge that runs the - # candidate never holds it. A superseded group's shard builds nothing. - # THE R2 KEYS HERE ONLY READ (busbar-release 069f032): this job runs candidate code, and the hop - # refuses to start (INFRA) with the write keys in its environment. Unset read-only keys fail - # open (no cache). The judge's cache puts land in r2-outbox/, uploaded below and published by - # `cache-publish`, the one job that holds the write keys (workflow-rules R19). - - working-directory: busbar-release - run: $RUNNER_TEMP/busbar-release ci hop - env: { GITHUB_TOKEN: "${{ github.token }}", R2_RO_KEY_ID: "${{ secrets.R2_CACHE_RO_ACCESS_KEY_ID }}", R2_RO_KEY_SECRET: "${{ secrets.R2_CACHE_RO_SECRET_ACCESS_KEY }}", CARGO_PROFILE_DEV_DEBUG: "0", SHARD: "${{ matrix.shard }}/4" } - - { if: always(), uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a, with: { name: "turnstile-report-${{ env.SHA }}-shard${{ matrix.shard }}", path: "artifacts/", if-no-files-found: warn } } # v7.0.1 - - { if: always(), uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a, with: { name: "r2-outbox-${{ env.SHA }}-shard${{ matrix.shard }}", path: "r2-outbox/", retention-days: 1, if-no-files-found: ignore, overwrite: true } } # v7.0.1 - # `hop` (the required check) is the hop's ONE verdict. In a merge group and on the predev->dev pull - # request it folds the shards: BOARD only when every shard reported and boarded and slsa-build is - # green (or skipped: a pull request whose rung reads no release artifact): a red slsa-build denies - # it; a shard that is missing, timed out or INFRA makes it INFRA, never green. A pull request - # INTO PREDEV whose pre-flight - # chose one runner (OWNER-LOCKED 2026-10-06, BUSBAR-1.6.0 Part 6 "Verification runs once per input") - # runs it as ONE runner: no shard, no slsa-build; the ladder's predev `enter_pr`, the merge group's - # gates and steps scoped to the crates the PR changes plus their reverse dependencies, on the PR merged - # onto the base tip, judged no worse than the base the merge group landed in its 4 shards - # (BASE_SHARDS). It runs the candidate's code, so it holds no write token either. - hop: - needs: [preflight, slsa-build, hop-shard, soak] - if: ${{ !cancelled() && needs.preflight.result == 'success' }} - permissions: { contents: read, actions: read } - runs-on: ${{ (github.event_name == 'pull_request' && github.base_ref != 'dev' && needs.preflight.outputs.one_runner == 'true') && needs.preflight.outputs.runner || 'ubuntu-latest' }} - timeout-minutes: ${{ (github.event_name == 'pull_request' && github.base_ref != 'dev' && needs.preflight.outputs.one_runner == 'true') && 330 || 30 }} - env: { ONE_RUNNER: "${{ github.event_name == 'pull_request' && github.base_ref != 'dev' && needs.preflight.outputs.one_runner == 'true' }}", RELEASE_REF: "${{ needs.preflight.outputs.engine_ref }}", ENGINE_SHA256: "${{ needs.preflight.outputs.engine_sha256 }}", RUNNERS: "${{ vars.RUNNERS }}" } - steps: - # THE RUN GATE said run=0 (neutral: the merge queue, or another run of this head, judges it). - # Nothing was built, so this run does NOT JUDGE the head: the required check fails here, never - # passes by skipping (a skipped required check counts as success and would land an unjudged - # merge group). The preflight writes one_runner=false on run=0, so nothing below builds either. - - if: needs.preflight.outputs.run != '1' - env: { REASON: "${{ needs.preflight.outputs.reason }}" } - run: | - echo "::error title=NOT JUDGED::not judged by this run (run=0): $REASON. Not a verdict on the code." - exit 1 - - if: env.ONE_RUNNER == 'true' - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: { ref: "${{ env.SHA }}", path: busbar, fetch-depth: 0, persist-credentials: false } - # THE ENGINE, downloaded, never compiled: CI's --no-default-features binary of RELEASE_REF from - # busbar-release's dev-bin release (dev-bin.yml), run only when its sha256 is ENGINE_SHA256. - # First the pin's shape: a full 40-hex commit and a 64-hex digest, or FAIL. - - name: Engine (busbar-release at RELEASE_REF, sha256-pinned) - env: { GH_TOKEN: "${{ secrets.TURNSTILE_DISPATCH_TOKEN }}" } - run: | - set -euo pipefail - [[ "$RELEASE_REF" =~ ^[0-9a-f]{40}$ ]] || { echo "::error title=ENGINE-PIN FAIL::vars.ENGINE_REF is unset or not a full 40-hex busbar-release commit (busbar-release engine pin )"; exit 1; } - [[ "$ENGINE_SHA256" =~ ^[0-9a-f]{64}$ ]] || { echo "::error title=ENGINE-PIN FAIL::vars.ENGINE_SHA256 is unset or not a 64-hex sha256 (busbar-release engine pin )"; exit 1; } - api=https://api.github.com/repos/GetBusbar/busbar-release/releases - name="busbar-release-ci-linux-x86_64-$RELEASE_REF" - id=$(curl -fsSL -H "Authorization: Bearer $GH_TOKEN" "$api/tags/dev-bin" | jq -r --arg n "$name" '.assets[] | select(.name == $n) | .id') - [ -n "$id" ] || { echo "::error::dev-bin has no $name"; exit 1; } - curl -fsSL -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/octet-stream" -o "$RUNNER_TEMP/busbar-release" "$api/assets/$id" - echo "$ENGINE_SHA256 $RUNNER_TEMP/busbar-release" | sha256sum -c - - chmod +x "$RUNNER_TEMP/busbar-release" - - { uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1, with: { repository: GetBusbar/busbar-release, ref: "${{ env.RELEASE_REF }}", token: "${{ secrets.TURNSTILE_DISPATCH_TOKEN }}", path: busbar-release, persist-credentials: false } } # v7 - # No debuginfo in the dev/test builds (as in the shards: the runner's disk). Read-only R2 keys, - # the puts to r2-outbox/ for `cache-publish` (as in the shards: the judge runs candidate code). - - if: env.ONE_RUNNER == 'true' - working-directory: busbar-release - run: $RUNNER_TEMP/busbar-release ci hop - env: { R2_RO_KEY_ID: "${{ secrets.R2_CACHE_RO_ACCESS_KEY_ID }}", R2_RO_KEY_SECRET: "${{ secrets.R2_CACHE_RO_SECRET_ACCESS_KEY }}", CARGO_PROFILE_DEV_DEBUG: "0", BASE_SHARDS: "4" } - - if: always() && env.ONE_RUNNER == 'true' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: { name: "turnstile-report-${{ env.SHA }}", path: "artifacts/", if-no-files-found: warn } - - if: always() && env.ONE_RUNNER == 'true' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: { name: "r2-outbox-${{ env.SHA }}-one", path: "r2-outbox/", retention-days: 1, if-no-files-found: ignore, overwrite: true } - - if: env.ONE_RUNNER != 'true' - continue-on-error: true - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: { pattern: "turnstile-report-${{ env.SHA }}-shard*", path: shards } - - name: Fold the shards - if: env.ONE_RUNNER != 'true' - env: { GH_TOKEN: "${{ github.token }}", SLSA_BUILD: "${{ needs.slsa-build.result }}", SOAK: "${{ needs.soak.result }}", SLSA_READ: "${{ github.event_name == 'merge_group' || needs.preflight.outputs.needs_release_artifact == 'true' }}", SOAK_READ: "${{ needs.preflight.outputs.soak }}" } - run: | - mkdir -p merge artifacts - # A SKIPPED needed job DENIES (busbar-release 03cb349) unless the rung does not read it: - # slsa-build is read in a merge group and wherever needs_release_artifact, the soak where - # the ladder asks for it (soak=true). OLD-ENGINE SHIM (remove once ENGINE_REF is at or past - # 03cb349): an engine without --must-run takes a skip as neutral itself. - must=() - if "$RUNNER_TEMP/busbar-release" ci turnstile merge --help | grep -q -- --must-run; then - must=(--must-run "slsa-build=$SLSA_READ" --must-run "soak=$SOAK_READ") - fi - # The run's own job list: a shard with no report is INFRA:platform when its job was never - # created (GitHub dropped it, as in the 2026-10-07 16:52-17:01Z Actions outage), else - # INFRA:ours. A list that cannot be read leaves plain INFRA; the hop is never green on it. - gh api "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/attempts/$GITHUB_RUN_ATTEMPT/jobs?per_page=100" \ - >"$GITHUB_WORKSPACE/shard-jobs.json" || true - for k in 1 2 3 4; do - d="shards/turnstile-report-$SHA-shard$k" - mkdir -p "merge/shard-$k" "artifacts/shard-$k" - [ -f "$d/turnstile-report.json" ] && cp "$d/turnstile-report.json" "merge/shard-$k/" - [ -d "$d" ] && cp -r "$d/." "artifacts/shard-$k/" - done - cd busbar-release - "$RUNNER_TEMP/busbar-release" \ - ci turnstile merge --sha "$SHA" --shards 4 --dir "$GITHUB_WORKSPACE/merge" --out "$GITHUB_WORKSPACE/artifacts/turnstile-report.json" \ - --shard-jobs "$GITHUB_WORKSPACE/shard-jobs.json" --needs "slsa-build=$SLSA_BUILD" --needs "soak=$SOAK" "${must[@]}" - # The durable verdict (verdict/), from the folded report and every shard's step logs: the artifact publish reads. - - if: always() && needs.preflight.outputs.run == '1' - working-directory: busbar-release - run: $RUNNER_TEMP/busbar-release ci verdict - - { if: "always() && needs.preflight.outputs.run == '1'", uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a, with: { name: "verdict-${{ env.SHA }}", path: "verdict/", retention-days: 90, if-no-files-found: warn } } # v7.0.1 - # THE R2 CACHE'S ONE WRITER (busbar-release 069f032). The hop jobs judge with READ-ONLY keys and - # leave their cache puts (base reports, step PASSes) in r2-outbox/ artifacts; this job uploads them - # with the WRITE keys. It runs NO candidate code: it checks out nothing, downloads the engine - # (sha256-pinned) and the outboxes, and runs `ci cache-publish`, which uploads only objects under - # the cache's own prefixes. It is no part of the verdict: nothing needs it, it is no required - # check, and it fails open (no keys, no outbox, a failed upload: a later cache miss). - # workflow-rules R19: no job that runs candidate code names the write keys. - cache-publish: - needs: [preflight, hop-shard, hop] - if: ${{ always() && needs.preflight.result == 'success' && needs.preflight.outputs.run == '1' }} - runs-on: ubuntu-latest - timeout-minutes: 15 - continue-on-error: true - permissions: { contents: read } - env: { RELEASE_REF: "${{ needs.preflight.outputs.engine_ref }}", ENGINE_SHA256: "${{ needs.preflight.outputs.engine_sha256 }}" } - steps: - # THE ENGINE, downloaded, never compiled: CI's --no-default-features binary of RELEASE_REF from - # busbar-release's dev-bin release (dev-bin.yml), run only when its sha256 is ENGINE_SHA256. - # First the pin's shape: a full 40-hex commit and a 64-hex digest, or FAIL. - - name: Engine (busbar-release at RELEASE_REF, sha256-pinned) - env: { GH_TOKEN: "${{ secrets.TURNSTILE_DISPATCH_TOKEN }}" } - run: | - set -euo pipefail - [[ "$RELEASE_REF" =~ ^[0-9a-f]{40}$ ]] || { echo "::error title=ENGINE-PIN FAIL::vars.ENGINE_REF is unset or not a full 40-hex busbar-release commit (busbar-release engine pin )"; exit 1; } - [[ "$ENGINE_SHA256" =~ ^[0-9a-f]{64}$ ]] || { echo "::error title=ENGINE-PIN FAIL::vars.ENGINE_SHA256 is unset or not a 64-hex sha256 (busbar-release engine pin )"; exit 1; } - api=https://api.github.com/repos/GetBusbar/busbar-release/releases - name="busbar-release-ci-linux-x86_64-$RELEASE_REF" - id=$(curl -fsSL -H "Authorization: Bearer $GH_TOKEN" "$api/tags/dev-bin" | jq -r --arg n "$name" '.assets[] | select(.name == $n) | .id') - [ -n "$id" ] || { echo "::error::dev-bin has no $name"; exit 1; } - curl -fsSL -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/octet-stream" -o "$RUNNER_TEMP/busbar-release" "$api/assets/$id" - echo "$ENGINE_SHA256 $RUNNER_TEMP/busbar-release" | sha256sum -c - - chmod +x "$RUNNER_TEMP/busbar-release" - - { continue-on-error: true, uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c, with: { pattern: "r2-outbox-${{ env.SHA }}-*", path: r2-outbox, merge-multiple: true } } # v8.0.1 - # OLD-ENGINE SHIM (remove once ENGINE_REF is at or past 069f032): an engine without the verb - # wrote the cache from the hop itself. - - name: Publish the hop's cache puts (the R2 write keys, in this job only) - env: { R2_KEY_ID: "${{ secrets.R2_CACHE_ACCESS_KEY_ID }}", R2_KEY_SECRET: "${{ secrets.R2_CACHE_SECRET_ACCESS_KEY }}" } - run: | - if ! "$RUNNER_TEMP/busbar-release" ci --help | grep -q cache-publish; then echo "engine $RELEASE_REF has no cache-publish: nothing published"; exit 0; fi - "$RUNNER_TEMP/busbar-release" ci cache-publish --outbox "$GITHUB_WORKSPACE/r2-outbox" - # A job with a write token (as is preflight-act). It never checks out the candidate: busbar-release at RELEASE_REF, - # and the hop's verdict artifact. The sticky PR comment and the branch health issue (`ci publish`); a - # hop whose runner was lost or timed out wrote no verdict, so `ci fallback` records the job and step - # conclusions the API keeps after the logs are purged, and the run gate's reason when it refused the - # run (STACK, wiring drift). A run the gate held neutral (run=0, preflight green) posts nothing: the - # queue or another run judges that head. - publish: - needs: [preflight, hop] - if: always() && needs.preflight.result != 'skipped' && (needs.preflight.result != 'success' || needs.preflight.outputs.run == '1') - runs-on: ubuntu-latest - timeout-minutes: 10 - # statuses: write: a merge group DENIED as its PR's own red (first in the group, or behind a green - # group) marks that PR's head red - # ("merge queue" status) beside its sticky verdict, so the watcher's fix agent starts at failure. - permissions: { contents: read, actions: read, checks: read, pull-requests: write, statuses: write } - env: { RELEASE_REF: "${{ needs.preflight.outputs.engine_ref }}", ENGINE_SHA256: "${{ needs.preflight.outputs.engine_sha256 }}" } - steps: - # THE ENGINE, downloaded, never compiled: CI's --no-default-features binary of RELEASE_REF from - # busbar-release's dev-bin release (dev-bin.yml), run only when its sha256 is ENGINE_SHA256. - # First the pin's shape: a full 40-hex commit and a 64-hex digest, or FAIL. - - name: Engine (busbar-release at RELEASE_REF, sha256-pinned) - env: { GH_TOKEN: "${{ secrets.TURNSTILE_DISPATCH_TOKEN }}" } - run: | - set -euo pipefail - [[ "$RELEASE_REF" =~ ^[0-9a-f]{40}$ ]] || { echo "::error title=ENGINE-PIN FAIL::vars.ENGINE_REF is unset or not a full 40-hex busbar-release commit (busbar-release engine pin )"; exit 1; } - [[ "$ENGINE_SHA256" =~ ^[0-9a-f]{64}$ ]] || { echo "::error title=ENGINE-PIN FAIL::vars.ENGINE_SHA256 is unset or not a 64-hex sha256 (busbar-release engine pin )"; exit 1; } - api=https://api.github.com/repos/GetBusbar/busbar-release/releases - name="busbar-release-ci-linux-x86_64-$RELEASE_REF" - id=$(curl -fsSL -H "Authorization: Bearer $GH_TOKEN" "$api/tags/dev-bin" | jq -r --arg n "$name" '.assets[] | select(.name == $n) | .id') - [ -n "$id" ] || { echo "::error::dev-bin has no $name"; exit 1; } - curl -fsSL -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/octet-stream" -o "$RUNNER_TEMP/busbar-release" "$api/assets/$id" - echo "$ENGINE_SHA256 $RUNNER_TEMP/busbar-release" | sha256sum -c - - chmod +x "$RUNNER_TEMP/busbar-release" - - { uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1, with: { repository: GetBusbar/busbar-release, ref: "${{ env.RELEASE_REF }}", token: "${{ secrets.TURNSTILE_DISPATCH_TOKEN }}", path: busbar-release, persist-credentials: false } } # v7 - - { id: verdict, continue-on-error: true, uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c, with: { name: "verdict-${{ env.SHA }}", path: verdict } } # v8.0.1 - - if: steps.verdict.outcome == 'success' - working-directory: busbar-release - run: $RUNNER_TEMP/busbar-release ci publish - env: { GH_TOKEN: "${{ github.token }}", HEALTH_BRANCHES: none } - - if: needs.preflight.result != 'success' || needs.hop.result != 'success' - working-directory: busbar-release - run: $RUNNER_TEMP/busbar-release ci fallback - env: { GH_TOKEN: "${{ github.token }}", PR_NUMBER: "${{ github.event.pull_request.number }}", PREFLIGHT_RESULT: "${{ needs.preflight.result }}", PREFLIGHT_REASON: "${{ needs.preflight.outputs.reason }}", HOP_RESULT: "${{ needs.hop.result }}", HEALTH_BRANCHES: none } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8aea311471..5e4db11e43 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,7 +26,7 @@ This page is the short version. Where the two differ, Part 6 wins. both sides moved (theirs + ours − base; anything else stays a conflict): `git config merge.ledger-count.driver "cargo xtask ledger-merge %O %A %B"`. - Builds, tests, clippy, the gates and the oracle run in CI (ModernCI for `predev` PRs; `.github/workflows/promote.yml` for the promotion). + Builds, tests, clippy, the gates and the oracle run in CI: ModernCI, for `predev` PRs and for every promotion (`.ci/*.yaml`). 3. Push the lane and open a PR into `predev`. ModernCI (required check `ci`) judges and lands it. Then stop; CI is the proof and a red comes back as a task. 4. The PR body says what changed, why, and the ruling or issue it implements. No AI attribution. diff --git a/README.md b/README.md index fb5f0810aa..aba9701729 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,6 @@

-promote Coverage OpenSSF Scorecard OpenSSF Best Practices diff --git a/RELEASE.md b/RELEASE.md index d13efc6719..8680b85a0c 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,33 +1,21 @@ # Releasing Busbar -The branch ladder is **predev → dev → qa → main**. Pull requests into `predev` are judged and landed by -ModernCI (required check `ci`, configured by [`.ci/default.yaml`](.ci/default.yaml)). The promotion -from `predev` to `dev` runs [`.github/workflows/promote.yml`](.github/workflows/promote.yml). The release engine lives in the private `GetBusbar/busbar-release` repository -(pinned outside this repo by the org variables `ENGINE_REF` and `ENGINE_SHA256`, set with -`busbar-release engine pin `; `workflow-rules` R16 holds promote.yml to a 40-hex check and a -sha256 verify of the engine at run time); busbar-release's `crates/busbar-release-train/ladder.json` -says what entering each rung needs. - -| Rung entered (the PR's base) | What the hop runs | Consent | -|---|---|---| -| **`predev`** (from a `lane-*` branch) | every registered gate (`cargo xtask gate --list`, each by name) | none | -| **`dev`** | the gates, the oracle against the 1.5.5 golden, conformance | none | -| **`qa`** | the same, plus build + test, then the release binaries and the container | the owner (the org ruleset keeps `qa` locked) | -| **`main`** | the same, then publish | the owner (the org ruleset keeps `main` locked) | - -A hop has two jobs, both in `promote.yml`: - -* **`preflight`**, on a free runner: `cargo fmt --all -- --check`, `cargo metadata --locked`, and the - generated C header is current (`cargo xtask gate abi-header --write` leaves no diff). -* **`hop`**, on a Latchkey runner sized by the rung: the pinned engine plans the hop and admits the - candidate sha, and the verdict is uploaded as an artifact. When - the rung needs no consent and the repository variable `PROMOTE_BOARD` is `true`, the engine - fast-forwards the base branch to the exact sha it tested. Both promotions are **fast-forward - pushes**: linear history is what makes "main's HEAD *is* the qa commit" true. - -> The entry builds a plan names (`release-binaries`, `container`, `publish`) are not yet ported into -> the engine: `promote.yml` fails a hop that plans one with that message rather than skipping it. -> Nothing is built or published until they land in busbar-release. +The branch ladder is **predev → dev → qa → main**, and ModernCI judges and moves every rung from +this repository's branch files (OWNER 2026-10-12, "ModernCI only"; the ruling is +[`docs/design/BUSBAR-1.6.0.md`](docs/design/BUSBAR-1.6.0.md) Part 6, "BUSBAR-RELEASE RETIRED FROM +BUSBAR"). A branch file is named for the branch being moved and says what the move must pass. + +| Move | Branch file | What it runs | Consent | +|---|---|---|---| +| a `lane-*` branch into **`predev`** | [`.ci/default.yaml`](.ci/default.yaml) | preflight, every registered gate, clippy, compile, test, doctest, deletion matrix, `cargo deny`; no worse than predev (required check `ci`) | none | +| **`predev`** into **`dev`** | [`.ci/predev.yaml`](.ci/predev.yaml) | the gates, the oracle against the 1.5.5 golden, conformance, `scripts/verify-1.6.0-done.sh`, the Q128 audit | the owner's approval | +| **`dev`** into **`qa`** | `.ci/dev.yaml` | the same, plus build and test, the fuzz smoke and the signing gate, the soak, the release binaries and the container | the owner (the org ruleset keeps `qa` locked) | +| **`qa`** into **`main`** | `.ci/qa.yaml` | the same; on landing, the release is signed and published from the build that was judged, never rebuilt | the owner (the org ruleset keeps `main` locked) | + +`.ci/dev.yaml` and `.ci/qa.yaml` are not written yet: until they land, nothing is built or published +from `qa` or `main`. The busbar-release hop (`promote.yml`, the engine pin) is deleted. busbar-release +remains the home of the oracle (`busbar-oracle`, pinned by `testing/shadow-oracle/oracle-rust.pin`), +the soak and the plugin fleet generator. **The tag is an OUTPUT of a green release, never an input.** Nobody tags by hand; no workflow is triggered by a `v*` tag (`cargo xtask gate workflow-rules`, rule R1). Docker Hub tag immutability @@ -49,19 +37,17 @@ python3 .github/scripts/roll_changelog.py 1.6.0 CHANGELOG.md ``` That bumps `crates/busbar/Cargo.toml` + `Cargo.lock`, regenerates the committed OpenAPI schema, and -rolls `[Unreleased]` to `[1.6.0]` with today's date. It does **not** tag. Open the result as a pull -request against `dev` and let the hop run. +rolls `[Unreleased]` to `[1.6.0]` with today's date. It does **not** tag. Land it on `predev` like +any other change; it reaches `dev` with the next promotion. ## Moving a version up the ladder -Open a pull request whose base is the next rung (`dev` into `qa`, `qa` into `main`; a lane branch into `predev`). `promote.yml` -runs the hop on the PR; the run's check names are `preflight` and `hop`. A red hop is fixed -forward on the lower branch and the pull request re-run: every push re-runs the hop on the new sha. +ModernCI opens and judges each promotion (`modernci call moves` shows how each stands). A red move +is fixed forward on the lower branch: every push re-judges it on the new head. ## Branch protection this assumes -`qa` and `main` require the two contexts `preflight` and `hop` (plus the org ruleset's owner -consent). +`predev` requires ModernCI's check `ci`; `qa` and `main` take the owner's consent (the org ruleset). Branch protection lives in repository rulesets, not in a script (see `.github/required-status-checks.md`). Keep `enforce_admins: true`, `required_linear_history: true`, `allow_force_pushes: false` and diff --git a/docs/ci/actions-pins.md b/docs/ci/actions-pins.md index 93e848d582..c3f9f461dc 100644 --- a/docs/ci/actions-pins.md +++ b/docs/ci/actions-pins.md @@ -63,8 +63,8 @@ PR that preserves the trailing tag comment. ## Every action currently in the tree -Every `uses:` in `.github/workflows/` (`promote.yml` and the reusable `plugin-*.yml` workflows) is one -of the nine distinct `(action, ref)` pairs above, pinned to a commit SHA with its tag kept as a +Every `uses:` in `.github/workflows/` (the reusable `plugin-*.yml` workflows; `promote.yml` is deleted) +is one of the distinct `(action, ref)` pairs above, pinned to a commit SHA with its tag kept as a trailing comment. This table is the independent re-verification of that state. There is no `.github/actions` composite action beyond `cargo-home`, and `R14` is proven able to catch a floating tag inside one by `r14_reads_uses_out_of_a_composite_action_too_not_only_workflows` in diff --git a/docs/design/BUSBAR-1.6.0.md b/docs/design/BUSBAR-1.6.0.md index 5178d9ec6f..bf77b09c7a 100644 --- a/docs/design/BUSBAR-1.6.0.md +++ b/docs/design/BUSBAR-1.6.0.md @@ -2893,7 +2893,7 @@ shape guarantee each is a clean add, never a break. That is "fits-or-cleanly-ext > definition and the owner rulings that shape execution. ## DONE = DEV-GREEN (the definition every wave drives to) -DEV-GREEN is reached when ALL of the following hold and turnstile boards ADMIT: +DEV-GREEN is reached when ALL of the following hold ~~and turnstile boards ADMIT~~ and ModernCI's `predev` → `dev` move (`.ci/predev.yaml`) is green and lands (Part 6, "BUSBAR-RELEASE RETIRED FROM BUSBAR", OWNER 2026-10-12/13): 1. **Oracle byte-identical vs the 1.5.5 golden on every family** (money sacred, #9/#10). The ONLY accepted diffs are the entries of `testing/shadow-oracle/accepted-differences.json`, all 45 re-signed by the owner 2026-09-30. Everything else byte-green. @@ -2911,7 +2911,7 @@ DEV-GREEN is reached when ALL of the following hold and turnstile boards ADMIT: independent architecture audit on the DEV-GREEN candidate sha, graded against the Laws and THE DESIGN; it passes with no open BLOCK findings. The "two audits ≥9/10" requirement is struck (#60; the §11a baseline that score depended on never existed). -6. **turnstile BOARD = ADMIT → train ff `predev`→`dev` = DEV-GREEN** (#32/#67). +6. ~~**turnstile BOARD = ADMIT → train ff `predev`→`dev` = DEV-GREEN** (#32/#67).~~ SUPERSEDED 2026-10-13 by Part 6, "BUSBAR-RELEASE RETIRED FROM BUSBAR": **ModernCI's `predev` → `dev` move green on `.ci/predev.yaml` and landed = DEV-GREEN.** ## money-touching step shadow-oracle-gated; each wave loops `/codeaudit` to 2 clean passes #60) @@ -2984,8 +2984,8 @@ Agents: (a) land `integration/conformance-b`; conformance MUST-set green for all arm + green every gate in the DONE battery above (each RED-provable, red-before-green). ### W8 — BOARD → DEV-GREEN [#32/#67] -turnstile runs gates + oracle + conformance → BOARD verdict. On ADMIT, the train fast-forwards -`predev`→`dev`. **DEV-GREEN.** +~~turnstile runs gates + oracle + conformance → BOARD verdict. On ADMIT, the train fast-forwards +`predev`→`dev`.~~ ModernCI runs `.ci/predev.yaml` (gates, oracle, conformance, the done-oracle, the Q128 audit) on the `predev` → `dev` move; green, it lands. **DEV-GREEN.** --- @@ -3110,14 +3110,60 @@ Everything in Parts 1–5 describes the product. This Part describes the machine lives in **two repos**, and that split is the single most common source of "I can't find the autoscaler" confusion. +## BUSBAR-RELEASE RETIRED FROM BUSBAR; MODERNCI IS BUSBAR'S CI (OWNER 2026-10-12, 2026-10-13) + +> **OWNER 2026-10-12:** *"ModernCI only."* **OWNER 2026-10-13:** *"delete anything you don't need +> anymore, yes"* and *"Yes, we keep the busbar-release repo, slimmed down. ModernCI replaces its CI +> half; the busbar-specific tools stay."* This section governs Part 6. Every passage below that +> names the hop, the turnstile, the train, the conductor, the runner gateway, `promote.yml` or the +> engine pin is SUPERSEDED by it and is struck where it stands. + +**ModernCI judges and moves every rung, from busbar's own branch files** (ModernCI's answer, +2026-10-13: a branch file per moving branch, `promote: auto` opens the promotion, `on.landed` steps +run on each new target tip): + +| Move | Branch file | What it runs | +|---|---|---| +| `lane-*` → `predev` | `.ci/default.yaml` | preflight, gates, clippy, compile, test, doctest, deletion-matrix, deny; no worse than predev; ModernCI's merge train lands it (required check `ci`) | +| `predev` → `dev` | `.ci/predev.yaml` | gates, the oracle against the 1.5.5 golden, conformance, `verify-1.6.0-done.sh`, the Q128 audit; the owner's approval. **Its green landing is DEV-GREEN** (Part 5) | +| `dev` → `qa` | `.ci/dev.yaml` | the `dev` rung plus build and test, the fuzz smoke and the signing gate (today `qa/pipeline-checks.json`), the soak, the release binaries and the container; the owner's approval | +| `qa` → `main` | `.ci/qa.yaml` | the same battery; on landing, the `release` step signs and publishes the build it judged (no rebuild) and a `run` step does cosign and the container push, with secrets scoped to `on_landed` | + +**busbar-release stays, as the tools repo** (judged by ModernCI, not by its own Actions, once +ModernCI registers it). KEEP: `busbar-release-oracle` (the money judge `busbar-oracle`, pinned by +`testing/shadow-oracle/oracle-rust.pin`: one commit, one sha256 per architecture), with +`busbar-release-fetch` and `busbar-release-golden-scope`; the published `busbar-oracle-linux--` +assets every pin names, kept for as long as a busbar branch pins them; `oracle-rerecord` (the +add-only golden recorder); `busbar-release-soak`; `busbar-release-plugin` (the fleet generator: +`plugin new|sync|check`, `template/`) and its daily `plugin-drift` check, which stays on Actions +until ModernCI's scheduled checks are live (ModernCI PR #91); `busbar-release-github` (the plugin +generator and the soak call GitHub through it); and the `busbar-release` command line, cut to those +verbs. DELETE: the conductor and its state repo, the turnstile, the hop, the train and the ladder +(`ci` verbs, `ladder.json`, `preflight.json`), the runner gateway (`busbar-release-runners`, +`vars.RUNNERS` once the last workflow that reads it is gone), the EC2 backend and autoscaler, the +runner canary and probe, the `dev` dispatch workflow, the engine build on `dev-bin`, and the engine +pin (the org variables `ENGINE_REF`, `ENGINE_SHA256`, `ENGINE_JUDGE`, and the R2 cache keys the +hop alone read). In busbar: `.github/workflows/promote.yml` and the `workflow-rules` rows that +held it (R16 the engine pin, R18 its release-artifact wiring, R19 its R2 keys). + +**The oracle and the golden do not move.** The 1.5.5 golden stays in +`testing/shadow-oracle/golden/1.5.5`, read-only and add-only (above, "The 1.5.5 golden is +read-only"). The judge is built from busbar-release at the pinned commit; ModernCI runs only that +pinned install, checked against the pin's sha256 for its host, and a candidate never picks its own +judge. + +**The plugin fleet.** Each plugin repo is judged by ModernCI once it is registered there with its +own branch files; until then the reusable `plugin-ci.yml`, `plugin-consumer-verify.yml`, +`plugin-release.yml` and `plugin-repin.yml` stay, because the fleet's rendered callers name them. + ## The branch roster (#67) — four branches, and no others | Branch | Rule | CI | |---|---|---| -| `predev` | **permanent WIP.** Every in-flight session lands here and forks from here. | Pull requests only, landed through GitHub's merge queue; `promote.yml` `preflight` + `hop` are the required checks on both the pull request (the fast pre-check) and the merge group (the full plan, the proof of record), and the verdict is "no worse than base" (see "How work lands" below). | -| `dev` | **release-train-write-only.** "Next version's WIP, nowhere near done." | full `ci.yml` | -| `qa` | promotion target. **The one release build happens here** — the PGO build that ships, tested for real. | full CI + the real-media matrix | -| `main` | **a push here cuts a release** — tag, GitHub Release, container promotion, `latest` moved. Irreversible. **`main` never compiles:** it tags and publishes the artifacts, by digest, that `qa` built and verified. | release orchestration | +| `predev` | **permanent WIP.** Every in-flight session lands here and forks from here. | ~~Pull requests only, landed through GitHub's merge queue; `promote.yml` `preflight` + `hop` are the required checks on both the pull request (the fast pre-check) and the merge group (the full plan, the proof of record), and the verdict is "no worse than base" (see "How work lands" below).~~ SUPERSEDED 2026-10-13 by "BUSBAR-RELEASE RETIRED FROM BUSBAR" above: pull requests only, judged by ModernCI on `.ci/default.yaml` (required check `ci`), no worse than predev, landed by ModernCI's merge train. | +| `dev` | **release-train-write-only.** "Next version's WIP, nowhere near done." | ~~full `ci.yml`~~ SUPERSEDED 2026-10-13 by "BUSBAR-RELEASE RETIRED FROM BUSBAR" above: `.ci/predev.yaml` judges the move in | +| `qa` | promotion target. **The one release build happens here** — the PGO build that ships, tested for real. | ~~full CI + the real-media matrix~~ SUPERSEDED 2026-10-13 by "BUSBAR-RELEASE RETIRED FROM BUSBAR" above: `.ci/dev.yaml` judges the move in | +| `main` | **a push here cuts a release** — tag, GitHub Release, container promotion, `latest` moved. Irreversible. **`main` never compiles:** it tags and publishes the artifacts, by digest, that `qa` built and verified. | ~~release orchestration~~ SUPERSEDED 2026-10-13 by "BUSBAR-RELEASE RETIRED FROM BUSBAR" above: `.ci/qa.yaml` judges the move in; its `on.landed` steps publish | **Never push `dev`, `qa` or `main` by hand.** `predev` and `dev` take changes by pull request only (ruleset; OWNER R3 2026-10-01); `--force-with-lease` is permitted only on your own `lane-*` branch. **Branch protection is on `qa` only** — @@ -3138,21 +3184,21 @@ Nothing is rebuilt between them. **`GetBusbar/busbar`** — the product. `crates/`, `xtask/` (every gate), `scripts/` (including `verify-1.6.0-done.sh`, the definition of done), `conformance/`, `testing/shadow-oracle/`. -**`GetBusbar/busbar-release`** — the engine, nine crates. Being redesigned as one PR state machine (busbar-release docs/ARCHITECTURE.md). +~~**`GetBusbar/busbar-release`** — the engine, nine crates. Being redesigned as one PR state machine (busbar-release docs/ARCHITECTURE.md).~~ SUPERSEDED 2026-10-13 by "BUSBAR-RELEASE RETIRED FROM BUSBAR" above: busbar-release is the tools repo; the rows below for the turnstile, the train, the autoscaler and the CI half of the command line are deleted. | Crate | Job | |---|---| -| `busbar-release-turnstile` | The gate. Runs the batteries and returns a **BOARD** verdict — `ADMIT` or deny. Nothing reaches `dev` without it. | -| `busbar-release-train` | The mover. Fast-forwards `predev` → `dev` → `qa` → `main` in lockstep, refusing unless every precondition holds. | +| `busbar-release-turnstile` | ~~The gate. Runs the batteries and returns a **BOARD** verdict — `ADMIT` or deny. Nothing reaches `dev` without it.~~ DELETE | +| `busbar-release-train` | ~~The mover. Fast-forwards `predev` → `dev` → `qa` → `main` in lockstep, refusing unless every precondition holds.~~ DELETE | | `busbar-release-oracle` | The byte-identity oracle: this build vs the published 1.5.5 binary. | -| `busbar-release-autoscaler` | Runner capacity. Ships the **`busbar-fleet`** binary (`src/bin/busbar-fleet.rs`) — this is the "fleet" tool; there is no separate fleet crate. | +| `busbar-release-autoscaler` | ~~Runner capacity. Ships the **`busbar-fleet`** binary (`src/bin/busbar-fleet.rs`) — this is the "fleet" tool; there is no separate fleet crate.~~ DELETE | | `busbar-release-corpus` | The recorded request corpus the oracle replays. | -| `busbar-release-cli` | The one `busbar-release` command line: ship, dev, watch, status, plugin, and the CI internals under `ci`. | +| `busbar-release-cli` | ~~The one `busbar-release` command line: ship, dev, watch, status, plugin, and the CI internals under `ci`.~~ KEEP only the oracle, soak and plugin verbs | | `busbar-release-github` | The one door to GitHub: batched GraphQL reads, REST writes, shared rate-limit and retry policy. | | `busbar-release-plugin` | The plugin fleet generator: renders `template/` into every `busbar--` repo, with drift check, sync and repin. | | `busbar-release-soak` | The sustained mixed-traffic soak of a built binary against the oracle's mocks, judged row by row. | -Its workflows: `turnstile.yml`, `fleet-control.yml`, `oracle-rerecord.yml`, `docker-autoscaler.yml`, `ci.yml`. +~~Its workflows: `turnstile.yml`, `fleet-control.yml`, `oracle-rerecord.yml`, `docker-autoscaler.yml`, `ci.yml`.~~ SUPERSEDED 2026-10-13 by "BUSBAR-RELEASE RETIRED FROM BUSBAR" above. **The marketing site is observed, never depended on** (owner, 2026-09-06). Nothing in the release path triggers, `needs:` or waits on the site deploy; the site keeps its own trigger. Site checks @@ -3165,7 +3211,7 @@ fully torn down — zero instances, zero volumes, zero NAT gateways, zero elasti both cheaper and the money oracle's home (**#56 wins over #29** — the oracle rides Latchkey, it does not need a dedicated fleet box; `CI_RUNNER_ONDEMAND_FLOOR` is 0, not 2). -**Runner gateway (OWNER 2026-10-10, supersedes the 2026-09-28 capacity order):** every job asks for a logical size (small, medium, large, xlarge), never a vendor label. One table maps each size to a backend (latchkey, ec2 or github), so switching a backend is a one-line change and nothing else breaks. Today every size maps to Latchkey (account cap 80 concurrent jobs); EC2 burst is off. A daily canary job runs on every backend in the table, including the switched-off ones, so a dormant backend is proven before anyone switches to it. #78's spend breaker binds every paid backend. +~~**Runner gateway (OWNER 2026-10-10, supersedes the 2026-09-28 capacity order):** every job asks for a logical size (small, medium, large, xlarge), never a vendor label. One table maps each size to a backend (latchkey, ec2 or github), so switching a backend is a one-line change and nothing else breaks. Today every size maps to Latchkey (account cap 80 concurrent jobs); EC2 burst is off. A daily canary job runs on every backend in the table, including the switched-off ones, so a dormant backend is proven before anyone switches to it. #78's spend breaker binds every paid backend.~~ SUPERSEDED 2026-10-13 by "BUSBAR-RELEASE RETIRED FROM BUSBAR" above: ModernCI places each check on its backends. Agent compute is Latchkey only, through one shared job budget (`1.6.0-TODO.md`, THE RULES 6.1-6.3); the owner has ruled no extra build capacity (2026-09-29). EC2 is allowed for the performance phase (§11.9). @@ -3270,7 +3316,7 @@ now fixed, one was a misdiagnosis, and one dissolves on the first promotion.** C ## Size is not a CI check (owner 2026-10-02) -The hop (`promote` → `preflight` + `hop`) never denies a PR for size, at any rung. Line counts are +ModernCI ~~The hop (`promote` → `preflight` + `hop`)~~ never denies a PR for size, at any rung. Line counts are not measured in CI at all: the construction gate's `loc-ceilings:*` and `surface-ceiling:*` rows, its `ceiling-rose` / `ceiling-slack` rows, `ship-ready`'s two ceiling rows and the per-cell counts of `kind-isolation:matrix` are deleted, and with them every hand-kept size integer in @@ -3297,33 +3343,36 @@ in the default `pull_request` types. ## THE CONDUCTOR (OWNER 2026-10-11) — one deterministic driver; authors write code, nothing else -> **OWNER 2026-10-11.** *"We have the pieces, not a thing pushing things through the pieces, and +> **SUPERSEDED 2026-10-13 by "BUSBAR-RELEASE RETIRED FROM BUSBAR" above.** The conductor is deleted: ModernCI is the one program that judges, trains and +> moves busbar's branches. The text below is struck, kept for its history. + +> ~~**OWNER 2026-10-11.** *"We have the pieces, not a thing pushing things through the pieces, and > that can't be an agent."* *"I wake up one day, never use agents again, replace them with human Bob, > and the system still works. Bob codes, runs one command, and it does its thing and tells Bob (or > the agent) if they need to do something else."* *"Every second of waste is $ burnt — money and > time."* *"The workflow is the workflow"* (no run cap). Supersedes, where they disagree: "How work > lands" item 3 (GitHub's merge queue, OWNER 2026-10-02), "Ship and stop", and every rule that has -> an agent push, prove, retry, land, promote, or be spawned by the pipeline. +> an agent push, prove, retry, land, promote, or be spawned by the pipeline.~~ -**Why (measured 2026-10-10/11, busbar-release's 2-hour audit ledger, 9 windows):** Latchkey $442, +~~**Why (measured 2026-10-10/11, busbar-release's 2-hour audit ledger, 9 windows):** Latchkey $442, of which $254 (57%) bought nothing: 166 of 264 merge-group runs were cascades thrown away behind a red group, 211 of 419 PR runs were red (fixes nobody had run), and the proof that would have caught -them cheaply was refused by a run cap. +them cheaply was refused by a run cap.~~ -**The rule.** One program decides; authors only write code. An AUTHOR is whoever writes code — a +~~**The rule.** One program decides; authors only write code. An AUTHOR is whoever writes code — a person or an agent, interchangeably; the pipeline never knows or cares which, and never spawns one. The CONDUCTOR is `busbar-release conductor`, one always-on service (today on the build server, the M3 at `10.1.7.14`; it runs anywhere from one config directory). It is deterministic code: every decision is a rule over measured facts. It updates itself from each green `main` release of -busbar-release; nothing about running it is agent-driven. +busbar-release; nothing about running it is agent-driven.~~ -**The author's whole interface is one command:** `busbar-release submit` from a branch (it replaces +~~**The author's whole interface is one command:** `busbar-release submit` from a branch (it replaces `ship`). The conductor takes it from there and tells the author only when they must act — what is red, with the output, and the one thing to do next (fix and `submit` again; resolve a conflict; owner sign-off needed) — on the terminal, as the PR's one verdict comment, and in `busbar-release -status`. +status`.~~ -**The stations after `submit`.** The conductor owns every move. +~~**The stations after `submit`.** The conductor owns every move.~~ | Station | What happens | Junk it refuses | |---|---|---| @@ -3332,46 +3381,46 @@ status`. | TRAIN | The conductor batches the PRs whose PR check is green onto the predev tip (`train/`, one merge per PR, a conflicting PR goes back to its author) and runs the full plan ONCE on the batch. Green: predev takes the batch and every PR in it lands. Red: the failing rows are attributed to the PRs whose changes reach them and those go back to their authors; the rest re-batch (unattributable: split in half). Only the conductor moves predev. | Speculative per-PR merge groups and their cascades (impossible by construction); a batch rebuilt for PRs that did not change. | | PROMOTE | predev -> dev -> qa -> main on the same engine, each rung judged once, absolutely where the ladder says so. | A promotion run that cannot pass (a rung whose required suite is registered not-run). | -**METER** runs under every station: each proof and CI run records backend, minutes, dollars and -outcome in one ledger; the waste over any window is a `health` row that fails above 10%. +~~**METER** runs under every station: each proof and CI run records backend, minutes, dollars and +outcome in one ledger; the waste over any window is a `health` row that fails above 10%.~~ -**PROVERS are registered backends, like runners.** A prover is a drop-in driver beside the runner +~~**PROVERS are registered backends, like runners.** A prover is a drop-in driver beside the runner gateway's (`Driver`): its name, slots, price per minute, readiness, canary, its own idempotent `up`/`down` (the M3 prover's `up` builds its Linux VM from nothing), and `prove(commit, commands) -> verdict`. Which provers are live, in what order and with how many slots is one table (`PROVERS`), switched with one command after a canary, never by code: today the M3 (an arm64 Linux VM, warm cache), Latchkey as overflow; tomorrow any mix of M3, Latchkey, EC2. The conductor fills the first prover with a free slot. A proof is a filter, never the verdict: the verdict stays CI's, on Linux -x86_64, so a rare arm64/x86_64 difference costs at most one CI run. +x86_64, so a rare arm64/x86_64 difference costs at most one CI run.~~ -**The conductor IS the workflow; GitHub is one forge driver (OWNER 2026-10-11).** *"Conductor is the +~~**The conductor IS the workflow; GitHub is one forge driver (OWNER 2026-10-11).** *"Conductor is the master workflow: what could be a GitHub Actions workflow is the Rust conductor with pluggable infra."* *"We shouldn't be tightly coupled to GitHub; it is a code repo, and if I want to go to -Forgejo I could, mid-work."* So: -- The conductor executes every job of every station itself, on INFRA DRIVERS (the M3's per-job +Forgejo I could, mid-work."* So:~~ +- ~~The conductor executes every job of every station itself, on INFRA DRIVERS (the M3's per-job Linux VMs, the Latchkey CLI, EC2 boxes, GitHub-hosted runners), chosen by one owner command (`busbar-release infra use `, canaried, effective on the next job). Every job asks for a size, including the light orchestration jobs; nothing names a vendor. It stores logs and links them in the PR's one verdict comment, and posts ONE verdict status per PR head that branch - protection requires. -- The code host is a FORGE DRIVER: git push/fetch, pull requests, comments, the verdict status, branch + protection requires.~~ +- ~~The code host is a FORGE DRIVER: git push/fetch, pull requests, comments, the verdict status, branch protection. GitHub is the driver today; Forgejo is the same interface. Nothing in the pipeline depends on a forge-only feature: Actions workflows (replaced by the conductor), the merge queue - (replaced by the train), forge-only APIs. Switching forge is configuration and a mirror push. -- Platform of record: busbar's VERDICT jobs run on x86_64 Linux (the 1.5.5 golden was recorded + (replaced by the train), forge-only APIs. Switching forge is configuration and a mirror push.~~ +- ~~Platform of record: busbar's VERDICT jobs run on x86_64 Linux (the 1.5.5 golden was recorded there) unless a measured run proves arm64 byte-identical; the M3's arm64 VMs take proofs and - busbar-release's own CI. The M3 runs untrusted-safe: one throwaway VM per job. -- OPEN (owner, before step 7 of 1.6.0): release signing without a forge-specific identity (SLSA + busbar-release's own CI. The M3 runs untrusted-safe: one throwaway VM per job.~~ +- ~~OPEN (owner, before step 7 of 1.6.0): release signing without a forge-specific identity (SLSA provenance today assumes GitHub's OIDC token, which exists only inside Actions): our own signing - key, or the chosen forge's CI. + key, or the chosen forge's CI.~~ -**What the conductor replaces** (deleted as each station lands): GitHub's merge queue on predev, +~~**What the conductor replaces** (deleted as each station lands): GitHub's merge queue on predev, `jump`, the merge-group re-check and orphan machinery, the STALE/UPDATE rule, `ship` and `dev` as author tools, and every agent the pipeline spawns (fix, stuck, rebase, shared). The turnstile, the -hop, the ladder, the step cache, autopin and the EC2 runner backend stay as its internals. +hop, the ladder, the step cache, autopin and the EC2 runner backend stay as its internals.~~ -**Interim, until the train is live:** predev's merge queue builds at most 2 groups at once (OWNER -2026-10-11, was 10). +~~**Interim, until the train is live:** predev's merge queue builds at most 2 groups at once (OWNER +2026-10-11, was 10).~~ ## How work lands: the PR flow and the rules every contributor follows @@ -3385,10 +3434,11 @@ branches live on GitHub so CI runs the pipeline). 1. One branch per lane, `lane-`, cut from `origin/predev`. A branch that depends on another unmerged lane says so in its PR body. -2. Changes reach `predev` and `dev` **only by pull request**. The ruleset on both requires the two - jobs of `.github/workflows/promote.yml`, `preflight` and `hop`; no direct push, no force-push, no - deletion. `dev` moves only through the promote engine. -3. predev lands through GitHub's merge queue (OWNER 2026-10-02). The MERGE-GROUP run is the proof of +2. Changes reach `predev` and `dev` **only by pull request**. The ruleset on both requires ~~the two + jobs of `.github/workflows/promote.yml`, `preflight` and `hop`~~ ModernCI's check `ci`; no direct + push, no force-push, no deletion. `dev` moves only through ~~the promote engine~~ ModernCI's + `predev` → `dev` move (`.ci/predev.yaml`). +3. ~~predev lands through GitHub's merge queue (OWNER 2026-10-02). The MERGE-GROUP run is the proof of record: the full plan (every gate, every build/test step, the money oracle forced, conformance produced at judge time and judged absolutely per #68) on the queue's merge commit, judged "no worse than base" against the base the queue built it on; its report becomes the base of the commit it @@ -3398,19 +3448,19 @@ branches live on GitHub so CI runs the pipeline). PR merged onto the base tip, with no oracle and no conformance; on ONE runner when that is estimated within 20 minutes, else as the merge group's shard matrix ("Verification runs once per input", OWNER-LOCKED 2026-10-06). Neither may add a red. A local Latchkey - run is for iteration only. + run is for iteration only.~~ SUPERSEDED 2026-10-13 by "BUSBAR-RELEASE RETIRED FROM BUSBAR" above: ModernCI judges every pull request on its branch file and lands it with its own merge train; `dev`, `qa` and `main` move by ModernCI's promotion. 4. A `$` (money-touching) change is its own PR, never bundled with anything else. 5. On merge the branch is deleted (OWNER 2026-10-01, BRANCH LIFETIME: "as soon as merged into predev get rid of the branch"). -**Ship and stop** (ARCHITECT 2026-10-02, supersedes every polling instruction). When a slice is +~~**Ship and stop** (ARCHITECT 2026-10-02, supersedes every polling instruction). When a slice is ready, run one command from the lane's worktree: `busbar-release ship "" [--body ]` (the release CLI, `bin/busbar-release` in a clone of GetBusbar/busbar-release; OWNER 2026-10-02: every release tool lives there, and `cargo xtask ship` is gone). It refuses any branch that is not `lane-*`, merges `origin/predev` in, runs the local pre-flight, commits what the pre-flight changed (tracked files only), pushes, opens or reuses the PR into `predev` and turns on auto-merge. Then STOP: no polling of CI, PRs or Latchkey, no sleep loops. A red or conflicted PR comes back to the owning -lane as a new task; fix it, ship again, stop again. +lane as a new task; fix it, ship again, stop again.~~ SUPERSEDED 2026-10-13 by "BUSBAR-RELEASE RETIRED FROM BUSBAR" above: the author pushes the branch and opens the pull request; ModernCI does the rest (`modernci done`). **Never bless a golden or a ratchet.** A red is fixed forward with a root cause; nothing is waived and nothing is blessed (OWNER 2026-10-01, PREDEV IS THE CONVERGENCE BRANCH). Customer-visible diff --git a/xtask/src/gates/no_self_filed_issues.rs b/xtask/src/gates/no_self_filed_issues.rs index 01cb82559b..85264976d2 100644 --- a/xtask/src/gates/no_self_filed_issues.rs +++ b/xtask/src/gates/no_self_filed_issues.rs @@ -42,9 +42,12 @@ pub const ROW_PERM: &str = "no-self-filed-issues:no-issues-permission"; const WF_DIR: &str = ".github/workflows"; -/// A discovery step that finds nothing passes everything. Twenty-odd workflows today; the floor is -/// the shell's, a `const` with no environment override. -const DISCOVERY_FLOOR: usize = 5; +/// A discovery step that finds nothing passes everything. The floor is the workflow count, a `const` +/// with no environment override: the four reusable `plugin-*.yml` workflows the plugin repos call. +/// It was the shell's 5 while `promote.yml` (the busbar-release hop) stood; that file is deleted +/// (OWNER 2026-10-12, "ModernCI only": busbar's moves are judged from `.ci/*.yaml`), so 5 would +/// read the honest tree as broken discovery. +const DISCOVERY_FLOOR: usize = 4; const CLEAN: &str = "the scan cleared its floor and named nothing"; diff --git a/xtask/src/gates/package_selectors.rs b/xtask/src/gates/package_selectors.rs index 0a92532e34..1e3973830b 100644 --- a/xtask/src/gates/package_selectors.rs +++ b/xtask/src/gates/package_selectors.rs @@ -1317,7 +1317,7 @@ mod tests { #[test] fn the_reader_is_picked_by_the_files_own_shape_and_only_markdown_is_unread() { assert_eq!(reader_for("xtask/src/gates/x.rs"), Reader::Rust); - assert_eq!(reader_for(".github/workflows/promote.yml"), Reader::Yaml); + assert_eq!(reader_for(".github/workflows/plugin-ci.yml"), Reader::Yaml); assert_eq!(reader_for(".github/actions/a/action.yaml"), Reader::Yaml); assert_eq!(reader_for("qa/pipeline-checks.json"), Reader::Json); assert_eq!(reader_for("qa/DESIGN-BINDINGS.md"), Reader::Prose); diff --git a/xtask/src/gates/ship_ready.rs b/xtask/src/gates/ship_ready.rs index b4815f6579..6abb1a9aac 100644 --- a/xtask/src/gates/ship_ready.rs +++ b/xtask/src/gates/ship_ready.rs @@ -60,8 +60,8 @@ pub struct ShipReadyGate; /// `HEAD` and the empty string are folded in here for the same reason: `git rev-parse --abbrev-ref /// HEAD` answers `HEAD` on a detached checkout, which is git saying it cannot name a branch, not /// git naming a branch called HEAD. Reading it as a dev-line name is the same fail-open one step -/// removed. The pipeline sets `XTASK_SHIP_TARGET` explicitly (`promote.yml`, the PR's base branch), which -/// is the supported way to say what a detached checkout is a checkout OF. +/// removed. The pipeline sets `XTASK_SHIP_TARGET` explicitly (`.ci/*.yaml`, the move's target branch), +/// which is the supported way to say what a detached checkout is a checkout OF. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Posture { /// The target, named: `XTASK_SHIP_TARGET` when set and non-empty, else the branch git reports. diff --git a/xtask/src/gates/tests/workflow_rules_tests.rs b/xtask/src/gates/tests/workflow_rules_tests.rs index 7a4c011c5c..279daaa56d 100644 --- a/xtask/src/gates/tests/workflow_rules_tests.rs +++ b/xtask/src/gates/tests/workflow_rules_tests.rs @@ -175,210 +175,6 @@ fn every_rule_is_proven_able_to_go_red() { crate::gates::verify_report(&gate, &report).expect("every rule proven RED"); } -/// A two-job workflow: `slsa-build` (under `BUILDER_IF`) uploads the release artifact, and a shard -/// downloads it from its own run under `STEP_IF`, in the block spelling promote.yml uses. -const R18_FIXTURE: &str = "on: [pull_request, merge_group] -jobs: - slsa-build: - if: BUILDER_IF - steps: - - run: cargo build --release - - { uses: actions/upload-artifact@abc, with: { name: \"slsa-artifact-x\", path: slsa/ } } # v7 - hop-shard: - steps: - - if: STEP_IF - uses: actions/download-artifact@abc # v8 - with: { name: \"slsa-artifact-x\", path: slsa } - - run: ci hop -"; - -fn r18(builder_if: &str, step_if: &str) -> Vec { - let text = R18_FIXTURE - .replace("BUILDER_IF", builder_if) - .replace("STEP_IF", step_if); - release_artifact_findings("promote.yml", &text) - .into_iter() - .map(|f| { - assert_eq!(f.rule, "R18"); - f.message - }) - .collect() -} - -const NEEDS_ARTIFACT: &str = "needs.preflight.outputs.needs_release_artifact == 'true'"; - -#[test] -fn r18_a_merge_group_only_build_under_a_pull_request_download_is_red() { - // #740 exactly: the builder runs only in a merge group, the download whenever the rung reads it. - let got = r18( - "github.event_name == 'merge_group' && needs.preflight.outputs.run == '1'", - NEEDS_ARTIFACT, - ); - assert_eq!(got.len(), 1, "{got:?}"); - assert!( - got[0].contains("`hop-shard`") && got[0].contains("`slsa-build`"), - "{got:?}" - ); - // The same narrowing written as a base-branch test is the same red. - assert_eq!(r18("github.base_ref == 'qa'", NEEDS_ARTIFACT).len(), 1); -} - -#[test] -fn r18_a_builder_that_admits_the_download_condition_is_green() { - let fixed = format!( - "needs.preflight.outputs.run == '1' && (github.event_name == 'merge_group' || {NEEDS_ARTIFACT})" - ); - assert!(r18(&fixed, NEEDS_ARTIFACT).is_empty()); - // Wrapped in `${{ }}` the same. - assert!(r18(&format!("${{{{ {fixed} }}}}"), NEEDS_ARTIFACT).is_empty()); - // A builder narrowed only by the run gate, not by event, runs wherever the run runs. - assert!(r18("needs.preflight.outputs.run == '1'", NEEDS_ARTIFACT).is_empty()); -} - -#[test] -fn r18_a_download_that_waits_on_the_builders_success_is_green() { - // The soak's spelling: it downloads only after slsa-build succeeded, else builds its own. - assert!(r18( - "github.event_name == 'merge_group'", - "needs.slsa-build.result == 'success'" - ) - .is_empty()); -} - -#[test] -fn r18_an_unconditional_download_under_an_event_narrowed_builder_is_red() { - let text = R18_FIXTURE - .replace("BUILDER_IF", "github.event_name == 'merge_group'") - .replace(" - if: STEP_IF\n uses:", " - uses:"); - assert_eq!(release_artifact_findings("promote.yml", &text).len(), 1); -} - -#[test] -fn r18_a_download_with_no_builder_in_the_run_is_red_and_a_run_id_download_is_not_judged() { - let orphan = R18_FIXTURE - .replace("BUILDER_IF", "github.event_name == 'merge_group'") - .replace("actions/upload-artifact@abc", "actions/cache@abc") - .replace("STEP_IF", NEEDS_ARTIFACT); - let got = release_artifact_findings("promote.yml", &orphan); - assert_eq!(got.len(), 1); - assert!(got[0] - .message - .contains("no job of this workflow uploads it")); - // Another run's artifact (by run-id) is not this run's to build. - let other = orphan.replace( - "with: { name: \"slsa-artifact-x\", path: slsa }", - "with: { name: \"slsa-artifact-x\", path: slsa, run-id: \"${{ env.RUN }}\" }", - ); - assert!(release_artifact_findings("promote.yml", &other).is_empty()); -} - -#[test] -fn r18_a_flow_step_condition_is_read_up_to_the_next_key() { - assert_eq!( - step_condition(&[" - { if: always(), uses: actions/upload-artifact@abc }"]).as_deref(), - Some("always()") - ); - assert_eq!( - step_condition(&[" - { uses: a@b, if: \"${{ x == 'y' }}\", with: { n: 1 } }"]) - .as_deref(), - Some("x == 'y'") - ); - assert_eq!(step_condition(&[" - run: true"]), None); -} - -#[test] -fn r18_the_mutation_anchor_rewrites_only_the_named_jobs_if() { - let t = "jobs:\n a:\n if: one\n slsa-build:\n if: two\n needs: a\n"; - assert_eq!( - reif_job(t, "slsa-build", "three"), - "jobs:\n a:\n if: one\n slsa-build:\n if: three\n needs: a\n" - ); - assert_eq!(reif_job(t, "absent", "three"), t); -} - -/// R19: a shard that runs the hop on the candidate with the read-only keys, and the publisher that -/// runs no candidate code with the write keys. -const R19_FIXTURE: &str = "on: [pull_request, merge_group] -jobs: - hop-shard: - steps: - - { uses: actions/checkout@abc, with: { ref: x, path: busbar } } # v7 - - { uses: actions/checkout@abc, with: { repository: GetBusbar/busbar-release, ref: x, path: busbar-release } } # v7 - - run: ci hop - env: { R2_RO_KEY_ID: \"${{ secrets.R2_CACHE_RO_ACCESS_KEY_ID }}\", R2_RO_KEY_SECRET: \"${{ secrets.R2_CACHE_RO_SECRET_ACCESS_KEY }}\" } - cache-publish: - needs: [hop-shard] - steps: - - run: echo the engine - - run: ci cache-publish - env: { R2_KEY_ID: \"${{ secrets.R2_CACHE_ACCESS_KEY_ID }}\", R2_KEY_SECRET: \"${{ secrets.R2_CACHE_SECRET_ACCESS_KEY }}\" } -"; - -fn r19(text: &str) -> Vec { - r2_write_key_findings("promote.yml", text) - .into_iter() - .map(|f| { - assert_eq!(f.rule, "R19"); - f.message - }) - .collect() -} - -#[test] -fn r19_read_only_keys_on_the_candidate_and_write_keys_on_the_publisher_are_green() { - let got = r19(R19_FIXTURE); - assert!(got.is_empty(), "{got:?}"); -} - -#[test] -fn r19_a_write_key_on_a_job_that_runs_candidate_code_is_red() { - // The shard as it stood before busbar-release 069f032. - let shard = R19_FIXTURE.replacen( - "R2_RO_KEY_ID: \"${{ secrets.R2_CACHE_RO_ACCESS_KEY_ID }}\"", - "R2_KEY_ID: \"${{ secrets.R2_CACHE_ACCESS_KEY_ID }}\"", - 1, - ); - let got = r19(&shard); - assert_eq!(got.len(), 1, "{got:?}"); - assert!( - got[0].contains("`hop-shard`") && got[0].contains("secrets.R2_CACHE_ACCESS_KEY_ID"), - "{got:?}" - ); - // A job that only builds (cargo, no checkout step of its own) runs candidate code too. - let build = "jobs:\n b:\n steps:\n - run: cargo build\n env: { K: \"${{ secrets.R2_CACHE_SECRET_ACCESS_KEY }}\" }\n"; - assert_eq!(r19(build).len(), 1); -} - -#[test] -fn r19_a_workflow_level_write_key_reaches_every_candidate_job() { - let text = String::from("env:\n K: \"${{ secrets.R2_CACHE_ACCESS_KEY_ID }}\"\n") + R19_FIXTURE; - let got = r19(&text); - assert_eq!(got.len(), 1, "{got:?}"); - assert!(got[0].contains("`hop-shard`"), "{got:?}"); -} - -#[test] -fn r19_reads_a_secret_whole_and_another_repositorys_checkout_as_no_candidate() { - assert!(r2_write_secrets_in("${{ secrets.R2_CACHE_ACCESS_KEY_ID2 }}").is_empty()); - assert!(r2_write_secrets_in("${{ secrets.R2_CACHE_RO_ACCESS_KEY_ID }}").is_empty()); - assert!(r2_write_secrets_in("${{ secrets.R2_CACHE_RO_SECRET_ACCESS_KEY }}").is_empty()); - assert_eq!( - r2_write_secrets_in("${{ secrets.R2_CACHE_SECRET_ACCESS_KEY }}"), - vec!["secrets.R2_CACHE_SECRET_ACCESS_KEY"] - ); - let other = "jobs:\n p:\n steps:\n - { uses: actions/checkout@abc, with: { repository: GetBusbar/busbar-release, ref: x } } # v7\n - run: ci cache-publish\n env: { K: \"${{ secrets.R2_CACHE_ACCESS_KEY_ID }}\" }\n"; - assert!(r19(other).is_empty()); -} - -const ENGINE_VAR_JOB: &str = "env:\n SHA: x\njobs:\n pre:\n env: { RELEASE_REF: \"${{ vars.ENGINE_REF }}\", ENGINE_SHA256: \"${{ vars.ENGINE_SHA256 }}\" }\n steps:\n - name: Engine\n run: |\n [[ \"$RELEASE_REF\" =~ ^[0-9a-f]{40}$ ]] || { echo bad; exit 1; }\n name=\"busbar-release-ci-linux-x86_64-$RELEASE_REF\"\n echo \"$ENGINE_SHA256 bin\" | sha256sum -c -\n - { uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1, with: { repository: GetBusbar/busbar-release, ref: \"${{ env.RELEASE_REF }}\", path: busbar-release } } # v7\n"; - -fn r16(text: &str) -> Vec { - engine_pin_findings("w.yml", text) - .into_iter() - .map(|f| f.message) - .collect() -} - fn r17(text: &str) -> Vec { vendor_runs_on(text) .into_iter() @@ -386,78 +182,6 @@ fn r17(text: &str) -> Vec { .collect() } -#[test] -fn r16_accepts_the_variable_pin_with_its_shape_check_and_its_sha256() { - assert_eq!(r16(ENGINE_VAR_JOB), Vec::::new()); -} - -#[test] -fn r16_accepts_an_in_file_sha_pin() { - let t = "jobs:\n a:\n steps:\n - { uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1, with: { repository: GetBusbar/busbar-release, ref: 7ab21912d664d11c7df6cc98fe78eed6b0119177 } } # v7\n"; - assert_eq!(r16(t), Vec::::new()); - // and through a workflow-level env var holding the literal - let t = "env:\n RELEASE_REF: 7ab21912d664d11c7df6cc98fe78eed6b0119177\njobs:\n a:\n steps:\n - { uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1, with: { repository: GetBusbar/busbar-release, ref: \"${{ env.RELEASE_REF }}\" } } # v7\n"; - assert_eq!(r16(t), Vec::::new()); -} - -#[test] -fn r16_refuses_a_branch_or_a_tag_ref() { - // RED: the checkout at a branch. - let t = ENGINE_VAR_JOB.replace("ref: \"${{ env.RELEASE_REF }}\"", "ref: dev"); - assert!(r16(&t).iter().any(|m| m.contains("`dev`")), "{:?}", r16(&t)); - // RED: the variable is defined as a tag. - let t = ENGINE_VAR_JOB.replace( - "RELEASE_REF: \"${{ vars.ENGINE_REF }}\"", - "RELEASE_REF: v1.6.0", - ); - assert!( - r16(&t).iter().any(|m| m.contains("v1.6.0")), - "{:?}", - r16(&t) - ); - // RED: in-file, but a tag at the workflow level. - let t = "env:\n RELEASE_REF: refs/tags/v1\njobs:\n a:\n steps:\n - { uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1, with: { repository: GetBusbar/busbar-release, ref: \"${{ env.RELEASE_REF }}\" } } # v7\n"; - assert!(!r16(t).is_empty()); - // RED: no ref at all (the default branch). - let t = "jobs:\n a:\n steps:\n - { uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1, with: { repository: GetBusbar/busbar-release } } # v7\n"; - assert!(r16(t).iter().any(|m| m.contains("no `ref:`"))); -} - -#[test] -fn r16_refuses_a_variable_pin_without_its_shape_check_or_its_sha256() { - let t = ENGINE_VAR_JOB.replace("=~ ^[0-9a-f]{40}$", "!= \"\""); - assert!( - r16(&t).iter().any(|m| m.contains("default branch")), - "{:?}", - r16(&t) - ); - let t = ENGINE_VAR_JOB.replace("sha256sum -c -", "cat"); - let got = r16(&t); - assert_eq!( - got.len(), - 2, - "the download and the pin both name the missing check: {got:?}" - ); - // The shape check AFTER the checkout guards nothing. - let t = "jobs:\n pre:\n env: { RELEASE_REF: \"${{ vars.ENGINE_REF }}\" }\n steps:\n - { uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1, with: { repository: GetBusbar/busbar-release, ref: \"${{ env.RELEASE_REF }}\" } } # v7\n - run: |\n [[ \"$RELEASE_REF\" =~ ^[0-9a-f]{40}$ ]] || exit 1\n echo \"$ENGINE_SHA256 b\" | sha256sum -c -\n"; - assert!(r16(t).iter().any(|m| m.contains("default branch"))); -} - -#[test] -fn a_key_value_is_read_whole_from_a_flow_mapping() { - assert_eq!( - key_value("{ repository: o/r, ref: \"${{ env.X }}\", path: p }", "ref").as_deref(), - Some("${{ env.X }}") - ); - assert_eq!( - key_value("{ ref: dev, path: p }", "ref").as_deref(), - Some("dev") - ); - assert_eq!(key_value("{ prefref: dev }", "ref"), None); - assert_eq!(env_ref("${{ env.RELEASE_REF }}"), Some("RELEASE_REF")); - assert_eq!(env_ref("${{ vars.ENGINE_REF }}"), None); -} - #[test] fn a_vendor_runs_on_label_is_refused_in_every_spelling() { assert_eq!( diff --git a/xtask/src/gates/workflow_rules.rs b/xtask/src/gates/workflow_rules.rs index 261a98fd9e..30b80982fd 100644 --- a/xtask/src/gates/workflow_rules.rs +++ b/xtask/src/gates/workflow_rules.rs @@ -2,14 +2,16 @@ //! //! This gate was `release-order`: "nothing may be tagged until it has been verified from the //! consumer side", enforced over the SHAPE OF busbar's own release workflows (`release.yml`, -//! `release-stage.yml`, `docker.yml`, `verify-deploy.yml`). Those workflows are deleted: busbar has -//! one pipeline workflow, `promote.yml`, and the release engine lives in busbar-release. The rules -//! that were ABOUT those files (the staged-verification graph, the draft flag, the red-branch gate, -//! the first gate's run list) went with them, rows and code together. +//! `release-stage.yml`, `docker.yml`, `verify-deploy.yml`). Those workflows are deleted, and so is +//! `promote.yml`, the busbar-release hop (OWNER 2026-10-12, "ModernCI only"): busbar's moves are +//! judged by ModernCI from `.ci/*.yaml`, which this gate does not read. The rules that were ABOUT +//! those files (the staged-verification graph, the draft flag, the red-branch gate, the first gate's +//! run list, and promote.yml's engine pin, release-artifact wiring and R2 keys, R16/R18/R19) went +//! with them, rows and code together. //! //! Five rules were never about one file. They are bans over EVERY workflow and composite action -//! that remains (`promote.yml` and the reusable `plugin-*.yml` workflows the plugin repos call), -//! and each has a specific observed failure behind it: +//! that remains (the reusable `plugin-*.yml` workflows the plugin repos call), and each has a +//! specific observed failure behind it: //! //! * **R1 — no workflow is triggered by a version tag.** A tag is the OUTPUT of a verified release, //! never its trigger. @@ -18,22 +20,8 @@ //! * **R14 — every third-party action runs from a commit sha** with its tag as a trailing comment, //! because an action that can be force-moved under a name we already trust runs with our tokens. //! * **R15 — every attestation verify names the workflow that signed**, not just the repository. -//! * **R16 — the engine runs from a commit, never a branch or a tag.** Every checkout of -//! busbar-release names a full 40-hex sha, either in the file or through an env var the job -//! shape-checks as 40 hex before the checkout (the org variable `vars.ENGINE_REF`, OWNER -//! 2026-10-10: the engine pin lives outside this repo), and every job that downloads the engine -//! binary runs it only after `sha256sum -c` against `ENGINE_SHA256`. //! * **R17 — no job hard-codes a vendor runner label.** Every job asks the runner gateway for a //! logical size and `vars.RUNNERS` names the label, so a workflow never spells a vendor. -//! * **R18 — a step that downloads the release artifact from its own run runs only where the job -//! that builds it runs.** `slsa-build` was merge-group-only while the predev->dev pull request's -//! shards (its rung runs the oracle and conformance) downloaded `slsa-artifact-` from their -//! own run: the artifact could never exist there, and #740 failed every run at that download. -//! * **R19 — no job that runs candidate code holds the R2 cache write keys.** The hop judges the -//! candidate's build scripts, tests and xtask in-process, and every process of a job can read -//! another's environment: the shards held `secrets.R2_CACHE_ACCESS_KEY_ID` / -//! `secrets.R2_CACHE_SECRET_ACCESS_KEY` until busbar-release 069f032, which refuses to start a -//! hop that holds them. They belong to `cache-publish` alone, a job that runs no candidate code. //! //! **The parser stays deliberately small, and here that is a correctness argument rather than a //! dependency one.** The rules are assertions about what a human WROTE (a trailing `# tag` comment @@ -61,28 +49,12 @@ const WORKFLOWS: &str = ".github/workflows"; const ACTIONS_DIR: &str = ".github/actions"; /// The floor under the workflow discovery. A scan that finds nothing satisfies every ban, and a -/// tree with fewer workflows than this has lost its subject, not been cleaned: `promote.yml` plus -/// the four reusable `plugin-*.yml` workflows. -const MIN_WORKFLOWS: usize = 5; +/// tree with fewer workflows than this has lost its subject, not been cleaned: the four reusable +/// `plugin-*.yml` workflows. +const MIN_WORKFLOWS: usize = 4; /// The rule ids, which are also the ledger row ids. -const RULES: &[&str] = &["R1", "R11", "R14", "R15", "R16", "R17", "R18", "R19"]; - -/// The release artifact's name prefix: `promote.yml`'s `slsa-build` uploads `slsa-artifact-`, -/// and the steps that read the release binary download it by that name (R18). -const RELEASE_ARTIFACT: &str = "slsa-artifact-"; -/// R16's subject: the release engine's repository, and the name prefix of its CI binary on the -/// `dev-bin` release. -const ENGINE_REPO: &str = "GetBusbar/busbar-release"; -const ENGINE_ASSET: &str = "busbar-release-ci-"; -/// The shape test R16 requires of a variable engine pin, and the bytes check of the binary. -const SHA40_TEST: &str = "=~ ^[0-9a-f]{40}$"; -const SHA256_CHECK: &str = "sha256sum -c"; -const SHA256_VAR: &str = "ENGINE_SHA256"; -/// The pinned judge's binary on the `dev-bin` release, and the digest its job checks it against -/// (`sha256_linux_x86_64=` of testing/shadow-oracle/oracle-rust.pin). -const ORACLE_ASSET: &str = "busbar-oracle-linux-x86_64-"; -const ORACLE_SHA256_VAR: &str = "ORACLE_SHA256"; +const RULES: &[&str] = &["R1", "R11", "R14", "R15", "R17"]; // ------------------------------------------------------------------------------------------- // THE SMALL PARSER @@ -379,257 +351,6 @@ fn pushes_bare_release_branch(cmd: &str) -> bool { false } -/// A line that opens a YAML key at exactly `indent` spaces: not a comment, not a sequence item, -/// not deeper. -fn opens_key_at(line: &str, indent: usize) -> bool { - let b = line.as_bytes(); - b.len() > indent - && b[..indent].iter().all(|c| *c == b' ') - && !matches!(b[indent], b' ' | b'\t' | b'#' | b'-') -} - -/// An `if:` condition as written, without its `${{ }}` wrapper or the double quotes around the -/// whole of it (a single quote that ends the condition, `== 'true'`, is the condition's own). -fn bare_condition(raw: &str) -> String { - let c = raw.trim(); - let c = c - .strip_prefix('"') - .and_then(|r| r.strip_suffix('"')) - .unwrap_or(c) - .trim(); - let c = c.strip_prefix("${{").unwrap_or(c); - let c = c.strip_suffix("}}").unwrap_or(c); - c.trim().to_string() -} - -/// A job's own `if:` (the line opening `if:` at four spaces), bare. `None`: the job is -/// unconditional. -fn job_condition(lines: &[&str]) -> Option { - lines - .iter() - .find(|l| opens_key_at(l, 4) && l.trim_start().starts_with("if:")) - .map(|l| bare_condition(&l.trim_start()["if:".len()..])) -} - -/// A job's steps: each step's lines, its item line first. The items sit at the indent of the first -/// `- ` line under `steps:`; deeper lines (a `run: |` body, a block `with:`) belong to the step. -fn steps_of<'a>(lines: &[&'a str]) -> Vec> { - let mut out: Vec> = Vec::new(); - let Some(start) = lines - .iter() - .position(|l| opens_key_at(l, 4) && l.trim_end() == " steps:") - else { - return out; - }; - let mut indent: Option = None; - for line in &lines[start + 1..] { - if opens_key_at(line, 4) { - break; - } - let lead = line.len() - line.trim_start().len(); - let item = line.trim_start().starts_with("- "); - if item && indent.is_none() { - indent = Some(lead); - } - if item && Some(lead) == indent { - out.push(vec![*line]); - } else if let Some(step) = out.last_mut() { - step.push(*line); - } - } - out -} - -/// A step's own `if:`, bare, in either spelling: `- if: X` or an `if:` line of the step (block), -/// or `- { if: X, uses: ... }` (flow). `None`: the step is unconditional. -fn step_condition(step: &[&str]) -> Option { - let first = step.first()?.trim_start().strip_prefix("- ")?.trim_start(); - if let Some(flow) = first.strip_prefix('{') { - let flow = flow.trim_start(); - let rest = match flow.strip_prefix("if:") { - Some(r) => r, - None => &flow[flow.find(", if:")? + ", if:".len()..], - }; - let end = [ - ", uses:", - ", run:", - ", with:", - ", id:", - ", name:", - ", env:", - ", working-directory:", - ", continue-on-error:", - ] - .iter() - .filter_map(|k| rest.find(k)) - .min() - .unwrap_or(rest.len()); - return Some(bare_condition(&rest[..end])); - } - if let Some(rest) = first.strip_prefix("if:") { - return Some(bare_condition(rest)); - } - step[1..] - .iter() - .map(|l| l.trim_start()) - .find_map(|l| l.strip_prefix("if:")) - .map(bare_condition) -} - -/// R18 over one workflow's comment-stripped text. Every step that downloads the release artifact -/// from its OWN run (no `run-id:`) must run only where a job of this run uploads it. The step is -/// safe when it waits on the builder's success (`needs..result == 'success'`), or when -/// the builder's `if:` does not narrow it by event; a builder narrowed by event -/// (`github.event_name`, `github.base_ref`) must also admit the step's own condition, written into -/// its `if:` as one of the alternatives. No builder at all is the same red: the artifact can never -/// exist in that run. -fn release_artifact_findings(name: &str, text: &str) -> Vec { - fn carries(step: &[&str], verb: &str) -> bool { - let body = step.join("\n"); - body.contains(verb) && body.contains(RELEASE_ARTIFACT) - } - let jobs = jobs_of(text); - let builders: Vec<(&str, Option)> = jobs - .iter() - .filter(|(_, lines)| { - steps_of(lines) - .iter() - .any(|s| carries(s, "upload-artifact")) - }) - .map(|(id, lines)| (id.as_str(), job_condition(lines))) - .collect(); - let mut bad = Vec::new(); - for (job, lines) in &jobs { - for step in steps_of(lines) { - if !carries(&step, "download-artifact") || step.join("\n").contains("run-id:") { - continue; - } - let gate = step_condition(&step).unwrap_or_default(); - let when = if gate.is_empty() { - "always" - } else { - gate.as_str() - }; - if builders.is_empty() { - bad.push(Finding::new( - "R18", - format!( - "{name}: job `{job}` downloads the release artifact \ - (`{RELEASE_ARTIFACT}*`) from its own run, and no job of this workflow \ - uploads it, so the download can never find it. Build it in this run, or \ - download it by `run-id:` from the run that built it." - ), - )); - continue; - } - for (builder, cond) in &builders { - if gate.contains(&format!("needs.{builder}.result == 'success'")) { - continue; - } - let Some(cond) = cond else { continue }; - let by_event = - cond.contains("github.event_name") || cond.contains("github.base_ref"); - if by_event && (gate.is_empty() || !cond.contains(gate.as_str())) { - bad.push(Finding::new( - "R18", - format!( - "{name}: job `{job}` downloads the release artifact \ - (`{RELEASE_ARTIFACT}*`) from its own run when `{when}`, but \ - `{builder}`, the job that uploads it, runs only when `{cond}`. In \ - every run where the first holds and the second does not, the artifact \ - does not exist and the download fails (#740: a merge-group-only \ - slsa-build under the predev->dev pull request's shards). Admit \ - `{when}` in `{builder}`'s `if:`." - ), - )); - } - } - } - } - bad -} - -/// R19's subject: the R2 cache's WRITE keys, as this repository's secrets name them. The read-only -/// pair (`secrets.R2_CACHE_RO_*`) is another name, not a longer spelling of these. -const R2_WRITE_SECRETS: [&str; 2] = [ - "secrets.R2_CACHE_ACCESS_KEY_ID", - "secrets.R2_CACHE_SECRET_ACCESS_KEY", -]; - -/// The R2 write-key secrets `text` names, each as a whole name (`secrets.R2_CACHE_ACCESS_KEY_ID2` -/// is not `secrets.R2_CACHE_ACCESS_KEY_ID`). -fn r2_write_secrets_in(text: &str) -> Vec<&'static str> { - R2_WRITE_SECRETS - .into_iter() - .filter(|s| { - text.match_indices(*s).any(|(at, m)| { - !text[at + m.len()..] - .chars() - .next() - .is_some_and(|c| c.is_ascii_alphanumeric() || c == '_') - }) - }) - .collect() -} - -/// How job `lines` runs candidate code, when it does: a step checks out this repository (an -/// `actions/checkout` with no `repository:` is the candidate), runs cargo, or runs the hop (`ci -/// hop` judges the candidate in-process). A job that only downloads the engine, checks out another -/// repository or downloads artifacts runs none. -fn candidate_code(lines: &[&str]) -> Option<&'static str> { - for step in steps_of(lines) { - let body = step.join("\n"); - if body.contains("actions/checkout@") && key_value(&body, "repository").is_none() { - return Some("checks out the candidate"); - } - if body.contains("cargo ") { - return Some("runs cargo"); - } - if body.contains("ci hop") { - return Some("runs `ci hop` on the candidate"); - } - } - None -} - -/// R19 over one workflow's comment-stripped text. A job that runs candidate code names no R2 cache -/// write key: not in its own lines (job env, step env, `with:`), and not through the workflow's -/// top-level `env:`, which every job inherits. -fn r2_write_key_findings(name: &str, text: &str) -> Vec { - let top = r2_write_secrets_in(&top_level_block(text, "env")); - let mut bad = Vec::new(); - for (job, lines) in jobs_of(text) { - let Some(how) = candidate_code(&lines) else { - continue; - }; - let mut held = r2_write_secrets_in(&lines.join("\n")); - for s in &top { - if !held.contains(s) { - held.push(*s); - } - } - if held.is_empty() { - continue; - } - let named = held - .iter() - .map(|s| format!("`{s}`")) - .collect::>() - .join(", "); - bad.push(Finding::new( - "R19", - format!( - "{name}: job `{job}` {how} and names the R2 cache write key(s) {named}: every \ - candidate process the job starts can read them from its environment, and `ci hop` \ - refuses to start with them (INFRA). Give it the read-only `secrets.R2_CACHE_RO_*` \ - keys; the write keys belong to `cache-publish` alone, a job that runs no candidate \ - code." - ), - )); - } - bad -} - /// True when there is an actual composite-action manifest to scan, either on disk or planted by a /// selftest overlay. Mirrors `Ctx::list`'s own `overlay_adds_it` test: `.github/actions` need not /// exist in the real tree today (it does not) for a plant UNDER it to be a legitimate root, exactly @@ -681,215 +402,6 @@ fn action_manifests(cx: &Ctx) -> Result, String> { Ok(out) } -/// The jobs of a workflow (comments already stripped): each job's name and its lines, from its -/// header (a key at exactly two spaces under the top-level `jobs:`) to the next job or top-level key. -fn jobs_of(text: &str) -> Vec<(String, Vec<&str>)> { - let mut out: Vec<(String, Vec<&str>)> = Vec::new(); - let mut in_jobs = false; - for line in text.lines() { - if !line.starts_with(' ') && !line.trim().is_empty() { - in_jobs = line.trim_end() == "jobs:"; - continue; - } - if !in_jobs { - continue; - } - let b = line.as_bytes(); - if b.len() > 2 && b[0] == b' ' && b[1] == b' ' && b[2] != b' ' { - if let Some(name) = line.trim().strip_suffix(':') { - out.push((name.to_string(), Vec::new())); - continue; - } - } - if let Some((_, lines)) = out.last_mut() { - lines.push(line); - } - } - out -} - -/// The value of `key:` in `text` (a flow mapping or a block line), the first time `key` appears as -/// a key (not as the tail of a longer one): a quoted string up to its closing quote, an `${{ }}` -/// expression whole, or a bare scalar up to whitespace, `,` or `}`. -fn key_value(text: &str, key: &str) -> Option { - let needle = format!("{key}:"); - let mut from = 0usize; - while let Some(rel) = text[from..].find(&needle) { - let at = from + rel; - from = at + needle.len(); - let before = text[..at].chars().next_back(); - if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.') { - continue; - } - let rest = text[from..].trim_start_matches([' ', '\t']); - let v = if let Some(q) = rest.strip_prefix('"') { - q.split('"').next().unwrap_or("") - } else if let Some(q) = rest.strip_prefix('\'') { - q.split('\'').next().unwrap_or("") - } else if rest.starts_with("${{") { - rest.find("}}").map(|i| &rest[..i + 2]).unwrap_or(rest) - } else { - let end = rest - .find(|c: char| c.is_whitespace() || c == ',' || c == '}') - .unwrap_or(rest.len()); - &rest[..end] - }; - return Some(v.trim().to_string()); - } - None -} - -/// `${{ env.NAME }}` -> `NAME`. -fn env_ref(v: &str) -> Option<&str> { - let inner = v.strip_prefix("${{")?.strip_suffix("}}")?.trim(); - let name = inner.strip_prefix("env.")?; - (!name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')) - .then_some(name) -} - -/// The step span holding line `at` of `lines`: from the nearest `- ` item at or above it to the -/// next item at that indent (exclusive). -fn step_span(lines: &[&str], at: usize) -> String { - let indent = |l: &str| l.len() - l.trim_start().len(); - let start = (0..=at) - .rev() - .find(|&i| lines[i].trim_start().starts_with("- ")) - .unwrap_or(at); - let ind = indent(lines[start]); - let end = (start + 1..lines.len()) - .find(|&i| { - let l = lines[i]; - !l.trim().is_empty() && indent(l) <= ind - }) - .unwrap_or(lines.len()); - lines[start..end].join("\n") -} - -/// R16 over one workflow's text (comments stripped). -/// -/// THE ENGINE IS PINNED BY A COMMIT, AND ITS BYTES BY A DIGEST. promote.yml runs busbar-release's -/// engine with the jobs' tokens; a checkout at a branch or a tag runs whatever that name points at -/// today. The pin may be written in the file (a 40-hex sha) or held outside it (OWNER 2026-10-10: -/// the org variable `vars.ENGINE_REF`, so an engine update never waits in predev's merge queue). The -/// variable form is accepted only when the job proves at run time what the file can no longer show: -/// a step BEFORE the checkout tests the variable against 40 hex and exits on anything else (an -/// unset variable must FAIL, never fall back to the default branch an empty `ref:` checks out), and -/// the downloaded binary runs only after `sha256sum -c` against `ENGINE_SHA256`. -fn engine_pin_findings(name: &str, text: &str) -> Vec { - let mut bad = Vec::new(); - let top_env = top_level_block(text, "env"); - for (job, lines) in jobs_of(text) { - let job_text = lines.join("\n"); - let downloads = job_text.contains(ENGINE_ASSET); - let verifies = lines - .iter() - .any(|l| l.contains(SHA256_CHECK) && l.contains(SHA256_VAR)); - // THE JUDGE'S BYTES TOO: a job that downloads the pinned oracle runs it only after - // `sha256sum -c` against the pin's digest. - if job_text.contains(ORACLE_ASSET) - && !lines - .iter() - .any(|l| l.contains(SHA256_CHECK) && l.contains(ORACLE_SHA256_VAR)) - { - bad.push(Finding::new( - "R16", - format!( - "{name}: job `{job}` downloads the pinned oracle ({ORACLE_ASSET}*) without \ - `{SHA256_CHECK}` against ${ORACLE_SHA256_VAR} (oracle-rust.pin's \ - sha256_linux_x86_64): the judge would be whatever the release holds." - ), - )); - } - if downloads && !verifies { - bad.push(Finding::new( - "R16", - format!( - "{name}: job `{job}` downloads the engine binary ({ENGINE_ASSET}*) without \ - `{SHA256_CHECK}` against ${SHA256_VAR}: the bytes it runs with this job's \ - tokens are whatever the release holds under that name today." - ), - )); - } - for (i, line) in lines.iter().enumerate() { - if !line.contains(&format!("repository: {ENGINE_REPO}")) { - continue; - } - let span = step_span(&lines, i); - let Some(r) = key_value(&span, "ref").filter(|r| !r.is_empty()) else { - bad.push(Finding::new( - "R16", - format!( - "{name}: job `{job}` checks out {ENGINE_REPO} with no `ref:`, so it runs \ - the engine's default branch, not a pinned commit." - ), - )); - continue; - }; - if is_sha40(&r) { - continue; - } - let Some(var) = env_ref(&r) else { - bad.push(Finding::new( - "R16", - format!( - "{name}: job `{job}` checks out {ENGINE_REPO} at `{r}`, a branch or tag \ - the engine's owner can move, not a commit. Pin a 40-hex sha, or \ - `${{{{ env.RELEASE_REF }}}}` from vars.ENGINE_REF with its shape checked." - ), - )); - continue; - }; - // The variable's definition: the job's own env first, then the workflow's. - let def = key_value(&job_text, var).or_else(|| key_value(&top_env, var)); - match def.as_deref() { - Some(d) if is_sha40(d) => continue, - Some(d) if d.starts_with("${{") => {} - other => { - bad.push(Finding::new( - "R16", - format!( - "{name}: job `{job}` checks out {ENGINE_REPO} at ${var} = `{}`, which \ - is not a commit: a branch or tag the engine's owner can move. Pin a \ - 40-hex sha, or set {var} from vars.ENGINE_REF.", - other.unwrap_or("(undefined)") - ), - )); - continue; - } - } - let guarded = lines[..i].iter().any(|l| { - l.contains(SHA40_TEST) - && (l.contains(&format!("${var}\"")) - || l.contains(&format!("${{{var}}}")) - || l.contains(&format!("${var} "))) - && l.contains("exit") - }); - if !guarded { - bad.push(Finding::new( - "R16", - format!( - "{name}: job `{job}` checks out {ENGINE_REPO} at the variable ${var} with \ - no step before it that tests ${var} `{SHA40_TEST}` and exits: an unset or \ - malformed variable then checks out the engine's default branch instead \ - of failing." - ), - )); - } - if !verifies { - bad.push(Finding::new( - "R16", - format!( - "{name}: job `{job}` pins the engine through ${var} but never runs \ - `{SHA256_CHECK}` against ${SHA256_VAR}: a variable pin is only a pin \ - when the bytes are checked at run time." - ), - )); - } - } - } - bad -} - /// One finding, carrying the rule id that owns it so the row set is derived from the findings /// rather than restated beside them. #[derive(Debug, Clone)] @@ -1267,12 +779,6 @@ pub fn check(cx: &Ctx) -> Result, String> { } } - // R16. THE ENGINE RUNS FROM A COMMIT, AND ITS BYTES FROM A DIGEST ([`engine_pin_findings`]). - for name in &names { - let text = strip_comments(&read(name).unwrap_or_default()); - bad.extend(engine_pin_findings(name, &text)); - } - // R17. NO JOB HARD-CODES A VENDOR RUNNER LABEL. // // Every job asks the runner gateway for a logical size (small, medium, large, xlarge) and the @@ -1289,38 +795,12 @@ pub fn check(cx: &Ctx) -> Result, String> { format!( "{name}:{line}: job '{job}' runs on the vendor label '{label}'. A workflow \ never names a runner vendor: use `fromJSON(vars.RUNNERS)..label` \ - (size is small, medium, large or xlarge) or the preflight output \ - `needs.preflight.outputs.runner`." + (size is small, medium, large or xlarge)." ), )); } } - // R18. A STEP THAT DOWNLOADS THE RELEASE ARTIFACT FROM ITS OWN RUN RUNS ONLY WHERE IT IS BUILT. - // - // The shards of a rung that runs the oracle or conformance (`needs_release_artifact=true`: the - // predev->dev pull request and up) download `slsa-artifact-` from their own run, after - // `ci turnstile await-job` reads `slsa-build` green, and a SKIPPED job reads green there. With - // slsa-build gated to merge groups, every predev->dev run waited on nothing, failed that - // download on all four shards, and the fold called it INFRA: #740, every run. The rule holds - // the builder's `if:` to admit every condition a same-run download of its artifact runs under. - for name in &names { - let text = strip_comments(&read(name).unwrap_or_default()); - bad.extend(release_artifact_findings(name, &text)); - } - - // R19. NO JOB THAT RUNS CANDIDATE CODE HOLDS THE R2 CACHE WRITE KEYS ([`r2_write_key_findings`]). - // - // The shards and the one-runner hop passed `R2_KEY_ID` / `R2_KEY_SECRET` (the write keys) to - // `ci hop`, whose judge runs the candidate's build scripts, tests and xtask: any of them could - // read the keys and write the bucket every later hop reads its base reports and step PASSes - // from. busbar-release 069f032 refuses (INFRA) a hop that holds them; this rule keeps the keys - // from coming back to any job that runs candidate code, where the hop's refusal is not there. - for name in &names { - let text = strip_comments(&read(name).unwrap_or_default()); - bad.extend(r2_write_key_findings(name, &text)); - } - Ok(bad) } @@ -1335,10 +815,7 @@ fn rule_title(rule: &str) -> &'static str { "R11" => "no workflow pushes a commit to a release branch", "R14" => "every third-party action runs from a commit sha", "R15" => "every attestation verify names the workflow that signed", - "R16" => "the engine runs from a commit sha, its bytes from a sha256", "R17" => "no job hard-codes a vendor runner label", - "R18" => "a same-run download of the release artifact runs only where it is built", - "R19" => "no job that runs candidate code holds the R2 cache write keys", _ => "workflow rule", } } @@ -1526,7 +1003,7 @@ fn mutations() -> Vec { file: "zz-planted-verify.yml", rule: "R15", apply: |_| { - "jobs:\n v:\n steps:\n - run: |\n gh attestation verify ./x --repo GetBusbar/busbar --signer-workflow GetBusbar/busbar/.github/workflows/promote.yml\n" + "jobs:\n v:\n steps:\n - run: |\n gh attestation verify ./x --repo GetBusbar/busbar --signer-workflow GetBusbar/busbar/.github/workflows/plugin-ci.yml\n" .to_string() }, creates: true, @@ -1579,24 +1056,31 @@ fn mutations() -> Vec { creates: false, }, Mutation { - // THE SAME TWO REGRESSIONS IN THE FLOW SPELLING the pipeline workflow itself is - // written in: a step as one `{ uses: ..., with: ... }` mapping. + // THE SAME TWO REGRESSIONS IN THE FLOW SPELLING: a step written as one + // `{ uses: ..., with: ... }` mapping. No remaining workflow is written that way (the + // flow-spelled promote.yml is deleted), so the regression is a workflow that is. label: "R14 a flow-style action reverts from a sha to a force-movable tag", - file: "promote.yml", + file: "zz-planted-flow-pin.yml", rule: "R14", - apply: |t| retag_first_pin(t, false), - creates: false, + apply: |_| { + "jobs:\n a:\n steps:\n - { uses: actions/checkout@v7, with: { fetch-depth: 0 } }\n" + .to_string() + }, + creates: true, }, Mutation { label: "R14 a flow-style pin loses the trailing tag comment Dependabot reads", - file: "promote.yml", + file: "zz-planted-flow-pin.yml", rule: "R14", - apply: |t| retag_first_pin(t, true), - creates: false, + apply: |_| { + "jobs:\n a:\n steps:\n - { uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1, with: { fetch-depth: 0 } }\n" + .to_string() + }, + creates: true, }, Mutation { label: "R1 a v* tag trigger comes back", - file: "promote.yml", + file: "plugin-ci.yml", rule: "R1", apply: |t| replant_on(t, "on:\n push:\n tags:\n - \"v*\"\n"), creates: false, @@ -1605,75 +1089,17 @@ fn mutations() -> Vec { // THE SAME RULE, IN THE OTHER YAML SPELLING. The block form above was caught; the flow // form is ordinary YAML the runner treats identically. label: "R1 a v* tag trigger comes back as a FLOW sequence", - file: "promote.yml", + file: "plugin-ci.yml", rule: "R1", apply: |t| replant_on(t, "on:\n push:\n tags: [\"v*\"]\n"), creates: false, }, - Mutation { - // THE REGRESSION AS IT WOULD ACTUALLY ARRIVE: "just follow dev" -- the engine checked - // out at its branch, so every hop runs whatever dev holds at that minute. - label: "R16 the engine is checked out at a branch", - file: "promote.yml", - rule: "R16", - apply: |t| { - replace_once( - t, - "repository: GetBusbar/busbar-release, ref: \"${{ env.RELEASE_REF }}\"", - "repository: GetBusbar/busbar-release, ref: dev", - ) - }, - creates: false, - }, - Mutation { - // The same through the variable: the pin becomes a release tag. - label: "R16 the engine pin becomes a movable tag", - file: "promote.yml", - rule: "R16", - apply: |t| { - replace_once( - t, - "RELEASE_REF: \"${{ vars.ENGINE_REF }}\"", - "RELEASE_REF: v1.6.0", - ) - }, - creates: false, - }, - Mutation { - // The variable form loses its shape check: an unset variable is an empty `ref:`, and - // an empty `ref:` is the default branch, a silent fallback instead of a FAIL. - label: "R16 the variable pin's 40-hex shape check is dropped", - file: "promote.yml", - rule: "R16", - apply: |t| drop_first_line_with(t, SHA40_TEST), - creates: false, - }, - Mutation { - label: "R16 the engine binary runs without its sha256 check", - file: "promote.yml", - rule: "R16", - apply: |t| drop_first_line_with(t, SHA256_CHECK), - creates: false, - }, - Mutation { - label: "R16 the pinned oracle runs without its sha256 check", - file: "promote.yml", - rule: "R16", - apply: |t| { - replace_once( - t, - "echo \"$ORACLE_SHA256 $RUNNER_TEMP/busbar-oracle\" | sha256sum -c -\n", - "", - ) - }, - creates: false, - }, Mutation { // The refspec form a promote script documents, which the `HEAD:` test and the // no-refspec test both walked past. THE ANCHOR IS `steps:`, NOT A `uses:` LINE: a // structural feature of every job cannot be renamed by a pin bump or a formatting pass. label: "R11 a workflow pushes a refspec straight to main", - file: "promote.yml", + file: "plugin-ci.yml", rule: "R11", apply: |t| { replace_once( @@ -1684,109 +1110,9 @@ fn mutations() -> Vec { }, creates: false, }, - Mutation { - // THE REGRESSION AS IT ARRIVED (#740): the release build gated to merge groups while - // the predev->dev pull request's shards still download its artifact from their run. - label: "R18 slsa-build goes back to merge-group-only under a pull request's download", - file: "promote.yml", - rule: "R18", - apply: |t| { - // The shards no longer download the artifact (nothing they run reads it), so the - // pull request's same-run download is planted back into hop-shard, gated on the - // rung as it was, with slsa-build narrowed to merge groups. - let t = plant_step_in_job( - t, - "hop-shard", - " - if: needs.preflight.outputs.needs_release_artifact == 'true'\n uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1\n with: { name: \"slsa-artifact-${{ env.SHA }}\", path: slsa }\n", - ); - reif_job( - &t, - "slsa-build", - "github.event_name == 'merge_group' && needs.preflight.outputs.run == '1'", - ) - }, - creates: false, - }, - Mutation { - // THE REGRESSION AS IT STOOD BEFORE busbar-release 069f032: a hop shard's `ci hop` holds - // the R2 write key again, where every candidate process it starts can read it. - label: "R19 a hop shard's `ci hop` holds the R2 cache write key", - file: "promote.yml", - rule: "R19", - apply: |t| { - replace_once( - t, - r#"R2_RO_KEY_ID: "${{ secrets.R2_CACHE_RO_ACCESS_KEY_ID }}""#, - r#"R2_KEY_ID: "${{ secrets.R2_CACHE_ACCESS_KEY_ID }}""#, - ) - }, - creates: false, - }, ] } -/// Insert `step` (a whole ` - ...\n` step) as the first step of job `job`. A renamed job or one -/// without `steps:` edits nothing, which the selftest reports as unproven. -fn plant_step_in_job(t: &str, job: &str, step: &str) -> String { - let head = format!(" {job}:"); - let mut in_job = false; - let mut done = false; - let mut out = String::with_capacity(t.len() + step.len()); - for line in t.split_inclusive('\n') { - let bare = line.trim_end_matches('\n'); - if opens_key_at(bare, 2) { - in_job = bare.trim_end() == head; - } - out.push_str(line); - if !done && in_job && bare == " steps:" { - out.push_str(step); - done = true; - } - } - out -} - -/// Replace job `job`'s own `if:` line with `if: `. The anchor is the job id and its -/// four-space `if:` key, not today's condition, so rewording the condition cannot unprove R18; a -/// renamed or unconditional job edits nothing, which the selftest reports as unproven. -fn reif_job(t: &str, job: &str, cond: &str) -> String { - let head = format!(" {job}:"); - let mut in_job = false; - let mut done = false; - let mut out = String::with_capacity(t.len() + cond.len()); - for line in t.split_inclusive('\n') { - let bare = line.trim_end_matches('\n'); - if opens_key_at(bare, 2) { - in_job = bare.trim_end() == head; - } - if !done && in_job && opens_key_at(bare, 4) && bare.trim_start().starts_with("if:") { - out.push_str(" if: "); - out.push_str(cond); - out.push('\n'); - done = true; - } else { - out.push_str(line); - } - } - out -} - -/// `t` without the first line containing `needle` (unchanged when none does: the case then -/// reports its anchor moved). -fn drop_first_line_with(t: &str, needle: &str) -> String { - let mut done = false; - t.split_inclusive('\n') - .filter(|l| { - if !done && l.contains(needle) { - done = true; - false - } else { - true - } - }) - .collect() -} - /// The R14 mutations both target the FIRST fully-pinned third-party action in the file, found by /// shape rather than by a literal sha. A literal would rot the day that action is bumped, and a /// mutation that edits nothing proves nothing. diff --git a/xtask/tests/infra.rs b/xtask/tests/infra.rs index 61f9dee334..fbb2ea17da 100644 --- a/xtask/tests/infra.rs +++ b/xtask/tests/infra.rs @@ -722,35 +722,35 @@ jobs: } #[test] -fn the_real_pipeline_workflow_parses_and_its_gate_call_sites_are_discoverable() { - let text = std::fs::read_to_string(repo_root().join(".github/workflows/promote.yml")).unwrap(); - let wf = yaml_lite::parse_workflow(&text).expect("the real promote.yml parses"); +fn every_gate_the_modernci_branch_files_call_is_registered() { + // THE PIPELINE IS `.ci/*.yaml` (OWNER 2026-10-12, "ModernCI only"): ModernCI runs each branch + // file's checks on every move, and promote.yml, the busbar-release hop, is deleted. EVERY GATE A + // BRANCH FILE CALLS BY NAME IS ONE THE REGISTRY ANSWERS TO: a `cargo xtask gate ` is a + // check that exits 2 on every move, and the registry is what can say so here rather than in a + // red move. `.ci/default.yaml` calls `abi-header` by name, so the scan is never empty. + let dir = repo_root().join(".ci"); + let mut files: Vec<_> = std::fs::read_dir(&dir) + .unwrap() + .filter_map(|e| e.ok().map(|e| e.path())) + .filter(|p| p.extension().is_some_and(|x| x == "yaml" || x == "yml")) + .collect(); + files.sort(); + let mut called_any = false; + for path in &files { + let text = std::fs::read_to_string(path).unwrap(); + for name in yaml_lite::xtask_gate_invocations(&text) { + called_any = true; + assert!( + gates::find(&name).is_some(), + "{} calls `cargo xtask gate {name}`, which no registration answers to", + path.display() + ); + } + } assert!( - wf.job_names().len() >= 2, - "promote.yml carries the preflight and hop jobs: {:?}", - wf.job_names() + called_any, + "no `.ci/*.yaml` calls a gate by name: the scan read nothing ({files:?})" ); - assert!( - wf.job_names() - .iter() - .any(|j| wf.job(j).unwrap().steps.iter().any(|s| s.run.is_some())), - "every job's `run:` steps must be visible to the reader" - ); - - // EVERY GATE THE PIPELINE CALLS BY NAME IS ONE THE REGISTRY ANSWERS TO: a `cargo xtask gate - // ` is a step that exits 2 on every hop, and the registry is what can say so here rather - // than in a red run. - // promote.yml is a thin bootstrap: it runs busbar-release's `busbar-release ci hop` at - // RELEASE_REF, and the gate calls (the C header's `abi-header`, the registry's `--list`) live in - // that engine, not in this file. So the file names no gate today; the loop below keeps holding - // anything it ever does name to the registry. - let called = yaml_lite::xtask_gate_invocations(&text); - for name in &called { - assert!( - gates::find(name).is_some(), - "promote.yml calls `cargo xtask gate {name}`, which no registration answers to" - ); - } assert_eq!( yaml_lite::xtask_gate_invocations(" run: |\n cargo xtask gate plane-purity --selftest\n cargo xtask gate plane-purity\n"), vec!["plane-purity".to_string()]