From bff3696f83ce767293cdf8521a5550e496933ee6 Mon Sep 17 00:00:00 2001
From: Yaswant Pradhan <2984440+yaswant@users.noreply.github.com>
Date: Tue, 30 Jun 2026 02:36:01 +0100
Subject: [PATCH 1/7] Draft Release Creation Action
---
.github/templates/release.yml | 65 ++++++++++++
.github/workflows/draft-release.yaml | 55 +++++++++++
draft-release/README.md | 141 +++++++++++++++++++++++++++
pyproject.toml | 6 ++
4 files changed, 267 insertions(+)
create mode 100644 .github/templates/release.yml
create mode 100644 .github/workflows/draft-release.yaml
create mode 100644 draft-release/README.md
diff --git a/.github/templates/release.yml b/.github/templates/release.yml
new file mode 100644
index 0000000..6d6fcd2
--- /dev/null
+++ b/.github/templates/release.yml
@@ -0,0 +1,65 @@
+# ------------------------------------------------------------------------------
+# (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 Changelog Configuration
+
+changelog:
+ # Categories are evaluated sequentially from top to bottom.
+ # If a PR has multiple matching labels, it will land in the FIRST category it satisfies.
+
+ categories:
+ # (breaking-change) Critical infrastructure changes or API removals.
+ - title: "💥 Breaking Changes"
+ labels:
+ - "breaking-change"
+
+ # (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 optimizations 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"
+
+ # (performance) Direct speed execution metrics or runtime improvements.
+ # (optimization) Memory, storage, or other resource optimizations.
+ - title: "⚡ Performance Improvements"
+ labels:
+ - "performance"
+ - "optimization"
+
+ # (refactor) Code cleanup, modularisation, or other internal improvements.
+ - title: "♻️ Refactoring"
+ labels:
+ - "refactor"
+
+ # Global Exclusions: Any PR matching these labels will be completely hidden
+ # from the final changelog output, taking ultimate precedence over categories above.
+ exclude:
+ labels:
+ - "build" # Changes affecting build tools or external compiler toolchains.
+ - "chore" # General housekeeping, license updates, or minor administrative tasks.
+ - "ci" # Modifications to GitHub Actions workflows or automation systems.
+ - "dependencies" # Automated third-party package upgrades (e.g., Dependabot alerts).
+ - "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/.github/workflows/draft-release.yaml b/.github/workflows/draft-release.yaml
new file mode 100644
index 0000000..d967c44
--- /dev/null
+++ b/.github/workflows/draft-release.yaml
@@ -0,0 +1,55 @@
+# ------------------------------------------------------------------------------
+# (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
+
+on:
+ workflow_call:
+
+jobs:
+ run-release:
+ name: "Draft Release"
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write # Required to create the draft release in the calling repository
+ steps:
+ # 1. Fetch Shared Release Template from your central public repository
+ # Public repos can be fetched by anyone, but we pass GITHUB_TOKEN for private callers
+ - name: Fetch Shared Release Template
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ repository: MetOffice/growss
+ path: central-assets
+ token: ${{ secrets.GITHUB_TOKEN }} # Ensures private callers can cross the bridge safely
+ persist-credentials: false
+
+ # 2. Fetch Active Workspace Code
+ # Using tokens ensures private forks can checkout their own code safely
+ - name: Fetch Active Workspace Code
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ path: local-code
+ token: ${{ secrets.GITHUB_TOKEN }}
+ persist-credentials: false
+ allow-unsafe-pr-checkout: true # allow checking out PR head refs from forks
+
+ # 3. Inject the file from your custom templates folder into the active local workspace
+ - name: Inject Configuration Layout File
+ run: |
+ mkdir -p local-code/.github
+ cp central-assets/.github/templates/release.yml local-code/.github/release.yml
+
+ # 4. Compile and create the native release using the injected template file
+ - name: Create Native Release Entry via GitHub CLI
+ env:
+ GITHUB_REF_NAME: ${{ github.ref_name }}
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ cd local-code
+ gh release create "$GITHUB_REF_NAME" \
+ --title "Release $GITHUB_REF_NAME (Draft)" \
+ --generate-notes \
+ --draft
diff --git a/draft-release/README.md b/draft-release/README.md
new file mode 100644
index 0000000..66a0483
--- /dev/null
+++ b/draft-release/README.md
@@ -0,0 +1,141 @@
+# Draft Release
+
+A reusable GitHub Actions [workflow](../.github/workflows/draft-release.yaml)
+that automates the creation of a draft GitHub release with an auto-generated
+changelog, driven by a centralised shared release template.
+
+## Features
+
+- **Zero Boilerplate:** Fetches a centralised configuration template from
+ [MetOffice/growss](https://github.com/MetOffice/growss) and injects it into
+ the calling repository at runtime. Downstream repositories do not need to
+ maintain a local `.github/release.yml` file.
+- **Strict Label Prioritization:** Generates a structured changelog dynamically
+ grouped by PR labels using the centralised
+ [release template](../.github/templates/release.yml).
+- **Safe Review Gate:** Creates the release in **draft** state, allowing
+ maintainers to audit or adjust notes before going live.
+- **Secure Architecture:** Native platform authentication passes `GITHUB_TOKEN`
+ seamlessly to handle both public and private repository cross-boundary
+ checkouts securely.
+- Tags the draft release with the triggering Git ref name (e.g. `v1.2.3`).
+
+## Changelog Categories for Simulation Systems
+
+Pull Requests are evaluated sequentially from **top to bottom**. A PR lands
+exclusively in the first category it satisfies. Single PRs will never be
+duplicated across separate sections.
+
+| Category | Labels | Example |
+| -------- | ------ | ------- |
+| 💥 Breaking Changes | `breaking-change` | Critical infrastructure changes, breaking adjustments, or API removals. |
+| 🐛 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 optimizations 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 | `performance`, `optimization` | **performance:** Direct speed execution metrics or runtime improvements.
**optimization:** Memory, storage, or other resource optimizations. |
+| ♻️ Refactoring | `refactor` | Code layout cleanup or modularisation without behavior changes. |
+
+### 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 |
+| ----- | ------- |
+| `build` | Changes to build tools or external compiler toolchains |
+| `chore` | General housekeeping, licence updates, or minor admin tasks |
+| `ci` | Infrastructure automation changes or GitHub Actions workflow logic updates |
+| `dependencies` | Automated third-party package upgrades (e.g. Dependabot) |
+| `ignore-changelog` | Manual escape-hatch label used to explicitly suppress a specific PR from release logs |
+| `test` | Unit tests additions, test framework assertions, or mock suite configurations |
+| `wip` | Work-in-progress PRs not ready for release |
+
+## Permissions
+
+The calling execution job block must explicitly declare `contents: write`
+to authorize the native GitHub CLI runner to write assets and publish release footprints:
+
+```yaml
+permissions:
+ contents: write
+```
+
+## Usage
+
+### Variant A: Production Tag Auto-Trigger (Recommended)
+
+```yaml
+name: Draft Release Deployment
+
+on:
+ push:
+ tags:
+ - "v*" # Triggers automatically for semantic production tags
+
+jobs:
+ release:
+ uses: MetOffice/growss/.github/workflows/draft-release.yaml@main
+ permissions:
+ contents: write
+```
+
+### Variant B: Hybrid Trigger (Tag Push + Manual Run)
+
+```yaml
+name: Draft Release Deployment
+
+on:
+ push:
+ tags:
+ - "v*"
+ workflow_dispatch:
+
+jobs:
+ release:
+ uses: MetOffice/growss/.github/workflows/draft-release.yaml@main
+ permissions:
+ contents: write
+```
+
+> [!WARNING]
+> **Important Trigger Caveat:** The draft release title, tag mapping,
+> and PR delta history bounds are determined dynamically from
+> `${{ github.ref_name }}`.
+>
+> - Triggering via **Tag Push** ensures the title matches the target
+> version tag (e.g. `v1.2.3`).
+> - Triggering via **Workflow Dispatch** uses the active branch name (e.g. `main`)
+> as the release name target, which will include all historical unreleased
+> commits instead of a bounded tag delta window.
+
+## How It Works
+
+```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. Fetch Shared Template]
+ Step1 --> Step2[2. Fetch Target Workspace]
+ Step2 --> Step3[3. Inject Runtime Config]
+ Step3 --> Step4[4. Create Draft Release]
+
+ %% Step Explanations (Side-nodes)
+ Step1 -.-> Desc1[Checks out MetOffice/growss to parse .github/templates/release.yml]
+ Step2 -.-> Desc2[Checks out the calling repository's
workspace directory branch]
+ Step3 -.-> Desc3[Copies the central template directly
into local .github/release.yml]
+ Step4 -.-> Desc4[Executes gh release create with --generate-notes --draft flags]
+
+ %% Assign Classes to Nodes
+ class Trigger trigger;
+ class Step1,Step2,Step3,Step4 step;
+ class Desc1,Desc2,Desc3,Desc4 desc;
+```
+
+## Licence
+
+© Crown copyright Met Office. See [LICENCE](../LICENCE) file for details.
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
From 00e05f707c92ead741d616ecb35062b3529ed3fb Mon Sep 17 00:00:00 2001
From: Yaswant Pradhan <2984440+yaswant@users.noreply.github.com>
Date: Sat, 4 Jul 2026 02:37:37 +0100
Subject: [PATCH 2/7] Enhance draft release workflow with new changelog
categories and a helper script for generating release notes
---
.github/bin/compile-release-notes.sh | 241 +++++++++++++++++++++++++++
.github/templates/release.yml | 28 ++--
.github/workflows/draft-release.yaml | 114 ++++++++++---
draft-release/README.md | 79 +++++----
4 files changed, 393 insertions(+), 69 deletions(-)
create mode 100644 .github/bin/compile-release-notes.sh
diff --git a/.github/bin/compile-release-notes.sh b/.github/bin/compile-release-notes.sh
new file mode 100644
index 0000000..6688b16
--- /dev/null
+++ b/.github/bin/compile-release-notes.sh
@@ -0,0 +1,241 @@
+#!/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 \
+# \
+#
+#
+# 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 7 ]] && error_exit "Usage: $0 "
+
+CALLER_REPO="$1"
+TEMPLATE_FILE="$2"
+GITHUB_SHA="$3"
+GITHUB_REF_NAME="$4"
+TEMPLATE_REPO="$5"
+TEMPLATE_REF="$6"
+TEMPLATE_PATH="$7"
+
+# 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. Not Implemented: 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 "::group::Caller Repository"
+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 "::endgroup::"
+echo "::group::Template Repository"
+echo "Template repository: ${TEMPLATE_REPO}"
+echo "Template ref: ${TEMPLATE_REF}"
+echo "Template path: ${TEMPLATE_PATH}"
+echo "Template file: ${TEMPLATE_FILE}"
+echo "::endgroup::"
+# ------------------------------------------------------------------------------
+
+if [ -n "$PREV_TAG" ]; then
+ COMPARE_RANGE="${PREV_TAG}...${GITHUB_SHA}"
+ # Single API call to get all commit hashes within the release window
+ gh api "repos/${CALLER_REPO}/compare/${COMPARE_RANGE}" --jq '.commits[].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
+# cat commit-shas.txt # debugging output
+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
+
+# Parallelised extraction of pull requests directly from the repository's main PR list.
+# Instead of iterating through commits, we pull the 100 most recent merged PRs,
+# then match them against commit-shas.txt entirely locally using awk/grep.
+gh api "repos/${CALLER_REPO}/pulls?state=closed&per_page=100" --paginate \
+ --jq '.[] | select(.merged_at != null) |
+ "\(.merge_commit_sha) \(.number) \(.title) | \(.user.login) | \([.labels[].name] | join(","))"' >recent-prs.txt
+
+# Create or clear file to store PR data
+: >pr-data.txt
+# Filter recent-prs down to ONLY those matching a commit SHA from our release range
+while IFS= read -r sha; do
+ [ -z "$sha" ] && continue
+ grep "^${sha} " recent-prs.txt >>pr-data.txt || true
+done 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
+
+# Read the local intersected PR file and map categories
+while IFS= read -r row || [ -n "$row" ]; do
+ [ -z "$row" ] && continue
+
+ # Row looks like: | |
+ metadata=$(echo "$row" | cut -d'|' -f1)
+ user=$(echo "$row" | cut -d'|' -f2 | xargs)
+ labels=$(echo "$row" | cut -d'|' -f3 | xargs)
+ pr=$(echo "$metadata" | awk '{print $2}')
+ title=$(echo "$metadata" | cut -d' ' -f3-)
+
+ # 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}"
+
+ # Check which categories this PR matches
+ 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
+ if [ "$matched_this_category" -eq 0 ]; then
+ matched_this_category=1
+ echo "$pr_line" >>"$tmp_dir/cat_${i}.md"
+ fi
+ break # Break out of PR labels loop; move 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/.github/templates/release.yml b/.github/templates/release.yml
index 6d6fcd2..c01edb4 100644
--- a/.github/templates/release.yml
+++ b/.github/templates/release.yml
@@ -15,6 +15,16 @@ changelog:
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"
@@ -40,12 +50,11 @@ changelog:
labels:
- "documentation"
- # (performance) Direct speed execution metrics or runtime improvements.
- # (optimization) Memory, storage, or other resource optimizations.
+ # (optimisation) Direct speed execution metrics, runtime improvements,
+ # Memory, storage, or other resource optimisations.
- title: "⚡ Performance Improvements"
labels:
- - "performance"
- - "optimization"
+ - "optimisation"
# (refactor) Code cleanup, modularisation, or other internal improvements.
- title: "♻️ Refactoring"
@@ -56,10 +65,9 @@ changelog:
# from the final changelog output, taking ultimate precedence over categories above.
exclude:
labels:
- - "build" # Changes affecting build tools or external compiler toolchains.
- - "chore" # General housekeeping, license updates, or minor administrative tasks.
- - "ci" # Modifications to GitHub Actions workflows or automation systems.
- - "dependencies" # Automated third-party package upgrades (e.g., Dependabot alerts).
+ - "build" # Changes affecting build tools or external compiler toolchains.
+ - "chore" # General housekeeping, license updates, or minor administrative tasks.
+ - "ci" # Modifications to GitHub Actions workflows or automation systems.
- "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.
+ - "test" # Changes related to testing frameworks or test cases.
+ - "wip" # Work in progress PRs that are not ready for release.
diff --git a/.github/workflows/draft-release.yaml b/.github/workflows/draft-release.yaml
index d967c44..10e35f3 100644
--- a/.github/workflows/draft-release.yaml
+++ b/.github/workflows/draft-release.yaml
@@ -8,48 +8,110 @@ name: Shared Global Release Engine
on:
workflow_call:
+ inputs:
+ template-repo:
+ description: Repository containing the shared changelog template
+ required: false
+ type: string
+ default: MetOffice/growss
+ template-ref:
+ description: Ref for the shared changelog template (branch, tag, or SHA)
+ required: false
+ type: string
+ default: main
+ template-path:
+ description: Path to changelog template inside template-repo
+ required: false
+ type: string
+ default: .github/templates/release.yml
+
+permissions:
+ contents: read
jobs:
run-release:
- name: "Draft Release"
+ name: Draft Summary
runs-on: ubuntu-latest
+ concurrency:
+ group: draft-release-${{ github.repository }}-${{ github.ref_name }}
+ cancel-in-progress: false
permissions:
- contents: write # Required to create the draft release in the calling repository
+ contents: write # Required to create or update a release
+ pull-requests: read # Required to query PRs for changelog generation
+
steps:
- # 1. Fetch Shared Release Template from your central public repository
- # Public repos can be fetched by anyone, but we pass GITHUB_TOKEN for private callers
- - name: Fetch Shared Release Template
+ - name: Checkout caller repository with full history
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
- repository: MetOffice/growss
- path: central-assets
- token: ${{ secrets.GITHUB_TOKEN }} # Ensures private callers can cross the bridge safely
+ path: local-code
+ fetch-depth: 0
+ fetch-tags: true
persist-credentials: false
- # 2. Fetch Active Workspace Code
- # Using tokens ensures private forks can checkout their own code safely
- - name: Fetch Active Workspace Code
+ - name: Checkout shared template repository (sparse)
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
- path: local-code
- token: ${{ secrets.GITHUB_TOKEN }}
+ repository: ${{ inputs.template-repo }}
+ ref: ${{ inputs.template-ref }}
+ path: central-assets
+ fetch-depth: 1
persist-credentials: false
- allow-unsafe-pr-checkout: true # allow checking out PR head refs from forks
+ sparse-checkout: |
+ .github/bin
+ .github/templates
- # 3. Inject the file from your custom templates folder into the active local workspace
- - name: Inject Configuration Layout File
+ - name: Compile categorised release notes from template labels
+ id: compile-release-notes
+ shell: bash
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ CALLER_REPO: ${{ github.repository }}
+ TEMPLATE_REPO: ${{ inputs.template-repo }}
+ TEMPLATE_PATH: ${{ inputs.template-path }}
+ TEMPLATE_FILE: central-assets/${{ inputs.template-path }}
+ TEMPLATE_REF: ${{ inputs.template-ref }}
+ GITHUB_REF_NAME: ${{ github.ref_name }}
+ GITHUB_SHA: ${{ github.sha }}
run: |
- mkdir -p local-code/.github
- cp central-assets/.github/templates/release.yml local-code/.github/release.yml
+ set -euo pipefail
+
+ # compile-release-notes.sh creates the release-notes.md
+ # draft release notes file, in workflow root, based on merged PRs and
+ # a shared changelog template. It is designed to be called from a
+ # GitHub Actions workflow, not directly by users.
+ # Usage:
+ # bash central-assets/.github/bin/compile-release-notes.sh \
+ # \
+ #
- # 4. Compile and create the native release using the injected template file
- - name: Create Native Release Entry via GitHub CLI
+ bash central-assets/.github/bin/compile-release-notes.sh \
+ "$CALLER_REPO" \
+ "$TEMPLATE_FILE" \
+ "$GITHUB_SHA" \
+ "$GITHUB_REF_NAME" \
+ "$TEMPLATE_REPO" \
+ "$TEMPLATE_REF" \
+ "$TEMPLATE_PATH"
+
+ - name: Create or update draft release
+ if: steps.compile-release-notes.outputs.has_commits == 'true'
+ working-directory: local-code
env:
- GITHUB_REF_NAME: ${{ github.ref_name }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GITHUB_REF_NAME: ${{ github.ref_name }}
run: |
- cd local-code
- gh release create "$GITHUB_REF_NAME" \
- --title "Release $GITHUB_REF_NAME (Draft)" \
- --generate-notes \
- --draft
+ 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}" \
+ --title "${TITLE}" \
+ --notes-file ../release-notes.md \
+ --draft
+ else
+ gh release create "${GITHUB_REF_NAME}" \
+ --title "${TITLE}" \
+ --notes-file ../release-notes.md \
+ --draft
+ fi
diff --git a/draft-release/README.md b/draft-release/README.md
index 66a0483..333aca6 100644
--- a/draft-release/README.md
+++ b/draft-release/README.md
@@ -10,7 +10,7 @@ changelog, driven by a centralised shared release template.
[MetOffice/growss](https://github.com/MetOffice/growss) and injects it into
the calling repository at runtime. Downstream repositories do not need to
maintain a local `.github/release.yml` file.
-- **Strict Label Prioritization:** Generates a structured changelog dynamically
+- **Strict Label Prioritisation:** Generates a structured changelog dynamically
grouped by PR labels using the centralised
[release template](../.github/templates/release.yml).
- **Safe Review Gate:** Creates the release in **draft** state, allowing
@@ -18,47 +18,53 @@ changelog, driven by a centralised shared release template.
- **Secure Architecture:** Native platform authentication passes `GITHUB_TOKEN`
seamlessly to handle both public and private repository cross-boundary
checkouts securely.
+- **Skip Empty Releases:** Automatically skips draft release creation if no
+ merged PRs are found within the release window.
- Tags the draft release with the triggering Git ref name (e.g. `v1.2.3`).
## Changelog Categories for Simulation Systems
-Pull Requests are evaluated sequentially from **top to bottom**. A PR lands
-exclusively in the first category it satisfies. Single PRs will never be
-duplicated across separate sections.
+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 optimizations or background logic shifts.|
+| 🔬 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 | `performance`, `optimization` | **performance:** Direct speed execution metrics or runtime improvements.
**optimization:** Memory, storage, or other resource optimizations. |
-| ♻️ Refactoring | `refactor` | Code layout cleanup or modularisation without behavior changes. |
+| ⚡ 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. |
### Excluded Labels
-PRs carrying any of the following labels are **hidden** from the changelog output
-entirely, regardless of any other labels they carry:
+PRs carrying any of the following labels are **hidden** from the changelog
+output entirely, regardless of any other labels they carry:
| Label | Purpose |
| ----- | ------- |
-| `build` | Changes to build tools or external compiler toolchains |
-| `chore` | General housekeeping, licence updates, or minor admin tasks |
-| `ci` | Infrastructure automation changes or GitHub Actions workflow logic updates |
-| `dependencies` | Automated third-party package upgrades (e.g. Dependabot) |
-| `ignore-changelog` | Manual escape-hatch label used to explicitly suppress a specific PR from release logs |
-| `test` | Unit tests additions, test framework assertions, or mock suite configurations |
-| `wip` | Work-in-progress PRs not ready for release |
+| `build` | Changes affecting build tools or external compiler toolchains. |
+| `chore` | General housekeeping, license updates, or minor administrative tasks. |
+| `ci` | Modifications to GitHub Actions workflows or automation systems. |
+| `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. |
## Permissions
-The calling execution job block must explicitly declare `contents: write`
-to authorize the native GitHub CLI runner to write assets and publish release footprints:
+The calling execution job block must explicitly declare `contents: write` to
+authorise the native GitHub CLI runner to write assets and publish release
+footprints:
```yaml
permissions:
- contents: write
+ contents: write
+ pull-requests: read
```
## Usage
@@ -76,8 +82,11 @@ on:
jobs:
release:
uses: MetOffice/growss/.github/workflows/draft-release.yaml@main
+ with:
+ template-ref: "main"
permissions:
contents: write
+ pull-requests: read
```
### Variant B: Hybrid Trigger (Tag Push + Manual Run)
@@ -94,20 +103,21 @@ on:
jobs:
release:
uses: MetOffice/growss/.github/workflows/draft-release.yaml@main
+ with:
+ template-ref: "main"
permissions:
contents: write
```
-> [!WARNING]
-> **Important Trigger Caveat:** The draft release title, tag mapping,
+> [!WARNING] **Important Trigger Caveat:** The draft release title, tag mapping,
> and PR delta history bounds are determined dynamically from
> `${{ github.ref_name }}`.
>
-> - Triggering via **Tag Push** ensures the title matches the target
-> version tag (e.g. `v1.2.3`).
-> - Triggering via **Workflow Dispatch** uses the active branch name (e.g. `main`)
-> as the release name target, which will include all historical unreleased
-> commits instead of a bounded tag delta window.
+> - Triggering via **Tag Push** ensures the title matches the target version tag
+> (e.g. `v1.2.3`).
+> - Triggering via **Workflow Dispatch** uses the active branch name (e.g.
+> `main`) as the release name target, which will include all historical
+> unreleased commits instead of a bounded tag delta window.
## How It Works
@@ -121,19 +131,22 @@ graph TD
%% Workflow Connections
Trigger([Tag Push Event]) --> Step1[1. Fetch Shared Template]
Step1 --> Step2[2. Fetch Target Workspace]
- Step2 --> Step3[3. Inject Runtime Config]
- Step3 --> Step4[4. Create Draft Release]
+ Step2 --> Step3[3. Compile Release Notes]
+ Step3 --> Step4{Has Merged PRs?}
+ Step4 -->|Yes| Step5[4. Create Draft Release]
+ Step4 -->|No| Skip[Skip Release Creation]
%% Step Explanations (Side-nodes)
Step1 -.-> Desc1[Checks out MetOffice/growss to parse .github/templates/release.yml]
- Step2 -.-> Desc2[Checks out the calling repository's
workspace directory branch]
- Step3 -.-> Desc3[Copies the central template directly
into local .github/release.yml]
- Step4 -.-> Desc4[Executes gh release create with --generate-notes --draft flags]
+ Step2 -.-> Desc2[Checks out the calling repository's
workspace directory branch with full history]
+ Step3 -.-> Desc3[Executes .github/bin/compile-release-notes.sh
to extract merged PRs and categorise by labels]
+ Step5 -.-> Desc4[Executes gh release create/edit
with categorised changelog notes in draft state]
+ Skip -.-> Desc5[Workflow completes early
if no merged PRs found in release window]
%% Assign Classes to Nodes
class Trigger trigger;
- class Step1,Step2,Step3,Step4 step;
- class Desc1,Desc2,Desc3,Desc4 desc;
+ class Step1,Step2,Step3,Step4,Step5,Skip step;
+ class Desc1,Desc2,Desc3,Desc4,Desc5 desc;
```
## Licence
From b7d296f9261df8aa609e6a8a42d25a671a741a8d Mon Sep 17 00:00:00 2001
From: Yaswant Pradhan <2984440+yaswant@users.noreply.github.com>
Date: Sat, 4 Jul 2026 18:22:41 +0100
Subject: [PATCH 3/7] Address reviewer comments
---
.github/bin/compile-release-notes.sh | 20 ++++++++------------
.github/templates/release.yml | 2 +-
.github/workflows/draft-release.yaml | 5 ++---
draft-release/README.md | 3 +++
4 files changed, 14 insertions(+), 16 deletions(-)
diff --git a/.github/bin/compile-release-notes.sh b/.github/bin/compile-release-notes.sh
index 6688b16..61a1403 100644
--- a/.github/bin/compile-release-notes.sh
+++ b/.github/bin/compile-release-notes.sh
@@ -82,20 +82,16 @@ if [ ! -s commit-shas.txt ]; then
exit 0
fi
-# Parallelised extraction of pull requests directly from the repository's main PR list.
-# Instead of iterating through commits, we pull the 100 most recent merged PRs,
-# then match them against commit-shas.txt entirely locally using awk/grep.
-gh api "repos/${CALLER_REPO}/pulls?state=closed&per_page=100" --paginate \
- --jq '.[] | select(.merged_at != null) |
+# Extract pull requests directly from the repository's main PR list.
+# Pull the 200 most recent merged PRs, then match them against commit-shas.txt
+{
+ 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(","))"' >recent-prs.txt
-# Create or clear file to store PR data
-: >pr-data.txt
-# Filter recent-prs down to ONLY those matching a commit SHA from our release range
-while IFS= read -r sha; do
- [ -z "$sha" ] && continue
- grep "^${sha} " recent-prs.txt >>pr-data.txt || true
-done pr-data.txt
# cat pr-data.txt # debugging output
if [ ! -s pr-data.txt ]; then
diff --git a/.github/templates/release.yml b/.github/templates/release.yml
index c01edb4..858a8e2 100644
--- a/.github/templates/release.yml
+++ b/.github/templates/release.yml
@@ -7,7 +7,7 @@
changelog:
# Categories are evaluated sequentially from top to bottom.
- # If a PR has multiple matching labels, it will land in the FIRST category it satisfies.
+ # If a PR has multiple matching labels, it will appear in all matching categories.
categories:
# (breaking-change) Critical infrastructure changes or API removals.
diff --git a/.github/workflows/draft-release.yaml b/.github/workflows/draft-release.yaml
index 10e35f3..db38618 100644
--- a/.github/workflows/draft-release.yaml
+++ b/.github/workflows/draft-release.yaml
@@ -16,9 +16,8 @@ on:
default: MetOffice/growss
template-ref:
description: Ref for the shared changelog template (branch, tag, or SHA)
- required: false
+ required: true
type: string
- default: main
template-path:
description: Path to changelog template inside template-repo
required: false
@@ -102,7 +101,7 @@ jobs:
run: |
set -euo pipefail
- TITLE="Release ${GITHUB_REF_NAME} (Draft)"
+ TITLE="Release ${GITHUB_REF_NAME} (Milestone?)"
if gh release view "${GITHUB_REF_NAME}" >/dev/null 2>&1; then
gh release edit "${GITHUB_REF_NAME}" \
diff --git a/draft-release/README.md b/draft-release/README.md
index 333aca6..8b03e2c 100644
--- a/draft-release/README.md
+++ b/draft-release/README.md
@@ -69,6 +69,9 @@ permissions:
## Usage
+To ensure deterministic behaviour across versions, use the same reference for
+both the workflow and `template-ref`.
+
### Variant A: Production Tag Auto-Trigger (Recommended)
```yaml
From cebb0ddf7291cabb9ef9344df965d69fc3a1caeb Mon Sep 17 00:00:00 2001
From: Yaswant Pradhan <2984440+yaswant@users.noreply.github.com>
Date: Mon, 6 Jul 2026 23:50:18 +0100
Subject: [PATCH 4/7] Refactor draft release workflow: Implement shared release
engine with categorised changelog generation
- Introduced a composite action for draft releases, consolidating the release process.
- Added a new script to compile release notes from a shared changelog template.
- Created a structured YAML template for changelog categories and exclusions.
- Updated README to reflect new structure and usage instructions.
- Removed previously implemented reusable workflow and streamlined the workflow for better maintainability.
---
.github/workflows/draft-release.yaml | 116 -----------
draft-release/README.md | 181 ++++++++----------
draft-release/action.yaml | 49 +++++
.../bin/compile-release-notes.sh | 96 ++++++----
.../templates/release.yml | 18 +-
5 files changed, 198 insertions(+), 262 deletions(-)
delete mode 100644 .github/workflows/draft-release.yaml
create mode 100644 draft-release/action.yaml
rename {.github => draft-release}/bin/compile-release-notes.sh (69%)
rename {.github => draft-release}/templates/release.yml (82%)
diff --git a/.github/workflows/draft-release.yaml b/.github/workflows/draft-release.yaml
deleted file mode 100644
index db38618..0000000
--- a/.github/workflows/draft-release.yaml
+++ /dev/null
@@ -1,116 +0,0 @@
-# ------------------------------------------------------------------------------
-# (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
-
-on:
- workflow_call:
- inputs:
- template-repo:
- description: Repository containing the shared changelog template
- required: false
- type: string
- default: MetOffice/growss
- template-ref:
- description: Ref for the shared changelog template (branch, tag, or SHA)
- required: true
- type: string
- template-path:
- description: Path to changelog template inside template-repo
- required: false
- type: string
- default: .github/templates/release.yml
-
-permissions:
- contents: read
-
-jobs:
- run-release:
- name: Draft Summary
- runs-on: ubuntu-latest
- concurrency:
- group: draft-release-${{ github.repository }}-${{ github.ref_name }}
- cancel-in-progress: false
- permissions:
- contents: write # Required to create or update a release
- pull-requests: read # Required to query PRs for changelog generation
-
- 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: Checkout shared template repository (sparse)
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- with:
- repository: ${{ inputs.template-repo }}
- ref: ${{ inputs.template-ref }}
- path: central-assets
- fetch-depth: 1
- persist-credentials: false
- sparse-checkout: |
- .github/bin
- .github/templates
-
- - name: Compile categorised release notes from template labels
- id: compile-release-notes
- shell: bash
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- CALLER_REPO: ${{ github.repository }}
- TEMPLATE_REPO: ${{ inputs.template-repo }}
- TEMPLATE_PATH: ${{ inputs.template-path }}
- TEMPLATE_FILE: central-assets/${{ inputs.template-path }}
- TEMPLATE_REF: ${{ inputs.template-ref }}
- GITHUB_REF_NAME: ${{ github.ref_name }}
- GITHUB_SHA: ${{ github.sha }}
- run: |
- set -euo pipefail
-
- # compile-release-notes.sh creates the release-notes.md
- # draft release notes file, in workflow root, based on merged PRs and
- # a shared changelog template. It is designed to be called from a
- # GitHub Actions workflow, not directly by users.
- # Usage:
- # bash central-assets/.github/bin/compile-release-notes.sh \
- # \
- #
-
- bash central-assets/.github/bin/compile-release-notes.sh \
- "$CALLER_REPO" \
- "$TEMPLATE_FILE" \
- "$GITHUB_SHA" \
- "$GITHUB_REF_NAME" \
- "$TEMPLATE_REPO" \
- "$TEMPLATE_REF" \
- "$TEMPLATE_PATH"
-
- - name: Create or update draft release
- if: steps.compile-release-notes.outputs.has_commits == 'true'
- working-directory: local-code
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- GITHUB_REF_NAME: ${{ github.ref_name }}
- run: |
- set -euo pipefail
-
- TITLE="Release ${GITHUB_REF_NAME} (Milestone?)"
-
- if gh release view "${GITHUB_REF_NAME}" >/dev/null 2>&1; then
- gh release edit "${GITHUB_REF_NAME}" \
- --title "${TITLE}" \
- --notes-file ../release-notes.md \
- --draft
- else
- gh release create "${GITHUB_REF_NAME}" \
- --title "${TITLE}" \
- --notes-file ../release-notes.md \
- --draft
- fi
diff --git a/draft-release/README.md b/draft-release/README.md
index 8b03e2c..21a8651 100644
--- a/draft-release/README.md
+++ b/draft-release/README.md
@@ -1,26 +1,20 @@
-# Draft Release
-
-A reusable GitHub Actions [workflow](../.github/workflows/draft-release.yaml)
-that automates the creation of a draft GitHub release with an auto-generated
-changelog, driven by a centralised shared release template.
-
-## Features
-
-- **Zero Boilerplate:** Fetches a centralised configuration template from
- [MetOffice/growss](https://github.com/MetOffice/growss) and injects it into
- the calling repository at runtime. Downstream repositories do not need to
- maintain a local `.github/release.yml` file.
-- **Strict Label Prioritisation:** Generates a structured changelog dynamically
- grouped by PR labels using the centralised
- [release template](../.github/templates/release.yml).
-- **Safe Review Gate:** Creates the release in **draft** state, allowing
- maintainers to audit or adjust notes before going live.
-- **Secure Architecture:** Native platform authentication passes `GITHUB_TOKEN`
- seamlessly to handle both public and private repository cross-boundary
- checkouts securely.
-- **Skip Empty Releases:** Automatically skips draft release creation if no
- merged PRs are found within the release window.
-- Tags the draft release with the triggering Git ref name (e.g. `v1.2.3`).
+# Draft Release - a Shared Global Release Engine
+
+Composite [action](action.yml) 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.yml` (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
@@ -36,10 +30,15 @@ for comprehensive changelog organisation.
| ⚠️ 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. |
+| 🔬 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
@@ -48,109 +47,85 @@ output entirely, regardless of any other labels they carry:
| Label | Purpose |
| ----- | ------- |
-| `build` | Changes affecting build tools or external compiler toolchains. |
-| `chore` | General housekeeping, license updates, or minor administrative tasks. |
-| `ci` | Modifications to GitHub Actions workflows or automation systems. |
| `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. |
-## Permissions
-
-The calling execution job block must explicitly declare `contents: write` to
-authorise the native GitHub CLI runner to write assets and publish release
-footprints:
-
-```yaml
-permissions:
- contents: write
- pull-requests: read
-```
-
-## Usage
+## How It Works
-To ensure deterministic behaviour across versions, use the same reference for
-both the workflow and `template-ref`.
+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.
-### Variant A: Production Tag Auto-Trigger (Recommended)
+```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;
-```yaml
-name: Draft Release Deployment
+ %% 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]
-on:
- push:
- tags:
- - "v*" # Triggers automatically for semantic production tags
+ %% 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]
-jobs:
- release:
- uses: MetOffice/growss/.github/workflows/draft-release.yaml@main
- with:
- template-ref: "main"
- permissions:
- contents: write
- pull-requests: read
+ %% Assign Classes to Nodes
+ class Trigger trigger;
+ class Step1,Step2,Step3,Step4,Skip step;
+ class Desc1,Desc2,Desc3,Desc4 desc;
```
-### Variant B: Hybrid Trigger (Tag Push + Manual Run)
+## Usage
```yaml
-name: Draft Release Deployment
+name: Automated Release Notes
on:
- push:
- tags:
- - "v*"
- workflow_dispatch:
+ push:
+ tags:
+ - "v*"
jobs:
- release:
- uses: MetOffice/growss/.github/workflows/draft-release.yaml@main
- with:
- template-ref: "main"
- permissions:
- contents: write
+ 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
```
-> [!WARNING] **Important Trigger Caveat:** The draft release title, tag mapping,
-> and PR delta history bounds are determined dynamically from
-> `${{ github.ref_name }}`.
->
-> - Triggering via **Tag Push** ensures the title matches the target version tag
-> (e.g. `v1.2.3`).
-> - Triggering via **Workflow Dispatch** uses the active branch name (e.g.
-> `main`) as the release name target, which will include all historical
-> unreleased commits instead of a bounded tag delta window.
+## Required Permissions
-## How It Works
+- `contents: write` to create/edit draft releases.
+- `pull-requests: read` to read merged PR metadata for changelog generation.
-```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;
+## Outputs
- %% Workflow Connections
- Trigger([Tag Push Event]) --> Step1[1. Fetch Shared Template]
- Step1 --> Step2[2. Fetch Target Workspace]
- Step2 --> Step3[3. Compile Release Notes]
- Step3 --> Step4{Has Merged PRs?}
- Step4 -->|Yes| Step5[4. Create Draft Release]
- Step4 -->|No| Skip[Skip Release Creation]
+The compile step exposes:
- %% Step Explanations (Side-nodes)
- Step1 -.-> Desc1[Checks out MetOffice/growss to parse .github/templates/release.yml]
- Step2 -.-> Desc2[Checks out the calling repository's
workspace directory branch with full history]
- Step3 -.-> Desc3[Executes .github/bin/compile-release-notes.sh
to extract merged PRs and categorise by labels]
- Step5 -.-> Desc4[Executes gh release create/edit
with categorised changelog notes in draft state]
- Skip -.-> Desc5[Workflow completes early
if no merged PRs found in release window]
+- `has_commits`: `true` when release notes were generated from commits,
+ otherwise `false`.
- %% Assign Classes to Nodes
- class Trigger trigger;
- class Step1,Step2,Step3,Step4,Step5,Skip step;
- class Desc1,Desc2,Desc3,Desc4,Desc5 desc;
-```
+## 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
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/.github/bin/compile-release-notes.sh b/draft-release/bin/compile-release-notes.sh
similarity index 69%
rename from .github/bin/compile-release-notes.sh
rename to draft-release/bin/compile-release-notes.sh
index 61a1403..79f5449 100644
--- a/.github/bin/compile-release-notes.sh
+++ b/draft-release/bin/compile-release-notes.sh
@@ -8,10 +8,11 @@
# Helper script for the draft-release GitHub Action workflow.
#
# Usage:
-# compile-release-notes.sh \
-# \
-#
+# 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
@@ -23,15 +24,18 @@ error_exit() {
exit 1
}
-[[ $# -lt 7 ]] && error_exit "Usage: $0 "
+[[ $# -lt 3 ]] && error_exit "Usage: $0 "
CALLER_REPO="$1"
-TEMPLATE_FILE="$2"
-GITHUB_SHA="$3"
-GITHUB_REF_NAME="$4"
-TEMPLATE_REPO="$5"
-TEMPLATE_REF="$6"
-TEMPLATE_PATH="$7"
+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."
@@ -41,36 +45,29 @@ command -v jq >/dev/null 2>&1 || error_exit "jq is required but not available."
# 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. Not Implemented: Get the latest tag starting with "v", excluding the current branch/tag name.
+# 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 "::group::Caller Repository"
-echo "Caller repository: ${CALLER_REPO}"
+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 "::endgroup::"
-echo "::group::Template Repository"
-echo "Template repository: ${TEMPLATE_REPO}"
-echo "Template ref: ${TEMPLATE_REF}"
+echo "**Template Repository**: ${TEMPLATE_REPO}"
+echo "Template ref (version): ${TEMPLATE_REF}"
echo "Template path: ${TEMPLATE_PATH}"
echo "Template file: ${TEMPLATE_FILE}"
-echo "::endgroup::"
# ------------------------------------------------------------------------------
if [ -n "$PREV_TAG" ]; then
- COMPARE_RANGE="${PREV_TAG}...${GITHUB_SHA}"
- # Single API call to get all commit hashes within the release window
- gh api "repos/${CALLER_REPO}/compare/${COMPARE_RANGE}" --jq '.commits[].sha' >commit-shas.txt
+ 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
-# cat commit-shas.txt # debugging output
if [ ! -s commit-shas.txt ]; then
{
echo "## Changelog"
@@ -82,18 +79,18 @@ if [ ! -s commit-shas.txt ]; then
exit 0
fi
-# Extract pull requests directly from the repository's main PR list.
-# Pull the 200 most recent merged PRs, then match them against commit-shas.txt
+# 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(","))"' >recent-prs.txt
+ "\(.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
+# 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
-# cat pr-data.txt # debugging output
if [ ! -s pr-data.txt ]; then
{
echo "## Changelog"
@@ -165,17 +162,37 @@ 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 looks like: | |
- metadata=$(echo "$row" | cut -d'|' -f1)
- user=$(echo "$row" | cut -d'|' -f2 | xargs)
- labels=$(echo "$row" | cut -d'|' -f3 | xargs)
- pr=$(echo "$metadata" | awk '{print $2}')
- title=$(echo "$metadata" | cut -d' ' -f3-)
+ # 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,,}"
@@ -194,20 +211,18 @@ while IFS= read -r row || [ -n "$row" ]; do
pr_line="1. ${title} (@${user}) in #${pr}"
- # Check which categories this PR matches
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
+ # 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 # Break out of PR labels loop; move to next category label
+ break # Breaks out of the PR labels loop; moves to next category label
fi
done
done
@@ -226,6 +241,13 @@ done
Date: Mon, 6 Jul 2026 23:52:17 +0100
Subject: [PATCH 5/7] Fix typo in README.md: change action.yml to action.yaml
---
draft-release/README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/draft-release/README.md b/draft-release/README.md
index 21a8651..7decd59 100644
--- a/draft-release/README.md
+++ b/draft-release/README.md
@@ -1,6 +1,6 @@
# Draft Release - a Shared Global Release Engine
-Composite [action](action.yml) that generates categorised draft release notes
+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.
From c02bc0dd001c0c8311c4dbc9c4dfa13cebbfec66 Mon Sep 17 00:00:00 2001
From: Yaswant Pradhan <2984440+yaswant@users.noreply.github.com>
Date: Tue, 7 Jul 2026 00:03:45 +0100
Subject: [PATCH 6/7] Fix typo in README.md: change action.yml to action.yaml
---
draft-release/README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/draft-release/README.md b/draft-release/README.md
index 7decd59..cbc79e3 100644
--- a/draft-release/README.md
+++ b/draft-release/README.md
@@ -9,7 +9,7 @@ the current tag.
This action is intentionally located at repository root level and has the
following components:
-- `draft-release/action.yml` (composite action)
+- `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)
From 36fbbb00864c048937f3975cd6af5c28f32ccc5f Mon Sep 17 00:00:00 2001
From: Yaswant Pradhan <2984440+yaswant@users.noreply.github.com>
Date: Tue, 7 Jul 2026 00:05:34 +0100
Subject: [PATCH 7/7] Fix indentation in draft release workflow YAML
configuration
---
draft-release/README.md | 22 +++++++++++-----------
1 file changed, 11 insertions(+), 11 deletions(-)
diff --git a/draft-release/README.md b/draft-release/README.md
index cbc79e3..35a7417 100644
--- a/draft-release/README.md
+++ b/draft-release/README.md
@@ -93,19 +93,19 @@ graph TD
name: Automated Release Notes
on:
- push:
- tags:
- - "v*"
+ 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
+ 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