Skip to content
Open
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
56 changes: 56 additions & 0 deletions .github/workflows/publish-npm.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
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:
- 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:
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"
50 changes: 50 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,56 @@ 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 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-<version>` 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. 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:
`@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**. 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
`v1.0.90-4` contain only the legacy launcher-manifest tarballs and cannot be
recovered through this workflow.


### Launching the CLI

Expand Down
188 changes: 188 additions & 0 deletions script/publish-npm-release.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
// 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}` : ""}`;
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);
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]) => assetName(version, platform));
names.push(assetName(version));
const expected = new Set(names);
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`);
}
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", "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}`);
}
const packages = [];
for (const platform of [...platforms, null]) {
const suffix = platform?.[0];
const name = packageName(suffix);
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) {
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 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) {
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"]);
Comment on lines +171 to +175
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;
});
}
Loading
Loading