diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md new file mode 100644 index 00000000..00566f3f --- /dev/null +++ b/.claude/skills/release/SKILL.md @@ -0,0 +1,133 @@ +--- +name: release +description: Cut a remo release — drive the release-please stable release PR, or build/validate/publish a pre-release (RC) — with a mandatory local "test before PyPI" gate and explicit approval before anything is tagged, merged, or published. +argument-hint: "[stable | rc [X.Y.ZrcN]] — omit to be asked" +metadata: + author: remo +--- + +# Release + +Operationalizes remo's two-lane release process (see +[CONTRIBUTING.md](../../../CONTRIBUTING.md) → Release Process). Pairs with the +release-please integration. + +**Non-negotiable safety rules — apply in every mode:** + +- **Publishing to PyPI is irreversible.** A version, once uploaded, can never be + replaced. Run the local build + smoke gate (Step V below) before any tag, + merge, or publish — no exceptions. +- **Never** `git push` a tag, `git push` a version-bump commit, or `gh pr merge` + a release PR **without explicit user approval in the current turn.** Approval + from an earlier task does not carry over. +- Work only from a **clean working tree** on an **up-to-date `main`**. If the + tree is dirty or `main` is behind `origin/main`, stop and surface it. +- Prefer testing **without** publishing. Only publish an RC to PyPI when the user + explicitly asks for a public prerelease. + +## Step 0 — Determine the mode + +From the argument, or by asking the user: + +- **`stable`** — drive the release-please release PR to a published stable release. +- **`rc`** — build/validate a pre-release, optionally publishing it. + +Then run `git fetch origin` and verify `git status` is clean and `main` is +up to date (`git rev-parse HEAD` == `git rev-parse origin/main`). + +--- + +## Stable lane (release-please owns the version + tag) + +1. `git checkout main && git pull --ff-only`. +2. Find the open release PR (release-please titles it `chore(main): release X.Y.Z`): + ```bash + gh pr list --state open --json number,title,headRefName,url \ + --search 'chore(main): release in:title' + ``` + If none exists, tell the user release-please has not opened one (no + releasable `feat:`/`fix:` commits since the last release) and **stop**. +3. Show the proposed bump + changelog for review: + ```bash + gh pr diff -- pyproject.toml CHANGELOG.md + ``` +4. **Run the validation gate (Step V) on the PR's head branch** so you build the + exact version that will publish. +5. **Only on explicit approval**, merge it (release-please then tags `vX.Y.Z`, + which triggers `release.yml` to publish to PyPI + GHCR): + ```bash + gh pr merge --squash + ``` + Before merging, confirm the `RELEASE_PLEASE_TOKEN` secret is set — without it + the tag will not trigger the publish. If it's missing, warn the user and let + them decide. +6. Return to `main`, `git pull --ff-only`, report the new tag, and offer to watch + the release CI (`gh run watch` / `gh pr checks`). + +--- + +## RC lane (manual — release-please stays out) + +1. `git checkout main && git pull --ff-only`. +2. Determine the RC version `X.Y.ZrcN` (PEP 440 form, **no separator**): + - `X.Y.Z` is the next target version (feat → minor, fix → patch over the last + stable tag). + - `N` increments from the last `vX.Y.Z-rcM` tag for the same `X.Y.Z` + (`git tag --list "vX.Y.Z-rc*"`), else `1`. + - Confirm the chosen version with the user. +3. Bump `pyproject.toml` `[project].version` to `X.Y.ZrcN` (optionally run + `uv lock` to keep the lockfile's version in step). +4. **Run the validation gate (Step V).** +5. Ask the user which outcome they want: + - **Local test only (default, no PyPI):** revert the bump + (`git checkout pyproject.toml uv.lock`), then hand off the built wheel + (`dist/*.whl`) or the git-install one-liner + (`uv tool install --force "git+https://github.com/get2knowio/remo.git@"`). + Nothing is committed, tagged, or published. + - **CI dev build for cross-machine testing (no PyPI):** trigger the + `dev-build.yml` workflow, which builds the wheel in clean CI and uploads it + as a run artifact. The stamped version carries a `+g` local segment, so + it is unique and can never reach PyPI. Nothing is committed or tagged. + ```bash + gh workflow run dev-build.yml -f version=X.Y.ZrcN # omit -f for an auto dev version + gh run watch # wait for it to finish + # then, on ANY machine: + gh run download -n remo-wheel -D ./dl + uv tool install --force ./dl/remo_cli-*.whl + ``` + - **Publish a prerelease (only on explicit approval):** + ```bash + git commit -am "chore(release): X.Y.ZrcN" + git tag vX.Y.Z-rcN + git push origin main vX.Y.Z-rcN + ``` + `release.yml` detects the `rc` suffix, publishes the prerelease to PyPI + + GHCR, and never moves `latest`. Offer to watch CI. + +--- + +## Step V — Validation gate (test the exact wheel before PyPI) + +Run this on whatever ref will be released (the release-please PR head for stable, +or the bumped working tree for an RC). The published version comes from +`pyproject.toml` via `uv build`, so this builds the identical artifact: + +```bash +uv run pytest -q # full suite must pass +uv build # -> dist/remo_cli--py3-none-any.whl +VENV="$(mktemp -d)/venv" +uv venv "$VENV" +uv pip install --python "$VENV/bin/python" ./dist/remo_cli--py3-none-any.whl +"$VENV/bin/remo" --version # MUST equal +"$VENV/bin/remo" --help # sanity-check the CLI loads +``` + +Report the results. If tests fail, the wheel version doesn't match, or the CLI +doesn't load, **stop** and surface it — do not proceed to tag/merge/publish. + +## Done when + +- The requested lane completed through the point the user approved (validated + only; RC wheel handed off; RC published; or stable PR merged). +- No tag was pushed, commit was pushed, or PR merged without explicit approval. +- The outcome (and, if published, the immutable version) was reported clearly. diff --git a/.github/workflows/dev-build.yml b/.github/workflows/dev-build.yml new file mode 100644 index 00000000..f46520ef --- /dev/null +++ b/.github/workflows/dev-build.yml @@ -0,0 +1,78 @@ +name: dev-build + +# Build an installable wheel and upload it as a run artifact you can download and +# test on ANY machine — no git tag, no PyPI, no version reserved. +# +# The stamped version always carries a PEP 440 *local* segment (`+g`), +# which PyPI rejects outright — so a dev build can never leak to PyPI, and every +# build is uniquely identifiable. +# +# Trigger manually: +# gh workflow run dev-build.yml # -> 2.2.0.dev+g +# gh workflow run dev-build.yml -f version=2.3.0rc1 # -> 2.3.0rc1+g +# +# Then, on any machine: +# gh run download -n remo-wheel -D ./dl +# uv tool install --force ./dl/remo_cli-*.whl + +on: + workflow_dispatch: + inputs: + version: + description: "Full base version to stamp (e.g. 2.3.0rc1, 2.3.0). Blank = auto dev version from pyproject. A +g local segment is always appended." + required: false + default: "" + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + + - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + + - name: Compute dev version + id: ver + env: + INPUT_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + SHA=$(git rev-parse --short HEAD) + if [ -n "$INPUT_VERSION" ]; then + BASE="$INPUT_VERSION" + else + # Core X.Y.Z from pyproject, plus a per-run .dev segment. + CORE=$(grep -m1 '^version = ' pyproject.toml \ + | sed -E 's/version = "([0-9]+\.[0-9]+\.[0-9]+).*"/\1/') + BASE="${CORE}.dev${GITHUB_RUN_NUMBER}" + fi + # Always append the local segment: unique + un-uploadable to PyPI. + VERSION="${BASE}+g${SHA}" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Building version: $VERSION" + + - name: Stamp version into pyproject.toml + env: + VERSION: ${{ steps.ver.outputs.version }} + run: | + set -euo pipefail + sed -i -E "0,/^version = \".*\"/s//version = \"${VERSION}\"/" pyproject.toml + grep -m1 '^version = ' pyproject.toml + + - name: Build wheel + sdist + run: uv build + + - name: Upload artifact + # NOTE: SHA-pin to match the repo's action-pinning convention once the + # v4 commit SHA is available (e.g. `@ # v4`). + uses: actions/upload-artifact@v4 + with: + name: remo-wheel + path: dist/* + retention-days: 30 + if-no-files-found: error