-
Notifications
You must be signed in to change notification settings - Fork 2
181 lines (154 loc) · 6.96 KB
/
Copy pathdco.yml
File metadata and controls
181 lines (154 loc) · 6.96 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
name: DCO
on:
pull_request_target:
types: [opened, reopened, synchronize]
pull_request_review:
types: [submitted]
issue_comment:
types: [created]
permissions:
contents: read
pull-requests: write
statuses: write
jobs:
dco:
runs-on: ubuntu-latest
if: |
github.event_name == 'pull_request_target' ||
(github.event_name == 'pull_request_review' && github.event.review.state == 'approved') ||
(github.event_name == 'issue_comment' && github.event.issue.pull_request &&
(contains(github.event.comment.body, 'I have read the Developer Certificate of Origin') ||
github.event.comment.body == 'recheck'))
steps:
- uses: actions/github-script@v7
with:
script: |
const SIGN_OFF_TEXT = 'I have read the Developer Certificate of Origin and I hereby sign the DCO for this PR';
const ALLOWLIST = new Set(['dependabot[bot]', 'github-actions[bot]', 'claude-code[bot]']);
const DCO_URL = 'https://developercertificate.org/';
const MARKER = '<!-- dco-sign-off-request -->';
// --- Determine PR number ---
const prNumber = context.payload.pull_request?.number || context.payload.issue?.number;
if (!prNumber) {
console.log('No PR context found');
return;
}
const owner = context.repo.owner;
const repo = context.repo.repo;
// --- Get PR details ---
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
const headSha = pr.head.sha;
async function setStatus(state, description) {
await github.rest.repos.createCommitStatus({
owner, repo, sha: headSha, state,
context: 'DCO',
description: description.slice(0, 140),
target_url: DCO_URL,
});
}
// --- Get latest approval ---
const { data: reviews } = await github.rest.pulls.listReviews({ owner, repo, pull_number: prNumber });
const approvals = reviews
.filter(r => r.state === 'APPROVED')
.sort((a, b) => new Date(a.submitted_at) - new Date(b.submitted_at));
const latestApproval = approvals.at(-1);
if (!latestApproval) {
await setStatus('pending', 'Awaiting PR approval before DCO sign-off');
return;
}
// --- Check for new commits since approval ---
if (headSha !== latestApproval.commit_id) {
await setStatus('pending', 'New commits since approval — awaiting re-approval');
return;
}
const approvalTime = new Date(latestApproval.submitted_at);
// --- Get all commit authors ---
const commits = await github.paginate(github.rest.pulls.listCommits, {
owner, repo, pull_number: prNumber,
});
const authors = [...new Set(
commits.map(c => c.author?.login).filter(Boolean)
)].filter(a => !ALLOWLIST.has(a));
if (authors.length === 0) {
await setStatus('success', 'All contributors are in the allowlist');
return;
}
// --- Check sign-off comments posted after approval ---
const comments = await github.paginate(github.rest.issues.listComments, {
owner, repo, issue_number: prNumber,
});
const signedAuthors = new Set(
comments
.filter(c => new Date(c.created_at) > approvalTime)
.filter(c => c.body.includes(SIGN_OFF_TEXT))
.map(c => c.user.login)
);
const signed = authors.filter(a => signedAuthors.has(a));
const unsigned = authors.filter(a => !signedAuthors.has(a));
// --- Build the DCO status comment body ---
const allSigned = unsigned.length === 0;
const signedList = signed.map(a => `- :white_check_mark: @${a}`).join('\n') || '_none yet_';
const unsignedList = unsigned.map(a => `- :x: @${a}`).join('\n');
const commentBody = [
MARKER,
'## Developer Certificate of Origin',
'',
allSigned
? ':white_check_mark: **All contributors have signed the DCO. This PR is ready to merge.**'
: 'This PR has been approved. Before it can be merged, all contributors must sign the [Developer Certificate of Origin](https://developercertificate.org/).',
'',
'### Status',
signedList,
...(unsignedList ? [unsignedList] : []),
'',
...(!allSigned ? [
'### How to sign',
'Post the following comment **exactly as written**:',
'',
`> ${SIGN_OFF_TEXT}`,
] : []),
].join('\n');
// --- Find existing bot comment or create new one ---
const existingComment = comments.find(c =>
c.body.includes(MARKER) && new Date(c.created_at) > approvalTime
);
if (existingComment) {
await github.rest.issues.updateComment({
owner, repo, comment_id: existingComment.id,
body: commentBody,
});
} else {
await github.rest.issues.createComment({
owner, repo, issue_number: prNumber,
body: commentBody,
});
}
// --- All signed: append Signed-off-by trailers to PR body ---
if (allSigned) {
const TRAILER_MARKER = '<!-- dco-signed-off-by -->';
const trailerLines = [];
for (const author of signed) {
const commit = commits.find(c => c.author?.login === author);
const name = commit?.commit?.author?.name || author;
const email = commit?.commit?.author?.email || `${author}@users.noreply.github.com`;
trailerLines.push(`Signed-off-by: ${name} <${email}>`);
}
const trailerBlock = `${TRAILER_MARKER}\n${trailerLines.join('\n')}`;
const currentBody = pr.body || '';
let newBody;
if (currentBody.includes(TRAILER_MARKER)) {
newBody = currentBody.replace(
new RegExp(`${TRAILER_MARKER}[\\s\\S]*$`),
trailerBlock,
);
} else {
newBody = `${currentBody}\n\n${trailerBlock}`;
}
await github.rest.pulls.update({
owner, repo, pull_number: prNumber,
body: newBody,
});
await setStatus('success', 'All contributors have signed the DCO');
return;
}
await setStatus('pending', `Awaiting DCO sign-off from: ${unsigned.join(', ')}`);