2471ea23ce
Stop shared Unreleased edits that conflict across PRs, and cut releases via Prepare release (towncrier + every plugin/marketplace version bump) then auto-tag on merge, with agent-oriented CONTRIBUTING and PR template guidance.
163 lines
5.4 KiB
YAML
163 lines
5.4 KiB
YAML
name: Changelog guard
|
|
|
|
# Non-release PRs must not edit CHANGELOG.md or bump lockstep version strings.
|
|
# Content edits to SKILL.md / pyproject.toml / uv.lock are fine.
|
|
# Release PRs (label: release) are exempt. Engine changes need a changelog
|
|
# fragment unless labeled skip-changelog.
|
|
|
|
on:
|
|
pull_request:
|
|
types: [opened, synchronize, reopened, labeled, unlabeled]
|
|
|
|
permissions: {}
|
|
|
|
jobs:
|
|
guard:
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
contents: read
|
|
pull-requests: read
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
with:
|
|
fetch-depth: 0
|
|
persist-credentials: false
|
|
|
|
- name: Enforce changelog / version lockstep rules
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
|
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
LABELS="$(gh api "repos/${{ github.repository }}/issues/${PR_NUMBER}/labels" --jq '.[].name')"
|
|
TITLE="$(gh api "repos/${{ github.repository }}/pulls/${PR_NUMBER}" --jq '.title')"
|
|
IS_RELEASE=0
|
|
SKIP_CHANGELOG=0
|
|
if printf '%s\n' "${LABELS}" | grep -qx 'release'; then
|
|
IS_RELEASE=1
|
|
fi
|
|
if printf '%s\n' "${TITLE}" | grep -qE '^chore\(release\): bump version to '; then
|
|
IS_RELEASE=1
|
|
fi
|
|
if printf '%s\n' "${LABELS}" | grep -qx 'skip-changelog'; then
|
|
SKIP_CHANGELOG=1
|
|
fi
|
|
|
|
mapfile -t CHANGED < <(git diff --name-only "${BASE_SHA}...${HEAD_SHA}")
|
|
|
|
changed_changelog=0
|
|
for path in "${CHANGED[@]}"; do
|
|
if [ "${path}" = "CHANGELOG.md" ]; then
|
|
changed_changelog=1
|
|
fi
|
|
done
|
|
|
|
if [ "${IS_RELEASE}" -eq 1 ]; then
|
|
echo "PR has label 'release' — version/CHANGELOG edits allowed."
|
|
exit 0
|
|
fi
|
|
|
|
if [ "${changed_changelog}" -eq 1 ]; then
|
|
echo "::error::Do not edit CHANGELOG.md in feature PRs."
|
|
echo "Add changelog.d/<n>.<type>.md instead (see changelog.d/README.md)."
|
|
echo "Release PRs created via Actions → Prepare release use the 'release' label."
|
|
exit 1
|
|
fi
|
|
|
|
version_at() {
|
|
local ref="$1"
|
|
local path="$2"
|
|
git show "${ref}:${path}" 2>/dev/null | PATH_ARG="${path}" python3 -c '
|
|
import json, os, re, sys
|
|
path = os.environ["PATH_ARG"]
|
|
text = sys.stdin.read()
|
|
if path.endswith("pyproject.toml"):
|
|
m = re.search(r"(?m)^version\s*=\s*\"([^\"]+)\"\s*$", text)
|
|
print(m.group(1) if m else "")
|
|
elif path.endswith("SKILL.md"):
|
|
m = re.search(r"(?m)^version:\s*\"([^\"]+)\"\s*$", text)
|
|
print(m.group(1) if m else "")
|
|
elif path.endswith("uv.lock"):
|
|
m = re.search(
|
|
r"(?ms)^\[\[package\]\]\nname = \"last30days-skill\"\nversion = \"([^\"]+)\"",
|
|
text,
|
|
)
|
|
print(m.group(1) if m else "")
|
|
elif path.endswith("marketplace.json"):
|
|
data = json.loads(text)
|
|
plugins = data.get("plugins") or []
|
|
print(plugins[0].get("version", "") if plugins else "")
|
|
else:
|
|
data = json.loads(text)
|
|
print(data.get("version", ""))
|
|
'
|
|
}
|
|
|
|
VERSION_PATHS=(
|
|
pyproject.toml
|
|
uv.lock
|
|
skills/last30days/SKILL.md
|
|
.claude-plugin/plugin.json
|
|
.claude-plugin/marketplace.json
|
|
.codex-plugin/plugin.json
|
|
.grok-plugin/plugin.json
|
|
.grok-plugin/marketplace.json
|
|
gemini-extension.json
|
|
)
|
|
|
|
bumps=()
|
|
for path in "${VERSION_PATHS[@]}"; do
|
|
# Only compare when the file exists on both sides.
|
|
if ! git cat-file -e "${BASE_SHA}:${path}" 2>/dev/null; then
|
|
continue
|
|
fi
|
|
if ! git cat-file -e "${HEAD_SHA}:${path}" 2>/dev/null; then
|
|
continue
|
|
fi
|
|
base_v="$(version_at "${BASE_SHA}" "${path}")"
|
|
head_v="$(version_at "${HEAD_SHA}" "${path}")"
|
|
if [ -n "${base_v}" ] && [ -n "${head_v}" ] && [ "${base_v}" != "${head_v}" ]; then
|
|
bumps+=("${path}: ${base_v} → ${head_v}")
|
|
fi
|
|
done
|
|
|
|
if [ "${#bumps[@]}" -gt 0 ]; then
|
|
echo "::error::Non-release PRs must not bump lockstep version strings."
|
|
printf ' - %s\n' "${bumps[@]}"
|
|
echo "Run Actions → Prepare release to cut a version bump PR."
|
|
exit 1
|
|
fi
|
|
|
|
has_fragment=0
|
|
for path in "${CHANGED[@]}"; do
|
|
case "${path}" in
|
|
changelog.d/*.md)
|
|
base="$(basename "${path}")"
|
|
if [ "${base}" != "README.md" ]; then
|
|
has_fragment=1
|
|
fi
|
|
;;
|
|
esac
|
|
done
|
|
|
|
touches_engine=0
|
|
for path in "${CHANGED[@]}"; do
|
|
case "${path}" in
|
|
skills/last30days/scripts/*|skills/last30days/SKILL.md|mcp/*)
|
|
touches_engine=1
|
|
;;
|
|
esac
|
|
done
|
|
|
|
if [ "${touches_engine}" -eq 1 ] && [ "${has_fragment}" -eq 0 ] && [ "${SKIP_CHANGELOG}" -eq 0 ]; then
|
|
echo "::error::Engine/skill changes need a changelog.d fragment (or the skip-changelog label)."
|
|
echo "See changelog.d/README.md"
|
|
exit 1
|
|
fi
|
|
|
|
echo "Changelog guard passed."
|