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");