From 1ca498035fbb775571dab7a7ea952d2af0cb344e Mon Sep 17 00:00:00 2001 From: Richard Wooding Date: Tue, 28 Apr 2026 13:43:40 +0200 Subject: [PATCH] Add release-pycel2sql agent skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures the procedure for cutting a pycel2sql release. The release flow is shorter than upstream cel2sql Go's (no manual CHANGELOG — release.yml generates notes from `git log $PREV_TAG..$TAG`) and simpler than cel2sql4j Java's (no GPG signing, no Sonatype staging — PyPI uses OIDC trusted publishing; hatch-vcs reads the tag, no version file to drift). Layout under .claude/skills/release-pycel2sql/: - SKILL.md (~80 lines) — quick start, semver decision, common slip-ups (lightweight tag, hatch-vcs version-file confusion, pre-release qualifier shape), PyPI OIDC notes, post-tag verification checklist. - scripts/release_preflight.sh — validates working tree + branch + sync state, CI status on origin/main, open Dependabot PRs (soft warning), release-notes preview from git log; if a version is supplied, validates the format and prints tag commands. Modeled on cel2sql4j's release_preflight.sh. The skill deliberately omits a separate references/ directory — the relevant config files (release.yml, ci.yml, pyproject.toml) are small enough to read directly when needed. Lints clean against .claude/skills/skill-authoring/scripts/lint_skill.py. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/release-pycel2sql/SKILL.md | 104 ++++++++++++ .../scripts/release_preflight.sh | 157 ++++++++++++++++++ 2 files changed, 261 insertions(+) create mode 100644 .claude/skills/release-pycel2sql/SKILL.md create mode 100755 .claude/skills/release-pycel2sql/scripts/release_preflight.sh diff --git a/.claude/skills/release-pycel2sql/SKILL.md b/.claude/skills/release-pycel2sql/SKILL.md new file mode 100644 index 0000000..d1b29b0 --- /dev/null +++ b/.claude/skills/release-pycel2sql/SKILL.md @@ -0,0 +1,104 @@ +--- +name: release-pycel2sql +description: Cuts a new pycel2sql release by validating the working tree, picking the semver bump, tagging vX.Y.Z, and pushing the tag to trigger .github/workflows/release.yml which runs CI, builds an sdist + wheel via hatch-vcs, and publishes to PyPI via OIDC trusted publishing. Use when shipping a new patch, minor, or major version of pycel2sql to PyPI. +--- + +# Release pycel2sql + +pycel2sql ships to PyPI whenever a `v*` tag lands on `main`. The release flow is shorter than upstream cel2sql Go's because there's no manual CHANGELOG to maintain — `release.yml` builds release notes from commit history via `softprops/action-gh-release@v2` (without `generate_release_notes: true` — it produces its own `release_notes.md` from `git log $PREV_TAG..$TAG`). It's also simpler than cel2sql4j's Maven Central flow: no GPG signing (PyPI uses OIDC), no Sonatype staging, no `gradle.properties` version drift (hatch-vcs reads the tag — there's no version file to forget). + +## Quick start + +```bash +git checkout main && git pull --ff-only origin main + +# 1. Run preflight (working tree clean, on main, in sync, CI green, list candidate commits). +bash .claude/skills/release-pycel2sql/scripts/release_preflight.sh + +# 2. Decide the version (see "Picking the version"). +VERSION=v0.3.0 + +# 3. Re-run preflight with the version to validate format + check for collisions. +bash .claude/skills/release-pycel2sql/scripts/release_preflight.sh "$VERSION" + +# 4. Tag annotated and push — the tag push is what triggers release.yml. +git tag -a "$VERSION" -m "Release $VERSION" +git push origin "$VERSION" + +# 5. Watch the release workflow. +gh run list --workflow Release --limit 1 +gh run watch +``` + +The workflow then: + +1. Reuses the `ci.yml` workflow (`workflow_call`) — runs ruff, mypy, unit tests on Python 3.12 + 3.13, integration tests in containers. +2. Builds sdist + wheel via `uv build` (hatch-vcs reads the tag for the version). +3. Publishes to PyPI via OIDC trusted publishing — the `publish-pypi` job uses the `pypi` GitHub Environment. +4. Creates a GitHub release with notes from `git log $PREV_TAG..$TAG`. + +The artifact lands at `https://pypi.org/project/pycel2sql/` once the workflow succeeds (sync usually under 2 minutes). + +## Picking the version + +pycel2sql follows plain semver. Quick rules: + +- **Patch (`v0.2.2`)** — Dependabot bumps without behaviour change, doc-only fixes, lock-file-only changes, internal refactors with identical generated SQL. +- **Minor (`v0.3.0`)** — new public kwarg on `convert()` / `convert_parameterized()` / `analyze()`, new dialect, new CEL feature, new `Dialect` ABC `@abstractmethod`. The most common bump. +- **Major (`v1.0.0`)** — removal of an exported function/class/kwarg, change to default behaviour that breaks callers, dropping a Python version. **Reserve for genuinely user-disruptive breakage.** + +When unsure between patch and minor: any new public API surface bumps minor. + +### Pre-1.0 caveat + +Today the project is in the `0.x` line. While in 0.x, breaking changes can land in a minor bump (`0.2` → `0.3`) per semver convention. Once `v1.0.0` ships, breaking changes require a major bump. + +## Common slip-ups + +- **Lightweight tag instead of annotated.** Use `git tag -a vX.Y.Z -m "Release vX.Y.Z"`, not `git tag vX.Y.Z`. The release workflow reads the tag's commit log for release notes. +- **Tagging from a feature branch.** The tag must point at a commit reachable from `main`. The preflight script checks this implicitly — it requires you to be on `main` and in sync with `origin/main`. +- **Forgetting open Dependabot security PRs.** The release otherwise ships known-vulnerable transitive deps. The preflight script flags any open Dependabot PRs as a soft warning — review them before tagging. +- **Pre-release qualifier shape.** Use `v0.3.0-rc1`, `v0.3.0-beta.1`, `v0.3.0-rc.2`. The validator (in `release.yml`'s tag-event regex implicitly via Python packaging conventions) accepts `^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$` after stripping the leading `v`. Underscores are rejected. +- **Forgetting that hatch-vcs uses the tag, not a version file.** There's no `pyproject.toml` version to bump and no `_version.py` to edit — `hatch-vcs` injects the version at build time from the git tag. **Don't try to edit a version anywhere before tagging.** `pyproject.toml` line 7 says `dynamic = ["version"]`; that's intentional. + +## PyPI OIDC trusted publishing + +The `publish-pypi` job uses the `pypi` GitHub Environment + Trusted Publishing. There is *no API token* anywhere — no `PYPI_API_TOKEN` secret, nothing to rotate. If a publish fails with HTTP 403, the cause is almost always: + +- The GitHub Environment isn't authorised on PyPI's side, OR +- The repo / workflow / environment combination doesn't match the trusted-publisher record on PyPI. + +That's an org-admin task at https://pypi.org/manage/project/pycel2sql/settings/publishing/ — not a release-skill task. + +## After the tag pushes + +Once the workflow goes green: + +1. The GitHub release at `https://github.com/SPANDigital/pycel2sql/releases/tag/` is created with notes from `git log $PREV_TAG..$TAG`. +2. The artifact appears on PyPI within a couple of minutes — sometimes longer if PyPI's CDN is slow. +3. The PyPI badge in `README.md` updates within ~5 minutes. +4. If the auto-generated notes need polishing (e.g. group changes by category), use `gh release edit --notes-file ` — post-edit polish is optional. + +## Verification checklist + +After pushing the tag: + +- [ ] Release workflow succeeded — `gh run list --workflow Release --limit 1`. +- [ ] GitHub release exists at the expected URL with auto-generated notes. +- [ ] `pip index versions pycel2sql` shows the new version. +- [ ] PyPI badge in README updates to the new version. +- [ ] Smoke install: `pip install pycel2sql==` in a fresh venv. + +If the workflow fails mid-publish, **don't simply re-tag with the same version**: PyPI rejects re-uploads of the same version filename. Bump the patch (`v0.3.0` → `v0.3.1`), fix the underlying cause, and tag the new version. + +## Scripts + +- **Run** `bash .claude/skills/release-pycel2sql/scripts/release_preflight.sh []` — validates the working tree (clean, on `main`, in sync with `origin/main`); checks the `CI` workflow is green on `origin/main` HEAD; lists open Dependabot PRs (soft warning); prints the commit log since the previous tag (release-notes preview); if `` is supplied, validates the version-string format, checks the tag doesn't already exist locally or on origin, and prints the exact `git tag -a` / `git push` commands. + +## References + +The relevant configuration files are small enough to read directly when needed (no separate references): + +- `.github/workflows/release.yml` — the workflow definition. +- `.github/workflows/ci.yml` — reused by `release.yml` via `workflow_call` (added `permissions: contents: read` in PR #9). +- `pyproject.toml` lines 1–32 — hatch-vcs configuration; `dynamic = ["version"]` line 7; `[tool.hatch.version] source = "vcs"` line 28; `[tool.hatch.build.hooks.vcs] version-file = "src/pycel2sql/_version.py"` line 31. diff --git a/.claude/skills/release-pycel2sql/scripts/release_preflight.sh b/.claude/skills/release-pycel2sql/scripts/release_preflight.sh new file mode 100755 index 0000000..c6cd01d --- /dev/null +++ b/.claude/skills/release-pycel2sql/scripts/release_preflight.sh @@ -0,0 +1,157 @@ +#!/usr/bin/env bash +# Pre-flight checks for cutting a pycel2sql release. +# +# Usage: +# release_preflight.sh [] +# +# Validates the working tree, checks CI on main, lists open Dependabot PRs, +# previews the commits that will be in the release, and — if is +# supplied — validates its format and prints the tag commands. +# +# Exits non-zero if any hard check fails (wrong branch, dirty tree, out of sync, +# CI red on main, tag collision). Soft warnings (open Dependabot PRs) print +# but don't fail. +set -euo pipefail + +VERSION="${1:-}" + +REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)" +if [ -z "$REPO_ROOT" ]; then + echo "Error: not inside a git repository" >&2 + exit 2 +fi +cd "$REPO_ROOT" + +ERRORS=0 +WARNINGS=0 + +err() { echo " ERROR: $*"; ERRORS=$((ERRORS+1)); } +warn() { echo " WARN: $*"; WARNINGS=$((WARNINGS+1)); } +ok() { echo " OK: $*"; } + +echo "=== Working tree ===" + +# 1. On main? +BRANCH="$(git rev-parse --abbrev-ref HEAD)" +if [ "$BRANCH" != "main" ]; then + err "current branch is '$BRANCH', expected 'main' — switch with: git checkout main" +else + ok "on main" +fi + +# 2. Working tree clean? +if [ -n "$(git status --porcelain)" ]; then + err "working tree has uncommitted changes — commit or stash first" + git status --short | sed 's/^/ /' +else + ok "working tree clean" +fi + +# 3. In sync with origin/main? +git fetch --quiet origin main || warn "could not fetch origin/main" +LOCAL_HEAD="$(git rev-parse HEAD)" +ORIGIN_HEAD="$(git rev-parse origin/main 2>/dev/null || echo unknown)" +if [ "$LOCAL_HEAD" != "$ORIGIN_HEAD" ]; then + AHEAD=$(git rev-list --count "origin/main..HEAD" 2>/dev/null || echo "?") + BEHIND=$(git rev-list --count "HEAD..origin/main" 2>/dev/null || echo "?") + err "local main is ahead $AHEAD / behind $BEHIND of origin/main — pull or push" +else + ok "in sync with origin/main ($LOCAL_HEAD)" +fi + +# 4. Last tag. +LAST_TAG="$(git describe --tags --abbrev=0 2>/dev/null || true)" +echo +if [ -n "$LAST_TAG" ]; then + echo "=== Commits since $LAST_TAG (release-notes preview) ===" + COMMITS_SINCE="$(git log "$LAST_TAG..HEAD" --oneline)" + if [ -z "$COMMITS_SINCE" ]; then + warn "no commits since $LAST_TAG — releasing now would be an empty release" + else + echo "$COMMITS_SINCE" | sed 's/^/ /' + fi +else + echo "=== Commit log (no prior tags) ===" + git log --oneline -20 | sed 's/^/ /' +fi + +# 5. CI status for the current main HEAD. +echo +echo "=== CI on origin/main HEAD ===" +if command -v gh >/dev/null 2>&1; then + HEAD_SHA="$(git rev-parse origin/main 2>/dev/null || echo "")" + ALL_RUNS="$(gh run list --limit 30 --json conclusion,name,status,headSha 2>/dev/null || true)" + if [ -z "$ALL_RUNS" ] || [ "$ALL_RUNS" = "[]" ]; then + warn "could not query GitHub Actions (gh not authed?) — verify CI manually" + else + if ! echo "$ALL_RUNS" | HEAD_SHA="$HEAD_SHA" python3 -c " +import json, os, sys +runs = [r for r in json.load(sys.stdin) if r.get('headSha') == os.environ['HEAD_SHA']] +if not runs: + print(' (no CI runs found yet for this HEAD)') + sys.exit(0) +latest = {} +for r in runs: + latest.setdefault(r.get('name','?'), r) +ci_failed = False +for name, r in latest.items(): + status = r.get('conclusion') or r.get('status') or '?' + advisory = '' if name == 'CI' else ' (advisory)' + print(f' {status:12} {name}{advisory}') + if name == 'CI' and r.get('conclusion') in ('failure', 'cancelled', 'timed_out'): + ci_failed = True +sys.exit(1 if ci_failed else 0) +"; then + err "CI workflow failing on origin/main HEAD — investigate before releasing" + fi + fi +else + warn "gh CLI not installed — verify CI manually at https://github.com/SPANDigital/pycel2sql/actions" +fi + +# 6. Open Dependabot PRs. +echo +echo "=== Open Dependabot PRs ===" +if command -v gh >/dev/null 2>&1; then + DEP_PRS="$(gh pr list --author "app/dependabot" --state open --json number,title 2>/dev/null || true)" + if [ -n "$DEP_PRS" ] && [ "$DEP_PRS" != "[]" ]; then + echo "$DEP_PRS" | python3 -c " +import json, sys +for p in json.load(sys.stdin): + print(f\" #{p['number']:<4} {p['title']}\")" + warn "open Dependabot PRs above — consider merging security ones before tagging" + else + ok "no open Dependabot PRs" + fi +fi + +# 7. Validate version (if supplied) and print the tag commands. +if [ -n "$VERSION" ]; then + echo + echo "=== Version check ===" + STRIPPED="${VERSION#v}" + if [[ ! "$STRIPPED" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then + err "version '$VERSION' does not match vX.Y.Z or vX.Y.Z-qualifier" + else + if [[ "$VERSION" != v* ]]; then + VERSION="v$VERSION" + fi + ok "version '$VERSION' format valid" + if git rev-parse "$VERSION" >/dev/null 2>&1; then + err "tag $VERSION already exists locally — pick a different version" + fi + if git ls-remote --tags origin "refs/tags/$VERSION" 2>/dev/null | grep -q "$VERSION"; then + err "tag $VERSION already exists on origin — pick a different version" + fi + echo + echo "=== Tag commands (run after preflight is clean) ===" + echo " git tag -a $VERSION -m \"Release $VERSION\"" + echo " git push origin $VERSION" + echo " gh run list --workflow Release --limit 1" + echo " gh run watch" + fi +fi + +echo +echo "=== Summary: $ERRORS error(s), $WARNINGS warning(s) ===" +[ "$ERRORS" -eq 0 ]