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 prod

It 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 checked

Each row is ok, missing, or unknown:

  • missing means the prerequisite is absent. The row prints the exact fix.
  • unknown means 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-as is always unknown. The control plane cannot read service-account IAM policies, so the row prints the gcloud command for each workload instead.
  • runtime-operation-grants and runtime-binding-grant-config are unknown when 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 as unknown. Run the command after putnami cloud login. The detail names the reason.
  • runtime-binding-grant-config is also unknown on a control plane that is not configured with config-api's service account. You cannot fix that from your workspace.
  • database-admin is unknown for a workload whose database does not exist yet. The first deploy creates the database, and the account name comes from it.
  • database-ownership is unknown for the same reason. It is also unknown when you do not hold platform.workspace.manage on 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 is unknown too 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>_owner does not exist yet: the row is missing, and the fix says that deploy creates the role and re-owns the objects.
  • project-identity is unknown when the control plane does not know the workloads' manifest names. The command sends them from your checkout; it cannot when putnami.ci.json selects no workload for the environment.
  • Every control-plane row is unknown when 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 postgres user or a password user. The deploy skips it and continues. The database-ownership row 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>_owner cannot 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 prod

It 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:

  1. The run publishes the impacted projects and releases one immutable release set.
  2. Accepting that release advances canary to the new set, and the platform emits one release_set.channel.advanced fact.
  3. The control plane consumes the fact, finds every environment that follows canary, and compares the set to what each environment runs right now.
  4. 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.json

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

Next