feat(release): auto-promote [Unreleased] into [<version>] on release workflow run (#436)
Fixes the silent-sparse-release-notes failure mode that surfaced on
v0.9.5: the Release workflow used to do a literal
`extract-release-notes.mjs <version>` lookup with an `[Unreleased]`
fallback. The fallback only triggered when the `[<version>]` block
DIDN'T exist at all — and in practice maintainers sometimes had a
sparse `[<version>]` block pre-populated (e.g. one early fix
documented before the rest of the work landed). The workflow then
extracted that sparse block, ignoring the much-larger `[Unreleased]`
section above it. Result: the published v0.9.5 release notes were
missing the shared MCP daemon, the per-file staleness banner, the
Objective-C indexing, AND the Mixed iOS/RN/Expo bridging.
The fix is a new `scripts/prepare-release.mjs` step that runs at the
start of the workflow:
Case A — `[<version>]` does not yet exist:
Rename `[Unreleased]` → `[<version>] - <today>`. Add a fresh
empty `[Unreleased]` above. The common path.
Case B — `[<version>]` exists AND `[Unreleased]` has content:
Merge `[Unreleased]`'s sub-sections (### Added / ### Fixed /
### Changed / ### Removed / ### Deprecated / ### Security) into
the corresponding sub-sections of `[<version>]`. Unmatched
sub-sections are appended. Then empty `[Unreleased]`.
Case C — `[Unreleased]` is empty:
No-op. Re-runs of the workflow are safe.
After the script runs, the workflow auto-commits + pushes the
CHANGELOG.md change back to main (with a `[skip ci]` tag in the
commit body) so future runs and human eyes both see the same
on-disk truth.
9 unit tests (`__tests__/prepare-release.test.ts`) cover all three
cases, idempotency, version-source precedence, and an
extract-release-notes.mjs integration check.
Workflow comment header rewritten to reflect the new flow.
Trigger reminder going forward: bump package.json. CHANGELOG entries
can live under `[Unreleased]` — the workflow takes care of moving
them.
937/939 existing tests pass (2 pre-existing skips); +9 new tests.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
5a4fcd56b7
commit
b77af782c5
@@ -2,25 +2,33 @@ name: Release
|
||||
|
||||
# Manually triggered ("Run workflow"). On trigger it:
|
||||
# 1. reads the version from package.json,
|
||||
# 2. builds a self-contained bundle for every platform (one runner — there's no
|
||||
# 2. promotes `## [Unreleased]` content into `## [<version>]` in
|
||||
# CHANGELOG.md (and commits + pushes that change back to main), so
|
||||
# the published release notes are never sparse just because the
|
||||
# maintainer didn't pre-stage the [<version>] block by hand,
|
||||
# 3. builds a self-contained bundle for every platform (one runner — there's no
|
||||
# native compilation, so cross-packaging is fine),
|
||||
# 3. creates the GitHub Release (tag v<version>) with all archives, using the
|
||||
# 4. creates the GitHub Release (tag v<version>) with all archives, using the
|
||||
# release notes from CHANGELOG.md,
|
||||
# 4. publishes the npm thin-installer (shim + per-platform packages).
|
||||
# 5. publishes the npm thin-installer (shim + per-platform packages).
|
||||
#
|
||||
# Before triggering: bump package.json and make sure CHANGELOG.md has the matching
|
||||
# section (## [<version>], or ## [Unreleased]). Set the NPM_TOKEN repo secret.
|
||||
# Before triggering: bump package.json. CHANGELOG.md entries can live under
|
||||
# `## [Unreleased]` — step 2 takes care of moving them. Set the NPM_TOKEN secret.
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
contents: write # create the GitHub Release + tag
|
||||
contents: write # create the GitHub Release + tag, push the CHANGELOG promote
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
# Default checkout is detached at a SHA; we need an actual branch
|
||||
# so the CHANGELOG-promote commit knows where to push.
|
||||
ref: ${{ github.ref }}
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
@@ -29,6 +37,32 @@ jobs:
|
||||
- name: Ensure zip/unzip
|
||||
run: sudo apt-get update -qq && sudo apt-get install -y -qq zip unzip
|
||||
|
||||
- name: Resolve version
|
||||
id: ver
|
||||
run: echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Promote [Unreleased] → [<version>] in CHANGELOG.md
|
||||
# Idempotent: a no-op if [Unreleased] is empty OR if the previous
|
||||
# run already moved everything. Auto-commit + push the change back
|
||||
# so the version block on main is the source of truth going
|
||||
# forward (and so subsequent extract-release-notes.mjs calls
|
||||
# surface the full content even if this run is re-triggered).
|
||||
run: |
|
||||
set -euo pipefail
|
||||
V="${{ steps.ver.outputs.version }}"
|
||||
before=$(git rev-parse HEAD)
|
||||
node scripts/prepare-release.mjs "$V"
|
||||
if git diff --quiet -- CHANGELOG.md; then
|
||||
echo "CHANGELOG.md unchanged — nothing to commit."
|
||||
else
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add CHANGELOG.md
|
||||
git commit -m "docs(changelog): promote [Unreleased] into [${V}]" -m "[skip ci] Auto-generated by Release workflow."
|
||||
# Push to the branch the workflow was triggered on (main).
|
||||
git push origin "HEAD:${GITHUB_REF#refs/heads/}"
|
||||
fi
|
||||
|
||||
- name: Build all platform bundles
|
||||
run: |
|
||||
for t in darwin-arm64 darwin-x64 linux-x64 linux-arm64 win32-x64 win32-arm64; do
|
||||
@@ -43,11 +77,10 @@ jobs:
|
||||
( cd release && sha256sum codegraph-* > SHA256SUMS )
|
||||
cat release/SHA256SUMS
|
||||
|
||||
- name: Resolve version
|
||||
id: ver
|
||||
run: echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Release notes from CHANGELOG.md
|
||||
# The [<version>] block was guaranteed-populated by the
|
||||
# "Promote" step above, so the [Unreleased] fallback should
|
||||
# never be needed in practice. Kept for defense-in-depth.
|
||||
run: |
|
||||
V="${{ steps.ver.outputs.version }}"
|
||||
node scripts/extract-release-notes.mjs "$V" > notes.md 2>/dev/null \
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Unit tests for `scripts/prepare-release.mjs`.
|
||||
*
|
||||
* The script reads CHANGELOG.md and package.json from `process.cwd()`,
|
||||
* so the tests run it via `node` in a temp directory after staging
|
||||
* those files. Real script, real fs — keeps the test honest about what
|
||||
* the workflow will actually do.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
|
||||
const SCRIPT = path.resolve(__dirname, '..', 'scripts', 'prepare-release.mjs');
|
||||
|
||||
function run(cwd: string, ...args: string[]) {
|
||||
const out = execFileSync('node', [SCRIPT, ...args], { cwd, encoding: 'utf8' });
|
||||
return out.trim();
|
||||
}
|
||||
|
||||
function setup(changelog: string, version = '1.2.3') {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'prepare-release-'));
|
||||
fs.writeFileSync(path.join(dir, 'CHANGELOG.md'), changelog);
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', version }));
|
||||
return dir;
|
||||
}
|
||||
|
||||
const HEADER = `# Changelog
|
||||
|
||||
Some intro.
|
||||
|
||||
`;
|
||||
|
||||
describe('prepare-release.mjs', () => {
|
||||
let dir: string;
|
||||
afterEach(() => {
|
||||
if (dir && fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('Case A: [version] block does not yet exist', () => {
|
||||
it('renames [Unreleased] to [version] - <today> and adds a fresh empty [Unreleased]', () => {
|
||||
dir = setup(
|
||||
HEADER +
|
||||
`## [Unreleased]\n\n### Added\n- New feature foo\n- New feature bar\n\n### Fixed\n- Fixed thing\n\n## [1.2.2] - 2026-01-01\n\n### Added\n- Old entry\n`,
|
||||
);
|
||||
const out = run(dir);
|
||||
expect(out).toMatch(/renamed \[Unreleased\] to \[1\.2\.3\]/);
|
||||
const result = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8');
|
||||
|
||||
// [Unreleased] is now empty and at the top.
|
||||
expect(result).toMatch(/## \[Unreleased\]\n\n\n## \[1\.2\.3\]/);
|
||||
// [1.2.3] gets a date.
|
||||
expect(result).toMatch(/## \[1\.2\.3\] - \d{4}-\d{2}-\d{2}/);
|
||||
// Promoted content lives under [1.2.3].
|
||||
const v123Section = result.split('## [1.2.3]')[1].split('## [1.2.2]')[0];
|
||||
expect(v123Section).toContain('### Added');
|
||||
expect(v123Section).toContain('- New feature foo');
|
||||
expect(v123Section).toContain('- New feature bar');
|
||||
expect(v123Section).toContain('### Fixed');
|
||||
expect(v123Section).toContain('- Fixed thing');
|
||||
// [1.2.2] is intact.
|
||||
expect(result).toContain('## [1.2.2] - 2026-01-01');
|
||||
expect(result).toContain('- Old entry');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Case B: [version] already exists AND [Unreleased] has content', () => {
|
||||
it('merges Unreleased sub-sections into the matching [version] sub-sections', () => {
|
||||
// The v0.9.5 scenario verbatim: sparse [0.9.5] with two Fixed
|
||||
// entries, full [Unreleased] above it with Added + more Fixed.
|
||||
dir = setup(
|
||||
HEADER +
|
||||
`## [Unreleased]\n\n### Added\n- Big feature 1\n- Big feature 2\n\n### Fixed\n- Watcher fix\n- Worktree fix\n\n## [1.2.3] - 2026-02-02\n\n### Fixed\n- Old fix A\n- Old fix B\n\n## [1.2.2] - 2026-01-01\n`,
|
||||
);
|
||||
const out = run(dir);
|
||||
expect(out).toMatch(/merged \d+ Unreleased entries/);
|
||||
const result = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8');
|
||||
|
||||
// [Unreleased] is emptied.
|
||||
const unrelSection = result.split('## [Unreleased]')[1].split('## [1.2.3]')[0];
|
||||
expect(unrelSection.trim()).toBe('');
|
||||
|
||||
// [1.2.3] now has BOTH the original Fixed entries AND the
|
||||
// Unreleased Fixed entries, plus the new Added sub-section.
|
||||
const v123Section = result.split('## [1.2.3]')[1].split('## [1.2.2]')[0];
|
||||
expect(v123Section).toContain('### Added');
|
||||
expect(v123Section).toContain('- Big feature 1');
|
||||
expect(v123Section).toContain('- Big feature 2');
|
||||
expect(v123Section).toContain('### Fixed');
|
||||
expect(v123Section).toContain('- Old fix A');
|
||||
expect(v123Section).toContain('- Old fix B');
|
||||
expect(v123Section).toContain('- Watcher fix');
|
||||
expect(v123Section).toContain('- Worktree fix');
|
||||
// Date on [1.2.3] is preserved (we don't re-stamp it).
|
||||
expect(result).toContain('## [1.2.3] - 2026-02-02');
|
||||
});
|
||||
|
||||
it('appends sub-sections that exist only in [Unreleased] to the [version] block', () => {
|
||||
dir = setup(
|
||||
HEADER +
|
||||
`## [Unreleased]\n\n### Security\n- CVE patch\n\n## [1.2.3] - 2026-02-02\n\n### Fixed\n- Old fix\n`,
|
||||
);
|
||||
run(dir);
|
||||
const result = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8');
|
||||
const v123 = result.split('## [1.2.3]')[1];
|
||||
expect(v123).toContain('### Fixed');
|
||||
expect(v123).toContain('- Old fix');
|
||||
expect(v123).toContain('### Security');
|
||||
expect(v123).toContain('- CVE patch');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Case C: [Unreleased] has no entries', () => {
|
||||
it('is a no-op when [Unreleased] is empty', () => {
|
||||
dir = setup(HEADER + `## [Unreleased]\n\n## [1.2.3] - 2026-02-02\n\n### Fixed\n- thing\n`);
|
||||
const before = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8');
|
||||
const out = run(dir);
|
||||
expect(out).toMatch(/nothing to do/);
|
||||
const after = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8');
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
|
||||
it('is a no-op when [Unreleased] has only sub-section headings with no bullets', () => {
|
||||
dir = setup(
|
||||
HEADER + `## [Unreleased]\n\n### Added\n\n### Fixed\n\n## [1.2.3] - 2026-02-02\n`,
|
||||
);
|
||||
const before = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8');
|
||||
const out = run(dir);
|
||||
expect(out).toMatch(/nothing to do/);
|
||||
const after = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8');
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('idempotency', () => {
|
||||
it('running twice produces the same output as running once', () => {
|
||||
dir = setup(
|
||||
HEADER +
|
||||
`## [Unreleased]\n\n### Added\n- Thing A\n\n## [1.2.2] - 2026-01-01\n\n### Added\n- Old\n`,
|
||||
);
|
||||
run(dir); // first run promotes
|
||||
const afterFirst = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8');
|
||||
const out2 = run(dir); // second run should be a no-op
|
||||
const afterSecond = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8');
|
||||
expect(out2).toMatch(/nothing to do/);
|
||||
expect(afterSecond).toBe(afterFirst);
|
||||
});
|
||||
});
|
||||
|
||||
describe('version source', () => {
|
||||
it('reads the target version from package.json by default', () => {
|
||||
dir = setup(HEADER + `## [Unreleased]\n\n### Added\n- x\n`, '9.9.9');
|
||||
run(dir);
|
||||
const result = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8');
|
||||
expect(result).toContain('## [9.9.9]');
|
||||
});
|
||||
|
||||
it('accepts an explicit version argument that overrides package.json', () => {
|
||||
dir = setup(HEADER + `## [Unreleased]\n\n### Added\n- x\n`, '9.9.9');
|
||||
run(dir, '5.5.5');
|
||||
const result = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8');
|
||||
expect(result).toContain('## [5.5.5]');
|
||||
expect(result).not.toContain('## [9.9.9]');
|
||||
});
|
||||
});
|
||||
|
||||
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
|
||||
// promoted entries.
|
||||
dir = setup(
|
||||
HEADER +
|
||||
`## [Unreleased]\n\n### Added\n- Feature A\n- Feature B\n\n### Fixed\n- Bug fix\n\n## [1.2.2] - 2026-01-01\n`,
|
||||
);
|
||||
run(dir);
|
||||
|
||||
const extractor = path.resolve(__dirname, '..', 'scripts', 'extract-release-notes.mjs');
|
||||
const notes = execFileSync('node', [extractor, '1.2.3'], { cwd: dir, encoding: 'utf8' });
|
||||
expect(notes).toContain('### Added');
|
||||
expect(notes).toContain('Feature A');
|
||||
expect(notes).toContain('Feature B');
|
||||
expect(notes).toContain('### Fixed');
|
||||
expect(notes).toContain('Bug fix');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,246 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Promote `## [Unreleased]` content into `## [<version>]` in CHANGELOG.md
|
||||
* so the release.yml workflow's `extract-release-notes.mjs <version>` call
|
||||
* picks up everything that landed since the last release.
|
||||
*
|
||||
* **Why this exists:** the release workflow used to do a literal
|
||||
* `extract-release-notes.mjs <version>` lookup with an `[Unreleased]`
|
||||
* fallback. The fallback only triggers if the `[<version>]` block
|
||||
* doesn't exist at all — and in practice maintainers sometimes had a
|
||||
* sparse `[<version>]` block pre-populated (e.g. one early fix
|
||||
* documented before the rest of the work landed). The workflow then
|
||||
* extracted that sparse block, ignoring the much larger `[Unreleased]`
|
||||
* section above it — so the published release notes were missing most
|
||||
* of what shipped. See v0.9.5 for the canonical post-mortem.
|
||||
*
|
||||
* **What it does**, idempotently:
|
||||
*
|
||||
* Case A — `[<version>]` does not exist yet:
|
||||
* Rename the `[Unreleased]` header to `[<version>] - <YYYY-MM-DD>`
|
||||
* and add a fresh empty `## [Unreleased]` block above it. This is
|
||||
* the common case.
|
||||
*
|
||||
* Case B — `[<version>]` exists AND `[Unreleased]` has content:
|
||||
* Merge `[Unreleased]`'s sub-sections (### Added / ### Fixed /
|
||||
* ### Changed / ### Removed / ### Deprecated / ### Security) into
|
||||
* the corresponding sub-sections of `[<version>]`. Unmatched
|
||||
* sub-sections are appended to `[<version>]`. The `[Unreleased]`
|
||||
* block is then emptied.
|
||||
*
|
||||
* Case C — `[Unreleased]` has no content:
|
||||
* No-op. Exit 0. Re-runs of the workflow are safe.
|
||||
*
|
||||
* **Where the date comes from:** for Case A, `<YYYY-MM-DD>` is the
|
||||
* UTC date at run time. Matches the existing CHANGELOG convention.
|
||||
*
|
||||
* **Usage:**
|
||||
*
|
||||
* node scripts/prepare-release.mjs # reads version from package.json
|
||||
* node scripts/prepare-release.mjs 1.2.3 # explicit version
|
||||
*
|
||||
* **Output:**
|
||||
*
|
||||
* Writes CHANGELOG.md in place. Prints a summary line to stdout
|
||||
* like `prepare-release: 0.9.5 — promoted 6 Unreleased entries`.
|
||||
* Exits non-zero on parse failures.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const CHANGELOG_PATH = resolve(process.cwd(), 'CHANGELOG.md');
|
||||
|
||||
function readPackageVersion() {
|
||||
const pkg = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf8'));
|
||||
if (!pkg.version) throw new Error('package.json has no "version" field');
|
||||
return pkg.version;
|
||||
}
|
||||
|
||||
function todayUtcIsoDate() {
|
||||
// YYYY-MM-DD in UTC. Matches the CHANGELOG's existing convention
|
||||
// (the existing dated entries don't disclose a timezone, but UTC is
|
||||
// stable across runners and is what the workflow's runner produces
|
||||
// by default anyway).
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Split the CHANGELOG into a header preface + an ordered list of
|
||||
* version blocks `{ header, body[] }`, preserving line content
|
||||
* verbatim so we can re-join without surprises.
|
||||
*/
|
||||
function parseChangelog(text) {
|
||||
const lines = text.split('\n');
|
||||
const versionHeaderRe = /^## \[([^\]]+)\](?:\s+-\s+(.+))?\s*$/;
|
||||
const preface = [];
|
||||
const blocks = []; // { header: string, name: string, body: string[] }
|
||||
let cur = null;
|
||||
for (const line of lines) {
|
||||
const m = line.match(versionHeaderRe);
|
||||
if (m) {
|
||||
if (cur) blocks.push(cur);
|
||||
cur = { header: line, name: m[1], date: m[2] ?? null, body: [] };
|
||||
} else if (cur) {
|
||||
cur.body.push(line);
|
||||
} else {
|
||||
preface.push(line);
|
||||
}
|
||||
}
|
||||
if (cur) blocks.push(cur);
|
||||
return { preface, blocks };
|
||||
}
|
||||
|
||||
function joinChangelog({ preface, blocks }) {
|
||||
const parts = [preface.join('\n')];
|
||||
for (const b of blocks) {
|
||||
// Reconstruct: header + body. The block body INCLUDES the blank
|
||||
// line after the header (it was captured verbatim).
|
||||
parts.push([b.header, ...b.body].join('\n'));
|
||||
}
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a block body into ordered sub-sections keyed by their
|
||||
* `### Heading`. Lines before the first `### Heading` go in
|
||||
* `leading`. Preserves the original (line-array) body inside each
|
||||
* sub-section so we can splice cleanly when merging.
|
||||
*/
|
||||
function splitSubsections(body) {
|
||||
const subsectionRe = /^### (\w+)\s*$/;
|
||||
const leading = [];
|
||||
const subs = []; // { heading: 'Added' | 'Fixed' | …, headerLine: string, body: string[] }
|
||||
let cur = null;
|
||||
for (const line of body) {
|
||||
const m = line.match(subsectionRe);
|
||||
if (m) {
|
||||
if (cur) subs.push(cur);
|
||||
cur = { heading: m[1], headerLine: line, body: [] };
|
||||
} else if (cur) {
|
||||
cur.body.push(line);
|
||||
} else {
|
||||
leading.push(line);
|
||||
}
|
||||
}
|
||||
if (cur) subs.push(cur);
|
||||
return { leading, subs };
|
||||
}
|
||||
|
||||
function rebuildBody({ leading, subs }) {
|
||||
const parts = [];
|
||||
if (leading.length) parts.push(leading.join('\n'));
|
||||
for (const s of subs) {
|
||||
parts.push([s.headerLine, ...s.body].join('\n'));
|
||||
}
|
||||
return parts.join('\n').split('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true when the block has any meaningful entries (a bullet line
|
||||
* starting with `-`, `*`, or a digit) — vs. being empty / just
|
||||
* whitespace / just sub-section headers with nothing under them.
|
||||
*/
|
||||
function blockHasContent(body) {
|
||||
for (const line of body) {
|
||||
if (/^\s*([-*]|\d+\.)\s+/.test(line)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trim trailing blank lines from an array of lines, then return.
|
||||
* Keeps the output tidy when merging.
|
||||
*/
|
||||
function trimTrailingBlank(arr) {
|
||||
let i = arr.length;
|
||||
while (i > 0 && /^\s*$/.test(arr[i - 1])) i--;
|
||||
return arr.slice(0, i);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const versionArg = process.argv[2];
|
||||
const version = versionArg || readPackageVersion();
|
||||
|
||||
const text = readFileSync(CHANGELOG_PATH, 'utf8');
|
||||
const parsed = parseChangelog(text);
|
||||
|
||||
const unrelIdx = parsed.blocks.findIndex((b) => b.name === 'Unreleased');
|
||||
const verIdx = parsed.blocks.findIndex((b) => b.name === version);
|
||||
|
||||
if (unrelIdx === -1) {
|
||||
console.log(`prepare-release: no [Unreleased] block — nothing to do`);
|
||||
return;
|
||||
}
|
||||
|
||||
const unrel = parsed.blocks[unrelIdx];
|
||||
if (!blockHasContent(unrel.body)) {
|
||||
console.log(`prepare-release: [Unreleased] is empty — nothing to do`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (verIdx === -1) {
|
||||
// Case A — promote Unreleased → [version].
|
||||
const today = todayUtcIsoDate();
|
||||
const promoted = {
|
||||
header: `## [${version}] - ${today}`,
|
||||
name: version,
|
||||
date: today,
|
||||
body: trimTrailingBlank(unrel.body).concat(['']), // single trailing blank
|
||||
};
|
||||
const emptied = {
|
||||
header: `## [Unreleased]`,
|
||||
name: 'Unreleased',
|
||||
date: null,
|
||||
body: ['', ''], // two blank lines for the next round of entries
|
||||
};
|
||||
parsed.blocks.splice(unrelIdx, 1, emptied, promoted);
|
||||
writeFileSync(CHANGELOG_PATH, joinChangelog(parsed));
|
||||
console.log(`prepare-release: ${version} — renamed [Unreleased] to [${version}] - ${today}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Case B — merge Unreleased sub-sections into the existing
|
||||
// [version] sub-sections. New sub-section headings encountered in
|
||||
// Unreleased that don't exist in [version] get appended.
|
||||
const ver = parsed.blocks[verIdx];
|
||||
const unrelSubs = splitSubsections(unrel.body);
|
||||
const verSubs = splitSubsections(ver.body);
|
||||
|
||||
let merged = 0;
|
||||
for (const us of unrelSubs.subs) {
|
||||
const target = verSubs.subs.find((s) => s.heading === us.heading);
|
||||
const usBody = trimTrailingBlank(us.body);
|
||||
if (usBody.length === 0) continue;
|
||||
if (target) {
|
||||
// Append Unreleased's entries to the end of the version's matching
|
||||
// sub-section, keeping their original ordering. Insert a separating
|
||||
// blank line if the existing sub-section doesn't already end in one.
|
||||
const existing = trimTrailingBlank(target.body);
|
||||
const sep = existing.length && !/^\s*$/.test(existing[existing.length - 1]) ? [''] : [];
|
||||
target.body = existing.concat(sep, usBody, ['']);
|
||||
} else {
|
||||
// Append the whole sub-section to the end.
|
||||
verSubs.subs.push({
|
||||
heading: us.heading,
|
||||
headerLine: us.headerLine,
|
||||
body: usBody.concat(['']),
|
||||
});
|
||||
}
|
||||
merged += usBody.filter((l) => /^\s*([-*]|\d+\.)\s+/.test(l)).length;
|
||||
}
|
||||
|
||||
ver.body = rebuildBody(verSubs);
|
||||
// Empty out Unreleased.
|
||||
unrel.body = ['', ''];
|
||||
|
||||
writeFileSync(CHANGELOG_PATH, joinChangelog(parsed));
|
||||
console.log(`prepare-release: ${version} — merged ${merged} Unreleased entries into existing [${version}] block`);
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (err) {
|
||||
console.error(`prepare-release: ${err?.message ?? err}`);
|
||||
process.exit(1);
|
||||
}
|
||||
Reference in New Issue
Block a user