Repository navigation
UN-4523 [MISC] CLA check action to replace cla-assistant.io #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
ba7ae51
feat: CLA check action to replace cla-assistant.io
ritwik-g a1b59f2
chore: add MIT licence
ritwik-g 392164d
fix: close CLA bypasses and lost-signature paths from self-review rou…
ritwik-g f336521
fix: ignore edited sign comments and date signatures by the comment
ritwik-g a253251
fix: log ignored edited sign comments and keep re-signing possible
ritwik-g 0109758
fix: only advise re-signing when it would help
ritwik-g 8167421
fix: tidy the ignored-comment advice and the agent-logins note
ritwik-g 5a5ded5
docs: point the agent-logins note at the real exemption rules
ritwik-g File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| name: CI | ||
|
|
||
| on: | ||
| pull_request: | ||
| push: | ||
| branches: [main] | ||
|
|
||
| permissions: | ||
| contents: read | ||
|
|
||
| jobs: | ||
| test: | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - uses: actions/checkout@v4 | ||
| - uses: actions/setup-node@v4 | ||
| with: | ||
| node-version: 24 | ||
| - run: npm test |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| node_modules/ |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| MIT License | ||
|
|
||
| Copyright (c) 2026 Zipstack, Inc. | ||
|
|
||
| Permission is hereby granted, free of charge, to any person obtaining a copy | ||
| of this software and associated documentation files (the "Software"), to deal | ||
| in the Software without restriction, including without limitation the rights | ||
| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | ||
| copies of the Software, and to permit persons to whom the Software is | ||
| furnished to do so, subject to the following conditions: | ||
|
|
||
| The above copyright notice and this permission notice shall be included in all | ||
| copies or substantial portions of the Software. | ||
|
|
||
| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | ||
| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | ||
| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | ||
| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | ||
| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | ||
| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||
| SOFTWARE. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,118 @@ | ||
| # cla-action | ||
|
|
||
| A GitHub Action that enforces the Zipstack Contributor License Agreement (CLA) on pull requests. It replaces cla-assistant.io with something that runs on GitHub Actions and depends on no external service. | ||
|
|
||
| ## How it works | ||
|
|
||
| On every pull request, and on every new PR comment: | ||
|
|
||
| 1. It lists the PR's commit authors, plus the PR author. | ||
| 2. Each of them passes if they have already signed, are a bot or on the allowlist, or are a member of the organization. | ||
| 3. It sets a commit status (`license/cla` by default) on the PR head: `success` when everyone passes, `failure` otherwise. **Make this status the required check.** | ||
| 4. When someone still needs to sign, it posts one comment on the PR naming them and giving the exact phrase to post. It edits that same comment as things change, instead of adding new ones. | ||
| 5. When a commit author or the PR author has posted the phrase in a comment on the PR, it records their signature. Any comment re-runs the check; the PR comment suggests `recheck`. | ||
|
|
||
| Every run reads all of the PR's comments for signatures, not just the comment that triggered it. The workflow's concurrency group keeps one pending run per PR and cancels the one already pending, so a sign comment's own run may never execute; the next run records it instead. | ||
|
|
||
| The PR author is checked even without commits of their own. Commit authors are matched by the commit's email address, which the committer chooses, so without that a contributor could attribute every commit to an exempt or already-signed account. | ||
|
|
||
| GitHub lists at most 250 commits of a pull request. A PR with more fails the check and asks to be split, rather than passing on a partial list. | ||
|
|
||
| A signature covers every repository that uses the same signatures file, so a contributor signs once. | ||
|
|
||
| ### Where signatures live | ||
|
|
||
| Signatures are stored in one JSON file (`signatures.json`) in a private repository, which the action reads and writes through the GitHub contents API: | ||
|
|
||
| ```json | ||
| { | ||
| "version": 1, | ||
| "signatures": [ | ||
| { | ||
| "id": 583231, | ||
| "login": "octocat", | ||
| "signed_at": "2026-10-27T10:00:00.000Z", | ||
| "cla_document": "https://gist.github.com/...", | ||
| "repository": "Zipstack/unstract", | ||
| "pull_request": 2373, | ||
| "comment_url": "https://github.com/Zipstack/unstract/pull/2373#issuecomment-..." | ||
| } | ||
| ] | ||
| } | ||
| ``` | ||
|
|
||
| Signers are matched by numeric GitHub user id, so renaming an account doesn't invalidate a signature. Each write includes the file's current blob sha, so two signatures recorded at the same moment can't overwrite each other: the later write is rejected, re-reads the file and retries. | ||
|
|
||
| ## Setup | ||
|
|
||
| 1. **Signatures repository.** Create a private repository (e.g. `Zipstack/cla-signatures`). The action creates `signatures.json` on the first signature. | ||
| 2. **GitHub App.** Create an app owned by the organization with these permissions: | ||
| - Repository → Contents: **Read and write**, installed on the signatures repository only. | ||
| - Organization → Members: **Read**, needed when `exempt-org-members` is on. | ||
|
|
||
| Store its id as the organization variable `CLA_APP_ID` and its private key as the organization secret `CLA_APP_PRIVATE_KEY`. | ||
| 3. **Workflow.** Copy [`examples/cla.yml`](examples/cla.yml) to `.github/workflows/cla.yml` in each repository that requires the CLA. | ||
| 4. **Branch protection.** Require the `license/cla` status. If the repository used cla-assistant.io, it already requires that context. Unlink the repository from cla-assistant.io first, so the two don't both set the status. | ||
|
|
||
| ### Why `pull_request_target` | ||
|
|
||
| PRs from forks don't get secrets under `pull_request`, and the action needs a token for the signatures repository. `pull_request_target` runs in the context of the base repository, so it gets the token. That is safe only because the workflow never checks out or runs the PR's code, so **never add a checkout step to this workflow.** | ||
|
|
||
| ## Inputs | ||
|
|
||
| | Input | Default | Description | | ||
| | --- | --- | --- | | ||
| | `cla-document-url` | — (required) | Link to the CLA text. | | ||
| | `signatures-repository` | — (required) | `owner/name` of the signatures repository. | | ||
| | `signatures-token` | — (required) | Token with contents read/write on the signatures repository, plus organization members read. | | ||
| | `signatures-path` | `signatures.json` | Path of the signatures file. | | ||
| | `signatures-branch` | `main` | Branch to read and write. | | ||
| | `sign-phrase` | `I have read the CLA Document and I hereby sign the CLA` | Comment that signs. Case, whitespace and a trailing full stop are ignored. | | ||
| | `allowlist` | `*[bot]` | Comma-separated logins that never need to sign. `*` is a wildcard. | | ||
| | `agent-logins` | `claude` | Coding-agent accounts whose commits count as the PR author's (see below). | | ||
| | `exempt-org-members` | `true` | Skip members of the organization. | | ||
| | `organization` | repository owner | Organization whose members are exempt. | | ||
| | `status-context` | `license/cla` | Name of the commit status. | | ||
| | `github-token` | `${{ github.token }}` | Token for the calling repository. The workflow must grant `pull-requests: write` and `statuses: write`. | | ||
|
|
||
| ## Commits that can't be signed | ||
|
|
||
| A commit whose author email isn't linked to any GitHub account has no GitHub user, so nobody can sign for it. The PR comment lists those commits by short sha and author name (never by email), and the check stays failing until the author links the email to their account or amends the commits. | ||
|
|
||
| ## Commits made by coding agents | ||
|
|
||
| When Claude Code is itself the commit author (for example in a Claude Code cloud session), the commit is authored by `noreply@anthropic.com`, which GitHub links to the account `claude`. A local Claude Code session usually commits as the developer and names Claude only in a `Co-Authored-By` trailer, which the action does not read. The `claude` account can't sign anything on the contributor's behalf, so commits from any login in `agent-logins` are attributed to the PR author, and the PR author signs for them. Org members stay exempt as usual. Don't put agent accounts on the `allowlist`: that would let anyone skip the CLA by having the agent make every commit. | ||
|
|
||
| ## Known limitations | ||
|
|
||
| - **A run that errors posts no status.** The required check then stays pending, and the error is only in the workflow run. API calls have no timeout or retry; only the signatures write is retried, and only on a 409 or 422 response, up to 5 attempts. | ||
| - **A missing signatures file reads as "nobody has signed yet",** and an organization the token can't see reads as "not a member". A misconfigured repository, path, branch or organization therefore asks everyone to sign rather than failing with a configuration error. | ||
| - **A sign comment that is not accepted gets no reply on the PR** (for example from someone who is neither a commit author nor the PR author). The reason is only in the workflow log. | ||
| - **A signature is not tied to a CLA version.** Any signature in the file counts for every repository and every `cla-document-url`. Records from the importer carry `cla_version`; records from the action don't. | ||
| - **The PR comment is found by its marker alone**, whoever wrote it, and an unlinked commit's author name is shown as the committer wrote it. | ||
| - **The signatures file must stay under 1 MB** (a few thousand signatures): above that the contents API stops returning the file and every run fails. | ||
| - **`exempt-org-members` is off only for the exact value `false`.** Any other value, including `False` or `no`, leaves org members exempt. | ||
| - **A PR opened by an account in `agent-logins` fails the check** unless that account itself signs or is exempt (see step 2 of *How it works*), because it counts as the PR author. The action does not stop an agent account from signing. Open the PR from a person's account, or from a GitHub App (`[bot]` accounts are exempt). | ||
| - **An edited or hidden comment never signs**, even when its author edited it into the phrase or a maintainer only hid it; the person posts the phrase again as a new comment. The workflow log says which comment was ignored. This is checked when the signature is recorded, normally seconds after the comment is posted: editing or hiding a comment afterwards does not remove a signature already recorded, and nothing in the action does. | ||
| - **The status description counts unlinked commits** as committers, one per commit. | ||
| - **The file's `version` field is not checked** when it is read. | ||
|
|
||
| ## Migrating from cla-assistant.io | ||
|
|
||
| 1. Log in at [cla-assistant.io](https://cla-assistant.io) with an account that has push access to the linked repository. Open the repository's signature report and download **Complete History as JSON**. | ||
| 2. Convert it: | ||
|
|
||
| ```sh | ||
| GITHUB_TOKEN=$(gh auth token) node scripts/import-cla-assistant.js cla-assistant.json > signatures.json | ||
| ``` | ||
|
|
||
| The script resolves each login to its GitHub user id, skips revoked signatures, keeps the earliest signature per person, and reports any account that no longer exists. To merge into an existing file, pass it as the second argument. | ||
| 3. Commit `signatures.json` to the signatures repository. | ||
|
|
||
| ## Development | ||
|
|
||
| The action has no dependencies; it uses Node's built-in `fetch` and test runner. | ||
|
|
||
| ```sh | ||
| npm test | ||
| ``` |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| name: Zipstack CLA check | ||
| description: Requires every commit author on a pull request to have signed the Contributor License Agreement, and records signatures made by PR comment. | ||
| author: Zipstack | ||
|
|
||
| inputs: | ||
| cla-document-url: | ||
| description: Link to the CLA text that contributors agree to. | ||
| required: true | ||
| signatures-repository: | ||
| description: Repository (owner/name) holding the signatures file, normally private. | ||
| required: true | ||
| signatures-token: | ||
| description: Token with contents read/write on the signatures repository, and organization members read when exempt-org-members is on. Use a GitHub App token. | ||
| required: true | ||
| signatures-path: | ||
| description: Path of the signatures file in the signatures repository. | ||
| default: signatures.json | ||
| signatures-branch: | ||
| description: Branch of the signatures repository to read and write. | ||
| default: main | ||
| sign-phrase: | ||
| description: Exact comment a contributor posts to sign. | ||
| default: I have read the CLA Document and I hereby sign the CLA | ||
| allowlist: | ||
| description: Comma-separated logins that never need to sign. `*` is a wildcard. | ||
| default: '*[bot]' | ||
| agent-logins: | ||
| description: Comma-separated logins of coding agents whose commits count as the PR author's, so the PR author signs for them. | ||
| default: claude | ||
| exempt-org-members: | ||
| description: Skip members of the organization (they are covered by their employment terms). | ||
| default: 'true' | ||
| organization: | ||
| description: Organization whose members are exempt. Defaults to the repository owner. | ||
| default: '' | ||
| status-context: | ||
| description: Name of the commit status this action sets. Make this the required check. | ||
| default: license/cla | ||
| github-token: | ||
| description: Token for the calling repository. Needs pull-requests write and statuses write. | ||
| default: ${{ github.token }} | ||
|
|
||
| runs: | ||
| using: node24 | ||
| main: src/index.js | ||
|
|
||
| branding: | ||
| icon: edit-3 | ||
| color: blue | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| # Copy to .github/workflows/cla.yml in each repository that requires the CLA. | ||
| # | ||
| # pull_request_target runs in the context of the base repository, so it gets | ||
| # secrets even for PRs from forks. That is safe here because this workflow | ||
| # never checks out or runs the PR's code. Do not add a checkout step. | ||
| name: CLA | ||
|
|
||
| on: | ||
| pull_request_target: | ||
| types: [opened, synchronize, reopened] | ||
| issue_comment: | ||
| types: [created] | ||
|
|
||
| permissions: | ||
| pull-requests: write | ||
| statuses: write | ||
|
|
||
| concurrency: | ||
| group: cla-${{ github.event.pull_request.number || github.event.issue.number }} | ||
|
ritwik-g marked this conversation as resolved.
|
||
|
|
||
| jobs: | ||
| cla: | ||
| # Comments on issues (not PRs) never need a CLA check. | ||
| if: github.event_name == 'pull_request_target' || github.event.issue.pull_request | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - name: Mint signatures token | ||
| id: app-token | ||
| uses: actions/create-github-app-token@v2 | ||
| with: | ||
| app-id: ${{ vars.CLA_APP_ID }} | ||
| private-key: ${{ secrets.CLA_APP_PRIVATE_KEY }} | ||
| owner: Zipstack | ||
| repositories: cla-signatures | ||
|
|
||
| - uses: Zipstack/cla-action@v1 | ||
| with: | ||
| cla-document-url: https://gist.github.com/arun-venkataswamy/0dec44b8e5155c43461caf62c38ddac3 | ||
| signatures-repository: Zipstack/cla-signatures | ||
| signatures-token: ${{ steps.app-token.outputs.token }} | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| { | ||
| "name": "cla-action", | ||
| "private": true, | ||
| "description": "GitHub Action that enforces the Zipstack Contributor License Agreement on pull requests", | ||
| "license": "MIT", | ||
| "engines": { | ||
| "node": ">=24" | ||
| }, | ||
| "scripts": { | ||
| "test": "node --test test/*.test.js" | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,72 @@ | ||
| #!/usr/bin/env node | ||
| 'use strict' | ||
|
|
||
| // One-off migration from cla-assistant.io. | ||
| // | ||
| // Export: log in at https://cla-assistant.io with a GitHub account that has | ||
| // push access to the linked repository, open the repository's signature | ||
| // report and download "Complete History as JSON". | ||
| // | ||
| // Usage: | ||
| // GITHUB_TOKEN=... node scripts/import-cla-assistant.js <export.json> [existing-signatures.json] > signatures.json | ||
| // | ||
| // cla-assistant records the GitHub login only, so each login is resolved to | ||
| // its numeric user id (which survives renames). Revoked signatures are | ||
| // skipped; for a login that signed more than once the earliest signature is | ||
| // kept. Logins that no longer exist are reported on stderr and skipped. | ||
|
|
||
| const fs = require('node:fs') | ||
| const { createClient } = require('../src/github') | ||
|
|
||
| async function main() { | ||
| const [exportPath, existingPath] = process.argv.slice(2) | ||
| if (!exportPath) throw new Error('usage: import-cla-assistant.js <export.json> [existing-signatures.json]') | ||
| if (!process.env.GITHUB_TOKEN) throw new Error('GITHUB_TOKEN is required to resolve GitHub user ids') | ||
|
|
||
| const exported = JSON.parse(fs.readFileSync(exportPath, 'utf8')) | ||
| const data = existingPath | ||
| ? JSON.parse(fs.readFileSync(existingPath, 'utf8')) | ||
| : { version: 1, signatures: [] } | ||
| const known = new Set(data.signatures.map((signature) => signature.id)) | ||
|
|
||
| const earliest = new Map() | ||
| for (const entry of exported) { | ||
| if (!entry.user_name || entry.revoked_at) continue | ||
| const login = entry.user_name.toLowerCase() | ||
| const current = earliest.get(login) | ||
| if (!current || new Date(entry.signed_at) < new Date(current.signed_at)) earliest.set(login, entry) | ||
| } | ||
|
|
||
| const gh = createClient(process.env.GITHUB_TOKEN) | ||
| let added = 0 | ||
| for (const entry of [...earliest.values()].sort((a, b) => new Date(a.signed_at) - new Date(b.signed_at))) { | ||
| let user | ||
| try { | ||
| user = await gh.request('GET', `/users/${encodeURIComponent(entry.user_name)}`) | ||
| } catch (error) { | ||
| if (error.status !== 404) throw error | ||
| console.error(`skipped ${entry.user_name}: GitHub account not found`) | ||
| continue | ||
| } | ||
| if (known.has(user.id)) continue | ||
| known.add(user.id) | ||
| data.signatures.push({ | ||
| id: user.id, | ||
| login: user.login, | ||
| signed_at: new Date(entry.signed_at).toISOString(), | ||
| cla_document: entry.gist_url, | ||
| cla_version: entry.gist_version, | ||
| repository: `${entry.repo_owner}/${entry.repo_name}`, | ||
| source: 'cla-assistant.io', | ||
| }) | ||
| added++ | ||
| } | ||
|
Comment on lines
+32
to
+63
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Medium] [Lens 13] — |
||
|
|
||
| process.stdout.write(`${JSON.stringify(data, null, 2)}\n`) | ||
| console.error(`imported ${added} signature(s); ${data.signatures.length} in total`) | ||
| } | ||
|
|
||
| main().catch((error) => { | ||
| console.error(error.message) | ||
| process.exitCode = 1 | ||
| }) | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Low] [Lens 16] — Minor prose mismatches:
action.yml:L22"Exact comment" (matching is normalised);src/store.js:L3-L6omits 422 and the 5-attempt cap;README.md:L7says every comment re-runs the check; importer messagescripts/import-cla-assistant.js:L48calls a renamed account "not found".There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
PARTIALLY RESOLVED in 392164d — README "How it works" rewritten;
action.yml"Exact comment", the store.js header and the importer message are unchanged (wording only, below the fix bar).