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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
12 changes: 4 additions & 8 deletions .github/workflows/publish-mcp.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
16 changes: 11 additions & 5 deletions publish-guides/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand All @@ -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
Expand All @@ -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
Expand Down
22 changes: 16 additions & 6 deletions publish-guides/npm.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
126 changes: 126 additions & 0 deletions scripts/verify-npm-publication.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
}
96 changes: 96 additions & 0 deletions scripts/verify-npm-publication.test.mjs
Original file line number Diff line number Diff line change
@@ -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/);
});
Loading