-
Notifications
You must be signed in to change notification settings - Fork 0
Release CodeTruss CLI v0.2.37 #20
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| // A release overwrote the previous release's heading once: CLI 0.2.36 replaced | ||
| // `## 0.2.35 — …` with its own heading, leaving 0.2.35's bullets orphaned under | ||
| // 0.2.36 and erasing 0.2.35 from the history entirely. Nothing caught it, because | ||
| // nothing read the changelog. These checks do. | ||
|
|
||
| const HEADING = /^## (?<version>\d+\.\d+\.\d+) — (?<date>\d{4}-\d{2}-\d{2})(?<suffix> \(unpublished\))?$/ | ||
| const UNRELEASED = /^## Unreleased$/ | ||
|
|
||
| function parseVersion(version) { | ||
| const [major, minor, patch] = version.split('.').map(Number) | ||
| return { major, minor, patch } | ||
| } | ||
|
|
||
| /** | ||
| * True when `newer` is the immediate semver successor of `older`: the next patch | ||
| * on the same minor, or the `.0` that opens the next minor or major. Anything | ||
| * else is a gap (a lost entry) or a reordering. | ||
| */ | ||
| function succeeds(newer, older) { | ||
| if (newer.major === older.major && newer.minor === older.minor) return newer.patch === older.patch + 1 | ||
| if (newer.major === older.major) return newer.minor === older.minor + 1 && newer.patch === 0 | ||
| return newer.major === older.major + 1 && newer.minor === 0 && newer.patch === 0 | ||
| } | ||
|
|
||
| /** | ||
| * Assert the changelog records `version` and that every release heading forms one | ||
| * unbroken descending chain. `changelog` is the file's raw text. | ||
| */ | ||
| export function assertChangelogPolicy(changelog, version) { | ||
| const lines = changelog.split('\n') | ||
| const headings = [] | ||
| for (const [index, line] of lines.entries()) { | ||
| const match = HEADING.exec(line) | ||
| if (match) headings.push({ ...match.groups, line: index + 1, ...parseVersion(match.groups.version) }) | ||
| } | ||
| if (headings.length === 0) throw new Error('CHANGELOG.md declares no release headings') | ||
|
|
||
| // Nothing may sit between the preamble and the first release except an | ||
| // optional, empty `## Unreleased`. Notes stranded above the newest release | ||
| // heading are notes no released version claims. | ||
| const firstHeadingIndex = headings[0].line - 1 | ||
| let sawUnreleased = false | ||
| for (let index = 0; index < firstHeadingIndex; index += 1) { | ||
| const line = lines[index] | ||
| if (UNRELEASED.test(line)) { | ||
| sawUnreleased = true | ||
| continue | ||
| } | ||
| if (line.startsWith('## ')) { | ||
| throw new Error(`CHANGELOG.md line ${index + 1}: unexpected section before the first release heading: ${line}`) | ||
| } | ||
| if (sawUnreleased && line.trim() !== '') { | ||
| throw new Error( | ||
| `CHANGELOG.md line ${index + 1}: "## Unreleased" still has content; move it into the release entry: ${line}`, | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| const seen = new Map() | ||
| for (const heading of headings) { | ||
| const previous = seen.get(heading.version) | ||
| if (previous !== undefined) { | ||
| throw new Error(`CHANGELOG.md declares version ${heading.version} twice (lines ${previous} and ${heading.line})`) | ||
| } | ||
| seen.set(heading.version, heading.line) | ||
| } | ||
|
|
||
| for (let index = 1; index < headings.length; index += 1) { | ||
| const newer = headings[index - 1] | ||
| const older = headings[index] | ||
| if (!succeeds(newer, older)) { | ||
| throw new Error( | ||
| `CHANGELOG.md release chain breaks between ${newer.version} (line ${newer.line}) and ${older.version} ` | ||
| + `(line ${older.line}): every released version must appear exactly once, in descending order, with no gaps. ` | ||
| + 'A version that was never published still needs its own "(unpublished)" entry.', | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| if (!seen.has(version)) { | ||
| throw new Error( | ||
| `CHANGELOG.md has no "## ${version} — <date>" heading; the release being built must document itself. ` | ||
| + `Newest entry is ${headings[0].version}.`, | ||
| ) | ||
| } | ||
| if (seen.get(version) !== headings[0].line) { | ||
| throw new Error(`CHANGELOG.md lists ${version} below a newer entry (${headings[0].version}); it must be the first release heading`) | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,87 @@ | ||
| import assert from 'node:assert/strict' | ||
| import { readFile } from 'node:fs/promises' | ||
| import { dirname, join, resolve } from 'node:path' | ||
| import { fileURLToPath } from 'node:url' | ||
| import { assertChangelogPolicy } from './changelog-policy.mjs' | ||
|
|
||
| const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), '..') | ||
|
|
||
| const preamble = [ | ||
| '# Changelog', | ||
| '', | ||
| 'CodeTruss CLI follows semantic versioning.', | ||
| '', | ||
| '## Unreleased', | ||
| '', | ||
| ].join('\n') | ||
|
|
||
| const changelog = (...entries) => `${preamble}${entries.map((entry) => `${entry}\n`).join('\n')}` | ||
| const entry = (version, date = '2026-08-07', body = '- a change') => `## ${version} — ${date}\n\n${body}` | ||
|
|
||
| // The shipped changelog and the version it documents must satisfy the policy. | ||
| const shipped = await readFile(join(packageDir, 'CHANGELOG.md'), 'utf8') | ||
| const pkg = JSON.parse(await readFile(join(packageDir, 'package.json'), 'utf8')) | ||
| assertChangelogPolicy(shipped, pkg.version) | ||
|
|
||
| // A well-formed chain passes, including the roll from one minor to the next. | ||
| assertChangelogPolicy(changelog(entry('0.2.1'), entry('0.2.0'), entry('0.1.1')), '0.2.1') | ||
| assertChangelogPolicy(changelog(entry('1.0.0'), entry('0.9.3'), entry('0.9.2')), '1.0.0') | ||
| // "(unpublished)" entries still count as links in the chain. | ||
| assertChangelogPolicy( | ||
| changelog(entry('0.2.2'), `## 0.2.1 — 2026-08-06 (unpublished)\n\n- skipped`, entry('0.2.0')), | ||
| '0.2.2', | ||
| ) | ||
|
|
||
| // (a) The version being released must have its own heading. | ||
| assert.throws( | ||
| () => assertChangelogPolicy(changelog(entry('0.2.1'), entry('0.2.0')), '0.2.2'), | ||
| /no "## 0\.2\.2 — <date>" heading/, | ||
| ) | ||
|
|
||
| // (b) The exact 0.2.36 regression: a release overwrites the previous heading, | ||
| // leaving a gap where 0.2.35 used to be. | ||
| assert.throws( | ||
| () => assertChangelogPolicy(changelog(entry('0.2.36'), entry('0.2.34'), entry('0.2.33')), '0.2.36'), | ||
| /release chain breaks between 0\.2\.36 .* and 0\.2\.34/, | ||
| ) | ||
|
|
||
| // (b) A version listed twice. | ||
| assert.throws( | ||
| () => assertChangelogPolicy(changelog(entry('0.2.2'), entry('0.2.2'), entry('0.2.1')), '0.2.2'), | ||
| /declares version 0\.2\.2 twice/, | ||
| ) | ||
|
|
||
| // (b) Headings out of descending order. | ||
| assert.throws( | ||
| () => assertChangelogPolicy(changelog(entry('0.2.0'), entry('0.2.1'), entry('0.1.1')), '0.2.0'), | ||
| /release chain breaks between 0\.2\.0 .* and 0\.2\.1/, | ||
| ) | ||
|
|
||
| // (b) The released version must be the newest entry, not buried mid-file. | ||
| assert.throws( | ||
| () => assertChangelogPolicy(changelog(entry('0.2.2'), entry('0.2.1'), entry('0.2.0')), '0.2.1'), | ||
| /lists 0\.2\.1 below a newer entry/, | ||
| ) | ||
|
|
||
| // (b) Notes stranded above the newest release heading belong to no version. | ||
| assert.throws( | ||
| () => assertChangelogPolicy( | ||
| `${preamble}- an orphaned bullet\n\n${entry('0.2.1')}\n\n${entry('0.2.0')}\n`, | ||
| '0.2.1', | ||
| ), | ||
| /"## Unreleased" still has content/, | ||
| ) | ||
|
|
||
| // (b) A non-release section wedged above the first release heading. | ||
| assert.throws( | ||
| () => assertChangelogPolicy( | ||
| `# Changelog\n\n## Notes\n\n${entry('0.2.1')}\n\n${entry('0.2.0')}\n`, | ||
| '0.2.1', | ||
| ), | ||
| /unexpected section before the first release heading/, | ||
| ) | ||
|
|
||
| // A changelog with no releases at all is not a changelog. | ||
| assert.throws(() => assertChangelogPolicy(preamble, '0.2.1'), /declares no release headings/) | ||
|
|
||
| process.stdout.write('changelog policy: chain, uniqueness, ordering, and self-documentation enforced\n') |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.