diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9e6c41b6..070d7c34 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -143,8 +143,19 @@ jobs: if [ -z "${registry_integrity}" ]; then pnpm publish "${tarball}" --access public --tag "${ACCEPTANCE_DIST_TAG}" elif [ "${registry_integrity}" != "${PACKED_INTEGRITY}" ]; then - echo "Registry dist.integrity differs from the packed candidate; refusing to republish different bytes" >&2 - exit 1 + mkdir -p release/cli-registry + registry_pack_json=$(npm pack "${PACKAGE_NAME}@${CANDIDATE_VERSION}" --json --pack-destination release/cli-registry) + registry_filename=$(printf '%s' "${registry_pack_json}" | node -e "let d=''; process.stdin.on('data', c => d += c); process.stdin.on('end', () => { const v=JSON.parse(d); console.log((Array.isArray(v) ? v[0] : v).filename) })") + registry_tarball="release/cli-registry/$(basename "${registry_filename}")" + if [ ! -f "${registry_tarball}" ]; then + echo "Unable to download the immutable registry candidate" >&2 + exit 1 + fi + pnpm validate:cli-package -- \ + --tarball "${tarball}" \ + --compare-tarball "${registry_tarball}" \ + --source-package-json packages/cli/package.json + npm dist-tag add "${PACKAGE_NAME}@${CANDIDATE_VERSION}" "${ACCEPTANCE_DIST_TAG}" else npm dist-tag add "${PACKAGE_NAME}@${CANDIDATE_VERSION}" "${ACCEPTANCE_DIST_TAG}" fi diff --git a/scripts/github-workflows.test.ts b/scripts/github-workflows.test.ts index 4e6185e0..dc647a24 100644 --- a/scripts/github-workflows.test.ts +++ b/scripts/github-workflows.test.ts @@ -589,6 +589,9 @@ describe("GitHub workflow boundaries", () => { ); expect(steps[stageIndex]?.run).toContain("dist.integrity"); expect(steps[stageIndex]?.run).toContain('pnpm publish "${tarball}"'); + expect(steps[stageIndex]?.run).toContain('npm pack "${PACKAGE_NAME}@${CANDIDATE_VERSION}"'); + expect(steps[stageIndex]?.run).toContain('--compare-tarball "${registry_tarball}"'); + expect(steps[stageIndex]?.run).toContain("npm dist-tag add"); expect(steps[stageIndex]?.run).not.toMatch(/(^|\s)npm publish "\$\{tarball\}"/); expect(steps[acceptanceIndex]?.run).toContain("pnpm acceptance:cli:update"); expect(steps[candidateCleanupIndex]?.if).toBe("inputs.promote == false"); diff --git a/scripts/validate-cli-package.test.ts b/scripts/validate-cli-package.test.ts index 6bbad548..c0ce63cd 100644 --- a/scripts/validate-cli-package.test.ts +++ b/scripts/validate-cli-package.test.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { create } from "tar"; import { describe, expect, it } from "vitest"; import { + compareCliPackageArchives, parseValidateCliPackageArguments, validateCliPackageArchive, } from "./validate-cli-package.js"; @@ -11,7 +12,9 @@ import { interface PackageFixtureOptions { binContent?: string; includeTypes?: boolean; + indexContent?: string; packedManifest?: Record; + tarMtime?: Date; } const sourceManifest = { @@ -61,6 +64,23 @@ describe("validate-cli-package", () => { }); }); + it("parses an optional published tarball comparison", () => { + expect( + parseValidateCliPackageArguments([ + "--tarball", + "release/candidate.tgz", + "--compare-tarball", + "release/published.tgz", + "--source-package-json", + "packages/cli/package.json", + ]) + ).toEqual({ + compareTarballPath: "release/published.tgz", + sourcePackageJsonPath: "packages/cli/package.json", + tarballPath: "release/candidate.tgz", + }); + }); + it("accepts package entry fields that resolve to real archive files", async () => { const fixture = await createPackageFixture(); @@ -94,12 +114,34 @@ describe("validate-cli-package", () => { "Packed CLI package.json still contains publishConfig" ); }); + + it("compares logical package contents instead of tarball metadata", async () => { + const candidate = await createPackageFixture({ tarMtime: new Date("2026-01-01T00:00:00Z") }); + const published = await createPackageFixture({ tarMtime: new Date("2026-02-01T00:00:00Z") }); + + await expect( + compareCliPackageArchives(candidate.tarballPath, published.tarballPath) + ).resolves.toBeUndefined(); + }); + + it("rejects a published package with different file bytes", async () => { + const candidate = await createPackageFixture(); + const published = await createPackageFixture({ + indexContent: "export const changed = true;\n", + }); + + await expect( + compareCliPackageArchives(candidate.tarballPath, published.tarballPath) + ).rejects.toThrow("package/dist/esm/index.mjs"); + }); }); async function createPackageFixture({ binContent = '#!/usr/bin/env node\nimport "./esm/index.mjs";\n', includeTypes = true, + indexContent = "export {};\n", packedManifest = validPackedManifest, + tarMtime, }: PackageFixtureOptions = {}) { const root = await mkdtemp(join(tmpdir(), "coder-studio-package-validation-")); const packageDir = join(root, "package"); @@ -110,11 +152,11 @@ async function createPackageFixture({ await writeFile(sourcePackageJsonPath, JSON.stringify(sourceManifest)); await writeFile(join(packageDir, "package.json"), JSON.stringify(packedManifest)); await writeFile(join(packageDir, "dist", "bin.js"), binContent); - await writeFile(join(packageDir, "dist", "esm", "index.mjs"), "export {};\n"); + await writeFile(join(packageDir, "dist", "esm", "index.mjs"), indexContent); if (includeTypes) { await writeFile(join(packageDir, "dist", "esm", "index.d.ts"), "export {};\n"); } - await create({ cwd: root, file: tarballPath, gzip: true }, ["package"]); + await create({ cwd: root, file: tarballPath, gzip: true, mtime: tarMtime }, ["package"]); return { sourcePackageJsonPath, tarballPath }; } diff --git a/scripts/validate-cli-package.ts b/scripts/validate-cli-package.ts index 68091513..48bb7852 100644 --- a/scripts/validate-cli-package.ts +++ b/scripts/validate-cli-package.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { readFile } from "node:fs/promises"; import { isDeepStrictEqual } from "node:util"; import { list, type ReadEntry } from "tar"; @@ -33,6 +34,7 @@ interface PackageEntryTarget { } export interface ValidateCliPackageInput { + compareTarballPath?: string; sourcePackageJsonPath: string; tarballPath: string; } @@ -43,6 +45,14 @@ export interface ValidatedCliPackage { version: string; } +interface ArchiveContentEntry { + digest: string; + executable: boolean; + linkpath: string; + size: number; + type: string; +} + export async function validateCliPackageArchive({ sourcePackageJsonPath, tarballPath, @@ -98,6 +108,84 @@ export async function validateCliPackageArchive({ }; } +export async function compareCliPackageArchives( + candidateTarballPath: string, + publishedTarballPath: string +): Promise { + const [candidateEntries, publishedEntries] = await Promise.all([ + readArchiveContentEntries(candidateTarballPath), + readArchiveContentEntries(publishedTarballPath), + ]); + const candidatePaths = [...candidateEntries.keys()].sort(); + const publishedPaths = [...publishedEntries.keys()].sort(); + if (!isDeepStrictEqual(candidatePaths, publishedPaths)) { + const candidateOnly = candidatePaths.filter((path) => !publishedEntries.has(path)); + const publishedOnly = publishedPaths.filter((path) => !candidateEntries.has(path)); + throw new Error( + `Packed CLI contents differ; candidate-only: ${candidateOnly.join(", ") || "none"}; published-only: ${publishedOnly.join(", ") || "none"}` + ); + } + + for (const path of candidatePaths) { + if (!isDeepStrictEqual(candidateEntries.get(path), publishedEntries.get(path))) { + throw new Error(`Packed CLI contents differ at ${path}`); + } + } +} + +async function readArchiveContentEntries( + tarballPath: string +): Promise> { + const entries = new Map(); + const seen = new Set(); + const reads: Promise[] = []; + + await list({ + file: tarballPath, + strict: true, + onReadEntry(entry) { + if (entry.type === "Directory") { + entry.resume(); + return; + } + const archivePath = normalizeArchivePath(entry.path); + if (seen.has(archivePath)) { + throw new Error(`Packed CLI contains duplicate archive path: ${archivePath}`); + } + seen.add(archivePath); + reads.push( + readEntryContent(entry).then((content) => { + entries.set(archivePath, content); + }) + ); + }, + }); + await Promise.all(reads); + return entries; +} + +function readEntryContent(entry: ReadEntry): Promise { + return new Promise((resolve, reject) => { + const hash = createHash("sha512"); + let size = 0; + entry.on("data", (chunk: Buffer | string) => { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + hash.update(buffer); + size += buffer.length; + }); + entry.on("end", () => + resolve({ + digest: hash.digest("base64"), + executable: ((entry.mode ?? 0) & 0o111) !== 0, + linkpath: entry.linkpath ?? "", + size, + type: entry.type, + }) + ); + entry.on("error", reject); + }); +} + async function readArchiveEntries(tarballPath: string): Promise> { const entries = new Map(); const reads: Promise[] = []; @@ -269,6 +357,7 @@ function isRecord(value: unknown): value is Record { } export function parseValidateCliPackageArguments(argv: string[]): ValidateCliPackageInput { + let compareTarballPath: string | undefined; let tarballPath: string | undefined; let sourcePackageJsonPath: string | undefined; @@ -279,6 +368,8 @@ export function parseValidateCliPackageArguments(argv: string[]): ValidateCliPac } if (argument === "--tarball") { tarballPath = argv[++index]; + } else if (argument === "--compare-tarball") { + compareTarballPath = argv[++index]; } else if (argument === "--source-package-json") { sourcePackageJsonPath = argv[++index]; } else { @@ -290,12 +381,24 @@ export function parseValidateCliPackageArguments(argv: string[]): ValidateCliPac throw new Error("Usage: validate-cli-package --tarball --source-package-json "); } - return { sourcePackageJsonPath, tarballPath }; + return { + ...(compareTarballPath ? { compareTarballPath } : {}), + sourcePackageJsonPath, + tarballPath, + }; } if (isDirectExecution(import.meta.url)) { - validateCliPackageArchive(parseValidateCliPackageArguments(process.argv.slice(2))) - .then((result) => { + const input = parseValidateCliPackageArguments(process.argv.slice(2)); + validateCliPackageArchive(input) + .then(async (result) => { + if (input.compareTarballPath) { + await validateCliPackageArchive({ + sourcePackageJsonPath: input.sourcePackageJsonPath, + tarballPath: input.compareTarballPath, + }); + await compareCliPackageArchives(input.tarballPath, input.compareTarballPath); + } success( `Validated ${result.name}@${result.version} package entry files: ${result.entryTargets.join(", ")}` ); diff --git a/scripts/verify-desktop-installed-update.ts b/scripts/verify-desktop-installed-update.ts index 4556262c..5cf90a21 100644 --- a/scripts/verify-desktop-installed-update.ts +++ b/scripts/verify-desktop-installed-update.ts @@ -263,6 +263,15 @@ async function connectBrowser(cdpUrl: string): Promise { ).toString(); const playwright = (await import(playwrightUrl)) as { chromium: { + launch(options: { channel: "msedge"; headless: true }): Promise<{ + newPage(): Promise<{ + addInitScript(callback: () => void): Promise; + goto(url: string, options: { waitUntil: "domcontentloaded" }): Promise; + waitForTimeout(timeout: number): Promise; + evaluate(callback: () => T | Promise): Promise; + }>; + close(): Promise; + }>; connectOverCDP(url: string): Promise<{ contexts(): Array<{ pages(): Array<{ @@ -300,8 +309,11 @@ async function connectBrowser(cdpUrl: string): Promise { return operation.call(bridge); }, method), verifyExternalSidecar: async (url) => { - if (!context) throw new Error("Installed Desktop CDP session has no browser context"); - const externalPage = await context.newPage(); + const externalBrowser = await playwright.chromium.launch({ + channel: "msedge", + headless: true, + }); + const externalPage = await externalBrowser.newPage(); try { await externalPage.addInitScript(() => { const sent: string[] = []; @@ -341,7 +353,7 @@ async function connectBrowser(cdpUrl: string): Promise { }; }); } finally { - await externalPage.close(); + await externalBrowser.close(); } }, };