Shell Execution
Cross-platform shell support with POSIX sh default and Go-native gosh
OrchStep supports multiple shell types for executing commands in do: blocks. The default is POSIX sh for maximum portability across Linux, macOS, Docker containers, and CI/CD environments.
Shell Types
| Type | Engine | Requires | Best For |
|---|---|---|---|
shell (default) | /bin/sh | Present on all Unix systems | Maximum portability — Alpine, distroless, all Linux/macOS |
gosh | Go-native (mvdan/sh) | Nothing — built into binary | True cross-platform — Windows without bash, single binary deployments |
bash | /bin/bash | bash installed | When you need bash-specific features (arrays, [[ ]], process substitution) |
zsh | /bin/zsh | zsh installed | macOS default shell features |
pwsh | PowerShell | pwsh installed | PowerShell scripting |
Why shell (sh) is the Default
- Alpine Linux (the #1 Docker base image) has no bash — only
/bin/sh - Distroless containers have
/bin/shbut not bash - All CI/CD runners (GitHub Actions, GitLab CI, Jenkins) have
/bin/sh - 90%+ of OrchStep
do:blocks use POSIX-compatible commands (echo, pipes, variables) - Zero friction — works everywhere without installing additional packages
Step Isolation & State
Every shell step runs in its own subprocess (sh -c "<do>"), with the working
directory reset to the workflow directory each time. Steps do not share a live
shell. So shell-level state set in one step is gone in the next:
steps:
- { name: a, func: shell, do: "cd /tmp && export FOO=bar && X=1" }
- { name: b, func: shell, do: 'pwd; echo "[$FOO][$X]"' } # -> workflow dir, [][]cd, export, plain variables, shell functions, aliases, and set options never
carry across steps. This is deliberate: isolated, declared per-step inputs are what
make execution deterministic and replayable.
What does carry across steps
The isolation is at the shell level, not the filesystem. State persists only through OrchStep's own layers — or through files written to disk:
| Carries across steps | How |
|---|---|
{{ vars.X }} | the variable namespace (task/step/runtime) |
{{ steps.a.output }} / named outputs: | step output capture |
env: and dotenv: values | OrchStep re-applies them to every step's subprocess |
{{ secrets.X }} | resolved once, injected where referenced |
Files on disk (~/.vault-token, build artifacts, ~/.aws/...) | normal filesystem — steps share $HOME and the working tree |
So the idiomatic way to pass data between steps is outputs: → {{ steps.x.output }}
or env:/vars:, not export:
- { name: build, func: shell, do: "echo v1.2.3", outputs: { tag: "{{ result.output }}" } }
- { name: ship, func: shell, do: "deploy --tag {{ steps.build.tag }}" }When you need one shared shell context
If several commands genuinely need the same shell process — activate a venv, cd
once, source a setup script, then run a sequence — put them in one step with a
multiline do:. It is still a single captured, replayable unit:
- name: build
func: shell
do: |
source .venv/bin/activate
cd service
export BUILD=release
make && make test # all in the same shell processOrchStep intentionally has no "shared session across steps" switch: a live shell shared between steps would hold mutable state the context store never captures, which would break per-step inspection and replay. Group the commands into one step instead.
Using gosh (Go-Native Shell)
gosh is OrchStep's built-in Go shell interpreter powered by mvdan/sh. It executes shell commands in pure Go — no external shell binary needed.
When to Use gosh
- Windows — run the same POSIX shell scripts without installing bash/WSL
- Minimal containers — no shell binary needed at all
- Single binary deployment — orchstep binary is completely self-contained
- Sandboxed environments — no exec to external processes
gosh Compatibility
gosh supports ~95% of bash syntax:
- Variable expansion:
$VAR,${VAR:-default},${#VAR} - Command substitution:
$(command) - Pipes and redirects:
|,>,>>,2>&1 - Conditionals:
if/then/else/fi,[ ],[[ ]] - Loops:
for,while,until - Arithmetic:
$(( )) - Functions, arrays, 74+ builtins
Known Limitations
- No real
fork()— subshells use goroutines - Some bash edge cases with associative arrays
- External commands must be on PATH
Configuration
Set the shell type at any level — more specific overrides less specific.
Global (applies to all workflows)
.orchstep/orchstep_config.yml:
func:
shell:
type: "gosh" # Use Go-native shell everywhereWorkflow Level (inline config)
name: my-workflow
config:
func:
shell:
type: "gosh"
tasks:
deploy:
steps:
- name: build
func: shell
do: echo "This runs in gosh"Function Level (per-step override)
steps:
- name: portable-step
func: shell
args:
type: "gosh"
cmd: echo "This specific step uses gosh"
- name: bash-step
func: shell
args:
type: "bash"
cmd: echo "This step needs real bash"Examples
Cross-Platform Workflow (Windows + Linux + macOS)
name: cross-platform-build
desc: "Works on any OS without bash"
config:
func:
shell:
type: "gosh"
defaults:
version: "1.0.0"
tasks:
build:
steps:
- name: compile
func: shell
do: |
echo "Building v{{ vars.version }}..."
echo "Platform: $(uname -s 2>/dev/null || echo Windows)"
echo "BUILD_STATUS=success"
outputs:
status: '{{ result.output | regexFind "BUILD_STATUS=(.+)" }}'Mixed Shell Types
name: mixed-shells
desc: "Different shells for different steps"
tasks:
deploy:
steps:
- name: check-env
func: shell
do: echo "Using default shell (sh)"
- name: bash-specific
func: shell
args:
type: "bash"
cmd: |
declare -A config
config[env]="production"
echo "Bash arrays: ${config[env]}"
- name: portable
func: shell
args:
type: "gosh"
cmd: echo "Go-native shell — no external deps"