From 2b72e35e0cc2db671c29f0998a9f6a50a1e375b5 Mon Sep 17 00:00:00 2001 From: Jake Wang <2645794+jakezwang@users.noreply.github.com> Date: Thu, 24 Sep 2026 03:59:30 -0400 Subject: [PATCH] verify npm publication before downstream registry release --- .github/workflows/ci.yml | 2 +- .github/workflows/publish-mcp.yml | 12 +-- .github/workflows/release.yml | 10 +- publish-guides/README.md | 16 ++- publish-guides/npm.md | 22 +++-- scripts/verify-npm-publication.mjs | 126 ++++++++++++++++++++++++ scripts/verify-npm-publication.test.mjs | 96 ++++++++++++++++++ 7 files changed, 263 insertions(+), 21 deletions(-) create mode 100644 scripts/verify-npm-publication.mjs create mode 100644 scripts/verify-npm-publication.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71ea18b..d255062 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -111,7 +111,7 @@ jobs: - name: Validate release metadata and installer run: | node scripts/release-version.js --check - node --test scripts/release-version.test.js npm/scripts/install.test.js + node --test scripts/release-version.test.js scripts/verify-npm-publication.test.mjs npm/scripts/install.test.js npm pack --dry-run ./npm - name: Start MinIO (S3 chunk store backend) diff --git a/.github/workflows/publish-mcp.yml b/.github/workflows/publish-mcp.yml index 1bf6a95..fe550a6 100644 --- a/.github/workflows/publish-mcp.yml +++ b/.github/workflows/publish-mcp.yml @@ -36,17 +36,13 @@ jobs: version=$(node scripts/release-version.js "$RELEASE_VERSION") echo "version=$version" >> "$GITHUB_OUTPUT" - name: Verify npm publication is visible + timeout-minutes: 20 env: RELEASE_VERSION: ${{ steps.version.outputs.version }} run: | - for attempt in $(seq 1 12); do - if [ "$(npm view "argonctl@$RELEASE_VERSION" mcpName 2>/dev/null)" = io.github.argon-lab/argon ]; then - exit 0 - fi - sleep 5 - done - echo 'The matching argonctl package with mcpName is not visible on npm.' >&2 - exit 1 + args=(--version "$RELEASE_VERSION" --metadata-only) + if [[ "$RELEASE_VERSION" == *-* ]]; then args+=(--allow-prerelease); fi + node scripts/verify-npm-publication.mjs "${args[@]}" - name: Install mcp-publisher run: | curl --fail --location --retry 3 --output mcp-publisher.tar.gz https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_linux_amd64.tar.gz diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dc3d3ce..ce38b61 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,7 +39,7 @@ jobs: # must identify the reviewed source that the reusable CI validates. test "$(git rev-parse "refs/tags/api/v${version}^{commit}")" = "$(git rev-parse HEAD)" - name: Check release tooling - run: node --test scripts/release-version.test.js npm/scripts/install.test.js + run: node --test scripts/release-version.test.js scripts/verify-npm-publication.test.mjs npm/scripts/install.test.js release: name: Build and publish verified binaries @@ -115,6 +115,14 @@ jobs: dist_tag=latest if [[ "$RELEASE_VERSION" == *-* ]]; then dist_tag=next; fi npm publish --access public --tag "$dist_tag" + - name: Verify public registry visibility and a fresh installation + timeout-minutes: 20 + env: + RELEASE_VERSION: ${{ needs.prepare.outputs.version }} + run: | + args=(--version "$RELEASE_VERSION") + if [[ "$RELEASE_VERSION" == *-* ]]; then args+=(--allow-prerelease); fi + node scripts/verify-npm-publication.mjs "${args[@]}" publish-mcp: name: Publish to MCP Registry diff --git a/publish-guides/README.md b/publish-guides/README.md index ef7819f..83d70dc 100644 --- a/publish-guides/README.md +++ b/publish-guides/README.md @@ -11,7 +11,7 @@ For example, to prepare 2.1.2: ```sh node scripts/release-version.js 2.1.2 node scripts/release-version.js --check -node --test scripts/release-version.test.js npm/scripts/install.test.js +node --test scripts/release-version.test.js scripts/verify-npm-publication.test.mjs npm/scripts/install.test.js bash scripts/check-go-module.sh ``` @@ -37,14 +37,16 @@ The tag workflow calls the complete CI workflow **on the tagged source**, checks both tags and committed version metadata, then builds five platform binaries. Only successful validation permits GitHub release → npm → MCP publication. The npm step needs the configured `NPM_TOKEN`; MCP uses GitHub OIDC. +After npm accepts the upload, the workflow allows up to 15 minutes for public +registry propagation, checking every 30 seconds. It then installs into a fresh +temporary prefix and cache, verifies the installed binary against the release's +`SHA256SUMS`, and checks the actual CLI version before allowing MCP publication. ## Verify every channel ```sh bash scripts/check-go-module.sh v2.1.2 # external consumer; no local replaces -npm view argonctl@2.1.2 version -npm install --prefix /tmp/argon-release-check argonctl@2.1.2 -/tmp/argon-release-check/node_modules/.bin/argon --version +node scripts/verify-npm-publication.mjs --version 2.1.2 ``` Verify the corresponding active version in the @@ -59,7 +61,11 @@ from package publication. - [npm recovery](npm.md): use the clean tagged checkout and existing release assets; do not regenerate or replace an already published version. - MCP only: manually dispatch `publish-mcp.yml` with the already published npm - version. It verifies npm visibility before publishing. + version. It uses the same 15-minute visibility window before publishing. For + a metadata-only recovery check, run + `node scripts/verify-npm-publication.mjs --version 2.1.2 --metadata-only`. + An accepted npm upload can take time to appear; rerun verification or only the + failed MCP workflow after it becomes visible, never republish that version. - Python: `argon-agents` is released from its [own repository](https://github.com/argon-lab/argon-agents). Verify the actual [PyPI version](https://pypi.org/project/argon-agents/) before recommending an diff --git a/publish-guides/npm.md b/publish-guides/npm.md index ac8cdcb..ba77eb3 100644 --- a/publish-guides/npm.md +++ b/publish-guides/npm.md @@ -7,15 +7,25 @@ independently: `VERSION`, npm, MCP and Go companion requirements must agree. If the npm job fails after the release assets were published: -1. Resolve the credential or registry failure and rerun the failed job. Verify - whether the version already exists before retrying; npm versions are immutable. -2. If manual recovery is necessary, check out the exact clean release tag, +1. Inspect the upload result. Once npm reports `+ argonctl@VERSION`, the upload + was accepted even if registry readers still return 404. Do not rerun the + publisher: allow propagation and run + `node scripts/verify-npm-publication.mjs --version VERSION`. It checks every + 30 seconds for up to 15 minutes, uses a fresh install prefix and cache, checks + the installed binary against release SHA256SUMS, and runs its CLI launcher. +2. For an upload that was not accepted, resolve the credential or registry + failure before retrying. If manual publication is necessary, check out the exact clean release tag, authenticate the authorized npm publisher and run `bash scripts/publish-npm.sh`. This script requires an exact tag, checks/stamps shared metadata, previews the tarball and asks for final confirmation. -3. Check `npm view argonctl@VERSION version mcpName` and install into a fresh - temporary prefix. Run both `argon --version` and `argonctl --version`. -4. If npm was recovered manually, dispatch `publish-mcp.yml` for that same version. +3. After verification succeeds, dispatch `publish-mcp.yml` for that same version + or rerun only its failed publication job. Its metadata-only readiness check + also waits up to 15 minutes. The same check is available locally with + `node scripts/verify-npm-publication.mjs --version VERSION --metadata-only`. + +Pass `--allow-prerelease` when checking an exact prerelease such as `2.2.0-rc.1`. +For short diagnostic probes, `--timeout-ms` and `--interval-ms` accept positive +integer durations. Neither verification mode publishes anything. The installer supports macOS and Linux on amd64/arm64 and Windows amd64. Windows arm64 is not a published binary target. The npm launchers download the matching diff --git a/scripts/verify-npm-publication.mjs b/scripts/verify-npm-publication.mjs new file mode 100644 index 0000000..e7a5c7d --- /dev/null +++ b/scripts/verify-npm-publication.mjs @@ -0,0 +1,126 @@ +#!/usr/bin/env node +// Verify the public registry after npm accepts an upload. Publication may take +// minutes to become readable; never retry npm publish to resolve that delay. +import { execFile } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseArgs, promisify } from 'node:util'; +import versionTools from './release-version.js'; +import installer from '../npm/scripts/install.js'; + +const REGISTRY = 'https://registry.npmjs.org'; +const MCP_NAME = 'io.github.argon-lab/argon'; +const execute = promisify(execFile); +class VerificationError extends Error {} + +export function optionsFromArgs(args) { + const { values } = parseArgs({ args, options: { + version: { type: 'string' }, + 'timeout-ms': { type: 'string', default: '900000' }, + 'interval-ms': { type: 'string', default: '30000' }, + 'metadata-only': { type: 'boolean', default: false }, + 'allow-prerelease': { type: 'boolean', default: false }, + } }); + const version = values.version; + if (!version || versionTools.normalizeVersion(version) !== version || (!values['allow-prerelease'] && version.includes('-'))) { + throw new Error('--version must be an exact stable version such as 2.1.2 (use --allow-prerelease for an exact prerelease)'); + } + const positive = key => { + const value = Number(values[key]); + if (!/^\d+$/.test(values[key]) || !Number.isSafeInteger(value) || value < 1 || value > 2147483647) { + throw new Error(`--${key} must be a positive integer no greater than 2147483647`); + } + return value; + }; + return { version, timeoutMS: positive('timeout-ms'), intervalMS: positive('interval-ms'), metadataOnly: values['metadata-only'] }; +} + +function remaining(deadline, now) { + const time = deadline - now(); + if (time <= 0) throw new Error('publication verification deadline reached'); + return Math.min(time, 2147483647); +} + +async function response(url, deadline, { fetchImpl, now }) { + const result = await fetchImpl(url, { signal: AbortSignal.timeout(Math.min(30000, remaining(deadline, now))), cache: 'no-store' }); + if (!result.ok) throw new Error(`HTTP ${result.status} from ${url}`); + return result; +} + +export async function verifyCleanInstall(version, deadline, deps = {}) { + const { run = execute, fetchImpl = fetch, now = Date.now } = deps; + const root = await mkdtemp(join(tmpdir(), 'argon-npm-verify-')); + try { + const prefix = join(root, 'prefix'); + const cache = join(root, 'cache'); + const userconfig = join(root, 'npmrc'); + await mkdir(prefix); + await writeFile(userconfig, ''); + await run('npm', ['install', '--prefix', prefix, '--cache', cache, + '--registry', REGISTRY, '--userconfig', userconfig, '--ignore-scripts=false', + '--no-audit', '--no-fund', '--foreground-scripts', `argonctl@${version}`], + { cwd: root, encoding: 'utf8', timeout: remaining(deadline, now), maxBuffer: 2 * 1024 * 1024 }); + const packageRoot = join(prefix, 'node_modules', 'argonctl'); + const pkg = JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8')); + if (pkg.name !== 'argonctl' || pkg.version !== version || pkg.mcpName !== MCP_NAME) { + throw new VerificationError('Installed package identity/version/mcpName differs from the requested publication'); + } + const suffix = process.platform === 'win32' ? '.exe' : ''; + const asset = `argon-${installer.getPlatform()}${suffix}`; + const checksums = await (await response(`https://github.com/argon-lab/argon/releases/download/v${version}/SHA256SUMS`, deadline, { fetchImpl, now })).text(); + const matches = checksums.split(/\r?\n/).map(line => /^([a-fA-F0-9]{64})\s+\*?(\S+)$/.exec(line)).filter(match => match?.[2] === asset); + if (matches.length !== 1) throw new VerificationError(`Release SHA256SUMS must contain exactly one checksum for ${asset}`); + const sha256 = createHash('sha256').update(await readFile(join(packageRoot, 'bin', `argon-bin${suffix}`))).digest('hex'); + if (sha256 !== matches[0][1].toLowerCase()) throw new VerificationError(`Installed ${asset} failed release SHA256 verification`); + const { stdout } = await run(process.execPath, [join(packageRoot, 'bin', 'argon.js'), '--version'], + { cwd: root, encoding: 'utf8', timeout: Math.min(30000, remaining(deadline, now)), maxBuffer: 1024 * 1024 }); + if (stdout.trim() !== `argon version ${version}`) throw new VerificationError(`Installed CLI reported an unexpected version: ${stdout.trim()}`); + return { asset, sha256, cliVersion: stdout.trim() }; + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +export async function verifyPublication(options, deps = {}) { + const { fetchImpl = fetch, now = Date.now, sleep = ms => new Promise(resolve => setTimeout(resolve, ms)), log = console.log } = deps; + const deadline = now() + options.timeoutMS; + let lastError; + let attempt = 0; + while (now() < deadline) { + attempt++; + try { + const metadata = await (await response(`${REGISTRY}/argonctl/${options.version}`, deadline, { fetchImpl, now })).json(); + if (metadata.name !== 'argonctl' || metadata.version !== options.version || metadata.mcpName !== MCP_NAME) { + throw new Error('Matching public package metadata and mcpName are not visible yet'); + } + if (options.metadataOnly) { + log(`argonctl@${options.version} is visible on the public registry with the expected mcpName.`); + return { version: options.version, metadataOnly: true }; + } + const installed = await verifyCleanInstall(options.version, deadline, { ...deps, fetchImpl, now }); + log(`Verified argonctl@${options.version}: fresh npm install, release SHA256 ${installed.sha256}, ${installed.cliVersion}.`); + return { version: options.version, ...installed }; + } catch (error) { + // Integrity/identity failures require investigation, not another download. + if (error instanceof VerificationError) throw error; + lastError = error; + const timeLeft = deadline - now(); + if (timeLeft <= 0) break; + log(`argonctl@${options.version} is not ready (attempt ${attempt}): ${error.message}. Retrying in ${Math.min(options.intervalMS, timeLeft)} ms.`); + await sleep(Math.min(options.intervalMS, timeLeft)); + } + } + throw new Error(`argonctl@${options.version} was not verified within ${options.timeoutMS} ms: ${lastError?.message ?? 'deadline reached'}. Do not republish this version; check registry processing and rerun verification.`); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + await verifyPublication(optionsFromArgs(process.argv.slice(2))); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} diff --git a/scripts/verify-npm-publication.test.mjs b/scripts/verify-npm-publication.test.mjs new file mode 100644 index 0000000..9db812a --- /dev/null +++ b/scripts/verify-npm-publication.test.mjs @@ -0,0 +1,96 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { access, mkdir, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import test from 'node:test'; +import installer from '../npm/scripts/install.js'; +import { optionsFromArgs, verifyCleanInstall, verifyPublication } from './verify-npm-publication.mjs'; + +const metadata = { name: 'argonctl', version: '2.1.1', mcpName: 'io.github.argon-lab/argon' }; + +test('requires exact versions and bounded positive polling durations', () => { + assert.deepEqual(optionsFromArgs(['--version', '2.1.1']), { version: '2.1.1', timeoutMS: 900000, intervalMS: 30000, metadataOnly: false }); + for (const version of ['latest', '^2.1.1', 'v2.1.1', '2.01.1', '2.1.1-rc.1', '2.1.1;echo bad', '2.1.1\n']) { + assert.throws(() => optionsFromArgs(['--version', version]), version); + } + assert.equal(optionsFromArgs(['--version', '2.1.1-rc.1', '--allow-prerelease']).version, '2.1.1-rc.1'); + for (const value of ['0', '-1', '1.5', 'NaN', '2147483648']) assert.throws(() => optionsFromArgs(['--version', '2.1.1', '--timeout-ms', value])); +}); + +test('polls public version visibility without installing in metadata-only mode', async () => { + let clock = 0, requests = 0; + const result = await verifyPublication({ version: '2.1.1', timeoutMS: 100, intervalMS: 30, metadataOnly: true }, { + now: () => clock, sleep: async ms => { clock += ms; }, log: () => {}, + fetchImpl: async url => { + assert.equal(url, 'https://registry.npmjs.org/argonctl/2.1.1'); + return ++requests === 1 ? { ok: false, status: 404 } : { ok: true, json: async () => metadata }; + }, + run: () => assert.fail('metadata-only must not launch npm'), + }); + assert.equal(result.version, '2.1.1'); + assert.equal(requests, 2); +}); + +test('unavailable publication stops at the deadline without republishing', async () => { + let clock = 0, requests = 0; + await assert.rejects(verifyPublication({ version: '2.1.1', timeoutMS: 65, intervalMS: 30, metadataOnly: true }, { + now: () => clock, sleep: async ms => { clock += ms; }, log: () => {}, + fetchImpl: async () => { requests++; return { ok: false, status: 404 }; }, + }), /not verified within 65 ms.*Do not republish/); + assert.equal(clock, 65); + assert.equal(requests, 3); +}); + +async function installFixture(t, { checksumValid = true, cliVersion = '2.1.1' } = {}) { + const binary = Buffer.from('fixture installed binary'); + const sha256 = createHash('sha256').update(binary).digest('hex'); + const asset = `argon-${installer.getPlatform()}${process.platform === 'win32' ? '.exe' : ''}`; + let root; + let npmCalls = 0; + const deps = { + now: () => 0, + fetchImpl: async url => { + assert.equal(url, 'https://github.com/argon-lab/argon/releases/download/v2.1.1/SHA256SUMS'); + return { ok: true, text: async () => `${checksumValid ? sha256 : '0'.repeat(64)} ${asset}\n` }; + }, + run: async (command, args, options) => { + root = options.cwd; + if (command === 'npm') { + npmCalls++; + assert.equal(args[0], 'install'); + assert.equal(args.at(-1), 'argonctl@2.1.1'); + assert(args.includes('--ignore-scripts=false')); + const prefix = args[args.indexOf('--prefix') + 1]; + const cache = args[args.indexOf('--cache') + 1]; + assert.equal(dirname(prefix), root); + assert.equal(dirname(cache), root); + await assert.rejects(access(cache)); + const pkg = join(prefix, 'node_modules', 'argonctl'); + await mkdir(join(pkg, 'bin'), { recursive: true }); + await writeFile(join(pkg, 'package.json'), JSON.stringify(metadata)); + await writeFile(join(pkg, 'bin', `argon-bin${process.platform === 'win32' ? '.exe' : ''}`), binary); + return { stdout: 'installed' }; + } + assert.equal(command, process.execPath); + assert.equal(args[0], join(root, 'prefix', 'node_modules', 'argonctl', 'bin', 'argon.js')); + assert.equal(args[1], '--version'); + return { stdout: `argon version ${cliVersion}\n` }; + }, + }; + t.after(async () => { assert.equal(npmCalls, 1); await assert.rejects(access(root)); }); + return deps; +} + +test('fresh install uses its own prefix/cache, matches release SHA256 and runs the launcher', async t => { + const result = await verifyCleanInstall('2.1.1', 10000, await installFixture(t)); + assert.equal(result.cliVersion, 'argon version 2.1.1'); + assert.match(result.sha256, /^[a-f0-9]{64}$/); +}); + +test('rejects an installed binary differing from release checksums and cleans its prefix', async t => { + await assert.rejects(verifyCleanInstall('2.1.1', 10000, await installFixture(t, { checksumValid: false })), /failed release SHA256/); +}); + +test('rejects the installed launcher reporting a different version', async t => { + await assert.rejects(verifyCleanInstall('2.1.1', 10000, await installFixture(t, { cliVersion: '2.1.0' })), /unexpected version/); +});