BLOG/TEAM CI/CD
THEME
TEAM CI/CD

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.

Jun 5, 2026 OrchStep Team 6 minROLE: DevOps EngineerSCALE: Any
RUNNABLE DEMO
Full source for this post: blog/same-yml-ci
VIEW SOURCE

Here 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.

orchstep.yml
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 ci

That'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 ci never waits for input. (The interactive picker lives in orchstep 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:

workflows/ci.yml
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=staging

That 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-run

You 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

ConcernTwo pipelinesOne orchstep.yml
Reproduce a CI failurepush, wait, read log, repeatorchstep run ci locally
Pipeline logic lives inCI YAML + your shell historyone file, version-controlled
Switch CI providersrewrite the pipelinerewrite ~9 lines of glue
"What will CI run?"read the YAML and guess--dry-run prints it
Exit codes / promptsper-step wrappersnon-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

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.

#CI#GITHUB-ACTIONS#REPRODUCIBILITY#DEVOPS
Try it in two minutes — one binary, no signup.
curl -fsSL https://orchstep.dev/install.sh | sh

RELATED — TEAM CI/CD