Skip to content

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/staging poisoning — 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. → cliq previews give every feature a full-stack environment.

The model at a glance

   feature/*  ──▶  dev  ──▶  release/next  ──▶  main
       │                          ⇵                │
    preview                  draft / ready        prod
                               staging

Whenever changes are queued, there is exactly one release PR — base main, head release/next. It lives in two states:

  • DRAFTaccumulating. Every merge to dev fast-forwards release/next, so staging tracks the trunk. The PR body grows with each change.
  • READYfrozen. release/next stops 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:

  1. Access — membership in the shootify-io GitHub org, an SSH key on your account, and gh auth login.
  2. Install cliq (drives the preview environments):
    curl -fsSL https://releases.sartiq.com/cliq/install.sh | bash
    cliq login
    
  3. Learn the two conventions used across the repositories:
  4. 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.
  5. PR title and commit header: use type(scope)[!]: subject exactly as defined in Commit and version contract. CI enforces the PR title rules for feature PRs into dev and hotfix PRs into main.

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

type(scope)[!]: subject

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)!, and refactor(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:

  1. Inspect the final included diff and identify its primary effect.
  2. Identify every affected public contract surface.
  3. Split unrelated effects where practical.
  4. 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.

  1. Branch from dev: git checkout dev && git pull && git checkout -b SAR-1234/forgot-password.
  2. Write the code. Iterate locally.
  3. 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 to dev. (Un-pushed commits aren't in the preview — commit and push first, always.) A preview is a full cliq preview environment 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 you create — 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 with cliq preview env set MY_FLAG_URL https://… --service backend first; details in per-preview env values.
  4. Validate on the preview. This is where you test against a real environment — there's no shared dev env anymore. Push more commits, then cliq preview rebuild SAR-1234 to 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-1234 re-snapshots env without a full rebuild. The complete validate-loop toolkit — rebuild, recreate, logs, and extend/hold for keeping a preview alive through a demo — is in Preview Environments → Lifecycle.
  5. 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.
  6. Get review approval. The preview URL lets reviewers poke at the running feature.
  7. Squash-merge to dev. One commit per PR keeps dev a clean linear trunk and revert mechanics simple. cliq preview destroy SAR-1234 when done.
  8. Verify on staging. While the release PR is DRAFT — the normal, default state — every merge to dev auto-mirrors to release/next and redeploys staging within minutes. Outside of a frozen release, what lands on dev is 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 onto dev, 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 dev and hotfix PRs into main receive 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 as fix or patch.
  • When the rolling release/next -> main PR 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 a feature/<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 on origin, 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:

cliq preview create SAR-1234        # finds SAR-1234/forgot-password by prefix

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:

cliq preview create review-157 --branch feature/instant-checkout
  • 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.
  • --branch overrides 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 to dev.

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:

  1. Fast-forwards release/next to match dev.
  2. Projects the next strict semantic tag, then updates the generated release PR title (Release vMAJOR.MINOR.PATCH) and body. The body uses git 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.
  3. 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:

  1. release/next freezes — automation stops mirroring dev, so staging holds a stable candidate. New dev merges accumulate for the next cycle; they don't disturb QA.
  2. The aggregate release review checks the complete candidate, then QA validates the frozen staging.
  3. 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 to release/next while preserving its commits at their original SHAs. Squash/rebase would rewrite history and break hotfix reconciliation.
  4. Automation tags that merge commit with the projected strict semantic version, publishes the release notes, deploys prod, and creates a prod-YYYY-MM-DD-HHMMSS audit tag. (Today prod rebuilds from main, whose content equals the tested candidate; promoting the exact tested image digest is a planned hardening, since the python:3.12 base image floats.)
  5. The next merge to dev opens 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:

  • #142feat(auth): add forgot-password — the SAR-1234 feature from Shipping a feature above. (This is the same #142 you saw in the DRAFT release-PR body earlier.)
  • #143feat(account): add password-strength meter — merged just after it, and it reuses a validation helper that #142 introduced.
  • #144feat(account): show last-login time — independent of both.

The release manager has marked the PR READY, so release/next is 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:

git revert --no-commit <142-sha>
git commit -m "revert(auth): remove forgot-password change"

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 #142 leaves it untouched. The revert applies clean; the release ships #143 + #144 without #142.
  • Case 2 — textual overlap, no dependency. #143 happened to edit the same auth/ 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 #142 introduced. Reverting #142 deletes that helper, so #143 no 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 #142 right back from dev.
  • Cherry-pick works once, but #142 still lurks on dev to 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

  1. Branch hotfix/<short-description> from main.
  2. Write the fix; validate it with cliq preview create <code>.
  3. Classify the complete hotfix diff under the normal header contract, including ! when required. Open a PR targeting main directly; title validation and contract review still apply. Merge after review, and prod deploys.
  4. Merge main back into dev (git checkout dev && git merge main). Because it's a merge — not a cherry-pick — the hotfix keeps its original SHA on dev, and release/next absorbs 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