Everything PyPI needs, and no way to reach it by accident
The distribution is named `mesh-term`, because `meshterm` is on PyPI's prohibited list and a request to free it is 319 deep in a queue. The name after `pip install` is the only thing that changes: the import package is still `meshterm`, the console script is still `meshterm`, and nothing in the code reads the distribution name, so this is one line rather than a rename. Proven rather than assumed — the wheel was built, installed into a clean interpreter, and the command run from outside the source tree. The rest is what a stranger sees on a package page and what the app never shows them: project URLs, trove classifiers for the console, the three operating systems and the four Python versions, and the licence moved to its PEP 639 expression form. No `License ::` classifier, which that form forbids, and no `Typing :: Typed` either — there is no `py.typed` marker, and a classifier that claims something untrue is worse than an absent one. `release.yml` publishes to PyPI and is built to be hard to fire. It has no `push: tags` trigger, and that absence is the design: a workflow that published on a `v*` tag would make tagging v0.9.0 on launch day claim a name on a public index, permanently, as a side effect of an unrelated action — and PyPI has no rename and no real delete. So a human runs it from the Actions tab and types the version, the job compares that against the version the tree actually built and refuses on a mismatch, and a `pypi` environment can hold it for a reviewer. Trusted Publishing means there is no API token in the repository to leak. It publishes whatever version is committed; there is no version argument, so it cannot publish something other than what is there. CI gains a packaging job that does the same build and clean-install check on every push, publishing nothing. Packaging breaks quietly, and the alternative is finding out on the one day that is worst for finding out. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
365df236b9
commit
117b958edf
4 files changed
+172
-2
No files matched your search
@@ -84,3 +84,32 @@ jobs:
|
||||
# screen has to stay readable at 72 columns regular and 53 on the PicoCalc, and
|
||||
# every PicoCalc case is a hard failure rather than a warning.
|
||||
- run: python -m pytest -q
|
||||
|
||||
# Builds the distributions and checks them the way PyPI would, publishing nothing.
|
||||
# Worth its minute: packaging breaks silently and is otherwise only discovered on
|
||||
# the one day you least want to discover it. The wheel is installed into a clean
|
||||
# environment and the command run from outside the source tree, because "it built"
|
||||
# and "it works when installed" are different claims.
|
||||
build:
|
||||
name: build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- run: pip install build twine
|
||||
|
||||
- run: python -m build
|
||||
|
||||
- run: twine check dist/*
|
||||
|
||||
- name: Install the wheel clean and run the command
|
||||
run: |
|
||||
python -m venv /tmp/fresh
|
||||
/tmp/fresh/bin/pip install --quiet dist/*.whl
|
||||
cd /tmp && /tmp/fresh/bin/meshterm --help > /dev/null
|
||||
echo "the wheel installs and the console script runs"
|
||||
@@ -0,0 +1,99 @@
|
||||
# Publishing to PyPI. Deliberately hard to fire.
|
||||
#
|
||||
# There is no `push: tags` trigger here, and that absence is the point. A release
|
||||
# workflow that publishes on a `v*` tag would mean tagging v0.9.0 on launch day
|
||||
# silently claims a distribution name on a public index, permanently, as a side
|
||||
# effect of an unrelated action. PyPI has no rename and no true delete, so that
|
||||
# side effect is not one you can take back.
|
||||
#
|
||||
# So: a human runs this, from the Actions tab, on purpose, and types the version
|
||||
# to prove they meant this version. Three gates before anything leaves the runner:
|
||||
#
|
||||
# 1. no automatic trigger at all
|
||||
# 2. the typed version must equal the version actually built
|
||||
# 3. the `pypi` environment, which can require a reviewer in repo settings
|
||||
#
|
||||
# It publishes whatever version is in the tree. There is no version argument, so
|
||||
# this cannot publish something other than what is committed.
|
||||
name: Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
confirm_version:
|
||||
description: "Version to publish, exactly (e.g. 0.9.0). A mismatch aborts."
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: build and check
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- run: pip install build twine
|
||||
|
||||
- run: python -m build
|
||||
|
||||
# Renders the README the way PyPI will, and fails on metadata PyPI would reject.
|
||||
- run: twine check dist/*
|
||||
|
||||
# The version the build actually produced, read off the artifact rather than
|
||||
# trusted from anywhere else.
|
||||
- id: built
|
||||
shell: bash
|
||||
run: |
|
||||
version=$(ls dist/*.tar.gz | sed -E 's/.*-([0-9][^-]*)\.tar\.gz/\1/')
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
echo "built version: $version"
|
||||
|
||||
- name: Confirm the typed version matches what was built
|
||||
shell: bash
|
||||
env:
|
||||
TYPED: ${{ inputs.confirm_version }}
|
||||
BUILT: ${{ steps.built.outputs.version }}
|
||||
run: |
|
||||
if [ "$TYPED" != "$BUILT" ]; then
|
||||
echo "::error::You typed '$TYPED' but the tree builds '$BUILT'. Nothing published."
|
||||
exit 1
|
||||
fi
|
||||
echo "confirmed: $BUILT"
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dist
|
||||
path: dist/
|
||||
|
||||
publish:
|
||||
name: publish to PyPI
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
# Configure this environment in repo settings with a required reviewer, and it
|
||||
# becomes a fourth gate: the job waits for an approval before it runs. The name
|
||||
# must also match the environment set on the PyPI trusted publisher, or the OIDC
|
||||
# exchange fails with an unhelpful error. If the publisher has no environment
|
||||
# configured, delete this line.
|
||||
environment:
|
||||
name: pypi
|
||||
url: https://pypi.org/p/mesh-term
|
||||
permissions:
|
||||
# Trusted Publishing mints a short-lived OIDC token instead of storing an API
|
||||
# token in the repository. This is the only thing that needs write here.
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: dist
|
||||
path: dist/
|
||||
|
||||
- uses: pypa/gh-action-pypi-publish@release/v1
|
||||
Reference in new issue
Block a user