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
141 changes: 141 additions & 0 deletions .github/actions/git/steps/compute-highest-commit-type/action.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/**
* @fileoverview Compute the highest-priority Conventional-Commit type across
* a set of commits, and the implied semver bump. Pure logic β€” exports the
* core functions for tests; the IIFE at the bottom drives the action.
*
* @module @cldmv/.github.git.steps.compute-highest-commit-type
*/

import { readFileSync } from "node:fs";
import { getInput, setOutputs } from "../../../common/common/core.mjs";

/**
* Conventional types in priority order (highest = most important). Used to
* pick the dominant type when a range contains multiple. Breaking commits
* outrank all of these regardless of their own type.
*
* @public
*/
export const TYPE_PRIORITY = [
"feat",
"fix",
"perf",
"revert",
"refactor",
"style",
"docs",
"test",
"build",
"ci",
"chore"
];

/**
* Map a (type, isBreaking) pair to an implied semver bump.
*
* @public
* @param {string} type - Conventional type (e.g. "feat", "fix"). Empty string
* when nothing parsed.
* @param {boolean} isBreaking - Whether any commit declared a breaking change.
* @returns {"major"|"minor"|"patch"|"none"}
*/
export function bumpFor(type, isBreaking) {
if (isBreaking) return "major";
if (type === "feat") return "minor";
if (type === "fix" || type === "perf" || type === "revert") return "patch";
return "none";
}

const CONVENTIONAL_SUBJECT_RE = /^([a-z]+)(?:\([^)]*\))?(!)?:\s*.+$/;
const BREAKING_FOOTER_RE = /^BREAKING[\s-]CHANGE:/m;

/**
* Parse a single commit subject + optional body into { type, isBreaking }.
* Returns null when the subject doesn't match Conventional Commits format.
*
* @public
* @param {string} subject - First line of the commit message.
* @param {string} [body=""] - Remainder of the commit message (may be empty).
* @returns {{ type: string, isBreaking: boolean } | null}
*/
export function parseCommit(subject, body = "") {
if (typeof subject !== "string") return null;
const m = subject.match(CONVENTIONAL_SUBJECT_RE);
if (!m) return null;
const type = m[1].toLowerCase();
const breakingMark = !!m[2];
const breakingFooter = typeof body === "string" && BREAKING_FOOTER_RE.test(body);
return { type, isBreaking: breakingMark || breakingFooter };
}

/**
* Reduce a list of parsed commits to the highest-priority result.
*
* @public
* @param {Array<{ subject: string, body?: string }>} commits
* @returns {{ highestType: string, isBreaking: boolean, bump: "major"|"minor"|"patch"|"none" }}
*/
export function computeHighest(commits) {
let highestType = "";
let highestRank = Infinity;
let isBreaking = false;

for (const c of commits || []) {
const parsed = parseCommit(c?.subject ?? "", c?.body ?? "");
if (!parsed) continue;
if (parsed.isBreaking) isBreaking = true;
const rank = TYPE_PRIORITY.indexOf(parsed.type);
// Unknown types rank after the known list but still get considered.
const effectiveRank = rank === -1 ? TYPE_PRIORITY.length : rank;
if (effectiveRank < highestRank) {
highestRank = effectiveRank;
highestType = parsed.type;
}
}

return {
highestType,
isBreaking,
bump: bumpFor(highestType, isBreaking)
};
}

function loadCommits() {
const file = getInput("commits-file");
const inline = getInput("commits-json");
if (file && inline) {
throw new Error("commits-file and commits-json are mutually exclusive β€” pass one, not both");
}
if (!file && !inline) {
throw new Error("either commits-file or commits-json must be provided");
}
const raw = file ? readFileSync(file, "utf8") : inline;
let parsed;
try {
parsed = JSON.parse(raw);
} catch (e) {
throw new Error(`Failed to parse commits JSON: ${e.message}`);
}
if (!Array.isArray(parsed)) {
throw new Error("commits JSON must be an array");
}
return parsed;
}

// Action entry β€” only runs when this module is the program entry, not when
// imported by test.mjs.
if (import.meta.url === `file://${process.argv[1]}`) {
try {
const commits = loadCommits();
const { highestType, isBreaking, bump } = computeHighest(commits);
setOutputs({
"highest-type": highestType,
"is-breaking": String(isBreaking),
bump
});
console.log(`πŸ“Š Highest type: ${highestType || "(none)"} | breaking: ${isBreaking} | bump: ${bump}`);
} catch (error) {
console.error(`::error::${error.message}`);
process.exit(1);
}
}
33 changes: 33 additions & 0 deletions .github/actions/git/steps/compute-highest-commit-type/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: "Compute Highest Commit Type"
description: "Parse a JSON array of commits in Conventional Commits format and return the highest-priority type plus the implied semver bump. Used by normalize-pr-title and check-release-commit to pick the dominant type across a range."

inputs:
commits-file:
description: "Path to a JSON file containing an array of commit objects with at least { subject, body } fields. Mutually exclusive with commits-json."
required: false
commits-json:
description: "Inline JSON array of commit objects with at least { subject, body } fields. Mutually exclusive with commits-file."
required: false

outputs:
highest-type:
description: "Highest-priority conventional type found (e.g. 'feat', 'fix', 'chore'). Empty string when no commit parses as conventional."
value: ${{ steps.compute.outputs.highest-type }}
is-breaking:
description: "'true' when any commit declares a breaking change (via '!' marker or 'BREAKING CHANGE:' footer)."
value: ${{ steps.compute.outputs.is-breaking }}
bump:
description: "Implied semver bump: 'major' / 'minor' / 'patch' / 'none'."
value: ${{ steps.compute.outputs.bump }}

runs:
using: "composite"
steps:
- name: Compute highest commit type
id: compute
shell: bash
env:
INPUT_COMMITS_FILE: ${{ inputs.commits-file }}
INPUT_COMMITS_JSON: ${{ inputs.commits-json }}
run: |
node "${{ github.action_path }}/action.mjs"
124 changes: 124 additions & 0 deletions .github/actions/git/steps/compute-highest-commit-type/test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
#!/usr/bin/env node
/**
* @fileoverview Unit tests for compute-highest-commit-type pure logic.
* Run directly: `node test.mjs` in this directory. Exits non-zero on failure.
*/

import { parseCommit, computeHighest, bumpFor, TYPE_PRIORITY } from "./action.mjs";

let failures = 0;

function eq(actual, expected, label) {
const ok = JSON.stringify(actual) === JSON.stringify(expected);
if (ok) {
console.log(` βœ… ${label}`);
} else {
console.error(` ❌ ${label}`);
console.error(` expected: ${JSON.stringify(expected)}`);
console.error(` actual: ${JSON.stringify(actual)}`);
failures++;
}
}

console.log("parseCommit:");
eq(parseCommit("feat: add foo"), { type: "feat", isBreaking: false }, "plain feat");
eq(parseCommit("fix(parser): handle empty input"), { type: "fix", isBreaking: false }, "fix with scope");
eq(parseCommit("feat!: drop legacy API"), { type: "feat", isBreaking: true }, "feat with breaking mark");
eq(parseCommit("chore(deps)!: bump major dep"), { type: "chore", isBreaking: true }, "scoped + breaking mark");
eq(
parseCommit("feat: add foo", "BREAKING CHANGE: removes /v1"),
{ type: "feat", isBreaking: true },
"breaking via body footer"
);
eq(
parseCommit("feat: add foo", "BREAKING-CHANGE: removes /v1"),
{ type: "feat", isBreaking: true },
"breaking via hyphenated body footer"
);
eq(parseCommit("Merge pull request #10 from foo"), null, "non-conventional β†’ null");
eq(parseCommit("FEAT: caps"), null, "uppercase type rejected");
eq(parseCommit(""), null, "empty subject");
eq(parseCommit(null), null, "null subject");

console.log("\nbumpFor:");
eq(bumpFor("feat", false), "minor", "feat β†’ minor");
eq(bumpFor("fix", false), "patch", "fix β†’ patch");
eq(bumpFor("perf", false), "patch", "perf β†’ patch");
eq(bumpFor("revert", false), "patch", "revert β†’ patch");
eq(bumpFor("chore", false), "none", "chore β†’ none");
eq(bumpFor("docs", false), "none", "docs β†’ none");
eq(bumpFor("feat", true), "major", "feat + breaking β†’ major");
eq(bumpFor("chore", true), "major", "chore + breaking β†’ major");
eq(bumpFor("", false), "none", "empty type β†’ none");

console.log("\ncomputeHighest:");
eq(
computeHighest([
{ subject: "chore: cleanup", body: "" },
{ subject: "feat: add foo", body: "" },
{ subject: "fix: bar", body: "" }
]),
{ highestType: "feat", isBreaking: false, bump: "minor" },
"feat wins over fix + chore"
);
eq(
computeHighest([
{ subject: "fix: bar", body: "" },
{ subject: "chore: cleanup", body: "" }
]),
{ highestType: "fix", isBreaking: false, bump: "patch" },
"fix wins over chore"
);
eq(
computeHighest([
{ subject: "chore!: rip out v1", body: "" }
]),
{ highestType: "chore", isBreaking: true, bump: "major" },
"breaking outranks bump category β€” chore! β†’ major"
);
eq(
computeHighest([
{ subject: "feat: add foo", body: "BREAKING CHANGE: removes /v1" }
]),
{ highestType: "feat", isBreaking: true, bump: "major" },
"footer-declared breaking β†’ major"
);
eq(
computeHighest([]),
{ highestType: "", isBreaking: false, bump: "none" },
"empty list β†’ all defaults"
);
eq(
computeHighest([
{ subject: "Merge pull request #10 from foo" },
{ subject: "non-conventional message" }
]),
{ highestType: "", isBreaking: false, bump: "none" },
"all non-conventional β†’ all defaults"
);
eq(
computeHighest([
{ subject: "weirdtype: a new category" },
{ subject: "fix: bar" }
]),
{ highestType: "fix", isBreaking: false, bump: "patch" },
"unknown type ranks below known types"
);
eq(
computeHighest([
{ subject: "weirdtype: foo" }
]),
{ highestType: "weirdtype", isBreaking: false, bump: "none" },
"unknown type still surfaces when only choice (bump=none)"
);

console.log("\nTYPE_PRIORITY ordering:");
eq(TYPE_PRIORITY[0], "feat", "feat is first");
eq(TYPE_PRIORITY[1], "fix", "fix is second");
eq(TYPE_PRIORITY.indexOf("perf") < TYPE_PRIORITY.indexOf("refactor"), true, "perf ranks above refactor");

if (failures > 0) {
console.error(`\n❌ ${failures} test(s) failed`);
process.exit(1);
}
console.log("\nβœ… all tests passed");
Loading
Loading