Releasing peclet — the workflow¶
The repeatable procedure for a family release of the suite: every PyPI package (CPU and CUDA), the HPC containers, the Snellius and LUMI site packages, the GitHub Releases + Zenodo DOIs, the documentation sites, and the example gallery. It is written so that the next release can be executed by following it top to bottom. The one-off state for the release currently being prepared lives in RELEASE_PREP.md; this file is the durable process.
Releases so far: 0.1.0 / 0.2.0 (2026-07-02), 0.3.0 … 0.6.0 (2026-07-03 … 07-25), 1.0.0 (2026-09-12) … 1.4.0 (2026-10-04). The 1.4.0 family is what PyPI serves today (see §4 for the version table). Everything below was reconstructed from those releases, the workflows in each repo, and the traps they hit.
0. What a release consists of¶
| Artifact | Where | Produced by | Status of the mechanism |
|---|---|---|---|
CPU wheels + sdists: peclet-core (sdist only), peclet-amr (sdist only), peclet-morton, peclet-flow, peclet-pnm, peclet-dem, peclet-voro, peclet-coupling (sdist only) |
PyPI | each submodule's .github/workflows/release.yml on a v* tag (cibuildwheel, manylinux x86-64 + aarch64, win_amd64, macOS arm64, cp310–cp314, PECLET_VENDOR_DEPS=ON; Trusted Publishing) |
works (used for every release) |
peclet metapackage (== pins on the CPU family; extras [mpi], [cfd-dem]) |
PyPI | umbrella .github/workflows/release.yml on a v* tag |
works |
CUDA wheels peclet-flow-cu13, peclet-pnm-cu13, peclet-dem-cu13, peclet-voro-cu13 |
PyPI | cuda-wheel job in the same release.yml (manylinux container + CUDA 13 toolkit, Kokkos-CUDA static, nvidia-cuda-runtime dependency wheel, rpath; one job per interpreter, cp310–cp314 — the same list as the CPU wheels, RELEASE_PREP 1.0–1.1 §11.6) |
works; all four published at 1.0.1 |
Containers peclet-cpu, peclet-cuda:*-sm80/-sm90, peclet-hip:*-gfx90a |
GHCR (oras://ghcr.io/computational-chemical-engineering/...) |
umbrella .github/workflows/containers.yml on a v* tag or dispatch |
cpu + cuda publish; hip builds since 2026-09-04 (failed on every tag 0.3.0–0.6.0, §8) |
| Snellius package | /projects/0/prjs1022/peclet (install script + wheelhouse) and the GHCR CUDA image |
tools/hpc/install_snellius.sh run from the release tag (§7) |
script validated for flow; family-wide script is new |
| LUMI package | HIP container + tools/hpc/install_lumi.sh |
§8 | image builds (since 2026-09-04); untested on AMD hardware |
| GitHub Releases (one per repo + umbrella) → Zenodo version DOIs | GitHub / Zenodo | gh release create (Zenodo webhook mints the DOI) |
works |
| Docs: umbrella MkDocs site, per-repo Doxygen, Python API pages | GitHub Pages | site.yml (umbrella, on push to main touching docs/), docs.yml per repo; tools/gen_python_api.py generates docs/python/*.md at build time, inside the released peclet-cpu image |
works; containers.yml re-runs the site after a release (§5.1); the site pages carry no version literals (§5.2) |
| Example gallery | peclet-examples Pages |
authors re-execute and commit _freeze/; CI renders only |
works; re-execution is manual and slow (§10) |
| CHANGELOG.md | umbrella | by hand | stale since 0.2.0 |
Dependency order matters because wheels vendor sibling headers at a pinned tag
(cmake/PecletDeps.cmake: PECLET_TPX_TAG = core tag, PECLET_MORTON_TAG), and the metapackage
pins with ==:
core, morton → flow, pnm, dem, voro → coupling → peclet (metapackage) → containers, sites, gallery
1. Choosing the moment (freeze criteria)¶
Do not start the workflow until all of the following hold. They are cheap to check and each one has cost a day when skipped.
- No unmerged worktrees. Every
flow-*,tel/*and other worktree branch is either merged to the submodule'smainor explicitly parked (its work is not in the release). List them withgit -C <sub> worktree listandgit -C <sub> branch --no-merged main. - Every checkout is at
origin/main.tools/release/check_release_state.shreportsbehind/aheadper submodule; all must be 0. (Theflow/checkout was 55 commits behindorigin/mainon 2026-09-04 because the VoF campaign landed through worktrees — a release cut from that checkout would have missed the whole campaign.) - Umbrella pointers == submodule HEADs and the umbrella
git statusis clean apart from the known scratch directories. - CI green on
mainof every repo (gh run list -R computational-chemical-engineering/<repo> -b main -L 3). - The local full test matrix (§3) passes on host and CUDA, including the MPI suites.
- No open "silently wrong" issues in
docs/SCALING_ISSUES.mdorpeclet-examples/ISSUES.mdthat a user would hit through the public Python API in a default configuration. Known limitations are fine if they are listed in the release notes.
A convenient moment is right after a campaign has landed and its gallery pages are frozen — the tests are fresh in memory and the release notes write themselves from the campaign summaries.
2. Phase A — freeze and tidy¶
cd ~/Codes/suite
tools/release/check_release_state.sh # versions, tags, pins, PyPI, worktrees, behind/ahead
- A1. Merge or park every worktree branch (§1.1). Remove finished worktrees:
git -C flow worktree remove ../flow-<name>(after the branch is merged and pushed). - A2. Fast-forward every checkout:
git -C <sub> pull --ff-onlyfor each ofcore morton flow pnm dem voro coupling, thengit submodule statusmust show no+. - A3. Scratch hygiene. The umbrella working tree carries untracked campaign directories
(
flow-*worktrees;sphere-cfd-validation/andproposal/moved to~/Codes/on 2026-09-08, and on 2026-09-22.sdf-campaign-probes/intoflow/tests/study/sdf_campaign/and the hash-named paper repo to~/Codes/paper-robust-scaled-ibm-solver, leaving the worktrees as the only untracked scratch). They are not committed and do not affect the release, but the sdist of a submodule built while the repo is a submodule (.gitis a file) tree-walks and pulls inbuild//.venv. Never build a release sdist locally from the submodule checkout — CI builds from a real clone. If you must build locally, clone the repo fresh. - A4. Stale test/scratch files inside submodules (
voro/test_polymesh_sphere.vtu,voro/python/__pycache__, dem root-levelverify_*.pylogs): delete or.gitignore; nevergit add -A(stage named paths only). - A5. Confirm CI green everywhere (§1.4). Dependabot action-bump PRs need
gh auth refresh -s workflowto merge; merge them before the freeze, not during.
3. Phase B — fully functioning codes (the test matrix)¶
Run on the workstation with OMP_NUM_THREADS=8 OMP_PROC_BIND=false (the CUDA prefix has an OpenMP
host backend; an unbounded pool on 48 cores is an hour-long trap) and with
PATH=/usr/local/cuda-13.2/bin:$PATH. Build trees named build_rel_* so they cannot be confused
with campaign trees; delete them first — FindPython artefact variables are sticky and a tree
configured against a retired venv fails with "Cannot run the interpreter".
| Project | Suite | Command (host prefix extern/install/host-openmp; repeat with nvidia-cuda) |
Expect |
|---|---|---|---|
| core | serial + MPI halo, migration, load balancing | cmake -S core -B core/build_rel -DCMAKE_PREFIX_PATH=$P && cmake --build core/build_rel -j && ctest --test-dir core/build_rel --output-on-failure |
all pass, np 1–8 |
| core | Kokkos device halo | cmake -S core -B core/build_rel_k -DPECLET_CORE_ENABLE_KOKKOS=ON ... per core/CLAUDE.md |
all pass |
| amr | octree, AMR NS, MPI (np 1–8) | cmake -S amr -B amr/build_rel -DCMAKE_PREFIX_PATH=$P && ... ctest per amr/CLAUDE.md |
all pass |
| morton | doctest + Kokkos + pytest | ctest --test-dir morton/build_rel; python -m pytest morton/bindings/python/tests -q |
all pass; non-BMI2 binary PDEP/PEXT-free |
| flow | kernel unit tests | cmake -S flow/tests/kokkos -B flow/build_rel_kokkos -DCMAKE_PREFIX_PATH=$P && ... ctest |
all pass |
| flow | distributed (PECLET_FLOW_MPI) |
cmake -S flow/tests/kokkos_mpi -B flow/build_rel_kmpi -DMPIEXEC_EXECUTABLE=/usr/bin/mpirun ... |
np 1,2,4 all pass, np=1 bit-exact |
| flow | accuracy/efficiency regression | PYTHONPATH=flow/build_rel python flow/tests/regression/sdflow_regression.py |
within baseline; do NOT --update to make it pass |
| flow | analytic verifications | flow/scripts/verify_poiseuille_flow.py, verify_periodic_spheres_sdflow.py, verify_lid_cavity_sdflow.py, verify_channel_sdflow.py, verify_bfs_sdflow.py |
each prints PASS |
| pnm | extraction + MPI | pnm/scripts/test_extraction.py ../flow/data/packing_ring.vti; tests/kokkos_mpi np 1,2,4 |
7199 pores; bit-exact |
| dem | kernel + MPI | dem/tests/kokkos (8), dem/tests/kokkos_mpi (24, host + CUDA); python dem/verify_packing_spheres.py |
all pass |
| voro | Kokkos + MPI | cmake -B voro/build_rel -DPECLET_VORO_KOKKOS=ON -DPECLET_VORO_BUILD_PYTHON=ON [-DPECLET_VORO_MPI=ON]; OMP_PROC_BIND=false ctest |
all pass (a plain build registers zero tests — not a pass) |
| coupling | Python integration tests | cmake -S coupling -B coupling/build_rel -DPECLET_COUPLING_TESTS=ON -DPECLET_FLOW_BUILD=<flow tree> -DPECLET_DEM_BUILD=<dem tree> ...; ctest — both _BUILD variables are required, see below |
all pass |
| whole family | import smoke | PYTHONPATH=<all build dirs> python -c "import peclet.flow, peclet.dem, peclet.voro, peclet.pnm, peclet.core.mpi, peclet.morton, peclet.coupling" |
every execution_space reports the intended backend |
Record the counts in RELEASE_PREP.md (they become the "tested" line of the release notes).
Anything red is a release blocker unless it is documented as a known limitation.
Free MPI ctests in CI: only the OpenMP single-rank builds run on GitHub (no GPU, no MPI runners). The MPI and CUDA columns of this matrix are a local obligation — the release checklist is the only place they are enforced.
coupling's two _BUILD variables are load-bearing, and omitting them fails SILENTLY.
coupling/tests/CMakeLists.txt builds the ctest PYTHONPATH as
"${CMAKE_BINARY_DIR}:${PECLET_FLOW_BUILD}:${PECLET_DEM_BUILD}". Leave them unset and those two
entries are empty, so the test subprocess falls through to whatever peclet-flow / peclet-dem are
installed in the venv — which until 2026-09-16 were 0.4.0 wheels from July (now uninstalled, see
the note below; today the same omission raises ModuleNotFoundError instead). Measured 2026-09-12:
the bare configure line makes 2 of 3 coupling tests abort with TypeError: initialize_shape(): ...
shape_type: int and a matching set_body_force() error, i.e. the July signatures, not the current
ones. That is the lucky outcome. The dangerous one is the same fall-through when the old signature
still happens to match: the tests pass, against a module nobody intended.
This is the same failure class as RELEASE_PREP 1.0–1.1 §1.3's SDFLOW_BUILD trap, and it is worth stating
the general rule: anything in this checklist that imports peclet.* must set PYTHONPATH to the
build trees explicitly, or it is not testing what you think it is. That includes
tools/gen_python_api.py and tools/release/audit_docstrings.py.
The silent half of this trap was removed on 2026-09-16.
.venvused to carry stale installed peclet wheels (peclet0.6.0,peclet-flow0.4.0,peclet-dem0.4.0,peclet-voro0.4.0,peclet-pnm0.1.0 — July builds), so a forgottenPYTHONPATHfell through to those instead of failing. They are uninstalled and site-packages carries nopecletresidue, so a bareimport peclet.flownow raisesModuleNotFoundError— loud, and immediately diagnosable — instead of quietly testing code from July. Do not reinstall them into.venv; develop against build trees, and use a throwaway venv to test a published wheel.Two consequences worth knowing. A build-tree import reports
__version__ == "0+unknown", because the version comes fromimportlib.metadataand a build tree has no dist-info — that string is now the signal that you are correctly on a build tree, not a defect. And the family import smoke at the foot of the matrix can no longer report a plausible-looking wrong version: its version column was never trustworthy while those wheels were installed, which is why the criterion isexecution_space, not the version.
4. Phase C — versions¶
4.1 Decide the numbers¶
Semantic versioning per package; the family version is the metapackage's. Rules used so far:
- major bump when a package removes or renames public Python API. The first one is the family's
1.0.0 (QUALITY_PLAN.md D9): every package ships 1.0.0 together — except
peclet-amr, which ships 0.1.0 and stays 0.x while under development (D9's recorded exception) — the umbrella CHANGELOG lists every removed name with its replacement, and from then on the NAMING.md alias ladder is binding, so a further break costs a major. - minor bump when a package gains public Python API or a numerical method (VoF, moving geometry,
a new drag law). (Before 1.0.0 a removal was also a minor — flow 0.4.0 removed
peclet.flow.pnm.) - patch bump for fixes with no API change.
- A package with zero commits since its tag keeps its version (morton and pnm at 0.6.0 time);
it is not re-released, and its
==pin in the metapackage stays. - The metapackage always bumps (its pins changed).
4.2 Where versions live (bump every one, in this order)¶
| File | Field | Notes |
|---|---|---|
<sub>/pyproject.toml |
version |
the single source (QUALITY_PLAN D4): CMake's project(VERSION) reads it at configure time, so no CMake literal to bump; tag must equal v<version> |
<sub>/packaging/*_init.py (__version__) and morton/bindings/python/peclet/morton/__init__.py |
__version__ |
must match pyproject (0.2.0 shipped with a stale core __version__) |
flow/packaging/pyproject-cuda.toml (and each future *-cu13) |
version |
same number as the CPU package |
<sub>/Doxyfile |
PROJECT_NUMBER |
Doxygen sites show it |
<sub>/CITATION.cff, umbrella CITATION.cff |
version, date-released |
Zenodo reads these |
<consumer>/cmake/PecletDeps.cmake |
PECLET_TPX_TAG, PECLET_MORTON_TAG |
flow, pnm, dem, voro, coupling: pin to the new core/morton tags — voro shipped 0.4.0 pinned to a stale core tag |
umbrella pyproject.toml |
version, every == pin, extras |
publish the members first — the pins must resolve on PyPI |
docs/containers.md, containers/README.md, containers/submit/*.slurm |
image tags in the apptainer pull lines |
today they still say 0.1.0 |
CHANGELOG.md |
new section | Keep-a-Changelog format; one section per family version |
tools/release/check_release_state.sh prints all of these next to each other so a mismatch is
visible before tagging.
5. Phase D — documentation and the Python API¶
5.1 Docstrings and generated API pages¶
- Build the whole family from MPI-enabled builds (host prefix is enough) and put every build tree
on
PYTHONPATH. Fix docstring findings in the binding TUs (<sub>/src/*_bindings.cpp,core/python/*_bindings.cpp);tools/release/audit_docstrings.py --strictlists bound callables without a docstring (a failure) and docstrings that still saysdflow/vorflow/tpx/peclet.flow.pnm.check_release_state.shruns it: a missing docstring blocks the release. - The Python API pages (
docs/python/<pkg>.md) are not committed and not regenerated by hand. The Site workflow generates them at build time withtools/gen_python_api.py, run inside the releasedpeclet-cpuimage; after the tag,containers.ymldispatches the Site workflow with the pinned version once that image is pushed (strict: everyPAGESmodule must import, from an MPI build). So the pages describe the released API, and the docstrings fixed in step 1 reach the site with the release. What the pre-flight still checks is coverage:check_api_pages.pyfails when a package pinned by the metapackage (pyproject.toml) lacks aPAGESentry intools/gen_python_api.pyor amkdocs.ymlnav entry.PAGEScovers every shipped package (peclet.flow/pnm/dem/voro/coupling/amr/morton,peclet.halo+peclet.geomoncore.md;peclet-coreis the deprecated shell and is covered bypeclet.halo). A new package must also be installed incontainers/cpu.def, or its page is a "not importable" stub. mkdocs build --strictmust pass (the Site workflow runs exactly that). Locally, generate the pages first withtools/gen_api_local.sh(Apptainer; pullspeclet-cpu:latest), thenmkdocs serve.
5.2 Prose¶
Update, in this order, and read each one end to end (they drift in small factual ways):
- umbrella
README.md,docs/index.md,docs/DEPLOYMENT.md,docs/containers.md,docs/PORTABILITY.md,docs/SNELLIUS.md,docs/LUMI.md,containers/README.md; - each submodule
README.md(badges, install line, feature list) andCLAUDE.mdbuild/test sections; CHANGELOG.md— write the family section from the per-packagegit log <old-tag>..HEAD --onelineand the campaign summaries indocs/*_PLAN.md;CLAUDE.mdlinks.- The landing pages are PyPI metadata, and PyPI metadata is IMMUTABLE. There is no "edit
README" on pypi.org: the long description is fixed at upload time. A stale README noticed after a
tag can only be corrected by publishing another version, which is why
peclet 1.1.1exists ("metapackage only, to refresh a page no edit could reach") — and 1.2.0 still shipped three pages (peclet,peclet-cu13,peclet-halo) describing the very layout that release replaced.
tools/release/check_landing_pages.py is the gate, and it runs in two places so it cannot be
skipped: check_release_state.sh runs it over the whole suite in the pre-flight, and the
umbrella's release.yml runs it as a landing-pages job that publish needs — the last
moment the mistake is still cheap.
The CI job passes --dists peclet,peclet-cu13: only the pages that upload CREATES. A member's
PyPI description is created by that member's own release, and CI checks submodules out at the
umbrella's recorded pointers, which legitimately lag a member's main whenever that member is
deliberately not being re-released. Without the scope, a metapackage refresh is blocked by a page
it does not publish — which is exactly what happened on 1.2.1's first attempt: the job failed on
dem/voro/pnm/coupling READMEs that were already fixed on their mains. Nothing was uploaded (the
gate ran before publish, so the version was not burned) and the tag was re-cut after scoping.
Every member repo now has the same job (landing-pages, which its publish needs), added
2026-09-21. A member does not vendor the checker: its job sparse-checks /docs/NAMING.md and
/tools/release/check_landing_pages.py out of the umbrella's main and runs
python .peclet-umbrella/tools/release/check_landing_pages.py \
--naming .peclet-umbrella/docs/NAMING.md \
--page README.md --dists peclet-flow,peclet-flow-cu13
so nine repos cannot drift apart on what counts as a retired spelling — the canon has one home.
--dists is per repo and lists every distribution that README is the description of, which is
how the *-cu13 pages get covered: flow/README.md is the PyPI page of both peclet-flow and
peclet-flow-cu13, and it used to name neither.
It needs no list of its own. docs/NAMING.md §2 is the canon; the tool parses every
| former | canonical | status | row whose status is removed/aliased/planned and greps the
landing pages for the former spellings. So every rename is covered the day it is recorded in
NAMING.md, which is one more reason to record it there. It also checks that a package's page
names the distribution it actually ships (core's README badged peclet-core while publishing
peclet-halo).
Naming an old spelling is not itself a failure — good migration prose should ("was
peclet.core.geom until 1.2.0"). The rule is that the canonical replacement must appear within
three lines. For a deliberate mention with no replacement to name — a negative statement like
"the module has no mpi_rank()" — mark it <!-- landing-ok: why -->. Repository URLs, badge
URLs, Zenodo DOIs and find_package(...) are exempt: the peclet-core repository did not move,
only the distribution did.
-
No version literals on the site pages (
docs/index.md,DEPLOYMENT.md,containers.md,python/index.md, umbrellaREADME.md): the PyPI badge, the CHANGELOG and the moving container tags (latest/sm80/sm90/gfx90a) carry the current version, andCITATION.cffkeeps only the concept DOI — so nothing on the site goes stale between releases (the "This release (v0.2.0)" line survived five releases before this rule).tools/release/check_release_state.shgreps for it. -
The quick start must RUN against the published wheels, not merely parse — and CI owns this.
.github/workflows/quickstart.ymlexecutesREADME.md,docs/index.mdanddocs/notebooks/quickstart_sphere.ipynbagainst the wheels on PyPI, and matches every python block underdocs/and every submoduleREADME.mdagainst the installed surface. It runs on a docs push or PR, weekly (the docs also break when the API moves under them, with no docs commit to trigger on), and — as aquickstartjob ofrelease.yml,needs: publish— on the wheels of every new tag, retrying while PyPI propagates. A release is not done until that job is green.
Locally it is the same tool, tools/release/check_docs_snippets.py:
python tools/release/check_docs_snippets.py # names at v1.0.0, from the submodules
python tools/release/check_docs_snippets.py --installed --run # exactly what CI runs, on the wheels
Before a tag exists the --ref form is the only one available (--installed can only ask for
what is already published), so run it against the submodule HEADs while preparing the release and
let the tag-triggered job confirm the real artifact.
Why it exists: 1.0.0 shipped a landing page whose first 20 lines raised TypeError. The clean
break had renamed cell_centres() to cell_centers() and repacked set_body_force(fx, fy, fz)
into one 3-sequence; audit_examples.py covers only the sibling gallery, so nothing looked at the
suite's own docs, and nothing at all executed them. Note which check catches which: the rename is
a NAMES failure, but the repacked signature renamed nothing and is invisible to any static audit —
only running it finds that class.
The audit list for the current release is in RELEASE_PREP.md.
5.3 CUDA wheel coverage (decision point)¶
The CUDA family is COMPLETE (re-verified 2026-09-12; this paragraph used to say only
peclet-flow-cu13 existed and list the rest as future work). flow, pnm, dem and voro each
carry a cuda-wheel job and a packaging/pyproject-cuda.toml, and all four are live on PyPI —
peclet-flow-cu13 0.5.1, peclet-pnm-cu13 0.1.2, peclet-dem-cu13 0.5.1, peclet-voro-cu13 0.5.1
— beside the peclet-cu13 metapackage at 0.7.2 (umbrella packaging/pyproject-cu13.toml, published
by a second job of the umbrella release workflow). The operational consequence: there are FOUR
CUDA version literals to bump, not one, and each must match its CPU package's number. dem
additionally builds ArborX into the prefix, and each module's rpath is
$ORIGIN/../../nvidia/cu13/lib, one level per package depth — pnm's was wrong once.
Per-package prerequisites: the module must survive interpreter teardown after a solve (dem's
atexit-finalize pattern; flow had a teardown abort), and the PyPI Trusted Publisher must be
registered for the new project name before tagging (a new name publishes fine with no extra
registration — pnm 0.1.0 proved it — but only if the workflow's environment: pypi is configured).
Validate locally before CI: PATH=/usr/local/cuda-13.2/bin:$PATH CMAKE_PREFIX_PATH=$PWD/extern/install/nvidia-cuda pip wheel ./dem
into a fresh venv, then run a verify script on the GPU.
6. Phase E — publish to PyPI¶
Per repo, in dependency order (§0). For each:
cd <sub>
# 1. version bumps (§4.2) committed on main, pushed
git push origin main
# 2. tag == pyproject version
git tag v<X.Y.Z> && git push origin v<X.Y.Z>
# 3. watch the Release workflow (sdist + wheels [+ cuda-wheel] + publish)
gh run watch -R computational-chemical-engineering/peclet-<sub> $(gh run list -R computational-chemical-engineering/peclet-<sub> -w Release -L 1 --json databaseId -q '.[0].databaseId')
# 4. confirm on PyPI (the JSON endpoint caches for minutes — use pip)
pip index versions peclet-<sub>
Then the umbrella: bump pyproject.toml pins + version, bump the submodule pointers
(git add core flow ... by name), commit, push, tag v<family>, watch Release and
Containers, then §9.
Traps recorded from 0.1.0–0.6.0:
- cibuildwheel 4.x dropped cp38: keep
requires-python >= 3.10and the build selector in sync. - macOS Intel runners queue for 40+ minutes and gate publish — morton dropped
macos-13; do not add it back. skip-existing: truein the flow publish step lets a partial upload be completed (e.g. adding the cu13 wheels after the CPU files); leave it.- A publish failure whose traceback ends in
sigstore/_internal/rekor/client.pyis TRANSIENT INFRASTRUCTURE, not your release.pypa/gh-action-pypi-publishsigns an attestation against Sigstore's Rekor transparency log before uploading anything, so aConnectionError: Connection reset by peerthere means nothing reached PyPI. Hit 2026-09-12 on the umbrella's own publish job with both build jobs already green.gh run rerun <id> --failedfixed it on the first retry. Do NOT respond by editing versions or cutting a new tag —skip-existing: truemakes the retry safe even if part of an upload had landed. - A
pypi/pyversionsbadge renders "missing" without trove classifiers; the static python badge is deliberate. - CUDA wheels must carry native SASS for every GPU major you support (sm_75/80/90/100/120 today) —
PTX does NOT carry a wheel forward: a driver older than the toolkit that emitted the PTX cannot JIT it,
and under minor-version compatibility the launch silently no-ops, so Kokkos aborts at import with
"likely mismatch of architecture" (0.4.0–0.5.0 shipped sm_75 + CUDA-13.2 PTX only and failed on every
non-Turing GPU with a 13.0/13.1 driver). The extra SASS goes in through
NVCC_APPEND_FLAGS(nvcc reads it itself; nvcc_wrapper rejects a second-arch/-gencode), and the job pins the OLDEST 13.x toolkit so the PTX JITs on any 13.x driver. Check a wheel withcuobjdump --list-elf <module>.so. - A gallery page with a stale freeze does not fail loudly. Its bootstrap pip-installs peclet and re-executes on CPU wheels, so a §9 re-check can quietly produce CPU-rendered numbers instead of a clean red — the failure mode is a slow silent re-execution, not an error. Reported from the peclet-examples side 2026-09-14, where a stray untracked draft installed peclet into a deliberately peclet-free gate venv.
- Smoke test in a fresh venv after the metapackage is live:
python -m venv /tmp/rel && /tmp/rel/bin/pip install peclet==<family> && /tmp/rel/bin/python -c "import peclet.flow, peclet.dem, peclet.voro, peclet.pnm, peclet.morton", then run one gallery example (poiseuille-ibm) against it; repeat withpeclet-cu13on the GPU box ANDpeclet[mpi,cfd-dem](the sdist members: core, coupling — the core sdist was silently unbuildable 0.1.0–0.6.0).
7. Phase F — Snellius package¶
What "a release for Snellius" means, in decreasing order of user convenience:
- The GHCR CUDA images
peclet-cuda:<family>-sm80(A100) and-sm90(H100), pulled withapptainer pull oras://...and launched bycontainers/snellius-run.sh/containers/submit/snellius.slurm. Multi-node runs need the host OpenMPI/UCX bind (the image bundles OpenMPI 4.1.6; match the site module). - A site-built install in the project space, from the release tag, with the validated 2024a
toolchain (
docs/SNELLIUS.md):tools/hpc/install_snellius.sh <tag> h100|a100|cpu. It clones the umbrella at the tag, bootstraps Kokkos for the backend, and pip-installs the whole family (PECLET_*_MPI=ON; distributionspeclet-morton peclet-geom peclet-halo peclet-core peclet-flow peclet-pnm peclet-dem peclet-voro peclet-amr peclet-coupling) into$SUITE/.venv, leaving a wheelhouse at/projects/0/prjs1022/peclet/wheelhouse/<tag>-<backend>/so project members canpip install --no-index --find-links <wheelhouse> peclet-flowinto their own venv without rebuilding. Wheels built on Snellius are site-specific (linked against the module OpenMPI, CUDA 12.6, sm_80/sm_90) and must never be uploaded to PyPI. - Source build by the user following
docs/SNELLIUS.md(the fallback; unchanged).
Release steps (billed: one build job per backend ≈ 1 GPU × ≤2 h; queue the CPU build on genoa):
ssh snellius
cd /projects/0/prjs1022/peclet && git clone --branch v<family> --recurse-submodules https://github.com/computational-chemical-engineering/peclet.git suite-v<family>
cd suite-v<family> # SUBMIT FROM INSIDE THE TREE -- see the two traps below
sbatch --nodes=1 --gpus-per-node=1 --ntasks-per-node=1 tools/hpc/install_snellius.sh v<family> h100
sbatch -p gpu_a100 --nodes=1 --gpus-per-node=1 --ntasks-per-node=1 tools/hpc/install_snellius.sh v<family> a100
sbatch -p genoa --gpus-per-node=0 --ntasks=1 --cpus-per-task=32 tools/hpc/install_snellius.sh v<family> cpu
Two traps, both hit on this script's first real run (2026-09-16, the 1.1.0 release), both now fixed in the script — the recipe above is the corrected one.
- Submit from inside the tree. sbatch copies the script to
/var/spool/slurm/..., so$BASH_SOURCEis not in the repo and the$SLURM_SUBMIT_DIRfallback is what resolvessnellius_env.sh. Submitting from the parent (which this file used to say) resolved to$PROJ/tools/hpcand died with a bareNo such file or directory. The script now tries three candidates and, failing all of them, prints what it tried and the fix. - Each backend gets its own tree (
suite-<tag>-<backend>).h100anda100both build thenvidia-cudaprefix but at different arch, and the script doesvenv --clearon$SUITE/.venvandrm -rf extern/install/$BACKEND— so two backends in one tree destroy each other whether they run together or in sequence. Submitted together against one tree, two of three died on a half-built venv (Permission denied: .../.venv/bin/activate.csh) and the survivor would have left a venv matching only itself. With per-backend trees the three run concurrently. --gpus=0does not override the script's#SBATCH --gpus-per-node=1on a GPU-less partition; use--gpus-per-node=0or sbatch rejects the job with "Requested node configuration is not available".
Then the smoke job tools/hpc/smoke_snellius.slurm (4 ranks on one node: has_mpi: True,
execution_space: Cuda, flow init_mpi bit-exact k vs np=1, dem step_mpi) and record the job
ids + numbers in docs/SNELLIUS.md "Validated releases". Give the release its own remote tree
(suite-v<family>), never the shared campaign checkout — concurrent sessions have rebuilt the
shared build_cuda_mpi on top of each other's uncommitted sources.
Snellius rules that apply here: positional sbatch arguments (leading VAR=x sbatch is silently
dropped), --gpus-per-node=1 for a 1-GPU job (billing is per allocated GPU), -DPython_EXECUTABLE
with a capital P, -DMPIEXEC_EXECUTABLE=$(which mpirun), FRESH=1 when the CUDA module changed.
8. Phase G — LUMI package (untested)¶
State: no peclet-hip image has ever been published. The hip-gfx90a container job fails at
link time in every release run since 0.3.0 with
ld.lld: error: undefined hidden symbol: vtable for Kokkos::Impl::SharedAllocationRecord<Kokkos::HIPSpace, ...>
ld.lld: error: undefined hidden symbol: vtable for std::_Sp_counted_ptr_inplace<peclet::core::halo::GridHaloTopology<3>, ...>
-Wl,--no-gc-sections (present in flow/pnm/voro/coupling CMake since 0.2.0) did not fix it, so
the diagnosis "gc-sections drops the vtables" is refuted. The references are emitted with hidden
visibility because nanobind sets CXX_VISIBILITY_PRESET hidden on the module target
(nanobind_set_visibility), and under hipcc/lld the vtables of these header-instantiated classes
are not emitted in the same object. The same link will fail for a source build on LUMI, so this
blocks every LUMI delivery, container or not.
Resolved 2026-09-04: experiment 1 (run 33868865327) made flow, dem, voro link; core's amr and
voro's wrappers then failed for virtual classes in anonymous namespaces (named namespaces fixed it);
run 33874788583 built the full image. The rule for new binding code: virtual wrapper classes go in a
named namespace. The list below is kept for the record.
Experiments, in order of plausibility (each is one workflow_dispatch of Containers with
only=hip, push=false, ≈25 min; no GPU is needed to link):
- After
nanobind_add_module(...)in flow/pnm/dem/voro/couplingCMakeLists.txt, gated onKokkos_ENABLE_HIP:set_target_properties(<target> PROPERTIES CXX_VISIBILITY_PRESET default). - Bootstrap Kokkos for
lumi-hipas a shared library (-DBUILD_SHARED_LIBS=ONintools/bootstrap_deps.sh), so the Kokkos vtables come fromlibkokkoscore.so. - Relocatable device code:
-DKokkos_ENABLE_HIP_RELOCATABLE_DEVICE_CODE=ON+-fgpu-rdcon the modules. - Drop
-fvisibility-inlines-hiddenif the ROCm clang adds it; checkhipcc --verbose.
Until one passes, the LUMI deliverable of a release is:
docs/LUMI.md— the site recipe (Cray PE modules,hipcc,craype-accel-amd-gfx90a, ROCm ≤ host driver, Cray-MPICH ABI bind model) with a banner "untested — no LUMI allocation yet";tools/hpc/install_lumi.sh— the source-install script mirroring the Snellius one (untested);containers/hip.def+lumi-run.sh+submit/lumi.slurmas they are, with the known-failure note indocs/containers.md.
The release notes must say this in one sentence. When LUMI time is granted: run experiment 1 on a LUMI login node first (link only, seconds), then the smoke job, then re-dispatch the container job.
9. Phase H — containers, GitHub Releases, Zenodo, sites¶
- Containers: the umbrella tag triggers
containers.yml(cpu, cuda-sm80, cuda-sm90 ≈ 20 min each; hip fails — expected until §8 is fixed, and it does not block the others because the matrix isfail-fast: false). Verify:apptainer pull oras://ghcr.io/computational-chemical-engineering/peclet-cuda:<family>-sm90on Snellius and run the smoke job throughcontainers/snellius-run.sh. The images install flow, dem, voro, core, morton only — addpnmandcouplingtocpu.def/cuda.def/hip.def(§ RELEASE_PREP) so the container family matches PyPI. - GitHub Releases: one per repo at its new tag + the umbrella, body = the CHANGELOG section
(
gh release create v<X> -R computational-chemical-engineering/peclet-<sub> --title "peclet-<sub> <X>" --notes-file <notes>). Zenodo mints a version DOI per repo within minutes; the concept DOIs in the badges andCITATION.cffdo not change;CITATION.cffcarries only the concept DOI (a version DOI there goes stale — Zenodo and each GitHub release link the version's own). - Sites: the umbrella
Siteworkflow runs on the docs push; each repo'sdocs.ymlon its push. Check that the PyPI badge onhttps://computational-chemical-engineering.github.io/peclet/shows the new version (shields.io caches for a few minutes) and that the Python API pages carry the new methods: thepeclet-cpuleg ofcontainers.ymldispatches the Site workflow with the pinned image tag (§5.1; there is no version to edit on the site). - Memory/notes: update
docs/SNELLIUS.md"Validated releases",RELEASE_PREP.md→ archive the closed cycle's sections verbatim asdocs/archive/RELEASE_PREP_<cycles>.md(entry indocs/archive/README.md), and resetRELEASE_PREP.mdfor the next cycle.
10. Phase I — the example gallery (after the release, not blocking it)¶
The gallery pins nothing: every bootstrap cell does pip install peclet (latest) unless
PECLET_LOCAL_BUILD points at a build tree, and the site renders from committed _freeze/
outputs. So a release cannot break the published site, but it can make a page's Colab button
run a different solver than the frozen numbers came from. The obligations are therefore:
- Before tagging, produce the re-check list with
tools/release/audit_examples.py(or the table inRELEASE_PREP.md): for each page, the peclet symbols it calls and whether each exists in the tagged bindings. Pages calling symbols that only exist onmainwere rendered against a local build and become runnable on PyPI only with this release — they are the ones to re-execute first, because their frozen output was produced by a pre-release build. - After the metapackage is live: re-execute the list in priority order in a fresh venv
(
pip install peclet==<family>; for GPU pagespeclet-flow-cu13), page by page withrender_example.sh <slug>(clears the page's freeze) — note that editing even a subtitle invalidates a freeze, andquartoexits 0 on a figure-cell error, so grep_siteforcell-output-errorbefore committing. Commit each page's_freeze/(force-add npz assets; a movie does not go in the repository — see §10.1). - Pages that need a build the wheels do not provide (MPI, Snellius,
peclet.coupling,core.geom) stay frozen and say so in their bootstrap comment; they are re-checked when the corresponding site package (§7) is installed. - Close or update the
ISSUES.mdentries the release fixes; open new ones for what the re-run finds.
10.1 The films and the channel¶
Movies live on @PecletHPC, never in a git repository, and
~/Codes/youtube_publishing (ytpub) is the whole workflow: videos.yaml is the hand-written
registry, published.json the ledger of slug → video id that makes a second run an update rather
than a duplicate upload. Quota is 10 000 units a day — an upload is 1600, a metadata or privacy
change 50 — so plan a release's uploads, and do not re-run sync idly.
cd ~/Codes/youtube_publishing
./ytpub.sh doctor # token, ffmpeg, manifest, and which channel it points at
./ytpub.sh sync --dry-run # what would happen and what it costs
./ytpub.sh sync <slug> # upload or update one
./ytpub.sh embed --write <slug> # rewire the example page to the YouTube copy
./ytpub.sh channel --apply # description, keywords, banner, trailer
Per release:
- Cut the release film in
films/<version>/(tools/narrate.pythentools/assemble.py), and watch it end to end before it goes anywhere. The audio and the picture are cut from one pause constant infilms/voice.yaml; if they ever drift, the film ends mid-sentence and-shortestsilently truncates the close. - Publish it: an entry in
videos.yamlwithroot: repo,encode.passthrough: true(the film is already 1080p30 and carries its own narration) andpage_url:pointing at the documentation, thenytpub sync <slug>. New clips default toprivacy: unlisted— watch each one on the channel, then setprivacy: publicand re-runsync(50 units, no re-upload). - Make it the front door:
channel_trailer:invideos.yamlandytpub channel --apply. The avatar is the one thing with no API — YouTube Studio only. - Rewire the pages:
ytpub embed --write <slug>for any page still carrying a local<video>, thengit rm --cachedthe mp4.--freezecan mirror the swap into Quarto's freeze cache and re-stamp its hash, but only use it on a page whose frozen output is genuinely current — during a re-render pass (§10 step 2) the pages are re-executed anyway and a re-stamp would publish stale output as if it were fresh. - Retire the previous film to
unlisted. Set the previous release's entry toprivacy: unlistedandsyncit — do not delete the entry, and do not useprivate. The entry is what holds the video id, andsynconly pushes a privacy it can read from the manifest, so deleting it leaves the old film public with nothing left to notice. Why unlisted and not private: a published distribution carries a FROZEN copy of the README and PyPI will not let a version be re-uploaded, so every release artifact links to the film of its day immutably. Private turned the peclet 1.1.0 page into a 404 thumbnail and a sign-in wall on 2026-09-19 — the pagepip install pecletsends people to. Unlisted keeps those references alive while keeping the film off the channel and out of search. Reserveprivatefor something that should never have been published. Then movechannel_trailer:to the new slug andytpub channel --apply. - Interlink both ways: every video description carries its page, the repository and the docs
(
site.docsinvideos.yaml); the docs home page, the gallery landing page and the README carry the film and the channel. Swapping a film means swapping the id in all three — and in the gallery's landing page the embed MUST stay inside its``{=html} fence, or Pandoc wraps the iframe in aand Bootstrap's.ratio` renders it at 300x150.
Picture quality is films/QUALITY.md, and it is a design contract, not advice: the 2160p
master, perspective rather than zoompan for the push-in, one x264 encode per item, and the
BT.709 conversion. tools/quality_check.py film gates all four on every cut. The 1.0.0 film
shipped with a shimmering push-in and the channel teal displayed as (0,132,136); both were
invisible to every check that existed at the time.
11. CI on GitHub — what runs, what must be green, what is missing¶
| Repo | Workflows | Trigger | Runs |
|---|---|---|---|
| core | ci.yml, quality.yml, docs.yml, release.yml |
push/PR; tag | OpenMP build + ctests, serial and MPI via mpirun on the runner |
| amr | ci.yml, quality.yml, docs.yml, release.yml |
push/PR; tag | the eighth package (split out 2026-09-10); its ci.yml carries an explicit "Honest CI" header and is the best-documented in the suite |
| morton | ci.yml (gcc, clang, AVX-512 under SDE, MSVC, python, docs), quality.yml, docs.yml, release.yml |
push/PR; tag | the most complete matrix in the suite |
| flow | ci.yml (single-rank + mpi), quality.yml, docs.yml, release.yml (sdist, wheels, cuda-wheel, publish) |
push/PR; tag | single-rank AND distributed at np = 1, 2 (4 and 8 when the np=2 pass left time) |
| pnm | ci.yml, quality.yml, docs.yml, release.yml |
push/PR; tag | |
| dem | ci.yml, quality.yml, docs.yml, release.yml |
push/PR; tag | single-rank only |
| voro | ci.yml (kokkos-openmp, clang-format, documentation), quality.yml, docs.yml, release.yml |
push/PR; tag | its pinned-tag guard COMPILES the module since 8cdac25, which is what makes a stale PECLET_CORE_TAG fail here rather than during the release |
| coupling | ci.yml, quality.yml, release.yml |
push/PR; tag | builds flow + dem + coupling and runs the pytest battery |
| umbrella | site.yml, release.yml, containers.yml, quickstart.yml |
docs push; tag; dispatch; weekly | quickstart.yml executes the landing page and the Colab notebook against the PyPI wheels, and runs as a needs: publish job of release.yml on every tag (added 2026-09-12, after 1.0.0 shipped a quick start that raised TypeError) |
| peclet-examples | publish.yml |
push to main | Quarto render from freeze (no solver) |
(Table re-verified 2026-09-12 against .github/workflows/ in every repo. It previously predated
amr, said coupling had "no CI at all" and pnm "no quality.yml", and described flow's CI as
single-rank only — all four were false.)
Must be green before tagging: every ci.yml and quality.yml python-lint job on main. The
clang-format jobs are informational by design.
Gaps to close (tracked in RELEASE_PREP.md; none blocks a release, all reduce risk):
- Containers
pushgate — add apushbooleanworkflow_dispatchinput tocontainers.ymlso HIP experiments (§8) can build without publishing an image tagged with a wrong version. - A family "release rehearsal" workflow in the umbrella (
workflow_dispatch): checks out all submodules, builds the CPU wheels of every member with cibuildwheel into artifacts without publishing, installs them together in one venv and runs the import smoke + one verify script. This is the only automated place where the family is tested as a set before tagging. - Version consistency check as a CI step on tags (
tools/release/check_release_state.sh --ci): fail the release job if the tag ≠ pyproject version or__version__. - ~~The CUDA wheel job runs only on tags~~ — wrong, and expensively so. Not one of the four
cuda-wheel jobs (flow, pnm, dem, voro) carries an
if:; onlypublishis tag-gated. So the dry run this bullet asks for already works today:gh workflow run release.ymlon any of them builds the CUDA wheel and uploads it as an artifact, publishing nothing. Nobody knew, so nobody rehearsed, and on 2026-09-14 voro's cuda-wheel job died at GitHub's 6-hour cap during the 1.0.1 release — the one job a rehearsal would have exercised. Corrected after the fact; see RELEASE_PREP 1.0–1.1 §11.5. - No
release.ymlin any of the eight repos setstimeout-minutes, so every job inherits the 6-hour default. That is not the cause of voro's failure — the work genuinely takes ~5 h — but it means the cap arrives as a kill with no warning rather than as a stated budget. actions/upload-artifact/download-artifactmajors differ between repos (v4 vs v7/v8) — the Dependabot bumps must be merged consistently or the artifact hand-off between jobs breaks.
12. Execution advice¶
- Run it as a two-day block, not interleaved with development: day 1 = Phases A–D (tests, docs, versions) on the workstation; day 2 = Phases E–H (publish, containers, Snellius). Snellius builds queue for up to an hour — submit them first thing on day 2 and do the PyPI publishing while they run; they only need the tag to exist.
- One driver, one checklist. Copy the phase headers of this file into
RELEASE_PREP.mdas a checkbox list and tick them in the order given; parallel agents may prepare docs and tests, but the tag/publish steps are sequential by dependency and should be done by one session. - Tags are permanent. If a release workflow fails after the tag exists, fix and re-run the
workflow (
gh run rerun --failed); never delete and re-push a tag that reached PyPI. A broken published package gets a patch release. - Do not touch numerics during the release window. Version bumps, docs and packaging only; a numerical fix found in Phase B is either a blocker (fix, re-run the matrix from the top) or a known limitation (release notes).
- Expected wall time from tagging the first member to a live metapackage: about 90 minutes (the CPU wheel builds compile Kokkos per Python version; the CUDA job is the long pole at ~40 min).