diff --git a/scripts/generate-skill-metadata.mjs b/scripts/generate-skill-metadata.mjs index 490976fb51..931163e3b1 100644 --- a/scripts/generate-skill-metadata.mjs +++ b/scripts/generate-skill-metadata.mjs @@ -16,8 +16,12 @@ const check = arguments_.has("--check"); const current = ( await readFile(join(sourceRoot, "SKILL.md"), "utf8") ).replaceAll("\r\n", "\n"); +const cacheBust = String(Date.now()); const { CATALOG_IDENTITY } = await import( - `${pathToFileURL(join(root, "dist/catalogIdentity.js")).href}?${String(Date.now())}` + `${pathToFileURL(join(root, "dist/catalogIdentity.js")).href}?${cacheBust}` +); +const { PACKAGE_METADATA } = await import( + `${pathToFileURL(join(root, "dist/generatedPackageMetadata.js")).href}?${cacheBust}` ); if (/^\s{2}(?:tool_count|catalog_digest):/mu.test(current)) throw new Error( @@ -26,10 +30,15 @@ if (/^\s{2}(?:tool_count|catalog_digest):/mu.test(current)) const versionLine = /^ {2}version: "[^"\r\n]+"$/mu; if (!versionLine.test(current)) throw new Error("Missing authored skill version"); -const source = current.replace( - versionLine, - `$&\n tool_count: ${String(CATALOG_IDENTITY.counts.mcp_tools)}`, -); +// A shipped skill must run the version it ships with, not whatever `@latest` resolves to. +const latestSpecifier = `${PACKAGE_METADATA.name}@latest`; +const pinnedSpecifier = `${PACKAGE_METADATA.name}@${PACKAGE_METADATA.version}`; +const source = current + .replace( + versionLine, + `$&\n tool_count: ${String(CATALOG_IDENTITY.counts.mcp_tools)}`, + ) + .replaceAll(latestSpecifier, pinnedSpecifier); const paths = await filePaths(sourceRoot); // Rebuild this owned output directory so removed references cannot survive a build. if (!check) await rm(outputRoot, { recursive: true, force: true }); diff --git a/tests/boundary/filesystem/dependencyInstall.test.ts b/tests/boundary/filesystem/dependencyInstall.test.ts index 18eb5f95aa..967d1a8390 100644 --- a/tests/boundary/filesystem/dependencyInstall.test.ts +++ b/tests/boundary/filesystem/dependencyInstall.test.ts @@ -100,7 +100,7 @@ describe("dependency install freshness", () => { it("checks installed dependencies before restoring a cached build", async () => { const root = await createBuildCacheFixture(); const seeded = await runNpm(root, ["run", "build:cached"]); - expect(seeded.status, seeded.stderr).toBe(0); + expect(seeded.status, `${seeded.stdout}${seeded.stderr}`).toBe(0); const rootLock = asRecord( JSON.parse(await readFile(join(root, "package-lock.json"), "utf8")), @@ -189,7 +189,13 @@ const createBuildCacheFixture = async (): Promise => { ); await writeFile( join(root, "scripts/cache-fixture-build.mjs"), - 'import { mkdir, writeFile } from "node:fs/promises"; await mkdir("dist", { recursive: true }); await writeFile("dist/cache-fixture.js", "built"); await writeFile("dist/catalogIdentity.js", "export const CATALOG_IDENTITY = { counts: { mcp_tools: 1 } };\\n");\n', + `import { mkdir, readFile, writeFile } from "node:fs/promises"; +const { name, version } = JSON.parse(await readFile("package.json", "utf8")); +await mkdir("dist", { recursive: true }); +await writeFile("dist/cache-fixture.js", "built"); +await writeFile("dist/catalogIdentity.js", "export const CATALOG_IDENTITY = { counts: { mcp_tools: 1 } };\\n"); +await writeFile("dist/generatedPackageMetadata.js", "export const PACKAGE_METADATA = " + JSON.stringify({ name, version }) + ";\\n"); +`, ); await createNodeModulesLinks(root); await writeFile( diff --git a/tests/boundary/filesystem/generatedSkill.test.ts b/tests/boundary/filesystem/generatedSkill.test.ts index c5db95c1a0..f35e0abb64 100644 --- a/tests/boundary/filesystem/generatedSkill.test.ts +++ b/tests/boundary/filesystem/generatedSkill.test.ts @@ -26,6 +26,10 @@ async function fixture(eol = "\n") { await mkdir(join(sourceRoot, "references"), { recursive: true }); await mkdir(join(root, "dist")); await writeFile(join(root, "package.json"), '{"type":"module"}\n'); + await writeFile( + join(root, "dist/generatedPackageMetadata.js"), + 'export const PACKAGE_METADATA = { name: "rea-agents", version: "6.2.0" };\n', + ); await writeFile(join(sourceRoot, "SKILL.md"), authored.replaceAll("\n", eol)); await writeFile( join(sourceRoot, "references/guide.md"), @@ -111,6 +115,22 @@ it("rejects missing, stale, and extra packaged references and removes deleted so await f.generate("--check"); }); +it("pins authored `@latest` commands to the packaged version", async () => { + const f = await fixture(); + await writeFile( + join(f.sourceRoot, "SKILL.md"), + authored.replace( + "Inspect the caller-selected artifact.", + "Run `npx -y rea-agents@latest doctor --json` first.", + ), + ); + await f.generate(); + const generated = await readFile(join(f.outputRoot, "SKILL.md"), "utf8"); + expect(generated).toContain("npx -y rea-agents@6.2.0 doctor --json"); + expect(generated).not.toContain("rea-agents@latest"); + await f.generate("--check"); +}); + it("rejects catalog-dependent metadata in the authored source", async () => { const f = await fixture(); await writeFile(