argus-reviewer-e2e 0.1.3 → 0.3.0

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.
Files changed (48) hide show
  1. package/README.md +86 -49
  2. package/action/action.yml +155 -61
  3. package/action/approval-review.mjs +393 -0
  4. package/action/bootstrap.mjs +102 -0
  5. package/action/cli.mjs +36 -0
  6. package/action/emit-review.mjs +336 -0
  7. package/action/runtime.mjs +154 -0
  8. package/action/sticky-comment.cjs +870 -0
  9. package/dist/api.d.ts +2 -0
  10. package/dist/api.js +4 -0
  11. package/dist/cache/store.d.ts +2 -0
  12. package/dist/cache/store.js +10 -3
  13. package/dist/cli.d.ts +137 -0
  14. package/dist/cli.js +677 -60
  15. package/dist/config.d.ts +67 -2
  16. package/dist/config.js +138 -7
  17. package/dist/debug.d.ts +1 -0
  18. package/dist/debug.js +9 -3
  19. package/dist/engine/loop.d.ts +12 -0
  20. package/dist/engine/loop.js +55 -6
  21. package/dist/evidence/ci.d.ts +3 -0
  22. package/dist/evidence/ci.js +3 -1
  23. package/dist/pipeline/budget.d.ts +11 -0
  24. package/dist/pipeline/budget.js +48 -0
  25. package/dist/pipeline/contracts.d.ts +12 -0
  26. package/dist/pipeline/contracts.js +16 -0
  27. package/dist/pipeline/verify.d.ts +27 -0
  28. package/dist/pipeline/verify.js +197 -0
  29. package/dist/probe/queue.d.ts +4 -1
  30. package/dist/probe/queue.js +46 -8
  31. package/dist/report/manifest.d.ts +87 -0
  32. package/dist/report/manifest.js +143 -0
  33. package/dist/report/run.d.ts +9 -0
  34. package/dist/report/run.js +6 -0
  35. package/dist/review/adjudicate.d.ts +63 -0
  36. package/dist/review/adjudicate.js +111 -0
  37. package/dist/review/secrets.d.ts +90 -0
  38. package/dist/review/secrets.js +224 -0
  39. package/dist/review/triage.d.ts +76 -0
  40. package/dist/review/triage.js +163 -0
  41. package/dist/trust.d.ts +50 -0
  42. package/dist/trust.js +103 -0
  43. package/dist/vision/cost.d.ts +14 -1
  44. package/dist/vision/cost.js +13 -0
  45. package/dist/vision/decisions.d.ts +95 -0
  46. package/dist/vision/decisions.js +232 -0
  47. package/package.json +9 -8
  48. package/action/sticky-comment.mjs +0 -404
@@ -0,0 +1,336 @@
1
+ // CLI entrypoint for the approval-review lane.
2
+ //
3
+ // Reads the harness's own code-review.json, maps its verdict to a GitHub
4
+ // review event, and submits the review with the approval-capable token. Runs
5
+ // as its own composite step because actions/github-script's Octokit is pinned
6
+ // to `github.token`, which GitHub refuses to approve with.
7
+
8
+ import { readFileSync } from 'node:fs'
9
+
10
+ import {
11
+ classifyApprovalFailure,
12
+ classifyEvidenceFailure,
13
+ dismissReview,
14
+ listCheckRuns,
15
+ listReviews,
16
+ prNumberFromEvent,
17
+ readCodeReview,
18
+ readPullRequestHead,
19
+ reviewBody,
20
+ reviewEventFor,
21
+ staleApprovals,
22
+ submitApprovalReview,
23
+ verifyEvidenceCheck,
24
+ } from './approval-review.mjs'
25
+ import { setActionOutput } from './runtime.mjs'
26
+
27
+ const DEFAULT_CHECK_APP = 'github-actions'
28
+
29
+ function warn(message) {
30
+ process.stdout.write(`::warning::${message}\n`)
31
+ }
32
+
33
+ function fail(message) {
34
+ process.stdout.write(`::error::${message}\n`)
35
+ }
36
+
37
+ function readEventPayload(path) {
38
+ if (!path) return undefined
39
+ try {
40
+ return JSON.parse(readFileSync(path, 'utf8'))
41
+ } catch {
42
+ return undefined
43
+ }
44
+ }
45
+
46
+ function statusFromMessage(message) {
47
+ const m = /HTTP (\d{3})/.exec(String(message))
48
+ return m ? Number(m[1]) : undefined
49
+ }
50
+
51
+ function detailFrom(err) {
52
+ const raw = String(err?.stderr ?? err?.message ?? '')
53
+ return (
54
+ raw
55
+ .split('\n')
56
+ .map((l) => l.trim())
57
+ .filter((l) => l !== '' && !l.startsWith('{'))
58
+ .slice(-1)[0] ?? 'no detail'
59
+ )
60
+ }
61
+
62
+ function shortSha(sha) {
63
+ const s = String(sha ?? '')
64
+ return s.length > 7 ? s.slice(0, 7) : s
65
+ }
66
+
67
+ /**
68
+ * Dismiss this identity's own approvals that describe a commit other than the
69
+ * current head.
70
+ *
71
+ * Best effort by design. The new approval is already submitted, so failing the
72
+ * step here would report a successful review as broken. A dismissal that fails
73
+ * is a real gap in the control, so it is logged loudly and surfaced as an
74
+ * output rather than swallowed.
75
+ */
76
+ async function retireStaleApprovals({ repo, pr, token, headSha, login, readReviews, dismiss, log }) {
77
+ const warn = (m) => process.stdout.write(`::warning::${m}\n`)
78
+ let existing
79
+ try {
80
+ existing = await readReviews({ repo, pr, token })
81
+ } catch (err) {
82
+ warn(
83
+ `argus-reviewer: could not list existing reviews on #${pr} — ${err?.message ?? err}. ` +
84
+ 'Any earlier approval of an older commit by this identity is still standing.',
85
+ )
86
+ setActionOutput('stale-dismissed', 'unknown')
87
+ return
88
+ }
89
+ const stale = staleApprovals(existing, { login, headSha })
90
+ if (stale.length === 0) {
91
+ setActionOutput('stale-dismissed', '0')
92
+ return
93
+ }
94
+ let dismissed = 0
95
+ for (const r of stale) {
96
+ try {
97
+ await dismiss({
98
+ repo,
99
+ pr,
100
+ reviewId: r.id,
101
+ token,
102
+ message: `Superseded: this approval covered ${shortSha(r.commit_id)}, which is no longer the head of this pull request.`,
103
+ })
104
+ dismissed += 1
105
+ } catch (err) {
106
+ warn(
107
+ `argus-reviewer: could not dismiss review ${r.id} on #${pr} — ${err?.message ?? err}. ` +
108
+ 'It still counts toward the approval gate for a commit it did not review.',
109
+ )
110
+ }
111
+ }
112
+ log(
113
+ `argus-reviewer: retired ${dismissed} of ${stale.length} stale approval(s) by this identity on #${pr} ` +
114
+ `(head ${shortSha(headSha)}).`,
115
+ )
116
+ setActionOutput('stale-dismissed', String(dismissed))
117
+ }
118
+
119
+ /**
120
+ * Resolve everything the lane needs from the step environment.
121
+ * Exported so the wiring is testable without a runner.
122
+ */
123
+ export function resolveLane(env) {
124
+ const token = (env.ARGUS_APPROVAL_TOKEN ?? '').trim()
125
+ const repo = (env.GITHUB_REPOSITORY ?? '').trim()
126
+ const reportDir = (env.ARGUS_REPORT_DIR ?? 'argus-reviewer-report').trim()
127
+ const workDir = (env.VISION_E2E_WORKING_DIR ?? '').trim()
128
+ const pr = Number(env.ARGUS_PR_NUMBER ?? '') || prNumberFromEvent(env.GITHUB_EVENT_NAME, env.__event)
129
+ return {
130
+ token,
131
+ evidence: (env.ARGUS_APPROVAL_EVIDENCE ?? '').trim(),
132
+ check: (env.ARGUS_APPROVAL_CHECK ?? '').trim(),
133
+ // Defaults here as well as on the action input, so a workflow that sets the
134
+ // env directly still gets the pinned producer. An empty value would mean
135
+ // "any producer", which is exactly what the fork-forgery case exploits.
136
+ checkApp: (env.ARGUS_APPROVAL_CHECK_APP ?? '').trim() || DEFAULT_CHECK_APP,
137
+ repo,
138
+ pr,
139
+ reportDir: workDir === '' ? reportDir : `${workDir}/${reportDir}`,
140
+ headSha: (env.ARGUS_HEAD_SHA ?? '').trim() || undefined,
141
+ runUrl: env.GITHUB_RUN_ID
142
+ ? `${env.GITHUB_SERVER_URL ?? 'https://github.com'}/${repo}/actions/runs/${env.GITHUB_RUN_ID}`
143
+ : undefined,
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Run the lane and report what happened. Returns rather than setting
149
+ * `process.exitCode` so the outcome is assertable in tests; the entrypoint
150
+ * below is what turns a non-ok result into a failed step.
151
+ *
152
+ * @param {Record<string, string | undefined>} env
153
+ * @param {{ submit?: typeof submitApprovalReview, read?: typeof readCodeReview, list?: typeof listCheckRuns, head?: typeof readPullRequestHead, reviews?: typeof listReviews, dismiss?: typeof dismissReview }} [deps]
154
+ * @returns {Promise<{ ok: boolean, reviewEvent: string, reviewState: string, message: string }>}
155
+ */
156
+ export async function emitApprovalReview(env, deps = {}) {
157
+ const submit = deps.submit ?? submitApprovalReview
158
+ const read = deps.read ?? readCodeReview
159
+ const list = deps.list ?? listCheckRuns
160
+ const readHead = deps.head ?? readPullRequestHead
161
+ const readReviews = deps.reviews ?? listReviews
162
+ const dismiss = deps.dismiss ?? dismissReview
163
+ const log = (line) => process.stdout.write(`${line}\n`)
164
+ const { token, evidence, check, checkApp, repo, pr, reportDir, headSha, runUrl } = resolveLane(env)
165
+
166
+ if (token === '') {
167
+ // Not an error. The sticky comment is Argus's default behaviour; approving
168
+ // is opt-in and needs a credential this step was not handed.
169
+ const message = 'argus-reviewer: no approval-token supplied — comment only, no formal review.'
170
+ log(message)
171
+ setActionOutput('review-event', 'none')
172
+ setActionOutput('review-state', 'not-submitted')
173
+ return { ok: true, reviewEvent: 'none', reviewState: 'not-submitted', message }
174
+ }
175
+
176
+ if (evidence === '') {
177
+ // Enforced, not advisory. An approval on a protected branch that cites no
178
+ // command is a rubber stamp with extra steps: nobody can re-run what the
179
+ // approval is standing on. Refuse rather than emit an uncited APPROVE.
180
+ const message =
181
+ 'argus-reviewer: approval-token was supplied without approval-evidence. An approval must ' +
182
+ 'cite the test command it stands on. Set approval-evidence to the command(s) that verify ' +
183
+ 'this pull request, or drop approval-token to stay on the comment lane.'
184
+ fail(message)
185
+ setActionOutput('review-event', 'none')
186
+ setActionOutput('review-state', 'no-evidence')
187
+ return { ok: false, reviewEvent: 'none', reviewState: 'no-evidence', message }
188
+ }
189
+
190
+ if (repo === '') {
191
+ const message = 'argus-reviewer: GITHUB_REPOSITORY is empty; cannot submit a review.'
192
+ fail(message)
193
+ setActionOutput('review-event', 'none')
194
+ setActionOutput('review-state', 'no-repo')
195
+ return { ok: false, reviewEvent: 'none', reviewState: 'no-repo', message }
196
+ }
197
+
198
+ if (!pr) {
199
+ // workflow_dispatch and push carry no pull request. Not a failure.
200
+ const message = 'argus-reviewer: no pull request in this event — skipping the review lane.'
201
+ log(message)
202
+ setActionOutput('review-event', 'none')
203
+ setActionOutput('review-state', 'no-pull-request')
204
+ return { ok: true, reviewEvent: 'none', reviewState: 'no-pull-request', message }
205
+ }
206
+
207
+ let codeReview
208
+ try {
209
+ codeReview = await read(reportDir)
210
+ } catch (err) {
211
+ // The code-review step failed or never ran. Fail closed and loudly: a
212
+ // missing report must never become an approval, and must never be silent.
213
+ const message =
214
+ `argus-reviewer: cannot read ${reportDir}/code-review.json — ${err?.message ?? err}. ` +
215
+ 'No review was submitted.'
216
+ fail(message)
217
+ setActionOutput('review-event', 'none')
218
+ setActionOutput('review-state', 'no-report')
219
+ return { ok: false, reviewEvent: 'none', reviewState: 'no-report', message }
220
+ }
221
+
222
+ const event = reviewEventFor(codeReview.verdict)
223
+
224
+ // The run reviewed the code at the head SHA its event carried. If the head has
225
+ // moved since, this review describes commits nobody looked at, and GitHub
226
+ // would attach it to a commit that is no longer the head. Refuse and let a
227
+ // fresh run answer the new head — the same reason `dismiss_stale_reviews`
228
+ // exists, enforced at submit time instead of in the branch settings.
229
+ if (headSha !== undefined) {
230
+ let currentHead
231
+ try {
232
+ currentHead = await readHead({ repo, pr, token })
233
+ } catch (err) {
234
+ const message =
235
+ `argus-reviewer: could not read the current head of #${pr} — ${err?.message ?? err}. ` +
236
+ 'No review was submitted.'
237
+ fail(message)
238
+ setActionOutput('review-event', 'none')
239
+ setActionOutput('review-state', 'no-head')
240
+ return { ok: false, reviewEvent: 'none', reviewState: 'no-head', message }
241
+ }
242
+ if (currentHead !== '' && currentHead !== headSha) {
243
+ const message =
244
+ `argus-reviewer: the head of #${pr} moved from ${shortSha(headSha)} to ${shortSha(currentHead)} ` +
245
+ 'while this run was working. This run reviewed the older commit, so no review was submitted. ' +
246
+ 'The push triggers a new run, which will review the new head.'
247
+ fail(message)
248
+ setActionOutput('review-event', 'none')
249
+ setActionOutput('review-state', 'head-moved')
250
+ return { ok: false, reviewEvent: 'none', reviewState: 'head-moved', message }
251
+ }
252
+ }
253
+
254
+ // APPROVE is the only event that satisfies a protected branch, so it is the
255
+ // only one that has to be backed by a check which actually ran. A negative
256
+ // review is exempt on purpose: red CI is exactly when a REQUEST_CHANGES
257
+ // should still go out.
258
+ let evidenceCheck
259
+ if (event === 'APPROVE') {
260
+ // Two ways to have no evidence, both cheap to detect without an API call:
261
+ // nobody named a check, or the head commit is unknown so the check cannot
262
+ // be bound to anything. Binding to a branch tip instead is the hole this
263
+ // exists to close, so an unknown SHA is a refusal rather than a guess.
264
+ if (check === '' || headSha === undefined) {
265
+ const message = `argus-reviewer: ${classifyEvidenceFailure(check, { ok: false, reason: 'no-check-named' }, checkApp)}`
266
+ fail(message)
267
+ setActionOutput('review-event', 'none')
268
+ setActionOutput('review-state', 'unverified-evidence')
269
+ return { ok: false, reviewEvent: 'none', reviewState: 'unverified-evidence', message }
270
+ }
271
+ let verdictOnCheck
272
+ try {
273
+ verdictOnCheck = verifyEvidenceCheck(await list({ repo, ref: headSha, token }), check, checkApp)
274
+ } catch (err) {
275
+ const message =
276
+ `argus-reviewer: could not read check runs for ${headSha} — ${err?.message ?? err}. ` +
277
+ 'No review was submitted.'
278
+ fail(message)
279
+ setActionOutput('review-event', 'none')
280
+ setActionOutput('review-state', 'no-check-runs')
281
+ return { ok: false, reviewEvent: 'none', reviewState: 'no-check-runs', message }
282
+ }
283
+ if (!verdictOnCheck.ok) {
284
+ const message = `argus-reviewer: ${classifyEvidenceFailure(check, verdictOnCheck, checkApp)}`
285
+ fail(message)
286
+ setActionOutput('review-event', 'none')
287
+ setActionOutput('review-state', 'unverified-evidence')
288
+ return { ok: false, reviewEvent: 'none', reviewState: 'unverified-evidence', message }
289
+ }
290
+ evidenceCheck = verdictOnCheck.check
291
+ }
292
+
293
+ try {
294
+ const { stdout } = await submit({
295
+ repo,
296
+ pr,
297
+ token,
298
+ event,
299
+ body: reviewBody(codeReview, runUrl, evidence, evidenceCheck),
300
+ commitId: headSha,
301
+ })
302
+ const review = JSON.parse(stdout)
303
+ const message =
304
+ `argus-reviewer: submitted ${event} on #${pr} as ${review.user?.login ?? 'unknown'} ` +
305
+ `(review ${review.id}, state ${review.state}).`
306
+ log(message)
307
+ setActionOutput('review-event', event)
308
+ setActionOutput('review-state', review.state ?? 'unknown')
309
+
310
+ // Retire this identity's own approvals of commits that are no longer the
311
+ // head. Done *after* the submit on purpose: if the submit fails, the
312
+ // previous approval is still standing, and a failed review must never be
313
+ // the reason a pull request loses the approval it already had.
314
+ if (event === 'APPROVE' && headSha !== undefined) {
315
+ await retireStaleApprovals({ repo, pr, token, headSha, login: review.user?.login, readReviews, dismiss, log })
316
+ }
317
+
318
+ return { ok: true, reviewEvent: event, reviewState: review.state ?? 'unknown', message }
319
+ } catch (err) {
320
+ const status = err?.status ?? statusFromMessage(err?.message ?? '')
321
+ const remedy = classifyApprovalFailure(status, `${err?.message ?? ''} ${err?.stderr ?? ''}`)
322
+ const message =
323
+ `argus-reviewer: could not submit the ${event} review on #${pr}. ` +
324
+ (remedy ?? `GitHub returned ${status ?? 'an error'}: ${detailFrom(err)}`)
325
+ fail(message)
326
+ setActionOutput('review-event', event)
327
+ setActionOutput('review-state', 'failed')
328
+ return { ok: false, reviewEvent: event, reviewState: 'failed', message }
329
+ }
330
+ }
331
+
332
+ if (process.argv[1] && process.argv[1].endsWith('emit-review.mjs')) {
333
+ const event = readEventPayload(process.env.GITHUB_EVENT_PATH)
334
+ const result = await emitApprovalReview({ ...process.env, __event: event })
335
+ if (!result.ok) process.exitCode = 1
336
+ }
@@ -0,0 +1,154 @@
1
+ import { lstat } from 'node:fs/promises'
2
+ import { appendFileSync, realpathSync } from 'node:fs'
3
+ import { isAbsolute, relative, resolve, sep } from 'node:path'
4
+
5
+ const SHELL_META = /[;&|<>`$\\()[\]{}\r\n\0]/
6
+
7
+ /** Parse the legacy `cli` input as an argv array without invoking a shell. */
8
+ export function parseCommand(raw) {
9
+ const input = String(raw ?? '').trim()
10
+ if (input === '') return undefined
11
+ if (SHELL_META.test(input)) {
12
+ throw new Error('cli must be an executable plus arguments; shell operators are not allowed')
13
+ }
14
+
15
+ const tokens = []
16
+ let token = ''
17
+ let quote
18
+ let escaped = false
19
+ let tokenStarted = false
20
+
21
+ for (const char of input) {
22
+ if (escaped) {
23
+ token += char
24
+ escaped = false
25
+ tokenStarted = true
26
+ continue
27
+ }
28
+ if (char === '\\' && quote !== "'") {
29
+ escaped = true
30
+ tokenStarted = true
31
+ continue
32
+ }
33
+ if (quote !== undefined) {
34
+ if (char === quote) quote = undefined
35
+ else token += char
36
+ tokenStarted = true
37
+ continue
38
+ }
39
+ if (char === '"' || char === "'") {
40
+ quote = char
41
+ tokenStarted = true
42
+ continue
43
+ }
44
+ if (/\s/.test(char)) {
45
+ if (tokenStarted) {
46
+ tokens.push(token)
47
+ token = ''
48
+ tokenStarted = false
49
+ }
50
+ continue
51
+ }
52
+ token += char
53
+ tokenStarted = true
54
+ }
55
+
56
+ if (escaped) throw new Error('cli ends with an incomplete escape')
57
+ if (quote !== undefined) throw new Error('cli has an unterminated quote')
58
+ if (tokenStarted) tokens.push(token)
59
+ if (tokens.length === 0 || tokens[0] === '') throw new Error('cli executable cannot be empty')
60
+ return tokens
61
+ }
62
+
63
+ export function validateVersion(raw) {
64
+ const version = String(raw ?? '').trim()
65
+ if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) {
66
+ throw new Error(`argus-version must be a pinned semver, got: ${version || '<empty>'}`)
67
+ }
68
+ return version
69
+ }
70
+
71
+ export function validateBrowser(raw) {
72
+ const browser = String(raw ?? 'chromium').trim()
73
+ if (!['chromium', 'firefox', 'webkit'].includes(browser)) {
74
+ throw new Error(`browser must be chromium, firefox, or webkit; got: ${browser || '<empty>'}`)
75
+ }
76
+ return browser
77
+ }
78
+
79
+ export function validateBudget(raw, name = 'budget-usd') {
80
+ if (raw === undefined || raw === '') return undefined
81
+ const value = Number(raw)
82
+ if (!Number.isFinite(value) || value <= 0) {
83
+ throw new Error(`${name} must be a positive number`)
84
+ }
85
+ return value
86
+ }
87
+
88
+ export function validateMaxComments(raw) {
89
+ if (raw === undefined || raw === '') return undefined
90
+ if (!/^\d+$/.test(String(raw).trim())) {
91
+ throw new Error('max-comments must be a non-negative integer')
92
+ }
93
+ return Number(raw)
94
+ }
95
+
96
+ export function resolveWorkingDirectory(workspace, input) {
97
+ const root = realpathSync(resolve(workspace))
98
+ const requested = String(input ?? '').trim()
99
+ const unresolvedCwd = requested === '' ? root : resolve(root, requested)
100
+ const unresolvedRel = relative(root, unresolvedCwd)
101
+ if (unresolvedRel === '..' || unresolvedRel.startsWith(`..${sep}`) || isAbsolute(unresolvedRel)) {
102
+ throw new Error('working-directory must stay inside GITHUB_WORKSPACE')
103
+ }
104
+ const cwd = realpathSync(unresolvedCwd)
105
+ const rel = relative(root, cwd)
106
+ if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
107
+ throw new Error('working-directory must stay inside GITHUB_WORKSPACE')
108
+ }
109
+ return unresolvedCwd
110
+ }
111
+
112
+ export function validatePathInput(raw, name) {
113
+ const value = String(raw ?? '')
114
+ if (value.includes('\0') || /[\r\n]/.test(value)) {
115
+ throw new Error(`${name} contains an invalid control character`)
116
+ }
117
+ return value
118
+ }
119
+
120
+ export function assertRegularFileInside(root, file, name) {
121
+ const unresolvedRoot = resolve(root)
122
+ const unresolvedFile = resolve(unresolvedRoot, file)
123
+ const unresolvedRel = relative(unresolvedRoot, unresolvedFile)
124
+ if (unresolvedRel === '..' || unresolvedRel.startsWith(`..${sep}`) || isAbsolute(unresolvedRel)) {
125
+ throw new Error(`${name} must stay inside the working directory`)
126
+ }
127
+ const rootPath = realpathSync(unresolvedRoot)
128
+ const filePath = realpathSync(unresolvedFile)
129
+ const rel = relative(rootPath, filePath)
130
+ if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
131
+ throw new Error(`${name} must stay inside the working directory`)
132
+ }
133
+ return unresolvedFile
134
+ }
135
+
136
+ export async function assertRegularFile(path, name) {
137
+ const stat = await lstat(path)
138
+ if (!stat.isFile() || stat.isSymbolicLink()) {
139
+ throw new Error(`${name} must be a regular non-symlink file: ${path}`)
140
+ }
141
+ return path
142
+ }
143
+
144
+ export function setActionOutput(name, value) {
145
+ const output = process.env.GITHUB_OUTPUT
146
+ const line = `${name}=${String(value).replace(/[\r\n]/g, '')}`
147
+ if (output === undefined || output === '') {
148
+ console.log(line)
149
+ return
150
+ }
151
+ // GITHUB_OUTPUT is supplied by the Actions runner. This synchronous append
152
+ // keeps bootstrap deterministic before the next composite step starts.
153
+ appendFileSync(output, `${line}\n`, 'utf8')
154
+ }