diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a4638d..92a8d69 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,6 +59,12 @@ jobs: - run: make test-ts - run: make build-ts + release-scripts: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - run: make test-release + integration: runs-on: ubuntu-latest needs: [go] diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6601e9b..fff7dbf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,14 +46,16 @@ jobs: - uses: actions/checkout@v7 with: fetch-depth: 0 - - name: Bump patch version + # Rules: docs/release-versioning.md + - name: Bump version from commit types id: bump run: | - LATEST=$(git tag --list 'v*' --sort=-v:refname | head -1 || echo "v0.0.0") - MAJOR=$(echo "$LATEST" | sed 's/^v//' | cut -d. -f1) - MINOR=$(echo "$LATEST" | sed 's/^v//' | cut -d. -f2) - PATCH=$(echo "$LATEST" | sed 's/^v//' | cut -d. -f3) - echo "tag=v${MAJOR}.${MINOR}.$((PATCH + 1))" >> "$GITHUB_OUTPUT" + set -euo pipefail + LATEST=$(git tag --list --merged HEAD --sort=-v:refname | grep -E -m1 '^v[0-9]+\.[0-9]+\.[0-9]+$' || true) + RANGE=${LATEST:+$LATEST..}HEAD + echo "Latest tag: ${LATEST:-none}; range: $RANGE" + git log -z --format=%B "$RANGE" | scripts/release-version.sh "$LATEST" | tee -a "$GITHUB_OUTPUT" + git log -z --format=%B "$RANGE" | scripts/release-notes.sh > "$RUNNER_TEMP/release-notes.md" - name: Create tag run: | git config user.name "github-actions[bot]" @@ -64,6 +66,7 @@ jobs: run: | gh release create "${{ steps.bump.outputs.tag }}" \ --title "${{ steps.bump.outputs.tag }}" \ + --notes-file "$RUNNER_TEMP/release-notes.md" \ --generate-notes \ --draft \ --prerelease diff --git a/AGENTS.md b/AGENTS.md index 2191fe9..eacc1f4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,8 +83,10 @@ substitute. its issue closed by hand. 4. **Commit with Conventional Commits** (`feat:`, `fix:`, `docs:`, `spec:`, `chore:`), optionally scoped — `fix(release):`. The release pipeline derives - version bumps from these, so the type is not cosmetic. Mark breaking changes - with `!` (`feat!:`) or a `BREAKING CHANGE:` footer. + version bumps from the commits since the last tag (see + [docs/release-versioning.md](docs/release-versioning.md)), so the type is not + cosmetic. Mark breaking changes with `!` (`feat!:`) or a `BREAKING CHANGE:` + footer; a `Release-As: vX.Y.Z` footer sets the version exactly. 5. **Open the PR with a closing keyword** so the issue auto-closes on merge: `Closes #123` in the body. Fill in `.github/PULL_REQUEST_TEMPLATE.md` honestly — only tick test boxes for suites actually run, and paste the evidence. diff --git a/Makefile b/Makefile index d98aa56..6dde96b 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ LISTENER_SPEC ?= spec/listener.qnt ROUTER_SPEC := spec/router.qnt BACKEND ?= -.PHONY: build clean test lint verify typecheck test-spec validate ci-verify release-verify +.PHONY: build clean test lint verify typecheck test-spec validate ci-verify release-verify test-release .PHONY: build-go test-go lint-go build-rs test-rs build-ts test-ts # ─── Go ────────────────────────────────────────────── @@ -142,6 +142,11 @@ test-integration-sock-ts: validate: typecheck verify lint-go test-go +# ─── Release scripts ───────────────────────────────── + +test-release: + bash scripts/release-version_test.sh + # ─── Reproducible build verification ───────────────── .PHONY: verify-reproducible-go verify-reproducible-rs verify-reproducible-ts verify-reproducible-all diff --git a/docs/release-versioning.md b/docs/release-versioning.md new file mode 100644 index 0000000..4359fc3 --- /dev/null +++ b/docs/release-versioning.md @@ -0,0 +1,97 @@ +# Release Versioning + +Every push to `main` cuts a release. The release workflow (`.github/workflows/release.yml`, `version` job) derives the next version from the commit messages since the latest release tag. This document defines the rule. + +## The Rule + +The workflow scans every commit in `..HEAD`. It finds the bump for each commit, and the highest bump wins. + +| Row | The commits in the range include | Below 1.0.0 | 1.0.0 and above | +|---|---|---|---| +| `release-as` | a `Release-As: vX.Y.Z` line (the newest one decides) | exactly that version | exactly that version | +| `breaking` | a subject `type!:` / `type(scope)!:`, or a line starting `BREAKING CHANGE:` / `BREAKING-CHANGE:` | minor | major | +| `feature` | a subject `feat:` / `feat(scope):` | minor | minor | +| `other` | anything else (`fix`, `docs`, `chore`, `test`, `spec`, `ci`, non-conventional) | patch | patch | +| `no-tag` | there is no tag matching `vX.Y.Z` | start from `v0.0.0`, then apply the rows above | — | + +- A minor bump resets the patch number. A major bump resets the minor and patch numbers. +- The latest tag is the highest version among tags that match `vX.Y.Z` exactly and are reachable from the commit being released (`git tag --merged HEAD`). Other tags are ignored. Versions are compared as numbers, so `v0.10.0` is greater than `v0.9.0`. +- If the range is empty (HEAD is already tagged), the release is still a patch. + +## What Counts + +| Signal | Where it must be | Example | +|---|---|---| +| Commit type (`feat`, `fix!`, …) | subject line (first line) only | `feat(ts): add flag` | +| `BREAKING CHANGE:` / `BREAKING-CHANGE:` | start of any line in the message | footer in the squash body | +| `Release-As:` | start of any line in the message | footer in the squash body | + +Types are matched case-insensitively (`Feat!:` counts as breaking), as Conventional Commits allows. The `BREAKING CHANGE:` / `BREAKING-CHANGE:` footer token is case-sensitive, as Conventional Commits requires. + +Only the subject decides the commit type because GitHub's default squash body lists the branch commits as `* …` bullets. A `* feat: x` bullet under a `fix:` subject is therefore history, not a feature, and it does not count. A breaking-change footer is a deliberate statement, so it counts wherever it starts a line. + +## Why the Whole Range + +One release can cover several merges. v0.2.25 covers #56 and #58, because the release run for `a654ff8` (#56) never reached the version job. If the workflow looked only at the last commit, a `feat` or breaking change in an earlier merge would be lost. + +## `Release-As` + +A `Release-As` footer sets the version exactly and overrides the computed bump. + +| Item | Rule | +|---|---| +| Syntax | `Release-As: vX.Y.Z`. The key is case-insensitive, the `v` is optional (`0.3.0` and `v0.3.0` are the same value), and whitespace around the value is ignored. | +| Several in the range | only the newest one decides; older ones are ignored. Within one message, the last `Release-As` line decides. | +| Valid value | three numeric parts, strictly greater than the latest tag | +| Newest value malformed (for example, `Release-As: 0.3`) | the `version` job's bump step fails | +| Newest value not greater than the latest tag | the `version` job's bump step fails | +| Scope | applies only to the release whose range contains it; the next release computes normally | + +The bump step runs before the tag is created, so when it fails no tag or release exists. To recover, merge a commit whose message carries a corrected `Release-As` footer: it becomes the newest one and decides. (Once the bump step has passed, a later failure, for example in `gh release create` or an artifact job, leaves the tag and draft behind; that is unchanged by this rule.) + +## Release Notes + +If the range contains breaking commits, the workflow generates a **⚠️ Breaking changes** section. A commit is breaking for the notes by exactly the `breaking` row's rule (a `!` subject or a `BREAKING CHANGE:` / `BREAKING-CHANGE:` line), so a `* feat!: …` bullet in a squash body does not add an entry. A footer's text runs from `BREAKING CHANGE:` to the next blank line, the next footer-token line (any `Word: …` line, for example `Release-As:` or `Note:`), or the end of the message; continuation lines are kept and indented under the entry. Put migration notes directly under the footer, before any other token line. The section lists the subject of each breaking commit and its `BREAKING CHANGE:` text. It is passed to `gh release create --notes-file … --generate-notes`, which prepends it to GitHub's generated notes. If there are no breaking commits, the notes are GitHub's generated notes only. You no longer have to add the breaking-change section by hand; impact or migration detail beyond the footer text still needs a human. + +## Guidance for Whoever Squash-Merges + +| Part | What goes there | +|---|---| +| Squash title | the Conventional Commit subject, which sets the type: `fix(release): … (#54)`, `feat!: … (#47)` | +| Squash body | footers: `BREAKING CHANGE: `, `Release-As: vX.Y.Z` | + +Check the pre-filled title and body before you merge: a wrong type gives a wrong version. + +## Worked Examples + +| Change | Shipped as | With this rule | +|---|---|---| +| #47 `feat!: dockerd-parity listening socket` | v0.2.22 (patch) | v0.3.0 (`breaking`, below 1.0) | +| #53 `fix!: deny percent-encoded request paths` | v0.2.26 (patch, notes written by hand) | v0.3.0 (`breaking`), with a generated breaking-changes section | +| #56 + #58 (both `fix`) | v0.2.25 | v0.2.25 (`other`) | +| #54 (this change), squash body `Release-As: v0.3.0` | — | v0.3.0 (`release-as`). It corrects the version for the breaking changes in #47 and #53. | + +Other examples: `fix!: x` on v1.4.2 gives v2.0.0. `feat: x` on v1.4.2 gives v1.5.0. `fix: x` with no tag gives v0.0.1. + +## What Is Unchanged + +- Every push to `main` still releases. +- Each release is still created as a draft prerelease and published automatically by the `publish-release` job once every artifact has uploaded. +- Pre-release tags, CHANGELOG files, and the release concurrency setting are out of scope. + +## Rejected Alternatives + +| Alternative | Why it was rejected | +|---|---| +| release-please / semantic-release | Heavier. They take over changelogs and release PRs, which this repo does not use. | +| Correct only AGENTS.md (say that every release is a patch) | Breaking changes would still ship as patches with hand-written notes, as #47 and #53 did. | +| `feat` gives a patch below 1.0 | Then a feature and a fix would look the same. Below 1.0, minor is the only signal left for "new behaviour". | + +## Implementation and Tests + +| File | Purpose | +|---|---| +| `scripts/release-version.sh` | Reads NUL-separated commit messages on stdin. Takes the latest tag (or empty) as its argument. Prints `bump=…` and `tag=vX.Y.Z`. | +| `scripts/release-notes.sh` | Reads the same input and prints the breaking-changes section, or nothing. | +| `scripts/release-version_test.sh` | One test case per row of the rule table, plus edge cases. | +| `make test-release` | Runs the tests. The tests also run in CI (`release-scripts` job). | diff --git a/docs/repo-standard.md b/docs/repo-standard.md index 3732855..20a527f 100644 --- a/docs/repo-standard.md +++ b/docs/repo-standard.md @@ -73,6 +73,7 @@ Build verification steps must be documented in the project README or a dedicated ## Versioning - SemVer (`vMAJOR.MINOR.PATCH`) +- In this repo, the bump is derived from Conventional Commit types since the last tag; see [release-versioning.md](release-versioning.md) - CHANGELOG per Keep a Changelog - Pre-release tags (e.g. `v1.0.0-rc.1`) publish with `--prerelease` diff --git a/scripts/release-notes.sh b/scripts/release-notes.sh new file mode 100755 index 0000000..73c2d6c --- /dev/null +++ b/scripts/release-notes.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Prints a breaking-changes section for the release notes, or nothing if there are none. +# Usage: git log -z --format=%B ..HEAD | release-notes.sh +# A commit is breaking by the `breaking` row of docs/release-versioning.md. +set -euo pipefail + +BREAKING_SUBJECT='^[A-Za-z]+(\([^)]*\))?!:' +FOOTER='^BREAKING[ -]CHANGE:' +TOKEN='^[A-Za-z-]+: ' + +# footers MSG — prints each BREAKING CHANGE footer as a " - " bullet. A footer runs until +# a blank line, the next footer token line, or the end of the message. +footers() { + local line text in_footer=0 started=0 + while IFS= read -r line; do + line=${line%$'\r'} + if [[ $line =~ $FOOTER ]]; then + in_footer=1 + started=0 + text=${line#BREAKING?CHANGE:} + elif [ "$in_footer" -eq 1 ] && [ -n "$line" ] && ! [[ $line =~ $TOKEN ]]; then + text=$line + else + in_footer=0 + continue + fi + text=${text#"${text%%[![:space:]]*}"} + [ -n "$text" ] || continue + if [ "$started" -eq 0 ]; then + echo " - $text" + started=1 + else + echo " $text" + fi + done <<<"$1" +} + +entries="" +while IFS= read -r -d '' msg || [ -n "$msg" ]; do + subject=${msg%%$'\n'*} + subject=${subject%$'\r'} + if [[ $subject =~ $BREAKING_SUBJECT ]] || grep -Eq "$FOOTER" <<<"$msg"; then + entries+="- $subject"$'\n' + body=$(footers "$msg") + if [ -n "$body" ]; then + entries+="$body"$'\n' + fi + fi +done + +if [ -n "$entries" ]; then + printf '## ⚠️ Breaking changes\n\n%s' "$entries" +fi diff --git a/scripts/release-version.sh b/scripts/release-version.sh new file mode 100755 index 0000000..b2703a4 --- /dev/null +++ b/scripts/release-version.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# Derives the next release version from the commit messages since the latest tag. +# Usage: git log -z --format=%B ..HEAD | release-version.sh +# Prints bump= and tag=vX.Y.Z. Rules: docs/release-versioning.md. +set -euo pipefail + +SEMVER='^v?([0-9]+)\.([0-9]+)\.([0-9]+)$' +BREAKING_SUBJECT='^[A-Za-z]+(\([^)]*\))?!:' +FEATURE_SUBJECT='^[Ff][Ee][Aa][Tt](\([^)]*\))?:' + +die() { + echo "release-version: $*" >&2 + exit 1 +} + +latest=${1:-v0.0.0} +[[ $latest =~ $SEMVER ]] || die "latest tag '$latest' is not vX.Y.Z" +major=$((10#${BASH_REMATCH[1]})) +minor=$((10#${BASH_REMATCH[2]})) +patch=$((10#${BASH_REMATCH[3]})) + +# 0 = patch, 1 = minor, 2 = major (breaking) +level=0 +release_as="" +release_as_found=0 + +while IFS= read -r -d '' msg || [ -n "$msg" ]; do + subject=${msg%%$'\n'*} + if [[ $subject =~ $BREAKING_SUBJECT ]] || grep -Eq '^BREAKING[ -]CHANGE:' <<<"$msg"; then + level=2 + elif [[ $subject =~ $FEATURE_SUBJECT ]] && [ "$level" -lt 1 ]; then + level=1 + fi + # Input is newest first, so the first message with a Release-As line decides. + if [ "$release_as_found" -eq 0 ]; then + line=$(grep -i '^release-as:' <<<"$msg" | tail -n 1 || true) + if [ -n "$line" ]; then + release_as_found=1 + release_as=$(sed -e 's/^[^:]*://' -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' <<<"$line") + fi + fi +done + +if [ "$release_as_found" -eq 1 ]; then + [[ $release_as =~ $SEMVER ]] || die "Release-As value '$release_as' is not vX.Y.Z" + ra_major=$((10#${BASH_REMATCH[1]})) + ra_minor=$((10#${BASH_REMATCH[2]})) + ra_patch=$((10#${BASH_REMATCH[3]})) + if [ "$ra_major" -gt "$major" ] || + { [ "$ra_major" -eq "$major" ] && [ "$ra_minor" -gt "$minor" ]; } || + { [ "$ra_major" -eq "$major" ] && [ "$ra_minor" -eq "$minor" ] && [ "$ra_patch" -gt "$patch" ]; }; then + echo "bump=release-as" + echo "tag=v$ra_major.$ra_minor.$ra_patch" + exit 0 + fi + die "Release-As v$ra_major.$ra_minor.$ra_patch is not greater than the latest tag v$major.$minor.$patch" +fi + +if [ "$level" -eq 2 ] && [ "$major" -ge 1 ]; then + echo "bump=major" + echo "tag=v$((major + 1)).0.0" +elif [ "$level" -ge 1 ]; then + echo "bump=minor" + echo "tag=v$major.$((minor + 1)).0" +else + echo "bump=patch" + echo "tag=v$major.$minor.$((patch + 1))" +fi diff --git a/scripts/release-version_test.sh b/scripts/release-version_test.sh new file mode 100755 index 0000000..c2884a7 --- /dev/null +++ b/scripts/release-version_test.sh @@ -0,0 +1,285 @@ +#!/usr/bin/env bash +# Tests for release-version.sh and release-notes.sh. The rules are defined in +# docs/release-versioning.md; case names match the rows of its rule table. +# Plain bash, portable to macOS /bin/bash 3.2. +set -u + +DIR=$(cd "$(dirname "$0")" && pwd) +VERSION_SCRIPT="$DIR/release-version.sh" +NOTES_SCRIPT="$DIR/release-notes.sh" + +TMP=$(mktemp -d "${TMPDIR:-/tmp}/release-version-test.XXXXXX") +trap 'rm -rf "$TMP"' EXIT + +passed=0 +failed=0 + +pass() { + passed=$((passed + 1)) + echo "PASS: $1" +} + +fail() { + failed=$((failed + 1)) + echo "FAIL: $1 ($2)" +} + +# feed MSG... — write messages in `git log -z --format=%B` framing, newest first: each +# record is the full message ending in "\n", followed by NUL (the last record too). +# No args = empty range (no bytes). +feed() { + if [ $# -gt 0 ]; then + printf '%s\n\0' "$@" + fi +} + +# unrunnable SCRIPT — prints why SCRIPT cannot be run, or nothing. Checked up front so a +# missing script never counts as the non-zero exit an error case is looking for. +unrunnable() { + if [ ! -f "$1" ]; then + echo "script not found: $1" + elif [ ! -x "$1" ]; then + echo "script not executable: $1" + fi +} + +# run SCRIPT ARG — runs SCRIPT ARG (directly, so the executable bit and shebang are +# exercised) with stdin from $TMP/in; sets rc, out, err. +# $(…) strips trailing newlines, so "exactly two lines" is checked on the content: +# out must equal "bump=…tag=…" with nothing before, between, or after. +run() { + "$1" "$2" <"$TMP/in" >"$TMP/out" 2>"$TMP/err" + rc=$? + out=$(cat "$TMP/out") + err=$(cat "$TMP/err") +} + +# check_version NAME LATEST BUMP TAG — on stdin $TMP/in, exits 0 and prints exactly +# bump=BUMP and tag=TAG. +check_version() { + local name=$1 latest=$2 bump=$3 tag=$4 why + why=$(unrunnable "$VERSION_SCRIPT") + if [ -n "$why" ]; then + fail "$name" "want bump=$bump tag=$tag, got $why" + return + fi + run "$VERSION_SCRIPT" "$latest" + local want + want=$(printf 'bump=%s\ntag=%s' "$bump" "$tag") + if [ "$rc" -ne 0 ]; then + fail "$name" "want bump=$bump tag=$tag, got exit $rc: $err" + elif [ "$out" != "$want" ]; then + fail "$name" "want $(echo "$want" | tr '\n' ' '), got $(echo "$out" | tr '\n' ' ')" + else + pass "$name" + fi +} + +# expect NAME LATEST BUMP TAG MSG... — check_version on the fed messages. +expect() { + local name=$1 latest=$2 bump=$3 tag=$4 + shift 4 + feed "$@" >"$TMP/in" + check_version "$name" "$latest" "$bump" "$tag" +} + +# expect_error NAME LATEST MSG... — script exits non-zero and explains why on stderr. +expect_error() { + local name=$1 latest=$2 why + shift 2 + why=$(unrunnable "$VERSION_SCRIPT") + if [ -n "$why" ]; then + fail "$name" "want non-zero exit with stderr message, got $why" + return + fi + feed "$@" >"$TMP/in" + run "$VERSION_SCRIPT" "$latest" + if [ "$rc" -eq 0 ]; then + fail "$name" "want non-zero exit, got exit 0: $(echo "$out" | tr '\n' ' ')" + elif [ -z "$err" ]; then + fail "$name" "want a message on stderr, got exit $rc with empty stderr" + else + pass "$name" + fi +} + +# expect_notes NAME NEEDLE... -- MSG... — notes exit 0 and contain every NEEDLE. +# A NEEDLE starting with "!" must NOT appear in the notes. +expect_notes() { + local name=$1 why + shift + local needles=() + while [ $# -gt 0 ] && [ "$1" != "--" ]; do + needles[${#needles[@]}]=$1 + shift + done + shift + why=$(unrunnable "$NOTES_SCRIPT") + if [ -n "$why" ]; then + fail "$name" "want breaking-changes section, got $why" + return + fi + feed "$@" >"$TMP/in" + run "$NOTES_SCRIPT" "" + if [ "$rc" -ne 0 ]; then + fail "$name" "want exit 0, got exit $rc: $err" + return + fi + local n + for n in "${needles[@]}"; do + case "$n" in + !*) + case "$out" in + *"${n#!}"*) + fail "$name" "want output without '${n#!}', got '$out'" + return + ;; + esac + ;; + *) + case "$out" in + *"$n"*) ;; + *) + fail "$name" "want output containing '$n', got '$out'" + return + ;; + esac + ;; + esac + done + pass "$name" +} + +# expect_no_notes NAME MSG... — notes exit 0 and print nothing. +expect_no_notes() { + local name=$1 why + shift + why=$(unrunnable "$NOTES_SCRIPT") + if [ -n "$why" ]; then + fail "$name" "want empty output, got $why" + return + fi + feed "$@" >"$TMP/in" + run "$NOTES_SCRIPT" "" + if [ "$rc" -ne 0 ]; then + fail "$name" "want exit 0, got exit $rc: $err" + elif [ -n "$out" ]; then + fail "$name" "want empty output, got '$out'" + else + pass "$name" + fi +} + +# gitc ARGS... — git with a fixed identity and no signing or hooks, so it runs in CI. +gitc() { + git -c user.name=release-test -c user.email=release-test@example.com \ + -c commit.gpgsign=false -c core.hooksPath=/dev/null "$@" +} + +NL=$'\n' + +# ─── other ─────────────────────────────────────────── +expect other v0.2.25 patch v0.2.26 "fix: x" +expect other/non-conventional v0.2.25 patch v0.2.26 "Update README" +expect other/docs-chore-test v0.2.25 patch v0.2.26 "docs: a" "chore: b" "test: c" "spec: d" "ci: e" +expect other/empty-range v0.2.25 patch v0.2.26 +expect other/case-insensitive v0.2.25 patch v0.2.26 "Fix: a" + +# ─── feature ───────────────────────────────────────── +expect feature v0.2.25 minor v0.3.0 "feat: x" +expect feature/scoped v0.2.25 minor v0.3.0 "feat(ts): x" +expect feature/body-bullet-under-fix v0.2.25 patch v0.2.26 "fix: x (#1)${NL}${NL}* feat: x${NL}* fix: y" +expect feature/mid-line v0.2.25 patch v0.2.26 "fix: handle feat: prefix" +expect feature/body-line-not-subject v0.2.25 patch v0.2.26 "fix: x${NL}${NL}feat: y" +expect feature/highest-wins v0.2.25 minor v0.3.0 "fix: a" "feat: b" "docs: c" +expect feature/above-1.0 v1.4.2 minor v1.5.0 "feat: x" +expect feature/case-insensitive v0.2.25 minor v0.3.0 "FEAT: a" + +# ─── breaking ──────────────────────────────────────── +expect breaking v0.2.25 minor v0.3.0 "fix!: x" +expect breaking/scoped-feat v0.2.25 minor v0.3.0 "feat(rs)!: x" +expect breaking/footer-under-fix v0.2.25 minor v0.3.0 "fix: x${NL}${NL}BREAKING CHANGE: y is gone" +expect breaking/hyphen-footer v0.2.25 minor v0.3.0 "fix: x${NL}${NL}BREAKING-CHANGE: y is gone" +expect breaking/footer-mid-line v0.2.25 patch v0.2.26 "fix: x${NL}${NL}This is not a BREAKING CHANGE: y" +expect breaking/body-bang-not-subject v0.2.25 patch v0.2.26 "fix: x${NL}${NL}fix!: y" +expect breaking/above-1.0 v1.4.2 major v2.0.0 "fix!: x" +expect breaking/feat-bang-major v1.4.2 major v2.0.0 "feat!: x" +expect breaking/highest-wins v1.4.2 major v2.0.0 "fix: a" "fix!: b" "feat: c" +expect breaking/case-insensitive v1.0.0 major v2.0.0 "Feat!: a" +# The footer token is case-sensitive (Conventional Commits): a lowercase one is plain text. +expect breaking/footer-lowercase v0.2.25 patch v0.2.26 "fix: x${NL}${NL}breaking change: y" + +# ─── no-tag ────────────────────────────────────────── +expect no-tag "" patch v0.0.1 "fix: x" +expect no-tag/feature "" minor v0.1.0 "feat: x" + +# ─── release-as ────────────────────────────────────── +expect release-as v0.2.26 release-as v0.3.0 "fix: x${NL}${NL}Release-As: v0.3.0" +expect release-as/lowercase-no-v v0.2.26 release-as v0.3.0 "fix: x${NL}${NL}release-as: 0.3.0" +expect release-as/whitespace v0.2.26 release-as v0.3.0 "fix: x${NL}${NL}Release-As: v0.3.0 " +expect release-as/overrides-breaking v0.2.26 release-as v0.3.0 "fix!: x${NL}${NL}Release-As: v0.3.0" +expect release-as/numeric-compare v0.9.3 release-as v0.10.0 "fix: x${NL}${NL}Release-As: v0.10.0" +expect_error release-as/not-greater v0.2.26 "fix: x${NL}${NL}Release-As: v0.2.26" +expect_error release-as/below-latest v0.2.26 "fix: x${NL}${NL}Release-As: v0.2.9" +expect_error release-as/malformed v0.2.26 "fix: x${NL}${NL}Release-As: 0.3" +expect_error release-as/trailing-text-rc v0.2.26 "fix: x${NL}${NL}Release-As: v0.3.0-rc1" +expect_error release-as/trailing-text-fourth-part v0.2.26 "fix: x${NL}${NL}Release-As: v0.3.0.1" +expect release-as/newest-wins v0.2.26 release-as v0.4.0 \ + "fix: b${NL}${NL}Release-As: v0.4.0" \ + "fix: a${NL}${NL}Release-As: v0.3.0" +expect release-as/newest-wins-in-message v0.2.26 release-as v0.4.0 \ + "fix: a${NL}${NL}Release-As: v0.3.0${NL}Release-As: v0.4.0" +expect release-as/recovery v0.2.26 release-as v0.3.0 \ + "fix: b${NL}${NL}Release-As: v0.3.0" \ + "fix: a${NL}${NL}Release-As: 0.3" +expect_error release-as/newest-malformed v0.2.26 \ + "fix: b${NL}${NL}Release-As: 0.3" \ + "fix: a${NL}${NL}Release-As: v0.3.0" + +# ─── input framing ─────────────────────────────────── +# The oldest record carries the feat and has no final NUL; dropping it would give a patch. +printf '%s\n\0%s\n' "fix: b" "feat: a" >"$TMP/in" +check_version framing/no-final-nul v0.2.26 minor v0.3.0 + +# End to end: real `git log -z --format=%B` output. The feat is the oldest commit, so a +# script that reads only the first line of the whole stream gives a patch. +repo="$TMP/repo" +if gitc init -q "$repo" && + gitc -C "$repo" commit -q --allow-empty -m "feat: a" && + gitc -C "$repo" commit -q --allow-empty -m "fix: b" -m "* chore: x" && + gitc -C "$repo" commit -q --allow-empty -m "docs: c" && + git -C "$repo" log -z --format=%B >"$TMP/in"; then + check_version framing/git-log-end-to-end v0.2.26 minor v0.3.0 +else + fail framing/git-log-end-to-end "want a throwaway git repo, got git failure" +fi + +# ─── notes ─────────────────────────────────────────── +expect_notes notes/breaking \ + "Breaking changes" "fix: x" "y is gone" -- \ + "docs: a" "fix: x${NL}${NL}BREAKING CHANGE: y is gone" "feat: b" +expect_notes notes/breaking-subject \ + "Breaking changes" "feat(rs)!: drop z" -- \ + "feat(rs)!: drop z" +expect_notes notes/breaking-subject-case-insensitive \ + "Breaking changes" "Feat!: a" -- \ + "Feat!: a" +# The footer value runs to a blank line, the next footer token, or the end of the message. +expect_notes notes/multi-line-footer \ + "- fix: x${NL} - line one${NL} continues here" "!Release-As" -- \ + "fix: x${NL}${NL}BREAKING CHANGE: line one${NL}continues here${NL}${NL}Release-As: v0.3.0" +expect_notes notes/footer-ends-at-next-token \ + " - line one" "!Refs" -- \ + "fix: x${NL}${NL}BREAKING CHANGE: line one${NL}Refs: #1" +expect_notes notes/bare-footer \ + "Breaking changes" "- fix: x" "!${NL} -" -- \ + "fix: x${NL}${NL}BREAKING CHANGE:" +expect_notes notes/crlf \ + "Breaking changes" "fix!: x" " - y is gone" "!"$'\r' -- \ + "fix!: x"$'\r'"${NL}"$'\r'"${NL}BREAKING CHANGE: y is gone"$'\r' +expect_no_notes notes/none "fix: a" "feat: b${NL}${NL}* feat!: bullet only" +expect_no_notes notes/empty-range + +echo +echo "$passed passed, $failed failed" +[ "$failed" -eq 0 ]