Skip to content
Open
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
9 changes: 9 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# The release workflow pins its actions to commit SHAs so that a moved tag in
# someone else's repository cannot run new code in the job that holds the PyPI
# OIDC identity. Dependabot is what keeps those pins from going stale.
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "monthly"
145 changes: 145 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
name: Release

# Builds and validates the distributions on every change that can affect
# packaging, and publishes to PyPI when a v* tag is pushed. Publishing uses
# PyPI Trusted Publishing (OIDC), so there is no API token stored in this repo.
# See RELEASING.md for the one-time PyPI-side setup and the release steps.
#
# Third-party actions are pinned to a commit SHA rather than a tag or branch:
# the publish job holds an OIDC identity that PyPI trusts to upload as `dion`,
# so a moved tag in someone else's repository must not be able to run new code
# inside it. .github/dependabot.yml keeps the pins current.

on:
push:
tags:
- "v*"
pull_request:
paths:
- "setup.py"
- "pyproject.toml"
- "MANIFEST.in"
- "requirements_*.txt"
# long_description, and the files MANIFEST.in ships.
- "README.md"
- "LICENSE"
- "NOTICE.md"
- "CHANGELOG.md"
# find_packages() decides what lands in the wheel, so the package layout
# is packaging input too -- a new subdirectory without an __init__.py is
# silently dropped from the distribution.
- "dion/**"
- "tests/test_packaging.py"
- ".github/workflows/release.yml"
workflow_dispatch:

permissions:
contents: read

jobs:
build:
name: Build and validate distributions
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
persist-credentials: false

- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"

- name: Install build tooling
run: python -m pip install --upgrade build twine pytest packaging

- name: Check packaging metadata
run: python -m pytest tests/test_packaging.py -v

# The bare `python -m build` is load-bearing: with no flags it builds the
# sdist from the source tree and then the wheel *from that sdist*, which
# is the round trip that exposes an sdist missing its requirements files.
# `python -m build --sdist --wheel` builds both from the source tree
# instead and would leave the wheel checks below passing on a broken sdist.
- name: Build sdist and wheel
run: python -m build

- name: Check distribution metadata
run: python -m twine check --strict dist/*

# twine check only renders the long description. These re-run the
# packaging tests against the actual artifacts, which is what catches an
# sdist that dropped its requirements files.
- name: Check built artifacts
env:
DION_DIST_DIR: dist
run: python -m pytest tests/test_packaging.py -v

# Checks the version baked into the built filenames -- the one that will
# actually be uploaded -- rather than re-reading setup.py, so the gate
# cannot drift from what the publish job pushes to PyPI.
- name: Verify the tag matches the built distributions
if: startsWith(github.ref, 'refs/tags/v')
env:
TAG_NAME: ${{ github.ref_name }}
run: |
python - <<'PY'
import os
import pathlib
from packaging.utils import parse_sdist_filename, parse_wheel_filename
from packaging.version import InvalidVersion, Version


def fail(message):
print(f"::error::{message}")
raise SystemExit(1)


def only(pattern):
matches = sorted(pathlib.Path("dist").glob(pattern))
if len(matches) != 1:
fail(f"expected exactly one {pattern} in dist/, found {[p.name for p in matches]}")
return matches[0]


built = {
parse_wheel_filename(only("*.whl").name)[1],
parse_sdist_filename(only("*.tar.gz").name)[1],
}
if len(built) != 1:
fail(f"sdist and wheel disagree on the version: {sorted(str(v) for v in built)}")
packaged = built.pop()

tag = os.environ["TAG_NAME"]
try:
tagged = Version(tag[1:])
except InvalidVersion:
fail(f"tag {tag} is not a PEP 440 version, so it cannot name a release")
if tagged != packaged:
fail(f"tag {tag} would publish version {packaged}; bump setup.py or retag")
print(f"tag {tag} matches the built version {packaged}")
PY

- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: dist
path: dist/

publish:
name: Publish to PyPI
needs: build
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
# The environment gates the OIDC identity that PyPI trusts, and is where a
# manual approval can be required before anything reaches PyPI.
environment:
name: pypi
url: https://pypi.org/p/dion
permissions:
id-token: write
steps:
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: dist
path: dist/

- uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,6 @@ output/
.venv/
submit.ipynb
aztool/
dion.egg-info/
dion.egg-info/
dist/
build/
11 changes: 11 additions & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# setup.py reads the requirements files at build time to populate
# install_requires and extras_require. Without them in the sdist,
# `read_requirements` finds nothing, warns, and returns an empty list -- so
# building from the sdist yields a wheel that declares no dependencies at all.
# The glob covers requirements files added later, which an explicit list would
# not. See tests/test_packaging.py.
include requirements_*.txt
include LICENSE
include NOTICE.md
include README.md
include CHANGELOG.md
90 changes: 90 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Releasing Dion

Dion publishes to [PyPI](https://pypi.org/p/dion) from
`.github/workflows/release.yml` when a `v*` tag is pushed. Publishing uses PyPI
[Trusted Publishing](https://docs.pypi.org/trusted-publishers/), so no API token
is stored in this repository.

## One-time setup

All three steps need a human with the right accounts; none can be done from a PR.

**1. Enable GitHub Actions for workflows in this repository.** As of this
writing no workflow defined in `.github/` has ever run here — the only entries
under *Actions* are the org's managed ones (CodeQL default setup, Copilot
review, Dependabot), and `release.yml` did not trigger on the pull request that
added it. Reading the setting needs admin, so check *Settings → Actions →
General* and confirm that actions are allowed and that `actions/*` and
`pypa/gh-action-pypi-publish` are permitted by the allow-list, if one is in
force. Without this neither half of the workflow runs: no pre-tag validation,
and no publish.

**2. Register the pending publisher on PyPI.** The `dion` name is unclaimed, so
the first upload creates the project. Log in to pypi.org with the account that
should own it, go to *Your projects → Publishing → Add a new pending publisher*,
and enter:

| Field | Value |
| --- | --- |
| PyPI Project Name | `dion` |
| Owner | `microsoft` |
| Repository name | `dion` |
| Workflow name | `release.yml` |
| Environment name | `pypi` |

A pending publisher is what lets the workflow create a project that does not
exist yet. Once the first release lands it becomes an ordinary trusted publisher.

**3. Create the `pypi` environment.** In *Settings → Environments*, add an
environment named `pypi`. The name must match the workflow and the pending
publisher exactly. Adding required reviewers here puts a human approval in front
of every upload, which is worth doing on a public package.

## Cutting a release

1. Bump `version` in `setup.py`. Versions below `1.0` are pre-release: breaking
changes are allowed, but a released version number can never be reused.
2. Move the `[Unreleased]` entries in `CHANGELOG.md` under a new
`## [X.Y.Z] - YYYY-MM-DD` heading.
3. Open a PR with both changes and merge it. The `Release` workflow builds and
validates the distributions on that PR, so packaging breakage surfaces before
the tag exists.
4. Tag the merge commit and push:

```bash
git tag vX.Y.Z && git push origin vX.Y.Z
```

5. The workflow rebuilds, verifies the tag matches the version baked into the
built distributions, and publishes. If the `pypi` environment requires
reviewers, approve the run.

## Notes

- **A version is permanent.** PyPI does not allow reuse of a version number or
of a distribution filename, even after deletion. A bad release is yanked and
superseded, never replaced. Test on TestPyPI first if a release is unusual:
configure a second pending publisher at test.pypi.org and run the workflow
against it, or upload once by hand with
`twine upload -r testpypi dist/*`.
- **`twine check` is not a metadata check.** It renders the long description and
little else. The fields PyPI actually validates on upload — `author_email`
above all — are covered by `tests/test_packaging.py`, which runs in CI.
- **The sdist has to carry `requirements_*.txt`.** `setup.py` reads them at build
time to populate `install_requires`; if `MANIFEST.in` stops shipping them, a
build from the sdist still succeeds but declares no dependencies at all.
`tests/test_packaging.py` asserts against the built artifacts to catch this.
- **`python -m build` is spelled without flags on purpose.** With no arguments it
builds the sdist from the source tree and then the wheel *from that sdist*,
which is the round trip that exposes the failure above. `python -m build
--sdist --wheel` builds both from the source tree and would leave the wheel
assertions passing over a broken sdist.
- **The workflow pins its actions to commit SHAs.** The publish job holds an
OIDC identity that PyPI trusts to upload as `dion`, so it must not run code
fetched from a mutable tag or branch in someone else's repository —
`pypa/gh-action-pypi-publish@release/v1` is a branch. `.github/dependabot.yml`
raises PRs to move the pins forward; the trailing `# vX.Y.Z` comment on each
is what it reads to know the current version.
- **A tag is enough to publish.** Anyone who can push a `v*` tag — or run the
workflow manually against one — reaches the upload step. Required reviewers on
the `pypi` environment are what stands between that and PyPI.
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Package metadata lives in setup.py. This file exists so that builds go through
# PEP 517 (`python -m build`, `pip install .`) with a declared, isolated build
# environment, rather than the deprecated `python setup.py bdist_wheel` path.
[build-system]
requires = ["setuptools>=64", "wheel"]
build-backend = "setuptools.build_meta"
21 changes: 20 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,15 +56,34 @@ def read_requirements(path):
# },
# Author information:
author="Ahn, Kwangjun and Xu, Byron and Abreu, Natalie and Langford, John", # as listed in the paper
author_email="{kwangjunahn, byronxu}@microsoft.com", # left this form to prevent bots from harvesting emails
# PyPI validates this field and rejects an unparseable address, so it has to be a
# real mailbox. Bug reports belong on the issue tracker (see project_urls) rather
# than in a maintainer's inbox.
author_email="jcl@microsoft.com",
# Description of the package:
description="Dion: Distributed Orthonormal Updates",
long_description=readme_contents,
long_description_content_type="text/markdown",
project_urls={
"Homepage": "https://github.com/microsoft/dion",
"Source": "https://github.com/microsoft/dion",
"Issues": "https://github.com/microsoft/dion/issues",
"Changelog": "https://github.com/microsoft/dion/blob/main/CHANGELOG.md",
},
# Plugins entry point
classifiers=[
"Development Status :: 4 - Beta",
"Intended Audience :: Science/Research",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
],
python_requires=">=3.9",
license="MIT",
Expand Down
Loading