Last active
May 21, 2026 18:40
-
-
Save quad/7bf90db449c87e42ec0f52d26ce8c19e to your computer and use it in GitHub Desktop.
A spike for phased updates from npm
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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')); | |
| }); | |
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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