Release Workflow (AtomiqFlow)¶
This is the sole authoritative cross-repository contract for commit classification, semantic versioning, and how code reaches production at Shootify across backend, webapp, and compute-server. New here? Read Getting started, Commit and version contract, then Shipping a feature. The rest is reference for releases and edge cases.
Why this workflow exists¶
The old dev → staging → main flow had five recurring pains. AtomiqFlow targets each:
- 🩹
dev/stagingpoisoning — one bad commit blocked everyone. → Previews validate before merge; the trunk stays clean. - 🪟 QA tested a moving target — staging shifted under their feet. → The release PR freezes staging on demand (DRAFT → READY).
- 🤷 Release managers flew blind — "what am I about to ship?" → The release PR body lists exactly what's queued.
- 🔎 Prod was hard to audit — "what was running when X broke?" → Every prod deploy gets a
prod-…timestamp tag. - 🧪 No real env to validate against except by merging. →
cliqpreviews give every feature a full-stack environment.
The model at a glance¶
Whenever changes are queued, there is exactly one release PR — base main, head release/next. It lives in two states:
- DRAFT — accumulating. Every merge to
devfast-forwardsrelease/next, so staging tracks the trunk. The PR body grows with each change. - READY — frozen.
release/nextstops moving; staging holds a stable candidate for QA. Merging the PR ships it to prod.
The release manager flips DRAFT ↔ READY. That toggle is the whole control surface.
Environments & branches¶
| Branch | Deploys to | Who writes |
|---|---|---|
feature/* |
Its own preview | 👤 The author |
hotfix/* |
Its own preview | 👤 The author (branched from main) |
dev |
— (no env) | 🤖 PR merges only |
release/next |
Staging | 🤖 Automation (while DRAFT) · 👤 Release manager (the merge) |
main |
Prod | 🤖 Approved release-PR or hotfix/* PR merges only |
Two deployed environments — staging and prod. There is no shared dev environment; previews cover per-change testing. Humans push only to feature/* and hotfix/*; protected branches move through approved PRs, automation, and the draft toggle.
Getting started¶
Before your first feature:
- Access — membership in the
shootify-ioGitHub org, an SSH key on your account, andgh auth login. - Install cliq (drives the preview environments):
- Learn the two conventions used across the repositories:
- Branch name:
<ISSUE-CODE>/<short-slug>— e.g.SAR-1234/forgot-password. cliq finds your preview by the<ISSUE-CODE>prefix, so the prefix must match across every repo you touch. - PR title and commit header: use
type(scope)[!]: subjectexactly as defined in Commit and version contract. CI enforces the PR title rules for feature PRs intodevand hotfix PRs intomain.
Commit and version contract¶
This section is the only normative source for commit headers and semantic-version derivation across the three repositories. Other pages may summarize an invariant but must link here rather than redefine the policy.
Header format¶
The brackets show that ! is optional; do not include the brackets in a real header.
- Use exactly one of the 11 types below.
- The scope is required, matches
[a-z0-9-]+, and identifies the smallest useful affected area. - The subject starts with a lowercase letter, uses the imperative mood, and has no trailing period.
- A PR title is at most 72 characters, including type, scope, optional
!, colon, and subject. CI enforces this limit; keep commit headers equally concise. - Put the
!marker immediately after the required scope whenever the final change makes an incompatible public contract change. An exclamation mark elsewhere is not the marker. This requirement is independent of type:feat(api)!,fix(auth)!, andrefactor(events)!can all be correct. - A
BREAKING CHANGE:footer explains impact and migration. It is explanatory only: it does not replace!and does not trigger a major version by itself.
The subject is not required to be entirely lowercase. Only its first character must be lowercase, so names such as JWT, WebSocket, and R2 may retain their normal casing later in the subject.
Exact type semantics¶
| Type | Use only when the primary effect is... |
|---|---|
feat |
Introducing or expanding functionality. |
fix |
Correcting defective behavior. |
perf |
Improving runtime performance without changing intended behavior. |
refactor |
Restructuring implementation without fixing a defect or changing intended behavior. |
docs |
Changing documentation only. |
test |
Adding or correcting tests without changing production behavior. |
build |
Changing the build system, packaging, or dependency management. |
ci |
Changing CI/CD workflows or their automation. |
chore |
Performing repository maintenance that fits none of the other types and does not change product behavior. |
style |
Changing formatting or whitespace only, with no behavior change. |
revert |
Reversing a previous change. |
Classify the primary production effect of the complete final diff. Tests, documentation, migrations, generated files, or other supporting edits inherit that classification rather than overriding it: a feature with tests and docs is still feat, and a bug fix with a regression test is still fix. Dependency changes are build; workflow changes are ci, not a generic chore.
Split unrelated releasable effects into separate changes. Do not hide an independently releasable fix, feature, or incompatible contract change inside another header.
Final-diff rule for people and agents¶
Choose the header only after inspecting the actual final diff that will be committed or squash-merged. In particular, agents must not infer the type, !, or version bump from an issue or ticket, branch name, commit history, user request, or requested bump. Those are context, not evidence of the implemented effect.
Before proposing a header:
- Inspect the final included diff and identify its primary effect.
- Identify every affected public contract surface.
- Split unrelated effects where practical.
- Apply
!if any included surface is incompatible, then choose the type independently.
Public contract surfaces¶
Public means consumed outside the changed implementation boundary, not merely public on the internet.
| Repository | Contract surfaces include | Compatible example | Incompatible example |
|---|---|---|---|
backend |
HTTP and WebSocket endpoints, request/response schemas, status and auth behavior, emitted events, persisted data formats, and externally consumed configuration | feat(api): add batch product archive endpoint |
refactor(api)!: rename product response field |
webapp |
Durable interfaces consumed outside the newly deployed bundle: externally used routes/queries, persisted browser state, externally called webapp endpoints, machine-consumed exports, deployment configuration, and shared exports with proven external consumers. Ordinary UI/product changes and new backend requirements are not breaking; backend dependencies require release coordination instead. | feat(editor): add comparison mode |
refactor(routes)!: remove emailed shooting links without a redirect |
compute-server |
HTTP and task payloads, queue/routing names, event schemas, provider interfaces, artifact formats, and externally consumed configuration | feat(tasks): add background removal task |
refactor(events)!: rename prediction output field |
An internal implementation change that preserves these surfaces does not need !. An incompatible change to any listed surface requires ! even when the type is not feat.
Semantic-version derivation¶
Each repository is versioned independently from its own eligible commits, but all repositories use the same mapping:
| Eligible implemented change | Version bump |
|---|---|
Any valid ! marker immediately after the scope |
Major |
Otherwise, any feat header |
Minor |
| Otherwise | Patch |
Only tags matching vMAJOR.MINOR.PATCH exactly are semantic baselines; each numeric component is 0 or a positive integer without a leading zero. The calculator selects the numerically highest strict tag and ignores deployment tags such as prod-*, prereleases, malformed versions, and other labels.
The one-time first-release baseline is repository-specific: backend and webapp bootstrap at v1.0.0, while compute-server intentionally bootstraps from project.version in pyproject.toml, currently 0.1.0, so its first strict tag is v0.1.0. This baseline difference does not change shared bump semantics. Later releases inspect non-merge commits since the highest strict tag, tolerate only GitHub's generated (#N) squash suffix, and fail if an included subject does not conform. The highest eligible effect sets the bump: ! before feat, then patch. A direct hotfix's reviewed PR title participates as a bump floor because its merge commit is excluded. All three repositories implement this equivalent semantic release workflow independently.
The docs repository follows the same human-authored header rules, but it is not one of these independently versioned service release workflows.
Shipping a feature¶
Concrete example: "add a forgot-password button," tracked as SAR-1234.
- Branch from
dev:git checkout dev && git pull && git checkout -b SAR-1234/forgot-password. - Write the code. Iterate locally.
- Push, then spin up a preview: push your branch on every repo you touched, then
cliq preview create SAR-1234. cliq stands up a 3-repo stack (backend · webapp · compute-server) with its own DB, bucket, and URLs. Repos without a matching branch fall back todev. (Un-pushed commits aren't in the preview — commit and push first, always.) A preview is a fullcliq previewenvironment with its own flags, timers, and lifecycle — skim that reference once before your first one. In particular, if your feature needs a new feature-tier env var, set it before youcreate— a preview pins an immutable env snapshot at create time, so a variable added afterwards won't reach the running stack until you rebuild. Set it withcliq preview env set MY_FLAG_URL https://… --service backendfirst; details in per-preview env values. - Validate on the preview. This is where you test against a real environment — there's no shared
devenv anymore. Push more commits, thencliq preview rebuild SAR-1234to fold them in (the DB and bucket survive the rebuild). If you only changed a feature-tier env var rather than code,cliq preview recreate SAR-1234re-snapshots env without a full rebuild. The complete validate-loop toolkit —rebuild,recreate,logs, andextend/holdfor keeping a preview alive through a demo — is in Preview Environments → Lifecycle. - Open a PR targeting
dev, mark it ready, request review. Use a title that follows the commit header contract. CI validates the title and reviews the complete feature diff for incompatible public contract changes. - Get review approval. The preview URL lets reviewers poke at the running feature.
- Squash-merge to
dev. One commit per PR keepsdeva clean linear trunk and revert mechanics simple.cliq preview destroy SAR-1234when done. - Verify on staging. While the release PR is DRAFT — the normal, default state — every merge to
devauto-mirrors torelease/nextand redeploys staging within minutes. Outside of a frozen release, what lands ondevis on staging almost immediately. Go exercise your feature there at least once: staging is the first place it runs alongside everyone else's merged work, against staging's prod-cloned data, and some bugs only surface in that combination (data shape, migration ordering, cross-feature interaction). Fix anything you find the normal way — another PR ontodev, or a revert PR (see Edge cases) if it's serious enough to pull back — before the release freezes for QA.
"Done" = merged to dev and validated on staging. The release plumbing from dev to prod is the system's job, not yours — you don't babysit the freeze, the merge commit, or the prod deploy, and if something downstream traces back to your PR you'll be looped in. But confirming your own feature actually works on staging is the one part that stays yours. A feature that merged but was never tried on staging isn't done — it's just queued, and the first person to discover it's broken will be QA on a frozen candidate, which is the expensive place to find out.
Contract review gates¶
- Feature PRs into
devand hotfix PRs intomainreceive the same final-diff contract review. If an incompatible public contract is found, the title must carry!after its scope; urgency does not preclassify a hotfix asfixor patch. - When the rolling
release/next -> mainPR becomes READY, an aggregate release review inspects the complete candidate for cross-change contract breaks and deployment blockers.
Reviewing a feature¶
Most of the time the author hands you a preview URL on the PR and you just click it. Two cases make you stand one up yourself:
- No preview attached — the author already ran
destroy, or never created one. - The branch doesn't follow
<ISSUE-CODE>/<slug>— most often afeature/<something>branch from a non-dev who vibe-coded a change and opened a PR. cliq resolves a preview by matching the code prefix (SAR-1234*) against branch names onorigin, so a branch with no issue-code prefix can't be found by code alone — you have to point cliq at it.
For a conforming branch, the code is the branch prefix, so cliq preview create <code> is enough:
For a non-conforming branch (e.g. feature/instant-checkout on PR #157), invent a canonical code for the preview yourself and name the branch explicitly:
- The code (
review-157) is just the preview's handle — it only has to be canonical ([a-z0-9-], leading letter). Deriving it from the PR number keeps it memorable and collision-free. --branchoverrides branch resolution for all three repos. If the change spans repos on differently-named branches, use--branch-<repo>per repo; repos with no override fall back todev.
From there it's identical to the author's flow — same URL shape, same cliq preview logs / rebuild, same teardown — so you can poke at the running feature, not just read the diff. Destroy it when you're done (cliq preview destroy review-157): review previews are easy to forget and each one holds a host lease. Full command and flag reference: Preview Environments.
The release lifecycle¶
While DRAFT — staging is the live integration trunk¶
The release PR sits in DRAFT by default. On every merge to dev, automation:
- Fast-forwards
release/nextto matchdev. - Projects the next strict semantic tag, then updates the generated release PR title (
Release vMAJOR.MINOR.PATCH) and body. The body usesgit log --no-merges, so it lists only eligible changes, never previous-release or back-merge commits; for example,- feat(auth): add forgot-password (#142) — SAR-1234. - Redeploys staging.
So staging continuously reflects the trunk, and the release PR always shows what's queued. No human action needed.
READY — freeze, QA, ship¶
When the release manager decides to ship, they mark the release PR Ready for review:
release/nextfreezes — automation stops mirroringdev, so staging holds a stable candidate. Newdevmerges accumulate for the next cycle; they don't disturb QA.- The aggregate release review checks the complete candidate, then QA validates the frozen staging.
- The release manager merges with "Create a merge commit". It must be a merge commit — not squash, not rebase: a merge commit makes
main's content identical torelease/nextwhile preserving its commits at their original SHAs. Squash/rebase would rewrite history and break hotfix reconciliation. - Automation tags that merge commit with the projected strict semantic version, publishes the release notes, deploys prod, and creates a
prod-YYYY-MM-DD-HHMMSSaudit tag. (Today prod rebuilds frommain, whose content equals the tested candidate; promoting the exact tested image digest is a planned hardening, since thepython:3.12base image floats.) - The next merge to
devopens a fresh DRAFT release PR for the next cycle.
The automation-generated Release vMAJOR.MINOR.PATCH PR title is exempt from the human-authored title format. Generated merge commits are structural metadata and are also exempt from the commit-header format. Release calculation excludes merge commits; it does not exempt ordinary authored commits.
Who is the release manager? A role, not a person — rotating, on-call, or whoever ships first that day. Only requirement: merge rights on main and accountability for the release PR's contents.
Cadence & the 48h rule. Ship as often as you like — small batches keep QA fast. But a release that's been frozen (READY) for more than ~2 days should either ship or be flipped back to DRAFT. Don't let a frozen candidate linger and starve the trunk.
Edge cases¶
The flow above is the 95% path. The rest is what makes or breaks the workflow in practice.
One scenario runs through every example below. Three features have merged to
dev, in this order, and are queued on the current release PR:
#142—feat(auth): add forgot-password— theSAR-1234feature from Shipping a feature above. (This is the same#142you saw in the DRAFT release-PR body earlier.)#143—feat(account): add password-strength meter— merged just after it, and it reuses a validation helper that#142introduced.#144—feat(account): show last-login time— independent of both.The release manager has marked the PR READY, so
release/nextis frozen and staging holds these three for QA. Then QA hits a problem in#142. Every edge case below traces that one thread — so when you see#143/#144, they're these two PRs.
QA finds a bad commit while frozen¶
The PR is READY and #142 (forgot-password) turns out to be broken. The move is: flip the PR back to DRAFT. That re-enables auto-mirror, so a correction landing on dev flows to staging.
You then have two choices.
Choice A — hold the whole release¶
Wait for the author to fix #142 via a normal PR. When the fix lands on dev, auto-mirror propagates it to release/next, staging redeploys, QA re-tests, you re-mark READY and ship. Simplest when the rest isn't urgent.
Choice B — exclude the bad commit, ship the rest¶
The author opens a normal PR and creates a conforming revert instead of keeping Git's generated Revert "..." subject:
The PR uses that same conforming title and follows the normal flow — preview, review, merge to dev. Auto-mirror propagates it; staging redeploys without #142's effects. Re-mark READY, QA re-verifies, ship. In prod, #142 and the revert(auth) change cancel out. The author opens a separate fix PR later, which ships in a future release.
Why this works: nobody ever touches release/next directly. Every change — including the revert — starts on dev. The classic "we fixed staging but forgot to backport to dev, so the bug came back" failure mode is impossible, because dev is the only writable trunk. The draft toggle does the rest.
When the revert isn't clean¶
Whether git revert applies cleanly depends on whether later PRs depend on the reverted change (not merely that they came after it):
- Case 1 — no overlap.
#144(last-login time) never touched the forgot-password code, so reverting#142leaves it untouched. The revert applies clean; the release ships#143+#144without#142. - Case 2 — textual overlap, no dependency.
#143happened to edit the sameauth/file as#142, but a different block.git revert <142-sha>throws a conflict that's purely textual — resolve it on the revert branch and move on; nothing actually depended on the reverted lines. - Case 3 — real dependency.
#143's password-strength meter imports the validation helper that#142introduced. Reverting#142deletes that helper, so#143no longer builds. A plain revert is off the table — pick a strategy from the table below.
The revert PR's preview tells you which case you're in: 🟢 green = Case 1 / resolved Case 2; 🔴 red = Case 3. For Case 3:
| Strategy | Use when | Tradeoff |
|---|---|---|
| Cascade revert | Authors agree to redo #143/#144 later |
Revert #144→#143→#142 together; ships nothing for those today |
| Fix-forward | Bug is small, local, well-understood | Ship #142 + fix; "patch and hope" — high confidence only |
| Hold release | Fix is non-trivial, the rest isn't urgent | Choice A above |
| Retro-flag | Bug only triggers when the feature is used | Wrap #142 in a flag, ship flag-off |
Why we can't just "skip #142" from release/next¶
Force-pushing release/next or cherry-picking a fresh branch both look tempting and both break the model:
- Force-push rewrites shared history; the next auto-mirror (once DRAFT) brings
#142right back fromdev. - Cherry-pick works once, but
#142still lurks ondevto ambush the next release — a hidden divergence between what shipped and the trunk.
Revert-on-dev is the only approach where dev and release/next agree. That consistency is what keeps the model sane across many releases.
Bug found in prod after release¶
- Branch
hotfix/<short-description>frommain. - Write the fix; validate it with
cliq preview create <code>. - Classify the complete hotfix diff under the normal header contract, including
!when required. Open a PR targetingmaindirectly; title validation and contract review still apply. Merge after review, and prod deploys. - Merge
mainback intodev(git checkout dev && git merge main). Because it's a merge — not a cherry-pick — the hotfix keeps its original SHA ondev, andrelease/nextabsorbs it via auto-mirror (while DRAFT).
Why merge-back is mandatory. Until the hotfix reaches dev/release/next, the branches diverge: main has a fix the candidate lacks. Merging main → dev first means release/next picks up the hotfix, staging redeploys, and QA re-validates the combined state. Since the hotfix is then the same SHA on both sides, the next release/next → main merge is conflict-free.
The merge-back leaves a small merge bubble on dev; git log --first-parent dev hides it.
Code that should not ship yet¶
The model assumes on dev ⇒ shippable. Code that lands on dev but mustn't ship for two weeks (marketing-gated launches, partner integrations, half-built collaborative features) fights that.
- The right answer is feature flags — code propagates, the runtime switch stays off until launch day. "Shipped" the day it merges; visible the day the flag flips.
- When no flag is possible (DB migrations, large refactors): hold the PR open until you actually want it. Suboptimal — long-running branches drift — but honest. Use it sparingly, not as a habit.
Cross-repo coordinated releases¶
Each repo runs its own AtomiqFlow cycle; they don't auto-coordinate.
- ✅ Backward compatibility — repos deployable independently; new endpoints land before the webapp uses them, old ones deprecate for a cycle before removal.
- ✅ Feature flags — ship both sides behind a flag, flip in lockstep.
- ⚠️ When neither works — sequence the merges (ship backend first, then the webapp release that depends on it) and note the order in both PRs.
Quick reference¶
| You want to... | You do... |
|---|---|
| Start a feature | Branch <ISSUE>/<slug> from dev, push, cliq preview create <code> |
| Set an env var before a preview boots | cliq preview env set KEY val --service <svc> before create (env values) |
Review a PR with no preview / a feature/* branch |
cliq preview create review-<pr> --branch <branch> (Reviewing a feature) |
| Get your feature to staging | Merge your approved PR to dev (release PR is DRAFT → staging redeploys) |
| Confirm your merged feature | Exercise it on staging while the release PR is DRAFT — required before "done" |
| Ship to prod | Release manager marks the release PR READY, QA signs off, merges (merge commit) |
| Drop a bad commit from a frozen release | Flip the release PR to DRAFT, open a revert PR against dev |
| Fix a prod bug | PR to main, then merge main back into dev |
| Hide unfinished code | Feature flag, off by default |
| Branch | Deploys to | Who pushes |
|---|---|---|
feature/* |
Preview | 👤 You |
hotfix/* |
Preview | 👤 Author, from main |
dev |
— | 🤖 PR merge only |
release/next |
Staging | 🤖 Automation (while DRAFT) · 👤 Release manager (merge) |
main |
Prod | 🤖 Approved release-PR or hotfix/* PR merges only |