mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
* fix(ci): background steps are single run steps, so no JavaScript action reads the event payload while the runner rewrites it * test(ci): guard the background slot cap and pin the payload-free allowlist
96 lines
3.3 KiB
JavaScript
96 lines
3.3 KiB
JavaScript
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
import { join } from 'node:path'
|
|
import { parse } from 'yaml'
|
|
import { expect, it } from 'vitest'
|
|
|
|
// Why: the runner rewrites $GITHUB_EVENT_PATH in place (truncate, then write) at every step start,
|
|
// and background steps start on their own task. A JavaScript action (@actions/github parses the
|
|
// payload at import) or composite (its inner steps start later) in the background therefore races
|
|
// the foreground's step starts and reads an empty file: "Unexpected end of JSON input".
|
|
const workflowFiles = readdirSync('.github/workflows')
|
|
.filter((name) => /\.ya?ml$/.test(name))
|
|
.map((name) => join('.github/workflows', name))
|
|
const actionFiles = readdirSync('.github/actions')
|
|
.map((name) => join('.github/actions', name, 'action.yml'))
|
|
.filter((file) => existsSync(file))
|
|
|
|
// Single-step JavaScript actions whose bundle never reads the payload (checked by searching dist/index.js).
|
|
const payloadFreeActions = ['ruby/setup-ruby@v1']
|
|
// The runner runs 10 at once; a queued step starts (and rewrites the payload) mid-foreground-step.
|
|
const backgroundSlots = 10
|
|
|
|
function stepLists(file) {
|
|
const document = parse(readFileSync(file, 'utf8'))
|
|
return document.jobs
|
|
? Object.entries(document.jobs).map(([job, value]) => [job, value.steps ?? []])
|
|
: [['composite', document.runs?.steps ?? []]]
|
|
}
|
|
|
|
function backgroundSteps() {
|
|
const found = []
|
|
for (const file of [...workflowFiles, ...actionFiles]) {
|
|
for (const [job, steps] of stepLists(file)) {
|
|
for (const step of steps) {
|
|
if (step.background) {
|
|
found.push({ where: `${file} ${job} ${step.id ?? step.name}`, step })
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return found
|
|
}
|
|
|
|
function readsEventPayload(run) {
|
|
const scripts = [...run.matchAll(/(?:config\/scripts|\.github\/actions)\/[\w./-]+/g)].map(
|
|
(match) => match[0]
|
|
)
|
|
return (
|
|
run.includes('GITHUB_EVENT_PATH') ||
|
|
scripts.some(
|
|
(file) =>
|
|
existsSync(file) &&
|
|
statSync(file).isFile() &&
|
|
readFileSync(file, 'utf8').includes('GITHUB_EVENT_PATH')
|
|
)
|
|
)
|
|
}
|
|
|
|
it('finds the background steps it guards', () => {
|
|
expect(backgroundSteps().length).toBeGreaterThan(10)
|
|
})
|
|
|
|
it('backgrounds only single run steps, never actions that start later steps or parse the payload', () => {
|
|
const actions = backgroundSteps()
|
|
.filter(({ step }) => typeof step.run !== 'string' && !payloadFreeActions.includes(step.uses))
|
|
.map(({ where }) => where)
|
|
expect(actions).toEqual([])
|
|
})
|
|
|
|
it('keeps the event payload out of background run steps', () => {
|
|
const readers = backgroundSteps()
|
|
.filter(({ step }) => typeof step.run === 'string' && readsEventPayload(step.run))
|
|
.map(({ where }) => where)
|
|
expect(readers).toEqual([])
|
|
})
|
|
|
|
it('never queues a background step behind a full slot pool', () => {
|
|
const crowded = []
|
|
for (const file of workflowFiles) {
|
|
for (const [job, steps] of stepLists(file)) {
|
|
const open = new Set()
|
|
for (const [index, step] of steps.entries()) {
|
|
if (step.background) {
|
|
open.add(step.id ?? index)
|
|
}
|
|
for (const id of [step.wait ?? []].flat()) {
|
|
open.delete(id)
|
|
}
|
|
if (open.size > backgroundSlots) {
|
|
crowded.push(`${file} ${job}`)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
expect(crowded).toEqual([])
|
|
})
|