vocalbin 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ OPENAI_API_KEY=your_openai_api_key_here
@@ -0,0 +1,109 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ build:
13
+ runs-on: ubuntu-latest
14
+ steps:
15
+ - name: Check out repository
16
+ uses: actions/checkout@v7
17
+ with:
18
+ fetch-depth: 0
19
+
20
+ - name: Install uv and Python
21
+ uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
22
+ with:
23
+ version: "0.11.30"
24
+ python-version: "3.13"
25
+ enable-cache: true
26
+
27
+ - name: Verify release tag
28
+ shell: bash
29
+ run: |
30
+ git fetch origin main
31
+ git merge-base --is-ancestor "$GITHUB_SHA" origin/main
32
+ tag_version="${GITHUB_REF_NAME#v}"
33
+ project_version="$(uv version --short)"
34
+ if [[ "$tag_version" != "$project_version" ]]; then
35
+ echo "Tag version $tag_version does not match project version $project_version."
36
+ exit 1
37
+ fi
38
+
39
+ - name: Install locked dependencies
40
+ run: uv sync --locked --dev --group release
41
+
42
+ - name: Run unit tests
43
+ run: uv run pytest
44
+
45
+ - name: Build distributions
46
+ run: uv build --out-dir dist
47
+
48
+ - name: Check distribution metadata
49
+ run: uv run twine check dist/*
50
+
51
+ - name: Smoke-test wheel
52
+ shell: bash
53
+ run: |
54
+ wheel="$(find dist -maxdepth 1 -type f -name '*.whl' -print -quit)"
55
+ if [[ -z "$wheel" ]]; then
56
+ echo "No wheel found in dist/."
57
+ exit 1
58
+ fi
59
+ uv venv --python 3.13 .smoke-venv
60
+ uv pip install --python .smoke-venv/bin/python "$wheel"
61
+ .smoke-venv/bin/python -c "import vocalbin; from importlib.metadata import version; print(version('vocalbin'))"
62
+
63
+ - name: Upload distributions
64
+ uses: actions/upload-artifact@v7
65
+ with:
66
+ name: python-package-distributions
67
+ path: dist/
68
+ if-no-files-found: error
69
+ retention-days: 7
70
+
71
+ publish-pypi:
72
+ needs: build
73
+ runs-on: ubuntu-latest
74
+ environment:
75
+ name: pypi
76
+ url: https://pypi.org/p/vocalbin
77
+ permissions:
78
+ id-token: write
79
+ steps:
80
+ - name: Download distributions
81
+ uses: actions/download-artifact@v8
82
+ with:
83
+ name: python-package-distributions
84
+ path: dist/
85
+
86
+ - name: Publish to PyPI
87
+ uses: pypa/gh-action-pypi-publish@v1.14.0
88
+
89
+ github-release:
90
+ needs: publish-pypi
91
+ runs-on: ubuntu-latest
92
+ permissions:
93
+ contents: write
94
+ steps:
95
+ - name: Download distributions
96
+ uses: actions/download-artifact@v8
97
+ with:
98
+ name: python-package-distributions
99
+ path: dist/
100
+
101
+ - name: Create GitHub release
102
+ env:
103
+ GH_TOKEN: ${{ github.token }}
104
+ run: >-
105
+ gh release create "$GITHUB_REF_NAME" dist/*
106
+ --repo "$GITHUB_REPOSITORY"
107
+ --verify-tag
108
+ --generate-notes
109
+ --title "$GITHUB_REF_NAME"
@@ -0,0 +1,30 @@
1
+ name: Tests
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ pull_request:
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ jobs:
13
+ unit-tests:
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - name: Check out repository
17
+ uses: actions/checkout@v7
18
+
19
+ - name: Install uv and Python
20
+ uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
21
+ with:
22
+ version: "0.11.30"
23
+ python-version: "3.13"
24
+ enable-cache: true
25
+
26
+ - name: Install dependencies
27
+ run: uv sync --locked --dev
28
+
29
+ - name: Run unit tests
30
+ run: uv run pytest
@@ -0,0 +1,86 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ *.manifest
31
+ *.spec
32
+
33
+ # Installer logs
34
+ pip-log.txt
35
+ pip-delete-this-directory.txt
36
+
37
+ # Unit test / coverage reports
38
+ htmlcov/
39
+ .tox/
40
+ .nox/
41
+ .coverage
42
+ .coverage.*
43
+ .cache
44
+ nosetests.xml
45
+ coverage.xml
46
+ *.cover
47
+ *.py,cover
48
+ .hypothesis/
49
+ .pytest_cache/
50
+
51
+ # Jupyter Notebook
52
+ .ipynb_checkpoints
53
+
54
+ # Environments
55
+ .env
56
+ .venv
57
+ env/
58
+ venv/
59
+ ENV/
60
+ env.bak/
61
+ venv.bak/
62
+
63
+ # mypy
64
+ .mypy_cache/
65
+ .dmypy.json
66
+ dmypy.json
67
+
68
+ # Pyre type checker
69
+ .pyre/
70
+
71
+ # pytype static type analyzer
72
+ .pytype/
73
+
74
+ # ruff
75
+ .ruff_cache/
76
+
77
+ # IDEs
78
+ .idea/
79
+ .vscode/
80
+
81
+ # Claude Code
82
+ .claude/
83
+
84
+ # OS
85
+ .DS_Store
86
+ Thumbs.db
@@ -0,0 +1,18 @@
1
+ repos:
2
+ - repo: https://github.com/pre-commit/pre-commit-hooks
3
+ rev: v5.0.0
4
+ hooks:
5
+ - id: trailing-whitespace
6
+ - id: end-of-file-fixer
7
+ - id: check-yaml
8
+ - id: check-toml
9
+ - id: check-added-large-files
10
+ - id: check-merge-conflict
11
+ - id: debug-statements
12
+
13
+ - repo: https://github.com/astral-sh/ruff-pre-commit
14
+ rev: v0.12.0
15
+ hooks:
16
+ - id: ruff
17
+ args: [--fix]
18
+ - id: ruff-format
@@ -0,0 +1 @@
1
+ 3.13
@@ -0,0 +1,9 @@
1
+ # Repository conventions
2
+
3
+ - Use relative imports for package re-exports in `__init__.py`; use absolute imports in all other modules.
4
+ - Model provider interfaces with `ABC` and `@abstractmethod`, not `Protocol`.
5
+ - Keep provider ports in `vocalbin/ports.py`.
6
+ - Keep credential loading in `vocalbin/credentials.py` and use `pydantic-settings`.
7
+ - Avoid comments and docstrings that merely restate what the code already says.
8
+ - Add a comment only when it explains a non-obvious reason, constraint, or tradeoff.
9
+ - Do not add module docstrings that only summarize the module name or contents.
@@ -0,0 +1,63 @@
1
+ # Contributing to vocalbin
2
+
3
+ Thanks for your interest in improving `vocalbin`. This project is intentionally
4
+ small: a typed, asynchronous wrapper around OpenAI's speech-to-text and
5
+ text-to-speech endpoints. A small API surface, clear types, few dependencies and
6
+ predictable behavior matter more than a long feature list.
7
+
8
+ ## Scope: read the Vision first
9
+
10
+ Before opening an issue or pull request, please read [`vision.md`](vision.md). It
11
+ defines what this library does, what it deliberately does **not** do, and the
12
+ guardrails for future changes.
13
+
14
+ A change is in scope only if you can answer **yes** to the five questions listed
15
+ under _"Leitlinie für zukünftige Pull Requests"_ in [`vision.md`](vision.md) — in
16
+ short:
17
+
18
+ 1. Does it directly support speech-to-text or text-to-speech via the OpenAI Audio API?
19
+ 2. Does it fit the existing request/response/port/client boundaries without adding
20
+ application-specific dependencies?
21
+ 3. Does the public API stay small, async, typed and understandable?
22
+ 4. Is the added dependency or complexity justified by a concrete benefit?
23
+ 5. Are behavior, validation and backward compatibility covered by tests?
24
+
25
+ Work that needs audio processing, framework integration, persistence or
26
+ orchestration belongs in the calling application or a separate package, not here.
27
+
28
+ ## Development setup
29
+
30
+ ```bash
31
+ uv sync
32
+ uv run pytest
33
+ ```
34
+
35
+ Tests run offline against fake clients, so no `OPENAI_API_KEY` is required for the
36
+ suite. A key is only needed to run the scripts in [`examples/`](examples/) against
37
+ the real API.
38
+
39
+ ## Code conventions
40
+
41
+ These follow [`AGENTS.md`](AGENTS.md):
42
+
43
+ - Use relative imports for package re-exports in `__init__.py`; use absolute
44
+ imports everywhere else.
45
+ - Model provider interfaces with `ABC` and `@abstractmethod`, not `Protocol`.
46
+ Keep the provider ports in `vocalbin/ports.py`.
47
+ - Avoid comments and docstrings that merely restate the code. Add a comment only
48
+ when it explains a non-obvious reason, constraint or tradeoff.
49
+ - Validate model capabilities up front (see `vocalbin/models.py`) and keep raw
50
+ provider data available on responses rather than discarding it.
51
+
52
+ ## Pull requests
53
+
54
+ - Keep changes focused; split unrelated work into separate pull requests.
55
+ - Add or update tests for any behavior, validation rule or capability change.
56
+ - Update the [`README.md`](README.md) and the relevant script(s) in
57
+ [`examples/`](examples/) when you add or change a model, voice, format or
58
+ parameter.
59
+ - Make sure `uv run pytest` passes before submitting.
60
+
61
+ A pull request that widens the project's scope only because the implementation is
62
+ technically possible here should be declined or split, as described in
63
+ [`vision.md`](vision.md).
vocalbin-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 mathisarends
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,152 @@
1
+ Metadata-Version: 2.4
2
+ Name: vocalbin
3
+ Version: 0.1.0
4
+ Summary: Typed async wrappers for OpenAI speech-to-text and text-to-speech APIs
5
+ License-File: LICENSE
6
+ Requires-Python: >=3.13
7
+ Requires-Dist: openai>=2.46.0
8
+ Requires-Dist: pydantic-settings>=2.14.2
9
+ Requires-Dist: pydantic>=2.13.4
10
+ Description-Content-Type: text/markdown
11
+
12
+ # 🎙️ vocalbin
13
+
14
+ ![vocalbin — typed, async voice for OpenAI](static/banner.png)
15
+
16
+ `vocalbin` is a small, typed, asynchronous wrapper around OpenAI's speech-to-text
17
+ and text-to-speech endpoints. It validates model capabilities up front, normalizes
18
+ responses without discarding raw data, and stays independent of any
19
+ application-specific settings or domain code.
20
+
21
+ ## Installation
22
+
23
+ ```bash
24
+ uv add vocalbin
25
+ ```
26
+
27
+ Set `OPENAI_API_KEY` in the environment, or pass an API key directly when creating
28
+ a service. The default path reads the environment through `OpenAICredentials`:
29
+
30
+ ```python
31
+ from vocalbin import OpenAICredentials
32
+
33
+ credentials = OpenAICredentials()
34
+ api_key = credentials.api_key.get_secret_value()
35
+ ```
36
+
37
+ An explicit `api_key` takes precedence over the environment. An injected
38
+ `AsyncOpenAI` client does not load credentials at all.
39
+
40
+ ## Speech to text
41
+
42
+ ```python
43
+ from pathlib import Path
44
+
45
+ from vocalbin import OpenAISpeechToText, SpeechToTextRequest
46
+
47
+
48
+ async def transcribe() -> str:
49
+ async with OpenAISpeechToText() as speech_to_text:
50
+ response = await speech_to_text.transcribe(
51
+ SpeechToTextRequest(audio_path=Path("speech.wav"), language="de")
52
+ )
53
+ return response.text
54
+ ```
55
+
56
+ Audio can also be supplied directly as bytes; `filename` only sets the multipart
57
+ upload name:
58
+
59
+ ```python
60
+ request = SpeechToTextRequest(audio=audio_bytes, filename="speech.wav")
61
+ ```
62
+
63
+ Every request carries the transcript on `response.text` and the untouched provider
64
+ payload on `response.raw` (a `dict` for JSON-like formats, a `str` for `text`,
65
+ `srt` and `vtt`).
66
+
67
+ ## Text to speech
68
+
69
+ ```python
70
+ from vocalbin import (
71
+ OpenAITextToSpeech,
72
+ TextToSpeechFormat,
73
+ TextToSpeechRequest,
74
+ TextToSpeechVoice,
75
+ )
76
+
77
+
78
+ async def synthesize() -> bytes:
79
+ async with OpenAITextToSpeech() as text_to_speech:
80
+ response = await text_to_speech.synthesize(
81
+ TextToSpeechRequest(
82
+ text="Hallo aus vocalbin!",
83
+ voice=TextToSpeechVoice.MARIN,
84
+ response_format=TextToSpeechFormat.MP3,
85
+ instructions="Sprich ruhig und freundlich.",
86
+ )
87
+ )
88
+ return response.audio
89
+ ```
90
+
91
+ `response.content_type` gives the matching MIME type (e.g. `audio/mpeg`).
92
+
93
+ ## Supported models, voices and formats
94
+
95
+ **Speech to text** — `gpt-4o-transcribe`, `gpt-4o-mini-transcribe`,
96
+ `gpt-4o-transcribe-diarize`, `whisper-1`. Response formats and options are
97
+ validated per model (for example, `timestamp_granularities` require `whisper-1`
98
+ with `verbose_json`, and `include=["logprobs"]` requires a GPT transcription model
99
+ with `json`).
100
+
101
+ **Text to speech** — `gpt-4o-mini-tts`, `tts-1`, `tts-1-hd`; output formats `mp3`,
102
+ `opus`, `aac`, `flac`, `wav`, `pcm`. The legacy `tts-1`/`tts-1-hd` models accept
103
+ only the legacy voices and do not support `instructions`.
104
+
105
+ ## Examples
106
+
107
+ The [`examples/`](examples/) directory holds runnable, integration-testable scripts
108
+ that exercise every model/voice/format combination and double as documentation.
109
+ With a valid `OPENAI_API_KEY` set:
110
+
111
+ ```bash
112
+ uv run python examples/text_to_speech.py # every TTS model, voice and format
113
+ uv run python examples/speech_to_text.py # every STT model and response format
114
+ uv run python examples/round_trip.py # synthesize -> transcribe, self-checking
115
+ uv run python examples/shared_client.py # one AsyncOpenAI client for both services
116
+ ```
117
+
118
+ Generated audio and transcripts are written to `examples/output/` (git-ignored).
119
+ `speech_to_text.py` synthesizes its own `sample.wav` on first run, so it needs no
120
+ external audio file.
121
+
122
+ ## Bring your own client
123
+
124
+ Both concrete services accept an existing `AsyncOpenAI` instance via `client=`,
125
+ which lets you share one configured client (custom `base_url`, timeouts, retries)
126
+ across both services. Injected clients remain owned by the caller and are not
127
+ closed by `vocalbin`:
128
+
129
+ ```python
130
+ from openai import AsyncOpenAI
131
+
132
+ from vocalbin import OpenAISpeechToText, OpenAITextToSpeech
133
+
134
+ client = AsyncOpenAI()
135
+ tts = OpenAITextToSpeech(client=client)
136
+ stt = OpenAISpeechToText(client=client)
137
+ # ... use both, then close it yourself:
138
+ await client.close()
139
+ ```
140
+
141
+ ## Ports
142
+
143
+ The provider-independent `SpeechToText` and `TextToSpeech` ports are abstract base
144
+ classes (`vocalbin/ports.py`). They mark the boundary of the library, so callers
145
+ can depend on the interface rather than the OpenAI implementation.
146
+
147
+ ## Development
148
+
149
+ ```bash
150
+ uv sync
151
+ uv run pytest
152
+ ```
@@ -0,0 +1,141 @@
1
+ # 🎙️ vocalbin
2
+
3
+ ![vocalbin — typed, async voice for OpenAI](static/banner.png)
4
+
5
+ `vocalbin` is a small, typed, asynchronous wrapper around OpenAI's speech-to-text
6
+ and text-to-speech endpoints. It validates model capabilities up front, normalizes
7
+ responses without discarding raw data, and stays independent of any
8
+ application-specific settings or domain code.
9
+
10
+ ## Installation
11
+
12
+ ```bash
13
+ uv add vocalbin
14
+ ```
15
+
16
+ Set `OPENAI_API_KEY` in the environment, or pass an API key directly when creating
17
+ a service. The default path reads the environment through `OpenAICredentials`:
18
+
19
+ ```python
20
+ from vocalbin import OpenAICredentials
21
+
22
+ credentials = OpenAICredentials()
23
+ api_key = credentials.api_key.get_secret_value()
24
+ ```
25
+
26
+ An explicit `api_key` takes precedence over the environment. An injected
27
+ `AsyncOpenAI` client does not load credentials at all.
28
+
29
+ ## Speech to text
30
+
31
+ ```python
32
+ from pathlib import Path
33
+
34
+ from vocalbin import OpenAISpeechToText, SpeechToTextRequest
35
+
36
+
37
+ async def transcribe() -> str:
38
+ async with OpenAISpeechToText() as speech_to_text:
39
+ response = await speech_to_text.transcribe(
40
+ SpeechToTextRequest(audio_path=Path("speech.wav"), language="de")
41
+ )
42
+ return response.text
43
+ ```
44
+
45
+ Audio can also be supplied directly as bytes; `filename` only sets the multipart
46
+ upload name:
47
+
48
+ ```python
49
+ request = SpeechToTextRequest(audio=audio_bytes, filename="speech.wav")
50
+ ```
51
+
52
+ Every request carries the transcript on `response.text` and the untouched provider
53
+ payload on `response.raw` (a `dict` for JSON-like formats, a `str` for `text`,
54
+ `srt` and `vtt`).
55
+
56
+ ## Text to speech
57
+
58
+ ```python
59
+ from vocalbin import (
60
+ OpenAITextToSpeech,
61
+ TextToSpeechFormat,
62
+ TextToSpeechRequest,
63
+ TextToSpeechVoice,
64
+ )
65
+
66
+
67
+ async def synthesize() -> bytes:
68
+ async with OpenAITextToSpeech() as text_to_speech:
69
+ response = await text_to_speech.synthesize(
70
+ TextToSpeechRequest(
71
+ text="Hallo aus vocalbin!",
72
+ voice=TextToSpeechVoice.MARIN,
73
+ response_format=TextToSpeechFormat.MP3,
74
+ instructions="Sprich ruhig und freundlich.",
75
+ )
76
+ )
77
+ return response.audio
78
+ ```
79
+
80
+ `response.content_type` gives the matching MIME type (e.g. `audio/mpeg`).
81
+
82
+ ## Supported models, voices and formats
83
+
84
+ **Speech to text** — `gpt-4o-transcribe`, `gpt-4o-mini-transcribe`,
85
+ `gpt-4o-transcribe-diarize`, `whisper-1`. Response formats and options are
86
+ validated per model (for example, `timestamp_granularities` require `whisper-1`
87
+ with `verbose_json`, and `include=["logprobs"]` requires a GPT transcription model
88
+ with `json`).
89
+
90
+ **Text to speech** — `gpt-4o-mini-tts`, `tts-1`, `tts-1-hd`; output formats `mp3`,
91
+ `opus`, `aac`, `flac`, `wav`, `pcm`. The legacy `tts-1`/`tts-1-hd` models accept
92
+ only the legacy voices and do not support `instructions`.
93
+
94
+ ## Examples
95
+
96
+ The [`examples/`](examples/) directory holds runnable, integration-testable scripts
97
+ that exercise every model/voice/format combination and double as documentation.
98
+ With a valid `OPENAI_API_KEY` set:
99
+
100
+ ```bash
101
+ uv run python examples/text_to_speech.py # every TTS model, voice and format
102
+ uv run python examples/speech_to_text.py # every STT model and response format
103
+ uv run python examples/round_trip.py # synthesize -> transcribe, self-checking
104
+ uv run python examples/shared_client.py # one AsyncOpenAI client for both services
105
+ ```
106
+
107
+ Generated audio and transcripts are written to `examples/output/` (git-ignored).
108
+ `speech_to_text.py` synthesizes its own `sample.wav` on first run, so it needs no
109
+ external audio file.
110
+
111
+ ## Bring your own client
112
+
113
+ Both concrete services accept an existing `AsyncOpenAI` instance via `client=`,
114
+ which lets you share one configured client (custom `base_url`, timeouts, retries)
115
+ across both services. Injected clients remain owned by the caller and are not
116
+ closed by `vocalbin`:
117
+
118
+ ```python
119
+ from openai import AsyncOpenAI
120
+
121
+ from vocalbin import OpenAISpeechToText, OpenAITextToSpeech
122
+
123
+ client = AsyncOpenAI()
124
+ tts = OpenAITextToSpeech(client=client)
125
+ stt = OpenAISpeechToText(client=client)
126
+ # ... use both, then close it yourself:
127
+ await client.close()
128
+ ```
129
+
130
+ ## Ports
131
+
132
+ The provider-independent `SpeechToText` and `TextToSpeech` ports are abstract base
133
+ classes (`vocalbin/ports.py`). They mark the boundary of the library, so callers
134
+ can depend on the interface rather than the OpenAI implementation.
135
+
136
+ ## Development
137
+
138
+ ```bash
139
+ uv sync
140
+ uv run pytest
141
+ ```
@@ -0,0 +1,61 @@
1
+ # Deployment
2
+
3
+ `.github/workflows/release.yml` publishes `vocalbin` to PyPI when a `v*` tag is
4
+ pushed. Authentication uses PyPI Trusted Publishing (OIDC); no API token is
5
+ stored in GitHub.
6
+
7
+ ## One-time setup
8
+
9
+ 1. Create a GitHub environment named `pypi` under **Settings > Environments**.
10
+ Optionally require approval and restrict deployments to tags matching `v*`.
11
+ 2. Add a GitHub Trusted Publisher to the `vocalbin` project on PyPI:
12
+
13
+ | Setting | Value |
14
+ | --- | --- |
15
+ | Owner | `mathisarends` |
16
+ | Repository | `vocalbin` |
17
+ | Workflow | `release.yml` |
18
+ | Environment | `pypi` |
19
+
20
+ If the PyPI project does not exist yet, create a pending publisher at
21
+ <https://pypi.org/manage/account/publishing/> using the same values.
22
+
23
+ ## Release
24
+
25
+ Set and commit the release version:
26
+
27
+ ```bash
28
+ uv version 0.2.0
29
+ uv run pytest
30
+ git add pyproject.toml uv.lock
31
+ git commit -m "Release 0.2.0"
32
+ git push origin main
33
+ ```
34
+
35
+ After the `Tests` workflow succeeds on `main`, create the matching tag:
36
+
37
+ ```bash
38
+ version="$(uv version --short)"
39
+ git tag -a "v$version" -m "Release v$version"
40
+ git push origin "v$version"
41
+ ```
42
+
43
+ The release workflow then:
44
+
45
+ 1. Confirms that the tagged commit belongs to `main` and the tag matches the
46
+ version in `pyproject.toml`.
47
+ 2. Installs the locked dependencies and runs all tests with 100% statement and
48
+ branch coverage.
49
+ 3. Builds the wheel and source distribution, checks their metadata, and installs
50
+ the wheel in a clean environment.
51
+ 4. Publishes the verified artifacts to PyPI through OIDC.
52
+ 5. Creates a GitHub Release with generated notes and both distributions attached.
53
+
54
+ ## Failures
55
+
56
+ - PyPI versions are immutable. Fix a broken release with a new version; yank the
57
+ old version if necessary.
58
+ - An OIDC error usually means the owner, repository, workflow, or environment
59
+ differs from the PyPI Trusted Publisher configuration.
60
+ - A mismatched tag is rejected before publishing. Create a tag matching
61
+ `uv version --short`.