diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aebe5e10..ad23a099 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,8 +12,11 @@ name: Release # release notes from CHANGELOG.md, # 5. publishes the npm thin-installer (shim + per-platform packages). # -# Before triggering: bump package.json. CHANGELOG.md entries can live under -# `## [Unreleased]` — step 2 takes care of moving them. Set the NPM_TOKEN secret. +# Before triggering: bump package.json on the default branch, and dispatch this +# workflow from that branch — a guard step rejects anything else, because step 2 +# pushes its commit to whichever branch triggered the run. CHANGELOG.md entries +# can live under `## [Unreleased]`; step 2 moves them. Never pre-create a +# `## []` block. Set the NPM_TOKEN secret. on: workflow_dispatch: {} @@ -24,6 +27,24 @@ jobs: release: runs-on: ubuntu-latest steps: + - name: Refuse to release from a non-default branch + # The promote step below pushes the CHANGELOG back to the branch + # this workflow was dispatched on. Dispatched from a short-lived + # `release/*` branch, that commit lands there and disappears with + # the branch — which is exactly how v1.16.0 shipped while `main` + # kept a stale CHANGELOG and package.json (#296). Releases come + # from the default branch or they do not happen. + env: + REF_NAME: ${{ github.ref_name }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + set -euo pipefail + if [ "$REF_NAME" != "$DEFAULT_BRANCH" ]; then + echo "::error::Release was dispatched from '$REF_NAME'. Run it from '$DEFAULT_BRANCH' so the CHANGELOG promotion is pushed to a branch that survives." + exit 1 + fi + echo "Releasing from '$REF_NAME' (default branch)." + - uses: actions/checkout@v6 with: # Default checkout is detached at a SHA; we need an actual branch diff --git a/CHANGELOG.md b/CHANGELOG.md index 87ba2832..2f8aeb47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,15 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Fixes + +- Release notes published to GitHub now carry every section of the changelog. A section whose heading was more than one word — such as the list of new features — could be dropped from the published notes without any warning, which is how v1.16.0 shipped with its entire feature list missing. Those entries have been restored to the changelog. (#296) +- Each version heading in the changelog now links to this project's own release instead of a different project's, where the tag does not exist. (#296) +- Publishing a release now refuses to run from anywhere but the project's main branch, so the changelog update it makes can no longer be left behind on a temporary branch and lost. (#296) + + +## [1.16.0] - 2026-09-03 + ### New Features - Module-level variables are now part of the graph, so shared state can be traced to the procedures that read and write it. (#251) @@ -193,12 +202,13 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed - Closed ArkTS test database handles before removing temporary directories, preventing Windows `EBUSY`/`EPERM` teardown failures. -[1.7.3]: https://github.com/colbymchenry/codegraph/releases/tag/v1.7.3 -[1.8.0]: https://github.com/colbymchenry/codegraph/releases/tag/v1.8.0 -[1.9.0]: https://github.com/colbymchenry/codegraph/releases/tag/v1.9.0 -[1.10.0]: https://github.com/colbymchenry/codegraph/releases/tag/v1.10.0 -[1.11.0]: https://github.com/colbymchenry/codegraph/releases/tag/v1.11.0 -[1.12.0]: https://github.com/colbymchenry/codegraph/releases/tag/v1.12.0 -[1.13.0]: https://github.com/colbymchenry/codegraph/releases/tag/v1.13.0 -[1.14.0]: https://github.com/colbymchenry/codegraph/releases/tag/v1.14.0 -[1.15.0]: https://github.com/colbymchenry/codegraph/releases/tag/v1.15.0 +[1.7.3]: https://github.com/ardelperal/codegraph-vba/releases/tag/v1.7.3 +[1.8.0]: https://github.com/ardelperal/codegraph-vba/releases/tag/v1.8.0 +[1.9.0]: https://github.com/ardelperal/codegraph-vba/releases/tag/v1.9.0 +[1.10.0]: https://github.com/ardelperal/codegraph-vba/releases/tag/v1.10.0 +[1.11.0]: https://github.com/ardelperal/codegraph-vba/releases/tag/v1.11.0 +[1.12.0]: https://github.com/ardelperal/codegraph-vba/releases/tag/v1.12.0 +[1.13.0]: https://github.com/ardelperal/codegraph-vba/releases/tag/v1.13.0 +[1.14.0]: https://github.com/ardelperal/codegraph-vba/releases/tag/v1.14.0 +[1.15.0]: https://github.com/ardelperal/codegraph-vba/releases/tag/v1.15.0 +[1.16.0]: https://github.com/ardelperal/codegraph-vba/releases/tag/v1.16.0 diff --git a/__tests__/prepare-release.test.ts b/__tests__/prepare-release.test.ts index 1491f889..3c7725a4 100644 --- a/__tests__/prepare-release.test.ts +++ b/__tests__/prepare-release.test.ts @@ -13,6 +13,7 @@ import * as path from 'node:path'; import * as os from 'node:os'; const SCRIPT = path.resolve(__dirname, '..', 'scripts', 'prepare-release.mjs'); +const EXTRACTOR = path.resolve(__dirname, '..', 'scripts', 'extract-release-notes.mjs'); function run(cwd: string, ...args: string[]) { const out = execFileSync('node', [SCRIPT, ...args], { cwd, encoding: 'utf8' }); @@ -32,6 +33,12 @@ Some intro. `; +/** Read back the `## [1.2.3]` block of the CHANGELOG the script just wrote. */ +function result123(cwd: string) { + const result = fs.readFileSync(path.join(cwd, 'CHANGELOG.md'), 'utf8'); + return result.split('## [1.2.3]')[1].split('## [1.2.2]')[0]; +} + describe('prepare-release.mjs', () => { let dir: string; afterEach(() => { @@ -171,7 +178,7 @@ describe('prepare-release.mjs', () => { 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', + '[1.2.3]: https://github.com/ardelperal/codegraph-vba/releases/tag/v1.2.3', ); }); @@ -182,12 +189,12 @@ describe('prepare-release.mjs', () => { 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', + '[1.2.3]: https://github.com/ardelperal/codegraph-vba/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'; + const ref = '[1.2.3]: https://github.com/ardelperal/codegraph-vba/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`, @@ -199,6 +206,202 @@ describe('prepare-release.mjs', () => { }); }); + // Regression suite for #296. Every fixture above uses the old + // single-word Keep-a-Changelog vocabulary, which is why a `\w+`-only + // sub-section pattern shipped green while the real CHANGELOG had + // already moved to `### New Features` / `### Breaking Changes`. + describe("multi-word sub-section headings (the repo's real vocabulary)", () => { + it('carries every New Features entry into [version] when merging (Case B)', () => { + dir = setup( + HEADER + + `## [1.2.3] - 2026-02-02 + +## [Unreleased] + +### New Features +- Feature one +- Feature two + +### Fixes +- A fix + +## [1.2.2] - 2026-01-01 +`, + ); + const out = run(dir); + expect(out).toContain('merged 3'); + + const v123 = result123(dir); + expect(v123).toContain('### New Features'); + expect(v123).toContain('- Feature one'); + expect(v123).toContain('- Feature two'); + expect(v123).toContain('### Fixes'); + expect(v123).toContain('- A fix'); + }); + + it('reproduces the v1.16.0 shape: New Features first, above an empty pre-created block', () => { + // The exact input that lost 16 bullets: a pre-created empty + // [version] block, and `### New Features` as the FIRST heading in + // [Unreleased] — so it landed in `leading`, which Case B ignored. + dir = setup( + HEADER + + `## [1.2.3] - 2026-02-02 + +## [Unreleased] + +### New Features +- Headline feature + +### Changed +- A change + +### Fixes +- A fix + +## [1.2.2] - 2026-01-01 +`, + ); + run(dir); + + const v123 = result123(dir); + expect(v123).toContain('- Headline feature'); + expect(v123).toContain('- A change'); + expect(v123).toContain('- A fix'); + }); + + it('merges into a matching multi-word heading instead of duplicating it', () => { + dir = setup( + HEADER + + `## [Unreleased] + +### New Features +- Late feature + +## [1.2.3] - 2026-02-02 + +### New Features +- Early feature + +## [1.2.2] - 2026-01-01 +`, + ); + run(dir); + + const v123 = result123(dir); + expect(v123).toContain('- Early feature'); + expect(v123).toContain('- Late feature'); + expect(v123.split('### New Features').length - 1).toBe(1); + }); + + it('still merges the single-word headings the older entries use', () => { + // Must stay silent: the fix widens the pattern, it must not change + // how the legacy vocabulary behaves. + dir = setup( + HEADER + + `## [Unreleased] + +### Added +- New thing + +## [1.2.3] - 2026-02-02 + +### Added +- Old thing + +## [1.2.2] - 2026-01-01 +`, + ); + run(dir); + + const v123 = result123(dir); + expect(v123).toContain('- Old thing'); + expect(v123).toContain('- New thing'); + expect(v123.split('### Added').length - 1).toBe(1); + }); + + it('carries over entries written before any heading rather than dropping them', () => { + // Defence in depth: whatever the heading vocabulary becomes, a + // bullet with no recognised heading above it must survive. + dir = setup( + HEADER + + `## [Unreleased] + +- Heading-less entry + +### Fixes +- A fix + +## [1.2.3] - 2026-02-02 + +### Fixes +- Prior fix + +## [1.2.2] - 2026-01-01 +`, + ); + const out = run(dir); + expect(out).toContain('merged 2'); + + const v123 = result123(dir); + expect(v123).toContain('- Heading-less entry'); + expect(v123).toContain('- Prior fix'); + expect(v123).toContain('- A fix'); + }); + + it('leaves [Unreleased] empty so the next release cannot republish these entries', () => { + // The second half of #296: entries left behind in [Unreleased] + // after a release get published a second time by the next one. + dir = setup( + HEADER + + `## [Unreleased] + +### New Features +- Shipped once + +## [1.2.3] - 2026-02-02 + +### Fixes +- Prior +`, + ); + run(dir); + + const result = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8'); + const unrel = result.split('## [Unreleased]')[1].split('## [1.2.3]')[0]; + expect(unrel).not.toContain('- Shipped once'); + expect(unrel.trim()).toBe(''); + }); + + it('the published notes carry the New Features section end to end', () => { + // Closes the loop through the script the workflow actually pipes + // into `gh release create --notes-file`. + dir = setup( + HEADER + + `## [1.2.3] - 2026-02-02 + +## [Unreleased] + +### New Features +- Headline feature + +### Fixes +- A fix + +## [1.2.2] - 2026-01-01 +`, + ); + run(dir); + + const notes = execFileSync('node', [EXTRACTOR, '1.2.3'], { + cwd: dir, + encoding: 'utf8', + }); + expect(notes).toContain('### New Features'); + expect(notes).toContain('- Headline feature'); + expect(notes).toContain('- A fix'); + }); + }); + 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 diff --git a/package.json b/package.json index 6b44410e..f9d6d6a2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@aroman22/codegraph-vba", - "version": "1.15.0", + "version": "1.16.0", "description": "Supercharge AI coding agents with semantic code intelligence — surgical context, fewer tool calls, faster answers. 100% local.", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/scripts/prepare-release.mjs b/scripts/prepare-release.mjs index f3725796..409fbce4 100644 --- a/scripts/prepare-release.mjs +++ b/scripts/prepare-release.mjs @@ -22,11 +22,13 @@ * the common case. * * Case B — `[]` exists AND `[Unreleased]` has content: - * Merge `[Unreleased]`'s sub-sections (### Added / ### Fixed / - * ### Changed / ### Removed / ### Deprecated / ### Security) into - * the corresponding sub-sections of `[]`. Unmatched - * sub-sections are appended to `[]`. The `[Unreleased]` - * block is then emptied. + * Merge `[Unreleased]`'s sub-sections into the corresponding + * sub-sections of `[]`. Heading text is matched verbatim + * and may be multi-word — this CHANGELOG uses `### New Features` + * and `### Breaking Changes` alongside the older single-word + * Keep-a-Changelog names. Unmatched sub-sections, and any entries + * appearing before the first heading, are appended to + * `[]`. The `[Unreleased]` block is then emptied. * * Case C — `[Unreleased]` has no content: * No-op. Exit 0. Re-runs of the workflow are safe. @@ -108,7 +110,12 @@ function joinChangelog({ preface, blocks }) { * sub-section so we can splice cleanly when merging. */ function splitSubsections(body) { - const subsectionRe = /^### (\w+)\s*$/; + // Multi-word headings are the norm, not the exception: this CHANGELOG + // uses `### New Features` / `### Breaking Changes` alongside the older + // single-word Keep-a-Changelog vocabulary (`### Added`, `### Fixed`). + // A `\w+`-only pattern silently failed to recognise the multi-word ones, + // which dropped every entry under them on the Case B path — see #296. + const subsectionRe = /^### (.+?)\s*$/; const leading = []; const subs = []; // { heading: 'Added' | 'Fixed' | …, headerLine: string, body: string[] } let cur = null; @@ -209,6 +216,20 @@ function main() { const verSubs = splitSubsections(ver.body); let merged = 0; + + // Entries that sit before Unreleased's first `### Heading` have no + // sub-section to merge into. Carry them over verbatim rather than + // letting them fall off the end of the merge loop: in #296 an + // unrecognised heading pushed 16 bullets in here and they were lost + // without a warning. Losing content must not be a silent outcome. + const unrelLeading = trimTrailingBlank(unrelSubs.leading); + if (blockHasContent(unrelLeading)) { + const existing = trimTrailingBlank(verSubs.leading); + const sep = existing.length && !/^\s*$/.test(existing[existing.length - 1]) ? [''] : []; + verSubs.leading = existing.concat(sep, unrelLeading, ['']); + merged += unrelLeading.filter((l) => /^\s*([-*]|\d+\.)\s+/.test(l)).length; + } + for (const us of unrelSubs.subs) { const target = verSubs.subs.find((s) => s.heading === us.heading); const usBody = trimTrailingBlank(us.body); @@ -241,7 +262,7 @@ function main() { } /** - * Append a `[X.Y.Z]: https://github.com/colbymchenry/codegraph/releases/tag/vX.Y.Z` + * Append a `[X.Y.Z]: https://github.com/ardelperal/codegraph-vba/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. @@ -251,7 +272,9 @@ function main() { * which CommonMark accepts regardless. */ function appendLinkRef(text, version) { - const refLine = `[${version}]: https://github.com/colbymchenry/codegraph/releases/tag/v${version}`; + // This fork publishes its own releases; an upstream URL points at a tag + // that does not exist here, so the `## [X.Y.Z]` heading links nowhere (#296). + const refLine = `[${version}]: https://github.com/ardelperal/codegraph-vba/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');