@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
|
@@ -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.
|