Shipping a New Version of Your App
Who this is for: developers who already have an app deployed and want to ship a change — a new build, a new type, a new status, a new setting. Scope: what happens today, command by command and pointer by pointer. No roadmap, no "will." Related: Building apps on Starhive (what the manifest is) · Starhive CLI (every command and flag).
1. The two pointers, and who follows which
An app has two pointers at any moment:
| Pointer | Moved by | Who resolves to it |
|---|---|---|
| latest deployed | every starhive app deploy | every developer of the app, on every workspace |
| published | starhive app promote | everyone else |
The original text said an installation resolves to the latest-deployed pointer only if it "opted
into development builds," and that "a developer who installs their own app without opting in gets
exactly what a customer gets." Verified against code:
AppRegistryService.resolveInstallVersion() (AppRegistryService.kt:556-561) is unconditional —
if (isDeveloper) latestVersion(app) else publishedVersion(app), where isDeveloper just checks
AppDeveloperRepository. There is no field on InstallationEntity or AppEntity for a per-install
opt-in, and no code anywhere in app-platform-service that would let a non-developer's workspace
follow the latest-deployed pointer, or let a developer's workspace follow the published one instead.
Every developer of the app is always on the latest deployed build, on every workspace they install
it into; everyone else is always on the published version. See §4 for the full correction of the
"development builds" feature this built on.
deploy ──▶ latest deployed ──▶ every developer's own installs
│
│ promote
▼
published ──▶ every other install, on their next upgrade
That split is the whole release model: deploying changes nothing for customers. You can deploy twenty builds in an afternoon and every customer stays where they are.
2. The version number
app.version in manifest.yaml is the version. There is no other source of truth, and nothing in
the CLI bumps it for you.
app:
key: com.acme.tracker
version: 1.3.0
The rules, exactly as they are enforced:
- The published version is immutable. Re-deploying the version that is currently published is refused.
- Any other version is replaceable. Re-deploying a version that exists but is not published
overwrites it in place: the manifest, the raw YAML, and the bundle are all replaced, and the
version id stays the same. This is what makes the develop loop work — you iterate on
1.4.0as many times as you like before promoting it. - Nothing reads semver. No comparison, no ordering, no "newer than." The published pointer is
wherever you last pointed it, so promoting
1.2.0after1.4.0is allowed and means exactly what it says. Use semver because customers read it, not because the platform does.
3. starhive app deploy
starhive app deploy [--build] [--dir ./build] [--manifest ./manifest.yaml]
| Flag | Default | What it does |
|---|---|---|
--dir <path> | ./build | The built bundle directory to zip and upload. |
--manifest <path> | ./manifest.yaml | The manifest to publish. |
--build | off | Runs npm run build first. |
--env <prod|dev|local> | prod | Which deployment to deploy to. |
--api <url> | — | A specific app-platform URL. Only if Starhive tells you to. |
Four HTTP steps, in this order:
- Publish the manifest. The YAML is parsed and validated, checked against the version customers are running (§10), and a version row is created or replaced.
- Request an upload URL for the zip.
- PUT the zip to that presigned URL. Files sit at the archive root, so
index.htmlis at the top. - Finalize, which flips the bundle to
READY.
→ zipping ./build…
→ publishing manifest…
→ uploading bundle (42 KB)…
→ finalizing…
✓ Deployed 1.4.0
appId: …
versionId: …
bundleStatus: READY
serveUrl: https://…/index.html
What a deploy moves and what it doesn't:
- It moves the latest-deployed pointer to this version, always.
- It does not touch the published pointer, so customers are unaffected.
- It does not change the app's marketplace status. A first deploy creates the app as
DEPLOYED— not registered, not installable by anyone. Registration, marketplace details and pricing live in the web console (Marketplace → Manage your apps), not in the manifest. - It moves every workspace where you're a developer of the app — not opt-in, see §4.
A 401 is the token, not the bundle — re-run starhive access with a fresh PAT. A 403 on a
version-up means you are not a developer of that app key; starhive app developers (unconfirmed —
see Building apps §11.3)
is described as showing who is.
4. Testing the build: development builds
The original version of this section described a toggle ("Receive development builds") that any single workspace — including a customer's — could turn on to follow every build a developer deploys, independent of whether that workspace's owner is a developer of the app.
Verified against code: no such thing exists. InstallationEntity.kt and AppEntity.kt have no
field for it, and AppRegistryService.resolveInstallVersion() (AppRegistryService.kt:556-561) —
the one place that decides which version an install resolves to — only ever branches on whether the
current viewer is a developer of the app:
val isDeveloper = viewer == null || developerRepository.existsByAppIdAndUserId(app.id!!, viewer.value)
return if (isDeveloper) latestVersion(app) else publishedVersion(app)
That means: every workspace a developer installs the app into automatically follows the latest deployed build, with no toggle needed — and there is no way found in this pass for a non-developer workspace (a customer's) to opt into pre-promotion builds. If you want a test bed, install the app as one of its developers; there's nothing to switch on beyond that, and (as far as this pass could confirm) no way to hand a customer's workspace this behavior deliberately.
One more thing worth knowing if you rely on the "Update available" signal to know whether a
build is new: InstallationResponse.updateAvailable (InstallationApiModels.kt:38-44) compares an
install's pinned version against AppRegistryService.latestVersionOrNull(), which — despite its own
doc comment saying "latest published version" — actually returns the most recently deployed
version of the app by creation time, regardless of promotion (AppRegistryService.kt:504-507). So
this signal alone doesn't distinguish a promoted release from a build still being tested; see §7 for
what drives the update flow for non-developer installs in practice (the published pointer).
5. starhive app promote
starhive app promote [--manifest ./manifest.yaml]
promote reads app.key and app.version from the manifest, finds that deployed version, and
points the published pointer at it. It reads the registry only — it never moves the
latest-deployed pointer, so your own builds are untouched.
Requirements:
- You are a developer of the app.
- That version exists and its bundle is
READY. A version whose upload never finished is refused:version 1.4.0 has no ready bundle to publish.
What happens the moment it lands:
- New installs and every customer upgrade (once triggered — see §7) now resolve to this version.
The original text also said installs whose pinned manifest is "identical apart from the version
number" are moved onto the new version immediately and automatically, with everyone else merely
shown "Update available." This pass could not confirm an automatic move: AppRegistryService.promote()
(AppRegistryService.kt:527-540) only updates the app's own publishedVersionId — it does not touch
any InstallationEntity row. Moving an installation to a new version happens in
InstallationService.upgrade() (InstallationService.kt:76-94), which this pass found no code
calling automatically as a side effect of promote(). Treat "some installs auto-update silently"
as unconfirmed; what's confirmed is that promote() changes what a future upgrade resolves to,
not that it reaches out and upgrades anything already installed.
→ resolving com.acme.tracker v1.4.0…
→ promoting 1.4.0…
✓ Promoted 1.4.0
Promote is the step that is felt — it's the only command here that changes what other people run.
6. When a promote waits for review
The original version of this section described a Starhive review step that parks a promote when a
version "reaches further outside Starhive" than the live one (a new/changed remotes entry), with
the app's status flipping to PUBLISHED_PENDING_REVIEW until an admin approves or refuses it.
None of this exists. Verified against code:
remotesisn't a real manifest field, so there's nothing to widen and nothing for this mechanism to key off (see Building apps §5.8).AppStatus(Common.kt:20) is exactlyDEPLOYED, PRIVATE, PENDING_REVIEW, PUBLISHED, DECOMMISSIONED— noPUBLISHED_PENDING_REVIEW.AppRegistryService.promote()(AppRegistryService.kt:527-540) unconditionally setspublishedVersionIdonce the caller is a developer and the version's bundle isREADY. It does not inspect the manifest at all, let alone diff it against the previously-published one.
What actually happens today: every successful promote takes effect immediately, for every
version, with no review step of any kind.
7. How the new version reaches a customer
Verified against code: InstallationService.upgrade() (InstallationService.kt:76-94) is what
runs an upgrade — it re-pins appVersionId to whatever resolveInstallVersion() currently resolves
to for that app (the published version for a non-developer, per §1/§4), then re-runs
provisionStageOne/finalizeBindings against the new manifest. It's a no-op if the install is
already on the target version. Nothing here is confirmed to require an explicit admin click —
that's a claim about the web console UI, which this pass could not find (see
Building apps §11);
what's confirmed is the backend endpoint that performs the re-pin + reconciliation when called.
UPGRADING status, and an upgrade doesn't "keep working" on failure theway this originally claimed
The original text said a running upgrade sets the install to UPGRADING, and that a failed upgrade
lands on UPGRADE_FAILED while the app "keeps working," resumable by clicking Update again.
Verified against code: InstallationStatus (Common.kt:62) is exactly INSTALLING, NEEDS_CONFIG, ACTIVE, UNINSTALLED, FAILED — no UPGRADING, no UPGRADE_FAILED. Looking at
InstallationService.upgrade() directly: it never sets the installation's status field to
anything before running provisioning — it only updates appVersionId and then calls
provisionStageOne, which itself only ever sets NEEDS_CONFIG or ACTIVE (via finalizeBindings).
So there is no distinct "upgrade in progress" status visible through the API at all; an installation
that's mid-upgrade still just reads whatever it read before (typically ACTIVE).
As for failure: install()/upgrade() and provisionStageOne()/finalizeBindings() all run inside
the same @Transactional scope. ProvisioningService.kt's own top-of-file comment notes that the
gRPC writes to space-manager inside that transaction are not rolled back if it fails — but the
database update (the re-pinned appVersionId, and any status change) is. That means a failed
upgrade most likely leaves the installation row exactly as it was before the upgrade attempt —
still on the old version, still whatever status it had (typically ACTIVE) — while any space-manager
writes that did complete before the failure are not undone and not tracked. This is a materially
different failure mode than "lands on UPGRADE_FAILED and keeps working, resume by clicking
Update": there's no distinguishable failed state to look at, and clicking Update again would attempt
the same upgrade from scratch rather than resuming a tracked partial state. Treat "what actually
happens when an upgrade fails" as only this pass's best reading of the transaction boundary, not a
directly observed behavior.
| Status | Meaning |
|---|---|
INSTALLING | First-time setup running. |
NEEDS_CONFIG | A required setting is unset. Your settings page must be usable in this state. |
ACTIVE | Set up and running — also what an installation reads while an upgrade is in progress or has failed against it, per the correction above. |
FAILED | Declared in the enum, but this pass found no code path that ever assigns it (see Building apps §9). Unconfirmed as a reachable state. |
UNINSTALLED | Soft — the space and its data are kept, and a reinstall reactivates them. |
8. Shipping a data-model change
Your data block is not your schema. It describes things that live in the customer's
workspace, holding the customer's objects, beside whatever their admins have added or renamed
themselves. A new version is therefore not a migration you run — it is a description the platform
converges on: it compares what you declared against what the workspace actually has, and makes
up the difference.
The original §8-11 described a coherent, enforced safety model: widening a schema is free, narrowing
is refused at deploy, and the only way to retire a field is deprecated: true. Verified against
code: none of the enforcement exists.
- There is no
deprecatedfield anywhere inAppManifest.kt— not onAttributeDef,TypeDef, orWorkflowStateDef. Zero matches for "deprecated" inapp-platform-service. AppRegistryService.deploy()(AppRegistryService.kt:86-134) never loads or diffs the previously-published manifest. The only check it makes is whether this exact version number is already published (in which case it's refused as immutable) — nothing about what the new manifest removes, narrows, or renames relative to any other version.ManifestParser.validate()(ManifestParser.kt:27-104) — the only validation that runs on deploy — checks manifest-internal consistency only:app.keyformat,app.versionnon-blank, at least one module, every${config.*}binding referencing a real config field, everyseedkey referencing a real type, and workflow/transition/state consistency. It never looks at any other version.
What this means: "Keys are forever," "nothing is ever deleted from a workspace," and "widening
is free" are true as observed effects of the reconciler being purely additive (see the corrected
§9 below) — but not because deploy enforces or refuses anything. You can deploy a manifest that
removes a type your customers depend on, and it will succeed. Nothing will retroactively touch what
already exists in a workspace (the reconciler only acts on what's still in your current manifest),
but there's no deprecated: true mechanism, no reserved-key protection, and no deploy-time warning
that you've done this. If you remove a key and later reuse it for something else, nothing stops you
— and nothing protects you from the consequences in a workspace that still has the old thing.
9. What an upgrade actually applies
The original table claimed new workflow states/transitions, new OPTION choices, dropped
required/cardinality bounds, and label changes all reach an existing install. Verified against
code, function by function in ProvisioningService.kt:
resolveTypes(ProvisioningService.kt:113-137): a tracked type gets renamed if its display name changed. An untracked type is created. A tracked type that was deleted out-of-band, while its Space still exists, is silently skipped — not recreated, not renamed. Only a deleted Space triggers full recreation (provisionStageOne, lines 61-73).resolveWorkflows(ProvisioningService.kt:139-153):if (workflowKeyToId.containsKey(workflow.key)) return@forEach— a workflow whose key is already tracked is skipped entirely, unconditionally. New states or transitions added to an already-provisioned workflow in a later manifest version are never applied to an existing installation. Only a brand-new workflow key gets created (with whatever states/transitions it declares at that moment).resolveAttributes(ProvisioningService.kt:155-188): a tracked (non-label) attribute gets, at most, a rename if its display name changed. Nothing else about a tracked attribute is ever revisited — itsconfigmap (soOPTIONchoices andmulti),required, andcardinalityare set once, at creation, and never updated by a later upgrade.- Labels: the label attribute is created and set only inside
resolveTypes's call togateway.createType(...)at type-creation time (labelAttributeName, lines 292-293). There is no code path that re-points an existing type's label on upgrade. - The space itself:
gateway.createSpace(data.space.name)is called only when there's no tracked space yet. There is norenameSpacecall anywhere — a space's name, once created, is never updated by a later manifest.
The only things a later version reliably applies to an existing installation are: a brand-new
type, a brand-new attribute on an existing type (except its label attribute), a brand-new workflow
(whole), a rename of an existing type's or attribute's display name (if it can still be found), seed
rows for types created in that pass, and config-bound REFERENCE attributes being created or
re-pointed when the relevant config value changes (finalizeBindings, lines 243-280 — this part
matches the original description).
| Change | Reaches an existing install? |
|---|---|
| New type | Yes — created |
| New attribute on an existing type | Yes — created (except the type's label attribute) |
| Renamed type/attribute display name (key unchanged) | Yes — but only if the tracked type/attribute can still be found in the workspace |
| New workflow (a whole new workflow key) | Yes — created with whatever states/transitions it declares |
| New state or transition on an already-tracked workflow | No — the whole workflow is skipped once its key is tracked |
New OPTION choice on an existing attribute | No — a tracked attribute's config is never revisited |
required: true dropped on an existing attribute | No — cardinality is set once, at creation |
cardinality: one dropped on an existing attribute | No — same |
New labelAttribute on an existing type | No — resolved only at type-creation time |
seed rows added to a type that already exists | No — seeding happens once per type, ever; new installs only |
Space name change | No — no rename call exists for the space |
| A type/attribute deleted out-of-band (space still present) | No — left stale rather than recreated |
${config.*}-bound reference created or re-pointed | Yes, when the config value changes — finalizeBindings |
| Anything removed from your manifest | Not refused at deploy — see §10 |
10. What deploy actually checks
The original section described a rich set of deploy-time refusals (removing a type/attribute,
renaming a key, changing an attribute's type, narrowing cardinality, removing an OPTION choice,
flipping includeChildren, re-pointing a REFERENCE, rebinding a WORKFLOW attribute, changing a
config field's type, removing data.space), each with a deprecated: true-based workaround, plus
a specific quoted error message and a set of self-consistency refusals (deprecated label attribute,
deprecated referenced type, etc.).
None of this exists. AppRegistryService.deploy() (AppRegistryService.kt:86-134) does not
load or compare the previously-published manifest at all — the only refusal it has is re-deploying
the exact version number that's already published (immutability, not a schema check). The quoted
error message ("version 2.0.0 ... would break workspaces running v1.3.0 ...") does not appear
anywhere in app-platform-service.
What deploy actually validates (ManifestParser.validate(), ManifestParser.kt:27-104) is
manifest-internal consistency only:
app.keyis non-blank and dot-notation;app.versionis non-blank.- At least one module is declared.
- Every
${config.*}-bound attribute target names a config field that's actually declared. - Every
seedkey names a type that's actually declared. - Every workflow has at least one state, no duplicate state keys, exactly one
initialstate, and every transition'sfrom/tonames a real state. - Every
WORKFLOWattribute names a workflow that's actually declared.
There is no includeChildren field to flip (§5.6 of the building guide), and there is no
deprecated field to reference in a workaround.
11. Retiring something
deprecated: true mechanismThe original section described deprecated: true as the only sanctioned way to retire a type,
attribute, or workflow state — withholding it from typeKeyToId/attributeKeyToId, keeping the
customer's data intact, and permanently reserving the key.
None of this exists — no manifest field, no withholding logic, no key-reservation check. What
actually happens if you just stop declaring something: per the corrected §9 above, the reconciler
only acts on what's in your current manifest. A type or attribute you stop declaring simply stops
being touched — it's neither recreated, renamed, nor deleted, and it stays fully resolvable (its id
stays in typeKeyToId/attributeKeyToId on every installation that already has it, since those
maps are only ever added to, never pruned). So in practice, today: the customer keeps everything
(true, but incidentally — provisioning was never going to touch existing data anyway) and your app
can still resolve the old key if you kept the id around client-side, since nothing withholds it.
There is no protection against later reusing that key for something else, and no signal anywhere
that a key was ever meant to be retired. If you need to stop using something, treat this as a
documentation/discipline problem on your side, not a platform-enforced one.
12. Recipes
Add a type or an attribute
Declare it. Nothing else.
types:
- key: item
name: Item
labelAttribute: title
attributes:
- { key: title, name: Title, type: TEXT, required: true }
- { key: notes, name: Notes, type: TEXT } # new in 1.4.0
- key: sprint # new in 1.4.0
name: Sprint
labelAttribute: title
attributes:
- { key: title, name: Title, type: TEXT, required: true }
A new type gets its seed rows on the next upgrade, because that type has never been seeded
on that installation. A new required attribute is created required, and every object that
already exists is then missing a value for it — allowed, but required: false plus a default in
your own write path is usually kinder.
Rename anything
Names are matched by key, so a changed name is issued as a rename.
- key: item
name: Work Item # was "Item"
attributes:
- { key: title, name: Summary, type: TEXT, required: true } # was "Title"
Renaming a key is not refused — deploy doesn't diff manifests at all (§10). What actually happens: the old key's type/attribute simply stops being managed (it's not in your manifest anymore, so the reconciler ignores it — it stays in the workspace, unrenamed, unseeded, with whatever data it has), and the new key gets created fresh as if it were brand new. Nothing stops you from doing this; nothing warns you either. If you want the old one gone from view, that's a manual conversation with the customer today, not something the platform does for you.
Add a workflow status
The original recipe showed adding a blocked state and two transitions to an existing lifecycle
workflow and described it as reaching installed workspaces. Verified against code:
resolveWorkflows (ProvisioningService.kt:139-153) skips a workflow entirely once its key is
already tracked — if (workflowKeyToId.containsKey(workflow.key)) return@forEach. A new state or
transition added to an existing workflow key is only ever seen by a new installation from that
point on; it never reaches a workspace that already has that workflow. There is no recipe for
getting a new state/transition into an existing install today, as far as this pass could confirm —
a new workflow key would be provisioned fresh, but that's a different workflow, not an addition
to the old one.
Add an OPTION choice
Verified against code: resolveAttributes (ProvisioningService.kt:155-188) only ever checks a
tracked attribute for a rename; its config map (where options/multi live) is never read again
once the attribute exists. Appending a choice to an existing OPTION attribute's manifest
declaration has no effect on installations that already have that attribute — same limitation as
workflow states above. A brand-new OPTION attribute picks up whatever choices you declare at
creation time, same as any other new attribute.
Relax a bound
Dropping required: true or cardinality: one on an attribute that's already provisioned has no
effect — resolveAttributes never revisits required/cardinality for a tracked attribute
(ProvisioningService.kt:155-188). This only does something meaningful for an attribute you're
declaring for the first time in this version, which is created fresh with whatever bound you wrote.
Move the label
The original recipe described Starhive refusing (and logging) a label move when existing objects are
missing the new label attribute's value. Verified against code: a type's label attribute is
resolved and created only once, inside resolveTypes's call to gateway.createType(...)
(ProvisioningService.kt:128, 292-293) — there's no code path that re-points an existing type's
label at all, successfully or otherwise. Changing labelAttribute in a later version's manifest has
no effect on installations that already have that type; it only matters for a type being created for
the first time.
Change the space
The original recipe said name is applied on upgrade and only access is fixed at creation.
Verified against code: space.access isn't a manifest field at all (SpaceDef has only key
and name — AppManifest.kt:85), and there's no renameSpace call anywhere in
ProvisioningService.kt — gateway.createSpace(data.space.name) is only ever called when there's
no tracked space yet. Changing name in a later version has no effect on an installation that
already has the space provisioned.
Re-point a ${config.*} reference
Not a manifest change at all — it is the admin changing a setting on your settings page, which re-points the binding at the type they picked, on the spot.
The original text said changing a static targetType (not config-bound) in the manifest "is a
different thing and is refused." Deploy doesn't check this (§10), and resolveAttributes
(ProvisioningService.kt:155-188) never re-targets a tracked attribute either — it only checks for
a rename. So changing a static targetType in a later version has no effect on an already-created
attribute (neither applied nor refused); it would only take effect on a brand-new attribute.
config:
- { key: linkedType, name: Linked type, type: typeRef, default: item }
types:
- key: timeEntry
attributes:
- { key: workItem, type: REFERENCE, targetType: "${config.linkedType}", cardinality: one }
13. Known limits
- No data migrations. There is no way to backfill a value onto existing objects, or convert one attribute's values into another's.
- No upgrade preview. Nothing found in this pass confirms an admin sees a diff before clicking Update (and whether there's an "Update" button to click at all is itself unconfirmed — see the web-console caution in Building apps §11).
- The original bullet here said a deleted type or attribute is recreated on the next upgrade,
while a deleted workflow is not. Verified against code: it's the other way around at the
type/attribute level —
resolveTypes/resolveAttributes(ProvisioningService.kt:113-188) silently skip a tracked type or attribute that's gone missing from the workspace; neither is recreated. Only a deleted Space triggers full recreation (provisionStageOne, lines 61-73). Whether a deleted workflow is "repaired" is moot either way, since an already-tracked workflow is never revisited regardless of whether it still exists (§9). - "Retired states are still offered as destinations,"
deprecatedStateKeys— removed; this described thedeprecated: truemechanism, which doesn't exist (§11), and nodeprecatedStateKeysfield appears inInstallationApiModels.kt. - "Manifests accumulate tombstones... the deliberate price of never reusing a key" — removed; there's no key-reservation mechanism, so there's nothing accumulating and no price being paid for it. The actual risk is the opposite of what this implied: reusing an old key for something new is not protected against at all (§11).
- Installing and upgrading are web-console-only actions (not confirmed to exist as a UI in this pass, though the backend endpoints are real) or direct API calls. The CLI ships versions; it does not install or upgrade them.
14. Release checklist
app.versionbumped, and the build you are shipping is the one you tested.data.typesand the attribute names your code reads still match.- Installed cleanly into a fresh workspace, reaching
ACTIVE. - Upgraded a workspace that was on the currently published version, not just a fresh
install — that is the path every customer takes. Check specifically for the gaps in §9: a new
workflow state/transition, a new
OPTIONchoice, or a relaxed bound on an existing attribute will not show up after an upgrade the way you might expect from the original version of this guide — only brand-new types/attributes/workflows do. - Every declared slot renders; the settings page works with nothing configured.
- Errors surface as toasts, not blank frames. Light and dark both fine.
starhive app promote— this takes effect immediately for every version; there is no review wait to expect (§6).