Skip to content

Commit 5360782

Browse files
committed
Fix npm release asset selection
Select only newly prefixed publishable npm tarballs, leave legacy launcher archives untouched, and reject old releases without the complete new asset set. Cover mixed and legacy-only release fixtures. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 23fc3b8d-7678-46d3-9fe7-875d8311b526
1 parent a943c59 commit 5360782

3 files changed

Lines changed: 73 additions & 22 deletions

File tree

‎README.md‎

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -98,13 +98,17 @@ npm install -g @github/copilot@prerelease
9898
### npm release publishing
9999

100100
The [npm release workflow](.github/workflows/publish-npm.yml) runs when a GitHub
101-
release is **published**. It downloads the nine already-built npm `.tgz` release
102-
assets and validates their names, SHA-256 digests, package identities, versions,
103-
platform metadata, and launcher dependencies before publishing anything. It does
104-
not build from or execute release-tag code. Run the workflow manually on `main`
105-
with the exact published `release_tag` (for example `v1.0.89`) to recover a
106-
missed or failed release event. Matching versions are skipped only when their
107-
npm `dist.integrity` matches the release tarball. Older releases use a
101+
release is **published**. It downloads only the nine already-built npm assets:
102+
`npm-github-copilot-${VERSION}.tgz` and
103+
`npm-github-copilot-${VERSION}-${PLATFORM}.tgz` for each of the eight supported
104+
platforms. It ignores the older `github-copilot-*.tgz` launcher tarballs and
105+
validates the nine new assets' names, SHA-256 digests, package identities,
106+
versions, platform metadata, and launcher dependencies before publishing
107+
anything. It does not build from or execute release-tag code. Run the workflow
108+
manually on `main` with the exact published `release_tag` to recover a missed
109+
or failed release event; releases without all nine new npm assets are rejected
110+
even on manual recovery. Matching versions are skipped only when their npm
111+
`dist.integrity` matches the release tarball. Older releases use a
108112
version-specific `release-<version>` npm tag if `latest` or `prerelease` has
109113
advanced, so recovery never intentionally downgrades those channels. A version
110114
already on npm with a missing/stale channel tag fails closed: npm OIDC cannot
@@ -125,11 +129,10 @@ no `NPM_TOKEN` or `NODE_AUTH_TOKEN`. The runtime repository must continue its
125129
existing publishing until this workflow is merged **and all nine npm trusted
126130
publishers are configured**; only then should its npm publication be cut over.
127131
Its internal Azure feed publication and ancillary release tasks remain separate.
128-
The release assets must actually contain nine publishable packages: as of
129-
September 29, 2026, the platform-named assets in `v1.0.90-4` still contain
130-
the `@github/copilot` launcher manifest rather than platform package manifests.
131-
This workflow will reject those assets; the release artifact producer must
132-
correct them before the npm cutover.
132+
The release artifact producer must attach the nine actual npm package tarballs
133+
under the new `npm-github-copilot-` names before cutover. Older releases such as
134+
`v1.0.90-4` contain only the legacy launcher-manifest tarballs and cannot be
135+
recovered through this workflow.
133136

134137

135138
### Launching the CLI

‎script/publish-npm-release.mjs‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const platforms = [
1818
];
1919
const repository = "github/copilot-cli";
2020
const packageName = (platform) => `@github/copilot${platform ? `-${platform}` : ""}`;
21+
const assetName = (version, platform) => `npm-github-copilot-${version}${platform ? `-${platform}` : ""}.tgz`;
2122

2223
export function validateRelease(release, tag, eventId, eventPrerelease) {
2324
const match = /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9]|[1-9]\d*))?$/.exec(tag);
@@ -35,10 +36,10 @@ export function validateRelease(release, tag, eventId, eventPrerelease) {
3536
throw new Error(`Prerelease flag for ${tag} differs from the triggering event`);
3637
}
3738
const version = tag.slice(1);
38-
const names = platforms.map(([platform]) => `github-copilot-${version}-${platform}.tgz`);
39-
names.push(`github-copilot-${version}.tgz`);
39+
const names = platforms.map(([platform]) => assetName(version, platform));
40+
names.push(assetName(version));
4041
const expected = new Set(names);
41-
const assets = release.assets.filter((asset) => asset.name.endsWith(".tgz"));
42+
const assets = release.assets.filter((asset) => asset.name.startsWith("npm-github-copilot-") && asset.name.endsWith(".tgz"));
4243
if (assets.length !== expected.size || assets.some((asset) => !expected.has(asset.name)) ||
4344
new Set(assets.map((asset) => asset.name)).size !== expected.size) {
4445
throw new Error(`Release ${tag} must contain exactly the nine expected npm tarballs`);
@@ -111,7 +112,7 @@ export async function publishRelease(tag, {
111112
const { version, prerelease, assets } = validateRelease(release, tag, eventId, eventPrerelease);
112113
const temp = mkdtempSync(join(tmpdir(), "copilot-npm-release-"));
113114
try {
114-
run("gh", ["release", "download", tag, "--repo", repository, "--pattern", "github-copilot-*.tgz", "--dir", temp]);
115+
run("gh", ["release", "download", tag, "--repo", repository, "--pattern", "npm-github-copilot-*.tgz", "--dir", temp]);
115116
const downloaded = readdirSync(temp);
116117
if (downloaded.length !== assets.length || assets.some((asset) => !downloaded.includes(asset.name))) {
117118
throw new Error(`Downloaded npm tarballs do not match release ${tag}`);
@@ -120,8 +121,8 @@ export async function publishRelease(tag, {
120121
for (const platform of [...platforms, null]) {
121122
const suffix = platform?.[0];
122123
const name = packageName(suffix);
123-
const file = join(temp, `github-copilot-${version}${suffix ? `-${suffix}` : ""}.tgz`);
124-
const asset = assets.find((entry) => entry.name === `github-copilot-${version}${suffix ? `-${suffix}` : ""}.tgz`);
124+
const file = join(temp, assetName(version, suffix));
125+
const asset = assets.find((entry) => entry.name === assetName(version, suffix));
125126
const bytes = readFileSync(file);
126127
if (statSync(file).size !== asset.size ||
127128
`sha256:${createHash("sha256").update(bytes).digest("hex")}` !== asset.digest) {

‎test/publish-npm-release.test.mjs‎

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ function fixture(version = "1.2.3-4") {
2626
for (const platform of [...platforms, null]) {
2727
const [suffix, os, cpu, libc] = platform ?? [];
2828
const name = `@github/copilot${suffix ? `-${suffix}` : ""}`;
29-
const filename = `github-copilot-${version}${suffix ? `-${suffix}` : ""}.tgz`;
29+
const filename = `npm-github-copilot-${version}${suffix ? `-${suffix}` : ""}.tgz`;
3030
const source = join(root, filename);
3131
const packageDir = join(root, "work", "package");
3232
mkdirSync(packageDir, { recursive: true });
@@ -54,6 +54,15 @@ function fixture(version = "1.2.3-4") {
5454
};
5555
}
5656

57+
function attachLegacyTarballs(f) {
58+
const launcher = f.release.assets.find((asset) => asset.name === `npm-github-copilot-${f.release.tag_name.slice(1)}.tgz`);
59+
for (const platform of [...platforms.map(([suffix]) => `-${suffix}`), ""]) {
60+
const name = `github-copilot-${f.release.tag_name.slice(1)}${platform}.tgz`;
61+
cpSync(join(f.root, launcher.name), join(f.root, name));
62+
f.release.assets.push({ ...launcher, name });
63+
}
64+
}
65+
5766
async function exercise(options = {}) {
5867
const f = fixture(options.version);
5968
const published = [];
@@ -64,7 +73,8 @@ async function exercise(options = {}) {
6473
const run = (tool, args) => {
6574
if (tool === "gh" && args[0] === "api") return JSON.stringify(f.release);
6675
if (tool === "gh" && args[0] === "release") {
67-
for (const asset of f.release.assets) {
76+
assert.equal(args[args.indexOf("--pattern") + 1], "npm-github-copilot-*.tgz");
77+
for (const asset of f.release.assets.filter((entry) => entry.name.startsWith("npm-github-copilot-") && entry.name.endsWith(".tgz"))) {
6878
const source = join(f.root, asset.name);
6979
cpSync(source, join(args.at(-1), asset.name));
7080
}
@@ -98,7 +108,43 @@ test("publishes all eight platforms before launcher with prerelease tag", async
98108
const { published } = await exercise({ eventId: "1234", eventPrerelease: "true" });
99109
assert.equal(published.length, 9);
100110
assert.ok(published.every((args) => args[args.indexOf("--tag") + 1] === "prerelease"));
101-
assert.match(published.at(-1)[1], /github-copilot-1\.2\.3-4\.tgz$/);
111+
assert.match(published.at(-1)[1], /npm-github-copilot-1\.2\.3-4\.tgz$/);
112+
});
113+
114+
test("ignores the nine old launcher-manifest tarballs and publishes only the new npm packages", async () => {
115+
const { published } = await exercise({ mutate: attachLegacyTarballs });
116+
assert.equal(published.length, 9);
117+
assert.ok(published.every((args) => args[1].split("/").at(-1).startsWith("npm-github-copilot-")));
118+
});
119+
120+
test("rejects an old release with only the nine legacy tarballs, including manual recovery", async () => {
121+
await assert.rejects(exercise({
122+
mutate: (f) => {
123+
attachLegacyTarballs(f);
124+
f.release.assets = f.release.assets.filter((asset) => !asset.name.startsWith("npm-github-copilot-"));
125+
},
126+
}), (error) => {
127+
assert.match(error.message, /nine expected npm tarballs/);
128+
assert.deepEqual(error.published, []);
129+
return true;
130+
});
131+
});
132+
133+
test("rejects a new platform asset whose manifest is actually the old launcher", async () => {
134+
await assert.rejects(exercise({
135+
mutate: (f) => {
136+
attachLegacyTarballs(f);
137+
const platformAsset = f.release.assets.find((asset) => asset.name === "npm-github-copilot-1.2.3-4-linux-x64.tgz");
138+
const launcher = f.release.assets.find((asset) => asset.name === "npm-github-copilot-1.2.3-4.tgz");
139+
cpSync(join(f.root, launcher.name), join(f.root, platformAsset.name));
140+
platformAsset.size = launcher.size;
141+
platformAsset.digest = launcher.digest;
142+
},
143+
}), (error) => {
144+
assert.match(error.message, /Package identity or repository mismatch for @github\/copilot-linux-x64/);
145+
assert.deepEqual(error.published, []);
146+
return true;
147+
});
102148
});
103149

104150
test("a matching existing package is skipped on a partial rerun", async () => {
@@ -131,7 +177,7 @@ test("rejects a registry integrity mismatch before any publish", async () => {
131177
});
132178

133179
test("rejects invalid launcher dependencies and platform metadata before publishing", async () => {
134-
for (const filename of ["github-copilot-1.2.3-4.tgz", "github-copilot-1.2.3-4-linuxmusl-x64.tgz"]) {
180+
for (const filename of ["npm-github-copilot-1.2.3-4.tgz", "npm-github-copilot-1.2.3-4-linuxmusl-x64.tgz"]) {
135181
await assert.rejects(exercise({
136182
mutate: (f) => {
137183
const work = join(f.root, "work");
@@ -166,6 +212,7 @@ test("rejects incomplete assets and incorrect digests before any publish", async
166212
(f) => { f.release.assets.pop(); },
167213
(f) => { f.release.assets[0].digest = `sha256:${"0".repeat(64)}`; },
168214
(f) => { f.release.assets[0].name = "unexpected.tgz"; },
215+
(f) => { f.release.assets.push({ ...f.release.assets[0], name: "npm-github-copilot-1.2.3-4-unknown.tgz" }); },
169216
]) {
170217
await assert.rejects(exercise({ mutate }), (error) => {
171218
assert.deepEqual(error.published, []);

0 commit comments

Comments
 (0)