The same orchstep.yml locally and in CI
The bug that only reproduces in CI usually isn't a bug — it's a second copy of your pipeline. Here's how to run one orchstep.yml on your laptop and on the runner, byte for byte.
blog/same-yml-ciHere is the loop every DevOps engineer knows. Tests pass on your machine. They fail in CI. You push a commit that adds echo "::group::debug", wait four minutes for the runner, read the log, push another commit, wait another four minutes. The "fix" is usually a one-character difference between what your laptop ran and what the runner ran — a flag, an env var, a step order.
The root cause isn't the test. It's that you have two pipelines: the commands in your head when you run things locally, and the YAML the runner executes. They drift, and the drift is invisible until it bites.
The fix is boring and effective: put the pipeline in one file, run that exact file in both places.
One file, two callers
orchstep run ci is the whole interface. You run it. The runner runs it. There is nothing in the workflow that knows or cares whether it's on a laptop or a GitHub runner — same steps, same order, same variable resolution.
name: ci
# One file. Same `orchstep run ci` on your laptop and on the runner.
# Plain stdout, real exit codes, no prompts — nothing CI-specific lives here.
defaults:
target: staging
tasks:
# `orchstep run ci`
ci:
steps:
- name: lint
task: lint
- name: test
task: test
- name: build
task: build
- name: summary
func: shell
do: echo "ci passed for target={{ vars.target }}"
lint:
steps:
- name: vet
func: shell
do: echo "linting sources"
test:
steps:
- name: unit
func: shell
do: echo "running unit tests"
- name: integration
func: shell
do: echo "running integration tests"
build:
steps:
- name: compile
func: shell
do: echo "building artifact for {{ vars.target }}"
outputs:
artifact: "dist/app-{{ vars.target }}.tar.gz"
- name: report
func: shell
do: echo "built {{ steps.compile.artifact }}"Locally:
orchstep run ciThat's the same command the runner invokes. When it fails in CI, you reproduce it in one line on your laptop — no guessing which step the runner does differently, because there is no difference.
What makes it CI-safe
A workflow that's pleasant locally can still be a hazard in CI if it prompts, colorizes, or buries the exit code. OrchStep is built for both:
- Non-interactive by default.
orchstep run cinever waits for input. (The interactive picker lives inorchstep menu, which you'd never call in a pipeline.) - Plain logs. Each step prints the command and its output to stdout — greppable, no escape-sequence soup in the runner log.
- Real exit codes. Any failed step exits non-zero, so the runner marks the job red without a wrapper script translating success into failure.
The thin runner side
The CI config shrinks to "check out, install orchstep, run the task." Here's the entire GitHub Actions side — note how little of it there is, and how none of it duplicates pipeline logic:
name: ci
on: [push, pull_request]
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Manual install alternative:
# - name: Install OrchStep
# run: curl -fsSL https://orchstep.dev/install.sh | sh
# - name: Run pipeline
# run: orchstep run ci --var target=staging
- uses: orchstep/run-orchstep@v1
with:
workflow: orchstep.yml
task: ci
vars: |
target=stagingThat orchstep/run-orchstep@v1 step is OrchStep's official GitHub Action: it installs the CLI and runs the task in one step, and for jobs that call several orchstep commands, setup-orchstep installs and caches the CLI on its own. The manual curl install is kept as a comment above if you'd rather pin it yourself.
When you later move to GitLab, Buildkite, or a cron box, you change those few lines — not the pipeline. The thing that holds your build logic doesn't move.
See the plan before the runner does
Before you push, resolve the whole pipeline without executing it:
orchstep run ci --dry-runYou see the steps, the resolved target, and the order — the exact plan the runner will follow. That's the difference between "I think CI runs integration after unit" and watching it print. Full tour: Previewing with Dry Run.
What you actually gained
| Concern | Two pipelines | One orchstep.yml |
|---|---|---|
| Reproduce a CI failure | push, wait, read log, repeat | orchstep run ci locally |
| Pipeline logic lives in | CI YAML + your shell history | one file, version-controlled |
| Switch CI providers | rewrite the pipeline | rewrite ~9 lines of glue |
| "What will CI run?" | read the YAML and guess | --dry-run prints it |
| Exit codes / prompts | per-step wrappers | non-interactive, exit-code clean |
If a single make ci already gives you this and never drifts, keep it — this isn't a campaign against Makefiles. But once the pipeline grows retries, conditionals, and per-target variables, a workflow with real structure stops the drift that Make leaves to convention.
Where to go next
- Quick Start — your first workflow in two minutes
- Variables & Outputs — the four-level scoping model
- Previewing with Dry Run — see the plan before it runs
Tired of debugging CI by committing to it? Put the pipeline in one file and run that file everywhere — curl -fsSL https://orchstep.dev/install.sh | sh.
curl -fsSL https://orchstep.dev/install.sh | sh