From d22ad0c00c5349f05bed46ffb249f35ad9611c28 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 1 Jun 2026 14:03:12 +1000 Subject: [PATCH] ci: guard releases against version/changelog drift Adds a verify-release job to the release workflow that fails a numbered release unless the git tag matches the [workspace.package] version in Cargo.toml and CHANGELOG.md has a matching section. The build matrix now depends on it, so a mismatched tag never builds or publishes. Reworks 'mise run release' to verify the same invariants client-side (clean, in-sync main; version and changelog match the tag; tag is new) before tagging, instead of blindly tagging whatever is checked out. Documents the prepare-release-PR -> tag flow in DEVELOPMENT.md. This prevents the drift that left v2.2.1 shipping with Cargo.toml still at 2.2.0-alpha.1 and no [2.2.1] changelog entry. --- .github/workflows/release.yml | 30 +++++++++++++++++++++ DEVELOPMENT.md | 38 ++++++++++++++++++++------ mise.toml | 50 ++++++++++++++++++++++++++++++++--- 3 files changed, 107 insertions(+), 11 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 54f43c6b..0dcce7a0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,8 +16,38 @@ env: REGISTRY_IMAGE: cipherstash/proxy jobs: + verify-release: + name: Verify release metadata + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + + # Only enforced for numbered releases. On push/PR/workflow_dispatch this + # 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." + build: name: Build binaries + Docker images + needs: verify-release strategy: fail-fast: false matrix: diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 57fd0d2f..fded1e84 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -735,29 +735,51 @@ Note: not all errors do this at the moment, and we will change over time. Releases are published via GitHub Actions when a GitHub release is created. -### Using mise (recommended) +A release happens in two steps: first a **prepare-release PR** that records the +version and notes, then **tagging** that merged commit. + +### 1. Prepare-release PR + +Open a PR against `main` that: + +1. Bumps `version` under `[workspace.package]` in the root `Cargo.toml` (and runs + `cargo update --workspace` so `Cargo.lock` matches). +2. Adds a `## [X.Y.Z]` section to `CHANGELOG.md` describing the user-facing changes. + +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`. + +### 2. Cut the release (recommended) + +Once the prepare-release PR is merged, from an up-to-date `main`: ```bash -mise run release v2.1.9 +mise run release vX.Y.Z ``` -This will: +This verifies you're on a clean, in-sync `main`, that `Cargo.toml` and +`CHANGELOG.md` already describe `X.Y.Z`, and that the tag doesn't already exist. +If those checks pass it will: 1. Create a git tag for the version 2. Push the tag to origin 3. Create a GitHub release with auto-generated notes 4. Trigger the release workflow which builds and publishes Docker images +The release workflow re-runs the same version/changelog check (the +`verify-release` job) before building, so a mismatched tag fails fast without +publishing anything. + ### Manual release -If you need more control over the release process: +If you need more control, the steps the task automates are: ```bash -# Create and push the tag -git tag v2.1.9 -git push origin v2.1.9 +# Create and push the tag (from the merged prepare-release commit on main) +git tag vX.Y.Z +git push origin vX.Y.Z # Create the GitHub release -gh release create v2.1.9 --generate-notes +gh release create vX.Y.Z --generate-notes ``` ### Re-releasing a version diff --git a/mise.toml b/mise.toml index 5bb91805..75110dd1 100644 --- a/mise.toml +++ b/mise.toml @@ -700,15 +700,59 @@ mise --env tls run proxy:down """ [tasks.release] -description = "Create a GitHub release" +description = "Create a GitHub release (run after the prepare-release PR is merged)" run = """ #!/usr/bin/env bash set -euo pipefail -VERSION="${1:?Version required, e.g., mise run release v2.1.9}" +VERSION="${1:?Version required, e.g., mise run release v2.2.2}" -echo "Creating release $VERSION..." +# 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 + echo "error: releases must be cut from main (currently on '$BRANCH')" >&2 + exit 1 +fi +if [ -n "$(git status --porcelain)" ]; then + echo "error: working tree is not clean; commit or stash changes first" >&2 + exit 1 +fi +git fetch origin main --quiet +if [ "$(git rev-parse HEAD)" != "$(git rev-parse origin/main)" ]; then + echo "error: local main is not in sync with origin/main; pull first" >&2 + 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 +if git rev-parse -q --verify "refs/tags/$VERSION" >/dev/null; then + echo "error: tag $VERSION already exists locally" >&2 + exit 1 +fi +if git ls-remote --exit-code --tags origin "refs/tags/$VERSION" >/dev/null 2>&1; then + echo "error: tag $VERSION already exists on origin" >&2 + exit 1 +fi +echo "Releasing $VERSION (verified version + changelog)..." git tag "$VERSION" git push origin "$VERSION" gh release create "$VERSION" --generate-notes