PyPI
Detection signals
Section titled “Detection signals”PyPI is present when pyproject.toml, setup.py, or setup.cfg turns up anywhere in the checkout, or any of uv.lock, poetry.lock, pdm.lock, or Pipfile.lock does.
PyPI is shipped when a pyproject.toml carries a [project] table with name and version, alongside a [build-system] table, and publish evidence exists: a release workflow uploading a built distribution to the index, or an existing project page on pypi.org for that name. Python packaging documents name as “required and is the only field that cannot be marked as dynamic”, and version as required though often dynamic.
Source: Writing your pyproject.toml.
Three cases decide most arguments:
- A
pyproject.tomlcarrying only[tool.*]tables and no[project]table declares no package at all. It is present and can never be shipped. This is the commonest false positive in any ecosystem, because repositories in every language adoptpyproject.tomlas the configuration file for a formatter or a linter and never intend a distribution. - The
Private :: Do Not Uploadclassifier settles the shipped answer against PyPI: “PyPI will always reject packages with classifiers beginning withPrivate ::.” The distribution may still ship to a private index, so name where it goes rather than calling it unpublished. - A lockfile with no publishable manifest beside it is present. An application pinned with
uv.lockresolves a dependency tree whether or not anybody can install it from an index.
Release track
Section titled “Release track”PyPI takes the registry-push track. A release uploads a built sdist and wheel to the index under a credential, so there is an upload to secure and a credential to scope, which is what assigns the track. The roster records "track": "registry-push" for PyPI and the release area’s preamble names PyPI in its registry-push list.
Verified 2026-07-31 against https://packaging.python.org/en/latest/guides/writing-pyproject-toml/.
Version badge
Section titled “Version badge”Shields.io serves this one at img.shields.io/pypi/v/PACKAGE, taking the project name as its only path parameter.
The default label is pypi. Keep it. PyPI normalizes a project name for its own URLs, lowercasing it and folding runs of ., -, and _ into a single -, so a project whose declared name carries capitals or underscores gets a badge and a link that do not look like the name in pyproject.toml. That is correct rather than a typo to fix.
Link the badge to the project page:
[](https://pypi.org/project/PACKAGE/)Install command
Section titled “Install command”python3 -m pip install "PACKAGE"That is the form the Python Packaging User Guide gives for Unix and macOS, with py -m pip install "PACKAGE" for Windows. The module form rather than a bare pip names which interpreter the package lands in, which is the whole question on a machine carrying more than one.
The guide quotes the argument, and the quotes stop mattering only while there is no version specifier. python3 -m pip install PACKAGE>=2 redirects the shell into a file called =2, so keep the quotes in the README rather than teaching a form that breaks the moment a reader pins.
A README serving both platforms shows both lines under one fence, or shows the Unix form and says so. Do not invent a third form, and do not show an installer the project does not document itself: uv, pipx, and conda each install a different thing in a different place, and the project decides which of them it supports.
Verified 2026-07-31 against Installing Packages, Package name normalization, PyPI Version badge, and services/pypi/pypi-version.service.js in badges/shields.
License declaration (R-COM-01)
Section titled “License declaration (R-COM-01)”pyproject.toml declares it under [project]. Since PEP 639, license is a string holding an SPDX license expression: license = "GPL-3.0-or-later", or license = "MIT AND (Apache-2.0 OR BSD-2-Clause)" where more than one applies. A license SPDX does not list gets a LicenseRef-<idstring> identifier. A separate license-files key lists the legal files shipped with the distribution, written as globs such as ["LICEN[CS]E*", "vendored/licenses/*.txt", "AUTHORS.md"].
Two older forms are still common in the wild and neither is what to write today. PEP 621’s table form, license = { file = "LICENSE" } or license = { text = "..." }, is deprecated by PEP 639. So are the License :: Trove classifiers: PEP 639 replaces them with the License-Expression core metadata field, permits a build tool to raise an error when a license expression and a license classifier are both present, and forbids tools from adding such classifiers themselves. Read a classifier in a project that predates the change, and do not add one.
A project that still builds from setup.py or setup.cfg states the same core metadata through that file’s own keys, so read whichever file the build backend actually consumes rather than assuming pyproject.toml is authoritative in a repository that has all three.
The manifest side of R-COM-01 is that license expression, and the file side is the root license file. Note that license-files and the root license file answer different questions: one says what the distribution ships, the other is what the rule reads.
Source: Writing your pyproject.toml and PEP 639.
Funding platform name
Section titled “Funding platform name”Tidelift’s platform name for this ecosystem is pypi, so the GitHub funding file’s entry reads tidelift: pypi/<package-name> against the name the project publishes under. The accepted key format is PLATFORM-NAME/PACKAGE-NAME, described in github.md beside the rest of the accepted keys.
The roster at skills/oss-audit/ecosystems.json records "tidelift": "pypi" and is the canonical copy. This line exists because a single-skill install of oss-community does not carry that file; where the two disagree, the roster is right and this line is corrected to it.
Verified 2026-07-31 against Writing your pyproject.toml, PEP 639, and Displaying a sponsor button in your repository.
Toolchain and matrix (R-CI-03)
Section titled “Toolchain and matrix (R-CI-03)”On GitHub Actions, actions/setup-python installs CPython, PyPy, or GraalPy and puts it on the PATH. Drive the matrix from requires-python in the [project] table of pyproject.toml, which is where a package declares the Python versions it supports.
strategy: matrix: python-version: ['3.11', '3.12', '3.13']steps: - uses: actions/setup-python@v7 # oss-harden pins this to a commit SHA with: python-version: ${{ matrix.python-version }}Set the version explicitly. With no python-version and no .python-version file the action falls back to whatever Python is on the runner’s PATH, and the README says that default varies between runner images and can change without notice.
For a uv project, uv documents its own action, astral-sh/setup-uv, and uv run selects the interpreter. On GitLab, run the job in the Python official image, or in uv’s published image for a uv project, and vary the tag with parallel:matrix.
Sources: actions/setup-python, Writing your pyproject.toml, uv, GitHub Actions, docker-library/python.
Dependency caching (R-CI-04)
Section titled “Dependency caching (R-CI-04)”actions/setup-python has a cache input that is off by default and accepts pip, pipenv, and poetry. What it caches differs by manager, and only the first is download data: for pip it caches the global cache directory, and for pipenv and poetry it caches virtualenv directories. Its default hash file is requirements.txt or pyproject.toml for pip, Pipfile.lock for pipenv, and poetry.lock for poetry, and cache-dependency-path overrides that.
Prefer keying on a lockfile the repository commits over the pip default of hashing requirements.txt, because a requirements file with an unpinned range resolves differently under an unchanged hash. The README says as much: it warns that a restored cache goes stale when the requirements file has not changed and a newer dependency version has been released.
The roster lists four lockfiles under PyPI. Two of those managers are outside the cache input’s accepted values: uv keys through its own action, astral-sh/setup-uv with enable-cache: true, which keys on uv.lock, and a pdm project caches through actions/cache keyed on pdm.lock. pip’s own cache directory is printed by pip cache dir and sits at ~/.cache/pip on Linux.
On GitLab, uv documents UV_CACHE_DIR: .uv-cache with cache:key:files on uv.lock and uv cache prune --ci in after_script. For pip, point the cache directory inside the project the same way, because GitLab caches only paths under the project directory.
Sources: actions/setup-python, pip, caching, uv, GitHub Actions, uv, GitLab CI/CD.
Test command (R-CI-06)
Section titled “Test command (R-CI-06)”pytest documents two equivalent invocations, pytest and python -m pytest; the second also adds the current directory to sys.path, which is the difference to know when an import resolves in one and not the other. A uv project runs uv run pytest, which uv’s own GitHub Actions guide uses.
Python declares the command outside the package manifest more often than inside it: pytest configuration lives in [tool.pytest.ini_options] in pyproject.toml, in pytest.ini, or in tox.ini, and a project using tox or nox declares the whole invocation in tox.ini or noxfile.py. Read those before falling back to a bare pytest, because a project with a tox matrix has already written the command CI should call.
Sources: pytest, how to invoke pytest, uv, GitHub Actions.
Verified 2026-07-31 against https://github.com/actions/setup-python, https://packaging.python.org/en/latest/guides/writing-pyproject-toml/, https://pip.pypa.io/en/stable/topics/caching/, https://docs.astral.sh/uv/guides/integration/github/, https://docs.astral.sh/uv/guides/integration/gitlab/, https://docs.pytest.org/en/stable/how-to/usage.html, and https://github.com/docker-library/python.
Automated dependency updates (R-SEC-03)
Section titled “Automated dependency updates (R-SEC-03)”Dependabot’s pip value covers pip, Pipenv, pip-compile, and Poetry, and uv is its own value; the table marks version updates and security updates supported for both. Poetry and Pipenv projects still declare package-ecosystem: "pip" rather than a value named after the tool, which is the one place a dependabot.yml written from the tool name silently covers nothing.
On GitLab the Renovate managers are pep621, pip_requirements, poetry, pipenv, and pip-compile.
Lockfile and frozen install (R-SEC-08)
Section titled “Lockfile and frozen install (R-SEC-08)”Which file the project has depends on the tool, and each tool has its own way to fail rather than re-resolve:
- uv writes
uv.lock.uv sync --lockedraises an error instead of updating the lockfile when it is not up to date. Its--frozendoes the opposite, using the lockfile without checking it, so--lockedis the CI flag and--frozenis not. - Poetry writes
poetry.lock, andpoetry installuses the exact versions from it instead of resolving. It does not fail on a lockfile that has drifted frompyproject.toml, so the gate is a separatepoetry check --lockstep, which verifies the lockfile against the currentpyproject.toml. - PDM writes
pdm.lock, andpdm install --checkis documented as “Check if the lock file is up to date and fail otherwise”. Its--frozen-lockfileonly stops PDM creating or updating the file, so it is not the checking flag. - Pipenv writes
Pipfile.lock, andpipenv install --deployaborts ifPipfile.lockis out of date.pipenv syncinstalls from the lockfile without checking it against thePipfile.
A project with no lockfile at all reaches the same place through a fully hashed requirements file and pip install --require-hashes, but only in full: pip’s hash-checking mode requires hashes for every requirement including transitive ones, and every requirement pinned with ==, a URL, or a path. A partially hashed file is an error rather than partial protection.
What breaks the first time you commit the lockfile
Section titled “What breaks the first time you commit the lockfile”Whether one lock covers the whole matrix depends on the tool, and the split is sharp. uv’s lockfile “is created with a universal resolution and is portable across platforms”, and uv states the cost of that: “all required packages must be compatible with the entire range of requires-python declared in the pyproject.toml”. Poetry describes the same design, saying its universal locking guarantees the project is installable on all supported Python versions. So under either tool one committed lock covers every job, and a dependency that drops the project’s own floor breaks resolution rather than one matrix leg.
Under pip-tools it does not, and the documentation says so. pip-tools records that “the dependencies of a package can change depending on the Python environment in which it is installed”, and instructs that users “must execute pip-compile on each Python environment separately to generate a requirements.txt valid for each said environment”. It adds that a later update can make a previously portable file environment-dependent, so the advice holds even where one file appears to work today. A project compiling on the newest Python and installing on the oldest is the failure this predicts. Commit one file per target environment, name each after its environment, and give the project one command that regenerates the set.
Those per-environment files are what an updater is least likely to maintain. Dependabot reaches the directories its configuration lists, so confirm the entry covers every compiled file rather than the one at the root. A file it does not bump drifts from the manifest it does.
Verified 2026-07-31 against uv resolution, Poetry basic usage, and pip-tools.
Static analysis (R-SEC-09)
Section titled “Static analysis (R-SEC-09)”CodeQL supports Python, so CodeQL default setup covers this ecosystem on GitHub. GitLab’s SAST table lists Python under its Semgrep-based analyzer with GitLab-managed rules.
Vulnerability watch (R-SEC-11)
Section titled “Vulnerability watch (R-SEC-11)”The dependency graph parses requirements.txt and pipfile.lock under pip, with pipfile and setup.py as additional files, and poetry.lock under Poetry with pyproject.toml as an additional file. Both rows support Dependabot graph jobs and automatic dependency submission. Neither uv.lock nor pdm.lock appears in that table, so a uv or PDM project’s graph reports what it can read from the manifest and nothing from the resolved set. GitHub also notes that a project listing its dependencies in setup.py may not have every dependency parsed.
Advisories come from the GitHub Advisory Database, which names this ecosystem Pip against the pypi.org registry.
osv-scanner reads uv.lock, pdm.lock, poetry.lock, Pipfile.lock, requirements.txt, and pylock.toml, which is what covers the uv and PDM residual.
Verified 2026-07-31 against Dependabot supported ecosystems and repositories, Dependency graph supported package ecosystems, GitHub Advisory Database, uv locking and syncing, Poetry CLI, PDM CLI reference, Pipenv CLI, pip secure installs, CodeQL supported languages and frameworks, GitLab SAST, Renovate managers, and osv-scanner supported languages and lockfiles.
Concrete flow for the decisions SKILL.md makes, for a package published to pypi.org. PyPI accepts four trusted publishing providers: GitHub Actions, GitLab CI/CD, Google Cloud, and ActiveState. This file covers GitHub Actions and GitLab CI/CD, matching oss-kit’s forge scope. The self-service GitLab flow below covers gitlab.com projects. A self-managed GitLab instance can use trusted publishing too, but PyPI’s own announcement scopes that path to organizations running their own GitLab, not to an individual maintainer’s self-hosted instance: the organization emails support+orgs@pypi.org with the instance URL and confirms its /.well-known/openid-configuration and /oauth/discovery/keys endpoints are reachable, and PyPI staff establish the trust relationship by hand. Tell the user about that path if git remote get-url origin points at a GitLab host other than gitlab.com and the repository belongs to an organization, rather than assuming the self-service flow below applies to it or that a solo maintainer’s personal instance qualifies.
Source: PyPI Docs, Trusted publishers, PyPI Docs, Using a publisher, and PyPI Blog, Trusted Publishing is popular, now for GitLab Self-Managed and Organizations.
Contents
Section titled “Contents”- Gather facts (Step 1)
- Configure trusted publishing (Step 2)
- Write the hardened release workflow (Step 3)
- Gate on manual approval (Step 4)
- Verify provenance (Step 5)
- Describe and sign what the release attaches (Step 6)
- Not yet published projects
Gather facts (Step 1)
Section titled “Gather facts (Step 1)”Read pyproject.toml (the project.name and project.urls tables), or setup.cfg or setup.py if the project has not migrated. Get the owner and repository from the project’s URL metadata, falling back to git remote get-url origin. Check whether the package is already published with curl -s -o /dev/null -w '%{http_code}' https://pypi.org/pypi/<name>/json; a 404 means it is not, and Not yet published projects below covers that case.
Configure trusted publishing (Step 2)
Section titled “Configure trusted publishing (Step 2)”PyPI supports both forges, so read the section for the forge detected in Step 1 and take the settings from there.
GitHub Actions
Section titled “GitHub Actions”For an already-published project, open https://pypi.org/manage/project/<name>/settings/publishing/ and enter:
- Owner: the owner from Step 1
- Repository name: the repository name from Step 1
- Workflow name:
release.yml, the filename rather than the workflow’sname: - Environment name: the approval environment name from
SKILL.mdStep 4, which this skill always writes asrelease
In the workflow, the publish job needs:
permissions: id-token: writeUse the maintained action rather than hand-rolling the OIDC exchange:
- uses: pypa/gh-action-pypi-publish@release/v1No PYPI_API_TOKEN or any other registry secret is needed once the trusted publisher above is configured.
GitLab CI/CD
Section titled “GitLab CI/CD”At the same settings page, choose GitLab and enter:
- Repository namespace: the GitLab username or group path, matching the
<owner>from Step 1 - Repository name: the project name
- Workflow filepath: the path to the pipeline file, for example
.gitlab-ci.yml - Environment name: the approval environment name from Step 4
In the pipeline, the publish job needs:
environment: name: releaseid_tokens: PYPI_ID_TOKEN: aud: pypiThe environment name has to match the form’s Environment name field exactly. PyPI compares the environment claim in the OIDC token against what the form recorded, and that string comparison is the whole of the check: nothing else about the environment reaches the registry, so naming one is not on its own a restriction. Make it a protected environment with deploy access restricted to whoever is allowed to release, which GitLab offers on Premium and Ultimate. On a tier without protected environments any job in the project can claim the name, and the claim is then self-asserted. That is the difference between a control and a label here, because the form binds namespace, project, and pipeline filepath and no ref at all: with the environment unprotected, a pipeline on any branch a contributor can push satisfies every claim PyPI checks.
With that token present, current Twine uses trusted publishing automatically; no explicit credential handling is needed. Resolve Twine’s current release from the PyPA repository before writing the pipeline, then lock that exact version and every transitive dependency with hashes. Do not install twine -U in the job that receives the OIDC token.
Write the hardened release workflow (Step 3)
Section titled “Write the hardened release workflow (Step 3)”Build the distribution in a separate job from the one that publishes it, so a compromised build dependency cannot reach the publish credential:
name: Releaseon: push: tags: - 'v*'jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 with: persist-credentials: false - uses: actions/setup-python@v7 with: python-version: '3.13' - run: <frozen project install command from CONTRIBUTING.md> - run: pytest # oss-ci decides the actual command from CONTRIBUTING.md (R-CI-02)
build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 with: persist-credentials: false - uses: actions/setup-python@v7 with: python-version: '3.13' - run: <frozen build-tool install command> - run: python -c "import os,sys,tomllib; sys.exit(tomllib.load(open('pyproject.toml','rb'))['project']['version'] != os.environ['GITHUB_REF_NAME'].removeprefix('v'))" - run: <documented build command> - uses: actions/upload-artifact@v7 with: name: dist path: dist/ retention-days: 1
publish: runs-on: ubuntu-latest needs: [test, build] environment: release permissions: id-token: write steps: - uses: actions/download-artifact@v8 with: name: dist path: dist/ - uses: pypa/gh-action-pypi-publish@release/v1Replace the remaining angle-bracket commands from the repository’s contributing guide, lockfiles, and build backend. The version check above reads a static project.version and strips one leading v from the tag. Derive both halves from what the repository actually does: a project declaring dynamic = ["version"] has nothing at that key and the check raises a KeyError rather than comparing anything, and a project tagging 1.2.3 has no prefix to strip. Where the version is dynamic, move the comparison after the build step and read it from the built distribution’s metadata, which is the only place it exists. Do not assume every project uses editable installs, pytest, or the build frontend. If the repository has no frozen build-tool install, add one using the project’s existing lock workflow after verifying the build frontend through its upstream documentation.
oss-harden pins every uses: line above to a commit SHA and sets this workflow’s permissions:, including the contents: read this skill left off the test and build jobs above; do not pin them or add permissions here.
On GitLab CI/CD, use separate build, attestation, and publish jobs. PyPI’s official GitLab flow requires the attestation job to request SIGSTORE_ID_TOKEN with audience sigstore, sign every distribution with pypi-attestations, and pass the distributions plus adjacent attestation files to the publish job. The publish job requests PYPI_ID_TOKEN with audience pypi and runs twine upload --attestations dist/*. Prepare hash-locked wheels for both CLIs in an uncredentialed job and install them offline, or use a project-controlled image pinned by digest. A plain twine upload dist/* publishes through OIDC but does not upload provenance.
If an existing workflow uses secrets.PYPI_API_TOKEN, remove it from the YAML now and tell the user to delete the corresponding secret once the new flow is verified.
Gate on manual approval (Step 4)
Section titled “Gate on manual approval (Step 4)”Pin the publish job to environment: release as above, and create that environment at https://github.com/<owner>/<repo>/settings/environments/new with required reviewers, or, on GitLab Premium or Ultimate, as a protected environment with approval rules. GitHub required reviewers work for public repositories on current plans; private or internal repositories need GitHub Enterprise Cloud. If the repository’s visibility or plan provides no native approval gate, report R-PUB-04 as unmet. PyPI has no registry-side approval fallback.
PyPI does not require any particular environment name, and pypi is what its own examples use. This skill writes release for every ecosystem, so the same word means the same thing in every repository it touches. What matters is that the form’s Environment name field and the job’s environment: are the same string; where a repository already has a working publisher entry naming something else, keep it rather than renaming both to match this file.
Create it with the API rather than the form. Reviewers and the tag policy are both settable, so nothing here needs a browser.
ENV=pypiGHUID=$(gh api user --jq .id)gh api -X PUT "repos/{owner}/{repo}/environments/$ENV" \ -F wait_timer=0 \ -F prevent_self_review=false \ -f 'reviewers[][type]=User' -F "reviewers[][id]=$GHUID" \ -F 'deployment_branch_policy[protected_branches]=false' \ -F 'deployment_branch_policy[custom_branch_policies]=true'gh api -X POST "repos/{owner}/{repo}/environments/$ENV/deployment-branch-policies" \ -f 'name=v*' -f type=tagThree details decide whether that runs. gh api substitutes {owner} and {repo} from the checkout it runs in. Use -F for the booleans and the reviewer id, because -f sends every value as a string and the endpoint rejects a quoted boolean. Do not name the shell variable UID: zsh marks it read only, so the assignment fails before gh runs.
reviewers[][id] takes a numeric user or team id rather than a login. A team needs type=Team and that team’s id.
Verify provenance (Step 5)
Section titled “Verify provenance (Step 5)”pypa/gh-action-pypi-publish generates and uploads a PEP 740 attestation by default when publishing through trusted publishing. The GitLab flow must generate and upload adjacent attestations explicitly as described above. After the first release, first confirm that PyPI serves provenance:
curl -s https://pypi.org/integrity/<name>/<version>/<filename>/provenanceA provenance object confirms the file has attestations; a 404 means it does not. Then cryptographically verify the distribution against the expected repository with the pypi-attestations CLI. It is a separate tool this skill does not bundle, published by Trail of Bits, and its own README gives the install command:
python -m pip install pypi-attestationsInstall it into a virtual environment rather than the system interpreter, then verify:
pypi-attestations verify pypi --repository https://<forge>/<owner>/<repo> <distribution-url>Source: PyPI Docs, Producing attestations and PyPI Docs, Consuming attestations.
Describe and sign what the release attaches (Step 6)
Section titled “Describe and sign what the release attaches (Step 6)”Only for a release that attaches a built asset to the forge release. The distributions this workflow uploads to PyPI carry the PEP 740 attestations Step 5 verifies, and the source archives GitHub generates for a tag are not built assets, so a project that attaches nothing beside them goes to Step 7 instead.
Two rules apply here and they ask for different things. R-PUB-05 wants an inventory of what went into the asset, in SPDX or CycloneDX. R-PUB-06 wants the assets signed, or listed by hash in a signed manifest. A manifest of hashes answers the second and nothing about the first, so do not report R-PUB-05 as met by publishing one.
This reference names no SBOM generator for Python. The ones in common use are third-party tools rather than part of the packaging toolchain, and a tool that reads the dependency tree inside the release workflow is one the maintainer vets before it goes there. What answers R-PUB-05 without one, on GitHub, is the forge’s own export of the repository’s dependency graph, which is already SPDX and needs nothing installed. The gh api step below writes it into dist/, so it ships as a release asset for R-PUB-05 and is listed in SHA256SUMS and attested alongside the distributions for R-PUB-06:
github-release: runs-on: ubuntu-latest needs: [publish] permissions: contents: write id-token: write attestations: write artifact-metadata: write steps: - uses: actions/download-artifact@v8 with: name: dist path: dist/ - run: gh api repos/${{ github.repository }}/dependency-graph/sbom --jq .sbom > dist/sbom.spdx.json env: GH_TOKEN: ${{ github.token }} - run: (cd dist && sha256sum *) > SHA256SUMS - uses: actions/attest@v4 with: subject-checksums: SHA256SUMS - run: gh release upload "$GITHUB_REF_NAME" dist/* SHA256SUMS env: GH_TOKEN: ${{ github.token }}State both of the export’s limits to the maintainer rather than leaving them to be discovered. It is GitHub only, so a GitLab project keeps this gap and R-PUB-05 stays unmet there with the reason named. And it covers the repository’s declared dependency graph rather than what is inside the asset, which is exact for a pure Python wheel, because a wheel declares its dependencies rather than bundling them, and an approximation for a wheel carrying compiled extensions, whose linked libraries the graph does not see. The graph resolves past the direct dependencies only where a lockfile or a fully hashed requirements file is committed, which R-SEC-08 already requires. Read the output back once with gh api repos/<owner>/<repo>/dependency-graph/sbom --jq '.sbom.packages | length' before reporting the rule met: a graph the forge does not parse for this ecosystem returns a near-empty package list rather than an error.
sha256sum runs from inside dist/ so the names it writes are the names the assets carry on the release. A manifest listing dist/foo.whl cannot be checked against a downloaded foo.whl.
subject-checksums makes every file listed in SHA256SUMS a subject of the attestation in its own right, by name and digest, and it takes the format sha256sum writes. Attesting SHA256SUMS itself with subject-path instead would leave a consumer able to verify the manifest and nothing about the assets it lists.
A consumer verifies an asset, then checks the rest of the download against the manifest:
gh attestation verify <asset> --repo <owner>/<repo>sha256sum -c SHA256SUMSRun the first command against each asset downloaded, never against SHA256SUMS, which is a subject of nothing. It proves that this workflow in this repository produced those exact bytes, and it prints the signing workflow it accepted; --signer-workflow <owner>/<repo>/.github/workflows/release.yml pins that, so an attestation from any other workflow in the repository fails. The second command is what a consumer without gh has. It proves the files match a list published beside them, and nothing about who produced either.
gh release upload fails when no release exists for the tag. Either create it in this job with gh release create "$GITHUB_REF_NAME" --generate-notes before the upload, or have the maintainer publish the release from the tag first. This reference does not choose between them, because release notes are the project’s own.
A third job is what makes the grants above safe, so copy the job boundary along with them. The build job runs the project’s own build command, so giving it release-asset writes and an attestation identity is exactly the credential split Step 3 exists to enforce, and it would write assets before the approval gate. The publish job holds the OIDC identity PyPI trusts. Only a separate job satisfies both, and needs: [publish] is what keeps the assets behind the gate.
The four grants above are copied exactly, on this job only. The workflow’s top-level block stays contents: read. Narrowing anything else, pinning each uses: to a commit SHA, and auditing the result are oss-harden’s.
Not yet published projects
Section titled “Not yet published projects”A trusted publisher can be created before the first release: open https://pypi.org/manage/account/publishing/ and enter the project name plus the same owner, repository, workflow filename, and environment as above. The first successful publish from that workflow claims the name.
Step 7 is in SKILL.md: read each R-PUB rule’s Check: line against what this file produced, and fix what fails before reporting done.
Verified 2026-07-31 against PyPI Docs, Adding a trusted publisher, PyPI Docs, Using a trusted publisher, and PyPI Docs, Digital attestations.
Version sources (R-CHG-03)
Section titled “Version sources (R-CHG-03)”pyproject.toml’s [project] version is the source where the project states it statically. The field is required, and the specification allows exactly two shapes: a literal value, or the name version listed in dynamic, in which case the build backend computes it and a backend must raise an error if neither is present.
A dynamic version moves the source somewhere else, and where it goes decides what R-CHG-03 compares. A backend that reads the git tag leaves the tag as the only source, so tag and changelog are the whole comparison. A backend that reads an attribute leaves a Python file holding __version__ as the source, and that file is what a release has to update. Read [build-system] requires and the backend’s own configuration table to find which of the two a repository uses before looking for a number to compare.
Older projects state the version in setup.py or setup.cfg instead. A repository carrying both pyproject.toml and one of those has two candidate sources and only one of them is read at build time.
Version syntax (R-CHG-02)
Section titled “Version syntax (R-CHG-02)”PyPI versions follow the Python version specification, not SemVer, and the two agree only on the plain three-part case. A Python version is an optional epoch N!, a release segment N(.N)* with no fixed number of components, then any of a pre-release aN, bN, or rcN, a post-release .postN, a development release .devN, and a local version +label.
Normalization is the part that surprises a reader comparing strings. Letters lowercase, so 1.1RC1 becomes 1.1rc1. Separators before a pre-release segment disappear, so 1.1-a1 becomes 1.1a1. Alternative spellings fold, with alpha to a, beta to b, and c to rc. An implicit number fills in, so 1.2a becomes 1.2a0. Leading zeros drop. The practical consequence is that a SemVer prerelease such as 1.0.0-rc.1 is not what the index will show, and the specification says users should prefer to state an already-normalized version.
So compare normalized forms when checking that the tag, the version source, and the changelog heading agree, and write the normalized form in all three. R-CHG-02’s bump semantics are unaffected by any of this; what changes is the spelling.
Major version in package identity (R-CHG-07)
Section titled “Major version in package identity (R-CHG-07)”PyPI does not encode the major version in package identity. A distribution name is stable across majors and every major is a release of the same project, so R-CHG-07 does not reach this ecosystem. The one identity rule that does apply is name normalization, which is about how a name is written rather than which version it carries.
Withdrawing a release (R-CHG-01)
Section titled “Withdrawing a release (R-CHG-01)”Yanking is the mechanism, and it is reversible. A yank is recorded in the simple index as a data-yanked attribute that may carry a reason string, and PyPI displays that reason on the release page and serves it through the index APIs installers read. An installer must ignore a yanked release when the constraints can be satisfied without it, and may refuse it outright; a yanked file is still installable when a version is pinned exactly with == or ===. The attribute is not immutable, so a yank may be rescinded and set again, and tooling has to cope with an unyanked file. Yank from the release management page for the project on PyPI.
Deleting is a different operation with no way back. “Deletion of a project, release or file on PyPI is permanent and irreversible, without exception.” A deleted filename can never be reused, a deleted project releases its name to any other PyPI user, and PyPI administrators cannot restore any of it. Yank rather than delete: a yank keeps the record a changelog points at, and a deletion turns every link to it into a 404 while handing the name away.
[YANKED] on the changelog heading maps to a yank. Keep the entry, add the reason, and remove the marker if the yank is later rescinded.
Verified 2026-07-31 against Writing your pyproject.toml, Version specifiers, File yanking, Yanking a release, and PyPI help.