Skip to content

Commit 73dabb3

Browse files
cliffhallclaude
andcommitted
fix: publish only an artifact that is the released package at the released version
detect-packages records each package's name and version from the released commit, before any dependency code runs (scripts/release-manifest.mjs). The publish jobs refuse an npm tarball whose manifest names another package or version, and a PyPI distribution whose file name is not this project's at this version. Every package trusts the same workflow and environment, so without this a tampered build could publish as another of them. The release skill refreshes its refs and reads the remote tree when checking for pending changesets. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: cliffhall <[email protected]>
1 parent 75f7063 commit 73dabb3

6 files changed

Lines changed: 274 additions & 6 deletions

File tree

‎.claude/skills/release/SKILL.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,10 @@ rather than assuming it, since a TypeScript change merged without its changeset
143143
releases nothing:
144144

145145
```sh
146-
ls .changeset/*.md | grep -v README.md # the pending changesets
146+
# Refresh first, and read the REMOTE tree: the checkout may be a preparation
147+
# branch cut before another PR merged.
148+
git fetch origin main v2/main --tags
149+
git ls-tree --name-only origin/v2/main .changeset/ | grep '\.md$' | grep -v README.md # the pending changesets
147150
git log --oneline "$(git describe --tags --abbrev=0 origin/main)"..origin/v2/main -- \
148151
src/everything src/filesystem src/memory src/sequentialthinking
149152
```

‎.github/workflows/release.yml‎

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ jobs:
7070
outputs:
7171
npm_packages: ${{ steps.find-packages.outputs.npm_packages }}
7272
pypi_packages: ${{ steps.find-packages.outputs.pypi_packages }}
73+
npm_expected: ${{ steps.expected.outputs.npm_expected }}
74+
pypi_expected: ${{ steps.expected.outputs.pypi_expected }}
7375
steps:
7476
# On a release event the default ref is the release's tag: the exact
7577
# commit the maintainer released. Full history, for the check below.
@@ -98,6 +100,14 @@ jobs:
98100
echo "npm_packages=$NPM" >> "$GITHUB_OUTPUT"
99101
echo "pypi_packages=$PYPI" >> "$GITHUB_OUTPUT"
100102
103+
# Each package's name and version, read from the released commit before
104+
# any dependency code has run. The publish jobs compare the artifact
105+
# they are handed with these; the reasoning is in the script's header.
106+
# It imports nothing outside Node, so nothing is installed for it.
107+
- name: Record what each package should be published as
108+
id: expected
109+
run: node scripts/release-manifest.mjs
110+
101111
build-npm:
102112
timeout-minutes: 15
103113
needs: [detect-packages]
@@ -213,6 +223,8 @@ jobs:
213223
- name: Publish package
214224
env:
215225
NPM_CONFIG_PROVENANCE: "true"
226+
EXPECTED_NAME: ${{ fromJson(needs.detect-packages.outputs.npm_expected)[matrix.package].name }}
227+
EXPECTED_VERSION: ${{ fromJson(needs.detect-packages.outputs.npm_expected)[matrix.package].version }}
216228
run: |
217229
if [ -e release-artifact/SKIP ]; then
218230
echo "This version is already on npm; nothing to publish."
@@ -231,20 +243,35 @@ jobs:
231243
# `publishConfig` into its own config before the OIDC exchange, so an
232244
# injected registry or proxy could redirect this job's credential
233245
# flow. No package here declares one, so any present is refused, and
234-
# the registry is named on the command line as well.
246+
# the registry is named on the command line as well. For the same
247+
# reason its name and version are compared with what the released
248+
# commit declares (recorded by `detect-packages`, before any
249+
# dependency code ran): every package here trusts this workflow, so
250+
# a tampered build could otherwise publish as another of them, or at
251+
# a version nobody reviewed.
235252
# The check fails closed: a tarball that cannot be read, or a
236253
# manifest that does not parse, stops the job. Only a manifest that
237254
# was read and has no `publishConfig` goes on to `npm publish`.
238255
MANIFEST="$(tar -xOzf "$1" package/package.json)" || {
239256
echo "Refusing to publish: could not read package/package.json from $1"
240257
exit 1
241258
}
242-
VERDICT="$(printf '%s' "$MANIFEST" | node -e "console.log('publishConfig' in JSON.parse(require('fs').readFileSync(0, 'utf8')) ? 'declared' : 'clean')")" || {
259+
VERDICT="$(printf '%s' "$MANIFEST" | node -e "
260+
const m = JSON.parse(require('fs').readFileSync(0, 'utf8'));
261+
if ('publishConfig' in m) console.log('declares publishConfig');
262+
else if (m.name !== process.env.EXPECTED_NAME) console.log('is named ' + m.name + ', expected ' + process.env.EXPECTED_NAME);
263+
else if (m.version !== process.env.EXPECTED_VERSION) console.log('is version ' + m.version + ', expected ' + process.env.EXPECTED_VERSION);
264+
else console.log('clean');
265+
")" || {
243266
echo "Refusing to publish: the tarball's package.json does not parse"
244267
exit 1
245268
}
269+
if [ -z "$EXPECTED_NAME" ] || [ -z "$EXPECTED_VERSION" ]; then
270+
echo "Refusing to publish: no expected name or version was recorded for this package"
271+
exit 1
272+
fi
246273
if [ "$VERDICT" != "clean" ]; then
247-
echo "Refusing to publish: the tarball's package.json declares publishConfig"
274+
echo "Refusing to publish: the tarball's package.json $VERDICT"
248275
exit 1
249276
fi
250277
npm publish "$1" --access public --registry https://registry.npmjs.org/
@@ -324,6 +351,35 @@ jobs:
324351
name: pypi-${{ matrix.package }}
325352
path: dist
326353

354+
# The artifact is untrusted input here, as in `publish-npm`: all three
355+
# PyPI projects trust this workflow, so each file must be a wheel or an
356+
# sdist of THIS project at the version the released commit declares.
357+
# PyPI itself rejects a file whose name disagrees with its metadata, so
358+
# the name is the thing to check.
359+
- name: Check the distributions are this package's
360+
env:
361+
STEM: ${{ fromJson(needs.detect-packages.outputs.pypi_expected)[matrix.package].stem }}
362+
run: |
363+
if [ -z "$STEM" ]; then
364+
echo "Refusing to publish: no expected name and version were recorded for this package"
365+
exit 1
366+
fi
367+
found=0
368+
for f in dist/* dist/.[!.]*; do
369+
[ -e "$f" ] || continue
370+
case "$(basename "$f")" in
371+
"$STEM.tar.gz" | "$STEM"-*.whl) found=$((found + 1)) ;;
372+
*)
373+
echo "Refusing to publish: $(basename "$f") is not a distribution of $STEM"
374+
exit 1
375+
;;
376+
esac
377+
done
378+
if [ "$found" -eq 0 ]; then
379+
echo "Refusing to publish: no distribution of $STEM in the artifact"
380+
exit 1
381+
fi
382+
327383
# `skip-existing` is the PyPI side of the registry-diff guard: an
328384
# already-published version is skipped, not failed.
329385
- name: Publish package to PyPI

‎AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ servers/
5454
├── .changeset/ Pending changesets for the TypeScript servers, and the changesets config
5555
├── scripts/ The pre-push gate (gate-lease, smoke-servers, validate-py), its guards (verify-*),
5656
│ the skills tooling, and release tooling (npm-publish-guard, prepare-python-release,
57-
│ pack-and-verify)
57+
│ pack-and-verify, release-manifest)
5858
├── docs/ Design documents; quality-gate.md is the gate's reference (stages, CI vs local, the lease);
5959
│ contribution-model.md holds the outside-PR backlog plan
6060
├── .github/workflows/ typescript.yml, python.yml (per-package CI), release.yml (publishes on a GitHub Release),

‎RELEASING.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ Publishing is triggered by a maintainer **publishing a GitHub Release** whose ta
5050
| Job | Holds | Does |
5151
| --- | --- | --- |
5252
| **Build** (`build-npm`, `build-pypi`) | No `id-token`, no environment | Installs dependencies, **runs the package's tests** (for Python, the whole `validate:py` chain: ruff, pyright, pytest), then runs **`pack:verify`**: it builds the tarball or wheel, installs it into an empty directory and boots the installed server. Uploads that verified artifact |
53-
| **Publish** (`publish-npm`, `publish-pypi`) | `id-token: write` and the `release` environment (a required reviewer must approve each deployment) | Downloads the artifact and hands it to the registry. No checkout, no dependency install, no build |
53+
| **Publish** (`publish-npm`, `publish-pypi`) | `id-token: write` and the `release` environment (a required reviewer must approve each deployment) | Downloads the artifact, checks that its name and version are the ones the released commit declares for that package, and hands it to the registry. No checkout, no dependency install, no build |
5454

5555
The split is about what runs next to the publish credential. Installing dependencies runs their lifecycle scripts, and a build runs a toolchain; in a job that can mint an OIDC token, any of that code could publish. Here the token only ever sits beside the registry client and SHA-pinned actions. Every action in the build and publish jobs is pinned to a commit SHA, enforced by `npm run verify:action-pins`.
5656

‎scripts/release-manifest.mjs‎

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
#!/usr/bin/env node
2+
// What each package on the released commit is called and which version it
3+
// carries, for `release.yml` (#4873).
4+
//
5+
// The publish jobs hold the OIDC credential and publish an artifact a build
6+
// job produced. That artifact is untrusted input: it was built after
7+
// dependency install scripts ran, and every package in this repository
8+
// trusts the same workflow and environment, so a tampered build could hand
9+
// the publish job a tarball named for a DIFFERENT trusted package, or
10+
// carrying an arbitrary version. The publish jobs therefore compare what they
11+
// are about to publish with what the released source says it should be.
12+
//
13+
// That expectation has to come from somewhere dependency code has not run.
14+
// `detect-packages` is that place: it checks out the tag and runs this script,
15+
// which reads the manifests with nothing installed and no third-party import.
16+
// Its output reaches each publish leg as a job output, not as a file a build
17+
// job could rewrite.
18+
//
19+
// Under GitHub Actions it writes two step outputs, `npm_expected` and
20+
// `pypi_expected`, each a JSON object keyed by the package's directory under
21+
// `src/`: `{ "memory": { "name": "@scope/pkg", "version": "1.0.0" } }`. It
22+
// always prints the same JSON to stdout.
23+
24+
import { appendFileSync, existsSync, readdirSync, readFileSync } from "node:fs";
25+
import path from "node:path";
26+
import { fileURLToPath, pathToFileURL } from "node:url";
27+
import { readProject } from "./prepare-python-release.mjs";
28+
29+
const repoRoot = path.resolve(
30+
path.dirname(fileURLToPath(import.meta.url)),
31+
"..",
32+
);
33+
34+
/**
35+
* The name and version of every package under `src/`, by registry.
36+
*
37+
* @param {string} [root] the repository root
38+
* @returns {{ npm: Record<string, { name: string, version: string }>, pypi: Record<string, { name: string, version: string }> }}
39+
*/
40+
export function expectedPackages(root = repoRoot) {
41+
const src = path.join(root, "src");
42+
const npm = {};
43+
const pypi = {};
44+
for (const entry of readdirSync(src, { withFileTypes: true })) {
45+
if (!entry.isDirectory()) continue;
46+
const dir = path.join(src, entry.name);
47+
const manifest = path.join(dir, "package.json");
48+
if (existsSync(manifest)) {
49+
const { name, version } = JSON.parse(readFileSync(manifest, "utf8"));
50+
npm[entry.name] = identity(name, version, manifest);
51+
}
52+
const pyproject = path.join(dir, "pyproject.toml");
53+
if (existsSync(pyproject)) {
54+
const { name, version } = readProject(readFileSync(pyproject, "utf8"));
55+
pypi[entry.name] = identity(name, version, pyproject);
56+
}
57+
}
58+
return { npm, pypi };
59+
}
60+
61+
/** A name and a version, both non-empty strings, or a loud failure. */
62+
function identity(name, version, file) {
63+
if (typeof name !== "string" || name === "")
64+
throw new Error(`${file} has no package name`);
65+
if (typeof version !== "string" || version === "")
66+
throw new Error(`${file} has no version`);
67+
return { name, version };
68+
}
69+
70+
/**
71+
* The file-name stem PyPI distributions of a project carry: the project name
72+
* with every run of `-`, `_` and `.` turned into one `_`, lowercased, then
73+
* `-<version>`. A wheel is `<stem>-<tags>.whl`, an sdist `<stem>.tar.gz`.
74+
*
75+
* @param {string} name
76+
* @param {string} version
77+
* @returns {string}
78+
*/
79+
export function distributionStem(name, version) {
80+
return `${name.replace(/[-_.]+/g, "_").toLowerCase()}-${version}`;
81+
}
82+
83+
/**
84+
* @param {object} [options]
85+
* @param {string} [options.root]
86+
* @param {NodeJS.ProcessEnv} [options.env]
87+
* @returns {number} the exit code
88+
*/
89+
export function main({ root = repoRoot, env = process.env } = {}) {
90+
const { npm, pypi } = expectedPackages(root);
91+
// The stem is computed here, once, so the publish job's shell compares file
92+
// names with a string and does no normalizing of its own.
93+
for (const pkg of Object.values(pypi))
94+
pkg.stem = distributionStem(pkg.name, pkg.version);
95+
console.log(JSON.stringify({ npm, pypi }, null, 2));
96+
if (env.GITHUB_OUTPUT)
97+
appendFileSync(
98+
env.GITHUB_OUTPUT,
99+
`npm_expected=${JSON.stringify(npm)}\npypi_expected=${JSON.stringify(pypi)}\n`,
100+
);
101+
return 0;
102+
}
103+
104+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
105+
process.exit(main());

‎scripts/release-manifest.test.mjs‎

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
// Tests for the released-package identities `release.yml` checks artifacts
2+
// against (#4873), over a throwaway tree and over this repository.
3+
// Run via `npm run test:scripts`.
4+
5+
import { test } from "node:test";
6+
import assert from "node:assert/strict";
7+
import {
8+
mkdirSync,
9+
mkdtempSync,
10+
readFileSync,
11+
rmSync,
12+
writeFileSync,
13+
} from "node:fs";
14+
import { tmpdir } from "node:os";
15+
import path from "node:path";
16+
import {
17+
distributionStem,
18+
expectedPackages,
19+
main,
20+
} from "./release-manifest.mjs";
21+
22+
function withTree(files, fn) {
23+
const root = mkdtempSync(path.join(tmpdir(), "release-manifest-"));
24+
try {
25+
mkdirSync(path.join(root, "src"));
26+
for (const [file, text] of Object.entries(files)) {
27+
mkdirSync(path.dirname(path.join(root, file)), { recursive: true });
28+
writeFileSync(path.join(root, file), text);
29+
}
30+
return fn(root);
31+
} finally {
32+
rmSync(root, { recursive: true, force: true });
33+
}
34+
}
35+
36+
const tree = {
37+
"src/memory/package.json": JSON.stringify({
38+
name: "@scope/server-memory",
39+
version: "1.2.0",
40+
}),
41+
"src/time/pyproject.toml":
42+
'[project]\nname = "mcp-server-time"\nversion = "2026.8.1"\n',
43+
"src/notes/README.md": "not a package",
44+
"src/stray.txt": "not a directory",
45+
};
46+
47+
test("expectedPackages reads each manifest, keyed by directory", () => {
48+
withTree(tree, (root) => {
49+
assert.deepEqual(expectedPackages(root), {
50+
npm: { memory: { name: "@scope/server-memory", version: "1.2.0" } },
51+
pypi: { time: { name: "mcp-server-time", version: "2026.8.1" } },
52+
});
53+
});
54+
});
55+
56+
test("a manifest with no name or no version fails loudly", () => {
57+
withTree({ "src/bad/package.json": JSON.stringify({ name: "x" }) }, (root) =>
58+
assert.throws(() => expectedPackages(root), /has no version/),
59+
);
60+
withTree(
61+
{ "src/bad/package.json": JSON.stringify({ version: "1.0.0" }) },
62+
(root) =>
63+
assert.throws(() => expectedPackages(root), /has no package name/),
64+
);
65+
});
66+
67+
test("distributionStem is the normalized name PyPI file names carry", () => {
68+
assert.equal(
69+
distributionStem("mcp-server-time", "2026.8.1"),
70+
"mcp_server_time-2026.8.1",
71+
);
72+
assert.equal(distributionStem("Some.Odd__Name", "1.0"), "some_odd_name-1.0");
73+
});
74+
75+
test("main writes both step outputs as single-line JSON", (t) => {
76+
t.mock.method(console, "log", () => {});
77+
withTree(tree, (root) => {
78+
const out = path.join(root, "output");
79+
assert.equal(main({ root, env: { GITHUB_OUTPUT: out } }), 0);
80+
const lines = readFileSync(out, "utf8").trimEnd().split("\n");
81+
assert.equal(lines.length, 2);
82+
assert.deepEqual(JSON.parse(lines[0].replace(/^npm_expected=/, "")), {
83+
memory: { name: "@scope/server-memory", version: "1.2.0" },
84+
});
85+
assert.deepEqual(JSON.parse(lines[1].replace(/^pypi_expected=/, "")), {
86+
time: {
87+
name: "mcp-server-time",
88+
version: "2026.8.1",
89+
stem: "mcp_server_time-2026.8.1",
90+
},
91+
});
92+
});
93+
});
94+
95+
test("this repository's seven packages are all found", () => {
96+
const { npm, pypi } = expectedPackages();
97+
assert.deepEqual(Object.keys(npm).sort(), [
98+
"everything",
99+
"filesystem",
100+
"memory",
101+
"sequentialthinking",
102+
]);
103+
assert.deepEqual(Object.keys(pypi).sort(), ["fetch", "git", "time"]);
104+
});

0 commit comments

Comments
 (0)