Skip to content

Commit e4d604e

Browse files
fix(release): re-derive release-verify-npm read-back budget from the 18.0.0 reading (35 min, flat) (#24989)
Fixes #24888 Clause-②: no `scripts/release-verify-npm.mjs` waited 15 minutes for npm's read path to settle. On 18.0.0 the last package became readable 16m22s after the first read, so a release that was complete read red. This PR re-derives the budget from the 18.0.0 reading and sets it to **35 minutes, one flat number for the group**. It still fails closed. The self-test now replays the 18.0.0 window on npm's own clock and pins `main()`'s exit code. ## The measurements, re-read | Reading | Source | Value | |---|---|---| | Publish job | Actions API, job 118624531794 (run 39421537775, head `3fbef41b4a`) | 12:24:14Z to 12:57:40Z, failure | | Step 21, "Publish to npm + push version tags" | same | 12:36:08Z to 12:57:38Z, failure; steps 22 and 23 skipped | | The verdict | check-run annotations of job 118624531794 | `publish ran but 1 of 73 package(s) are still not readable on npm after 19 round(s) over 900s: @objectstack/console@18.0.0` | | `console` readable | `npm view @objectstack/console time --json` | `"18.0.0": "2026-10-20T12:57:59.327Z"` | | Verification start | the card's reading of the job log | 12:41:37Z | **The start time is not re-read here.** The job-log download redirects to a blob host that this container's egress proxy refuses (403, a policy denial, not retried). The readings above corroborate it: - 900 s of waits that end at 12:57:38Z put the start at or before 12:42:38Z. - Replaying the 19 rounds against npm's `time` for all 73 packages gives 408 probes. The card's start implies 61 s of probing, about 150 ms per probe. **Spread: 12:41:37Z to 12:57:59.327Z = 982.3 s (16m22s).** **Sizes** come from npm's `dist` fields and the tarball bytes, downloaded: | Package | Packed | Unpacked | Files | Readable at | |---|---|---|---|---| | `@objectstack/console` | 9,399,458 B | 37,040,621 B | 2282 | +16m22s (last) | | `@objectstack/spec` | 42,118,133 B | 197,756,260 B | 2100 | +10m04s | The card's "42424 KB" for `console` lives in the job log, so it could not be re-read. It is not `console`'s tarball, which is 9.4 MB. The release's largest tarball is `spec`'s. ## Shape: flat, 35 minutes. The deciding axis: actual business need (实际业务需求) **Derivation.** 17.3.0's margin was 900 s / 427 s = 2.11x. The same margin over the new spread is 982.3 s x 2.11 = 2070 s (34m30s). Rounded up, that is 35 minutes (2100 s, 2.14x the 18.0.0 spread). The header records this next to the 17.3.0 derivation. | Axis | Flat budget | Size-scaled allowance per package | |---|---|---| | **Actual business need (实际业务需求), measured: decides** | Covers the group's spread, which is what the 18.0.0 red was. | Its premise is that the slow package was slow because it is large. The measurement says otherwise: `spec` (42.1 MB packed, the largest) was readable six minutes **before** `console` (9.4 MB). On 17.5.0 the order was reversed (`console` 08:05:24Z, `spec` last at 08:09:33Z), and on 17.3.0 the last package was `runtime`. Any monotonic size scale gives the slowest 18.0.0 package a smaller allowance than one that settled six minutes earlier. | | Long-term soundness (项目长远合理性) | One constant, re-derived from readings recorded in the header. | Adds a second model (size to latency) that has no reading behind it and would need its own re-derivation. | | Guarding against AI mistakes (防 AI 写代码犯错) | One number that a reader can check against the header. | Needs the size of a package the registry cannot serve yet, so the size has to come from a new local input. Each missing or wrong size becomes a per-package fallback, which is the lenient direction. | | No scope spread at startup stage (创业阶段不扩散需求) | Changes one constant. | Adds a new mechanism and a new input. | **The budget is a ceiling, not a wait.** The loop returns on the first round that finds the last target, so a good publish pays only its own settle time. Replayed on this schedule, 18.0.0 goes green at +17m15s (battery 14 pins that figure). Only a publish that is really broken spends all 35 minutes. ## Fit against the caller `release.yml` was read, not edited. Neither the `publish` job (`:1565` to `:2132`) nor its step "Publish to npm + push version tags" declares `timeout-minutes`. The only `timeout-minutes` in the file is `full-battery`'s 10, at `:1536`. GitHub's default therefore bounds both: 360 minutes per job. The `release-integrity` audit calls the script with `--probe` (`:1251`). That path passes a budget of 0 and is unchanged; battery 10 pins its single round. Measured on 18.0.0: | Segment | Time | |---|---| | Before step 21 (build 4m44s, vendored Console SPA 5m44s) | 11m54s | | Step 21 before verification (`pnpm run release`: publish plus tag push) | 5m29s | | Verification at the old budget | 16m01s (900 s of waits plus about 61 s of probing) | | Steps 22 and 23 on a green run | 1m55s: 1m48s plus 7s, measured on the same two scripts in the backfill, run 39426677839, job 118640898073 | Worst case under the new budget, using budget plus poll interval plus the rest of the step: | Term | Value | |---|---| | Waits | exactly 2100 s (38 waits, 39 rounds); battery 1 pins that the schedule sums to the budget | | Poll interval | 60 s, the cap | | Probing when **nothing** reached npm | 39 rounds x 73 probes x 150 ms = 427 s (7m07s) | | Rest of the step | 5m29s | | **Step** | 5m29s + 35m00s + 1m00s + 7m07s = **48m36s** | | **Job** | 11m54s + 48m36s + 1m55s = **62m25s, against 360 minutes: 297 minutes of margin (5.8x)** | The same terms at the old budget gave 38m46s. One case lies outside that formula and existed before this change: a registry where every request hangs to the 15 s per-request ceiling. Each round then costs 73 x 15 s = 18m15s. That totals 347 minutes of probing at the old 19 rounds and 712 minutes at the new 39. In both cases GitHub's 360-minute default cancels the job. The job is red, so the run still fails closed, but the cancellation does not name a package. The outcome is the same before and after this change. ## Self-test and ablation The self-test goes from 115 cases across 13 batteries to **131 cases across 14 batteries**, with every battery floored. **Battery 1** gains two cases: the budget outlasts the 18.0.0 spread, and it keeps the 2.11x margin over it. **Battery 14** ("The 18.0.0 publish window: console readable at +16m22s, past the old 900 s") has 14 cases. It replays npm's own `time` for `sdui-parser`, `cli`, `spec` and `console` on a clock that the stub `sleep` drives. Probing is taken as instant, so a green in the replay is one the real step would also have seen. No case sleeps. - The old budget (900 s, passed as a parameter) reds and names only `console`, after 19 rounds over 900 s, which matches the run's annotation. Its last read of `console` came before `console` landed. - The production budget is never passed in, so it is the default. It goes green on the first round after `console` landed, at +17m15s, past the round where 900 s ran out. - **A package readable after 900 s and before the new budget passes:** `main()` over a fixture workspace exits 0 and prints the `::notice::` line. - **An absent package exits non-zero and is named:** with `console` never landing, `main()` exits 1. Its `::error::` line names `@objectstack/console@18.0.0` and "1 of 4", and names none of the three packages that landed. It concludes only after the waits sum to exactly `RETRY_BUDGET_MS`. This needed one seam change. `main()` now accepts the verifier's own `probe` and `sleep` seams; the defaults are the production ones. Its exit code and naming can therefore be pinned without the network or a real wait. The case of a large and a small package, each held to its own allowance, does not apply, because there is no per-package allowance. **Ablation.** I committed the fix first (`a9e61579a3`). I then ran `scripts/ablation-replace.mjs` in wrap mode, replacing `export const RETRY_BUDGET_MS = 35 * 60 * 1000;` with `15 * 60 * 1000;`. - Proof that the mutation landed: anchor count went from 1 to 0, replacement count from 0 to 1, blob `7f259d67ff62` became `c36e8cf51a07`. - `node scripts/release-verify-npm.mjs --self-test` then exited 1 with **8 of 131 cases failed**, exactly the cases that need more than 900 s (one JSON detail is shortened): ```text x the budget outlasts the 16m22s spread measured on the 18.0.0 burst -- 900000 > 982327 x and keeps the 17.3.0 margin (2.11x) over it -- 900000 >= 2070478 (0.92x) x THIS BUDGET sits through the 16m22s spread and goes GREEN -- {"ok":false,"missing":[{"name":"@objectstack/console",...}],"rounds":19,"waitedMs":900000,"checked":4} x ...on a round the old budget never reached -- waited=900000 x ...the FIRST round after console landed, not by spending the budget -- 2026-10-20T12:55:52.000Z .. 2026-10-20T12:56:37.000Z x ...at +17m15s, the figure the header quotes -- waited=900000 x main() exits 0 on the 18.0.0 replay, under the production budget -- code=1 ::error::publish ran but 1 of 4 package(s) are still not readable on npm after 19 round(s) over 900s: @objectstack/console@18.0.0 x ...and its notice says the whole group settled -- 1 of 4 package(s) not readable yet after round 18; waiting 45s (900s of 900s budget spent). x release-verify-npm self-test: 8 of 131 case(s) failed. ``` The red went in the expected direction. The old-budget repro and the absent-package cases stayed green, as they should. The restore was proven on disk: the blob after the restore equals the HEAD blob (`7f259d67ff62`), `git diff HEAD` was empty and `git status --porcelain` printed nothing. ## Changeset: `skip-changeset`, measured `scripts/release-verify-npm.mjs` belongs to the root package `@objectstack/spec-monorepo`, which is `private: true` and has no `files`. No non-private workspace package lists a `files[]` entry under `scripts` or with `..`. No tracked symlink points into the root `scripts/`. `git grep -l RETRY_BUDGET_MS` over `packages/**`, `apps/**` and `skills/**` returns 0 hits (exit 1). The positive control over `scripts/**` returns `scripts/release-verify-npm.mjs` (exit 0). ## Gates (local, at `a9e61579a3`) - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 33 commands for this one-file diff. All 33 ran and each exited 0, `check:pm-dispatch-gates` included (fast tier, 1913 cases). - `--ran` reconciliation: `33 derived, 33 run, 0 NOT-MEASURED, 0 UNRUN`. - `npx eslint --no-inline-config scripts/release-verify-npm.mjs` exited 0. - `git merge-tree --write-tree origin/main HEAD` at `origin/main` `78ab6474ca` exited 0. The commits since the base touch none of this script, `release-github-releases.mjs`, `workspace-enumerator.mjs` or `release.yml`. CI owns the rest of the farm. `lint.yml`'s "Post-publish npm verification self-test" step runs this self-test. ## Acceptance notes - **Stale comments in a governed file, not edited here.** Two comments in `.github/workflows/release.yml` still describe the old budget: - `:2077` to `:2080` says the verifier "retries with bounded backoff for 15 minutes, derived from ... 7m07s"; - `:1236` says "instead of waited on for 15 minutes". Both now read stale. `.github/workflows/**` is governed, so this needs a separate comment-only edit if wanted. Carrier: none. - **Pre-existing.** The hung-registry worst case above exceeds GitHub's 360-minute default at either budget, and is cancelled red without naming a package. This is an inference from the constants, not a reproduction. Carrier: none. --- _Generated by [Claude Code](https://claude.ai/code/session_01FM29ZqLTrnPNXxijioZwMp)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9532e27 commit e4d604e

1 file changed

Lines changed: 198 additions & 19 deletions

File tree

‎scripts/release-verify-npm.mjs‎

Lines changed: 198 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -42,15 +42,44 @@
4242
*
4343
* ## Where the retry budget comes from
4444
*
45-
* Not a guess -- npm's own `time` field on that release. The burst took
46-
* ~8 minutes to commit end to end, and the spread from the FIRST read
47-
* (`cli`, 10:53:25, which is when this verification starts) to the LAST commit
48-
* (`runtime`, 11:00:32) is 7m07s. `RETRY_BUDGET_MS` is 15 minutes: a little
49-
* over twice the observed spread, because burst latency scales with whatever
50-
* else the registry is absorbing and the cost of being wrong in the short
51-
* direction is a false red on a good release. The cost in the other direction
52-
* is that a GENUINELY broken publish is reported 15 minutes later -- paid once,
53-
* by a job that has already spent longer than that building.
45+
* Not a guess -- npm's own `time` field, read on the releases that set it. The
46+
* spread runs from the FIRST read (when this verification starts) to the LAST
47+
* package npm commits, and the budget is that spread times one margin.
48+
*
49+
* **17.3.0** (run 8330, job 100996109699). The burst took ~8 minutes to commit
50+
* end to end, and the spread from the first read (`cli`, 10:53:25) to the last
51+
* commit (`runtime`, 11:00:32) is 7m07s. The budget was set at 15 minutes: a
52+
* little over twice the observed spread (900 s / 427 s = 2.11x), because burst
53+
* latency scales with whatever else the registry is absorbing and the cost of
54+
* being wrong in the short direction is a false red on a good release.
55+
*
56+
* **18.0.0** (run 39421537775, job 118624531794) is the first reading past it.
57+
* Verification started at 12:41:37Z and spent its 900 s over 19 rounds; the
58+
* step ended red at 12:57:38Z with 1 of 73 packages unread,
59+
* `@objectstack/console`, whose npm `time` is 12:57:59.327Z -- 21 s later, and
60+
* 16m22s after the first read. All 73 were on npm; the false red skipped that
61+
* job's GitHub Releases, its ADR-0087 D4 asset and the runtime image. Same
62+
* margin, new spread: 982 s x 2.11 = 34m30s, so `RETRY_BUDGET_MS` is 35
63+
* minutes (2.14x the 18.0.0 spread).
64+
*
65+
* It is ONE number for the whole group, not an allowance per package scaled by
66+
* its size, because size did not predict which package settled last. On
67+
* 18.0.0 `console`'s packed tarball is 9,399,458 bytes (36 MB unpacked) and
68+
* `spec`'s is 42,118,133 bytes (193 MB unpacked), yet `spec` was readable at
69+
* +10m04s, six minutes before `console`. On 17.5.0 the order was the other way
70+
* round (`console` 08:05:24Z, `spec` last at 08:09:33Z), and on 17.3.0 the
71+
* last package was `runtime`. Scaled by size, the slowest package of 18.0.0
72+
* would have drawn a SMALLER allowance than one that settled six minutes before
73+
* it -- and the allowance would need the size of a package the registry cannot
74+
* serve yet.
75+
*
76+
* The budget is a CEILING, not a wait: the loop returns on the first round
77+
* that finds the last target, so a good publish pays only its own settle time
78+
* (18.0.0, replayed on this schedule, goes green at +17m15s -- battery 14).
79+
* Only a GENUINELY broken publish spends all 35 minutes before it is reported.
80+
* Whoever raises it re-checks the caller: the publish job declares no
81+
* `timeout-minutes`, so GitHub's 360-minute job default bounds this step
82+
* together with everything before it (17m23s on 18.0.0, build included).
5483
*
5584
* ## ⛔ It still fails CLOSED, and that is the whole point
5685
*
@@ -128,7 +157,7 @@
128157
* ONE round (a budget of 0 — the parameter battery 2 already drives) and prints
129158
* one JSON line: `state` is `published`, `unpublished` (some package is
130159
* definitively absent) or `unknown` (nothing is absent, but a read failed).
131-
* One round, not fifteen minutes of backoff, because the direction of error is
160+
* One round, not the verifier's whole backoff budget, because the direction of error is
132161
* the safe one: a cold read answers `unpublished` and the backfill waits for
133162
* the next landing, while a false `published` is impossible — every package
134163
* has to answer present. The exit status is 0 for all three answers; it is
@@ -195,7 +224,7 @@ const AUDIT_STEP_NAME = 'Audit the release that main actually carries';
195224
// must not red. A battery BELOW its floor means cases stopped running; the
196225
// remedy is to find what stopped registering.
197226
const SELF_TEST_BATTERIES = Object.freeze({
198-
'1. The backoff schedule is bounded and spends its whole budget': 8,
227+
'1. The backoff schedule is bounded and spends its whole budget': 10,
199228
'2. The #15321 false red: a cold read seven seconds after the commit': 6,
200229
'3. The masked defect: runtime never lands, and cli is fine': 6,
201230
'4. Absence after the budget is a HARD failure, never softened': 5,
@@ -208,11 +237,12 @@ const SELF_TEST_BATTERIES = Object.freeze({
208237
'11. The 17.5.0 publish window, replayed on npm\'s own clock': 7,
209238
'12. The audit step backfills only a version whose whole group is on npm, and no publish or image build of it is in flight': 34,
210239
'13. The backfill builds from the version commit\'s tree, as the publish does': 13,
240+
'14. The 18.0.0 publish window: console readable at +16m22s, past the old 900 s': 14,
211241
});
212242

213243
// DELETING an entry silences that battery's floor exactly as effectively as
214244
// zeroing it, so the roster's own size is pinned too.
215-
const SELF_TEST_BATTERY_FLOOR = 13;
245+
const SELF_TEST_BATTERY_FLOOR = 14;
216246

217247
// The key an assertion is filed under when no battery is open. It is not a
218248
// declared battery, so it reds by the same set difference rather than silently
@@ -282,8 +312,11 @@ function batteryFloorFailures() {
282312
// Constants
283313
// ---------------------------------------------------------------------------
284314

285-
/** See "Where the retry budget comes from" in the header — 7m07s observed, 15m budgeted. */
286-
export const RETRY_BUDGET_MS = 15 * 60 * 1000;
315+
/**
316+
* See "Where the retry budget comes from" in the header — spreads of 7m07s
317+
* (17.3.0) and 16m22s (18.0.0) observed; 35m budgeted, the 17.3.0 margin over 18.0.0.
318+
*/
319+
export const RETRY_BUDGET_MS = 35 * 60 * 1000;
287320

288321
/** First wait after the first round comes back short. */
289322
export const FIRST_DELAY_MS = 5 * 1000;
@@ -697,10 +730,21 @@ export function formatSuccess(result) {
697730
// ---------------------------------------------------------------------------
698731

699732
/**
700-
* @param {{ argv?: string[], env?: NodeJS.ProcessEnv }} [options]
733+
* `probe` and `sleep` are the verifier's own seams, threaded through so the
734+
* self-test pins THIS function's exit code and its `::error::` naming on a
735+
* replayed clock — no network, no real wait — under the production budget.
736+
*
737+
* @param {{ argv?: string[], env?: NodeJS.ProcessEnv,
738+
* probe?: (name: string, version: string) => Promise<boolean>,
739+
* sleep?: (ms: number) => Promise<void> }} [options]
701740
* @returns {Promise<number>} process exit code
702741
*/
703-
export async function main({ argv = process.argv.slice(2), env = process.env } = {}) {
742+
export async function main({
743+
argv = process.argv.slice(2),
744+
env = process.env,
745+
probe = (name, version) => probeVersion(name, version),
746+
sleep = realSleep,
747+
} = {}) {
704748
const probeMode = argv.includes('--probe');
705749
const what = probeMode ? 'probe the fixed group' : 'verify the publish';
706750
if (probeMode && argv.includes('--dry-run')) {
@@ -733,7 +777,7 @@ export async function main({ argv = process.argv.slice(2), env = process.env } =
733777
}
734778

735779
if (probeMode) {
736-
const answer = await probeGroup({ targets, probe: (name, version) => probeVersion(name, version) });
780+
const answer = await probeGroup({ targets, probe });
737781
// stdout is the answer and nothing else; the log line goes to stderr.
738782
process.stdout.write(`${JSON.stringify({ version: env.RELEASE_VERSION, ...answer })}\n`);
739783
console.error(formatProbe(env.RELEASE_VERSION, answer));
@@ -753,7 +797,8 @@ export async function main({ argv = process.argv.slice(2), env = process.env } =
753797

754798
const result = await verifyAllPublished({
755799
targets,
756-
probe: (name, version) => probeVersion(name, version),
800+
probe,
801+
sleep,
757802
log: (line) => console.log(line),
758803
});
759804

@@ -1381,6 +1426,18 @@ export async function selfTest() {
13811426
const OBSERVED_SPREAD_MS = (7 * 60 + 7) * 1000;
13821427
t('the budget outlasts the 7m07s spread measured on the 17.3.0 burst', RETRY_BUDGET_MS > OBSERVED_SPREAD_MS, `${RETRY_BUDGET_MS} > ${OBSERVED_SPREAD_MS}`);
13831428

1429+
// The 18.0.0 reading: the first read at 12:41:37Z, `console` committed at
1430+
// 12:57:59.327Z. The budget has to sit through it with the margin 17.3.0
1431+
// was given (900 s over 427 s), not merely outlast it.
1432+
const SPREAD_18_0_0_MS = Date.parse('2026-10-20T12:57:59.327Z') - Date.parse('2026-10-20T12:41:37Z');
1433+
const MARGIN_17_3_0 = (15 * 60 * 1000) / OBSERVED_SPREAD_MS;
1434+
t('the budget outlasts the 16m22s spread measured on the 18.0.0 burst', RETRY_BUDGET_MS > SPREAD_18_0_0_MS, `${RETRY_BUDGET_MS} > ${SPREAD_18_0_0_MS}`);
1435+
t(
1436+
'and keeps the 17.3.0 margin (2.11x) over it',
1437+
RETRY_BUDGET_MS >= SPREAD_18_0_0_MS * MARGIN_17_3_0,
1438+
`${RETRY_BUDGET_MS} >= ${Math.round(SPREAD_18_0_0_MS * MARGIN_17_3_0)} (${(RETRY_BUDGET_MS / SPREAD_18_0_0_MS).toFixed(2)}x)`,
1439+
);
1440+
13841441
const tiny = backoffDelays(7000, { firstMs: 5000, capMs: 60000 });
13851442
t('a budget smaller than two waits is truncated, not overspent', tiny.reduce((a, b) => a + b, 0) === 7000, JSON.stringify(tiny));
13861443
}
@@ -2214,6 +2271,127 @@ export async function selfTest() {
22142271
}
22152272
}
22162273

2274+
// ── 14. The 18.0.0 window ─────────────────────────────────────────────────
2275+
battery('14. The 18.0.0 publish window: console readable at +16m22s, past the old 900 s');
2276+
{
2277+
// npm's own `time` field for 18.0.0 (run 39421537775, job 118624531794):
2278+
// the first package of the group, the anchor, the largest tarball, and the
2279+
// package the 900 s budget ran out on, readable 16m22s after the first read.
2280+
const STARTED = Date.parse('2026-10-20T12:41:37Z');
2281+
const LANDED = {
2282+
'@objectstack/sdui-parser': '2026-10-20T12:40:32.440Z',
2283+
'@objectstack/cli': '2026-10-20T12:44:06.409Z',
2284+
'@objectstack/spec': '2026-10-20T12:51:40.837Z',
2285+
'@objectstack/console': '2026-10-20T12:57:59.327Z',
2286+
};
2287+
const CONSOLE = '@objectstack/console';
2288+
const GROUP = Object.keys(LANDED).sort().map((name) => ({ name, version: '18.0.0' }));
2289+
// npm's clock, driven by the stub sleep: a round reads the registry at
2290+
// STARTED plus every wait taken so far. Probing is taken as instant, so
2291+
// each round reads no later than the real step's did — a green here is
2292+
// one the real step would also have seen.
2293+
const replay = (landed) => {
2294+
const waits = [];
2295+
const reads = [];
2296+
let waited = 0;
2297+
return {
2298+
waits,
2299+
reads,
2300+
sleep: async (ms) => { waits.push(ms); waited += ms; },
2301+
probe: async (name) => {
2302+
const now = STARTED + waited;
2303+
reads.push({ name, now });
2304+
return landed[name] !== undefined && now >= Date.parse(landed[name]);
2305+
},
2306+
};
2307+
};
2308+
const consoleReads = (r) => r.reads.filter((x) => x.name === CONSOLE).map((x) => x.now);
2309+
const at = (ms) => new Date(ms).toISOString();
2310+
2311+
// OLD BUDGET — 900 s, as a parameter: the run's own red, reproduced.
2312+
const old = replay(LANDED);
2313+
const oldResult = await verifyAllPublished({ targets: GROUP, probe: old.probe, sleep: old.sleep, budgetMs: 15 * 60 * 1000 });
2314+
t('OLD BUDGET (900 s) reds on 18.0.0, a release that WAS complete', oldResult.ok === false, JSON.stringify(oldResult.missing));
2315+
t('...naming @objectstack/console@18.0.0 and nothing else', oldResult.missing.length === 1 && oldResult.missing[0].name === CONSOLE, JSON.stringify(oldResult.missing));
2316+
t('...after 19 rounds over 900 s, as the run\'s annotation reads', oldResult.rounds === 19 && oldResult.waitedMs === 900_000, `rounds=${oldResult.rounds} waited=${oldResult.waitedMs}`);
2317+
t(
2318+
'...its last read of console came BEFORE console landed',
2319+
Math.max(...consoleReads(old)) < Date.parse(LANDED[CONSOLE]),
2320+
`${at(Math.max(...consoleReads(old)))} < ${LANDED[CONSOLE]}`,
2321+
);
2322+
2323+
// THIS BUDGET — the production default, never passed in, so restoring the
2324+
// old constant reds the cases below.
2325+
const now = replay(LANDED);
2326+
const result = await verifyAllPublished({ targets: GROUP, probe: now.probe, sleep: now.sleep });
2327+
t('THIS BUDGET sits through the 16m22s spread and goes GREEN', result.ok === true, JSON.stringify(result));
2328+
t('...on a round the old budget never reached', result.waitedMs > 900_000, `waited=${result.waitedMs}`);
2329+
const reads = consoleReads(now);
2330+
t(
2331+
'...the FIRST round after console landed, not by spending the budget',
2332+
reads.length >= 2 && reads[reads.length - 2] < Date.parse(LANDED[CONSOLE]) && reads[reads.length - 1] >= Date.parse(LANDED[CONSOLE])
2333+
&& result.waitedMs < RETRY_BUDGET_MS,
2334+
reads.slice(-2).map(at).join(' .. '),
2335+
);
2336+
t('...at +17m15s, the figure the header quotes', result.waitedMs === (17 * 60 + 15) * 1000, `waited=${result.waitedMs}`);
2337+
2338+
// main() itself, over a fixture workspace of the same four packages: its
2339+
// exit code and its `::error::` line are what the publish step reads.
2340+
const ws = mkdtempSync(join(tmpdir(), 'release-verify-npm-18-'));
2341+
try {
2342+
writeFileSync(join(ws, 'pnpm-workspace.yaml'), "packages:\n - 'packages/*'\n");
2343+
for (const { name, version } of GROUP) {
2344+
const dir = join(ws, 'packages', name.split('/')[1]);
2345+
mkdirSync(dir, { recursive: true });
2346+
writeFileSync(join(dir, 'package.json'), `${JSON.stringify({ name, version }, null, 2)}\n`);
2347+
}
2348+
const runMain = async (landed) => {
2349+
const r = replay(landed);
2350+
const out = [];
2351+
const err = [];
2352+
const { log, error } = console;
2353+
console.log = (...a) => { out.push(a.join(' ')); };
2354+
console.error = (...a) => { err.push(a.join(' ')); };
2355+
let code;
2356+
try {
2357+
code = await main({ argv: ['--root', ws], env: { RELEASE_VERSION: '18.0.0' }, probe: r.probe, sleep: r.sleep });
2358+
} catch (e) {
2359+
code = `threw: ${e instanceof Error ? e.message : String(e)}`;
2360+
} finally {
2361+
console.log = log;
2362+
console.error = error;
2363+
}
2364+
return { code, out: out.join('\n'), err: err.join('\n'), waits: r.waits };
2365+
};
2366+
2367+
const pass = await runMain(LANDED);
2368+
t('main() exits 0 on the 18.0.0 replay, under the production budget', pass.code === 0, `code=${pass.code} ${pass.err.split('\n')[0]}`);
2369+
t('...and its notice says the whole group settled', /::notice::all 4 published package\(s\) are readable on npm/.test(pass.out), pass.out.split('\n').slice(-1)[0]);
2370+
2371+
const withoutConsole = Object.fromEntries(Object.entries(LANDED).filter(([name]) => name !== CONSOLE));
2372+
const absent = await runMain(withoutConsole);
2373+
const annotation = absent.err.split('\n')[0];
2374+
t('ABSENT: main() exits NON-ZERO when console never lands', absent.code === 1, `code=${absent.code}`);
2375+
t(
2376+
'...its ::error:: line names @objectstack/console@18.0.0, 1 of 4',
2377+
annotation.startsWith('::error::') && annotation.includes(`${CONSOLE}@18.0.0`) && annotation.includes('1 of 4'),
2378+
annotation.slice(0, 160),
2379+
);
2380+
t(
2381+
'...and names none of the three that landed',
2382+
!['@objectstack/cli@', '@objectstack/spec@', '@objectstack/sdui-parser@'].some((n) => absent.err.includes(n)),
2383+
absent.err.split('\n').filter((l) => l.includes('@objectstack/')).join(' | '),
2384+
);
2385+
t(
2386+
'...concluded only after the WHOLE budget was spent',
2387+
absent.waits.reduce((a, b) => a + b, 0) === RETRY_BUDGET_MS,
2388+
`${absent.waits.reduce((a, b) => a + b, 0)} of ${RETRY_BUDGET_MS}`,
2389+
);
2390+
} finally {
2391+
rmSync(ws, { recursive: true, force: true });
2392+
}
2393+
}
2394+
22172395
// The floor runs BEFORE the verdict below, so a success line can only be
22182396
// printed by a run in which every declared battery registered its cases.
22192397
for (const message of batteryFloorFailures()) cases.push({ name: message, ok: false });
@@ -2226,7 +2404,8 @@ export async function selfTest() {
22262404
}
22272405
console.log(
22282406
`OK release-verify-npm self-test: ${cases.length} cases pass across `
2229-
+ `${Object.keys(SELF_TEST_BATTERIES).length} batteries (the #15321 false red reproduced and absorbed, `
2407+
+ `${Object.keys(SELF_TEST_BATTERIES).length} batteries (the #15321 false red reproduced and absorbed, the 18.0.0 `
2408+
+ '16m22s spread red under the old 900 s and green under the production budget, '
22302409
+ 'the masked partial publish caught, absence still fatal, the release audit backfilling '
22312410
+ 'only a version whose whole group is on npm and whose publish and image build are not in flight, and that backfill built '
22322411
+ 'from the version commit\'s tree).',

0 commit comments

Comments
 (0)