diff --git a/packages/coding-agent/.changes/release-manifest-forward-compat.md b/packages/coding-agent/.changes/release-manifest-forward-compat.md new file mode 100644 index 000000000..f6f842474 --- /dev/null +++ b/packages/coding-agent/.changes/release-manifest-forward-compat.md @@ -0,0 +1,2 @@ +- Fixed release manifest parsing rejecting all native binary entries when encountering an unknown future platform; unknown platforms are now skipped while known-platform entries remain strictly validated. +- Added versioned native binary metadata so the v1 manifest schema stays compatible while the v2 schema advertises every musl and baseline archive. diff --git a/packages/coding-agent/src/utils/version-check.ts b/packages/coding-agent/src/utils/version-check.ts index 8a2996b2c..741314ae7 100644 --- a/packages/coding-agent/src/utils/version-check.ts +++ b/packages/coding-agent/src/utils/version-check.ts @@ -186,6 +186,7 @@ export async function getLatestPiRelease( tarball?: unknown; version?: unknown; binaries?: unknown; + binariesV2?: unknown; }; if (typeof data.version !== "string" || !data.version.trim()) { return undefined; @@ -204,17 +205,23 @@ export async function getLatestPiRelease( if (installSpec) { release.installSpec = installSpec; } - if (Array.isArray(data.binaries)) { - // Invalid optional native metadata must not discard a valid npm release. - // Publish the list only after every entry passes validation. + // Prefer the complete v2 schema, with the v1 schema as a compatibility + // fallback. Structurally valid entries for future platforms are ignored, + // while malformed or duplicate supported-platform entries reject the list. + const binarySource = Array.isArray(data.binariesV2) + ? data.binariesV2 + : Array.isArray(data.binaries) + ? data.binaries + : undefined; + if (binarySource) { const binaries: NativeReleaseArtifact[] = []; const platforms = new Set(); - for (const candidate of data.binaries) { + for (const candidate of binarySource) { if (!candidate || typeof candidate !== "object") return release; const artifact = candidate as Partial; + if (typeof artifact.platform !== "string") return release; + if (!isNativePlatform(artifact.platform)) continue; if ( - typeof artifact.platform !== "string" || - !isNativePlatform(artifact.platform) || platforms.has(artifact.platform) || artifact.file !== `prime-agent-${release.version}-${artifact.platform}.tar.gz` || typeof artifact.sha256 !== "string" || @@ -224,7 +231,7 @@ export async function getLatestPiRelease( platforms.add(artifact.platform); binaries.push({ platform: artifact.platform, file: artifact.file, sha256: artifact.sha256 }); } - release.binaries = binaries; + if (binaries.length > 0) release.binaries = binaries; } return release; } diff --git a/packages/coding-agent/test/release-workflow.test.ts b/packages/coding-agent/test/release-workflow.test.ts index 3716c2c90..dba83727b 100644 --- a/packages/coding-agent/test/release-workflow.test.ts +++ b/packages/coding-agent/test/release-workflow.test.ts @@ -245,3 +245,75 @@ ${step(validation, "Verify and exercise exact final Mac archives").run}`, }, ); }); + +describe("release manifest schemas", () => { + const manifestV1Platforms = ["darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64"]; + const binaries = releasePlatforms.map((platform, index) => ({ + platform, + file: `prime-agent-1.2.4-${platform}.tar.gz`, + sha256: (index + 1).toString(16).padStart(64, "0"), + executableSha256: (index + 11).toString(16).padStart(64, "0"), + })); + const tarballs = [ + ["prime-agent-ai", "1"], + ["prime-agent-core", "2"], + ["prime-agent-tui", "3"], + ["prime-agent", "4"], + ].map(([name, hash]) => ({ + name, + file: `${name}-1.2.4.tgz`, + sha256: hash.repeat(64), + })); + + it.each([ + ["stable", "latest.json"], + ["beta", "beta.json"], + ] as const)("writes the %s channel with v1 and v2 binary schemas", (channel, manifestName) => { + const directory = mkdtempSync(join(tmpdir(), "prime-release-manifest-")); + try { + const result = spawnSync( + process.execPath, + [ + "--input-type=module", + "-e", + `import { writeReleaseMetadata } from ${JSON.stringify(join(repository, "scripts/pack-prime-agent-release.mjs"))}; writeReleaseMetadata(${JSON.stringify( + { + artifactsDir: directory, + channel, + releaseVersion: "1.2.4", + codingAgentTarball: "prime-agent-1.2.4.tgz", + tarballs, + binaries, + }, + )});`, + ], + { encoding: "utf8" }, + ); + expect(result.status, result.stderr).toBe(0); + + const manifest = JSON.parse(readFileSync(join(directory, manifestName), "utf8")); + expect(manifest.version).toBe("v1.2.4"); + expect(manifest.tarballs).toEqual( + tarballs.map(({ name: packageName, file, sha256 }) => ({ package: packageName, file, sha256 })), + ); + expect(manifest.binaries.map((entry: { platform: string }) => entry.platform)).toEqual(manifestV1Platforms); + expect(manifest.binariesV2.map((entry: { platform: string }) => entry.platform)).toEqual(releasePlatforms); + expect(manifest.binariesV2.map((entry: { platform: string }) => entry.platform)).toEqual( + expect.arrayContaining([ + "linux-arm64-musl", + "linux-x64-baseline", + "linux-x64-musl", + "linux-x64-musl-baseline", + ]), + ); + expect(readFileSync(join(directory, channel), "utf8")).toBe("v1.2.4\n"); + + const expectedChecksums = [...tarballs, ...binaries] + .map((artifact) => `${artifact.sha256} ${artifact.file}`) + .join("\n"); + expect(readFileSync(join(directory, "SHA256SUMS"), "utf8")).toBe(`${expectedChecksums}\n`); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/coding-agent/test/version-check.test.ts b/packages/coding-agent/test/version-check.test.ts index e41ddb6cd..ca76ba791 100644 --- a/packages/coding-agent/test/version-check.test.ts +++ b/packages/coding-agent/test/version-check.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { NATIVE_PLATFORMS } from "../src/utils/native-installation.js"; import { checkForNewPiVersion, comparePackageVersions, @@ -161,3 +162,97 @@ describe("update channel preference", () => { await expect(checkForNewPiVersion("1.2.4", "nightly")).resolves.toBe("1.2.5-beta.1.1.abcdef0"); }); }); + +describe("manifest binary schema compatibility", () => { + const artifact = (platform: string, sha256 = "a".repeat(64)) => ({ + platform, + file: `prime-agent-1.2.4-${platform}.tar.gz`, + sha256, + }); + + function stubManifest(fields: Record): void { + vi.stubGlobal( + "fetch", + vi.fn(async () => + Response.json({ + version: "v1.2.4", + package: "prime-agent", + tarball: "releases/v1.2.4/prime-agent-1.2.4.tgz", + ...fields, + }), + ), + ); + } + + it("prefers binariesV2, keeps every supported platform, and skips future platforms", async () => { + stubManifest({ + binaries: [artifact("darwin-arm64", "b".repeat(64))], + binariesV2: [ + ...NATIVE_PLATFORMS.map((platform, index) => artifact(platform, index.toString(16).padStart(64, "0"))), + artifact("future-riscv128", "f".repeat(64)), + ], + }); + + const release = await getLatestPiRelease("1.2.3"); + expect(release?.binaries?.map(({ platform }) => platform)).toEqual(NATIVE_PLATFORMS); + expect(release?.binaries?.map(({ platform }) => platform)).toEqual( + expect.arrayContaining([ + "linux-arm64-musl", + "linux-x64-baseline", + "linux-x64-musl", + "linux-x64-musl-baseline", + ]), + ); + }); + + it("falls back to binaries when binariesV2 is absent", async () => { + stubManifest({ binaries: [artifact("darwin-arm64")] }); + + await expect(getLatestPiRelease("1.2.3")).resolves.toMatchObject({ + binaries: [artifact("darwin-arm64")], + }); + }); + + it("rejects the selected list when an entry is structurally malformed", async () => { + stubManifest({ + binaries: [artifact("darwin-arm64")], + binariesV2: [artifact("linux-x64"), null], + }); + + const release = await getLatestPiRelease("1.2.3"); + expect(release?.version).toBe("1.2.4"); + expect(release?.binaries).toBeUndefined(); + }); + + it("rejects the selected list when a supported platform entry is malformed", async () => { + stubManifest({ + binaries: [artifact("darwin-arm64")], + binariesV2: [artifact("linux-x64"), artifact("linux-x64-musl", "not-hex")], + }); + + const release = await getLatestPiRelease("1.2.3"); + expect(release?.version).toBe("1.2.4"); + expect(release?.binaries).toBeUndefined(); + }); + + it("rejects the selected list when a supported platform is duplicated", async () => { + stubManifest({ + binaries: [artifact("darwin-arm64")], + binariesV2: [artifact("linux-x64"), artifact("linux-x64", "b".repeat(64))], + }); + + const release = await getLatestPiRelease("1.2.3"); + expect(release?.version).toBe("1.2.4"); + expect(release?.binaries).toBeUndefined(); + }); + + it("preserves npm release info when the selected list contains only future platforms", async () => { + stubManifest({ binariesV2: [artifact("future-riscv128", "b".repeat(64))] }); + + await expect(getLatestPiRelease("1.2.3")).resolves.toEqual({ + version: "1.2.4", + packageName: "prime-agent", + installSpec: `${defaultPrimeAgentDownloadBaseUrl}/releases/v1.2.4/prime-agent-1.2.4.tgz`, + }); + }); +}); diff --git a/scripts/pack-prime-agent-release.mjs b/scripts/pack-prime-agent-release.mjs index 2e0575336..b34ac9800 100644 --- a/scripts/pack-prime-agent-release.mjs +++ b/scripts/pack-prime-agent-release.mjs @@ -13,8 +13,9 @@ import { writeFileSync, } from "node:fs"; import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { assembleBinaryArchives } from "./assemble-release-archives.mjs"; +import { manifestV1Platforms } from "./release-platforms.mjs"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const defaultOutputDir = join(root, "packages", "coding-agent", "release"); @@ -243,6 +244,37 @@ function sha256File(path) { return hash.digest("hex"); } +export function writeReleaseMetadata({ + artifactsDir, + channel, + releaseVersion, + codingAgentTarball, + tarballs, + binaries, +}) { + writeFileSync( + join(artifactsDir, "SHA256SUMS"), + [...tarballs, ...binaries].map((artifact) => `${artifact.sha256} ${artifact.file}`).join("\n") + "\n", + ); + writeFileSync(join(artifactsDir, channel), `v${releaseVersion}\n`); + + const manifestName = channel === "stable" ? "latest.json" : "beta.json"; + const manifestV1PlatformSet = new Set(manifestV1Platforms); + const binariesV1 = binaries.filter((entry) => manifestV1PlatformSet.has(entry.platform)); + writeJson(join(artifactsDir, manifestName), { + version: `v${releaseVersion}`, + package: publicPackageName, + tarball: `releases/v${releaseVersion}/${codingAgentTarball}`, + ...(binariesV1.length > 0 ? { binaries: binariesV1 } : {}), + ...(binaries.length > 0 ? { binariesV2: binaries } : {}), + tarballs: tarballs.map((tarball) => ({ + package: tarball.name, + file: tarball.file, + sha256: tarball.sha256, + })), + }); +} + function main() { const args = parseArgs(process.argv.slice(2)); const sourcePackages = new Map( @@ -340,22 +372,13 @@ function main() { const binaries = args.binaryDir ? assembleBinaryArchives({ binaryDir: args.binaryDir, artifactsDir, version: releaseVersion }) : []; - writeFileSync( - join(artifactsDir, "SHA256SUMS"), - [...tarballs, ...binaries].map((artifact) => `${artifact.sha256} ${artifact.file}`).join("\n") + "\n", - ); - writeFileSync(join(artifactsDir, args.channel), `v${releaseVersion}\n`); - const manifestName = args.channel === "stable" ? "latest.json" : "beta.json"; - writeJson(join(artifactsDir, manifestName), { - version: `v${releaseVersion}`, - package: publicPackageName, - tarball: `releases/v${releaseVersion}/${artifactFiles.get("coding-agent")}`, - ...(binaries.length > 0 ? { binaries } : {}), - tarballs: tarballs.map((tarball) => ({ - package: tarball.name, - file: tarball.file, - sha256: tarball.sha256, - })), + writeReleaseMetadata({ + artifactsDir, + channel: args.channel, + releaseVersion, + codingAgentTarball: artifactFiles.get("coding-agent"), + tarballs, + binaries, }); for (const artifact of [...tarballs, ...binaries]) { @@ -363,9 +386,11 @@ function main() { } } -try { - main(); -} catch (error) { - console.error(error instanceof Error ? error.message : String(error)); - process.exit(1); +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } } diff --git a/scripts/release-platforms.mjs b/scripts/release-platforms.mjs index 20e78946e..27f04f8d5 100644 --- a/scripts/release-platforms.mjs +++ b/scripts/release-platforms.mjs @@ -22,6 +22,16 @@ export const releasePlatforms = [ "linux-x64-musl-baseline", ]; +// The v1 manifest field stays limited to the original platform schema. +// The v2 field uses releasePlatforms so current clients can select every +// supported archive without changing the v1 compatibility contract. +export const manifestV1Platforms = [ + "darwin-arm64", + "darwin-x64", + "linux-arm64", + "linux-x64", +]; + export const darwinReleasePlatforms = releasePlatforms.filter((platform) => platform.startsWith("darwin-")); if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {