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.
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:
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 buildWorkflow: cross-platform
Task: build
Step: compile
[ok] compile
Step: report
$ echo "status was success"
[ok] report
Result: successThe 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
| Concern | Plain sh/bash scripts | OrchStep + gosh |
|---|---|---|
| Runs on Windows | needs WSL / Git Bash | yes, no install |
| Runs on Alpine / distroless | needs a shell layer | yes, no /bin/sh needed |
| One artifact for all OSes | no | one static binary |
| Same behavior in CI and locally | "works on my machine" | identical engine everywhere |
| Opt into real bash per step | n/a | args: { 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
- Shell Execution — every shell type, isolation rules, and the full gosh compatibility list
- Where OrchStep Runs — why one binary shows up the same everywhere
- CLI Reference —
run,lint, and the rest
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.
curl -fsSL https://orchstep.dev/install.sh | sh