From 65ef2f0d540593c499911567bead255c5e4fa0d0 Mon Sep 17 00:00:00 2001 From: Tran Duc Anh Dang Date: Sun, 8 Nov 2026 00:52:39 +1100 Subject: [PATCH] feat: interim licence-grant check for outside pull requests --- .github/workflows/licence-grant.yml | 156 ++++++++++++++++++++++++++++ .github/workflows/test.yml | 17 +++ README.md | 6 ++ examples/licence-grant-caller.yml | 27 +++++ scripts/licence-grant.test.mjs | 44 ++++++++ 5 files changed, 250 insertions(+) create mode 100644 .github/workflows/licence-grant.yml create mode 100644 .github/workflows/test.yml create mode 100644 examples/licence-grant-caller.yml create mode 100644 scripts/licence-grant.test.mjs diff --git a/.github/workflows/licence-grant.yml b/.github/workflows/licence-grant.yml new file mode 100644 index 0000000..30ea9a8 --- /dev/null +++ b/.github/workflows/licence-grant.yml @@ -0,0 +1,156 @@ +# 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/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..cf21bb0 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,17 @@ +name: Tests + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + scripts: + name: Workflow script tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + - run: node --test scripts/*.test.mjs diff --git a/README.md b/README.md index 9493f6f..b00f118 100644 --- a/README.md +++ b/README.md @@ -6,3 +6,9 @@ This repository will hold organisation-wide files for Orbyn projects, starting with a contributor licence agreement. The draft is under review in a pull request. 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`. diff --git a/examples/licence-grant-caller.yml b/examples/licence-grant-caller.yml new file mode 100644 index 0000000..4a7b677 --- /dev/null +++ b/examples/licence-grant-caller.yml @@ -0,0 +1,27 @@ +# 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 new file mode 100644 index 0000000..9b709f9 --- /dev/null +++ b/scripts/licence-grant.test.mjs @@ -0,0 +1,44 @@ +// 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); +});