Skip to content

Documentation

Version Checking

How SveltyCMS handles version display, update checking, and the automated release pipeline.

10/5/2026
10 min read Edit on GitHub

SveltyCMS uses a simple two-branch model with automated releases triggered when CI passes on main.

Branch Model

Branch Purpose Releases
next Active development — features, fixes, and experiments land here first No
main Production — merged from next when stable Yes (auto on CI pass)

Release Pipeline

next or main:  pre-push gate passes → push
                 │
                 ▼
main:   CI runs (whitebox → build → DB matrix → bench → E2E)
                 │
                 ▼ (CI passes)
        auto-release:
        1. reads version from package.json
        2. if that version's tag already exists and this commit is ahead of it,
           scripts/version.ts patch writes the next version and the workflow
           commits and pushes it
        3. creates the git tag for the version now in package.json
        4. GitHub Release with auto-generated notes (independent of npm)
        5. npm publish (best-effort — npm never blocks the release)

        the created tag is the package.json version ✅
        a checkout that already is that tag is skipped ✅

Deriving the next version

bun run version:bump runs scripts/version.ts. It reads the released version from the highest v* git tag reachable from the current branch (falling back to package.json when no tag exists yet), inspects the commits since that tag, and writes the next version back into package.json. Every run prints the source it used, the commit range it inspected, and the proposed version before anything is written — --dry-run stops right after that report. An explicit x.y.z argument (optionally v-prefixed) sets that version instead: the manifest is written as given, no commit is inspected, and the same refusals below still apply.

bun run version:bump              # auto policy (default)
bun run version:bump patch        # force a patch bump
bun run version:bump minor        # force a minor bump
bun run version:bump major        # force a major bump
bun run version:bump 0.0.10       # set exactly this version (no commit detection)
bun run version:bump --dry-run    # print the decision, change nothing

Preparing the bump on push

The pre-push gate can run the derivation for you. bun run git push --release arms a release step in .githooks/pre-push (kind optional: --release=patch|minor|major, default auto); once all nine checks pass, the hook runs scripts/version.ts, commits the manifest as chore(release): bump version to vX.Y.Z and prints the push that publishes it. The flag is consumed by scripts/git-safe.ts, which exports SVELTY_RELEASE for the hook — git never forwards hook arguments. The manual equivalent is bun run gate --release.

Three properties keep it safe to automate: it runs after the full gate (a version that ships for a tree the gate never validated is the stray bump §8 of AGENTS.md warns about); the release commit cannot travel inside the push that triggered it, because git resolves the pushed refs before the hook runs — so the bump stays local and reversible (git reset --hard HEAD~1) until you push again; and re-running is a no-op, since the derivation is stable within one release cycle. It is skipped on the docs-only and --only-failures paths, and a version.ts refusal fails the hook (the push is blocked) rather than leaving behind a push that looks released but is not.

Check the proposal before the second push. auto derives from the highest reachable v* tag, which is not always git describe’s nearest tag. A test tag v0.2.0 (5 July 2026, manifest 0.0.7) used to outrank v0.1.2 and propose 0.3.0; it was deleted on 2026-09-30 and was never on origin. The bump commit stays local until you push it, so a wrong proposal costs one git reset — or pass an explicit target (bun run version:bump 0.1.3) when the range contains feat commits but the release should stay a patch on the shipped line.

Auto policy (default) — Conventional Commits subjects mapped to SemVer, highest rule first:

Commits since the latest v* tag Result
BREAKING CHANGE footer or type!: subject while major === 0 minor (SemVer §4)
The same markers once major >= 1 major (SemVer §8)
feat subject — new backwards-compatible functionality minor (SemVer §7)
Everything else (fix, perf, refactor, docs, chore, ci, test, build, style, revert, merge commits) patch (SemVer §6)

SemVer §7 defines MINOR as new backwards-compatible functionality, so a feat is never a patch. SemVer §4 (0.y.z may change anything) is why a breaking commit is a minor while major === 0 and a major afterwards (§8); §6 keeps pure fix/refactor/docs ranges at patch. A range that mixes types follows the highest rule that applies — breaking beats feat, feat beats everything else — so feat!: on 1.x is a major release even beside fix commits. patch, minor or major as an argument overrides detection, while an explicit x.y.z literal sets that version verbatim without inspecting commits.

Single source of truth

Two rules keep the tag and the manifest in lockstep (as of September 2026): the release workflow tags whatever version it finds in package.json, and scripts/version.ts derives the next package.json version from the latest v* tag — it reads git, not the hand-edited manifest, so the two cannot drift apart. Because the script refuses to derive a version that already carries a tag, re-releasing an existing version fails fast locally instead of the runner quietly skipping tag creation.

Two further safety rails (as of September 2026): the script refuses to run while package.json has uncommitted changes (--dry-run still previews the decision and prints a warning), and the script itself never creates a commit or a tag. Auto Release commits the bump after CI passes on main. The local pre-push release step also commits one, and only after an explicit --release request and a full green gate.

The same refusals cover an explicit target: a literal that is not MAJOR.MINOR.PATCH, a version that already carries a v* tag, or one lower than package.json (a downgrade). Because the derived proposal comes from the tag rather than the manifest, it can equal package.json — that means the pending version is the next release above the newest tag, so a patch bump reports nothing to write and the script says so, pointing at the explicit form (bun run version:bump 0.0.10) for any other release.

The version is written when CI passes on main. If package.json still names a version that already has a tag, and the commit CI validated is ahead of that tag, Auto Release runs scripts/version.ts auto — the same SemVer policy as the table above — commits chore(release): bump version to vX.Y.Z, pushes that commit to main, and tags it. So a range containing a feat becomes a minor (0.2.0 → 0.3.0), and a pure fix/perf range becomes a patch (0.2.0 → 0.2.1). A checkout that is already the tagged release logs Release skipped and exits green. bun run git push --release prepares that local decision before CI.

A manual trigger remains at Actions → Auto Release → Run workflow, and pushing a v* tag directly (git tag v0.0.10 && git push origin v0.0.10) triggers the same publish steps from the tag name.

npm token caveat: the GitHub Release (tag + release notes) is created before npm publish and never blocked by it. If NPM_TOKEN is missing or invalid, the run logs a warning and skips publishing — the release still exists, so the in-app update check (/api/system/version/check) keeps working.


Version Pipeline Components

Component Path Role
Version Service src/services/core/version-service.ts Reads local version from package.json, checks GitHub Releases API for newer versions
Version Handler src/routes/api/[...path]/handlers/system.ts REST endpoints /api/system/version (public: installed version) and /api/system/version/check (authenticated: update check)
VersionCheck Component src/components/version-check.svelte Admin UI badge — displays current version; the badge background carries the status (primary / warning / surface / error)
Version Tests tests/unit/api/version.test.ts Unit tests for compareVersions (SemVer incl. prerelease precedence) and checkForUpdates — these import the real implementation, not a copy
Auto Release .github/workflows/auto-release.yaml CI pass on main (or manual v* tag push / dispatch) → tag, GitHub Release, npm publish

Note: src/utils/media/version-history.ts is a separate module for tracking media file revisions (create/update/replace diffs) — not related to CMS release versioning.


API Endpoints

Check for Updates

GET /api/system/version/check

Queries the GitHub Releases API and compares against the installed version from package.json.

Response (update available):

{
  "success": true,
  "data": {
    "currentVersion": "0.0.9",
    "latestVersion": "0.0.10",
    "updateAvailable": true,
    "checkedAt": "2026-07-08T12:00:00.000Z"
  }
}

Response (up to date):

{
  "success": true,
  "data": {
    "currentVersion": "0.0.10",
    "latestVersion": "0.0.10",
    "updateAvailable": false,
    "checkedAt": "2026-07-08T12:00:00.000Z"
  }
}

If GitHub is unreachable, the endpoint returns latestVersion: null with updateAvailable: false and an error field.


Admin UI

The VersionCheck component (src/components/version-check.svelte) appears in the admin interface and:

  • Reads PKG_VERSION from publicEnv (loaded from package.json at server boot via settings-service.ts → +layout.server.ts)
  • Fetches /api/system/version/check every hour to compare against the latest GitHub release (not on /setup — see below)
  • Shows a clickable badge linking to the releases page
Badge State Meaning
Green (primary) You are up to date (also the /setup install screen, where the check cannot run)
Yellow (warning) A newer version is available (e.g. “Update to v0.0.10 recommended”), or GitHub answered with an error
Grey (surface) Waiting for the check, or no verdict (not authorized / no data)
Red (error) The check request itself failed (network / timeout) — an error alert is shown

The update check is authenticated by design (system:read via the fail-closed dispatcher, pinned by tests/integration/api/version.test.ts → “should require authentication”): the endpoint performs an outbound GitHub lookup, so an anonymous caller must not be able to trigger it. The installed version keeps its own public endpoint (/api/system/version). /setup mounts the badge before a session exists, so the component does not call the endpoint at all and presents the installed version in the brand-green (primary) colour with no status dot and no error alert — a pre-auth request could only answer 401, and a grey “unavailable” badge described the check rather than the version. /login keeps a fixed neutral transparent pill that never repaints (status colour, dots included, are gone there), and the authenticated surfaces (sidebar, config) keep the real check (src/components/version-check.svelte).


Automated Upgrades

SveltyCMS includes an automated upgrade CLI:

bun run scripts/upgrade.ts

The script:

  1. Fetches the latest release from GitHub
  2. Merges changes into your local branch
  3. Refreshes dependencies (bun install)

Always run bun run check && bun run test:unit after an upgrade to ensure system integrity.


File Version History

For media file version tracking (distinct from CMS release versioning), SveltyCMS uses src/utils/media/version-history.ts which provides:

  • createVersion() — records file create/update/replace actions with change diffs
  • compareMediaVersions() — compares two file versions for content and metadata changes
  • detectChanges() — deep-diff comparison between old and new file metadata
  • getVersionStats() — aggregates version counts, sizes, and user activity

This powers the media gallery’s version history viewer — not the CMS release cycle.


Related

versionupdateupgradeconfiguration
Was this page helpful?