Skip to content
Merged
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
156 changes: 156 additions & 0 deletions .github/workflows/licence-grant.yml
Original file line number Diff line number Diff line change
@@ -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 = '<!-- orbyn:licence-grant:ask -->';
const THANKS = '<!-- orbyn:licence-grant: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'),
});
17 changes: 17 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
27 changes: 27 additions & 0 deletions examples/licence-grant-caller.yml
Original file line number Diff line number Diff line change
@@ -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
44 changes: 44 additions & 0 deletions scripts/licence-grant.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
Loading