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
133 changes: 133 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# ADR-0007: a published GitHub release is built here from its tag and sent
# to PyPI by trusted publishing. No token is stored anywhere.
name: publish

on:
release:
types: [published]
# Dry run against an existing tag: builds, checks and compares, never publishes.
workflow_dispatch:
inputs:
tag:
description: "Release tag to build and check, e.g. v0.2.0"
required: true

permissions:
contents: read

concurrency:
group: publish-${{ inputs.tag || github.event.release.tag_name }}
cancel-in-progress: false

env:
TAG: ${{ inputs.tag || github.event.release.tag_name }}

jobs:
build:
name: build and check
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
attach: ${{ steps.compare.outputs.attach }}
steps:
- uses: actions/checkout@v4
with:
ref: refs/tags/${{ env.TAG }}
- uses: astral-sh/setup-uv@v6
with:
python-version: "3.12"
- name: Tag matches the package version
id: version
run: |
version="$(uv version --short)"
if [ "v${version}" != "${TAG}" ]; then
echo "::error::tag ${TAG} does not match version ${version} in pyproject.toml"
exit 1
fi
echo "version=${version}" >> "$GITHUB_OUTPUT"
- name: Build
run: uv build
- name: twine check
run: uvx twine check --strict dist/*
- name: Install the wheel as a tool and run it from another directory
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
uv tool install dist/*.whl
cd "${RUNNER_TEMP}"
got="$(jevify --version)"
if [ "${got}" != "jevify ${VERSION}" ]; then
echo "::error::installed wheel reports '${got}', expected 'jevify ${VERSION}'"
exit 1
fi
- name: Compare with the files attached to the release
id: compare
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: |
attached_names="$(gh release view "${TAG}" --json assets \
-q '.assets[].name | select(endswith(".whl") or endswith(".tar.gz"))')"
if [ -z "${attached_names}" ]; then
echo "No wheel or sdist attached to ${TAG}; this build is attached after publishing."
echo "attach=true" >> "$GITHUB_OUTPUT"
exit 0
fi
dir="${RUNNER_TEMP}/attached"
mkdir -p "${dir}"
gh release download "${TAG}" -p '*.whl' -p '*.tar.gz' -D "${dir}"
built="$(cd dist && sha256sum -- *.whl *.tar.gz)"
attached="$(cd "${dir}" && sha256sum -- *.whl *.tar.gz)"
if [ "${built}" != "${attached}" ]; then
echo "::error::the files attached to ${TAG} differ from this build of the tag"
echo "built:"; echo "${built}"
echo "attached:"; echo "${attached}"
exit 1
fi
echo "Attached files are byte-identical to this build:"
echo "${built}"
echo "attach=false" >> "$GITHUB_OUTPUT"
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
if-no-files-found: error

publish:
name: publish to PyPI
needs: build
if: github.event_name == 'release'
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/project/jevify/${{ needs.build.outputs.version }}/
permissions:
id-token: write
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- uses: pypa/gh-action-pypi-publish@release/v1

attach:
name: attach build to the release
needs: [build, publish]
if: github.event_name == 'release' && needs.build.outputs.attach == 'true'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Upload wheel, sdist and SHA256SUMS
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: |
cd dist
sha256sum -- *.whl *.tar.gz > SHA256SUMS
cat SHA256SUMS
gh release upload "${TAG}" *.whl *.tar.gz SHA256SUMS
65 changes: 65 additions & 0 deletions docs/adr/0007-release-publishing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# ADR-0007 — Releases reach PyPI from CI, by trusted publishing

- **Status:** accepted (2026-11-04).
- **Extends:** ADR-0006 D4 (build and distribution); records who builds the
files that go to PyPI and how they get there.

## Context

0.1.0 and 0.2.0 were built by hand with `uv build` from their tags, attached
to the GitHub release with a `SHA256SUMS`, and uploaded to PyPI with a token.
The upload step needed a PyPI token on the operator's machine, and the house
rule keeps secrets out of files, logs and chat. A rebuild of `v0.2.0` from a
fresh clone produced files byte-identical to the ones attached to the
release, so the build is reproducible and a second build can check the first.

## Decision

### D1 — Publishing a GitHub release publishes to PyPI

`.github/workflows/publish.yml` runs when a release is published. It checks
out the release tag, refuses a tag that is not `v` + the version in
`pyproject.toml`, builds with `uv build`, runs `twine check --strict`, and
installs the wheel as a tool and runs `jevify --version`.

### D2 — Trusted publishing, no stored token

The upload uses PyPI trusted publishing (OIDC) from the `publish` job in the
GitHub environment `pypi`. No PyPI token exists in the repository, its
secrets or the operator's machine. The PyPI project's trusted publisher names
this repository, the workflow file `publish.yml` and the environment `pypi`;
renaming either breaks publishing until PyPI is updated.

### D3 — The CI build must match what the release shows

If the release already carries a wheel and an sdist, the CI build must be
byte-identical to them or the workflow stops before uploading. If it carries
none, the CI build is uploaded to PyPI and then attached to the release with
a `SHA256SUMS`. Either way the files on PyPI and on the GitHub release are
the same bytes. Files are attached while the release is a draft, before it
is published, so the workflow never races a half-attached release.

### D4 — Dry run

`workflow_dispatch` with a `tag` runs the same build and checks against an
existing release without publishing.

## Consequences

- Cutting a release is: bump the version, merge, tag, then publish a GitHub
release from the tag. Publishing it is the PyPI upload; there is no
separate step.
- Each job holds only the permission it needs: `contents: read` to build,
`id-token: write` to publish, `contents: write` to attach.
- A non-reproducible build (a new hatchling changing metadata, say) shows up
as a mismatch in D3 when files were attached by hand. The remedy is to let
CI attach them.

## Rejected alternatives

- **An API token in repository secrets.** It works, but it is a long-lived
secret that trusted publishing makes unnecessary.
- **Uploading the hand-attached files as they are.** PyPI would carry files
that no CI run built; D3 keeps hand-built files allowed but checked.
- **Publishing on tag push.** A tag is not a release note; the GitHub release
is the reviewed announcement, so it is the trigger.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@ later documents can cite "ADR-0002 D3".
| [0004](0004-recipes.md) | Recipes: the hashed file that is the only way a model is asked | Accepted (2026-09-19) |
| [0005](0005-evaluation-evidence-and-calibration.md) | Suites, scorecards, committed evidence, post-hoc calibration | Accepted (2026-09-19) |
| [0006](0006-toolchain-dependency-budget-and-portability.md) | Toolchain, dependency budget and portability: five light core packages, extras, argparse, uv/pipx, Python 3.12–3.14, no lock-in | Accepted (2026-09-19); makes ADR-0001 D4 precise |
| [0007](0007-release-publishing.md) | Releases reach PyPI from CI by trusted publishing; the CI build must match the release files | Accepted (2026-11-04) |
Loading