Skip to content

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 aeolus1d rename (2026-08-06) and the jobs were re-armed 2026-08-09. 0.3.0rc1, 0.3.0rc2 and 0.3.0 were 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.0 while the newest final tag is still v0.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.0 is published (07:32 UTC, verified on both indexes). Its exit criteria, EC-Q1…EC-Q12 in release-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 the v0.4.0 waiver below (EC-Q6). The box that follows is the v0.3.0 position, 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 under src/: the PyPI Documentation link to the now-public docs site, and two GUI changes. No validation number can have moved, so v0.4.0's criteria and waiver stand for it unchanged; v0.5.0 re-asks the question.

v0.4.2 (cut and published 2026-10-13, 12:05 UTC, verified on both indexes) is a patch that DOES change src/, so it cannot inherit v0.4.0's criteria the way v0.4.1 did; they were re-measured on the tree instead. The 3-op EPW validation on main d341a665 (predictive, all three ops): 3 of 3 PASS, every channel within 5.3e-5 relative of v0.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-budget and regression-bitid included. 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-budget failed first (80.9 s against the 80 s ceiling, every series about 1.65x slow together, and wallclock-budget on 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.0rc2 and 0.3.0 final. aeolus1d 0.3.0rc2 is on PyPI (tag v0.3.0-rc2, verified against the index). The per-criterion table with evidence is in release-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.md readiness 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 aeolus1d skips 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 gating v0.3.0, not rcN.

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.0 too.
  • 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 in BACKLOG.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 9222699d with the recommended --throttle --wall-heat invocation. 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.0 waiver'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-level examples/*.toml are bundled into the wheel under aeolus1d/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 heavy avl_cruise_m / validation subtrees are intentionally not shipped (external data, bloat).
  • GUI [gui] extra — wheel-install-smoke: does not exercise aeolus1d-gui because the [gui] extra pulls PySide6 + pyvista + Qt + VTK (~600 MB), slowing the smoke by minutes. The GUI install path is covered by the existing gui-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. The vX.Y.Z tag pattern is configured as protected in Settings → 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

  1. Verify on PyPI: open https://pypi.org/project/aeolus1d/ and confirm the new version is the default download.
  2. 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
    
  3. GitLab Release (optional but recommended) — create a GitLab Release on the tag via Deployments → Releases → New. Copy the release notes from docs/changelog.md. The Release surface attaches the dist artefacts and renders a download page distinct from the raw tag.
  4. 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:

  1. 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.
  2. Cut a patch — bump to the next patch version (e.g. 0.3.0 → 0.3.1) following the normal flow above.
  3. Note in changelog — call out the yank + replacement in docs/changelog.md so 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:

  1. 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).
  2. Add id_tokens: to the release-pypi: job:
    id_tokens:
      PYPI_ID_TOKEN:
        aud: pypi
    
  3. Replace twine upload with python -m twine upload --skip-existing invoked via the ID-token flow (twine 5.1+ honours PYPI_ID_TOKEN).
  4. Revoke the PYPI_API_TOKEN CI/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.