Environments follow channels
Point an environment at a release channel, and it deploys itself every time that channel moves. Nobody runs a deploy command, and nothing in the path calls back into CI.
You already publish to channels: a merge to main publishes your projects and
advances a channel, a promotion moves a channel to an existing release set. This
page makes an environment react to that move — it redeploys the workloads whose
content changed, and leaves the rest alone.
Declare a following environment
Add an envs entry to putnami.ci.json. The environment names the channel it
follows and lists the workloads it owns:
{
"envs": {
"prod": {
"channel": "canary",
"workloads": [
{ "select": ["apps/web-api", "apps/worker"] }
]
}
}
}| Field | Meaning |
|---|---|
channel |
the channel this environment follows. Without one, the environment only deploys when you ask it to. |
workloads[].select |
the exact workload projects this environment runs. A workload no rule selects is not part of the environment. |
workloads[].channel |
overrides the channel for those workloads. An environment whose workloads end up on different channels is not deployed by a move — see Not yet supported. |
Selectors are exact project paths. The environment key is a plain name such as
prod or staging.
The control plane reads this file from your connected repository at an exact
commit and accepts it as a numbered revision. Connect the repository first with
putnami cloud source connect --repo <owner>/<repo>. A move deploys against the
accepted revision, so an environment the control plane has not accepted yet
follows nothing.
Before the first deploy
Run the preflight from the workspace root before you merge the change that turns an environment on:
putnami cloud env doctor prodIt checks every prerequisite continuous delivery needs and prints one row per
check. It changes nothing: it reads files in your checkout and makes one read
request to the control plane. Without an environment name, it checks the
environment your workspace link names, then prod.
Environment prod of workspace 0b8f3c9e-…
ok put-binding active put namespace acme
missing oci-registry putnami.workspace.json names no registries.oci.publish with a host and a namespace
fix: set in putnami.workspace.json: "registries": {"oci": {"publish": "oci.putnami.dev/acme"}}
…
not ready: 1 missing, 2 could not be checkedEach row is ok, missing, or unknown:
missingmeans the prerequisite is absent. The row prints the exact fix.unknownmeans the check could not be run. The detail says why, and the row still prints the fix.
The last line counts both. The command exits 1 when any row is missing or
when the control plane does not answer, and 0 otherwise. Any other unknown
row does not fail the command unless you pass --strict, so exit 0 does not
mean that every row was checked: read the summary line, or unchecked in
structured output.
| Row | Checks |
|---|---|
put-binding |
The workspace has an active put namespace binding. |
oci-registry |
putnami.workspace.json sets registries.oci.publish to a host and a namespace, such as oci.putnami.dev/acme. |
oci-binding |
The workspace has an active oci namespace binding. |
environment-definition |
The control plane accepted an environment definition that declares this environment. It does not check that the accepted source revision is on your default branch yet, because Source has no read for that. |
ci-envs |
putnami.ci.json declares the environment with exact workload paths, each with a putnami.json, and sets distribution.memberAttribution to true. |
publish-namespaces |
Each selected workload with a Config schema declares a @putnami/cloud:publish-config namespace, and each one whose build produced a migration bundle declares a @putnami/cloud:publish-migration namespace. |
runtime-operation-grants |
Runtime lets the control plane's callers run operations in this workspace. |
runtime-binding-grant-config |
Runtime lets config-api use its binding in this workspace. |
workload-act-as |
Runtime's service account may act as each workload's service account. |
database-admin |
Each database a selected workload migrates has its db-admin service account. The fix names the exact account ID: for a long database name, that ID is shortened to 30 characters and ends in a hash. |
database-ownership |
In each database a selected workload migrates, the <database>_owner role owns every object the first deploy re-owns in public, migration, and the workload's schemas (see What the first deploy re-owns). A workload that served before continuous delivery created its objects as its own login. The row names the first objects and their owners. The next continuous-delivery deploy re-owns them, and until then the fix prints one transaction per database that re-owns them by hand. |
project-identity |
No deployment or datasource record is still keyed by a workload's manifest name instead of its workspace path. |
These rows can answer unknown:
workload-act-asis alwaysunknown. The control plane cannot read service-account IAM policies, so the row prints thegcloudcommand for each workload instead.runtime-operation-grantsandruntime-binding-grant-configareunknownwhen the control plane cannot read Runtime's grants: Runtime did not answer, or it refused the read. Runtime answers grant reads only for a signed-in user, so a run with a machine token, such as in CI, reads both rows asunknown. Run the command afterputnami cloud login. The detail names the reason.runtime-binding-grant-configis alsounknownon a control plane that is not configured with config-api's service account. You cannot fix that from your workspace.database-adminisunknownfor a workload whose database does not exist yet. The first deploy creates the database, and the account name comes from it.database-ownershipisunknownfor the same reason. It is alsounknownwhen you do not holdplatform.workspace.manageon the workspace: the workspace's migration worker reads each database with the platform's database authority, so the control plane asks it only for a workspace manager. The fix then says to run the command as a manager. The row isunknowntoo when the worker cannot answer: it does not exist yet, or it runs an image older than this check. The detail names the reason, and the fix prints, for each database, the query that lists every object the deploy would re-own. Before the first continuous-delivery deploy,<database>_ownerdoes not exist yet: the row ismissing, and the fix says that deploy creates the role and re-owns the objects.project-identityisunknownwhen the control plane does not know the workloads' manifest names. The command sends them from your checkout; it cannot whenputnami.ci.jsonselects no workload for the environment.- Every control-plane row is
unknownwhen the command cannot reach the control plane or resolve your session. The detail names the failure.
A migration bundle exists only after a build, so run putnami build first if a
workload owns migrations. With --output=json, the command returns the same
rows under data.rows and the counts under data.missing and
data.unchecked. A failed check still carries them.
What the first deploy re-owns in a database
A workload that served before continuous delivery created its database objects
as its own login. Its first continuous-delivery deploy re-owns them to
<database>_owner, in public, migration, and the workload's schemas:
- schemas;
- tables, partitioned tables, foreign tables, views, materialized views, and sequences;
- functions, procedures, aggregates, and window functions;
- enum types, composite types, and domains.
It does not re-own these objects:
- range types and base types;
- members of an extension, such as pgaudit's functions;
- routines internal to a type, such as a range type's constructors;
- a sequence owned by a table column: it moves with its table;
- an object whose owner the db-provisioner can neither act as nor borrow,
such as one owned by Cloud SQL's built-in
postgresuser or a password user. The deploy skips it and continues. Thedatabase-ownershiprow keeps listing it, and the fix says who can re-own it: a superuser, or the db-provisioner once it is a member of that owner. A table among these objects still fails the deploy's grants; - every object in a schema that such an owner owns, whoever owns the object:
<database>_ownercannot create in that schema, so the deploy skips those objects too. The row lists them as waiting for their schema. The deploy's grants on that schema still fail until a superuser re-owns it to<database>_owner; the next deploy then adopts the objects.
The deploy grants <database>_app what the owner's default privileges give:
SELECT, INSERT, UPDATE, and DELETE on each re-owned table and view, and
USAGE and SELECT on each sequence. The migration schema stays read-only
for <database>_app.
The previous serving login keeps working. A Cloud SQL IAM service-account
login (…@….iam) that owned at least one table in those schemas, other than
the database's db-admin, is granted <database>_app. A login that owned only
a view, a sequence, a routine, or a type is not. The grant stays after the
old revision is retired. Once no revision serves as that login, revoke it as
the db-provisioner:
REVOKE "<database>_app" FROM "<login>";Re-owning a relation locks it (ACCESS EXCLUSIVE) until the deploy's database
bootstrap commits. The bootstrap waits at most 5 seconds for each lock. When
the old revision holds a relation longer, the deploy fails instead of making
the old revision's queries wait, and the next deploy retries. A later deploy
finds nothing to re-own and locks nothing.
Enable the environment in one command
putnami cloud env doctor tells you what is missing. putnami cloud env enable
applies every fix your workspace owns, then prints the same table:
putnami cloud env enable prodIt runs the doctor checks first, converges each row that is not ok in the
order the table lists them, then reads the checks again:
| Row | What enable does |
|---|---|
put-binding, oci-binding |
Activates the namespace binding under the distribution.namespace of putnami.ci.json. |
environment-definition |
Accepts putnami.ci.json from the head of your repository's default branch, resolved by the control plane. Pass --source-revision <commit> to accept another commit. It never reads your local checkout. |
runtime-operation-grants |
Enables the Runtime operation grant of each caller the control plane names. |
runtime-binding-grant-config |
Enables config-api's Runtime binding grant. |
oci-registry, ci-envs, publish-namespaces |
Reported only: these are edits to files in your checkout. The row prints the fix. |
workload-act-as, database-admin, database-ownership, project-identity |
Reported only: an operator or the first deploy converges them. The row prints the fix. |
The command prints one line per step, applied, skipped, failed, or
reported, with the fix under each failed or reported step, then the doctor
table:
Enable prod of workspace 0b8f3c9e-…
applied put-binding activated put namespace acme (idempotency key acme-put)
applied environment-definition accepted revision 1 at source revision 4f2e… (default-branch head)
skipped runtime-operation-grants cpa-worker@… already enabled at revision 3
reported workload-act-as not written by this command: the control plane cannot read service-account IAM
fix: gcloud iam service-accounts add-iam-policy-binding …
3 applied, 0 failed
Environment prod of workspace 0b8f3c9e-…
…Running it again writes nothing: every write reads the current revision first
and sends it as the expected revision, so a concurrent change fails the step
with the revision moved instead of overwriting it, and a second run finds
nothing to do. A failed step does not stop the others. The command exits 1
when any step failed or any row is still missing, and 0 otherwise.
The command needs platform.workspace.manage in the workspace. A session
without it is refused in one line before any write. A row the doctor could
not check is reported, not written, because a write on a guess could apply the
wrong fix. With --output=json, the steps are under data.steps, the counts
under data.applied and data.failed, and the doctor result under
data.readiness.
What happens when you merge
Your rules already say which channels a trigger publishes. With the rule
below, a merge to main publishes every impacted project and advances canary:
{
"rules": [
{ "branches": "main", "publish": ["canary"] }
]
}The chain from there is:
- The run publishes the impacted projects and releases one immutable release set.
- Accepting that release advances
canaryto the new set, and the platform emits onerelease_set.channel.advancedfact. - The control plane consumes the fact, finds every environment that follows
canary, and compares the set to what each environment runs right now. - It deploys only the workloads whose image, config or migrations differ.
No step in that chain is a call from the CI run to the control plane. The run
reports its own lifecycle and nothing else; the accepted channel move is what
triggers the deployment. The fact records cause: release for this path.
Only the commit main points at when the release happens may advance a
channel. When two merges land close together, the run for the older commit
fails its release instead of moving canary back to older code, and its run
turns red. The run for the newer commit publishes and deploys. Pull-request
channels and tag channels are not affected.
Promote an existing set
A promotion points a second channel at a release set that already exists — the
usual canary to stable step. It is the same immutable set, so no artifact is
rebuilt.
The channel set is a Distribution provider operation. The platform serves it through the release-set provider seam, which takes a protocol request file:
putnami cloud release-set channel-set --request-file /absolute/path/request.jsonThe file must be an absolute path to a regular, mode-0600 file.
Every environment that follows the target channel then synchronizes exactly as
it does on a merge. The only difference an environment sees is the fact's
cause: advance.
Rolling back is a promotion too: point the channel at the previous set. That is a new move with its own generation, so it opens its own deployment run.
Read the result
A run a channel move opened is marked on the deploy reads — the release status,
the deploy history, the watch stream, and the per-project deployment view. Each
project carries provenance: channel-move instead of cli, and the run carries
a trigger object:
{
"trigger": {
"kind": "channel-move",
"namespace": "acme",
"channel": "canary",
"generation": 42,
"cause": "release",
"moved_at": "2026-09-14T09:12:04Z",
"release_set_id": "rs_9f2c…",
"source_revision": "4eb9cac6d…",
"fact_revision": 137
}
}| Field | Answers |
|---|---|
channel, namespace |
which channel moved |
generation |
that channel's own counter for this move; it increases by one per accepted move |
cause |
release for a publish, advance for a promotion |
moved_at |
when the channel moved, not when the deployment started |
release_set_id |
the immutable set the environment was moved onto |
source_revision |
the commit the deployed content was cut from |
Re-fetch a past release's per-project detail with:
putnami cloud deploy status <release-id>There is no filter for triggered runs. The trigger object is present on the
runs a move opened and absent on the ones you submitted, so filter on it in your
own tooling.
When a move deploys nothing
Not every channel move produces a deployment. Each case below is recorded on the move rather than dropped:
| Outcome | What happened |
|---|---|
no_follower |
No environment of the accepted definition follows that channel, or the control plane has accepted no definition for the workspace. |
not_deployable |
The release set does not cover a workload an environment declares. The record names the workload — for example a project that published no image member, or no config member, in that set. One uncoverable environment holds the whole move: no follower of that channel is deployed. |
converged |
Every declared workload already runs exactly these artifacts. Nothing differs, so nothing is deployed. |
superseded |
A later generation of the same channel already settled, whether or not it deployed anything. The older move is dropped instead of walking the environment backwards onto a stale set. |
A move that arrives twice is a replayed delivery: the second delivery is a
no-op, and the move keeps the outcome it already recorded. One move opens at
most one run per environment.
A move also deploys a subset of an environment. The comparison is per workload, over three things: the container image, the authored config document, and the ordered list of migration bundles. A workload whose three match is left running; a workload whose last deployment failed or is still in flight is redeployed, because its state is unknown.
Not yet supported
| Not supported | What happens instead |
|---|---|
constraints.approval: manual |
The environment is not deployed by a channel move. Deploy it yourself with putnami deploy --env <env>. |
Progressive rollouts (rollout.strategy: progressive, steps, timed advance, abort signals) |
Only all-at-once is accepted. |
| Environment variants | An environment declaring variants is refused. |
| An environment whose workloads follow different channels | The move is recorded as not deployable rather than deploying part of the environment. |