diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml new file mode 100644 index 00000000..e33c84b8 --- /dev/null +++ b/.github/workflows/publish-npm.yml @@ -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" diff --git a/README.md b/README.md index 347cf2dc..a699eb6d 100644 --- a/README.md +++ b/README.md @@ -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-` 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 diff --git a/script/publish-npm-release.mjs b/script/publish-npm-release.mjs new file mode 100644 index 00000000..f84a4e60 --- /dev/null +++ b/script/publish-npm-release.mjs @@ -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"]); + 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..0a62a2d3 --- /dev/null +++ b/test/publish-npm-release.test.mjs @@ -0,0 +1,240 @@ +// 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 = `npm-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, + }, + }; +} + +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 = []; + 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") { + 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)); + } + 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], /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 () => { + 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 ["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"); + 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"; }, + (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, []); + 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); +});