Skip to content

Commit 422c0e5

Browse files
TMHSDigitalclaude
andcommitted
ci: bump detection reads BREAKING CHANGE footers, not prose in subjects
release.yml grepped subjects with -i for "BREAKING[ -]CHANGE" anywhere, so a subject like "docs: explain breaking-change policy" would have cut a major (reproduced: the old regex matches it), while a real conventional-commit `BREAKING CHANGE:` footer in a body was never seen (only %s was scanned). pages.yml carried a hand-copied variant of the same pattern. .github/scripts/bump-kind.sh now owns the decision for both workflows. It reads NUL-separated full messages and prints major/minor/patch/none: - major: feat/fix subject with `!`, or a body line `BREAKING CHANGE: ` / `BREAKING-CHANGE: ` (case-sensitive, footer position) - minor: feat; patch: fix; none: everything else release.yml feeds `git log RANGE --format='%B%x00'`; pages.yml feeds the push payload's messages via jq. tests/test_bump_kind.py (8 cases) runs in validate-harness. Closes #324 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 10fcd33 commit 422c0e5

5 files changed

Lines changed: 116 additions & 20 deletions

File tree

‎.github/scripts/bump-kind.sh‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
#!/usr/bin/env bash
2+
# Print the release bump a set of commits calls for: major, minor, patch or none.
3+
#
4+
# stdin: full commit messages, each terminated by NUL
5+
# git log RANGE --format='%B%x00' | bash .github/scripts/bump-kind.sh
6+
#
7+
# major a feat/fix subject marked breaking (`feat!:`, `fix(scope)!:`), or a
8+
# body line starting `BREAKING CHANGE:` / `BREAKING-CHANGE:` (the
9+
# conventional-commit footer; case-sensitive, so prose that merely
10+
# mentions a breaking change in a subject does not cut a major)
11+
# minor a feat/feature subject
12+
# patch a fix subject
13+
# none anything else (docs, chore, ci, ...), which does not release
14+
#
15+
# release.yml uses the level; pages.yml only needs "not none". Both call this
16+
# script so the two decisions cannot drift apart.
17+
set -euo pipefail
18+
19+
rank=0
20+
while IFS= read -r -d '' msg || [ -n "${msg:-}" ]; do
21+
msg="${msg#$'\n'}"
22+
subject="${msg%%$'\n'*}"
23+
if printf '%s\n' "$subject" | grep -qiE '^(feat|feature|fix)(\(.+\))?!:' \
24+
|| printf '%s\n' "$msg" | tail -n +2 | grep -qE '^BREAKING[ -]CHANGE: '; then
25+
rank=3
26+
elif printf '%s\n' "$subject" | grep -qiE '^(feat|feature)(\(.+\))?:'; then
27+
[ "$rank" -lt 2 ] && rank=2
28+
elif printf '%s\n' "$subject" | grep -qiE '^fix(\(.+\))?:'; then
29+
[ "$rank" -lt 1 ] && rank=1
30+
fi
31+
msg=""
32+
done
33+
34+
case "$rank" in
35+
3) echo major ;;
36+
2) echo minor ;;
37+
1) echo patch ;;
38+
*) echo none ;;
39+
esac

‎.github/workflows/pages.yml‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,22 +37,25 @@ jobs:
3737
# A push that will cut a release gets its deploy from release.yml, which dispatches
3838
# this workflow after committing the version bump. Deploying on the push too only
3939
# built a page that the dispatch then cancelled (8 of 9 push runs measured, #215).
40-
# The pattern mirrors release.yml's "Determine bump type" step exactly. If that
40+
# Both workflows call .github/scripts/bump-kind.sh, so the decision cannot drift. If that
4141
# release fails, redeploy by hand: Actions > Deploy GitHub Pages > Run workflow.
4242
gate:
4343
runs-on: ubuntu-latest
4444
outputs:
4545
deploy: ${{ steps.decide.outputs.deploy }}
4646
steps:
47+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
48+
with:
49+
sparse-checkout: .github/scripts
4750
- id: decide
4851
env:
4952
EVENT: ${{ github.event_name }}
5053
COMMITS: ${{ toJSON(github.event.commits) }}
5154
run: |
5255
deploy=true
53-
if [ "$EVENT" = "push" ] && echo "$COMMITS" \
54-
| jq -r '.[].message | split("\n")[0]' \
55-
| grep -qiE '^(feat|feature|fix)(\(.+\))?!?:|BREAKING[ -]CHANGE'; then
56+
if [ "$EVENT" = "push" ] && [ "$(echo "$COMMITS" \
57+
| jq -j '.[].message + "\u0000"' \
58+
| bash .github/scripts/bump-kind.sh)" != "none" ]; then
5659
deploy=false
5760
echo "Release-worthy push: release.yml dispatches Pages after the version bump."
5861
fi

‎.github/workflows/release.yml‎

Lines changed: 10 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -62,31 +62,25 @@ jobs:
6262
id: bump
6363
run: |
6464
last_tag=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
65-
if [ -z "$last_tag" ]; then
66-
commits=$(git log --oneline --format="%s")
67-
else
68-
commits=$(git log "$last_tag"..HEAD --oneline --format="%s")
69-
fi
65+
range="${last_tag:+$last_tag..}HEAD"
7066
7167
echo "Commits since last release:"
72-
echo "$commits"
68+
git log "$range" --format="%s"
7369
74-
# Release ONLY when a commit subject since the last tag carries a release-worthy
75-
# conventional prefix. docs/chore/ci/refactor/test/style/build/perf land WITHOUT
76-
# cutting a release. A mixed push (docs + a real fix) still releases: the scan
77-
# matches any release-worthy commit in the range regardless of the others.
70+
# Release ONLY when a commit since the last tag is release-worthy:
71+
# feat/fix subjects, `!` breaking subjects, or a `BREAKING CHANGE:` body
72+
# footer. docs/chore/ci/refactor/test/style/build/perf land WITHOUT
73+
# cutting a release; a mixed push (docs + a real fix) still releases.
74+
# .github/scripts/bump-kind.sh owns the patterns (pages.yml shares it).
7875
# [skip ci] (guarded on the job) remains an optional belt-and-suspenders override.
76+
kind=$(git log "$range" --format='%B%x00' | bash .github/scripts/bump-kind.sh)
7977
bump="patch"
8078
release="false"
8179
if [ -z "$last_tag" ]; then
8280
# Initial release: no prior tag -> cut the first release at the current VERSION.
8381
release="true"
84-
elif echo "$commits" | grep -qiE "^(feat|feature|fix)(\(.+\))?!:|BREAKING[ -]CHANGE"; then
85-
bump="major"; release="true"
86-
elif echo "$commits" | grep -qiE "^(feat|feature)(\(.+\))?:"; then
87-
bump="minor"; release="true"
88-
elif echo "$commits" | grep -qiE "^fix(\(.+\))?:"; then
89-
bump="patch"; release="true"
82+
elif [ "$kind" != "none" ]; then
83+
bump="$kind"; release="true"
9084
fi
9185
9286
echo "bump=$bump" >> "$GITHUB_OUTPUT"

‎.github/workflows/validate.yml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,9 @@ jobs:
389389
- name: Release gate fails closed (stubbed gh)
390390
run: python3 tests/test_release_gate.py -v
391391

392+
- name: Release bump detection (bump-kind.sh)
393+
run: python3 tests/test_bump_kind.py -v
394+
392395
- name: Hero-drift tool exit-code tests
393396
run: |
394397
pip install numpy==2.2.6 Pillow==11.2.1

‎tests/test_bump_kind.py‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"""Tests for .github/scripts/bump-kind.sh (release.yml and pages.yml share it).
2+
3+
Run: python tests/test_bump_kind.py -v (needs bash).
4+
"""
5+
from __future__ import annotations
6+
7+
import shutil
8+
import subprocess
9+
import unittest
10+
from pathlib import Path
11+
12+
SCRIPT = Path(__file__).resolve().parent.parent / ".github" / "scripts" / "bump-kind.sh"
13+
# Resolve now: on Windows CreateProcess would find System32\bash.exe (WSL) first.
14+
BASH = shutil.which("bash")
15+
16+
17+
def kind(*messages: str) -> str:
18+
# git log --format='%B%x00' separates entries with "\n" after each NUL.
19+
stdin = "\n".join(m + "\0" for m in messages)
20+
# Bytes, not text mode: on Windows text mode rewrites LF as CRLF.
21+
out = subprocess.run(
22+
[BASH, SCRIPT.as_posix()], input=stdin.encode(), capture_output=True, check=True
23+
)
24+
return out.stdout.decode().strip()
25+
26+
27+
@unittest.skipIf(BASH is None, "bash not available")
28+
class BumpKind(unittest.TestCase):
29+
def test_docs_and_chore_do_not_release(self):
30+
self.assertEqual(kind("docs: tidy README\n", "chore: bump version to 1.2.3 [skip ci]\n"), "none")
31+
32+
def test_fix_is_patch(self):
33+
self.assertEqual(kind("fix(snippets): x\n"), "patch")
34+
35+
def test_feat_is_minor_and_beats_fix(self):
36+
self.assertEqual(kind("fix: a\n", "feat(showcase): b\n"), "minor")
37+
38+
def test_bang_subject_is_major(self):
39+
self.assertEqual(kind("feat(api)!: drop 4.5\n"), "major")
40+
41+
def test_breaking_footer_is_major(self):
42+
self.assertEqual(kind("refactor: x\n\nBREAKING CHANGE: removes y\n"), "major")
43+
44+
def test_breaking_prose_in_subject_is_not_major(self):
45+
# Was major: the old grep matched "BREAKING[ -]CHANGE" anywhere, -i.
46+
self.assertEqual(kind("docs: explain breaking-change policy\n"), "none")
47+
self.assertEqual(kind("fix: note the breaking change in docs\n"), "patch")
48+
49+
def test_lowercase_footer_is_not_major(self):
50+
self.assertEqual(kind("fix: x\n\nbreaking change: no\n"), "patch")
51+
52+
def test_empty_input_is_none(self):
53+
self.assertEqual(kind(), "none")
54+
55+
56+
if __name__ == "__main__":
57+
unittest.main()

0 commit comments

Comments
 (0)