From 9fc1745671968bea9336bf991d6c4d129f033d83 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 00:20:38 +0000 Subject: [PATCH 1/3] ci: fail version-bump PRs until the changelog has the new section The version/changelog rule was only checked after a tag existed, in the release workflow's verify-release job, so a mismatched tag reached a failed publish before anyone noticed. Move the rule into one script and run it on pull requests too: a PR that changes the workspace version fails until CHANGELOG.md has a '## [X.Y.Z]' heading for it. PRs that leave the version alone pass without checking anything. The release workflow and `mise run release` call the same script, so the three can't drift. The script reads the version from [workspace.package] rather than the first `version =` line, and only accepts the heading at the start of a line. This prepares for release-plz, whose release PR bumps the version while the changelog stays hand-written. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/release-metadata.sh | 107 +++++++++++++++ .github/scripts/release-metadata.test.sh | 164 +++++++++++++++++++++++ .github/workflows/release-metadata.yml | 35 +++++ .github/workflows/release.yml | 20 +-- DEVELOPMENT.md | 5 + mise.toml | 28 ++-- 6 files changed, 322 insertions(+), 37 deletions(-) create mode 100755 .github/scripts/release-metadata.sh create mode 100755 .github/scripts/release-metadata.test.sh create mode 100644 .github/workflows/release-metadata.yml diff --git a/.github/scripts/release-metadata.sh b/.github/scripts/release-metadata.sh new file mode 100755 index 000000000..b6920ceb9 --- /dev/null +++ b/.github/scripts/release-metadata.sh @@ -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 ' (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 diff --git a/.github/scripts/release-metadata.test.sh b/.github/scripts/release-metadata.test.sh new file mode 100755 index 000000000..587eef17b --- /dev/null +++ b/.github/scripts/release-metadata.test.sh @@ -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" <"$dir/CHANGELOG.md" +} + +commit_all() { # dir message + git -C "$1" add -A + git -C "$1" commit --quiet -m "$2" +} + +# expect +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] - '" "$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 ] diff --git a/.github/workflows/release-metadata.yml b/.github/workflows/release-metadata.yml new file mode 100644 index 000000000..78e28bf2f --- /dev/null +++ b/.github/workflows/release-metadata.yml @@ -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" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6760ad776..83af0bea0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index ad644368b..526245730 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -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] - `. +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`: diff --git a/mise.toml b/mise.toml index f1cdb3146..9a52a8a10 100644 --- a/mise.toml +++ b/mise.toml @@ -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' @@ -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 @@ -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 From 9982e4e54296e47945888e6589808175c88e933b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 00:48:15 +0000 Subject: [PATCH 2/3] chore: demo version bump (do not merge) Throwaway commit to show the Release metadata check failing on a version bump without a changelog section. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 6 +++--- Cargo.toml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 161133246..e0c6226b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -839,7 +839,7 @@ dependencies = [ [[package]] name = "cipherstash-proxy" -version = "3.0.1" +version = "3.0.2" dependencies = [ "arc-swap", "async-trait", @@ -1633,7 +1633,7 @@ dependencies = [ [[package]] name = "eql-mapper-macros" -version = "3.0.1" +version = "3.0.2" dependencies = [ "pretty_assertions", "proc-macro2", @@ -4468,7 +4468,7 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "showcase" -version = "3.0.1" +version = "3.0.2" dependencies = [ "cipherstash-client", "cipherstash-config", diff --git a/Cargo.toml b/Cargo.toml index 91d8d148c..6aa0b4c4e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 From 153bd747210fdc470da8810dfc2291ad64183d63 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 00:49:13 +0000 Subject: [PATCH 3/3] docs: demo changelog section for 3.0.2 (do not merge) Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37c446ddd..69e32a4c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`.