Repository navigation
Commit 7a7a435
ci(scheduled-full-run): the release gate retries a transient 5xx or connection reset on a run listing, inside one budget per gate run (#24748)
Fixes #24741
Clause-②: no
## What this changes
One file: `scripts/ci/scheduled-full-run.mjs` (HALF 3, the release
gate's live read, and its `--self-test`). `release.yml`, `lint.yml` and
`scheduled-full-run-card.yml` are unchanged, and no verdict changes.
- `readRuns` reads each page through `readBoard`, the citation gate's
retry policy, imported. An HTTP 5xx or a named connection failure
(`ECONNRESET`, `UND_ERR_SOCKET`) is retried: 3 attempts per read, with
500 / 1000 ms backoff. A 4xx, an OK body with no `workflow_runs` array,
and every other error are final on the first attempt. It takes two new
optional parameters, `fetchImpl` (default: the global `fetch`) and
`budget`.
- `readFullBattery` (new export) reads every workflow x event x page of
one gate run under ONE `boardReadBudget()`. That is 4 retries per gate
run, never 4 per `readRuns` call.
- `runReleaseGate` (new export) is the gate as `{ exit, lines, verdict
}`, with `readHistory`, `fetchImpl` and `sleep` injectable.
`--release-gate` prints the same lines and exits the same codes as
before: 0 pass, 1 refused, 3 NOT MEASURED.
- A spent ladder or budget still refuses. The line keeps its prefix
`::error::release gate NOT MEASURED -- ` and its suffix `. Refusing: an
unread full battery is not a green one.`, and the exit stays
`EXIT_PREREQUISITE_NOT_MET` (3). Its text now names the last answer and
the attempts, `readFailure`-style, for example `GET
https://api.github.com/repos/o/r/actions/workflows/lint.yml/runs?branch=main&event=schedule&status=completed&per_page=100&page=1
-> HTTP 503 after 3 attempts (every attempt one read is allowed, so not
one blip)`.
- Self-test: a new battery, `release gate: the read path retries a
transient answer, inside one budget per run`, with 16 cases and a pinned
floor of 16. The battery-count floor moves from 6 to 7.
## The card's Wanted list, item by item
1. **`readRuns` retries a transient failure, and only a transient
failure.** Done. Retried: HTTP 500 to 599, `ECONNRESET` and
`UND_ERR_SOCKET` (the closed list `RETRYABLE_NETWORK_CODES`). Final at
once, each one pinned: 401, 403, 404, 422 and 429; an OK body without
`workflow_runs`; `ECONNREFUSED`; `ENOTFOUND`; a body that is not JSON.
2. **Reuse #23361's policy, not a second copy of it.** Done, by import.
`scheduled-full-run.mjs` imports `BOARD_READ_RETRY`, `boardReadBudget`,
`readBoard` and `readFailure` from `scripts/check-issue-citations.mjs`,
which is byte-identical to the base. Why import and not lift: see below.
3. **Still refuse when the ladder or budget is spent.** Done. The
prefix, suffix and exit are the same. The text names the last status (or
the connection code) and the attempts.
4. **Pins in the file's self-test, with an injectable `fetch` and
`sleep`.** Done: four pins, and each is RED on the base (see below). No
case touches the network or sleeps.
5. **Not a new gate; no change to `release.yml`, its environment or its
dispatch.** Held. The diff is one file under `scripts/ci/`. No workflow
file is touched, and no release workflow was run or dispatched.
## The four pins, and the proof that each one can fail
| pin | self-test cases (HEAD) | CLI probe on BASE `7e7e605de3` | CLI
probe on HEAD `5cad717ed4` |
|---|---|---|---|
| 1. a 502 then a 200 is measured, and the verdict is computed | 502
then 200 passes (7 requests, one 500 ms backoff). A red battery behind
the same blip refuses as a verdict (exit 1), not as NOT MEASURED. A
reset, then 200; a socket closed mid-body, then 200; a 503 on page 2,
then 200. | **RED**: exit 3, `... page=1 answered HTTP 502. Refusing:
...` | GREEN: exit 0, PASS, 7 requests |
| 2. three 5xx answers refuse NOT MEASURED, naming the attempts | Three
503s: exit 3, `-> HTTP 503 after 3 attempts`, backoff 500,1000.
Persistent resets name `ECONNRESET`. A persistent
500/502/503/504/reset/socket close on each of the 6 listings refuses. |
**RED**: 1 request to that listing, and the text names no attempts |
GREEN: exit 3, 3 requests to that listing, `after 3 attempts` |
| 3. a 404 refuses at once, with no retry | 404: exit 3, 1 request, no
backoff, `-> HTTP 404 after 1 attempt (not retried`. Also
401/403/422/429, no `workflow_runs`, and ECONNREFUSED / ENOTFOUND /
non-JSON. | **RED**: exit 3 and 1 request, but the text names no attempt
count | GREEN: exit 3, 1 request |
| 4. the run budget caps total retries across workflows and events |
Every listing blips once: 4 retries are spent, the fifth listing's 502
is final (`this run's budget of 4 retries is spent`), 9 requests.
Control: 4 blips are all measured. | **RED**: refused at the first
request | GREEN: exit 3, 9 requests |
The CLI probe runs `node scripts/ci/scheduled-full-run.mjs
--release-gate --release-commit 7e7e605…` in each tree. It preloads a
stub `fetch` and uses the real git history, so BASE's own code path is
what goes red. On BASE, pin 3 holds its "at once" half (the base never
retried anything) and goes red only on the attempt count. Two ablations
on HEAD prove the behavioural halves of pins 3 and 4:
- **A1:** `retryable` is widened to every status 400 and up in
`check-issue-citations.mjs` (via `scripts/ablation-replace.mjs`; anchor
1 -> 0, blob changed). `--self-test` then exits 1 at pin 3: `got exit 3
after 3 request(s), slept 500,1000`. Restored: blob == HEAD, and `git
diff HEAD` is empty.
- **A2:** `readFullBattery` creates a fresh `boardReadBudget()` per
`readRuns` call. `--self-test` then exits 1 at pin 4: `got exit 0 after
12 requests and 6 backoff(s)`. Restored the same way.
The dispatch's offline end-to-end probe is pin 1's row: one 502 on
`lint.yml`'s `schedule` listing, then a 200. BASE refuses it as NOT
MEASURED (exit 3); HEAD measures it and passes (exit 0).
## Why import, not lift
Both shapes leave one definition. I decided on the four axes:
- **实际业务需求 (real need).** Both serve the same measured pull: release run
39230668843 for this gate, and the merge-queue ejection behind #23361
for the citation gate. Neither adds a capability. Tie.
- **项目长远合理性 (long-term soundness).** A lifted module would name the
policy for what it is, and would keep the citation gate's own imports
out of the publish lane's load closure. Import adds
`check-issue-citations.mjs` and four helpers (`git-env`, `glob-match`,
`js-comment-mask`, `symbol-anchors`; about 5,600 lines) to that closure.
Measured: every one of them imports only `node:*`, and none runs
anything at load (0 child processes and 0 fs calls during the import).
The gate already loads `pm/check-half-states.mjs` (about 35,000 lines),
so the added coupling is small. Lift wins, narrowly.
- **防 AI 写错 (resistance to AI error).** Both leave one definition, and a
third consumer finds two importers of the citation gate and imports too.
The constraint the publish lane depends on (builtins only, nothing run
at load) is written at the import. Tie.
- **创业阶段不扩散需求 (startup scope).** Import costs one import line and zero
edits to the citation gate. The lift was measured on this branch: it
shifts check-issue-citations' line-anchored `local-env` census row in
`scripts/pm/dispatch-gates.self-test.mjs` from `:204` to `:205` (`pnpm
check:pm-dispatch-gates` went 1 of 1913 cases red). That file is outside
this card's surface, so the lift needs a fourth file. Import wins.
**Decision: import.** I tried the lift first (`42643e8ca9`) and reverted
it in `5cad717ed4`, so the net diff is one file.
**The import loads in each job that loads this file:**
- `release.yml` `full-battery`: `actions/checkout` (`fetch-depth: 0`)
and `setup-node` 22, with no install step. The closure is node builtins
only. Proof: I loaded the module and ran `--self-test` from a `git
archive` of `scripts/` and `.github/`, with no `node_modules` anywhere
above it. Exit 0.
- `scheduled-full-run-card.yml` `file`: `actions/checkout`, with no
setup-node and no install. `actions/github-script` `import()`s the file
by absolute path, so the closure and the proof are the same.
- `lint.yml` `lint-ci-engine` (the step that runs `--self-test` and
`--check-concurrency`): checkout (`fetch-depth: 0`), setup-node, then
`pnpm install --frozen-lockfile`. It loads there too.
## Gates
Run on HEAD `5cad717ed4`, the final commit (the union ran after it):
- `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands` derived 32 commands (no paths given: it reads the change set
off the merge base). All 32 exit 0, including `pnpm
check:pm-dispatch-gates` (1913 cases). `--ran` reconciles them: `32
derived, 32 run, 0 NOT-MEASURED, 0 UNRUN`, exit 0.
- `node scripts/ci/scheduled-full-run.mjs --self-test`: exit 0.
`--check-concurrency`: exit 0. `node scripts/check-issue-citations.mjs
--self-test`: exit 0 (215 cases, 10 batteries).
- `pnpm exec eslint --no-inline-config
scripts/ci/scheduled-full-run.mjs`: exit 0.
On BASE `7e7e605de3`, the same three direct commands each exit 0, and
the citation gate's self-test reports the same 215 cases.
## Acceptance notes
- `origin/main` moved past the base (4 commits, to `39e93b9ef9` at the
last fetch). None of them touches this file or its import closure, so I
did not merge; CI tests the merge ref.
- The builtins-only constraint on the closure is written in a comment at
the import, but nothing enforces it. `lint.yml`'s job installs
dependencies, so a non-builtin import added anywhere in the closure
would fail first on a release run. This is not a defect today. No
carrier: noted, not filed.
- Changeset: none. The diff publishes nothing: `scripts/` belongs to the
private root package (`private: true`), and no published package ships
it. Following PR #24305's precedent, `skip-changeset` is declared when
this PR is created.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01FM29ZqLTrnPNXxijioZwMp)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 1f949da commit 7a7a435
1 file changed
Lines changed: 304 additions & 25 deletions
0 commit comments