Skip to content
Merged
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
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,16 @@ jobs:
with:
node-version: 24

- name: Enforce commit and pull request title conventions
if: github.event_name == 'pull_request'
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_TITLE: ${{ github.event.pull_request.title }}
run: |
node scripts/validate-commit-message.mjs --range "$BASE_SHA" "$HEAD_SHA"
node scripts/validate-commit-message.mjs --title "$PR_TITLE"

- name: Check changed files for whitespace errors
shell: bash
env:
Expand Down
11 changes: 11 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,17 @@
- Sprint and routine development pull requests target `v2.1`, not `main`.
- After a successful merge, delete only the merged short-lived branch when cleanup is authorized.

## Commit messages

- Use exactly `<Gitmoji>[<Action>] <imperative subject>` for every human-authored commit. There is no space before `[Action]`, square brackets are mandatory, and exactly one space follows `]`.
- Use one fixed pair: `✨[Feat]`, `➕[Add]`, `🚀[Deploy]`, `✅[Test]`, `📈[Data]`, `🐛[Fix]`, `♻️[Refactor]`, `🔧[Config]`, `🚨[Hotfix]`, `⚙️[Chore]`, `🎉[Init]`, `📄[Docs]`, `🎀[Style]`, or `🚚[Rename]`.
- Write a concise imperative subject for one logical change. Split unrelated changes into separate commits.
- Plain Conventional Commit prefixes such as `feat:`, mismatched pairs such as `🐛[Feat]`, and spaced forms such as `✨ [Feat]` are prohibited.
- Good: `✨[Feat] Add Image to Text OCR`, `✅[Test] Cover OCR cancellation`, `📄[Docs] Document release workflow`.
- Bad: `feat: add OCR`, `✨ [Feat] Add OCR`, `✨[Fix] Add OCR`.
- Inspect recent conforming history if uncertain. Do not create a commit until its message satisfies this convention.
- Commit creation does not authorize merging; the merge-authority policy below still applies.

## Merge authority

- Creating a pull request and merging it are separate operations.
Expand Down
8 changes: 8 additions & 0 deletions docs/development-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ feat/* fix/* test/* chore/*
5. Treat review and merge as a separate step. An agent does not merge its own pull request or enable auto-merge unless the user explicitly authorizes that specific action.
6. After a successful merge and verification, remove the merged short-lived branch when branch cleanup is authorized.

## Commit convention

Human-authored commits use `<Gitmoji>[<Action>] <imperative subject>`. The prefix must be one fixed canonical pair: `✨[Feat]`, `➕[Add]`, `🚀[Deploy]`, `✅[Test]`, `📈[Data]`, `🐛[Fix]`, `♻️[Refactor]`, `🔧[Config]`, `🚨[Hotfix]`, `⚙️[Chore]`, `🎉[Init]`, `📄[Docs]`, `🎀[Style]`, or `🚚[Rename]`.

There is no space between the Gitmoji and `[Action]`; exactly one space separates the closing bracket from a non-empty, concise imperative subject. Each commit represents one logical change. For example, `✨[Feat] Add Image to Text OCR` and `✅[Test] Cover OCR cancellation` are valid; `feat: add OCR`, `✨ [Feat] Add OCR`, and `✨[Fix] Add OCR` are invalid.

CI validates non-merge commits introduced by the pull request’s actual base-to-head range and validates the pull-request title with the same structural rule. Technical merge commits are excluded by their multiple-parent topology so normal merge commits remain supported. Published non-conforming history is retained and never rewritten solely for message compliance.

## Production release

After the v2.1.0 scope is integrated, complete release hardening and final verification on `v2.1`. Promote it through a dedicated `v2.1` → `main` pull request. Only after that pull request is explicitly reviewed and merged may a separately authorized task create the v2.1.0 tag and release.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"build": "node scripts/prepare-ocr-assets.mjs --check",
"prepare:ocr": "node scripts/prepare-ocr-assets.mjs",
"smoke:ocr:browser": "node tests/serve-ocr-smoke.mjs",
"test:commit-messages": "node tests/commit-message.test.mjs",
"test": "node tests/run-all.mjs"
},
"dependencies": {
Expand Down
91 changes: 91 additions & 0 deletions scripts/validate-commit-message.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";

export const CANONICAL_PAIRS = Object.freeze([
"✨[Feat]",
"➕[Add]",
"🚀[Deploy]",
"✅[Test]",
"📈[Data]",
"🐛[Fix]",
"♻️[Refactor]",
"🔧[Config]",
"🚨[Hotfix]",
"⚙️[Chore]",
"🎉[Init]",
"📄[Docs]",
"🎀[Style]",
"🚚[Rename]",
]);

const conventionalSubject = /^(?:build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(?:\([^\r\n)]+\))?!?:\s/i;
const escapedPairs = CANONICAL_PAIRS.map((pair) => pair.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
const commitPattern = new RegExp(`^(?:${escapedPairs.join("|")}) ([^\\s].*)$`, "u");

export function validateCommitMessage(message) {
if (typeof message !== "string" || message !== message.trim() || /[\r\n]/.test(message) || !commitPattern.test(message)) {
return { valid: false, reason: "message must use one exact canonical Gitmoji/action pair followed by one space and a non-empty subject" };
}

const subject = message.slice(message.indexOf("]") + 2);
if (conventionalSubject.test(subject)) {
return { valid: false, reason: "subject must not repeat Conventional Commit syntax after the action tag" };
}
return { valid: true, reason: null };
}

function git(argumentsList, cwd) {
return execFileSync("git", argumentsList, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
}

export function nonMergeCommits(base, head, cwd = process.cwd()) {
const output = git(["rev-list", "--reverse", "--no-merges", `${base}..${head}`], cwd);
return output ? output.split(/\r?\n/) : [];
}

export function validateCommitRange(base, head, cwd = process.cwd()) {
return nonMergeCommits(base, head, cwd).map((sha) => {
const subject = git(["show", "-s", "--format=%s", sha], cwd);
return { sha, subject, ...validateCommitMessage(subject) };
});
}

function failureText(label, subject, reason) {
return [
`Invalid ${label}: ${subject || "<empty>"}`,
`Reason: ${reason}`,
"Expected: <Gitmoji>[<Action>] <imperative subject>",
"Examples: ✨[Feat] Add Image to Text OCR | ✅[Test] Cover OCR cancellation | 📄[Docs] Document release workflow",
`Canonical pairs: ${CANONICAL_PAIRS.join(", ")}`,
].join("\n");
}

export function run(argumentsList = process.argv.slice(2), cwd = process.cwd()) {
const [mode, ...values] = argumentsList;
if ((mode === "--message" || mode === "--title") && values.length === 1) {
const result = validateCommitMessage(values[0]);
if (!result.valid) throw new Error(failureText(mode === "--title" ? "pull request title" : "commit message", values[0], result.reason));
console.log(`${mode === "--title" ? "Pull request title" : "Commit message"} follows the Secure Tools convention.`);
return;
}

if (mode === "--range" && values.length === 2) {
const results = validateCommitRange(values[0], values[1], cwd);
const failures = results.filter((result) => !result.valid);
if (failures.length) {
throw new Error(failures.map((failure) => failureText(`commit ${failure.sha}`, failure.subject, failure.reason)).join("\n\n"));
}
console.log(`Validated ${results.length} non-merge commit${results.length === 1 ? "" : "s"} in ${values[0]}..${values[1]}.`);
return;
}

throw new Error("Usage: node scripts/validate-commit-message.mjs --message <subject> | --title <title> | --range <base-sha> <head-sha>");
}

if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
try { run(); }
catch (error) {
console.error(error.message);
process.exitCode = 1;
}
}
6 changes: 6 additions & 0 deletions tests/ci-foundation.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ assert.match(workflow, /\^\(feat\|fix\|test\|chore\)\/\.\+\$/);
assert.match(workflow, /HEAD_REF" == "v2\.1"/);
assert.match(workflow, /\^hotfix\/\.\+\$/);
assert.match(workflow, /Only v2\.1 release promotion or an explicit hotfix/);
assert.match(workflow, /name: Enforce commit and pull request title conventions/);
assert.match(workflow, /BASE_SHA: \$\{\{ github\.event\.pull_request\.base\.sha \}\}/);
assert.match(workflow, /HEAD_SHA: \$\{\{ github\.event\.pull_request\.head\.sha \}\}/);
assert.match(workflow, /PR_TITLE: \$\{\{ github\.event\.pull_request\.title \}\}/);
assert.match(workflow, /node scripts\/validate-commit-message\.mjs --range "\$BASE_SHA" "\$HEAD_SHA"/);
assert.match(workflow, /node scripts\/validate-commit-message\.mjs --title "\$PR_TITLE"/);
assert.match(workflow, /git diff --check/);
assert.match(workflow, /run: npm ci --ignore-scripts/);
assert.match(workflow, /run: npm run build/);
Expand Down
88 changes: 88 additions & 0 deletions tests/commit-message.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";

import { CANONICAL_PAIRS, run, validateCommitMessage, validateCommitRange } from "../scripts/validate-commit-message.mjs";

const accepted = [
"✨[Feat] Add Image to Text OCR",
"➕[Add] Add local OCR assets",
"🚀[Deploy] Publish v2.1.0",
"✅[Test] Cover OCR cancellation",
"📈[Data] Update language model inventory",
"🐛[Fix] Prevent stale OCR callbacks",
"♻️[Refactor] Simplify OCR worker lifecycle",
"🔧[Config] Enforce protected branch workflow",
"🚨[Hotfix] Restore production OCR loading",
"⚙️[Chore] Reinstate commit convention",
"🎉[Init] Initialize Secure Tools",
"📄[Docs] Document release workflow",
"🎀[Style] Align OCR action layout",
"🚚[Rename] Rename metadata helper",
];

const rejected = [
"feat: add OCR",
"✨ feat: add OCR",
"✨ [Feat] Add OCR",
"[Feat] ✨ Add OCR",
"✨[Fix] Add OCR",
"🐛[Feat] Fix OCR",
"✨[Feat]",
"✨[Unknown] Add OCR",
"Add OCR",
"✨[Feat] Add OCR",
"✨[Feat] feat: add OCR",
];

assert.equal(CANONICAL_PAIRS.length, 14);
for (const message of accepted) assert.deepEqual(validateCommitMessage(message), { valid: true, reason: null }, message);
for (const message of rejected) assert.equal(validateCommitMessage(message).valid, false, message);
assert.doesNotThrow(() => run(["--title", "⚙️[Chore] Reinstate commit convention"]));
assert.throws(
() => run(["--message", "docs: explain policy"]),
(error) => /Invalid commit message: docs: explain policy[\s\S]*Expected:[\s\S]*Canonical pairs:/.test(error.message),
);

const temporaryRepository = fs.mkdtempSync(path.join(os.tmpdir(), "secure-tools-commit-validator-"));
const git = (...argumentsList) => execFileSync("git", argumentsList, { cwd: temporaryRepository, encoding: "utf8" }).trim();
try {
git("init", "-q");
git("config", "user.name", "Secure Tools Test");
git("config", "user.email", "test@securetools.invalid");
fs.writeFileSync(path.join(temporaryRepository, "base.txt"), "base\n");
git("add", "base.txt");
git("commit", "-q", "-m", "🎉[Init] Initialize validator fixture");
const base = git("rev-parse", "HEAD");

git("switch", "-q", "-c", "side");
fs.writeFileSync(path.join(temporaryRepository, "side.txt"), "side\n");
git("add", "side.txt");
git("commit", "-q", "-m", "✅[Test] Add side fixture");

git("switch", "-q", "-c", "feature", base);
fs.writeFileSync(path.join(temporaryRepository, "feature.txt"), "feature\n");
git("add", "feature.txt");
git("commit", "-q", "-m", "✨[Feat] Add feature fixture");
git("merge", "-q", "--no-ff", "side", "-m", "Merge side into feature");

const mergeHead = git("rev-parse", "HEAD");
const mergeResults = validateCommitRange(base, mergeHead, temporaryRepository);
assert.equal(mergeResults.length, 2, "the topology-based range excludes the technical merge commit");
assert.ok(mergeResults.every((result) => result.valid));

fs.writeFileSync(path.join(temporaryRepository, "invalid.txt"), "invalid\n");
git("add", "invalid.txt");
git("commit", "-q", "-m", "test: add invalid fixture");
const invalidHead = git("rev-parse", "HEAD");
const failures = validateCommitRange(mergeHead, invalidHead, temporaryRepository).filter((result) => !result.valid);
assert.equal(failures.length, 1);
assert.equal(failures[0].subject, "test: add invalid fixture");
assert.match(failures[0].sha, /^[0-9a-f]{40}$/);
} finally {
fs.rmSync(temporaryRepository, { recursive: true, force: true });
}

console.log("Commit message accepted, rejected, range, and merge-topology checks passed.");
1 change: 1 addition & 0 deletions tests/run-all.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ for (const test of [
"tests/i18n-quality.test.mjs",
"tests/ux-consistency.test.mjs",
"tests/ci-foundation.test.mjs",
"tests/commit-message.test.mjs",
"tests/cloudflare-bridge.test.mjs",
]) {
runNode([test], test);
Expand Down
Loading