diff --git a/eng/external-plugin-intake.mjs b/eng/external-plugin-intake.mjs index 372c73397..1a945b41a 100644 --- a/eng/external-plugin-intake.mjs +++ b/eng/external-plugin-intake.mjs @@ -57,10 +57,13 @@ const LEGACY_FIELD_TITLES = Object.freeze({ }); const EXTERNAL_CANVAS_KEYWORD = "canvas"; const EXTERNAL_CANVAS_PREVIEW_PATH = "assets/preview.png"; +// NOTE: Keep in sync with PLUGIN_JSON_CANDIDATES in external-plugin-quality-gates.mjs +// and EXTERNAL_PLUGIN_ROOT_MANIFEST_PATHS in external-plugin-validation.mjs. const EXTERNAL_PLUGIN_ROOT_MANIFEST_PATHS = Object.freeze([ ".github/plugin/plugin.json", ".plugin/plugin.json", "plugin.json", + ".claude-plugin/plugin.json", ]); function normalizeMultilineText(value) { diff --git a/eng/external-plugin-quality-gates.mjs b/eng/external-plugin-quality-gates.mjs index 014f9b83f..25cedd826 100644 --- a/eng/external-plugin-quality-gates.mjs +++ b/eng/external-plugin-quality-gates.mjs @@ -148,11 +148,21 @@ function cloneSubmissionRepository(workDir, plugin) { // Both the Copilot CLI and many external repos use nested conventions. We read the // manifest ourselves so skill paths can be resolved from the plugin root consistently, // regardless of where the manifest lives. -// NOTE: Keep in sync with EXTERNAL_PLUGIN_ROOT_MANIFEST_PATHS in external-plugin-validation.mjs +// NOTE: Keep this SET in sync with EXTERNAL_PLUGIN_ROOT_MANIFEST_PATHS in +// external-plugin-validation.mjs (used only to format the expected-location error +// message) and in external-plugin-intake.mjs (used to resolve canvas plugin +// metadata). Those two orderings already differ from this one; ordering is +// precedence only where a list actually resolves a manifest. +// +// These four directories are the same set the Copilot CLI searches for a plugin +// manifest, so a plugin the CLI can install resolves here too. `.claude-plugin` +// is last: a repo shipping any of the three original locations keeps resolving +// to that one. const PLUGIN_JSON_CANDIDATES = [ [".github", "plugin", "plugin.json"], [".plugin", "plugin.json"], ["plugin.json"], + [".claude-plugin", "plugin.json"], ]; function toPosixPath(...segments) { diff --git a/eng/external-plugin-quality-gates.test.mjs b/eng/external-plugin-quality-gates.test.mjs index b24d3037c..065e0fd1b 100644 --- a/eng/external-plugin-quality-gates.test.mjs +++ b/eng/external-plugin-quality-gates.test.mjs @@ -333,3 +333,82 @@ test("runRefShaConsistencyGate passes when ref and sha point to the same commit" const result = runRefShaConsistencyGate(repoDir, plugin, sha); assert.equal(result.status, "pass", result.output); }); + +// Manifest-location coverage: `.claude-plugin/plugin.json` is one of the four +// directories the Copilot CLI itself searches for a plugin manifest, so a plugin the +// CLI can install resolves here too. It resolves last, so a repo shipping one of the +// three original locations keeps resolving to that one. + +function writePluginManifestAt(repoDir, pluginPath, manifestRelativePath, manifest) { + const manifestPath = path.join(repoDir, pluginPath, manifestRelativePath); + fs.mkdirSync(path.dirname(manifestPath), { recursive: true }); + fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); +} + +test("runVersionMatchGate resolves a manifest at .claude-plugin/plugin.json", () => { + const remoteDir = initRemoteRepo(); + writePluginManifestAt(remoteDir, "plugins/my-plugin", ".claude-plugin/plugin.json", { + name: "my-plugin", + version: "1.0.0", + }); + const sha = commitAll(remoteDir, "Add Claude Code spec plugin manifest"); + + const repoDir = cloneSubmissionRepo(remoteDir, sha); + const plugin = { + name: "my-plugin", + version: "1.0.0", + source: { source: "github", repo: "owner/repo", path: "plugins/my-plugin", sha }, + }; + + const result = runVersionMatchGate(repoDir, plugin, sha); + assert.equal(result.status, "pass", result.output); + assert.match(result.output, /matched version "1\.0\.0" at "plugins\/my-plugin\/\.claude-plugin\/plugin\.json"/); +}); + +test("runVersionMatchGate still fails when no manifest exists in any known location", () => { + const remoteDir = initRemoteRepo(); + fs.mkdirSync(path.join(remoteDir, "plugins", "my-plugin"), { recursive: true }); + fs.writeFileSync(path.join(remoteDir, "plugins", "my-plugin", "README.md"), "no manifest here\n"); + const sha = commitAll(remoteDir, "Add plugin folder without a manifest"); + + const repoDir = cloneSubmissionRepo(remoteDir, sha); + const plugin = { + name: "my-plugin", + version: "1.0.0", + source: { source: "github", repo: "owner/repo", path: "plugins/my-plugin", sha }, + }; + + const result = runVersionMatchGate(repoDir, plugin, sha); + assert.equal(result.status, "fail", result.output); + assert.match(result.output, /No plugin\.json found/); + // The failure message must enumerate every supported location, including the new one. + assert.match(result.output, /plugins\/my-plugin\/\.github\/plugin\/plugin\.json/); + assert.match(result.output, /plugins\/my-plugin\/\.plugin\/plugin\.json/); + assert.match(result.output, /plugins\/my-plugin\/plugin\.json/); + assert.match(result.output, /plugins\/my-plugin\/\.claude-plugin\/plugin\.json/); +}); + +test("runVersionMatchGate prefers .github/plugin/plugin.json over .claude-plugin/plugin.json", () => { + const remoteDir = initRemoteRepo(); + writePluginManifestAt(remoteDir, "plugins/my-plugin", ".github/plugin/plugin.json", { + name: "my-plugin", + version: "1.0.0", + }); + writePluginManifestAt(remoteDir, "plugins/my-plugin", ".claude-plugin/plugin.json", { + name: "my-plugin", + version: "9.9.9", + }); + const sha = commitAll(remoteDir, "Add both manifest locations"); + + const repoDir = cloneSubmissionRepo(remoteDir, sha); + const plugin = { + name: "my-plugin", + version: "1.0.0", + source: { source: "github", repo: "owner/repo", path: "plugins/my-plugin", sha }, + }; + + const result = runVersionMatchGate(repoDir, plugin, sha); + assert.equal(result.status, "pass", result.output); + assert.match(result.output, /at "plugins\/my-plugin\/\.github\/plugin\/plugin\.json"/); + assert.doesNotMatch(result.output, /\.claude-plugin/); +}); diff --git a/eng/external-plugin-validation.mjs b/eng/external-plugin-validation.mjs index 07f14c256..931365770 100644 --- a/eng/external-plugin-validation.mjs +++ b/eng/external-plugin-validation.mjs @@ -51,11 +51,15 @@ const ALLOWED_SOURCE_KEYS = Object.freeze(["source", "repo", "path", "ref", "sha const SEMVER_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/; -// NOTE: Keep in sync with PLUGIN_JSON_CANDIDATES in external-plugin-quality-gates.mjs +// NOTE: Keep this SET in sync with PLUGIN_JSON_CANDIDATES in +// external-plugin-quality-gates.mjs and EXTERNAL_PLUGIN_ROOT_MANIFEST_PATHS in +// external-plugin-intake.mjs. This list only formats the expected-location error +// message, so its order carries no precedence meaning. const EXTERNAL_PLUGIN_ROOT_MANIFEST_PATHS = Object.freeze([ "plugin.json", ".github/plugin/plugin.json", ".plugin/plugin.json", + ".claude-plugin/plugin.json", ]); function resolvePolicy(policy) {