Created
February 3, 2026 23:36
-
-
Save fabiosoft/2a6b2935b131fd5f39b7e8175d90c119 to your computer and use it in GitHub Desktop.
Bulk upload GitLab CI/CD variables from a .env file
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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