Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
54 changes: 54 additions & 0 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
@@ -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
<https://pypi.org/manage/account/publishing/> 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.
Loading