55 workflow_dispatch :
66 inputs :
77 bump :
8- description : " Force a specific bump (auto|patch|minor|major)"
8+ description : " Force a specific bump (auto|patch|minor|major|none )"
99 required : false
1010 default : " auto"
1111 type : choice
12- options : [auto, patch, minor, major]
12+ options : [auto, patch, minor, major, none ]
1313
1414concurrency :
1515 group : version-bump-${{ github.ref }}
@@ -18,6 +18,10 @@ concurrency:
1818permissions :
1919 contents : write
2020
21+ env :
22+ # Opt into Node.js 24 for actions to silence the Node 20 deprecation warning.
23+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24 : " true"
24+
2125jobs :
2226 bump :
2327 name : Bump version & tag
@@ -43,83 +47,131 @@ jobs:
4347 - name : Determine bump type
4448 id : classify
4549 shell : bash
50+ # NOTE: intentionally NOT using `set -e`. The grep pipelines below are
51+ # expected to return non-zero when no match is found, which is normal
52+ # control flow — letting `-e` kill the step caused historical false
53+ # failures. Errors that matter are reported explicitly.
4654 run : |
47- set -e
48- last_tag=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
49- if [ -n "$last_tag" ]; then range="${last_tag}..HEAD"; else range="HEAD"; fi
50- msgs=$(git log --pretty=%s "$range")
55+ set +e
56+ # ----------------------------------------------------------------
57+ # Resolve commit range since the last semver tag (or full history).
58+ # ----------------------------------------------------------------
59+ last_tag="$(git describe --tags --abbrev=0 2>/dev/null || true)"
60+ if [ -n "$last_tag" ]; then
61+ range="${last_tag}..HEAD"
62+ else
63+ range="HEAD"
64+ fi
65+ msgs="$(git log --pretty=%s "$range" 2>/dev/null || true)"
66+
67+ # ----------------------------------------------------------------
68+ # Honour an explicit input from workflow_dispatch.
69+ # ----------------------------------------------------------------
5170 forced="${{ github.event.inputs.bump }}"
5271 if [ -n "$forced" ] && [ "$forced" != "auto" ]; then
53- echo "bump=$forced" >> "$GITHUB_OUTPUT"
54- echo "explicit_version=" >> "$GITHUB_OUTPUT"
72+ echo "bump=$forced" >> "$GITHUB_OUTPUT"
73+ echo "explicit_version=" >> "$GITHUB_OUTPUT"
74+ echo "Forced bump: $forced"
5575 exit 0
5676 fi
57- # Detect explicit "chore: bump v1.2.3" / "chore(release): v1.2.3" /
58- # "release v1.2.3" style messages so manual bumps still get tagged.
59- explicit=$(echo "$msgs" | grep -oiE '\bv[0-9]+\.[0-9]+\.[0-9]+\b' | head -n1 | sed 's/^[vV]//')
77+
78+ # ----------------------------------------------------------------
79+ # Look for an explicit "v1.2.3"-style tag in any commit message so
80+ # manual releases still get tagged.
81+ # ----------------------------------------------------------------
82+ explicit="$(printf '%s\n' "$msgs" | grep -oiE '\bv[0-9]+\.[0-9]+\.[0-9]+\b' | head -n1 | sed 's/^[vV]//' || true)"
83+
84+ # ----------------------------------------------------------------
85+ # Conventional Commits classification.
86+ # ----------------------------------------------------------------
6087 bump=none
61- while IFS= read -r m; do
62- if echo "$m" | grep -qiE '^(feat|fix|refactor|perf)(\(.+\))?!:|BREAKING CHANGE'; then
63- bump=major; break
64- elif echo "$m" | grep -qiE '^feat(\(.+\))?:'; then
88+ if [ -n "$msgs" ]; then
89+ while IFS= read -r m; do
90+ [ -z "$m" ] && continue
91+ if printf '%s' "$m" | grep -qiE '^(feat|fix|refactor|perf)(\(.+\))?!:|BREAKING CHANGE'; then
92+ bump=major
93+ break
94+ elif printf '%s' "$m" | grep -qiE '^feat(\(.+\))?:'; then
95+ bump=minor
96+ elif [ "$bump" != "minor" ] && printf '%s' "$m" | grep -qiE '^fix(\(.+\))?:'; then
97+ bump=patch
98+ fi
99+ done <<< "$msgs"
100+ fi
101+
102+ # ----------------------------------------------------------------
103+ # Loose-keyword fallback for repos that don't use Conventional Commits.
104+ # ----------------------------------------------------------------
105+ if [ "$bump" = "none" ]; then
106+ if printf '%s' "$msgs" | grep -qiE 'feature|added|implement'; then
65107 bump=minor
66- elif echo "$m" | grep -qiE '^fix(\(.+\))?:' && [ "$bump" != "minor" ]; then
108+ elif printf '%s' "$msgs" | grep -qiE 'fix|bug|error|crash'; then
109+ bump=patch
110+ elif [ -n "$explicit" ]; then
111+ bump=patch
112+ elif printf '%s' "$msgs" | grep -qiE '\b(bump|release|publish|version)\b'; then
67113 bump=patch
68- fi
69- done <<< "$msgs"
70- if [ "$bump" = "none" ]; then
71- if echo "$msgs" | grep -qiE 'feature|added|implement'; then bump=minor
72- elif echo "$msgs" | grep -qiE 'fix|bug|error|crash'; then bump=patch
73- elif [ -n "$explicit" ] || echo "$msgs" | grep -qiE '\b(bump|release|publish|version)\b'; then bump=patch
74114 fi
75115 fi
76- echo "bump=$bump" >> "$GITHUB_OUTPUT"
77- echo "explicit_version=$explicit" >> "$GITHUB_OUTPUT"
116+
117+ echo "bump=$bump" >> "$GITHUB_OUTPUT"
118+ echo "explicit_version=$explicit" >> "$GITHUB_OUTPUT"
78119 echo "Detected bump: $bump (explicit: ${explicit:-none})"
120+ exit 0
79121
80122 - name : Bump pyproject version
81123 id : bump
82124 if : steps.classify.outputs.bump != 'none' || steps.classify.outputs.explicit_version != ''
83125 shell : bash
84126 run : |
127+ set -e
85128 python - <<'PY' >> "$GITHUB_OUTPUT"
86- import re, pathlib
87- bump = "${{ steps.classify.outputs.bump }}"
129+ import re, pathlib, sys
130+ bump = "${{ steps.classify.outputs.bump }}"
88131 explicit = "${{ steps.classify.outputs.explicit_version }}"
89132 p = pathlib.Path("pyproject.toml")
133+ if not p.exists():
134+ print("bumped=false")
135+ sys.exit(0)
90136 txt = p.read_text(encoding="utf-8")
91137 m = re.search(r'(?m)^version\s*=\s*"([^"]+)"', txt)
92138 if not m:
93139 print("bumped=false")
94- raise SystemExit (0)
140+ sys.exit (0)
95141 current = m.group(1)
96142 if explicit:
97143 new = explicit
98144 else:
99- maj, mn, pt = (int(x) for x in current.split("."))
100- if bump == "major":
101- maj, mn, pt = maj + 1, 0, 0
102- elif bump == "minor":
103- mn, pt = mn + 1, 0
104- elif bump == "patch":
105- pt = pt + 1
145+ try:
146+ maj, mn, pt = (int(x) for x in current.split("."))
147+ except Exception:
148+ print("bumped=false")
149+ sys.exit(0)
150+ if bump == "major": maj, mn, pt = maj + 1, 0, 0
151+ elif bump == "minor": mn, pt = mn + 1, 0
152+ elif bump == "patch": pt = pt + 1
153+ else: new = current; print(f"new_version={new}"); print("bumped=false"); sys.exit(0)
106154 new = f"{maj}.{mn}.{pt}"
107155 if new != current:
108156 p.write_text(
109157 re.sub(r'(?m)^version\s*=\s*"[^"]+"', f'version = "{new}"', txt),
110158 encoding="utf-8",
111159 )
112- print(f"new_version={new}")
113- print("bumped=true")
160+ print(f"new_version={new}")
161+ print("bumped=true")
162+ else:
163+ print(f"new_version={new}")
164+ print("bumped=false")
114165 PY
115166
116167 - name : Commit, tag, push
117168 if : steps.bump.outputs.bumped == 'true'
118169 env :
119170 NEW : ${{ steps.bump.outputs.new_version }}
171+ shell : bash
120172 run : |
121173 set -e
122- git config user.name "github-actions[bot]"
174+ git config user.name "github-actions[bot]"
123175 git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
124176 # Skip entirely if the tag already exists (manual tag or prior run).
125177 if git rev-parse "v${NEW}" >/dev/null 2>&1 || \
@@ -128,7 +180,6 @@ jobs:
128180 exit 0
129181 fi
130182 git add pyproject.toml
131- # Only commit if pyproject changed (manual bumps already committed).
132183 if ! git diff --cached --quiet; then
133184 git commit -m "chore(release): v${NEW}"
134185 fi
0 commit comments