From 5cbb99978af56dd7dcdb113d0a069c71e5404236 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:39:16 +1000 Subject: [PATCH 1/5] Add external plugin brainmaxxing (#2473) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/plugin/marketplace.json | 28 ++++++++++++++++++++++++++++ plugins/external.json | 28 ++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index a68cbe9a..b575acef 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -166,6 +166,34 @@ "description": "Wander an endless first-person backrooms in a Copilot canvas while agents work; their status ghost-writes on the walls.", "version": "1.0.0" }, + { + "name": "brainmaxxing", + "description": "Codebase-grounded concept-mastery quizzes with an interactive BrainMax Canvas dashboard.", + "version": "1.0.0", + "author": { + "name": "Julia Muiruri", + "url": "https://juliawakiru.dev" + }, + "repository": "https://github.com/juliamuiruri4/brainmaxxing", + "homepage": "https://github.com/juliamuiruri4/brainmaxxing", + "license": "MIT", + "keywords": [ + "assessment", + "canvas", + "developer-education", + "learning", + "quiz", + "skills-mastery", + "software-engineering", + "student-developer" + ], + "source": { + "source": "github", + "repo": "juliamuiruri4/brainmaxxing", + "ref": "v1.0.0", + "sha": "8a8a102ff3ab8736f015764207cdb7e68fcd5ecf" + } + }, { "name": "cast-imaging", "source": "plugins/cast-imaging", diff --git a/plugins/external.json b/plugins/external.json index 1c45ecb4..30b06c26 100644 --- a/plugins/external.json +++ b/plugins/external.json @@ -78,6 +78,34 @@ "path": ".github/plugins/azure-skills" } }, + { + "name": "brainmaxxing", + "description": "Codebase-grounded concept-mastery quizzes with an interactive BrainMax Canvas dashboard.", + "version": "1.0.0", + "author": { + "name": "Julia Muiruri", + "url": "https://juliawakiru.dev" + }, + "repository": "https://github.com/juliamuiruri4/brainmaxxing", + "homepage": "https://github.com/juliamuiruri4/brainmaxxing", + "license": "MIT", + "keywords": [ + "assessment", + "canvas", + "developer-education", + "learning", + "quiz", + "skills-mastery", + "software-engineering", + "student-developer" + ], + "source": { + "source": "github", + "repo": "juliamuiruri4/brainmaxxing", + "ref": "v1.0.0", + "sha": "8a8a102ff3ab8736f015764207cdb7e68fcd5ecf" + } + }, { "name": "chrome-devtools-plugin", "description": "Reliable automation, in-depth debugging, and performance analysis in Chrome using Chrome DevTools and Puppeteer.", From b34ac0918c43351500b7953bedec6611c3071643 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:40:52 +1000 Subject: [PATCH 2/5] Add external plugin timebase (#2462) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/plugin/marketplace.json | 24 ++++++++++++++++++++++++ plugins/external.json | 24 ++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index b575acef..5462ba9a 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -1211,6 +1211,30 @@ "description": "Stop being the switchboard between your AI agents — direct a team. The Workshop puts long-running AI agents (desks) in the same room, on the same work, each with its own memory and history, sharing one workspace so you direct the work instead of relaying it.", "version": "0.1.0" }, + { + "name": "timebase", + "description": "Connect your AI agent to TimeBase, a high-performance database for financial market and other time-series data, via MCP tools and skills for QQL query and TimeBase client code generation.", + "version": "0.1.2", + "author": { + "name": "EPAM Systems", + "url": "https://www.epam.com/" + }, + "repository": "https://github.com/epam/TimeBase-Agent-Plugins", + "license": "Apache-2.0", + "keywords": [ + "timebase", + "time-series", + "db", + "qql", + "financial-data" + ], + "source": { + "source": "github", + "repo": "epam/TimeBase-Agent-Plugins", + "ref": "v0.1.2", + "sha": "846b341a17becbf4d0cd43374588fc6ab1930d0b" + } + }, { "name": "tiny-tool-town-submitter", "source": "extensions/tiny-tool-town-submitter", diff --git a/plugins/external.json b/plugins/external.json index 30b06c26..ba99b0e6 100644 --- a/plugins/external.json +++ b/plugins/external.json @@ -709,6 +709,30 @@ "ref": "2.3.0" } }, + { + "name": "timebase", + "description": "Connect your AI agent to TimeBase, a high-performance database for financial market and other time-series data, via MCP tools and skills for QQL query and TimeBase client code generation.", + "version": "0.1.2", + "author": { + "name": "EPAM Systems", + "url": "https://www.epam.com/" + }, + "repository": "https://github.com/epam/TimeBase-Agent-Plugins", + "license": "Apache-2.0", + "keywords": [ + "timebase", + "time-series", + "db", + "qql", + "financial-data" + ], + "source": { + "source": "github", + "repo": "epam/TimeBase-Agent-Plugins", + "ref": "v0.1.2", + "sha": "846b341a17becbf4d0cd43374588fc6ab1930d0b" + } + }, { "name": "ui-theme-designer", "description": "Equips AI coding agents with knowledge and tooling for working with UI theme designer, the SAP Design System, and SAP Fiori design tokens.", From 63c2527ace0f7a9943973839e2447278f2184ad8 Mon Sep 17 00:00:00 2001 From: Tim Mulholland Date: Tue, 28 Jul 2026 21:05:31 -0700 Subject: [PATCH 3/5] Accept nested extensions//extension.mjs in external-plugin canvas checks (#2403) * Accept nested extensions//extension.mjs in external-plugin canvas checks The external-plugin canvas structure check (quality gate) and intake validation both hardcoded a flat extensions/extension.mjs entry point, falsely rejecting the documented nested extensions//extension.mjs layout that installs and runs fine. Scan the extensions/ directory for a nested subfolder containing extension.mjs while still accepting the flat form for backward compatibility. Applied to both runCanvasStructureGate (git-object lookups) and validateCanvasPluginMetadata (Contents API), keeping them behaviorally aligned. Added regression coverage for both paths. Fixes #2402 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Harden nested canvas extension detection after adversarial review Address multi-model review findings on the nested canvas extension fix: - Quality gate: enumerate extensions/ via 'git ls-tree -z' with spawnSync (NUL-delimited, untruncated) so large directories no longer drop the real entry past the 12KB output cap. - Intake: decouple the flat extensions/extension.mjs check from the directory listing, require an array listing (Array.isArray) before treating it as a directory, and surface an unverifiable (warning) result instead of a false rejection when the listing or a nested lookup hits a transient API error. - Add regression tests: nested entry beyond the legacy output cap (gate) and unverifiable/flat-still-accepted paths when the listing errors (intake). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Enumerate canvas extensions via a single recursive Git Trees call in intake Address PR review: the Contents API caps directory listings at 1,000 entries and required one request per extension subfolder, so a nested entry beyond the cap could be falsely rejected (the same truncation class the git gate avoids) and large repos risked latency / rate-limit exhaustion. Replace the per-subfolder Contents API enumeration with one recursive 'git/trees/?recursive=1' fetch and inspect 'extensions/extension.mjs' and immediate 'extensions//extension.mjs' paths locally. A truncated tree without a located entry point is reported as unverifiable (warning) rather than rejected, and refs are normalized so 'refs/tags/' resolves as a tree-ish. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Bound canvas extension discovery to plugin scope Address reviewer feedback on unbounded scaling for untrusted/large repos: - Quality gate: replace the per-candidate-directory git cat-file spawns in locateCanvasEntryPoint with a single recursive git ls-tree over the extensions subtree, classifying flat/nested entry points in memory. Process count is now constant regardless of how many folders live under extensions/. - Intake: stop fetching the recursive git tree from the repo root (which a large unrelated monorepo can push past the Trees API truncation limit and never validate). Walk to the plugin's extensions directory one level at a time to resolve its tree SHA, then fetch only that subtree recursively, so verifiability depends on the plugin's own size, not the whole repository. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: aaronpowell <434140+aaronpowell@users.noreply.github.com> --- eng/external-plugin-intake.mjs | 153 +++++++++--- eng/external-plugin-intake.test.mjs | 260 +++++++++++++++++++++ eng/external-plugin-quality-gates.mjs | 97 +++++++- eng/external-plugin-quality-gates.test.mjs | 84 +++++++ 4 files changed, 560 insertions(+), 34 deletions(-) create mode 100644 eng/external-plugin-intake.test.mjs diff --git a/eng/external-plugin-intake.mjs b/eng/external-plugin-intake.mjs index fa9696e1..3a995bcc 100644 --- a/eng/external-plugin-intake.mjs +++ b/eng/external-plugin-intake.mjs @@ -380,7 +380,92 @@ async function validateRemoteRepository(repo, { ref, sha }, errors, warnings, to } } -async function validateCanvasPluginMetadata(plugin, errors, warnings, token) { +function buildGitTreePath(repo, treeish, { recursive = false } = {}) { + const encodedRepo = encodeRepoPath(repo); + const query = recursive ? "?recursive=1" : ""; + return `/repos/${encodedRepo}/git/trees/${encodeURIComponent(treeish)}${query}`; +} + +function normalizeTreeish(locator) { + const value = String(locator ?? "").trim(); + // The Git Trees API takes the tree-ish as a single path segment. A full "refs/tags/" + // ref would break that, so reduce it to the bare tag name; commit SHAs and simple tag + // names pass through unchanged. + return value.startsWith("refs/tags/") ? value.slice("refs/tags/".length) : value; +} + +// Resolve the tree SHA of a directory by walking the path one level at a time. Each hop is a +// non-recursive tree fetch of a single directory, so the work is bounded by the path depth and +// is independent of the overall repository size — unlike a root recursive fetch, which a large +// unrelated monorepo can push over the API's truncation limit and never validate. +async function resolveDirectoryTreeSha(repo, treeish, segments, token) { + let currentTreeish = treeish; + for (const segment of segments) { + const response = await fetchGitHubJson(buildGitTreePath(repo, currentTreeish), token); + if (response.kind !== "found" || !Array.isArray(response.data?.tree)) { + return { status: "apiError" }; + } + if (response.data.truncated) { + // A single directory level exceeded the response limit; presence is unverifiable. + return { status: "apiError" }; + } + + const match = response.data.tree.find((entry) => entry?.path === segment); + if (!match) { + return { status: "missing" }; + } + if (match.type !== "tree") { + return { status: "notDirectory" }; + } + currentTreeish = match.sha; + } + + return { status: "found", treeSha: currentTreeish }; +} + +// Inspect the (recursively fetched) "extensions" subtree for the plugin's canvas extension +// entry point. Paths are relative to "extensions/", so the flat form is "extension.mjs" and a +// nested form is "/extension.mjs". Scoping the recursive fetch to this subtree keeps the +// lookup complete without depending on the size of the rest of the repository. +function analyzeCanvasExtensionSubtree(subtreeEntries) { + let flatIsBlob = false; + let flatIsTree = false; + let nestedEntryPath = null; + + for (const entry of subtreeEntries) { + const entryPath = entry?.path; + if (typeof entryPath !== "string") { + continue; + } + + if (entryPath === "extension.mjs") { + if (entry.type === "blob") { + flatIsBlob = true; + } else if (entry.type === "tree") { + flatIsTree = true; + } + continue; + } + + const segments = entryPath.split("/"); + if (segments.length === 2 && segments[1] === "extension.mjs" && entry.type === "blob") { + nestedEntryPath = nestedEntryPath ?? `extensions/${entryPath}`; + } + } + + if (flatIsBlob) { + return { status: "found", entryPath: "extensions/extension.mjs" }; + } + if (nestedEntryPath) { + return { status: "found", entryPath: nestedEntryPath }; + } + if (flatIsTree) { + return { status: "notFile" }; + } + return { status: "notFound" }; +} + +export async function validateCanvasPluginMetadata(plugin, errors, warnings, token) { const repo = plugin?.source?.repo; const sha = plugin?.source?.sha; const ref = plugin?.source?.ref; @@ -471,41 +556,51 @@ async function validateCanvasPluginMetadata(plugin, errors, warnings, token) { ); } - const extensionContainerPath = joinRepoPath(pluginRoot, "extensions"); - const extensionContainerResponse = await fetchGitHubFile(repo, extensionContainerPath, releaseLocator, token); - if (extensionContainerResponse.kind === "notFound") { + const unverifiableEntryPointWarning = + `submission: could not verify the canvas extension entry point in GitHub repository "${repo}" at ${releaseLocatorDescription}; a maintainer should re-run intake`; + const extensionsSegments = [...(pluginRoot ? pluginRoot.split("/") : []), "extensions"]; + const extensionsTree = await resolveDirectoryTreeSha( + repo, + normalizeTreeish(releaseLocator), + extensionsSegments, + token, + ); + if (extensionsTree.status === "apiError") { + warnings.push(unverifiableEntryPointWarning); + } else if (extensionsTree.status === "missing") { errors.push( `submission: plugins tagged with "canvas" must include an "extensions" directory at ${releaseLocatorDescription}`, ); - } else if (extensionContainerResponse.kind === "apiError") { - warnings.push( - `submission: could not verify "extensions" directory in GitHub repository "${repo}" at ${releaseLocatorDescription}; a maintainer should re-run intake`, - ); - } else if ( - !( - extensionContainerResponse.data?.type === "dir" - || Array.isArray(extensionContainerResponse.data) - ) - ) { + } else if (extensionsTree.status === "notDirectory") { errors.push( `submission: "extensions" must be a directory in ${releaseLocatorDescription}`, ); - } - - const extensionEntryPath = joinRepoPath(pluginRoot, "extensions", "extension.mjs"); - const extensionEntryResponse = await fetchGitHubFile(repo, extensionEntryPath, releaseLocator, token); - if (extensionEntryResponse.kind === "notFound") { - errors.push( - `submission: plugins tagged with "canvas" must include "extensions/extension.mjs" at ${releaseLocatorDescription}`, - ); - } else if (extensionEntryResponse.kind === "apiError") { - warnings.push( - `submission: could not verify "extensions/extension.mjs" in GitHub repository "${repo}" at ${releaseLocatorDescription}; a maintainer should re-run intake`, - ); - } else if (extensionEntryResponse.data?.type !== "file") { - errors.push( - `submission: "extensions/extension.mjs" must be a file in ${releaseLocatorDescription}`, + } else { + const subtreeResponse = await fetchGitHubJson( + buildGitTreePath(repo, extensionsTree.treeSha, { recursive: true }), + token, ); + if (subtreeResponse.kind !== "found" || !Array.isArray(subtreeResponse.data?.tree)) { + warnings.push(unverifiableEntryPointWarning); + } else { + const canvasStructure = analyzeCanvasExtensionSubtree(subtreeResponse.data.tree); + if (canvasStructure.status === "found") { + // Entry point located (flat or nested); nothing to report. + } else if (subtreeResponse.data.truncated) { + // Absence is only inconclusive if the (already extensions-scoped) subtree itself is + // truncated, which would take an implausibly large extensions directory; flag it as + // unverifiable rather than falsely rejecting. + warnings.push(unverifiableEntryPointWarning); + } else if (canvasStructure.status === "notFile") { + errors.push( + `submission: "extensions/extension.mjs" must be a file in ${releaseLocatorDescription}`, + ); + } else { + errors.push( + `submission: plugins tagged with "canvas" must include a canvas extension entry point at "extensions/extension.mjs" or "extensions//extension.mjs" at ${releaseLocatorDescription}`, + ); + } + } } const previewPath = joinRepoPath(pluginRoot, EXTERNAL_CANVAS_PREVIEW_PATH); diff --git a/eng/external-plugin-intake.test.mjs b/eng/external-plugin-intake.test.mjs new file mode 100644 index 00000000..5cf9cbe8 --- /dev/null +++ b/eng/external-plugin-intake.test.mjs @@ -0,0 +1,260 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { validateCanvasPluginMetadata } from "./external-plugin-intake.mjs"; + +const REPO = "owner/repo"; +const SHA = "0123456789abcdef0123456789abcdef01234567"; +const PLUGIN_ROOT = "plugins/upgrade-agent"; + +const TREE_PLUGINS = "tree-plugins"; +const TREE_UPGRADE_AGENT = "tree-upgrade-agent"; +const TREE_EXTENSIONS = "tree-extensions"; + +function fileNode(content) { + return { type: "file", content: Buffer.from(content, "utf8").toString("base64") }; +} + +function treeEntry(path, type, sha) { + return { path, type, mode: type === "tree" ? "040000" : "100644", sha: sha ?? "deadbeef" }; +} + +function treeResponse(entries, { truncated = false } = {}) { + return { status: 200, data: { sha: "resolved", truncated, tree: entries } }; +} + +function decodeContentsPath(url) { + const { pathname } = new URL(url); + const afterContents = pathname.split("/contents/")[1] ?? ""; + return afterContents + .split("/") + .filter(Boolean) + .map((segment) => decodeURIComponent(segment)) + .join("/"); +} + +function decodeTreeish(url) { + const match = String(url).match(/\/git\/trees\/([^/?]+)/); + return match ? decodeURIComponent(match[1]) : null; +} + +async function withMockedFetch({ contents = {}, trees = {} }, run) { + const originalFetch = globalThis.fetch; + globalThis.fetch = async (url) => { + const requestUrl = String(url); + let route; + if (requestUrl.includes("/git/trees/")) { + route = trees[decodeTreeish(requestUrl)] ?? { status: 404, data: {} }; + } else { + route = contents[decodeContentsPath(requestUrl)] ?? { status: 404, data: {} }; + } + return { + ok: route.status >= 200 && route.status < 300, + status: route.status, + json: async () => route.data ?? {}, + }; + }; + + try { + return await run(); + } finally { + globalThis.fetch = originalFetch; + } +} + +const canvasManifest = fileNode( + JSON.stringify({ + name: "upgrade-agent", + version: "1.0.0", + description: "Canvas plugin", + logo: "assets/preview.png", + }), +); + +function baseContents(extra) { + return { + [`${PLUGIN_ROOT}/.github/plugin/plugin.json`]: { status: 200, data: canvasManifest }, + [`${PLUGIN_ROOT}/assets/preview.png`]: { status: 200, data: fileNode("binary") }, + ...extra, + }; +} + +// Wires up the directory-walk that resolves plugins/upgrade-agent/extensions to a tree SHA. +// `extensionsEntry` controls what the walk finds at the final ".../extensions" step, and +// `extensionsSubtree` is the recursive listing returned for that resolved tree SHA. +function buildTrees({ extensionsEntry, extensionsSubtree, overrides } = {}) { + const trees = { + [SHA]: treeResponse([treeEntry("plugins", "tree", TREE_PLUGINS)]), + [TREE_PLUGINS]: treeResponse([treeEntry("upgrade-agent", "tree", TREE_UPGRADE_AGENT)]), + [TREE_UPGRADE_AGENT]: treeResponse([ + extensionsEntry ?? treeEntry("extensions", "tree", TREE_EXTENSIONS), + ]), + }; + if (extensionsSubtree) { + trees[TREE_EXTENSIONS] = extensionsSubtree; + } + return { ...trees, ...overrides }; +} + +function makePlugin() { + return { + name: "upgrade-agent", + keywords: ["canvas"], + source: { source: "github", repo: REPO, sha: SHA, path: PLUGIN_ROOT }, + }; +} + +async function runValidation({ contents, trees }) { + const errors = []; + const warnings = []; + await withMockedFetch({ contents: baseContents(contents), trees }, () => + validateCanvasPluginMetadata(makePlugin(), errors, warnings, null), + ); + return { errors, warnings }; +} + +test("validateCanvasPluginMetadata accepts a nested extension entry point", async () => { + const { errors, warnings } = await runValidation({ + trees: buildTrees({ + extensionsSubtree: treeResponse([ + treeEntry("modernize-dashboard", "tree"), + treeEntry("modernize-dashboard/extension.mjs", "blob"), + ]), + }), + }); + + assert.deepEqual(errors, []); + assert.deepEqual(warnings, []); +}); + +test("validateCanvasPluginMetadata accepts a flat extension entry point", async () => { + const { errors, warnings } = await runValidation({ + trees: buildTrees({ + extensionsSubtree: treeResponse([treeEntry("extension.mjs", "blob")]), + }), + }); + + assert.deepEqual(errors, []); + assert.deepEqual(warnings, []); +}); + +test("validateCanvasPluginMetadata rejects when no extension.mjs exists flat or nested", async () => { + const { errors } = await runValidation({ + trees: buildTrees({ + extensionsSubtree: treeResponse([ + treeEntry("modernize-dashboard", "tree"), + treeEntry("modernize-dashboard/index.mjs", "blob"), + ]), + }), + }); + + assert.equal( + errors.some((message) => /must include a canvas extension entry point/.test(message)), + true, + ); +}); + +test("validateCanvasPluginMetadata rejects when the extensions directory is missing", async () => { + const { errors } = await runValidation({ + trees: buildTrees({ + extensionsEntry: treeEntry("other-dir", "tree", "tree-other"), + }), + }); + + assert.equal( + errors.some((message) => /must include an "extensions" directory/.test(message)), + true, + ); +}); + +test("validateCanvasPluginMetadata rejects when extensions is a file rather than a directory", async () => { + const { errors } = await runValidation({ + trees: buildTrees({ + extensionsEntry: treeEntry("extensions", "blob", "blob-extensions"), + }), + }); + + assert.equal( + errors.some((message) => /"extensions" must be a directory/.test(message)), + true, + ); +}); + +test("validateCanvasPluginMetadata rejects when extensions/extension.mjs is a directory", async () => { + const { errors } = await runValidation({ + trees: buildTrees({ + extensionsSubtree: treeResponse([ + treeEntry("extension.mjs", "tree"), + treeEntry("extension.mjs/placeholder.txt", "blob"), + ]), + }), + }); + + assert.equal( + errors.some((message) => /"extensions\/extension\.mjs" must be a file/.test(message)), + true, + ); +}); + +test("validateCanvasPluginMetadata surfaces an unverifiable result when the tree walk errors", async () => { + const { errors, warnings } = await runValidation({ + trees: buildTrees({ + overrides: { [SHA]: { status: 500, data: {} } }, + }), + }); + + assert.deepEqual(errors, []); + assert.equal( + warnings.some((message) => /could not verify the canvas extension entry point/.test(message)), + true, + ); +}); + +test("validateCanvasPluginMetadata treats a truncated walk level as unverifiable", async () => { + const { errors, warnings } = await runValidation({ + trees: buildTrees({ + overrides: { + [TREE_UPGRADE_AGENT]: treeResponse( + [treeEntry("extensions", "tree", TREE_EXTENSIONS)], + { truncated: true }, + ), + }, + }), + }); + + assert.deepEqual(errors, []); + assert.equal( + warnings.some((message) => /could not verify the canvas extension entry point/.test(message)), + true, + ); +}); + +test("validateCanvasPluginMetadata treats a truncated extensions subtree without an entry point as unverifiable", async () => { + const { errors, warnings } = await runValidation({ + trees: buildTrees({ + extensionsSubtree: treeResponse([treeEntry("modernize-dashboard", "tree")], { truncated: true }), + }), + }); + + assert.deepEqual(errors, []); + assert.equal( + warnings.some((message) => /could not verify the canvas extension entry point/.test(message)), + true, + ); +}); + +test("validateCanvasPluginMetadata accepts an entry point found within a truncated subtree", async () => { + const { errors, warnings } = await runValidation({ + trees: buildTrees({ + extensionsSubtree: treeResponse( + [ + treeEntry("modernize-dashboard", "tree"), + treeEntry("modernize-dashboard/extension.mjs", "blob"), + ], + { truncated: true }, + ), + }), + }); + + assert.deepEqual(errors, []); + assert.deepEqual(warnings, []); +}); diff --git a/eng/external-plugin-quality-gates.mjs b/eng/external-plugin-quality-gates.mjs index 1b9fb3c4..1392862f 100644 --- a/eng/external-plugin-quality-gates.mjs +++ b/eng/external-plugin-quality-gates.mjs @@ -529,6 +529,91 @@ function checkPathExistsAtLocator(repoDir, readRef, locator, repoPath, expectedT }; } +function listTreeEntries(repoDir, readRef, locator, treePath, { recursive = false } = {}) { + // Parse the full, untruncated tree listing directly. runCommand()/truncateOutput() + // would cap stdout at MAX_OUTPUT_LENGTH and silently drop later entries, and the + // default (non-"-z") output quotes unusual names; "-z" gives raw, NUL-delimited records. + // "-r -t" recurses in a single process and still lists intermediate tree objects, so a + // whole subtree can be inspected without spawning one git process per candidate path. + const args = recursive + ? ["ls-tree", "-r", "-t", "-z", `${readRef}:${treePath}`] + : ["ls-tree", "-z", `${readRef}:${treePath}`]; + const result = spawnSync("git", args, { + cwd: repoDir, + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + }); + + if (result.status !== 0) { + const detail = truncateOutput(`${result.stdout ?? ""}\n${result.stderr ?? ""}`); + return { + entries: [], + output: `Unable to list directory "${treePath}" at "${locator}": ${detail}`, + }; + } + + const entries = []; + for (const record of String(result.stdout ?? "").split("\0")) { + if (!record) { + continue; + } + + const tabIndex = record.indexOf("\t"); + if (tabIndex === -1) { + continue; + } + + const meta = record.slice(0, tabIndex).trim().split(/\s+/); + const name = record.slice(tabIndex + 1); + if (!name) { + continue; + } + + entries.push({ type: meta[1] ?? "", name }); + } + + return { entries, output: "" }; +} + +function locateCanvasEntryPoint(repoDir, readRef, locator, extensionsDir) { + // Enumerate the extensions subtree with a single recursive git process rather than + // spawning a git cat-file per candidate directory, so discovery stays bounded no matter + // how many folders an untrusted repository packs under "extensions/". "-r" yields paths + // relative to extensionsDir, so nested entry points appear as "/extension.mjs". + const listing = listTreeEntries(repoDir, readRef, locator, extensionsDir, { recursive: true }); + if (listing.output) { + return { entryPoint: null, output: listing.output }; + } + + let flatIsBlob = false; + let flatIsTree = false; + let nestedEntryPoint = null; + for (const entry of listing.entries) { + if (entry.name === "extension.mjs") { + if (entry.type === "blob") { + flatIsBlob = true; + } else if (entry.type === "tree") { + flatIsTree = true; + } + continue; + } + + const segments = entry.name.split("/"); + if (segments.length === 2 && segments[1] === "extension.mjs" && entry.type === "blob" && !nestedEntryPoint) { + nestedEntryPoint = toPosixPath(extensionsDir, segments[0], "extension.mjs"); + } + } + + if (flatIsBlob) { + return { entryPoint: toPosixPath(extensionsDir, "extension.mjs"), output: "" }; + } + if (nestedEntryPoint) { + return { entryPoint: nestedEntryPoint, output: "" }; + } + + return { entryPoint: null, output: "", flatKindMismatch: flatIsTree }; +} + export function runCanvasStructureGate(repoDir, plugin, primaryFetchSpec) { if (!hasCanvasKeyword(plugin)) { return { @@ -589,23 +674,25 @@ export function runCanvasStructureGate(repoDir, plugin, primaryFetchSpec) { continue; } - const extensionEntryCheck = checkPathExistsAtLocator(repoDir, readRef, locator, extensionEntryPoint, "blob"); + const extensionEntryCheck = locateCanvasEntryPoint(repoDir, readRef, locator, extensionsDir); if (extensionEntryCheck.output) { hasInfraError = true; messages.push(`- ${locator}: ${extensionEntryCheck.output}`); continue; } - if (!extensionEntryCheck.exists) { + if (!extensionEntryCheck.entryPoint) { hasFailure = true; - if (extensionEntryCheck.kindMismatch) { + if (extensionEntryCheck.flatKindMismatch) { messages.push(`- ${locator}: "${extensionEntryPoint}" must be a file.`); } else { - messages.push(`- ${locator}: missing required canvas extension entry point "${extensionEntryPoint}".`); + messages.push( + `- ${locator}: missing required canvas extension entry point "${extensionEntryPoint}" (or a nested "${extensionsDir}//extension.mjs").`, + ); } continue; } - messages.push(`- ${locator}: found "${extensionsDir}" with entry point "${extensionEntryPoint}".`); + messages.push(`- ${locator}: found "${extensionsDir}" with entry point "${extensionEntryCheck.entryPoint}".`); } if (hasInfraError) { diff --git a/eng/external-plugin-quality-gates.test.mjs b/eng/external-plugin-quality-gates.test.mjs index 04eb27e7..14afd091 100644 --- a/eng/external-plugin-quality-gates.test.mjs +++ b/eng/external-plugin-quality-gates.test.mjs @@ -100,6 +100,90 @@ test("runCanvasStructureGate fails when extension entrypoint path is a directory assert.match(result.output, /"extensions\/extension\.mjs" must be a file/); }); +test("runCanvasStructureGate passes when extension lives in a nested subfolder", () => { + const repoDir = createTempRepo(); + fs.mkdirSync(path.join(repoDir, "extensions", "modernize-dashboard"), { recursive: true }); + fs.writeFileSync( + path.join(repoDir, "extensions", "modernize-dashboard", "extension.mjs"), + "export default {};\n", + ); + const sha = commitAll(repoDir, "Add nested canvas extension"); + + const plugin = { + name: "canvas-plugin", + keywords: ["canvas"], + source: { + source: "github", + repo: "owner/repo", + sha, + }, + }; + + const result = runCanvasStructureGate(repoDir, plugin, sha); + assert.equal(result.status, "pass"); + assert.match(result.output, /entry point "extensions\/modernize-dashboard\/extension\.mjs"/); +}); + +test("runCanvasStructureGate fails when no extension.mjs exists flat or nested", () => { + const repoDir = createTempRepo(); + fs.mkdirSync(path.join(repoDir, "extensions", "modernize-dashboard"), { recursive: true }); + fs.writeFileSync( + path.join(repoDir, "extensions", "modernize-dashboard", "index.mjs"), + "export default {};\n", + ); + const sha = commitAll(repoDir, "Add extensions directory without entry point"); + + const plugin = { + name: "canvas-plugin", + keywords: ["canvas"], + source: { + source: "github", + repo: "owner/repo", + sha, + }, + }; + + const result = runCanvasStructureGate(repoDir, plugin, sha); + assert.equal(result.status, "fail"); + assert.match(result.output, /missing required canvas extension entry point/); +}); + +test("runCanvasStructureGate finds a nested extension listed past the legacy output cap", () => { + const repoDir = createTempRepo(); + // Many sibling directories push the real extension past the ~12 KB stdout cap that the + // previous truncating implementation applied, which would silently drop it from the listing. + // Long names inflate each git ls-tree record so fewer directories are needed to exceed the cap. + for (let index = 0; index < 160; index += 1) { + const filler = path.join( + repoDir, + "extensions", + `filler-directory-that-pads-the-tree-listing-${String(index).padStart(4, "0")}`, + ); + fs.mkdirSync(filler, { recursive: true }); + fs.writeFileSync(path.join(filler, "readme.txt"), "filler\n"); + } + fs.mkdirSync(path.join(repoDir, "extensions", "zzz-real-extension"), { recursive: true }); + fs.writeFileSync( + path.join(repoDir, "extensions", "zzz-real-extension", "extension.mjs"), + "export default {};\n", + ); + const sha = commitAll(repoDir, "Add nested extension after many siblings"); + + const plugin = { + name: "canvas-plugin", + keywords: ["canvas"], + source: { + source: "github", + repo: "owner/repo", + sha, + }, + }; + + const result = runCanvasStructureGate(repoDir, plugin, sha); + assert.equal(result.status, "pass"); + assert.match(result.output, /entry point "extensions\/zzz-real-extension\/extension\.mjs"/); +}); + // Regression tests for issue #2397: a tag-name locator (e.g. "v1.0.0") must be // readable by the version-match and canvas-structure gates. `git fetch origin ` // only updates FETCH_HEAD and never creates a local `refs/tags/`, so reading via From 1e14bd4faa3e2687ea6f662f6ee3304ea8dab6dc Mon Sep 17 00:00:00 2001 From: qinezh Date: Wed, 29 Jul 2026 12:07:08 +0800 Subject: [PATCH 4/5] chore: upgrade microsoft-foundry plugin to 1.0.5 (#2464) Update the external plugin catalog to the 1.0.5 source commit and regenerate the marketplace output. Co-authored-by: Aaron Powell --- .github/plugin/marketplace.json | 4 ++-- plugins/external.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index 5462ba9a..536f0f85 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -798,7 +798,7 @@ { "name": "microsoft-foundry", "description": "Skills and interactive Copilot canvas for designing, configuring, testing and deploying agents to Microsoft Foundry.", - "version": "1.0.4", + "version": "1.0.5", "author": { "name": "Microsoft", "url": "https://www.microsoft.com" @@ -819,7 +819,7 @@ "source": "github", "repo": "microsoft/foundry-toolkit", "path": "microsoft-foundry", - "sha": "478668e6c62035d702ad3405361d4d229f31ca03" + "sha": "b4ffffc810130893117a7b8f797e0a33102e66de" } }, { diff --git a/plugins/external.json b/plugins/external.json index ba99b0e6..6f4c8b71 100644 --- a/plugins/external.json +++ b/plugins/external.json @@ -530,7 +530,7 @@ { "name": "microsoft-foundry", "description": "Skills and interactive Copilot canvas for designing, configuring, testing and deploying agents to Microsoft Foundry.", - "version": "1.0.4", + "version": "1.0.5", "author": { "name": "Microsoft", "url": "https://www.microsoft.com" @@ -551,7 +551,7 @@ "source": "github", "repo": "microsoft/foundry-toolkit", "path": "microsoft-foundry", - "sha": "478668e6c62035d702ad3405361d4d229f31ca03" + "sha": "b4ffffc810130893117a7b8f797e0a33102e66de" } }, { From 8ae5a99109124c22288eee0254da61741e44d12a Mon Sep 17 00:00:00 2001 From: Aaron Powell Date: Wed, 29 Jul 2026 15:28:13 +1000 Subject: [PATCH 5/5] Enforce external plugin ref/sha consistency (#2463) * Enforce external plugin ref/sha consistency Extract shared ref/sha normalization and consistency checks into eng/lib and reuse them in intake plus quality gate flows. Add a dedicated ref/sha consistency quality gate surfaced in PR/intake summaries, and add targeted tests for matching and mismatched refs. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6afe21ad-eafa-4c90-a1f2-053dedac7625 * Address review: tree/blob ref errors and PR workflow ref/sha column - resolveCommitShaAtReadRef: classify rev-parse failure as 'fail' instead of 'infra_error' because a successfully-fetched ref that doesn't dereference to a commit is a submitter problem, not infra. - validateRemoteRepository (intake): treat HTTP 422 from the commit endpoint as a submitter error; all other non-404 errors remain transient warnings requiring maintainer re-run. - external-plugin-pr-quality-gates.yml: add ref/sha consistency column to the per-plugin quality table and failure details block. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6afe21ad-eafa-4c90-a1f2-053dedac7625 --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6afe21ad-eafa-4c90-a1f2-053dedac7625 --- .../external-plugin-pr-quality-gates.yml | 13 +- eng/external-plugin-intake.mjs | 81 ++++++++++ eng/external-plugin-intake.test.mjs | 147 +++++++++++++++++- eng/external-plugin-pr-quality-gates.mjs | 4 +- eng/external-plugin-quality-gates.mjs | 92 +++++++++++ eng/external-plugin-quality-gates.test.mjs | 37 ++++- eng/lib/external-plugin-source-ref-sha.mjs | 31 ++++ 7 files changed, 397 insertions(+), 8 deletions(-) create mode 100644 eng/lib/external-plugin-source-ref-sha.mjs diff --git a/.github/workflows/external-plugin-pr-quality-gates.yml b/.github/workflows/external-plugin-pr-quality-gates.yml index 6978738e..0087880c 100644 --- a/.github/workflows/external-plugin-pr-quality-gates.yml +++ b/.github/workflows/external-plugin-pr-quality-gates.yml @@ -279,17 +279,19 @@ jobs: const vallyLintStatus = escapeMarkdownTableCell(quality.vally_lint_status || 'not_run'); const smokeStatus = escapeMarkdownTableCell(quality.smoke_status || 'not_run'); const versionMatchStatus = escapeMarkdownTableCell(quality.version_match_status || 'not_run'); + const refShaConsistencyStatus = escapeMarkdownTableCell(quality.ref_sha_consistency_status || 'not_run'); const canvasStructureStatus = escapeMarkdownTableCell(quality.canvas_structure_status || 'not_run'); const overallStatus = escapeMarkdownTableCell(quality.overall_status || 'not_run'); - return `| ${name} | ${vallyLintStatus} | ${smokeStatus} | ${versionMatchStatus} | ${canvasStructureStatus} | ${overallStatus} | ${sourceCell} |`; + return `| ${name} | ${vallyLintStatus} | ${smokeStatus} | ${versionMatchStatus} | ${refShaConsistencyStatus} | ${canvasStructureStatus} | ${overallStatus} | ${sourceCell} |`; }) - : ['| _none_ | not_run | not_run | not_run | not_run | not_run | _n/a_ |']; + : ['| _none_ | not_run | not_run | not_run | not_run | not_run | not_run | _n/a_ |']; const failureDetails = checkedPlugins.flatMap((entry) => { const name = String(entry?.name || 'unknown'); const quality = entry?.quality || {}; const shouldShowVally = quality.vally_lint_status === 'fail' || quality.vally_lint_status === 'infra_error' || String(quality.vally_lint_output || '').trim().length > 0; const shouldShowSmoke = quality.smoke_status === 'fail' || quality.smoke_status === 'infra_error' || String(quality.smoke_output || '').trim().length > 0; const shouldShowVersionMatch = quality.version_match_status === 'fail' || quality.version_match_status === 'infra_error' || String(quality.version_match_output || '').trim().length > 0; + const shouldShowRefShaConsistency = quality.ref_sha_consistency_status === 'fail' || quality.ref_sha_consistency_status === 'infra_error' || String(quality.ref_sha_consistency_output || '').trim().length > 0; const shouldShowCanvasStructure = quality.canvas_structure_status === 'fail' || quality.canvas_structure_status === 'infra_error' || String(quality.canvas_structure_output || '').trim().length > 0; const details = []; @@ -302,6 +304,9 @@ jobs: if (shouldShowVersionMatch) { details.push(formatGateOutput(name, 'version match', quality.version_match_status, quality.version_match_output)); } + if (shouldShowRefShaConsistency) { + details.push(formatGateOutput(name, 'ref/sha consistency', quality.ref_sha_consistency_status, quality.ref_sha_consistency_output)); + } if (shouldShowCanvasStructure) { details.push(formatGateOutput(name, 'canvas structure', quality.canvas_structure_status, quality.canvas_structure_output)); } @@ -317,8 +322,8 @@ jobs: '', '### Per-plugin quality summary', '', - '| Plugin | vally lint | install smoke test | version match | canvas structure | overall | source tree |', - '|---|---|---|---|---|---|---|', + '| Plugin | vally lint | install smoke test | version match | ref/sha consistency | canvas structure | overall | source tree |', + '|---|---|---|---|---|---|---|---|', ...rows, '', ...(failureDetails.length > 0 diff --git a/eng/external-plugin-intake.mjs b/eng/external-plugin-intake.mjs index 3a995bcc..372c7339 100644 --- a/eng/external-plugin-intake.mjs +++ b/eng/external-plugin-intake.mjs @@ -5,6 +5,7 @@ import path from "path"; import { fileURLToPath } from "url"; import { ROOT_FOLDER } from "./constants.mjs"; import { readExternalPlugins, validateExternalPlugin } from "./external-plugin-validation.mjs"; +import { evaluateRefShaConsistency, normalizeCommitSha } from "./lib/external-plugin-source-ref-sha.mjs"; export const ISSUE_FORM_MARKER = ""; export const EXTERNAL_PLUGIN_INTAKE_COMMENT_MARKER = ""; @@ -293,9 +294,23 @@ function encodeRepoPath(repo) { return `${encodeURIComponent(owner ?? "")}/${encodeURIComponent(name ?? "")}`; } +async function resolveCommitSha(repo, locator, token) { + const encodedRepo = encodeRepoPath(repo); + const commitResponse = await fetchGitHubJson(`/repos/${encodedRepo}/commits/${encodeURIComponent(locator)}`, token); + if (commitResponse.kind !== "found") { + return commitResponse; + } + + return { + ...commitResponse, + commitSha: normalizeCommitSha(commitResponse.data?.sha), + }; +} + async function validateRemoteRepository(repo, { ref, sha }, errors, warnings, token) { const encodedRepo = encodeRepoPath(repo); const repositoryResponse = await fetchGitHubJson(`/repos/${encodedRepo}`, token); + const normalizedSha = normalizeCommitSha(sha); if (repositoryResponse.kind === "notFound") { errors.push(`submission: GitHub repository "${repo}" was not found`); @@ -333,6 +348,19 @@ async function validateRemoteRepository(repo, { ref, sha }, errors, warnings, to } + function validateRefShaConsistency(refCommitSha) { + if (!normalizedSha || !refCommitSha) { + return; + } + + const consistency = evaluateRefShaConsistency({ ref, sha, resolvedRefCommitSha: refCommitSha }); + if (!consistency.matches) { + errors.push( + `submission: when both "Ref to review" and "Commit SHA to review" are provided, they must reference the same commit (ref "${ref}" resolves to "${consistency.normalizedRefCommitSha}", sha is "${sha}")`, + ); + } + } + if (!ref) { return; } @@ -347,6 +375,8 @@ async function validateRemoteRepository(repo, { ref, sha }, errors, warnings, to `submission: could not verify commit "${ref}" in GitHub repository "${repo}" (${statusText}${commitResponse.reason ? ` — ${commitResponse.reason}` : ""}); a maintainer should re-run intake`, ); } + + validateRefShaConsistency(normalizeCommitSha(ref)); return; } @@ -362,6 +392,38 @@ async function validateRemoteRepository(repo, { ref, sha }, errors, warnings, to const tagResponse = await fetchGitHubJson(`/repos/${encodedRepo}/git/ref/tags/${encodeURIComponent(tagName)}`, token); if (tagResponse.kind === "found") { + if (!normalizedSha) { + return; + } + + const resolvedRefResponse = await resolveCommitSha(repo, ref, token); + if (resolvedRefResponse.kind === "notFound") { + errors.push(`submission: ref "${ref}" could not be resolved to a commit in GitHub repository "${repo}"`); + return; + } + + if (resolvedRefResponse.kind === "apiError") { + if (resolvedRefResponse.status === 422) { + errors.push( + `submission: ref "${ref}" does not resolve to a commit in GitHub repository "${repo}" (it may point to a tag object, tree, or blob); only commit-backed refs are supported`, + ); + return; + } + const statusText = resolvedRefResponse.status ? `HTTP ${resolvedRefResponse.status}` : "network error"; + warnings.push( + `submission: could not resolve ref "${ref}" to a commit in GitHub repository "${repo}" (${statusText}${resolvedRefResponse.reason ? ` — ${resolvedRefResponse.reason}` : ""}); a maintainer should re-run intake`, + ); + return; + } + + if (!resolvedRefResponse.commitSha) { + warnings.push( + `submission: could not determine the commit SHA for ref "${ref}" in GitHub repository "${repo}"; a maintainer should re-run intake`, + ); + return; + } + + validateRefShaConsistency(resolvedRefResponse.commitSha); return; } @@ -724,12 +786,14 @@ function normalizeQualityGateResult(rawResult) { vally_lint_status: "not_run", smoke_status: "not_run", version_match_status: "not_run", + ref_sha_consistency_status: "not_run", canvas_structure_status: "not_run", failure_class: "none", summary: "", vally_lint_output: "", smoke_output: "", version_match_output: "", + ref_sha_consistency_output: "", canvas_structure_output: "", }; @@ -747,6 +811,7 @@ function buildQualityGatesCommentSection(qualityResult) { const vallyState = qualityResult.vally_lint_status || "not_run"; const smokeState = qualityResult.smoke_status || "not_run"; const versionMatchState = qualityResult.version_match_status || "not_run"; + const refShaConsistencyState = qualityResult.ref_sha_consistency_status || "not_run"; const canvasStructureState = qualityResult.canvas_structure_status || "not_run"; const summaryText = String(qualityResult.summary || "").trim() || "_No quality gate details were provided._"; @@ -758,6 +823,7 @@ function buildQualityGatesCommentSection(qualityResult) { `| vally lint | ${vallyState} |`, `| install smoke test | ${smokeState} |`, `| version match | ${versionMatchState} |`, + `| ref/sha consistency | ${refShaConsistencyState} |`, `| canvas structure | ${canvasStructureState} |`, "", summaryText, @@ -808,6 +874,21 @@ function buildQualityGatesCommentSection(qualityResult) { ); } + const refShaConsistencyOutput = String(qualityResult.ref_sha_consistency_output || "").trim(); + if (refShaConsistencyOutput) { + sections.push( + "", + "
", + "Ref/SHA consistency output", + "", + "```text", + refShaConsistencyOutput, + "```", + "", + "
", + ); + } + const canvasStructureOutput = String(qualityResult.canvas_structure_output || "").trim(); if (canvasStructureOutput) { sections.push( diff --git a/eng/external-plugin-intake.test.mjs b/eng/external-plugin-intake.test.mjs index 5cf9cbe8..66dc9e7a 100644 --- a/eng/external-plugin-intake.test.mjs +++ b/eng/external-plugin-intake.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; -import { test } from "node:test"; -import { validateCanvasPluginMetadata } from "./external-plugin-intake.mjs"; +import { afterEach, test } from "node:test"; +import { evaluateExternalPluginIssue, validateCanvasPluginMetadata } from "./external-plugin-intake.mjs"; const REPO = "owner/repo"; const SHA = "0123456789abcdef0123456789abcdef01234567"; @@ -258,3 +258,146 @@ test("validateCanvasPluginMetadata accepts an entry point found within a truncat assert.deepEqual(errors, []); assert.deepEqual(warnings, []); }); + +// --------------------------------------------------------------------------- +// ref/sha consistency tests (evaluateExternalPluginIssue) +// --------------------------------------------------------------------------- + +const ORIGINAL_FETCH = global.fetch; +const INTAKE_REPO = "octo/example"; +const RESOLVED_REF_SHA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const PROVIDED_SHA = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + +afterEach(() => { + global.fetch = ORIGINAL_FETCH; +}); + +function buildIssueBody({ ref, sha }) { + return [ + "", + "### Plugin name", + "", + "intake-ref-sha-consistency-test-plugin", + "", + "### Short description", + "", + "Test plugin for external intake validation.", + "", + "### GitHub repository", + "", + INTAKE_REPO, + "", + "### Plugin path inside the repository", + "", + "_No response_", + "", + "### Ref to review", + "", + ref, + "", + "### Commit SHA to review", + "", + sha, + "", + "### Version", + "", + "1.2.3", + "", + "### License identifier", + "", + "MIT", + "", + "### Author name", + "", + "Copilot Test", + "", + "### Author URL", + "", + "_No response_", + "", + "### Homepage URL", + "", + "_No response_", + "", + "### Keywords", + "", + "testing", + "", + "### Additional notes for reviewers", + "", + "_No response_", + "", + "### Submission checklist", + "", + "- [x] The plugin lives in a public GitHub repository.", + "- [x] The ref and/or sha I provided is immutable (release tag and/or full 40-character commit SHA), not a branch.", + "- [x] This submission follows this repository's contribution, security, and responsible AI policies.", + "- [x] This plugin is not already listed in the Awesome Copilot marketplace.", + "", + ].join("\n"); +} + +function jsonResponse(payload, { status = 200 } = {}) { + return { + ok: status >= 200 && status < 300, + status, + statusText: status === 404 ? "Not Found" : "OK", + headers: new Map(), + async json() { + return payload; + }, + }; +} + +function installMockFetch() { + global.fetch = async (url) => { + const requestUrl = String(url); + if (requestUrl === `https://api.github.com/repos/${INTAKE_REPO}`) { + return jsonResponse({ private: false, archived: false }); + } + + if ( + requestUrl === `https://api.github.com/repos/${INTAKE_REPO}/git/commits/${PROVIDED_SHA}` || + requestUrl === `https://api.github.com/repos/${INTAKE_REPO}/git/commits/${RESOLVED_REF_SHA}` + ) { + const sha = requestUrl.endsWith(RESOLVED_REF_SHA) ? RESOLVED_REF_SHA : PROVIDED_SHA; + return jsonResponse({ sha }); + } + + if (requestUrl === `https://api.github.com/repos/${INTAKE_REPO}/git/ref/tags/v1.2.3`) { + return jsonResponse({ object: { type: "tag", sha: "cccccccccccccccccccccccccccccccccccccccc" } }); + } + + if (requestUrl === `https://api.github.com/repos/${INTAKE_REPO}/commits/v1.2.3`) { + return jsonResponse({ sha: RESOLVED_REF_SHA }); + } + + return jsonResponse({}, { status: 404 }); + }; +} + +test("evaluateExternalPluginIssue fails when ref and sha resolve to different commits", async () => { + installMockFetch(); + const issue = { body: buildIssueBody({ ref: "v1.2.3", sha: PROVIDED_SHA }) }; + + const result = await evaluateExternalPluginIssue({ issue }); + + assert.equal(result.valid, false); + assert.match( + result.commentBody, + /must reference the same commit \(ref "v1\.2\.3" resolves to "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", sha is "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"\)/, + ); +}); + +test("evaluateExternalPluginIssue passes when ref and sha resolve to the same commit", async () => { + installMockFetch(); + const issue = { body: buildIssueBody({ ref: "v1.2.3", sha: RESOLVED_REF_SHA }) }; + + const result = await evaluateExternalPluginIssue({ issue }); + + assert.equal(result.valid, true); + assert.equal( + result.errors.some((error) => error.includes("must reference the same commit")), + false, + ); +}); diff --git a/eng/external-plugin-pr-quality-gates.mjs b/eng/external-plugin-pr-quality-gates.mjs index 07d4a330..f7f8ded5 100644 --- a/eng/external-plugin-pr-quality-gates.mjs +++ b/eng/external-plugin-pr-quality-gates.mjs @@ -74,12 +74,14 @@ function createValidationFailureQuality(errors) { vally_lint_status: "fail", smoke_status: "not_run", version_match_status: "not_run", + ref_sha_consistency_status: "not_run", canvas_structure_status: "not_run", failure_class: "submitter_fixes", summary: "Plugin entry failed external.json validation. Fix the listed errors and re-run quality checks.", vally_lint_output: output, smoke_output: "Install smoke test skipped due to external.json validation errors.", version_match_output: "Version match skipped due to external.json validation errors.", + ref_sha_consistency_output: "Ref/SHA consistency check skipped due to external.json validation errors.", canvas_structure_output: "Canvas structure check skipped due to external.json validation errors.", }; } @@ -107,7 +109,7 @@ export async function runExternalPluginPrQualityGates(plugins) { ? "No changed external plugin entries were detected in plugins/external.json." : checkedPlugins .map((entry) => - `- ${entry.name}: vally-lint=${entry.quality.vally_lint_status}, install-smoke=${entry.quality.smoke_status}, version-match=${entry.quality.version_match_status}, canvas-structure=${entry.quality.canvas_structure_status}, overall=${entry.quality.overall_status}` + `- ${entry.name}: vally-lint=${entry.quality.vally_lint_status}, install-smoke=${entry.quality.smoke_status}, version-match=${entry.quality.version_match_status}, ref-sha-consistency=${entry.quality.ref_sha_consistency_status}, canvas-structure=${entry.quality.canvas_structure_status}, overall=${entry.quality.overall_status}` ) .join("\n"); diff --git a/eng/external-plugin-quality-gates.mjs b/eng/external-plugin-quality-gates.mjs index 1392862f..014f9b83 100644 --- a/eng/external-plugin-quality-gates.mjs +++ b/eng/external-plugin-quality-gates.mjs @@ -6,6 +6,7 @@ import path from "path"; import { Writable } from "stream"; import { spawnSync } from "child_process"; import { runLint, LintConsoleReporter } from "@microsoft/vally"; +import { evaluateRefShaConsistency, normalizeCommitSha } from "./lib/external-plugin-source-ref-sha.mjs"; const MAX_OUTPUT_LENGTH = 12000; const EXTERNAL_CANVAS_KEYWORD = "canvas"; @@ -384,6 +385,7 @@ function readPluginManifestAtLocator(repoDir, readRef, locator, normalizedPlugin message: `Invalid JSON in "${manifestPath}" at "${locator}": ${error.message}`, }; } + } if (isMissingPathAtLocator(showResult.output)) { @@ -402,6 +404,87 @@ function readPluginManifestAtLocator(repoDir, readRef, locator, normalizedPlugin }; } +function resolveCommitShaAtReadRef(repoDir, readRef, locator) { + const revParse = runCommand("git", ["rev-parse", `${readRef}^{commit}`], { cwd: repoDir }); + if (revParse.exitCode !== 0) { + return { + status: "fail", + commitSha: null, + output: `source.ref "${locator}" does not identify a commit (it may point to a tag object, tree, or blob); only commit-backed refs are supported`, + }; + } + + const commitSha = normalizeCommitSha(revParse.stdout); + if (!commitSha) { + return { + status: "infra_error", + commitSha: null, + output: `Unable to parse commit SHA for "${locator}" from "${readRef}".`, + }; + } + + return { + status: "pass", + commitSha, + output: "", + }; +} + +export function runRefShaConsistencyGate(repoDir, plugin, primaryFetchSpec) { + const sourceRef = typeof plugin?.source?.ref === "string" ? plugin.source.ref.trim() : ""; + const sourceSha = typeof plugin?.source?.sha === "string" ? plugin.source.sha.trim() : ""; + if (!sourceRef || !sourceSha) { + return { + status: "not_run", + output: "Ref/SHA consistency gate skipped because one of source.ref or source.sha was not provided.", + }; + } + + const refResult = resolveLocatorReadRef(repoDir, sourceRef, primaryFetchSpec); + if (refResult.status === "fail") { + return { + status: "fail", + output: refResult.output, + }; + } + + if (refResult.status === "infra_error") { + return { + status: "infra_error", + output: refResult.output, + }; + } + + const commitResult = resolveCommitShaAtReadRef(repoDir, refResult.readRef, sourceRef); + if (commitResult.status !== "pass") { + return commitResult; + } + + const consistency = evaluateRefShaConsistency({ + ref: sourceRef, + sha: sourceSha, + resolvedRefCommitSha: commitResult.commitSha, + }); + if (!consistency.comparable) { + return { + status: "not_run", + output: "Ref/SHA consistency gate skipped because source.sha is not a full 40-character commit SHA.", + }; + } + + if (!consistency.matches) { + return { + status: "fail", + output: `source.ref "${sourceRef}" resolves to "${consistency.normalizedRefCommitSha}", which does not match source.sha "${sourceSha}".`, + }; + } + + return { + status: "pass", + output: `source.ref "${sourceRef}" resolves to the same commit as source.sha "${sourceSha}".`, + }; +} + export function runVersionMatchGate(repoDir, plugin, primaryFetchSpec) { const expectedVersion = String(plugin?.version ?? "").trim(); const normalizedPluginPath = normalizePluginPath(plugin?.source?.path || "/"); @@ -745,12 +828,14 @@ export async function runExternalPluginQualityGates(plugin) { vally_lint_status: "not_run", smoke_status: "not_run", version_match_status: "not_run", + ref_sha_consistency_status: "not_run", canvas_structure_status: "not_run", failure_class: "none", summary: "", vally_lint_output: "", smoke_output: "", version_match_output: "", + ref_sha_consistency_output: "", canvas_structure_output: "", }; @@ -763,6 +848,7 @@ export async function runExternalPluginQualityGates(plugin) { result.vally_lint_status = "fail"; result.smoke_status = "fail"; result.version_match_status = "fail"; + result.ref_sha_consistency_status = "not_run"; result.canvas_structure_status = hasCanvasKeyword(plugin) ? "fail" : "not_run"; result.overall_status = "fail"; result.failure_class = "submitter_fixes"; @@ -778,6 +864,10 @@ export async function runExternalPluginQualityGates(plugin) { result.version_match_status = versionMatchResult.status; result.version_match_output = versionMatchResult.output; + const refShaConsistencyResult = runRefShaConsistencyGate(repoDir, plugin, fetchSpec); + result.ref_sha_consistency_status = refShaConsistencyResult.status; + result.ref_sha_consistency_output = refShaConsistencyResult.output; + const canvasStructureResult = runCanvasStructureGate(repoDir, plugin, fetchSpec); result.canvas_structure_status = canvasStructureResult.status; result.canvas_structure_output = canvasStructureResult.output; @@ -794,6 +884,7 @@ export async function runExternalPluginQualityGates(plugin) { result.vally_lint_status, result.smoke_status, result.version_match_status, + result.ref_sha_consistency_status, result.canvas_structure_status, ]); result.failure_class = toFailureClass(result.overall_status); @@ -801,6 +892,7 @@ export async function runExternalPluginQualityGates(plugin) { `- vally lint: ${result.vally_lint_status}`, `- install smoke test: ${result.smoke_status}`, `- version match: ${result.version_match_status}`, + `- ref/sha consistency: ${result.ref_sha_consistency_status}`, `- canvas structure: ${result.canvas_structure_status}`, `- overall: ${result.overall_status}`, ].join("\n"); diff --git a/eng/external-plugin-quality-gates.test.mjs b/eng/external-plugin-quality-gates.test.mjs index 14afd091..b24d3037 100644 --- a/eng/external-plugin-quality-gates.test.mjs +++ b/eng/external-plugin-quality-gates.test.mjs @@ -4,7 +4,7 @@ import os from "os"; import path from "path"; import { spawnSync } from "child_process"; import { after, test } from "node:test"; -import { runCanvasStructureGate, runVersionMatchGate } from "./external-plugin-quality-gates.mjs"; +import { runCanvasStructureGate, runRefShaConsistencyGate, runVersionMatchGate } from "./external-plugin-quality-gates.mjs"; const tempDirs = []; @@ -298,3 +298,38 @@ test("runCanvasStructureGate passes when the primary locator is a tag ref", () = assert.equal(result.status, "pass", result.output); assert.match(result.output, /- v1\.0\.0: found "extensions"/); }); + +test("runRefShaConsistencyGate fails when ref and sha point to different commits", () => { + const remoteDir = initRemoteRepo(); + writeValidPluginContent(remoteDir); + const firstSha = commitAll(remoteDir, "Add plugin manifest v1"); + runGit(remoteDir, "tag", "-a", "v1.0.0", "-m", "release 1.0.0"); + fs.writeFileSync(path.join(remoteDir, "README.md"), "v2\n"); + const secondSha = commitAll(remoteDir, "Add plugin manifest v2"); + + const repoDir = cloneSubmissionRepo(remoteDir, secondSha); + const plugin = { + name: "tag-plugin", + source: { source: "github", repo: "owner/repo", ref: "v1.0.0", sha: secondSha }, + }; + + const result = runRefShaConsistencyGate(repoDir, plugin, secondSha); + assert.equal(result.status, "fail", result.output); + assert.match(result.output, new RegExp(`resolves to "${firstSha}"`)); +}); + +test("runRefShaConsistencyGate passes when ref and sha point to the same commit", () => { + const remoteDir = initRemoteRepo(); + writeValidPluginContent(remoteDir); + const sha = commitAll(remoteDir, "Add plugin manifest"); + runGit(remoteDir, "tag", "-a", "v1.0.0", "-m", "release 1.0.0"); + + const repoDir = cloneSubmissionRepo(remoteDir, sha); + const plugin = { + name: "tag-plugin", + source: { source: "github", repo: "owner/repo", ref: "v1.0.0", sha }, + }; + + const result = runRefShaConsistencyGate(repoDir, plugin, sha); + assert.equal(result.status, "pass", result.output); +}); diff --git a/eng/lib/external-plugin-source-ref-sha.mjs b/eng/lib/external-plugin-source-ref-sha.mjs new file mode 100644 index 00000000..3a5d886c --- /dev/null +++ b/eng/lib/external-plugin-source-ref-sha.mjs @@ -0,0 +1,31 @@ +export function normalizeCommitSha(value) { + if (typeof value !== "string") { + return undefined; + } + + const normalized = value.trim().toLowerCase(); + return /^[0-9a-f]{40}$/.test(normalized) ? normalized : undefined; +} + +export function evaluateRefShaConsistency({ ref, sha, resolvedRefCommitSha }) { + const normalizedSha = normalizeCommitSha(sha); + const normalizedRefCommitSha = normalizeCommitSha(resolvedRefCommitSha); + + if (!normalizedSha || !normalizedRefCommitSha) { + return { + comparable: false, + matches: true, + normalizedSha, + normalizedRefCommitSha, + }; + } + + return { + comparable: true, + matches: normalizedSha === normalizedRefCommitSha, + normalizedSha, + normalizedRefCommitSha, + ref: typeof ref === "string" ? ref.trim() : "", + sha: typeof sha === "string" ? sha.trim() : "", + }; +}