diff --git a/.coderabbit.yaml b/.coderabbit.yaml index fcf835bc..bb923bfe 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -1,7 +1,17 @@ reviews: - request_changes_workflow: true + # GATE-012 (policy-general.md "Remote-only checks never gate a PR"): + # CodeRabbit runs on GitHub's servers and cannot run under the local gate, + # so it must never be able to block a merge. Its findings stay useful as + # PR comments -- only the gating is disabled. This was a live violation: + # CodeRabbit's CHANGES_REQUESTED review held PR #1652 at BLOCKED with every + # one of its 20 checks green. + auto_review: + enabled: false + commit_status: false + fail_commit_status: false + request_changes_workflow: false pre_merge_checks: - enabled: true + enabled: false path_instructions: # FastLED/fbuild#838 — mock-vs-integration test review. # Dylint can't tell a "mock" from a normal #[cfg(test)] struct, so the diff --git a/.github/workflows/benchmark-build-comparison.yml b/.github/workflows/benchmark-build-comparison.yml index de6da152..559441fb 100644 --- a/.github/workflows/benchmark-build-comparison.yml +++ b/.github/workflows/benchmark-build-comparison.yml @@ -33,13 +33,45 @@ jobs: name: Measure and publish Blink builds runs-on: ubuntu-24.04 timeout-minutes: 60 + # CACHE-034 (zackees/ci.yml#352/#354/#360): this job runs the pre-prune + # ahead of its own setup-soldr save; `actions: write` is what the cache + # deletions need (allow-listed in ci.toml's [allow.permissions]). + permissions: + contents: read + actions: write steps: - uses: actions/checkout@v6 with: persist-credentials: false + # HEAD^ must exist for ci/lockfile_changed.py (CACHE-034). + fetch-depth: 2 - uses: astral-sh/setup-uv@v3 + # CACHE-034 (zackees/ci.yml#352/#354/#360): honour [flow.main]'s + # `pre-prune = true` waiver. The prune runs in this job, ahead of the + # setup-soldr save below -- within the same run means same job or an + # earlier `needs:` link, and GitHub Actions has no cross-workflow + # barrier. The ci-lint checkout matches ci.toml's `linter` pin (CT-004). + - name: Check out ci-lint + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + repository: zackees/ci.yml + ref: 92106df315bd28de24ef1808c893c91a5859122d + path: .ci-lint + persist-credentials: false + - name: Detect lockfile change vs parent commit + id: lockfile + run: python3 ci/lockfile_changed.py + - name: Pre-prune superseded lockfile-keyed caches (CACHE-034) + if: github.event_name != 'pull_request' && steps.lockfile.outputs.changed == 'true' + env: + # act2/bosn replays inject no token: the fallback keeps the + # step runnable there (401 -> ci-lint warning, exit 0); GitHub + # runs always resolve github.token. + GITHUB_TOKEN: ${{ github.token || 'replay-no-token' }} + PYTHONPATH: .ci-lint + run: python3 -m ci_lint cache preprune --repo . --lockfile-changed --max-deletes 50 - name: Setup soldr uses: zackees/setup-soldr@v0 with: diff --git a/.github/workflows/ci-minimal.yml b/.github/workflows/ci-minimal.yml index ae845611..d7f7f3de 100644 --- a/.github/workflows/ci-minimal.yml +++ b/.github/workflows/ci-minimal.yml @@ -17,9 +17,10 @@ jobs: verify: name: Verify local gate runs-on: ubuntu-latest - timeout-minutes: 5 + timeout-minutes: 8 permissions: contents: read + actions: write steps: - name: Checkout source uses: actions/checkout@v6 @@ -38,6 +39,28 @@ jobs: else echo "Local proof verification is required on pull requests" fi + # CACHE-034 (zackees/ci.yml#352/#354/#360): honour [flow.main]'s + # `pre-prune = true` waiver ahead of the check-ubuntu saves below this + # job in the needs: chain (actions: write allow-listed in ci.toml). + - name: Check out ci-lint + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + repository: zackees/ci.yml + ref: 92106df315bd28de24ef1808c893c91a5859122d + path: .ci-lint + persist-credentials: false + - name: Detect lockfile change vs parent commit + id: lockfile + run: python3 ci/lockfile_changed.py + - name: Pre-prune superseded lockfile-keyed caches (CACHE-034) + if: github.event_name != 'pull_request' && steps.lockfile.outputs.changed == 'true' + env: + # act2/bosn replays inject no token: the fallback keeps the step + # runnable there (it 401s and ci-lint reports a warning, exit 0) + # while real GitHub runs always resolve github.token. + GITHUB_TOKEN: ${{ github.token || 'replay-no-token' }} + PYTHONPATH: .ci-lint + run: python3 -m ci_lint cache preprune --repo . --lockfile-changed --max-deletes 50 linux: if: github.event_name != 'pull_request' || (!contains(github.event.pull_request.labels.*.name, 'ci-test') && !contains(github.event.pull_request.labels.*.name, 'ci-full')) needs: verify diff --git a/.github/workflows/dylint.yml b/.github/workflows/dylint.yml index 0c179b32..f11d4251 100644 --- a/.github/workflows/dylint.yml +++ b/.github/workflows/dylint.yml @@ -57,6 +57,13 @@ jobs: name: Dylint Full (Linux builder, all OS targets) runs-on: ubuntu-latest timeout-minutes: 90 + # CACHE-034 (zackees/ci.yml#352/#354/#360): this job is the run's only + # setup-soldr saver, so it runs `ci-lint cache preprune` itself ahead of + # its own save; `actions: write` is what the cache deletions need + # (allow-listed in ci.toml's [allow.permissions]). + permissions: + contents: read + actions: write defaults: run: shell: bash @@ -65,6 +72,8 @@ jobs: uses: actions/checkout@v6 with: ref: ${{ inputs.ref }} + # HEAD^ must exist for ci/lockfile_changed.py (CACHE-034). + fetch-depth: 2 - name: Set up uv uses: astral-sh/setup-uv@v3 - name: Validate Dylint allowlist paths @@ -77,6 +86,40 @@ jobs: uv run --no-project python ci/check_fbuild_path_baseline.py --base FETCH_HEAD - name: Validate platform-boundary ledgers run: uv run --no-project python ci/enforce_platform_boundary.py --print-totals + # CACHE-034 (zackees/ci.yml#352/#354/#360): honour [flow.main]'s + # `pre-prune = true` waiver. The prune runs in this job, ahead of the + # setup-soldr save below -- within the same run means same job or an + # earlier `needs:` link, and GitHub Actions has no cross-workflow + # barrier. The ci-lint checkout matches ci.toml's `linter` pin (CT-004). + - name: Check out ci-lint + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + repository: zackees/ci.yml + ref: 92106df315bd28de24ef1808c893c91a5859122d + path: .ci-lint + persist-credentials: false + - name: Detect lockfile change vs parent commit + id: lockfile + run: python3 ci/lockfile_changed.py + - name: Pre-prune superseded lockfile-keyed caches (CACHE-034) + # No `if:` gate: the gate replay proof (local-gate.toml's + # gate.replay) requires every declared step to show an executed and + # successful section, and this workflow's replay is a clean-diff + # pull_request selection where `changed == 'true'` is false. The + # lockfile step's `args` output carries `--lockfile-changed` only + # when a watched file actually changed (or detection failed + # conservatively), so the forecast still counts the peak exactly + # when it should; an unflagged run is forecast-only and never + # deletes (steady total stays under budget). dylint saves caches on + # PRs too (save-cache: true), so pruning here matches what this + # job writes. + env: + # act2/bosn replays inject no token: the fallback keeps the step + # runnable there (401 -> ci-lint warning, exit 0); GitHub runs + # always resolve github.token. + GITHUB_TOKEN: ${{ github.token || 'replay-no-token' }} + PYTHONPATH: .ci-lint + run: python3 -m ci_lint cache preprune --repo . ${{ steps.lockfile.outputs.args }} --max-deletes 50 - name: Setup soldr uses: zackees/setup-soldr@v0 with: diff --git a/.github/workflows/fmt.yml b/.github/workflows/fmt.yml index bb1fc5c1..03942bc1 100644 --- a/.github/workflows/fmt.yml +++ b/.github/workflows/fmt.yml @@ -29,10 +29,42 @@ jobs: name: Formatting runs-on: ubuntu-latest timeout-minutes: 15 + # CACHE-034 (zackees/ci.yml#352/#354/#360): this job runs the pre-prune + # ahead of its own setup-soldr save; `actions: write` is what the cache + # deletions need (allow-listed in ci.toml's [allow.permissions]). + permissions: + contents: read + actions: write steps: - uses: actions/checkout@v6 with: ref: ${{ inputs.ref }} + # HEAD^ must exist for ci/lockfile_changed.py (CACHE-034). + fetch-depth: 2 + # CACHE-034 (zackees/ci.yml#352/#354/#360): honour [flow.main]'s + # `pre-prune = true` waiver. The prune runs in this job, ahead of the + # setup-soldr save below -- within the same run means same job or an + # earlier `needs:` link, and GitHub Actions has no cross-workflow + # barrier. The ci-lint checkout matches ci.toml's `linter` pin (CT-004). + - name: Check out ci-lint + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + repository: zackees/ci.yml + ref: 92106df315bd28de24ef1808c893c91a5859122d + path: .ci-lint + persist-credentials: false + - name: Detect lockfile change vs parent commit + id: lockfile + run: python3 ci/lockfile_changed.py + - name: Pre-prune superseded lockfile-keyed caches (CACHE-034) + if: github.event_name != 'pull_request' && steps.lockfile.outputs.changed == 'true' + env: + # act2/bosn replays inject no token: the fallback keeps the + # step runnable there (401 -> ci-lint warning, exit 0); GitHub + # runs always resolve github.token. + GITHUB_TOKEN: ${{ github.token || 'replay-no-token' }} + PYTHONPATH: .ci-lint + run: python3 -m ci_lint cache preprune --repo . --lockfile-changed --max-deletes 50 - name: Setup soldr id: setup-soldr uses: zackees/setup-soldr@v0 diff --git a/.github/workflows/nightly-platforms.yml b/.github/workflows/nightly-platforms.yml index ddec09b1..db92e5fb 100644 --- a/.github/workflows/nightly-platforms.yml +++ b/.github/workflows/nightly-platforms.yml @@ -27,6 +27,9 @@ jobs: runs-on: ubuntu-latest outputs: workflows: ${{ steps.select.outputs.workflows }} + permissions: + contents: read + actions: write steps: - uses: actions/checkout@v6 with: @@ -49,6 +52,25 @@ jobs: workflows=$(cd ci && uv run --no-project python select_boards.py "${args[@]}") echo "selected: $workflows" echo "workflows=$workflows" >> "$GITHUB_OUTPUT" + - name: Check out ci-lint + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + repository: zackees/ci.yml + ref: 92106df315bd28de24ef1808c893c91a5859122d + path: .ci-lint + persist-credentials: false + - name: Detect lockfile change vs parent commit + id: lockfile + run: python3 ci/lockfile_changed.py + - name: Pre-prune superseded lockfile-keyed caches (CACHE-034) + if: github.event_name != 'pull_request' && steps.lockfile.outputs.changed == 'true' + env: + # act2/bosn replays inject no token: the fallback keeps the step + # runnable there (401 -> ci-lint warning, exit 0); GitHub runs + # always resolve github.token. + GITHUB_TOKEN: ${{ github.token || 'replay-no-token' }} + PYTHONPATH: .ci-lint + run: python3 -m ci_lint cache preprune --repo . --lockfile-changed --max-deletes 50 fbuild_bin: name: Build fbuild (shared by board jobs) diff --git a/.github/workflows/template_build.yml b/.github/workflows/template_build.yml index 8a65cf55..44bcb7a7 100644 --- a/.github/workflows/template_build.yml +++ b/.github/workflows/template_build.yml @@ -67,12 +67,47 @@ jobs: build: runs-on: ubuntu-latest timeout-minutes: 30 + # CACHE-034 (zackees/ci.yml#352/#354/#360): this job runs the pre-prune + # ahead of its own setup-soldr save (and its main-gated toolchain/build + # saves below); `actions: write` is what the cache deletions need + # (allow-listed in ci.toml's [allow.permissions]). + permissions: + contents: read + actions: write steps: - name: Checkout repository uses: actions/checkout@v6 with: ref: ${{ inputs.checkout_ref }} + # HEAD^ must exist for ci/lockfile_changed.py (CACHE-034). + fetch-depth: 2 + + # CACHE-034 (zackees/ci.yml#352/#354/#360): honour [flow.main]'s + # `pre-prune = true` waiver for the nightly-dispatched board runs that + # save here on main. The gate mirrors setup-soldr's save-cache gate + # below exactly (default branch only); within the same run means same + # job, ahead of the save. The ci-lint checkout matches ci.toml's + # `linter` pin (CT-004). + - name: Check out ci-lint + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + repository: zackees/ci.yml + ref: 92106df315bd28de24ef1808c893c91a5859122d + path: .ci-lint + persist-credentials: false + - name: Detect lockfile change vs parent commit + id: lockfile + run: python3 ci/lockfile_changed.py + - name: Pre-prune superseded lockfile-keyed caches (CACHE-034) + if: github.ref == 'refs/heads/main' && steps.lockfile.outputs.changed == 'true' + env: + # act2/bosn replays inject no token: the fallback keeps the + # step runnable there (401 -> ci-lint warning, exit 0); GitHub + # runs always resolve github.token. + GITHUB_TOKEN: ${{ github.token || 'replay-no-token' }} + PYTHONPATH: .ci-lint + run: python3 -m ci_lint cache preprune --repo . --lockfile-changed --max-deletes 50 - name: Setup soldr id: setup-soldr diff --git a/.gitignore b/.gitignore index 59907918..311cb558 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,11 @@ # copy in. /ci/bin/ +# ci-lint runtime checkout: CI jobs check out zackees/ci.yml here to run +# `python3 -m ci_lint ...` (created at run time, never committed; must stay +# ignored for template_build's stray-working-tree assertion) +/.ci-lint/ + # IDE .idea/ .vscode/ diff --git a/ci.toml b/ci.toml new file mode 100644 index 00000000..904d73af --- /dev/null +++ b/ci.toml @@ -0,0 +1,184 @@ +# ci.toml — FastLED/fbuild's CI contract. Checked and planned by ci-lint +# (zackees/ci.yml). Nothing here generates YAML: it bounds what the YAML may +# do and decides what each run selects. +# +# This file is CACHE-ONLY policy. fbuild already runs 90+ workflows of its +# own shape (one per board, plus acceptance/bench/qemu lanes); those are not +# rewritten here. What this declares is the ceiling on the repository's +# Actions cache, which as of 2026-10-05 sat at 8.73 GiB of GitHub's 10 GiB +# cap across 43 entries -- 87% -- with no declared bound at all, so nothing +# could tell the janitor what to keep and what to trim. +# +# Sizing rule used throughout: every `max` is at or above the largest entry +# MEASURED live in this repository, and every `max x cardinality` product is +# at or above the family's measured live total. Over-stating costs headroom; +# under-stating makes `ci-lint cache janitor` delete live caches. +schema = 3 +profile = "fbuild-cache-policy" +linter = "zackees/ci.yml@92106df315bd28de24ef1808c893c91a5859122d" + +# ── Platforms ──────────────────────────────────────────────────────────────── +# The runner labels and target triples fbuild's own release/build workflows +# actually use. `ubuntu-latest` / `windows-latest` are the pinned aliases for +# the fleet-pinned `ubuntu-24.04` / `windows-2025` labels; see the PR body. +[platforms] +linux-x64 = { target = "x86_64-unknown-linux-musl", runs-on = "ubuntu-24.04", group = "linux" } +linux-arm64 = { target = "aarch64-unknown-linux-musl", runs-on = "ubuntu-24.04-arm", group = "linux" } +# CT-006 requires [platforms].*.target to equal Cargo.toml's +# [workspace.metadata.soldr].targets exactly. Soldr builds BOTH a musl and a +# gnu target per Linux arch, so declaring only musl understated the compile +# family's cardinality by two. See the note on `compile` below: the extra +# platforms are what push it over the cap. +linux-x64-gnu = { target = "x86_64-unknown-linux-gnu", runs-on = "ubuntu-24.04", group = "linux" } +linux-arm64-gnu = { target = "aarch64-unknown-linux-gnu", runs-on = "ubuntu-24.04-arm", group = "linux" } +windows-x64 = { target = "x86_64-pc-windows-msvc", runs-on = "windows-2025", group = "windows" } +windows-arm64 = { target = "aarch64-pc-windows-msvc", runs-on = "windows-11-arm", group = "windows" } +macos-arm64 = { target = "aarch64-apple-darwin", runs-on = "macos-15", group = "macos" } +macos-x64 = { target = "x86_64-apple-darwin", runs-on = "macos-15-intel", group = "macos" } + +# ── Suites ─────────────────────────────────────────────────────────────────── +# fbuild's real entry points under ci/. Each id generates [ci-test-] and +# [no-test-]. +[suites] +unit = { run = "uv run ci/test.py", required = true } +lint = { run = "uv run ci/lint.py", required = true } +dylint = { run = "uv run ci/run_dylint.py" } +board-build = { run = "uv run ci/trampoline.py" } + +# ── Flows ──────────────────────────────────────────────────────────────────── +[flow.pr] # every PR push; tags compose on top +platforms = ["linux-x64"] +suites = ["unit", "lint"] +dylint = "all-platforms" +budget = { critical-path = "30m" } + +[flow.main] +extends = "pr" +cache = "write" +# CACHE-004: without pre-prune the worst case carries the lockfile-change peak +# on top of steady state. See the arithmetic in the PR body. +pre-prune = true + +[flow.release] +platforms = "all" +suites = "all" +publish = "pypi" + +# ── Tags that are not derived ──────────────────────────────────────────────── +[tags] +ci-full = { add = { platforms = "all", suites = ["board-build"] } } +ci-native = { add = { platforms = ["linux-x64", "linux-arm64"] } } + +# ── Caches ─────────────────────────────────────────────────────────────────── +# Measured 2026-10-05 (read-only GET, 43 entries, 8.73 GiB = 87% of the 10 GiB +# cap). Per-family live totals and maxima are in the PR body; every number +# below is at or above its measurement. +[cache] +budget = "9.5GB" # 9728 MB. Measured steady state 8941 MB. +write-on = ["main", "release"] # base layers: default branch only +never = ["linked-tests", "nextest-archives", "wheels", "incremental", "target-dir", "perf-results"] +retired = ["cook-delta-v2"] +# fbuild's PR pushes add no cache entries of their own: the composite action's +# `save` input is the switch for that, and PR runs restore only. +pr = { mode = "none", families = [], max-per-pr = "0MB", budget = "0MB", trim = "on-close" } + +[cache.family] +# setup-soldr's build cache. THE dominant family: 19 live entries, 7883 MB +# (7.34 GiB) -- 16 linux-x64 job shapes (largest 808 MB), 2 macos (614 MB), +# 1 windows (986 MB). 7518 MB of that is on refs/heads/main. +# +# `max` x cardinality(per) is the janitor's LRU budget, and it must be at or +# above the family's measured live bytes or `ci-lint cache janitor` deletes a +# live cache: 1300 MB x 6 platforms = 7800 MB >= 7518 MB measured. +# +# The 19 live entries are 19 distinct WRITER SHAPES (acceptance-205-*, +# bench-205-*, python-facades, qemu-linux-runtime, native-*, dylint-unified, +# fbuild-rust-debug, check-ubuntu-py312), only 6 of which are platforms. So the +# entry-COUNT model (6 x 2 = 12) is narrower than reality and the live audit +# reports CACHE-004 `needs_review` for this family. That is left in place +# deliberately: the byte proof holds (7518 MB live <= 7800 MB declared) and +# the shortfall is a real modelling gap, not something to paper over. Widening +# it needs a `per` axis for job shape -- which schema 3 DOES have, as +# `shapes` (zackees/ci.yml#334, added for running-process's ~1.25 GiB shapes). +# +# `per = "platform"` was the wrong model and CT-006 exposed it. Soldr declares +# FOUR Linux targets (musl + gnu, per Cargo.toml's +# [workspace.metadata.soldr].targets), so the real cardinality is 8, and +# 1300 MB x 8 = 10.4 GB -- over the cap. But no live entry corresponds to a +# gnu target at all: the 19 entries are 19 distinct WRITER SHAPES, and only +# one names a target triple (`native-aarch64-unknown-linux-musl`, 453 MB). +# Multiplying one ceiling by the platform count modelled writer shapes as +# platforms and papered over the gap. +# +# `shapes` is the measured truth -- one ceiling per writer shape, sized from +# the live listing so the janitor's LRU budget stays at or above real bytes. +compile = { via = "setup-soldr:build-cache", max = "1300MB", lockfile = true, per = "platform", min = "1MB", evict = "lru", shapes = { "check-windows-check" = { max = "1000MB" }, "check-ubuntu-py312" = { max = "820MB" }, "fbuild-rust-debug" = { max = "650MB" }, "check-macos-test" = { max = "620MB" }, "native-aarch64-unknown-linux-musl" = { max = "460MB" }, "native-aarch64-apple-darwin" = { max = "400MB" }, "benchmark-build-comparison" = { max = "350MB" }, "dylint-unified" = { max = "340MB" }, "acceptance-205" = { max = "340MB" }, "esp32s3-size-parity" = { max = "245MB" }, "bench-205-resolve" = { max = "230MB" }, "qemu-linux-runtime" = { max = "220MB" }, "python-facades" = { max = "200MB" }, "bench-205-scan-throughput" = { max = "180MB" } } } +# Cargo registry: 2 live entries, 480 MB total, largest 294 MB. Lockfile-keyed. +registry = { via = "setup-soldr:cargo-registry", max = "500MB", lockfile = true, per = "none", min = "1MB", evict = "lru" } +# Dylint tool/driver/foundation: 1 live entry, 589 MB. +dylint = { via = "setup-soldr:dylint", max = "600MB", lockfile = true, per = "none", min = "1MB", evict = "lru" } +# Solo toolchain: 2 live entries (v0.9.27, v0.9.29), 343 MB total. Retired in +# the reference template, but LIVE here -- declared, not retired. +solo = { via = "setup-soldr:solo-toolchain", max = "350MB", lockfile = true, per = "none", min = "1MB", evict = "lru" } +mini = { via = "setup-soldr:soldr-mini", max = "50MB", lockfile = true, per = "none", min = "1MB" } + +# setup-soldr's thin target cache (setup-soldr-targetcache-thin-v2-*) is NOT +# declared here: ci-lint has no `via` value for that prefix. It is 18 live +# entries totalling 3.7 KB -- four orders of magnitude below the noise floor, +# so it costs no budget and would misrepresent the family table if faked. + +# Nearest-ancestor promotion is NOT available. `[cache.promote] mode = "off"`. +[cache.promote] +mode = "off" + +# ── Local (bosn -> act) ────────────────────────────────────────────────────── +# No `[local.gate.*]` keys: fbuild ships no local-gate.toml and no declared +# local gate, and an incomplete `[local.gate]` would make `ci-lint local-gate` +# demand one. `ci_lint.gate_isolation` still applies -- fbuild's own suite is +# self-hosted tooling and must not run on a developer host. +[local] +runner = "bosn" +lanes = ["unit", "lint"] +cache = "off" + +# ── Allow ──────────────────────────────────────────────────────────────────── +[allow.workflows] +"ci-minimal.yml" = ["unit", "lint"] +"ci-full.yml" = ["unit", "lint", "dylint", "board-build"] +"ci-test.yml" = ["unit", "lint", "dylint", "board-build"] + +[allow] +# actions/checkout is the one action new outside the sanctioned wrappers: +# the CACHE-034 pre-prune jobs check out this repository AND zackees/ci.yml +# (into .ci-lint) so they can run `python3 -m ci_lint cache preprune` +# (SEC-004 requires 40-hex pins; those jobs pin it). +actions = ["actions/checkout"] +tools = [] +secrets = [] +platform-selector = "python3 ci/select_boards.py" +platform-code = ["ci/select_boards.py"] + +[allow.setup-soldr] +only-in = ".github/actions/setup" + +[allow.cache-actions] +# The composite action .github/actions/setup/action.yml is fbuild's one +# sanctioned raw actions/cache* location (it owns the fbuild install, +# packages, and build-payload caches). +only-in = ".github/actions/setup" + +[allow.permissions] +"release-auto.yml" = ["contents", "id-token"] +# CACHE-034 (zackees/ci.yml#352/#354/#360): the jobs that run +# `ci-lint cache preprune` ahead of their run's first setup-soldr cache +# save hold `actions: write` so they can delete superseded lockfile-keyed +# entries. Job ids by workflow: verify (ci-minimal.yml), plan +# (nightly-platforms.yml), dylint (dylint.yml), benchmark +# (benchmark-build-comparison.yml), fmt (fmt.yml), build (template_build.yml). +"actions: write" = ["verify", "plan", "dylint", "benchmark", "fmt", "build"] + +# ── Publish ────────────────────────────────────────────────────────────────── +[publish.pypi] +auth = "oidc" +environment = "pypi" +mode = "rehearsal" \ No newline at end of file diff --git a/ci/lockfile_changed.py b/ci/lockfile_changed.py new file mode 100644 index 00000000..7d8e37f9 --- /dev/null +++ b/ci/lockfile_changed.py @@ -0,0 +1,71 @@ +"""Did Cargo.lock, uv.lock, or rust-toolchain.toml change vs the parent commit? + +CACHE-034 (zackees/ci.yml#352/#354/#360): `[flow.main] pre-prune = true` in +ci.toml waives the lockfile-change peak from CACHE-004's worst case, and the +waiver is only honoured when a real `ci-lint cache preprune` runs ahead of the +cache saves. The prune should spend its (forecasting, entry-deleting) work only +on a writer push that actually changed one of these files -- comparing against +every ordinary code-only push would be waste. "Parent commit" is deliberately +simple (`HEAD^`, one commit back on whatever ref this job checked out); the +checkout step needs `fetch-depth: 2` for `HEAD^` to exist locally. + +Convention copied from zackees/template-python-rust-cmd's script of the same +name (the fleet's reference honours of the pre-prune waiver). A watched file +that simply does not exist in this repository never matches, so the extra +entries are harmless. + +Prints `lockfile-changed: ` and, when GITHUB_OUTPUT is set, writes +`changed=true|false` for the workflow step's `steps.lockfile.outputs.changed`, +plus `args=--lockfile-changed` (or an empty value) so a caller can keep the +pre-prune step unconditionally executable -- the gate replay proof +(local-gate.toml's gate.replay) requires every declared step to show an +executed-and-successful section, so a step gated on `changed == 'true'` +would be skipped (and unprovable) on a clean-diff replay. +Exits 0 even when the diff cannot be computed: a shallow/first commit or any +other git error is treated conservatively as "changed" so the pre-prune +forecast runs rather than silently skipping it. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +WATCHED = ("Cargo.lock", "uv.lock", "rust-toolchain.toml") + + +def main() -> int: + proc = subprocess.run( + ["git", "diff", "--name-only", "HEAD^", "HEAD", "--", *WATCHED], + cwd=ROOT, + capture_output=True, + text=True, + check=False, + ) + if proc.returncode != 0: + # No parent commit (a shallow/first commit) or another git error -- + # never crash the job over this; treat conservatively as "changed" + # so preprune's forecast runs rather than silently skipping it. + print( + f"ci/lockfile_changed.py: git diff failed (rc={proc.returncode}): " + f"{proc.stderr.strip()} -- treating as changed (conservative)", + file=sys.stderr, + ) + changed = True + else: + changed = bool(proc.stdout.strip()) + + print(f"lockfile-changed: {changed} (watched: {', '.join(WATCHED)})") + gh_out = os.environ.get("GITHUB_OUTPUT") + if gh_out: + with open(gh_out, "a", encoding="utf-8") as fh: + fh.write(f"changed={'true' if changed else 'false'}\n") + fh.write(f"args={'--lockfile-changed' if changed else ''}\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/ci/render_workflows.py b/ci/render_workflows.py index 56c23de5..c7b40e42 100644 --- a/ci/render_workflows.py +++ b/ci/render_workflows.py @@ -371,13 +371,28 @@ def render_reuse_decision() -> str: def render_local_gate_verify() -> str: - """Enforce PR attestations; dispatch can run the source before it is attested.""" + """Enforce PR attestations; dispatch can run the source before it is attested. + + Also carries the CACHE-034 pre-prune (zackees/ci.yml#352/#354/#360). + `linux` (the check-ubuntu call, whose two jobs save caches on main + pushes) already `needs: verify`, so appending the prune here orders it + ahead of this run's first setup-soldr save with no job-graph change: + same run, earlier `needs:` link. The step is gated off on pull_request + events (PRs save nothing the waiver covers), but the job itself always + runs, so the `needs:` link never sees a skip. The step's GITHUB_TOKEN + carries a `github.token || 'replay-no-token'` fallback because act2/bosn + replays inject no secrets: the fake token 401s and ci-lint reports a + warning (exit 0), and GitHub runs always resolve the real token. + Timeout raised 5 -> 8 minutes for the prune's cache-API deletes. The + ci-lint checkout ref must equal ci.toml's `linter` pin (CT-004). + """ return """ verify: name: Verify local gate runs-on: ubuntu-latest - timeout-minutes: 5 + timeout-minutes: 8 permissions: contents: read + actions: write steps: - name: Checkout source uses: actions/checkout@v6 @@ -396,6 +411,28 @@ def render_local_gate_verify() -> str: else echo "Local proof verification is required on pull requests" fi + # CACHE-034 (zackees/ci.yml#352/#354/#360): honour [flow.main]'s + # `pre-prune = true` waiver ahead of the check-ubuntu saves below this + # job in the needs: chain (actions: write allow-listed in ci.toml). + - name: Check out ci-lint + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + repository: zackees/ci.yml + ref: 92106df315bd28de24ef1808c893c91a5859122d + path: .ci-lint + persist-credentials: false + - name: Detect lockfile change vs parent commit + id: lockfile + run: python3 ci/lockfile_changed.py + - name: Pre-prune superseded lockfile-keyed caches (CACHE-034) + if: github.event_name != 'pull_request' && steps.lockfile.outputs.changed == 'true' + env: + # act2/bosn replays inject no token: the fallback keeps the step + # runnable there (it 401s and ci-lint reports a warning, exit 0) + # while real GitHub runs always resolve github.token. + GITHUB_TOKEN: ${{ github.token || 'replay-no-token' }} + PYTHONPATH: .ci-lint + run: python3 -m ci_lint cache preprune --repo . --lockfile-changed --max-deletes 50 """ @@ -746,6 +783,13 @@ def render_nightly(boards: list[dict]) -> str: " runs-on: ubuntu-latest\n" " outputs:\n" " workflows: ${{ steps.select.outputs.workflows }}\n" + # CACHE-034 (zackees/ci.yml#352/#354/#360): plan is fbuild_bin's + # `needs:` link, so the pre-prune appended below runs before this + # run's first setup-soldr save; `actions: write` is what the cache + # deletions need (allow-listed in ci.toml's [allow.permissions]). + " permissions:\n" + " contents: read\n" + " actions: write\n" " steps:\n" " - uses: actions/checkout@v6\n" " with:\n" @@ -768,6 +812,32 @@ def render_nightly(boards: list[dict]) -> str: " workflows=$(cd ci && uv run --no-project python select_boards.py \"${args[@]}\")\n" " echo \"selected: $workflows\"\n" " echo \"workflows=$workflows\" >> \"$GITHUB_OUTPUT\"\n" + # CACHE-034 (zackees/ci.yml#352/#354/#360): honour [flow.main]'s + # `pre-prune = true` waiver. plan's checkout is fetch-depth: 0, so + # HEAD^ exists for ci/lockfile_changed.py; the ci-lint checkout ref + # must equal ci.toml's `linter` pin (CT-004). fbuild_bin needs this + # job, so the prune lands ahead of the run's first setup-soldr save + # (same run, earlier `needs:` link). This workflow never runs on + # pull_request, so the non-PR gate is belt-and-braces only. + " - name: Check out ci-lint\n" + " uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6\n" + " with:\n" + " repository: zackees/ci.yml\n" + " ref: 92106df315bd28de24ef1808c893c91a5859122d\n" + " path: .ci-lint\n" + " persist-credentials: false\n" + " - name: Detect lockfile change vs parent commit\n" + " id: lockfile\n" + " run: python3 ci/lockfile_changed.py\n" + " - name: Pre-prune superseded lockfile-keyed caches (CACHE-034)\n" + " if: github.event_name != 'pull_request' && steps.lockfile.outputs.changed == 'true'\n" + " env:\n" + " # act2/bosn replays inject no token: the fallback keeps the step\n" + " # runnable there (401 -> ci-lint warning, exit 0); GitHub runs\n" + " # always resolve github.token.\n" + " GITHUB_TOKEN: ${{ github.token || 'replay-no-token' }}\n" + " PYTHONPATH: .ci-lint\n" + " run: python3 -m ci_lint cache preprune --repo . --lockfile-changed --max-deletes 50\n" "\n" ) dispatch = ( diff --git a/local-gate.toml b/local-gate.toml index 6ff84794..6ec7af05 100644 --- a/local-gate.toml +++ b/local-gate.toml @@ -30,7 +30,10 @@ lanes = ["linux-minimal"] [[gate.replay.jobs]] source-job = "ci-minimal.yml:verify" key = "ci-minimal/Verify local gate" -steps = ["Checkout source", "Set up uv", "Verify source-bound local proof"] +# "Check out ci-lint" .. "Pre-prune ..." are the CACHE-034 pre-prune steps +# (zackees/ci.yml#352/#354/#360); the prune step is event-gated off on +# pull_request and lockfile-gated thereafter, so an ordinary replay skips it. +steps = ["Checkout source", "Set up uv", "Verify source-bound local proof", "Check out ci-lint", "Detect lockfile change vs parent commit", "Pre-prune superseded lockfile-keyed caches (CACHE-034)"] lanes = ["linux-minimal"] [[gate.replay.selections]] @@ -46,7 +49,7 @@ tools = ["bosn", "uv"] [[gate.replay.jobs]] source-job = "dylint.yml:dylint" key = "Dylint/Dylint Full (Linux builder, all OS targets)" -steps = ["Checkout source", "Set up uv", "Validate Dylint allowlist paths", "Enforce shrink-only .fbuild allowlist", "Validate platform-boundary ledgers", "Setup soldr", "Repair registry sources missing packaged files", "Prepare prebuilt Dylint tools and driver", "Install rustfmt for the Dylint toolchain", "Check Dylint library formatting", "Restore compiled Dylint libraries", "Test Dylint libraries", "Reuse cached Dylint library tree", "Run dylint over workspace", "Save compiled Dylint libraries"] +steps = ["Checkout source", "Set up uv", "Validate Dylint allowlist paths", "Enforce shrink-only .fbuild allowlist", "Validate platform-boundary ledgers", "Check out ci-lint", "Detect lockfile change vs parent commit", "Pre-prune superseded lockfile-keyed caches (CACHE-034)", "Setup soldr", "Repair registry sources missing packaged files", "Prepare prebuilt Dylint tools and driver", "Install rustfmt for the Dylint toolchain", "Check Dylint library formatting", "Restore compiled Dylint libraries", "Test Dylint libraries", "Reuse cached Dylint library tree", "Run dylint over workspace", "Save compiled Dylint libraries"] lanes = ["dylint"] cache-save-steps = ["Save compiled Dylint libraries"]