Skip to content

Commit aee77ad

Browse files
committed
chore(release): add release helper command
Why Release prep currently depends on a manual checklist for package metadata, generated bundles, release note preview, commit creation, and tag creation. A repeatable command reduces the chance of cutting a tag before the package version and dist bundle are ready. User impact Maintainers can run one command to prepare the version bump and tag, then opt into pushing when ready to trigger the release workflow. Verification node --check scripts/release.mjs node scripts/release.mjs --help node scripts/release.mjs 1.0.6 --pus node scripts/release.mjs 1.0.6 extra npm run release -- 1.0.6 --dry-run --allow-non-main --allow-dirty --skip-checks npm -w packages/cli run build npm -w packages/cli run typecheck npm -w packages/cli run lint npm -w packages/cli test Release note: skip
1 parent d94c6ea commit aee77ad

6 files changed

Lines changed: 243 additions & 8 deletions

File tree

AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,15 +145,15 @@ Each `UserPromptSubmit` increments a turn counter. File changes inherit the curr
145145
- **Keep release notes human-sized.** A multi-commit PR should normally produce one clear release bullet per user-visible change, not one bullet per review fix. Put the public wording on the primary implementation commit with `Release note: <sentence>`, and add `Release note: skip` to follow-up commits such as `address review findings`, `tighten fallback`, `bound window`, or generated bundle syncs unless they describe a distinct user-visible outcome.
146146
- **Write release notes as natural English sentences.** The generator capitalizes the first character as a safety net, but do not rely on that to fix awkward wording. Prefer `Release note: Codex commits made from cmux sessions are now recorded reliably.` over `Release note: recover codex env sessions`.
147147
- **PR titles should be release-summary quality.** Write PR titles as a user-facing outcome, not an implementation step. Even though GitHub Releases are generated from commits, a good PR title is the easiest review-time signal that the eventual release note will be understandable.
148-
- **Preview release notes before merging release-sensitive PRs.** If `git-cliff --config .github/cliff.toml --latest --strip header` reads like an implementation log, rewrite commit subjects/bodies before merge or tag. Do not leave low-level cleanup commits visible just because tests pass.
148+
- **Preview release notes before merging release-sensitive PRs.** If `git-cliff --config .github/cliff.toml --latest --strip header` reads like an implementation log, rewrite commit subjects/bodies before merge. Before tagging, use the release command below so the preview targets the next version.
149149
- **Do not rely on merge commits for release copy.** Release notes include merged PR links from `Merge pull request...` commits, but the user-facing bullets still come from implementation commits and `Release note:` lines. Version bumps and generated bundle sync commits stay hidden.
150150
- **Structural vs behavioral changes** must not be mixed in a single commit. Renames/reformats separate from feature/fix commits.
151151
- **Before committing**, all four checks must pass (run from `packages/cli/`):
152152
1. `npm run build` — esbuild bundle
153153
2. `npm run typecheck` — tsc --noEmit
154154
3. `npm run lint` — biome check
155155
4. `npm test` — node:test (requires build first)
156-
- **Version bumps** go in a dedicated `chore: bump version to x.y.z` commit. Tag `vx.y.z` triggers the release workflow (test → GitHub Release → npm publish).
156+
- **Version bumps** go in a dedicated `chore: bump version to x.y.z` commit. Prefer `npm run release -- x.y.z` to prepare the bump commit and tag; add `--push` only when ready to trigger the release workflow (test → GitHub Release → npm publish).
157157

158158
## Constraints
159159

CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,15 +145,15 @@ Each `UserPromptSubmit` increments a turn counter. File changes inherit the curr
145145
- **Keep release notes human-sized.** A multi-commit PR should normally produce one clear release bullet per user-visible change, not one bullet per review fix. Put the public wording on the primary implementation commit with `Release note: <sentence>`, and add `Release note: skip` to follow-up commits such as `address review findings`, `tighten fallback`, `bound window`, or generated bundle syncs unless they describe a distinct user-visible outcome.
146146
- **Write release notes as natural English sentences.** The generator capitalizes the first character as a safety net, but do not rely on that to fix awkward wording. Prefer `Release note: Codex commits made from cmux sessions are now recorded reliably.` over `Release note: recover codex env sessions`.
147147
- **PR titles should be release-summary quality.** Write PR titles as a user-facing outcome, not an implementation step. Even though GitHub Releases are generated from commits, a good PR title is the easiest review-time signal that the eventual release note will be understandable.
148-
- **Preview release notes before merging release-sensitive PRs.** If `git-cliff --config .github/cliff.toml --latest --strip header` reads like an implementation log, rewrite commit subjects/bodies before merge or tag. Do not leave low-level cleanup commits visible just because tests pass.
148+
- **Preview release notes before merging release-sensitive PRs.** If `git-cliff --config .github/cliff.toml --latest --strip header` reads like an implementation log, rewrite commit subjects/bodies before merge. Before tagging, use the release command below so the preview targets the next version.
149149
- **Do not rely on merge commits for release copy.** Release notes include merged PR links from `Merge pull request...` commits, but the user-facing bullets still come from implementation commits and `Release note:` lines. Version bumps and generated bundle sync commits stay hidden.
150150
- **Structural vs behavioral changes** must not be mixed in a single commit. Renames/reformats separate from feature/fix commits.
151151
- **Before committing**, all four checks must pass (run from `packages/cli/`):
152152
1. `npm run build` — esbuild bundle
153153
2. `npm run typecheck` — tsc --noEmit
154154
3. `npm run lint` — biome check
155155
4. `npm test` — node:test (requires build first)
156-
- **Version bumps** go in a dedicated `chore: bump version to x.y.z` commit. Tag `vx.y.z` triggers the release workflow (test → GitHub Release → npm publish).
156+
- **Version bumps** go in a dedicated `chore: bump version to x.y.z` commit. Prefer `npm run release -- x.y.z` to prepare the bump commit and tag; add `--push` only when ready to trigger the release workflow (test → GitHub Release → npm publish).
157157

158158
## Constraints
159159

docs/architecture.md

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -617,15 +617,31 @@ should still be written as a clear sentence in the commit body.
617617
618618
The canonical npm package is `agent-note`. The workflow also publishes `@wasabeef/agentnote` from the same built `dist/` as a reserved alias package, but end-user documentation should continue to point to `agent-note`.
619619
620-
Release steps:
620+
Preferred release command:
621+
622+
```bash
623+
npm run release -- X.Y.Z # prepare local version-bump commit and tag
624+
npm run release -- X.Y.Z --push # also push main and vX.Y.Z
625+
```
626+
627+
The command updates `packages/cli/package.json`, syncs the `packages/cli` entry
628+
in `package-lock.json`, rebuilds `packages/cli/dist/cli.js`, runs the release
629+
checks, prints the `git-cliff` release note preview, commits
630+
`chore: bump version to X.Y.Z`, and creates the annotated `vX.Y.Z` tag. Without
631+
`--push`, the release stays local so the generated notes can be inspected before
632+
triggering `release.yml`.
633+
634+
Manual fallback steps:
621635

622636
1. Update the CLI package version in `packages/cli/package.json`.
623637
2. Keep the workspace lockfile in sync. At minimum, update the `packages/cli` entry in `package-lock.json` so the committed workspace metadata matches the published package version.
624638
3. Run the release checks locally:
625639
- `npm -w packages/cli run build`
640+
- `npm -w packages/cli run typecheck`
641+
- `npm -w packages/cli run lint`
626642
- `npm -w packages/cli test`
627643
4. Review the generated release note locally before tagging:
628-
- `git-cliff --config .github/cliff.toml --latest --strip header`
644+
- `git-cliff --config .github/cliff.toml --unreleased --tag vX.Y.Z --strip header`
629645
5. If the generated note reads like an implementation log, rewrite the relevant
630646
commit subjects or add `Release note:` / `Release note: skip` lines before
631647
tagging.

docs/engineering.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ subject as public copy unless the commit type is intentionally internal.
112112
- Keep multi-commit PRs readable in the generated release note. Review-fix follow-up commits in a multi-commit PR should usually use `Release note: skip` unless they describe a distinct user-visible change.
113113
- The release generator capitalizes the first character of each bullet as a safety net. Still write natural English yourself; this only fixes mechanical lower-case commit subjects.
114114
- A PR title is not the release-note source, but it should still read like the top-level release summary for the PR. If the title would be a bad release bullet, improve it before opening or merging the PR.
115-
- Before tagging, run `git-cliff --config .github/cliff.toml --latest --strip header`. If the output reads like an implementation log, rewrite commit subjects/bodies before cutting the release.
115+
- Before tagging, prefer `npm run release -- x.y.z` so the version bump, build, tests, release note preview, commit, and tag are produced by one repeatable command. If running the steps manually, run `git-cliff --config .github/cliff.toml --unreleased --tag vx.y.z --strip header` and rewrite commit subjects/bodies before cutting the release if the output reads like an implementation log.
116116

117117
Good commit body shape:
118118

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
"dashboard:restore-notes": "npm run workflow:restore-notes --prefix packages/dashboard",
1313
"dashboard:sync-notes": "npm run workflow:sync-notes --prefix packages/dashboard",
1414
"dashboard:build-pages": "npm run workflow:build-pages --prefix packages/dashboard",
15-
"dashboard:persist-notes": "npm run workflow:persist-notes --prefix packages/dashboard"
15+
"dashboard:persist-notes": "npm run workflow:persist-notes --prefix packages/dashboard",
16+
"release": "node scripts/release.mjs"
1617
},
1718
"workspaces": [
1819
"packages/cli",

scripts/release.mjs

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
#!/usr/bin/env node
2+
3+
import { execFileSync } from "node:child_process";
4+
import { readFileSync, writeFileSync } from "node:fs";
5+
6+
const CLI_PACKAGE_PATH = "packages/cli/package.json";
7+
const LOCKFILE_PATH = "package-lock.json";
8+
const CLI_DIST_PATH = "packages/cli/dist/cli.js";
9+
const KNOWN_FLAGS = new Set([
10+
"--push",
11+
"--dry-run",
12+
"--skip-checks",
13+
"--allow-dirty",
14+
"--allow-non-main",
15+
"--help",
16+
"-h",
17+
]);
18+
const RELEASE_CHECKS = [
19+
["npm", ["-w", "packages/cli", "run", "build"]],
20+
["npm", ["-w", "packages/cli", "run", "typecheck"]],
21+
["npm", ["-w", "packages/cli", "run", "lint"]],
22+
["npm", ["-w", "packages/cli", "test"]],
23+
];
24+
25+
function printUsage() {
26+
console.log(`Usage:
27+
npm run release -- <version> [--push]
28+
29+
Examples:
30+
npm run release -- 1.0.6
31+
npm run release -- 1.0.6 --push
32+
npm run release -- 1.0.6 --dry-run --allow-non-main --allow-dirty
33+
34+
Options:
35+
--push Push main and the release tag after creating them locally.
36+
--dry-run Print the planned release without editing files, committing, or tagging.
37+
--skip-checks Skip local build/typecheck/lint/test checks.
38+
--allow-dirty Allow a dirty working tree before the release command starts.
39+
--allow-non-main Allow running from a branch other than main.
40+
--help Show this help.
41+
`);
42+
}
43+
44+
function run(command, args, opts = {}) {
45+
const printable = [command, ...args].join(" ");
46+
if (opts.dryRun) {
47+
console.log(`$ ${printable}`);
48+
return "";
49+
}
50+
console.log(`$ ${printable}`);
51+
return execFileSync(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] });
52+
}
53+
54+
function readJson(path) {
55+
return JSON.parse(readFileSync(path, "utf8"));
56+
}
57+
58+
function writeJson(path, value, dryRun) {
59+
if (dryRun) return;
60+
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
61+
}
62+
63+
function normalizeVersion(input) {
64+
const version = input.startsWith("v") ? input.slice(1) : input;
65+
if (!/^\d+\.\d+\.\d+$/.test(version)) {
66+
throw new Error(`Release version must be x.y.z, got: ${input}`);
67+
}
68+
return version;
69+
}
70+
71+
function gitOutput(args) {
72+
return execFileSync("git", args, { encoding: "utf8" }).trim();
73+
}
74+
75+
function assertCleanWorktree(allowDirty) {
76+
if (allowDirty) return;
77+
const status = gitOutput(["status", "--porcelain"]);
78+
if (status) {
79+
throw new Error(
80+
"Working tree must be clean before release. Commit/stash changes or pass --allow-dirty.",
81+
);
82+
}
83+
}
84+
85+
function assertMainBranch(allowNonMain) {
86+
if (allowNonMain) return;
87+
const branch = gitOutput(["branch", "--show-current"]);
88+
if (branch !== "main") {
89+
throw new Error(`Release command must run on main, current branch is: ${branch}`);
90+
}
91+
}
92+
93+
function assertTagAvailable(tag) {
94+
const existing = gitOutput(["tag", "--list", tag]);
95+
if (existing) throw new Error(`Local tag already exists: ${tag}`);
96+
}
97+
98+
function updateVersions(version, dryRun) {
99+
const cliPackage = readJson(CLI_PACKAGE_PATH);
100+
cliPackage.version = version;
101+
writeJson(CLI_PACKAGE_PATH, cliPackage, dryRun);
102+
103+
const lockfile = readJson(LOCKFILE_PATH);
104+
const packages = lockfile.packages;
105+
const cliPackageLock = packages?.["packages/cli"];
106+
if (!cliPackageLock) {
107+
throw new Error("package-lock.json is missing packages/cli metadata.");
108+
}
109+
cliPackageLock.version = version;
110+
writeJson(LOCKFILE_PATH, lockfile, dryRun);
111+
}
112+
113+
function previewReleaseNotes(tag) {
114+
console.log("\nRelease note preview:\n");
115+
const output = execFileSync(
116+
"git-cliff",
117+
["--config", ".github/cliff.toml", "--unreleased", "--tag", tag, "--strip", "header"],
118+
{ encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] },
119+
);
120+
console.log(output.trim());
121+
console.log("");
122+
}
123+
124+
function commitAndTag(version, tag, dryRun) {
125+
run("git", ["add", CLI_PACKAGE_PATH, LOCKFILE_PATH], { dryRun });
126+
run("git", ["add", "-f", CLI_DIST_PATH], { dryRun });
127+
run(
128+
"git",
129+
[
130+
"commit",
131+
"-m",
132+
`chore: bump version to ${version}`,
133+
"-m",
134+
[
135+
"Why",
136+
`Prepare the CLI package for the v${version} release. The release workflow publishes the committed package version, so package metadata and the bundled CLI must match the tag.`,
137+
"",
138+
"User impact",
139+
`The v${version} npm package and GitHub release will publish the changes already merged on main.`,
140+
"",
141+
"Verification",
142+
"npm -w packages/cli run build",
143+
"npm -w packages/cli run typecheck",
144+
"npm -w packages/cli run lint",
145+
"npm -w packages/cli test",
146+
`git-cliff --config .github/cliff.toml --unreleased --tag ${tag} --strip header`,
147+
"",
148+
"Release note: skip",
149+
].join("\n"),
150+
],
151+
{ dryRun },
152+
);
153+
run("git", ["tag", "-a", tag, "-m", tag], { dryRun });
154+
}
155+
156+
function parseArgs(argv) {
157+
const flags = new Set(argv.filter((arg) => arg.startsWith("-")));
158+
const positional = argv.filter((arg) => !arg.startsWith("-"));
159+
for (const flag of flags) {
160+
if (!KNOWN_FLAGS.has(flag)) {
161+
throw new Error(`Unknown option: ${flag}`);
162+
}
163+
}
164+
if (positional.length > 1) {
165+
throw new Error(`Expected one version argument, got: ${positional.join(" ")}`);
166+
}
167+
return {
168+
version: positional[0] ?? null,
169+
push: flags.has("--push"),
170+
dryRun: flags.has("--dry-run"),
171+
skipChecks: flags.has("--skip-checks"),
172+
allowDirty: flags.has("--allow-dirty"),
173+
allowNonMain: flags.has("--allow-non-main"),
174+
help: flags.has("--help") || flags.has("-h"),
175+
};
176+
}
177+
178+
function main() {
179+
const args = parseArgs(process.argv.slice(2));
180+
if (args.help || !args.version) {
181+
printUsage();
182+
process.exit(args.help ? 0 : 1);
183+
}
184+
185+
const version = normalizeVersion(args.version);
186+
const tag = `v${version}`;
187+
188+
assertMainBranch(args.allowNonMain);
189+
assertCleanWorktree(args.allowDirty);
190+
assertTagAvailable(tag);
191+
192+
console.log(`Preparing ${tag}${args.dryRun ? " (dry run)" : ""}`);
193+
updateVersions(version, args.dryRun);
194+
195+
if (!args.skipChecks) {
196+
for (const [command, commandArgs] of RELEASE_CHECKS) {
197+
run(command, commandArgs, { dryRun: args.dryRun });
198+
}
199+
}
200+
201+
previewReleaseNotes(tag);
202+
commitAndTag(version, tag, args.dryRun);
203+
204+
if (args.push) {
205+
run("git", ["push", "origin", "main"], { dryRun: args.dryRun });
206+
run("git", ["push", "origin", tag], { dryRun: args.dryRun });
207+
} else {
208+
console.log(`Local release commit and ${tag} tag are ready.`);
209+
console.log(`Run: git push origin main && git push origin ${tag}`);
210+
}
211+
}
212+
213+
try {
214+
main();
215+
} catch (error) {
216+
console.error(`release: ${error instanceof Error ? error.message : String(error)}`);
217+
process.exit(1);
218+
}

0 commit comments

Comments
 (0)