Skip to content

Instantly share code, notes, and snippets.

@quad
Last active May 21, 2026 18:40
Show Gist options
  • Select an option

  • Save quad/7bf90db449c87e42ec0f52d26ce8c19e to your computer and use it in GitHub Desktop.

Select an option

Save quad/7bf90db449c87e42ec0f52d26ce8c19e to your computer and use it in GitHub Desktop.
A spike for phased updates from npm
import assert from 'node:assert';
import { describe, it } from 'node:test';
import { isVersionReady } from './index.ts';
const FIXED = {
packageName: 'axios',
version: '1.14.0',
publishTime: new Date('2026-01-10T00:00:00.000Z'),
consumerId: 'test-consumer-0',
};
describe('isVersionReady', () => {
it('ready field reflects elapsedDays >= delayDays', () => {
const { ready, elapsedDays, delayDays } = isVersionReady(
FIXED.packageName, FIXED.version, FIXED.publishTime, FIXED.consumerId,
);
assert.strictEqual(elapsedDays >= delayDays, ready);
});
it('staggered rollout: consumers get different delays for the same package', () => {
const MS_PER_DAY = 86_400_000;
const now = new Date(FIXED.publishTime.getTime() + 7 * MS_PER_DAY);
const check = (consumerId: string) =>
isVersionReady(FIXED.packageName, FIXED.version, FIXED.publishTime, consumerId, now).ready;
assert.ok(check('consumer-0'));
assert.ok(!check('consumer-1'));
assert.ok(check('consumer-3'));
});
});
import { execFile, spawn } from 'node:child_process';
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { promisify } from 'node:util';
import semver from 'semver';
const execFileAsync = promisify(execFile);
const MAX_DELAY_DAYS = 14;
type PackageJson = { name?: string };
function readPackageJson() {
return JSON.parse(readFileSync('package.json', 'utf8')) as PackageJson;
}
// Override with a secret value in CI to prevent delay enumeration by adversaries.
function getConsumerId({ name }: PackageJson) {
if (!name) throw new Error('package.json is missing a name field');
return process.env['COOLDOWN_CONSUMER_ID'] || name;
}
type NpmViewResult = { versions: string[]; time: Record<string, string> };
async function runNpmView(packageName: string) {
const { stdout } = await execFileAsync('npm', ['view', '--json', '--', packageName]);
return JSON.parse(stdout) as NpmViewResult;
}
type OutdatedEntry = { current: string; wanted: string };
async function runNpmOutdated() {
try {
const { stdout } = await execFileAsync('npm', ['outdated', '--json']);
return JSON.parse(stdout) as Record<string, OutdatedEntry>;
} catch (e: unknown) {
// npm outdated exits 1 when any package is outdated; stdout still contains JSON
const err = e as { stdout?: string };
if (!err.stdout) throw e;
return JSON.parse(err.stdout) as Record<string, OutdatedEntry>;
}
}
export function computeDelayDays(
packageName: string,
version: string,
publishTime: Date,
consumerId: string,
) {
const raw = createHash('sha256')
.update(
JSON.stringify({ consumerId, packageName, version, publishTime }),
)
.digest();
// Treat the first 8 bytes of the hash as a fraction of 2^64 to get a uniform value in [0, 1).
const fraction = Number(raw.readBigUInt64BE(0)) / 2 ** 64;
// sqrt skews uniform → most consumers fall in the 7–14 day range, producing a gradual rollout
// tail: ~75% wait past the midpoint rather than bunching near day 0.
return MAX_DELAY_DAYS * Math.sqrt(fraction);
}
export function isVersionReady(
packageName: string,
version: string,
publishTime: Date,
consumerId: string,
now = new Date(),
) {
const delayDays = computeDelayDays(packageName, version, publishTime, consumerId);
const MS_PER_DAY = 86_400_000;
const elapsedDays = (now.getTime() - publishTime.getTime()) / MS_PER_DAY;
return { ready: elapsedDays >= delayDays, delayDays, elapsedDays };
}
type Update = {
packageName: string;
current: string;
wanted: string;
} & (
| { readyVersion: string; elapsedDays: number }
| { readyVersion: null; wantedRemainingDays: number }
);
async function decideUpdate(
packageName: string,
info: OutdatedEntry,
consumerId: string,
): Promise<Update | null> {
const view = await runNpmView(packageName);
if (!semver.valid(info.wanted)) return null;
// npm outdated computes `wanted` as the highest version satisfying the declared range,
// so (current, wanted] is equivalent to filtering by the range itself.
const candidates = view.versions
.filter((v) => semver.gt(v, info.current) && view.time[v] && semver.lte(v, info.wanted))
.sort(semver.rcompare);
if (candidates.length === 0) return null;
const evaluated = candidates.map((version) => ({
version,
...isVersionReady(packageName, version, new Date(view.time[version]), consumerId),
}));
const base = { packageName, ...info };
// Install the highest version that has cleared its delay; if none has, report how long
// until the declared `wanted` version is ready.
const readyCandidate = evaluated.find((c) => c.ready);
if (readyCandidate)
return { ...base, readyVersion: readyCandidate.version, elapsedDays: readyCandidate.elapsedDays };
const wantedResult = evaluated.find((c) => c.version === info.wanted);
if (!wantedResult) throw new Error(`${packageName}@${info.wanted} not found in npm view`);
return { ...base, readyVersion: null, wantedRemainingDays: wantedResult.delayDays - wantedResult.elapsedDays };
}
async function computeUpdates() {
const consumerId = getConsumerId(readPackageJson());
const outdated = await runNpmOutdated();
const results = await Promise.all(
Object.entries(outdated).map(([packageName, info]) =>
decideUpdate(packageName, info, consumerId),
),
);
return results
.filter((d): d is Update => d !== null)
.sort((a, b) => a.packageName.localeCompare(b.packageName));
}
async function runNpmInstall(packages: string[]) {
await new Promise<void>((resolve, reject) =>
spawn('npm', ['install', '--', ...packages], { stdio: 'inherit' })
.on('error', reject)
.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`npm install exited ${code}`)))),
);
}
async function applyUpdates(decisions: Update[]) {
const toInstall = decisions.flatMap((d) =>
d.readyVersion !== null ? [`${d.packageName}@${d.readyVersion}`] : [],
);
if (toInstall.length === 0) return;
await runNpmInstall(toInstall);
}
async function main() {
const decisions = await computeUpdates();
const fmt = (d: number) =>
d >= 1 ? `${d.toFixed(1)}d` : `${(d * 24).toFixed(1)}h`;
for (const d of decisions) {
const out = d.readyVersion === null
? `waiting\t${d.packageName}\t${d.current}\t${d.wanted}\t${fmt(d.wantedRemainingDays)}\n`
: `ready\t${d.packageName}\t${d.current}\t${d.readyVersion}\t${fmt(d.elapsedDays)}\n`;
process.stdout.write(out);
}
const dryRun = process.argv.includes('--dry-run');
if (!dryRun) await applyUpdates(decisions);
}
if (import.meta.main) await main();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment