diff --git a/.claude/skills/check/SKILL.md b/.claude/skills/check/SKILL.md index 381e1007..9e635ce3 100644 --- a/.claude/skills/check/SKILL.md +++ b/.claude/skills/check/SKILL.md @@ -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. @@ -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 diff --git a/.claude/skills/release-prep/SKILL.md b/.claude/skills/release-prep/SKILL.md index 588a37a8..ae747718 100644 --- a/.claude/skills/release-prep/SKILL.md +++ b/.claude/skills/release-prep/SKILL.md @@ -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 @@ -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. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8fc5a41a..045fb5af 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -190,48 +190,6 @@ jobs: # Append `--ignore-vuln ` 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..4220f8b6 --- /dev/null +++ b/.github/workflows/release.yml @@ -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 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index af2ad0e8..0b6796a7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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: @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 75c5f826..bcaaffc9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. @@ -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 diff --git a/README.md b/README.md index b363d797..b3269d06 100644 --- a/README.md +++ b/README.md @@ -31,10 +31,11 @@ ```sh brew tap assemblyai/cli https://github.com/AssemblyAI/cli -brew install --HEAD assembly +brew trust assemblyai/cli +brew install assembly ``` -`brew install` pulls in `ffmpeg` and `portaudio` for you, so `transcribe`, `stream`, and `agent` work out of the box. While the repo is private and pre-release, `--HEAD` builds from the latest `main` (the tap clone uses your existing GitHub credentials); once the first release is tagged, drop the flag and just `brew install assembly`. Upgrade with `brew upgrade --fetch-HEAD assembly`; remove with `brew uninstall assembly`. +`brew install` pulls in `ffmpeg` and `portaudio` for you, so `transcribe`, `stream`, and `agent` work out of the box. Releases ship a prebuilt arm64 bottle, so `brew install`/`brew upgrade assembly` is a fast binary install (no compiler toolchain); Intel Macs build from source or can use the pipx/uv path below. Remove with `brew uninstall assembly`. ### pipx / uv @@ -48,14 +49,6 @@ uv tool install "git+https://github.com/AssemblyAI/cli.git" Requires Python 3.12+. Microphone and speaker support (for `stream` and `agent`) is included by default via [`sounddevice`](https://python-sounddevice.readthedocs.io) — its macOS and Windows wheels bundle PortAudio. On Linux, install the runtime once: `sudo apt-get install libportaudio2`. You'll also want [`ffmpeg`](https://ffmpeg.org) on `PATH` to decode non-WAV/URL audio. -### One-liner - -```sh -curl -fsSL https://raw.githubusercontent.com/AssemblyAI/cli/main/install.sh | sh -``` - -Prefers [`pipx`](https://pipx.pypa.io), falling back to `pip --user`. - ## Quick Start ```sh @@ -306,12 +299,21 @@ assembly stream --help This project uses [uv](https://docs.astral.sh/uv/). Run tools through `uv run` so they use the locked environment (`pyproject.toml` + `uv.lock`): ```sh -uv sync --extra dev # create/refresh the venv with dev dependencies +uv sync # create/refresh the venv (the dev group installs by default) uv run assembly --help # run the CLI from the locked environment uv run pytest # run the test suite (uv run mypy / ruff likewise) ./scripts/check.sh # ruff + mypy + pytest — the same checks CI runs on every PR ``` +### Releasing + +Releases ship as a prebuilt arm64 Homebrew bottle, so `brew install`/`brew upgrade assembly` is a fast binary install. To cut one: + +1. Bump `version` in `pyproject.toml` (keep `aai_cli/__init__.py` `__version__` in sync) and merge that PR. +2. From an up-to-date `main`, run `./scripts/cut_release.sh` — it checks the tree is clean, on `main`, and in sync with origin, then tags `vX.Y.Z` and pushes it. (`--dry-run` verifies without tagging.) +3. The tag triggers [`.github/workflows/release.yml`](.github/workflows/release.yml): it builds the bottle, creates the GitHub Release, and opens a `release/vX.Y.Z-formula` PR pinning the formula and adding the bottle block. +4. Merge that formula PR with the repo-admin "merge without waiting for requirements" override — a `GITHUB_TOKEN`-opened PR doesn't trigger CI, so the required check won't report on its own. + ## License Released under the [MIT license](LICENSE). diff --git a/docs/superpowers/plans/2026-06-11-bottle-release-pipeline.md b/docs/superpowers/plans/2026-06-11-bottle-release-pipeline.md new file mode 100644 index 00000000..98cc33a8 --- /dev/null +++ b/docs/superpowers/plans/2026-06-11-bottle-release-pipeline.md @@ -0,0 +1,560 @@ +# Bottle Release Pipeline Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship a prebuilt arm64 Homebrew bottle on every `vX.Y.Z` tag so `brew install assembly` / `brew upgrade assembly` is fast (no Rust/LLVM source build), and remove the `install.sh` one-liner so the install story is just Homebrew (primary) + pipx/uv `git+https` (fallback). + +**Architecture:** A tag-triggered `release.yml` with two jobs — `bottle` (macOS arm64: pin the formula to the tag's source, build the bottle, merge the `bottle do` block into the formula) and `publish` (Linux: create the GitHub Release with the bottle attached, open a formula PR a maintainer merges by hand). No special token: the built-in `GITHUB_TOKEN` opens the PR; CI doesn't auto-run on `GITHUB_TOKEN` PRs, so the maintainer merges with the admin override. Separately, delete `install.sh` and everything that exists only to support it. + +**Tech Stack:** GitHub Actions, Homebrew (`brew bottle`/`brew tap-new`), Python `re` for formula edits, `gh` CLI, pytest. + +**Spec:** `docs/superpowers/specs/2026-06-11-bottle-release-pipeline-design.md` + +--- + +## File Structure + +**Created:** +- `.github/workflows/release.yml` — the tag-triggered release pipeline (two jobs). + +**Modified:** +- `Formula/assembly.rb` — unchanged by hand; the pipeline rewrites `url`/`sha256` and inserts the `bottle do` block at release time. (No edit in this plan.) +- `pyproject.toml` — drop the `install_script` marker registration + its `addopts` exclusion. +- `tests/conftest.py` — drop `install_script` from `_NETWORK_MARKERS`. +- `.github/workflows/ci.yml` — delete the `install-smoke` job. +- `scripts/check.sh` — drop `install.sh` from the `shellcheck` arg list, drop `install_script` from the pytest `-m` filter, update comments. +- `scripts/mutation_gate.py` — drop `install_script` from `_DEFAULT_MARKERS`. +- `README.md` — delete the "One-liner" section; keep the pipx/uv section; note the bottle in the Homebrew section. +- `AGENTS.md` — drop `install_script` from the marker docs (two spots). +- `.claude/skills/check/SKILL.md` — drop the `install_script` line + `install.sh` mention. +- `.claude/skills/release-prep/SKILL.md` — rewrite for the tag→bottle flow. + +**Deleted:** +- `install.sh` +- `tests/test_install_sh.py` +- `tests/test_install_script_smoke.py` + +--- + +## Task 1: Remove `install.sh` and its support + +This is a cohesive removal — the tree is only green once *all* references are gone, so the edits land in one commit verified by the full gate. Do the edits, then run `./scripts/check.sh`, then commit. + +**Files:** +- Delete: `install.sh`, `tests/test_install_sh.py`, `tests/test_install_script_smoke.py` +- Modify: `pyproject.toml`, `tests/conftest.py`, `.github/workflows/ci.yml`, `scripts/check.sh`, `scripts/mutation_gate.py`, `README.md`, `AGENTS.md`, `.claude/skills/check/SKILL.md` + +- [ ] **Step 1: Delete the script and its two test files** + +```bash +git rm install.sh tests/test_install_sh.py tests/test_install_script_smoke.py +``` + +- [ ] **Step 2: Drop the `install_script` marker from `pyproject.toml`** + +In `pyproject.toml`, change the `addopts` line (currently line ~131): + +```toml +addopts = "--disable-socket --allow-unix-socket -m 'not e2e and not install and not install_script'" +``` + +to: + +```toml +addopts = "--disable-socket --allow-unix-socket -m 'not e2e and not install'" +``` + +Then delete the `install_script` entry from the `markers = [` list (the line beginning `"install_script: real install of a locally-built wheel via install.sh …"`). Also delete/adjust the comment block just above `addopts` that names `install_script` (the lines starting `# install_script marker suites are slow…`) so it reads about `e2e`/`install` only — don't leave a comment referencing a marker that no longer exists (vulture/grep won't catch a stale comment, but the gate's markdown/style won't either; keep docs honest). + +- [ ] **Step 3: Drop `install_script` from the conftest network markers** + +In `tests/conftest.py` (line ~18): + +```python +_NETWORK_MARKERS = ("e2e", "install", "install_script") +``` + +to: + +```python +_NETWORK_MARKERS = ("e2e", "install") +``` + +- [ ] **Step 4: Delete the `install-smoke` CI job** + +In `.github/workflows/ci.yml`, delete the entire `install-smoke:` job (starts at the `install-smoke:` key, line ~193, through the end of its last step — the line before `formula-install:` at line ~235). `formula-install:` and everything else stays. + +- [ ] **Step 5: Update `scripts/check.sh`** + +Three edits: + +1. The `shellcheck` invocation (line ~125) — drop `install.sh`: + +```sh + shellcheck install.sh scripts/check.sh scripts/docker_build_check.sh +``` + +to: + +```sh + shellcheck scripts/check.sh scripts/docker_build_check.sh +``` + +2. The shellcheck echo just above (line ~121) `echo "==> shellcheck (install.sh)"` → `echo "==> shellcheck"`. + +3. The pytest line (line ~181) — drop `and not install_script`: + +```sh +uv run pytest -q --strict-config --strict-markers -n auto -m "not e2e and not install and not install_script" --cov=aai_cli --cov-branch --cov-context=test --cov-report=term-missing --cov-report=xml --cov-fail-under=90 +``` + +to: + +```sh +uv run pytest -q --strict-config --strict-markers -n auto -m "not e2e and not install" --cov=aai_cli --cov-branch --cov-context=test --cov-report=term-missing --cov-report=xml --cov-fail-under=90 +``` + +4. Update the two comments naming `install_script` (lines ~172 and ~224) to drop it — e.g. the "and install_script (builds a wheel and runs install.sh for real…" comment, and the "env-gated marker suites (e2e/install/install_script)…" comment → "(e2e/install)". + +- [ ] **Step 6: Update `scripts/mutation_gate.py`** + +Line ~32: + +```python +_DEFAULT_MARKERS = "not e2e and not install and not install_script" +``` + +to: + +```python +_DEFAULT_MARKERS = "not e2e and not install" +``` + +- [ ] **Step 7: Remove the README "One-liner" section** + +In `README.md`, delete the entire block (lines ~52–58): + +```markdown +### One-liner + +```sh +curl -fsSL https://raw.githubusercontent.com/AssemblyAI/cli/main/install.sh | sh +``` + +Prefers [`pipx`](https://pipx.pypa.io), falling back to `pip --user`. +``` + +Leave the `### pipx / uv` section above it intact (it's the fallback path). + +- [ ] **Step 8: Update `AGENTS.md`** + +`AGENTS.md` is the source; `CLAUDE.md` is a symlink to it, so edit `AGENTS.md` only. + +1. The marker-sets sentence (line ~57): change "excludes **three** slow/credentialed marker sets — … `-m "not e2e and not install and not install_script"`" to "excludes **two** … `-m "not e2e and not install"`". +2. Delete the `uv run pytest -m install_script # builds a wheel and runs install.sh for real; …` line (line ~62) from the opt-in examples block. +3. The hermetic-suite paragraph (line ~71): change the two `e2e`/`install`/`install_script` enumerations to `e2e`/`install`. + +- [ ] **Step 9: Update `.claude/skills/check/SKILL.md`** + +1. In the gate-order sentence (line ~19): change `shellcheck install.sh scripts/check.sh` → `shellcheck scripts/check.sh`, and change "excluding `e2e`/`install`/`install_script` markers" → "excluding `e2e`/`install` markers". +2. Delete the `uv run pytest -m install_script # builds a wheel and runs install.sh for real; needs network + uv/pipx` line (line ~31). + +- [ ] **Step 10: Run the full gate** + +Run: `./scripts/check.sh` +Expected: ends with `All checks passed.` — deptry/vulture/ruff confirm no orphaned imports or dead code; the snapshot tests and `--strict-markers` confirm nothing still references the removed marker. + +If a `--help` or other syrupy snapshot shifts (it shouldn't — no command output changed), do **not** hand-edit it; regenerate with `uv run pytest --snapshot-update` and re-run the gate. + +- [ ] **Step 11: Confirm no stray references remain** + +Run: + +```bash +grep -rn "install\.sh\|install_script" --include="*.py" --include="*.yml" --include="*.toml" --include="*.md" --include="*.sh" . | grep -v "/.venv/" | grep -v "docs/superpowers/specs/2026-06-04" | grep -v "docs/superpowers/specs/2026-06-08" | grep -v "docs/superpowers/plans/2026-06-04" | grep -v "docs/superpowers/plans/2026-06-08" | grep -v "docs/superpowers/specs/2026-06-11-bottle-release-pipeline" | grep -v "docs/superpowers/plans/2026-06-11-bottle-release-pipeline" | grep -v "release-prep" +``` + +Expected: **no output** (the only remaining mentions are the dated historical specs/plans, this plan/spec, and `release-prep` which Task 3 rewrites). + +- [ ] **Step 12: Commit** + +```bash +git add -A +git commit -m "Remove install.sh one-liner and its support + +Homebrew (bottle) + pipx/uv git+https are the supported install paths; +drop the curl|sh installer, its unit + smoke tests, the install_script +marker, and the install-smoke CI job. + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 2: Add the `release.yml` bottle pipeline + +A workflow can only be fully exercised by a tag push, so verification here is: it parses, passes `actionlint` + `zizmor` (the same gates `check.sh`/CI apply to workflows), and `Formula/assembly.rb` still passes `brew style` after a local `--merge` dry check. The first real `v0.1.0` tag is the integration test (see Task 4). + +**Files:** +- Create: `.github/workflows/release.yml` + +- [ ] **Step 1: Resolve SHA pins for the artifact actions** + +`release.yml` adds `actions/upload-artifact` and `actions/download-artifact`, which `ci.yml` doesn't use yet. `zizmor` requires SHA-pinned `uses:`, so resolve the v4 tag SHAs at implementation time (don't guess): + +```bash +gh api repos/actions/upload-artifact/git/ref/tags/v4 --jq '.object.sha' +gh api repos/actions/download-artifact/git/ref/tags/v4 --jq '.object.sha' +``` + +Note the two SHAs; substitute them for `` / `` in Step 2. (If `git/ref/tags/v4` 404s because v4 is a moving major tag pointing at a SHA via an annotated tag, use `gh api repos/actions/upload-artifact/git/refs/tags/v4 --jq '.[0].object.sha'` or read the SHA from the action's latest v4.x release.) + +- [ ] **Step 2: Write `.github/workflows/release.yml`** + +Create `.github/workflows/release.yml` with this content (fill in the four SHAs — reuse `ci.yml`'s pins for `checkout`/`setup-homebrew`, and the two resolved in Step 1): + +```yaml +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 or + # opening a PR (publish steps are gated on a real tag push below). + 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) + # Pin to the OLDEST supported arm64 macOS on purpose: Homebrew uses the + # newest bottle whose tag is <= the running OS, so an arm64_sonoma bottle is + # auto-selected on Sonoma AND every newer macOS. Building on newer would not + # serve older. macos-14 == Sonoma/arm64. + 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 + - uses: Homebrew/actions/setup-homebrew@2ebcf16054461267868620b1414507f3ccc765c1 + + - name: Resolve tag + source sha256 + id: meta + run: | + set -euo pipefail + tag="${{ github.event.inputs.tag || github.ref_name }}" + url="https://github.com/${GITHUB_REPOSITORY}/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/${GITHUB_REPOSITORY}/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() + # count=1: only the stable url/sha256 (the first sha256 in the file); + # every `resource` block's sha256 stays untouched. + src = re.sub(r'url ".*?"', f'url "{url}"', src, count=1) + src = re.sub(r'sha256 "[0-9a-f]*"', 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 + # Newer Homebrew refuses formulae outside a tap; use a throwaway local one. + 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 + # brew writes a double-dash local name; the canonical download name is + # single-dash. Rename the tarball; the JSON already records both names. + for f in assembly--*.bottle.tar.gz; do mv "$f" "${f/--/-}"; done + # Merge the bottle block into the TAP copy (where the formula lives), + # then copy the finalized formula back to the repo path for upload. + 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@ # 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] + # Real publish only on a tag push; workflow_dispatch is a build-only dry-run. + if: github.event_name == 'push' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write # create the release + push the formula branch + pull-requests: write # open the formula PR + steps: + # NOTE: default persist-credentials (true) on purpose — this job pushes a + # branch, so it needs the token wired into git. + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - uses: actions/download-artifact@ # 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 + # Bottle must live at / == the release download path. + gh release create "$TAG" \ + --title "$TAG" \ + --generate-notes \ + artifacts/assembly-*.bottle.tar.gz + + - name: Open the formula PR + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ needs.bottle.outputs.tag }} + 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 origin "$branch" + 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 do\` block. + + **Merge with the admin override** (\"merge without waiting for requirements\"): a PR opened by \`GITHUB_TOKEN\` does not trigger CI, so the required \`lint + typecheck + tests\` check will not report on its own. The diff is formula-only." +``` + +- [ ] **Step 3: Lint the workflow** + +If `actionlint` + `zizmor` are installed locally (the Go binaries `check.sh` self-skips when absent): + +Run: `actionlint .github/workflows/release.yml && zizmor .github/workflows/release.yml` +Expected: no findings. + +If they're not installed locally, run the full gate (`./scripts/check.sh`) — it runs both over `.github/workflows/` — or rely on CI's workflow-lint on the PR. Expected: clean. + +- [ ] **Step 4: Sanity-check the formula still styles after a local merge (optional, macOS only)** + +On a macOS box with Homebrew, dry-run the pin+merge against the existing tag-less formula to confirm `brew style` accepts the generated block. (Skippable on Linux/CI; the first real release validates end-to-end.) + +```bash +cp Formula/assembly.rb /tmp/assembly.bak.rb +# simulate: leave url/sha placeholders, just check style baseline +brew style ./Formula/assembly.rb +``` +Expected: PASS (baseline). Restore if you edited: `cp /tmp/assembly.bak.rb Formula/assembly.rb`. + +- [ ] **Step 5: Commit** + +```bash +git add .github/workflows/release.yml +git commit -m "Add tag-triggered bottle release pipeline + +release.yml builds an arm64 macOS bottle on a vX.Y.Z tag, publishes it to +the GitHub Release, and opens a formula PR (url+sha256+bottle block) for a +maintainer to merge. Built-in GITHUB_TOKEN only; no special secret. + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 3: Rewrite the `release-prep` skill + note the bottle in the README + +Bring the docs in line with the new flow: a release is a version-bump PR, a tag push, and a formula-PR merge — the bottle/release artifacts are produced by `release.yml`, not by hand. + +**Files:** +- Modify: `.claude/skills/release-prep/SKILL.md`, `README.md` + +- [ ] **Step 1: Rewrite `.claude/skills/release-prep/SKILL.md`** + +Replace the file body with the tag→bottle flow. Key changes: drop the `uv run pytest -m install_script` step (Section 3) entirely; replace the "Distribution caveat" mention of `install.sh` with Homebrew-bottle + pipx/uv. New content: + +```markdown +--- +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. +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 + +- 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 + +```sh +./scripts/check.sh +``` + +Must end with `All checks passed.` (ruff, mypy, markdownlint, shellcheck, pytest+coverage, build, `twine check --strict`). + +## 3. Tag to trigger the bottle pipeline + +```sh +git tag vX.Y.Z +git push origin vX.Y.Z +``` + +The push 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. +3. Opens a `release/vX.Y.Z-formula` PR pinning the formula to the tag's source and adding the `bottle do` block. + +## 4. Merge the formula PR + +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 + +On a clean arm64 Mac: + +```sh +brew update && brew install assembly # pulls the bottle — fast, no rust/llvm +assembly --version # matches the tagged version +``` + +## Distribution caveat + +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. +``` + +- [ ] **Step 2: Note the bottle in the README Homebrew section** + +In `README.md`, the Homebrew prose currently reads: + +```markdown +`brew install` pulls in `ffmpeg` and `portaudio` for you, so `transcribe`, `stream`, and `agent` work out of the box. Upgrade with `brew upgrade assembly`; remove with `brew uninstall assembly`. +``` + +Append a sentence so users know upgrades are prebuilt: + +```markdown +`brew install` pulls in `ffmpeg` and `portaudio` for you, so `transcribe`, `stream`, and `agent` work out of the box. Releases ship a prebuilt arm64 bottle, so `brew install`/`brew upgrade assembly` is a fast binary install (no compiler toolchain); Intel Macs build from source or can use the pipx/uv path below. Remove with `brew uninstall assembly`. +``` + +- [ ] **Step 3: Run markdownlint + the gate** + +Run: `./scripts/check.sh` +Expected: `All checks passed.` (markdownlint covers the README + SKILL.md edits; the snapshot suite is unaffected — no command output changed). + +- [ ] **Step 4: Commit** + +```bash +git add .claude/skills/release-prep/SKILL.md README.md +git commit -m "Docs: release-prep + README reflect the bottle release flow + +Release is now: version-bump PR -> tag push -> release.yml builds the +bottle + opens the formula PR -> admin-merge. Drop the install.sh smoke +step and the squatted-PyPI install.sh caveat. + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 4: First-release integration verification (manual, post-merge) + +Not a code change — the end-to-end proof that the pipeline works. Run after Tasks 1–3 are merged to `main` and you're ready to cut the first real version. + +- [ ] **Step 1: Bump + tag** + +Follow the rewritten `release-prep` skill: bump `pyproject.toml` to `0.1.0` via PR, merge, then `git tag v0.1.0 && git push origin v0.1.0`. + +- [ ] **Step 2: Watch the workflow** + +Run: `gh run watch` (or the Actions tab). Expected: `bottle` job green (bottle built + merged), `publish` job green (release created, `release/v0.1.0-formula` PR opened). + +- [ ] **Step 3: Merge the formula PR (admin override)** + +Run: `gh pr view release/v0.1.0-formula --web` → review the formula-only diff → merge with "merge without waiting for requirements". + +- [ ] **Step 4: Confirm the bottle is used** + +On a clean arm64 Mac: + +Run: +```bash +brew untap assemblyai/cli 2>/dev/null || true +brew tap assemblyai/cli https://github.com/AssemblyAI/cli +brew trust assemblyai/cli +brew install assembly +assembly --version +``` +Expected: install is a fast bottle pour with **no** `rust`/`llvm`/`python@3.14` pulled, and `assembly --version` prints `0.1.0`. Confirm with `brew deps assembly` — `rust`/`pkgconf` should be absent (they were `:build`-only and a bottle skips the build). + +--- + +## Self-Review + +**Spec coverage:** +- arm64 bottle on tag → Task 2 (`bottle` job). ✅ +- GitHub Releases hosting / `root_url` → Task 2 (`publish` job, `gh release create`). ✅ +- No special token / maintainer-merged PR → Task 2 (`publish` PR step), Task 3 (release-prep step 4). ✅ +- Ordering problem (url/sha256/bottle-block post-tag) → Task 2 (pin + merge steps). ✅ +- Remove install.sh + support → Task 1. ✅ +- pipx/uv stay (`git+https`) → Task 1 keeps the README section; Task 3 caveat. ✅ +- No wheels / no moving tag / no PEP 503 index → not built (correctly absent). ✅ +- Conventions (SHA pins, persist-credentials, timeouts, concurrency) → Task 2 Steps 1–2. ✅ +- Testing strategy (actionlint/zizmor, gate green, first-release proof) → Task 2 Step 3, Task 1 Step 10, Task 4. ✅ +- Release runbook → Task 3 (release-prep rewrite). ✅ + +**Placeholder scan:** The only intentional placeholders are `` / ``, resolved in Task 2 Step 1 before use. No TBD/TODO. + +**Consistency:** Artifact name `release-artifacts` is uploaded (bottle job) and downloaded (publish job) with the same name; `needs.bottle.outputs.tag` is declared as a `bottle` job output and consumed in `publish`. Bottle filename `assembly-*.bottle.tar.gz` (single-dash, post-rename) is the form uploaded, released, and matched by `root_url`. Branch name `release/${TAG}-formula` is consistent across Task 2 and Tasks 3–4. diff --git a/docs/superpowers/specs/2026-06-11-bottle-release-pipeline-design.md b/docs/superpowers/specs/2026-06-11-bottle-release-pipeline-design.md new file mode 100644 index 00000000..91f0642c --- /dev/null +++ b/docs/superpowers/specs/2026-06-11-bottle-release-pipeline-design.md @@ -0,0 +1,252 @@ +# Bottle release pipeline — tagged releases that ship a prebuilt Homebrew bottle + +**Date:** 2026-06-11 +**Status:** Approved design + +## Goal + +Make the CLI **simple to install and upgrade for as many Mac developers as +possible, while keeping CI infrastructure as simple as possible.** + +On macOS, Homebrew is how developers install and upgrade CLIs, so the primary +lever is shipping a **prebuilt Homebrew bottle**: `brew install assembly` / +`brew upgrade assembly` with no version, no URL, and — crucially — no +compile-from-source. Everything else in this design is subordinate to that goal, +and anything that adds CI surface without serving it has been cut. + +## Summary + +Add a tag-triggered GitHub Actions workflow (`release.yml`) that, on a pushed +`vX.Y.Z` tag, builds an **arm64 macOS bottle**, publishes it to the tag's GitHub +Release, and finalizes the Homebrew formula (`url` + `sha256` + `bottle do` block) +so `brew install assembly` downloads the bottle instead of compiling the +Rust-backed dependencies from source. + +Motivation: the formula vendors source distributions (sdists), so a from-source +`brew install` drags in `rust` → `llvm` → `z3` / `libgit2` / a second +`python@3.14` purely to compile `pydantic-core`, `jiter`, and `cryptography`. A +bottle is a tarball of the already-built Cellar tree — installing it needs none of +that toolchain, and a second Python disappears from the dependency graph. + +This change also **removes the `install.sh` one-liner** and its supporting tests / +CI, simplifying the install story to two paths: Homebrew (primary) and pipx/uv +(fallback). + +### Scope decisions (locked during brainstorming) + +- **arm64 macOS bottle only.** macOS is where `brew install` is the recommended + path and Apple Silicon is the dominant Mac base. Intel Macs and Linux are served + by the pipx/uv fallback (below), which is actually *faster* for them than a brew + source build. `x86_64_linux` / Intel-Mac (`ventura`) bottles are additive later. +- **Bottle hosted on GitHub Releases** (a `root_url` on the release download path), + not ghcr.io — simplest for a single self-tap on a public repo, no extra auth. +- **No special token.** The workflow uses only the built-in `GITHUB_TOKEN`; the + finalized-formula change lands via a PR a maintainer merges by hand (see + "Committing the formula back to main"). +- **No release wheels / no PyPI artifacts / no moving `stable` tag / no PEP 503 + index.** These were considered and cut: they add CI surface for a non-Mac-primary + audience. pipx/uv stay on the native `git+https` install, which costs nothing to + support and needs no release artifacts. +- **`install.sh` removed.** One fewer install path and its dedicated tests + CI, + and `brew` is a strictly better Mac story than `curl | sh`. + +### Install paths after this change + +| Path | Role | New CI cost | +|---|---|---| +| Homebrew bottle (arm64) | **Primary** — `brew install` / `brew upgrade` | the `release.yml` pipeline | +| pipx / uv (`git+https`) | Fallback — Intel Mac, Linux, non-brew users | ~zero (README line; natively supported) | +| ~~`install.sh` one-liner~~ | **removed** | — | + +The pipx/uv fallback is the *fast* path for the platforms the arm64 bottle does not +cover: `pipx`/`uv` pull prebuilt dependency wheels from PyPI, so they never invoke +Rust. Keeping them maximizes Mac coverage (arm64 → bottle, Intel → pipx) at no CI +cost. + +## Background: the ordering problem + +A bottled formula needs three values that do not all exist until *after* the tag +is pushed: + +1. `url` — the `vX.Y.Z` source-tarball URL + (`https://github.com/AssemblyAI/cli/archive/refs/tags/vX.Y.Z.tar.gz`). +2. `sha256` — that source tarball's checksum. GitHub generates the archive on + demand; its bytes are only pinnable once the tag exists. +3. `bottle do … end` — the bottle's own checksum, only known after the bottle is + built (which itself requires the formula to already point at a real, downloadable + `url` + `sha256`). + +Today the committed formula carries placeholders for (1)/(2) — `url` points at a +not-yet-cut `v0.1.0` and `sha256` is `"0" * 64` — and has no bottle block. The +workflow computes all three after the tag push and writes them back in one commit. + +## Trigger and human workflow + +`release.yml` triggers on `push:` with `tags: ["v*"]`. + +A release is two manual steps, plus one click at the end: + +1. Bump the version in `pyproject.toml` to `X.Y.Z` via a normal PR (regular CI). +2. Push the tag `vX.Y.Z` on `main`. +3. Merge the formula PR the workflow opens (admin override merge — see below). + +## Architecture — two jobs + +### Job 1 · `bottle` (macos-14) + +Pinned to the **oldest supported arm64 macOS on purpose**: Homebrew uses the +newest bottle whose macOS tag is ≤ the running OS, so a bottle built on Sonoma +(`arm64_sonoma`) is auto-selected on Sonoma *and* every newer macOS (Sequoia, +Tahoe, …). One build covers the whole range; building on the newest OS would +*not* serve older ones. + +Steps: + +1. Check out the repo at the tag (`persist-credentials: false`, like every other + job). +2. **Compute the source sha256:** `curl -fL` the tag archive + (`…/archive/refs/tags/vX.Y.Z.tar.gz`) and `shasum -a 256`. Curling GitHub's + actual generated archive (rather than reconstructing it with `git archive`) + guarantees the checksum matches what end users' `brew install` downloads. +3. **Repoint the formula** `url` → the tag archive and `sha256` → the computed + value (replacing the placeholders), leaving every `resource` block untouched + (`count=1` style edits, mirroring the existing `formula-install` job's patch). +4. **Build the bottle** through a throwaway local tap (newer Homebrew refuses + formulae outside a tap): + - `brew tap-new --no-git assembly/local` + - copy the patched `Formula/assembly.rb` into the local tap + - `brew install --build-bottle --formula assembly/local/assembly` + - `brew bottle --json --no-rebuild --root-url="https://github.com/AssemblyAI/cli/releases/download/vX.Y.Z" assembly/local/assembly` +5. **Rename** the produced `assembly--X.Y.Z.arm64_sonoma.bottle.tar.gz` (Homebrew + writes a double dash locally) to the canonical single-dash download name + `assembly-X.Y.Z.arm64_sonoma.bottle.tar.gz`. +6. Upload the renamed tarball **and** the `*.bottle.json` as artifacts. + +### Job 2 · `publish` (ubuntu-latest, `needs: [bottle]`) + +1. Download the bottle artifacts. +2. **Merge the bottle block into the formula:** `brew bottle --merge --write + --no-commit .json`, run against the same patched formula (url + real + sha256). The formula now carries `url`, the real `sha256`, and a `bottle do` + block with `root_url` + the `arm64_sonoma` checksum. (Re-derive the source + sha256 the same way as Job 1, or pass it through as a job output, so `publish` + writes the identical `url` + `sha256`.) +3. **Create the release:** `gh release create vX.Y.Z` (or `gh release upload` if + it already exists) attaching the renamed bottle tarball. The bottle must live at + `/`, which is exactly the release download path, so + the `root_url` and the upload target agree. (No wheel/sdist — out of scope.) +4. **Open the formula PR** (see below). + +## Committing the formula back to `main` + +`main` is branch-protected with a required `lint + typecheck + tests` check (the +`ci.yml` comments confirm this) and almost certainly "require a PR before +merging". The finalized formula (url + sha256 + bottle block) must live on `main` +because that is the branch users tap. + +A direct push from CI is blocked by protection. Auto-merging a PR is blocked by a +GitHub anti-recursion rule: **a PR opened by `GITHUB_TOKEN` does not trigger other +workflows**, so `ci.yml`'s `pull_request` jobs never run on it, the required check +never reports, and auto-merge would hang forever. + +**Chosen approach (no special token):** Job 2 opens a PR from a +`release/vX.Y.Z-formula` branch using the built-in `GITHUB_TOKEN` +(`permissions: contents: write, pull-requests: write`). Because that PR's checks +don't auto-run, a **maintainer merges it manually** using the repo-admin "merge +without waiting for requirements" override. The formula-only diff is small and +glanceable, so a human approve-and-merge is appropriate. Zero stored secrets; one +extra click per release. + +Explicitly rejected alternative: a fine-grained PAT or GitHub App in the +branch-protection bypass list. It enables full hands-off auto-merge but introduces +a privileged stored secret and punches a hole in branch protection — not worth it +for a release cadence that already has two manual steps. + +## Removing `install.sh` and its support + +Delete the one-liner installer and everything that exists only to support it. The +plan must locate the exact references (a repo grep for `install.sh` / +`install_script` is the starting point); the known footprint is: + +- **`install.sh`** — the script itself. +- **`tests/test_install_sh.py`** and **`tests/test_install_script_smoke.py`** — + dedicated tests. The `install_script` real-install smoke also exercises pipx / + uv-tool installs via a `-k "pipx or uv_tool"` filter; since pipx/uv are now a + documented `git+https` fallback (natively supported, not a release artifact), + drop these real-network smoke tests too — they're CI cost for a path that needs + no artifact. (Confirm during plan-writing by reading both files.) +- **`pyproject.toml`** — the `install_script` marker registration and its mention + in `addopts`'s `-m "not …"` exclusion. +- **`tests/conftest.py`** — the `install_script` entry in the socket-granting + `pytest_collection_modifyitems` hook and any marker registration. +- **`.github/workflows/ci.yml`** — the `install-smoke` job (it runs only + `-m install_script`); remove the whole job. +- **`README.md`** — the "One-liner" section (`curl … | sh`). The pipx/uv section + stays (it's the fallback). +- **`AGENTS.md`** (== `CLAUDE.md`) — the `install_script` marker docs. +- **`scripts/check.sh`**, **`scripts/mutation_gate.py`** — any `install_script` + handling. +- **`.claude/skills/check/SKILL.md`**, **`.claude/skills/release-prep/SKILL.md`** — + doc references. + +Leave the dated historical specs/plans under `docs/superpowers/` untouched — they +are append-only records, not live config. + +After removal, `scripts/check.sh` must still print `All checks passed.` — +`vulture`/`deptry`/`ruff` will catch any orphaned import or now-dead helper left +behind by the deletions. + +## Conventions to follow (match existing `ci.yml`) + +- Pin every action to a commit SHA with a `# vX.Y.Z` comment; Dependabot keeps + them current. Reuse the SHAs already pinned in `ci.yml` + (`actions/checkout`, `Homebrew/actions/setup-homebrew`). +- `persist-credentials: false` on every checkout that doesn't push; scope + `permissions:` to the minimum per job (`contents: read` default; `contents: + write` + `pull-requests: write` only on `publish`). +- `timeout-minutes` on every job (the from-source bottle build compiles Rust, so + `bottle` needs a generous timeout — model it on `formula-install`'s 40). +- `concurrency` group keyed on the tag so a re-pushed tag supersedes cleanly. + +## Testing strategy + +The pipeline can only be fully exercised by pushing a real tag, so validation is +layered: + +- **`brew style` already gates the formula** on every PR (`lint-formula` job). The + finalized formula with a `bottle do` block must keep passing `brew style`; if the + generated block needs reformatting, the merge step normalizes it. +- **`formula-install` (existing) stays the from-source correctness check.** It + already builds the branch's formula from source on macOS and runs `brew test`. + The bottle is the *same* build packaged, so this job continues to prove the + resource list installs and `assembly --version` works. +- **Workflow lint:** `release.yml` must pass `actionlint` + `zizmor`, which + `check.sh` already runs over `.github/workflows/`. +- **Full suite stays green after the `install.sh` removal** — `./scripts/check.sh` + prints `All checks passed.` with the deleted tests/markers gone and no orphaned + references (vulture/deptry/ruff enforce this). +- **First real release is the integration test.** Cutting `v0.1.0` exercises the + end-to-end path; a follow-up `brew install assembly` on a clean arm64 Mac + confirms the bottle is selected (no `rust`/`llvm` pulled). Documented in the + release runbook. +- **Optional dry-run hook:** support `workflow_dispatch` on `release.yml` so the + build-bottle path can be run against an existing tag without re-tagging, for + debugging, gated so it never publishes/commits. + +## Release runbook (to document in repo) + +1. `pyproject.toml` version bump → PR → merge. +2. `git tag vX.Y.Z && git push origin vX.Y.Z`. +3. Wait for `release.yml`; review and merge the `release/vX.Y.Z-formula` PR + (admin override merge). +4. Verify on a clean arm64 Mac: `brew update && brew install assembly` pulls the + bottle (fast, no `rust`); `assembly --version` matches. + +## Out of scope + +- Linux (`x86_64_linux`) and Intel-Mac (`ventura`) bottles — additive later. +- Release wheels / sdist artifacts, a moving `stable` tag, and a PEP 503 index — + cut as CI surface for a non-Mac-primary audience; pipx/uv use `git+https`. +- Publishing to PyPI (the `assemblyai-cli` name is squatted). +- ghcr.io / OCI bottle hosting. +- Automating the version bump itself (stays a human PR). diff --git a/install.sh b/install.sh deleted file mode 100755 index aecf33f6..00000000 --- a/install.sh +++ /dev/null @@ -1,45 +0,0 @@ -#!/bin/sh -# Install the AssemblyAI CLI (`assembly`) without cloning the repo: -# -# curl -fsSL https://raw.githubusercontent.com/AssemblyAI/cli/main/install.sh | sh -# -# Overridable via env: AAI_REPO (owner/name), AAI_REF (branch/tag/sha). -set -eu - -REPO="${AAI_REPO:-AssemblyAI/cli}" -REF="${AAI_REF:-main}" -# AAI_SPEC (test-only) installs an arbitrary pip spec verbatim — e.g. a locally -# built wheel — instead of the public git URL, so tests can exercise this script -# against the current checkout without pushing. Unset for normal installs. -SPEC="${AAI_SPEC:-git+https://github.com/${REPO}.git@${REF}}" - -info() { printf '\033[1;34m==>\033[0m %s\n' "$1"; } -err() { printf '\033[1;31merror:\033[0m %s\n' "$1" >&2; } - -# --- Require Python 3.12+ ------------------------------------------------- -PY="$(command -v python3 || command -v python || true)" -if [ -z "$PY" ]; then - err "Python 3.12+ is required, but no python3 was found on PATH." - exit 1 -fi -if ! "$PY" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 12) else 1)'; then - err "Python 3.12+ is required (found $("$PY" -V 2>&1))." - exit 1 -fi - -# --- Install (prefer pipx for an isolated env; fall back to pip --user) ---- -if command -v pipx >/dev/null 2>&1; then - info "Installing assembly with pipx from ${REPO}@${REF}..." - pipx install --force "$SPEC" -else - info "pipx not found; installing with pip --user from ${REPO}@${REF}..." - "$PY" -m pip install --user --upgrade "$SPEC" -fi - -# --- Next steps ----------------------------------------------------------- -if command -v assembly >/dev/null 2>&1; then - info "Installed. Next: run 'assembly onboard'." -else - info "Installed, but 'assembly' isn't on your PATH yet." - info "Run 'pipx ensurepath' (or add ~/.local/bin to PATH), then restart your shell." -fi diff --git a/pyproject.toml b/pyproject.toml index a0717c9a..3ed7d936 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -124,11 +124,11 @@ testpaths = ["tests"] # that bind a loopback server (OAuth callback, dev server, tunnel) opt back in with # `@pytest.mark.allow_hosts([...])`. The e2e/install suites drive the CLI as a # subprocess, whose sockets this in-process patch never sees, so they're unaffected. -# -m: a bare `pytest` runs the same suite check.sh gates — the e2e/install/ -# install_script marker suites are slow, need network/credentials, and fail -# confusingly in sandboxes otherwise. An explicit command-line -m (e.g. -# `pytest -m e2e`) overrides this one, so the opt-in runs keep working. -addopts = "--disable-socket --allow-unix-socket -m 'not e2e and not install and not install_script'" +# -m: a bare `pytest` runs the same suite check.sh gates — the e2e/install +# marker suites are slow, need network/credentials, and fail confusingly in +# sandboxes otherwise. An explicit command-line -m (e.g. `pytest -m e2e`) +# overrides this one, so the opt-in runs keep working. +addopts = "--disable-socket --allow-unix-socket -m 'not e2e and not install'" # Treat warnings as errors so a deprecation or resource leak introduced in a change # fails the build instead of scrolling past. Listed-later filters take precedence, so # the targeted ignores below override the blanket `error`. @@ -137,7 +137,6 @@ filterwarnings = [ ] markers = [ "e2e: real-API end-to-end tests that drive the CLI (need ASSEMBLYAI_API_KEY; skip otherwise)", - "install_script: real install of a locally-built wheel via install.sh (pipx / pip --user) and `uv tool install`; asserts `assembly` runs (slow; needs network + uv/pipx; skip otherwise)", "install: install each init template's requirements.txt into a clean venv and import it (slow; needs network + uv; skip otherwise)", ] diff --git a/scripts/check.sh b/scripts/check.sh index 6094697b..641139b8 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -118,17 +118,17 @@ else echo " prettier not found; skipping (CI runs it)" fi -echo "==> shellcheck (install.sh)" -# Static-lint the public install script and this gate script. CI's ubuntu runner ships shellcheck; +echo "==> shellcheck" +# Static-lint this gate script. CI's ubuntu runner ships shellcheck; # locally it's skipped with a notice if not installed. if command -v shellcheck >/dev/null 2>&1; then - shellcheck install.sh scripts/check.sh scripts/docker_build_check.sh + shellcheck scripts/check.sh scripts/docker_build_check.sh scripts/cut_release.sh else echo " shellcheck not found; skipping (CI runs it)" fi echo "==> actionlint (GitHub Actions workflow lint)" -# Static-lint the CI workflows the same way shellcheck covers install.sh: catches +# Static-lint the CI workflows the same way shellcheck covers shell scripts: catches # bad expressions, undefined needs/matrix refs, and shell bugs inside `run:` blocks. # Go binary (no PyPI wheel), so it self-skips locally and CI installs it (see ci.yml). if command -v actionlint >/dev/null 2>&1; then @@ -168,17 +168,15 @@ uv run python scripts/template_contract_gate.py echo "==> pytest (with branch-coverage gate)" # Exclude e2e: they drive the CLI as a subprocess (uncounted by coverage) and need -# a live API key. Exclude install (real per-template dep install, slow + network) -# and install_script (builds a wheel and runs install.sh for real; slow, needs -# network + uv/pipx). All are uncounted by coverage. Run them with: +# a live API key. Exclude install (real per-template dep install, slow + network). +# All are uncounted by coverage. Run them with: # uv run pytest -m e2e # uv run pytest -m install -# uv run pytest -m install_script # -n auto parallelizes across CPUs (pytest-xdist); pytest-cov combines per-worker # data, and the per-test --cov-context=test contexts the mutation gate below relies # on survive that combine. The suite is order-independent (pytest-randomly), so # splitting it across workers is safe. -uv run pytest -q --strict-config --strict-markers -n auto -m "not e2e and not install and not install_script" --cov=aai_cli --cov-branch --cov-context=test --cov-report=term-missing --cov-report=xml --cov-fail-under=90 +uv run pytest -q --strict-config --strict-markers -n auto -m "not e2e and not install" --cov=aai_cli --cov-branch --cov-context=test --cov-report=term-missing --cov-report=xml --cov-fail-under=90 echo "==> diff-cover (patch coverage: every changed line must be tested)" # The 90% gate above is project-wide, so new code can ride on the existing suite and @@ -221,9 +219,9 @@ if git rev-parse --verify --quiet origin/main >/dev/null; then # Test-suite escape hatches, same net-new-only policy: a skip/xfail is how an agent # makes a red test go away instead of fixing it, and time.sleep() is the classic # source of flakiness (use events/polling). The legitimate existing skips guard the - # env-gated marker suites (e2e/install/install_script) and live on origin/main, so - # they aren't added diff lines and don't trip this; a genuinely-needed new one must - # update this gate deliberately. Scoped to tests/ — production sleeps are fine. + # env-gated marker suites (e2e/install) and live on origin/main, so they aren't + # added diff lines and don't trip this; a genuinely-needed new one must update this + # gate deliberately. Scoped to tests/ — production sleeps are fine. test_shortcuts="$(git diff -U0 origin/main -- tests \ | rg '^\+.*(pytest\.skip\(|pytest\.xfail\(|@pytest\.mark\.(skip|xfail)|\btime\.sleep\()' || true)" if [[ -n "$test_shortcuts" ]]; then diff --git a/scripts/cut_release.sh b/scripts/cut_release.sh new file mode 100755 index 00000000..1181d726 --- /dev/null +++ b/scripts/cut_release.sh @@ -0,0 +1,96 @@ +#!/bin/sh +# Cut an AssemblyAI CLI release: tag the version from pyproject.toml and push the +# tag, which triggers .github/workflows/release.yml (builds the arm64 bottle, +# creates the GitHub Release, opens the formula PR). +# +# ./scripts/cut_release.sh # verify, confirm, then tag + push +# ./scripts/cut_release.sh --yes # skip the interactive confirmation +# ./scripts/cut_release.sh -n # dry run: verify only, don't tag or push +# +# Bump the version (pyproject.toml + aai_cli/__init__.py) and merge that PR +# BEFORE running this — the script tags whatever version main currently holds. +set -eu + +ASSUME_YES=0 +DRY_RUN=0 +for arg in "$@"; do + case "$arg" in + -y | --yes) ASSUME_YES=1 ;; + -n | --dry-run) DRY_RUN=1 ;; + -h | --help) + sed -n '2,11p' "$0" | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) + printf 'unknown argument: %s (try --help)\n' "$arg" >&2 + exit 2 + ;; + esac +done + +info() { printf '\033[1;34m==>\033[0m %s\n' "$1"; } +err() { + printf '\033[1;31merror:\033[0m %s\n' "$1" >&2 + exit 1 +} + +# Run from the repo root so the relative paths below resolve regardless of CWD. +root="$(git rev-parse --show-toplevel)" || err "not inside a git repository." +cd "$root" + +# --- Single source of truth: the version in pyproject.toml ----------------- +version="$(grep -m1 '^version = ' pyproject.toml | sed -E 's/^version = "([^"]+)".*/\1/')" +[ -n "$version" ] || err "could not read version from pyproject.toml." +tag="v${version}" + +# __version__ must match (the `version` command and tests read it). +init_version="$(grep -m1 '__version__' aai_cli/__init__.py | sed -E 's/.*"([^"]+)".*/\1/')" +[ "$init_version" = "$version" ] || + err "version mismatch: pyproject.toml=$version but aai_cli/__init__.py=$init_version." + +# --- Safety gates ---------------------------------------------------------- +[ -f .github/workflows/release.yml ] || + err "release.yml not found on this checkout — merge the release pipeline to main first." + +branch="$(git rev-parse --abbrev-ref HEAD)" +[ "$branch" = "main" ] || err "on branch '$branch'; releases are tagged from 'main'." + +[ -z "$(git status --porcelain)" ] || err "working tree is dirty; commit or stash first." + +info "Fetching origin..." +git fetch --quiet origin main + +[ "$(git rev-parse main)" = "$(git rev-parse origin/main)" ] || + err "local main is not in sync with origin/main; pull/push so they match, then retry." + +if git rev-parse -q --verify "refs/tags/${tag}" >/dev/null; then + err "tag ${tag} already exists locally." +fi +if [ -n "$(git ls-remote --tags origin "refs/tags/${tag}")" ]; then + err "tag ${tag} already exists on origin." +fi + +sha="$(git rev-parse --short HEAD)" +info "Ready to release ${tag} at ${sha} (main, in sync with origin)." + +if [ "$DRY_RUN" -eq 1 ]; then + info "Dry run: all checks passed. Not tagging or pushing." + exit 0 +fi + +# --- Confirm, then tag + push ---------------------------------------------- +if [ "$ASSUME_YES" -ne 1 ]; then + printf 'Tag %s at %s and push to origin? [y/N] ' "$tag" "$sha" + read -r reply + case "$reply" in + [yY] | [yY][eE][sS]) ;; + *) err "aborted." ;; + esac +fi + +git tag -a "$tag" -m "Release ${tag}" +info "Created tag ${tag}. Pushing..." +git push origin "$tag" + +info "Pushed ${tag}. release.yml is now building the bottle." +info "Next: watch it with 'gh run watch', then admin-merge the 'release/${tag}-formula' PR." diff --git a/scripts/mutation_gate.py b/scripts/mutation_gate.py index 6671b803..a2683a6e 100644 --- a/scripts/mutation_gate.py +++ b/scripts/mutation_gate.py @@ -29,7 +29,7 @@ _PKG = "aai_cli" _TEMPLATES = "aai_cli/init/templates" -_DEFAULT_MARKERS = "not e2e and not install and not install_script" +_DEFAULT_MARKERS = "not e2e and not install" _TEST_TIMEOUT = 120 # seconds; a mutant that hangs (e.g. a flipped loop guard) counts killed _SUPPRESS = "pragma: no mutate" diff --git a/tests/conftest.py b/tests/conftest.py index b33d79b3..be2d24ec 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,7 +15,7 @@ # opt out of the suite-wide --disable-socket; every other test stays blocked, so an # unmocked call in the unit suite still fails loudly. Tests that only bind a loopback # server use the tighter `@pytest.mark.allow_hosts(["127.0.0.1"])` instead. -_NETWORK_MARKERS = ("e2e", "install", "install_script") +_NETWORK_MARKERS = ("e2e", "install") def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: diff --git a/tests/test_install_script_smoke.py b/tests/test_install_script_smoke.py deleted file mode 100644 index 765c4d0c..00000000 --- a/tests/test_install_script_smoke.py +++ /dev/null @@ -1,148 +0,0 @@ -"""Real install-and-run smoke tests for the documented install paths. - -Builds a wheel from the checkout once, then installs it into hermetic locations -and asserts the resulting `assembly` binary actually runs — exercising dependency -resolution + the console entrypoint for each documented installer: - -* install.sh's pipx and pip --user branches (via the test-only AAI_SPEC - override, which installs the wheel instead of the public git URL so tests - need no push); install.sh's pipx branch is literally `pipx install `, - so it doubles as coverage of the README's `pipx install` command. -* `uv tool install ` — the README's uv path. - -The Homebrew path is covered separately by the `formula-install` CI job, which -does a real `brew install` of the formula built against the branch source. - -Marked `install_script`: slow + needs network (deps resolve from PyPI) and -pipx/uv. Excluded from the default run; invoke explicitly:: - - uv run pytest -m install_script - -CI runs every branch on Linux; on macOS it runs the pipx + uv tool branches -(`pip --user` is flaky there under PEP 668). -""" - -from __future__ import annotations - -import functools -import os -import shutil -import subprocess -import sys -import urllib.error -import urllib.request -from pathlib import Path - -import pytest - -from aai_cli import __version__ - -pytestmark = pytest.mark.install_script - -REPO_ROOT = Path(__file__).resolve().parent.parent -INSTALL_SH = REPO_ROOT / "install.sh" - - -@functools.lru_cache(maxsize=1) -def _pypi_reachable() -> bool: - # Cached: both tests ask the same question, so probe the network once. - try: - urllib.request.urlopen("https://pypi.org/simple/", timeout=5) - return True - except (urllib.error.URLError, OSError): - return False - - -def _sh() -> str: - return shutil.which("sh") or "/bin/sh" - - -@pytest.fixture(scope="session") -def built_wheel(tmp_path_factory) -> Path: - # Skip (never fail) when the machine can't build the wheel — mirrors the - # template install test, so offline/sandboxed local runs aren't blocked. - if shutil.which("uv") is None: - pytest.skip("uv not on PATH; needed to build the wheel under test") - out = tmp_path_factory.mktemp("dist") - subprocess.run( - ["uv", "build", "--wheel", "--out-dir", str(out)], - cwd=REPO_ROOT, - check=True, - capture_output=True, - text=True, - ) - wheels: list[Path] = list(out.glob("*.whl")) - assert len(wheels) == 1, f"expected exactly one wheel, got {wheels}" - return wheels[0] - - -def _assert_aai_runs(aai_bin: Path) -> None: - assert aai_bin.is_file(), f"install.sh did not produce {aai_bin}" - result = subprocess.run([str(aai_bin), "--version"], capture_output=True, text=True) - assert result.returncode == 0, result.stderr - assert result.stdout.strip() == __version__ - - -def test_install_via_pipx(built_wheel: Path, tmp_path: Path) -> None: - if shutil.which("pipx") is None: - pytest.skip("pipx not on PATH; required for the pipx install branch") - if not _pypi_reachable(): - pytest.skip("PyPI unreachable; skipping real-install smoke test (offline)") - - pipx_bin = tmp_path / "pipx_bin" - # Inherit the real env so pipx/python resolve normally; the overrides keep - # the install hermetic (its own pipx home + an isolated bin dir). - env = { - **os.environ, - "AAI_SPEC": str(built_wheel), - "PIPX_HOME": str(tmp_path / "pipx_home"), - "PIPX_BIN_DIR": str(pipx_bin), - } - run = subprocess.run([_sh(), str(INSTALL_SH)], env=env, capture_output=True, text=True) - assert run.returncode == 0, run.stderr - _assert_aai_runs(pipx_bin / "assembly") - - -def test_install_via_pip_user(built_wheel: Path, tmp_path: Path) -> None: - if not _pypi_reachable(): - pytest.skip("PyPI unreachable; skipping real-install smoke test (offline)") - - # Hermetic PATH with ONLY python3 → `command -v pipx` fails, forcing the - # pip --user fallback. pip --user honors PYTHONUSERBASE for the install root. - bindir = tmp_path / "bin" - bindir.mkdir() - python = shutil.which("python3") or sys.executable - (bindir / "python3").symlink_to(python) - userbase = tmp_path / "userbase" - env = { - "PATH": str(bindir), - "AAI_SPEC": str(built_wheel), - "PYTHONUSERBASE": str(userbase), - } - run = subprocess.run([_sh(), str(INSTALL_SH)], env=env, capture_output=True, text=True) - assert run.returncode == 0, run.stderr - _assert_aai_runs(userbase / "bin" / "assembly") - - -def test_install_via_uv_tool(built_wheel: Path, tmp_path: Path) -> None: - if shutil.which("uv") is None: - pytest.skip("uv not on PATH; required for the uv tool install branch") - if not _pypi_reachable(): - pytest.skip("PyPI unreachable; skipping real-install smoke test (offline)") - - # Hermetic uv tool dirs so the install never touches the developer's real - # toolchain; UV_TOOL_BIN_DIR is where uv links the `assembly` entrypoint. - bindir = tmp_path / "uv_bin" - env = { - **os.environ, - "UV_TOOL_DIR": str(tmp_path / "uv_tools"), - "UV_TOOL_BIN_DIR": str(bindir), - } - run = subprocess.run( - ["uv", "tool", "install", str(built_wheel)], - env=env, - capture_output=True, - text=True, - ) - assert run.returncode == 0, run.stderr - _assert_aai_runs(bindir / "assembly") diff --git a/tests/test_install_sh.py b/tests/test_install_sh.py deleted file mode 100644 index 5cda52ec..00000000 --- a/tests/test_install_sh.py +++ /dev/null @@ -1,126 +0,0 @@ -"""Shell-logic unit tests for install.sh. - -Run install.sh under a sandboxed PATH of fake shims so we can assert *which* -installer it invokes and with *what* spec — without any real install or network. -The shims record their argv to files in a temp dir; the script's only external -dependencies (python3, pipx, and pip via `python -m pip`) are all faked. - -Fast; runs in the default suite. The real install-and-boot test lives in -test_install_script_smoke.py (marked `install_script`). -""" - -from __future__ import annotations - -import shutil -import subprocess -from pathlib import Path - -INSTALL_SH = Path(__file__).resolve().parent.parent / "install.sh" -DEFAULT_SPEC = "git+https://github.com/AssemblyAI/cli.git@main" - - -def _sh() -> str: - return shutil.which("sh") or "/bin/sh" - - -def _shim(path: Path, body: str) -> None: - path.write_text("#!/bin/sh\n" + body) - path.chmod(0o755) - - -def _python_shim(bindir: Path, *, version: str = "3.12.0", gate_ok: bool = True) -> None: - # Fakes the three ways install.sh calls python: - # -V → print a version (used in the <3.12 error message) - # -c '' → exit 0/1 to pass/fail the 3.12+ gate - # -m pip ... → record argv to pip.args (the pip --user fallback) - rec = bindir / "pip.args" - _shim( - bindir / "python3", - f'case "$1" in\n' - f' -V|--version) echo "Python {version}"; exit 0 ;;\n' - f" -c) exit {0 if gate_ok else 1} ;;\n" - f' -m) shift; echo "$@" > "{rec}"; exit 0 ;;\n' - f"esac\n" - f"exit 0\n", - ) - - -def _pipx_shim(bindir: Path) -> None: - rec = bindir / "pipx.args" - _shim(bindir / "pipx", f'echo "$@" > "{rec}"\nexit 0\n') - - -def _run(bindir: Path, env_extra: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]: - env = {"PATH": str(bindir)} - if env_extra: - env.update(env_extra) - return subprocess.run([_sh(), str(INSTALL_SH)], env=env, capture_output=True, text=True) - - -def test_errors_when_no_python(tmp_path): - # Empty bindir: no python3/python on PATH at all. - result = _run(tmp_path) - assert result.returncode == 1 - assert "Python 3.12+ is required" in result.stderr - - -def test_errors_when_python_too_old(tmp_path): - _python_shim(tmp_path, version="3.9.18", gate_ok=False) - result = _run(tmp_path) - assert result.returncode == 1 - assert "Python 3.12+ is required" in result.stderr - assert "3.9.18" in result.stderr - - -def test_uses_pipx_when_present(tmp_path): - _python_shim(tmp_path) - _pipx_shim(tmp_path) - result = _run(tmp_path) - assert result.returncode == 0 - assert (tmp_path / "pipx.args").read_text().strip() == f"install --force {DEFAULT_SPEC}" - assert not (tmp_path / "pip.args").exists() # pip fallback not taken - - -def test_falls_back_to_pip_user_when_no_pipx(tmp_path): - _python_shim(tmp_path) # no pipx shim → `command -v pipx` fails - result = _run(tmp_path) - assert result.returncode == 0 - assert ( - tmp_path / "pip.args" - ).read_text().strip() == f"pip install --user --upgrade {DEFAULT_SPEC}" - - -def test_repo_and_ref_override_the_spec(tmp_path): - _python_shim(tmp_path) - _pipx_shim(tmp_path) - result = _run(tmp_path, {"AAI_REPO": "me/fork", "AAI_REF": "dev"}) - assert result.returncode == 0 - assert ( - tmp_path / "pipx.args" - ).read_text().strip() == "install --force git+https://github.com/me/fork.git@dev" - - -def test_aai_spec_is_used_verbatim(tmp_path): - _python_shim(tmp_path) - _pipx_shim(tmp_path) - result = _run(tmp_path, {"AAI_SPEC": "/tmp/aai_cli-0.1.0-py3-none-any.whl"}) - assert result.returncode == 0 - assert ( - tmp_path / "pipx.args" - ).read_text().strip() == "install --force /tmp/aai_cli-0.1.0-py3-none-any.whl" - - -def test_path_hint_when_aai_not_on_path(tmp_path): - _python_shim(tmp_path) - _pipx_shim(tmp_path) - result = _run(tmp_path) # no `assembly` shim → `command -v assembly` fails - assert result.returncode == 0 - assert "isn't on your PATH yet" in result.stdout - - -def test_next_steps_when_aai_present(tmp_path): - _python_shim(tmp_path) - _pipx_shim(tmp_path) - _shim(tmp_path / "assembly", "exit 0\n") - result = _run(tmp_path) - assert "Installed. Next: run 'assembly onboard'" in result.stdout