Skip to content

release

release #4

Workflow file for this run

name: release
# Publish a release to PyPI and cut the GitHub Release to match.
#
# Push a tag and this does the rest:
#
# git tag -a v2.3.0 -m "jupyddl 2.3.0" && git push origin v2.3.0
#
# Authentication is PyPI **Trusted Publishing** (OIDC), so there is no API
# token in this repository's secrets to leak or rotate. That requires a
# one-time setup on PyPI — see `docs/RELEASING.md`. Until it is done the
# `pypi` job fails and everything before it still succeeds, so a tag never
# leaves you with half a release and no artifacts.
#
# A release can also be cut entirely from the Actions tab, without anyone
# pushing a tag from a laptop:
#
# Actions -> release -> Run workflow -> target: pypi
#
# That path creates the tag itself, at the commit it built and tested, so the
# tag can never point at something that was never verified. Choosing
# `testpypi` instead runs the identical pipeline against TestPyPI and creates
# no tag, which is how to validate a change to this file without spending a
# real version number. PyPI releases are effectively permanent: a version can
# be yanked but never replaced, so the dry run is worth the two minutes.
on:
push:
tags: ["v*"]
workflow_dispatch:
inputs:
target:
description: "Where to publish (pypi also tags and cuts the release)"
required: true
default: testpypi
type: choice
options: [testpypi, pypi]
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: read
jobs:
build:
name: build and verify
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@v7
with:
submodules: false
fetch-depth: 0
- uses: actions/setup-python@v7
with:
python-version: '3.12'
- name: Read the version the package declares
id: version
run: |
version=$(python -c "import re,pathlib; \
print(re.search(r'^version = \"([^\"]+)\"', \
pathlib.Path('pyproject.toml').read_text(), re.M).group(1))")
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "pyproject declares $version"
# A tag that disagrees with the package version publishes something
# nobody asked for under a name nobody expects, and PyPI will not let
# you take it back. Refuse before anything is built.
- name: Refuse a tag that disagrees with the package version
if: startsWith(github.ref, 'refs/tags/')
run: |
tag="${GITHUB_REF_NAME#v}"
declared="${{ steps.version.outputs.version }}"
if [ "$tag" != "$declared" ]; then
echo "::error::tag ${GITHUB_REF_NAME} does not match pyproject version ${declared}." \
"Retag, or fix the version, but do not publish a mismatch."
exit 1
fi
echo "tag ${GITHUB_REF_NAME} matches ${declared}"
# jupyddl exports __version__ as well, and a package whose metadata and
# runtime disagree is a support ticket waiting to happen.
- name: Refuse a package whose __version__ disagrees with its metadata
run: |
runtime=$(python -c "import re,pathlib; \
print(re.search(r'^__version__ = \"([^\"]+)\"', \
pathlib.Path('jupyddl/__init__.py').read_text(), re.M).group(1))")
declared="${{ steps.version.outputs.version }}"
if [ "$runtime" != "$declared" ]; then
echo "::error::jupyddl.__version__ is ${runtime} but pyproject says ${declared}"
exit 1
fi
echo "__version__ matches ${declared}"
- name: Build the sdist and the wheel
run: |
python -m pip install --upgrade pip build twine
python -m build
twine check --strict dist/*
# The same smoke test the `build` workflow runs on every push, repeated
# here because this is the artefact that actually goes out.
- name: Install the wheel clean and plan with it
working-directory: /tmp
run: |
python -m venv /tmp/fresh
/tmp/fresh/bin/pip install "$GITHUB_WORKSPACE"/dist/*.whl
installed=$(/tmp/fresh/bin/python -c "import jupyddl; print(jupyddl.__version__)")
if [ "$installed" != "${{ steps.version.outputs.version }}" ]; then
echo "::error::installed wheel reports $installed"
exit 1
fi
/tmp/fresh/bin/jupyddl generate gripper -n 3 --seed 1 -o /tmp/smoke
/tmp/fresh/bin/jupyddl solve \
/tmp/smoke/gripper-03-1/domain.pddl \
/tmp/smoke/gripper-03-1/problem.pddl \
-s astar -H lmcut | tee /tmp/plan.txt
grep -q "Valid: True" /tmp/plan.txt
- name: Extract this version's changelog section
run: |
python - <<'PY' > release-notes.md
import pathlib, re
version = "${{ steps.version.outputs.version }}"
text = pathlib.Path("CHANGELOG.md").read_text(encoding="utf-8")
# Everything between this version's heading and the next one.
match = re.search(
rf"^## \[{re.escape(version)}\][^\n]*\n(.*?)(?=^## \[|\Z)",
text, re.M | re.S,
)
print(match.group(1).strip() if match else
f"See CHANGELOG.md for what changed in {version}.")
PY
cat release-notes.md
- uses: actions/upload-artifact@v7
with:
name: distributions
path: |
dist/
release-notes.md
pypi:
name: publish to ${{ github.event.inputs.target || 'pypi' }}
needs: build
runs-on: ubuntu-latest
# The environment is what a PyPI trusted publisher is scoped to, and it is
# also where a required reviewer can be attached if you ever want a human
# to approve each publish.
environment:
name: ${{ github.event.inputs.target || 'pypi' }}
url: https://pypi.org/project/jupyddl/${{ needs.build.outputs.version }}
permissions:
id-token: write # mint the OIDC token PyPI verifies; no secrets involved
steps:
- uses: actions/download-artifact@v8
with:
name: distributions
- name: Publish
uses: pypa/gh-action-pypi-publish@release/v1
with:
repository-url: >-
${{ github.event.inputs.target == 'testpypi'
&& 'https://test.pypi.org/legacy/' || '' }}
# A tag re-run after a partial failure should not fall over on the
# files that already made it.
skip-existing: true
github-release:
name: cut the GitHub release
needs: [build, pypi]
# Either a tag was pushed, or someone asked for a real release from the
# Actions tab. A TestPyPI dry run deliberately creates neither tag nor
# release -- it exists to rehearse, not to leave traces.
#
# Deliberately NOT gated on the publish succeeding. A GitHub Release
# records that a version was cut from a verified commit; PyPI is a
# downstream channel that can fail for reasons the code has nothing to do
# with -- auth not configured, an outage, a rate limit. Losing the tag and
# the release because of that would mean no record of the version and a
# full re-run to get one. `build` succeeding is the gate that matters:
# that is where every check lives. A failed publish stays red and visible,
# and re-running just that job finishes the job.
if: >-
always()
&& needs.build.result == 'success'
&& (startsWith(github.ref, 'refs/tags/')
|| github.event.inputs.target == 'pypi')
runs-on: ubuntu-latest
permissions:
contents: write # create the tag, the release, and attach the artifacts
steps:
- uses: actions/download-artifact@v8
with:
name: distributions
- name: Create the release
uses: softprops/action-gh-release@v3
with:
# Named rather than inherited from the ref, so the dispatch path
# creates `v<version>` at this commit. The release API creates the
# tag when it does not exist, and the build job has already refused
# to get here if the version were inconsistent anywhere.
tag_name: v${{ needs.build.outputs.version }}
target_commitish: ${{ github.sha }}
name: jupyddl ${{ needs.build.outputs.version }}
body_path: release-notes.md
files: dist/*
draft: false
prerelease: ${{ contains(needs.build.outputs.version, 'rc')
|| contains(needs.build.outputs.version, 'a')
|| contains(needs.build.outputs.version, 'b') }}