From 4a42956a3cfd7d652bd1f0e09563de0630471a6a Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Wed, 26 Aug 2026 11:19:38 +0200 Subject: [PATCH] fix(release): stop the coana bump from hand-writing versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bump-coana skill bumped package.json's version itself and wrote its own `## []` changelog heading. Both belong to the release workflow, and the skill's version of them caused real damage: headings for 1.1.160, a version that never existed anywhere, and a v1.1.159 that shipped with no notes at all because the hand-written heading had consumed the `[Unreleased]` block the release meant to promote. Point the skill at `[Unreleased]` instead, recreating that heading when the previous release consumed it — having nowhere to put notes is what motivated the invented headings in the first place. Also separate the two failures behind writeManifestVersion's guard. A manifest already holding the target version rewrites to itself, and `replaced === raw` read that no-op as a missing field, so a release whose version the skill had already written failed with "no top-level version line" against a manifest whose version field was present and well-formed. Test for the field, then replace, and let an already-correct manifest pass through. --- .claude/skills/bump-coana/SKILL.md | 37 ++++++++++++++++++++++-------- scripts/release/bump.mts | 16 ++++++++----- test/release-bump.test.mts | 17 ++++++++++++++ 3 files changed, 55 insertions(+), 15 deletions(-) diff --git a/.claude/skills/bump-coana/SKILL.md b/.claude/skills/bump-coana/SKILL.md index d75ee1d294..8f8f6e918e 100644 --- a/.claude/skills/bump-coana/SKILL.md +++ b/.claude/skills/bump-coana/SKILL.md @@ -37,22 +37,37 @@ fi 1. Read `package.json` in the repository root. 2. Find the current `@coana-tech/cli` version in `devDependencies` and note it as `CURRENT_VERSION`. 3. Update `@coana-tech/cli` to the new version. -4. Bump the patch version of the package (e.g., `1.1.59` → `1.1.60`). -5. Write the updated `package.json`. +4. Write the updated `package.json`. + +🚨 **Do NOT touch the top-level `version` field.** The release workflow owns it: +between releases the manifest holds the last released version, and +`scripts/release/bump.mts` derives and writes the next one in-run. Hand-writing +it invents a version that never releases and breaks the next release — a manifest +already sitting on the version being released has no line for the bump to +advance. **Values to extract**: - `CURRENT_VERSION`: The old @coana-tech/cli version (for PR body) -- `NEW_PKG_VERSION`: The bumped package.json version (for changelog) ### Step 3: Update CHANGELOG.md 1. Read `CHANGELOG.md` in the repository root. -2. Add a new version entry after the header section (which ends with "The format is based on..."). -3. Use today's date in `YYYY-MM-DD` format. - -**Entry format**: +2. Find the `## [Unreleased]` heading. If it is absent — the previous release + consumes it — recreate it directly after the header section (which ends with + "The format is based on..."). +3. Add the entry under `## [Unreleased]`, in its `### Changed` subsection, + creating that subsection if it is missing. If a Coana line is already there + from an earlier unreleased bump, update it in place rather than adding a + second one. + +🚨 **Never write a `## []` heading.** Release headings belong to the +release workflow, which promotes the whole `## [Unreleased]` block under the +version it derives. Writing one here both names a version that may never exist +and consumes the block, leaving the real release with empty notes. + +**Resulting shape**: ```markdown -## [NEW_PKG_VERSION](https://github.com/SocketDev/socket-cli/releases/tag/vNEW_PKG_VERSION) - YYYY-MM-DD +## [Unreleased] ### Changed - Updated the Coana CLI to v `COANA_VERSION`. @@ -105,7 +120,8 @@ Replace `CURRENT_VERSION` and `COANA_VERSION` with actual values. - Branch: `coana-` pushed to origin - PR: Created targeting `v1.x` branch -- Files modified: `package.json`, `CHANGELOG.md`, `pnpm-lock.yaml` +- Files modified: `package.json` (the `@coana-tech/cli` devDependency only, never + the top-level `version`), `CHANGELOG.md` (under `## [Unreleased]`), `pnpm-lock.yaml` Report the PR URL to the user when complete. @@ -120,3 +136,6 @@ Report the PR URL to the user when complete. - Do NOT add any AI/Claude co-authorship or attribution to the commit message or PR. - Do NOT include "Generated with Claude Code" or similar text anywhere. +- Do NOT bump `package.json`'s `version` or write a `## []` changelog + heading. Both belong to the release workflow. Hand-writing them has produced + versions that never released and cost a real release its notes entirely. diff --git a/scripts/release/bump.mts b/scripts/release/bump.mts index 960cac22e0..53b31cc8c1 100644 --- a/scripts/release/bump.mts +++ b/scripts/release/bump.mts @@ -53,6 +53,8 @@ const rootPath = path.join( const REGISTRY_URL = 'https://registry.npmjs.org' +const VERSION_FIELD_PATTERN = /("version":\s*")[^"]+(")/ + interface PackageJsonShape { name?: string | undefined repository?: { url?: string | undefined } | string | undefined @@ -158,11 +160,10 @@ function readPackageJson(): { parsed: PackageJsonShape; raw: string } { * is the one line a reviewer expects. */ export function writeManifestVersion(raw: string, version: string): string { - const replaced = raw.replace( - /("version":\s*")[^"]+(")/, - (_m, pre: string, post: string) => `${pre}${version}${post}`, - ) - if (replaced === raw) { + // Test for the field before replacing: a manifest already sitting on + // `version` rewrites to itself, and comparing output to input cannot tell + // that no-op apart from a missing field. + if (!VERSION_FIELD_PATTERN.test(raw)) { throw new Error( '[bump] could not rewrite the package.json version field.\n' + ' Where: the root package.json, at bump time.\n' + @@ -170,7 +171,10 @@ export function writeManifestVersion(raw: string, version: string): string { ' Fix: restore the version field, then re-dispatch.', ) } - return replaced + return raw.replace( + VERSION_FIELD_PATTERN, + (_m, pre: string, post: string) => `${pre}${version}${post}`, + ) } function emitOutputs(outputs: Record): void { diff --git a/test/release-bump.test.mts b/test/release-bump.test.mts index 1dcd8bfad1..004edd2278 100644 --- a/test/release-bump.test.mts +++ b/test/release-bump.test.mts @@ -31,6 +31,23 @@ describe('writeManifestVersion', () => { ) }) + // A manifest already sitting on the target rewrites to itself. The prior + // `replaced === raw` guard read that no-op as a missing field and failed the + // release with "no top-level version line" for a manifest whose version was + // present and well-formed — the exact failure that broke the 1.1.160 run, + // where a hand-written bump had already put 1.1.160 in the manifest. + it('is a no-op when the manifest already holds the target version', () => { + const raw = [ + '{', + ' "name": "socket",', + ' "version": "1.1.160",', + ' "description": "CLI for Socket.dev"', + '}', + '', + ].join('\n') + expect(writeManifestVersion(raw, '1.1.160')).toBe(raw) + }) + it('throws when the manifest has no version field', () => { const raw = '{\n "name": "socket"\n}\n' expect(() => writeManifestVersion(raw, '1.1.160')).toThrow(