@ebalzan/ci 0.0.0-stage → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +302 -2
- package/bin/_json.mjs +95 -0
- package/bin/_lib.sh +153 -0
- package/bin/cli.mjs +105 -0
- package/bin/gh-bootstrap.sh +622 -0
- package/bin/gh-reset.sh +148 -0
- package/bin/onboard.sh +340 -0
- package/bin/sync-managed.sh +81 -0
- package/config/actions.env +33 -0
- package/config/repo-settings.json +10 -0
- package/config/repos.env +25 -0
- package/config/rulesets/protect-dev.json +10 -0
- package/config/rulesets/protect-main.json +10 -0
- package/config/rulesets/require-pull-request.json +33 -0
- package/config/secrets.env.example +31 -0
- package/docs/ARCHITECTURE.md +477 -0
- package/docs/AUTO_WORK_ITEMS_WORKFLOW.md +305 -0
- package/docs/CI_FLOW.md +205 -0
- package/docs/CLI.md +229 -0
- package/docs/COMMIT_GUIDE.md +133 -0
- package/docs/CONFIGURATION.md +177 -0
- package/docs/GITHUB_ACTIONS_SETUP.md +315 -0
- package/docs/MIGRATION.md +175 -0
- package/docs/ONBOARDING.md +166 -0
- package/docs/REPO_RULES.md +116 -0
- package/managed/scripts/release/validate-conventional-subject.sh +32 -0
- package/package.json +66 -4
- package/renovate/tooling.json +45 -0
- package/templates/.github/actions.env +17 -0
- package/templates/.github/workflows/auto-pr.yml +33 -0
- package/templates/.github/workflows/pr-checks.yml +48 -0
- package/templates/.github/workflows/promote-dev-to-main.yml +26 -0
- package/templates/.github/workflows/release.yml +36 -0
- package/templates/.husky/commit-msg +18 -0
- package/templates/eslint.config.mjs +6 -0
- package/templates/renovate.json +4 -0
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# gh-bootstrap.sh — apply the shared rules to a GitHub repo: Actions variables
|
|
4
|
+
# and secrets, repository settings and branch rulesets, each merged from the
|
|
5
|
+
# SHARED layer (this package's config/) and the repo's OWN layer (.github/).
|
|
6
|
+
#
|
|
7
|
+
# Layers, lowest precedence first — for any key, the repo's value wins:
|
|
8
|
+
#
|
|
9
|
+
# variables config/actions.env (read at run time, never pushed)
|
|
10
|
+
# <repo>/.github/actions.env (pushed)
|
|
11
|
+
# repository secrets <shared secrets dir>/secrets.env
|
|
12
|
+
# <repo secrets dir>/secrets.env
|
|
13
|
+
# environment secrets <shared secrets dir>/secrets.<environment>.env
|
|
14
|
+
# (environment <name>) <repo secrets dir>/secrets.<environment>.env
|
|
15
|
+
# repository settings config/repo-settings.json (deep-merged JSON)
|
|
16
|
+
# <repo>/.github/repo-settings.json
|
|
17
|
+
# branch rulesets config/rulesets/<file>.json (deep-merged per file name)
|
|
18
|
+
# <repo>/.github/rulesets/<file>.json
|
|
19
|
+
#
|
|
20
|
+
# A repo can therefore override any shared key and add any key of its own, in
|
|
21
|
+
# any scope. Every environment named by a secrets file in either layer is
|
|
22
|
+
# created on the repo and synced. Secrets files are gitignored; each has a
|
|
23
|
+
# committed .example beside it.
|
|
24
|
+
#
|
|
25
|
+
# Shared secrets dir: $CI_SECRETS_DIR if set; else config/ when this is a git
|
|
26
|
+
# checkout of ebalzan/ci; else ${XDG_CONFIG_HOME:-~/.config}/ebalzan-ci (the
|
|
27
|
+
# place for them when the tools run from the npm package).
|
|
28
|
+
#
|
|
29
|
+
# Repo secrets dir: <shared secrets dir>/repos/<owner>/<repo>/ when it exists
|
|
30
|
+
# (recommended: every repo's secrets in one place outside git, usable without
|
|
31
|
+
# a checkout); else the checkout's .github/ (gitignored files).
|
|
32
|
+
#
|
|
33
|
+
# Variables vs secrets. Shared VARIABLES are not copied onto repos: the actions
|
|
34
|
+
# read config/actions.env from the copy of ebalzan/ci the runner downloads for
|
|
35
|
+
# `uses: …@v2` (actions/auto-work-items/src/sharedVars.ts) and fill only what
|
|
36
|
+
# the repo leaves unset. So a shared variable changes by moving the major tag,
|
|
37
|
+
# and only the repo's own keys are pushed — --prune deletes old shared copies.
|
|
38
|
+
# Shared SECRETS cannot work that way (they are not committed), so they are
|
|
39
|
+
# merged in at sync time: after changing one, re-run with --all.
|
|
40
|
+
#
|
|
41
|
+
# Secret mechanics:
|
|
42
|
+
# - A KEY ending in _FILE is a path: the secret is set from that file's
|
|
43
|
+
# contents under the stripped name (multiline values cannot live in a
|
|
44
|
+
# dotenv file). A relative path is relative to the env file's directory.
|
|
45
|
+
# FOO_FILE in a repo overrides a shared FOO, and vice versa.
|
|
46
|
+
# - LOCAL_ONLY_KEYS in the repo's .github/bootstrap.conf are never pushed:
|
|
47
|
+
# they are read only by local tools, so keeping them on GitHub would only
|
|
48
|
+
# widen exposure.
|
|
49
|
+
# - Empty values and placeholder values (xxxx, sk-ant-xxx, github_pat_xxx)
|
|
50
|
+
# are refused: a secret cannot be read back to notice a bad write.
|
|
51
|
+
#
|
|
52
|
+
# Settings and rulesets: keys starting with `$` (e.g. "$comment") are
|
|
53
|
+
# documentation and never sent. A ruleset is matched to the one on GitHub by
|
|
54
|
+
# its "name"; --prune deletes repo rulesets named "ci: …" that no file defines
|
|
55
|
+
# (rulesets you created by hand under another name are never touched).
|
|
56
|
+
# Rulesets on a PRIVATE repo need GitHub Pro (or Team); on the free plan the
|
|
57
|
+
# step warns and skips.
|
|
58
|
+
#
|
|
59
|
+
# Remote mode. With --repo and no local checkout of that repo, its committed
|
|
60
|
+
# files (.github/actions.env, bootstrap.conf, repo-settings.json, rulesets/)
|
|
61
|
+
# are read from its default branch through the API. Its secrets come from
|
|
62
|
+
# repos/<owner>/<repo>/ in the shared secrets dir; without that folder they are
|
|
63
|
+
# unknown, so only the shared secrets are synced and secrets are never pruned.
|
|
64
|
+
#
|
|
65
|
+
# Removing a key from a file does NOT delete it on GitHub — pass --prune, which
|
|
66
|
+
# deletes every variable/secret in a synced scope that no layer defines (after a
|
|
67
|
+
# confirmation listing them). Environments that no file names are left alone.
|
|
68
|
+
#
|
|
69
|
+
# Usage:
|
|
70
|
+
# ebalzan-ci bootstrap [options] # repo in the current directory
|
|
71
|
+
# ebalzan-ci bootstrap -C ../my-repo # a local checkout
|
|
72
|
+
# ebalzan-ci bootstrap -R owner/repo # no checkout needed (remote mode)
|
|
73
|
+
# ebalzan-ci bootstrap --all # every repo in config/repos.env
|
|
74
|
+
# bin/gh-bootstrap.sh [options] # same, from a checkout of ebalzan/ci
|
|
75
|
+
#
|
|
76
|
+
# Options:
|
|
77
|
+
# -C, --repo-dir DIR Repo checkout to sync (default: current directory)
|
|
78
|
+
# -R, --repo OWNER/REPO Target repo. Alone: remote mode, unless the current
|
|
79
|
+
# directory is a checkout of it. With -C: push DIR's
|
|
80
|
+
# config to this slug.
|
|
81
|
+
# -a, --all Sync every repo config/repos.env selects (topic + list)
|
|
82
|
+
# --skip LIST Comma-separated sections to leave alone:
|
|
83
|
+
# vars, secrets, settings, rulesets
|
|
84
|
+
# --prune Also delete config on GitHub that no layer defines
|
|
85
|
+
# -n, --dry-run Show the merged plan; change nothing
|
|
86
|
+
# -y, --yes Skip the confirmation prompts
|
|
87
|
+
# -h, --help Show this help
|
|
88
|
+
#
|
|
89
|
+
# Requirements: gh (GitHub CLI), authenticated with admin on the target repo;
|
|
90
|
+
# node (settings and rulesets).
|
|
91
|
+
set -euo pipefail
|
|
92
|
+
|
|
93
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
94
|
+
CI_ROOT="$(dirname "$SCRIPT_DIR")"
|
|
95
|
+
# shellcheck source=_lib.sh
|
|
96
|
+
source "$SCRIPT_DIR/_lib.sh"
|
|
97
|
+
|
|
98
|
+
SHARED_DIR="$CI_ROOT/config"
|
|
99
|
+
REPOS_FILE="$SHARED_DIR/repos.env"
|
|
100
|
+
JSON="$SCRIPT_DIR/_json.mjs"
|
|
101
|
+
PLACEHOLDER_RE='(xxxx|sk-ant-xxx|github_pat_xxx)'
|
|
102
|
+
# Rulesets whose name starts with this are managed here, and --prune may delete them.
|
|
103
|
+
MANAGED_RULESET_PREFIX='ci: '
|
|
104
|
+
|
|
105
|
+
if [[ -n "${CI_SECRETS_DIR:-}" ]]; then
|
|
106
|
+
SHARED_SECRETS_DIR="$CI_SECRETS_DIR"
|
|
107
|
+
elif [[ -d "$CI_ROOT/.git" || -f "$CI_ROOT/.git" ]]; then
|
|
108
|
+
SHARED_SECRETS_DIR="$SHARED_DIR"
|
|
109
|
+
else
|
|
110
|
+
SHARED_SECRETS_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/ebalzan-ci"
|
|
111
|
+
fi
|
|
112
|
+
|
|
113
|
+
# ---- args --------------------------------------------------------------------
|
|
114
|
+
REPO_DIR="."
|
|
115
|
+
REPO_DIR_SET=false
|
|
116
|
+
REPO=""
|
|
117
|
+
ALL=false
|
|
118
|
+
PRUNE=false
|
|
119
|
+
DRY_RUN=false
|
|
120
|
+
ASSUME_YES=false
|
|
121
|
+
SKIP=""
|
|
122
|
+
PASSTHROUGH=()
|
|
123
|
+
while [[ $# -gt 0 ]]; do
|
|
124
|
+
case "$1" in
|
|
125
|
+
-C|--repo-dir) REPO_DIR="${2:-}"; REPO_DIR_SET=true; shift 2 ;;
|
|
126
|
+
-R|--repo) REPO="${2:-}"; shift 2 ;;
|
|
127
|
+
-a|--all) ALL=true; shift ;;
|
|
128
|
+
--skip) SKIP="${2:-}"; PASSTHROUGH+=("$1" "${2:-}"); shift 2 ;;
|
|
129
|
+
--prune) PRUNE=true; PASSTHROUGH+=("$1"); shift ;;
|
|
130
|
+
-n|--dry-run) DRY_RUN=true; PASSTHROUGH+=("$1"); shift ;;
|
|
131
|
+
-y|--yes) ASSUME_YES=true; PASSTHROUGH+=("$1"); shift ;;
|
|
132
|
+
-h|--help) print_help "${BASH_SOURCE[0]}"; exit 0 ;;
|
|
133
|
+
*) die "Unknown option: $1 (try --help)" ;;
|
|
134
|
+
esac
|
|
135
|
+
done
|
|
136
|
+
|
|
137
|
+
for section in ${SKIP//,/ }; do
|
|
138
|
+
case "$section" in
|
|
139
|
+
vars|secrets|settings|rulesets) ;;
|
|
140
|
+
*) die "--skip: unknown section '$section' (vars, secrets, settings, rulesets)." ;;
|
|
141
|
+
esac
|
|
142
|
+
done
|
|
143
|
+
enabled() { [[ ",$SKIP," != *",$1,"* ]]; }
|
|
144
|
+
|
|
145
|
+
# ---- --all: re-run once per repo ---------------------------------------------
|
|
146
|
+
if $ALL; then
|
|
147
|
+
[[ -z "$REPO" ]] || die "--repo cannot be combined with --all."
|
|
148
|
+
! $REPO_DIR_SET || die "--repo-dir cannot be combined with --all."
|
|
149
|
+
require_gh
|
|
150
|
+
require_auth
|
|
151
|
+
slugs="$(managed_repos "$REPOS_FILE" "$CI_ROOT")"
|
|
152
|
+
[[ -n "$slugs" ]] || die "No repos selected: tag repos with REPOS_TOPIC or list them in REPOS ($REPOS_FILE)."
|
|
153
|
+
failed=()
|
|
154
|
+
for slug in $slugs; do
|
|
155
|
+
info ""
|
|
156
|
+
info "${BOLD}━━ $slug ━━${RESET}"
|
|
157
|
+
dir="$(local_checkout "$slug" "$CI_ROOT")"
|
|
158
|
+
if [[ -n "$dir" ]]; then
|
|
159
|
+
"${BASH_SOURCE[0]}" --repo-dir "$dir" --repo "$slug" ${PASSTHROUGH[@]+"${PASSTHROUGH[@]}"} || failed+=("$slug")
|
|
160
|
+
else
|
|
161
|
+
"${BASH_SOURCE[0]}" --repo "$slug" ${PASSTHROUGH[@]+"${PASSTHROUGH[@]}"} || failed+=("$slug")
|
|
162
|
+
fi
|
|
163
|
+
done
|
|
164
|
+
info ""
|
|
165
|
+
[[ ${#failed[@]} -eq 0 ]] || die "Not synced: ${failed[*]}"
|
|
166
|
+
ok "${BOLD}All repos processed.${RESET}"
|
|
167
|
+
exit 0
|
|
168
|
+
fi
|
|
169
|
+
|
|
170
|
+
# ---- preconditions & targets -------------------------------------------------
|
|
171
|
+
require_gh
|
|
172
|
+
$DRY_RUN || require_auth
|
|
173
|
+
|
|
174
|
+
# Remote mode: --repo alone, and the current directory is not that repo.
|
|
175
|
+
REMOTE=false
|
|
176
|
+
if [[ -n "$REPO" ]] && ! $REPO_DIR_SET; then
|
|
177
|
+
here="$(detect_repo . 2>/dev/null || true)"
|
|
178
|
+
if [[ "$(printf '%s' "$here" | tr '[:upper:]' '[:lower:]')" != "$(printf '%s' "$REPO" | tr '[:upper:]' '[:lower:]')" ]]; then
|
|
179
|
+
REMOTE=true
|
|
180
|
+
fi
|
|
181
|
+
fi
|
|
182
|
+
|
|
183
|
+
WORK_DIR="$(mktemp -d)"
|
|
184
|
+
trap 'rm -rf "$WORK_DIR"' EXIT
|
|
185
|
+
|
|
186
|
+
# fetch_remote_config SLUG DEST — copy the repo's committed .github config
|
|
187
|
+
# from its default branch into DEST/.github. Secrets files are gitignored and
|
|
188
|
+
# their .example files are deliberately skipped: an example must never stand in
|
|
189
|
+
# for real values.
|
|
190
|
+
fetch_remote_config() {
|
|
191
|
+
local slug="$1" dest="$2/.github" name
|
|
192
|
+
mkdir -p "$dest/rulesets"
|
|
193
|
+
gh api "repos/$slug" >/dev/null 2>&1 || die "Cannot read $slug (does it exist, and can gh see it?)."
|
|
194
|
+
for name in actions.env bootstrap.conf repo-settings.json; do
|
|
195
|
+
gh api -H 'Accept: application/vnd.github.raw' "repos/$slug/contents/.github/$name" \
|
|
196
|
+
> "$dest/$name" 2>/dev/null || rm -f "$dest/$name"
|
|
197
|
+
done
|
|
198
|
+
for name in $(gh api "repos/$slug/contents/.github/rulesets" -q '.[] | select(.type == "file") | .name' 2>/dev/null || true); do
|
|
199
|
+
[[ "$name" == *.json ]] || continue
|
|
200
|
+
gh api -H 'Accept: application/vnd.github.raw' "repos/$slug/contents/.github/rulesets/$name" \
|
|
201
|
+
> "$dest/rulesets/$name"
|
|
202
|
+
done
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if $REMOTE; then
|
|
206
|
+
fetch_remote_config "$REPO" "$WORK_DIR/remote"
|
|
207
|
+
REPO_DIR="$WORK_DIR/remote"
|
|
208
|
+
SOURCE_LABEL="$REPO@default-branch (remote: no local checkout)"
|
|
209
|
+
else
|
|
210
|
+
[[ -d "$REPO_DIR/.git" || -f "$REPO_DIR/.git" ]] || die "$REPO_DIR is not a git checkout. Pass -R OWNER/REPO to work without one."
|
|
211
|
+
REPO_DIR="$(cd "$REPO_DIR" && pwd)"
|
|
212
|
+
[[ -n "$REPO" ]] || REPO="$(detect_repo "$REPO_DIR")"
|
|
213
|
+
[[ -n "$REPO" ]] || die "Could not determine the repo for $REPO_DIR. Pass --repo OWNER/REPO."
|
|
214
|
+
SOURCE_LABEL="$REPO_DIR"
|
|
215
|
+
fi
|
|
216
|
+
REPO_GITHUB_DIR="$REPO_DIR/.github"
|
|
217
|
+
OWNER="${REPO%%/*}"
|
|
218
|
+
|
|
219
|
+
# repo_vault_dir — the repo's folder in the shared secrets dir
|
|
220
|
+
# (repos/<owner>/<repo>/, matched case-insensitively), or nothing.
|
|
221
|
+
repo_vault_dir() {
|
|
222
|
+
local base="$SHARED_SECRETS_DIR/repos" d want
|
|
223
|
+
[[ -d "$base" ]] || return 0
|
|
224
|
+
want="$(printf '%s/' "$REPO" | tr '[:upper:]' '[:lower:]')"
|
|
225
|
+
for d in "$base"/*/*/; do
|
|
226
|
+
[[ -d "$d" ]] || continue
|
|
227
|
+
if [[ "$(printf '%s' "${d#"$base"/}" | tr '[:upper:]' '[:lower:]')" == "$want" ]]; then
|
|
228
|
+
(cd "$d" && pwd); return 0
|
|
229
|
+
fi
|
|
230
|
+
done
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
# Where the repo's own secrets files live. Its folder in the shared secrets dir
|
|
234
|
+
# wins when it exists: then every repo's secrets sit in one place outside git,
|
|
235
|
+
# and are known even without a checkout (remote mode). Else the checkout's
|
|
236
|
+
# .github/, where they are gitignored.
|
|
237
|
+
REPO_SECRETS_DIR="$(repo_vault_dir)"
|
|
238
|
+
if [[ -n "$REPO_SECRETS_DIR" ]]; then
|
|
239
|
+
SECRETS_KNOWN=true
|
|
240
|
+
if ! $REMOTE && compgen -G "$REPO_GITHUB_DIR/secrets*.env" >/dev/null; then
|
|
241
|
+
warn "Using $REPO_SECRETS_DIR for $REPO's secrets; the secrets*.env files in $REPO_GITHUB_DIR are ignored."
|
|
242
|
+
fi
|
|
243
|
+
else
|
|
244
|
+
REPO_SECRETS_DIR="$REPO_GITHUB_DIR"
|
|
245
|
+
if $REMOTE; then SECRETS_KNOWN=false; else SECRETS_KNOWN=true; fi
|
|
246
|
+
fi
|
|
247
|
+
|
|
248
|
+
# A private CI repo can only be called by repos of the same account.
|
|
249
|
+
CI_REPO="$(unquote "$(env_get "$REPOS_FILE" CI_REPO)")"
|
|
250
|
+
if ! $DRY_RUN && [[ -n "$CI_REPO" && "${CI_REPO%%/*}" != "$OWNER" ]]; then
|
|
251
|
+
if [[ "$(gh repo view "$CI_REPO" --json visibility -q .visibility 2>/dev/null || true)" == "PRIVATE" ]]; then
|
|
252
|
+
warn "$CI_REPO is private and $REPO is owned by someone else: its workflows cannot call $CI_REPO."
|
|
253
|
+
fi
|
|
254
|
+
fi
|
|
255
|
+
|
|
256
|
+
LOCAL_ONLY_KEYS="$(env_get "$REPO_GITHUB_DIR/bootstrap.conf" LOCAL_ONLY_KEYS)"
|
|
257
|
+
is_local_only() { [[ " $LOCAL_ONLY_KEYS " == *" $1 "* ]]; }
|
|
258
|
+
|
|
259
|
+
# Usable secrets source: the real file if present, else the committed example.
|
|
260
|
+
secrets_src() { if [[ -f "$1" ]]; then printf '%s' "$1"; elif [[ -f "$1.example" ]]; then printf '%s' "$1.example"; fi; }
|
|
261
|
+
|
|
262
|
+
# Seed a gitignored secrets file from its example on first run (real runs only).
|
|
263
|
+
seed_secret_file() {
|
|
264
|
+
[[ -f "$1.example" && ! -f "$1" ]] || return 0
|
|
265
|
+
cp "$1.example" "$1"
|
|
266
|
+
if [[ "$(env_count "$1")" -gt 0 ]]; then
|
|
267
|
+
warn "Created $1 from its example — fill in real values before syncing."
|
|
268
|
+
fi
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
# Environment names from secrets.<env>.env (or its .example) in either layer.
|
|
272
|
+
environments() {
|
|
273
|
+
local f base
|
|
274
|
+
for f in "$SHARED_SECRETS_DIR"/secrets.*.env "$SHARED_SECRETS_DIR"/secrets.*.env.example \
|
|
275
|
+
"$REPO_SECRETS_DIR"/secrets.*.env "$REPO_SECRETS_DIR"/secrets.*.env.example; do
|
|
276
|
+
[[ -e "$f" ]] || continue
|
|
277
|
+
base="$(basename "$f")"; base="${base%.example}"; base="${base#secrets.}"; base="${base%.env}"
|
|
278
|
+
printf '%s\n' "$base"
|
|
279
|
+
done | sort -u
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
# merge_layers STRIP_FILE_SUFFIX FILE... — the merged key set, one line per key:
|
|
283
|
+
# NAME <tab> FILE <tab> RAW_KEY <tab> LAYER
|
|
284
|
+
# Later files win. LAYER is "shared", "repo", "repo (overrides shared)", or
|
|
285
|
+
# "repo (same as shared: redundant)" — a repo line that only repeats the shared
|
|
286
|
+
# value can be deleted from the repo's file.
|
|
287
|
+
# With STRIP_FILE_SUFFIX=1, FOO_FILE and FOO are the same key (secrets only).
|
|
288
|
+
merge_layers() {
|
|
289
|
+
local strip="$1" f layer; shift
|
|
290
|
+
for f in "$@"; do
|
|
291
|
+
[[ -n "$f" && -f "$f" ]] || continue
|
|
292
|
+
if [[ "$f" == "$REPO_GITHUB_DIR"/* || "$f" == "$REPO_SECRETS_DIR"/* ]]; then layer=repo; else layer=shared; fi
|
|
293
|
+
clean_env "$f" | awk -v file="$f" -v layer="$layer" -v strip="$strip" '
|
|
294
|
+
{ i = index($0, "="); raw = substr($0, 1, i - 1); value = substr($0, i + 1)
|
|
295
|
+
name = raw; if (strip == 1) sub(/_FILE$/, "", name)
|
|
296
|
+
print name "\t" file "\t" raw "\t" layer "\t" value }'
|
|
297
|
+
done | awk -F'\t' '
|
|
298
|
+
{ if (!($1 in row)) order[++n] = $1
|
|
299
|
+
else if (layer[$1] == "shared" && $4 == "repo")
|
|
300
|
+
note[$1] = ($5 == value[$1] && $3 == raw[$1]) ? "repo (same as shared: redundant)" : "repo (overrides shared)"
|
|
301
|
+
row[$1] = $1 "\t" $2 "\t" $3; layer[$1] = $4; value[$1] = $5; raw[$1] = $3 }
|
|
302
|
+
END { for (i = 1; i <= n; i++) { k = order[i]
|
|
303
|
+
print row[k] "\t" (k in note ? note[k] : layer[k]) } }'
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
# Drop LOCAL_ONLY_KEYS from a merged listing (secrets only).
|
|
307
|
+
without_local_only() {
|
|
308
|
+
local name rest
|
|
309
|
+
while IFS=$'\t' read -r name rest; do
|
|
310
|
+
is_local_only "$name" || is_local_only "${name}_FILE" || printf '%s\t%s\n' "$name" "$rest"
|
|
311
|
+
done
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
print_plan() { # print_plan TITLE (merged listing on stdin)
|
|
315
|
+
local title="$1" name file raw layer any=false
|
|
316
|
+
while IFS=$'\t' read -r name file raw layer; do
|
|
317
|
+
$any || { info " ${BOLD}$title${RESET}"; any=true; }
|
|
318
|
+
printf ' %-34s %s%s%s\n' "$name" "$DIM" "$layer" "$RESET"
|
|
319
|
+
done
|
|
320
|
+
return 0
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
# Read a merged entry's value: a _FILE key yields the referenced file's contents.
|
|
324
|
+
entry_value() { # entry_value FILE RAW_KEY
|
|
325
|
+
local value path
|
|
326
|
+
value="$(unquote "$(env_get "$1" "$2")")"
|
|
327
|
+
if [[ "$2" == *_FILE ]]; then
|
|
328
|
+
path="${value/#\~/$HOME}"
|
|
329
|
+
[[ "$path" == /* ]] || path="$(dirname "$1")/$path"
|
|
330
|
+
[[ -f "$path" ]] || die "$2 in $1 points to '$value', which does not exist."
|
|
331
|
+
cat "$path"
|
|
332
|
+
else
|
|
333
|
+
printf '%s' "$value"
|
|
334
|
+
fi
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
# Refuse empty and placeholder values before anything is pushed.
|
|
338
|
+
check_values() { # (merged secrets listing on stdin)
|
|
339
|
+
local name file raw layer value
|
|
340
|
+
while IFS=$'\t' read -r name file raw layer; do
|
|
341
|
+
[[ "$raw" == *_FILE ]] && continue
|
|
342
|
+
value="$(unquote "$(env_get "$file" "$raw")")"
|
|
343
|
+
[[ -n "$value" ]] || die "$raw has an empty value in $file. Fill it in, or comment the line out."
|
|
344
|
+
if printf '%s' "$value" | grep -qiE "$PLACEHOLDER_RE"; then
|
|
345
|
+
die "$raw in $file is still a placeholder. Fill it in and re-run (or use --dry-run)."
|
|
346
|
+
fi
|
|
347
|
+
done
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
# ---- settings & rulesets: desired state --------------------------------------
|
|
351
|
+
# merged_settings — the merged repo-settings JSON on stdout, or nothing.
|
|
352
|
+
merged_settings() {
|
|
353
|
+
[[ -f "$SHARED_DIR/repo-settings.json" || -f "$REPO_GITHUB_DIR/repo-settings.json" ]] || return 0
|
|
354
|
+
node "$JSON" merge "$SHARED_DIR/repo-settings.json" "$REPO_GITHUB_DIR/repo-settings.json"
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
# ruleset_files — the file names (x.json) defined by either layer.
|
|
358
|
+
ruleset_files() {
|
|
359
|
+
local f
|
|
360
|
+
for f in "$SHARED_DIR"/rulesets/*.json "$REPO_GITHUB_DIR"/rulesets/*.json; do
|
|
361
|
+
[[ -f "$f" ]] && basename "$f"
|
|
362
|
+
done | sort -u
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
# Write each merged ruleset to $WORK_DIR/rulesets/<file>; print "<file>\t<name>\t<enforcement>".
|
|
366
|
+
build_rulesets() {
|
|
367
|
+
local file out name
|
|
368
|
+
mkdir -p "$WORK_DIR/rulesets"
|
|
369
|
+
for file in $(ruleset_files); do
|
|
370
|
+
out="$WORK_DIR/rulesets/$file"
|
|
371
|
+
node "$JSON" merge "$SHARED_DIR/rulesets/$file" "$REPO_GITHUB_DIR/rulesets/$file" > "$out"
|
|
372
|
+
name="$(node "$JSON" get "$out" name)"
|
|
373
|
+
[[ -n "$name" ]] || die "Ruleset $file has no \"name\"."
|
|
374
|
+
printf '%s\t%s\t%s\n' "$file" "$name" "$(node "$JSON" get "$out" enforcement)"
|
|
375
|
+
done
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
# ---- apply -------------------------------------------------------------------
|
|
379
|
+
set_vars() { # (merged vars listing on stdin)
|
|
380
|
+
local tmp name file raw layer n=0
|
|
381
|
+
tmp="$WORK_DIR/vars.env"
|
|
382
|
+
: > "$tmp"
|
|
383
|
+
while IFS=$'\t' read -r name file raw layer; do
|
|
384
|
+
clean_env "$file" | grep -E "^$raw=" | head -1 >> "$tmp"; n=$((n + 1))
|
|
385
|
+
done
|
|
386
|
+
if [[ "$n" -gt 0 ]]; then
|
|
387
|
+
step "Setting $n repo variable(s)"
|
|
388
|
+
gh variable set --repo "$REPO" --env-file "$tmp" || die "gh variable set failed."
|
|
389
|
+
ok "done"
|
|
390
|
+
fi
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
set_secrets() { # set_secrets [ENV] (merged secrets listing on stdin)
|
|
394
|
+
local envname="${1:-}" name file raw layer names=() scope=(--repo "$REPO")
|
|
395
|
+
[[ -z "$envname" ]] || scope+=(--env "$envname")
|
|
396
|
+
while IFS=$'\t' read -r name file raw layer; do
|
|
397
|
+
entry_value "$file" "$raw" | gh secret set "$name" "${scope[@]}"
|
|
398
|
+
names+=("$name")
|
|
399
|
+
done
|
|
400
|
+
[[ ${#names[@]} -gt 0 ]] && ok "${envname:+environment '$envname' }secrets synced (values not shown): ${names[*]}"
|
|
401
|
+
return 0
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
# prune KIND MERGED_NAMES [ENV] — delete KIND (variable|secret) entries in scope
|
|
405
|
+
# that are not in MERGED_NAMES (newline-separated).
|
|
406
|
+
prune() {
|
|
407
|
+
local kind="$1" keep="$2" envname="${3:-}" scope=(--repo "$REPO") existing stale name
|
|
408
|
+
[[ -z "$envname" ]] || scope+=(--env "$envname")
|
|
409
|
+
existing="$(gh "$kind" list "${scope[@]}" --json name --jq '.[].name' 2>/dev/null || true)"
|
|
410
|
+
stale="$(comm -23 <(printf '%s\n' "$existing" | grep . | sort -u) <(printf '%s\n' "$keep" | grep . | sort -u) || true)"
|
|
411
|
+
[[ -n "$stale" ]] || return 0
|
|
412
|
+
step "Stale ${kind}s on ${envname:+environment '$envname' of }$REPO (no layer defines them):"
|
|
413
|
+
printf ' - %s\n' $stale
|
|
414
|
+
$DRY_RUN && return 0
|
|
415
|
+
if ! $ASSUME_YES; then
|
|
416
|
+
confirm "Delete these?" || { warn "Kept."; return 0; }
|
|
417
|
+
fi
|
|
418
|
+
while IFS= read -r name; do
|
|
419
|
+
[[ -n "$name" ]] && gh "$kind" delete "$name" "${scope[@]}" && ok "deleted $kind $name"
|
|
420
|
+
done <<< "$stale"
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
names_of() { cut -f1; }
|
|
424
|
+
|
|
425
|
+
# apply_settings FILE — PATCH the repo; on a dry run show what would change.
|
|
426
|
+
apply_settings() {
|
|
427
|
+
local desired="$1" current="$WORK_DIR/current-settings.json" diff key now want
|
|
428
|
+
if gh api "repos/$REPO" > "$current" 2>/dev/null; then
|
|
429
|
+
diff="$(node "$JSON" diff "$desired" "$current")"
|
|
430
|
+
if [[ -z "$diff" ]]; then ok "repository settings already match"; return 0; fi
|
|
431
|
+
step "Repository settings to change"
|
|
432
|
+
while IFS=$'\t' read -r key now want; do
|
|
433
|
+
printf ' %-30s %s → %s\n' "$key" "$now" "$want"
|
|
434
|
+
done <<< "$diff"
|
|
435
|
+
else
|
|
436
|
+
step "Repository settings (current values unknown)"
|
|
437
|
+
sed 's/^/ /' "$desired"
|
|
438
|
+
fi
|
|
439
|
+
$DRY_RUN && return 0
|
|
440
|
+
gh api -X PATCH "repos/$REPO" --input "$desired" >/dev/null || die "Updating repository settings failed."
|
|
441
|
+
ok "repository settings updated"
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
# rulesets_unavailable OUTPUT — the API's answer when rulesets need a paid plan.
|
|
445
|
+
rulesets_unavailable() { grep -qiE 'upgrade to github pro|make this repository public' <<< "$1"; }
|
|
446
|
+
|
|
447
|
+
# apply_rulesets PLAN — create or update each merged ruleset by name.
|
|
448
|
+
apply_rulesets() {
|
|
449
|
+
local plan="$1" existing out file name enforcement id
|
|
450
|
+
if ! existing="$(gh api "repos/$REPO/rulesets?includes_parents=false" \
|
|
451
|
+
-q '.[] | select(.source_type == "Repository") | [.id, .name] | @tsv' 2>&1)"; then
|
|
452
|
+
if rulesets_unavailable "$existing"; then
|
|
453
|
+
warn "Rulesets are not available for $REPO (a private repo needs GitHub Pro or Team) — skipped."
|
|
454
|
+
return 0
|
|
455
|
+
elif $DRY_RUN; then
|
|
456
|
+
warn "Could not list rulesets on $REPO; showing every ruleset as new."
|
|
457
|
+
existing=""
|
|
458
|
+
else
|
|
459
|
+
die "Could not list rulesets on $REPO: $existing"
|
|
460
|
+
fi
|
|
461
|
+
fi
|
|
462
|
+
while IFS=$'\t' read -r file name enforcement; do
|
|
463
|
+
[[ -n "$file" ]] || continue
|
|
464
|
+
id="$(awk -F'\t' -v n="$name" '$2 == n { print $1; exit }' <<< "$existing")"
|
|
465
|
+
if $DRY_RUN; then
|
|
466
|
+
info " $([[ -n "$id" ]] && echo update || echo create) $(printf '%-28s' "'$name'") ${DIM}$enforcement · $file${RESET}"
|
|
467
|
+
continue
|
|
468
|
+
fi
|
|
469
|
+
if [[ -n "$id" ]]; then
|
|
470
|
+
out="$(gh api -X PUT "repos/$REPO/rulesets/$id" --input "$WORK_DIR/rulesets/$file" 2>&1)" \
|
|
471
|
+
|| { rulesets_unavailable "$out" && { warn "Rulesets are not available for $REPO — skipped."; return 0; }; die "Updating ruleset '$name' failed: $out"; }
|
|
472
|
+
ok "ruleset '$name' updated ($enforcement)"
|
|
473
|
+
else
|
|
474
|
+
out="$(gh api -X POST "repos/$REPO/rulesets" --input "$WORK_DIR/rulesets/$file" 2>&1)" \
|
|
475
|
+
|| { rulesets_unavailable "$out" && { warn "Rulesets are not available for $REPO (a private repo needs GitHub Pro or Team) — skipped."; return 0; }; die "Creating ruleset '$name' failed: $out"; }
|
|
476
|
+
ok "ruleset '$name' created ($enforcement)"
|
|
477
|
+
fi
|
|
478
|
+
done <<< "$plan"
|
|
479
|
+
|
|
480
|
+
$PRUNE || return 0
|
|
481
|
+
local stale
|
|
482
|
+
stale="$(awk -F'\t' -v p="$MANAGED_RULESET_PREFIX" 'index($2, p) == 1' <<< "$existing" \
|
|
483
|
+
| while IFS=$'\t' read -r id name; do
|
|
484
|
+
cut -f2 <<< "$plan" | grep -qxF "$name" || printf '%s\t%s\n' "$id" "$name"
|
|
485
|
+
done)"
|
|
486
|
+
[[ -n "$stale" ]] || return 0
|
|
487
|
+
step "Stale rulesets on $REPO (named '${MANAGED_RULESET_PREFIX}…', no file defines them):"
|
|
488
|
+
cut -f2 <<< "$stale" | sed 's/^/ - /'
|
|
489
|
+
$DRY_RUN && return 0
|
|
490
|
+
if ! $ASSUME_YES; then
|
|
491
|
+
confirm "Delete these?" || { warn "Kept."; return 0; }
|
|
492
|
+
fi
|
|
493
|
+
while IFS=$'\t' read -r id name; do
|
|
494
|
+
gh api -X DELETE "repos/$REPO/rulesets/$id" >/dev/null && ok "deleted ruleset '$name'"
|
|
495
|
+
done <<< "$stale"
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
# ---- plan --------------------------------------------------------------------
|
|
499
|
+
if ! $DRY_RUN && enabled secrets; then
|
|
500
|
+
seed_secret_file "$SHARED_SECRETS_DIR/secrets.env"
|
|
501
|
+
[[ "$REPO_SECRETS_DIR" != "$REPO_GITHUB_DIR" ]] || $REMOTE || seed_secret_file "$REPO_GITHUB_DIR/secrets.env"
|
|
502
|
+
fi
|
|
503
|
+
|
|
504
|
+
# ALL_VARS is the merged view, for the plan and the board check; VARS is the
|
|
505
|
+
# repo's own keys — the only variables pushed (see "Variables vs secrets").
|
|
506
|
+
ALL_VARS="$(merge_layers 0 "$SHARED_DIR/actions.env" "$REPO_GITHUB_DIR/actions.env")"
|
|
507
|
+
VARS="$(printf '%s\n' "$ALL_VARS" | awk -F'\t' '$4 != "shared"')"
|
|
508
|
+
RUNTIME_VARS="$(printf '%s\n' "$ALL_VARS" | awk -F'\t' '$4 == "shared" { printf "%s ", $1 }')"
|
|
509
|
+
SECRETS="$(merge_layers 1 "$(secrets_src "$SHARED_SECRETS_DIR/secrets.env")" \
|
|
510
|
+
"$(secrets_src "$REPO_SECRETS_DIR/secrets.env")" | without_local_only)"
|
|
511
|
+
ENVS="$(environments)"
|
|
512
|
+
SETTINGS_FILE=""
|
|
513
|
+
if enabled settings; then
|
|
514
|
+
SETTINGS_FILE="$WORK_DIR/settings.json"
|
|
515
|
+
merged_settings > "$SETTINGS_FILE"
|
|
516
|
+
[[ -s "$SETTINGS_FILE" ]] || SETTINGS_FILE=""
|
|
517
|
+
fi
|
|
518
|
+
RULESETS=""
|
|
519
|
+
enabled rulesets && RULESETS="$(build_rulesets)"
|
|
520
|
+
|
|
521
|
+
info ""
|
|
522
|
+
info "${BOLD}Bootstrap${RESET} ${DIM}$REPO ← $SOURCE_LABEL${RESET}"
|
|
523
|
+
info "${DIM}shared config: $SHARED_DIR · shared secrets: $SHARED_SECRETS_DIR · repo secrets: $($SECRETS_KNOWN && printf '%s' "$REPO_SECRETS_DIR" || printf 'unknown')${SKIP:+ · skipping: $SKIP}${RESET}"
|
|
524
|
+
info ""
|
|
525
|
+
if enabled vars; then
|
|
526
|
+
printf '%s\n' "$VARS" | grep . | print_plan "repository variables" || true
|
|
527
|
+
[[ -z "$RUNTIME_VARS" ]] || info " ${DIM}shared variables, read at run time (not pushed): ${RUNTIME_VARS% }${RESET}"
|
|
528
|
+
fi
|
|
529
|
+
if enabled secrets; then
|
|
530
|
+
printf '%s\n' "$SECRETS" | grep . | print_plan "repository secrets" || true
|
|
531
|
+
for env in $ENVS; do
|
|
532
|
+
merge_layers 1 "$(secrets_src "$SHARED_SECRETS_DIR/secrets.$env.env")" \
|
|
533
|
+
"$(secrets_src "$REPO_SECRETS_DIR/secrets.$env.env")" | without_local_only \
|
|
534
|
+
| print_plan "environment '$env' secrets"
|
|
535
|
+
done
|
|
536
|
+
[[ -z "$LOCAL_ONLY_KEYS" ]] || info " ${DIM}local only, never pushed: $LOCAL_ONLY_KEYS${RESET}"
|
|
537
|
+
$SECRETS_KNOWN || info " ${DIM}remote mode, no repos/$REPO/ in the shared secrets dir: the repo's own secrets are unknown; shared secrets only, never pruned${RESET}"
|
|
538
|
+
fi
|
|
539
|
+
if [[ -n "$SETTINGS_FILE" ]]; then
|
|
540
|
+
info " ${BOLD}repository settings${RESET}"
|
|
541
|
+
info " $(node -e 'const s=require(process.argv[1]);console.log(Object.entries(s).map(([k,v])=>`${k}=${v}`).join(" "))' "$SETTINGS_FILE")"
|
|
542
|
+
fi
|
|
543
|
+
if [[ -n "$RULESETS" ]]; then
|
|
544
|
+
info " ${BOLD}rulesets${RESET}"
|
|
545
|
+
while IFS=$'\t' read -r file name enforcement; do
|
|
546
|
+
printf ' %-34s %s%s · %s%s\n' "$name" "$DIM" "$enforcement" "$file" "$RESET"
|
|
547
|
+
done <<< "$RULESETS"
|
|
548
|
+
fi
|
|
549
|
+
info ""
|
|
550
|
+
|
|
551
|
+
if $DRY_RUN; then
|
|
552
|
+
if gh auth status >/dev/null 2>&1; then
|
|
553
|
+
if $PRUNE && enabled vars; then prune variable "$(printf '%s\n' "$VARS" | names_of)"; fi
|
|
554
|
+
if $PRUNE && enabled secrets && $SECRETS_KNOWN; then prune secret "$(printf '%s\n' "$SECRETS" | names_of)"; fi
|
|
555
|
+
[[ -z "$SETTINGS_FILE" ]] || apply_settings "$SETTINGS_FILE"
|
|
556
|
+
if [[ -n "$RULESETS" ]]; then step "Rulesets"; apply_rulesets "$RULESETS"; fi
|
|
557
|
+
fi
|
|
558
|
+
ok "Dry run complete — nothing was changed."
|
|
559
|
+
exit 0
|
|
560
|
+
fi
|
|
561
|
+
|
|
562
|
+
if enabled secrets; then
|
|
563
|
+
printf '%s\n' "$SECRETS" | grep . | check_values || true
|
|
564
|
+
for env in $ENVS; do
|
|
565
|
+
merge_layers 1 "$(secrets_src "$SHARED_SECRETS_DIR/secrets.$env.env")" \
|
|
566
|
+
"$(secrets_src "$REPO_SECRETS_DIR/secrets.$env.env")" | without_local_only | check_values
|
|
567
|
+
done
|
|
568
|
+
fi
|
|
569
|
+
|
|
570
|
+
if ! $ASSUME_YES; then
|
|
571
|
+
confirm "Push this config → $REPO?" || die "Aborted."
|
|
572
|
+
fi
|
|
573
|
+
|
|
574
|
+
# ---- execute -----------------------------------------------------------------
|
|
575
|
+
if enabled vars; then
|
|
576
|
+
printf '%s\n' "$VARS" | grep . | set_vars || true
|
|
577
|
+
if $PRUNE; then prune variable "$(printf '%s\n' "$VARS" | names_of)"; fi
|
|
578
|
+
fi
|
|
579
|
+
|
|
580
|
+
if enabled secrets; then
|
|
581
|
+
printf '%s\n' "$SECRETS" | grep . | set_secrets || true
|
|
582
|
+
if $PRUNE; then
|
|
583
|
+
if ! $SECRETS_KNOWN; then
|
|
584
|
+
warn "Remote mode: secrets are not pruned (the repo's own secrets files are unknown)."
|
|
585
|
+
else
|
|
586
|
+
prune secret "$(printf '%s\n' "$SECRETS" | names_of)"
|
|
587
|
+
fi
|
|
588
|
+
fi
|
|
589
|
+
|
|
590
|
+
for env in $ENVS; do
|
|
591
|
+
env_secrets="$(merge_layers 1 "$(secrets_src "$SHARED_SECRETS_DIR/secrets.$env.env")" \
|
|
592
|
+
"$(secrets_src "$REPO_SECRETS_DIR/secrets.$env.env")" | without_local_only)"
|
|
593
|
+
# Environments are per-repo; a job only sees these secrets when it declares
|
|
594
|
+
# `environment: <name>`. Created idempotently before syncing.
|
|
595
|
+
gh api -X PUT "repos/$REPO/environments/$env" >/dev/null
|
|
596
|
+
printf '%s\n' "$env_secrets" | grep . | set_secrets "$env" || true
|
|
597
|
+
if $PRUNE && $SECRETS_KNOWN; then prune secret "$(printf '%s\n' "$env_secrets" | names_of)" "$env"; fi
|
|
598
|
+
done
|
|
599
|
+
fi
|
|
600
|
+
|
|
601
|
+
[[ -z "$SETTINGS_FILE" ]] || apply_settings "$SETTINGS_FILE"
|
|
602
|
+
if [[ -n "$RULESETS" ]]; then step "Rulesets"; apply_rulesets "$RULESETS"; fi
|
|
603
|
+
|
|
604
|
+
# ---- verify project board (best effort) --------------------------------------
|
|
605
|
+
var_value() { # var_value NAME — merged variable value
|
|
606
|
+
local line; line="$(printf '%s\n' "$ALL_VARS" | awk -F'\t' -v k="$1" '$1 == k' | head -1)"
|
|
607
|
+
[[ -n "$line" ]] && env_get "$(cut -f2 <<< "$line")" "$1"
|
|
608
|
+
}
|
|
609
|
+
PROJECT_OWNER="$(var_value AUTO_PROJECT_OWNER || true)"
|
|
610
|
+
[[ -n "$PROJECT_OWNER" ]] || PROJECT_OWNER="$OWNER"
|
|
611
|
+
NUMBER="$(var_value AUTO_PROJECT_NUMBER || true)"
|
|
612
|
+
if [[ -n "$NUMBER" ]]; then
|
|
613
|
+
step "Checking Projects (v2) board"
|
|
614
|
+
if gh project view "$NUMBER" --owner "$PROJECT_OWNER" >/dev/null 2>&1; then
|
|
615
|
+
ok "Board reachable: $(project_url "$PROJECT_OWNER" "$NUMBER")"
|
|
616
|
+
else
|
|
617
|
+
warn "Could not verify board — grant scope with: ${BOLD}gh auth refresh -s project${RESET}"
|
|
618
|
+
fi
|
|
619
|
+
fi
|
|
620
|
+
|
|
621
|
+
info ""
|
|
622
|
+
ok "${BOLD}Bootstrap complete for $REPO.${RESET}"
|