Release notes were hand-edited after every release to turn GitHub's flat --generate-notes output into the curated Install + Feature/Enhancement/Fix format. Add scripts/release-notes/format-github-release-notes.cjs to do this deterministically (classifying each PR by its conventional-commit title prefix) and wire it into the pre-release, final, and hotfix release steps so it runs right after `gh release create --generate-notes`. Closes #625 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
4
.github/workflows/hotfix.yml
vendored
4
.github/workflows/hotfix.yml
vendored
@@ -376,6 +376,10 @@ jobs:
|
||||
--generate-notes \
|
||||
--latest
|
||||
fi
|
||||
# Reformat the auto-generated notes into the curated
|
||||
# Install + Feature/Enhancement/Fix format.
|
||||
node scripts/release-notes/format-github-release-notes.cjs \
|
||||
--tag "v${VERSION}" --latest --apply
|
||||
|
||||
- name: Create PR to merge hotfix back to main
|
||||
if: ${{ !inputs.dry_run }}
|
||||
|
||||
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
@@ -238,6 +238,10 @@ jobs:
|
||||
--title "v${PRE_VERSION}" \
|
||||
--generate-notes \
|
||||
--prerelease
|
||||
# Reformat the auto-generated notes into the curated
|
||||
# Install + Feature/Enhancement/Fix format.
|
||||
node scripts/release-notes/format-github-release-notes.cjs \
|
||||
--tag "v${PRE_VERSION}" --prerelease --apply
|
||||
|
||||
- name: Verify publish
|
||||
if: ${{ !inputs.dry_run }}
|
||||
@@ -382,6 +386,10 @@ jobs:
|
||||
--title "v${VERSION}" \
|
||||
--generate-notes \
|
||||
--latest
|
||||
# Reformat the auto-generated notes into the curated
|
||||
# Install + Feature/Enhancement/Fix format.
|
||||
node scripts/release-notes/format-github-release-notes.cjs \
|
||||
--tag "v${VERSION}" --latest --apply
|
||||
|
||||
- name: Clean up next dist-tag
|
||||
if: ${{ !inputs.dry_run }}
|
||||
|
||||
@@ -207,6 +207,27 @@ Fragments are consolidated into `CHANGELOG.md` at release time by the release wo
|
||||
|
||||
**Opt-out:** PRs with no user-facing impact (test refactors, lint config changes, CI tweaks, formatting-only changes) can add the `no-changelog` label. The lint honors it. When unsure whether a change is user-facing, **add the fragment**.
|
||||
|
||||
### Release notes formatting
|
||||
|
||||
GitHub release notes are generated automatically. The release and hotfix
|
||||
workflows first create the release with `gh release create --generate-notes`,
|
||||
then run `scripts/release-notes/format-github-release-notes.cjs --apply` to
|
||||
rewrite the body into the project's curated format: an **Install** block,
|
||||
followed by **What's Changed** grouped into **Feature** / **Enhancement** /
|
||||
**Fix** sections (classified by each PR's conventional-commit title prefix —
|
||||
`feat` → Feature, `fix` → Fix, everything else → Enhancement), then
|
||||
**New Contributors** and the **Full Changelog** link.
|
||||
|
||||
To re-format an existing release by hand (e.g. backfilling an older release):
|
||||
|
||||
```bash
|
||||
node scripts/release-notes/format-github-release-notes.cjs \
|
||||
--tag vX.Y.Z --repo open-gsd/gsd-core --apply
|
||||
```
|
||||
|
||||
Omit `--apply` to print the reformatted body to stdout for review without
|
||||
publishing.
|
||||
|
||||
## Documentation Updates — Update the Relevant Docs
|
||||
|
||||
If your PR adds, changes, deprecates, or removes user-visible behavior, you **must** update the relevant documentation in `docs/`. CI will fail any PR whose changeset fragment is typed `Added`, `Changed`, `Deprecated`, or `Removed` without also modifying at least one file under `docs/` ([#3213](https://github.com/open-gsd/gsd-core/issues/3213)).
|
||||
|
||||
256
scripts/release-notes/format-github-release-notes.cjs
Normal file
256
scripts/release-notes/format-github-release-notes.cjs
Normal file
@@ -0,0 +1,256 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const fs = require('fs');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
/**
|
||||
* Classify a What's-Changed bullet line into 'Feature', 'Fix', or 'Enhancement'.
|
||||
* @param {string} bulletLine - Full bullet line including the leading `* ` or `- ` marker.
|
||||
* @returns {'Feature'|'Fix'|'Enhancement'}
|
||||
*/
|
||||
function classifyTitle(bulletLine) {
|
||||
// Strip leading `* ` or `- ` marker
|
||||
const withoutMarker = bulletLine.replace(/^[*-]\s+/, '');
|
||||
|
||||
// Extract title = text before ` by @`
|
||||
const byIdx = withoutMarker.indexOf(' by @');
|
||||
const title = (byIdx !== -1 ? withoutMarker.slice(0, byIdx) : withoutMarker).trim();
|
||||
|
||||
if (/^feat(?:ure)?\s*(?:\(|!|:)/i.test(title)) return 'Feature';
|
||||
if (/^fix\s*(?:\(|!|:)/i.test(title)) return 'Fix';
|
||||
return 'Enhancement';
|
||||
}
|
||||
|
||||
/**
|
||||
* Reformat GitHub's auto-generated release notes into the repo's hand-curated format.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {string} opts.generatedBody - The raw GitHub-generated release body.
|
||||
* @param {string} opts.version - Version string (e.g. "1.3.0-rc.1"), no leading "v".
|
||||
* @param {boolean} opts.prerelease - Whether this is a pre-release.
|
||||
* @param {string} opts.packageName - npm package name (e.g. "@opengsd/gsd-core").
|
||||
* @returns {string} Formatted release body (no trailing newline).
|
||||
*/
|
||||
function formatReleaseNotes({ generatedBody, version, prerelease, packageName }) {
|
||||
const lines = generatedBody.split('\n');
|
||||
|
||||
const featureBullets = [];
|
||||
const fixBullets = [];
|
||||
const enhancementBullets = [];
|
||||
const newContributorBullets = [];
|
||||
let fullChangelogLine = null;
|
||||
|
||||
let inWhatsChanged = false;
|
||||
let inNewContributors = false;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Detect section headings
|
||||
if (trimmed === '## What\'s Changed') {
|
||||
inWhatsChanged = true;
|
||||
inNewContributors = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed === '## New Contributors') {
|
||||
inWhatsChanged = false;
|
||||
inNewContributors = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Full changelog line ends the What's Changed section
|
||||
if (trimmed.startsWith('**Full Changelog**:')) {
|
||||
inWhatsChanged = false;
|
||||
inNewContributors = false;
|
||||
fullChangelogLine = trimmed;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Any other `##` heading ends current section
|
||||
if (trimmed.startsWith('## ')) {
|
||||
inWhatsChanged = false;
|
||||
inNewContributors = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Collect bullets
|
||||
if (inWhatsChanged && (trimmed.startsWith('* ') || trimmed.startsWith('- '))) {
|
||||
const category = classifyTitle(trimmed);
|
||||
if (category === 'Feature') featureBullets.push(trimmed);
|
||||
else if (category === 'Fix') fixBullets.push(trimmed);
|
||||
else enhancementBullets.push(trimmed);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inNewContributors && (trimmed.startsWith('* ') || trimmed.startsWith('- '))) {
|
||||
newContributorBullets.push(trimmed);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Build Install block
|
||||
let installBlock;
|
||||
if (prerelease) {
|
||||
installBlock = [
|
||||
'## Install',
|
||||
'',
|
||||
'This pre-release is published to npm under the `next` dist-tag.',
|
||||
'',
|
||||
'```bash',
|
||||
`npm i ${packageName}@${version}`,
|
||||
'# or',
|
||||
`npm i ${packageName}@next`,
|
||||
'```',
|
||||
].join('\n');
|
||||
} else {
|
||||
installBlock = [
|
||||
'## Install',
|
||||
'',
|
||||
'```bash',
|
||||
`npm i ${packageName}@${version}`,
|
||||
'# or',
|
||||
`npm i ${packageName}@latest`,
|
||||
'```',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// Assemble groups (omit empty ones)
|
||||
const groups = [];
|
||||
|
||||
// Group A: Install
|
||||
groups.push(installBlock);
|
||||
|
||||
// Group B: What's Changed heading
|
||||
groups.push('## What\'s Changed');
|
||||
|
||||
// Group C: Features
|
||||
if (featureBullets.length > 0) {
|
||||
groups.push('### Feature\n' + featureBullets.join('\n'));
|
||||
}
|
||||
|
||||
// Group D: Enhancements
|
||||
if (enhancementBullets.length > 0) {
|
||||
groups.push('### Enhancement\n' + enhancementBullets.join('\n'));
|
||||
}
|
||||
|
||||
// Group E: Fixes
|
||||
if (fixBullets.length > 0) {
|
||||
groups.push('### Fix\n' + fixBullets.join('\n'));
|
||||
}
|
||||
|
||||
// Group F: New Contributors
|
||||
if (newContributorBullets.length > 0) {
|
||||
groups.push('## New Contributors\n' + newContributorBullets.join('\n'));
|
||||
}
|
||||
|
||||
// Group G: Full Changelog
|
||||
if (fullChangelogLine) {
|
||||
groups.push(fullChangelogLine);
|
||||
}
|
||||
|
||||
return groups.join('\n\n');
|
||||
}
|
||||
|
||||
// CLI entry point
|
||||
if (require.main === module) {
|
||||
try {
|
||||
const argv = process.argv.slice(2);
|
||||
|
||||
let tag = null;
|
||||
let repo = null;
|
||||
let packageName = null;
|
||||
let prerelease = null;
|
||||
let useStdin = false;
|
||||
let doApply = false;
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === '--tag') {
|
||||
tag = argv[++i];
|
||||
} else if (arg === '--repo') {
|
||||
repo = argv[++i];
|
||||
} else if (arg === '--package') {
|
||||
packageName = argv[++i];
|
||||
} else if (arg === '--prerelease') {
|
||||
prerelease = true;
|
||||
} else if (arg === '--latest') {
|
||||
prerelease = false;
|
||||
} else if (arg === '--stdin') {
|
||||
useStdin = true;
|
||||
} else if (arg === '--apply') {
|
||||
doApply = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Derive version from tag
|
||||
const version = tag ? tag.replace(/^v/, '') : null;
|
||||
|
||||
// Resolve package name if not provided
|
||||
if (!packageName) {
|
||||
const repoRoot = path.resolve(__dirname, '..', '..');
|
||||
const pkgJson = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8'));
|
||||
packageName = pkgJson.name;
|
||||
}
|
||||
|
||||
let generatedBody;
|
||||
|
||||
if (useStdin) {
|
||||
// Read from stdin
|
||||
if (!version) {
|
||||
throw new Error('--stdin mode requires --tag or --version to derive version');
|
||||
}
|
||||
if (prerelease === null) {
|
||||
throw new Error('--stdin mode requires --prerelease or --latest');
|
||||
}
|
||||
generatedBody = fs.readFileSync('/dev/stdin', 'utf8');
|
||||
} else {
|
||||
// Fetch from gh
|
||||
if (!tag) {
|
||||
throw new Error('--tag <tag> is required');
|
||||
}
|
||||
|
||||
const ghArgs = ['release', 'view', tag, '--json', 'body', '-q', '.body'];
|
||||
if (repo) ghArgs.push('--repo', repo);
|
||||
|
||||
generatedBody = execFileSync('gh', ghArgs, { encoding: 'utf8' });
|
||||
|
||||
// Determine prerelease if not forced
|
||||
if (prerelease === null) {
|
||||
try {
|
||||
const ghPreArgs = ['release', 'view', tag, '--json', 'isPrerelease', '-q', '.isPrerelease'];
|
||||
if (repo) ghPreArgs.push('--repo', repo);
|
||||
const result = execFileSync('gh', ghPreArgs, { encoding: 'utf8' }).trim();
|
||||
prerelease = result === 'true';
|
||||
} catch (_e) {
|
||||
// Final fallback: check if tag contains `-` after version digits
|
||||
prerelease = /-/.test(version);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const formatted = formatReleaseNotes({ generatedBody, version, prerelease, packageName });
|
||||
|
||||
if (doApply) {
|
||||
const tmpFile = path.join(os.tmpdir(), `release-notes-${Date.now()}.md`);
|
||||
fs.writeFileSync(tmpFile, formatted, 'utf8');
|
||||
try {
|
||||
const ghArgs = ['release', 'edit', tag, '--notes-file', tmpFile];
|
||||
if (repo) ghArgs.push('--repo', repo);
|
||||
execFileSync('gh', ghArgs, { encoding: 'utf8' });
|
||||
process.stderr.write(`Release notes updated for ${tag}\n`);
|
||||
} finally {
|
||||
fs.unlinkSync(tmpFile);
|
||||
}
|
||||
} else {
|
||||
process.stdout.write(formatted + '\n');
|
||||
}
|
||||
} catch (err) {
|
||||
process.stderr.write((err.message || String(err)) + '\n');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { formatReleaseNotes, classifyTitle };
|
||||
220
tests/format-github-release-notes.test.cjs
Normal file
220
tests/format-github-release-notes.test.cjs
Normal file
@@ -0,0 +1,220 @@
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const {
|
||||
formatReleaseNotes,
|
||||
classifyTitle,
|
||||
} = require('../scripts/release-notes/format-github-release-notes.cjs');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// classifyTitle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('classifyTitle', () => {
|
||||
test('returns Feature for feat(#39): title', () => {
|
||||
assert.equal(
|
||||
classifyTitle('* feat(#39): milestone-prefixed phase IDs by @trek-e in https://github.com/open-gsd/gsd-core/pull/565'),
|
||||
'Feature'
|
||||
);
|
||||
});
|
||||
|
||||
test('returns Feature for feat: title', () => {
|
||||
assert.equal(
|
||||
classifyTitle('* feat: some feature by @trek-e in https://github.com/open-gsd/gsd-core/pull/1'),
|
||||
'Feature'
|
||||
);
|
||||
});
|
||||
|
||||
test('returns Feature for feature(x): title', () => {
|
||||
assert.equal(
|
||||
classifyTitle('* feature(x): something by @trek-e in https://github.com/open-gsd/gsd-core/pull/2'),
|
||||
'Feature'
|
||||
);
|
||||
});
|
||||
|
||||
test('returns Fix for fix(#1): title', () => {
|
||||
assert.equal(
|
||||
classifyTitle('* fix(#1): some fix by @trek-e in https://github.com/open-gsd/gsd-core/pull/3'),
|
||||
'Fix'
|
||||
);
|
||||
});
|
||||
|
||||
test('returns Fix for fix: title', () => {
|
||||
assert.equal(
|
||||
classifyTitle('* fix: another fix by @trek-e in https://github.com/open-gsd/gsd-core/pull/4'),
|
||||
'Fix'
|
||||
);
|
||||
});
|
||||
|
||||
test('returns Enhancement for chore(#2): title', () => {
|
||||
assert.equal(
|
||||
classifyTitle('* chore(#2): some chore by @trek-e in https://github.com/open-gsd/gsd-core/pull/5'),
|
||||
'Enhancement'
|
||||
);
|
||||
});
|
||||
|
||||
test('returns Enhancement for docs: title', () => {
|
||||
assert.equal(
|
||||
classifyTitle('* docs: documentation update by @trek-e in https://github.com/open-gsd/gsd-core/pull/6'),
|
||||
'Enhancement'
|
||||
);
|
||||
});
|
||||
|
||||
test('returns Enhancement for [codex] Rebrand', () => {
|
||||
assert.equal(
|
||||
classifyTitle('* [codex] Rebrand public docs as GSD Core by @jeremymcs in https://github.com/open-gsd/gsd-core/pull/524'),
|
||||
'Enhancement'
|
||||
);
|
||||
});
|
||||
|
||||
test('returns Enhancement for plain title with no conventional prefix', () => {
|
||||
assert.equal(
|
||||
classifyTitle('* Main changes by @trek-e in https://github.com/open-gsd/gsd-core/pull/7'),
|
||||
'Enhancement'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// formatReleaseNotes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SAMPLE_BODY = `## What's Changed
|
||||
* feat(#39): milestone-prefixed phase IDs by @trek-e in https://github.com/open-gsd/gsd-core/pull/565
|
||||
* fix(#557): milestone erased on update by @trek-e in https://github.com/open-gsd/gsd-core/pull/563
|
||||
* chore(#2): update dependencies by @trek-e in https://github.com/open-gsd/gsd-core/pull/560
|
||||
|
||||
## New Contributors
|
||||
* @someone made their first contribution in https://github.com/open-gsd/gsd-core/pull/123
|
||||
|
||||
**Full Changelog**: https://github.com/open-gsd/gsd-core/compare/v1.2.0...v1.3.0-rc.1
|
||||
`;
|
||||
|
||||
describe('formatReleaseNotes', () => {
|
||||
test('prerelease=true: Install block contains @next and pre-release text', () => {
|
||||
const out = formatReleaseNotes({
|
||||
generatedBody: SAMPLE_BODY,
|
||||
version: '1.3.0-rc.1',
|
||||
prerelease: true,
|
||||
packageName: '@opengsd/gsd-core',
|
||||
});
|
||||
|
||||
assert.ok(out.startsWith('## Install'), 'should start with ## Install');
|
||||
assert.ok(out.includes('This pre-release is published to npm under the `next` dist-tag.'), 'should mention next dist-tag');
|
||||
assert.ok(out.includes('npm i @opengsd/gsd-core@1.3.0-rc.1'), 'should contain versioned install');
|
||||
assert.ok(out.includes('@next'), 'should contain @next tag');
|
||||
});
|
||||
|
||||
test('prerelease=true: sections appear in correct order', () => {
|
||||
const out = formatReleaseNotes({
|
||||
generatedBody: SAMPLE_BODY,
|
||||
version: '1.3.0-rc.1',
|
||||
prerelease: true,
|
||||
packageName: '@opengsd/gsd-core',
|
||||
});
|
||||
|
||||
const installIdx = out.indexOf('## Install');
|
||||
const whatsChangedIdx = out.indexOf("## What's Changed");
|
||||
const featureIdx = out.indexOf('### Feature');
|
||||
const enhancementIdx = out.indexOf('### Enhancement');
|
||||
const fixIdx = out.indexOf('### Fix');
|
||||
const newContribIdx = out.indexOf('## New Contributors');
|
||||
const fullChangelogIdx = out.indexOf('**Full Changelog**:');
|
||||
|
||||
assert.ok(installIdx < whatsChangedIdx, '## Install should precede ## Whats Changed');
|
||||
assert.ok(whatsChangedIdx < featureIdx, "## What's Changed should precede ### Feature");
|
||||
assert.ok(featureIdx < enhancementIdx, '### Feature should precede ### Enhancement');
|
||||
assert.ok(enhancementIdx < fixIdx, '### Enhancement should precede ### Fix');
|
||||
assert.ok(fixIdx < newContribIdx, '### Fix should precede ## New Contributors');
|
||||
assert.ok(newContribIdx < fullChangelogIdx, '## New Contributors should precede **Full Changelog**');
|
||||
});
|
||||
|
||||
test('prerelease=true: New Contributors block is preserved', () => {
|
||||
const out = formatReleaseNotes({
|
||||
generatedBody: SAMPLE_BODY,
|
||||
version: '1.3.0-rc.1',
|
||||
prerelease: true,
|
||||
packageName: '@opengsd/gsd-core',
|
||||
});
|
||||
|
||||
assert.ok(out.includes('## New Contributors'), 'should contain ## New Contributors');
|
||||
assert.ok(
|
||||
out.includes('* @someone made their first contribution'),
|
||||
'should preserve contributor bullet'
|
||||
);
|
||||
});
|
||||
|
||||
test('prerelease=true: ends with Full Changelog line and no trailing newline', () => {
|
||||
const out = formatReleaseNotes({
|
||||
generatedBody: SAMPLE_BODY,
|
||||
version: '1.3.0-rc.1',
|
||||
prerelease: true,
|
||||
packageName: '@opengsd/gsd-core',
|
||||
});
|
||||
|
||||
assert.ok(
|
||||
out.endsWith('**Full Changelog**: https://github.com/open-gsd/gsd-core/compare/v1.2.0...v1.3.0-rc.1'),
|
||||
'should end with Full Changelog line'
|
||||
);
|
||||
assert.ok(!out.endsWith('\n'), 'should have no trailing newline');
|
||||
});
|
||||
|
||||
test('prerelease=false: Install block uses @latest and omits pre-release sentence', () => {
|
||||
const out = formatReleaseNotes({
|
||||
generatedBody: SAMPLE_BODY,
|
||||
version: '1.3.0',
|
||||
prerelease: false,
|
||||
packageName: '@opengsd/gsd-core',
|
||||
});
|
||||
|
||||
assert.ok(out.includes('@latest'), 'should contain @latest');
|
||||
assert.ok(
|
||||
!out.includes('This pre-release is published'),
|
||||
'should not contain pre-release sentence'
|
||||
);
|
||||
assert.ok(out.includes('npm i @opengsd/gsd-core@1.3.0'), 'should contain versioned install');
|
||||
});
|
||||
|
||||
test('empty-section omission: only fix bullets → no Feature or Enhancement headers', () => {
|
||||
const fixOnlyBody = `## What's Changed
|
||||
* fix(#1): only a fix by @trek-e in https://github.com/open-gsd/gsd-core/pull/1
|
||||
|
||||
**Full Changelog**: https://github.com/open-gsd/gsd-core/compare/v1.0.0...v1.0.1
|
||||
`;
|
||||
|
||||
const out = formatReleaseNotes({
|
||||
generatedBody: fixOnlyBody,
|
||||
version: '1.0.1',
|
||||
prerelease: false,
|
||||
packageName: '@opengsd/gsd-core',
|
||||
});
|
||||
|
||||
assert.ok(out.includes('### Fix'), 'should contain ### Fix');
|
||||
assert.ok(!out.includes('### Feature'), 'should NOT contain ### Feature');
|
||||
assert.ok(!out.includes('### Enhancement'), 'should NOT contain ### Enhancement');
|
||||
});
|
||||
|
||||
test('ordering within a category is preserved', () => {
|
||||
const twoFeatBody = `## What's Changed
|
||||
* feat(#1): first feature by @trek-e in https://github.com/open-gsd/gsd-core/pull/1
|
||||
* feat(#2): second feature by @trek-e in https://github.com/open-gsd/gsd-core/pull/2
|
||||
|
||||
**Full Changelog**: https://github.com/open-gsd/gsd-core/compare/v1.0.0...v1.1.0
|
||||
`;
|
||||
|
||||
const out = formatReleaseNotes({
|
||||
generatedBody: twoFeatBody,
|
||||
version: '1.1.0',
|
||||
prerelease: false,
|
||||
packageName: '@opengsd/gsd-core',
|
||||
});
|
||||
|
||||
const firstIdx = out.indexOf('feat(#1): first feature');
|
||||
const secondIdx = out.indexOf('feat(#2): second feature');
|
||||
assert.ok(firstIdx !== -1, 'first feature bullet should be present');
|
||||
assert.ok(secondIdx !== -1, 'second feature bullet should be present');
|
||||
assert.ok(firstIdx < secondIdx, 'first feature should appear before second feature');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user