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
3 changes: 1 addition & 2 deletions .claude/skills/check/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Run the project's canonical verification gate and report the result.
./scripts/check.sh
```

This runs, in order: `uv lock --check` → `ruff check` → `ruff format --check` → `mypy` (src + tests) → `pyright` (src strict, then tests) → `vulture` (dead code) → `deptry` (dependency hygiene) → `lint-imports` (architecture contracts) → `xenon` (cyclomatic complexity, max grade B / project avg A) → `swiftlint` + swift compile (macOS only) → `markdownlint` (excludes generated `docs/`) → `prettier` (init template JS/CSS) → `shellcheck install.sh scripts/check.sh` → generated `--show-code` compile gate → init template contract gate → `pytest` with a **90% branch-coverage gate** (`--cov-fail-under=90`, excluding `e2e`/`install`/`install_script` markers) → `diff-cover` (100% patch coverage vs `origin/main`) → a "no new escape hatches" diff gate → `uv build` + `twine check --strict`. Everything Python runs through `uv run` against the locked environment.
This runs, in order: `uv lock --check` → `ruff check` → `ruff format --check` → `mypy` (src + tests) → `pyright` (src strict, then tests) → `vulture` (dead code) → `deptry` (dependency hygiene) → `lint-imports` (architecture contracts) → `xenon` (cyclomatic complexity, max grade B / project avg A) → `swiftlint` + swift compile (macOS only) → `markdownlint` (excludes generated `docs/`) → `prettier` (init template JS/CSS) → `shellcheck scripts/check.sh` → generated `--show-code` compile gate → init template contract gate → `pytest` with a **90% branch-coverage gate** (`--cov-fail-under=90`, excluding `e2e`/`install` markers) → `diff-cover` (100% patch coverage vs `origin/main`) → a "no new escape hatches" diff gate → `uv build` + `twine check --strict`. Everything Python runs through `uv run` against the locked environment.

Heads-up on the stages `ruff`+`mypy` don't cover: `vulture` flags unused code, `deptry` flags unused/missing/misplaced dependencies, `lint-imports` enforces the import-architecture contracts in `.importlinter`, and `xenon` fails any function over cyclomatic-complexity grade B (CC > 10). These are the ones that most often surprise an otherwise-clean change.

Expand All @@ -28,7 +28,6 @@ Run these only when relevant — they are slow and/or need credentials:

```sh
uv run pytest -m e2e # real-API end-to-end; needs ASSEMBLYAI_API_KEY + kokoro
uv run pytest -m install_script # builds a wheel and runs install.sh for real; needs network + uv/pipx
```

## Notes
Expand Down
38 changes: 23 additions & 15 deletions .claude/skills/release-prep/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
---
name: release-prep
description: Prepare an assembly CLI release — bump the version, run the full gate, build and validate the wheel/sdist, and smoke-test the real install. Use when cutting a new release.
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.
disable-model-invocation: true
---

# release-prep

Drive an `assembly` release to a verified, publishable state. Stop and report at the first failure — never push or publish on a red check.
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

- Update `version` in `pyproject.toml` (`[project]`). Confirm `aai_cli/__init__.py` `__version__` stays in sync (the `version` command and install smoke test read it).
- Update `version` in `pyproject.toml` (`[project]`). Confirm `aai_cli/__init__.py` `__version__` stays in sync (the `version` command reads it).
- 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

Expand All @@ -21,26 +22,33 @@ Drive an `assembly` release to a verified, publishable state. Stop and report at

Must end with `All checks passed.` (ruff, mypy, markdownlint, shellcheck, pytest+coverage, build, `twine check --strict`).

## 3. Real install smoke test
## 3. Tag to trigger the bottle pipeline

```sh
uv run pytest -q -m install_script
./scripts/cut_release.sh
```

This builds the wheel and runs `install.sh` for real (pipx + pip --user), asserting `assembly` runs. Needs network + uv/pipx.
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.)

## 4. Build + metadata validation
The pushed tag triggers `.github/workflows/release.yml`, which:

```sh
rm -rf dist && uv build && uvx twine check --strict dist/*
```
1. Builds the arm64 macOS bottle (`arm64_sonoma`).
2. Creates the `vX.Y.Z` GitHub Release with the bottle attached.
3. Opens a `release/vX.Y.Z-formula` PR pinning the formula to the tag's source and adding the `bottle do` block.

Confirm both an sdist and a wheel are produced and the README renders for PyPI.
## 4. Merge the formula PR

## Distribution caveat
Review the `release/vX.Y.Z-formula` PR (formula-only diff) and merge it with the repo-admin **"merge without waiting for requirements"** override — a PR opened by `GITHUB_TOKEN` doesn't trigger CI, so the required check won't report on its own.

## 5. Verify the bottle

The PyPI name **`assemblyai-cli` is squatted by a third party** — do **not** assume `pip install assemblyai-cli` resolves to this project. Publishing/distribution currently goes through `install.sh` (git install via pipx / pip --user) and any Homebrew tap, not that PyPI name. Flag this if a release step assumes the squatted name.
On a clean arm64 Mac:

## Output
```sh
brew update && brew install assembly # pulls the bottle — fast, no rust/llvm
assembly --version # matches the tagged version
```

## Distribution caveat

Report the version bumped to, the gate result (with output tail), and confirm `dist/` contains a validated wheel + sdist. Only then is the release ready to tag/push.
The PyPI name **`assemblyai-cli` is squatted by a third party** — `pip install assemblyai-cli` does **not** resolve to this project. Distribution is the **Homebrew bottle** (primary, macOS arm64) and **pipx/uv `git+https`** (fallback, all platforms). There is no PyPI publish step.
42 changes: 0 additions & 42 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -190,48 +190,6 @@ jobs:
# Append `--ignore-vuln <ID>` to accept an unfixable transitive advisory.
python -m pip_audit

install-smoke:
name: package install — real (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
kfilter: "" # all branches: pipx + pip --user + uv tool
- os: macos-latest
kfilter: '-k "pipx or uv_tool"' # pip --user is flaky on macOS (PEP 668)
runs-on: ${{ matrix.os }}
timeout-minutes: 15
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false # no job pushes; don't leave the token in .git/config
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"
cache: pip

# `assembly --version` imports the package, which pulls in sounddevice (needs
# PortAudio) and ffmpeg-backed sources. Match the other jobs' system deps.
- name: System deps (Linux)
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y libportaudio2 ffmpeg
- name: System deps (macOS)
if: runner.os == 'macOS'
run: brew install portaudio ffmpeg

# Use the system interpreter (no virtualenv) so install.sh's `pip --user`
# branch is allowed. Editable install makes `aai_cli` importable for the
# test's __version__ check; uv builds the wheel and drives the uv tool
# branch; pipx drives the pipx branch (which doubles as `pipx install`).
- name: Tooling
run: |
python -m pip install --upgrade pip # need pip >= 25.1 for --group
python -m pip install -e . --group dev uv pipx

- name: Real install smoke
run: python -m pytest -q -m install_script ${{ matrix.kfilter }}

formula-install:
# Real `brew install` of the Homebrew formula, built from THIS branch's source.
# macOS only: that's where the README points brew users first, and where the
Expand Down
159 changes: 159 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
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.
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

permissions:
contents: read

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

jobs:
bottle:
name: build arm64 bottle (macOS)
runs-on: macos-14
timeout-minutes: 40
permissions:
contents: read
outputs:
tag: ${{ steps.meta.outputs.tag }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false # this job doesn't push
# Homebrew/actions is a monorepo (setup-homebrew is a subpath); pin it to a
# commit SHA like every other action here — Dependabot keeps it current.
- uses: Homebrew/actions/setup-homebrew@2ebcf16054461267868620b1414507f3ccc765c1

- name: Resolve tag + 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 }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
tag="${INPUT_TAG:-$REF_NAME}"
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 }}
SOURCE_SHA: ${{ steps.meta.outputs.source_sha }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
python3 - <<'PY'
import os, re, pathlib
tag, sha, repo = os.environ["TAG"], os.environ["SOURCE_SHA"], os.environ["REPO"]
url = f"https://github.com/{repo}/archive/refs/tags/{tag}.tar.gz"
p = pathlib.Path("Formula/assembly.rb")
src = p.read_text()
src = re.sub(r'url ".*?"', f'url "{url}"', src, count=1)
src = re.sub(r"sha256 .*", f'sha256 "{sha}"', src, count=1)
p.write_text(src)
PY
grep -nE '^ (url|sha256) ' Formula/assembly.rb | head -2

- name: Build the bottle + merge the block into the formula
env:
ROOT_URL: ${{ steps.meta.outputs.root_url }}
run: |
set -euo pipefail
brew tap-new --no-git assembly/local
tap_formula="$(brew --repository assembly/local)/Formula/assembly.rb"
cp Formula/assembly.rb "$tap_formula"
brew install --build-bottle --formula assembly/local/assembly
brew bottle --json --no-rebuild --root-url="$ROOT_URL" assembly/local/assembly
for f in assembly--*.bottle.tar.gz; do mv "$f" "${f/--/-}"; done
shopt -s nullglob
tarballs=( assembly-*.bottle.tar.gz )
shopt -u nullglob
if [[ ${#tarballs[@]} -ne 1 ]]; then
echo "Expected exactly one bottle tarball, found ${#tarballs[@]}: ${tarballs[*]:-none}" >&2
exit 1
fi
brew bottle --merge --write --no-commit assembly--*.bottle.json
cp "$tap_formula" Formula/assembly.rb
echo "--- finalized formula head ---"
sed -n '1,20p' Formula/assembly.rb

- name: Upload bottle + finalized formula
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: release-artifacts
path: |
assembly-*.bottle.tar.gz
Formula/assembly.rb
if-no-files-found: error

publish:
name: publish release + open formula PR
needs: [bottle]
if: github.event_name == 'push'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false # push via explicit tokened remote instead

- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: release-artifacts
path: artifacts

- name: Create the GitHub Release with the bottle attached
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.bottle.outputs.tag }}
run: |
set -euo pipefail
if ! gh release view "$TAG" >/dev/null 2>&1; then
gh release create "$TAG" --title "$TAG" --generate-notes
fi
gh release upload "$TAG" artifacts/assembly-*.bottle.tar.gz --clobber

- name: Open the formula PR
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.bottle.outputs.tag }}
GITHUB_REPOSITORY: ${{ github.repository }}
run: |
set -euo pipefail
branch="release/${TAG}-formula"
cp artifacts/Formula/assembly.rb Formula/assembly.rb
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -B "$branch"
git add Formula/assembly.rb
git commit -m "Bottle ${TAG}: pin url + sha256, add arm64_sonoma bottle"
git push --force "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "$branch"
if ! gh pr view "$branch" >/dev/null 2>&1; then
gh pr create --base main --head "$branch" \
--title "Bottle ${TAG}" \
--body "Automated by release.yml: pins the formula to the ${TAG} source tarball and adds the arm64_sonoma bottle block. Merge with the admin override (a GITHUB_TOKEN PR does not trigger CI, so the required check will not report). The diff is formula-only."
fi
11 changes: 5 additions & 6 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ repos:
# Lint the GitHub Actions workflows themselves (the jobs in ci.yml). Both hooks
# self-scope to .github/workflows/. actionlint-py is the pip-packaged wrapper
# (bundles the actionlint binary; no Go/Docker), and brings shellcheck-py so
# embedded `run:` shell is shellcheck'd the same way check.sh checks install.sh.
# embedded `run:` shell is shellcheck'd the same way check.sh checks scripts/check.sh.
- repo: https://github.com/Mateusz-Grzelinski/actionlint-py
rev: v1.7.12.24
hooks:
Expand All @@ -48,11 +48,10 @@ repos:
# them the moment an upstream release adds a value.
- id: pytest
name: pytest
# Mirror check.sh: exclude both e2e (real API) and install_script (builds a
# wheel + runs install.sh / pip --user). install_script has its own dedicated
# "package install — real" CI jobs; running it here is slow and breaks under
# the locked .venv (the user-site install can't import aai_cli).
entry: uv run --frozen python -m pytest -q -m "not e2e and not install_script"
# Mirror check.sh: exclude both e2e (real API) and install (real
# init-template dependency installs — slow, needs network). Running either
# here would be slow and would reach the network under the locked .venv.
entry: uv run --frozen python -m pytest -q -m "not e2e and not install"
language: system
types: [python]
pass_filenames: false
Expand Down
5 changes: 2 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,11 @@ uv run python scripts/mutation_sweep.py aai_cli/config.py # or omit paths for

### Test markers

The default suite **excludes** three slow/credentialed marker sets — `pyproject.toml`'s `addopts` carries `-m "not e2e and not install and not install_script"`, so a bare `pytest` matches what `check.sh` gates. An explicit command-line `-m` overrides it for the opt-in runs:
The default suite **excludes** two slow/credentialed marker sets — `pyproject.toml`'s `addopts` carries `-m "not e2e and not install"`, so a bare `pytest` matches what `check.sh` gates. An explicit command-line `-m` overrides it for the opt-in runs:

```sh
uv run pytest -m e2e # real-API end-to-end; needs ASSEMBLYAI_API_KEY, else skips
uv run pytest -m install # installs each init template's requirements for real; needs network + uv
uv run pytest -m install_script # builds a wheel and runs install.sh for real; needs network + uv/pipx
```

`check.sh` runs the default suite with a **90% branch-coverage gate** (`--cov-fail-under=90`). New code generally needs tests to clear that gate.
Expand All @@ -68,7 +67,7 @@ CLI output is pinned by **syrupy snapshot tests** (`tests/__snapshots__/*.ambr`)

The post-edit hook (`.claude/settings.json`) runs `ruff check --fix --unfixable F401` + `ruff format` on every edited `*.py`. `--unfixable F401` means a just-added import is **not** auto-deleted while it's momentarily unused — so adding an import in one edit and its usage in the next is safe. The flip side: a genuinely unused import survives the hook and only fails at `ruff check` in the gate, so still prefer making the import and its first usage land in the same edit.

The suite is hermetic by construction, enforced three ways (`tests/conftest.py` + `pyproject.toml` `[tool.pytest.ini_options]`): **pytest-randomly** shuffles order, an autouse `pin_timezone` fixture pins `TZ` to a fixed non-UTC zone (UTC-normalized rendering must be unaffected; use **time-machine** to freeze `now`), and **pytest-socket** (`--disable-socket`) blocks real network so an unmocked SDK/HTTP call fails loudly instead of hitting the API. A test that only binds a loopback server opts back in with the tight `@pytest.mark.allow_hosts(["127.0.0.1"])` (still blocks external hosts). The `e2e`/`install`/`install_script` marker suites legitimately reach the real network in-process (PyPI reachability probes, real-API runs), so a `pytest_collection_modifyitems` hook in `conftest.py` auto-grants them full sockets — adding a network marker is all that's needed, no per-test `enable_socket`.
The suite is hermetic by construction, enforced three ways (`tests/conftest.py` + `pyproject.toml` `[tool.pytest.ini_options]`): **pytest-randomly** shuffles order, an autouse `pin_timezone` fixture pins `TZ` to a fixed non-UTC zone (UTC-normalized rendering must be unaffected; use **time-machine** to freeze `now`), and **pytest-socket** (`--disable-socket`) blocks real network so an unmocked SDK/HTTP call fails loudly instead of hitting the API. A test that only binds a loopback server opts back in with the tight `@pytest.mark.allow_hosts(["127.0.0.1"])` (still blocks external hosts). The `e2e`/`install` marker suites legitimately reach the real network in-process (PyPI reachability probes, real-API runs), so a `pytest_collection_modifyitems` hook in `conftest.py` auto-grants them full sockets — adding a network marker is all that's needed, no per-test `enable_socket`.

### Writing tests that pass the diff gates

Expand Down
Loading
Loading