diff --git a/.github/workflows/licence-grant.yml b/.github/workflows/licence-grant.yml deleted file mode 100644 index 30ea9a8..0000000 --- a/.github/workflows/licence-grant.yml +++ /dev/null @@ -1,156 +0,0 @@ -# Interim licence grant for outside contributors, until the reviewed contributor -# licence agreement replaces it. -# -# A product repository calls this workflow from `examples/licence-grant-caller.yml`. -# It sets the commit status "Licence grant" on the pull request's head commit: -# - success for organisation members and bots; -# - pending until the pull request's author has posted the grant sentence as a comment; -# - success once they have. -# -# It never checks out or runs code from the pull request. Comment text is only read as -# data inside this script. That makes `pull_request_target` and `issue_comment` safe here. -name: Licence grant - -on: - workflow_call: - inputs: - pull: - description: Pull request number (only when called from workflow_dispatch) - type: string - default: "" - exempt: - description: Comma-separated logins that never need the grant, in addition to organisation members and bots - type: string - default: "kinqsradio" - secrets: - CLA_SIGNATURES_TOKEN: - description: Optional fine-grained token with contents write on orbyn-dev/cla-signatures. Without it, the PR comment is the record. - required: false - -permissions: - contents: read - pull-requests: write - issues: write - statuses: write - -jobs: - licence-grant: - name: Check the licence grant - runs-on: ubuntu-latest - if: github.event_name != 'issue_comment' || github.event.issue.pull_request - concurrency: - group: licence-grant-${{ github.repository }}-${{ github.event.pull_request.number || github.event.issue.number || inputs.pull }} - cancel-in-progress: false - steps: - - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - env: - PULL: ${{ inputs.pull }} - EXEMPT: ${{ inputs.exempt }} - SIGNATURES_TOKEN: ${{ secrets.CLA_SIGNATURES_TOKEN }} - with: - script: | - // matcher:start - const SENTENCE = 'I grant Anh Dang a perpetual, irrevocable, worldwide licence to use, modify, sublicense and relicense this contribution under any terms.'; - const ASK = ''; - const THANKS = ''; - /** Drop quote markers, Markdown emphasis, smart quotes and extra spaces, so a copied quote still counts. */ - function normalise(text) { - return String(text ?? '') - .replace(/\r/g, '') - .split('\n') - .map((line) => line.replace(/^\s*>\s?/, '')) - .join(' ') - .replace(/[‘’]/g, "'") - .replace(/[“”"`*_]/g, '') - .replace(/\s+/g, ' ') - .trim(); - } - /** True when a comment contains the whole grant sentence, ignoring formatting. */ - function grants(body) { - return normalise(body).includes(normalise(SENTENCE)); - } - /** Organisation members, the listed logins and bots never need the grant. */ - function isExempt(pull, exemptLogins) { - const login = pull.user.login.toLowerCase(); - return pull.user.type === 'Bot' - || ['OWNER', 'MEMBER'].includes(pull.author_association) - || exemptLogins.includes(login); - } - // matcher:end - - const { owner, repo } = context.repo; - const number = Number(context.payload.pull_request?.number ?? context.payload.issue?.number ?? process.env.PULL); - if (!number) { core.setFailed('No pull request number.'); return; } - const { data: pull } = await github.rest.pulls.get({ owner, repo, pull_number: number }); - const exemptLogins = process.env.EXEMPT.split(',').map((login) => login.trim().toLowerCase()).filter(Boolean); - const status = (state, description) => github.rest.repos.createCommitStatus({ - owner, repo, sha: pull.head.sha, state, description, context: 'Licence grant', - target_url: `${pull.html_url}#issuecomment`, - }); - - if (isExempt(pull, exemptLogins)) { - await status('success', 'Not needed for organisation members and bots'); - return; - } - - const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: number }); - const grant = comments.find((comment) => comment.user.id === pull.user.id && grants(comment.body)); - const posted = (marker) => comments.some((comment) => comment.body?.includes(marker)); - - if (!grant) { - await status('pending', 'Waiting for the licence grant sentence from the author'); - if (!posted(ASK)) { - await github.rest.issues.createComment({ - owner, repo, issue_number: number, - body: [ - ASK, - `Thanks for your contribution, @${pull.user.login}!`, - '', - 'Orbyn\'s contributor licence agreement is still being drafted. Until it is ready, each pull request from outside the organisation needs a short licence grant from its author before it can be merged. It lets us keep using your work in Orbyn under the licences we choose, including future ones. You keep the copyright in your work.', - '', - 'Please reply on this pull request with exactly this sentence:', - '', - '```', - SENTENCE, - '```', - '', - 'The "Licence grant" check turns green as soon as you post it.', - ].join('\n'), - }); - } - return; - } - - await status('success', 'Licence grant recorded'); - if (!posted(THANKS)) { - await github.rest.issues.createComment({ - owner, repo, issue_number: number, - body: `${THANKS}\nThanks, licence grant recorded.`, - }); - } - - if (!process.env.SIGNATURES_TOKEN) return; - const store = getOctokit(process.env.SIGNATURES_TOKEN); - const path = 'grants/licence-grant.json'; - let list = []; - let sha; - try { - const { data } = await store.rest.repos.getContent({ owner: 'orbyn-dev', repo: 'cla-signatures', path }); - list = JSON.parse(Buffer.from(data.content, 'base64').toString('utf8')); - sha = data.sha; - } catch (error) { - if (error.status !== 404) throw error; - } - if (list.some((entry) => entry.pull_request === pull.html_url)) return; - list.push({ - github_id: pull.user.id, - login: pull.user.login, - pull_request: pull.html_url, - comment: grant.html_url, - granted_at: grant.created_at, - }); - await store.rest.repos.createOrUpdateFileContents({ - owner: 'orbyn-dev', repo: 'cla-signatures', path, sha, - message: `Record licence grant by ${pull.user.login} on ${owner}/${repo}#${number}`, - content: Buffer.from(JSON.stringify(list, null, 2) + '\n').toString('base64'), - }); diff --git a/README.md b/README.md index 2be7e63..7d14b8d 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,6 @@ This repository holds organisation-wide files for Orbyn projects, starting with Contact: hello@orbyn.dev -## Licence grant check (in force) - -`.github/workflows/licence-grant.yml` is an interim check for pull requests from outside the organisation, until the contributor licence agreement is approved. The author posts one licence-grant sentence on their pull request, and the "Licence grant" status turns green. Organisation members and bots pass at once. - -Product repositories call it from `examples/licence-grant-caller.yml`. - ## Contributor agreement check `.github/workflows/cla.yml` is a reusable check for the [contributor agreement](CLA.md). A contributor comments `/orbynAgree` once on their own pull request; that covers every covered Orbyn repository. Organisation members and bots pass at once. The skills library (`orbyn-fullstack-skills`) stays MIT with a DCO and is not covered. @@ -22,7 +16,7 @@ Product repositories call it from `examples/licence-grant-caller.yml`. 1. Copy [`examples/cla-caller.yml`](examples/cla-caller.yml) to `.github/workflows/cla.yml`, or run `scripts/enable-cla.sh `, which opens a pull request doing exactly that (it does not touch branch protection or secrets). 2. Make sure the organisation secret `CLA_SIGNATURES_TOKEN` is available to the repository (limit it to the covered repositories). The caller must pass the `agreement-version` input; it has no default. 3. Require the status check `CLA` on the default branch. -4. Settings, Actions, General: set fork pull request workflows to "Require approval for all outside collaborators", and look at any change under `.github/workflows` before approving. A fork PR workflow can post a status with the same name, so the required check alone is not proof. A dedicated GitHub App that posts the status is the planned stronger fix. The old "Licence grant" check has the same exposure. +4. Settings, Actions, General: set fork pull request workflows to "Require approval for all outside collaborators", and look at any change under `.github/workflows` before approving. A fork PR workflow can post a status with the same name, so the required check alone is not proof. A dedicated GitHub App that posts the status is the planned stronger fix. Exemptions are by numeric account id (`allowed` input) and by `MEMBER`/`OWNER` association. A private organisation member reads as `CONTRIBUTOR`, so they should make their membership public or be listed by id. diff --git a/REVIEW.md b/REVIEW.md index 51bc2bf..23c74ee 100644 --- a/REVIEW.md +++ b/REVIEW.md @@ -43,7 +43,7 @@ ## Known limits and follow-ups -- **A required status can be faked.** A workflow in a fork PR can contain a job that posts a status named `CLA` (or whose check has the same name). Interim mitigation: in each covered repository set Settings, Actions, General, "Fork pull request workflows from outside collaborators" to "Require approval for all outside collaborators", and reviewers look at any change under `.github/workflows` before approving a run. The stronger fix is a dedicated GitHub App that posts the status, with that app set as the required source of `CLA`; this is a follow-up. The older "Licence grant" check has the same exposure. +- **A required status can be faked.** A workflow in a fork PR can contain a job that posts a status named `CLA` (or whose check has the same name). Interim mitigation: in each covered repository set Settings, Actions, General, "Fork pull request workflows from outside collaborators" to "Require approval for all outside collaborators", and reviewers look at any change under `.github/workflows` before approving a run. The stronger fix is a dedicated GitHub App that posts the status, with that app set as the required source of `CLA`; this is a follow-up. - **Existing contributors without an open PR** (for example Luan) can agree by commenting `/orbynAgree` on any old pull request of theirs: the check also runs on comments, so this records the signature for every covered repository. - **The token expires.** A fine-grained personal access token expires, and then every check ends in an error until it is renewed. A GitHub App installation token is the better long-term option. Limit the org secret `CLA_SIGNATURES_TOKEN` to the covered repositories. @@ -55,4 +55,4 @@ Nothing runs until these steps are done, and the only manual ones are: 2. Anh runs `scripts/enable-cla.sh ` for `orbyn-engine`, `orbyn-planner`, `orbyn-engineering`, `orbyn.dev` and `orbyn-notes`, and merges the pull request each opens. 3. Anh requires the status check `CLA` on each repository's default branch, and sets "Require approval for all outside collaborators" for fork pull request workflows. -The older "Licence grant" check (`licence-grant.yml`) stays in force until Anh switches over. +The older "Licence grant" check was retired on 2026-11-08, once `CLA` was required in every covered repository. diff --git a/examples/licence-grant-caller.yml b/examples/licence-grant-caller.yml deleted file mode 100644 index 4a7b677..0000000 --- a/examples/licence-grant-caller.yml +++ /dev/null @@ -1,27 +0,0 @@ -# Copy to .github/workflows/licence-grant.yml in a product repository. Then make -# "Licence grant" a required status check on main. Organisation members and bots pass at once. -name: Licence grant - -on: - pull_request_target: - types: [opened, reopened, synchronize] - issue_comment: - types: [created, edited] - workflow_dispatch: - inputs: - pull: - description: Pull request number to check again - required: true - -permissions: - contents: read - pull-requests: write - issues: write - statuses: write - -jobs: - licence-grant: - uses: orbyn-dev/.github/.github/workflows/licence-grant.yml@SHA_OF_ORBYN_DEV_GITHUB_MAIN - with: - pull: ${{ inputs.pull }} - secrets: inherit diff --git a/scripts/licence-grant.test.mjs b/scripts/licence-grant.test.mjs deleted file mode 100644 index 9b709f9..0000000 --- a/scripts/licence-grant.test.mjs +++ /dev/null @@ -1,44 +0,0 @@ -// Tests the matcher inside .github/workflows/licence-grant.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/licence-grant.yml", import.meta.url), "utf8"); -const block = workflow.match(/\/\/ matcher:start\n([\s\S]*?)\/\/ matcher:end/)[1]; -const { SENTENCE, grants, isExempt } = new Function(`${block}\nreturn { SENTENCE, grants, isExempt };`)(); - -const pull = (login, association, type = "User") => ({ user: { login, type }, author_association: association }); - -test("the exact sentence counts", () => { - assert.equal(grants(SENTENCE), true); -}); - -test("a quoted, code-fenced or reflowed copy still counts", () => { - assert.equal(grants(`> ${SENTENCE}`), true); - assert.equal(grants("```\n" + SENTENCE + "\n```"), true); - assert.equal(grants(SENTENCE.replace("worldwide licence", "worldwide\nlicence")), true); - assert.equal(grants(`Sure!\n\n"${SENTENCE}"`), true); -}); - -test("a changed or partial sentence does not count", () => { - assert.equal(grants(SENTENCE.replace("irrevocable", "revocable")), false); - assert.equal(grants(SENTENCE.slice(0, 60)), false); - assert.equal(grants(SENTENCE.replace("Anh Dang", "someone")), false); - assert.equal(grants(""), false); - assert.equal(grants(undefined), false); -}); - -test("organisation members, listed logins and bots are exempt", () => { - assert.equal(isExempt(pull("someone", "MEMBER"), []), true); - assert.equal(isExempt(pull("someone", "OWNER"), []), true); - assert.equal(isExempt(pull("dependabot[bot]", "NONE", "Bot"), []), true); - assert.equal(isExempt(pull("KinqsRadio", "CONTRIBUTOR"), ["kinqsradio"]), true); -}); - -test("outside collaborators and first-time contributors are not exempt", () => { - assert.equal(isExempt(pull("luannn010", "COLLABORATOR"), ["kinqsradio"]), false); - assert.equal(isExempt(pull("newcomer", "FIRST_TIME_CONTRIBUTOR"), ["kinqsradio"]), false); - assert.equal(isExempt(pull("someone", "NONE"), ["kinqsradio"]), false); -});