How a release is versioned, how a release PR is cut, and what merging one creates.
Merging a release PR creates the
v<version>tag, a GitHub Releases entry with the notes, a source tarball with its.sha256sumattached to it, and the pull request that reopens the development cycle. Announcing a release is still to come — see After a release.
Semantic versioning, MAJOR.MINOR.PATCH, with an optional prerelease
suffix (-alpha1, -rc1, -dev). A version must also be legal as a
PostgreSQL extension version: no --, and no leading or trailing -.
The version lives in one file: VERSION, a single line read by
meson.build's project(version: files('VERSION')). A release generates
these from it:
| artifact | carries the version as |
|---|---|
pg_vectorsearch.control |
default_version |
pg_vectorsearch--<version>.control |
module_pathname |
pg_vectorsearch--<version>.sql |
its filename |
pg_vectorsearch-<version>.so |
its filename |
pg_vectorsearch_version(), the CLI banner |
VS_VERSION |
Between releases, VERSION carries a -dev suffix. main sits at
X.Y.Z-dev. A release PR flips it to X.Y.Z and the next cycle reopens
at the following -dev.
-dev is the only suffix that means "not a release". -rc1 and
-alpha1 are versions that get released — tagged, entered in Releases and gated
like any other — so the release check keys on -dev specifically rather
than on any prerelease suffix.
Multiple versions can be installed side by side. The library is
version-named and each version installs its own secondary control file,
whose module_pathname PostgreSQL uses to resolve MODULE_PATHNAME in
that version's scripts. Several versions can be installed in one cluster
and databases upgrade independently. C symbols may therefore change
freely between versions; the obligation is that an upgrade script
repoints every C function at the new version's library.
A prerelease version also turns on an install-time notice: CREATE EXTENSION warns that upgrading from a prerelease may not be possible.
It is a runtime check against pg_vectorsearch_version(), so a final
release carries nothing to strip.
The low byte of PRISM_META_MAGIC (src/pg/meta.h) is the index format
version. Bumping it makes an index built by an earlier version get
rejected when opened, so every existing index has to be recreated.
This is checked rather than remembered. Both prepare-release.sh and the
release check compare the byte against the previous release tag, and:
- a patch release is refused. Patch upgrades must not require reindexing, so a format change needs a minor or major version;
- the release notes get the upgrade note automatically —
prepare-release.shwrites an "Indexes must be rebuilt" bullet into the breaking-changes section, naming the old and new format bytes.
The comparison is skipped when there is no previous release, or when the byte cannot be read at either end — an unreadable end means unknown, never changed.
There are two ways in, running the same procedure. From the Actions
tab, Cut a release PR takes the version and optionally the next
development version, and runs
the release skill — no checkout
needed. The workflow runs prepare-release.sh itself, the agent
finishes the draft with a read-only token and a shell limited to git
and gh reads, and a second job runs open-release-pr.sh on the two
files, on a fresh checkout, as the release App, so the pull request
carries main's required checks precisely because the App is not
GITHUB_TOKEN. Locally, the steps below are the same two scripts run
by hand, with the notes written in between.
Either way the notes are the work, and either way a human reviews and merges the result. The workflow cannot merge, tag or publish.
./scripts/prepare-release.shIt checks the repository first, before asking anything. Uncommitted changes to tracked files are refused — they would ride onto the release branch, so anything you built or tested there would not be what the PR contains. Untracked files do not block it: having them is normal, and they can reach neither the release commit nor the tag. But one of them might be a file that should have been committed, so they are listed and confirmed rather than passed over:
WARNING: untracked files present -- check none belong in the release:
massif.out.36
results/
Release anyway? [y/N]
With no terminal there is nobody to ask, and stray files in a checkout
must not fail a release, so it warns and continues.
--ignore-untracked-files skips the question outright; they are still
listed, since knowing what was passed over costs one line.
Then it proposes both versions from VERSION — the release is that
version with its suffix removed, the next cycle a minor bump (a patch
bump on an X.Y.x maintenance branch) — and asks you to confirm or edit
them. With no terminal it takes the defaults, so automation needs no
flags, and --version and --next-version override either.
For automation, pass all three flags:
./scripts/prepare-release.sh --version 0.2.0 --next-version 0.3.0-dev \
--ignore-untracked-filesThat run never consults a terminal, so it cannot stall on a runner that happens to allocate one — relying on terminal detection alone would.
It then writes the draft and stops. Nothing in the checkout changes: the draft is two files under a temporary directory, and the run ends by printing the command that opens the pull request from them:
==> Draft written to /tmp/pg_vectorsearch-release-0.2.0.XXXXXX. The checkout is unchanged.
Finish the notes in CHANGELOG.md there (every FILL-IN) and say what
the release is in body.md, keeping Next-Version as its last line.
Then cut the branch and open the pull request with:
./scripts/open-release-pr.sh 0.2.0 0.3.0-dev main \
/tmp/pg_vectorsearch-release-0.2.0.XXXXXX/CHANGELOG.md \
/tmp/pg_vectorsearch-release-0.2.0.XXXXXX/body.md
CHANGELOG.md is the whole changelog with the new entry on top: the
commit list git-cliff generated under ### Changes, and the notes
template above it with a FILL-IN placeholder in every section a
person writes. body.md is the pull request description. A squash
merge turns it into main's commit message, so it ends with the
Next-Version trailer and must keep it last; the highlights go in
above the trailer once they exist.
To preview a release, stop here. The draft is the real changelog entry, generated commit list included, and nothing has left the machine.
open-release-pr.sh is the step that makes the release public. It
takes the two files and does everything else from a clean checkout of
the base: creates chore/release-<version>, copies the changelog in,
sets VERSION, commits the two with the trailer, runs the release
check, pushes the branch and opens the pull request with the title,
label, milestone and body.
It refuses before pushing when the notes are not done. A FILL-IN
anywhere, in the changelog entry or the body, fails the same release
check the pull request runs, so a pull request never opens failing
it. The body must end with exactly one Next-Version trailer naming
the version passed in. The checkout must be clean and on a commit the
remote's base branch has, and the release commit is built from the
two files alone, so nothing else in the checkout reaches it.
A refused run leaves the checkout as it found it. Whatever was cut
before the refusal, the branch and its commit, is dropped, a branch
that --force recreated goes back to its old tip, and the checkout
returns to where it started, so fixing the draft and re-running the
printed command is the whole recovery.
Changing the notes after the pull request is open is ordinary git:
edit CHANGELOG.md on the release branch, amend the release commit,
and push with --force-with-lease. A release pull request is one
commit, and the check refuses a second.
Cutting the same version twice is supported, because rehearsing the flow
means doing it repeatedly and a cut that went wrong is easiest to redo
from scratch. If chore/release-<version> already exists — locally, on
the remote, or both — open-release-pr.sh refuses, and --force
recreates it from the base:
WARNING: recreating chore/release-0.1.0 from main
WARNING: the push will force over the commit these open pull requests are reviewing:
#278 into main (https://github.com/timescale/pg_vectorsearch/pull/278)
The push then carries --force-with-lease pinned to the commit the
remote had when the run looked — so it refuses if the branch moved in
between, which is exactly when somebody else is working on it:
! [rejected] chore/release-0.1.0 -> chore/release-0.1.0 (stale info)
A pull request follows its head branch by name, so a force-push rewrites the head of every open PR built on that branch — including one against a different base, whose diff then becomes meaningless. GitHub never repoints a PR's base; that only changes when someone changes it. So every open PR on the branch is listed before the push.
What makes overwriting safe to offer at all is that the tag check runs
first. A version that has shipped has a v<version> tag, so it is
refused whatever the flags:
ERROR: tag v0.1.0 already exists
No duplicate PR is possible. Before creating one, the run looks for
an open PR from this branch into this base and, finding one, reports it
instead — a force-push has already updated it. gh pr create refuses a
duplicate of its own accord as well, so the guard is belt and braces.
The base is part of that lookup, not decoration: GitHub allows one open
PR per head-and-base pair, so a PR from this branch into some other
base must not stand in for the one being opened. Only open PRs count; a
closed one is an abandoned attempt, and a fresh PR is the right answer
there.
Re-running with a PR already open refreshes its body from the body file, so the PR never disagrees with the branch it is reviewing.
The one edit that could be lost is a reviewer changing Next-Version to
ask for a different bump. That is not discarded silently — the previous
body is saved next to the body file, and a differing trailer is named:
WARNING: the PR body declares Next-Version: 1.0.0-dev, but this run used
0.2.0-dev -- replacing it; the old body is at /tmp/.../body-previous.md
The PR gets the release label and the matching Release <version>
milestone, warning and continuing if none matches.
The PR body says what the PR does, not how releasing works. It states the version being released and, once written, the highlights. The procedure lives here in this document, where it stays current, instead of being copied into every release PR.
The version the cycle reopens at is declared as a git trailer — a key-value line in RFC 822 header style, in the last paragraph of the commit message:
Next-Version: 0.3.0-dev
A trailer rather than prose because the release pipeline reads it back:
git interpret-trailers --parse, or
git log -1 --format='%(trailers:key=Next-Version,valueonly)', gets the
value off the commit that landed on main with no API call and no
parsing of human text.
The script writes it in two places: the release commit, and the PR
body. Which one survives depends on the merge. A rebase merge
keeps the branch commits, and the post-merge step reads the trailer
with git log -1, so the tip commit is the one that counts. A
squash merge builds a new commit message from the PR body
(squash_merge_commit_message is PR_BODY), so the body's copy is
the one that lands.
The release PR is one commit. The release check rejects a second.
Changing the notes after it is open amends that commit rather than
adding another, and the push carries --force-with-lease. The
trailer on that commit and the copy in the PR body must be the same
value. A rebase merge reads the commit. A squash merge reads the body.
Editing it is how a different bump gets requested — a major one, say. Change both copies, and keep each one the last paragraph: git only parses a trailer block that ends the message.
The notes are finished in the draft, before the PR. The entry is
drafted with .release-notes-template.md's FILL-IN placeholders in
it, and open-release-pr.sh runs the release check before pushing,
which fails while any remain — so an unfinished release neither opens
nor merges. git-cliff generates the entry's ### Changes list from
the conventional commit history; that list is raw material, so
trimming it to the notable items is part of writing the notes. This
applies to the first release too: --unreleased means "not reachable
from any tag", which with no tags yet is the whole history rather than
nothing.
.github/workflows/release-check.yml runs scripts/ci/release-check.sh
on every PR. It asserts:
- the version string is valid — checked before the PR is classified, so
a malformed
VERSIONcannot read as a development version and skip everything below; - no
v<version>tag exists yet, locally or onorigin; - the PR is a single commit;
CHANGELOG.mdhas an entry for it with noFILL-INleft;README.mdanddocs/carry no stale version references;- an incompatible on-disk format change is not shipping as a patch;
- an upgrade script exists for the step from the previous release, and
src/pg/meson.buildlists it inext_update_scripts.
The upgrade script is the one thing a release cannot generate for
itself. The install script is sql/pg_vectorsearch.sql copied under the
versioned name at build time, so a new release always installs; moving
an existing installation needs
sql/pg_vectorsearch--<prev>--<new>.sql, and without it
ALTER EXTENSION UPDATE fails and the only way forward is dropping the
extension, which takes the indexes with it. A file that exists but is
not listed is never installed, so it fails the same way, later. A first
release has nothing to upgrade from and is not asked for one.
Only final releases are steps in that chain. A release candidate is
not a version anything upgrades to, and CREATE EXTENSION on one
already warns that upgrading away from it may not be possible, so
candidates are asked for no script and skipped over when the step is
measured. 0.1.0 → 0.2.0-rc1 → 0.2.0 needs one script, from
0.1.0 to 0.2.0.
A candidate may still carry one if its PR adds it — none is required,
but every file in sql/ named <name>--<from>--<to>.sql has to appear
in ext_update_scripts, or it is built and never installed.
A release can ship without a path, deliberately. Open it with
./scripts/open-release-pr.sh --disable-check upgrade-path ...which appends
Disable-check: upgrade-path
to the pull request description, and the check reports the override
instead of failing. The same line in the description of an open pull
request works too, as long as it is visible: one inside an HTML
comment is ignored. open-release-pr.sh refuses a body file that
carries the line itself, so the override is always the decision of
whoever opens the pull request and never part of the notes. It is
the same trailer the commit-count check takes, so one line can name
both. Reach for it when an upgrade is genuinely impossible — say the
on-disk format changed in a way no script can migrate — and say so
in the release notes, because users will have to drop the
extension.
Which PRs get checked is decided by what the PR does, not by what its
branch is called: a release PR is one that changes VERSION to a value
with no -dev suffix. Anything else — an ordinary PR, or one
reopening a development cycle — reports success without checking
further. Keying on the branch name would let a release PR cut from a
differently-named branch skip its checks silently, which is the one PR
where that matters most.
It runs locally too, against the current checkout:
./scripts/ci/release-check.shWhen the check says a PR is a release, a second job in the same workflow packages the source tarball and attaches it to the run, then comments on the PR with the link. Reviewing a release PR includes downloading that archive and building the extension from it.
The tarball is what ships, and it is not the branch: meson dist
decides what goes in, and it carries no .git. CI unpacks it and puts
it through the same configure, compile, install and test sequence as
any build, against a real PostgreSQL — so a tarball that would not
build, or whose extension would not install, fails the PR. What that
cannot cover is the PostgreSQL you have, which is why reviewing a
release PR includes building the archive yourself.
release.yml builds the same tarball, but it runs on the merge — before
tagging, so a broken archive still cannot be published, yet too late for
anyone to try it while the release can still be changed. Packaging on
the PR is what makes it reviewable.
The job packages the PR's head commit rather than the event's synthetic
merge of it into the base, since meson dist bakes the packaged commit
in for pg_vectorsearch_git_commit() and a merge ref's sha resolves
nowhere once the run is over. The comment names that commit, so a
reviewer can tell whether the archive is the one their latest push
produced. It is updated in place rather than added to, because a release
PR is re-pushed whenever the notes are amended.
The job is skipped on a PR from a fork: a fork does not have the release App's key, and a release is never cut from one. The comment is posted by that App, the same account that cuts the pull request.
Merging a release PR pushes VERSION to main, and that is what
.github/workflows/release.yml triggers on. Nothing else touches that
file, and there are exactly two things that can happen to it — a release
(0.2.0-dev → 0.2.0) or reopening the cycle (0.2.0 → 0.3.0-dev) —
so the suffix test is a precise partition rather than a guess.
CHANGELOG.md is deliberately not a trigger. It would work under the
current convention, where entries are generated at release time, but
that is a convention rather than a property: if PRs ever append to it
during a cycle, it becomes a noisy path firing the pipeline on every
merge. VERSION cannot degrade that way.
Five jobs run in order.
Gate (scripts/ci/release-gate.sh) first checks the commit is one
open-release-pr.sh made, by requiring its Next-Version trailer.
Everything else it asks is a property of the tree — no -dev suffix, a
finished changelog entry, no tag yet — and all of those stay true for
any commit merged between a release and the dev-cycle bump. The trailer
is what identifies the commit rather than the state.
It then answers two separate questions.
Eligibility — is this a release at all? A -dev version, or one whose
tag already exists, is not, and the run ends quietly with
release=false. Readiness — is it fit to release? A missing or unfinished
changelog entry fails loudly, because that means this is a release
and it is broken.
Keeping them apart is what lets the gate be cheap on ordinary pushes and
strict on real ones. It also means releasing does not depend on
release-check having blocked the PR: an unfinished release that reached
main anyway stops here rather than shipping.
Build builds and tests at the exact commit the gate approved, not at
whatever main has moved to since. It then packages the tarball with
scripts/ci/release-package.sh, which unpacks the archive and builds,
installs and tests it with this build's own options — so the extension
is proven to install and pass its suite from the tarball, not just to
compile. meson dist writes the .sha256sum beside it.
The install is what makes that real: pg_regress loads the extension
PostgreSQL has installed, not the one in a build tree, so an archive
whose tests run before an install is reporting on whatever was already
there.
Packaging therefore gates tagging: a tarball that would not build fails the build job, and nothing downstream runs. The assets travel to the next job as an artifact, since that job has neither the toolchain nor a build directory.
Tag and create the draft entry (scripts/ci/release-create.sh)
makes exactly two things: the git ref refs/tags/v<version> in this
repository, and one draft GitHub Releases entry against it whose
body is the CHANGELOG.md section for that version. It uploads no
files. A prerelease is flagged as one so it does not become the "latest
release". It refuses unless HEAD is the sha the build job tested — a
release may only ever tag a verified commit.
Attach the assets (scripts/ci/release-publish.sh --keep-draft)
uploads the tarball and its checksum to that draft and reads the upload
back from the API to confirm every file arrived. It stops there.
This repository has immutable releases on. GitHub freezes
a release's assets and its tag at the moment it is published and
rejects every later upload with Cannot upload assets to an immutable release. A release published before its files are attached can
therefore never acquire them, and deleting it does not help — a tag
that has carried an immutable release can never be reused, so the
version has to be abandoned and the next one released instead.
A draft, by contrast, is mutable and invisible, so every failure before the publish is re-runnable and leaves nothing public behind.
Reopen the development cycle (release-dev-cycle.yml, which runs
scripts/ci/release-open-dev-cycle.sh) bumps VERSION to the
Next-Version the release commit declared and opens that pull request.
It runs while the release is still a draft: the tag is already pushed,
so the version is cut whether or not the Releases entry is visible, and
a bump that cannot be opened should not leave behind a published
release that nothing can bump past. See
After a release for what it needs and what happens
without it.
It is a workflow of its own, called by this one, so that it can also be
run alone. Re-dispatching release.yml would reach it as well, but only
after rebuilding and retesting a commit whose release is otherwise
finished — so when the bump is all that is outstanding, running it alone
is the cheaper route. Dispatch Release dev cycle with the
released version; the script reads the next one off the tag's own
commit, the same trailer the gate read, so nothing else has to be
supplied.
It could trigger on the tag instead, now that the App creates it, and deliberately does not: the bump is called from the pipeline so the publish can be ordered after it. A tag-triggered run is a separate workflow run, which nothing in the release run can wait on.
Publish (scripts/ci/release-publish.sh, with no flag) runs last.
It re-attaches the assets, re-reads the list, and flips the draft.
Last because it is the only step with no undo: publishing signs a release attestation over the tag, the commit and the asset digests, and from that moment the assets are frozen and the tag name is spent. Everything reversible — building, testing, packaging, tagging, attaching, reopening the cycle — has already succeeded by the time it runs. Anything that fails earlier leaves a draft, which can be published by hand, re-run, or deleted without spending the version.
The verification runs again in this job rather than being trusted from the previous one, so what is sealed is checked by the job that seals it.
It reports the release milestone's remaining open issues but never closes it: closing a milestone that still has open work is a judgement call.
The release can be held back as a draft instead of published. The tag is still pushed, the assets still attached and verified, the cycle still reopened — only the Releases entry stays unpublished, for someone to read before it goes public. Finishing it afterwards is one click in the Releases UI, or a re-run of the workflow, since nothing downstream is waiting on it.
Three things can ask for that, and the most specific one that has an opinion wins:
| source | scope |
|---|---|
keep_draft dispatch input (yes/no) |
this run |
Keep-Draft: trailer on the release commit |
this release |
RELEASE_KEEP_DRAFT repository variable |
every release |
The dispatch input defaults to default, meaning "no opinion" rather
than "publish", so a dispatched re-run does not silently overrule a
trailer or the variable. Choosing yes or no is the operator
overruling both.
The trailer is how the decision gets made where it is reviewable —
written into the release pull request and visible in its diff, like
Next-Version. Add it to the commit message, or to the last paragraph
of the pull request body, for the same reason Next-Version appears in
both: a rebase merge keeps the commit, a squash merge builds the
message from the body.
chore: release 0.2.0
Next-Version: 0.3.0-dev
Keep-Draft: true
Silence everywhere publishes. Holding a release back is the deliberate act, so it is the one that has to be said.
A release that failed after its push event was consumed can be re-run
from the Actions tab (workflow_dispatch), giving the version. The gate
asserts that the input matches VERSION, so this cannot release a
version the ref does not carry.
Re-running is safe at any stage, because no step trusts a verdict taken before it ran. The gate decides only whether the commit is a release worth making, never whether one has already been made; each step then checks its own work against the repository at the moment it acts. A tag that already points at the tested commit is reused, and one pointing anywhere else is fatal. A draft is resumed. Assets are re-uploaded and read back. An open bump pull request is left alone, and so is a release already published with its assets. The one state no re-run can repair is a release published without them — which is the whole reason publishing comes last.
main now sits at a released version. Until the bump lands, anything
merged is built and tested as a version that has already shipped, so
reopening the cycle is the one thing still outstanding.
VERSION is either a development version or one that has been
released, and which one it is decides what may merge. The Release cycle check enforces it on every pull request to main and to the
release branches:
base VERSION |
what the PR does to VERSION |
verdict |
|---|---|---|
0.2.0-dev |
nothing | pass |
0.2.0-dev |
sets 0.2.0 |
pass — a release starts |
0.2.0-dev |
sets 0.3.0-dev |
fail — no cycle to reopen |
0.2.0 |
sets 0.3.0-dev, alone |
pass — the bump |
0.2.0 |
sets 0.3.0 |
fail — 0.2.0 still in flight |
0.2.0 |
nothing | fail — release in flight |
The bottom three rows are the release window. A candidate opens one
too: 0.1.0-rc1 is a version people can download.
Whether a pull request is a release or a bump is decided by what it
does to VERSION, not by what its branch is called. No branch name
is read at all: a bump is a bump whether the pipeline opened it as
chore/dev-0.3.0-dev or somebody cut it by hand as foo/bar. The
diff is
taken from the fork point, so it is what the pull request carries. Read
against the base tip instead, a branch that predates the release would
show VERSION as changed because the base moved it — and a stale
branch carrying nothing of its own would read as the bump.
Only the transition is checked here. Whether a release pull request is
sound — changelog filled in, no tag yet, no stale version references
— is release-check.sh's job.
The base is read live, so re-running the check can change its answer: red once the release lands, green again once the cycle reopens. Nothing re-runs it when the base moves, and the ruleset does not require branches to be up to date, so a pull request opened before the release keeps its last answer until it is pushed to or re-run. Treat it as a prompt, not a proof.
The release workflow opens that pull request itself. Its dev-cycle
job bumps VERSION to the Next-Version the release commit declared,
pushes chore/dev-<version>, opens the pull request and arms
auto-merge, so it lands as soon as the checks pass and someone
approves. Release cycle asserts that it changes nothing but
VERSION and that the value carries a -dev suffix. The branch name
is the pipeline's own convention for finding the branch again; nothing
checks it, and a bump opened by hand under any name is admitted on the
same terms.
It needs a credential that is not GITHUB_TOKEN, because GitHub
starts no workflow runs from events that token creates: the required
checks would never report and the pull request could not be merged at
all.
That is true of everything the pipeline writes, not just this pull
request, so the whole pipeline writes as the App — the tag, the
Releases entry and this bump alike. Each job mints its own token from
RELEASE_APP_CLIENT_ID (a repository variable — the App's client id,
which actions/create-github-app-token now prefers over the numeric
app id) and RELEASE_APP_PRIVATE_KEY (a secret), scoped to what that
job does: tagging and publishing take contents alone, and only the
bump asks for pull-requests. The gate refuses a release when the
variable is unset, so the run stops before the build rather than at the
first write.
Using the App for some writes and not others is what made the
distinction bite: a tag created by GITHUB_TOKEN triggers nothing, so
no workflow can ever be driven by one. Nothing in this repository
listens for tag or release events today, but the pipeline no longer
forecloses it.
Reopening it by hand. Bump VERSION to the next -dev — the
release commit says which in its Next-Version trailer:
git log -1 --format='%(trailers:key=Next-Version,valueonly)' origin/mainThen open a chore/dev-<version> pull request with that one change.
Minor and major releases happen on main. Patch releases happen on an
X.Y.x maintenance branch, where prepare-release.sh defaults the next
development version to a patch bump instead of a minor one.