diff --git a/draft-release/README.md b/draft-release/README.md
new file mode 100644
index 0000000..35a7417
--- /dev/null
+++ b/draft-release/README.md
@@ -0,0 +1,132 @@
+# Draft Release - a Shared Global Release Engine
+
+Composite [action](action.yaml) that generates categorised draft release notes
+from merged pull requests and updates (or creates) a draft GitHub Release for
+the current tag.
+
+## Location
+
+This action is intentionally located at repository root level and has the
+following components:
+
+- `draft-release/action.yaml` (composite action)
+- `draft-release/bin/compile-release-notes.sh` (compiler script)
+- `draft-release/templates/release.yml` (release category template)
+- `draft-release/README.md` (this document)
+
+The changelog template source of truth is `draft-release/templates/release.yml`.
+
+## Changelog Categories for Simulation Systems
+
+Pull Requests are evaluated against all categories from **top to bottom**. If a
+PR has multiple labels matching different categories, it will appear in **all
+matching categories**. This allows a single PR to be listed in multiple sections
+for comprehensive changelog organisation.
+
+| Category | Labels | Example |
+| -------- | ------ | ------- |
+| 💥 Breaking Changes | `breaking-change` | Critical infrastructure changes, breaking adjustments, or API removals. |
+| 📦 Dependency Updates | `dependency` | Updates to third-party dependencies, including security patches. |
+| ⚠️ Deprecations | `deprecated` | Features or APIs that are being phased out, but still functional. |
+| 🐛 Bug Fixes | `bugfix` | Code corrections or hotfixes resolving functional issues. |
+| ✨ New Features | `feature` | Customer-facing features, enhancements, or structural additions. |
+| 🔬 Scientific & Algorithmic Updates | `science`, `technical` | **science**: Domain-specific mathematical changes or model updates.
**technical**: Deep algorithmic optimisations or background logic shifts. |
+| 📚 Documentation | `documentation` | Changes isolated to READMEs, inline code docstrings, scientific documentation, working practices, or other non-functional documentation updates. |
+| ⚡ Performance Improvements | `optimisation` | Direct speed execution metrics, runtime improvements, memory, storage, or other resource optimisations. |
+| ♻️ Refactoring | `refactor` | Code cleanup, modularisation, or other internal improvements without behavior changes. |
+| 🛠️ Maintenance | `build`, `chore`, `ci` | **build**: Changes affecting build tools or external compiler toolchains.
**chore**: General housekeeping, licence updates, or minor administrative tasks.
**ci**: Changes to GitHub Actions workflows, CI/CD pipelines, or other automation.|
+
+> [!NOTE]
+> First time contributors are added automatically when the merged PR has an
+> `author_association` of `FIRST_TIME_CONTRIBUTOR`.
+
+### Excluded Labels
+
+PRs carrying any of the following labels are **hidden** from the changelog
+output entirely, regardless of any other labels they carry:
+
+| Label | Purpose |
+| ----- | ------- |
+| `ignore-changelog` | Escape-hatch label to manually suppress a specific PR from the logs. |
+| `test` | Changes related to testing frameworks or test cases. |
+| `wip` | Work in progress PRs that are not ready for release. |
+
+## How It Works
+
+1. Checks out the caller repository into `local-code` with full tag history.
+2. Runs `draft-release/bin/compile-release-notes.sh` to:
+ - find release commits,
+ - map merged PRs to changelog categories from
+ `draft-release/templates/release.yml`,
+ - write `release-notes.md` and set `has_commits` output.
+3. If commits exist, creates or updates a draft release for the current tag.
+
+```mermaid
+graph TD
+ %% Define Node Styles for Scannability
+ classDef trigger style fill:#f9f,stroke:#333,stroke-width:2px,font-weight:bold;
+ classDef step style fill:#bbf,stroke:#333,stroke-width:1px;
+ classDef desc style fill:#fff,stroke:#666,stroke-width:1px,stroke-dasharray: 5 5,font-size:12px;
+
+ %% Workflow Connections
+ Trigger([Tag Push Event]) --> Step1[1. Checkout Caller Repository]
+ Step1 --> Step2[2. Compile Release Notes]
+ Step2 --> Step3{Has Merged PRs?}
+ Step3 -->|Yes| Step4[3. Create or Update Draft Release]
+ Step3 -->|No| Skip[Skip Release Creation]
+
+ %% Step Explanations (Side-nodes)
+ Step1 -.-> Desc1[Checks out the calling repository into local-code
with full history and tags]
+ Step2 -.-> Desc2[Executes compile-release-notes.sh
using action-local template release.yml]
+ Step4 -.-> Desc3[Executes gh release create/edit
with categorised changelog notes in draft state]
+ Skip -.-> Desc4[Workflow completes early
if no merged PRs found in release window]
+
+ %% Assign Classes to Nodes
+ class Trigger trigger;
+ class Step1,Step2,Step3,Step4,Skip step;
+ class Desc1,Desc2,Desc3,Desc4 desc;
+```
+
+## Usage
+
+```yaml
+name: Automated Release Notes
+
+on:
+ push:
+ tags:
+ - "v*"
+
+jobs:
+ release:
+ runs-on: ubuntu-slim
+ permissions:
+ contents: write
+ pull-requests: read
+ steps:
+ - name: Draft Release
+ uses: MetOffice/growss/draft-release@main # or tag or sha
+```
+
+## Required Permissions
+
+- `contents: write` to create/edit draft releases.
+- `pull-requests: read` to read merged PR metadata for changelog generation.
+
+## Outputs
+
+The compile step exposes:
+
+- `has_commits`: `true` when release notes were generated from commits,
+ otherwise `false`.
+
+## Notes
+
+- The action uses `${{ github.token }}` internally for `gh` API commands.
+- `release-notes.md` is generated in the GitHub Actions workspace root.
+- The release body is grouped by labels defined in
+ `draft-release/templates/release.yml`.
+
+## Licence
+
+© Crown copyright Met Office. See [LICENCE](../LICENCE) file for details.
diff --git a/draft-release/action.yaml b/draft-release/action.yaml
new file mode 100644
index 0000000..c8e05f1
--- /dev/null
+++ b/draft-release/action.yaml
@@ -0,0 +1,49 @@
+# ------------------------------------------------------------------------------
+# (c) Crown copyright Met Office. All rights reserved.
+# The file LICENCE, distributed with this code, contains details of the terms
+# under which the code may be used.
+# ------------------------------------------------------------------------------
+
+name: Shared Global Release Engine
+description: Automates draft releases using centralised assets
+
+runs:
+ using: "composite"
+ steps:
+ - name: Checkout caller repository with full history
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ path: local-code
+ fetch-depth: 0
+ fetch-tags: true
+ persist-credentials: false
+
+ - name: Compile categorised release notes from template labels
+ id: compile-release-notes
+ shell: bash
+ env:
+ GH_TOKEN: ${{ github.token }}
+ CALLER_REPO: ${{ github.repository }}
+ GITHUB_REF_NAME: ${{ github.ref_name }}
+ GITHUB_SHA: ${{ github.sha }}
+ run: |
+ set -euo pipefail
+ bash "${{ github.action_path }}/bin/compile-release-notes.sh" \
+ "$CALLER_REPO" "$GITHUB_SHA" "$GITHUB_REF_NAME"
+
+ - if: steps.compile-release-notes.outputs.has_commits == 'true'
+ shell: bash
+ working-directory: local-code
+ env:
+ GH_TOKEN: ${{ github.token }}
+ GITHUB_REF_NAME: ${{ github.ref_name }}
+ run: |
+ set -euo pipefail
+ TITLE="Release ${GITHUB_REF_NAME} (Draft)"
+ if gh release view "${GITHUB_REF_NAME}" >/dev/null 2>&1; then
+ gh release edit "${GITHUB_REF_NAME}" --draft --title "${TITLE}" \
+ --notes-file ../release-notes.md
+ else
+ gh release create "${GITHUB_REF_NAME}" --draft --title "${TITLE}" \
+ --notes-file ../release-notes.md
+ fi
diff --git a/draft-release/bin/compile-release-notes.sh b/draft-release/bin/compile-release-notes.sh
new file mode 100644
index 0000000..79f5449
--- /dev/null
+++ b/draft-release/bin/compile-release-notes.sh
@@ -0,0 +1,259 @@
+#!/usr/bin/env bash
+# ------------------------------------------------------------------------------
+# (c) Crown copyright Met Office. All rights reserved.
+# The file LICENCE, distributed with this code, contains details of the terms
+# under which the code may be used.
+# ------------------------------------------------------------------------------
+# Compile categorised release notes from a shared changelog template.
+# Helper script for the draft-release GitHub Action workflow.
+#
+# Usage:
+# compile-release-notes.sh
+#
+# - External repository that called this workflow
+# - SHA of checkout in local-code (Caller repository) for which to generate release notes
+# - refs/tags/v1.0.0, refs/heads/main, or a specific commit SHA
+# Required environment:
+# GH_TOKEN - GitHub token for API access
+# GITHUB_OUTPUT - Set automatically in GitHub Actions
+
+set -euo pipefail
+
+error_exit() {
+ echo "::error::$1" >&2
+ exit 1
+}
+
+[[ $# -lt 3 ]] && error_exit "Usage: $0 "
+
+CALLER_REPO="$1"
+GITHUB_SHA="$2"
+GITHUB_REF_NAME="$3"
+TEMPLATE_REF="${TEMPLATE_REF:-unknown}"
+
+# Dynamically calculate the path relative to where this script lives on disk
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+TEMPLATE_FILE="$SCRIPT_DIR/../templates/release.yml"
+TEMPLATE_REPO="MetOffice/growss"
+TEMPLATE_PATH="draft-release/templates/release.yml"
+
+# Check for required commands and files
+command -v gh >/dev/null 2>&1 || error_exit "GitHub CLI (gh) is required but not available."
+command -v jq >/dev/null 2>&1 || error_exit "jq is required but not available."
+[[ -f "$TEMPLATE_FILE" ]] || error_exit "Template file not found: $TEMPLATE_FILE"
+
+# Determine previous tag from local-code git history
+# a. Get the most recent tag reachable from the commit before the current GITHUB_SHA.
+PREV_TAG="$(git -C local-code describe --tags --abbrev=0 "${GITHUB_SHA}^" 2>/dev/null || true)"
+# b. Get the latest tag starting with "v", excluding the current branch/tag name.
+# Sorts by creation date (newest first), takes the top result, and defaults to empty if none exist.
+PREV_TAG0="$(git -C local-code tag --list "v*" --sort=-creatordate | grep -v "^${GITHUB_REF_NAME}$" | head -n 1 || true)"
+
+# -- Debugging output ----------------------------------------------------------
+echo "**Caller Repository**: ${CALLER_REPO}"
+echo "Current ref name: ${GITHUB_REF_NAME}"
+echo "Current commit SHA: ${GITHUB_SHA}"
+echo "Previous tag (v*): ${PREV_TAG0:-None}"
+echo "Previous tag (git describe): ${PREV_TAG:-None}"
+echo "**Template Repository**: ${TEMPLATE_REPO}"
+echo "Template ref (version): ${TEMPLATE_REF}"
+echo "Template path: ${TEMPLATE_PATH}"
+echo "Template file: ${TEMPLATE_FILE}"
+# ------------------------------------------------------------------------------
+
+if [ -n "$PREV_TAG" ]; then
+ git -C local-code rev-list --max-count=300 "${PREV_TAG}..${GITHUB_SHA}" >commit-shas.txt
+else
+ git -C local-code rev-list --max-count=300 "${GITHUB_SHA}" >commit-shas.txt
+fi
+
+# If no commits were found, create a release notes file indicating this and exit early
+if [ ! -s commit-shas.txt ]; then
+ {
+ echo "## Changelog"
+ echo
+ echo "* No commits found for this release window."
+ } >release-notes.md
+ echo "has_commits=false" >>"${GITHUB_OUTPUT}"
+ echo "::warning::No commits found for this release window."
+ exit 0
+fi
+
+# Instead of iterating through commits, we pull the 200 most recent merged PRs,
+# (for all PRs use --paginate with single API call) then match them against
+# commit-shas.txt locally.
+{
+ gh api "repos/${CALLER_REPO}/pulls?state=closed&per_page=100&page=1"
+ gh api "repos/${CALLER_REPO}/pulls?state=closed&per_page=100&page=2"
+} | jq -r '.[] | select(.merged_at != null) |
+ "\(.merge_commit_sha) \(.number) \(.title) | \(.user.login) | \([.labels[].name] | join(",")) | \(.author_association)"' >recent-prs.txt
+
+# Filter recent-prs down to ONLY those matching a commit SHA from our release range
+awk 'NR==FNR { shas[tolower($1)]=1; next } (tolower($1) in shas) { print }' commit-shas.txt recent-prs.txt >pr-data.txt
+
+if [ ! -s pr-data.txt ]; then
+ {
+ echo "## Changelog"
+ echo
+ echo "* No pull requests found for this release window."
+ } >release-notes.md
+ echo "has_commits=false" >>"${GITHUB_OUTPUT}"
+ echo "::warning::No pull requests found for this release window."
+ exit 0
+fi
+
+# Parse template YAML (limited parser for current release.yml structure)
+declare -a CATEGORY_TITLES=()
+declare -a CATEGORY_LABELS=()
+declare -a EXCLUDE_LABELS=()
+
+mode=""
+in_category_labels=0
+current_index=-1
+
+while IFS= read -r raw_line; do
+ line="${raw_line%%#*}"
+ trimmed="${line#"${line%%[![:space:]]*}"}"
+ trimmed="${trimmed%"${trimmed##*[![:space:]]}"}"
+ [ -z "$trimmed" ] && continue
+
+ case "$trimmed" in
+ changelog:) continue ;;
+ categories: | exclude:)
+ mode="${trimmed%?}"
+ in_category_labels=0
+ continue
+ ;;
+ labels:)
+ case "$mode" in
+ categories) [ "$current_index" -ge 0 ] && in_category_labels=1 ;;
+ exclude) in_category_labels=2 ;;
+ esac
+ continue
+ ;;
+ esac
+
+ if [ "$mode" = "categories" ] && [[ "$trimmed" =~ ^-[[:space:]]title:[[:space:]]*\"(.*)\"$ ]]; then
+ CATEGORY_TITLES+=("${BASH_REMATCH[1]}")
+ CATEGORY_LABELS+=("")
+ current_index=$((current_index + 1))
+ in_category_labels=0
+ continue
+ fi
+
+ if [ "$in_category_labels" -eq 1 ] && [[ "$trimmed" =~ ^-[[:space:]]+\"?([A-Za-z0-9._-]+)\"?\;?$ ]]; then
+ lbl="${BASH_REMATCH[1],,}"
+ if [ -z "${CATEGORY_LABELS[$current_index]}" ]; then
+ CATEGORY_LABELS[current_index]="$lbl"
+ else
+ CATEGORY_LABELS[current_index]+="|$lbl"
+ fi
+ continue
+ fi
+
+ if [ "$in_category_labels" -eq 2 ] && [[ "$trimmed" =~ ^-[[:space:]]+\"?([A-Za-z0-9._-]+)\"?\;?$ ]]; then
+ EXCLUDE_LABELS+=("${BASH_REMATCH[1],,}")
+ continue
+ fi
+done <"$TEMPLATE_FILE"
+
+# Store PR lines in temporary files for each category, then concatenate them into the final release notes.
+tmp_dir="$(mktemp -d)"
+trap 'rm -rf "$tmp_dir"' EXIT
+
+for i in "${!CATEGORY_TITLES[@]}"; do : >"$tmp_dir/cat_${i}.md"; done
+: >"$tmp_dir/new_contributors.md" # Create or clear file for new contributors
+
+# Read the local intersected PR file and map categories
+while IFS= read -r row || [ -n "$row" ]; do
+ [ -z "$row" ] && continue
+
+ # Row structure: | | |
+ # Uses ' [|] ' as a definitive delimiter anchor to safely handle titles containing literal '|' characters.
+ regex='^([^[:space:]]+)[[:space:]]+([0-9]+)[[:space:]]+(.*)[[:space:]]\|[[:space:]]+([^|]+)[[:space:]]+\|[[:space:]]*([^|]*)[[:space:]]*\|[[:space:]]*(.*)$'
+
+ if [[ "$row" =~ $regex ]]; then
+ _sha="${BASH_REMATCH[1]}"
+ pr="${BASH_REMATCH[2]}"
+ title="${BASH_REMATCH[3]}"
+ user="${BASH_REMATCH[4]}"
+ user=$(echo "$user" | xargs)
+ labels="${BASH_REMATCH[5]}"
+ labels=$(echo "$labels" | xargs)
+ association="${BASH_REMATCH[6]}"
+ association=$(echo "$association" | xargs)
+ else
+ echo "::warning::Row did not match expected format: $row"
+ continue
+ fi
+
+ # Map New Contributors,without relying on labels
+ if [ "$association" = "FIRST_TIME_CONTRIBUTOR" ]; then
+ if ! grep -q "@${user}" "$tmp_dir/new_contributors.md"; then
+ echo "* @${user} made their first contribution in #${pr}" >> "$tmp_dir/new_contributors.md"
+ fi
+ fi
+
+ # Convert labels to an array and normalize to lowercase
+ IFS=',' read -r -a label_array <<<"${labels,,}"
+
+ # Check exclude labels
+ skip=0
+ for ex in "${EXCLUDE_LABELS[@]}"; do
+ for lbl in "${label_array[@]}"; do
+ [ "$lbl" = "$ex" ] && {
+ skip=1
+ break 2 # Breaks out of both loops
+ }
+ done
+ done
+ [ "$skip" -eq 1 ] && continue
+
+ pr_line="1. ${title} (@${user}) in #${pr}"
+
+ for i in "${!CATEGORY_TITLES[@]}"; do
+ IFS='|' read -r -a cat_labels <<<"${CATEGORY_LABELS[$i]}"
+ matched_this_category=0
+ for cat_lbl in "${cat_labels[@]}"; do
+ for lbl in "${label_array[@]}"; do
+ if [ "$lbl" = "$cat_lbl" ]; then
+ # Only append once per category block, even if multiple labels match this group
+ if [ "$matched_this_category" -eq 0 ]; then
+ matched_this_category=1
+ echo "$pr_line" >>"$tmp_dir/cat_${i}.md"
+ fi
+ break # Breaks out of the PR labels loop; moves to next category label
+ fi
+ done
+ done
+ done
+done release-notes.md
+
+echo "has_commits=true" >>"${GITHUB_OUTPUT}"
+echo "::notice::Draft release notes generated successfully."
diff --git a/draft-release/templates/release.yml b/draft-release/templates/release.yml
new file mode 100644
index 0000000..cd25bc3
--- /dev/null
+++ b/draft-release/templates/release.yml
@@ -0,0 +1,79 @@
+# ------------------------------------------------------------------------------
+# (c) Crown copyright Met Office. All rights reserved.
+# The file LICENCE, distributed with this code, contains details of the terms
+# under which the code may be used.
+# ------------------------------------------------------------------------------
+# Simulation Systems Release Changelog Configuration
+
+changelog:
+ # Categories are evaluated sequentially from top to bottom.
+ # If a PR has multiple matching labels, it will appear in all matching categories.
+
+ categories:
+ # (breaking-change) Critical infrastructure changes or API removals.
+ - title: "💥 Breaking Changes"
+ labels:
+ - "breaking-change"
+
+ # (dependency) Updates to third-party dependencies, including security patches.
+ - title: "📦 Dependency Updates"
+ labels:
+ - "dependency"
+
+ # (deprecated) Features or APIs that are being phased out, but still functional.
+ - title: "⚠️ Deprecations"
+ labels:
+ - "deprecated"
+
+ # (bugfix) Placed near the top so that bug fixes always take priority
+ # over general scientific updates or refactoring.
+ - title: "🐛 Bug Fixes"
+ labels:
+ - "bugfix"
+
+ # (feature) Standard customer-facing features and structural additions.
+ - title: "✨ New Features"
+ labels:
+ - "feature"
+
+ # (science) Domain-specific mathematical changes or model updates.
+ # (technical) Deep algorithmic optimisations or background logic shifts.
+ - title: "🔬 Scientific & Algorithmic Updates"
+ labels:
+ - "science"
+ - "technical"
+
+ # (documentation) Changes isolated to READMEs, inline code docstrings,
+ # scientific documentation, working practices, or
+ # other non-functional documentation updates.
+ - title: "📚 Documentation"
+ labels:
+ - "documentation"
+
+ # (optimisation) Direct speed execution metrics, runtime improvements,
+ # Memory, storage, or other resource optimisations.
+ - title: "⚡ Performance Improvements"
+ labels:
+ - "optimisation"
+
+ # (refactor) Code cleanup, modularisation, or other internal improvements.
+ - title: "♻️ Refactoring"
+ labels:
+ - "refactor"
+
+ # (build) Changes affecting build tools or external compiler toolchains.
+ # (chore) General housekeeping, license updates, or minor administrative tasks.
+ # (ci) Changes to GitHub Actions workflows, CI/CD pipelines, or other automation.
+ - title: "🛠️ Maintenance"
+ labels:
+ - "build"
+ - "chore"
+ - "ci"
+
+ # Global Exclusions: Any PR matching these labels will be completely hidden
+ # from the final changelog output, taking ultimate precedence over categories above.
+ exclude:
+ labels:
+ - "ignore-changelog" # Escape-hatch label to manually suppress a specific PR from the logs.
+ - "test" # Changes related to testing frameworks or test cases.
+ - "wip" # Work in progress PRs that are not ready for release.
diff --git a/pyproject.toml b/pyproject.toml
index e5ec9b4..1e74d12 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -18,6 +18,12 @@ respect-gitignore = true
exclude = [
".github/pull_request_template.md",
]
+disable = [
+ "MD033", # Inline HTML
+ "MD034", # Bare URL used
+ "MD036", # Emphasis used instead of a header
+ "MD041", # First line in file should be a top level header
+]
[tool.rumdl.MD013]
line-length = 80 # Keeps normal paragraph text restricted to 80 chars
code-blocks = false # Disables the 80 char limit strictly for fenced code blocks