返回 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.
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
154 lines YAML