From a943c59c7f769ac3e5eb65493f5b28eab831a222 Mon Sep 17 00:00:00 2001 From: Devraj Mehta Date: Tue, 29 Sep 2026 18:58:30 +0000 Subject: [PATCH 1/3] Publish npm release assets from copilot-cli Add a trusted-publishing workflow for published releases with explicit-tag recovery, preflight asset validation, and integrity-checked idempotent reruns. Document npm trusted-publisher setup and the current release-asset blocker. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 23fc3b8d-7678-46d3-9fe7-875d8311b526 --- .github/workflows/publish-npm.yml | 48 ++++++++ README.md | 36 ++++++ script/publish-npm-release.mjs | 187 +++++++++++++++++++++++++++++ test/publish-npm-release.test.mjs | 193 ++++++++++++++++++++++++++++++ 4 files changed, 464 insertions(+) create mode 100644 .github/workflows/publish-npm.yml create mode 100644 script/publish-npm-release.mjs create mode 100644 test/publish-npm-release.test.mjs diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml new file mode 100644 index 00000000..b73a3f3f --- /dev/null +++ b/.github/workflows/publish-npm.yml @@ -0,0 +1,48 @@ +name: Publish npm packages from release + +on: + release: + types: [published] + workflow_dispatch: + inputs: + release_tag: + description: Published GitHub release tag to recover (for example v1.0.89) + required: true + type: string + +concurrency: + group: copilot-cli-npm-publish + cancel-in-progress: false + +jobs: + publish: + if: >- + github.repository == 'github/copilot-cli' && + (github.event_name == 'release' || + (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main')) + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + # Never check out the release tag: it can contain different workflow/script code. + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: main + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '24' + registry-url: https://registry.npmjs.org + - name: Ensure npm supports OIDC trusted publishing + run: | + npm install --global npm@11.19.0 + node -e 'if (Number(process.versions.node.split(".")[0]) < 24) process.exit(1)' + node -e 'const [major, minor, patch] = require("child_process").execFileSync("npm", ["--version"], {encoding:"utf8"}).trim().split(".").map(Number); if (major < 11 || (major === 11 && (minor < 5 || (minor === 5 && patch < 1)))) process.exit(1)' + - name: Publish release tarballs + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag }} + RELEASE_ID: ${{ github.event.release.id }} + RELEASE_PRERELEASE: ${{ github.event.release.prerelease }} + run: node script/publish-npm-release.mjs "$RELEASE_TAG" diff --git a/README.md b/README.md index 347cf2dc..c01155a5 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,42 @@ npm install -g @github/copilot npm install -g @github/copilot@prerelease ``` +### npm release publishing + +The [npm release workflow](.github/workflows/publish-npm.yml) runs when a GitHub +release is **published**. It downloads the nine already-built npm `.tgz` release +assets and validates their names, SHA-256 digests, package identities, versions, +platform metadata, and launcher dependencies before publishing anything. It does +not build from or execute release-tag code. Run the workflow manually on `main` +with the exact published `release_tag` (for example `v1.0.89`) to recover a +missed or failed release event. Matching versions are skipped only when their +npm `dist.integrity` matches the release tarball. Older releases use a +version-specific `release-` npm tag if `latest` or `prerelease` has +advanced, so recovery never intentionally downgrades those channels. A version +already on npm with a missing/stale channel tag fails closed: npm OIDC cannot +perform `npm dist-tag add`, so an npm administrator must repair that tag +separately. + +**Required setup before cutover:** On npmjs.com, configure an npm trusted +publisher **with `npm publish` permission** for each of the nine packages: +`@github/copilot`, `@github/copilot-darwin-arm64`, +`@github/copilot-darwin-x64`, `@github/copilot-linux-arm64`, +`@github/copilot-linux-x64`, `@github/copilot-linuxmusl-arm64`, +`@github/copilot-linuxmusl-x64`, `@github/copilot-win32-arm64`, and +`@github/copilot-win32-x64`. Set organization/user to `github`, repository to +`copilot-cli`, and workflow filename to **`publish-npm.yml`** (the exact +repository is `github/copilot-cli`); leave environment unset. Use GitHub-hosted +runners. The workflow uses Node 24, npm >= 11.5.1 and `id-token: write`, with +no `NPM_TOKEN` or `NODE_AUTH_TOKEN`. The runtime repository must continue its +existing publishing until this workflow is merged **and all nine npm trusted +publishers are configured**; only then should its npm publication be cut over. +Its internal Azure feed publication and ancillary release tasks remain separate. +The release assets must actually contain nine publishable packages: as of +September 29, 2026, the platform-named assets in `v1.0.90-4` still contain +the `@github/copilot` launcher manifest rather than platform package manifests. +This workflow will reject those assets; the release artifact producer must +correct them before the npm cutover. + ### Launching the CLI diff --git a/script/publish-npm-release.mjs b/script/publish-npm-release.mjs new file mode 100644 index 00000000..1b737025 --- /dev/null +++ b/script/publish-npm-release.mjs @@ -0,0 +1,187 @@ +// Copyright (c) GitHub, Inc. All rights reserved. +import { createHash } from "node:crypto"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +const platforms = [ + ["darwin-arm64", "darwin", "arm64"], + ["darwin-x64", "darwin", "x64"], + ["linux-arm64", "linux", "arm64", "glibc"], + ["linux-x64", "linux", "x64", "glibc"], + ["linuxmusl-arm64", "linux", "arm64", "musl"], + ["linuxmusl-x64", "linux", "x64", "musl"], + ["win32-arm64", "win32", "arm64"], + ["win32-x64", "win32", "x64"], +]; +const repository = "github/copilot-cli"; +const packageName = (platform) => `@github/copilot${platform ? `-${platform}` : ""}`; + +export function validateRelease(release, tag, eventId, eventPrerelease) { + const match = /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9]|[1-9]\d*))?$/.exec(tag); + if (!match || release.tag_name !== tag || release.draft || !release.published_at) { + throw new Error(`Not a published Copilot CLI release with a canonical tag: ${tag}`); + } + const prerelease = match[4] !== undefined; + if (release.prerelease !== prerelease) { + throw new Error(`Prerelease flag does not match tag ${tag}`); + } + if (eventId && String(release.id) !== eventId) { + throw new Error(`Release ID for ${tag} differs from the triggering event`); + } + if (eventPrerelease && String(release.prerelease) !== eventPrerelease) { + throw new Error(`Prerelease flag for ${tag} differs from the triggering event`); + } + const version = tag.slice(1); + const names = platforms.map(([platform]) => `github-copilot-${version}-${platform}.tgz`); + names.push(`github-copilot-${version}.tgz`); + const expected = new Set(names); + const assets = release.assets.filter((asset) => asset.name.endsWith(".tgz")); + if (assets.length !== expected.size || assets.some((asset) => !expected.has(asset.name)) || + new Set(assets.map((asset) => asset.name)).size !== expected.size) { + throw new Error(`Release ${tag} must contain exactly the nine expected npm tarballs`); + } + for (const asset of assets) { + if (asset.state !== "uploaded" || !Number.isSafeInteger(asset.size) || asset.size <= 0 || + !/^sha256:[a-f0-9]{64}$/.test(asset.digest ?? "")) { + throw new Error(`Missing uploaded asset size or SHA-256 digest for ${asset.name}`); + } + } + return { version, prerelease, assets }; +} + +export function compareVersions(left, right) { + const parse = (version) => { + const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9]|[1-9]\d*))?$/.exec(version); + if (!match) throw new Error(`Unexpected npm dist-tag version: ${version}`); + return match.slice(1).map((value) => value === undefined ? null : BigInt(value)); + }; + const a = parse(left); + const b = parse(right); + for (let index = 0; index < 3; index++) { + if (a[index] !== b[index]) return a[index] > b[index] ? 1 : -1; + } + if (a[3] === null || b[3] === null) return a[3] === b[3] ? 0 : a[3] === null ? 1 : -1; + return a[3] === b[3] ? 0 : a[3] > b[3] ? 1 : -1; +} + +export function validatePackage(metadata, platform, version) { + const [suffix, os, cpu, libc] = platform ?? []; + const name = packageName(suffix); + if (metadata.name !== name || metadata.version !== version || + metadata.repository?.url !== "git+https://github.com/github/copilot-cli.git") { + throw new Error(`Package identity or repository mismatch for ${name}@${version}`); + } + if (platform) { + if (JSON.stringify(metadata.os) !== JSON.stringify([os]) || + JSON.stringify(metadata.cpu) !== JSON.stringify([cpu]) || + (libc ? JSON.stringify(metadata.libc) !== JSON.stringify([libc]) : metadata.libc !== undefined)) { + throw new Error(`Platform metadata mismatch for ${name}@${version}`); + } + } else { + const dependencies = Object.fromEntries(platforms.map(([suffix]) => [packageName(suffix), version])); + if (JSON.stringify(Object.entries(metadata.optionalDependencies ?? {}).sort()) !== + JSON.stringify(Object.entries(dependencies).sort()) || + metadata.os !== undefined || metadata.cpu !== undefined || metadata.libc !== undefined) { + throw new Error(`Launcher platform dependencies mismatch for ${name}@${version}`); + } + } +} + +function command(executable, args, options = {}) { + return execFileSync(executable, args, { encoding: "utf8", ...options }).trim(); +} + +async function registryVersion(name, version) { + const response = await fetch(`https://registry.npmjs.org/${name.replace("/", "%2f")}/${version}`); + if (response.status === 404) return null; + if (!response.ok) throw new Error(`npm registry lookup failed for ${name}@${version}: HTTP ${response.status}`); + return response.json(); +} + +export async function publishRelease(tag, { + run = command, + lookup = registryVersion, + eventId = process.env.RELEASE_ID, + eventPrerelease = process.env.RELEASE_PRERELEASE, +} = {}) { + const release = JSON.parse(run("gh", ["api", `repos/${repository}/releases/tags/${tag}`])); + const { version, prerelease, assets } = validateRelease(release, tag, eventId, eventPrerelease); + const temp = mkdtempSync(join(tmpdir(), "copilot-npm-release-")); + try { + run("gh", ["release", "download", tag, "--repo", repository, "--pattern", "github-copilot-*.tgz", "--dir", temp]); + const downloaded = readdirSync(temp); + if (downloaded.length !== assets.length || assets.some((asset) => !downloaded.includes(asset.name))) { + throw new Error(`Downloaded npm tarballs do not match release ${tag}`); + } + const packages = []; + for (const platform of [...platforms, null]) { + const suffix = platform?.[0]; + const name = packageName(suffix); + const file = join(temp, `github-copilot-${version}${suffix ? `-${suffix}` : ""}.tgz`); + const asset = assets.find((entry) => entry.name === `github-copilot-${version}${suffix ? `-${suffix}` : ""}.tgz`); + const bytes = readFileSync(file); + if (statSync(file).size !== asset.size || + `sha256:${createHash("sha256").update(bytes).digest("hex")}` !== asset.digest) { + throw new Error(`Release asset checksum mismatch: ${asset.name}`); + } + const metadata = JSON.parse(run("tar", ["-xOzf", file, "package/package.json"])); + validatePackage(metadata, platform, version); + const integrity = `sha512-${createHash("sha512").update(bytes).digest("base64")}`; + packages.push({ name, file, integrity }); + } + + // Complete all nine archive and registry checks before the first irreversible publish. + const channel = prerelease ? "prerelease" : "latest"; + for (const item of packages) { + const existing = await lookup(item.name, version); + if (existing && existing.dist?.integrity !== item.integrity) { + throw new Error(`Existing npm ${item.name}@${version} has different dist.integrity`); + } + const tags = JSON.parse(run("npm", ["view", item.name, "dist-tags", "--json", "--registry", "https://registry.npmjs.org"])); + const current = tags[channel]; + if (current && compareVersions(current, version) > 0) { + item.tag = `release-${version.replaceAll(".", "-")}`; + } else { + item.tag = channel; + } + if (existing && item.tag === channel && current !== version) { + throw new Error(`Cannot repair ${item.name} ${channel} dist-tag with OIDC; it points to ${current ?? "(none)"}`); + } + item.existing = !!existing; + } + + for (const item of packages) { + if (item.existing) { + console.log(`Already published ${item.name}@${version} (integrity matches)`); + continue; + } + // Re-check immediately before publishing; a concurrent external publisher must not move a newer tag back. + const existing = await lookup(item.name, version); + if (existing) { + if (existing.dist?.integrity !== item.integrity) { + throw new Error(`Existing npm ${item.name}@${version} changed dist.integrity`); + } + console.log(`Already published ${item.name}@${version} (integrity matches)`); + continue; + } + const tags = JSON.parse(run("npm", ["view", item.name, "dist-tags", "--json", "--registry", "https://registry.npmjs.org"])); + if (item.tag === channel && tags[channel] && compareVersions(tags[channel], version) > 0) { + item.tag = `release-${version.replaceAll(".", "-")}`; + } + run("npm", ["publish", item.file, "--ignore-scripts", "--access", "public", "--tag", item.tag, "--registry", "https://registry.npmjs.org"]); + console.log(`Published ${item.name}@${version} with ${item.tag} tag`); + } + } finally { + rmSync(temp, { recursive: true, force: true }); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + publishRelease(process.argv[2]).catch((error) => { + console.error(error); + process.exitCode = 1; + }); +} diff --git a/test/publish-npm-release.test.mjs b/test/publish-npm-release.test.mjs new file mode 100644 index 00000000..0b2df9ee --- /dev/null +++ b/test/publish-npm-release.test.mjs @@ -0,0 +1,193 @@ +// Copyright (c) GitHub, Inc. All rights reserved. +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { compareVersions, publishRelease, validateRelease } from "../script/publish-npm-release.mjs"; + +const platforms = [ + ["darwin-arm64", "darwin", "arm64"], + ["darwin-x64", "darwin", "x64"], + ["linux-arm64", "linux", "arm64", "glibc"], + ["linux-x64", "linux", "x64", "glibc"], + ["linuxmusl-arm64", "linux", "arm64", "musl"], + ["linuxmusl-x64", "linux", "x64", "musl"], + ["win32-arm64", "win32", "arm64"], + ["win32-x64", "win32", "x64"], +]; + +function fixture(version = "1.2.3-4") { + const root = mkdtempSync(join(tmpdir(), "copilot-npm-publish-test-")); + const assets = []; + const integrity = new Map(); + for (const platform of [...platforms, null]) { + const [suffix, os, cpu, libc] = platform ?? []; + const name = `@github/copilot${suffix ? `-${suffix}` : ""}`; + const filename = `github-copilot-${version}${suffix ? `-${suffix}` : ""}.tgz`; + const source = join(root, filename); + const packageDir = join(root, "work", "package"); + mkdirSync(packageDir, { recursive: true }); + writeFileSync(join(packageDir, "package.json"), JSON.stringify({ + name, version, + repository: { url: "git+https://github.com/github/copilot-cli.git" }, + ...(platform ? { os: [os], cpu: [cpu], ...(libc ? { libc: [libc] } : {}) } : { + optionalDependencies: Object.fromEntries(platforms.map(([value]) => [`@github/copilot-${value}`, version])), + }), + })); + execFileSync("tar", ["-czf", source, "-C", join(root, "work"), "package"]); + const bytes = readFileSync(source); + assets.push({ + name: filename, size: bytes.length, state: "uploaded", + digest: `sha256:${createHash("sha256").update(bytes).digest("hex")}`, + }); + integrity.set(name, `sha512-${createHash("sha512").update(bytes).digest("base64")}`); + } + return { + root, integrity, + release: { + id: 1234, tag_name: `v${version}`, prerelease: version.includes("-"), + published_at: "2026-09-29T00:00:00Z", draft: false, assets, + }, + }; +} + +async function exercise(options = {}) { + const f = fixture(options.version); + const published = []; + const existing = typeof options.existing === "function" ? options.existing(f) : options.existing ?? new Map(); + const tags = options.tags ?? { latest: "1.2.2", prerelease: "1.2.3-3" }; + try { + options.mutate?.(f); + const run = (tool, args) => { + if (tool === "gh" && args[0] === "api") return JSON.stringify(f.release); + if (tool === "gh" && args[0] === "release") { + for (const asset of f.release.assets) { + const source = join(f.root, asset.name); + cpSync(source, join(args.at(-1), asset.name)); + } + return ""; + } + if (tool === "tar") return execFileSync("tar", args, { encoding: "utf8" }).trim(); + if (tool === "npm" && args[0] === "view") return JSON.stringify( + typeof tags === "function" ? tags(args[1]) : tags + ); + if (tool === "npm" && args[0] === "publish") { + published.push(args); + assert.ok(args.includes("--ignore-scripts")); + return ""; + } + throw new Error(`Unexpected command ${tool} ${args.join(" ")}`); + }; + const lookup = async (name) => existing.has(name) ? { dist: { integrity: existing.get(name) } } : null; + const result = await publishRelease(f.release.tag_name, { + run, lookup, eventId: options.eventId, eventPrerelease: options.eventPrerelease, + }); + return { published, fixture: f, result }; + } catch (error) { + error.published = published; + throw error; + } finally { + rmSync(f.root, { recursive: true, force: true }); + } +} + +test("publishes all eight platforms before launcher with prerelease tag", async () => { + const { published } = await exercise({ eventId: "1234", eventPrerelease: "true" }); + assert.equal(published.length, 9); + assert.ok(published.every((args) => args[args.indexOf("--tag") + 1] === "prerelease")); + assert.match(published.at(-1)[1], /github-copilot-1\.2\.3-4\.tgz$/); +}); + +test("a matching existing package is skipped on a partial rerun", async () => { + const { published } = await exercise({ + existing: (f) => new Map([["@github/copilot-darwin-arm64", f.integrity.get("@github/copilot-darwin-arm64")]]), + tags: (name) => ({ prerelease: name === "@github/copilot-darwin-arm64" ? "1.2.3-4" : "1.2.3-3" }), + }); + assert.equal(published.length, 8); +}); + +test("older recovery never moves a newer channel tag backwards", async () => { + const { published } = await exercise({ tags: { prerelease: "1.2.3-5" } }); + assert.equal(published.length, 9); + assert.ok(published.every((args) => args[args.indexOf("--tag") + 1] === "release-1-2-3-4")); +}); + +test("stable release uses latest, and an older stable release uses a version tag", async () => { + const current = await exercise({ version: "1.2.3", tags: { latest: "1.2.2" } }); + assert.ok(current.published.every((args) => args[args.indexOf("--tag") + 1] === "latest")); + const old = await exercise({ version: "1.2.3", tags: { latest: "1.2.4" } }); + assert.ok(old.published.every((args) => args[args.indexOf("--tag") + 1] === "release-1-2-3")); +}); + +test("rejects a registry integrity mismatch before any publish", async () => { + await assert.rejects(exercise({ existing: new Map([["@github/copilot-win32-x64", "sha512-wrong"]]) }), (error) => { + assert.match(error.message, /different dist.integrity/); + assert.deepEqual(error.published, []); + return true; + }); +}); + +test("rejects invalid launcher dependencies and platform metadata before publishing", async () => { + for (const filename of ["github-copilot-1.2.3-4.tgz", "github-copilot-1.2.3-4-linuxmusl-x64.tgz"]) { + await assert.rejects(exercise({ + mutate: (f) => { + const work = join(f.root, "work"); + execFileSync("tar", ["-xzf", join(f.root, filename), "-C", work]); + const manifest = join(work, "package", "package.json"); + const metadata = JSON.parse(readFileSync(manifest, "utf8")); + if (metadata.optionalDependencies) delete metadata.optionalDependencies["@github/copilot-win32-x64"]; + else metadata.libc = ["glibc"]; + writeFileSync(manifest, JSON.stringify(metadata)); + execFileSync("tar", ["-czf", join(f.root, filename), "-C", work, "package"]); + const asset = f.release.assets.find((entry) => entry.name === filename); + const bytes = readFileSync(join(f.root, filename)); + asset.size = bytes.length; + asset.digest = `sha256:${createHash("sha256").update(bytes).digest("hex")}`; + }, + }), (error) => { + assert.match(error.message, /metadata mismatch|dependencies mismatch/); + assert.deepEqual(error.published, []); + return true; + }); + } +}); + +test("a previously published version with a stale channel tag fails explicitly", async () => { + await assert.rejects(exercise({ + existing: (f) => new Map([["@github/copilot-darwin-arm64", f.integrity.get("@github/copilot-darwin-arm64")]]), + }), /Cannot repair .* dist-tag with OIDC/); +}); + +test("rejects incomplete assets and incorrect digests before any publish", async () => { + for (const mutate of [ + (f) => { f.release.assets.pop(); }, + (f) => { f.release.assets[0].digest = `sha256:${"0".repeat(64)}`; }, + (f) => { f.release.assets[0].name = "unexpected.tgz"; }, + ]) { + await assert.rejects(exercise({ mutate }), (error) => { + assert.deepEqual(error.published, []); + return true; + }); + } +}); + +test("rejects mismatched event and release identity", async () => { + await assert.rejects(exercise({ eventId: "5678" }), /differs from the triggering event/); + await assert.rejects(exercise({ eventPrerelease: "false" }), /differs from the triggering event/); +}); + +test("release tag and prerelease flag are canonical", () => { + const f = fixture(); + try { + assert.throws(() => validateRelease(f.release, "v01.2.3-4"), /canonical tag/); + f.release.prerelease = false; + assert.throws(() => validateRelease(f.release, "v1.2.3-4"), /Prerelease flag/); + } finally { + rmSync(f.root, { recursive: true, force: true }); + } + assert.equal(compareVersions("1.2.3", "1.2.3-4"), 1); + assert.equal(compareVersions("1.2.3-10", "1.2.3-9"), 1); +}); From 5360782489e35364c8ed64ab45f5b77168fad5c1 Mon Sep 17 00:00:00 2001 From: Devraj Mehta Date: Tue, 29 Sep 2026 19:01:34 +0000 Subject: [PATCH 2/3] 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 --- README.md | 27 ++++++++------- script/publish-npm-release.mjs | 13 ++++---- test/publish-npm-release.test.mjs | 55 ++++++++++++++++++++++++++++--- 3 files changed, 73 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index c01155a5..c013e867 100644 --- a/README.md +++ b/README.md @@ -98,13 +98,17 @@ npm install -g @github/copilot@prerelease ### npm release publishing The [npm release workflow](.github/workflows/publish-npm.yml) runs when a GitHub -release is **published**. It downloads the nine already-built npm `.tgz` release -assets and validates their names, SHA-256 digests, package identities, versions, -platform metadata, and launcher dependencies before publishing anything. It does -not build from or execute release-tag code. Run the workflow manually on `main` -with the exact published `release_tag` (for example `v1.0.89`) to recover a -missed or failed release event. Matching versions are skipped only when their -npm `dist.integrity` matches the release tarball. Older releases use a +release is **published**. It downloads only the nine already-built npm assets: +`npm-github-copilot-${VERSION}.tgz` and +`npm-github-copilot-${VERSION}-${PLATFORM}.tgz` for each of the eight supported +platforms. It ignores the older `github-copilot-*.tgz` launcher tarballs and +validates the nine new assets' names, SHA-256 digests, package identities, +versions, platform metadata, and launcher dependencies before publishing +anything. It does not build from or execute release-tag code. Run the workflow +manually on `main` with the exact published `release_tag` to recover a missed +or failed release event; releases without all nine new npm assets are rejected +even on manual recovery. Matching versions are skipped only when their npm +`dist.integrity` matches the release tarball. Older releases use a version-specific `release-` npm tag if `latest` or `prerelease` has advanced, so recovery never intentionally downgrades those channels. A version 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 existing publishing until this workflow is merged **and all nine npm trusted publishers are configured**; only then should its npm publication be cut over. Its internal Azure feed publication and ancillary release tasks remain separate. -The release assets must actually contain nine publishable packages: as of -September 29, 2026, the platform-named assets in `v1.0.90-4` still contain -the `@github/copilot` launcher manifest rather than platform package manifests. -This workflow will reject those assets; the release artifact producer must -correct them before the npm cutover. +The release artifact producer must attach the nine actual npm package tarballs +under the new `npm-github-copilot-` names before cutover. Older releases such as +`v1.0.90-4` contain only the legacy launcher-manifest tarballs and cannot be +recovered through this workflow. ### Launching the CLI diff --git a/script/publish-npm-release.mjs b/script/publish-npm-release.mjs index 1b737025..3029bda6 100644 --- a/script/publish-npm-release.mjs +++ b/script/publish-npm-release.mjs @@ -18,6 +18,7 @@ const platforms = [ ]; const repository = "github/copilot-cli"; const packageName = (platform) => `@github/copilot${platform ? `-${platform}` : ""}`; +const assetName = (version, platform) => `npm-github-copilot-${version}${platform ? `-${platform}` : ""}.tgz`; export function validateRelease(release, tag, eventId, eventPrerelease) { 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) { throw new Error(`Prerelease flag for ${tag} differs from the triggering event`); } const version = tag.slice(1); - const names = platforms.map(([platform]) => `github-copilot-${version}-${platform}.tgz`); - names.push(`github-copilot-${version}.tgz`); + const names = platforms.map(([platform]) => assetName(version, platform)); + names.push(assetName(version)); const expected = new Set(names); - const assets = release.assets.filter((asset) => asset.name.endsWith(".tgz")); + const assets = release.assets.filter((asset) => asset.name.startsWith("npm-github-copilot-") && asset.name.endsWith(".tgz")); if (assets.length !== expected.size || assets.some((asset) => !expected.has(asset.name)) || new Set(assets.map((asset) => asset.name)).size !== expected.size) { throw new Error(`Release ${tag} must contain exactly the nine expected npm tarballs`); @@ -111,7 +112,7 @@ export async function publishRelease(tag, { const { version, prerelease, assets } = validateRelease(release, tag, eventId, eventPrerelease); const temp = mkdtempSync(join(tmpdir(), "copilot-npm-release-")); try { - run("gh", ["release", "download", tag, "--repo", repository, "--pattern", "github-copilot-*.tgz", "--dir", temp]); + run("gh", ["release", "download", tag, "--repo", repository, "--pattern", "npm-github-copilot-*.tgz", "--dir", temp]); const downloaded = readdirSync(temp); if (downloaded.length !== assets.length || assets.some((asset) => !downloaded.includes(asset.name))) { throw new Error(`Downloaded npm tarballs do not match release ${tag}`); @@ -120,8 +121,8 @@ export async function publishRelease(tag, { for (const platform of [...platforms, null]) { const suffix = platform?.[0]; const name = packageName(suffix); - const file = join(temp, `github-copilot-${version}${suffix ? `-${suffix}` : ""}.tgz`); - const asset = assets.find((entry) => entry.name === `github-copilot-${version}${suffix ? `-${suffix}` : ""}.tgz`); + const file = join(temp, assetName(version, suffix)); + const asset = assets.find((entry) => entry.name === assetName(version, suffix)); const bytes = readFileSync(file); if (statSync(file).size !== asset.size || `sha256:${createHash("sha256").update(bytes).digest("hex")}` !== asset.digest) { diff --git a/test/publish-npm-release.test.mjs b/test/publish-npm-release.test.mjs index 0b2df9ee..0a62a2d3 100644 --- a/test/publish-npm-release.test.mjs +++ b/test/publish-npm-release.test.mjs @@ -26,7 +26,7 @@ function fixture(version = "1.2.3-4") { for (const platform of [...platforms, null]) { const [suffix, os, cpu, libc] = platform ?? []; const name = `@github/copilot${suffix ? `-${suffix}` : ""}`; - const filename = `github-copilot-${version}${suffix ? `-${suffix}` : ""}.tgz`; + const filename = `npm-github-copilot-${version}${suffix ? `-${suffix}` : ""}.tgz`; const source = join(root, filename); const packageDir = join(root, "work", "package"); mkdirSync(packageDir, { recursive: true }); @@ -54,6 +54,15 @@ function fixture(version = "1.2.3-4") { }; } +function attachLegacyTarballs(f) { + const launcher = f.release.assets.find((asset) => asset.name === `npm-github-copilot-${f.release.tag_name.slice(1)}.tgz`); + for (const platform of [...platforms.map(([suffix]) => `-${suffix}`), ""]) { + const name = `github-copilot-${f.release.tag_name.slice(1)}${platform}.tgz`; + cpSync(join(f.root, launcher.name), join(f.root, name)); + f.release.assets.push({ ...launcher, name }); + } +} + async function exercise(options = {}) { const f = fixture(options.version); const published = []; @@ -64,7 +73,8 @@ async function exercise(options = {}) { const run = (tool, args) => { if (tool === "gh" && args[0] === "api") return JSON.stringify(f.release); if (tool === "gh" && args[0] === "release") { - for (const asset of f.release.assets) { + assert.equal(args[args.indexOf("--pattern") + 1], "npm-github-copilot-*.tgz"); + for (const asset of f.release.assets.filter((entry) => entry.name.startsWith("npm-github-copilot-") && entry.name.endsWith(".tgz"))) { const source = join(f.root, asset.name); cpSync(source, join(args.at(-1), asset.name)); } @@ -98,7 +108,43 @@ test("publishes all eight platforms before launcher with prerelease tag", async const { published } = await exercise({ eventId: "1234", eventPrerelease: "true" }); assert.equal(published.length, 9); assert.ok(published.every((args) => args[args.indexOf("--tag") + 1] === "prerelease")); - assert.match(published.at(-1)[1], /github-copilot-1\.2\.3-4\.tgz$/); + assert.match(published.at(-1)[1], /npm-github-copilot-1\.2\.3-4\.tgz$/); +}); + +test("ignores the nine old launcher-manifest tarballs and publishes only the new npm packages", async () => { + const { published } = await exercise({ mutate: attachLegacyTarballs }); + assert.equal(published.length, 9); + assert.ok(published.every((args) => args[1].split("/").at(-1).startsWith("npm-github-copilot-"))); +}); + +test("rejects an old release with only the nine legacy tarballs, including manual recovery", async () => { + await assert.rejects(exercise({ + mutate: (f) => { + attachLegacyTarballs(f); + f.release.assets = f.release.assets.filter((asset) => !asset.name.startsWith("npm-github-copilot-")); + }, + }), (error) => { + assert.match(error.message, /nine expected npm tarballs/); + assert.deepEqual(error.published, []); + return true; + }); +}); + +test("rejects a new platform asset whose manifest is actually the old launcher", async () => { + await assert.rejects(exercise({ + mutate: (f) => { + attachLegacyTarballs(f); + const platformAsset = f.release.assets.find((asset) => asset.name === "npm-github-copilot-1.2.3-4-linux-x64.tgz"); + const launcher = f.release.assets.find((asset) => asset.name === "npm-github-copilot-1.2.3-4.tgz"); + cpSync(join(f.root, launcher.name), join(f.root, platformAsset.name)); + platformAsset.size = launcher.size; + platformAsset.digest = launcher.digest; + }, + }), (error) => { + assert.match(error.message, /Package identity or repository mismatch for @github\/copilot-linux-x64/); + assert.deepEqual(error.published, []); + return true; + }); }); 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 () => { }); test("rejects invalid launcher dependencies and platform metadata before publishing", async () => { - for (const filename of ["github-copilot-1.2.3-4.tgz", "github-copilot-1.2.3-4-linuxmusl-x64.tgz"]) { + for (const filename of ["npm-github-copilot-1.2.3-4.tgz", "npm-github-copilot-1.2.3-4-linuxmusl-x64.tgz"]) { await assert.rejects(exercise({ mutate: (f) => { const work = join(f.root, "work"); @@ -166,6 +212,7 @@ test("rejects incomplete assets and incorrect digests before any publish", async (f) => { f.release.assets.pop(); }, (f) => { f.release.assets[0].digest = `sha256:${"0".repeat(64)}`; }, (f) => { f.release.assets[0].name = "unexpected.tgz"; }, + (f) => { f.release.assets.push({ ...f.release.assets[0], name: "npm-github-copilot-1.2.3-4-unknown.tgz" }); }, ]) { await assert.rejects(exercise({ mutate }), (error) => { assert.deepEqual(error.published, []); From f9ccf0c9f364979d36fef57812157573dbae335c Mon Sep 17 00:00:00 2001 From: Devraj Mehta Date: Tue, 29 Sep 2026 19:14:55 +0000 Subject: [PATCH 3/3] Guard npm release publishing behind exclusive cutover Require an operator-confirmed cutover before publishing with channel tags; document retirement and draining of the old publisher and correct the recheck comment. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 23fc3b8d-7678-46d3-9fe7-875d8311b526 --- .github/workflows/publish-npm.yml | 8 ++++++++ README.md | 15 +++++++++++++-- script/publish-npm-release.mjs | 2 +- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index b73a3f3f..e33c84b8 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -25,6 +25,14 @@ jobs: contents: read id-token: write steps: + - name: Require completed npm publisher cutover + env: + CUTOVER_COMPLETE: ${{ vars.CLI_NPM_RELEASE_CUTOVER_COMPLETE }} + run: | + if [ "$CUTOVER_COMPLETE" != "true" ]; then + echo "Set CLI_NPM_RELEASE_CUTOVER_COMPLETE only after retiring and draining the runtime npm publisher." >&2 + exit 1 + fi # Never check out the release tag: it can contain different workflow/script code. - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: diff --git a/README.md b/README.md index c013e867..a699eb6d 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,9 @@ version-specific `release-` npm tag if `latest` or `prerelease` has advanced, so recovery never intentionally downgrades those channels. A version already on npm with a missing/stale channel tag fails closed: npm OIDC cannot perform `npm dist-tag add`, so an npm administrator must repair that tag -separately. +separately. The npm dist-tag recheck cannot prevent a concurrent publisher +from advancing a tag between the read and `npm publish --tag`; the cutover +requires exclusive ownership of these packages' channel tags. **Required setup before cutover:** On npmjs.com, configure an npm trusted publisher **with `npm publish` permission** for each of the nine packages: @@ -127,7 +129,16 @@ repository is `github/copilot-cli`); leave environment unset. Use GitHub-hosted runners. The workflow uses Node 24, npm >= 11.5.1 and `id-token: write`, with no `NPM_TOKEN` or `NODE_AUTH_TOKEN`. The runtime repository must continue its existing publishing until this workflow is merged **and all nine npm trusted -publishers are configured**; only then should its npm publication be cut over. +publishers are configured**. At cutover, disable the old runtime +`publish-cli.yml` workflow, wait for all its in-progress and queued runs to +finish, then merge the runtime workflow change. Retire any other publisher +of these nine packages and prohibit reruns of older runtime release runs. +Only then set the `CLI_NPM_RELEASE_CUTOVER_COMPLETE` repository Actions +variable to `true` in `github/copilot-cli` and re-enable the updated runtime +workflow. Without this variable the new workflow fails before any npm +publish, including manual recovery. If an external publisher is restarted, +unset the variable before publishing another release; a dist-tag read is +not a concurrency lock. Its internal Azure feed publication and ancillary release tasks remain separate. The release artifact producer must attach the nine actual npm package tarballs under the new `npm-github-copilot-` names before cutover. Older releases such as diff --git a/script/publish-npm-release.mjs b/script/publish-npm-release.mjs index 3029bda6..f84a4e60 100644 --- a/script/publish-npm-release.mjs +++ b/script/publish-npm-release.mjs @@ -159,7 +159,7 @@ export async function publishRelease(tag, { console.log(`Already published ${item.name}@${version} (integrity matches)`); continue; } - // Re-check immediately before publishing; a concurrent external publisher must not move a newer tag back. + // Re-check for sequential changes; cross-repository publishers must be retired at cutover. const existing = await lookup(item.name, version); if (existing) { if (existing.dist?.integrity !== item.integrity) {