@vzn/vx-dte 0.0.0 → 0.0.650
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 +923 -1
- package/index.ts +4 -0
- package/package.json +40 -4
- package/src/cpu-account.ts +134 -0
- package/src/dispatch.ts +268 -0
- package/src/docker.ts +156 -0
- package/src/forecast.ts +68 -0
- package/src/github.ts +523 -0
- package/src/gitlab.ts +130 -0
- package/src/index.ts +12 -0
- package/src/init.ts +522 -0
- package/src/local.ts +256 -0
- package/src/messages.ts +219 -0
- package/src/noise.ts +248 -0
- package/src/orchestrator.ts +2359 -0
- package/src/outputs.ts +117 -0
- package/src/planner.ts +988 -0
- package/src/plugin.ts +1018 -0
- package/src/printable.ts +11 -0
- package/src/seal.ts +509 -0
- package/src/strategy.ts +50 -0
- package/src/trace.ts +288 -0
- package/src/tree.ts +78 -0
- package/src/usage.ts +135 -0
- package/src/warm-ups.ts +85 -0
- package/src/wire.ts +1060 -0
- package/src/worker.ts +1038 -0
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
|
+
}
|