---
name: ci-refuses-to-start-deploy-by-hand
description: The pipeline refuses a run before it starts, so the deploy has to happen from a workstation — told apart from a real failure first, and with what the manual path costs put back afterwards.
runbook:
  schema_version: v1
  semver: 1.0.0
  profile: P1
  min_runtime_profile: R2
  domain: cloud-infra
  targets:
    - github-actions
    - terraform
    - aws
  trigger: symptom
  execution: human-only
  risk: irreversible
  blast_radius: service
  duration: ">30m"
  capabilities: []
  publisher: std
  lang: en
  license: CC-BY-4.0
  inputs:
    environment:
      type: string
      required: true
      description: which environment is being deployed, as your tooling names it
    asset_url:
      type: string
      required: false
      description: a URL that only the new build serves, for confirming what is live
  steps:
    - id: s1
      kind: check
      title: Check how long the run lasted
      expect: the run ended in seconds without executing a job
      on_fail: escalate:s11
      next: s2
    - id: s2
      kind: decision
      title: Refused, or failed?
      question: Did the job start and then fail, or did it never start?
      branches:
        started: s11
        default: s3
    - id: s3
      kind: check
      title: Read the run's annotation for the reason
      expect: the annotation names an account condition, not a step of the build
      on_fail: escalate:s11
      next: s4
    - id: s4
      kind: check
      title: Check the workstation has what the pipeline had
      expect: credentials, the key that decrypts deploy secrets, and the toolchain are all present
      on_fail: escalate:s11
      next: s5
    - id: s5
      kind: check
      title: Confirm the working tree is clean and pushed
      expect: nothing uncommitted, and the branch exists on the remote
      on_fail: escalate:s11
      next: s6
    - id: s6
      kind: action
      title: Produce the plan without applying it
      risk: read-only
      expect: a change list is printed
      on_fail: escalate:s11
      next: s7
    - id: s7
      kind: check
      title: Read the plan for destroyed durables
      expect: only build artefacts are replaced; no bucket, distribution, role or table is destroyed
      on_fail: escalate:s11
      next: s8
    - id: s8
      kind: decision
      title: Is the plan the shape a deploy has?
      question: Does the plan destroy anything that holds state or serves traffic?
      branches:
        destroys-state: s11
        default: s9
    - id: s9
      kind: action
      title: Apply, confirming each stack
      risk: irreversible
      requires_approval: true
      expect: each stack reports the changes the plan named
      on_fail: escalate:s11
      next: s10
    - id: s10
      kind: check
      title: Confirm what is live is the new build
      expect: an address only the new build serves answers 200
      on_fail: escalate:s11
      next: s12
    - id: s12
      kind: action
      title: Put back what the manual path disturbed
      risk: reversible-write
      expect: change-detection state and the local environment file are as they were
      on_fail: escalate:s11
      next: end:success
    - id: s11
      kind: escalate
      title: Escalate to whoever owns the pipeline
      to: team:platform
  source:
    url: https://runbooks.directory/r/std/ci-refuses-to-start-deploy-by-hand/source.md
    fetched_at: 2026-09-04
    upstream_state: current
---

## When to reach for this

A deploy is needed and the pipeline will not run it — not because the build broke, but
because the pipeline itself was refused: billing failed, a spending limit was reached, a
quota ran out, a runner pool is empty. The branches are pushed and nothing is wrong with
the code, and production stays on the old release for exactly as long as nobody deploys it
by hand.

There is nothing to wait for in this state, and nothing in the repository to fix. That is
what makes it worth writing down: the temptation is to look for the bug.

## Telling a refusal from a failure

By duration, before anything else. A build and its tests take minutes; a refusal ends in
seconds, having executed nothing. If the run is quick and the annotation talks about the
account rather than about a step, it is a refusal.

This matters because the two lead opposite ways. A failure is fixed in the repository. A
refusal cannot be — no change to the code will make a pipeline run that was declined
before it began — and hunting for one burns the time the manual deploy was going to take.

## Reading a plan

Producing the plan first is not caution for its own sake. It is the only point at which
the whole change is visible: an apply asks for confirmation per stack, and by then it
shows the stack in front of you rather than the shape of the whole deploy.

What a normal deploy looks like: build artefacts replaced, a handful of functions updated
in place, some bookkeeping entries for the previous build's assets destroyed. Content-hashed
filenames change every build, so their bookkeeping churns every build.

What means stop: a bucket, a CDN distribution, an IAM role, a database or a table on the
destroy list. That is not a deploy that looks unusual, it is a deploy that is about to take
something away that nothing here intended to touch. Stop and find out why the state
diverged from the configuration before anything is applied.

Nothing here uses an auto-approve flag. On a production estate that flag turns a typo into
an incident, which is why the step asks for a human decision per stack.

## What the manual path costs

A pipeline does more than run commands, and doing its job from a workstation quietly skips
the rest. Three costs are worth knowing in advance, because each is discovered later
otherwise:

**Change detection may be cleared.** A pipeline that skips unchanged components usually
records what it deployed — a tag, a marker, a stored hash. If that record is written by the
pipeline rather than by the infrastructure tool, applying from a workstation removes it, and
the next pipeline run considers everything changed and redeploys all of it. Not a breakage;
an expensive run nobody asked for.

**The local environment file may be overwritten.** Decrypting deploy secrets often writes
them over the file the local tooling reads. After deploying production, scripts that read
that file are pointed at production — including the ones that write. Keep a copy, or decide
deliberately which environment you want to be in afterwards.

**It builds from the disk, not from the branch.** A pipeline deploys what is committed. A
workstation deploys what is in front of it, uncommitted changes included. That is how code
nobody else has ends up in production, which is why the tree is checked before the plan
rather than after the apply.

## What an agent cannot do here

`execution: human-only`, and not as a formality. The apply is irreversible at the blast
radius of a service; the plan needs a judgement about which destructions are ordinary and
which mean the state has diverged; and the whole procedure begins with reading a message
written for a person. `min_runtime_profile: R2` says what a supervisor would have to enforce
before any of it were delegated, and the answer today is that none of it is.

## Verifying the deploy without being fooled

Check an address only the new build serves — a content-hashed asset, a version endpoint —
rather than a page. A page can be cached, and on environments behind an authentication gate
a redirect to the login screen is not evidence of anything about the build.
