mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-08-01 19:18:28 -04:00
Add GitHub workflows for release management
This commit is contained in:
@@ -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: ["*"]
|
||||||
@@ -10,6 +10,8 @@ permissions:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
update-stable:
|
update-stable:
|
||||||
|
# skip prerelease tags
|
||||||
|
if: ${{ !contains(github.ref_name, '-') }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Create GitHub App token
|
- name: Create GitHub App token
|
||||||
@@ -28,4 +30,50 @@ jobs:
|
|||||||
- name: Push to stable branch
|
- name: Push to stable branch
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
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}"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ on:
|
|||||||
- "core/**"
|
- "core/**"
|
||||||
- ".github/workflows/go-ci.yml"
|
- ".github/workflows/go-ci.yml"
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [master, main]
|
branches: [master, main, "release/**"]
|
||||||
paths:
|
paths:
|
||||||
- "core/**"
|
- "core/**"
|
||||||
- ".github/workflows/go-ci.yml"
|
- ".github/workflows/go-ci.yml"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ name: Nix flake and NixOS tests
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [master, main]
|
branches: [master, main, "release/**"]
|
||||||
paths:
|
paths:
|
||||||
- "flake.*"
|
- "flake.*"
|
||||||
- "distro/nix/**"
|
- "distro/nix/**"
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
name: Port audit
|
||||||
|
|
||||||
|
# On-demand report of master commits not yet ported to a release branch.
|
||||||
|
# Updates the "Port status: <branch>" 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
|
||||||
@@ -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: <branch>" 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
|
||||||
@@ -3,7 +3,7 @@ name: Pre-commit Checks
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [master, main]
|
branches: [master, main, "release/**"]
|
||||||
jobs:
|
jobs:
|
||||||
pre-commit-check:
|
pre-commit-check:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -205,13 +205,23 @@ jobs:
|
|||||||
|
|
||||||
- name: Generate Changelog
|
- name: Generate Changelog
|
||||||
id: changelog
|
id: changelog
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
PREVIOUS_TAG=$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || echo "")
|
PREVIOUS_TAG=$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || echo "")
|
||||||
if [ -z "$PREVIOUS_TAG" ]; then
|
CHANGELOG=""
|
||||||
CHANGELOG=$(git log --oneline --pretty=format:"%an|%s (%h)" | grep -v "^github-actions\[bot\]|" | sed 's/^[^|]*|/- /' | head -50)
|
if [ -n "$PREVIOUS_TAG" ]; then
|
||||||
else
|
# PR-based notes with author credits; falls back to raw git log below
|
||||||
CHANGELOG=$(git log --oneline --pretty=format:"%an|%s (%h)" "${PREVIOUS_TAG}..${TAG}" | grep -v "^github-actions\[bot\]|" | sed 's/^[^|]*|/- /')
|
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
|
fi
|
||||||
|
|
||||||
cat > RELEASE_BODY.md << 'EOF'
|
cat > RELEASE_BODY.md << 'EOF'
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ name: Void Linux XBPS Repository
|
|||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: "0 2,5,14,17,20,23 * * *" # 9am, 12pm, 3pm, 6pm, 9pm, 12am EST (UTC times shown)
|
- cron: "0 2,5,14,17,20,23 * * *" # 9am, 12pm, 3pm, 6pm, 9pm, 12am EST (UTC times shown)
|
||||||
release:
|
# release trigger disabled; dispatch manually after a release
|
||||||
types: [published]
|
# release:
|
||||||
|
# types: [published]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
version:
|
version:
|
||||||
|
|||||||
Executable
+96
@@ -0,0 +1,96 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Report master commits missing from a release branch.
|
||||||
|
#
|
||||||
|
# Usage: port-audit.sh [<target-branch>] [--issue]
|
||||||
|
#
|
||||||
|
# Target defaults to the newest origin/release/* branch. --issue writes the
|
||||||
|
# report to the "Port status: <target>" 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 <<EOF
|
||||||
|
## Port audit: \`${TARGET}\` vs \`master\`
|
||||||
|
|
||||||
|
Base: \`$(git rev-parse --short "$BASE")\` · generated $(date -u +%Y-%m-%dT%H:%MZ)
|
||||||
|
|
||||||
|
### Candidate fixes not ported (${fix_count})
|
||||||
|
|
||||||
|
${fixes:-_none — all caught up_ }
|
||||||
|
|
||||||
|
<details><summary>Other unported commits (${other_count}) — held for next major</summary>
|
||||||
|
|
||||||
|
${others:-none}
|
||||||
|
|
||||||
|
</details>
|
||||||
|
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
|
||||||
Executable
+118
@@ -0,0 +1,118 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Port (cherry-pick -x) commits onto a release branch.
|
||||||
|
#
|
||||||
|
# Usage: port.sh <target-branch> <sha> [<sha>...]
|
||||||
|
#
|
||||||
|
# Already-ported commits are skipped. Conflicts get a port/<sha>-<ver>
|
||||||
|
# branch and a comment on the "Port status: <target>" 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 <target-branch> <sha> [<sha>...]}"
|
||||||
|
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 "- <sha>" 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 <<EOF
|
||||||
|
:warning: **Conflict** porting \`${short}\` — ${subject}${PORT_SOURCE_PR:+ (from #${PORT_SOURCE_PR})} — to \`${TARGET}\`.
|
||||||
|
|
||||||
|
Resolve locally:
|
||||||
|
\`\`\`bash
|
||||||
|
git fetch origin
|
||||||
|
git switch ${branch}
|
||||||
|
git cherry-pick -x ${sha}
|
||||||
|
# resolve conflicts, git cherry-pick --continue, then:
|
||||||
|
git push origin ${branch}:${TARGET}
|
||||||
|
git push origin --delete ${branch}
|
||||||
|
\`\`\`
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
log "conflict on ${short} reported to issue #${issue}"
|
||||||
|
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
|
||||||
|
echo ":warning: conflict porting \`${short}\` (${subject}) to \`${TARGET}\` — see tracking issue #${issue}" >> "$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
|
||||||
Executable
+346
@@ -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 "<br/>".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 += ["<!-- paste under \"## Bug Fixes and Improvements\" -->",
|
||||||
|
"<details>",
|
||||||
|
f"<summary>View Details ({len(fixes)} fixes in {rng})</summary>", ""]
|
||||||
|
out += fixes
|
||||||
|
out += ["", "</details>", ""]
|
||||||
|
out += ["## Community Contributors", "",
|
||||||
|
f"<!-- {len(contributors)} community contributors in {rng} -->", ""]
|
||||||
|
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())
|
||||||
Reference in New Issue
Block a user