BLOG/DEVELOPER DAILY
THEME
DEVELOPER DAILY

Run the same workflow on Windows (no bash)

OrchStep ships a Go-native shell. Set one config line and your sh-style workflow runs identically on Windows, Alpine, and distroless — no bash, no WSL, nothing to install.

Jun 23, 2026 OrchStep Team 6 minROLE: AnySCALE: Any

You wrote a workflow on macOS. It uses echo, a pipe, a ${VAR:-default} fallback — nothing exotic. Then a Windows teammate (or a distroless container, or an Alpine CI image) tries to run it and the first sh -c call fails because there is no /bin/sh to call.

The usual answers are all installs: WSL, Git Bash, a busybox layer. OrchStep's answer is that it already shipped a shell. The binary embeds gosh, a Go-native shell interpreter (mvdan/sh) that runs POSIX shell syntax in pure Go — no external /bin/sh, no bash, no WSL. One binary, identical behavior everywhere.

One line makes a workflow portable

By default a shell step runs through the system /bin/sh — perfect for Linux, macOS, and most containers. To make a workflow run anywhere, including Windows, set the shell type to gosh. The cleanest place is a workflow-level config: block, so every step inherits it:

orchstep.yml
name: cross-platform
config:
  func:
    shell:
      type: gosh          # Go-native shell — no external shell needed
defaults:
  version: "1.0.0"
tasks:
  build:
    steps:
      - name: compile
        func: shell
        do: |
          echo "Building v{{ vars.version }}"
          echo "BUILD_STATUS=success"
        outputs:
          status: '{{ result.output | regexFind "BUILD_STATUS=(.+)" }}'
      - name: report
        func: shell
        do: echo "status was {{ steps.compile.status }}"

That workflow lints clean and runs the same on every OS:

orchstep run build
Workflow: cross-platform
Task: build
  Step: compile
  [ok] compile
  Step: report
  $ echo "status was success"
  [ok] report
Result: success

The output extraction (regexFind) and the cross-step reference ({{ steps.compile.status }}) work identically — because the engine is the same Go binary on every platform. There is no "works on my machine" gap when there is nothing to install differently.

gosh speaks the shell you already write

gosh covers roughly 95% of bash syntax, so most existing scripts run unchanged:

  • variable expansion: $VAR, ${VAR:-default}, ${#VAR}
  • command substitution: $(command)
  • pipes and redirects: |, >, >>, 2>&1
  • conditionals and loops: if/then/fi, [ ], [[ ]], for, while
  • arithmetic $(( )), functions, arrays, and 74+ builtins
- name: portable
  func: shell
  args:
    type: gosh
    cmd: echo "value is ${VAR:-default}"

That step prints value is default whether it runs on Windows PowerShell's host, an Alpine image, or your laptop — the ${VAR:-default} is interpreted by gosh, not by an OS shell.

Mix shells when you actually need one

gosh is a per-step override too, so you are never locked in. Need real bash for an associative array on one step? Ask for it explicitly and leave the rest portable:

tasks:
  deploy:
    steps:
      - name: check
        func: shell
        do: echo "default sh — portable"
      - name: bash-only
        func: shell
        args:
          type: bash        # this one step requires bash
          cmd: |
            declare -A cfg
            cfg[env]="production"
            echo "${cfg[env]}"

The precedence is least-specific-wins-loses: global config < workflow config: < per-step args.type. Set gosh once at the top and override the one step that genuinely needs more.

What you gained

ConcernPlain sh/bash scriptsOrchStep + gosh
Runs on Windowsneeds WSL / Git Bashyes, no install
Runs on Alpine / distrolessneeds a shell layeryes, no /bin/sh needed
One artifact for all OSesnoone static binary
Same behavior in CI and locally"works on my machine"identical engine everywhere
Opt into real bash per stepn/aargs: { type: bash }

The honest boundary

gosh is an interpreter, not bash. The last ~5% bites in real ways: no true fork() (subshells run as goroutines), some associative-array edge cases, and external commands still have to be on PATH. gosh makes the shell syntax portable; it does not put kubectl or docker on a machine that lacks them. If a step shells out to a tool, that tool still needs to exist on the target. For pure shell logic, though, one type: gosh line retires a whole class of "install a shell first" tickets.

Where to go next

Have a workflow that only runs where bash does? Add config: { func: { shell: { type: gosh } } } and orchstep lint it — then hand it to the Windows person on your team.

#WINDOWS#CROSS-PLATFORM#SHELL#PORTABILITY
Try it in two minutes — one binary, no signup.
curl -fsSL https://orchstep.dev/install.sh | sh

RELATED — DEVELOPER DAILY