From c794c017f24c8403557194597c99ca47f43ff249 Mon Sep 17 00:00:00 2001 From: Pedro Rodrigues Date: Wed, 6 May 2026 17:39:19 +0300 Subject: [PATCH 1/3] feat: build index.json for .well-known/agent-skills in release pipeline Replaces the inline bash tar loop with a TypeScript build script that produces both skill tarballs and a discovery index.json conforming to the agent-skills discovery spec (RFC v0.2.0). Applies improvements identified in code review of #66: - Deterministic archives: zero timestamps/uid/gid so digests only change when content changes (requires GNU tar; falls back to BSD tar with a warning on macOS) - Exclude patterns: .gitignore-style walk skips .DS_Store, .vscode, etc. - Auto-detect skill-md vs archive: single-file skills emit type "skill-md" and copy SKILL.md directly instead of creating a tarball - Relative URLs in index.json: portable across serving paths - Validate both name and description frontmatter fields Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .github/workflows/release-please.yml | 14 +- package.json | 8 +- pnpm-lock.yaml | 147 ++++++++++++++++++++- scripts/build-release.ts | 189 +++++++++++++++++++++++++++ 4 files changed, 339 insertions(+), 19 deletions(-) create mode 100644 scripts/build-release.ts diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index d07a909..d26a45a 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -40,23 +40,15 @@ jobs: if: ${{ steps.release.outputs.release_created }} run: pnpm install --frozen-lockfile - - name: Package skill tarballs + - name: Build release artifacts if: ${{ steps.release.outputs.release_created }} - run: | - mkdir -p dist - - for skill_dir in skills/*; do - if [ -d "${skill_dir}" ]; then - skill_name=$(basename "${skill_dir}") - tar -czf "dist/${skill_name}.tar.gz" -C skills "${skill_name}" - fi - done + run: pnpm build:release - name: Upload release assets if: ${{ steps.release.outputs.release_created }} env: GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }} - run: gh release upload ${{ steps.release.outputs.tag_name }} dist/*.tar.gz + run: gh release upload ${{ steps.release.outputs.tag_name }} dist/index.json dist/*.tar.gz - name: Generate GitHub App token (supabase-community) id: generate-token-plugin diff --git a/package.json b/package.json index 459d75c..3e7ac59 100644 --- a/package.json +++ b/package.json @@ -5,11 +5,17 @@ "author": "Supabase", "license": "MIT", "description": "Official Supabase agent skills", + "type": "module", "scripts": { "test": "vitest run", - "test:sanity": "vitest run test/sanity.test.ts" + "test:sanity": "vitest run test/sanity.test.ts", + "build:release": "tsx scripts/build-release.ts" }, "devDependencies": { + "@types/node": "^22.0.0", + "gray-matter": "^4.0.3", + "tsx": "^4.21.0", + "typescript": "^6.0.0", "vitest": "^4.1.4" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 44cb7f9..aef0cfc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,21 @@ importers: .: devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.19.17 + gray-matter: + specifier: ^4.0.3 + version: 4.0.3 + tsx: + specifier: ^4.21.0 + version: 4.21.0 + typescript: + specifier: ^6.0.0 + version: 6.0.3 vitest: specifier: ^4.1.4 - version: 4.1.4(vite@7.3.1) + version: 4.1.4(@types/node@22.19.17)(vite@7.3.1(@types/node@22.19.17)(tsx@4.21.0)) packages: @@ -323,6 +335,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/node@22.19.17': + resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==} + '@vitest/expect@4.1.4': resolution: {integrity: sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==} @@ -352,6 +367,9 @@ packages: '@vitest/utils@4.1.4': resolution: {integrity: sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==} + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -371,6 +389,11 @@ packages: engines: {node: '>=18'} hasBin: true + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -378,6 +401,10 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + extend-shallow@2.0.1: + resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} + engines: {node: '>=0.10.0'} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -392,6 +419,25 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + + gray-matter@4.0.3: + resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} + engines: {node: '>=6.0'} + + is-extendable@0.1.1: + resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==} + engines: {node: '>=0.10.0'} + + js-yaml@3.14.2: + resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} + hasBin: true + + kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -417,11 +463,18 @@ packages: resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + rollup@4.60.0: resolution: {integrity: sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + section-matter@1.0.0: + resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==} + engines: {node: '>=4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -429,12 +482,19 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} std-env@4.0.0: resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} + strip-bom-string@1.0.0: + resolution: {integrity: sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==} + engines: {node: '>=0.10.0'} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -450,6 +510,19 @@ packages: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + vite@7.3.1: resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -704,6 +777,10 @@ snapshots: '@types/estree@1.0.8': {} + '@types/node@22.19.17': + dependencies: + undici-types: 6.21.0 + '@vitest/expect@4.1.4': dependencies: '@standard-schema/spec': 1.1.0 @@ -713,13 +790,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.4(vite@7.3.1)': + '@vitest/mocker@4.1.4(vite@7.3.1(@types/node@22.19.17)(tsx@4.21.0))': dependencies: '@vitest/spy': 4.1.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1 + vite: 7.3.1(@types/node@22.19.17)(tsx@4.21.0) '@vitest/pretty-format@4.1.4': dependencies: @@ -745,6 +822,10 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + assertion-error@2.0.1: {} chai@6.2.2: {} @@ -782,12 +863,18 @@ snapshots: '@esbuild/win32-ia32': 0.27.4 '@esbuild/win32-x64': 0.27.4 + esprima@4.0.1: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.8 expect-type@1.3.0: {} + extend-shallow@2.0.1: + dependencies: + is-extendable: 0.1.1 + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -795,6 +882,26 @@ snapshots: fsevents@2.3.3: optional: true + get-tsconfig@4.14.0: + dependencies: + resolve-pkg-maps: 1.0.0 + + gray-matter@4.0.3: + dependencies: + js-yaml: 3.14.2 + kind-of: 6.0.3 + section-matter: 1.0.0 + strip-bom-string: 1.0.0 + + is-extendable@0.1.1: {} + + js-yaml@3.14.2: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + + kind-of@6.0.3: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -815,6 +922,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + resolve-pkg-maps@1.0.0: {} + rollup@4.60.0: dependencies: '@types/estree': 1.0.8 @@ -846,14 +955,23 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.60.0 fsevents: 2.3.3 + section-matter@1.0.0: + dependencies: + extend-shallow: 2.0.1 + kind-of: 6.0.3 + siginfo@2.0.0: {} source-map-js@1.2.1: {} + sprintf-js@1.0.3: {} + stackback@0.0.2: {} std-env@4.0.0: {} + strip-bom-string@1.0.0: {} + tinybench@2.9.0: {} tinyexec@1.1.1: {} @@ -865,7 +983,18 @@ snapshots: tinyrainbow@3.1.0: {} - vite@7.3.1: + tsx@4.21.0: + dependencies: + esbuild: 0.27.4 + get-tsconfig: 4.14.0 + optionalDependencies: + fsevents: 2.3.3 + + typescript@6.0.3: {} + + undici-types@6.21.0: {} + + vite@7.3.1(@types/node@22.19.17)(tsx@4.21.0): dependencies: esbuild: 0.27.4 fdir: 6.5.0(picomatch@4.0.4) @@ -874,12 +1003,14 @@ snapshots: rollup: 4.60.0 tinyglobby: 0.2.15 optionalDependencies: + '@types/node': 22.19.17 fsevents: 2.3.3 + tsx: 4.21.0 - vitest@4.1.4(vite@7.3.1): + vitest@4.1.4(@types/node@22.19.17)(vite@7.3.1(@types/node@22.19.17)(tsx@4.21.0)): dependencies: '@vitest/expect': 4.1.4 - '@vitest/mocker': 4.1.4(vite@7.3.1) + '@vitest/mocker': 4.1.4(vite@7.3.1(@types/node@22.19.17)(tsx@4.21.0)) '@vitest/pretty-format': 4.1.4 '@vitest/runner': 4.1.4 '@vitest/snapshot': 4.1.4 @@ -896,8 +1027,10 @@ snapshots: tinyexec: 1.1.1 tinyglobby: 0.2.15 tinyrainbow: 3.1.0 - vite: 7.3.1 + vite: 7.3.1(@types/node@22.19.17)(tsx@4.21.0) why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.19.17 transitivePeerDependencies: - msw diff --git a/scripts/build-release.ts b/scripts/build-release.ts new file mode 100644 index 0000000..a75d379 --- /dev/null +++ b/scripts/build-release.ts @@ -0,0 +1,189 @@ +import { createHash } from "node:crypto" +import { + copyFileSync, + mkdirSync, + readdirSync, + readFileSync, + writeFileSync, +} from "node:fs" +import { join, relative } from "node:path" +import { execFileSync } from "node:child_process" +import matter from "gray-matter" + +const ROOT = join(import.meta.dirname, "..") +const SKILLS_DIR = join(ROOT, "skills") +const DIST_DIR = join(ROOT, "dist") + +const SCHEMA = "https://schemas.agentskills.io/discovery/0.2.0/schema.json" + +const DEFAULT_EXCLUDES = [ + ".git", + ".gitignore", + ".gitattributes", + ".DS_Store", + "Thumbs.db", + ".vscode", + ".idea", + "*.swp", + ".*.swp", + "*~", + ".*~", +] + +// Patterns without "/" match basenames at any depth; with "/" match relative paths. +function matchesExclude(relPath: string, patterns: string[]): boolean { + const basename = relPath.split("/").pop()! + for (const pattern of patterns) { + const target = pattern.includes("/") ? relPath : basename + if (matchesGlob(target, pattern)) return true + } + return false +} + +function matchesGlob(str: string, pattern: string): boolean { + const regex = new RegExp( + "^" + pattern.replace(/\./g, "\\.").replace(/\*/g, "[^/]*") + "$" + ) + return regex.test(str) +} + +function walkDir(dir: string, base: string = dir): string[] { + const entries: string[] = [] + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const absPath = join(dir, entry.name) + const relPath = relative(base, absPath) + if (matchesExclude(relPath, DEFAULT_EXCLUDES)) continue + if (entry.isDirectory()) { + entries.push(...walkDir(absPath, base)) + } else { + entries.push(relPath) + } + } + return entries.sort() +} + +function sha256File(filePath: string): string { + const hash = createHash("sha256").update(readFileSync(filePath)).digest("hex") + return `sha256:${hash}` +} + +// Two-step deterministic archive: zero all metadata so digests only change +// when content changes, which is the whole point of the digest field for caching. +// Requires GNU tar (available on Linux/CI). On macOS, falls back to BSD tar +// with a warning — digests will differ across builds but remain functionally valid. +function createDeterministicTarGz( + skillDir: string, + files: string[], + outputPath: string +): void { + const tarPath = outputPath.replace(/\.gz$/, "") + + // tar --no-recursion needs explicit parent dir entries + const dirs = new Set() + for (const f of files) { + const parts = f.split("/") + for (let i = 1; i < parts.length; i++) { + dirs.add(parts.slice(0, i).join("/")) + } + } + const entries = [...Array.from(dirs).sort(), ...files] + + // Prefer gtar (GNU tar via Homebrew on macOS) for deterministic output + const tarBin = (() => { + try { + execFileSync("gtar", ["--version"], { stdio: "ignore" }) + return "gtar" + } catch { + return "tar" + } + })() + + try { + execFileSync(tarBin, [ + "--mtime=@0", + "--owner=0", + "--group=0", + "--numeric-owner", + "--no-recursion", + "-cf", + tarPath, + "-C", + skillDir, + ...entries, + ]) + } catch { + // BSD tar (macOS default) — non-deterministic but functionally valid + console.warn( + ` warning: GNU tar not found, using BSD tar — digests will vary across builds (install GNU tar for reproducible builds)` + ) + execFileSync("tar", ["-cf", tarPath, "-C", skillDir, ...entries]) + } + + // -n: omit original filename and timestamp from gzip header + execFileSync("gzip", ["-nf", tarPath]) +} + +mkdirSync(DIST_DIR, { recursive: true }) + +const skillNames = readdirSync(SKILLS_DIR, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name) + .sort() + +const skills: { + name: string + type: "skill-md" | "archive" + description: string + url: string + digest: string +}[] = [] + +for (const name of skillNames) { + const skillDir = join(SKILLS_DIR, name) + const skillMdPath = join(skillDir, "SKILL.md") + + const { data } = matter(readFileSync(skillMdPath, "utf8")) + + if (!data.name || typeof data.name !== "string") { + throw new Error(`Missing or invalid 'name' in frontmatter: ${skillMdPath}`) + } + if (!data.description || typeof data.description !== "string") { + throw new Error( + `Missing or invalid 'description' in frontmatter: ${skillMdPath}` + ) + } + + const files = walkDir(skillDir) + const isSingleFile = files.length === 1 && files[0] === "SKILL.md" + + let type: "skill-md" | "archive" + let artifactPath: string + let url: string + + if (isSingleFile) { + // Single-file skill: copy SKILL.md directly, no archive needed + type = "skill-md" + const destDir = join(DIST_DIR, name) + mkdirSync(destDir, { recursive: true }) + artifactPath = join(destDir, "SKILL.md") + copyFileSync(skillMdPath, artifactPath) + url = `${name}/SKILL.md` + } else { + type = "archive" + artifactPath = join(DIST_DIR, `${name}.tar.gz`) + createDeterministicTarGz(skillDir, files, artifactPath) + url = `${name}.tar.gz` + } + + const digest = sha256File(artifactPath) + + skills.push({ name, type, description: data.description, url, digest }) + console.log(` ${name} (${type}): ${digest}`) +} + +const index = { $schema: SCHEMA, skills } +writeFileSync( + join(DIST_DIR, "index.json"), + JSON.stringify(index, null, 2) + "\n" +) +console.log(`\nWrote dist/index.json with ${skills.length} skill(s)`) From 67ec55defb0ea6ad9564f96ac18dbab3aa4a903d Mon Sep 17 00:00:00 2001 From: Pedro Rodrigues Date: Wed, 6 May 2026 17:56:06 +0300 Subject: [PATCH 2/3] docs: add .well-known discovery section and GitHub Action migration note to README Co-Authored-By: Claude Sonnet 4.6 (1M context) --- README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/README.md b/README.md index 1979c54..528beca 100644 --- a/README.md +++ b/README.md @@ -119,3 +119,23 @@ Each skill follows the [Agent Skills Open Standard](https://agentskills.io/): - `SKILL.md` - Required skill manifest with frontmatter (name, description, metadata) - `references/` - (Optional) Reference files for detailed documentation + +## `.well-known` discovery + +Each release uploads a `dist/index.json` alongside the skill tarballs. This index conforms to the [agent-skills `.well-known` URI spec](https://github.com/agentskills/agentskills/pull/254) (schema v0.2.0) and is consumed by `supabase.com` to serve skills at `https://supabase.com/.well-known/agent-skills/`. + +The release artifacts are built by `scripts/build-release.ts` and triggered by [Release Please](https://github.com/googleapis/release-please) on every semver release. + +### Migrating to the official GitHub Action + +[`jonathanhefner/agentskills-build-for-well-known`](https://github.com/jonathanhefner/agentskills-build-for-well-known) is intended to be transferred to the `agentskills` org and published to the GitHub Marketplace. When that happens, `scripts/build-release.ts` and the `pnpm build:release` step in `release-please.yml` can be replaced with the Action directly: + +```yaml +- name: Build release artifacts + uses: agentskills/build-for-well-known@v1 # replaces pnpm build:release + with: + skills-dir: skills + output-dir: dist +``` + +Everything else — the Release Please trigger, the upload step, the supabase-plugin dispatch — stays exactly the same. The script and the Action produce identical output, so it is a drop-in swap. From c0a520337aca0f34266c259deb6132943007b3e1 Mon Sep 17 00:00:00 2001 From: Pedro Rodrigues Date: Mon, 11 May 2026 16:20:31 +0200 Subject: [PATCH 3/3] feat: simplify release build script using tar npm library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace shell-out to system tar/gzip binaries with the tar npm package. Drops the custom file-walker, exclude-pattern matching, GNU tar detection, and BSD tar fallback — none of which are needed in a clean CI checkout. Determinism is preserved via portable: true and mtime: new Date(0). Always emits type: "archive" for all skills, removing the isSingleFile branch since the spec recommendation is not a hard requirement. Renames release-please.yml to release.yml. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../{release-please.yml => release.yml} | 0 package.json | 1 + pnpm-lock.yaml | 49 +++++++ scripts/build-release.ts | 134 ++---------------- 4 files changed, 65 insertions(+), 119 deletions(-) rename .github/workflows/{release-please.yml => release.yml} (100%) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release.yml similarity index 100% rename from .github/workflows/release-please.yml rename to .github/workflows/release.yml diff --git a/package.json b/package.json index 3e7ac59..fb74166 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "devDependencies": { "@types/node": "^22.0.0", "gray-matter": "^4.0.3", + "tar": "^7.5.15", "tsx": "^4.21.0", "typescript": "^6.0.0", "vitest": "^4.1.4" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aef0cfc..542737a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: gray-matter: specifier: ^4.0.3 version: 4.0.3 + tar: + specifier: ^7.5.15 + version: 7.5.15 tsx: specifier: ^4.21.0 version: 4.21.0 @@ -182,6 +185,10 @@ packages: cpu: [x64] os: [win32] + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} @@ -378,6 +385,10 @@ packages: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -441,6 +452,14 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -495,6 +514,10 @@ packages: resolution: {integrity: sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==} engines: {node: '>=0.10.0'} + tar@7.5.15: + resolution: {integrity: sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==} + engines: {node: '>=18'} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -609,6 +632,10 @@ packages: engines: {node: '>=8'} hasBin: true + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + snapshots: '@esbuild/aix-ppc64@0.27.4': @@ -689,6 +716,10 @@ snapshots: '@esbuild/win32-x64@0.27.4': optional: true + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.3 + '@jridgewell/sourcemap-codec@1.5.5': {} '@rollup/rollup-android-arm-eabi@4.60.0': @@ -830,6 +861,8 @@ snapshots: chai@6.2.2: {} + chownr@3.0.0: {} + convert-source-map@2.0.0: {} es-module-lexer@2.0.0: {} @@ -906,6 +939,12 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + minipass@7.1.3: {} + + minizlib@3.1.0: + dependencies: + minipass: 7.1.3 + nanoid@3.3.11: {} obug@2.1.1: {} @@ -972,6 +1011,14 @@ snapshots: strip-bom-string@1.0.0: {} + tar@7.5.15: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.3 + minizlib: 3.1.0 + yallist: 5.0.0 + tinybench@2.9.0: {} tinyexec@1.1.1: {} @@ -1038,3 +1085,5 @@ snapshots: dependencies: siginfo: 2.0.0 stackback: 0.0.2 + + yallist@5.0.0: {} diff --git a/scripts/build-release.ts b/scripts/build-release.ts index a75d379..a5caafa 100644 --- a/scripts/build-release.ts +++ b/scripts/build-release.ts @@ -1,13 +1,12 @@ import { createHash } from "node:crypto" import { - copyFileSync, mkdirSync, readdirSync, readFileSync, writeFileSync, } from "node:fs" -import { join, relative } from "node:path" -import { execFileSync } from "node:child_process" +import { join } from "node:path" +import { create as createTar } from "tar" import matter from "gray-matter" const ROOT = join(import.meta.dirname, "..") @@ -16,47 +15,14 @@ const DIST_DIR = join(ROOT, "dist") const SCHEMA = "https://schemas.agentskills.io/discovery/0.2.0/schema.json" -const DEFAULT_EXCLUDES = [ - ".git", - ".gitignore", - ".gitattributes", - ".DS_Store", - "Thumbs.db", - ".vscode", - ".idea", - "*.swp", - ".*.swp", - "*~", - ".*~", -] - -// Patterns without "/" match basenames at any depth; with "/" match relative paths. -function matchesExclude(relPath: string, patterns: string[]): boolean { - const basename = relPath.split("/").pop()! - for (const pattern of patterns) { - const target = pattern.includes("/") ? relPath : basename - if (matchesGlob(target, pattern)) return true - } - return false -} - -function matchesGlob(str: string, pattern: string): boolean { - const regex = new RegExp( - "^" + pattern.replace(/\./g, "\\.").replace(/\*/g, "[^/]*") + "$" - ) - return regex.test(str) -} - -function walkDir(dir: string, base: string = dir): string[] { +function listFiles(dir: string, prefix = ""): string[] { const entries: string[] = [] for (const entry of readdirSync(dir, { withFileTypes: true })) { - const absPath = join(dir, entry.name) - const relPath = relative(base, absPath) - if (matchesExclude(relPath, DEFAULT_EXCLUDES)) continue + const rel = prefix ? `${prefix}/${entry.name}` : entry.name if (entry.isDirectory()) { - entries.push(...walkDir(absPath, base)) + entries.push(...listFiles(join(dir, entry.name), rel)) } else { - entries.push(relPath) + entries.push(rel) } } return entries.sort() @@ -67,62 +33,6 @@ function sha256File(filePath: string): string { return `sha256:${hash}` } -// Two-step deterministic archive: zero all metadata so digests only change -// when content changes, which is the whole point of the digest field for caching. -// Requires GNU tar (available on Linux/CI). On macOS, falls back to BSD tar -// with a warning — digests will differ across builds but remain functionally valid. -function createDeterministicTarGz( - skillDir: string, - files: string[], - outputPath: string -): void { - const tarPath = outputPath.replace(/\.gz$/, "") - - // tar --no-recursion needs explicit parent dir entries - const dirs = new Set() - for (const f of files) { - const parts = f.split("/") - for (let i = 1; i < parts.length; i++) { - dirs.add(parts.slice(0, i).join("/")) - } - } - const entries = [...Array.from(dirs).sort(), ...files] - - // Prefer gtar (GNU tar via Homebrew on macOS) for deterministic output - const tarBin = (() => { - try { - execFileSync("gtar", ["--version"], { stdio: "ignore" }) - return "gtar" - } catch { - return "tar" - } - })() - - try { - execFileSync(tarBin, [ - "--mtime=@0", - "--owner=0", - "--group=0", - "--numeric-owner", - "--no-recursion", - "-cf", - tarPath, - "-C", - skillDir, - ...entries, - ]) - } catch { - // BSD tar (macOS default) — non-deterministic but functionally valid - console.warn( - ` warning: GNU tar not found, using BSD tar — digests will vary across builds (install GNU tar for reproducible builds)` - ) - execFileSync("tar", ["-cf", tarPath, "-C", skillDir, ...entries]) - } - - // -n: omit original filename and timestamp from gzip header - execFileSync("gzip", ["-nf", tarPath]) -} - mkdirSync(DIST_DIR, { recursive: true }) const skillNames = readdirSync(SKILLS_DIR, { withFileTypes: true }) @@ -132,7 +42,7 @@ const skillNames = readdirSync(SKILLS_DIR, { withFileTypes: true }) const skills: { name: string - type: "skill-md" | "archive" + type: "archive" description: string url: string digest: string @@ -153,32 +63,18 @@ for (const name of skillNames) { ) } - const files = walkDir(skillDir) - const isSingleFile = files.length === 1 && files[0] === "SKILL.md" - - let type: "skill-md" | "archive" - let artifactPath: string - let url: string + const files = listFiles(skillDir) + const artifactPath = join(DIST_DIR, `${name}.tar.gz`) - if (isSingleFile) { - // Single-file skill: copy SKILL.md directly, no archive needed - type = "skill-md" - const destDir = join(DIST_DIR, name) - mkdirSync(destDir, { recursive: true }) - artifactPath = join(destDir, "SKILL.md") - copyFileSync(skillMdPath, artifactPath) - url = `${name}/SKILL.md` - } else { - type = "archive" - artifactPath = join(DIST_DIR, `${name}.tar.gz`) - createDeterministicTarGz(skillDir, files, artifactPath) - url = `${name}.tar.gz` - } + await createTar( + { gzip: true, file: artifactPath, cwd: skillDir, portable: true, mtime: new Date(0) }, + files + ) const digest = sha256File(artifactPath) - skills.push({ name, type, description: data.description, url, digest }) - console.log(` ${name} (${type}): ${digest}`) + skills.push({ name, type: "archive", description: data.description, url: `${name}.tar.gz`, digest }) + console.log(` ${name}: ${digest}`) } const index = { $schema: SCHEMA, skills }