Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions lefthook.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
# Equivalent of the aaif-goose/goose Husky hooks, using lefthook.
# See: https://github.com/evilmartians/lefthook

commit-msg:
commands:
dco-signoff:
run: node ./scripts/validate-dco-signoff.mjs "{1}"

pre-commit:
commands:
biome-format:
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
"format": "biome format --write .",
"preview": "vite preview",
"tauri": "tauri",
"test": "vitest run",
"test": "vitest run && node --test scripts/dco-signoff.test.mjs",
"test:release-scripts": "vitest run --config vitest.release-scripts.config.ts",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
Expand Down
62 changes: 62 additions & 0 deletions scripts/dco-signoff.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import assert from "node:assert/strict";
import { execFileSync, spawnSync } from "node:child_process";
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import test from "node:test";

const validator = fileURLToPath(
new URL("./validate-dco-signoff.mjs", import.meta.url),
);

function run(
message,
author = { name: "Ada Lovelace", email: "ada@example.com" },
) {
const repo = mkdtempSync(join(tmpdir(), "berd-dco-"));
execFileSync("git", ["init", "--quiet", repo]);
execFileSync("git", ["-C", repo, "config", "user.name", author.name]);
execFileSync("git", ["-C", repo, "config", "user.email", author.email]);
const messagePath = join(repo, "COMMIT_EDITMSG");
writeFileSync(messagePath, message);
return spawnSync("node", [validator, messagePath], {
cwd: repo,
encoding: "utf8",
env: {
...process.env,
GIT_AUTHOR_NAME: author.name,
GIT_AUTHOR_EMAIL: author.email,
},
});
}

test("accepts an author-matching sign-off", () => {
const result = run(
"Subject\n\nSigned-off-by: Ada Lovelace <ada@example.com>\n",
);
assert.equal(result.status, 0, result.stderr);
});

test("rejects a missing sign-off with remediation", () => {
const result = run("Subject\n");
assert.equal(result.status, 1);
assert.match(result.stderr, /Signed-off-by: Ada Lovelace <ada@example\.com>/);
assert.match(result.stderr, /git commit --signoff/);
});

test("rejects a sign-off from someone other than the author", () => {
const result = run(
"Subject\n\nSigned-off-by: Grace Hopper <grace@example.com>\n",
);
assert.equal(result.status, 1);
assert.match(result.stderr, /Signed-off-by: Ada Lovelace <ada@example\.com>/);
});

test("honors the effective author identity", () => {
const result = run(
"Subject\n\nSigned-off-by: Zoë Agent <zoe@example.com>\n",
{ name: "Zoë Agent", email: "zoe@example.com" },
);
assert.equal(result.status, 0, result.stderr);
});
46 changes: 46 additions & 0 deletions scripts/validate-dco-signoff.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
#!/usr/bin/env node

import { execFileSync } from "node:child_process";

function git(...args) {
return execFileSync("git", args, { encoding: "utf8" }).trim();
}

function authorIdentity() {
const ident = git("var", "GIT_AUTHOR_IDENT");
const match = ident.match(/^(.* <[^<>]+>) \d+ [+-]\d{4}$/);
if (!match) {
throw new Error(`could not parse Git author identity: ${ident}`);
}
return match[1];
}

function signedOffByIdentities(messagePath) {
const trailers = git("interpret-trailers", "--parse", "--", messagePath);
return trailers
.split("\n")
.map((line) => line.match(/^Signed-off-by:\s*(.+)$/i)?.[1].trim())
.filter(Boolean);
}

const messagePath = process.argv[2];
if (!messagePath) {
console.error("usage: validate-dco-signoff.mjs <commit-message-file>");
process.exit(2);
}

try {
const author = authorIdentity();
if (!signedOffByIdentities(messagePath).includes(author)) {
console.error(
`DCO sign-off required: add exactly "Signed-off-by: ${author}".`,
);
console.error(
"Retry the commit with `git commit --signoff` or add that trailer in your commit editor.",
);
process.exit(1);
}
} catch (error) {
console.error(`Unable to validate DCO sign-off: ${error.message}`);
process.exit(1);
}
Loading