mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-12 13:59:47 +08:00
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:
@@ -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);
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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(", ");
|
||||
}
|
||||
@@ -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"],
|
||||
}),
|
||||
[],
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user