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
20 changes: 12 additions & 8 deletions .claude/skills/release-prep/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,36 +1,40 @@
---
name: release-prep
description: Prepare an assembly CLI release — bump the version, run the full gate, then tag to trigger the bottle pipeline. Use when cutting a new release.
description: Prepare an assembly CLI release — confirm main is green, then tag (locally or via the manual workflow) to trigger the bottle pipeline. Use when cutting a new release.
disable-model-invocation: true
---

# release-prep

Drive an `assembly` release to a verified, tagged state. Stop and report at the first failure — never tag on a red check.

## 1. Version bump
## 1. Pick the version

- Update `version` in `pyproject.toml` (`[project]`). Confirm `aai_cli/__init__.py` `__version__` stays in sync (the `version` command reads it).
- With hatch-vcs **the git tag _is_ the version** — there is no `pyproject.toml` / `aai_cli/__init__.py` string to bump. `cut_release.sh` defaults to the next patch above the latest `vX.Y.Z` tag; pass `X.Y.Z` for a minor/major bump.
- Decide the bump (patch/minor/major) from what changed since the last tag; ask the user if it's ambiguous.
- Land the bump via a normal PR (regular CI) before tagging.

## 2. Full gate

```sh
./scripts/check.sh
```

Must end with `All checks passed.` (ruff, mypy, markdownlint, shellcheck, pytest+coverage, build, `twine check --strict`).
Must end with `All checks passed.` (ruff, mypy, markdownlint, shellcheck, pytest+coverage, build, `twine check --strict`). The release builds whatever `main` points at, so confirm `main` is green before tagging.

## 3. Tag to trigger the bottle pipeline

Two equivalent ways to cut the tag — both land on `.github/workflows/release.yml`:

**Local** (from a clean `main` in sync with `origin/main`):

```sh
./scripts/cut_release.sh
./scripts/cut_release.sh # next patch; --dry-run verifies without tagging, --yes skips the prompt
./scripts/cut_release.sh 0.3.0 # explicit version
```

This derives the version from `pyproject.toml`, verifies the tree is clean, on `main`, and in sync with origin, then tags `vX.Y.Z` and pushes it. (`--dry-run` verifies without tagging; `--yes` skips the confirmation prompt.)
**No local checkout** (e.g. a Claude web session on a feature branch): run the **Release** workflow's manual `workflow_dispatch` — GitHub's "Run workflow" button, or the `actions_run_trigger` MCP tool — with an optional `version` input (blank = next patch). Its `tag` job resolves the version and creates+pushes the tag from `main`, then the same run builds and publishes. Set `dry_run: true` to build the bottle for an existing tag without publishing.

The pushed tag triggers `.github/workflows/release.yml`, which:
The tag triggers `.github/workflows/release.yml`, which:

1. Builds the arm64 macOS bottle (`arm64_sonoma`).
2. Creates the `vX.Y.Z` GitHub Release with the bottle attached.
Expand Down
98 changes: 82 additions & 16 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,34 +1,100 @@
name: Release

# Cut a release by pushing a vX.Y.Z tag (after the version-bump PR merges).
# Builds the arm64 macOS bottle, publishes it to the tag's GitHub Release, and
# opens a formula PR (url + sha256 + bottle block) for a maintainer to merge.
# Cut a release one of two ways, both landing on the same bottle pipeline below:
# 1. Push a vX.Y.Z tag (what `scripts/cut_release.sh` does from a clean `main`).
# 2. Run this workflow manually ("Run workflow" / the actions_run_trigger MCP
# tool) — the `tag` job resolves the version, creates the tag, and pushes it,
# so a Claude web session (which works on a feature branch and can't push
# tags itself) can cut a release without a local checkout.
# Either way the pipeline builds the arm64 macOS bottle, publishes it to the tag's
# GitHub Release, and opens a formula PR (url + sha256 + bottle block) to merge.
on:
push:
tags: ["v*"]
# Manual dry-run: build the bottle for an existing tag WITHOUT publishing.
workflow_dispatch:
inputs:
tag:
description: "Existing tag to build a bottle for (dry-run; no publish)"
required: true
version:
description: "Release version X.Y.Z (blank = next patch above the latest vX.Y.Z tag)"
required: false
default: ""
dry_run:
description: "Build the bottle only — don't create the tag, GitHub Release, or formula PR"
type: boolean
default: false

permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.event.inputs.tag || github.ref }}
group: ${{ github.workflow }}-${{ github.event.inputs.version || github.ref }}
cancel-in-progress: false

jobs:
# Resolve the tag the rest of the pipeline builds. On a tag push it already
# exists (just echo it). On a manual real release it's created here from the
# same logic maintainers run locally; on a manual dry run we build an existing
# tag without creating anything. Always runs so `bottle` has a single source
# for the tag regardless of trigger.
tag:
name: resolve release tag
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write # only the manual real-release path pushes a tag; unused otherwise
outputs:
tag: ${{ steps.resolve.outputs.tag }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
# Release from main; full history brings the vX.Y.Z tags cut_release.sh
# bumps from. persist-credentials off (the real-release push below uses
# an explicit tokened remote, matching the publish job).
ref: main
fetch-depth: 0
persist-credentials: false

- name: Resolve the release tag
id: resolve
env:
EVENT_NAME: ${{ github.event_name }}
REF_NAME: ${{ github.ref_name }}
INPUT_VERSION: ${{ github.event.inputs.version }}
DRY_RUN: ${{ github.event.inputs.dry_run }}
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
if [ "$EVENT_NAME" = "push" ]; then
tag="$REF_NAME"
elif [ "$DRY_RUN" = "true" ]; then
# Build a bottle for an already-existing tag without publishing.
if [ -n "$INPUT_VERSION" ]; then
tag="v${INPUT_VERSION}"
else
tag="$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname | head -n1)"
fi
[ -n "$tag" ] || { echo "no existing vX.Y.Z tag to dry-run" >&2; exit 1; }
else
# Real manual release: resolve + validate + create the tag locally via
# the same script maintainers run, then push it with an explicit
# tokened remote (persist-credentials is off above).
extra=()
[ -n "$INPUT_VERSION" ] && extra+=("$INPUT_VERSION")
./scripts/cut_release.sh --yes --no-push "${extra[@]}"
tag="$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname | head -n1)"
git push "https://x-access-token:${GH_TOKEN}@github.com/${REPO}.git" "refs/tags/${tag}"
fi
echo "tag=${tag}" >> "$GITHUB_OUTPUT"

bottle:
name: build arm64 bottle (macOS)
needs: [tag]
runs-on: macos-14
timeout-minutes: 40
permissions:
contents: read
outputs:
tag: ${{ steps.meta.outputs.tag }}
tag: ${{ needs.tag.outputs.tag }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
Expand All @@ -37,28 +103,26 @@ jobs:
# commit SHA like every other action here — Dependabot keeps it current.
- uses: Homebrew/actions/setup-homebrew@2ebcf16054461267868620b1414507f3ccc765c1

- name: Resolve tag + source sha256
- name: Resolve source sha256
id: meta
env:
# Pass via env (not inline ${{ }}) to satisfy zizmor template-injection.
INPUT_TAG: ${{ github.event.inputs.tag }}
REF_NAME: ${{ github.ref_name }}
TAG: ${{ needs.tag.outputs.tag }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
tag="${INPUT_TAG:-$REF_NAME}"
tag="$TAG"
url="https://github.com/${REPO}/archive/refs/tags/${tag}.tar.gz"
curl -fL "$url" -o source.tar.gz
sha="$(shasum -a 256 source.tar.gz | awk '{print $1}')"
{
echo "tag=${tag}"
echo "source_sha=${sha}"
echo "root_url=https://github.com/${REPO}/releases/download/${tag}"
} >> "$GITHUB_OUTPUT"

- name: Pin the formula to the release tag
env:
TAG: ${{ steps.meta.outputs.tag }}
TAG: ${{ needs.tag.outputs.tag }}
SOURCE_SHA: ${{ steps.meta.outputs.source_sha }}
REPO: ${{ github.repository }}
run: |
Expand Down Expand Up @@ -114,7 +178,9 @@ jobs:
publish:
name: publish release + open formula PR
needs: [bottle]
if: github.event_name == 'push'
# Publish for real tag pushes and for manual real releases; skip on a manual
# dry run (which only builds the bottle to prove the formula installs).
if: ${{ github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ structured so independent changes stay in disjoint files. Keep it that way:
- The **package/module** is `aai_cli`; the **distribution** name is `aai-cli`; the **console command** is `assembly` (`[project.scripts] assembly = "aai_cli.main:run"`).
- `assembly init` templates live in `aai_cli/init/templates/` and are **committed**, including renamed dotfiles (`gitignore` → `.gitignore`, `env.example`). The wheel force-includes them via `[tool.hatch.build.targets.wheel] artifacts`, excluding `__pycache__/*.pyc`. Editing templates needs care — see the parametrized contract tests (`tests/test_init_template_*.py`).
- `audioop` left the stdlib in 3.13; `audioop-lts` backfills it (conditional dependency). Supported Pythons: 3.12–3.13.
- **Releasing is tag-triggered.** The version is **derived from the git tag** by hatch-vcs and written to a gitignored `aai_cli/_version.py` at build time — there is no version string to keep in sync across `pyproject.toml` or `aai_cli/__init__.py`, and `bump_patch.sh` no longer exists. To cut a release, run `scripts/cut_release.sh` from a clean `main` in sync with `origin/main`: no argument → next patch above the latest `vX.Y.Z` tag; `cut_release.sh X.Y.Z` → explicit version. It tags + pushes, which fires `.github/workflows/release.yml` — that builds the prebuilt arm64 Homebrew bottle (`Formula/assembly.rb`), cuts the GitHub Release, and opens the formula PR. Bottling matters because the deps include Rust-backed sdists (`pydantic-core`, `jiter`, `cryptography`) that would otherwise compile from source on `brew install`. The Homebrew formula builds from a git-less GitHub source tarball, so `Formula/assembly.rb`'s `def install` sets the generic `SETUPTOOLS_SCM_PRETEND_VERSION` env var (installing resources first under a clean env, then setting the var for our package only) to feed the tag version to the build. **`cut_release.sh` only runs from a clean `main` in sync with `origin/main`** (it hard-errors on a feature branch / dirty tree), so cut releases from `main`, not your working branch. The "update available" notice users see is `aai_cli/update_check.py`.
- **Releasing is tag-triggered.** The version is **derived from the git tag** by hatch-vcs and written to a gitignored `aai_cli/_version.py` at build time — there is no version string to keep in sync across `pyproject.toml` or `aai_cli/__init__.py`, and `bump_patch.sh` no longer exists. To cut a release, run `scripts/cut_release.sh` from a clean `main` in sync with `origin/main`: no argument → next patch above the latest `vX.Y.Z` tag; `cut_release.sh X.Y.Z` → explicit version. It tags + pushes, which fires `.github/workflows/release.yml` — that builds the prebuilt arm64 Homebrew bottle (`Formula/assembly.rb`), cuts the GitHub Release, and opens the formula PR. **You don't need a local checkout to release:** `release.yml` also has a manual `workflow_dispatch` (GitHub's "Run workflow" button, or `actions_run_trigger` from a Claude web session) taking an optional `version` input — its `tag` job resolves the version and creates+pushes the tag (reusing `cut_release.sh --no-push`), and the rest of the pipeline then runs in that same workflow run. Tag creation lives *inside* the release run on purpose: a `GITHUB_TOKEN` tag push wouldn't re-trigger the `on: push` half, so a separate "push the tag" workflow would silently never build. (`dry_run: true` builds the bottle for an existing tag without publishing.) Bottling matters because the deps include Rust-backed sdists (`pydantic-core`, `jiter`, `cryptography`) that would otherwise compile from source on `brew install`. The Homebrew formula builds from a git-less GitHub source tarball, so `Formula/assembly.rb`'s `def install` sets the generic `SETUPTOOLS_SCM_PRETEND_VERSION` env var (installing resources first under a clean env, then setting the var for our package only) to feed the tag version to the build. **`cut_release.sh` only runs from a clean `main` in sync with `origin/main`** (it hard-errors on a feature branch / dirty tree), so cut releases from `main`, not your working branch. The "update available" notice users see is `aai_cli/update_check.py`.

## Manual QA / running the CLI in sandboxed sessions

Expand Down
15 changes: 13 additions & 2 deletions scripts/cut_release.sh
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
#!/bin/sh
# Cut an AssemblyAI CLI release: tag the version and push the tag, which triggers
# .github/workflows/release.yml (builds the arm64 bottle, creates the GitHub
# Release, opens the formula PR).
# Release, opens the formula PR). release.yml's manual "Run workflow" dispatch
# runs these same steps in CI — so a Claude web session can cut a release with no
# local checkout — by calling this script with --no-push and pushing the tag itself.
#
# With hatch-vcs the git tag IS the version — there is no version file to bump
# or version-bump PR to merge first. By default the script tags the next patch
Expand All @@ -11,16 +13,19 @@
# ./scripts/cut_release.sh 0.2.0 # tag an explicit version instead
# ./scripts/cut_release.sh --yes # skip the interactive confirmation
# ./scripts/cut_release.sh -n # dry run: verify only, don't tag or push
# ./scripts/cut_release.sh --no-push # create the tag locally, don't push it
set -eu

ASSUME_YES=0
DRY_RUN=0
NO_PUSH=0
for arg in "$@"; do
case "$arg" in
-y | --yes) ASSUME_YES=1 ;;
-n | --dry-run) DRY_RUN=1 ;;
--no-push) NO_PUSH=1 ;;
-h | --help)
sed -n '2,13p' "$0" | sed 's/^# \{0,1\}//'
sed -n '2,16p' "$0" | sed 's/^# \{0,1\}//'
exit 0
;;
[0-9]*.[0-9]*.[0-9]*) EXPLICIT_VERSION="$arg" ;;
Expand Down Expand Up @@ -107,6 +112,12 @@ if [ "$ASSUME_YES" -ne 1 ]; then
fi

git tag -a "$tag" -m "Release ${tag}"

if [ "$NO_PUSH" -eq 1 ]; then
info "Created tag ${tag} locally; --no-push set, leaving the push to the caller."
exit 0
fi

info "Created tag ${tag}. Pushing..."
git push origin "$tag"

Expand Down
Loading