Release process
How a change on a feature branch becomes a versioned Cloud release with images, CLI, website, PWA, and npm packages.
Cloud releases from main. Nobody edits version numbers or the changelog by
hand; release-please derives them from squash-commit titles.
Branch and merge
main is protected. Work on a feature branch, open a pull request, and let the
gate check pass. A green pull request enters the merge queue, which runs the
gate once more on the exact merge result and squash-merges it, so the PR title
becomes the commit on main. main itself is not verified again; main.yml
starts straight from the push.
The title is a Conventional Commit of
the form type(scope): outcome:
| Title | Next release |
|---|---|
fix(mail): keep list subscriptions after provider restart |
patch |
feat(grids): add column formulas |
minor |
feat(core)!: require the identity KEK at startup |
major |
docs: …, chore: …, refactor: …, test: …, ci: … |
no release on its own |
A BREAKING CHANGE: footer in the PR body has the same effect as !. Use
either only with explicit maintainer approval, because it moves every
application image to a new major version.
release-please copies commit descriptions and BREAKING CHANGE: or
BREAKING-CHANGE: paragraphs into the release PR body and parses it as HTML.
Write placeholders without angle brackets: use DIR or {dir} instead of
<word>, even inside backticks. The check job reads the live PR title and
body on the PR and again in the merge queue. It refuses HTML-like markup in
the title, Conventional Commit header lines in the body, breaking-change
paragraphs, and BEGIN_NESTED_COMMIT or BEGIN_COMMIT_OVERRIDE blocks
(#1298). This cheap check
covers the usual forms of release-please's commit grammar, not every one; the
release-body job below parses with release-please itself. After fixing the
text, re-run the failed check job to check it again.
The check job reads the body as written, but the squash merge writes it
wrapped at about 72 columns, and wrapped text can parse differently. A line
that now starts with a word directly followed by an opening parenthesis,
such as `color-mix(, can make release-please drop the whole commit
without a warning. A line that now starts with a word and a colon reads as a
second commit (#1592).
Before a release pull request merges, parse every squash commit since the
last release with parseConventionalCommits from the release-please version
that main.yml pins. Restore a dropped feat or fix with an override pull
request, as for an advisory merge below.
A change to a runtime contract (configuration, routes, permissions, data
format, deployment) needs a feat or feat! title and a note on the matching
Operations page. It lands in the next cloud-vX.Y.Z
automatically.
When a pull request for a built-in app shows screenshots of the UI, take them in light mode; use dark mode only when the change is about dark rendering. Use demo data, never a person's real data, and show only the states that changed or that a decision depends on. Pull requests are public, and one baseline theme lets reviewers compare screenshots across changes.
Refresh the bundled location snapshot
The image mirror keeps every pinned DB-IP month, so builds and rebuilds of old
releases do not depend on db-ip.com. To ship newer data, run
bun scripts/geoip-snapshot.ts <YYYY-MM> and review the Dockerfile pin and
sign-in location snapshot details.
The pull request's mirror job copies the new month to the mirror. Merge it
while DB-IP still serves that month: the current or previous month.
Let release-please cut the release
release-please runs on every push to main:
- It opens or updates one release pull request per component. The PR bumps
versionfields, updatesCHANGELOG.md, and records the new version in.release-please-manifest.json. - Merging the release PR creates the tag. The Cloud component tags
cloud-vX.Y.Z; the npm packages tagnpm-cloud-vX.Y.Z,npm-ui-vX.Y.Z, andnpm-ssr-vX.Y.Zas separate components with their own version lines. - No workflow starts from these tags. The
main.ymlrun that created them continues: itsimagesjob builds every image of the release set once (one per application fromscripts/workspace.tspluscloud-website,cloud-pwa-auth, andcloud-notes;bun scripts/workspace.ts imageslists them) forlinux/amd64only, attests its build provenance, and tags its image indexsha-<12>, plusvX.Y.Zfor a Cloud release.main.ymlthen callsrelease.yml. For a Cloud release, it reuses these images instead of building them again, builds the CLI binaries, and attaches the binaries andrelease.jsonto thecloud-vX.Y.ZGitHub release. - For each new
npm-cloud-v*,npm-ui-v*,npm-ssr-v*, ornpm-editor-v*tag,release.ymlpublishes@k2b/cloud,@k2b/ui,@k2b/ssr, or@k2b/editorwith npm provenance. A prerelease version such as0.8.0-rc.1publishes under thenextdist-tag; a stable version publishes underlatest.@k2b/cloudpins exact@k2b/ui,@k2b/ssr, and@k2b/editorversions, so it publishes only after all three are installable from npm. When the same release publishes any of them, the workflow waits up to 20 minutes in total; otherwise the pinned version must already be on npm, or@k2b/cloudstays unpublished and the release fails.@k2b/editorpins an exact@k2b/uiversion and waits for it the same way.
The release PR runs the packed check with --release, including in the
merge queue, to test already published sibling pins from npm and versions
this release publishes from their tarballs. A published sibling pin equal to its
workspace version must also have the same shipped TypeScript surface. A new Cloud
release fails clearly if that source changed without a version bump. A checkout
whose Cloud version is already published tests changed snapshot sources from
current tarballs instead. See
What CI runs.
The comparison checks TypeScript file bytes, including shipped comments and
compiler output. To resolve a mismatch, refresh a stale release PR or land a
scoped fix/feat commit for the affected package so release-please bumps it.
Do not edit versions manually.
The release-body job checks the release PR and its merge queue run. It parses
the live PR body with the release-please version and dependency tree pinned by
the action in main.yml. It fails when a bumped component is missing, has a
different version, or has swallowed another release section into its notes.
When this guard fails on an open release PR, the cause is already in a commit
on main. The maintainer must edit the release PR body as described below,
right before merging: release-please rewrites the body on every push to
main. Re-run the failed job after saving the body; a body edit does not
start CI.
Merge the release PR only after release-please has refreshed it after the last
feature PR merged. Check that its parent is the current main:
git fetch origin && test "$(git rev-parse origin/release-please--branches--main^)" = "$(git rev-parse origin/main)"If it differs, wait for the release-please job of the latest main.yml run.
In #1221, a stale release PR
left @k2b/ui unbumped, so published Cloud code imported an export its pinned
UI version did not have.
Each package publishes through an npm Trusted Publisher for k2b-dev/cloud
with the workflow file main.yml. Configure it for a new package, such as
@k2b/editor, before its first release. release.yml runs the publish, but it is a
reusable workflow, and npm checks the workflow that calls it.
release-please updates exact pins between workspace packages but leaves peer
ranges alone. @k2b/ui pins @k2b/ssr as a development dependency, so every
@k2b/ssr release also cuts a @k2b/ui patch release. @k2b/ui also
declares @k2b/ssr as a peer range, so a release PR that moves @k2b/ssr
outside it fails the dependencies check. Widen the range in a normal pull
request on main first; the release PR then picks it up.
The image builds take their base images, the Dockerfile frontend, BuildKit, and
the SBOM generator from the public mirror ghcr.io/k2b-dev/mirror at the
digests pinned in .github/mirror-images.txt, never from Docker Hub, so a
release does not depend on Docker Hub's limit for anonymous pulls. The
provenance attestation names the mirror reference with the same digest as the
Docker Hub image. See
Where CI images come from.
release.json lists every image with its tag and digest. Deployments pin the
vX.Y.Z tag or the digest from that file; see
Build and deploy.
Every push to main also builds sha-<12> images for staging. Those tags are
not releases: they carry no changelog entry and no support commitment.
Release a fix merged from a security advisory
A fix built in a private security advisory fork reaches main when the
maintainer merges it on the advisory page. GitHub writes one commit titled
Merge commit from fork, without a pull request in this repository. That
title is not a Conventional Commit, so release-please drops the whole commit
without a warning: its fixes, features, and BREAKING CHANGE: footer reach
neither the changelog nor the version bump. The check job never sees it,
because no pull request carries it.
The advisory's pull request compares the fork branch with the fork's own
main, which GitHub does not keep current, so it can show no conflict while
the branch conflicts with main. Before the maintainer merges it,
fast-forward the fork's main to main, merge main into the fork branch,
push both to the fork only, and check that
git merge-tree --write-tree origin/main HEAD reports no conflict.
Before asking the maintainer to merge, check the advisory page and the current
main commit: the fix may already have been merged there even if an earlier
review still says it is pending. Record the actual resulting commit and parse
its message; when it contains only Merge commit from fork, use the verified
private pull request title for the override entry.
Before the release, open a small pull request with a real change and put a
BEGIN_COMMIT_OVERRIDE block in its body. release-please then reads the
block instead of that pull request's own commit message:
- List the pull request's own entry and the advisory commit's own
user-facing entries, with each
BREAKING CHANGE:footer copied exactly. The advisory commit's body also repeats the history that the fork branch merged frommain, including earlierBREAKING CHANGE:footers. Parse it and leave those repeated entries out, or they appear twice. Put an entry with!first: release-please starts a new entry only at a header without!after an empty line, so a later!header joins the entry before it. - Change a file in every component the advisory commit changed, for example
under
packages/cloud/when it changed@k2b/cloud. release-please gives the override only to the components of the pull request's own files. - When several advisory commits changed different components, use one
override pull request per set of components. The override gives every
entry to all components of its pull request, so a Mail-only fix in the
same block as an
@k2b/cloudfix would also appear in the@k2b/cloudchangelog. - Write the marker only once, where the block starts: release-please takes
the text after the first
BEGIN_COMMIT_OVERRIDEin the body, also when that first one stands in a sentence. - Keep the block and the rest of the text free of HTML-like markup, and name the behavior, not the vulnerability, until the advisory is published.
After the merge, check that the refreshed release PR lists the advisory
entries under each component, and run
bun scripts/check-release-pr-body.ts --pr <n> on it. Publish the advisory
after the release, with the released @k2b/cloud version as the first
patched version.
Recover a release that release-please skipped
This is a maintainer procedure. Agents never perform these steps:
AGENTS.md forbids agents from creating tags or triggering releases.
For cloud-v0.44.0, HTML-like placeholders swallowed the Cloud section of
release PR #1248, so release-please created only the npm releases
(#1298). Recover a skipped
release in this order:
Edit the merged release PR body. Replace each
<that starts markup in the release notes with&lt;; keep the structuraldetailsandsummarytags. The GitHub release then shows<. Do not use<: release-please decodes it to<, and the GitHub release hides the text as an HTML tag. Check the edited file before saving it on GitHub:bun scripts/check-release-pr-body.ts --pr <n> --body <file>After saving, run
bun scripts/check-release-pr-body.ts --pr <n>to check the live body.Change the PR label from
autorelease: taggedback toautorelease: pending.Push a branch pointing at the release commit and dispatch
main.ymlon it:git push origin <release-sha>:refs/heads/chore/recover-release-X-Y-Z gh workflow run main.yml --ref chore/recover-release-X-Y-Zrelease-please creates the missing tag and GitHub release at that commit and skips existing releases as duplicates. The run builds images from the release commit. CLI asset signing fails on this branch because its signature identity must use
refs/heads/main. If therelease-pleasejob still logsPull request contains releases, but not for component, it has set the label toautorelease: taggedagain: check the live body, repeat step 2, and dispatch again. The first 0.44.0 attempt needed this.Finish the release from
main:gh workflow run main.yml --ref main -f version=X.Y.ZThis checks out the release tag and publishes the CLI assets and
release.json.Delete the recovery branch:
git push origin --delete chore/recover-release-X-Y-Z
Verify the CLI assets
The GitHub release for cloud-vX.Y.Z also carries the cld binaries
(cld_darwin_arm64, cld_darwin_x64, cld_linux_arm64, cld_linux_x64),
the cloud-cli-skill.tar.gz skill archive, and checksums.txt with the
SHA-256 of each. Current cld versions embed the skill and ignore the
archive; it stays published for installed cld versions that still download
it. The release workflow signs checksums.txt keylessly with
Cosign and publishes the Sigstore bundle checksums.txt.sigstore.json. Verify
it with Cosign 2.4.2 or newer:
cosign verify-blob \
--bundle checksums.txt.sigstore.json \
--certificate-identity-regexp '^https://github\.com/k2b-dev/cloud/\.github/workflows/release\.yml@refs/heads/main$' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
checksums.txt
sha256sum --check --ignore-missing checksums.txtThe installer and cld update run the same check whenever cosign is on
PATH; SHA-256 is always checked. Releases published before the bundle carry
only a detached signature checksums.txt.sig and certificate
checksums.txt.pem. The installer and cld update use these only when a
release has no bundle. New releases still publish both files, taken from the
bundle, so already installed cld versions keep updating; they will be
dropped in a later release.
Ship a hotfix
Branch from the release tag, not from main:
git switch -c fix/mail-subscriptions cloud-v0.7.0Open the pull request against main as usual. release-please includes the fix
in the next patch release. If main already contains unreleased minor changes,
the fix ships with them; a separate patch line for an old minor is not
maintained.
Roll back
Pin the previous vX.Y.Z or its digests from that release's release.json,
then run the fleet preflight against the running installation:
CLOUD_IMAGE_TAG=v0.7.0 bun run release:preflightThe preflight needs CLOUD_IMAGE_TAG, SYNC_NAMESPACE, CLOUD_CORE_URL, and
CLOUD_ADMIN_TOKEN in the process environment. Check migration compatibility
before rolling back across a release that changed the database; the changelog
marks such releases.
Do not
- edit
versionfields,CHANGELOG.md, or.release-please-manifest.json; - create, move, or delete tags;
- push to
maindirectly; - re-run a release for a tag that already exists, except for the maintainer recovery procedure above. Otherwise, fix forward with a new patch release.