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
4 changes: 3 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -205,14 +205,16 @@ jobs:
}
});
' <<<"$metadata"
- name: Publish npm package
- id: publish
name: Publish npm package
run: npm publish --provenance --access public
- name: Verify npm publication is publicly discoverable
shell: bash
env:
VERSION: ${{ inputs.release_tag || github.ref_name }}
run: node scripts/verify-npm-publication.mjs "${VERSION#v}"
- name: Attach npm package to GitHub release
if: always() && steps.publish.outcome == 'success'
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ inputs.release_tag || github.ref_name }}
Expand Down
13 changes: 3 additions & 10 deletions npm/package.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ test("release verifies npm discovery before publishing MCP Registry metadata", a
assert.match(workflow, /node scripts\/verify-npm-publication\.mjs/);
});

test("publication verification requires the public latest tag and a fresh npx runtime", async () => {
test("publication verification requires the public latest tag and a resolvable version", async () => {
const workspace = await mkdtemp(join(tmpdir(), "gitcontribute-publication-check-"));
try {
const client = join(workspace, "registry-client");
Expand All @@ -95,7 +95,6 @@ const args = process.argv.slice(2);
fs.appendFileSync(process.env.GITCONTRIBUTE_TEST_CALL_LOG, JSON.stringify(args) + "\\n");
if (args[0] === "view" && args[2] === "dist-tags.latest") process.stdout.write('"1.2.3"\\n');
else if (args[0] === "view" && args[1] === "gitcontribute@1.2.3" && args[2] === "version") process.stdout.write('"1.2.3"\\n');
else if (args[0] === "--yes") process.stdout.write('{"version":"1.2.3"}\\n');
else process.exitCode = 1;
`);
await chmod(client, 0o755);
Expand All @@ -105,7 +104,6 @@ else process.exitCode = 1;
env: {
...process.env,
GITCONTRIBUTE_NPM_COMMAND: client,
GITCONTRIBUTE_NPX_COMMAND: client,
GITCONTRIBUTE_NPM_PUBLICATION_ATTEMPTS: "1",
GITCONTRIBUTE_TEST_CALL_LOG: log,
},
Expand All @@ -115,7 +113,6 @@ else process.exitCode = 1;
assert.deepEqual(calls, [
["view", "gitcontribute", "dist-tags.latest", "--json", "--prefer-online", "--registry=https://registry.npmjs.org"],
["view", "gitcontribute@1.2.3", "version", "--json", "--prefer-online", "--registry=https://registry.npmjs.org"],
["--yes", "--prefer-online", "gitcontribute@latest", "metadata", "--json"],
]);
} finally {
await rm(workspace, { recursive: true, force: true });
Expand All @@ -134,7 +131,6 @@ fs.writeFileSync(process.env.GITCONTRIBUTE_TEST_ATTEMPTS, String(count + 1));
if (count === 0) process.exitCode = 1;
else if (process.argv[2] === "view" && process.argv[4] === "dist-tags.latest") process.stdout.write('"1.2.3"\\n');
else if (process.argv[2] === "view" && process.argv[3] === "gitcontribute@1.2.3" && process.argv[4] === "version") process.stdout.write('"1.2.3"\\n');
else if (process.argv[2] === "--yes") process.stdout.write('{"version":"1.2.3"}\\n');
else process.exitCode = 1;
`);
await chmod(client, 0o755);
Expand All @@ -144,14 +140,13 @@ else process.exitCode = 1;
env: {
...process.env,
GITCONTRIBUTE_NPM_COMMAND: client,
GITCONTRIBUTE_NPX_COMMAND: client,
GITCONTRIBUTE_NPM_PUBLICATION_ATTEMPTS: "2",
GITCONTRIBUTE_NPM_PUBLICATION_DELAY_MS: "1",
GITCONTRIBUTE_TEST_ATTEMPTS: state,
},
});
assert.equal(result.status, 0, result.stderr || result.stdout);
assert.equal(await readFile(state, "utf8"), "4");
assert.equal(await readFile(state, "utf8"), "3");
} finally {
await rm(workspace, { recursive: true, force: true });
}
Expand All @@ -169,7 +164,6 @@ fs.writeFileSync(process.env.GITCONTRIBUTE_TEST_ATTEMPTS, String(count + 1));
if (count === 0) setInterval(() => {}, 1_000);
else if (process.argv[2] === "view" && process.argv[4] === "dist-tags.latest") process.stdout.write('"1.2.3"\\n');
else if (process.argv[2] === "view" && process.argv[3] === "gitcontribute@1.2.3" && process.argv[4] === "version") process.stdout.write('"1.2.3"\\n');
else if (process.argv[2] === "--yes") process.stdout.write('{"version":"1.2.3"}\\n');
else process.exitCode = 1;
`);
await chmod(client, 0o755);
Expand All @@ -179,15 +173,14 @@ else process.exitCode = 1;
env: {
...process.env,
GITCONTRIBUTE_NPM_COMMAND: client,
GITCONTRIBUTE_NPX_COMMAND: client,
GITCONTRIBUTE_NPM_PUBLICATION_ATTEMPTS: "2",
GITCONTRIBUTE_NPM_PUBLICATION_DELAY_MS: "1",
GITCONTRIBUTE_NPM_PUBLICATION_PROBE_TIMEOUT_MS: "500",
GITCONTRIBUTE_TEST_ATTEMPTS: state,
},
});
assert.equal(result.status, 0, result.stderr || result.stdout);
assert.equal(await readFile(state, "utf8"), "4");
assert.equal(await readFile(state, "utf8"), "3");
} finally {
await rm(workspace, { recursive: true, force: true });
}
Expand Down
23 changes: 12 additions & 11 deletions scripts/verify-npm-publication.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,29 @@ import { spawn } from "node:child_process";
const expectedVersion = process.argv[2];
if (!expectedVersion) throw new Error("expected version argument is required");

// Public discoverability is a registry-metadata property: the version must be
// resolvable and the `latest` dist-tag must point at it. The published binary
// is already smoke-tested earlier in the release job via the local tarball, so
// re-resolving `@latest` through npx here would only re-download the native
// tarball to re-confirm metadata that `npm view` already proves. That extra
// round-trip was the flaky straggler that failed the 3.0.0 release.
const registry = "https://registry.npmjs.org";
const attempts = positiveInteger("GITCONTRIBUTE_NPM_PUBLICATION_ATTEMPTS", 10, 30);
const delayMS = positiveInteger("GITCONTRIBUTE_NPM_PUBLICATION_DELAY_MS", 6_000, 60_000);
const attempts = positiveInteger("GITCONTRIBUTE_NPM_PUBLICATION_ATTEMPTS", 30, 60);
const delayMS = positiveInteger("GITCONTRIBUTE_NPM_PUBLICATION_DELAY_MS", 5_000, 60_000);
const probeTimeoutMS = positiveInteger("GITCONTRIBUTE_NPM_PUBLICATION_PROBE_TIMEOUT_MS", 30_000, 120_000);
const npm = process.env.GITCONTRIBUTE_NPM_COMMAND || "npm";
const npx = process.env.GITCONTRIBUTE_NPX_COMMAND || "npx";

for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
const latest = await output(npm, ["view", "gitcontribute", "dist-tags.latest", "--json", "--prefer-online", `--registry=${registry}`]);
const published = await output(npm, ["view", `gitcontribute@${expectedVersion}`, "version", "--json", "--prefer-online", `--registry=${registry}`]);
if (jsonString(latest) === expectedVersion && jsonString(published) === expectedVersion) {
const metadata = await output(npx, ["--yes", "--prefer-online", "gitcontribute@latest", "metadata", "--json"]);
if (JSON.parse(metadata).version === expectedVersion) {
console.log(`npm release ${expectedVersion} is publicly discoverable`);
process.exit(0);
}
console.log(`npm release ${expectedVersion} is publicly discoverable`);
process.exit(0);
}
} catch {
// Registry propagation and fresh npx resolution are expected to be
// transient immediately after publication. The bounded retry loop owns
// those probes as one operation.
// Registry propagation is expected to be transient immediately after
// publication. The bounded retry loop owns those probes as one operation.
}
if (attempt < attempts) await new Promise((resolve) => setTimeout(resolve, delayMS));
}
Expand Down
Loading