BLOG/DEVELOPER DAILY
THEME
DEVELOPER DAILY

Dry-run everything before you run it

Stop guessing what your deploy script will do. --dry-run resolves every variable, renders every command, decides every branch it can, and executes nothing — in the terminal or as a self-contained HTML plan.

Jun 7, 2026 OrchStep Team 6 minROLE: AnySCALE: Any
RUNNABLE DEMO
Full source for this post: blog/dry-run-preview
VIEW SOURCE

You're about to run deploy production. Do you actually know what it will do? Which branch the if takes, what the version string resolves to, whether that variable came from the CLI or a stale default? With a bash script the honest answer is "mostly" — and "mostly" is how the wrong image ships to prod.

echo-ing every command before running it doesn't help: echo shows the line, not the decisions. It can't tell you the gate will skip, or that a variable you think is 2.0.0 is silently still 1.4.0.

OrchStep has a real answer: --dry-run. It builds the complete plan — every variable resolved with its winning source, every command rendered, every branch it can decide decided — and executes nothing. It's the difference between "I think canary triggers in prod" and seeing it on screen.

A pipeline worth previewing

This task has the three things that make a plan interesting: a step output another step depends on, a variable-decided gate, and a loop:

orchstep.yml
name: dryrun-demo
defaults:
  app: checkout
  version: "2.4.0"
  target: staging
  notify: ["alice", "bob"]

tasks:
  ship:
    steps:
      - name: build
        func: shell
        do: echo "built {{ vars.app }}:{{ vars.version }}"
        outputs:
          image: '{{ result.output | regexFind "[a-z]+:[0-9.]+" }}'

      - name: push
        func: shell
        do: echo "pushing {{ steps.build.image }}"
        retry: { max_attempts: 3, interval: "1s" }

      - name: gate
        if: '{{ eq vars.target "production" }}'
        then:
          - name: canary
            func: shell
            do: echo "canary verify in {{ vars.target }}"
        else:
          - name: full
            func: shell
            do: echo "full rollout to {{ vars.target }}"

      - name: notify_each
        func: shell
        loop:
          items: "{{ vars.notify }}"
        do: 'echo "notify {{ loop.item }}: {{ vars.app }} {{ vars.version }} live"'
    finally:
      - name: cleanup
        func: shell
        do: echo "cleanup"

The plan, in the terminal

orchstep run ship --dry-run

The output is the whole machine, resolved:

VARIABLES (winning layer)
  app     = checkout  (defaults)
  notify  = ["alice","bob"]  (defaults)
  target  = staging  (defaults)
  version = 2.4.0  (defaults)

STEPS
   1. build  [shell]  executes
        | echo "built checkout:2.4.0"
        outputs: image
   2. push  [shell]  executes
        | echo "pushing ⟨steps.build.image⟩"
        retry: up to 3 attempts, interval 1s
   3. gate  [if]  executes
        condition: {{ eq vars.target "production" }}  -> false
        then [skipped]
           1. canary  [shell]  skipped
        else [-> taken]
           1. full  [shell]  executes
                | echo "full rollout to staging"
   4. notify_each  [shell]  executes
        loop: items: {{ vars.notify }} -> 2 iteration(s)  [alice bob]

Three things to read here:

  1. Variables show their source. version = 2.4.0 (defaults) — not a --var, not an env file. The plan answers "which layer won?" before you've wondered.
  2. push renders a placeholder. ⟨steps.build.image⟩ can't exist without running build, so dry-run marks exactly where runtime data flows in instead of inventing a value.
  3. gate gets a real verdict. Its condition only reads vars.target, fully known at plan time: -> false, the canary branch dimmed skipped, full marked -> taken. You can see staging won't canary.

Flip an input, watch the verdict change

The whole point is the what-if. Change one variable and re-plan:

orchstep run ship --dry-run --var target=production
   3. gate  [if]  executes
        condition: {{ eq vars.target "production" }}  -> true
        then [-> taken]
           1. canary  [shell]  executes
                | echo "canary verify in production"
        else [skipped]
           1. full  [shell]  skipped

The verdict flips: canary now -> taken, full rollout skipped. You just confirmed the production path takes the canary branch — without touching production.

See it, then prove nothing ran

--open writes a self-contained HTML plan — the same decisions as a diagram, with placeholders as chips and skipped branches dimmed — and opens it in your browser:

orchstep run ship --dry-run --open

It has no external dependencies and makes no network requests, so it's safe to generate in CI and attach as an artifact. Then run it for real and compare:

orchstep run ship          # the ⟨steps.build.image⟩ placeholder becomes checkout:2.4.0

Because every step is a harmless echo, the real run lands exactly where the plan's placeholders were. Plan, then run, then diff — no surprises.

A deeper linter, for free

Dry-run renders every do: and args: template, so it catches mistakes a static read can't. Break a template on purpose and the plan reports that step as an error — before a real deploy explodes halfway through. Want a CI gate? --output json emits the structured plan; fail the PR if it contains errors, or diff two plans to catch unintended changes:

orchstep run ship --dry-run --output json | jq '.summary'

What you gained

QuestionbashOrchStep
Which branch runs?read it and hopebranch verdict in the plan
Where did this var come from?grep the whole pipelinewinning-layer column
What's known only at runtime?unknowable up frontshown as ⟨placeholder⟩
Will a template error?find out mid-deployflagged as error at plan time
A shareable preview?paste terminal output--open HTML artifact

Where to go next

The demo is echo-only, so plan it, run it, and compare: orchstep run ship --dry-run.

#DRY-RUN#PREVIEW#SAFETY#CI#DEPLOY
Try it in two minutes — one binary, no signup.
curl -fsSL https://orchstep.dev/install.sh | sh

RELATED — DEVELOPER DAILY