diff --git a/.github/actions/release-notes/action.yaml b/.github/actions/release-notes/action.yaml new file mode 100644 index 00000000..f5cc71e5 --- /dev/null +++ b/.github/actions/release-notes/action.yaml @@ -0,0 +1,32 @@ +name: "Release notes sync" +description: "Fetch release notes data from JIRA and update the Jekyll data cache" +inputs: + output-file: + description: "Path to the generated release notes cache file" + required: false + default: "docs/_data/release-notes.json" + jql: + description: "JQL used to fetch release notes" + required: false + default: 'project = CCM AND "Release Notes" IS NOT EMPTY AND fixVersion IS NOT EMPTY AND updated >= -365d' + max-results: + description: "Maximum JIRA issues to fetch per page" + required: false + default: "50" + project-key: + description: "JIRA project key used to look up fix version release dates" + required: false + default: "CCM" + +runs: + using: "composite" + steps: + - name: "Fetch release notes cache" + env: + RELEASE_NOTES_CACHE_FILE: "${{ inputs.output-file }}" + RELEASE_NOTES_JQL: "${{ inputs.jql }}" + RELEASE_NOTES_MAX_RESULTS: "${{ inputs.max-results }}" + RELEASE_NOTES_PROJECT_KEY: "${{ inputs.project-key }}" + shell: bash + run: | + node ./.github/actions/release-notes/fetch-release-notes.js diff --git a/.github/actions/release-notes/fetch-release-notes.js b/.github/actions/release-notes/fetch-release-notes.js new file mode 100644 index 00000000..d6f0247e --- /dev/null +++ b/.github/actions/release-notes/fetch-release-notes.js @@ -0,0 +1,305 @@ +#!/usr/bin/env node + +const fs = require('node:fs'); +const path = require('node:path'); + +const DEFAULT_RELEASE_NOTES_JQL = 'project = CCM AND "Release Notes" IS NOT EMPTY AND fixVersion IS NOT EMPTY AND updated >= -365d'; +const DEFAULT_RELEASE_NOTES_CACHE_FILE = 'docs/_data/release-notes.json'; +const DEFAULT_RELEASE_NOTES_MAX_RESULTS = 50; +const DEFAULT_RELEASE_NOTES_PROJECT_KEY = 'CCM'; + +async function main() { + const repoRoot = process.cwd(); + const outputFile = process.env.RELEASE_NOTES_CACHE_FILE || DEFAULT_RELEASE_NOTES_CACHE_FILE; + const jiraBaseUrl = resolveJiraBaseUrl(); + + if (!jiraBaseUrl) { + throw new Error('Set JIRA_BASE_URL or JIRA_URL before running the release notes sync.'); + } + + const releaseNotesJql = process.env.RELEASE_NOTES_JQL || DEFAULT_RELEASE_NOTES_JQL; + const maxResults = Number.parseInt(process.env.RELEASE_NOTES_MAX_RESULTS || String(DEFAULT_RELEASE_NOTES_MAX_RESULTS), 10); + const releaseNotesProjectKey = process.env.RELEASE_NOTES_PROJECT_KEY || DEFAULT_RELEASE_NOTES_PROJECT_KEY; + const startedAt = new Date(); + + if (!Number.isInteger(maxResults) || maxResults <= 0) { + throw new Error('RELEASE_NOTES_MAX_RESULTS must be a positive integer.'); + } + + if (!process.env.JIRA_TOKEN && !process.env.JIRA_AUTH_HEADER) { + throw new Error('Set JIRA_TOKEN or JIRA_AUTH_HEADER before running the release notes sync.'); + } + + fs.mkdirSync(path.dirname(path.resolve(repoRoot, outputFile)), { recursive: true }); + + console.log(`Fetching release notes from ${jiraBaseUrl}`); + + // Look up the custom field ID once so the search request can read release notes text. + const fields = await requestFields(jiraBaseUrl); + const releaseNotesFieldId = resolveReleaseNotesFieldId(fields); + const projectVersions = await requestProjectVersions(jiraBaseUrl, releaseNotesProjectKey); + const releaseDatesByName = buildReleaseDateMap(projectVersions); + + console.log(`Resolved Release Notes field: ${releaseNotesFieldId}`); + + const issuesByKey = new Map(); + let startAt = 0; + let pageNumber = 1; + let total = 0; + + while (true) { + // Fetch one page of JIRA issues at a time until we have them all. + const payload = await requestSearch({ + jiraBaseUrl, + releaseNotesFieldId, + jql: releaseNotesJql, + startAt, + maxResults, + }); + + total = Number(payload.total || 0); + const pageIssues = Array.isArray(payload.issues) ? payload.issues : []; + console.log(`Fetched page ${pageNumber} with ${pageIssues.length} issue(s)`); + + for (const rawIssue of pageIssues) { + const issue = normalizeIssue(rawIssue, releaseNotesFieldId); + if (issue) { + issuesByKey.set(issue.key, issue); + } + } + + if (pageIssues.length === 0 || issuesByKey.size >= total) { + break; + } + + startAt += maxResults; + pageNumber += 1; + } + + const output = { + releases: groupIssuesByFixVersion(Array.from(issuesByKey.values()), releaseDatesByName), + }; + + fs.writeFileSync(path.resolve(repoRoot, outputFile), `${JSON.stringify(output, null, 2)}\n`, 'utf8'); + console.log(`Updated release notes cache at ${outputFile}`); +} + +async function requestFields(jiraBaseUrl) { + return fetchJson(`${jiraBaseUrl}/field`); +} + +async function requestProjectVersions(jiraBaseUrl, projectKey) { + const encodedProjectKey = encodeURIComponent(projectKey); + return fetchJson(`${jiraBaseUrl}/project/${encodedProjectKey}/versions`); +} + +async function requestSearch({ + jiraBaseUrl, + releaseNotesFieldId, + jql, + startAt, + maxResults, +}) { + const url = new URL(`${jiraBaseUrl}/search`); + url.searchParams.set('jql', jql); + url.searchParams.set('fields', `summary,fixVersions,issuetype,updated,${releaseNotesFieldId}`); + url.searchParams.set('startAt', String(startAt)); + url.searchParams.set('maxResults', String(maxResults)); + + return fetchJson(url.toString()); +} + +async function fetchJson(url) { + const response = await fetch(url, { + method: 'GET', + headers: buildHeaders(), + }); + + if (!response.ok) { + throw new Error(`JIRA request failed with status ${response.status} ${response.statusText}`); + } + + return response.json(); +} + +function buildHeaders() { + const headers = { + Accept: 'application/json', + }; + + if (process.env.JIRA_AUTH_HEADER && process.env.JIRA_AUTH_HEADER.trim()) { + const header = process.env.JIRA_AUTH_HEADER; + const index = header.indexOf(':'); + if (index <= 0) { + throw new Error('JIRA_AUTH_HEADER must be in the format "Header-Name: value".'); + } + headers[header.slice(0, index).trim()] = header.slice(index + 1).trim(); + return headers; + } + + headers.Authorization = `Bearer ${process.env.JIRA_TOKEN}`; + return headers; +} + +function resolveReleaseNotesFieldId(fields) { + const match = fields.find((field) => String(field.name || '').trim().toLowerCase() === 'release notes'); + if (!match || !match.id) { + throw new Error("Unable to resolve the JIRA field named 'Release Notes'."); + } + return match.id; +} + +function buildReleaseDateMap(versions) { + const releaseDatesByName = new Map(); + + if (!Array.isArray(versions)) { + return releaseDatesByName; + } + + for (const version of versions) { + const name = String(version && version.name ? version.name : '').trim(); + const releaseDate = String(version && version.releaseDate ? version.releaseDate : '').trim(); + + if (name && releaseDate) { + releaseDatesByName.set(name, releaseDate); + } + } + + return releaseDatesByName; +} + +function normalizeIssue(issue, releaseNotesFieldId) { + const fields = issue && typeof issue === 'object' ? issue.fields || {} : {}; + const fixVersions = Array.isArray(fields.fixVersions) + ? fields.fixVersions + .map((version) => String(version && version.name ? version.name : '').trim()) + .filter(Boolean) + : []; + const releaseNotes = extractText(fields[releaseNotesFieldId]); + // Keep only the fields needed by the generated JSON. + const normalized = { + key: String(issue && issue.key ? issue.key : '').trim(), + fix_versions: fixVersions, + release_notes: releaseNotes, + }; + + if (!normalized.key || !normalized.release_notes || normalized.fix_versions.length === 0) { + return null; + } + + return normalized; +} + +function resolveJiraBaseUrl() { + if (process.env.JIRA_BASE_URL && process.env.JIRA_BASE_URL.trim()) { + return process.env.JIRA_BASE_URL.replace(/\/$/, ''); + } + + if (process.env.JIRA_URL && process.env.JIRA_URL.trim()) { + return `${process.env.JIRA_URL.replace(/\/$/, '')}/rest/api/2`; + } + + return ''; +} + +function extractText(value) { + if (value === null || value === undefined) { + return ''; + } + + if (typeof value === 'string') { + return value.trim(); + } + + if (Array.isArray(value)) { + return value.map(extractText).filter(Boolean).join('\n').trim(); + } + + if (typeof value === 'object') { + if (typeof value.text === 'string') { + return value.text.trim(); + } + if (Array.isArray(value.content)) { + return value.content + .map(extractText) + .filter(Boolean) + .join('\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); + } + } + + return String(value).trim(); +} + +function groupIssuesByFixVersion(issues, releaseDatesByName) { + const releases = new Map(); + + for (const issue of issues) { + // Group each issue under every fix version it belongs to. + for (const fixVersion of issue.fix_versions) { + if (!releases.has(fixVersion)) { + releases.set(fixVersion, []); + } + releases.get(fixVersion).push({ + key: issue.key, + release_notes: issue.release_notes, + }); + } + } + + return Array.from(releases, ([rawName, items]) => ({ + name: formatReleaseName(rawName), + jira_name: rawName, + release_date: releaseDatesByName.get(rawName) || null, + items, + })).sort(compareReleasesByDateDesc); +} + +function compareReleasesByDateDesc(left, right) { + const leftDate = left.release_date ? Date.parse(left.release_date) : Number.NaN; + const rightDate = right.release_date ? Date.parse(right.release_date) : Number.NaN; + const leftHasDate = Number.isFinite(leftDate); + const rightHasDate = Number.isFinite(rightDate); + + if (leftHasDate && rightHasDate && leftDate !== rightDate) { + return rightDate - leftDate; + } + + if (leftHasDate && !rightHasDate) { + return -1; + } + + if (!leftHasDate && rightHasDate) { + return 1; + } + + return right.name.localeCompare(left.name, undefined, { + numeric: true, + sensitivity: 'base', + }); +} + +function formatReleaseName(name) { + const trimmedName = String(name || '').trim(); + + // Jira names prefixed with "Release" are displayed as "Core". + if (/^release\s+/i.test(trimmedName)) { + return trimmedName.replace(/^release\s+/i, 'Core '); + } + + // Reformat kebab-case names (e.g. digital-letters-0.0.0 → Digital Letters 0.0.0). + if (!trimmedName.includes('-') || trimmedName.includes(' ')) { + return trimmedName; + } + + return trimmedName + .split('-') + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); +} + +main().catch((error) => { + console.error(error.message || error); + process.exit(1); +}); diff --git a/.github/workflows/release-notes-sync.yaml b/.github/workflows/release-notes-sync.yaml new file mode 100644 index 00000000..577e0a65 --- /dev/null +++ b/.github/workflows/release-notes-sync.yaml @@ -0,0 +1,54 @@ +name: "Release notes sync" + +on: + workflow_dispatch: + schedule: + - cron: "0 6 * * 1" + +permissions: + contents: write + pull-requests: write + +jobs: + sync-release-notes: + name: "Sync release notes" + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: "Checkout code" + uses: actions/checkout@v4 + + - name: "Update release notes data" + uses: ./.github/actions/release-notes + env: + JIRA_TOKEN: "${{ secrets.JIRA_TOKEN }}" + JIRA_BASE_URL: "${{ secrets.JIRA_URL }}/rest/api/2" + + - name: "Detect release notes changes" + id: changes + shell: bash + run: | + if git diff --quiet -- docs/_data/release-notes.json; then + echo "has_changes=false" >> "$GITHUB_OUTPUT" + else + echo "has_changes=true" >> "$GITHUB_OUTPUT" + fi + + - name: "Create pull request" + if: steps.changes.outputs.has_changes == 'true' + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 + with: + token: "${{ secrets.GITHUB_TOKEN }}" + branch: "automation/release-notes-cache" + delete-branch: true + commit-message: "sync release notes" + title: "CCM-18043: Sync release notes" + body: | + ## Summary + + This PR syncs the release notes generated from JIRA for the last year. + + - Trigger: `${{ github.event_name }}` + - Source: `JIRA_URL secret (expanded to REST API base path)` + add-paths: | + docs/_data/release-notes.json diff --git a/README.md b/README.md index 89e17c6e..ef98f89f 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,27 @@ - This site includes the content for the public [N]HS Notify web site](https://notify.nhs.uk/) - It uses Jekyll to generate static web HTML files from markdown content -- The source code for the web site is in `/docs` directory -- Page content is inside the `/docs/pages` directory -- Page CSS is inside `/docs/_sass` directory +- the source code for the web site is in /docs folder +- page content is inside the `/docs/pages` folder +- page css is inside `/docs/_sass` folder + +## Release notes data sync + +- Release notes data is generated into `docs/_data/release-notes.json` +- The cache is refreshed by `.github/workflows/release-notes-sync.yaml` +- The workflow supports a weekly scheduled run and manual `workflow_dispatch` +- Set the `JIRA_TOKEN` GitHub Actions secret before enabling the workflow +- The sync action resolves the custom `Release Notes` field dynamically from the JIRA API, so the field ID is not hard coded in the repository - The webpage is published to GitHub Pages using [this GitHub Actions workflow](.github/workflows/jekyll-gh-pages.yml) +## Getting Started - First time setup + +This is only needed once. + +To get started, please create a new GitHub workspace from the main branch. + +This will setup a development environment for you to edit the web site in. The first time this runs, it will take approximately 10 minutes. You do not need to install ANY tools on your local computer. + ### Pre-requisites - A GitHub account diff --git a/docs/_data/release-notes.json b/docs/_data/release-notes.json new file mode 100644 index 00000000..4233fdc8 --- /dev/null +++ b/docs/_data/release-notes.json @@ -0,0 +1,219 @@ +{ + "releases": [ + { + "name": "Digital Letters 1.1.0", + "jira_name": "digital-letters-1.1.0", + "release_date": "2026-07-02", + "items": [ + { + "key": "CCM-20922", + "release_notes": "NHS Notify are able to onboard a Trust to send a PDF letter attached to an NHS App message, and fallback to printed letters where needed." + } + ] + }, + { + "name": "Digital Letters 1.0.1", + "jira_name": "digital-letters-1.0.1", + "release_date": "2026-06-17", + "items": [ + { + "key": "CCM-18345", + "release_notes": "NHS Notify will have the ability to query data and produce reporting relating to digital letters within Power BI by Trust per day." + }, + { + "key": "CCM-18337", + "release_notes": "Investigate and resolve the memory issues for PDM uploader for digital letters" + }, + { + "key": "CCM-18212", + "release_notes": "NHS Notify are able to onboard a Trust to send a PDF letter attached to an NHS App message, and fallback to printed letters where needed." + } + ] + }, + { + "name": "Digital Letters 1.0.0", + "jira_name": "digital-letters-1.0.0", + "release_date": "2026-06-17", + "items": [ + { + "key": "CCM-18337", + "release_notes": "Investigate and resolve the memory issues for PDM uploader for digital letters" + } + ] + }, + { + "name": "WebUI 0.1.18", + "jira_name": "WebUI 0.1.18", + "release_date": "2026-05-28", + "items": [ + { + "key": "CCM-8366", + "release_notes": "Users can now send a test message to their NHS App test account" + } + ] + }, + { + "name": "Core 4.67.1", + "jira_name": "Release 4.67.1", + "release_date": "2026-05-28", + "items": [ + { + "key": "CCM-17732", + "release_notes": "A timeout setting has been increased to resolve an error processing Vaccs reminders" + } + ] + }, + { + "name": "WebUI 0.1.17", + "jira_name": "WebUI 0.1.17", + "release_date": "2026-05-21", + "items": [ + { + "key": "CCM-17623", + "release_notes": "A bug has been fixed that blocked Digitrials templates" + }, + { + "key": "CCM-17502", + "release_notes": "The Letter Authoring (Carbone) journey now prevents uploads of files over 5MB" + }, + { + "key": "CCM-17341", + "release_notes": "A bug has been fixed which caused problems with certain Word fonts" + }, + { + "key": "CCM-17129", + "release_notes": "The 'Copy to Clipboard' option now indicates whether the message plan is in Draft or Production." + }, + { + "key": "CCM-16449", + "release_notes": "Additional config checks have been added to the Printing and Postage option" + }, + { + "key": "CCM-11322", + "release_notes": "Additional validation has been added to ensure links in pages are secure" + } + ] + }, + { + "name": "WebUI 0.1.16", + "jira_name": "WebUI 0.1.16", + "release_date": "2026-05-01", + "items": [ + { + "key": "CCM-16488", + "release_notes": "Clearer warnings on the 'Get ready to approve' letter page" + }, + { + "key": "CCM-16455", + "release_notes": "A warning message has been added to tell users they will be unable to edit a campaign after creating a message plan" + }, + { + "key": "CCM-14768", + "release_notes": "Message Plan letter templates can now be previewed in a new tab" + }, + { + "key": "CCM-12653", + "release_notes": "A bug which stopped buttons working correctly after a double click has been fixed" + } + ] + }, + { + "name": "Core 4.65.1", + "jira_name": "Release 4.65.1", + "release_date": "2026-04-29", + "items": [ + { + "key": "CCM-17272", + "release_notes": "- Memory allocation has been increased to support Digitrials' large 'aspiring' templates" + }, + { + "key": "CCM-15512", + "release_notes": "- Carbone letter events can now be consumed" + }, + { + "key": "CCM-13387", + "release_notes": "- We’ve added a new printSupplier field in NHS Notify Core to store the allocated supplier selected by supplier‑api for each letter.\r\nThis ensures that Core’s internal state now matches the final print supplier, improving accuracy for reporting, billing, and operational audits.\r\nDownstream systems can now use this field for correct supplier tracking and reconciliation across domains." + } + ] + }, + { + "name": "WebUI 0.1.15", + "jira_name": "WebUI 0.1.15", + "release_date": "2026-04-22", + "items": [ + { + "key": "CCM-15036", + "release_notes": "Users are now warned that they may lose their chosen printing and postage option when switching campaign" + }, + { + "key": "CCM-15018", + "release_notes": "Users are now informed of the process for requesting digital proofs" + }, + { + "key": "CCM-14747", + "release_notes": "British Sign Language letter templates can now be included in a message plan" + } + ] + }, + { + "name": "WebUI 0.1.14", + "jira_name": "WebUI 0.1.14", + "release_date": "2026-04-17", + "items": [ + { + "key": "CCM-14264", + "release_notes": "An 'Email Only' option has been added to the routing config" + }, + { + "key": "CCM-14211", + "release_notes": "DOCX files can now be uploaded" + }, + { + "key": "CCM-8353", + "release_notes": "In the NHS App, test messages can now be sent from the template preview page" + } + ] + }, + { + "name": "Core 4.64.1", + "jira_name": "Release 4.64.1", + "release_date": "2026-04-15", + "items": [ + { + "key": "CCM-15626", + "release_notes": "A change has been made to support letters which use large templates that were previously prone to timing out." + }, + { + "key": "CCM-15207", + "release_notes": "We’ve defined and registered JSON Schemas for the message and channel status events published by NHS Notify Core.\r\n\r\nThis provides a versioned contract for the callbacks bounded context, allowing subscribers to validate event payloads reliably.\r\n\r\nThese schemas support the migration to an event‑driven callbacks domain, ensuring consistent communication across services.\r\n\r\nWe’ve improved internal event handling and validation processes to strengthen system integrity." + }, + { + "key": "CCM-15037", + "release_notes": "Preparatory work to support the introduction of British Sign Language" + } + ] + }, + { + "name": "Core 4.63.0", + "jira_name": "Release 4.63.0", + "release_date": "2026-04-01", + "items": [ + { + "key": "CCM-15266", + "release_notes": "Additional monitoring and alerting added" + } + ] + }, + { + "name": "Supplier Api 1.1.5", + "jira_name": "supplier-api-1.1.5", + "release_date": "2026-03-26", + "items": [ + { + "key": "CCM-13906", + "release_notes": "A change has been made to prioritise urgent letters so that they are processed ahead of BAU letters" + } + ] + } + ] +} diff --git a/docs/_includes/components/timeline.html b/docs/_includes/components/timeline.html new file mode 100644 index 00000000..05b5df4d --- /dev/null +++ b/docs/_includes/components/timeline.html @@ -0,0 +1,10 @@ +