Release Process
This page documents the release process for trueppm-suite. Releases are created by running scripts/release.sh on main, which bumps all version manifests, rotates the changelog, commits, and creates an annotated git tag. Pushing the tag triggers the CI publish jobs.
Prerequisites
Section titled “Prerequisites”Before cutting any release:
-
All MRs for the milestone are merged. Check with
glab issue list --milestone <version>. -
mainpipeline is green. Releases are cut from a clean, passingmain. -
Changelog fragments are present. Every user-visible change should have a fragment in
changelog.d/. Runcat changelog.d/*.mdto preview the pending entries. (Do not runscripts/assemble-changelog.shto preview — it assembles for real, consuming the fragments and rewritingCHANGELOG.md; the release script invokes it at the right moment.) -
Smoke test passes.
Terminal window make release-smokeThis boots the dev stack, seeds the demo project, and curls every shipped endpoint. Fix any failures before proceeding.
-
Version-tense alignment. Diff the docs under
packages/website/src/content/docs/againstoverview/roadmap.md(the single source of truth) for version-tense drift: every version mentioned in past/present tense must be under the roadmap’s ## Shipped section; anything under Underway or Planned must read in future tense. Runbash scripts/check-version-status.sh(the same gate thedocs:version-accuracyCI job runs).When the version moves into Shipped — and when it does not (#2824).
0.Xmoves into the roadmap’s ## Shipped section when the release line reaches the maturity that version promises — for 0.4, the firstbetatag. Alpha prereleases on the way to that milestone do not promote it. Promote it, and bumpSHIPPEDinsrc/content/_release-status.mdx, at that tag — not at the first tag on the line that happens to carry the version number.This is the one step on this checklist that is destructive if taken early, so it is worth being precise about. 0.1, 0.2, and 0.3 each reached their promised maturity at their first alpha, so promoting at
X.Y.0-alpha.1was correct all three times — which is exactly why the habit is easy to carry into a line where it is wrong.Moving the version into ## Shipped is what arms the reverse half of the gate: every
:::note[Ships in <version>]callout on the tree becomes the same misinformation inverted, andcheck-version-status.shfails until they are gone.scripts/release.shacts on that automatically — it runsscripts/remove-ships-in-callouts.sh --apply <MAJOR.MINOR>during the cut and deletes every such callout (131 blocks across 78 pages for 0.4, measured 2026-08-30). Promote early and that deletion publishes unshipped features as available, withcheck-version-status.shstill passing, because it polices only the opposite direction.scripts/check-early-promotion.shguards the worst case — it refuses an alpha cut against an already-promoted version whose callouts are still live, before any manifest is touched (override, for a version whose promised maturity genuinely is alpha:--allow-early-promotion).To run the sweep by hand ahead of the cut, use
bash scripts/remove-ships-in-callouts.sh --apply <version>for the mechanical ones, then hand-edit whatever it reports as flagged-not-removed — those need editorial judgment, not string surgery. Pages whose prose is already written for the shipped state (license.md’s Community-Edition list, for instance) need only the callout deleted.Two more gates belong to the same pass.
bash scripts/check-ws-event-reachability.shasserts the published WebSocket taxonomy does not advertise an event no client can subscribe to, and inverts when the channel that makes one deliverable ships. And the declaration-coverage ratchet insidecheck-version-status.shrequires every page underfeatures/,administration/andgetting-started/to either declaredocumentedForor be hash-recorded inpackages/website/docs-declaration-baseline.txt— re-record withbash scripts/check-version-status.sh --update-baselineafter a release sweep touches those pages.Two link gates run on every docs change and matter most at the cut, because the callout sweep and the version snapshot both rewrite pages in bulk.
python3 scripts/check-docs-internal-links.py(CIdocs:internal-links) fails on an internal link to a missing page, heading anchor, asset, or repository file; it reads the Markdown source, so run it after step 7 commits the snapshot and it checks the frozen tree’s/<version>/…links against the frozen pages.python3 scripts/check-docs-api-routes.py(CIdocs:api-routes) fails on a documentedMETHOD /api/v1/…route thatdocs/api/openapi.jsonno longer has — which is what an endpoint removed during the release line leaves behind. -
Migrations are squashed (minor boundaries only). At each
0.x → 0.(x+1)minor cut — not for patch/alpha/beta/RC bumps within a series — collapse each app’s migration history with Django’s non-destructivereplaces=squash (python manage.py squashmigrations <app> <start> <end>), per the migration-discipline rules in the rootCLAUDE.md(rule 6). The originals stay on disk, so fresh installs use the collapsed migration while existing databases upgrade as a no-op — never hand-write a regenerated0001_initial, which cannot reproduce the non-regenerable ops (the ltree/pg_trgm/btree_gist extensions, the GiSTwbs_pathindex, thehistoricaltaskcomposite index). After squashing, runruff check --fix <migrations> && ruff format <migrations>and confirmpython manage.py makemigrations --check --dry-runreports No changes detected. Any deferred per-app squash work is tracked in the milestone issue for the cut (e.g. #1360 for 0.4). -
Docs version is cut (minor boundaries only). At each
0.x → 0.(x+1)minor cut — starting with 0.4, the first frozen version — freeze the shipped docs as astarlight-versionssnapshot, so users on the released version keep accurate docs while the live site tracks the next release as “Next (unreleased)”. After prerequisite 5 has bumped the release-status SSOT to the new version, editpackages/website/astro.config.mjsto add the entry to theversionsarray (e.g.{ slug: "0.4", label: "v0.4" }), runnpm run buildinpackages/websiteonce so the plugin archives the current docs tree intosrc/content/docs/<version>/, then commit the generated snapshot. Not done for patch/alpha/beta/RC bumps within a series. The full recipe lives in the comment aboveconst versionsinastro.config.mjs.
Version scheme
Section titled “Version scheme”TruePPM follows semantic versioning. The script manages both stable and pre-release series:
| Command | Example | Result |
|---|---|---|
./scripts/release.sh patch | 0.1.0 → 0.1.1 | Bugfix release |
./scripts/release.sh minor | 0.1.0 → 0.2.0 | New features, backwards-compatible |
./scripts/release.sh major | 0.1.0 → 1.0.0 | Breaking changes |
./scripts/release.sh minor alpha | 0.1.0 → 0.2.0-alpha.1 | Start alpha series |
./scripts/release.sh alpha | 0.2.0-alpha.1 → 0.2.0-alpha.2 | Next alpha |
./scripts/release.sh beta | 0.2.0-alpha.2 → 0.2.0-beta.1 | Promote to beta |
./scripts/release.sh rc | 0.2.0-beta.1 → 0.2.0-rc.1 | Promote to RC |
./scripts/release.sh release | 0.2.0-rc.1 → 0.2.0 | Finalize pre-release |
./scripts/release.sh 1.2.3 | explicit | Pin to specific version |
Pre-release CHANGELOG behavior: Alpha/beta/RC bumps do NOT rotate the [Unreleased] section — notes accumulate until the final stable release.
Confirmation gate
Section titled “Confirmation gate”Before any manifest is touched, the script shows the computed version as a suggestion and asks you to confirm it. Because every release cuts two immutable tags (v<semver> and scheduler-v<pep440>), this is the last point at which a wrong stage or a stale base can be caught:
About to cut a release: current : 0.2.0-alpha.1 new : 0.3.0-alpha.1 <- suggested tags : v0.3.0-alpha.1, scheduler-v0.3.0a1 note : pre-release — CHANGELOG will not be rotatedEnter to accept 0.3.0-alpha.1, type an explicit version to override, or 'q' to abort:- Enter accepts the suggested version.
- Type an explicit semver (e.g.
0.2.0-beta.1) to override — the prompt re-displays with the new version and its tags so you re-confirm before proceeding. qaborts without writing anything.
Pass -y / --yes (or set RELEASE_ASSUME_YES=1) to accept the computed version without the prompt — required for non-interactive runs, which otherwise fail closed rather than auto-cut a tag:
./scripts/release.sh minor --yes # accept the computed 0.2.0 non-interactivelyStep-by-step: stable release
Section titled “Step-by-step: stable release”# 1. Ensure you're on a clean, up-to-date maingit checkout main && git pull origin maingit status # must be clean
# 2. Verify the milestone is completeglab issue list --milestone 0.2 # should return 0 open issues
# 3. Run the smoke testmake release-smoke
# 4. Cut the release./scripts/release.sh minor # e.g. 0.1.0 → 0.2.0
# 5. Review the generated commit and taggit log --oneline -3git show v0.2.0 --stat
# 6. Push — this triggers the CI publish jobsgit push origin main v0.2.0The git push origin main v0.2.0 command triggers the CI publish stage:
api:publish— builds the API Docker image, Trivy-scans it, generates a CycloneDX SBOM (Syft), pushes it to the internal GitLab container registry and to the public GHCR (ghcr.io/<user>/api:<version>+latest), then Cosign-signs the GHCR digest keyless and attaches the SBOM as a CycloneDX attestationweb:publish— same as above for the web image (ghcr.io/<user>/web)helm:publish— packages and pushes the Helm chart tooci://ghcr.io/<user>/charts, then Cosign-signs the pushed chart digestapi:publish:pypi— publishestrueppm-apito PyPI via Trusted Publishing (see PyPI Trusted Publishing)release:create— creates the GitLab release entry
Starting with the 0.4 beta, GHCR will become the public release target, not an optional mirror. The image/chart publish jobs will push to GHCR in addition to the internal GitLab container registry, and a missing GHCR_USER/GHCR_TOKEN will fail the job rather than skipping — so a release tag can never appear to succeed without the public artifacts landing. Cosign signing is keyless (Sigstore via the GitLab SIGSTORE_ID_TOKEN OIDC audience) and needs no key material. Configure GHCR_USER + GHCR_TOKEN (Masked + Protected) before pushing the first v0.4.0-beta* tag.
Additionally, pushing the scheduler-v* tag triggers scheduler:publish, which publishes trueppm-scheduler to PyPI.
Scheduler releases
Section titled “Scheduler releases”The scheduler package (packages/scheduler) is released in lockstep with the rest of the platform: scripts/release.sh bumps all manifests to the same version and creates both tags in one run. The same version is translated to PEP 440 for the scheduler’s scheduler-v* tag (e.g. 0.2.0-alpha.1 → scheduler-v0.2.0a1).
# One release run produces both tags./scripts/release.sh minor # bumps all manifests including schedulergit push origin main v0.2.0 scheduler-v0.2.0The scheduler:publish CI job fires on scheduler-v* tags and publishes to PyPI.
PyPI Trusted Publishing (one-time setup)
Section titled “PyPI Trusted Publishing (one-time setup)”scheduler:publish, mcp:publish, and api:publish:pypi all authenticate to PyPI via Trusted Publishing (GitLab OIDC) — none carries a static PYPI_TOKEN on its publish path (api:publish:pypi was migrated off one in #3943). Each job presents a short-lived GitLab ID token that PyPI exchanges for a single-use, project-scoped upload token. For that exchange to succeed, a Trusted Publisher must be registered on the corresponding PyPI project whose claims match the pipeline exactly. This is a one-time PyPI-side configuration per package — do it before the first tag that publishes via OIDC, or the mint-token step fails HTTP 422 before anything is uploaded.
On PyPI → the project (trueppm-scheduler, trueppm-mcp, or trueppm-api) → Manage → Publishing → Add a new publisher (GitLab):
| Field | trueppm-scheduler | trueppm-mcp | trueppm-api |
|---|---|---|---|
| Namespace | trueppm | trueppm | trueppm |
| Project name | trueppm (the repo is trueppm/trueppm) | trueppm | trueppm |
| Top-level pipeline file path | .gitlab-ci.yml | .gitlab-ci.yml | .gitlab-ci.yml |
| Environment name | pypi-scheduler — must exactly match the environment: block on scheduler:publish | pypi-mcp — must exactly match the environment: block on mcp:publish | pypi-api — must exactly match the environment: block on api:publish:pypi |
The values must match the running job: namespace/project come from the repo path (gitlab.com/trueppm/trueppm), and the environment field must match what that job declares (a mismatch, including a blank value where the job declares a name, makes the OIDC claims mismatch and PyPI returns 422). After registering the publisher, retry the job on the existing tag — no re-tag is needed; it rebuilds from the tag and the fix is purely PyPI-side.
trueppm-api specifically: register the pypi-api publisher before the next v* tag. api:publish:pypi is tag-only, so this MR’s own pipeline cannot exercise it — the migration lands in .gitlab-ci.yml but is unproven until that tag runs. Every trueppm-api version through 0.4.0-beta.3 was published under the old static-token job and carries no attestation.
When a future package is migrated from a static token to OIDC, complete this PyPI-side registration as part of the migration, not after the first failed tag.
Enterprise release
Section titled “Enterprise release”After pushing the OSS tag, run the enterprise repo’s own scripts/release.sh from a trueppm-enterprise checkout (the --oss-tag flag belongs to that script, not to the OSS scripts/release.sh):
cd ../trueppm-enterprise./scripts/release.sh --oss-tag v0.2.0The enterprise script pins TRUEPPM_OSS_TAG to the OSS release and bumps the enterprise version independently.
Hotfix procedure
Section titled “Hotfix procedure”For a critical fix on an already-released version:
# Branch from the release taggit checkout -b fix/critical-bug v0.1.0
# Apply the fix, commit, open an MR back to main# After the MR merges to main, cherry-pick or re-cut as a patch release:git checkout main && git pull origin main./scripts/release.sh patch # 0.1.0 → 0.1.1git push origin main v0.1.1What the script modifies
Section titled “What the script modifies”| File | Change |
|---|---|
packages/scheduler/pyproject.toml | version = "x.y.z" |
packages/api/pyproject.toml | version = "x.y.z" |
packages/web/package.json | "version": "x.y.z" |
packages/api/src/trueppm_api/settings/base.py | SPECTACULAR_SETTINGS["VERSION"] set to the base semver (pre-release suffix stripped) |
docs/api/openapi.json | Regenerated via scripts/export-openapi.sh so the committed schema matches the tag |
CHANGELOG.md | [Unreleased] → [x.y.z] - YYYY-MM-DD (stable only) |
The Helm chart version in packages/helm/Chart.yaml is kept in sync manually — bump version and appVersion to match before running release.sh. Keep appVersion as the bare semver (0.4.0): the chart’s default image tag is derived as v<appVersion> to match the v-prefixed tags api:publish / web:publish push, so a v-prefixed appVersion would render vv0.4.0. scripts/helm-structure-check.sh asserts both halves.
Troubleshooting
Section titled “Troubleshooting”“Tag vX.Y.Z already exists” — the tag was already pushed. Check if the CI jobs ran correctly; if the images are already published, no action is needed.
“already exists on origin” / “already exists on PyPI” — release.sh checks origin’s tags and PyPI (trueppm-scheduler, trueppm-mcp, trueppm-api) before bumping anything. A published version is never re-cut: PyPI uploads are immutable, so cut the next version instead. If it says it “could not read” origin or PyPI, it refuses rather than assume the version is free; fix network access and re-run.
git fetch --tags says “would clobber existing tag” — your clone holds a local tag that differs from origin’s published one. Repair each with git tag -d <tag> && git fetch origin tag <tag>.
“Working tree is not clean” — stash or commit pending changes before running the script.
“[Unreleased] section is empty” — add changelog fragments to changelog.d/ and run bash scripts/assemble-changelog.sh to populate [Unreleased] before releasing.
scheduler:publish / mcp:publish / api:publish:pypi fails HTTP 422 at the mint-token step — PyPI has no Trusted Publisher matching the pipeline’s OIDC claims (it builds and signs the wheel correctly, then fails before any upload, so nothing is published). Register the publisher as described in PyPI Trusted Publishing above, then retry the job on the existing tag — no re-tag needed. The job prints PyPI’s exact reason from the 422 response body to the log, so read it to confirm the mismatched claim (most often an environment name that differs from the one the job declares).
scheduler:publish / mcp:publish / api:publish:pypi fails InvalidDistribution: ... has no associated attestations — twine --attestations is an opt-in gate, not filesystem discovery: it only attaches .publish.attestation sidecars that are passed to it as explicit arguments. The twine upload command must list the sidecars (dist/*.publish.attestation) alongside the dists, or it rejects the upload before anything is published. (Fixed in #1390 for scheduler:publish; the same shape applies to any job built on this pattern.)
CI publish job fails — the failure is in the build or push itself (Docker build error, registry outage, expired credentials), so read the job log. Starting with the 0.4 beta a missing GHCR_TOKEN/GHCR_USER will fail the image/chart publish jobs with an actionable error (GHCR will become the public release target, not optional) — set both in GitLab CI/CD variables (Settings → CI/CD → Variables, Masked + Protected) with a PAT that has write:packages scope before tagging. api:publish:pypi no longer has a token to be absent (#3943) — it now fails loudly at the mint-token step if the Trusted Publisher isn’t registered, per the entry above.