From 8008238ca02da40dccfcd077da23f67a522b25f6 Mon Sep 17 00:00:00 2001 From: purian23 Date: Thu, 9 Jul 2026 11:26:30 -0400 Subject: [PATCH] Add GitHub workflows for release management --- .github/release.yml | 23 ++ .github/workflows/dms-stable.yml | 50 +++- .github/workflows/go-ci.yml | 2 +- .github/workflows/nix-pr-check.yml | 2 +- .github/workflows/point-release.yml | 90 ++++++++ .github/workflows/port-audit.yml | 30 +++ .github/workflows/port.yml | 111 +++++++++ .github/workflows/prek.yml | 2 +- .github/workflows/release.yml | 18 +- .github/workflows/run-xbps.yml | 5 +- scripts/port-audit.sh | 96 ++++++++ scripts/port.sh | 118 ++++++++++ scripts/release-notes.py | 346 ++++++++++++++++++++++++++++ 13 files changed, 883 insertions(+), 10 deletions(-) create mode 100644 .github/release.yml create mode 100644 .github/workflows/point-release.yml create mode 100644 .github/workflows/port-audit.yml create mode 100644 .github/workflows/port.yml create mode 100755 scripts/port-audit.sh create mode 100755 scripts/port.sh create mode 100755 scripts/release-notes.py diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 000000000..495817991 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,23 @@ +# Categories for GitHub's auto-generated release notes +changelog: + exclude: + authors: + - github-actions[bot] + - dependabot[bot] + labels: + - internal + categories: + - title: Breaking Changes + labels: [breaking] + - title: Features + labels: [feature] + - title: Fixes + labels: [fix] + - title: Packaging + labels: [packaging] + - title: Internationalization + labels: [i18n] + - title: Documentation + labels: [docs] + - title: Other Changes + labels: ["*"] diff --git a/.github/workflows/dms-stable.yml b/.github/workflows/dms-stable.yml index 17151c567..2c26923a8 100644 --- a/.github/workflows/dms-stable.yml +++ b/.github/workflows/dms-stable.yml @@ -10,6 +10,8 @@ permissions: jobs: update-stable: + # skip prerelease tags + if: ${{ !contains(github.ref_name, '-') }} runs-on: ubuntu-latest steps: - name: Create GitHub App token @@ -28,4 +30,50 @@ jobs: - name: Push to stable branch env: GH_TOKEN: ${{ steps.app_token.outputs.token }} - run: git push https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git HEAD:refs/heads/stable --force + run: | + set -euo pipefail + # don't roll stable backwards + if git fetch origin stable --quiet 2>/dev/null; then + stable_tag=$(git describe --tags --abbrev=0 FETCH_HEAD 2>/dev/null || echo "v0.0.0") + newest=$(printf '%s\n%s\n' "$stable_tag" "${GITHUB_REF_NAME}" | sort -V | tail -1) + if [ "$newest" != "${GITHUB_REF_NAME}" ]; then + echo "skipping: ${GITHUB_REF_NAME} is older than stable (${stable_tag})" + exit 0 + fi + fi + git push "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" HEAD:refs/heads/stable --force + + cut-release-branch: + # create release/X.Y at each vX.Y.0 tag + if: ${{ !contains(github.ref_name, '-') }} + runs-on: ubuntu-latest + steps: + - name: Create GitHub App token + id: app_token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ steps.app_token.outputs.token }} + + - name: Create release branch + env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} + run: | + set -euo pipefail + if [[ ! "${GITHUB_REF_NAME}" =~ ^v([0-9]+)\.([0-9]+)\.0$ ]]; then + echo "not a vX.Y.0 tag, no release branch to cut" + exit 0 + fi + branch="release/${BASH_REMATCH[1]}.${BASH_REMATCH[2]}" + if git ls-remote --exit-code origin "refs/heads/${branch}" >/dev/null 2>&1; then + echo "${branch} already exists" + exit 0 + fi + git push "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" "HEAD:refs/heads/${branch}" + echo "created ${branch} at ${GITHUB_REF_NAME}" diff --git a/.github/workflows/go-ci.yml b/.github/workflows/go-ci.yml index f68d42cdf..d4fae9f2d 100644 --- a/.github/workflows/go-ci.yml +++ b/.github/workflows/go-ci.yml @@ -8,7 +8,7 @@ on: - "core/**" - ".github/workflows/go-ci.yml" pull_request: - branches: [master, main] + branches: [master, main, "release/**"] paths: - "core/**" - ".github/workflows/go-ci.yml" diff --git a/.github/workflows/nix-pr-check.yml b/.github/workflows/nix-pr-check.yml index 545c2956e..78319dcd1 100644 --- a/.github/workflows/nix-pr-check.yml +++ b/.github/workflows/nix-pr-check.yml @@ -2,7 +2,7 @@ name: Nix flake and NixOS tests on: pull_request: - branches: [master, main] + branches: [master, main, "release/**"] paths: - "flake.*" - "distro/nix/**" diff --git a/.github/workflows/point-release.yml b/.github/workflows/point-release.yml new file mode 100644 index 000000000..fceeec9c9 --- /dev/null +++ b/.github/workflows/point-release.yml @@ -0,0 +1,90 @@ +name: Point release + +# Cuts vX.Y.Z from release/X.Y: runs the port audit (warn-only), bumps +# quickshell/VERSION, tags, and dispatches the Release workflow. Distro +# builds are dispatched separately. + +on: + workflow_dispatch: + inputs: + version: + description: "Point release version (e.g. 1.5.1)" + required: true + type: string + +permissions: + contents: write + actions: write + +concurrency: + group: point-release + cancel-in-progress: false + +jobs: + release: + runs-on: ubuntu-latest + env: + VERSION: ${{ inputs.version }} + steps: + - name: Validate version and derive branch + id: derive + run: | + set -euo pipefail + if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::version must be X.Y.Z (got '$VERSION')"; exit 1 + fi + echo "branch=release/${VERSION%.*}" >> "$GITHUB_OUTPUT" + echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT" + + - name: Create GitHub App token + id: app_token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Checkout release branch + uses: actions/checkout@v6 + with: + ref: ${{ steps.derive.outputs.branch }} + fetch-depth: 0 + token: ${{ steps.app_token.outputs.token }} + + - name: Port audit (informational) + env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} + run: | + bash scripts/port-audit.sh "${{ steps.derive.outputs.branch }}" || + echo "::warning::port audit failed; continuing" + + - name: Bump VERSION, tag, and push + env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} + TAG: ${{ steps.derive.outputs.tag }} + BRANCH: ${{ steps.derive.outputs.branch }} + run: | + set -euo pipefail + if git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then + echo "::error::tag ${TAG} already exists"; exit 1 + fi + git config user.name "dms-ci[bot]" + git config user.email "dms-ci[bot]@users.noreply.github.com" + + echo "${TAG}" > quickshell/VERSION + git add quickshell/VERSION + git commit -m "bump VERSION to ${TAG}" + git tag "${TAG}" + git push "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" "HEAD:${BRANCH}" "refs/tags/${TAG}" + + - name: Dispatch Release workflow + env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} + run: gh workflow run release.yml --ref "${{ steps.derive.outputs.tag }}" -f tag="${{ steps.derive.outputs.tag }}" + + - name: Next steps + run: | + { + echo "## ${{ steps.derive.outputs.tag }} tagged on ${{ steps.derive.outputs.branch }} — Release workflow dispatched" + echo "" + echo "Distro builds are manual: run the per-distro workflows (COPR/OBS/PPA/XBPS) once the release is published." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/port-audit.yml b/.github/workflows/port-audit.yml new file mode 100644 index 000000000..3e601d638 --- /dev/null +++ b/.github/workflows/port-audit.yml @@ -0,0 +1,30 @@ +name: Port audit + +# On-demand report of master commits not yet ported to a release branch. +# Updates the "Port status: " tracking issue and the step summary. + +on: + workflow_dispatch: + inputs: + target: + description: "Release branch to audit (default: newest release/*)" + required: false + type: string + +permissions: + contents: read + issues: write + +jobs: + audit: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Run audit + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: bash scripts/port-audit.sh ${{ inputs.target }} --issue diff --git a/.github/workflows/port.yml b/.github/workflows/port.yml new file mode 100644 index 000000000..a503c15da --- /dev/null +++ b/.github/workflows/port.yml @@ -0,0 +1,111 @@ +name: Port to release branch + +# Ports flagged commits from master onto release/X.Y branches: +# - "Port: 1.5" trailer in a commit message pushed to master +# - "port release/1.5" label on a merged PR +# Conflicts are reported to the "Port status: " tracking issue. + +on: + push: + branches: [master] + pull_request_target: + types: [closed, labeled] + +permissions: + contents: write + issues: write + +jobs: + trailer: + name: Port trailer-flagged commits + if: github.event_name == 'push' + runs-on: ubuntu-latest + concurrency: + group: port-engine + cancel-in-progress: false + steps: + - name: Create GitHub App token + id: app_token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ steps.app_token.outputs.token }} + + - name: Port flagged commits + env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} + COMMITS: ${{ toJSON(github.event.commits) }} + run: | + set -euo pipefail + git config user.name "dms-ci[bot]" + git config user.email "dms-ci[bot]@users.noreply.github.com" + + for sha in $(jq -r '.[].id' <<<"$COMMITS"); do + git cat-file -e "$sha" 2>/dev/null || continue + # skip merge commits (handled by the label path) + [ "$(git rev-list --no-walk --count --min-parents=2 "$sha")" -eq 0 ] || continue + + targets=$(git log -1 --format=%B "$sha" | + { grep -iE '^Port:' || true; } | sed 's/^port: *//I' | tr ',' '\n' | + sed 's/[[:space:]]//g; /^$/d' | sed 's|^release/||I' | sort -u) + for ver in $targets; do + echo "::group::port $sha -> release/$ver" + bash scripts/port.sh "release/$ver" "$sha" + echo "::endgroup::" + done + done + + label: + name: Port label-flagged PR + if: > + github.event_name == 'pull_request_target' && + github.event.pull_request.merged == true && + (github.event.action == 'closed' || + (github.event.action == 'labeled' && startsWith(github.event.label.name, 'port '))) + runs-on: ubuntu-latest + concurrency: + group: port-engine + cancel-in-progress: false + steps: + - name: Create GitHub App token + id: app_token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + # Base-repo code only; PR head code is never checked out or executed. + - name: Checkout + uses: actions/checkout@v6 + with: + ref: master + fetch-depth: 0 + token: ${{ steps.app_token.outputs.token }} + + - name: Port merge commit + env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} + LABELS: ${{ toJSON(github.event.pull_request.labels.*.name) }} + MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} + PORT_SOURCE_PR: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + git config user.name "dms-ci[bot]" + git config user.email "dms-ci[bot]@users.noreply.github.com" + + targets=$(jq -r '.[] | select(startswith("port ")) | sub("^port +"; "")' <<<"$LABELS" | + sed 's|^release/||' | sort -u) + [ -n "$targets" ] || { echo "no port labels, nothing to do"; exit 0; } + [ -n "$MERGE_SHA" ] || { echo "::error::PR has no merge commit sha"; exit 1; } + + for ver in $targets; do + echo "::group::port PR #${PORT_SOURCE_PR} ($MERGE_SHA) -> release/$ver" + bash scripts/port.sh "release/$ver" "$MERGE_SHA" + echo "::endgroup::" + done diff --git a/.github/workflows/prek.yml b/.github/workflows/prek.yml index 35a80599a..af6678a19 100644 --- a/.github/workflows/prek.yml +++ b/.github/workflows/prek.yml @@ -3,7 +3,7 @@ name: Pre-commit Checks on: push: pull_request: - branches: [master, main] + branches: [master, main, "release/**"] jobs: pre-commit-check: runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 89962eebc..96605f6b5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -205,13 +205,23 @@ jobs: - name: Generate Changelog id: changelog + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -e PREVIOUS_TAG=$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || echo "") - if [ -z "$PREVIOUS_TAG" ]; then - CHANGELOG=$(git log --oneline --pretty=format:"%an|%s (%h)" | grep -v "^github-actions\[bot\]|" | sed 's/^[^|]*|/- /' | head -50) - else - CHANGELOG=$(git log --oneline --pretty=format:"%an|%s (%h)" "${PREVIOUS_TAG}..${TAG}" | grep -v "^github-actions\[bot\]|" | sed 's/^[^|]*|/- /') + CHANGELOG="" + if [ -n "$PREVIOUS_TAG" ]; then + # PR-based notes with author credits; falls back to raw git log below + CHANGELOG=$(python3 scripts/release-notes.py "${PREVIOUS_TAG}..${TAG}" --format github --bare 2>/dev/null || true) + fi + if [ -z "$CHANGELOG" ]; then + echo "release-notes.py unavailable or empty, using git log fallback" + if [ -z "$PREVIOUS_TAG" ]; then + CHANGELOG=$(git log --oneline --pretty=format:"%an|%s (%h)" | grep -v "^github-actions\[bot\]|" | sed 's/^[^|]*|/- /' | head -50) + else + CHANGELOG=$(git log --oneline --pretty=format:"%an|%s (%h)" "${PREVIOUS_TAG}..${TAG}" | grep -v "^github-actions\[bot\]|" | sed 's/^[^|]*|/- /') + fi fi cat > RELEASE_BODY.md << 'EOF' diff --git a/.github/workflows/run-xbps.yml b/.github/workflows/run-xbps.yml index 1684811ef..46dc2f506 100644 --- a/.github/workflows/run-xbps.yml +++ b/.github/workflows/run-xbps.yml @@ -3,8 +3,9 @@ name: Void Linux XBPS Repository on: schedule: - cron: "0 2,5,14,17,20,23 * * *" # 9am, 12pm, 3pm, 6pm, 9pm, 12am EST (UTC times shown) - release: - types: [published] + # release trigger disabled; dispatch manually after a release + # release: + # types: [published] workflow_dispatch: inputs: version: diff --git a/scripts/port-audit.sh b/scripts/port-audit.sh new file mode 100755 index 000000000..1f6a486eb --- /dev/null +++ b/scripts/port-audit.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# Report master commits missing from a release branch. +# +# Usage: port-audit.sh [] [--issue] +# +# Target defaults to the newest origin/release/* branch. --issue writes the +# report to the "Port status: " tracking issue body (needs gh auth). +set -euo pipefail + +TARGET="" +UPDATE_ISSUE=0 +for arg in "$@"; do + case "$arg" in + --issue) UPDATE_ISSUE=1 ;; + *) TARGET="$arg" ;; + esac +done + +git fetch origin --quiet 2>/dev/null || true + +if [ -z "$TARGET" ]; then + TARGET=$(git branch -r --list 'origin/release/*' --format='%(refname:short)' | + sed 's|^origin/||' | sort -V | tail -1) + [ -n "$TARGET" ] || { echo "error: no origin/release/* branch found" >&2; exit 1; } +fi +git rev-parse --verify "origin/${TARGET}" >/dev/null 2>&1 || + { echo "error: branch origin/${TARGET} not found" >&2; exit 1; } + +BASE=$(git merge-base "origin/${TARGET}" origin/master) + +# shas already ported via cherry-pick -x trailers +declare -A PORTED +while read -r sha; do + [ -n "$sha" ] && PORTED[$sha]=1 +done < <(git log --format=%B "${BASE}..origin/${TARGET}" 2>/dev/null | + grep -oE 'cherry picked from commit [0-9a-f]{40}' | awk '{print $5}') + +# git cherry: "+ sha" = not on target (by patch-id), "- sha" = equivalent exists +fixes="" +others="" +fix_count=0 +other_count=0 +while read -r mark sha; do + [ "$mark" = "+" ] || continue + [ -n "${PORTED[$sha]:-}" ] && continue + subject=$(git log -1 --format=%s "$sha") + # skip automated bumps/CI commits + author=$(git log -1 --format=%an "$sha") + case "$author" in *"[bot]"*) continue ;; esac + short=$(git rev-parse --short "$sha") + pr=$(grep -oE '#[0-9]+' <<<"$subject" | head -1 || true) + line="- [ ] \`${short}\` ${subject} (${author}${pr:+, ${pr}})" + if grep -qiE '^(fix|hotfix|bugfix)([(:! ]|$)|^[a-z0-9_-]+: *fix' <<<"$subject"; then + fixes+="${line}"$'\n' + fix_count=$((fix_count + 1)) + else + others+="${line#- [ ] }"$'\n' + other_count=$((other_count + 1)) + fi +done < <(git cherry "origin/${TARGET}" origin/master "$BASE") + +REPORT=$(cat <Other unported commits (${other_count}) — held for next major + +${others:-none} + + +EOF +) + +echo "$REPORT" +if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + echo "$REPORT" >> "$GITHUB_STEP_SUMMARY" +fi + +if [ "$UPDATE_ISSUE" = 1 ]; then + REPO="${GITHUB_REPOSITORY:-$(gh repo view --json nameWithOwner --jq .nameWithOwner)}" + TITLE="Port status: ${TARGET}" + num=$(gh issue list --repo "$REPO" --state open --search "in:title \"${TITLE}\"" \ + --json number,title --jq "map(select(.title == \"${TITLE}\")) | .[0].number // empty") + if [ -z "$num" ]; then + num=$(gh issue create --repo "$REPO" --title "$TITLE" --body "$REPORT" | grep -oE '[0-9]+$') + echo "created tracking issue #${num}" >&2 + else + gh issue edit "$num" --repo "$REPO" --body "$REPORT" >/dev/null + echo "updated tracking issue #${num}" >&2 + fi +fi diff --git a/scripts/port.sh b/scripts/port.sh new file mode 100755 index 000000000..498acd9a5 --- /dev/null +++ b/scripts/port.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# Port (cherry-pick -x) commits onto a release branch. +# +# Usage: port.sh [...] +# +# Already-ported commits are skipped. Conflicts get a port/- +# branch and a comment on the "Port status: " tracking issue. +# +# Requires: full-history checkout, git identity, push access, gh auth. +# Env: PORT_SOURCE_PR - source PR number (optional, for reporting) +set -euo pipefail + +TARGET="${1:?usage: port.sh [...]}" +shift +[ "$#" -ge 1 ] || { echo "error: no commits given" >&2; exit 1; } + +REPO="${GITHUB_REPOSITORY:-$(gh repo view --json nameWithOwner --jq .nameWithOwner)}" +TRACKING_TITLE="Port status: ${TARGET}" + +log() { echo "[port] $*" >&2; } + +git fetch origin "refs/heads/${TARGET}:refs/remotes/origin/${TARGET}" --quiet || + { echo "error: branch origin/${TARGET} not found" >&2; exit 1; } + +tracking_issue() { + local num + num=$(gh issue list --repo "$REPO" --state open --search "in:title \"${TRACKING_TITLE}\"" \ + --json number,title --jq "map(select(.title == \"${TRACKING_TITLE}\")) | .[0].number // empty") + if [ -z "$num" ]; then + num=$(gh issue create --repo "$REPO" --title "${TRACKING_TITLE}" \ + --body "Maintainer tracking issue for ports to \`${TARGET}\`. The port audit updates this body; the port engine reports conflicts as comments." \ + | grep -oE '[0-9]+$') + fi + echo "$num" +} + +already_ported() { + local sha="$1" + if git log "origin/${TARGET}" --grep="cherry picked from commit ${sha}" --format=%H | grep -q .; then + return 0 + fi + # git cherry prints "- " when a patch-equivalent commit exists upstream + if git cherry "origin/${TARGET}" "$sha" "${sha}~1" 2>/dev/null | grep -q "^- "; then + return 0 + fi + return 1 +} + +report_conflict() { + local sha="$1" short subject branch issue + short=$(git rev-parse --short "$sha") + subject=$(git log -1 --format=%s "$sha") + branch="port/${short}-${TARGET#release/}" + git push origin "refs/remotes/origin/${TARGET}:refs/heads/${branch}" 2>/dev/null || + log "conflict branch ${branch} already exists" + issue=$(tracking_issue) + gh issue comment "$issue" --repo "$REPO" --body "$(cat <> "$GITHUB_STEP_SUMMARY" + fi +} + +WORK="_port_worktree_$$" +git worktree add --detach "$WORK" "origin/${TARGET}" >/dev/null +trap 'cd "${OLDPWD:-.}" 2>/dev/null; git worktree remove --force "$WORK" 2>/dev/null || true' EXIT +cd "$WORK" + +picked=0 +for ref in "$@"; do + sha=$(git rev-parse --verify "${ref}^{commit}") || { log "skip ${ref}: not a commit"; continue; } + short=$(git rev-parse --short "$sha") + + if already_ported "$sha"; then + log "skip ${short}: already on ${TARGET}" + continue + fi + + pick_args=(-x) + # PR merge commits need the mainline parent + if [ "$(git rev-list --no-walk --count --min-parents=2 "$sha")" -gt 0 ]; then + pick_args+=(-m 1) + fi + + if git cherry-pick "${pick_args[@]}" "$sha"; then + log "picked ${short}" + picked=$((picked + 1)) + else + git cherry-pick --abort || true + report_conflict "$sha" + fi +done + +if [ "$picked" -gt 0 ]; then + if ! git push origin "HEAD:refs/heads/${TARGET}"; then + # another run may have advanced the branch; rebase our picks and retry once + git fetch origin "refs/heads/${TARGET}:refs/remotes/origin/${TARGET}" + git rebase "origin/${TARGET}" + git push origin "HEAD:refs/heads/${TARGET}" + fi + log "pushed ${picked} commit(s) to ${TARGET}" + if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + echo ":white_check_mark: ported ${picked} commit(s) to \`${TARGET}\`" >> "$GITHUB_STEP_SUMMARY" + fi +fi diff --git a/scripts/release-notes.py b/scripts/release-notes.py new file mode 100755 index 000000000..72b7c1cd8 --- /dev/null +++ b/scripts/release-notes.py @@ -0,0 +1,346 @@ +#!/usr/bin/env python3 +"""Generate release notes and contributor credits from merged PRs. + +Attribution comes from GitHub PR data (author login, title, labels) via +`gh api graphql`, falling back to git commit authors for direct pushes. +Ported commits (cherry-pick -x trailers) resolve back to their original +master commit so point releases credit the right PR and author. + +Usage: + release-notes.py v1.4.6..v1.5.0 --format github # GH release "What's Changed" + release-notes.py v1.4.6..v1.5.0 --format blog # MDX contributor tables for danklinux-docs + release-notes.py v1.4.6..v1.5.0 --format checklist # flat PR/author review list + +Requires: git (full history), gh authenticated. --repo defaults to origin. +""" + +import argparse +import json +import re +import subprocess +import sys +from collections import OrderedDict + +BOT_RE = re.compile(r"\[bot\]$|^github-actions$|^dependabot$", re.I) +# default blog-table exclusions +MAINTAINERS = ["purian23", "bbedward"] +CHERRY_RE = re.compile(r"cherry picked from commit ([0-9a-f]{40})") +PR_REF_RE = re.compile(r"\(#(\d+)\)") + +CATEGORIES = OrderedDict([ + ("breaking", "Breaking Changes"), + ("feature", "Features"), + ("fix", "Fixes"), + ("packaging", "Packaging"), + ("i18n", "Internationalization"), + ("docs", "Documentation"), + ("other", "Other Changes"), +]) +SUBJECT_HINTS = [ + (re.compile(r"^feat", re.I), "feature"), + (re.compile(r"^(fix|hotfix|bugfix)", re.I), "fix"), + (re.compile(r"^[\w./-]+: *fix", re.I), "fix"), + (re.compile(r"^docs?\b", re.I), "docs"), + (re.compile(r"^i18n", re.I), "i18n"), + (re.compile(r"^(distro|packaging|nix|copr|obs|ppa|xbps)", re.I), "packaging"), +] + + +def run(cmd, **kw): + return subprocess.run(cmd, check=True, capture_output=True, text=True, **kw).stdout + + +def git_commits(rng): + """[(sha, author_name, author_email, subject, body)] oldest-first, no merges.""" + sep, rec = "\x00", "\x1e" + # %x00/%x1e escapes: a literal NUL in argv is invalid + out = run(["git", "log", "--reverse", "--no-merges", + "--format=%H%x00%an%x00%ae%x00%s%x00%b%x1e", rng]) + commits = [] + for chunk in out.split(rec): + chunk = chunk.strip("\n") + if not chunk: + continue + sha, an, ae, subject, body = (chunk.split(sep) + [""] * 5)[:5] + commits.append((sha, an, ae, subject, body)) + return commits + + +def fetch_pr_data(repo, shas): + """{sha: {number, title, url, login, author_url, labels}} via batched GraphQL.""" + owner, name = repo.split("/") + result = {} + for i in range(0, len(shas), 50): + batch = shas[i:i + 50] + fields = [] + for j, sha in enumerate(batch): + fields.append( + f'c{j}: object(oid: "{sha}") {{ ... on Commit {{ ' + f'author {{ user {{ login url }} }} ' + f'associatedPullRequests(first: 1) {{ nodes {{ ' + f'number title url merged author {{ login url }} ' + f'labels(first: 20) {{ nodes {{ name }} }} }} }} }} }}') + query = (f'query {{ repository(owner: "{owner}", name: "{name}") ' + f'{{ {" ".join(fields)} }} }}') + try: + data = json.loads(run(["gh", "api", "graphql", "-f", f"query={query}"])) + except subprocess.CalledProcessError as e: + print(f"warning: GraphQL batch failed: {e.stderr.strip()}", file=sys.stderr) + continue + repo_data = data.get("data", {}).get("repository") or {} + for j, sha in enumerate(batch): + node = repo_data.get(f"c{j}") or {} + prs = (node.get("associatedPullRequests") or {}).get("nodes") or [] + pr = next((p for p in prs if p.get("merged")), None) + commit_user = (node.get("author") or {}).get("user") or {} + entry = {} + if pr: + author = pr.get("author") or {} + entry = { + "number": pr["number"], "title": pr["title"], "url": pr["url"], + "login": author.get("login"), "author_url": author.get("url"), + "labels": [l["name"] for l in (pr.get("labels") or {}).get("nodes", [])], + } + elif commit_user.get("login"): + entry = {"login": commit_user["login"], "author_url": commit_user.get("url"), + "labels": []} + if entry: + result[sha] = entry + return result + + +def categorize(labels, subject): + for key in CATEGORIES: + if key in labels: + return key + for rx, key in SUBJECT_HINTS: + if rx.search(subject or ""): + return key + return "other" + + +def build_entries(repo, rng, use_api=True): + """One entry per PR (or per direct commit). Ported commits resolve to origin.""" + commits = git_commits(rng) + lookup_shas = [] + origin_of = {} + for sha, _an, _ae, _subj, body in commits: + m = CHERRY_RE.search(body or "") + origin_of[sha] = m.group(1) if m else sha + lookup_shas.append(origin_of[sha]) + pr_data = fetch_pr_data(repo, lookup_shas) if use_api else {} + + entries, seen_prs = [], set() + for sha, an, ae, subject, _body in commits: + info = pr_data.get(origin_of[sha], {}) + login = info.get("login") + if login and BOT_RE.search(login): + continue + if not login and BOT_RE.search(an): + continue + number = info.get("number") + if number: + if number in seen_prs: + continue + seen_prs.add(number) + else: + m = PR_REF_RE.search(subject) + if m: + number = int(m.group(1)) + if number in seen_prs: + continue + seen_prs.add(number) + entries.append({ + "sha": sha, "subject": subject, + "title": info.get("title") or re.sub(PR_REF_RE, "", subject).strip(), + "number": number, + "pr_url": info.get("url") or (number and f"https://github.com/{repo}/pull/{number}"), + "login": login, "author_name": an, "author_email": ae, + "author_url": info.get("author_url") or (login and f"https://github.com/{login}"), + "category": categorize(info.get("labels", []), info.get("title") or subject), + }) + + by_email, by_name = {}, {} + for e in entries: + if e["login"]: + by_email.setdefault(e["author_email"].lower(), e) + by_name.setdefault(e["author_name"].lower(), e) + by_name.setdefault(e["login"].lower(), e) + for e in entries: + if not e["login"]: + match = (by_email.get(e["author_email"].lower()) + or by_name.get(e["author_name"].lower())) + if match: + e["login"] = match["login"] + e["author_url"] = match["author_url"] + return entries + + +def author_md(e): + if e["login"]: + return f"@{e['login']}" + return e["author_name"] + + +def format_github(repo, entries, rng, bare=False): + prev = rng.split("..")[0] + tag = rng.split("..")[1] if ".." in rng else "HEAD" + out = [] if bare else ["## What's Changed", ""] + for key, heading in CATEGORIES.items(): + group = [e for e in entries if e["category"] == key] + if not group: + continue + out.append(f"### {heading}") + for e in group: + ref = f" in #{e['number']}" if e["number"] else f" ({e['sha'][:7]})" + out.append(f"- {e['title']} by {author_md(e)}{ref}") + out.append("") + if not bare: + out.append(f"**Full Changelog**: https://github.com/{repo}/compare/{prev}...{tag}") + return "\n".join(out) + + +TYPE_PREFIX_RE = re.compile( + r"^(?:feat(?:ure)?|fix(?:es)?|hotfix|bugfix|docs?|refactor|chore|perf" + r"|style|test|i18n|build|ci)\b!?\s*(?:\([^)]*\))?\s*[:/\-]\s*", re.I) +AREA_PREFIX_RE = re.compile(r"^(?:\([^)]*\)|[\w./-]{1,24}):\s+") + + +def clean_title(title): + """De-robotize a commit/PR title for prose: drop type/area prefixes.""" + t = title.strip() + for _ in range(3): + stripped = TYPE_PREFIX_RE.sub("", t) + if stripped == t: + stripped = AREA_PREFIX_RE.sub("", t) + if stripped == t or not stripped: + break + t = stripped.strip() + return (t[:1].upper() + t[1:]) if t else title + + +def format_blog(repo, entries, rng, exclude=frozenset()): + def mdx_safe(text): + # titles land in MDX table cells: escape JSX/expression/table chars + return (text.replace("{", "{").replace("<", "<") + .replace("|", "\\|")) + + def is_excluded(e): + return ((e["login"] or "").lower() in exclude + or e["author_name"].lower() in exclude) + + def table(group): + rows = {} + for e in group: + name = e["login"] or e["author_name"] + key = name.lower() + rows.setdefault(key, {"e": e, "name": name, "items": []}) + pr = (f"[PR #{e['number']}]({e['pr_url']})" if e["number"] + else f"`{e['sha'][:7]}`") + rows[key]["items"].append(f"{mdx_safe(clean_title(e['title']))} ({pr})") + lines = ["| Contributor | Contribution |", "|---|---|"] + for key in sorted(rows): + r = rows[key] + handle = (f"**[{r['name']}]({r['e']['author_url']})**" if r["e"]["author_url"] + else f"**{r['name']}**") + items = r["items"] + cell = (items[0] if len(items) == 1 + else "
".join(f"• {it}" for it in items)) + lines.append(f"| {handle} | {cell} |") + return "\n".join(lines) + + def fix_item(e): + title = mdx_safe(clean_title(e["title"])).rstrip(".") + ref = f"[PR #{e['number']}]({e['pr_url']})" if e["number"] else f"`{e['sha'][:7]}`" + if not is_excluded(e) and e["author_url"]: + name = e["login"] or e["author_name"] + return f"- {title} (contributed by **[{name}]({e['author_url']})** {ref})." + return f"- {title} ({ref})." + + # fixes list includes excluded authors; credit shown for the rest + fixes, seen_titles = [], set() + for e in sorted((e for e in entries if e["category"] == "fix"), + key=lambda e: clean_title(e["title"]).lower()): + t = clean_title(e["title"]).lower() + if t in seen_titles: + continue + seen_titles.add(t) + fixes.append(fix_item(e)) + + # tables cover non-fix work; fix authors are credited inline above + community = [e for e in entries if not is_excluded(e)] + feats = [e for e in community if e["category"] in ("feature", "breaking")] + rest = [e for e in community + if e["category"] not in ("feature", "breaking", "fix")] + contributors = {(e["login"] or e["author_name"]).lower() for e in community} + + out = [] + if fixes: + out += ["", + "
", + f"View Details ({len(fixes)} fixes in {rng})", ""] + out += fixes + out += ["", "
", ""] + out += ["## Community Contributors", "", + f"", ""] + if feats: + out += ["### Feature Contributors", "", table(feats), ""] + if rest: + out += ["### General Contributions", "", table(rest), ""] + return "\n".join(out) + + +def format_checklist(entries): + out = [] + for e in entries: + ref = f"#{e['number']}" if e["number"] else e["sha"][:7] + out.append(f"- [ ] {ref} {e['title']} — {author_md(e)} [{e['category']}]") + return "\n".join(out) + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("range", help="git range, e.g. v1.4.6..v1.5.0") + ap.add_argument("--format", choices=["github", "blog", "checklist"], default="github") + ap.add_argument("--repo", default=None, help="owner/name (default: from origin)") + ap.add_argument("--exclude", action="append", default=None, metavar="LOGIN", + help="drop this author (repeatable). Blog format defaults " + f"to maintainers ({', '.join(MAINTAINERS)}); pass " + "--exclude to override, --exclude '' for nobody") + ap.add_argument("--bare", action="store_true", + help="github format: omit heading and Full Changelog footer") + ap.add_argument("--no-api", action="store_true", + help="skip GitHub API, use git data only (degraded attribution)") + args = ap.parse_args() + + repo = args.repo + if not repo: + url = run(["git", "remote", "get-url", "origin"]).strip() + m = re.search(r"github\.com[:/]([^/]+/[^/.]+)", url) + repo = m.group(1) if m else "AvengeMedia/DankMaterialShell" + + entries = build_entries(repo, args.range, use_api=not args.no_api) + if not entries: + print("no commits in range", file=sys.stderr) + return 1 + excludes = args.exclude + if excludes is None: + excludes = MAINTAINERS if args.format == "blog" else [] + drop = {x.lower() for x in excludes if x} + if args.format == "blog": + # blog excludes from tables only; fixes list keeps everyone + print(format_blog(repo, entries, args.range, exclude=drop)) + return 0 + if drop: + entries = [e for e in entries + if (e["login"] or "").lower() not in drop + and e["author_name"].lower() not in drop] + if args.format == "github": + print(format_github(repo, entries, args.range, bare=args.bare)) + else: + print(format_checklist(entries)) + return 0 + + +if __name__ == "__main__": + sys.exit(main())