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
+5
View File
@@ -11,6 +11,8 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -18,6 +20,9 @@ jobs:
node-version: 22 node-version: 22
cache: npm cache: npm
- name: Verify skill release commits
run: npm run verify:skill-release-commits
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
+4
View File
@@ -23,6 +23,10 @@ bash scripts/sync-clawhub.sh <skill> # sync one skill
Release hooks are configured via `.releaserc.yml`. This repo does not stage a separate release directory: publish reads the skill directory directly and validates that local package references and CLI bin targets are self-contained. Release hooks are configured via `.releaserc.yml`. This repo does not stage a separate release directory: publish reads the skill directory directly and validates that local package references and CLI bin targets are self-contained.
Every skill release must keep the `version:` in that skill's `SKILL.md` aligned with the version being published. `publish-skill.mjs` and `sync-clawhub.mjs` both reject mismatches so a registry payload cannot ship with stale skill metadata.
Commits that touch `skills/<name>/**` must use Conventional Commit subjects, for example `fix(baoyu-post-to-wechat): handle WeChat editor focus`. CI runs `npm run verify:skill-release-commits` against the pushed or PR commit range so bare subjects like `Fix WeChat browser article publishing` cannot bypass per-skill release versioning silently.
## Shared Workspace Packages ## Shared Workspace Packages
`packages/` is the source of truth for shared runtime code. Most skills consume shared packages from npm with semver ranges. `baoyu-url-to-markdown` is the exception: it vendors the `baoyu-fetch` runtime into `skills/baoyu-url-to-markdown/scripts/lib/` so the published skill is self-contained and does not depend on the `baoyu-fetch` npm package. `packages/` is the source of truth for shared runtime code. Most skills consume shared packages from npm with semver ranges. `baoyu-url-to-markdown` is the exception: it vendors the `baoyu-fetch` runtime into `skills/baoyu-url-to-markdown/scripts/lib/` so the published skill is self-contained and does not depend on the `baoyu-fetch` npm package.
+2 -1
View File
@@ -7,7 +7,8 @@
], ],
"scripts": { "scripts": {
"test": "node ./scripts/run-node-tests.mjs", "test": "node ./scripts/run-node-tests.mjs",
"test:coverage": "node ./scripts/run-node-tests.mjs --experimental-test-coverage" "test:coverage": "node ./scripts/run-node-tests.mjs --experimental-test-coverage",
"verify:skill-release-commits": "node ./scripts/verify-skill-release-commits.mjs"
}, },
"devDependencies": { "devDependencies": {
"@mozilla/readability": "^0.6.0", "@mozilla/readability": "^0.6.0",
+45
View File
@@ -25,6 +25,37 @@ const MIME_MAP = {
".svg": "image/svg+xml", ".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) { export async function listReleaseFiles(root) {
const resolvedRoot = path.resolve(root); const resolvedRoot = path.resolve(root);
const files = []; const files = [];
@@ -53,6 +84,20 @@ export async function listReleaseFiles(root) {
return files; 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) { export async function validateSelfContainedRelease(root) {
const resolvedRoot = path.resolve(root); const resolvedRoot = path.resolve(root);
const files = await listReleaseFiles(root); const files = await listReleaseFiles(root);
+31
View File
@@ -6,6 +6,9 @@ import test from "node:test";
import { import {
listReleaseFiles, listReleaseFiles,
readSkillFrontmatterVersion,
readSkillMetadataVersion,
validateSkillMetadataVersion,
validateSelfContainedRelease, validateSelfContainedRelease,
} from "./release-files.mjs"; } 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) => { test("validateSelfContainedRelease accepts file dependencies that stay within the release root", async (t) => {
const root = await makeTempDir("baoyu-release-ok-"); const root = await makeTempDir("baoyu-release-ok-");
t.after(() => fs.rm(root, { recursive: true, force: true })); 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"],
}),
[],
);
});
+7 -1
View File
@@ -5,7 +5,12 @@ import { existsSync } from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { listReleaseFiles, mimeType, validateSelfContainedRelease } from "./lib/release-files.mjs"; import {
listReleaseFiles,
mimeType,
validateSelfContainedRelease,
validateSkillMetadataVersion,
} from "./lib/release-files.mjs";
const DEFAULT_REGISTRY = "https://clawhub.ai"; const DEFAULT_REGISTRY = "https://clawhub.ai";
@@ -21,6 +26,7 @@ async function main() {
? await fs.readFile(path.resolve(options.changelogFile), "utf8") ? await fs.readFile(path.resolve(options.changelogFile), "utf8")
: ""; : "";
await validateSkillMetadataVersion(skillDir, options.version);
await validateSelfContainedRelease(skillDir); await validateSelfContainedRelease(skillDir);
const files = await listReleaseFiles(skillDir); const files = await listReleaseFiles(skillDir);
if (files.length === 0) { if (files.length === 0) {
+46 -15
View File
@@ -6,7 +6,13 @@ import { existsSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import os from "node:os"; import os from "node:os";
import { listReleaseFiles, mimeType, validateSelfContainedRelease } from "./lib/release-files.mjs"; import {
listReleaseFiles,
mimeType,
readSkillMetadataVersion,
validateSelfContainedRelease,
validateSkillMetadataVersion,
} from "./lib/release-files.mjs";
const DEFAULT_REGISTRY = "https://clawhub.ai"; const DEFAULT_REGISTRY = "https://clawhub.ai";
@@ -38,9 +44,11 @@ async function main() {
const locals = await mapWithConcurrency(skills, options.concurrency, async (skill) => { const locals = await mapWithConcurrency(skills, options.concurrency, async (skill) => {
const files = await collectReleaseFiles(skill.folder); const files = await collectReleaseFiles(skill.folder);
const localVersion = await readSkillMetadataVersion(skill.folder);
const fingerprint = buildFingerprint(files); const fingerprint = buildFingerprint(files);
return { return {
...skill, ...skill,
localVersion,
fileCount: files.length, fileCount: files.length,
fingerprint, fingerprint,
}; };
@@ -119,12 +127,12 @@ async function main() {
for (const candidate of actionable) { for (const candidate of actionable) {
const version = const version =
candidate.status === "new" candidate.status === "new"
? "1.0.0" ? candidate.localVersion
: bumpSemver(candidate.latestVersion, options.bump); : resolveUpdateVersion(candidate, options.bump);
console.log(`Publishing ${candidate.slug}@${version}`); console.log(`Publishing ${candidate.slug}@${version}`);
try { try {
const files = await collectReleaseFiles(candidate.folder); const files = await collectReleaseFiles(candidate.folder, version);
await publishSkill({ await publishSkill({
registry, registry,
token: config.token, token: config.token,
@@ -325,7 +333,10 @@ async function hasSkillMarker(folder) {
); );
} }
async function collectReleaseFiles(root) { async function collectReleaseFiles(root, expectedVersion = "") {
if (expectedVersion) {
await validateSkillMetadataVersion(root, expectedVersion);
}
await validateSelfContainedRelease(root); await validateSelfContainedRelease(root);
return listReleaseFiles(root); return listReleaseFiles(root);
} }
@@ -423,28 +434,48 @@ async function mapWithConcurrency(items, limit, fn) {
function formatCandidate(candidate, bump) { function formatCandidate(candidate, bump) {
if (candidate.status === "new") { if (candidate.status === "new") {
return `${candidate.slug} NEW (${candidate.fileCount} files)`; return `${candidate.slug} NEW ${candidate.localVersion} (${candidate.fileCount} files)`;
} }
return `${candidate.slug} UPDATE ${candidate.latestVersion} -> ${bumpSemver( return `${candidate.slug} UPDATE ${candidate.latestVersion} -> ${resolveUpdateVersion(
candidate.latestVersion, candidate,
bump bump
)} (${candidate.fileCount} files)`; )} (${candidate.fileCount} files)`;
} }
function bumpSemver(version, bump) { function resolveUpdateVersion(candidate, bump) {
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version ?? ""); if (compareSemver(candidate.localVersion, candidate.latestVersion) > 0) {
if (!match) { return candidate.localVersion;
throw new Error(`Invalid semver: ${version}`);
} }
const major = Number(match[1]); return bumpSemver(candidate.latestVersion, bump);
const minor = Number(match[2]); }
const patch = Number(match[3]);
function compareSemver(left, right) {
const leftParts = parseSemver(left);
const rightParts = parseSemver(right);
for (let index = 0; index < leftParts.length; index += 1) {
if (leftParts[index] !== rightParts[index]) {
return leftParts[index] - rightParts[index];
}
}
return 0;
}
function bumpSemver(version, bump) {
const [major, minor, patch] = parseSemver(version);
if (bump === "major") return `${major + 1}.0.0`; if (bump === "major") return `${major + 1}.0.0`;
if (bump === "minor") return `${major}.${minor + 1}.0`; if (bump === "minor") return `${major}.${minor + 1}.0`;
return `${major}.${minor}.${patch + 1}`; return `${major}.${minor}.${patch + 1}`;
} }
function parseSemver(version) {
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version ?? "");
if (!match) {
throw new Error(`Invalid semver: ${version}`);
}
return [Number(match[1]), Number(match[2]), Number(match[3])];
}
function sanitizeSlug(value) { function sanitizeSlug(value) {
return value return value
.trim() .trim()
+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env node
import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import { promisify } from "node:util";
import {
formatSkillReleaseFailures,
validateSkillReleaseCommit,
} from "./lib/skill-release-guard.mjs";
const execFileAsync = promisify(execFile);
const ZERO_SHA = /^0{40}$/;
async function main() {
const options = parseArgs(process.argv.slice(2));
const range = await resolveRange(options);
const commits = await listCommits(range);
const failures = [];
for (const commit of commits) {
const [subject, paths] = await Promise.all([readCommitSubject(commit), readCommitPaths(commit)]);
failures.push(...validateSkillReleaseCommit({ commit, subject, paths }));
}
if (failures.length > 0) {
console.error(formatSkillReleaseFailures(failures));
process.exit(1);
}
console.log(`Skill release commit check passed (${commits.length} commit${commits.length === 1 ? "" : "s"} checked).`);
}
function parseArgs(argv) {
const options = {
base: "",
head: "HEAD",
range: "",
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--base") {
options.base = argv[index + 1] ?? "";
index += 1;
continue;
}
if (arg === "--head") {
options.head = argv[index + 1] ?? "";
index += 1;
continue;
}
if (arg === "--range") {
options.range = argv[index + 1] ?? "";
index += 1;
continue;
}
if (arg === "-h" || arg === "--help") {
printUsage();
process.exit(0);
}
throw new Error(`Unknown argument: ${arg}`);
}
return options;
}
function printUsage() {
console.log(`Usage: verify-skill-release-commits.mjs [--base <rev> --head <rev> | --range <rev-range>]
Checks non-merge commits in the selected range. Any commit that touches
skills/<name>/** must use a Conventional Commit subject, for example:
fix(baoyu-post-to-wechat): handle WeChat editor focus
Without explicit arguments, GitHub Actions event metadata is used when
available. Otherwise the fallback range is HEAD^..HEAD.`);
}
async function resolveRange(options) {
if (options.range) {
return { args: [options.range], label: options.range };
}
if (options.base) {
return {
args: [`${options.base}..${options.head || "HEAD"}`],
label: `${options.base}..${options.head || "HEAD"}`,
};
}
const githubRange = await resolveGitHubRange();
if (githubRange) return githubRange;
return { args: ["HEAD^..HEAD"], label: "HEAD^..HEAD" };
}
async function resolveGitHubRange() {
const eventPath = process.env.GITHUB_EVENT_PATH;
if (!eventPath) return null;
let event = null;
try {
event = JSON.parse(await fs.readFile(eventPath, "utf8"));
} catch {
return null;
}
if (event?.pull_request?.base?.sha) {
const base = event.pull_request.base.sha;
const head = process.env.GITHUB_SHA || "HEAD";
return { args: [`${base}..${head}`], label: `${base}..${head}` };
}
if (event?.before && !ZERO_SHA.test(event.before)) {
const head = event.after || process.env.GITHUB_SHA || "HEAD";
return { args: [`${event.before}..${head}`], label: `${event.before}..${head}` };
}
return null;
}
async function listCommits(range) {
const output = await git(["rev-list", "--no-merges", "--reverse", ...range.args]);
return output ? output.split("\n").filter(Boolean) : [];
}
async function readCommitSubject(commit) {
return git(["log", "-1", "--format=%s", commit]);
}
async function readCommitPaths(commit) {
const output = await git(["diff-tree", "--no-commit-id", "--name-only", "-r", "--root", commit]);
return output ? output.split("\n").filter(Boolean) : [];
}
async function git(args) {
const { stdout } = await execFileAsync("git", args, {
encoding: "utf8",
maxBuffer: 10 * 1024 * 1024,
});
return stdout.trimEnd();
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});