Skip to content

Commit cdc7a3d

Browse files
Merge pull request #13 from manaflow-ai/feat/team-friendly-signers
feat: require signatures from primary authors and the opener only
2 parents 212a0f2 + 0a9c9e3 commit cdc7a3d

14 files changed

Lines changed: 136 additions & 217 deletions

README.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ The action exposes `signature_recorded=true` only after it persists a new signat
155155

156156
The action exposes `cla_passed=true` only after the write-capable signer has confirmed that every required contributor is signed and successfully applied the final all-signed CLA bot comment. It remains `false` for signer-preflight, unsigned, closed, and error runs. `signature_recorded=true` only means that this run persisted one new signature, so it can be true while `cla_passed` remains false for another unsigned contributor. `cla_passed` is a per-run result, not durable authorization; consumers must also require the writer job to succeed.
157157

158-
For a least-privilege admission gate, run the action with `mode: signer-preflight` in a separate job that has only `contents: read`, `pull-requests: read`, and `issues: read`. The GraphQL commit identity connection requires `contents: read` in private repositories. The mode re-fetches the live Pull Request, resolves the current author/co-author/committer identities through the same bounded GraphQL query as the signer, and verifies the event comment against the canonical unedited comment. It sets `signer_authorized=true` only for a matching account ID and emits the validated head and base commits as `head_sha` and `base_sha`. It also emits `comment_id`, `comment_created_at`, and `comment_author_id` for the exact declaration it authenticated. Pass those three outputs to the writer's `expected-comment-id`, `expected-comment-created-at`, and `expected-comment-author-id` inputs. The writer re-fetches that exact comment before every ledger or Pull Request comment write and accepts no replacement declaration. Inspect `signer_decision` to distinguish `authorized`, an exact declaration accepted by identity policy, from `unauthorized`, an exact declaration rejected by identity policy, and `error`, a validation or GitHub request failure. The action fails the job for both non-authorized decisions. Run the write-capable signer only when `signer_authorized` is `'true'`. Pass both SHA outputs to its `expected-head-sha` and `expected-base-sha` inputs. The writer compares all five values with the live Pull Request and comment before any ledger or comment write and on every later revalidation, so a force-push, base-branch advance, edited comment, or replacement comment fails closed. The write-capable signer must still perform its own live validation after the gate because preflight outputs are ephemeral admission results, not durable authorization tokens.
158+
For a least-privilege admission gate, run the action with `mode: signer-preflight` in a separate job that has only `contents: read`, `pull-requests: read`, and `issues: read`. The GraphQL commit identity connection requires `contents: read` in private repositories. The mode re-fetches the live Pull Request, resolves the current primary author identities through the same bounded GraphQL query as the signer, and verifies the event comment against the canonical unedited comment. It sets `signer_authorized=true` only for a matching account ID and emits the validated head and base commits as `head_sha` and `base_sha`. It also emits `comment_id`, `comment_created_at`, and `comment_author_id` for the exact declaration it authenticated. Pass those three outputs to the writer's `expected-comment-id`, `expected-comment-created-at`, and `expected-comment-author-id` inputs. The writer re-fetches that exact comment before every ledger or Pull Request comment write and accepts no replacement declaration. Inspect `signer_decision` to distinguish `authorized`, an exact declaration accepted by identity policy, from `unauthorized`, an exact declaration rejected by identity policy, and `error`, a validation or GitHub request failure. The action fails the job for both non-authorized decisions. Run the write-capable signer only when `signer_authorized` is `'true'`. Pass both SHA outputs to its `expected-head-sha` and `expected-base-sha` inputs. The writer compares all five values with the live Pull Request and comment before any ledger or comment write and on every later revalidation, so a force-push, base-branch advance, edited comment, or replacement comment fails closed. The write-capable signer must still perform its own live validation after the gate because preflight outputs are ephemeral admission results, not durable authorization tokens.
159159

160160
The action publishes an all-signed bot comment only after it revalidates the signing comments and persists any new signatures. If a signer edits or deletes the declaration during the run, the ledger and the previous trusted bot status stay unchanged.
161161

@@ -219,12 +219,12 @@ The action re-fetches accepted signing comments immediately before a ledger writ
219219
220220
CLA action workflow will be triggered on Pull Request `opened, edited, closed, reopened, synchronize, ready_for_review` events. This workflow will always run in the base repository and that's why we are making use of the [pull_request_target](https://docs.github.com/en/actions/reference/events-that-trigger-workflows#pull_request_target) event. The action validates the live Pull Request state, opener, base repository ID, base branch, head repository ID, head branch, and head commit before it writes signature data.
221221
222-
The action fails closed for every unlinked committer, including metadata that claims to be `GitHub <noreply@github.com>` or `web-flow`. Git names and email addresses are not authenticated and can be forged. `allowlist-ids` cannot match an unresolved identity.
222+
The action fails closed for every unlinked primary commit author. Git names and email addresses are not authenticated and can be forged, and `allowlist-ids` cannot match an unresolved identity. The git committer field is ignored entirely: it records who applied a commit (a maintainer, GitHub's `web-flow` merge, a rebase tool), not who holds the copyright, so `GitHub <noreply@github.com>` merge commits from the "Update branch" button never block a check.
223223
224-
GitHub can map an author, co-author, or committer email to an account ID, but this mapping does not authenticate authorship. Every non-opener identity from git metadata must post the exact declaration on the current Pull Request. A stored signature is reusable only for the account authenticated by the live Pull Request API as the opener. A committer-only match does not satisfy the opener author/co-author guard.
224+
Signers are the authenticated Pull Request opener plus every primary commit author that GitHub maps to an account. A ledger signature belongs to an account ID and is reused in every later Pull Request, whichever role that account plays. `Co-authored-by` trailers are unverified text that commonly names pairing partners and AI coding agents; they satisfy the opener author/co-author guard but never create a signing obligation. A committer-only match does not satisfy the opener guard.
225225
<br/> When the CLA workflow is triggered on pull request `closed` event and the Pull Request was merged, it will lock the Pull Request conversation with GitHub's `resolved` reason so that the contributors cannot modify or delete the signatures (Pull Request comment) later. The action re-fetches the closed Pull Request and matches its immutable base repository, base branch, opener, and merge state. A later source branch advance or deletion does not prevent locking. This feature is optional. A failed lock request fails the action. The action never removes a conversation lock. A maintainer must unlock a reopened Pull Request before contributors can sign.
226226
227-
The action fails closed when a Pull Request has more than 1,000 commits, more than 101,000 git identity assertions, more than 1,000 comments, or combined comment bodies over 10,000,000 UTF-8 bytes. Bodies over 65,536 UTF-8 bytes are discarded as non-matching comments because signing declarations must match the complete short phrase exactly, but their bytes still count toward the aggregate response bound. Invalid comment data fails closed. The identity bound is the finite envelope of 1,000 commits with up to 100 author/co-author identities plus one committer assertion per commit. A signature ledger also fails closed above 10,000 entries or 1,000,000 bytes. The byte limits bound the validated response data before any downstream comment or ledger writes. These limits bound work on untrusted Pull Request and ledger data. Split a larger contribution or start a new versioned ledger before running the CLA check.
227+
The action fails closed when a Pull Request has more than 1,000 commits, more than 100,000 git identity assertions, more than 1,000 comments, or combined comment bodies over 10,000,000 UTF-8 bytes. Bodies over 65,536 UTF-8 bytes are discarded as non-matching comments because signing declarations must match the complete short phrase exactly, but their bytes still count toward the aggregate response bound. Invalid comment data fails closed. The identity bound is the finite envelope of 1,000 commits with up to 100 author/co-author identities per commit. A signature ledger also fails closed above 10,000 entries or 1,000,000 bytes. The byte limits bound the validated response data before any downstream comment or ledger writes. These limits bound work on untrusted Pull Request and ledger data. Split a larger contribution or start a new versioned ledger before running the CLA check.
228228
229229
#### 3. Signing the CLA
230230
@@ -262,7 +262,7 @@ and `remote-repository-name`: `<your repo name>` in your CLA workflow file.
262262
263263
#### 5. Authenticated opener ID allowlist
264264
265-
Use `allowlist-ids` only when a specific automated Pull Request opener must be exempt. Values are comma-separated numeric GitHub database IDs. The action applies an exemption only when the live Pull Request API authenticates that ID as the opener, and then bypasses only the opener-authorship hard-fail. It never exempts an author, co-author, or committer derived only from git metadata. The deprecated `allowlist` name, email, and glob input is ignored because commit metadata can spoof those values.
265+
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.
266266
267267
##### Demo for step 5
268268
@@ -298,7 +298,7 @@ Do not configure `PERSONAL_ACCESS_TOKEN` when signatures stay in the current rep
298298
| `expected-comment-id` | _optional_ | REST ID of the exact signing comment emitted by `signer-preflight`. Set this together with the creation timestamp and author ID to prevent a writer from accepting another exact declaration on the same Pull Request. | `${{ steps.preflight.outputs.comment_id }}` |
299299
| `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 }}` |
300300
| `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 }}` |
301-
| `allowlist-ids` | _optional_ | Comma-separated numeric GitHub user IDs. Only the authenticated live Pull Request opener can be exempt. Commit-derived identities are never exempt. | Leave empty unless a documented automated opener was reviewed. |
301+
| `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. |
302302
| `allowlist` | _deprecated_ | Ignored. Raw names, emails, and globs are unsafe identity evidence. | |
303303
| `remote-repository-name` | _optional_ | provide the remote repository name where all the signatures should be stored . | remote repository name |
304304
| `remote-organization-name` | _optional_ | provide the remote organization name where all the signatures should be stored. | remote organization name |
@@ -316,7 +316,7 @@ Do not configure `PERSONAL_ACCESS_TOKEN` when signatures stay in the current rep
316316
317317
| Name | Description |
318318
| --------------------- | ----------- |
319-
| `signer_authorized` | In `signer-preflight` mode, set to `'true'` only when the current newly-created, exact, unedited declaration is authored by an authenticated identity in the live Pull Request's author, co-author, committer, or opener set. The write-capable signer must repeat its live checks; this output is not a bearer authorization token. |
319+
| `signer_authorized` | In `signer-preflight` mode, set to `'true'` only when the current newly-created, exact, unedited declaration is authored by an authenticated identity in the live Pull Request's primary author or opener set. The write-capable signer must repeat its live checks; this output is not a bearer authorization token. |
320320
| `cla_passed` | Set to `'true'` only after the write-capable signer has confirmed that every required contributor is signed and successfully applied the final all-signed CLA bot comment. It is `'false'` for signer-preflight, unsigned, closed, and error runs. It is independent of `signature_recorded`, which can be `'true'` while another contributor remains unsigned. |
321321
| `signer_decision` | In `signer-preflight` mode, `authorized` means the declaration passed identity policy, `unauthorized` means an exact current declaration failed identity policy, and `error` means validation or a GitHub request failed. The action fails the job for both `unauthorized` and `error`; in `sign` mode it remains `error`. |
322322
| `head_sha` | In `signer-preflight` mode, the exact live Pull Request head SHA observed after validation. Pass it to the writer as `expected-head-sha` to reject a force-push between jobs. |

__tests__/integration/preflight.test.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -329,7 +329,7 @@ describe('signer-preflight mode', () => {
329329
['co-author', [{ login: 'alice', id: 1001 }], [{ login: 'bob', id: 2002 }]],
330330
['committer', [{ login: 'alice', id: 1001 }], undefined]
331331
] as const)(
332-
'authorizes a current %s identity by numeric GitHub ID',
332+
'does not admit a %s identity that is not a required signer',
333333
async (_role, authors, coAuthors) => {
334334
const watch = watchCore()
335335
const commits = [
@@ -352,9 +352,13 @@ describe('signer-preflight mode', () => {
352352

353353
await runAction()
354354

355-
expect(watch.outputs).toContainEqual(['signer_authorized', true])
356-
expect(watch.outputs).toContainEqual(['signer_decision', 'authorized'])
357-
expect(watch.failures).toEqual([])
355+
// Co-authors and git committers never need to sign, so a declaration
356+
// from one of them is not an admission of anything.
357+
expect(watch.outputs).toContainEqual(['signer_authorized', false])
358+
expect(watch.outputs).toContainEqual(['signer_decision', 'unauthorized'])
359+
expect(watch.failures.join('\n')).toMatch(
360+
/not authored by an authenticated identity/i
361+
)
358362
expect(writeRequests()).toEqual([])
359363
watch.restore()
360364
}

0 commit comments

Comments
 (0)