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