diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9141619..565ea7b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -208,9 +208,52 @@ jobs: safe-change-sbom.cdx.json if-no-files-found: error + bind-artifacts: + name: Bind Exact Release Artifacts + needs: [build-native, build-root] + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.2.0 + with: + node-version: 20.x + + - name: Download native binaries + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + pattern: native-binary-* + path: artifacts + + - name: Download root package and SBOM + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: root-package-tarball + path: root-artifacts + + - name: Download release self-evidence + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: release-evidence-bundle + path: root-artifacts + + - name: Bind package metadata, source SHA, and artifact digests + run: node scripts/publish-orchestrator.mjs --phase prepare + + - name: Upload bound release artifacts + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: bound-release-artifacts + path: release-bound + if-no-files-found: error + publish-native: name: Publish Native Platform Packages - needs: [preflight, build-native] + needs: [preflight, bind-artifacts] runs-on: ubuntu-latest if: github.event_name == 'push' && github.ref_type == 'tag' && startsWith(github.ref_name, 'v') environment: npm-production @@ -226,10 +269,14 @@ jobs: node-version: 20.x registry-url: "https://registry.npmjs.org" - - name: Download all native artifacts + - name: Download bound release artifacts uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: - path: artifacts + name: bound-release-artifacts + path: release-bound + + - name: Verify bound artifact manifest before native publication + run: node scripts/publish-orchestrator.mjs --dry-run --phase native - name: Orchestrate exact native tarball publication run: node scripts/publish-orchestrator.mjs --phase native @@ -238,7 +285,7 @@ jobs: publish-root: name: Publish Root safe-change Package - needs: [preflight, build-root, publish-native] + needs: [preflight, bind-artifacts, publish-native] runs-on: ubuntu-latest if: github.event_name == 'push' && github.ref_type == 'tag' && startsWith(github.ref_name, 'v') environment: npm-production @@ -257,20 +304,14 @@ jobs: node-version: 20.x registry-url: "https://registry.npmjs.org" - - name: Download root tarball artifact - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - with: - name: root-package-tarball - path: . - - - name: Download verified release self-evidence + - name: Download bound release artifacts uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: - name: release-evidence-bundle - path: . + name: bound-release-artifacts + path: release-bound - - name: Verify self-evidence archive checksum - run: sha256sum -c release-evidence.tar.gz.sha256 + - name: Verify bound artifact manifest before root publication + run: node scripts/publish-orchestrator.mjs --dry-run --phase root - name: Publish exact verified root tarball run: node scripts/publish-orchestrator.mjs --phase root @@ -287,11 +328,11 @@ jobs: if [[ "$RELEASE_TAG" == *-* ]]; then gh release create "$RELEASE_TAG" --prerelease --latest=false \ --title "$RELEASE_TAG — Experimental Developer Preview" \ - --generate-notes safe-change-*.tgz safe-change-sbom.cdx.json safe-change-audit-anchor.json release-evidence.tar.gz release-evidence.tar.gz.sha256 + --generate-notes release-bound/*.tgz release-bound/safe-change-sbom.cdx.json safe-change-audit-anchor.json release-bound/release-evidence.tar.gz release-bound/release-evidence.tar.gz.sha256 release-bound/release-artifact-manifest.json release-bound/release-artifact-manifest.json.sha256 else gh release create "$RELEASE_TAG" \ --title "$RELEASE_TAG" \ - --generate-notes safe-change-*.tgz safe-change-sbom.cdx.json safe-change-audit-anchor.json release-evidence.tar.gz release-evidence.tar.gz.sha256 + --generate-notes release-bound/*.tgz release-bound/safe-change-sbom.cdx.json safe-change-audit-anchor.json release-bound/release-evidence.tar.gz release-bound/release-evidence.tar.gz.sha256 release-bound/release-artifact-manifest.json release-bound/release-artifact-manifest.json.sha256 fi env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -299,7 +340,7 @@ jobs: dry-run-artifacts: name: Verify Exact Release Artifacts (Dry Run) - needs: [build-native, build-root] + needs: bind-artifacts if: github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest steps: @@ -314,26 +355,11 @@ jobs: node-version: 20.x registry-url: "https://registry.npmjs.org" - - name: Download native artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - with: - pattern: native-binary-* - path: artifacts - - - name: Download root tarball artifact + - name: Download bound release artifacts uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: - name: root-package-tarball - path: . - - - name: Download verified release self-evidence - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - with: - name: release-evidence-bundle - path: . - - - name: Verify self-evidence archive checksum - run: sha256sum -c release-evidence.tar.gz.sha256 + name: bound-release-artifacts + path: release-bound - name: Verify exact native and root artifacts without publishing run: node scripts/publish-orchestrator.mjs --dry-run diff --git a/scripts/publish-orchestrator.mjs b/scripts/publish-orchestrator.mjs index 0249bd5..9c4e3a1 100644 --- a/scripts/publish-orchestrator.mjs +++ b/scripts/publish-orchestrator.mjs @@ -5,9 +5,12 @@ import { statSync, mkdirSync, copyFileSync, + writeFileSync, + rmSync, + lstatSync, } from "node:fs"; import { createHash } from "node:crypto"; -import { resolve, join, basename } from "node:path"; +import { resolve, join, basename, dirname } from "node:path"; import { execFileSync } from "node:child_process"; import { fileURLToPath } from "node:url"; @@ -15,6 +18,9 @@ import { isValidSemver } from "./sync-version.mjs"; import { NATIVE_TARGET_NAMES } from "./native-targets.mjs"; export const ACTIVE_TARGETS = NATIVE_TARGET_NAMES; +export const BOUND_RELEASE_DIR = "release-bound"; +export const ARTIFACT_MANIFEST = "release-artifact-manifest.json"; +export const ARTIFACT_MANIFEST_CHECKSUM = `${ARTIFACT_MANIFEST}.sha256`; function encodePackageName(pkgName) { return encodeURIComponent(pkgName); @@ -147,6 +153,289 @@ export function findRootTarball(rootDir, packageName, version) { return { path, sha256: sha256File(path) }; } +function assertRegularFile(filePath) { + if ( + !existsSync(filePath) || + !lstatSync(filePath).isFile() || + statSync(filePath).size === 0 + ) { + throw new Error( + `Required regular artifact is missing or empty: ${filePath}`, + ); + } +} + +export function inspectPackageTarball(tarballPath) { + assertRegularFile(tarballPath); + const tarballDirectory = dirname(tarballPath); + const tarballName = basename(tarballPath); + try { + const metadata = JSON.parse( + execFileSync("tar", ["-xOf", tarballName, "package/package.json"], { + cwd: tarballDirectory, + encoding: "utf8", + maxBuffer: 16 * 1024 * 1024, + }), + ); + const files = execFileSync("tar", ["-tzf", tarballName], { + cwd: tarballDirectory, + encoding: "utf8", + maxBuffer: 16 * 1024 * 1024, + }) + .split(/\r?\n/) + .filter(Boolean); + return { metadata, files }; + } catch (error) { + throw new Error( + `Cannot inspect package tarball ${basename(tarballPath)}: ${error.message}`, + ); + } +} + +function artifactRecord(filePath, fields) { + assertRegularFile(filePath); + return { + ...fields, + file: basename(filePath), + size: statSync(filePath).size, + sha256: sha256File(filePath), + }; +} + +function copyRequiredFile(source, destination) { + assertRegularFile(source); + copyFileSync(source, destination); + assertRegularFile(destination); +} + +function validateEvidenceChecksum(boundDir) { + const checksum = readFileSync( + join(boundDir, "release-evidence.tar.gz.sha256"), + "utf8", + ).trim(); + const match = /^([a-f0-9]{64}) release-evidence\.tar\.gz$/.exec(checksum); + if ( + !match || + match[1] !== sha256File(join(boundDir, "release-evidence.tar.gz")) + ) { + throw new Error("Release evidence checksum is invalid."); + } +} + +export function validateBoundReleaseArtifacts({ + rootDir = resolve("."), + sourceSha = process.env.GITHUB_SHA, + runId = process.env.GITHUB_RUN_ID, +} = {}) { + if (!/^[a-f0-9]{40}$/.test(sourceSha ?? "") || !/^\d+$/.test(runId ?? "")) { + throw new Error("Missing or invalid bound-artifact provenance."); + } + const boundDir = join(rootDir, BOUND_RELEASE_DIR); + const manifestPath = join(boundDir, ARTIFACT_MANIFEST); + const checksumPath = join(boundDir, ARTIFACT_MANIFEST_CHECKSUM); + assertRegularFile(manifestPath); + assertRegularFile(checksumPath); + const checksum = readFileSync(checksumPath, "utf8").trim(); + const checksumMatch = new RegExp( + `^([a-f0-9]{64}) ${ARTIFACT_MANIFEST}$`, + ).exec(checksum); + if (!checksumMatch || checksumMatch[1] !== sha256File(manifestPath)) { + throw new Error("Release artifact manifest checksum is invalid."); + } + + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + const rootPkg = JSON.parse( + readFileSync(join(rootDir, "package.json"), "utf8"), + ); + if ( + manifest.schemaVersion !== 1 || + manifest.sourceSha !== sourceSha || + manifest.runId !== runId || + manifest.packageVersion !== rootPkg.version || + !Array.isArray(manifest.artifacts) + ) { + throw new Error("Release artifact manifest provenance or schema mismatch."); + } + + const expectedKinds = [ + "root-package", + ...ACTIVE_TARGETS.map((target) => `native-package:${target}`), + "sbom", + "release-evidence", + "release-evidence-checksum", + ]; + const actualKinds = manifest.artifacts.map((artifact) => + artifact.kind === "native-package" + ? `${artifact.kind}:${artifact.target}` + : artifact.kind, + ); + if (actualKinds.join() !== expectedKinds.join()) { + throw new Error( + "Release artifact manifest has an unexpected or incomplete artifact set.", + ); + } + + const seenFiles = new Set(); + for (const artifact of manifest.artifacts) { + if ( + typeof artifact.file !== "string" || + basename(artifact.file) !== artifact.file || + seenFiles.has(artifact.file) || + !/^[a-f0-9]{64}$/.test(artifact.sha256) || + !Number.isSafeInteger(artifact.size) || + artifact.size <= 0 + ) { + throw new Error("Release artifact record is malformed."); + } + seenFiles.add(artifact.file); + const artifactPath = join(boundDir, artifact.file); + assertRegularFile(artifactPath); + if ( + statSync(artifactPath).size !== artifact.size || + sha256File(artifactPath) !== artifact.sha256 + ) { + throw new Error(`Bound release artifact mismatch: ${artifact.file}`); + } + } + + for (const artifact of manifest.artifacts.filter((entry) => + ["root-package", "native-package"].includes(entry.kind), + )) { + const { metadata, files } = inspectPackageTarball( + join(boundDir, artifact.file), + ); + const expectedName = + artifact.kind === "root-package" + ? rootPkg.name + : `@safe-change/${artifact.target}`; + if ( + artifact.packageName !== expectedName || + artifact.version !== rootPkg.version || + metadata.name !== expectedName || + metadata.version !== rootPkg.version + ) { + throw new Error(`Bound package metadata mismatch: ${artifact.file}`); + } + if ( + artifact.kind === "native-package" && + !files.includes("package/safe-change-native.node") + ) { + throw new Error( + `Bound native package is missing its binary: ${artifact.file}`, + ); + } + if (artifact.kind === "root-package") { + for (const target of ACTIVE_TARGETS) { + if ( + metadata.optionalDependencies?.[`@safe-change/${target}`] !== + rootPkg.version + ) { + throw new Error( + `Bound root package is missing native dependency ${target}.`, + ); + } + } + } + } + validateEvidenceChecksum(boundDir); + return manifest; +} + +export function prepareBoundReleaseArtifacts({ + rootDir = resolve("."), + sourceSha = process.env.GITHUB_SHA, + runId = process.env.GITHUB_RUN_ID, +} = {}) { + if (!/^[a-f0-9]{40}$/.test(sourceSha ?? "") || !/^\d+$/.test(runId ?? "")) { + throw new Error("Missing or invalid bound-artifact provenance."); + } + const rootPkg = JSON.parse( + readFileSync(join(rootDir, "package.json"), "utf8"), + ); + if (!isValidSemver(rootPkg.version)) + throw new Error("Invalid root package version."); + const boundDir = join(rootDir, BOUND_RELEASE_DIR); + rmSync(boundDir, { recursive: true, force: true }); + mkdirSync(boundDir, { recursive: true }); + + copyNativeArtifacts(rootDir); + const artifacts = []; + const rootArtifactsDir = join(rootDir, "root-artifacts"); + const rootTarball = findRootTarball( + rootArtifactsDir, + rootPkg.name, + rootPkg.version, + ); + const boundRootTarball = join(boundDir, basename(rootTarball.path)); + copyRequiredFile(rootTarball.path, boundRootTarball); + const rootMetadata = inspectPackageTarball(boundRootTarball).metadata; + if ( + rootMetadata.name !== rootPkg.name || + rootMetadata.version !== rootPkg.version + ) { + throw new Error( + "Root tarball metadata does not match the release package.", + ); + } + artifacts.push( + artifactRecord(boundRootTarball, { + kind: "root-package", + packageName: rootPkg.name, + version: rootPkg.version, + target: null, + }), + ); + + for (const target of ACTIVE_TARGETS) { + const targetDir = join(rootDir, "npm", target); + assertRegularFile(join(targetDir, "safe-change-native.node")); + const tarball = createPackageTarball(targetDir, boundDir); + if ( + tarball.name !== `@safe-change/${target}` || + tarball.version !== rootPkg.version || + !tarball.files.includes("safe-change-native.node") + ) { + throw new Error(`Native tarball metadata mismatch for ${target}.`); + } + artifacts.push( + artifactRecord(tarball.path, { + kind: "native-package", + packageName: tarball.name, + version: tarball.version, + target, + }), + ); + } + + for (const [file, kind] of [ + ["safe-change-sbom.cdx.json", "sbom"], + ["release-evidence.tar.gz", "release-evidence"], + ["release-evidence.tar.gz.sha256", "release-evidence-checksum"], + ]) { + const destination = join(boundDir, file); + copyRequiredFile(join(rootArtifactsDir, file), destination); + artifacts.push(artifactRecord(destination, { kind })); + } + validateEvidenceChecksum(boundDir); + + const manifest = { + schemaVersion: 1, + sourceSha, + runId, + packageVersion: rootPkg.version, + artifacts, + }; + const manifestPath = join(boundDir, ARTIFACT_MANIFEST); + writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); + writeFileSync( + join(boundDir, ARTIFACT_MANIFEST_CHECKSUM), + `${sha256File(manifestPath)} ${ARTIFACT_MANIFEST}\n`, + "utf8", + ); + validateBoundReleaseArtifacts({ rootDir, sourceSha, runId }); + return manifest; +} + export async function determineReleaseMode( version, registry = "https://registry.npmjs.org", @@ -208,12 +497,10 @@ export async function orchestrateNativePublish({ registry = "https://registry.npmjs.org", } = {}) { const rootPkg = JSON.parse( - readFileSync(join(rootDir, "package.json"), "utf-8"), + readFileSync(join(rootDir, "package.json"), "utf8"), ); const version = rootPkg.version; - copyNativeArtifacts(rootDir); - const outputDir = join(rootDir, "release-tarballs"); - + const manifest = validateBoundReleaseArtifacts({ rootDir }); for (const target of ACTIVE_TARGETS) { const pkgName = `@safe-change/${target}`; const status = await getPackageVersionStatus(pkgName, version, registry); @@ -228,37 +515,15 @@ export async function orchestrateNativePublish({ `Cannot determine publication state for ${pkgName}@${version}.`, ); } - - const targetDir = join(rootDir, "npm", target); - const metadata = JSON.parse( - readFileSync(join(targetDir, "package.json"), "utf-8"), + const artifact = manifest.artifacts.find( + (entry) => entry.kind === "native-package" && entry.target === target, ); - if (metadata.name !== pkgName || metadata.version !== version) { - throw new Error(`Package metadata mismatch for ${target}.`); - } - const binaryPath = join(targetDir, "safe-change-native.node"); - if (!existsSync(binaryPath) || statSync(binaryPath).size === 0) { - if (dryRun) { - console.warn( - `[DRY-RUN] Native binary unavailable for ${target}; publication was not attempted.`, - ); - continue; - } - throw new Error(`Missing or empty native binary for ${target}.`); - } - - const tarball = createPackageTarball(targetDir, outputDir); - if (tarball.name !== pkgName || tarball.version !== version) { - throw new Error(`Packed metadata mismatch for ${pkgName}@${version}.`); - } - if (!tarball.files.includes("safe-change-native.node")) { - throw new Error( - `Native binary missing from packed tarball for ${target}.`, - ); - } - console.log(`Verified ${basename(tarball.path)} sha256=${tarball.sha256}`); + if (!artifact) + throw new Error(`Bound native artifact missing for ${target}.`); + const tarballPath = join(rootDir, BOUND_RELEASE_DIR, artifact.file); + console.log(`Verified ${artifact.file} sha256=${artifact.sha256}`); if (!dryRun) { - publishTarball(tarball.path, version); + publishTarball(tarballPath, version); if (!(await pollPackageVisibility(pkgName, version, registry))) { throw new Error( `${pkgName}@${version} is not visible after publication.`, @@ -274,10 +539,10 @@ export async function orchestrateRootPublish({ registry = "https://registry.npmjs.org", } = {}) { const rootPkg = JSON.parse( - readFileSync(join(rootDir, "package.json"), "utf-8"), + readFileSync(join(rootDir, "package.json"), "utf8"), ); const version = rootPkg.version; - + const manifest = validateBoundReleaseArtifacts({ rootDir }); for (const target of ACTIVE_TARGETS) { const pkgName = `@safe-change/${target}`; if ( @@ -289,13 +554,15 @@ export async function orchestrateRootPublish({ ); } } - - const tarball = findRootTarball(rootDir, rootPkg.name, version); + const rootArtifact = manifest.artifacts.find( + (entry) => entry.kind === "root-package", + ); + if (!rootArtifact) throw new Error("Bound root artifact is missing."); + const tarballPath = join(rootDir, BOUND_RELEASE_DIR, rootArtifact.file); console.log( - `Verified exact root artifact ${basename(tarball.path)} sha256=${tarball.sha256}`, + `Verified exact root artifact ${rootArtifact.file} sha256=${rootArtifact.sha256}`, ); if (dryRun) return; - const rootStatus = await getPackageVersionStatus( rootPkg.name, version, @@ -307,9 +574,9 @@ export async function orchestrateRootPublish({ ); } if (rootStatus.status !== "not_published") { - throw new Error(`Cannot determine root package publication state.`); + throw new Error("Cannot determine root package publication state."); } - publishTarball(tarball.path, version); + publishTarball(tarballPath, version); if (!(await pollPackageVisibility(rootPkg.name, version, registry))) { throw new Error( `${rootPkg.name}@${version} is not visible after publication.`, @@ -322,9 +589,10 @@ export async function main() { const dryRun = args.includes("--dry-run"); const phaseIndex = args.indexOf("--phase"); const phase = phaseIndex === -1 ? "all" : args[phaseIndex + 1]; - if (!new Set(["all", "native", "root"]).has(phase)) { + if (!new Set(["all", "prepare", "native", "root"]).has(phase)) { throw new Error(`Invalid release phase: ${phase}`); } + if (phase === "prepare") prepareBoundReleaseArtifacts(); if (phase === "native" || phase === "all") await orchestrateNativePublish({ dryRun }); if (phase === "root" || phase === "all") diff --git a/tests/unit/publish-orchestrator.test.ts b/tests/unit/publish-orchestrator.test.ts index bd08bed..6fe4863 100644 --- a/tests/unit/publish-orchestrator.test.ts +++ b/tests/unit/publish-orchestrator.test.ts @@ -1,6 +1,22 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { ACTIVE_TARGETS, pollPackageVisibility, sha256File, findRootTarball } from "../../scripts/publish-orchestrator.mjs"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { + ACTIVE_TARGETS, + ARTIFACT_MANIFEST, + BOUND_RELEASE_DIR, + createPackageTarball, + findRootTarball, + pollPackageVisibility, + prepareBoundReleaseArtifacts, + sha256File, + validateBoundReleaseArtifacts, +} from "../../scripts/publish-orchestrator.mjs"; +import { + appendFileSync, + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -28,7 +44,13 @@ describe("scripts/publish-orchestrator.mjs", () => { const mockFetch = vi.fn().mockResolvedValue({ status: 200 }); vi.stubGlobal("fetch", mockFetch); - const visible = await pollPackageVisibility("safe-change", "0.3.1", "https://registry.npmjs.org", 2, 10); + const visible = await pollPackageVisibility( + "safe-change", + "0.3.1", + "https://registry.npmjs.org", + 2, + 10, + ); expect(visible).toBe(true); expect(mockFetch).toHaveBeenCalledTimes(1); }); @@ -40,7 +62,13 @@ describe("scripts/publish-orchestrator.mjs", () => { .mockResolvedValueOnce({ status: 200 }); vi.stubGlobal("fetch", mockFetch); - const visible = await pollPackageVisibility("@safe-change/linux-x64-gnu", "0.3.1", "https://registry.npmjs.org", 3, 10); + const visible = await pollPackageVisibility( + "@safe-change/linux-x64-gnu", + "0.3.1", + "https://registry.npmjs.org", + 3, + 10, + ); expect(visible).toBe(true); expect(mockFetch).toHaveBeenCalledTimes(2); }); @@ -49,14 +77,19 @@ describe("scripts/publish-orchestrator.mjs", () => { const mockFetch = vi.fn().mockResolvedValue({ status: 404 }); vi.stubGlobal("fetch", mockFetch); - const visible = await pollPackageVisibility("@safe-change/win32-x64-msvc", "0.3.1", "https://registry.npmjs.org", 2, 10); + const visible = await pollPackageVisibility( + "@safe-change/win32-x64-msvc", + "0.3.1", + "https://registry.npmjs.org", + 2, + 10, + ); expect(visible).toBe(false); expect(mockFetch).toHaveBeenCalledTimes(2); }); }); }); - describe("exact release artifacts", () => { let dir: string; @@ -78,6 +111,113 @@ describe("exact release artifacts", () => { }); it("rejects a missing root tarball", () => { - expect(() => findRootTarball(dir, "safe-change", "0.3.1")).toThrow(/Expected exact root tarball/); + expect(() => findRootTarball(dir, "safe-change", "0.3.1")).toThrow( + /Expected exact root tarball/, + ); + }); +}); + +describe("bound release artifact manifest", () => { + let dir: string; + const version = "1.2.3-preview.1"; + const sourceSha = "a".repeat(40); + const runId = "12345"; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "safe-change-bound-release-")); + const optionalDependencies = Object.fromEntries( + ACTIVE_TARGETS.map((target) => [`@safe-change/${target}`, version]), + ); + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ name: "safe-change", version, optionalDependencies }), + ); + + for (const target of ACTIVE_TARGETS) { + const packageDir = join(dir, "npm", target); + mkdirSync(packageDir, { recursive: true }); + writeFileSync( + join(packageDir, "package.json"), + JSON.stringify({ + name: `@safe-change/${target}`, + version, + files: ["safe-change-native.node"], + }), + ); + const artifactDir = join(dir, "artifacts", `native-binary-${target}`); + mkdirSync(artifactDir, { recursive: true }); + writeFileSync(join(artifactDir, `${target}.node`), `binary-${target}`); + } + + const rootPackageDir = join(dir, "root-package"); + mkdirSync(rootPackageDir); + writeFileSync( + join(rootPackageDir, "package.json"), + JSON.stringify({ name: "safe-change", version, optionalDependencies }), + ); + const rootArtifactsDir = join(dir, "root-artifacts"); + createPackageTarball(rootPackageDir, rootArtifactsDir); + writeFileSync(join(rootArtifactsDir, "safe-change-sbom.cdx.json"), "{}\n"); + const evidencePath = join(rootArtifactsDir, "release-evidence.tar.gz"); + writeFileSync(evidencePath, "bound-evidence"); + writeFileSync( + `${evidencePath}.sha256`, + `${sha256File(evidencePath)} release-evidence.tar.gz\n`, + ); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it("binds root, all native packages, SBOM, and self-evidence before publication", () => { + const manifest = prepareBoundReleaseArtifacts({ + rootDir: dir, + sourceSha, + runId, + }); + expect(manifest.sourceSha).toBe(sourceSha); + expect(manifest.runId).toBe(runId); + expect(manifest.artifacts).toHaveLength(10); + expect( + manifest.artifacts.filter( + (entry: any) => entry.kind === "native-package", + ), + ).toHaveLength(6); + expect(() => + validateBoundReleaseArtifacts({ rootDir: dir, sourceSha, runId }), + ).not.toThrow(); + expect(sha256File(join(dir, BOUND_RELEASE_DIR, ARTIFACT_MANIFEST))).toMatch( + /^[a-f0-9]{64}$/, + ); + }); + + it("rejects any tarball mutation after the manifest is produced", () => { + const manifest = prepareBoundReleaseArtifacts({ + rootDir: dir, + sourceSha, + runId, + }); + const native = manifest.artifacts.find( + (entry: any) => entry.kind === "native-package", + ); + appendFileSync(join(dir, BOUND_RELEASE_DIR, native.file), "tampered"); + expect(() => + validateBoundReleaseArtifacts({ rootDir: dir, sourceSha, runId }), + ).toThrow(/Bound release artifact mismatch/); + }); + + it("rejects provenance from another source commit or workflow run", () => { + prepareBoundReleaseArtifacts({ rootDir: dir, sourceSha, runId }); + expect(() => + validateBoundReleaseArtifacts({ + rootDir: dir, + sourceSha: "b".repeat(40), + runId, + }), + ).toThrow(/provenance/); + expect(() => + validateBoundReleaseArtifacts({ rootDir: dir, sourceSha, runId: "999" }), + ).toThrow(/provenance/); }); }); diff --git a/tests/unit/release-evidence.test.ts b/tests/unit/release-evidence.test.ts index 59e2138..f6c7d0c 100644 --- a/tests/unit/release-evidence.test.ts +++ b/tests/unit/release-evidence.test.ts @@ -2,53 +2,201 @@ import { describe, expect, it } from "vitest"; import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; // @ts-expect-error release-internal JavaScript helper is exercised as-is. -import { components, validateReleaseEvidence } from "../../scripts/release-evidence.mjs"; -const sha = "a".repeat(40), baselineId = `sha256:${"b".repeat(64)}`; -const hash = (bytes: string) => createHash("sha256").update(bytes).digest("hex"); +import { + components, + validateReleaseEvidence, +} from "../../scripts/release-evidence.mjs"; +const sha = "a".repeat(40), + baselineId = `sha256:${"b".repeat(64)}`; +const hash = (bytes: string) => + createHash("sha256").update(bytes).digest("hex"); function fixture(change: (data: any) => void = () => undefined) { const data: Record = { "baseline-summary.json": { headCommit: sha }, "diff-summary.json": { added: [], modified: [], deleted: [] }, "environment-fingerprint.json": { digest: "fixture" }, - "verification-report.json": { schemaVersion: 1, state: "verified", exitCode: 0, verificationBinding: { baselineDigest: baselineId }, checks: [ - { name: "build", result: "pass-pass", exitCode: 0, timedOut: false, outputBlocked: false, detectedSecretTypes: [] }, - { name: "test", result: "pass-pass", exitCode: 0, timedOut: false, outputBlocked: false, detectedSecretTypes: [] }, - ] }, - "manifest.json": { schemaVersion: 1, sessionId: `release-${sha}`, baselineId, verificationState: "verified", verificationFreshness: "fresh", files: components }, + "verification-report.json": { + schemaVersion: 1, + state: "verified", + exitCode: 0, + verificationBinding: { baselineDigest: baselineId }, + checks: [ + { + name: "build", + result: "pass-pass", + exitCode: 0, + timedOut: false, + outputBlocked: false, + detectedSecretTypes: [], + }, + { + name: "test", + result: "pass-pass", + exitCode: 0, + timedOut: false, + outputBlocked: false, + detectedSecretTypes: [], + }, + ], + }, + "manifest.json": { + schemaVersion: 1, + sessionId: `release-${sha}`, + baselineId, + verificationState: "verified", + verificationFreshness: "fresh", + files: components, + }, }; change(data); - const files = Object.fromEntries(Object.entries(data).map(([name, value]) => [name, JSON.stringify(value)])); - data["manifest.json"].evidenceDigest = `sha256:${hash(JSON.stringify(Object.fromEntries(components.map((name: string) => [name, `sha256:${hash(files[name])}`]))))}`; + const files = Object.fromEntries( + Object.entries(data).map(([name, value]) => [name, JSON.stringify(value)]), + ); + data["manifest.json"].evidenceDigest = + `sha256:${hash(JSON.stringify(Object.fromEntries(components.map((name: string) => [name, `sha256:${hash(files[name])}`]))))}`; files["manifest.json"] = JSON.stringify(data["manifest.json"]); - files["checksums.txt"] = Object.entries(files).sort(([a], [b]) => a.localeCompare(b)).map(([name, bytes]) => `${hash(bytes)} ${name}`).join("\n") + "\n"; + files["checksums.txt"] = + Object.entries(files) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([name, bytes]) => `${hash(bytes)} ${name}`) + .join("\n") + "\n"; return files; } describe("release evidence packaging gate", () => { - it("accepts only matching fresh bound configured pass-pass evidence", () => { expect(validateReleaseEvidence(fixture(), sha, ["build", "test"]).verificationState).toBe("verified"); }); + it("accepts only matching fresh bound configured pass-pass evidence", () => { + expect( + validateReleaseEvidence(fixture(), sha, ["build", "test"]) + .verificationState, + ).toBe("verified"); + }); it.each([ - ["stale", (d: any) => { d["manifest.json"].verificationFreshness = "stale"; }], - ["unverified", (d: any) => { d["verification-report.json"].state = "not-verified"; }], - ["different commit", (d: any) => { d["baseline-summary.json"].headCommit = "c".repeat(40); }], - ["different baseline", (d: any) => { d["verification-report.json"].verificationBinding.baselineDigest = "wrong"; }], - ["failed", (d: any) => { d["verification-report.json"].checks[0].result = "pass-fail"; }], - ["bad exit", (d: any) => { d["verification-report.json"].checks[0].exitCode = 1; }], - ["timeout", (d: any) => { d["verification-report.json"].checks[0].timedOut = true; }], - ["blocked output", (d: any) => { d["verification-report.json"].checks[0].outputBlocked = true; }], - ["secret", (d: any) => { d["verification-report.json"].checks[0].detectedSecretTypes = ["fixture"]; }], - ["missing", (d: any) => { d["verification-report.json"].checks.pop(); }], - ["duplicate", (d: any) => { d["verification-report.json"].checks[1].name = "build"; }], - ["changed source", (d: any) => { d["diff-summary.json"].modified.push("src/index.ts"); }], - ])("rejects rechecksummed %s evidence", (_label, change) => { expect(() => validateReleaseEvidence(fixture(change as any), sha, ["build", "test"])).toThrow(); }); - it("rejects changed component bytes", () => { const files = fixture(); files["baseline-summary.json"] += " "; expect(() => validateReleaseEvidence(files, sha, ["build", "test"])).toThrow(/Checksum/); }); - it("rejects unexpected files rather than packing private material", () => { const files = fixture(); files["private.pem"] = "fixture"; expect(() => validateReleaseEvidence(files, sha, ["build", "test"])).toThrow(/file set/); }); - it("rejects traversal checksum names", () => { const files = fixture(); files["checksums.txt"] = files["checksums.txt"].replace("manifest.json", "../manifest.json"); expect(() => validateReleaseEvidence(files, sha, ["build", "test"])).toThrow(); }); - it("does not equate empty or duplicated requirements with verification", () => { for (const required of [[], ["build", "build"]]) expect(() => validateReleaseEvidence(fixture(), sha, required)).toThrow(); }); + [ + "stale", + (d: any) => { + d["manifest.json"].verificationFreshness = "stale"; + }, + ], + [ + "unverified", + (d: any) => { + d["verification-report.json"].state = "not-verified"; + }, + ], + [ + "different commit", + (d: any) => { + d["baseline-summary.json"].headCommit = "c".repeat(40); + }, + ], + [ + "different baseline", + (d: any) => { + d["verification-report.json"].verificationBinding.baselineDigest = + "wrong"; + }, + ], + [ + "failed", + (d: any) => { + d["verification-report.json"].checks[0].result = "pass-fail"; + }, + ], + [ + "bad exit", + (d: any) => { + d["verification-report.json"].checks[0].exitCode = 1; + }, + ], + [ + "timeout", + (d: any) => { + d["verification-report.json"].checks[0].timedOut = true; + }, + ], + [ + "blocked output", + (d: any) => { + d["verification-report.json"].checks[0].outputBlocked = true; + }, + ], + [ + "secret", + (d: any) => { + d["verification-report.json"].checks[0].detectedSecretTypes = [ + "fixture", + ]; + }, + ], + [ + "missing", + (d: any) => { + d["verification-report.json"].checks.pop(); + }, + ], + [ + "duplicate", + (d: any) => { + d["verification-report.json"].checks[1].name = "build"; + }, + ], + [ + "changed source", + (d: any) => { + d["diff-summary.json"].modified.push("src/index.ts"); + }, + ], + ])("rejects rechecksummed %s evidence", (_label, change) => { + expect(() => + validateReleaseEvidence(fixture(change as any), sha, ["build", "test"]), + ).toThrow(); + }); + it("rejects changed component bytes", () => { + const files = fixture(); + files["baseline-summary.json"] += " "; + expect(() => + validateReleaseEvidence(files, sha, ["build", "test"]), + ).toThrow(/Checksum/); + }); + it("rejects unexpected files rather than packing private material", () => { + const files = fixture(); + files["private.pem"] = "fixture"; + expect(() => + validateReleaseEvidence(files, sha, ["build", "test"]), + ).toThrow(/file set/); + }); + it("rejects traversal checksum names", () => { + const files = fixture(); + files["checksums.txt"] = files["checksums.txt"].replace( + "manifest.json", + "../manifest.json", + ); + expect(() => + validateReleaseEvidence(files, sha, ["build", "test"]), + ).toThrow(); + }); + it("does not equate empty or duplicated requirements with verification", () => { + for (const required of [[], ["build", "build"]]) + expect(() => validateReleaseEvidence(fixture(), sha, required)).toThrow(); + }); it("wires actual CLI dogfooding and both release attachment paths without new publishing permission", () => { - const ci = readFileSync(".github/workflows/ci.yml", "utf8"), release = readFileSync(".github/workflows/release.yml", "utf8"); - expect(ci).toContain("node scripts/release-evidence.mjs"); expect(ci).toContain("release-evidence-bundle"); + const ci = readFileSync(".github/workflows/ci.yml", "utf8"), + release = readFileSync(".github/workflows/release.yml", "utf8"); + expect(ci).toContain("node scripts/release-evidence.mjs"); + expect(ci).toContain("release-evidence-bundle"); expect(release).toContain("node scripts/release-evidence.mjs"); - expect(release.match(/--generate-notes .*release-evidence\.tar\.gz release-evidence\.tar\.gz\.sha256/g)).toHaveLength(2); + expect( + release.match( + /--generate-notes .*release-bound\/release-evidence\.tar\.gz release-bound\/release-evidence\.tar\.gz\.sha256 .*release-artifact-manifest\.json/g, + ), + ).toHaveLength(2); + expect(release).toContain( + "Bind package metadata, source SHA, and artifact digests", + ); expect(release).toContain("if-no-files-found: error"); - expect(release.split("if: github.event_name == 'push' && github.ref_type == 'tag' && startsWith(github.ref_name, 'v')")).toHaveLength(3); + expect( + release.split( + "if: github.event_name == 'push' && github.ref_type == 'tag' && startsWith(github.ref_name, 'v')", + ), + ).toHaveLength(3); }); }); diff --git a/tests/unit/release-workflow-policy.test.ts b/tests/unit/release-workflow-policy.test.ts index ad04ee6..d9d71c7 100644 --- a/tests/unit/release-workflow-policy.test.ts +++ b/tests/unit/release-workflow-policy.test.ts @@ -2,7 +2,10 @@ import { describe, expect, it } from "vitest"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; -const workflow = readFileSync(resolve(".github/workflows/release.yml"), "utf-8"); +const workflow = readFileSync( + resolve(".github/workflows/release.yml"), + "utf-8", +); describe("release workflow production policy", () => { it("keeps manual dispatch non-publishing", () => { @@ -13,16 +16,20 @@ describe("release workflow production policy", () => { }); it("passes the immutable Git tag to strict validation", () => { - expect(workflow).toContain('validate-release.mjs "${{ github.ref_name }}" --require-tag'); + expect(workflow).toContain( + 'validate-release.mjs "${{ github.ref_name }}" --require-tag', + ); }); it("guards every publication job with a tag-push condition", () => { - const guard = "if: github.event_name == 'push' && github.ref_type == 'tag' && startsWith(github.ref_name, 'v')"; + const guard = + "if: github.event_name == 'push' && github.ref_type == 'tag' && startsWith(github.ref_name, 'v')"; expect(workflow.split(guard)).toHaveLength(3); }); it("binds npm preflight to the protected production environment", () => { - const preflight = workflow.split(" preflight:")[1]?.split(" build-native:")[0] ?? ""; + const preflight = + workflow.split(" preflight:")[1]?.split(" build-native:")[0] ?? ""; expect(preflight).toContain("environment: npm-production"); }); @@ -31,6 +38,19 @@ describe("release workflow production policy", () => { expect(workflow).toContain("safe-change-sbom.cdx.json"); }); + it("binds every publish step to one source-scoped artifact manifest", () => { + expect(workflow).toContain("bind-artifacts:"); + expect(workflow).toContain("publish-orchestrator.mjs --phase prepare"); + expect(workflow).toContain("name: bound-release-artifacts"); + expect(workflow).toContain("release-artifact-manifest.json.sha256"); + expect( + workflow.match(/Verify bound artifact manifest before/g), + ).toHaveLength(2); + expect(workflow.indexOf("bind-artifacts:")).toBeLessThan( + workflow.indexOf("publish-native:"), + ); + }); + it("publishes through the orchestrator instead of npm publish in YAML", () => { expect(workflow).not.toMatch(/run:\s+npm publish/); expect(workflow).toContain("publish-orchestrator.mjs --phase native");