From f2a2bb891f49e9bdce31dd95443ae4a4651b84fe Mon Sep 17 00:00:00 2001 From: Raghu Betina Date: Wed, 30 Sep 2026 09:57:45 -0500 Subject: [PATCH] Start a changelog and trim the release runbook Release policy lived in eight places across three repositories, and this runbook carried per-release prose that went stale after 0.8.0. The service repository owns that policy, so RELEASING.md keeps only the mechanics: npm version, the v tag, the publish workflow, and the publisher settings. The tag rulesets block only deletion and non-fast-forward moves, so the runbook states the no-move rule as policy rather than as something GitHub enforces. Dist-tag repair lives in the public skills repository. The local guide is a hand-edited gist copy of the service's local-app.md, and the 0.8.0 release skipped republishing it. The service repository owns that page and its publication, so this runbook drops the republish step. The links stay on the gist until the service serves the page at a public URL. docs/release-history.md stopped at 0.4.0. It is frozen as history, and CHANGELOG.md takes over, written in the pull request that sets each version. Its headings carry no release status, and the documentation tests reject one, so nothing edits the file after publication. The publish workflow refuses a tag whose version has no entry. Finding that only at tag time would burn the tagged version, so CI also checks any version that has no tag yet. The package shipped RELEASING.md and the release history to every user, including inside the Skills plugin. Both now stay in the repository. Packaged pages link them on GitHub, and the link tests follow those absolute links into the checkout. The Skills copy of the package file list drops the same two paths when Skills next bundles this CLI. --- .github/workflows/ci.yml | 12 +++ .github/workflows/publish.yml | 2 + AGENTS.md | 22 ++--- CHANGELOG.md | 11 +++ README.md | 34 +++---- RELEASING.md | 173 +++++++++++++++------------------- docs/README.md | 17 ++-- docs/commands.md | 16 ++-- docs/release-history.md | 9 +- package.json | 5 +- scripts/changelog.js | 88 +++++++++++++++++ scripts/check-changelog.js | 56 +++++++++++ scripts/check-pack.js | 2 - test/changelog.test.js | 138 +++++++++++++++++++++++++++ test/documentation.test.js | 119 ++++++++++++++++++----- test/package.test.js | 28 +++++- 16 files changed, 558 insertions(+), 174 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 scripts/changelog.js create mode 100644 scripts/check-changelog.js create mode 100644 test/changelog.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5012ea8..a371bc2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,3 +54,15 @@ jobs: - run: npm ci --ignore-scripts - run: npm audit - run: npm run check + # A missing entry found at tag time would cost the tagged version, so check it before the tag exists. + - name: Require a changelog entry for an untagged version + run: | + set -euo pipefail + version="$(node --print 'JSON.parse(require("node:fs").readFileSync("package.json", "utf8")).version')" + status=0 + git ls-remote --exit-code --tags origin "refs/tags/v$version" > /dev/null || status=$? + case "$status" in + 0) echo "v$version is tagged, so this check does not apply." ;; + 2) node scripts/check-changelog.js "$version" ;; + *) exit "$status" ;; + esac diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b429b61..56ae449 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -29,6 +29,8 @@ jobs: package-manager-cache: false - name: Verify tag and source commit run: bash scripts/check-release-source.sh + - name: Require a changelog entry for this version + run: node scripts/check-changelog.js "$GITHUB_REF_NAME" - name: Reuse successful CI for this source env: GH_TOKEN: ${{ github.token }} diff --git a/AGENTS.md b/AGENTS.md index 4aa5788..b0ddbd6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,21 +1,21 @@ # Agent Instructions — First Draft CLI Start with `docs/README.md` and follow its task routes. Detailed command semantics belong in `docs/commands.md`, -handled-error recovery in `docs/errors.md`, living release policy in `RELEASING.md`, and dated release observations -in `docs/release-history.md`. When behavior changes, update its owning document in the same change. +handled-error recovery in `docs/errors.md`, publication mechanics in `RELEASING.md`, and changes by version in +`CHANGELOG.md`. When behavior changes, update its owning document in the same change. - Verify with `npm run check`, which takes about 30 seconds. A fresh checkout needs `npm ci --ignore-scripts` first. -- To add a command, follow `docs/commands.md#add-a-command`. To change the version, follow the `npm version` step in - `RELEASING.md`. +- To add a command, follow `docs/commands.md#add-a-command`. To change the version, follow + `RELEASING.md#prepare-the-version-pull-request`, which includes the `CHANGELOG.md` entry. - Sibling repositories: `firstdraft/firstdraft` (private) owns the Service API and Plan format; `firstdraft/skills` owns the Skill and plugin packaging. - Changes to the accepted API-contract range, accepted Plan formats, command names or flags, handled `error` values, - or exit statuses get an independent review through cross-review: `codex-review` from Claude Code, `$claude-review` - from Codex. + exit statuses, `AGENTS.md`, `CLAUDE.md`, or `RELEASING.md` get an independent review through cross-review: + `codex-review` from Claude Code, `$claude-review` from Codex. Pass the service repository's `docs/review-focus.md` + as `--focus-file`, fetched with `gh api` when no sibling checkout exists. Put the reviewer, session id, and verdict + in the pull request body, and leave the findings out. - `firstdraft plan compile` defaults to local output in the current directory. GitHub publication requires `--github`; Codespaces is a fallback. Keep Skill callers and recovery instructions aligned with this boundary. -- A coordinated release needs explicit approval once. Reuse an existing approval for its named scope; do not ask - again between repository publication steps. A merge alone does not authorize a release. -- Publish approved versions directly to `latest`. Reuse successful CI for the exact source and relevant smoke - evidence. When changed behavior needs a smoke, use local compilation; Codespaces and Revyl are not release gates. - Preserve dated release observations as history. +- The service repository coordinates releases and owns their approval and smoke policy. One approved coordinated + release covers its named CLI steps; do not ask again between them. A merge alone does not authorize a release. + `RELEASING.md` covers the publication steps. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b0869c5 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,11 @@ +# First Draft CLI changelog + +This file records changes to the `@firstdraft.com/cli` package, newest first. Each entry starts with a +`## ` heading and says what changed and what callers need to do, if anything. + +An entry is written in the pull request that sets its version, so the newest entry can precede publication. Headings +carry no release status. The `v` tag and npm show whether a version is published. + +Entries start with the first version prepared after this file was added, and earlier versions have none. The frozen +[release history](docs/release-history.md) records releases through 0.4.0. The repository's +[tags](https://github.com/firstdraft/cli/tags) identify the source of each published version, including later ones. diff --git a/README.md b/README.md index 8ad498b..70b3b0b 100644 --- a/README.md +++ b/README.md @@ -33,21 +33,21 @@ repository owns the exact command and transport behavior between them. | Change the CLI | [Agent instructions](https://github.com/firstdraft/cli/blob/main/AGENTS.md), then [documentation map](docs/README.md) | | Find a command or output contract | [Command reference](docs/commands.md) | | Interpret an error or recover safely | [Errors and recovery](docs/errors.md) | -| Prepare or publish a package | [Release runbook](RELEASING.md) | -| Inspect dated package observations | [Release history](docs/release-history.md) | +| Prepare or publish a package | [Release runbook](https://github.com/firstdraft/cli/blob/main/RELEASING.md) | +| See what changed in a version | [Changelog](https://github.com/firstdraft/cli/blob/main/CHANGELOG.md) | | Report a vulnerability | [Security policy](SECURITY.md) | Run firstdraft --help or a command group's --help for concise terminal syntax. ## Repository layout -| Path | Responsibility | -| -------- | ------------------------------------------------------------- | -| bin/ | Published executable entrypoint | -| src/ | Commands, API client, local Plan state, and output contracts | -| test/ | Command, protocol, recovery, and package tests | -| scripts/ | Test runner, package checks, and version sync | -| docs/ | Command, error, release-history, and maintainer documentation | +| Path | Responsibility | +| -------- | --------------------------------------------------------------------------- | +| bin/ | Published executable entrypoint | +| src/ | Commands, API client, local Plan state, and output contracts | +| test/ | Command, protocol, recovery, and package tests | +| scripts/ | Test runner, package checks, and version sync | +| docs/ | Documentation map, command and error references, and frozen release history | ## Development @@ -88,8 +88,8 @@ The published CLI supports Node.js 22 or newer. Direct automation callers can in npm install --global @firstdraft.com/cli ``` -Pin an exact compatible version when a repeatable installation matters; [RELEASING.md](RELEASING.md) owns channel -and release meaning. +Pin an exact compatible version when a repeatable installation matters. The +[changelog](https://github.com/firstdraft/cli/blob/main/CHANGELOG.md) describes each version. The published package: @@ -100,11 +100,7 @@ The published package: - reads Bearer credentials only from the environment; and - carries npm provenance linking registry bytes to its GitHub workflow and commit. -Inspect the packed file list whenever a source or documentation path moves. The public documentation graph, -including the release runbook and dated release history, ships with the package. `AGENTS.md` and the source-only -`release/compatibility.json` do not. - -## Release boundary - -Merging source is not package publication. An approved coordinated release publishes directly to `latest`, reusing -successful CI for the exact source. [RELEASING.md](RELEASING.md) owns the short release and recovery procedure. +The package includes this README, the documentation map, the command and error references, and the security +policy. Repository files for maintainers stay out of it: `AGENTS.md`, the release runbook, the changelog, the +release history, and the source-only `release/compatibility.json`. Inspect the packed file list whenever a source or +documentation path moves. diff --git a/RELEASING.md b/RELEASING.md index c6cd182..c888de3 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,85 +1,64 @@ # Releasing First Draft CLI -An approved coordinated release publishes directly to npm's `latest` channel. Tests and review belong before merge; -publication reuses successful CI for the exact source. It does not repeat the suite or require a second -`next`-to-`latest` promotion. Dated observations remain in [release history](docs/release-history.md). - -A merge alone does not authorize publication. Obtain one approval for the intended coordinated release, or use the -approval already given for that scope. The existing GitHub `npm` environment protection still applies; its approval -executes the same release decision. Do not ask for another conversational approval between already-approved steps. - -## Version and compatibility policy - -Before `1.0.0`, use ordinary `0.MINOR.PATCH` versions: increase `MINOR` for a breaking compatibility-line change and -`PATCH` for a backward-compatible change within that line. Never reuse a published version or move a protected -release tag. An unpublished, untagged candidate can retain its proposed version while its source changes. -The published version may remain in source during documentation and test maintenance; recording its release history -does not require preparing another version. Choose an unused version when preparing the next publication. - -CLI `0.8.x` requires API `0.7.x`, Plan `firstdraft.foundation-plan.sketch/0.23`, and target -`rails-sketch/2026-09-bookmark-assets`. This breaking contract retires `application.pwa`; the Rails target retains -ordinary bookmark assets and metadata without a Plan switch. Earlier Plan formats and target profiles are not -accepted for artifact materialization. Existing applications and old Plans are not migrated. - -Align the Service and Skills companions before publishing a CLI version that changes this contract. Source -checks and packed-package smokes do not establish a published or deployed tuple. Staging continues to require -`FIRSTDRAFT_STAGING_API_TOKEN`, including existing pinned Projects. Production remains the default and uses -`FIRSTDRAFT_API_TOKEN`, as do custom origins. - -Local output is the default: `firstdraft plan compile` is equivalent to `firstdraft plan compile --output .`, -with GitHub publication selected by explicit `--github`. The root archive is `.firstdraft/design`. - -`release/compatibility.json` declares the package version, accepted API-contract range, and accepted Plan formats. -It is source-only metadata, validated by the normal test suite and absent from the npm tarball. Coordinate the -explicit CLI comparator and bundled CLI pin in `firstdraft/skills` when this version changes. The service's -`script/release_compatibility_check` compares the three exact revisions; compatibility establishes eligibility, -not authorization or runtime proof. Its closed `firstdraft.release-compatibility/1` format rejects unknown keys. - -## Prepare before merge - -1. Set the version with `npm version --no-git-tag-version --ignore-scripts=false`. npm updates `package.json` - and `package-lock.json`, and the package's `version` script copies the version into `release/compatibility.json`. - The last flag is required because `.npmrc` sets `ignore-scripts=true`, which also skips that script. If it was - omitted, run `node scripts/sync-version.js --apply`. Then align the Skills CLI requirement. -2. Update the command, error, and Skill guidance affected by the change. When onboarding changes, coordinate the - [local guide](https://gist.github.com/raghubetina/3d424a97a1eaa6de8c406e67f32a237e) publication from the Service's - `docs/guides/local-app.md` before the new CLI reaches `latest`. Preserve dated release evidence. -3. Run focused checks while developing and the repository's required CI for the merge candidate. For a fresh - checkout, the complete local check is `npm ci --ignore-scripts`, `npm audit`, then `npm run check`. -4. Review and merge the change. Wait for the existing `CI` workflow to pass for the selected `main` SHA; publication - uses that run instead of starting another one. - -Use existing smoke evidence when it covers the changed behavior. If changed CLI/Service/Skill behavior warrants a -live smoke, use a simple Plan, compile locally with `firstdraft plan compile --output .`, and boot the generated app locally -when runtime behavior changed. A CLI dispatch-only change can be covered by local command and packed-package tests. -Do not require Codespaces, GitHub Publication, native builds, or Revyl for a routine release. Codespaces is a fallback -development environment. Additional integration checks belong only to changes affecting those integrations. - -## Publish the approved source - -From a clean checkout of the selected `main` revision: - -1. Confirm the exact package version and `v` tag are both unused. If either identity is already - consumed, prepare the next version required by the pre-1.0 policy rather than moving or reusing it. -2. Confirm the intended three revisions are compatible and the coordinated release approval covers them. -3. Create and push `v` at that source revision. Push one release tag at a time; the workflow - serializes publication and GitHub retains at most one pending run in a concurrency group. -4. Approve the existing `npm` environment deployment for that tag. The workflow publishes with provenance under - `latest`; no separate dist-tag mutation is needed. - -The workflow requires a protected `v*` tag in `firstdraft/cli`, the matching `package.json` version, an unchanged -remote tag, and a commit in the first-parent history of protected `main`. It finds a successful `CI` push run for -that exact SHA using `gh run list`, checks the package file allowlist, then rechecks mutable refs after environment -approval. It does not install development dependencies, rerun tests or audit, or request interactive npm login. -Both source checks invoke `scripts/check-release-source.sh`; the postapproval invocation must remain before publish. - -If CI is still running, let that run finish and rerun the failed publication verification job. Resolve failing -checks in CI itself; publication does not start a duplicate suite. A source fix after tagging requires a new version. -Do not retest unrelated surfaces merely because time has passed since merge. +This page covers the mechanics of publishing `@firstdraft.com/cli` to npm. The service repository coordinates each +release of the Service, this CLI, and the Skills plugin, and it owns the approval and smoke policy. A merge alone +does not authorize publication. + +## Versions + +Before `1.0.0`, raise `MINOR` for a breaking change and `PATCH` for a backward-compatible one. npm never accepts a +published version twice, and a pushed `v` tag must not move. A fix after tagging therefore takes a new +version. + +`release/compatibility.json` declares the package version, the accepted API-contract range, and the accepted Plan +formats. The tests validate it, and it stays out of the npm package. `firstdraft/skills` pins an exact CLI revision +and version and keeps a copy of the package file list. Skills updates all three when it bundles a new CLI version. + +## Prepare the version pull request + +1. Set the version: + + ```sh + npm version --no-git-tag-version --ignore-scripts=false + ``` + + npm updates `package.json` and `package-lock.json`. The package's `version` lifecycle then copies the version + into `release/compatibility.json`. The last flag is required because `.npmrc` sets `ignore-scripts=true`. If it + was left off, run `node scripts/sync-version.js --apply`. + +2. Add the version's entry to [CHANGELOG.md](CHANGELOG.md): a `## ` heading, then what changed and what + callers need to do. `node scripts/check-changelog.js` confirms the entry. +3. Run `npm ci --ignore-scripts`, `npm audit`, and `npm run check`. CI also fails when the version has no tag and + no changelog entry. +4. Merge, then wait for the `CI` push run on `main` to pass. Publication reuses that run. + +## Publish + +1. Tag the merged commit and push the tag: + + ```sh + git tag v + git push origin v + ``` + + Push one release tag at a time. The workflow's concurrency group keeps one pending run and cancels an older one. + +2. The tag starts the [publish workflow](.github/workflows/publish.yml). Its `verify` job requires: + - a protected `v*` tag that names the `package.json` version and still points at the pushed commit; + - a commit in the first-parent history of `main`; + - a successful `CI` push run for that exact commit; + - an entry for the version in CHANGELOG.md; and + - the exact package file list. + +3. Approve the pending `npm` environment deployment in the workflow run. The `publish` job rechecks the tag and + `main`, then publishes to `latest` with provenance. It does not install development dependencies or rerun tests. + +If `verify` fails because CI was still running, let CI finish and rerun the failed job. Fix a failing check in CI +itself; publication never starts a second test run. ## Verify and recover -After publication, inspect the registry before retrying a failed workflow; the immutable version may already exist: +Check the registry before retrying a failed publication, because the version may already exist: ```sh FD_CLI_RELEASE_VERSION="$(node -p "require('./package.json').version")" @@ -88,33 +67,33 @@ npm view "@firstdraft.com/cli@$FD_CLI_RELEASE_VERSION" \ npm dist-tag ls '@firstdraft.com/cli' ``` -Confirm the intended version is `latest` and has integrity/provenance metadata. Install that exact version in a -temporary prefix, confirm `firstdraft --version`, and run `npm audit signatures` there to verify the published -artifact. This checks distribution; it does not repeat application qualification. Record the version, source, -package integrity, and any relevant smoke evidence in the dated release record. +Confirm that `latest` selects the version and that the version has integrity and provenance metadata. Install that +exact version into a temporary prefix, check `firstdraft --version`, and run `npm audit signatures` there. -If OIDC authentication fails, reconcile the registry version and protected tag before retrying. Correct a broken -trusted-publisher relationship when necessary, then rerun failed jobs at the existing tag. If the tagged workflow -identity itself is wrong, prepare a new version; never move the tag or add a persistent-token fallback. +If OIDC authentication fails, reconcile the registry version and the tag before retrying. Correct a broken +trusted-publisher relationship if needed, then rerun the failed jobs at the same tag. If the tagged workflow itself +is wrong, prepare a new version. Never move the tag or add a persistent npm token. -For a bad release, move `latest` to a known-good compatible version as an incident rollback, or deprecate the bad -version and publish a corrected higher version. Unpublishing is exceptional incident response, not routine rollback. +For a bad release, deprecate the version and publish a corrected higher one. Alternatively, move `latest` back to a +known-good version with the [dist-tag repair](https://github.com/firstdraft/skills/blob/main/docs/dist-tag-repair.md) +procedure. Unpublishing is exceptional incident response. ## Publisher configuration -These are durable repository and npm controls, not a per-release account audit. Verify them when provisioning, -changing publisher configuration, or diagnosing an actual failure: +These repository and npm settings are durable. Check them when provisioning, when changing publisher configuration, +or when diagnosing a failure, not on every release. -- `firstdraft/cli` is public; `main` requires pull requests and CI, and a `v*` ruleset restricts tag mutation. -- The `npm` GitHub environment is limited to release tags, requires its existing reviewer, disables administrator - bypass, and defines `NPM_RELEASE_ENABLED=true`. +- `firstdraft/cli` is public. `main` requires pull requests and the `CI` checks. +- The `v*` tag rulesets limit tag creation to organization administrators. They block deleting a tag and moving it + to a commit that does not descend from its current one. +- The `npm` GitHub environment accepts only `v*` tags, requires its reviewer, disallows administrator bypass, and + defines `NPM_RELEASE_ENABLED=true`. - npm trusted publishing identifies package `@firstdraft.com/cli`, repository `firstdraft/cli`, workflow - `publish.yml`, environment `npm`, and permission `createPackage`. The publishing account retains the intended - organization access and write-protecting 2FA. Configure these with an administrator only when needed. -- Publication runs on a GitHub-hosted runner with `id-token: write`, pinned Node.js 24.18.0 and npm 11.16.0. npm's + `publish.yml`, environment `npm`, and permission `createPackage`. The publishing account keeps its organization + access and write-protecting 2FA. Change these with an administrator only when needed. +- Publication runs on a GitHub-hosted runner with `id-token: write`, Node.js 24.18.0, and npm 11.16.0. npm's short-lived OIDC exchange is the only publication credential; no persistent npm token or Actions secret is used. The CI lookup uses GitHub's read-only workflow token. -Ordinary installation and use require no npm login. Ordinary trusted publication requires no local maintainer -login or per-release security-key ceremony. Request npm interaction only when npm requires it for a governance -change or an actual authentication failure. See [npm trusted publishing](https://docs.npmjs.com/trusted-publishers/). +Installing and using the package requires no npm login. Trusted publication requires no local maintainer login or +per-release security-key ceremony. See [npm trusted publishing](https://docs.npmjs.com/trusted-publishers/). diff --git a/docs/README.md b/docs/README.md index 87d58d0..5af3b25 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,8 +10,8 @@ evidence for implemented behavior; if they contradict a document, surface the co | Installation or package contract | [Root README](../README.md) | | Commands, Service API, or output | [Command reference](commands.md); [add a command](commands.md#add-a-command) | | Errors and recovery | [Errors and recovery](errors.md) | -| Versioning and publication | [Release policy and runbook](../RELEASING.md) | -| Dated release observations | [Release history](release-history.md) | +| Versioning and publication | [Release runbook](https://github.com/firstdraft/cli/blob/main/RELEASING.md) | +| Changes by version | [Changelog](https://github.com/firstdraft/cli/blob/main/CHANGELOG.md) | | Vulnerability reporting | [Security policy](../SECURITY.md) | ## Authority boundaries @@ -20,9 +20,8 @@ evidence for implemented behavior; if they contradict a document, surface the co - [commands.md](commands.md) owns detailed command semantics, Service endpoints, and the add-a-command checklist. Built-in `--help`, runtime source, and tests own exact executable syntax and behavior. - [errors.md](errors.md) owns handled-error interpretation and recovery guidance. -- [RELEASING.md](../RELEASING.md) owns living release policy and the operator runbook. -- [release-history.md](release-history.md) preserves dated release observations. Recheck live tags, package versions, - and dist-tags before relying on them operationally; publisher configuration is checked when it changes or fails. +- `RELEASING.md` owns publication mechanics. The service repository owns release policy. +- `CHANGELOG.md` gets each new version's entry in the pull request that sets the version. - The source repository's `AGENTS.md` routes agent work; it should stay compact rather than duplicate these documents. Its `CLAUDE.md` only imports `AGENTS.md`, so Claude Code and Codex read the same instructions. @@ -32,9 +31,11 @@ Start here, then load the one owning document for the task. Follow a cross-link authority boundary. Create another page only for a distinct audience, task, or authority. The documentation tests cap `AGENTS.md` at 2 KiB, the root README at 6 KiB, and this map at 4 KiB. They also -require every public topic to be reachable from this map or the root README, and they check local links and -fragments. Outside release history, they reject retired version identities and release-status labels on the current -version. The package check verifies that relative links in packaged Markdown resolve inside the package. +require every public topic to be reachable from this map or the root README. They check relative links, links to +this repository's `main` on GitHub, and their fragments. Outside the changelog and release history, they reject +retired version identities and release-status labels on the current version. They also reject status labels in +changelog headings. The package check verifies that relative links in packaged Markdown resolve inside the package; +packaged pages link other files on GitHub. ## Work on the repository diff --git a/docs/commands.md b/docs/commands.md index 3e544fc..172b039 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -6,8 +6,8 @@ group's `--help` for concise executable syntax. See [Errors and recovery](errors The current `0.8.x` source line contains the auditable command shell, local Foundation Plan initialization, local application-key and UUID generation, conditional whole-document push, whole-graph analysis status polling, direct Compile-and-materialize and private publish orchestration, and retained-Compilation inspection. CLI `0.8.x` -requires the service's `0.7.x` API contract. See the [release policy](../RELEASING.md) for versioning and channel -semantics and [release history](release-history.md) for the transition from prereleases. +requires the service's `0.7.x` API contract. The +[changelog](https://github.com/firstdraft/cli/blob/main/CHANGELOG.md) describes each version. ## Command map @@ -486,8 +486,10 @@ Use this checklist when a change adds a command or subcommand. Each step names t `firstdraft/skills` with its CLI pin. 6. **Review it.** New command names, flags, `error` values, and exit statuses need the independent review named in `AGENTS.md`. -7. **Version it.** Choose the next version with the [version policy](../RELEASING.md#version-and-compatibility-policy) - and apply it with the [version step](../RELEASING.md#prepare-before-merge). If the command needs a route or - response that the accepted API range lacks, the Service ships it first under a new API-contract version. Then - raise `requires.api_contract` in `release/compatibility.json`, and align the Skills CLI requirement. Because the - CLI does not read the contract header, an older Service rejects the new route as not found. +7. **Version it.** Choose the next version with the + [version rule](https://github.com/firstdraft/cli/blob/main/RELEASING.md#versions), and apply it with the + [version pull request](https://github.com/firstdraft/cli/blob/main/RELEASING.md#prepare-the-version-pull-request) + steps. If the command needs a route or response that the accepted API range lacks, the Service ships it first + under a new API-contract version. Then raise `requires.api_contract` in `release/compatibility.json`, and align + the Skills CLI requirement. Because the CLI does not read the contract header, an older Service rejects the new + route as not found. diff --git a/docs/release-history.md b/docs/release-history.md index eb4ade0..2217dac 100644 --- a/docs/release-history.md +++ b/docs/release-history.md @@ -1,9 +1,10 @@ # First Draft CLI release history -This page preserves dated release and registry observations. It is historical evidence, not a statement of current -npm, GitHub, service, or qualification state. Before acting, recheck the registry, protected tags, exact source SHA, -compatibility declarations, trusted-publisher relationship, and named release-specific qualification by following -the living [release policy and runbook](../RELEASING.md). +**Status:** Historical, frozen 2026-09-30. + +This page preserves dated release and registry observations from the alpha publications through CLI 0.4.0. It is no +longer updated and does not describe current npm, GitHub, service, or qualification state. Versions prepared after +it was frozen have entries in [CHANGELOG.md](../CHANGELOG.md). [RELEASING.md](../RELEASING.md) describes publication. ## 0.4.0 local-output release diff --git a/package.json b/package.json index 7820649..8e1f04e 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,10 @@ }, "files": [ "bin", - "docs", + "docs/README.md", + "docs/commands.md", + "docs/errors.md", "src", - "RELEASING.md", "SECURITY.md" ], "engines": { diff --git a/scripts/changelog.js b/scripts/changelog.js new file mode 100644 index 0000000..bbf6dee --- /dev/null +++ b/scripts/changelog.js @@ -0,0 +1,88 @@ +const fencePattern = /^ {0,3}(`{3,}|~{3,})(.*)$/; +const sectionHeadingPattern = /^ {0,3}#{1,2}(?:[ \t]|$)/; +const entryHeadingPattern = /^ {0,3}##[ \t]+(.*)$/; + +/** + * Explains why CHANGELOG.md lacks a usable entry for version, or returns + * undefined when it has exactly one. An entry is a `## ` heading + * followed by text; the version may be followed by a space or colon and a + * title, as in `## 1.4.0: Local output`. + * + * @param {string} source + * @param {string} version + * @returns {string | undefined} + */ +export function changelogProblem(source, version) { + const entries = changelogEntries(source, version); + + if (entries.length === 0) { + return ( + `CHANGELOG.md has no entry for ${version}. Add a "## ${version}" heading ` + + "and the changes under it in the pull request that sets the version." + ); + } + if (entries.length > 1) { + return `CHANGELOG.md has ${entries.length} entries for ${version}. Keep one.`; + } + if (entries[0]?.trim() === "") { + return `CHANGELOG.md's ${version} entry has no text. Describe the changes under its heading.`; + } + return undefined; +} + +/** + * @param {string} source + * @param {string} version + * @returns {string[]} the body of each entry for version + */ +export function changelogEntries(source, version) { + /** @type {string[][]} */ + const entries = []; + /** @type {string[] | undefined} */ + let body; + /** @type {string | undefined} */ + let fence; + + for (const line of source.split(/\r?\n/)) { + const [, marker, rest = ""] = fencePattern.exec(line) ?? []; + if (marker !== undefined && fence === undefined) { + fence = marker; + } else if (marker !== undefined && closes(marker, rest, fence)) { + fence = undefined; + } else if (fence === undefined && sectionHeadingPattern.test(line)) { + const title = entryHeadingPattern.exec(line)?.[1]; + body = + title !== undefined && namesVersion(title, version) ? [] : undefined; + if (body) entries.push(body); + continue; + } + + body?.push(line); + } + + return entries.map((lines) => lines.join("\n")); +} + +/** + * A fence closes on a bare run of its own character at least as long as the run that opened it. + * + * @param {string} marker + * @param {string} rest + * @param {string | undefined} fence + */ +function closes(marker, rest, fence) { + return ( + fence !== undefined && + marker[0] === fence[0] && + marker.length >= fence.length && + rest.trim() === "" + ); +} + +/** @param {string} title @param {string} version */ +function namesVersion(title, version) { + return ( + title.startsWith(version) && + /^(?:$|[\s:])/.test(title.slice(version.length)) + ); +} diff --git a/scripts/check-changelog.js b/scripts/check-changelog.js new file mode 100644 index 0000000..1597ecf --- /dev/null +++ b/scripts/check-changelog.js @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { parseArgs } from "node:util"; +import { fileURLToPath } from "node:url"; + +import { changelogProblem } from "./changelog.js"; + +const USAGE = `Usage: node scripts/check-changelog.js [] + +Checks that CHANGELOG.md has exactly one entry for : a +"## " heading followed by text. defaults to the +version in package.json. A leading "v", as in the tag v1.4.0, is removed. + +The publish workflow runs this for the pushed tag, and CI runs it for a +version that has no tag yet. It only reads files, and it exits 1 when the +entry is missing, empty, or repeated. + +Options: + -h, --help Show this help +`; + +const { values, positionals } = parseArgs({ + options: { + help: { type: "boolean", short: "h", default: false }, + }, + allowPositionals: true, + strict: true, +}); + +if (values.help) { + process.stdout.write(USAGE); +} else { + assert(positionals.length <= 1, `Expected at most one version.\n\n${USAGE}`); + const repository = path.dirname(path.dirname(fileURLToPath(import.meta.url))); + const version = ( + positionals[0] ?? + JSON.parse(readFileSync(path.join(repository, "package.json"), "utf8")) + .version + ).replace(/^v/, ""); + assert.match( + version, + /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/, + `Expected a version such as 1.4.0 or a tag such as v1.4.0, not ${version}`, + ); + const changelog = path.join(repository, "CHANGELOG.md"); + + process.stdout.write(`Checking ${changelog} for version ${version}\n`); + const problem = changelogProblem(readFileSync(changelog, "utf8"), version); + if (problem) { + process.stderr.write(`${problem}\n`); + process.exitCode = 1; + } else { + process.stdout.write(`Found the ${version} entry.\n`); + } +} diff --git a/scripts/check-pack.js b/scripts/check-pack.js index af73c72..bed80a8 100644 --- a/scripts/check-pack.js +++ b/scripts/check-pack.js @@ -30,13 +30,11 @@ if (result.status !== 0) { assert.deepEqual(paths, [ "LICENSE", "README.md", - "RELEASING.md", "SECURITY.md", "bin/firstdraft.js", "docs/README.md", "docs/commands.md", "docs/errors.md", - "docs/release-history.md", "package.json", "src/api-authentication.js", "src/api-response.js", diff --git a/test/changelog.test.js b/test/changelog.test.js new file mode 100644 index 0000000..6a82c49 --- /dev/null +++ b/test/changelog.test.js @@ -0,0 +1,138 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { changelogEntries, changelogProblem } from "../scripts/changelog.js"; + +const script = fileURLToPath( + new URL("../scripts/check-changelog.js", import.meta.url), +); + +/** @param {string[]} lines */ +const changelog = (...lines) => + ["# Changelog", "", "Newest first.", "", ...lines, ""].join("\n"); + +test("a version heading followed by text is the version's entry", () => { + for (const heading of [ + "## 1.4.0", + "## 1.4.0: Local output", + "## 1.4.0 (breaking)", + "## 1.4.0 ##", + " ## 1.4.0", + ]) { + const source = changelog(heading, "", "Adds `--output`.", "", "## 1.3.2"); + assert.equal(changelogProblem(source, "1.4.0"), undefined, heading); + assert.deepEqual( + changelogEntries(source, "1.4.0"), + ["\nAdds `--output`.\n"], + heading, + ); + } + assert.equal( + changelogProblem(changelog("## 1.4.0\r", "Adds `--output`.\r"), "1.4.0"), + undefined, + "CRLF line endings", + ); + assert.equal( + changelogProblem( + changelog("## 1.4.0", "", "```sh", "firstdraft plan compile", "```"), + "1.4.0", + ), + undefined, + "an entry may hold only a code block", + ); + assert.equal( + changelogProblem( + changelog("````md", "```", "````", "", "## 1.4.0", "Adds `--output`."), + "1.4.0", + ), + undefined, + "a shorter fence inside a closed block leaves later entries visible", + ); +}); + +test("other headings and versions are not the version's entry", () => { + for (const heading of [ + "### 1.4.0", + "# 1.4.0", + "## v1.4.0", + "## [1.4.0]", + "## 1.4.0-rc.1", + "## 1.4.01", + "## 1.4.0.1", + "## 11.4.0", + "## Release 1.4.0", + "##1.4.0", + " ## 1.4.0", + ]) { + const source = changelog(heading, "", "Adds `--output`."); + assert.deepEqual(changelogEntries(source, "1.4.0"), [], heading); + assert.match( + changelogProblem(source, "1.4.0") ?? "", + /has no entry for 1\.4\.0\. Add a "## 1\.4\.0" heading/, + heading, + ); + } + + for (const fence of [ + ["```md", "## 1.4.0", "Adds `--output`.", "```"], + ["~~~", "## 1.4.0", "Adds `--output`.", "~~~"], + ["````md", "```", "## 1.4.0", "Adds `--output`.", "````"], + ["```", "``` not a close", "## 1.4.0", "Adds `--output`.", "```"], + ["```", "~~~", "## 1.4.0", "Adds `--output`.", "```"], + ]) { + const source = changelog(...fence); + assert.deepEqual(changelogEntries(source, "1.4.0"), [], fence.join("|")); + } +}); + +test("an entry ends at the next section and must hold text", () => { + for (const source of [ + changelog("## 1.4.0", "", "## 1.3.2", "", "Fixes recovery."), + changelog("## 1.4.0", "", "# Archive", "", "Older notes."), + changelog("## 1.4.0", "", " "), + ]) { + assert.match( + changelogProblem(source, "1.4.0") ?? "", + /1\.4\.0 entry has no text/, + ); + } + + const nested = changelog("## 1.4.0", "", "### Added", "", "- `--output`"); + assert.deepEqual(changelogEntries(nested, "1.4.0"), [ + "\n### Added\n\n- `--output`\n", + ]); +}); + +test("a version with two entries is rejected", () => { + const source = changelog("## 1.4.0", "One.", "", "## 1.4.0", "Two."); + assert.equal( + changelogProblem(source, "1.4.0"), + "CHANGELOG.md has 2 entries for 1.4.0. Keep one.", + ); +}); + +test("the check reads the repository changelog and accepts a tag", () => { + const help = spawnSync(process.execPath, [script, "--help"], { + encoding: "utf8", + }); + assert.equal(help.status, 0, help.stderr); + assert.match(help.stdout, /^Usage: node scripts\/check-changelog\.js/); + + const missing = spawnSync(process.execPath, [script, "v0.0.0"], { + encoding: "utf8", + }); + assert.equal(missing.status, 1, missing.stderr); + assert.match( + missing.stdout, + /^Checking .*CHANGELOG\.md for version 0\.0\.0\n/, + ); + assert.match(missing.stderr, /^CHANGELOG\.md has no entry for 0\.0\.0\./); + + const invalid = spawnSync(process.execPath, [script, "latest"], { + encoding: "utf8", + }); + assert.notEqual(invalid.status, 0); + assert.match(invalid.stderr, /Expected a version such as 1\.4\.0/); +}); diff --git a/test/documentation.test.js b/test/documentation.test.js index 86e1512..7d52ba3 100644 --- a/test/documentation.test.js +++ b/test/documentation.test.js @@ -14,9 +14,13 @@ import { VERSION } from "../src/version.js"; const repository = fileURLToPath(new URL("..", import.meta.url)); const markdownFiles = [ - ...["AGENTS.md", "README.md", "RELEASING.md", "SECURITY.md"].map((file) => - path.join(repository, file), - ), + ...[ + "AGENTS.md", + "CHANGELOG.md", + "README.md", + "RELEASING.md", + "SECURITY.md", + ].map((file) => path.join(repository, file)), ...findMarkdownFiles(path.join(repository, "docs")), ]; @@ -84,15 +88,8 @@ test("documentation entrypoints stay lean and route every public topic", () => { const source = sources.get(sourceFile); assert(source); for (const target of markdownLinkTargets(source)) { - if (isExternalTarget(target)) continue; - - const [rawPath] = target.split("#", 1); - if (rawPath === undefined || rawPath === "") continue; - const targetFile = path.resolve( - path.dirname(sourceFile), - decodeURIComponent(rawPath), - ); - if (publicTopics.has(targetFile)) pending.push(targetFile); + const targetFile = repositoryLink(sourceFile, target)?.file; + if (targetFile && publicTopics.has(targetFile)) pending.push(targetFile); } } @@ -105,15 +102,12 @@ test("documentation entrypoints stay lean and route every public topic", () => { } }); -test("local documentation links and fragments resolve", () => { +test("links to repository files and fragments resolve", () => { for (const [sourceFile, source] of sources) { for (const target of markdownLinkTargets(source)) { - if (isExternalTarget(target)) continue; - - const [rawPath, rawFragment] = target.split("#", 2); - const targetFile = rawPath - ? path.resolve(path.dirname(sourceFile), decodeURIComponent(rawPath)) - : sourceFile; + const link = repositoryLink(sourceFile, target); + if (link === undefined) continue; + const { file: targetFile, fragment: rawFragment } = link; assert.equal( existsSync(targetFile) && statSync(targetFile).isFile(), @@ -146,7 +140,12 @@ test("living documentation names only current version identities", () => { for (const [file, source] of sources) { const name = path.relative(repository, file); - if (name === path.join("docs", "release-history.md")) continue; + if ( + name === "CHANGELOG.md" || + name === path.join("docs", "release-history.md") + ) { + continue; + } findings.push(...staleVersionFindings(name, source, identities)); @@ -207,6 +206,33 @@ test("the version lint catches unlabeled, prerelease, and unreleased CLI version } }); +test("changelog headings carry no release status", () => { + const changelog = sources.get(path.join(repository, "CHANGELOG.md")); + assert(changelog); + assert.deepEqual(changelogStatusFindings(changelog), []); + + for (const heading of [ + "## 0.9.0 (unreleased)", + "## 0.9.0: Released 2026-10-01", + "## 0.9.0 release candidate", + "## 0.9.0 (not yet published)", + ]) { + assert.equal( + changelogStatusFindings(`${heading}\n\nAdds a flag.\n`).length, + 1, + heading, + ); + } + for (const source of [ + "## 0.9.0\n\nAdds a flag.\n", + "## 0.9.0: Local output\n\nAdds a flag.\n", + "Whether a version is published shows in npm.\n", + "```md\n## 0.9.0 (unreleased)\n```\n", + ]) { + assert.deepEqual(changelogStatusFindings(source), [], source); + } +}); + const identityLabels = { cli: "CLI ", api: "API ", @@ -219,8 +245,13 @@ const versionToken = /(? /^ {0,3}##[ \t]/.test(line)) + .flatMap((line) => { + const heading = line.trim(); + const status = headingReleaseStatus.exec(heading)?.[0]; + return status === undefined + ? [] + : [ + `CHANGELOG.md heading "${heading}" says "${status}". The v tag and npm show whether a ` + + "version is published, so remove the label.", + ]; + }); +} + /** @param {Version} left @param {Version} right */ function sameLine(left, right) { return left.major === right.major && left.minor === right.minor; @@ -429,6 +477,33 @@ function sentenceAround(source, index) { return source.slice(start, end); } +const repositoryBlob = "https://github.com/firstdraft/cli/blob/main/"; + +/** + * Resolves a relative link, or an absolute link to this repository's main branch, to a local file. Packaged + * Markdown links unpackaged repository files by absolute URL, so both forms need checking. + * + * @param {string} sourceFile + * @param {string} target + * @returns {{file: string, fragment: string | undefined} | undefined} + */ +function repositoryLink(sourceFile, target) { + const absolute = target.startsWith(repositoryBlob); + if (!absolute && isExternalTarget(target)) return undefined; + + const [rawPath, fragment] = ( + absolute ? target.slice(repositoryBlob.length) : target + ).split("#", 2); + if (rawPath === undefined || rawPath === "") { + return absolute ? undefined : { file: sourceFile, fragment }; + } + + const file = absolute + ? path.join(repository, decodeURIComponent(rawPath)) + : path.resolve(path.dirname(sourceFile), decodeURIComponent(rawPath)); + return { file, fragment }; +} + /** @param {string} directory @returns {string[]} */ function findMarkdownFiles(directory) { return readdirSync(directory, { withFileTypes: true }) diff --git a/test/package.test.js b/test/package.test.js index 872ac99..8efaa32 100644 --- a/test/package.test.js +++ b/test/package.test.js @@ -12,6 +12,10 @@ const publishWorkflow = await readFile( new URL("../.github/workflows/publish.yml", import.meta.url), "utf8", ); +const ciWorkflow = await readFile( + new URL("../.github/workflows/ci.yml", import.meta.url), + "utf8", +); const releaseSourceScript = await readFile( new URL("../scripts/check-release-source.sh", import.meta.url), "utf8", @@ -24,9 +28,10 @@ test("package metadata preserves the audited runtime boundary", () => { assert.deepEqual(metadata.bin, { firstdraft: "bin/firstdraft.js" }); assert.deepEqual(metadata.files, [ "bin", - "docs", + "docs/README.md", + "docs/commands.md", + "docs/errors.md", "src", - "RELEASING.md", "SECURITY.md", ]); assert.equal(metadata.scripts.test, "node scripts/run-tests.js"); @@ -79,6 +84,25 @@ test("publication reuses successful exact-source CI instead of rerunning the sui ); }); +test("CI and publication require a changelog entry for the version", () => { + const verifyJob = workflowJob(publishWorkflow, "verify"); + const tagCheck = 'node scripts/check-changelog.js "$GITHUB_REF_NAME"'; + assert.ok( + verifyJob.includes(tagCheck), + "verify must check the tag's changelog entry", + ); + assert.ok( + verifyJob.indexOf("bash scripts/check-release-source.sh") < + verifyJob.indexOf(tagCheck), + "the tag must match package.json before its changelog entry is checked", + ); + assert.match( + workflowJob(ciWorkflow, "quality"), + /node scripts\/check-changelog\.js "\$version"/, + "CI must check the entry before the version is tagged", + ); +}); + test("OIDC publication rechecks source after approval and before publishing", () => { const verifyJobStart = publishWorkflow.indexOf("\n verify:\n"); const publishJobStart = publishWorkflow.indexOf("\n publish:\n");