Skip to content

feat: add Homebrew tap support with release auto-bump and plugin Python ABI guard#42

Merged
lukeocodes merged 6 commits intomainfrom
homebrew-release-automation
Apr 30, 2026
Merged

feat: add Homebrew tap support with release auto-bump and plugin Python ABI guard#42
lukeocodes merged 6 commits intomainfrom
homebrew-release-automation

Conversation

@lukeocodes
Copy link
Copy Markdown
Member

@lukeocodes lukeocodes commented Apr 29, 2026

Summary

Adds first-party Homebrew tap support for the Deepgram CLI. Three pieces of work that share the same lifecycle:

  1. README install instructions — adds brew tap deepgram/tap + brew install deepgram to "Quick Install" with a note that Homebrew handles ffmpeg + portaudio automatically.
  2. Release automation — new bump-brew-formula job in release.yml (gated on root v* tags only) that regenerates Formula/deepgram.rb on every CLI release using homebrew-pypi-poet, then opens a bump PR back against deepgram/homebrew-tap. Uses a fine-grained PAT (CLI_TAP_SYNC_PAT) scoped to homebrew-tap only (Contents: Write, Pull requests: Write) for cross-repo auth.
  3. Plugin venv ABI guard — the plugin system already routes Homebrew installs through IsolatedVenvStrategy, so plugins survive the install path by design. This adds the last edge case: when brew upgrade python@3.13 rebuilds the CLI against a newer Python without users recreating their plugin venv, surface a one-line warning with explicit remediation. Pure-Python plugins keep loading transparently; only C-extension plugins were affected.

What lands

Path What Why
README.md Homebrew block in Quick Install (two-step brew tap + brew install deepgram) Most discoverable Mac/Linux install path
scripts/templates/deepgram.rb.template Source of truth for the formula Hand-edit to change deps / install / test
scripts/bump_brew_formula.py Reads template + PyPI metadata + homebrew-pypi-poet output → renders formula Deterministic, byte-identical to the formula in the tap PR for a given version
.github/workflows/release.yml New bump-brew-formula job gated on root v* tags only Sub-package tags like deepctl-cmd-listen-v0.0.3 are skipped (matches existing mark-latest / deploy-web gating)
packages/deepctl-core/src/deepctl_core/plugin_env.py New get_venv_python_version() reads pyvenv.cfg, returns (major, minor) or None Handles both stdlib version key and uv-style version_info key
packages/deepctl-core/src/deepctl_core/plugin_manager.py New _warn_if_plugin_venv_python_mismatch() fires inside _load_plugin_venv_entries Pure-Python plugins still load via sys.path bridging; only the warning is new
packages/deepctl-core/tests/unit/test_plugin_env.py 6 new tests for get_venv_python_version covers missing venv / missing cfg / stdlib key / uv key / malformed / no version key
packages/deepctl-core/tests/unit/test_plugin_manager.py 4 new tests for the warning logic silent on unknown / silent on match / warns on minor diff / warns on major diff

Cross-repo auth

GITHUB_TOKEN is repo-scoped and cannot push to deepgram/homebrew-tap. We're using a fine-grained PAT — CLI_TAP_SYNC_PAT — scoped to deepgram/homebrew-tap only with the minimum permissions required:

  • Contents: Write — to push the version-bump branch
  • Pull requests: Write — to open the bump PR

Stored as a repo secret in deepgram/cli. Already created and added.

Commit attribution: the bump commit's author is set to the PAT owner (looked up via gh api /user) so the homebrew-tap PR has a clear, linkable author. Using ${{ github.actor }} would misattribute to release-please[bot] (the workflow trigger), not the credential owner.

Trade-off vs an org-owned GitHub App: the PAT is bound to a real user, so if that user rotates or revokes the token the workflow breaks until a new PAT is provisioned. Easy to migrate to an App later — the workflow's only secret reference is secrets.CLI_TAP_SYNC_PAT.

Verification

Check Result
pytest packages/deepctl-core/tests/unit/test_plugin_*.py -v 48 passed (10 new, 38 existing)
ruff check (changed src files) clean
ruff format --check (changed src files) clean
mypy --strict (changed src files) clean
python -c "import yaml; yaml.safe_load(open('.github/workflows/release.yml'))" parses, 7 jobs registered
Byte-identical reproduction scripts/bump_brew_formula.py --version 0.2.18 --dry-run produces output diff-equal to the formula in deepgram/homebrew-tap#1 (post-banner)

The first auto-bump after this lands will produce a clean version-only diff, not a churn diff.

What only a real release can validate

  • PyPI index lag staying under 10 min (empirically ~30s)
  • gh pr create from the runner correctly attributing the bump PR to the PAT owner

If any misfires on first run, the rest of the release pipeline is unaffected — the failure surface is contained to the bump job.

Companion PRs

Pre-existing comments / docstrings

Several comments in the new code were retained as Category 3 necessary:

  • Auto-generated banner on the formula template (prevents manual edits being silently overwritten)
  • setuptools<80 pin rationale in the bump script (prevents "tidy up pinned deps" PRs that would silently break it)
  • extract_resource_blocks rationale (documents the template-driven design vs naively using poet's full output)
  • Action-SHA-pin version comments in the workflow (matches existing pattern; required for security review of pin updates)
  • Click _DG_COMPLETE rationale in the template (prevents future "simplifications" that would silently break tab-completion for dg)
  • Plugin regression test rationale in the template's test block (prevents the test being deleted as "redundant" — it's the only Homebrew-specific install-path coverage)
  • get_venv_python_version and _warn_if_plugin_venv_python_mismatch docstrings (document the dual version/version_info key support, the None contract, and why we warn instead of block)

Each describes a behavioral contract that the code alone can't communicate; removing them would predictably lead to wrong-direction refactors.

Wires the new deepgram/homebrew-tap/Formula/deepgram.rb to regenerate
itself on every root v* release of deepctl.

Pieces:

- scripts/templates/deepgram.rb.template: source of truth for the formula.
  Contains the same hand-authored header / install / test as what's now in
  the tap, plus {{TARBALL_URL}} / {{TARBALL_SHA256}} / {{RESOURCES}}
  placeholders.

- scripts/bump_brew_formula.py: fetches PyPI metadata, runs
  homebrew-pypi-poet inside an isolated venv (with setuptools<80 to work
  around the pkg_resources removal in setuptools 80+), extracts only the
  resource blocks, and substitutes them into the template. Output is
  deterministic per (version, python_exe). mypy + ruff clean.

- .github/workflows/release.yml: adds a bump-brew-formula job gated on
  root v* tags only (sub-package tags like deepctl-cmd-listen-v0.0.3 are
  skipped, matching the existing mark-latest / deploy-web pattern). The
  job waits for the new version to appear on PyPI, mints a short-lived
  installation token via actions/create-github-app-token, checks out
  homebrew-tap, regenerates the formula, and opens a bump PR.

- docs/HOMEBREW_AUTOMATION.md: how the system fits together, the GitHub
  App security spec, credential names, manual/emergency bump steps, key
  rotation, and operational notes.

Verified locally: running scripts/bump_brew_formula.py --version 0.2.18
--dry-run produces a byte-identical formula to what's in the open
deepgram/homebrew-tap PR.

The job will fail until the GitHub App is created and the
HOMEBREW_BUMP_APP_ID variable + HOMEBREW_BUMP_APP_KEY secret are populated
in this repo's settings; the rest of the release pipeline is unaffected.
The bump script (scripts/bump_brew_formula.py), template, and workflow
job in release.yml are self-documenting:

- The script's --help output covers manual / emergency usage
- The workflow's job step names + comments cover the operational flow
- The auto-generated formula's banner points back to the template

The GitHub App setup spec lives outside this repo (in the security
team's intake — Slack / Notion / their tracker of choice). No reason
to mirror it here.
When a user installs deepctl via Homebrew (or any path that ends up using
IsolatedVenvStrategy), then later upgrades the underlying interpreter
(e.g. brew upgrade python@3.13), the running interpreter no longer
matches the Python that built ~/.deepctl/plugins/venv/. Pure-Python
plugins keep loading because PluginManager bridges them via sys.path,
but C-extension plugins fail with a confusing low-level ImportError that
gives users no obvious remediation.

This adds a one-line warning at plugin-load time that surfaces the
mismatch and tells the user how to rebuild:

    Plugin environment was built with Python 3.12 but you're running
    Python 3.13. C-extension plugins may fail to load. To rebuild:
      rm -rf ~/.deepctl/plugins/venv && deepctl plugin install <plugin>

Implementation:
- New get_venv_python_version() in plugin_env.py reads pyvenv.cfg and
  returns (major, minor). Handles both stdlib's 'version' key and uv's
  'version_info' key. Returns None on missing/malformed cfg so callers
  can treat 'unknown' as 'no warning'.
- New _warn_if_plugin_venv_python_mismatch() in PluginManager fires
  before sys.path bridging in _load_plugin_venv_entries, only when
  there's a real mismatch (no warning for unknown / matching versions).

Tests:
- 6 cases for get_venv_python_version (missing venv, missing cfg,
  stdlib 'version' key, uv 'version_info' key, malformed, no version key)
- 4 cases for the warning logic (silent on unknown, silent on match,
  warns on minor diff, warns on major diff)

All 48 plugin tests pass. mypy + ruff clean on changed src files.
Adds 'macOS / Linux (Homebrew)' as the first option under Quick Install:

  brew install deepgram/tap/deepgram

Documents that Homebrew handles ffmpeg + portaudio automatically (covers
dg listen --mic, dg debug probe, raw audio piping) and notes brew upgrade
deepgram for updates.

Companion to deepgram/homebrew-tap#1 (formula) and #42
(release-automation that keeps the formula fresh).
Switches the README's Homebrew snippet from the one-shot

  brew install deepgram/tap/deepgram

to the two-step

  brew tap deepgram/tap
  brew install deepgram

After tapping, future installs and upgrades use the bare formula name
(brew upgrade deepgram), which is what users will most often see and
type. The two-step form is also more explicit about what's being
tapped.
@lukeocodes lukeocodes changed the title chore(release): add Homebrew formula auto-bump workflow Homebrew tap support: README + release-automation + plugin venv ABI guard Apr 29, 2026
@lukeocodes lukeocodes changed the title Homebrew tap support: README + release-automation + plugin venv ABI guard feat(homebrew): homebrew tap support Apr 29, 2026
Replaces the actions/create-github-app-token step + APP_ID variable +
APP_KEY secret with a single fine-grained PAT scoped to deepgram/homebrew-tap
(Contents: Write, Pull requests: Write).

Workflow becomes shorter and removes the App-installation discovery step.
Trade-off: PRs are attributed to the PAT owner (a real GitHub user)
rather than a bot identity — which is fine since the PAT owner is the
explicit accountable party for these automated bumps.

Commit attribution uses the PAT owner's noreply email (looked up via
'gh api /user') instead of ${{ github.actor }} so commits show the
credential owner, not whoever triggered the release (which is typically
release-please[bot]).

Required repo secret: CLI_TAP_SYNC_PAT (already added).
@lukeocodes lukeocodes merged commit fb22d8a into main Apr 30, 2026
37 of 39 checks passed
@lukeocodes lukeocodes deleted the homebrew-release-automation branch April 30, 2026 12:09
@lukeocodes lukeocodes changed the title feat(homebrew): homebrew tap support feat: add Homebrew tap support with release auto-bump and plugin Python ABI guard Apr 30, 2026
lukeocodes added a commit that referenced this pull request May 1, 2026
## Summary

Restores the CI Lint job (\`uv run ruff format --check && ruff check &&
mypy --strict\` against \`src/\` + \`packages/*/src\`) to green. Was
failing on \`origin/main\` against pre-existing files, blocking every
PR's lint gate until either fixed or admin-overridden.

## What it does

| Fix | Why it was broken |
|---|---|
| \`ruff format\` 3 files | \`debug-toolkit/{command,fetcher}.py\` +
\`core/auth.py\` were committed unformatted (likely landed via [no-ci]
paths or admin merges) |
| Add \`deepgram_mcp\` + \`deepgram_mcp.*\` to the existing
\`ignore_missing_imports\` mypy override | The MCP SDK ships without
type stubs, so \`import deepgram_mcp\` produced \`import-untyped\`
errors. The existing override already covers \`sounddevice\`/\`numpy\`;
adding \`deepgram_mcp\` to the same list is the minimal fix. |
| Switch one import in \`mcp/models.py\` to \`from X import Y as Y\` |
The current \`from deepgram_mcp import TransportType\` + \`# noqa:
F401\` produced an \`attr-defined\` error when re-exported via
\`__init__.py\` under \`mypy --strict\`. The explicit \`as\` form is the
standard way to mark a re-export for strict mypy and drops the
now-redundant \`noqa\`. |

5 files, ~30 lines diff. **No logic changes** — pure config + format +
import-statement shape.

## Verification

```
uv run ruff format --check src/ packages/*/src   # 112 files already formatted
uv run ruff check src/ packages/*/src            # All checks passed!
uv run mypy src/ packages/*/src                  # Success: no issues found in 112 source files
```

These are the exact commands [CI's Lint
job](https://github.com/deepgram/cli/blob/main/.github/workflows/test.yml#L41-L65)
runs.

## Context

These two fix commits originally landed on the [\`#42\`
branch](#42) while it was open, but
#42 was admin-merged before the fix commits could be picked up. They got
stranded on the deleted branch. This PR brings them back as a single
small chore so future PRs aren't blocked.
lukeocodes added a commit that referenced this pull request May 1, 2026
…47)

## Summary

Two release-infrastructure fixes that need to land before
[#46](#46) (release-please's `chore:
release main`) is merged. Without this, `pip install deepctl==0.2.19`
will **fail to resolve dependencies** for every user.

## What's broken

### 🔴 Install-blocker: `deepctl-cmd-debug-toolkit` is a phantom package

The toolkit subcommand was added in
[`768f34f`](768f34f)
(`feat(debug): add toolkit subcommand for deepgram/support-toolkit
scripts`). The commit:

- ✅ Created `packages/deepctl-cmd-debug-toolkit/` with `pyproject.toml`,
`src/`, etc.
- ✅ Added `deepctl-cmd-debug-toolkit>=0.0.1` to the umbrella
`pyproject.toml` dependencies
- ✅ Added `[tool.uv.sources]` workspace mapping
- ❌ **Did not register the package in
[`release-please-config.json`](https://github.com/deepgram/cli/blob/main/.github/release-please-config.json)**
- ❌ **Did not register the package in
[`.release-please-manifest.json`](https://github.com/deepgram/cli/blob/main/.github/.release-please-manifest.json)**

Result: the package has never been built or published. `pip install
deepctl-cmd-debug-toolkit` returns `Not Found` on PyPI today. When
`deepctl 0.2.19` publishes with `deepctl-cmd-debug-toolkit>=0.0.1` in
its dependency list, pip will try to resolve that and fail with a
missing-distribution error.

[AGENTS.md step
6](https://github.com/deepgram/cli/blob/main/AGENTS.md#6-wire-into-the-workspace)
calls out exactly this set of files as the workspace integration
checklist for new packages — it just wasn't followed.

### 🟡 deepctl-core's plugin warning never reaches PyPI users


[`fb22d8a`](fb22d8a)
(the squash merge of #42) added `_warn_if_plugin_venv_python_mismatch()`
to `deepctl-core`'s `PluginManager`. But its squash-merge body included
markdown tables and `## Summary` / `## What lands` headings, and
`conventional-commits-parser` choked on it. The release-please run logs
show:

```
❯ commit could not be parsed: fb22d8a feat(homebrew): homebrew tap support (#42)
```

release-please skipped the commit entirely. So deepctl-core stayed at
**0.2.8** in the manifest, no bump, no PyPI release. The umbrella
`deepctl 0.2.19` will publish with `deepctl-core>=0.1.10` which pip
resolves to PyPI's 0.2.8 — **the version before the plugin warning was
added**. The warning code is on `main` but no PyPI user gets it.

## Fixes in this PR

1. **Register `deepctl-cmd-debug-toolkit` with release-please.**
- `.github/release-please-config.json` — add the package block (matches
the convention every other sub-package uses)
- `.github/.release-please-manifest.json` — add
`\"packages/deepctl-cmd-debug-toolkit\": \"0.0.0\"` so release-please's
first release bumps it to `0.0.1` (matches `pyproject.toml`)

2. **Trigger deepctl-core release with a parseable conventional
commit.**

Tweaks the plugin warning's remediation hint from `deepctl plugin
install` to `dg plugin install` — the canonical user-facing alias used
everywhere else in user messages and docs. Small real improvement. The
squash-merge title `feat(deepctl-core): ...` parses cleanly and gives
release-please a path-attributable feat on `packages/deepctl-core/*` to
detect.

3. **Regenerate stale READMEs.**

`scripts/generate_readmes.py --check` flagged 3 stale READMEs on `main`
(root, `deepctl-cmd-listen`, `deepctl-cmd-mcp`) plus the missing
`deepctl-cmd-debug-toolkit/README.md`. All four regenerated.

## After this merges

release-please will recompute and PR #46's body will look like:

```
<details><summary>0.2.19</summary>          # deepctl root
<details><summary>deepctl-cmd-debug-toolkit: 0.0.1</summary>   # NEW — fixes the install
<details><summary>deepctl-core: 0.2.9</summary>                # NEW — ships the plugin warning
```

Merging #46 then publishes all three to PyPI in one release event, and
`pip install deepctl==0.2.19` resolves end-to-end.

## Verification

- `ruff format --check src/ packages/*/src` — 112 files already
formatted
- `ruff check src/ packages/*/src` — clean
- `mypy --strict src/ packages/*/src` — clean (112 source files)
- `python scripts/generate_readmes.py --check` — all current
- 4 plugin-warning tests still pass (test message regex doesn't depend
on `deepctl` vs `dg`)

## Pre-existing comments / docstrings

A couple of necessary comments still in this PR were retained per
project convention:
- `_warn_if_plugin_venv_python_mismatch` docstring — Category 3,
documents the *when* (Homebrew Python upgrade scenario), the
*why-warn-instead-of-block* contract, and *None semantics*. Same
justification as in #42.
- The text change is one character (`deepctl` → `dg`) and a minor
improvement, not a behavioral change.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant