1
0
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:
purian23
2026-07-09 11:26:30 -04:00
parent a095d0ed90
commit 8008238ca0
13 changed files with 883 additions and 10 deletions
+96
View File
@@ -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
+118
View File
@@ -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
+346
View File
@@ -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("{", "&#123;").replace("<", "&lt;")
.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())