-
-
Notifications
You must be signed in to change notification settings - Fork 0
fix(release): stamp the version into the changelog #69
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
246fd4b
fix(release): stamp the version into the changelog
michen00 fe38a84
Merge branch 'main' into fix/stamp-changelog-version
michen00 abeb632
fix(release): validate the tag before using it
michen00 ba08193
test: pin where the version helper is resolved
michen00 86b964b
test: make the helper-lookup decoy actually bite
michen00 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
|
|
||
| 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 | ||
|
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" | ||
|
qodo-code-review[bot] marked this conversation as resolved.
|
||
|
|
||
| echo "Stamped $tag into $changelog_file" | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,212 @@ | ||
| #!/usr/bin/env bash | ||
|
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 ] | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.