Skip to main content

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:

PointerMoved byWho resolves to it
latest deployedevery starhive app deployevery developer of the app, on every workspace
publishedstarhive app promoteeveryone else
Corrected — resolution is by developer status, not a per-installation opt-in

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.0 as 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.0 after 1.4.0 is 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]
FlagDefaultWhat it does
--dir <path>./buildThe built bundle directory to zip and upload.
--manifest <path>./manifest.yamlThe manifest to publish.
--buildoffRuns npm run build first.
--env <prod|dev|local>prodWhich deployment to deploy to.
--api <url>—A specific app-platform URL. Only if Starhive tells you to.

Four HTTP steps, in this order:

  1. 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.
  2. Request an upload URL for the zip.
  3. PUT the zip to that presigned URL. Files sit at the archive root, so index.html is at the top.
  4. 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​

Corrected — there is no per-installation "development builds" opt-in

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.
Could not verify against code

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​

Corrected — promote is never review-gated today

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:

  • remotes isn'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 exactly DEPLOYED, PRIVATE, PENDING_REVIEW, PUBLISHED, DECOMMISSIONED — no PUBLISHED_PENDING_REVIEW.
  • AppRegistryService.promote() (AppRegistryService.kt:527-540) unconditionally sets publishedVersionId once the caller is a developer and the version's bundle is READY. 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.

Corrected — no UPGRADING status, and an upgrade doesn't "keep working" on failure the

way 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.

StatusMeaning
INSTALLINGFirst-time setup running.
NEEDS_CONFIGA required setting is unset. Your settings page must be usable in this state.
ACTIVESet up and running — also what an installation reads while an upgrade is in progress or has failed against it, per the correction above.
FAILEDDeclared in the enum, but this pass found no code path that ever assigns it (see Building apps §9). Unconfirmed as a reachable state.
UNINSTALLEDSoft — 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.

Corrected — the release-safety model in this section (and §9-11 below) does not exist

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 deprecated field anywhere in AppManifest.kt — not on AttributeDef, TypeDef, or WorkflowStateDef. Zero matches for "deprecated" in app-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.key format, app.version non-blank, at least one module, every ${config.*} binding referencing a real config field, every seed key 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​

Corrected — this table significantly overstates what the reconciler keeps in sync

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 — its config map (so OPTION choices and multi), required, and cardinality are set once, at creation, and never updated by a later upgrade.
  • Labels: the label attribute is created and set only inside resolveTypes's call to gateway.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 no renameSpace call 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).

ChangeReaches an existing install?
New typeYes — created
New attribute on an existing typeYes — 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 workflowNo — the whole workflow is skipped once its key is tracked
New OPTION choice on an existing attributeNo — a tracked attribute's config is never revisited
required: true dropped on an existing attributeNo — cardinality is set once, at creation
cardinality: one dropped on an existing attributeNo — same
New labelAttribute on an existing typeNo — resolved only at type-creation time
seed rows added to a type that already existsNo — seeding happens once per type, ever; new installs only
Space name changeNo — 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-pointedYes, when the config value changes — finalizeBindings
Anything removed from your manifestNot refused at deploy — see §10

10. What deploy actually checks​

Corrected — deploy does not refuse breaking changes

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.key is non-blank and dot-notation; app.version is non-blank.
  • At least one module is declared.
  • Every ${config.*}-bound attribute target names a config field that's actually declared.
  • Every seed key names a type that's actually declared.
  • Every workflow has at least one state, no duplicate state keys, exactly one initial state, and every transition's from/to names a real state.
  • Every WORKFLOW attribute 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​

Corrected — there is no deprecated: true mechanism

The 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"
Corrected

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​

Corrected — this does not reach an existing install

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​

Corrected — this does not reach an existing install either

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​

Corrected — only applies to a brand-new attribute

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​

Corrected — this has no effect on an existing install

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​

Corrected — the space's name isn't applied on upgrade either

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.

Corrected

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).
Corrected
  • 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 the deprecated: true mechanism, which doesn't exist (§11), and no deprecatedStateKeys field appears in InstallationApiModels.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.version bumped, and the build you are shipping is the one you tested.
  • data.types and 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 OPTION choice, 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).