Skip to content

Commit

Permalink
Merge pull request #5635 from alphagov/automate-build-release
Browse files Browse the repository at this point in the history
Automate Build release
  • Loading branch information
owenatgov authored Jan 28, 2025
2 parents b435127 + 0a68ec6 commit 1c7023b
Show file tree
Hide file tree
Showing 8 changed files with 457 additions and 48 deletions.
71 changes: 71 additions & 0 deletions .github/workflows/build-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
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/[email protected]

- name: Setup Node
uses: actions/[email protected]
with:
node-version-file: .nvmrc

- name: Install dependencies
run: npm ci

- name: Validate version
uses: actions/[email protected]
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/[email protected]
with:
script: |
const { updateChangelog } = await import('${{ github.workspace }}/.github/workflows/scripts/changelog-release-helper.mjs')
updateChangelog('${{ inputs.version }}')
- name: Generate release notes
uses: actions/[email protected]
with:
script: |
const { generateReleaseNotes } = await import('${{ github.workspace }}/.github/workflows/scripts/changelog-release-helper.mjs')
generateReleaseNotes()
- 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"
248 changes: 248 additions & 0 deletions .github/workflows/scripts/changelog-release-helper.mjs
Original file line number Diff line number Diff line change
@@ -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<string>} - 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<string>} changelogLines
* @param {boolean} fromUnreleasedHeading - Specifies if we get the first index from the 'Unreleased' heading or the first version heading we find
* @returns {Array<number>} - 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<string>} 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)`
}
Loading

0 comments on commit 1c7023b

Please sign in to comment.