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: | <user> | <labels> + 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 <pr-data.txt + +{ + echo "## Key Changes" + echo + wrote_any=0 + for i in "${!CATEGORY_TITLES[@]}"; do + if [ -s "$tmp_dir/cat_${i}.md" ]; then + wrote_any=1 + echo "### ${CATEGORY_TITLES[$i]}" + cat "$tmp_dir/cat_${i}.md" + echo + fi + done + [ "$wrote_any" -eq 0 ] && echo "* No pull requests matched release categories." + if [ -n "$PREV_TAG" ]; then + echo + echo "**Full Changelog**: https://github.com/${CALLER_REPO}/compare/${PREV_TAG}...${GITHUB_REF_NAME}" + fi +} >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 \ + # <caller_repo> <template_file> <github_sha> <github_ref_name> \ + # <template_repo> <template_ref> <template_path> - # 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.<br>**technical:** Deep algorithmic optimizations or background logic shifts.| +| 🔬 Scientific & Algorithmic Updates | `science`, `technical` | **science:** Domain-specific mathematical changes or model updates.<br>**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.<br>**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 <code>MetOffice/growss</code> to parse <code>.github/templates/release.yml</code>] - Step2 -.-> Desc2[Checks out the calling repository's<br>workspace directory branch] - Step3 -.-> Desc3[Copies the central template directly<br>into local <code>.github/release.yml</code>] - Step4 -.-> Desc4[Executes <code>gh release create</code> with <code>--generate-notes --draft</code> flags] + Step2 -.-> Desc2[Checks out the calling repository's<br>workspace directory branch with full history] + Step3 -.-> Desc3[Executes <code>.github/bin/compile-release-notes.sh</code><br>to extract merged PRs and categorise by labels] + Step5 -.-> Desc4[Executes <code>gh release create/edit</code><br>with categorised changelog notes in draft state] + Skip -.-> Desc5[Workflow completes early<br>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 <commit-shas.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 # 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 \ - # <caller_repo> <template_file> <github_sha> <github_ref_name> \ - # <template_repo> <template_ref> <template_path> - - 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.<br>**technical:** Deep algorithmic optimisations or background logic shifts. | +| 🔬 Scientific & Algorithmic Updates | `science`, `technical` | **science**: Domain-specific mathematical changes or model updates.<br>**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.<br>**chore**: General housekeeping, licence updates, or minor administrative tasks.<br>**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 <code>local-code</code><br>with full history and tags] + Step2 -.-> Desc2[Executes <code>compile-release-notes.sh</code><br>using action-local template <code>release.yml</code>] + Step4 -.-> Desc3[Executes <code>gh release create/edit</code><br>with categorised changelog notes in draft state] + Skip -.-> Desc4[Workflow completes early<br>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 <code>MetOffice/growss</code> to parse <code>.github/templates/release.yml</code>] - Step2 -.-> Desc2[Checks out the calling repository's<br>workspace directory branch with full history] - Step3 -.-> Desc3[Executes <code>.github/bin/compile-release-notes.sh</code><br>to extract merged PRs and categorise by labels] - Step5 -.-> Desc4[Executes <code>gh release create/edit</code><br>with categorised changelog notes in draft state] - Skip -.-> Desc5[Workflow completes early<br>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 <caller_repo> <template_file> <github_sha> \ -# <github_ref_name> <template_repo> <template_ref> \ -# <template_path> +# compile-release-notes.sh <caller_repo> <github_sha> <github_ref_name> # +# <caller_repo> - External repository that called this workflow +# <github_sha> - SHA of checkout in local-code (Caller repository) for which to generate release notes +# <github_ref_name> - 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 <caller_repo> <template_file> <github_sha> <github_ref_name> <template_repo> <template_ref> <template_path>" +[[ $# -lt 3 ]] && error_exit "Usage: $0 <caller_repo> <github_sha> <github_ref_name>" 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: <merge_sha> <pr_number> <title> | <user> | <labels> - 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: <merge_sha> <pr_number> <title> | <user> | <labels> | <author_association> + # 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 <pr-data.txt echo fi done + + if [ -s "$tmp_dir/new_contributors.md" ]; then + wrote_any=1 + echo "### New Contributors 🎉" + cat "$tmp_dir/new_contributors.md" + fi + [ "$wrote_any" -eq 0 ] && echo "* No pull requests matched release categories." if [ -n "$PREV_TAG" ]; then echo diff --git a/.github/templates/release.yml b/draft-release/templates/release.yml similarity index 82% rename from .github/templates/release.yml rename to draft-release/templates/release.yml index 858a8e2..77209e8 100644 --- a/.github/templates/release.yml +++ b/draft-release/templates/release.yml @@ -3,7 +3,7 @@ # The file LICENCE, distributed with this code, contains details of the terms # under which the code may be used. # ------------------------------------------------------------------------------ -# Simulation Systems Changelog Configuration +# Simulation Systems Release Changelog Configuration changelog: # Categories are evaluated sequentially from top to bottom. @@ -61,13 +61,19 @@ changelog: 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: - - "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. From 56c5ff5a450ff5c411ccca579db56a9bb6eb5355 Mon Sep 17 00:00:00 2001 From: Yaswant Pradhan <2984440+yaswant@users.noreply.github.com> 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