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
127 changes: 127 additions & 0 deletions .claude/skills/release/SKILL.md
Original file line number Diff line number Diff line change
@@ -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 <alpha|beta|rc|final> \
--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 "<tag>" --target main --title "<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
```
167 changes: 167 additions & 0 deletions .claude/skills/release/next_release.py
Original file line number Diff line number Diff line change
@@ -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:]))
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading