@ebalzan/ci 0.0.0-stage → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +302 -2
- package/bin/_json.mjs +95 -0
- package/bin/_lib.sh +153 -0
- package/bin/cli.mjs +105 -0
- package/bin/gh-bootstrap.sh +622 -0
- package/bin/gh-reset.sh +148 -0
- package/bin/onboard.sh +340 -0
- package/bin/sync-managed.sh +81 -0
- package/config/actions.env +33 -0
- package/config/repo-settings.json +10 -0
- package/config/repos.env +25 -0
- package/config/rulesets/protect-dev.json +10 -0
- package/config/rulesets/protect-main.json +10 -0
- package/config/rulesets/require-pull-request.json +33 -0
- package/config/secrets.env.example +31 -0
- package/docs/ARCHITECTURE.md +477 -0
- package/docs/AUTO_WORK_ITEMS_WORKFLOW.md +305 -0
- package/docs/CI_FLOW.md +205 -0
- package/docs/CLI.md +229 -0
- package/docs/COMMIT_GUIDE.md +133 -0
- package/docs/CONFIGURATION.md +177 -0
- package/docs/GITHUB_ACTIONS_SETUP.md +315 -0
- package/docs/MIGRATION.md +175 -0
- package/docs/ONBOARDING.md +166 -0
- package/docs/REPO_RULES.md +116 -0
- package/managed/scripts/release/validate-conventional-subject.sh +32 -0
- package/package.json +66 -4
- package/renovate/tooling.json +45 -0
- package/templates/.github/actions.env +17 -0
- package/templates/.github/workflows/auto-pr.yml +33 -0
- package/templates/.github/workflows/pr-checks.yml +48 -0
- package/templates/.github/workflows/promote-dev-to-main.yml +26 -0
- package/templates/.github/workflows/release.yml +36 -0
- package/templates/.husky/commit-msg +18 -0
- package/templates/eslint.config.mjs +6 -0
- package/templates/renovate.json +4 -0
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
|
-
#
|
|
1
|
+
# ebalzan/ci
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@ebalzan/ci)
|
|
4
|
+
[](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
|
+
}
|