Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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.
38 changes: 33 additions & 5 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -78,18 +104,20 @@ 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
npm audit
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

Expand Down
9 changes: 9 additions & 0 deletions release/compatibility.json
Original file line number Diff line number Diff line change
@@ -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"]
}
}
4 changes: 3 additions & 1 deletion src/commands/plan-init.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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",
Expand Down
70 changes: 70 additions & 0 deletions test/release-compatibility.test.js
Original file line number Diff line number Diff line change
@@ -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());
}