feat(ci): add skill release commit validation

Add CI check to ensure commits touching skills/<name>/** use
Conventional Commit subjects. Also validates SKILL.md version
alignment during publish/sync.
This commit is contained in:
Jim Liu 宝玉
2026-05-20 07:58:14 -05:00
parent 5d2a39c636
commit edcdc19ac5
10 changed files with 424 additions and 17 deletions
+45
View File
@@ -25,6 +25,37 @@ const MIME_MAP = {
".svg": "image/svg+xml",
};
export async function readSkillMetadataVersion(root) {
const skillFile = await findSkillMarkdown(root);
const source = await fs.readFile(skillFile, "utf8");
const version = readSkillFrontmatterVersion(source);
if (!version) {
throw new Error(`Missing version in ${path.relative(process.cwd(), skillFile) || skillFile}`);
}
return version;
}
export async function validateSkillMetadataVersion(root, expectedVersion) {
const actualVersion = await readSkillMetadataVersion(root);
if (actualVersion !== expectedVersion) {
throw new Error(
`SKILL.md version mismatch for ${path.basename(path.resolve(root))}: expected ${expectedVersion}, found ${actualVersion}`,
);
}
}
export function readSkillFrontmatterVersion(source) {
const match = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(source);
if (!match) return null;
for (const line of match[1].split(/\r?\n/)) {
const versionMatch = /^version:\s*["']?([^"'\s#]+)["']?\s*(?:#.*)?$/.exec(line.trim());
if (versionMatch) return versionMatch[1];
}
return null;
}
export async function listReleaseFiles(root) {
const resolvedRoot = path.resolve(root);
const files = [];
@@ -53,6 +84,20 @@ export async function listReleaseFiles(root) {
return files;
}
async function findSkillMarkdown(root) {
const resolvedRoot = path.resolve(root);
for (const name of ["SKILL.md", "skill.md"]) {
const candidate = path.join(resolvedRoot, name);
try {
const stat = await fs.stat(candidate);
if (stat.isFile()) return candidate;
} catch {
// Try the next supported skill filename.
}
}
throw new Error(`Missing SKILL.md in ${resolvedRoot}`);
}
export async function validateSelfContainedRelease(root) {
const resolvedRoot = path.resolve(root);
const files = await listReleaseFiles(root);
+31
View File
@@ -6,6 +6,9 @@ import test from "node:test";
import {
listReleaseFiles,
readSkillFrontmatterVersion,
readSkillMetadataVersion,
validateSkillMetadataVersion,
validateSelfContainedRelease,
} from "./release-files.mjs";
@@ -45,6 +48,34 @@ test("listReleaseFiles skips generated paths and returns sorted relative paths",
);
});
test("readSkillFrontmatterVersion reads quoted and unquoted versions", () => {
assert.equal(readSkillFrontmatterVersion("---\nname: demo\nversion: 1.2.3\n---\n"), "1.2.3");
assert.equal(readSkillFrontmatterVersion("---\nversion: \"2.0.0\"\n---\n"), "2.0.0");
assert.equal(readSkillFrontmatterVersion("# Missing frontmatter\n"), null);
});
test("validateSkillMetadataVersion accepts matching SKILL.md version", async (t) => {
const root = await makeTempDir("baoyu-release-version-ok-");
t.after(() => fs.rm(root, { recursive: true, force: true }));
await writeFile(path.join(root, "SKILL.md"), "---\nname: demo\nversion: 1.2.3\n---\n");
assert.equal(await readSkillMetadataVersion(root), "1.2.3");
await assert.doesNotReject(() => validateSkillMetadataVersion(root, "1.2.3"));
});
test("validateSkillMetadataVersion rejects mismatched SKILL.md version", async (t) => {
const root = await makeTempDir("baoyu-release-version-mismatch-");
t.after(() => fs.rm(root, { recursive: true, force: true }));
await writeFile(path.join(root, "SKILL.md"), "---\nname: demo\nversion: 1.2.3\n---\n");
await assert.rejects(
() => validateSkillMetadataVersion(root, "1.2.4"),
/SKILL\.md version mismatch/,
);
});
test("validateSelfContainedRelease accepts file dependencies that stay within the release root", async (t) => {
const root = await makeTempDir("baoyu-release-ok-");
t.after(() => fs.rm(root, { recursive: true, force: true }));
+68
View File
@@ -0,0 +1,68 @@
const SKILL_PATH_PATTERN = /^skills\/([^/]+)\//;
const CONVENTIONAL_SUBJECT_PATTERN =
/^(?<type>[a-z][a-z0-9-]*)(?:\((?<scope>[^()\n]+)\))?(?<breaking>!)?: (?<description>\S[\s\S]*)$/;
export function parseConventionalCommitSubject(subject) {
const match = CONVENTIONAL_SUBJECT_PATTERN.exec(subject.trim());
if (!match?.groups) return null;
return {
type: match.groups.type,
scope: match.groups.scope ?? "",
breaking: Boolean(match.groups.breaking),
description: match.groups.description,
};
}
export function changedSkillsForPaths(paths) {
const skills = new Set();
for (const filePath of paths) {
const normalizedPath = filePath.replaceAll("\\", "/");
const match = SKILL_PATH_PATTERN.exec(normalizedPath);
if (match) skills.add(match[1]);
}
return [...skills].sort((left, right) => left.localeCompare(right));
}
export function validateSkillReleaseCommit({ commit = "", subject, paths }) {
const skills = changedSkillsForPaths(paths);
if (skills.length === 0) return [];
const parsed = parseConventionalCommitSubject(subject);
if (parsed) return [];
return [
{
commit,
subject,
skills,
message: `Commit ${formatCommit(commit)} changes ${formatSkills(skills)} but its subject is not a Conventional Commit: ${subject}`,
},
];
}
export function formatSkillReleaseFailures(failures) {
if (failures.length === 0) return "";
const lines = [
"Skill release commit check failed.",
"",
"Commits that touch skills/<name>/** must use Conventional Commit subjects so per-skill release tooling can derive a version bump.",
"Example: fix(baoyu-post-to-wechat): handle WeChat editor focus",
"",
];
for (const failure of failures) {
lines.push(`- ${failure.message}`);
}
return lines.join("\n");
}
function formatCommit(commit) {
return commit ? commit.slice(0, 12) : "<unknown>";
}
function formatSkills(skills) {
return skills.map((skill) => `skills/${skill}/**`).join(", ");
}
+69
View File
@@ -0,0 +1,69 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
changedSkillsForPaths,
formatSkillReleaseFailures,
parseConventionalCommitSubject,
validateSkillReleaseCommit,
} from "./skill-release-guard.mjs";
test("parseConventionalCommitSubject accepts scoped, unscoped, and breaking subjects", () => {
assert.deepEqual(parseConventionalCommitSubject("fix(baoyu-post-to-wechat): repair editor paste"), {
type: "fix",
scope: "baoyu-post-to-wechat",
breaking: false,
description: "repair editor paste",
});
assert.deepEqual(parseConventionalCommitSubject("feat!: change skill metadata format"), {
type: "feat",
scope: "",
breaking: true,
description: "change skill metadata format",
});
assert.equal(parseConventionalCommitSubject("Fix WeChat browser article publishing"), null);
});
test("changedSkillsForPaths returns sorted unique skills", () => {
assert.deepEqual(
changedSkillsForPaths([
"README.md",
"skills/baoyu-post-to-wechat/scripts/wechat-article.ts",
"skills/baoyu-post-to-wechat/SKILL.md",
"skills/baoyu-url-to-markdown/SKILL.md",
]),
["baoyu-post-to-wechat", "baoyu-url-to-markdown"],
);
});
test("validateSkillReleaseCommit ignores non-skill changes", () => {
assert.deepEqual(
validateSkillReleaseCommit({
subject: "Fix test workflow",
paths: [".github/workflows/test.yml"],
}),
[],
);
});
test("validateSkillReleaseCommit rejects non-conventional skill commit subjects", () => {
const failures = validateSkillReleaseCommit({
commit: "81377416b4a7",
subject: "Fix WeChat browser article publishing",
paths: ["skills/baoyu-post-to-wechat/scripts/wechat-article.ts"],
});
assert.equal(failures.length, 1);
assert.match(failures[0]!.message, /Conventional Commit/);
assert.match(formatSkillReleaseFailures(failures), /fix\(baoyu-post-to-wechat\):/);
});
test("validateSkillReleaseCommit accepts conventional skill commit subjects", () => {
assert.deepEqual(
validateSkillReleaseCommit({
subject: "fix(browser): ensure tab activation before copy/paste in WeChat editor",
paths: ["skills/baoyu-post-to-wechat/scripts/wechat-article.ts"],
}),
[],
);
});