Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions .github/workflows/arch-probe.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
name: arch-probe

# THE MATCHED PAIR CLOUD-1416 OWED AND NEVER TOOK.
#
# CLOUD-1416 moved `ci`, `bats`, `commit-lint` and `cache-warm-linux` to arm64 on
# the strength of its own title — "measured at up to 40% faster" — a figure
# imported from outside this repository and never reproduced in it. Everything
# PR #847 measured afterwards was the CACHE-KEY fix (CLOUD-1410): a cold build
# became a warm restore, which is build-versus-no-build and swamps any per-core
# difference. `mise run ci` went 498s cold to 315s warm on the SAME
# architecture, so not one landed number varies the arch.
#
# This is the experiment that does. Two jobs, one run, one commit, same steps,
# same minute, differing ONLY in `runs-on`.
#
# COLD ON BOTH ARMS, DELIBERATELY, and that is what makes it a pair. There is no
# `Swatinem/rust-cache` step here at all. A warm comparison is not available even
# in principle: `runnerOS-runnerArch` lands at rust-cache's `config.ts:93`, inside
# the prefix assigned to `restoreKey` at `:133`, so each architecture is its own
# cache family and `cache-warm-linux` writes exactly one of them. A warm arm64
# reader has no warm x64 counterpart to be compared against, which is the same
# fact that made the family move together or not at all.
#
# `cargo nextest run --no-run --workspace` and nothing else: a pure build, which
# is the term an architecture could plausibly move, with no test flakiness and no
# network in the measurement. It is the same command `cache-warm-linux` uses, so
# the work is already known to be representative of the pole's dominant term.
#
# `--no-run` IS LOAD-BEARING IN TWO DIRECTIONS AT ONCE, and finding that out is
# what fixed a rule rather than this file. Two committed predicates read the same
# five characters and want opposite things:
#
# `ci-parity`'s `foreign-cargo-is-the-declared-spelling` EXEMPTS a line
# containing ` --no-run`, on reasoning that applies here exactly — "a build
# that compiles and executes nothing cannot go green on work it no longer
# covers, because it covers none." Drop `--no-run` and this step is refused
# for not being `test:cargo`'s declared argv, which it must not be: this
# measures a build and runs no suite.
#
# `ci-hygiene`'s `cache-warm-compile-is-guarded` TRIGGERS on the same string as
# its proxy for "a compile that fills a cache", and demands an `if:` guarding
# on a `cache-hit` output. Measured: it fired on this file.
#
# The second demand is unsatisfiable here, not merely unmet. This job has NO
# cache step by design, so `steps.<id>.outputs.cache-hit` resolves to empty, the
# guard admits everything, and naming an id no step carries is that rule's OTHER
# arm. Its measured harm ("two cache entries carrying the SAME key across five
# merges... the restore skips saving when the key already exists") is a statement
# about a restore, and there is no restore here to skip.
#
# So the preset was narrowed instead: its primary arm now requires the job to
# actually carry a cache action before it demands a cache guard. Writing this
# step in some third spelling to slip between the two rules was the first attempt
# and it was a dodge — it satisfied neither predicate's intent and traded a
# false positive for a differently-shaped one.
#
# Comparability is unaffected either way, because the pair is compared to ITSELF:
# both arms run the identical command in the same run, and what the reading has
# to support is arm-versus-arm, never probe-versus-`cache-warm-linux`.
#
# `workflow_dispatch` ONLY. It is not a `pull_request` workflow, so it is not in
# `$CI_REQUIRED_CHECKS`, `ci-local-parity` does not reach it, and it costs
# nothing until somebody asks it a question. It answers a property of the WORLD —
# how fast a runner class is — which `.claude/rules/toolchain.md` puts on a
# schedule or a trigger rather than in the gate, exactly as `lock-currency.yml`
# is split from `lock-complete`.
#
# HOW TO READ THE RESULT: compare the two `build` step durations, not the job
# durations. A job carries lease, checkout and mise install, and those are
# network-bound and vary between runners for reasons that are not the CPU.
on:
workflow_dispatch:

# Least privilege: this reads the repo and measures. Nothing else.
permissions:
contents: read

concurrency:
group: arch-probe-${{ github.ref }}
cancel-in-progress: true

env:
MISE_TASK_RUN_AUTO_INSTALL: false
MISE_EXEC_AUTO_INSTALL: false
MISE_LOCKFILE: true
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0

jobs:
probe:
name: probe (${{ matrix.arch }})
strategy:
# `fail-fast: false` so one arm dying still leaves the other's number
# readable. A pair with one half missing is not a pair, but a single
# reading beats none and says which arm to re-run.
fail-fast: false
matrix:
include:
- arch: x64
runner: ubuntu-latest
- arch: arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
# DERIVED FROM THE ARM WE HAVE, WHICH IS THE ONLY HONEST BASIS. `mise run ci`
# cold on arm64 measured 498s (run 33834442988), of which 194s was the 133
# `Compiling` lines; a build-only arm is that term plus the crates a test
# binary adds. 900s is that rounded well up, and x3 is the multiplier every
# job in this repository carries — so 45 minutes. A probe that times out has
# produced no reading, which is the one outcome worth spending headroom to
# avoid.
timeout-minutes: 45 # budget: p95=900s x3 measured=2026-09-04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 (CLOUD-404 retry fix, now a release)
with:
# Only what the build needs. This job runs no gate, so the `ci` job's
# wide list would be paying install time on both arms for tools
# nothing here invokes — and install time is exactly the term the
# reading has to exclude.
install_args: rust github:nextest-rs/nextest
# THE MEASUREMENT. Named `build` so both arms' durations sit under one
# step name in the run summary and the pair is read without arithmetic.
- name: build
run: mise exec -- cargo nextest run --no-run --workspace
101 changes: 52 additions & 49 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,11 +85,23 @@ jobs:
# which this became on 2026-09-03, and carry identical specifications to
# their x64 counterparts — 4 CPUs, 16 GB RAM, 14 GB storage.
#
# AND THE SPEED CLAIM IS UNMEASURED — CORRECTED RATHER THAN QUIETLY DROPPED.
# This comment used to justify the stale budget below with "the expected
# direction is faster." Nothing in this repository has ever measured that.
# CLOUD-1416's title carries "up to 40% faster", a figure imported from
# outside and never reproduced here, and every number PR #847 took afterwards
# measures the CACHE-KEY fix instead: `mise run ci` went 498s cold to 315s
# warm on the SAME architecture, which is build-versus-no-build and swamps
# any per-core difference. Two lines above say these runners "carry identical
# specifications to their x64 counterparts", which is not a reason to expect
# a speedup either. `arch-probe.yml` is the matched cold pair that decides
# it; until it has run, arm64's benefit here is UNKNOWN rather than expected.
#
# THE BUDGET BELOW IS STILL AN x64 READING and is owed a re-derivation from
# the first arm64 runs, recorded on CLOUD-1420. It is carried rather than
# guessed because the expected direction is faster, which makes an x64-derived
# ceiling a weaker backstop rather than a tighter one — the safe direction to
# be wrong in, and still the wrong number.
# the first arm64 runs, recorded on CLOUD-1420. It is carried because an
# x64-derived ceiling over an unknown direction is a backstop of unknown
# tightness — which is a reason to re-derive it, never a reason to relax
# about it.
runs-on: ubuntu-24.04-arm
# RE-MEASURED IN THE SAME PASS AS THE POLE (CLOUD-386). The 2026-08-14 reading
# declared `p95=701s`; `mise run timeout-drift` over the last 25 successful runs
Expand Down Expand Up @@ -586,13 +598,24 @@ jobs:
# that was supposed to make this a finding rather than a surprise does not
# reach it. That asymmetry is the row's to close, not this job's.
#
# COLD, AND DELIBERATELY SO. `cache-warm-linux` writes `ci-` on arm64 now, so
# no warm x64 entry exists for this job and it pays a full build. That costs
# nothing: splitting this job out (CLOUD-1413) was to take 189.6s off `ci`'s
# SERIAL chain, and a job running in parallel with a 1730s pole has ~1200s of
# slack. Buying it a warm entry would need a second warm writer, doubling the
# cache families against a store measured at 10.72 GiB over a 10 GiB ceiling
# — a worse trade, and one this change deliberately declines.
# COLD, AND THE JUSTIFICATION FOR THAT IS NOW FALSE. `cache-warm-linux`
# writes `ci-` on arm64, so no warm x64 entry exists for this job and it pays
# a full build every run.
#
# This comment used to price that as free: "a job running in parallel with a
# 1730s pole has ~1200s of slack." The premise was true when it was written
# and this repository's own change falsified it. Measured on run 33845451921,
# the first warm arm64 run of a real `crates/**` change: `ci` 364s, `bats`
# 271s, and THIS JOB 683s — 1.9x the next longest, and the critical path of
# the whole workflow. There is no 1730s pole to hide behind any more, because
# CLOUD-1410 removed it. The slack is gone and this job is the pole.
#
# What has not changed is the trade that was declined: buying a warm x64
# entry needs a second warm writer, doubling the cache families against a
# store measured at 10.72 GiB over a 10 GiB ceiling. So the answer is not a
# second writer — it is CLOUD-1431, which removes the reason this job is
# stranded here at all, or a revert of the family to x64 if `arch-probe.yml`
# shows arm64 buys nothing.
runs-on: ubuntu-latest
# DERIVED RATHER THAN OBSERVED, because this job has no history yet. The
# terms: 189.6s for the step itself (job 100792703868), plus this job's own
Expand Down Expand Up @@ -631,46 +654,26 @@ jobs:
fetch-depth: 0
- name: Fetch the comparison base refs
run: git fetch origin main:refs/remotes/origin/main
# NAME THE CHECKOUT, BECAUSE ONE RULE'S SUBJECT IS A BRANCH (CLOUD-1422).
# `actions/checkout` leaves a `pull_request` run on a DETACHED merge
# commit, and `claim-not-raced` — the only rule in the set whose subject is
# this branch rather than a file — identifies itself by asking `gh pr view`
# with no argument, which `gh` resolves from the checked-out branch. Measured
# detached: `could not determine current branch: failed to run git: not on
# any branch`, so the gate's own PR is not excluded from the competitor list
# and the branch is reported as racing itself (job 100910411899).
# A "Name the checkout's branch" STEP STOOD HERE AND IS DELETED, because
# the thing it fed retired out from under it (CLOUD-1422). It created a
# local branch from `github.head_ref` so that `claim-not-raced` — then a
# shell gate that identified its own PR with `gh pr view` and no argument —
# could resolve `self` on a checkout `actions/checkout` leaves detached.
#
# THE FIX IS TO SUPPLY THE INPUT, NOT TO SWITCH THE GATE OFF. `gh` resolves
# a PR by branch NAME alone — measured, independent of what the branch
# points at — so a local branch named `github.head_ref` at the merge commit
# makes `self` resolve and the exclusion fire, and the gate then answers in
# CI instead of abstaining. That is the difference between a gate that can
# see and a gate that is disabled: `BATTEN_CLAIM_RACE_BYPASS` here would
# have made every future PR's duplicate-claim check silently vacuous.
# `mise-tasks/claim-race-check.sh` is now `batten claim race`, and
# `race::identify` matches the head COMMIT against the pull-request listing
# rather than reading a branch name. The step changed no commit, so it fed
# nothing: dead the moment that landed.
#
# `-C` rather than `-c`: `fetch-depth: 0` brings the branch ref down, so the
# name can already exist locally. Pointing it at the merge commit is
# deliberate — HEAD's content is unchanged, and `origin/main..HEAD` still
# enumerates this PR's own commits.
#
# THE VALUE GOES THROUGH `env`, never inline. `github.head_ref` is a branch
# name an outside contributor chooses, so interpolating it into a `run:`
# body is the template-injection `zizmor` refuses; an env binding read as a
# quoted variable is the mitigation it asks for.
#
# WHAT MADE THIS URGENT RATHER THAN LATENT is worth the line, because it is
# not this PR. `gh pr list` needs `pull-requests: read` on a PRIVATE
# repository, and no job here grants it — so for this gate's whole life in
# CI the call 403'd and it fail-opened, exactly as its header says it
# should. This repository went public on 2026-09-03, which handed the job
# token PR-read access through `metadata: read`, and the gate started
# actually running in CI for the first time. The first thing it did was
# refuse. Same class as CLOUD-867: going public changed what the default
# token can reach, and a check that had been silently abstaining woke up.
- name: Name the checkout's branch
env:
HEAD_REF: ${{ github.head_ref }}
run: git switch -C "$HEAD_REF"
# AND THE SUCCESSOR ABSTAINS HERE, WHICH IS THE FINDING THE STEP WAS
# HIDING. `race.rs` says "a SHA survives a detached checkout" — true of a
# checkout at the PR's HEAD sha, false of one at the MERGE commit, which is
# the only kind a `pull_request` event produces. Measured: PR #848's merge
# ref is `a31c22b3` and its head sha is `54789fe9`, and from that checkout
# the verb exits 0 with "no open pull request has this commit as its head,
# so there is nothing claiming anything yet". A clean pass, byte-identical
# to a real one. That is CLOUD-1433's class in freshly landed code, and it
# is recorded there rather than papered over with a step that never fixed it.
- uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 (CLOUD-404 retry fix, now a release)
with:
# THE `ci` JOB'S LIST, NOT A NARROWER ONE, and the width is decided by
Expand Down
43 changes: 43 additions & 0 deletions batten.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5855,6 +5855,29 @@ line_sources = [".github/workflows/*.yml"]
module = "policy/ci-cache-declared.rego"
severity = "deny"

# CLOUD-1431. The two pinned-tool surfaces this repository has, compared for the
# first time: what `release-artifacts.yml` PUBLISHES against what a
# `[[provision]]` row can RESOLVE.
#
# `lock-complete` already requires every `[tools]` entry to install on three
# mandatory platforms, linux-arm64 among them — so that surface is gated and
# CLOUD-1416 was right to read it as proven. `[[provision]]` carries no
# platform-completeness requirement at all and holds exactly one entry, which is
# why the asymmetry went unnoticed until an arm64 runner met it as
# `provision ripsecrets: no artifact for linux-aarch64`.
#
# `sources` names both documents and no `line_sources`: the predicate reads
# structure — a matrix's `target` values and a platform table's keys — never a
# line, so declaring a line surface would acquire bytes nothing decides over.
[[rule]]
id = "release-provision-parity"
kind = "policy"
scope = "tree"
sources = [".github/workflows/release-artifacts.yml", "batten.toml"]
module = "policy/release-provision-parity.rego"
severity = "deny"
no_fix_reason = "a platform a provisioned tool cannot reach is not fixable in this tree: either the upstream artifact exists and the row should pin it, or it does not and the gap is declared with its consequence — and which of the two is a supply-chain decision, not a rewrite"

[[rule]]
id = "bats-invocation"
kind = "policy"
Expand Down Expand Up @@ -9173,6 +9196,26 @@ id = "workflow read first"
kind = "document"
target = ".github/workflows/ci.yml"

[[verdict]]
id = "release cover partial"
gloss = "a release target this repository publishes names a platform a provisioned tool cannot resolve"
class = """
Two pinned-tool surfaces exist and only one is gated. `lock-complete` holds every \
`[tools]` entry to three mandatory platforms; `[[provision]]` carries no such \
requirement, so a release target can be added — or a tool provisioned — for a \
platform whose artifact nobody pinned, and the consequence arrives on a \
consumer's machine as `no artifact for <key>` rather than on the change that \
caused it. Measured: three arm64 targets published against a table pinning one \
Linux platform, and `x86_64-pc-windows-gnu` in the same state for its whole life \
with no runner to reveal it. The runtime is already fail-closed and loud, which \
is right — this class is about deciding the pairing at authoring time instead.
"""

[[verdict.route]]
id = "config read first"
kind = "document"
target = "batten.toml"

[[verdict]]
id = "workflow read unread"
gloss = "the workflow this rule judges would not parse, so nothing was decided"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,34 @@ guarded_on_cache_hit(path, name, index) if {
contains(workflow[path].jobs[name].steps[index]["if"], "cache-hit")
}

# A CACHE MUST BE PRESENT BEFORE A CACHE GUARD CAN BE DEMANDED.
#
# `warm_step` reads `--no-run` as its proxy for "a compile that fills a cache",
# and that proxy is one-sided: `--no-run` says the step compiles and runs
# nothing, never that a cache is involved. A job that compiles to MEASURE — a
# runner benchmark, a build-throughput probe — matches identically and has no
# cache at all, so the demanded guard is unsatisfiable rather than merely
# missing: `steps.<id>.outputs.cache-hit` resolves to empty with no cache action
# in the job, the guard then admits everything, and naming an id no step carries
# is this rule's OTHER arm. Both routes out are worse than the finding.
#
# The measured harm this rule exists for needs the cache to exist — "two cache
# entries carrying the SAME key across five merges... the restore skips saving
# when the key already exists" is a statement about a restore. With no restore
# there is nothing to skip and nothing wasted beyond the compile somebody asked
# for on purpose.
#
# THE DIRECTION OF THE MISS, stated because it is real: keyed on `uses`
# containing `cache`, which is the ecosystem's own spelling and names no
# consumer. A caching action whose name omits it would now go unjudged — but
# such a job could not express the guard either, since the guard references a
# `cache-hit` output only a cache action emits, so the rule was unenforceable
# there before this conjunct rather than after it.
job_caches(path, name) if {
some step in workflow[path].jobs[name].steps
contains(object.get(step, "uses", ""), "cache")
}

violation contains {
"rule": "cache-warm-compile-is-guarded",
"verdict": "cache build loose",
Expand All @@ -273,6 +301,7 @@ violation contains {
some name, _ in workflow[path].jobs
some index, _ in workflow[path].jobs[name].steps
warm_step(path, name, index)
job_caches(path, name)
not guarded_on_cache_hit(path, name, index)
}

Expand Down
Loading
Loading