@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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ebalzan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,303 @@
1
- # Temporary Holding Version
1
+ # ebalzan/ci
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm](https://img.shields.io/npm/v/@ebalzan/ci)](https://www.npmjs.com/package/@ebalzan/ci)
4
+ [![license: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
5
+
6
+ **One set of CI/CD rules for every GitHub repo you own.** Point a repo at this
7
+ one and it gets the same branch model, the same commit convention, the same
8
+ automatic semver releases, the same repository settings and the same branch
9
+ protection as every other repo — and when a rule changes here, it changes
10
+ everywhere.
11
+
12
+ It has three parts:
13
+
14
+ | Part | What it is |
15
+ | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
16
+ | **Reusable workflows** (`.github/workflows`) | Called by four thin files in each repo: auto issue + PR per branch, PR-title check, the evergreen `dev → main` PR, and the release (bump, tag, GitHub Release). |
17
+ | **CLI** (`npx @ebalzan/ci`) | `onboard` a repo in one command; `bootstrap` its Actions variables, secrets, settings and rulesets from shared + per-repo files (one repo or `--all`); validate commit messages in a git hook. |
18
+ | **Shared config** (`config/`) | The house rules: shared Actions variables, repository settings, branch rulesets — each overridable per repo. |
19
+
20
+ ```text
21
+ feat/login ── push ──▶ issue + PR into dev (auto-work-items)
22
+ │ PR title checked (pr-title)
23
+ ▼ squash-merge
24
+ dev ──▶ one dev → main PR (promote-dev-to-main)
25
+ │ "feat: promote dev to main (v1.4.0)"
26
+ ▼ squash-merge = release approval
27
+ main ──▶ v1.4.0 tag + GitHub Release, dev synced (release)
28
+ └─▶ your own deploy job, chained on the tag
29
+ ```
30
+
31
+ ---
32
+
33
+ ## Contents
34
+
35
+ - [Quick start](#quick-start)
36
+ - [What every repo gets](#what-every-repo-gets)
37
+ - [Using the workflows by hand](#using-the-workflows-by-hand)
38
+ - [Commands](#commands)
39
+ - [Configuration in one minute](#configuration-in-one-minute)
40
+ - [Versions and updates](#versions-and-updates)
41
+ - [Requirements and limits](#requirements-and-limits)
42
+ - [Documentation](#documentation)
43
+ - [Contributing](#contributing) · [License](#license)
44
+
45
+ ---
46
+
47
+ ## Quick start
48
+
49
+ **Prerequisites:** [GitHub CLI](https://cli.github.com) logged in as an admin of
50
+ the repo (`gh auth login`, plus `gh auth refresh -s project` if you use project
51
+ boards), Node.js ≥ 20, git, and bash (Linux, macOS, WSL or Git Bash).
52
+
53
+ **1. Put the shared secrets in one place** (once per machine). Every repo gets
54
+ these as repository secrets:
55
+
56
+ ```bash
57
+ mkdir -p ~/.config/ebalzan-ci
58
+ cat > ~/.config/ebalzan-ci/secrets.env <<'EOF'
59
+ # Writes the issue/PR descriptions. Optional: without it the body is a stub.
60
+ ANTHROPIC_API_KEY=sk-ant-...
61
+ # Classic PAT, scopes `repo` + `project`. Optional: lets the bots' PRs trigger
62
+ # PR Checks and write to Projects (v2) boards. Why classic: docs/GITHUB_ACTIONS_SETUP.md
63
+ GH_PROJECT_TOKEN=ghp_...
64
+ EOF
65
+ ```
66
+
67
+ A repo's **own** secrets (deploy credentials, …) go in the same folder, under
68
+ `repos/<owner>/<repo>/` — e.g. `repos/me/my-api/secrets.production.env` for its
69
+ `production` environment. Then `bootstrap --all` works with no checkouts
70
+ ([ARCHITECTURE §7](docs/ARCHITECTURE.md#7-where-you-run-commands)).
71
+
72
+ **2. Onboard a repo**, from its checkout:
73
+
74
+ ```bash
75
+ cd my-repo
76
+ npx @ebalzan/ci onboard --dry-run # see exactly what it will do
77
+ npx @ebalzan/ci onboard # do it
78
+ ```
79
+
80
+ That creates a `ci/onboard-ebalzan-ci` branch with the four workflow callers, a
81
+ starter `.github/actions.env`, `renovate.json`, the `.gitignore` lines for
82
+ secrets files and, for Node repos, the husky `commit-msg` hook and the
83
+ [`@ebalzan/config`](https://www.npmjs.com/package/@ebalzan/config) Prettier and
84
+ ESLint wiring; creates `dev` if missing; tags the repo `ebalzan-ci`; applies
85
+ the variables, secrets, settings and rulesets; and pushes. The new
86
+ `auto-pr.yml` opens the PR into `dev` on that very push.
87
+
88
+ **3. Merge it** (squash), then merge the `dev → main` PR that appears: that is
89
+ the repo's first release.
90
+
91
+ From then on, the flow is just:
92
+
93
+ ```bash
94
+ git switch dev && git pull
95
+ git switch -c feat/search-filters
96
+ git commit -m "feat(search): add filters" # the hook checks the format
97
+ git push -u origin feat/search-filters # issue + PR into dev appear
98
+ ```
99
+
100
+ Full walkthrough and the manual alternative: [docs/ONBOARDING.md](docs/ONBOARDING.md).
101
+
102
+ ---
103
+
104
+ ## What every repo gets
105
+
106
+ | Rule | What it means | Details |
107
+ | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------- |
108
+ | Branch model | Work branches `<type>/<desc>` → `dev` (integration) → `main` (releases). Only the automated promotion PR merges into `main`. | [CI_FLOW](docs/CI_FLOW.md) |
109
+ | Commit convention | Conventional Commits with the types `feat fix docs style refact perf test build ci chore revert`. Checked locally by the hook and on every PR title. | [COMMIT_GUIDE](docs/COMMIT_GUIDE.md) |
110
+ | Branch ↔ title match | A `feat/` branch needs a `feat:` title (minor), any other type a patch-level title, unless it is marked breaking (`!`). | [CI_FLOW §3](docs/CI_FLOW.md#stage-2--pr-checks) |
111
+ | Work items | Every pushed work branch gets a tracking issue and a PR into `dev`, labelled, assigned, optionally on a milestone and a Projects (v2) board with priority/size/estimate/dates. | [AUTO_WORK_ITEMS](docs/AUTO_WORK_ITEMS_WORKFLOW.md) |
112
+ | Releases | Every push to `main` is a release: semver bump from the commits since the last tag, `vX.Y.Z` tag, GitHub Release, `package.json` bumped if there is one (tag-only otherwise). | [CI_FLOW §4](docs/CI_FLOW.md#4-version-bump-rules-commit-scan) |
113
+ | Repo settings | Squash-merge only, squash commit titled from the PR title, branches deleted after merge. | [REPO_RULES](docs/REPO_RULES.md) |
114
+ | Branch rulesets | `main` cannot be deleted or force-pushed; `dev` cannot be deleted; an opt-in rule requires PRs + the title check. | [REPO_RULES](docs/REPO_RULES.md) |
115
+ | Code style (Node) | Prettier, ESLint and TypeScript rules from [`@ebalzan/config`](https://www.npmjs.com/package/@ebalzan/config), extended — not copied — by each repo. | [ARCHITECTURE §10](docs/ARCHITECTURE.md#10-the-two-packages-ebalzanci-and-ebalzanconfig) |
116
+ | Dev tooling versions | A Renovate preset keeps Prettier, ESLint, husky, lint-staged, TypeScript and both `@ebalzan` packages on the same versions everywhere — one grouped PR a week, nothing else touched. | [renovate/tooling.json](renovate/tooling.json) |
117
+
118
+ Every rule can be overridden per repo, in that repo — see
119
+ [Configuration](#configuration-in-one-minute).
120
+
121
+ **What stays in each repo:** the four small caller files (two identical
122
+ everywhere, two with the repo's own jobs added), its build/test jobs, its
123
+ deploy, and its own variables and secrets. Which file is which, what runs
124
+ where, and how to structure a deploy: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
125
+
126
+ ---
127
+
128
+ ## Using the workflows by hand
129
+
130
+ `onboard` writes these for you ([templates/](templates/)), but nothing more is
131
+ needed than four small files. Each holds only what a reusable workflow cannot:
132
+ the trigger, the permissions and the secrets.
133
+
134
+ ```yaml
135
+ # .github/workflows/auto-pr.yml
136
+ name: Auto Create Work Items
137
+ on:
138
+ push:
139
+ branches-ignore: [main, dev]
140
+ tags-ignore: ['**']
141
+ permissions:
142
+ contents: read
143
+ issues: write
144
+ pull-requests: write
145
+ repository-projects: write
146
+ jobs:
147
+ work-items:
148
+ uses: ebalzan/ci/.github/workflows/auto-work-items.yml@v2
149
+ secrets:
150
+ ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
151
+ GH_PROJECT_TOKEN: ${{ secrets.GH_PROJECT_TOKEN }}
152
+ ```
153
+
154
+ ```yaml
155
+ # .github/workflows/release.yml — with a deploy chained on the new tag
156
+ name: Release
157
+ on:
158
+ push:
159
+ branches: [main]
160
+ permissions:
161
+ contents: write
162
+ pull-requests: read
163
+ jobs:
164
+ release:
165
+ uses: ebalzan/ci/.github/workflows/release.yml@v2
166
+ deploy:
167
+ needs: release
168
+ if: ${{ needs.release.outputs.tag != '' }}
169
+ uses: ./.github/workflows/deploy.yml
170
+ with:
171
+ image_tag: ${{ needs.release.outputs.tag }}
172
+ ```
173
+
174
+ | Reusable workflow | Inputs (all optional) | Secrets | Caller must grant |
175
+ | ------------------------- | --------------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------- |
176
+ | `auto-work-items.yml` | `runs-on` (`ubuntu-latest`), `base-branch` (`dev`) | `ANTHROPIC_API_KEY`, `GH_PROJECT_TOKEN` | `contents: read`, `issues: write`, `pull-requests: write`, `repository-projects: write` |
177
+ | `pr-title.yml` | `runs-on` | — | `contents: read` |
178
+ | `promote-dev-to-main.yml` | `runs-on` | `GH_PROJECT_TOKEN` | `contents: read`, `issues: write`, `pull-requests: write` |
179
+ | `release.yml` | `runs-on`, `sync-dev` (`true`) · **outputs** `tag`, `sha` | — | `contents: write`, `pull-requests: read` |
180
+
181
+ Secrets are passed by name: `secrets: inherit` is only guaranteed within one
182
+ organization. Variables need no passing — inside a reusable workflow, `vars` are
183
+ the **caller's**. Call `pr-title.yml` from a job named `title` so the check is
184
+ reported as `title / Validate PR Title`, the name the rulesets require.
185
+
186
+ ---
187
+
188
+ ## Commands
189
+
190
+ ```text
191
+ npx @ebalzan/ci <command> [options] # or `ebalzan-ci` once installed
192
+ ```
193
+
194
+ | Command | Does |
195
+ | -------------- | ----------------------------------------------------------------------------------------------------- |
196
+ | `onboard` | Adds the templates on a branch, creates `dev`, tags the repo, bootstraps it, pushes. |
197
+ | `bootstrap` | Syncs variables, secrets, settings and rulesets onto one repo (`-C dir`, `-R owner/repo`) or `--all`. |
198
+ | `reset` | Deletes Actions variables/secrets on a repo after a typed confirmation. |
199
+ | `commit-msg` | Validates a commit message; the husky hook calls it. |
200
+ | `path` | Prints the package directory. |
201
+ | `sync-managed` | Deprecated: refreshes old local copies of the validator. |
202
+
203
+ Every command has `--help`, and `bootstrap`, `reset` and `onboard` have
204
+ `--dry-run`. Full reference: [docs/CLI.md](docs/CLI.md).
205
+
206
+ ---
207
+
208
+ ## Configuration in one minute
209
+
210
+ Everything is a file, merged in two layers — **the repo's value always wins**:
211
+
212
+ ```text
213
+ shared (this package) repo
214
+ Actions variables config/actions.env + .github/actions.env
215
+ repository secrets ~/.config/ebalzan-ci/secrets.env + ~/.config/ebalzan-ci/repos/<owner>/<repo>/secrets.env
216
+ environment secrets …/secrets.<env>.env + …/repos/<owner>/<repo>/secrets.<env>.env
217
+ (or the repo's gitignored .github/secrets*.env)
218
+ repo settings config/repo-settings.json + .github/repo-settings.json (deep merge)
219
+ branch rulesets config/rulesets/<name>.json + .github/rulesets/<name>.json (deep merge)
220
+ ```
221
+
222
+ `ebalzan-ci bootstrap` shows the merged plan (`--dry-run`), labelling each key
223
+ `shared`, `repo`, `repo (overrides shared)` or `repo (same as shared:
224
+ redundant)`, and applies it. Shared **variables** are never copied onto repos:
225
+ the actions read `config/actions.env` at run time from the version the caller
226
+ pins, so changing one is a release, not a sync.
227
+
228
+ `--all` acts on every repo tagged with the `ebalzan-ci` topic (which `onboard`
229
+ adds) plus any listed in `config/repos.env`.
230
+
231
+ - Every key, its default and who reads it: [docs/CONFIGURATION.md](docs/CONFIGURATION.md)
232
+ - Variables vs secrets, repository vs environment scope: [docs/GITHUB_ACTIONS_SETUP.md](docs/GITHUB_ACTIONS_SETUP.md)
233
+ - Settings and rulesets: [docs/REPO_RULES.md](docs/REPO_RULES.md)
234
+
235
+ ---
236
+
237
+ ## Versions and updates
238
+
239
+ This repo releases itself with its own pipeline, and the version means the
240
+ same thing in both places:
241
+
242
+ - **Callers pin a major tag**: `uses: ebalzan/ci/…@v2`. Every release moves
243
+ `v2` to it, so fixes and new features reach every repo with no change on
244
+ their side. Pinning `@v2.3.1` or a SHA freezes only the workflow file — the
245
+ actions inside it still follow `v2` ([why](docs/ARCHITECTURE.md#9-versions-what-a-pin-freezes)).
246
+ - **The npm package** `@ebalzan/ci` has the same version as the tag.
247
+ - **Breaking changes** (a removed input, a new required permission or secret, a
248
+ renamed variable) only ever ship as a new major (`v3`). `v2` stays where it is,
249
+ and each repo moves when you are ready.
250
+
251
+ What changed in each release: [GitHub Releases](https://github.com/ebalzan/ci/releases).
252
+ Upgrading from the private `tracebim-ci` v1: [docs/MIGRATION.md](docs/MIGRATION.md).
253
+
254
+ ---
255
+
256
+ ## Requirements and limits
257
+
258
+ - **GitHub.com** repos. The reusable workflows are public, so any repo — personal
259
+ or organization — can call them.
260
+ - **The flow is fixed:** `main` + `dev` + typed work branches + squash merges.
261
+ That is the point (the same rules everywhere), not an accident. `runs-on`,
262
+ the board, labels, milestone, priorities, sizes and descriptions are
263
+ configurable; the branch model and commit types are not.
264
+ - **Rulesets** need a public repo or a paid plan (GitHub Pro/Team) for private
265
+ ones; on the free plan `bootstrap` warns and skips them. Everything else works
266
+ on the free plan.
267
+ - **Projects (v2) boards owned by a personal account** need a _classic_ PAT
268
+ (`GH_PROJECT_TOKEN`); fine-grained tokens cannot reach them.
269
+ - **The CLI needs bash** (Linux, macOS, WSL, Git Bash). The `commit-msg` command
270
+ needs a POSIX `sh`.
271
+ - The work-item descriptions are written by Claude (Anthropic API) when
272
+ `ANTHROPIC_API_KEY` is set; without it a plain template is used.
273
+
274
+ ---
275
+
276
+ ## Documentation
277
+
278
+ | Doc | Covers |
279
+ | ------------------------------------------------------------ | ------------------------------------------------------------------------------ |
280
+ | [ARCHITECTURE](docs/ARCHITECTURE.md) | What is shared vs repo-specific, what runs where, deploys, design decisions |
281
+ | [ONBOARDING](docs/ONBOARDING.md) | Adding a repo with `onboard`, or by hand; what each generated file is for |
282
+ | [CI_FLOW](docs/CI_FLOW.md) | Branch → `dev` → `main` → release, stage by stage; bump rules; troubleshooting |
283
+ | [COMMIT_GUIDE](docs/COMMIT_GUIDE.md) | Commit types, PR titles, versioning, for people writing commits |
284
+ | [AUTO_WORK_ITEMS_WORKFLOW](docs/AUTO_WORK_ITEMS_WORKFLOW.md) | The issue / PR / board automation in detail |
285
+ | [CONFIGURATION](docs/CONFIGURATION.md) | Every variable, secret and config file, with defaults |
286
+ | [GITHUB_ACTIONS_SETUP](docs/GITHUB_ACTIONS_SETUP.md) | Where a value belongs (variable/secret, repo/environment) and why |
287
+ | [REPO_RULES](docs/REPO_RULES.md) | Repository settings and branch rulesets |
288
+ | [CLI](docs/CLI.md) | Every command and option |
289
+ | [MIGRATION](docs/MIGRATION.md) | From `ebalzan/tracebim-ci` v1 to `ebalzan/ci` v2 |
290
+ | [CONTRIBUTING](CONTRIBUTING.md) | Working on this repo: layout, tests, releasing, publishing to npm |
291
+
292
+ ---
293
+
294
+ ## Contributing
295
+
296
+ Issues and PRs are welcome. This repo uses its own rules, so a PR needs a
297
+ typed branch and a Conventional Commit title. Setup, tests and the release
298
+ process: [CONTRIBUTING.md](CONTRIBUTING.md). Security reports:
299
+ [SECURITY.md](SECURITY.md).
300
+
301
+ ## License
302
+
303
+ [MIT](LICENSE) © ebalzan
package/bin/_json.mjs ADDED
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env node
2
+ //
3
+ // _json.mjs — JSON helpers for the bash tools (bash has no JSON of its own, and
4
+ // node is already a requirement of the package). Not a public command.
5
+ //
6
+ // merge FILE... Deep-merge the files left to right (later wins) and
7
+ // print the result. Objects merge key by key; arrays
8
+ // and scalars are replaced whole. Missing files are
9
+ // skipped. Top-level keys starting with `$` (e.g.
10
+ // "$comment") are documentation and are dropped.
11
+ // get FILE KEY Print one top-level value (strings unquoted).
12
+ // diff DESIRED CURRENT Print `key<TAB>current<TAB>desired` for every
13
+ // top-level key of DESIRED whose value differs in
14
+ // CURRENT. Exit 0 either way.
15
+ //
16
+ // Used by gh-bootstrap.sh for config/repo-settings.json and config/rulesets/.
17
+
18
+ import { existsSync, readFileSync, realpathSync } from 'node:fs'
19
+ import process from 'node:process'
20
+ import { pathToFileURL } from 'node:url'
21
+
22
+ function fail(message) {
23
+ console.error(`_json.mjs: ${message}`)
24
+ process.exit(2)
25
+ }
26
+
27
+ function read(file) {
28
+ try {
29
+ return JSON.parse(readFileSync(file, 'utf8'))
30
+ } catch (error) {
31
+ fail(`${file}: ${error instanceof Error ? error.message : error}`)
32
+ }
33
+ }
34
+
35
+ function isPlainObject(value) {
36
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
37
+ }
38
+
39
+ export function deepMerge(base, override) {
40
+ if (!isPlainObject(base) || !isPlainObject(override)) return override
41
+ const merged = { ...base }
42
+ for (const [key, value] of Object.entries(override)) {
43
+ merged[key] = key in base ? deepMerge(base[key], value) : value
44
+ }
45
+ return merged
46
+ }
47
+
48
+ export function stripDocKeys(value) {
49
+ if (!isPlainObject(value)) return value
50
+ return Object.fromEntries(Object.entries(value).filter(([key]) => !key.startsWith('$')))
51
+ }
52
+
53
+ export function diffTopLevel(desired, current) {
54
+ const rows = []
55
+ for (const [key, value] of Object.entries(desired)) {
56
+ const now = current?.[key]
57
+ if (JSON.stringify(now) !== JSON.stringify(value)) {
58
+ rows.push([key, now === undefined ? '(unset)' : JSON.stringify(now), JSON.stringify(value)])
59
+ }
60
+ }
61
+ return rows
62
+ }
63
+
64
+ function main(argv) {
65
+ const [command, ...args] = argv
66
+ switch (command) {
67
+ case 'merge': {
68
+ const docs = args.filter(file => existsSync(file)).map(read)
69
+ if (docs.length === 0) fail('merge: none of the files exist')
70
+ process.stdout.write(`${JSON.stringify(stripDocKeys(docs.reduce(deepMerge)), null, 2)}\n`)
71
+ return
72
+ }
73
+ case 'get': {
74
+ const [file, key] = args
75
+ const value = read(file)?.[key]
76
+ if (value === undefined || value === null) return
77
+ process.stdout.write(typeof value === 'string' ? value : JSON.stringify(value))
78
+ return
79
+ }
80
+ case 'diff': {
81
+ const [desired, current] = args
82
+ for (const row of diffTopLevel(read(desired), read(current))) {
83
+ process.stdout.write(`${row.join('\t')}\n`)
84
+ }
85
+ return
86
+ }
87
+ default:
88
+ fail(`unknown command '${command ?? ''}' (merge | get | diff)`)
89
+ }
90
+ }
91
+
92
+ // Run as a script, not when imported by the tests.
93
+ if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
94
+ main(process.argv.slice(2))
95
+ }
package/bin/_lib.sh ADDED
@@ -0,0 +1,153 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # _lib.sh — shared helpers for the ebalzan/ci GitHub-config tools.
4
+ # This file is *sourced*, not executed. The sourcing script owns `set -euo pipefail`.
5
+ #
6
+ # Also meant to be sourced by a repo's own admin scripts, so keep it free of
7
+ # anything specific to one script. Find it with the CLI:
8
+ #
9
+ # source "$(npx --no-install ebalzan-ci path)/bin/_lib.sh"
10
+ #
11
+ # Bash 3.2 compatible (macOS /bin/bash): no associative arrays, no mapfile.
12
+
13
+ # ---- pretty output -----------------------------------------------------------
14
+ if [[ -t 1 && -z "${NO_COLOR:-}" ]]; then
15
+ BOLD=$'\033[1m'; DIM=$'\033[2m'; RED=$'\033[31m'; GREEN=$'\033[32m'
16
+ YELLOW=$'\033[33m'; CYAN=$'\033[36m'; RESET=$'\033[0m'
17
+ else
18
+ BOLD=''; DIM=''; RED=''; GREEN=''; YELLOW=''; CYAN=''; RESET=''
19
+ fi
20
+ info() { printf '%s\n' "$*"; }
21
+ step() { printf '%s==>%s %s\n' "$CYAN$BOLD" "$RESET" "$*"; }
22
+ ok() { printf '%s ✓%s %s\n' "$GREEN" "$RESET" "$*"; }
23
+ warn() { printf '%s ! %s%s\n' "$YELLOW" "$*" "$RESET"; }
24
+ die() { printf '%s ✗ %s%s\n' "$RED" "$*" "$RESET" >&2; exit 1; }
25
+
26
+ # ---- dotenv helpers ----------------------------------------------------------
27
+ # Print only KEY=VALUE lines (drop comments and blanks). Missing file → nothing.
28
+ clean_env() { [[ -f "$1" ]] && grep -vE '^[[:space:]]*(#|$)' "$1" || true; }
29
+ # Print just the key names from an env file.
30
+ env_keys() { clean_env "$1" | sed -E 's/=.*//'; }
31
+ # Count the KEY=VALUE lines in an env file (0 if missing/empty).
32
+ env_count() { clean_env "$1" | grep -c . || true; }
33
+ # Read a single value: env_get FILE KEY. A missing key or file prints nothing
34
+ # and still succeeds, so `x="$(env_get ...)"` is safe under `set -e`.
35
+ env_get() { { clean_env "$1" | grep -E "^$2=" | head -1 | cut -d= -f2-; } || true; }
36
+ # Strip one pair of matching surrounding quotes, as dotenv parsers do.
37
+ unquote() {
38
+ local v="$1"
39
+ if [[ ${#v} -ge 2 && ( ( "${v:0:1}" == '"' && "${v: -1}" == '"' ) || ( "${v:0:1}" == "'" && "${v: -1}" == "'" ) ) ]]; then
40
+ v="${v:1:${#v}-2}"
41
+ fi
42
+ printf '%s' "$v"
43
+ }
44
+
45
+ # ---- gh helpers --------------------------------------------------------------
46
+ require_gh() {
47
+ command -v gh >/dev/null 2>&1 \
48
+ || die "GitHub CLI (gh) is not installed: https://cli.github.com"
49
+ }
50
+ require_auth() {
51
+ gh auth status >/dev/null 2>&1 \
52
+ || die "gh is not authenticated. Run: gh auth login (board scope: gh auth refresh -s project)"
53
+ }
54
+
55
+ # detect_repo [DIR] — OWNER/REPO of the checkout at DIR (default: cwd). Prints it.
56
+ # Starts from the git remote, then asks GitHub for the canonical name, so a repo
57
+ # that was transferred (whose old remote URL still redirects) resolves to where
58
+ # it lives now — the scope its variables and secrets actually belong to.
59
+ detect_repo() {
60
+ local dir="${1:-.}" repo canonical
61
+ repo="$(git -C "$dir" remote get-url origin 2>/dev/null \
62
+ | sed -E 's#(git@|ssh://git@|https://)([^/:]+)[:/]##; s#\.git$##' || true)"
63
+ if [[ -n "$repo" ]]; then
64
+ canonical="$(gh repo view "$repo" --json nameWithOwner -q .nameWithOwner 2>/dev/null || true)"
65
+ [[ -n "$canonical" ]] && repo="$canonical"
66
+ fi
67
+ printf '%s' "$repo"
68
+ }
69
+
70
+ # Web URL of a Projects (v2) board. User-owned boards live under /users/, not
71
+ # /orgs/ — the owner type decides, so ask the API and default to users.
72
+ project_url() { # project_url OWNER NUMBER
73
+ local kind
74
+ kind="$(gh api "users/$1" -q .type 2>/dev/null || true)"
75
+ if [[ "$kind" == "Organization" ]]; then
76
+ printf 'https://github.com/orgs/%s/projects/%s' "$1" "$2"
77
+ else
78
+ printf 'https://github.com/users/%s/projects/%s' "$1" "$2"
79
+ fi
80
+ }
81
+
82
+ # ---- prompts -----------------------------------------------------------------
83
+ # confirm "question" → 0 if the user answers y/Y
84
+ confirm() {
85
+ local reply
86
+ printf '%s%s [y/N] %s' "$YELLOW" "$1" "$RESET"
87
+ read -r reply
88
+ [[ "$reply" =~ ^[Yy]$ ]]
89
+ }
90
+ # confirm_typed EXPECTED "prompt" → 0 only if the user types EXPECTED verbatim
91
+ confirm_typed() {
92
+ local reply
93
+ printf '%s%s%s ' "$YELLOW" "$2" "$RESET"
94
+ read -r reply
95
+ [[ "$reply" == "$1" ]]
96
+ }
97
+
98
+ # ---- repo discovery ----------------------------------------------------------
99
+ # ci_repos_root CI_ROOT — where `--all` looks for local checkouts
100
+ # (<root>/<repo name>). TRACEBIM_REPOS_ROOT is the pre-2.0 name, still honoured.
101
+ ci_repos_root() {
102
+ printf '%s' "${CI_REPOS_ROOT:-${TRACEBIM_REPOS_ROOT:-$(dirname "$1")}}"
103
+ }
104
+
105
+ # managed_repos REPOS_FILE CI_ROOT — every OWNER/REPO `--all` acts on, one per
106
+ # line, de-duplicated: the repos of REPOS_OWNER (default: the gh user) tagged
107
+ # with REPOS_TOPIC, plus the explicit REPOS list. A REPOS entry without a slash
108
+ # is a pre-2.0 checkout directory name under ci_repos_root; its slug comes from
109
+ # that checkout's remote.
110
+ managed_repos() {
111
+ local file="$1" ci_root="$2" topic owner entry root
112
+ topic="$(unquote "$(env_get "$file" REPOS_TOPIC)")"
113
+ owner="$(unquote "$(env_get "$file" REPOS_OWNER)")"
114
+ root="$(ci_repos_root "$ci_root")"
115
+ {
116
+ if [[ -n "$topic" ]]; then
117
+ [[ -n "$owner" ]] || owner="$(gh api user -q .login 2>/dev/null || true)"
118
+ [[ -n "$owner" ]] || die "REPOS_TOPIC is set but the gh user is unknown. Run gh auth login, or set REPOS_OWNER."
119
+ gh repo list "$owner" --topic "$topic" --no-archived --limit 1000 \
120
+ --json nameWithOwner -q '.[].nameWithOwner' \
121
+ || die "Could not list $owner's repos tagged '$topic'."
122
+ fi
123
+ for entry in $(unquote "$(env_get "$file" REPOS)"); do
124
+ if [[ "$entry" == */* ]]; then
125
+ printf '%s\n' "$entry"
126
+ elif [[ -d "$root/$entry" ]]; then
127
+ detect_repo "$root/$entry"; printf '\n'
128
+ else
129
+ warn "REPOS entry '$entry' has no checkout at $root/$entry — use OWNER/REPO."
130
+ fi
131
+ done
132
+ } | awk 'NF && !seen[tolower($0)]++'
133
+ }
134
+
135
+ # local_checkout SLUG CI_ROOT — the local checkout of SLUG, if there is one:
136
+ # this repo itself, or <ci_repos_root>/<repo name> whose remote is SLUG. Prints
137
+ # nothing otherwise.
138
+ local_checkout() {
139
+ local slug="$1" ci_root="$2" dir found
140
+ for dir in "$ci_root" "$(ci_repos_root "$ci_root")/${slug#*/}"; do
141
+ [[ -d "$dir/.git" || -f "$dir/.git" ]] || continue
142
+ found="$(detect_repo "$dir")"
143
+ if [[ "$(printf '%s' "$found" | tr '[:upper:]' '[:lower:]')" == "$(printf '%s' "$slug" | tr '[:upper:]' '[:lower:]')" ]]; then
144
+ (cd "$dir" && pwd); return 0
145
+ fi
146
+ done
147
+ return 0
148
+ }
149
+
150
+ # print_help SCRIPT — the script's leading comment block, minus the shebang.
151
+ print_help() {
152
+ awk 'NR>1 && /^#/{ sub(/^# ?/, ""); print; next } NR>1{ exit }' "$1"
153
+ }