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 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. Checks the schedule quota — refuses the whole manifest, writing nothing, if the revision would hold more schedules than scheduler.schedules_per_revision after the deploy.
  5. Pass one — resolves and validates every entry’s target, writing no schedule.
  6. 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 invocation_target.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 reference → 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 invocation_target.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.

The schedule quota is checked first

Before it resolves any target, deploy reads the blob’s scheduler.schedules_per_revision and counts the schedules the revision would hold once the deploy has run: every schedule it lists, plus each manifest entry whose alias is not among them. deploy never deletes, so a schedule the manifest does not cover still counts. If the count is over the limit, deploy refuses the whole manifest with LIMIT_EXCEEDED and writes nothing: no schedule and no target session. --dry-run runs the same check.
held is the revision’s current count, so the message names the count the deploy would have left and the limit object names the count it found. Under --json the limit object carries id, value and held. If the limits cannot be read, the check is skipped and the platform refuses the create that goes past the limit instead; schedules this deploy created before that refusal stay on the revision. scheduler.min_interval_seconds is never pre-checked: the platform refuses a schedule whose cron fires more often as LIMIT_EXCEEDED, with its limit object, as that write happens, and schedules written before it stay. See Error codes.

--dry-run still creates sessions

--dry-run skips the create_schedule and update_schedule calls, and nothing else. Every resolution still happens, which means an invocation_target.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 takes one of that session’s execution slots. Retention frees a slot as it removes a finished execution — after 90 days by default — so a schedule that fires more often than the session’s limit allows per retention period fills it. A fire into a full session is refused, and nothing breaks loudly: the schedule stays active and enabled, fires on time, and records a refused run, carrying the limit, instead of running anything, until retention frees a slot. Four things worth knowing about the check:
  • It compares a holding with a limit. It reads how many executions the session holds, from its held, and its blob’s workflow.executions_per_session, and warns once the holding reaches 80% of the limit.
  • 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, and neither does a session pass one has just created, which holds nothing.
  • It is skipped when either read fails. An advisory never guesses.
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. invocation_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. A qualified invocation_target.workflow naming a definition in a third blob also needs READ on that blob. 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