Skip to content

Commit 38eafc2

Browse files
committed
Add Signed-off-by rule
Signed-off-by: James M Snell <jasnell@gmail.com> Assisted-By: Opencode/Opus 4.6
1 parent 450f858 commit 38eafc2

File tree

7 files changed

+563
-9
lines changed

7 files changed

+563
-9
lines changed

lib/rules/signed-off-by.js

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
const id = 'signed-off-by'
2+
3+
// Matches the name and email from a Signed-off-by line
4+
const signoffParts = /^Signed-off-by: (.*) <([^>]+)>/i
5+
6+
// Bot/AI patterns: name ending in [bot], or GitHub bot noreply emails
7+
const botNamePattern = /\[bot\]$/i
8+
const botEmailPattern = /\[bot\]@users\.noreply\.github\.com$/i
9+
10+
// Extract email from an author string like "Name <email>"
11+
function parseEmail (authorStr) {
12+
if (!authorStr) return null
13+
const match = authorStr.match(/<([^>]+)>/)
14+
return match ? match[1].toLowerCase() : null
15+
}
16+
17+
export default {
18+
id,
19+
meta: {
20+
description: 'enforce DCO sign-off',
21+
recommended: true
22+
},
23+
defaults: {},
24+
options: {},
25+
validate: (context, rule) => {
26+
const parsed = context.toJSON()
27+
28+
// Release commits generally won't have sign-offs
29+
if (parsed.release) {
30+
context.report({
31+
id,
32+
message: 'skipping sign-off for release commit',
33+
string: '',
34+
level: 'skip'
35+
})
36+
return
37+
}
38+
39+
const signoffPattern = /^Signed-off-by: /i
40+
const validSignoff = /^Signed-off-by: .+ <[^@]+@[^@]+\.[^@]+>/i
41+
const signoffs = parsed.body
42+
.map((line, i) => [line, i])
43+
.filter(([line]) => signoffPattern.test(line))
44+
45+
if (signoffs.length === 0) {
46+
context.report({
47+
id,
48+
message: 'Commit must have a "Signed-off-by" trailer',
49+
string: '',
50+
level: 'fail'
51+
})
52+
return
53+
}
54+
55+
// Check that at least one sign-off has a valid email
56+
const hasValid = signoffs.some(([line]) => validSignoff.test(line))
57+
if (!hasValid) {
58+
const [line, lineNum] = signoffs[0]
59+
context.report({
60+
id,
61+
message: '"Signed-off-by" trailer has invalid email',
62+
string: line,
63+
line: lineNum,
64+
column: 0,
65+
level: 'fail'
66+
})
67+
return
68+
}
69+
70+
// Check that no sign-off appears to be from a bot or AI agent.
71+
// Bots and AI agents are not permitted to sign off on commits.
72+
for (const [line, lineNum] of signoffs) {
73+
const match = line.match(signoffParts)
74+
if (!match) continue
75+
const name = match[1]
76+
const email = match[2]
77+
if (botNamePattern.test(name) || botEmailPattern.test(email)) {
78+
context.report({
79+
id,
80+
message: '"Signed-off-by" must be from a human author, ' +
81+
'not a bot or AI agent',
82+
string: line,
83+
line: lineNum,
84+
column: 0,
85+
level: 'warn'
86+
})
87+
return
88+
}
89+
}
90+
91+
// When author info is available, warn if none of the sign-off emails
92+
// match the commit author email. This may indicate an automated tool
93+
// signed off on behalf of the author.
94+
const authorEmail = parseEmail(parsed.author)
95+
if (authorEmail) {
96+
const authorMatch = signoffs.some(([line]) => {
97+
const match = line.match(signoffParts)
98+
if (!match) return false
99+
return match[2].toLowerCase() === authorEmail
100+
})
101+
if (!authorMatch) {
102+
context.report({
103+
id,
104+
message: '"Signed-off-by" email does not match the ' +
105+
'commit author email',
106+
string: signoffs[0][0],
107+
line: signoffs[0][1],
108+
column: 0,
109+
level: 'warn'
110+
})
111+
return
112+
}
113+
}
114+
115+
context.report({
116+
id,
117+
message: 'has valid Signed-off-by',
118+
string: '',
119+
level: 'pass'
120+
})
121+
}
122+
}

lib/validator.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import metadataEnd from './rules/metadata-end.js'
1111
import prUrl from './rules/pr-url.js'
1212
import reviewers from './rules/reviewers.js'
1313
import subsystem from './rules/subsystem.js'
14+
import signedOffBy from './rules/signed-off-by.js'
1415
import titleFormat from './rules/title-format.js'
1516
import titleLength from './rules/title-length.js'
1617

@@ -22,6 +23,7 @@ const RULES = {
2223
'metadata-end': metadataEnd,
2324
'pr-url': prUrl,
2425
reviewers,
26+
'signed-off-by': signedOffBy,
2527
subsystem,
2628
'title-format': titleFormat,
2729
'title-length': titleLength

test/cli-test.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ test('Test cli flags', (t) => {
155155
t.test('test stdin with valid JSON', (tt) => {
156156
const validCommit = {
157157
id: '2b98d02b52',
158-
message: 'stream: make null an invalid chunk to write in object mode\n\nthis harmonizes behavior between readable, writable, and transform\nstreams so that they all handle nulls in object mode the same way by\nconsidering them invalid chunks.\n\nPR-URL: https://github.com/nodejs/node/pull/6170\nReviewed-By: James M Snell <jasnell@gmail.com>\nReviewed-By: Matteo Collina <matteo.collina@gmail.com>'
158+
message: 'stream: make null an invalid chunk to write in object mode\n\nthis harmonizes behavior between readable, writable, and transform\nstreams so that they all handle nulls in object mode the same way by\nconsidering them invalid chunks.\n\nSigned-off-by: Calvin Metcalf <cmetcalf@appgeo.com>\nPR-URL: https://github.com/nodejs/node/pull/6170\nReviewed-By: James M Snell <jasnell@gmail.com>\nReviewed-By: Matteo Collina <matteo.collina@gmail.com>'
159159
}
160160
const input = JSON.stringify([validCommit])
161161

@@ -211,11 +211,11 @@ test('Test cli flags', (t) => {
211211
const commits = [
212212
{
213213
id: 'commit1',
214-
message: 'doc: update README\n\nPR-URL: https://github.com/nodejs/node/pull/1111\nReviewed-By: Someone <someone@example.com>'
214+
message: 'doc: update README\n\nSigned-off-by: Someone <someone@example.com>\nPR-URL: https://github.com/nodejs/node/pull/1111\nReviewed-By: Someone <someone@example.com>'
215215
},
216216
{
217217
id: 'commit2',
218-
message: 'test: add new test case\n\nPR-URL: https://github.com/nodejs/node/pull/2222\nReviewed-By: Someone <someone@example.com>'
218+
message: 'test: add new test case\n\nSigned-off-by: Someone <someone@example.com>\nPR-URL: https://github.com/nodejs/node/pull/2222\nReviewed-By: Someone <someone@example.com>'
219219
}
220220
]
221221
const input = JSON.stringify(commits)
@@ -337,7 +337,7 @@ test('Test cli flags', (t) => {
337337
t.test('test stdin with --no-validate-metadata', (tt) => {
338338
const commit = {
339339
id: 'novalidate',
340-
message: 'doc: update README\n\nThis commit has no PR-URL or reviewers'
340+
message: 'doc: update README\n\nThis commit has no PR-URL or reviewers\n\nSigned-off-by: Someone <someone@example.com>'
341341
}
342342
const input = JSON.stringify([commit])
343343

test/fixtures/commit.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
"sha": "d3f20ccfaa7b0919a7c5a472e344b7de8829b30c",
1717
"url": "https://api.github.com/repos/nodejs/node/git/trees/d3f20ccfaa7b0919a7c5a472e344b7de8829b30c"
1818
},
19-
"message": "stream: make null an invalid chunk to write in object mode\n\nthis harmonizes behavior between readable, writable, and transform\nstreams so that they all handle nulls in object mode the same way by\nconsidering them invalid chunks.\n\nPR-URL: https://github.com/nodejs/node/pull/6170\nReviewed-By: James M Snell <jasnell@gmail.com>\nReviewed-By: Matteo Collina <matteo.collina@gmail.com>",
19+
"message": "stream: make null an invalid chunk to write in object mode\n\nthis harmonizes behavior between readable, writable, and transform\nstreams so that they all handle nulls in object mode the same way by\nconsidering them invalid chunks.\n\nSigned-off-by: Calvin Metcalf <cmetcalf@appgeo.com>\nPR-URL: https://github.com/nodejs/node/pull/6170\nReviewed-By: James M Snell <jasnell@gmail.com>\nReviewed-By: Matteo Collina <matteo.collina@gmail.com>",
2020
"parents": [
2121
{
2222
"sha": "ec2822adaad76b126b5cccdeaa1addf2376c9aa6",

test/fixtures/pr.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
"email": "cmetcalf@appgeo.com",
1313
"date": "2016-04-13T16:33:55Z"
1414
},
15-
"message": "stream: make null an invalid chunk to write in object mode\n\nthis harmonizes behavior between readable, writable, and transform\nstreams so that they all handle nulls in object mode the same way by\nconsidering them invalid chunks.\n\nPR-URL: https://github.com/nodejs/node/pull/6170\nReviewed-By: James M Snell <jasnell@gmail.com>\nReviewed-By: Matteo Collina <matteo.collina@gmail.com>",
15+
"message": "stream: make null an invalid chunk to write in object mode\n\nthis harmonizes behavior between readable, writable, and transform\nstreams so that they all handle nulls in object mode the same way by\nconsidering them invalid chunks.\n\nSigned-off-by: Calvin Metcalf <cmetcalf@appgeo.com>\nPR-URL: https://github.com/nodejs/node/pull/6170\nReviewed-By: James M Snell <jasnell@gmail.com>\nReviewed-By: Matteo Collina <matteo.collina@gmail.com>",
1616
"tree": {
1717
"sha": "e4f9381fdd77d1fd38fe27a80dc43486ac732d48",
1818
"url": "https://api.github.com/repos/nodejs/node/git/trees/e4f9381fdd77d1fd38fe27a80dc43486ac732d48"

0 commit comments

Comments
 (0)