Skip to content

Commit 2ddafef

Browse files
committed
Refactor preview-failure-comment script to JS Action
1 parent fc44e26 commit 2ddafef

3 files changed

Lines changed: 321 additions & 0 deletions

File tree

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import { writeFileSync } from 'fs'
2+
import { tmpdir } from 'os'
3+
import { resolve } from 'path'
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
type CoreMock = {
7+
getInput: ReturnType<typeof vi.fn>
8+
info: ReturnType<typeof vi.fn>
9+
notice: ReturnType<typeof vi.fn>
10+
setFailed: ReturnType<typeof vi.fn>
11+
}
12+
13+
const core: CoreMock = {
14+
getInput: vi.fn(),
15+
info: vi.fn(),
16+
notice: vi.fn(),
17+
setFailed: vi.fn(),
18+
}
19+
20+
vi.mock('@actions/core', () => core)
21+
22+
const createTempEventFile = (payload: unknown) => {
23+
const filePath = resolve(tmpdir(), `lint-and-unit-tests-pass-${Date.now()}-${Math.random().toString(16).slice(2)}.json`)
24+
writeFileSync(filePath, JSON.stringify(payload), 'utf8')
25+
return filePath
26+
}
27+
28+
const createMockResponse = (params: { ok: boolean; status: number; json?: unknown }) => {
29+
return {
30+
ok: params.ok,
31+
status: params.status,
32+
json: async () => params.json,
33+
}
34+
}
35+
36+
describe('lint-and-unit-tests-pass action', () => {
37+
const originalEnv = process.env
38+
39+
beforeEach(() => {
40+
vi.clearAllMocks()
41+
process.env = { ...originalEnv }
42+
delete process.env['GITHUB_EVENT_PATH']
43+
44+
core.getInput = vi.fn()
45+
core.info = vi.fn()
46+
core.notice = vi.fn()
47+
core.setFailed = vi.fn()
48+
})
49+
50+
afterEach(() => {
51+
process.env = originalEnv
52+
vi.unstubAllGlobals()
53+
})
54+
55+
it('skips verification for hotfix pull_request workflow_run', async () => {
56+
const { run } = await import('../src/index')
57+
58+
const eventPath = createTempEventFile({
59+
'workflow_run': {
60+
'id': 123,
61+
'head_branch': 'hotfix/something',
62+
'event': 'pull_request',
63+
},
64+
repository: { owner: { login: 'webstackdev' }, name: 'astro.webstackbuilders.com' },
65+
})
66+
67+
process.env['GITHUB_EVENT_PATH'] = eventPath
68+
69+
core.getInput.mockImplementation((name: string, options?: { required?: boolean }) => {
70+
if (name === 'github-token') return 'ghs_test'
71+
if (options?.required) throw new Error(`Missing required input: ${name}`)
72+
return ''
73+
})
74+
75+
const fetchMock = vi.fn(async () => createMockResponse({ ok: true, status: 200, json: {} }))
76+
vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch)
77+
78+
await run()
79+
80+
expect(core.notice).toHaveBeenCalledWith('Hotfix branch: skipping CI verification requirements.')
81+
expect(fetchMock).not.toHaveBeenCalled()
82+
expect(core.setFailed).not.toHaveBeenCalled()
83+
})
84+
85+
it('fails when required jobs are missing or not successful', async () => {
86+
const { run } = await import('../src/index')
87+
88+
const eventPath = createTempEventFile({
89+
'workflow_run': {
90+
'id': 123,
91+
'head_branch': 'feature/thing',
92+
'event': 'pull_request',
93+
},
94+
repository: { owner: { login: 'webstackdev' }, name: 'astro.webstackbuilders.com' },
95+
})
96+
97+
process.env['GITHUB_EVENT_PATH'] = eventPath
98+
99+
core.getInput.mockImplementation((name: string, options?: { required?: boolean }) => {
100+
if (name === 'github-token') return 'ghs_test'
101+
if (options?.required) throw new Error(`Missing required input: ${name}`)
102+
return ''
103+
})
104+
105+
const fetchMock = vi.fn(async (url: string) => {
106+
if (String(url).includes('/actions/runs/123/jobs')) {
107+
return createMockResponse({
108+
ok: true,
109+
status: 200,
110+
json: {
111+
jobs: [{ name: 'Lint', conclusion: 'success' }],
112+
},
113+
})
114+
}
115+
return createMockResponse({ ok: false, status: 404 })
116+
})
117+
vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch)
118+
119+
await run()
120+
121+
expect(core.setFailed).toHaveBeenCalledWith('Required CI jobs missing or failed: Unit Tests')
122+
})
123+
124+
it('passes when required jobs succeeded', async () => {
125+
const { run } = await import('../src/index')
126+
127+
const eventPath = createTempEventFile({
128+
'workflow_run': {
129+
'id': 123,
130+
'head_branch': 'feature/thing',
131+
'event': 'pull_request',
132+
},
133+
repository: { owner: { login: 'webstackdev' }, name: 'astro.webstackbuilders.com' },
134+
})
135+
136+
process.env['GITHUB_EVENT_PATH'] = eventPath
137+
138+
core.getInput.mockImplementation((name: string, options?: { required?: boolean }) => {
139+
if (name === 'github-token') return 'ghs_test'
140+
if (options?.required) throw new Error(`Missing required input: ${name}`)
141+
return ''
142+
})
143+
144+
const fetchMock = vi.fn(async (url: string) => {
145+
if (String(url).includes('/actions/runs/123/jobs')) {
146+
return createMockResponse({
147+
ok: true,
148+
status: 200,
149+
json: {
150+
jobs: [
151+
{ name: 'Lint', conclusion: 'success' },
152+
{ name: 'Unit Tests', conclusion: 'success' },
153+
],
154+
},
155+
})
156+
}
157+
return createMockResponse({ ok: false, status: 404 })
158+
})
159+
vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch)
160+
161+
await run()
162+
163+
expect(core.setFailed).not.toHaveBeenCalled()
164+
expect(core.info).toHaveBeenCalledWith('Required CI jobs succeeded.')
165+
})
166+
})
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
name: Lint and Unit Tests Pass
2+
description: Verifies Lint and Unit Tests jobs succeeded for the triggering workflow_run.
3+
4+
inputs:
5+
github-token:
6+
description: GitHub token used to query workflow run jobs.
7+
required: true
8+
9+
runs:
10+
using: node20
11+
main: dist/index.mjs
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import { getInput, info, notice, setFailed } from '@actions/core'
2+
import { readFileSync } from 'fs'
3+
import { pathToFileURL } from 'url'
4+
5+
type WorkflowRunPayload = {
6+
workflow_run?: {
7+
id?: number
8+
head_branch?: string
9+
event?: string
10+
}
11+
repository?: {
12+
owner?: {
13+
login?: string
14+
}
15+
name?: string
16+
}
17+
}
18+
19+
type WorkflowJob = {
20+
name?: string
21+
conclusion?: string | null
22+
}
23+
24+
type ListJobsResponse = {
25+
jobs?: WorkflowJob[]
26+
}
27+
28+
const getRequiredEnv = (name: string): string => {
29+
const value = (process.env[name] ?? '').trim()
30+
if (!value) {
31+
throw new Error(`Missing required environment variable: ${name}`)
32+
}
33+
return value
34+
}
35+
36+
const getJsonFromFile = <T>(filePath: string): T => {
37+
const raw = readFileSync(filePath, 'utf8')
38+
return JSON.parse(raw) as T
39+
}
40+
41+
const createGitHubRequestHeaders = (token: string) => ({
42+
Authorization: `Bearer ${token}`,
43+
Accept: 'application/vnd.github+json',
44+
'X-GitHub-Api-Version': '2022-11-28',
45+
'User-Agent': 'webstackbuilders-lint-and-unit-tests-pass-action',
46+
})
47+
48+
const fetchJson = async <T>(
49+
url: string,
50+
init: RequestInit,
51+
): Promise<{ ok: boolean; status: number; data: T | null }> => {
52+
if (typeof fetch !== 'function') {
53+
throw new Error('Fetch API unavailable in this runtime.')
54+
}
55+
56+
const response = await fetch(url, init)
57+
if (!response.ok) {
58+
return { ok: false, status: response.status, data: null }
59+
}
60+
return { ok: true, status: response.status, data: (await response.json()) as T }
61+
}
62+
63+
const listAllJobsForRun = async (params: {
64+
owner: string
65+
repo: string
66+
runId: number
67+
token: string
68+
}): Promise<WorkflowJob[]> => {
69+
const headers = createGitHubRequestHeaders(params.token)
70+
71+
const allJobs: WorkflowJob[] = []
72+
for (let page = 1; page <= 10; page += 1) {
73+
const url = `https://api.github.com/repos/${params.owner}/${params.repo}/actions/runs/${params.runId}/jobs?per_page=100&page=${page}`
74+
const { ok, status, data } = await fetchJson<ListJobsResponse>(url, { headers })
75+
76+
if (!ok) {
77+
throw new Error(`Unable to list jobs for workflow run (status ${status}).`)
78+
}
79+
80+
const jobs = data?.jobs ?? []
81+
allJobs.push(...jobs)
82+
83+
if (jobs.length < 100) {
84+
break
85+
}
86+
}
87+
88+
return allJobs
89+
}
90+
91+
export const run = async (): Promise<void> => {
92+
try {
93+
const githubToken = getInput('github-token', { required: true })
94+
95+
const eventPath = getRequiredEnv('GITHUB_EVENT_PATH')
96+
const payload = getJsonFromFile<WorkflowRunPayload>(eventPath)
97+
98+
const workflowRun = payload.workflow_run
99+
const runId = workflowRun?.id
100+
const branch = workflowRun?.head_branch ?? ''
101+
const event = workflowRun?.event ?? ''
102+
103+
const isHotfix = branch.startsWith('hotfix/')
104+
if (event === 'pull_request' && isHotfix) {
105+
notice('Hotfix branch: skipping CI verification requirements.')
106+
return
107+
}
108+
109+
const owner = payload.repository?.owner?.login
110+
const repo = payload.repository?.name
111+
112+
if (!owner || !repo) {
113+
setFailed('Missing repository metadata in event payload.')
114+
return
115+
}
116+
117+
if (typeof runId !== 'number') {
118+
setFailed('Missing workflow_run.id in event payload.')
119+
return
120+
}
121+
122+
const requiredJobs = ['Lint', 'Unit Tests']
123+
const jobs = await listAllJobsForRun({ owner, repo, runId, token: githubToken })
124+
125+
const missing = requiredJobs.filter((requiredName) => {
126+
const job = jobs.find((entry) => entry.name === requiredName)
127+
return !job || job.conclusion !== 'success'
128+
})
129+
130+
if (missing.length > 0) {
131+
setFailed(`Required CI jobs missing or failed: ${missing.join(', ')}`)
132+
return
133+
}
134+
135+
info('Required CI jobs succeeded.')
136+
} catch (error: unknown) {
137+
setFailed(error instanceof Error ? error.message : String(error))
138+
}
139+
}
140+
141+
const mainModulePath = process.argv[1]
142+
if (mainModulePath && import.meta.url === pathToFileURL(mainModulePath).href) {
143+
void run()
144+
}

0 commit comments

Comments
 (0)