diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4bd5b94 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,7 @@ +# Agent Instructions — First Draft CLI + +For release details, follow `RELEASING.md`. + +- After merging to `main`, report the exact merged SHA and ask whether to coordinate and promote the three-repository + candidate. If the user defers, call the SHA unpromoted. Never publish npm, deploy First Draft, or release the + plugin without explicit approval. diff --git a/RELEASING.md b/RELEASING.md index 8844e5f..e832663 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -3,6 +3,32 @@ Publishing is a separate, explicit action after a release-preparation pull request has merged. npm registry bytes and package versions cannot be replaced, so do not create or push a release tag as a dry run. +## Coordinated candidate eligibility + +`release/compatibility.json` declares this package's SemVer version, the First Draft API-contract range it accepts, +and the exact Foundation Plan formats it accepts. It is source-only release metadata and is intentionally absent from +the npm tarball. The normal test suite validates the manifest's shape, keeps its version equal to `package.json`, and +binds its Foundation Plan format to the implemented CLI constant. + +The `script/release_compatibility_check` evaluator in `firstdraft/firstdraft` reads this declaration with the matching +declarations from exact, clean checkouts of `firstdraft/firstdraft` and `firstdraft/skills`. It implements SemVer 2.0 +precedence. The service API contract is currently the stable `0.1.0` contract line even though this CLI package is a +prerelease. Comparator arrays form one conjunction, while `foundation_plan_formats` lists alternatives. A +prerelease satisfies a comparator set only when a comparator explicitly names a prerelease with the same major, +minor, and patch numbers; Skills therefore names this CLI alpha explicitly. `firstdraft.release-compatibility/1` is +intentionally closed. The evaluator in `firstdraft/firstdraft` rejects an unrecognized format and unknown keys, so +adding a key requires a coordinated compatibility-format bump rather than silently changing version 1. + +A compatible result establishes candidate eligibility, not authorization or runtime proof. Exact Git SHAs identify +the three-repository candidate. A merge to `main` is integration only: report the merged SHA and ask the user whether +to coordinate the three repositories and promote that candidate. If promotion is declined, record the SHA as +unpromoted. + +Promotion is manual and approval-gated. One operator serializes mutations: qualify the exact candidate on staging, +obtain human approval, and only then promote the approved service revision to production or authorize the +corresponding npm and plugin releases. Do not publish npm, deploy either environment, or release the plugin merely +because the compatibility check passes. + ## Repository and registry setup Before the first release, a repository administrator must: @@ -78,10 +104,12 @@ Do not move or reuse that tag or version. The first organization-scoped candidat ## Prepare a release -1. Update `package.json` and `package-lock.json` to the exact release version. -2. Keep prereleases on the `next` dist-tag. Do not create `latest` until a stable release is intentionally approved. -3. Update user-facing documentation and release notes for behavior changes. -4. Run: +1. Update `package.json`, `package-lock.json`, and `release/compatibility.json` to the exact release version. +2. When that version changes, coordinate the matching explicit CLI comparator in `firstdraft/skills` before + qualification; an old alpha comparator intentionally makes the three-repository candidate ineligible. +3. Keep prereleases on the `next` dist-tag. Do not create `latest` until a stable release is intentionally approved. +4. Update user-facing documentation and release notes for behavior changes. +5. Run: ```sh npm ci --ignore-scripts @@ -89,7 +117,7 @@ Do not move or reuse that tag or version. The first organization-scoped candidat npm run check ``` -5. Merge the reviewed pull request only after local and hosted checks pass. +6. Merge the reviewed pull request only after local and hosted checks pass. ## Publish diff --git a/release/compatibility.json b/release/compatibility.json new file mode 100644 index 0000000..48cca0b --- /dev/null +++ b/release/compatibility.json @@ -0,0 +1,9 @@ +{ + "format": "firstdraft.release-compatibility/1", + "component": "cli", + "version": "0.1.0-alpha.2", + "requires": { + "api_contract": [">= 0.1.0", "< 0.2.0"], + "foundation_plan_formats": ["firstdraft.foundation-plan.sketch/0.19"] + } +} diff --git a/src/commands/plan-init.js b/src/commands/plan-init.js index 018c5c2..b4f01ae 100644 --- a/src/commands/plan-init.js +++ b/src/commands/plan-init.js @@ -1,6 +1,8 @@ import { mkdirSync, writeFileSync } from "node:fs"; import path from "node:path"; +import { FOUNDATION_PLAN_FORMAT } from "../compilation-artifact.js"; + /** * @typedef {object} FileSystem * @property {typeof mkdirSync} mkdirSync @@ -53,7 +55,7 @@ export function initializePlan({ /** @param {string} applicationKey @param {string} name */ function emptyPlan(applicationKey, name) { return { - format: "firstdraft.foundation-plan.sketch/0.19", + format: FOUNDATION_PLAN_FORMAT, target: { id: "rails", profile: "rails-sketch/2026-08", diff --git a/test/release-compatibility.test.js b/test/release-compatibility.test.js new file mode 100644 index 0000000..e52bfdf --- /dev/null +++ b/test/release-compatibility.test.js @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +import { FOUNDATION_PLAN_FORMAT } from "../src/compilation-artifact.js"; + +const packageMetadata = JSON.parse( + await readFile(new URL("../package.json", import.meta.url), "utf8"), +); +const compatibility = JSON.parse( + await readFile( + new URL("../release/compatibility.json", import.meta.url), + "utf8", + ), +); + +const semverPattern = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/; + +test("release compatibility declares the coordinated CLI contract", () => { + assertExactKeys(compatibility, [ + "component", + "format", + "requires", + "version", + ]); + assert.equal(compatibility.format, "firstdraft.release-compatibility/1"); + assert.equal(compatibility.component, "cli"); + assert.equal(typeof compatibility.version, "string"); + assert.match(compatibility.version, semverPattern); + assert.equal(compatibility.version, packageMetadata.version); + + assertExactKeys(compatibility.requires, [ + "api_contract", + "foundation_plan_formats", + ]); + assert.deepEqual(compatibility.requires.api_contract, [ + ">= 0.1.0", + "< 0.2.0", + ]); + assert.deepEqual(compatibility.requires.foundation_plan_formats, [ + FOUNDATION_PLAN_FORMAT, + ]); + + for (const requirement of compatibility.requires.api_contract) { + assert.equal(typeof requirement, "string"); + const match = /^(?:=|<|<=|>|>=)\s+(.+)$/.exec(requirement); + + assert.ok(match, `invalid SemVer comparator: ${requirement}`); + const requiredVersion = match[1]; + + assert.ok(requiredVersion !== undefined); + assert.match(requiredVersion, semverPattern); + } + + for (const format of compatibility.requires.foundation_plan_formats) { + assert.equal(typeof format, "string"); + } +}); + +/** + * @param {unknown} value + * @param {string[]} expectedKeys + */ +function assertExactKeys(value, expectedKeys) { + assert.ok( + value !== null && typeof value === "object" && !Array.isArray(value), + ); + assert.deepEqual(Object.keys(value).sort(), [...expectedKeys].sort()); +}