diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml new file mode 100644 index 0000000..3244254 --- /dev/null +++ b/.github/workflows/cla.yml @@ -0,0 +1,293 @@ +# DRAFT, not in force. A reusable check that the author of a pull request has agreed to the +# Orbyn Individual Contributor Licence Agreement. It runs only when a repository calls it +# (on: workflow_call); no repository does yet. See ../../REVIEW.md. +# +# One agreement covers every Orbyn repository: the author comments `/orbynAgree` once, the +# signature goes to the single file signatures/icla-.json in orbyn-dev/cla-signatures, +# and every calling repository reads that same file. +# +# It sets the commit status `CLA` on the pull request's head commit, from both events: +# - pull_request_target (opened, synchronize, reopened) and +# - issue_comment (created), so the status turns green in the same run that records the +# signature, with no re-push or re-run. +# A repository requires the status context `CLA` (see ../../README.md). +# +# Exempt: organisation members and owners (author_association), and the numeric account ids in +# `allowed`. Ids, not logins. There is no blanket bot exemption; list a bot by its account id. +# +# Security: the caller triggers on pull_request_target and issue_comment. This workflow never +# checks out, builds or runs anything from the pull request. It reads only event metadata, the +# pull request's author id and head sha, and comment bodies, and it passes untrusted text only +# through the matcher functions below, never into a shell. Every write uses either the +# workflow's GITHUB_TOKEN (status, comment) or the signatures token (the record). +name: Contributor licence agreement + +on: + workflow_call: + inputs: + agreement-version: + description: Version of CLA.md being agreed to. Required, with no default, so a repository that forgets it cannot record agreement to the wrong version. + type: string + required: true + signatures-repository: + description: Private repository holding signatures/icla-.json + type: string + default: orbyn-dev/cla-signatures + allowed: + description: Comma-separated numeric GitHub account ids that never need to agree. Ids, not logins, because a login can be renamed and reused. Default 63905487 = kinqsradio (Anh), 49699333 = dependabot[bot], 41898282 = github-actions[bot]. + type: string + default: "63905487,49699333,41898282" + secrets: + signatures-token: + description: Fine-grained token with contents read and write on the signatures repository only + required: true + +permissions: + contents: read + pull-requests: write + issues: write + statuses: write + +jobs: + check: + name: CLA + runs-on: ubuntu-latest + if: >- + github.event_name == 'pull_request_target' || + (github.event_name == 'issue_comment' && github.event.action == 'created' && + github.event.issue.pull_request && github.event.comment.user.type != 'Bot' && + contains(toLower(github.event.comment.body), 'agree')) + concurrency: + group: orbyn-cla-${{ github.repository }}-${{ github.event.pull_request.number || github.event.issue.number }} + cancel-in-progress: false + steps: + - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + VERSION: ${{ inputs.agreement-version }} + SIGNATURES: ${{ inputs.signatures-repository }} + ALLOWED: ${{ inputs.allowed }} + SIGNATURES_TOKEN: ${{ secrets.signatures-token }} + with: + script: | + // matcher:start + const COMMAND = /^\/orbyn[ \t]*agree$/i; + const MARKER = ''; + const CLA_URL = 'https://github.com/orbyn-dev/.github/blob/main/CLA.md'; + + /** + * True when a comment holds the command alone on a line. Lines inside a code fence, + * quoted lines (>) and indented code do not count, and neither does the command + * inside a sentence or inside inline code. + */ + function hasAgreeCommand(body) { + let fence = null; + // HTML comments are invisible when rendered, so a command inside one does not count. + const visible = String(body ?? '').replace(/\r/g, '').replace(//g, '').replace(/ + +## Contributor licence agreement + +Before we can merge your first pull request, you need to accept the [Orbyn Individual Contributor Licence Agreement](https://github.com/orbyn-dev/.github/blob/main/CLA.md). + +- **You keep the copyright** in your work. You give us a licence to use it in Orbyn, including under other licences in the future. +- **You may edit a local copy** of the project to prepare your contribution, even where the project's own licence doesn't allow changes. +- **To accept,** comment `/orbynAgree` on a line of its own on your pull request. You do this once: it covers your earlier and later contributions, in every Orbyn repository that uses the Agreement. The `CLA` check turns green right away. +- **If your employer owns your work,** ask us for the entity version before you contribute. diff --git a/examples/PULL_REQUEST_TEMPLATE-cla.md b/examples/PULL_REQUEST_TEMPLATE-cla.md new file mode 100644 index 0000000..5da93a4 --- /dev/null +++ b/examples/PULL_REQUEST_TEMPLATE-cla.md @@ -0,0 +1,2 @@ + +- [ ] I have accepted the [Orbyn Contributor Licence Agreement](https://github.com/orbyn-dev/.github/blob/main/CLA.md) by commenting `/orbynAgree` (once, for all Orbyn repositories), and I've said which parts, if any, I didn't write myself diff --git a/examples/cla-caller.yml b/examples/cla-caller.yml new file mode 100644 index 0000000..6fc0919 --- /dev/null +++ b/examples/cla-caller.yml @@ -0,0 +1,26 @@ +# EXAMPLE ONLY, not active. This file lives outside .github/workflows, so it never runs. +# After Anh approves the Agreement and creates the CLA_SIGNATURES_TOKEN org secret, copy it +# to .github/workflows/cla.yml in a covered repository (scripts/enable-cla.sh does this in a +# pull request). Replace SHA_OF_ORBYN_DEV_GITHUB_MAIN with the commit of orbyn-dev/.github to use. +# The agreement-version input is required. Then require the status check `CLA` on the default branch. +name: Contributor licence agreement + +on: + pull_request_target: + types: [opened, reopened, synchronize] + issue_comment: + types: [created] + +permissions: + contents: read + pull-requests: write + issues: write + statuses: write + +jobs: + cla: + uses: orbyn-dev/.github/.github/workflows/cla.yml@SHA_OF_ORBYN_DEV_GITHUB_MAIN + with: + agreement-version: "0.1" + secrets: + signatures-token: ${{ secrets.CLA_SIGNATURES_TOKEN }} diff --git a/scripts/cla.test.mjs b/scripts/cla.test.mjs new file mode 100644 index 0000000..c85a101 --- /dev/null +++ b/scripts/cla.test.mjs @@ -0,0 +1,211 @@ +// Tests the matcher inside .github/workflows/cla.yml. It reads the block between the +// matcher:start and matcher:end comments straight from the workflow, so there is only one +// copy of the code. Run with `node --test scripts/*.test.mjs`. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +const workflow = readFileSync(new URL("../.github/workflows/cla.yml", import.meta.url), "utf8"); +const block = workflow.match(/\/\/ matcher:start\n([\s\S]*?)\/\/ matcher:end/)[1]; +const { hasAgreeCommand, isAgreement, findAgreement, isExempt, parseIds, parseRecord, buildEntry, isSigned, recordSignature, askBody, agreedBody } = new Function( + `${block}\nreturn { hasAgreeCommand, isAgreement, findAgreement, isExempt, parseIds, parseRecord, buildEntry, isSigned, recordSignature, askBody, agreedBody };`, +)(); + +const T = "2026-11-08T10:00:00Z"; +const pull = (id = 1, login = "newcomer", association = "NONE", type = "User") => ({ user: { id, login, type }, author_association: association }); +const comment = (body, { id = 1, type = "User", created = T, updated = T } = {}) => ({ + body, user: { id, type }, created_at: created, updated_at: updated, html_url: "https://example.test/c/1", +}); + +test("the command counts in its accepted spellings", () => { + for (const body of ["/orbynAgree", "/orbyn agree", "/ORBYNAGREE", "/Orbyn Agree", " /orbynAgree ", "/orbynAgree\r\n", "Hello\n\n/orbynAgree\n\nThanks"]) { + assert.equal(hasAgreeCommand(body), true, JSON.stringify(body)); + } +}); + +test("quoted, fenced, indented, inline and mid-sentence copies do not count", () => { + const no = [ + "> /orbynAgree", + ">/orbynAgree", + "```\n/orbynAgree\n```", + "~~~\n/orbynAgree\n~~~", + "```\n/orbynAgree", + "````\n```\n/orbynAgree\n```\n````", + " /orbynAgree", + "\t/orbynAgree", + "`/orbynAgree`", + "I will /orbynAgree later", + "/orbynAgree please", + "do /orbyn agree", + "/orbynAgreed", + "/orbyn not agree", + "", + "", + "/); + assert.match(agreedBody("newcomer", undefined), /all Orbyn repositories/); + assert.match(agreedBody("newcomer", undefined), //); +}); + +const conflict = (status = 409) => Object.assign(new Error("conflict"), { status }); +const noSleep = () => Promise.resolve(); + +function fakeStore(initial = [], failures = [], sha = "s0") { + const state = { list: initial, sha: sha ?? undefined, writes: 0, reads: 0, queue: [...failures], concurrent: [] }; + return { + state, + read: async () => { + state.reads++; + return { list: state.list, sha: state.sha }; + }, + write: async (list, sha) => { + state.writes++; + const failure = state.queue.shift(); + if (failure) { + // Someone else wrote first: the file now holds their entry and a new sha. + state.list = [...state.list, ...state.concurrent.splice(0)]; + state.sha = `s${state.writes}`; + throw failure; + } + assert.equal(sha, state.sha, "write used a stale sha"); + state.list = list; + state.sha = `s${state.writes}`; + }, + }; +} + +test("recordSignature adds an entry once and skips an existing one", async () => { + const store = fakeStore([{ github_id: 7 }]); + assert.deepEqual(await recordSignature({ ...store, entry: { github_id: 7 }, sleep: noSleep }), { added: false, attempts: 1 }); + assert.equal(store.state.writes, 0); + const result = await recordSignature({ ...store, entry: { github_id: 8 }, sleep: noSleep }); + assert.equal(result.added, true); + assert.deepEqual(store.state.list.map((entry) => entry.github_id), [7, 8]); +}); + +test("recordSignature retries on 409 and keeps the concurrent signature", async () => { + const store = fakeStore([], [conflict(409)]); + store.state.concurrent.push({ github_id: 99 }); + const result = await recordSignature({ ...store, entry: { github_id: 8 }, sleep: noSleep }); + assert.deepEqual(result, { added: true, attempts: 2 }); + assert.deepEqual(store.state.list.map((entry) => entry.github_id), [99, 8]); +}); + +test("parseRecord reads a large record (over 1 MB) and an empty one", () => { + const list = Array.from({ length: 12000 }, (_, i) => ({ github_id: i, login: `user-${i}`, comment_sha256: "0".repeat(64), head_sha: "f".repeat(40) })); + const text = JSON.stringify(list, null, 2) + "\n"; + assert.ok(text.length > 1024 * 1024); + // The git blobs API returns base64 with line breaks. + const base64 = Buffer.from(text).toString("base64").replace(/(.{60})/g, "$1\n"); + const parsed = parseRecord(base64); + assert.equal(parsed.length, 12000); + assert.equal(isSigned(parsed, 11999), true); + assert.deepEqual(parseRecord(""), []); + assert.deepEqual(parseRecord(undefined), []); +}); + +test("buildEntry keeps what proves the agreement and no name, email or comment body", () => { + const entry = buildEntry({ + pull: { user: { id: 9, login: "newcomer" }, html_url: "https://example.test/pull/1" }, + comment: { created_at: T, html_url: "https://example.test/c/1", body: "/orbynAgree" }, + repository: "orbyn-dev/orbyn-engine", version: "0.1", headSha: "a".repeat(40), + bodySha256: "b".repeat(64), claBlobSha: "c".repeat(40), recordedAt: "2026-11-08T10:00:05Z", + }); + assert.deepEqual(Object.keys(entry).sort(), [ + "agreement_version", "cla_blob_sha", "comment_sha256", "comment_url", "github_id", "head_sha", + "login", "pull_request", "recorded_at", "repository", "signed_at", + ]); + assert.equal(entry.signed_at, T); + assert.ok(!JSON.stringify(entry).includes("/orbynAgree")); +}); + +test("recordSignature retries on a 422 only for a create race, and stops when the other run signed the same person", async () => { + const store = fakeStore([], [conflict(422)], null); + store.state.concurrent.push({ github_id: 8 }); + const result = await recordSignature({ ...store, entry: { github_id: 8 }, sleep: noSleep }); + assert.deepEqual(result, { added: false, attempts: 2 }); + assert.equal(store.state.list.length, 1); +}); + +test("a 422 on an update (sha supplied) is a real error and is not retried", async () => { + const store = fakeStore([{ github_id: 1 }], [conflict(422)], "s0"); + await assert.rejects(recordSignature({ ...store, entry: { github_id: 8 }, sleep: noSleep }), { status: 422 }); + assert.equal(store.state.writes, 1); +}); + +test("recordSignature gives up after the attempt limit and rethrows other errors at once", async () => { + const stuck = fakeStore([], [conflict(), conflict(), conflict()]); + await assert.rejects(recordSignature({ ...stuck, entry: { github_id: 8 }, attempts: 3, sleep: noSleep }), { status: 409 }); + assert.equal(stuck.state.writes, 3); + const broken = fakeStore([], [Object.assign(new Error("boom"), { status: 500 })]); + await assert.rejects(recordSignature({ ...broken, entry: { github_id: 8 }, sleep: noSleep }), { status: 500 }); + assert.equal(broken.state.writes, 1); +}); diff --git a/scripts/enable-cla.sh b/scripts/enable-cla.sh new file mode 100755 index 0000000..68d259c --- /dev/null +++ b/scripts/enable-cla.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# Open a pull request that adds the CLA caller workflow to one Orbyn repository. +# +# Usage: scripts/enable-cla.sh [--ref ] [--yes] +# owner/name, or just a name (owner defaults to orbyn-dev) +# --ref commit of orbyn-dev/.github to call (default: its current main) +# --yes do not ask before opening the pull request +# +# It prints what it will do first. It only creates a branch, one file and a pull request in +# the target repository. It does not touch branch protection, required checks or secrets. +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +template="$here/../examples/cla-caller.yml" +file=".github/workflows/cla.yml" +branch="chore/cla-check" +yes=0 +ref="" +repo="" + +while [ $# -gt 0 ]; do + case "$1" in + --ref) ref="${2:?--ref needs a commit}"; shift 2 ;; + --yes) yes=1; shift ;; + -h|--help) sed -n '2,10p' "$0"; exit 0 ;; + -*) echo "Unknown option: $1" >&2; exit 2 ;; + *) repo="$1"; shift ;; + esac +done +[ -n "$repo" ] || { echo "Usage: $0 [--ref ] [--yes]" >&2; exit 2; } +case "$repo" in */*) ;; *) repo="orbyn-dev/$repo" ;; esac +command -v gh >/dev/null || { echo "The gh CLI is required." >&2; exit 1; } +[ -f "$template" ] || { echo "Missing $template" >&2; exit 1; } + +case "$repo" in + orbyn-dev/orbyn-fullstack-skills) + echo "orbyn-fullstack-skills stays MIT with a DCO sign-off and is not covered by the CLA." >&2 + exit 1 ;; +esac + +[ -n "$ref" ] || ref="$(gh api repos/orbyn-dev/.github/commits/main --jq .sha)" +printf '%s' "$ref" | grep -Eq '^[0-9a-f]{40}$' || { echo "--ref must be a full 40-character commit sha, got: $ref" >&2; exit 1; } +default_branch="$(gh api "repos/$repo" --jq .default_branch)" +base_sha="$(gh api "repos/$repo/git/ref/heads/$default_branch" --jq .object.sha)" +if gh api "repos/$repo/contents/$file?ref=$default_branch" >/dev/null 2>&1; then + echo "$repo already has $file on $default_branch. Nothing to do." >&2 + exit 1 +fi + +# Re-running is safe: stop if the branch or an open pull request already exists. +open_pr="$(gh pr list --repo "$repo" --head "$branch" --state open --json url --jq '.[0].url // empty')" +if [ -n "$open_pr" ]; then + echo "$repo already has an open pull request for $branch: $open_pr. Nothing to do." >&2 + exit 0 +fi +if gh api "repos/$repo/git/ref/heads/$branch" >/dev/null 2>&1; then + echo "$repo already has the branch $branch but no open pull request. Open one from it, or delete the branch and run this again." >&2 + exit 1 +fi + +# Drop the "EXAMPLE ONLY, not active" header (everything before `name:`) from the live copy. +content="$(awk 'found || /^name:/ { found = 1; print }' "$template" | sed "s/SHA_OF_ORBYN_DEV_GITHUB_MAIN/$ref/")" + +cat </dev/null +gh api -X PUT "repos/$repo/contents/$file" \ + -f message="chore: check the Orbyn contributor agreement on pull requests" \ + -f content="$(printf '%s\n' "$content" | base64 | tr -d '\n')" \ + -f branch="$branch" >/dev/null +gh pr create --repo "$repo" --base "$default_branch" --head "$branch" \ + --title "chore: check the Orbyn contributor agreement on pull requests" \ + --body "Adds the shared CLA check (\`CLA\` status). Authors comment \`/orbynAgree\` once, which covers every Orbyn repository. Organisation members and bots need nothing. Merge, then require the \`CLA\` status check on \`$default_branch\`. See https://github.com/orbyn-dev/.github/blob/main/README.md."