From 10b0f5e9dfc9b89acc0d1f67b5dc5091b362a3c6 Mon Sep 17 00:00:00 2001 From: kattsushi Date: Mon, 31 Aug 2026 11:04:10 -0600 Subject: [PATCH 1/4] fix(release): package reviewed stable artifacts --- scripts/release-package-stable.mjs | 675 +++++++++++++++++++++++ scripts/release-package-stable.test.mjs | 396 +++++++++++++ scripts/release-stable-abandonments.json | 12 + 3 files changed, 1083 insertions(+) create mode 100644 scripts/release-package-stable.mjs create mode 100644 scripts/release-package-stable.test.mjs create mode 100644 scripts/release-stable-abandonments.json diff --git a/scripts/release-package-stable.mjs b/scripts/release-package-stable.mjs new file mode 100644 index 00000000..06644f2f --- /dev/null +++ b/scripts/release-package-stable.mjs @@ -0,0 +1,675 @@ +#!/usr/bin/env node +import { spawn } from "node:child_process" +import { createHash } from "node:crypto" +import { realpathSync } from "node:fs" +import { lstat, mkdir, readFile, readdir, writeFile } from "node:fs/promises" +import { basename, isAbsolute, join, resolve } from "node:path" +import { fileURLToPath, pathToFileURL } from "node:url" +import { isDeepStrictEqual } from "node:util" +import { gunzipSync } from "node:zlib" + +const HANDOFF_SCHEMA_VERSION = 1 +const MAX_HANDOFF_BYTES = 1024 * 1024 +const MAX_TARBALL_BYTES = 128 * 1024 * 1024 +const MAX_UNPACKED_BYTES = 256 * 1024 * 1024 +const MAX_ENTRY_BYTES = 64 * 1024 * 1024 +const MAX_ENTRIES = 20_000 +const MAX_COMMAND_OUTPUT = 1024 * 1024 +const COMMAND_TIMEOUT_MS = 120_000 +const WORKFLOW_PATH = ".github/workflows/release-stable.yml" +const PINNED_ABANDONMENT = Object.freeze({ + artifactSha: "f31390ce66ea157ea8b75f5259c203123e269759", + project: "@effectify/prisma", + name: "@effectify/prisma", + version: "1.1.14", + reason: "Reviewed exception: 1.1.14 has broken CLI/export paths; publish a reviewed 1.1.15 instead.", +}) + +function fail(message) { + throw new Error(message) +} +function object(value) { + return value !== null && typeof value === "object" && !Array.isArray(value) +} +function exactKeys(value, expected, label) { + if (!object(value)) fail(`${label} must be an object`) + const actual = Object.keys(value).sort() + const required = [...expected].sort() + if (!isDeepStrictEqual(actual, required)) fail(`${label} has unknown or missing fields`) +} +function parseJson(bytes, label) { + try { + return JSON.parse(Buffer.isBuffer(bytes) ? bytes.toString("utf8") : bytes) + } catch { + fail(`${label} is malformed JSON`) + } +} +function safeName(value) { + return ( + typeof value === "string" && + value.length > 0 && + value.length <= 214 && + /^@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/.test(value) + ) +} +function safeRoot(value) { + return ( + typeof value === "string" && + value.length > 0 && + value.length <= 512 && + !isAbsolute(value) && + !value.includes("\\") && + !/[\u0000-\u001f\u007f]/.test(value) && + value.split("/").every((part) => part !== "" && part !== "." && part !== "..") + ) +} +function safePackedPath(value) { + return safeRoot(value) && value.startsWith("package/") && value !== "package/" && !value.includes("//") +} +function safeBasename(value) { + return ( + typeof value === "string" && + value.length > 4 && + value.length <= 255 && + value === basename(value) && + /^[A-Za-z0-9._-]+\.tgz$/.test(value) + ) +} +function semver(value) { + return ( + typeof value === "string" && + /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-(?:0|[1-9][0-9]*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9][0-9]*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+(?:[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/.test( + value, + ) + ) +} +function stableSemver(value) { + return typeof value === "string" && /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/.test(value) +} +function fullSha(value) { + return typeof value === "string" && /^[0-9a-f]{40}$/.test(value) +} +function decimalIdentifier(value) { + return typeof value === "string" && /^(0|[1-9][0-9]*)$/.test(value) +} +function digest(algorithm, bytes) { + return createHash(algorithm).update(bytes).digest("hex") +} +function sortedUniqueNames(values, label) { + if (!Array.isArray(values) || values.length === 0 || values.some((value) => !safeName(value))) { + fail(`${label} is invalid`) + } + const sorted = [...values].sort() + if (new Set(sorted).size !== sorted.length) fail(`${label} contains duplicates`) + return sorted +} +function validateMetadata(value) { + exactKeys( + value, + ["repository", "workflowPath", "workflowRef", "workflowSha", "runId", "runAttempt", "expectedSha", "artifactSha"], + "handoff metadata", + ) + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value.repository)) fail("repository metadata is invalid") + if (value.workflowPath !== WORKFLOW_PATH) fail("workflow path metadata is invalid") + if (value.workflowRef !== "refs/heads/master") fail("workflow ref metadata is invalid") + if (!fullSha(value.workflowSha) || value.workflowSha !== value.expectedSha) fail("workflow SHA metadata is invalid") + if (!decimalIdentifier(value.runId)) fail("run ID metadata is invalid") + if (!decimalIdentifier(value.runAttempt) || value.runAttempt === "0") fail("run attempt metadata is invalid") + if (!fullSha(value.expectedSha)) fail("expected SHA metadata is invalid") + if (!fullSha(value.artifactSha)) fail("artifact SHA metadata is invalid") +} + +async function regularFile(path, label, maximum = Number.POSITIVE_INFINITY) { + let status + try { + status = await lstat(path) + } catch { + fail(`${label} is missing`) + } + if (status.isSymbolicLink() || !status.isFile()) fail(`${label} must be a regular file, not a symlink`) + if (status.size <= 0) fail(`${label} is empty`) + if (status.size > maximum) fail(`${label} exceeds its size bound`) + return status +} +async function readJsonFile(path, label, maximum = MAX_HANDOFF_BYTES) { + await regularFile(path, label, maximum) + return parseJson(await readFile(path), label) +} + +export async function loadStableAbandonments(path) { + const value = await readJsonFile(path, "stable abandonment ledger") + exactKeys(value, ["schemaVersion", "abandonments"], "stable abandonment ledger") + if (value.schemaVersion !== 1 || !Array.isArray(value.abandonments)) + fail("stable abandonment ledger schema is invalid") + if (value.abandonments.length !== 1) fail("stable abandonment ledger must contain exactly one reviewed disposition") + const record = value.abandonments[0] + exactKeys(record, ["artifactSha", "project", "name", "version", "reason"], "stable abandonment record") + if ( + !fullSha(record.artifactSha) || + !safeName(record.project) || + !safeName(record.name) || + !stableSemver(record.version) || + typeof record.reason !== "string" || + record.reason.length === 0 || + record.reason.length > 240 || + /[\u0000-\u001f\u007f*?${}]/.test(record.reason) || + !isDeepStrictEqual(record, PINNED_ABANDONMENT) + ) { + fail("stable abandonment record is not the exact reviewed disposition") + } + return structuredClone(value.abandonments) +} + +function parseOctal(field, label) { + const text = field.toString("ascii").replace(/\0.*$/, "").trim() + if (!/^[0-7]+$/.test(text)) fail(`tar ${label} is malformed`) + const value = Number.parseInt(text, 8) + if (!Number.isSafeInteger(value) || value < 0) fail(`tar ${label} exceeds its bound`) + return value +} +function tarString(field) { + const zero = field.indexOf(0) + return field.subarray(0, zero === -1 ? field.length : zero).toString("utf8") +} +function allZero(bytes) { + return bytes.every((byte) => byte === 0) +} +function collectRuntimeTargets(value, targets, label = "exports") { + if (typeof value === "string") { + targets.add(value) + return + } + if (value === null) return + if (Array.isArray(value)) { + for (const item of value) collectRuntimeTargets(item, targets, label) + return + } + if (!object(value)) fail(`packed ${label} is invalid`) + for (const [condition, target] of Object.entries(value)) { + if (condition === "@effectify/source") continue + collectRuntimeTargets(target, targets, label) + } +} +function unresolvedNormalization(value) { + if (typeof value === "string") return /^(?:catalog|workspace):/.test(value) + if (Array.isArray(value)) return value.some(unresolvedNormalization) + return object(value) && Object.values(value).some(unresolvedNormalization) +} + +export function inspectStableTarball(bytes, expectedIdentity) { + if (!Buffer.isBuffer(bytes) || bytes.length === 0 || bytes.length > MAX_TARBALL_BYTES) { + fail("tarball is empty or exceeds its size bound") + } + let archive + try { + archive = gunzipSync(bytes, { maxOutputLength: MAX_UNPACKED_BYTES }) + } catch { + fail("tarball gzip payload is invalid or oversized") + } + const inventory = [] + const bodies = new Map() + const paths = new Set() + let offset = 0 + let terminated = false + while (offset + 512 <= archive.length) { + const header = archive.subarray(offset, offset + 512) + if (allZero(header)) { + if (offset + 1024 > archive.length || !allZero(archive.subarray(offset + 512, offset + 1024))) { + fail("tar archive lacks two zero terminator blocks") + } + if (!allZero(archive.subarray(offset + 1024))) fail("tar archive has data after its terminator") + terminated = true + break + } + if (inventory.length >= MAX_ENTRIES) fail("tar inventory exceeds its entry bound") + const storedChecksum = parseOctal(header.subarray(148, 156), "checksum") + const copy = Buffer.from(header) + copy.fill(0x20, 148, 156) + const actualChecksum = [...copy].reduce((total, byte) => total + byte, 0) + if (storedChecksum !== actualChecksum) fail("tar header checksum mismatch") + const name = tarString(header.subarray(0, 100)) + const prefix = tarString(header.subarray(345, 500)) + const path = prefix ? `${prefix}/${name}` : name + if (!safePackedPath(path)) fail(`unsafe packed path: ${JSON.stringify(path)}`) + if (paths.has(path)) fail(`duplicate packed path: ${path}`) + paths.add(path) + const size = parseOctal(header.subarray(124, 136), "entry size") + if (size > MAX_ENTRY_BYTES) fail(`packed entry exceeds its size bound: ${path}`) + const mode = parseOctal(header.subarray(100, 108), "mode") + const rawType = header[156] + const type = rawType === 0 || rawType === 48 ? "file" : rawType === 53 ? "directory" : "unsafe" + if (type === "unsafe") fail(`packed links and special entries are forbidden: ${path}`) + if (type === "directory" && size !== 0) fail(`packed directory has content bytes: ${path}`) + const bodyStart = offset + 512 + const bodyEnd = bodyStart + size + if (bodyEnd > archive.length) fail(`packed entry is truncated: ${path}`) + inventory.push({ path, size, mode, type }) + if (type === "file") bodies.set(path, archive.subarray(bodyStart, bodyEnd)) + offset = bodyStart + Math.ceil(size / 512) * 512 + } + if (!terminated) fail("tar archive is unterminated") + inventory.sort((left, right) => left.path.localeCompare(right.path)) + + const manifestBytes = bodies.get("package/package.json") + if (!manifestBytes || manifestBytes.length === 0) fail("packed package.json is missing or empty") + const manifest = parseJson(manifestBytes, "packed package.json") + if (!object(manifest) || manifest.name !== expectedIdentity.name || manifest.version !== expectedIdentity.version) { + fail("packed package identity does not match the reviewed package") + } + if (unresolvedNormalization(manifest)) fail("pnpm package normalization left catalog: or workspace: references") + const nonemptyDist = [...bodies.entries()].some(([path, body]) => path.startsWith("package/dist/") && body.length > 0) + if (!nonemptyDist) fail("packed package has missing or empty dist output") + + const runtimeTargets = new Set() + for (const field of ["main", "module", "types"]) { + if (manifest[field] !== undefined) { + if (typeof manifest[field] !== "string") fail(`packed ${field} entrypoint is invalid`) + runtimeTargets.add(manifest[field]) + } + } + if (manifest.bin !== undefined) { + if (typeof manifest.bin === "string") runtimeTargets.add(manifest.bin) + else if (object(manifest.bin) && Object.values(manifest.bin).every((value) => typeof value === "string")) { + for (const value of Object.values(manifest.bin)) runtimeTargets.add(value) + } else fail("packed bin entrypoint is invalid") + } + if (manifest.exports !== undefined) collectRuntimeTargets(manifest.exports, runtimeTargets) + if (runtimeTargets.size === 0) fail("packed package declares no runtime entrypoints") + for (const target of runtimeTargets) { + if (typeof target !== "string" || !target.startsWith("./") || target.includes("*") || !safeRoot(target.slice(2))) { + fail(`packed runtime entrypoint is unsafe: ${JSON.stringify(target)}`) + } + const body = bodies.get(`package/${target.slice(2)}`) + if (!body) fail(`missing runtime entrypoint: ${target}`) + if (body.length === 0) fail(`empty runtime entrypoint: ${target}`) + } + return { inventory, manifest } +} + +function run(file, args, { cwd, env }) { + return new Promise((resolvePromise, reject) => { + const child = spawn(file, args, { cwd, env, shell: false, stdio: ["ignore", "pipe", "pipe"] }) + let stdout = Buffer.alloc(0) + let stderr = Buffer.alloc(0) + let excessive = false + const append = (current, chunk) => { + if (current.length + chunk.length > MAX_COMMAND_OUTPUT) { + excessive = true + child.kill("SIGKILL") + return current + } + return Buffer.concat([current, chunk]) + } + child.stdout.on("data", (chunk) => (stdout = append(stdout, chunk))) + child.stderr.on("data", (chunk) => (stderr = append(stderr, chunk))) + const timer = setTimeout(() => child.kill("SIGKILL"), COMMAND_TIMEOUT_MS) + child.on("error", (error) => { + clearTimeout(timer) + reject(new Error(`pnpm pack could not start: ${error.message}`)) + }) + child.on("close", (code, signal) => { + clearTimeout(timer) + if (excessive) reject(new Error("pnpm pack output exceeded its bound")) + else if (signal) reject(new Error("pnpm pack timed out or was terminated")) + else if (code !== 0) reject(new Error(`pnpm pack failed with exit code ${code}`)) + else resolvePromise({ stdout: stdout.toString("utf8"), stderr: stderr.toString("utf8") }) + }) + }) +} + +async function sourceCatalog(sourceRoot, selection) { + const nx = await readJsonFile(join(sourceRoot, "nx.json"), "source nx.json") + const roots = nx?.release?.projects + if (!Array.isArray(roots) || roots.length === 0 || roots.some((root) => !safeRoot(root))) { + fail("source nx.json release projects are invalid") + } + if (new Set(roots).size !== roots.length) fail("source nx.json release projects contain duplicates") + const catalog = new Map() + const manifestPaths = new Set() + const selected = new Set(selection) + for (const root of roots) { + const project = await readJsonFile(join(sourceRoot, root, "project.json"), `source project ${root}`) + const manifestPath = `${root}/package.json` + const manifestFile = join(sourceRoot, manifestPath) + await regularFile(manifestFile, `source manifest ${manifestPath}`, MAX_HANDOFF_BYTES) + const manifestBytes = await readFile(manifestFile) + const manifest = parseJson(manifestBytes, `source manifest ${manifestPath}`) + if (!object(project) || !safeName(project.name) || !object(manifest) || !safeName(manifest.name)) { + fail(`source package identity is invalid for ${root}`) + } + if (project.name !== manifest.name || !semver(manifest.version)) { + fail(`source project and manifest identity mismatch for ${root}`) + } + if (selected.has(project.name) && !stableSemver(manifest.version)) { + fail(`selected source package version must be stable SemVer: ${project.name}`) + } + if (catalog.has(project.name) || manifestPaths.has(manifestPath)) + fail(`source package identity is duplicated: ${project.name}`) + manifestPaths.add(manifestPath) + catalog.set(project.name, { + project: project.name, + root, + name: manifest.name, + version: manifest.version, + manifestBytes, + }) + } + for (const project of selection) + if (!catalog.has(project)) fail(`selected project is not in source release projects: ${project}`) + return catalog +} +function applicableAbandonments(ledger, catalog, selection, artifactSha) { + const selected = new Set(selection) + const result = [] + for (const disposition of ledger) { + if (disposition.artifactSha !== artifactSha || !selected.has(disposition.project)) continue + const record = catalog?.get(disposition.project) + if (record && (record.name !== disposition.name || record.version !== disposition.version)) { + fail(`abandonment identity does not match source package: ${disposition.project}`) + } + result.push(structuredClone(disposition)) + } + return result.sort((left, right) => left.project.localeCompare(right.project)) +} +function makePackageRecord(record, tarballBasename, bytes, inspected) { + const sha512 = digest("sha512", bytes) + return { + project: record.project, + root: record.root, + name: record.name, + version: record.version, + sourceManifestSha256: digest("sha256", record.manifestBytes), + tarball: { + basename: tarballBasename, + size: bytes.length, + sha1: digest("sha1", bytes), + sha256: digest("sha256", bytes), + sha512, + integrity: `sha512-${Buffer.from(sha512, "hex").toString("base64")}`, + }, + inventory: inspected.inventory, + } +} + +export async function createStableHandoff({ + sourceRoot, + outputDirectory, + abandonmentPath, + selection: requestedSelection, + metadata, + pnpmExecutable = "pnpm", + environment = process.env, +}) { + validateMetadata(metadata) + const selection = sortedUniqueNames(requestedSelection, "stable selection") + const absoluteSource = resolve(sourceRoot) + const absoluteOutput = resolve(outputDirectory) + if (absoluteSource === absoluteOutput || absoluteOutput.startsWith(`${absoluteSource}/`)) { + fail("handoff output must be outside the source checkout") + } + const ledger = await loadStableAbandonments(abandonmentPath) + const catalog = await sourceCatalog(absoluteSource, selection) + const abandonments = applicableAbandonments(ledger, catalog, selection, metadata.artifactSha) + const abandonedProjects = new Set(abandonments.map((item) => item.project)) + try { + await mkdir(absoluteOutput) + } catch (error) { + if (error?.code !== "EEXIST") throw error + const existing = await readdir(absoluteOutput) + if (existing.length > 0) fail("handoff output directory must be empty") + } + + const packages = [] + for (const project of selection) { + if (abandonedProjects.has(project)) continue + const record = catalog.get(project) + const before = new Set(await readdir(absoluteOutput)) + await run(pnpmExecutable, ["pack", "--json", "--pack-destination", absoluteOutput], { + cwd: join(absoluteSource, record.root), + env: environment, + }) + const after = await readdir(absoluteOutput) + const added = after.filter((name) => !before.has(name)) + if (added.length !== 1 || !safeBasename(added[0])) + fail(`pnpm pack did not create exactly one safe tarball for ${project}`) + const tarballPath = join(absoluteOutput, added[0]) + await regularFile(tarballPath, `packed tarball for ${project}`, MAX_TARBALL_BYTES) + const bytes = await readFile(tarballPath) + const inspected = inspectStableTarball(bytes, record) + packages.push(makePackageRecord(record, added[0], bytes, inspected)) + } + packages.sort((left, right) => left.project.localeCompare(right.project)) + const handoff = { + schemaVersion: HANDOFF_SCHEMA_VERSION, + repository: metadata.repository, + workflow: { path: metadata.workflowPath, ref: metadata.workflowRef, sha: metadata.workflowSha }, + run: { id: metadata.runId, attempt: metadata.runAttempt }, + expectedSha: metadata.expectedSha, + artifactSha: metadata.artifactSha, + selection, + abandonments, + packages, + } + await writeFile(join(absoluteOutput, "handoff.json"), `${JSON.stringify(handoff, null, 2)}\n`, { + flag: "wx", + mode: 0o600, + }) + return handoff +} + +function validateDispositionArray(value, expected) { + if (!Array.isArray(value)) fail("handoff abandonments are invalid") + for (const item of value) + exactKeys(item, ["artifactSha", "project", "name", "version", "reason"], "handoff abandonment") + if (!isDeepStrictEqual(value, expected)) fail("handoff abandonments do not match the reviewed ledger") +} +function validateInventory(value) { + if (!Array.isArray(value) || value.length === 0) fail("handoff packed inventory is invalid") + let previous = "" + const seen = new Set() + for (const item of value) { + exactKeys(item, ["path", "size", "mode", "type"], "handoff inventory entry") + if ( + !safePackedPath(item.path) || + !Number.isSafeInteger(item.size) || + item.size < 0 || + !Number.isSafeInteger(item.mode) || + item.mode < 0 || + !["file", "directory"].includes(item.type) + ) { + fail("handoff packed inventory entry is invalid") + } + if (seen.has(item.path)) fail("handoff packed inventory contains duplicates") + if (previous && previous.localeCompare(item.path) >= 0) fail("handoff packed inventory is not exactly sorted") + seen.add(item.path) + previous = item.path + } +} +function validatePackageSchema(item) { + exactKeys( + item, + ["project", "root", "name", "version", "sourceManifestSha256", "tarball", "inventory"], + "handoff package", + ) + if (!safeName(item.project) || !safeName(item.name) || item.project !== item.name || !safeRoot(item.root)) { + fail("handoff package identity is invalid") + } + if (!stableSemver(item.version) || !/^[0-9a-f]{64}$/.test(item.sourceManifestSha256)) { + fail("handoff package version or source-manifest digest is invalid") + } + exactKeys(item.tarball, ["basename", "size", "sha1", "sha256", "sha512", "integrity"], "handoff tarball") + if ( + !safeBasename(item.tarball.basename) || + !Number.isSafeInteger(item.tarball.size) || + item.tarball.size <= 0 || + !/^[0-9a-f]{40}$/.test(item.tarball.sha1) || + !/^[0-9a-f]{64}$/.test(item.tarball.sha256) || + !/^[0-9a-f]{128}$/.test(item.tarball.sha512) || + item.tarball.integrity !== `sha512-${Buffer.from(item.tarball.sha512, "hex").toString("base64")}` + ) { + fail("handoff tarball identity or digest is invalid") + } + validateInventory(item.inventory) +} + +export async function verifyStableHandoff({ directory, abandonmentPath, expected }) { + if (!object(expected)) fail("expected handoff metadata is invalid") + const { selection: expectedSelectionInput, ...expectedMetadata } = expected + validateMetadata(expectedMetadata) + const expectedSelection = sortedUniqueNames(expectedSelectionInput, "expected stable selection") + const ledger = await loadStableAbandonments(abandonmentPath) + const absoluteDirectory = resolve(directory) + const handoffPath = join(absoluteDirectory, "handoff.json") + const handoff = await readJsonFile(handoffPath, "stable handoff", MAX_HANDOFF_BYTES) + exactKeys( + handoff, + [ + "schemaVersion", + "repository", + "workflow", + "run", + "expectedSha", + "artifactSha", + "selection", + "abandonments", + "packages", + ], + "stable handoff", + ) + if (handoff.schemaVersion !== HANDOFF_SCHEMA_VERSION) fail("stable handoff schema version is unsupported") + exactKeys(handoff.workflow, ["path", "ref", "sha"], "handoff workflow") + exactKeys(handoff.run, ["id", "attempt"], "handoff run") + for (const [label, actual, wanted] of [ + ["repository", handoff.repository, expected.repository], + ["workflow path", handoff.workflow.path, expected.workflowPath], + ["workflow ref", handoff.workflow.ref, expected.workflowRef], + ["workflow SHA", handoff.workflow.sha, expected.workflowSha], + ["run ID", handoff.run.id, expected.runId], + ["run attempt", handoff.run.attempt, expected.runAttempt], + ["expected SHA", handoff.expectedSha, expected.expectedSha], + ["artifact SHA", handoff.artifactSha, expected.artifactSha], + ]) { + if (actual !== wanted) fail(`handoff ${label} does not match the current run`) + } + if (!isDeepStrictEqual(handoff.selection, expectedSelection)) + fail("handoff selection does not exactly match the request") + const expectedAbandonments = applicableAbandonments(ledger, null, expectedSelection, expected.artifactSha) + validateDispositionArray(handoff.abandonments, expectedAbandonments) + if (!Array.isArray(handoff.packages)) fail("handoff packages are invalid") + const expectedProjects = expectedSelection.filter( + (project) => !expectedAbandonments.some((disposition) => disposition.project === project), + ) + const packageProjects = handoff.packages.map((item) => item?.project) + if (!isDeepStrictEqual(packageProjects, expectedProjects)) fail("handoff package selection is wrong or unsorted") + const basenames = new Set() + for (const item of handoff.packages) { + validatePackageSchema(item) + if (basenames.has(item.tarball.basename)) fail("handoff tarball basename is duplicated") + basenames.add(item.tarball.basename) + const tarballPath = join(absoluteDirectory, item.tarball.basename) + const status = await regularFile(tarballPath, `handoff tarball ${item.tarball.basename}`, MAX_TARBALL_BYTES) + if (status.size !== item.tarball.size) fail(`handoff tarball size mismatch: ${item.tarball.basename}`) + const bytes = await readFile(tarballPath) + for (const [algorithm, wanted] of [ + ["sha1", item.tarball.sha1], + ["sha256", item.tarball.sha256], + ["sha512", item.tarball.sha512], + ]) { + if (digest(algorithm, bytes) !== wanted) fail(`handoff tarball digest mismatch: ${item.tarball.basename}`) + } + const inspected = inspectStableTarball(bytes, item) + if (!isDeepStrictEqual(inspected.inventory, item.inventory)) { + fail(`handoff tarball inventory mismatch: ${item.tarball.basename}`) + } + } + const entries = await readdir(absoluteDirectory, { withFileTypes: true }) + const expectedFiles = new Set(["handoff.json", ...basenames]) + for (const entry of entries) { + if (!entry.isFile()) fail(`handoff contains a symlink or non-file extra: ${entry.name}`) + if (!expectedFiles.has(entry.name)) fail(`handoff contains an extra file: ${entry.name}`) + } + if (entries.length !== expectedFiles.size) fail("handoff file set is incomplete") + return handoff +} + +function cliOptions(arguments_) { + const options = {} + for (let index = 0; index < arguments_.length; index += 2) { + const name = arguments_[index] + const value = arguments_[index + 1] + if (!name?.startsWith("--") || value === undefined || value.startsWith("--")) + fail("malformed packaging helper arguments") + if (Object.hasOwn(options, name)) fail(`duplicate packaging helper argument: ${name}`) + options[name] = value + } + return options +} +function envMetadata() { + return { + repository: process.env.GITHUB_REPOSITORY ?? "", + workflowPath: process.env.WORKFLOW_PATH ?? "", + workflowRef: process.env.WORKFLOW_REF ?? "", + workflowSha: process.env.WORKFLOW_SHA ?? "", + runId: process.env.GITHUB_RUN_ID ?? "", + runAttempt: process.env.GITHUB_RUN_ATTEMPT ?? "", + expectedSha: process.env.EXPECTED_SHA ?? "", + artifactSha: process.env.ARTIFACT_SHA ?? "", + } +} +function envSelection() { + return (process.env.PROJECTS ?? "") + .split(",") + .map((value) => value.trim()) + .filter(Boolean) +} +async function main() { + const [command, ...arguments_] = process.argv.slice(2) + const options = cliOptions(arguments_) + if (command === "create") { + const allowed = ["--source-root", "--output", "--abandonments"] + if (Object.keys(options).some((name) => !allowed.includes(name)) || allowed.some((name) => !options[name])) { + fail("create requires --source-root, --output, and --abandonments") + } + const handoff = await createStableHandoff({ + sourceRoot: options["--source-root"], + outputDirectory: options["--output"], + abandonmentPath: options["--abandonments"], + selection: envSelection(), + metadata: envMetadata(), + }) + process.stdout.write( + `${JSON.stringify({ ok: true, packages: handoff.packages.length, abandonments: handoff.abandonments.length })}\n`, + ) + return + } + if (command === "verify") { + const allowed = ["--directory", "--abandonments"] + if (Object.keys(options).some((name) => !allowed.includes(name)) || allowed.some((name) => !options[name])) { + fail("verify requires --directory and --abandonments") + } + const handoff = await verifyStableHandoff({ + directory: options["--directory"], + abandonmentPath: options["--abandonments"], + expected: { ...envMetadata(), selection: envSelection() }, + }) + process.stdout.write( + `${JSON.stringify({ ok: true, packages: handoff.packages.length, abandonments: handoff.abandonments.length })}\n`, + ) + return + } + fail("packaging helper command must be create or verify") +} +function isMainModule() { + const entry = process.argv[1] + if (!entry) return false + try { + return pathToFileURL(realpathSync(entry)).href === pathToFileURL(realpathSync(fileURLToPath(import.meta.url))).href + } catch { + return false + } +} +if (isMainModule()) + main().catch((error) => { + process.stderr.write(`::error::${error.message}\n`) + process.exitCode = 1 + }) diff --git a/scripts/release-package-stable.test.mjs b/scripts/release-package-stable.test.mjs new file mode 100644 index 00000000..31e5ea95 --- /dev/null +++ b/scripts/release-package-stable.test.mjs @@ -0,0 +1,396 @@ +import assert from "node:assert/strict" +import { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs" +import { cp, readFile, rm, writeFile } from "node:fs/promises" +import { gzipSync } from "node:zlib" +import { tmpdir } from "node:os" +import { basename, join } from "node:path" +import test from "node:test" + +import { createStableHandoff, verifyStableHandoff } from "./release-package-stable.mjs" + +const artifactSha = "f31390ce66ea157ea8b75f5259c203123e269759" +const expectedSha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +const metadata = { + repository: "devx-op/effectify", + workflowPath: ".github/workflows/release-stable.yml", + workflowRef: "refs/heads/master", + workflowSha: expectedSha, + runId: "33399900011", + runAttempt: "2", + expectedSha, + artifactSha, +} +const selection = ["@effectify/hatchet", "@effectify/prisma", "@effectify/react-query"] +const ledger = new URL("release-stable-abandonments.json", import.meta.url).pathname + +function tarHeader(path, size) { + const header = Buffer.alloc(512) + const put = (value, offset, length) => header.write(value, offset, Math.min(length, Buffer.byteLength(value)), "utf8") + put(path, 0, 100) + put("0000644\0", 100, 8) + put("0000000\0", 108, 8) + put("0000000\0", 116, 8) + put(`${size.toString(8).padStart(11, "0")}\0`, 124, 12) + put("00000000000\0", 136, 12) + header.fill(0x20, 148, 156) + header[156] = "0".charCodeAt(0) + put("ustar\0", 257, 6) + put("00", 263, 2) + const checksum = [...header].reduce((total, byte) => total + byte, 0) + put(`${checksum.toString(8).padStart(6, "0")}\0 `, 148, 8) + return header +} + +function makeTarball(path, entries) { + const blocks = [] + for (const [name, value] of [...entries].sort(([left], [right]) => left.localeCompare(right))) { + const body = Buffer.isBuffer(value) ? value : Buffer.from(value) + blocks.push(tarHeader(name, body.length), body) + const padding = (512 - (body.length % 512)) % 512 + if (padding) blocks.push(Buffer.alloc(padding)) + } + blocks.push(Buffer.alloc(1024)) + writeFileSync(path, gzipSync(Buffer.concat(blocks), { mtime: 0 })) +} + +const manifests = { + "@effectify/react-query": { + name: "@effectify/react-query", + version: "1.0.1", + type: "module", + main: "./dist/src/index.js", + types: "./dist/src/index.d.ts", + exports: { + ".": { + "@effectify/source": "./src/index.ts", + types: "./dist/src/index.d.ts", + import: "./dist/src/index.js", + default: "./dist/src/index.js", + }, + }, + files: ["dist"], + dependencies: { tslib: "catalog:" }, + }, + "@effectify/prisma": { + name: "@effectify/prisma", + version: "1.1.14", + main: "./src/cli.js", + exports: { ".": "./dist/src/runtime/index.js", "./cli": "./src/cli.js" }, + files: ["dist"], + }, + "@effectify/hatchet": { + name: "@effectify/hatchet", + version: "0.2.0", + type: "module", + main: "./dist/src/index.js", + types: "./dist/src/index.d.ts", + exports: { + ".": { + "@effectify/source": "./src/index.ts", + types: "./dist/src/index.d.ts", + import: "./dist/src/index.js", + default: "./dist/src/index.js", + }, + "./testing": { + "@effectify/source": "./src/testing/index.ts", + types: "./dist/src/testing/index.d.ts", + import: "./dist/src/testing/index.js", + }, + }, + files: ["dist"], + }, +} +const roots = { + "@effectify/react-query": "packages/react/query", + "@effectify/prisma": "packages/prisma", + "@effectify/hatchet": "packages/hatchet", +} + +function packedManifest(name) { + const manifest = structuredClone(manifests[name]) + if (manifest.dependencies?.tslib === "catalog:") manifest.dependencies.tslib = "^2.8.1" + return manifest +} + +function entriesFor(name, override = {}) { + const entries = new Map([ + ["package/package.json", `${JSON.stringify(packedManifest(name), null, 2)}\n`], + ["package/dist/src/index.js", "export const value = 1\n"], + ["package/dist/src/index.d.ts", "export declare const value: number\n"], + ]) + if (name === "@effectify/hatchet") { + entries.set("package/dist/src/testing/index.js", "export const testValue = 1\n") + entries.set("package/dist/src/testing/index.d.ts", "export declare const testValue: number\n") + } + for (const [path, value] of Object.entries(override)) { + if (value === undefined) entries.delete(path) + else entries.set(path, value) + } + return entries +} + +async function fixture(t, options = {}) { + const cwd = mkdtempSync(join(tmpdir(), "stable-package-")) + t.after(async () => { + await rm(cwd, { recursive: true, force: true }) + assert.equal(existsSync(cwd), false) + }) + const sourceRoot = join(cwd, "source") + const outputDirectory = join(cwd, "handoff") + const tarballSource = join(cwd, "tarballs") + mkdirSync(sourceRoot, { recursive: true }) + mkdirSync(tarballSource) + writeFileSync(join(sourceRoot, "nx.json"), JSON.stringify({ release: { projects: Object.values(roots) } })) + for (const name of selection) { + const root = join(sourceRoot, roots[name]) + mkdirSync(root, { recursive: true }) + writeFileSync(join(root, "project.json"), JSON.stringify({ name })) + writeFileSync(join(root, "package.json"), `${JSON.stringify(manifests[name], null, 2)}\n`) + if (name !== "@effectify/prisma") { + const slug = name.slice(1).replace("/", "-") + makeTarball( + join(tarballSource, `${slug}-${manifests[name].version}.tgz`), + entriesFor(name, options.entries?.[name]), + ) + } + } + const log = join(cwd, "pnpm-log.jsonl") + const fakePnpm = join(cwd, "pnpm") + writeFileSync( + fakePnpm, + String.raw`#!/usr/bin/env node +const fs=require("node:fs"),path=require("node:path") +const args=process.argv.slice(2),destination=args[args.indexOf("--pack-destination")+1] +const manifest=JSON.parse(fs.readFileSync(path.join(process.cwd(),"package.json"),"utf8")) +const slug=manifest.name.slice(1).replace("/","-"),file=slug+"-"+manifest.version+".tgz" +fs.appendFileSync(process.env.PACK_LOG,JSON.stringify({cwd:process.cwd(),args})+"\n") +fs.copyFileSync(path.join(process.env.PACK_SOURCES,file),path.join(destination,file)) +process.stdout.write(JSON.stringify([{name:manifest.name,version:manifest.version,filename:file}])+"\n") +`, + ) + chmodSync(fakePnpm, 0o755) + + const create = () => + createStableHandoff({ + sourceRoot, + outputDirectory, + abandonmentPath: ledger, + selection, + metadata, + pnpmExecutable: fakePnpm, + environment: { ...process.env, PACK_LOG: log, PACK_SOURCES: tarballSource }, + }) + const verify = (expected = {}) => + verifyStableHandoff({ + directory: outputDirectory, + abandonmentPath: ledger, + expected: { ...metadata, selection, ...expected }, + }) + return { cwd, sourceRoot, outputDirectory, log, create, verify } +} + +test("the pinned abandonment ledger has one fail-closed Prisma 1.1.14 disposition", () => { + const value = JSON.parse(readFileSync(ledger, "utf8")) + assert.deepEqual(Object.keys(value).sort(), ["abandonments", "schemaVersion"]) + assert.equal(value.schemaVersion, 1) + assert.deepEqual( + value.abandonments.map(({ artifactSha, project, name, version }) => ({ artifactSha, project, name, version })), + [{ artifactSha, project: "@effectify/prisma", name: "@effectify/prisma", version: "1.1.14" }], + ) + assert.match(value.abandonments[0].reason, /broken CLI\/export paths/) + assert.doesNotMatch(JSON.stringify(value), /wildcard|override|process\.env|\$\{/i) +}) + +test("create packs only non-abandoned projects and verifies an exact schema-versioned handoff", async (t) => { + const world = await fixture(t) + const created = await world.create() + assert.equal(created.schemaVersion, 1) + assert.deepEqual(created.selection, [...selection].sort()) + assert.deepEqual( + created.packages.map((item) => item.project), + ["@effectify/hatchet", "@effectify/react-query"], + ) + assert.equal(created.abandonments.length, 1) + assert.equal(created.abandonments[0].project, "@effectify/prisma") + assert.deepEqual((await world.verify()).packages, created.packages) + + const files = (await import("node:fs/promises")).readdir(world.outputDirectory) + assert.deepEqual((await files).sort(), [ + "effectify-hatchet-0.2.0.tgz", + "effectify-react-query-1.0.1.tgz", + "handoff.json", + ]) + const calls = readFileSync(world.log, "utf8").trim().split("\n").map(JSON.parse) + assert.equal(calls.length, 2) + assert.equal( + calls.some((call) => call.cwd.endsWith("/packages/prisma")), + false, + ) + for (const call of calls) { + assert.deepEqual(call.args.slice(0, 2), ["pack", "--json"]) + assert.equal(call.args.includes("--pack-destination"), true) + } +}) + +test("create accepts an unselected prerelease release project without packing it", async (t) => { + const world = await fixture(t) + const prereleaseName = "@effectify/canary" + const prereleaseRoot = "packages/canary" + const nxPath = join(world.sourceRoot, "nx.json") + const nx = JSON.parse(readFileSync(nxPath, "utf8")) + nx.release.projects.push(prereleaseRoot) + writeFileSync(nxPath, JSON.stringify(nx)) + mkdirSync(join(world.sourceRoot, prereleaseRoot), { recursive: true }) + writeFileSync(join(world.sourceRoot, prereleaseRoot, "project.json"), JSON.stringify({ name: prereleaseName })) + writeFileSync( + join(world.sourceRoot, prereleaseRoot, "package.json"), + JSON.stringify({ name: prereleaseName, version: "2.0.0-beta.3" }), + ) + + const created = await world.create() + + assert.deepEqual(created.selection, [...selection].sort()) + assert.equal( + created.packages.some(({ project }) => project === prereleaseName), + false, + ) + const calls = readFileSync(world.log, "utf8").trim().split("\n").map(JSON.parse) + assert.equal( + calls.some((call) => call.cwd.endsWith(`/${prereleaseRoot}`)), + false, + ) +}) + +test("create rejects selected prereleases and keeps abandonment identity matching exact", async (t) => { + await t.test("selected prerelease", async (t) => { + const world = await fixture(t) + const manifest = { ...manifests["@effectify/hatchet"], version: "0.2.1-beta.1" } + writeFileSync(join(world.sourceRoot, roots["@effectify/hatchet"], "package.json"), JSON.stringify(manifest)) + + await assert.rejects(world.create, /selected source package version must be stable SemVer/i) + }) + + await t.test("abandonment version mismatch", async (t) => { + const world = await fixture(t) + const manifest = { ...manifests["@effectify/prisma"], version: "1.1.15" } + writeFileSync(join(world.sourceRoot, roots["@effectify/prisma"], "package.json"), JSON.stringify(manifest)) + + await assert.rejects(world.create, /abandonment identity does not match source package/i) + }) +}) + +test("handoff binds current-run metadata, selection, source manifests, tarball digests, integrity, and inventory", async (t) => { + const world = await fixture(t) + await world.create() + const handoff = JSON.parse(await readFile(join(world.outputDirectory, "handoff.json"), "utf8")) + assert.deepEqual( + { + repository: handoff.repository, + workflowPath: handoff.workflow.path, + workflowRef: handoff.workflow.ref, + workflowSha: handoff.workflow.sha, + runId: handoff.run.id, + runAttempt: handoff.run.attempt, + expectedSha: handoff.expectedSha, + artifactSha: handoff.artifactSha, + }, + metadata, + ) + for (const item of handoff.packages) { + assert.match(item.sourceManifestSha256, /^[0-9a-f]{64}$/) + assert.match(item.tarball.sha1, /^[0-9a-f]{40}$/) + assert.match(item.tarball.sha256, /^[0-9a-f]{64}$/) + assert.match(item.tarball.sha512, /^[0-9a-f]{128}$/) + assert.equal(item.tarball.integrity, `sha512-${Buffer.from(item.tarball.sha512, "hex").toString("base64")}`) + assert.ok(item.tarball.size > 0) + assert.ok(item.inventory.some(({ path, size }) => path === "package/package.json" && size > 0)) + assert.ok(item.inventory.some(({ path, size }) => path.startsWith("package/dist/") && size > 0)) + } +}) + +test("verification rejects wrong current-run metadata, selection, extras, symlinks, and digest changes", async (t) => { + for (const [name, mutate, pattern] of [ + ["run ID", async (world) => world.verify({ runId: "33399900012" }), /run ID/i], + ["selection", async (world) => world.verify({ selection: selection.slice(1) }), /selection/i], + [ + "extra file", + async (world) => { + await writeFile(join(world.outputDirectory, "extra.txt"), "extra") + return world.verify() + }, + /extra/i, + ], + [ + "symlink", + async (world) => { + symlinkSync("handoff.json", join(world.outputDirectory, "alias.tgz")) + return world.verify() + }, + /symlink|regular file/i, + ], + [ + "digest", + async (world) => { + const tarball = join(world.outputDirectory, "effectify-hatchet-0.2.0.tgz") + const value = await readFile(tarball) + value[value.length - 1] ^= 1 + await writeFile(tarball, value) + return world.verify() + }, + /digest|tarball/i, + ], + ]) { + await t.test(name, async (t) => { + const world = await fixture(t) + await world.create() + await assert.rejects(() => mutate(world), pattern) + }) + } +}) + +test("create rejects missing or empty dist and runtime entrypoints while ignoring @effectify/source", async (t) => { + for (const [name, entries, pattern] of [ + [ + "missing dist", + { + "package/dist/src/index.js": undefined, + "package/dist/src/index.d.ts": undefined, + "package/dist/src/testing/index.js": undefined, + "package/dist/src/testing/index.d.ts": undefined, + }, + /dist/i, + ], + ["empty runtime", { "package/dist/src/index.js": "" }, /empty runtime entrypoint/i], + ["missing runtime", { "package/dist/src/index.d.ts": undefined }, /missing runtime entrypoint/i], + ]) { + await t.test(name, async (t) => { + const world = await fixture(t, { entries: { "@effectify/hatchet": entries } }) + await assert.rejects(world.create, pattern) + }) + } +}) + +test("verification rejects unsafe packed inventory, malformed identity, and unresolved package normalization", async (t) => { + for (const [name, project, override, pattern] of [ + ["unsafe path", "@effectify/hatchet", { "package/../escape": "bad" }, /unsafe packed path/i], + [ + "wrong name", + "@effectify/hatchet", + { + "package/package.json": `${JSON.stringify({ ...packedManifest("@effectify/hatchet"), name: "@effectify/imposter" })}\n`, + }, + /package identity/i, + ], + [ + "unresolved catalog", + "@effectify/react-query", + { "package/package.json": `${JSON.stringify(manifests["@effectify/react-query"])}\n` }, + /package normalization/i, + ], + ]) { + await t.test(name, async (t) => { + const world = await fixture(t, { entries: { [project]: override } }) + await assert.rejects(world.create, pattern) + }) + } +}) diff --git a/scripts/release-stable-abandonments.json b/scripts/release-stable-abandonments.json new file mode 100644 index 00000000..87aef046 --- /dev/null +++ b/scripts/release-stable-abandonments.json @@ -0,0 +1,12 @@ +{ + "schemaVersion": 1, + "abandonments": [ + { + "artifactSha": "f31390ce66ea157ea8b75f5259c203123e269759", + "project": "@effectify/prisma", + "name": "@effectify/prisma", + "version": "1.1.14", + "reason": "Reviewed exception: 1.1.14 has broken CLI/export paths; publish a reviewed 1.1.15 instead." + } + ] +} From 25e7b9eceb598f70ce4a0483ddd13c1a3e6f73ad Mon Sep 17 00:00:00 2001 From: kattsushi Date: Mon, 31 Aug 2026 11:04:18 -0600 Subject: [PATCH 2/4] fix(release): recover stable npm publication --- scripts/release-finalize-stable.mjs | 835 ++++++-- scripts/release-finalize-stable.test.mjs | 2262 +++++++++++----------- 2 files changed, 1786 insertions(+), 1311 deletions(-) diff --git a/scripts/release-finalize-stable.mjs b/scripts/release-finalize-stable.mjs index 053937ba..a313407f 100644 --- a/scripts/release-finalize-stable.mjs +++ b/scripts/release-finalize-stable.mjs @@ -1,15 +1,37 @@ #!/usr/bin/env node import { spawn } from "node:child_process" -import { realpathSync } from "node:fs" -import { readFile } from "node:fs/promises" +import { createHash } from "node:crypto" +import { constants, realpathSync } from "node:fs" +import { lstat, open, readFile } from "node:fs/promises" +import { isAbsolute, join } from "node:path" import { fileURLToPath, pathToFileURL } from "node:url" import { isDeepStrictEqual } from "node:util" +import { verifyStableHandoff } from "./release-package-stable.mjs" + +const WORKFLOW_PATH = ".github/workflows/release-stable.yml" +const WORKFLOW_REF = "refs/heads/master" +const ABANDONMENT_PATH = fileURLToPath(new URL("./release-stable-abandonments.json", import.meta.url)) +const ALLOWED_HISTORICAL_PATHS = Object.freeze([ + ".github/SETUP.md", + ".github/workflows/release-stable.yml", + "scripts/release-finalize-stable.mjs", + "scripts/release-finalize-stable.test.mjs", + "scripts/release-package-stable.mjs", + "scripts/release-package-stable.test.mjs", + "scripts/release-policy-contract.test.mjs", + "scripts/release-stable-abandonments.json", +]) +const MAX_HISTORICAL_COMMITS = 8 +const MAX_NPM_READS = 6 +const MAX_NPM_CONFIG_BYTES = 64 * 1024 +const MAX_TRACKED_NPM_CONFIGS = 64 +const NPM_REGISTRY = "https://registry.npmjs.org/" +const NPM_ATTESTATION_PATH_PREFIX = "/-/npm/v1/attestations/" const expectedSha = process.env.EXPECTED_SHA ?? "" const artifactSha = process.env.ARTIFACT_SHA || expectedSha const requestedProjectsText = process.env.PROJECTS ?? "" const historicalReplay = artifactSha !== expectedSha -const maxReads = 6 const delayMs = Number(process.env.NPM_READ_DELAY_MS ?? Number(process.env.NPM_READ_DELAY ?? 10) * 1000) const commandTimeoutMs = Number(process.env.FINALIZE_COMMAND_TIMEOUT_MS ?? 60_000) const httpTimeoutMs = Number(process.env.FINALIZE_HTTP_TIMEOUT_MS ?? 30_000) @@ -21,15 +43,30 @@ const jsonOutput = cliArguments.includes("--json") function fail(message) { throw new Error(message) } +function object(value) { + return value !== null && typeof value === "object" && !Array.isArray(value) +} function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)) } +function digest(algorithm, bytes) { + return createHash(algorithm).update(bytes).digest("hex") +} +function validRuntimeBound(value, minimum, maximum) { + return Number.isSafeInteger(value) && value >= minimum && value <= maximum +} +function validateRuntimeBounds() { + if (!validRuntimeBound(delayMs, 0, 60_000)) fail("NPM read delay is invalid") + if (!validRuntimeBound(commandTimeoutMs, 1, 300_000)) fail("FINALIZE command timeout is invalid") + if (!validRuntimeBound(httpTimeoutMs, 1, 300_000)) fail("FINALIZE HTTP timeout is invalid") + if (!validRuntimeBound(outputLimit, 1024, 16 * 1024 * 1024)) fail("FINALIZE output limit is invalid") +} function run(file, args, { ok = [0], env } = {}) { return new Promise((resolve, reject) => { const child = spawn(file, args, { shell: false, stdio: ["ignore", "pipe", "pipe"], env }) - let stdout = Buffer.alloc(0), - stderr = Buffer.alloc(0), - excessive = false + let stdout = Buffer.alloc(0) + let stderr = Buffer.alloc(0) + let excessive = false const append = (current, chunk) => { if (current.length + chunk.length > outputLimit) { excessive = true @@ -54,8 +91,11 @@ function run(file, args, { ok = [0], env } = {}) { const result = { code, signal, stdout: stdout.toString("utf8"), stderr: stderr.toString("utf8") } if (excessive) reject(new Error(`${file} output exceeded bound`)) else if (signal) reject(new Error(`${file} timed out or terminated (${signal})`)) - else if (!ok.includes(code)) reject(new Error(`${file} failed (${code})`)) - else resolve(result) + else if (!ok.includes(code)) { + const error = new Error(`${file} failed (${code})`) + Object.defineProperty(error, "commandResult", { value: result }) + reject(error) + } else resolve(result) }) }) } @@ -66,17 +106,14 @@ function parseJson(text, label) { fail(`${label} returned malformed JSON`) } } -function object(value) { - return value && typeof value === "object" && !Array.isArray(value) -} function safeRoot(value) { return ( typeof value === "string" && value.length > 0 && value.length <= 512 && - !value.startsWith("/") && + !isAbsolute(value) && !value.includes("\\") && - !value.includes("\u0000") && + !/[\u0000-\u001f\u007f]/.test(value) && !value.includes("//") && value.split("/").every((part) => part && part !== "." && part !== "..") ) @@ -86,10 +123,12 @@ function safeName(value) { typeof value === "string" && value.length > 0 && value.length <= 214 && - !/[\s,]/.test(value) && - !value.includes("\u0000") + /^@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/.test(value) ) } +function fullSha(value) { + return typeof value === "string" && /^[0-9a-f]{40}$/.test(value) +} function parseSemver(value) { if (typeof value !== "string") return null const match = value.match( @@ -99,8 +138,9 @@ function parseSemver(value) { const prerelease = match[4]?.split(".") ?? [] if ( prerelease.some((identifier) => /^[0-9]+$/.test(identifier) && identifier.length > 1 && identifier.startsWith("0")) - ) + ) { return null + } return { major: BigInt(match[1]), minor: BigInt(match[2]), patch: BigInt(match[3]), prerelease } } function compareSemver(left, right) { @@ -113,12 +153,12 @@ function compareSemver(left, right) { } const length = Math.max(left.prerelease.length, right.prerelease.length) for (let index = 0; index < length; index++) { - const leftIdentifier = left.prerelease[index], - rightIdentifier = right.prerelease[index] + const leftIdentifier = left.prerelease[index] + const rightIdentifier = right.prerelease[index] if (leftIdentifier === undefined || rightIdentifier === undefined) return leftIdentifier === undefined ? -1 : 1 if (leftIdentifier === rightIdentifier) continue - const leftNumeric = /^[0-9]+$/.test(leftIdentifier), - rightNumeric = /^[0-9]+$/.test(rightIdentifier) + const leftNumeric = /^[0-9]+$/.test(leftIdentifier) + const rightNumeric = /^[0-9]+$/.test(rightIdentifier) if (leftNumeric && rightNumeric) return BigInt(leftIdentifier) < BigInt(rightIdentifier) ? -1 : 1 if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1 return leftIdentifier < rightIdentifier ? -1 : 1 @@ -131,10 +171,56 @@ function parseRequestedProjects() { .map((value) => value.trim()) .filter(Boolean) if (raw.length === 0) fail("stable selection is empty") + if (raw.some((value) => !safeName(value))) fail("stable selection contains an invalid project") const duplicates = raw.filter((value, index) => raw.indexOf(value) !== index) if (duplicates.length > 0) fail(`duplicate requested project: ${duplicates[0]}`) return raw.sort() } + +async function verifyCurrentRunHandoff(projects) { + const directory = process.env.STABLE_HANDOFF_DIRECTORY ?? "" + const artifactId = process.env.STABLE_HANDOFF_ARTIFACT_ID ?? "" + const artifactDigest = process.env.STABLE_HANDOFF_ARTIFACT_DIGEST ?? "" + const repository = process.env.GITHUB_REPOSITORY ?? "" + const workflowSha = process.env.GITHUB_WORKFLOW_SHA ?? "" + const runId = process.env.GITHUB_RUN_ID ?? "" + const runAttempt = process.env.GITHUB_RUN_ATTEMPT ?? "" + const workflowReference = process.env.GITHUB_WORKFLOW_REF ?? "" + + if (!isAbsolute(directory)) fail("stable handoff directory must be an absolute path") + if (!/^[1-9][0-9]*$/.test(artifactId)) fail("stable handoff artifact ID is invalid") + if (!/^sha256:[0-9a-f]{64}$/.test(artifactDigest)) fail("stable handoff artifact digest is invalid") + if (workflowReference !== `${repository}/${WORKFLOW_PATH}@${WORKFLOW_REF}`) { + fail("current workflow reference is invalid") + } + if (workflowSha !== expectedSha) fail("current workflow SHA does not match expected SHA") + + let handoff + try { + handoff = await verifyStableHandoff({ + directory, + abandonmentPath: ABANDONMENT_PATH, + expected: { + repository, + workflowPath: WORKFLOW_PATH, + workflowRef: WORKFLOW_REF, + workflowSha, + runId, + runAttempt, + expectedSha, + artifactSha, + selection: projects, + }, + }) + } catch (error) { + if (error?.message === "handoff selection does not exactly match the request") { + fail("requested projects do not exactly match the stable handoff selection") + } + throw error + } + return { handoff, directory, artifactId, artifactDigest } +} + async function commitParents(revision, label) { let result try { @@ -145,7 +231,7 @@ async function commitParents(revision, label) { const parts = result.stdout.trimEnd().split(" ") if ( parts.length < 2 || - parts.some((part) => !/^[0-9a-f]{40}$/.test(part)) || + parts.some((part) => !fullSha(part)) || parts[0] !== revision || new Set(parts).size !== parts.length ) { @@ -171,18 +257,43 @@ async function verifyArtifactLineage() { fail("reviewed merge trees are unreadable") } const treeIds = trees.stdout.trimEnd().split("\n") - if (treeIds.length !== 2 || treeIds.some((tree) => !/^[0-9a-f]{40}$/.test(tree)) || treeIds[0] !== treeIds[1]) { + if (treeIds.length !== 2 || treeIds.some((tree) => !fullSha(tree)) || treeIds[0] !== treeIds[1]) { fail("reviewed merge tree must exactly match its generated second parent") } } -async function verifyHistoricalAncestry() { - let result +async function verifyHistoricalRecoveryBounds() { + let ancestry try { - result = await run("git", ["merge-base", "--is-ancestor", artifactSha, expectedSha], { ok: [0, 1] }) + ancestry = await run("git", ["merge-base", "--is-ancestor", artifactSha, expectedSha], { ok: [0, 1] }) } catch { fail("historical artifact ancestry is unreadable") } - if (result.code !== 0) fail("historical artifact SHA must be an ancestor of expected SHA") + if (ancestry.code !== 0) fail("historical artifact SHA must be an ancestor of expected SHA") + + let countResult + try { + countResult = await run("git", ["rev-list", "--count", `${artifactSha}..${expectedSha}`]) + } catch { + fail("historical recovery commit-count bound is unreadable") + } + const countText = countResult.stdout.trim() + if (!/^[0-9]+$/.test(countText)) fail("historical recovery commit-count bound is invalid") + const count = Number(countText) + if (!Number.isSafeInteger(count) || count < 1 || count > MAX_HISTORICAL_COMMITS) { + fail(`historical recovery exceeds the commit-count bound of ${MAX_HISTORICAL_COMMITS}`) + } + + let pathsResult + try { + pathsResult = await run("git", ["diff", "--name-only", "--no-renames", artifactSha, expectedSha]) + } catch { + fail("historical recovery changed paths are unreadable") + } + const paths = pathsResult.stdout.split("\n").filter(Boolean) + if (new Set(paths).size !== paths.length) fail("historical recovery changed paths contain duplicates") + const allowed = new Set(ALLOWED_HISTORICAL_PATHS) + const unexpected = paths.find((path) => !allowed.has(path)) + if (unexpected) fail(`historical recovery changed path is not allowlisted: ${unexpected}`) } async function verifyArtifactChangelog() { let result @@ -193,14 +304,17 @@ async function verifyArtifactChangelog() { } if (result.stdout !== "blob\n") fail("reviewed artifact requires root CHANGELOG.md to exist as a blob") } -async function artifactJson(path, revision = artifactSha) { +async function artifactDocument(path, revision = artifactSha) { let result try { result = await run("git", ["show", `${revision}:${path}`]) } catch { fail(`artifact repository read failed for ${revision}:${path}`) } - return parseJson(result.stdout, `artifact ${revision}:${path}`) + return { text: result.stdout, value: parseJson(result.stdout, `artifact ${revision}:${path}`) } +} +async function artifactJson(path, revision = artifactSha) { + return (await artifactDocument(path, revision)).value } async function deriveReviewedRecords(projects) { const nx = await artifactJson("nx.json") @@ -211,16 +325,17 @@ async function deriveReviewedRecords(projects) { if (new Set(roots).size !== roots.length) fail("artifact nx.json release projects contain duplicates") const catalog = [] - const projectNames = new Set(), - packageNames = new Set(), - manifestPaths = new Set() - for (const root of roots) { + const projectNames = new Set() + const packageNames = new Set() + const manifestPaths = new Set() + for (const [releaseOrder, root] of roots.entries()) { const projectJson = await artifactJson(`${root}/project.json`) const manifestPath = `${root}/package.json` - const manifest = await artifactJson(manifestPath) - const project = projectJson?.name, - name = manifest?.name, - version = manifest?.version + const manifestDocument = await artifactDocument(manifestPath) + const manifest = manifestDocument.value + const project = projectJson?.name + const name = manifest?.name + const version = manifest?.version if (!object(projectJson) || !safeName(project)) fail(`artifact project identity is invalid for ${root}`) if (!object(manifest) || !safeName(name) || typeof version !== "string") { fail(`artifact manifest identity is invalid for ${manifestPath}`) @@ -232,10 +347,20 @@ async function deriveReviewedRecords(projects) { projectNames.add(project) packageNames.add(name) manifestPaths.add(manifestPath) - catalog.push({ project, root, manifestPath, name, version, reviewedManifest: manifest }) + catalog.push({ + project, + root, + manifestPath, + name, + version, + releaseOrder, + reviewedManifest: manifest, + sourceManifestSha256: digest("sha256", manifestDocument.text), + }) } - for (const project of projects) + for (const project of projects) { if (!projectNames.has(project)) fail(`requested project is not in artifact release projects: ${project}`) + } const changedResult = await run("git", ["diff", "--name-only", "--no-renames", `${artifactSha}^1`, artifactSha]) const changedPaths = changedResult.stdout.split("\n").filter(Boolean) @@ -275,55 +400,160 @@ async function deriveReviewedRecords(projects) { } return records } -async function npmState(name, version, betaVersion) { +function bindHandoffToRecords(handoff, records) { + const byProject = new Map(records.map((record) => [record.project, record])) + const abandonedProjects = new Set() + for (const disposition of handoff.abandonments) { + const record = byProject.get(disposition.project) + if (!record || record.name !== disposition.name || record.version !== disposition.version) { + fail(`reviewed abandonment identity does not match artifact record: ${disposition.project}`) + } + abandonedProjects.add(disposition.project) + } + + const packageByProject = new Map() + for (const item of handoff.packages) { + const record = byProject.get(item.project) + if ( + !record || + abandonedProjects.has(item.project) || + item.root !== record.root || + item.name !== record.name || + item.version !== record.version || + item.sourceManifestSha256 !== record.sourceManifestSha256 + ) { + fail(`stable handoff package does not exactly match reviewed artifact record: ${item.project}`) + } + packageByProject.set(item.project, item) + } + for (const record of records) { + if (!abandonedProjects.has(record.project) && !packageByProject.has(record.project)) { + fail(`stable handoff package is missing for ${record.project}`) + } + } + return { abandonedProjects, packageByProject } +} + +function exactAttestationUrl(value, record) { + if (typeof value !== "string") return false + let url + try { + url = new URL(value) + } catch { + return false + } + if ( + url.protocol !== "https:" || + url.host !== "registry.npmjs.org" || + url.username !== "" || + url.password !== "" || + url.search !== "" || + url.hash !== "" || + !url.pathname.startsWith(NPM_ATTESTATION_PATH_PREFIX) + ) { + return false + } + let specification + try { + specification = decodeURIComponent(url.pathname.slice(NPM_ATTESTATION_PATH_PREFIX.length)) + } catch { + return false + } + return specification === `${record.name}@${record.version}` +} +function distDivergence(dist, record, handoffPackage) { + if (!object(dist)) return "dist" + if (dist.integrity !== handoffPackage.tarball.integrity) return "integrity" + if (dist.shasum !== handoffPackage.tarball.sha1) return "shasum" + if (!object(dist.attestations)) return "attestation" + if (!exactAttestationUrl(dist.attestations.url, record)) return "attestation URL" + if ( + !object(dist.attestations.provenance) || + dist.attestations.provenance.predicateType !== "https://slsa.dev/provenance/v1" + ) { + return "provenance" + } + return "" +} +async function npmState(record, handoffPackage) { try { - const versionsDoc = (await run("npm", ["view", name, "versions", "--json"])).stdout - const tagsDoc = (await run("npm", ["view", name, "dist-tags", "--json"])).stdout - const versionsValue = parseJson(versionsDoc, `${name} versions`), - tags = parseJson(tagsDoc, `${name} dist-tags`) + const versionsDocument = (await run("npm", ["view", record.name, "versions", "--json", "--registry", NPM_REGISTRY])) + .stdout + const tagsDocument = (await run("npm", ["view", record.name, "dist-tags", "--json", "--registry", NPM_REGISTRY])) + .stdout + const versionsValue = parseJson(versionsDocument, `${record.name} versions`) + const tags = parseJson(tagsDocument, `${record.name} dist-tags`) const versions = typeof versionsValue === "string" ? [versionsValue] : versionsValue - if (!Array.isArray(versions) || versions.some((value) => typeof value !== "string") || !object(tags)) + if (!Array.isArray(versions) || versions.some((value) => typeof value !== "string") || !object(tags)) { return { kind: "unknown" } - if (new Set(versions).size !== versions.length || versions.some((value) => !parseSemver(value))) + } + if (new Set(versions).size !== versions.length || versions.some((value) => !parseSemver(value))) { return { kind: "unknown" } - const target = parseSemver(version), - beta = parseSemver(betaVersion) + } + const target = parseSemver(record.version) + const beta = parseSemver(record.betaVersion) if (!target || !beta) return { kind: "unknown" } - const versionSet = new Set(versions), - targetPresent = versionSet.has(version) + const versionSet = new Set(versions) + const targetPresent = versionSet.has(record.version) const hasLatest = Object.hasOwn(tags, "latest") let latest if (hasLatest) { if (typeof tags.latest !== "string" || !(latest = parseSemver(tags.latest))) return { kind: "unknown" } - if (!versionSet.has(tags.latest)) return { kind: "divergent" } + if (!versionSet.has(tags.latest)) return { kind: "divergent", reason: "latest" } + } + if (targetPresent) { + if (!handoffPackage) return { kind: "present" } + if (!hasLatest || tags.latest !== record.version) return { kind: "divergent", reason: "latest" } + const distDocument = ( + await run("npm", ["view", `${record.name}@${record.version}`, "dist", "--json", "--registry", NPM_REGISTRY]) + ).stdout + const dist = parseJson(distDocument, `${record.name}@${record.version} dist`) + const reason = distDivergence(dist, record, handoffPackage) + return reason ? { kind: "divergent", reason } : { kind: "exact" } } - if (targetPresent) return hasLatest && tags.latest === version ? { kind: "exact" } : { kind: "divergent" } - if (tags.beta !== betaVersion || !versionSet.has(betaVersion)) return { kind: "divergent" } - if (hasLatest && compareSemver(latest, target) >= 0) return { kind: "divergent" } + if (tags.beta !== record.betaVersion || !versionSet.has(record.betaVersion)) { + return { kind: "divergent", reason: "beta baseline" } + } + if (hasLatest && compareSemver(latest, target) >= 0) return { kind: "divergent", reason: "latest" } return { kind: "absent" } } catch { return { kind: "unknown" } } } -async function npmBounded(name, version, betaVersion, { acceptAbsent = false } = {}) { +async function npmBounded(record, handoffPackage, { acceptAbsent = false } = {}) { let state - for (let attempt = 1; attempt <= maxReads; attempt++) { - state = await npmState(name, version, betaVersion) + for (let attempt = 1; attempt <= MAX_NPM_READS; attempt++) { + state = await npmState(record, handoffPackage) if (state.kind === "exact" || (acceptAbsent && state.kind === "absent")) return state - if (attempt < maxReads) await sleep(delayMs) + if (attempt < MAX_NPM_READS) await sleep(delayMs) } - if (state.kind === "absent") fail(`npm version remained absent after ${maxReads} attempts for ${name}`) - fail( - state.kind === "divergent" - ? `permanent npm state divergence for ${name}` - : `npm state unreadable after ${maxReads} attempts for ${name}`, - ) + if (state.kind === "absent") fail(`npm version remained absent after ${MAX_NPM_READS} attempts for ${record.name}`) + if (state.kind === "divergent") { + fail(`permanent npm state divergence (${state.reason ?? "unknown"}) for ${record.name}@${record.version}`) + } + fail(`npm state unreadable after ${MAX_NPM_READS} attempts for ${record.name}`) } +async function npmAbandonmentState(record) { + let state + for (let attempt = 1; attempt <= MAX_NPM_READS; attempt++) { + state = await npmState(record, null) + if (state.kind === "present") { + fail(`abandoned package ${record.name}@${record.version} must remain absent from npm`) + } + if (state.kind === "absent") return { kind: "absent-abandoned" } + if (attempt < MAX_NPM_READS) await sleep(delayMs) + } + if (state.kind === "divergent") { + fail(`permanent npm state divergence (${state.reason ?? "unknown"}) for abandoned ${record.name}@${record.version}`) + } + fail(`npm state unreadable after ${MAX_NPM_READS} attempts for abandoned ${record.name}`) +} + function parseTag(text, tag) { - const direct = [], - peeled = [], - directRef = `refs/tags/${tag}`, - peeledRef = `${directRef}^{}` + const direct = [] + const peeled = [] + const directRef = `refs/tags/${tag}` + const peeledRef = `${directRef}^{}` if (text === "") return { kind: "absent" } for (const line of text.split("\n")) { if (!line) continue @@ -372,8 +602,8 @@ function tagPushConfiguration() { return `http.https://github.com/.extraheader=AUTHORIZATION: basic ${basicAuth}` } async function github(method, path, body) { - const controller = new AbortController(), - timer = setTimeout(() => controller.abort(), httpTimeoutMs) + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), httpTimeoutMs) try { const options = { method, @@ -392,6 +622,7 @@ async function github(method, path, body) { options, ) const text = await response.text() + if (Buffer.byteLength(text) > outputLimit) fail("GitHub response exceeded bound") return { status: response.status, text } } catch (error) { fail(`GitHub transport failure: ${error.message}`) @@ -408,78 +639,257 @@ async function releaseState(tag) { ? { kind: "exact" } : { kind: "divergent" } } -async function verifyPublicationSource(records) { - const status = await run("git", ["status", "--porcelain=v1", "--untracked-files=all"]) - if (status.stdout !== "") fail("stable publication requires a clean index and worktree") - for (const record of records) { - let text - try { - text = await readFile(record.manifestPath, "utf8") - } catch { - fail(`on-disk manifest is unreadable for ${record.manifestPath}`) - } - const manifest = parseJson(text, `on-disk manifest ${record.manifestPath}`) - if (!object(manifest) || !isDeepStrictEqual(manifest, record.reviewedManifest)) { - fail(`on-disk manifest does not exactly match reviewed artifact for ${record.manifestPath}`) - } - } -} -async function inspect(records) { +async function inspect(records, packageByProject, abandonedProjects) { await run("git", ["fetch", "origin", "master:refs/remotes/origin/master", "--no-tags"]) const head = (await run("git", ["rev-parse", "HEAD"])).stdout.trim() const origin = (await run("git", ["rev-parse", "origin/master"])).stdout.trim() if (head !== expectedSha) fail("HEAD does not match expected SHA") if (origin !== expectedSha) fail("origin/master does not match expected SHA") + const states = [] for (const record of records) { - const npm = await npmBounded(record.name, record.version, record.betaVersion, { acceptAbsent: true }) + const npm = abandonedProjects.has(record.project) + ? await npmAbandonmentState(record) + : await npmBounded(record, packageByProject.get(record.project), { acceptAbsent: true }) const tag = await tagState(`${record.name}@${record.version}`) const release = await releaseState(`${record.name}@${record.version}`) for (const [label, state] of [ ["tag", tag], ["GitHub Release", release], ]) { - if (!["exact", "absent"].includes(state.kind)) + if (!["exact", "absent"].includes(state.kind)) { fail( `${label} state is ${state.kind} for ${record.name}@${record.version}${state.status ? ` (HTTP ${state.status})` : ""}`, ) + } } - const state = { ...record, npm: npm.kind, tag: tag.kind, release: release.kind } - delete state.reviewedManifest - states.push(state) + states.push({ + project: record.project, + root: record.root, + manifestPath: record.manifestPath, + name: record.name, + version: record.version, + betaVersion: record.betaVersion, + releaseOrder: record.releaseOrder, + npm: npm.kind, + tag: tag.kind, + release: release.kind, + }) } return states } -async function main() { - if (cliArguments.some((argument) => !["--preflight", "--json"].includes(argument))) fail("unknown argument") - if (jsonOutput && !preflight) fail("--json requires --preflight") - if (!/^[0-9a-f]{40}$/.test(expectedSha)) fail("FINALIZE requires full lowercase expected SHA") - if (!/^[0-9a-f]{40}$/.test(artifactSha)) fail("FINALIZE requires full lowercase artifact SHA") - const projects = parseRequestedProjects() - if (!preflight && process.env.GITHUB_ACTIONS !== "true") - fail("FINALIZE publication is allowed only in GitHub Actions") - if (historicalReplay) await verifyHistoricalAncestry() - await verifyArtifactLineage() - await verifyArtifactChangelog() - const records = await deriveReviewedRecords(projects) - const states = await inspect(records) - if (historicalReplay) { - const incomplete = states.find((item) => item.tag !== "exact" || item.release !== "exact" || item.npm !== "exact") - if (incomplete) - fail( - `historical replay requires exact existing tag, GitHub Release, and npm latest for ${incomplete.name}@${incomplete.version}`, - ) + +async function verifyPublicationSource(records) { + const status = await run("git", ["status", "--porcelain=v1", "--untracked-files=all"]) + if (status.stdout !== "") fail("stable publication requires a clean index and worktree") + for (const record of records) { + let text + try { + text = await readFile(record.manifestPath, "utf8") + } catch { + fail(`on-disk manifest is unreadable for ${record.manifestPath}`) + } + const manifest = parseJson(text, `on-disk manifest ${record.manifestPath}`) + if (!object(manifest) || !isDeepStrictEqual(manifest, record.reviewedManifest)) { + fail(`on-disk manifest does not exactly match reviewed artifact for ${record.manifestPath}`) + } + } +} +function npmConfigFailure() { + fail("npm auth configuration could not be safely verified at the publication boundary") +} +function stableFileIdentity(left, right) { + return ["dev", "ino", "mode", "nlink", "size", "mtimeNs", "ctimeNs"].every((key) => left[key] === right[key]) +} +function authBearingNpmConfig(text) { + if ( + /[\u0000-\u0009\u000b\u000c\u000e-\u001f\u007f-\u009f]/u.test(text) || + text.replaceAll("\r\n", "").includes("\r") + ) { + return true } - if (preflight) process.stdout.write(`${JSON.stringify({ ok: true, expectedSha, artifactSha, projects, states })}\n`) - if (historicalReplay || preflight) return - await verifyPublicationSource(records) - const missingTags = states.filter((item) => item.tag === "absent") + for (const line of text.split("\n")) { + const active = line.trimStart() + if (active === "" || active.startsWith("#") || active.startsWith(";")) continue + + const separator = active.indexOf("=") + const key = (separator === -1 ? active : active.slice(0, separator)).trim().toLowerCase() + const value = separator === -1 ? "" : active.slice(separator + 1).trim() + if (key === "") return true + + const leaf = key.split(/[:/]/u).at(-1).replace(/^_+/u, "").replace(/[-_]/gu, "") + if ( + new Set([ + "auth", + "authtoken", + "token", + "accesstoken", + "password", + "passwd", + "pass", + "username", + "user", + "alwaysauth", + "otp", + "cert", + "certfile", + "key", + "keyfile", + ]).has(leaf) + ) { + return true + } + if (/[a-z][a-z0-9+.-]*:\/\/[^/\s]*@/iu.test(value)) return true + for (const interpolation of active.matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/gu)) { + if (/(?:auth|token|passw|passwd|credential|secret)/iu.test(interpolation[1])) return true + } + } + return false +} +async function inspectNpmConfig(path, { allowMissing = false } = {}) { + let listed + try { + listed = await lstat(path, { bigint: true }) + } catch (error) { + if (allowMissing && error?.code === "ENOENT") return + npmConfigFailure() + } + if (!listed.isFile() || listed.isSymbolicLink() || listed.size > BigInt(MAX_NPM_CONFIG_BYTES)) npmConfigFailure() + + let handle + try { + handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)) + const before = await handle.stat({ bigint: true }) + if (!before.isFile() || !stableFileIdentity(listed, before) || before.size > BigInt(MAX_NPM_CONFIG_BYTES)) { + npmConfigFailure() + } + + const buffer = Buffer.alloc(MAX_NPM_CONFIG_BYTES + 1) + let length = 0 + while (length < buffer.length) { + const { bytesRead } = await handle.read(buffer, length, buffer.length - length, length) + if (bytesRead === 0) break + length += bytesRead + } + const after = await handle.stat({ bigint: true }) + if (length > MAX_NPM_CONFIG_BYTES || BigInt(length) !== before.size || !stableFileIdentity(before, after)) { + npmConfigFailure() + } + + let text + try { + text = new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, length)) + } catch { + npmConfigFailure() + } + if (authBearingNpmConfig(text)) npmConfigFailure() + } catch { + npmConfigFailure() + } finally { + if (handle) { + try { + await handle.close() + } catch { + npmConfigFailure() + } + } + } +} +function trackedNpmConfigPaths(text) { + if (text === "") return [] + if (!text.endsWith("\u0000")) npmConfigFailure() + const paths = text.slice(0, -1).split("\u0000") + if (paths.length > MAX_TRACKED_NPM_CONFIGS || new Set(paths).size !== paths.length) npmConfigFailure() + for (const path of paths) { + if ( + path === "" || + Buffer.byteLength(path) > 4096 || + isAbsolute(path) || + path.includes("\\") || + /[\u0000-\u001f\u007f\ufffd]/u.test(path) || + !(path === ".npmrc" || path.endsWith("/.npmrc")) || + path.split("/").some((part) => part === "" || part === "." || part === "..") + ) { + npmConfigFailure() + } + } + return paths +} +function verifyNpmEnvironment() { + const allowedConfiguration = new Map([ + ["NPM_CONFIG_IGNORE_SCRIPTS", "true"], + ["NPM_CONFIG_PROVENANCE", "true"], + ]) + const staticCredentialNames = new Set(["NODE_AUTH_TOKEN", "NPM_AUTH_TOKEN", "NPM_TOKEN"]) + for (const [name, value] of Object.entries(process.env)) { + const normalizedName = name.toUpperCase() + if (staticCredentialNames.has(normalizedName)) { + fail("static npm credentials are forbidden at the publication boundary") + } + if ( + normalizedName.startsWith("NPM_CONFIG_") && + (name !== normalizedName || allowedConfiguration.get(name) !== value) + ) { + fail("npm configuration environment is not allowlisted at the publication boundary") + } + } +} +async function configuredNpmPath(key) { + let configured + try { + configured = await run("npm", ["config", "get", key, "--json"]) + } catch { + npmConfigFailure() + } + const path = parseJson(configured.stdout, `npm ${key} configuration`) + if ( + typeof path !== "string" || + path.length === 0 || + Buffer.byteLength(path) > 4096 || + !isAbsolute(path) || + /[\u0000-\u001f\u007f\ufffd]/u.test(path) + ) { + npmConfigFailure() + } + return path +} +async function verifyTrustedPublishingBoundary() { + verifyNpmEnvironment() + + let tracked + try { + tracked = await run("git", ["ls-files", "-z", "--", ".npmrc", ":(glob)**/.npmrc"]) + } catch { + npmConfigFailure() + } + for (const path of trackedNpmConfigPaths(tracked.stdout)) await inspectNpmConfig(path) + + let registry + try { + registry = await run("npm", ["config", "get", "registry", "--json"]) + } catch { + npmConfigFailure() + } + if (parseJson(registry.stdout, "effective npm registry") !== NPM_REGISTRY) { + fail("effective npm registry is not the trusted npmjs registry at the publication boundary") + } + + const userConfigPath = await configuredNpmPath("userconfig") + const globalConfigPath = await configuredNpmPath("globalconfig") + await inspectNpmConfig(userConfigPath, { allowMissing: true }) + await inspectNpmConfig(globalConfigPath, { allowMissing: true }) +} + +async function createCurrentArtifacts(states) { + const missingTags = states + .filter((item) => item.tag === "absent") + .sort((left, right) => left.releaseOrder - right.releaseOrder) const pushConfiguration = missingTags.length > 0 ? tagPushConfiguration() : "" const localTags = [] for (const item of missingTags) { - const tag = `${item.name}@${item.version}`, - local = await localTagState(tag) + const tag = `${item.name}@${item.version}` + const local = await localTagState(tag) if (!["exact", "absent"].includes(local.kind)) fail(`local tag state is ${local.kind} for ${tag}`) localTags.push({ tag, local: local.kind }) } @@ -487,8 +897,9 @@ async function main() { await run("git", ["config", "user.name", "github-actions[bot]"]) await run("git", ["config", "user.email", "github-actions[bot]@users.noreply.github.com"]) } - for (const { tag, local } of localTags) + for (const { tag, local } of localTags) { if (local === "absent") await run("git", ["tag", "-a", tag, artifactSha, "-m", tag]) + } if (missingTags.length > 0) { const refs = missingTags.map( (item) => `refs/tags/${item.name}@${item.version}:refs/tags/${item.name}@${item.version}`, @@ -496,12 +907,14 @@ async function main() { try { await run("git", ["-c", pushConfiguration, "push", "--atomic", "origin", ...refs]) } catch { - /* response loss is reconciled below */ + // A lost response is accepted only if every remote tag reconciles exactly below. } } - for (const item of states) - if ((await tagState(`${item.name}@${item.version}`)).kind !== "exact") + for (const item of states) { + if ((await tagState(`${item.name}@${item.version}`)).kind !== "exact") { fail(`remote tag postverification failed for ${item.name}@${item.version}`) + } + } for (const item of states.filter((state) => state.release === "absent")) { let result @@ -513,45 +926,187 @@ async function main() { prerelease: false, }) } catch { - /* response loss is reconciled below */ + // A lost response is accepted only if the Release reconciles exactly below. } - if (result && ![201, 422].includes(result.status)) + if (result && ![201, 422].includes(result.status)) { fail(`GitHub Release creation failed for ${item.name}@${item.version} (HTTP ${result.status})`) + } } - for (const item of states) - if ((await releaseState(`${item.name}@${item.version}`)).kind !== "exact") + for (const item of states) { + if ((await releaseState(`${item.name}@${item.version}`)).kind !== "exact") { fail(`GitHub Release postverification failed for ${item.name}@${item.version}`) + } + } +} - const missing = [] - for (const item of states.filter((state) => state.npm === "absent")) { - const current = await npmBounded(item.name, item.version, item.betaVersion, { acceptAbsent: true }) - if (current.kind === "absent") missing.push(item.project) +function npmFailureCode(error) { + const result = error?.commandResult + for (const text of [result?.stderr, result?.stdout]) { + if (typeof text !== "string") continue + try { + const value = JSON.parse(text) + const candidate = value?.error?.code ?? value?.code + if (typeof candidate === "string" && /^[A-Z][A-Z0-9_-]{1,31}$/.test(candidate)) return candidate + } catch { + // Only a bounded, allowlisted code is extracted from non-JSON output. + } + const match = text.match(/\b(E(?:401|403|404|409|422|429|5[0-9]{2}|OTP|AUTH|ACCESS))\b/i) + if (match) return match[1].toUpperCase() } - if (missing.length > 0) { - await verifyPublicationSource(records) - await run("pnpm", ["nx", "release", "publish", `--projects=${missing.join(",")}`], { - env: { ...process.env, NPM_CONFIG_IGNORE_SCRIPTS: "true" }, - }) + return "UNKNOWN" +} +function npmFailureDiagnostic(error, record) { + const code = npmFailureCode(error) + const descriptions = { + E401: "trusted publishing authentication failed", + E403: "trusted publishing authorization was denied", + E404: "npm package or registry endpoint was not found", + E409: "npm reported a publication conflict", + E422: "npm rejected the publication payload", + E429: "npm rate-limited the publication", + E500: "npm registry service failed", + E501: "npm registry service failed", + E502: "npm registry service failed", + E503: "npm registry service failed", + E504: "npm registry service failed", + EOTP: "interactive npm authentication is forbidden", + EAUTH: "trusted publishing authentication failed", + EACCESS: "trusted publishing authorization was denied", + } + const description = descriptions[code] ?? "npm publication failed without a recognized safe error code" + const exitCode = Number.isInteger(error?.commandResult?.code) ? `; exit ${error.commandResult.code}` : "" + return `npm publish failed for ${record.name}@${record.version}: ${description} (${code}${exitCode}). Registry state remained absent after ${MAX_NPM_READS} bounded reconciliation reads. Verify npm trusted publishing configuration and GitHub OIDC permissions.` +} +async function reconcileAfterFailedPublish(record, handoffPackage) { + let state + for (let attempt = 1; attempt <= MAX_NPM_READS; attempt++) { + state = await npmState(record, handoffPackage) + if (state.kind === "exact") return true + if (attempt < MAX_NPM_READS) await sleep(delayMs) } + if (state.kind === "divergent") { + fail(`permanent npm state divergence (${state.reason ?? "unknown"}) for ${record.name}@${record.version}`) + } + if (state.kind === "unknown") { + fail(`npm state unreadable after ${MAX_NPM_READS} attempts for ${record.name}`) + } + return false +} +async function publishMissingPackages(states, records, packageByProject, handoffDirectory) { + const byProject = new Map(records.map((record) => [record.project, record])) for (const item of states) { - const state = await npmBounded(item.name, item.version, item.betaVersion) - if (state.kind !== "exact") fail(`npm did not converge for ${item.name}`) + if (item.npm === "absent-abandoned") continue + const record = byProject.get(item.project) + const handoffPackage = packageByProject.get(item.project) + const current = await npmBounded(record, handoffPackage, { acceptAbsent: true }) + if (current.kind === "exact") continue + + const tarballPath = join(handoffDirectory, handoffPackage.tarball.basename) + let publishFailure + try { + await run("npm", [ + "publish", + tarballPath, + "--registry", + NPM_REGISTRY, + "--access", + "public", + "--tag", + "latest", + "--provenance", + "--ignore-scripts", + "--json", + ]) + } catch (error) { + publishFailure = error + } + if (publishFailure) { + if (await reconcileAfterFailedPublish(record, handoffPackage)) continue + fail(npmFailureDiagnostic(publishFailure, record)) + } + await npmBounded(record, handoffPackage) + } +} + +async function main() { + if (cliArguments.some((argument) => !["--preflight", "--json"].includes(argument))) fail("unknown argument") + if (new Set(cliArguments).size !== cliArguments.length) fail("duplicate argument") + if (jsonOutput && !preflight) fail("--json requires --preflight") + validateRuntimeBounds() + if (!fullSha(expectedSha)) fail("FINALIZE requires full lowercase expected SHA") + if (!fullSha(artifactSha)) fail("FINALIZE requires full lowercase artifact SHA") + const projects = parseRequestedProjects() + if (!preflight && process.env.GITHUB_ACTIONS !== "true") { + fail("FINALIZE publication is allowed only in GitHub Actions") + } + + const handoffContext = await verifyCurrentRunHandoff(projects) + if (historicalReplay) await verifyHistoricalRecoveryBounds() + await verifyArtifactLineage() + await verifyArtifactChangelog() + const records = await deriveReviewedRecords(projects) + const { abandonedProjects, packageByProject } = bindHandoffToRecords(handoffContext.handoff, records) + const states = await inspect(records, packageByProject, abandonedProjects) + + if (historicalReplay) { + for (const state of states) { + if (state.tag !== "exact") + fail(`historical recovery requires exact existing tag for ${state.name}@${state.version}`) + if (state.release !== "exact") { + fail(`historical recovery requires exact existing GitHub Release for ${state.name}@${state.version}`) + } + if (state.npm === "absent-abandoned") continue + if (!["exact", "absent"].includes(state.npm)) { + fail(`historical recovery npm state is invalid for ${state.name}@${state.version}`) + } + } + } + + const report = { + ok: true, + mode: historicalReplay ? "historical-npm-only" : "current-exact", + historicalNpmOnly: historicalReplay, + expectedSha, + artifactSha, + artifactId: handoffContext.artifactId, + artifactDigest: handoffContext.artifactDigest, + projects, + abandonments: handoffContext.handoff.abandonments, + states, + } + if (preflight) { + process.stdout.write(`${JSON.stringify(report)}\n`) + return + } + + if (states.some((state) => state.npm === "absent")) await verifyTrustedPublishingBoundary() + + if (!historicalReplay) { + await verifyPublicationSource(records) + await createCurrentArtifacts(states) + } + await publishMissingPackages(states, records, packageByProject, handoffContext.directory) + + for (const record of records) { + if (abandonedProjects.has(record.project)) { + await npmAbandonmentState(record) + } else { + await npmBounded(record, packageByProject.get(record.project)) + } } } function isMainModule() { const entry = process.argv[1] if (!entry) return false - let resolvedEntry, resolvedModule try { - resolvedEntry = realpathSync(entry) - resolvedModule = realpathSync(fileURLToPath(import.meta.url)) + return pathToFileURL(realpathSync(entry)).href === pathToFileURL(realpathSync(fileURLToPath(import.meta.url))).href } catch { return false } - return pathToFileURL(resolvedEntry).href === pathToFileURL(resolvedModule).href } -if (isMainModule()) +if (isMainModule()) { main().catch((error) => { process.stderr.write(`::error::${error.message}\n`) process.exitCode = 1 }) +} diff --git a/scripts/release-finalize-stable.test.mjs b/scripts/release-finalize-stable.test.mjs index c0d06906..dbe9ceaf 100644 --- a/scripts/release-finalize-stable.test.mjs +++ b/scripts/release-finalize-stable.test.mjs @@ -1,124 +1,333 @@ import assert from "node:assert/strict" import { spawn } from "node:child_process" +import { createHash } from "node:crypto" import { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs" import { rm } from "node:fs/promises" import { createServer } from "node:http" import { tmpdir } from "node:os" -import { join } from "node:path" +import { basename, join } from "node:path" import test from "node:test" +import { isDeepStrictEqual } from "node:util" +import { gzipSync } from "node:zlib" const script = new URL("release-finalize-stable.mjs", import.meta.url).pathname -const stableWorkflow = readFileSync(new URL("../.github/workflows/release-stable.yml", import.meta.url), "utf8") -const sha = "1234567890abcdef1234567890abcdef12345678" -const historicalSha = "abcdef1234567890abcdef1234567890abcdef12" +const artifactSha = "f31390ce66ea157ea8b75f5259c203123e269759" +const advancedSha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" const parentSha = "fedcba0987654321fedcba0987654321fedcba09" const secondParentSha = "0123456789abcdef0123456789abcdef01234567" -const thirdParentSha = "89abcdef0123456789abcdef0123456789abcdef" const treeSha = "9999999999999999999999999999999999999999" +const repository = "devx-op/effectify" +const workflowPath = ".github/workflows/release-stable.yml" +const workflowRef = "refs/heads/master" +const runId = "33399900011" +const runAttempt = "2" +const artifactId = "987654321" +const artifactDigest = `sha256:${"9".repeat(64)}` +const npmRegistry = "https://registry.npmjs.org/" const catalog = [ - ["@future/nebula", "packages/future/nebula", "4.7.0-beta.12", "4.7.0"], - ["@future/orbit", "packages/future/orbit", "8.0.1-beta.3", "8.0.1"], - ["@future/quasar", "packages/future/quasar", "12.3.5-beta.27", "12.3.5"], + ["@effectify/hatchet", "packages/hatchet", "0.2.0-beta.0", "0.2.0"], + ["@effectify/node-better-auth", "packages/node/better-auth", "0.5.13-beta.0", "0.5.13"], + ["@effectify/prisma", "packages/prisma", "1.1.14-beta.0", "1.1.14"], + ["@effectify/react-query", "packages/react/query", "1.0.1-beta.0", "1.0.1"], + ["@effectify/react-router-better-auth", "packages/react/router-better-auth", "0.5.13-beta.0", "0.5.13"], + ["@effectify/react-router", "packages/react/router", "0.6.1-beta.0", "0.6.1"], + ["@effectify/solid-query", "packages/solid/query", "0.5.14-beta.0", "0.5.14"], ] -const selected = [catalog[0], catalog[2]] -const records = selected.map(([project, root, beta, version]) => [project, `${root}/package.json`, version, beta]) -const selectedProjects = records - .map(([project]) => project) - .sort() - .join(",") +const records = catalog.map(([project, root, betaVersion, version]) => ({ + project, + root, + manifestPath: `${root}/package.json`, + betaVersion, + version, +})) +const projects = records.map(({ project }) => project).sort() +const projectsText = projects.join(",") +const prisma = records.find(({ project }) => project === "@effectify/prisma") +const publishable = records + .filter(({ project }) => project !== prisma.project) + .sort((left, right) => left.project.localeCompare(right.project)) +const allowedHistoricalPaths = [ + ".github/SETUP.md", + ".github/workflows/release-stable.yml", + "scripts/release-finalize-stable.mjs", + "scripts/release-finalize-stable.test.mjs", + "scripts/release-package-stable.mjs", + "scripts/release-package-stable.test.mjs", + "scripts/release-policy-contract.test.mjs", + "scripts/release-stable-abandonments.json", +] +const safeNpmrc = `# NPM Configuration for CI/CD +#registry=https://registry.npmjs.org/ +#always-auth=true + +# JSR Configuration (for Deno packages) +#@jsr:registry=https://npm.jsr.io/ + +hoist=false +` +const realAttestationUrls = new Map([ + ["@effectify/hatchet", "https://registry.npmjs.org/-/npm/v1/attestations/@effectify%2fhatchet@0.2.0"], + [ + "@effectify/node-better-auth", + "https://registry.npmjs.org/-/npm/v1/attestations/@effectify%2fnode-better-auth@0.5.13", + ], + ["@effectify/react-query", "https://registry.npmjs.org/-/npm/v1/attestations/@effectify%2freact-query@1.0.1"], + [ + "@effectify/react-router-better-auth", + "https://registry.npmjs.org/-/npm/v1/attestations/@effectify%2freact-router-better-auth@0.5.13", + ], + ["@effectify/react-router", "https://registry.npmjs.org/-/npm/v1/attestations/@effectify%2freact-router@0.6.1"], + ["@effectify/solid-query", "https://registry.npmjs.org/-/npm/v1/attestations/@effectify%2fsolid-query@0.5.14"], +]) + +function digest(algorithm, bytes) { + return createHash(algorithm).update(bytes).digest("hex") +} +function tarHeader(path, size) { + const header = Buffer.alloc(512) + const put = (value, offset, length) => header.write(value, offset, Math.min(length, Buffer.byteLength(value)), "utf8") + put(path, 0, 100) + put("0000644\0", 100, 8) + put("0000000\0", 108, 8) + put("0000000\0", 116, 8) + put(`${size.toString(8).padStart(11, "0")}\0`, 124, 12) + put("00000000000\0", 136, 12) + header.fill(0x20, 148, 156) + header[156] = "0".charCodeAt(0) + put("ustar\0", 257, 6) + put("00", 263, 2) + const checksum = [...header].reduce((total, byte) => total + byte, 0) + put(`${checksum.toString(8).padStart(6, "0")}\0 `, 148, 8) + return header +} +function tarballBytes(entries) { + const blocks = [] + for (const [path, value] of [...entries].sort(([left], [right]) => left.localeCompare(right))) { + const body = Buffer.isBuffer(value) ? value : Buffer.from(value) + blocks.push(tarHeader(path, body.length), body) + const padding = (512 - (body.length % 512)) % 512 + if (padding) blocks.push(Buffer.alloc(padding)) + } + blocks.push(Buffer.alloc(1024)) + return gzipSync(Buffer.concat(blocks), { mtime: 0 }) +} +function expectedDist(record, handoffPackage) { + return { + integrity: handoffPackage.tarball.integrity, + shasum: handoffPackage.tarball.sha1, + attestations: { + url: realAttestationUrls.get(record.project), + provenance: { predicateType: "https://slsa.dev/provenance/v1" }, + }, + } +} +function addArtifactFiles(gitFiles) { + gitFiles[`${artifactSha}:CHANGELOG.md`] = "# Changelog\n" + gitFiles[`${artifactSha}:nx.json`] = { release: { projects: catalog.map(([, root]) => root) } } + for (const record of records) { + gitFiles[`${artifactSha}:${record.root}/project.json`] = { name: record.project } + gitFiles[`${artifactSha}:${record.manifestPath}`] = { name: record.project, version: record.version } + gitFiles[`${artifactSha}^1:${record.manifestPath}`] = { name: record.project, version: record.betaVersion } + } +} +function makeHandoff(directory, expectedSha) { + mkdirSync(directory) + const packages = [] + for (const record of publishable) { + const packedManifest = { + name: record.project, + version: record.version, + type: "module", + main: "./dist/index.js", + exports: { ".": "./dist/index.js" }, + files: ["dist"], + } + const manifestBody = `${JSON.stringify(packedManifest, null, 2)}\n` + const distBody = "export const stable = true\n" + const bytes = tarballBytes([ + ["package/package.json", manifestBody], + ["package/dist/index.js", distBody], + ]) + const tarballBasename = `${record.project.slice(1).replace("/", "-")}-${record.version}.tgz` + writeFileSync(join(directory, tarballBasename), bytes) + const sha512 = digest("sha512", bytes) + packages.push({ + project: record.project, + root: record.root, + name: record.project, + version: record.version, + sourceManifestSha256: digest("sha256", JSON.stringify({ name: record.project, version: record.version })), + tarball: { + basename: tarballBasename, + size: bytes.length, + sha1: digest("sha1", bytes), + sha256: digest("sha256", bytes), + sha512, + integrity: `sha512-${Buffer.from(sha512, "hex").toString("base64")}`, + }, + inventory: [ + { path: "package/dist/index.js", size: Buffer.byteLength(distBody), mode: 0o644, type: "file" }, + { path: "package/package.json", size: Buffer.byteLength(manifestBody), mode: 0o644, type: "file" }, + ], + }) + } + packages.sort((left, right) => left.project.localeCompare(right.project)) + const handoff = { + schemaVersion: 1, + repository, + workflow: { path: workflowPath, ref: workflowRef, sha: expectedSha }, + run: { id: runId, attempt: runAttempt }, + expectedSha, + artifactSha, + selection: projects, + abandonments: [ + { + artifactSha, + project: prisma.project, + name: prisma.project, + version: prisma.version, + reason: "Reviewed exception: 1.1.14 has broken CLI/export paths; publish a reviewed 1.1.15 instead.", + }, + ], + packages, + } + writeFileSync(join(directory, "handoff.json"), `${JSON.stringify(handoff, null, 2)}\n`) + return handoff +} const fake = String.raw`#!/usr/bin/env node -const fs=require('fs'),p=require('path'),cmd=p.basename(process.argv[1]),raw=process.argv.slice(2),oneShot=cmd==='git'&&raw[0]==='-c',authConfiguration=oneShot?raw[1]:undefined,a=oneShot?raw.slice(2):raw,f=process.env.FAKE_STATE -let s=JSON.parse(fs.readFileSync(f)), out=x=>process.stdout.write(String(x)), save=()=>fs.writeFileSync(f,JSON.stringify(s)) -s.log.push([cmd,...a]); +const fs=require("node:fs"),p=require("node:path"),cmd=p.basename(process.argv[1]),raw=process.argv.slice(2),oneShot=cmd==="git"&&raw[0]==="-c",authConfiguration=oneShot?raw[1]:undefined,a=oneShot?raw.slice(2):raw,f=process.env.FAKE_STATE +let s=JSON.parse(fs.readFileSync(f)),out=x=>process.stdout.write(String(x)),save=()=>fs.writeFileSync(f,JSON.stringify(s)) +s.log.push([cmd,...a]) function finish(code=0){save();process.exit(code)} -if(cmd==='git'){ - if(a[0]==='fetch'||a[0]==='config')finish() - if(a[0]==='cat-file'&&a[1]==='-t'){const v=s.gitFiles[a[2]];if(v===undefined)finish(128);out((s.gitTypes?.[a[2]]??'blob')+'\n');finish()} - if(a[0]==='merge-base'){finish(s.ancestorExit??(s.ancestor===false?1:0))} - if(a[0]==='rev-list'){out((s.commitLines?.[a.at(-1)]??s.commitLine??(s.artifactSha+' '+s.parentSha))+'\n');finish()} - if(a[0]==='rev-parse'){ - if(a[1].endsWith('^{tree}')){out((s.generatedTreeSha??s.treeSha)+'\n'+(s.artifactTreeSha??s.treeSha)+'\n');finish()} - out((a[1]==='HEAD'?s.head:s.origin)+'\n');finish() +function take(value,key,fallback){const queue=value[key];if(queue&&queue.length){const next=queue.shift();if(queue.length===0)value[key.replace(/Queue$/,"")]=next;return next}return fallback} +function emit(value){if(value&&typeof value==="object"&&value.exit){if(value.stdout)process.stdout.write(value.stdout);if(value.stderr)process.stderr.write(value.stderr);finish(value.exit)}if(value&&typeof value==="object"&&Object.hasOwn(value,"raw"))out(value.raw);else out(JSON.stringify(value)+"\n");finish()} +function materialize(name){const value=s.npm[name],pkg=s.packages[name];if(!value.versions.includes(pkg.version))value.versions.push(pkg.version);value.latest=pkg.version;value.dist=pkg.dist} +if(cmd==="git"){ + if(a[0]==="fetch"||a[0]==="config")finish() + if(a[0]==="cat-file"&&a[1]==="-t"){const value=s.gitFiles[a[2]];if(value===undefined)finish(128);out((s.gitTypes?.[a[2]]??"blob")+"\n");finish()} + if(a[0]==="merge-base")finish(s.ancestorExit??(s.ancestor===false?1:0)) + if(a[0]==="rev-list"&&a[1]==="--count"){out(String(s.historicalCount)+"\n");finish()} + if(a[0]==="rev-list"){out((s.commitLines?.[a.at(-1)]??s.commitLine??(s.artifactSha+" "+s.parentSha))+"\n");finish()} + if(a[0]==="rev-parse"){ + if(a[1].endsWith("^{tree}")){out((s.generatedTreeSha??s.treeSha)+"\n"+(s.artifactTreeSha??s.treeSha)+"\n");finish()} + out((a[1]==="HEAD"?s.head:s.origin)+"\n");finish() } - if(a[0]==='status'){out(s.worktreeStatus??'');finish()} - if(a[0]==='show'){const value=s.gitFiles[a[1]];if(value===undefined)finish(128);out(typeof value==='string'?value:JSON.stringify(value));finish()} - if(a[0]==='diff'){out(s.changedPaths.join('\n')+(s.changedPaths.length?'\n':''));finish()} - if(a[0]==='ls-remote'){ - const t=a[3].slice(10),v=s.tags[t]; if(v){if(v.raw)out(v.raw.replaceAll('$TAG',t));else{out((v.direct||'a'.repeat(40))+'\trefs/tags/'+t+'\n');if(v.peeled!==null)out((v.peeled||s.artifactSha)+'\trefs/tags/'+t+'^{}\n')}} finish() + if(a[0]==="status"){out(s.worktreeStatus??"");finish()} + if(a[0]==="show"){const value=s.gitFiles[a[1]];if(value===undefined)finish(128);out(typeof value==="string"?value:JSON.stringify(value));finish()} + if(a[0]==="diff"){ + const historical=a.at(-2)===s.artifactSha&&a.at(-1)===s.expectedSha + const paths=historical?s.historicalPaths:s.changedPaths + out(paths.join("\n")+(paths.length?"\n":""));finish() } - if(a[0]==='for-each-ref'){const t=a[2].slice(10),v=s.localTags[t];if(v)out((v.type||'tag')+'\t'+(v.peeled||s.artifactSha)+'\n');finish()} - if(a[0]==='tag'){s.localTags[a[2]]={type:'tag',peeled:a[3]};finish()} - if(a[0]==='push'){ - const token=process.env.GITHUB_TOKEN||'',basic=Buffer.from('x-access-token:'+token,'utf8').toString('base64') - s.pushAuthentication={oneShot,matchesToken:authConfiguration==='http.https://github.com/.extraheader=AUTHORIZATION: basic '+basic,tokenLiteral:Boolean(token)&&raw.some(value=>value.includes(token))} - const materialize=()=>{for(const r of a.slice(3)){const t=r.split(':')[0].slice(10);s.tags[t]={peeled:s.localTags[t].peeled}}} - if(s.pushMaterializesOnFailure){materialize();finish(s.pushExit||1)} + if(a[0]==="ls-files"){out(s.trackedNpmrc??"");finish()} + if(a[0]==="ls-remote"){ + const tag=a[3].slice(10),value=s.tags[tag] + if(value){if(value.raw)out(value.raw.replaceAll("$TAG",tag));else{out((value.direct||"b".repeat(40))+"\trefs/tags/"+tag+"\n");if(value.peeled!==null)out((value.peeled||s.artifactSha)+"\trefs/tags/"+tag+"^{}\n")}}finish() + } + if(a[0]==="for-each-ref"){const tag=a[2].slice(10),value=s.localTags[tag];if(value)out((value.type||"tag")+"\t"+(value.peeled||s.artifactSha)+"\n");finish()} + if(a[0]==="tag"){s.localTags[a[2]]={type:"tag",peeled:a[3]};finish()} + if(a[0]==="push"){ + const token=process.env.GITHUB_TOKEN||"",basic=Buffer.from("x-access-token:"+token,"utf8").toString("base64") + s.pushAuthentication={oneShot,matchesToken:authConfiguration==="http.https://github.com/.extraheader=AUTHORIZATION: basic "+basic,tokenLiteral:Boolean(token)&&raw.some(value=>value.includes(token))} + const materializeTags=()=>{for(const ref of a.slice(3)){const tag=ref.split(":")[0].slice(10);s.tags[tag]={peeled:s.localTags[tag].peeled}}} + if(s.pushMaterializesOnFailure){materializeTags();finish(s.pushExit||1)} if(s.pushExit)finish(s.pushExit) - materialize();finish() + materializeTags();finish() } finish(127) } -if(cmd==='npm'){ - const n=a[1],field=a[2],v=s.npm[n] - const take=(key,fallback)=>{const q=v[key],x=q&&q.length?q.shift():fallback;if(q&&q.length===0){if(key==='versionsQueue')v.versions=x;if(key==='latestQueue')v.latest=x}return x} - let x - if(field==='versions')x=take('versionsQueue',v.versions) - else if(field==='dist-tags.latest')x=take('latestQueue',v.latest) - else if(field==='dist-tags'){ - const q=v.distTagsQueue - if(q&&q.length){x=q.shift();if(q.length===0&&x&&typeof x==='object'&&!x.exit&&!Object.hasOwn(x,'raw')){v.alpha=x.alpha;v.beta=x.beta;v.latest=x.latest}} - else{const latest=take('latestQueue',v.latest);x={alpha:v.alpha,beta:v.beta};if(latest!==undefined)x.latest=latest} - }else finish(127) - if(x&&typeof x==='object'&&x.exit){process.stderr.write(x.stderr||'failure');finish(x.exit)} - if(x&&typeof x==='object'&&Object.hasOwn(x,'raw'))out(x.raw);else out(JSON.stringify(x)+'\n');finish() -} -if(cmd==='pnpm'){ - s.publishEnvironment={ignoreScripts:process.env.NPM_CONFIG_IGNORE_SCRIPTS,inheritedSentinel:process.env.FINALIZE_ENV_SENTINEL} - const projects=a[3].slice(11).split(','),count=s.publishSubset??projects.length - for(const project of projects.slice(0,count)){const n=s.projectPackages[project],v=s.expected[n],old=s.npm[n];old.versions=[v];old.latest=v;if(old.delayedVersions){old.versions=[];old.versionsQueue=Array(old.delayedVersions).fill([]).concat([[v]])}if(old.delayedLatest){old.latest='0.0.1';old.latestQueue=Array(old.delayedLatest).fill('0.0.1').concat(v)}} - finish(s.publishExit||0) +if(cmd==="npm"){ + if(a[0]==="config"&&a[1]==="get"&&a[2]==="userconfig")emit(s.userConfigPath) + if(a[0]==="config"&&a[1]==="get"&&a[2]==="globalconfig")emit(s.globalConfigPath) + if(a[0]==="config"&&a[1]==="get"&&a[2]==="registry")emit(s.registry) + if(a[0]==="view"){ + const spec=a[1],field=a[2] + if(field==="dist"){ + const at=spec.lastIndexOf("@"),name=spec.slice(0,at),value=s.npm[name] + emit(take(value,"distQueue",value.dist)) + } + const name=spec,value=s.npm[name] + if(field==="versions")emit(take(value,"versionsQueue",value.versions)) + if(field==="dist-tags"){ + const fallback={alpha:value.alpha,beta:value.beta} + if(value.latest!==undefined)fallback.latest=value.latest + emit(take(value,"distTagsQueue",fallback)) + } + finish(127) + } + if(a[0]==="publish"){ + const packageEntry=Object.values(s.packages).find(value=>p.basename(a[1])===value.basename) + if(!packageEntry)finish(91) + const failure=s.publishFailures?.[packageEntry.name] + if(failure?.materialize)materialize(packageEntry.name) + if(failure){if(failure.stdout)process.stdout.write(failure.stdout);if(failure.stderr)process.stderr.write(failure.stderr);finish(failure.exit??1)} + materialize(packageEntry.name);out(JSON.stringify({id:packageEntry.name+"@"+packageEntry.version})+"\n");finish() + } + finish(127) } +if(cmd==="pnpm")finish(88) finish(127)` -function load(file) { - return JSON.parse(readFileSync(file, "utf8")) +function load(path) { + return JSON.parse(readFileSync(path, "utf8")) } -function save(file, value) { - writeFileSync(file, JSON.stringify(value)) +function save(path, value) { + writeFileSync(path, JSON.stringify(value)) } -function mutations(state) { - // Fetch is a local synchronization/read operation: it updates remote-tracking state but cannot publish anything. +function mutationCalls(state) { return state.log.filter( ([command, operation]) => command === "pnpm" || - (command === "git" && (operation === "tag" || operation === "push")) || + (command === "npm" && operation === "publish") || + (command === "git" && ["tag", "push"].includes(operation)) || (command === "http" && operation === "POST"), ) } -function addArtifactFiles(gitFiles, commit) { - gitFiles[`${commit}:CHANGELOG.md`] = "# Changelog\n" - gitFiles[`${commit}:nx.json`] = { release: { projects: catalog.map(([, root]) => root) } } - for (const [project, root, beta, version] of catalog) { - gitFiles[`${commit}:${root}/project.json`] = { name: project } - gitFiles[`${commit}:${root}/package.json`] = { name: project, version } - gitFiles[`${commit}^1:${root}/package.json`] = { name: project, version: beta } +function publishCalls(state) { + return state.log.filter(([command, operation]) => command === "npm" && operation === "publish") +} +function setNpmExact(state, record) { + const value = state.npm[record.project] + if (!value.versions.includes(record.version)) value.versions.push(record.version) + value.latest = record.version + if (record.project !== prisma.project) value.dist = state.packages[record.project].dist +} +function setArtifactsExact(state) { + for (const record of records) { + const tag = `${record.project}@${record.version}` + state.tags[tag] = { peeled: artifactSha } + state.releases[tag] = { tag_name: tag, draft: false, prerelease: false } } } - -async function discardWorld({ cwd, server }) { +function assertExact(state) { + for (const record of records) { + const tag = `${record.project}@${record.version}` + assert.equal(state.tags[tag]?.peeled, artifactSha) + assert.deepEqual(state.releases[tag], { tag_name: tag, draft: false, prerelease: false }) + if (record.project === prisma.project) { + assert.equal(state.npm[record.project].versions.includes(record.version), false) + continue + } + assert.equal(state.npm[record.project].versions.includes(record.version), true) + assert.equal(state.npm[record.project].latest, record.version) + assert.deepEqual(state.npm[record.project].dist, state.packages[record.project].dist) + } +} +async function discardWorld(world) { try { - if (server?.listening) await new Promise((resolve) => server.close(resolve)) + if (world.server?.listening) await new Promise((resolve) => world.server.close(resolve)) } finally { - await rm(cwd, { recursive: true, force: true }) + await rm(world.cwd, { recursive: true, force: true }) } - assert.equal(existsSync(cwd), false) + assert.equal(existsSync(world.cwd), false) } - -async function world(mode = "absent") { - const cwd = mkdtempSync(join(tmpdir(), "stable-finalize-")), - bin = join(cwd, "bin"), - stateFile = join(cwd, "state.json") +async function makeWorld({ historical = false, npmMode = "absent", artifacts = "exact" } = {}) { + const cwd = mkdtempSync(join(tmpdir(), "stable-finalize-handoff-")) + const bin = join(cwd, "bin") + const handoffDirectory = join(cwd, "handoff") + const stateFile = join(cwd, "state.json") + const expectedSha = historical ? advancedSha : artifactSha let server try { mkdirSync(bin) @@ -126,80 +335,99 @@ async function world(mode = "absent") { chmodSync(join(bin, "fake.cjs"), 0o755) for (const command of ["git", "npm", "pnpm"]) symlinkSync("fake.cjs", join(bin, command)) symlinkSync(process.execPath, join(bin, "node")) - const expected = {}, - npm = {}, - tags = {}, - releases = {}, - projectPackages = {}, - gitFiles = {} - addArtifactFiles(gitFiles, sha) - addArtifactFiles(gitFiles, historicalSha) - for (const [project, path, version, betaVersion] of records) { - mkdirSync(join(cwd, path, ".."), { recursive: true }) - writeFileSync(join(cwd, path), JSON.stringify({ name: project, version })) - projectPackages[project] = project - expected[project] = version - npm[project] = { - versions: mode === "exact" ? [betaVersion, version] : ["0.0.1", betaVersion], - latest: mode === "exact" ? version : "0.0.1", + const handoff = makeHandoff(handoffDirectory, expectedSha) + const packages = Object.fromEntries( + handoff.packages.map((item) => { + const record = records.find(({ project }) => project === item.project) + return [ + item.project, + { + name: item.project, + version: item.version, + basename: item.tarball.basename, + dist: expectedDist(record, item), + }, + ] + }), + ) + const npm = {} + for (const record of records) { + npm[record.project] = { + versions: ["0.0.1", record.betaVersion], + latest: "0.0.1", alpha: "alpha-sentinel", - beta: betaVersion, - } - if (mode === "exact") { - const tag = `${project}@${version}` - tags[tag] = { peeled: sha } - releases[tag] = { tag_name: tag, draft: false, prerelease: false } + beta: record.betaVersion, } + if (npmMode === "exact" && record.project !== prisma.project) setNpmExact({ npm, packages }, record) + } + const gitFiles = {} + addArtifactFiles(gitFiles) + for (const record of records) { + mkdirSync(join(cwd, record.root), { recursive: true }) + writeFileSync(join(cwd, record.manifestPath), JSON.stringify({ name: record.project, version: record.version })) } - const changedPaths = ["CHANGELOG.md", ...records.map(([, path]) => path)].sort() - save(stateFile, { - sha, - artifactSha: sha, + const state = { + artifactSha, + expectedSha, parentSha, treeSha, - head: sha, - origin: sha, - expected, + head: expectedSha, + origin: expectedSha, + commitLine: `${artifactSha} ${parentSha}`, + historicalCount: 2, + historicalPaths: ["scripts/release-finalize-stable.mjs", "scripts/release-package-stable.mjs"], + changedPaths: ["CHANGELOG.md", ...records.map(({ manifestPath }) => manifestPath)].sort(), + gitFiles, + packages, npm, - tags, - releases, + tags: {}, + releases: {}, localTags: {}, - projectPackages, - gitFiles, - changedPaths, + userConfigPath: join(cwd, "missing-user-npmrc"), + globalConfigPath: join(cwd, "missing-global-npmrc"), + registry: npmRegistry, log: [], - }) + } + if (artifacts === "exact") setArtifactsExact(state) + save(stateFile, state) server = createServer((request, response) => { - const state = load(stateFile), - method = request.method, - path = request.url - state.log.push(["http", method, path]) + const current = load(stateFile) + current.log.push(["http", request.method, request.url]) const send = (status, body = "") => { - save(stateFile, state) + save(stateFile, current) response.writeHead(status, { "content-type": "application/json" }) response.end(typeof body === "string" ? body : JSON.stringify(body)) } - if (method === "GET") { - const tag = decodeURIComponent(path.split("/releases/tags/")[1] || ""), - configured = state.ghReadStatus - if (configured) return send(configured, { message: "configured" }) - return state.releases[tag] ? send(200, state.releases[tag]) : send(404, { message: "not found" }) + if (request.method === "GET") { + const tag = decodeURIComponent(request.url.split("/releases/tags/")[1] || "") + if (current.ghReadStatus) return send(current.ghReadStatus, { message: "configured" }) + return current.releases[tag] ? send(200, current.releases[tag]) : send(404, { message: "not found" }) } let body = "" request.on("data", (chunk) => (body += chunk)) request.on("end", () => { - const value = JSON.parse(body), - tag = value.tag_name, - status = state.ghCreateStatus || 201 - if (state.ghCreateMaterializes !== false) - state.releases[tag] = { tag_name: tag, draft: false, prerelease: false } - save(stateFile, state) - if (state.ghCreateResponseLoss) return response.destroy() - send(status, status === 422 ? { message: "already exists" } : state.releases[tag]) + const value = JSON.parse(body) + const tag = value.tag_name + const status = current.ghCreateStatus || 201 + if (current.ghCreateMaterializes !== false) { + current.releases[tag] = { tag_name: tag, draft: false, prerelease: false } + } + save(stateFile, current) + if (current.ghCreateResponseLoss) return response.destroy() + send(status, status === 422 ? { message: "already exists" } : current.releases[tag]) }) }) await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)) - return { cwd, bin, stateFile, server, api: `http://127.0.0.1:${server.address().port}` } + return { + cwd, + bin, + handoffDirectory, + handoff, + stateFile, + server, + expectedSha, + api: `http://127.0.0.1:${server.address().port}`, + } } catch (error) { await discardWorld({ cwd, server }) throw error @@ -211,1034 +439,802 @@ async function run(world, args = [], environment = {}) { cwd: world.cwd, env: { PATH: world.bin, - EXPECTED_SHA: sha, - ARTIFACT_SHA: "", - PROJECTS: selectedProjects, + EXPECTED_SHA: world.expectedSha, + ARTIFACT_SHA: artifactSha, + PROJECTS: projectsText, GITHUB_ACTIONS: "true", + GITHUB_API_URL: world.api, + GITHUB_REPOSITORY: repository, + GITHUB_TOKEN: "fake-github-token", + GITHUB_WORKFLOW_REF: `${repository}/${workflowPath}@${workflowRef}`, + GITHUB_WORKFLOW_SHA: world.expectedSha, + GITHUB_RUN_ID: runId, + GITHUB_RUN_ATTEMPT: runAttempt, + STABLE_HANDOFF_DIRECTORY: world.handoffDirectory, + STABLE_HANDOFF_ARTIFACT_ID: artifactId, + STABLE_HANDOFF_ARTIFACT_DIGEST: artifactDigest, NPM_READ_DELAY_MS: "0", + NPM_CONFIG_IGNORE_SCRIPTS: "true", + NPM_CONFIG_PROVENANCE: "true", FINALIZE_COMMAND_TIMEOUT_MS: "5000", - GITHUB_API_URL: world.api, - GITHUB_REPOSITORY: "owner/repo", - GITHUB_TOKEN: "fake", + FINALIZE_HTTP_TIMEOUT_MS: "5000", FAKE_STATE: world.stateFile, ...environment, }, }) - let stdout = "", - stderr = "" + let stdout = "" + let stderr = "" child.stdout.on("data", (chunk) => (stdout += chunk)) child.stderr.on("data", (chunk) => (stderr += chunk)) child.on("close", (status) => resolve({ status, stdout, stderr })) }) } -async function scenario(t, name, setup, verify, mode = "exact", args = [], environment = {}) { - await t.test(name, async () => { - const fixture = await world(mode) - try { - const state = load(fixture.stateFile) - await setup(state, fixture) - save(fixture.stateFile, state) - const result = await run(fixture, args, environment) - await verify(result, load(fixture.stateFile), fixture) - } finally { - await discardWorld(fixture) - } - }) -} -function exactState(state) { - assert.equal(Object.keys(state.tags).length, records.length) - assert.equal(Object.keys(state.releases).length, records.length) - for (const [project, , version, betaVersion] of records) { - assert.ok(state.npm[project].versions.includes(version)) - assert.equal(state.npm[project].latest, version) - assert.equal(state.npm[project].alpha, "alpha-sentinel") - assert.equal(state.npm[project].beta, betaVersion) +async function scenario(t, options, body) { + const world = await makeWorld(options) + try { + await body(world) + } finally { + await discardWorld(world) } } -function historicalTags(state) { - state.artifactSha = historicalSha - for (const [project, , version] of records) state.tags[`${project}@${version}`] = { peeled: historicalSha } -} -function mergeArtifact(state) { - state.commitLine = `${sha} ${parentSha} ${secondParentSha}` - state.commitLines = { [secondParentSha]: `${secondParentSha} ${parentSha}` } -} -function workflowPreflightInvocation() { - const match = stableWorkflow.match( - /^[ \t]*- name: 🔎 PREFLIGHT exact stable artifacts\n([\s\S]*?)(?=^[ \t]*- name:)/m, - ) - assert.ok(match, "stable workflow preflight step") - assert.match(match[1], /PROJECTS:\s*\$\{\{ needs\.validate\.outputs\.projects \}\}/) - const commands = [...match[1].matchAll(/^[ \t]*run:\s*(.+)$/gm)].map((entry) => entry[1].trim()) - assert.deepEqual(commands, ["bash scripts/release-finalize-stable.sh --preflight --json"]) - return { args: commands[0].split(/\s+/).slice(2), source: match[1] } -} -const scenarioNames = [] -test("hermetic arbitrary-subset Node CLI matrix", { timeout: 120_000 }, async (t) => { - const add = async (...args) => { - scenarioNames.push(args[0]) - await scenario(t, ...args) - } - await add( - "single-parent squash or single-commit rebase future subset exact replay locally synchronizes then performs zero tag, Release, or npm mutation", - async () => {}, - (result, state) => { - assert.equal(result.status, 0, result.stderr) - assert.deepEqual(mutations(state), []) - assert.ok( - state.log.some( - (call) => call[0] === "git" && call[1] === "rev-list" && call.includes("--parents") && call.at(-1) === sha, - ), - ) - assert.ok( - state.log.some( - (call) => call[0] === "git" && call[1] === "diff" && call.includes(`${sha}^1`) && call.at(-1) === sha, - ), - ) - }, - ) - await add( - "same-SHA future subset publishes only normalized reviewed projects", - async () => {}, - (result, state) => { - assert.equal(result.status, 0, result.stderr) - exactState(state) - const status = state.log.find((call) => call[0] === "git" && call[1] === "status") - assert.deepEqual(status, ["git", "status", "--porcelain=v1", "--untracked-files=all"]) - const publish = state.log.find((call) => call[0] === "pnpm") - assert.equal(publish[4], `--projects=${selectedProjects}`) - }, - "absent", - ) - await add( - "publish child disables lifecycle scripts while preserving inherited environment", - async () => {}, - (result, state) => { - assert.equal(result.status, 0, result.stderr) - assert.deepEqual(state.publishEnvironment, { - ignoreScripts: "true", - inheritedSentinel: "preserved", - }) - exactState(state) - }, - "absent", - [], - { NPM_CONFIG_IGNORE_SCRIPTS: "false", FINALIZE_ENV_SENTINEL: "preserved" }, - ) - await add( - "exact stable replay does not require beta version or beta tag", - async (state) => { - for (const [project, , version] of records) { - state.npm[project].versions = [version] - delete state.npm[project].beta +await test("stable handoff verification and current-run artifact metadata fail before every public mutation", async (t) => { + await scenario(t, { npmMode: "absent" }, async (world) => { + const handoffPath = join(world.handoffDirectory, "handoff.json") + const handoff = JSON.parse(readFileSync(handoffPath, "utf8")) + handoff.run.id = "33399900012" + writeFileSync(handoffPath, JSON.stringify(handoff)) + const result = await run(world) + const state = load(world.stateFile) + assert.notEqual(result.status, 0) + assert.match(result.stderr, /handoff run ID does not match the current run/i) + assert.deepEqual(mutationCalls(state), []) + assert.equal( + state.log.some(([command]) => command === "npm" || command === "http"), + false, + ) + }) + for (const [name, environment, pattern] of [ + ["missing artifact ID", { STABLE_HANDOFF_ARTIFACT_ID: "" }, /artifact ID/i], + ["malformed artifact digest", { STABLE_HANDOFF_ARTIFACT_DIGEST: "sha256:nope" }, /artifact digest/i], + ["relative handoff directory", { STABLE_HANDOFF_DIRECTORY: "handoff" }, /handoff directory/i], + ["wrong workflow SHA", { GITHUB_WORKFLOW_SHA: advancedSha }, /workflow SHA/i], + ]) { + await t.test(name, async () => { + const world = await makeWorld({ npmMode: "absent" }) + try { + const result = await run(world, [], environment) + assert.notEqual(result.status, 0) + assert.match(result.stderr, pattern) + assert.deepEqual(mutationCalls(load(world.stateFile)), []) + } finally { + await discardWorld(world) } - }, - (result, state) => { - assert.equal(result.status, 0, result.stderr) - assert.deepEqual(mutations(state), []) - }, - ) - await add( - "stable absence with exact reviewed beta provenance publishes", - async () => {}, - (result, state) => { - assert.equal(result.status, 0, result.stderr) - exactState(state) - }, - "absent", - ) - await add( - "atomic tag push uses one-shot GitHub Basic authentication without exposing the token", - async () => {}, - (result, state) => { - assert.equal(result.status, 0, result.stderr) - assert.deepEqual(state.pushAuthentication, { - oneShot: true, - matchesToken: true, - tokenLiteral: false, - }) - assert.equal( - state.log.some( - (call) => call[0] === "git" && call[1] === "config" && /authorization|extraheader/i.test(call.join(" ")), - ), - false, - ) - assert.deepEqual(state.log.find((call) => call[0] === "git" && call[1] === "push"), [ + }) + } +}) + +await test("current exact mode keeps atomic authenticated tags and Releases, then publishes six exact handoff tarballs", async (t) => { + await scenario(t, { npmMode: "absent", artifacts: "absent" }, async (world) => { + const result = await run(world, [], { GITHUB_TOKEN: "tag-push-secret" }) + const state = load(world.stateFile) + assert.equal(result.status, 0, result.stderr) + assertExact(state) + assert.deepEqual(state.pushAuthentication, { oneShot: true, matchesToken: true, tokenLiteral: false }) + assert.deepEqual( + state.log.find(([command, operation]) => command === "git" && operation === "push"), + [ "git", "push", "--atomic", "origin", - ...records.map( - ([project, , version]) => `refs/tags/${project}@${version}:refs/tags/${project}@${version}`, - ), - ]) - assert.doesNotMatch(`${result.stdout}\n${result.stderr}`, /tag-push-secret/) - exactState(state) - }, - "absent", - [], - { GITHUB_TOKEN: "tag-push-secret" }, - ) - for (const [name, token] of [ - ["missing", ""], - ["unsafe", "unsafe token"], - ]) - await add( - `${name} GitHub tag-push authentication fails closed before mutation`, - async () => {}, - (result, state) => { - assert.notEqual(result.status, 0) - assert.match(result.stderr, /tag push requires a safe non-empty GITHUB_TOKEN/) - assert.deepEqual(mutations(state), []) - }, - "absent", - [], - { GITHUB_TOKEN: token }, + ...records.map(({ project, version }) => `refs/tags/${project}@${version}:refs/tags/${project}@${version}`), + ], ) - await add( - "stable absence with no latest publishes", - async (state) => { - for (const [project] of records) delete state.npm[project].latest - }, - (result, state) => { - assert.equal(result.status, 0, result.stderr) - exactState(state) - }, - "absent", - ) - await add( - "prerelease latest strictly below stable target publishes", - async (state) => { - state.npm[records[0][0]].latest = records[0][3] - }, - (result, state) => { - assert.equal(result.status, 0, result.stderr) - exactState(state) - }, - "absent", - ) - await add( - "historical all-existing artifacts perform zero tag, Release, or npm mutation", - async (state) => historicalTags(state), - (result, state) => { - assert.equal(result.status, 0, result.stderr) - assert.deepEqual(mutations(state), []) - }, - "exact", - [], - { ARTIFACT_SHA: historicalSha }, - ) - await add( - "historical all-exact replay returns before checking an advanced current master manifest", - async (state, fixture) => { - historicalTags(state) - for (const [project, path] of records) { - const currentManifest = { name: project, version: "99.0.0", scripts: { build: "current-only" } } - state.gitFiles[`${sha}:${path}`] = currentManifest - writeFileSync(join(fixture.cwd, path), JSON.stringify(currentManifest)) - } - }, - (result, state) => { - assert.equal(result.status, 0, result.stderr) - assert.deepEqual(mutations(state), []) - assert.equal( - state.log.some((call) => call[0] === "git" && call[1] === "status"), - false, - ) - }, - "exact", - [], - { ARTIFACT_SHA: historicalSha }, - ) - await add( - "historical ancestor PREFLIGHT checks ancestry before artifact and registry reads", - async (state) => historicalTags(state), - (result, state) => { - assert.equal(result.status, 0, result.stderr) - assert.deepEqual(mutations(state), []) - const ancestry = state.log.findIndex((call) => call[0] === "git" && call[1] === "merge-base") - const artifactRead = state.log.findIndex((call) => call[0] === "git" && call[1] === "show") - const registryRead = state.log.findIndex((call) => call[0] === "npm") - assert.ok(ancestry >= 0 && ancestry < artifactRead && ancestry < registryRead) - }, - "exact", - ["--preflight"], - { ARTIFACT_SHA: historicalSha }, - ) - await add( - "historical rebased non-ancestor fails before PREFLIGHT verification", - async (state) => { - historicalTags(state) - state.ancestor = false - }, - (result, state) => { - assert.notEqual(result.status, 0) - assert.match(result.stderr, /ancestor/) - assert.deepEqual(mutations(state), []) - assert.equal( - state.log.some((call) => call[0] === "git" && call[1] === "show"), - false, - ) - assert.equal( - state.log.some((call) => call[0] === "npm" || call[0] === "http"), - false, - ) - }, - "exact", - ["--preflight"], - { ARTIFACT_SHA: historicalSha }, - ) - await add( - "historical ancestry command ambiguity fails closed", - async (state) => { - historicalTags(state) - state.ancestorExit = 128 - }, - (result, state) => { - assert.notEqual(result.status, 0) - assert.deepEqual(mutations(state), []) - assert.equal( - state.log.some((call) => call[0] === "git" && call[1] === "show"), - false, - ) - }, - "exact", - ["--preflight"], - { ARTIFACT_SHA: historicalSha }, - ) - await add( - "valid two-parent merge with a single generated release commit based on its first parent succeeds", - async (state) => mergeArtifact(state), - (result, state) => { - assert.equal(result.status, 0, result.stderr) - assert.deepEqual(mutations(state), []) - }, - ) - await add( - "octopus reviewed artifact fails closed", - async (state) => { - state.commitLine = `${sha} ${parentSha} ${secondParentSha} ${thirdParentSha}` - }, - (result, state) => { - assert.notEqual(result.status, 0) - assert.match(result.stderr, /single-parent commit or exact two-parent merge/) - assert.deepEqual(mutations(state), []) - }, - ) - await add( - "merge second parent not based directly on first parent fails closed", - async (state) => { - mergeArtifact(state) - state.commitLines[secondParentSha] = `${secondParentSha} ${thirdParentSha}` - }, - (result, state) => { - assert.notEqual(result.status, 0) - assert.match(result.stderr, /based directly on first parent/) - assert.deepEqual(mutations(state), []) - }, - ) - await add( - "merge second parent with multiple commits fails closed", - async (state) => { - mergeArtifact(state) - state.commitLines[secondParentSha] = `${secondParentSha} ${parentSha} ${thirdParentSha}` - }, - (result, state) => { - assert.notEqual(result.status, 0) - assert.match(result.stderr, /single commit based directly on first parent/) - assert.deepEqual(mutations(state), []) - }, - ) - await add( - "merge tree differing from generated second parent fails closed", - async (state) => { - mergeArtifact(state) - state.artifactTreeSha = "8888888888888888888888888888888888888888" - }, - (result, state) => { - assert.notEqual(result.status, 0) - assert.match(result.stderr, /merge tree must exactly match/) - assert.deepEqual(mutations(state), []) - }, - ) - await add( - "merge aggregate first-parent diff rejects an extra path", - async (state) => { - mergeArtifact(state) - state.changedPaths.push("README.md") - }, - (result, state) => { - assert.notEqual(result.status, 0) - assert.match(result.stderr, /unexpected reviewed path/) - assert.deepEqual(mutations(state), []) - }, - ) - await add( - "merge aggregate first-parent diff rejects an invalid manifest transition", - async (state) => { - mergeArtifact(state) - state.gitFiles[`${sha}:${records[0][1]}`].version = "4.7.1" - }, - (result, state) => { - assert.notEqual(result.status, 0) - assert.match(result.stderr, /beta-to-stable transition/) - assert.deepEqual(mutations(state), []) - }, - ) - await add( - "malformed reviewed commit shape fails closed", - async (state) => { - state.commitLine = "malformed history" - }, - (result, state) => { - assert.notEqual(result.status, 0) - assert.match(result.stderr, /commit shape is invalid/) - assert.deepEqual(mutations(state), []) - }, - ) - await add( - "historical split release commits are not reconstructed by history search", - async (state) => { - historicalTags(state) - state.changedPaths = ["CHANGELOG.md", records[0][1]] - }, - (result, state) => { - assert.notEqual(result.status, 0) - assert.match(result.stderr, /requested projects do not exactly match/) - assert.deepEqual(mutations(state), []) - assert.equal( - state.log.some((call) => call[0] === "git" && call[1] === "log"), - false, - ) - }, - "exact", - ["--preflight"], - { ARTIFACT_SHA: historicalSha }, - ) - await add( - "historical missing tag fails before mutation", - async (state) => { - historicalTags(state) - delete state.tags[`${records[0][0]}@${records[0][2]}`] - }, - (result, state) => { - assert.notEqual(result.status, 0) - assert.match(result.stderr, /historical replay requires exact existing/) - assert.deepEqual(mutations(state), []) - }, - "exact", - [], - { ARTIFACT_SHA: historicalSha }, - ) - for (const [index] of records.entries()) - await add( - `tag partial subset ${index + 1} replays`, - async (state) => { - for (const [project, , version] of records.slice(0, index + 1)) - state.tags[`${project}@${version}`] = { peeled: sha } - }, - (result, state) => { - assert.equal(result.status, 0, result.stderr) - exactState(state) - }, - "absent", + assert.equal( + state.log.filter(([command, operation]) => command === "http" && operation === "POST").length, + records.length, ) - for (const [index] of records.entries()) - await add( - `release partial subset ${index + 1} replays`, - async (state) => { - for (const [project, , version] of records) state.tags[`${project}@${version}`] = { peeled: sha } - for (const [project, , version] of records.slice(0, index + 1)) - state.releases[`${project}@${version}`] = { - tag_name: `${project}@${version}`, - draft: false, - prerelease: false, - } - }, - (result, state) => { - assert.equal(result.status, 0, result.stderr) - exactState(state) - }, - "absent", + const calls = publishCalls(state) + assert.equal(calls.length, publishable.length) + assert.deepEqual( + calls, + publishable.map((record) => [ + "npm", + "publish", + join(world.handoffDirectory, state.packages[record.project].basename), + "--registry", + npmRegistry, + "--access", + "public", + "--tag", + "latest", + "--provenance", + "--ignore-scripts", + "--json", + ]), ) - for (const [index] of records.entries()) - await add( - `npm partial subset ${index + 1} replays`, - async (state) => { - for (const [project, , version] of records) { - state.tags[`${project}@${version}`] = { peeled: sha } - state.releases[`${project}@${version}`] = { - tag_name: `${project}@${version}`, - draft: false, - prerelease: false, - } - } - for (const [project, , version] of records.slice(0, index + 1)) { - state.npm[project].versions = [version] - state.npm[project].latest = version - } - }, - (result, state) => { - assert.equal(result.status, 0, result.stderr) - exactState(state) - }, - "absent", + const registryReads = state.log.filter(([command, operation]) => command === "npm" && operation === "view") + assert.ok(registryReads.length > 0) + for (const call of registryReads) assert.deepEqual(call.slice(-2), ["--registry", npmRegistry]) + assert.equal( + calls.some((call) => call.join(" ").includes("prisma")), + false, ) - await add( - "publish nonzero after subset then replay", - async (state) => { - state.publishSubset = 1 - state.publishExit = 42 - }, - async (result, state, fixture) => { - assert.notEqual(result.status, 0) - delete state.publishExit - delete state.publishSubset - save(fixture.stateFile, state) - const replay = await run(fixture) - assert.equal(replay.status, 0, replay.stderr) - exactState(load(fixture.stateFile)) - }, - "absent", - ) - await add( - "atomic push response loss reconciles exact remote refs", - async (state) => { - state.pushExit = 1 - state.pushMaterializesOnFailure = true - }, - (result, state) => { - assert.equal(result.status, 0, result.stderr) - exactState(state) - }, - "absent", - ) - await add( - "failed atomic push materializes no refs and replay reuses local tags", - async (state) => { - state.pushExit = 1 - }, - async (result, state, fixture) => { - assert.notEqual(result.status, 0) - assert.equal(Object.keys(state.tags).length, 0) - assert.equal(Object.keys(state.localTags).length, records.length) - delete state.pushExit - save(fixture.stateFile, state) - const replay = await run(fixture) - assert.equal(replay.status, 0, replay.stderr) - exactState(load(fixture.stateFile)) - }, - "absent", - ) - await add( - "GitHub create response loss reconciles exact Release", - async (state) => { - state.ghCreateResponseLoss = true - }, - (result, state) => { - assert.equal(result.status, 0, result.stderr) - exactState(state) - }, - "absent", - ) - await add( - "GitHub 422 create reconciles materialized exact release", - async (state) => { - state.ghCreateStatus = 422 - }, - (result, state) => { - assert.equal(result.status, 0, result.stderr) - exactState(state) - }, - "absent", - ) - await add( - "GitHub 422 without exact state fails", - async (state) => { - state.ghCreateStatus = 422 - state.ghCreateMaterializes = false - }, - (result) => assert.notEqual(result.status, 0), - "absent", - ) - for (const [format, value] of [ - ["array", [records[0][2]]], - ["scalar", records[0][2]], - ]) - await add( - `npm ${format} versions JSON`, - async (state) => { - state.npm[records[0][0]].versionsQueue = [value] - }, - (result) => assert.equal(result.status, 0, result.stderr), + assert.equal( + state.log.some(([command]) => command === "pnpm"), + false, ) - await add( - "npm delayed latest converges", - async (state) => { - state.npm[records[0][0]].latestQueue = [records[0][3], records[0][3], records[0][2]] - }, - (result) => assert.equal(result.status, 0, result.stderr), - ) - await add( - "missing reviewed beta version blocks stable publication", - async (state) => { - const [project, , , betaVersion] = records[0] - state.npm[project].versions = state.npm[project].versions.filter((version) => version !== betaVersion) - }, - (result, state) => { - assert.notEqual(result.status, 0) - assert.equal(mutations(state).length, 0) - }, - "absent", - ) - await add( - "missing beta dist-tag blocks stable publication", - async (state) => { - delete state.npm[records[0][0]].beta - }, - (result, state) => { - assert.notEqual(result.status, 0) - assert.equal(mutations(state).length, 0) - }, - "absent", - ) - await add( - "different beta dist-tag blocks stable publication", - async (state) => { - const project = records[0][0], - other = "4.7.0-beta.11" - state.npm[project].versions.push(other) - state.npm[project].beta = other - }, - (result, state) => { - assert.notEqual(result.status, 0) - assert.equal(mutations(state).length, 0) - }, - "absent", - ) - await add( - "beta provenance loss before npm publish blocks pnpm mutation", - async (state) => { - const [project, , , betaVersion] = records[0] - const good = { alpha: "alpha-sentinel", beta: betaVersion, latest: "0.0.1" } - const bad = { alpha: "alpha-sentinel", beta: "4.7.0-beta.11", latest: "0.0.1" } - state.npm[project].distTagsQueue = [good, ...Array(6).fill(bad)] - }, - (result, state) => { - assert.notEqual(result.status, 0) - assert.equal( - state.log.some((call) => call[0] === "pnpm"), - false, - ) - }, - "absent", - ) - await add( - "concurrent exact stable appearance is omitted from missing-only publish", - async (state) => { - const [project, , version, betaVersion] = records[0] - state.npm[project].versionsQueue = [ - ["0.0.1", betaVersion], - [betaVersion, version], - ] - state.npm[project].distTagsQueue = [ - { alpha: "alpha-sentinel", beta: betaVersion, latest: "0.0.1" }, - { alpha: "alpha-sentinel", beta: betaVersion, latest: version }, - ] - }, - (result, state) => { - assert.equal(result.status, 0, result.stderr) - const publish = state.log.find((call) => call[0] === "pnpm") - assert.equal(publish[4], `--projects=${records[1][0]}`) - exactState(state) - }, - "absent", - ) - await add( - "latest tag absent from versions list blocks publication", - async (state) => { - state.npm[records[0][0]].latest = records[0][2] - }, - (result, state) => { - assert.notEqual(result.status, 0) - assert.equal(mutations(state).length, 0) - }, - "absent", - ) - await add( - "higher latest blocks publication from moving latest backward", - async (state) => { - const project = records[0][0], - higher = "4.7.1" - state.npm[project].versions.push(higher) - state.npm[project].latest = higher - }, - (result, state) => { - assert.notEqual(result.status, 0) - assert.equal(mutations(state).length, 0) - }, - "absent", - ) - await add( - "latest not present in versions is inconsistent", - async (state) => { - state.npm[records[0][0]].latest = "1.0.0" - }, - (result, state) => { - assert.notEqual(result.status, 0) - assert.equal(mutations(state).length, 0) - }, - "absent", - ) - await add( - "latest SemVer with a leading zero fails closed", - async (state) => { - const project = records[0][0], - malformed = "01.0.0" - state.npm[project].versions.push(malformed) - state.npm[project].latest = malformed - }, - (result, state) => { - assert.notEqual(result.status, 0) - assert.equal(mutations(state).length, 0) - }, - "absent", - ) - await add( - "malformed historical version list entry fails closed", - async (state) => { - state.npm[records[0][0]].versions.push("1.02.3") - }, - (result, state) => { - assert.notEqual(result.status, 0) - assert.equal(mutations(state).length, 0) - }, - "absent", - ) - await add( - "duplicate npm versions fail closed", - async (state) => { - state.npm[records[0][0]].versions.push(records[0][3]) - }, - (result, state) => { - assert.notEqual(result.status, 0) - assert.equal(mutations(state).length, 0) - }, - "absent", - ) - await add( - "malformed dist-tags JSON fails closed", - async (state) => { - state.npm[records[0][0]].distTagsQueue = Array(6).fill({ raw: "{" }) - }, - (result, state) => { - assert.notEqual(result.status, 0) - assert.equal(mutations(state).length, 0) - }, - "absent", - ) - await add( - "post-publish delayed version visibility converges", - async (state) => { - state.npm[records[0][0]].delayedVersions = 2 - }, - (result, state) => { - assert.equal(result.status, 0, result.stderr) - exactState(state) - }, - "absent", - ) - await add( - "post-publish delayed latest converges", - async (state) => { - state.npm[records[0][0]].delayedLatest = 2 - }, - (result, state) => { - assert.equal(result.status, 0, result.stderr) - exactState(state) - }, - "absent", - ) - for (const [name, spec] of [ - ["null", null], - ["empty", { raw: "" }], - ["truncated", { raw: '["1.0' }], - ["object", {}], - ["mixed", [records[0][2], 3]], - ["execution error", { exit: 1, stderr: "E503" }], - ]) - await add( - `npm ${name} is unknown and never publishes`, - async (state) => { - state.npm[records[0][0]].versionsQueue = Array(6).fill(spec) - }, - (result, state) => { - assert.notEqual(result.status, 0) - assert.equal(mutations(state).length, 0) + }) +}) + +await test("abandoned Prisma is terminally absent while exact integrity and provenance make replay idempotent", async (t) => { + await scenario(t, { npmMode: "exact" }, async (world) => { + const first = await run(world) + assert.equal(first.status, 0, first.stderr) + let state = load(world.stateFile) + assert.deepEqual(mutationCalls(state), []) + assertExact(state) + state.log = [] + save(world.stateFile, state) + const replay = await run(world) + state = load(world.stateFile) + assert.equal(replay.status, 0, replay.stderr) + assert.deepEqual(mutationCalls(state), []) + assert.equal(state.npm[prisma.project].versions.includes(prisma.version), false) + }) +}) + +await test("partial npm state publishes only missing tarballs and an interrupted per-package run replays safely", async (t) => { + await scenario(t, { npmMode: "absent" }, async (world) => { + let state = load(world.stateFile) + setNpmExact(state, publishable[0]) + setNpmExact(state, publishable[1]) + state.publishFailures = { + [publishable[3].project]: { + exit: 42, + stderr: JSON.stringify({ error: { code: "E503", summary: "temporary registry failure" } }), }, + } + save(world.stateFile, state) + const first = await run(world) + state = load(world.stateFile) + assert.notEqual(first.status, 0) + assert.equal(publishCalls(state).length, 2) + assert.equal(state.npm[publishable[2].project].versions.includes(publishable[2].version), true) + delete state.publishFailures + state.log = [] + save(world.stateFile, state) + const replay = await run(world) + state = load(world.stateFile) + assert.equal(replay.status, 0, replay.stderr) + assert.deepEqual( + publishCalls(state).map((call) => basename(call[2])), + publishable.slice(3).map((record) => state.packages[record.project].basename), ) - await add( - "GitHub non-200 read is unknown", - async (state) => { - state.ghReadStatus = 503 - }, - (result, state) => { - assert.notEqual(result.status, 0) - assert.equal(mutations(state).length, 0) - }, - ) - for (const [name, raw] of [ - ["lightweight", `${"a".repeat(40)}\trefs/tags/$TAG\n`], - ["malformed", "garbage\n"], - ["wrong SHA", `${"a".repeat(40)}\trefs/tags/$TAG\n${"f".repeat(40)}\trefs/tags/$TAG^{}\n`], - ["duplicate", `${"a".repeat(40)}\trefs/tags/$TAG\n${"b".repeat(40)}\trefs/tags/$TAG\n${sha}\trefs/tags/$TAG^{}\n`], - ]) - await add( - `tag ${name} fails closed`, - async (state) => { - state.tags[`${records[0][0]}@${records[0][2]}`] = { raw } - }, - (result, state) => { + assertExact(state) + }) +}) + +await test("preexisting exact versions are accepted only with handoff integrity, shasum, and provenance", async (t) => { + for (const [name, mutate, pattern] of [ + ["integrity", (dist) => (dist.integrity = "sha512-wrong"), /integrity|npm state divergence/i], + ["shasum", (dist) => (dist.shasum = "0".repeat(40)), /shasum|npm state divergence/i], + ["provenance", (dist) => delete dist.attestations.provenance, /provenance|npm state divergence/i], + [ + "attestation URL", + (dist) => (dist.attestations.url = "https://example.com/attestation"), + /attestation|npm state divergence/i, + ], + ]) { + await t.test(name, async () => { + const world = await makeWorld({ npmMode: "exact" }) + try { + const state = load(world.stateFile) + mutate(state.npm[publishable[0].project].dist) + save(world.stateFile, state) + const result = await run(world) assert.notEqual(result.status, 0) - assert.equal(mutations(state).length, 0) - }, - "absent", + assert.match(result.stderr, pattern) + assert.deepEqual(mutationCalls(load(world.stateFile)), []) + } finally { + await discardWorld(world) + } + }) + } +}) + +await test("npm attestation URLs match real scoped metadata semantically and reject boundary changes", async (t) => { + await scenario(t, { npmMode: "exact" }, async (world) => { + const target = publishable[0] + let state = load(world.stateFile) + assert.equal( + state.npm[target.project].dist.attestations.url, + "https://registry.npmjs.org/-/npm/v1/attestations/@effectify%2fhatchet@0.2.0", ) - for (const [name, value] of [ - ["lightweight", { type: "commit", peeled: sha }], - ["wrong SHA", { type: "tag", peeled: "f".repeat(40) }], - ]) - await add( - `local tag ${name} fails before mutation`, - async (state) => { - state.localTags[`${records[0][0]}@${records[0][2]}`] = value - }, - (result, state) => { - assert.notEqual(result.status, 0) - assert.equal(mutations(state).length, 0) + state.npm[target.project].dist.attestations.url = + "https://registry.npmjs.org/-/npm/v1/attestations/@effectify%2Fhatchet@0.2.0" + save(world.stateFile, state) + const uppercaseHex = await run(world) + assert.equal(uppercaseHex.status, 0, uppercaseHex.stderr) + + for (const invalidUrl of [ + "http://registry.npmjs.org/-/npm/v1/attestations/@effectify%2fhatchet@0.2.0", + "https://registry.npmjs.org.evil.example/-/npm/v1/attestations/@effectify%2fhatchet@0.2.0", + "https://registry.npmjs.org/npm/v1/attestations/@effectify%2fhatchet@0.2.0", + "https://registry.npmjs.org/-/npm/v1/attestations/@effectify%2fhatchet@^0.2.0", + "https://user@registry.npmjs.org/-/npm/v1/attestations/@effectify%2fhatchet@0.2.0", + "https://registry.npmjs.org/-/npm/v1/attestations/@effectify%2fhatchet@0.2.0?download=true", + "https://registry.npmjs.org/-/npm/v1/attestations/@effectify%2fhatchet@0.2.0#fragment", + ]) { + state = load(world.stateFile) + state.npm[target.project].dist.attestations.url = invalidUrl + save(world.stateFile, state) + const result = await run(world) + assert.notEqual(result.status, 0, invalidUrl) + assert.match(result.stderr, /attestation URL|npm state divergence/i) + assert.deepEqual(mutationCalls(load(world.stateFile)), []) + } + }) +}) + +await test("npm publish response loss and delayed registry visibility reconcile before retrying", async (t) => { + await scenario(t, { npmMode: "absent" }, async (world) => { + const state = load(world.stateFile) + state.publishFailures = { + [publishable[0].project]: { + exit: 1, + stderr: "ECONNRESET after request upload", + materialize: true, }, - "absent", + } + save(world.stateFile, state) + const result = await run(world) + const final = load(world.stateFile) + assert.equal(result.status, 0, result.stderr) + assert.equal( + publishCalls(final).filter((call) => call[2].endsWith(state.packages[publishable[0].project].basename)).length, + 1, ) - for (const [name, status] of [ - ["dirty tracked source", " M packages/future/nebula/src/index.ts\n"], - ["staged changes", "M packages/future/nebula/src/index.ts\n"], - ["untracked package file", "?? packages/future/nebula/src/generated.js\n"], - ]) - await add( - `${name} fails before mutation`, - async (state) => { - state.worktreeStatus = status - }, - (result, state) => { - assert.notEqual(result.status, 0) - assert.match(result.stderr, /clean index and worktree/) - assert.equal(mutations(state).length, 0) + assertExact(final) + }) + + await scenario(t, { npmMode: "absent" }, async (world) => { + const target = publishable[0] + const state = load(world.stateFile) + const npm = state.npm[target.project] + const baselineVersions = [...npm.versions] + const baselineTags = { alpha: npm.alpha, beta: npm.beta, latest: npm.latest } + setNpmExact(state, target) + const targetVersions = [...npm.versions] + const targetTags = { alpha: npm.alpha, beta: npm.beta, latest: npm.latest } + const exactDist = state.packages[target.project].dist + npm.versionsQueue = [baselineVersions, baselineVersions, targetVersions, targetVersions, targetVersions] + npm.distTagsQueue = [baselineTags, baselineTags, baselineTags, targetTags, targetTags] + npm.distQueue = [ + { + integrity: exactDist.integrity, + shasum: exactDist.shasum, + attestations: { url: exactDist.attestations.url }, }, - "absent", + exactDist, + ] + state.publishFailures = { + [target.project]: { exit: 1, stderr: "ECONNRESET after request upload" }, + } + save(world.stateFile, state) + + const result = await run(world) + const final = load(world.stateFile) + assert.equal(result.status, 0, result.stderr) + assert.equal( + publishCalls(final).filter((call) => call[2].endsWith(state.packages[target.project].basename)).length, + 1, ) - for (const [name, manifest] of [ - ["altered on-disk selected manifest name", { name: "@future/imposter", version: records[0][2] }], - ["altered on-disk selected manifest version", { name: records[0][0], version: "99.0.0" }], - ]) - await add( - `${name} fails before mutation`, - async (state, fixture) => { - writeFileSync(join(fixture.cwd, records[0][1]), JSON.stringify(manifest)) - }, - (result, state) => { - assert.notEqual(result.status, 0) - assert.match(result.stderr, /on-disk manifest/) - assert.equal(mutations(state).length, 0) + assertExact(final) + }) + + await scenario(t, { npmMode: "absent" }, async (world) => { + const target = publishable[0] + const state = load(world.stateFile) + const npm = state.npm[target.project] + const baselineVersions = [...npm.versions] + const baselineTags = { alpha: npm.alpha, beta: npm.beta, latest: npm.latest } + setNpmExact(state, target) + const targetVersions = [...npm.versions] + npm.latest = baselineTags.latest + npm.versionsQueue = [baselineVersions, baselineVersions, ...Array.from({ length: 6 }, () => targetVersions)] + npm.distTagsQueue = [baselineTags, baselineTags, ...Array.from({ length: 6 }, () => baselineTags)] + state.publishFailures = { + [target.project]: { exit: 1, stderr: "ECONNRESET after request upload" }, + } + save(world.stateFile, state) + + const result = await run(world) + const final = load(world.stateFile) + assert.notEqual(result.status, 0) + assert.match(result.stderr, /permanent npm state divergence \(latest\)/i) + const publishIndex = final.log.findIndex(([command, operation]) => command === "npm" && operation === "publish") + const reconciliationReads = final.log + .slice(publishIndex + 1) + .filter( + ([command, operation, name, field]) => + command === "npm" && operation === "view" && name === target.project && field === "versions", + ) + assert.equal(reconciliationReads.length, 6) + }) +}) + +await test("npm failures are reconciled and reported with bounded sanitized fixed guidance", async (t) => { + await scenario(t, { npmMode: "absent" }, async (world) => { + const target = publishable[0] + const jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJzZWNyZXQifQ.signaturevalue" + const secretValues = [ + "bearer-secret", + jwt, + "npm_ABCDEFGHIJKLMNOPQRSTUVWXYZ123456", + "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ123456", + "auth-token-secret", + "url-password", + "query-secret", + ] + const noisy = `\u001b[31mBearer bearer-secret\u001b[0m ${jwt} npm_ABCDEFGHIJKLMNOPQRSTUVWXYZ123456 ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ123456 _authToken=auth-token-secret https://user:url-password@example.test/path?token=query-secret ${"detail ".repeat(2000)}` + const state = load(world.stateFile) + state.publishFailures = { + [target.project]: { + exit: 1, + stderr: JSON.stringify({ error: { code: "E401", summary: noisy, detail: noisy } }), }, - "absent", + } + save(world.stateFile, state) + const result = await run(world) + const final = load(world.stateFile) + assert.notEqual(result.status, 0) + assert.ok(result.stderr.length < 5000, `diagnostic length: ${result.stderr.length}`) + for (const secret of secretValues) + assert.doesNotMatch(result.stderr, new RegExp(secret.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))) + assert.doesNotMatch(result.stderr, /\u001b|\x1b|\[31m/) + assert.match(result.stderr, /trusted publishing authentication failed/i) + assert.match(result.stderr, /E401/) + const publishIndex = final.log.findIndex(([command, operation]) => command === "npm" && operation === "publish") + const laterRegistryRead = final.log.findIndex( + ([command, operation], index) => index > publishIndex && command === "npm" && operation === "view", ) - await add( - "altered on-disk selected manifest dependency fails before mutation", - async (state, fixture) => { - const path = records[0][1], - reviewed = state.gitFiles[`${sha}:${path}`] - reviewed.dependencies = { "reviewed-dependency": "1.0.0" } - writeFileSync( - join(fixture.cwd, path), - JSON.stringify({ ...reviewed, dependencies: { "reviewed-dependency": "2.0.0" } }), + assert.ok(publishIndex >= 0 && laterRegistryRead > publishIndex) + }) +}) + +await test("safe tracked npm configuration is accepted at the trusted-publishing boundary", async (t) => { + await scenario(t, { npmMode: "absent" }, async (world) => { + const state = load(world.stateFile) + state.trackedNpmrc = ".npmrc\0" + writeFileSync(join(world.cwd, ".npmrc"), safeNpmrc) + save(world.stateFile, state) + + const result = await run(world) + assert.equal(result.status, 0, result.stderr) + assert.equal(publishCalls(load(world.stateFile)).length, publishable.length) + }) +}) + +await test("trusted npmjs boundary accepts safe global config and rejects registry or npm config overrides", async (t) => { + await scenario(t, { npmMode: "absent" }, async (world) => { + const state = load(world.stateFile) + state.globalConfigPath = join(world.cwd, "global.npmrc") + writeFileSync(state.globalConfigPath, safeNpmrc) + save(world.stateFile, state) + + const result = await run(world) + const final = load(world.stateFile) + assert.equal(result.status, 0, result.stderr) + for (const expectedCall of [ + ["npm", "config", "get", "registry", "--json"], + ["npm", "config", "get", "userconfig", "--json"], + ["npm", "config", "get", "globalconfig", "--json"], + ]) { + assert.ok( + final.log.some((call) => isDeepStrictEqual(call, expectedCall)), + JSON.stringify(expectedCall), ) - }, - (result, state) => { - assert.notEqual(result.status, 0) - assert.match(result.stderr, /on-disk manifest/) - assert.equal(mutations(state).length, 0) - }, - "absent", - ) - await add( - "EXPECTED_SHA controls HEAD", - async (state) => { - state.head = "f".repeat(40) - }, - (result, state) => { - assert.notEqual(result.status, 0) - assert.equal(mutations(state).length, 0) - }, - ) - await add( - "EXPECTED_SHA controls origin", - async (state) => { - state.origin = "f".repeat(40) - }, - (result, state) => { - assert.notEqual(result.status, 0) - assert.equal(mutations(state).length, 0) - }, - ) + } + assert.equal(publishCalls(final).length, publishable.length) + }) - for (const [name, setup, environment, pattern] of [ + for (const [name, environment, forbiddenValue] of [ + ["registry override", { NPM_CONFIG_REGISTRY: "https://registry.example.test/" }, "registry.example.test"], + ["lowercase userconfig override", { npm_config_userconfig: "/tmp/alternate-userconfig" }, "alternate-userconfig"], + ["unexpected npm config override", { NPM_CONFIG_CACHE: "/tmp/npm-cache-override" }, "npm-cache-override"], + ["wrong provenance value", { NPM_CONFIG_PROVENANCE: "false" }, "false"], + ]) { + await t.test(name, async () => { + const world = await makeWorld({ npmMode: "absent" }) + try { + const result = await run(world, [], environment) + const final = load(world.stateFile) + assert.notEqual(result.status, 0) + assert.match(result.stderr, /npm.*configuration|publication boundary/i) + assert.equal(publishCalls(final).length, 0) + assert.doesNotMatch(result.stderr, new RegExp(forbiddenValue)) + } finally { + await discardWorld(world) + } + }) + } + + await scenario(t, { npmMode: "absent" }, async (world) => { + const state = load(world.stateFile) + state.registry = "https://registry.example.test/" + save(world.stateFile, state) + const result = await run(world) + const final = load(world.stateFile) + assert.notEqual(result.status, 0) + assert.match(result.stderr, /npm registry|publication boundary/i) + assert.equal(publishCalls(final).length, 0) + assert.doesNotMatch(result.stderr, /registry\.example\.test/) + }) +}) + +await test("static credentials and auth-bearing tracked, user, or global npm configuration are rejected and redacted", async (t) => { + for (const [name, setup, environment, secret] of [ + ["NODE_AUTH_TOKEN", async () => {}, { NODE_AUTH_TOKEN: "static-secret" }, "static-secret"], + ["NPM_TOKEN", async () => {}, { NPM_TOKEN: "static-secret" }, "static-secret"], [ - "requested projects must include every reviewed manifest", - async () => {}, - { PROJECTS: records[0][0] }, - /requested projects do not exactly match reviewed manifest changes/, + "tracked scoped registry auth token", + async (state, world) => { + state.trackedNpmrc = ".npmrc\0" + writeFileSync(join(world.cwd, ".npmrc"), "//registry.npmjs.org/:_authToken=tracked-token-secret\n") + }, + {}, + "tracked-token-secret", ], [ - "requested projects cannot include an unreviewed allowlisted project", - async () => {}, - { PROJECTS: [records[0][0], catalog[1][0], records[1][0]].sort().join(",") }, - /requested projects do not exactly match reviewed manifest changes/, + "tracked base64 auth", + async (state, world) => { + state.trackedNpmrc = ".npmrc\0" + writeFileSync(join(world.cwd, ".npmrc"), "_auth=tracked-auth-secret\n") + }, + {}, + "tracked-auth-secret", ], [ - "duplicate requested projects fail closed", - async () => {}, - { PROJECTS: `${records[0][0]},${records[0][0]}` }, - /duplicate requested project/, + "user password", + async (state, world) => { + state.userConfigPath = join(world.cwd, "user.npmrc") + writeFileSync(state.userConfigPath, "//registry.npmjs.org/:_password=user-password-secret\n") + }, + {}, + "user-password-secret", ], [ - "non-release requested project fails closed", - async () => {}, - { PROJECTS: "@future/not-release" }, - /not in artifact release projects/, + "global auth token", + async (state, world) => { + state.globalConfigPath = join(world.cwd, "global.npmrc") + writeFileSync(state.globalConfigPath, "//registry.npmjs.org/:_authToken=global-token-secret\n") + }, + {}, + "global-token-secret", ], [ - "reviewed diff requires root changelog", - async (state) => { - state.changedPaths = state.changedPaths.filter((path) => path !== "CHANGELOG.md") + "user username", + async (state, world) => { + state.userConfigPath = join(world.cwd, "user.npmrc") + writeFileSync(state.userConfigPath, "//registry.npmjs.org/:username=user-name-secret\n") }, {}, - /root CHANGELOG/, + "user-name-secret", ], [ - "reviewed diff rejects extra path", - async (state) => { - state.changedPaths.push("README.md") + "user token form", + async (state, world) => { + state.userConfigPath = join(world.cwd, "user.npmrc") + writeFileSync(state.userConfigPath, "token=user-token-secret\n") }, {}, - /unexpected reviewed path/, + "user-token-secret", ], [ - "reviewed diff rejects stable source", - async (state) => { - state.gitFiles[`${sha}^1:${records[0][1]}`].version = records[0][2] + "environment token interpolation", + async (state, world) => { + state.userConfigPath = join(world.cwd, "user.npmrc") + writeFileSync(state.userConfigPath, "cache=/tmp/${NPM_TOKEN_INTERPOLATION_SECRET}\n") }, {}, - /beta-to-stable transition/, + "NPM_TOKEN_INTERPOLATION_SECRET", + ], + ]) { + await t.test(name, async () => { + const world = await makeWorld({ npmMode: "absent" }) + try { + const state = load(world.stateFile) + await setup(state, world) + save(world.stateFile, state) + const result = await run(world, [], environment) + const final = load(world.stateFile) + assert.notEqual(result.status, 0) + assert.match(result.stderr, /static npm credential|npm auth configuration/i) + assert.equal(publishCalls(final).length, 0) + assert.doesNotMatch(result.stderr, new RegExp(secret)) + } finally { + await discardWorld(world) + } + }) + } +}) + +await test("npm configuration inspection rejects unsafe files and ambiguous state without echoing content", async (t) => { + for (const [name, setup, secret] of [ + [ + "control character", + async (state, world) => { + state.trackedNpmrc = ".npmrc\0" + writeFileSync(join(world.cwd, ".npmrc"), "hoist=false\0control-secret\n") + }, + "control-secret", ], [ - "reviewed diff rejects a target other than beta base", - async (state) => { - state.gitFiles[`${sha}:${records[0][1]}`].version = "4.7.1" + "oversized file", + async (state, world) => { + state.userConfigPath = join(world.cwd, "user.npmrc") + writeFileSync(state.userConfigPath, `hoist=false\n# ${"oversized-secret".repeat(5000)}\n`) }, - {}, - /beta-to-stable transition/, + "oversized-secret", + ], + [ + "symlink", + async (state, world) => { + const target = join(world.cwd, "symlink-target.npmrc") + state.userConfigPath = join(world.cwd, "user.npmrc") + writeFileSync(target, "_authToken=symlink-secret\n") + symlinkSync(target, state.userConfigPath) + }, + "symlink-secret", + ], + [ + "nonregular file", + async (state, world) => { + state.userConfigPath = join(world.cwd, "user.npmrc") + mkdirSync(state.userConfigPath) + }, + "not-present", ], [ - "reviewed diff rejects package rename", + "unreadable path state", async (state) => { - state.gitFiles[`${sha}:${records[0][1]}`].name = "@future/renamed" + state.userConfigPath = "/dev/null/user.npmrc" }, - {}, - /manifest identity/, + "not-present", ], - ]) - await add( - name, - setup, - (result, state) => { + [ + "ambiguous tracked path list", + async (state, world) => { + state.trackedNpmrc = ".npmrc" + writeFileSync(join(world.cwd, ".npmrc"), safeNpmrc) + }, + "not-present", + ], + ]) { + await t.test(name, async () => { + const world = await makeWorld({ npmMode: "absent" }) + try { + const state = load(world.stateFile) + await setup(state, world) + save(world.stateFile, state) + const result = await run(world) + const final = load(world.stateFile) + assert.notEqual(result.status, 0) + assert.match(result.stderr, /npm auth configuration/i) + assert.equal(publishCalls(final).length, 0) + assert.doesNotMatch(result.stderr, new RegExp(secret)) + } finally { + await discardWorld(world) + } + }) + } +}) + +await test("historical recovery is bounded, allowlisted, npm-only, and reports abandonment in read-only PREFLIGHT", async (t) => { + await scenario(t, { historical: true, npmMode: "absent" }, async (world) => { + const preflight = await run(world, ["--preflight", "--json"], { GITHUB_ACTIONS: "" }) + let state = load(world.stateFile) + assert.equal(preflight.status, 0, preflight.stderr) + const report = JSON.parse(preflight.stdout) + assert.equal(report.mode, "historical-npm-only") + assert.equal(report.historicalNpmOnly, true) + assert.deepEqual( + report.abandonments.map(({ project, version }) => ({ project, version })), + [{ project: prisma.project, version: prisma.version }], + ) + assert.equal(report.states.find(({ project }) => project === prisma.project).npm, "absent-abandoned") + assert.deepEqual(mutationCalls(state), []) + state.log = [] + save(world.stateFile, state) + const result = await run(world) + state = load(world.stateFile) + assert.equal(result.status, 0, result.stderr) + assert.equal(publishCalls(state).length, publishable.length) + assert.equal( + mutationCalls(state).some(([command, operation]) => command === "git" && ["tag", "push"].includes(operation)), + false, + ) + assert.equal( + mutationCalls(state).some(([command, operation]) => command === "http" && operation === "POST"), + false, + ) + assertExact(state) + }) +}) + +await test("historical recovery rejects excessive commits and every changed path outside the exact control-file allowlist", async (t) => { + for (const [name, setup, pattern] of [ + ["too many commits", (state) => (state.historicalCount = 9), /commit-count bound/i], + ["application path", (state) => state.historicalPaths.push("packages/hatchet/src/index.ts"), /changed path/i], + ["rename-like unexpected path", (state) => state.historicalPaths.push("README.md"), /changed path/i], + ]) { + await t.test(name, async () => { + const world = await makeWorld({ historical: true, npmMode: "absent" }) + try { + const state = load(world.stateFile) + setup(state) + save(world.stateFile, state) + const result = await run(world) + const final = load(world.stateFile) assert.notEqual(result.status, 0) assert.match(result.stderr, pattern) - assert.deepEqual(mutations(state), []) + assert.deepEqual(mutationCalls(final), []) + assert.equal( + final.log.some(([command]) => command === "npm" || command === "http"), + false, + ) + } finally { + await discardWorld(world) + } + }) + } + assert.deepEqual([...allowedHistoricalPaths].sort(), allowedHistoricalPaths) +}) + +await test("historical PREFLIGHT requires exact existing tags and Releases without mutation", async (t) => { + for (const [name, setup, pattern] of [ + [ + "missing tag", + (state) => delete state.tags[`${publishable[0].project}@${publishable[0].version}`], + /historical.*tag/i, + ], + [ + "missing Release", + (state) => delete state.releases[`${publishable[0].project}@${publishable[0].version}`], + /historical.*GitHub Release/i, + ], + ]) { + await t.test(name, async () => { + const world = await makeWorld({ historical: true, npmMode: "absent" }) + try { + const state = load(world.stateFile) + setup(state) + save(world.stateFile, state) + const result = await run(world, ["--preflight", "--json"], { GITHUB_ACTIONS: "" }) + const final = load(world.stateFile) + assert.notEqual(result.status, 0) + assert.match(result.stderr, pattern) + assert.deepEqual(mutationCalls(final), []) + } finally { + await discardWorld(world) + } + }) + } +}) + +await test("historical recovery requires all tags and Releases exact and abandoned Prisma absent", async (t) => { + for (const [name, setup, pattern] of [ + [ + "missing tag", + (state) => delete state.tags[`${publishable[0].project}@${publishable[0].version}`], + /historical.*tag/i, + ], + [ + "missing Release", + (state) => delete state.releases[`${publishable[0].project}@${publishable[0].version}`], + /historical.*GitHub Release/i, + ], + [ + "published abandoned Prisma", + (state) => { + state.npm[prisma.project].versions.push(prisma.version) + state.npm[prisma.project].latest = prisma.version }, - "exact", - [], - environment, - ) + /abandoned.*remain absent/i, + ], + ]) { + await t.test(name, async () => { + const world = await makeWorld({ historical: true, npmMode: "absent" }) + try { + const state = load(world.stateFile) + setup(state) + save(world.stateFile, state) + const result = await run(world) + const final = load(world.stateFile) + assert.notEqual(result.status, 0) + assert.match(result.stderr, pattern) + assert.deepEqual(mutationCalls(final), []) + } finally { + await discardWorld(world) + } + }) + } +}) - await add( - "a changed-path changelog entry cannot substitute for an artifact changelog blob", - async (state) => { - assert.ok(state.changedPaths.includes("CHANGELOG.md")) - delete state.gitFiles[`${sha}:CHANGELOG.md`] - }, - (result, state) => { - assert.notEqual(result.status, 0) - assert.match(result.stderr, /root CHANGELOG\.md to exist as a blob/) - assert.deepEqual(mutations(state), []) - assert.equal( - state.log.some((call) => call[0] === "npm" || call[0] === "http"), - false, - ) - }, - ) +await test("current tag and Release response loss still reconcile exact authenticated state", async (t) => { + await scenario(t, { npmMode: "absent", artifacts: "absent" }, async (world) => { + const state = load(world.stateFile) + state.pushExit = 1 + state.pushMaterializesOnFailure = true + state.ghCreateResponseLoss = true + save(world.stateFile, state) + const result = await run(world) + assert.equal(result.status, 0, result.stderr) + assertExact(load(world.stateFile)) + }) +}) - await add( - "FINALIZE refuses to run outside GitHub Actions", - async () => {}, - (result, state) => { - assert.notEqual(result.status, 0) - assert.match(result.stderr, /GitHub Actions/) - assert.deepEqual(mutations(state), []) - }, - "absent", - [], - { GITHUB_ACTIONS: "" }, - ) - const preflight = workflowPreflightInvocation() - assert.doesNotMatch( - preflight.source, - /NODE_AUTH_TOKEN|NPM_CONFIG_PROVENANCE|npm whoami|nx release publish|git (?:tag|push)|gh release (?:create|delete)/, - ) - await add( - "PREFLIGHT locally synchronizes before SHA authorization and performs zero tag, Release, or npm mutation", - async () => {}, - (result, state) => { - assert.equal(result.status, 0, result.stderr) - const output = JSON.parse(result.stdout) - assert.deepEqual(output.projects, selectedProjects.split(",")) - assert.equal(output.expectedSha, sha) - assert.equal(output.artifactSha, sha) - const fetch = state.log.findIndex( - (call) => - call[0] === "git" && call.slice(1).join(" ") === "fetch origin master:refs/remotes/origin/master --no-tags", - ) - const authorization = state.log.findIndex( - (call) => call[0] === "git" && call[1] === "rev-parse" && call[2] === "origin/master", - ) - assert.ok(fetch >= 0 && fetch < authorization) - assert.deepEqual(mutations(state), []) - }, - "exact", - preflight.args, - { GITHUB_ACTIONS: "" }, - ) - assert.equal(new Set(scenarioNames).size, scenarioNames.length) +await test("reviewed artifact lineage and exact project derivation remain fail closed before mutation", async (t) => { + for (const [name, setup, pattern, environment = {}] of [ + ["unexpected reviewed path", (state) => state.changedPaths.push("README.md"), /unexpected reviewed path/i], + [ + "project mismatch", + () => {}, + /requested projects do not exactly match/i, + { PROJECTS: projects.slice(1).join(",") }, + ], + [ + "invalid merge shape", + (state) => (state.commitLine = `${artifactSha} ${parentSha} ${secondParentSha} ${"8".repeat(40)}`), + /single-parent commit or exact two-parent merge/i, + ], + ]) { + await t.test(name, async () => { + const world = await makeWorld({ npmMode: "exact" }) + try { + const state = load(world.stateFile) + setup(state) + save(world.stateFile, state) + const result = await run(world, [], environment) + assert.notEqual(result.status, 0) + assert.match(result.stderr, pattern) + assert.deepEqual(mutationCalls(load(world.stateFile)), []) + } finally { + await discardWorld(world) + } + }) + } +}) + +await test("FINALIZE retains the GitHub Actions-only publication guard while PREFLIGHT is read-only", async (t) => { + await scenario(t, { npmMode: "absent" }, async (world) => { + const result = await run(world, [], { GITHUB_ACTIONS: "" }) + assert.notEqual(result.status, 0) + assert.match(result.stderr, /GitHub Actions/i) + assert.deepEqual(mutationCalls(load(world.stateFile)), []) + }) + await scenario(t, { npmMode: "absent" }, async (world) => { + const result = await run(world, ["--preflight", "--json"], { GITHUB_ACTIONS: "" }) + assert.equal(result.status, 0, result.stderr) + const output = JSON.parse(result.stdout) + assert.equal(output.mode, "current-exact") + assert.equal(output.historicalNpmOnly, false) + assert.equal(output.artifactId, artifactId) + assert.equal(output.artifactDigest, artifactDigest) + assert.deepEqual(mutationCalls(load(world.stateFile)), []) + }) }) -test("importing with a nonexistent argv entry is inert", async () => { +await test("importing with a nonexistent argv entry is inert", async () => { const cwd = mkdtempSync(join(tmpdir(), "stable-finalize-import-")) const previousEntry = process.argv[1] const previousExitCode = process.exitCode const previousStderrWrite = process.stderr.write const stderr = [] try { - process.argv[1] = join(cwd, "guaranteed-missing-entry.mjs") + process.argv[1] = join(cwd, "missing-entry.mjs") process.stderr.write = (chunk) => { stderr.push(String(chunk)) return true @@ -1256,90 +1252,14 @@ test("importing with a nonexistent argv entry is inert", async () => { assert.equal(existsSync(cwd), false) }) -test("a URL-significant executable path still enters the finalizer main module", async () => { - const cwd = mkdtempSync(join(tmpdir(), "stable-finalize-entry-")) - let result - try { - const entry = join(cwd, "release # %.mjs") - writeFileSync(entry, readFileSync(script)) - result = await new Promise((resolve) => { - const child = spawn(process.execPath, [entry], { - cwd, - env: { - ...process.env, - EXPECTED_SHA: "", - ARTIFACT_SHA: "", - PROJECTS: "", - }, - }) - let stdout = "", - stderr = "" - child.stdout.on("data", (chunk) => (stdout += chunk)) - child.stderr.on("data", (chunk) => (stderr += chunk)) - child.on("close", (status) => resolve({ status, stdout, stderr })) - }) - } finally { - await rm(cwd, { recursive: true, force: true }) - } - - assert.equal(existsSync(cwd), false) - assert.notEqual(result.status, 0) - assert.equal(result.stdout, "") - assert.match(result.stderr, /FINALIZE requires full lowercase expected SHA/) -}) - -test("a URL-significant symlink enters the finalizer main module with preserved symlink identity", async () => { - const cwd = mkdtempSync(join(tmpdir(), "stable-finalize-symlink-entry-")) - let result - try { - const entry = join(cwd, "release # %.mjs") - symlinkSync(script, entry) - result = await new Promise((resolve) => { - const child = spawn(process.execPath, ["--preserve-symlinks-main", entry], { - cwd, - env: { - ...process.env, - EXPECTED_SHA: "", - ARTIFACT_SHA: "", - PROJECTS: "", - }, - }) - let stdout = "", - stderr = "" - child.stdout.on("data", (chunk) => (stdout += chunk)) - child.stderr.on("data", (chunk) => (stderr += chunk)) - child.on("close", (status) => resolve({ status, stdout, stderr })) - }) - } finally { - await rm(cwd, { recursive: true, force: true }) - } - - assert.equal(existsSync(cwd), false) - assert.notEqual(result.status, 0) - assert.equal(result.stdout, "") - assert.match(result.stderr, /FINALIZE requires full lowercase expected SHA/) -}) - -test("workflow FINALIZE step disables publication lifecycle scripts", () => { - const finalizeJob = stableWorkflow.match(/^ finalize:\n([\s\S]*?)(?=^ summary:)/m) - assert.ok(finalizeJob, "stable FINALIZE job") - const finalizeStep = finalizeJob[1].match(/^ - name: 🚀 FINALIZE exact stable artifacts\n([\s\S]*)$/m) - assert.ok(finalizeStep, "stable FINALIZE step") - assert.match(finalizeStep[1], /NPM_CONFIG_IGNORE_SCRIPTS:\s*true/) -}) - -test("static command and publication boundary removes historical truth", () => { +await test("static publication boundary is per-tarball, provenance-bearing, and never Nx batch publication", () => { const source = readFileSync(script, "utf8") - assert.match(source, /spawn\(file, args, \{ shell: false/) - assert.match(source, /process\.env\.GITHUB_ACTIONS/) - assert.match(source, /function isMainModule\(\)/) - assert.match(source, /resolvedEntry = realpathSync\(entry\)/) - assert.match(source, /resolvedModule = realpathSync\(fileURLToPath\(import\.meta\.url\)\)/) - assert.match(source, /pathToFileURL\(resolvedEntry\)\.href === pathToFileURL\(resolvedModule\)\.href/) - assert.match(source, /run\("git", \["cat-file", "-t", `\$\{artifactSha\}:CHANGELOG\.md`\]\)/) - assert.equal(source.match(/\\u0000/g)?.length, 2) - assert.match(source, /artifactSha.*nx\.json|nx\.json.*artifactSha/s) - assert.doesNotMatch(source, /^const records\s*=\s*\[/m) - assert.doesNotMatch(source, /@effectify\/(?:hatchet|react-query|solid-query)|0\.5\.13|1\.1\.13/) - assert.doesNotMatch(source, /execSync|spawnSync|shell: true|npm dist-tag|npm unpublish|release delete|tag", "-f/) + assert.match(source, /verifyStableHandoff/) + assert.match(source, /release-stable-abandonments\.json/) + assert.match(source, /"npm",\s*\[\s*"publish"/s) + assert.match( + source, + /"--access",\s*"public",\s*"--tag",\s*"latest",\s*"--provenance",\s*"--ignore-scripts",\s*"--json"/s, + ) + assert.doesNotMatch(source, /nx",\s*"release",\s*"publish"|npm dist-tag|npm unpublish|shell:\s*true/) }) From ca665ea01cf907633c94ab803e2edcf19b5d0008 Mon Sep 17 00:00:00 2001 From: kattsushi Date: Mon, 31 Aug 2026 11:04:25 -0600 Subject: [PATCH 3/4] fix(release): isolate stable package handoff --- .github/SETUP.md | 86 +++- .github/workflows/release-stable.yml | 117 +++++- scripts/release-policy-contract.test.mjs | 499 +++++++++++++++++++---- 3 files changed, 578 insertions(+), 124 deletions(-) diff --git a/.github/SETUP.md b/.github/SETUP.md index 5dd09e93..18297b36 100644 --- a/.github/SETUP.md +++ b/.github/SETUP.md @@ -10,7 +10,7 @@ Effectify releases through three isolated channels. Stable promotion is a two-st | Beta | Push to `master` | `beta` | `.github/workflows/cd.yml` | | Stable | Manual workflow against current `master` | default (`latest`) | `.github/workflows/release-stable.yml` | -Alpha remains prerelease-only with `--tag=alpha`; beta remains prerelease-only with `--tag=beta`. Only stable omits a tag and may advance npm `latest`. +Alpha remains prerelease-only with `--tag=alpha`; beta remains prerelease-only with `--tag=beta`. Only protected stable may publish with `--tag latest` and advance npm `latest`. ## Required repository and npm setup @@ -20,18 +20,23 @@ Configure `NPM_TOKEN` for the existing alpha and beta workflows. Stable publicat Create the `stable-release` environment under **Settings > Environments** and require reviewers who are independent from the dispatcher. Restrict deployment branches to protected `master`. The environment is attached to the entire FINALIZE job. -| Stable job | Declared job permissions | Explicit step environment and capability | -| ----------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------- | -| `validate` | `contents: read` | No secret/token environment; policy, install, build, and test only; checkout credentials are not stored | -| `prepare` | `contents: write` | `GH_TOKEN` is declared only on the release-branch push step; no OIDC; checkout credentials are not stored | -| `preflight` | `contents: read` | `GITHUB_TOKEN` is declared on the read-only API step; no npm credential or publication capability | -| `finalize` | `contents: write`, `id-token: write` | Protected environment applies to the job; publication environment is declared only on the finalizer step | +| Stable job | Declared job permissions | Explicit capability boundary | +| ------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------- | +| `validate` | `contents: read` | No secret/token environment; policy, install, build, and test only; checkout credentials are not stored | +| `package_artifacts` | `contents: read` | No OIDC, npm credential, or repository write token; exact-source build/pack and one immutable current-run upload only | +| `prepare` | `contents: write` | `GH_TOKEN` is declared only on the release-branch push step; no OIDC; checkout credentials are not stored | +| `preflight` | `contents: read` | `GITHUB_TOKEN` is declared only on the read-only finalizer step; no npm credential or publication capability | +| `finalize` | `contents: write`, `id-token: write` | Protected environment applies to the job; token and publication settings are declared only on the finalizer step | -FINALIZE installs required tooling with `--ignore-scripts`, forces `NPM_CONFIG_IGNORE_SCRIPTS=true` for the finalizer step and publish child, and runs no build, test, or package lifecycle scripts. Declared job permissions, including `contents` and `id-token`, are available job-wide; when `id-token: write` is declared, OIDC is not step-scoped. The only step-scoped credential controls are explicit secret or token environment variables on their listed API or mutation steps. This environment scoping is defense in depth; it does not turn job permissions into step-only capabilities. Every checkout sets `persist-credentials: false`, so checkout credentials are not persisted. +`package_artifacts` is read-only with respect to repository and public release state. It checks out the current `expected_sha` control plane and the exact `artifact_sha` source into separate directories, then installs with `--ignore-scripts`, builds without cache reuse, and packs only in the artifact checkout. The packaging helper verifies package identity, normalized manifests, runtime entrypoints, inventory, and digests before `actions/upload-artifact` uploads one run/attempt-named handoff with overwrite disabled. + +PREFLIGHT and protected FINALIZE download that current-run handoff by exact artifact ID. Before any state decision, the finalizer binds the artifact ID and digest plus the handoff's repository, workflow path/ref/SHA, run ID/attempt, `expected_sha`, `artifact_sha`, and exact normalized selection; it then independently verifies every tarball and its recorded digests and inventory. Privileged FINALIZE installs no dependencies, builds or tests nothing, and runs no package code or package lifecycle scripts. It publishes only those verified tarballs, with scripts disabled, through npm trusted publishing with provenance. + +Declared job permissions, including `contents` and `id-token`, are available job-wide; when `id-token: write` is declared, OIDC is not step-scoped. The only step-scoped credential controls are explicit secret or token environment variables on their listed API or mutation steps. This environment scoping is defense in depth; it does not turn job permissions into step-only capabilities. Every checkout sets `persist-credentials: false`, so checkout credentials are not persisted. FINALIZE derives `x-access-token:` Basic authentication from its step-scoped `GITHUB_TOKEN` only for the atomic tag push and supplies it through a one-shot GitHub-scoped `git -c` extraheader. Missing, oversized, whitespace, control-character, or non-ASCII authentication fails before local tag creation. The credential is never written to checkout configuration or printed. PREFLIGHT retains only its existing read token and does not construct tag-push authentication. -The real stable publication boundary is protected `stable-release` environment review, authorization of the reviewed SHA, and npm trusted publishing bound to the repository, workflow, environment, and OIDC claims. `GITHUB_ACTIONS` is checked only as an accidental-use guard, so FINALIZE is refused outside GitHub Actions; it is not an unspoofable local security gate because a local process can set it. +The real stable publication boundary is protected `stable-release` environment review, authorization of the reviewed SHA, and npm trusted publishing bound to the repository, workflow, environment, and OIDC claims. Concretely, the external npm trusted-publisher configuration must remain bound to this repository, `.github/workflows/release-stable.yml`, and the `stable-release` environment. `GITHUB_ACTIONS` is checked only as an accidental-use guard, so FINALIZE is refused outside GitHub Actions; it is not an unspoofable local security gate because a local process can set it. ## Nx release projects @@ -125,7 +130,7 @@ ARTIFACT_SHA=$(gh pr view "$STABLE_PR" --json mergeCommit --jq '.mergeCommit.oid test "$EXPECTED_SHA" = "$ARTIFACT_SHA" ``` -For the normal current release the values are identical. `expected_sha` authorizes the current `master`; `artifact_sha` identifies the reviewed release shape. A different artifact SHA is historical verification-only and cannot repair or publish. +For the normal current release the values are identical. `expected_sha` authorizes the current `master`; `artifact_sha` identifies the reviewed release shape. Different values enter only the bounded historical npm publish-only recovery path described below. Dispatch read-only PREFLIGHT with the same normalized subset: @@ -138,7 +143,7 @@ gh workflow run release-stable.yml --ref master \ -f artifact_sha="$ARTIFACT_SHA" ``` -PREFLIGHT freshly proves `HEAD == origin/master == expected_sha`, derives the reviewed records from the artifact first-parent diff, and reads npm, tags, and Releases. It has no npm credentials, OIDC, or write token and performs no mutation. +`package_artifacts` creates the run-bound handoff from the separate exact artifact checkout first. PREFLIGHT downloads it by exact artifact ID, verifies all handoff bindings and tarballs, freshly proves `HEAD == origin/master == expected_sha`, derives the reviewed records from the artifact first-parent diff, and reads npm, tags, and Releases. It has no npm credentials, OIDC, or write token and performs no mutation. ### 5. Dispatch protected FINALIZE @@ -153,14 +158,14 @@ gh workflow run release-stable.yml --ref master \ -f artifact_sha="$ARTIFACT_SHA" ``` -Protected-environment approval occurs before the privileged job. FINALIZE again proves the exact expected and artifact SHA authorization, then reconciles in order: +Protected-environment approval occurs before the privileged job. FINALIZE downloads the handoff by exact artifact ID, repeats every binding and tarball verification, and then reconciles in order: -1. exact annotated tags targeting `artifact_sha`, with one atomic explicit tag-refspec push using the non-persisted one-shot Basic extraheader; -2. exact non-draft, non-prerelease GitHub Releases; -3. only npm packages still missing, through Nx without a prerelease tag; +1. for a current artifact, exact annotated tags targeting `artifact_sha`, with one atomic explicit tag-refspec push using the non-persisted one-shot Basic extraheader; +2. for a current artifact, exact non-draft, non-prerelease GitHub Releases; +3. only npm versions still missing, by publishing the independently verified `.tgz` files with `latest`, trusted OIDC, provenance, and lifecycle scripts disabled; 4. bounded verification of every selected npm version and `latest`. -Matching state is retained, response loss is reconciled by rereading, and unknown or conflicting state stops the run. Publish-only recovery retries use the same workflow inputs. +Matching state is retained, response loss is reconciled by rereading, and unknown or conflicting state stops the run. Current-artifact retries use the same exact SHAs and selection; historical recovery has the stricter procedure below. ## Structural suppression and fail-closed behavior @@ -173,7 +178,7 @@ Missing changelog, extra paths, package renames, leading-zero SemVer identifiers Before release mutation, the validation job runs: ```bash -node --test scripts/release-policy-contract.test.mjs +node --test scripts/release-package-stable.test.mjs scripts/release-finalize-stable.test.mjs scripts/release-policy-contract.test.mjs ``` React Router readiness is checked only when that project is selected: @@ -186,9 +191,54 @@ pnpm nx run @effectify/react-router-example:migration:manifest pnpm nx run @effectify/react-router-example:consolidation:verify ``` +### Historical npm-only recovery + +Historical recovery is exceptional and release-control-path-only. The historical `artifact_sha` must be an ancestor from 1–8 commits behind the current `expected_sha`, and the aggregate `artifact_sha..expected_sha` changed-path set must stay within this hard-coded allowlist: + +- `.github/SETUP.md` +- `.github/workflows/release-stable.yml` +- `scripts/release-finalize-stable.mjs` and `scripts/release-finalize-stable.test.mjs` +- `scripts/release-package-stable.mjs` and `scripts/release-package-stable.test.mjs` +- `scripts/release-policy-contract.test.mjs` +- `scripts/release-stable-abandonments.json` + +Historical FINALIZE requires every selected annotated tag and non-draft, non-prerelease GitHub Release to exist already and match the historical artifact exactly. It cannot create or change either public artifact; it may mutate only a missing npm version by publishing its independently verified historical tarball. Run read-only PREFLIGHT first with the current exact `master` SHA and the older reviewed artifact SHA: + +```bash +EXPECTED_SHA=$(gh api repos/{owner}/{repo}/git/ref/heads/master --jq '.object.sha') +ARTIFACT_SHA='' +[[ "$EXPECTED_SHA" =~ ^[0-9a-f]{40}$ ]] || exit 1 +[[ "$ARTIFACT_SHA" =~ ^[0-9a-f]{40}$ ]] || exit 1 +test "$EXPECTED_SHA" != "$ARTIFACT_SHA" + +gh workflow run release-stable.yml --ref master \ + -f projects="$PROJECTS" \ + -f publish_only=false \ + -f preflight_only=true \ + -f expected_sha="$EXPECTED_SHA" \ + -f artifact_sha="$ARTIFACT_SHA" +``` + +Wait for PREFLIGHT to succeed and confirm exact existing tag/Release state for every selected record. Only then dispatch protected FINALIZE with the same selection and SHAs: + +```bash +gh workflow run release-stable.yml --ref master \ + -f projects="$PROJECTS" \ + -f publish_only=true \ + -f preflight_only=false \ + -f expected_sha="$EXPECTED_SHA" \ + -f artifact_sha="$ARTIFACT_SHA" +``` + +> **Reviewed abandonment:** `@effectify/prisma@1.1.14` at artifact `f31390ce66ea157ea8b75f5259c203123e269759` keeps its exact tag and GitHub Release but must remain absent from npm because its CLI/export paths are broken. Do not publish it; a separately reviewed `@effectify/prisma@1.1.15` is required. + +### Failure handling + +npm reconciliation is bounded. Publication failures expose only a redacted, allowlisted diagnostic classification—authentication, authorization, not found, conflict, rejected payload, rate limiting, registry service failure, forbidden interactive authentication, or unknown—plus the bounded-read outcome; raw npm output is not emitted. A diagnostic never relaxes the external trusted-publisher requirement: npm must still bind this repository, `.github/workflows/release-stable.yml`, and the `stable-release` environment. + **Stop immediately** on a moved `master`, changed selection, missing or invalid first parent, unexpected diff path, non-beta source, target other than the beta base, package rename, malformed external response, lightweight or wrong-target tag, conflicting Release, stable collision, or an existing npm version whose `latest` differs. -Retry only the same exact SHAs and selected subset. Before a release PR merges, rollback is limited to abandoning the prepared branch or closing the PR. After merge but before public artifacts, use a protected revert PR. After any public artifact exists, never delete, retarget, unpublish, deprecate, or rewrite it; recover forward through the same authorized FINALIZE. +Retry only the same exact SHAs and selected subset. Before a release PR merges, rollback is limited to abandoning the prepared branch or closing the PR. After merge but before public artifacts, use a protected revert PR. After any public tag or Release exists, never delete, retarget, unpublish, deprecate, or rewrite it. Do not edit or revert that public state, and do not use a repository revert to undo it; recover forward only through the same authorized FINALIZE. ## Residual GitHub-host assumptions diff --git a/.github/workflows/release-stable.yml b/.github/workflows/release-stable.yml index 38fa506d..53b2a664 100644 --- a/.github/workflows/release-stable.yml +++ b/.github/workflows/release-stable.yml @@ -82,8 +82,8 @@ jobs: - name: 📦 Install dependencies run: pnpm install --frozen-lockfile - - name: 🛡️ Verify release policy contract - run: node --test scripts/release-policy-contract.test.mjs + - name: 🛡️ Verify stable packaging and release policy contracts + run: node --test scripts/release-package-stable.test.mjs scripts/release-finalize-stable.test.mjs scripts/release-policy-contract.test.mjs - name: 🧭 Resolve exact stable mode and selection id: release @@ -173,6 +173,79 @@ jobs: pnpm nx run @effectify/react-router-example:migration:manifest pnpm nx run @effectify/react-router-example:consolidation:verify + package_artifacts: + name: 📦 Package reviewed stable artifacts + needs: validate + if: ${{ needs.validate.outputs.mode == 'preflight' || needs.validate.outputs.mode == 'finalize' }} + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + artifact_id: ${{ steps.upload.outputs.artifact-id }} + artifact_digest: ${{ steps.upload.outputs.artifact-digest }} + steps: + - name: 📥 Checkout current control plane without credentials + uses: actions/checkout@v5 + with: + ref: ${{ needs.validate.outputs.expected_sha }} + fetch-depth: 0 + persist-credentials: false + path: stable-control + + - name: 📥 Checkout exact reviewed source without credentials + uses: actions/checkout@v5 + with: + ref: ${{ needs.validate.outputs.artifact_sha }} + fetch-depth: 0 + persist-credentials: false + path: stable-source + + - name: 📦 Install pnpm + uses: pnpm/action-setup@v6 + with: + version: 10.14.0 + + - name: 🏗️ Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: "24.19.0" + package-manager-cache: false + + - name: 📦 Install reviewed source dependencies without lifecycle scripts + working-directory: stable-source + run: pnpm install --frozen-lockfile --ignore-scripts + + - name: 🏗️ Build exact reviewed projects without cache reuse + working-directory: stable-source + env: + PROJECTS: ${{ needs.validate.outputs.projects }} + NX_NO_CLOUD: true + NX_SKIP_NX_CACHE: true + run: pnpm nx run-many -t build "--projects=$PROJECTS" --parallel=3 --skip-nx-cache + + - name: 📦 Create run-bound stable handoff + env: + GITHUB_REPOSITORY: ${{ github.repository }} + WORKFLOW_PATH: .github/workflows/release-stable.yml + WORKFLOW_REF: refs/heads/master + WORKFLOW_SHA: ${{ needs.validate.outputs.expected_sha }} + GITHUB_RUN_ID: ${{ github.run_id }} + GITHUB_RUN_ATTEMPT: ${{ github.run_attempt }} + EXPECTED_SHA: ${{ needs.validate.outputs.expected_sha }} + ARTIFACT_SHA: ${{ needs.validate.outputs.artifact_sha }} + PROJECTS: ${{ needs.validate.outputs.projects }} + run: node "$GITHUB_WORKSPACE/stable-control/scripts/release-package-stable.mjs" create --source-root "$GITHUB_WORKSPACE/stable-source" --output "$RUNNER_TEMP/stable-handoff" --abandonments "$GITHUB_WORKSPACE/stable-control/scripts/release-stable-abandonments.json" + + - name: 📤 Upload immutable stable handoff + id: upload + uses: actions/upload-artifact@v4 + with: + name: stable-handoff-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/stable-handoff + if-no-files-found: error + retention-days: 1 + overwrite: false + prepare: name: 🌿 PREPARE protected stable branch needs: validate @@ -317,7 +390,7 @@ jobs: preflight: name: 🔎 PREFLIGHT exact stable artifacts - needs: validate + needs: [validate, package_artifacts] if: ${{ needs.validate.outputs.mode == 'preflight' }} runs-on: ubuntu-latest permissions: @@ -326,7 +399,7 @@ jobs: - name: 📥 Checkout validated master without credentials uses: actions/checkout@v5 with: - ref: ${{ needs.validate.outputs.validated_sha }} + ref: ${{ needs.validate.outputs.expected_sha }} fetch-depth: 0 persist-credentials: false @@ -336,17 +409,27 @@ jobs: node-version: "24.19.0" package-manager-cache: false + - name: 📥 Download exact current-run stable handoff + uses: actions/download-artifact@v5 + with: + artifact-ids: ${{ needs.package_artifacts.outputs.artifact_id }} + path: ${{ runner.temp }}/stable-handoff + merge-multiple: true + - name: 🔎 PREFLIGHT exact stable artifacts env: PROJECTS: ${{ needs.validate.outputs.projects }} EXPECTED_SHA: ${{ needs.validate.outputs.expected_sha }} ARTIFACT_SHA: ${{ needs.validate.outputs.artifact_sha }} + STABLE_HANDOFF_DIRECTORY: ${{ runner.temp }}/stable-handoff + STABLE_HANDOFF_ARTIFACT_ID: ${{ needs.package_artifacts.outputs.artifact_id }} + STABLE_HANDOFF_ARTIFACT_DIGEST: sha256:${{ needs.package_artifacts.outputs.artifact_digest }} GITHUB_TOKEN: ${{ github.token }} - run: bash scripts/release-finalize-stable.sh --preflight --json + run: node scripts/release-finalize-stable.mjs --preflight --json finalize: name: 🚀 FINALIZE exact stable artifacts - needs: validate + needs: [validate, package_artifacts] if: ${{ needs.validate.outputs.mode == 'finalize' }} runs-on: ubuntu-latest environment: stable-release @@ -357,33 +440,35 @@ jobs: - name: 📥 Checkout validated master without credentials uses: actions/checkout@v5 with: - ref: ${{ needs.validate.outputs.validated_sha }} + ref: ${{ needs.validate.outputs.expected_sha }} fetch-depth: 0 persist-credentials: false - - name: 📦 Install pnpm - uses: pnpm/action-setup@v6 - with: - version: 10.14.0 - - name: 🏗️ Setup Node.js uses: actions/setup-node@v5 with: node-version: "24.19.0" - cache: pnpm + package-manager-cache: false - - name: 📦 Install publication tooling without lifecycle scripts - run: pnpm install --frozen-lockfile --ignore-scripts + - name: 📥 Download exact current-run stable handoff + uses: actions/download-artifact@v5 + with: + artifact-ids: ${{ needs.package_artifacts.outputs.artifact_id }} + path: ${{ runner.temp }}/stable-handoff + merge-multiple: true - name: 🚀 FINALIZE exact stable artifacts env: PROJECTS: ${{ needs.validate.outputs.projects }} EXPECTED_SHA: ${{ needs.validate.outputs.expected_sha }} ARTIFACT_SHA: ${{ needs.validate.outputs.artifact_sha }} + STABLE_HANDOFF_DIRECTORY: ${{ runner.temp }}/stable-handoff + STABLE_HANDOFF_ARTIFACT_ID: ${{ needs.package_artifacts.outputs.artifact_id }} + STABLE_HANDOFF_ARTIFACT_DIGEST: sha256:${{ needs.package_artifacts.outputs.artifact_digest }} GITHUB_TOKEN: ${{ github.token }} NPM_CONFIG_PROVENANCE: true NPM_CONFIG_IGNORE_SCRIPTS: true - run: bash scripts/release-finalize-stable.sh + run: node scripts/release-finalize-stable.mjs summary: name: 📊 Stable summary diff --git a/scripts/release-policy-contract.test.mjs b/scripts/release-policy-contract.test.mjs index 30e87520..feecdba4 100644 --- a/scripts/release-policy-contract.test.mjs +++ b/scripts/release-policy-contract.test.mjs @@ -4,6 +4,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" import test from "node:test" +import { isDeepStrictEqual } from "node:util" const read = (path) => { try { @@ -23,6 +24,10 @@ const readme = read("README.md") const setup = read(".github/SETUP.md") const stableFinalizeWrapper = read("scripts/release-finalize-stable.sh") const stableFinalizeScript = read("scripts/release-finalize-stable.mjs") +const stablePackageScript = read("scripts/release-package-stable.mjs") +const stablePackageTest = read("scripts/release-package-stable.test.mjs") +const stableFinalizeTest = read("scripts/release-finalize-stable.test.mjs") +const stableAbandonmentLedger = read("scripts/release-stable-abandonments.json") const releaseProjects = [ "@effectify/react-router", @@ -228,6 +233,8 @@ const exactCommand = (value) => new RegExp(`^${value.replace(/[.*+?^${}()|[\]\\] const buildCommand = /^pnpm nx run-many -t build "--projects=\$PROJECTS" --parallel=3$/ const testCommand = /^pnpm nx run-many -t test "--projects=\$PROJECTS" --parallel=3 --passWithNoTests$/ const contractCommand = /^node --test scripts\/release-policy-contract\.test\.mjs$/ +const stableContractCommand = + /^node --test scripts\/release-package-stable\.test\.mjs scripts\/release-finalize-stable\.test\.mjs scripts\/release-policy-contract\.test\.mjs$/ const releaseSubjectGuard = 'if [[ "$HEAD_SUBJECT" == *"chore(release):"* || "$HEAD_SUBJECT" == *"[skip release]"* ]]; then' const releaseManifestGuard = @@ -246,6 +253,8 @@ const betaFinalizeExpectedShaGuard = '[[ "$EXPECTED_SHA" =~ ^[0-9a-f]{40}$ ]] || const stableFinalizeExpectedShaGuard = "[[ \"$EXPECTED_SHA\" =~ ^[0-9a-f]{40}$ ]] || { echo '::error::FINALIZE requires full lowercase expected_sha'; exit 1; }" const stableTransitionVersionPattern = "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)-beta\\.(0|[1-9][0-9]*)$" +const exactStablePublishCall = + /run\("npm", \[\s*"publish",\s*tarballPath,\s*"--registry",\s*NPM_REGISTRY,\s*"--access",\s*"public",\s*"--tag",\s*"latest",\s*"--provenance",\s*"--ignore-scripts",\s*"--json",\s*\]\)/ const permissionEntries = (job) => { const block = job.match(/^\s{4}permissions:\s*\n((?:\s{6}[A-Za-z-]+:\s*[^\n]+\n?)+)/m)?.[1] ?? "" @@ -266,16 +275,188 @@ const checkoutPersistsCredentials = (job) => (step) => step.uses.startsWith("actions/checkout@") && !/persist-credentials:\s*false/.test(step.source), ) +const stableArtifactTopologyViolations = (source) => { + const violations = [] + const jobs = Object.fromEntries( + ["package_artifacts", "preflight", "finalize"].map((name) => [name, extractJob(source, name)]), + ) + if (!jobs.package_artifacts) return ["stable package_artifacts job"] + + const packageJob = jobs.package_artifacts + if (!/^\s{4}needs:\s*validate\s*$/m.test(packageJob)) + violations.push("stable package_artifacts validation dependency") + if ( + !/^\s{4}if:\s*\$\{\{ needs\.validate\.outputs\.mode == 'preflight' \|\| needs\.validate\.outputs\.mode == 'finalize' \}\}\s*$/m.test( + packageJob, + ) + ) { + violations.push("stable package_artifacts mode isolation") + } + if (!hasExactPermissions(packageJob, { contents: "read" })) { + violations.push("stable package_artifacts least privilege") + } + if ( + /id-token:\s*write|contents:\s*write|\b(?:GITHUB_TOKEN|GH_TOKEN|NODE_AUTH_TOKEN|NPM_TOKEN|RELEASE_TOKEN):|secrets\.|github\.token/.test( + packageJob, + ) + ) { + violations.push("stable package_artifacts credential isolation") + } + for (const [output, value] of [ + ["artifact_id", "${{ steps.upload.outputs.artifact-id }}"], + ["artifact_digest", "${{ steps.upload.outputs.artifact-digest }}"], + ]) { + if (!packageJob.includes(` ${output}: ${value}`)) violations.push(`stable package_artifacts ${output} output`) + } + + const packageSteps = extractSteps(packageJob) + const checkouts = packageSteps.filter((step) => step.uses === "actions/checkout@v5") + if (checkouts.length !== 2) { + violations.push("stable package_artifacts dual checkout") + } else { + for (const [checkout, ref, path] of [ + [checkouts[0], "needs.validate.outputs.expected_sha", "stable-control"], + [checkouts[1], "needs.validate.outputs.artifact_sha", "stable-source"], + ]) { + if ( + !new RegExp(`ref:\\s*\\$\\{\\{ ${ref.replaceAll(".", "\\.")} \\}\\}`).test(checkout.source) || + !/fetch-depth:\s*0/.test(checkout.source) || + !/persist-credentials:\s*false/.test(checkout.source) || + !new RegExp(`path:\\s*${path}(?:\\s|$)`).test(checkout.source) + ) { + violations.push(`stable package_artifacts exact ${path} checkout`) + } + } + } + + const pnpmSetup = packageSteps.filter((step) => step.uses === "pnpm/action-setup@v6") + if (pnpmSetup.length !== 1 || !/version:\s*10\.14\.0/.test(pnpmSetup[0].source)) { + violations.push("stable package_artifacts pinned pnpm") + } + const nodeSetup = packageSteps.filter((step) => step.uses === "actions/setup-node@v5") + if ( + nodeSetup.length !== 1 || + !/node-version:\s*["']?24\.19\.0["']?/.test(nodeSetup[0].source) || + nodeSetup[0].packageManagerCache !== "false" + ) { + violations.push("stable package_artifacts pinned cacheless Node") + } + const install = packageSteps.find((step) => step.commands.includes("pnpm install --frozen-lockfile --ignore-scripts")) + if (!install || !/working-directory:\s*stable-source/.test(install.source)) { + violations.push("stable package_artifacts source install") + } + const build = packageSteps.find((step) => + step.commands.includes('pnpm nx run-many -t build "--projects=$PROJECTS" --parallel=3 --skip-nx-cache'), + ) + if ( + !build || + !/working-directory:\s*stable-source/.test(build.source) || + !/PROJECTS:\s*\$\{\{ needs\.validate\.outputs\.projects \}\}/.test(build.source) || + !/NX_NO_CLOUD:\s*true/.test(build.source) || + !/NX_SKIP_NX_CACHE:\s*true/.test(build.source) + ) { + violations.push("stable package_artifacts exact cacheless build") + } + const create = packageSteps.find((step) => + step.commands.includes( + 'node "$GITHUB_WORKSPACE/stable-control/scripts/release-package-stable.mjs" create --source-root "$GITHUB_WORKSPACE/stable-source" --output "$RUNNER_TEMP/stable-handoff" --abandonments "$GITHUB_WORKSPACE/stable-control/scripts/release-stable-abandonments.json"', + ), + ) + if (!create) { + violations.push("stable package_artifacts control helper invocation") + } else { + for (const [pattern, name] of [ + [/GITHUB_REPOSITORY:\s*\$\{\{ github\.repository \}\}/, "repository"], + [/WORKFLOW_PATH:\s*\.github\/workflows\/release-stable\.yml/, "workflow path"], + [/WORKFLOW_REF:\s*refs\/heads\/master/, "workflow ref"], + [/WORKFLOW_SHA:\s*\$\{\{ needs\.validate\.outputs\.expected_sha \}\}/, "workflow SHA"], + [/GITHUB_RUN_ID:\s*\$\{\{ github\.run_id \}\}/, "run ID"], + [/GITHUB_RUN_ATTEMPT:\s*\$\{\{ github\.run_attempt \}\}/, "run attempt"], + [/EXPECTED_SHA:\s*\$\{\{ needs\.validate\.outputs\.expected_sha \}\}/, "expected SHA"], + [/ARTIFACT_SHA:\s*\$\{\{ needs\.validate\.outputs\.artifact_sha \}\}/, "artifact SHA"], + [/PROJECTS:\s*\$\{\{ needs\.validate\.outputs\.projects \}\}/, "projects"], + ]) { + if (!pattern.test(create.source)) violations.push(`stable package_artifacts helper ${name}`) + } + } + + const uploads = extractSteps(source).filter((step) => step.uses.startsWith("actions/upload-artifact@")) + const upload = packageSteps.find((step) => step.uses.startsWith("actions/upload-artifact@")) + if ( + uploads.length !== 1 || + !upload || + upload.uses !== "actions/upload-artifact@v4" || + !/name:\s*stable-handoff-\$\{\{ github\.run_id \}\}-\$\{\{ github\.run_attempt \}\}/.test(upload.source) || + !/path:\s*\$\{\{ runner\.temp \}\}\/stable-handoff/.test(upload.source) || + !/if-no-files-found:\s*error/.test(upload.source) || + !/retention-days:\s*1/.test(upload.source) || + !/overwrite:\s*false/.test(upload.source) + ) { + violations.push("stable package_artifacts single immutable upload") + } + if (upload && packageSteps.indexOf(upload) <= packageSteps.indexOf(create)) { + violations.push("stable package_artifacts create before upload") + } + + for (const [name, command] of [ + ["preflight", "node scripts/release-finalize-stable.mjs --preflight --json"], + ["finalize", "node scripts/release-finalize-stable.mjs"], + ]) { + const job = jobs[name] + if (!job) continue + if (!/^\s{4}needs:\s*\[validate, package_artifacts\]\s*$/m.test(job)) { + violations.push(`stable ${name} package_artifacts dependency`) + } + const steps = extractSteps(job) + const downloads = steps.filter((step) => step.uses.startsWith("actions/download-artifact@")) + const download = downloads[0] + if ( + downloads.length !== 1 || + download.uses !== "actions/download-artifact@v5" || + !/artifact-ids:\s*\$\{\{ needs\.package_artifacts\.outputs\.artifact_id \}\}/.test(download.source) || + !/path:\s*\$\{\{ runner\.temp \}\}\/stable-handoff/.test(download.source) || + !/merge-multiple:\s*true/.test(download.source) || + /^\s*(?:name|pattern|run-id|repository|github-token):/m.test(download.source) + ) { + violations.push(`stable ${name} exact current-run artifact download`) + } + const finalizer = steps.find((step) => step.commands.includes(command)) + if (!finalizer) { + violations.push(`stable ${name} exact Node finalizer invocation`) + continue + } + for (const [pattern, label] of [ + [/PROJECTS:\s*\$\{\{ needs\.validate\.outputs\.projects \}\}/, "projects"], + [/EXPECTED_SHA:\s*\$\{\{ needs\.validate\.outputs\.expected_sha \}\}/, "expected SHA"], + [/ARTIFACT_SHA:\s*\$\{\{ needs\.validate\.outputs\.artifact_sha \}\}/, "artifact SHA"], + [/STABLE_HANDOFF_DIRECTORY:\s*\$\{\{ runner\.temp \}\}\/stable-handoff/, "handoff directory"], + [/STABLE_HANDOFF_ARTIFACT_ID:\s*\$\{\{ needs\.package_artifacts\.outputs\.artifact_id \}\}/, "artifact ID"], + [ + /STABLE_HANDOFF_ARTIFACT_DIGEST:\s*sha256:\$\{\{ needs\.package_artifacts\.outputs\.artifact_digest \}\}/, + "artifact digest", + ], + ]) { + if (!pattern.test(finalizer.source)) violations.push(`stable ${name} ${label} binding`) + } + } + return violations +} + const stableCapabilityViolations = (source) => { const violations = [] const jobs = Object.fromEntries( - ["validate", "prepare", "preflight", "finalize"].map((name) => [name, extractJob(source, name)]), + ["validate", "package_artifacts", "prepare", "preflight", "finalize"].map((name) => [ + name, + extractJob(source, name), + ]), ) for (const [name, job] of Object.entries(jobs)) if (!job) violations.push(`stable ${name} job`) if (violations.length > 0) return violations + violations.push(...stableArtifactTopologyViolations(source)) for (const [name, expected] of [ ["validate", { contents: "read" }], + ["package_artifacts", { contents: "read" }], ["prepare", { contents: "write" }], ["preflight", { contents: "read" }], ["finalize", { contents: "write", "id-token": "write" }], @@ -287,7 +468,7 @@ const stableCapabilityViolations = (source) => { } const validateSteps = extractSteps(jobs.validate) - for (const required of [contractCommand, buildCommand, testCommand]) { + for (const required of [stableContractCommand, buildCommand, testCommand]) { if (!validateSteps.some((step) => step.commands.some((command) => required.test(command)))) { violations.push(`stable validation ${String(required)}`) } @@ -341,10 +522,22 @@ const stableCapabilityViolations = (source) => { ) { violations.push("stable PREFLIGHT read-only credentials") } - const preflightSetupNode = extractSteps(jobs.preflight).find((step) => step.uses.startsWith("actions/setup-node@")) + const preflightSteps = extractSteps(jobs.preflight) + const preflightSetupNode = preflightSteps.find((step) => step.uses.startsWith("actions/setup-node@")) if (preflightSetupNode?.packageManagerCache !== "false") { violations.push("stable PREFLIGHT setup-node package-manager cache") } + if ( + preflightSteps.some( + (step) => + step.uses.startsWith("pnpm/action-setup@") || + step.commands.some((command) => + /\b(?:pnpm|npx|yarn|nx)\b|\bnpm\s+(?:install|ci|run|exec|pack|publish)\b/.test(command), + ), + ) + ) { + violations.push("stable PREFLIGHT Node-only execution") + } const finalizeSteps = extractSteps(jobs.finalize) const finalizeCredentialSteps = finalizeSteps.filter((step) => @@ -367,16 +560,22 @@ const stableCapabilityViolations = (source) => { ) { violations.push("stable FINALIZE lifecycle-script environment") } + const finalizeNode = finalizeSteps.find((step) => step.uses.startsWith("actions/setup-node@")) + if (finalizeNode?.packageManagerCache !== "false") { + violations.push("stable FINALIZE setup-node package-manager cache") + } if ( - finalizeSteps.some((step) => - step.commands.some( - (command) => - /(?:^|\s)(?:build|test)(?:\s|$)|nx (?:run|test)|npm whoami|nx release version/.test(command) || - (/^pnpm install\b/.test(command) && !/--ignore-scripts/.test(command)), - ), + finalizeSteps.some( + (step) => + step.uses.startsWith("pnpm/action-setup@") || + step.commands.some((command) => + /\b(?:pnpm|npx|yarn|nx)\b|\bnpm\s+(?:install|ci|run|exec|pack|rebuild|whoami)\b|\b(?:preinstall|postinstall|prepare|prepublish|prepublishOnly|prepack|postpack)\b|(?:^|\s)(?:build|test)(?:\s|$)/.test( + command, + ), + ), ) ) { - violations.push("stable FINALIZE lifecycle isolation") + violations.push("stable FINALIZE Node/npm-only lifecycle isolation") } return violations } @@ -939,8 +1138,89 @@ const betaViolations = (source) => { return violations } +const exactHistoricalStableControlPaths = [ + ".github/SETUP.md", + ".github/workflows/release-stable.yml", + "scripts/release-finalize-stable.mjs", + "scripts/release-finalize-stable.test.mjs", + "scripts/release-package-stable.mjs", + "scripts/release-package-stable.test.mjs", + "scripts/release-policy-contract.test.mjs", + "scripts/release-stable-abandonments.json", +] +const exactStableAbandonment = { + artifactSha: "f31390ce66ea157ea8b75f5259c203123e269759", + project: "@effectify/prisma", + name: "@effectify/prisma", + version: "1.1.14", + reason: "Reviewed exception: 1.1.14 has broken CLI/export paths; publish a reviewed 1.1.15 instead.", +} + +const stableControlFileViolations = (source, finalizeScript = stableFinalizeScript) => { + const violations = [] + const inputsStart = source.indexOf(" inputs:\n") + const inputsEnd = source.indexOf("\nconcurrency:", inputsStart) + const inputsBlock = inputsStart >= 0 && inputsEnd > inputsStart ? source.slice(inputsStart, inputsEnd) : "" + const inputNames = [...inputsBlock.matchAll(/^ {6}([a-z][a-z0-9_]*):\s*$/gm)].map(([, name]) => name) + if (!isDeepStrictEqual(inputNames, ["projects", "publish_only", "preflight_only", "expected_sha", "artifact_sha"])) { + violations.push("stable exact dispatch inputs without bypass") + } + if ( + /inputs\.(?:abandon|bypass|skip|force)|process\.env\.[A-Z0-9_]*(?:ABANDON|BYPASS|SKIP|FORCE)/.test( + `${source}\n${stablePackageScript}\n${finalizeScript}`, + ) + ) { + violations.push("stable no abandonment or bypass user control") + } + + let ledger + try { + ledger = JSON.parse(stableAbandonmentLedger) + } catch { + violations.push("stable abandonment ledger valid JSON") + } + if (ledger && !isDeepStrictEqual(ledger, { schemaVersion: 1, abandonments: [exactStableAbandonment] })) { + violations.push("stable exact Prisma 1.1.14 abandonment") + } + for (const [text, label] of [ + [stablePackageScript, "package helper"], + [stablePackageTest, "package helper tests"], + [stableFinalizeTest, "finalizer tests"], + [stableAbandonmentLedger, "abandonment ledger"], + ]) { + if (!text) violations.push(`stable required ${label}`) + } + for (const value of Object.values(exactStableAbandonment)) { + if (!stablePackageScript.includes(value)) violations.push("stable helper exact reviewed abandonment") + } + if ( + !/value\.abandonments\.length !== 1/.test(stablePackageScript) || + !/isDeepStrictEqual\(record, PINNED_ABANDONMENT\)/.test(stablePackageScript) || + !stablePackageTest.includes("release-stable-abandonments.json") + ) { + violations.push("stable abandonment fail-closed validation") + } + + const historicalBlock = finalizeScript.match(/const ALLOWED_HISTORICAL_PATHS = Object\.freeze\(\[([\s\S]*?)\]\)/)?.[1] + const historicalPaths = historicalBlock + ? [...historicalBlock.matchAll(/["']([^"']+)["']/g)].map(([, path]) => path) + : [] + if (!isDeepStrictEqual(historicalPaths, exactHistoricalStableControlPaths)) { + violations.push("stable exact historical recovery control files") + } + for (const pattern of [ + /const MAX_HISTORICAL_COMMITS = 8/, + /\["merge-base", "--is-ancestor", artifactSha, expectedSha\]/, + /\["diff", "--name-only", "--no-renames", artifactSha, expectedSha\]/, + /historical recovery changed path is not allowlisted/, + ]) { + if (!pattern.test(finalizeScript)) violations.push("stable bounded historical recovery controls") + } + return violations +} + const stableViolations = (source, finalizeScript = stableFinalizeScript) => { - const violations = [...stableCapabilityViolations(source)] + const violations = [...stableCapabilityViolations(source), ...stableControlFileViolations(source, finalizeScript)] const active = withoutComments(source) const activeFinalize = withoutComments(finalizeScript) { @@ -976,17 +1256,17 @@ const stableViolations = (source, finalizeScript = stableFinalizeScript) => { ["historical SHA distinction", /const historicalReplay = artifactSha !== expectedSha/, activeFinalize], [ "import-safe URL-aware main-module guard", - /function isMainModule\(\) \{[\s\S]*const entry = process\.argv\[1\][\s\S]*if \(!entry\) return false[\s\S]*resolvedEntry = realpathSync\(entry\)[\s\S]*resolvedModule = realpathSync\(fileURLToPath\(import\.meta\.url\)\)[\s\S]*catch \{[\s\S]*return false[\s\S]*pathToFileURL\(resolvedEntry\)\.href === pathToFileURL\(resolvedModule\)\.href/, + /function isMainModule\(\) \{[\s\S]*const entry = process\.argv\[1\][\s\S]*if \(!entry\) return false[\s\S]*pathToFileURL\(realpathSync\(entry\)\)\.href === pathToFileURL\(realpathSync\(fileURLToPath\(import\.meta\.url\)\)\)\.href[\s\S]*catch \{[\s\S]*return false/, activeFinalize, ], [ "strict expected SHA", - /if \(!\/\^\[0-9a-f\]\{40\}\$\/\.test\(expectedSha\)\) fail\("FINALIZE requires full lowercase expected SHA"\)/, + /if \(!fullSha\(expectedSha\)\) fail\("FINALIZE requires full lowercase expected SHA"\)/, activeFinalize, ], [ "strict artifact SHA", - /if \(!\/\^\[0-9a-f\]\{40\}\$\/\.test\(artifactSha\)\) fail\("FINALIZE requires full lowercase artifact SHA"\)/, + /if \(!fullSha\(artifactSha\)\) fail\("FINALIZE requires full lowercase artifact SHA"\)/, activeFinalize, ], ["fresh master", /master:refs\/remotes\/origin\/master/, activeFinalize], @@ -1007,7 +1287,7 @@ const stableViolations = (source, finalizeScript = stableFinalizeScript) => { ], [ "artifact changelog blob before publication inspection", - /await verifyArtifactChangelog\(\)[\s\S]*const records = await deriveReviewedRecords\(projects\)[\s\S]*const states = await inspect\(records\)/, + /await verifyCurrentRunHandoff\(projects\)[\s\S]*await verifyArtifactChangelog\(\)[\s\S]*const records = await deriveReviewedRecords\(projects\)[\s\S]*const states = await inspect\(records, packageByProject, abandonedProjects\)/, activeFinalize, ], [ @@ -1017,7 +1297,7 @@ const stableViolations = (source, finalizeScript = stableFinalizeScript) => { ], ["artifact nx release roots", /artifactJson\("nx\.json"/, activeFinalize], ["artifact project identity", /artifactJson\(`\$\{root\}\/project\.json`/, activeFinalize], - ["artifact manifest identity", /artifactJson\(manifestPath/, activeFinalize], + ["artifact manifest identity", /artifactDocument\(manifestPath/, activeFinalize], [ "single-parent or exact two-parent artifact", /if \(parents\.length === 1\) return[\s\S]*if \(parents\.length !== 2\) fail\("reviewed artifact must be a single-parent commit or exact two-parent merge"\)/, @@ -1038,7 +1318,7 @@ const stableViolations = (source, finalizeScript = stableFinalizeScript) => { /\["diff", "--name-only", "--no-renames", `\$\{artifactSha\}\^1`, artifactSha\]/, activeFinalize, ], - ["strict beta source", /previous\.version\.match\(\/\^.*-beta\\\./, activeFinalize], + ["strict beta source", /previous\.version\.match\([\s\S]*-beta\\\./, activeFinalize], [ "derived stable target", /const stableVersion = match \? `\$\{match\[1\]\}\.\$\{match\[2\]\}\.\$\{match\[3\]\}`/, @@ -1050,14 +1330,14 @@ const stableViolations = (source, finalizeScript = stableFinalizeScript) => { activeFinalize, ], ["GitHub Actions FINALIZE boundary", /process\.env\.GITHUB_ACTIONS !== "true"/, activeFinalize], - ["bounded npm reads", /const maxReads = 6\b/, activeFinalize], + ["bounded npm reads", /const MAX_NPM_READS = 6\b/, activeFinalize], ["post-publish absence retries", /acceptAbsent && state\.kind === "absent"/, activeFinalize], [ "local annotated tag inspection", /async function localTagState[\s\S]*objecttype[\s\S]*\^tag\\t/, activeFinalize, ], - ["independent npm documents", /const versionsDoc[\s\S]*const tagsDoc/, activeFinalize], + ["independent npm documents", /const versionsDocument[\s\S]*const tagsDocument/, activeFinalize], [ "strict tag parse", /direct\.length === 1 && peeled\.length === 1 && peeled\[0\] === artifactSha/, @@ -1067,11 +1347,7 @@ const stableViolations = (source, finalizeScript = stableFinalizeScript) => { ["HTTP 404 absence", /result\.status === 404/, activeFinalize], ["unknown Release fail closed", /result\.status !== 200/, activeFinalize], ["annotated artifact tag", /\["tag", "-a", tag, artifactSha, "-m", tag\]/, activeFinalize], - [ - "bounded printable-ASCII tag-push token", - /!\/\^\[\\x21-\\x7e\]\{1,4096\}\$\/\.test\(token\)/, - activeFinalize, - ], + ["bounded printable-ASCII tag-push token", /!\/\^\[\\x21-\\x7e\]\{1,4096\}\$\/\.test\(token\)/, activeFinalize], [ "Basic tag-push credential", /Buffer\.from\(`x-access-token:\$\{token\}`, "utf8"\)\.toString\("base64"\)/, @@ -1094,30 +1370,23 @@ const stableViolations = (source, finalizeScript = stableFinalizeScript) => { ], [ "missing npm subset", - /states\.filter\(\((?:state|item)\) => (?:state|item)\.npm === "absent"\)/, - activeFinalize, - ], - [ - "default publication", - /\["nx", "release", "publish", `--projects=\$\{missing\.join\(","\)\}`\]/, - activeFinalize, - ], - [ - "publish lifecycle-script environment", - /env:\s*\{\s*\.\.\.process\.env,\s*NPM_CONFIG_IGNORE_SCRIPTS:\s*"true"\s*\}/, + /const current = await npmBounded\(record, handoffPackage, \{ acceptAbsent: true \}\)[\s\S]*if \(current\.kind === "exact"\) continue/, activeFinalize, ], + ["pinned npmjs registry", /const NPM_REGISTRY = "https:\/\/registry\.npmjs\.org\/"/, activeFinalize], + ["tarball-only default publication", exactStablePublishCall, activeFinalize], + ["publish lifecycle-script argument", /"--provenance",\s*"--ignore-scripts",\s*"--json",/, activeFinalize], [ - "historical all-existing guard", - /if \(historicalReplay\) \{[\s\S]*item\.tag !== "exact" \|\| item\.release !== "exact" \|\| item\.npm !== "exact"[\s\S]*historical replay requires exact existing tag, GitHub Release, and npm latest/, + "historical npm-only guard", + /if \(historicalReplay\) \{[\s\S]*state\.tag !== "exact"[\s\S]*state\.release !== "exact"[\s\S]*\["exact", "absent"\]\.includes\(state\.npm\)/, activeFinalize, ], [ "preflight reviewed selection", - /JSON\.stringify\(\{ ok: true, expectedSha, artifactSha, projects, states \}\)/, + /const report = \{[\s\S]*artifactDigest:[\s\S]*projects,[\s\S]*states,/, activeFinalize, ], - ["preflight return", /if \(historicalReplay \|\| preflight\) return/, activeFinalize], + ["preflight return", /if \(preflight\) \{[\s\S]*process\.stdout\.write[\s\S]*return/, activeFinalize], ["selection release roots from nx", /jq -r ['"]?\.release\.projects\[\]['"]? nx\.json/, active], ["selection project metadata", /pnpm nx show project "\$RELEASE_ROOT" --json/, active], ["selection exact allowlist", /grep -Fx -- "\$project"/, active], @@ -1247,7 +1516,7 @@ const stableViolations = (source, finalizeScript = stableFinalizeScript) => { } if ( preflight.commands.length !== 1 || - preflight.commands[0] !== "bash scripts/release-finalize-stable.sh --preflight --json" + preflight.commands[0] !== "node scripts/release-finalize-stable.mjs --preflight --json" ) { violations.push("stable PREFLIGHT exact read-only invocation") } @@ -1268,13 +1537,10 @@ const stableViolations = (source, finalizeScript = stableFinalizeScript) => { if (/npm dist-tag|npm unpublish|gh release delete|git tag -f|--tag=(?:alpha|beta)/.test(activeFinalize)) violations.push("stable destructive or channel repair") const order = [ - "const states = await inspect(records)", - '["tag", "-a"', - '["-c", pushConfiguration, "push", "--atomic"', - 'github("POST"', - "releaseState(`${item.name}", - '["nx", "release", "publish"', - "const state = await npmBounded(item.name", + "const states = await inspect(records, packageByProject, abandonedProjects)", + "await createCurrentArtifacts(states)", + "await publishMissingPackages(states, records, packageByProject, handoffContext.directory)", + "await npmBounded(record, packageByProject.get(record.project))", ].map((token) => activeFinalize.indexOf(token)) if ( order.some((position) => position < 0) || @@ -1836,10 +2102,12 @@ test("the Node-only release policy job can bootstrap setup-node without pnpm", ( test("stable PREFLIGHT can bootstrap setup-node without pnpm", () => { assert.deepEqual(stableCapabilityViolations(workflows.stable), []) - const cacheEnabled = mutate(workflows.stable, "package-manager-cache: false", "package-manager-cache: true") - assert.ok( - stableCapabilityViolations(cacheEnabled).includes("stable PREFLIGHT setup-node package-manager cache"), + const preflightJob = extractJob(workflows.stable, "preflight") + const cacheEnabled = workflows.stable.replace( + preflightJob, + mutate(preflightJob, "package-manager-cache: false", "package-manager-cache: true"), ) + assert.ok(stableCapabilityViolations(cacheEnabled).includes("stable PREFLIGHT setup-node package-manager cache")) }) test("release documentation leads with the three-channel mapping", () => { @@ -2015,8 +2283,8 @@ test("protected stable PREFLIGHT rejects authorization and mutation-boundary dri mutateStep( stable, "PREFLIGHT exact stable artifacts", - "bash scripts/release-finalize-stable.sh --preflight --json", - "bash scripts/release-finalize-stable.sh", + "node scripts/release-finalize-stable.mjs --preflight --json", + "node scripts/release-finalize-stable.mjs", ), ], [ @@ -2024,8 +2292,8 @@ test("protected stable PREFLIGHT rejects authorization and mutation-boundary dri mutateStep( stable, "PREFLIGHT exact stable artifacts", - "run: bash scripts/release-finalize-stable.sh --preflight --json", - "run: |\n bash scripts/release-finalize-stable.sh --preflight --json\n git push origin master", + "run: node scripts/release-finalize-stable.mjs --preflight --json", + "run: |\n node scripts/release-finalize-stable.mjs --preflight --json\n git push origin master", ), ], [ @@ -2055,18 +2323,18 @@ test("protected stable PREPARE and FINALIZE reject independent safety mutations" const policy = { ...workflows, docs: readme } assert.deepEqual(stableViolations(policy.stable), []) for (const [name, before, after] of [ - ["weaken expected SHA", "if (!/^[0-9a-f]{40}$/.test(expectedSha))", "if (!/^[0-9a-f]{7,40}$/.test(expectedSha))"], - ["weaken artifact SHA", "if (!/^[0-9a-f]{40}$/.test(artifactSha))", "if (!/^[0-9a-f]{7,40}$/.test(artifactSha))"], - ["remove entry realpath resolution", "resolvedEntry = realpathSync(entry)", "resolvedEntry = entry"], + ["weaken expected SHA", "if (!fullSha(expectedSha))", "if (!/^[0-9a-f]{7,40}$/.test(expectedSha))"], + ["weaken artifact SHA", "if (!fullSha(artifactSha))", "if (!/^[0-9a-f]{7,40}$/.test(artifactSha))"], + ["remove entry realpath resolution", "pathToFileURL(realpathSync(entry)).href", "pathToFileURL(entry).href"], [ "remove module URL realpath resolution", - "resolvedModule = realpathSync(fileURLToPath(import.meta.url))", - "resolvedModule = fileURLToPath(import.meta.url)", + "pathToFileURL(realpathSync(fileURLToPath(import.meta.url))).href", + "pathToFileURL(fileURLToPath(import.meta.url)).href", ], [ "remove main-module URL normalization", - "return pathToFileURL(resolvedEntry).href === pathToFileURL(resolvedModule).href", - "return resolvedEntry === resolvedModule", + "return pathToFileURL(realpathSync(entry)).href === pathToFileURL(realpathSync(fileURLToPath(import.meta.url))).href", + "return realpathSync(entry) === realpathSync(fileURLToPath(import.meta.url))", ], ["remove expected SHA environment", 'const expectedSha = process.env.EXPECTED_SHA ?? ""', 'const expectedSha = ""'], [ @@ -2076,13 +2344,13 @@ test("protected stable PREPARE and FINALIZE reject independent safety mutations" ], [ "swap expected SHA validation", - '.test(expectedSha)) fail("FINALIZE requires full lowercase expected SHA")', - '.test(artifactSha)) fail("FINALIZE requires full lowercase expected SHA")', + 'fullSha(expectedSha)) fail("FINALIZE requires full lowercase expected SHA")', + 'fullSha(artifactSha)) fail("FINALIZE requires full lowercase expected SHA")', ], [ "swap artifact SHA validation", - '.test(artifactSha)) fail("FINALIZE requires full lowercase artifact SHA")', - '.test(expectedSha)) fail("FINALIZE requires full lowercase artifact SHA")', + 'fullSha(artifactSha)) fail("FINALIZE requires full lowercase artifact SHA")', + 'fullSha(expectedSha)) fail("FINALIZE requires full lowercase artifact SHA")', ], ["authorize HEAD with artifact SHA", "head !== expectedSha", "head !== artifactSha"], ["authorize origin with artifact SHA", "origin !== expectedSha", "origin !== artifactSha"], @@ -2094,7 +2362,7 @@ test("protected stable PREPARE and FINALIZE reject independent safety mutations" '["tag", "-a", tag, expectedSha, "-m", tag]', ], ["remove historical all-existing guard", "if (historicalReplay) {", "if (false) {"], - ["weaken historical npm exactness", 'item.npm !== "exact"', 'item.npm === "unknown"'], + ["weaken historical npm exactness", '!["exact", "absent"].includes(state.npm)', 'state.npm === "unknown"'], ["skip artifact changelog blob verification", "await verifyArtifactChangelog()", ""], ["accept octopus artifacts", "parents.length !== 2", "parents.length < 2"], [ @@ -2103,11 +2371,66 @@ test("protected stable PREPARE and FINALIZE reject independent safety mutations" "generatedParents[0] === firstParent", ], ["accept a differing merge tree", "treeIds[0] !== treeIds[1]", "treeIds[0] === treeIds[1]"], - ["unbound retries", "const maxReads = 6", "const maxReads = 60"], - [ - "remove publish lifecycle-script environment", - 'env: { ...process.env, NPM_CONFIG_IGNORE_SCRIPTS: "true" }', - "env: process.env", + ["unbound retries", "const MAX_NPM_READS = 6", "const MAX_NPM_READS = 60"], + [ + "redirect pinned npmjs registry", + 'const NPM_REGISTRY = "https://registry.npmjs.org/"', + 'const NPM_REGISTRY = "https://registry.example.test/"', + ], + [ + "remove publish registry arguments", + ` await run("npm", [ + "publish", + tarballPath, + "--registry", + NPM_REGISTRY, + "--access", + "public", + "--tag", + "latest", + "--provenance", + "--ignore-scripts", + "--json", + ])`, + ` await run("npm", [ + "publish", + tarballPath, + "--access", + "public", + "--tag", + "latest", + "--provenance", + "--ignore-scripts", + "--json", + ])`, + ], + [ + "remove publish lifecycle-script argument", + ` await run("npm", [ + "publish", + tarballPath, + "--registry", + NPM_REGISTRY, + "--access", + "public", + "--tag", + "latest", + "--provenance", + "--ignore-scripts", + "--json", + ])`, + ` await run("npm", [ + "publish", + tarballPath, + "--registry", + NPM_REGISTRY, + "--access", + "public", + "--tag", + "latest", + "--provenance", + "--json", + ])`, ], [ @@ -2122,11 +2445,7 @@ test("protected stable PREPARE and FINALIZE reject independent safety mutations" 'if (typeof token !== "string" || !/^[\\x21-\\x7e]{1,4096}$/.test(token)) {', "if (false) {", ], - [ - "use raw tag-push token", - 'Buffer.from(`x-access-token:${token}`, "utf8").toString("base64")', - "token", - ], + ["use raw tag-push token", 'Buffer.from(`x-access-token:${token}`, "utf8").toString("base64")', "token"], [ "remove one-shot tag-push authentication", '["-c", pushConfiguration, "push", "--atomic", "origin", ...refs]', @@ -2137,7 +2456,7 @@ test("protected stable PREPARE and FINALIZE reject independent safety mutations" '["-c", pushConfiguration, "push", "--atomic", "origin", ...refs]', '["-c", pushConfiguration, "push", "origin", ...refs]', ], - ["publish all projects", 'states.filter((state) => state.npm === "absent")', "states"], + ["publish all projects", 'if (current.kind === "exact") continue', "if (false) continue"], ]) { const changed = mutate(stableFinalizeScript, before, after) assert.notDeepEqual(stableViolations(policy.stable, changed), [], name) @@ -2236,15 +2555,15 @@ test("stable mode jobs reject capability and credential drift", () => { ["persist checkout credentials", "persist-credentials: false", "persist-credentials: true"], ["remove protected environment", "environment: stable-release", "environment: unprotected"], [ - "enable FINALIZE lifecycle scripts", - " - name: 📦 Install publication tooling without lifecycle scripts\n run: pnpm install --frozen-lockfile --ignore-scripts", - " - name: 📦 Install publication tooling without lifecycle scripts\n run: pnpm install --frozen-lockfile", + "add FINALIZE dependency installation", + " run: node scripts/release-finalize-stable.mjs", + " run: |\n pnpm install --frozen-lockfile --ignore-scripts\n node scripts/release-finalize-stable.mjs", ], ["remove FINALIZE lifecycle-script environment", " NPM_CONFIG_IGNORE_SCRIPTS: true\n", ""], [ "give PREFLIGHT OIDC", - " preflight:\n name: 🔎 PREFLIGHT exact stable artifacts\n needs: validate\n if: ${{ needs.validate.outputs.mode == 'preflight' }}\n runs-on: ubuntu-latest\n permissions:\n contents: read", - " preflight:\n name: 🔎 PREFLIGHT exact stable artifacts\n needs: validate\n if: ${{ needs.validate.outputs.mode == 'preflight' }}\n runs-on: ubuntu-latest\n permissions:\n contents: read\n id-token: write", + " preflight:\n name: 🔎 PREFLIGHT exact stable artifacts\n needs: [validate, package_artifacts]\n if: ${{ needs.validate.outputs.mode == 'preflight' }}\n runs-on: ubuntu-latest\n permissions:\n contents: read", + " preflight:\n name: 🔎 PREFLIGHT exact stable artifacts\n needs: [validate, package_artifacts]\n if: ${{ needs.validate.outputs.mode == 'preflight' }}\n runs-on: ubuntu-latest\n permissions:\n contents: read\n id-token: write", ], ]) { assert.notDeepEqual(stableViolations(mutate(workflows.stable, before, after)), [], name) @@ -2407,13 +2726,14 @@ test("protected stable documentation exposes authorization and recovery boundari }) test("protected stable promotion exposes exact PREPARE and FINALIZE contracts", () => { - const active = withoutComments(`${workflows.stable}\n${stableFinalizeScript}`) + const active = withoutComments(`${workflows.stable}\n${stablePackageScript}\n${stableFinalizeScript}`) assert.match(active, /expected_sha:/) assert.match(active, /artifact_sha:/) assert.match(active, /ARTIFACT_SHA:\s*\$\{\{ inputs\.artifact_sha \}\}/) assert.match(active, /const artifactSha = process\.env\.ARTIFACT_SHA \|\| expectedSha/) assert.match(active, /const historicalReplay = artifactSha !== expectedSha/) - assert.match(active, /historical replay requires exact existing tag, GitHub Release, and npm latest/) + assert.match(active, /historical recovery requires exact existing tag/) + assert.match(active, /historical recovery requires exact existing GitHub Release/) assert.match(active, /MODE=prepare/) assert.match(active, /MODE=finalize/) assert.match(active, /jq -r ['"]?\.release\.projects\[\]['"]? nx\.json/) @@ -2423,15 +2743,14 @@ test("protected stable promotion exposes exact PREPARE and FINALIZE contracts", /pnpm nx release version "\$NEW" "--projects=\$PROJECT" --git-commit=false --git-tag=false --git-push=false --stage-changes=false/, ) assert.match(active, /push origin "HEAD:refs\/heads\/\$BRANCH"/) - assert.match( - active, - /run\("git", \["-c", pushConfiguration, "push", "--atomic", "origin", \.\.\.refs\]\)/, - ) + assert.match(active, /run\("git", \["-c", pushConfiguration, "push", "--atomic", "origin", \.\.\.refs\]\)/) assert.match(active, /Buffer\.from\(`x-access-token:\$\{token\}`, "utf8"\)\.toString\("base64"\)/) assert.match(active, /github\("POST", "\/releases"/) - assert.match(active, /run\("pnpm", \["nx", "release", "publish"/) + assert.match(active, /const NPM_REGISTRY = "https:\/\/registry\.npmjs\.org\/"/) + assert.match(active, exactStablePublishCall) + assert.match(active, /verifyStableHandoff/) assert.doesNotMatch(active, /--tag=(?:alpha|beta)/) - assert.match(active, /const maxReads = 6/) + assert.match(active, /const MAX_NPM_READS = 6/) assert.match(active, /NPM_READ_DELAY_MS/) assert.match(active, /await sleep\(delayMs\)/) }) From 63c4f34c2cfd28e09448023ee9001cdfe933ccdb Mon Sep 17 00:00:00 2001 From: kattsushi Date: Mon, 31 Aug 2026 11:50:33 -0600 Subject: [PATCH 4/4] fix(release): close stable recovery review gaps --- .github/SETUP.md | 2 +- .github/workflows/release-stable.yml | 2 +- scripts/release-finalize-stable.mjs | 65 ++++++++++--- scripts/release-finalize-stable.test.mjs | 113 ++++++++++++++++++++--- scripts/release-package-stable.mjs | 7 +- scripts/release-package-stable.test.mjs | 25 +++-- scripts/release-policy-contract.test.mjs | 9 +- 7 files changed, 175 insertions(+), 48 deletions(-) diff --git a/.github/SETUP.md b/.github/SETUP.md index 18297b36..6f74d161 100644 --- a/.github/SETUP.md +++ b/.github/SETUP.md @@ -30,7 +30,7 @@ Create the `stable-release` environment under **Settings > Environments** and re `package_artifacts` is read-only with respect to repository and public release state. It checks out the current `expected_sha` control plane and the exact `artifact_sha` source into separate directories, then installs with `--ignore-scripts`, builds without cache reuse, and packs only in the artifact checkout. The packaging helper verifies package identity, normalized manifests, runtime entrypoints, inventory, and digests before `actions/upload-artifact` uploads one run/attempt-named handoff with overwrite disabled. -PREFLIGHT and protected FINALIZE download that current-run handoff by exact artifact ID. Before any state decision, the finalizer binds the artifact ID and digest plus the handoff's repository, workflow path/ref/SHA, run ID/attempt, `expected_sha`, `artifact_sha`, and exact normalized selection; it then independently verifies every tarball and its recorded digests and inventory. Privileged FINALIZE installs no dependencies, builds or tests nothing, and runs no package code or package lifecycle scripts. It publishes only those verified tarballs, with scripts disabled, through npm trusted publishing with provenance. +PREFLIGHT and protected FINALIZE download that current-run handoff by exact artifact ID. The finalizer records and shape-checks the GitHub artifact digest; it does not recompute or compare the overall GitHub artifact archive digest. Before any state decision, it binds the artifact ID plus the handoff's repository, workflow path/ref/SHA, run ID/attempt, `expected_sha`, `artifact_sha`, and exact normalized selection. It validates the exact `handoff.json` schema and independently verifies every tarball against its recorded digests and inventory. Privileged FINALIZE installs no dependencies, builds or tests nothing, and runs no package code or package lifecycle scripts. It publishes only those verified tarballs, with scripts disabled, through npm trusted publishing with provenance. Declared job permissions, including `contents` and `id-token`, are available job-wide; when `id-token: write` is declared, OIDC is not step-scoped. The only step-scoped credential controls are explicit secret or token environment variables on their listed API or mutation steps. This environment scoping is defense in depth; it does not turn job permissions into step-only capabilities. Every checkout sets `persist-credentials: false`, so checkout credentials are not persisted. diff --git a/.github/workflows/release-stable.yml b/.github/workflows/release-stable.yml index 53b2a664..9d698df7 100644 --- a/.github/workflows/release-stable.yml +++ b/.github/workflows/release-stable.yml @@ -243,7 +243,7 @@ jobs: name: stable-handoff-${{ github.run_id }}-${{ github.run_attempt }} path: ${{ runner.temp }}/stable-handoff if-no-files-found: error - retention-days: 1 + retention-days: 5 overwrite: false prepare: diff --git a/scripts/release-finalize-stable.mjs b/scripts/release-finalize-stable.mjs index a313407f..1230aa41 100644 --- a/scripts/release-finalize-stable.mjs +++ b/scripts/release-finalize-stable.mjs @@ -25,6 +25,8 @@ const MAX_HISTORICAL_COMMITS = 8 const MAX_NPM_READS = 6 const MAX_NPM_CONFIG_BYTES = 64 * 1024 const MAX_TRACKED_NPM_CONFIGS = 64 +const MAX_OPERATIONAL_ERROR_CHARS = 2048 +const MAX_OPERATIONAL_DIAGNOSTIC_BYTES = 320 const NPM_REGISTRY = "https://registry.npmjs.org/" const NPM_ATTESTATION_PATH_PREFIX = "/-/npm/v1/attestations/" @@ -52,6 +54,37 @@ function sleep(ms) { function digest(algorithm, bytes) { return createHash(algorithm).update(bytes).digest("hex") } +function boundedUtf8(value, maximumBytes) { + let result = "" + let bytes = 0 + for (const character of value) { + const size = Buffer.byteLength(character) + if (bytes + size > maximumBytes) break + result += character + bytes += size + } + return result +} +export function operationalFailureDiagnostic(error) { + const message = typeof error?.message === "string" ? error.message.slice(0, MAX_OPERATIONAL_ERROR_CHARS) : "" + const sanitized = message + .replace(/\u001b\[[0-?]*[ -/]*[@-~]/gu, " ") + .replace(/\b[a-z][a-z0-9+.-]*:\/\/[^\s]+/giu, "[redacted URL]") + .replace(/\b(?:bearer|basic)\s+[^\s]+/giu, "[redacted credential]") + .replace(/\b[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/gu, "[redacted JWT]") + .replace(/\b(?:github_pat_|gh[pousr]_|npm_)[A-Za-z0-9_-]{8,}\b/gu, "[redacted token]") + .replace( + /((?:(?:auth|access|refresh|id)[_-]?token|_authToken|password|passwd|secret|credential|api[_-]?key)\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;]+)/giu, + "$1[redacted]", + ) + .replace(/[\u0000-\u001f\u007f-\u009f]/gu, " ") + .replace(/\s+/gu, " ") + .trim() + return boundedUtf8(sanitized || "operation failed without a safe message", MAX_OPERATIONAL_DIAGNOSTIC_BYTES) +} +function operationalCauseSuffix(error) { + return `; operation cause: ${operationalFailureDiagnostic(error)}` +} function validRuntimeBound(value, minimum, maximum) { return Number.isSafeInteger(value) && value >= minimum && value <= maximum } @@ -900,41 +933,47 @@ async function createCurrentArtifacts(states) { for (const { tag, local } of localTags) { if (local === "absent") await run("git", ["tag", "-a", tag, artifactSha, "-m", tag]) } + let tagPushFailure if (missingTags.length > 0) { const refs = missingTags.map( (item) => `refs/tags/${item.name}@${item.version}:refs/tags/${item.name}@${item.version}`, ) try { await run("git", ["-c", pushConfiguration, "push", "--atomic", "origin", ...refs]) - } catch { - // A lost response is accepted only if every remote tag reconciles exactly below. + } catch (error) { + tagPushFailure = error } } for (const item of states) { if ((await tagState(`${item.name}@${item.version}`)).kind !== "exact") { - fail(`remote tag postverification failed for ${item.name}@${item.version}`) + fail( + `remote tag postverification failed for ${item.name}@${item.version}${tagPushFailure ? operationalCauseSuffix(tagPushFailure) : ""}`, + ) } } + const releaseFailures = new Map() for (const item of states.filter((state) => state.release === "absent")) { - let result + const tag = `${item.name}@${item.version}` try { - result = await github("POST", "/releases", { - tag_name: `${item.name}@${item.version}`, + const result = await github("POST", "/releases", { + tag_name: tag, generate_release_notes: true, draft: false, prerelease: false, }) - } catch { - // A lost response is accepted only if the Release reconciles exactly below. - } - if (result && ![201, 422].includes(result.status)) { - fail(`GitHub Release creation failed for ${item.name}@${item.version} (HTTP ${result.status})`) + if (result.status !== 201) { + releaseFailures.set(tag, new Error(`GitHub Release creation failed for ${tag} (HTTP ${result.status})`)) + } + } catch (error) { + releaseFailures.set(tag, error) } } for (const item of states) { - if ((await releaseState(`${item.name}@${item.version}`)).kind !== "exact") { - fail(`GitHub Release postverification failed for ${item.name}@${item.version}`) + const tag = `${item.name}@${item.version}` + if ((await releaseState(tag)).kind !== "exact") { + const failure = releaseFailures.get(tag) + fail(`GitHub Release postverification failed for ${tag}${failure ? operationalCauseSuffix(failure) : ""}`) } } } diff --git a/scripts/release-finalize-stable.test.mjs b/scripts/release-finalize-stable.test.mjs index dbe9ceaf..869e0f0b 100644 --- a/scripts/release-finalize-stable.test.mjs +++ b/scripts/release-finalize-stable.test.mjs @@ -10,7 +10,15 @@ import test from "node:test" import { isDeepStrictEqual } from "node:util" import { gzipSync } from "node:zlib" +import { operationalFailureDiagnostic } from "./release-finalize-stable.mjs" + const script = new URL("release-finalize-stable.mjs", import.meta.url).pathname +const finalizerSource = readFileSync(script, "utf8") +function parseFrozenStringArray(source, name) { + const declaration = source.match(new RegExp(`const ${name} = Object\\.freeze\\(\\[([\\s\\S]*?)\\]\\)`)) + assert.ok(declaration, `${name} declaration`) + return JSON.parse(`[${declaration[1].replace(/,\s*$/, "")}]`) +} const artifactSha = "f31390ce66ea157ea8b75f5259c203123e269759" const advancedSha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" const parentSha = "fedcba0987654321fedcba0987654321fedcba09" @@ -46,16 +54,7 @@ const prisma = records.find(({ project }) => project === "@effectify/prisma") const publishable = records .filter(({ project }) => project !== prisma.project) .sort((left, right) => left.project.localeCompare(right.project)) -const allowedHistoricalPaths = [ - ".github/SETUP.md", - ".github/workflows/release-stable.yml", - "scripts/release-finalize-stable.mjs", - "scripts/release-finalize-stable.test.mjs", - "scripts/release-package-stable.mjs", - "scripts/release-package-stable.test.mjs", - "scripts/release-policy-contract.test.mjs", - "scripts/release-stable-abandonments.json", -] +const allowedHistoricalPaths = parseFrozenStringArray(finalizerSource, "ALLOWED_HISTORICAL_PATHS") const safeNpmrc = `# NPM Configuration for CI/CD #registry=https://registry.npmjs.org/ #always-auth=true @@ -230,9 +229,9 @@ if(cmd==="git"){ if(a[0]==="push"){ const token=process.env.GITHUB_TOKEN||"",basic=Buffer.from("x-access-token:"+token,"utf8").toString("base64") s.pushAuthentication={oneShot,matchesToken:authConfiguration==="http.https://github.com/.extraheader=AUTHORIZATION: basic "+basic,tokenLiteral:Boolean(token)&&raw.some(value=>value.includes(token))} - const materializeTags=()=>{for(const ref of a.slice(3)){const tag=ref.split(":")[0].slice(10);s.tags[tag]={peeled:s.localTags[tag].peeled}}} - if(s.pushMaterializesOnFailure){materializeTags();finish(s.pushExit||1)} - if(s.pushExit)finish(s.pushExit) + const materializeTags=()=>{for(const ref of a.slice(3)){const tag=ref.split(":")[0].slice(10);s.tags[tag]={peeled:s.localTags[tag].peeled}}},failPush=code=>{if(s.pushStderr)process.stderr.write(s.pushStderr);finish(code)} + if(s.pushMaterializesOnFailure){materializeTags();failPush(s.pushExit||1)} + if(s.pushExit)failPush(s.pushExit) materializeTags();finish() } finish(127) @@ -414,7 +413,10 @@ async function makeWorld({ historical = false, npmMode = "absent", artifacts = " } save(stateFile, current) if (current.ghCreateResponseLoss) return response.destroy() - send(status, status === 422 ? { message: "already exists" } : current.releases[tag]) + send( + status, + current.ghCreateResponseBody ?? (status === 422 ? { message: "already exists" } : current.releases[tag]), + ) }) }) await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)) @@ -768,7 +770,7 @@ await test("npm publish response loss and delayed registry visibility reconcile await test("npm failures are reconciled and reported with bounded sanitized fixed guidance", async (t) => { await scenario(t, { npmMode: "absent" }, async (world) => { const target = publishable[0] - const jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJzZWNyZXQifQ.signaturevalue" + const jwt = ["eyJhbGciOiJIUzI1NiJ9", "eyJzdWIiOiJzZWNyZXQifQ", "signaturevalue"].join(".") const secretValues = [ "bearer-secret", jwt, @@ -1065,6 +1067,16 @@ await test("historical recovery is bounded, allowlisted, npm-only, and reports a }) await test("historical recovery rejects excessive commits and every changed path outside the exact control-file allowlist", async (t) => { + await scenario(t, { historical: true, npmMode: "absent" }, async (world) => { + const state = load(world.stateFile) + state.historicalPaths = [...allowedHistoricalPaths] + save(world.stateFile, state) + + const result = await run(world, ["--preflight", "--json"], { GITHUB_ACTIONS: "" }) + assert.equal(result.status, 0, result.stderr) + assert.deepEqual(mutationCalls(load(world.stateFile)), []) + }) + for (const [name, setup, pattern] of [ ["too many commits", (state) => (state.historicalCount = 9), /commit-count bound/i], ["application path", (state) => state.historicalPaths.push("packages/hatchet/src/index.ts"), /changed path/i], @@ -1163,6 +1175,77 @@ await test("historical recovery requires all tags and Releases exact and abandon } }) +test("operational failure diagnostics retain safe context while bounding and redacting unsafe messages", () => { + const jwt = ["eyJhbGciOiJIUzI1NiJ9", "eyJzdWIiOiJzZWNyZXQifQ", "signaturevalue"].join(".") + const diagnostic = operationalFailureDiagnostic( + new Error( + `safe tag push failure \u001b[31mBearer credential-secret\u001b[0m ${jwt} ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ123456 https://user:url-password@example.test/path?token=query-secret ${"detail ".repeat(2_000)}`, + ), + ) + + assert.match(diagnostic, /safe tag push failure/) + assert.match(diagnostic, /\[redacted credential\]/) + assert.match(diagnostic, /\[redacted JWT\]/) + assert.match(diagnostic, /\[redacted token\]/) + assert.match(diagnostic, /\[redacted URL\]/) + assert.ok(Buffer.byteLength(diagnostic) <= 320) + for (const secret of [ + jwt, + "credential-secret", + "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ123456", + "url-password", + "query-secret", + ]) { + assert.equal(diagnostic.includes(secret), false) + } + assert.doesNotMatch(diagnostic, /\u001b|\x1b|\[31m/) +}) + +await test("failed tag pushes and Release creation retain only safe bounded causes after postverification", async (t) => { + const jwt = ["eyJhbGciOiJIUzI1NiJ9", "eyJzdWIiOiJzZWNyZXQifQ", "signaturevalue"].join(".") + const secretValues = [ + "push-token-secret", + jwt, + "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ123456", + "url-password", + "query-secret", + "https://user:url-password@example.test/path?token=query-secret", + ] + const noisy = `\u001b[31mBearer push-token-secret\u001b[0m ${jwt} ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ123456 https://user:url-password@example.test/path?token=query-secret ${"detail ".repeat(2_000)}` + + await scenario(t, { npmMode: "absent", artifacts: "absent" }, async (world) => { + const state = load(world.stateFile) + state.pushExit = 37 + state.pushStderr = noisy + save(world.stateFile, state) + + const result = await run(world) + assert.notEqual(result.status, 0) + assert.match(result.stderr, /remote tag postverification failed.*operation cause: git failed \(37\)/is) + assert.ok(result.stderr.length < 2_000, `diagnostic length: ${result.stderr.length}`) + for (const secret of secretValues) assert.equal(result.stderr.includes(secret), false) + assert.doesNotMatch(result.stderr, /\u001b|\x1b|\[31m/) + }) + + await scenario(t, { npmMode: "absent", artifacts: "absent" }, async (world) => { + const state = load(world.stateFile) + state.ghCreateStatus = 503 + state.ghCreateMaterializes = false + state.ghCreateResponseBody = { message: noisy } + save(world.stateFile, state) + + const result = await run(world) + assert.notEqual(result.status, 0) + assert.match( + result.stderr, + /GitHub Release postverification failed.*operation cause: GitHub Release creation failed.*HTTP 503/is, + ) + assert.ok(result.stderr.length < 2_000, `diagnostic length: ${result.stderr.length}`) + for (const secret of secretValues) assert.equal(result.stderr.includes(secret), false) + assert.doesNotMatch(result.stderr, /\u001b|\x1b|\[31m/) + }) +}) + await test("current tag and Release response loss still reconcile exact authenticated state", async (t) => { await scenario(t, { npmMode: "absent", artifacts: "absent" }, async (world) => { const state = load(world.stateFile) diff --git a/scripts/release-package-stable.mjs b/scripts/release-package-stable.mjs index 06644f2f..10b15934 100644 --- a/scripts/release-package-stable.mjs +++ b/scripts/release-package-stable.mjs @@ -450,10 +450,9 @@ export async function createStableHandoff({ abandonments, packages, } - await writeFile(join(absoluteOutput, "handoff.json"), `${JSON.stringify(handoff, null, 2)}\n`, { - flag: "wx", - mode: 0o600, - }) + const handoffBytes = Buffer.from(`${JSON.stringify(handoff, null, 2)}\n`) + if (handoffBytes.length > MAX_HANDOFF_BYTES) fail("stable handoff exceeds its size bound") + await writeFile(join(absoluteOutput, "handoff.json"), handoffBytes, { flag: "wx", mode: 0o600 }) return handoff } diff --git a/scripts/release-package-stable.test.mjs b/scripts/release-package-stable.test.mjs index 31e5ea95..0018557a 100644 --- a/scripts/release-package-stable.test.mjs +++ b/scripts/release-package-stable.test.mjs @@ -1,9 +1,9 @@ import assert from "node:assert/strict" import { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs" -import { cp, readFile, rm, writeFile } from "node:fs/promises" +import { readFile, readdir, rm, writeFile } from "node:fs/promises" import { gzipSync } from "node:zlib" import { tmpdir } from "node:os" -import { basename, join } from "node:path" +import { join } from "node:path" import test from "node:test" import { createStableHandoff, verifyStableHandoff } from "./release-package-stable.mjs" @@ -214,12 +214,8 @@ test("create packs only non-abandoned projects and verifies an exact schema-vers assert.equal(created.abandonments[0].project, "@effectify/prisma") assert.deepEqual((await world.verify()).packages, created.packages) - const files = (await import("node:fs/promises")).readdir(world.outputDirectory) - assert.deepEqual((await files).sort(), [ - "effectify-hatchet-0.2.0.tgz", - "effectify-react-query-1.0.1.tgz", - "handoff.json", - ]) + const files = await readdir(world.outputDirectory) + assert.deepEqual(files.sort(), ["effectify-hatchet-0.2.0.tgz", "effectify-react-query-1.0.1.tgz", "handoff.json"]) const calls = readFileSync(world.log, "utf8").trim().split("\n").map(JSON.parse) assert.equal(calls.length, 2) assert.equal( @@ -232,6 +228,19 @@ test("create packs only non-abandoned projects and verifies an exact schema-vers } }) +test("oversized create rejects before persisting handoff.json", async (t) => { + const oversizedEntries = Object.fromEntries( + Array.from({ length: 9_000 }, (_, index) => [ + `package/dist/oversized/${String(index).padStart(5, "0")}-${"x".repeat(64)}.js`, + "x", + ]), + ) + const world = await fixture(t, { entries: { "@effectify/hatchet": oversizedEntries } }) + + await assert.rejects(world.create, /handoff exceeds its size bound/i) + assert.equal((await readdir(world.outputDirectory)).includes("handoff.json"), false) +}) + test("create accepts an unselected prerelease release project without packing it", async (t) => { const world = await fixture(t) const prereleaseName = "@effectify/canary" diff --git a/scripts/release-policy-contract.test.mjs b/scripts/release-policy-contract.test.mjs index feecdba4..5cbac9f7 100644 --- a/scripts/release-policy-contract.test.mjs +++ b/scripts/release-policy-contract.test.mjs @@ -389,7 +389,7 @@ const stableArtifactTopologyViolations = (source) => { !/name:\s*stable-handoff-\$\{\{ github\.run_id \}\}-\$\{\{ github\.run_attempt \}\}/.test(upload.source) || !/path:\s*\$\{\{ runner\.temp \}\}\/stable-handoff/.test(upload.source) || !/if-no-files-found:\s*error/.test(upload.source) || - !/retention-days:\s*1/.test(upload.source) || + !/^\s*retention-days:\s*5\s*$/m.test(upload.source) || !/overwrite:\s*false/.test(upload.source) ) { violations.push("stable package_artifacts single immutable upload") @@ -1363,11 +1363,7 @@ const stableViolations = (source, finalizeScript = stableFinalizeScript) => { /\["-c", pushConfiguration, "push", "--atomic", "origin", \.\.\.refs\]/, activeFinalize, ], - [ - "release exact postverification", - /releaseState\(`\$\{item\.name\}@\$\{item\.version\}`\)\)\.kind !== "exact"/, - activeFinalize, - ], + ["release exact postverification", /releaseState\(tag\)\)\.kind !== "exact"/, activeFinalize], [ "missing npm subset", /const current = await npmBounded\(record, handoffPackage, \{ acceptAbsent: true \}\)[\s\S]*if \(current\.kind === "exact"\) continue/, @@ -2553,6 +2549,7 @@ test("stable mode jobs reject capability and credential drift", () => { " validate:\n name: 🔎 Validate stable request\n runs-on: ubuntu-latest\n permissions:\n contents: write", ], ["persist checkout credentials", "persist-credentials: false", "persist-credentials: true"], + ["shorten immutable handoff retention", "retention-days: 5", "retention-days: 1"], ["remove protected environment", "environment: stable-release", "environment: unprotected"], [ "add FINALIZE dependency installation",