Skip to content

Commit 7a7a435

Browse files
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

File tree

0 commit comments

Comments
 (0)