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:
| Variable | Value | Type | Protected | Masked |
|---|
ARGOCD_PROJECT_ID | 100 (your GitOps repo project ID) | Variable | ✓ | - |
GITLAB_ACCESS_TOKEN | glpat-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#
- Pipeline starts → GitLab reads the script from
$ARGOCD_GIT_COMMIT - Script contains
5S5 → GitLab doesn’t expand it (not a valid variable) sed replaces 5S5 with $ → Now it’s valid bash syntax- 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:
- File doesn’t exist → POST creates it (HTTP 201)
- File exists → POST fails (HTTP 400/409) → PUT updates it (HTTP 200)
This avoids race conditions and ensures idempotency.
Debugging Tips#
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#
Always use set -euo pipefail at the top of your scripts. Exit on errors, undefined variables, and pipe failures.
Use trap for cleanup: Ensure temp files are deleted even if script fails.
1
| trap "rm -f '$TEMP_FILE'" EXIT
|
Check HTTP response codes: Don’t assume API calls succeed. Parse and validate.
Test locally first: Replace 5S5 with $ manually and run on your machine before committing.
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.