From c30a33d5e595986d921531aa21696b7b0c893bf1 Mon Sep 17 00:00:00 2001 From: Marti Planellas Date: Wed, 28 Oct 2026 17:54:14 +0100 Subject: [PATCH 1/3] chore: remove pull-based skills sync (PD-126336) Skills are now pushed to the Sync PR by the producer repo, so the nightly workflow that pulled them with a personal token is no longer needed. Drops the script, its hash helper and the .sync-notes.md ignore entry. skills.lock.json is kept because the push-based sync still uses it. Co-Authored-By: Claude Sonnet 5.5 --- .gitignore | 1 - scripts/lib/hash.mjs | 38 -------- scripts/sync-skills.mjs | 200 ---------------------------------------- 3 files changed, 239 deletions(-) delete mode 100644 scripts/lib/hash.mjs delete mode 100644 scripts/sync-skills.mjs diff --git a/.gitignore b/.gitignore index 330a976..5994828 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,2 @@ dist/ -.sync-notes.md .DS_Store diff --git a/scripts/lib/hash.mjs b/scripts/lib/hash.mjs deleted file mode 100644 index 6384614..0000000 --- a/scripts/lib/hash.mjs +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Content hash of a skill directory. - * - * Mirrors agentic-hub-skills/tools/catalog/hash.mjs exactly, so this plugin's - * change-detection matches the source repo's own notion of "a skill changed". - * Returns `sha256:` folded over every file under `dirPath` - * (sorted forward-slash relative path + file bytes). - */ -import { createHash } from "node:crypto"; -import { readdirSync, readFileSync } from "node:fs"; -import { join, relative, sep } from "node:path"; - -function listFilesRecursive(dir) { - const files = []; - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const full = join(dir, entry.name); - if (entry.isDirectory()) files.push(...listFilesRecursive(full)); - else if (entry.isFile()) files.push(full); - } - return files; -} - -function digestOf(bytes) { - return createHash("sha256").update(bytes).digest(); -} - -export function hashDirectory(dirPath) { - const relativePaths = listFilesRecursive(dirPath) - .map((file) => relative(dirPath, file).split(sep).join("/")) - .sort(); - - const hash = createHash("sha256"); - for (const relativePath of relativePaths) { - hash.update(digestOf(relativePath)); - hash.update(digestOf(readFileSync(join(dirPath, relativePath)))); - } - return `sha256:${hash.digest("hex")}`; -} diff --git a/scripts/sync-skills.mjs b/scripts/sync-skills.mjs deleted file mode 100644 index 896302e..0000000 --- a/scripts/sync-skills.mjs +++ /dev/null @@ -1,200 +0,0 @@ -#!/usr/bin/env node -/** - * sync-skills.mjs — import PandaDoc skills from agentic-hub-skills into this plugin. - * - * This repo's root IS the Claude plugin, so skills are vendored into ./skills and the version is - * bumped in ./.claude-plugin/plugin.json. - * - * - Resolves a source ref (latest v* tag, else dev-* tag, else main; override with --ref). - * - Copies each skills// directory verbatim into ./skills/. - * - Detects change by per-skill content hash (mirrors the source repo's tools/catalog/hash.mjs), - * compared against the committed skills.lock.json. - * - On change: bumps .claude-plugin/plugin.json version, rewrites skills.lock.json, - * prepends CHANGELOG.md, and (in CI) emits step outputs + release notes. - * - Idempotent: no change → no writes, exit 0. - * - * Local run (no token/network needed — uses a local checkout of the skills repo): - * SKILLS_SOURCE_DIR=/path/to/agentic-hub-skills node scripts/sync-skills.mjs --dry-run - * - * Options (flag or env): --ref/SKILLS_REF · --bump/BUMP (patch|minor|major) - * · --source-dir/SKILLS_SOURCE_DIR · --dry-run/DRY_RUN - */ -import { execFileSync } from "node:child_process"; -import { - appendFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, - readdirSync, readFileSync, rmSync, writeFileSync, -} from "node:fs"; -import { tmpdir } from "node:os"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import { hashDirectory } from "./lib/hash.mjs"; - -const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const PLUGIN_JSON = join(REPO_ROOT, ".claude-plugin", "plugin.json"); -const SKILLS_DEST = join(REPO_ROOT, "skills"); -const LOCK_PATH = join(REPO_ROOT, "skills.lock.json"); -const CHANGELOG_PATH = join(REPO_ROOT, "CHANGELOG.md"); - -const SOURCE_REPO = process.env.SKILLS_REPO || "pandadoc-studio/agentic-hub-skills"; -const SOURCE_URL = `https://github.com/${SOURCE_REPO}.git`; - -const args = parseArgs(process.argv.slice(2)); -const bump = args.bump || process.env.BUMP || "patch"; -const dryRun = Boolean(args["dry-run"] || truthy(process.env.DRY_RUN)); -const explicitRef = args.ref || process.env.SKILLS_REF || ""; -const sourceDirOverride = args["source-dir"] || process.env.SKILLS_SOURCE_DIR || ""; - -main(); - -function main() { - const { sourceSkills, refLabel, sourceCommit, cleanup } = obtainSource(); - try { - const slugs = readdirSync(sourceSkills, { withFileTypes: true }) - .filter((d) => d.isDirectory() && existsSync(join(sourceSkills, d.name, "SKILL.md"))) - .map((d) => d.name) - .sort(); - if (slugs.length === 0) fail(`No skills found under ${sourceSkills}`); - - const current = {}; - for (const slug of slugs) current[slug] = hashDirectory(join(sourceSkills, slug)); - - const prevLock = existsSync(LOCK_PATH) ? JSON.parse(readFileSync(LOCK_PATH, "utf8")) : null; - const prev = (prevLock && prevLock.skills) || {}; - const firstImport = !prevLock; - - const added = slugs.filter((s) => !(s in prev)); - const changed = slugs.filter((s) => s in prev && prev[s] !== current[s]); - const removed = Object.keys(prev).filter((s) => !(s in current)); - const hasChange = added.length + changed.length + removed.length > 0; - - const summary = `+${added.length} ~${changed.length} -${removed.length}`; - console.log(`Source: ${SOURCE_REPO}@${refLabel}${sourceCommit ? ` (${sourceCommit.slice(0, 8)})` : ""}`); - console.log(`Skills: ${slugs.length} found — ${summary}`); - - if (!hasChange) { - console.log("No changes; plugin already in sync."); - emitOutput({ changed: false }); - return; - } - - printList("Added", added); - printList("Updated", changed); - printList("Removed", removed); - - if (dryRun) { - console.log("\nDRY RUN — changes detected but nothing written."); - emitOutput({ changed: true, version: readVersion(), summary }); - return; - } - - // 1) sync files verbatim, with delete semantics - rmSync(SKILLS_DEST, { recursive: true, force: true }); - mkdirSync(SKILLS_DEST, { recursive: true }); - for (const slug of slugs) { - cpSync(join(sourceSkills, slug), join(SKILLS_DEST, slug), { recursive: true }); - } - - // 2) version — baseline on first import, patch/minor/major bump thereafter - const plugin = JSON.parse(readFileSync(PLUGIN_JSON, "utf8")); - const oldVersion = plugin.version || "0.0.0"; - const newVersion = firstImport ? oldVersion : bumpVersion(oldVersion, bump); - plugin.version = newVersion; - writeJson(PLUGIN_JSON, plugin); - - // 3) lock (provenance + per-skill hashes) - writeJson(LOCK_PATH, { - source: { repo: SOURCE_REPO, ref: refLabel, commit: sourceCommit }, - pluginVersion: newVersion, - syncedAt: new Date().toISOString(), - skills: current, - }); - - // 4) changelog + release notes - const date = new Date().toISOString().slice(0, 10); - const entryLines = [ - `## v${newVersion} — ${date}`, - `Synced from \`${SOURCE_REPO}@${refLabel}\`${sourceCommit ? ` (\`${sourceCommit.slice(0, 8)}\`)` : ""}.`, - firstImport ? `- Initial import (${slugs.length} skills).` : null, - added.length ? `- Added: ${added.join(", ")}` : null, - changed.length ? `- Updated: ${changed.join(", ")}` : null, - removed.length ? `- Removed: ${removed.join(", ")}` : null, - ].filter(Boolean); - prependChangelog(entryLines.join("\n") + "\n"); - if (process.env.SYNC_NOTES_FILE) writeFileSync(process.env.SYNC_NOTES_FILE, entryLines.slice(1).join("\n")); - - console.log(`\nWrote ${slugs.length} skills. Version ${oldVersion} → ${newVersion}.`); - emitOutput({ changed: true, version: newVersion, summary }); - } finally { - cleanup(); - } -} - -function obtainSource() { - if (sourceDirOverride) { - const root = resolve(sourceDirOverride); - const skills = join(root, "skills"); - if (!existsSync(skills)) fail(`SKILLS_SOURCE_DIR has no skills/ directory: ${root}`); - return { - sourceSkills: skills, - refLabel: explicitRef || tryGit(root, ["rev-parse", "--abbrev-ref", "HEAD"]) || "local", - sourceCommit: tryGit(root, ["rev-parse", "HEAD"]), - cleanup: () => {}, - }; - } - const ref = explicitRef || resolveLatestRef(); - const tmp = mkdtempSync(join(tmpdir(), "ahs-")); - console.log(`Cloning ${SOURCE_URL} @ ${ref} …`); - execFileSync("git", ["clone", "--depth", "1", "--branch", ref, SOURCE_URL, tmp], { stdio: "inherit" }); - return { - sourceSkills: join(tmp, "skills"), - refLabel: ref, - sourceCommit: tryGit(tmp, ["rev-parse", "HEAD"]), - cleanup: () => rmSync(tmp, { recursive: true, force: true }), - }; -} - -function resolveLatestRef() { - const out = execFileSync("git", ["ls-remote", "--tags", "--refs", SOURCE_URL], { encoding: "utf8" }); - const tags = out.split("\n").filter(Boolean).map((l) => l.split("\t")[1].replace("refs/tags/", "")); - const pick = (re) => tags.filter((t) => re.test(t)).sort(semverCmp).pop(); - const ref = pick(/^v\d+\.\d+\.\d+$/) || pick(/^dev-\d+\.\d+\.\d+$/) || "main"; - console.log(`Resolved source ref: ${ref}`); - return ref; -} - -// ---- helpers ---- -function parseArgs(argv) { - const out = {}; - for (let i = 0; i < argv.length; i++) { - if (!argv[i].startsWith("--")) continue; - const key = argv[i].slice(2); - const next = argv[i + 1]; - if (next && !next.startsWith("--")) { out[key] = next; i++; } else { out[key] = true; } - } - return out; -} -function truthy(v) { return v === "1" || v === "true" || v === "yes"; } -function tryGit(cwd, a) { try { return execFileSync("git", a, { cwd, encoding: "utf8" }).trim(); } catch { return ""; } } -function parseVer(v) { const m = String(v).replace(/^v/, "").replace(/^dev-/, "").match(/^(\d+)\.(\d+)\.(\d+)/); return m ? [+m[1], +m[2], +m[3]] : [0, 0, 0]; } -function semverCmp(a, b) { const x = parseVer(a), y = parseVer(b); return x[0] - y[0] || x[1] - y[1] || x[2] - y[2]; } -function bumpVersion(v, kind) { let [a, b, c] = parseVer(v); if (kind === "major") { a++; b = 0; c = 0; } else if (kind === "minor") { b++; c = 0; } else { c++; } return `${a}.${b}.${c}`; } -function readVersion() { try { return JSON.parse(readFileSync(PLUGIN_JSON, "utf8")).version || "0.0.0"; } catch { return "0.0.0"; } } -function writeJson(p, obj) { mkdirSync(dirname(p), { recursive: true }); writeFileSync(p, `${JSON.stringify(obj, null, 2)}\n`); } -function printList(label, arr) { if (arr.length) console.log(` ${label}: ${arr.join(", ")}`); } -function prependChangelog(entry) { - const title = "# Changelog\n\n"; - let body = ""; - if (existsSync(CHANGELOG_PATH)) { - const cur = readFileSync(CHANGELOG_PATH, "utf8"); - body = cur.startsWith(title) ? cur.slice(title.length) : cur; - } - writeFileSync(CHANGELOG_PATH, title + entry + "\n" + body); -} -function emitOutput(o) { - const gh = process.env.GITHUB_OUTPUT; - if (!gh) return; - let s = `changed=${o.changed ? "true" : "false"}\n`; - if (o.changed) s += `version=${o.version}\nsummary=${o.summary}\n`; - appendFileSync(gh, s); -} -function fail(msg) { console.error(`ERROR: ${msg}`); process.exit(1); } From 171a13058a8b1e5c022ac9d43ad6a35fc1d00287 Mon Sep 17 00:00:00 2001 From: Marti Planellas Date: Wed, 28 Oct 2026 17:54:14 +0100 Subject: [PATCH 2/3] ci: bump plugin version and validate on skills Sync PRs (PD-126336) The push-based sync only writes skills/. This workflow runs on the automated/skills-sync PR, bumps every plugin manifest to base+1, updates CHANGELOG.md and runs plugin validate --strict before pushing the bump. Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/sync-release-chores.yml | 58 +++++++++++++++++++ scripts/bump-plugin-version.mjs | 68 +++++++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 .github/workflows/sync-release-chores.yml create mode 100644 scripts/bump-plugin-version.mjs diff --git a/.github/workflows/sync-release-chores.yml b/.github/workflows/sync-release-chores.yml new file mode 100644 index 0000000..d5e96cb --- /dev/null +++ b/.github/workflows/sync-release-chores.yml @@ -0,0 +1,58 @@ +name: Sync release chores + +# Runs on the rolling skills Sync PR. +# Bumps the plugin version and updates CHANGELOG.md on the same branch. +# Files outside skills/ survive later syncs (docs/skills-sync.md, "Human commits on the Sync Branch"). + +on: + pull_request: + types: [opened, synchronize, reopened] + branches: [main] + +permissions: + contents: read + +concurrency: + group: sync-release-chores-${{ github.head_ref }} + cancel-in-progress: true + +jobs: + bump: + if: github.head_ref == 'automated/skills-sync' && github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + steps: + # A push made with GITHUB_TOKEN would not trigger CI on the PR, so use an App token. + - uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + id: app-token + with: + app-id: ${{ secrets.RELEASE_BOT_APP_ID }} + private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }} + + - uses: actions/checkout@v7 + with: + ref: ${{ github.head_ref }} + fetch-depth: 0 + token: ${{ steps.app-token.outputs.token }} + + - uses: actions/setup-node@v7 + with: + node-version: 24 + + - name: Bump version and changelog + env: + BASE_REF: origin/${{ github.base_ref }} + run: node scripts/bump-plugin-version.mjs + + - name: Validate plugin + run: npx --yes @anthropic-ai/claude-code@latest plugin validate . --strict + + - name: Commit + run: | + if git diff --quiet; then + echo "Nothing to bump" + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git commit -am "chore(plugin): bump version for skills sync" + git push diff --git a/scripts/bump-plugin-version.mjs b/scripts/bump-plugin-version.mjs new file mode 100644 index 0000000..ada7267 --- /dev/null +++ b/scripts/bump-plugin-version.mjs @@ -0,0 +1,68 @@ +// Sets every plugin manifest to base version + 1 (patch) and writes the matching CHANGELOG entry. +// Idempotent: the target is derived from the base branch, so re-running on a re-synced PR +// rewrites the same version and replaces the same changelog entry. +import { execFileSync } from "node:child_process"; +import { readFileSync, writeFileSync } from "node:fs"; + +const BASE_REF = process.env.BASE_REF ?? "origin/main"; +// .claude-plugin/plugin.json is the source of truth; the others are kept equal to it. +const SOURCE = ".claude-plugin/plugin.json"; +const MANIFESTS = [ + "plugin.json", + SOURCE, + ".cursor-plugin/plugin.json", + ".github/plugin/plugin.json", + "gemini-extension.json", + "server.json", +]; + +const git = (...args) => execFileSync("git", args, { encoding: "utf8" }).trim(); + +const skillChanges = git("diff", "--name-status", `${BASE_REF}...HEAD`, "--", "skills/") + .split("\n") + .filter(Boolean) + .map((line) => { + const [status, path] = line.split("\t"); + return { status, name: path.split("/")[1] }; + }); + +if (skillChanges.length === 0) { + console.log("No skill changes against base, nothing to bump"); + process.exit(0); +} + +const baseVersion = JSON.parse(git("show", `${BASE_REF}:${SOURCE}`)).version; +const [major, minor, patch] = baseVersion.split(".").map(Number); +const next = `${major}.${minor}.${patch + 1}`; + +for (const file of MANIFESTS) { + const json = JSON.parse(readFileSync(file, "utf8")); + json.version = next; + writeFileSync(file, `${JSON.stringify(json, null, 2)}\n`); +} + +const names = (statuses) => + [...new Set(skillChanges.filter((c) => statuses.includes(c.status[0])).map((c) => c.name))].sort(); +const lines = [ + ["Added", names(["A"])], + ["Updated", names(["M", "R"])], + ["Removed", names(["D"])], +] + .filter(([, list]) => list.length) + .map(([label, list]) => `- ${label}: ${list.join(", ")}`); + +const changelog = readFileSync("CHANGELOG.md", "utf8"); +const heading = `## v${next}`; +const existing = changelog.match(new RegExp(`^${heading.replace(".", "\\.")} — (\\S+)$`, "m")); +// Keep the original date when replacing, so a re-run on another day does not create a diff. +const date = existing?.[1] ?? new Date().toISOString().slice(0, 10); +const entry = `${heading} — ${date}\n${lines.join("\n")}\n\n`; + +const withoutOld = changelog.replace( + new RegExp(`^${heading.replace(".", "\\.")} — .*?\\n\\n(?=## |$)`, "ms"), + "", +); +const [title, ...rest] = withoutOld.split("\n\n"); +writeFileSync("CHANGELOG.md", `${title}\n\n${entry}${rest.join("\n\n")}`); + +console.log(`Bumped ${baseVersion} -> ${next}`); From ef6d1360a99acc9a3fec5e67718bb84a4d8e7e1b Mon Sep 17 00:00:00 2001 From: Marti Planellas Date: Thu, 29 Oct 2026 16:12:49 +0100 Subject: [PATCH 3/3] chore: remove skills.lock.json (PD-126336) Only the removed pull-based sync read and wrote this file. The push-based sync commits touch skills/ only, and nothing else references it. Co-Authored-By: Claude Sonnet 5.5 --- skills.lock.json | 21 --------------------- 1 file changed, 21 deletions(-) delete mode 100644 skills.lock.json diff --git a/skills.lock.json b/skills.lock.json deleted file mode 100644 index 5668bc3..0000000 --- a/skills.lock.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "source": { - "repo": "pandadoc-studio/agentic-hub-skills", - "ref": "dev-0.2.2", - "commit": "c33f9d8e50dc24ca8c18818b6740b43da9ed5b31" - }, - "pluginVersion": "1.0.3", - "syncedAt": "2026-10-23T10:56:41.555Z", - "skills": { - "pandadoc-approval-send": "sha256:84fea1cc5b7cf1a3407c1ed89337f0cfc28523d6c1cf60dfd3e19c66a6eb2c46", - "pandadoc-contract-register": "sha256:9dd7bd53d39b83ba2a53bb0df994b91a67c6329dbeb36e4bf4404d5c31bcd7a4", - "pandadoc-engagement-tracker": "sha256:76f196291a6749f8f1b37af4da466568bc0a2e54f877e26bb8cde4dec3d703eb", - "pandadoc-followup-list": "sha256:a90c4c74162a788ef6f4031aa23261ee10c7810492c4ba9c0544de552953bee8", - "pandadoc-invoice-handoff": "sha256:9e5cc8c033182aed53d8fdc98a7120c05eb1269273bc8d1eace297d8a4c787df", - "pandadoc-nda-fast-send": "sha256:3a2ce72bde311cd27a1071ca31586e8e71d53f9c535d01cc8ddb07ba72473184", - "pandadoc-onboarding-agreement": "sha256:193ab2d5dbf738089fa77ccfeb45367cff1f3c914f09caa533aaa20d099618a8", - "pandadoc-quote-builder": "sha256:2bfe2708df32dda2f3ac9484061943e3f8ad55191dad85cccd0b48b32300f111", - "pandadoc-renewal-radar": "sha256:c915049d5ac19fe3de7958d78f567e145709806dbf3d3ef05d86b80b624de82e", - "pandadoc-signing-velocity": "sha256:28c2771d0fd91c23c8829672d5362610e41823627346143ae192084b226a5022" - } -}