feat: govern frontend contract compatibility
This commit is contained in:
@@ -0,0 +1,63 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"families": {
|
||||||
|
"api": {
|
||||||
|
"additive": {
|
||||||
|
"before": { "required": ["id"], "properties": { "id": {} } },
|
||||||
|
"after": {
|
||||||
|
"required": ["id"],
|
||||||
|
"properties": { "id": {}, "displayName": {} }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"breaking": {
|
||||||
|
"before": { "required": ["id"], "properties": { "id": {} } },
|
||||||
|
"after": {
|
||||||
|
"required": ["id", "name"],
|
||||||
|
"properties": { "id": {}, "name": {} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"additive": {
|
||||||
|
"before": { "required": ["APP_ENV"], "properties": { "APP_ENV": {} } },
|
||||||
|
"after": {
|
||||||
|
"required": ["APP_ENV"],
|
||||||
|
"properties": { "APP_ENV": {}, "OPTIONAL_FLAG": {} }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"breaking": {
|
||||||
|
"before": { "required": ["APP_ENV"], "properties": { "APP_ENV": {} } },
|
||||||
|
"after": {
|
||||||
|
"required": ["APP_ENV", "NEW_REQUIRED"],
|
||||||
|
"properties": { "APP_ENV": {}, "NEW_REQUIRED": {} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"storage": {
|
||||||
|
"additive": {
|
||||||
|
"before": { "properties": { "theme": {} } },
|
||||||
|
"after": { "properties": { "theme": {}, "contrast": {} } }
|
||||||
|
},
|
||||||
|
"breaking": {
|
||||||
|
"before": { "properties": { "theme": {} } },
|
||||||
|
"after": { "properties": {} }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"release": {
|
||||||
|
"additive": {
|
||||||
|
"before": { "required": ["buildId"], "properties": { "buildId": {} } },
|
||||||
|
"after": {
|
||||||
|
"required": ["buildId"],
|
||||||
|
"properties": { "buildId": {}, "builtAt": {} }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"breaking": {
|
||||||
|
"before": { "required": ["buildId"], "properties": { "buildId": {} } },
|
||||||
|
"after": {
|
||||||
|
"required": ["buildId", "assetManifestHash"],
|
||||||
|
"properties": { "buildId": {}, "assetManifestHash": {} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Contract compatibility and rollback rules
|
||||||
|
|
||||||
|
The blocking tuple is `(buildId, configSchemaVersion, apiContractVersion,
|
||||||
|
assetManifestHash, releaseId)`. Versions are parsed numerically.
|
||||||
|
|
||||||
|
1. additive changes preserve current required fields
|
||||||
|
2. breaking changes require a major version bump
|
||||||
|
3. persisted cache is discarded unless an explicit tested migration exists
|
||||||
|
4. an incompatible config or API contract blocks product mount
|
||||||
|
5. rollback restores HTML, assets, runtime config, API compatibility, and
|
||||||
|
release manifest as one coherent set
|
||||||
+2
-1
@@ -30,7 +30,8 @@
|
|||||||
"generate:supply-chain": "node scripts/generate-supply-chain.mjs",
|
"generate:supply-chain": "node scripts/generate-supply-chain.mjs",
|
||||||
"scan:security": "node scripts/security-scan.mjs",
|
"scan:security": "node scripts/security-scan.mjs",
|
||||||
"check:browser-security": "node scripts/check-browser-security.mjs",
|
"check:browser-security": "node scripts/check-browser-security.mjs",
|
||||||
"check:registries": "node scripts/check-registries.mjs"
|
"check:registries": "node scripts/check-registries.mjs",
|
||||||
|
"verify:compatibility": "node scripts/check-compatibility.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "5.101.4",
|
"@tanstack/react-query": "5.101.4",
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
|
|
||||||
|
import { classifyObjectSchemaChange } from "../src/application/policies/compatibility.js";
|
||||||
|
|
||||||
|
const fixtures = JSON.parse(
|
||||||
|
await readFile("config/compatibility/fixtures.json", "utf8"),
|
||||||
|
);
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
for (const [family, cases] of Object.entries(fixtures.families)) {
|
||||||
|
for (const expected of ["additive", "breaking"]) {
|
||||||
|
const fixture = cases[expected];
|
||||||
|
const actual = classifyObjectSchemaChange(fixture.before, fixture.after);
|
||||||
|
results.push({ family, expected, actual, passed: actual === expected });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await mkdir("artifacts/release", { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
"artifacts/release/compatibility.json",
|
||||||
|
`${JSON.stringify(
|
||||||
|
{
|
||||||
|
schemaVersion: 1,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
rules: [
|
||||||
|
"additive changes preserve required fields",
|
||||||
|
"breaking changes require version bump and migration, discard, fallback, or rollback",
|
||||||
|
"config and API major versions must match",
|
||||||
|
"incompatible persisted cache is discarded by default",
|
||||||
|
"rollback uses a coherent compatibility tuple",
|
||||||
|
],
|
||||||
|
results,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
)}\n`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (results.some((result) => !result.passed)) {
|
||||||
|
process.stderr.write("Compatibility fixture classification failed.\n");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
process.stdout.write("Compatibility fixtures: PASS\n");
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
export const COMPATIBILITY_TUPLE_FIELDS = Object.freeze([
|
||||||
|
"buildId",
|
||||||
|
"configSchemaVersion",
|
||||||
|
"apiContractVersion",
|
||||||
|
"assetManifestHash",
|
||||||
|
"releaseId",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** @param {string} version */
|
||||||
|
export function parseNumericVersion(version) {
|
||||||
|
const match = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?$/.exec(version);
|
||||||
|
if (!match) return null;
|
||||||
|
return {
|
||||||
|
major: Number(match[1]),
|
||||||
|
minor: Number(match[2] ?? 0),
|
||||||
|
patch: Number(match[3] ?? 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} supported @param {string} actual */
|
||||||
|
export function isVersionCompatible(supported, actual) {
|
||||||
|
const expected = parseNumericVersion(supported);
|
||||||
|
const candidate = parseNumericVersion(actual);
|
||||||
|
if (!expected || !candidate) return false;
|
||||||
|
return (
|
||||||
|
expected.major === candidate.major &&
|
||||||
|
candidate.minor >= expected.minor
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{
|
||||||
|
* frontend: {
|
||||||
|
* buildId: string,
|
||||||
|
* configSchemaVersion: string,
|
||||||
|
* apiContractVersion: string,
|
||||||
|
* assetManifestHash: string,
|
||||||
|
* releaseId: string
|
||||||
|
* },
|
||||||
|
* runtime: {
|
||||||
|
* buildId: string,
|
||||||
|
* configSchemaVersion: string,
|
||||||
|
* apiContractVersion: string,
|
||||||
|
* assetManifestHash: string,
|
||||||
|
* releaseId: string
|
||||||
|
* }
|
||||||
|
* }} input
|
||||||
|
*/
|
||||||
|
export function verifyCompatibilityTuple(input) {
|
||||||
|
const mismatches = [];
|
||||||
|
if (input.frontend.buildId !== input.runtime.buildId) mismatches.push("buildId");
|
||||||
|
if (
|
||||||
|
!isVersionCompatible(
|
||||||
|
input.frontend.configSchemaVersion,
|
||||||
|
input.runtime.configSchemaVersion,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
mismatches.push("configSchemaVersion");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!isVersionCompatible(
|
||||||
|
input.frontend.apiContractVersion,
|
||||||
|
input.runtime.apiContractVersion,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
mismatches.push("apiContractVersion");
|
||||||
|
}
|
||||||
|
if (input.frontend.assetManifestHash !== input.runtime.assetManifestHash) {
|
||||||
|
mismatches.push("assetManifestHash");
|
||||||
|
}
|
||||||
|
|
||||||
|
const releaseWarning =
|
||||||
|
input.frontend.releaseId === input.runtime.releaseId
|
||||||
|
? null
|
||||||
|
: "releaseId";
|
||||||
|
return Object.freeze({
|
||||||
|
compatible: mismatches.length === 0,
|
||||||
|
mismatches: Object.freeze(mismatches),
|
||||||
|
warnings: Object.freeze(releaseWarning ? [releaseWarning] : []),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ required?: string[], properties?: Record<string, unknown> }} before
|
||||||
|
* @param {{ required?: string[], properties?: Record<string, unknown> }} after
|
||||||
|
*/
|
||||||
|
export function classifyObjectSchemaChange(before, after) {
|
||||||
|
const beforeRequired = new Set(before.required ?? []);
|
||||||
|
const afterRequired = new Set(after.required ?? []);
|
||||||
|
const removedProperties = Object.keys(before.properties ?? {}).filter(
|
||||||
|
(key) => !(key in (after.properties ?? {})),
|
||||||
|
);
|
||||||
|
const addedRequired = [...afterRequired].filter(
|
||||||
|
(key) => !beforeRequired.has(key),
|
||||||
|
);
|
||||||
|
if (removedProperties.length > 0 || addedRequired.length > 0) return "breaking";
|
||||||
|
|
||||||
|
const addedProperties = Object.keys(after.properties ?? {}).filter(
|
||||||
|
(key) => !(key in (before.properties ?? {})),
|
||||||
|
);
|
||||||
|
return addedProperties.length > 0 ? "additive" : "none";
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
classifyObjectSchemaChange,
|
||||||
|
isVersionCompatible,
|
||||||
|
parseNumericVersion,
|
||||||
|
verifyCompatibilityTuple,
|
||||||
|
} from "../../src/application/policies/compatibility.js";
|
||||||
|
|
||||||
|
describe("contract compatibility", () => {
|
||||||
|
it("uses numeric version parsing rather than lexical comparison", () => {
|
||||||
|
expect(parseNumericVersion("1.10.0")).toEqual({ major: 1, minor: 10, patch: 0 });
|
||||||
|
expect(isVersionCompatible("1.9", "1.10")).toBe(true);
|
||||||
|
expect(isVersionCompatible("1.9", "2.0")).toBe(false);
|
||||||
|
expect(isVersionCompatible("next", "1.0")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("distinguishes additive and breaking object changes", () => {
|
||||||
|
const base = { required: ["id"], properties: { id: {} } };
|
||||||
|
expect(
|
||||||
|
classifyObjectSchemaChange(base, {
|
||||||
|
required: ["id"],
|
||||||
|
properties: { id: {}, name: {} },
|
||||||
|
}),
|
||||||
|
).toBe("additive");
|
||||||
|
expect(
|
||||||
|
classifyObjectSchemaChange(base, {
|
||||||
|
required: ["id", "name"],
|
||||||
|
properties: { id: {}, name: {} },
|
||||||
|
}),
|
||||||
|
).toBe("breaking");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats release ID mismatch as a warning when the blocking tuple is coherent", () => {
|
||||||
|
const frontend = {
|
||||||
|
buildId: "build-a",
|
||||||
|
configSchemaVersion: "1.0",
|
||||||
|
apiContractVersion: "1.0",
|
||||||
|
assetManifestHash: "hash-a",
|
||||||
|
releaseId: "release-a",
|
||||||
|
};
|
||||||
|
expect(
|
||||||
|
verifyCompatibilityTuple({
|
||||||
|
frontend,
|
||||||
|
runtime: { ...frontend, releaseId: "release-b" },
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
compatible: true,
|
||||||
|
mismatches: [],
|
||||||
|
warnings: ["releaseId"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks mixed build, config, API, or asset tuples", () => {
|
||||||
|
const frontend = {
|
||||||
|
buildId: "build-a",
|
||||||
|
configSchemaVersion: "1.0",
|
||||||
|
apiContractVersion: "1.0",
|
||||||
|
assetManifestHash: "hash-a",
|
||||||
|
releaseId: "release-a",
|
||||||
|
};
|
||||||
|
expect(
|
||||||
|
verifyCompatibilityTuple({
|
||||||
|
frontend,
|
||||||
|
runtime: {
|
||||||
|
...frontend,
|
||||||
|
buildId: "build-b",
|
||||||
|
configSchemaVersion: "2.0",
|
||||||
|
assetManifestHash: "hash-b",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
compatible: false,
|
||||||
|
mismatches: ["buildId", "configSchemaVersion", "assetManifestHash"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user