From 934d8ad8a8a1dc70958ece9bf649ad8bc15e93d1 Mon Sep 17 00:00:00 2001 From: maruson08 Date: Fri, 25 Sep 2026 19:00:24 +0900 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=93=84[Docs]=20Reinstate=20Secure=20T?= =?UTF-8?q?ools=20commit=20convention?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 11 +++++++++++ docs/development-workflow.md | 8 ++++++++ 2 files changed, 19 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 00795a3..38c1065 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 `[] ` 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. diff --git a/docs/development-workflow.md b/docs/development-workflow.md index 44170aa..0e1b7a1 100644 --- a/docs/development-workflow.md +++ b/docs/development-workflow.md @@ -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 `[] `. 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. From d829da38e7ddc59ff96c590a6f8b77372cf4fc6d Mon Sep 17 00:00:00 2001 From: maruson08 Date: Fri, 25 Sep 2026 19:02:38 +0900 Subject: [PATCH 2/3] =?UTF-8?q?=E2=9C=85[Test]=20Add=20commit=20message=20?= =?UTF-8?q?validation=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + scripts/validate-commit-message.mjs | 91 +++++++++++++++++++++++++++++ tests/commit-message.test.mjs | 88 ++++++++++++++++++++++++++++ tests/run-all.mjs | 1 + 4 files changed, 181 insertions(+) create mode 100644 scripts/validate-commit-message.mjs create mode 100644 tests/commit-message.test.mjs diff --git a/package.json b/package.json index 684367a..2500808 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/scripts/validate-commit-message.mjs b/scripts/validate-commit-message.mjs new file mode 100644 index 0000000..c3e0c83 --- /dev/null +++ b/scripts/validate-commit-message.mjs @@ -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 || ""}`, + `Reason: ${reason}`, + "Expected: [] ", + "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 | --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; + } +} diff --git a/tests/commit-message.test.mjs b/tests/commit-message.test.mjs new file mode 100644 index 0000000..f8304aa --- /dev/null +++ b/tests/commit-message.test.mjs @@ -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."); diff --git a/tests/run-all.mjs b/tests/run-all.mjs index 9fb5322..f0916d6 100644 --- a/tests/run-all.mjs +++ b/tests/run-all.mjs @@ -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); From 89a65233f2ba350ed989eb1a5590f09219fc675b Mon Sep 17 00:00:00 2001 From: maruson08 <maruson08@naver.com> Date: Fri, 25 Sep 2026 19:03:50 +0900 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=94=A7[Config]=20Enforce=20commit=20c?= =?UTF-8?q?onvention=20in=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 10 ++++++++++ tests/ci-foundation.test.mjs | 6 ++++++ 2 files changed, 16 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d9ef9cd..407481f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: diff --git a/tests/ci-foundation.test.mjs b/tests/ci-foundation.test.mjs index 63ef1c3..4118666 100644 --- a/tests/ci-foundation.test.mjs +++ b/tests/ci-foundation.test.mjs @@ -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/);