@ebalzan/ci 0.0.0-stage → 1.0.1

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/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": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.1",
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 }}