@ebalzan/ci 0.0.0-stage → 1.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/bin/cli.mjs +101 -0
- package/commitlint.config.mjs +39 -0
- package/config/actions.env +28 -0
- package/config/repo-settings.json +12 -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/rulesets/require-release-pr.json +33 -0
- package/config/secrets.env.example +33 -0
- package/config/types.json +14 -0
- package/lib/config.mjs +182 -0
- package/lib/github.mjs +132 -0
- package/lib/log.mjs +34 -0
- package/lib/onboard.mjs +526 -0
- package/lib/sync.mjs +312 -0
- package/package.json +72 -4
- package/renovate/tooling.json +45 -0
- package/templates/.github/actions.env +21 -0
- package/templates/.github/workflows/auto-pr.yml +56 -0
- package/templates/.github/workflows/deploy.yml +88 -0
- package/templates/.github/workflows/pr-checks.yml +110 -0
- package/templates/.github/workflows/release.yml +76 -0
- package/templates/.husky/commit-msg +11 -0
- package/templates/eslint.config.mjs +6 -0
- package/templates/renovate.json +4 -0
- package/templates/secrets.gitignore +4 -0
- package/README.md +0 -3
package/lib/sync.mjs
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
// sync.mjs — `ebalzan-ci sync`: make GitHub repos match the config files.
|
|
2
|
+
// lib/config.mjs reads what each repo should have; lib/github.mjs reads and
|
|
3
|
+
// writes what it has. Every step prints what it does; --dry-run stops before
|
|
4
|
+
// any write.
|
|
5
|
+
|
|
6
|
+
import { dirname, join, resolve } from 'node:path'
|
|
7
|
+
import { fileURLToPath } from 'node:url'
|
|
8
|
+
import { parseArgs } from 'node:util'
|
|
9
|
+
|
|
10
|
+
import { desiredState, diffTopLevel, secretValue, sharedSecretsDir } from './config.mjs'
|
|
11
|
+
import * as github from './github.mjs'
|
|
12
|
+
import { bold, confirm, dim, fail, info, ok, step, warn } from './log.mjs'
|
|
13
|
+
|
|
14
|
+
const CI_ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
|
|
15
|
+
const SHARED_DIR = join(CI_ROOT, 'config')
|
|
16
|
+
const SECTIONS = ['vars', 'secrets', 'settings', 'rulesets']
|
|
17
|
+
// Rulesets named like this are managed here, and --prune may delete them.
|
|
18
|
+
const MANAGED_RULESET_PREFIX = 'ci: '
|
|
19
|
+
|
|
20
|
+
export const HELP = `Usage: ebalzan-ci sync [options]
|
|
21
|
+
|
|
22
|
+
Make the GitHub repo of the checkout you run it in match the config files:
|
|
23
|
+
Actions variables and secrets, repository settings and branch rulesets. Each comes from two layers; for any
|
|
24
|
+
key, the repo's own value wins.
|
|
25
|
+
|
|
26
|
+
shared, every repo the repo's own
|
|
27
|
+
variables config/actions.env .github/actions.env
|
|
28
|
+
secrets <secrets>/secrets.env .github/secrets.env
|
|
29
|
+
environment secrets <secrets>/secrets.<env>.env .github/secrets.<env>.env
|
|
30
|
+
repository settings config/repo-settings.json .github/repo-settings.json
|
|
31
|
+
branch rulesets config/rulesets/<file>.json .github/rulesets/<file>.json
|
|
32
|
+
|
|
33
|
+
- Shared variables are not pushed: the actions read config/actions.env at run
|
|
34
|
+
time, so changing one only needs a new release of this package.
|
|
35
|
+
- JSON files deep-merge; top-level keys starting with $ are comments. A
|
|
36
|
+
ruleset is matched to the one on GitHub by its "name".
|
|
37
|
+
- KEY_FILE=path in a secrets file sets KEY from that file's contents.
|
|
38
|
+
- Secrets files are gitignored. <secrets> is $CI_SECRETS_DIR, else config/ in
|
|
39
|
+
a git checkout of this package, else ~/.config/ebalzan-ci.
|
|
40
|
+
- Removing a key does not delete it on GitHub: pass --prune. It only deletes
|
|
41
|
+
rulesets named "${MANAGED_RULESET_PREFIX}…".
|
|
42
|
+
|
|
43
|
+
Options:
|
|
44
|
+
-C, --repo-dir DIR Checkout of the repo to sync (default: .)
|
|
45
|
+
-R, --repo OWNER/REPO Its slug (default: from the checkout's origin remote)
|
|
46
|
+
--skip LIST Sections to leave alone, comma-separated:
|
|
47
|
+
${SECTIONS.join(', ')}
|
|
48
|
+
--prune Also delete what no file defines (asks first)
|
|
49
|
+
-n, --dry-run Show the plan; change nothing
|
|
50
|
+
-y, --yes Skip the confirmations
|
|
51
|
+
-h, --help Show this help
|
|
52
|
+
|
|
53
|
+
Requires gh, authenticated with admin on the repo (a dry run works without).`
|
|
54
|
+
|
|
55
|
+
// ---- target ------------------------------------------------------------------
|
|
56
|
+
// The checkout to read (-C, else .) and the GitHub repo to write (-R, else the
|
|
57
|
+
// checkout's origin remote).
|
|
58
|
+
function resolveTarget(opts) {
|
|
59
|
+
const dir = resolve(opts['repo-dir'] ?? '.')
|
|
60
|
+
if (!github.isCheckout(dir)) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`${dir} is not a git checkout.${opts['repo-dir'] ? '' : ' Run sync in the repo, or pass -C DIR.'}`,
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
const repo = opts.repo || github.detectRepo(dir)
|
|
66
|
+
if (!repo) throw new Error(`Could not determine the repo for ${dir}. Pass --repo OWNER/REPO.`)
|
|
67
|
+
return { repo, dir }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ---- plan --------------------------------------------------------------------
|
|
71
|
+
function listEntries(title, entries) {
|
|
72
|
+
if (entries.length === 0) return
|
|
73
|
+
info(` ${bold(title)}`)
|
|
74
|
+
for (const { name, layer } of entries) info(` ${name.padEnd(34)} ${dim(layer)}`)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function printPlan(ctx, repo, dir, want) {
|
|
78
|
+
const { variables, secrets, settings, rulesets } = want
|
|
79
|
+
info()
|
|
80
|
+
info(`${bold('Sync')} ${dim(`${repo} ← ${dir}`)}`)
|
|
81
|
+
info(
|
|
82
|
+
dim(
|
|
83
|
+
`shared config: ${SHARED_DIR} · shared secrets: ${ctx.secretsDir}${ctx.skip.length ? ` · skipping: ${ctx.skip}` : ''}`,
|
|
84
|
+
),
|
|
85
|
+
)
|
|
86
|
+
info()
|
|
87
|
+
if (ctx.on('vars')) {
|
|
88
|
+
listEntries(
|
|
89
|
+
'repository variables',
|
|
90
|
+
variables.filter(entry => entry.layer !== 'shared'),
|
|
91
|
+
)
|
|
92
|
+
const runtime = variables.filter(entry => entry.layer === 'shared').map(entry => entry.name)
|
|
93
|
+
if (runtime.length)
|
|
94
|
+
info(` ${dim(`shared variables, read at run time (not pushed): ${runtime.join(' ')}`)}`)
|
|
95
|
+
}
|
|
96
|
+
if (ctx.on('secrets')) {
|
|
97
|
+
listEntries('repository secrets', secrets.repository)
|
|
98
|
+
for (const [env, entries] of Object.entries(secrets.environments)) {
|
|
99
|
+
listEntries(`environment '${env}' secrets`, entries)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (ctx.on('settings') && settings) {
|
|
103
|
+
info(` ${bold('repository settings')}`)
|
|
104
|
+
const show = value => (typeof value === 'object' ? JSON.stringify(value) : value)
|
|
105
|
+
info(
|
|
106
|
+
` ${Object.entries(settings)
|
|
107
|
+
.map(([key, value]) => `${key}=${show(value)}`)
|
|
108
|
+
.join(' ')}`,
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
if (ctx.on('rulesets') && rulesets.length) {
|
|
112
|
+
info(` ${bold('rulesets')}`)
|
|
113
|
+
for (const { file, body } of rulesets)
|
|
114
|
+
info(` ${body.name.padEnd(34)} ${dim(`${body.enforcement} · ${file}`)}`)
|
|
115
|
+
}
|
|
116
|
+
info()
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---- apply -------------------------------------------------------------------
|
|
120
|
+
// prune — delete the KIND entries on GitHub that KEEP does not name.
|
|
121
|
+
async function prune(ctx, kind, repo, keep, env) {
|
|
122
|
+
let existing
|
|
123
|
+
try {
|
|
124
|
+
existing = github.listNames(kind, repo, env)
|
|
125
|
+
} catch {
|
|
126
|
+
return // e.g. the environment does not exist yet
|
|
127
|
+
}
|
|
128
|
+
const stale = existing.filter(name => !keep.includes(name))
|
|
129
|
+
if (stale.length === 0) return
|
|
130
|
+
step(`Stale ${kind}s on ${env ? `environment '${env}' of ` : ''}${repo} (no file defines them):`)
|
|
131
|
+
for (const name of stale) info(` - ${name}`)
|
|
132
|
+
if (ctx.dryRun) return
|
|
133
|
+
if (!ctx.yes && !(await confirm('Delete these?'))) return warn('Kept.')
|
|
134
|
+
for (const name of stale) {
|
|
135
|
+
github.deleteEntry(kind, repo, name, env)
|
|
136
|
+
ok(`deleted ${kind} ${name}`)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function syncVariables(ctx, repo, variables) {
|
|
141
|
+
const own = variables.filter(entry => entry.layer !== 'shared')
|
|
142
|
+
if (!ctx.dryRun && own.length) {
|
|
143
|
+
step(`Setting ${own.length} repo variable(s)`)
|
|
144
|
+
for (const { name, value } of own) github.setVariable(repo, name, value)
|
|
145
|
+
ok('done')
|
|
146
|
+
}
|
|
147
|
+
if (ctx.prune)
|
|
148
|
+
await prune(
|
|
149
|
+
ctx,
|
|
150
|
+
'variable',
|
|
151
|
+
repo,
|
|
152
|
+
own.map(entry => entry.name),
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function syncSecrets(ctx, repo, secrets) {
|
|
157
|
+
const scopes = [[undefined, secrets.repository], ...Object.entries(secrets.environments)]
|
|
158
|
+
for (const [env, entries] of scopes) {
|
|
159
|
+
const names = entries.map(entry => entry.name)
|
|
160
|
+
if (!ctx.dryRun) {
|
|
161
|
+
// A job only sees an environment's secrets when it declares `environment: <name>`.
|
|
162
|
+
if (env) github.createEnvironment(repo, env)
|
|
163
|
+
for (const entry of entries) github.setSecret(repo, entry.name, secretValue(entry), env)
|
|
164
|
+
if (names.length)
|
|
165
|
+
ok(
|
|
166
|
+
`${env ? `environment '${env}' ` : ''}secrets synced (values not shown): ${names.join(' ')}`,
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
if (ctx.prune) await prune(ctx, 'secret', repo, names, env)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function syncSettings(ctx, repo, settings) {
|
|
174
|
+
const changes = diffTopLevel(settings, github.getRepo(repo))
|
|
175
|
+
if (changes.length === 0) return ok('repository settings already match')
|
|
176
|
+
step('Repository settings to change')
|
|
177
|
+
for (const [key, now, want] of changes) info(` ${key.padEnd(30)} ${now} → ${want}`)
|
|
178
|
+
if (ctx.dryRun) return
|
|
179
|
+
github.patchRepo(repo, settings)
|
|
180
|
+
ok('repository settings updated')
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function syncRulesets(ctx, repo, rulesets) {
|
|
184
|
+
const unavailable = () =>
|
|
185
|
+
warn(
|
|
186
|
+
`Rulesets are not available for ${repo} (a private repo needs GitHub Pro or Team) — skipped.`,
|
|
187
|
+
)
|
|
188
|
+
step('Rulesets')
|
|
189
|
+
let existing
|
|
190
|
+
try {
|
|
191
|
+
existing = github.listRulesets(repo)
|
|
192
|
+
} catch (error) {
|
|
193
|
+
if (github.rulesetsUnavailable(error)) return unavailable()
|
|
194
|
+
throw new Error(`Could not list rulesets on ${repo}: ${error.message}`, { cause: error })
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
for (const { file, body } of rulesets) {
|
|
198
|
+
const match = existing.find(ruleset => ruleset.name === body.name)
|
|
199
|
+
const action = match ? 'update' : 'create'
|
|
200
|
+
if (ctx.dryRun) {
|
|
201
|
+
info(` ${action} ${`'${body.name}'`.padEnd(28)} ${dim(`${body.enforcement} · ${file}`)}`)
|
|
202
|
+
continue
|
|
203
|
+
}
|
|
204
|
+
try {
|
|
205
|
+
if (match) github.updateRuleset(repo, match.id, body)
|
|
206
|
+
else github.createRuleset(repo, body)
|
|
207
|
+
} catch (error) {
|
|
208
|
+
if (github.rulesetsUnavailable(error)) return unavailable()
|
|
209
|
+
throw new Error(`Could not ${action} ruleset '${body.name}': ${error.message}`, {
|
|
210
|
+
cause: error,
|
|
211
|
+
})
|
|
212
|
+
}
|
|
213
|
+
ok(`ruleset '${body.name}' ${action}d (${body.enforcement})`)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (!ctx.prune) return
|
|
217
|
+
const stale = existing.filter(
|
|
218
|
+
ruleset =>
|
|
219
|
+
ruleset.name.startsWith(MANAGED_RULESET_PREFIX) &&
|
|
220
|
+
!rulesets.some(({ body }) => body.name === ruleset.name),
|
|
221
|
+
)
|
|
222
|
+
if (stale.length === 0) return
|
|
223
|
+
step(`Stale rulesets on ${repo} (named '${MANAGED_RULESET_PREFIX}…', no file defines them):`)
|
|
224
|
+
for (const { name } of stale) info(` - ${name}`)
|
|
225
|
+
if (ctx.dryRun) return
|
|
226
|
+
if (!ctx.yes && !(await confirm('Delete these?'))) return warn('Kept.')
|
|
227
|
+
for (const { id, name } of stale) {
|
|
228
|
+
github.deleteRuleset(repo, id)
|
|
229
|
+
ok(`deleted ruleset '${name}'`)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function syncRepo(ctx, repo, dir) {
|
|
234
|
+
const want = desiredState({ sharedDir: SHARED_DIR, secretsDir: ctx.secretsDir, repoDir: dir })
|
|
235
|
+
printPlan(ctx, repo, dir, want)
|
|
236
|
+
|
|
237
|
+
if (ctx.dryRun) {
|
|
238
|
+
// Comparing with GitHub needs credentials; without them the plan is all there is.
|
|
239
|
+
if (!github.isAuthenticated()) return ok('Dry run complete — nothing was changed.')
|
|
240
|
+
} else {
|
|
241
|
+
github.requireAuth()
|
|
242
|
+
if (ctx.on('vars')) {
|
|
243
|
+
for (const { key, value, file, layer } of want.variables) {
|
|
244
|
+
if (layer !== 'shared' && !value) throw new Error(`${key} has an empty value in ${file}.`)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (ctx.on('secrets')) {
|
|
248
|
+
for (const entry of [
|
|
249
|
+
want.secrets.repository,
|
|
250
|
+
...Object.values(want.secrets.environments),
|
|
251
|
+
].flat()) {
|
|
252
|
+
secretValue(entry)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (!ctx.yes && !(await confirm(`Push this config → ${repo}?`))) throw new Error('Aborted.')
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (ctx.on('vars')) await syncVariables(ctx, repo, want.variables)
|
|
259
|
+
if (ctx.on('secrets')) await syncSecrets(ctx, repo, want.secrets)
|
|
260
|
+
if (ctx.on('settings') && want.settings) syncSettings(ctx, repo, want.settings)
|
|
261
|
+
if (ctx.on('rulesets') && want.rulesets.length) await syncRulesets(ctx, repo, want.rulesets)
|
|
262
|
+
|
|
263
|
+
info()
|
|
264
|
+
ok(ctx.dryRun ? 'Dry run complete — nothing was changed.' : bold(`Sync complete for ${repo}.`))
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export async function sync(argv) {
|
|
268
|
+
let opts
|
|
269
|
+
try {
|
|
270
|
+
opts = parseArgs({
|
|
271
|
+
args: argv,
|
|
272
|
+
options: {
|
|
273
|
+
'repo-dir': { type: 'string', short: 'C' },
|
|
274
|
+
repo: { type: 'string', short: 'R' },
|
|
275
|
+
skip: { type: 'string', default: '' },
|
|
276
|
+
prune: { type: 'boolean' },
|
|
277
|
+
'dry-run': { type: 'boolean', short: 'n' },
|
|
278
|
+
yes: { type: 'boolean', short: 'y' },
|
|
279
|
+
help: { type: 'boolean', short: 'h' },
|
|
280
|
+
},
|
|
281
|
+
}).values
|
|
282
|
+
} catch (error) {
|
|
283
|
+
fail(`${error.message} (try --help)`)
|
|
284
|
+
return 1
|
|
285
|
+
}
|
|
286
|
+
if (opts.help) {
|
|
287
|
+
console.log(HELP)
|
|
288
|
+
return 0
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
const skip = opts.skip.split(',').filter(Boolean)
|
|
293
|
+
for (const section of skip) {
|
|
294
|
+
if (!SECTIONS.includes(section))
|
|
295
|
+
throw new Error(`--skip: unknown section '${section}' (${SECTIONS.join(', ')}).`)
|
|
296
|
+
}
|
|
297
|
+
const ctx = {
|
|
298
|
+
secretsDir: sharedSecretsDir(CI_ROOT),
|
|
299
|
+
skip,
|
|
300
|
+
on: section => !skip.includes(section),
|
|
301
|
+
prune: Boolean(opts.prune),
|
|
302
|
+
dryRun: Boolean(opts['dry-run']),
|
|
303
|
+
yes: Boolean(opts.yes),
|
|
304
|
+
}
|
|
305
|
+
const { repo, dir } = resolveTarget(opts)
|
|
306
|
+
await syncRepo(ctx, repo, dir)
|
|
307
|
+
return 0
|
|
308
|
+
} catch (error) {
|
|
309
|
+
fail(error.message)
|
|
310
|
+
return 1
|
|
311
|
+
}
|
|
312
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,74 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ebalzan/ci",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "One set of CI/CD rules for all your GitHub repos: reusable workflows (auto work and release PRs with AI descriptions, PR checks, semver releases), a CLI that syncs Actions config, repo settings and branch rulesets, and a commitlint commit-msg hook.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"github-actions",
|
|
7
|
+
"reusable-workflows",
|
|
8
|
+
"ci",
|
|
9
|
+
"cd",
|
|
10
|
+
"conventional-commits",
|
|
11
|
+
"commitlint",
|
|
12
|
+
"danger",
|
|
13
|
+
"git-cliff",
|
|
14
|
+
"semver",
|
|
15
|
+
"rulesets",
|
|
16
|
+
"husky",
|
|
17
|
+
"commit-msg",
|
|
18
|
+
"renovate-preset",
|
|
19
|
+
"devops"
|
|
20
|
+
],
|
|
21
|
+
"homepage": "https://github.com/ebalzan/ci",
|
|
22
|
+
"bugs": {
|
|
23
|
+
"url": "https://github.com/ebalzan/ci/issues"
|
|
24
|
+
},
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/ebalzan/ci.git"
|
|
28
|
+
},
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"author": "ebalzan",
|
|
31
|
+
"type": "module",
|
|
32
|
+
"bin": {
|
|
33
|
+
"ebalzan-ci": "bin/cli.mjs"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"bin/",
|
|
37
|
+
"lib/",
|
|
38
|
+
"templates/",
|
|
39
|
+
"renovate/",
|
|
40
|
+
"commitlint.config.mjs",
|
|
41
|
+
"config/actions.env",
|
|
42
|
+
"config/types.json",
|
|
43
|
+
"config/repo-settings.json",
|
|
44
|
+
"config/rulesets/",
|
|
45
|
+
"config/secrets.env.example"
|
|
46
|
+
],
|
|
47
|
+
"engines": {
|
|
48
|
+
"node": ">=22.12"
|
|
49
|
+
},
|
|
50
|
+
"publishConfig": {
|
|
51
|
+
"access": "public"
|
|
52
|
+
},
|
|
53
|
+
"scripts": {
|
|
54
|
+
"check": "for f in actions/*/*.sh scripts/*.sh; do bash -n \"$f\" || exit 1; done && for f in bin/*.mjs lib/*.mjs scripts/*.mjs actions/*/*.mjs actions/*/*/*.mjs commitlint.config.mjs; do node --check \"$f\" || exit 1; done",
|
|
55
|
+
"lint": "eslint --max-warnings 0 .",
|
|
56
|
+
"test": "node --test 'test/*.test.mjs'",
|
|
57
|
+
"gh:sync": "node bin/cli.mjs sync",
|
|
58
|
+
"format": "prettier --write .",
|
|
59
|
+
"format:check": "prettier --check .",
|
|
60
|
+
"prepare": "husky"
|
|
61
|
+
},
|
|
62
|
+
"dependencies": {
|
|
63
|
+
"@commitlint/cli": "21.2.3"
|
|
64
|
+
},
|
|
65
|
+
"devDependencies": {
|
|
66
|
+
"@ebalzan/config": "0.2.0",
|
|
67
|
+
"danger": "14.0.7",
|
|
68
|
+
"eslint": "10.14.0",
|
|
69
|
+
"git-cliff": "2.14.2",
|
|
70
|
+
"husky": "9.1.7",
|
|
71
|
+
"prettier": "3.9.10"
|
|
72
|
+
},
|
|
73
|
+
"prettier": "@ebalzan/config/prettier"
|
|
74
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
|
3
|
+
"description": "Shared Renovate preset: keeps the lint, format, git-hook and compiler tooling on the same versions in every repo that extends it as github>ebalzan/ci//renovate/tooling#v1. Only the packages listed below are managed; every other dependency is left alone.",
|
|
4
|
+
"extends": ["config:recommended"],
|
|
5
|
+
"baseBranchPatterns": ["dev"],
|
|
6
|
+
"branchPrefix": "chore/renovate-",
|
|
7
|
+
"semanticCommits": "enabled",
|
|
8
|
+
"semanticCommitType": "chore",
|
|
9
|
+
"semanticCommitScope": "deps",
|
|
10
|
+
"rangeStrategy": "pin",
|
|
11
|
+
"minimumReleaseAge": "3 days",
|
|
12
|
+
"schedule": ["before 9am on monday"],
|
|
13
|
+
"dependencyDashboard": true,
|
|
14
|
+
"packageRules": [
|
|
15
|
+
{
|
|
16
|
+
"description": "Only the shared tooling below is managed; every other dependency (framework, runtime, GitHub Actions) stays manual.",
|
|
17
|
+
"matchPackageNames": ["*"],
|
|
18
|
+
"enabled": false
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"description": "One PR per repo for the shared tooling, including the two @ebalzan packages (majors get their own PR). chore keeps the branch type and the PR title in step for the work-PR check.",
|
|
22
|
+
"matchPackageNames": [
|
|
23
|
+
"prettier",
|
|
24
|
+
"eslint",
|
|
25
|
+
"@eslint/js",
|
|
26
|
+
"eslint-config-prettier",
|
|
27
|
+
"eslint-plugin-prettier",
|
|
28
|
+
"typescript-eslint",
|
|
29
|
+
"lint-staged",
|
|
30
|
+
"husky",
|
|
31
|
+
"typescript",
|
|
32
|
+
"@ebalzan/config",
|
|
33
|
+
"@ebalzan/ci"
|
|
34
|
+
],
|
|
35
|
+
"groupName": "dev tooling",
|
|
36
|
+
"semanticCommitType": "chore",
|
|
37
|
+
"enabled": true
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"description": "typescript-eslint (used directly, or through eslint-config-next / eslint-config-expo) only supports typescript <6.1. Raise this once it does.",
|
|
41
|
+
"matchPackageNames": ["typescript"],
|
|
42
|
+
"allowedVersions": "<6.1"
|
|
43
|
+
}
|
|
44
|
+
]
|
|
45
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# GitHub Actions VARIABLES specific to THIS repo. Non-sensitive; committed.
|
|
2
|
+
#
|
|
3
|
+
# The shared defaults (describe model and effort, …) live in
|
|
4
|
+
# __CI_REPO__'s config/actions.env and are read at run time — they are not
|
|
5
|
+
# pushed here. `ebalzan-ci sync` pushes this file as repository variables;
|
|
6
|
+
# a key set here overrides the shared one.
|
|
7
|
+
#
|
|
8
|
+
# Removing a line does not delete the variable on GitHub; run
|
|
9
|
+
# `ebalzan-ci sync --prune`.
|
|
10
|
+
|
|
11
|
+
# This repo's Projects (v2) board number (owned by the repo owner unless
|
|
12
|
+
# AUTO_PROJECT_OWNER says otherwise). auto-pr adds every work PR and the release
|
|
13
|
+
# PR to it and the PR check requires it there. Leave unset to run without a board.
|
|
14
|
+
# AUTO_PROJECT_NUMBER=
|
|
15
|
+
|
|
16
|
+
# Milestone put on every work PR and the release PR (exact title, open); the
|
|
17
|
+
# PR check requires it on both.
|
|
18
|
+
# AUTO_MILESTONE_TITLE=
|
|
19
|
+
|
|
20
|
+
# Work PRs changing more lines than this get a warning (not a failure).
|
|
21
|
+
# AUTO_MAX_CHANGED_LINES=500
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# =============================================================================
|
|
2
|
+
# Auto PR — runs __CI_REPO__'s auto-pr action.
|
|
3
|
+
#
|
|
4
|
+
# On every push to a work branch (feat/…, fix/…, …) it opens or updates the PR
|
|
5
|
+
# into dev: type label, assignee, the AUTO_MILESTONE_TITLE milestone and the
|
|
6
|
+
# AUTO_PROJECT_NUMBER board when set, and an AI-written description. No issue
|
|
7
|
+
# is created. On every push to dev it opens or updates THE release PR, dev →
|
|
8
|
+
# main, titled after the release it will cut (e.g. `feat: release v1.4.0`).
|
|
9
|
+
# Generated by `ebalzan-ci onboard`; the logic lives in the action (it follows
|
|
10
|
+
# __CI_REF__), so this file only holds the trigger, the permissions, the
|
|
11
|
+
# checkout and the secrets. Refresh it with `ebalzan-ci onboard --force
|
|
12
|
+
# .github/workflows/auto-pr.yml`.
|
|
13
|
+
# =============================================================================
|
|
14
|
+
name: Auto PR
|
|
15
|
+
|
|
16
|
+
on:
|
|
17
|
+
push:
|
|
18
|
+
branches-ignore:
|
|
19
|
+
- main
|
|
20
|
+
tags-ignore:
|
|
21
|
+
- '**'
|
|
22
|
+
workflow_dispatch: {}
|
|
23
|
+
|
|
24
|
+
permissions:
|
|
25
|
+
contents: read
|
|
26
|
+
pull-requests: write
|
|
27
|
+
|
|
28
|
+
jobs:
|
|
29
|
+
auto-pr:
|
|
30
|
+
name: Open or update PR
|
|
31
|
+
# Renovate opens and rewrites its own PR on these branches.
|
|
32
|
+
if: ${{ !startsWith(github.ref_name, 'chore/renovate-') }}
|
|
33
|
+
runs-on: __RUNS_ON__
|
|
34
|
+
# Queued, never cancelled: a run cut short could leave its new PR in draft.
|
|
35
|
+
concurrency:
|
|
36
|
+
group: auto-pr-${{ github.ref }}
|
|
37
|
+
cancel-in-progress: false
|
|
38
|
+
env:
|
|
39
|
+
AUTO_BASE_BRANCH: dev
|
|
40
|
+
AUTO_RELEASE_BRANCH: main
|
|
41
|
+
REPO_VARS_JSON: ${{ toJSON(vars) }}
|
|
42
|
+
steps:
|
|
43
|
+
# Full history with the tags: the release PR's version comes from them.
|
|
44
|
+
- uses: actions/checkout@v4
|
|
45
|
+
with:
|
|
46
|
+
fetch-depth: 0
|
|
47
|
+
persist-credentials: false
|
|
48
|
+
|
|
49
|
+
- uses: actions/setup-node@v4
|
|
50
|
+
with:
|
|
51
|
+
node-version: 24
|
|
52
|
+
|
|
53
|
+
- uses: __CI_REPO__/actions/auto-pr@__CI_REF__
|
|
54
|
+
with:
|
|
55
|
+
github-token: ${{ secrets.GH_PROJECT_TOKEN || github.token }}
|
|
56
|
+
anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# =============================================================================
|
|
2
|
+
# Deploy — what ships a release of THIS repo. Yours to edit.
|
|
3
|
+
#
|
|
4
|
+
# release.yml calls it with the new tag after every release. Run it by hand
|
|
5
|
+
# (Actions → Deploy → Run workflow) to redeploy any tag, or to roll back to an
|
|
6
|
+
# older one.
|
|
7
|
+
#
|
|
8
|
+
# Permissions: the block below, for a run by hand. On a release, the `deploy`
|
|
9
|
+
# job of release.yml grants them and this file may not ask for more — change
|
|
10
|
+
# both together. Secrets: all of the repo's, and the `production` environment's.
|
|
11
|
+
#
|
|
12
|
+
# The `production` environment: required reviewers and environment secrets
|
|
13
|
+
# work. Do not limit it to the main branch: a release's run has the ref
|
|
14
|
+
# refs/pull/<n>/merge (it starts from the merged PR), so it would be refused.
|
|
15
|
+
# Generated by `ebalzan-ci onboard`, which never overwrites it (not even with
|
|
16
|
+
# --force): changes here are yours to keep.
|
|
17
|
+
# =============================================================================
|
|
18
|
+
name: Deploy
|
|
19
|
+
|
|
20
|
+
on:
|
|
21
|
+
workflow_call:
|
|
22
|
+
inputs:
|
|
23
|
+
tag:
|
|
24
|
+
description: The release to deploy (vX.Y.Z).
|
|
25
|
+
type: string
|
|
26
|
+
required: true
|
|
27
|
+
workflow_dispatch:
|
|
28
|
+
inputs:
|
|
29
|
+
tag:
|
|
30
|
+
description: The release to deploy (vX.Y.Z).
|
|
31
|
+
type: string
|
|
32
|
+
required: true
|
|
33
|
+
|
|
34
|
+
# Same as the `deploy` job of release.yml.
|
|
35
|
+
permissions:
|
|
36
|
+
contents: read
|
|
37
|
+
id-token: write
|
|
38
|
+
packages: write
|
|
39
|
+
deployments: write
|
|
40
|
+
|
|
41
|
+
jobs:
|
|
42
|
+
deploy:
|
|
43
|
+
name: Deploy
|
|
44
|
+
runs-on: __RUNS_ON__
|
|
45
|
+
environment: production
|
|
46
|
+
# One deploy at a time, never cancelled half-way.
|
|
47
|
+
concurrency:
|
|
48
|
+
group: deploy-production
|
|
49
|
+
cancel-in-progress: false
|
|
50
|
+
steps:
|
|
51
|
+
- uses: actions/checkout@v4
|
|
52
|
+
with:
|
|
53
|
+
ref: ${{ inputs.tag }}
|
|
54
|
+
|
|
55
|
+
- name: Deploy
|
|
56
|
+
env:
|
|
57
|
+
TAG: ${{ inputs.tag }}
|
|
58
|
+
run: echo "Nothing to deploy yet for $TAG — replace this step."
|
|
59
|
+
|
|
60
|
+
# Examples — keep what fits, drop the rest.
|
|
61
|
+
#
|
|
62
|
+
# An npm package (trusted publishing, no token: the job needs
|
|
63
|
+
# id-token: write):
|
|
64
|
+
# - uses: actions/setup-node@v4
|
|
65
|
+
# with:
|
|
66
|
+
# node-version: 24
|
|
67
|
+
# registry-url: https://registry.npmjs.org
|
|
68
|
+
# - run: npm ci
|
|
69
|
+
# - run: npm publish --provenance --access public
|
|
70
|
+
#
|
|
71
|
+
# A container image on GHCR (packages: write):
|
|
72
|
+
# - uses: docker/login-action@v3
|
|
73
|
+
# with:
|
|
74
|
+
# registry: ghcr.io
|
|
75
|
+
# username: ${{ github.actor }}
|
|
76
|
+
# password: ${{ github.token }}
|
|
77
|
+
# - uses: docker/build-push-action@v6
|
|
78
|
+
# with:
|
|
79
|
+
# push: true
|
|
80
|
+
# tags: ghcr.io/${{ github.repository }}:${{ inputs.tag }}
|
|
81
|
+
#
|
|
82
|
+
# A Vercel production deploy (VERCEL_TOKEN, VERCEL_ORG_ID and
|
|
83
|
+
# VERCEL_PROJECT_ID as secrets):
|
|
84
|
+
# - run: npx --yes vercel@latest deploy --prod --token "$VERCEL_TOKEN"
|
|
85
|
+
# env:
|
|
86
|
+
# VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
|
|
87
|
+
# VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
|
|
88
|
+
# VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
|