@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,148 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # gh-reset.sh — delete GitHub Actions variables (and optionally secrets) on a repo.
4
+ #
5
+ # Lists what exists on the repo, shows it, and deletes it after a typed
6
+ # confirmation. Destructive and irreversible — use --dry-run first.
7
+ #
8
+ # Repository scope by default (organization-level config is never touched).
9
+ # By default only variables are removed; secret deletion must be requested
10
+ # explicitly. Settings and rulesets are not reset here — edit or delete them in
11
+ # the repo's Settings, or let `ebalzan-ci bootstrap --prune` remove managed
12
+ # rulesets no file defines.
13
+ #
14
+ # --env NAME switches to an ENVIRONMENT's config instead of the repository's.
15
+ # The two scopes are separate namespaces — a repo-scope run cannot see, list, or
16
+ # delete an environment's secrets, which is why clearing deploy credentials
17
+ # needs the flag:
18
+ #
19
+ # ebalzan-ci reset -R owner/repo --env production --secrets-only --dry-run
20
+ #
21
+ # Note that gh-bootstrap.sh strips a _FILE suffix when it sets a secret, so
22
+ # FOO_FILE in a dotenv file is named FOO on GitHub.
23
+ #
24
+ # Re-running the bootstrap after a reset restores the repo's variables and
25
+ # the merged shared + repo secrets. To remove only keys that no file defines any more, prefer
26
+ # `gh-bootstrap.sh --prune`, which works out the stale set for you.
27
+ #
28
+ # Usage:
29
+ # ebalzan-ci reset [options] # repo in the current directory
30
+ # ebalzan-ci reset -C ../my-repo [options]
31
+ # ebalzan-ci reset -R owner/repo [options] # no checkout needed
32
+ # bin/gh-reset.sh [options] # same, from a checkout of ebalzan/ci
33
+ #
34
+ # Options:
35
+ # -C, --repo-dir DIR Repo checkout whose remote picks the target (default: .)
36
+ # -R, --repo OWNER/REPO Target repo (default: detected from DIR's git remote)
37
+ # -e, --env NAME Target the environment NAME instead of the repository
38
+ # --include-secrets Also delete secrets (default: variables only)
39
+ # --secrets-only Delete secrets only, leave variables
40
+ # -m, --match REGEX Only delete names matching this extended regex
41
+ # -n, --dry-run List what would be deleted; delete nothing
42
+ # -y, --yes Skip the typed confirmation (dangerous)
43
+ # -h, --help Show this help
44
+ #
45
+ # Requirements: gh (GitHub CLI), authenticated with admin on the target repo.
46
+ set -euo pipefail
47
+
48
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
49
+ # shellcheck source=_lib.sh
50
+ source "$SCRIPT_DIR/_lib.sh"
51
+
52
+ # ---- args --------------------------------------------------------------------
53
+ REPO=""
54
+ REPO_DIR="."
55
+ ENVIRONMENT=""
56
+ DO_VARS=true
57
+ DO_SECRETS=false
58
+ MATCH=""
59
+ DRY_RUN=false
60
+ ASSUME_YES=false
61
+ while [[ $# -gt 0 ]]; do
62
+ case "$1" in
63
+ -C|--repo-dir) REPO_DIR="${2:-}"; shift 2 ;;
64
+ -R|--repo) REPO="${2:-}"; shift 2 ;;
65
+ -e|--env) ENVIRONMENT="${2:-}"; shift 2 ;;
66
+ --include-secrets) DO_SECRETS=true; shift ;;
67
+ --secrets-only) DO_SECRETS=true; DO_VARS=false; shift ;;
68
+ -m|--match) MATCH="${2:-}"; shift 2 ;;
69
+ -n|--dry-run) DRY_RUN=true; shift ;;
70
+ -y|--yes) ASSUME_YES=true; shift ;;
71
+ -h|--help) print_help "${BASH_SOURCE[0]}"; exit 0 ;;
72
+ *) die "Unknown option: $1 (try --help)" ;;
73
+ esac
74
+ done
75
+
76
+ # ---- preconditions & targets -------------------------------------------------
77
+ require_gh
78
+ require_auth
79
+ [[ -n "$REPO" ]] || REPO="$(detect_repo "$REPO_DIR")"
80
+ [[ -n "$REPO" ]] || die "Could not determine the repo. Pass --repo OWNER/REPO."
81
+
82
+ # Repo scope unless --env was given. Every gh call takes these flags, so the two
83
+ # scopes can never be mixed up halfway through a run.
84
+ GH_SCOPE=(--repo "$REPO")
85
+ if [[ -n "$ENVIRONMENT" ]]; then
86
+ gh api "repos/$REPO/environments/$ENVIRONMENT" >/dev/null 2>&1 \
87
+ || die "Environment '$ENVIRONMENT' does not exist on $REPO."
88
+ GH_SCOPE+=(--env "$ENVIRONMENT")
89
+ fi
90
+ SCOPE_LABEL="$REPO${ENVIRONMENT:+ · environment: $ENVIRONMENT}"
91
+
92
+ # List the names of a kind (variable|secret) in scope, filtered by --match.
93
+ list_names() { # list_names KIND
94
+ local names
95
+ names="$(gh "$1" list "${GH_SCOPE[@]}" --json name --jq '.[].name' 2>/dev/null || true)"
96
+ [[ -n "$MATCH" ]] && names="$(printf '%s\n' "$names" | grep -E "$MATCH" || true)"
97
+ printf '%s' "$names"
98
+ }
99
+
100
+ # ---- one kind batch ----------------------------------------------------------
101
+ reset_batch() { # reset_batch KIND (KIND = variable|secret)
102
+ local kind="$1" names count
103
+ names="$(list_names "$kind")"
104
+ count="$(printf '%s\n' "$names" | grep -c . || true)"
105
+
106
+ if [[ "$count" -eq 0 ]]; then
107
+ info " ${DIM}no ${kind}s${MATCH:+ matching /$MATCH/}${RESET}"
108
+ return
109
+ fi
110
+
111
+ step "${kind}s to delete from ${BOLD}$SCOPE_LABEL${RESET} ($count)"
112
+ printf ' - %s\n' $names
113
+
114
+ if $DRY_RUN; then return; fi
115
+
116
+ if ! $ASSUME_YES; then
117
+ confirm_typed "$REPO" \
118
+ "Type '${BOLD}$REPO${RESET}' to permanently delete these $count ${kind}(s) from ${BOLD}$SCOPE_LABEL${RESET}:" \
119
+ || { warn "Skipped ${kind}s (confirmation did not match)."; return; }
120
+ fi
121
+
122
+ local name
123
+ while IFS= read -r name; do
124
+ [[ -n "$name" ]] || continue
125
+ gh "$kind" delete "$name" "${GH_SCOPE[@]}" && ok "deleted $kind $name"
126
+ done <<< "$names"
127
+ }
128
+
129
+ # ---- execute -----------------------------------------------------------------
130
+ info ""
131
+ info "${BOLD}Reset GitHub Actions config${RESET} ${DIM}repo: $SCOPE_LABEL${MATCH:+ · match: /$MATCH/}${DRY_RUN:+ · dry-run}${RESET}"
132
+ info ""
133
+
134
+ # An environment holds secrets far more often than variables, and the default
135
+ # is variables-only — so say so rather than reporting an empty run as success.
136
+ if [[ -n "$ENVIRONMENT" ]] && ! $DO_SECRETS; then
137
+ warn "Only variables are in scope. Environment secrets need --secrets-only (or --include-secrets)."
138
+ fi
139
+
140
+ if $DO_VARS; then reset_batch variable; fi
141
+ if $DO_SECRETS; then reset_batch secret; fi
142
+
143
+ info ""
144
+ if $DRY_RUN; then
145
+ ok "Dry run complete — nothing was deleted."
146
+ else
147
+ ok "${BOLD}Reset complete.${RESET}"
148
+ fi
package/bin/onboard.sh ADDED
@@ -0,0 +1,340 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # onboard.sh — bring a repo under the shared rules in one step.
4
+ #
5
+ # In the repo's checkout it:
6
+ # 1. creates a branch (ci/onboard-ebalzan-ci) from dev — or from the default
7
+ # branch when dev does not exist yet;
8
+ # 2. writes the templates/ files: the four thin workflow callers, a starter
9
+ # .github/actions.env, renovate.json and, for a Node repo, the husky
10
+ # commit-msg hook. A file that already exists is kept (--force replaces it);
11
+ # 3. adds the .github/secrets*.env files to .gitignore when they are not
12
+ # ignored yet (before any secret file exists);
13
+ # 4. for a Node repo (package.json): installs this package and husky as
14
+ # devDependencies, sets `prepare: husky` when no prepare script exists,
15
+ # and wires the code-style rules of @ebalzan/config — package.json
16
+ # "prettier" and an eslint.config.mjs — where the repo has no Prettier or
17
+ # ESLint config of its own (--no-config skips this);
18
+ # 5. commits it all as `ci: onboard <ci repo>`.
19
+ # Then, unless --no-push:
20
+ # 6. creates the dev branch on GitHub if it is missing;
21
+ # 7. tags the repo with REPOS_TOPIC (config/repos.env), so `--all` finds it;
22
+ # 8. runs the bootstrap (variables, secrets, settings, rulesets);
23
+ # 9. pushes the branch. The auto-pr.yml it adds runs on that very push and
24
+ # opens the tracking issue and the PR into dev. Review and squash-merge it.
25
+ #
26
+ # Usage:
27
+ # ebalzan-ci onboard [options] # the repo in the current directory
28
+ # ebalzan-ci onboard -C ../my-repo [options]
29
+ #
30
+ # Options:
31
+ # -C, --repo-dir DIR Checkout of the repo to onboard (default: .)
32
+ # -R, --repo OWNER/REPO Its slug (default: from DIR's git remote)
33
+ # --runs-on LABEL Runner label for every job (default: ubuntu-latest)
34
+ # --ref REF Ref the callers pin (default: v<major of this package>)
35
+ # --branch NAME Branch to commit on (default: ci/onboard-ebalzan-ci)
36
+ # --force Overwrite files that already exist
37
+ # --no-npm Skip the npm installs even when package.json exists
38
+ # --no-config Do not wire @ebalzan/config (Prettier, ESLint)
39
+ # --no-push Stop after the local commit (no GitHub changes at all)
40
+ # -n, --dry-run Show what would happen; change nothing
41
+ # -y, --yes Skip the confirmations (also for the bootstrap)
42
+ # -h, --help Show this help
43
+ #
44
+ # Requirements: git; gh authenticated with admin on the repo (not needed with
45
+ # --no-push or --dry-run); npm for a Node repo.
46
+ set -euo pipefail
47
+
48
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
49
+ CI_ROOT="$(dirname "$SCRIPT_DIR")"
50
+ # shellcheck source=_lib.sh
51
+ source "$SCRIPT_DIR/_lib.sh"
52
+
53
+ TEMPLATES_DIR="$CI_ROOT/templates"
54
+ REPOS_FILE="$CI_ROOT/config/repos.env"
55
+ CI_REPO="$(unquote "$(env_get "$REPOS_FILE" CI_REPO)")"
56
+ CI_PACKAGE="$(node "$SCRIPT_DIR/_json.mjs" get "$CI_ROOT/package.json" name)"
57
+ CI_VERSION="$(node "$SCRIPT_DIR/_json.mjs" get "$CI_ROOT/package.json" version)"
58
+ TOPIC="$(unquote "$(env_get "$REPOS_FILE" REPOS_TOPIC)")"
59
+
60
+ # ---- args --------------------------------------------------------------------
61
+ REPO_DIR="."
62
+ REPO=""
63
+ RUNS_ON="ubuntu-latest"
64
+ REF="v${CI_VERSION%%.*}"
65
+ BRANCH="ci/onboard-ebalzan-ci"
66
+ FORCE=false
67
+ NPM=true
68
+ CONFIG=true
69
+ CONFIG_PACKAGE="@ebalzan/config"
70
+ PUSH=true
71
+ DRY_RUN=false
72
+ ASSUME_YES=false
73
+ while [[ $# -gt 0 ]]; do
74
+ case "$1" in
75
+ -C|--repo-dir) REPO_DIR="${2:-}"; shift 2 ;;
76
+ -R|--repo) REPO="${2:-}"; shift 2 ;;
77
+ --runs-on) RUNS_ON="${2:-}"; shift 2 ;;
78
+ --ref) REF="${2:-}"; shift 2 ;;
79
+ --branch) BRANCH="${2:-}"; shift 2 ;;
80
+ --force) FORCE=true; shift ;;
81
+ --no-npm) NPM=false; shift ;;
82
+ --no-config) CONFIG=false; shift ;;
83
+ --no-push) PUSH=false; shift ;;
84
+ -n|--dry-run) DRY_RUN=true; shift ;;
85
+ -y|--yes) ASSUME_YES=true; shift ;;
86
+ -h|--help) print_help "${BASH_SOURCE[0]}"; exit 0 ;;
87
+ *) die "Unknown option: $1 (try --help)" ;;
88
+ esac
89
+ done
90
+
91
+ [[ -n "$CI_REPO" ]] || die "CI_REPO is not set in $REPOS_FILE."
92
+ [[ -n "$RUNS_ON" && -n "$REF" && -n "$BRANCH" ]] || die "--runs-on, --ref and --branch need a value."
93
+
94
+ # ---- preconditions -----------------------------------------------------------
95
+ [[ -d "$REPO_DIR/.git" || -f "$REPO_DIR/.git" ]] || die "$REPO_DIR is not a git checkout."
96
+ REPO_DIR="$(cd "$REPO_DIR" && pwd)"
97
+ git_() { git -C "$REPO_DIR" "$@"; }
98
+
99
+ if $PUSH && ! $DRY_RUN; then
100
+ require_gh
101
+ require_auth
102
+ fi
103
+ if [[ -z "$REPO" ]] && command -v gh >/dev/null 2>&1; then REPO="$(detect_repo "$REPO_DIR")"; fi
104
+ if $PUSH && [[ -z "$REPO" ]]; then
105
+ die "Could not determine the repo for $REPO_DIR. Pass --repo OWNER/REPO (or --no-push)."
106
+ fi
107
+ if [[ -n "$(git_ status --porcelain)" ]] && ! $DRY_RUN; then
108
+ die "$REPO_DIR has uncommitted changes. Commit or stash them first."
109
+ fi
110
+ if git_ rev-parse --verify --quiet "refs/heads/$BRANCH" >/dev/null; then
111
+ die "Branch $BRANCH already exists in $REPO_DIR. Delete it or pass --branch."
112
+ fi
113
+
114
+ # Branch point: dev if it exists, else the default branch, else HEAD (no remote).
115
+ HAS_ORIGIN=false
116
+ git_ remote get-url origin >/dev/null 2>&1 && HAS_ORIGIN=true
117
+ if $HAS_ORIGIN && ! $DRY_RUN; then git_ fetch --quiet origin || warn "git fetch origin failed; using local refs."; fi
118
+ DEV_EXISTS=false
119
+ if $HAS_ORIGIN && git_ rev-parse --verify --quiet refs/remotes/origin/dev >/dev/null; then
120
+ DEV_EXISTS=true; BASE_REF="origin/dev"
121
+ elif $HAS_ORIGIN && git_ rev-parse --verify --quiet refs/remotes/origin/HEAD >/dev/null; then
122
+ BASE_REF="$(git_ symbolic-ref --short refs/remotes/origin/HEAD)"
123
+ else
124
+ BASE_REF="HEAD"
125
+ fi
126
+
127
+ IS_NODE=false
128
+ [[ -f "$REPO_DIR/package.json" ]] && IS_NODE=true
129
+ $IS_NODE || CONFIG=false
130
+
131
+ # present FILE... — the given repo-relative paths that exist, space-separated.
132
+ present() {
133
+ local f out=""
134
+ for f in "$@"; do [[ -e "$REPO_DIR/$f" ]] && out+="$f "; done
135
+ printf '%s' "${out% }"
136
+ }
137
+ PRETTIER_FILES=(.prettierrc .prettierrc.json .prettierrc.json5 .prettierrc.yaml .prettierrc.yml .prettierrc.toml
138
+ .prettierrc.js .prettierrc.cjs .prettierrc.mjs .prettierrc.ts prettier.config.js prettier.config.cjs
139
+ prettier.config.mjs prettier.config.ts)
140
+ ESLINT_FILES=(eslint.config.js eslint.config.cjs eslint.config.mjs eslint.config.ts eslint.config.mts
141
+ eslint.config.cts .eslintrc .eslintrc.js .eslintrc.cjs .eslintrc.json .eslintrc.yaml .eslintrc.yml)
142
+
143
+ # ---- plan --------------------------------------------------------------------
144
+ # applies REL — whether template REL belongs in this repo at all; when it does
145
+ # not, SKIP_REASON says why.
146
+ SKIP_REASON=""
147
+ applies() {
148
+ local f
149
+ case "$1" in
150
+ .husky/*)
151
+ $IS_NODE || { SKIP_REASON="no package.json"; return 1; } ;;
152
+ eslint.config.mjs)
153
+ $CONFIG || { SKIP_REASON="$($IS_NODE && echo '--no-config' || echo 'no package.json')"; return 1; }
154
+ # A repo with its own ESLint config extends the preset there; never add a second one.
155
+ f="$(present "${ESLINT_FILES[@]}")"
156
+ if [[ -n "$f" && "$f" != eslint.config.mjs ]]; then SKIP_REASON="repo has $f — extend $CONFIG_PACKAGE/eslint/node there"; return 1; fi ;;
157
+ renovate.json)
158
+ # Any existing Renovate config is the repo's own; extend the preset there.
159
+ for f in renovate.json renovate.json5 .renovaterc .renovaterc.json .github/renovate.json .github/renovate.json5; do
160
+ if [[ -e "$REPO_DIR/$f" ]] && ! $FORCE; then SKIP_REASON="repo has its own Renovate config"; return 1; fi
161
+ done ;;
162
+ esac
163
+ return 0
164
+ }
165
+
166
+ # Secrets files must be ignored before the first one exists.
167
+ GITIGNORE=false
168
+ if ! git_ check-ignore -q .github/secrets.env || ! git_ check-ignore -q .github/secrets.production.env; then
169
+ GITIGNORE=true
170
+ fi
171
+
172
+ # Prettier: point package.json at the shared config unless the repo has its own.
173
+ PRETTIER_SET=false
174
+ PRETTIER_OWN=""
175
+ if $CONFIG; then
176
+ PRETTIER_OWN="$(present "${PRETTIER_FILES[@]}")"
177
+ if [[ -z "$PRETTIER_OWN" ]] && [[ "$(cd "$REPO_DIR" && npm pkg get prettier)" == "{}" ]]; then
178
+ PRETTIER_SET=true
179
+ elif [[ -z "$PRETTIER_OWN" ]]; then
180
+ PRETTIER_OWN="package.json \"prettier\""
181
+ fi
182
+ fi
183
+ # TypeScript is reported, never edited: tsconfig files are JSON with comments.
184
+ TSCONFIG_NOTE=""
185
+ if $CONFIG && [[ -f "$REPO_DIR/tsconfig.json" ]] && ! grep -q "$CONFIG_PACKAGE/tsconfig/" "$REPO_DIR/tsconfig.json"; then
186
+ TSCONFIG_NOTE="tsconfig.json does not extend $CONFIG_PACKAGE/tsconfig/*.json — add \"extends\" by hand"
187
+ fi
188
+
189
+ PLAN=() # "action<TAB>rel" with action = create | overwrite | keep | skip
190
+ while IFS= read -r rel; do
191
+ if ! applies "$rel"; then PLAN+=("skip $rel $SKIP_REASON")
192
+ elif [[ -e "$REPO_DIR/$rel" ]]; then
193
+ if $FORCE; then PLAN+=("overwrite $rel"); else PLAN+=("keep $rel"); fi
194
+ else PLAN+=("create $rel")
195
+ fi
196
+ done < <(cd "$TEMPLATES_DIR" && find . -type f | sed 's#^\./##' | sort)
197
+
198
+ info ""
199
+ info "${BOLD}Onboard${RESET} ${DIM}${REPO:-$(basename "$REPO_DIR")} ← $CI_REPO@$REF (runs-on: $RUNS_ON)${RESET}"
200
+ info ""
201
+ info " ${BOLD}files${RESET} ${DIM}(branch $BRANCH from $BASE_REF)${RESET}"
202
+ for entry in "${PLAN[@]}"; do
203
+ IFS=' ' read -r action rel reason <<< "$entry"
204
+ case "$action" in
205
+ create|overwrite) printf ' %-10s %s\n' "$action" "$rel" ;;
206
+ keep) printf ' %s%-10s %s (exists — --force replaces it)%s\n' "$DIM" keep "$rel" "$RESET" ;;
207
+ skip) printf ' %s%-10s %s (%s)%s\n' "$DIM" skip "$rel" "$reason" "$RESET" ;;
208
+ esac
209
+ done
210
+ $GITIGNORE && printf ' %-10s %s\n' update ".gitignore (ignore .github/secrets*.env)"
211
+ $PRETTIER_SET && printf ' %-10s %s\n' update "package.json \"prettier\": \"$CONFIG_PACKAGE/prettier\""
212
+ [[ -z "$PRETTIER_OWN" ]] || info " ${DIM}keep $PRETTIER_OWN (own Prettier config — extend $CONFIG_PACKAGE/prettier there)${RESET}"
213
+ [[ -z "$TSCONFIG_NOTE" ]] || info " ${DIM}note $TSCONFIG_NOTE${RESET}"
214
+ if $IS_NODE && $NPM; then
215
+ info " ${BOLD}npm${RESET}"
216
+ info " install --save-dev $CI_PACKAGE@^${CI_VERSION%%.*}.0.0 (and husky, if missing)"
217
+ ! $CONFIG || info " install --save-dev --save-exact $CONFIG_PACKAGE (and eslint, prettier, if missing)"
218
+ fi
219
+ if $PUSH; then
220
+ info " ${BOLD}GitHub${RESET} ${DIM}($REPO)${RESET}"
221
+ $DEV_EXISTS || info " create branch dev from $BASE_REF"
222
+ [[ -z "$TOPIC" ]] || info " add topic '$TOPIC'"
223
+ info " bootstrap: variables, secrets, settings, rulesets"
224
+ info " push $BRANCH → auto-pr.yml opens the PR into dev"
225
+ else
226
+ info " ${DIM}--no-push: GitHub is left untouched${RESET}"
227
+ fi
228
+ info ""
229
+
230
+ if $DRY_RUN; then
231
+ ok "Dry run complete — nothing was changed."
232
+ exit 0
233
+ fi
234
+
235
+ # ---- local changes -----------------------------------------------------------
236
+ git_ switch --quiet --no-track -c "$BRANCH" "$BASE_REF"
237
+
238
+ render() { # render REL — template → repo, placeholders filled in
239
+ local src="$TEMPLATES_DIR/$1" dest="$REPO_DIR/$1"
240
+ mkdir -p "$(dirname "$dest")"
241
+ sed -e "s#__CI_REPO__#$CI_REPO#g" \
242
+ -e "s#__CI_REPO_NAME__#${CI_REPO#*/}#g" \
243
+ -e "s#__CI_REF__#$REF#g" \
244
+ -e "s#__CI_PACKAGE__#$CI_PACKAGE#g" \
245
+ -e "s#__RUNS_ON__#$RUNS_ON#g" "$src" > "$dest"
246
+ [[ ! -x "$src" && "$1" != .husky/* ]] || chmod +x "$dest"
247
+ }
248
+
249
+ for entry in "${PLAN[@]}"; do
250
+ IFS=' ' read -r action rel reason <<< "$entry"
251
+ case "$action" in create|overwrite) render "$rel"; ok "$action $rel" ;; esac
252
+ done
253
+
254
+ if $GITIGNORE; then
255
+ [[ ! -s "$REPO_DIR/.gitignore" ]] || [[ -z "$(tail -c1 "$REPO_DIR/.gitignore")" ]] || printf '\n' >> "$REPO_DIR/.gitignore"
256
+ cat >> "$REPO_DIR/.gitignore" <<'GITIGNORE'
257
+
258
+ # Actions secrets, synced by `ebalzan-ci bootstrap`. Never commit them.
259
+ /.github/secrets.env
260
+ /.github/secrets.*.env
261
+ !/.github/secrets*.env.example
262
+ GITIGNORE
263
+ ok "update .gitignore"
264
+ fi
265
+
266
+ if $PRETTIER_SET; then
267
+ (cd "$REPO_DIR" && npm pkg set "prettier=$CONFIG_PACKAGE/prettier") && ok "set package.json \"prettier\""
268
+ fi
269
+
270
+ if $IS_NODE && $NPM; then
271
+ step "Installing devDependencies"
272
+ (cd "$REPO_DIR" && npm install --save-dev --no-audit --no-fund "$CI_PACKAGE@^${CI_VERSION%%.*}.0.0") \
273
+ || warn "Could not install $CI_PACKAGE (is it published?). The hook skips the local check until it is installed."
274
+ if ! (cd "$REPO_DIR" && node -e 'const p=require("./package.json");process.exit((p.devDependencies||{}).husky||(p.dependencies||{}).husky?0:1)'); then
275
+ (cd "$REPO_DIR" && npm install --save-dev --no-audit --no-fund husky) || warn "Could not install husky."
276
+ fi
277
+ if [[ "$(cd "$REPO_DIR" && npm pkg get scripts.prepare)" == "{}" ]]; then
278
+ (cd "$REPO_DIR" && npm pkg set scripts.prepare=husky) && ok "set scripts.prepare = husky"
279
+ fi
280
+ if $CONFIG; then
281
+ # Its peers are pinned by the repo, so add the ones it does not list yet.
282
+ config_pkgs=("$CONFIG_PACKAGE")
283
+ for peer in eslint prettier; do
284
+ (cd "$REPO_DIR" && node -e 'const p=require("./package.json");process.exit({...p.dependencies,...p.devDependencies}[process.argv[1]]?0:1)' "$peer") \
285
+ || config_pkgs+=("$peer")
286
+ done
287
+ (cd "$REPO_DIR" && npm install --save-dev --save-exact --no-audit --no-fund "${config_pkgs[@]}") \
288
+ || warn "Could not install ${config_pkgs[*]}. Install them before running Prettier or ESLint."
289
+ fi
290
+ fi
291
+
292
+ git_ add -A
293
+ if git_ diff --cached --quiet; then
294
+ warn "Nothing to commit — every file already exists. Use --force to replace them."
295
+ git_ switch --quiet -
296
+ git_ branch --quiet -D "$BRANCH"
297
+ exit 0
298
+ fi
299
+ git_ commit --quiet -m "ci: onboard $CI_REPO shared workflows and rules"
300
+ ok "Committed on $BRANCH: $(git_ log -1 --pretty=%s)"
301
+
302
+ if ! $PUSH; then
303
+ info ""
304
+ ok "${BOLD}Local onboarding done.${RESET} Review with: git -C $REPO_DIR show --stat"
305
+ info "Then push it yourself, or re-run without --no-push after deleting the branch."
306
+ exit 0
307
+ fi
308
+
309
+ # ---- GitHub ------------------------------------------------------------------
310
+ if ! $ASSUME_YES; then
311
+ confirm "Apply the GitHub steps above to $REPO and push $BRANCH?" || die "Stopped. The local commit on $BRANCH is kept."
312
+ fi
313
+
314
+ if ! $DEV_EXISTS; then
315
+ default_branch="$(gh repo view "$REPO" --json defaultBranchRef -q .defaultBranchRef.name)"
316
+ sha="$(gh api "repos/$REPO/commits/$default_branch" -q .sha)"
317
+ gh api -X POST "repos/$REPO/git/refs" -f ref=refs/heads/dev -f sha="$sha" >/dev/null
318
+ ok "created branch dev from $default_branch (${sha:0:7})"
319
+ fi
320
+
321
+ if [[ -n "$TOPIC" ]]; then
322
+ gh repo edit "$REPO" --add-topic "$TOPIC" >/dev/null && ok "added topic '$TOPIC'"
323
+ fi
324
+
325
+ BOOTSTRAP_ARGS=(--repo-dir "$REPO_DIR" --repo "$REPO")
326
+ $ASSUME_YES && BOOTSTRAP_ARGS+=(--yes)
327
+ "$SCRIPT_DIR/gh-bootstrap.sh" "${BOOTSTRAP_ARGS[@]}" \
328
+ || warn "Bootstrap did not finish; fix the cause and re-run: ebalzan-ci bootstrap -C $REPO_DIR"
329
+
330
+ git_ push --quiet -u origin "$BRANCH"
331
+ ok "pushed $BRANCH"
332
+
333
+ info ""
334
+ ok "${BOLD}$REPO is onboarded.${RESET}"
335
+ info " 1. The PR into dev opens within a minute (auto-pr.yml). If it does not:"
336
+ info " https://github.com/$REPO/compare/dev...$BRANCH"
337
+ info " 2. Add the repo's own checks to .github/workflows/pr-checks.yml and its"
338
+ info " ship job to release.yml (both have a commented example)."
339
+ [[ -z "$TSCONFIG_NOTE" ]] || info " · $TSCONFIG_NOTE"
340
+ info " 3. Squash-merge the PR, then the dev → main PR: the first release."
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # sync-managed.sh — refresh repos' local copies of ebalzan/ci's managed/ files.
4
+ #
5
+ # DEPRECATED since 2.0. managed/ holds files a repo's local tooling runs —
6
+ # today only the Conventional Commits validator behind the husky commit-msg
7
+ # hook. Repos now run it from the npm package (`ebalzan-ci commit-msg`, see
8
+ # templates/.husky/commit-msg) and keep NO copy. This script remains for repos
9
+ # that still keep a verbatim copy at <repo>/<path>: it keeps those copies
10
+ # current until they move to the package. The PR-title check
11
+ # (actions/pr-title) fails on any copy that differs.
12
+ #
13
+ # Only copies that already exist are updated; --install creates missing ones.
14
+ #
15
+ # Usage:
16
+ # ebalzan-ci sync-managed [-C DIR | --all] [--check | --install]
17
+ #
18
+ # Options:
19
+ # -C, --repo-dir DIR Repo checkout to update (default: current directory)
20
+ # -a, --all Every repo config/repos.env selects that has a local
21
+ # checkout under $CI_REPOS_ROOT
22
+ # --check Report differing copies; change nothing (exit 1 on drift)
23
+ # --install Also create copies the repo does not have yet
24
+ # -h, --help Show this help
25
+ set -euo pipefail
26
+
27
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
28
+ CI_ROOT="$(dirname "$SCRIPT_DIR")"
29
+ # shellcheck source=_lib.sh
30
+ source "$SCRIPT_DIR/_lib.sh"
31
+ MANAGED_DIR="$CI_ROOT/managed"
32
+
33
+ REPO_DIRS=()
34
+ ALL=false
35
+ CHECK=false
36
+ INSTALL=false
37
+ while [[ $# -gt 0 ]]; do
38
+ case "$1" in
39
+ -C|--repo-dir) REPO_DIRS+=("${2:-}"); shift 2 ;;
40
+ -a|--all) ALL=true; shift ;;
41
+ --check) CHECK=true; shift ;;
42
+ --install) INSTALL=true; shift ;;
43
+ -h|--help) print_help "${BASH_SOURCE[0]}"; exit 0 ;;
44
+ *) die "Unknown option: $1 (try --help)" ;;
45
+ esac
46
+ done
47
+
48
+ if $ALL; then
49
+ require_gh
50
+ require_auth
51
+ for slug in $(managed_repos "$CI_ROOT/config/repos.env" "$CI_ROOT"); do
52
+ dir="$(local_checkout "$slug" "$CI_ROOT")"
53
+ if [[ -n "$dir" ]]; then REPO_DIRS+=("$dir"); else info "${DIM}$slug — no local checkout, skipped${RESET}"; fi
54
+ done
55
+ fi
56
+ [[ ${#REPO_DIRS[@]} -gt 0 ]] || REPO_DIRS=(".")
57
+
58
+ drift=0
59
+ for dir in "${REPO_DIRS[@]}"; do
60
+ [[ -d "$dir" ]] || { warn "No checkout at $dir — skipped."; drift=1; continue; }
61
+ step "$(basename "$(cd "$dir" && pwd)")"
62
+ while IFS= read -r rel; do
63
+ target="$dir/$rel"
64
+ if [[ ! -f "$target" ]] && ! $INSTALL; then
65
+ info " ${DIM}$rel — no local copy (the hook runs it from the package)${RESET}"; continue
66
+ fi
67
+ if [[ -f "$target" ]] && cmp -s "$MANAGED_DIR/$rel" "$target"; then
68
+ ok "$rel up to date"; continue
69
+ fi
70
+ if $CHECK; then
71
+ warn "$rel differs"; drift=1; continue
72
+ fi
73
+ mkdir -p "$(dirname "$target")"
74
+ cp "$MANAGED_DIR/$rel" "$target"
75
+ chmod +x "$target"
76
+ ok "$rel updated"
77
+ done < <(cd "$MANAGED_DIR" && find . -type f | sed 's#^\./##' | sort)
78
+ done
79
+
80
+ if $CHECK && [[ "$drift" -ne 0 ]]; then die "Managed files out of date — run sync-managed without --check."; fi
81
+ exit 0
@@ -0,0 +1,33 @@
1
+ # SHARED GitHub Actions VARIABLES — the house rules every repo calling the
2
+ # shared workflows gets. Non-sensitive; committed; published with the package.
3
+ #
4
+ # A repo adds or overrides any key in its own .github/actions.env; the repo's
5
+ # value wins. Keep here only what is genuinely the same everywhere — anything
6
+ # tied to one project (AUTO_PROJECT_NUMBER, AUTO_MILESTONE_TITLE, deploy
7
+ # settings) belongs to that repo.
8
+ #
9
+ # Read at run time, never copied onto repos: the actions load this file from the
10
+ # copy of ebalzan/ci the runner downloads for `uses: …@v2`
11
+ # (actions/auto-work-items/src/sharedVars.ts) and fill every key the repo leaves
12
+ # unset. A change here reaches the repos when it is released and the major tag
13
+ # moves to it — no bootstrap run.
14
+ #
15
+ # Only values that differ from the code defaults live here
16
+ # (actions/auto-work-items/src/config.ts, describe.ts, project.ts): priority
17
+ # comes from TYPE_CONFIG, size defaults to M, estimate to 1, dates to
18
+ # today → +7 days, strict metadata on, the board owner is the repo owner, and
19
+ # no board or milestone unless the repo sets one. The workflows pass the repo's
20
+ # variables whole (REPO_VARS_JSON), so setting a key here or in a repo is all it
21
+ # takes — no workflow edit.
22
+ #
23
+ # Full key reference: docs/CONFIGURATION.md.
24
+
25
+ # Claude model that writes the issue/PR descriptions (describe.ts). Kept here,
26
+ # not just as the code default, so it can be switched without a code change.
27
+ # AUTO_DESCRIBE_EFFORT: low | medium | high | xhigh | max — ignored for Haiku,
28
+ # which does not accept it.
29
+ AUTO_DESCRIBE_MODEL=claude-sonnet-5-5
30
+ AUTO_DESCRIBE_EFFORT=low
31
+
32
+ AUTO_SIZE_MAP_JSON={"feat":"M","fix":"S","docs":"S","style":"S","refact":"M","perf":"M","test":"S","build":"S","ci":"S","chore":"S","revert":"S"}
33
+ AUTO_ESTIMATE_MAP_JSON={"feat":"3","fix":"2","docs":"1","style":"1","refact":"2","perf":"2","test":"1","build":"1","ci":"1","chore":"1","revert":"1"}
@@ -0,0 +1,10 @@
1
+ {
2
+ "$comment": "Repository settings every managed repo gets (PATCH /repos/{owner}/{repo}). A repo overrides any key in its own .github/repo-settings.json. Keys starting with $ are documentation and are never sent. Reference: docs/REPO_RULES.md.",
3
+ "allow_squash_merge": true,
4
+ "allow_merge_commit": false,
5
+ "allow_rebase_merge": true,
6
+ "squash_merge_commit_title": "PR_TITLE",
7
+ "squash_merge_commit_message": "COMMIT_MESSAGES",
8
+ "delete_branch_on_merge": true,
9
+ "allow_update_branch": true
10
+ }
@@ -0,0 +1,25 @@
1
+ # Which repos `--all` acts on (bin/gh-bootstrap.sh, bin/sync-managed.sh).
2
+ # The union of both lists below is used; a repo listed twice is synced once.
3
+ #
4
+ # Discovery by topic (recommended): every repo of REPOS_OWNER tagged with
5
+ # REPOS_TOPIC. `ebalzan-ci onboard` adds the topic, so onboarding a repo needs
6
+ # no edit here. Leave REPOS_TOPIC empty to turn discovery off.
7
+ REPOS_TOPIC=ebalzan-ci
8
+ # Account whose repos are searched for the topic. Empty = the account gh is
9
+ # logged in as (`gh api user`).
10
+ REPOS_OWNER=
11
+
12
+ # Explicit extra repos, as OWNER/REPO slugs separated by spaces — e.g. repos of
13
+ # another account or organization you administer.
14
+ REPOS=
15
+
16
+ # Where `--all` looks for local checkouts: <CI_REPOS_ROOT>/<repo name>. A repo
17
+ # with a local checkout gets its gitignored .github/secrets*.env synced too; one
18
+ # without is synced from its default branch through the API (variables,
19
+ # settings, rulesets, shared secrets — never its own secrets, which only exist
20
+ # locally). Default: the directory holding this checkout. Override with the
21
+ # CI_REPOS_ROOT environment variable.
22
+
23
+ # Where this repo lives — the slug every caller's `uses:` names, and what
24
+ # `ebalzan-ci onboard` writes into the templates. Change it only if you fork.
25
+ CI_REPO=ebalzan/ci
@@ -0,0 +1,10 @@
1
+ {
2
+ "$comment": "dev is the integration branch: it cannot be deleted. Force-pushes stay allowed because release.yml's sync-dev job force-points dev at each release commit. Reference: docs/REPO_RULES.md.",
3
+ "name": "ci: protect dev",
4
+ "target": "branch",
5
+ "enforcement": "active",
6
+ "conditions": {
7
+ "ref_name": { "include": ["refs/heads/dev"], "exclude": [] }
8
+ },
9
+ "rules": [{ "type": "deletion" }]
10
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "$comment": "main is the release branch: it cannot be deleted or force-pushed. The release job only fast-forwards it, so nothing in the pipeline needs a bypass. Reference: docs/REPO_RULES.md.",
3
+ "name": "ci: protect main",
4
+ "target": "branch",
5
+ "enforcement": "active",
6
+ "conditions": {
7
+ "ref_name": { "include": ["refs/heads/main"], "exclude": [] }
8
+ },
9
+ "rules": [{ "type": "deletion" }, { "type": "non_fast_forward" }]
10
+ }