diff --git a/lefthook.yml b/lefthook.yml index 272ce8636..c7d8a3b42 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -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: diff --git a/package.json b/package.json index effad5dd2..411192d3e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/dco-signoff.test.mjs b/scripts/dco-signoff.test.mjs new file mode 100644 index 000000000..03bf0b3ba --- /dev/null +++ b/scripts/dco-signoff.test.mjs @@ -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 \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 /); + 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 \n", + ); + assert.equal(result.status, 1); + assert.match(result.stderr, /Signed-off-by: Ada Lovelace /); +}); + +test("honors the effective author identity", () => { + const result = run( + "Subject\n\nSigned-off-by: Zoë Agent \n", + { name: "Zoë Agent", email: "zoe@example.com" }, + ); + assert.equal(result.status, 0, result.stderr); +}); diff --git a/scripts/validate-dco-signoff.mjs b/scripts/validate-dco-signoff.mjs new file mode 100755 index 000000000..c26a6ba60 --- /dev/null +++ b/scripts/validate-dco-signoff.mjs @@ -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 "); + 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); +}