Skip to content
Open
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ commit that introduced it.

## Unreleased

### Added

- `trusted-merge-status-context` and `trusted-merge-status-creator-ids`
exempt a merge commit from signing when this repository's newest commit
status with that context is a success created by a configured account.
Automated base-branch merges authored by a bot no longer fail the check,
and no git name, email, or signature is trusted.

### Security

- The write-capable signer now emits `cla_passed=true` only after final
Expand Down
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,9 @@ jobs:
contents: write # this can be read if signatures are in a remote repository
issues: write
pull-requests: write
# No statuses permission is needed. The action fails or succeeds this
# GitHub Actions job through @actions/core and never calls the commit
# status or check-run APIs.
# The action fails or succeeds this job through @actions/core and never
# writes commit statuses or check runs. Add `statuses: read` only when
# trusted-merge-status-context is set; it reads merge commit statuses.
# Advisory signer only. A separate trusted exact-head worker is required
# when this check is required by branch protection. Serialize signer runs
# for one Pull Request. A separate lock job uses the
Expand Down Expand Up @@ -264,6 +264,12 @@ and `remote-repository-name`: `<your repo name>` in your CLA workflow file.

Use `allowlist-ids` for maintainers and documented automation accounts that never need to sign. Values are comma-separated numeric GitHub database IDs. A configured ID is exempt as the Pull Request opener and as a primary commit author, and an allowlisted opener also bypasses the opener-authorship hard-fail. Unlinked identities have no ID and can never match. The deprecated `allowlist` name, email, and glob input is ignored because commit metadata can spoof those values.

##### Trusted merge status

A workflow that merges the base branch into open Pull Requests (a "catch-up" bot) authors merge commits as its own bot account, which cannot sign. To exempt those merges without trusting any git metadata, have that workflow post a commit status on each merge commit it verified, then set `trusted-merge-status-context` to the status context and `trusted-merge-status-creator-ids` to the numeric ID of the account that posts it (41898282 for `github-actions[bot]` using the workflow's `GITHUB_TOKEN`). A commit is exempt only when it has two or more parents and this repository's newest status with that context is a success created by a configured account. Statuses are stored per repository and need statuses write access to create, so a fork cannot vouch for its own commits. Post the status before the merge reaches the Pull Request branch, for example by pushing the commit to a scratch ref first, so the check that the push starts already sees it. Grant `statuses: read` to the signer job (and to a `signer-preflight` gate) when these inputs are set: an explicit permissions list leaves it at `none`, and the status request then fails the run.

The creator ID is the whole trust anchor, so configure only an account whose status-write credential is held by trusted code. `github-actions[bot]` qualifies only if no workflow in the repository gives Pull Request-controlled code a `GITHUB_TOKEN` with `statuses: write` (for example a `pull_request_target` or `workflow_run` job that checks out and runs the head). If one does, use a dedicated GitHub App whose key only the merge workflow can read. Statuses are paged newest first; the action reads up to 5 pages of 100, and a context not found in them counts as unvouched.

##### Demo for step 5

![allowlist](https://github.com/cla-assistant/github-action/blob/master/images/allowlist.gif?raw=true)
Expand Down Expand Up @@ -299,6 +305,8 @@ Do not configure `PERSONAL_ACCESS_TOKEN` when signatures stay in the current rep
| `expected-comment-created-at` | _optional_ | Exact creation timestamp emitted by `signer-preflight` for the authenticated signing comment. Required when `expected-comment-id` is set. | `${{ steps.preflight.outputs.comment_created_at }}` |
| `expected-comment-author-id` | _optional_ | Numeric GitHub account ID emitted by `signer-preflight` for the authenticated signing comment. Required when `expected-comment-id` is set. | `${{ steps.preflight.outputs.comment_author_id }}` |
| `allowlist-ids` | _optional_ | Comma-separated numeric GitHub user IDs that never need to sign, as the opener or as a primary commit author. Unlinked identities cannot match. | Maintainer and reviewed automation account IDs. |
| `trusted-merge-status-context` | _optional_ | Commit status context that exempts a merge commit's primary author when this repository's newest status with that context is a success from a configured creator. Set together with `trusted-merge-status-creator-ids`. | `cmux/catch-up` |
| `trusted-merge-status-creator-ids` | _optional_ | Comma-separated numeric GitHub account IDs whose trusted merge status counts. | 41898282 |
| `allowlist` | _deprecated_ | Ignored. Raw names, emails, and globs are unsafe identity evidence. | |
| `remote-repository-name` | _optional_ | provide the remote repository name where all the signatures should be stored . | remote repository name |
| `remote-organization-name` | _optional_ | provide the remote organization name where all the signatures should be stored. | remote organization name |
Expand Down
254 changes: 254 additions & 0 deletions __tests__/integration/trustedMergeStatus.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
import * as core from '@actions/core'
import { installFakeGitHub, FakeGitHub } from '../testHelpers/fakeGithub'
import { CommitStatusFixture } from '../testHelpers/fakeGithubCore'
import { resetEnv, setDefaultInputs } from '../testHelpers/env'
import { reloadOctokit, setContext } from '../testHelpers/context'

const MERGE_SHA = 'a'.repeat(40)
const ACTIONS_BOT = { login: 'github-actions[bot]', id: 41898282 }
const CATCH_UP = 'cmux/catch-up'

/** Runs the action's main entry with a fresh module cache and Octokit. */
async function runAction() {
reloadOctokit()
for (const path of Object.keys(require.cache)) {
if (path.includes('/src/')) delete require.cache[path]
}
const { run } = require('../../src/main') as typeof import('../../src/main')
await run()
}

/** Spies on setFailed, info, and setOutput; restore() undoes the spies. */
function watchCore() {
const failed = jest.spyOn(core, 'setFailed').mockImplementation(() => {})
const info = jest.spyOn(core, 'info').mockImplementation(() => {})
const output = jest.spyOn(core, 'setOutput').mockImplementation(() => {})
return {
get failures() {
return failed.mock.calls.map(c => String(c[0]))
},
get outputs() {
return output.mock.calls.map(c => [c[0], c[1]])
},
restore() {
failed.mockRestore()
info.mockRestore()
output.mockRestore()
}
}
}

describe('trusted merge status', () => {
let fake: FakeGitHub

afterEach(async () => {
await fake.close()
resetEnv()
})

/**
* Builds PR 12 in acme/widgets: a signed commit by alice and a merge commit
* by github-actions[bot], with the given merge statuses (oldest first) in
* this repository and optionally in a fork, then sets the event context.
*/
function setUp(options: {
inputs?: Record<string, string>
parentCount?: number
statuses?: CommitStatusFixture[]
forkStatuses?: CommitStatusFixture[]
}) {
setDefaultInputs({
'path-to-signatures': 'signatures/cla.json',
'trusted-merge-status-context': CATCH_UP,
'trusted-merge-status-creator-ids': String(ACTIONS_BOT.id),
...(options.inputs || {})
})
fake = installFakeGitHub()
const repository = fake.repo('acme', 'widgets')
repository.addPullRequest({
number: 12,
head: { sha: 'headsha', ref: 'feature/cla' },
user: { login: 'alice', id: 1001 },
commits: [
{ author: { login: 'alice', id: 1001 } },
{
oid: MERGE_SHA,
parentCount: options.parentCount ?? 2,
author: ACTIONS_BOT
}
]
})
repository.setFile('signatures/cla.json', {
signedContributors: [{ name: 'alice', id: 1001 }]
})
// Oldest first here; addCommitStatus lists the newest first.
for (const status of options.statuses || []) {
repository.addCommitStatus(MERGE_SHA, status)
}
for (const status of options.forkStatuses || []) {
fake.repo('mallory', 'widgets').addCommitStatus(MERGE_SHA, status)
}
setContext({
owner: 'acme',
repo: 'widgets',
issueNumber: 12,
actor: 'alice',
eventName: 'pull_request_target',
payload: {
pull_request: { number: 12, state: 'open' },
repository: { id: repository.state.id },
action: 'synchronize'
}
})
}

const success: CommitStatusFixture = {
context: CATCH_UP,
state: 'success',
creator: ACTIONS_BOT
}

it('skips a merge commit vouched for by a trusted status in this repository', async () => {
setUp({ statuses: [success] })
const watch = watchCore()
await runAction()
expect(watch.failures).toEqual([])
expect(watch.outputs).toContainEqual(['cla_passed', true])
expect(
fake.requestLog.some(
r =>
r.method === 'GET' &&
r.path.startsWith(`/repos/acme/widgets/commits/${MERGE_SHA}/statuses`)
)
).toBe(true)
watch.restore()
})

it('requires the merge author to sign without a status', async () => {
setUp({})
const watch = watchCore()
await runAction()
expect(watch.failures.join('\n')).toMatch(
/Committers of Pull Request number 12/
)
watch.restore()
})

it('ignores a status created by an account that is not configured', async () => {
setUp({
statuses: [
{ context: CATCH_UP, state: 'success', creator: { login: 'x', id: 7 } }
]
})
const watch = watchCore()
await runAction()
expect(watch.failures.join('\n')).toMatch(
/Committers of Pull Request number 12/
)
watch.restore()
})

it('honors only the newest status for the context', async () => {
setUp({ statuses: [success, { ...success, state: 'failure' }] })
const watch = watchCore()
await runAction()
expect(watch.failures.join('\n')).toMatch(
/Committers of Pull Request number 12/
)
watch.restore()
})

it('never exempts a single-parent commit', async () => {
setUp({ parentCount: 1, statuses: [success] })
const watch = watchCore()
await runAction()
expect(watch.failures.join('\n')).toMatch(
/Committers of Pull Request number 12/
)
watch.restore()
})

it('ignores a status that exists only in another repository', async () => {
setUp({ forkStatuses: [success] })
const watch = watchCore()
await runAction()
expect(watch.failures.join('\n')).toMatch(
/Committers of Pull Request number 12/
)
watch.restore()
})

it('reads no statuses when the feature is not configured', async () => {
setUp({
inputs: {
'trusted-merge-status-context': '',
'trusted-merge-status-creator-ids': ''
},
statuses: [success]
})
const watch = watchCore()
await runAction()
expect(watch.failures.join('\n')).toMatch(
/Committers of Pull Request number 12/
)
expect(fake.requestLog.some(r => r.path.includes('/statuses'))).toBe(false)
watch.restore()
})

it('fails closed on a partial configuration', async () => {
setUp({
inputs: { 'trusted-merge-status-creator-ids': '' },
statuses: [success]
})
const watch = watchCore()
await runAction()
expect(watch.failures.join('\n')).toMatch(
/trusted-merge-status-context and trusted-merge-status-creator-ids must be provided together/
)
watch.restore()
})

it('fails closed on malformed creator IDs', async () => {
setUp({
inputs: { 'trusted-merge-status-creator-ids': 'github-actions[bot]' },
statuses: [success]
})
const watch = watchCore()
await runAction()
expect(watch.failures.join('\n')).toMatch(
/must be comma-separated numeric GitHub account IDs/
)
watch.restore()
})

/** `count` statuses for an unrelated context, all newer than `success`. */
function otherStatuses(count: number): CommitStatusFixture[] {
return Array.from({ length: count }, () => ({
context: 'ci/other',
state: 'success' as const,
creator: ACTIONS_BOT
}))
}

it('finds the trusted status past the first page', async () => {
setUp({ statuses: [success, ...otherStatuses(150)] })
const watch = watchCore()
await runAction()
expect(watch.failures).toEqual([])
expect(watch.outputs).toContainEqual(['cla_passed', true])
watch.restore()
})

it('treats a context beyond the page bound as unvouched', async () => {
setUp({ statuses: [success, ...otherStatuses(500)] })
const watch = watchCore()
await runAction()
expect(watch.failures.join('\n')).toMatch(
/Committers of Pull Request number 12/
)
expect(
fake.requestLog.filter(r => r.path.includes('/statuses')).length
).toBe(5)
watch.restore()
})
})
42 changes: 41 additions & 1 deletion __tests__/testHelpers/fakeGithubCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ export interface PullRequest {
merged?: boolean
state?: 'open' | 'closed'
commits: Array<{
/** Commit SHA; defaults to a unique 40-hex value per commit. */
oid?: string
/** Number of parents; 2 or more is a merge commit. Defaults to 1. */
parentCount?: number
author: GitActorFixture
committer?: GitActorFixture
coAuthors?: GitActorFixture[]
Expand Down Expand Up @@ -83,8 +87,16 @@ export interface Workflow {
runs: WorkflowRun[]
}

export interface CommitStatusFixture {
context: string
state: 'success' | 'failure' | 'pending' | 'error'
creator: { login: string; id: number } | null
}

export interface RepoState {
id: number
/** Commit statuses per SHA, newest first like the REST API. */
statuses: Map<string, CommitStatusFixture[]>
files: Map<string, FileRecord>
comments: Map<number, Comment[]>
pulls: Map<number, PullRequest>
Expand All @@ -104,6 +116,8 @@ export interface FakeRepoHandle {
listComments(issueNumber: number): Comment[]
isLocked(issueNumber: number): boolean
addWorkflow(name: string, runs?: WorkflowRun[]): Workflow
/** Record a commit status; the newest is listed first. */
addCommitStatus(sha: string, status: CommitStatusFixture): FakeRepoHandle
state: RepoState
}

Expand Down Expand Up @@ -155,6 +169,7 @@ export function createFakeGitHubCore(): FakeGitHubCore {
r = {
id: nextRepoId++,
files: new Map(),
statuses: new Map(),
comments: new Map(),
pulls: new Map(),
workflows: [],
Expand Down Expand Up @@ -328,6 +343,22 @@ export function createFakeGitHubCore(): FakeGitHubCore {
type: 'Bot'
})
})
// Commit statuses for one SHA in one repository, newest first, paginated.
addRoute(
getRoutes,
'/repos/:owner/:repo/commits/:sha/statuses',
(m, _body, query) => {
const owner = decodeURIComponent(m[1]!)
const name = decodeURIComponent(m[2]!)
const s = decodeURIComponent(m[3]!)
const all = getRepo(owner, name).statuses.get(s) || []
return paginate(
all,
query,
`/repos/${owner}/${name}/commits/${s}/statuses`
)
}
)
addRoute(getRoutes, '/repos/:owner/:repo/git/commits/:sha', m => {
const s = decodeURIComponent(m[3]!)
return json(200, { sha: s, tree: { sha: `tree-${s}` } })
Expand Down Expand Up @@ -501,9 +532,11 @@ export function createFakeGitHubCore(): FakeGitHubCore {
}
: null
})
const edges = pr.commits.map(c => ({
const edges = pr.commits.map((c, index) => ({
node: {
commit: {
oid: c.oid ?? (index + 1).toString(16).padStart(40, 'c'),
parents: { totalCount: c.parentCount ?? 1 },
message: c.message || '',
author: actor(c.author),
committer: actor(c.committer || c.author),
Expand Down Expand Up @@ -631,6 +664,13 @@ export function createFakeGitHubCore(): FakeGitHubCore {
l => l.owner === owner && l.repo === name && l.issue === issueNumber
)
},
/** Prepends a status so the list stays newest first, like the API. */
addCommitStatus(sha_, status) {
const list = repo.statuses.get(sha_) || []
list.unshift({ ...status })
repo.statuses.set(sha_, list)
return this
},
addWorkflow(name_, runs = []) {
const wf: Workflow = {
id: 10000 + repo.workflows.length,
Expand Down
Loading
Loading