Bläddra i källkod

docs(claude): rewrite release section for auto-promote workflow + link-ref on promote

Two paired updates:

1. **`CLAUDE.md` § Releases** — rewritten to match the actual workflow now
   that #436's auto-promote step lands the entries automatically.

   The old text told Claude to 'Add a new `## [X.Y.Z] - YYYY-MM-DD`
   block at the top of CHANGELOG.md' as the first step. That instruction
   is the exact pattern that caused the v0.9.5 sparse-release-notes
   incident — a hand-added sparse `[X.Y.Z]` block (one early fix
   pre-staged) is what the extractor picked, ignoring everything under
   `[Unreleased]` above it.

   New default: write entries under `## [Unreleased]` during normal
   work. The Release workflow promotes them at release time. The
   formatting rules (sub-section grouping, user-perspective wording,
   issue/PR refs) are preserved. The link-reference rule moves to 'don't
   add it yourself' since `prepare-release.mjs` now appends it.

2. **`scripts/prepare-release.mjs`** — extended to also append a
   `[X.Y.Z]: https://github.com/colbymchenry/codegraph/releases/tag/vX.Y.Z`
   link reference at the end of CHANGELOG.md when promoting (idempotent
   — no-op if one already exists, regardless of where in the file it
   sits). This is what makes the `## [X.Y.Z]` heading text auto-link
   to its release tag in GitHub's renderer; without it the heading still
   renders, just unlinked. 3 new tests cover Case A append, Case B
   append-when-merging, and no-double-add.

940/942 existing tests still pass (2 pre-existing skips); +3 new tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Colby McHenry 4 veckor sedan
förälder
incheckning
d81736a7f2
3 ändrade filer med 85 tillägg och 17 borttagningar
  1. 25 15
      CLAUDE.md
  2. 34 0
      __tests__/prepare-release.test.ts
  3. 26 2
      scripts/prepare-release.mjs

+ 25 - 15
CLAUDE.md

@@ -204,34 +204,44 @@ Released to npm and mirrored as [GitHub Releases](https://github.com/colbymchenr
 
 ### Writing changelog entries
 
-When asked for an entry for a new version:
+**Default: write entries under `## [Unreleased]`** — that's the section reserved for work landing between releases. **Don't pre-create a `## [X.Y.Z]` block** for the next release: the Release workflow's first step is `scripts/prepare-release.mjs`, which automatically promotes everything under `[Unreleased]` into a new `## [X.Y.Z] - <YYYY-MM-DD>` block at release time (or merges into a pre-existing `[X.Y.Z]` block if one exists — but you don't need one). Pre-staging is what caused the v0.9.5 sparse-release-notes incident: a sparse `[0.9.5]` block hand-added before the rest of the work landed got picked by the extractor over the much-larger `[Unreleased]` section above it. Don't do that.
 
-1. Add a new `## [X.Y.Z] - YYYY-MM-DD` block at the **top** of `CHANGELOG.md` (under the intro, above the previous version).
-2. Group under `### Added`, `### Changed`, `### Fixed`, `### Removed`, `### Deprecated`, `### Security` — omit empty sections.
-3. Write from the **user's perspective**, not the implementation's. Lead with the observable symptom or capability; mention internals only if a user needs them (e.g., to work around an existing bad install).
-4. Add the link reference at the bottom: `[X.Y.Z]: https://github.com/colbymchenry/codegraph/releases/tag/vX.Y.Z`.
+Formatting rules for any entry (anywhere — `[Unreleased]` or otherwise):
+
+1. Group under `### Added`, `### Changed`, `### Fixed`, `### Removed`, `### Deprecated`, `### Security` — omit empty sections. The promote step merges matching sub-section headings, so writing under `### Added` in `[Unreleased]` lands under `### Added` in `[X.Y.Z]`.
+2. Write from the **user's perspective**, not the implementation's. Lead with the observable symptom or capability; mention internals only if a user needs them (e.g., to work around an existing bad install).
+3. Issue / PR references in entries are by number (`(#403)` etc.); the GitHub renderer auto-links them in the published release notes.
+4. **Don't add a `[X.Y.Z]: https://...` link reference yourself** — `prepare-release.mjs` appends it automatically when it promotes the version (idempotent: a re-run is a no-op if it already exists).
 
 ### Release flow (the user runs these)
 
 Releases are built and published by the **GitHub Actions "Release" workflow**
-(`.github/workflows/release.yml`). It bundles a Node runtime per platform
-(`scripts/build-bundle.sh`) and publishes both the GitHub Release and the npm
-thin-installer (`scripts/pack-npm.sh`: a shim package + per-platform packages).
+(`.github/workflows/release.yml`). It runs `scripts/prepare-release.mjs` to
+promote `[Unreleased]` into `[<version>]` (and auto-commit + push that
+CHANGELOG change back to `main` so on-disk truth matches the published
+notes), then bundles a Node runtime per platform (`scripts/build-bundle.sh`)
+and publishes both the GitHub Release and the npm thin-installer
+(`scripts/pack-npm.sh`: a shim package + per-platform packages).
 Publishing manually is **wrong** now — a plain `npm publish` ships the root
 package (non-bundled), which breaks anyone on Node < 22.5.
 
-After the changelog entry is written and `package.json` is bumped:
+The release cut itself is:
 
 ```bash
-git add package.json package-lock.json CHANGELOG.md
-git commit -m "release: X.Y.Z (<one-line summary>)"
+# CHANGELOG already has entries under [Unreleased] — no manual moves needed.
+# Just bump the version:
+npm version --no-git-tag-version <X.Y.Z>      # or edit package.json manually
+git add package.json package-lock.json
+git commit -m "release: X.Y.Z"
 git push
 ```
 
-Then trigger **Actions → Release → Run workflow** (on `main`). It reads the
-version from `package.json`, builds every platform bundle on one runner, creates
-the GitHub Release with notes from the matching `CHANGELOG.md` section, and
-publishes to npm. Requires the `NPM_TOKEN` repo secret.
+Then trigger **Actions → Release → Run workflow** (on `main`). The workflow:
+
+1. Runs `prepare-release.mjs <X.Y.Z>` → promotes `[Unreleased]` → `[X.Y.Z] - <today>` in `CHANGELOG.md`, appends the link reference, commits + pushes the move with `[skip ci]`.
+2. Builds every platform bundle on one runner, generates `SHA256SUMS`.
+3. Creates the GitHub Release with notes from the freshly-promoted `[X.Y.Z]` block.
+4. Publishes the npm shim + per-platform packages. Requires the `NPM_TOKEN` repo secret.
 
 **Do not run `npm publish`, `git push`, or `git tag` yourself** — these are
 publish actions on shared state. Write the files, hand the user the commands.

+ 34 - 0
__tests__/prepare-release.test.ts

@@ -165,6 +165,40 @@ describe('prepare-release.mjs', () => {
     });
   });
 
+  describe('link reference', () => {
+    it('appends a `[version]: https://...` link reference at EOF when promoting (Case A)', () => {
+      dir = setup(HEADER + `## [Unreleased]\n\n### Added\n- x\n\n## [1.2.2] - 2026-01-01\n`);
+      run(dir);
+      const result = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8');
+      expect(result).toContain(
+        '[1.2.3]: https://github.com/colbymchenry/codegraph/releases/tag/v1.2.3',
+      );
+    });
+
+    it('appends a link reference when merging into an existing [version] (Case B)', () => {
+      dir = setup(
+        HEADER + `## [Unreleased]\n\n### Added\n- new\n\n## [1.2.3] - 2026-02-02\n\n### Fixed\n- prior\n`,
+      );
+      run(dir);
+      const result = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8');
+      expect(result).toContain(
+        '[1.2.3]: https://github.com/colbymchenry/codegraph/releases/tag/v1.2.3',
+      );
+    });
+
+    it('does not double-add an existing link reference', () => {
+      const ref = '[1.2.3]: https://github.com/colbymchenry/codegraph/releases/tag/v1.2.3';
+      dir = setup(
+        HEADER +
+          `## [Unreleased]\n\n### Added\n- x\n\n## [1.2.2] - 2026-01-01\n\n${ref}\n`,
+      );
+      run(dir);
+      const result = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8');
+      const occurrences = result.split(ref).length - 1;
+      expect(occurrences).toBe(1);
+    });
+  });
+
   describe('extractor integration', () => {
     it('the resulting [version] block is what extract-release-notes.mjs would surface', () => {
       // Run prepare, then extract — confirm the output contains all the

+ 26 - 2
scripts/prepare-release.mjs

@@ -195,7 +195,8 @@ function main() {
       body: ['', ''], // two blank lines for the next round of entries
     };
     parsed.blocks.splice(unrelIdx, 1, emptied, promoted);
-    writeFileSync(CHANGELOG_PATH, joinChangelog(parsed));
+    const next = joinChangelog(parsed);
+    writeFileSync(CHANGELOG_PATH, appendLinkRef(next, version));
     console.log(`prepare-release: ${version} — renamed [Unreleased] to [${version}] - ${today}`);
     return;
   }
@@ -234,10 +235,33 @@ function main() {
   // Empty out Unreleased.
   unrel.body = ['', ''];
 
-  writeFileSync(CHANGELOG_PATH, joinChangelog(parsed));
+  const merged_text = joinChangelog(parsed);
+  writeFileSync(CHANGELOG_PATH, appendLinkRef(merged_text, version));
   console.log(`prepare-release: ${version} — merged ${merged} Unreleased entries into existing [${version}] block`);
 }
 
+/**
+ * Append a `[X.Y.Z]: https://github.com/colbymchenry/codegraph/releases/tag/vX.Y.Z`
+ * link reference at the end of the file IF one doesn't already exist. The
+ * link ref is what makes `## [X.Y.Z]` heading text auto-link to its tag in
+ * GitHub's renderer; without it the heading still renders, just unlinked.
+ *
+ * Idempotent. The existing CHANGELOG mixes link refs scattered through the
+ * file and a sorted block at the bottom — we just append at the very end,
+ * which CommonMark accepts regardless.
+ */
+function appendLinkRef(text, version) {
+  const refLine = `[${version}]: https://github.com/colbymchenry/codegraph/releases/tag/v${version}`;
+  // Already there? Look for a line that EQUALS this (anywhere in the file)
+  // to keep idempotency robust against the scattered-vs-block layout.
+  const lines = text.split('\n');
+  if (lines.some((l) => l.trim() === refLine)) return text;
+  // Append, separated by a blank line from the prior content. Preserve a
+  // single trailing newline at EOF.
+  const trailingNewline = text.endsWith('\n') ? '' : '\n';
+  return text + trailingNewline + refLine + '\n';
+}
+
 try {
   main();
 } catch (err) {