Deploy to an environment via CircleCI
This skill approves a CircleCI manual-approval gate for a chosen environment and watches the resulting deploy job. It is project-agnostic: it discovers the CircleCI project, workflow, and job names automatically, so the same skill works across every repo whose pipeline gates deploys behind a manual approval.
Invoking with no input IS a deploy request
When this skill is invoked with no extra prompt or arguments, treat the invocation itself as "deploy now". Do NOT ask the user for a prompt or a target environment first — go straight to running the script (in the background) and let environment resolution handle the rest:
- Run
deploy.shwith no environment argument. - If the repo has exactly one deploy environment, it deploys that one.
- If it has several, the script exits 10 and prints
DEPLOY_ENVS=...; only then ask the user which environment (with AskUserQuestion) and re-run with it.
A prompt/argument is optional — it only narrows the target when given (e.g. "deploy to sat"). Its absence is not a reason to stop and ask.
When this triggers
Direct requests to deploy a project to some environment:
- "deploy to dev" / "ship to staging" / "release to prod"
- "deploy this" (environment resolved automatically — see below)
- naming a repo you haven't cloned — "deploy backend delivery to sat" (remote mode — see "Deploying a repo you haven't cloned" below)
When NOT to trigger:
- Local
docker compose upor running the app locally. - "How do I deploy?" — that's a documentation question; explain the steps instead.
- Pipelines with no manual approval gate where there's nothing to approve.
Resolving the target environment
- User named an environment ("deploy dev", "ship to sat") → that environment.
- User did not name one ("deploy this") → the script inspects the workflow's
deploy jobs:
- exactly one deploy environment → it's used automatically, no prompt.
- multiple environments (e.g.
deploy_dev+deploy_sat) → the script exits 10 and printsDEPLOY_ENVS=dev,sat. Ask the user which one with AskUserQuestion, then re-run with the chosen environment.
Cascading through prerequisite environments
Most pipelines gate environments in a chain — build → hold_dev → deploy_dev → hold_sat → deploy_sat → hold_prod → deploy_prod — where a later environment's
approval gate only opens once the earlier deploy has succeeded. Asking to deploy
prod on a fresh pipeline therefore can't just approve hold_prod; that gate is
still blocked behind dev and sat.
The script handles this automatically. It derives the full prerequisite chain to
the target from the workflow's job dependency graph and walks it in order:
approve hold_dev → watch deploy_dev → approve hold_sat → watch deploy_sat
→ approve hold_prod → watch deploy_prod. This is hands-off — it actually
deploys every prerequisite environment on the way to the target, in one
background run. Stages already deployed (gate approved, deploy succeeded) are
skipped, so it stays idempotent on re-runs and partial pipelines.
Single-environment pipelines (one deploy job, like a dev-only repo) are just a one-stage chain — behaviour is unchanged.
Multiple approval gates per stage (auto-detected)
A stage isn't limited to a single gate. Some pipelines guard one environment with
several sequential approvals — e.g. hold_build_prod → build_prod → hold_prod → deploy_prod, where the first gate releases the build and the second releases
the deploy. The script derives the complete ordered set of approval gates on
each deploy job's dependency path straight from the graph (no per-repo config) and
clears them one at a time: approve hold_build_prod → wait for build_prod →
approve hold_prod → watch deploy_prod. This generalises to any workflow shape:
- one gate per deploy (the common
hold_x → deploy_x) → approve the one gate; - several sequential gates → approve each in dependency order, waiting for each to
open (a later gate often stays
blockeduntil the earlier one's build runs); - no gate (auto-deploy) → approve nothing, just watch the deploy;
- cascaded stages → each stage clears only its own gates; a prerequisite env's gate is never re-approved under a later env.
Because the gate set is graph-derived, multiple env-matching approval jobs are no
longer an exit-9 ambiguity — they're simply that stage's gate list. --approval-job
still overrides the target stage when you need to pin one explicitly.
Announce the plan. The script prints a Cascade: dev → sat → prod line once
it resolves the chain (it appears early in the background output). When the chain
has more than one stage, relay it to the user up front so they know prod will be
preceded by automatic dev + sat deploys.
How to run it
Invoke the bundled script. It is idempotent and safe to re-run.
.claude/skills/ci-deploy/scripts/deploy.sh <env>
On Windows PowerShell, invoke the bundled wrapper instead. It finds Git Bash,
reads the Windows CircleCI CLI token if needed, and forwards all arguments to
deploy.sh:
.\.claude\skills\ci-deploy\scripts\deploy.ps1 <env>
<env> is optional (see resolution above). Useful options:
deploy.sh <env> --repo <owner/repo> # remote mode: deploy a repo you haven't cloned
deploy.sh <env> --branch <name> # override the branch (remote mode defaults to master)
deploy.sh <env> --sha <commit> # target a specific commit instead of the branch tip
deploy.sh <env> --workflow <name-or-id> # pin the workflow when a pipeline has several
deploy.sh <env> --deploy-job <name> # explicit deploy job (resolves exit-9 ambiguity)
deploy.sh <env> --approval-job <name> # explicit approval gate
deploy.sh <env> --service <name> # monorepo: deploy ONE service (see Monorepo service mode)
deploy.sh <env> --service-path <glob> # override the service's source path (default services/<name>)
deploy.sh <env> --yes # skip the foreign-commit confirmation gate (exit 12)
deploy.sh <env> --test-job <name> # restrict the "build failed" check to one job
deploy.sh <env> --rerun # cancel + rerun the workflow from START, then watch the fresh run
deploy.sh <env> --rerun-from-failed # cancel + rerun only failed jobs + downstream, then watch
The path above assumes the skill is installed into a project's .claude/skills/.
When run from the plugin cache, use the script's own absolute path — the script
resolves its sibling detect.py relative to itself either way.
Ownership check (exit 12)
Before deploying, the script prints an ownership summary for the resolved
pipeline: who triggered it, the commit author + subject, and — for monorepos —
which services the commit changed. (The run-*-build parameter values are not
exposed by the CircleCI API, so changed services are derived from the commit's
services/<name>/ paths, which is exactly what path-filtering keys off; a
non-monorepo simply has no such paths and shows none.)
Confirmation gate. The script compares the service's latest workflow (the
target commit — the newest change to services/<svc>/) against your most recent
commit on the branch (a commit maps 1:1 to a pipeline, so equal SHAs mean the same
workflow). It requires confirmation (exit 12, bypass with --yes) when either:
- the service's latest change is not your last commit (someone changed it after you, or your last commit didn't touch this service); or
- the target commit didn't actually change the service (deploying it would halt into a no-op) — this check always runs.
If the service's latest change is your last commit and it changed the service, it
deploys directly. On exit 12: show the user the reasons (triggered-by actor, target
commit + author, your last commit, changed services, commit message) and ask them to
confirm. Only after they approve, re-run the exact same command with --yes
appended. Never pass --yes pre-emptively.
Monorepo service mode (--service)
Some repos are monorepos that build and deploy several services from one
path-filtered CircleCI pipeline — each service is (re)built only when its own files
change. accounting-service, item-management-service, … are services inside
premier-store-os, not standalone repos. For these:
- Do not
gh search reposfor the service name — it is not a repo. - Do not target the branch tip. A tip pipeline that changed a different service halts this service's deploy job into a green no-op (now caught as exit 11).
Name the service with --service instead:
# from inside the monorepo clone
deploy.sh sat --service accounting-service
# or without a clone
deploy.sh sat --repo Mercaso/premier-store-os --service accounting-service
--service X resolves:
- Jobs —
deploy_<X>_<env>and gatehold_<X>_<env>by convention (override with--deploy-job/--approval-job, or the per-repo config below). Requires an environment argument. - Target commit — unless
--shais given, the latest commit on the branch that changed the service's path (services/<X>/by default, or--service-path), so the pipeline that actually built the service is chosen.
Optional per-repo config
Drop .claude/ci-deploy.json in the monorepo root to declare a non-default layout
(templates use {service} and {env}):
{
"service_path": "services/{service}",
"deploy_job": "deploy_{service}_{env}",
"approval_job": "hold_{service}_{env}"
}
The defaults above already match premier-store-os, so the file is optional there.
Deploying a repo you haven't cloned (remote mode)
When the user names a repo they have not cloned — e.g. "deploy backend
delivery to sat" — resolve the repo and the target commit with gh, then hand
an explicit slug + commit to the script. This requires an authenticated gh
CLI. The default GitHub org is Mercaso.
Resolve the repo from the description. Search the org:
gh search repos --owner Mercaso "<description>" --limit 10 --json fullName,description- Exactly one strong match → use it; tell the user which repo you picked when you start the deploy.
- Several plausible matches → ask the user which one with AskUserQuestion.
- No match → tell the user, ask them to refine the description.
- If the user already gave an explicit
owner/repo, skip the search. The org defaults toMercasoonly when the user gives a bare name; an explicit owner always wins.
Resolve the target commit — the user's latest commit on
master. Filter by GitHub author (committer changes under rebase/squash):login=$(gh api user --jq .login) gh api "repos/<owner>/<repo>/commits?sha=master&author=$login&per_page=1" --jq '.[0].sha'- Got a SHA → deploy that commit.
- Empty (the user has no commit on
master) → tell the user; offer to deploy the plainmastertip instead (drop--sha), or stop.
Deploy. Run in the background, same as local mode:
deploy.sh <env> --repo Mercaso/<repo> --branch master --sha <sha>Environment disambiguation (exit 10) and every other exit code behave exactly as in local mode.
No clone means no auto-fix loop. The exit-5 auto-fix loop below commits and
pushes fixes, which needs a local working copy. In remote mode there is none, so
on exit 5 fetch the failed logs (fetch_failed_logs.sh) and report the
failure — do not enter the commit/push loop. If the user wants the build
fixed, tell them a local clone is required first.
What it auto-detects
- Project slug from
git remote get-url origin(GitHub →gh/, Bitbucket →bb/). - Target commit: the current branch's remote tip by default (
--shaoverrides). - Pipeline: the CircleCI pipeline whose revision matches the target commit on the current branch.
- Workflow: the workflow in that pipeline containing a deploy job (
--workflowto pin by name or workflow ID). - Deploy / approval / test jobs: by naming convention — a deploy job is
deploy_<env>/deploy-<env>/release-<env>etc.; the approval gate is the approval-type job matching the environment; everything else is a build/test job.
Run it in the background
The script runs in two phases per stage: it waits for the build/test jobs (2–4 min
typical, up to ~13 min ceiling), approves the gate, then watches the deploy job
until it finishes (3–8 min typical, up to ~20 min ceiling). A single-environment
deploy is usually under 15 minutes. A cascade multiplies this by the number of
prerequisite environments — deploying prod through dev + sat runs three
deploy stages back-to-back, so budget proportionally longer. Always run it in the
background — you'll be notified when it completes, then you can report success or
surface the failure.
When telling the user what you're doing, name the environment and the commit (short SHA). The script prints the workflow URL once it locks onto a pipeline; include that link so the user can watch in parallel. When the script exits, its final lines say whether the deploy succeeded or how it failed — relay that.
When the build fails: auto-fix loop
Exit code 5 means a build/test job failed. Treat this as part of the normal deploy flow, not a stopping point — the user invoked this skill to get a deploy out, not just to learn the build broke. Run the loop below until the deploy succeeds, or until one of the explicit stop conditions hits.
Remote mode (--repo, no local clone) skips this loop entirely — there is no
working copy to edit, commit, or push. On exit 5 in remote mode, fetch the failed
logs and report the failure (see "Deploying a repo you haven't cloned"); offer a
local clone if the user wants it fixed.
The loop is your responsibility, not the script's. The script does deploy
mechanics; you do diagnosis, fix, and retry. Re-running deploy.sh after each fix
automatically picks up the new tip of the current branch.
One iteration
Grab the workflow ID. The script's background-output file contains a line like
Workflow: a50e082f-…. Read the file (the path is in the task notification you got) and extract that UUID. It's how you fetch logs.Fetch the failed step output. Run:
.claude/skills/ci-deploy/scripts/fetch_failed_logs.sh <workflow-id>On Windows PowerShell, use:
.\.claude\skills\ci-deploy\scripts\fetch_failed_logs.ps1 <workflow-id>With no job name it inspects every failed job and tails each failed step to ~300 lines, so you read the actual diagnostic, not 50k lines of resolver chatter. Override with
TAIL_LINES=N, or pass a job name as the 2nd arg.Diagnose the root cause. Common shapes:
- Test assertion /
StopIteration/AttributeError— actual code or test bug.StopIterationfrom aMagicMockalmost always means a mock'sside_effectlist is too short because a new code path was added without updating the mock. - Import /
ModuleNotFoundError— broken module path or missing dep. Check the project's dependency manifest and the import statement. - Type / lint — file + line is right there in the message. Fix it.
- Infrastructure flake (network timeout, container 137, "checkout failed") — not something you patch in code. See stop conditions.
- Test assertion /
Apply the smallest fix. Don't add features, don't refactor, don't widen the change beyond what the failure needs. Don't bypass tests with
--no-verify,xfail,pytest.skip, or# type: ignoreunless the user explicitly asked.Commit and push. One focused commit, message like
Fix <thing> caught by CI. Push to the current branch (the branch the deploy targets).Re-run the deploy script in the background. It auto-targets the current branch's new tip, so your fix gets picked up. Wait for the task notification.
On exit 0 → deploy succeeded, report success. On exit 5 again → loop back to step 1, reading the new logs, not your memory of the old failure. On exit 6, 7, 8 → see "Other exit codes" below.
Give the user one tight update per iteration: which commit you pushed, what you fixed, the new workflow URL. Don't narrate every poll.
Stop conditions (do NOT keep looping)
Pause and ask the user the moment any of these hit:
- Iteration count ≥ 5. Hard ceiling. If five fixes haven't gotten a green build, something deeper is wrong.
- Same root cause two iterations in a row. Your fix isn't working.
- Infrastructure-level failure with no per-step output, or
fetch_failed_logs.shreports no failed steps. Likely a CI runner issue, not code. - Failure requires a decision you can't make (e.g. an API contract changed and the test asserts the old shape — which side is correct is a product call).
- Failure looks flaky (network blip, timing-sensitive test). Don't "fix" something that wasn't broken; offer to retry without a code change.
- The build/test jobs never ran (exit 3 or 4): the pipeline didn't ingest the commit or the workflow filter rejected the branch. Don't loop — investigate.
Detection ambiguity (exit 9 / 10)
- Exit 10 — environment omitted and multiple deploy environments exist. The
script prints
DEPLOY_ENVS=.... Ask the user which environment with AskUserQuestion, then re-run with that environment as the first argument. - Exit 9 — multiple jobs match (e.g.
deploy_devanddeploy_dev_canary, or several workflows have deploy jobs). The script prints the candidates. Pick the right one and re-run with--deploy-job/--approval-job/--workflow. If it's genuinely unclear which is correct, ask the user. Note: multiple approval gates on a stage's path are NOT an exit-9 case — they're auto-detected and approved in order (see "Multiple approval gates per stage" above). Exit 9 is only for ambiguous deploy jobs or workflows. - Re-runs are handled automatically — they no longer trip exit 9. When a
pipeline holds several workflow runs of the same name (CircleCI leaves the
superseded runs
canceledafter a "rerun from beginning/failed"), the script drops the canceled runs and keeps the single live run, so the duplicate-name reruns collapse to one candidate. Only genuinely distinct workflows (different names) still surface as exit 9 and need--workflow. This means a partially approved rerun (e.g.deploy_devalready succeeded,hold_sat/hold_prodstill on hold) resumes cleanly on a re-invoke.
Other exit codes
- Exit 6 (deploy job failed) — the build was fine but a deploy broke. In a
cascade the failing stage may be a prerequisite, not the target: the script's
stderr names it (
deploy failed at env 'sat' — cascade stopped before prod), and later stages are left untouched. Fetch the failed deploy-job logs with the job name:
Don't auto-retry — deploy failures often indicate environment drift (missing secret, image push failure, infra service down) and need eyes on them. Once the root cause is fixed (the secret added, the image pushed, the policy granted), rerun the deploy with.claude/skills/ci-deploy/scripts/fetch_failed_logs.sh <workflow-id> <deploy-job-name>--rerunrather than manually poking CircleCI — see "Rerunning a workflow" below. - Exit 7 / 8 — timeouts. Don't blindly retry; surface to the user.
Rerunning a workflow (--rerun / --rerun-from-failed)
A deploy job that has already failed sits in a terminal failed state. Plain
re-invocation of deploy.sh will not retry it — run_stage sees the terminal
failure on its first poll and returns exit 6 immediately. To actually re-run the
deploy you must trigger a CircleCI rerun, and the script does this for you:
--rerun— rerun from the start. Cancels the current run, waits for it to reach a terminal state, triggers a fresh full run, and switches to watching the new workflow id. Build/test jobs re-run and the approval gates re-open (the cascade re-approves them). This is the right choice after fixing an external cause of a deploy failure, because it discards any stale/cached run state — a--rerun-from-failedcan re-fail instantly by reusing the same bad state.--rerun-from-failed— rerun only the failed jobs + their downstream. Reuses upstream successes and already-approved gates. Faster, but keeps prior run state; use only when you're confident the failed job will behave differently on a plain re-run (rarely the case for env-drift failures).
Why a cancel is needed first. CircleCI only reruns a workflow that is in a
terminal state. A run that still has a later gate on_hold (e.g. hold_prod
awaiting approval) stays failing/on_hold indefinitely and the rerun API
returns 400 Workflow must be in a terminal state to be rerun. The script cancels
the run to force a terminal state before rerunning — this is safe: cancelling does
not roll back environments already deployed, it only stops pending gates.
Typical recovery flow for a deploy failure you've since fixed:
# fix the root cause (push the image / add the secret / grant the ECR policy) …
deploy.sh sat --repo Mercaso/<repo> --branch master --sha <sha> --rerun
Run it in the background like any other deploy; it walks the cascade to the target on the fresh run and reports success or the next failure.
Exit code reference
| Code | Meaning | What to tell the user |
|---|---|---|
| 0 | deploy succeeded — every stage through the target (or already success on a re-run) | Confirm deploy is live, link the workflow |
| 1 | usage / can't determine repo or slug | Run from inside the project git repo, or pass --repo owner/repo |
| 2 | missing CircleCI token | Run circleci setup or set CIRCLECI_TOKEN |
| 3 | no pipeline found for the SHA yet | CI hasn't ingested the commit; retry in a minute |
| 4 | no deploy job / workflow found | Branch may be filtered out, or no deploy job for the env |
| 5 | a build/test job failed | Enter the auto-fix loop above |
| 6 | a deploy job failed (stderr names which env in a cascade) | Fetch that deploy-job's logs; do not auto-retry |
| 7 | gate never reached on_hold within ~13 min | Check the pipeline manually |
| 8 | deploy job didn't finish within ~20 min | Check the workflow; job may be stuck |
| 9 | ambiguous detection | Re-run with explicit --deploy-job/--approval-job/--workflow |
| 10 | multiple deploy environments, none chosen | Ask the user which env, re-run with it |
| 11 | deploy job halted (no-op) — service not built in this pipeline | Not a real deploy; target the pipeline of the commit that changed the service, or CI_DEPLOY_HALT_CHECK=0 to bypass |
| 12 | the service's latest change is not your last commit, or the target didn't change the service (needs confirmation) | Show the reasons; re-run with --yes once the user confirms |
Requirements
- A CircleCI token with read+approve access to the project. The script reads
$CIRCLECI_TOKENfirst, then falls back to~/.circleci/cli.yml. git,curl, andpython3on PATH.- Windows PowerShell: use
scripts\deploy.ps1orscripts\fetch_failed_logs.ps1. The wrappers require Git for Windows, or setCI_DEPLOY_BASHto a compatiblebash.exe; they reuse the same arguments as the.shscripts. - Local mode: run from inside the project's git repository (the script needs
originand the current branch). - Remote mode (
--repo): an authenticatedghCLI (gh auth status). No local clone needed. Default GitHub org isMercaso. - A CircleCI workflow that deploys behind a manual-approval gate, with deploy jobs
named with the environment embedded (
deploy_<env>,deploy-<env>, …).