diff --git a/.github/scripts/release-notes.sh b/.github/scripts/release-notes.sh new file mode 100755 index 00000000..71378433 --- /dev/null +++ b/.github/scripts/release-notes.sh @@ -0,0 +1,148 @@ +#!/usr/bin/env bash +# Renders the "What's Changed" section for a release tag as Markdown on stdout. +# Usage: release-notes.sh [previous-tag] +set -euo pipefail + +TAG="${1:?usage: release-notes.sh [previous-tag]}" +PREV="${2-}" + +MAX_ENTRIES="${MAX_ENTRIES:-80}" +MAX_LEN="${MAX_LEN:-140}" + +REPO_URL="${GITHUB_SERVER_URL:-https://github.com}/${GITHUB_REPOSITORY:-}" +if [[ -z "${GITHUB_REPOSITORY:-}" ]]; then + origin=$(git config --get remote.origin.url || echo "") + origin="${origin%.git}" + REPO_URL="${origin:-https://github.com}" +fi + +# Previous tag: newest release tag that is an ancestor of this one. A stable tag +# compares against the last stable tag, so a v1.2.0 release still lists +# everything its rc tags already covered. +if [[ -z "$PREV" ]]; then + tag_sha=$(git rev-parse "${TAG}^{commit}") + while read -r candidate; do + [[ -n "$candidate" ]] || continue + [[ "$TAG" == *-* ]] || [[ "$candidate" != *-* ]] || continue + [[ "$(git rev-parse "${candidate}^{commit}")" != "$tag_sha" ]] || continue + PREV="$candidate" + break + done < <(git tag -l 'v*' --sort=-v:refname --merged "$TAG") +fi + +if [[ -n "$PREV" ]]; then + RANGE="${PREV}..${TAG}" +else + RANGE="$TAG" +fi + +# Trim a subject to something readable, preferring a clause boundary over a +# hard cut. Commit subjects in this repo are deliberately long and specific; +# the full text is one click away behind the PR link. +shorten() { + local s="$1" cut best candidate sep + s="${s//$'\n'/ }" + s="$(printf '%s' "$s" | tr -s '[:space:]' ' ')" + s="${s#"${s%%[![:space:]]*}"}" + s="${s%"${s##*[![:space:]]}"}" + if (( ${#s} <= MAX_LEN )); then + printf '%s' "$s" + return + fi + cut="${s:0:MAX_LEN}" + best="" + for sep in ', ' '; ' ': ' ' ('; do + candidate="${cut%"$sep"*}" + [[ "$candidate" != "$cut" ]] || continue + (( ${#candidate} >= 60 )) || continue + (( ${#candidate} > ${#best} )) && best="$candidate" + done + if [[ -z "$best" ]]; then + best="${cut% *}" + [[ "$best" != "$cut" ]] || best="$cut" + fi + best="${best%[,;:.\ ]}" + printf '%s…' "$best" +} + +declare -A SECTION_LINES=() +SECTION_ORDER=(Features Fixes Performance Security Documentation Maintenance Other) +total=0 +shown=0 + +while IFS=$'\t' read -r hash subject; do + [[ -n "$hash" ]] || continue + # Branch-sync merges carry no release-visible change of their own. + [[ "$subject" != Merge\ branch\ * ]] || continue + [[ "$subject" != Merge\ remote-tracking\ * ]] || continue + + pr="" + title="$subject" + if [[ "$subject" =~ ^Merge\ pull\ request\ \#([0-9]+) ]]; then + pr="${BASH_REMATCH[1]}" + body_first=$(git log -1 --format=%b "$hash" | grep -m1 -v '^[[:space:]]*$' || true) + if [[ -n "$body_first" ]]; then + title="$body_first" + else + # No PR title in the merge body: fall back to the branch name. + title="${subject#*from }" + title="${title#*/}" + title="${title//-/ }" + fi + elif [[ "$subject" =~ \(#([0-9]+)\)$ ]]; then + pr="${BASH_REMATCH[1]}" + title="${subject% (#"${pr}")}" + fi + + type="other" + if [[ "$title" =~ ^([a-zA-Z]+)(\([^\)]*\))?(!)?:[[:space:]]*(.*)$ ]]; then + type="${BASH_REMATCH[1],,}" + title="${BASH_REMATCH[4]}" + fi + + case "$type" in + feat|feature) section="Features" ;; + fix|bugfix|hotfix) section="Fixes" ;; + perf) section="Performance" ;; + sec|security) section="Security" ;; + docs|doc) section="Documentation" ;; + refactor|chore|build|ci|test|tests|style|deps) section="Maintenance" ;; + *) section="Other" ;; + esac + + total=$((total + 1)) + (( shown < MAX_ENTRIES )) || continue + shown=$((shown + 1)) + + title="$(shorten "$title")" + [[ -z "$title" ]] && title="$subject" + title="${title^}" + + if [[ -n "$pr" ]]; then + ref="([#${pr}](${REPO_URL}/pull/${pr}))" + else + ref="([\`$(git rev-parse --short "$hash")\`](${REPO_URL}/commit/${hash}))" + fi + + SECTION_LINES[$section]+="- ${title} ${ref}"$'\n' +done < <(git log --first-parent --format='%H%x09%s' "$RANGE") + +printf '## What'\''s Changed\n\n' + +if (( total == 0 )); then + printf 'No changes recorded for this release.\n' +else + for section in "${SECTION_ORDER[@]}"; do + [[ -n "${SECTION_LINES[$section]:-}" ]] || continue + printf '### %s\n\n%s\n' "$section" "${SECTION_LINES[$section]}" + done + if (( total > shown )); then + printf '_...and %d more changes. See the full changelog below._\n\n' "$((total - shown))" + fi +fi + +if [[ -n "$PREV" ]]; then + printf '**Full Changelog**: %s/compare/%s...%s\n' "$REPO_URL" "$PREV" "$TAG" +else + printf '**Full Changelog**: %s/commits/%s\n' "$REPO_URL" "$TAG" +fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 00a025e6..cbace67e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -221,53 +221,46 @@ jobs: with: fetch-depth: 0 - - name: Generate changelog - id: changelog + - name: Generate release notes + env: + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_REPOSITORY: ${{ github.repository }} + TAG: ${{ github.ref_name }} run: | - PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") + .github/scripts/release-notes.sh "$TAG" > /tmp/changelog.md + cat /tmp/changelog.md - if [[ -n "$PREV_TAG" ]]; then - echo "Generating changelog from $PREV_TAG to ${{ github.ref_name }}" - CHANGELOG=$(git log --pretty=format:"- %s (%h)" $PREV_TAG..${{ github.ref_name }} | head -50) - else - echo "No previous tag found, generating from beginning" - CHANGELOG=$(git log --pretty=format:"- %s (%h)" | head -50) - fi + - name: Assemble release body + run: | + { + cat /tmp/changelog.md + cat <<'EOF' - # Escape for GitHub Actions - CHANGELOG="${CHANGELOG//'%'/'%25'}" - CHANGELOG="${CHANGELOG//$'\n'/'%0A'}" - CHANGELOG="${CHANGELOG//$'\r'/'%0D'}" + ## Docker Images - echo "changelog=${CHANGELOG}" >> $GITHUB_OUTPUT + All images are available at `ghcr.io/${{ github.repository_owner }}/warmbly`: + + | Service | Image | + |---------|-------| + | Backend | `${{ env.IMAGE_PREFIX }}/backend:${{ github.ref_name }}` | + | Consumer | `${{ env.IMAGE_PREFIX }}/consumer:${{ github.ref_name }}` | + | Worker | `${{ env.IMAGE_PREFIX }}/worker:${{ github.ref_name }}` | + | Tracking | `${{ env.IMAGE_PREFIX }}/tracking:${{ github.ref_name }}` | + | Realtime | `${{ env.IMAGE_PREFIX }}/realtime:${{ github.ref_name }}` | + | Dashboard (web) | `${{ env.IMAGE_PREFIX }}/web:${{ github.ref_name }}` | + | Admin | `${{ env.IMAGE_PREFIX }}/admin:${{ github.ref_name }}` | + + ## Deployment + + This release will be automatically deployed to production. + EOF + } > /tmp/release-body.md - name: Create Release uses: softprops/action-gh-release@v2 with: tag_name: ${{ github.ref_name }} - name: Release ${{ github.ref_name }} - body: | - ## What's Changed - - ${{ steps.changelog.outputs.changelog }} - - ## Docker Images - - All images are available at `ghcr.io/${{ github.repository_owner }}/warmbly`: - - | Service | Image | - |---------|-------| - | Backend | `${{ env.IMAGE_PREFIX }}/backend:${{ github.ref_name }}` | - | Consumer | `${{ env.IMAGE_PREFIX }}/consumer:${{ github.ref_name }}` | - | Worker | `${{ env.IMAGE_PREFIX }}/worker:${{ github.ref_name }}` | - | Tracking | `${{ env.IMAGE_PREFIX }}/tracking:${{ github.ref_name }}` | - | Realtime | `${{ env.IMAGE_PREFIX }}/realtime:${{ github.ref_name }}` | - | Dashboard (web) | `${{ env.IMAGE_PREFIX }}/web:${{ github.ref_name }}` | - | Admin | `${{ env.IMAGE_PREFIX }}/admin:${{ github.ref_name }}` | - - ## Deployment - - This release will be automatically deployed to production. + name: ${{ github.ref_name }} + body_path: /tmp/release-body.md draft: false prerelease: ${{ contains(github.ref_name, '-') }} - generate_release_notes: true