Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 24 additions & 6 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -293,11 +293,17 @@ jobs:

if [ -n "$LAST_FINAL" ]; then
COMMIT_LOG=$(git log "${LAST_FINAL}..HEAD" --pretty=format:"- %s" | head -40)
BREAKING=$(bash scripts/extract-breaking-changes.sh "$LAST_FINAL" HEAD || true)
else
COMMIT_LOG=$(git log --pretty=format:"- %s" | head -40)
BREAKING=""
fi

printf '%s\n\n%s' "$NOTES" "$COMMIT_LOG" > /tmp/rc_notes.md
if [ -n "$BREAKING" ]; then
printf '%s\n\n%s\n\n%s' "$NOTES" "$BREAKING" "$COMMIT_LOG" > /tmp/rc_notes.md
else
printf '%s\n\n%s' "$NOTES" "$COMMIT_LOG" > /tmp/rc_notes.md
fi
echo "notes_file=/tmp/rc_notes.md" >> "$GITHUB_OUTPUT"

- name: Create GitHub pre-release
Expand Down Expand Up @@ -364,9 +370,11 @@ jobs:
if [ -n "$LAST_FINAL" ]; then
COMMITS=$(git log "${LAST_FINAL}..HEAD" --pretty=format:"- %s" \
| grep -v "^- release: " | head -50)
BREAKING=$(bash scripts/extract-breaking-changes.sh "$LAST_FINAL" HEAD || true)
else
COMMITS=$(git log --pretty=format:"- %s" \
| grep -v "^- release: " | head -50)
BREAKING=""
fi

# Build the entry in a temp file rather than interpolating COMMITS
Expand All @@ -379,6 +387,10 @@ jobs:
{
echo "## v${NEW} (${DATE}) — ${BUMP} bump"
echo ""
if [ -n "$BREAKING" ]; then
echo "$BREAKING"
echo ""
fi
echo "$COMMITS"
} > "$ENTRY_FILE"

Expand Down Expand Up @@ -433,16 +445,22 @@ jobs:
if [ -n "$LAST_FINAL" ]; then
COMMITS=$(git log "${LAST_FINAL}..HEAD" --pretty=format:"- %s" \
| grep -v "^- release: " | head -50)
BREAKING=$(bash scripts/extract-breaking-changes.sh "$LAST_FINAL" HEAD || true)
else
COMMITS=$(git log --pretty=format:"- %s" \
| grep -v "^- release: " | head -50)
BREAKING=""
fi

cat > /tmp/release_notes.md <<NOTESEOF
## v${NEW} — ${BUMP} bump

${COMMITS}
NOTESEOF
{
echo "## v${NEW} — ${BUMP} bump"
echo ""
if [ -n "$BREAKING" ]; then
echo "$BREAKING"
echo ""
fi
echo "$COMMITS"
} > /tmp/release_notes.md

echo "notes_file=/tmp/release_notes.md" >> "$GITHUB_OUTPUT"

Expand Down
68 changes: 48 additions & 20 deletions scripts/compute_version_bump.sh
Original file line number Diff line number Diff line change
Expand Up @@ -88,15 +88,6 @@ else
SINCE_TAG=$(last_final_tag)
fi

if [[ -z "$SINCE_TAG" ]]; then
# No prior final tag — scan all commits. Whether this is actually safe is
# decided by the baseline resolution below (it requires an explicit
# --baseline in this case rather than silently proceeding).
COMMITS=$(git log --pretty=format:"%s" 2>/dev/null || true)
else
COMMITS=$(git log "${SINCE_TAG}..HEAD" --pretty=format:"%s" 2>/dev/null || true)
fi

if [[ -n "$BASELINE_OVERRIDE" ]]; then
BASELINE="$BASELINE_OVERRIDE"
elif [[ -n "$SINCE_TAG" ]]; then
Expand All @@ -109,23 +100,39 @@ else
fi

# ── Determine bump type ───────────────────────────────────────────────────────
# Conventional-commits: a breaking change is declared EITHER as `type!:` in the
# subject OR as a `BREAKING CHANGE` footer, which by definition lives in the
# body. The subject determines feat/fix. So we must read the body, not just the
# subject (%s) -- reading %s alone made the footer branch unreachable and shipped
# footer-declared breaking changes as patches (PR #177 review).
BUMP_TYPE="patch"

while IFS= read -r subject; do
[[ -z "$subject" ]] && continue
if [[ -z "$SINCE_TAG" ]]; then
RANGE_HASHES=$(git log --pretty=format:"%H" 2>/dev/null || true)
else
RANGE_HASHES=$(git log "${SINCE_TAG}..HEAD" --pretty=format:"%H" 2>/dev/null || true)
fi

while IFS= read -r sha; do
[[ -z "$sha" ]] && continue
subject=$(git log -1 --format="%s" "$sha" 2>/dev/null || true)
body=$(git log -1 --format="%b" "$sha" 2>/dev/null || true)

# BREAKING CHANGE in footer (multi-line) or ! in type
if echo "$subject" | grep -qiE '(\bBREAKING[[:space:]]+CHANGE\b|^[a-z]+(\([^)]*\))?!:)'; then
# Major: `type!:` in the subject, OR a BREAKING CHANGE / BREAKING-CHANGE marker
# anywhere in the message (footer or deliberate prose).
if echo "$subject" | grep -qE '^[a-z]+(\([^)]*\))?!:' \
|| printf '%s\n%s\n' "$subject" "$body" | grep -qE '\bBREAKING[[:space:] -]+CHANGE\b'; then
BUMP_TYPE="major"
break
fi

# Minor: feat: in the subject (type is declared in the subject, never the body).
if [[ "$BUMP_TYPE" != "major" ]]; then
if echo "$subject" | grep -qE '^feat(\([^)]*\))?:'; then
BUMP_TYPE="minor"
fi
fi
done <<< "$COMMITS"
done <<< "$RANGE_HASHES"

# ── Compute target version ────────────────────────────────────────────────────
TARGET_VERSION=$(bump_version "$BASELINE" "$BUMP_TYPE")
Expand Down Expand Up @@ -153,19 +160,30 @@ if [[ "${SELF_TEST:-0}" == "1" ]]; then
local tmpdir
tmpdir=$(mktemp -d)
git init -q "$tmpdir"
# Self-contained identity so the self-test runs anywhere (fresh runners,
# no global git config).
git -C "$tmpdir" config user.email "selftest@bnk-forge.local"
git -C "$tmpdir" config user.name "bnk-forge self-test"
git -C "$tmpdir" commit --allow-empty -m "initial" -q
if [[ -n "$since" ]]; then
git -C "$tmpdir" tag "$since"
fi
# Add fake commits
while IFS='|' read -r msg; do
[[ -z "$msg" ]] && continue
git -C "$tmpdir" commit --allow-empty -m "$msg" -q
# Add fake commits. An entry may carry a body via "subject~~BODY~~body"
# so tests can exercise a footer-declared BREAKING CHANGE (bodies, not
# subjects, are where the spec puts it). Entries are comma-separated, so
# test strings must not contain commas.
while IFS= read -r entry; do
[[ -z "$entry" ]] && continue
if [[ "$entry" == *"~~BODY~~"* ]]; then
git -C "$tmpdir" commit --allow-empty \
-m "${entry%%~~BODY~~*}" -m "${entry#*~~BODY~~}" -q
else
git -C "$tmpdir" commit --allow-empty -m "$entry" -q
fi
done <<< "$(echo "$commits_str" | tr ',' '\n')"

# Run the version computer inside the temp repo so it scans the fake range.
local result
result=$(bash "$(dirname "$0")/$(basename "$0")" --since-tag "${since:-}" --baseline "$baseline" 2>/dev/null || true)
# Override COMMITS via the temp repo by running in that dir
result=$(cd "$tmpdir" && bash "$OLDPWD/$(dirname "$0")/$(basename "$0")" \
${since:+--since-tag "$since"} --baseline "$baseline" 2>/dev/null || true)

Expand Down Expand Up @@ -199,5 +217,15 @@ if [[ "${SELF_TEST:-0}" == "1" ]]; then
# Test 4: no commits → patch bump
run_test "no commits → patch" "patch" "1.2.4" "v1.2.3" "1.2.3" ""

# Test 5: BREAKING CHANGE footer in the BODY → major (the PR #177 bug: a
# fix-subject commit whose body declares the break must still bump major).
run_test "BREAKING CHANGE footer in body → major" "major" "2.0.0" "v1.2.3" "1.2.3" \
"fix: harden non-root gate~~BODY~~BREAKING CHANGE: USER nonroot must become USER 65532"

# Test 6: lowercase "breaking change" in body prose must NOT trigger major
# (case-sensitive marker, so reading bodies can't false-positive on prose).
run_test "lowercase breaking change in body → patch" "patch" "1.2.4" "v1.2.3" "1.2.3" \
"fix: tidy up~~BODY~~this is explicitly not a breaking change"

echo "=== END SELF-TEST ==="
fi
37 changes: 37 additions & 0 deletions scripts/extract-breaking-changes.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Emit a markdown "Breaking Changes" block for the commits in a range.
#
# Conventional-commits declares a breaking change as a `BREAKING CHANGE` footer,
# which lives in the commit BODY. The release notes / CHANGELOG generation reads
# only subjects (%s), so footer-declared breaks — and any migration steps they
# spell out — never reach operators (PR #177 review). This surfaces them.
#
# Usage: extract-breaking-changes.sh <since_ref> [<until_ref>]
# Prints a "### ⚠️ Breaking Changes" section, or nothing if there are none.
set -euo pipefail

SINCE="${1:?usage: extract-breaking-changes.sh <since_ref> [until_ref]}"
UNTIL="${2:-HEAD}"

block=""
while IFS= read -r sha; do
[[ -z "$sha" ]] && continue
body=$(git log -1 --format="%b" "$sha" 2>/dev/null || true)
# Uppercase footer/marker only (spec form), so body prose like "not a
# breaking change" does not false-trigger.
if printf '%s\n' "$body" | grep -qE '\bBREAKING[[:space:] -]+CHANGE\b'; then
subj=$(git log -1 --format="%s" "$sha" 2>/dev/null || true)
# The BREAKING CHANGE line and its paragraph (up to the next blank line),
# flattened to one line and stripped of markdown bold.
note=$(printf '%s\n' "$body" \
| awk '/BREAKING[[:space:] -]+CHANGE/{p=1} p{print} p&&/^$/{exit}' \
| tr '\n' ' ' | sed 's/\*\*//g; s/ */ /g; s/ *$//')
block="${block}- **${subj}**
${note}
"
fi
done < <(git log "${SINCE}..${UNTIL}" --pretty=format:"%H" 2>/dev/null || true)

if [[ -n "$block" ]]; then
printf '### ⚠️ Breaking Changes\n\n%s\n' "$block"
fi
Loading