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
48 changes: 0 additions & 48 deletions .github/workflows/publish-to-test-pypi.yml

This file was deleted.

100 changes: 84 additions & 16 deletions .github/workflows/wheels.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,24 @@ on:
default: none
options: [none, testpypi, pypi]

permissions:
contents: read

jobs:
plan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: "3.12"
- name: Check package version and publication target
env:
RELEASE_TARGET: ${{ github.event_name == 'workflow_dispatch' && inputs.publish || 'none' }}
run: python tools/check_release.py --target "$RELEASE_TARGET"

sdist:
needs: plan
name: sdist
runs-on: ubuntu-latest
steps:
Expand Down Expand Up @@ -59,10 +75,11 @@ jobs:

- uses: actions/upload-artifact@v7
with:
name: sdist
name: release-sdist
path: dist/*.tar.gz

wheels:
needs: plan
name: wheels (linux x86_64)
runs-on: ubuntu-latest
steps:
Expand Down Expand Up @@ -141,29 +158,80 @@ jobs:

- uses: actions/upload-artifact@v7
with:
name: wheels-linux-x86_64
name: release-wheels-linux-x86_64
path: wheelhouse/*.whl

publish:
name: publish to ${{ github.event.inputs.publish }}
validate:
needs: [sdist, wheels]
if: github.event_name == 'workflow_dispatch' && github.event.inputs.publish != 'none'
runs-on: ubuntu-latest
environment: ${{ github.event.inputs.publish }}
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: "3.12"
- uses: actions/download-artifact@v8
with:
pattern: release-*
path: dist
merge-multiple: true
- run: python -m pip install twine
- name: Validate distribution metadata and wheel coverage
env:
FULL_MATRIX: ${{ github.event_name == 'workflow_dispatch' }}
run: |
python -m twine check --strict dist/*
if [ "$FULL_MATRIX" = "true" ]; then
python tools/check_release.py --artifacts dist --full-matrix
else
python tools/check_release.py --artifacts dist
fi

publish-testpypi:
name: Publish to TestPyPI
needs: validate
if: github.event_name == 'workflow_dispatch' && inputs.publish == 'testpypi'
runs-on: ubuntu-latest
environment:
name: testpypi
url: https://test.pypi.org/project/python-ace/
concurrency:
group: publish-python-ace-testpypi
cancel-in-progress: false
permissions:
id-token: write # trusted publishing; no API token in secrets
id-token: write
steps:
- uses: actions/download-artifact@v8
with:
pattern: release-*
path: dist
merge-multiple: true

- run: ls -l dist/

- name: Publish
uses: pypa/gh-action-pypi-publish@release/v1
- uses: pypa/gh-action-pypi-publish@release/v1
with:
password: ${{ secrets.TEST_PYPI_API_TOKEN }}
attestations: ${{ secrets.TEST_PYPI_API_TOKEN == '' }}
repository-url: https://test.pypi.org/legacy/

publish-pypi:
name: Publish to PyPI
needs: validate
if: github.event_name == 'workflow_dispatch' && inputs.publish == 'pypi'
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/project/python-ace/
concurrency:
group: publish-python-ace-pypi
cancel-in-progress: false
permissions:
id-token: write
steps:
- uses: actions/download-artifact@v8
with:
pattern: release-*
path: dist
merge-multiple: true
- uses: pypa/gh-action-pypi-publish@release/v1
with:
repository-url: >-
${{ github.event.inputs.publish == 'testpypi'
&& 'https://test.pypi.org/legacy/'
|| 'https://upload.pypi.org/legacy/' }}
user: ${{ secrets.PYPI_USERNAME || '__token__' }}
password: ${{ secrets.PYPI_PASSWORD }}
attestations: ${{ secrets.PYPI_PASSWORD == '' }}
63 changes: 63 additions & 0 deletions docs/pacemaker/releases.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Building and publishing python-ace

The **Build wheels** GitHub Actions workflow (`.github/workflows/wheels.yml`)
builds a source distribution and Linux x86-64 manylinux_2_28 wheels. Pull
requests and normal pushes build Python 3.12; manual runs build Python
3.9, 3.10, 3.11, 3.12, and 3.13. The wheels are installed and smoke-tested,
then all distributions pass metadata, package-content, and version checks.
This workflow currently produces Linux x86-64 wheels; other platforms can
build from the source distribution with a C++ compiler.

## Build without uploading

In GitHub Actions, select **Build wheels → Run workflow** and leave `publish`
as `none`. Download `release-sdist` and `release-wheels-linux-x86_64` from the
completed run. This works before allocating a new release version.

```bash
gh workflow run wheels.yml --repo ICAMS/python-ace --ref master -f publish=none
```

## Publish a release

1. Set a new version in `pyproject.toml` and merge that change into `master`.
Version 0.4.0rc1 already exists on PyPI and cannot be replaced.
2. Run **Build wheels** from `master` with `publish=testpypi` first.
3. After reviewing the TestPyPI artifacts and installation, run it from
`master` with `publish=pypi`.
4. Verify the release on PyPI and install the explicit new version in a clean
environment. Prereleases require an explicit version or pip's `--pre`.

```bash
gh workflow run wheels.yml --repo ICAMS/python-ace --ref master -f publish=testpypi
# After checking the TestPyPI release:
gh workflow run wheels.yml --repo ICAMS/python-ace --ref master -f publish=pypi
```

Publishing is manual. The workflow rejects non-master publication and versions
already present on the selected index before building. It uploads only after
all builds, wheel smoke tests, and distribution checks pass. PyPI and TestPyPI
use separate publishing jobs and GitHub environments (`pypi` and `testpypi`).

## Authentication

The workflow uses the repository's existing secrets:

| Index | Secrets |
| --- | --- |
| PyPI | `PYPI_PASSWORD` containing a project-scoped API token; `PYPI_USERNAME` must be `__token__` or omitted |
| TestPyPI | `TEST_PYPI_API_TOKEN` containing a TestPyPI API token |

The workflow never prints token values. The existing secrets' validity cannot
be verified without attempting an upload. Tokens must have permission for the
`python-ace` project on the respective index.

For token-free Trusted Publishing, configure an existing-project publisher on
PyPI (and separately on TestPyPI) with owner `ICAMS`, repository `python-ace`,
workflow filename `wheels.yml`, and the matching environment name `pypi` or
`testpypi`. Remove the corresponding password secret after configuring the
publisher; the workflow then uses OIDC and enables attestations automatically.
The publisher registration is performed in the PyPI/TestPyPI account settings,
not in GitHub Actions.

See the [PyPI Trusted Publishing guide](https://docs.pypi.org/trusted-publishers/adding-a-publisher/).
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ extra_javascript:
nav:
- Home: index.md
- Installation: pacemaker/install.md
- Building and publishing: pacemaker/releases.md
- Quick start: pacemaker/quickstart.md
- Pacemaker workflow: pacemaker/workflow.md
- Extrapolation grade and active learning: pacemaker/active_learning.md
Expand Down
70 changes: 70 additions & 0 deletions tools/check_release.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""Check publication eligibility and the distributions built by wheels.yml."""
import argparse
from email.parser import BytesParser
import os
from pathlib import Path
import tarfile
import tomllib
from urllib.error import HTTPError
from urllib.request import urlopen
import zipfile


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--target", choices=["none", "testpypi", "pypi"], default="none")
parser.add_argument("--artifacts", type=Path)
parser.add_argument("--full-matrix", action="store_true")
args = parser.parse_args()
project = tomllib.loads(Path("pyproject.toml").read_text())["project"]
name, version = project["name"], project["version"]
if name != "python-ace":
raise SystemExit(f"Unexpected distribution name: {name}")
print(f"Distribution: {name} {version}", flush=True)
if args.target != "none":
if os.environ.get("GITHUB_REF") != "refs/heads/master":
raise SystemExit("Publish from master after merging the release version change.")
host = "test.pypi.org" if args.target == "testpypi" else "pypi.org"
url = f"https://{host}/pypi/{name}/{version}/json"
try:
with urlopen(url, timeout=30):
pass
except HTTPError as error:
if error.code != 404:
raise
else:
raise SystemExit(f"{name} {version} already exists on {host}; choose a new version in pyproject.toml.")
print(f"Version is available on {host}.")
if args.artifacts is None:
return
wheels = sorted(args.artifacts.glob("*.whl"))
sdists = sorted(args.artifacts.glob("*.tar.gz"))
if not wheels or len(sdists) != 1:
raise SystemExit("Expected at least one wheel and exactly one source distribution.")

def check_metadata(raw, filename):
metadata = BytesParser().parsebytes(raw)
if metadata["Name"] != name or metadata["Version"] != version:
raise SystemExit(f"Distribution metadata disagrees with pyproject.toml: {filename}")

for wheel in wheels:
with zipfile.ZipFile(wheel) as archive:
entries = [n for n in archive.namelist() if n.endswith(".dist-info/METADATA")]
if len(entries) != 1:
raise SystemExit(f"Expected one metadata file in {wheel}")
check_metadata(archive.read(entries[0]), wheel)
with tarfile.open(sdists[0]) as archive:
entries = [m for m in archive.getmembers() if m.name.endswith("/PKG-INFO") and m.name.count("/") == 1]
if len(entries) != 1:
raise SystemExit("Expected one top-level PKG-INFO in the source distribution.")
check_metadata(archive.extractfile(entries[0]).read(), sdists[0])
if args.full_matrix:
expected = {"cp39", "cp310", "cp311", "cp312", "cp313"}
actual = [w.name.split("-")[2] for w in wheels]
if set(actual) != expected or len(actual) != len(expected):
raise SystemExit(f"Expected exactly one wheel per interpreter {sorted(expected)}, found {actual}")
print(f"Validated {len(wheels)} wheels and one source distribution for {name} {version}.")


if __name__ == "__main__":
main()
Loading