From c64ed8e0eee2573a5beddf9c258cd3165e58e903 Mon Sep 17 00:00:00 2001 From: Owen Jones Date: Fri, 24 Jan 2025 14:15:58 +0000 Subject: [PATCH 1/4] Create workflow for building a new release --- .github/workflows/build-release.yml | 36 +++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 .github/workflows/build-release.yml diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml new file mode 100644 index 0000000000..b15f64eaed --- /dev/null +++ b/.github/workflows/build-release.yml @@ -0,0 +1,36 @@ +name: 'RELEASE: Build release' + +on: + workflow_dispatch: + inputs: + version: + description: 'New version number eg: 5.3.1' + required: true + type: string + +permissions: + contents: write + pull-requests: write + +jobs: + build-release: + name: Build release + runs-on: Ubuntu-22.04 + + steps: + - name: Checkout + uses: actions/checkout@v4.2.2 + + - name: Setup Node + uses: actions/setup-node@v4.2.0 + with: + node-version-file: .nvmrc + + - name: Install dependencies + run: npm ci + + - name: Update package version + run: npm version --no-git-tag-version --workspace govuk-frontend ${{ inputs.version }} + + - name: Build release + run: npm run build:release From bfa41d1c0384109d88cc8832d3c0c6f5b31bedbd Mon Sep 17 00:00:00 2001 From: Owen Jones Date: Fri, 24 Jan 2025 14:18:18 +0000 Subject: [PATCH 2/4] 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 | 248 ++++++++++++++++++ .../scripts/changelog-release-helper.test.mjs | 123 +++++++++ .github/workflows/scripts/package.json | 3 +- .github/workflows/scripts/tsconfig.json | 2 +- .gitignore | 1 + jest.config.mjs | 5 + 7 files changed, 404 insertions(+), 2 deletions(-) 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 b15f64eaed..e72cab1914 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') + + 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') + + 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') + + 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..1dd679912f --- /dev/null +++ b/.github/workflows/scripts/changelog-release-helper.mjs @@ -0,0 +1,248 @@ +import { readFileSync, writeFileSync } from 'fs' + +import semver from 'semver' + +const processingErrorMessage = + 'There was a problem processing information from the changelog. This likely means that there is an issue with the changelog content itself. Please check it and try running this task again.' + +/** + * 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 function validateVersion(newVersion) { + const changelogLines = 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] + ) + + if (!previousReleaseNumber) { + throw new Error(processingErrorMessage) + } + + // 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') { + checkVersionIncrement(newVersion, previousReleaseNumber, 'major') + } else if (versionDiff === 'minor') { + checkVersionIncrement(newVersion, previousReleaseNumber, 'minor') + } else if (versionDiff === 'patch') { + checkVersionIncrement(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 function updateChangelog(newVersion) { + const changelogLines = getChangelogLines() + const [startIndex, previousReleaseLineIndex] = + getChangelogLineIndexes(changelogLines) + + // Convert the previous release heading into a processable semver + const previousReleaseNumber = convertVersionHeadingToSemver( + changelogLines[previousReleaseLineIndex] + ) + + if (!previousReleaseNumber) { + throw new Error(processingErrorMessage) + } + + const versionDiff = semver.diff(newVersion, previousReleaseNumber) + + if (!versionDiff) { + throw new Error(processingErrorMessage) + } + + const newVersionTitle = buildNewReleaseTitle(newVersion, versionDiff) + + changelogLines.splice(startIndex + 1, 0, '', newVersionTitle) + writeFileSync('./CHANGELOG.md', changelogLines.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 function generateReleaseNotes(fromUnreleasedHeading = false) { + const changelogLines = 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 + }) + + writeFileSync('./release-notes-body', releaseNotes.join('\n')) +} + +/** + * Get the changelog and split it into an array separated by lines + * + * @returns {Array} - Changelog split into an array by lines + */ +function getChangelogLines() { + return 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 - Specifies if we get the first index from the 'Unreleased' heading or the first version heading we find + * @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 startIndex = findIndexOfFirstMatchingLine( + changelogLines, + fromUnreleasedHeading ? /^\s*#+\s+Unreleased\s*$/i : versionTitleRegex + ) + + if (!startIndex) { + throw new Error(processingErrorMessage) + } + + const endIndex = findIndexOfFirstMatchingLine( + changelogLines, + versionTitleRegex, + fromUnreleasedHeading ? 0 : startIndex + 1 + ) + + if (endIndex === -1) { + throw new Error(processingErrorMessage) + } + + 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} - Index in changeLogLines or -1 if we can't locate the index + */ +function findIndexOfFirstMatchingLine(changelogLines, regExp, offset = 0) { + const foundIndex = changelogLines + .slice(offset) + .map((x, index) => (x.match(regExp) ? index : undefined)) + .filter((x) => x !== undefined) + .at(0) + return foundIndex ? foundIndex + offset : -1 +} + +/** + * Convert a release heading into a semver + * + * @param {string} heading + * @returns {string|null} - Processed semver which we expect to have the format X.Y.Z + */ +function convertVersionHeadingToSemver(heading) { + const coercedHeading = semver.coerce(heading) + return coercedHeading && semver.valid(coercedHeading) +} + +/** + * 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 checkVersionIncrement(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..3f99039529 --- /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(() => { + jest.mocked(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', () => { + expect(() => validateVersion('3.1.0')).not.toThrow() + }) + + it('throws an error if an invalid semver is parsed', () => { + expect(() => validateVersion('pizza')).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', () => { + expect(() => validateVersion('2.11.0')).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', () => { + 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) { + expect(() => validateVersion(increment.badVersion)).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', () => { + 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', () => { + // Pass 'true' here so that the function reads from the 'Unreleased' heading + 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', () => { + // 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 + jest.mocked(fs.readFileSync).mockReturnValue(` + ## Unreleased + + ## v3.1.0 (Feature release) + + ### Fixes + + Bing bong + + ## v3.0.0 (Breaking release) + `) + + generateReleaseNotes() + expect(fs.writeFileSync).toHaveBeenCalledWith( + './release-notes-body', + expect.stringContaining('Bing bong') + ) + }) + + it('increases the heading levels from the changelog by one', () => { + 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/.github/workflows/scripts/tsconfig.json b/.github/workflows/scripts/tsconfig.json index 6c669d0663..54a7f4a81c 100644 --- a/.github/workflows/scripts/tsconfig.json +++ b/.github/workflows/scripts/tsconfig.json @@ -3,6 +3,6 @@ "include": ["**/*.mjs"], "compilerOptions": { "strict": true, - "types": ["@actions/github", "@octokit/rest", "node"] + "types": ["@actions/github", "@octokit/rest", "node", "jest"] } } 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}'] } ], From 84728ea9fa72867fce0b26cacece78b44aa4d4bc Mon Sep 17 00:00:00 2001 From: Owen Jones Date: Fri, 24 Jan 2025 14:18:49 +0000 Subject: [PATCH 3/4] Create pull request for generated build --- .github/workflows/build-release.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index e72cab1914..8cf1b523a9 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -58,3 +58,14 @@ jobs: - name: Build release run: npm run build:release + + - name: Create pull request + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + git config --global user.name '${{ github.actor }}' + git config --global user.email '${{ github.actor }}@digital.cabinet-office.gov.uk' + git commit -am "Release ${{ inputs.version }}" + git checkout -b release-${{ inputs.version }} + git push -u origin release-${{ inputs.version }} + gh pr create --base main --head release-${{ inputs.version }} --title "Release ${{ inputs.version }}" --body-file "release-notes-body" From 0a68ec62cbe70dbeadf03bd3d186f7fa43a727f8 Mon Sep 17 00:00:00 2001 From: Owen Jones Date: Fri, 24 Jan 2025 14:36:48 +0000 Subject: [PATCH 4/4] Update publishing docs --- docs/releasing/publishing.md | 52 +++++------------------------------- 1 file changed, 6 insertions(+), 46 deletions(-) diff --git a/docs/releasing/publishing.md b/docs/releasing/publishing.md index fafed7714a..84dee02654 100644 --- a/docs/releasing/publishing.md +++ b/docs/releasing/publishing.md @@ -11,57 +11,17 @@ See the [documentation on support branches](https://team-playbook.design-system. Developers should pair on releases. When remote working, it can be useful to be on a call together. -> [!IMPORTANT] -> Before starting to publish, make sure the developer running the commands: -> -> - has [nvm](https://github.com/nvm-sh/nvm) or [`asdf`](https://asdf-vm.com/guide/getting-started.html) -> on their machine to install the correct version of NodeJS and npm -> - has access to Bitwarden to retreive the credentials for publishing on npm +## Build the release -1. Check out the **main** branch and pull the latest changes. +1. Before running the build release workflow, make sure that the [`CHANGELOG`](/CHANGELOG.md) is up to date with the latest release notes under the 'Unreleased' heading. If it isn't, do so in a separate pull request before proceeding. -2. Ensure you're running the version of NodeJS matching [`.nvmrc`](/.nvmrc). +2. Open the actions tab on the `alphagov/govuk-frontend` repo. - - If you use NVM, run `nvm use` to set up the right version - - If you use another management system (like [`asdf`](https://asdf-vm.com/guide/getting-started.html)), compare the output of `node --version` and install the right one if necessary +3. Select the ["RELEASE: Build release" workflow](https://github.com/alphagov/govuk-frontend/actions/workflows/build-release.yml), provide the new version of GOV.UK Frontend you are releasing and run the workflow on the `main` branch. This will build the release and generate a pull request to review the new build. -3. Run `npm ci` to make sure you have the exact dependencies installed. +4. When reviewing the PR, check that the version numbers have been updated and that the compiled assets use this version number. -4. Pick a new version number according to the [versioning documentation](/docs/contributing/versioning.md) and apply it by running: - - ```shell - npm version --no-git-tag-version --workspace govuk-frontend - ``` - - ...where `` is the literal number without a 'v' in front of it. This step will update [`govuk-frontend`'s `package.json`](/packages/govuk-frontend/package.json) and project [`package-lock.json`](/package-lock.json) files. - - Do not commit the changes. - -5. Create and check out a new branch (`release-[version]`) - - ```shell - git switch -c "release-$(npm run version --silent --workspace govuk-frontend)" - ``` - -6. Update the [`CHANGELOG.md`](/CHANGELOG.md) by: - - - checking that the 'Unreleased' section of the changelog has the same content as the drafted release notes and updating it if necessary - - if the changelog has headings from [pre-releases](/docs/releasing/publishing-a-pre-release.md#publish-a-new-version-of-govuk-frontend), regroup the content under those headings in a single block - - adding a new heading with the version number and release type (`## ()`) after the 'Unreleased' heading. For example, '## 3.11.0 (Feature release)' - - saving your changes - -7. Run `npm run build-release` to: - - - build GOV.UK Frontend into [the package's `/dist`](/packages/govuk-frontend/dist) and [root `/dist`](/dist) directories - - commit the changes - - push a branch to GitHub - - You will now be prompted to continue or cancel. Check the details and enter `y` to continue. If something does not look right, press `N` to cancel the build and creation of the branch on GitHub. - -8. Create a pull request and copy the changelog text. - When reviewing the PR, check that the version numbers have been updated and that the compiled assets use this version number. - -9. Once a reviewer approves the pull request, merge it to **main**. +5. Once a reviewer approves the pull request, merge it to **main**. ## Publish a release to npm