Skip to content

Instantly share code, notes, and snippets.

@fabiosoft
Created February 3, 2026 23:36
Show Gist options
  • Select an option

  • Save fabiosoft/2a6b2935b131fd5f39b7e8175d90c119 to your computer and use it in GitHub Desktop.

Select an option

Save fabiosoft/2a6b2935b131fd5f39b7e8175d90c119 to your computer and use it in GitHub Desktop.
Bulk upload GitLab CI/CD variables from a .env file
#!/usr/bin/env bash
set -euo pipefail
# Bulk upload GitLab CI/CD variables from a .env file
#
# Usage:
# export GITLAB_TOKEN="glpat-..."
# ./gitlab-env-bulk.sh <repo_url> [env_file] [env_scope] [options]
#
# Options:
# --dry-run Do not call GitLab API, just print actions
# -h, --help Show help
print_help() {
cat <<'EOF'
Bulk upload GitLab CI/CD variables from a .env file
Usage:
gitlab-env-bulk.sh <repo_url> [env_file] [env_scope] [options]
Arguments:
repo_url Repository URL (https or ssh)
env_file .env file to load (default: .env.staging)
env_scope GitLab environment scope (default: staging)
Options:
--dry-run Do not call GitLab API, just print actions
-h, --help Show this help and exit
Examples:
gitlab-env-bulk.sh https://gitlab.com/user/repo .env.staging staging
gitlab-env-bulk.sh git@git.gitlab.com:user/repo.git .env.production production
gitlab-env-bulk.sh https://git.gitlab.com/user/repo .env.staging staging --dry-run
Notes:
- Requires GITLAB_TOKEN (just api scope) -- export GITLAB_TOKEN=<token>
- All variables are created as type: env_var
- This parser ignores only full-line comments starting with '#'
(so '#' inside values like URLs is preserved).
EOF
}
# --- Flags / help first ---
for arg in "$@"; do
case "$arg" in
-h|--help)
print_help
exit 0
;;
esac
done
REPO_URL="${1:-}"
ENV_FILE="${2:-.env}"
ENV_SCOPE="${3:-staging}"
DRY_RUN="false"
# Optional flags after positional args
for arg in "${@:4}"; do
case "$arg" in
--dry-run) DRY_RUN="true" ;;
*) echo "Flag sconosciuta: $arg"; echo; print_help; exit 1 ;;
esac
done
if [[ -z "$REPO_URL" ]]; then
print_help
exit 1
fi
# Token API: Personal Access Token con scope "api"
: "${GITLAB_TOKEN:?Devi esportare GITLAB_TOKEN (PAT con scope api). Esempio: export GITLAB_TOKEN='glpat-...'}"
# Se vuoi rendere le variabili "protected" (solo protected branches/tags)
PROTECTED="false"
# Se vuoi rendere le variabili "masked" in base al nome (consigliato)
AUTO_MASK="true"
should_mask() {
local key="$1"
if [[ "$key" =~ (TOKEN|SECRET|PASSWORD|PASS|API_KEY|PRIVATE|KEY|JWT) ]]; then
echo "true"
else
echo "false"
fi
}
urlencode() {
python3 - <<'PY' "$1"
import urllib.parse, sys
print(urllib.parse.quote(sys.argv[1], safe=''))
PY
}
# --- Parse repo URL to get host + path_with_namespace ---
# Supports:
# - https://host/group/project
# - https://host/group/project.git
# - git@host:group/project.git
get_host_and_path() {
local u="$1"
local host path
if [[ "$u" =~ ^git@([^:]+):(.+)$ ]]; then
host="${BASH_REMATCH[1]}"
path="${BASH_REMATCH[2]}"
path="${path%.git}"
echo "https://$host" "$path"
return 0
fi
if [[ "$u" =~ ^https?:// ]]; then
local rest="${u#http://}"
rest="${rest#https://}"
host="${rest%%/*}"
path="${rest#*/}"
# strip query/fragment from the REPO URL only
path="${path%%\?*}"
path="${path%%\#*}"
path="${path%.git}"
path="${path%/}"
echo "https://$host" "$path"
return 0
fi
echo "Errore: formato repo_url non riconosciuto: $u" >&2
exit 1
}
read -r GITLAB_URL PROJECT_PATH <<<"$(get_host_and_path "$REPO_URL")"
PROJECT_PATH_ENC="$(urlencode "$PROJECT_PATH")"
# Resolve project id
project_json="$(curl -sS \
--header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"$GITLAB_URL/api/v4/projects/$PROJECT_PATH_ENC")"
PROJECT_ID="$(python3 - <<'PY' "$project_json"
import json, sys
data = json.loads(sys.argv[1])
print(data["id"])
PY
)"
API="$GITLAB_URL/api/v4/projects/$PROJECT_ID/variables"
# --- Robust-ish .env parsing:
# - ignores empty lines and lines starting with #
# - supports: export KEY=VALUE
# - supports quoted values with spaces: KEY="bar baz"
# - preserves # inside values (only full-line comments are ignored)
parse_env_file() {
local file="$1"
python3 - "$file" <<'PY'
import sys
path = sys.argv[1]
with open(path, "r", encoding="utf-8", errors="replace") as f:
for raw in f:
line = raw.rstrip("\n")
if line.endswith("\r"):
line = line[:-1]
if not line.strip():
continue
if line.lstrip().startswith("#"):
continue
s = line.lstrip()
if s.startswith("export "):
s = s[len("export "):].lstrip()
if "=" not in s:
continue
key, val = s.split("=", 1)
key = key.strip()
if not key:
continue
val = val.strip()
# Remove only OUTER quotes, keep everything else (including #)
if len(val) >= 2 and ((val[0] == '"' and val[-1] == '"') or (val[0] == "'" and val[-1] == "'")):
val = val[1:-1]
# Print as tab-separated. (If your value contains tabs, this is rare; change delimiter if needed.)
print(f"{key}\t{val}")
PY
}
create_var() {
local key="$1" value="$2" masked="$3"
if [[ "$DRY_RUN" == "true" ]]; then
echo "POST create: key=$key masked=$masked protected=$PROTECTED scope=$ENV_SCOPE value_len=${#value}"
return 0
fi
curl -sS -o /dev/null -w "%{http_code}" \
--request POST \
--header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
--form "key=$key" \
--form "value=$value" \
--form "environment_scope=$ENV_SCOPE" \
--form "protected=$PROTECTED" \
--form "masked=$masked" \
--form "variable_type=env_var" \
"$API"
}
update_var() {
local key="$1" value="$2" masked="$3"
local key_enc
key_enc="$(urlencode "$key")"
if [[ "$DRY_RUN" == "true" ]]; then
echo "PUT update: key=$key masked=$masked protected=$PROTECTED scope=$ENV_SCOPE value_len=${#value}"
return 0
fi
curl -sS -o /dev/null -w "%{http_code}" \
--request PUT \
--header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
--form "value=$value" \
--form "environment_scope=$ENV_SCOPE" \
--form "protected=$PROTECTED" \
--form "masked=$masked" \
--form "variable_type=env_var" \
"$API/$key_enc"
}
upsert() {
local key="$1" value="$2"
local masked="false"
if [[ "$AUTO_MASK" == "true" ]]; then
masked="$(should_mask "$key")"
fi
if [[ "$DRY_RUN" == "true" ]]; then
# Don't leak full secrets: show only a short preview
local preview="${value:0:60}"
echo "Would upsert: $key (masked=$masked scope=$ENV_SCOPE) value_preview='${preview}'"
return 0
fi
local code
code="$(create_var "$key" "$value" "$masked")"
if [[ "$code" == "201" ]]; then
echo "✔ created $key (masked=$masked scope=$ENV_SCOPE)"
return 0
fi
code="$(update_var "$key" "$value" "$masked")"
if [[ "$code" =~ ^20[0-9]$ ]]; then
echo "↺ updated $key (masked=$masked scope=$ENV_SCOPE)"
return 0
fi
echo "✖ failed $key (HTTP $code)"
return 1
}
echo "== Bulk upload GitLab CI/CD variables =="
echo "Repo URL: $REPO_URL"
echo "GitLab: $GITLAB_URL"
echo "Project: $PROJECT_PATH"
echo "ProjectID:$PROJECT_ID"
echo "Env file: $ENV_FILE"
echo "Scope: $ENV_SCOPE"
echo "Dry-run: $DRY_RUN"
echo
if [[ ! -f "$ENV_FILE" ]]; then
echo "Errore: file non trovato: $ENV_FILE"
exit 1
fi
# Read parsed key/value pairs from python parser (tab-separated)
while IFS=$'\t' read -r key value; do
[[ -z "$key" ]] && continue
upsert "$key" "$value"
done < <(parse_env_file "$ENV_FILE")
echo
echo "Done."
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment