diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index e9dd055..4f7d209 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -74,8 +74,16 @@ jobs: python -m pip install --upgrade pip python -m pip install --upgrade pre-commit - - name: Update unreleased changelog - run: scripts/update-unreleased.sh + # Stamps the pending version as its own section rather than refreshing + # Unreleased. Refreshing is what the weekly changelog-autoupdate workflow + # does and is right there, but it is wrong during a release: the moment + # the tag exists, `git cliff --unreleased` stops reporting the commits it + # covers, and any entry that only ever lived under Unreleased is dropped + # by the next run with nowhere to go. + - name: Stamp the release into the changelog + env: + RELEASE_TAG: ${{ steps.version.outputs.tag }} + run: scripts/release/stamp-changelog.sh "$RELEASE_TAG" - name: Run prettier on CHANGELOG.md run: npx prettier --write CHANGELOG.md diff --git a/AGENTS.md b/AGENTS.md index 6f94565..18a10d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,7 +32,8 @@ git cliff --tag v1.0.0 --output CHANGELOG.md - **Release Publish** (`.github/workflows/release-publish.yml`): runs on `v*.*.*` tag push (or manual dispatch) to build, sign (Sigstore + GPG), and upload assets. - Both release workflows declare the protected `release` environment, but only **Release Tag** actually prompts: when it dispatches Release Publish the deployment is bot-created and the reviewer rule is skipped. One approval per release, on Release Tag. A merged `release/*` PR therefore does not release on its own. - Land PRs with squash merge only; rebase merge replays commits unsigned. See the Git Workflow section of `CLAUDE.md`. -- Scripts: `scripts/release/build-artifacts.sh`, `scripts/release/sign-artifacts.sh`, `scripts/release/parse-version.sh`, `scripts/update-unreleased.sh`. +- Scripts: `scripts/release/build-artifacts.sh`, `scripts/release/sign-artifacts.sh`, `scripts/release/parse-version.sh`, `scripts/release/stamp-changelog.sh`, `scripts/update-unreleased.sh`. +- Two changelog scripts, and they are not interchangeable. `update-unreleased.sh` refreshes the Unreleased section and belongs to the weekly autoupdate. `release/stamp-changelog.sh` writes the pending version as its own `## [X.Y.Z]` section and is what a release must use: once the tag exists, `git cliff --unreleased` no longer reports the commits it covers, so anything that only ever lived under Unreleased is dropped by the next refresh. That is how v0.1.0 shipped without a changelog section and nearly took 134 lines of history with it. - All three workflows validate versions through `scripts/release/parse-version.sh`; it is covered by `tests/test-parse-version.sh`. Do not replace it with a `case` glob such as `v[0-9]*.[0-9]*.[0-9]*`, which also matches `v1.0.0; rm -rf /`. - Full steps and verification commands: see [CONTRIBUTING](CONTRIBUTING.md#creating-a-release). diff --git a/CLAUDE.md b/CLAUDE.md index 2ea22ee..5da78b2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ See @README.md for project overview and features. - `.pre-commit-hooks.yaml` - Hook definitions for pre-commit framework - `cliff.toml` - Configuration for git-cliff changelog generation - `tests/` - Hook tests and test utilities (including `tests/test-unit.sh`) -- `scripts/release/` - Release artifact build and signing, plus `parse-version.sh` (shared strict `vX.Y.Z` validation used by all release workflows); `scripts/update-unreleased.sh` for changelog. Release process and verification: see CONTRIBUTING. +- `scripts/release/` - Release artifact build and signing, plus `parse-version.sh` (shared strict `vX.Y.Z` validation used by all release workflows); `stamp-changelog.sh` writes the pending version as its own changelog section during a release, while `scripts/update-unreleased.sh` only refreshes Unreleased for the weekly autoupdate — a release must use the former, or entries that never got a versioned section are dropped by the next refresh. Release process and verification: see CONTRIBUTING. ## Code Style diff --git a/scripts/release/stamp-changelog.sh b/scripts/release/stamp-changelog.sh new file mode 100755 index 0000000..24aa4eb --- /dev/null +++ b/scripts/release/stamp-changelog.sh @@ -0,0 +1,93 @@ +#!/bin/sh + +# Regenerate the changelog with the release under preparation stamped as its own +# section. +# +# Usage: stamp-changelog.sh [changelog_file] +# +# This exists because refreshing the Unreleased section is the wrong operation +# during a release. `git cliff --unreleased` reports commits since the newest +# tag, so once that tag is created every commit it covers stops being unreleased +# — and if no section was ever written for it, the next refresh drops those +# entries with nowhere to put them. That is what happened to v0.1.0: it shipped, +# no `## [0.1.0]` section was ever added, and the following release PR deleted +# 134 lines of history that had only ever lived under Unreleased. +# +# Passing --tag makes git-cliff treat the pending version as released, so the +# whole file is rebuilt with a section per tag and the pending one at the top. +# Regenerating everything rather than splicing is safe here because the released +# sections are derived from tags and commits, both immutable; the only observed +# differences against a hand-formatted file are whitespace that prettier +# normalizes on the very next step of the workflow. + +set -eu + +if [ "$#" -lt 1 ] || [ "$#" -gt 2 ]; then + echo "Usage: $0 [changelog_file]" >&2 + exit 1 +fi + +# The release workflows validate the tag before they get here, but this script +# is also run by hand, and everything below treats the version as trusted: it +# reaches git-cliff as a tag and the guard below as a grep pattern. Normalize it +# through the same strict parser the workflows use so a standalone run cannot +# smuggle anything but three dot-separated runs of digits past this point. +script_dir="$(dirname "$0")" +if ! tag="$("$script_dir/parse-version.sh" "$1")"; then + exit 1 +fi + +changelog_file="${2:-CHANGELOG.md}" + +if [ ! -f "$changelog_file" ]; then + echo "Error: changelog file not found: $changelog_file" >&2 + exit 1 +fi + +if ! command -v git-cliff >/dev/null 2>&1; then + echo "Error: git-cliff is required to update $changelog_file" >&2 + exit 1 +fi + +tmp_changelog="$(mktemp)" +trap 'rm -f "$tmp_changelog"' EXIT INT TERM HUP + +# Build into scratch space first. `--output` truncates before it writes, so +# pointing it at the changelog would leave a half-written file behind on any +# git-cliff failure — which matters for local runs, where the tree is not +# disposable. +git cliff --tag "$tag" --output "$tmp_changelog" + +if [ ! -s "$tmp_changelog" ]; then + echo "Error: git-cliff produced an empty changelog for $tag" >&2 + exit 1 +fi + +# A regeneration that lost the section for the version being released, or one +# that came back shorter than what it replaces, means git-cliff disagreed with +# the tag history rather than that there was nothing to say. Refuse it: the +# whole point of this script is that a silent shrink already cost this +# repository its v0.1.0 entries once. +version="${tag#v}" +# Dots are wildcards to grep, so a bare ${version} would also accept a +# `## [1x2x3]` header. Validation above leaves dots as the only metacharacter +# that can still reach this pattern. +version_pattern="$(printf '%s' "$version" | sed 's/\./\\./g')" +if ! grep -q "^## \[${version_pattern}\]" "$tmp_changelog"; then + echo "Error: no '## [${version}]' section in the regenerated changelog." >&2 + exit 1 +fi + +old_sections="$(grep -c '^## \[' "$changelog_file" || true)" +new_sections="$(grep -c '^## \[' "$tmp_changelog" || true)" +if [ "$new_sections" -lt "$old_sections" ]; then + echo "Error: regeneration dropped sections ($old_sections -> $new_sections)." >&2 + exit 1 +fi + +# git-cliff succeeded and the result passed inspection, so publish it. Writing +# through the existing file rather than renaming over it keeps the changelog's +# permissions and inode. +cat "$tmp_changelog" >"$changelog_file" + +echo "Stamped $tag into $changelog_file" diff --git a/tests/test-stamp-changelog.sh b/tests/test-stamp-changelog.sh new file mode 100755 index 0000000..7d174f4 --- /dev/null +++ b/tests/test-stamp-changelog.sh @@ -0,0 +1,212 @@ +#!/usr/bin/env bash +# Test script for scripts/release/stamp-changelog.sh + +set -uo pipefail +TEST_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Load shared configurations +# shellcheck disable=SC1091 # Dynamic path via $TEST_SCRIPT_DIR +. "$TEST_SCRIPT_DIR/colors.sh" + +STAMP="$TEST_SCRIPT_DIR/../scripts/release/stamp-changelog.sh" + +PASSED=0 +FAILED=0 + +pass() { + echo -e "${GREEN}✓${NC} $1" + ((PASSED++)) +} + +fail() { + echo -e "${RED}✗${NC} $1" + shift + for msg in "$@"; do + echo -e " ${YELLOW}$msg${NC}" + done + ((FAILED++)) +} + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +# A changelog with one released section, standing in for the real file. The +# guards care only about `## [` section headers, so nothing else has to be real. +write_changelog() { + cat >"$1" <<'MD' +# Changelog + +## [0.1.0](https://example.com/compare/v0.0.9..v0.1.0) - 2026-08-05 + +- something shipped +MD +} + +# git-cliff is not installed on every machine that runs this suite, and driving +# the real one would need a fixture repository with tags. The guards under test +# are about what the script does with git-cliff's *output*, so a stub on PATH +# gives exact control over that output and keeps the test hermetic. +make_stub() { + stub_dir="$work/bin" + mkdir -p "$stub_dir" + cat >"$stub_dir/git-cliff" <"\$2" + exit 0 + fi + shift +done +exit 1 +STUB + chmod +x "$stub_dir/git-cliff" + PATH="$stub_dir:$PATH" + export PATH +} + +# -- argument handling, before any dependency is consulted -- + +if ! "$STAMP" >/dev/null 2>&1; then + pass "no arguments is rejected" +else + fail "no arguments is rejected" "expected a non-zero exit" +fi + +if ! "$STAMP" v1.0.0 a b >/dev/null 2>&1; then + pass "too many arguments is rejected" +else + fail "too many arguments is rejected" "expected a non-zero exit" +fi + +if ! "$STAMP" v1.0.0 "$work/missing.md" >/dev/null 2>&1; then + pass "a missing changelog is rejected" +else + fail "a missing changelog is rejected" "expected a non-zero exit" +fi + +# The version reaches git-cliff as a tag and the section guard as a grep +# pattern, so a malformed one has to be refused before either sees it. Matching +# the message keeps this honest on machines where git-cliff is absent and the +# script would have exited non-zero anyway. +malformed="$work/malformed.md" +write_changelog "$malformed" +if ! err="$("$STAMP" 1.2 "$malformed" 2>&1)" && + printf '%s' "$err" | grep -q 'invalid version'; then + pass "a malformed version is rejected" +else + fail "a malformed version is rejected" "expected a non-zero exit and an invalid-version message" +fi + +make_stub + +# -- what the script does with git-cliff's output -- + +changelog="$work/CHANGELOG.md" + +write_changelog "$changelog" +STUB_BODY="# Changelog + +## [0.2.0] - 2026-08-07 + +- the new thing + +## [0.1.0] - 2026-08-05 + +- something shipped +" +export STUB_BODY +if "$STAMP" v0.2.0 "$changelog" >/dev/null 2>&1 && + grep -q '^## \[0.2.0\]' "$changelog" && + grep -q '^## \[0.1.0\]' "$changelog"; then + pass "a well-formed regeneration is published" +else + fail "a well-formed regeneration is published" "expected both sections present" +fi + +# The v0.1.0 failure in this repository: the pending version got no section of +# its own, so its entries had nowhere to live and were dropped. +write_changelog "$changelog" +STUB_BODY="# Changelog + +## [0.1.0] - 2026-08-05 + +- something shipped +" +export STUB_BODY +before="$(cat "$changelog")" +if ! "$STAMP" v0.2.0 "$changelog" >/dev/null 2>&1 && [ "$(cat "$changelog")" = "$before" ]; then + pass "a regeneration missing the pending version is refused" +else + fail "a regeneration missing the pending version is refused" \ + "expected a non-zero exit and an unchanged file" +fi + +# The shape of the clobber itself: fewer sections out than in. +write_changelog "$changelog" +printf '\n## [0.0.9] - 2026-01-01\n\n- older\n' >>"$changelog" +STUB_BODY="# Changelog + +## [0.2.0] - 2026-08-07 + +- the new thing +" +export STUB_BODY +before="$(cat "$changelog")" +if ! "$STAMP" v0.2.0 "$changelog" >/dev/null 2>&1 && [ "$(cat "$changelog")" = "$before" ]; then + pass "a regeneration that drops sections is refused" +else + fail "a regeneration that drops sections is refused" \ + "expected a non-zero exit and an unchanged file" +fi + +write_changelog "$changelog" +STUB_BODY="" +export STUB_BODY +before="$(cat "$changelog")" +if ! "$STAMP" v0.2.0 "$changelog" >/dev/null 2>&1 && [ "$(cat "$changelog")" = "$before" ]; then + pass "an empty regeneration is refused" +else + fail "an empty regeneration is refused" "expected a non-zero exit and an unchanged file" +fi + +# Locating parse-version.sh became load-bearing when the tag started being +# normalized through it, so pin where the lookup happens: next to the script, +# never in the caller's working directory and never through PATH. Running from +# the decoy's own directory with that directory on PATH covers both wrong +# answers at once: a bare `parse-version.sh` finds it, and a `./` one does too. +# The decoy answers v9.9.9, which no stub body contains, so whichever way it +# won the section guard would reject the regeneration. +write_changelog "$changelog" +STUB_BODY="# Changelog + +## [0.2.0] - 2026-08-07 + +- the new thing + +## [0.1.0] - 2026-08-05 + +- something shipped +" +export STUB_BODY +decoy_dir="$work/decoy" +mkdir -p "$decoy_dir" +cat >"$decoy_dir/parse-version.sh" <<'DECOY' +#!/bin/sh +echo v9.9.9 +DECOY +chmod +x "$decoy_dir/parse-version.sh" +if ( + cd "$decoy_dir" && + PATH="$decoy_dir:$PATH" "$STAMP" v0.2.0 "$changelog" >/dev/null 2>&1 +) && grep -q '^## \[0.2.0\]' "$changelog"; then + pass "the helper is resolved next to the script, not via CWD or PATH" +else + fail "the helper is resolved next to the script, not via CWD or PATH" \ + "expected the real parse-version.sh to win over a decoy in the CWD and on PATH" +fi + +echo +echo -e "Results: ${GREEN}$PASSED passed${NC}, ${RED}$FAILED failed${NC}" +[ "$FAILED" -eq 0 ] diff --git a/tests/test-unit.sh b/tests/test-unit.sh index 3825fc0..7f916a2 100755 --- a/tests/test-unit.sh +++ b/tests/test-unit.sh @@ -42,6 +42,7 @@ run_test "$TEST_SCRIPT_DIR/test-enhance-scope.sh" "enhance-scope tests" run_test "$TEST_SCRIPT_DIR/test-conventional-merge-commit.sh" "conventional-merge-commit tests" run_test "$TEST_SCRIPT_DIR/test-parse-version.sh" "release/parse-version.sh tests" run_test "$TEST_SCRIPT_DIR/test-bump-pins.sh" "release/bump-pins.sh tests" +run_test "$TEST_SCRIPT_DIR/test-stamp-changelog.sh" "release/stamp-changelog.sh tests" # Summary echo "=========================================="