Files
mvanhorn--last30days-skill/.github/workflows/changelog-guard.yml
T
Workflow config file is invalid. Please check your config file: model.ReadWorkflow: yaml: line 75: could not find expected ':'
Trevin Chow 2471ea23ce Add towncrier fragments and automated lockstep release workflow
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.
2026-07-24 16:10:52 -07:00

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."