Skip to content

Latest commit

 

History

History
667 lines (538 loc) · 30.4 KB

release.md

File metadata and controls

667 lines (538 loc) · 30.4 KB

Releasing pg_vectorsearch

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 .sha256sum attached to it, and the pull request that reopens the development cycle. Announcing a release is still to come — see After a release.

Versioning

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.

Releases with a new on-disk format (breaking change)

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.sh writes 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.

Cutting a release PR

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.sh

It 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-files

That 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.

Opening the pull request

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.

Re-cutting a version

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 next development version

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.

The release check

.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 VERSION cannot read as a development version and skip everything below;
  • no v<version> tag exists yet, locally or on origin;
  • the PR is a single commit;
  • CHANGELOG.md has an entry for it with no FILL-IN left;
  • README.md and docs/ 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.build lists it in ext_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.sh

Building from the tarball before merging

When 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.

What merging a release PR creates

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.

After a release

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.

The release cycle

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/main

Then open a chore/dev-<version> pull request with that one change.

Branches

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.