diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 03f71bc..0fa8cbf 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,6 +11,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Setup Node.js uses: actions/setup-node@v4 @@ -18,6 +20,9 @@ jobs: node-version: 22 cache: npm + - name: Verify skill release commits + run: npm run verify:skill-release-commits + - name: Install dependencies run: npm ci diff --git a/docs/publishing.md b/docs/publishing.md index 1008d7c..5d00ae7 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -23,6 +23,10 @@ bash scripts/sync-clawhub.sh # 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. +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//**` 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 `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. diff --git a/package.json b/package.json index ef83905..5768f58 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ ], "scripts": { "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": { "@mozilla/readability": "^0.6.0", diff --git a/scripts/lib/release-files.mjs b/scripts/lib/release-files.mjs index b05640c..c1e646c 100644 --- a/scripts/lib/release-files.mjs +++ b/scripts/lib/release-files.mjs @@ -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); diff --git a/scripts/lib/release-files.test.ts b/scripts/lib/release-files.test.ts index ead0bfd..c58b9a1 100644 --- a/scripts/lib/release-files.test.ts +++ b/scripts/lib/release-files.test.ts @@ -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 })); diff --git a/scripts/lib/skill-release-guard.mjs b/scripts/lib/skill-release-guard.mjs new file mode 100644 index 0000000..f4b5301 --- /dev/null +++ b/scripts/lib/skill-release-guard.mjs @@ -0,0 +1,68 @@ +const SKILL_PATH_PATTERN = /^skills\/([^/]+)\//; +const CONVENTIONAL_SUBJECT_PATTERN = + /^(?[a-z][a-z0-9-]*)(?:\((?[^()\n]+)\))?(?!)?: (?\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//** 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) : ""; +} + +function formatSkills(skills) { + return skills.map((skill) => `skills/${skill}/**`).join(", "); +} diff --git a/scripts/lib/skill-release-guard.test.ts b/scripts/lib/skill-release-guard.test.ts new file mode 100644 index 0000000..a422f58 --- /dev/null +++ b/scripts/lib/skill-release-guard.test.ts @@ -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"], + }), + [], + ); +}); diff --git a/scripts/publish-skill.mjs b/scripts/publish-skill.mjs index 2868e12..402ef17 100644 --- a/scripts/publish-skill.mjs +++ b/scripts/publish-skill.mjs @@ -5,7 +5,12 @@ import { existsSync } from "node:fs"; import os from "node:os"; 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"; @@ -21,6 +26,7 @@ async function main() { ? await fs.readFile(path.resolve(options.changelogFile), "utf8") : ""; + await validateSkillMetadataVersion(skillDir, options.version); await validateSelfContainedRelease(skillDir); const files = await listReleaseFiles(skillDir); if (files.length === 0) { diff --git a/scripts/sync-clawhub.mjs b/scripts/sync-clawhub.mjs index a91df7d..3940c6c 100644 --- a/scripts/sync-clawhub.mjs +++ b/scripts/sync-clawhub.mjs @@ -6,7 +6,13 @@ import { existsSync } from "node:fs"; import path from "node:path"; 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"; @@ -38,9 +44,11 @@ async function main() { const locals = await mapWithConcurrency(skills, options.concurrency, async (skill) => { const files = await collectReleaseFiles(skill.folder); + const localVersion = await readSkillMetadataVersion(skill.folder); const fingerprint = buildFingerprint(files); return { ...skill, + localVersion, fileCount: files.length, fingerprint, }; @@ -119,12 +127,12 @@ async function main() { for (const candidate of actionable) { const version = candidate.status === "new" - ? "1.0.0" - : bumpSemver(candidate.latestVersion, options.bump); + ? candidate.localVersion + : resolveUpdateVersion(candidate, options.bump); console.log(`Publishing ${candidate.slug}@${version}`); try { - const files = await collectReleaseFiles(candidate.folder); + const files = await collectReleaseFiles(candidate.folder, version); await publishSkill({ registry, 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); return listReleaseFiles(root); } @@ -423,28 +434,48 @@ async function mapWithConcurrency(items, limit, fn) { function formatCandidate(candidate, bump) { 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( - candidate.latestVersion, + return `${candidate.slug} UPDATE ${candidate.latestVersion} -> ${resolveUpdateVersion( + candidate, bump )} (${candidate.fileCount} files)`; } -function bumpSemver(version, bump) { - const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version ?? ""); - if (!match) { - throw new Error(`Invalid semver: ${version}`); +function resolveUpdateVersion(candidate, bump) { + if (compareSemver(candidate.localVersion, candidate.latestVersion) > 0) { + return candidate.localVersion; } - const major = Number(match[1]); - const minor = Number(match[2]); - const patch = Number(match[3]); + return bumpSemver(candidate.latestVersion, bump); +} + +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 === "minor") return `${major}.${minor + 1}.0`; 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) { return value .trim() diff --git a/scripts/verify-skill-release-commits.mjs b/scripts/verify-skill-release-commits.mjs new file mode 100644 index 0000000..97c8bb5 --- /dev/null +++ b/scripts/verify-skill-release-commits.mjs @@ -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 --head | --range ] + +Checks non-merge commits in the selected range. Any commit that touches +skills//** 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); +});