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
19 changes: 19 additions & 0 deletions .github/workflows/ci.yml
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
node_modules/
21 changes: 21 additions & 0 deletions LICENSE
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.
118 changes: 118 additions & 0 deletions README.md
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
```
49 changes: 49 additions & 0 deletions action.yml
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.

Copy link
Copy Markdown
Contributor Author

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-L6 omits 422 and the 5-attempt cap; README.md:L7 says every comment re-runs the check; importer message scripts/import-cla-assistant.js:L48 calls a renamed account "not found".

Copy link
Copy Markdown
Contributor Author

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).

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
40 changes: 40 additions & 0 deletions examples/cla.yml
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 }}
Comment thread
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 }}
12 changes: 12 additions & 0 deletions package.json
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"
}
}
72 changes: 72 additions & 0 deletions scripts/import-cla-assistant.js
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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] [Lens 13] — scripts/import-cla-assistant.js is untested and its export field names (revoked_at etc.) are unconfirmed against a real export — scripts/import-cla-assistant.js:L32-L63.


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
})
Loading
Loading