Skip to content
Draft
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
119 changes: 119 additions & 0 deletions .github/workflows/caa.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# Contributor Assignment Agreement gate.
#
# Every mas-bandwidth library calls this workflow from its own cla.yml. It
# holds a pull request until its author has signed the CAA, and records the
# signature in one org-wide ledger, so a person signs once and it counts in
# every repository.
#
# A contributor signs by posting this exact sentence, on its own, as a comment
# on their pull request:
#
# I have read the CAA and I hereby sign it, assigning copyright in my
# contributions to Más Bandwidth LLC.
#
# The ledger is signatures/caa.json on the cla-signatures branch of this
# repository. Writing it needs CAA_LEDGER_TOKEN, a fine-grained PAT whose only
# permission is contents read and write on mas-bandwidth/.github. Every other
# call uses the calling repository's GITHUB_TOKEN, which needs no more than
# pull-requests write, statuses write, and contents read.
#
# Branch protection watches a commit status named by status-context, default
# `caa`. The status is set on every run of this workflow, whatever the verdict,
# so a gated pull request always has one. The status is separate from the
# Actions check this job reports, which a reusable workflow necessarily names
# after the caller's job.
#
# The logic is tools/caa in this repository. Run its tests with `go test ./...`.

name: Contributor Assignment Agreement

on:
workflow_call:
inputs:
sentence:
description: The exact sentence a comment must be to count as a signature.
type: string
required: false
default: 'I have read the CAA and I hereby sign it, assigning copyright in my contributions to Más Bandwidth LLC.'
allowlist:
description: Comma separated logins that never need to sign. Bot accounts are always exempt.
type: string
required: false
default: gafferongames,rowan-claude
status-context:
description: Name of the commit status this workflow sets, and so the name branch protection uses.
type: string
required: false
default: caa
document-url:
description: The agreement a contributor is asked to read.
type: string
required: false
default: https://github.com/mas-bandwidth/.github/blob/main/CAA.md
ledger-repository:
description: Repository holding the signature ledger.
type: string
required: false
default: mas-bandwidth/.github
ledger-branch:
description: Branch holding the signature ledger.
type: string
required: false
default: cla-signatures
ledger-path:
description: Path to the signature ledger within its branch.
type: string
required: false
default: signatures/caa.json
secrets:
CAA_LEDGER_TOKEN:
description: Fine-grained PAT with contents read and write on the ledger repository, and nothing else.
required: true

jobs:
caa:
# A comment on a plain issue is not a pull request and has no commit to
# gate. Those runs stop here rather than starting a runner.
if: >-
github.event_name == 'pull_request_target' ||
github.event.issue.pull_request != null
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
statuses: write
steps:
# This repository, at the commit of the workflow file being called, so
# the program that runs is the program this file was reviewed with. The
# pull request's own head is never checked out.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: mas-bandwidth/.github
ref: ${{ github.job_workflow_sha }}
persist-credentials: false

- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: '1.26'
cache: false

# Everything reaches the program through the environment. No value out of
# an event payload is ever interpolated into a command line.
- name: Gate on the CAA
env:
CAA_EVENT_NAME: ${{ github.event_name }}
CAA_EVENT_PATH: ${{ github.event_path }}
CAA_REPOSITORY: ${{ github.repository }}
CAA_API_URL: ${{ github.api_url }}
CAA_SERVER_URL: ${{ github.server_url }}
CAA_RUN_ID: ${{ github.run_id }}
CAA_GITHUB_TOKEN: ${{ github.token }}
CAA_LEDGER_TOKEN: ${{ secrets.CAA_LEDGER_TOKEN }}
CAA_SENTENCE: ${{ inputs.sentence }}
CAA_ALLOWLIST: ${{ inputs.allowlist }}
CAA_STATUS_CONTEXT: ${{ inputs.status-context }}
CAA_DOCUMENT_URL: ${{ inputs.document-url }}
CAA_LEDGER_REPOSITORY: ${{ inputs.ledger-repository }}
CAA_LEDGER_BRANCH: ${{ inputs.ledger-branch }}
CAA_LEDGER_PATH: ${{ inputs.ledger-path }}
run: go run ./tools/caa
33 changes: 33 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Tests for tools/caa, the logic behind the CAA gate in caa.yml.

name: CI

on:
push:
branches: [main]
pull_request:

permissions:
contents: read

jobs:
go:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: '1.26'
cache: false

- name: gofmt
run: test -z "$(gofmt -l .)" || { gofmt -l .; exit 1; }

- name: go vet
run: go vet ./...

- name: go test
run: go test ./...
32 changes: 31 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,31 @@
# .github
# .github

Org-wide GitHub configuration for Más Bandwidth LLC.

- [`CAA.md`](CAA.md) is the Contributor Assignment Agreement. Every outside
contributor signs it once, and the signature counts in every repository.
- [`.github/workflows/caa.yml`](.github/workflows/caa.yml) is the reusable
workflow that holds a pull request until its author has signed. Each library
repository calls it from its own `cla.yml`.
- [`tools/caa`](tools/caa) is the program behind that workflow. `go test ./...`
runs its tests.
- `FUNDING.yml` puts the Sponsor button on every repository.

The signature ledger is `signatures/caa.json` on the `cla-signatures` branch of
this repository. Its entries look like this:

```json
{
"name": "<github login>",
"id": 12345,
"comment_id": 67890,
"created_at": "2026-08-23T11:04:08Z",
"repoId": 59925747,
"pullRequestNo": 331
}
```

To record a signature that arrived some other way, such as on an issue rather
than a pull request, append an entry by hand on that branch and commit it with
a message naming the signer and the thread. Any later comment on the pull
request re-runs the gate and turns the status green.
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module github.com/mas-bandwidth/dot-github

go 1.26
101 changes: 101 additions & 0 deletions tools/caa/decide.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package main

import "strings"

// Event is the part of the GitHub event payload the decision depends on. It is
// filled from the event file and one read of the pull request, and nothing in
// the decision below reaches back to the API, so the whole policy is testable
// as a pure function.
type Event struct {
Name string // pull_request_target or issue_comment
OnPullRequest bool // an issue_comment is on a pull request, not a plain issue
RepoID int64
PullRequestNo int
HeadSHA string
Author string // the pull request's author
CommentBody string
CommentAuthor string
CommentAuthorID int64
CommentID int64
CommentCreatedAt string
}

// Config is the policy the workflow passes in.
type Config struct {
Sentence string // the exact sentence a signature must be
Allowlist []string // logins that never need to sign
}

// Decision is what the run does. Nothing else in the program decides anything.
type Decision struct {
Skip bool // the event is not about a pull request: do nothing at all
Sign bool // append Signature to the ledger
Signature Signature
Comment bool // ask the author to sign, unless the ask is already on the thread
Status string // success or failure, always set when Skip is false
}

// isBot reports whether a login is a GitHub App account. Bots author commits
// and comments in our own automation and never sign.
func isBot(login string) bool {
return strings.HasSuffix(login, "[bot]")
}

func allowlisted(login string, allowlist []string) bool {
for _, allowed := range allowlist {
if allowed != "" && strings.EqualFold(allowed, login) {
return true
}
}
return false
}

// IsSignature reports whether a comment body is the signature sentence. The
// whole body must be the sentence: leading and trailing whitespace is ignored
// and the comparison is case insensitive, but a body carrying any other text is
// not a signature.
func IsSignature(body, sentence string) bool {
return strings.EqualFold(strings.TrimSpace(body), strings.TrimSpace(sentence))
}

// Decide is the entire policy.
func Decide(ev Event, led *Ledger, cfg Config) Decision {
if ev.Name == "issue_comment" && !ev.OnPullRequest {
return Decision{Skip: true}
}

if allowlisted(ev.Author, cfg.Allowlist) || isBot(ev.Author) {
return Decision{Status: "success"}
}

dec := Decision{}
signed := led.Has(ev.Author)

// A signature counts only when the pull request's own author posts it. A
// comment carrying the sentence from anyone else signs nothing.
if !signed && ev.Name == "issue_comment" &&
strings.EqualFold(ev.CommentAuthor, ev.Author) &&
IsSignature(ev.CommentBody, cfg.Sentence) {
dec.Sign = true
dec.Signature = Signature{
Name: ev.CommentAuthor,
ID: ev.CommentAuthorID,
CommentID: ev.CommentID,
CreatedAt: ev.CommentCreatedAt,
RepoID: ev.RepoID,
PullRequestNo: ev.PullRequestNo,
}
signed = true
}

if signed {
dec.Status = "success"
return dec
}

dec.Status = "failure"
// The ask goes out when the pull request opens or moves. A comment that is
// not a signature restates the status and says nothing on the thread.
dec.Comment = ev.Name == "pull_request_target"
return dec
}
Loading