ci(nightly-next): an older build hands its sign slot to a newer build whose sign job is queued
An older nightly-next build hands its sign slot to a newer build whose sign job is already queued, running or signed. Nothing is cancelled, and the newest finished build always signs.
What was wrong
GitHub gives a free runner the oldest queued job on its label, and a runner can't choose another. Publication is latest-wins. So on 2026-11-04 around 17:25Z, the three sign slots were held by the oldest nightly-next builds (runs 41866781601, 41867447845, 41868557041), and the newer builds' build-sign-notarize-nightly jobs waited behind them: 15, 19 and 24 minutes for the three that had started, with three more still queued (41869470224, 41870869803, 41873130397). Each older build then published a nightly that the next one replaced within minutes, and the newest tip reached the feed about an hour after its landing.
The existing "Leave notarization to a newer published build" check (sign-pick-overtaken.sh, #33109) runs in pick-nightly-sign-runner, before the sign job joins the queue. With oldest-first order it almost never fires.
What this changes
- One step,
Yield the sign slot to a newer build, runs inbuild-sign-notarize-nightlyright after the claim, before any secret or signing work. It isscripts/ci/nightly_sign_yield.py, run from the trusted signing controls.- It reads the in-progress nightly-next push runs newer than this one.
- If one has its own sign job queued, running or signed, this job exits 1 with
yielded=true, and the runner takes the next queued job (about a minute instead of a full sign and notarize). - Otherwise, and on any doubt (an unreadable answer, a malformed field, a listing that doesn't add up), it signs as before.
- Scope: attempt 1 of a nightly-next push only. Re-runs, dispatches and main's nightly never yield. Setting
NIGHTLY_NEXT_SIGN_YIELD=0turns it off.
report-nightly-failuredoes not report a yielded sign whose run had nothing else fail. This is the same pattern as its existing "replaced" build.- Nothing is cancelled, and no concurrency group changes. The rulebook's "never cancel a started NEXT nightly" holds. Verify, publish and deltas are skipped for the yielded run because the sign job did not succeed, as for any failed sign.
The rule this narrows (#33109)
sign-pick-overtaken.sh says: "Only a published newer build counts, never a newer push alone: a newer run that then fails must not leave the feed with nothing (#28961)."
- What still holds: a newer push alone still never counts. A newer run whose build is still running, or whose sign job failed, was skipped or was cancelled, is no reason to yield.
- What now counts: a newer run whose sign job exists, which GitHub creates only once that run's build jobs pass.
- Why it can't starve the feed: the newest such sign job never yields (no newer run has one), so the newest finished build always signs. #28961's starvation came from a run skipping its build as soon as nightly-next moved, before any newer build existed.
- What is given up: if that newest sign then fails, the older builds that yielded to it go unsigned, and the next tip builds as usual.
Decided on cmuxterm-hq#7079. Ordering the slots newest-first was tried first. teamleaderleo/glaeda#1815 found that a runner can't choose which queued job it takes; glaeda can only refuse a job, which fails it.
Tests
tests/test_nightly_sign_yield.py (new, linux-guard):
- the 2026-11-04 shape: the newest queued build never yields, nor does a newer run whose sign job doesn't exist yet;
- an older build yields to a newer sign job that is queued, running or signed;
- a newer push alone never counts (no sign job, a failed, skipped or cancelled one, or the legacy-identity signer);
- an older run is never a reason;
- out of scope (attempt 2, dispatch, main, another track,
NIGHTLY_NEXT_SIGN_YIELD=0, a bad id): it signs without a read; - any doubt signs;
- the CLI writes
yielded=falseand exits 0 when the API is unreachable; - wiring: the step sits right after the claim, with no secret before it or in it, and report-nightly-failure skips only a yielded sign.
Red on base: the wiring tests fail on 4d20a29a816 (no such step or output); the decision tests have no script to load there. Green on this head.
python3 -m unittest tests/test_nightly_sign_yield.py tests/test_nightly_step_coverage.py: 14 OK on this head. Every other test file that reads nightly.yml (106) was run on the head and on 4d20a29a816: 69 pass on both, and the remaining 37 fail exactly the same tests on both (they need tools this checkout lacks, such as actionlint).
Observed / Not run
- Observed: the run and job timings above, read from the Actions API at about 17:25Z.
- Not run: a live nightly-next run with the step. The first nightly-next runs after this lands show it in their sign job's log (
signing: ...or theSign yieldnotice).
Follow-up, not here: a superseded run's sign-nightly-identity-legacy also takes the exclusive class on its mini (35 min on run 41799796683); the same yield could apply to it.
Changelog
none
Agent session: agent session
🤖 Generated with Claude Code
![Autofix with [code]smith](https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark-3x.png)
Open in [code]smith · Need help on this PR? Tag @codesmith-bot with what you need.
Summary by cubic
An older nightly-next build now hands its sign slot to a newer build whose sign job is queued, running, or signed, cutting the newest tip's feed delay from roughly 20 minutes per slot to about a minute.
GitHub gives a free runner the oldest queued job on its label, so the newest build's sign job waited behind older builds whose nightlies were replaced within minutes. A new step right after the signing claim, before any secret, checks the in-progress nightly-next runs; if a newer run's sign job already exists (GitHub creates it only once that run's build passes), this job exits 1 and the runner picks the next queued job. Nothing is cancelled, and report-nightly-failure skips a yielded sign whose run had nothing else fail.
- Narrowing #33109's rule: a newer push alone still never counts, but a newer run whose sign job exists now does. The newest sign job never yields, so the newest finished build always signs; the trade-off is that if that sign then fails, the older builds that yielded to it go unsigned.
- The yield script pages the run and job listings until an empty page, since a status-filtered list's
total_countcan run ahead; more thanMAX_PAGESpages signs as before. The test is registered as manual, so the registry validator passes.