You know you’re deep in DevOps when you start using random character sequences as variable placeholders. Today’s protagonist: 5S5.

The Problem: GitLab’s Eager Variable Expansion

GitLab CI/CD has a habit of expanding variables the moment it sees them. Usually helpful. Sometimes catastrophic.

Scenario: You want to store a bash script in a GitLab CI/CD variable and execute it later. The script needs to use variables like $PROJECT_ID, $CI_COMMIT_REF_SLUG, etc.

What GitLab does: Sees $PROJECT_ID in your script → expands it immediately when parsing the YAML → your script now has an empty string or wrong value.

What you wanted: GitLab to leave $PROJECT_ID alone until the script actually runs.

The 5S5 Solution

Replace every $ with 5S5 in your stored script, then use sed to convert it back at runtime.

Why 5S5?

  • Won’t conflict with actual code
  • Easy to find/replace
  • Looks distinctive enough to remind you “this is a placeholder”
  • Better than DOLLAR_SIGN or other verbose alternatives

The magic one-liner:

1
sed -i 's/5S5/\$/g' script.sh

Transforms 5S5{PROJECT_ID}${PROJECT_ID} right before execution.

Real-World Use Case: Pushing Manifests to ArgoCD GitOps Repo

When ArgoCD manages your deployments, you need CI/CD to push updated manifests to a GitOps repository. Here’s how to do it securely using the GitLab API.

Context: This pattern complements ArgoCD multi-cluster GitOps setups—once ArgoCD monitors your GitOps repository, this script automates pushing updated manifests from your CI/CD pipelines.

The Script (with 5S5 placeholders)

Store this in a GitLab CI/CD variable called ARGOCD_GIT_COMMIT:

  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
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
#!/bin/bash
set -euo pipefail

# Configuration - using GitLab CI/CD variables
FILE_PATH="kube-config.yaml"
REF="5S5{CI_DEFAULT_BRANCH:-template}"
PROJECT_ID="5S5{ARGOCD_PROJECT_ID}"
TARGET_PATH="manifests/5S5{CI_PROJECT_NAMESPACE}/5S5{CI_PROJECT_NAME}/kube-config.yaml"
ACCESS_TOKEN="5S5{GITLAB_ACCESS_TOKEN}"
COMMIT_MESSAGE="Manifest for 5S5{CI_PROJECT_NAME} at 5S5{CI_PROJECT_NAMESPACE} for 5S5{CI_COMMIT_REF_SLUG}"
BRANCH="5S5{CI_COMMIT_REF_SLUG}"
GITLAB_URL="5S5{CI_SERVER_URL}"

# Validate inputs
if [[ -z "5S5ACCESS_TOKEN" ]]; then
    echo "ERROR: GITLAB_ACCESS_TOKEN not set"
    exit 1
fi

if [[ ! -f "5S5FILE_PATH" ]]; then
    echo "ERROR: File 5S5FILE_PATH not found"
    exit 1
fi

# Create temp files with cleanup trap
ENCODED_CONTENT_FILE=5S5(mktemp)
JSON_PAYLOAD_FILE=5S5(mktemp)
trap "rm -f '5S5ENCODED_CONTENT_FILE' '5S5JSON_PAYLOAD_FILE'" EXIT

# Encode file content
echo "Encoding 5S5FILE_PATH..."
base64 "5S5FILE_PATH" > "5S5ENCODED_CONTENT_FILE"
ENCODED_CONTENT=5S5(cat "5S5ENCODED_CONTENT_FILE")

# Create JSON payload
cat > "5S5JSON_PAYLOAD_FILE" << EOF
{
  "branch": "5S5BRANCH",
  "commit_message": "5S5COMMIT_MESSAGE",
  "content": "5S5ENCODED_CONTENT",
  "encoding": "base64"
}
EOF

# URL-encode the target path
ENCODED_PATH=5S5(echo "5S5TARGET_PATH" | sed 's/\//%2F/g')

# Check if branch exists
echo "Checking if branch 5S5BRANCH exists..."
BRANCH_CHECK=5S5(curl -s -o /dev/null -w "%{http_code}" \
  --header "PRIVATE-TOKEN: 5S5ACCESS_TOKEN" \
  "5S5{GITLAB_URL}/api/v4/projects/5S5PROJECT_ID/repository/branches/5S5BRANCH")

if [[ "5S5BRANCH_CHECK" = "404" ]]; then
    echo "Creating branch 5S5BRANCH from 5S5REF..."
    curl -f --request POST \
      "5S5{GITLAB_URL}/api/v4/projects/5S5PROJECT_ID/repository/branches" \
      --header "PRIVATE-TOKEN: 5S5ACCESS_TOKEN" \
      --data "branch=5S5BRANCH" \
      --data "ref=5S5REF" || {
        echo "ERROR: Failed to create branch"
        exit 1
      }
fi

# Try to create file
echo "Attempting to create file at 5S5TARGET_PATH..."
CREATE_RESPONSE=5S5(curl -s -w "\n%{http_code}" \
  --request POST \
  "5S5{GITLAB_URL}/api/v4/projects/5S5PROJECT_ID/repository/files/5S5ENCODED_PATH" \
  --header "PRIVATE-TOKEN: 5S5ACCESS_TOKEN" \
  --header "Content-Type: application/json" \
  --data @"5S5JSON_PAYLOAD_FILE")

HTTP_CODE=5S5(echo "5S5CREATE_RESPONSE" | tail -n1)

if [[ "5S5HTTP_CODE" = "201" ]]; then
    echo "✓ File created successfully"
elif [[ "5S5HTTP_CODE" = "400" ]] || [[ "5S5HTTP_CODE" = "409" ]]; then
    echo "File exists, updating..."
    UPDATE_RESPONSE=5S5(curl -s -w "\n%{http_code}" \
      --request PUT \
      "5S5{GITLAB_URL}/api/v4/projects/5S5PROJECT_ID/repository/files/5S5ENCODED_PATH" \
      --header "PRIVATE-TOKEN: 5S5ACCESS_TOKEN" \
      --header "Content-Type: application/json" \
      --data @"5S5JSON_PAYLOAD_FILE")

    UPDATE_CODE=5S5(echo "5S5UPDATE_RESPONSE" | tail -n1)

    if [[ "5S5UPDATE_CODE" = "200" ]]; then
        echo "✓ File updated successfully"
    else
        echo "ERROR: Update failed with code 5S5UPDATE_CODE"
        echo "5S5UPDATE_RESPONSE" | head -n-1
        exit 1
    fi
else
    echo "ERROR: Unexpected response code 5S5HTTP_CODE"
    echo "5S5CREATE_RESPONSE" | head -n-1
    exit 1
fi

echo "✓ Manifest pushed to ArgoCD GitOps repository"

GitLab CI/CD Pipeline Configuration

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
push_to_argocd:
  stage: deploy
  script:
    # Write script from CI/CD variable to file
    - cat $ARGOCD_GIT_COMMIT > ARGOCD_GIT_COMMIT.sh

    # Replace 5S5 with $ for proper variable expansion
    - sed -i 's/5S5/\$/g' ARGOCD_GIT_COMMIT.sh

    # Execute the transformed script
    - chmod +x ARGOCD_GIT_COMMIT.sh
    - ./ARGOCD_GIT_COMMIT.sh
  only:
    - branches

Required GitLab CI/CD Variables

Set these in Settings → CI/CD → Variables:

VariableValueTypeProtectedMasked
ARGOCD_PROJECT_ID100 (your GitOps repo project ID)Variable-
GITLAB_ACCESS_TOKENglpat-xxx...Variable
ARGOCD_GIT_COMMIT(the script above)File--

Critical: Mark GITLAB_ACCESS_TOKEN as both Protected and Masked to prevent token leaks.

How It Works

  1. Pipeline starts → GitLab reads the script from $ARGOCD_GIT_COMMIT
  2. Script contains 5S5 → GitLab doesn’t expand it (not a valid variable)
  3. sed replaces 5S5 with $ → Now it’s valid bash syntax
  4. Script executes → Variables expand at runtime with correct values

Why This Matters: The Security Story

Common mistake (what we found in the wild):

1
ACCESS_TOKEN="glpat-ZRxo6--q_aD89LY-Q7xf"  # Hardcoded token 🚨

Problem: Token is exposed in:

  • CI/CD logs
  • Git history (if script is versioned)
  • Anyone with repository access

Fix using 5S5 pattern:

1
ACCESS_TOKEN="5S5{GITLAB_ACCESS_TOKEN}"  # Secure ✅

After sed transformation → ACCESS_TOKEN="${GITLAB_ACCESS_TOKEN}" → reads from masked CI/CD variable.

The Create-or-Update Logic

The script handles both scenarios:

  1. File doesn’t exist → POST creates it (HTTP 201)
  2. File exists → POST fails (HTTP 400/409) → PUT updates it (HTTP 200)

This avoids race conditions and ensures idempotency.

Debugging Tips

View script before/after transformation

1
2
3
4
5
6
7
8
script:
  - cat $ARGOCD_GIT_COMMIT > ARGOCD_GIT_COMMIT.sh
  - echo "=== Before sed ==="
  - head -20 ARGOCD_GIT_COMMIT.sh
  - sed -i 's/5S5/\$/g' ARGOCD_GIT_COMMIT.sh
  - echo "=== After sed ==="
  - head -20 ARGOCD_GIT_COMMIT.sh
  - ./ARGOCD_GIT_COMMIT.sh

Test the GitLab API call manually

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Check if branch exists
curl -s "https://gitlab.example.com/api/v4/projects/100/repository/branches/feature-branch" \
  --header "PRIVATE-TOKEN: glpat-xxx"

# Test file creation
curl -X POST "https://gitlab.example.com/api/v4/projects/100/repository/files/manifests%2Ftest.yaml" \
  --header "PRIVATE-TOKEN: glpat-xxx" \
  --header "Content-Type: application/json" \
  --data '{
    "branch": "main",
    "commit_message": "Test commit",
    "content": "dGVzdA==",
    "encoding": "base64"
  }'

Alternative Approaches (And Why We Didn’t Use Them)

Option 1: Double-dollar escaping

1
PROJECT_ID="$$CI_PROJECT_ID"

Problem: Only works in GitLab YAML, not in stored scripts.

Option 2: Using \$ escaping

1
PROJECT_ID="\$CI_PROJECT_ID"

Problem: GitLab still expands it in some contexts. Unreliable.

Option 3: Heredoc with quotes

1
2
3
cat << 'EOF' > script.sh
PROJECT_ID="$CI_PROJECT_ID"
EOF

Problem: Can’t store in CI/CD variables, defeats the purpose of centralized script management.

Option 4: Base64 encode the entire script

1
echo "$SCRIPT_BASE64" | base64 -d > script.sh

Problem: Harder to read, maintain, and debug. Good for obfuscation, terrible for collaboration.

The 5S5 Origin Story (Or: Why Not DOLLAR or XXX?)

Initial attempt: Used DOLLAR as placeholder.

1
PROJECT_ID="DOLLAR{CI_PROJECT_ID}"

Problem: Too long, looked ugly, easy to forget.

Second attempt: Used XXX as placeholder.

1
PROJECT_ID="XXX{CI_PROJECT_ID}"

Problem: Conflicts with actual code (variable names, test data).

Third attempt: Used ___ (triple underscore).

1
PROJECT_ID="___{CI_PROJECT_ID}"

Problem: Valid variable name in bash, causes confusion.

Final solution: 5S5 - short, distinctive, won’t conflict with real code, easy to sed.

Pro Tips

  1. Always use set -euo pipefail at the top of your scripts. Exit on errors, undefined variables, and pipe failures.

  2. Use trap for cleanup: Ensure temp files are deleted even if script fails.

    1
    
    trap "rm -f '$TEMP_FILE'" EXIT
    
  3. Check HTTP response codes: Don’t assume API calls succeed. Parse and validate.

  4. Test locally first: Replace 5S5 with $ manually and run on your machine before committing.

  5. Document the pattern: Leave a comment in your CI/CD YAML explaining the 5S5$ transformation.

Pro tip: This pattern works for any CI/CD system with eager variable expansion—not just GitLab. Jenkins, GitHub Actions, CircleCI all have similar quirks. The placeholder technique is universal.


More from me on www.uk4.in.