Repository navigation
284 lines (254 loc) · 13.8 KB
/
Copy pathcommit-requirements.yaml
File metadata and controls
284 lines (254 loc) · 13.8 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
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
name: Commit Requirements
# Every commit must carry a DCO sign-off (`git commit -s`) and a verified
# cryptographic signature (`git commit -S`), both documented in CONTRIBUTING.md.
# Neither was actually checked before this workflow: `.github/dco.yml` only
# configures the probot DCO app, which is not reporting on this repository, and
# the `required_signatures` rule on `main` is satisfied by the signature GitHub
# puts on the squash/merge commit it creates, not by the contributor's commits.
#
# This job reports; it does not gate. It is not a required check, so a red run
# here is visible without wedging the merge button. Promote it by adding
# `Sign-off and signature` to the main ruleset's required checks, as commit
# linting is, once the open-PR backlog is clean. It cannot join merge-gate.yaml
# instead: that runs on pull_request, and this needs pull_request_target.
#
# pull_request_target is required to comment on pull requests from forks, which
# is where the violations come from. It is safe here for the usual reason: the
# job never checks out or executes the contributor's code, it only reads commit
# metadata through the API.
on:
pull_request_target:
types: [opened, synchronize, reopened, ready_for_review]
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
commit-requirements:
name: Sign-off and signature
runs-on: ubuntu-latest
permissions:
pull-requests: write
timeout-minutes: 5
steps:
- name: Check commits and report
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const MARKER = '<!-- commit-requirements -->';
const CONTRIBUTING = `https://github.com/${context.repo.owner}/${context.repo.repo}/blob/main/CONTRIBUTING.md#developer-certificate-of-origin-and-commit-signing`;
const { owner, repo } = context.repo;
const number = context.payload.pull_request.number;
// The commits endpoint tops out here. Reporting success on a
// truncated list would clear a pull request nobody fully checked.
const LIST_COMMITS_CAP = 250;
// Read the pull request on both sides of the commit list, and give up
// if the head moved in between. `context.payload.pull_request` is a
// snapshot from when the event fired and a run can sit in the queue
// for hours, so nothing here may be paired with it: a force-push
// during the read window would otherwise judge one head's commits
// against another head's commit count. The push that moved the head
// queued its own run, which sees a stable head and reports for it.
const headBefore = (
await github.rest.pulls.get({ owner, repo, pull_number: number })
).data.head.sha;
const commits = await github.paginate(github.rest.pulls.listCommits, {
owner, repo, pull_number: number, per_page: 100,
});
const { data: pull } = await github.rest.pulls.get({
owner, repo, pull_number: number,
});
if (pull.head.sha !== headBefore) {
core.info(
`Head moved while the commits were being read (${headBefore} to ${pull.head.sha}). A later run covers the current head.`,
);
return;
}
const expectedCommits = pull.commits;
const shortfall =
typeof expectedCommits === 'number' && commits.length < expectedCommits;
const truncated = shortfall && commits.length >= LIST_COMMITS_CAP;
// Short of the cap the endpoint hands back every commit the head has,
// so a shortfall there is the pull request's own `commits` count
// lagging the ref, not commits withheld from the list. Note it and
// check what we were given: the list is complete for this head, and
// returning early here would pass a violation through unreported.
if (shortfall && !truncated) {
core.info(
`Pull request reports ${expectedCommits} commits but the endpoint listed ${commits.length}, short of the ${LIST_COMMITS_CAP} cap. Treating the count as stale and checking the commits the endpoint returned.`,
);
}
// Bots cannot run `git commit -s -S`; Renovate and Dependabot commits
// are signed by GitHub's own key and carry no sign-off by design.
//
// Keyed on the GitHub identity the API resolved, never on `commit.author`.
// The latter is git metadata the committer sets freely, so matching a
// `[bot]` name or noreply address there would let anyone skip both checks
// with a one-line `git config`. Dependabot and the github-actions app that
// runs Renovate here both resolve to `author.type === 'Bot'`.
const isBot = (c) => c.author?.type === 'Bot';
// Merge commits are exempt from sign-off, not from signing. GitHub's
// "Update branch" and merge buttons author them with no `Signed-off-by`
// and give the clicker no way to add one, and a merge introduces no new
// authorship for the DCO to certify. They are still GitHub-signed, so
// the signature requirement below still applies to them.
const isMerge = (c) => (c.parents?.length ?? 0) > 1;
// Only the trailer block counts, matching `git interpret-trailers`:
// the last paragraph, and never a single-paragraph message. Scanning
// every line would accept a `Signed-off-by:` quoted in prose while git
// itself sees no trailer at all.
//
// Presence only, deliberately not matched against the commit author's
// email. GitHub hands out `NNN+user@users.noreply.github.com` aliases,
// so a contributor who signs off with their real address trips an
// author/trailer mismatch through no fault of their own. Flagging that
// is noise, and a check people learn to ignore enforces nothing.
const hasSignOff = (message) => {
const paragraphs = (message ?? '')
.replace(/\r/g, '')
.split(/\n[ \t]*\n/)
.filter((p) => p.trim().length > 0);
if (paragraphs.length < 2) return false;
// Anchored at column 0 and the paragraph is not trimmed: git does not
// treat an indented line as a trailer, so neither may this.
return paragraphs[paragraphs.length - 1]
.split('\n')
.some((line) => /^Signed-off-by:\s*.+<.+>\s*$/i.test(line));
};
const checked = commits.filter((c) => !isBot(c));
const violations = [];
for (const c of checked) {
const problems = [];
if (!isMerge(c) && !hasSignOff(c.commit.message)) {
problems.push('no `Signed-off-by` trailer (`git commit -s`)');
}
if (c.commit.verification?.verified !== true) {
const reason = c.commit.verification?.reason ?? 'unknown';
problems.push(`signature not verified: \`${reason}\` (\`git commit -S\`)`);
}
if (problems.length > 0) {
violations.push({ sha: c.sha, subject: c.commit.message.split('\n')[0], problems });
}
}
// Match the author as well as the marker. MARKER is an HTML comment, so
// anyone can paste it into a comment of their own; keying on the body
// alone would make a later run edit that comment instead of this
// workflow's, and the job holds `pull-requests: write`.
const existing = (
await github.paginate(github.rest.issues.listComments, {
owner, repo, issue_number: number, per_page: 100,
})
).find(
(comment) =>
comment.user?.login === 'github-actions[bot]' && comment.body?.includes(MARKER),
);
const upsert = async (body) => {
if (existing) {
await github.rest.issues.updateComment({
owner, repo, comment_id: existing.id, body: `${MARKER}\n${body}`,
});
} else {
await github.rest.issues.createComment({
owner, repo, issue_number: number, body: `${MARKER}\n${body}`,
});
}
};
if (truncated) {
const body = [
'### ⚠️ Could not check every commit',
'',
`GitHub returned ${commits.length} of this pull request's ${expectedCommits} commits, so the sign-off and signature check is incomplete and is not reporting a pass.`,
'',
`The pull request commits endpoint returns at most ${LIST_COMMITS_CAP} commits. Splitting this into smaller pull requests, or rebasing to reduce the commit count, lets the check cover everything.`,
'',
`The requirements themselves are unchanged and are described in [CONTRIBUTING.md](${CONTRIBUTING}).`,
].join('\n');
await upsert(body);
core.setFailed(
`Commit list truncated: got ${commits.length} of ${expectedCommits} commits.`,
);
return;
}
if (violations.length === 0) {
core.info(`All ${checked.length} non-bot commit(s) are signed off and signed.`);
if (existing) {
await upsert('✅ Every non-bot commit on this pull request is now signed off and signed. Thanks!');
}
return;
}
// The head ref is attacker-chosen and lands in bash blocks a maintainer
// is invited to copy-paste. `git check-ref-format` permits `;`, `$(...)`,
// backticks, `&&` and `|` in a branch name, so this must be quoted.
const shellQuote = (v) => "'" + String(v).replace(/'/g, "'\\''") + "'";
const headRef = shellQuote(context.payload.pull_request.head.ref);
const rows = violations
.map((v) => `| \`${v.sha.slice(0, 8)}\` | ${v.subject} | ${v.problems.join('<br>')} |`)
.join('\n');
await upsert([
'### ❌ Some commits are missing a sign-off or a signature',
'',
'Every commit in this repository must be **signed off** (`-s`, the [DCO](https://developercertificate.org/) certification that you wrote the patch) **and cryptographically signed** (`-S`, proving the commit came from you). They are independent; you need both, on every commit.',
'',
'| Commit | Subject | Problem |',
'| --- | --- | --- |',
rows,
'',
'<details>',
'<summary>How to fix</summary>',
'',
'One-time setup, so you only ever need `-s` from here on:',
'',
'```bash',
'# Commit identity, used in the Signed-off-by trailer.',
'# Use an address GitHub has verified on your account.',
'git config user.name "Your Name"',
'git config user.email "your.email@example.com"',
'',
'# Signing key, separate from the identity above. For SSH:',
'git config gpg.format ssh',
'git config user.signingkey ~/.ssh/id_ed25519.pub',
'',
'# Sign every commit from now on',
'git config commit.gpgsign true',
'```',
'',
'The key must also be registered with GitHub: see [generating a GPG or SSH signing key](https://docs.github.com/en/authentication/managing-commit-signature-verification).',
'',
'To fix the most recent commit:',
'',
'```bash',
'git commit --amend -s -S --no-edit',
'git push --force-with-lease origin ' + headRef,
'```',
'',
'To fix every commit on the branch at once. Check for merge commits first, because a plain rebase drops them:',
'',
'```bash',
'git log --oneline --merges origin/main..HEAD # empty output means linear',
'',
'# Linear branch:',
"git rebase --exec 'git commit --amend -s -S --no-edit' origin/main",
'',
'# Branch with merge commits, preserving them:',
"git rebase --rebase-merges --exec 'git commit --amend -s -S --no-edit' origin/main",
'```',
'',
'Confirm the rewrite changed nothing but the signatures before pushing:',
'',
'```bash',
'git range-diff @{u}...HEAD',
'git push --force-with-lease origin ' + headRef,
'```',
'',
'Re-signing rewrites every commit, which outdates inline review comments. If the PR is already under review, leave a note saying you force-pushed.',
'',
'</details>',
'',
`Full details are in [CONTRIBUTING.md](${CONTRIBUTING}). This check reports but does not block your merge; a maintainer will still ask you to fix it.`,
].join('\n'));
core.setFailed(
`${violations.length} of ${checked.length} non-bot commit(s) are missing a sign-off or a verified signature.`,
);