From e351b8aa4cf8ccb06210409fa06e5eb405f10e4e Mon Sep 17 00:00:00 2001 From: Owen Jones Date: Fri, 24 Jan 2025 14:18:18 +0000 Subject: [PATCH] Add helper for automatic changelog management Adds a script which: - validates the new version based on the version in the changelog - updates the changelog headings - gneerates release notes from the changelog --- .github/workflows/build-release.yml | 24 ++ .../scripts/changelog-release-helper.mjs | 251 ++++++++++++++++++ .../scripts/changelog-release-helper.test.mjs | 123 +++++++++ .github/workflows/scripts/package.json | 3 +- .gitignore | 1 + jest.config.mjs | 5 + 6 files changed, 406 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/scripts/changelog-release-helper.mjs create mode 100644 .github/workflows/scripts/changelog-release-helper.test.mjs diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index b38b0399ec..54fdbfe6b1 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -29,8 +29,32 @@ jobs: - name: Install dependencies run: npm ci + - name: Validate version + uses: actions/github-script@v7.0.1 + with: + script: | + const { validateVersion } = await import('${{ github.workspace }}/.github/workflows/scripts/changelog-release-helper.mjs') + + await validateVersion('${{ inputs.version }}') + - name: Update package version run: npm version --no-git-tag-version --workspace govuk-frontend ${{ inputs.version }} + - name: Update CHANGELOG + uses: actions/github-script@v7.0.1 + with: + script: | + const { updateChangelog } = await import('${{ github.workspace }}/.github/workflows/scripts/changelog-release-helper.mjs') + + await updateChangelog('${{ inputs.version }}') + + - name: Generate release notes + uses: actions/github-script@v7.0.1 + with: + script: | + const { generateReleaseNotes } = await import('${{ github.workspace }}/.github/workflows/scripts/changelog-release-helper.mjs') + + await generateReleaseNotes() + - name: Build release run: npm run build:release diff --git a/.github/workflows/scripts/changelog-release-helper.mjs b/.github/workflows/scripts/changelog-release-helper.mjs new file mode 100644 index 0000000000..d464bc7fc6 --- /dev/null +++ b/.github/workflows/scripts/changelog-release-helper.mjs @@ -0,0 +1,251 @@ +import { readFileSync, writeFileSync } from 'fs' + +import semver from 'semver' + +/** + * Validate a new version of GOV.UK Frontend + * + * Throws an error if any of the following are true: + * + * - The version can't be processed by semver and we therefore presume it isn't + * a valid semver + * - The version is less than the current version + * - The version increments the current version by more than one possible + * increment, eg: going from 3.1.0 to 5.0.0, 3.3.0 or 3.1.2 + * + * @param {string} newVersion + */ +export async function validateVersion(newVersion) { + const changelogLines = await getChangelogLines() + const previousReleaseLineIndex = getChangelogLineIndexes(changelogLines)[1] + + if (!semver.valid(newVersion)) { + throw new Error( + `New version number ${newVersion} could not be processed by Semver. Please ensure you are providing a valid semantic version` + ) + } + + // Convert the previous release heading into a processable semver + const previousReleaseNumber = convertVersionHeadingToSemver( + changelogLines[previousReleaseLineIndex] + ) + + // Check the new version against the old version. Firstly a quick check that + // the new one isn't less than the old one + if (semver.lte(newVersion, previousReleaseNumber)) { + throw new Error( + `New version number ${newVersion} is less than or equal to the most recent version (${previousReleaseNumber}). Please provide a newer version number` + ) + } + + // Get the version diff keyword (major, minor or patch) which we can use to + // help with validating the new version + const versionDiff = semver.diff(newVersion, previousReleaseNumber) + + if (versionDiff === 'major') { + newVersionIsOnlyIncrementingByOne( + newVersion, + previousReleaseNumber, + 'major' + ) + } else if (versionDiff === 'minor') { + newVersionIsOnlyIncrementingByOne( + newVersion, + previousReleaseNumber, + 'minor' + ) + } else if (versionDiff === 'patch') { + newVersionIsOnlyIncrementingByOne( + newVersion, + previousReleaseNumber, + 'patch' + ) + } + + console.log('No errors noted in the new version. We can proceed!') +} + +/** + * Update the changelog with a new version heading + * + * Inserts a new heading between the 'Unreleased' heading and the most recent + * content + * + * @param {string} newVersion + */ +export async function updateChangelog(newVersion) { + const changelogLines = await getChangelogLines() + const [startIndex, previousReleaseLineIndex] = + getChangelogLineIndexes(changelogLines) + + // Convert the previous release heading into a processable semver + const previousReleaseNumber = convertVersionHeadingToSemver( + changelogLines[previousReleaseLineIndex] + ) + const versionDiff = semver.diff(newVersion, previousReleaseNumber) + + if (!versionDiff) { + throw new Error('There was a problem') + } + + const newVersionTitle = buildNewReleaseTitle(newVersion, versionDiff) + const newChangelogLines = [].concat(changelogLines) + + newChangelogLines.splice(startIndex + 1, 0, '', newVersionTitle) + await writeFileSync('./CHANGELOG.md', newChangelogLines.join('\n')) +} + +/** + * Generates release notes from the most recent changelog + * + * Creates a text file 'release-notes-body' from the content between either the + * first release heading (default) or the 'Unreleased' heading and the following + * release heading + * + * @param {boolean} fromUnreleasedHeading + */ +export async function generateReleaseNotes(fromUnreleasedHeading = false) { + const changelogLines = await getChangelogLines() + const [startIndex, previousReleaseLineIndex] = getChangelogLineIndexes( + changelogLines, + fromUnreleasedHeading + ) + + const releaseNotes = changelogLines + .slice(startIndex + 1, previousReleaseLineIndex - 1) + .filter((value, index, arr) => { + if (value !== '') { + return true + } + if ( + arr[index + 1].startsWith('#') || + (index > 0 && arr[index - 1].startsWith('#')) + ) { + return true + } + return false + }) + .map((value) => { + const line = value.replace(/^\s+/, '') + return line.startsWith('##') ? line.substring(1) : line + }) + + await writeFileSync('./release-notes-body', releaseNotes.join('\n')) +} + +/** + * Get the changelog and split it into an array separated by lines + * + * @returns {Promise>} - Changelog split into an array by lines + */ +async function getChangelogLines() { + return (await readFileSync('./CHANGELOG.md', 'utf8')).split('\n') +} + +/** + * Gets the start and end headings in the changelog for processing by the + * exported functions + * + * @param {Array} changelogLines + * @param {boolean} fromUnreleasedHeading + * @returns {Array} - Indexes in the changelog identifying start and end lines + */ +function getChangelogLineIndexes(changelogLines, fromUnreleasedHeading = true) { + const versionTitleRegex = /^\s*#+\s+v\d+\.\d+\.\d+\s+\(.+\)$/i + const errorMessage = + 'There was a problem retrieving indexes from the changelog. This likely means that there is an issue with the changelog file itself. Please check it and try running this task again.' + const startIndex = findIndexOfFirstMatchingLine( + changelogLines, + fromUnreleasedHeading ? /^\s*#+\s+Unreleased\s*$/i : versionTitleRegex + ) + + if (!startIndex) { + throw new Error(errorMessage) + } + + const endIndex = findIndexOfFirstMatchingLine( + changelogLines, + versionTitleRegex, + fromUnreleasedHeading ? 0 : startIndex + 1 + ) + + if (!endIndex) { + throw new Error() + } + + return [startIndex, endIndex] +} + +/** + * Get the first matching line in the changelog that matches the passed regex + * + * @param {Array} changelogLines + * @param {RegExp} regExp + * @param {number} offset + * @returns {number|undefined} - Index in changeLogLines + */ +function findIndexOfFirstMatchingLine(changelogLines, regExp, offset = 0) { + return ( + changelogLines + .slice(offset) + .map((x, index) => (x.match(regExp) ? index : undefined)) + .filter((x) => x !== undefined) + .at(0) + offset + ) +} + +/** + * Convert a release heading into a semver + * + * @param {string} heading + * @returns {string} - Processed semver which we expect to have the format X.Y.Z + */ +function convertVersionHeadingToSemver(heading) { + return semver.valid(semver.coerce(heading)) +} + +/** + * Checks to see if the new version increments from the old version by one for + * its change type (major, minor or patch) and throws an error if it doesn't. + * Eg: if the current version is 4.3.12: + * + * - 4.3.13, 4.4.0 and 5.0.0 are valid + * - 4.3.14, 4.5.0, 6.0.0 and above for all aren't valid + * + * @param {string} newVersion + * @param {string} oldVersion + * @param {import('semver').ReleaseType} incType + */ +function newVersionIsOnlyIncrementingByOne(newVersion, oldVersion, incType) { + const correctIncrement = semver.inc(oldVersion, incType) + + if (!semver.satisfies(newVersion, `<=${correctIncrement}`)) { + throw new Error( + `New version number ${newVersion} is incrementing more than one for its increment type (${incType}). Please provide a version number than only increments by one from the current version. In this case, it's likely that your new version number should be: ${correctIncrement}` + ) + } +} + +/** + * Constructs a release heading for the changelog based on a passed semver and + * a diff keyword (major, minor or patch) + * + * @param {string} newVersion + * @param {string} incType + * @returns {string} - Constructed release heading of format '## v[semver (X.Y.Z)] ([Type] release)' + */ +function buildNewReleaseTitle(newVersion, incType) { + let rewordedIncType + + if (incType === 'major') { + rewordedIncType = 'Breaking' + } else if (incType === 'minor') { + rewordedIncType = 'Feature' + } else if (incType === 'patch') { + rewordedIncType = 'Fix' + } else { + rewordedIncType = incType + } + + return `## v${newVersion} (${rewordedIncType} release)` +} diff --git a/.github/workflows/scripts/changelog-release-helper.test.mjs b/.github/workflows/scripts/changelog-release-helper.test.mjs new file mode 100644 index 0000000000..da3da68958 --- /dev/null +++ b/.github/workflows/scripts/changelog-release-helper.test.mjs @@ -0,0 +1,123 @@ +import fs from 'fs' + +import { + validateVersion, + updateChangelog, + generateReleaseNotes +} from './changelog-release-helper.mjs' + +jest.mock('fs') + +describe('Changelog release helper', () => { + beforeEach(() => { + fs.readFileSync.mockReturnValue(` + ## Unreleased + + ### Fixes + + Bing bong + + ## v3.0.0 (Breaking release) + `) + }) + + describe('Validate version', () => { + it('runs normally if a valid new version is parsed to it', async () => { + await expect(validateVersion('3.1.0')).resolves.not.toThrow() + }) + + it('throws an error if an invalid semver is parsed', async () => { + await expect(validateVersion('pizza')).rejects.toThrow( + 'New version number pizza could not be processed by Semver. Please ensure you are providing a valid semantic version' + ) + }) + + it('throws an error if new version is less than old version', async () => { + await expect(validateVersion('2.11.0')).rejects.toThrow( + 'New version number 2.11.0 is less than or equal to the most recent version (3.0.0). Please provide a newer version number' + ) + }) + + it('throws an error if new version is more than one possible increment', async () => { + const increments = [ + { + badVersion: '5.0.0', + type: 'major', + goodVersion: '4.0.0' + }, + { + badVersion: '3.2.0', + type: 'minor', + goodVersion: '3.1.0' + }, + { + badVersion: '3.0.2', + type: 'patch', + goodVersion: '3.0.1' + } + ] + + for (const increment of increments) { + await expect(validateVersion(increment.badVersion)).rejects.toThrow( + `New version number ${increment.badVersion} is incrementing more than one for its increment type (${increment.type}). Please provide a version number than only increments by one from the current version. In this case, it's likely that your new version number should be: ${increment.goodVersion}` + ) + } + }) + }) + + describe('Update changelog', () => { + it('adds a new heading to the changelog for the new version', async () => { + await updateChangelog('3.1.0') + expect(fs.writeFileSync).toHaveBeenCalledWith( + './CHANGELOG.md', + expect.stringContaining('## v3.1.0 (Feature release)') + ) + }) + }) + + describe('Generate release notes', () => { + it('writes release notes from the changelog from the Unreleased heading', async () => { + // Pass 'true' here so that the function reads from the 'Unreleased' heading + await generateReleaseNotes(true) + expect(fs.writeFileSync).toHaveBeenCalledWith( + './release-notes-body', + expect.stringContaining('Bing bong') + ) + }) + + it('writes release notes from the changelog from the last version heading', async () => { + // re-mock the readFileSync return value as if we'd just run + // updateChangelog and the contents we wanted was between the new and + // current version headings + fs.readFileSync.mockReturnValue(` + ## Unreleased + + ## v3.1.0 (Feature release) + + ### Fixes + + Bing bong + + ## v3.0.0 (Breaking release) + `) + + await generateReleaseNotes() + expect(fs.writeFileSync).toHaveBeenCalledWith( + './release-notes-body', + expect.stringContaining('Bing bong') + ) + }) + + it('increases the heading levels from the changelog by one', async () => { + await generateReleaseNotes(true) + expect(fs.writeFileSync).toHaveBeenCalledWith( + './release-notes-body', + expect.stringContaining('## Fixes') + ) + expect(fs.writeFileSync).toHaveBeenCalledWith( + './release-notes-body', + expect.not.stringContaining('### Fixes') + ) + }) + }) +}) diff --git a/.github/workflows/scripts/package.json b/.github/workflows/scripts/package.json index 6f77445054..5969ad1898 100644 --- a/.github/workflows/scripts/package.json +++ b/.github/workflows/scripts/package.json @@ -10,6 +10,7 @@ "devDependencies": { "@govuk-frontend/lib": "*", "@govuk-frontend/stats": "*", - "outdent": "^0.8.0" + "outdent": "^0.8.0", + "semver": "^7.6.2" } } diff --git a/.gitignore b/.gitignore index 40e93e2b40..b7399fb81b 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ dist/ *.log *.tsbuildinfo *.zip +release-notes-body # Project lockfile only package-lock.json diff --git a/jest.config.mjs b/jest.config.mjs index 497a5dc380..dd56965875 100644 --- a/jest.config.mjs +++ b/jest.config.mjs @@ -140,6 +140,11 @@ export default { // Web server and browser required globalSetup: '@govuk-frontend/helpers/jest/browser/open.mjs', globalTeardown: '@govuk-frontend/helpers/jest/browser/close.mjs' + }, + { + ...config, + displayName: 'Workflow helper tests', + testMatch: ['**/workflows/scripts/*.test.{js,mjs}'] } ],