Skip to content

ci: let the consumer SBOM outlast the registry it waits on - #716

Merged
allxsmith merged 13 commits into
mainfrom
ci/sbom-release-race
Sep 22, 2026
Merged

allxsmith merged 13 commits into
mainfrom
ci/sbom-release-race

Conversation

@allxsmith

@allxsmith allxsmith commented Sep 21, 2026 •

Copy link
Copy Markdown
Owner

Every release-triggered supply-chain run has failed since bestax-migrate@2.3.2, each time on the one matrix leg whose version the release names. The scheduled runs pass, which is why this stayed quiet.

What was actually wrong

Not the decision logic. On the eslint-plugin release the step resolved spec=@allxsmith/eslint-plugin-bestax@1.0.1 and expect=1.0.1 correctly, then every npm install returned ETARGET / No matching version found and the leg gave up about half a minute after the publish. The step's own comment already named this hazard ("asking for an exact version seconds after npm publish can 404 while the packument propagates"); what it had was a retry too small to survive it.

Two separate defects:

npm was not being re-asked. The logs show the identical ETARGET at each interval. npm caches the packument it fetched on the way in, so each attempt could re-read the same pre-publish document out of ~/.npm. Sleeping between attempts that all consult one cached document only proves the cache agrees with itself, so --prefer-online is what makes a retry mean anything here.

The budget was measured against the wrong clock. The release event fires when the GitHub release is created; the version becomes installable when the registry finishes serving it. Nothing in this job can hurry that, so a fixed attempt count is the wrong shape. It is now a deadline.

Scope

Only the released leg ever reached this path. The legs no release names resolve latest, which always had something to resolve, so they succeed on the first attempt and never approach the deadline.

Per .github/CLAUDE.md

Middle row: workflow logic. I1 is untouched, since this job holds no model-auth token and runs no model. I2 is untouched: no new trigger, no change to permissions:, no new identity, and nothing new leaves the job. The only egress is registry.npmjs.org, already allow-listed, so no allowlist widens. The change is confined to one step's retry policy.

Where the retry policy lives

It started as a shell loop in the step. It is now scripts/npm-install-retry.mjs with a node --test sibling, per rule 9 of .github/CLAUDE.md, and the reason is specific to this job rather than general tidiness: consumer-sbom runs on release, schedule and workflow_dispatch, so no PR event reaches it, and the pinned path fires only on a real release. A test sibling is not one more check here, it is the only check. The version this replaces had none, which is how it stayed broken on every release-triggered run for over a week.

Every outside dependency is injected — runner, clock, sleep, log — so the suite reaches neither the network nor a subprocess, and the budget costs no time however large it is set.

Verification

  • Mutation-tested rather than asserted. Each of these changes the code and kills a named case: dropping --prefer-online from the exported list, dropping it at the call site, replacing the deadline with an attempt count, removing the overshoot guard, removing forLog from the retry line, making every spawn error fatal, and accepting a zero budget.
  • End to end, as the workflow invokes it. Run from the consumer directory with --dir, it exits 0, installs the pinned version, and leaves package-lock.json where the cp on the next line expects it.
  • --prefer-online confirmed a real npm config key (npm config get prefer-online), not an accepted-and-ignored flag.
  • A version that will never exist returns the same ETARGET / No matching version found as one that has not propagated, so no fail-fast heuristic can separate them. That is why this waits rather than guessing, and why the exhaustion message names the release step.
  • pnpm all green; conformance 22/22 including fragile-prose; prettier and eslint clean; the root node --test "scripts/*.test.mjs" glob picks the new file up with no wiring.

The syft path beyond the install still only runs on release/schedule/workflow_dispatch, so the next release is still the only instrument for the propagation budget itself.

Found while reviewing this, filed separately

#719 — verify-provenance installs its roster by bare name on the same release trigger, with no pin and no read-back, so a stale packument means it verifies the previous release's attestation and goes green. Worse than the bug this PR fixes, because asking for an exact version and failing is loud while latest always resolves to something. Out of scope here: it is a behaviour change to the provenance gate, and this PR's job is to stop every release-triggered run going red.

Summary by CodeRabbit

  • Bug Fixes

    • Improved package installation reliability by retrying failed installations over a time-based window.
    • Installations now prefer the latest online package metadata and provide clearer errors when retries are exhausted or installation cannot start.
  • Tests

    • Added comprehensive coverage for retry timing, validation, failure handling, command execution, logging, and workflow-safety protections.

Every release-triggered supply-chain run has failed since bestax-migrate
2.3.2, each time on the one matrix leg whose version the release names.
The decision logic was right every time: the step resolved
`@allxsmith/eslint-plugin-bestax@1.0.1` and `expect=1.0.1` correctly, then
`npm install` returned ETARGET three times over half a minute and the leg
gave up.

Two things were wrong with the retry, and the step's own comment already
predicted the failure it could not survive.

`--prefer-online`, because npm caches the packument it fetched on the way
in. Every attempt could re-read the same pre-publish document out of
`~/.npm` and fail identically, which is what the logs show: the same
ETARGET at each interval, no new registry read. Sleeping between attempts
that all consult one cached document only proves the cache agrees with
itself.

A deadline instead of a fixed attempt count, because the thing being
waited on is not this job. The `release` event fires when the GitHub
release is created, and the version becomes installable when the registry
finishes serving it. Nothing here can hurry that, so the budget has to be
generous rather than clever. The old one spent well under a minute.

The legs no release names are unaffected: they resolve `latest`, which
always had something to resolve, so they still succeed on the first
attempt and never reach the deadline.
@coderabbitai

coderabbitai Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

Warning

Review limit reached

Next included review available in 18 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Repository: allxsmith/bestax/.coderabbit.yaml

Review profile: CHILL

Plan: Advanced

Run ID: 30b8b296-7590-445d-980f-2157922ea6b8

📥 Commits

Reviewing files that changed from the base of the PR and between fc38a68 and 81407fd.

📒 Files selected for processing (3)
  • .github/workflows/supply-chain.yml
  • scripts/npm-install-retry.mjs
  • scripts/npm-install-retry.test.mjs

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: allxsmith/bestax/.coderabbit.yaml

Review profile: CHILL

Plan: Advanced

Run ID: e4f80942-5643-4b45-9541-f058f1cd71d5

📥 Commits

Reviewing files that changed from the base of the PR and between c703dcd and fc38a68.

📒 Files selected for processing (3)
  • .github/workflows/supply-chain.yml
  • scripts/npm-install-retry.mjs
  • scripts/npm-install-retry.test.mjs

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


Walkthrough

The consumer SBOM workflow now calls a dedicated Node.js script for npm installation. The script uses duration-based retries, online package resolution, validated arguments, distinct exit codes, and injected test dependencies.

Changes

npm install retry flow

Layer / File(s) Summary
Retry script implementation
scripts/npm-install-retry.mjs
Adds duration-based npm retries, argument parsing, npm process handling, logging, and exit codes.
Workflow integration
.github/workflows/supply-chain.yml
Replaces the inline three-attempt shell loop with the retry script invocation.
Retry behavior validation
scripts/npm-install-retry.test.mjs
Adds coverage for retry timing, validation, npm flags, failures, diagnostics, and log sanitization.

Priority: ⬇️ Low

Estimated code review effort: 3 (Moderate) | ~30 minutes

Change: Bug fix

Sequence Diagram(s)

sequenceDiagram
  participant consumer-sbom
  participant npm-install-retry.mjs
  participant npm
  consumer-sbom->>npm-install-retry.mjs: pass package spec and consumer directory
  npm-install-retry.mjs->>npm: install with --prefer-online and --ignore-scripts
  npm-->>npm-install-retry.mjs: return installation status
  npm-install-retry.mjs->>npm: retry within the configured budget
  npm-install-retry.mjs-->>consumer-sbom: return exit code
Loading

Merge Risk: ⚪ Minimal · up to fc38a

The consumer SBOM workflow now uses bounded online npm retries, with coverage for failure and logging paths; it is ready to merge.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description provides a detailed and relevant explanation of the failure, implementation, scope, testing, and follow-up issue. However, it does not use the required template sections or provide the… Reformat the description using the repository template. Add the affected package or explicitly select Other, complete the Related Issue(s) and Type of Change sections, mark each applicable Checklist item, and include the Screenshots / Demos…
✅ Passed checks (4 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 83.33% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 6 functions across 2 files. (1 skipped: 1 u…
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly identifies the main change: allowing the consumer SBOM workflow to tolerate registry delays. It is concise and specific.
Full details: Description check

Explanation

The description provides a detailed and relevant explanation of the failure, implementation, scope, testing, and follow-up issue. However, it does not use the required template sections or provide the required package selection, related-issue fields, type-of-change selection, checklist responses, or screenshots/additional-context headings.

Resolution

Reformat the description using the repository template. Add the affected package or explicitly select Other, complete the Related Issue(s) and Type of Change sections, mark each applicable Checklist item, and include the Screenshots / Demos and Additional Context sections with Not applicable where appropriate.

✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

Preview Deployment

Preview URL: https://6f785e5d.bestax.pages.dev

@allxsmith

Copy link
Copy Markdown
Owner Author

deep-review: fresh — please read ee6a9e6

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deep review — 0 blocking · 3 advisory

No. Severity Area Finding Location
1 🔵 Advisory Robustness A non-propagation npm install failure (a version that never published, a registry outage, a broken tarball) now burns the full 600 s budget on that leg instead of failing in ~31 s. Accepted cost — fail-fast: false keeps the siblings independent, and the job sets no timeout-minutes, so the 360-minute default is nowhere near — but a genuinely broken released leg now reports red ~10 minutes later than it used to. .github/workflows/supply-chain.yml:410
2 🔵 Advisory Security Out of this diff, surfaced while tracing the failure class: the install list in verify-provenance names four packages. @allxsmith/eslint-plugin-bestax is published (dist-tags.latest = 1.0.1) and is in the consumer-SBOM matrix of this same file, so it gets an SBOM but its attestation is never checked — exactly the drift the comment on that step already warns about. Worth its own issue. .github/workflows/supply-chain.yml:1043
3 🔵 Advisory Robustness Point-in-time evidence in the new comment — "The budget was well under a minute when every release-triggered run began failing at this step" — move to the issue and link it. .github/workflows/supply-chain.yml:408

Overall: The change is sound, and the diagnosis holds up against the logs rather than only against the diff. I confirmed the failure shape in job 106176103562 (@allxsmith/eslint-plugin-bestax@1.0.1): three identical ETARGET / No matching version found inside 31 s (00:23:47 to 00:24:18), then failed three times. The cache diagnosis is right — without --prefer-online npm fetches with cache: default, which honours the max-age on the packument, so all three attempts inside that window could be answered from ~/.npm with no second registry read; --prefer-online is documented as forcing staleness checks even for fresh package data, which is what the comment claims and no more. The loop is correct under the default bash -e -o pipefail: the until condition is exempt from errexit, attempt=$(( attempt + 1 )) is an assignment and so exits 0 (not (( attempt++ )), which would have killed the step on the first pass), and the if yields 0 when the test is false. deadline=$(( SECONDS + 600 )) is captured once, SECONDS is a bash builtin, and the step sets no shell: override. The riskiest part is unavoidable: the syft path beyond this install runs only on release, schedule and workflow_dispatch, so the next release is the only real proof. Send the human to row 2 first — a live gap, unrelated to this PR.

Residual risk:

  • The other four matrix legs — refuted. They resolve latest, which always resolves. I checked the failing-job list on five runs across the window (35547552745, 35548900387, 35510390330, 35052264412, 35547462874): in each one, exactly the leg whose version the release names failed, and only that leg.
  • --prefer-online guarantees a revalidation request, not a fresh document — the CDN in front of the registry can answer the conditional request with 304 while the edge still holds the pre-publish packument. Open, but covered by the other half of the change: the 600 s deadline is what actually outlasts the CDN, and the comment says "revalidate against the registry" rather than claiming freshness, so it does not overstate its mechanism.
  • Tarball not yet served even though the packument is — refuted as a new risk. The fetch-retries built into npm handle the transient case, and a hard failure falls out of npm install into this loop, which now has the budget to outlast it. Strictly better than three attempts.
  • A partial tree from a failed attempt poisoning a later one — refuted. ETARGET aborts during ideal-tree resolution, before reify writes the lockfile, and the --expect read-back in consumer-sbom-meta.mjs stamp still fails the leg if the installed version is not the one the release names.
  • The sibling registry consumer in this file, verify-provenance — refuted as this failure class. It installs by bare name with no version, so it has nothing to wait on; the one time it failed (2026-09-11) is outside this window and is a different job. The roster gap is row 2, not a propagation race.
  • Trust boundary — nothing on the exposure axis. No new trigger, no permissions: change, no new identity, no new allowed-endpoints entry (registry.npmjs.org:443 was already allow-listed and is still the only host this step reaches), and nothing new leaves the job. Middle row, workflow logic only.
  • Gates — pnpm check:conformance is green across all 22 rules including fragile-prose; the added lines are whitespace-clean (no tabs, no trailing space, block-scalar indentation intact); nothing in the tree referenced the removed failed three times string. The --prefer-online precedent already exists here, in visual-regression.yml via commit 1f2e15d.

🏄 Total mellow set here. The old retry was paddling hard against its own cache and wondering why the wave never came — now it just waits out the registry with a real deadline and a flag that actually re-asks. Clean lines, no drop-ins, good to go.

The sentence explaining why the budget grew carried its reason but not
its evidence. Every other comment in this step hands a reader an issue
number for the history (#412, #526, #530); this one paraphrased a run
list instead.

The claim itself stays. It is past tense and stays true: the budget was
well under a minute, and the release-triggered runs did begin failing
here. What it was missing is the hop.
@allxsmith

Copy link
Copy Markdown
Owner Author

Round 1: one row already fixed elsewhere, one refuted, one that had a real improvement in it.

Row 3 — partially taken. The sentence stays, because it is past tense and stays true: the budget was well under a minute, and runs did begin failing at this step. That does not decay the way a count or a run id does, and it is the rationale for the change rather than a measurement of the current tree. What was genuinely missing is the hop to the evidence, which is how the rest of this file works (#412, #526, #530 all sit in comments right here). It now reads ... began failing at this step (#716), so the logs, the run list and the diagnosis are one reference away instead of paraphrased in the comment.

Row 1 — refuting the actionable half, accepting the cost. The premise is that a non-propagation failure now waits out the full budget, and for a "version that never published" that is true but unavoidable: it is the same error. A version that will never exist returns exactly what a not-yet-propagated one returns:

$ npm install --prefer-online --ignore-scripts '@allxsmith/eslint-plugin-bestax@99.0.0'
npm error code ETARGET
npm error notarget No matching version found for @allxsmith/eslint-plugin-bestax@99.0.0.

That is byte-identical in shape to the 1.0.1 failure this PR fixes. The packument resolves and the version is absent, and nothing in the error says whether it is absent forever or absent for another minute. So no fail-fast heuristic can separate them, which is precisely why a deadline is the right shape and why the error message names the release step as the place to look.

For the failures that are distinguishable — a registry outage, a connection reset — waiting is the correct response rather than a cost, since those recover. npm's own fetch-retries handles the transient layer underneath this loop.

So the ten-minute delay on a genuinely dead release is real and I am accepting it, not designing it away. The reason it is affordable: fail-fast: false keeps the siblings independent, the job sets no timeout-minutes against the 360-minute default, and this fires per release event rather than per PR.

Row 2 — already fixed, in #717. That roster gap is hunk 5 of #706, which #709 auto-closed while the hunk was still outstanding. #706 is reopened and #717 closes it. It was deferred rather than missed: verify-attestation.mjs fails closed, so the roster entry could not land while the only registry version was the hand-published 0.0.0-development placeholder that no laptop publish can attest. That precondition is now met and I verified it against the real gate rather than reasoning about it:

verify-attestation: checking 5 package(s): @allxsmith/bestax-bulma, @allxsmith/eslint-plugin-bestax, bestax-mcp, bestax-migrate, create-bestax
ok: @allxsmith/eslint-plugin-bestax@1.0.1 — built by https://github.com/allxsmith/bestax
verify-attestation: 5 package(s) check out

Two notes on the verification in the overall, both of which I agree with and want to underline rather than let pass as flattery. The 304-from-the-CDN point is the right read: --prefer-online buys a revalidation request, not a fresh document, so the deadline is what actually outlasts a stale edge. And the --expect read-back is the reason a stale answer cannot silently produce a wrong SBOM, which is worth more than the flag is.

@allxsmith

Copy link
Copy Markdown
Owner Author

deep-review: fresh — please read 7d60f84

@github-actions

Copy link
Copy Markdown
Contributor

Preview Deployment

Preview URL: https://78e4de72.bestax.pages.dev

@allxsmith

Copy link
Copy Markdown
Owner Author

deep-review: fresh — please read 7d60f84

@allxsmith

Copy link
Copy Markdown
Owner Author

deep-review: fresh — please read 7d60f84

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deep review — 0 blocking · 3 advisory

# Severity Area Finding Location
1 🔵 Advisory Robustness A failure that retrying can never fix — a version that never published, a broken tarball, a registry outage — now costs the full 600 s on that leg instead of ~31 s. sign-sbom and attach-sbom both needs: this job, and needs waits for completion, not success, so a release's SBOMs now attach up to ten minutes later when any one leg is stuck. Accepted cost of trading a count for a deadline; fail-fast: false keeps the siblings independent and no timeout-minutes is anywhere near. .github/workflows/supply-chain.yml:410
2 🔵 Advisory Correctness Out of this diff, found while enumerating the same failure class in this file: verify-provenance installs its roster by bare name — no version, no --prefer-online, no retry — on the same release trigger. Right after a publish that is exactly the "CDN-cached packument resolves the PREVIOUS version" hazard the consumer-sbom comment names above the Decide which version to install step, and here it is silent: the job verifies the previous release's attestation and goes green without ever checking the tarball the release is attached to. verify-attestation.mjs budgets [5000, 10000] ms for this same propagation lag — half the budget this PR just declared too small. Worth its own issue. .github/workflows/supply-chain.yml:1042
3 🔵 Advisory Coverage The retry policy stays untested YAML. The repo's other registry-race handler went the other way — verify-attestation.mjs + lib/fetch-retry.mjs, extracted under rule 9 with a node --test sibling, and its header records that it replaced a shell loop spending "three attempts for exactly this". This loop's first version was wrong across every release run in the window with nothing able to catch it. .github/workflows/supply-chain.yml:409

Overall: The change is sound, and the diagnosis survives the logs rather than only the diff. I pulled job 106176103562 myself: spec=@allxsmith/eslint-plugin-bestax@1.0.1 / expect=1.0.1 resolved correctly, then three identical ETARGET / No matching version found between 00:23:48 and 00:24:18 — 31 s — and failed three times. I also read the failing-job list on 35548900387, 35547388242 and 35547552745: in each, exactly the leg whose version the release names failed, and only that leg, so the latest legs really are untouched by this path. The cache half of the diagnosis holds up structurally — the runner starts with an empty ~/.npm, attempt 1 populates it, and attempts 2 and 3 land inside the packument's max-age, so no second registry read was guaranteed; --prefer-online forces the staleness check that makes a retry mean anything. The loop is correct under the default bash -e: until's condition is exempt from errexit, attempt=$(( attempt + 1 )) is an assignment and so exits 0 (unlike (( attempt++ )), which would have killed the step on the first pass), a false if with no else yields 0, and deadline is captured once from bash's own SECONDS. I also confirmed the release ordering the whole fix depends on: in every release.config.js the pnpm-publish plugins sit before @semantic-release/github, so the GitHub release really is created after npm publish returns — this is propagation lag, not a pipeline that publishes after it announces. Send the human to row 2 first: it is a live silent pass in this same file, and unrelated to this PR.

Residual risk:

  • The other four matrix legs — refuted, on the run data rather than on the argument: only the release-named leg ever entered this path, in every run I checked.
  • --prefer-online buys a revalidation request, not a fresh document — open, and correctly not claimed otherwise. A CDN can answer the conditional request from the same pre-publish edge copy. The deadline is the half of the change that actually outlasts that, and the comment says "revalidate against the registry" rather than claiming freshness, so it does not overstate its mechanism.
  • The budget is a guess, not a measurement — open by construction, and named as such in the comment ("generous rather than clever"). No run has ever observed how long this propagation actually takes, because every one gave up at 31 s. The next release is the only instrument.
  • A hang rather than a failure — open, and unchanged by this diff. The deadline is only read between attempts, so an npm install that never returns is bounded by the job's default timeout, not by the budget. Pre-existing; the three-attempt loop had the same hole.
  • A partial tree poisoning a later attempt — refuted. ETARGET aborts during ideal-tree resolution, before reify writes a lockfile, and the --expect read-back in consumer-sbom-meta.mjs stamp still fails the leg if the installed version is not the one the release names.
  • The --prefer-online precedent — a correction for the record, because my own earlier pass on this PR cited it wrongly. 1f2e15d did add the flag to visual-regression.yml, but it is not there today: 3460241f removed it when that workflow moved to pnpm. It was never reverted for misbehaving, so the precedent still supports the change — it just no longer exists in the tree.
  • Trust boundary — nothing on the exposure axis. No new trigger, no permissions: change, no new identity, no new allowed-endpoints entry (registry.npmjs.org:443 was already listed and is still the only host this step reaches), and nothing new leaves the job. Middle row, workflow logic only; I1 and I2 are untouched, as the PR body says.
  • Gates — Build and Test is green on the head commit, which is what runs check:conformance including fragile-prose. Nothing in the tree referenced the removed failed three times string. One PR-body imprecision for the maintainer, not worth a row of its own: "the leg gave up about half a minute after the publish" measures 31 s from the step starting, not from the publish — the release event had already fired before the step, and the publish before that, so the real window this failed inside is longer than the body implies. That only makes the new budget look more justified, not less.
  • The previous pass's advisory on point-in-time evidence — settled by the head commit, which added the (#716) hop that "evidence goes on the issue and is linked" asks for. The surviving clause is past tense and true, so re-litigating it would be reversing an applied suggestion.

🏄 Total mellow set here. The old retry was paddling hard against its own cache and wondering why the wave never showed — now it re-asks the registry for real and waits the swell out on an honest deadline instead of a countdown nobody measured. Clean lines, no drop-ins, good to go.

Rule 9 of .github/CLAUDE.md puts logic worth testing outside YAML, and
this logic earned it the hard way: the shell loop was wrong in two ways
at once and stayed wrong on every release-triggered run for over a week,
because nothing existed that could fail when it was wrong.

That is not incidental to this job. consumer-sbom runs on release,
schedule and workflow_dispatch, so no PR event reaches it, and the
pinned-install path fires only on a real release. A test sibling is not
one more check here, it is the only check.

scripts/npm-install-retry.mjs holds the policy with every outside
dependency injected, so the budget, the stopping condition and the log
can be driven without a registry, a subprocess or a real wait. The
assertions are about what the policy spends and when it stops rather
than wall-clock time, and one of them fails for any policy that counts
attempts instead of reading a clock, which is the regression that
started this.

Writing them found a real wart: a budget shorter than one interval slept
a full interval anyway, to make an attempt whose result the loop had
already decided to discard. So a leg that waiting could never fix
reported its failure an interval later than it knew it. The stopping
condition now refuses a wait that would land past the deadline.

Two things the extraction buys beyond testability. The spec reaches npm
as an argument array rather than a shell word, so nothing gives it a
second reading. And the exhaustion message runs through forLog, which is
what that helper's own header asks for: the message is printed as an
::error:: workflow command, a workflow command ends at a newline, and
the spec arrives from argv.
@allxsmith

Copy link
Copy Markdown
Owner Author

Round 2: one taken as written, one filed as its own issue, one accepted.

Row 3 — taken. This was the strongest finding in either round, and the argument is the same one I used in #709 to justify consumer-sbom-meta.test.mjs, so I could hardly refuse it here. My only counter would have been a stub npm I ran locally and did not commit, which is worth nothing to the next person.

The policy now lives in scripts/npm-install-retry.mjs with a node --test sibling, every outside dependency injected so the budget, stopping condition and log can be driven without a registry, a subprocess or a real wait. The assertions are about what the policy spends and when it stops rather than wall-clock time, and one of them fails for any policy that counts attempts instead of reading a clock, which is precisely the regression that started this.

Writing them found a real wart, which is the best argument for the finding that I can offer. A budget shorter than one sleep interval slept a full interval anyway, to make an attempt whose result the loop had already decided to discard: a leg that waiting can never fix reported its failure an interval later than it knew. The stopping condition now refuses a wait that would land past the deadline. That was invisible in YAML and fell out of the first test case that set a small budget.

Two things the extraction bought beyond testability. The spec reaches npm as an argument array rather than a shell word. And the exhaustion message goes through forLog, which is what that helper's own header asks for: the message prints as an ::error:: workflow command, a workflow command ends at a newline, and the spec arrives from argv. The YAML it replaces interpolated $SPEC raw into that line — guarded upstream by assertVersion, so not a live hole, but guarded in one place rather than two.

End to end, as the workflow actually calls it: run from the consumer directory with --dir, it exits 0, installs 1.0.1, and leaves package-lock.json where the cp on the next line expects it.

Row 2 — confirmed and filed as #719. I checked the claim rather than taking it: verify-provenance has no if: and no needs:, fires on release, and installs bare names with no pin and no read-back. So a stale packument means it resolves the previous version, verify-attestation.mjs validates that older tarball honestly, and the job goes green having never opened the tarball the release is attached to.

Worth naming why this one is worse than the bug this PR fixes: #716 was loud, because asking for an exact version and not getting it is an error. Asking for latest always resolves to something, so there is nothing to notice.

The issue also records the asymmetry you flagged, that ATTESTATION_BACKOFF_MS = [5000, 10000] is waiting out attestation lag rather than packument lag, so whatever fixes #719 should not assume the two are the same size. And one thing not in your row: the pin should be conditional on the event, since on schedule and workflow_dispatch there is no release to name and latest is genuinely the right question.

It stays out of this PR because it is a behaviour change to the provenance gate, and this PR's job is to stop every release-triggered run going red.

Row 1 — accepted, including the part I had not considered. The needs:-waits-for-completion consequence is new to me and correct: sign-sbom and attach-sbom both wait on this job, so a stuck leg now delays a release's SBOM attachment by up to the budget. That only bites on the path where a release is already broken, and the alternative is the failure this PR exists to fix, so I am still accepting it rather than designing it away. The reason it stays affordable is unchanged: fail-fast: false, no timeout-minutes anywhere near, and this fires per release rather than per PR.

Head 6fa599f5. pnpm all green with three Tasks: summaries, 982 script tests including the 15 new ones, conformance 22/22.

@allxsmith

Copy link
Copy Markdown
Owner Author

deep-review: fresh — please read 6fa599f

@allxsmith

Copy link
Copy Markdown
Owner Author

deep-review: fresh — please read 6fa599f

@allxsmith

Copy link
Copy Markdown
Owner Author

deep-review: fresh — please read b4384db

@github-actions

Copy link
Copy Markdown
Contributor

Preview Deployment

Preview URL: https://a7088400.bestax.pages.dev

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deep review — 0 blocking · 5 advisory

# Severity Area Finding Location
1 🔵 Advisory Correctness The focus commit's replacement sentence points at the wrong message. It sits above the success log line, whose text is resolved after ${waited}s and ${attempt} attempts — no budget in it; only the exhaustion message at :325 prints (${waited}s of ${budgetSeconds}s, …). Clause to delete so the remainder is true: the comparison the message makes —. The comparison itself is sound; a reader just has to bring DEFAULT_BUDGET_SECONDS to it on the success path. scripts/npm-install-retry.mjs:118
2 🔵 Advisory Robustness Carried from all nine earlier passes and re-verified this one: a failure no wait can fix now costs the full 900 s on that leg instead of ~33 s, and sign-sbom (needs: [sbom, consumer-sbom], line 684) plus attach-sbom (line 886) gate on completion, so a release's signatures and assets attach up to 15 minutes later. Bounded — fail-fast: false, no timeout-minutes on the job. Accepted cost of trading a count for a deadline. .github/workflows/supply-chain.yml:406
3 🔵 Advisory Robustness The budget remains a point estimate against a distribution that has already shifted once, which the commit series itself says. 900 s clears the worst case measured on this PR (bestax-migrate@2.3.4, 732 s) by 168 s, and that measurement is a lower bound on what a runner sees through a CDN edge. The new waited line is the right instrument for the next resize, but it lives only in the run log, so the window is GitHub's log retention. scripts/npm-install-retry.mjs:55
4 🔵 Advisory Coverage Two production expressions no case executes: the entry guard's process.exitCode = await main() (:336) and the ?? spawnSync fallback in main's installer wiring (:311). Both are deliberate — driving either means spawning a subprocess, which the suite's header bans — and both are backstopped downstream (see residual risk). ?? spawnSync is also redundant with runNpmInstall's own spawn = spawnSync default. Nothing gates coverage on scripts/*.test.mjs either (no --experimental-test-coverage, no threshold), so "the sibling is the only check" is unmeasured. scripts/npm-install-retry.mjs:336
5 🔵 Advisory Correctness For the maintainer, not the fix agent — the PR body still says "a ten-minute budget costs no time". The budget became 900 s (fifteen minutes) in b785bab8. Neither this review nor the fix agent can edit a PR body. PR body, Where the retry policy lives

Overall: Sound, and the focus commit is a clean close on the one thread it set out to close — I traced both figures it pins against the injected clock rather than taking the message for them (budget 50 / interval 20 → sleeps at 0, 20 000, 40 000 ms, guard trips at attempt 3, waited = 40), so waited: elapsed() → waited: 0 and dropping ${waited}s of from the exhaustion message both die now; my one open thread from the previous pass is replied to and resolved. The prose correction is the right call too: the figure genuinely bounds nothing, because it starts when the script starts rather than at the publish. The riskiest part is unchanged and is not code — it is the 900 s number itself, fitted to a nine-release sample whose worst case it clears by 168 s. Focus there first, since a release is the only instrument that will ever exercise it.

Residual risk:

  • Propagation exceeding 900 s still reds the release — open. Nothing in the diff can refute this; it is the residual the budget is. The margin over the worst case measured on this PR is 168 s, and that measurement understates what a runner sees through a CDN edge.
  • --prefer-online defeats the local cache but not a stale CDN edge — refuted as a new failure mode. It forces the staleness check, which is what the identical-ETARGET-at-every-interval logs showed was missing; edge staleness is only curable by waiting, and waiting is now what the policy does. The flag cannot be silently dropped again: every attempt carries --prefer-online, and the spec comes last deep-equals the whole argv off an injected spawn, and INSTALL_ARGS is separately asserted as data, so a rewritten call site is still caught. --ignore-scripts rides the same two assertions.
  • A swallowed exit code going silently green — refuted, three independent backstops. main returns 1 on exhaustion into process.exitCode; the step is a plain run: under bash -e with the node call neither last, piped, nor inside an if; a failed install writes no package-lock.json, so the cp two lines down fails; and consumer-sbom-meta.mjs stamp --expect re-reads the installed version against the release's.
  • An npm install that hangs instead of failing — open, pre-existing, unchanged. The deadline is read only between attempts (stated in the module at :89), and consumer-sbom sets no timeout-minutes, so the bound is the 360-minute job default. Equally true of the shell loop this replaces.
  • The latest legs now force-revalidate too — refuted as a regression. A release event can make a non-released leg resolve a newer latest than the cached packument would have given it, which is exactly what a consumer SBOM is defined as describing; EXPECT is empty on those legs, so nothing asserts a version and the stamp path is unchanged.
  • Rule 9's bootstrap gap — refuted. supply-chain.yml declares only release/schedule/workflow_dispatch, and the checkout takes the event ref, so the first run to read npm-install-retry.mjs is the first release after merge, by which time it is in the tree the tag points at.
  • Trust boundary — unchanged, and the PR body's I1/I2 claim checks out. No new trigger, no permissions: edit, no identity, no checkout of PR code; registry.npmjs.org:443 was already in allowed-endpoints (line 172) and the diff adds no host; consumer-sbom holds no model-auth token and runs no model. Nothing new leaves the job. forLog is JSON.stringify(String(...)), so the per-attempt retry line and the cwd in the throw are both newline-neutralised before reaching stdout.
  • On this pass's own verification: script execution was unavailable in this session and the Build and Test check for b4384db8 was still in_progress when this was posted, so the two new assertions were verified by tracing the injected clock by hand rather than by running them. The same suite is green at c19b7613 (job 106604323833, with the last wait never lands past the deadline and an exhausted budget exits 1 and says where to look both passing), and the head commit only adds assertions to those two cases.

🏄 Eleven commits chasing one number down the beach and it finally sits still — the figure says what it is, the exhausted leg reports what it actually spent, and the last free-surfing mutant got wiped out. All that's left is a stale minute-count in the PR body and a budget that's a judgement call, not a bug. Send it.

The sentence sat above the success log line and described it as printing
the spend against the budget. It prints the spend alone. Only the
exhaustion message prints both, and the comment now says which is which.

Fifth correction to this comment block, against one defect in the policy
it describes, so the pattern is worth naming in the history: the prose
around this change has been considerably less reliable than the change.

Also drops a redundant fallback. main passed `spawn ?? spawnSync` into
runNpmInstall, which already defaults the same parameter to the same
value. Two fallbacks for one thing only make a reader work out which one
wins.
@allxsmith

Copy link
Copy Markdown
Owner Author

Round 11: three taken, two carried, and I think this is the point to stop.

Row 1 — taken, fifth correction to this comment block. The sentence sat above the success line and credited it with printing the spend against the budget. It prints the spend alone; the exhaustion message is the one that prints both. Fixed by deletion again, with the distinction named.

Row 4 — taken, the redundancy half. spawn ?? spawnSync in main's wiring duplicated runNpmInstall's own default for the same parameter, and two fallbacks for one thing only make a reader work out which wins. Removed; the argument-swap mutant still dies, so the coverage that row bought last pass is intact.

On the two genuinely uncovered expressions: both stay uncovered on purpose. Driving process.exitCode = await main() or the real spawnSync default means spawning a subprocess, which is the thing the suite's header promises not to do, and the promise is worth more than the two lines. The measurement gap is #724.

Row 5 — taken. The body said "a ten-minute budget costs no time" after the budget became 900 s. Rephrased so it cannot decay with the number again. I could edit it, so it is done rather than handed over.

Rows 2 and 3 — carried, both honest limits rather than defects. The 900 s point estimate against a distribution that shifted once, and the stuck-leg delay now being 900 s rather than the 600 s I first accepted it at.

Why I am calling it here rather than asking for round twelve.

The last four passes found no defect in the retry policy. Every finding was coverage of something I had just added, or a sentence I had written wrong: five prose corrections against one real policy defect across the whole review. That is a useful ratio to know and a bad one to keep paying for, because the marginal round is now mostly auditing my own descriptions.

Against that, the bug is still live. @allxsmith/bestax-bulma@5.16.6 published at 03:19 today and its run died 33 s in on the same ETARGET; every release-triggered supply-chain run since bestax-migrate@2.3.2 has failed, and each one costs that release its consumer SBOM. The remaining risk you have correctly kept open — whether 900 s is enough — cannot be closed by another review, because a release is the only instrument that exercises it. Merging is what produces the first real reading, and the resolved after …s line is now there to record it.

So: policy mutation-verified from eight directions, 27 cases, pnpm all green, conformance 22/22, and the three things found along the way that are not this PR's job filed as #719, #723 and #724.

Head bb02bef6.

@github-actions

Copy link
Copy Markdown
Contributor

Preview Deployment

Preview URL: https://9cdb47d6.bestax.pages.dev

@allxsmith

Copy link
Copy Markdown
Owner Author

deep-review: fresh — please read bb02bef

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deep review — 0 blocking · 4 advisory

# Severity Area Finding Location
1 🔵 Advisory Correctness The waited instrument has a third skew the comment does not name, and it is on the upper side: elapsed() is read after await run(spec) returns, so a successful attempt's own resolve/reify time is inside the figure. On the bestax-migrate leg that is a 98-package closure. The comment names the start-side skew (script start, not publish) and the one-interval granularity; this one inflates rather than truncates. Safe direction — it biases the next resize larger — but it is the instrument #716 built for exactly that resize. Nothing to change in the file: the honest fix is a qualifier, and adding one is the move the house rule forbids. scripts/npm-install-retry.mjs:125
2 🔵 Advisory Robustness Carried from all ten earlier passes and re-verified here rather than restated: a failure no wait can fix costs the full 900 s on that leg instead of ~31 s, and both sign-sbom (needs: [sbom, consumer-sbom]) and attach-sbom (needs: [sbom, consumer-sbom, sign-sbom]) gate on completion, not success — so a release's signatures and assets attach up to fifteen minutes later. Bounded by fail-fast: false and by nothing here being able to fail a release. Accepted cost of trading a count for a deadline. .github/workflows/supply-chain.yml:406
3 🔵 Advisory Coverage Two production expressions the suite still does not execute, both deliberately: the entry guard process.exitCode = await main(), and runNpmInstall's spawn = spawnSync default — driving either means spawning a real subprocess, which the suite header bans. bb02bef6 improved this rather than worsening it by deleting the duplicate ?? spawnSync in main, so there is now exactly one fallback and undefined still reaches the default parameter correctly. Coverage on scripts/ remains ungated (no threshold, no --experimental-test-coverage), which is #724. scripts/npm-install-retry.mjs:336
4 🔵 Advisory Robustness Point-in-time evidence and one stale attribution in the added prose — one row for the lot, per the house rule: "on every release-triggered run for over a week" (twice), "usually under a couple of minutes but was once an order of magnitude worse", and # New in #530: this job runs scripts from scripts/ — the install policy, …, which now attributes the install policy to #530 when it arrives with #716. The (#716) hop that the rule actually asks for is already there in both places, so this is the residue, not the omission. .github/workflows/supply-chain.yml:221

Overall: Sound, and the evidence holds up under independent checking rather than on the PR body's word — every one of the last ten release-triggered supply-chain runs failed, each on exactly the leg its tag names (run 35547552745 → Consumer SBOM (@allxsmith/eslint-plugin-bestax); run 35682756557 → the bestax-bulma leg), while the last five schedule runs are all green, and the failing install step ran 00:23:47 → 00:24:18 — thirty-one seconds, which is the 10+20 sleep budget plus three fast attempts and nothing else. The riskiest part is still not code: it is the 900 s number, fitted to a nine-release sample whose worst case it clears by 168 s, against a distribution the commit series itself says has already shifted once. Focus there first, because a real release is the only instrument that will ever exercise it. The focus commit bb02bef6 is clean on both halves — the success line genuinely prints the spend without the budget, only the exhaustion message prints both, and dropping spawn ?? spawnSync is behaviour-preserving because passing undefined is what triggers runNpmInstall's own default.

Residual risk:

  • Propagation exceeding 900 s still reds the release — open. This is the residual the budget is; nothing in the diff can refute it. The margin over the worst case measured on this PR is 168 s, and that measurement is a lower bound on what a runner sees through a CDN edge.
  • A 15-minute wait sits under harden-runner's periodic DNS refresh instead of one window — open, new with this change, low likelihood. Rule 10 of .github/CLAUDE.md records that the agent re-resolves allow-listed hosts every 30 s and on failure only logs failed to insert new ipaddress in firewall. The loop this replaces finished inside a single refresh window; this one can sit under the firewall for the whole budget, so a registry.npmjs.org IP rotation with a failed refresh would fail every remaining attempt and be reported by the exhaustion message as propagation. The diagnosis is at least in the agent log, and the endpoint is unchanged — no allowlist widens.
  • --prefer-online defeats the local cache but not a stale CDN edge — refuted as a new failure mode. It forces the staleness check that the identical-ETARGET-at-every-interval logs showed was missing; edge staleness is only curable by waiting, and waiting is what the policy now does. The flag cannot be silently dropped again: the argv is deep-equalled off an injected spawn and INSTALL_ARGS is separately asserted as data, so a rewritten call site is still caught. --ignore-scripts rides the same two assertions, which matters more than the retry does.
  • A failed install going silently green — refuted, four independent backstops. main returns 1 into process.exitCode; the step is a plain run: under bash -e with the node call neither last, piped, nor inside an if; a failed install writes no package-lock.json, so the cp two lines down fails; and consumer-sbom-meta.mjs stamp --expect re-reads the installed version against the release's.
  • An npm install that hangs rather than failing — open, pre-existing, unchanged. The deadline is read only between attempts, stated in the module itself, and consumer-sbom sets no timeout-minutes, so the bound is the 360-minute job default. Equally true of the shell loop.
  • Release bursts holding runners longer — open, bounded. supply-chain.yml declares no concurrency: group, and semantic-release fires releases in bursts (five runs inside four minutes on 2026-09-21, 00:19–00:23). Each burst can now hold one long-lived job per run for up to fifteen minutes where it previously held one for thirty-one seconds. Bounded: only the pinned leg can reach the deadline, the siblings resolve latest on the first attempt, and nothing here can fail a release.
  • Rule 9's bootstrap gap — refuted. supply-chain.yml declares only release / schedule / workflow_dispatch, and the checkout takes the event ref deliberately, so the first run to read npm-install-retry.mjs is the first release after merge, by which time it is in the tree the tag points at.
  • Trust boundary — unchanged, and the PR body's I1/I2 claim checks out against the default branch's .github/CLAUDE.md. No new trigger, no permissions: edit, no new identity, no checkout of PR code, no new third-party action. registry.npmjs.org:443 was already in allowed-endpoints and the diff adds no host, so nothing widens who can trigger the job, what it can reach, or what leaves it. consumer-sbom holds no model-auth token and runs no model. Both places the spec is printed go through forLog (JSON.stringify(String(…))), so a crafted spec cannot terminate a workflow command, and the spec reaches npm as an argv element rather than a shell word.
  • On this pass's own verification: node and pnpm execution were unavailable in this session, so the suite was read rather than run — but Build and Test is green at bb02bef6, and root pnpm test is turbo run test && node --test "scripts/*.test.mjs", so the new file ran there. I additionally hand-traced the two arithmetic cases against the injected clock (budget 50 / interval 20 → sleeps at 0 and 20 000 ms, guard trips at attempt 3, waited = 40; budget 600 / interval 20 → 31 attempts) and both match their assertions.

🏄 Twelve commits deep and the swell finally cleaned up — the retry actually re-asks the registry, the clock does the stopping, and the last comment that was claiming a wave it never caught got trimmed back to the truth. Only thing still floating out there is the 900 you can't test from shore. Paddle it out.

Deleting the stale count from this comment left the sentence attributing
the install policy to #530, which introduced the other two scripts. The
policy arrives here. Each is credited to what added it now.
@allxsmith

Copy link
Copy Markdown
Owner Author

Round 12: one actionable, and it was mine.

Row 4 — the attribution, fixed. When I deleted the stale count from that comment in an earlier round, I left the sentence crediting the install policy to #530, which introduced the other two scripts. The policy arrives with this PR. Each is credited to what added it now.

That one only surfaces from reading the sentence against the history rather than against the code, so thanks for doing that. The point-in-time phrases in the same row I am leaving: the (#716) hop the rule asks for is in both places, and rewriting "on every release-triggered run for over a week" into a command would cost more clarity than the staleness risk is worth.

Row 1 — agreed, and agreed about not fixing it. A third skew in the same figure, this one inflating: elapsed() is read after await run(spec) returns, so the successful attempt's own resolve and reify time is inside it, and on the bestax-migrate leg that is a 98-package closure. Your conclusion is the right one — the honest fix is a qualifier and the house rule forbids adding one. This is the third time this figure's description has been wrong, and each time the instinct to qualify would have made it worse. The comment names the two skews that truncate; this one biases a future resize larger, which is the safe direction, and it is on this thread now rather than in the file.

Rows 2 and 3 — carried, and both accurate. The stuck-leg delay at 900s rather than the 600s I first accepted it at, and the two deliberately uncovered expressions. Worth acknowledging your note that bb02bef6 improved row 3 rather than worsening it, and for the right reason: passing undefined is exactly what triggers runNpmInstall's own default, so deleting the duplicate fallback was behaviour-preserving.

Head 81407fd9, a one-line comment attribution. pnpm all green with three Tasks: summaries, conformance 22/22.

On the evidence you re-derived independently: thirty-one seconds being the 10+20 sleep budget plus three fast attempts and nothing else is the cleanest statement of the original bug anyone has managed, including me. That is the whole defect in one sentence.

@allxsmith

Copy link
Copy Markdown
Owner Author

deep-review: fresh — please read 81407fd

@github-actions

Copy link
Copy Markdown
Contributor

Preview Deployment

Preview URL: https://ebba862d.bestax.pages.dev

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deep review — 0 blocking · 4 advisory

# Severity Area Finding Location
1 🔵 Advisory Security Two of the ::error::-bound strings this module can emit interpolate argv raw — ${key} needs a value and unknown flag --${name} — while their neighbour a line above uses forLog(key). Unreachable from the workflow (the step hardcodes --spec/--dir, so even-index argv is always a literal and only values can be crafted), so there is no failure scenario today; noted only because the suite’s own case is named "the spec is neutralised in both places it is printed", and these are the two places a hand-run argv could still forge a workflow command. scripts/npm-install-retry.mjs:215
2 🔵 Advisory Robustness Carried and re-verified rather than restated: a failure no wait can fix costs the full 900 s on that leg instead of ~31 s, and both sign-sbom and attach-sbom gate on consumer-sbom completing, not succeeding — so a release’s signatures and assets attach up to fifteen minutes later. Bounded by fail-fast: false and by nothing here being able to fail a release. .github/workflows/supply-chain.yml:406
3 🔵 Advisory Coverage Measured this pass rather than inferred: node --test --experimental-test-coverage reports 99.41 % lines, 95.83 % branches, 100 % funcs, with only the entry guard unexecuted. The one residual uncovered branch is runNpmInstall’s spawn = spawnSync default, deliberately unreachable from a suite that bans real subprocesses. Coverage on scripts/ remains ungated (#724). scripts/npm-install-retry.mjs:336
4 🔵 Advisory Robustness Point-in-time evidence in the added prose — one row for the lot, per the house rule: "on every release-triggered run for over a week" (twice) and "usually under a couple of minutes but was once an order of magnitude worse". The (#716) hop the rule asks for is present in both places and resolves — the nine-release table is in the round-7 comment here — so this is residue, not an omission. The focus commit’s re-attribution checks out: the job does now call three scripts (consumer-sbom-meta, npm-install-retry, check-consumer-sbom), and the install policy is genuinely not one of #530’s two. scripts/npm-install-retry.mjs:39

Overall: Sound, and this pass could execute rather than read. The suite runs green (27/27) and the failure it was written against is real in exactly the shape claimed: job 106603059044 on run 35682756557 shows the step starting at 03:20:21.98, ETARGET / No matching version found for @allxsmith/bestax-bulma@5.16.6 three times, and the leg dead at 03:20:54.55 — thirty-three seconds. The cache diagnosis holds up in the timings too: attempt 1 took ~1.95 s and attempts 2 and 3 ~0.29 s each, which is a ~/.npm packument hit rather than a registry round trip, so --prefer-online is addressing the thing that actually happened. The riskiest part is still not code — it is the 900 s constant, fitted to nine observations whose worst is 732 s — and the one thing a human should weigh is whether that outlier was tail behaviour or an incident, because the answer decides whether every doomed leg should now wait fifteen minutes.

Residual risk:

  • Propagation exceeding 900 s still reds the release — open. This is the residual the budget is; the diff cannot refute it. The self-measuring resolved after Ns and N attempts line makes the next resize readable off the runs instead of the registry API, which is the right answer to a constant nothing here can fit.
  • --prefer-online defeats the local cache but not a stale CDN edge — refuted as a new failure mode. The identical ETARGET at each interval plus the 1.95 s to 0.29 s timing collapse is the local-cache signature, and that is what the flag closes; edge staleness is only curable by waiting, which the deadline now does. The flag cannot be silently dropped again: INSTALL_ARGS is deep-equalled off an injected spawn and asserted separately as data, and I confirmed each half fails independently of the other.
  • A silently-green failed install — refuted, four backstops, each checked. main returns 1 into process.exitCode; the node call sits under bash -e, neither last nor piped nor inside an if; a failed install writes no package-lock.json, so the cp two lines down fails; and consumer-sbom-meta.mjs stamp --expect re-reads the installed version.
  • An empty $SPEC quietly installing nothing — refuted, and improved by this change. npm install --ignore-scripts "" would have installed the consumer manifest’s (empty) deps and exited 0; parseArgs now rejects it with --spec is required and exit 2.
  • An npm install that hangs rather than failing — open, pre-existing, unchanged. The deadline is read only between attempts (stated in the module), and consumer-sbom sets no timeout-minutes, so the bound is the 360-minute job default. Worst case is deadline plus one attempt, not deadline times attempts.
  • A 15-minute wait spanning harden-runner’s 30 s DNS refresh — open, new with this change, low likelihood. Rule 10 records that a failed re-resolve only logs; a registry.npmjs.org rotation mid-budget would be reported by the exhaustion message as propagation. No allowlist widens, and the diagnosis is at least in the agent log.
  • Rule 9’s bootstrap gap — refuted. The workflow declares only release/schedule/workflow_dispatch, the checkout takes the event ref deliberately, and the first run to read npm-install-retry.mjs is the first release after merge, by which time the tag’s tree carries it. The new forLog import is side-effect-free — consumer-sbom-meta.mjs keeps its CLI behind an import.meta.url guard — so nothing extra runs at module load.
  • The same race in a sibling job — open, out of scope, already filed. verify-provenance installs its roster by bare name on the same release trigger (#719). Not in this diff.
  • Trust boundary — unchanged, verified against the default branch’s .github/CLAUDE.md. No new trigger, no permissions: edit, no new identity, no new action, no checkout of PR code; registry.npmjs.org:443 was already allow-listed and the diff adds no host. consumer-sbom holds no model-auth token and runs no model. The spec reaches npm as an argv element rather than a shell word, which is strictly narrower than the quoted $SPEC it replaces.
  • Repo gates — run, not assumed. node --test scripts/npm-install-retry.test.mjs 27/27; check-conformance.mjs 22/22 including fragile-prose; prettier --check and eslint clean on all three changed files.

🏄 Thirteen commits and the set finally lined up — the loop re-asks the registry for real, the clock calls the session, and the last comment that was giving #530 credit for someone else’s wave got handed back. Nothing left out there but that 900 you can only read from the lineup. Send it.

@allxsmith

Copy link
Copy Markdown
Owner Author

Round 13: nothing actionable. Refuting row 1 and carrying the rest, so 81407fd9 stands as reviewed.

Row 1 — refuted, on trust class rather than on reachability. Your own note that it is unreachable is correct, but the reason not to change it is stronger than "unreachable today", because unreachable-today is exactly the argument that ages badly.

These two strings interpolate a flag key, and keys and values come from different places. The workflow passes --spec "$SPEC" --dir "$RUNNER_TEMP/consumer", so every even-index argv is a literal in the YAML; the only things that can carry crafted content are the values, and $SPEC is the one that flows from a release tag. That is why the spec goes through forLog and is worth a case, and it is why these do not: the only actor who can put a newline in a flag key is the person typing the command, who already has the shell. Neutralising it would protect them from themselves.

Adding forLog to both would be two lines of code answering no threat, and it would blur the distinction the module currently draws correctly — repo-derived input gets neutralised, argv structure does not need to be.

On the test name: "in both places it is printed" is scoped to the spec, which is printed in exactly two places (the retry line and the exhaustion message) and is neutralised in both. It does not claim every argv-bearing string is neutralised, so the name is accurate for what the case covers.

Row 3 — no action, and the number is a good result worth recording. 99.41% lines, 95.83% branches, 100% funcs, with only the entry guard unexecuted and the one residual branch being runNpmInstall's spawn = spawnSync default. Both are the deliberate consequence of a suite that will not spawn a real subprocess, which is a promise worth more than the two expressions. Thanks for measuring it rather than inferring — that is the first hard coverage figure on this file, and it belongs on #724 as evidence that the gate would have something sane to assert.

Row 4 — accepted as residue, no action. You confirmed the (#716) hop resolves to the nine-release table, which is what the rule asks for, and rewriting the two remaining phrases into commands would cost more clarity than the staleness risk is worth. Also noting you verified the re-attribution: three scripts, and the install policy genuinely not one of #530's two.

Row 2 — carried, accepted, unchanged for the eleventh time.

So this is the convergence point, and this time the head I am claiming it for is the head that was reviewed. 81407fd9: 0 blocking, every advisory either refuted with a reason, carried as an accepted cost, or filed (#719, #723, #724). No code change this round, so nothing new to re-review.

@allxsmith
allxsmith merged commit 2ad5c11 into main Sep 22, 2026
32 of 33 checks passed
allxsmith added a commit that referenced this pull request Sep 22, 2026
The budget stopped being a guess. Across the releases preceding this
change, the gap between a release going up and the registry writing the
new version into the packument is usually under a couple of minutes, and
was once an order of magnitude worse. The previous value cleared the
typical case and would have expired short of the worst one, which is the
false red this whole change exists to prevent. The figures and how they
were taken are on #716, because measurements belong on the issue rather
than in a comment that nothing re-measures.

It is set to clear the worst observed case with margin, on the reasoning
that the two mistakes are not symmetric: a generous budget spends extra
minutes on a release that was already broken, and a short one reds a
release that was fine.

The default sleep was the last function nothing could call. As an inline
default, dropping its argument left every case green, because every
other case injects its own sleep, while turning a few dozen packument
reads into a few thousand inside the same budget. Exported and timed
now.

A retryable spawn failure threw its errno away. stdio inherit carries
npm's own diagnosis when npm runs, but a spawn that never started
produced nothing to inherit, so EAGAIN gave a column of bare retry lines
under a propagation diagnosis that does not fit it. It says the code out
loud.

And a usage-error case let main fall through to console.log, so two
::error:: workflow commands reached stdout during pnpm test. On a runner
those are filed as failure annotations against a green job. Captured and
asserted here; the same leak in four other script suites is #723.
@allxsmith
allxsmith deleted the ci/sbom-release-race branch September 22, 2026 04:52
@bestax-release-bot

Copy link
Copy Markdown

🎉 This PR is included in version 5.16.7 🎉

The release is available on:

Your semantic-release bot 📦🚀

@bestax-release-bot

Copy link
Copy Markdown

🎉 This PR is included in version 4.2.10 🎉

The release is available on:

Your semantic-release bot 📦🚀

@bestax-release-bot

Copy link
Copy Markdown

🎉 This PR is included in version 2.3.8 🎉

The release is available on:

Your semantic-release bot 📦🚀

@bestax-release-bot

Copy link
Copy Markdown

🎉 This PR is included in version 1.2.7 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant