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