@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.
Files changed (37) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +302 -2
  3. package/bin/_json.mjs +95 -0
  4. package/bin/_lib.sh +153 -0
  5. package/bin/cli.mjs +105 -0
  6. package/bin/gh-bootstrap.sh +622 -0
  7. package/bin/gh-reset.sh +148 -0
  8. package/bin/onboard.sh +340 -0
  9. package/bin/sync-managed.sh +81 -0
  10. package/config/actions.env +33 -0
  11. package/config/repo-settings.json +10 -0
  12. package/config/repos.env +25 -0
  13. package/config/rulesets/protect-dev.json +10 -0
  14. package/config/rulesets/protect-main.json +10 -0
  15. package/config/rulesets/require-pull-request.json +33 -0
  16. package/config/secrets.env.example +31 -0
  17. package/docs/ARCHITECTURE.md +477 -0
  18. package/docs/AUTO_WORK_ITEMS_WORKFLOW.md +305 -0
  19. package/docs/CI_FLOW.md +205 -0
  20. package/docs/CLI.md +229 -0
  21. package/docs/COMMIT_GUIDE.md +133 -0
  22. package/docs/CONFIGURATION.md +177 -0
  23. package/docs/GITHUB_ACTIONS_SETUP.md +315 -0
  24. package/docs/MIGRATION.md +175 -0
  25. package/docs/ONBOARDING.md +166 -0
  26. package/docs/REPO_RULES.md +116 -0
  27. package/managed/scripts/release/validate-conventional-subject.sh +32 -0
  28. package/package.json +66 -4
  29. package/renovate/tooling.json +45 -0
  30. package/templates/.github/actions.env +17 -0
  31. package/templates/.github/workflows/auto-pr.yml +33 -0
  32. package/templates/.github/workflows/pr-checks.yml +48 -0
  33. package/templates/.github/workflows/promote-dev-to-main.yml +26 -0
  34. package/templates/.github/workflows/release.yml +36 -0
  35. package/templates/.husky/commit-msg +18 -0
  36. package/templates/eslint.config.mjs +6 -0
  37. 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}"