Merge branch 'main' into feature/techlog-ui-migration
Integrates the frontend template sync (a0fbafb → 5434760) into the TechLog UI migration. Merged in this direction so every conflict is resolved and proved in the worktree; main is only fast-forwarded afterwards and never holds a state that was not verified here. 15 conflicts. The rule throughout: keep the template's mechanism, keep the product's content, and never invent a third state neither branch would accept. The template's product manifest and its runtime feature kill switch are adopted. The route registry is deliberately not composed from contract.routes: the reference feature still declares screens this product deleted, and reducing over them would register paths with no component behind them. ROUTE_FEATURE_OWNER is narrowed to registered routes for the same reason. The first resolution did compose from contract.routes and was rejected by product-features.test.ts. Three files pinned counts and a digest describing the gate contract. Neither side's numbers describe the merged config/ci/gates.json, so they were recomputed from it rather than chosen: 27 gates, 82 commands, 94 command references, 107 evidence references, 128 artifacts, shape sha256 5063586d. README.md and docs/accessibility/manual-checklist.md now enumerate this product's 27 routes, which the template's own verify:documentation requires. product-feature-switch.test.tsx was rewritten around the invariant that still applies here — no registered route without a component — rather than deleted with the screens it used to exercise. docs/operations/template-merge-2026-08-17.md records every decision, the gate results, and the three follow-ups this merge deliberately did not decide. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -35,6 +35,7 @@ import {
|
||||
registryGovernanceRunArtifactSchema,
|
||||
registryCompatibilityFixturesArtifactSchema,
|
||||
registrySnapshotArtifactSchema,
|
||||
deploymentAdmissionArtifactSchema,
|
||||
releaseVerificationArtifactSchema,
|
||||
reproducibleBuildArtifactSchema,
|
||||
runbookRecordArtifactSchema,
|
||||
@@ -238,6 +239,7 @@ const executableJsonSchemas: Readonly<Record<ExecutableJsonSchemaId, ZodType>> =
|
||||
"provider-provenance": provenanceProviderAttestationSchema,
|
||||
"provider-verification": providerVerificationArtifactSchema,
|
||||
"ci-contract-report": ciContractReportSchema,
|
||||
"deployment-admission": deploymentAdmissionArtifactSchema,
|
||||
});
|
||||
|
||||
export function hasCiArtifactSemanticValidator(
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { FileHandle } from "node:fs/promises";
|
||||
import {
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
open,
|
||||
readFile,
|
||||
readdir,
|
||||
@@ -28,6 +27,10 @@ import {
|
||||
assertSafePublishLeaf,
|
||||
ensureSafePublishDirectory,
|
||||
} from "./ci-gate-log.ts";
|
||||
import {
|
||||
makePrivateTemporaryDirectory,
|
||||
withPrivateUmask,
|
||||
} from "./private-filesystem.ts";
|
||||
|
||||
const MAX_ARCHIVE_BYTES = 268_435_456;
|
||||
const MAX_CANDIDATE_FILES = 4_096;
|
||||
@@ -147,11 +150,11 @@ export async function verifyCiCandidateArchive(
|
||||
path.dirname(extractionTarget),
|
||||
);
|
||||
await assertSafePublishLeaf(extractionTarget, input.extractTo);
|
||||
extractionRoot = await mkdtemp(
|
||||
extractionRoot = makePrivateTemporaryDirectory(
|
||||
path.join(path.dirname(extractionTarget), `.${path.basename(extractionTarget)}.verified-`),
|
||||
);
|
||||
} else {
|
||||
extractionRoot = await mkdtemp(path.join(tmpdir(), "ci-candidate-archive-"));
|
||||
extractionRoot = makePrivateTemporaryDirectory(path.join(tmpdir(), "ci-candidate-archive-"));
|
||||
}
|
||||
let published = false;
|
||||
try {
|
||||
@@ -221,7 +224,7 @@ export async function verifyCapturedCiCandidateArchive(
|
||||
throw new Error("candidate archive SHA-256 mismatch");
|
||||
}
|
||||
const captured = await materializeCapturedArchive(archive);
|
||||
const extractionRoot = await mkdtemp(path.join(tmpdir(), "ci-captured-candidate-"));
|
||||
const extractionRoot = makePrivateTemporaryDirectory(path.join(tmpdir(), "ci-captured-candidate-"));
|
||||
try {
|
||||
const manifest = preflightArchiveHandle(captured.handle);
|
||||
extractArchiveHandle(captured.handle, extractionRoot);
|
||||
@@ -318,25 +321,33 @@ function preflightArchiveHandle(archiveHandle: FileHandle): ReleaseCandidateMani
|
||||
}
|
||||
|
||||
function extractArchiveHandle(archiveHandle: FileHandle, extractionRoot: string): void {
|
||||
const extracted = spawnSync(
|
||||
TAR_EXECUTABLE,
|
||||
[
|
||||
"--extract",
|
||||
"--gzip",
|
||||
"--file",
|
||||
"/proc/self/fd/3",
|
||||
"--directory",
|
||||
extractionRoot,
|
||||
"--no-same-owner",
|
||||
"--no-same-permissions",
|
||||
],
|
||||
{
|
||||
encoding: "utf8",
|
||||
maxBuffer: 1_048_576,
|
||||
timeout: 30_000,
|
||||
env: TAR_ENVIRONMENT,
|
||||
stdio: ["ignore", "pipe", "pipe", archiveHandle.fd],
|
||||
},
|
||||
// `--no-same-permissions` is what keeps an untrusted archive from choosing
|
||||
// its own modes, but it hands the decision to the inherited umask instead.
|
||||
// Under a hardened `umask 077x` tar then creates directories it cannot
|
||||
// descend into and extraction fails part-way. Pinning the umask for the
|
||||
// duration makes the extracted tree exactly private, whatever the caller's
|
||||
// ambient state is. `spawnSync` keeps this window free of interleaved work.
|
||||
const extracted = withPrivateUmask(() =>
|
||||
spawnSync(
|
||||
TAR_EXECUTABLE,
|
||||
[
|
||||
"--extract",
|
||||
"--gzip",
|
||||
"--file",
|
||||
"/proc/self/fd/3",
|
||||
"--directory",
|
||||
extractionRoot,
|
||||
"--no-same-owner",
|
||||
"--no-same-permissions",
|
||||
],
|
||||
{
|
||||
encoding: "utf8",
|
||||
maxBuffer: 1_048_576,
|
||||
timeout: 30_000,
|
||||
env: TAR_ENVIRONMENT,
|
||||
stdio: ["ignore", "pipe", "pipe", archiveHandle.fd],
|
||||
},
|
||||
),
|
||||
);
|
||||
if (extracted.status !== 0 || extracted.signal || extracted.error) {
|
||||
throw new Error(
|
||||
@@ -592,7 +603,7 @@ function readManifestFromArchive(archiveHandle: FileHandle): ReleaseCandidateMan
|
||||
async function materializeCapturedArchive(
|
||||
archive: Buffer,
|
||||
): Promise<Readonly<{ root: string; handle: FileHandle }>> {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "ci-captured-archive-"));
|
||||
const root = makePrivateTemporaryDirectory(path.join(tmpdir(), "ci-captured-archive-"));
|
||||
const file = path.join(root, "candidate.tar.gz");
|
||||
let handle: FileHandle | undefined;
|
||||
try {
|
||||
@@ -601,6 +612,11 @@ async function materializeCapturedArchive(
|
||||
constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
|
||||
0o600,
|
||||
);
|
||||
// `open` subtracts the umask too. The extractor re-opens this file by
|
||||
// `/proc/self/fd/N` from a child process, and that re-open is a real
|
||||
// permission check, so a umask-zeroed mode makes `tar` fail to read the
|
||||
// candidate it was just handed.
|
||||
await handle.chmod(0o600);
|
||||
await handle.writeFile(archive);
|
||||
await handle.sync();
|
||||
await unlink(file);
|
||||
|
||||
@@ -4,7 +4,7 @@ export const ciContractReportSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(2),
|
||||
nodeVersion: z.string().regex(/^\d+\.\d+\.\d+$/u),
|
||||
gateCount: z.literal(26),
|
||||
gateCount: z.literal(27),
|
||||
commandDefinitionCount: z.number().int().positive(),
|
||||
commandReferenceCount: z.number().int().positive(),
|
||||
artifactCount: z.number().int().positive(),
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { mkdirSync, mkdtempSync } from "node:fs";
|
||||
|
||||
/**
|
||||
* Creation modes that must not depend on the caller's ambient umask.
|
||||
*
|
||||
* `mkdir(path, { mode: 0o700 })` and `open(path, ..., 0o600)` are requests, not
|
||||
* guarantees: the kernel subtracts the process umask from every one of them. A
|
||||
* runner hardened with `umask 0777` therefore produces directories nobody can
|
||||
* enter and files nobody can read, and the failure surfaces far from its cause
|
||||
* — as `tar` failing to mkdir a nested path, or as EACCES opening a staging
|
||||
* leaf this process created moments earlier.
|
||||
*
|
||||
* Release evidence has to be exactly private, so the mode is pinned rather than
|
||||
* inherited. The pin is held across a synchronous call only: nothing else in
|
||||
* this process can interleave, so the global umask is never observably changed.
|
||||
*/
|
||||
const PRIVATE_UMASK = 0o077;
|
||||
|
||||
export function withPrivateUmask<T>(operation: () => T): T {
|
||||
const previous = process.umask(PRIVATE_UMASK);
|
||||
try {
|
||||
return operation();
|
||||
} finally {
|
||||
process.umask(previous);
|
||||
}
|
||||
}
|
||||
|
||||
/** Creates a uniquely named private directory under `prefix`. */
|
||||
export function makePrivateTemporaryDirectory(prefix: string): string {
|
||||
return withPrivateUmask(() => mkdtempSync(prefix));
|
||||
}
|
||||
|
||||
/** Creates `target` privately, failing if it already exists. */
|
||||
export function makePrivateDirectory(target: string): void {
|
||||
withPrivateUmask(() => mkdirSync(target, { mode: 0o700 }));
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
import { constants } from "node:fs";
|
||||
import {
|
||||
lstat,
|
||||
mkdir,
|
||||
open,
|
||||
readdir,
|
||||
rm,
|
||||
@@ -38,6 +37,7 @@ import {
|
||||
} from "./ci-candidate-archive.ts";
|
||||
import { verifyArchivedLocalEvidence } from "./local-release-evidence.ts";
|
||||
import { readBoundedRegularFile } from "./ci-artifact-validator.ts";
|
||||
import { makePrivateDirectory } from "./private-filesystem.ts";
|
||||
|
||||
|
||||
export type StagedFile = Readonly<{
|
||||
@@ -334,6 +334,18 @@ export async function cleanupFinalizedPromotion(input: Readonly<{
|
||||
await dependencies.beforeRemove?.();
|
||||
const visibleParent = await lstat(parent);
|
||||
assertRunnerTempIdentity(visibleParent, input.runnerTempIdentity);
|
||||
// Re-bind the name to the inode before removing anything.
|
||||
//
|
||||
// The removals below run through the pinned staging descriptor, so they
|
||||
// always reach the owned inode even after the name has been re-pointed
|
||||
// somewhere else. That is safe for the substitute, but it destroys this
|
||||
// promotion's exact five first and only reports the substitution
|
||||
// afterwards — a caller that retries then finds a half-emptied staging
|
||||
// directory and no way to tell a completed cleanup from an interrupted
|
||||
// one. Detecting the swap here makes the failure total: nothing is
|
||||
// removed unless the leaf still is what was validated.
|
||||
assertStagingIdentity(await lstat(descriptorExpected), input.stagingIdentity);
|
||||
assertStagingIdentity(await stat(stagingDescriptorRoot), input.stagingIdentity);
|
||||
for (const name of PROMOTED_FILE_NAMES) {
|
||||
await rm(path.join(stagingDescriptorRoot, name), { force: false });
|
||||
}
|
||||
@@ -456,7 +468,7 @@ export async function publishPrivatePromotionStaging(
|
||||
try {
|
||||
const procMetadata = await stat(descriptorRoot);
|
||||
if (!procMetadata.isDirectory()) throw new Error("descriptor-relative staging is unavailable");
|
||||
await mkdir(descriptorStaging, { mode: 0o700 });
|
||||
makePrivateDirectory(descriptorStaging);
|
||||
ownsStaging = true;
|
||||
const createdStaging = await lstat(descriptorStaging);
|
||||
if (!createdStaging.isDirectory() || createdStaging.isSymbolicLink()) {
|
||||
|
||||
@@ -112,6 +112,21 @@ export function systemdRunProviderArguments(
|
||||
|
||||
export type ProviderScopeFrame = Readonly<{
|
||||
bwrapInput: Buffer;
|
||||
/**
|
||||
* The sandboxed command, kept out of the args file on purpose.
|
||||
*
|
||||
* `bwrap --args FD` splices the file's options into the option stream, but
|
||||
* bubblewrap stops at the first non-option and never propagates the command
|
||||
* back out of the recursive parse. A command written into the args file is
|
||||
* therefore silently dropped and bubblewrap exits with its usage text, so
|
||||
* the sandbox is never entered and the provider produces no evidence at all.
|
||||
* Only the options may be hidden; the command travels on real argv.
|
||||
*
|
||||
* Nothing secret lives here: credentials and the provider command reach the
|
||||
* sandbox through `--setenv` inside the args file, and this vector only ever
|
||||
* names `prlimit` and a shell that expands `$PROVIDER_COMMAND`.
|
||||
*/
|
||||
bwrapCommand: readonly string[];
|
||||
reportPath: string;
|
||||
reportDev: number;
|
||||
reportIno: number;
|
||||
@@ -126,8 +141,10 @@ export function encodeProviderScopeFrame(input: ProviderScopeFrame): Buffer {
|
||||
) {
|
||||
throw new TypeError("provider scope frame is invalid");
|
||||
}
|
||||
assertBwrapCommand(input.bwrapCommand);
|
||||
const payload = Buffer.from(JSON.stringify({
|
||||
bwrapInputBase64: input.bwrapInput.toString("base64"),
|
||||
bwrapCommand: [...input.bwrapCommand],
|
||||
reportPath: input.reportPath,
|
||||
reportDev: input.reportDev,
|
||||
reportIno: input.reportIno,
|
||||
@@ -138,13 +155,36 @@ export function encodeProviderScopeFrame(input: ProviderScopeFrame): Buffer {
|
||||
return frame;
|
||||
}
|
||||
|
||||
/**
|
||||
* The command vector bubblewrap will exec. It has to be an absolute executable
|
||||
* so the sandbox never resolves it through a `PATH` the caller controls.
|
||||
*/
|
||||
export function assertBwrapCommand(command: readonly string[]): void {
|
||||
if (
|
||||
!Array.isArray(command) || command.length === 0 ||
|
||||
typeof command[0] !== "string" || !command[0].startsWith("/") ||
|
||||
command.some((argument) =>
|
||||
typeof argument !== "string" || argument.includes("\0"),
|
||||
)
|
||||
) {
|
||||
throw new TypeError("provider bwrap command is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
export function encodeProviderBwrapInput(
|
||||
arguments_: readonly string[],
|
||||
optionArguments: readonly string[],
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
): Buffer {
|
||||
if (arguments_.some((argument) => argument.includes("\0"))) {
|
||||
if (optionArguments.some((argument) => argument.includes("\0"))) {
|
||||
throw new TypeError("provider bwrap argument is invalid");
|
||||
}
|
||||
// A bare `--` ends bubblewrap's option stream. Inside an args file that also
|
||||
// ends the recursive parse, so everything after it is discarded rather than
|
||||
// executed. Refusing it here keeps the drop from being reintroduced by a
|
||||
// caller that appends a command to the option list.
|
||||
if (optionArguments.includes("--")) {
|
||||
throw new TypeError("provider bwrap options may not terminate the option stream");
|
||||
}
|
||||
const entries = Object.entries(environment).sort(([left], [right]) =>
|
||||
left < right ? -1 : left > right ? 1 : 0,
|
||||
);
|
||||
@@ -155,7 +195,7 @@ export function encodeProviderBwrapInput(
|
||||
}
|
||||
const input = ["--clearenv"];
|
||||
for (const [name, value] of entries) input.push("--setenv", name, value ?? "");
|
||||
input.push(...arguments_);
|
||||
input.push(...optionArguments);
|
||||
return Buffer.from(`${input.join("\0")}\0`);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { closeSync, createReadStream, writeSync } from "node:fs";
|
||||
import { closeSync, writeSync } from "node:fs";
|
||||
import { Socket } from "node:net";
|
||||
|
||||
import { cleanupOwnedProviderReport } from "./provider-raw-cleanup.ts";
|
||||
|
||||
@@ -10,7 +11,18 @@ let expectedBytes: number | undefined;
|
||||
let provider: ReturnType<typeof spawn> | undefined;
|
||||
let providerClosed = false;
|
||||
let livenessLost = false;
|
||||
const liveness = createReadStream("", { fd: 0, autoClose: false });
|
||||
/**
|
||||
* The supervisor keeps this pipe open for the scope's whole life — that is how
|
||||
* parent loss is observed — and only ever writes one frame into it.
|
||||
*
|
||||
* It must be read through libuv's event loop, not through `fs`. An `fs` read
|
||||
* runs a blocking `read(2)` on a threadpool thread, and on a pipe with a live
|
||||
* writer that call never returns. Closing the descriptor does not interrupt it,
|
||||
* so once bubblewrap exits the wrapper deadlocks in `process.exit` waiting to
|
||||
* join that thread: the scope outlives the provider, the supervisor's wall
|
||||
* clock expires, and a completed provider is reported as a timeout kill.
|
||||
*/
|
||||
const liveness = openLivenessChannel();
|
||||
|
||||
liveness.on("data", (chunk: Buffer | string) => {
|
||||
if (provider) {
|
||||
@@ -46,10 +58,17 @@ function launchProvider(payload: Buffer): void {
|
||||
throw new TypeError("provider scope frame identity does not match its launch identity");
|
||||
}
|
||||
const bwrapInput = Buffer.from(frame.bwrapInputBase64, "base64");
|
||||
provider = spawn("/usr/bin/bwrap", ["--args", "0"], {
|
||||
// The options are read from fd 0; the command must stay on real argv because
|
||||
// bubblewrap discards whatever follows the option stream inside an args file.
|
||||
provider = spawn("/usr/bin/bwrap", ["--args", "0", "--", ...frame.bwrapCommand], {
|
||||
detached: true,
|
||||
stdio: ["pipe", "inherit", "inherit"],
|
||||
});
|
||||
// bubblewrap can exit before the options are fully written — a usage error
|
||||
// closes fd 0 immediately. Without this the EPIPE would surface as an
|
||||
// unhandled stream error and the scope would be torn down as a crash rather
|
||||
// than reported as the provider exit it is.
|
||||
provider.stdin?.once("error", () => {});
|
||||
provider.stdin?.end(bwrapInput);
|
||||
provider.once("error", (error) => finishProvider(frame, null, null, error));
|
||||
provider.once("close", (code, signal) => finishProvider(frame, code, signal));
|
||||
@@ -102,18 +121,34 @@ function terminateForProtocolFailure(message: string): void {
|
||||
terminateForParentLoss();
|
||||
}
|
||||
|
||||
function openLivenessChannel(): Socket {
|
||||
try {
|
||||
return new Socket({ fd: 0, readable: true, writable: false });
|
||||
} catch (error) {
|
||||
// Without an observable parent this process cannot be trusted to notice
|
||||
// supervisor loss, and an unsupervised sandbox is worse than no run.
|
||||
writeSync(2, `provider scope liveness channel is unavailable: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}\n`);
|
||||
process.exit(125);
|
||||
}
|
||||
}
|
||||
|
||||
function closeLivenessInput(): void {
|
||||
liveness.removeAllListeners();
|
||||
liveness.destroy();
|
||||
try {
|
||||
closeSync(0);
|
||||
} catch (error) {
|
||||
// `Socket.destroy()` owns the descriptor and closes it itself, so a second
|
||||
// close is expected rather than exceptional.
|
||||
if (!hasErrorCode(error, "EBADF")) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function parseFrame(payload: Buffer): Readonly<{
|
||||
bwrapInputBase64: string;
|
||||
bwrapCommand: readonly string[];
|
||||
reportPath: string;
|
||||
reportDev: number;
|
||||
reportIno: number;
|
||||
@@ -127,14 +162,26 @@ function parseFrame(payload: Buffer): Readonly<{
|
||||
) {
|
||||
throw new TypeError("provider scope frame payload is invalid");
|
||||
}
|
||||
assertBwrapCommand(value.bwrapCommand);
|
||||
return {
|
||||
bwrapInputBase64: value.bwrapInputBase64,
|
||||
bwrapCommand: Object.freeze([...value.bwrapCommand]),
|
||||
reportPath: value.reportPath,
|
||||
reportDev: Number(value.reportDev),
|
||||
reportIno: Number(value.reportIno),
|
||||
};
|
||||
}
|
||||
|
||||
function assertBwrapCommand(value: unknown): asserts value is readonly string[] {
|
||||
if (
|
||||
!Array.isArray(value) || value.length === 0 ||
|
||||
typeof value[0] !== "string" || !value[0].startsWith("/") ||
|
||||
value.some((argument) => typeof argument !== "string" || argument.includes("\0"))
|
||||
) {
|
||||
throw new TypeError("provider scope frame command is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
function parseReportIdentity(arguments_: readonly string[]): Readonly<{
|
||||
cpuSeconds: number;
|
||||
reportPath: string;
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
readFile,
|
||||
readdir,
|
||||
rm,
|
||||
stat,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
@@ -24,9 +25,75 @@ export const REMOVAL_FIXTURE_COPY_TARGETS = Object.freeze([
|
||||
"vite.service-worker.config.ts", "vite.config.ts", "vitest.config.ts",
|
||||
"playwright.config.ts", "playwright.capabilities.config.ts", "playwright.dev.config.ts",
|
||||
"playwright.storybook.config.ts", "playwright.visual.config.ts", "eslint.config.ts",
|
||||
".dependency-cruiser.json", ".nvmrc",
|
||||
".dependency-cruiser.json", ".nvmrc", ".gitignore",
|
||||
// Install and workspace identity. Without these the fixture is not the same
|
||||
// project: `corepack pnpm` resolves a different store, and the provider
|
||||
// suites — which build a release candidate containing `pnpm-lock.yaml` —
|
||||
// cannot assemble their fixture at all.
|
||||
".npmrc", "pnpm-lock.yaml", "pnpm-workspace.yaml",
|
||||
] as const);
|
||||
|
||||
/**
|
||||
* This is the only copy-target list. Each removal script used to keep its own,
|
||||
* and they drifted: the reference-feature fixture omitted
|
||||
* `playwright.capabilities.config.ts`, which the repository file inventory
|
||||
* requires, so supply-chain generation failed inside the fixture and took every
|
||||
* provider suite down with it — twenty-odd failures with one cause.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Regenerated result trees under `artifacts/`: traces, coverage HTML, recorded
|
||||
* videos and Storybook bundles. They are tens of megabytes and mean nothing to
|
||||
* a fixture. Everything else under `artifacts/` is release evidence a candidate
|
||||
* is assembled from — and most of it is git-ignored too, so "is it tracked?"
|
||||
* cannot be used to tell the two apart. `keepsReleaseEvidence` in
|
||||
* tests/unit/removal-fixture.test.ts pins both halves of this split.
|
||||
*/
|
||||
const REGENERATED_ARTIFACT_TREES: readonly string[] = Object.freeze([
|
||||
"artifacts/storybook",
|
||||
"artifacts/tests/browser-capabilities",
|
||||
"artifacts/tests/coverage",
|
||||
"artifacts/tests/e2e",
|
||||
"artifacts/tests/storybook",
|
||||
"artifacts/tests/visual",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Copies the release evidence a candidate build needs into a fixture root.
|
||||
*
|
||||
* A fixture that omits it cannot assemble a candidate archive at all, so every
|
||||
* provider suite fails while constructing its own fixture — long before it
|
||||
* reaches an assertion, and with an error that says nothing about the
|
||||
* capability under test.
|
||||
*/
|
||||
export async function copyReleaseEvidenceTree(
|
||||
sourceRoot: string,
|
||||
destinationRoot: string,
|
||||
): Promise<void> {
|
||||
const source = path.join(sourceRoot, "artifacts");
|
||||
try {
|
||||
await stat(source);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
await cp(source, path.join(destinationRoot, "artifacts"), {
|
||||
recursive: true,
|
||||
filter: (candidate) => {
|
||||
const relative = path.relative(sourceRoot, candidate).split(path.sep).join("/");
|
||||
return !REGENERATED_ARTIFACT_TREES.some(
|
||||
(tree) => relative === tree || relative.startsWith(`${tree}/`),
|
||||
);
|
||||
},
|
||||
});
|
||||
// The result directories still have to exist: several are tracked through a
|
||||
// `.gitkeep` the repository inventory expects to find.
|
||||
for (const tree of REGENERATED_ARTIFACT_TREES) {
|
||||
await mkdir(path.join(destinationRoot, tree), { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
export const RELEASE_EVIDENCE_REGENERATED_TREES = REGENERATED_ARTIFACT_TREES;
|
||||
|
||||
export function requireRemovalFixtureEnvironment(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value) throw new Error(`${name} is required for removal verification`);
|
||||
@@ -42,9 +109,56 @@ export async function prepareRemovalFixture(
|
||||
for (const target of copyTargets) {
|
||||
await cp(target, path.join(root, target), { recursive: true });
|
||||
}
|
||||
await copyReleaseEvidenceTree(process.cwd(), root);
|
||||
runFixtureGit(root, ["init", "--quiet", "--initial-branch=fixture"]);
|
||||
await linkFixtureNodeModules(root);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the fixture's post-removal contents as its repository state.
|
||||
*
|
||||
* The release candidate path asks `git ls-files` what the repository contains —
|
||||
* the supply-chain inventory is defined as the tracked file set, not as
|
||||
* whatever happens to be on disk. A fixture without a repository cannot answer
|
||||
* that, so supply-chain generation failed and took every provider suite down
|
||||
* with it; the claim "this build still produces a release candidate after the
|
||||
* capability is removed" was never actually being tested.
|
||||
*
|
||||
* It runs after the removal, not during preparation: an index recorded before
|
||||
* the deletions still lists the removed files, and the inventory then demands
|
||||
* files the fixture exists to prove are gone.
|
||||
*/
|
||||
export function sealRemovalFixtureRepository(root: string): void {
|
||||
// `.gitignore` travels with the fixture, so the tracked set it records is the
|
||||
// same tracked set the real repository has. Without it every generated
|
||||
// artifact and every linked module landed in the index, and the supply-chain
|
||||
// inventory refused the fixture for having tracked and generated paths
|
||||
// collide — the fixture disagreed with the repository it was copied from.
|
||||
runFixtureGit(root, ["add", "--all"]);
|
||||
runFixtureGit(root, ["commit", "--quiet", "--no-gpg-sign", "-m", "removal fixture"]);
|
||||
}
|
||||
|
||||
function runFixtureGit(root: string, argv: readonly string[]): void {
|
||||
const result = spawnSync("git", [...argv], {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_AUTHOR_NAME: "removal-fixture",
|
||||
GIT_AUTHOR_EMAIL: "removal-fixture@localhost",
|
||||
GIT_COMMITTER_NAME: "removal-fixture",
|
||||
GIT_COMMITTER_EMAIL: "removal-fixture@localhost",
|
||||
},
|
||||
});
|
||||
if (result.error || result.status !== 0) {
|
||||
throw new Error(
|
||||
`removal fixture repository setup failed at git ${argv[0]}: ${
|
||||
result.stderr || result.error?.message || `exit ${result.status}`
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function runRemovalFixturePnpm(
|
||||
root: string,
|
||||
pnpmCli: string,
|
||||
@@ -162,6 +276,45 @@ export function pruneScriptOrchestration(
|
||||
export async function regenerateRemovalFixtureWorkflow(root: string): Promise<void> {
|
||||
const contract = await loadCiGateContract(root, { mode: "removal-fixture" });
|
||||
await generateCiWorkflow({ root, contract, check: false });
|
||||
// Every removal script calls this once, after it has finished mutating the
|
||||
// tree, so it is the one place where the fixture's contents are final.
|
||||
await pruneRemovalFixtureInventoryRoots(root);
|
||||
sealRemovalFixtureRepository(root);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops repository roots the removal deleted from the supply-chain inventory
|
||||
* policy.
|
||||
*
|
||||
* The policy lists `recipes` as a required tracked root, and removing an
|
||||
* optional recipe deletes exactly that directory. Supply-chain generation then
|
||||
* refused the fixture for missing a root the removal was supposed to remove, so
|
||||
* the capability could never be shown to be removable. A root that is not on
|
||||
* disk after the removal is not required of the result.
|
||||
*/
|
||||
async function pruneRemovalFixtureInventoryRoots(root: string): Promise<void> {
|
||||
const policyPath = path.join(root, "config/security/secret-scan-policy.json");
|
||||
let policy: Record<string, unknown>;
|
||||
try {
|
||||
policy = JSON.parse(await readFile(policyPath, "utf8")) as Record<string, unknown>;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const tracked = policy["trackedRoots"];
|
||||
if (!Array.isArray(tracked)) return;
|
||||
const surviving: string[] = [];
|
||||
for (const entry of tracked) {
|
||||
if (typeof entry !== "string") continue;
|
||||
try {
|
||||
await stat(path.join(root, entry));
|
||||
surviving.push(entry);
|
||||
} catch {
|
||||
// Deleted by the removal under test.
|
||||
}
|
||||
}
|
||||
if (surviving.length === tracked.length) return;
|
||||
policy["trackedRoots"] = surviving;
|
||||
await writeFile(policyPath, `${JSON.stringify(policy, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
export async function pruneRemovalFixtureCiContract(options: Readonly<{
|
||||
|
||||
Reference in New Issue
Block a user