Skip to content
Merged

Dev #202

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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
44 changes: 40 additions & 4 deletions .github/BRANCH_WORKFLOW.md
Original file line number Diff line number Diff line change
Expand Up @@ -383,8 +383,8 @@ upload credential when the token's claims match the publisher above, and
Sigstore and uploads its PEP 740 attestation. A failed-jobs re-run of the upload
asks for a fresh token. Once a run has published this way, the old
`melder_api_token` environment secret can be deleted and its token revoked on
TestPyPI: no workflow reads it. Production continues using its independent pypi
environment and PYPI_API_TOKEN credential.
TestPyPI: no workflow reads it. Production publishes the same way, through its
own `pypi` environment and PyPI Trusted Publisher (Final publication checks).

An `invalid-publisher` error from the upload means the publisher's values and the
job's claims disagree; an earlier OIDC attempt failed that way, so compare all four
Expand Down Expand Up @@ -459,21 +459,57 @@ workflow on prod, starts a fresh qualification chain:
5. Recheck distribution identity and candidate qualification, then the live release tag,
fetch/check prod again immediately before the PyPI upload action. Annotated
tags use their peeled target; missing or moved tags refuse publication.
6. Upload by Trusted Publishing: `pypa/gh-action-pypi-publish`, pinned to its
commit, signs every file with Sigstore and uploads its PEP 740 attestation.
7. `Release / provenance` then checks every released file's provenance on PyPI
in the release candidate's five layers: a provenance exists; its publisher is
`python-publish.yml` in `pypi`; Sigstore verifies the attestation on the
production trust root; the signing certificate names this repository and its
owner (with their numeric ids), the workflow at the release tag (or `prod`, for
a manual dispatch), the released commit, this run and the attempt that
uploaded, its event, a GitHub-hosted runner, public visibility and the `pypi`
environment; and the attested statement names exactly the file and its
sha256. The files are on PyPI by then, so a refusal fails the run before the
release is announced but cannot unpublish anything; `.github/ci_cd/validating.md`
says what each refusal means. Its report, `release-provenance-<run>-<attempt>`,
keeps each file's verified signer, run and trigger.

Earlier dev/preprod success does not replace these checks. A prod movement during
the checks or environment approval refuses publication. GitHub prerelease events do
not trigger final publication. All publication runs share a serialization group.
Only the upload job uses the pypi environment and its PYPI_API_TOKEN secret.
Production builds and validates fresh final distributions; it does not relabel
or claim byte identity with an earlier rcN package.

Only the upload job uses the pypi environment, and PyPI publication has no
stored credential (owner, 2026-10-24). Add the publisher before the first final
release made with this workflow: on PyPI, the `melder` project -> Manage ->
Publishing -> add a GitHub publisher with exactly these values.

| Field | Value |
| --- | --- |
| Owner | `Synaptic724` |
| Repository name | `melder` |
| Workflow name | `python-publish.yml` |
| Environment name | `pypi` |

The upload job alone requests a short-lived OIDC token (`id-token: write`); PyPI
exchanges it for an upload credential when the token's claims match the
publisher above. An `invalid-publisher` error means they disagree and nothing
was uploaded: compare the four values, then **Re-run all jobs**. Once a release
has published this way, delete the `PYPI_API_TOKEN` environment secret and
revoke its token on PyPI: no workflow reads it, and a contract test refuses a
workflow that names either retired token.

Artifacts include the workflow run and attempt in their names. After a failed
publication, use **Re-run all jobs** to obtain fresh qualification and fresh
artifacts; rerunning only a failed upload must not reuse an earlier attempt's
unqualified artifact implicitly, so a failed-jobs re-run of the upload stops
before downloading anything, with an error naming **Re-run all jobs**. Confirm
PyPI state before retrying an upload whose outcome is uncertain; existing
package versions are never silently skipped.
package versions are never silently skipped. A `Release / provenance` check that
failed only because PyPI or the verifier's trust root could not be reached is
rerun alone with **Re-run failed jobs**: it reads the same build and still
expects the attempt that uploaded, and it uploads nothing.

## Activating GitHub enforcement

Expand Down
5 changes: 3 additions & 2 deletions .github/ci_cd/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Read this folder before you change anything under `.github/`, change `project.re
dependencies in `pyproject.toml`, add a Python release, a CI job or a benchmark, or diagnose a failed run. It says
what every CI file is for, how the pieces connect, how to extend them and how to check a change before handing it
off. Branch and release policy (which pull request may target which branch, cutting a release, the TestPyPI and
PyPI credentials) lives in [BRANCH_WORKFLOW.md](../BRANCH_WORKFLOW.md); this folder links to it instead of
PyPI Trusted Publishers) lives in [BRANCH_WORKFLOW.md](../BRANCH_WORKFLOW.md); this folder links to it instead of
repeating it.

| Read | When |
Expand Down Expand Up @@ -39,7 +39,8 @@ push to release_candidate, or a manual run there --> release-candidate.yml
Windows and macOS) -> package-ready

published release, or a manual run on prod --> python-publish.yml
release-gate -> hygiene, source-assets, repo-assets -> release-build -> pypi-publish
release-gate -> hygiene, source-assets, repo-assets -> release-build -> pypi-publish (PyPI, by Trusted
Publishing, with attestations) -> pypi-provenance (every released file's attestation verified)
```

Workflows only orchestrate. The decisions (routes, required results, Python matrices, installs, package checks)
Expand Down
18 changes: 11 additions & 7 deletions .github/ci_cd/extending.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,11 @@ Every change below ends the same way: run the workflow tests, update this folder
`RuntimeMatrixPolicy.TARGETS` together and run the full matrix.
- `PYTHON_GIL=0` goes on the steps that run Melder, never on a whole job: Python's setup on macOS runs a
certificate installer that cannot start with the GIL disabled.
- Secrets are passed explicitly and only where needed: `tests` receives `CODECOV_TOKEN` and nothing else, and the
PyPI token lives in the `pypi` environment, used by one job. TestPyPI publication has no secret: the
release candidate's `publish` job authenticates by Trusted Publishing and is the only job in any workflow that
holds `id-token: write`; a third-party action in a job that holds it is pinned to a full commit SHA.
- Secrets are passed explicitly and only where needed: `tests` receives `CODECOV_TOKEN` and nothing else.
Publication has no secret: the release candidate's `publish` job (TestPyPI, environment `pypitest`) and
`python-publish.yml`'s `pypi-publish` job (PyPI, environment `pypi`) authenticate by Trusted Publishing and are
the only jobs in any workflow that hold `id-token: write`; a third-party action in a job that holds it is pinned
to a full commit SHA, and no workflow may name the retired upload tokens `melder_api_token` or `PYPI_API_TOKEN`.
- Artifact names carry `${{ github.run_id }}-${{ github.run_attempt }}`, so a rerun never picks up another
attempt's files by accident. A job that downloads a build made earlier in the same run names it from the build
job's output (`needs.<build>.outputs.artifact-name`), never from its own attempt, because a failed-jobs re-run
Expand All @@ -52,15 +53,18 @@ workflow, as `speed-tests.yml` does for the speed tests.
## Add a stage to the release-candidate or publication run

`package-ready` requires exactly `CIPolicy.CANDIDATE_JOBS`; a new release-candidate stage goes into its `needs`
and that list together. A publication job that must finish before the upload goes into `pypi-publish`'s `needs`.
and that list together. A publication job that must finish before the upload goes into `pypi-publish`'s `needs`;
a check of what was uploaded, like `pypi-provenance`, needs `pypi-publish`, downloads the build by
`needs.release-build.outputs.artifact-name` and reads the upload's attempt from `pypi-publish`'s outputs.

## Change the provenance tool

The provenance check verifies with the toolchain the publishing action signs with, so the three move together
in one change:

1. Pin `pypa/gh-action-pypi-publish` in `release-candidate.yml` to the new release's full commit SHA (with the
tag in a comment) and update the pinned SHA in `test_workflow_contracts.py`.
1. Pin `pypa/gh-action-pypi-publish` in `release-candidate.yml` and `python-publish.yml` to the new release's full
commit SHA (with the tag in a comment), the same in both, and update the pinned SHA in
`test_workflow_contracts.py`.
2. Regenerate `.github/provenance/requirements.txt` with the command in its header: `pypi-attestations` at the
version that release's `requirements/runtime.txt` pins, with that file as constraints, so sigstore and the
rest take the action's versions (hashes for every pin, wheels only, the helper release on Linux x64).
Expand Down
28 changes: 23 additions & 5 deletions .github/ci_cd/scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@ import, with two exceptions that run inside the environment they test: `run_runt
| `ci_qualification.py` | `record`, `select`, `verify` | `ci.yml`, `verify-source-qualification.yml` | `test_source_qualification.py`, `test_checkout_identity.py` |
| `check_candidate_run.py` | `--wait-seconds` | `ci.yml`, `python-publish.yml` | `test_candidate_publication.py` |
| `testpypi_candidate.py` | `prepare-upload`, `probe-install` | `release-candidate.yml` | `test_candidate_publication.py` |
| `testpypi_provenance.py` | `verify` | `release-candidate.yml` | `test_candidate_provenance.py` |
| `verify_distributions.py` | `--directory`, `--release-tag` | `build-distributions.yml`, `python-publish.yml` | `test_distributions.py` |
| `testpypi_provenance.py` | `verify` | `release-candidate.yml`; `pypi_provenance.py` imports its layers | `test_candidate_provenance.py` |
| `pypi_provenance.py` | `verify` | `python-publish.yml` | `test_release_provenance.py` |
| `verify_distributions.py` | `--directory`, `--release-tag` | `build-distributions.yml`, `python-publish.yml`; `pypi_provenance.py` calls it with the release tag | `test_distributions.py` |
| `normalize_sdist.py` | `--directory` | `build-distributions.yml` | `test_sdist_normalization.py` |
| `smoke_wheel.py` | `--expected-version` | `build-distributions.yml`, `testpypi_candidate.py probe-install` | the distribution contract in `test_workflow_contracts.py` |

Expand Down Expand Up @@ -107,6 +108,23 @@ sigstore, the versions `pypa/gh-action-pypi-publish` signs with. The same run fi
genuine attestation `.github/provenance/known_good_provenance.json` must verify, and two tampered copies of it (another
digest, another signer) must be refused, or no verdict counts. The expected identity lives in one class,
`ProvenancePolicy`; every refusal names the file, the claim, what was expected and what was found. The report
`reports/provenance.json` is written only after every check passed. The layers that read an index
(`fetch_provenance`, `single_attestation`, `require_outcomes`) take it from the policy they are given, which is how
`pypi_provenance.py` reuses them for PyPI.

### `pypi_provenance.py`

`verify` runs in `Release / provenance`, after `pypi-publish` has uploaded a final release. It is the candidate
check with the release's identity: the same five layers, the same pinned verifier, lock and control, imported from
`testpypi_provenance.py`, and one policy class, `ReleaseProvenancePolicy` (a `ProvenancePolicy` subclass), naming
PyPI's Integrity API (`https://pypi.org/integrity/melder/<version>/<file>/provenance`), `python-publish.yml` and the
`pypi` environment. `release_context` reads the run: a published release at `refs/tags/<tag>`, the tag passed from
the event as `RELEASE_TAG`, or a manual dispatch on `prod` with no tag; anything else is refused before PyPI is
asked. The downloaded files are verified again with that tag, which must name the package version. Every released
file must then name exactly this run's upload: PyPI takes a file name once and the upload never skips one, so,
unlike the candidate check, no claim is matched by pattern - the workflow at the run's ref, the commit, the run, the
attempt `pypi-publish` reported (`RELEASE_PUBLISH_ATTEMPT`) and its event. A 404 that outlasts the retries is
reported as a release whose files are published without an accepted attestation. The report
`reports/provenance.json` is written only after every check passed.

### `verify_distributions.py`, `normalize_sdist.py` and `smoke_wheel.py`
Expand All @@ -121,14 +139,14 @@ agree, and a small bind, conjure and meld scenario must work with the GIL off.
## Other files in `.github`

- `BRANCH_WORKFLOW.md`: branch, release and publication policy for people and agents.
- `provenance/`: the provenance check's tool, used only by `testpypi_provenance.py`.
- `provenance/`: the provenance checks' tool, used by `testpypi_provenance.py` and `pypi_provenance.py`.
`.github/provenance/verify_attestations.py` is the verifier, the one script under `.github/` that imports
packages outside the standard library, so it never runs on a bare interpreter.
`.github/provenance/requirements.txt` is its hashed lock; its header gives the command that regenerates it.
`.github/provenance/known_good_provenance.json` is the recorded control: PyPI's provenance of the
pypi-attestations wheel the lock installs. Moving the tool is one change: the publishing action's pin in
`release-candidate.yml`, the lock at the versions that action signs with, and the control re-recorded for the
new pypi-attestations wheel with `ProvenanceControl` updated ([extending.md](extending.md)).
`release-candidate.yml` and `python-publish.yml`, the lock at the versions that action signs with, and the control
re-recorded for the new pypi-attestations wheel with `ProvenanceControl` updated ([extending.md](extending.md)).
- `python/`: the Python release manifests ([python_versions.md](python_versions.md)).
- `rulesets/`: `dev.json`, `preprod.json`, `release_candidate.json` and `prod.json` are the branch protection
payloads. Each requires a pull request and the `CI / merge-ready` status from the GitHub Actions app (ID 15368)
Expand Down
9 changes: 7 additions & 2 deletions .github/ci_cd/validating.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,18 @@ Start with `CI / merge-ready`: its message lists every required job that did not
| `RC / provenance`: "The pinned tool refused the recorded genuine attestation" | The verifier could not verify in that environment (most often Sigstore's trust root could not be fetched). **Re-run failed jobs**; if it persists, check the lock and the control ([extending.md](extending.md), "Change the provenance tool"). |
| An RC job: "Artifact not found for name: candidate-dists-<run>-<n>" | A failed-jobs re-run of a run that started before the build reported its artifact (2026-10-06); use **Re-run all jobs**. |
| `Publish to PyPI`: "Use Re-run all jobs: this attempt did not build the distributions" | A failed-jobs re-run of the upload; publication never reuses an earlier attempt's build. Use **Re-run all jobs**. |
| `Publish to PyPI`: `invalid-publisher` | PyPI's Trusted Publisher and the job's OIDC claims disagree, and nothing was uploaded. Check the publisher's four values against BRANCH_WORKFLOW.md (owner `Synaptic724`, repository `melder`, workflow `python-publish.yml`, environment `pypi`), then **Re-run all jobs**. |
| `Release / provenance`: "PyPI holds no provenance for <file>" | The files were uploaded but PyPI holds no accepted attestation for them. They are published and cannot be replaced: compare PyPI's Trusted Publisher with BRANCH_WORKFLOW.md and read the `Publish to PyPI` log before the release is announced; whether to yank it is the owner's call. |
| `Release / provenance`: "... does not name the expected signer" or "is not the expected Trusted Publisher" | The message lists each claim with what was expected and what was found. `ReleaseProvenancePolicy` in `pypi_provenance.py` holds the expected identity; a changed workflow, environment, repository or event changes it. The files are published: find the cause before the release is announced. |
| `Release / provenance`: a PyPI lookup, the tool's install or its trust root failed after the retries | **Re-run failed jobs**: the check runs alone again on the same build and still expects the attempt that uploaded. |
| Prod pull request: "Candidate qualification has not passed" | The release-candidate run for that commit is pending or failed; finish or fix it, then rerun the check. |
| The Codecov upload fails | Reporting only: it never blocks a merge. |

Artifacts worth opening: `runtime-results-*` (JUnit) and `coverage-*` for each test cell,
`runtime-python-matrix-*` (the releases a run tested), the speed tests' results folders (logs, `install.log`,
`environment.json`), `source-qualification-*`, and the release candidate's `candidate-*` reports, among them
`candidate-provenance-*`, which records each file's verified signer, run and trigger.
`environment.json`), `source-qualification-*`, the release candidate's `candidate-*` reports, among them
`candidate-provenance-*`, which records each file's verified signer, run and trigger, and a release's
`release-provenance-*`, which records the same for every file published to PyPI.

## Environment notes for agents

Expand Down
7 changes: 6 additions & 1 deletion .github/ci_cd/workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,12 @@ Publication runs are serialized.
| `release-gate` (Require current prod HEAD) | The event, the checkout and the current `prod` head (and the live release tag) are one commit (`ci_policy.py release-head`), and that commit's candidate passed TestPyPI qualification (`check_candidate_run.py`). |
| `hygiene`, `source-assets`, `repo-assets` | Hygiene and both asset checks again, fresh. The runtime tests do not run again: full CI and the release candidate tested this exact tree, and `release-gate` refuses any other (owner, 2026-10-24). |
| `release-build` | Builds and verifies the distributions and compares the release tag with the package version (`build-distributions.yml`). |
| `pypi-publish` (Publish to PyPI) | In the `pypi` environment: refuses a failed-jobs re-run, because publication never reuses an earlier attempt's build (use **Re-run all jobs**), then rechecks the files, the candidate and the release head immediately before uploading with `PYPI_API_TOKEN`. |
| `pypi-publish` (Publish to PyPI) | In the `pypi` environment: refuses a failed-jobs re-run, because publication never reuses an earlier attempt's build (use **Re-run all jobs**), then rechecks the files, the candidate and the release head immediately before uploading them by Trusted Publishing with a PEP 740 attestation each (`pypa/gh-action-pypi-publish`, pinned to the commit the release candidate pins). With the release candidate's `publish` it is one of the two jobs that hold `id-token: write`; it uses no secret and reports its run attempt as an output. |
| `pypi-provenance` (Release / provenance) | After the upload, on the helper release, for every released file: reads its provenance from PyPI's Integrity API and checks it in the five layers `RC / provenance` uses, with the release's identity - its publisher is this workflow in `pypi`, and the signing certificate names this repository, the workflow at the run's ref (the release tag, or `prod` for a manual dispatch), the released commit, this run's upload attempt and its event (`pypi_provenance.py verify`). The files are on PyPI by then: a refusal fails the run, before the release is announced, but cannot unpublish them. Keeps `release-provenance-<run>-<attempt>`. |

`pypi-provenance` downloads the build `release-build` reports and reads the attempt `pypi-publish` recorded, so
after a PyPI lookup that ran out of retries **Re-run failed jobs** runs the check alone on the same files; it
never uploads anything.

## Reusable workflows

Expand Down
Loading