From 6a7011c8e9f5952ce16a5e261336b7ac67736379 Mon Sep 17 00:00:00 2001 From: Rowan Claude Date: Fri, 4 Sep 2026 15:46:05 +1000 Subject: [PATCH] Own the CAA gate: a reusable workflow and a Go program in this repository The gate ran on contributor-assistant/github-action, a third-party action whose repository is archived at v2.6.1. This replaces it with an organization-owned reusable workflow that every library repository calls. .github/workflows/caa.yml holds a pull request until its author appears in the signature ledger, asks an unsigned author to sign, records the signature when the author posts the exact sentence, and sets a commit status named caa on every run so branch protection has one whatever the verdict. A reusable workflow necessarily names its Actions check after the caller's job, so the name branch protection watches is now the commit status rather than the check. tools/caa carries the logic. The ledger keeps the shape it already has, so every signature recorded before this still counts, and a round trip through the program is byte for byte the file it read. Two tokens, never crossed: the ledger write uses CAA_LEDGER_TOKEN, which needs contents read and write on this repository and nothing else; every other call uses the calling repository's GITHUB_TOKEN with pull-requests write, statuses write, and contents read. The pull request's head is never checked out, and no value from an event payload reaches a command line. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/caa.yml | 119 ++++++++++++++++ .github/workflows/ci.yml | 33 +++++ README.md | 32 ++++- go.mod | 3 + tools/caa/decide.go | 101 ++++++++++++++ tools/caa/decide_test.go | 181 +++++++++++++++++++++++++ tools/caa/github.go | 170 +++++++++++++++++++++++ tools/caa/ledger.go | 71 ++++++++++ tools/caa/ledger_test.go | 116 ++++++++++++++++ tools/caa/main.go | 241 +++++++++++++++++++++++++++++++++ tools/caa/testdata/ledger.json | 172 +++++++++++++++++++++++ 11 files changed, 1238 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/caa.yml create mode 100644 .github/workflows/ci.yml create mode 100644 go.mod create mode 100644 tools/caa/decide.go create mode 100644 tools/caa/decide_test.go create mode 100644 tools/caa/github.go create mode 100644 tools/caa/ledger.go create mode 100644 tools/caa/ledger_test.go create mode 100644 tools/caa/main.go create mode 100644 tools/caa/testdata/ledger.json diff --git a/.github/workflows/caa.yml b/.github/workflows/caa.yml new file mode 100644 index 0000000..45dbe4c --- /dev/null +++ b/.github/workflows/caa.yml @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d96c417 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 ./... diff --git a/README.md b/README.md index a46ae92..691aa18 100644 --- a/README.md +++ b/README.md @@ -1 +1,31 @@ -# .github \ No newline at end of file +# .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": "", + "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. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8f8fdac --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/mas-bandwidth/dot-github + +go 1.26 diff --git a/tools/caa/decide.go b/tools/caa/decide.go new file mode 100644 index 0000000..77da617 --- /dev/null +++ b/tools/caa/decide.go @@ -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 +} diff --git a/tools/caa/decide_test.go b/tools/caa/decide_test.go new file mode 100644 index 0000000..4fab9f2 --- /dev/null +++ b/tools/caa/decide_test.go @@ -0,0 +1,181 @@ +package main + +import "testing" + +const sentence = "I have read the CAA and I hereby sign it, assigning copyright in my contributions to Más Bandwidth LLC." + +func testConfig() Config { + return Config{Sentence: sentence, Allowlist: []string{"gafferongames", "rowan-claude"}} +} + +func TestIsSignature(t *testing.T) { + cases := []struct { + name string + body string + want bool + }{ + {"the sentence", sentence, true}, + {"surrounding whitespace", " \n" + sentence + "\n\n", true}, + {"a trailing carriage return", sentence + "\r\n", true}, + {"different capitalization", "i have read the caa and i hereby sign it, assigning copyright in my contributions to más bandwidth llc.", true}, + {"a version pinned remark after it", sentence + " (CAA as of 2026-07-24)", false}, + {"a footnote before it", "Happy to help. " + sentence, false}, + {"the wrong wording", "I have read the CLA Document and I hereby sign the CLA", false}, + {"an empty body", "", false}, + {"the ask to sign, which quotes the sentence", askToSign(sentence), false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := IsSignature(tc.body, sentence); got != tc.want { + t.Fatalf("got %v, want %v", got, tc.want) + } + }) + } +} + +func prEvent(author string) Event { + return Event{ + Name: "pull_request_target", + OnPullRequest: true, + RepoID: 59925747, + PullRequestNo: 331, + HeadSHA: "864046f9a0fa1d98ec39a57b08bd997a5a3e2e7d", + Author: author, + } +} + +func commentEvent(author, commenter, body string) Event { + ev := prEvent(author) + ev.Name = "issue_comment" + ev.CommentBody = body + ev.CommentAuthor = commenter + ev.CommentAuthorID = 2938071 + ev.CommentID = 5385662641 + ev.CommentCreatedAt = "2026-08-23T11:04:08Z" + return ev +} + +func TestPullRequestFromAnUnsignedAuthorAsksAndFails(t *testing.T) { + got := Decide(prEvent("newcomer"), &Ledger{}, testConfig()) + if !got.Comment { + t.Error("Comment: got false, want true") + } + if got.Sign { + t.Error("Sign: got true, want false") + } + if got.Status != "failure" { + t.Errorf("Status: got %q, want failure", got.Status) + } +} + +func TestPullRequestFromASignedAuthorPassesQuietly(t *testing.T) { + led := &Ledger{SignedContributors: []Signature{{Name: "Green-Sky"}}} + got := Decide(prEvent("green-sky"), led, testConfig()) + if got.Comment || got.Sign { + t.Errorf("got %+v, want no comment and no signature", got) + } + if got.Status != "success" { + t.Errorf("Status: got %q, want success", got.Status) + } +} + +func TestAllowlistedAndBotAuthorsNeverSign(t *testing.T) { + for _, author := range []string{"gafferongames", "rowan-claude", "GafferOnGames", "dependabot[bot]"} { + got := Decide(prEvent(author), &Ledger{}, testConfig()) + if got.Status != "success" || got.Comment || got.Sign { + t.Errorf("%s: got %+v, want a quiet success", author, got) + } + } +} + +func TestTheAuthorSigningRecordsTheSignature(t *testing.T) { + got := Decide(commentEvent("Green-Sky", "Green-Sky", sentence), &Ledger{}, testConfig()) + if !got.Sign { + t.Fatal("Sign: got false, want true") + } + if got.Status != "success" { + t.Errorf("Status: got %q, want success", got.Status) + } + if got.Comment { + t.Error("Comment: got true, want false: a signature is answered by the status, not by another comment") + } + want := Signature{ + Name: "Green-Sky", + ID: 2938071, + CommentID: 5385662641, + CreatedAt: "2026-08-23T11:04:08Z", + RepoID: 59925747, + PullRequestNo: 331, + } + if got.Signature != want { + t.Fatalf("Signature: got %+v, want %+v", got.Signature, want) + } +} + +// The negative control: the sentence carries weight only from the person whose +// contribution it assigns. Anyone else posting it signs nothing, and leaves the +// pull request red. +func TestTheSentenceFromAnotherUserSignsNothing(t *testing.T) { + got := Decide(commentEvent("newcomer", "bystander", sentence), &Ledger{}, testConfig()) + if got.Sign { + t.Fatalf("Sign: got true, want false: %+v", got.Signature) + } + if got.Status != "failure" { + t.Errorf("Status: got %q, want failure", got.Status) + } +} + +// A signed contributor commenting the sentence on someone else's pull request +// must not carry that pull request. +func TestASignedBystanderDoesNotCarryTheAuthor(t *testing.T) { + led := &Ledger{SignedContributors: []Signature{{Name: "bystander"}}} + got := Decide(commentEvent("newcomer", "bystander", sentence), led, testConfig()) + if got.Sign || got.Status != "failure" { + t.Fatalf("got %+v, want no signature and a failure", got) + } +} + +func TestACommentThatIsNotTheSentenceSignsNothing(t *testing.T) { + for _, body := range []string{"recheck", "LGTM", sentence + " -- with reservations"} { + got := Decide(commentEvent("newcomer", "newcomer", body), &Ledger{}, testConfig()) + if got.Sign { + t.Errorf("%q: signed, want no signature", body) + } + if got.Status != "failure" { + t.Errorf("%q: Status got %q, want failure", body, got.Status) + } + if got.Comment { + t.Errorf("%q: Comment got true, want false", body) + } + } +} + +// Signing twice records once. The second run reads a ledger that already holds +// the author and restates the passing status. +func TestSigningAnAlreadySignedAuthorRecordsNothing(t *testing.T) { + led := &Ledger{SignedContributors: []Signature{{Name: "Green-Sky", ID: 2938071, PullRequestNo: 331}}} + got := Decide(commentEvent("Green-Sky", "Green-Sky", sentence), led, testConfig()) + if got.Sign { + t.Fatal("Sign: got true, want false") + } + if got.Status != "success" { + t.Errorf("Status: got %q, want success", got.Status) + } + if len(led.SignedContributors) != 1 { + t.Fatalf("ledger grew to %d entries", len(led.SignedContributors)) + } +} + +// A comment on a plain issue is not a pull request event. The run does nothing +// at all: no status, because there is no commit to set one on. +func TestACommentOnAnIssueIsSkipped(t *testing.T) { + ev := commentEvent("newcomer", "newcomer", sentence) + ev.OnPullRequest = false + got := Decide(ev, &Ledger{}, testConfig()) + if !got.Skip { + t.Fatalf("Skip: got false, want true: %+v", got) + } + if got.Status != "" || got.Sign || got.Comment { + t.Fatalf("got %+v, want an empty decision", got) + } +} diff --git a/tools/caa/github.go b/tools/caa/github.go new file mode 100644 index 0000000..01d5ece --- /dev/null +++ b/tools/caa/github.go @@ -0,0 +1,170 @@ +package main + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// client talks to one GitHub API host with one token. The program builds two: +// one on the calling repository's GITHUB_TOKEN for pull request metadata, +// comments and the commit status, and one on CAA_LEDGER_TOKEN for the ledger. +// Nothing shares a token across those two jobs. +type client struct { + api string + token string + http *http.Client +} + +func newClient(api, token string) *client { + return &client{ + api: strings.TrimSuffix(api, "/"), + token: token, + http: &http.Client{Timeout: 30 * time.Second}, + } +} + +func (c *client) do(method, path string, body any, out any) (int, error) { + var payload io.Reader + if body != nil { + raw, err := json.Marshal(body) + if err != nil { + return 0, err + } + payload = bytes.NewReader(raw) + } + + req, err := http.NewRequest(method, c.api+path, payload) + if err != nil { + return 0, err + } + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := c.http.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return resp.StatusCode, err + } + if resp.StatusCode >= 300 { + return resp.StatusCode, fmt.Errorf("%s %s: %s: %s", method, path, resp.Status, strings.TrimSpace(string(raw))) + } + if out != nil { + if err := json.Unmarshal(raw, out); err != nil { + return resp.StatusCode, fmt.Errorf("%s %s: decode response: %w", method, path, err) + } + } + return resp.StatusCode, nil +} + +type pullRequest struct { + Number int `json:"number"` + User struct { + Login string `json:"login"` + } `json:"user"` + Head struct { + SHA string `json:"sha"` + } `json:"head"` +} + +func (c *client) pullRequest(repo string, number int) (*pullRequest, error) { + pr := &pullRequest{} + _, err := c.do(http.MethodGet, fmt.Sprintf("/repos/%s/pulls/%d", repo, number), nil, pr) + return pr, err +} + +type issueComment struct { + Body string `json:"body"` +} + +// hasComment reports whether a comment carrying marker is already on the +// thread, so the ask to sign is posted once and not once per push. +func (c *client) hasComment(repo string, number int, marker string) (bool, error) { + for page := 1; page <= 10; page++ { + var comments []issueComment + path := fmt.Sprintf("/repos/%s/issues/%d/comments?per_page=100&page=%d", repo, number, page) + if _, err := c.do(http.MethodGet, path, nil, &comments); err != nil { + return false, err + } + for _, comment := range comments { + if strings.Contains(comment.Body, marker) { + return true, nil + } + } + if len(comments) < 100 { + return false, nil + } + } + return false, nil +} + +func (c *client) postComment(repo string, number int, body string) error { + _, err := c.do(http.MethodPost, fmt.Sprintf("/repos/%s/issues/%d/comments", repo, number), + map[string]string{"body": body}, nil) + return err +} + +func (c *client) setStatus(repo, sha, state, context, description, targetURL string) error { + _, err := c.do(http.MethodPost, fmt.Sprintf("/repos/%s/statuses/%s", repo, sha), map[string]string{ + "state": state, + "context": context, + "description": description, + "target_url": targetURL, + }, nil) + return err +} + +type contentsFile struct { + Content string `json:"content"` + Encoding string `json:"encoding"` + SHA string `json:"sha"` +} + +// readLedger returns the ledger bytes and the blob sha the write has to send +// back. A ledger file that does not exist yet reads as empty with an empty sha. +func (c *client) readLedger(repo, path, branch string) ([]byte, string, error) { + file := &contentsFile{} + endpoint := fmt.Sprintf("/repos/%s/contents/%s?ref=%s", repo, path, url.QueryEscape(branch)) + status, err := c.do(http.MethodGet, endpoint, nil, file) + if status == http.StatusNotFound { + return nil, "", nil + } + if err != nil { + return nil, "", err + } + if file.Encoding != "base64" { + return nil, "", fmt.Errorf("read ledger: unexpected encoding %q", file.Encoding) + } + raw, err := base64.StdEncoding.DecodeString(strings.ReplaceAll(file.Content, "\n", "")) + if err != nil { + return nil, "", fmt.Errorf("read ledger: %w", err) + } + return raw, file.SHA, nil +} + +func (c *client) writeLedger(repo, path, branch, blobSHA, message string, raw []byte) (int, error) { + body := map[string]string{ + "message": message, + "content": base64.StdEncoding.EncodeToString(raw), + "branch": branch, + } + if blobSHA != "" { + body["sha"] = blobSHA + } + return c.do(http.MethodPut, fmt.Sprintf("/repos/%s/contents/%s", repo, path), body, nil) +} diff --git a/tools/caa/ledger.go b/tools/caa/ledger.go new file mode 100644 index 0000000..b5d02f0 --- /dev/null +++ b/tools/caa/ledger.go @@ -0,0 +1,71 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" +) + +// Signature is one entry in the signature ledger. The field names and the +// order they marshal in are the shape the ledger already holds, so entries +// written before this program stay valid and a round trip is byte for byte. +type Signature struct { + Name string `json:"name"` + ID int64 `json:"id"` + CommentID int64 `json:"comment_id"` + CreatedAt string `json:"created_at"` + RepoID int64 `json:"repoId"` + PullRequestNo int `json:"pullRequestNo"` +} + +// Ledger is the whole signature file: signatures/caa.json on the +// cla-signatures branch of mas-bandwidth/.github. +type Ledger struct { + SignedContributors []Signature `json:"signedContributors"` +} + +// ParseLedger reads the ledger file. An empty file is an empty ledger, so a +// ledger that does not exist yet does not need special handling upstream. +func ParseLedger(raw []byte) (*Ledger, error) { + led := &Ledger{} + if len(bytes.TrimSpace(raw)) == 0 { + return led, nil + } + if err := json.Unmarshal(raw, led); err != nil { + return nil, fmt.Errorf("parse ledger: %w", err) + } + return led, nil +} + +// Marshal renders the ledger in the file's existing formatting: two space +// indent, one trailing newline. +func (l *Ledger) Marshal() ([]byte, error) { + raw, err := json.MarshalIndent(l, "", " ") + if err != nil { + return nil, fmt.Errorf("render ledger: %w", err) + } + return append(raw, '\n'), nil +} + +// Has reports whether a login already appears in the ledger. GitHub logins are +// case insensitive, so the comparison is too. +func (l *Ledger) Has(login string) bool { + for _, sig := range l.SignedContributors { + if strings.EqualFold(sig.Name, login) { + return true + } + } + return false +} + +// Append adds a signature and reports whether the ledger changed. Appending a +// login the ledger already holds changes nothing, so a replayed or duplicated +// event cannot write a second entry for the same person. +func (l *Ledger) Append(sig Signature) bool { + if l.Has(sig.Name) { + return false + } + l.SignedContributors = append(l.SignedContributors, sig) + return true +} diff --git a/tools/caa/ledger_test.go b/tools/caa/ledger_test.go new file mode 100644 index 0000000..936300c --- /dev/null +++ b/tools/caa/ledger_test.go @@ -0,0 +1,116 @@ +package main + +import ( + "bytes" + "os" + "testing" +) + +// testdata/ledger.json is a snapshot of signatures/caa.json as it stands on the +// cla-signatures branch, written by the automation this program replaces. +const snapshot = "testdata/ledger.json" + +func TestParseLedgerReadsTheExistingFile(t *testing.T) { + raw, err := os.ReadFile(snapshot) + if err != nil { + t.Fatal(err) + } + led, err := ParseLedger(raw) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(led.SignedContributors) != 21 { + t.Fatalf("signatures: got %d, want 21", len(led.SignedContributors)) + } + + first := led.SignedContributors[0] + want := Signature{ + Name: "bingham909", + ID: 239700075, + CommentID: 5018843853, + CreatedAt: "2026-07-20T04:38:33Z", + RepoID: 59925747, + PullRequestNo: 307, + } + if first != want { + t.Fatalf("first signature: got %+v, want %+v", first, want) + } +} + +// A signature recorded before this program has to keep counting, and the file +// this program writes has to stay the file the ledger already is, byte for +// byte, so a diff shows only the appended entry. +func TestLedgerRoundTripIsByteIdentical(t *testing.T) { + raw, err := os.ReadFile(snapshot) + if err != nil { + t.Fatal(err) + } + led, err := ParseLedger(raw) + if err != nil { + t.Fatalf("parse: %v", err) + } + out, err := led.Marshal() + if err != nil { + t.Fatalf("marshal: %v", err) + } + if !bytes.Equal(raw, out) { + t.Fatalf("round trip changed the file:\n--- got ---\n%s\n--- want ---\n%s", out, raw) + } +} + +func TestParseLedgerAcceptsAnEmptyFile(t *testing.T) { + for _, raw := range [][]byte{nil, []byte(""), []byte(" \n")} { + led, err := ParseLedger(raw) + if err != nil { + t.Fatalf("parse %q: %v", raw, err) + } + if len(led.SignedContributors) != 0 { + t.Fatalf("parse %q: got %d signatures, want 0", raw, len(led.SignedContributors)) + } + } +} + +func TestParseLedgerRejectsGarbage(t *testing.T) { + if _, err := ParseLedger([]byte("not json")); err == nil { + t.Fatal("parsing garbage returned no error") + } +} + +func TestHasIsCaseInsensitive(t *testing.T) { + led := &Ledger{SignedContributors: []Signature{{Name: "Green-Sky"}}} + for _, login := range []string{"Green-Sky", "green-sky", "GREEN-SKY"} { + if !led.Has(login) { + t.Fatalf("Has(%q): got false, want true", login) + } + } + if led.Has("green-skye") { + t.Fatal("Has(green-skye): got true, want false") + } +} + +func TestAppendIsIdempotent(t *testing.T) { + led := &Ledger{} + sig := Signature{Name: "octocat", ID: 1, CommentID: 2, CreatedAt: "2026-09-04T00:00:00Z", RepoID: 3, PullRequestNo: 4} + + if !led.Append(sig) { + t.Fatal("first append: got false, want true") + } + if led.Append(sig) { + t.Fatal("second append: got true, want false") + } + // A second run carrying a different comment on the same login is still the + // same person, and still must not add a row. + other := sig + other.CommentID = 99 + other.PullRequestNo = 42 + if led.Append(other) { + t.Fatal("append of the same login from another comment: got true, want false") + } + // A different login does append. + if !led.Append(Signature{Name: "hubot", ID: 5}) { + t.Fatal("append of a new login: got false, want true") + } + if len(led.SignedContributors) != 2 { + t.Fatalf("signatures: got %d, want 2", len(led.SignedContributors)) + } +} diff --git a/tools/caa/main.go b/tools/caa/main.go new file mode 100644 index 0000000..1b6bbdd --- /dev/null +++ b/tools/caa/main.go @@ -0,0 +1,241 @@ +// Command caa gates a pull request on the Contributor Assignment Agreement. +// +// It runs from .github/workflows/caa.yml in this repository, which every +// mas-bandwidth library calls as a reusable workflow. On a pull request whose +// author has not signed it posts the ask to sign; on a comment that is the +// signature sentence, from the pull request's own author, it appends the +// signature to the ledger. Every run ends by setting the commit status that +// branch protection watches. +// +// Everything it needs arrives in the environment. Nothing from an event +// payload is ever interpolated into a command line. +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "strings" +) + +// marker identifies the ask to sign so it is posted once per pull request. +const marker = "" + +type payload struct { + Repository struct { + ID int64 `json:"id"` + FullName string `json:"full_name"` + } `json:"repository"` + PullRequest *struct { + Number int `json:"number"` + User struct { + Login string `json:"login"` + } `json:"user"` + Head struct { + SHA string `json:"sha"` + } `json:"head"` + } `json:"pull_request"` + Issue *struct { + Number int `json:"number"` + PullRequest json.RawMessage `json:"pull_request"` + User struct { + Login string `json:"login"` + } `json:"user"` + } `json:"issue"` + Comment *struct { + ID int64 `json:"id"` + Body string `json:"body"` + CreatedAt string `json:"created_at"` + User struct { + Login string `json:"login"` + ID int64 `json:"id"` + } `json:"user"` + } `json:"comment"` +} + +func env(name, fallback string) string { + if value := strings.TrimSpace(os.Getenv(name)); value != "" { + return value + } + return fallback +} + +func must(name string) string { + value := strings.TrimSpace(os.Getenv(name)) + if value == "" { + fail("%s is not set", name) + } + return value +} + +func fail(format string, args ...any) { + fmt.Fprintf(os.Stderr, "caa: "+format+"\n", args...) + os.Exit(1) +} + +func splitList(raw string) []string { + var out []string + for _, item := range strings.Split(raw, ",") { + if item = strings.TrimSpace(item); item != "" { + out = append(out, item) + } + } + return out +} + +func main() { + eventName := must("CAA_EVENT_NAME") + repo := must("CAA_REPOSITORY") + api := env("CAA_API_URL", "https://api.github.com") + + raw, err := os.ReadFile(must("CAA_EVENT_PATH")) + if err != nil { + fail("read event: %v", err) + } + var event payload + if err := json.Unmarshal(raw, &event); err != nil { + fail("parse event: %v", err) + } + + cfg := Config{ + Sentence: must("CAA_SENTENCE"), + Allowlist: splitList(os.Getenv("CAA_ALLOWLIST")), + } + + caller := newClient(api, must("CAA_GITHUB_TOKEN")) + + ev := Event{Name: eventName, RepoID: event.Repository.ID} + switch eventName { + case "pull_request_target": + if event.PullRequest == nil { + fail("pull_request_target event carries no pull request") + } + ev.OnPullRequest = true + ev.PullRequestNo = event.PullRequest.Number + ev.Author = event.PullRequest.User.Login + ev.HeadSHA = event.PullRequest.Head.SHA + case "issue_comment": + if event.Issue == nil || event.Comment == nil { + fail("issue_comment event is missing its issue or comment") + } + // A plain issue has no pull request. The old automation died here with + // a GraphQL error; this exits quietly instead. + if len(event.Issue.PullRequest) == 0 || string(event.Issue.PullRequest) == "null" { + fmt.Println("comment is on an issue, not a pull request: nothing to do") + return + } + ev.OnPullRequest = true + ev.PullRequestNo = event.Issue.Number + ev.CommentBody = event.Comment.Body + ev.CommentAuthor = event.Comment.User.Login + ev.CommentAuthorID = event.Comment.User.ID + ev.CommentID = event.Comment.ID + ev.CommentCreatedAt = event.Comment.CreatedAt + + pr, err := caller.pullRequest(repo, ev.PullRequestNo) + if err != nil { + fail("read pull request %d: %v", ev.PullRequestNo, err) + } + ev.Author = pr.User.Login + ev.HeadSHA = pr.Head.SHA + default: + fail("unsupported event %q", eventName) + } + + ledgerRepo := env("CAA_LEDGER_REPOSITORY", "mas-bandwidth/.github") + ledgerPath := env("CAA_LEDGER_PATH", "signatures/caa.json") + ledgerBranch := env("CAA_LEDGER_BRANCH", "cla-signatures") + ledgerClient := newClient(api, must("CAA_LEDGER_TOKEN")) + + ledgerRaw, blobSHA, err := ledgerClient.readLedger(ledgerRepo, ledgerPath, ledgerBranch) + if err != nil { + fail("read ledger: %v", err) + } + ledger, err := ParseLedger(ledgerRaw) + if err != nil { + fail("%v", err) + } + + decision := Decide(ev, ledger, cfg) + if decision.Skip { + fmt.Println("nothing to do") + return + } + + if decision.Sign { + if err := sign(ledgerClient, ledgerRepo, ledgerPath, ledgerBranch, blobSHA, ledger, decision.Signature, repo); err != nil { + fail("%v", err) + } + fmt.Printf("recorded a signature from %s on %s#%d\n", decision.Signature.Name, repo, ev.PullRequestNo) + } + + if decision.Comment { + posted, err := caller.hasComment(repo, ev.PullRequestNo, marker) + if err != nil { + fail("read comments on %s#%d: %v", repo, ev.PullRequestNo, err) + } + if !posted { + if err := caller.postComment(repo, ev.PullRequestNo, askToSign(cfg.Sentence)); err != nil { + fail("post the ask to sign on %s#%d: %v", repo, ev.PullRequestNo, err) + } + fmt.Printf("asked %s to sign on %s#%d\n", ev.Author, repo, ev.PullRequestNo) + } + } + + description := "Waiting for the Contributor Assignment Agreement signature" + if decision.Status == "success" { + description = "Contributor Assignment Agreement signed" + } + runURL := fmt.Sprintf("%s/%s/actions/runs/%s", + env("CAA_SERVER_URL", "https://github.com"), repo, os.Getenv("CAA_RUN_ID")) + if err := caller.setStatus(repo, ev.HeadSHA, decision.Status, env("CAA_STATUS_CONTEXT", "caa"), description, runURL); err != nil { + fail("set the commit status on %s: %v", ev.HeadSHA, err) + } + fmt.Printf("status %s on %s\n", decision.Status, ev.HeadSHA) +} + +// sign appends one signature and writes the ledger back. A write that loses a +// race with another run is retried against the ledger as it now stands, and +// the append is a no-op if that other run already recorded the same person. +func sign(c *client, repo, path, branch, blobSHA string, ledger *Ledger, sig Signature, from string) error { + message := fmt.Sprintf("Record the CAA signature of %s on %s#%d", sig.Name, from, sig.PullRequestNo) + + for attempt := 0; ; attempt++ { + if !ledger.Append(sig) { + return nil + } + raw, err := ledger.Marshal() + if err != nil { + return err + } + status, err := c.writeLedger(repo, path, branch, blobSHA, message, raw) + if err == nil { + return nil + } + if attempt == 4 || (status != http.StatusConflict && status != http.StatusUnprocessableEntity) { + return fmt.Errorf("write ledger: %w", err) + } + + current, currentSHA, err := c.readLedger(repo, path, branch) + if err != nil { + return fmt.Errorf("reread ledger: %w", err) + } + if ledger, err = ParseLedger(current); err != nil { + return err + } + blobSHA = currentSHA + } +} + +func askToSign(sentence string) string { + document := env("CAA_DOCUMENT_URL", "https://github.com/mas-bandwidth/.github/blob/main/CAA.md") + return strings.Join([]string{ + marker, + "Thanks for the contribution. Before it can be merged, please read the", + "[Contributor Assignment Agreement](" + document + ") and sign it by posting", + "the sentence below, on its own, as a comment on this pull request.", + "", + "> " + sentence, + }, "\n") +} diff --git a/tools/caa/testdata/ledger.json b/tools/caa/testdata/ledger.json new file mode 100644 index 0000000..90cf611 --- /dev/null +++ b/tools/caa/testdata/ledger.json @@ -0,0 +1,172 @@ +{ + "signedContributors": [ + { + "name": "bingham909", + "id": 239700075, + "comment_id": 5018843853, + "created_at": "2026-07-20T04:38:33Z", + "repoId": 59925747, + "pullRequestNo": 307 + }, + { + "name": "oliverzx", + "id": 2737354, + "comment_id": 5020598136, + "created_at": "2026-07-20T09:13:52Z", + "repoId": 89861379, + "pullRequestNo": 44 + }, + { + "name": "wirepair", + "id": 1073742, + "comment_id": 5013298649, + "created_at": "2026-07-18T23:13:37Z", + "repoId": 77645747, + "pullRequestNo": 160 + }, + { + "name": "valverl", + "id": 7373077, + "comment_id": 5015721782, + "created_at": "2026-07-19T12:31:37Z", + "repoId": 59925747, + "pullRequestNo": 306 + }, + { + "name": "Tornamic", + "id": 103134732, + "comment_id": 5025462571, + "created_at": "2026-07-20T17:58:09Z", + "repoId": 735424288, + "pullRequestNo": 14 + }, + { + "name": "dbechrd", + "id": 707367, + "comment_id": 5029284616, + "created_at": "2026-07-21T01:45:15Z", + "repoId": 59925747, + "pullRequestNo": 306 + }, + { + "name": "jampai", + "id": 26148141, + "comment_id": 5228067758, + "created_at": "2026-08-08T20:47:43Z", + "repoId": 59925747, + "pullRequestNo": 328 + }, + { + "name": "SirLynix", + "id": 3002461, + "comment_id": 5385716632, + "created_at": "2026-08-23T11:18:02Z", + "repoId": 77645747, + "pullRequestNo": 160 + }, + { + "name": "G07cha", + "id": 6943514, + "comment_id": 5385706909, + "created_at": "2026-08-23T11:15:29Z", + "repoId": 59925747, + "pullRequestNo": 331 + }, + { + "name": "Green-Sky", + "id": 2938071, + "comment_id": 5385662641, + "created_at": "2026-08-23T11:04:08Z", + "repoId": 59925747, + "pullRequestNo": 331 + }, + { + "name": "toqueteos", + "id": 699969, + "comment_id": 5385776186, + "created_at": "2026-08-23T11:33:35Z", + "repoId": 77645747, + "pullRequestNo": 160 + }, + { + "name": "FredGithub", + "id": 1611174, + "comment_id": 5386036937, + "created_at": "2026-08-23T12:38:11Z", + "repoId": 59925747, + "pullRequestNo": 331 + }, + { + "name": "kkimdev", + "id": 503414, + "comment_id": 5386131648, + "created_at": "2026-08-23T13:01:53Z", + "repoId": 59925747, + "pullRequestNo": 331 + }, + { + "name": "FirstSS-Sub", + "id": 23026828, + "comment_id": 5386210492, + "created_at": "2026-08-23T13:19:36Z", + "repoId": 59925747, + "pullRequestNo": 331 + }, + { + "name": "ananace", + "id": 534460, + "comment_id": 5386304180, + "created_at": "2026-08-23T13:40:33Z", + "repoId": 59925747, + "pullRequestNo": 331 + }, + { + "name": "Mokosha", + "id": 885364, + "comment_id": 5386537184, + "created_at": "2026-08-23T14:32:56Z", + "repoId": 77645747, + "pullRequestNo": 160 + }, + { + "name": "mgriley", + "id": 13209161, + "comment_id": 5387758289, + "created_at": "2026-08-23T18:33:57Z", + "repoId": 59925747, + "pullRequestNo": 331 + }, + { + "name": "yzsolt", + "id": 1765019, + "comment_id": 5388092401, + "created_at": "2026-08-23T19:44:07Z", + "repoId": 77645747, + "pullRequestNo": 160 + }, + { + "name": "msinilo", + "id": 11487336, + "comment_id": 5389075996, + "created_at": "2026-08-23T23:27:58Z", + "repoId": 59925747, + "pullRequestNo": 331 + }, + { + "name": "JohannesMP", + "id": 662874, + "comment_id": 5389247733, + "created_at": "2026-08-24T00:07:19Z", + "repoId": 77645747, + "pullRequestNo": 160 + }, + { + "name": "jakecoffman", + "id": 886768, + "comment_id": 5393103476, + "created_at": "2026-08-24T09:14:37Z", + "repoId": 89861379, + "pullRequestNo": 43 + } + ] +}