Releasing aeolus1d¶
This page documents the maintainer release process — what to do to ship a new version to PyPI. Contributors don't need to read it.
Versioning¶
aeolus1d follows Semantic Versioning. Until
v1.0, breaking changes happen between minor versions and are called
out explicitly in docs/changelog.md.
The canonical version string lives in one place:
pyproject.toml→project.version
aeolus1d.__version__ (in src/aeolus1d/__init__.py) reads it back
via importlib.metadata.version("aeolus1d"). CITATION.cff carries a
mirrored version: field — bump it in lockstep.
The pause below was LIFTED (2026-08-09) — kept for its history
Resolved. release-pypi: and release-pypi-test: no longer carry
the leading - when: never; both are armed, tag-gated and
when: manual (.gitlab-ci.yml), and tests/unit/test_release_is_paused.py
— which guarded the paused state — is gone with it. They have since
published for real: 0.3.0rc1 (2026-08-10) and 0.3.0rc2
(2026-08-14), each over OIDC with no static token, verified against
the index rather than inferred from a green job (EC-9 / EC-10 in
release-readiness.md).
The block below is left standing rather than deleted because the reasoning it records — why a first publish is a one-way door for a name, and why the OIDC migration had to happen before anything was uploaded with a token — is still the reasoning that governs. Read it as history, not as current state.
PyPI release was PAUSED (2026-08-06) — by decision, not by obstacle
The original blocker is gone. The pause was introduced when
pyproject.toml declared name = "aeolus" — a name owned on PyPI by an
unrelated, actively-maintained project ("Analysis and visualisation of
atmospheric model output powered by iris", Dennis Sergeev, LGPL-3, 24
releases 2019-11 → 2024-11), so twine upload would have returned 403.
The distribution was renamed to aeolus1d on 2026-08-06, which we can
claim. The jobs stay disabled anyway, deliberately: nothing has been
published yet, a first release is a one-way door for a name, and the
validation gates listed below are not all green.
Both release-pypi: and release-pypi-test: carry an unconditional
when: never as their first rule. Order matters — GitLab stops at the
first matching rule, so a never after the tag rule would never be reached
on the pipeline that matters.
To re-arm: delete that leading - when: never from both jobs. The
existing rules and twine config sit untouched underneath, so re-arming is a
deletion rather than a rewrite.
tests/unit/test_release_is_paused.py guards the state.
Do the OIDC migration first. Trusted publishing is now unblocked —
a trusted publisher is configured against a project name, and aeolus1d is
ours. The pre-first-publish "pending publisher" flow means the project need
never hold a long-lived API token at all, and that window closes the
moment anything is uploaded with a token.
CI-driven release (the normal path)¶
These steps run today — the name question was settled by the
aeolus1drename (2026-08-06) and the jobs were re-armed 2026-08-09.0.3.0rc1,0.3.0rc2and0.3.0were all cut this way.
The release is tag-driven. Push a vX.Y.Z tag and the
release-pypi: job in .gitlab-ci.yml (Phase 3 of the open-source
maturity remediation) builds the wheel + sdist and uploads them to
PyPI.
# 1. pyproject leads the tag between releases and EQUALS it here.
sed -i 's/^version = ".*"/version = "0.3.0"/' pyproject.toml
# 2. FINAL releases only: move the three release-facing surfaces, which
# track the last FINAL release. An rcN leaves them alone — that is
# why rc1/rc2 never tripped the ordering trap in step 4.
sed -i 's/^version: ".*"/version: "0.3.0"/' CITATION.cff
sed -i 's/^date-released: ".*"/date-released: "YYYY-MM-DD"/' CITATION.cff
sed -i 's#badge/version-[0-9][^-]*-#badge/version-0.3.0-#' README.md
sed -i 's/Current supported version: \*\*v.*\*\*.*/Current supported version: **v0.3.0** (released YYYY-MM-DD)./' SECURITY.md
# 3. docs/changelog.md: rename `## Unreleased` to the release heading and
# update the "On PyPI" note. THEN the root CHANGELOG.md — its Releases
# list is enforced by test_version_agreement's
# test_root_changelog_names_current_release_line, and this step used to
# omit it (found cutting v0.3.3). The anchor it links must match the
# heading you just wrote, and changes again when "published" is appended.
# 4. TAG THE RELEASE COMMIT ITSELF, then open the MR.
git tag -a v0.3.0 -m "aeolus1d 0.3.0"
git push origin v0.3.0 # tag FIRST
git push -u origin release/v0.3.0
# …then open the MR and merge it.
# 5. Open the Pipelines page → find the tag pipeline →
# click ▶ on the `release-pypi` job to actually publish.
Why step 4 tags the branch commit and not the merge commit (rc1 and
rc2 tagged the merge commit; this changed for v0.3.0, measured
2026-08-16). tests/unit/test_version_agreement.py::test_release_facing_matches_latest_release_tag
asserts the three surfaces equal the latest final tag. That makes
the two obvious orderings both red:
- bump-then-merge-then-tag — main's post-merge pipeline sees surfaces at
0.3.0while the newest final tag is stillv0.2.0; red until the tag lands; - tag-then-bump — the tag exists while the surfaces still say
0.2.0, which is precisely the "tagged a release, forgot the bump" case the test was written to catch.
Tagging the release commit before pushing the branch closes the window:
every pipeline that ever runs — branch, MR, and main's after merge —
observes tag and surfaces agreeing. Pre-release tags are invisible to
that test (_latest_release_tag skips prereleases), which is why this
only bites final releases and why it stayed hidden until v0.3.0.
The release-pypi: job picks up the tag pipeline, builds, and
twine-checks — then waits for a manual click. The
when: manual gate is deliberate: it lets the maintainer
review the build artefacts and the build-smoke + wheel-install-
smoke pipeline status before anything leaves for PyPI. Drop
the when: manual from .gitlab-ci.yml once validation
coverage is mature enough to trust auto-on-tag behaviour
(see the validation readiness section below).
Total wall-clock from clicking ▶ to PyPI availability is typically 1–2 minutes.
Validation readiness gate¶
Current state, 2026-10-10:
v0.4.0is published (07:32 UTC, verified on both indexes). Its exit criteria, EC-Q1…EC-Q12 inrelease-readiness.md, are all met; EC-Q7 re-ran the EPW criteria on the release tree rather than inheriting them. The two amber W1 rows ship under thev0.4.0waiver below (EC-Q6). The box that follows is thev0.3.0position, kept as history.
v0.4.1(cut and published 2026-10-10, 19:46 UTC, verified on both indexes) is a patch on it with no change undersrc/: the PyPIDocumentationlink to the now-public docs site, and two GUI changes. No validation number can have moved, sov0.4.0's criteria and waiver stand for it unchanged;v0.5.0re-asks the question.
v0.4.2(cut and published 2026-10-13, 12:05 UTC, verified on both indexes) is a patch that DOES changesrc/, so it cannot inheritv0.4.0's criteria the wayv0.4.1did; they were re-measured on the tree instead. The 3-op EPW validation onmaind341a665(predictive, all three ops): 3 of 3 PASS, every channel within 5.3e-5 relative ofv0.4.1's tree. The nightly pipeline on the same tree (2940493607) green on every automated job,integration-slow(the validation suites),perf-budget,wallclock-budgetandregression-bitidincluded. The security fixes on top touch only the user-code switch, the XML reader and the GT-Suite path resolution; no bundled example uses any of them, and re-importing the local corpora moved 2 of 104 GT-Suite models (the two acoustics examples whose sub-assembly sits outside their folder, now refused with a warning) and 0 of 143 Cruise M models. One stale number ships documented, not fixed: the EPW NOx comparison (EC-4, "Stale again"), which was stale before these fixes and is report-only.v0.4.0's waiver stands for the two W1 rows. On the tag pipeline,perf-budgetfailed first (80.9 s against the 80 s ceiling, every series about 1.65x slow together, andwallclock-budgeton the same host 351 s against a 195-218 s band) and passed on a retry (67.3 s). Nothing in product code since the last green nightly reaches the case, so the host, not the code, moved.Position on 2026-08-14: every exit criterion is MET, and the two gates below are what stand between
0.3.0rc2and0.3.0final.aeolus1d 0.3.0rc2is on PyPI (tagv0.3.0-rc2, verified against the index). The per-criterion table with evidence is inrelease-readiness.md.This paragraph was wrong until 2026-08-14, and the way it was wrong is worth keeping. It read "NOT READY for v0.3.0 — three exit criteria are unmet in code (EC-1a/1c band keys, EC-3 GCI, EC-9 OIDC), and EC-12 is blocked on a human action". All four had since closed, while EC-6 in
release-readiness.md— "docs/release.mdreadiness table reflects current state" — read MET the whole time. One document certified the other as current, and it was not. A cross-reference is not a check.Note also the distinction this section did not draw: it says "don't actually publish until the validation suites below are green", and rc1 and rc2 were both published with the two W1 rows still amber. That is deliberate for a pre-release —
pip install aeolus1dskips it without--pre, so no user gets it by accident — but the sentence below is written as if every publish were final. Read it as gatingv0.3.0, notrcN.
The PyPI machinery is in place and proven, but don't publish a FINAL
v0.3.0 until the validation suites below are green — pre-release
rcN tags are exempt, since pip install aeolus1d skips them. PyPI
release is one-way: once a version is published, even yanking
it doesn't remove pinned installs. Better to wait a sprint
than ship a regression.
Gate waiver for v0.3.0 (decided 2026-08-16)¶
The two amber W1 rows do not turn green, and v0.3.0 ships anyway,
as documented limitations rather than pending work. Recorded here
because a rule waived silently is a rule deleted.
What the rule is for: "better to wait a sprint than ship a regression." Neither amber row is a regression, and both are measured:
- W1 — Ricardo WAVE KTM 510 cc. 6/10 at the recommended
invocation. The breathing physics is verified and
conservation-pinned (ADR-0008); the quantitative miss is a
single-point-calibrated cam over-admitting air across RPM. Closing
it needs proprietary OEM cam data that is not public, so waiting
does not converge — the row would be amber at
v0.4.0too. - W1 — Mendeley ethanol/water. 39/60 on the baseline arm — the
honest score, since the older 44/60 was flattered by a knock-retard
bug (bisected to
643fc3fb). 43/60 with the predictive burn, whose water columns are unfitted prediction. The residual at 40–50 % water is a named, measured physics gap — combustion-efficiency loss under dilution, which the entrainment model cannot express because it always finishes its burn — with the next step filed inBACKLOG.md.
Both limitations are stated where a user meets them
(docs/scope-and-limitations.md, the suite READMEs, the validation
dossier), each with its harness. Pre-1.0 software with honestly
documented limits is the norm; an unshipped 0.3.0 helps nobody, and
a silently amber one would be worse than either.
This waiver covers v0.3.0 only. The rows stay amber, the BACKLOG
items stay open, and the next release re-asks the question.
Gate waiver for v0.4.0 (decided 2026-08-30)¶
The two amber W1 rows are waived again, and re-argued rather than
carried. The v0.3.0 waiver above says the next release re-asks the
question; this is that answer. Release criterion EC-Q6 exists because the
failure mode is a waiver that carries forward silently.
Re-deriving it changed two facts about the KTM row, and neither was
known when the v0.3.0 waiver was written.
- It is 7/10, not 6/10. Re-measured 2026-08-30 on
9222699dwith the recommended--throttle --wall-heatinvocation. Which point flipped is unrecoverable — the 2026-06-09 record kept only the aggregate — so the per-point table now lives in the suite README with its harness. All ten points over-predict, mean +20.3 %, and the profile is U-shaped in speed rather than a constant offset. - Its reference is not experimental. The suite compares against the
OEM column of IMECE2017-70638's Tables 2 + 3, which is Ricardo WAVE's
own simulated baseline. The paper's chassis-dyno curves exist and are
not what is scored against. So the
v0.3.0waiver's phrase "the quantitative miss" was measuring this solver against another simulator, and said "experiment" while doing it.
Neither change makes the waiver weaker, and that is the point of
re-deriving it. The blocker is unchanged and unchanged in kind:
proprietary OEM cam lift geometry that is not published — confirmed
2026-08-30 by reading the source paper, whose Fig. 5 is an exhaust-length
plot and not the cam profile a stale citation in
examples/71_ktm_510_si.toml claimed. Table 1 publishes four valve-timing
scalars and two peak lifts and no lift curve. Waiting does not converge;
the row would be amber at v0.5.0 too.
The Mendeley row is unchanged from the v0.3.0 waiver above: 39/60 on the
baseline arm, residual concentrated at 40–50 % water, the named physics gap
filed. It remains the only experiment-referenced half of W1.
What changed around the rows, and is why the waiver is narrower than it
was: aftertreatment is no longer un-bench-validated
(heliyon_twc_co_oxidation, open-access CC BY synthetic gas bench), and W4
reached measured data on 2026-08-29. Three suites now carry an obtainable
experimental reference. The KTM is waived on its own merits, not because
the project has nothing better.
This waiver covers v0.4.0 only. Same terms as above: the rows stay amber,
the BACKLOG items stay open, and v0.5.0 re-asks the question.
The consolidated, runnable status of the four parity workflows (W1–W4) lives in the validation dossier; the table below is the release-gating subset.
| Suite | Status (2026-06-07) | Required for release? |
|---|---|---|
| W1 — Ricardo WAVE KTM 510 cc SI | ⚠️ breathing verified (ADR-0008); quantitative 6/10 recommended (3/10 bare), down from 8/10 legacy — per-RPM re-cal owed (OEM cam data) | Yes (as breathing-verified; re-cal tracked) |
| W1 — Mendeley ethanol/water | ⚠️ 39/60 — the honest score: the earlier 44/60 (2026-06-09) was flattered by a knock-integral bug that silently retarded combustion 10° on this scaffold (bisected 2026-08-15 to 643fc3fb, the 2026-07-01 fix; closure in BACKLOG_HISTORY.md); residual at 40–50 % water is combustion deterioration under water dilution — the earlier "charge-cooling / const-γ" diagnosis was implemented, measured, and retracted 2026-08-15 (charge cooling moves the prediction the wrong way; see the suite README's retraction) |
Yes |
| W3 — AVL C02006 aftertreatment drive cycle | ✅ Runs end-to-end, ~6.5 min / 500 s (co_conv_cat structurally blocked — see scope page) |
Yes |
| W4 — Acoustics transmission loss (Chung–Blaser) | ✅ Banked vs analytic resonance + GT-Suite TMM cross-check | Yes |
| W2 — Turbo matching (shaft power balance) | ✅ Self-consistency check, 3/3 (conservation + map fidelity) | Yes |
| Real-gas EOS — RK / PR — round-trip | ⏳ Pending — analytic-Jacobian regression | Stretch |
| Network mode analysis (Phase 42) | ⏳ Pending — eigenfreq vs. closed-form | Stretch |
"Banked" means the validation TOML grid runs green under
run_validation.py against its pass_criteria.toml. New
suites land under examples/validation/<dataset>/. The
H4-series methodology is the template — replicate it for
each new dataset.
Frozen-report governance: frozen validation reports (e.g. validation report v0.3.0) change only via logged errata in their own change-log section — never by silently editing the frozen numbers or conclusions. New results go in a new report (or the living validation dossier), not into a frozen one.
The CI release-pypi: job will not publish until the manual
gate is clicked. Until the Yes-marked suites are all
closed, the maintainer should:
- Tag pre-release versions (
v0.3.0rc1,v0.3.0rc2, …) freely — they trip the tag-driven pipeline but won't publish without a click. The wheel + sdist artefacts land on the pipeline page for download. - Use
release-pypi-test:(the manual TestPyPI job on the same tag pipeline) to verify the install flow without touching production PyPI. - Only click ▶ on
release-pypi:once the gate is genuinely passed.
Known wheel-install gaps¶
The wheel-install-smoke: CI job installs the freshly-built
wheel into a fresh venv against the hash-pinned
requirements.lock (pip install --require-hashes), then checks
aeolus1d --version, the top-level import aeolus1d,
aeolus1d examples, and a full aeolus1d run of a bundled case.
Status:
aeolus1d examples— resolved (v0.3.0). The top-levelexamples/*.tomlare bundled into the wheel underaeolus1d/examples/by the hatchling build hook (hatch_build.py);aeolus1d._resources.examples_dir()resolves them at runtime.wheel-install-smoke:now lists the bundled cases and runs one end-to-end. The heavyavl_cruise_m/validationsubtrees are intentionally not shipped (external data, bloat).- GUI
[gui]extra —wheel-install-smoke:does not exerciseaeolus1d-guibecause the[gui]extra pulls PySide6 + pyvista + Qt + VTK (~600 MB), slowing the smoke by minutes. The GUI install path is covered by the existinggui-tests:CI job using an editable install; the wheel-install variant should be added once a dedicated GUI smoke target exists.
GitLab CI/CD variables required¶
The release jobs read two protected, masked variables from
Settings → CI/CD → Variables:
| Variable | Use | Value |
|---|---|---|
PYPI_API_TOKEN |
release-pypi: job |
Project-scoped PyPI token, prefix pypi- |
TEST_PYPI_API_TOKEN |
release-pypi-test: job |
Project-scoped TestPyPI token |
Issue both as project-scoped tokens with upload permission only — never use a maintainer's account-wide token. Rotate after any incident or contractor offboarding.
Both variables must be set with:
- Protected:
true— only available on protected branches / tags. ThevX.Y.Ztag pattern is configured as protected inSettings → Repository → Protected tags. - Masked:
true— value is replaced with[MASKED]in job logs.
Dry-run via TestPyPI¶
The release-pypi-test: job is manual — it shows up on the
tag pipeline with a play button. Use it before a real release to
verify metadata, file inclusion, and the install flow.
# Trigger the manual job from the Pipelines page (▶ icon).
# Once green:
python -m pip install \
--index-url https://test.pypi.org/simple/ \
--extra-index-url https://pypi.org/simple/ \
"aeolus1d==0.3.0rc1"
# Smoke the entry points:
aeolus1d --version
aeolus1d doctor # open-source maturity Phase 2 diagnostics
TestPyPI refuses re-uploads to the same version, so the dry-run
flow uses a pre-release tag suffix (e.g. v0.3.0rc1). Cut a real
v0.3.0 tag once the dry-run looks clean.
Local fallback¶
If CI is unavailable, the release can be cut from a maintainer workstation:
export TWINE_USERNAME=__token__
export TWINE_PASSWORD=<your-pypi-token>
make release # build + twine check + upload
Same for TestPyPI:
export TWINE_USERNAME=__token__
export TWINE_PASSWORD=<your-test-pypi-token>
make release-test # uploads to TestPyPI
make build (without upload) is the cheapest sanity check —
useful before opening the bump MR.
After the release¶
- Verify on PyPI: open https://pypi.org/project/aeolus1d/ and confirm the new version is the default download.
- Sanity install in a fresh venv:
python -m venv /tmp/aeolus1d-test source /tmp/aeolus1d-test/bin/activate pip install aeolus1d aeolus1d --version aeolus1d doctor - GitLab Release (optional but recommended) — create a
GitLab Release on the tag via
Deployments → Releases → New. Copy the release notes fromdocs/changelog.md. The Release surface attaches the dist artefacts and renders a download page distinct from the raw tag. - Zenodo — if the Zenodo integration is set up (Phase 9 of
the open-source maturity remediation), the new tag will
trigger an automatic DOI mint. Update CITATION.cff's
doi:field once Zenodo returns the new DOI.
Pre-release labels¶
Pre-releases use Python's standard suffixes:
| Stage | Tag | PyPI version |
|---|---|---|
| alpha | v0.3.0a1 |
0.3.0a1 |
| beta | v0.3.0b1 |
0.3.0b1 |
| release candidate | v0.3.0rc1 |
0.3.0rc1 |
| post-release fix | v0.3.0.post1 |
0.3.0.post1 |
The release-pypi: tag regex (^v\d+\.\d+\.\d+(?:[-+].*)?$)
matches all of these. PyPI installs pre-releases only with
pip install --pre aeolus1d, so production users stay on stable.
Yanking a broken release¶
If a release ships and a critical bug surfaces:
- Yank on PyPI —
https://pypi.org/manage/project/aeolus1d/→ release → Options → Yank release. Yanked versions stay installable by exact pin but are skipped by version-resolver defaults. - Cut a patch — bump to the next patch version (e.g.
0.3.0→0.3.1) following the normal flow above. - Note in changelog — call out the yank + replacement in
docs/changelog.mdso users on the broken pin know to upgrade.
Do not delete a release outright — pinned installations break silently. Yank is the reversible analogue.
Future hardening — PyPI Trusted Publishing¶
The current flow uses a project-scoped API token stored as a GitLab CI/CD variable. That's the standard pattern but carries a residual risk: a CI compromise could exfiltrate the token and publish a malicious version under aeolus1d's name.
PyPI offers Trusted Publishing (PEP 740 / OIDC-based) as a token-less alternative. PyPI trusts the GitLab tag pipeline directly via OIDC; no long-lived secret lives in CI/CD variables. GitLab support landed in 2024; aeolus1d's self-hosted runner is on docker-executor + recent gitlab-runner, so the prerequisites are met.
Migration is a four-step:
- Register the GitLab project as a Trusted Publisher on
pypi.org → Account settings → Publishing → Add a new pending publisher(workflow filename:.gitlab-ci.yml, environment:release). - Add
id_tokens:to therelease-pypi:job:id_tokens: PYPI_ID_TOKEN: aud: pypi - Replace
twine uploadwithpython -m twine upload --skip-existinginvoked via the ID-token flow (twine 5.1+ honoursPYPI_ID_TOKEN). - Revoke the
PYPI_API_TOKENCI/CD variable.
Defer this until after the first 1-2 successful token-based releases — gives the existing flow time to harden, and avoids mixing "new release process" with "new auth model" in the same change.