Versions
StartOS uses Extended Versioning (ExVer) to manage package versions, allowing downstream maintainers to release updates without upstream changes.
Version Format
[#flavor:]<upstream>[-upstream-prerelease]:<downstream>
| Component | Description | Example |
|---|---|---|
flavor | Optional variant for diverging forks | #libre: |
upstream | Upstream project version (SemVer) | 26.0.0 |
upstream-prerelease | Upstream prerelease suffix | -beta.1 |
downstream | StartOS wrapper revision | 0, 1, 2 |
Note
ExVer allows a prerelease suffix on the downstream revision too (e.g.
:0-beta.0), but Start9 packages don’t use it — the downstream revision is always a plain integer. Prerelease suffixes appear only on the upstream side, when wrapping an upstream alpha/beta/rc.
Flavor
Flavors are for diverging forks of a project that maintain separate version histories. Example: if a project forks into “libre” and “pro” editions that diverge significantly, each would have its own flavor prefix.
Note
Do NOT use flavors for hardware variants (like GPU types) – those should be handled via build configuration.
Examples
| Version String | Upstream | Downstream |
|---|---|---|
26.0.0:0 | 26.0.0 (stable) | 0 |
26.0.0-rc.1:0 | 26.0.0-rc.1 | 0 |
0.13.5:0 | 0.13.5 (stable) | 0 |
2.3.2:1 | 2.3.2 (stable) | 1 |
Version Ordering
Versions are compared by:
- Upstream version (most significant)
- Upstream prerelease (stable > rc > beta > alpha)
- Downstream revision
Example ordering (lowest to highest):
1.0.0-alpha.0:01.0.0-beta.0:01.0.0-rc.0:01.0.0:0(fully stable)1.0.0:11.1.0:0
Choosing a Version
When creating a new package:
- Select the latest stable upstream version – avoid prereleases (alpha, beta, rc) unless necessary.
- Match the Docker image tag – the version in
manifest/index.tsimages.*.source.dockerTagmust match the upstream version. - Match the git submodule – if using a submodule, check out the corresponding tag.
- Start downstream at 0 – increment only when making wrapper-only changes.
Version Consistency Checklist
Ensure these all match for upstream version X.Y.Z:
- The current version lives in
startos/versions/current.ts - Version string matches:
version: 'X.Y.Z:0'in VersionInfo - Docker tag matches:
dockerTag: 'image:X.Y.Z'inmanifest/index.ts(if using pre-built image) - Git submodule checked out to
vX.Y.Ztag (if applicable)
File Structure
The latest version always lives in startos/versions/current.ts. The filename never changes as you bump — only its contents do. Historical versions that a migration needs to upgrade from are kept as version-named files alongside it.
startos/versions/
├── index.ts # VersionGraph: imports current, lists historical versions in `other`
├── current.ts # The latest version (always this filename)
├── v1.0.0_0.ts # Historical version 1.0.0:0, kept because a later migration upgrades from it
└── v1.1.0_0.ts # Historical version 1.1.0:0, ditto
A brand-new package has only index.ts and current.ts — no historical files until a migration forces one out (see When to Create a New Version File).
current.ts Template
current.ts exports its VersionInfo under the stable name current. Keeping the export name fixed is what makes an in-place bump touch only this file — index.ts never changes.
import { VersionInfo, IMPOSSIBLE } from '@start9labs/start-sdk'
export const current = VersionInfo.of({
version: 'X.Y.Z:0',
releaseNotes: {
en_US: 'Initial release for StartOS',
es_ES: 'Version inicial para StartOS',
de_DE: 'Erstveeroffentlichung fuer StartOS',
pl_PL: 'Pierwsze wydanie dla StartOS',
fr_FR: 'Version initiale pour StartOS',
},
migrations: {
up: async ({ effects }) => {},
down: IMPOSSIBLE, // Use for initial versions or breaking changes
},
})
index.ts
import { VersionGraph } from '@start9labs/start-sdk'
import { current } from './current'
export const versionGraph = VersionGraph.of({
current,
other: [], // Add historical versions here so migrations run when upgrading through them
})
Historical Version File Naming
When a migration forces a version out of current.ts (see below), the spun-off file is named after the version it holds, in the same form as its git tag: prefix with v, replace the : with _, and add .ts. The upstream portion keeps its dots; prerelease suffixes are left as-is.
| Version | Filename |
|---|---|
26.0.0:0 | v26.0.0_0.ts |
26.0.0-rc.1:0 | v26.0.0-rc.1_0.ts |
2.3.2:1 | v2.3.2_1.ts |
A historical file’s export is renamed to match the version, with every ., :, and - becoming _ — e.g. 2.3.2:1 → v_2_3_2_1. Only current.ts uses the stable current export.
Incrementing Versions
When to Create a New Version File
The deciding question is does this bump need a migration?
No migration (the common case): bump current.ts in place. Edit version and releaseNotes in startos/versions/current.ts and you’re done. Don’t rename the file, don’t touch the export name, don’t touch index.ts, leave other as it is. Git history of current.ts preserves the prior release notes automatically, so there is no separate “keep the old notes” step.
Migration needed: spin the old version off, then write a fresh current.ts.
- Rename
current.tsto the version it currently holds — e.g.v2.3.2_1.ts(see Historical Version File Naming), and rename its export fromcurrentto the matchingv_2_3_2_1. - Add that historical version to the
otherarray inindex.tsso its migration runs when users upgrade through it. - Create a new
startos/versions/current.tsexportingcurrentwith the new version string, release notes, and theup/downmigration.
This keeps versions/ lean: only versions that a migration upgrades from survive as their own files; everything else is just the latest state of current.ts.
Upstream Update
When the upstream project releases a new version:
- Update git submodule to new tag
- Update
dockerTagin manifest/index.ts - Update
current.tsto the new upstream version (spin off a historical file only if the bump needs a migration — see above) - Reset downstream to 0
Wrapper-Only Changes
When making changes to the StartOS wrapper without upstream changes:
- Keep upstream version the same
- Increment downstream revision
- Apply the migration rule — most wrapper-only bumps need no migration, so just edit
current.tsin place
Release Notes
releaseNotes renders as markdown in the StartOS UI. Match the length to the content — if it fits on one line, write one line. A single-change release doesn’t need bullets, headers, or a backtick template literal. Reach for bullets when there are several items in one category; add bold section headers (**Bumps**, **Features**, **Fixes**, **Internal**) only when the release spans more than one category. Localize the headers in every locale; don’t leave them in English.
// One change: plain string.
releaseNotes: { en_US: 'Bumps Ghost → 6.38.0.', /* …other locales */ },
// Several items, one category: bullets, no header.
releaseNotes: {
en_US: `- Ghost → 6.38.0
- MySQL → 8.4.9`,
// …
},
// Multiple categories: headers + bullets.
releaseNotes: {
en_US: `**Bumps**
- Ghost → 6.38.0
**Fixes**
- Crash on backup restart.`,
// …
},
Use a template literal (backticks) only when the note actually spans multiple lines, and never indent its content lines.
Migrations
Migrations run when users update between versions:
migrations: {
up: async ({ effects }) => {
// Code to migrate from previous version
// Access volumes, update configs, etc.
},
down: async ({ effects }) => {
// Code to rollback (if possible)
},
}
Use IMPOSSIBLE for the down migration when:
- It is the initial version (nothing to roll back to)
- The migration involves breaking changes that cannot be reversed
migrations: {
up: async ({ effects }) => {
// Migration logic
},
down: IMPOSSIBLE,
}
Warning
Migrations are only for migrating data that is not migrated by the upstream service itself.
setupOnInit
Use sdk.setupOnInit() to run setup logic during installation, restore, or container rebuild. It receives a kind parameter:
| Kind | When it runs |
|---|---|
'install' | Fresh install |
'restore' | Restoring from backup |
null | Container rebuild (no data changes) |
Bootstrapping Config Files
Generate passwords, write initial config files, and seed stores on fresh install:
// init/seedFiles.ts
export const seedFiles = sdk.setupOnInit(async (effects, kind) => {
if (kind !== 'install') return
const secretKey = utils.getDefaultString({ charset: 'a-z,A-Z,0-9', len: 32 })
await storeJson.merge(effects, { secretKey })
await configToml.merge(effects, { /* initial config */ })
})
Creating Tasks
Tasks reference actions, so they must be created in a setupOnInit that runs after actions are registered in the init sequence:
// init/initializeService.ts
export const initializeService = sdk.setupOnInit(async (effects, kind) => {
if (kind !== 'install') return
await sdk.action.createOwnTask(effects, toggleRegistrations, 'important', {
reason: 'After creating your admin account, disable registrations.',
})
})
Git Tag Conventions
Releases are published via git tags. The StartOS tag format is:
v{upstream_version}[-upstream-prerelease]_{wrapper_revision}
| Package version | Git tag |
|---|---|
26.0.0:0 | v26.0.0_0 |
26.0.0-rc.1:0 | v26.0.0-rc.1_0 |
0.13.5:2 | v0.13.5_2 |
Conventions:
- Underscore between upstream and wrapper. The
:from the version string becomes_in the tag — tags can’t contain colons. - No package-name prefix. The tag is just the version, not
myservice-v26.0.0_0. - Keep the upstream prerelease suffix (
-alpha.N/-beta.N/-rc.N) when wrapping an upstream prerelease — it stays inline in the upstream portion. The downstream revision is always a plain integer with no suffix. - Push tags individually (
git push origin <tag>), not withgit push --tags.