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/.github/workflows/stage-1-commit.yaml b/.github/workflows/stage-1-commit.yaml index 47a96300..3e4eef29 100644 --- a/.github/workflows/stage-1-commit.yaml +++ b/.github/workflows/stage-1-commit.yaml @@ -46,7 +46,7 @@ jobs: with: fetch-depth: 0 # Full history is needed to scan all commits - name: "Scan secrets" - uses: NHSDigital/nhs-notify-shared-modules/.github/actions/scan-secrets@3.0.9 + uses: NHSDigital/nhs-notify-shared-modules/.github/actions/scan-secrets@5.1.0 # NOSONAR - githubactions:S7637 - internally controlled repo, pinned by tag check-file-format: name: "Check file format" runs-on: ubuntu-latest @@ -57,7 +57,7 @@ jobs: with: fetch-depth: 0 # Full history is needed to compare branches - name: "Check file format" - uses: NHSDigital/nhs-notify-shared-modules/.github/actions/check-file-format@3.0.9 + uses: NHSDigital/nhs-notify-shared-modules/.github/actions/check-file-format@5.1.0 # NOSONAR - githubactions:S7637 - internally controlled repo, pinned by tag check-markdown-format: name: "Check Markdown format" runs-on: ubuntu-latest @@ -68,7 +68,7 @@ jobs: with: fetch-depth: 0 # Full history is needed to compare branches - name: "Check Markdown format" - uses: NHSDigital/nhs-notify-shared-modules/.github/actions/check-markdown-format@3.0.9 + uses: NHSDigital/nhs-notify-shared-modules/.github/actions/check-markdown-format@5.1.0 # NOSONAR - githubactions:S7637 - internally controlled repo, pinned by tag check-english-usage: name: "Check English usage" runs-on: ubuntu-latest @@ -79,7 +79,7 @@ jobs: with: fetch-depth: 0 # Full history is needed to compare branches - name: "Check English usage" - uses: NHSDigital/nhs-notify-shared-modules/.github/actions/check-english-usage@3.0.9 + uses: NHSDigital/nhs-notify-shared-modules/.github/actions/check-english-usage@5.1.0 # NOSONAR - githubactions:S7637 - internally controlled repo, pinned by tag count-lines-of-code: name: "Count lines of code" runs-on: ubuntu-latest @@ -91,7 +91,7 @@ jobs: - name: "Checkout code" uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1 - name: "Count lines of code" - uses: NHSDigital/nhs-notify-shared-modules/.github/actions/create-lines-of-code-report@3.0.9 + uses: NHSDigital/nhs-notify-shared-modules/.github/actions/create-lines-of-code-report@5.1.0 # NOSONAR - githubactions:S7637 - internally controlled repo, pinned by tag with: build_datetime: "${{ inputs.build_datetime }}" build_timestamp: "${{ inputs.build_timestamp }}" diff --git a/.github/workflows/stage-2-test.yaml b/.github/workflows/stage-2-test.yaml index a3384c56..55b4c7fc 100644 --- a/.github/workflows/stage-2-test.yaml +++ b/.github/workflows/stage-2-test.yaml @@ -87,7 +87,7 @@ jobs: with: fetch-depth: 0 # Full history is needed to improving relevancy of reporting - name: "Perform static analysis" - uses: NHSDigital/nhs-notify-shared-modules/.github/actions/perform-static-analysis@3.0.9 + uses: NHSDigital/nhs-notify-shared-modules/.github/actions/perform-static-analysis@5.1.0 # NOSONAR - githubactions:S7637 - internally controlled repo, pinned by tag with: sonar_organisation_key: "${{ vars.SONAR_ORGANISATION_KEY }}" sonar_project_key: "${{ vars.SONAR_PROJECT_KEY }}" 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/footer-navigation.yml b/docs/_data/footer-navigation.yml index 3c87869f..f4886431 100644 --- a/docs/_data/footer-navigation.yml +++ b/docs/_data/footer-navigation.yml @@ -18,3 +18,6 @@ - title: Sitemap link: /sitemap/ + +- title: Release notes + link: /release-notes/ diff --git a/docs/_data/release-notes.json b/docs/_data/release-notes.json new file mode 100644 index 00000000..0ce3bfcb --- /dev/null +++ b/docs/_data/release-notes.json @@ -0,0 +1,15 @@ +{ + "releases": [ + { + "name": "Digital Letters 1.0.1", + "jira_name": "digital-letters-1.0.1", + "release_date": "2026-06-17", + "items": [ + { + "key": "CCM-18212", + "release_notes": "NHS Notify is introducing a new capability that enables secondary care NHS Trusts to send PDF digital letters directly to patients via NHS App messages. By making healthcare communications available within the NHS App, patients can access important information quickly, securely and conveniently through a trusted digital channel, while retaining the option to receive a printed paper letter if preferred.\r\n\r\nThe capability will also help NHS organisations reduce the costs associated with printing and postage, supporting more efficient and sustainable delivery of patient communications while maintaining inclusivity for those who need or prefer paper correspondence." + } + ] + } + ] +} 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 @@ +
    + {% for item in include.items %} +
  1. + +
    +
    {{ item.release_notes | markdownify }}
    +
    +
  2. + {% endfor %} +
diff --git a/docs/assets/css/_nhsnotify.scss b/docs/assets/css/_nhsnotify.scss index 7eb7b14b..aaa1983f 100644 --- a/docs/assets/css/_nhsnotify.scss +++ b/docs/assets/css/_nhsnotify.scss @@ -210,3 +210,49 @@ .nhsuk-header__account-item:last-child { outline: none; } + +.nhsnotify-timeline { + list-style: none; + margin: 0 0 40px; + padding: 0; + position: relative; +} + +.nhsnotify-timeline::before { + background-color: $color_nhsuk-grey-3; + bottom: 8px; + content: ""; + left: 5px; + position: absolute; + top: 8px; + width: 2px; +} + +.nhsnotify-timeline__item { + margin: 0 0 24px; + padding: 0 0 0 24px; + position: relative; + + &:last-child { + margin-bottom: 0; + } +} + +.nhsnotify-timeline__marker { + background-color: $color_nhsuk-white; + border: 2px solid $color_nhsuk-grey-3; + border-radius: 50%; + height: 12px; + left: 0; + position: absolute; + top: 4px; + width: 12px; +} + +.nhsnotify-timeline__content { + padding-left: 8px; +} + +.nhsnotify-timeline__body > :last-child { + margin-bottom: 0; +} diff --git a/docs/pages/footer/release-notes.md b/docs/pages/footer/release-notes.md new file mode 100644 index 00000000..7b735be9 --- /dev/null +++ b/docs/pages/footer/release-notes.md @@ -0,0 +1,34 @@ +--- +layout: page +title: NHS Notify release notes +parent: About +nav_order: 3 +permalink: /release-notes/ +--- + +Find out about technical updates and bug fixes for NHS Notify. + +This page is aimed at people with technical roles, like developers. + +{% assign releases = site.data["release-notes"].releases %} + +{% if releases and releases.size > 0 %} +{% for release in releases %} + +## {{ release.name }} + +{% if release.release_date %} + +Released on {{ release.release_date | date: "%d %B %Y" }} +{% endif %} + +{% if release.items and release.items.size > 0 %} +{% include components/timeline.html items=release.items %} +{% else %} +No release notes available for this release. +{% endif %} + +{% endfor %} +{% else %} +No release notes data is currently available. +{% endif %} diff --git a/scripts/config/vale/styles/config/vocabularies/words/accept.txt b/scripts/config/vale/styles/config/vocabularies/words/accept.txt index b926a40a..23d79c57 100644 --- a/scripts/config/vale/styles/config/vocabularies/words/accept.txt +++ b/scripts/config/vale/styles/config/vocabularies/words/accept.txt @@ -1,4 +1,3 @@ -: [cC]yber [iI]nset [Uu][Rr][Ll] @@ -13,8 +12,10 @@ Codespace Codespaces Cohorting ctrl +css Dependabot endfor +endif Fireship fullName Futuna