@vzn/vx-ci 0.0.0 → 0.0.518

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 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
- Placeholder. See https://github.com/vznjs/vx.
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
@@ -0,0 +1,4 @@
1
+ // Root entry shim: Bun 1.4.0's `--compile` binaries resolve an on-disk
2
+ // package by `<pkg>/index.ts` and ignore package.json `exports` / `main`
3
+ // (see packages/vx/index.ts). Same module as the exports map names.
4
+ export * from './src/index.js'
package/package.json CHANGED
@@ -1,7 +1,45 @@
1
1
  {
2
2
  "name": "@vzn/vx-ci",
3
- "version": "0.0.0",
4
- "description": "Placeholder. The real package is published by vx's release workflow.",
3
+ "version": "0.0.518",
5
4
  "license": "MIT",
6
- "repository": { "type": "git", "url": "git+https://github.com/vznjs/vx.git" }
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.518"
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
+ }