Automate with CI/CD
Once you have verified your app by hand in your Staging sub-account, the next step is to make releases repeatable: build and submit from a pipeline instead of a developer's laptop, and promote the exact code you tested to production.
This page describes a setup that keeps the Staging and production apps completely separate while guaranteeing they contain the same code — and that lets your developers release without ever holding admin rights in your production account.
Put your code in a library, not in the app
Step 4 of the Staging environment flow asks you to create a QA version of your app. Do this by sharing one extension between two thin apps rather than copying the source, so there is only ever one implementation to test and maintain.
my-workspace/
├── libs/app-ext/ ← all the code: components, logic, unit tests
└── apps/
├── myapp-staging/ ← manifest only
└── myapp/ ← manifest only (production)
Each app is a wrapper whose manifest imports the same extension. The package name
always differs — it is the app's identity on the platform — and the installation and
marketplace blocks differ according to who each app is for.
Your Staging wrapper is always a private app targeting only your Staging account, so you can install it and test without it being visible to anyone else:
import appExt from '@yourorg/app-ext';
installation: {
accountIds: ['<staging-account-id>'],
pricingPlanPolicy: { ALL_PLANS: 'PREINSTALLED' },
},
marketplace: { showInMarketplace: false, /* ... */ },
extensions: [appExt],
Your production wrapper depends on how the app is distributed. Do not copy the Staging
shape onto a Marketplace app — showInMarketplace: false would hide it from the store.
If your app is private to your own accounts, the production wrapper mirrors the Staging one, pointing at your production account instead:
import appExt from '@yourorg/app-ext';
installation: {
accountIds: ['<production-account-id>'],
pricingPlanPolicy: { ALL_PLANS: 'PREINSTALLED' },
},
marketplace: { showInMarketplace: false, /* ... */ },
extensions: [appExt],
If your app is customer-facing on the Marketplace, keep the production marketplace
configuration and install policy you intend to ship — step 9 of the
Staging environment flow — and only the package name and the shared
extensions import are carried over from the Staging wrapper:
import appExt from '@yourorg/app-ext';
// installation and marketplace: your intended production configuration.
// See ../publish-app/marketplace-config.md
marketplace: { showInMarketplace: true, /* ... */ },
extensions: [appExt],
A Marketplace version always goes to manual review — see Review & approval — so plan for that step in your release, rather than expecting the production publish to land immediately.
Because your tests run against libs/app-ext, testing it once covers both published apps.
The name in each app's package.json becomes the app's identifier on the platform, so
the two apps must have different package names. That name also namespaces the app at
runtime: extension routes are /fleet/app/<orgId>/<appId>/<extensionId>, so two apps
built from the same extension never collide — even when both are installed in the same
account.
Give each account its own API key
An app is published to an account, by a key belonging to that account. Create one API key per account, so the credential that can reach production is never the one your developers use day to day.
| Account | Key created by | Key held by | Publishes |
|---|---|---|---|
| Staging | Staging account admin | Your CI pipeline, and developers | @yourorg/myapp-staging |
| Production | Production account admin | A protected CI context only | @yourorg/myapp |
Each key is created under Administration → API Keys with iris.app.submit as its
only allowed scope, and is used from a pipeline exactly as described in
CI/CD publishing. The App SDK exchanges the Client ID
and Secret for a short-lived token itself, so no browser login is involved.
This is the pipeline equivalent of step 3 of the Staging environment flow — "log in as the Staging account before creating the QA app". Publishing with the Staging account's key makes the Staging account the owner of the QA app, exactly as an interactive login would.
The Client Secret is shown once, when the key is created. A production admin can paste it
straight into your CI system's secret store without anyone else ever reading it. Never
commit it — it is easy to leave a .env file next to your app while getting started and
forget it is tracked in git. If a secret is ever committed, revoke the key in Manager and
issue a new one; rewriting history is not enough.
Promote the commit, not the artifact
There is no "move this build to production" action, and you do not need one. Because the production app is a separate package, it is built separately — so anchor both builds to the same commit and let the source guarantee they match.
A tag-driven pipeline does this well:
| Trigger | Key used | Publishes to |
|---|---|---|
Merge to main | Dev key, if you keep a separate dev account | Dev account |
Tag staging-v1.4.0 | Staging key | Staging account |
Tag prod-v1.4.0 on the same commit | Production key | Production account |
Two things make this reliable:
- Tag the commit you tested. Point
prod-v1.4.0at the exact commit thatstaging-v1.4.0named, rather than rebuilding from a branch that has moved on. Afterwardsgit diff staging-v1.4.0 prod-v1.4.0is empty — a direct answer to "is production running what we tested?" - Bump the version every time. A version can never be submitted twice; the submit is
rejected if that version already exists. Deriving the app's
versionfrom the tag keeps every publish unique and traceable.
Gate the production job on the prod-* tag and on the CI context that holds the production
key, then restrict who can push that tag. That, rather than account permissions, becomes
your release control.
Review both releases side by side
If your production app is private and your Staging account sits under the account
you publish production from, you can target both apps at Staging — add the Staging account
to the production app's installation.accountIds alongside your production account. Both
releases then appear in the Staging account's menu, so a tester can switch between the
current production version and the release candidate without changing accounts.
The two apps stay completely independent: they have different URLs, and each is pinned or unpinned on its own.
For this to publish without waiting for review, all of the following must hold:
- Staging is a descendant of the publishing account. A Staging account created alongside production under a shared parent is a sibling, not a descendant, and is outside the hierarchy the platform checks.
- You publish production with the production account's key, since the hierarchy is resolved from the account that publishes.
- Both manifests keep
showInMarketplace: falseand use a specificaccountIdslist rather than"ALL_ACCOUNTS".
If any of those does not hold — most commonly because the production app is a Marketplace app — the publish still succeeds, but that version waits for manual review. See Review & approval.
Both apps are built from the same extension, so they share the extension's menuItem.name
and appear under the same label. Leave menuItem.description unset in the shared extension
and each entry falls back to its own app's marketplace.description in the Applications
menu — a good place to say "release candidate" or "production". Alternatively, set a
distinct menuItem.name per app.
Next step
Learn what triggers automatic approval versus manual review in Review & approval.