Skip to content

Commit 05053ef

Browse files
committed
Refactor deploy-production-failure script to JS Action
1 parent 2ddafef commit 05053ef

10 files changed

Lines changed: 832 additions & 97 deletions

File tree

.github/actions/preview-failure-comment/__tests__/index.spec.ts renamed to .github/actions/deploy-preview-failure-comment/__tests__/index.spec.ts

File renamed without changes.

.github/actions/preview-failure-comment/action.yml renamed to .github/actions/deploy-preview-failure-comment/action.yml

File renamed without changes.

.github/actions/preview-failure-comment/src/index.ts renamed to .github/actions/deploy-preview-failure-comment/src/index.ts

File renamed without changes.
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+
warning: 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+
warning: 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(), `deploy-production-failure-${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('deploy-production-failure 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.warning = vi.fn()
47+
core.setFailed = vi.fn()
48+
})
49+
50+
afterEach(() => {
51+
process.env = originalEnv
52+
vi.unstubAllGlobals()
53+
})
54+
55+
it('skips when head_sha is missing', async () => {
56+
const { run } = await import('../src/index')
57+
58+
const eventPath = createTempEventFile({
59+
'workflow_run': {},
60+
repository: { owner: { login: 'webstackdev' }, name: 'astro.webstackbuilders.com' },
61+
})
62+
63+
process.env['GITHUB_EVENT_PATH'] = eventPath
64+
65+
core.getInput.mockImplementation((name: string, options?: { required?: boolean }) => {
66+
if (name === 'github-token') return 'ghs_test'
67+
if (options?.required) throw new Error(`Missing required input: ${name}`)
68+
return ''
69+
})
70+
71+
const fetchMock = vi.fn(async () => createMockResponse({ ok: true, status: 200, json: {} }))
72+
vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch)
73+
74+
await run()
75+
76+
expect(core.warning).toHaveBeenCalledWith('Missing workflow_run.head_sha; skipping production failure comment.')
77+
expect(fetchMock).not.toHaveBeenCalled()
78+
expect(core.setFailed).not.toHaveBeenCalled()
79+
})
80+
81+
it('creates commit comment with production url when provided', async () => {
82+
const { run } = await import('../src/index')
83+
84+
const eventPath = createTempEventFile({
85+
'workflow_run': { 'head_sha': '0123456789abcdef' },
86+
repository: { owner: { login: 'webstackdev' }, name: 'astro.webstackbuilders.com' },
87+
})
88+
89+
process.env['GITHUB_EVENT_PATH'] = eventPath
90+
91+
const inputs: Record<string, string> = {
92+
'github-token': 'ghs_test',
93+
'preview-url': 'https://example.vercel.app',
94+
}
95+
96+
core.getInput.mockImplementation((name: string, options?: { required?: boolean }) => {
97+
const value = (inputs[name] ?? '').trim()
98+
if (options?.required && !value) {
99+
throw new Error(`Missing required input: ${name}`)
100+
}
101+
return value
102+
})
103+
104+
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
105+
if (String(url).includes('/commits/0123456789abcdef/comments') && init?.method === 'POST') {
106+
return createMockResponse({ ok: true, status: 201, json: { id: 1 } })
107+
}
108+
return createMockResponse({ ok: false, status: 404 })
109+
})
110+
111+
vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch)
112+
113+
await run()
114+
115+
const createCall = fetchMock.mock.calls.find(
116+
([url, init]) => String(url).includes('/commits/0123456789abcdef/comments') && (init as RequestInit | undefined)?.method === 'POST',
117+
)
118+
expect(createCall).toBeTruthy()
119+
120+
const [, init] = createCall as [string, RequestInit]
121+
const body = JSON.parse(String(init.body)) as { body?: string }
122+
123+
expect(body.body).toContain('❌ Production deployment failed.')
124+
expect(body.body).toContain('🔗 https://example.vercel.app')
125+
expect(core.setFailed).not.toHaveBeenCalled()
126+
})
127+
128+
it('creates commit comment without url when none provided', async () => {
129+
const { run } = await import('../src/index')
130+
131+
const eventPath = createTempEventFile({
132+
'workflow_run': { 'head_sha': '0123456789abcdef' },
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, init?: RequestInit) => {
145+
if (String(url).includes('/commits/0123456789abcdef/comments') && init?.method === 'POST') {
146+
return createMockResponse({ ok: true, status: 201, json: { id: 1 } })
147+
}
148+
return createMockResponse({ ok: false, status: 404 })
149+
})
150+
151+
vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch)
152+
153+
await run()
154+
155+
const createCall = fetchMock.mock.calls.find(
156+
([url, init]) => String(url).includes('/commits/0123456789abcdef/comments') && (init as RequestInit | undefined)?.method === 'POST',
157+
)
158+
expect(createCall).toBeTruthy()
159+
160+
const [, init] = createCall as [string, RequestInit]
161+
const body = JSON.parse(String(init.body)) as { body?: string }
162+
163+
expect(body.body).toBe('❌ Production deployment failed. Please review the Vercel logs.')
164+
expect(core.setFailed).not.toHaveBeenCalled()
165+
})
166+
})
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
name: Deploy Production Failure Comment
2+
description: Comments on the commit when a production deployment fails.
3+
4+
inputs:
5+
github-token:
6+
description: GitHub token used to create commit comments.
7+
required: true
8+
preview-url:
9+
description: Production URL (if available) from the Vercel deployment step.
10+
required: false
11+
default: ""
12+
13+
runs:
14+
using: node20
15+
main: dist/index.mjs
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { getInput, info, setFailed, warning } from '@actions/core'
2+
import { readFileSync } from 'fs'
3+
import { pathToFileURL } from 'url'
4+
5+
type WorkflowRunPayload = {
6+
workflow_run?: {
7+
head_sha?: string
8+
}
9+
repository?: {
10+
owner?: {
11+
login?: string
12+
}
13+
name?: string
14+
}
15+
}
16+
17+
const getRequiredEnv = (name: string): string => {
18+
const value = (process.env[name] ?? '').trim()
19+
if (!value) {
20+
throw new Error(`Missing required environment variable: ${name}`)
21+
}
22+
return value
23+
}
24+
25+
const getJsonFromFile = <T>(filePath: string): T => {
26+
const raw = readFileSync(filePath, 'utf8')
27+
return JSON.parse(raw) as T
28+
}
29+
30+
const createGitHubRequestHeaders = (token: string) => ({
31+
Authorization: `Bearer ${token}`,
32+
Accept: 'application/vnd.github+json',
33+
'X-GitHub-Api-Version': '2022-11-28',
34+
'User-Agent': 'webstackbuilders-deploy-production-failure-action',
35+
})
36+
37+
const fetchJson = async <T>(
38+
url: string,
39+
init: RequestInit,
40+
): Promise<{ ok: boolean; status: number; data: T | null }> => {
41+
if (typeof fetch !== 'function') {
42+
throw new Error('Fetch API unavailable in this runtime.')
43+
}
44+
45+
const response = await fetch(url, init)
46+
if (!response.ok) {
47+
return { ok: false, status: response.status, data: null }
48+
}
49+
return { ok: true, status: response.status, data: (await response.json()) as T }
50+
}
51+
52+
export const buildProductionFailureCommentBody = (targetUrl: string): string => {
53+
const trimmedUrl = targetUrl.trim()
54+
return trimmedUrl
55+
? `❌ Production deployment failed.\n\n🔗 ${trimmedUrl}\n\nPlease review the Vercel logs.`
56+
: '❌ Production deployment failed. Please review the Vercel logs.'
57+
}
58+
59+
const createCommitComment = async (params: {
60+
owner: string
61+
repo: string
62+
sha: string
63+
token: string
64+
body: string
65+
}): Promise<void> => {
66+
const url = `https://api.github.com/repos/${params.owner}/${params.repo}/commits/${params.sha}/comments`
67+
const headers = {
68+
...createGitHubRequestHeaders(params.token),
69+
'Content-Type': 'application/json',
70+
}
71+
72+
const { ok, status } = await fetchJson<unknown>(url, {
73+
method: 'POST',
74+
headers,
75+
body: JSON.stringify({ body: params.body }),
76+
})
77+
78+
if (!ok) {
79+
throw new Error(`Unable to create commit comment (status ${status}).`)
80+
}
81+
}
82+
83+
export const run = async (): Promise<void> => {
84+
try {
85+
const githubToken = getInput('github-token', { required: true })
86+
const previewUrl = getInput('preview-url')
87+
88+
const eventPath = getRequiredEnv('GITHUB_EVENT_PATH')
89+
const payload = getJsonFromFile<WorkflowRunPayload>(eventPath)
90+
91+
const sha = (payload.workflow_run?.head_sha ?? '').trim()
92+
if (!sha) {
93+
warning('Missing workflow_run.head_sha; skipping production failure comment.')
94+
return
95+
}
96+
97+
const owner = payload.repository?.owner?.login
98+
const repo = payload.repository?.name
99+
if (!owner || !repo) {
100+
setFailed('Missing repository metadata in event payload.')
101+
return
102+
}
103+
104+
const body = buildProductionFailureCommentBody(previewUrl)
105+
106+
await createCommitComment({
107+
owner,
108+
repo,
109+
sha,
110+
token: githubToken,
111+
body,
112+
})
113+
114+
info('Commented on commit about production deployment failure.')
115+
} catch (error: unknown) {
116+
setFailed(error instanceof Error ? error.message : String(error))
117+
}
118+
}
119+
120+
const mainModulePath = process.argv[1]
121+
if (mainModulePath && import.meta.url === pathToFileURL(mainModulePath).href) {
122+
void run()
123+
}

0 commit comments

Comments
 (0)