Skip to content

Commit 65d5e7c

Browse files
committed
feat: support prod and staging GitHub Apps
1 parent 20e83fc commit 65d5e7c

File tree

5 files changed

+189
-53
lines changed

5 files changed

+189
-53
lines changed

.gitleaksignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1-
/github/workspace/src/auth.ts:generic-api-key:8
1+
/github/workspace/src/auth.ts:generic-api-key:16
2+
/github/workspace/src/auth.ts:generic-api-key:22

__tests__/auth.test.ts

Lines changed: 93 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,70 @@
1-
import {
2-
authenticate,
3-
GITHUB_AUTH_API_KEY,
4-
GITHUB_AUTH_SERVICE_URL
5-
} from '../src/auth'
1+
import { authenticate, PRODUCTION_SERVICE, STAGING_SERVICE } from '../src/auth'
62
import { jest } from '@jest/globals'
3+
import core from '@actions/core'
74

85
describe('authenticate', () => {
96
const mockFetch = jest.spyOn(global, 'fetch')
107

118
beforeEach(() => {
129
process.env.GITHUB_TOKEN = 'ghp_default_token'
13-
mockFetch.mockClear()
10+
11+
jest.clearAllMocks()
12+
13+
jest.spyOn(core, 'warning').mockReturnValue()
14+
jest.spyOn(core, 'debug').mockReturnValue()
1415
})
1516

16-
it('should use GitHub App authentication when app is installed', async () => {
17+
it('should use production GitHub App when installed', async () => {
1718
mockFetch.mockResolvedValue({
1819
ok: true,
1920
status: 200,
20-
json: async () => Promise.resolve({ token: 'ghs_app_123' })
21+
json: async () => Promise.resolve({ token: 'ghs_prod_token' })
2122
} as Response)
2223

2324
const result = await authenticate(
2425
{ owner: 'dunder-mifflin', repo: 'website' },
2526
'ghp_default_token'
2627
)
2728

28-
expect(result).toBe('ghs_app_123')
29+
expect(result).toBe('ghs_prod_token')
30+
expect(mockFetch).toHaveBeenCalledTimes(1)
31+
expect(mockFetch).toHaveBeenCalledWith(
32+
`${PRODUCTION_SERVICE.url}/github/dunder-mifflin/website/installation-token`,
33+
expect.objectContaining({
34+
method: 'POST',
35+
headers: { Authorization: `Bearer ${PRODUCTION_SERVICE.key}` }
36+
})
37+
)
38+
})
39+
40+
it('should use staging GitHub App when production not installed', async () => {
41+
mockFetch
42+
.mockResolvedValueOnce({
43+
ok: false,
44+
status: 404,
45+
json: async () => ({ error: 'Not installed' })
46+
} as Response)
47+
.mockResolvedValueOnce({
48+
ok: true,
49+
status: 200,
50+
json: async () => ({ token: 'ghs_staging_token' })
51+
} as Response)
52+
53+
const result = await authenticate(
54+
{ owner: 'dunder-mifflin', repo: 'website' },
55+
'ghp_default_token'
56+
)
57+
58+
expect(result).toBe('ghs_staging_token')
59+
expect(mockFetch).toHaveBeenCalledTimes(2)
2960
expect(mockFetch).toHaveBeenCalledWith(
30-
`${GITHUB_AUTH_SERVICE_URL}/github/dunder-mifflin/website/installation-token`,
61+
`${STAGING_SERVICE.url}/github/dunder-mifflin/website/installation-token`,
3162
expect.objectContaining({
3263
method: 'POST',
33-
headers: { Authorization: `Bearer ${GITHUB_AUTH_API_KEY}` }
64+
headers: { Authorization: `Bearer ${STAGING_SERVICE.key}` }
3465
})
3566
)
67+
expect(core.debug).toHaveBeenCalledTimes(1)
3668
})
3769

3870
it('should fall back to standard authentication when app is not installed', async () => {
@@ -48,6 +80,8 @@ describe('authenticate', () => {
4880
)
4981

5082
expect(result).toBe('ghp_default_token')
83+
expect(mockFetch).toHaveBeenCalledTimes(2)
84+
expect(core.debug).toHaveBeenCalledTimes(2)
5185
})
5286

5387
it('should fall back to standard authentication when service is unavailable', async () => {
@@ -65,11 +99,58 @@ describe('authenticate', () => {
6599
const customPAT = 'ghp_custom_pat'
66100

67101
const result = await authenticate(
68-
{ owner: 'owner', repo: 'repo' },
102+
{ owner: 'dunder-mifflin', repo: 'website' },
69103
customPAT
70104
)
71105

72106
expect(result).toBe(customPAT)
73107
expect(mockFetch).not.toHaveBeenCalled()
74108
})
109+
110+
it('should handle all services returning errors', async () => {
111+
mockFetch
112+
.mockResolvedValueOnce({
113+
ok: false,
114+
status: 401,
115+
json: async () => ({ error: 'Unauthorized' })
116+
} as Response)
117+
.mockResolvedValueOnce({
118+
ok: false,
119+
status: 500,
120+
json: async () => ({ error: 'Server error' })
121+
} as Response)
122+
123+
const result = await authenticate(
124+
{ owner: 'dunder-mifflin', repo: 'website' },
125+
'ghp_default_token'
126+
)
127+
128+
expect(result).toBe('ghp_default_token')
129+
expect(mockFetch).toHaveBeenCalledTimes(2)
130+
expect(core.warning).toHaveBeenCalledTimes(2)
131+
})
132+
133+
it('should handle unexpected status codes', async () => {
134+
mockFetch
135+
.mockResolvedValueOnce({
136+
ok: false,
137+
status: 403,
138+
json: async () => ({ error: 'Forbidden' })
139+
} as Response)
140+
.mockResolvedValueOnce({
141+
ok: false,
142+
status: 403,
143+
json: async () => ({ error: 'Forbidden' })
144+
} as Response)
145+
146+
const result = await authenticate(
147+
{ owner: 'dunder-mifflin', repo: 'website' },
148+
'ghp_default_token'
149+
)
150+
151+
expect(result).toBe('ghp_default_token')
152+
expect(core.warning).toHaveBeenCalledWith(
153+
expect.stringContaining('returned unexpected status: 403')
154+
)
155+
})
75156
})

dist/index.js

Lines changed: 38 additions & 17 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/index.js.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/auth.ts

Lines changed: 55 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,28 @@
1-
import * as core from '@actions/core'
1+
import core from '@actions/core'
22
import type { Context } from '@actions/github/lib/context'
33

4-
// TODO: check production URL?
5-
export const GITHUB_AUTH_SERVICE_URL =
6-
'https://github-auth.staging.code-pushup.dev'
7-
8-
export const GITHUB_AUTH_API_KEY =
9-
'18850f2513adad10662e85f4f085a9714e64cef7793fc2ffe903b5ddcd62de42'
10-
114
type TokenResponse = {
125
token: string
136
}
147

8+
type AuthService = {
9+
url: string
10+
key: string
11+
name: string
12+
}
13+
14+
export const STAGING_SERVICE: AuthService = {
15+
url: 'https://github-auth.staging.code-pushup.dev',
16+
key: '18850f2513adad10662e85f4f085a9714e64cef7793fc2ffe903b5ddcd62de42',
17+
name: 'Code PushUp (staging)'
18+
}
19+
20+
export const PRODUCTION_SERVICE: AuthService = {
21+
url: 'https://github-auth.code-pushup.dev',
22+
key: 'b2585352366ceead1323a1f3a7cf6b9212387ea6d2d8aeb397e7950aaa3ba776',
23+
name: 'Code PushUp'
24+
}
25+
1526
export async function authenticate(
1627
{ owner, repo }: Context['repo'],
1728
token: string
@@ -20,29 +31,47 @@ export async function authenticate(
2031
core.info('Using user-provided PAT')
2132
return token
2233
}
34+
const productionResult = await tryService(PRODUCTION_SERVICE, owner, repo)
35+
if (productionResult) {
36+
core.info(`Using ${PRODUCTION_SERVICE.name} GitHub App installation token`)
37+
return productionResult.token
38+
}
39+
const stagingResult = await tryService(STAGING_SERVICE, owner, repo)
40+
if (stagingResult) {
41+
core.info(`Using ${STAGING_SERVICE.name} GitHub App installation token`)
42+
return stagingResult.token
43+
}
44+
core.info('Using default GITHUB_TOKEN')
45+
return token
46+
}
47+
48+
async function tryService(
49+
service: AuthService,
50+
owner: string,
51+
repo: string
52+
): Promise<{ token: string; service: AuthService } | null> {
2353
try {
2454
const response = await fetch(
25-
`${GITHUB_AUTH_SERVICE_URL}/github/${owner}/${repo}/installation-token`,
55+
`${service.url}/github/${owner}/${repo}/installation-token`,
2656
{
2757
method: 'POST',
2858
headers: {
29-
Authorization: `Bearer ${GITHUB_AUTH_API_KEY}`
59+
Authorization: `Bearer ${service.key}`
3060
}
3161
}
3262
)
3363
const data = await response.json()
3464
if (response.ok && isTokenResponse(data)) {
35-
core.info('Using Code PushUp GitHub App installation token')
36-
return data.token
65+
return { token: data.token, service }
3766
}
38-
handleErrorResponse(response.status)
67+
handleErrorResponse(response.status, service.name)
3968
} catch (error) {
40-
core.warning(
41-
`Unable to contact Code PushUp authentication service: ${error}`
69+
core.debug(
70+
`Unable to contact ${service.name} authentication service: ${error}`
4271
)
72+
return null
4373
}
44-
core.info('Using default GITHUB_TOKEN')
45-
return token
74+
return null
4675
}
4776

4877
function isTokenResponse(res: unknown): res is TokenResponse {
@@ -54,18 +83,22 @@ function isTokenResponse(res: unknown): res is TokenResponse {
5483
)
5584
}
5685

57-
function handleErrorResponse(status: number): void {
86+
function handleErrorResponse(status: number, serviceName: string): void {
5887
switch (status) {
5988
case 404:
60-
core.debug('Code PushUp GitHub App not installed on this repository')
89+
core.debug(`${serviceName} GitHub App not installed on this repository`)
6190
break
6291
case 401:
63-
core.warning('Code PushUp authentication service authorization failed')
92+
core.warning(`${serviceName} authentication service authorization failed`)
6493
break
6594
case 500:
66-
core.warning('Code PushUp authentication service temporarily unavailable')
95+
core.warning(
96+
`${serviceName} authentication service temporarily unavailable`
97+
)
6798
break
6899
default:
69-
core.debug(`Code PushUp authentication service returned status ${status}`)
100+
core.warning(
101+
`${serviceName} authentication service returned unexpected status: ${status}`
102+
)
70103
}
71104
}

0 commit comments

Comments
 (0)