@vzn/vx-ci 0.0.646 → 0.0.648
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/README.md +98 -46
- package/package.json +4 -5
- package/src/cache-scope.ts +83 -17
- package/src/gitlab.ts +52 -17
- package/src/index.ts +30 -7
- package/src/plugin.ts +30 -101
- package/src/setup.ts +208 -0
- package/src/sink.ts +5 -28
- package/src/summary.ts +129 -10
- package/src/checks.ts +0 -199
package/src/plugin.ts
CHANGED
|
@@ -1,22 +1,17 @@
|
|
|
1
1
|
// The `github()` telemetry plugin. Contributes one observe-only sink that
|
|
2
2
|
// writes the run as a GitHub Actions job summary. Off Actions
|
|
3
3
|
// (`GITHUB_ACTIONS` not 'true') the factory returns undefined, which
|
|
4
|
-
// `plugins` skips: no
|
|
5
|
-
//
|
|
4
|
+
// `plugins` skips: no hook, no load, so declaring `github()` costs nothing
|
|
5
|
+
// on a laptop. index.ts checks its options everywhere.
|
|
6
6
|
import { appendFile, stat } from 'node:fs/promises'
|
|
7
7
|
import { createRequire } from 'node:module'
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
definePlugin,
|
|
12
|
-
UserError,
|
|
13
|
-
type VxPlugin,
|
|
14
|
-
} from '@vzn/vx'
|
|
15
|
-
import type { FetchFn } from './checks.js'
|
|
8
|
+
import { homedir } from 'node:os'
|
|
9
|
+
import path from 'node:path'
|
|
10
|
+
import { definePlugin, type VxPlugin } from '@vzn/vx'
|
|
16
11
|
import { githubBranch } from './cache-scope.js'
|
|
17
12
|
|
|
18
|
-
// Every run evaluates `.vx/workspace.ts`; the sink
|
|
19
|
-
//
|
|
13
|
+
// Every run evaluates `.vx/workspace.ts`; the sink and the summary
|
|
14
|
+
// renderer load only where a summary file is set. The
|
|
20
15
|
// telemetry hook is synchronous, so the module loads by `require`.
|
|
21
16
|
const loadSink = (): typeof import('./sink.js') => createRequire(import.meta.url)('./sink.js')
|
|
22
17
|
|
|
@@ -29,16 +24,6 @@ export interface GithubPluginOptions {
|
|
|
29
24
|
summaryFile?: string
|
|
30
25
|
/** Heading for the summary block. Default: `'vx run'`. */
|
|
31
26
|
title?: string
|
|
32
|
-
/**
|
|
33
|
-
* Also create a completed check-run on the built commit (Checks API).
|
|
34
|
-
* Default: on when the environment carries `GITHUB_TOKEN` +
|
|
35
|
-
* `GITHUB_REPOSITORY` + `GITHUB_SHA` (the workflow must grant
|
|
36
|
-
* `permissions: checks: write`); set `false` to opt out, `true` to warn
|
|
37
|
-
* when the environment is missing instead of silently skipping.
|
|
38
|
-
*/
|
|
39
|
-
checks?: boolean
|
|
40
|
-
/** Check-run name. Default: `'vx'`. */
|
|
41
|
-
checkName?: string
|
|
42
27
|
/**
|
|
43
28
|
* Name the run's cache scope and base commit from the Actions event: none
|
|
44
29
|
* on the default branch, else `<owner>/<repo>:<branch>` (a fork's names
|
|
@@ -47,100 +32,44 @@ export interface GithubPluginOptions {
|
|
|
47
32
|
* `false` opts out.
|
|
48
33
|
*/
|
|
49
34
|
cacheScope?: boolean
|
|
50
|
-
/** Test seam — inject the append. Defaults to fs appendFile. */
|
|
51
|
-
append?: (file: string, markdown: string) => Promise<void>
|
|
52
|
-
/** Test seam — inject the Checks API transport. Defaults to fetch. */
|
|
53
|
-
fetchFn?: FetchFn
|
|
54
|
-
/**
|
|
55
|
-
* Test seam — bytes the summary file already holds. Defaults to its size
|
|
56
|
-
* on disk; with `append` injected, 0 (that writer owns the file).
|
|
57
|
-
*/
|
|
58
|
-
sizeOf?: (file: string) => Promise<number>
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Why a string cannot be an HTTP header value, or null. Bun's `fetch`
|
|
63
|
-
* refuses a line break, a NUL or a character past Latin-1 once it has
|
|
64
|
-
* trimmed the ends (measured on 1.4.2), and quotes the value in its error.
|
|
65
|
-
*/
|
|
66
|
-
function headerValueFault(value: string): string | null {
|
|
67
|
-
let past = false
|
|
68
|
-
for (const ch of value.trim()) {
|
|
69
|
-
const c = ch.codePointAt(0)!
|
|
70
|
-
if (c === 0x0a || c === 0x0d || c === 0) return 'a line break or NUL'
|
|
71
|
-
if (c > 0xff) past = true
|
|
72
|
-
}
|
|
73
|
-
return past ? 'a character past Latin-1' : null
|
|
74
35
|
}
|
|
75
36
|
|
|
76
37
|
/** Each option `GithubPluginOptions` names, with its kind: derived from the type, so the two cannot drift. */
|
|
77
|
-
const GITHUB_PLUGIN_KEYS: PluginOptionKinds<GithubPluginOptions> = {
|
|
78
|
-
summaryFile: 'string',
|
|
79
|
-
title: 'string',
|
|
80
|
-
checks: 'boolean',
|
|
81
|
-
checkName: 'string',
|
|
82
|
-
cacheScope: 'boolean',
|
|
83
|
-
append: 'function',
|
|
84
|
-
fetchFn: 'function',
|
|
85
|
-
sizeOf: 'function',
|
|
86
|
-
}
|
|
87
|
-
|
|
88
38
|
export function github(options: GithubPluginOptions = {}): VxPlugin | undefined {
|
|
89
39
|
if (process.env['GITHUB_ACTIONS'] !== 'true') return undefined
|
|
90
|
-
|
|
91
|
-
// '' passes the kind check but reads wrong: `??` keeps it, so summaryFile
|
|
92
|
-
// declined instead of using GITHUB_STEP_SUMMARY, checkName POSTed a name
|
|
93
|
-
// GitHub refuses (422) and title rendered an empty heading.
|
|
94
|
-
for (const key of ['summaryFile', 'title', 'checkName'] as const) {
|
|
95
|
-
if (options[key] === '')
|
|
96
|
-
throw new UserError(`github() option "${key}" must be a non-empty string, got ""`)
|
|
97
|
-
}
|
|
40
|
+
// Its options were checked by index.ts's github(), the only caller.
|
|
98
41
|
return definePlugin(import.meta, {
|
|
99
|
-
|
|
42
|
+
commands: {
|
|
43
|
+
// Kept where actions/cache can save it: a hit installs the
|
|
44
|
+
// packages from it, no index fetch (14 to 52 s, 2026-10-22).
|
|
45
|
+
setup: {
|
|
46
|
+
description: 'github: install and prove the sandbox runtime on this runner',
|
|
47
|
+
run: async (argv) =>
|
|
48
|
+
(await import('./setup.js')).runSetup(
|
|
49
|
+
'github',
|
|
50
|
+
argv,
|
|
51
|
+
path.join(homedir(), '.cache', 'vx', 'setup-debs'),
|
|
52
|
+
),
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
async config(workspace, ctx) {
|
|
100
56
|
if (options.cacheScope === false) return
|
|
101
|
-
ctx.setBranch(await githubBranch(process.env))
|
|
57
|
+
ctx.setBranch(await githubBranch(process.env, workspace.baseBranch))
|
|
102
58
|
},
|
|
103
59
|
telemetry(ctx) {
|
|
104
60
|
const file = options.summaryFile ?? process.env['GITHUB_STEP_SUMMARY']
|
|
105
61
|
if (file === undefined || file === '') return undefined
|
|
106
|
-
const
|
|
107
|
-
const { GithubSummarySink, resolveCheckRunEnv } = loadSink()
|
|
108
|
-
let check: ConstructorParameters<typeof GithubSummarySink>[4]
|
|
109
|
-
if (options.checks !== false) {
|
|
110
|
-
const env = resolveCheckRunEnv(process.env)
|
|
111
|
-
// fetch refuses a token no header can carry and QUOTES it in its
|
|
112
|
-
// error, which the check-run warning printed (item 928).
|
|
113
|
-
const fault = env === null ? null : headerValueFault(env.token)
|
|
114
|
-
if (fault !== null) {
|
|
115
|
-
ctx.warn(
|
|
116
|
-
`vx-ci: GITHUB_TOKEN holds ${fault}, which no HTTP header can carry — no check-run will be created (the token is not printed)`,
|
|
117
|
-
)
|
|
118
|
-
} else if (env !== null) {
|
|
119
|
-
check = {
|
|
120
|
-
env,
|
|
121
|
-
name: options.checkName ?? 'vx',
|
|
122
|
-
fetchFn: options.fetchFn ?? (fetch as unknown as FetchFn),
|
|
123
|
-
}
|
|
124
|
-
} else if (options.checks === true) {
|
|
125
|
-
ctx.warn(
|
|
126
|
-
'vx-ci: checks requested but GITHUB_TOKEN / GITHUB_REPOSITORY / GITHUB_SHA are not all set — no check-run will be created',
|
|
127
|
-
)
|
|
128
|
-
}
|
|
129
|
-
}
|
|
62
|
+
const { GithubSummarySink } = loadSink()
|
|
130
63
|
return new GithubSummarySink(
|
|
131
64
|
file,
|
|
132
65
|
options.title ?? 'vx run',
|
|
133
|
-
|
|
66
|
+
(f, md) => appendFile(f, md, 'utf8'),
|
|
134
67
|
(m) => ctx.warn(m),
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
stat(f).then(
|
|
141
|
-
(st) => st.size,
|
|
142
|
-
() => 0,
|
|
143
|
-
)),
|
|
68
|
+
(f) =>
|
|
69
|
+
stat(f).then(
|
|
70
|
+
(st) => st.size,
|
|
71
|
+
() => 0,
|
|
72
|
+
),
|
|
144
73
|
)
|
|
145
74
|
},
|
|
146
75
|
})
|
package/src/setup.ts
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// `vx setup github` / `vx setup gitlab`: what a CI machine needs before
|
|
2
|
+
// `vx run` can run a sandboxed task. On Linux: bubblewrap, socat, strace
|
|
3
|
+
// and ripgrep, bwrap allowed its user namespaces, and a proof it runs. On
|
|
4
|
+
// macOS the sandbox is built in. A step of the job, before vx runs; the
|
|
5
|
+
// verb is the plugin's, so a repo on neither CI has none.
|
|
6
|
+
|
|
7
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
8
|
+
import { mkdir, writeFile } from 'node:fs/promises'
|
|
9
|
+
import os from 'node:os'
|
|
10
|
+
import path from 'node:path'
|
|
11
|
+
import { ui, UserError } from '@vzn/vx'
|
|
12
|
+
|
|
13
|
+
const PACKAGES = ['bubblewrap', 'socat', 'strace', 'ripgrep']
|
|
14
|
+
const BINARIES = ['bwrap', 'socat', 'strace', 'rg']
|
|
15
|
+
|
|
16
|
+
/** A command's exit code; it writes to this process's stdout and stderr. */
|
|
17
|
+
export type Shell = (argv: readonly string[]) => Promise<number>
|
|
18
|
+
|
|
19
|
+
const shell: Shell = (argv) =>
|
|
20
|
+
Bun.spawn([...argv], { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' }).exited
|
|
21
|
+
|
|
22
|
+
export interface SetupOptions {
|
|
23
|
+
readonly ci: 'github' | 'gitlab'
|
|
24
|
+
/** Where fetched packages are kept, for the job's cache to save and restore. */
|
|
25
|
+
readonly debs: string
|
|
26
|
+
readonly platform?: NodeJS.Platform
|
|
27
|
+
readonly root?: boolean
|
|
28
|
+
readonly sh?: Shell
|
|
29
|
+
readonly has?: (bin: string) => boolean
|
|
30
|
+
/** GitHub's step output file: `fetched=true` when packages were fetched. */
|
|
31
|
+
readonly output?: string | undefined
|
|
32
|
+
readonly print?: (line: string) => void
|
|
33
|
+
/** Whether the kernel lets a program make user namespaces only under an AppArmor profile. */
|
|
34
|
+
readonly restricted?: () => boolean
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const userns = (): boolean => {
|
|
38
|
+
try {
|
|
39
|
+
return (
|
|
40
|
+
readFileSync('/proc/sys/kernel/apparmor_restrict_unprivileged_userns', 'utf8').trim() === '1'
|
|
41
|
+
)
|
|
42
|
+
} catch {
|
|
43
|
+
return false
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function setup(o: SetupOptions): Promise<number> {
|
|
48
|
+
const print = o.print ?? ((l: string) => ui.say('info', l, 'out'))
|
|
49
|
+
const platform = o.platform ?? process.platform
|
|
50
|
+
if (platform === 'darwin') {
|
|
51
|
+
print('vx setup: macOS has its sandbox built in; nothing to set up')
|
|
52
|
+
return 0
|
|
53
|
+
}
|
|
54
|
+
if (platform !== 'linux') throw new UserError(`vx setup: ${platform} has no vx sandbox`)
|
|
55
|
+
const sh = o.sh ?? shell
|
|
56
|
+
const has = o.has ?? ((bin: string) => Bun.which(bin) !== null)
|
|
57
|
+
const sudo = (o.root ?? process.getuid?.() === 0) ? [] : ['sudo']
|
|
58
|
+
const run = async (argv: readonly string[]): Promise<void> => {
|
|
59
|
+
const code = await sh([...sudo, ...argv])
|
|
60
|
+
if (code !== 0) throw new UserError(`vx setup: \`${argv.join(' ')}\` exited ${code}`)
|
|
61
|
+
}
|
|
62
|
+
let fetched = false
|
|
63
|
+
const missing = BINARIES.filter((b) => !has(b))
|
|
64
|
+
const debs = readdir(o.debs).filter((f) => f.endsWith('.deb'))
|
|
65
|
+
// What the job's cache restored, else fetched: kept from an older
|
|
66
|
+
// image, they may not install, and a set fetched where the image had a
|
|
67
|
+
// package already holds no deb of it.
|
|
68
|
+
const kept =
|
|
69
|
+
missing.length > 0 &&
|
|
70
|
+
debs.length > 0 &&
|
|
71
|
+
has('dpkg') &&
|
|
72
|
+
(await sh([...sudo, 'dpkg', '-i', ...debs.map((f) => path.join(o.debs, f))])) === 0 &&
|
|
73
|
+
BINARIES.every((b) => has(b))
|
|
74
|
+
if (missing.length > 0 && !kept) {
|
|
75
|
+
if (has('apt-get')) {
|
|
76
|
+
await run(['rm', '-rf', o.debs])
|
|
77
|
+
// man-db re-indexes every page on each install: seconds for nothing.
|
|
78
|
+
await run(['rm', '-f', '/var/lib/man-db/auto-update'])
|
|
79
|
+
// Only the distribution's own index is needed: a third-party source
|
|
80
|
+
// (Chrome, Microsoft on GitHub's images) serving a stale index fails
|
|
81
|
+
// `apt-get update` before vx runs at all (2026-09-09).
|
|
82
|
+
if (existsSync('/etc/apt/sources.list.d/ubuntu.sources'))
|
|
83
|
+
await run([
|
|
84
|
+
'find',
|
|
85
|
+
'/etc/apt/sources.list.d',
|
|
86
|
+
'-type',
|
|
87
|
+
'f',
|
|
88
|
+
'!',
|
|
89
|
+
'-name',
|
|
90
|
+
'ubuntu.sources',
|
|
91
|
+
'-delete',
|
|
92
|
+
])
|
|
93
|
+
await run(['apt-get', 'update'])
|
|
94
|
+
await mkdir(path.join(o.debs, 'partial'), { recursive: true })
|
|
95
|
+
await run([
|
|
96
|
+
'apt-get',
|
|
97
|
+
'install',
|
|
98
|
+
'-y',
|
|
99
|
+
'-o',
|
|
100
|
+
`Dir::Cache::Archives=${o.debs}`,
|
|
101
|
+
'-o',
|
|
102
|
+
'APT::Keep-Downloaded-Packages=true',
|
|
103
|
+
...PACKAGES,
|
|
104
|
+
])
|
|
105
|
+
await run(['rm', '-rf', path.join(o.debs, 'partial'), path.join(o.debs, 'lock')])
|
|
106
|
+
await run(['chown', '-R', os.userInfo().username, o.debs])
|
|
107
|
+
fetched = true
|
|
108
|
+
} else if (has('apk')) {
|
|
109
|
+
await run(['apk', 'add', '--no-cache', ...PACKAGES])
|
|
110
|
+
} else {
|
|
111
|
+
throw new UserError(
|
|
112
|
+
`vx setup: ${missing.join(', ')} missing and no apt-get or apk to install ${PACKAGES.join(' ')}`,
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// Ubuntu 24+ lets a program make user namespaces only under an AppArmor
|
|
117
|
+
// profile granting `userns` (discourse.ubuntu.com/t/58007). Ubuntu 26
|
|
118
|
+
// ships bwrap-userns-restrict instead, which strips the capabilities of
|
|
119
|
+
// the namespace nested in bwrap's, where the sandbox applies seccomp:
|
|
120
|
+
// every sandboxed task failed "nested userns is capability-restricted"
|
|
121
|
+
// (2026-10-23). bwrap gets the plain profile in its place.
|
|
122
|
+
if ((o.restricted ?? userns)()) {
|
|
123
|
+
if (existsSync('/etc/apparmor.d/bwrap-userns-restrict')) {
|
|
124
|
+
await run(['apparmor_parser', '-R', '/etc/apparmor.d/bwrap-userns-restrict'])
|
|
125
|
+
await run(['rm', '/etc/apparmor.d/bwrap-userns-restrict'])
|
|
126
|
+
}
|
|
127
|
+
const profile = path.join(os.tmpdir(), `vx-bwrap-${process.pid}.apparmor`)
|
|
128
|
+
await writeFile(
|
|
129
|
+
profile,
|
|
130
|
+
'abi <abi/4.0>,\ninclude <tunables/global>\nprofile bwrap /usr/bin/bwrap flags=(unconfined) {\n userns,\n}\n',
|
|
131
|
+
)
|
|
132
|
+
await run(['cp', profile, '/etc/apparmor.d/bwrap'])
|
|
133
|
+
await run(['apparmor_parser', '-r', '/etc/apparmor.d/bwrap'])
|
|
134
|
+
}
|
|
135
|
+
// GitLab keeps it in the checkout, where a root task's `**` keyed the
|
|
136
|
+
// debs: a job that had them missed every hit saved without them.
|
|
137
|
+
if (existsSync(o.debs)) await writeFile(path.join(o.debs, '.gitignore'), '*\n')
|
|
138
|
+
if (o.output !== undefined && o.output !== '')
|
|
139
|
+
await writeFile(o.output, `fetched=${fetched}\n`, { flag: 'a' })
|
|
140
|
+
// The proof: a sandbox that cannot start fails here, named, not as every
|
|
141
|
+
// sandboxed task's failure later.
|
|
142
|
+
const code = await sh([
|
|
143
|
+
'bwrap',
|
|
144
|
+
'--ro-bind',
|
|
145
|
+
'/',
|
|
146
|
+
'/',
|
|
147
|
+
'--proc',
|
|
148
|
+
'/proc',
|
|
149
|
+
'--dev',
|
|
150
|
+
'/dev',
|
|
151
|
+
'/bin/true',
|
|
152
|
+
])
|
|
153
|
+
if (code !== 0)
|
|
154
|
+
throw new UserError(
|
|
155
|
+
`vx setup: bwrap cannot start a sandbox here (exit ${code}): the machine must allow user namespaces`,
|
|
156
|
+
)
|
|
157
|
+
print(`vx setup: the sandbox runs on this ${o.ci === 'github' ? 'runner' : 'job'}`)
|
|
158
|
+
return 0
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function readdir(dir: string): string[] {
|
|
162
|
+
try {
|
|
163
|
+
return readdirSync(dir)
|
|
164
|
+
} catch {
|
|
165
|
+
return []
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The key to save fetched packages under: this runner image's, after the
|
|
171
|
+
* `vx-setup-debs-<runner.os>-<runner.arch>-` a job restores by. Led by the
|
|
172
|
+
* image alone, no restore ever matched it and every worker fetched again
|
|
173
|
+
* (main, 2026-10-23: 6-55 s of each worker's start).
|
|
174
|
+
*/
|
|
175
|
+
export function debsKey(env: Record<string, string | undefined>): string {
|
|
176
|
+
return `vx-setup-debs-${env['RUNNER_OS'] ?? ''}-${env['RUNNER_ARCH'] ?? ''}-${env['ImageOS'] ?? ''}-${env['ImageVersion'] ?? ''}`
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The `setup` verb of `github()` or `gitlab()`: `vx setup <ci> [--debs <dir>]`. */
|
|
180
|
+
export async function runSetup(
|
|
181
|
+
ci: 'github' | 'gitlab',
|
|
182
|
+
argv: readonly string[],
|
|
183
|
+
defaultDebs: string,
|
|
184
|
+
): Promise<number> {
|
|
185
|
+
const usage = `Usage: vx setup ${ci} [--debs <dir>]`
|
|
186
|
+
// Asked for, the usage is an answer: `vx setup --help` exited 1 (2026-10-26).
|
|
187
|
+
if (argv.includes('--help') || argv.includes('-h')) {
|
|
188
|
+
ui.say('info', usage, 'out')
|
|
189
|
+
return 0
|
|
190
|
+
}
|
|
191
|
+
// The bare usage said nothing of what was wrong with the line typed.
|
|
192
|
+
const wrong = (what: string): never => {
|
|
193
|
+
throw new UserError(`${what}\n${usage}`, 'VX_E_USAGE')
|
|
194
|
+
}
|
|
195
|
+
if (argv[0] === undefined) wrong(`missing the CI: vx setup ${ci}`)
|
|
196
|
+
if (argv[0] !== ci) wrong(`unknown CI: ${argv[0]} (this workspace sets up ${ci})`)
|
|
197
|
+
let debs = defaultDebs
|
|
198
|
+
for (let i = 1; i < argv.length; i++) {
|
|
199
|
+
if (argv[i] !== '--debs') wrong(`unknown argument: ${argv[i]}`)
|
|
200
|
+
if (argv[i + 1] === undefined) wrong('--debs needs a directory')
|
|
201
|
+
debs = path.resolve(argv[++i]!)
|
|
202
|
+
}
|
|
203
|
+
await mkdir(debs, { recursive: true })
|
|
204
|
+
const output = ci === 'github' ? process.env['GITHUB_OUTPUT'] : undefined
|
|
205
|
+
if (output !== undefined && output !== '')
|
|
206
|
+
await writeFile(output, `key=${debsKey(process.env)}\n`, { flag: 'a' })
|
|
207
|
+
return setup({ ci, debs, output })
|
|
208
|
+
}
|
package/src/sink.ts
CHANGED
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
// The job summary sink: loaded only on a runner that sets a summary file,
|
|
2
2
|
// so a run elsewhere pays no module of it.
|
|
3
3
|
import { type RunSummaryRecord, type TelemetrySink } from '@vzn/vx'
|
|
4
|
-
import { buildCheckRunPayload, postCheckRun, type CheckRunEnv, type FetchFn } from './checks.js'
|
|
5
4
|
import { clampJobSummary, MAX_JOB_SUMMARY_BYTES, renderJobSummary } from './summary.js'
|
|
6
5
|
|
|
7
|
-
export { resolveCheckRunEnv } from './checks.js'
|
|
8
|
-
|
|
9
6
|
export class GithubSummarySink implements TelemetrySink {
|
|
10
7
|
readonly name = 'github-job-summary'
|
|
11
8
|
/** Summary-only: no streaming records at all — zero per-event cost. */
|
|
@@ -17,7 +14,6 @@ export class GithubSummarySink implements TelemetrySink {
|
|
|
17
14
|
private readonly title: string,
|
|
18
15
|
private readonly append: (file: string, markdown: string) => Promise<void>,
|
|
19
16
|
private readonly warn: (m: string) => void,
|
|
20
|
-
private readonly check?: { env: CheckRunEnv; name: string; fetchFn: FetchFn },
|
|
21
17
|
/** Bytes the summary file already holds. */
|
|
22
18
|
private readonly sizeOf: (file: string) => Promise<number> = async () => 0,
|
|
23
19
|
) {}
|
|
@@ -27,20 +23,15 @@ export class GithubSummarySink implements TelemetrySink {
|
|
|
27
23
|
this.summary = summary
|
|
28
24
|
}
|
|
29
25
|
|
|
30
|
-
async flush(
|
|
26
|
+
async flush(): Promise<void> {
|
|
31
27
|
if (this.summary === undefined) return
|
|
32
28
|
const markdown = renderJobSummary(this.summary, this.title)
|
|
33
|
-
//
|
|
34
|
-
// one direction: it declines the check-run without a token and still
|
|
35
|
-
// writes the summary. The reverse has to hold, or a full disk on the
|
|
36
|
-
// runner costs the PR its check — the more visible of the two. Reported,
|
|
37
|
-
// not thrown: a telemetry sink may never break a run.
|
|
29
|
+
// Reported, not thrown: a telemetry sink may never break a run.
|
|
38
30
|
try {
|
|
39
31
|
// Clamped: past GitHub's 1 MiB cap the runner drops the summary whole,
|
|
40
32
|
// so a bounded page beats none. The cap covers the step's whole file,
|
|
41
33
|
// and a page clamped to 1 MiB after another writer's output lost both
|
|
42
|
-
// (F-42): the room is what is left.
|
|
43
|
-
// smaller cap and is clamped where it is built.
|
|
34
|
+
// (F-42): the room is what is left.
|
|
44
35
|
const used = await this.sizeOf(this.file)
|
|
45
36
|
// After another writer the page starts on its own line: one that left
|
|
46
37
|
// no final newline ran vx's heading into its paragraph (F-51).
|
|
@@ -48,29 +39,15 @@ export class GithubSummarySink implements TelemetrySink {
|
|
|
48
39
|
const page = clampJobSummary(markdown, MAX_JOB_SUMMARY_BYTES - used - lead.length)
|
|
49
40
|
if (page === '') {
|
|
50
41
|
this.warn(
|
|
51
|
-
`vx-ci: ${this.file} already holds ${used} bytes of GitHub's 1 MiB job summary cap — no room for vx's page`,
|
|
42
|
+
`[vx] vx-ci: ${this.file} already holds ${used} bytes of GitHub's 1 MiB job summary cap — no room for vx's page`,
|
|
52
43
|
)
|
|
53
44
|
} else {
|
|
54
45
|
await this.append(this.file, lead + page)
|
|
55
46
|
}
|
|
56
47
|
} catch (err) {
|
|
57
48
|
this.warn(
|
|
58
|
-
`vx-ci: could not write the job summary to ${this.file}: ${err instanceof Error ? err.message : String(err)}`,
|
|
49
|
+
`[vx] vx-ci: could not write the job summary to ${this.file}: ${err instanceof Error ? err.message : String(err)}`,
|
|
59
50
|
)
|
|
60
51
|
}
|
|
61
|
-
if (this.check !== undefined) {
|
|
62
|
-
await postCheckRun({
|
|
63
|
-
env: this.check.env,
|
|
64
|
-
payload: buildCheckRunPayload({
|
|
65
|
-
summary: this.summary,
|
|
66
|
-
markdown,
|
|
67
|
-
name: this.check.name,
|
|
68
|
-
sha: this.check.env.sha,
|
|
69
|
-
}),
|
|
70
|
-
fetchFn: this.check.fetchFn,
|
|
71
|
-
warn: this.warn,
|
|
72
|
-
...(signal === undefined ? {} : { signal }),
|
|
73
|
-
})
|
|
74
|
-
}
|
|
75
52
|
}
|
|
76
53
|
}
|
package/src/summary.ts
CHANGED
|
@@ -21,6 +21,11 @@ const STATUS_LABEL: Record<string, string> = {
|
|
|
21
21
|
// Rounded once, then split: rounding the remainder after flooring the
|
|
22
22
|
// minutes printed 119.7 s as `1m 60s`. The tier is chosen by the rounded
|
|
23
23
|
// value: 59.96 s printed `60.0s` and 999.6 ms `1000ms` (F-51).
|
|
24
|
+
/** Bytes as gigabytes, one decimal. */
|
|
25
|
+
function gb(bytes: number): string {
|
|
26
|
+
return (bytes / 1e9).toFixed(1)
|
|
27
|
+
}
|
|
28
|
+
|
|
24
29
|
function fmtMs(ms: number): string {
|
|
25
30
|
if (Math.round(ms) < 1000) return `${Math.round(ms)}ms`
|
|
26
31
|
if (Math.round(ms / 100) < 600) return `${(ms / 1000).toFixed(1)}s`
|
|
@@ -29,12 +34,31 @@ function fmtMs(ms: number): string {
|
|
|
29
34
|
}
|
|
30
35
|
|
|
31
36
|
function statusLabel(t: TaskTelemetry): string {
|
|
37
|
+
// A carried server ran inside each dependant, on its worker: its time is
|
|
38
|
+
// theirs, and a `ran 0ms` row read as a run that did nothing (2026-10-23).
|
|
39
|
+
if (t.carried === true) return '🔗 served'
|
|
32
40
|
// A hit that found its outputs in place restored nothing: up to date,
|
|
33
|
-
//
|
|
41
|
+
// as vx's own summary says it (a remote hit always restored).
|
|
34
42
|
if (isCacheHit(t.status) && t.restored !== true) return '✔️ up-to-date'
|
|
35
43
|
return STATUS_LABEL[t.status] ?? t.status
|
|
36
44
|
}
|
|
37
45
|
|
|
46
|
+
/** Rows by status label, in this order; any other after them. */
|
|
47
|
+
const ORDER = [
|
|
48
|
+
'❌ failed',
|
|
49
|
+
'🛑 aborted',
|
|
50
|
+
'✅ ran',
|
|
51
|
+
'🔗 served',
|
|
52
|
+
'⚡ restored',
|
|
53
|
+
'☁️ restored remote',
|
|
54
|
+
'✔️ up-to-date',
|
|
55
|
+
'⏭️ skipped',
|
|
56
|
+
]
|
|
57
|
+
const rank = (t: TaskTelemetry): number => {
|
|
58
|
+
const i = ORDER.indexOf(statusLabel(t))
|
|
59
|
+
return i === -1 ? ORDER.length : i
|
|
60
|
+
}
|
|
61
|
+
|
|
38
62
|
/** `(3 up-to-date, 2 restored)`, with the remote share when any came from there. */
|
|
39
63
|
function hitSplit(s: RunSummaryRecord): string {
|
|
40
64
|
const restored = s.restoredLocalCount + s.restoredRemoteCount
|
|
@@ -63,7 +87,7 @@ export function clampJobSummary(markdown: string, room = MAX_JOB_SUMMARY_BYTES):
|
|
|
63
87
|
}
|
|
64
88
|
|
|
65
89
|
/** Cuts `text` to `maxBytes` of UTF-8, `suffix` included, never splitting a character. */
|
|
66
|
-
|
|
90
|
+
function truncateUtf8(text: string, maxBytes: number, suffix: string): string {
|
|
67
91
|
// The cap is BYTES, and the page is not ASCII: every status label is an
|
|
68
92
|
// emoji (4 bytes, 2 UTF-16 units) and every separator a `·` or a `—`
|
|
69
93
|
// (2 and 3 bytes, 1 unit). Measured in `.length`, a clamped page of those
|
|
@@ -94,10 +118,11 @@ function escapeMarkdownCell(value: string): string {
|
|
|
94
118
|
|
|
95
119
|
/**
|
|
96
120
|
* A task id as inline markdown: `a#*x*` rendered as `a#` and an italic
|
|
97
|
-
* `x` (item 1058)
|
|
121
|
+
* `x` (item 1058), `a#~x~` struck through and `<` as `<`. Table cells
|
|
122
|
+
* escape their pipes on top of this.
|
|
98
123
|
*/
|
|
99
124
|
function escapeInline(s: string): string {
|
|
100
|
-
return s.replace(/[\\`*_[\]
|
|
125
|
+
return s.replace(/[\\`*_[\]<>~&]/g, '\\$&')
|
|
101
126
|
}
|
|
102
127
|
|
|
103
128
|
/**
|
|
@@ -123,8 +148,9 @@ export function renderJobSummary(summary: RunSummaryRecord, title = 'vx run'): s
|
|
|
123
148
|
const lines: string[] = []
|
|
124
149
|
lines.push(`## ${verdict} ${title}`)
|
|
125
150
|
lines.push('')
|
|
151
|
+
// A carried server ran inside its dependants, on their worker.
|
|
126
152
|
const executed = summary.tasks.filter(
|
|
127
|
-
(t) => t.status === 'success' || t.status === 'failed',
|
|
153
|
+
(t) => (t.status === 'success' || t.status === 'failed') && t.carried !== true,
|
|
128
154
|
).length
|
|
129
155
|
const stats = [
|
|
130
156
|
`**${summary.taskCount}** task${summary.taskCount === 1 ? '' : 's'}`,
|
|
@@ -133,7 +159,10 @@ export function renderJobSummary(summary: RunSummaryRecord, title = 'vx run'): s
|
|
|
133
159
|
(summary.hitCount > 0 ? ` ${hitSplit(summary)}` : ''),
|
|
134
160
|
...(summary.failedCount > 0 ? [`**${summary.failedCount}** failed`] : []),
|
|
135
161
|
...(summary.abortedCount > 0 ? [`**${summary.abortedCount}** aborted`] : []),
|
|
136
|
-
|
|
162
|
+
// The run played perfectly on what each task took, beside the real time (2026-10-23).
|
|
163
|
+
summary.idealMs === undefined
|
|
164
|
+
? fmtMs(summary.totalDurationMs)
|
|
165
|
+
: `${fmtMs(summary.totalDurationMs)} (ideal ${fmtMs(summary.idealMs)})`,
|
|
137
166
|
]
|
|
138
167
|
lines.push(stats.join(' · '))
|
|
139
168
|
lines.push('')
|
|
@@ -166,14 +195,26 @@ export function renderJobSummary(summary: RunSummaryRecord, title = 'vx run'): s
|
|
|
166
195
|
lines.push('')
|
|
167
196
|
}
|
|
168
197
|
|
|
198
|
+
if (summary.machines !== undefined && summary.machines.length > 0) {
|
|
199
|
+
lines.push(...renderWorkers(summary.machines, summary.tasks), '')
|
|
200
|
+
}
|
|
201
|
+
|
|
169
202
|
const header = ['Task', 'Status', 'Duration']
|
|
170
203
|
lines.push(`| ${header.join(' | ')} |`)
|
|
171
204
|
lines.push(`|${header.map(() => ' --- ').join('|')}|`)
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
const ordered = [...
|
|
205
|
+
// By status, failures first (the eye lands on the table's top rows),
|
|
206
|
+
// then the longest first (2026-10-23).
|
|
207
|
+
const ordered = [...summary.tasks].sort(
|
|
208
|
+
(a, b) =>
|
|
209
|
+
rank(a) - rank(b) ||
|
|
210
|
+
(b.carried === true ? 0 : b.durationMs) - (a.carried === true ? 0 : a.durationMs),
|
|
211
|
+
)
|
|
175
212
|
for (const t of ordered) {
|
|
176
|
-
const cells = [
|
|
213
|
+
const cells = [
|
|
214
|
+
escapeMarkdownCell(escapeInline(t.taskId)),
|
|
215
|
+
statusLabel(t),
|
|
216
|
+
t.carried === true ? '' : fmtMs(t.durationMs),
|
|
217
|
+
]
|
|
177
218
|
lines.push(`| ${cells.join(' | ')} |`)
|
|
178
219
|
}
|
|
179
220
|
lines.push('')
|
|
@@ -189,3 +230,81 @@ export function renderJobSummary(summary: RunSummaryRecord, title = 'vx run'): s
|
|
|
189
230
|
lines.push('')
|
|
190
231
|
return lines.join('\n')
|
|
191
232
|
}
|
|
233
|
+
|
|
234
|
+
// "13 (1 failed)" read as 13 run in all, of 14 (2026-10-23).
|
|
235
|
+
const tasksCell = (ok: number, failed: number): string =>
|
|
236
|
+
failed > 0 ? `${ok} ok, ${failed} failed` : `${ok}`
|
|
237
|
+
|
|
238
|
+
const pct = (x: number): string => `${Math.round(x * 100)}%`
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* The workers an executor ran the run on (vx-dte's): the cores its tasks
|
|
242
|
+
* booked beside the cores they used, each averaged over the time the
|
|
243
|
+
* worker was online, so an idle or overfilled one shows on the page
|
|
244
|
+
* (2026-10-23). "4.0 of 4" read as a peak; it is that average (M:
|
|
245
|
+
* "is this an avg booking?").
|
|
246
|
+
*/
|
|
247
|
+
function renderWorkers(
|
|
248
|
+
machines: NonNullable<RunSummaryRecord['machines']>,
|
|
249
|
+
tasks: RunSummaryRecord['tasks'],
|
|
250
|
+
): string[] {
|
|
251
|
+
const lines = [
|
|
252
|
+
'### Workers',
|
|
253
|
+
'',
|
|
254
|
+
'| Worker | Status | Started at | Total | Warm-up | Online after warm-up | Idle while online | Tasks | Booked cores, avg | Used cores, avg | Peak RAM | Used of all cores |',
|
|
255
|
+
'| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |',
|
|
256
|
+
]
|
|
257
|
+
let coreMs = 0
|
|
258
|
+
let cpuMs = 0
|
|
259
|
+
// Over the workers that report a booking only: an executor may report
|
|
260
|
+
// none, and counting that as 0 read as tasks booking nothing.
|
|
261
|
+
let bookedCoreMs = 0
|
|
262
|
+
let bookedMs = 0
|
|
263
|
+
for (const m of machines) {
|
|
264
|
+
coreMs += m.cores * m.onlineMs
|
|
265
|
+
cpuMs += m.cpuAvg * m.onlineMs
|
|
266
|
+
if (m.bookedAvg !== undefined) {
|
|
267
|
+
bookedCoreMs += m.cores * m.onlineMs
|
|
268
|
+
bookedMs += m.bookedAvg * m.onlineMs
|
|
269
|
+
}
|
|
270
|
+
lines.push(
|
|
271
|
+
`| ${[
|
|
272
|
+
escapeMarkdownCell(escapeInline(m.name)),
|
|
273
|
+
escapeMarkdownCell(m.status),
|
|
274
|
+
m.startMs === undefined ? '' : fmtMs(m.startMs),
|
|
275
|
+
fmtMs((m.warmUpMs ?? 0) + m.onlineMs),
|
|
276
|
+
m.warmUpMs === undefined ? '' : fmtMs(m.warmUpMs),
|
|
277
|
+
fmtMs(m.onlineMs),
|
|
278
|
+
fmtMs(Math.max(0, m.onlineMs - m.busyMs)),
|
|
279
|
+
tasksCell(m.done, m.failed),
|
|
280
|
+
m.bookedAvg === undefined ? '' : `${m.bookedAvg.toFixed(1)} of ${m.cores}`,
|
|
281
|
+
`${m.cpuAvg.toFixed(1)} of ${m.cores}`,
|
|
282
|
+
`${gb(m.memoryPeakBytes)} of ${gb(m.memoryBytes)} GB`,
|
|
283
|
+
pct(m.utilization),
|
|
284
|
+
].join(' | ')} |`,
|
|
285
|
+
)
|
|
286
|
+
}
|
|
287
|
+
// Nothing runs on a distributed run's own machine (2026-10-23):
|
|
288
|
+
// a task that did is named, a bug to see. A carried server ran inside its
|
|
289
|
+
// dependants, on their worker.
|
|
290
|
+
const ran = tasks.filter(
|
|
291
|
+
(t) => (t.status === 'success' || t.status === 'failed') && t.carried !== true,
|
|
292
|
+
)
|
|
293
|
+
const here = ran.filter((t) => t.where === undefined)
|
|
294
|
+
if (here.length > 0) {
|
|
295
|
+
const failed = here.filter((t) => t.status === 'failed').length
|
|
296
|
+
lines.push(
|
|
297
|
+
`| this machine | ok | | | | | | ${tasksCell(here.length - failed, failed)} | | | | |`,
|
|
298
|
+
'',
|
|
299
|
+
`${ran.length} executed: ${ran.length - here.length} on workers, ${here.length} on this machine: ${here.map((t) => escapeMarkdownCell(escapeInline(t.taskId))).join(', ')}.`,
|
|
300
|
+
)
|
|
301
|
+
}
|
|
302
|
+
if (coreMs > 0)
|
|
303
|
+
lines.push(
|
|
304
|
+
'',
|
|
305
|
+
bookedCoreMs > 0
|
|
306
|
+
? `Over their online time, tasks booked ${pct(bookedMs / bookedCoreMs)} of the workers' cores and used ${pct(cpuMs / coreMs)}.`
|
|
307
|
+
: `Over their online time, tasks used ${pct(cpuMs / coreMs)} of the workers' cores.`,
|
|
308
|
+
)
|
|
309
|
+
return lines
|
|
310
|
+
}
|