A run: block starts as one line. Then someone adds a retry. Then a check. Six months later it is 50 lines of Bash inside YAML, and nobody can run it outside CI.

This post shows how to move that logic into scripts, using the Docker example from rzv-ci-plb, the Runzivo CI playbook.

The common mistake

Don't do this. A smoke test written inline:

yaml
- name: Smoke test image
  run: |
    docker run -d --name app -p 8080:8080 "app:${{ github.sha }}"
    for attempt in 1 2 3 4 5; do
      if curl -fsS http://127.0.0.1:8080/healthz; then
        break
      fi
      sleep 1
    done
    docker rm -f app

It looks fine. It has four problems:

  • It passes when the app is broken. If every curl fails, the loop ends quietly and docker rm succeeds, so the step exits 0. I ran it with a failing curl to confirm.
  • Cleanup is skipped on failure. If docker run fails, the step stops before docker rm.
  • You can't run it locally without copying it out of the YAML.
  • ${{ github.sha }} is pasted into the script text before the shell starts. That is safe for a SHA. The same habit with a pull request title is a script injection.

One more detail: without shell: bash, GitHub runs this with bash -e {0}, which has no pipefail. With shell: bash, it runs bash --noprofile --norc -eo pipefail {0}. The workflow syntax reference lists both. The playbook sets defaults.run.shell: bash in every workflow.

The three-line rule

My rule: a run: block of three lines or fewer can stay inline. Move it to a script when it has:

  • conditions or loops
  • retries
  • cleanup
  • error handling
  • more than three lines

This is a Runzivo convention, not a GitHub requirement. It is stricter than the Google Shell Style Guide, which suggests a structured language once a script passes 100 lines or has non-straightforward control flow. The point is the same: keep shell small and easy to read.

A GitHub Actions step and a developer terminal both run examples/docker/ci/02-test.sh. The script installs error and cleanup traps, registers container cleanup, retries the health check and fails with the exit code kept. It uses the shared helpers in scripts/lib, which have their own Bats tests.

The workflow steps become single calls. Values come in through env:. From example-docker.yml:

yaml
- name: Build image
  run: bash "${GITHUB_WORKSPACE}/examples/docker/ci/01-build.sh"

- name: Smoke test image
  env:
    REPORT_DIR: ${{ runner.temp }}/reports
  run: bash "${GITHUB_WORKSPACE}/examples/docker/ci/02-test.sh"

The image name comes from a job-level env: block (IMAGE), so no expression lands inside a script.

The smoke test script holds the logic. This excerpt is from examples/docker/ci/02-test.sh:

bash
container_id=""
save_logs_and_remove() {
  if [[ -n "$container_id" ]]; then
    # Logs are best effort: a container that failed to start may have none.
    docker logs "$container_id" >"${REPORT_DIR}/docker-container.log" 2>&1 ||
      log_warn "could not save container logs"
    docker rm -f "$container_id" >/dev/null
  fi
}
cleanup_register save_logs_and_remove

log_group_start "Start container"
container_id="$(docker run --detach --publish 127.0.0.1::8080 \
  --label "ci.example=docker" --read-only --cap-drop ALL "$IMAGE")"
host_port="$(docker port "$container_id" 8080/tcp)"
host_port="${host_port##*:}"
log_info "container ${container_id:0:12} listening on 127.0.0.1:${host_port}"
log_group_end

log_group_start "Check /healthz"
body="$(retry 10 1 curl --fail --silent --show-error "http://127.0.0.1:${host_port}/healthz")"
[[ "$body" == "ok" ]] || die "unexpected /healthz body: ${body}"
log_group_end

Each problem from the inline version is fixed:

bash
bash scripts/ci/02-test.sh docker

That builds the image with 01-build.sh, then runs 02-test.sh.

Number scripts in run order

Every script in the playbook starts with a two-digit number: 01-build.sh runs before 02-test.sh, and 00 is for setup. Directory listings then show the order, and an unused script stands out. See numbered scripts.

What you get

Local runs. A developer reproduces a CI failure with one command.

Real linting. ShellCheck reads a .sh file directly. actionlint also runs ShellCheck on run: blocks, but a script file is easier to check, format and review.

Tests. Bats tests the shared helpers, including the failure paths. See tests/bats/03-errors.bats for retry.

Portable workflows. The YAML only coordinates, so moving it to another compatible runner means changing runs-on, not rewriting steps.

Commands to check your scripts

From the repository README:

bash
brew install actionlint shellcheck shfmt bats-core zizmor   # on a Mac
bash scripts/ci/01-validate.sh        # actionlint, shellcheck, shfmt, zizmor
bash scripts/ci/02-test.sh            # Bats tests and every example
bash scripts/ci/02-test.sh golang     # one suite

When inline is fine

Short, single-purpose commands can stay in YAML. A simplified example:

yaml
- name: Install dependencies
  run: npm ci

If you are unsure, ask: "Would I want to run this on my laptop when it fails?" If yes, make it a script.

Running on Runzivo

Runzivo is in Early Access. It runs each eligible GitHub Actions job in a fresh, single-use microVM. Because the logic lives in scripts, moving a job means changing one line:

yaml
runs-on: rzv-ubuntu-24.04-x64-2vcpu-8gb

See examples/runzivo-runners and the Runzivo docs.

In the repo

Next: error handling with traps, cleanup, logging and exit codes.

Every example in this series is tested in rzv-ci-plb, the Runzivo CI playbook, pinned to tag v0.1.0.