Skip to content
Merged
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
15 changes: 15 additions & 0 deletions .agents/skills/bump-version/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<version>
```

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.
14 changes: 10 additions & 4 deletions .agents/skills/pre-release-check/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<version>
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

Expand Down
64 changes: 61 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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/`.
Expand Down
118 changes: 118 additions & 0 deletions scripts/check_release_tag.py
Original file line number Diff line number Diff line change
@@ -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<version>[^\"]*)\"",
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 `## <version>`, 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`, ` -- <date>` 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()
Loading