Skip to content
Merged
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
15 changes: 13 additions & 2 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions scripts/github-workflows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
46 changes: 44 additions & 2 deletions scripts/validate-cli-package.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,17 @@ import { join } from "node:path";
import { create } from "tar";
import { describe, expect, it } from "vitest";
import {
compareCliPackageArchives,
parseValidateCliPackageArguments,
validateCliPackageArchive,
} from "./validate-cli-package.js";

interface PackageFixtureOptions {
binContent?: string;
includeTypes?: boolean;
indexContent?: string;
packedManifest?: Record<string, unknown>;
tarMtime?: Date;
}

const sourceManifest = {
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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");
Expand All @@ -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 };
}
109 changes: 106 additions & 3 deletions scripts/validate-cli-package.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -33,6 +34,7 @@ interface PackageEntryTarget {
}

export interface ValidateCliPackageInput {
compareTarballPath?: string;
sourcePackageJsonPath: string;
tarballPath: string;
}
Expand All @@ -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,
Expand Down Expand Up @@ -98,6 +108,84 @@ export async function validateCliPackageArchive({
};
}

export async function compareCliPackageArchives(
candidateTarballPath: string,
publishedTarballPath: string
): Promise<void> {
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<Map<string, ArchiveContentEntry>> {
const entries = new Map<string, ArchiveContentEntry>();
const seen = new Set<string>();
const reads: Promise<void>[] = [];

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<ArchiveContentEntry> {
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<Map<string, ArchiveEntry>> {
const entries = new Map<string, ArchiveEntry>();
const reads: Promise<void>[] = [];
Expand Down Expand Up @@ -269,6 +357,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
}

export function parseValidateCliPackageArguments(argv: string[]): ValidateCliPackageInput {
let compareTarballPath: string | undefined;
let tarballPath: string | undefined;
let sourcePackageJsonPath: string | undefined;

Expand All @@ -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 {
Expand All @@ -290,12 +381,24 @@ export function parseValidateCliPackageArguments(argv: string[]): ValidateCliPac
throw new Error("Usage: validate-cli-package --tarball <file> --source-package-json <file>");
}

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(", ")}`
);
Expand Down
18 changes: 15 additions & 3 deletions scripts/verify-desktop-installed-update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,15 @@ async function connectBrowser(cdpUrl: string): Promise<BrowserSession> {
).toString();
const playwright = (await import(playwrightUrl)) as {
chromium: {
launch(options: { channel: "msedge"; headless: true }): Promise<{
newPage(): Promise<{
addInitScript(callback: () => void): Promise<void>;
goto(url: string, options: { waitUntil: "domcontentloaded" }): Promise<unknown>;
waitForTimeout(timeout: number): Promise<void>;
evaluate<T>(callback: () => T | Promise<T>): Promise<T>;
}>;
close(): Promise<void>;
}>;
connectOverCDP(url: string): Promise<{
contexts(): Array<{
pages(): Array<{
Expand Down Expand Up @@ -300,8 +309,11 @@ async function connectBrowser(cdpUrl: string): Promise<BrowserSession> {
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[] = [];
Expand Down Expand Up @@ -341,7 +353,7 @@ async function connectBrowser(cdpUrl: string): Promise<BrowserSession> {
};
});
} finally {
await externalPage.close();
await externalBrowser.close();
}
},
};
Expand Down
Loading