@vzn/vx-ci 0.0.646 → 0.0.649
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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/checks.ts
DELETED
|
@@ -1,199 +0,0 @@
|
|
|
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
|
-
import { truncateUtf8 } from './summary.js'
|
|
7
|
-
|
|
8
|
-
export type FetchFn = (
|
|
9
|
-
url: string,
|
|
10
|
-
init: { method: string; headers: Record<string, string>; body: string; signal?: AbortSignal },
|
|
11
|
-
) => Promise<{ ok: boolean; status: number; text(): Promise<string> }>
|
|
12
|
-
|
|
13
|
-
export interface CheckRunEnv {
|
|
14
|
-
token: string
|
|
15
|
-
repository: string
|
|
16
|
-
/** The commit the check attaches to. On `pull_request` events
|
|
17
|
-
* `GITHUB_SHA` is the MERGE commit; GitHub still renders the check on
|
|
18
|
-
* the PR, so that is the documented default. */
|
|
19
|
-
sha: string
|
|
20
|
-
apiUrl: string
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** Read the Actions environment; `null` when any piece is missing. */
|
|
24
|
-
export function resolveCheckRunEnv(env: Record<string, string | undefined>): CheckRunEnv | null {
|
|
25
|
-
const token = env['GITHUB_TOKEN']
|
|
26
|
-
const repository = env['GITHUB_REPOSITORY']
|
|
27
|
-
const sha = env['GITHUB_SHA']
|
|
28
|
-
// All three treated alike: an EMPTY var is as absent as a missing one.
|
|
29
|
-
// Only the token used to be checked for empty, so `GITHUB_REPOSITORY=''`
|
|
30
|
-
// built a POST to `/repos//check-runs` and `GITHUB_SHA=''` sent
|
|
31
|
-
// `head_sha: ''` — a 404 or 422 warning where a clean decline was meant.
|
|
32
|
-
if (
|
|
33
|
-
token === undefined ||
|
|
34
|
-
token === '' ||
|
|
35
|
-
repository === undefined ||
|
|
36
|
-
repository === '' ||
|
|
37
|
-
sha === undefined ||
|
|
38
|
-
sha === ''
|
|
39
|
-
) {
|
|
40
|
-
return null
|
|
41
|
-
}
|
|
42
|
-
// The same for the API URL: `??` kept an empty one, and the POST went to a
|
|
43
|
-
// relative URL fetch refuses (item 924).
|
|
44
|
-
const apiUrl = env['GITHUB_API_URL']
|
|
45
|
-
return {
|
|
46
|
-
token,
|
|
47
|
-
repository,
|
|
48
|
-
sha,
|
|
49
|
-
apiUrl: apiUrl === undefined || apiUrl === '' ? 'https://api.github.com' : apiUrl,
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** fetch refused the server's certificate (untrusted, expired, wrong name). */
|
|
54
|
-
function isCertificateRefusal(err: unknown): boolean {
|
|
55
|
-
const code = (err as { code?: unknown } | null)?.code
|
|
56
|
-
return (
|
|
57
|
-
typeof code === 'string' &&
|
|
58
|
-
(code.includes('CERT') || code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE')
|
|
59
|
-
)
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* GitHub caps `output.summary` at 65535 characters; truncate with a tell.
|
|
64
|
-
* Counted in UTF-8 bytes, which is never fewer than the characters, so
|
|
65
|
-
* the page fits whichever unit GitHub counts. The cut was in UTF-16 units
|
|
66
|
-
* and split an emoji (the aborted label 🛑) into a lone surrogate the JSON
|
|
67
|
-
* body carried (item 924); it now backs off a continuation byte, as the job
|
|
68
|
-
* summary's does.
|
|
69
|
-
*/
|
|
70
|
-
export function clampSummary(markdown: string): string {
|
|
71
|
-
return truncateUtf8(
|
|
72
|
-
markdown,
|
|
73
|
-
65_535,
|
|
74
|
-
'\n\n…truncated by @vzn/vx-ci (65535-char Checks API limit)',
|
|
75
|
-
)
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export function buildCheckRunPayload(args: {
|
|
79
|
-
summary: RunSummaryRecord
|
|
80
|
-
markdown: string
|
|
81
|
-
name: string
|
|
82
|
-
sha: string
|
|
83
|
-
}): Record<string, unknown> {
|
|
84
|
-
const ok = args.summary.exitOk
|
|
85
|
-
// Stopped, not broken: a cancelled CI job's run has aborted tasks and
|
|
86
|
-
// nothing failed, and GitHub has a conclusion for exactly that.
|
|
87
|
-
const cancelled = !ok && args.summary.failedCount === 0 && args.summary.abortedCount > 0
|
|
88
|
-
return {
|
|
89
|
-
name: args.name,
|
|
90
|
-
head_sha: args.sha,
|
|
91
|
-
status: 'completed',
|
|
92
|
-
conclusion: ok ? 'success' : cancelled ? 'cancelled' : 'failure',
|
|
93
|
-
started_at: new Date(args.summary.startedAt).toISOString(),
|
|
94
|
-
completed_at: new Date(args.summary.endedAt).toISOString(),
|
|
95
|
-
output: {
|
|
96
|
-
title: ok
|
|
97
|
-
? `${args.summary.taskCount} task${args.summary.taskCount === 1 ? '' : 's'} · ${args.summary.hitCount} cached` +
|
|
98
|
-
(args.summary.hitCount > 0
|
|
99
|
-
? ` (${args.summary.upToDateCount} up-to-date, ${args.summary.restoredLocalCount + args.summary.restoredRemoteCount} restored)`
|
|
100
|
-
: '')
|
|
101
|
-
: cancelled
|
|
102
|
-
? `cancelled · ${args.summary.abortedCount} aborted`
|
|
103
|
-
: `${args.summary.failedCount} failed`,
|
|
104
|
-
summary: clampSummary(args.markdown),
|
|
105
|
-
},
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/** Resolves true when `signal` ended the wait. */
|
|
110
|
-
function sleepUnless(ms: number, signal: AbortSignal | undefined): Promise<boolean> {
|
|
111
|
-
return new Promise((resolve) => {
|
|
112
|
-
const done = (): void => {
|
|
113
|
-
clearTimeout(timer)
|
|
114
|
-
signal?.removeEventListener('abort', done)
|
|
115
|
-
resolve(signal?.aborted === true)
|
|
116
|
-
}
|
|
117
|
-
const timer = setTimeout(done, ms)
|
|
118
|
-
signal?.addEventListener('abort', done, { once: true })
|
|
119
|
-
})
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const CHECK_RETRY_DELAYS_MS = [200, 800] as const
|
|
123
|
-
const CHECK_RETRY_STATUS = new Set([502, 503, 504])
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* POST the check run. Failures are REPORTED via `warn`, never thrown —
|
|
127
|
-
* observability must never break a run. `signal` is core's flush deadline:
|
|
128
|
-
* the deadline alone abandoned the request, and a hanging API kept the
|
|
129
|
-
* process alive with no end (item 1055).
|
|
130
|
-
*/
|
|
131
|
-
export async function postCheckRun(args: {
|
|
132
|
-
env: CheckRunEnv
|
|
133
|
-
payload: Record<string, unknown>
|
|
134
|
-
fetchFn: FetchFn
|
|
135
|
-
warn: (m: string) => void
|
|
136
|
-
signal?: AbortSignal
|
|
137
|
-
}): Promise<void> {
|
|
138
|
-
const url = `${args.env.apiUrl}/repos/${args.env.repository}/check-runs`
|
|
139
|
-
const post = () =>
|
|
140
|
-
args.fetchFn(url, {
|
|
141
|
-
method: 'POST',
|
|
142
|
-
headers: {
|
|
143
|
-
authorization: `Bearer ${args.env.token}`,
|
|
144
|
-
accept: 'application/vnd.github+json',
|
|
145
|
-
'content-type': 'application/json',
|
|
146
|
-
'x-github-api-version': '2022-11-28',
|
|
147
|
-
'user-agent': 'vzn-vx-ci',
|
|
148
|
-
},
|
|
149
|
-
body: JSON.stringify(args.payload),
|
|
150
|
-
...(args.signal === undefined ? {} : { signal: args.signal }),
|
|
151
|
-
})
|
|
152
|
-
try {
|
|
153
|
-
// GitHub's own blips (502, 503, 504, a dropped connection) are retried
|
|
154
|
-
// twice, 200 then 800 ms apart, inside the flush deadline: one of them
|
|
155
|
-
// cost the run its check (F-29). A retry after a 502 that GitHub did
|
|
156
|
-
// process adds a second run of the same name, which GitHub shows as one.
|
|
157
|
-
let res: Awaited<ReturnType<FetchFn>> | undefined
|
|
158
|
-
for (let attempt = 0; ; attempt++) {
|
|
159
|
-
const delay = CHECK_RETRY_DELAYS_MS[attempt]
|
|
160
|
-
try {
|
|
161
|
-
res = await post()
|
|
162
|
-
if (!CHECK_RETRY_STATUS.has(res.status) || delay === undefined) break
|
|
163
|
-
} catch (err) {
|
|
164
|
-
// A refused certificate (a GHES host behind a private CA) fails the
|
|
165
|
-
// same way every time: not retried (F-40).
|
|
166
|
-
if (delay === undefined || args.signal?.aborted === true || isCertificateRefusal(err)) {
|
|
167
|
-
throw err
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
if (args.signal?.aborted === true) break
|
|
171
|
-
// The deadline ended the wait: GitHub's last answer is what to tell,
|
|
172
|
-
// not the AbortError a further POST would throw (F-46).
|
|
173
|
-
if (await sleepUnless(delay, args.signal)) break
|
|
174
|
-
}
|
|
175
|
-
if (res === undefined) throw new Error('the flush deadline passed before it could be retried')
|
|
176
|
-
if (!res.ok) {
|
|
177
|
-
const body = await res.text().catch(() => '')
|
|
178
|
-
// GitHub answers a rate limit (primary or secondary) with 403 as often
|
|
179
|
-
// as 429, and the permissions hint sent every such run to its workflow
|
|
180
|
-
// file. Not retried: its retry-after is seconds to minutes, past core's
|
|
181
|
-
// flush deadline (F-13).
|
|
182
|
-
const limited = res.status === 429 || /rate limit/i.test(body)
|
|
183
|
-
const hint = limited
|
|
184
|
-
? ' — rate-limited by GitHub; this run has no check'
|
|
185
|
-
: res.status === 403
|
|
186
|
-
? ' — does the workflow grant `permissions: checks: write`?'
|
|
187
|
-
: ''
|
|
188
|
-
args.warn(`vx-ci: check-run POST failed (${res.status})${hint}: ${body.slice(0, 200)}`)
|
|
189
|
-
}
|
|
190
|
-
} catch (err) {
|
|
191
|
-
// A GHES host behind a private CA: Bun's fetch trusts a CA named there.
|
|
192
|
-
const hint = isCertificateRefusal(err)
|
|
193
|
-
? " — for a host behind a private CA, set NODE_EXTRA_CA_CERTS to its CA's PEM file"
|
|
194
|
-
: ''
|
|
195
|
-
args.warn(
|
|
196
|
-
`vx-ci: check-run POST failed: ${err instanceof Error ? err.message : String(err)}${hint}`,
|
|
197
|
-
)
|
|
198
|
-
}
|
|
199
|
-
}
|