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
104 changes: 104 additions & 0 deletions .claude/skills/release-pycel2sql/SKILL.md
Original file line number Diff line number Diff line change
@@ -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/<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 <tag> --notes-file <path>` — 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==<version>` 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 [<version>]` — 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 `<version>` 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.
157 changes: 157 additions & 0 deletions .claude/skills/release-pycel2sql/scripts/release_preflight.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
#!/usr/bin/env bash
# Pre-flight checks for cutting a pycel2sql release.
#
# Usage:
# release_preflight.sh [<version>]
#
# Validates the working tree, checks CI on main, lists open Dependabot PRs,
# previews the commits that will be in the release, and — if <version> 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 ]
Loading