Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/pr-quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,34 @@ jobs:
- name: Check error-reference completeness
run: pnpm check:error-reference

skill-packaging:
name: Skill Packaging
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false

- name: Set up pnpm
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8

- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: .node-version
cache: pnpm

- name: Install dependencies
run: pnpm install --frozen-lockfile

# Packs the `prisma` package the same way publish does and reads the
# skill back out of the tarball: the stamp, the file set, and byte
# equality with the tracked skills/ tree.
- name: Check skill packaging
run: pnpm check:skill-packaging

test:
name: Test
runs-on: ubuntu-latest
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ tmp/
# Build and test output
artifacts/
dist/
# Staged by scripts/stage-skills.mjs at prepack; source of truth is /skills
/packages/prisma/skills/
.publish/
coverage/
.vitest/
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"build:compute": "pnpm --filter @prisma/compute build",
"check:grammar": "turbo run check:grammar",
"check:error-reference": "node scripts/list-error-codes.mjs --verify docs/reference/error-reference.md",
"check:skill-packaging": "node scripts/check-skill-packaging.mjs",
"format": "biome format . --write",
"lint": "biome check . --error-on-warnings",
"lint:fix": "biome check . --write",
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/lib/skills/allowlist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ export const SKILL_SOURCE_PACKAGES: readonly string[] = [
"@prisma/orm-sqlite",
"@prisma/orm-mongo",
"@prisma/composer",
// The CLI's own package: ships prisma-platform-core-concepts, staged
// into the tarball by scripts/stage-skills.mjs at prepack.
"prisma",
];

/** The directory inside a source package's tarball that holds its skill
Expand Down
3 changes: 2 additions & 1 deletion packages/prisma/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
},
"files": [
"dist",
"skills",
"README.md",
"LICENSE"
],
Expand Down Expand Up @@ -43,7 +44,7 @@
"license": "Apache-2.0",
"scripts": {
"build": "tsdown",
"prepack": "pnpm run build",
"prepack": "pnpm run build && node ../../scripts/stage-skills.mjs",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
Expand Down
142 changes: 142 additions & 0 deletions scripts/check-skill-packaging.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
#!/usr/bin/env node
// The `prisma-platform-core-concepts` skill must arrive in the `prisma`
// tarball, carrying the version of the tarball it arrived in.
//
// That claim is only worth as much as the artifact that proves it. The
// manifest can list `"skills"` in `files` while `prepack` failed to stage
// anything; the staged copy can be a leftover from an older version; the
// frontmatter stamp can be missing because someone hand-edited the skill.
// None of those show up in a unit test — they show up in what npm uploads.
// So this check packs the package the same way the publish workflow does and
// reads the skill back out of the tarball.
//
// It also compares the packed skill byte-for-byte against the repo-root
// `skills/` tree, which is the tracked source. If those two ever disagree,
// the tarball is serving different instructions than the repo records.
//
// Usage: node scripts/check-skill-packaging.mjs

import { execFileSync } from "node:child_process";
import {
existsSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
statSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";

import { readSkillFrontmatter } from "./skill-frontmatter.ts";

const PACKAGE = "prisma";

const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const packageDir = join(repoRoot, "packages/prisma");
const skillsRoot = join(repoRoot, "skills");

const failures = [];
function require_(condition, message) {
if (!condition) failures.push(message);
}

/** Every file under `dir`, as paths relative to it, sorted. */
function filesUnder(dir) {
return readdirSync(dir, { recursive: true, encoding: "utf-8" })
.filter((entry) => statSync(join(dir, entry)).isFile())
.sort();
}

/** The skills the repo-root tree says belong to this package. */
function ownedSkillNames() {
return readdirSync(skillsRoot, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.filter((entry) => existsSync(join(skillsRoot, entry.name, "SKILL.md")))
.filter(
(entry) =>
readSkillFrontmatter(
readFileSync(join(skillsRoot, entry.name, "SKILL.md"), "utf-8"),
).library === PACKAGE,
)
.map((entry) => entry.name);
}

const expected = ownedSkillNames();
require_(
expected.length > 0,
`no skill under skills/ declares \`metadata.library: "${PACKAGE}"\` — the frontmatter is what routes a skill into a tarball, so without it nothing ships.`,
);

// pnpm pack, not npm pack: it runs the same `prepack` and rewrites the same
// specifiers a real publish does.
const work = mkdtempSync(join(tmpdir(), "skill-packaging-"));
try {
execFileSync("pnpm", ["pack", "--pack-destination", work], {
cwd: packageDir,
stdio: ["ignore", "ignore", "inherit"],
});
const tarball = readdirSync(work).find((f) => f.endsWith(".tgz"));
if (tarball === undefined)
throw new Error(`pnpm pack produced no tarball for ${PACKAGE}`);
execFileSync("tar", ["xzf", tarball], { cwd: work });
const packedRoot = join(work, "package");

const packedVersion = JSON.parse(
readFileSync(join(packedRoot, "package.json"), "utf-8"),
).version;

for (const name of expected) {
const packedSkillDir = join(packedRoot, "skills", name);
const packedSkill = join(packedSkillDir, "SKILL.md");
if (!existsSync(packedSkill)) {
require_(
false,
`the packed tarball has no skills/${name}/SKILL.md — check that "skills" is in the package's \`files\` and that the \`prepack\` script runs scripts/stage-skills.mjs.`,
);
continue;
}

const { library, libraryVersion } = readSkillFrontmatter(
readFileSync(packedSkill, "utf-8"),
);
require_(
library === PACKAGE,
`the packed skills/${name}/SKILL.md declares metadata.library: "${library}", not "${PACKAGE}".`,
);
require_(
libraryVersion === packedVersion,
`the packed skills/${name}/SKILL.md is stamped metadata.library_version: "${libraryVersion}" but ships in ${PACKAGE}@${packedVersion} — the stamp is what tells a reader which CLI surface the skill describes. Run \`node scripts/set-version.ts ${packedVersion}\`.`,
);

// Byte equality with the tracked source: the tarball must serve exactly
// the instructions the repo records.
const sourceDir = join(skillsRoot, name);
const sourceFiles = filesUnder(sourceDir);
const packedFiles = filesUnder(packedSkillDir);
require_(
sourceFiles.join("\n") === packedFiles.join("\n"),
`the packed skills/${name}/ does not have the same files as ${relative(repoRoot, sourceDir)}/ (packed: ${packedFiles.join(", ")}; source: ${sourceFiles.join(", ")}).`,
);
for (const file of sourceFiles.filter((f) => packedFiles.includes(f))) {
require_(
readFileSync(join(sourceDir, file), "utf-8") ===
readFileSync(join(packedSkillDir, file), "utf-8"),
`the packed skills/${name}/${file} differs from the tracked ${relative(repoRoot, join(sourceDir, file))}.`,
);
}
}
} finally {
rmSync(work, { recursive: true, force: true });
}

if (failures.length > 0) {
process.stderr.write(
`\nFAIL — skill packaging check:\n${failures.map((f) => ` - ${f}\n`).join("")}`,
);
process.exit(1);
}
process.stderr.write(
`\nOK — the ${PACKAGE} tarball carries ${expected.join(", ")}, stamped with its version and identical to skills/.\n`,
);
27 changes: 26 additions & 1 deletion scripts/set-version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
participatesInLockstep,
rewriteWorkspaceDeps,
} from "./set-version-utils.ts";
import { stampSkillVersion } from "./skill-frontmatter.ts";

// Operator rulings: `@prisma/compute` versions independently pending
// extraction to another repo (2026-08-10), and `@prisma/cli-engine`
Expand Down Expand Up @@ -114,4 +115,28 @@ for (const manifestPath of trackedManifests) {
updatedCount++;
}

console.log(`\nDone! Updated ${updatedCount} packages.`);
// Skills ship inside the `prisma` tarball and their frontmatter names
// the version of the CLI they describe (`metadata.library_version`).
// The `prisma` package versions in lockstep, so every skill is stamped
// with the same root version; check-skill-packaging.mjs verifies the
// stamp against the packed tarball.
const skillsDir = path.join(rootDir, "skills");
const skillDirEntries = await fs
.readdir(skillsDir, { withFileTypes: true })
.catch(() => []);
let stampedSkills = 0;
for (const entry of skillDirEntries) {
if (!entry.isDirectory()) continue;
const skillPath = path.join(skillsDir, entry.name, "SKILL.md");
// biome-ignore lint/performance/noAwaitInLoops: each skill prints its "Stamped" line as it is rewritten, so a run that fails part way through leaves an accurate record of which files already changed.
const source = await fs.readFile(skillPath, "utf-8").catch(() => undefined);
if (source === undefined) continue;

await fs.writeFile(skillPath, stampSkillVersion(source, version));
console.log(`Stamped ${path.relative(rootDir, skillPath)} with ${version}`);
stampedSkills++;
}

console.log(
`\nDone! Updated ${updatedCount} packages and ${stampedSkills} skills.`,
);
109 changes: 109 additions & 0 deletions scripts/skill-frontmatter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// Pure helpers for the `metadata.library` / `metadata.library_version`
// frontmatter keys that tie a skill to the npm package it describes.
//
// The skill ships inside the `prisma` tarball, so the version a reader
// sees must be the version they installed. `set-version.ts` stamps
// `library_version` from the same root version it writes into every
// package.json, and `check-skill-packaging.mjs` re-reads it out of the
// packed tarball. Both go through here so there is one definition of
// what the frontmatter looks like.
//
// Under `metadata:` rather than at the top level because the Agent
// Skills spec (agentskills.io) defines the top-level key set and
// reserves `metadata` — a string→string map — for exactly this kind of
// publisher extension. A top-level `library:` would be an undefined key
// that a strict runtime is entitled to reject.

const FRONTMATTER_BLOCK = /^---\r?\n([\s\S]*?)\r?\n---(\r?\n|$)/;

/** The `metadata:` mapping: the key line plus the indented block under it. */
const METADATA_SECTION = /^metadata:[ \t]*\r?\n((?:[ \t]+\S.*(?:\r?\n|$))*)/m;

const LEADING_INDENT = /^[ \t]+/;

function keyPattern(key: string): RegExp {
return new RegExp(`^[ \\t]+${key}:[ \\t]*(.*)$`, "m");
}

function unquote(value: string): string {
const trimmed = value.trim();
const quote = trimmed[0];
if (
(quote === '"' || quote === "'") &&
trimmed.endsWith(quote) &&
trimmed.length >= 2
) {
return trimmed.slice(1, -1);
}
return trimmed;
}

export interface SkillFrontmatter {
/** The npm package the skill ships inside, e.g. `prisma`. */
library?: string;
/** The version of that package, stamped at release time. */
libraryVersion?: string;
}

/** The indented body of the frontmatter's `metadata:` map, if it has one. */
function metadataBlock(source: string): string | undefined {
const frontmatter = FRONTMATTER_BLOCK.exec(source)?.[1];
if (frontmatter === undefined) return undefined;
return METADATA_SECTION.exec(frontmatter)?.[1];
}

/** Read the version-stamp keys out of a `SKILL.md`. Absent keys stay undefined. */
export function readSkillFrontmatter(source: string): SkillFrontmatter {
const metadata = metadataBlock(source);
if (metadata === undefined) return {};

const library = keyPattern("library").exec(metadata)?.[1];
const libraryVersion = keyPattern("library_version").exec(metadata)?.[1];
return {
library: library === undefined ? undefined : unquote(library),
libraryVersion:
libraryVersion === undefined ? undefined : unquote(libraryVersion),
};
}

/**
* Rewrite `metadata.library_version` in a `SKILL.md` to `version`. Idempotent.
*
* Both keys must already be present. Adding them is an authoring decision —
* a skill that ships in a tarball declares which package it ships in — and
* silently inserting them here would let a skill with no `library` key be
* stamped with a version that means nothing.
*/
export function stampSkillVersion(source: string, version: string): string {
if (FRONTMATTER_BLOCK.exec(source)?.[1] === undefined) {
throw new Error("SKILL.md has no YAML frontmatter block.");
}
const metadata = metadataBlock(source);
if (metadata === undefined) {
throw new Error(
"SKILL.md frontmatter has no `metadata` map — the version stamp lives there, because the Agent Skills spec reserves `metadata` for publisher keys and defines the top-level ones.",
);
}
const { library, libraryVersion } = readSkillFrontmatter(source);
if (library === undefined) {
throw new Error(
"SKILL.md frontmatter has no `metadata.library` key — a skill that ships inside a tarball must name the package it ships in.",
);
}
if (libraryVersion === undefined) {
throw new Error(
"SKILL.md frontmatter has no `metadata.library_version` key — add it (any string) so the release pipeline can stamp it.",
);
}

// Function replacements throughout: the skill body and its long
// `description` are prose, and a literal `$&` or `$1` in them would be
// expanded as a capture reference by the string form. The version is
// quoted because `metadata` is a string→string map.
const stamped = metadata.replace(
keyPattern("library_version"),
(line) =>
`${LEADING_INDENT.exec(line)?.[0] ?? " "}library_version: "${version}"`,
);
return source.replace(metadata, () => stamped);
}
Loading
Loading