Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
3 changes: 3 additions & 0 deletions eng/external-plugin-intake.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
12 changes: 11 additions & 1 deletion eng/external-plugin-quality-gates.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
79 changes: 79 additions & 0 deletions eng/external-plugin-quality-gates.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
6 changes: 5 additions & 1 deletion eng/external-plugin-validation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading