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
12 changes: 10 additions & 2 deletions .github/workflows/release-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
93 changes: 93 additions & 0 deletions scripts/release/stamp-changelog.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
#!/bin/sh

# Regenerate the changelog with the release under preparation stamped as its own
# section.
#
# Usage: stamp-changelog.sh <vX.Y.Z> [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 <vX.Y.Z> [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
Comment thread
qodo-code-review[bot] marked this conversation as resolved.

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
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
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"
Comment thread
qodo-code-review[bot] marked this conversation as resolved.

echo "Stamped $tag into $changelog_file"
212 changes: 212 additions & 0 deletions tests/test-stamp-changelog.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
#!/usr/bin/env bash
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
# 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" <<STUB
#!/bin/sh
# Emits \$STUB_BODY to the path following --output, ignoring everything else.
while [ "\$#" -gt 0 ]; do
if [ "\$1" = "--output" ]; then
printf '%s' "\$STUB_BODY" >"\$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 ]
1 change: 1 addition & 0 deletions tests/test-unit.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 "=========================================="
Expand Down