Skip to content
Closed
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

## [3.0.2] - 2026-11-09

### Added

- **Connection flags**: `--database-url` sets the database host, port, user, password and name from one PostgreSQL connection string, and `--db-port` (`-P`) and `--db-password` (`-W`) join the existing `--db-*` flags, which override matching parts of the URL. A URL sets only the parts it contains. Connection strings with any other option, such as `sslmode`, or with a Unix socket host or multiple hosts, are rejected, because Proxy cannot apply them. A password on the command line is exposed in shell history and the process list, so prefer `CS_DATABASE__PASSWORD`.
Expand Down
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ resolver = "2"
members = ["packages/*"]

[workspace.package]
version = "3.0.1"
version = "3.0.2"
edition = "2021"
# Minimum supported Rust version (MSRV). CI builds the production binary on
# exactly this toolchain. See "Supported Rust versions" in DEVELOPMENT.md
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
Loading
Loading