release #4
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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') }} |