Skip to content
shellhacks.dev
CI/CD

CI Scripts in YAML vs Shell Scripts vs Makefiles Fully

Where should CI logic live: YAML, shell scripts or a Makefile? Compare testability, portability, readability and error handling, with a recommended split.

6 min read
Summarize in AI
Illustration for "CI Scripts in YAML vs Shell Scripts vs Makefiles Fully": a terminal session.

The first CI job is five lines of YAML. A year later it is 200 lines of shell inside a YAML string, with nested quoting, copy-pasted blocks and no way to run it except by pushing. The cure is deciding what belongs in each layer before the mess grows. The case for the scripts layer is in portable CI scripts; here are the three options side by side.

The same job in three styles

YAML
# inline in YAML
- name: Test and report
  run: |
    set -euo pipefail
    pip install -r requirements.txt
    pytest -q --junitxml=report.xml
    if [ "$(grep -c '<failure' report.xml)" -gt 0 ]; then
      echo "failures found"; exit 1
    fi
Shell
# ci/test.sh
#!/usr/bin/env bash
set -euo pipefail
pip install -r requirements.txt
pytest -q --junitxml=report.xml
Text
# Makefile
.PHONY: test
test:
	./ci/test.sh

The inline form works but cannot be run without the CI, mixes YAML escaping with shell quoting and is not checked by ShellCheck (unless you extract it). The script form is runnable anywhere. The Makefile gives the entry point a name. The CI file then reads run: make test.

What each layer is good for

Text
YAML       triggers, matrix, caching, secrets, artifacts, environments, job dependencies
Make/just  names for tasks: test, lint, build, release; task dependencies
Scripts    the actual logic: loops, conditions, error handling, tools

YAML can express "run on pull requests, on three Python versions, cache pip" far better than a script can. A script handles "retry three times, parse this output, fail with a clear message" better than YAML ever will. A Makefile (see Makefile automation) or just and Taskfile sits between and documents what the project can do.

Testability and linting

Shell
shellcheck ci/*.sh
bats tests/ci.bats
bash -n ci/test.sh
yq '.jobs.*.steps[].run' .github/workflows/ci.yml | shellcheck -s bash -

Scripts in files get static analysis for free and unit tests when they are structured as functions. Inline blocks can be extracted and linted as above, with actionlint (which runs ShellCheck on run: blocks) as the more convenient option. Provider expressions like ${{ ... }} inside shell are a security risk as well as a lint problem, because they are substituted before the shell runs; pass them through env: instead. See ShellCheck in CI.

Error handling

Providers choose the shell and its flags for you. On GitHub Actions, a Linux run: uses bash -e {0} by default, or bash --noprofile --norc -eo pipefail {0} when you set shell: bash explicitly. GitLab uses the image's shell with its own behaviour. Differences like these cause jobs that pass on one provider and hide failures on another. A script with set -euo pipefail at the top behaves the same everywhere, which is why strict mode belongs in the script, as in failing fast in CI.

Use YAML for everything the provider does for you. Have steps call make targets. Keep each script under a few hundred lines, one purpose each, documented with a usage message. If a script grows too big for Bash, rewrite it in Python or Go, as in a Python deploy script. Inline YAML is fine for one-liners like run: make test or run: npm ci.

What goes wrong when logic lives in YAML

YAML is a data format, and shell inside a YAML string inherits the problems of both. Indentation determines structure, so a copy-and-paste that shifts a line changes meaning without an error. Special characters such as a colon followed by a space, a leading * or a # can end or alter a scalar. Multi-line blocks use indicators like | and > that change how newlines are kept, and a folded block silently joins lines that were meant to be separate commands. Provider expressions are expanded before the shell sees the text, so a value containing a quote can break the command or, worse, inject one.

The deeper problem is that the logic can only be run in CI. Each experiment is a commit, a push and a wait of several minutes, and the feedback loop shapes what people are willing to try. A script that can be run on a laptop is debugged in seconds, with a debugger, with set -x and with a copy of the failing input. Time spent shortening that loop is repaid on every change afterwards.

Keeping the interface small and stable

The contract between the pipeline and the project is the set of named tasks: make lint, make test, make build, make release. The pipeline file mentions only those names, and the project decides what they do. Changing the test runner or adding a step then needs no change to the pipeline definition, and moving to a different CI provider means rewriting the thin layer of triggers and caching, not the project's logic. Document the tasks in the README, and let a new contributor run exactly what CI runs, with the same flags, on the first day.

Parameters travel as environment variables or arguments with defaults. A script that takes ENV=staging and defaults to a safe value is easier to test than one that reads provider-specific variables deep inside. Where the script needs CI information, such as the branch or commit, read it in one small adapter function so that supporting another provider means changing one place.

Secrets and permissions across the layers

Each layer has a role in handling credentials. YAML declares which secrets a job may see, and scopes them to the step that needs them. The script receives them as environment variables and must never print them. A task runner should not echo commands that include secrets, which the default behaviour of Make does for each recipe line, so mark such lines with a leading @ or move the sensitive call into a script that disables tracing. Review the scripts as carefully as the pipeline file, because they are the code that holds the credential.

Migrating an existing pipeline

Do not rewrite everything at once. Take the longest inline block, move it into a script unchanged, call the script from the YAML and confirm the pipeline still passes. Then run ShellCheck on it and fix what it finds, one commit at a time. Repeat with the next block. After a few rounds the YAML contains triggers, matrices, caching and calls to named tasks, and the project contains scripts that run anywhere. The behaviour never changed during the migration, so each step is reviewable and reversible.

Version the interface as well. When a task changes meaning, for example when make test starts running slower integration checks, tell the team in the change description and in the README, because the pipeline and every developer's habits depend on what the name promises. Names that stay stable while their contents improve are the main benefit of the layering.

Where this does not apply

For tiny projects, a few inline lines are fine and extra layers are noise. Reusable workflows and composite actions are the provider-native way to share logic inside one platform; they give reuse at the price of portability. See reusable workflows. The GitHub Actions shell documentation lists the default flags.

The actionlint project checks workflow files, including shell in run: steps, and is quick to add as a pre-commit hook or a CI step.

Share

Keep reading

Get new posts by email

One email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.