Skip to content

Commit 052ad2e

Browse files
alexkromanalexkroman-assemblyclaude
authored
Ship a prebuilt Homebrew bottle on release; drop install.sh (#69)
* Add bottle release pipeline design spec Tag-triggered release.yml that builds the Python wheel/sdist + an arm64 macOS bottle, publishes them to the GitHub Release, and finalizes the Homebrew formula (url + sha256 + bottle block) so `brew install assembly` stops compiling the Rust deps from source. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Trim bottle pipeline spec to lean scope Drop release wheels/sdist, the moving stable tag, and the PEP 503 index; pipx/uv stay on git+https. Add install.sh removal. Reframe around the goal: simplest install/upgrade for Mac devs, minimal CI. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Add bottle release pipeline implementation plan Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * README: add `brew trust` and drop `--HEAD` from the Homebrew install Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * Clean up install.sh/install_script leftovers in pre-commit config The .pre-commit-config.yaml was missed by the removal grep (.yaml vs .yml); align its pytest -m filter with check.sh (exclude e2e + install) and fix the stale install.sh comments. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * Document the setup-homebrew bare-SHA pin in release.yml Match ci.yml's convention (monorepo subpath, no vX tag). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Harden release.yml: fix sha256 pin regex + make publish re-runnable - sha256 pin now consumes the whole placeholder line (matches formula-install); the old [0-9a-f]* regex left '* 64 # ...' dangling on the first release. - guard for exactly one bottle tarball before merge. - gh release create/upload and the formula branch/PR are now idempotent so a retried publish job doesn't die on 'already exists'. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * Add blank line before the release.yml steps list in release-prep Minor markdown style (MD032). Not gate-enforced (.claude/ is outside the markdownlint glob), just consistent list formatting. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Add scripts/cut_release.sh + document the release process cut_release.sh derives the version from pyproject.toml and tags/pushes only when the tree is clean, on main, and in sync with origin (gated by shellcheck). README's Development section gains a Releasing runbook (and fixes the stale `uv sync --extra dev`, which errors — dev is a dependency-group); release-prep step 3 now calls the script. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Alex Kroman <alex@assemblyai.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent cefa5ab commit 052ad2e

17 files changed

Lines changed: 1128 additions & 418 deletions

.claude/skills/check/SKILL.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ Run the project's canonical verification gate and report the result.
1616
./scripts/check.sh
1717
```
1818

19-
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.
19+
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.
2020

2121
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.
2222

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

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

3433
## Notes
Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
11
---
22
name: release-prep
3-
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.
3+
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.
44
disable-model-invocation: true
55
---
66

77
# release-prep
88

9-
Drive an `assembly` release to a verified, publishable state. Stop and report at the first failure — never push or publish on a red check.
9+
Drive an `assembly` release to a verified, tagged state. Stop and report at the first failure — never tag on a red check.
1010

1111
## 1. Version bump
1212

13-
- Update `version` in `pyproject.toml` (`[project]`). Confirm `aai_cli/__init__.py` `__version__` stays in sync (the `version` command and install smoke test read it).
13+
- Update `version` in `pyproject.toml` (`[project]`). Confirm `aai_cli/__init__.py` `__version__` stays in sync (the `version` command reads it).
1414
- Decide the bump (patch/minor/major) from what changed since the last tag; ask the user if it's ambiguous.
15+
- Land the bump via a normal PR (regular CI) before tagging.
1516

1617
## 2. Full gate
1718

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

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

24-
## 3. Real install smoke test
25+
## 3. Tag to trigger the bottle pipeline
2526

2627
```sh
27-
uv run pytest -q -m install_script
28+
./scripts/cut_release.sh
2829
```
2930

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

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

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

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

40-
## Distribution caveat
41+
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.
42+
43+
## 5. Verify the bottle
4144

42-
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.
45+
On a clean arm64 Mac:
4346

44-
## Output
47+
```sh
48+
brew update && brew install assembly # pulls the bottle — fast, no rust/llvm
49+
assembly --version # matches the tagged version
50+
```
51+
52+
## Distribution caveat
4553

46-
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.
54+
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.

.github/workflows/ci.yml

Lines changed: 0 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -190,48 +190,6 @@ jobs:
190190
# Append `--ignore-vuln <ID>` to accept an unfixable transitive advisory.
191191
python -m pip_audit
192192
193-
install-smoke:
194-
name: package install — real (${{ matrix.os }})
195-
strategy:
196-
fail-fast: false
197-
matrix:
198-
include:
199-
- os: ubuntu-latest
200-
kfilter: "" # all branches: pipx + pip --user + uv tool
201-
- os: macos-latest
202-
kfilter: '-k "pipx or uv_tool"' # pip --user is flaky on macOS (PEP 668)
203-
runs-on: ${{ matrix.os }}
204-
timeout-minutes: 15
205-
steps:
206-
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
207-
with:
208-
persist-credentials: false # no job pushes; don't leave the token in .git/config
209-
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
210-
with:
211-
python-version: "3.12"
212-
cache: pip
213-
214-
# `assembly --version` imports the package, which pulls in sounddevice (needs
215-
# PortAudio) and ffmpeg-backed sources. Match the other jobs' system deps.
216-
- name: System deps (Linux)
217-
if: runner.os == 'Linux'
218-
run: sudo apt-get update && sudo apt-get install -y libportaudio2 ffmpeg
219-
- name: System deps (macOS)
220-
if: runner.os == 'macOS'
221-
run: brew install portaudio ffmpeg
222-
223-
# Use the system interpreter (no virtualenv) so install.sh's `pip --user`
224-
# branch is allowed. Editable install makes `aai_cli` importable for the
225-
# test's __version__ check; uv builds the wheel and drives the uv tool
226-
# branch; pipx drives the pipx branch (which doubles as `pipx install`).
227-
- name: Tooling
228-
run: |
229-
python -m pip install --upgrade pip # need pip >= 25.1 for --group
230-
python -m pip install -e . --group dev uv pipx
231-
232-
- name: Real install smoke
233-
run: python -m pytest -q -m install_script ${{ matrix.kfilter }}
234-
235193
formula-install:
236194
# Real `brew install` of the Homebrew formula, built from THIS branch's source.
237195
# macOS only: that's where the README points brew users first, and where the

.github/workflows/release.yml

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
name: Release
2+
3+
# Cut a release by pushing a vX.Y.Z tag (after the version-bump PR merges).
4+
# Builds the arm64 macOS bottle, publishes it to the tag's GitHub Release, and
5+
# opens a formula PR (url + sha256 + bottle block) for a maintainer to merge.
6+
on:
7+
push:
8+
tags: ["v*"]
9+
# Manual dry-run: build the bottle for an existing tag WITHOUT publishing.
10+
workflow_dispatch:
11+
inputs:
12+
tag:
13+
description: "Existing tag to build a bottle for (dry-run; no publish)"
14+
required: true
15+
16+
permissions:
17+
contents: read
18+
19+
concurrency:
20+
group: ${{ github.workflow }}-${{ github.event.inputs.tag || github.ref }}
21+
cancel-in-progress: false
22+
23+
jobs:
24+
bottle:
25+
name: build arm64 bottle (macOS)
26+
runs-on: macos-14
27+
timeout-minutes: 40
28+
permissions:
29+
contents: read
30+
outputs:
31+
tag: ${{ steps.meta.outputs.tag }}
32+
steps:
33+
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
34+
with:
35+
persist-credentials: false # this job doesn't push
36+
# Homebrew/actions is a monorepo (setup-homebrew is a subpath); pin it to a
37+
# commit SHA like every other action here — Dependabot keeps it current.
38+
- uses: Homebrew/actions/setup-homebrew@2ebcf16054461267868620b1414507f3ccc765c1
39+
40+
- name: Resolve tag + source sha256
41+
id: meta
42+
env:
43+
# Pass via env (not inline ${{ }}) to satisfy zizmor template-injection.
44+
INPUT_TAG: ${{ github.event.inputs.tag }}
45+
REF_NAME: ${{ github.ref_name }}
46+
REPO: ${{ github.repository }}
47+
run: |
48+
set -euo pipefail
49+
tag="${INPUT_TAG:-$REF_NAME}"
50+
url="https://github.com/${REPO}/archive/refs/tags/${tag}.tar.gz"
51+
curl -fL "$url" -o source.tar.gz
52+
sha="$(shasum -a 256 source.tar.gz | awk '{print $1}')"
53+
{
54+
echo "tag=${tag}"
55+
echo "source_sha=${sha}"
56+
echo "root_url=https://github.com/${REPO}/releases/download/${tag}"
57+
} >> "$GITHUB_OUTPUT"
58+
59+
- name: Pin the formula to the release tag
60+
env:
61+
TAG: ${{ steps.meta.outputs.tag }}
62+
SOURCE_SHA: ${{ steps.meta.outputs.source_sha }}
63+
REPO: ${{ github.repository }}
64+
run: |
65+
set -euo pipefail
66+
python3 - <<'PY'
67+
import os, re, pathlib
68+
tag, sha, repo = os.environ["TAG"], os.environ["SOURCE_SHA"], os.environ["REPO"]
69+
url = f"https://github.com/{repo}/archive/refs/tags/{tag}.tar.gz"
70+
p = pathlib.Path("Formula/assembly.rb")
71+
src = p.read_text()
72+
src = re.sub(r'url ".*?"', f'url "{url}"', src, count=1)
73+
src = re.sub(r"sha256 .*", f'sha256 "{sha}"', src, count=1)
74+
p.write_text(src)
75+
PY
76+
grep -nE '^ (url|sha256) ' Formula/assembly.rb | head -2
77+
78+
- name: Build the bottle + merge the block into the formula
79+
env:
80+
ROOT_URL: ${{ steps.meta.outputs.root_url }}
81+
run: |
82+
set -euo pipefail
83+
brew tap-new --no-git assembly/local
84+
tap_formula="$(brew --repository assembly/local)/Formula/assembly.rb"
85+
cp Formula/assembly.rb "$tap_formula"
86+
brew install --build-bottle --formula assembly/local/assembly
87+
brew bottle --json --no-rebuild --root-url="$ROOT_URL" assembly/local/assembly
88+
for f in assembly--*.bottle.tar.gz; do mv "$f" "${f/--/-}"; done
89+
shopt -s nullglob
90+
tarballs=( assembly-*.bottle.tar.gz )
91+
shopt -u nullglob
92+
if [[ ${#tarballs[@]} -ne 1 ]]; then
93+
echo "Expected exactly one bottle tarball, found ${#tarballs[@]}: ${tarballs[*]:-none}" >&2
94+
exit 1
95+
fi
96+
brew bottle --merge --write --no-commit assembly--*.bottle.json
97+
cp "$tap_formula" Formula/assembly.rb
98+
echo "--- finalized formula head ---"
99+
sed -n '1,20p' Formula/assembly.rb
100+
101+
- name: Upload bottle + finalized formula
102+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
103+
with:
104+
name: release-artifacts
105+
path: |
106+
assembly-*.bottle.tar.gz
107+
Formula/assembly.rb
108+
if-no-files-found: error
109+
110+
publish:
111+
name: publish release + open formula PR
112+
needs: [bottle]
113+
if: github.event_name == 'push'
114+
runs-on: ubuntu-latest
115+
timeout-minutes: 10
116+
permissions:
117+
contents: write
118+
pull-requests: write
119+
steps:
120+
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
121+
with:
122+
persist-credentials: false # push via explicit tokened remote instead
123+
124+
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
125+
with:
126+
name: release-artifacts
127+
path: artifacts
128+
129+
- name: Create the GitHub Release with the bottle attached
130+
env:
131+
GH_TOKEN: ${{ github.token }}
132+
TAG: ${{ needs.bottle.outputs.tag }}
133+
run: |
134+
set -euo pipefail
135+
if ! gh release view "$TAG" >/dev/null 2>&1; then
136+
gh release create "$TAG" --title "$TAG" --generate-notes
137+
fi
138+
gh release upload "$TAG" artifacts/assembly-*.bottle.tar.gz --clobber
139+
140+
- name: Open the formula PR
141+
env:
142+
GH_TOKEN: ${{ github.token }}
143+
TAG: ${{ needs.bottle.outputs.tag }}
144+
GITHUB_REPOSITORY: ${{ github.repository }}
145+
run: |
146+
set -euo pipefail
147+
branch="release/${TAG}-formula"
148+
cp artifacts/Formula/assembly.rb Formula/assembly.rb
149+
git config user.name "github-actions[bot]"
150+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
151+
git checkout -B "$branch"
152+
git add Formula/assembly.rb
153+
git commit -m "Bottle ${TAG}: pin url + sha256, add arm64_sonoma bottle"
154+
git push --force "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "$branch"
155+
if ! gh pr view "$branch" >/dev/null 2>&1; then
156+
gh pr create --base main --head "$branch" \
157+
--title "Bottle ${TAG}" \
158+
--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."
159+
fi

.pre-commit-config.yaml

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ repos:
2424
# Lint the GitHub Actions workflows themselves (the jobs in ci.yml). Both hooks
2525
# self-scope to .github/workflows/. actionlint-py is the pip-packaged wrapper
2626
# (bundles the actionlint binary; no Go/Docker), and brings shellcheck-py so
27-
# embedded `run:` shell is shellcheck'd the same way check.sh checks install.sh.
27+
# embedded `run:` shell is shellcheck'd the same way check.sh checks scripts/check.sh.
2828
- repo: https://github.com/Mateusz-Grzelinski/actionlint-py
2929
rev: v1.7.12.24
3030
hooks:
@@ -48,11 +48,10 @@ repos:
4848
# them the moment an upstream release adds a value.
4949
- id: pytest
5050
name: pytest
51-
# Mirror check.sh: exclude both e2e (real API) and install_script (builds a
52-
# wheel + runs install.sh / pip --user). install_script has its own dedicated
53-
# "package install — real" CI jobs; running it here is slow and breaks under
54-
# the locked .venv (the user-site install can't import aai_cli).
55-
entry: uv run --frozen python -m pytest -q -m "not e2e and not install_script"
51+
# Mirror check.sh: exclude both e2e (real API) and install (real
52+
# init-template dependency installs — slow, needs network). Running either
53+
# here would be slow and would reach the network under the locked .venv.
54+
entry: uv run --frozen python -m pytest -q -m "not e2e and not install"
5655
language: system
5756
types: [python]
5857
pass_filenames: false

AGENTS.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,11 @@ uv run python scripts/mutation_sweep.py aai_cli/config.py # or omit paths for
5454

5555
### Test markers
5656

57-
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:
57+
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:
5858

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

6564
`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`)
6867

6968
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.
7069

71-
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`.
70+
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`.
7271

7372
### Writing tests that pass the diff gates
7473

0 commit comments

Comments
 (0)