Releasing¶
How a version of Axiom is cut. Written for whoever is doing it, which today is a maintainer with push access.
The version lives in one place¶
extension/Cargo.toml's [package] version declares it. Everything else
derives:
| Where | How |
|---|---|
extension/axiom.control |
pgrx templates @CARGO_VERSION@ at package time |
axiom_version() in SQL |
env!("CARGO_PKG_VERSION") at compile time |
| the gateway binary | -ldflags -X main.version, from the image build's VERSION arg |
the gateway's Ping reply |
the same variable, so a client can see what it is talking to |
None of those is a second copy to keep in step, and make release-check fails
if one is turned into one. The gateway's default is dev on purpose: an
unstamped binary should say so rather than claim a release it is not.
To change the version, edit extension/Cargo.toml and nothing else.
What earns which number¶
Decide this from the rule, not from scratch at each release. The number is a claim about what changed for someone using Axiom, so the test is always "what can a user now do, or no longer do", never "how much work was it".
| Bump | For |
|---|---|
patch — 0.1.0 → 0.1.1 |
Fixes, packaging, distribution, tooling, docs, and security. Anything that does not change what Axiom can do. |
minor — 0.1.1 → 0.2.0 |
Meaningful capability: roadmap items, new SQL surface, new gateway behaviour. Grouped, and cut every few months rather than per merge. While the major version is 0, breaking changes land here too. |
major — 1.0.0 → 2.0.0 |
A breaking change to a public surface, once there is a 1.0 to break. |
Three consequences are easy to get wrong:
Shipping a capability is not the same as building one. The extension
tarballs are the example: they made installing Axiom into an existing Postgres
possible without a Rust toolchain, which is a large improvement and still a
patch, because Axiom does exactly what it did before. New delivery of the same
functionality is mechanics. That is why the entry can be kind: added in the
changelog and still land in a patch release — the changelog describes the
change, the version describes the capability.
While the major version is 0, breaking changes go in the minor. This is
Axiom's convention, not a rule semver imposes: semver says only that in 0.y.z
"anything MAY change at any time", which would permit breaking something in a
patch. We do not, because there is no 1.0 yet, so major is not available as a
signal and bumping it would spend the one-time meaning of 1.0 — the
commitment that the surfaces below are stable — on an ordinary breaking change.
Minor is the loudest number left. A minor that breaks something says so at the
top of its changelog section, in the imperative, with the migration. After
1.0, breaking moves to major and this paragraph goes away.
Adding to a surface is not breaking it. A new FDW option, a new promoted
column, a new RPC: those are minors after 1.0 too. Only removing or
redefining something that already worked is a major.
The two surfaces¶
Axiom has two, and they break differently:
- The SQL surface — server and user-mapping options,
IMPORT FOREIGN SCHEMAoptions, promoted columns, and theaxiom_*()functions. Breaking it breaks someone's queries and DDL. - The gRPC API between the extension and the gateway. Breaking it means the two halves must be upgraded together, and that is not something a version number communicates on its own. Say it in the changelog entry, and say which direction of skew fails.
Everything under deploy/ is an example, not a surface. Applying a renamed
manifest leaves the old objects in place and still working, so a rename there
does not break a running cluster and does not force a minor. It does oblige a
changelog entry with the cleanup commands and a warning about local edits —
0.1.1's ClusterRole rename is the worked example. If a manifest change ever
would break a cluster that just re-applies it, that is a breaking change and
the paragraph above applies.
Cadence¶
Minors go when enough has accumulated to be worth a release note, which in practice is every few months. That is a rhythm, not a deadline: never ship an empty minor to hit a date, and never rush half a feature into one. Patches go whenever there is something to fix.
Security fixes are the exception to all of it — they ship as a patch immediately, and are never held back for a cadence.
Cutting one¶
# extension/Cargo.toml -> X.Y.Z first; everything else derives from it
make release-check # changesets well-formed, version consistent
make changelog # read what the release will say
make release-notes VERSION=X.Y.Z # assemble CHANGELOG.md, empty .changes/
Commit that, open it as a PR like anything else, and merge it. Then:
git tag vX.Y.Z && git push origin vX.Y.Z
Then publish a GitHub release from the tag. That is the step that matters:
both gateway-image.yml and postgres-image.yml trigger on
release: [published], not on the tag. A pushed tag with no published release
builds nothing.
postgres-image.yml does most of it: the per-major images, the extension
tarballs attached to the release, the install check that pulls the published
images with no credentials and runs the whole procedure against them, and only
then the floating tags.
The tag carries a leading v; the changelog heading and make release-notes
do not. scripts/version check vX.Y.Z compares the tag against
extension/Cargo.toml — it does not read CHANGELOG.md, so nothing mechanical
catches a changelog heading that disagrees. The release workflow calls it before
publishing anything.
Rehearsing with a release candidate¶
A prerelease runs the whole publish path and moves nothing. The version tags
and every release asset are produced exactly as they would be, while latest,
latest-pgNN and the gateway's latest stay where they are. One job moves all
three and it declines on github.event.release.prerelease, so there is no
second mechanism to get wrong and no tag that can slip through. That makes an
rc the only way to find out whether a release works without a release
depending on the answer.
# extension/Cargo.toml -> 0.1.2-rc.1, and extension/Cargo.lock with it
make release-check
Commit that and merge it like any other change before tagging. The tag has
to point at a commit that carries the bump: scripts/version check runs in the
release workflow and compares the tag against extension/Cargo.toml, so a tag
on the unbumped tree fails there rather than here. Then:
git tag v0.1.2-rc.1 && git push origin v0.1.2-rc.1
Then publish it from the tag with "Set as a pre-release" ticked. promote
also refuses any tag containing a hyphen, so a forgotten checkbox cannot move
the floating tags on its own — but tick it anyway, because that belt only
catches tags spelled as prereleases.
Moving off an rc means re-running setup. Axiom ships no extension upgrade
scripts yet (#65), so ALTER EXTENSION axiom UPDATE from 0.1.2-rc.1 to
0.1.2 fails. The way forward is DROP EXTENSION axiom CASCADE, then
CREATE EXTENSION, CREATE SERVER and IMPORT FOREIGN SCHEMA again. Axiom
keeps no data of its own, so nothing is lost but definitions, though CASCADE
also drops views built on its tables. The package upgrade itself works fine.
Say that in the rc's release body, in those terms. The rc notes for 0.2.0 first called such a database "throwaway", which is accurate about the extension and wrong about everything a reader cares about, and reads as a reason not to try the rc at all.
Do not run make release-notes for an rc. The changesets belong to the
release the rc is rehearsing; consuming them would leave the real release with
an empty changelog. Paste make changelog into the GitHub release body
instead — it renders the pending entries without consuming them.
Afterwards, cut the real release: set extension/Cargo.toml back to the final
version — 0.1.2, not 0.1.2-rc.1 — commit that with Cargo.lock, and follow
Cutting one. Forgetting the bump is the easy mistake: the
release workflow runs scripts/version check against the tag and fails there,
after the release is published, rather than here. The rc's tag and release stay
as a record; nothing needs deleting.
Prereleases are spelled differently in packages¶
Semver writes 0.1.2-rc.1. Neither package manager accepts that: rpm rejects a
hyphen in Version outright, and dpkg would read rc.1 as the Debian
revision, so the final 0.1.2-1 would compare as older than the rc and apt
would refuse the upgrade.
scripts/package-native translates the hyphen to a tilde for both, which is
what each of them uses for "precedes the release of the same name":
0.1.1 < 0.1.2~rc.1 < 0.1.2
So the tarball is axiom-0.1.2-rc.1-…, the package is
postgresql-17-axiom_0.1.2~rc.1-1_amd64.deb, and axiom_version() reports
0.1.2-rc.1, since that comes from CARGO_PKG_VERSION. The two spellings are
deliberate and the gates assert them separately.
GitHub then renames the ~ to . when the package is attached to a release,
so the download is postgresql-17-axiom_0.1.2.rc.1-1_amd64.deb. Only the file
name changes: the version inside the package is still 0.1.2~rc.1, which is
what dpkg and rpm compare, so the ordering above holds.
Writing the notes¶
Do not write them at release time. They are the .changes/ files, written by
whoever made each change, when the reason is still fresh — see
.changes/README.md. make release-notes only
assembles them, in Keep a Changelog order, under a ## <version> heading.
If the assembled notes read badly, fix the changesets and run it again; the command refuses to write a section that already exists, so revert the file first.
Development images are not releases¶
ghcr.io/dhilipkumars/axiom-gateway:development and
ghcr.io/dhilipkumars/axiom-postgres:development-pgNN are built nightly from
main, and only when main has moved since the last run. A merge publishes
nothing on its own.
So a development tag means "main, as of last night", not "main, right now". If
you need the current head, run the postgres image or gateway image workflow
by hand — both accept workflow_dispatch — or build locally.
Nothing about a development image is a release: no version tag is written, and
latest is untouched. Only publishing a GitHub release moves those.
What a release publishes¶
- Container images, multi-architecture:
axiom-postgres:<version>-pgNNper supported major, andaxiom-gateway:v<version>— note the gateway keeps the tag's leadingvand the Postgres images do not.
Every floating tag moves on one piece of evidence. postgres-image.yml
promotes latest, latest-pgNN and the gateway's latest, in one step,
and only after install-check has pulled the published images without
credentials and run the whole install through against them — the gateway
included, since that check deploys the released gateway image. So a release
that does not install leaves every floating tag where it was, and two
unpinned pulls give the pair that was tested together.
gateway-image.yml publishes the version tag and stops there. It cannot
promote: it knows both architectures built, which is not the same as knowing
they work.
- Extension tarballs, one per major and architecture, attached to the
GitHub release with a .sha256 beside each. These are for installing into a
Postgres someone already runs; the images are for trying Axiom.
- .deb and .rpm packages, the same six majors-and-architectures in two
formats, also with a .sha256 each. Built from the same exported tree as the
tarball, so the library inside a package is the same bytes rather than a
second compile. Prefer them wherever a package manager applies: they declare
the glibc floor read off the binary, so an unsupported system is refused at
install time instead of at the next postmaster start.
v0.1.0 predates the tarballs and has only images.
v0.1.1 carries packages, but its own workflow did not build them. They were cut from that release's published tarballs after the fact and uploaded by hand, so the bytes match — the library inside each package is byte-identical to the one in the tarball — but re-running v0.1.1's workflow would not reproduce them. From v0.2.0 the workflow produces all three formats itself. Do not repeat the manual step; if a release is missing artifacts, fix the workflow and cut another patch.