@vzn/vx-ci 0.0.0 → 0.0.519
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 +109 -1
- package/index.ts +4 -0
- package/package.json +41 -3
- package/src/cache-scope.ts +36 -0
- package/src/checks.ts +201 -0
- package/src/index.ts +15 -0
- package/src/plugin.ts +208 -0
- package/src/summary.ts +175 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 vx contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,111 @@
|
|
|
1
1
|
# @vzn/vx-ci
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
GitHub Actions integration for [`@vzn/vx`](https://github.com/vznjs/vx) — a
|
|
4
|
+
telemetry plugin that writes every `vx run` as a **job summary** on the
|
|
5
|
+
workflow run page.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install -D @vzn/vx @vzn/vx-ci # or: pnpm add -D -w · yarn add -D (-W on Yarn 1) · bun add -d
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
// vx.workspace.ts
|
|
13
|
+
import { defineWorkspace } from '@vzn/vx/config'
|
|
14
|
+
import { github } from '@vzn/vx-ci'
|
|
15
|
+
|
|
16
|
+
export default defineWorkspace({
|
|
17
|
+
plugins: [github()],
|
|
18
|
+
})
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
That's the whole setup. On a GitHub Actions runner (`GITHUB_STEP_SUMMARY`
|
|
22
|
+
set) every `vx run` appends a summary block, on its own line after
|
|
23
|
+
anything already written in the step: verdict headline, stats
|
|
24
|
+
(tasks / executed / cache hits / duration), failures called out above the
|
|
25
|
+
per-task table with their exit code, the signal an exit above 128
|
|
26
|
+
stands for (`exit 137 (128 + SIGKILL)`, as the run's own frame and
|
|
27
|
+
`vx last` say it), a timeout as `timed out, exit 143`, a persistent task
|
|
28
|
+
that never became ready as `never ready (timed out)`, a sandboxed task's
|
|
29
|
+
violation count, and the tasks each failure blocked. A footer line
|
|
30
|
+
names the vx version, the command (what follows `--` counted, not
|
|
31
|
+
quoted), tasks passed and outputs restored.
|
|
32
|
+
Anywhere else — laptops, other CI — the plugin **declines** and costs
|
|
33
|
+
nothing, so declaring it unconditionally is safe.
|
|
34
|
+
|
|
35
|
+
## Options
|
|
36
|
+
|
|
37
|
+
The options type is `GithubPluginOptions`; `renderJobSummary` renders the
|
|
38
|
+
summary lines the plugin posts (the site's CI guide sample is rendered from it).
|
|
39
|
+
The package exports `github`, `renderJobSummary` and the types `GithubPluginOptions`
|
|
40
|
+
and `FetchFn`; nothing else. `fetchFn`, `append` and `sizeOf` are test seams:
|
|
41
|
+
they replace the Checks API transport, the summary writer and its size probe.
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
github({
|
|
45
|
+
summaryFile: '/path/override.md', // default: $GITHUB_STEP_SUMMARY
|
|
46
|
+
title: 'build & test', // default: 'vx run'
|
|
47
|
+
checks: true, // default: on with GITHUB_TOKEN; true warns when it is missing, false opts out
|
|
48
|
+
checkName: 'ci', // default: 'vx', the check run's name
|
|
49
|
+
cacheScope: false, // default: on; scope remote cache writes by ref
|
|
50
|
+
})
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Cache scope
|
|
54
|
+
|
|
55
|
+
On Actions (`GITHUB_ACTIONS=true`), when `vx.workspace.ts` sets no `cacheScope`, the plugin's
|
|
56
|
+
`config` stage sets it from the ref (`GITHUB_REF`, `GITHUB_REF_NAME`, and
|
|
57
|
+
the default branch from the `GITHUB_EVENT_PATH` payload): a push to the default branch stays
|
|
58
|
+
trusted (reads and writes the task keys), a pull request becomes
|
|
59
|
+
`pr-<n>` and any other branch or tag `ref-<name>`, which read their
|
|
60
|
+
own keys, then the trusted ones, and write only their own. A PR never writes what main
|
|
61
|
+
reads. `VX_CACHE_SCOPE` or a `cacheScope` in `vx.workspace.ts` overrides it. This is a client-side convention: the real boundary is a cache
|
|
62
|
+
token the server limits, so give PR jobs one that cannot write the
|
|
63
|
+
trusted keys (`docs/security.md` § Cache poisoning).
|
|
64
|
+
|
|
65
|
+
## How it works
|
|
66
|
+
|
|
67
|
+
`github()` contributes one observe-only telemetry sink through vx's
|
|
68
|
+
`telemetry` seam. It receives the versioned `RunSummaryRecord` at run end
|
|
69
|
+
and renders + appends the markdown in `flush()` — it holds no run handle,
|
|
70
|
+
streams no per-event records (`wants: []`), and a slow or failing write can
|
|
71
|
+
never fail or stall the run (core's crash-isolation + flush deadline).
|
|
72
|
+
|
|
73
|
+
Core's manual path still exists without this plugin:
|
|
74
|
+
`vx run --report=markdown --report-file "$GITHUB_STEP_SUMMARY"` writes a
|
|
75
|
+
plain table. The plugin's summary is richer (verdict, stats, failure
|
|
76
|
+
callouts) and automatic on every run.
|
|
77
|
+
|
|
78
|
+
## The PR check run
|
|
79
|
+
|
|
80
|
+
With `GITHUB_TOKEN` in the environment (plus `GITHUB_REPOSITORY` /
|
|
81
|
+
`GITHUB_SHA`, both set by the runner) the plugin also creates one
|
|
82
|
+
**completed check-run** on the built commit — conclusion `success`,
|
|
83
|
+
`failure`, or `cancelled` for a run a signal stopped with nothing
|
|
84
|
+
failed (a cancelled job), its output the same summary markdown — so the verdict shows in
|
|
85
|
+
the PR's checks list, not just the workflow page. The workflow must grant
|
|
86
|
+
the permission:
|
|
87
|
+
|
|
88
|
+
```yaml
|
|
89
|
+
permissions:
|
|
90
|
+
checks: write
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Without the token the check is silently skipped (the job summary still
|
|
94
|
+
writes); pass `checks: true` to warn instead, or `checks: false` to opt
|
|
95
|
+
out entirely; a value that is not a boolean is refused. GitHub's own blips (`502`, `503`, `504`, a dropped
|
|
96
|
+
connection) are retried twice, 200 then 800 ms apart, until the flush deadline, which warns the last answer. A failed POST warns and never fails the run — a `403` says
|
|
97
|
+
to check `permissions: checks: write`, a rate limit (`429`, or a `403`
|
|
98
|
+
saying so) says so and is not retried — and a slow API costs the run
|
|
99
|
+
nothing past core's end-of-run flush deadline. On `pull_request` events
|
|
100
|
+
`GITHUB_SHA` is the merge commit; GitHub still surfaces the check on the
|
|
101
|
+
PR.
|
|
102
|
+
|
|
103
|
+
On GitHub Enterprise Server the POST goes to `GITHUB_API_URL`. A host
|
|
104
|
+
behind a private CA is trusted through `NODE_EXTRA_CA_CERTS` (a PEM
|
|
105
|
+
file of the CA); an untrusted certificate is not retried and its warning
|
|
106
|
+
names that variable.
|
|
107
|
+
|
|
108
|
+
Both artifacts are bounded by GitHub's own limits, because exceeding
|
|
109
|
+
either loses the whole thing rather than its tail: the check-run output
|
|
110
|
+
at 65 535 bytes, the job summary at 1 MiB counted in bytes (about 19 000 task rows; fewer when task names are not ASCII), cut on a character boundary. GitHub's cap is the step's whole summary file, so the page fits in what earlier writers in the step left; with no room it is skipped with a warning.
|
|
111
|
+
Past either, what is written ends with a line saying it was truncated.
|
package/index.ts
ADDED
package/package.json
CHANGED
|
@@ -1,7 +1,45 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx-ci",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "Placeholder. The real package is published by vx's release workflow.",
|
|
3
|
+
"version": "0.0.519",
|
|
5
4
|
"license": "MIT",
|
|
6
|
-
"
|
|
5
|
+
"description": "GitHub Actions integration for vx — a telemetry plugin that writes the run as a GitHub Actions job summary.",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"vx",
|
|
8
|
+
"vx-plugin",
|
|
9
|
+
"monorepo",
|
|
10
|
+
"github-actions",
|
|
11
|
+
"job-summary",
|
|
12
|
+
"checks"
|
|
13
|
+
],
|
|
14
|
+
"type": "module",
|
|
15
|
+
"main": "./src/index.ts",
|
|
16
|
+
"types": "./src/index.ts",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"types": "./src/index.ts",
|
|
20
|
+
"import": "./src/index.ts"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"index.ts",
|
|
25
|
+
"src",
|
|
26
|
+
"README.md",
|
|
27
|
+
"LICENSE"
|
|
28
|
+
],
|
|
29
|
+
"engines": {
|
|
30
|
+
"bun": ">=1.4"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"@vzn/vx": "^0.0.519"
|
|
37
|
+
},
|
|
38
|
+
"repository": {
|
|
39
|
+
"type": "git",
|
|
40
|
+
"url": "git+https://github.com/vznjs/vx.git",
|
|
41
|
+
"directory": "packages/vx-ci"
|
|
42
|
+
},
|
|
43
|
+
"homepage": "https://github.com/vznjs/vx/tree/main/packages/vx-ci#readme",
|
|
44
|
+
"bugs": "https://github.com/vznjs/vx/issues"
|
|
7
45
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// The cache scope a GitHub Actions run gets when the workspace names none:
|
|
2
|
+
// the default branch is trusted, anything else writes to its own scope.
|
|
3
|
+
import { readFile } from 'node:fs/promises'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `undefined` (leave the workspace's default, trusted) on a push to the
|
|
7
|
+
* default branch, outside Actions, or when the ref is unknown; else
|
|
8
|
+
* `pr-<n>` for a pull request and `ref-<name>` for any other branch or tag.
|
|
9
|
+
*/
|
|
10
|
+
export async function githubCacheScope(
|
|
11
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
12
|
+
): Promise<string | undefined> {
|
|
13
|
+
if (env['GITHUB_ACTIONS'] !== 'true') return undefined
|
|
14
|
+
const ref = env['GITHUB_REF'] ?? ''
|
|
15
|
+
const pr = /^refs\/pull\/(\d+)\//.exec(ref)
|
|
16
|
+
if (pr !== null) return `pr-${pr[1]}`
|
|
17
|
+
const name = env['GITHUB_REF_NAME']
|
|
18
|
+
if (ref === '' || name === undefined || name === '') return undefined
|
|
19
|
+
if (ref === `refs/heads/${await defaultBranch(env['GITHUB_EVENT_PATH'])}`) return undefined
|
|
20
|
+
// `cacheScope` takes letters, digits and . _ - / @; a ref may hold more.
|
|
21
|
+
return `ref-${name.replace(/[^\w.@/-]/g, '_').slice(0, 124)}`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The repository's default branch, from the event payload the runner writes. */
|
|
25
|
+
async function defaultBranch(eventPath: string | undefined): Promise<string | undefined> {
|
|
26
|
+
if (eventPath === undefined || eventPath === '') return undefined
|
|
27
|
+
try {
|
|
28
|
+
const event = JSON.parse(await readFile(eventPath, 'utf8')) as {
|
|
29
|
+
repository?: { default_branch?: unknown }
|
|
30
|
+
}
|
|
31
|
+
const branch = event.repository?.default_branch
|
|
32
|
+
return typeof branch === 'string' && branch !== '' ? branch : undefined
|
|
33
|
+
} catch {
|
|
34
|
+
return undefined
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/checks.ts
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// The Checks API half: one completed `check-run` on the built commit, whose
|
|
2
|
+
// output is the same summary markdown the job summary shows. Needs
|
|
3
|
+
// `GITHUB_TOKEN` with `checks: write`; the plugin declines the check (not
|
|
4
|
+
// the whole sink) without one, so the summary still works token-less.
|
|
5
|
+
import type { RunSummaryRecord } from '@vzn/vx'
|
|
6
|
+
|
|
7
|
+
export type FetchFn = (
|
|
8
|
+
url: string,
|
|
9
|
+
init: { method: string; headers: Record<string, string>; body: string; signal?: AbortSignal },
|
|
10
|
+
) => Promise<{ ok: boolean; status: number; text(): Promise<string> }>
|
|
11
|
+
|
|
12
|
+
export interface CheckRunEnv {
|
|
13
|
+
token: string
|
|
14
|
+
repository: string
|
|
15
|
+
/** The commit the check attaches to. On `pull_request` events
|
|
16
|
+
* `GITHUB_SHA` is the MERGE commit; GitHub still renders the check on
|
|
17
|
+
* the PR, so that is the documented default. */
|
|
18
|
+
sha: string
|
|
19
|
+
apiUrl: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Read the Actions environment; `null` when any piece is missing. */
|
|
23
|
+
export function resolveCheckRunEnv(env: Record<string, string | undefined>): CheckRunEnv | null {
|
|
24
|
+
const token = env['GITHUB_TOKEN']
|
|
25
|
+
const repository = env['GITHUB_REPOSITORY']
|
|
26
|
+
const sha = env['GITHUB_SHA']
|
|
27
|
+
// All three treated alike: an EMPTY var is as absent as a missing one.
|
|
28
|
+
// Only the token used to be checked for empty, so `GITHUB_REPOSITORY=''`
|
|
29
|
+
// built a POST to `/repos//check-runs` and `GITHUB_SHA=''` sent
|
|
30
|
+
// `head_sha: ''` — a 404 or 422 warning where a clean decline was meant.
|
|
31
|
+
if (
|
|
32
|
+
token === undefined ||
|
|
33
|
+
token === '' ||
|
|
34
|
+
repository === undefined ||
|
|
35
|
+
repository === '' ||
|
|
36
|
+
sha === undefined ||
|
|
37
|
+
sha === ''
|
|
38
|
+
) {
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
// The same for the API URL: `??` kept an empty one, and the POST went to a
|
|
42
|
+
// relative URL fetch refuses (item 924).
|
|
43
|
+
const apiUrl = env['GITHUB_API_URL']
|
|
44
|
+
return {
|
|
45
|
+
token,
|
|
46
|
+
repository,
|
|
47
|
+
sha,
|
|
48
|
+
apiUrl: apiUrl === undefined || apiUrl === '' ? 'https://api.github.com' : apiUrl,
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** fetch refused the server's certificate (untrusted, expired, wrong name). */
|
|
53
|
+
function isCertificateRefusal(err: unknown): boolean {
|
|
54
|
+
const code = (err as { code?: unknown } | null)?.code
|
|
55
|
+
return (
|
|
56
|
+
typeof code === 'string' &&
|
|
57
|
+
(code.includes('CERT') || code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE')
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* GitHub caps `output.summary` at 65535 characters; truncate with a tell.
|
|
63
|
+
* Counted in UTF-8 bytes, which is never fewer than the characters, so
|
|
64
|
+
* the page fits whichever unit GitHub counts. The cut was in UTF-16 units
|
|
65
|
+
* and split an emoji (the aborted label 🛑) into a lone surrogate the JSON
|
|
66
|
+
* body carried (item 924); it now backs off a continuation byte, as the job
|
|
67
|
+
* summary's does.
|
|
68
|
+
*/
|
|
69
|
+
export function clampSummary(markdown: string): string {
|
|
70
|
+
const MAX = 65_535
|
|
71
|
+
if (markdown.length * 3 <= MAX) return markdown
|
|
72
|
+
const bytes = new TextEncoder().encode(markdown)
|
|
73
|
+
if (bytes.byteLength <= MAX) return markdown
|
|
74
|
+
const suffix = '\n\n…truncated by @vzn/vx-ci (65535-char Checks API limit)'
|
|
75
|
+
let end = MAX - new TextEncoder().encode(suffix).byteLength
|
|
76
|
+
while (end > 0 && (bytes[end]! & 0xc0) === 0x80) end--
|
|
77
|
+
return new TextDecoder().decode(bytes.subarray(0, end)) + suffix
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function buildCheckRunPayload(args: {
|
|
81
|
+
summary: RunSummaryRecord
|
|
82
|
+
markdown: string
|
|
83
|
+
name: string
|
|
84
|
+
sha: string
|
|
85
|
+
}): Record<string, unknown> {
|
|
86
|
+
const ok = args.summary.exitOk
|
|
87
|
+
// Stopped, not broken: a cancelled CI job's run has aborted tasks and
|
|
88
|
+
// nothing failed, and GitHub has a conclusion for exactly that.
|
|
89
|
+
const cancelled = !ok && args.summary.failedCount === 0 && args.summary.abortedCount > 0
|
|
90
|
+
return {
|
|
91
|
+
name: args.name,
|
|
92
|
+
head_sha: args.sha,
|
|
93
|
+
status: 'completed',
|
|
94
|
+
conclusion: ok ? 'success' : cancelled ? 'cancelled' : 'failure',
|
|
95
|
+
started_at: new Date(args.summary.startedAt).toISOString(),
|
|
96
|
+
completed_at: new Date(args.summary.endedAt).toISOString(),
|
|
97
|
+
output: {
|
|
98
|
+
title: ok
|
|
99
|
+
? `${args.summary.taskCount} task${args.summary.taskCount === 1 ? '' : 's'} · ${args.summary.hitCount} cached` +
|
|
100
|
+
(args.summary.hitCount > 0
|
|
101
|
+
? ` (${args.summary.upToDateCount} up-to-date, ${args.summary.restoredLocalCount + args.summary.restoredRemoteCount} restored)`
|
|
102
|
+
: '')
|
|
103
|
+
: cancelled
|
|
104
|
+
? `cancelled · ${args.summary.abortedCount} aborted`
|
|
105
|
+
: `${args.summary.failedCount} failed`,
|
|
106
|
+
summary: clampSummary(args.markdown),
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Resolves true when `signal` ended the wait. */
|
|
112
|
+
function sleepUnless(ms: number, signal: AbortSignal | undefined): Promise<boolean> {
|
|
113
|
+
return new Promise((resolve) => {
|
|
114
|
+
const done = (): void => {
|
|
115
|
+
clearTimeout(timer)
|
|
116
|
+
signal?.removeEventListener('abort', done)
|
|
117
|
+
resolve(signal?.aborted === true)
|
|
118
|
+
}
|
|
119
|
+
const timer = setTimeout(done, ms)
|
|
120
|
+
signal?.addEventListener('abort', done, { once: true })
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const CHECK_RETRY_DELAYS_MS = [200, 800] as const
|
|
125
|
+
const CHECK_RETRY_STATUS = new Set([502, 503, 504])
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* POST the check run. Failures are REPORTED via `warn`, never thrown —
|
|
129
|
+
* observability must never break a run. `signal` is core's flush deadline:
|
|
130
|
+
* the deadline alone abandoned the request, and a hanging API kept the
|
|
131
|
+
* process alive with no end (item 1055).
|
|
132
|
+
*/
|
|
133
|
+
export async function postCheckRun(args: {
|
|
134
|
+
env: CheckRunEnv
|
|
135
|
+
payload: Record<string, unknown>
|
|
136
|
+
fetchFn: FetchFn
|
|
137
|
+
warn: (m: string) => void
|
|
138
|
+
signal?: AbortSignal
|
|
139
|
+
}): Promise<void> {
|
|
140
|
+
const url = `${args.env.apiUrl}/repos/${args.env.repository}/check-runs`
|
|
141
|
+
const post = () =>
|
|
142
|
+
args.fetchFn(url, {
|
|
143
|
+
method: 'POST',
|
|
144
|
+
headers: {
|
|
145
|
+
authorization: `Bearer ${args.env.token}`,
|
|
146
|
+
accept: 'application/vnd.github+json',
|
|
147
|
+
'content-type': 'application/json',
|
|
148
|
+
'x-github-api-version': '2022-11-28',
|
|
149
|
+
'user-agent': 'vzn-vx-ci',
|
|
150
|
+
},
|
|
151
|
+
body: JSON.stringify(args.payload),
|
|
152
|
+
...(args.signal === undefined ? {} : { signal: args.signal }),
|
|
153
|
+
})
|
|
154
|
+
try {
|
|
155
|
+
// GitHub's own blips (502, 503, 504, a dropped connection) are retried
|
|
156
|
+
// twice, 200 then 800 ms apart, inside the flush deadline: one of them
|
|
157
|
+
// cost the run its check (F-29). A retry after a 502 that GitHub did
|
|
158
|
+
// process adds a second run of the same name, which GitHub shows as one.
|
|
159
|
+
let res: Awaited<ReturnType<FetchFn>> | undefined
|
|
160
|
+
for (let attempt = 0; ; attempt++) {
|
|
161
|
+
const delay = CHECK_RETRY_DELAYS_MS[attempt]
|
|
162
|
+
try {
|
|
163
|
+
res = await post()
|
|
164
|
+
if (!CHECK_RETRY_STATUS.has(res.status) || delay === undefined) break
|
|
165
|
+
} catch (err) {
|
|
166
|
+
// A refused certificate (a GHES host behind a private CA) fails the
|
|
167
|
+
// same way every time: not retried (F-40).
|
|
168
|
+
if (delay === undefined || args.signal?.aborted === true || isCertificateRefusal(err)) {
|
|
169
|
+
throw err
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (args.signal?.aborted === true) break
|
|
173
|
+
// The deadline ended the wait: GitHub's last answer is what to tell,
|
|
174
|
+
// not the AbortError a further POST would throw (F-46).
|
|
175
|
+
if (await sleepUnless(delay, args.signal)) break
|
|
176
|
+
}
|
|
177
|
+
if (res === undefined) throw new Error('the flush deadline passed before it could be retried')
|
|
178
|
+
if (!res.ok) {
|
|
179
|
+
const body = await res.text().catch(() => '')
|
|
180
|
+
// GitHub answers a rate limit (primary or secondary) with 403 as often
|
|
181
|
+
// as 429, and the permissions hint sent every such run to its workflow
|
|
182
|
+
// file. Not retried: its retry-after is seconds to minutes, past core's
|
|
183
|
+
// flush deadline (F-13).
|
|
184
|
+
const limited = res.status === 429 || /rate limit/i.test(body)
|
|
185
|
+
const hint = limited
|
|
186
|
+
? ' — rate-limited by GitHub; this run has no check'
|
|
187
|
+
: res.status === 403
|
|
188
|
+
? ' — does the workflow grant `permissions: checks: write`?'
|
|
189
|
+
: ''
|
|
190
|
+
args.warn(`vx-ci: check-run POST failed (${res.status})${hint}: ${body.slice(0, 200)}`)
|
|
191
|
+
}
|
|
192
|
+
} catch (err) {
|
|
193
|
+
// A GHES host behind a private CA: Bun's fetch trusts a CA named there.
|
|
194
|
+
const hint = isCertificateRefusal(err)
|
|
195
|
+
? " — for a host behind a private CA, set NODE_EXTRA_CA_CERTS to its CA's PEM file"
|
|
196
|
+
: ''
|
|
197
|
+
args.warn(
|
|
198
|
+
`vx-ci: check-run POST failed: ${err instanceof Error ? err.message : String(err)}${hint}`,
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Public API for @vzn/vx-ci — the GitHub Actions integration plugin.
|
|
2
|
+
//
|
|
3
|
+
// Usage in vx.workspace.ts:
|
|
4
|
+
// import { defineWorkspace } from '@vzn/vx'
|
|
5
|
+
// import { github } from '@vzn/vx-ci'
|
|
6
|
+
// export default defineWorkspace({ plugins: [github()] })
|
|
7
|
+
//
|
|
8
|
+
// On a GitHub Actions runner (GITHUB_STEP_SUMMARY set) every `vx run`
|
|
9
|
+
// appends a job summary: verdict, stats, failures, and the per-task table.
|
|
10
|
+
// Anywhere else the plugin declines and costs nothing.
|
|
11
|
+
export { github, type GithubPluginOptions } from './plugin.js'
|
|
12
|
+
export { renderJobSummary } from './summary.js'
|
|
13
|
+
// The Checks API helpers are the plugin's own; only the transport type
|
|
14
|
+
// the options name is public (1.0 freezes what this file exports).
|
|
15
|
+
export { type FetchFn } from './checks.js'
|
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// The `github()` telemetry plugin. Contributes one observe-only sink that
|
|
2
|
+
// writes the run as a GitHub Actions job summary. Declines (returns
|
|
3
|
+
// undefined) outside GitHub Actions — no `GITHUB_STEP_SUMMARY` file to write
|
|
4
|
+
// and no cost — so declaring `github()` is safe in every environment, the
|
|
5
|
+
// same decline pattern as `otel()`.
|
|
6
|
+
import { appendFile, stat } from 'node:fs/promises'
|
|
7
|
+
import {
|
|
8
|
+
refuseUnknownOptions,
|
|
9
|
+
type PluginOptionKinds,
|
|
10
|
+
definePlugin,
|
|
11
|
+
type RunSummaryRecord,
|
|
12
|
+
type TelemetrySink,
|
|
13
|
+
type VxPlugin,
|
|
14
|
+
} from '@vzn/vx'
|
|
15
|
+
import {
|
|
16
|
+
buildCheckRunPayload,
|
|
17
|
+
postCheckRun,
|
|
18
|
+
resolveCheckRunEnv,
|
|
19
|
+
type CheckRunEnv,
|
|
20
|
+
type FetchFn,
|
|
21
|
+
} from './checks.js'
|
|
22
|
+
import { githubCacheScope } from './cache-scope.js'
|
|
23
|
+
import { clampJobSummary, MAX_JOB_SUMMARY_BYTES, renderJobSummary } from './summary.js'
|
|
24
|
+
|
|
25
|
+
export interface GithubPluginOptions {
|
|
26
|
+
/**
|
|
27
|
+
* Target file for the summary markdown. Falls back to
|
|
28
|
+
* `GITHUB_STEP_SUMMARY` (set by the Actions runner); with neither, the
|
|
29
|
+
* plugin declines.
|
|
30
|
+
*/
|
|
31
|
+
summaryFile?: string
|
|
32
|
+
/** Heading for the summary block. Default: `'vx run'`. */
|
|
33
|
+
title?: string
|
|
34
|
+
/**
|
|
35
|
+
* Also create a completed check-run on the built commit (Checks API).
|
|
36
|
+
* Default: on when the environment carries `GITHUB_TOKEN` +
|
|
37
|
+
* `GITHUB_REPOSITORY` + `GITHUB_SHA` (the workflow must grant
|
|
38
|
+
* `permissions: checks: write`); set `false` to opt out, `true` to warn
|
|
39
|
+
* when the environment is missing instead of silently skipping.
|
|
40
|
+
*/
|
|
41
|
+
checks?: boolean
|
|
42
|
+
/** Check-run name. Default: `'vx'`. */
|
|
43
|
+
checkName?: string
|
|
44
|
+
/**
|
|
45
|
+
* Set the workspace's `cacheScope` from the run's ref when it names none:
|
|
46
|
+
* a push to the default branch stays trusted, a pull request writes to
|
|
47
|
+
* `pr-<n>`, any other ref to `ref-<name>`. Default: on; `false` opts out.
|
|
48
|
+
*/
|
|
49
|
+
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
|
+
export class GithubSummarySink implements TelemetrySink {
|
|
62
|
+
readonly name = 'github-job-summary'
|
|
63
|
+
/** Summary-only: no streaming records at all — zero per-event cost. */
|
|
64
|
+
readonly wants: [] = []
|
|
65
|
+
private summary: RunSummaryRecord | undefined
|
|
66
|
+
|
|
67
|
+
constructor(
|
|
68
|
+
private readonly file: string,
|
|
69
|
+
private readonly title: string,
|
|
70
|
+
private readonly append: (file: string, markdown: string) => Promise<void>,
|
|
71
|
+
private readonly warn: (m: string) => void,
|
|
72
|
+
private readonly check?: { env: CheckRunEnv; name: string; fetchFn: FetchFn },
|
|
73
|
+
/** Bytes the summary file already holds. */
|
|
74
|
+
private readonly sizeOf: (file: string) => Promise<number> = async () => 0,
|
|
75
|
+
) {}
|
|
76
|
+
|
|
77
|
+
onRunSummary(summary: RunSummaryRecord): void {
|
|
78
|
+
// MUST return promptly (contract): stash, render + write in flush().
|
|
79
|
+
this.summary = summary
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async flush(signal?: AbortSignal): Promise<void> {
|
|
83
|
+
if (this.summary === undefined) return
|
|
84
|
+
const markdown = renderJobSummary(this.summary, this.title)
|
|
85
|
+
// The two artifacts are INDEPENDENT, and the plugin already says so in
|
|
86
|
+
// one direction: it declines the check-run without a token and still
|
|
87
|
+
// writes the summary. The reverse has to hold, or a full disk on the
|
|
88
|
+
// runner costs the PR its check — the more visible of the two. Reported,
|
|
89
|
+
// not thrown: a telemetry sink may never break a run.
|
|
90
|
+
try {
|
|
91
|
+
// Clamped: past GitHub's 1 MiB cap the runner drops the summary whole,
|
|
92
|
+
// so a bounded page beats none. The cap covers the step's whole file,
|
|
93
|
+
// and a page clamped to 1 MiB after another writer's output lost both
|
|
94
|
+
// (F-42): the room is what is left. The check-run payload has its own,
|
|
95
|
+
// smaller cap and is clamped where it is built.
|
|
96
|
+
const used = await this.sizeOf(this.file)
|
|
97
|
+
// After another writer the page starts on its own line: one that left
|
|
98
|
+
// no final newline ran vx's heading into its paragraph (F-51).
|
|
99
|
+
const lead = used > 0 ? '\n' : ''
|
|
100
|
+
const page = clampJobSummary(markdown, MAX_JOB_SUMMARY_BYTES - used - lead.length)
|
|
101
|
+
if (page === '') {
|
|
102
|
+
this.warn(
|
|
103
|
+
`vx-ci: ${this.file} already holds ${used} bytes of GitHub's 1 MiB job summary cap — no room for vx's page`,
|
|
104
|
+
)
|
|
105
|
+
} else {
|
|
106
|
+
await this.append(this.file, lead + page)
|
|
107
|
+
}
|
|
108
|
+
} catch (err) {
|
|
109
|
+
this.warn(
|
|
110
|
+
`vx-ci: could not write the job summary to ${this.file}: ${err instanceof Error ? err.message : String(err)}`,
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
if (this.check !== undefined) {
|
|
114
|
+
await postCheckRun({
|
|
115
|
+
env: this.check.env,
|
|
116
|
+
payload: buildCheckRunPayload({
|
|
117
|
+
summary: this.summary,
|
|
118
|
+
markdown,
|
|
119
|
+
name: this.check.name,
|
|
120
|
+
sha: this.check.env.sha,
|
|
121
|
+
}),
|
|
122
|
+
fetchFn: this.check.fetchFn,
|
|
123
|
+
warn: this.warn,
|
|
124
|
+
...(signal === undefined ? {} : { signal }),
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Why a string cannot be an HTTP header value, or null. Bun's `fetch`
|
|
132
|
+
* refuses a line break, a NUL or a character past Latin-1 once it has
|
|
133
|
+
* trimmed the ends (measured on 1.4.2), and quotes the value in its error.
|
|
134
|
+
*/
|
|
135
|
+
function headerValueFault(value: string): string | null {
|
|
136
|
+
let past = false
|
|
137
|
+
for (const ch of value.trim()) {
|
|
138
|
+
const c = ch.codePointAt(0)!
|
|
139
|
+
if (c === 0x0a || c === 0x0d || c === 0) return 'a line break or NUL'
|
|
140
|
+
if (c > 0xff) past = true
|
|
141
|
+
}
|
|
142
|
+
return past ? 'a character past Latin-1' : null
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Each option `GithubPluginOptions` names, with its kind: derived from the type, so the two cannot drift. */
|
|
146
|
+
const GITHUB_PLUGIN_KEYS: PluginOptionKinds<GithubPluginOptions> = {
|
|
147
|
+
summaryFile: 'string',
|
|
148
|
+
title: 'string',
|
|
149
|
+
checks: 'boolean',
|
|
150
|
+
checkName: 'string',
|
|
151
|
+
cacheScope: 'boolean',
|
|
152
|
+
append: 'function',
|
|
153
|
+
fetchFn: 'function',
|
|
154
|
+
sizeOf: 'function',
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function github(options: GithubPluginOptions = {}): VxPlugin {
|
|
158
|
+
refuseUnknownOptions('github()', options, GITHUB_PLUGIN_KEYS)
|
|
159
|
+
return definePlugin(import.meta, {
|
|
160
|
+
async config(workspace) {
|
|
161
|
+
if (options.cacheScope === false || workspace.cacheScope !== undefined) return
|
|
162
|
+
const scope = await githubCacheScope(process.env)
|
|
163
|
+
if (scope !== undefined) workspace.cacheScope = scope
|
|
164
|
+
},
|
|
165
|
+
telemetry(ctx) {
|
|
166
|
+
const file = options.summaryFile ?? process.env['GITHUB_STEP_SUMMARY']
|
|
167
|
+
if (file === undefined || file === '') return undefined
|
|
168
|
+
const append = options.append ?? (async (f: string, md: string) => appendFile(f, md, 'utf8'))
|
|
169
|
+
let check: ConstructorParameters<typeof GithubSummarySink>[4]
|
|
170
|
+
if (options.checks !== false) {
|
|
171
|
+
const env = resolveCheckRunEnv(process.env)
|
|
172
|
+
// fetch refuses a token no header can carry and QUOTES it in its
|
|
173
|
+
// error, which the check-run warning printed (item 928).
|
|
174
|
+
const fault = env === null ? null : headerValueFault(env.token)
|
|
175
|
+
if (fault !== null) {
|
|
176
|
+
ctx.warn(
|
|
177
|
+
`vx-ci: GITHUB_TOKEN holds ${fault}, which no HTTP header can carry — no check-run will be created (the token is not printed)`,
|
|
178
|
+
)
|
|
179
|
+
} else if (env !== null) {
|
|
180
|
+
check = {
|
|
181
|
+
env,
|
|
182
|
+
name: options.checkName ?? 'vx',
|
|
183
|
+
fetchFn: options.fetchFn ?? (fetch as unknown as FetchFn),
|
|
184
|
+
}
|
|
185
|
+
} else if (options.checks === true) {
|
|
186
|
+
ctx.warn(
|
|
187
|
+
'vx-ci: checks requested but GITHUB_TOKEN / GITHUB_REPOSITORY / GITHUB_SHA are not all set — no check-run will be created',
|
|
188
|
+
)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return new GithubSummarySink(
|
|
192
|
+
file,
|
|
193
|
+
options.title ?? 'vx run',
|
|
194
|
+
append,
|
|
195
|
+
(m) => ctx.warn(m),
|
|
196
|
+
check,
|
|
197
|
+
options.sizeOf ??
|
|
198
|
+
(options.append !== undefined
|
|
199
|
+
? async () => 0
|
|
200
|
+
: (f: string) =>
|
|
201
|
+
stat(f).then(
|
|
202
|
+
(st) => st.size,
|
|
203
|
+
() => 0,
|
|
204
|
+
)),
|
|
205
|
+
)
|
|
206
|
+
},
|
|
207
|
+
})
|
|
208
|
+
}
|
package/src/summary.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Pure markdown rendering: RunSummaryRecord -> a GitHub Actions job summary.
|
|
2
|
+
// Purpose-built for the job-summary surface (verdict headline, stats line,
|
|
3
|
+
// failures called out above the table) rather than reusing core's
|
|
4
|
+
// `--report=markdown` table — a job summary is a landing page, not a cell
|
|
5
|
+
// grid. `escapeMarkdownCell` comes from core's façade: task names are the
|
|
6
|
+
// same unvalidated strings core renders, and the old cloud job summary
|
|
7
|
+
// shipped without the escape once already.
|
|
8
|
+
import {
|
|
9
|
+
escapeMarkdownCell,
|
|
10
|
+
exitSignal,
|
|
11
|
+
isCacheHit,
|
|
12
|
+
isPassStatus,
|
|
13
|
+
type RunSummaryRecord,
|
|
14
|
+
type TaskTelemetry,
|
|
15
|
+
} from '@vzn/vx'
|
|
16
|
+
|
|
17
|
+
const STATUS_LABEL: Record<string, string> = {
|
|
18
|
+
success: '✅ ran',
|
|
19
|
+
'cache-hit': '⚡ restored',
|
|
20
|
+
'cache-hit-remote': '☁️ restored remote',
|
|
21
|
+
failed: '❌ failed',
|
|
22
|
+
skipped: '⏭️ skipped',
|
|
23
|
+
aborted: '🛑 aborted',
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Rounded once, then split: rounding the remainder after flooring the
|
|
27
|
+
// minutes printed 119.7 s as `1m 60s`. The tier is chosen by the rounded
|
|
28
|
+
// value: 59.96 s printed `60.0s` and 999.6 ms `1000ms` (F-51).
|
|
29
|
+
function fmtMs(ms: number): string {
|
|
30
|
+
if (Math.round(ms) < 1000) return `${Math.round(ms)}ms`
|
|
31
|
+
if (Math.round(ms / 100) < 600) return `${(ms / 1000).toFixed(1)}s`
|
|
32
|
+
const s = Math.round(ms / 1000)
|
|
33
|
+
return `${Math.floor(s / 60)}m ${s % 60}s`
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function statusLabel(t: TaskTelemetry): string {
|
|
37
|
+
// A hit that found its outputs in place restored nothing: up to date,
|
|
38
|
+
// whichever layer answered, as vx's own summary says it.
|
|
39
|
+
if (isCacheHit(t.status) && t.restored !== true) return '✔️ up-to-date'
|
|
40
|
+
return STATUS_LABEL[t.status] ?? t.status
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** `(3 up-to-date, 2 restored)`, with the remote share when any came from there. */
|
|
44
|
+
function hitSplit(s: RunSummaryRecord): string {
|
|
45
|
+
const restored = s.restoredLocalCount + s.restoredRemoteCount
|
|
46
|
+
const remote = s.restoredRemoteCount > 0 ? `, ${s.restoredRemoteCount} from remote` : ''
|
|
47
|
+
return `(${s.upToDateCount} up-to-date, ${restored} restored${remote})`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* GitHub caps a step's job summary at 1 MiB and REJECTS the upload past it,
|
|
52
|
+
* so an unbounded summary costs the whole page rather than its tail. At the
|
|
53
|
+
* measured ~55 bytes a row that is about 19 000 tasks — a scale this repo's
|
|
54
|
+
* own bench generates (5 000 projects × four tasks), so it is reachable
|
|
55
|
+
* rather than theoretical. Cut from the END: the verdict, the stats line and
|
|
56
|
+
* the Failures section are rendered first, and they are what a reader needs.
|
|
57
|
+
* The cap is the step's whole file, so the room is what other writers in
|
|
58
|
+
* the step have left (F-42).
|
|
59
|
+
*/
|
|
60
|
+
export const MAX_JOB_SUMMARY_BYTES = 1024 * 1024
|
|
61
|
+
|
|
62
|
+
export function clampJobSummary(markdown: string, room = MAX_JOB_SUMMARY_BYTES): string {
|
|
63
|
+
// The cap is BYTES, and the page is not ASCII: every status label is an
|
|
64
|
+
// emoji (4 bytes, 2 UTF-16 units) and every separator a `·` or a `—`
|
|
65
|
+
// (2 and 3 bytes, 1 unit). Measured in `.length`, a clamped page of those
|
|
66
|
+
// rows came out past 1 MiB and GitHub refused it whole (item 806). A unit
|
|
67
|
+
// is at most 3 bytes, so a short page skips the encode.
|
|
68
|
+
if (markdown.length * 3 <= room) return markdown
|
|
69
|
+
const bytes = new TextEncoder().encode(markdown)
|
|
70
|
+
if (bytes.byteLength <= room) return markdown
|
|
71
|
+
const suffix = '\n\n…truncated by @vzn/vx-ci (GitHub caps a job summary at 1 MiB)\n'
|
|
72
|
+
let end = room - new TextEncoder().encode(suffix).byteLength
|
|
73
|
+
if (end <= 0) return ''
|
|
74
|
+
// Back up off a continuation byte, so the cut never splits a character.
|
|
75
|
+
while (end > 0 && (bytes[end]! & 0xc0) === 0x80) end--
|
|
76
|
+
return new TextDecoder().decode(bytes.subarray(0, end)) + suffix
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A task id as inline markdown: `a#*x*` rendered as `a#` and an italic
|
|
81
|
+
* `x` (item 1058). Table cells escape their pipes on top of this.
|
|
82
|
+
*/
|
|
83
|
+
function escapeInline(s: string): string {
|
|
84
|
+
return s.replace(/[\\`*_[\]<>]/g, '\\$&')
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A code span that holds any text: its fence is one backtick longer than
|
|
89
|
+
* the longest run inside, padded when the text starts or ends with one.
|
|
90
|
+
* The footer cell-escaped the command instead, which is for tables: a `|`
|
|
91
|
+
* showed as `\|`, and a backtick ended the span early (item 1058).
|
|
92
|
+
*/
|
|
93
|
+
function codeSpan(raw: string): string {
|
|
94
|
+
// A lone CR is a line ending too (CommonMark), and broke the span (F-51).
|
|
95
|
+
const s = raw.replace(/\r\n?|\n/g, ' ')
|
|
96
|
+
const longest = Math.max(0, ...(s.match(/`+/g) ?? []).map((r) => r.length))
|
|
97
|
+
const fence = '`'.repeat(longest + 1)
|
|
98
|
+
const pad = s.startsWith('`') || s.endsWith('`') ? ' ' : ''
|
|
99
|
+
return `${fence}${pad}${s}${pad}${fence}`
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Render the whole job summary. Deterministic for a given record. */
|
|
103
|
+
export function renderJobSummary(summary: RunSummaryRecord, title = 'vx run'): string {
|
|
104
|
+
const failed = summary.tasks.filter((t) => t.status === 'failed')
|
|
105
|
+
const cancelled = !summary.exitOk && summary.failedCount === 0 && summary.abortedCount > 0
|
|
106
|
+
const verdict = summary.exitOk ? '✅' : cancelled ? '⏹️' : '❌'
|
|
107
|
+
const lines: string[] = []
|
|
108
|
+
lines.push(`## ${verdict} ${title}`)
|
|
109
|
+
lines.push('')
|
|
110
|
+
const executed = summary.tasks.filter(
|
|
111
|
+
(t) => t.status === 'success' || t.status === 'failed',
|
|
112
|
+
).length
|
|
113
|
+
const stats = [
|
|
114
|
+
`**${summary.taskCount}** task${summary.taskCount === 1 ? '' : 's'}`,
|
|
115
|
+
`**${executed}** executed`,
|
|
116
|
+
`**${summary.hitCount}** cache hit${summary.hitCount === 1 ? '' : 's'}` +
|
|
117
|
+
(summary.hitCount > 0 ? ` ${hitSplit(summary)}` : ''),
|
|
118
|
+
...(summary.failedCount > 0 ? [`**${summary.failedCount}** failed`] : []),
|
|
119
|
+
...(summary.abortedCount > 0 ? [`**${summary.abortedCount}** aborted`] : []),
|
|
120
|
+
fmtMs(summary.totalDurationMs),
|
|
121
|
+
]
|
|
122
|
+
lines.push(stats.join(' · '))
|
|
123
|
+
lines.push('')
|
|
124
|
+
|
|
125
|
+
if (failed.length > 0) {
|
|
126
|
+
// What each failure cost: the tasks that never started because of it
|
|
127
|
+
// (the record's `blockedBy` names the root of each block). Grouped once:
|
|
128
|
+
// a scan of every task per failure took 1.3 s for 10 000 failures of
|
|
129
|
+
// 20 000 tasks (F-38).
|
|
130
|
+
const blockedBy = new Map<string, string[]>()
|
|
131
|
+
for (const s of summary.tasks) {
|
|
132
|
+
if (s.status !== 'skipped' || s.blockedBy === undefined) continue
|
|
133
|
+
const list = blockedBy.get(s.blockedBy)
|
|
134
|
+
const id = escapeMarkdownCell(escapeInline(s.taskId))
|
|
135
|
+
if (list === undefined) blockedBy.set(s.blockedBy, [id])
|
|
136
|
+
else list.push(id)
|
|
137
|
+
}
|
|
138
|
+
lines.push('### Failures')
|
|
139
|
+
lines.push('')
|
|
140
|
+
for (const t of failed) {
|
|
141
|
+
// The signal an exit above 128 stands for, as the frame and `vx last`
|
|
142
|
+
// say it (the shell's convention, so a command exiting 137 on its
|
|
143
|
+
// own reads the same).
|
|
144
|
+
const signal = exitSignal(t.exitCode)
|
|
145
|
+
const blocked = blockedBy.get(t.taskId) ?? []
|
|
146
|
+
lines.push(
|
|
147
|
+
`- **${escapeMarkdownCell(escapeInline(t.taskId))}** — ${t.notReady !== undefined ? `never ready (${t.notReady === 'timeout' ? 'timed out' : t.notReady === 'exited' ? 'exited' : 'spawn failed'}), exit ${t.exitCode}` : t.timedOut === true ? `timed out, exit ${t.exitCode}` : `exit ${t.exitCode}${signal === undefined ? '' : ` (128 + ${signal})`}`}${t.sandboxViolations !== undefined && t.sandboxViolations > 0 ? ` · ${t.sandboxViolations} sandbox violation${t.sandboxViolations === 1 ? '' : 's'}` : ''}${blocked.length > 0 ? ` · blocked ${blocked.join(', ')}` : ''}`,
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
lines.push('')
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const header = ['Task', 'Status', 'Duration']
|
|
154
|
+
lines.push(`| ${header.join(' | ')} |`)
|
|
155
|
+
lines.push(`|${header.map(() => ' --- ').join('|')}|`)
|
|
156
|
+
// Failures first (the eye lands on the table's top rows), then execution
|
|
157
|
+
// order as delivered.
|
|
158
|
+
const ordered = [...failed, ...summary.tasks.filter((t) => t.status !== 'failed')]
|
|
159
|
+
for (const t of ordered) {
|
|
160
|
+
const cells = [escapeMarkdownCell(escapeInline(t.taskId)), statusLabel(t), fmtMs(t.durationMs)]
|
|
161
|
+
lines.push(`| ${cells.join(' | ')} |`)
|
|
162
|
+
}
|
|
163
|
+
lines.push('')
|
|
164
|
+
|
|
165
|
+
// A one-line footer so a page with several vx runs stays attributable.
|
|
166
|
+
// `restored` counts only the hits that wrote outputs; it once counted
|
|
167
|
+
// every hit, up-to-date ones included.
|
|
168
|
+
const restored = summary.restoredLocalCount + summary.restoredRemoteCount
|
|
169
|
+
const passed = summary.tasks.filter((t) => isPassStatus(t.status)).length
|
|
170
|
+
lines.push(
|
|
171
|
+
`<sub>vx ${summary.run.vxVersion} · ${codeSpan(summary.run.command)} · ${passed}/${summary.taskCount} passed · ${summary.upToDateCount} up-to-date · ${restored} restored</sub>`,
|
|
172
|
+
)
|
|
173
|
+
lines.push('')
|
|
174
|
+
return lines.join('\n')
|
|
175
|
+
}
|