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