Cutting a release
A release promotes dev to main. That is the only time main moves, and it is a different lifecycle from everyday change work: a change is triggered by an issue and lands on dev; a release is triggered by a decision to ship what dev already holds.
/ff-release <semver> drives it. Omit the version and it reads the current one, proposes the next bump from the change set (breaking → major, feat → minor, fix/chore only → patch), and confirms before editing anything.
Preconditions
Section titled “Preconditions”The command aborts unless all of these hold. It never releases from a dirty or behind tree.
git rev-parse --abbrev-ref HEAD # must be devgit fetch origingit status --porcelain # must be emptygit rev-list --left-right --count origin/dev...HEAD # must be 0 0dev CI must also be green on origin — gh run list --branch dev --limit 5.
What carries a version
Section titled “What carries a version”| Component | Versioning |
|---|---|
backend/pyproject.toml | The single source of truth. The only backend version literal |
backend/packages/**/pyproject.toml | Declare dynamic = ["version"] and read the root through hatchling — nothing to bump |
frontend/package.json | Its own literal, tracking the backend version |
docsite/package.json | Independent. Not bumped in a normal app release, only when the docs themselves are released |
cli/ | No version field — tagged with the repo |
So a backend bump edits one line. The 19 member packages follow automatically, which is deliberate: they each used to carry their own literal, and they drifted — factflow-confluence sat at 0.1.0 while the rest said 0.5.1 and the tags had reached v0.7.2.
Two consequences worth knowing:
- Regenerate the lock with
uv lockafter the bump.frontend/bun.lockdoes not record the root package version, so it will not change. - A local venv keeps the old version until you reinstall. Editable installs cache their metadata, so a plain
uv syncafter a bump leaves member packages reporting the previous number. Useuv sync --all-groups --reinstall. Fresh builds — CI and Docker — are always correct.
The verification gate
Section titled “The verification gate”cd backend && uv run ruff check . && uv run ruff format --check . && uv run pytest && cd ..cd frontend && bun run check && bun run build && cd ..cd cli && go build ./cmd/factflow/ && go vet ./... && go test -race ./... && cd ..Note that the backend step runs uv run pytest with no path. That is deliberate here and is the one place it applies. During development you always scope pytest to the package you touched, because the full suite starts Testcontainers for PostgreSQL, Artemis, RabbitMQ and Pulsar and takes a long time. A release is the opposite trade: the point is to prove the whole suite is green before anything reaches main, so the cost is the feature.
Do not release red. If the OpenAPI snapshot is part of the release surface, regenerate it and confirm it is committed.
Naming
Section titled “Naming”| Artifact | Form |
|---|---|
| Bump commit | chore(release): vX.Y.Z |
| Release PR | title Release vX.Y.Z, --base main --head dev |
| Tag / GitHub release | vX.Y.Z |
Release notes summarize merged work grouped by type (feat / fix / chore) from git log --oneline origin/main..origin/dev. No AI attribution, no marketing URLs.
Merging the release PR needs an override
Section titled “Merging the release PR needs an override”The radicalAI org applies a ruleset named GitHub Flow to every repository's default branch. Two of its rules matter here:
pull_request— one approving reviewrequired_linear_history— merge commits are not allowed onmain
A release PR merged normally produces a merge commit, so the second rule rejects it. A repository admin is a bypass actor on that ruleset, so the merge goes through with:
GH_HOST=dnb.ghe.com gh pr merge <PR> --repo radicalAI/factflow --merge --adminThat is a deliberate override, not a formality — it bypasses the approval requirement as well, so get the review first.
The alternative is --rebase, which satisfies linear history without any override. It costs you branch identity: rebasing replays every commit as a new object, so main and dev no longer sit at the same SHA afterwards and each will report the other as diverged. If you want the two branches identical after a release, take the admin merge.
The ruleset is org-level, so it cannot be changed from this repository. Ask an org admin to amend ruleset 6141 if this becomes a recurring obstacle — and ask only about required_linear_history, since the other rules in it are ones we want.
After the merge
Section titled “After the merge”Tag main at the release commit with gh release create vX.Y.Z --target main, then return to dev. No back-merge is needed because dev was the source, but confirm main and dev match at the release commit.
If main has picked up a commit of its own — a hotfix applied directly to it — the two branches will not match even after the release. Bring it back with a fast-forward of dev to main once main contains everything dev had, so the next release does not carry the gap forward.