From ca2adadf8c59a72f5befd2d057ba289ff2f5eb42 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 21:06:43 +0000 Subject: [PATCH] Let release.yml be cut from a manual workflow_dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a tag job to release.yml that, on manual dispatch, resolves the version and creates+pushes the vX.Y.Z tag (reusing cut_release.sh --no-push), then builds and publishes in the same run. This lets a Claude web session — which works on a feature branch and can't push tags — cut a release via the "Run workflow" button / actions_run_trigger. Tag creation lives inside the release run on purpose: a GITHUB_TOKEN tag push does not re-trigger the on:push half, so a standalone tag-push workflow would silently never build. https://claude.ai/code/session_01D6zsmQxdvq3dUPuVrbUaNg --- .claude/skills/release-prep/SKILL.md | 20 +++--- .github/workflows/release.yml | 98 +++++++++++++++++++++++----- AGENTS.md | 2 +- scripts/cut_release.sh | 15 ++++- 4 files changed, 108 insertions(+), 27 deletions(-) diff --git a/.claude/skills/release-prep/SKILL.md b/.claude/skills/release-prep/SKILL.md index ae747718..96efaf09 100644 --- a/.claude/skills/release-prep/SKILL.md +++ b/.claude/skills/release-prep/SKILL.md @@ -1,6 +1,6 @@ --- name: release-prep -description: Prepare an assembly CLI release — bump the version, run the full gate, then tag to trigger the bottle pipeline. Use when cutting a new release. +description: Prepare an assembly CLI release — confirm main is green, then tag (locally or via the manual workflow) to trigger the bottle pipeline. Use when cutting a new release. disable-model-invocation: true --- @@ -8,11 +8,10 @@ disable-model-invocation: true Drive an `assembly` release to a verified, tagged state. Stop and report at the first failure — never tag on a red check. -## 1. Version bump +## 1. Pick the version -- Update `version` in `pyproject.toml` (`[project]`). Confirm `aai_cli/__init__.py` `__version__` stays in sync (the `version` command reads it). +- With hatch-vcs **the git tag _is_ the version** — there is no `pyproject.toml` / `aai_cli/__init__.py` string to bump. `cut_release.sh` defaults to the next patch above the latest `vX.Y.Z` tag; pass `X.Y.Z` for a minor/major bump. - Decide the bump (patch/minor/major) from what changed since the last tag; ask the user if it's ambiguous. -- Land the bump via a normal PR (regular CI) before tagging. ## 2. Full gate @@ -20,17 +19,22 @@ Drive an `assembly` release to a verified, tagged state. Stop and report at the ./scripts/check.sh ``` -Must end with `All checks passed.` (ruff, mypy, markdownlint, shellcheck, pytest+coverage, build, `twine check --strict`). +Must end with `All checks passed.` (ruff, mypy, markdownlint, shellcheck, pytest+coverage, build, `twine check --strict`). The release builds whatever `main` points at, so confirm `main` is green before tagging. ## 3. Tag to trigger the bottle pipeline +Two equivalent ways to cut the tag — both land on `.github/workflows/release.yml`: + +**Local** (from a clean `main` in sync with `origin/main`): + ```sh -./scripts/cut_release.sh +./scripts/cut_release.sh # next patch; --dry-run verifies without tagging, --yes skips the prompt +./scripts/cut_release.sh 0.3.0 # explicit version ``` -This derives the version from `pyproject.toml`, verifies the tree is clean, on `main`, and in sync with origin, then tags `vX.Y.Z` and pushes it. (`--dry-run` verifies without tagging; `--yes` skips the confirmation prompt.) +**No local checkout** (e.g. a Claude web session on a feature branch): run the **Release** workflow's manual `workflow_dispatch` — GitHub's "Run workflow" button, or the `actions_run_trigger` MCP tool — with an optional `version` input (blank = next patch). Its `tag` job resolves the version and creates+pushes the tag from `main`, then the same run builds and publishes. Set `dry_run: true` to build the bottle for an existing tag without publishing. -The pushed tag triggers `.github/workflows/release.yml`, which: +The tag triggers `.github/workflows/release.yml`, which: 1. Builds the arm64 macOS bottle (`arm64_sonoma`). 2. Creates the `vX.Y.Z` GitHub Release with the bottle attached. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 58237bcd..ce16efc5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,34 +1,100 @@ name: Release -# Cut a release by pushing a vX.Y.Z tag (after the version-bump PR merges). -# Builds the arm64 macOS bottle, publishes it to the tag's GitHub Release, and -# opens a formula PR (url + sha256 + bottle block) for a maintainer to merge. +# Cut a release one of two ways, both landing on the same bottle pipeline below: +# 1. Push a vX.Y.Z tag (what `scripts/cut_release.sh` does from a clean `main`). +# 2. Run this workflow manually ("Run workflow" / the actions_run_trigger MCP +# tool) — the `tag` job resolves the version, creates the tag, and pushes it, +# so a Claude web session (which works on a feature branch and can't push +# tags itself) can cut a release without a local checkout. +# Either way the pipeline builds the arm64 macOS bottle, publishes it to the tag's +# GitHub Release, and opens a formula PR (url + sha256 + bottle block) to merge. on: push: tags: ["v*"] - # Manual dry-run: build the bottle for an existing tag WITHOUT publishing. workflow_dispatch: inputs: - tag: - description: "Existing tag to build a bottle for (dry-run; no publish)" - required: true + version: + description: "Release version X.Y.Z (blank = next patch above the latest vX.Y.Z tag)" + required: false + default: "" + dry_run: + description: "Build the bottle only — don't create the tag, GitHub Release, or formula PR" + type: boolean + default: false permissions: contents: read concurrency: - group: ${{ github.workflow }}-${{ github.event.inputs.tag || github.ref }} + group: ${{ github.workflow }}-${{ github.event.inputs.version || github.ref }} cancel-in-progress: false jobs: + # Resolve the tag the rest of the pipeline builds. On a tag push it already + # exists (just echo it). On a manual real release it's created here from the + # same logic maintainers run locally; on a manual dry run we build an existing + # tag without creating anything. Always runs so `bottle` has a single source + # for the tag regardless of trigger. + tag: + name: resolve release tag + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write # only the manual real-release path pushes a tag; unused otherwise + outputs: + tag: ${{ steps.resolve.outputs.tag }} + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + # Release from main; full history brings the vX.Y.Z tags cut_release.sh + # bumps from. persist-credentials off (the real-release push below uses + # an explicit tokened remote, matching the publish job). + ref: main + fetch-depth: 0 + persist-credentials: false + + - name: Resolve the release tag + id: resolve + env: + EVENT_NAME: ${{ github.event_name }} + REF_NAME: ${{ github.ref_name }} + INPUT_VERSION: ${{ github.event.inputs.version }} + DRY_RUN: ${{ github.event.inputs.dry_run }} + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + if [ "$EVENT_NAME" = "push" ]; then + tag="$REF_NAME" + elif [ "$DRY_RUN" = "true" ]; then + # Build a bottle for an already-existing tag without publishing. + if [ -n "$INPUT_VERSION" ]; then + tag="v${INPUT_VERSION}" + else + tag="$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname | head -n1)" + fi + [ -n "$tag" ] || { echo "no existing vX.Y.Z tag to dry-run" >&2; exit 1; } + else + # Real manual release: resolve + validate + create the tag locally via + # the same script maintainers run, then push it with an explicit + # tokened remote (persist-credentials is off above). + extra=() + [ -n "$INPUT_VERSION" ] && extra+=("$INPUT_VERSION") + ./scripts/cut_release.sh --yes --no-push "${extra[@]}" + tag="$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname | head -n1)" + git push "https://x-access-token:${GH_TOKEN}@github.com/${REPO}.git" "refs/tags/${tag}" + fi + echo "tag=${tag}" >> "$GITHUB_OUTPUT" + bottle: name: build arm64 bottle (macOS) + needs: [tag] runs-on: macos-14 timeout-minutes: 40 permissions: contents: read outputs: - tag: ${{ steps.meta.outputs.tag }} + tag: ${{ needs.tag.outputs.tag }} steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: @@ -37,28 +103,26 @@ jobs: # commit SHA like every other action here — Dependabot keeps it current. - uses: Homebrew/actions/setup-homebrew@2ebcf16054461267868620b1414507f3ccc765c1 - - name: Resolve tag + source sha256 + - name: Resolve source sha256 id: meta env: # Pass via env (not inline ${{ }}) to satisfy zizmor template-injection. - INPUT_TAG: ${{ github.event.inputs.tag }} - REF_NAME: ${{ github.ref_name }} + TAG: ${{ needs.tag.outputs.tag }} REPO: ${{ github.repository }} run: | set -euo pipefail - tag="${INPUT_TAG:-$REF_NAME}" + tag="$TAG" url="https://github.com/${REPO}/archive/refs/tags/${tag}.tar.gz" curl -fL "$url" -o source.tar.gz sha="$(shasum -a 256 source.tar.gz | awk '{print $1}')" { - echo "tag=${tag}" echo "source_sha=${sha}" echo "root_url=https://github.com/${REPO}/releases/download/${tag}" } >> "$GITHUB_OUTPUT" - name: Pin the formula to the release tag env: - TAG: ${{ steps.meta.outputs.tag }} + TAG: ${{ needs.tag.outputs.tag }} SOURCE_SHA: ${{ steps.meta.outputs.source_sha }} REPO: ${{ github.repository }} run: | @@ -114,7 +178,9 @@ jobs: publish: name: publish release + open formula PR needs: [bottle] - if: github.event_name == 'push' + # Publish for real tag pushes and for manual real releases; skip on a manual + # dry run (which only builds the bottle to prove the formula installs). + if: ${{ github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true' }} runs-on: ubuntu-latest timeout-minutes: 10 permissions: diff --git a/AGENTS.md b/AGENTS.md index 69b529b6..1a5d9a3c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,7 +75,7 @@ structured so independent changes stay in disjoint files. Keep it that way: - The **package/module** is `aai_cli`; the **distribution** name is `aai-cli`; the **console command** is `assembly` (`[project.scripts] assembly = "aai_cli.main:run"`). - `assembly init` templates live in `aai_cli/init/templates/` and are **committed**, including renamed dotfiles (`gitignore` → `.gitignore`, `env.example`). The wheel force-includes them via `[tool.hatch.build.targets.wheel] artifacts`, excluding `__pycache__/*.pyc`. Editing templates needs care — see the parametrized contract tests (`tests/test_init_template_*.py`). - `audioop` left the stdlib in 3.13; `audioop-lts` backfills it (conditional dependency). Supported Pythons: 3.12–3.13. -- **Releasing is tag-triggered.** The version is **derived from the git tag** by hatch-vcs and written to a gitignored `aai_cli/_version.py` at build time — there is no version string to keep in sync across `pyproject.toml` or `aai_cli/__init__.py`, and `bump_patch.sh` no longer exists. To cut a release, run `scripts/cut_release.sh` from a clean `main` in sync with `origin/main`: no argument → next patch above the latest `vX.Y.Z` tag; `cut_release.sh X.Y.Z` → explicit version. It tags + pushes, which fires `.github/workflows/release.yml` — that builds the prebuilt arm64 Homebrew bottle (`Formula/assembly.rb`), cuts the GitHub Release, and opens the formula PR. Bottling matters because the deps include Rust-backed sdists (`pydantic-core`, `jiter`, `cryptography`) that would otherwise compile from source on `brew install`. The Homebrew formula builds from a git-less GitHub source tarball, so `Formula/assembly.rb`'s `def install` sets the generic `SETUPTOOLS_SCM_PRETEND_VERSION` env var (installing resources first under a clean env, then setting the var for our package only) to feed the tag version to the build. **`cut_release.sh` only runs from a clean `main` in sync with `origin/main`** (it hard-errors on a feature branch / dirty tree), so cut releases from `main`, not your working branch. The "update available" notice users see is `aai_cli/update_check.py`. +- **Releasing is tag-triggered.** The version is **derived from the git tag** by hatch-vcs and written to a gitignored `aai_cli/_version.py` at build time — there is no version string to keep in sync across `pyproject.toml` or `aai_cli/__init__.py`, and `bump_patch.sh` no longer exists. To cut a release, run `scripts/cut_release.sh` from a clean `main` in sync with `origin/main`: no argument → next patch above the latest `vX.Y.Z` tag; `cut_release.sh X.Y.Z` → explicit version. It tags + pushes, which fires `.github/workflows/release.yml` — that builds the prebuilt arm64 Homebrew bottle (`Formula/assembly.rb`), cuts the GitHub Release, and opens the formula PR. **You don't need a local checkout to release:** `release.yml` also has a manual `workflow_dispatch` (GitHub's "Run workflow" button, or `actions_run_trigger` from a Claude web session) taking an optional `version` input — its `tag` job resolves the version and creates+pushes the tag (reusing `cut_release.sh --no-push`), and the rest of the pipeline then runs in that same workflow run. Tag creation lives *inside* the release run on purpose: a `GITHUB_TOKEN` tag push wouldn't re-trigger the `on: push` half, so a separate "push the tag" workflow would silently never build. (`dry_run: true` builds the bottle for an existing tag without publishing.) Bottling matters because the deps include Rust-backed sdists (`pydantic-core`, `jiter`, `cryptography`) that would otherwise compile from source on `brew install`. The Homebrew formula builds from a git-less GitHub source tarball, so `Formula/assembly.rb`'s `def install` sets the generic `SETUPTOOLS_SCM_PRETEND_VERSION` env var (installing resources first under a clean env, then setting the var for our package only) to feed the tag version to the build. **`cut_release.sh` only runs from a clean `main` in sync with `origin/main`** (it hard-errors on a feature branch / dirty tree), so cut releases from `main`, not your working branch. The "update available" notice users see is `aai_cli/update_check.py`. ## Manual QA / running the CLI in sandboxed sessions diff --git a/scripts/cut_release.sh b/scripts/cut_release.sh index 54b14199..c5993077 100755 --- a/scripts/cut_release.sh +++ b/scripts/cut_release.sh @@ -1,7 +1,9 @@ #!/bin/sh # Cut an AssemblyAI CLI release: tag the version and push the tag, which triggers # .github/workflows/release.yml (builds the arm64 bottle, creates the GitHub -# Release, opens the formula PR). +# Release, opens the formula PR). release.yml's manual "Run workflow" dispatch +# runs these same steps in CI — so a Claude web session can cut a release with no +# local checkout — by calling this script with --no-push and pushing the tag itself. # # With hatch-vcs the git tag IS the version — there is no version file to bump # or version-bump PR to merge first. By default the script tags the next patch @@ -11,16 +13,19 @@ # ./scripts/cut_release.sh 0.2.0 # tag an explicit version instead # ./scripts/cut_release.sh --yes # skip the interactive confirmation # ./scripts/cut_release.sh -n # dry run: verify only, don't tag or push +# ./scripts/cut_release.sh --no-push # create the tag locally, don't push it set -eu ASSUME_YES=0 DRY_RUN=0 +NO_PUSH=0 for arg in "$@"; do case "$arg" in -y | --yes) ASSUME_YES=1 ;; -n | --dry-run) DRY_RUN=1 ;; + --no-push) NO_PUSH=1 ;; -h | --help) - sed -n '2,13p' "$0" | sed 's/^# \{0,1\}//' + sed -n '2,16p' "$0" | sed 's/^# \{0,1\}//' exit 0 ;; [0-9]*.[0-9]*.[0-9]*) EXPLICIT_VERSION="$arg" ;; @@ -107,6 +112,12 @@ if [ "$ASSUME_YES" -ne 1 ]; then fi git tag -a "$tag" -m "Release ${tag}" + +if [ "$NO_PUSH" -eq 1 ]; then + info "Created tag ${tag} locally; --no-push set, leaving the push to the caller." + exit 0 +fi + info "Created tag ${tag}. Pushing..." git push origin "$tag"