-
Notifications
You must be signed in to change notification settings - Fork 53
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(ng-dev/release): add marker between generated changelog entries (#…
…212) Adding a marker `<!-- CHANGELOG SPLIT MARKER -->` between changelog entries allows for easier automated modification of the changelog file, for things like removing prerelease changelog entries when publishing a new major, or automatically moving old changelog entries to an archive changelog. PR Close #212
- Loading branch information
1 parent
97cba5e
commit 284cb3d
Showing
9 changed files
with
380 additions
and
46 deletions.
There are no files selected for viewing
This file contains 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 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,117 @@ | ||
import {existsSync, readFileSync} from 'fs'; | ||
import {SemVer} from 'semver'; | ||
import {dedent} from '../../utils/testing/dedent'; | ||
import {getMockGitClient} from '../publish/test/test-utils/git-client-mock'; | ||
import {Changelog, splitMarker} from './changelog'; | ||
|
||
describe('Changelog', () => { | ||
let changelog: Changelog; | ||
|
||
beforeEach(() => { | ||
const gitClient = getMockGitClient( | ||
{owner: 'angular', name: 'dev-infra-test', mainBranchName: 'main'}, | ||
/* useSandboxGitClient */ false, | ||
); | ||
changelog = new Changelog(gitClient); | ||
}); | ||
|
||
it('throws an error if it cannot find the anchor containing the version for an entry', () => { | ||
expect(() => changelog.prependEntryToChangelog('does not have version <a> tag')).toThrow(); | ||
}); | ||
|
||
it('throws an error if it cannot determine the version for an entry', () => { | ||
expect(() => changelog.prependEntryToChangelog(createChangelogEntry('NotSemVer'))).toThrow(); | ||
}); | ||
|
||
it('concatenates the changelog entries into the changelog file with the split marker between', () => { | ||
changelog.prependEntryToChangelog(createChangelogEntry('1.0.0')); | ||
changelog.prependEntryToChangelog(createChangelogEntry('2.0.0')); | ||
changelog.prependEntryToChangelog(createChangelogEntry('3.0.0')); | ||
|
||
expect(readFileAsString(changelog.filePath)).toBe( | ||
dedent` | ||
<a name="3.0.0"></a> | ||
${splitMarker} | ||
<a name="2.0.0"></a> | ||
${splitMarker} | ||
<a name="1.0.0"></a> | ||
`.trim(), | ||
); | ||
|
||
changelog.moveEntriesPriorToVersionToArchive(new SemVer('3.0.0')); | ||
|
||
expect(readFileAsString(changelog.archiveFilePath)).toBe( | ||
dedent` | ||
<a name="2.0.0"></a> | ||
${splitMarker} | ||
<a name="1.0.0"></a> | ||
`.trim(), | ||
); | ||
|
||
expect(readFileAsString(changelog.filePath)).toBe(`<a name="3.0.0"></a>`); | ||
}); | ||
|
||
describe('adds entries to the changelog', () => { | ||
it('creates a new changelog file if one does not exist.', () => { | ||
expect(existsSync(changelog.filePath)).toBe(false); | ||
|
||
changelog.prependEntryToChangelog(createChangelogEntry('0.0.0')); | ||
expect(existsSync(changelog.filePath)).toBe(true); | ||
}); | ||
|
||
it('should not include a split marker when only one changelog entry is in the changelog.', () => { | ||
changelog.prependEntryToChangelog(createChangelogEntry('0.0.0')); | ||
|
||
expect(readFileAsString(changelog.filePath)).not.toContain(splitMarker); | ||
}); | ||
|
||
it('separates multiple changelog entries using a standard split marker', () => { | ||
for (let i = 0; i < 2; i++) { | ||
changelog.prependEntryToChangelog(createChangelogEntry(`0.0.${i}`)); | ||
} | ||
|
||
expect(readFileAsString(changelog.filePath)).toContain(splitMarker); | ||
}); | ||
}); | ||
|
||
describe('adds entries to the changelog archive', () => { | ||
it('only updates or creates the changelog archive if necessary', () => { | ||
changelog.prependEntryToChangelog(createChangelogEntry('1.0.0')); | ||
expect(existsSync(changelog.archiveFilePath)).toBe(false); | ||
|
||
changelog.moveEntriesPriorToVersionToArchive(new SemVer('1.0.0')); | ||
expect(existsSync(changelog.archiveFilePath)).toBe(false); | ||
|
||
changelog.moveEntriesPriorToVersionToArchive(new SemVer('2.0.0')); | ||
expect(existsSync(changelog.archiveFilePath)).toBe(true); | ||
}); | ||
|
||
it('from the primary changelog older than a provided version', () => { | ||
changelog.prependEntryToChangelog(createChangelogEntry('1.0.0', 'This is version 1')); | ||
changelog.prependEntryToChangelog(createChangelogEntry('2.0.0', 'This is version 2')); | ||
changelog.prependEntryToChangelog(createChangelogEntry('3.0.0', 'This is version 3')); | ||
|
||
changelog.moveEntriesPriorToVersionToArchive(new SemVer('3.0.0')); | ||
expect(readFileAsString(changelog.archiveFilePath)).toContain('version 1'); | ||
expect(readFileAsString(changelog.archiveFilePath)).toContain('version 2'); | ||
expect(readFileAsString(changelog.archiveFilePath)).not.toContain('version 3'); | ||
}); | ||
}); | ||
}); | ||
|
||
function readFileAsString(file: string) { | ||
return readFileSync(file, {encoding: 'utf8'}); | ||
} | ||
|
||
function createChangelogEntry(version: string, content = '') { | ||
return dedent` | ||
<a name="${version}"></a> | ||
${content} | ||
`; | ||
} |
This file contains 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,142 @@ | ||
import {existsSync, readFileSync, writeFileSync} from 'fs'; | ||
import {join} from 'path'; | ||
import * as semver from 'semver'; | ||
import {GitClient} from '../../utils/git/git-client'; | ||
|
||
/** Project-relative path for the changelog file. */ | ||
export const changelogPath = 'CHANGELOG.md'; | ||
|
||
/** Project-relative path for the changelog archive file. */ | ||
export const changelogArchivePath = 'CHANGELOG_ARCHIVE.md'; | ||
|
||
/** A marker used to split a CHANGELOG.md file into individual entries. */ | ||
export const splitMarker = '<!-- CHANGELOG SPLIT MARKER -->'; | ||
|
||
/** | ||
* A string to use between each changelog entry when joining them together. | ||
* | ||
* Since all every changelog entry's content is trimmed, when joining back together, two new lines | ||
* must be placed around the splitMarker to create a one line buffer around the comment in the | ||
* markdown. | ||
* i.e. | ||
* <changelog entry content> | ||
* | ||
* <!-- CHANGELOG SPLIT MARKER --> | ||
* | ||
* <changelog entry content> | ||
*/ | ||
const joinMarker = `\n\n${splitMarker}\n\n`; | ||
|
||
/** A RegExp matcher to extract the version of a changelog entry from the entry content. */ | ||
const versionAnchorMatcher = new RegExp(`<a name="(.*)"></a>`); | ||
|
||
/** An individual changelog entry. */ | ||
interface ChangelogEntry { | ||
content: string; | ||
version: semver.SemVer; | ||
} | ||
|
||
export class Changelog { | ||
/** The absolute path to the changelog file. */ | ||
readonly filePath = join(this.git.baseDir, changelogPath); | ||
/** The absolute path to the changelog archive file. */ | ||
readonly archiveFilePath = join(this.git.baseDir, changelogArchivePath); | ||
/** The changelog entries in the CHANGELOG.md file. */ | ||
private entries = this.getEntriesFor(this.filePath); | ||
/** | ||
* The changelog entries in the CHANGELOG_ARCHIVE.md file. | ||
* Delays reading the CHANGELOG_ARCHIVE.md file until it is actually used. | ||
*/ | ||
private get archiveEntries() { | ||
if (this._archiveEntries === undefined) { | ||
return (this._archiveEntries = this.getEntriesFor(this.archiveFilePath)); | ||
} | ||
return this._archiveEntries; | ||
} | ||
private _archiveEntries: undefined | ChangelogEntry[] = undefined; | ||
|
||
constructor(private git: GitClient) {} | ||
|
||
/** Prepend a changelog entry to the changelog. */ | ||
prependEntryToChangelog(entry: string) { | ||
this.entries.unshift(parseChangelogEntry(entry)); | ||
this.writeToChangelogFile(); | ||
} | ||
|
||
/** | ||
* Move all changelog entries from the CHANGELOG.md file for versions prior to the provided | ||
* version to the changelog archive. | ||
* | ||
* Versions should be used to determine which entries are moved to archive as versions are the | ||
* most accurate piece of context found within a changelog entry to determine its relationship to | ||
* other changelog entries. This allows for example, moving all changelog entries out of the | ||
* main changelog when a version moves out of support. | ||
*/ | ||
moveEntriesPriorToVersionToArchive(version: semver.SemVer) { | ||
[...this.entries].reverse().forEach((entry: ChangelogEntry) => { | ||
if (semver.lt(entry.version, version)) { | ||
this.archiveEntries.unshift(entry); | ||
this.entries.splice(this.entries.indexOf(entry), 1); | ||
} | ||
}); | ||
|
||
this.writeToChangelogFile(); | ||
if (this.archiveEntries.length) { | ||
this.writeToChangelogArchiveFile(); | ||
} | ||
} | ||
|
||
/** Update the changelog archive file with the known changelog archive entries. */ | ||
private writeToChangelogArchiveFile(): void { | ||
const changelogArchive = this.archiveEntries.map((entry) => entry.content).join(joinMarker); | ||
writeFileSync(this.archiveFilePath, changelogArchive); | ||
} | ||
|
||
/** Update the changelog file with the known changelog entries. */ | ||
private writeToChangelogFile(): void { | ||
const changelog = this.entries.map((entry) => entry.content).join(joinMarker); | ||
writeFileSync(this.filePath, changelog); | ||
} | ||
|
||
/** | ||
* Retrieve the changelog entries for the provide changelog path, if the file does not exist an | ||
* empty array is returned. | ||
*/ | ||
private getEntriesFor(path: string): ChangelogEntry[] { | ||
if (!existsSync(path)) { | ||
return []; | ||
} | ||
|
||
return ( | ||
readFileSync(path, {encoding: 'utf8'}) | ||
// Use the versionMarker as the separator for .split(). | ||
.split(splitMarker) | ||
// If the `split()` method finds the separator at the beginning or end of a string, it | ||
// includes an empty string at the respective locaiton, so we filter to remove all of these | ||
// potential empty strings. | ||
.filter((entry) => entry.trim().length !== 0) | ||
// Create a ChangelogEntry for each of the string entry. | ||
.map(parseChangelogEntry) | ||
); | ||
} | ||
} | ||
|
||
/** Parse the provided string into a ChangelogEntry object. */ | ||
function parseChangelogEntry(content: string): ChangelogEntry { | ||
const versionMatcherResult = versionAnchorMatcher.exec(content); | ||
if (versionMatcherResult === null) { | ||
throw Error(`Unable to determine version for changelog entry: ${content}`); | ||
} | ||
const version = semver.parse(versionMatcherResult[1]); | ||
|
||
if (version === null) { | ||
throw Error( | ||
`Unable to determine version for changelog entry, with tag: ${versionMatcherResult[1]}`, | ||
); | ||
} | ||
|
||
return { | ||
content: content.trim(), | ||
version, | ||
}; | ||
} |
This file contains 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 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 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 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 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.