Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions .github/workflows/npm-package-existence.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@ on:
branches:
- main
# Refresh the cached record of packages and versions known to exist on npm once
# the release that publishes them has finished, so the versions it published are
# on the registry by the time they are probed. Refreshing on push to main instead
# would race that release and record its versions as absent.
# the release that publishes them has finished. Refreshing on push to main
# instead would race that release and record its versions as absent. A publish
# does not reach the registry instantly either - npm scans it for malware first -
# so the refresh waits that out rather than the workflow trying to time it; see
# refresh-npm-package-record.mts.
#
# release-npm-packages.yml raises this event, rather than this workflow listening
# for it to complete, for cache access: only a trusted trigger may write to the
Expand Down Expand Up @@ -95,6 +97,9 @@ jobs:
refresh-npm-package-record:
runs-on: ubuntu-latest
name: Refresh record of published packages
# Comfortably over the wait budget in shared.mts, which is what makes this job
# long-running, so that a hang is cut short well before the 6 hour default.
timeout-minutes: 40
# The release raises the event only once it has published, so a release that
# failed, was cancelled or published nothing never gets here: those versions
# stay absent from the registry, which is what the next release PR's check must
Expand Down
11 changes: 8 additions & 3 deletions scripts/npm-packages/ensure-packages-exist-on-npm.mts
Original file line number Diff line number Diff line change
Expand Up @@ -226,9 +226,14 @@ if (missingNames.length || missingVersions.length) {
`${missingVersions.length} version(s) this PR supersedes are not published on npm:`,
...missingVersions.map((v) => ` - ${formatPackageVersion(v)}`),
"",
"A previous release PR versioned these, but the release that should have",
"published them did not complete. Merging this PR bumps past them, and no",
"later release will ever publish them. A maintainer must:",
"If the release that publishes them finished only minutes ago, they are",
"most likely still going through npm's publish-time malware scan, which",
"keeps a new version off the registry until it passes - usually for a few",
"minutes, sometimes 15 or more. Re-run this check before anything else.",
"",
"Otherwise a previous release PR versioned these but the release that should",
"have published them did not complete. Merging this PR bumps past them, and",
"no later release will ever publish them. A maintainer must:",
"",
" 1. Open the most recent release-npm-packages.yml run and find out why",
" the publish did not finish.",
Expand Down
33 changes: 29 additions & 4 deletions scripts/npm-packages/refresh-npm-package-record.mts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@
* write to the cache scope that pull requests restore from, which is why the
* refresh happens here and not on the release PR itself.
*
* Publishing does not make a version available immediately - npm scans it for
* malware first, which usually takes a few minutes - so this run starts inside
* the window where the versions it is about to record still read as absent.
* Anything the registry does not have yet is therefore re-checked until it
* appears, or until the wait budget in shared.mts runs out. Without that wait a
* release would record none of what it just published and every check on the next
* release PR would fall back to the registry.
*
* Versions that are not on npm are reported but never fail the run: a package can
* legitimately sit on main unpublished until a maintainer does its one-time manual
* first publish, and a release may have published only some of what it versioned.
Expand All @@ -29,7 +37,8 @@ import {
formatPackageVersion,
getAllPublishablePackages,
loadRecord,
partitionByExistence,
partitionByExistenceWaitingForPublish,
type PackageVersion,
type PublishedRecord,
RECORD_DISPLAY,
saveRecord,
Expand Down Expand Up @@ -57,7 +66,22 @@ if (toVerify.length === 0) {
process.exit(0);
}

const { exists, missingNames, missingVersions, unknown } = await partitionByExistence(toVerify);
// The full list of what is still pending is worth seeing once; after that only
// how many, so a long wait does not bury the rest of the log.
let listPending = true;
const reportWait = (pending: PackageVersion[], waitMs: number) => {
console.log(
`⏳ ${pending.length} version(s) are not on the registry yet, which is expected for a few minutes after a ` +
`publish while npm scans it; re-checking in ${Math.round(waitMs / 1000)}s` +
(listPending ? `:\n ${pending.map(formatPackageVersion).join("\n ")}` : ".")
);
listPending = false;
};

const { exists, missingNames, missingVersions, unknown } = await partitionByExistenceWaitingForPublish(
toVerify,
reportWait
);
// Why a version is absent does not matter here: either way it is left unrecorded.
const missing = [...missingNames, ...missingVersions];
for (const { name, version } of exists) {
Expand All @@ -70,8 +94,9 @@ warnUnknown(unknown.map(formatPackageVersion));

if (missing.length) {
console.log(
`ℹ️ ${missing.length} version(s) on main are not on npm, either because the release publishing them has ` +
`not finished or because the package has never been published at all:\n ` +
`ℹ️ ${missing.length} version(s) on main are not on npm: the release publishing them did not finish, the ` +
`package has never been published at all, or npm's publish-time scan is holding the version back for longer ` +
`than this run waited for:\n ` +
missing.map(formatPackageVersion).join("\n ")
);
}
Expand Down
86 changes: 85 additions & 1 deletion scripts/npm-packages/shared.mts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
// Aliased, so a wait cannot be misread as scheduling a callback with the global.
import { setTimeout as sleep } from "node:timers/promises";
import { fileURLToPath } from "node:url";

type ExistStatus = "exists" | "missing" | "unknown";
Expand Down Expand Up @@ -204,12 +206,20 @@ async function check({ name, version }: PackageCheck): Promise<CheckStatus> {
return (await probe(packageUrl(name))) === "missing" ? "missingName" : "missingVersion";
}

/** How a set of checks came out: every check lands in exactly one bucket. */
export interface ExistencePartition<T extends PackageCheck> {
exists: T[];
missingNames: T[];
missingVersions: T[];
unknown: T[];
}

/**
* Queries the registry for the given checks and splits them by what it found:
* names that do not exist at all, versions that are not published under a name
* that does, and checks the registry could not answer.
*/
export async function partitionByExistence<T extends PackageCheck>(checks: T[]) {
export async function partitionByExistence<T extends PackageCheck>(checks: T[]): Promise<ExistencePartition<T>> {
const results = await Promise.all(checks.map(async (item) => ({ item, status: await check(item) })));
const withStatus = (status: CheckStatus) => results.filter((r) => r.status === status).map((r) => r.item);
return {
Expand All @@ -220,6 +230,80 @@ export async function partitionByExistence<T extends PackageCheck>(checks: T[])
};
}

/**
* How long to keep re-checking a package the registry does not have yet, and how
* long to leave between passes.
*
* Since 2026-07-28 npm scans every publish for malware before making it
* available for install, which typically takes around five minutes and can take
* 15 or more depending on the size and content of the package and on how busy
* the registry is. Until the scan passes the registry answers 404 for the new
* version exactly as it does for a version that was never published, so a caller
* probing versions that were only just published cannot tell the two apart and
* has to wait the scan out. See
* https://github.blog/changelog/2026-07-28-npm-publish-time-malware-scanning-and-dual-use-metadata/.
*
* The budget is deliberately well past the quoted 15 minutes, since those times
* are described as current typical behaviour rather than a guarantee. Overrunning
* it is not a failure: the only cost is a version left unverified.
*/
const DEFAULT_PUBLISH_SCAN_WAIT_MS = 25 * 60_000;
const PUBLISH_SCAN_POLL_INTERVAL_MS = 60_000;

/**
* The wait budget, which NPM_PUBLISH_SCAN_WAIT_MS overrides - set it to 0 for a
* local run that should not wait at all. A value that is not a non-negative
* number is an error rather than a silent fall back to the default, so a typo
* cannot leave a run waiting for 25 minutes unexplained.
*/
function getPublishScanWaitMs(): number {
const override = process.env.NPM_PUBLISH_SCAN_WAIT_MS?.trim();
if (!override) {
return DEFAULT_PUBLISH_SCAN_WAIT_MS;
}
const ms = Number(override);
if (!Number.isFinite(ms) || ms < 0) {
fail(`NPM_PUBLISH_SCAN_WAIT_MS must be a number of milliseconds >= 0, got ${override}.`);
}
return ms;
}

const PUBLISH_SCAN_WAIT_MS = getPublishScanWaitMs();

/**
* partitionByExistence for a caller probing versions that may have been
* published moments ago: whatever the registry does not have yet is re-checked
* until it appears or PUBLISH_SCAN_WAIT_MS runs out, so a version still going
* through npm's publish-time scan is not mistaken for one that was never
* published.
*
* Checks the registry could not answer are re-checked too, so a transient
* registry outage that outlasts probe's own retries is also waited out. onWait is
* called before each wait, so the caller can report that it is still waiting and
* on what.
*/
export async function partitionByExistenceWaitingForPublish<T extends PackageCheck>(
checks: T[],
onWait?: (pending: T[], waitMs: number) => void
): Promise<ExistencePartition<T>> {
const deadline = Date.now() + PUBLISH_SCAN_WAIT_MS;
let partition = await partitionByExistence(checks);
// Only the pending checks are re-queried, so each pass costs less than the
// last; what already exists is carried across passes.
const exists = [...partition.exists];
let pending = [...partition.missingNames, ...partition.missingVersions, ...partition.unknown];
while (pending.length > 0 && Date.now() < deadline) {
const waitMs = Math.min(PUBLISH_SCAN_POLL_INTERVAL_MS, deadline - Date.now());
onWait?.(pending, waitMs);
await sleep(waitMs);
partition = await partitionByExistence(pending);
exists.push(...partition.exists);
pending = [...partition.missingNames, ...partition.missingVersions, ...partition.unknown];
}
// The last pass decides how anything still pending is reported.
return { ...partition, exists };
}

/** Warns about entries the registry could not answer for. */
export function warnUnknown(unknown: string[]): void {
if (unknown.length) {
Expand Down