Skip to content

Commit 8ae5a99

Browse files
aaronpowellCopilot
andauthored
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
1 parent 1e14bd4 commit 8ae5a99

7 files changed

Lines changed: 397 additions & 8 deletions

.github/workflows/external-plugin-pr-quality-gates.yml

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -279,17 +279,19 @@ jobs:
279279
const vallyLintStatus = escapeMarkdownTableCell(quality.vally_lint_status || 'not_run');
280280
const smokeStatus = escapeMarkdownTableCell(quality.smoke_status || 'not_run');
281281
const versionMatchStatus = escapeMarkdownTableCell(quality.version_match_status || 'not_run');
282+
const refShaConsistencyStatus = escapeMarkdownTableCell(quality.ref_sha_consistency_status || 'not_run');
282283
const canvasStructureStatus = escapeMarkdownTableCell(quality.canvas_structure_status || 'not_run');
283284
const overallStatus = escapeMarkdownTableCell(quality.overall_status || 'not_run');
284-
return `| ${name} | ${vallyLintStatus} | ${smokeStatus} | ${versionMatchStatus} | ${canvasStructureStatus} | ${overallStatus} | ${sourceCell} |`;
285+
return `| ${name} | ${vallyLintStatus} | ${smokeStatus} | ${versionMatchStatus} | ${refShaConsistencyStatus} | ${canvasStructureStatus} | ${overallStatus} | ${sourceCell} |`;
285286
})
286-
: ['| _none_ | not_run | not_run | not_run | not_run | not_run | _n/a_ |'];
287+
: ['| _none_ | not_run | not_run | not_run | not_run | not_run | not_run | _n/a_ |'];
287288
const failureDetails = checkedPlugins.flatMap((entry) => {
288289
const name = String(entry?.name || 'unknown');
289290
const quality = entry?.quality || {};
290291
const shouldShowVally = quality.vally_lint_status === 'fail' || quality.vally_lint_status === 'infra_error' || String(quality.vally_lint_output || '').trim().length > 0;
291292
const shouldShowSmoke = quality.smoke_status === 'fail' || quality.smoke_status === 'infra_error' || String(quality.smoke_output || '').trim().length > 0;
292293
const shouldShowVersionMatch = quality.version_match_status === 'fail' || quality.version_match_status === 'infra_error' || String(quality.version_match_output || '').trim().length > 0;
294+
const shouldShowRefShaConsistency = quality.ref_sha_consistency_status === 'fail' || quality.ref_sha_consistency_status === 'infra_error' || String(quality.ref_sha_consistency_output || '').trim().length > 0;
293295
const shouldShowCanvasStructure = quality.canvas_structure_status === 'fail' || quality.canvas_structure_status === 'infra_error' || String(quality.canvas_structure_output || '').trim().length > 0;
294296
295297
const details = [];
@@ -302,6 +304,9 @@ jobs:
302304
if (shouldShowVersionMatch) {
303305
details.push(formatGateOutput(name, 'version match', quality.version_match_status, quality.version_match_output));
304306
}
307+
if (shouldShowRefShaConsistency) {
308+
details.push(formatGateOutput(name, 'ref/sha consistency', quality.ref_sha_consistency_status, quality.ref_sha_consistency_output));
309+
}
305310
if (shouldShowCanvasStructure) {
306311
details.push(formatGateOutput(name, 'canvas structure', quality.canvas_structure_status, quality.canvas_structure_output));
307312
}
@@ -317,8 +322,8 @@ jobs:
317322
'',
318323
'### Per-plugin quality summary',
319324
'',
320-
'| Plugin | vally lint | install smoke test | version match | canvas structure | overall | source tree |',
321-
'|---|---|---|---|---|---|---|',
325+
'| Plugin | vally lint | install smoke test | version match | ref/sha consistency | canvas structure | overall | source tree |',
326+
'|---|---|---|---|---|---|---|---|',
322327
...rows,
323328
'',
324329
...(failureDetails.length > 0

eng/external-plugin-intake.mjs

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import path from "path";
55
import { fileURLToPath } from "url";
66
import { ROOT_FOLDER } from "./constants.mjs";
77
import { readExternalPlugins, validateExternalPlugin } from "./external-plugin-validation.mjs";
8+
import { evaluateRefShaConsistency, normalizeCommitSha } from "./lib/external-plugin-source-ref-sha.mjs";
89

910
export const ISSUE_FORM_MARKER = "<!-- external-plugin-submission -->";
1011
export const EXTERNAL_PLUGIN_INTAKE_COMMENT_MARKER = "<!-- external-plugin-intake -->";
@@ -293,9 +294,23 @@ function encodeRepoPath(repo) {
293294
return `${encodeURIComponent(owner ?? "")}/${encodeURIComponent(name ?? "")}`;
294295
}
295296

297+
async function resolveCommitSha(repo, locator, token) {
298+
const encodedRepo = encodeRepoPath(repo);
299+
const commitResponse = await fetchGitHubJson(`/repos/${encodedRepo}/commits/${encodeURIComponent(locator)}`, token);
300+
if (commitResponse.kind !== "found") {
301+
return commitResponse;
302+
}
303+
304+
return {
305+
...commitResponse,
306+
commitSha: normalizeCommitSha(commitResponse.data?.sha),
307+
};
308+
}
309+
296310
async function validateRemoteRepository(repo, { ref, sha }, errors, warnings, token) {
297311
const encodedRepo = encodeRepoPath(repo);
298312
const repositoryResponse = await fetchGitHubJson(`/repos/${encodedRepo}`, token);
313+
const normalizedSha = normalizeCommitSha(sha);
299314

300315
if (repositoryResponse.kind === "notFound") {
301316
errors.push(`submission: GitHub repository "${repo}" was not found`);
@@ -333,6 +348,19 @@ async function validateRemoteRepository(repo, { ref, sha }, errors, warnings, to
333348

334349
}
335350

351+
function validateRefShaConsistency(refCommitSha) {
352+
if (!normalizedSha || !refCommitSha) {
353+
return;
354+
}
355+
356+
const consistency = evaluateRefShaConsistency({ ref, sha, resolvedRefCommitSha: refCommitSha });
357+
if (!consistency.matches) {
358+
errors.push(
359+
`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}")`,
360+
);
361+
}
362+
}
363+
336364
if (!ref) {
337365
return;
338366
}
@@ -347,6 +375,8 @@ async function validateRemoteRepository(repo, { ref, sha }, errors, warnings, to
347375
`submission: could not verify commit "${ref}" in GitHub repository "${repo}" (${statusText}${commitResponse.reason ? ` — ${commitResponse.reason}` : ""}); a maintainer should re-run intake`,
348376
);
349377
}
378+
379+
validateRefShaConsistency(normalizeCommitSha(ref));
350380
return;
351381
}
352382

@@ -362,6 +392,38 @@ async function validateRemoteRepository(repo, { ref, sha }, errors, warnings, to
362392
const tagResponse = await fetchGitHubJson(`/repos/${encodedRepo}/git/ref/tags/${encodeURIComponent(tagName)}`, token);
363393

364394
if (tagResponse.kind === "found") {
395+
if (!normalizedSha) {
396+
return;
397+
}
398+
399+
const resolvedRefResponse = await resolveCommitSha(repo, ref, token);
400+
if (resolvedRefResponse.kind === "notFound") {
401+
errors.push(`submission: ref "${ref}" could not be resolved to a commit in GitHub repository "${repo}"`);
402+
return;
403+
}
404+
405+
if (resolvedRefResponse.kind === "apiError") {
406+
if (resolvedRefResponse.status === 422) {
407+
errors.push(
408+
`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`,
409+
);
410+
return;
411+
}
412+
const statusText = resolvedRefResponse.status ? `HTTP ${resolvedRefResponse.status}` : "network error";
413+
warnings.push(
414+
`submission: could not resolve ref "${ref}" to a commit in GitHub repository "${repo}" (${statusText}${resolvedRefResponse.reason ? ` — ${resolvedRefResponse.reason}` : ""}); a maintainer should re-run intake`,
415+
);
416+
return;
417+
}
418+
419+
if (!resolvedRefResponse.commitSha) {
420+
warnings.push(
421+
`submission: could not determine the commit SHA for ref "${ref}" in GitHub repository "${repo}"; a maintainer should re-run intake`,
422+
);
423+
return;
424+
}
425+
426+
validateRefShaConsistency(resolvedRefResponse.commitSha);
365427
return;
366428
}
367429

@@ -724,12 +786,14 @@ function normalizeQualityGateResult(rawResult) {
724786
vally_lint_status: "not_run",
725787
smoke_status: "not_run",
726788
version_match_status: "not_run",
789+
ref_sha_consistency_status: "not_run",
727790
canvas_structure_status: "not_run",
728791
failure_class: "none",
729792
summary: "",
730793
vally_lint_output: "",
731794
smoke_output: "",
732795
version_match_output: "",
796+
ref_sha_consistency_output: "",
733797
canvas_structure_output: "",
734798
};
735799

@@ -747,6 +811,7 @@ function buildQualityGatesCommentSection(qualityResult) {
747811
const vallyState = qualityResult.vally_lint_status || "not_run";
748812
const smokeState = qualityResult.smoke_status || "not_run";
749813
const versionMatchState = qualityResult.version_match_status || "not_run";
814+
const refShaConsistencyState = qualityResult.ref_sha_consistency_status || "not_run";
750815
const canvasStructureState = qualityResult.canvas_structure_status || "not_run";
751816
const summaryText = String(qualityResult.summary || "").trim() || "_No quality gate details were provided._";
752817

@@ -758,6 +823,7 @@ function buildQualityGatesCommentSection(qualityResult) {
758823
`| vally lint | ${vallyState} |`,
759824
`| install smoke test | ${smokeState} |`,
760825
`| version match | ${versionMatchState} |`,
826+
`| ref/sha consistency | ${refShaConsistencyState} |`,
761827
`| canvas structure | ${canvasStructureState} |`,
762828
"",
763829
summaryText,
@@ -808,6 +874,21 @@ function buildQualityGatesCommentSection(qualityResult) {
808874
);
809875
}
810876

877+
const refShaConsistencyOutput = String(qualityResult.ref_sha_consistency_output || "").trim();
878+
if (refShaConsistencyOutput) {
879+
sections.push(
880+
"",
881+
"<details>",
882+
"<summary>Ref/SHA consistency output</summary>",
883+
"",
884+
"```text",
885+
refShaConsistencyOutput,
886+
"```",
887+
"",
888+
"</details>",
889+
);
890+
}
891+
811892
const canvasStructureOutput = String(qualityResult.canvas_structure_output || "").trim();
812893
if (canvasStructureOutput) {
813894
sections.push(

eng/external-plugin-intake.test.mjs

Lines changed: 145 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import assert from "node:assert/strict";
2-
import { test } from "node:test";
3-
import { validateCanvasPluginMetadata } from "./external-plugin-intake.mjs";
2+
import { afterEach, test } from "node:test";
3+
import { evaluateExternalPluginIssue, validateCanvasPluginMetadata } from "./external-plugin-intake.mjs";
44

55
const REPO = "owner/repo";
66
const SHA = "0123456789abcdef0123456789abcdef01234567";
@@ -258,3 +258,146 @@ test("validateCanvasPluginMetadata accepts an entry point found within a truncat
258258
assert.deepEqual(errors, []);
259259
assert.deepEqual(warnings, []);
260260
});
261+
262+
// ---------------------------------------------------------------------------
263+
// ref/sha consistency tests (evaluateExternalPluginIssue)
264+
// ---------------------------------------------------------------------------
265+
266+
const ORIGINAL_FETCH = global.fetch;
267+
const INTAKE_REPO = "octo/example";
268+
const RESOLVED_REF_SHA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
269+
const PROVIDED_SHA = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
270+
271+
afterEach(() => {
272+
global.fetch = ORIGINAL_FETCH;
273+
});
274+
275+
function buildIssueBody({ ref, sha }) {
276+
return [
277+
"<!-- external-plugin-submission -->",
278+
"### Plugin name",
279+
"",
280+
"intake-ref-sha-consistency-test-plugin",
281+
"",
282+
"### Short description",
283+
"",
284+
"Test plugin for external intake validation.",
285+
"",
286+
"### GitHub repository",
287+
"",
288+
INTAKE_REPO,
289+
"",
290+
"### Plugin path inside the repository",
291+
"",
292+
"_No response_",
293+
"",
294+
"### Ref to review",
295+
"",
296+
ref,
297+
"",
298+
"### Commit SHA to review",
299+
"",
300+
sha,
301+
"",
302+
"### Version",
303+
"",
304+
"1.2.3",
305+
"",
306+
"### License identifier",
307+
"",
308+
"MIT",
309+
"",
310+
"### Author name",
311+
"",
312+
"Copilot Test",
313+
"",
314+
"### Author URL",
315+
"",
316+
"_No response_",
317+
"",
318+
"### Homepage URL",
319+
"",
320+
"_No response_",
321+
"",
322+
"### Keywords",
323+
"",
324+
"testing",
325+
"",
326+
"### Additional notes for reviewers",
327+
"",
328+
"_No response_",
329+
"",
330+
"### Submission checklist",
331+
"",
332+
"- [x] The plugin lives in a public GitHub repository.",
333+
"- [x] The ref and/or sha I provided is immutable (release tag and/or full 40-character commit SHA), not a branch.",
334+
"- [x] This submission follows this repository's contribution, security, and responsible AI policies.",
335+
"- [x] This plugin is not already listed in the Awesome Copilot marketplace.",
336+
"",
337+
].join("\n");
338+
}
339+
340+
function jsonResponse(payload, { status = 200 } = {}) {
341+
return {
342+
ok: status >= 200 && status < 300,
343+
status,
344+
statusText: status === 404 ? "Not Found" : "OK",
345+
headers: new Map(),
346+
async json() {
347+
return payload;
348+
},
349+
};
350+
}
351+
352+
function installMockFetch() {
353+
global.fetch = async (url) => {
354+
const requestUrl = String(url);
355+
if (requestUrl === `https://api.github.com/repos/${INTAKE_REPO}`) {
356+
return jsonResponse({ private: false, archived: false });
357+
}
358+
359+
if (
360+
requestUrl === `https://api.github.com/repos/${INTAKE_REPO}/git/commits/${PROVIDED_SHA}` ||
361+
requestUrl === `https://api.github.com/repos/${INTAKE_REPO}/git/commits/${RESOLVED_REF_SHA}`
362+
) {
363+
const sha = requestUrl.endsWith(RESOLVED_REF_SHA) ? RESOLVED_REF_SHA : PROVIDED_SHA;
364+
return jsonResponse({ sha });
365+
}
366+
367+
if (requestUrl === `https://api.github.com/repos/${INTAKE_REPO}/git/ref/tags/v1.2.3`) {
368+
return jsonResponse({ object: { type: "tag", sha: "cccccccccccccccccccccccccccccccccccccccc" } });
369+
}
370+
371+
if (requestUrl === `https://api.github.com/repos/${INTAKE_REPO}/commits/v1.2.3`) {
372+
return jsonResponse({ sha: RESOLVED_REF_SHA });
373+
}
374+
375+
return jsonResponse({}, { status: 404 });
376+
};
377+
}
378+
379+
test("evaluateExternalPluginIssue fails when ref and sha resolve to different commits", async () => {
380+
installMockFetch();
381+
const issue = { body: buildIssueBody({ ref: "v1.2.3", sha: PROVIDED_SHA }) };
382+
383+
const result = await evaluateExternalPluginIssue({ issue });
384+
385+
assert.equal(result.valid, false);
386+
assert.match(
387+
result.commentBody,
388+
/must reference the same commit \(ref "v1\.2\.3" resolves to "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", sha is "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"\)/,
389+
);
390+
});
391+
392+
test("evaluateExternalPluginIssue passes when ref and sha resolve to the same commit", async () => {
393+
installMockFetch();
394+
const issue = { body: buildIssueBody({ ref: "v1.2.3", sha: RESOLVED_REF_SHA }) };
395+
396+
const result = await evaluateExternalPluginIssue({ issue });
397+
398+
assert.equal(result.valid, true);
399+
assert.equal(
400+
result.errors.some((error) => error.includes("must reference the same commit")),
401+
false,
402+
);
403+
});

eng/external-plugin-pr-quality-gates.mjs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,12 +74,14 @@ function createValidationFailureQuality(errors) {
7474
vally_lint_status: "fail",
7575
smoke_status: "not_run",
7676
version_match_status: "not_run",
77+
ref_sha_consistency_status: "not_run",
7778
canvas_structure_status: "not_run",
7879
failure_class: "submitter_fixes",
7980
summary: "Plugin entry failed external.json validation. Fix the listed errors and re-run quality checks.",
8081
vally_lint_output: output,
8182
smoke_output: "Install smoke test skipped due to external.json validation errors.",
8283
version_match_output: "Version match skipped due to external.json validation errors.",
84+
ref_sha_consistency_output: "Ref/SHA consistency check skipped due to external.json validation errors.",
8385
canvas_structure_output: "Canvas structure check skipped due to external.json validation errors.",
8486
};
8587
}
@@ -107,7 +109,7 @@ export async function runExternalPluginPrQualityGates(plugins) {
107109
? "No changed external plugin entries were detected in plugins/external.json."
108110
: checkedPlugins
109111
.map((entry) =>
110-
`- ${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}`
112+
`- ${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}`
111113
)
112114
.join("\n");
113115

0 commit comments

Comments
 (0)