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:
DongHyeonka
2026-08-17 18:38:08 +09:00
co-authored by Claude Opus 5
100 changed files with 3310 additions and 443 deletions
+19 -9
View File
@@ -13,13 +13,20 @@ import { INSTALLED_RUNTIME_CAPABILITIES } from "../src/features/installed-runtim
* 1. clean dist and .generated/frontend-runtime
* 2. generate contractSet and build-info source
* 3. Vite app build (emptyOutDir = true)
* 4. scan app dist and generate the static asset source
* 5. ACTIVE only: Vite Service Worker build (emptyOutDir = false)
* 6. generate the self-contained TechLog production serving boundary
* 7. generate Release Manifest V2 and the build manifest
* 4. materialize dist/config.json from the declared APP_PROFILE
* 5. scan app dist and generate the static asset source
* 6. ACTIVE only: Vite Service Worker build (emptyOutDir = false)
* 7. generate the self-contained TechLog production serving boundary
* 8. generate Release Manifest V2 and the build manifest
*
* Steps 4 and 5 are skipped for `REMOVE_REGISTRATION`, `PURGE_OWNED_RESOURCES`
* Steps 5 and 6 are skipped for `REMOVE_REGISTRATION`, `PURGE_OWNED_RESOURCES`
* and `null`: those modes never run an active worker build.
*
* Step 4 has to follow the Vite build and precede the asset scan. Vite copies
* `public/` verbatim, so without it every build — including a production one —
* ships the local runtime document; and the Service Worker hashes the emitted
* `config.json`, so the profile must be in place before that inventory is
* taken.
*/
const selection = INSTALLED_RUNTIME_CAPABILITIES.serviceWorker;
@@ -46,10 +53,13 @@ run("node", ["scripts/generate-contract-set.ts"]);
// 3. app build
run("npx", ["vite", "build"]);
// 4. runtime config for the declared profile
run("node", ["scripts/generate-runtime-config.ts"]);
if (buildsActiveWorker) {
// 4. hashed asset inventory
// 5. hashed asset inventory
run("node", ["scripts/generate-service-worker-assets.ts", "dist"]);
// 5. service worker build
// 6. service worker build
run("npx", ["vite", "build", "--config", "vite.service-worker.config.ts"]);
} else {
process.stdout.write(
@@ -57,8 +67,8 @@ if (buildsActiveWorker) {
);
}
// 6. production serving boundary
// 7. production serving boundary
run("node", ["scripts/generate-tech-log-serving-artifact.ts"]);
// 7. release + build manifest
// 8. release + build manifest
run("node", ["scripts/generate-build-manifest.ts"]);
+43 -3
View File
@@ -723,9 +723,13 @@ function findArchitectureViolations(
continue;
}
for (const dependency of dependencies) {
const sourceGroups = rule.from?.path
? (new RegExp(rule.from.path, "u").exec(dependency.source)?.slice(1) ??
[])
: [];
if (
matchesPath(dependency.source, rule.from) &&
matchesPath(dependency.target, rule.to)
matchesPath(dependency.target, rule.to, sourceGroups)
) {
violations.push({
rule: rule.name,
@@ -746,16 +750,52 @@ function findArchitectureViolations(
function matchesPath(
modulePath: string,
criterion: PathRule | undefined,
sourceGroups: readonly string[] = [],
): boolean {
if (!criterion) return true;
if (criterion.path && !new RegExp(criterion.path, "u").test(modulePath)) {
if (
criterion.path &&
!new RegExp(expandSourceGroups(criterion.path, sourceGroups), "u").test(
modulePath,
)
) {
return false;
}
return !(
criterion.pathNot && new RegExp(criterion.pathNot, "u").test(modulePath)
criterion.pathNot &&
new RegExp(expandSourceGroups(criterion.pathNot, sourceGroups), "u").test(
modulePath,
)
);
}
/**
* Substitutes `$1`..`$9` in a `to` pattern with the capture groups the `from`
* pattern matched on the importing module.
*
* Without it, "an adapter may not import a *different* adapter" cannot be
* written as one rule: the target pattern has to name the importer's own
* directory to exempt it. The alternative is one rule per adapter group, which
* silently stops covering a group the moment somebody adds one — exactly the
* gap that let `diagnostics` import `telemetry` while the documented rule said
* it could not.
*/
function expandSourceGroups(
pattern: string,
sourceGroups: readonly string[],
): string {
return pattern.replaceAll(/\$([1-9])/gu, (whole, index: string) => {
const captured = sourceGroups[Number(index) - 1];
// A `from` pattern that did not capture leaves the token literal rather
// than quietly matching everything.
return captured === undefined ? whole : escapeRegExp(captured);
});
}
function escapeRegExp(value: string): string {
return value.replaceAll(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`);
}
function validateArchitectureRules(rules: readonly ArchitectureRule[]): void {
if (!rules.some((rule) => rule.to?.circular === true)) {
throw new Error("Architecture configuration must contain a circular rule");
+2 -2
View File
@@ -77,7 +77,7 @@ if (!architecture?.evidenceArtifactIds.some((id) => index.artifacts.get(id)?.pat
}
const expectedGateIds = Array.from(
{ length: 26 },
{ length: 27 },
(_, index) => `FE-GATE-${String(index + 1).padStart(3, "0")}`,
);
const passingResults: Record<string, GateResult> = Object.fromEntries(
@@ -139,4 +139,4 @@ if (failures.length > 0) {
process.stderr.write(`CI contract failed:\n${failures.join("\n")}\n`);
process.exit(1);
}
process.stdout.write("CI contract: 26 gates, strict v2 graph and generated workflow model PASS\n");
process.stdout.write("CI contract: 27 gates, strict v2 graph and generated workflow model PASS\n");
+108
View File
@@ -0,0 +1,108 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import process from "node:process";
import {
DEPLOYMENT_TARGETS,
findAdmissionViolations,
isDeploymentTarget,
type AdmissionInput,
} from "../src/contracts/deployment-admission.ts";
import { parseRuntimeConfigArtifact } from "../src/contracts/release-artifacts.ts";
/**
* §6.4 / FE-GATE-027. Refuses to admit an artifact to an environment it was not
* built for.
*
* Release coherence already proves the artifacts agree with each other. It
* cannot prove they belong in production, because a local build is coherent
* with itself: `APP_ENV: local`, `AUTH_MODE: demo` and a loopback API pass
* every existing gate. This gate closes that by making the destination an
* explicit, declared input and refusing anything that does not match it.
*
* It fails closed in both directions. An undeclared destination is a refusal,
* not a default, so an artifact can never be admitted by omission; and every
* rule is stated as a reason to refuse, so an unreadable field cannot pass.
*/
const RUNTIME_CONFIG_PATH = "dist/config.json";
const RECORD_PATH = "artifacts/release/deployment-admission.json";
async function main(): Promise<void> {
const declared = process.env["RELEASE_TARGET"];
if (!isDeploymentTarget(declared)) {
process.stderr.write(
"release admission refused: RELEASE_TARGET must be declared as one of " +
`${DEPLOYMENT_TARGETS.join(", ")}; received ${
declared === undefined ? "nothing" : declared
}.\n` +
"An artifact is never admitted by default — name the environment it is for.\n",
);
process.exitCode = 1;
return;
}
let document: unknown;
try {
document = JSON.parse(await readFile(RUNTIME_CONFIG_PATH, "utf8"));
} catch (error) {
process.stderr.write(
`release admission refused: ${RUNTIME_CONFIG_PATH} is unreadable: ${
error instanceof Error ? error.message : String(error)
}\n`,
);
process.exitCode = 1;
return;
}
let config: AdmissionInput;
try {
config = parseRuntimeConfigArtifact(document) as AdmissionInput;
} catch (error) {
process.stderr.write(
`release admission refused: ${RUNTIME_CONFIG_PATH} is not a valid runtime config: ${
error instanceof Error ? error.message : String(error)
}\n`,
);
process.exitCode = 1;
return;
}
const violations = findAdmissionViolations(declared, config);
await mkdir("artifacts/release", { recursive: true });
await writeFile(
RECORD_PATH,
`${JSON.stringify(
{
schemaVersion: 1,
target: declared,
appEnv: config.APP_ENV,
authMode: config.AUTH_MODE,
apiBaseUrl: config.API_BASE_URL,
buildId: config.BUILD_ID ?? null,
releaseId: config.RELEASE_ID ?? null,
status: violations.length === 0 ? "ADMITTED" : "REFUSED",
violations,
},
null,
2,
)}\n`,
"utf8",
);
if (violations.length > 0) {
process.stderr.write(
`release admission refused for ${declared}:\n${violations
.map((violation) => ` ${violation.field}: ${violation.reason}`)
.join("\n")}\n`,
);
process.exitCode = 1;
return;
}
process.stdout.write(
`release admission: ${declared} ADMITTED ` +
`(APP_ENV=${config.APP_ENV}, AUTH_MODE=${config.AUTH_MODE}, ` +
`API=${config.API_BASE_URL}); record at ${RECORD_PATH}\n`,
);
}
await main();
+41 -13
View File
@@ -248,6 +248,7 @@ const artifactSchemaSchema = z.discriminatedUnion("kind", [
"provider-provenance",
"provider-verification",
"ci-contract-report",
"deployment-admission",
]),
})
.strict(),
@@ -443,7 +444,9 @@ export type LoadCiGateContractOptions = Readonly<{
}>;
const CANONICAL_GATE_SHAPE_SHA256 =
"492331de6d07926b1cdb569824c8feaa761eef76b9ac782c0404d40797383ef0";
// Template merge. Both sides carried a digest of their own gate set; neither
// describes the merged one. Recomputed from the merged config/ci/gates.json.
"5063586d799f51de94c0f0ddaf9b75e180825bba5051bc309550425013ea81ef";
function canonicalGateShapeSha256(gates: CiGateContract["gates"]): string {
const normalized = gates.map(
@@ -474,16 +477,17 @@ function canonicalAuthorityBaselineFailures(contract: CiGateContract): string[]
(total, gate) => total + gate.commandIds.length,
0,
);
if (contract.gates.length !== 26) {
failures.push(`gate authority baseline must contain exactly 26 gates; received ${contract.gates.length}`);
if (contract.gates.length !== 27) {
failures.push(`gate authority baseline must contain exactly 27 gates; received ${contract.gates.length}`);
}
if (contract.commands.length !== 81 || commandReferenceCount !== 93) {
if (contract.commands.length !== 82 || commandReferenceCount !== 94) {
failures.push(
`command authority baseline must contain exactly 81 definitions and 93 references; received ${contract.commands.length} definitions and ${commandReferenceCount} references`,
`command authority baseline must contain exactly 82 definitions and 94 references; received ${contract.commands.length} definitions and ${commandReferenceCount} references`,
);
}
if (contract.artifacts.length !== 126) {
failures.push(`artifact authority baseline must contain exactly 126 artifacts; received ${contract.artifacts.length}`);
// Template merge. 126 product artifacts plus the two the template added.
if (contract.artifacts.length !== 128) {
failures.push(`artifact authority baseline must contain exactly 128 artifacts; received ${contract.artifacts.length}`);
}
if (contract.stages.length !== 5) {
failures.push(`stage authority baseline must contain exactly 5 stages; received ${contract.stages.length}`);
@@ -512,7 +516,7 @@ export function parseCiGateContract(
.join("\n");
throw new TypeError(`CI gate contract invalid:\n${diagnostic}`);
}
if ((options.mode ?? "canonical") === "canonical") {
if ((options.mode ?? defaultCiContractMode()) === "canonical") {
const failures = canonicalAuthorityBaselineFailures(result.data);
if (failures.length > 0) {
throw new TypeError(`CI gate contract invalid:\n${failures.map((failure) => `root: ${failure}`).join("\n")}`);
@@ -521,11 +525,29 @@ export function parseCiGateContract(
return result.data;
}
/**
* A removal fixture runs the whole suite against a deliberately *reduced* CI
* contract: the removed capability's gates, commands and artifacts are pruned.
* Loading that contract in canonical mode re-imposes the full exact-count
* authority on it, so the fixture failed on the very reduction it exists to
* prove. `runRemovalFixturePnpm` marks those runs, and this is where the mark
* is honoured.
*/
export function defaultCiContractMode(): "canonical" | "removal-fixture" {
return process.env.CI_CONTRACT_MODE === "removal-fixture"
? "removal-fixture"
: "canonical";
}
export function isReducedCiContractRun(): boolean {
return defaultCiContractMode() === "removal-fixture";
}
export async function loadCiGateContract(
root = process.cwd(),
options: LoadCiGateContractOptions = {},
): Promise<CiGateContract> {
const mode = options.mode ?? "canonical";
const mode = options.mode ?? defaultCiContractMode();
const [rawContract, rawPackage] = await Promise.all([
readFile(path.join(root, "config/ci/gates.json"), "utf8"),
readFile(path.join(root, "package.json"), "utf8"),
@@ -798,11 +820,11 @@ function validateContractSemantics(
}
const expectedGateIds = Array.from(
{ length: 26 },
{ length: 27 },
(_, index) => `FE-GATE-${String(index + 1).padStart(3, "0")}`,
);
if (JSON.stringify(contract.gates.map(({ id }) => id)) !== JSON.stringify(expectedGateIds)) {
issue("gate registry must contain FE-GATE-001..026 in canonical order");
issue("gate registry must contain FE-GATE-001..027 in canonical order");
}
const expectedStages: ReadonlyArray<readonly [string, string, readonly string[], readonly string[]]> = [
["merge", "MERGE_READY", [], PROMOTION_FORMULA.MERGE_READY],
@@ -873,7 +895,7 @@ function validateContractSemantics(
const expectedJobOwnership: Readonly<Record<string, readonly string[]>> = {
merge_gate: ["FE-GATE-001", "FE-GATE-002", "FE-GATE-003", "FE-GATE-004", "FE-GATE-005", "FE-GATE-006", "FE-GATE-007", "FE-GATE-008", "FE-GATE-009", "FE-GATE-010", "FE-GATE-011", "FE-GATE-013", "FE-GATE-020"],
release_gate: ["FE-GATE-012", "FE-GATE-014", "FE-GATE-019", "FE-GATE-026"],
immutable_build: ["FE-GATE-015"],
immutable_build: ["FE-GATE-015", "FE-GATE-027"],
vulnerability_provider: [],
provenance_provider: [],
promotion: [],
@@ -927,7 +949,13 @@ function validateContractSemantics(
const expectedEnvironmentBindings: Readonly<Record<string, readonly Readonly<{ name: string; value: string }> []>> = {
merge_gate: [],
release_gate: [{ name: "HOSTING_BASE_URL", value: "${{ vars.HOSTING_BASE_URL }}" }],
immutable_build: [],
immutable_build: [
// FE-GATE-027 admits the built artifact to a named environment, so both
// the profile it was built from and the destination it is claimed for are
// declared inputs. An absent RELEASE_TARGET is a refusal, not a default.
{ name: "APP_PROFILE", value: "${{ vars.APP_PROFILE }}" },
{ name: "RELEASE_TARGET", value: "${{ vars.RELEASE_TARGET }}" },
],
vulnerability_provider: [
{ name: "CANDIDATE_ARCHIVE_SHA256", value: "${{ needs.immutable_build.outputs.archive_sha256 }}" },
{ name: "CANDIDATE_ARCHIVE_PATH", value: ".release/vulnerability-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" },
+56 -3
View File
@@ -670,6 +670,27 @@ export const labPerformanceArtifactSchema = z
})
.strict();
/**
* FE-GATE-027. The record of which environment an artifact was admitted to, and
* every reason it was refused. Refusals are kept in the artifact so a rejected
* promotion leaves evidence rather than only a non-zero exit code.
*/
export const deploymentAdmissionArtifactSchema = z
.object({
schemaVersion: z.literal(1),
target: z.enum(["local", "development", "staging", "production"]),
appEnv: z.enum(["local", "development", "staging", "production"]),
authMode: z.enum(["external", "demo"]),
apiBaseUrl: nonEmptyString,
buildId: nonEmptyString.nullable(),
releaseId: nonEmptyString.nullable(),
status: z.enum(["ADMITTED", "REFUSED"]),
violations: z.array(
z.object({ field: nonEmptyString, reason: nonEmptyString }).strict(),
),
})
.strict();
export const releaseVerificationArtifactSchema = z
.object({
schemaVersion: z.literal(1),
@@ -1321,9 +1342,28 @@ export const documentationReviewArtifactSchema = z
reviewer: z.literal("wiki-diagram-reviewer"),
standard: z.literal("rules/diagram-standards.md v2"),
evidenceReport: z
.object({ repoPath: nonEmptyString, canonicalPath: nonEmptyString, canonicalSha256: sha256 })
.object({
repoPath: nonEmptyString,
upstreamCanonicalPath: nonEmptyString,
canonicalSha256: sha256,
})
.strict(),
reportDigestValid: z.boolean(),
/**
* The declared review scope, derived from the installed route registry
* rather than read off a sentence. Both scope documents claimed six routes
* while ten were registered.
*/
routeScope: z.array(
z
.object({
path: nonEmptyString,
missingRouteIds: z.array(nonEmptyString),
documented: z.boolean(),
})
.strict(),
).min(1),
routeScopeDocumented: z.boolean(),
results: z.array(
z
.object({
@@ -1350,8 +1390,21 @@ export const documentationReviewArtifactSchema = z
context.addIssue({ code: "custom", path: ["results", index, "passed"], message: "must agree with review evidence" });
}
});
if (artifact.passed !== (artifact.reportDigestValid && artifact.results.every(({ passed }) => passed))) {
context.addIssue({ code: "custom", path: ["passed"], message: "must agree with report digest and review results" });
artifact.routeScope.forEach((entry, index) => {
if (entry.documented !== (entry.missingRouteIds.length === 0)) {
context.addIssue({ code: "custom", path: ["routeScope", index, "documented"], message: "must agree with the missing route list" });
}
});
if (artifact.routeScopeDocumented !== artifact.routeScope.every(({ documented }) => documented)) {
context.addIssue({ code: "custom", path: ["routeScopeDocumented"], message: "must agree with every scope document" });
}
if (
artifact.passed !==
(artifact.reportDigestValid &&
artifact.routeScopeDocumented &&
artifact.results.every(({ passed }) => passed))
) {
context.addIssue({ code: "custom", path: ["passed"], message: "must agree with report digest, documented scope and review results" });
}
});
+93
View File
@@ -0,0 +1,93 @@
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import {
DEPLOYMENT_TARGETS,
isDeploymentTarget,
type DeploymentTarget,
} from "../src/contracts/deployment-admission.ts";
import { runtimeConfigV2ArtifactSchema } from "../src/contracts/release-artifacts.ts";
/**
* §6.4. Materializes `dist/config.json` from the profile the build declares.
*
* `public/` is copied verbatim into `dist/`, so before this step the runtime
* document that shipped with every build was the local one — `APP_ENV: local`,
* `AUTH_MODE: demo`, a loopback API — regardless of what the build was for.
* The profile is the source of truth instead, and the only values a deployment
* may inject are the ones it actually owns: its endpoints and its identity.
*
* The result is validated against the same V2 schema the browser will apply, so
* an override cannot produce a document that only fails at boot.
*/
const PROFILE_DIRECTORY = "config/runtime";
const OUTPUT_PATH = "dist/config.json";
/**
* Deployment-supplied values. Everything else is fixed by the profile so a
* deployment cannot quietly widen what was reviewed.
*/
const OVERRIDES = Object.freeze({
API_BASE_URL: "RUNTIME_API_BASE_URL",
TELEMETRY_ENDPOINT: "RUNTIME_TELEMETRY_ENDPOINT",
} as const);
export async function generateRuntimeConfig(
target: DeploymentTarget,
environment: NodeJS.ProcessEnv = process.env,
): Promise<Record<string, unknown>> {
const profilePath = path.join(PROFILE_DIRECTORY, `${target}.json`);
const source: unknown = JSON.parse(await readFile(profilePath, "utf8"));
if (source === null || typeof source !== "object" || Array.isArray(source)) {
throw new TypeError(`${profilePath}: runtime profile must be an object`);
}
const draft: Record<string, unknown> = { ...(source as Record<string, unknown>) };
if (draft["APP_ENV"] !== target) {
throw new Error(
`${profilePath}: declares APP_ENV ${String(draft["APP_ENV"])}, expected ${target}`,
);
}
for (const [field, variable] of Object.entries(OVERRIDES)) {
const supplied = environment[variable];
if (supplied !== undefined && supplied !== "") draft[field] = supplied;
}
const buildId = environment["VITE_BUILD_ID"] ?? "local-build";
const releaseId = environment["RELEASE_ID"] ?? "local-release";
draft["BUILD_ID"] = buildId;
draft["RELEASE_ID"] = releaseId;
const parsed = runtimeConfigV2ArtifactSchema.safeParse(draft);
if (!parsed.success) {
const issues = parsed.error.issues
.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
.join("\n ");
throw new Error(`${profilePath}: runtime config is invalid\n ${issues}`);
}
return draft;
}
function resolveTarget(environment: NodeJS.ProcessEnv): DeploymentTarget {
const declared = environment["APP_PROFILE"] ?? "local";
if (!isDeploymentTarget(declared)) {
throw new Error(
`APP_PROFILE must be one of ${DEPLOYMENT_TARGETS.join(", ")}; received ${declared}`,
);
}
return declared;
}
async function main(): Promise<void> {
const target = resolveTarget(process.env);
const config = await generateRuntimeConfig(target);
await writeFile(OUTPUT_PATH, `${JSON.stringify(config, null, 2)}\n`, "utf8");
process.stdout.write(
`runtime config: ${target} profile written to ${OUTPUT_PATH} ` +
`(APP_ENV=${String(config["APP_ENV"])}, AUTH_MODE=${String(config["AUTH_MODE"])})\n`,
);
}
if (process.argv[1] && import.meta.url.endsWith(path.basename(process.argv[1]))) {
await main();
}
+2
View File
@@ -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(
+40 -24
View File
@@ -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);
+1 -1
View 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(),
+36
View File
@@ -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 }));
}
+14 -2
View File
@@ -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()) {
+43 -3
View File
@@ -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`);
}
+50 -3
View File
@@ -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;
+154 -1
View File
@@ -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<{
+39 -3
View File
@@ -281,14 +281,25 @@ async function runProviderInSandbox(
"--remount-ro", "/",
"--bind", reportAbsolute, reportAbsolute,
"--chdir", workspaceRoot,
"--", "/usr/bin/prlimit",
);
/**
* Everything above is a bubblewrap *option* and travels in the args file, so
* host paths never reach `/proc/<pid>/cmdline`. The command below cannot: an
* args file's option stream ends at the first non-option and bubblewrap drops
* the remainder, so a command written there is never executed. It stays on
* real argv, and it is safe there because the provider command and its
* credentials are passed as `--setenv PROVIDER_COMMAND` inside the args file
* and only expanded by the innermost shell.
*/
const bwrapCommand = [
"/usr/bin/prlimit",
"--core=0:0",
"--fsize=8388607:8388607",
"--nofile=64:64",
`--cpu=${cpuSeconds}:${cpuSeconds}`,
"--", "/bin/sh", "-eu", "-c",
'exec /bin/sh -eu -c "$PROVIDER_COMMAND"',
);
];
const unitName = formatProviderCgroupUnitName(
providerKind,
process.pid,
@@ -301,6 +312,7 @@ async function runProviderInSandbox(
});
const scopeFrame = encodeProviderScopeFrame({
bwrapInput,
bwrapCommand,
reportPath: reportAbsolute,
reportDev: reportIdentity.dev,
reportIno: reportIdentity.ino,
@@ -361,7 +373,25 @@ async function waitForProvider(
PROVIDER_MAX_OUTPUT_BYTES,
() => terminate("output"),
);
/**
* Lines the sandbox tooling itself emits, kept so a launch failure can say
* why. Everything else the child writes is provider output and may carry
* credentials, so it is counted and discarded as before.
*
* Without this a sandbox that never started reported only `exit=1`, and the
* actual cause — `bwrap: loopback: Failed RTM_NEWADDR: Operation not
* permitted` on a host with `kernel.apparmor_restrict_unprivileged_userns=1`
* — was invisible. That turned a host restriction into an unexplained
* product failure.
*/
const SANDBOX_DIAGNOSTIC = /^(?:bwrap|prlimit|systemd-run|systemctl):\s.*$/gmu;
const sandboxDiagnostics: string[] = [];
const capture = (chunk: Buffer | string): void => {
for (const line of String(chunk).matchAll(SANDBOX_DIAGNOSTIC)) {
if (sandboxDiagnostics.length < 8 && !sandboxDiagnostics.includes(line[0])) {
sandboxDiagnostics.push(line[0]);
}
}
if (termination) return;
outputLimiter.consume(chunk);
};
@@ -415,7 +445,13 @@ async function waitForProvider(
await collection;
if (result.error) throw result.error;
if (result.code !== 0 || result.signal !== null) {
throw new Error(`sandboxed external provider failed: exit=${result.code ?? "none"}, signal=${result.signal ?? "none"}`);
throw new Error(
`sandboxed external provider failed: exit=${result.code ?? "none"}, ` +
`signal=${result.signal ?? "none"}` +
(sandboxDiagnostics.length > 0
? `; sandbox reported: ${sandboxDiagnostics.join("; ")}`
: ""),
);
}
if (inputError) throw inputError;
} finally {
+1 -32
View File
@@ -18,43 +18,12 @@ import {
const fixtureRoot = path.resolve(".tmp/optional-recipe-removal");
const pnpmCli = requireRemovalFixtureEnvironment("npm_execpath");
const copyTargets = [
"src",
"tests",
"recipes",
"scripts",
"schemas",
"config",
"public",
".gitea",
".storybook",
"index.html",
"package.json",
"tsconfig.base.json",
"tsconfig.json",
"tsconfig.app.json",
"tsconfig.node.json",
"tsconfig.test.json",
"tsconfig.recipes.json",
"tsconfig.web-worker.json",
"tsconfig.service-worker.json",
"vite.service-worker.config.ts",
"vite.config.ts",
"vitest.config.ts",
"playwright.config.ts",
"playwright.dev.config.ts",
"playwright.storybook.config.ts",
"playwright.visual.config.ts",
"eslint.config.ts",
".dependency-cruiser.json",
".nvmrc",
];
function runPnpm(script: string): boolean {
return runRemovalFixturePnpm(fixtureRoot, pnpmCli, script);
}
await prepareRemovalFixture(fixtureRoot, copyTargets);
await prepareRemovalFixture(fixtureRoot);
for (const rootOnlyTest of [
"tests/unit/ci-workflow-generation.test.ts",
"tests/unit/__snapshots__/ci-workflow-generation.test.ts.snap",
+7
View File
@@ -86,6 +86,13 @@ try {
await page.goto(baseUrl, { waitUntil: "networkidle" });
const target = ROUTE_REGISTRY.TECH_LOG_HOME;
const interactionStarted = performance.now();
// Template merge. The template clicked a navigation link here; this
// product measures its own landing route, which `goto` already reached, so
// there is no click and no `targetLabel`. The template's lesson is kept
// because it applies to whatever click lands here next: Playwright matches
// accessible names by substring, so a nav entry can also match a call to
// action and resolve to two links — a strict-mode violation before the
// first measurement, which produces no performance evidence at all.
await page.getByRole("heading", { name: target.title }).waitFor();
const namedInteractionMs = performance.now() - interactionStarted;
const paint = await page.evaluate(
+7 -32
View File
@@ -27,10 +27,16 @@ const fixtureRoot = await mkdtemp(
const pnpmCli = requireRemovalFixtureEnvironment("npm_execpath");
const featureSource = "src/features/reference-feature";
const featureTests = "tests/features/reference-feature";
/**
* Platform tests that must survive the sample feature's removal. Asserting they
* are still present is what stops the removal fixture from "passing" by having
* quietly deleted the platform's own coverage along with the feature.
*/
const commonTestPaths = [
"tests/unit/external-contract-runtime.test.ts",
"tests/unit/http-execution-v3.test.ts",
"tests/unit/runtime-adapters.test.ts",
"tests/integration/http-execution-v3-observability.test.ts",
];
const featureOwnedPaths = [
featureSource,
@@ -44,37 +50,6 @@ const featureOwnedPaths = [
"tests/fixtures/typecheck/invalid-feature-input.ts",
"tests/fixtures/typecheck/invalid-reference-operation.ts",
];
const copyTargets = [
"src",
"tests",
"recipes",
"scripts",
"schemas",
"config",
"public",
".gitea",
".storybook",
"index.html",
"package.json",
"tsconfig.base.json",
"tsconfig.json",
"tsconfig.app.json",
"tsconfig.node.json",
"tsconfig.test.json",
"tsconfig.recipes.json",
"tsconfig.web-worker.json",
"tsconfig.service-worker.json",
"vite.service-worker.config.ts",
"vite.config.ts",
"vitest.config.ts",
"playwright.config.ts",
"playwright.dev.config.ts",
"playwright.storybook.config.ts",
"playwright.visual.config.ts",
"eslint.config.ts",
".dependency-cruiser.json",
".nvmrc",
];
const emptyContracts = `import type { RouteDefinition } from "../contracts/routes.ts";
import { PLATFORM_SCHEMA_REGISTRY } from "../contracts/schema-registry.ts";
@@ -190,7 +165,7 @@ function runPnpm(script: string, extra: string[] = []): boolean {
}
try {
await prepareRemovalFixture(fixtureRoot, copyTargets);
await prepareRemovalFixture(fixtureRoot);
for (const excludedFixtureTest of [
"tests/integration/security-followup-archive.test.ts",
"tests/unit/ci-artifact-contract.test.ts",
+42 -1
View File
@@ -1,8 +1,24 @@
import { mkdir, readFile } from "node:fs/promises";
import { access, mkdir, readFile } from "node:fs/promises";
import { ROUTE_REGISTRY } from "../src/features/installed-feature-contracts.ts";
import { documentationReviewArtifactSchema } from "./contracts/release-artifacts.ts";
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
/**
* Documents that state the review scope. The route registry is the source of
* truth for what that scope is, so these have to enumerate exactly the
* installed routes.
*
* Both said "six routes" while ten were registered: the four newest — the
* platform overview and three reference-resource screens — were outside the
* declared manual accessibility scope without anybody deciding they should be.
* A hand-typed count drifts silently, so it is derived here instead.
*/
const ROUTE_SCOPE_DOCUMENTS = Object.freeze([
"README.md",
"docs/accessibility/manual-checklist.md",
]);
type DocumentationReview = Readonly<{
sourcePath: string;
sha256: string;
@@ -14,6 +30,7 @@ type DocumentationReview = Readonly<{
type ReviewLedger = Readonly<{
evidenceReport: Readonly<{
repoPath: string;
upstreamCanonicalPath: string;
canonicalSha256: string;
}>;
reviews: Record<string, DocumentationReview>;
@@ -58,8 +75,30 @@ for (const [diagram, review] of Object.entries(ledger.reviews)) {
const reportDigestValid =
/^[0-9a-f]{64}$/.test(ledger.evidenceReport.canonicalSha256) &&
evidence.includes(ledger.evidenceReport.canonicalSha256);
const installedRouteIds = Object.values(ROUTE_REGISTRY)
.map((route) => route.routeId)
.sort();
const routeScope = [];
for (const path of ROUTE_SCOPE_DOCUMENTS) {
let text: string;
try {
await access(path);
text = await readFile(path, "utf8");
} catch {
routeScope.push({ path, missingRouteIds: [...installedRouteIds], documented: false });
continue;
}
const missingRouteIds = installedRouteIds.filter(
(routeId) => !text.includes(routeId),
);
routeScope.push({ path, missingRouteIds, documented: missingRouteIds.length === 0 });
}
const routeScopeDocumented = routeScope.every((entry) => entry.documented);
const passed =
reportDigestValid &&
routeScopeDocumented &&
results.length === 2 &&
results.every((result) => result.passed);
await mkdir("artifacts/quality", { recursive: true });
@@ -74,6 +113,8 @@ await writeValidatedJsonArtifact({
standard: ledger.standard,
evidenceReport: ledger.evidenceReport,
reportDigestValid,
routeScope,
routeScopeDocumented,
results,
passed,
},