From 4cf57a2aba2561e4a0d9c7c07c6a7ed3a772d4e2 Mon Sep 17 00:00:00 2001 From: "a.b.christie" Date: Mon, 8 Jun 2026 08:54:43 +0100 Subject: [PATCH] feat(7): add a release skill with tested numbering logic Add a Claude Code 'release' skill (.claude/skills/release/) that cuts a release of the operator from the latest main: - Refuses to release when the 'build' CI run for the commit has not succeeded. - Enforces that the release major matches the pinned 'kubernetes' package. - Supports semver pre-releases (alpha/beta/rc, numbered from 1) marked as a GitHub Pre-release, and full releases. - Asks the user for a title and the channel, generates release notes from the commits since the prior release, and creates the GitHub release/tag. The numbering logic lives in a pure, unit-tested helper (next_release.py / tests/test_release.py) so it is deterministic across releases; the SKILL.md orchestrates the interactive, cluster-facing steps. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/skills/release/SKILL.md | 127 +++++++++++++++++++ .claude/skills/release/next_release.py | 167 +++++++++++++++++++++++++ README.md | 16 +++ tests/test_release.py | 155 +++++++++++++++++++++++ 4 files changed, 465 insertions(+) create mode 100644 .claude/skills/release/SKILL.md create mode 100755 .claude/skills/release/next_release.py create mode 100644 tests/test_release.py diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md new file mode 100644 index 0000000..3d1ad56 --- /dev/null +++ b/.claude/skills/release/SKILL.md @@ -0,0 +1,127 @@ +--- +name: release +description: >- + Cut a release of the squonk2-data-manager-viz-operator. Use when the user + asks to "release", "cut a release", "make a release", "publish a release", or + "tag a release" of this repository. Handles semver numbering (alpha/beta/rc + pre-releases and full releases), enforces that the major matches the pinned + kubernetes package, blocks releases when CI has failed, generates release + notes, and creates the GitHub release. +--- + +# Release + +Cut a release of this operator. A release is a Git **tag** plus a **GitHub +release**; pushing the tag triggers the `build-tag` workflow, which builds and +pushes the operator container image. Releases are therefore outward-facing and +hard to reverse — follow the steps in order and **confirm with the user before +creating the release**. + +## Rules (must hold) + +- Releases are **always cut from the latest `main`**. +- The release **major must equal** the major of the operator's pinned + `kubernetes` package (`operator/requirements.txt`). The helper enforces this. +- Numbering is semver with pre-release channels **alpha** (`-alpha.N`), **beta** + (`-beta.N`) and **release candidate** (`-rc.N`); the first pre-release in a + series uses `N = 1`. A full release has no suffix. +- alpha/beta/rc releases are marked as a **Pre-release** on GitHub. +- **Never release if CI has failed** for the commit being released. + +## Steps + +### 1. Get onto the latest `main` + +Run this from the main checkout (not a feature worktree): + +```bash +git checkout main +git fetch origin +git pull --ff-only origin main +git rev-parse HEAD # the commit being released +``` + +If the working tree is dirty or `main` cannot fast-forward, stop and tell the +user. + +### 2. Refuse to release on a failed (or unfinished) CI run + +Check the `build` workflow for the exact commit being released. Do **not** +release unless its latest run is `completed` with conclusion `success`: + +```bash +sha="$(git rev-parse HEAD)" +gh run list --branch main --workflow build --commit "$sha" \ + --limit 1 --json status,conclusion,headSha,url +``` + +- conclusion `success` → continue. +- conclusion `failure`/`cancelled`/`timed_out` → **abort** and report the run URL. +- still `in_progress`/`queued`, or no run found yet → tell the user CI has not + finished and stop (offer to wait and retry rather than releasing blind). + +### 3. Ask the user for the title and channel + +Use `AskUserQuestion` to collect: + +1. **Title** — a short human title for the release (free text). +2. **Channel** — one of: + - `alpha` — early pre-release + - `beta` — pre-release + - `rc` — release candidate + - `final` — full (non pre-release) release + +### 4. Compute the next tag + +The helper reads the kubernetes major from `operator/requirements.txt`, defaults +the base version to `{major}.0.0`, inspects existing tags, and prints the next +tag (incrementing `N` per channel, first is `1`): + +```bash +python .claude/skills/release/next_release.py next \ + --channel \ + --requirements operator/requirements.txt +``` + +- If the user needs a different minor/patch (the major is fixed), pass an + explicit `--base MAJOR.MINOR.PATCH` (its major must still match the package). +- The helper raises (non-zero exit) on a major mismatch, a malformed base, or a + `final` tag that already exists — surface the message and stop; do not invent + a tag. + +Show the computed tag to the user and **get explicit confirmation** before the +next step. + +### 5. Create the GitHub release + +Create the release on `main`'s head. `--generate-notes` builds the notes from +all commits/PRs since the previous release. Add `--prerelease` for +alpha/beta/rc (i.e. any channel other than `final`): + +```bash +# Pre-release (alpha/beta/rc): +gh release create "" --target main --title "" \ + --generate-notes --prerelease + +# Full release (final): +gh release create "<tag>" --target main --title "<title>" \ + --generate-notes +``` + +`gh release create` creates the tag for you; do not create the tag separately. + +### 6. Confirm + +Report the release URL (`gh release view <tag> --web` / the create output) and +remind the user that the `build-tag` workflow is now building and pushing the +`informaticsmatters/data-manager-viz-operator:<tag>` image. + +## Helper + +`next_release.py` holds the pure, unit-tested numbering logic +(`tests/test_release.py`). It also exposes `major` to print the pinned +kubernetes major: + +```bash +python .claude/skills/release/next_release.py major operator/requirements.txt +``` diff --git a/.claude/skills/release/next_release.py b/.claude/skills/release/next_release.py new file mode 100755 index 0000000..4331483 --- /dev/null +++ b/.claude/skills/release/next_release.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +"""Release-numbering helpers for the repository's ``release`` skill. + +The release *process* (checking CI, asking for a title/channel, creating the +GitHub release) is orchestration described in ``SKILL.md``. The numbering +*logic* lives here, in small pure functions, so it is deterministic and unit +tested (see ``tests/test_release.py``): + +- ``kubernetes_major`` reads the major version the operator is pinned to. The + release's major **must** match it. +- ``next_tag`` computes the next semver tag for a chosen channel - ``alpha``, + ``beta`` or ``rc`` pre-releases (``X.Y.Z-channel.N``, first ``N`` is ``1``), + or a ``final`` full release (``X.Y.Z``). + +A small command-line wrapper lets the skill call these from a shell; it reads +the existing tags from ``git`` so the caller does not have to. +""" + +import argparse +import re +import subprocess +import sys +from typing import List, Tuple + +# The recognised pre-release channels, in the order they are expected to be +# used during a release cycle. +PRERELEASE_CHANNELS: Tuple[str, ...] = ("alpha", "beta", "rc") +# The sentinel channel for a full (non pre-release) release. +FINAL_CHANNEL: str = "final" + +_KUBERNETES_PIN = re.compile(r"^\s*kubernetes\s*==\s*(\d+)\.\d+\.\d+", re.MULTILINE) +_BASE_VERSION = re.compile(r"^(\d+)\.(\d+)\.(\d+)$") + + +def kubernetes_major(requirements_text: str) -> int: + """Return the major version of the pinned ``kubernetes`` package. + + Reads a ``requirements.txt`` body looking for a line such as + ``kubernetes == 35.0.0``. A missing/unparsable pin is an error - we must + never guess the major, as the release tag is built from it. + """ + match = _KUBERNETES_PIN.search(requirements_text) + if not match: + raise ValueError( + "Could not find a 'kubernetes == X.Y.Z' pin in the requirements" + ) + return int(match.group(1)) + + +def is_prerelease(channel: str) -> bool: + """Whether ``channel`` denotes a pre-release (alpha/beta/rc).""" + return channel in PRERELEASE_CHANNELS + + +def _parse_base_version(base_version: str) -> Tuple[int, int, int]: + """Parse and validate an ``X.Y.Z`` base version, returning its components.""" + match = _BASE_VERSION.match(base_version) + if not match: + raise ValueError( + f"Base version '{base_version}' is not a 'MAJOR.MINOR.PATCH' string" + ) + return int(match.group(1)), int(match.group(2)), int(match.group(3)) + + +def next_tag( + *, + channel: str, + base_version: str, + existing_tags: List[str], + kubernetes_major: int, # pylint: disable=redefined-outer-name +) -> str: + """Compute the next release tag for ``channel`` against ``base_version``. + + ``channel`` is one of ``alpha``/``beta``/``rc`` (a pre-release) or + ``final`` (a full release). For a pre-release the result is + ``{base}-{channel}.{N}`` where ``N`` is one greater than the highest + existing ``N`` for the same base and channel (``1`` if none exist). For a + ``final`` release the result is the bare ``{base}``. + + Raises ``ValueError`` when the channel is unknown, the base version is + malformed, the major does not match ``kubernetes_major``, or the computed + tag already exists. + """ + if channel != FINAL_CHANNEL and channel not in PRERELEASE_CHANNELS: + recognised = ", ".join((*PRERELEASE_CHANNELS, FINAL_CHANNEL)) + raise ValueError(f"Unknown channel '{channel}'; expected one of: {recognised}") + + major, _, _ = _parse_base_version(base_version) + if major != kubernetes_major: + raise ValueError( + f"Release major ({major}) must match the kubernetes package major " + f"({kubernetes_major})" + ) + + if channel == FINAL_CHANNEL: + if base_version in existing_tags: + raise ValueError(f"Tag '{base_version}' already exists") + return base_version + + prefix = f"{base_version}-{channel}." + highest = 0 + for tag in existing_tags: + if tag.startswith(prefix): + suffix = tag[len(prefix) :] + if suffix.isdigit(): + highest = max(highest, int(suffix)) + return f"{prefix}{highest + 1}" + + +def _git_tags() -> List[str]: + """Return the repository's existing tags (one per line from ``git tag``).""" + result = subprocess.run( + ["git", "tag", "--list"], + check=True, + capture_output=True, + text=True, + ) + return [line.strip() for line in result.stdout.splitlines() if line.strip()] + + +def _main(argv: List[str]) -> int: + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest="command", required=True) + + p_major = sub.add_parser("major", help="print the pinned kubernetes major") + p_major.add_argument("requirements", help="path to requirements.txt") + + p_next = sub.add_parser("next", help="print the next release tag") + p_next.add_argument( + "--channel", + required=True, + choices=(*PRERELEASE_CHANNELS, FINAL_CHANNEL), + ) + p_next.add_argument( + "--requirements", + required=True, + help="path to requirements.txt (provides the major version)", + ) + p_next.add_argument( + "--base", + default=None, + help="base 'MAJOR.MINOR.PATCH' version (default '{major}.0.0')", + ) + + args = parser.parse_args(argv) + + if args.command == "major": + with open(args.requirements, encoding="utf-8") as requirements: + print(kubernetes_major(requirements.read())) + return 0 + + with open(args.requirements, encoding="utf-8") as requirements: + major = kubernetes_major(requirements.read()) + base_version = args.base or f"{major}.0.0" + print( + next_tag( + channel=args.channel, + base_version=base_version, + existing_tags=_git_tags(), + kubernetes_major=major, + ) + ) + return 0 + + +if __name__ == "__main__": + sys.exit(_main(sys.argv[1:])) diff --git a/README.md b/README.md index 1a0f14c..1117b88 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,22 @@ a specific tag: - `kubernetes` PyPI package the operator is built against (currently `35`, for Kubernetes 1.35). +## Cutting a release + +Releases are cut from the latest `main` as a Git **tag**, which triggers the +`build-tag` workflow to build and push the image. The repository ships a +Claude Code `release` skill (`.claude/skills/release/`) that automates this: +it refuses to release when CI has failed, enforces that the release **major** +matches the pinned `kubernetes` package, and supports semver pre-releases — +`alpha`, `beta` and `rc` (each numbered from `1`, e.g. `35.0.0-alpha.1`, and +marked as a GitHub _Pre-release_) — as well as full releases. + +The pure numbering logic lives in `.claude/skills/release/next_release.py` and +is unit tested (`tests/test_release.py`); it can also be run directly: - + + python .claude/skills/release/next_release.py next \ + --channel alpha --requirements operator/requirements.txt + # Data Manager Application Compliance In order to expose the CRD as an _Application_ in the Data Manager API service diff --git a/tests/test_release.py b/tests/test_release.py new file mode 100644 index 0000000..0065a26 --- /dev/null +++ b/tests/test_release.py @@ -0,0 +1,155 @@ +"""Unit tests for the release skill's pure helper functions. + +The release process itself (asking the user for a title/channel, checking CI, +creating the GitHub release) is orchestration described in +``.claude/skills/release/SKILL.md``. The *numbering* logic - which is easy to +get subtly wrong and must be deterministic across releases - lives in pure +functions in ``.claude/skills/release/next_release.py`` and is tested here. +""" + +import os +import sys + +import pytest + +# Make the release skill helper importable. +sys.path.insert( + 0, + os.path.join(os.path.dirname(__file__), "..", ".claude", "skills", "release"), +) + +import next_release # noqa: E402 pylint: disable=wrong-import-position + + +# --- kubernetes major ------------------------------------------------------- + + +def test_kubernetes_major_is_parsed_from_the_pin() -> None: + text = "kopf == 1.40.1\nkubernetes == 35.0.0\n" + assert next_release.kubernetes_major(text) == 35 + + +def test_kubernetes_major_tolerates_whitespace_and_extras() -> None: + text = "kubernetes==36.1.2 # comment\n" + assert next_release.kubernetes_major(text) == 36 + + +def test_kubernetes_major_raises_when_absent() -> None: + with pytest.raises(ValueError): + next_release.kubernetes_major("kopf == 1.40.1\n") + + +# --- next tag: pre-releases ------------------------------------------------- + + +def test_first_alpha_uses_n_of_one() -> None: + tag = next_release.next_tag( + channel="alpha", + base_version="35.0.0", + existing_tags=[], + kubernetes_major=35, + ) + assert tag == "35.0.0-alpha.1" + + +def test_alpha_increments_past_the_highest_existing_alpha() -> None: + tag = next_release.next_tag( + channel="alpha", + base_version="35.0.0", + existing_tags=["35.0.0-alpha.1", "35.0.0-alpha.2"], + kubernetes_major=35, + ) + assert tag == "35.0.0-alpha.3" + + +def test_beta_numbering_is_independent_of_alpha() -> None: + tag = next_release.next_tag( + channel="beta", + base_version="35.0.0", + existing_tags=["35.0.0-alpha.1", "35.0.0-alpha.2"], + kubernetes_major=35, + ) + assert tag == "35.0.0-beta.1" + + +def test_release_candidate_increments_its_own_series() -> None: + tag = next_release.next_tag( + channel="rc", + base_version="35.0.0", + existing_tags=["35.0.0-beta.1", "35.0.0-rc.1"], + kubernetes_major=35, + ) + assert tag == "35.0.0-rc.2" + + +def test_prerelease_numbering_ignores_other_base_versions() -> None: + tag = next_release.next_tag( + channel="alpha", + base_version="35.1.0", + existing_tags=["35.0.0-alpha.1", "35.0.0-alpha.2"], + kubernetes_major=35, + ) + assert tag == "35.1.0-alpha.1" + + +# --- next tag: final release ------------------------------------------------ + + +def test_final_release_has_no_suffix() -> None: + tag = next_release.next_tag( + channel="final", + base_version="35.0.0", + existing_tags=["35.0.0-rc.1"], + kubernetes_major=35, + ) + assert tag == "35.0.0" + + +def test_final_release_refuses_to_reuse_an_existing_tag() -> None: + with pytest.raises(ValueError): + next_release.next_tag( + channel="final", + base_version="35.0.0", + existing_tags=["35.0.0"], + kubernetes_major=35, + ) + + +# --- validation ------------------------------------------------------------- + + +def test_major_must_match_the_kubernetes_package_major() -> None: + with pytest.raises(ValueError): + next_release.next_tag( + channel="alpha", + base_version="34.0.0", + existing_tags=[], + kubernetes_major=35, + ) + + +def test_unknown_channel_is_rejected() -> None: + with pytest.raises(ValueError): + next_release.next_tag( + channel="gamma", + base_version="35.0.0", + existing_tags=[], + kubernetes_major=35, + ) + + +def test_malformed_base_version_is_rejected() -> None: + with pytest.raises(ValueError): + next_release.next_tag( + channel="alpha", + base_version="35.0", + existing_tags=[], + kubernetes_major=35, + ) + + +def test_is_prerelease_classifies_channels() -> None: + assert next_release.is_prerelease("alpha") is True + assert next_release.is_prerelease("beta") is True + assert next_release.is_prerelease("rc") is True + assert next_release.is_prerelease("final") is False