| 1 | name: Changelog guard |
| 2 | |
| 3 | # Non-release PRs must not edit CHANGELOG.md or bump lockstep version strings. |
| 4 | # Content edits to SKILL.md / pyproject.toml / uv.lock are fine. |
| 5 | # Release PRs (label: release) are exempt. Engine changes need a changelog |
| 6 | # fragment unless labeled skip-changelog. |
| 7 | |
| 8 | on: |
| 9 | pull_request: |
| 10 | types: [opened, synchronize, reopened, labeled, unlabeled] |
| 11 | |
| 12 | permissions: {} |
| 13 | |
| 14 | jobs: |
| 15 | guard: |
| 16 | runs-on: ubuntu-latest |
| 17 | permissions: |
| 18 | contents: read |
| 19 | pull-requests: read |
| 20 | steps: |
| 21 | - name: Checkout |
| 22 | uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 |
| 23 | with: |
| 24 | fetch-depth: 0 |
| 25 | persist-credentials: false |
| 26 | |
| 27 | - name: Enforce changelog / version lockstep rules |
| 28 | env: |
| 29 | GH_TOKEN: ${{ github.token }} |
| 30 | PR_NUMBER: ${{ github.event.pull_request.number }} |
| 31 | BASE_SHA: ${{ github.event.pull_request.base.sha }} |
| 32 | HEAD_SHA: ${{ github.event.pull_request.head.sha }} |
| 33 | run: | |
| 34 | set -euo pipefail |
| 35 | |
| 36 | LABELS="$(gh api "repos/${{ github.repository }}/issues/${PR_NUMBER}/labels" --jq '.[].name')" |
| 37 | IS_RELEASE=0 |
| 38 | SKIP_CHANGELOG=0 |
| 39 | if printf '%s\n' "${LABELS}" | grep -qx 'release'; then |
| 40 | IS_RELEASE=1 |
| 41 | fi |
| 42 | if printf '%s\n' "${LABELS}" | grep -qx 'skip-changelog'; then |
| 43 | SKIP_CHANGELOG=1 |
| 44 | fi |
| 45 | |
| 46 | mapfile -t CHANGED < <(git diff --name-only "${BASE_SHA}...${HEAD_SHA}") |
| 47 | |
| 48 | changed_changelog=0 |
| 49 | for path in "${CHANGED[@]}"; do |
| 50 | if [ "${path}" = "CHANGELOG.md" ]; then |
| 51 | changed_changelog=1 |
| 52 | fi |
| 53 | done |
| 54 | |
| 55 | if [ "${IS_RELEASE}" -eq 1 ]; then |
| 56 | echo "PR has label 'release' — version/CHANGELOG edits allowed." |
| 57 | exit 0 |
| 58 | fi |
| 59 | |
| 60 | if [ "${changed_changelog}" -eq 1 ]; then |
| 61 | # One-time towncrier adoption: replacing ## [Unreleased] with the |
| 62 | # start marker / notice is allowed. Adding release-note bullets |
| 63 | # (+### sections) is not. |
| 64 | cl_diff="$(git diff "${BASE_SHA}...${HEAD_SHA}" -- CHANGELOG.md || true)" |
| 65 | if printf '%s\n' "${cl_diff}" | grep -q 'towncrier release notes start' \ |
| 66 | && ! printf '%s\n' "${cl_diff}" | grep -qE '^\+### '; then |
| 67 | echo "Allowing towncrier bootstrap CHANGELOG.md header change." |
| 68 | changed_changelog=0 |
| 69 | fi |
| 70 | fi |
| 71 | |
| 72 | if [ "${changed_changelog}" -eq 1 ]; then |
| 73 | echo "::error::Do not edit CHANGELOG.md in feature PRs." |
| 74 | echo "Add changelog.d/<n>.<type>.md instead (see changelog.d/README.md)." |
| 75 | echo "Release PRs created via Actions → Prepare release use the 'release' label." |
| 76 | exit 1 |
| 77 | fi |
| 78 | |
| 79 | # Keep version parsing in .github/scripts/ — a prior inline |
| 80 | # python3 -c block used column-0 source and made Actions refuse |
| 81 | # to parse this workflow (every run failed with empty jobs). |
| 82 | version_at() { |
| 83 | local ref="$1" |
| 84 | local path="$2" |
| 85 | # Fail closed: do not swallow helper/parse errors with || true. |
| 86 | # Callers only invoke this after git cat-file confirms the blob. |
| 87 | git show "${ref}:${path}" \ |
| 88 | | python3 .github/scripts/read_manifest_version.py "${path}" |
| 89 | } |
| 90 | |
| 91 | VERSION_PATHS=( |
| 92 | pyproject.toml |
| 93 | uv.lock |
| 94 | skills/last30days/SKILL.md |
| 95 | .claude-plugin/plugin.json |
| 96 | .claude-plugin/marketplace.json |
| 97 | .codex-plugin/plugin.json |
| 98 | .grok-plugin/plugin.json |
| 99 | .grok-plugin/marketplace.json |
| 100 | gemini-extension.json |
| 101 | ) |
| 102 | |
| 103 | bumps=() |
| 104 | for path in "${VERSION_PATHS[@]}"; do |
| 105 | # Only compare when the file exists on both sides. |
| 106 | if ! git cat-file -e "${BASE_SHA}:${path}" 2>/dev/null; then |
| 107 | continue |
| 108 | fi |
| 109 | if ! git cat-file -e "${HEAD_SHA}:${path}" 2>/dev/null; then |
| 110 | continue |
| 111 | fi |
| 112 | base_v="$(version_at "${BASE_SHA}" "${path}")" |
| 113 | head_v="$(version_at "${HEAD_SHA}" "${path}")" |
| 114 | if [ -n "${base_v}" ] && [ -n "${head_v}" ] && [ "${base_v}" != "${head_v}" ]; then |
| 115 | bumps+=("${path}: ${base_v} → ${head_v}") |
| 116 | fi |
| 117 | done |
| 118 | |
| 119 | if [ "${#bumps[@]}" -gt 0 ]; then |
| 120 | echo "::error::Non-release PRs must not bump lockstep version strings." |
| 121 | printf ' - %s\n' "${bumps[@]}" |
| 122 | echo "Run Actions → Prepare release to cut a version bump PR." |
| 123 | exit 1 |
| 124 | fi |
| 125 | |
| 126 | has_fragment=0 |
| 127 | for path in "${CHANGED[@]}"; do |
| 128 | case "${path}" in |
| 129 | changelog.d/*.md) |
| 130 | base="$(basename "${path}")" |
| 131 | if [ "${base}" != "README.md" ]; then |
| 132 | has_fragment=1 |
| 133 | fi |
| 134 | ;; |
| 135 | esac |
| 136 | done |
| 137 | |
| 138 | touches_engine=0 |
| 139 | for path in "${CHANGED[@]}"; do |
| 140 | case "${path}" in |
| 141 | skills/last30days/scripts/*|skills/last30days/SKILL.md|mcp/*) |
| 142 | touches_engine=1 |
| 143 | ;; |
| 144 | esac |
| 145 | done |
| 146 | |
| 147 | if [ "${touches_engine}" -eq 1 ] && [ "${has_fragment}" -eq 0 ] && [ "${SKIP_CHANGELOG}" -eq 0 ]; then |
| 148 | echo "::error::Engine/skill changes need a changelog.d fragment (or the skip-changelog label)." |
| 149 | echo "See changelog.d/README.md" |
| 150 | exit 1 |
| 151 | fi |
| 152 | |
| 153 | echo "Changelog guard passed." |
| 154 |