Files
ljwx-deploy/scripts/github/sync-org-secrets-from-env.sh
T

293 lines
6.9 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=scripts/github/_gh_env.sh
source "$SCRIPT_DIR/_gh_env.sh"
# shellcheck source=scripts/github/_gh_retry.sh
source "$SCRIPT_DIR/_gh_retry.sh"
WORKSPACE_ENV_PY="$(cd "$SCRIPT_DIR/.." && pwd)/env/workspace_env.py"
ENV_FILE=""
ORG="${GITHUB_ORG:-${GH_ORG:-lingjingwanxiang}}"
VISIBILITY=""
HOST_OVERRIDE=""
SHOW_KEYS=0
DRY_RUN=0
declare -a SECRET_NAMES=()
declare -a REPOS=()
usage() {
cat <<'EOF'
Usage:
bash scripts/github/sync-org-secrets-from-env.sh --env-file FILE --org ORG --secret NAME [options]
Options:
--env-file FILE Source env file. Standard KEY=value is recommended.
Legacy lines like export KEY=value or "KEY": "value",
are still accepted for migration.
--org ORG GitHub organization (default: lingjingwanxiang)
--secret NAME Secret name to sync, repeatable
--repo NAME Selected repository name, repeatable
--repos CSV Selected repositories, comma-separated
--visibility MODE all | private | selected
--host HOST Override GH_HOST for GitHub Enterprise
--show-keys List parseable keys from env file and exit
--dry-run Print planned updates without mutating GitHub
-h, --help Show help
Examples:
bash scripts/github/sync-org-secrets-from-env.sh \
--env-file /root/codes/.env \
--org lingjingwanxiang \
--secret HARBOR_USERNAME \
--secret HARBOR_PASSWORD \
--repo ljwx-bookstore \
--repo ljwx-dify
bash scripts/github/sync-org-secrets-from-env.sh \
--env-file /root/codes/.env \
--show-keys
EOF
}
fail() {
echo "[错误] $1" >&2
exit 1
}
normalize_repo_name() {
local raw="$1"
local name="$raw"
if [[ "$raw" == */* ]]; then
local owner="${raw%%/*}"
local repo="${raw##*/}"
if [[ -n "$ORG" && "$owner" != "$ORG" ]]; then
fail "仓库 ${raw} 的 owner 与 --org ${ORG} 不一致"
fi
name="$repo"
fi
name="$(printf '%s' "$name" | xargs)"
if [[ -z "$name" ]]; then
fail "仓库名不能为空"
fi
printf '%s\n' "$name"
}
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--env-file)
ENV_FILE="${2:?missing value for --env-file}"
shift 2
;;
--org)
ORG="${2:?missing value for --org}"
shift 2
;;
--secret)
SECRET_NAMES+=("${2:?missing value for --secret}")
shift 2
;;
--repo)
REPOS+=("$(normalize_repo_name "${2:?missing value for --repo}")")
shift 2
;;
--repos)
IFS=',' read -r -a csv_repos <<<"${2:?missing value for --repos}"
for repo in "${csv_repos[@]}"; do
REPOS+=("$(normalize_repo_name "$repo")")
done
shift 2
;;
--visibility)
VISIBILITY="${2:?missing value for --visibility}"
shift 2
;;
--host)
HOST_OVERRIDE="${2:?missing value for --host}"
shift 2
;;
--show-keys)
SHOW_KEYS=1
shift
;;
--dry-run)
DRY_RUN=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
fail "未知参数: $1"
;;
esac
done
if [[ -z "$ENV_FILE" ]]; then
fail "必须提供 --env-file"
fi
if [[ ! -f "$ENV_FILE" ]]; then
fail "env 文件不存在: $ENV_FILE"
fi
case "${VISIBILITY:-}" in
""|all|private|selected) ;;
*)
fail "--visibility 只能是 all / private / selected"
;;
esac
if (( SHOW_KEYS == 0 )) && (( ${#SECRET_NAMES[@]} == 0 )); then
fail "至少提供一个 --secret,或使用 --show-keys"
fi
if [[ -z "$VISIBILITY" ]]; then
if (( ${#REPOS[@]} > 0 )); then
VISIBILITY="selected"
else
VISIBILITY="private"
fi
fi
if [[ "$VISIBILITY" == "selected" && ${#REPOS[@]} -eq 0 ]]; then
fail "visibility=selected 时必须提供 --repo 或 --repos"
fi
if [[ "$VISIBILITY" != "selected" && ${#REPOS[@]} -gt 0 ]]; then
fail "提供了 --repo/--repos 时,--visibility 必须为 selected"
fi
}
prepare_gh_auth() {
if [[ -n "${HOST_OVERRIDE:-}" ]]; then
export GH_HOST="$HOST_OVERRIDE"
fi
if [[ -n "${GH_TOKEN:-${GITHUB_TOKEN:-}}" ]]; then
gh_env_prepare
fi
if ! gh auth status >/dev/null 2>&1; then
fail "gh 未登录,且当前环境未提供 GH_TOKEN/GITHUB_TOKEN"
fi
}
build_parsed_cache() {
local output_file="$1"
if [[ ! -x "$WORKSPACE_ENV_PY" ]]; then
fail "未找到共享 env 解析器: $WORKSPACE_ENV_PY"
fi
python3 "$WORKSPACE_ENV_PY" json --env-file "$ENV_FILE" >"$output_file"
}
list_keys() {
local parsed_cache="$1"
python3 - "$parsed_cache" <<'PY'
import json
import sys
from pathlib import Path
data = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
for key in sorted(data):
print(key)
PY
}
read_secret_value() {
local parsed_cache="$1"
local secret_name="$2"
python3 - "$parsed_cache" "$secret_name" <<'PY'
import json
import sys
from pathlib import Path
data = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
key = sys.argv[2]
value = data.get(key)
if value is None:
raise SystemExit(2)
sys.stdout.write(str(value))
PY
}
unique_repos_csv() {
if (( ${#REPOS[@]} == 0 )); then
return 0
fi
python3 - "${REPOS[@]}" <<'PY'
import sys
seen: set[str] = set()
ordered: list[str] = []
for repo in sys.argv[1:]:
name = repo.strip()
if not name or name in seen:
continue
seen.add(name)
ordered.append(name)
print(",".join(ordered))
PY
}
sync_secret() {
local secret_name="$1"
local secret_value="$2"
local repos_csv="$3"
local label="sync secret ${ORG}/${secret_name}"
if (( DRY_RUN == 1 )); then
if [[ "$VISIBILITY" == "selected" ]]; then
echo "[dry-run] org=${ORG} secret=${secret_name} visibility=selected repos=${repos_csv}"
else
echo "[dry-run] org=${ORG} secret=${secret_name} visibility=${VISIBILITY}"
fi
return 0
fi
local -a cmd=(gh secret set "$secret_name" --org "$ORG" --body "$secret_value")
if [[ "$VISIBILITY" == "selected" ]]; then
cmd+=(--repos "$repos_csv")
else
cmd+=(--visibility "$VISIBILITY")
fi
gh_retry_command "$label" "${cmd[@]}" >/dev/null
echo "[ok] 已同步 org secret: ${secret_name}"
}
main() {
parse_args "$@"
prepare_gh_auth
local parsed_cache=""
parsed_cache="$(mktemp)"
trap "rm -f -- '$parsed_cache'" EXIT
build_parsed_cache "$parsed_cache"
if (( SHOW_KEYS == 1 )); then
list_keys "$parsed_cache"
exit 0
fi
local repos_csv=""
repos_csv="$(unique_repos_csv)"
local secret_name=""
local secret_value=""
for secret_name in "${SECRET_NAMES[@]}"; do
if ! secret_value="$(read_secret_value "$parsed_cache" "$secret_name")"; then
fail "env 文件中未找到 secret: ${secret_name}"
fi
sync_secret "$secret_name" "$secret_value" "$repos_csv"
done
}
main "$@"