chore: publish from main

This commit is contained in:
github-actions[bot]
2026-07-29 04:05:59 +00:00
parent b03284e90e
commit 8fd72adebe
4 changed files with 560 additions and 34 deletions
+124 -29
View File
@@ -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/<tag>"
// 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 "<name>/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>/extension.mjs" at ${releaseLocatorDescription}`,
);
}
}
}
const previewPath = joinRepoPath(pluginRoot, EXTERNAL_CANVAS_PREVIEW_PATH);
+260
View File
@@ -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, []);
});
+92 -5
View File
@@ -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 "<name>/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>/extension.mjs").`,
);
}
continue;
}
messages.push(`- ${locator}: found "${extensionsDir}" with entry point "${extensionEntryPoint}".`);
messages.push(`- ${locator}: found "${extensionsDir}" with entry point "${extensionEntryCheck.entryPoint}".`);
}
if (hasInfraError) {
@@ -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 <tag>`
// only updates FETCH_HEAD and never creates a local `refs/tags/<tag>`, so reading via