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
189 changes: 189 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
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.
#
# `workflow_dispatch` runs the same pipeline against TestPyPI, 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"
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@v4
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@v4
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]
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
permissions:
contents: write # create the release and attach the distributions
steps:
- uses: actions/download-artifact@v4
with:
name: distributions

- name: Create the release
uses: softprops/action-gh-release@v2
with:
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') }}
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,14 @@ native build step, and the core has zero runtime dependencies.
rebuilds `web/dist` in the same commit; reformatting a bundled source and
committing without the rebuild would leave main in a state where `pages`
refuses to deploy.
- **`release`** — fires on a `v*` tag. Builds, refuses a tag that disagrees
with `pyproject.toml` or a `__version__` that disagrees with either, runs
`twine check --strict`, installs the wheel clean and plans with it, publishes
to PyPI over OIDC (no stored token), then cuts the GitHub Release from the
changelog section for that version. `docs/RELEASING.md` is the runbook,
including the one-time PyPI trusted-publisher setup only a maintainer can do.
**Bump the version in two places** — `pyproject.toml` and
`jupyddl/__init__.py` — and rebuild `web/dist`, which carries it too.
- **`pages`** — bundles, refuses to deploy a stale `web/dist`, then deploys.
Its job is called `bundle`, not `build`, because `.mergify.yml` keys a merge
rule on a check named `build` and that has to mean the packaging workflow.
Expand Down
5 changes: 3 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ All notable changes to this project are documented in this file. The format is
based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and this
project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
## [2.3.0] - 2026-07-31

### Added
- **`jupyddl.learn`: heuristics trained from your own solved plans.** Every
Expand All @@ -24,7 +24,8 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
distribution shift, bootstrapping for the instances too hard to label, and
the cross-entropy method over the weight vector with the planner as a black
box. On blocksworld this took a held-out set from 366 expansions and 0.90
coverage to 131 and 1.00.
coverage to 137 and 1.00 — though see `.docs/rl-for-search.md`: that mean is
dominated by one hard instance, and the durable claim is the coverage one.
- **`jupyddl learn`**, and `learned:<model.json>` accepted anywhere a heuristic
name is — `solve`, `benchmark`, the API. `make_heuristic` also passes through
an already-built heuristic, so callers holding a trained model need not
Expand Down
14 changes: 12 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ actually *watch*. ✨

<div align="center">

[![PyPI](https://img.shields.io/pypi/v/jupyddl.svg)](https://pypi.org/project/jupyddl/)
[![Python versions](https://img.shields.io/pypi/pyversions/jupyddl.svg)](https://pypi.org/project/jupyddl/)
![tests](https://github.com/APLA-Toolbox/PythonPDDL/workflows/tests/badge.svg?branch=main)
![build](https://github.com/APLA-Toolbox/PythonPDDL/workflows/build/badge.svg?branch=main)
[![GitHub license](https://img.shields.io/github/license/Apla-Toolbox/PythonPDDL.svg)](./LICENSE)
Expand Down Expand Up @@ -70,11 +72,19 @@ is trivial to install, embed, teach with, and build on.

## Install 💾

Requires Python ≥ 3.9. Using [uv](https://docs.astral.sh/uv/) (recommended):
Requires Python ≥ 3.9, and nothing else:

```bash
pip install jupyddl # the framework and the CLI
pip install "jupyddl[viz]" # + matplotlib, for the charts and animations
pip install "jupyddl[viz,learn]" # + numpy, which only makes learning faster
```

To work on it, from a clone, using [uv](https://docs.astral.sh/uv/):

```bash
uv venv
uv pip install -e ".[dev,viz,learn]" # viz = matplotlib charts, learn = numpy (speed only)
uv pip install -e ".[dev,viz,learn]"
```

or with plain pip:
Expand Down
110 changes: 110 additions & 0 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# Releasing jupyddl

Releases are cut by pushing a tag. `.github/workflows/release.yml` builds,
verifies, publishes to PyPI and creates the GitHub Release from the changelog.

```bash
git tag -a v2.3.0 -m "jupyddl 2.3.0"
git push origin v2.3.0
```

## One-time setup on PyPI (a human has to do this)

The workflow authenticates with **Trusted Publishing** (OIDC), so there is no
API token stored in this repository — nothing to leak, nothing to rotate, and
nothing that keeps working if someone walks off with a laptop. The trade is one
piece of setup that can only be done by a PyPI maintainer of the project.

On <https://pypi.org/manage/project/jupyddl/settings/publishing/>, add a
publisher with **exactly** these values:

| Field | Value |
|---|---|
| Owner | `APLA-Toolbox` |
| Repository name | `PythonPDDL` |
| Workflow name | `release.yml` |
| Environment name | `pypi` |

The environment name matters: the `pypi` job declares
`environment: pypi`, and PyPI will reject a token minted from anywhere else.
That scoping is the point — a workflow added later by someone else cannot
publish unless it also runs in that environment.

Repeat on <https://test.pypi.org> with environment name `testpypi` if you want
the dry run below to work.

Until this exists the `pypi` job fails with an OIDC error. Everything before it
still succeeds, so a tag pushed early leaves you with verified artifacts and no
partial publish — re-run the job once the publisher is configured.

### Optional: require a human to approve each publish

In **Settings → Environments → pypi**, add yourself as a required reviewer.
GitHub then pauses the `pypi` job until someone approves it. Worth it if you
would rather a mistaken tag not reach PyPI unattended.

## Dry run

Validate the whole pipeline without spending a version number:

**Actions → release → Run workflow → target: `testpypi`**

A PyPI release is effectively permanent. A version can be *yanked*, which hides
it from resolvers, but it can never be replaced or re-uploaded — so the version
number is spent either way. Two minutes on TestPyPI is cheap next to that.

## Cutting a release

1. **Land everything on `main`** and confirm CI is green.
2. **Bump the version in two places** — they are checked against each other and
against the tag, and a mismatch fails the build rather than publishing a
surprise:
- `pyproject.toml` → `version`
- `jupyddl/__init__.py` → `__version__`
3. **Close the changelog section.** Rename `## [Unreleased]` to
`## [X.Y.Z] - YYYY-MM-DD`. The workflow extracts exactly this section as the
GitHub Release body, so what you write here is what people read.
4. **Rebuild the browser bundle** — `web/dist/build.json` carries the version:
```bash
python tools/build_web.py
```
5. Commit, push, merge.
6. **Tag the merge commit** and push the tag.

## What the workflow refuses to do

Each of these is a way a release goes wrong quietly, so each one is a hard
failure rather than a warning:

- **Tag disagrees with `pyproject.toml`.** Publishing `2.3.0` from a tag reading
`v2.4.0` cannot be undone.
- **`jupyddl.__version__` disagrees with the metadata.** A package that reports
a different version at runtime than the one you installed is a support ticket
with no obvious cause.
- **`twine check --strict` finds anything.** Malformed metadata renders as raw
text on the project page and is only fixable with another release.
- **The built wheel does not work.** It is installed into a clean environment,
away from the source tree, and made to generate and solve an instance. An
editable install hides a module missing from the wheel; this does not.

## Versioning

[Semantic versioning](https://semver.org). In practice:

- **patch** — fixes that change no API and no plan output.
- **minor** — new planners, heuristics, generators, CLI commands, or PDDL
requirement support. Everything that worked still works.
- **major** — a removal or a change in behaviour that existing code would
notice. The 1.0.0 rewrite that removed Julia is the model.

Note that changing what a heuristic *returns* can change which plan comes back
even when the API is untouched. Plans are not part of the compatibility
promise; costs and validity are.

## History

PyPI carried `0.4.1` — the original Julia-backed wrapper — for a long time
after the pure-Python rewrite landed here, because there was no release
automation and nobody published by hand. `pip install jupyddl` therefore gave
people a different library than this repository documented. That is what this
workflow exists to prevent.
2 changes: 1 addition & 1 deletion jupyddl/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
TraceRecorder,
)

__version__ = "2.2.0"
__version__ = "2.3.0"

__all__ = [
"solve",
Expand Down
Loading
Loading