From 82b2825d2c612b2cb9099fefb66062f8fb16e036 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kim=20Harjam=C3=A4ki?= Date: Thu, 11 Jun 2026 14:00:01 +0300 Subject: [PATCH 1/8] docs(02): create compatibility and distribution phase plans --- .planning/REQUIREMENTS.md | 4 +- .planning/ROADMAP.md | 8 +++ .planning/STATE.md | 20 ++++++- .planning/config.json | 2 +- .../02-01-PLAN.md | 55 ++++++++++++++++++ .../02-02-PLAN.md | 56 +++++++++++++++++++ .../02-RESEARCH.md | 29 ++++++++++ 7 files changed, 169 insertions(+), 5 deletions(-) create mode 100644 .planning/phases/02-compatibility-automation-and-distribution/02-01-PLAN.md create mode 100644 .planning/phases/02-compatibility-automation-and-distribution/02-02-PLAN.md create mode 100644 .planning/phases/02-compatibility-automation-and-distribution/02-RESEARCH.md diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index f1c3dcd..6fadb2c 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -50,8 +50,10 @@ | GOV-01 | Phase 1 | Complete | | GOV-02 | Phase 1 | Complete | | GOV-03 | Phase 1 | Complete | +| COMP-01 | Phase 2 | Pending | +| REG-01 | Phase 2 | Pending | -**Coverage:** 9 v0.1 requirements, 9 mapped, 0 unmapped. +**Coverage:** 12 requirements, 12 mapped, 0 unmapped. --- *Last updated: 2026-06-11 after Phase 1 verification* diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 62d4f84..13ab666 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -18,6 +18,14 @@ **Requirements:** COMP-01, REG-01 +**Plans:** 2 plans + +**Wave 1** +- [ ] 02-01: Automated compatibility classification and PR enforcement + +**Wave 2** *(blocked on Wave 1 completion)* +- [ ] 02-02: Deterministic versioned registry and release distribution + **Success criteria:** 1. Pull requests receive automated breaking-change classification. 2. Released schemas are resolvable from stable versioned URLs. diff --git a/.planning/STATE.md b/.planning/STATE.md index 88935a5..1f874c7 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -1,3 +1,17 @@ +--- +gsd_state_version: 1.0 +milestone: v0.1 +milestone_name: Foundation +status: unknown +last_updated: "2026-06-11T10:59:47.777Z" +progress: + total_phases: 3 + completed_phases: 0 + total_plans: 2 + completed_plans: 0 + percent: 0 +--- + # Project State ## Project Reference @@ -5,14 +19,14 @@ See: `.planning/PROJECT.md` (updated 2026-06-11) **Core value:** Every CAS component can exchange trustworthy lifecycle data without guessing structure, identity, traceability, or compatibility. -**Current focus:** Phase 2 - Compatibility Automation and Distribution +**Current focus:** Phase 2 - Compatibility Automation and Distribution (ready to execute) ## Status -- Phase: 1 of 3 complete; Phase 2 pending +- Phase: 1 of 3 complete; Phase 2 planned (0/2 plans complete) - Milestone: v0.1 - Mode: YOLO, quality, parallel -- Next action: Integrate v0.1 contracts into CAS producers and consumers, then automate compatibility checks. +- Next action: Execute Phase 2 compatibility automation and registry distribution plans. ## Decisions diff --git a/.planning/config.json b/.planning/config.json index 88f1703..a52f3c1 100644 --- a/.planning/config.json +++ b/.planning/config.json @@ -10,6 +10,6 @@ "verifier": true, "nyquist_validation": true, "auto_advance": true, - "_auto_chain_active": true + "_auto_chain_active": false } } diff --git a/.planning/phases/02-compatibility-automation-and-distribution/02-01-PLAN.md b/.planning/phases/02-compatibility-automation-and-distribution/02-01-PLAN.md new file mode 100644 index 0000000..eb266bd --- /dev/null +++ b/.planning/phases/02-compatibility-automation-and-distribution/02-01-PLAN.md @@ -0,0 +1,55 @@ +--- +phase: 02-compatibility-automation-and-distribution +plan: "01" +type: execute +wave: 1 +depends_on: [] +files_modified: + - package.json + - scripts/compatibility.mjs + - scripts/lib.mjs + - tests/compatibility.test.mjs + - tests/fixtures/compatibility/ + - .github/workflows/compatibility.yml + - docs/VERSIONING.md + - CONTRIBUTING.md +autonomous: true +requirements: [COMP-01] +must_haves: + truths: + - Pull requests automatically classify schema changes against their merge base. + - Breaking and review-required changes cannot be silently reported as compatible. + - Compatibility results are available as machine-readable JSON and a CI summary. +--- + + +Implement conservative automated JSON Schema breaking-change classification and enforce it in pull-request CI. + + + + + Implement classifier and CLI + scripts/lib.mjs, docs/VERSIONING.md, schemas/v0.1/common.schema.json + Add a dependency-free directional schema-tree comparator. Report unchanged, compatible, breaking, and review_required outcomes with JSON details. Exit nonzero for breaking changes and support explicit base/head directories. + `node scripts/compatibility.mjs --help` exits successfully and documents CLI inputs and exit behavior. + + + Add compatibility fixtures and tests + tests/contracts.test.mjs, scripts/compatibility.mjs + Add tests for additions, removals, required properties, type and enum narrowing, extensibility tightening, constraints, schema removal, and unknown changed keywords. + `npm.cmd test` passes and exercises all four classifications. + + + Integrate PR compatibility CI and governance docs + .github/workflows/ci.yml, CONTRIBUTING.md, docs/VERSIONING.md + Add a least-privilege pull-request workflow that compares against the merge base, writes a job summary, uploads the JSON report, and fails on breaking changes. Document local classification and review-required semantics. + Workflow uses full-history checkout, runs the classifier, uploads its report, and has read-only contents permission. + + + + +- `npm.cmd test` +- `npm.cmd run validate` +- Run the CLI against identical schema trees and a known breaking fixture. + + diff --git a/.planning/phases/02-compatibility-automation-and-distribution/02-02-PLAN.md b/.planning/phases/02-compatibility-automation-and-distribution/02-02-PLAN.md new file mode 100644 index 0000000..f1ae1c2 --- /dev/null +++ b/.planning/phases/02-compatibility-automation-and-distribution/02-02-PLAN.md @@ -0,0 +1,56 @@ +--- +phase: 02-compatibility-automation-and-distribution +plan: "02" +type: execute +wave: 2 +depends_on: ["02-01"] +files_modified: + - package.json + - scripts/build-registry.mjs + - scripts/lib.mjs + - tests/registry.test.mjs + - .github/workflows/publish-registry.yml + - README.md + - docs/DISTRIBUTION.md + - CHANGELOG.md +autonomous: true +requirements: [REG-01] +must_haves: + truths: + - Released schemas are packaged reproducibly under immutable release and stable major/minor paths. + - Every distributed schema remains valid and resolvable with relative references. + - Release publication deploys the validated registry to GitHub Pages using least privilege. +--- + + +Build and publish a deterministic versioned schema registry that serves released schemas from stable URLs. + + + + + Implement deterministic registry builder + scripts/lib.mjs, package.json, schemas/v0.1/common.schema.json + Add a cross-platform builder that validates a semantic release version, copies the matching schema line to immutable and stable paths, creates digest-bearing manifests, and produces deterministic output. + `npm.cmd run build:registry -- --version 0.1.0` produces validated registry output with manifests and SHA-256 digests. + + + Add registry distribution tests + tests/contracts.test.mjs, scripts/build-registry.mjs + Test path layout, manifest contents, digests, deterministic rebuilds, compiled distributed schemas, and invalid version rejection. + `npm.cmd test` passes with registry tests included. + + + Add release publication workflow and consumer docs + .github/workflows/ci.yml, README.md, docs/VERSIONING.md + Add a semantic-tag release workflow that builds, validates, uploads, and deploys the Pages artifact. Document stable and immutable URLs, discovery manifests, integrity verification, and Pages/custom-domain setup. + Workflow has explicit Pages permissions, release-tag validation, artifact build validation, and deployment concurrency. + + + + +- `npm.cmd test` +- `npm.cmd run validate` +- `npm.cmd run build:registry -- --version 0.1.0` +- Validate generated manifests and distributed schemas. + + diff --git a/.planning/phases/02-compatibility-automation-and-distribution/02-RESEARCH.md b/.planning/phases/02-compatibility-automation-and-distribution/02-RESEARCH.md new file mode 100644 index 0000000..c6a64c3 --- /dev/null +++ b/.planning/phases/02-compatibility-automation-and-distribution/02-RESEARCH.md @@ -0,0 +1,29 @@ +# Phase 2 Research: Compatibility Automation and Distribution + +## Findings + +- JSON Schema compatibility is directional: a consumer-compatible change must continue accepting every instance accepted by the prior schema. +- A deterministic, conservative classifier is preferable to an incomplete "compatible" claim. Unknown changed keywords must be reported as `review_required`. +- Breaking patterns that can be classified reliably include removed schemas or properties, newly required properties, narrowed types or enums, tightened object extensibility, and tightened numeric/string/array bounds. +- Pull request automation should compare the proposed `schemas/` tree with the merge-base version and publish a machine-readable report plus a human-readable job summary. +- The authoritative `$id` namespace is already `https://schemas.coding-autopilot.dev/v0.1/`. +- Distribution should be reproducible locally, validate every generated schema, and deploy only immutable release-tag content to GitHub Pages. +- A registry manifest should provide release version, available schema IDs, relative paths, and SHA-256 digests so consumers can discover and verify artifacts. + +## Strategy + +1. Add a dependency-free compatibility classifier and fixtures that prove breaking, non-breaking, unchanged, and review-required outcomes. +2. Add CI that classifies pull-request schema changes against the merge base and uploads the JSON report. +3. Add a deterministic registry builder that packages schemas under both an immutable release path and the stable major/minor path. +4. Add release-triggered Pages deployment with explicit permissions, concurrency, and artifact validation. + +## Risks And Mitigations + +| Risk | Mitigation | +|------|------------| +| False compatibility claim | Classify unsupported changed keywords as `review_required`, never silently compatible | +| Mutable release artifacts | Build only from semantic-version tags and include SHA-256 digests | +| Broken relative `$ref` values | Preserve each version directory layout and compile generated schemas in tests | +| CI unable to compare shallow history | Checkout full history for compatibility job | +| Pages custom domain not configured | Document DNS/Pages prerequisite and keep generated artifact independently testable | + From ef2c6a6e1f95128c3a4dfcfcfa029028674569fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kim=20Harjam=C3=A4ki?= Date: Thu, 11 Jun 2026 14:01:40 +0300 Subject: [PATCH 2/8] feat(02-01): automate schema compatibility classification --- .github/workflows/compatibility.yml | 57 ++++++++ CONTRIBUTING.md | 8 ++ docs/VERSIONING.md | 17 +++ package.json | 1 + scripts/compatibility.mjs | 213 ++++++++++++++++++++++++++++ scripts/lib.mjs | 9 ++ tests/compatibility.test.mjs | 58 ++++++++ 7 files changed, 363 insertions(+) create mode 100644 .github/workflows/compatibility.yml create mode 100644 scripts/compatibility.mjs create mode 100644 tests/compatibility.test.mjs diff --git a/.github/workflows/compatibility.yml b/.github/workflows/compatibility.yml new file mode 100644 index 0000000..1c803ea --- /dev/null +++ b/.github/workflows/compatibility.yml @@ -0,0 +1,57 @@ +name: Compatibility + +on: + pull_request: + paths: + - "schemas/**" + - "scripts/compatibility.mjs" + - "scripts/lib.mjs" + - "tests/compatibility.test.mjs" + - ".github/workflows/compatibility.yml" + +permissions: + contents: read + +jobs: + classify: + name: Classify schema compatibility + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out proposed contracts + uses: actions/checkout@v4 + with: + path: candidate + fetch-depth: 0 + - name: Check out base contracts + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.base.sha }} + path: baseline + fetch-depth: 1 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: candidate/package-lock.json + - run: npm ci + working-directory: candidate + - name: Classify changes + id: classify + shell: bash + run: | + set +e + node candidate/scripts/compatibility.mjs \ + --base baseline/schemas \ + --head candidate/schemas \ + --output candidate/compatibility-report.json + code=$? + cat candidate/compatibility-report.json >> "$GITHUB_STEP_SUMMARY" + exit "$code" + - name: Upload compatibility report + if: always() + uses: actions/upload-artifact@v4 + with: + name: compatibility-report + path: candidate/compatibility-report.json + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 33ce3dd..40fcf66 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,6 +18,14 @@ npm run validate 4. Run the full validation suite. 5. Submit a focused pull request with compatibility impact and migration notes. +Run the automated compatibility classifier against the current release or target branch before submitting: + +```powershell +npm run compatibility -- --base path\to\baseline\schemas --head schemas --output compatibility-report.json +``` + +Pull-request CI repeats this comparison. Breaking changes and changes requiring semantic review fail the compatibility job. + Breaking changes require a new major-version directory and explicit maintainer approval. Do not include secrets, credentials, customer data, or personal data in schemas or examples. diff --git a/docs/VERSIONING.md b/docs/VERSIONING.md index 186a22a..99aa854 100644 --- a/docs/VERSIONING.md +++ b/docs/VERSIONING.md @@ -27,3 +27,20 @@ Major releases may remove or rename properties, add required properties, narrow ## Change Procedure Every contract change must include updated examples, tests, changelog entry, compatibility classification, and migration notes for breaking changes. + +## Automated Classification + +Pull requests that affect schemas run the directional compatibility classifier against the base branch: + +```powershell +npm run compatibility -- --base path\to\baseline\schemas --head schemas --output compatibility-report.json +``` + +The classifier reports: + +- `unchanged`: no schema behavior changed. +- `compatible`: known additive or relaxed changes only. +- `breaking`: known changes that invalidate previously accepted payloads. +- `review_required`: changed semantics that cannot be classified safely by the automated rules. + +Both `breaking` and `review_required` exit nonzero. A `review_required` result must be resolved by simplifying the change, extending classifier coverage with tests, or obtaining explicit maintainer review. Automation never treats an unknown semantic change as compatible. diff --git a/package.json b/package.json index b0dcf0f..75777b6 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "node": ">=22" }, "scripts": { + "compatibility": "node scripts/compatibility.mjs", "validate": "node scripts/validate.mjs", "test": "node --test" }, diff --git a/scripts/compatibility.mjs b/scripts/compatibility.mjs new file mode 100644 index 0000000..768e912 --- /dev/null +++ b/scripts/compatibility.mjs @@ -0,0 +1,213 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { jsonFilesRecursive, readJson } from "./lib.mjs"; + +const severity = { unchanged: 0, compatible: 1, review_required: 2, breaking: 3 }; +const annotations = new Set([ + "$comment", "$schema", "default", "deprecated", "description", "examples", + "readOnly", "title", "writeOnly" +]); +const handled = new Set([ + ...annotations, "$defs", "$id", "$ref", "additionalProperties", "const", "enum", + "exclusiveMaximum", "exclusiveMinimum", "format", "items", "maxItems", "maxLength", + "maxProperties", "maximum", "minItems", "minLength", "minProperties", "minimum", + "multipleOf", "pattern", "properties", "required", "type", "unevaluatedProperties", + "uniqueItems" +]); + +function stable(value) { + if (Array.isArray(value)) return `[${value.map(stable).join(",")}]`; + if (value && typeof value === "object") { + return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stable(value[key])}`).join(",")}}`; + } + return JSON.stringify(value); +} + +function equal(left, right) { + return stable(left) === stable(right); +} + +function add(changes, status, location, message) { + changes.push({ status, location, message }); +} + +function asSet(value) { + if (value === undefined) return null; + return new Set(Array.isArray(value) ? value : [value]); +} + +function compareSetKeyword(changes, keyword, before, after, location) { + const oldSet = asSet(before); + const newSet = asSet(after); + if (equal(before, after)) return; + if (!newSet) return add(changes, "compatible", location, `${keyword} constraint removed`); + if (!oldSet) return add(changes, "breaking", location, `${keyword} constraint added`); + const removed = [...oldSet].filter((item) => !newSet.has(item)); + add(changes, removed.length ? "breaking" : "compatible", location, + removed.length ? `${keyword} narrowed by removing ${removed.join(", ")}` : `${keyword} widened`); +} + +function compareBound(changes, keyword, before, after, location, lowerBound) { + if (equal(before, after)) return; + if (after === undefined) return add(changes, "compatible", location, `${keyword} constraint removed`); + if (before === undefined) return add(changes, "breaking", location, `${keyword} constraint added`); + const tighter = lowerBound ? after > before : after < before; + add(changes, tighter ? "breaking" : "compatible", location, `${keyword} changed from ${before} to ${after}`); +} + +function compareBooleanConstraint(changes, keyword, before, after, location) { + if (equal(before, after)) return; + if (after === undefined || after === false) { + return add(changes, "compatible", location, `${keyword} constraint relaxed`); + } + add(changes, "breaking", location, `${keyword} constraint tightened`); +} + +function compareSchema(before, after, location, changes) { + if (equal(before, after)) return; + if (typeof before !== "object" || before === null || typeof after !== "object" || after === null) { + add(changes, "review_required", location, "schema shape changed"); + return; + } + + compareSetKeyword(changes, "type", before.type, after.type, location); + compareSetKeyword(changes, "enum", before.enum, after.enum, location); + + if (!equal(before.const, after.const)) { + if (after.const === undefined) add(changes, "compatible", location, "const constraint removed"); + else add(changes, "breaking", location, "const constraint added or changed"); + } + + const oldRequired = new Set(before.required ?? []); + const newRequired = new Set(after.required ?? []); + for (const name of newRequired) { + if (!oldRequired.has(name)) add(changes, "breaking", `${location}/required`, `required property added: ${name}`); + } + for (const name of oldRequired) { + if (!newRequired.has(name)) add(changes, "compatible", `${location}/required`, `required property removed: ${name}`); + } + + const oldProperties = before.properties ?? {}; + const newProperties = after.properties ?? {}; + for (const name of Object.keys(oldProperties)) { + if (!(name in newProperties)) add(changes, "breaking", `${location}/properties/${name}`, "property removed"); + else compareSchema(oldProperties[name], newProperties[name], `${location}/properties/${name}`, changes); + } + for (const name of Object.keys(newProperties)) { + if (!(name in oldProperties)) { + add(changes, newRequired.has(name) ? "breaking" : "compatible", `${location}/properties/${name}`, + newRequired.has(name) ? "required property added" : "optional property added"); + } + } + + for (const keyword of ["additionalProperties", "unevaluatedProperties"]) { + const oldValue = before[keyword]; + const newValue = after[keyword]; + if (equal(oldValue, newValue)) continue; + if (newValue === false && oldValue !== false) add(changes, "breaking", location, `${keyword} tightened to false`); + else if (oldValue === false && newValue !== false) add(changes, "compatible", location, `${keyword} relaxed`); + else if (typeof oldValue === "object" && typeof newValue === "object") { + compareSchema(oldValue, newValue, `${location}/${keyword}`, changes); + } else add(changes, "review_required", location, `${keyword} changed`); + } + + for (const [keyword, lower] of [ + ["minimum", true], ["exclusiveMinimum", true], ["minLength", true], ["minItems", true], ["minProperties", true], + ["maximum", false], ["exclusiveMaximum", false], ["maxLength", false], ["maxItems", false], ["maxProperties", false] + ]) compareBound(changes, keyword, before[keyword], after[keyword], location, lower); + + for (const keyword of ["pattern", "format", "multipleOf", "$ref", "$id"]) { + if (equal(before[keyword], after[keyword])) continue; + if (after[keyword] === undefined && !["$ref", "$id"].includes(keyword)) { + add(changes, "compatible", location, `${keyword} constraint removed`); + } else if (before[keyword] === undefined && !["$ref", "$id"].includes(keyword)) { + add(changes, "breaking", location, `${keyword} constraint added`); + } else add(changes, "review_required", location, `${keyword} changed`); + } + + compareBooleanConstraint(changes, "uniqueItems", before.uniqueItems, after.uniqueItems, location); + if (!equal(before.items, after.items)) { + if (before.items === undefined) add(changes, "breaking", `${location}/items`, "items constraint added"); + else if (after.items === undefined) add(changes, "compatible", `${location}/items`, "items constraint removed"); + else compareSchema(before.items, after.items, `${location}/items`, changes); + } + + if (!equal(before.$defs, after.$defs)) { + const oldDefs = before.$defs ?? {}; + const newDefs = after.$defs ?? {}; + for (const name of Object.keys(oldDefs)) { + if (!(name in newDefs)) add(changes, "breaking", `${location}/$defs/${name}`, "definition removed"); + else compareSchema(oldDefs[name], newDefs[name], `${location}/$defs/${name}`, changes); + } + for (const name of Object.keys(newDefs)) { + if (!(name in oldDefs)) add(changes, "compatible", `${location}/$defs/${name}`, "definition added"); + } + } + + for (const keyword of new Set([...Object.keys(before), ...Object.keys(after)])) { + if (!handled.has(keyword) && !equal(before[keyword], after[keyword])) { + add(changes, "review_required", `${location}/${keyword}`, "unsupported keyword changed"); + } + } +} + +export async function classifyDirectories(baseDirectory, headDirectory) { + const baseFiles = await jsonFilesRecursive(baseDirectory); + const headFiles = await jsonFilesRecursive(headDirectory); + const base = new Map(baseFiles.map((file) => [path.relative(baseDirectory, file).replaceAll("\\", "/"), file])); + const head = new Map(headFiles.map((file) => [path.relative(headDirectory, file).replaceAll("\\", "/"), file])); + const changes = []; + + for (const [relative, file] of base) { + if (!head.has(relative)) add(changes, "breaking", relative, "schema removed"); + else compareSchema(await readJson(file), await readJson(head.get(relative)), relative, changes); + } + for (const relative of head.keys()) { + if (!base.has(relative)) add(changes, "compatible", relative, "schema added"); + } + + const status = changes.reduce((result, change) => severity[change.status] > severity[result] ? change.status : result, "unchanged"); + return { + status, + summary: Object.fromEntries(Object.keys(severity).map((key) => [key, changes.filter((change) => change.status === key).length])), + changes + }; +} + +function parseArguments(args) { + if (args.includes("--help")) return { help: true }; + const result = {}; + for (let index = 0; index < args.length; index += 2) { + const key = args[index]?.replace(/^--/, ""); + if (!key || !args[index + 1]) throw new Error(`Missing value for ${args[index] ?? "argument"}`); + result[key] = args[index + 1]; + } + if (!result.base || !result.head) throw new Error("--base and --head are required"); + return result; +} + +async function main() { + const args = parseArguments(process.argv.slice(2)); + if (args.help) { + console.log("Usage: node scripts/compatibility.mjs --base --head [--output ]"); + console.log("Exit codes: 0 unchanged/compatible, 1 breaking, 2 review_required or usage error."); + return; + } + const report = await classifyDirectories(path.resolve(args.base), path.resolve(args.head)); + const output = `${JSON.stringify(report, null, 2)}\n`; + if (args.output) { + await mkdir(path.dirname(path.resolve(args.output)), { recursive: true }); + await writeFile(path.resolve(args.output), output); + } + console.log(output.trim()); + process.exitCode = report.status === "breaking" ? 1 : report.status === "review_required" ? 2 : 0; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + console.error(error.message); + process.exitCode = 2; + }); +} + diff --git a/scripts/lib.mjs b/scripts/lib.mjs index a2eb665..90fcb8d 100644 --- a/scripts/lib.mjs +++ b/scripts/lib.mjs @@ -18,6 +18,15 @@ export async function jsonFiles(directory) { .map((name) => path.join(directory, name)); } +export async function jsonFilesRecursive(directory) { + const entries = await readdir(directory, { withFileTypes: true }); + const files = await Promise.all(entries.map((entry) => { + const entryPath = path.join(directory, entry.name); + return entry.isDirectory() ? jsonFilesRecursive(entryPath) : entry.name.endsWith(".json") ? [entryPath] : []; + })); + return files.flat().sort(); +} + export async function createValidator() { const ajv = new Ajv2020({ allErrors: true, strict: true }); addFormats(ajv); diff --git a/tests/compatibility.test.mjs b/tests/compatibility.test.mjs new file mode 100644 index 0000000..5640165 --- /dev/null +++ b/tests/compatibility.test.mjs @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { classifyDirectories } from "../scripts/compatibility.mjs"; + +async function classify(before, after, additionalBefore = {}, additionalAfter = {}) { + const root = await mkdtemp(path.join(os.tmpdir(), "cas-compat-")); + const base = path.join(root, "base"); + const head = path.join(root, "head"); + await Promise.all([mkdir(base), mkdir(head)]); + const write = (directory, name, value) => writeFile(path.join(directory, name), JSON.stringify(value)); + await write(base, "contract.schema.json", before); + await write(head, "contract.schema.json", after); + for (const [name, value] of Object.entries(additionalBefore)) await write(base, name, value); + for (const [name, value] of Object.entries(additionalAfter)) await write(head, name, value); + return classifyDirectories(base, head); +} + +const objectSchema = { + type: "object", + additionalProperties: false, + required: ["id"], + properties: { id: { type: "string" } } +}; + +test("classifies identical trees as unchanged", async () => { + assert.equal((await classify(objectSchema, objectSchema)).status, "unchanged"); +}); + +test("classifies additive optional properties and schemas as compatible", async () => { + const after = structuredClone(objectSchema); + after.properties.label = { type: "string" }; + const report = await classify(objectSchema, after, {}, { "new.schema.json": { type: "string" } }); + assert.equal(report.status, "compatible"); + assert.equal(report.summary.compatible, 2); +}); + +test("classifies required additions, removals, narrowing, and tightened constraints as breaking", async () => { + const cases = [ + [{ ...objectSchema }, { ...objectSchema, required: ["id", "label"], properties: { ...objectSchema.properties, label: { type: "string" } } }], + [objectSchema, { ...objectSchema, properties: {} }], + [{ type: ["string", "null"] }, { type: "string" }], + [{ enum: ["a", "b"] }, { enum: ["a"] }], + [{ type: "string", maxLength: 10 }, { type: "string", maxLength: 5 }], + [{ type: "object" }, { type: "object", additionalProperties: false }] + ]; + for (const [before, after] of cases) assert.equal((await classify(before, after)).status, "breaking"); + assert.equal((await classify(objectSchema, objectSchema, { "removed.schema.json": { type: "string" } })).status, "breaking"); +}); + +test("classifies unsupported semantic keyword changes as review_required", async () => { + const report = await classify({ oneOf: [{ type: "string" }] }, { oneOf: [{ type: "number" }] }); + assert.equal(report.status, "review_required"); + assert.match(report.changes[0].location, /oneOf/); +}); + From cc87eb3d9d7ca546afa4a3355ebec73ee9e38f7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kim=20Harjam=C3=A4ki?= Date: Thu, 11 Jun 2026 14:01:57 +0300 Subject: [PATCH 3/8] docs(02-01): complete compatibility automation plan --- .planning/REQUIREMENTS.md | 4 +- .planning/ROADMAP.md | 2 +- .planning/STATE.md | 4 +- .../02-01-SUMMARY.md | 50 +++++++++++++++++++ 4 files changed, 55 insertions(+), 5 deletions(-) create mode 100644 .planning/phases/02-compatibility-automation-and-distribution/02-01-SUMMARY.md diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index 6fadb2c..78a5b8f 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -27,7 +27,7 @@ - **SDK-01**: Consumers can install generated typed SDKs for supported languages. - **REG-01**: Consumers can resolve published schemas from a stable registry endpoint. -- **COMP-01**: CI automatically compares proposed schemas against the latest release for compatibility. +- [x] **COMP-01**: CI automatically compares proposed schemas against the latest release for compatibility. ## Out of Scope @@ -50,7 +50,7 @@ | GOV-01 | Phase 1 | Complete | | GOV-02 | Phase 1 | Complete | | GOV-03 | Phase 1 | Complete | -| COMP-01 | Phase 2 | Pending | +| COMP-01 | Phase 2 | Complete | | REG-01 | Phase 2 | Pending | **Coverage:** 12 requirements, 12 mapped, 0 unmapped. diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 13ab666..cd9baf0 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -21,7 +21,7 @@ **Plans:** 2 plans **Wave 1** -- [ ] 02-01: Automated compatibility classification and PR enforcement +- [x] 02-01: Automated compatibility classification and PR enforcement **Wave 2** *(blocked on Wave 1 completion)* - [ ] 02-02: Deterministic versioned registry and release distribution diff --git a/.planning/STATE.md b/.planning/STATE.md index 1f874c7..3cdbe85 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -23,10 +23,10 @@ See: `.planning/PROJECT.md` (updated 2026-06-11) ## Status -- Phase: 1 of 3 complete; Phase 2 planned (0/2 plans complete) +- Phase: 1 of 3 complete; Phase 2 in progress (1/2 plans complete) - Milestone: v0.1 - Mode: YOLO, quality, parallel -- Next action: Execute Phase 2 compatibility automation and registry distribution plans. +- Next action: Execute Phase 2 deterministic registry and release distribution plan. ## Decisions diff --git a/.planning/phases/02-compatibility-automation-and-distribution/02-01-SUMMARY.md b/.planning/phases/02-compatibility-automation-and-distribution/02-01-SUMMARY.md new file mode 100644 index 0000000..a2c300e --- /dev/null +++ b/.planning/phases/02-compatibility-automation-and-distribution/02-01-SUMMARY.md @@ -0,0 +1,50 @@ +--- +phase: 02-compatibility-automation-and-distribution +plan: "01" +subsystem: compatibility +tags: [json-schema, ci, semver] +requires: [01-useful-v0.1-foundation] +provides: [compatibility-classifier, compatibility-pr-check] +affects: [distribution, governance] +tech-stack: + added: [] + patterns: [conservative-directional-classification, machine-readable-ci-report] +key-files: + created: + - scripts/compatibility.mjs + - tests/compatibility.test.mjs + - .github/workflows/compatibility.yml + modified: + - scripts/lib.mjs + - docs/VERSIONING.md + - CONTRIBUTING.md +key-decisions: + - Unknown semantic keyword changes are review_required and fail CI rather than being falsely classified compatible. +requirements-completed: [COMP-01] +completed: 2026-06-11 +--- + +# Phase 2 Plan 1: Compatibility Automation Summary + +Implemented a dependency-free directional JSON Schema classifier with pull-request enforcement and machine-readable reports. + +## Results + +- Classifies unchanged, compatible, breaking, and review-required schema tree changes. +- Detects property, required-field, type, enum, constraint, extensibility, definition, and schema-file compatibility changes. +- Fails pull-request CI for breaking and review-required changes. +- Documents local use and governance semantics. + +## Verification + +- `npm.cmd test`: 8/8 passed +- `npm.cmd run validate`: 7 lifecycle examples validated +- Identical live schema tree classified as `unchanged` +- CLI help and exit behavior verified + +## Deviations from Plan + +Compatibility tests generate isolated fixtures at runtime instead of storing static fixture directories. This keeps each case explicit and avoids fixture drift. + +## Self-Check: PASSED + From da012775c5254019c2baee7535457ca3ae1056f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kim=20Harjam=C3=A4ki?= Date: Thu, 11 Jun 2026 14:04:38 +0300 Subject: [PATCH 4/8] feat(02-02): publish deterministic schema registry --- .github/workflows/publish-registry.yml | 66 ++++++++++++++++ .gitignore | 3 + CHANGELOG.md | 6 ++ README.md | 5 +- docs/DISTRIBUTION.md | 38 +++++++++ package.json | 2 + scripts/build-registry.mjs | 104 +++++++++++++++++++++++++ scripts/lib.mjs | 4 +- scripts/validate-registry.mjs | 52 +++++++++++++ tests/registry.test.mjs | 60 ++++++++++++++ 10 files changed, 337 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/publish-registry.yml create mode 100644 docs/DISTRIBUTION.md create mode 100644 scripts/build-registry.mjs create mode 100644 scripts/validate-registry.mjs create mode 100644 tests/registry.test.mjs diff --git a/.github/workflows/publish-registry.yml b/.github/workflows/publish-registry.yml new file mode 100644 index 0000000..a03e47f --- /dev/null +++ b/.github/workflows/publish-registry.yml @@ -0,0 +1,66 @@ +name: Publish schema registry + +on: + push: + tags: + - "v[0-9]+.[0-9]+.[0-9]+" + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + name: Build registry + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm test + - name: Build every released schema version + shell: bash + run: | + set -euo pipefail + rm -rf registry release-source + while IFS= read -r tag; do + version="${tag#v}" + line="v${version%.*}" + mkdir -p "release-source/$version" + git archive "$tag" "schemas/$line" | tar -x -C "release-source/$version" + node scripts/build-registry.mjs \ + --version "$version" \ + --source "release-source/$version/schemas/$line" \ + --output registry \ + --append + done < <(git tag --list "v[0-9]*.[0-9]*.[0-9]*" --sort=version:refname) + - run: npm run validate:registry -- --registry registry + - uses: actions/configure-pages@v5 + - uses: actions/upload-pages-artifact@v3 + with: + path: registry + + deploy: + name: Deploy registry + needs: build + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 612c939..b2b198d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ node_modules/ coverage/ +registry/ +release-source/ +.tmp/ *.log .DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index 51abeb4..9307c4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes follow Semantic Versioning and Keep a Changelog conventions. ## [Unreleased] +### Added + +- Conservative automated JSON Schema compatibility classification in pull-request CI. +- Deterministic schema registry packaging and release-triggered GitHub Pages distribution. +- Stable major/minor and immutable patch-version schema URL contracts with SHA-256 manifests. + ## [0.1.0] - 2026-06-11 ### Added diff --git a/README.md b/README.md index dab99e0..14e7881 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ npm run validate | `examples/v0.1/` | Complete executable lifecycle fixtures | | `tests/` | Positive, negative, and lifecycle contract tests | | `docs/VERSIONING.md` | Compatibility and evolution policy | +| `docs/DISTRIBUTION.md` | Stable and immutable schema registry URLs | | `.planning/` | GSD project context, requirements, roadmap, and research | ## Adoption @@ -54,9 +55,11 @@ Producers should emit records that validate against the declared version. Consum The schemas are public APIs. Review [versioning and compatibility](docs/VERSIONING.md), [contributing](CONTRIBUTING.md), and [security](SECURITY.md) before proposing changes. +Released schemas are discoverable from `https://schemas.coding-autopilot.dev/index.json`. Use stable `vMAJOR.MINOR` URLs for compatible updates or immutable `releases/vMAJOR.MINOR.PATCH` URLs for reproducible builds. See [schema distribution](docs/DISTRIBUTION.md). + ## Status -`v0.1.0` is the useful foundation release. It is ready for early integration and intentionally precedes generated SDKs and automated compatibility comparison. +`v0.1.0` is the useful foundation release. Compatibility automation and versioned registry distribution are available for subsequent releases. ## License diff --git a/docs/DISTRIBUTION.md b/docs/DISTRIBUTION.md new file mode 100644 index 0000000..435ad0f --- /dev/null +++ b/docs/DISTRIBUTION.md @@ -0,0 +1,38 @@ +# Schema Distribution + +Released CAS schemas are distributed as a static registry from `https://schemas.coding-autopilot.dev`. + +## URL Contract + +Use a stable major/minor URL when a consumer should automatically receive compatible patch releases: + +```text +https://schemas.coding-autopilot.dev/v0.1/prompt-envelope.schema.json +``` + +Use an immutable release URL when builds or evidence must remain reproducible: + +```text +https://schemas.coding-autopilot.dev/releases/v0.1.0/prompt-envelope.schema.json +``` + +Discovery and integrity metadata are available from: + +- `/index.json`: available releases and the current release for each major/minor line. +- `/v0.1/manifest.json`: schemas and SHA-256 digests for the current compatible release. +- `/releases/v0.1.0/manifest.json`: schemas and SHA-256 digests for one immutable release. + +## Local Build + +```powershell +npm run build:registry -- --version 0.1.0 +npm run validate:registry -- --registry registry +``` + +The command writes `registry/`, validates the version, preserves relative schema references, and produces deterministic manifests. + +## Publication + +Pushing a semantic version tag such as `v0.1.1` runs `.github/workflows/publish-registry.yml`. The workflow rebuilds every tagged release, preserves immutable release paths, advances stable major/minor paths, validates the repository, and deploys the result to GitHub Pages. + +Repository administrators must configure GitHub Pages to use **GitHub Actions** as its source and configure DNS for the `schemas.coding-autopilot.dev` custom domain. Publication uses GitHub's OIDC token and does not require stored deployment credentials. diff --git a/package.json b/package.json index 75777b6..d20790b 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,9 @@ "node": ">=22" }, "scripts": { + "build:registry": "node scripts/build-registry.mjs", "compatibility": "node scripts/compatibility.mjs", + "validate:registry": "node scripts/validate-registry.mjs", "validate": "node scripts/validate.mjs", "test": "node --test" }, diff --git a/scripts/build-registry.mjs b/scripts/build-registry.mjs new file mode 100644 index 0000000..7b810a4 --- /dev/null +++ b/scripts/build-registry.mjs @@ -0,0 +1,104 @@ +import { copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { jsonFilesRecursive, readJson, root } from "./lib.mjs"; + +const versionPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; + +function parseArguments(args) { + if (args.includes("--help")) return { help: true }; + const result = { output: path.join(root, "registry"), append: false, domain: "schemas.coding-autopilot.dev" }; + for (let index = 0; index < args.length; index += 1) { + if (args[index] === "--append") result.append = true; + else { + const key = args[index].replace(/^--/, ""); + if (!args[index + 1]) throw new Error(`Missing value for ${args[index]}`); + result[key] = args[++index]; + } + } + if (!versionPattern.test(result.version ?? "")) throw new Error("--version must be a semantic version such as 0.1.0"); + const [, major, minor] = result.version.match(versionPattern); + result.line = `v${major}.${minor}`; + result.source ??= path.join(root, "schemas", result.line); + return result; +} + +async function digest(file) { + return createHash("sha256").update(await readFile(file)).digest("hex"); +} + +async function writeJson(file, value) { + await mkdir(path.dirname(file), { recursive: true }); + await writeFile(file, `${JSON.stringify(value, null, 2)}\n`); +} + +async function packageSchemas(source, destination, version) { + const files = await jsonFilesRecursive(source); + if (!files.length) throw new Error(`No JSON schemas found in ${source}`); + const schemas = []; + for (const file of files) { + const relative = path.relative(source, file).replaceAll("\\", "/"); + const output = path.join(destination, relative); + const schema = await readJson(file); + if (!schema.$id) throw new Error(`${relative} does not declare $id`); + await mkdir(path.dirname(output), { recursive: true }); + await copyFile(file, output); + schemas.push({ id: schema.$id, path: relative, sha256: await digest(file) }); + } + const manifest = { version, schemas: schemas.sort((left, right) => left.path.localeCompare(right.path)) }; + await writeJson(path.join(destination, "manifest.json"), manifest); + return manifest; +} + +export async function buildRegistry({ version, source, output, append = false, domain = "schemas.coding-autopilot.dev" }) { + if (!versionPattern.test(version ?? "")) throw new Error("version must be a semantic version such as 0.1.0"); + const [, major, minor] = version.match(versionPattern); + const line = `v${major}.${minor}`; + const outputRoot = path.resolve(output); + if (!append) await rm(outputRoot, { recursive: true, force: true }); + await mkdir(outputRoot, { recursive: true }); + + const immutablePath = path.join(outputRoot, "releases", `v${version}`); + const stablePath = path.join(outputRoot, line); + await rm(immutablePath, { recursive: true, force: true }); + await rm(stablePath, { recursive: true, force: true }); + const immutable = await packageSchemas(path.resolve(source), immutablePath, version); + const stable = await packageSchemas(path.resolve(source), stablePath, version); + + const indexPath = path.join(outputRoot, "index.json"); + let existing = { releases: [], lines: {} }; + if (append) { + try { existing = JSON.parse(await readFile(indexPath, "utf8")); } catch (error) { + if (error.code !== "ENOENT") throw error; + } + } + const releases = [...new Set([...existing.releases, version])].sort((left, right) => + left.localeCompare(right, undefined, { numeric: true })); + const index = { + schemaVersion: "1.0.0", + releases, + lines: { ...existing.lines, [line]: version } + }; + await writeJson(indexPath, index); + await writeFile(path.join(outputRoot, "CNAME"), `${domain}\n`); + return { index, immutable, stable, output: outputRoot }; +} + +async function main() { + const args = parseArguments(process.argv.slice(2)); + if (args.help) { + console.log("Usage: node scripts/build-registry.mjs --version [--source ] [--output ] [--append]"); + return; + } + const result = await buildRegistry(args); + console.log(`Built ${result.stable.schemas.length} schemas for ${args.version} at ${result.output}`); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + console.error(error.message); + process.exitCode = 1; + }); +} + diff --git a/scripts/lib.mjs b/scripts/lib.mjs index 90fcb8d..b9f2194 100644 --- a/scripts/lib.mjs +++ b/scripts/lib.mjs @@ -27,11 +27,11 @@ export async function jsonFilesRecursive(directory) { return files.flat().sort(); } -export async function createValidator() { +export async function createValidator(directory = schemaDirectory) { const ajv = new Ajv2020({ allErrors: true, strict: true }); addFormats(ajv); - for (const schemaPath of await jsonFiles(schemaDirectory)) { + for (const schemaPath of (await jsonFiles(directory)).filter((file) => file.endsWith(".schema.json"))) { ajv.addSchema(await readJson(schemaPath)); } diff --git a/scripts/validate-registry.mjs b/scripts/validate-registry.mjs new file mode 100644 index 0000000..1be6d1e --- /dev/null +++ b/scripts/validate-registry.mjs @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { createValidator, readJson, root } from "./lib.mjs"; + +async function validateDirectory(directory, expectedVersion) { + const manifest = await readJson(path.join(directory, "manifest.json")); + assert.equal(manifest.version, expectedVersion, `${directory} has the expected version`); + const validator = await createValidator(directory); + for (const entry of manifest.schemas) { + const file = path.join(directory, entry.path); + const content = await readFile(file); + assert.equal(createHash("sha256").update(content).digest("hex"), entry.sha256, `${entry.path} digest matches`); + const schema = JSON.parse(content); + assert.equal(schema.$id, entry.id, `${entry.path} id matches`); + assert.ok(validator.getSchema(entry.id), `${entry.id} compiles`); + } +} + +export async function validateRegistry(registry) { + const directory = path.resolve(registry); + const index = await readJson(path.join(directory, "index.json")); + assert.equal(index.schemaVersion, "1.0.0"); + for (const version of index.releases) { + assert.match(version, /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/); + await validateDirectory(path.join(directory, "releases", `v${version}`), version); + } + for (const [line, version] of Object.entries(index.lines)) { + assert.match(line, /^v(0|[1-9]\d*)\.(0|[1-9]\d*)$/); + assert.ok(index.releases.includes(version), `${line} points to a released version`); + await validateDirectory(path.join(directory, line), version); + } + return index; +} + +async function main() { + const index = process.argv.indexOf("--registry"); + const registry = index >= 0 ? process.argv[index + 1] : path.join(root, "registry"); + if (!registry) throw new Error("--registry requires a path"); + const result = await validateRegistry(registry); + console.log(`Validated ${result.releases.length} release(s) and ${Object.keys(result.lines).length} stable line(s).`); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + console.error(error.message); + process.exitCode = 1; + }); +} + diff --git a/tests/registry.test.mjs b/tests/registry.test.mjs new file mode 100644 index 0000000..e89063f --- /dev/null +++ b/tests/registry.test.mjs @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdtemp, readFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { buildRegistry } from "../scripts/build-registry.mjs"; +import { createValidator, jsonFiles, readJson, schemaDirectory } from "../scripts/lib.mjs"; +import { validateRegistry } from "../scripts/validate-registry.mjs"; + +async function build() { + const output = await mkdtemp(path.join(os.tmpdir(), "cas-registry-")); + await buildRegistry({ version: "0.1.0", source: schemaDirectory, output }); + return output; +} + +test("registry packages immutable and stable schema paths with verified digests", async () => { + const output = await build(); + const index = await readJson(path.join(output, "index.json")); + const stableManifest = await readJson(path.join(output, "v0.1", "manifest.json")); + const immutableManifest = await readJson(path.join(output, "releases", "v0.1.0", "manifest.json")); + + assert.deepEqual(index, { schemaVersion: "1.0.0", releases: ["0.1.0"], lines: { "v0.1": "0.1.0" } }); + assert.deepEqual(stableManifest, immutableManifest); + assert.equal(stableManifest.schemas.length, 8); + assert.match(stableManifest.schemas[0].sha256, /^[\da-f]{64}$/); + for (const schema of stableManifest.schemas) { + const content = await readFile(path.join(output, "v0.1", schema.path)); + assert.equal(createHash("sha256").update(content).digest("hex"), schema.sha256); + } + assert.equal(await readFile(path.join(output, "CNAME"), "utf8"), "schemas.coding-autopilot.dev\n"); +}); + +test("distributed stable schemas compile and retain authoritative ids", async () => { + const output = await build(); + const directory = path.join(output, "v0.1"); + const validator = await createValidator(directory); + for (const file of await jsonFiles(directory)) { + const schema = await readJson(file); + if (file.endsWith("manifest.json")) continue; + assert.ok(validator.getSchema(schema.$id), `${schema.$id} compiles from distributed output`); + } +}); + +test("registry builds are deterministic and append preserves immutable releases", async () => { + const output = await build(); + const first = await readFile(path.join(output, "v0.1", "manifest.json"), "utf8"); + await buildRegistry({ version: "0.1.0", source: schemaDirectory, output }); + assert.equal(await readFile(path.join(output, "v0.1", "manifest.json"), "utf8"), first); + await buildRegistry({ version: "0.1.1", source: schemaDirectory, output, append: true }); + const index = await readJson(path.join(output, "index.json")); + assert.deepEqual(index.releases, ["0.1.0", "0.1.1"]); + assert.equal(index.lines["v0.1"], "0.1.1"); + assert.equal((await readJson(path.join(output, "releases", "v0.1.0", "manifest.json"))).version, "0.1.0"); + assert.deepEqual(await validateRegistry(output), index); +}); + +test("registry rejects invalid release versions", async () => { + await assert.rejects(() => buildRegistry({ version: "latest", source: schemaDirectory, output: "ignored" }), /semantic version/); +}); From 27560bfe307beb18b33aa0391da4f3650d46df91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kim=20Harjam=C3=A4ki?= Date: Thu, 11 Jun 2026 14:04:57 +0300 Subject: [PATCH 5/8] docs(02-02): complete registry distribution plan --- .planning/REQUIREMENTS.md | 4 +- .planning/ROADMAP.md | 2 +- .planning/STATE.md | 4 +- .../02-02-SUMMARY.md | 56 +++++++++++++++++++ 4 files changed, 61 insertions(+), 5 deletions(-) create mode 100644 .planning/phases/02-compatibility-automation-and-distribution/02-02-SUMMARY.md diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index 78a5b8f..7df715d 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -26,7 +26,7 @@ ## vNext Requirements - **SDK-01**: Consumers can install generated typed SDKs for supported languages. -- **REG-01**: Consumers can resolve published schemas from a stable registry endpoint. +- [x] **REG-01**: Consumers can resolve published schemas from a stable registry endpoint. - [x] **COMP-01**: CI automatically compares proposed schemas against the latest release for compatibility. ## Out of Scope @@ -51,7 +51,7 @@ | GOV-02 | Phase 1 | Complete | | GOV-03 | Phase 1 | Complete | | COMP-01 | Phase 2 | Complete | -| REG-01 | Phase 2 | Pending | +| REG-01 | Phase 2 | Complete | **Coverage:** 12 requirements, 12 mapped, 0 unmapped. diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index cd9baf0..c0e808b 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -24,7 +24,7 @@ - [x] 02-01: Automated compatibility classification and PR enforcement **Wave 2** *(blocked on Wave 1 completion)* -- [ ] 02-02: Deterministic versioned registry and release distribution +- [x] 02-02: Deterministic versioned registry and release distribution **Success criteria:** 1. Pull requests receive automated breaking-change classification. diff --git a/.planning/STATE.md b/.planning/STATE.md index 3cdbe85..3a30a26 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -23,10 +23,10 @@ See: `.planning/PROJECT.md` (updated 2026-06-11) ## Status -- Phase: 1 of 3 complete; Phase 2 in progress (1/2 plans complete) +- Phase: 1 of 3 complete; Phase 2 implementation complete (2/2 plans complete), verification pending - Milestone: v0.1 - Mode: YOLO, quality, parallel -- Next action: Execute Phase 2 deterministic registry and release distribution plan. +- Next action: Verify Phase 2 compatibility automation and registry distribution. ## Decisions diff --git a/.planning/phases/02-compatibility-automation-and-distribution/02-02-SUMMARY.md b/.planning/phases/02-compatibility-automation-and-distribution/02-02-SUMMARY.md new file mode 100644 index 0000000..5db4196 --- /dev/null +++ b/.planning/phases/02-compatibility-automation-and-distribution/02-02-SUMMARY.md @@ -0,0 +1,56 @@ +--- +phase: 02-compatibility-automation-and-distribution +plan: "02" +subsystem: distribution +tags: [registry, github-pages, releases, integrity] +requires: [02-01-compatibility-automation] +provides: [versioned-schema-registry, release-publication] +affects: [consumers, release-governance] +tech-stack: + added: [] + patterns: [deterministic-release-packaging, immutable-and-stable-urls, digest-manifests] +key-files: + created: + - scripts/build-registry.mjs + - scripts/validate-registry.mjs + - tests/registry.test.mjs + - .github/workflows/publish-registry.yml + - docs/DISTRIBUTION.md + modified: + - scripts/lib.mjs + - README.md + - CHANGELOG.md +key-decisions: + - Rebuild all semantic-version tags on publication so immutable release URLs remain available. + - Advance stable major/minor URLs to the latest patch release in each line. + - Publish digest-bearing manifests and validate the exact Pages artifact before deployment. +requirements-completed: [REG-01] +completed: 2026-06-11 +--- + +# Phase 2 Plan 2: Versioned Registry Distribution Summary + +Implemented deterministic schema registry packaging with stable and immutable URLs, integrity manifests, validation, documentation, and release-triggered GitHub Pages deployment. + +## Results + +- Packages schemas under `/vMAJOR.MINOR/` and `/releases/vMAJOR.MINOR.PATCH/`. +- Preserves every semantic-version tag during Pages publication. +- Produces root discovery metadata and SHA-256 manifests. +- Validates schema compilation, IDs, versions, and digests before deployment. +- Documents consumer URL selection and administrator setup. + +## Verification + +- `npm.cmd test`: 12/12 passed +- `npm.cmd run validate`: 7 lifecycle examples validated +- `npm.cmd run build:registry -- --version 0.1.0`: 8 schemas built +- `npm.cmd run validate:registry -- --registry registry`: 1 release and 1 stable line validated +- Distributed schemas compared compatible with source schemas + +## Deviations from Plan + +Added a dedicated registry validator beyond the original plan so the exact multi-tag artifact is verified after assembly and before deployment. + +## Self-Check: PASSED + From a3e074a6d3fcc667be48e30ee5d97f4b0dcca06b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kim=20Harjam=C3=A4ki?= Date: Thu, 11 Jun 2026 14:06:06 +0300 Subject: [PATCH 6/8] fix(02): classify released allOf contracts accurately --- .github/workflows/compatibility.yml | 23 +++++++++----- .../02-01-SUMMARY.md | 3 +- docs/VERSIONING.md | 2 +- scripts/compatibility.mjs | 31 ++++++++++++++++--- tests/compatibility.test.mjs | 5 +++ 5 files changed, 49 insertions(+), 15 deletions(-) diff --git a/.github/workflows/compatibility.yml b/.github/workflows/compatibility.yml index 1c803ea..5cdf798 100644 --- a/.github/workflows/compatibility.yml +++ b/.github/workflows/compatibility.yml @@ -23,12 +23,6 @@ jobs: with: path: candidate fetch-depth: 0 - - name: Check out base contracts - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.base.sha }} - path: baseline - fetch-depth: 1 - uses: actions/setup-node@v4 with: node-version: 22 @@ -36,6 +30,15 @@ jobs: cache-dependency-path: candidate/package-lock.json - run: npm ci working-directory: candidate + - name: Extract latest released contracts + shell: bash + working-directory: candidate + run: | + set -euo pipefail + latest="$(git tag --list "v[0-9]*.[0-9]*.[0-9]*" --sort=-version:refname | head -n 1)" + test -n "$latest" + mkdir -p ../baseline + git archive "$latest" schemas | tar -x -C ../baseline - name: Classify changes id: classify shell: bash @@ -46,7 +49,12 @@ jobs: --head candidate/schemas \ --output candidate/compatibility-report.json code=$? - cat candidate/compatibility-report.json >> "$GITHUB_STEP_SUMMARY" + { + echo "## Compatibility against latest release" + echo '```json' + cat candidate/compatibility-report.json + echo '```' + } >> "$GITHUB_STEP_SUMMARY" exit "$code" - name: Upload compatibility report if: always() @@ -54,4 +62,3 @@ jobs: with: name: compatibility-report path: candidate/compatibility-report.json - diff --git a/.planning/phases/02-compatibility-automation-and-distribution/02-01-SUMMARY.md b/.planning/phases/02-compatibility-automation-and-distribution/02-01-SUMMARY.md index a2c300e..ea5cd19 100644 --- a/.planning/phases/02-compatibility-automation-and-distribution/02-01-SUMMARY.md +++ b/.planning/phases/02-compatibility-automation-and-distribution/02-01-SUMMARY.md @@ -32,7 +32,7 @@ Implemented a dependency-free directional JSON Schema classifier with pull-reque - Classifies unchanged, compatible, breaking, and review-required schema tree changes. - Detects property, required-field, type, enum, constraint, extensibility, definition, and schema-file compatibility changes. -- Fails pull-request CI for breaking and review-required changes. +- Compares pull requests with the latest semantic release and fails CI for breaking and review-required changes. - Documents local use and governance semantics. ## Verification @@ -47,4 +47,3 @@ Implemented a dependency-free directional JSON Schema classifier with pull-reque Compatibility tests generate isolated fixtures at runtime instead of storing static fixture directories. This keeps each case explicit and avoids fixture drift. ## Self-Check: PASSED - diff --git a/docs/VERSIONING.md b/docs/VERSIONING.md index 99aa854..17d4f86 100644 --- a/docs/VERSIONING.md +++ b/docs/VERSIONING.md @@ -30,7 +30,7 @@ Every contract change must include updated examples, tests, changelog entry, com ## Automated Classification -Pull requests that affect schemas run the directional compatibility classifier against the base branch: +Pull requests that affect schemas run the directional compatibility classifier against the latest semantic-version release: ```powershell npm run compatibility -- --base path\to\baseline\schemas --head schemas --output compatibility-report.json diff --git a/scripts/compatibility.mjs b/scripts/compatibility.mjs index 768e912..c281bb6 100644 --- a/scripts/compatibility.mjs +++ b/scripts/compatibility.mjs @@ -10,9 +10,9 @@ const annotations = new Set([ ]); const handled = new Set([ ...annotations, "$defs", "$id", "$ref", "additionalProperties", "const", "enum", - "exclusiveMaximum", "exclusiveMinimum", "format", "items", "maxItems", "maxLength", + "allOf", "anyOf", "exclusiveMaximum", "exclusiveMinimum", "format", "items", "maxItems", "maxLength", "maxProperties", "maximum", "minItems", "minLength", "minProperties", "minimum", - "multipleOf", "pattern", "properties", "required", "type", "unevaluatedProperties", + "multipleOf", "oneOf", "pattern", "properties", "required", "type", "unevaluatedProperties", "uniqueItems" ]); @@ -91,7 +91,11 @@ function compareSchema(before, after, location, changes) { const oldProperties = before.properties ?? {}; const newProperties = after.properties ?? {}; for (const name of Object.keys(oldProperties)) { - if (!(name in newProperties)) add(changes, "breaking", `${location}/properties/${name}`, "property removed"); + if (!(name in newProperties)) { + const closesObject = before.additionalProperties === false || before.unevaluatedProperties === false; + add(changes, closesObject ? "breaking" : "compatible", `${location}/properties/${name}`, + closesObject ? "property removed from closed object" : "property constraint removed"); + } else compareSchema(oldProperties[name], newProperties[name], `${location}/properties/${name}`, changes); } for (const name of Object.keys(newProperties)) { @@ -133,6 +137,26 @@ function compareSchema(before, after, location, changes) { else compareSchema(before.items, after.items, `${location}/items`, changes); } + for (const keyword of ["allOf", "anyOf"]) { + if (equal(before[keyword], after[keyword])) continue; + const oldSchemas = before[keyword] ?? []; + const newSchemas = after[keyword] ?? []; + const common = Math.min(oldSchemas.length, newSchemas.length); + for (let index = 0; index < common; index += 1) { + compareSchema(oldSchemas[index], newSchemas[index], `${location}/${keyword}/${index}`, changes); + } + if (oldSchemas.length !== newSchemas.length) { + const additionBreaks = keyword === "allOf"; + const added = newSchemas.length > oldSchemas.length; + add(changes, added === additionBreaks ? "breaking" : "compatible", `${location}/${keyword}`, + `${keyword} schema count changed from ${oldSchemas.length} to ${newSchemas.length}`); + } + } + + if (!equal(before.oneOf, after.oneOf)) { + add(changes, "review_required", `${location}/oneOf`, "oneOf semantics changed"); + } + if (!equal(before.$defs, after.$defs)) { const oldDefs = before.$defs ?? {}; const newDefs = after.$defs ?? {}; @@ -210,4 +234,3 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) process.exitCode = 2; }); } - diff --git a/tests/compatibility.test.mjs b/tests/compatibility.test.mjs index 5640165..da6fde5 100644 --- a/tests/compatibility.test.mjs +++ b/tests/compatibility.test.mjs @@ -41,6 +41,7 @@ test("classifies required additions, removals, narrowing, and tightened constrai const cases = [ [{ ...objectSchema }, { ...objectSchema, required: ["id", "label"], properties: { ...objectSchema.properties, label: { type: "string" } } }], [objectSchema, { ...objectSchema, properties: {} }], + [{ allOf: [objectSchema] }, { allOf: [{ ...objectSchema, required: ["id", "label"], properties: { ...objectSchema.properties, label: { type: "string" } } }] }], [{ type: ["string", "null"] }, { type: "string" }], [{ enum: ["a", "b"] }, { enum: ["a"] }], [{ type: "string", maxLength: 10 }, { type: "string", maxLength: 5 }], @@ -56,3 +57,7 @@ test("classifies unsupported semantic keyword changes as review_required", async assert.match(report.changes[0].location, /oneOf/); }); +test("classifies property constraint removal from an open object as compatible", async () => { + const report = await classify({ type: "object", properties: { value: { type: "string" } } }, { type: "object", properties: {} }); + assert.equal(report.status, "compatible"); +}); From c2cadbe1b90075e00857c46a8fb8b1932ec45424 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kim=20Harjam=C3=A4ki?= Date: Thu, 11 Jun 2026 14:06:49 +0300 Subject: [PATCH 7/8] chore(02): normalize phase artifact endings --- .../02-compatibility-automation-and-distribution/02-01-PLAN.md | 1 - .../02-compatibility-automation-and-distribution/02-02-PLAN.md | 1 - .../02-02-SUMMARY.md | 1 - .../02-compatibility-automation-and-distribution/02-RESEARCH.md | 1 - scripts/build-registry.mjs | 1 - scripts/validate-registry.mjs | 1 - 6 files changed, 6 deletions(-) diff --git a/.planning/phases/02-compatibility-automation-and-distribution/02-01-PLAN.md b/.planning/phases/02-compatibility-automation-and-distribution/02-01-PLAN.md index eb266bd..93983fb 100644 --- a/.planning/phases/02-compatibility-automation-and-distribution/02-01-PLAN.md +++ b/.planning/phases/02-compatibility-automation-and-distribution/02-01-PLAN.md @@ -52,4 +52,3 @@ Implement conservative automated JSON Schema breaking-change classification and - `npm.cmd run validate` - Run the CLI against identical schema trees and a known breaking fixture. - diff --git a/.planning/phases/02-compatibility-automation-and-distribution/02-02-PLAN.md b/.planning/phases/02-compatibility-automation-and-distribution/02-02-PLAN.md index f1ae1c2..84b692b 100644 --- a/.planning/phases/02-compatibility-automation-and-distribution/02-02-PLAN.md +++ b/.planning/phases/02-compatibility-automation-and-distribution/02-02-PLAN.md @@ -53,4 +53,3 @@ Build and publish a deterministic versioned schema registry that serves released - `npm.cmd run build:registry -- --version 0.1.0` - Validate generated manifests and distributed schemas. - diff --git a/.planning/phases/02-compatibility-automation-and-distribution/02-02-SUMMARY.md b/.planning/phases/02-compatibility-automation-and-distribution/02-02-SUMMARY.md index 5db4196..7e79c39 100644 --- a/.planning/phases/02-compatibility-automation-and-distribution/02-02-SUMMARY.md +++ b/.planning/phases/02-compatibility-automation-and-distribution/02-02-SUMMARY.md @@ -53,4 +53,3 @@ Implemented deterministic schema registry packaging with stable and immutable UR Added a dedicated registry validator beyond the original plan so the exact multi-tag artifact is verified after assembly and before deployment. ## Self-Check: PASSED - diff --git a/.planning/phases/02-compatibility-automation-and-distribution/02-RESEARCH.md b/.planning/phases/02-compatibility-automation-and-distribution/02-RESEARCH.md index c6a64c3..3f8d27e 100644 --- a/.planning/phases/02-compatibility-automation-and-distribution/02-RESEARCH.md +++ b/.planning/phases/02-compatibility-automation-and-distribution/02-RESEARCH.md @@ -26,4 +26,3 @@ | Broken relative `$ref` values | Preserve each version directory layout and compile generated schemas in tests | | CI unable to compare shallow history | Checkout full history for compatibility job | | Pages custom domain not configured | Document DNS/Pages prerequisite and keep generated artifact independently testable | - diff --git a/scripts/build-registry.mjs b/scripts/build-registry.mjs index 7b810a4..77e3760 100644 --- a/scripts/build-registry.mjs +++ b/scripts/build-registry.mjs @@ -101,4 +101,3 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) process.exitCode = 1; }); } - diff --git a/scripts/validate-registry.mjs b/scripts/validate-registry.mjs index 1be6d1e..3e01025 100644 --- a/scripts/validate-registry.mjs +++ b/scripts/validate-registry.mjs @@ -49,4 +49,3 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) process.exitCode = 1; }); } - From 2b9d57e76d74e88d661d1fe9745d7a478ccc0e9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kim=20Harjam=C3=A4ki?= Date: Thu, 11 Jun 2026 14:08:08 +0300 Subject: [PATCH 8/8] docs(phase-2): complete compatibility and distribution phase --- .planning/PROJECT.md | 5 ++- .planning/REQUIREMENTS.md | 2 +- .planning/ROADMAP.md | 4 +- .planning/STATE.md | 21 ++++++---- .../02-VERIFICATION.md | 42 +++++++++++++++++++ 5 files changed, 62 insertions(+), 12 deletions(-) create mode 100644 .planning/phases/02-compatibility-automation-and-distribution/02-VERIFICATION.md diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md index 070689c..e8a8727 100644 --- a/.planning/PROJECT.md +++ b/.planning/PROJECT.md @@ -13,6 +13,7 @@ Every CAS component can exchange trustworthy lifecycle data without guessing str ### Validated - v0.1 lifecycle schemas, examples, tests, CI, and governance - Phase 1 +- Automated compatibility classification and stable versioned schema distribution - Phase 2 ### Active @@ -48,6 +49,8 @@ CAS currently has multiple repositories that need a shared language for prompts, | Use one common lifecycle metadata definition | Prevent traceability drift across contracts | Pending | | Keep contracts implementation-neutral | Allow all CAS runtimes to adopt them | Pending | | Publish examples as executable fixtures | Documentation and validation stay synchronized | Pending | +| Fail unknown schema semantics as review-required | Prevent false compatibility claims | Implemented in Phase 2 | +| Rebuild all release tags for registry publication | Preserve immutable release URLs while stable lines advance | Implemented in Phase 2 | ## Evolution @@ -58,4 +61,4 @@ This document evolves at phase transitions and milestone boundaries. 3. Reconfirm that contract interoperability remains the core value. --- -*Last updated: 2026-06-11 after Phase 1 verification* +*Last updated: 2026-06-11 after Phase 2 verification* diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index 7df715d..29b3ce4 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -56,4 +56,4 @@ **Coverage:** 12 requirements, 12 mapped, 0 unmapped. --- -*Last updated: 2026-06-11 after Phase 1 verification* +*Last updated: 2026-06-11 after Phase 2 verification* diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index c0e808b..1b3b8eb 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -18,7 +18,9 @@ **Requirements:** COMP-01, REG-01 -**Plans:** 2 plans +**Status:** Complete (2026-06-11) + +**Plans:** 2/2 plans complete **Wave 1** - [x] 02-01: Automated compatibility classification and PR enforcement diff --git a/.planning/STATE.md b/.planning/STATE.md index 3a30a26..90d5e92 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -2,14 +2,15 @@ gsd_state_version: 1.0 milestone: v0.1 milestone_name: Foundation -status: unknown -last_updated: "2026-06-11T10:59:47.777Z" +status: ready_to_plan +last_updated: 2026-06-11T11:07:16.086Z progress: total_phases: 3 - completed_phases: 0 + completed_phases: 2 total_plans: 2 - completed_plans: 0 - percent: 0 + completed_plans: 2 + percent: 67 +stopped_at: Phase 2 complete (2/2) - ready to discuss Phase 3 --- # Project State @@ -18,18 +19,20 @@ progress: See: `.planning/PROJECT.md` (updated 2026-06-11) -**Core value:** Every CAS component can exchange trustworthy lifecycle data without guessing structure, identity, traceability, or compatibility. -**Current focus:** Phase 2 - Compatibility Automation and Distribution (ready to execute) +**Core value:** Every CAS component can exchange trustworthy lifecycle data without guessing structure, identity, traceability, or compatibility. +**Current focus:** Phase 3 - Typed SDKs and Adoption ## Status -- Phase: 1 of 3 complete; Phase 2 implementation complete (2/2 plans complete), verification pending +- Phase: 2 of 3 complete; Phase 3 pending - Milestone: v0.1 - Mode: YOLO, quality, parallel -- Next action: Verify Phase 2 compatibility automation and registry distribution. +- Next action: Plan Phase 3 typed SDKs and adoption. ## Decisions - JSON Schema Draft 2020-12 is authoritative. - Shared lifecycle metadata is mandatory. - Examples are executable fixtures. +- Unknown schema semantic changes require review rather than a compatibility claim. +- Registry publication preserves immutable releases and advances stable version lines. diff --git a/.planning/phases/02-compatibility-automation-and-distribution/02-VERIFICATION.md b/.planning/phases/02-compatibility-automation-and-distribution/02-VERIFICATION.md new file mode 100644 index 0000000..de23e04 --- /dev/null +++ b/.planning/phases/02-compatibility-automation-and-distribution/02-VERIFICATION.md @@ -0,0 +1,42 @@ +--- +phase: 02-compatibility-automation-and-distribution +status: passed +score: 2/2 +verified: 2026-06-11 +requirements: [COMP-01, REG-01] +--- + +# Phase 2 Verification + +## Goal + +Make contracts safe and easy to consume across repositories through automated compatibility classification and stable versioned distribution. + +## Must-Have Verification + +| Requirement | Result | Evidence | +|-------------|--------|----------| +| COMP-01: CI automatically compares proposed schemas against the latest release for compatibility | Passed | `scripts/compatibility.mjs`, 5 compatibility tests, and `.github/workflows/compatibility.yml` compare pull requests with the latest semantic tag and fail breaking or review-required results | +| REG-01: Consumers can resolve published schemas from a stable registry endpoint | Passed | `scripts/build-registry.mjs`, `scripts/validate-registry.mjs`, 4 registry tests, `.github/workflows/publish-registry.yml`, and `docs/DISTRIBUTION.md` define stable and immutable release URLs | + +## Automated Checks + +- `npm.cmd test`: 13/13 passed +- `npm.cmd run validate`: 7 lifecycle examples validated +- `npm.cmd run build:registry -- --version 0.1.0`: 8 schemas packaged +- `npm.cmd run validate:registry -- --registry registry`: 1 release and 1 stable line validated +- Compatibility comparison against archived tag `v0.1.0`: `unchanged` +- Compatibility comparison against current tree: `unchanged` +- New workflow YAML validation: passed +- Schema drift gate: no drift +- `git diff --check`: passed after normalization + +## Review Notes + +- The classifier intentionally fails unknown semantic changes as `review_required`. +- Publication rebuilds all semantic tags so immutable release paths are retained. +- The GSD codebase-drift command was unavailable; this gate is non-blocking and direct diff review found no structural gaps. + +## Result + +Phase 2 goal and both mapped requirements are verified.