@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,33 @@
1
+ {
2
+ "$comment": "Changes reach main and dev only through a squash-merged PR whose 'title / Validate PR Title' check passed. DISABLED by default: release.yml pushes the release commit to main and force-points dev with GITHUB_TOKEN, so the GitHub Actions app (Integration 15368) must be able to bypass it. Enable it per repo with .github/rulesets/require-pull-request.json containing {\"enforcement\": \"active\"}, run one release, and confirm it went through. Reference: docs/REPO_RULES.md.",
3
+ "name": "ci: require pull request",
4
+ "target": "branch",
5
+ "enforcement": "disabled",
6
+ "conditions": {
7
+ "ref_name": { "include": ["refs/heads/main", "refs/heads/dev"], "exclude": [] }
8
+ },
9
+ "bypass_actors": [
10
+ { "actor_id": 5, "actor_type": "RepositoryRole", "bypass_mode": "always" },
11
+ { "actor_id": 15368, "actor_type": "Integration", "bypass_mode": "always" }
12
+ ],
13
+ "rules": [
14
+ {
15
+ "type": "pull_request",
16
+ "parameters": {
17
+ "required_approving_review_count": 0,
18
+ "dismiss_stale_reviews_on_push": false,
19
+ "require_code_owner_review": false,
20
+ "require_last_push_approval": false,
21
+ "required_review_thread_resolution": false,
22
+ "allowed_merge_methods": ["squash"]
23
+ }
24
+ },
25
+ {
26
+ "type": "required_status_checks",
27
+ "parameters": {
28
+ "strict_required_status_checks_policy": false,
29
+ "required_status_checks": [{ "context": "title / Validate PR Title" }]
30
+ }
31
+ }
32
+ ]
33
+ }
@@ -0,0 +1,31 @@
1
+ # SHARED repository SECRETS — every repo in repos.env gets these.
2
+ #
3
+ # 1. Copy: cp config/secrets.env.example config/secrets.env
4
+ # 2. Fill in the real values (the copy is gitignored).
5
+ # 3. Sync: bin/gh-bootstrap.sh --all
6
+ #
7
+ # A repo adds or overrides any key in its own .github/secrets.env (the repo's
8
+ # value wins). Shared secrets for a GitHub ENVIRONMENT would go in
9
+ # config/secrets.<environment>.env, overridable by the repo's
10
+ # .github/secrets.<environment>.env — none exist today: every deploy secret is
11
+ # specific to one repo's deploy target.
12
+ #
13
+ # GITHUB_TOKEN is provided automatically by Actions — never set it here.
14
+
15
+ # Anthropic API key used by auto-work-items.yml to describe issues/PRs.
16
+ # https://console.anthropic.com/settings/keys
17
+ ANTHROPIC_API_KEY=sk-ant-xxxxxxxxxxxxxxxxxxxxxxxx
18
+
19
+ # CLASSIC PAT used by auto-work-items.yml and promote-dev-to-main.yml to write to
20
+ # your user Projects (v2) boards, and so the PRs they open trigger PR Checks.
21
+ # Scopes: `project` (read/write on Projects v2) and `repo` (create issues/PRs on
22
+ # private repos).
23
+ #
24
+ # It must be classic, not fine-grained: the boards are owned by your personal
25
+ # account, and a fine-grained PAT only offers a Projects permission under
26
+ # ORGANIZATION permissions — there is no account-level equivalent, so it cannot
27
+ # reach a user-owned board at all. The cost is that `repo` cannot be narrowed to
28
+ # selected repositories the way a fine-grained token can, so keep the expiry
29
+ # short. One token serves every repo.
30
+ # https://github.com/settings/tokens
31
+ GH_PROJECT_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxx
@@ -0,0 +1,477 @@
1
+ # Architecture: what lives in ebalzan/ci, what lives in each repo
2
+
3
+ This is the map. It answers three questions: which parts are the same in every
4
+ repo, which parts each repo writes itself (tests, deploys), and where each
5
+ piece of code runs. Read it once before onboarding your second repo.
6
+
7
+ - [1. The model in one picture](#1-the-model-in-one-picture)
8
+ - [2. The four stages: shared or yours?](#2-the-four-stages-shared-or-yours)
9
+ - [3. What actually runs, stage by stage](#3-what-actually-runs-stage-by-stage)
10
+ - [4. Deploys are yours](#4-deploys-are-yours)
11
+ - [5. The files a repo needs](#5-the-files-a-repo-needs)
12
+ - [6. package.json](#6-packagejson)
13
+ - [7. Where you run commands](#7-where-you-run-commands)
14
+ - [8. Design decisions](#8-design-decisions)
15
+ - [9. Versions: what a pin freezes](#9-versions-what-a-pin-freezes)
16
+ - [10. The two packages: @ebalzan/ci and @ebalzan/config](#10-the-two-packages-ebalzanci-and-ebalzanconfig)
17
+
18
+ ---
19
+
20
+ ## 1. The model in one picture
21
+
22
+ Everything reaches a repo in one of three ways:
23
+
24
+ ```text
25
+ ebalzan/ci a repo (my-api)
26
+ ┌──────────────────────────────────────────┐ ┌──────────────────────────────────────┐
27
+ │ PULLED at run time — automatic │ uses: │ 4 caller files (identical shape) │
28
+ │ reusable workflows, actions, scripts, │◀─────────│ auto-pr.yml promote-dev-to-main │
29
+ │ commit validator, config/actions.env, │ @v2 │ pr-checks.yml release.yml │
30
+ │ renovate preset │ │ │
31
+ ├──────────────────────────────────────────┤ │ YOURS — the repo writes them │
32
+ │ PUSHED by a command — `bootstrap` │─────────▶│ build/test jobs, deploy, its vars │
33
+ │ shared secrets, repo-settings.json, │ gh api │ and secrets, any extra workflow │
34
+ │ rulesets/ (+ the repo's own config) │ │ │
35
+ └──────────────────────────────────────────┘ └──────────────────────────────────────┘
36
+ ```
37
+
38
+ - **Pulled.** A repo uses ebalzan/ci by naming it in `uses:` lines. Every run
39
+ downloads the code from the `v2` tag, so a release of ebalzan/ci reaches every
40
+ repo on its next run. Nothing has to know which repos exist.
41
+ - **Pushed.** Some things cannot be pulled: Actions variables and secrets,
42
+ repository settings and rulesets live on GitHub, per repo. `ebalzan-ci
43
+ bootstrap` writes them, and that is the only reason ebalzan/ci keeps a list
44
+ of repos (§7).
45
+ - **Yours.** What differs between repos: how to build and test it, how to ship
46
+ it, and the values those jobs need. These live in the repo, next to the code
47
+ they act on.
48
+
49
+ A consequence: **ebalzan/ci cannot make a repo use it.** GitHub only runs
50
+ workflow files that are in the repo, so each repo needs its four small caller
51
+ files. `onboard` writes them once. After that, they almost never change: a new
52
+ caller is only needed when ebalzan/ci releases a new major version.
53
+
54
+ ---
55
+
56
+ ## 2. The four stages: shared or yours?
57
+
58
+ Each stage has a **caller** in the repo (the trigger, the permissions, a
59
+ `uses:` line) and an **implementation** in ebalzan/ci. Two of the four callers
60
+ are identical in every repo. The other two are half shared, half yours.
61
+
62
+ | Stage | Caller in the repo | Implementation in ebalzan/ci | Identical in every repo? |
63
+ | ---------- | ------------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------ |
64
+ | Work items | `.github/workflows/auto-pr.yml` | `.github/workflows/auto-work-items.yml` | **Yes.** Only `runs-on` may differ. |
65
+ | PR checks | `.github/workflows/pr-checks.yml` | `.github/workflows/pr-title.yml` (job `title`) | **The `title` job, yes.** Build, lint and test jobs are **yours**. |
66
+ | Promotion | `.github/workflows/promote-dev-to-main.yml` | `.github/workflows/promote-dev-to-main.yml` | **Yes.** Only `runs-on` may differ. |
67
+ | Release | `.github/workflows/release.yml` | `.github/workflows/release.yml` (job `release`) | **The `release` job, yes.** Deploy jobs are **yours**. |
68
+
69
+ `auto-pr.yml` and `auto-work-items.yml` are the same feature: the first is the
70
+ repo's trigger, the second is the shared logic it calls. The same is true of
71
+ the two files named `promote-dev-to-main.yml` and the two named `release.yml`.
72
+
73
+ Why PR checks and deploys stay in the repo: a Node API with a database needs
74
+ a generated client and placeholder env before its tests run; a frontend builds
75
+ with a framework CLI; a Go service runs `go test`; one repo ships a container
76
+ to a VM, another to Vercel, another to npm. Putting all of that behind one
77
+ shared workflow would turn it into a pile of `if:`s and inputs that every repo
78
+ pays for. The shared part is what is **really** the same: the title rule, the
79
+ versioning, the promotion and the release. [§8](#8-design-decisions) covers
80
+ when to move something into ebalzan/ci.
81
+
82
+ ---
83
+
84
+ ## 3. What actually runs, stage by stage
85
+
86
+ How a `uses:` line becomes code on the runner:
87
+
88
+ - `uses: ebalzan/ci/.github/workflows/X.yml@v2` (in a job) loads the reusable
89
+ workflow file at `v2`. Its jobs run **in the caller repo's context**:
90
+ `github.repository`, `vars` and the token are the caller's.
91
+ - Inside it, `uses: ebalzan/ci/actions/Y@v2` (in a step) makes the runner
92
+ download **the whole ebalzan/ci repo at `v2`** into its actions cache. That
93
+ is why the actions can run `scripts/`, `managed/` and read
94
+ `config/actions.env` from `${{ github.action_path }}/../..`.
95
+ - The caller repo is only checked out where a stage needs its files.
96
+
97
+ ### Work items: `auto-pr.yml` → `auto-work-items.yml`
98
+
99
+ ```text
100
+ push to feat/x
101
+ └─ auto-pr.yml (repo) trigger, permissions, secrets
102
+ └─ auto-work-items.yml (ci) job env: AUTO_BASE_BRANCH, REPO_VARS_JSON = toJSON(vars)
103
+ └─ actions/auto-work-items (ci) composite: actions/github-script runs dist/index.js
104
+ └─ src/index.ts orchestrates, all through the GitHub API (no checkout)
105
+ ├─ sharedVars.ts repo vars (AUTO_*) + config/actions.env → env
106
+ ├─ config.ts branch type → title, labels, priority, size, estimate
107
+ ├─ labels.ts milestone.ts create/sync labels; resolve milestone
108
+ ├─ describe.ts branch commits + diff → Claude → issue/PR body
109
+ ├─ issue.ts pr.ts assignee.ts upsert issue, PR into dev, assignee
110
+ └─ project.ts board card + fields (skipped without AUTO_PROJECT_NUMBER)
111
+ ```
112
+
113
+ ### PR checks: `pr-checks.yml` → `pr-title.yml` + your jobs
114
+
115
+ ```text
116
+ PR into dev or main
117
+ └─ pr-checks.yml (repo)
118
+ ├─ job title → pr-title.yml (ci)
119
+ │ ├─ sparse checkout of the repo (only to compare old validator copies)
120
+ │ └─ actions/pr-title (ci)
121
+ │ ├─ managed/scripts/release/validate-conventional-subject.sh title is a Conventional Commit
122
+ │ ├─ scripts/release/verify-pr-release-type.mjs title type matches branch type
123
+ │ └─ drift check of local copies of managed/ files (deprecated copies only)
124
+ └─ your jobs (repo) — checkout, install, lint, typecheck, test, build, image build…
125
+ ```
126
+
127
+ ### Promotion: `promote-dev-to-main.yml` → `promote-dev-to-main.yml`
128
+
129
+ ```text
130
+ push to dev
131
+ └─ promote-dev-to-main.yml (repo)
132
+ └─ promote-dev-to-main.yml (ci) checkout of the repo (for package.json), Node 24
133
+ └─ actions/promote (ci)
134
+ └─ scripts/release/promote-dev-to-main.mjs
135
+ ├─ compare main...dev, compute the bump (release-utils.mjs)
136
+ ├─ open/update the single dev → main PR, title "feat: promote dev to main (vX.Y.Z)"
137
+ └─ labels, assignee, milestone, board (best effort)
138
+ ```
139
+
140
+ ### Release: `release.yml` → `release.yml` + your deploy
141
+
142
+ ```text
143
+ push to main
144
+ └─ release.yml (repo)
145
+ ├─ job release → release.yml (ci)
146
+ │ ├─ job release: full-history checkout, Node 24, git identity
147
+ │ │ └─ actions/release (ci) → scripts/release/release-main.mjs
148
+ │ │ ├─ next version from the commits since the last vX.Y.Z tag
149
+ │ │ ├─ package.json? bump + "chore(release): X.Y.Z" commit : tag only
150
+ │ │ ├─ tag vX.Y.Z, push, GitHub Release
151
+ │ │ └─ outputs: tag, sha
152
+ │ └─ job sync-dev: force-point dev at the release commit (inline shell)
153
+ └─ your deploy jobs (repo), on needs.release.outputs.tag
154
+ ```
155
+
156
+ ---
157
+
158
+ ## 4. Deploys are yours
159
+
160
+ The release stage ends with two outputs, and that is the whole contract:
161
+
162
+ | Output | Value |
163
+ | ------ | --------------------------------------------------------------------------------------------------- |
164
+ | `tag` | `vX.Y.Z` just released, or **empty** when nothing was released (e.g. the release commit's own run). |
165
+ | `sha` | The commit the tag points at. |
166
+
167
+ Everything after it — building artifacts, pushing images, rolling out, smoke
168
+ tests — is the repo's code.
169
+
170
+ ### Recommended shape
171
+
172
+ ```text
173
+ .github/workflows/
174
+ release.yml shared release job + `deploy: uses: ./.github/workflows/deploy.yml`
175
+ deploy.yml on: workflow_call — THE deploy logic, inputs: tag (+ sha, publish flag)
176
+ redeploy.yml on: workflow_dispatch — calls deploy.yml with an existing tag (rollback / retry)
177
+ scripts/
178
+ deploy-*.sh anything that runs on a host ships with the code it deploys
179
+ .github/
180
+ secrets.production.env(.example) deploy credentials, environment-scoped (bootstrap syncs them)
181
+ ```
182
+
183
+ ```yaml
184
+ # release.yml (repo)
185
+ permissions:
186
+ contents: write # the shared release job
187
+ pull-requests: read # the shared release job
188
+ packages: write # your deploy: push an image
189
+ id-token: write # your deploy: cloud OIDC login
190
+ jobs:
191
+ release:
192
+ uses: ebalzan/ci/.github/workflows/release.yml@v2
193
+ deploy:
194
+ needs: release
195
+ if: ${{ needs.release.outputs.tag != '' }}
196
+ uses: ./.github/workflows/deploy.yml
197
+ with:
198
+ tag: ${{ needs.release.outputs.tag }}
199
+ sha: ${{ needs.release.outputs.sha }}
200
+ secrets: inherit # fine here: same repo
201
+ ```
202
+
203
+ ```yaml
204
+ # deploy.yml (repo)
205
+ on:
206
+ workflow_call:
207
+ inputs:
208
+ tag: { type: string, required: true }
209
+ sha: { type: string, required: false }
210
+ concurrency:
211
+ group: deploy-production
212
+ cancel-in-progress: false # never stop a rollout halfway
213
+ jobs:
214
+ deploy:
215
+ environment: production # unlocks the environment's secrets, records the deployment
216
+ runs-on: ubuntu-latest
217
+ steps:
218
+ - uses: actions/checkout@v4
219
+ with:
220
+ ref: ${{ inputs.sha || inputs.tag }}
221
+ - run: ./scripts/deploy.sh "${{ inputs.tag }}"
222
+ ```
223
+
224
+ Why split `deploy.yml` out of `release.yml`: it lets `redeploy.yml` roll back
225
+ to any earlier tag, or retry a failed rollout, without cutting a new release.
226
+
227
+ Points to get right:
228
+
229
+ - **Permissions add up.** A reusable workflow can only narrow what its caller
230
+ grants. The caller's `permissions:` must cover both the shared release job
231
+ (`contents: write`, `pull-requests: read`) and your deploy.
232
+ - **Environment secrets** resolve only in a job that declares `environment:`.
233
+ Put deploy credentials in `.github/secrets.production.env`; `bootstrap`
234
+ creates the environment and fills it.
235
+ - **Typical deploys**: a container image to a registry, then a VM (SSM or SSH)
236
+ or Kubernetes; a static site or Vercel/Netlify deploy of the tag; an npm
237
+ publish (this repo's own `self-release.yml` has one); a mobile build
238
+ submitted to a store.
239
+
240
+ Two other kinds of repo-specific job follow the same pattern: **PR previews**
241
+ (a Vercel preview, an image build without push) go in `pr-checks.yml`, and
242
+ **scheduled jobs** (nightly tests, backups) get their own workflow file.
243
+
244
+ ---
245
+
246
+ ## 5. The files a repo needs
247
+
248
+ | File | Needed when | Same in every repo? | Written by |
249
+ | ----------------------------------------------------------------- | ------------------------------------------------------------ | ------------------------------ | ----------------------------------------------------------- |
250
+ | `.github/workflows/auto-pr.yml` | always | yes (`runs-on` aside) | `onboard` |
251
+ | `.github/workflows/promote-dev-to-main.yml` | always | yes (`runs-on` aside) | `onboard` |
252
+ | `.github/workflows/pr-checks.yml` | always | `title` job yes; other jobs no | `onboard`, then you |
253
+ | `.github/workflows/release.yml` | always | `release` job yes; deploy no | `onboard`, then you |
254
+ | `.github/workflows/deploy.yml`, `redeploy.yml` | the repo deploys | no | you |
255
+ | branches `main` and `dev` | always | — | `onboard` creates `dev` |
256
+ | `.github/actions.env` | it has its own variables (board, milestone, deploy settings) | no | `onboard` (empty starter), you |
257
+ | repo secrets: `secrets.env`, `secrets.<env>.env` | it has its own secrets (deploy credentials, …) | no | you — **never committed** ([§7](#7-where-you-run-commands)) |
258
+ | `.gitignore`: `/.github/secrets*.env` | always | yes | `onboard` |
259
+ | `.github/bootstrap.conf` | some secrets only local scripts read | no | you |
260
+ | `.github/repo-settings.json` | it deviates from the shared settings | no | you |
261
+ | `.github/rulesets/*.json` | it deviates from / adds to the shared rulesets | no | you |
262
+ | `.husky/commit-msg` | Node repo | yes | `onboard` |
263
+ | `package.json` `"prettier": "@ebalzan/config/prettier"` | Node repo | yes | `onboard`, unless the repo has its own Prettier config |
264
+ | `eslint.config.mjs` spreading `@ebalzan/config/eslint/node` | Node repo | the base yes; repo blocks no | `onboard`, unless the repo has its own ESLint config |
265
+ | `tsconfig.json` `"extends": "@ebalzan/config/tsconfig/node.json"` | TypeScript repo | the base yes; paths no | you (`onboard` only reports it) |
266
+ | `renovate.json` | you use Renovate | yes | `onboard` |
267
+
268
+ A brand-new Node repo that only needs the flow has the four workflow files,
269
+ the hook, the Prettier/ESLint wiring, `renovate.json`, an empty `actions.env`
270
+ and the `.gitignore` lines, and nothing else. A non-Node repo has the four
271
+ workflow files, `renovate.json`, `actions.env` and `.gitignore`.
272
+
273
+ The secrets files are ignored before any exists: `onboard` appends this to
274
+ `.gitignore` unless the repo already ignores them:
275
+
276
+ ```gitignore
277
+ /.github/secrets.env
278
+ /.github/secrets.*.env
279
+ !/.github/secrets*.env.example
280
+ ```
281
+
282
+ ---
283
+
284
+ ## 6. package.json
285
+
286
+ The pipeline itself needs **nothing** from `package.json`. A repo without one
287
+ is released tag-only. With one:
288
+
289
+ | Field / script | Why |
290
+ | ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
291
+ | `"version": "X.Y.Z"` | The release bumps it and commits it (the tag is the source of truth; this mirrors it). |
292
+ | devDependency `@ebalzan/ci` | The `commit-msg` hook runs `ebalzan-ci commit-msg` from it: instant, offline. |
293
+ | devDependency `husky`, `"prepare": "husky"` | Installs the hook on `npm install`. |
294
+ | devDependencies `@ebalzan/config`, `eslint`, `prettier` (exact versions) | The shared code-style rules ([§10](#10-the-two-packages-ebalzanci-and-ebalzanconfig)); `eslint`, `prettier` and `typescript` are its peers, pinned by the repo. |
295
+ | `"prettier": "@ebalzan/config/prettier"` | Prettier reads the shared options; no `.prettierrc` in the repo. |
296
+ | your quality scripts | Whatever your `pr-checks.yml` jobs call (below). |
297
+
298
+ **Admin scripts are not needed.** `gh:bootstrap`/`gh:reset` scripts in each
299
+ repo were the v1 way to reach the tools. In v2 you run them from one place
300
+ ([§7](#7-where-you-run-commands)), or with `npx @ebalzan/ci …` from anywhere.
301
+
302
+ **Quality scripts: a convention, not a requirement.** CI is easiest to keep
303
+ honest when one script runs every check, and both you and CI call it:
304
+
305
+ ```json
306
+ "scripts": {
307
+ "format": "prettier --write .",
308
+ "format:check": "prettier --check .",
309
+ "lint": "eslint --max-warnings 0 .",
310
+ "typecheck": "tsc --noEmit",
311
+ "test": "node --test",
312
+ "build": "tsc -p tsconfig.build.json",
313
+ "validate": "npm run format:check && npm run typecheck && npm run lint && npm test"
314
+ }
315
+ ```
316
+
317
+ Then the repo's job in `pr-checks.yml` is `npm ci`, `npx --no-install
318
+ ebalzan-config verify` (the repo extends the shared rules rather than copying
319
+ them) and `npm run validate` — the commented `validate` job in the template.
320
+ "It passed locally" then means it passes in CI.
321
+
322
+ **Gotcha:** `preversion`, `version` and `postversion` scripts run during the
323
+ release, because the release calls `npm version`. Leave them out unless you
324
+ want them to run on the CI runner at every release.
325
+
326
+ ---
327
+
328
+ ## 7. Where you run commands
329
+
330
+ **Developers of a repo run nothing CI-related.** They branch, commit (the hook
331
+ checks the message) and push. Everything else happens in Actions.
332
+
333
+ **You, as the admin, run everything from one place**: a checkout of ebalzan/ci
334
+ (or `npx @ebalzan/ci` anywhere):
335
+
336
+ ```bash
337
+ cd ~/Developer/ci
338
+ npm run gh:bootstrap -- --all --dry-run # every repo: plan
339
+ npm run gh:bootstrap -- --all # every repo: apply
340
+ npm run gh:bootstrap -- -R owner/my-api # one repo, no checkout needed
341
+ bin/onboard.sh -C ../my-new-repo # onboard a repo
342
+ ```
343
+
344
+ What `--all` touches, and where each piece comes from:
345
+
346
+ | Piece | Comes from |
347
+ | -------------------------------------- | ----------------------------------------------------------------------------------------------------- |
348
+ | which repos | GitHub topic `ebalzan-ci` (added by `onboard`), plus slugs in `config/repos.env` → `REPOS` |
349
+ | shared variables, settings, rulesets | ebalzan/ci `config/` |
350
+ | shared secrets | `~/.config/ebalzan-ci/secrets.env` (or `config/secrets.env` in your checkout) |
351
+ | a repo's variables, settings, rulesets | its committed `.github/` — from a local checkout under `CI_REPOS_ROOT`, else via the API |
352
+ | a repo's own secrets | `~/.config/ebalzan-ci/repos/<owner>/<repo>/secrets*.env` — else its checkout's `.github/secrets*.env` |
353
+
354
+ **Keep every repo's secrets in the one folder** (recommended):
355
+
356
+ ```text
357
+ ~/.config/ebalzan-ci/ (or $CI_SECRETS_DIR)
358
+ secrets.env shared: every repo gets these
359
+ repos/
360
+ owner/my-api/
361
+ secrets.env my-api's repository secrets
362
+ secrets.production.env my-api's `production` environment secrets
363
+ owner/my-web/
364
+ secrets.preview.env
365
+ ```
366
+
367
+ With it, `--all` syncs every repo's secrets with **no checkouts at all**,
368
+ there is one folder to back up (or keep in an encrypted store), and nothing
369
+ secret sits inside a git working tree. When a repo has a folder there, its
370
+ checkout's `secrets*.env` files are ignored (the bootstrap warns if both
371
+ exist). A repo without one falls back to its checkout's gitignored
372
+ `.github/secrets*.env`; with no checkout either, only the shared secrets are
373
+ synced and the bootstrap says so. `LOCAL_ONLY_KEYS` still come from the repo's
374
+ committed `.github/bootstrap.conf`.
375
+
376
+ ---
377
+
378
+ ## 8. Design decisions
379
+
380
+ ### Why each repo's config lives in that repo, not in ebalzan/ci
381
+
382
+ It could be centralized (`ebalzan/ci/repos/<name>/actions.env`, with every
383
+ command run from here). It is not, because:
384
+
385
+ 1. **ebalzan/ci is public and on npm.** Per-repo config names private repos,
386
+ hosts, environments and board numbers. Variables are not secrets, but they
387
+ do not belong in a public package either.
388
+ 2. **Config belongs next to the code that reads it.** A deploy job and the
389
+ variables it reads change in the same PR, are reviewed together, and are
390
+ reverted together. Split across two repos, they drift.
391
+ 3. **A repo explains itself.** Clone it and you see everything it needs to run.
392
+ Delete it and nothing is left behind in a central registry.
393
+ 4. **Other people can use the package.** A central registry would only ever
394
+ describe your repos.
395
+
396
+ The cost, needing the checkouts for per-repo secrets, is the limit described
397
+ in §7.
398
+
399
+ ### Why two packages, and who owns the commit rule
400
+
401
+ See [§10](#10-the-two-packages-ebalzanci-and-ebalzanconfig).
402
+
403
+ ### Why a topic, not a committed list of repos
404
+
405
+ `REPOS=` in `config/repos.env` works, and is there for repos you cannot tag
406
+ (another organization's, say). The topic is the default because it is not
407
+ published: the list lives on GitHub, visible only to people who can see the
408
+ repos. It updates itself when `onboard` runs, without a PR or release of
409
+ ebalzan/ci, and it cannot drift from reality the way a file can.
410
+
411
+ The list only scopes **admin commands**. It does not decide which repos _use_
412
+ ebalzan/ci; the `uses:` lines do.
413
+
414
+ ### When something moves into ebalzan/ci
415
+
416
+ Code moves here when **two or more repos need the same job with the same
417
+ steps**, not before. A shared workflow designed for one repo will fit the
418
+ second one badly. The test: can it be expressed with a few inputs and no
419
+ `if: repo == …`? Likely candidates as repos are added:
420
+
421
+ - a `docker-publish.yml` (build and push an image for `tag`), once two
422
+ repos ship containers;
423
+ - a `node-validate.yml` (`npm ci` + cached `node_modules` + `npm run validate`),
424
+ once several repos follow the script convention of §6 with no special setup;
425
+ - a deploy for one target (e.g. Vercel), once two repos deploy there.
426
+
427
+ The repo would still keep its caller job, its environment and its secrets.
428
+
429
+ ---
430
+
431
+ ## 9. Versions: what a pin freezes
432
+
433
+ Callers pin the **major tag** (`@v2`), and every release of ebalzan/ci moves it.
434
+ Pinning an exact version (`@v2.3.1`) or a commit SHA freezes only the
435
+ **reusable workflow file**. The steps inside it name their actions as
436
+ `ebalzan/ci/actions/<name>@v2`, because a reusable workflow cannot know the ref
437
+ it was called at. So the actions, the scripts and `config/actions.env` still
438
+ come from the latest `v2`.
439
+
440
+ | You pin | Workflow file | Actions, scripts, shared variables |
441
+ | ------------------ | ------------- | ------------------------------------------ |
442
+ | `@v2` | latest v2 | latest v2 |
443
+ | `@v2.3.1` / a SHA | frozen | latest v2 |
444
+ | a fork, at any ref | your fork | your fork (once its internal refs name it) |
445
+
446
+ A new major (`v3`) never moves `v2`, so breaking changes never reach a repo
447
+ until you change its `uses:` lines. To freeze everything, fork ebalzan/ci
448
+ ([CONTRIBUTING → Forking](../CONTRIBUTING.md#forking-for-your-own-rules)).
449
+
450
+ ---
451
+
452
+ ## 10. The two packages: @ebalzan/ci and @ebalzan/config
453
+
454
+ | Package | Owns | Reaches a repo through | An update arrives |
455
+ | ----------------- | ------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | ------------------------------------------ |
456
+ | `@ebalzan/ci` | **how code is shipped**: commit/PR-title rule, the four stages, releases, repo settings and rulesets, the admin CLI | `uses: ebalzan/ci/…@v2` at run time; devDependency for the hook | instantly, when `v2` moves |
457
+ | `@ebalzan/config` | **how code is written**: Prettier options, ESLint presets, tsconfig bases | devDependency, read by the editor, ESLint, Prettier and tsc | as a Renovate PR, checked by the repo's CI |
458
+
459
+ They stay separate because their updates must behave differently. A pipeline
460
+ fix needs no change in any repo, so it ships instantly through the major tag.
461
+ A new lint rule can fail existing code, so it has to arrive as a PR that each
462
+ repo's CI checks before it merges. In one package, every pipeline fix would
463
+ open a Renovate PR in every repo, and every breaking lint rule would force a
464
+ new pipeline major (`v3`, editing every `uses:` line). `@ebalzan/ci` also
465
+ serves non-JS repos, which should not install the ESLint stack to run the
466
+ admin CLI.
467
+
468
+ **The commit rule belongs to `@ebalzan/ci`.** It is a pipeline rule: the PR
469
+ title becomes the squash commit that decides the version bump, and the same
470
+ types drive the branch check, the bump and the labels — all here, kept in
471
+ agreement by `test/rules.test.mjs`. `@ebalzan/config` has no commit rule; the
472
+ hook runs `ebalzan-ci commit-msg`.
473
+
474
+ In a Node repo the two meet in three places, all written by `onboard`:
475
+ `package.json` (both devDependencies, `"prettier"`), `eslint.config.mjs`, and
476
+ the `validate` job in `pr-checks.yml`, which runs `ebalzan-config verify`.
477
+ Renovate keeps both packages current, in the same weekly `dev tooling` PR.