diff --git a/.github/scripts/release-notes.sh b/.github/scripts/release-notes.sh new file mode 100755 index 0000000..bf00dc8 --- /dev/null +++ b/.github/scripts/release-notes.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# Generates release-notes body text for (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 " >&2 + exit 2 +} + +[ $# -ge 1 ] || usage +current_tag="$1" + +# Same strict SemVer-release-tag check as validate-release-tag.sh (T005). +# In the real release.yml pipeline that script already runs first and +# would have failed the workflow before this one is ever invoked, but +# this script is also runnable standalone -- don't trust an un-validated +# tag (e.g. "v1.2.3-rc1" or "test-tag") to reach git log unchecked. +if ! [[ "$current_tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "error: '$current_tag' is not a valid SemVer release tag (expected vMAJOR.MINOR.PATCH)" >&2 + exit 1 +fi + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +previous_tag="$("$script_dir/previous-release-tag.sh" "$current_tag")" + +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. +# printf, not echo: echo interprets backslash escapes in some shell +# configurations (xpg_echo), which would mangle a commit subject that +# happens to contain a literal backslash instead of preserving it +# verbatim. +git log --format=%s "$range" -- | while IFS= read -r line; do + [ -z "$line" ] && continue + case "$line" in + feat:*|feat\(*\):*|feat!:*|feat\(*\)!:*) printf -- '- %s\n' "${line#*: }" >> "$tmp/feat" ;; + fix:*|fix\(*\):*|fix!:*|fix\(*\)!:*) printf -- '- %s\n' "${line#*: }" >> "$tmp/fix" ;; + docs:*|docs\(*\):*|docs!:*|docs\(*\)!:*) printf -- '- %s\n' "${line#*: }" >> "$tmp/docs" ;; + chore:*|chore\(*\):*|chore!:*|chore\(*\)!:*) printf -- '- %s\n' "${line#*: }" >> "$tmp/chore" ;; + *) printf -- '- %s\n' "$line" >> "$tmp/other" ;; + esac +done + +print_section() { + local title="$1" file="$2" + [ -s "$file" ] || return 0 + printf -- '### %s\n' "$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" diff --git a/.github/scripts/tests/test-release-notes.sh b/.github/scripts/tests/test-release-notes.sh new file mode 100755 index 0000000..9e04a17 --- /dev/null +++ b/.github/scripts/tests/test-release-notes.sh @@ -0,0 +1,134 @@ +#!/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 +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 +} + +# Unlike check_contains (substring match), this asserts a FULL emitted +# bullet line. Substring matching can't actually verify prefix-stripping: +# a bug that failed to strip "feat: " would still leave the original +# subject's tail as a substring of the unstripped line, so a substring +# check for "add substrate boundary" would pass whether the script +# emitted "- add substrate boundary" (correct) or +# "- feat: add substrate boundary" (prefix not stripped). +check_contains_exact_line() { + local desc="$1" haystack="$2" exact_line="$3" + if printf '%s\n' "$haystack" | grep -qFx -- "$exact_line"; then + pass=$((pass + 1)) + else + fail=$((fail + 1)) + echo "FAIL: $desc (expected exact line '$exact_line')" + 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_exact_line "feat commit message stripped of prefix" "$out" "- add substrate boundary" +check_contains "fix commit grouped under Fixes" "$out" "### Fixes" +check_contains_exact_line "fix commit message stripped of prefix" "$out" "- correct rollback semantics" +check_contains "docs commit grouped under Documentation" "$out" "### Documentation" +check_contains_exact_line "docs commit message stripped of prefix" "$out" "- update AGENTS.md" +check_contains "chore commit grouped under Chores" "$out" "### Chores" +check_contains_exact_line "chore commit message stripped of prefix" "$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_exact_line "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" +check_contains_exact_line "feat! message stripped of prefix" "$out_breaking" "- drop deprecated flag" +check_contains "fix(scope)! groups under Fixes" "$out_breaking" "### Fixes" +check_contains_exact_line "fix(scope)! message stripped of prefix" "$out_breaking" "- change exit code semantics" +check_not_contains "breaking-change commits do not also fall into Other Changes" "$out_breaking" "### Other Changes" + +# --- a non-SemVer tag is rejected before it can reach git log, even +# though it exists in the repo (defense in depth -- the real release.yml +# pipeline already validates this via T005 before this script ever runs, +# but this script is also runnable standalone) --- +(cd "$tmp" && git -c tag.gpgsign=false tag "not-a-release-tag") +if (cd "$tmp" && bash "$script" "not-a-release-tag") >/dev/null 2>&1; then + fail=$((fail + 1)) + echo "FAIL (expected failure but succeeded): non-SemVer tag" +else + pass=$((pass + 1)) +fi + +echo "----" +echo "pass=$pass fail=$fail" +[ "$fail" -eq 0 ] diff --git a/specs/005-ci-release-versioning/tasks.md b/specs/005-ci-release-versioning/tasks.md index 10e59ae..3915f1c 100644 --- a/specs/005-ci-release-versioning/tasks.md +++ b/specs/005-ci-release-versioning/tasks.md @@ -286,18 +286,89 @@ 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 ` (full + history reachable from the tag, no lower bound) instead of a range. + Groups `git log --format=%s --` 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 (18/18) -- prefix + grouping for all four types (asserted by exact emitted bullet line, + not substring, so a stripping regression is actually caught), + 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, breaking-change markers + (`feat!:`, `fix(scope)!:`) grouping with their non-breaking + counterparts rather than the fallback bucket (and explicitly NOT also + landing in `### Other Changes`), and a non-SemVer tag being rejected + before it can reach `git log`. 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 `, 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. + **Fixes after coderabbitai/cubic-dev-ai review**: (1) `current_tag` + was never validated against the same strict SemVer regex + `validate-release-tag.sh` (T005) already enforces in the real + pipeline -- this script is also runnable standalone, so added the + same check here rather than trusting an upstream caller. (2) bucket + writes used `echo`, which interprets backslash escapes under + `xpg_echo` and could mangle a commit subject containing a literal + backslash -- switched every write to `printf`. (3) the "message + present" assertions used substring matching, which can't actually + detect a failed prefix-strip (the original subject's tail is a + substring of the unstripped line too) -- converted to exact + full-bullet-line matching throughout the file, not just the two + breaking-change cases originally flagged, for consistency. (4) added + an explicit assertion that breaking-change commits are excluded from + `### Other Changes`, not just present under their own section. + Declined: wiring this fixture suite into `make test`/`ci.yml` + (same pre-existing gap as T005/T006/T011's suites, not specific to + this task -- worth a dedicated follow-up covering all of them + uniformly); extracting the `case` prefix patterns into a shared + regex matcher (stylistic, and the current patterns were already + precisely verified correct by self-review, so rewriting them risks a + new bug for marginal maintainability gain); explicit exit-status + checking around `previous-release-tag.sh`'s call (already handled + correctly by `set -e` -- verified empirically that a failing call + propagates as a nonzero exit with the child script's own clear error + message, no additional handling needed). - [ ] **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