diff --git a/.agents/skills/bump-version/SKILL.md b/.agents/skills/bump-version/SKILL.md index 063ecf0..bb77cf6 100644 --- a/.agents/skills/bump-version/SKILL.md +++ b/.agents/skills/bump-version/SKILL.md @@ -80,3 +80,18 @@ Pushing the tag is what starts `.github/workflows/release.yml`, which builds the three targets, composes the release body from the CHANGES.md section this skill just wrote, and publishes. So a mistake here becomes a published release; that is the reason for the split. + +Print the check that goes with the tag command, to be run **on the branch being +tagged, after pulling it**: + +```bash +uv run scripts/check_release_tag.py v +``` + +It says whether that checkout carries the version and the CHANGES.md section +the tag claims. A final tag is cut from `main` after the release pull request +is merged, and a tag on a `main` that has not been pulled builds and publishes +the previous release under the new number -- `v1.1.0` was once pushed at a +1.0.0 tree, and three image tags were published before anything noticed. The +`verify` job in the release workflow now refuses that, but it refuses it after +the tag exists; this catches it before. diff --git a/.agents/skills/pre-release-check/SKILL.md b/.agents/skills/pre-release-check/SKILL.md index e49efbd..e75172f 100644 --- a/.agents/skills/pre-release-check/SKILL.md +++ b/.agents/skills/pre-release-check/SKILL.md @@ -89,13 +89,19 @@ else this skill turns up. ## Then check the version ```bash -grep -n '^version' Cargo.toml +uv run scripts/check_release_tag.py v git tag --list | tail -5 ``` -The workspace version must be ahead of the newest release tag, and the -CHANGES.md heading for it must exist with a date. If the version is unchanged -since the last tag, the release has not been prepared -- stop and say so. +The script is the same check the release workflow's `verify` job runs: this +checkout's workspace version equals the version being released, and CHANGES.md +has a non-empty section for it. It must also be ahead of the newest release tag +-- if the version is unchanged since the last tag, the release has not been +prepared, so stop and say so. + +Run it again on `main` after the release pull request is merged and pulled, +immediately before cutting the tag. That is the check that would have caught +`v1.1.0` being tagged on an unmerged `main` and published as a 1.0.0 build. ## Then dry-run the release scripts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1c0d2c3..e815402 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,9 +2,10 @@ # release whose body is the CHANGES.md section for the tag plus a download # list. # -# Triggered by a version tag. Branch rules for where a tag may be cut from are -# in CONTRIBUTING.md and are not enforced here -- a tag is the decision, and -# this workflow carries it out. +# Triggered by a version tag. The `verify` job checks the tag against the commit +# it names -- version, CHANGES.md section, and the branch rule in CONTRIBUTING.md +# -- and every other job descends from it, so nothing is built or published +# until the tag is one this repository allows. # # A pre-release tag (`v1.2.3-rc.1`, anything with a `-` qualifier) builds and # publishes exactly like a final one, marked as a pre-release so it does not @@ -30,10 +31,67 @@ concurrency: cancel-in-progress: false jobs: + # A tag is a claim about a commit, and this is the only job that checks the + # claim before anything acts on it. Everything downstream treats the tag name + # as the truth: the image is tagged from it and pushed, and the Docker Hub + # overview is rewritten to name it. So a tag cut from a commit that does not + # carry the release -- the release pull request still open, the branch not + # pulled -- publishes a mislabelled image and only fails at the notes step, + # long after. That happened to `v1.1.0`. + # + # It costs seconds on a bare runner, and every other job descends from + # `frontend`, so gating that one gates all of them. A job added here that + # does not is a job outside the gate. + verify: + name: Check the tag against the tree + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + # Full history: the branch check below asks whether this commit is an + # ancestor of `main`, and a shallow clone has no common history to + # answer that with. No `lfs`, nothing here reads an asset. + - name: Checkout repository + uses: actions/checkout@v7 + with: + ref: ${{ github.event.inputs.tag || github.ref }} + fetch-depth: 0 + + - name: Install uv + uses: astral-sh/setup-uv@v9.0.0 + + - name: The tag names this commit's version, and CHANGES.md describes it + env: + TAG: ${{ github.event.inputs.tag || github.ref_name }} + run: uv run scripts/check_release_tag.py "$TAG" + + # CONTRIBUTING.md and AGENTS.md both say a final tag may only be cut from + # `main` and that this workflow rejects one that is not -- so here it is. + # Reachability rather than "which branch is this", because a tag has no + # branch: what `main` promises is that the commit is on it. + # + # Release candidates are exempt by design. An RC exists to exercise this + # pipeline before the work reaches `main`, so requiring it to be there + # first would defeat the point. + - name: A final tag is on main + if: ${{ !contains(github.event.inputs.tag || github.ref_name, '-') }} + env: + TAG: ${{ github.event.inputs.tag || github.ref_name }} + run: | + set -euo pipefail + git fetch --quiet origin main + if ! git merge-base --is-ancestor HEAD origin/main; then + echo "$TAG is not on main -- final tags are cut from main, after" \ + "develop has merged there. A candidate would be ${TAG}-rc.1." >&2 + exit 1 + fi + echo "$TAG is on main" + frontend: name: Build the dashboard runs-on: ubuntu-latest timeout-minutes: 15 + needs: verify steps: # `lfs: true` for the favicon the build copies out of `assets/`. diff --git a/scripts/check_release_tag.py b/scripts/check_release_tag.py new file mode 100755 index 0000000..76b3d86 --- /dev/null +++ b/scripts/check_release_tag.py @@ -0,0 +1,118 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.11" +# dependencies = [] +# /// +"""Check that a tag matches the tree it points at. + + uv run scripts/check_release_tag.py v1.1.0 + uv run scripts/check_release_tag.py # falls back to $GITHUB_REF_NAME + +A single leading `v` is stripped, so a tag name works as-is. + +This exists because a tag is a claim about a commit, and nothing else in the +build verifies the claim before acting on it. A tag cut from a branch that +does not carry the release commit -- the release pull request still open, the +branch not pulled -- builds and publishes perfectly well: the version in the +binary, the image tags and the Docker Hub overview all say what the tag said, +and the contents are whatever that commit held. `v1.1.0` was once pushed at a +1.0.0 tree and the image was published under three tags before the notes step +noticed. + +So the two things a release cannot be wrong about are checked first, on a bare +runner, in seconds: + +1. The workspace version equals the tag. This is what ends up in + `doppel --version`, in `doppel_build_info` and in the image labels. +2. CHANGES.md has a non-empty section for it. This is the release body, and + the one part of a release that cannot be regenerated later from the tree. + +Standard library only, so this runs with nothing installed but uv. The PEP 723 +block above is what lets `uv run` execute it directly. +""" + +from __future__ import annotations + +import os +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + + +def fail(message: str) -> None: + print(f"check-release-tag: {message}", file=sys.stderr) + raise SystemExit(1) + + +def workspace_version(text: str) -> str: + """The version under `[workspace.package]`. + + Anchored to that table, the way `bump_version.py` anchors its rewrite, so + a `version = ` line in a dependency entry cannot be read instead. + """ + found = re.search( + r"\[workspace\.package\][^\[]*?^version\s*=\s*\"(?P[^\"]*)\"", + text, + re.MULTILINE | re.DOTALL, + ) + if not found: + fail("no version under [workspace.package] in Cargo.toml") + return found.group("version") + + +def changes_section(changes: str, version: str) -> str: + """The body under `## `, up to the next `## ` heading. + + Matched the way `release_notes.py` matches it -- trailing boundary so + `0.1.0` does not also match `0.1.01`, ` -- ` suffix tolerated -- so + that a tag passing this check cannot fail there. + """ + lines = changes.splitlines() + heading = re.compile(rf"^## {re.escape(version)}(\s|$)") + + start = next((i for i, line in enumerate(lines) if heading.match(line)), None) + if start is None: + return "" + + end = len(lines) + for i in range(start + 1, len(lines)): + if lines[i].startswith("## "): + end = i + break + + return "\n".join(lines[start + 1 : end]).strip() + + +def main() -> None: + raw_tag = sys.argv[1] if len(sys.argv) > 1 else os.environ.get("GITHUB_REF_NAME") + if not raw_tag: + fail("no tag given (argument or $GITHUB_REF_NAME)") + + version = raw_tag.removeprefix("v") + + cargo_path = ROOT / "Cargo.toml" + changes_path = ROOT / "CHANGES.md" + for path in (cargo_path, changes_path): + if not path.exists(): + fail(f"no such file: {path}") + + found = workspace_version(cargo_path.read_text(encoding="utf-8")) + if found != version: + fail( + f"tag {raw_tag} names version {version}, but this commit is " + f"{found} -- is the release commit merged, and is this tag on it?" + ) + + if not changes_section(changes_path.read_text(encoding="utf-8"), version): + fail( + f'no "## {version}" section in CHANGES.md, or it is empty -- ' + f"the release body comes from it" + ) + + print(f"check-release-tag: {raw_tag} matches version {found} and CHANGES.md") + + +if __name__ == "__main__": + main()