Skip to content

Commit f5cb30f

Browse files
authored
Add reusable PR contributor terms workflow with team exemption (#1)
* Add reusable PR contributor terms workflow with team exemption * Extract PR contributor terms verification logic into separate JS file * Add unit tests and CI test workflow using node:test
1 parent f972223 commit f5cb30f

4 files changed

Lines changed: 407 additions & 0 deletions

File tree

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
name: 'PR Contributor Terms (Reusable)'
2+
3+
on:
4+
workflow_call:
5+
inputs:
6+
exempt_associations:
7+
description: 'Comma-separated list of author associations to exempt from contributor agreement verification'
8+
required: false
9+
type: string
10+
default: 'OWNER,MEMBER,COLLABORATOR'
11+
exempt_users:
12+
description: 'Comma-separated list of GitHub usernames/bots to exempt'
13+
required: false
14+
type: string
15+
default: ''
16+
required_terms:
17+
description: 'Newline-separated list of required contributor agreement terms (leave empty for default Keras terms)'
18+
required: false
19+
type: string
20+
default: ''
21+
22+
permissions:
23+
contents: read
24+
pull-requests: read
25+
26+
jobs:
27+
verify:
28+
runs-on: ubuntu-latest
29+
steps:
30+
- name: 'Check out shared workflows repo'
31+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
32+
with:
33+
repository: 'keras-team/shared-workflows'
34+
persist-credentials: false
35+
path: '.shared-workflows'
36+
37+
- name: 'Verify contributor agreement'
38+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
39+
env:
40+
EXEMPT_ASSOCIATIONS: '${{ inputs.exempt_associations }}'
41+
EXEMPT_USERS: '${{ inputs.exempt_users }}'
42+
REQUIRED_TERMS: '${{ inputs.required_terms }}'
43+
with:
44+
github-token: '${{ secrets.GITHUB_TOKEN }}'
45+
script: |
46+
const verifyTerms = require('./.shared-workflows/scripts/verify-pr-contributor-terms.js');
47+
await verifyTerms({ github, context, core });

.github/workflows/test.yml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
name: 'Tests'
2+
3+
on:
4+
push:
5+
branches:
6+
- 'main'
7+
pull_request:
8+
branches:
9+
- 'main'
10+
11+
permissions:
12+
contents: 'read'
13+
14+
jobs:
15+
unit-tests:
16+
runs-on: 'ubuntu-latest'
17+
steps:
18+
- name: 'Check out repository'
19+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
20+
with:
21+
persist-credentials: false
22+
23+
- name: 'Run unit tests'
24+
run: node --test 'scripts/**/*.test.js'
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/**
2+
* @license
3+
* Copyright 2026 The Keras Authors. All Rights Reserved.
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
* =============================================================================
17+
*/
18+
19+
const DEFAULT_TERMS = [
20+
'I am a human, and not a bot.',
21+
'I will be responsible for responding to review comments in a timely manner.',
22+
'I will work with the maintainers to push this PR forward until submission.'
23+
];
24+
25+
/**
26+
* Verifies that the PR contributor agreement has been checked by external contributors,
27+
* while exempting repository maintainers, organization members, and bots.
28+
*
29+
* @param {!object} params
30+
* @param {!object} params.github - GitHub octokit client.
31+
* @param {!object} params.context - GitHub actions context.
32+
* @param {!object} params.core - Actions core library for logging and failures.
33+
*/
34+
module.exports = async function verifyPrContributorTerms({ github, context, core }) {
35+
const prNumber = context.payload.pull_request
36+
? context.payload.pull_request.number
37+
: (context.payload.issue ? context.payload.issue.number : context.issue?.number);
38+
39+
const { data: pr } = await github.rest.pulls.get({
40+
owner: context.repo.owner,
41+
repo: context.repo.repo,
42+
pull_number: prNumber,
43+
});
44+
45+
const authorAssociation = (pr.author_association || '').toUpperCase();
46+
const authorLogin = (pr.user && pr.user.login ? pr.user.login : '').toLowerCase();
47+
const authorType = pr.user && pr.user.type ? pr.user.type : '';
48+
49+
core.info(`PR #${prNumber} author: ${authorLogin} (type: ${authorType}, association: ${authorAssociation})`);
50+
51+
// Check if author is an exempt user or bot.
52+
const rawExemptUsers = process.env.EXEMPT_USERS || '';
53+
const exemptUsers = rawExemptUsers
54+
.split(',')
55+
.map(s => s.trim().toLowerCase())
56+
.filter(Boolean);
57+
58+
if (exemptUsers.includes(authorLogin) || authorType === 'Bot' || authorLogin.endsWith('[bot]')) {
59+
core.info(`PR author '${authorLogin}' is exempt from contributor terms check.`);
60+
return;
61+
}
62+
63+
// Check if author association is exempt.
64+
const rawExemptAssociations = process.env.EXEMPT_ASSOCIATIONS || 'OWNER,MEMBER,COLLABORATOR';
65+
const exemptAssociations = rawExemptAssociations
66+
.split(',')
67+
.map(s => s.trim().toUpperCase())
68+
.filter(Boolean);
69+
70+
if (exemptAssociations.includes(authorAssociation)) {
71+
core.info(`PR author association '${authorAssociation}' is exempt from contributor terms check.`);
72+
return;
73+
}
74+
75+
// Determine required terms.
76+
const rawTerms = process.env.REQUIRED_TERMS || '';
77+
const customTerms = rawTerms
78+
.split('\n')
79+
.map(s => s.trim())
80+
.filter(Boolean);
81+
82+
const requiredTerms = customTerms.length > 0 ? customTerms : DEFAULT_TERMS;
83+
const body = pr.body || '';
84+
85+
const unchecked = [];
86+
for (const term of requiredTerms) {
87+
// Check that the checkbox is checked: [x] or [X] with bullet list prefix (- or *).
88+
const escapedTerm = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
89+
const checkedPattern = new RegExp(`[-*]\\s*\\[\\s*[xX]\\s*\\]\\s*${escapedTerm}`);
90+
if (!checkedPattern.test(body)) {
91+
unchecked.push(term);
92+
}
93+
}
94+
95+
if (unchecked.length > 0) {
96+
core.setFailed(
97+
`The following contributor agreement terms have not been accepted:\n` +
98+
unchecked.map(t => ` - ${t}`).join('\n') +
99+
`\n\nPlease check all boxes in the Contributor Agreement section of the PR description.`
100+
);
101+
} else {
102+
core.info('All contributor agreement terms accepted.');
103+
}
104+
};

0 commit comments

Comments
 (0)