Skip to main content
deploy is the command that writes. It validates every entry’s target, then creates the schedules the revision does not have and updates the ones that differ, matching by alias — so a second deploy of an unchanged manifest issues no calls at all rather than duplicating what is already there. It is the only command in this group that needs a writable revision, and the only one that changes anything on the platform.

Synopsis

There is no per-schedule narrowing flag and no --force. A manifest describes one revision’s schedule set, and deploy acts on that set.

What it does

  1. Loads and validates the manifest offline. Alias grammar, duplicate aliases, repeat and its timing field, target blob and revision shapes, and the 256-character target.description cap are all answered before a single call goes out.
  2. Resolves the scheduler blob and revision. This is a write: status ready, phase draft or managed.
  3. Pages list_schedules to completion and keys the result by alias.
  4. Pass one — resolves and validates every entry’s target, writing no schedule.
  5. Pass two — for each entry, creates it if absent, updates it if fields differ, and skips it entirely if not.
Under --dry-run the schedule lines read would create and would update. The session line does not hedge, because the session really was created. Under --json the payload carries blob, created, updated (each with alias and fields), unchanged, sessions_created, dry_run and drift, alongside the envelope’s schema_version.

Two passes, and the exact limit of that guarantee

Every target is validated before any schedule is written. A manifest whose third entry names a workflow that does not exist fails with no schedule created at all — not the first two and then an error. That matters more here than it would elsewhere, because a half-deployed schedule set is worse than a refused one: the half that landed is already firing on its cron. There is no scheduler prune yet to take it back off, so an accidental partial deploy leaves behind schedules you have to remove through the web UI. blobhub workflow deploy runs two passes as well, and they guarantee the opposite thing. Its passes are upload then check, so a failure leaves a fully applied deploy and a report that stops at the first failing definition — it cannot roll an upload back, so it makes sure nothing is half-applied by finishing every upload first. Here the passes are validate then write, so a failure leaves nothing written at all. Both orderings exist for the same reason: neither command can undo its own writes, so each puts the unrecoverable step where a failure cannot strand it. The guarantee covers schedules. It does not cover sessions. Pass one is what resolves each entry’s target.session, and resolving an alias that does not exist yet creates it. So a manifest that fails on a later entry can still leave earlier entries’ sessions behind:
— and the first entry’s session now exists on the target revision, though no schedule was written and nothing will ever append to it. That is accepted rather than fixed: an empty session fires nothing, costs nothing, and the corrected re-run reuses it. It is reported under sessions_created precisely because it is the one piece of state a reader could not infer from the schedules that were, or were not, written. Nothing in this CLI deletes a session. Within pass one, each entry is validated blob → revision → workflow alias → session, in that order, and the session is deliberately last: every check that can still refuse the target runs while refusing is free. A typo in workflow: therefore leaves no session behind for its own entry.

Create, update, unchanged

Matching is by alias, which is why alias is required on every manifest entry even though the platform does not require one on a schedule. It is the manifest’s identity for a schedule, and what makes a redeploy an update rather than a duplicate. The comparison is the same one diff reports, reused rather than re-derived — the two can never disagree about what “changed” means. Only the timing field that repeat gives meaning to is compared — diff explains why. An update never sends an alias, so deploy cannot rename a schedule and cannot adopt one that has no alias. Schedules the manifest does not cover are drift: reported, left untouched, and still firing.

--dry-run still creates sessions

--dry-run skips the create_schedule and update_schedule calls, and nothing else. Every resolution still happens, which means a target.session alias that does not exist yet is created by the dry run:
The session line is printed without the “would” hedging the schedule lines get, deliberately. Run the same dry run twice and the second reports no session creation, because the first one already did it. If what you want is a preview with no side effect at all, use diff. It is the command built for that, and the session is the only thing the two disagree about.

SESSION_NEAR_SATURATION

An advisory, on stderr, exit code unchanged:
A recurring schedule pins one session for its entire life, and every fire appends an execution to it. The per-session execution limit is enforced when an execution is created, so a session that reaches it does not break the schedule loudly — the schedule stays active and enabled, fires on time, and each fire records an invocation_error instead of running anything. Four things worth knowing about the check:
  • It counts, then stops. Counting halts as soon as saturation is proven, so the number in the message is a lower bound past that point, not an exact total. A saturated session is exactly the case where a full count would be most expensive.
  • It runs for every entry, whether the schedule was created, updated, or left unchanged. It is a fact about the session, not about what deploy just did to the schedule record.
  • one_time schedules never trigger it. One more execution cannot saturate anything.
  • It names the configured maximum only when limits are readable, which needs an admin-scoped key. With the read/write keys the platform recommends for automation, you get the count and no maximum.
Like every advisory it is suppressed when stderr is not a TTY, so a CI log will not carry it.

What deploy cannot check for you

Two classes of failure survive a clean deploy, and both surface later. cron_expression and timezone are validated when the schedule is written, not before. Neither is constrained by any schema, and the CLI passes both through as opaque strings. A malformed expression is refused by the platform as the write happens, surfacing as API_COMMAND_FAILED carrying the server’s own invalid_cron_expression — and because writes happen in pass two, a bad cron on a later entry can leave earlier entries deployed, which is the one hole in the all-or-nothing guarantee above. Check your syntax against cron expressions first; the plausible 0 9 * * MON-FRI * is rejected, and 0 9 ? * MON-FRI * is the working form. target.input_data is not validated until the schedule fires. The scheduler’s schema types it as a bare list, so any list at all deploys successfully. The item shape is enforced by create_execution at fire time, where a violation surfaces as an invocation_error on the schedule’s execution history — a schedule that deployed cleanly, looks healthy in ls, and has never once run its workflow. See Create Execution for the shape.

Permissions

Firing a schedule writes an execution into the target revision, so the platform requires WRITE on the target blob as well as on the scheduler blob. A key with only read access to the target is refused. The target revision itself is only read. Deploying against a target in commit phase is fine, and is the normal case — what you schedule is usually a committed workflow.

Errors

Every code above, with its remediation, is in Error codes.

See also