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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
Docs: updated … / Docs: none, because …

<!--
When AGENTS.md requires an independent review for this change, add one line:
Review: <reviewer>, session <id>, <verdict>. Leave the findings out.
When AGENTS.md requires an independent review for this change, add one line with the verdict and counts:
Review: <reviewer>, <verdict> (<rounds> rounds, <fixed> findings fixed). Leave the findings and session ids out.

Leave out agent attribution, such as a "Generated with" line.
Use a closing keyword (Closes #123) only for an Issue this change completes.
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ jobs:
cli_revision="$(node --input-type=module -e 'import { cliRevision } from "./script/cli-contract/config.mjs"; console.log(cliRevision)')"
git -C tmp/firstdraft-cli merge-base --is-ancestor "$cli_revision" HEAD
git -C tmp/firstdraft-cli checkout --detach "$cli_revision"
- run: sh script/check --cli-root tmp/firstdraft-cli
- run: sh script/check --cli-root tmp/firstdraft-cli ${{ steps.scope.outputs.digest_changed == 'true' && '--require-digest' || '' }}
if: steps.scope.outputs.catalog_only != 'true'
- run: node script/check-cli-contract.mjs tmp/firstdraft-cli
if: steps.scope.outputs.catalog_only != 'true'
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/pr-docs-line.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ name: Docs line
on:
pull_request:
types: [opened, edited, reopened, synchronize]
# A catalog selection changes no documented behavior.
paths-ignore: [.claude-plugin/marketplace.json]

permissions: {}

Expand Down
20 changes: 12 additions & 8 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,16 @@ and release notes in `CHANGELOG.md`.

## Checks and review

- Run `npm ci --ignore-scripts && npm audit && sh script/check` before committing. A change to packaged bytes needs
a new `plugin_source.tarball_sha256` in `release/compatibility.json`; the local check uses a stub CLI and skips
that digest, so [reproduce CI](RELEASING.md#reproduce-ci-locally) with the exact CLI.
- Get an independent review through [cross-review](https://github.com/raghubetina/cross-review) before merging a
contract change or an instruction-policy change. A contract change edits a `requires` entry in
- Run `npm ci --ignore-scripts && npm audit && sh script/check` before committing. Leave
`plugin_source.tarball_sha256` alone: the release records it before its tag
([RELEASING.md](RELEASING.md#2-update-the-pin-and-digest)).
- Get an independent review through [cross-review](https://github.com/raghubetina/cross-review) at the effort the
change's risk needs. Packaged Skill text that ships to users gets one round at `--effort high`. A contract change
or a change to scripts and workflows gets the default `max`. A contract change edits a `requires` entry in
`release/compatibility.json` or the bundled Plan schema in `skills/create-full-stack-app/references/`. An
instruction-policy change edits `AGENTS.md`, `CLAUDE.md`, `CONTRIBUTING.md`, `RELEASING.md`,
`.claude/settings.json`, or `.codex/config.toml`. Use the `codex-review` Skill from Claude Code and
instruction-policy change alone, to `AGENTS.md`, `CLAUDE.md`, `CONTRIBUTING.md`, `RELEASING.md`,
`.claude/settings.json`, or `.codex/config.toml`, gets one session at `--effort high`. Mirror text, version pins,
catalog selections, and digest records get none. Use the `codex-review` Skill from Claude Code and
`$claude-review` from Codex; neither is a shell command. [`CONTRIBUTING.md`](CONTRIBUTING.md#independent-review)
covers setup, the service's focus file, and what the pull request body records.

Expand All @@ -40,6 +42,8 @@ and release notes in `CHANGELOG.md`.
mutation.
- Never reuse a published npm version, protected tag, or marketplace version for different package bytes. Before
1.0, a breaking compatibility change takes a minor bump. The marketplace selects only an already-published
package, through a passing pull request; do not bypass the protected environment or CI.
package, after the catalog checks pass: the service's `script/release ship` commits that selection to `main`
under the release approval, and a selection by hand goes through a passing pull request. Do not bypass the
protected environment or CI.
- Keep one editable source per Skill under `skills/`, and update the packaging inventory when adding a Skill or
reference. Deferred UI Skills are not packaged.
8 changes: 5 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,11 @@ outside the sandbox; `sh script/check` runs inside it.
shows what changed.
- Update the documentation the change makes stale. The pull request body carries one line: `Docs: updated X` or
`Docs: none, because ...`. The [`Docs line`](.github/workflows/pr-docs-line.yml) check fails until the body has
one, and it skips pull requests that a bot such as Dependabot opens.
- When a review is required, add the line `Review: <reviewer>, session <id>, <verdict>` to the body, and leave the
findings out.
one. It skips pull requests that a bot such as Dependabot opens, and catalog selections that change only
`.claude-plugin/marketplace.json`.
- When a review is required, add one line with the reviewer, the verdict, and the counts, such as
`Review: Codex, approve (2 rounds, 3 findings fixed)`. Leave out session ids, which name files in a deleted
worktree, and the findings themselves; give a material finding one line in the commit message.
- Use GitHub closing keywords only for completed Issues: even `does not close #123` can close an Issue. Say the
remainder is tracked in open Issue `#123` instead.

Expand Down
36 changes: 24 additions & 12 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@ and each packaged Skill line that names the plugin version or its `claude-v` tag
repository's [`.npmrc`](.npmrc) stops npm from committing or creating a `v<x.y.z>` tag.

The version change does not touch the [catalog selection](.claude-plugin/marketplace.json), which moves only after
publication ([step 4](#4-select-the-published-version-in-the-catalog)). Until the tag is pushed, the candidate may
change at a new commit and digest without another version.
publication ([step 4](#4-select-the-published-version-in-the-catalog)), or the
[package digest](#2-update-the-pin-and-digest). Until the tag is pushed, the candidate may change at a new commit
without another version.

Write the CHANGELOG entry in the same change. Give it a dated heading and a bold lead that names the plugin version
as released:
Expand Down Expand Up @@ -67,25 +68,35 @@ pinned CLI. No CLI check fails when they change, so the next pin change fails he
A Plan format change also renames the versioned schema and reference under
`skills/create-full-stack-app/references/`. Search `script/` and `test/` for the old file names.

After the last packaged edit, pack with the exact CLI and copy the printed `sha256` into
`plugin_source.tarball_sha256`:
Content changes leave `plugin_source.tarball_sha256` in `release/compatibility.json` alone. The release records it
once, before the tag: the service repository's `script/release ship` packs the `main` commit it smoked with the exact
CLI under the Node that the [publish workflow](.github/workflows/publish.yml) pins. When the committed digest
differs, it commits the new digest on that commit as the only change, pushes it as a fast-forward of `main`, waits
for that commit's CI, and tags it. CI packs with the exact CLI and compares the committed digest only in a change
that edits it, so that commit's CI checks the digest the tag will publish.

By hand, after the last packaged edit lands, pack `main` with the exact CLI under that Node:

```sh
node script/claude-plugin-package.mjs pack tmp/plugin --cli-root /path/to/exact/cli
```

Commit only the printed `sha256` as `plugin_source.tarball_sha256` on that commit, push it to `main`, and tag it once
its CI passes.

## 3. Tag and publish

Publish the CLI first. The workflow compares the bundled CLI with the published CLI package. Then tag the approved
`main` commit:
`main` commit that records the digest:

```sh
git tag -a claude-v<x.y.z> <main-sha> -m "Release First Draft plugin <x.y.z>"
git push origin claude-v<x.y.z>
```

Pushing the tag is the irreversible step. The tag is protected, and npm never accepts a version twice. The publish
job waits for approval in the GitHub `npm` environment.
job waits for approval in the GitHub `npm` environment; `script/release ship` approves it under the release
approval.

**Retry after a CLI tarball 404.** The registry can answer E404 for a newly published CLI tarball for several
minutes. The `check-cli-registry-package.mjs` step then fails before `npm publish` runs. The
Expand All @@ -99,22 +110,23 @@ After success, read the registry with the

## 4. Select the published version in the catalog

Open a PR that sets both `version` and `source.version` in
[`.claude-plugin/marketplace.json`](.claude-plugin/marketplace.json) to the published version. CI treats a change to
only those fields as catalog-only. It validates the catalog metadata and checks that npm has the version, and it
Set both `version` and `source.version` in [`.claude-plugin/marketplace.json`](.claude-plugin/marketplace.json) to the
published version. `script/release ship` commits that change to `main` after `sh script/check` and
`node script/check-catalog.mjs` pass on it; by hand, open a pull request. CI treats a change to only those fields as
catalog-only. It validates the catalog metadata and checks that npm has the version, and it
skips the full suite, including the currency check. That check fails once the catalog selects the version and any
current page still calls it a candidate or unreleased. Run `sh script/check` on the catalog change before opening
the PR, or the next ordinary PR fails instead. Merging the PR changes the live catalog for Claude and Codex users.
It does not refresh existing installations.

## Reproduce CI locally

Without `--cli-root`, `sh script/check` packs the plugin with a stub CLI and skips the package digest. CI uses the
pinned CLI checkout:
Without `--cli-root`, `sh script/check` packs the plugin with a stub CLI. CI uses the pinned CLI checkout, and adds
`--require-digest` when the change edits the digest:

```sh
npm ci --ignore-scripts
sh script/check --cli-root /path/to/exact/cli
sh script/check --cli-root /path/to/exact/cli [--require-digest]
node script/check-cli-contract.mjs /path/to/exact/cli
```

Expand Down
6 changes: 5 additions & 1 deletion script/check-claude-plugin-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ const cliRoot =
cliRootIndex === -1
? undefined
: path.resolve(process.argv[cliRootIndex + 1] || "");
// Content changes leave plugin_source.tarball_sha256 alone; the release records it in a commit that changes only it.
const requireDigest = process.argv.includes("--require-digest");
assert(!requireDigest || cliRoot, "--require-digest needs --cli-root with the exact CLI");

assert.equal(packageTemplate.name, "@firstdraft.com/claude-code");
assert.equal(packageTemplate.version, compatibility.version);
Expand Down Expand Up @@ -89,7 +92,8 @@ try {
process.umask(previousUmask);
}
assert.equal(first.sha256, second.sha256, "plugin tarballs must be independent of the process umask");
if (cliRoot) {
if (cliRoot) process.stdout.write(`Packed plugin sha256 ${first.sha256}\n`);
if (requireDigest) {
assert.equal(
first.sha256,
compatibility.plugin_source.tarball_sha256,
Expand Down
13 changes: 13 additions & 0 deletions script/ci-scope.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { isDeepStrictEqual } from "node:util";

const repository = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const catalogPath = ".claude-plugin/marketplace.json";
const compatibilityPath = "release/compatibility.json";

export function isCatalogSelectionChange(paths, before, after) {
if (paths.length !== 1 || paths[0] !== catalogPath) return false;
Expand Down Expand Up @@ -34,6 +35,18 @@ export function catalogOnlyChange(base, root = repository) {
return isCatalogSelectionChange(paths, JSON.parse(previous.stdout), JSON.parse(readFileSync(path.join(root, catalogPath), "utf8")));
}

// The plugin digest is recorded once per release, in a commit that changes only it. CI compares its own pack with
// the committed digest when a change edits it, or when the base is unknown.
export function digestChange(base, root = repository) {
const git = (...args) => spawnSync("git", args, { cwd: root, encoding: "utf8" });
if (!base || git("cat-file", "-e", `${base}^{commit}`).status !== 0) return true;
const previous = git("show", `${base}:${compatibilityPath}`);
if (previous.status !== 0) return true;
const digest = (source) => JSON.parse(source).plugin_source?.tarball_sha256;
return digest(previous.stdout) !== digest(readFileSync(path.join(root, compatibilityPath), "utf8"));
}

if (process.argv[1] === fileURLToPath(import.meta.url)) {
process.stdout.write(`catalog_only=${catalogOnlyChange(process.argv[2])}\n`);
process.stdout.write(`digest_changed=${digestChange(process.argv[2])}\n`);
}
3 changes: 1 addition & 2 deletions script/sync-plugin-version.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -127,8 +127,7 @@ async function main(argv) {
"Left unchanged:",
" .claude-plugin/marketplace.json keeps the published selection until npm publishes this version.",
" script/cli-contract/config.mjs and requires in release/compatibility.json keep the bundled CLI pin.",
" plugin_source.tarball_sha256 in release/compatibility.json. After the last packaged edit, record the digest",
" that this prints: node script/claude-plugin-package.mjs pack tmp/plugin --cli-root /path/to/exact/cli",
" plugin_source.tarball_sha256 in release/compatibility.json, which the release records before its tag.",
"",
"Next, sh script/check lists any packaged Skill text that must change before this version is released.",
];
Expand Down
28 changes: 27 additions & 1 deletion test/catalog-ci.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
import path from "node:path";
import test from "node:test";

import { catalogOnlyChange, isCatalogSelectionChange } from "../script/ci-scope.mjs";
import { catalogOnlyChange, digestChange, isCatalogSelectionChange } from "../script/ci-scope.mjs";
import { assertPublishedCatalogSelection } from "../script/check-catalog.mjs";

const source = {
Expand Down Expand Up @@ -57,6 +57,32 @@ test("git scope falls back to full CI without a base or after another file chang
assert.equal(catalogOnlyChange(base, root), false);
});

test("CI compares the plugin digest only in a change that edits it", (t) => {
const root = mkdtempSync(path.join(tmpdir(), "firstdraft-digest-ci-"));
t.after(() => rmSync(root, { recursive: true, force: true }));
const git = (...args) => execFileSync("git", args, { cwd: root, encoding: "utf8" }).trim();
const compatibility = (digest) =>
`${JSON.stringify({ version: "0.8.1", plugin_source: { tarball_sha256: digest } }, null, 2)}\n`;
git("init", "--quiet");
git("config", "user.name", "Test");
git("config", "user.email", "[email protected]");
mkdirSync(path.join(root, "release"));
writeFileSync(path.join(root, "release/compatibility.json"), compatibility("a".repeat(64)));
writeFileSync(path.join(root, "SKILL.md"), "first\n");
git("add", ".");
git("commit", "--quiet", "-m", "Release");
const release = git("rev-parse", "HEAD");
writeFileSync(path.join(root, "SKILL.md"), "second\n");
git("commit", "--quiet", "-am", "Change packaged text");
const content = git("rev-parse", "HEAD");
assert.equal(digestChange(release, root), false);
writeFileSync(path.join(root, "release/compatibility.json"), compatibility("b".repeat(64)));
git("commit", "--quiet", "-am", "Record the digest");
assert.equal(digestChange(content, root), true);
assert.equal(digestChange("0".repeat(40), root), true);
assert.equal(digestChange(undefined, root), true);
});

test("catalog publication check rejects missing or mismatched registry identity", () => {
const published = {
name: source.package, version: source.version,
Expand Down
2 changes: 1 addition & 1 deletion test/repository.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -660,7 +660,7 @@ test("CI binds publication to protected identities and checks", async () => {
);
assert.match(
workflow,
/sh script\/check --cli-root tmp\/firstdraft-cli/,
/sh script\/check --cli-root tmp\/firstdraft-cli \$\{\{ steps\.scope\.outputs\.digest_changed == 'true' && '--require-digest' \|\| '' \}\}/,
);
assert.doesNotMatch(workflow, /node script\/check-claude-plugin-package/);
assert.match(repositoryCheck, /node script\/check-claude-plugin-package\.mjs "\$@"/);
Expand Down
Loading