Skip to content
Merged

Dev #199

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
  •  
  •  
  •  
145 changes: 86 additions & 59 deletions .github/BRANCH_WORKFLOW.md

Large diffs are not rendered by default.

11 changes: 7 additions & 4 deletions .github/ci_cd/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,12 @@ pull request into preprod --> speed-tests.yml, beside ci.yml; nothing waits fo
real-world-gauntlet, persistent-runtime-gauntlet, shallow-all-thread-scaling when route says so

push to release_candidate, or a manual run there --> release-candidate.yml
authorize -> source-qualification -> build -> publish (TestPyPI)
-> install (every test-manifest release on Linux, Windows and macOS) -> package-ready
authorize -> source-qualification -> build -> publish (TestPyPI, by Trusted Publishing, with attestations)
-> provenance (every file's attestation verified) -> install (every test-manifest release on Linux,
Windows and macOS) -> package-ready

published release, or a manual run on prod --> python-publish.yml
release-gate -> hygiene, source-assets, repo-assets, tests -> release-build -> pypi-publish
release-gate -> hygiene, source-assets, repo-assets -> release-build -> pypi-publish
```

Workflows only orchestrate. The decisions (routes, required results, Python matrices, installs, package checks)
Expand All @@ -54,7 +55,9 @@ Python releases CI may use are data: one manifest per release in `.github/python
only reports, like the speed tests, gets its own workflow and never runs in `ci.yml`: later promotions reuse a
CI run only when the whole run finished green.
3. Logic goes in a script with a test, not in YAML. Scripts stay standard-library only: CI runs them before
anything is installed.
anything is installed, and a contract test enforces it. The one tool that needs packages, the provenance
verifier, lives in `.github/provenance/` and runs only in its own hash-pinned environment
([scripts.md](scripts.md)).
4. Check every CI change locally ([validating.md](validating.md)). Hosted runs belong to the owner: report them as
"Not run" until you have seen their result.
5. Keep this folder current in the change that changes CI. A contract test fails when a workflow, script or
Expand Down
21 changes: 20 additions & 1 deletion .github/ci_cd/extending.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ Every change below ends the same way: run the workflow tests, update this folder
- `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
publication tokens live in the `pypitest` and `pypi` environments, each used by one job.
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.
- 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,6 +54,23 @@ workflow, as `speed-tests.yml` does for the speed tests.
`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`.

## 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`.
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).
3. Record the new control: save PyPI's Integrity API answer for the `pypi-attestations` wheel the lock installs
as `.github/provenance/known_good_provenance.json`, byte for byte, and update `ProvenanceControl` in
`testpypi_provenance.py` (name, sha256, signer, claims). A control whose wheel the lock does not install is
refused before anything runs.
4. Build the tool environment from the lock in a scratch folder and run the verifier on the controls; the genuine
one must verify and both tampered copies must be refused. Then run the workflow tests.

## Add or change a route

Routes are decided only by `validate_route`, `validation_requirements` and, for the speed tests, `speed_required`
Expand Down
71 changes: 51 additions & 20 deletions .github/ci_cd/python_versions.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ CI never chooses a Python release by itself. Every interpreter it sets up is one
| --- | --- | --- |
| Runtime test cells (`test-runtime.yml`) and the release candidate's install probes (`release-candidate.yml`) | Every release that has a test manifest, free-threaded, on Linux x64, Windows x64 and macOS arm64 | `.github/python/tests/<release>.toml`, read by `python_runtime_matrix.py discover` |
| The three speed tests | The one speed release, free-threaded, on three x64 runners | `.github/python/speed/<release>.toml`, read by `python_runtime_matrix.py speed` |
| Every other job (policy, assets, docs, builds, publication, the speed tests' manifest jobs) | The helper release, written as `python-version: "3.14.7"` | The workflow file; the release must have a test manifest |
| Every other job (policy, assets, docs, builds, publication, the provenance check, the speed tests' manifest jobs) | The helper release, written as `python-version: "3.14.7"` | The workflow file; the release must have a test manifest |

Nothing is looked up on the network and nothing follows the newest patch, so a new Python release, 3.15 included,
runs only after someone adds its manifest. `test_workflow_contracts.py` refuses any `setup-python` step that asks
for `check-latest`, a version file, a pre-release, a second helper release, or a release with no test manifest.
Nothing is looked up on the network and nothing follows the newest patch, so a new Python release runs only after
someone adds its manifest. `test_workflow_contracts.py` refuses any `setup-python` step that asks for
`check-latest`, a version file, a pre-release, a second helper release, or a release with no test manifest.

## The manifest format

Expand All @@ -21,6 +21,8 @@ A manifest is a TOML file named after its exact release:
python = "3.14.0"
freethreaded = true
dependencies = [
# Melder's runtime dependency ([project.dependencies] in pyproject.toml); the rest is the test group.
"pip==26.2.2",
"pytest==9.1.1",
"pytest-cov==7.1.0",
"coverage==7.16.1",
Expand All @@ -37,9 +39,13 @@ dependencies = [
- `freethreaded` must be `true`: Melder CI qualifies free-threaded builds only.
- `dependencies` is the whole install: exact `name==version` pins, one per distribution, each optionally followed
by `; marker`. Installs skip dependency resolution, so a pinned package's own dependencies must be pinned too.
The test manifests hold the `test` group's closure from `uv.lock`.
- `build_from_source` (optional) lists pinned distributions that pip must build from their sdist because no
usable wheel exists. The speed manifest uses it for dependency-injector.
The test manifests hold Melder's runtime dependency (pip) and the `test` group's closure from `uv.lock`; the
speed manifest holds pip and the benchmarks' pins.
- `build_from_source` (optional) lists pinned distributions built from their sdist because no usable wheel
exists: dependency-injector in the speed manifest, PyYAML in the 3.15 test manifests. The speed install
passes each to pip as `--no-binary`; a test cell's `uv pip install` builds any pin that has no wheel for
its release, so in a test manifest the list records which pins are built. Their build tools (setuptools
and Cython for PyYAML) are fetched into that isolated build only.

`load_manifest` refuses anything else: another or a non-final release, `freethreaded = false`, a range or a
missing pin, a pin repeated, an unknown key, a source build that is not pinned. The tests folder must contain the
Expand All @@ -65,21 +71,43 @@ folder>`. That refuses any interpreter except the manifest's free-threaded relea
requirements.txt` and keeps `install.log` beside them. The provenance step then asserts that
`platform.python_version()` equals `SPEED_PYTHON`.

The release candidate's probes install only the published wheel, without dependencies (see the dependency recipe
below).
The two installed-package probes install a built wheel beside Melder's runtime dependencies, at the pins of the
test manifest of the release they run, with no resolver. `python_runtime_matrix.py runtime-requirements` writes
those pins: the manifest's pins of the names in `[project.dependencies]`, refused when the manifest leaves one out.
The wheel probe in `build-distributions.yml` runs, on the helper release:

```bash
python .github/scripts/python_runtime_matrix.py runtime-requirements --output "$RUNNER_TEMP/melder-runtime-requirements.txt"
uv venv --python "<the helper interpreter>" "$RUNNER_TEMP/melder-wheel-probe"
uv pip install --python "$RUNNER_TEMP/melder-wheel-probe/bin/python" --no-deps -r "$RUNNER_TEMP/melder-runtime-requirements.txt" dist/*.whl
uv pip check --python "$RUNNER_TEMP/melder-wheel-probe/bin/python"
```

and then `smoke_wheel.py` in that environment. The release candidate's probe (`testpypi_candidate.py
probe-install`, on every test-manifest release) downloads the wheel from TestPyPI by its hash and the same pins
from PyPI (`pip download --no-deps --only-binary=:all:`), installs them together into a fresh environment with
`pip install --no-deps --no-index`, runs `pip check` and then `smoke_wheel.py`. `pip check` is what proves the
pinned set satisfies the wheel's own requirements.

The provenance check (`testpypi_provenance.py verify`, on the helper release) installs nothing from a test
manifest. It builds its own environment from `.github/provenance/requirements.txt`, the hashed closure of the
publishing action's signing tools, with `pip install --isolated --no-deps --only-binary=:all: --require-hashes`
and then `pip check`. That lock is resolved for the helper release on Linux x64.

## Recipes

### Add a Python release (for example 3.14.9)

1. Copy the newest test manifest to `.github/python/tests/3.14.9.toml` and set `python = "3.14.9"`.
2. Make sure every pin installs on that release as a free-threaded wheel (a `cp314t` wheel or a pure-Python one);
change the pins that do not, in this manifest only.
change the pins that do not, in this manifest only. A pin that has no such wheel in any version may stay
if its sdist builds on all three runners: list it in `build_from_source`, as the 3.15 manifests do for PyYAML.
3. Run the workflow tests ([validating.md](validating.md)). The runtime matrix and the release candidate's probes
each gain three jobs; nothing else changes.

A new minor (3.15) works the same way. Expect to adjust pins: libraries publish free-threaded wheels for a new
minor on their own schedule.
A new minor works the same way. Expect to adjust pins: libraries publish free-threaded wheels for a new minor on
their own schedule. 3.15 arrived before PyYAML published a `cp315t` wheel, so its manifests build PyYAML from
source; when a release with that wheel appears, pin it there and drop `build_from_source`.

### Retire releases or raise the floor

Expand All @@ -99,14 +127,15 @@ pin when a newer version of the library does not support it.

1. Add it to `[project.dependencies]` in `pyproject.toml` and update `uv.lock`.
2. Pin it, with its own dependencies, in every test manifest. A test fails while any test manifest leaves out a
declared dependency.
declared dependency, or pins one at another version than an exact `==` declaration.
3. Pin it in the speed manifest too: the speed jobs import Melder from the checkout (`PYTHONPATH=src`), not from
an installed wheel.
4. Before merging, change the two installed-package probes. They install the wheel without its dependencies and
would then fail to import Melder: the wheel probe in `build-distributions.yml`
(`uv pip install --no-deps dist/*.whl`) and `probe_install` in `testpypi_candidate.py`
(`pip install --no-deps --no-index`). Pinning the dependency for them keeps the probes reproducible.
5. If the documentation build imports Melder, add the dependency to the documentation requirements as well.
an installed wheel. A test fails while the speed manifest leaves out a declared dependency.
4. The installed-package probes follow on their own: both install, beside the wheel, the pins `runtime_pins`
selects (see "How each consumer installs"), and `pip check` runs before the smoke probe. They select
declared names only, so a dependency that brings its own dependencies fails `pip check` until `runtime_pins`
in `python_runtime_matrix.py` selects those too (pip declares none).
5. The documentation build imports Melder from `src` for autodoc (`docs/conf.py`): pin the dependency in
`docs/requirements.lock` as well.

### Move the speed tests to another release

Expand All @@ -117,4 +146,6 @@ benchmark numbers compare only between runs on the same manifest.
### Change the helper release

Replace every `python-version: "3.14.7"` in `.github/workflows/` (including the speed tests' `'3.14.7'`) with the
new release in one change; it must have a test manifest. The contract test refuses a mix.
new release in one change; it must have a test manifest. The contract test refuses a mix. A new minor also needs
the provenance tool's lock regenerated for it (the command is in the header of
`.github/provenance/requirements.txt`).
Loading
Loading