Equalify ships as one product from one repository. Rather than version each workspace independently, we keep a single product version that the whole repo shares. This document is the spec for how that version is defined, bumped, tagged, and surfaced.
We follow Semantic Versioning 2.0.0: MAJOR.MINOR.PATCH.
Equalify is a deployed SaaS product, not a published library, so we interpret the three numbers in terms of our consumers (API integrators, end users, and the database/auth contract) rather than a public package API:
| Bump | When | Examples |
|---|---|---|
| MAJOR | Breaking change to a consumer contract: API request/response shape, auth, a DB migration that isn't backward compatible, or a removed feature. | New required auth header; renamed GraphQL field; non-additive schema migration. |
| MINOR | Backward-compatible new capability. | New scan type; new dashboard chart; additive API field; additive migration. |
| PATCH | Backward-compatible bug fix or internal change with no consumer-visible surface change. | Fix stuck-scan clearing; perf tuning; styling; refactors; chores. |
While we are pre-1.0 in spirit (the product is still moving fast), the root
package.json already declares 1.0.0, so we treat 1.0.0 as the baseline
and move forward from there. If we want to signal "still unstable, breaking
changes may land in minor," that is a conscious decision to make once — see
§7. Until then, the table above is the rule.
Staging builds and release candidates use a pre-release suffix:
vX.Y.Z-rc.N— release candidate being validated on staging before promotion.
Pre-releases sort below their final version (1.4.0-rc.1 < 1.4.0), which is
exactly what we want.
- The version lives in the root
package.jsonversionfield. This is the one canonical number. - Workspace
package.jsonversions (apps/frontend,apps/backend,services/*,shared/types) are not maintained independently. Set them to"0.0.0"and treat them as private/unpublished, or keep them in sync with the root via the release script (§4). Do not hand-edit them. - The git tag (§3) is the immutable record of what a version was.
- Format:
vMAJOR.MINOR.PATCH(e.g.v1.4.0), with optional-rc.N. - Annotated tags only (
git tag -a), so they carry a message and date. - One tag per release, created on the commit that is promoted to
main. - Legacy tags (
MVP-1,vMVP-5.1,v1-rc4, …) are left in place for history but are abandoned. The first tag under this spec starts the new line cleanly — see §7 for the suggested starting point.
This maps onto the existing trunk-based deploy model
(.github/workflows/deploy-apps.yml):
staging deploys to the staging env, main deploys to production.
feature branch ──PR──▶ staging ──(validate on staging env)──▶ main
│ │
-rc.N tag vX.Y.Z tag
(optional) + CHANGELOG entry
A release is cut when promoting staging → main:
- Decide the bump (MAJOR/MINOR/PATCH) from the commits since the last tag (§5 makes this mechanical).
- Run the release script (§6) on the merge commit. It:
- updates
versionin rootpackage.json, - updates the
CHANGELOG.md(§5), - commits as
chore(release): vX.Y.Z, - creates the annotated
vX.Y.Ztag.
- updates
- Push the tag. Existing deploy workflows fire from the branch push as they do today; the tag is the record, not a new deploy trigger (unless we wire that up later — §7).
We already write Conventional Commits-style subjects. Standardize on these types so bumps and changelogs can be derived:
| Type | Bump | Changelog section |
|---|---|---|
feat |
MINOR | Added / Changed |
fix |
PATCH | Fixed |
perf |
PATCH | Changed |
refactor |
PATCH | (omit or Changed) |
chore |
none | (omit) |
docs |
none | (omit) |
A ! after the type/scope or a BREAKING CHANGE: footer forces a MAJOR
bump regardless of type, e.g. feat(api)!: require auth header.
Normalize
bug→fix. Several existing commits usebug(scope): …. Conventional Commits has nobugtype; it is treated asfix. Preferfixgoing forward so tooling counts it correctly.
CHANGELOG.md follows Keep a Changelog: newest
version on top, grouped by Added / Changed / Fixed, each entry linked to its
compare range (vA.B.C...vX.Y.Z).
Start with npm's built-in version plus a thin script — no new heavy deps.
Manual flow per release:
npm run release:minor # bumps root package.json
# edit CHANGELOG.md (or generate — see upgrade path)
git commit -am "chore(release): v$(node -p "require('./package.json').version")"
git tag -a "v$(node -p "require('./package.json').version")" -m "Release notes…"
git push --follow-tagsA version is only useful if you can read it off a running environment.
-
Frontend — inject at build via Vite
define, read frompackage.json, and show it (footer +console.info):// vite.config — define: { __APP_VERSION__: JSON.stringify(process.env.npm_package_version) } -
Backend API — expose a
GET /health(or response headerX-Equalify-Version) returning{ version, commit, env }. The deploy workflow can pass the version/short SHA in as an env var at build time. -
Lambdas — optional; tag the deployed function with the version or log it on cold start for traceability.
- Reset the new line at
v1.0.0(orv1.1.0if you want the first release under this spec to clearly differ from the legacy1.0.0inpackage.json). - Add
CHANGELOG.mdwith an## [Unreleased]section. - Add the
release:*scripts above. - Normalize commit types (
bug→fix). - Add version surfacing (§7) when convenient.
When cutting releases by hand becomes friction, adopt semantic-release or changesets:
- semantic-release — fully automated: on merge to
mainit reads commit types, computes the next version, updates the changelog, tags, and (optionally) triggers deploy. Best fit for our single-version, trunk-based model. Requires disciplined commit messages — which the conventions in §5 already give us. - changesets — better if we ever move to per-package independent
versioning (publishing
shared/types, etc.). More ceremony than we need today.
Recommendation: stay manual (§6) until release cadence is regular, then move to semantic-release without changing this spec's scheme.