DOC_INDEX
THEME
DOCS/Specification/Shell Execution

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

TypeEngineRequiresBest For
shell (default)/bin/shPresent on all Unix systemsMaximum portability — Alpine, distroless, all Linux/macOS
goshGo-native (mvdan/sh)Nothing — built into binaryTrue cross-platform — Windows without bash, single binary deployments
bash/bin/bashbash installedWhen you need bash-specific features (arrays, [[ ]], process substitution)
zsh/bin/zshzsh installedmacOS default shell features
pwshPowerShellpwsh installedPowerShell scripting

Why shell (sh) is the Default

  • Alpine Linux (the #1 Docker base image) has no bash — only /bin/sh
  • Distroless containers have /bin/sh but 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 stepsHow
{{ vars.X }}the variable namespace (task/step/runtime)
{{ steps.a.output }} / named outputs:step output capture
env: and dotenv: valuesOrchStep 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 process

OrchStep 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 everywhere

Workflow 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"