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:
@@ -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"]);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
@@ -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" },
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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<{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user