Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitleaksignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/github/workspace/src/auth.ts:generic-api-key:8
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,36 @@ Example of using step outputs:
echo "Artifact ID is ${{ steps.code-pushup.outputs.artifact-id }}"
```

## Authentication

The GitHub Action supports multiple authentication methods to integrate with
your CI workflows.

### GitHub App authentication (recommended)

For the most seamless authentication experience, we recommend installing the
[Code PushUp GitHub App](https://github.com/apps/code-pushup-staging).

The action automatically detects the GitHub App installation and uses it for
enhanced API access. This provides better security through short-lived tokens
and requires zero configuration on your part.

### Default authentication

If the GitHub App is not installed, the action automatically uses the default
`GITHUB_TOKEN` provided by GitHub Actions, which works perfectly for most use
cases.

### Custom authentication

You can provide your own token if you have specific requirements:

```yml
- uses: code-pushup/github-action@v0
with:
token: ${{ secrets.YOUR_PAT }}
```

## Monorepo mode

By default, the GitHub Action assumes your repository is a standalone project.
Expand Down
75 changes: 75 additions & 0 deletions __tests__/auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import {
authenticate,
GITHUB_AUTH_API_KEY,
GITHUB_AUTH_SERVICE_URL
} from '../src/auth'
import { jest } from '@jest/globals'

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

beforeEach(() => {
process.env.GITHUB_TOKEN = 'ghp_default_token'
mockFetch.mockClear()
})

it('should use GitHub App authentication when app is installed', async () => {
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: async () => Promise.resolve({ token: 'ghs_app_123' })
} as Response)

const result = await authenticate(
{ owner: 'dunder-mifflin', repo: 'website' },
'ghp_default_token'
)

expect(result).toBe('ghs_app_123')
expect(mockFetch).toHaveBeenCalledWith(
`${GITHUB_AUTH_SERVICE_URL}/github/dunder-mifflin/website/installation-token`,
expect.objectContaining({
method: 'POST',
headers: { Authorization: `Bearer ${GITHUB_AUTH_API_KEY}` }
})
)
})

it('should fall back to standard authentication when app is not installed', async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 404,
json: async () => Promise.resolve({ error: 'Not installed' })
} as Response)

const result = await authenticate(
{ owner: 'dunder-mifflin', repo: 'website' },
'ghp_default_token'
)

expect(result).toBe('ghp_default_token')
})

it('should fall back to standard authentication when service is unavailable', async () => {
mockFetch.mockRejectedValue(new Error('Network error'))

const result = await authenticate(
{ owner: 'dunder-mifflin', repo: 'website' },
'ghp_default_token'
)

expect(result).toBe('ghp_default_token')
})

it('should use user-provided PAT when different from GITHUB_TOKEN', async () => {
const customPAT = 'ghp_custom_pat'

const result = await authenticate(
{ owner: 'owner', repo: 'repo' },
customPAT
)

expect(result).toBe(customPAT)
expect(mockFetch).not.toHaveBeenCalled()
})
})
60 changes: 57 additions & 3 deletions dist/index.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion dist/index.js.map

Large diffs are not rendered by default.

71 changes: 71 additions & 0 deletions src/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import * as core from '@actions/core'
import type { Context } from '@actions/github/lib/context'

// TODO: check production URL?
export const GITHUB_AUTH_SERVICE_URL =
'https://github-auth.staging.code-pushup.dev'

export const GITHUB_AUTH_API_KEY =
'18850f2513adad10662e85f4f085a9714e64cef7793fc2ffe903b5ddcd62de42'

type TokenResponse = {
token: string
}

export async function authenticate(
{ owner, repo }: Context['repo'],
token: string
): Promise<string> {
if (token !== process.env.GITHUB_TOKEN) {
core.info('Using user-provided PAT')
return token
}
try {
const response = await fetch(
`${GITHUB_AUTH_SERVICE_URL}/github/${owner}/${repo}/installation-token`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${GITHUB_AUTH_API_KEY}`
}
}
)
const data = await response.json()
if (response.ok && isTokenResponse(data)) {
core.info('Using Code PushUp GitHub App installation token')
return data.token
}
handleErrorResponse(response.status)
} catch (error) {
core.warning(
`Unable to contact Code PushUp authentication service: ${error}`
)
}
core.info('Using default GITHUB_TOKEN')
return token
}

function isTokenResponse(res: unknown): res is TokenResponse {
return (
!!res &&
typeof res === 'object' &&
'token' in res &&
typeof res.token === 'string'
)
}

function handleErrorResponse(status: number): void {
switch (status) {
case 404:
core.debug('Code PushUp GitHub App not installed on this repository')
break
case 401:
core.warning('Code PushUp authentication service authorization failed')
break
case 500:
core.warning('Code PushUp authentication service temporarily unavailable')
break
default:
core.debug(`Code PushUp authentication service returned status ${status}`)
}
}
6 changes: 5 additions & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { simpleGit } from 'simple-git'
import { createAnnotationsFromIssues } from './annotations'
import { GitHubApiClient } from './api'
import { REPORT_ARTIFACT_NAME, uploadArtifact } from './artifact'
import { authenticate } from './auth'
import { parseInputs } from './inputs'
import { createOptions } from './options'
import { parseGitRefs } from './refs'
Expand Down Expand Up @@ -44,7 +45,10 @@ export async function run(
}

const refs = parseGitRefs()
const api = new GitHubApiClient(inputs.token, refs, artifact, getOctokit)

const token = await authenticate(github.context.repo, inputs.token)

const api = new GitHubApiClient(token, refs, artifact, getOctokit)

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

Expand Down
Loading