Skip to content

fix(release): bound the crates.io publish phase by the token's real lifetime, not just the budget - #3383

Open
BIMvoice wants to merge 1 commit into
relfix/crates-poll-budgetfrom
fix-3258-token-budget-clock
Open

fix(release): bound the crates.io publish phase by the token's real lifetime, not just the budget#3383
BIMvoice wants to merge 1 commit into
relfix/crates-poll-budgetfrom
fix-3258-token-budget-clock

Conversation

@BIMvoice

Copy link
Copy Markdown
Collaborator

Summary

#3258: PUBLISH_PHASE_BUDGET_MS in scripts/release-crates.mjs (on relfix/crates-poll-budget, #3188's line of work) starts its clock only inside publishAllCrates. Everything between minting CARGO_REGISTRY_TOKEN and reaching that loop — a second pnpm build, test:esm, and the entire npm publish with per-tarball OIDC provenance — was never charged against it, so a large release-wide budget could pass even when the 30-minute token was already close to (or past) its claimed lifetime by the time the crates loop started.

The ordering, quoted

Mint (.github/workflows/release.yml:213-215, unchanged):

      - name: Authenticate to crates.io (OIDC)
        id: crates-auth
        uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 # v1.0.5

Unrelated work runs next, inside the changesets step (release.yml:217-244, publish-script: pnpm run release), which package.json defines as:

pnpm build && pnpm test:esm && node scripts/release-all.mjs

and scripts/release-all.mjs runs npm first, crates second.

The clock previously started only here, scripts/release-crates.mjs:

const budgetDeadline = Date.now() + totalBudgetMs;

— i.e. after the second build, test:esm, and the whole npm publish had already run and consumed an unmeasured, unbounded amount of the 30-minute token.

Worst-case gap

Unbounded before this change: a slow second build + test:esm + npm publish (many packages, provenance attached to each) could consume most or all of the token before publishAllCrates ever took its first timestamp, and a budget sized only against the crates work would not notice.

Fix

release.yml now stamps the mint time immediately after the OIDC exchange (Stamp crates.io token mint time, a single Date.now() — no secret touched or logged) and passes it to the changesets step as CRATES_TOKEN_MINTED_AT_MS. publishAllCrates takes the deadline as whichever is earlier: its own totalBudgetMs, or tokenMintedAtMs + 30min − a 60s margin. Time spent before the loop is now charged against the token's real remaining lifetime instead of escaping the bound.

main() validates the env value at the boundary (parseTokenMintedAtMs) and throws on a non-numeric value rather than letting a NaN deadline silently defeat every comparison downstream — the same failure shape flagged in the issue for an unvalidated numeric env input. Omitting the env var (a manual/local run) reproduces the exact previous budget-only behavior — pinned by a regression test.

Could this be unit-tested? Yes.

The workflow ordering itself (mint → unrelated work → clock) is YAML and isn't unit-testable, so I read it directly and quoted it above. The arithmetic bug — the clock not accounting for the token's real remaining lifetime — lives entirely in scripts/release-crates.mjs, which already has scripts/release-crates.test.mjs with a fake-clock harness. Added there:

  • a token minted well before the loop starts is already past its lifetime, even with a large, untouched release-wide budget (reproduces the defect)
  • the token bound catches a stall that the release-wide budget alone would have allowed through
  • omitting the mint timestamp falls back to the exact pre-fix budget-only behavior (regression pin, byte-identical message)
  • parseTokenMintedAtMs boundary validation: unset → undefined, valid numeric string → parsed, non-numeric → throws (guards the NaN-hangs-forever shape)
$ node --test scripts/release-crates.test.mjs
# tests 19
# pass 19
# fail 0

$ node --test scripts/*.test.mjs scripts/lib/*.test.mjs
# tests 661
# pass 661
# fail 0

$ node scripts/check-module-size.mjs
check-module-size: OK (1936 files measured, 314 allowlisted, 0 new over 400)

$ node scripts/check-source-text-assertions.mjs
check-source-text-assertions: OK (8 allowlisted, 0 marked, 0 new)

$ actionlint .github/workflows/release.yml
# same 9 pre-existing shellcheck findings as the base branch; none on the new step

Confirmed RED against the pre-fix code: reverting scripts/release-crates.mjs alone while keeping the new tests fails immediately (parseTokenMintedAtMs isn't exported yet), and restoring the fix turns the suite green again.

Same-shape grep elsewhere

Searched .github/workflows/ and scripts/ for another mint-then-unrelated-work-then-clock sequence. The only other OIDC/trusted-publish flow is PyPI (python-wheels.yml, pypa/gh-action-pypi-publish), which performs the OIDC exchange and the publish inside one self-contained action step — no separate script-side budget/poll loop of ours sits between a mint and a clock there, so this issue's family doesn't recur. No other occurrence found; nothing else changed.

Base branch

This PR targets relfix/crates-poll-budget (not main) because the budget code #3258 describes was introduced there and hasn't merged yet — PUBLISH_PHASE_BUDGET_MS doesn't exist on main.

Refs #3258

…emaining lifetime, not just the release-wide budget

release-crates.mjs's PUBLISH_PHASE_BUDGET_MS clock started only inside
publishAllCrates, so the second `pnpm build`, `test:esm`, and the entire npm
publish that release.yml runs between minting CARGO_REGISTRY_TOKEN and
reaching the crates loop were never charged against it. A large budget could
therefore sail through even though the 30-minute token was already close to
(or past) its claimed lifetime by the time the loop started.

release.yml now stamps the mint time right after the OIDC exchange and
passes it down as CRATES_TOKEN_MINTED_AT_MS. publishAllCrates takes the
deadline as the earlier of its own budget and (mint time + 30 minutes minus
a margin), so unmeasured work before the loop is charged against the token
instead of escaping the bound. The env value is validated at the boundary in
main() and refused outright rather than propagating a NaN deadline that
would silently defeat every comparison downstream. Omitting the timestamp
(a manual/local run) reproduces the previous budget-only behavior exactly.

Refs #3258
@BIMvoice
BIMvoice requested a review from louistrue as a code owner August 28, 2026 07:12
@coderabbitai

coderabbitai Bot commented Aug 28, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 5c387e29-47cd-4d68-8d8f-5e6a9eab60f2

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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

@BIMvoice

Copy link
Copy Markdown
Collaborator Author

Heads-up on this PR's CI, because it will read green without having been tested.

It targets relfix/crates-poll-budget, not main, which was the right call — PUBLISH_PHASE_BUDGET_MS only exists on that branch, so basing on main would have been fixing code that isn't there. But test.yml is on: pull_request: branches: [main], so the whole test matrix never fires for this PR.

It currently carries 13 checks, and none of them build or test the code:

parity, full corpus, Validate Server Binary (x3), Verify release assets,
Release Server Binary, Vercel x3, Vercel Agent Review, Vercel Preview Comments, CodeRabbit

Missing: Typecheck, Lint, Node tests, Build packages + WASM, Build + WASM + Rust + Node (the required aggregate), the viewer shards, Rust tests, Rust crate semver, Detect changes.

This is the same shape you documented on #3185 — the required aggregate is not red, it is absent, and fail=0 is literally true while answering a different question. Retargeting later does not fire the workflows retroactively.

So the CI signal here is worth nothing on its own. What the change actually has behind it is a local run, reported in full rather than summarised:

node --test scripts/release-crates.test.mjs           tests 19, pass 19, fail 0
node --test scripts/*.test.mjs scripts/lib/*.test.mjs tests 661, pass 661, fail 0
node scripts/check-module-size.mjs                    OK, 0 new over 400
node scripts/check-source-text-assertions.mjs         OK, 0 new
actionlint .github/workflows/release.yml              9 pre-existing findings, none on the new step

RED was confirmed by reverting scripts/release-crates.mjs alone and watching the new tests fail.

Treat that local run as the evidence, not the green ticks above. If you would rather this ran the full matrix, the options are to land relfix/crates-poll-budget first and rebase this onto main, or to fold this commit into that branch directly — happy to do either.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant