Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
83 changes: 83 additions & 0 deletions .github/scripts/release-notes.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
# Generates release-notes body text for <current_tag> (FR-006). Groups
# commit subjects in previous_release_tag..current_tag by conventional-
# commit prefix (feat/fix/docs/chore), with a fallback bucket for anything
# else (FR-007). previous_release_tag comes from T005's
# previous-release-tag.sh (FR-011); when it's empty (first release, no
# previous tag), falls back to the full history reachable from
# current_tag (FR-009).
#
# No bash arrays: macOS's default /usr/bin/env bash resolves to bash 3.2,
# which raises "unbound variable" on an empty array expansion under
# `set -u`. Buckets are plain temp files instead -- portable across bash
# 3.2 (local/macOS) and the GitHub Actions runner's bash 5.
set -euo pipefail

usage() {
echo "usage: $0 <current_tag>" >&2
exit 2
}

[ $# -ge 1 ] || usage
current_tag="$1"

script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
previous_tag="$("$script_dir/previous-release-tag.sh" "$current_tag")"
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if [ -n "$previous_tag" ]; then
range="${previous_tag}..${current_tag}"
else
range="$current_tag"
fi

tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT

: > "$tmp/feat"
: > "$tmp/fix"
: > "$tmp/docs"
: > "$tmp/chore"
: > "$tmp/other"

# `--`: without it, a first-release history that happens to collide with a
# path in the working tree (e.g. a tag "v1.0.0" and a directory
# "v1.0.0/") makes git treat the argument as ambiguous and fail; `--`
# forces it to be read as a revision. The previous..current range form is
# immune (a `..` argument is never path-ambiguous) but costs nothing to
# guard uniformly.
#
# `!` variants (feat!:, fix(scope)!:, ...) are Conventional Commits'
# breaking-change marker -- grouped with their non-breaking counterparts
# rather than a fallback bucket, since they're still feat/fix/etc in
# intent; `${line#*: }` already strips the `!` along with the rest of the
# prefix correctly.
git log --format=%s "$range" -- | while IFS= read -r line; do
[ -z "$line" ] && continue
case "$line" in
feat:*|feat\(*\):*|feat!:*|feat\(*\)!:*) echo "- ${line#*: }" >> "$tmp/feat" ;;
fix:*|fix\(*\):*|fix!:*|fix\(*\)!:*) echo "- ${line#*: }" >> "$tmp/fix" ;;
docs:*|docs\(*\):*|docs!:*|docs\(*\)!:*) echo "- ${line#*: }" >> "$tmp/docs" ;;
chore:*|chore\(*\):*|chore!:*|chore\(*\)!:*) echo "- ${line#*: }" >> "$tmp/chore" ;;
*) echo "- $line" >> "$tmp/other" ;;
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
esac
done

print_section() {
local title="$1" file="$2"
[ -s "$file" ] || return 0
echo "### $title"
cat "$file"
echo
}

if [ ! -s "$tmp/feat" ] && [ ! -s "$tmp/fix" ] && [ ! -s "$tmp/docs" ] \
&& [ ! -s "$tmp/chore" ] && [ ! -s "$tmp/other" ]; then
echo "No changes since the previous release."
exit 0
fi

print_section "Features" "$tmp/feat"
print_section "Fixes" "$tmp/fix"
print_section "Documentation" "$tmp/docs"
print_section "Chores" "$tmp/chore"
print_section "Other Changes" "$tmp/other"
104 changes: 104 additions & 0 deletions .github/scripts/tests/test-release-notes.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
#!/usr/bin/env bash
# Fixture-based tests for release-notes.sh (T013/T014). Builds a throwaway
# git repo with a known commit/tag history rather than touching the real
# repo's history. Run manually:
# bash .github/scripts/tests/test-release-notes.sh
Comment thread
rsenna marked this conversation as resolved.
set -euo pipefail

script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
script="$script_dir/../release-notes.sh"

tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT

pass=0
fail=0

check_contains() {
local desc="$1" haystack="$2" needle="$3"
if printf '%s' "$haystack" | grep -qF -- "$needle"; then
pass=$((pass + 1))
else
fail=$((fail + 1))
echo "FAIL: $desc (expected to find '$needle')"
fi
}

check_not_contains() {
local desc="$1" haystack="$2" needle="$3"
if printf '%s' "$haystack" | grep -qF -- "$needle"; then
fail=$((fail + 1))
echo "FAIL: $desc (unexpectedly found '$needle')"
else
pass=$((pass + 1))
fi
}

(
cd "$tmp"
git init -q
git config user.email "test@example.com"
git config user.name "Test"
git config tag.gpgsign false
tag() { git -c tag.gpgsign=false tag "$1"; }

echo "one" >file.txt
git add file.txt
git commit -q -m "chore: repo init"
tag v1.0.0

echo "two" >file.txt
git commit -q -am "feat: add substrate boundary"
echo "three" >file.txt
git commit -q -am "fix: correct rollback semantics"
echo "four" >file.txt
git commit -q -am "docs: update AGENTS.md"
echo "five" >file.txt
git commit -q -am "chore: bump toolchain"
echo "six" >file.txt
git commit -q -am "tidy up variable names"
tag v1.1.0
)

# --- conventional-commit-prefix grouping (feat/fix/docs/chore), FR-007 ---
out="$(cd "$tmp" && bash "$script" v1.1.0)"
check_contains "feat commit grouped under Features" "$out" "### Features"
check_contains "feat commit message present" "$out" "add substrate boundary"
check_contains "fix commit grouped under Fixes" "$out" "### Fixes"
check_contains "fix commit message present" "$out" "correct rollback semantics"
check_contains "docs commit grouped under Documentation" "$out" "### Documentation"
check_contains "docs commit message present" "$out" "update AGENTS.md"
check_contains "chore commit grouped under Chores" "$out" "### Chores"
check_contains "chore commit message present" "$out" "bump toolchain"

# --- fallback bucket for unmatched commit subjects, FR-007 ---
check_contains "non-conventional commit grouped under Other Changes" "$out" "### Other Changes"
check_contains "non-conventional commit message present verbatim" "$out" "tidy up variable names"

# --- only commits in previous_tag..current_tag are included, not the
# tagging commit itself (v1.0.0's "chore: repo init" must NOT appear) ---
check_not_contains "v1.0.0's own commit is excluded from v1.1.0's notes" "$out" "repo init"

# --- first-release fallback path when no previous tag exists, FR-009 ---
out_first="$(cd "$tmp" && bash "$script" v1.0.0)"
check_contains "first release includes its own commit (full-history fallback)" "$out_first" "repo init"

# --- breaking-change marker (feat!:, fix(scope)!:) groups with its
# non-breaking counterpart, not the fallback bucket ---
(
cd "$tmp"
echo "seven" >file.txt
git commit -q -am "feat!: drop deprecated flag"
echo "eight" >file.txt
git commit -q -am "fix(cli)!: change exit code semantics"
git -c tag.gpgsign=false tag v1.2.0
)
out_breaking="$(cd "$tmp" && bash "$script" v1.2.0)"
check_contains "feat! groups under Features" "$out_breaking" "### Features"
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
check_contains "feat! message stripped of prefix" "$out_breaking" "drop deprecated flag"
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
check_contains "fix(scope)! groups under Fixes" "$out_breaking" "### Fixes"
check_contains "fix(scope)! message stripped of prefix" "$out_breaking" "change exit code semantics"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

echo "----"
echo "pass=$pass fail=$fail"
[ "$fail" -eq 0 ]
45 changes: 43 additions & 2 deletions specs/005-ci-release-versioning/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,18 +286,59 @@ progression and generated notes reflect `previous_tag..current_tag` commits.

### Tests for User Story 3 (write first)

- [ ] **T013** [US3] Write fixture-based tests for the release-notes script
- [x] **T013** [US3] Write fixture-based tests for the release-notes script
covering: conventional-commit-prefix grouping (`feat`/`fix`/`docs`/`chore`),
a fallback bucket for unmatched commit subjects (FR-007), and the
first-release fallback path when no previous tag exists (FR-009).
**Done 2026-08-14**: `.github/scripts/tests/test-release-notes.sh`,
throwaway-git-repo fixture matching T005's pattern. Confirmed red
(script didn't exist) before implementing T014.

### Implementation for User Story 3

- [ ] **T014** [US3] Implement `.github/scripts/release-notes.sh` (or
- [x] **T014** [US3] Implement `.github/scripts/release-notes.sh` (or
equivalent): computes `previous_release_tag..current_release_tag` (via
T005's tag-selection logic), groups commits by conventional-commit intent
with a fallback bucket, and produces the release body text (FR-006,
FR-007, FR-009).
**Done 2026-08-14**: `.github/scripts/release-notes.sh`. Calls T005's
`previous-release-tag.sh` for `previous_release_tag`; empty result
(first release, FR-009) falls back to `git log <current_tag>` (full
history reachable from the tag, no lower bound) instead of a range.
Groups `git log --format=%s <range> --` output by `feat`/`fix`/`docs`/
`chore` prefix (with or without a `(scope)` and/or a Conventional
Commits `!` breaking-change marker) into `### Features`/`### Fixes`/
`### Documentation`/`### Chores` sections; anything else falls into
`### Other Changes` verbatim (FR-007). Only sections with entries are
printed; an entirely empty range prints a deterministic "No changes
since the previous release." line rather than an empty body (this
branch is defensive and not independently tested -- through the
script's own single-tag interface, `previous_tag` is always a strict
ancestor of `current_tag` via `previous-release-tag.sh`'s `^`
traversal, so the range always contains at least the tagged commit
itself; a genuinely empty range isn't reachable through realistic
input, so forcing a fake test for it would be padding, not coverage).
No bash arrays -- macOS's default `/usr/bin/env bash` resolves to bash
3.2, which raises "unbound variable" on an empty array expansion under
`set -u`; buckets are plain temp files instead, portable across bash
3.2 (local) and the GitHub Actions runner's bash 5. Test-first
(Constitution I): T013's fixture suite green (16/16) -- prefix
grouping for all four types, fallback-bucket verbatim text,
range-exclusivity (the previous tag's own commit must NOT appear in
the next release's notes), the first-release full-history fallback,
and breaking-change markers (`feat!:`, `fix(scope)!:`) grouping with
their non-breaking counterparts rather than the fallback bucket.
Manually inspected output for both a with-previous-tag and a
first-release case -- reads as clean, correctly-sectioned markdown.
**Fixes after self-review** (pr-review-toolkit:code-reviewer): (1)
`!` breaking-change commits were falling into the fallback bucket
instead of grouping with feat/fix/docs/chore -- added the `!` case
variants. (2) the first-release fallback path (`git log <tag>`, no
range) had no `--` separator, so a tag name colliding with a path in
the working tree (e.g. a tag `v1.0.0` and a directory `v1.0.0/`)
would make git treat the argument as ambiguous and fail -- added
`--`; the `previous..current` range form was already immune since a
`..` argument is never path-ambiguous.
- [ ] **T015** [US3] Wire T006's build identifier into release metadata and
artifact naming (FR-005).
- [ ] **T016** [US3] Verify SC-003/SC-004 end-to-end: two consecutive
Expand Down