@vzn/vx-dte 0.0.0-stage → 0.0.649

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/src/init.ts ADDED
@@ -0,0 +1,522 @@
1
+ // `vx dte init`: the whole CI of a DTE run, written from
2
+ // `dte()` itself: the run's job and its workers', never copied by hand.
3
+ // Each adapter writes its own (`DteAdapter.init`); one that starts workers
4
+ // on this machine (`local()`, `docker()`) needs none.
5
+
6
+ import { existsSync, lstatSync, readlinkSync } from 'node:fs'
7
+ import { mkdir, open, readFile, rename, rm } from 'node:fs/promises'
8
+ import path from 'node:path'
9
+ import { UserError, type Platform } from '@vzn/vx'
10
+ import type { GithubOptions } from './github.js'
11
+ import type { GitlabOptions } from './gitlab.js'
12
+
13
+ export type PackageManager = 'bun' | 'pnpm' | 'yarn' | 'npm'
14
+
15
+ /** What `vx dte init` hands an adapter that writes CI files. */
16
+ export interface DteSetup {
17
+ readonly workspaceRoot: string
18
+ /** The workspace's directory in its repository, '' at its root: where the CI's commands run. */
19
+ readonly dir: string
20
+ /** The relay `dte()` names, if any. */
21
+ readonly relay?: string
22
+ /** `dte()`'s sizes in declared order, with the platform each names. */
23
+ readonly sizes: readonly {
24
+ readonly name: string
25
+ /** Absent: as many as the work needs. */
26
+ readonly max?: number
27
+ readonly platform?: Platform
28
+ }[]
29
+ /** The workspace's package manager, by its lockfile. */
30
+ readonly manager: PackageManager
31
+ /** The tasks a push to the default branch runs (with `--all`) and a change request runs (with `--affected`), space separated; '' is none named yet, so the run fails until they are. */
32
+ readonly tasks: { readonly main: string; readonly change: string }
33
+ /** `owner/repo` (a GitLab project's full path) of the `origin` remote, if it has one. */
34
+ readonly repository?: string
35
+ /** The `origin` remote's host: a self-managed GitLab issues its tokens as itself. */
36
+ readonly host?: string
37
+ }
38
+
39
+ /** What an adapter's `init` gives back: files to write (workspace-relative path to text), then lines to print. */
40
+ export interface DteInit {
41
+ readonly files: Readonly<Record<string, string>>
42
+ readonly next: readonly string[]
43
+ }
44
+
45
+ /** `owner/repo` of the `origin` remote and its host: what the relay's ALLOW names. */
46
+ export function originOf(root: string): { repository?: string; host?: string } {
47
+ const r = Bun.spawnSync(['git', 'config', '--get', 'remote.origin.url'], { cwd: root })
48
+ const url = r.exitCode === 0 ? r.stdout.toString().trim() : ''
49
+ const m = /[:/]([^:]+?\/[^/]+?)(?:\.git)?\/?$/.exec(url.replace(/^[a-z+]+:\/\/[^/]+\//, '/'))
50
+ // An https URL's port is its issuer's; an ssh one's is not.
51
+ const host =
52
+ /^https?:\/\/(?:[^@/]*@)?([^/]+)/.exec(url)?.[1] ??
53
+ /^[a-z+]+:\/\/(?:[^@/]*@)?([^/:]+)/.exec(url)?.[1] ??
54
+ /^(?:[^@/:]*@)?([^/:]+):/.exec(url)?.[1]
55
+ return {
56
+ ...(m?.[1] === undefined ? {} : { repository: m[1] }),
57
+ ...(host === undefined ? {} : { host }),
58
+ }
59
+ }
60
+
61
+ const SITE = 'https://vznjs.github.io/vx/docs'
62
+ const RELAY_DOCS = 'https://vznjs.github.io/vx/docs/relay/'
63
+
64
+ /** What is left after the files: a relay that lets them in, a shared cache, a commit. */
65
+ function nextSteps(relay: string | undefined, allow: string, files: readonly string[]): string[] {
66
+ return [
67
+ '',
68
+ 'Next:',
69
+ relay === undefined
70
+ ? ` 1. A relay, where the run and its workers meet. Deploy your own on Cloudflare's free plan (the Deploy button) or run it with Bun or Docker: ${RELAY_DOCS}`
71
+ : ` 1. Let these workflows use the relay at ${relay}: set its ALLOW.`,
72
+ ` Its ALLOW (a plain var, no secret): ${allow}`,
73
+ ...(relay === undefined
74
+ ? [" Then name it in .vx/workspace.ts: dte({ relay: 'https://<your relay>' })"]
75
+ : []),
76
+ ` 2. A remote cache the run and the workers share: workers hand back outputs only through it. ${SITE}/remote-cache/`,
77
+ ` 3. Commit ${files.join(', ')}, .vx/workspace.ts and the lock (vx lock), and push.`,
78
+ ` How a run spreads over workers: ${SITE}/distributed-task-execution/`,
79
+ ]
80
+ }
81
+
82
+ const TASKS = /^[\w@/#.:*-]+( [\w@/#.:*-]+)*$/
83
+
84
+ /**
85
+ * Asks, in a terminal, what main runs and what a change request runs (main's
86
+ * by default). Elsewhere none are named, and the written run fails until
87
+ * its owner names them.
88
+ */
89
+ export function askTasks(): { main: string; change: string } {
90
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return { main: '', change: '' }
91
+ const ask = (q: string, fallback: string): string => {
92
+ const a = (prompt(q) ?? '').trim().replace(/\s+/g, ' ') || fallback
93
+ if (a !== '' && !TASKS.test(a))
94
+ throw new UserError(`vx dte init: ${JSON.stringify(a)} is not a list of task names`)
95
+ return a
96
+ }
97
+ const main = ask('Tasks to run on main (such as build test):', '')
98
+ const change = ask(
99
+ `Tasks to run on a pull or merge request${main === '' ? '' : ` [${main}]`}:`,
100
+ main,
101
+ )
102
+ return { main, change }
103
+ }
104
+
105
+ export function managerOf(root: string): PackageManager {
106
+ const has = (f: string): boolean => existsSync(path.join(root, f))
107
+ if (has('pnpm-lock.yaml')) return 'pnpm'
108
+ if (has('bun.lock') || has('bun.lockb')) return 'bun'
109
+ if (has('yarn.lock')) return 'yarn'
110
+ if (has('package-lock.json') || has('npm-shrinkwrap.json')) return 'npm'
111
+ throw new UserError(
112
+ 'vx dte init: no lockfile at the workspace root (bun.lock, pnpm-lock.yaml, yarn.lock, package-lock.json): workers install from it',
113
+ )
114
+ }
115
+
116
+ /** The frozen install a fresh runner does, as shell lines. */
117
+ function installLines(manager: PackageManager): readonly string[] {
118
+ if (manager === 'bun') return ['bun install --frozen-lockfile']
119
+ if (manager === 'pnpm') return ['pnpm install --frozen-lockfile']
120
+ // Yarn 1 ignores --immutable; Berry takes --frozen-lockfile as its alias.
121
+ if (manager === 'yarn') return ['corepack enable', 'yarn install --frozen-lockfile']
122
+ return ['npm ci']
123
+ }
124
+
125
+ /**
126
+ * How a runner calls the workspace's own vx, under every manager: Yarn
127
+ * Berry's `yarn vx` runs a bin with node whatever its shebang, and Bun
128
+ * reads no Plug'n'Play, so a Yarn workspace needs `node_modules` anyway.
129
+ */
130
+ const VX = 'bun vx'
131
+
132
+ /**
133
+ * Writes what `init` returned. A file that exists with other bytes is kept
134
+ * (a step its owner added, such as a cache restore) unless `force`.
135
+ */
136
+ export async function writeInit(
137
+ root: string,
138
+ init: DteInit,
139
+ force: boolean,
140
+ out: (line: string) => void,
141
+ ): Promise<number> {
142
+ const kept: string[] = []
143
+ // A repo can carry `ci.yml -> /anywhere`: refused before any write, as
144
+ // core's own writers do (`writeNoFollow`).
145
+ for (const rel of Object.keys(init.files)) {
146
+ const link = linkOf(path.join(root, rel))
147
+ if (link !== undefined) {
148
+ throw new UserError(
149
+ `vx dte init: refusing to write ${rel}: it is a symlink to ${link}; remove the link and run again`,
150
+ )
151
+ }
152
+ }
153
+ for (const [rel, text] of Object.entries(init.files)) {
154
+ const at = path.join(root, rel)
155
+ // quiet: a file that cannot be read is one to write
156
+ const had = await readFile(at, 'utf8').catch(() => undefined)
157
+ if (had === text) {
158
+ out(`vx dte init: ${rel} is up to date`)
159
+ continue
160
+ }
161
+ if (had !== undefined && !force) {
162
+ kept.push(rel)
163
+ continue
164
+ }
165
+ await mkdir(path.dirname(at), { recursive: true })
166
+ await replace(at, text)
167
+ out(`vx dte init: wrote ${rel}`)
168
+ }
169
+ if (kept.length > 0) {
170
+ throw new UserError(
171
+ `vx dte init: ${kept.join(', ')} already exists with other contents: compare it with vx dte init --dry, or overwrite it with --force`,
172
+ )
173
+ }
174
+ for (const line of init.next) out(line)
175
+ return 0
176
+ }
177
+
178
+ // Each action at a commit, never a tag its maintainer (or whoever takes
179
+ // their account) can move; the comment is the release, for an updater.
180
+ const CHECKOUT = 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1'
181
+ const SETUP_BUN = 'oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0'
182
+ const SETUP_PNPM = 'pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0'
183
+
184
+ /**
185
+ * The worker's first step. A run that ended without cancelling its workers
186
+ * (its API limit spent, a kill) left them queued, each to hold a runner
187
+ * through its setup for no one (2026-10-28): one that starts after its run
188
+ * cancels its own workflow run, its queued fellows with it. An API that
189
+ * answers nothing leaves it to the relay, which turns it away.
190
+ */
191
+ const LEAVE_IF_OVER = ` - name: Leave if the run is over
192
+ if: inputs.parent != ''
193
+ env: { GH_TOKEN: '\${{ github.token }}', PARENT: '\${{ inputs.parent }}' }
194
+ run: |
195
+ runs="$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/runs"
196
+ status=$(curl -fsS -H "authorization: Bearer $GH_TOKEN" "$runs/$PARENT" | jq -r .status) || exit 0
197
+ [ "$status" = completed ] || exit 0
198
+ echo "::notice::run $PARENT is over: cancelling this one"
199
+ curl -fsS -o /dev/null -X POST -H "authorization: Bearer $GH_TOKEN" "$runs/$GITHUB_RUN_ID/cancel"
200
+ `
201
+
202
+ /**
203
+ * The run's last step: github() lists each worker run it dispatched in
204
+ * RUNNER_TEMP, since a run cancelled or timed out can die before it
205
+ * cancels them itself.
206
+ */
207
+ const CANCEL_WORKERS = ` - name: Cancel the workers
208
+ if: cancelled()
209
+ run: |
210
+ [ -f "$RUNNER_TEMP/vx-dte-runs" ] || exit 0
211
+ while read -r run; do curl -sS -o /dev/null -X POST -H "authorization: Bearer $GITHUB_TOKEN" "$GITHUB_API_URL$run/cancel" || true; done < "$RUNNER_TEMP/vx-dte-runs"
212
+ `
213
+ /** Where `file` links to, or undefined for a file or nothing. */
214
+ function linkOf(file: string): string | undefined {
215
+ try {
216
+ return lstatSync(file).isSymbolicLink() ? readlinkSync(file) : undefined
217
+ } catch {
218
+ // quiet: nothing there to write through
219
+ return undefined
220
+ }
221
+ }
222
+
223
+ /** A temp beside `file`, made with O_EXCL and renamed over it: never written through a link. */
224
+ async function replace(file: string, text: string): Promise<void> {
225
+ const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`
226
+ try {
227
+ const fh = await open(tmp, 'wx')
228
+ try {
229
+ await fh.writeFile(text)
230
+ } finally {
231
+ await fh.close()
232
+ }
233
+ await rename(tmp, file)
234
+ } catch (err) {
235
+ await rm(tmp, { force: true }).catch(() => {})
236
+ throw err
237
+ }
238
+ }
239
+
240
+ /** GitHub's hosted runner of each platform; a size naming none runs on Linux x64. */
241
+ const RUNNERS: Record<Platform, string> = {
242
+ 'linux-x64': 'ubuntu-latest',
243
+ 'linux-arm64': 'ubuntu-24.04-arm',
244
+ 'darwin-x64': 'macos-15-intel',
245
+ 'darwin-arm64': 'macos-latest',
246
+ }
247
+
248
+ /** The workers' workflow: what `dispatch` sends, as `vx dte worker` reads it. */
249
+ export function githubInit(options: GithubOptions, setup: DteSetup): DteInit {
250
+ const workflow = options.workflow ?? 'vx-worker.yml'
251
+ const runners = Object.fromEntries(
252
+ setup.sizes.map((s) => {
253
+ const label: unknown = options.runsOn?.[s.name]
254
+ if (label !== undefined && (typeof label !== 'string' || label === ''))
255
+ throw new UserError(`vx dte init: github() runsOn.${s.name} is a runner label`)
256
+ return [s.name, label ?? RUNNERS[s.platform ?? 'linux-x64']]
257
+ }),
258
+ )
259
+ // A workspace in a subdirectory: CI files live at the repository's root,
260
+ // and their commands run in the workspace.
261
+ const inDir =
262
+ setup.dir === ''
263
+ ? ''
264
+ : ` defaults:\n run:\n working-directory: ${quoted(setup.dir)}\n`
265
+ const install = [
266
+ ...(setup.manager === 'pnpm'
267
+ ? [pnpmAction(setup.dir)]
268
+ : [
269
+ ' # Every project: only pnpm selects by a git diff, as',
270
+ ' # `pnpm install --frozen-lockfile --filter "...[origin/main]"`',
271
+ ' # (fetch origin/main first: the checkout is one commit).',
272
+ ]),
273
+ ...installLines(setup.manager).map((l) => ` - run: ${l}`),
274
+ ]
275
+ const text = `# Written by vx dte init from dte() in .vx/workspace.ts: the workers of a
276
+ # DTE run, which github() dispatches once per size. Run vx dte init
277
+ # again after changing the sizes.
278
+ name: vx-worker
279
+
280
+ on:
281
+ workflow_dispatch:
282
+ inputs:
283
+ run: { required: true }
284
+ relay: { required: true }
285
+ sha: { required: true }
286
+ size: { required: true }
287
+ workers: { required: true }
288
+ # The variables github({ env }) hands the workers, as a JSON object.
289
+ env: { required: false, default: '{}' }
290
+ # The run's attempt, which a worker reads first.
291
+ parent: { required: false, default: '' }
292
+
293
+ permissions:
294
+ contents: read
295
+ id-token: write
296
+
297
+ jobs:
298
+ worker:
299
+ # The run reads a job that failed before its worker joined by this name.
300
+ name: \${{ matrix.worker }}
301
+ # A job's permissions replace the workflow's, so it names all three.
302
+ permissions:
303
+ contents: read
304
+ id-token: write
305
+ # A worker of a run that is over cancels its own run.
306
+ actions: write
307
+ runs-on: \${{ fromJSON('${JSON.stringify(runners)}')[inputs.size] }}
308
+ timeout-minutes: 30
309
+ ${inDir} strategy:
310
+ fail-fast: false
311
+ matrix:
312
+ worker: \${{ fromJSON(inputs.workers) }}
313
+ env:
314
+ VX_DTE_RUN: \${{ inputs.run }}
315
+ VX_DTE_RELAY: \${{ inputs.relay }}
316
+ VX_DTE_WORKER: \${{ matrix.worker }}
317
+ steps:
318
+ ${LEAVE_IF_OVER} - uses: ${CHECKOUT}
319
+ with: { ref: '\${{ inputs.sha }}', persist-credentials: false }
320
+ - env: { HANDED: '\${{ inputs.env }}' }
321
+ run: |
322
+ jq -r 'to_entries[] | if (.key | test("^[A-Za-z_][A-Za-z0-9_]*$")) and (.value | type == "string") and (.value | test("[\\r\\n]") | not) then "\\(.key)=\\(.value)" else error("env \\(.key): a name, and a string of one line") end' <<<"$HANDED" >> "$GITHUB_ENV"
323
+ - uses: ${SETUP_BUN}
324
+ ${install.join('\n')}
325
+ - run: ${VX} dte worker
326
+ `
327
+ const named = setup.tasks.main !== '' && setup.tasks.change !== ''
328
+ const run = `# Written by vx dte init: the run. main and a merge queue run every task;
329
+ # a pull request runs what it changed and its dependents. This job only
330
+ # orchestrates: every task runs on a worker (${workflow}).
331
+ name: ci
332
+
333
+ on:
334
+ pull_request:
335
+ merge_group:
336
+ push:
337
+ branches: [main]
338
+
339
+ # A newer push to a pull request cancels its older run, and its workers.
340
+ concurrency:
341
+ group: ci-\${{ github.event.pull_request.number || github.ref }}
342
+ cancel-in-progress: \${{ github.event_name == 'pull_request' }}
343
+
344
+ jobs:
345
+ vx:
346
+ runs-on: ubuntu-latest
347
+ timeout-minutes: 30
348
+ ${inDir} permissions:
349
+ contents: read
350
+ # The relay and the workers: each side proves itself by its OIDC token.
351
+ id-token: write
352
+ # Dispatching and cancelling the workers.
353
+ actions: write
354
+ env:
355
+ GITHUB_TOKEN: \${{ github.token }}
356
+ steps:
357
+ # One commit: --affected fetches its base itself.
358
+ - uses: ${CHECKOUT}
359
+ with: { persist-credentials: false }
360
+ - uses: ${SETUP_BUN}
361
+ ${rootInstall(setup.manager, setup.dir).join('\n')}
362
+ - run: ${VX} lock --check
363
+ ${
364
+ named
365
+ ? ''
366
+ : ` # Name the tasks to run, as '${VX} run build test --affected': with
367
+ # none named, vx fails, listing the workspace's tasks.
368
+ `
369
+ } - run: ${VX} run \${{ github.event_name == 'pull_request' && '${both(setup.tasks.change, '--affected')}' || '${both(setup.tasks.main, '--all')}' }}
370
+ ${CANCEL_WORKERS}`
371
+ const repo = setup.repository ?? '<owner>/<repo>'
372
+ const files = { '.github/workflows/ci.yml': run, [`.github/workflows/${workflow}`]: text }
373
+ return {
374
+ files,
375
+ next: nextSteps(
376
+ setup.relay,
377
+ `${GITHUB_ISSUER}:${repo}:ci.yml ${GITHUB_ISSUER}:${repo}:${workflow}`,
378
+ Object.keys(files),
379
+ ),
380
+ }
381
+ }
382
+
383
+ /** A YAML single-quoted string, as oxfmt writes one. */
384
+ function quoted(s: string): string {
385
+ return `'${s.replaceAll("'", "''")}'`
386
+ }
387
+
388
+ /** Tasks then a flag, either possibly none. */
389
+ function both(tasks: string, flag: string): string {
390
+ return tasks === '' ? flag : `${tasks} ${flag}`
391
+ }
392
+
393
+ const GITHUB_ISSUER = 'https://token.actions.githubusercontent.com'
394
+
395
+ /** The run's job needs vx and the plugins only: no task runs there. */
396
+ function rootInstall(manager: DteSetup['manager'], dir: string): string[] {
397
+ if (manager === 'bun') return [" - run: bun install --frozen-lockfile --filter './'"]
398
+ return [
399
+ ...(manager === 'pnpm' ? [pnpmAction(dir)] : []),
400
+ ...installLines(manager).map((l) => ` - run: ${l}`),
401
+ ]
402
+ }
403
+
404
+ /** pnpm's setup step, which reads the pnpm version from the workspace's `package.json`. */
405
+ function pnpmAction(dir: string): string {
406
+ return dir === ''
407
+ ? ` - uses: ${SETUP_PNPM}`
408
+ : ` - uses: ${SETUP_PNPM}\n with: { package_json_file: ${quoted(`${dir}/package.json`)} }`
409
+ }
410
+
411
+ /**
412
+ * The project's one pipeline file: the run's `vx` job on a merge request
413
+ * or the default branch, and a job per worker size, which only a pipeline
414
+ * the run triggers (`VX_DTE_SIZE`) runs.
415
+ */
416
+ export function gitlabInit(options: GitlabOptions, setup: DteSetup): DteInit {
417
+ if (setup.relay === undefined)
418
+ throw new UserError(
419
+ 'vx dte init: gitlab() needs dte({ relay }) first, since each job asks its ID token for the relay: deploy one (https://vznjs.github.io/vx/docs/relay/) and name it',
420
+ )
421
+ const relay = setup.relay
422
+ const named = setup.tasks.main !== '' && setup.tasks.change !== ''
423
+ // oven/bun has no Node, npm or corepack; another manager's jobs run on
424
+ // Node's image, which has git too, and add Bun, which runs vx.
425
+ const install =
426
+ setup.manager === 'bun'
427
+ ? installLines('bun')
428
+ : [
429
+ 'npm install -g --silent bun@1.4',
430
+ ...(setup.manager === 'pnpm' ? ['corepack enable'] : []),
431
+ ...installLines(setup.manager),
432
+ ]
433
+ const image =
434
+ setup.manager === 'bun'
435
+ ? 'oven/bun:1.4.4@sha256:1d22548f177f4ff38e0c15baa3c2c46c6c2060ba2d1bc573bb4720b407548f1d'
436
+ : 'node:22.23.4@sha256:18a6d90fd60542269850c80ca03b29b1d9b14d6b221ac7ee8e35b0d33ef904bd'
437
+ const root = setup.manager === 'bun' ? ["bun install --frozen-lockfile --filter './'"] : install
438
+ const lines = (l: readonly string[]): string => l.map((x) => ` - ${x}`).join('\n')
439
+ // A workspace in a subdirectory: the job's commands run in it.
440
+ const cd = setup.dir === '' ? [] : [`cd ${quoted(setup.dir)}`]
441
+ // oven/bun has no git, which the run's snapshot and a worker's fetch need.
442
+ const git =
443
+ 'command -v git || (apt-get update -qq && apt-get install -qq -y --no-install-recommends git)'
444
+ const workers = setup.sizes.map((s) => {
445
+ const tags = options.tags?.[s.name]
446
+ if (tags !== undefined && (!Array.isArray(tags) || tags.some((t) => typeof t !== 'string')))
447
+ throw new UserError(`vx dte init: gitlab() tags.${s.name} is a list of runner tags`)
448
+ // A matrix takes at most 200 jobs; a wave picks its slots.
449
+ if (s.max === undefined || s.max < 1 || s.max > 200)
450
+ throw new UserError(
451
+ `vx dte init: gitlab() needs workers.${s.name}.max, 1 to 200: its job is a matrix of that many slots`,
452
+ )
453
+ const slots = Array.from({ length: s.max }, (_, i) => quoted(String(i + 1)))
454
+ return `
455
+ ${quoted(`vx-worker ${s.name}`)}:
456
+ extends: .vx-worker
457
+ parallel:
458
+ matrix:
459
+ - VX_DTE_SLOT: [${slots.join(', ')}]
460
+ rules: [{ if: $VX_DTE_SIZE == ${JSON.stringify(s.name)} && $VX_DTE_SLOT =~ $VX_DTE_PICK }]
461
+ ${tags === undefined ? '' : ` tags: [${tags.map(quoted).join(', ')}]\n`}`
462
+ })
463
+ const ci = `# Written by vx dte init from dte() in .vx/workspace.ts. The vx job
464
+ # runs no task: once it is on the relay it triggers a pipeline of this
465
+ # file for each wave of workers of a size, which runs only those slots
466
+ # of that size's job.${
467
+ named
468
+ ? ''
469
+ : `
470
+ # Name the tasks in the vx job, as 'build test --all': with none named,
471
+ # vx fails, listing them.`
472
+ }
473
+ workflow:
474
+ rules:
475
+ - if: $CI_PIPELINE_SOURCE == "merge_request_event"
476
+ - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
477
+ - if: $CI_PIPELINE_SOURCE == "pipeline" && $VX_DTE_SIZE
478
+
479
+ # The image needs Bun; a job adds git if it lacks it. By digest: a tag
480
+ # can move under the same name.
481
+ default:
482
+ image: ${image}
483
+
484
+ vx:
485
+ rules:
486
+ - if: $VX_DTE_SIZE
487
+ when: never
488
+ - if: $CI_PIPELINE_SOURCE == "merge_request_event"
489
+ variables: { VX_TASKS: '${both(setup.tasks.change, '--affected')}' }
490
+ - when: on_success
491
+ variables: { VX_TASKS: '${both(setup.tasks.main, '--all')}' }
492
+ id_tokens: { VX_DTE_TOKEN: { aud: ${relay} } }
493
+ before_script:
494
+ ${lines([git, ...cd, ...root])}
495
+ script:
496
+ - ${VX} lock --check
497
+ - ${VX} run $VX_TASKS
498
+
499
+ # A worker runs at the run's commit, which its branch may have moved past.
500
+ .vx-worker:
501
+ variables: { GIT_DEPTH: 1 }
502
+ id_tokens: { VX_DTE_TOKEN: { aud: ${relay} } }
503
+ before_script:
504
+ ${lines([git])}
505
+ - git fetch -q --depth 1 origin $VX_DTE_SHA
506
+ - git checkout -q FETCH_HEAD
507
+ ${lines([...cd, ...install])}
508
+ script: [${VX} dte worker]
509
+ ${workers.join('')}`.replace(/\n+$/, '\n')
510
+ const next = nextSteps(
511
+ relay,
512
+ `https://${setup.host ?? 'gitlab.com'}:${setup.repository ?? '<group>/<project>'}:.gitlab-ci.yml`,
513
+ ['.gitlab-ci.yml'],
514
+ )
515
+ // A project made since GitLab 17.7 lets no one set them: every trigger is refused.
516
+ next.splice(
517
+ -1,
518
+ 0,
519
+ ' 4. In Settings > CI/CD > Variables: set "Minimum role to use pipeline variables" to Developer: each pipeline the run triggers takes its workers\' variables.',
520
+ )
521
+ return { files: { '.gitlab-ci.yml': ci }, next }
522
+ }