Skip to content

Commit 0c0009a

Browse files
authored
feat: implement GitHub App auth with fallback
1 parent 5b22a49 commit 0c0009a

File tree

7 files changed

+240
-5
lines changed

7 files changed

+240
-5
lines changed

.gitleaksignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/github/workspace/src/auth.ts:generic-api-key:8

README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,36 @@ Example of using step outputs:
103103
echo "Artifact ID is ${{ steps.code-pushup.outputs.artifact-id }}"
104104
```
105105

106+
## Authentication
107+
108+
The GitHub Action supports multiple authentication methods to integrate with
109+
your CI workflows.
110+
111+
### GitHub App authentication (recommended)
112+
113+
For the most seamless authentication experience, we recommend installing the
114+
[Code PushUp GitHub App](https://github.com/apps/code-pushup-staging).
115+
116+
The action automatically detects the GitHub App installation and uses it for
117+
enhanced API access. This provides better security through short-lived tokens
118+
and requires zero configuration on your part.
119+
120+
### Default authentication
121+
122+
If the GitHub App is not installed, the action automatically uses the default
123+
`GITHUB_TOKEN` provided by GitHub Actions, which works perfectly for most use
124+
cases.
125+
126+
### Custom authentication
127+
128+
You can provide your own token if you have specific requirements:
129+
130+
```yml
131+
- uses: code-pushup/github-action@v0
132+
with:
133+
token: ${{ secrets.YOUR_PAT }}
134+
```
135+
106136
## Monorepo mode
107137

108138
By default, the GitHub Action assumes your repository is a standalone project.

__tests__/auth.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import {
2+
authenticate,
3+
GITHUB_AUTH_API_KEY,
4+
GITHUB_AUTH_SERVICE_URL
5+
} from '../src/auth'
6+
import { jest } from '@jest/globals'
7+
8+
describe('authenticate', () => {
9+
const mockFetch = jest.spyOn(global, 'fetch')
10+
11+
beforeEach(() => {
12+
process.env.GITHUB_TOKEN = 'ghp_default_token'
13+
mockFetch.mockClear()
14+
})
15+
16+
it('should use GitHub App authentication when app is installed', async () => {
17+
mockFetch.mockResolvedValue({
18+
ok: true,
19+
status: 200,
20+
json: async () => Promise.resolve({ token: 'ghs_app_123' })
21+
} as Response)
22+
23+
const result = await authenticate(
24+
{ owner: 'dunder-mifflin', repo: 'website' },
25+
'ghp_default_token'
26+
)
27+
28+
expect(result).toBe('ghs_app_123')
29+
expect(mockFetch).toHaveBeenCalledWith(
30+
`${GITHUB_AUTH_SERVICE_URL}/github/dunder-mifflin/website/installation-token`,
31+
expect.objectContaining({
32+
method: 'POST',
33+
headers: { Authorization: `Bearer ${GITHUB_AUTH_API_KEY}` }
34+
})
35+
)
36+
})
37+
38+
it('should fall back to standard authentication when app is not installed', async () => {
39+
mockFetch.mockResolvedValue({
40+
ok: false,
41+
status: 404,
42+
json: async () => Promise.resolve({ error: 'Not installed' })
43+
} as Response)
44+
45+
const result = await authenticate(
46+
{ owner: 'dunder-mifflin', repo: 'website' },
47+
'ghp_default_token'
48+
)
49+
50+
expect(result).toBe('ghp_default_token')
51+
})
52+
53+
it('should fall back to standard authentication when service is unavailable', async () => {
54+
mockFetch.mockRejectedValue(new Error('Network error'))
55+
56+
const result = await authenticate(
57+
{ owner: 'dunder-mifflin', repo: 'website' },
58+
'ghp_default_token'
59+
)
60+
61+
expect(result).toBe('ghp_default_token')
62+
})
63+
64+
it('should use user-provided PAT when different from GITHUB_TOKEN', async () => {
65+
const customPAT = 'ghp_custom_pat'
66+
67+
const result = await authenticate(
68+
{ owner: 'owner', repo: 'repo' },
69+
customPAT
70+
)
71+
72+
expect(result).toBe(customPAT)
73+
expect(mockFetch).not.toHaveBeenCalled()
74+
})
75+
})

dist/index.js

Lines changed: 57 additions & 3 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: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import * as core from '@actions/core'
2+
import type { Context } from '@actions/github/lib/context'
3+
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+
11+
type TokenResponse = {
12+
token: string
13+
}
14+
15+
export async function authenticate(
16+
{ owner, repo }: Context['repo'],
17+
token: string
18+
): Promise<string> {
19+
if (token !== process.env.GITHUB_TOKEN) {
20+
core.info('Using user-provided PAT')
21+
return token
22+
}
23+
try {
24+
const response = await fetch(
25+
`${GITHUB_AUTH_SERVICE_URL}/github/${owner}/${repo}/installation-token`,
26+
{
27+
method: 'POST',
28+
headers: {
29+
Authorization: `Bearer ${GITHUB_AUTH_API_KEY}`
30+
}
31+
}
32+
)
33+
const data = await response.json()
34+
if (response.ok && isTokenResponse(data)) {
35+
core.info('Using Code PushUp GitHub App installation token')
36+
return data.token
37+
}
38+
handleErrorResponse(response.status)
39+
} catch (error) {
40+
core.warning(
41+
`Unable to contact Code PushUp authentication service: ${error}`
42+
)
43+
}
44+
core.info('Using default GITHUB_TOKEN')
45+
return token
46+
}
47+
48+
function isTokenResponse(res: unknown): res is TokenResponse {
49+
return (
50+
!!res &&
51+
typeof res === 'object' &&
52+
'token' in res &&
53+
typeof res.token === 'string'
54+
)
55+
}
56+
57+
function handleErrorResponse(status: number): void {
58+
switch (status) {
59+
case 404:
60+
core.debug('Code PushUp GitHub App not installed on this repository')
61+
break
62+
case 401:
63+
core.warning('Code PushUp authentication service authorization failed')
64+
break
65+
case 500:
66+
core.warning('Code PushUp authentication service temporarily unavailable')
67+
break
68+
default:
69+
core.debug(`Code PushUp authentication service returned status ${status}`)
70+
}
71+
}

src/main.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { simpleGit } from 'simple-git'
66
import { createAnnotationsFromIssues } from './annotations'
77
import { GitHubApiClient } from './api'
88
import { REPORT_ARTIFACT_NAME, uploadArtifact } from './artifact'
9+
import { authenticate } from './auth'
910
import { parseInputs } from './inputs'
1011
import { createOptions } from './options'
1112
import { parseGitRefs } from './refs'
@@ -44,7 +45,10 @@ export async function run(
4445
}
4546

4647
const refs = parseGitRefs()
47-
const api = new GitHubApiClient(inputs.token, refs, artifact, getOctokit)
48+
49+
const token = await authenticate(github.context.repo, inputs.token)
50+
51+
const api = new GitHubApiClient(token, refs, artifact, getOctokit)
4852

4953
const result = await runInCI(refs, api, options, git)
5054

0 commit comments

Comments
 (0)