Add canvas-specific intake validation for external plugins (#2319)

* Add canvas-aware checks to external plugin intake

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 0f03fd92-3bfa-4c67-a709-177fbd46c40e

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Aaron Powell
2026-07-16 16:58:02 +10:00
committed by GitHub
parent 1a77b83008
commit fb80ec4f21
5 changed files with 180 additions and 2 deletions
+3 -1
View File
@@ -15,6 +15,8 @@ body:
- Public submissions are **GitHub-only** in v1. - Public submissions are **GitHub-only** in v1.
- The plugin must live in a **public GitHub repository**. - The plugin must live in a **public GitHub repository**.
- Provide an immutable **ref**, **sha**, or both for review. - Provide an immutable **ref**, **sha**, or both for review.
- If your plugin includes a canvas extension, include the **canvas** keyword.
- Canvas plugins are validated for `logo: "assets/preview.png"` using the submitted immutable **sha** or **ref**.
- Do **not** open a PR that edits `plugins/external.json` directly. - Do **not** open a PR that edits `plugins/external.json` directly.
- type: input - type: input
id: plugin-name id: plugin-name
@@ -106,7 +108,7 @@ body:
id: keywords id: keywords
attributes: attributes:
label: Keywords label: Keywords
description: Comma-separated or newline-separated lowercase tags. description: Comma-separated or newline-separated lowercase tags. Include `canvas` if the plugin contains a canvas extension.
placeholder: | placeholder: |
automation automation
github github
@@ -559,7 +559,11 @@ jobs:
owner: context.repo.owner, owner: context.repo.owner,
repo: context.repo.repo, repo: context.repo.repo,
issueNumber: context.issue.number, issueNumber: context.issue.number,
desiredLabels: new Set(['external-plugin', 'ready-for-review']) desiredLabels: new Set([
'external-plugin',
'ready-for-review',
...(labelNames.has('external-plugin-canvas') ? ['external-plugin-canvas'] : [])
])
}); });
const marker = '<!-- external-plugin-mark-ready-override -->'; const marker = '<!-- external-plugin-mark-ready-override -->';
+4
View File
@@ -44,6 +44,10 @@ jobs:
color: 'FEF2C0', color: 'FEF2C0',
description: 'Public external plugin submission' description: 'Public external plugin submission'
}, },
'external-plugin-canvas': {
color: '1D76DB',
description: 'External plugin submission includes a canvas extension'
},
'hooks': { 'hooks': {
color: 'C2E0C6', color: 'C2E0C6',
description: 'PR touches hooks' description: 'PR touches hooks'
+8
View File
@@ -3,6 +3,10 @@ export const EXTERNAL_PLUGIN_INTAKE_LABELS = Object.freeze({
color: "FEF2C0", color: "FEF2C0",
description: "Public external plugin submission", description: "Public external plugin submission",
}, },
"external-plugin-canvas": {
color: "1D76DB",
description: "External plugin submission includes a canvas extension",
},
"awaiting-review": { "awaiting-review": {
color: "FBCA04", color: "FBCA04",
description: "Submission is waiting for automated intake validation", description: "Submission is waiting for automated intake validation",
@@ -27,6 +31,7 @@ export const EXTERNAL_PLUGIN_INTAKE_LABELS = Object.freeze({
const EXTERNAL_PLUGIN_INTAKE_SYNC_LABELS = Object.freeze([ const EXTERNAL_PLUGIN_INTAKE_SYNC_LABELS = Object.freeze([
"external-plugin", "external-plugin",
"external-plugin-canvas",
"awaiting-review", "awaiting-review",
"ready-for-review", "ready-for-review",
"requires-submitter-fixes", "requires-submitter-fixes",
@@ -129,6 +134,9 @@ export async function applyExternalPluginIntakeEvaluation({
rejected: new Set(["external-plugin", "rejected"]), rejected: new Set(["external-plugin", "rejected"]),
}; };
const desiredLabels = desiredLabelsByState[state] ?? desiredLabelsByState.rejected; const desiredLabels = desiredLabelsByState[state] ?? desiredLabelsByState.rejected;
if (evaluation.isCanvasPlugin) {
desiredLabels.add("external-plugin-canvas");
}
await syncExternalPluginIntakeLabels({ await syncExternalPluginIntakeLabels({
github, github,
+160
View File
@@ -54,6 +54,13 @@ const FIELD_TITLES = Object.freeze({
const LEGACY_FIELD_TITLES = Object.freeze({ const LEGACY_FIELD_TITLES = Object.freeze({
immutableRef: "Immutable ref to review", immutableRef: "Immutable ref to review",
}); });
const EXTERNAL_CANVAS_KEYWORD = "canvas";
const EXTERNAL_CANVAS_PREVIEW_PATH = "assets/preview.png";
const EXTERNAL_PLUGIN_ROOT_MANIFEST_PATHS = Object.freeze([
".github/plugin/plugin.json",
".plugin/plugin.json",
"plugin.json",
]);
function normalizeMultilineText(value) { function normalizeMultilineText(value) {
return String(value ?? "").replace(/\r\n/g, "\n"); return String(value ?? "").replace(/\r\n/g, "\n");
@@ -116,6 +123,29 @@ function parseKeywords(value) {
return keywords.length > 0 ? keywords : undefined; return keywords.length > 0 ? keywords : undefined;
} }
function hasCanvasKeyword(plugin) {
return (plugin?.keywords ?? []).some(
(keyword) => String(keyword).trim().toLowerCase() === EXTERNAL_CANVAS_KEYWORD,
);
}
function normalizeRepoRelativePath(value) {
const normalized = stripNoResponse(value);
if (!normalized || normalized === "/") {
return "";
}
return normalized.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
}
function joinRepoPath(...segments) {
return segments
.map((segment) => String(segment ?? "").trim())
.filter(Boolean)
.join("/")
.replace(/\/+/g, "/");
}
function parseChecklist(value) { function parseChecklist(value) {
const checked = new Set(); const checked = new Set();
const normalized = normalizeMultilineText(value); const normalized = normalizeMultilineText(value);
@@ -231,6 +261,33 @@ async function fetchGitHubJson(apiPath, token) {
} }
} }
function encodeRepoContentPath(value) {
return String(value)
.split("/")
.filter((segment) => segment.length > 0)
.map((segment) => encodeURIComponent(segment))
.join("/");
}
async function fetchGitHubFile(repo, filePath, ref, token) {
const encodedRepo = encodeRepoPath(repo);
const encodedPath = encodeRepoContentPath(filePath);
return fetchGitHubJson(
`/repos/${encodedRepo}/contents/${encodedPath}?ref=${encodeURIComponent(ref)}`,
token,
);
}
function decodeGitHubFileContent(fileResponse) {
const encodedContent = fileResponse?.data?.content;
if (!encodedContent || typeof encodedContent !== "string") {
return null;
}
const normalized = encodedContent.replace(/\n/g, "");
return Buffer.from(normalized, "base64").toString("utf8");
}
function encodeRepoPath(repo) { function encodeRepoPath(repo) {
const [owner, name] = String(repo).split("/"); const [owner, name] = String(repo).split("/");
return `${encodeURIComponent(owner ?? "")}/${encodeURIComponent(name ?? "")}`; return `${encodeURIComponent(owner ?? "")}/${encodeURIComponent(name ?? "")}`;
@@ -273,6 +330,7 @@ async function validateRemoteRepository(repo, { ref, sha }, errors, warnings, to
); );
} }
} }
} }
if (!ref) { if (!ref) {
@@ -322,6 +380,102 @@ async function validateRemoteRepository(repo, { ref, sha }, errors, warnings, to
} }
} }
async function validateCanvasPluginMetadata(plugin, errors, warnings, token) {
const repo = plugin?.source?.repo;
const sha = plugin?.source?.sha;
const ref = plugin?.source?.ref;
const releaseLocator = sha || ref;
const releaseLocatorDescription = sha ? `commit "${sha}"` : `ref "${ref}"`;
const pluginRoot = normalizeRepoRelativePath(plugin?.source?.path);
if (!releaseLocator) {
errors.push('submission: plugins tagged with "canvas" must provide "Ref to review" and/or "Commit SHA to review"');
return;
}
if (!repo) {
return;
}
let manifest = null;
let manifestPath = null;
let sawManifestApiError = false;
const manifestCandidates = EXTERNAL_PLUGIN_ROOT_MANIFEST_PATHS.map((relativePath) =>
joinRepoPath(pluginRoot, relativePath),
);
for (const candidatePath of manifestCandidates) {
const response = await fetchGitHubFile(repo, candidatePath, releaseLocator, token);
if (response.kind === "notFound") {
continue;
}
if (response.kind === "apiError") {
sawManifestApiError = true;
continue;
}
if (response.data?.type !== "file") {
continue;
}
const decoded = decodeGitHubFileContent(response);
if (!decoded) {
errors.push(`submission: could not decode plugin manifest "${candidatePath}" at ${releaseLocatorDescription}`);
return;
}
try {
manifest = JSON.parse(decoded);
manifestPath = candidatePath;
break;
} catch (error) {
errors.push(
`submission: plugin manifest "${candidatePath}" at ${releaseLocatorDescription} is not valid JSON (${error.message})`,
);
return;
}
}
if (!manifest) {
if (sawManifestApiError) {
warnings.push(
`submission: could not verify canvas plugin manifest in GitHub repository "${repo}" at ${releaseLocatorDescription}; a maintainer should re-run intake`,
);
return;
}
const expectedPaths = manifestCandidates.map((candidatePath) => `"${candidatePath}"`).join(", ");
errors.push(
`submission: plugins tagged with "canvas" must include a manifest at one of ${expectedPaths} in ${releaseLocatorDescription}`,
);
return;
}
if (manifest.logo !== EXTERNAL_CANVAS_PREVIEW_PATH) {
errors.push(
`submission: plugins tagged with "canvas" must set "logo" to "${EXTERNAL_CANVAS_PREVIEW_PATH}" in "${manifestPath}"`,
);
}
const previewPath = joinRepoPath(pluginRoot, EXTERNAL_CANVAS_PREVIEW_PATH);
const previewResponse = await fetchGitHubFile(repo, previewPath, releaseLocator, token);
if (previewResponse.kind === "notFound") {
errors.push(
`submission: plugins tagged with "canvas" must include "${EXTERNAL_CANVAS_PREVIEW_PATH}" at ${releaseLocatorDescription}`,
);
} else if (previewResponse.kind === "apiError") {
warnings.push(
`submission: could not verify "${EXTERNAL_CANVAS_PREVIEW_PATH}" in GitHub repository "${repo}" at ${releaseLocatorDescription}; a maintainer should re-run intake`,
);
} else if (previewResponse.data?.type !== "file") {
errors.push(
`submission: "${EXTERNAL_CANVAS_PREVIEW_PATH}" must be a file in ${releaseLocatorDescription}`,
);
}
}
export function parseExternalPluginIssueBody(body) { export function parseExternalPluginIssueBody(body) {
const sections = parseIssueFormSections(body); const sections = parseIssueFormSections(body);
const errors = []; const errors = [];
@@ -606,6 +760,7 @@ export async function evaluateExternalPluginIssue({ issue, token, runId, owner,
const validationResult = validateExternalPlugin(parsed.plugin, 0, { policy: "publicSubmission" }); const validationResult = validateExternalPlugin(parsed.plugin, 0, { policy: "publicSubmission" });
errors.push(...validationResult.errors.map(toSubmissionError)); errors.push(...validationResult.errors.map(toSubmissionError));
warnings.push(...validationResult.warnings.map(toSubmissionError)); warnings.push(...validationResult.warnings.map(toSubmissionError));
const isCanvasPlugin = hasCanvasKeyword(parsed.plugin);
if (parsed.plugin?.name) { if (parsed.plugin?.name) {
const matchingName = duplicateNames.find( const matchingName = duplicateNames.find(
@@ -620,6 +775,10 @@ export async function evaluateExternalPluginIssue({ issue, token, runId, owner,
await validateRemoteRepository(parsed.plugin.source.repo, parsed.plugin.source, errors, warnings, token); await validateRemoteRepository(parsed.plugin.source.repo, parsed.plugin.source, errors, warnings, token);
} }
if (isCanvasPlugin) {
await validateCanvasPluginMetadata(parsed.plugin, errors, warnings, token);
}
const dedupedErrors = [...new Set(errors)]; const dedupedErrors = [...new Set(errors)];
const dedupedWarnings = [...new Set(warnings)]; const dedupedWarnings = [...new Set(warnings)];
const valid = dedupedErrors.length === 0; const valid = dedupedErrors.length === 0;
@@ -687,6 +846,7 @@ export async function evaluateExternalPluginIssue({ issue, token, runId, owner,
errors: dedupedErrors, errors: dedupedErrors,
warnings: dedupedWarnings, warnings: dedupedWarnings,
plugin: parsed.plugin, plugin: parsed.plugin,
isCanvasPlugin,
commentBody, commentBody,
commentMarker: marker, commentMarker: marker,
}; };