Skip to content
Open
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
107 changes: 107 additions & 0 deletions .github/scripts/release-metadata.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
#!/usr/bin/env bash
# Checks that the workspace version in Cargo.toml has release notes in
# CHANGELOG.md. One script so that pull requests, the release workflow and
# `mise run release` apply the same rule.
#
# Usage (from the repository root):
# release-metadata.sh version print the workspace version
# release-metadata.sh check [vX.Y.Z] fail unless CHANGELOG.md has a
# '## [X.Y.Z]' section for the
# workspace version (and, with a tag,
# unless the tag matches it)
# release-metadata.sh check-pr BASE_REF run `check` only if the workspace
# version differs from BASE_REF's
#
# Exit status: 0 ok, 1 check failed, 2 usage or input error.
set -euo pipefail

error() {
if [ -n "${GITHUB_ACTIONS:-}" ]; then
echo "::error::$*" >&2
else
echo "error: $*" >&2
fi
}

# Print the `version` under `[workspace.package]` of the Cargo.toml on stdin.
workspace_version() {
awk '
/^[[:space:]]*\[/ { in_pkg = ($0 ~ /^[[:space:]]*\[workspace\.package\][[:space:]]*(#.*)?$/); next }
in_pkg && /^[[:space:]]*version[[:space:]]*=/ {
sub(/^[[:space:]]*version[[:space:]]*=[[:space:]]*["\047]/, "")
sub(/["\047].*$/, "")
print
exit
}
'
}

# True if CHANGELOG.md has a line starting with '## [VERSION]'.
has_changelog_section() {
awk -v want="## [$1]" 'index($0, want) == 1 { found = 1; exit } END { exit !found }' CHANGELOG.md
}

cmd_version() {
local version
version="$(workspace_version <Cargo.toml)"
if [ -z "$version" ]; then
error "Cargo.toml has no version under [workspace.package]"
exit 2
fi
echo "$version"
}

cmd_check() {
local tag="${1:-}" version
version="$(cmd_version)"

if [ -n "$tag" ]; then
if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then
error "release tag must look like v1.2.3 (got '$tag')"
exit 2
fi
if [ "${tag#v}" != "$version" ]; then
error "Cargo.toml workspace version ($version) does not match release tag $tag. Bump the version before tagging."
exit 1
fi
fi

if [ ! -f CHANGELOG.md ]; then
error "CHANGELOG.md not found in $(pwd)"
exit 2
fi
if ! has_changelog_section "$version"; then
error "CHANGELOG.md has no '## [$version]' section for the workspace version $version. In CHANGELOG.md, rename '## [Unreleased]' to '## [$version] - <release date>' (and add a new empty '## [Unreleased]' above it if you like)."
exit 1
fi

echo "OK: Cargo.toml version $version has a '## [$version]' section in CHANGELOG.md${tag:+ and matches $tag}."
}

cmd_check_pr() {
local base_ref="${1:?usage: release-metadata.sh check-pr BASE_REF}" base_toml base_version head_version
if ! base_toml="$(git show "$base_ref:Cargo.toml" 2>/dev/null)"; then
error "cannot read Cargo.toml at $base_ref (is it fetched?)"
exit 2
fi
base_version="$(workspace_version <<<"$base_toml")"
head_version="$(cmd_version)"

if [ "$base_version" = "$head_version" ]; then
echo "Workspace version unchanged ($head_version); nothing to check."
return 0
fi

echo "Workspace version changes $base_version -> $head_version."
cmd_check
}

case "${1:-}" in
version) cmd_version ;;
check) shift; cmd_check "$@" ;;
check-pr) shift; cmd_check_pr "$@" ;;
*)
echo "usage: release-metadata.sh {version | check [vX.Y.Z] | check-pr BASE_REF}" >&2
exit 2
;;
esac
164 changes: 164 additions & 0 deletions .github/scripts/release-metadata.test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
#!/usr/bin/env bash
# Tests for release-metadata.sh. Run with: mise run test:release-metadata
set -uo pipefail

SCRIPT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/release-metadata.sh"
failures=0
tests=0

# Keep the developer's own git config (hooks, templates, signing) out of the
# throwaway repositories.
export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1

dirs=()
trap 'rm -rf "${dirs[@]}"' EXIT

# Each test runs in its own git repository so `check-pr` can compare refs.
# Sets $d rather than printing it, so the trap above sees every directory.
new_repo() {
d="$(mktemp -d)"
dirs+=("$d")
git -C "$d" init --quiet --initial-branch=main
git -C "$d" config user.email test@example.com
git -C "$d" config user.name test
}

write_cargo() { # dir version
cat >"$1/Cargo.toml" <<EOF
[workspace]
members = ["packages/*"]

[workspace.package]
version = "$2"
edition = "2021"

[workspace.dependencies]
sqltk = { version = "0.11.0" }
EOF
}

write_changelog() { # dir heading...
local dir="$1"
shift
{
echo "# Changelog"
echo
for heading in "$@"; do
echo "$heading"
echo
echo "- something"
echo
done
echo "[3.0.1]: https://example.com/compare/v3.0.0...v3.0.1"
} >"$dir/CHANGELOG.md"
}

commit_all() { # dir message
git -C "$1" add -A
git -C "$1" commit --quiet -m "$2"
}

# expect <name> <expected-exit> <expected-output-substring> <dir> <args...>
expect() {
local name="$1" want_status="$2" want_text="$3" dir="$4"
shift 4
tests=$((tests + 1))
local out status
out="$(cd "$dir" && env -u GITHUB_ACTIONS bash "$SCRIPT" "$@" 2>&1)"
status=$?
if [ "$status" -ne "$want_status" ] || [[ "$out" != *"$want_text"* ]]; then
failures=$((failures + 1))
echo "FAIL: $name"
echo " wanted exit $want_status and output containing: $want_text"
echo " got exit $status and output:"
printf ' %s\n' "$out"
else
echo "ok: $name"
fi
}

# --- version -------------------------------------------------------------

new_repo
write_cargo "$d" "3.1.0"
expect "version prints the workspace version, not a dependency version" 0 "3.1.0" "$d" version

new_repo
cat >"$d/Cargo.toml" <<'EOF2'
[workspace.package] # shared by every package
version = '3.2.0' # bumped by release-plz
EOF2
expect "version handles single quotes and trailing comments" 0 "3.2.0" "$d" version

# --- check ---------------------------------------------------------------

new_repo
write_cargo "$d" "3.1.0"
write_changelog "$d" "## [Unreleased]" "## [3.0.1] - 2026-08-05"
expect "check fails when the version has no changelog section" 1 "rename '## [Unreleased]' to '## [3.1.0] - <release date>'" "$d" check

new_repo
write_cargo "$d" "3.1.0"
write_changelog "$d" "## [3.1.0] - 2026-11-09" "## [3.0.1] - 2026-08-05"
expect "check passes when the changelog has the section" 0 "3.1.0" "$d" check

new_repo
write_cargo "$d" "3.0.10"
write_changelog "$d" "## [Unreleased]" "## [3.0.1] - 2026-08-05"
expect "check does not treat 3.0.1 as a section for 3.0.10" 1 "## [3.0.10]" "$d" check

new_repo
write_cargo "$d" "3.0.1"
write_changelog "$d" "## [Unreleased]" "Not a heading: ## [3.0.1]"
expect "check only accepts the section as a heading at the start of a line" 1 "## [3.0.1]" "$d" check

new_repo
write_cargo "$d" "3.1.0"
expect "check reports a missing CHANGELOG.md as an input error" 2 "CHANGELOG.md not found" "$d" check

new_repo
write_cargo "$d" "3.1.0"
write_changelog "$d" "## [3.1.0] - 2026-11-09"
expect "check with a matching tag passes" 0 "v3.1.0" "$d" check v3.1.0
expect "check with a mismatched tag fails" 1 "does not match release tag v3.2.0" "$d" check v3.2.0
expect "check rejects a malformed tag" 2 "must look like v1.2.3" "$d" check 3.1

# --- check-pr --------------------------------------------------------------

new_repo
write_cargo "$d" "3.0.1"
write_changelog "$d" "## [Unreleased]" "## [3.0.1] - 2026-08-05"
commit_all "$d" base
git -C "$d" branch base
echo "// change" >"$d/lib.rs"
commit_all "$d" "feat: unrelated change"
expect "check-pr skips a PR that does not change the version" 0 "unchanged" "$d" check-pr base

write_cargo "$d" "3.1.0"
commit_all "$d" "chore: release v3.1.0"
expect "check-pr fails a version bump without a changelog section" 1 "rename '## [Unreleased]'" "$d" check-pr base

write_changelog "$d" "## [3.1.0] - 2026-11-09" "## [3.0.1] - 2026-08-05"
commit_all "$d" "docs: changelog for 3.1.0"
expect "check-pr passes once the section exists" 0 "3.0.1 -> 3.1.0" "$d" check-pr base

expect "check-pr reports an unknown base ref" 2 "cannot read Cargo.toml at no-such-ref" "$d" check-pr no-such-ref

# --- GitHub annotations ----------------------------------------------------

new_repo
write_cargo "$d" "3.1.0"
write_changelog "$d" "## [Unreleased]"
tests=$((tests + 1))
out="$(cd "$d" && GITHUB_ACTIONS=true bash "$SCRIPT" check 2>&1)"
if [[ "$out" == "::error::"* ]]; then
echo "ok: errors are GitHub annotations inside Actions"
else
failures=$((failures + 1))
echo "FAIL: errors are GitHub annotations inside Actions"
printf ' %s\n' "$out"
fi

echo
echo "$((tests - failures))/$tests passed"
[ "$failures" -eq 0 ]
35 changes: 35 additions & 0 deletions .github/workflows/release-metadata.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: Release metadata
on:
# No `branches` or `paths` filter: a version bump can come from any PR (a
# release-plz release PR, or a hand-made prepare-release PR), and a check that
# sometimes doesn't report can't safely be made required later. release-plz
# opens its PR with a GitHub App token, so this workflow runs on it.
pull_request:

permissions:
contents: read

jobs:
check:
name: Release metadata
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4

- name: Fetch the base branch
env:
BASE_REF: ${{ github.base_ref }}
run: git fetch --no-tags --depth=1 origin "+refs/heads/$BASE_REF:refs/remotes/origin/$BASE_REF"

- name: Test the release metadata script
run: .github/scripts/release-metadata.test.sh

# Passes without checking anything when the PR leaves the workspace
# version alone. When it changes the version, CHANGELOG.md must already
# have a '## [X.Y.Z]' section for it, so a release PR can't merge with
# its notes still under '## [Unreleased]'.
- name: Check version bump has release notes
env:
BASE_REF: ${{ github.base_ref }}
run: .github/scripts/release-metadata.sh check-pr "origin/$BASE_REF"
20 changes: 3 additions & 17 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,23 +27,9 @@ jobs:
# job is a no-op so it can still gate the build matrix below.
- name: Check version + changelog match the release tag
if: github.event_name == 'release'
run: |
tag='${{ github.event.release.tag_name }}'
version="${tag#v}"

cargo_version="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)"
if [ "$cargo_version" != "$version" ]; then
echo "::error::Cargo.toml workspace version ($cargo_version) does not match release tag $tag. Bump the version in a prepare-release PR before tagging."
exit 1
fi

# Fixed-string match so dots in the version aren't treated as regex wildcards.
if ! grep -qF "## [$version]" CHANGELOG.md; then
echo "::error::CHANGELOG.md has no '## [$version]' section. Add release notes in a prepare-release PR before tagging."
exit 1
fi

echo "OK: tag $tag matches Cargo.toml version and CHANGELOG has a [$version] section."
env:
TAG: ${{ github.event.release.tag_name }}
run: .github/scripts/release-metadata.sh check "$TAG"

build:
name: Build binaries + Docker images
Expand Down
5 changes: 5 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -813,6 +813,11 @@ Open a PR against `main` that:
Both are required: the release tooling (and the release workflow) will refuse to
publish a tag whose version isn't reflected in `Cargo.toml` and `CHANGELOG.md`.

The `Release metadata` check enforces this on the PR itself.
Any PR that changes the workspace version fails until `CHANGELOG.md` has a `## [X.Y.Z]` heading for the new version, usually by renaming `## [Unreleased]` to `## [X.Y.Z] - <release date>`.
PRs that leave the version alone pass without checking anything.
The PR check, the release workflow and `mise run release` all use `.github/scripts/release-metadata.sh`, tested by `mise run test:release-metadata`.

### 2. Cut the release (recommended)

Once the prepare-release PR is merged, from an up-to-date `main`:
Expand Down
28 changes: 8 additions & 20 deletions mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,10 @@ echo "cargo fmt --version = " $(cargo fmt --version)
echo "cargo clippy --version = " $(cargo clippy --version)
"""

[tasks."test:release-metadata"]
description = "Tests the release version/changelog check script"
run = ".github/scripts/release-metadata.test.sh"

[tasks."test:format"]
description = "Runs cargo fmt"
run = 'cargo fmt --all -- --check'
Expand Down Expand Up @@ -725,13 +729,6 @@ set -euo pipefail

VERSION="${1:?Version required, e.g., mise run release v2.2.2}"

# Tag must be vMAJOR.MINOR.PATCH with an optional pre-release suffix.
if [[ ! "$VERSION" =~ ^v[0-9]+\\.[0-9]+\\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then
echo "error: version must look like v2.2.2 (got '$VERSION')" >&2
exit 1
fi
BARE="${VERSION#v}"

# Releases are cut from a clean, up-to-date main.
BRANCH="$(git rev-parse --abbrev-ref HEAD)"
if [ "$BRANCH" != "main" ]; then
Expand All @@ -748,19 +745,10 @@ if [ "$(git rev-parse HEAD)" != "$(git rev-parse origin/main)" ]; then
exit 1
fi

# The prepare-release PR must have bumped the version and added release notes.
CARGO_VERSION="$(sed -n 's/^version = "\\(.*\\)"/\\1/p' Cargo.toml | head -1)"
if [ "$CARGO_VERSION" != "$BARE" ]; then
echo "error: Cargo.toml workspace version ($CARGO_VERSION) does not match $VERSION" >&2
echo " bump the version in a prepare-release PR and merge it before releasing" >&2
exit 1
fi
# Fixed-string match so dots in the version aren't treated as regex wildcards.
if ! grep -qF "## [$BARE]" CHANGELOG.md; then
echo "error: CHANGELOG.md has no '## [$BARE]' section" >&2
echo " add release notes in a prepare-release PR and merge it before releasing" >&2
exit 1
fi
# The prepare-release PR must have bumped the version and added release notes,
# and the tag must look like vMAJOR.MINOR.PATCH. Same check as the release
# workflow's verify-release job.
.github/scripts/release-metadata.sh check "$VERSION"
if git rev-parse -q --verify "refs/tags/$VERSION" >/dev/null; then
echo "error: tag $VERSION already exists locally" >&2
exit 1
Expand Down
Loading