diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..38a0bfd --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,65 @@ +name: Release + +on: + release: + types: [published] + +# Deny by default; each job grants only what it needs. +permissions: {} + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + + - name: Build sdist and wheel + run: | + python -m pip install --upgrade pip build twine + python -m build + + - name: Verify the built version matches the release tag + env: + TAG: ${{ github.event.release.tag_name }} + run: | + BUILT=$(python -c "import sys; sys.path.insert(0, 'src'); from codesnake._version import __version__; print(__version__)") + echo "tag=$TAG built=$BUILT" + if [ "$TAG" != "v$BUILT" ]; then + echo "::error::Release tag $TAG does not match _version.py ($BUILT); refusing to publish." + exit 1 + fi + + - name: Check distribution metadata + run: twine check dist/* + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: dist + path: dist/ + if-no-files-found: error + + publish: + needs: build + runs-on: ubuntu-latest + # Gated: this environment requires a human to approve the deployment, so a + # tag push alone cannot ship a release. + environment: + name: pypi + url: https://pypi.org/p/codesnake + permissions: + # OIDC for PyPI Trusted Publishing. There is no API token to steal. + id-token: write + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: dist + path: dist/ + + - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..ba5aad3 --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,54 @@ +# Releasing + +Publishing is automated but deliberately not automatic: `.github/workflows/release.yml` runs when a GitHub Release is **published**, builds the distributions, and uploads them to PyPI through Trusted Publishing. + +## One-time PyPI setup + +CodeSnake publishes with **Trusted Publishing (OIDC)** rather than an API token. There is no secret stored in this repository — PyPI verifies a short-lived token minted by GitHub for this specific workflow. A stolen repository secret cannot publish, because there isn't one. + +Before the first release, add a pending publisher at + with exactly these values: + +| Field | Value | +|---|---| +| PyPI project name | `codesnake` | +| Owner | `bitWarrior` | +| Repository name | `codesnake` | +| Workflow name | `release.yml` | +| Environment name | `pypi` | + +All five must match or PyPI rejects the token exchange. The environment name is the one that gets forgotten. + +Consider claiming the name with a release to [TestPyPI](https://test.pypi.org) first, using the same flow with `repository-url: https://test.pypi.org/legacy/` on the publish step. + +## Cutting a release + +1. Bump `__version__` in `src/codesnake/_version.py`. That is the only place the version lives; `pyproject.toml` reads it via `tool.setuptools.dynamic`. +2. Open a PR with the bump and merge it once CI is green. +3. Tag the merge commit and push the tag: + ```bash + git tag -a v1.3.0 -m "CodeSnake 1.3.0" + git push origin v1.3.0 + ``` +4. Publish a GitHub Release on that tag with notes. Publishing is what starts the workflow. +5. Approve the `pypi` deployment when GitHub asks. + +The build job refuses to publish when the release tag and `_version.py` disagree, so a `v1.3.0` tag cannot ship a `1.2.0` artifact. + +## What protects the release path + +- **Trusted Publishing** — no long-lived credential exists to steal. +- **The `pypi` environment** — deployments are restricted to `v*` tags, and the publish job is the only thing granted `id-token: write`. +- **Split jobs** — the build job has `contents: read` and no OIDC; the publish job has OIDC and never checks out the repository. Code from the repo and the ability to publish never sit in the same job. +- **Pinned actions** — every action is pinned to a commit SHA, not a mutable tag. + +### Still to enable + +**Required reviewers on the `pypi` environment.** This is what makes publishing need a human, and it is the single most valuable control here. Environment protection rules are unavailable on private repositories on a free plan; add the rule as soon as the repository is public: + +```bash +gh api -X PUT repos/bitWarrior/codesnake/environments/pypi \ + -f 'reviewers[][type]=User' -F 'reviewers[][id]=164793' +``` + +Until then, publishing a GitHub Release ships to PyPI without a second gate.