From 6673003984565ec407320f3300db5e7cccff8376 Mon Sep 17 00:00:00 2001 From: Tran Duc Anh Dang Date: Sun, 8 Nov 2026 00:31:58 +1100 Subject: [PATCH 1/5] docs: draft contributor licence agreement and check, not in force --- .github/workflows/cla.yml | 108 ++++++++++++++++++++++ CLA.md | 123 ++++++++++++++++++++++++++ REVIEW.md | 38 ++++++++ examples/CONTRIBUTING-cla.md | 10 +++ examples/PULL_REQUEST_TEMPLATE-cla.md | 2 + examples/cla-caller.yml | 20 +++++ 6 files changed, 301 insertions(+) create mode 100644 .github/workflows/cla.yml create mode 100644 CLA.md create mode 100644 REVIEW.md create mode 100644 examples/CONTRIBUTING-cla.md create mode 100644 examples/PULL_REQUEST_TEMPLATE-cla.md create mode 100644 examples/cla-caller.yml diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml new file mode 100644 index 0000000..59c9e2b --- /dev/null +++ b/.github/workflows/cla.yml @@ -0,0 +1,108 @@ +# DRAFT, not in force. A reusable check that the author of a pull request has accepted the +# Orbyn Individual Contributor Licence Agreement. It runs only when a repository calls it +# (on: workflow_call); no repository does yet. See ../../REVIEW.md. +# +# The caller runs on pull_request_target and issue_comment. This workflow never checks out +# or runs code from the pull request, so pull_request_target is safe here. +name: Contributor licence agreement + +on: + workflow_call: + inputs: + agreement-version: + description: Version of CLA.md being accepted + type: string + default: "0.1" + signatures-repository: + description: Private repository holding signatures/icla-.json + type: string + default: orbyn-dev/cla-signatures + allowed: + description: Comma-separated logins that never need to sign (maintainers and bots) + type: string + default: "kinqsradio,dependabot[bot],github-actions[bot]" + 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 + +jobs: + check: + runs-on: ubuntu-latest + if: github.event_name == 'pull_request_target' || (github.event_name == 'issue_comment' && github.event.issue.pull_request) + 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: | + const version = process.env.VERSION; + const [sigOwner, sigRepo] = process.env.SIGNATURES.split('/'); + const path = `signatures/icla-${version}.json`; + const phrase = `I have read the Orbyn Individual Contributor Licence Agreement version ${version} and I agree to it.`; + const marker = ``; + const allowed = new Set(process.env.ALLOWED.split(',').map((login) => login.trim().toLowerCase()).filter(Boolean)); + const store = getOctokit(process.env.SIGNATURES_TOKEN); + const { owner, repo } = context.repo; + const number = context.payload.pull_request?.number ?? context.payload.issue.number; + const { data: pull } = await github.rest.pulls.get({ owner, repo, pull_number: number }); + const author = pull.user; + + async function load() { + try { + const { data } = await store.rest.repos.getContent({ owner: sigOwner, repo: sigRepo, path }); + return { list: JSON.parse(Buffer.from(data.content, 'base64').toString('utf8')), sha: data.sha }; + } catch (error) { + if (error.status === 404) return { list: [], sha: undefined }; + throw error; + } + } + + if (allowed.has(author.login.toLowerCase())) { + core.info(`${author.login} is on the allowed list.`); + return; + } + + let { list, sha } = await load(); + let signed = list.some((entry) => entry.github_id === author.id); + + const comment = context.payload.comment; + if (!signed && comment && comment.user.id === author.id && comment.body.trim() === phrase) { + list.push({ + github_id: author.id, + login: author.login, + signed_at: new Date().toISOString(), + pull_request: pull.html_url, + comment_id: comment.id, + }); + await store.rest.repos.createOrUpdateFileContents({ + owner: sigOwner, repo: sigRepo, path, sha, + message: `Record CLA ${version} acceptance by ${author.login}`, + content: Buffer.from(JSON.stringify(list, null, 2) + '\n').toString('base64'), + }); + signed = true; + core.info(`Recorded ${author.login}.`); + } + + if (signed) return; + + const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: number }); + if (!comments.some((existing) => existing.body?.includes(marker))) { + await github.rest.issues.createComment({ + owner, repo, issue_number: number, + body: [ + marker, + `Thanks for your contribution, @${author.login}. Before we can merge it, please read the [Orbyn Individual Contributor Licence Agreement](https://github.com/orbyn-dev/.github/blob/main/CLA.md) and, if you agree, reply with exactly:`, + '', + `> ${phrase}`, + ].join('\n'), + }); + } + core.setFailed(`${author.login} has not accepted the Orbyn CLA version ${version}.`); diff --git a/CLA.md b/CLA.md new file mode 100644 index 0000000..cd8415d --- /dev/null +++ b/CLA.md @@ -0,0 +1,123 @@ +# Orbyn Individual Contributor Licence Agreement + +**Version 0.1, DRAFT for legal review. Not in force.** This is not legal advice. Nothing here applies to anyone until it is reviewed, approved and published as version 1.0. + +> Notes for counsel are in [the review notes](REVIEW.md). Bracketed text in capitals, such as `[JURISDICTION]`, is a placeholder still to be decided. + +Thank you for your interest in contributing to Orbyn projects published by Anh Dang ("We" or "Us"). + +This contributor agreement ("Agreement") documents the rights granted by contributors to Us. To make this Agreement effective, accept it as described in [How to accept](#how-to-accept). This is a legally binding document, so please read it carefully before agreeing to it. The Agreement covers every Orbyn project whose repository links to it. + +## 1. Definitions + +"You" means the individual who Submits a Contribution to Us. + +"Contribution" means any work of authorship that is Submitted by You to Us in which You own or assert ownership of the Copyright. If You do not own the Copyright in the entire work of authorship, please follow the instructions in [Work You did not write](#work-you-did-not-write). + +"Copyright" means all rights protecting works of authorship owned or controlled by You, including copyright, moral and neighbouring rights, as appropriate, for the full term of their existence including any extensions by You. + +"Material" means the work of authorship which is made available by Us to third parties. When this Agreement covers more than one software project, the Material means the work of authorship to which the Contribution was Submitted. After You Submit the Contribution, it may be included in the Material. + +"Submit" means any form of electronic, verbal, or written communication sent to Us or our representatives, including but not limited to electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, Us for the purpose of discussing and improving the Material, but excluding communication that is conspicuously marked or otherwise designated in writing by You as "Not a Contribution." + +"Submission Date" means the date on which You Submit a Contribution to Us. + +"Effective Date" means the date You accept this Agreement or the date You first Submit a Contribution to Us, whichever is earlier. + +"Media" means any portion of a Contribution which is not software. + +## 2. Grant of Rights + +### 2.1 Copyright Licence + +(a) You retain ownership of the Copyright in Your Contribution and have the same rights to use or license the Contribution which You would have had without entering into the Agreement. + +(b) To the maximum extent permitted by the relevant law, You grant to Us a perpetual, worldwide, non-exclusive, transferable, royalty-free, irrevocable licence under the Copyright covering the Contribution, with the right to sublicense such rights through multiple tiers of sublicensees, to reproduce, modify, display, perform and distribute the Contribution as part of the Material; provided that this licence is conditioned upon compliance with Section 2.3. + +### 2.2 Patent Licence + +For patent claims including, without limitation, method, process, and apparatus claims which You own, control or have the right to grant, now or in the future, You grant to Us a perpetual, worldwide, non-exclusive, transferable, royalty-free, irrevocable patent licence, with the right to sublicense these rights to multiple tiers of sublicensees, to make, have made, use, sell, offer for sale, import and otherwise transfer the Contribution and the Contribution in combination with the Material (and portions of such combination). This licence is granted only to the extent that the exercise of the licensed rights infringes such patent claims; and provided that this licence is conditioned upon compliance with Section 2.3. + +### 2.3 Outbound Licence + +Based on the grant of rights in Sections 2.1 and 2.2, if We include Your Contribution in a Material, We may license the Contribution under any licence, including copyleft, permissive, commercial, or proprietary licences. As a condition on the exercise of this right, We agree to also license the Contribution under the terms of the licence or licences which We are using for the Material on the Submission Date. + +In addition, We may license Media in the Contribution under any licence on the same basis as this Section 2.3. + +### 2.4 Moral Rights + +If moral rights apply to the Contribution, to the maximum extent permitted by law, You waive and agree not to assert such moral rights against Us or our successors in interest, or any of our licensees, either direct or indirect. + +### 2.5 Our Rights + +You acknowledge that We are not obligated to use Your Contribution as part of the Material and may decide to include any Contribution We consider appropriate. + +### 2.6 Reservation of Rights + +Any rights not expressly licensed under this section are expressly reserved by You. + +### 2.7 Licence to Prepare Contributions + +The licences We use for some Material do not allow anyone to modify it. So that You can contribute, We grant You a personal, worldwide, non-exclusive, non-transferable, royalty-free licence, for as long as this Agreement is in effect, to download, install, run, copy and modify the Material solely to develop, test and Submit Contributions to Us. This licence does not let You distribute the Material or any modified version of it to anyone except Us, or use it for any other purpose. Your rights under the licence that applies to the Material are not reduced by this Section. + +## 3. Agreement + +You confirm that: + +(a) You have the legal authority to enter into this Agreement. + +(b) You own the Copyright and patent claims covering the Contribution which are required to grant the rights under Section 2. + +(c) The grant of rights under Section 2 does not violate any grant of rights which You have made to third parties, including Your employer. If You are an employee, You have had Your employer approve this Agreement or sign the entity version of this Agreement. If You are less than eighteen years old, please have Your parents or guardian accept the Agreement. + +(d) You have followed the instructions in [Work You did not write](#work-you-did-not-write), if You do not own the Copyright in the entire work of authorship Submitted. + +## 4. Disclaimer + +EXCEPT FOR THE EXPRESS WARRANTIES IN SECTION 3, THE CONTRIBUTION IS PROVIDED "AS IS". MORE PARTICULARLY, ALL EXPRESS OR IMPLIED WARRANTIES INCLUDING, WITHOUT LIMITATION, ANY IMPLIED WARRANTY OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT ARE EXPRESSLY DISCLAIMED BY YOU TO US. TO THE EXTENT THAT ANY SUCH WARRANTIES CANNOT BE DISCLAIMED, SUCH WARRANTY IS LIMITED IN DURATION TO THE MINIMUM PERIOD PERMITTED BY LAW. + +## 5. Consequential Damage Waiver + +TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, IN NO EVENT WILL YOU BE LIABLE FOR ANY LOSS OF PROFITS, LOSS OF ANTICIPATED SAVINGS, LOSS OF DATA, INDIRECT, SPECIAL, INCIDENTAL, CONSEQUENTIAL AND EXEMPLARY DAMAGES ARISING OUT OF THIS AGREEMENT REGARDLESS OF THE LEGAL OR EQUITABLE THEORY (CONTRACT, TORT OR OTHERWISE) UPON WHICH THE CLAIM IS BASED. + +## 6. Miscellaneous + +6.1 This Agreement will be governed by and construed in accordance with the laws of `[JURISDICTION]` excluding its conflicts of law provisions. Under certain circumstances, the governing law in this section might be superseded by the United Nations Convention on Contracts for the International Sale of Goods ("UN Convention") and the parties intend to avoid the application of the UN Convention to this Agreement and, thus, exclude the application of the UN Convention in its entirety to this Agreement. + +6.2 This Agreement sets out the entire agreement between You and Us for Your Contributions to Us and overrides all other agreements or understandings. + +6.3 If You or We assign the rights or obligations received through this Agreement to a third party, as a condition of the assignment, that third party must agree in writing to abide by all the rights and obligations in the Agreement. + +6.4 The failure of either party to require performance by the other party of any provision of this Agreement in one situation shall not affect the right of a party to require such performance at any time in the future. A waiver of performance under a provision in one situation shall not be considered a waiver of the performance of the provision in the future or a waiver of the provision in its entirety. + +6.5 If any provision of this Agreement is found void and unenforceable, such provision will be replaced to the extent possible with a provision that comes closest to the meaning of the original provision and which is enforceable. The terms and conditions set forth in this Agreement shall apply notwithstanding any failure of essential purpose of this Agreement or any limited remedy to the maximum extent possible under law. + +## How to accept + +Comment on your pull request with exactly this sentence, from the GitHub account that opened it: + +> I have read the Orbyn Individual Contributor Licence Agreement version 0.1 and I agree to it. + +Your acceptance covers that contribution and every later contribution You Submit to an Orbyn project while this version is in effect. We keep a record of your GitHub account number, login, the date and a link to the comment. We don't record your name, email or address. + +## Work You did not write + +If part of what You Submit was written by someone else, say so in the pull request before it is merged: name the part, where it came from and the licence it is under. We may decline any part We cannot accept. Don't Submit work You are not allowed to share. + +--- + +## Credit + +This Agreement is adapted from the [Harmony Individual Contributor License Agreement, version 1.0](https://www.harmonyagreements.org/agreements), by the Harmony Agreements project, used under the [Creative Commons Attribution 3.0 Unported License](https://creativecommons.org/licenses/by/3.0/). Harmony did not write or endorse this version. + +Changes from the template: + +- Filled the placeholders. "We" is Anh Dang. The Agreement covers every Orbyn project whose repository links to it. +- Chose Outbound Option Five ("any licence") in 2.3 and removed the other four options. Media follows the same rule. +- Removed the optional "or Your Affiliates" wording, since this is the individual version. +- Added Section 2.7, a licence to prepare contributions. +- Replaced the signature blocks with acceptance by a pull request comment, and replaced the submission and non-owner placeholders with the two sections above. +- Used British spelling ("licence" as a noun) and Markdown headings. +- Left `[JURISDICTION]` for counsel. + +This adapted text is also available under the Creative Commons Attribution 3.0 Unported License. diff --git a/REVIEW.md b/REVIEW.md new file mode 100644 index 0000000..421fbf6 --- /dev/null +++ b/REVIEW.md @@ -0,0 +1,38 @@ +# Review notes for counsel + +**DRAFT, not in force. Not legal advice.** These notes explain the draft in [CLA.md](CLA.md) and list what needs a decision before version 1.0. + +## Why a CLA, and why this base + +- Orbyn's apps are licensed under PolyForm Strict 1.0.0, which lets no one modify or share the code. The engine is planned to be dual-licensed. Taking outside contributions therefore needs a grant broad enough for Us to keep relicensing them. +- **Base text:** the Harmony Individual Contributor License Agreement 1.0. Its own text says it is licensed under CC BY 3.0, so it can be adapted with credit. +- **Apache ICLA, not used:** the Apache Individual CLA (https://www.apache.org/licenses/icla.pdf) was considered and not used. The document carries no licence or reuse grant of its own; the Apache-2.0 notice on apache.org is the website footer and covers the ASF's software. No Apache text is copied here. +- **Outbound option:** Option Five ("any licence") was chosen so that one agreement covers PolyForm Strict, the dual-licensed engine and any future commercial licence. + +## Decisions for counsel and Anh + +1. **Who "We" is.** The draft names Anh Dang, matching the PolyForm notice ("Copyright (c) 2026 Anh Dang"). If a company will hold the rights, it should be named here, together with what happens to existing grants if the rights move to that company. Section 6.3 covers assignment in general terms. +2. **Licence or assignment.** The draft keeps copyright with the contributor and takes a broad licence. Harmony also has an assignment version. Is a licence with Option Five enough for later relicensing and enforcement, or is an assignment needed? +3. **Section 2.7, the licence to prepare contributions.** It isn't in Harmony; it was added because PolyForm Strict forbids modification, so contributors would otherwise have no right to edit a local copy. Please check its scope: development and submission only, no distribution, and it lasts as long as the Agreement. +4. **Acceptance by comment.** Contributors accept by posting a fixed sentence on their pull request, which is recorded with their GitHub account number. Is that sufficient evidence of agreement in the relevant jurisdictions, or is a signed form needed? +5. **Governing law** (`[JURISDICTION]`). +6. **Minors.** 3(c) keeps Harmony's line that a parent or guardian should accept for anyone under eighteen. With acceptance by comment there is no way to verify this. Should the age line change, or should minors be refused? +7. **Entity version.** The draft is the individual version only. Contributors whose employer owns their work need an entity agreement (Harmony has one, also CC BY 3.0). Should it be added now or on request? +8. **Contributions made before this Agreement.** Open pull requests from outside contributors exist today. Should their authors accept this Agreement before merge, with the acceptance stated to cover their earlier submissions? +9. **Which projects.** The Agreement covers every Orbyn project whose repository links to it. + - Proposed: orbyn-engine, orbyn-planner, orbyn-engineering and orbyn.dev. + - orbyn-designs is private. If outsiders ever get access, they would also need a confidentiality agreement, which this Agreement does not provide. +10. **The MIT skills library** (orbyn-fullstack-skills). Under MIT, inbound equals outbound, and a Developer Certificate of Origin sign-off (`git commit -s`) is usually enough. A CLA is only needed if Anh might ever relicense the library away from MIT. **Question for Anh: might the skills library ever leave MIT?** + +## Records + +- Acceptances go to a private repository, `orbyn-dev/cla-signatures`, one file per agreement version. +- Each entry holds only the GitHub account number, login, date, pull request and comment link. No names, emails or addresses are stored in any public place. +- The check in `.github/workflows/cla.yml` reads and writes that file with a token limited to that one repository. Anh creates the token when he approves the Agreement. No repository runs the check until then. + +## Technical notes (not for counsel) + +- **Base check.** The check is our own short script on `actions/github-script` v9.0.0 (MIT), pinned by commit. The common tool for this, CLA Assistant Lite (`contributor-assistant/github-action`, Apache-2.0), was archived in March 2026, so it isn't used. None of its code is copied. +- **Who is checked.** Only the pull request's author. Co-authors listed in commits aren't checked yet. +- **Re-running after acceptance.** Accepting by comment records the signature in its own run. The earlier failed check on the pull request stays red until the author pushes again or a maintainer re-runs it. This should be fixed before version 1.0. +- **Running it.** The workflow runs only through `workflow_call`, and no repository calls it. Turning it on takes three steps: Anh creates the `CLA_SIGNATURES_TOKEN` org secret (a fine-grained token limited to `orbyn-dev/cla-signatures`), then each owner copies `examples/cla-caller.yml` into their own repository and adds the snippets. diff --git a/examples/CONTRIBUTING-cla.md b/examples/CONTRIBUTING-cla.md new file mode 100644 index 0000000..d82fb69 --- /dev/null +++ b/examples/CONTRIBUTING-cla.md @@ -0,0 +1,10 @@ + + +## 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,** reply on your pull request with the sentence the check asks for. You only do this once per version of the Agreement. +- **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..e18d29e --- /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), 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..1d99ae1 --- /dev/null +++ b/examples/cla-caller.yml @@ -0,0 +1,20 @@ +# 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, each +# repository owner copies it to .github/workflows/cla.yml in their own repository. +name: Contributor licence agreement + +on: + pull_request_target: + types: [opened, reopened, synchronize] + issue_comment: + types: [created] + +permissions: + contents: read + pull-requests: write + +jobs: + cla: + uses: orbyn-dev/.github/.github/workflows/cla.yml@main + secrets: + signatures-token: ${{ secrets.CLA_SIGNATURES_TOKEN }} From 90f82578bee74c14408872161a0232817e7811e2 Mon Sep 17 00:00:00 2001 From: Tran Duc Anh Dang Date: Sun, 8 Nov 2026 00:34:17 +1100 Subject: [PATCH 2/5] docs: engine is MIT OR Apache-2.0; skills library uses a DCO --- REVIEW.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/REVIEW.md b/REVIEW.md index 421fbf6..beb699a 100644 --- a/REVIEW.md +++ b/REVIEW.md @@ -4,10 +4,10 @@ ## Why a CLA, and why this base -- Orbyn's apps are licensed under PolyForm Strict 1.0.0, which lets no one modify or share the code. The engine is planned to be dual-licensed. Taking outside contributions therefore needs a grant broad enough for Us to keep relicensing them. +- **Licences.** Orbyn's apps (orbyn-planner, orbyn-engineering) and orbyn.dev are under PolyForm Strict 1.0.0, which lets no one modify or share the code. The engine (orbyn-engine) is moving to MIT OR Apache-2.0. Taking outside contributions across both needs a grant broad enough for Us to license them under any of these, and commercially. - **Base text:** the Harmony Individual Contributor License Agreement 1.0. Its own text says it is licensed under CC BY 3.0, so it can be adapted with credit. - **Apache ICLA, not used:** the Apache Individual CLA (https://www.apache.org/licenses/icla.pdf) was considered and not used. The document carries no licence or reuse grant of its own; the Apache-2.0 notice on apache.org is the website footer and covers the ASF's software. No Apache text is copied here. -- **Outbound option:** Option Five ("any licence") was chosen so that one agreement covers PolyForm Strict, the dual-licensed engine and any future commercial licence. +- **Outbound option:** Option Five ("any licence") was chosen so that one agreement covers PolyForm Strict, the engine's MIT OR Apache-2.0 and any future commercial licence. The engine keeps the CLA even though it is permissive, so one rule applies to every covered project and a commercial licence stays possible. ## Decisions for counsel and Anh @@ -19,10 +19,9 @@ 6. **Minors.** 3(c) keeps Harmony's line that a parent or guardian should accept for anyone under eighteen. With acceptance by comment there is no way to verify this. Should the age line change, or should minors be refused? 7. **Entity version.** The draft is the individual version only. Contributors whose employer owns their work need an entity agreement (Harmony has one, also CC BY 3.0). Should it be added now or on request? 8. **Contributions made before this Agreement.** Open pull requests from outside contributors exist today. Should their authors accept this Agreement before merge, with the acceptance stated to cover their earlier submissions? -9. **Which projects.** The Agreement covers every Orbyn project whose repository links to it. - - Proposed: orbyn-engine, orbyn-planner, orbyn-engineering and orbyn.dev. +9. **Which projects (decided).** The Agreement covers orbyn-engine, orbyn-planner, orbyn-engineering and orbyn.dev. - orbyn-designs is private. If outsiders ever get access, they would also need a confidentiality agreement, which this Agreement does not provide. -10. **The MIT skills library** (orbyn-fullstack-skills). Under MIT, inbound equals outbound, and a Developer Certificate of Origin sign-off (`git commit -s`) is usually enough. A CLA is only needed if Anh might ever relicense the library away from MIT. **Question for Anh: might the skills library ever leave MIT?** +10. **The MIT skills library (decided).** orbyn-fullstack-skills stays MIT for good. It doesn't use this Agreement: inbound equals outbound, with a Developer Certificate of Origin sign-off (`git commit -s`) on every commit. ## Records From 8bad3c04c7acac8731a8c75a3086003703658f07 Mon Sep 17 00:00:00 2001 From: Tran Duc Anh Dang Date: Sun, 8 Nov 2026 12:37:58 +1100 Subject: [PATCH 3/5] docs: one acceptance covers contributions made before it --- CLA.md | 2 +- REVIEW.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CLA.md b/CLA.md index cd8415d..c8caf8d 100644 --- a/CLA.md +++ b/CLA.md @@ -98,7 +98,7 @@ Comment on your pull request with exactly this sentence, from the GitHub account > I have read the Orbyn Individual Contributor Licence Agreement version 0.1 and I agree to it. -Your acceptance covers that contribution and every later contribution You Submit to an Orbyn project while this version is in effect. We keep a record of your GitHub account number, login, the date and a link to the comment. We don't record your name, email or address. +Your acceptance covers that contribution, every contribution You Submitted to an Orbyn project before You accepted (including work that is already merged), and every later contribution You Submit while this version is in effect. You only need to accept once for all of it. We keep a record of your GitHub account number, login, the date and a link to the comment. We don't record your name, email or address. ## Work You did not write diff --git a/REVIEW.md b/REVIEW.md index beb699a..c78f326 100644 --- a/REVIEW.md +++ b/REVIEW.md @@ -18,7 +18,7 @@ 5. **Governing law** (`[JURISDICTION]`). 6. **Minors.** 3(c) keeps Harmony's line that a parent or guardian should accept for anyone under eighteen. With acceptance by comment there is no way to verify this. Should the age line change, or should minors be refused? 7. **Entity version.** The draft is the individual version only. Contributors whose employer owns their work need an entity agreement (Harmony has one, also CC BY 3.0). Should it be added now or on request? -8. **Contributions made before this Agreement.** Open pull requests from outside contributors exist today. Should their authors accept this Agreement before merge, with the acceptance stated to cover their earlier submissions? +8. **Contributions made before this Agreement.** Drafted so that one acceptance covers everything the person has already Submitted, including work that is already merged, plus later contributions (see "How to accept"). This lets an existing contributor, such as Luan, sign once when the Agreement is in force. Please confirm that a later acceptance can validly cover earlier submissions in the relevant jurisdictions, whether the agreement should say the earlier work was contributed on the same terms from the date of each submission, and whether a short confirmation from existing contributors is needed in addition to the comment. 9. **Which projects (decided).** The Agreement covers orbyn-engine, orbyn-planner, orbyn-engineering and orbyn.dev. - orbyn-designs is private. If outsiders ever get access, they would also need a confidentiality agreement, which this Agreement does not provide. 10. **The MIT skills library (decided).** orbyn-fullstack-skills stays MIT for good. It doesn't use this Agreement: inbound equals outbound, with a Developer Certificate of Origin sign-off (`git commit -s`) on every commit. From 83aa7357498ad3453b5bd4889845bfc01340e0f5 Mon Sep 17 00:00:00 2001 From: Tran Duc Anh Dang Date: Sun, 8 Nov 2026 12:49:42 +1100 Subject: [PATCH 4/5] feat: agree once with /orbynAgree for every Orbyn repository; CLA commit status --- .github/workflows/cla.yml | 234 ++++++++++++++++++++------ CLA.md | 8 +- README.md | 12 ++ REVIEW.md | 26 ++- examples/CONTRIBUTING-cla.md | 2 +- examples/PULL_REQUEST_TEMPLATE-cla.md | 2 +- examples/cla-caller.yml | 10 +- scripts/cla.test.mjs | 163 ++++++++++++++++++ scripts/enable-cla.sh | 74 ++++++++ 9 files changed, 464 insertions(+), 67 deletions(-) create mode 100644 scripts/cla.test.mjs create mode 100755 scripts/enable-cla.sh diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml index 59c9e2b..f7f8346 100644 --- a/.github/workflows/cla.yml +++ b/.github/workflows/cla.yml @@ -1,16 +1,29 @@ -# DRAFT, not in force. A reusable check that the author of a pull request has accepted the +# 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. # -# The caller runs on pull_request_target and issue_comment. This workflow never checks out -# or runs code from the pull request, so pull_request_target is safe here. +# 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). +# +# 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 accepted + description: Version of CLA.md being agreed to type: string default: "0.1" signatures-repository: @@ -18,7 +31,7 @@ on: type: string default: orbyn-dev/cla-signatures allowed: - description: Comma-separated logins that never need to sign (maintainers and bots) + description: Comma-separated logins that never need to agree (maintainers and bots) type: string default: "kinqsradio,dependabot[bot],github-actions[bot]" secrets: @@ -29,11 +42,20 @@ on: 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.issue.pull_request) + 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') + 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: @@ -43,19 +65,123 @@ jobs: 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; + for (const raw of String(body ?? '').replace(/\r/g, '').split('\n')) { + const opener = raw.match(/^ {0,3}(`{3,}|~{3,})/); + if (fence) { + const closer = raw.match(/^ {0,3}(`{3,}|~{3,})[ \t]*$/); + if (closer && closer[1][0] === fence[0] && closer[1].length >= fence.length) fence = null; + continue; + } + if (opener) { fence = opener[1]; continue; } + if (/^(?: {4}|\t)/.test(raw) || /^\s*>/.test(raw)) continue; + if (COMMAND.test(raw.trim())) return true; + } + return false; + } + + /** A comment counts when the pull request's author wrote it, it is not a bot, it was never edited, and it holds the command. */ + function isAgreement(comment, pull) { + return Boolean(comment && comment.user && pull && pull.user) + && comment.user.id === pull.user.id + && comment.user.type !== 'Bot' + && comment.created_at === comment.updated_at + && hasAgreeCommand(comment.body); + } + + /** The first valid agreement comment on a pull request, if any. */ + function findAgreement(comments, pull) { + return comments.find((comment) => isAgreement(comment, pull)); + } + + /** Organisation members and owners, bots and the listed logins never need to agree. */ + function isExempt(pull, allowedLogins) { + return pull.user.type === 'Bot' + || ['OWNER', 'MEMBER'].includes(pull.author_association) + || allowedLogins.includes(pull.user.login.toLowerCase()); + } + + /** True when the account id is already in the record. */ + function isSigned(list, githubId) { + return list.some((entry) => Number(entry.github_id) === Number(githubId)); + } + + /** + * Add an entry to the shared record without losing a concurrent signature. `read` + * returns { list, sha }; `write(list, sha)` throws an error with status 409 (or 422 + * when a create races with another create) if the file changed in between. On a + * conflict, read again and retry, up to `attempts` times. + */ + async function recordSignature({ read, write, entry, attempts = 5, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) }) { + for (let attempt = 1; ; attempt++) { + const { list, sha } = await read(); + if (isSigned(list, entry.github_id)) return { added: false, attempts: attempt }; + try { + await write([...list, entry], sha); + return { added: true, attempts: attempt }; + } catch (error) { + if (![409, 422].includes(error?.status) || attempt >= attempts) throw error; + await sleep(200 * attempt + Math.floor(Math.random() * 200)); + } + } + } + + function askBody(login) { + return [ + MARKER, + `Thanks for your contribution, @${login}! Before it can be merged, please read the [Orbyn Individual Contributor Licence Agreement](${CLA_URL}).`, + '', + 'If you agree, comment `/orbynAgree` on a line of its own. You do this once. It covers this pull request, your earlier contributions and every Orbyn repository that uses the agreement, so you will not be asked again anywhere else.', + '', + 'The `CLA` check turns green as soon as you do.', + ].join('\n'); + } + + function agreedBody(login, comment) { + return [ + MARKER, + `Thanks, @${login}. Your agreement to the [Orbyn Individual Contributor Licence Agreement](${CLA_URL}) is recorded${comment ? ` ([comment](${comment.html_url}))` : ''}. It covers all Orbyn repositories that use the agreement, so you will not be asked again.`, + ].join('\n'); + } + // matcher:end + const version = process.env.VERSION; const [sigOwner, sigRepo] = process.env.SIGNATURES.split('/'); const path = `signatures/icla-${version}.json`; - const phrase = `I have read the Orbyn Individual Contributor Licence Agreement version ${version} and I agree to it.`; - const marker = ``; - const allowed = new Set(process.env.ALLOWED.split(',').map((login) => login.trim().toLowerCase()).filter(Boolean)); - const store = getOctokit(process.env.SIGNATURES_TOKEN); + const allowedLogins = process.env.ALLOWED.split(',').map((login) => login.trim().toLowerCase()).filter(Boolean); const { owner, repo } = context.repo; - const number = context.payload.pull_request?.number ?? context.payload.issue.number; + const number = Number(context.payload.pull_request?.number ?? context.payload.issue?.number); + if (!number) { core.setFailed('No pull request number.'); return; } + const { data: pull } = await github.rest.pulls.get({ owner, repo, pull_number: number }); - const author = pull.user; + const setStatus = (state, description) => github.rest.repos.createCommitStatus({ + owner, repo, sha: pull.head.sha, state, description, context: 'CLA', target_url: CLA_URL, + }); + + if (isExempt(pull, allowedLogins)) { + await setStatus('success', 'No agreement needed'); + return; + } - async function load() { + if (!process.env.SIGNATURES_TOKEN) { + await setStatus('error', 'The agreement record is not configured.'); + core.setFailed('The signatures-token secret is empty.'); + return; + } + const store = getOctokit(process.env.SIGNATURES_TOKEN); + + async function read() { try { const { data } = await store.rest.repos.getContent({ owner: sigOwner, repo: sigRepo, path }); return { list: JSON.parse(Buffer.from(data.content, 'base64').toString('utf8')), sha: data.sha }; @@ -65,44 +191,52 @@ jobs: } } - if (allowed.has(author.login.toLowerCase())) { - core.info(`${author.login} is on the allowed list.`); - return; - } + const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: number }); + // Bots may not claim the marker, so only a bot-written comment counts as ours. + const ours = comments.find((comment) => comment.user?.type === 'Bot' && comment.body?.includes(MARKER)); + const upsert = async (body) => { + if (!ours) return github.rest.issues.createComment({ owner, repo, issue_number: number, body }); + if (ours.body !== body) return github.rest.issues.updateComment({ owner, repo, comment_id: ours.id, body }); + }; - let { list, sha } = await load(); - let signed = list.some((entry) => entry.github_id === author.id); - - const comment = context.payload.comment; - if (!signed && comment && comment.user.id === author.id && comment.body.trim() === phrase) { - list.push({ - github_id: author.id, - login: author.login, - signed_at: new Date().toISOString(), - pull_request: pull.html_url, - comment_id: comment.id, - }); - await store.rest.repos.createOrUpdateFileContents({ - owner: sigOwner, repo: sigRepo, path, sha, - message: `Record CLA ${version} acceptance by ${author.login}`, - content: Buffer.from(JSON.stringify(list, null, 2) + '\n').toString('base64'), - }); - signed = true; - core.info(`Recorded ${author.login}.`); + let signed; + let agreement; + try { + signed = isSigned((await read()).list, pull.user.id); + if (!signed) { + agreement = findAgreement(comments, pull); + if (agreement) { + const entry = { + github_id: pull.user.id, + login: pull.user.login, + signed_at: agreement.created_at, + repository: `${owner}/${repo}`, + pull_request: pull.html_url, + comment_url: agreement.html_url, + }; + await recordSignature({ + read, + write: (list, sha) => store.rest.repos.createOrUpdateFileContents({ + owner: sigOwner, repo: sigRepo, path, sha, + message: `Record CLA ${version} agreement by ${pull.user.login} (${owner}/${repo}#${number})`, + content: Buffer.from(JSON.stringify(list, null, 2) + '\n').toString('base64'), + }), + entry, + }); + signed = true; + core.info(`Recorded ${pull.user.login}.`); + } + } + } catch (error) { + await setStatus('error', 'Could not check the agreement record.'); + throw error; } - if (signed) return; - - const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: number }); - if (!comments.some((existing) => existing.body?.includes(marker))) { - await github.rest.issues.createComment({ - owner, repo, issue_number: number, - body: [ - marker, - `Thanks for your contribution, @${author.login}. Before we can merge it, please read the [Orbyn Individual Contributor Licence Agreement](https://github.com/orbyn-dev/.github/blob/main/CLA.md) and, if you agree, reply with exactly:`, - '', - `> ${phrase}`, - ].join('\n'), - }); + if (signed) { + await setStatus('success', 'Agreed to the Orbyn contributor agreement'); + if (ours || agreement) await upsert(agreedBody(pull.user.login, agreement)); + return; } - core.setFailed(`${author.login} has not accepted the Orbyn CLA version ${version}.`); + + await setStatus('failure', 'Comment /orbynAgree once to agree to the Orbyn contributor agreement.'); + await upsert(askBody(pull.user.login)); diff --git a/CLA.md b/CLA.md index c8caf8d..ba563f0 100644 --- a/CLA.md +++ b/CLA.md @@ -94,11 +94,9 @@ TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, IN NO EVENT WILL YOU BE LIABL ## How to accept -Comment on your pull request with exactly this sentence, from the GitHub account that opened it: +Comment `/orbynAgree` on a line of its own on your pull request, from the GitHub account that opened it. (`/orbyn agree` works too, in any capitalisation. A quoted line, code, or the command inside a sentence does not count.) -> I have read the Orbyn Individual Contributor Licence Agreement version 0.1 and I agree to it. - -Your acceptance covers that contribution, every contribution You Submitted to an Orbyn project before You accepted (including work that is already merged), and every later contribution You Submit while this version is in effect. You only need to accept once for all of it. We keep a record of your GitHub account number, login, the date and a link to the comment. We don't record your name, email or address. +You only need to accept once. Your acceptance covers that contribution, every contribution You Submitted to an Orbyn project before You accepted (including work that is already merged), and every later contribution You Submit while this version is in effect, in every Orbyn repository that uses this Agreement. We keep one record of your GitHub account number, login, the date, the repository and pull request where You accepted, and a link to the comment. We don't record your name, email or address. ## Work You did not write @@ -116,7 +114,7 @@ Changes from the template: - Chose Outbound Option Five ("any licence") in 2.3 and removed the other four options. Media follows the same rule. - Removed the optional "or Your Affiliates" wording, since this is the individual version. - Added Section 2.7, a licence to prepare contributions. -- Replaced the signature blocks with acceptance by a pull request comment, and replaced the submission and non-owner placeholders with the two sections above. +- Replaced the signature blocks with acceptance by a one-time `/orbynAgree` pull request comment, and replaced the submission and non-owner placeholders with the two sections above. - Used British spelling ("licence" as a noun) and Markdown headings. - Left `[JURISDICTION]` for counsel. diff --git a/README.md b/README.md index b00f118..77fa945 100644 --- a/README.md +++ b/README.md @@ -12,3 +12,15 @@ Contact: hello@orbyn.dev `.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 (draft, not in force) + +`.github/workflows/cla.yml` is a reusable check for the draft [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. + +### Turning it on for a repository + +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. +3. Require the status check `CLA` on the default branch. + +See [REVIEW.md](REVIEW.md) for the records, the security notes and the exact manual steps. diff --git a/REVIEW.md b/REVIEW.md index c78f326..2a32794 100644 --- a/REVIEW.md +++ b/REVIEW.md @@ -14,7 +14,7 @@ 1. **Who "We" is.** The draft names Anh Dang, matching the PolyForm notice ("Copyright (c) 2026 Anh Dang"). If a company will hold the rights, it should be named here, together with what happens to existing grants if the rights move to that company. Section 6.3 covers assignment in general terms. 2. **Licence or assignment.** The draft keeps copyright with the contributor and takes a broad licence. Harmony also has an assignment version. Is a licence with Option Five enough for later relicensing and enforcement, or is an assignment needed? 3. **Section 2.7, the licence to prepare contributions.** It isn't in Harmony; it was added because PolyForm Strict forbids modification, so contributors would otherwise have no right to edit a local copy. Please check its scope: development and submission only, no distribution, and it lasts as long as the Agreement. -4. **Acceptance by comment.** Contributors accept by posting a fixed sentence on their pull request, which is recorded with their GitHub account number. Is that sufficient evidence of agreement in the relevant jurisdictions, or is a signed form needed? +4. **Acceptance by comment.** Contributors accept once by commenting `/orbynAgree` on their own pull request; the comment is recorded with their GitHub account number and covers every covered Orbyn repository. Is that sufficient evidence of agreement in the relevant jurisdictions, or is a signed form needed? 5. **Governing law** (`[JURISDICTION]`). 6. **Minors.** 3(c) keeps Harmony's line that a parent or guardian should accept for anyone under eighteen. With acceptance by comment there is no way to verify this. Should the age line change, or should minors be refused? 7. **Entity version.** The draft is the individual version only. Contributors whose employer owns their work need an entity agreement (Harmony has one, also CC BY 3.0). Should it be added now or on request? @@ -25,13 +25,25 @@ ## Records -- Acceptances go to a private repository, `orbyn-dev/cla-signatures`, one file per agreement version. -- Each entry holds only the GitHub account number, login, date, pull request and comment link. No names, emails or addresses are stored in any public place. -- The check in `.github/workflows/cla.yml` reads and writes that file with a token limited to that one repository. Anh creates the token when he approves the Agreement. No repository runs the check until then. +- Acceptances go to one private repository, `orbyn-dev/cla-signatures`, one file per agreement version (`signatures/icla-.json`). Every covered repository reads that same file, so a person who accepts in one repository is covered in all of them. +- Each entry holds only the GitHub account number, login, date, the repository and pull request where they accepted, and the comment link. No names, emails or addresses are stored in any public place. +- The check in `.github/workflows/cla.yml` reads and writes that file with a token limited to that one repository. Concurrent signatures are handled with the contents API `sha` and a retry on conflict. ## Technical notes (not for counsel) - **Base check.** The check is our own short script on `actions/github-script` v9.0.0 (MIT), pinned by commit. The common tool for this, CLA Assistant Lite (`contributor-assistant/github-action`, Apache-2.0), was archived in March 2026, so it isn't used. None of its code is copied. -- **Who is checked.** Only the pull request's author. Co-authors listed in commits aren't checked yet. -- **Re-running after acceptance.** Accepting by comment records the signature in its own run. The earlier failed check on the pull request stays red until the author pushes again or a maintainer re-runs it. This should be fixed before version 1.0. -- **Running it.** The workflow runs only through `workflow_call`, and no repository calls it. Turning it on takes three steps: Anh creates the `CLA_SIGNATURES_TOKEN` org secret (a fine-grained token limited to `orbyn-dev/cla-signatures`), then each owner copies `examples/cla-caller.yml` into their own repository and adds the snippets. +- **Who is checked.** Only the pull request's author. Co-authors listed in commits aren't checked yet. Organisation members and owners, bots and the `allowed` logins need nothing (status `CLA` = success, "No agreement needed"). +- **The `CLA` status.** The workflow sets a commit status named `CLA` on the pull request's head commit, from `pull_request_target` and from `issue_comment` (created). The run triggered by `/orbynAgree` records the signature and turns the status green, so there is no stale red check and no re-push or re-run. A repository requires the status context `CLA`. +- **Security.** It runs on `pull_request_target` and `issue_comment`, and never checks out, builds or runs anything from the pull request. It reads event metadata, the author's account id, the head sha and comment bodies, and passes untrusted text only through the matcher functions, never a shell. Only a comment by the pull request's author (compared by account id), not by a bot, never edited, with the command alone on a line, counts. +- **Not covered.** `orbyn-fullstack-skills` stays MIT with a DCO and has no CLA check. +- **Not verified without a real run.** Setting a commit status from an `issue_comment` run and from a called workflow, and the contents API conflict codes, are written to GitHub's documented behaviour but have not been run against GitHub. + +## Running it + +Nothing runs until these steps are done, and the only manual ones are: + +1. Anh creates a fine-grained token with contents read and write on `orbyn-dev/cla-signatures` only, and saves it as the organisation secret `CLA_SIGNATURES_TOKEN` (available to the covered repositories). +2. Anh runs `scripts/enable-cla.sh ` for `orbyn-engine`, `orbyn-planner`, `orbyn-engineering` and `orbyn.dev`, and merges the pull request each opens. +3. Anh requires the status check `CLA` on each repository's default branch. + +The older "Licence grant" check (`licence-grant.yml`) stays in force until Anh switches over. diff --git a/examples/CONTRIBUTING-cla.md b/examples/CONTRIBUTING-cla.md index d82fb69..9afae6c 100644 --- a/examples/CONTRIBUTING-cla.md +++ b/examples/CONTRIBUTING-cla.md @@ -6,5 +6,5 @@ Before we can merge your first pull request, you need to accept the [Orbyn Indiv - **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,** reply on your pull request with the sentence the check asks for. You only do this once per version of the Agreement. +- **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 index e18d29e..5da93a4 100644 --- a/examples/PULL_REQUEST_TEMPLATE-cla.md +++ b/examples/PULL_REQUEST_TEMPLATE-cla.md @@ -1,2 +1,2 @@ -- [ ] I have accepted the [Orbyn Contributor Licence Agreement](https://github.com/orbyn-dev/.github/blob/main/CLA.md), and I've said which parts, if any, I didn't write myself +- [ ] 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 index 1d99ae1..c262b60 100644 --- a/examples/cla-caller.yml +++ b/examples/cla-caller.yml @@ -1,6 +1,8 @@ # 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, each -# repository owner copies it to .github/workflows/cla.yml in their own repository. +# 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. +# Then require the status check `CLA` on the default branch. name: Contributor licence agreement on: @@ -12,9 +14,11 @@ on: permissions: contents: read pull-requests: write + issues: write + statuses: write jobs: cla: - uses: orbyn-dev/.github/.github/workflows/cla.yml@main + uses: orbyn-dev/.github/.github/workflows/cla.yml@SHA_OF_ORBYN_DEV_GITHUB_MAIN 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..adea34a --- /dev/null +++ b/scripts/cla.test.mjs @@ -0,0 +1,163 @@ +// 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, isSigned, recordSignature, askBody, agreedBody } = new Function( + `${block}\nreturn { hasAgreeCommand, isAgreement, findAgreement, isExempt, 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", + "I agree", + "", + ]; + for (const body of no) assert.equal(hasAgreeCommand(body), false, JSON.stringify(body)); + assert.equal(hasAgreeCommand(undefined), false); + assert.equal(hasAgreeCommand(null), false); +}); + +test("a command after a closed fence still counts", () => { + assert.equal(hasAgreeCommand("```\nexample\n```\n/orbynAgree"), true); + assert.equal(hasAgreeCommand("> quoted\n/orbynAgree"), true); +}); + +test("only the pull request's author counts, by account id", () => { + assert.equal(isAgreement(comment("/orbynAgree", { id: 1 }), pull(1)), true); + assert.equal(isAgreement(comment("/orbynAgree", { id: 2 }), pull(1)), false); +}); + +test("bot comments and edited comments do not count", () => { + assert.equal(isAgreement(comment("/orbynAgree", { id: 1, type: "Bot" }), pull(1)), false); + assert.equal(isAgreement(comment("/orbynAgree", { updated: "2026-11-08T11:00:00Z" }), pull(1)), false); + assert.equal(isAgreement(undefined, pull(1)), false); +}); + +test("findAgreement picks the author's valid comment among others", () => { + const good = comment("/orbynAgree", { id: 1 }); + const list = [comment("/orbynAgree", { id: 2 }), comment("> /orbynAgree", { id: 1 }), good]; + assert.equal(findAgreement(list, pull(1)), good); + assert.equal(findAgreement(list.slice(0, 2), pull(1)), undefined); +}); + +test("organisation members, owners, bots and listed logins are exempt", () => { + const allowed = ["kinqsradio", "dependabot[bot]"]; + assert.equal(isExempt(pull(1, "a", "MEMBER"), allowed), true); + assert.equal(isExempt(pull(1, "a", "OWNER"), allowed), true); + assert.equal(isExempt(pull(1, "some-app[bot]", "NONE", "Bot"), allowed), true); + assert.equal(isExempt(pull(1, "KinqsRadio", "CONTRIBUTOR"), allowed), true); + assert.equal(isExempt(pull(1, "dependabot[bot]", "NONE"), allowed), true); +}); + +test("collaborators and first-time contributors are not exempt", () => { + const allowed = ["kinqsradio"]; + for (const association of ["COLLABORATOR", "CONTRIBUTOR", "FIRST_TIME_CONTRIBUTOR", "FIRST_TIMER", "NONE"]) { + assert.equal(isExempt(pull(1, "luannn010", association), allowed), false, association); + } +}); + +test("a signature is found by account id, not login", () => { + const list = [{ github_id: 42, login: "old-name" }]; + assert.equal(isSigned(list, 42), true); + assert.equal(isSigned(list, "42"), true); + assert.equal(isSigned(list, 43), false); + assert.equal(isSigned([], 42), false); +}); + +test("the bot comments say how to agree and, once agreed, that it covers every repository", () => { + assert.match(askBody("newcomer"), /`\/orbynAgree`/); + assert.match(askBody("newcomer"), //); + 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 = []) { + const state = { list: initial, sha: "s0", 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("recordSignature retries on a 422 create race, and stops when the other run signed the same person", async () => { + const store = fakeStore([], [conflict(422)]); + 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("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..063fbcf --- /dev/null +++ b/scripts/enable-cla.sh @@ -0,0 +1,74 @@ +#!/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)" +case "$ref" in *[!0-9a-f]*|"") echo "Not a commit sha: $ref" >&2; exit 1 ;; esac +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 + +content="$(sed "s/SHA_OF_ORBYN_DEV_GITHUB_MAIN/$ref/" "$template")" + +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." From 5e1e635081231d317e15e9fb73084d64cf8c055f Mon Sep 17 00:00:00 2001 From: Tran Duc Anh Dang Date: Sun, 8 Nov 2026 12:54:08 +1100 Subject: [PATCH 5/5] fix: required version, account-id exemptions, richer record, large-file read, hardening and docs --- .github/workflows/cla.yml | 115 +++++++++++++++++++++++++++----------- CLA.md | 6 +- README.md | 7 ++- REVIEW.md | 16 ++++-- examples/cla-caller.yml | 4 +- scripts/cla.test.mjs | 76 ++++++++++++++++++++----- scripts/enable-cla.sh | 16 +++++- 7 files changed, 183 insertions(+), 57 deletions(-) diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml index f7f8346..3244254 100644 --- a/.github/workflows/cla.yml +++ b/.github/workflows/cla.yml @@ -12,6 +12,9 @@ # 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 @@ -23,17 +26,17 @@ on: workflow_call: inputs: agreement-version: - description: Version of CLA.md being agreed to + 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 - default: "0.1" + required: true signatures-repository: description: Private repository holding signatures/icla-.json type: string default: orbyn-dev/cla-signatures allowed: - description: Comma-separated logins that never need to agree (maintainers and bots) + 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: "kinqsradio,dependabot[bot],github-actions[bot]" + default: "63905487,49699333,41898282" secrets: signatures-token: description: Fine-grained token with contents read and write on the signatures repository only @@ -52,7 +55,8 @@ jobs: 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') + 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 @@ -77,7 +81,9 @@ jobs: */ function hasAgreeCommand(body) { let fence = null; - for (const raw of String(body ?? '').replace(/\r/g, '').split('\n')) { + // HTML comments are invisible when rendered, so a command inside one does not count. + const visible = String(body ?? '').replace(/\r/g, '').replace(//g, '').replace(/", + "", + "