Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
11 KiB
Release Process
This document describes how PicPeak releases are cut. It's the maintainer's reference, not user documentation — for the user-facing channel choice (stable vs pre-release) see the Release Channels section in README.md.
TL;DR
mainbranch receives all merged work (active development). Every push triggers arelease-pleasePR that proposes the nextvX.Y.Z-beta.Npre-release. Merging that PR tags the pre-release and publishes Docker images under the:mainrolling tag + the version-specific tag.stablebranch holds the curated stable channel. Stable releases are cut from a known-goodmainpoint via arelease/X.Y.Z-merge-from-mainbranch and a manual PR tostable. Merging that PR triggersrelease-pleaseto propose the stable release.- Target cadence: a stable release every 4–6 weeks, or sooner if
mainhas been quiet and ready for promotion.
Branch model background —
main(active dev) was previously calledbeta, andstable(curated channel) was previously calledmain. The rename happened with #669 to match the convention every other open-source project uses. The mechanics below all reference the post-rename names.
Cadence target
4–6 weeks between stable releases is the working target. Reasoning:
- Long enough that each stable carries meaningful changes worth the upgrade burden.
- Short enough that pre-release users aren't carrying the "real" project alone for months — the stable channel should actually be usable as the recommended channel for new installs.
- Aligns with how release-please surfaces pre-releases (multiple pre-release points usually accumulate inside a 4–6 week window, which gives natural promotion candidates).
This is a target, not a hard rule. Cut sooner if main has been quiet and stable longer than usual. Cut later if main is in flux for security or migration reasons.
Promotion criteria
A main tip is eligible for promotion to stable when all of the following hold:
- CI green on the candidate
maintip. Specifically:schema-drift(upgrade-from-bootstrap),fresh-install,Tests(backend Jest + frontend Vitest), the fourBuild and Push Docker Imagesarch matrices, andGitGuardian Security Checks. - No open
bug-labelled issues against the candidate for at least 7 days. Issues fixed-but-not-yet-closed count as fixed; verify their PR is in the candidatemaintip before closing them out. - An upgrade walk has been done on real production-shaped data — apply the candidate's migration chain to a snapshot of the previous stable's DB and verify no manual intervention is required. CI proves fresh-install works; the upgrade walk is what proves the upgrade path works.
- Operator-time smoke on the candidate: log in, create event, upload photos, share gallery, open as a customer, log out. Catches binary-incompatibility regressions and UI-level breaks that unit tests don't see.
If any of the four fail, the promotion waits. File any blockers as bug-labelled issues and let them bake on main before re-evaluating.
How a stable release is cut
The actual mechanics, in order:
-
Pick the
maintip. Confirm it satisfies the four promotion criteria above. Note the exact SHA — that's what you're promoting. -
Create the release branch from the
maintip.git push origin <main-tip-sha>:refs/heads/release/X.Y.Z-merge-from-mainNaming convention:
release/X.Y.Z-merge-from-main, whereX.Y.Zis the stable version you intend to land. release-please will write the actualX.Y.Zon merge — the branch name is just a human label. -
Open a PR to
stable. Title:chore(release): promote main → stable as vX.Y.Z. Body should summarise the major themes since the previous stable, the migration count, and any operator notes (e.g. "this release adds 22 migrations; existing installs should snapshot before upgrading"). See PR #568 as a worked example (predates the rename; the mechanics are unchanged). -
Resolve conflicts.
stablealmost always has commitsmaindoesn't (security backports, release-please's stable-channel release commits, README rewrites). For each conflicting file, decide deliberately:backend/package.json/package-lock.json+frontend/package.json/package-lock.json— usually takemain's version (superset), but verify any security-pinned deps (axios,nodemailer,i18next-http-backend,multer,tar) onmainare>=the pinned versions onstable. Ifstablehas a newer pinned version (e.g. an emergency CVE backportmainhasn't picked up), takestable's pin.README.md— keepstable's version if it has had a recent rewrite thatmaindidn't pick up; otherwise takemain's.CHANGELOG.md— keepstable's; release-please regenerates entries on its next stable cut from the commits going forward..release-please-manifest.json— keepstable's; release-please owns this file.- Any other auto-merged file — spot-check that the auto-merge produced something sensible, especially for security-sensitive files (
backend/src/middleware/,backend/src/utils/tokenUtils.js).
-
Pin the stable version to match
main(number alignment — see Versioning). DetermineX.Y.Z= themaintip's base version (itsvX.Y.Z-beta.N, dropping the-beta.Nsuffix), and add an empty commit on the release branch:git commit --allow-empty -m "chore: release X.Y.Z" -m "Release-As: X.Y.Z"The
Release-As:footer forces release-please to cut exactlyX.Y.Z. Without it, release-please computes the next MINOR from the previous stable tag (e.g.3.45.0→3.46.0) whilemainis already at3.84.x, so the stable number drifts ever further behind for the same code. -
Wait for CI on the PR. All ten checks (the original eight plus
merge-backendandmerge-frontend) must be green. If anything fails, fix on the release branch (NOT onmain—mainhas already moved on). -
Merge. Standard merge commit, not squash — the PR's history (the individual feature commits) carries forward into
stable's log. -
release-please picks it up. Within minutes, release-please will open a new
chore(stable): release X.Y.ZPR proposing the stable release. Review the auto-generated CHANGELOG.md entries for accuracy, edit if needed, and merge. That merge creates thevX.Y.Zgit tag, publishes Docker images on the:stableand:latesttags, and creates the GitHub Release page. -
Close the loop. Bulk-close any
bugissues that were fixed-but-not-closed and now appear in the released changelog. Reference the merge commit so reporters know which version contains the fix.
Hotfix path (backport to current stable)
Regular bug fixes are generally backported automatically from main to stable. Keep backports focused on the fix, without unrelated features, and resolve conflicts manually when needed.
Security fixes are always released on both stable and main. Do not wait for a full promotion to deliver a security update. A fix first applied to stable must also be forward-ported to main; a fix first applied to main must also reach stable. See SECURITY.md for the support policy.
When a backport needs manual handling:
- Create a
security/cve-backport-X.Y.Zorfix/critical-X.Y.Zbranch offstable. - Cherry-pick or hand-write the minimal fix.
- Open a PR to
stablewith the smallest possible diff. - After merge, release-please will propose a patch-level stable release (e.g.
v3.55.1). - Forward-port the fix to
mainif it isn't already there. Otherwise the next full promotion will reintroduce the bug. - For security fixes, verify that the fix has been published through both release channels; merging the code is only part of delivery.
PR #412 ("backport 18 dependency CVE patches from beta") is a worked example of this path (predates the rename; the mechanics are unchanged).
Versioning
PicPeak follows Semantic Versioning with one project-specific convention:
- MAJOR bumps are reserved for breaking schema changes that require operator action on upgrade (e.g. a migration that's not safe to auto-apply, an env-var rename that can't be auto-detected).
- MINOR bumps for new features, additive schema changes, and any change to the public HTTP API surface.
- PATCH bumps for bug fixes and operator-invisible internal changes.
- Pre-release suffix (
-beta.N) for everymain-channel cut; theNcounter resets on each new MINOR or MAJOR target. The suffix kept the historical-betaliteral even after the branch rename — operators were already pinning tov3.x.y-beta.Nand changing the literal would have broken those pins.
release-please derives all of this from conventional commit prefixes (feat:, fix:, BREAKING CHANGE:, etc.) automatically.
Stable ↔ pre-release number alignment
The two channels run independent release-please counters: main bumps on every merge (racing ahead), while stable only bumps on a promotion. Left to itself, stable computes each promotion as the next MINOR from the previous stable tag, so the two drift far apart — e.g. main at v3.83.x-beta.0 while stable sat at v3.45.0 for the same code, which reads as "stable is 38 versions behind" when it isn't.
To keep the numbers legible, a promotion sets the stable version to the current main base version (the X.Y.Z of the main tip's vX.Y.Z-beta.N, minus the suffix). Promoting a main at v3.84.2-beta.0 therefore cuts stable v3.84.2, and the stable number tracks main instead of lagging. This is forced with the Release-As: commit in step 5 of the cut procedure — the one-time catch-up jump (e.g. 3.45.0 → 3.84.x) is expected and happens only on the first aligned promotion.
Release-engineering note (2026-07):
release-please.yml(the stable workflow) must keeptarget-branch: stable. Without it, release-please defaults to the repo's default branch (main), readsmain's stale.release-please-manifest.json, and cuts a wrong/regressed version — this is what produced a bogusv2.7.0once. If a promotion ever yields an unexpected version, check that first.
Things that don't go through this process
- Documentation-only changes can land on either
stableormaindirectly (no release cut needed); release-please will pick them up on the next regular release. - Test-only changes — same.
- CI / workflow changes — same, but be aware they take effect on the branch they land on, so a CI fix targeting
mainwon't fix a broken stable-channel workflow until the next promotion.
When this doc is wrong
If you find yourself working around something here, update the doc before doing the workaround. The point of a written process is that future-you doesn't have to remember the workaround.