Files
awesome-copilot/.github/workflows/external-plugin-pr-quality-gates.yml
T
Aaron Powell 8ae5a99109 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
2026-07-29 15:28:13 +10:00

350 lines
16 KiB
YAML

name: External Plugin PR Quality Gates
on:
pull_request_target:
branches: [main]
paths:
- "plugins/external.json"
types: [opened, synchronize, reopened, edited, ready_for_review]
concurrency:
group: external-plugin-pr-quality-${{ github.event.pull_request.number }}
cancel-in-progress: true
permissions:
contents: read
jobs:
detect-changed-plugins:
runs-on: ubuntu-latest
outputs:
changed-plugins: ${{ steps.detect.outputs.changed-plugins }}
changed-count: ${{ steps.detect.outputs.changed-count }}
should-run: ${{ steps.detect.outputs.should-run }}
steps:
- name: Detect changed external plugins
id: detect
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
with:
script: |
const filePath = 'plugins/external.json';
const baseRef = context.payload.pull_request.base.sha;
const headRef = context.payload.pull_request.head.sha;
function normalizePath(value) {
if (!value || value === '/') {
return '';
}
return String(value).trim().replace(/^\/+|\/+$/g, '').toLowerCase();
}
function toIdentity(plugin) {
return [
String(plugin?.name ?? '').trim().toLowerCase(),
String(plugin?.source?.repo ?? '').trim().toLowerCase(),
normalizePath(plugin?.source?.path),
].join('|');
}
async function readExternalJson(ref) {
const response = await github.rest.repos.getContent({
owner: context.repo.owner,
repo: context.repo.repo,
path: filePath,
ref,
});
const encoded = response.data?.content ?? '';
const decoded = Buffer.from(encoded, 'base64').toString('utf8');
return JSON.parse(decoded);
}
const basePlugins = await readExternalJson(baseRef);
const headPlugins = await readExternalJson(headRef);
const baseByIdentity = new Map(basePlugins.map((plugin) => [toIdentity(plugin), plugin]));
const changedPlugins = headPlugins.filter((plugin) => {
const identity = toIdentity(plugin);
const basePlugin = baseByIdentity.get(identity);
return !basePlugin || JSON.stringify(basePlugin) !== JSON.stringify(plugin);
});
core.setOutput('changed-plugins', JSON.stringify(changedPlugins));
core.setOutput('changed-count', String(changedPlugins.length));
core.setOutput('should-run', changedPlugins.length > 0 ? 'true' : 'false');
run-quality-gates:
runs-on: ubuntu-latest
needs: detect-changed-plugins
if: needs.detect-changed-plugins.outputs.should-run == 'true'
outputs:
quality-result: ${{ steps.quality.outputs.quality-result }}
steps:
- name: Checkout main branch
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
ref: main
persist-credentials: false
submodules: false
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22
- name: Install GitHub Copilot CLI
run: npm install -g @github/copilot
- name: Install @microsoft/vally
run: npm install @microsoft/vally
- name: Run external plugin PR quality gates
id: quality
env:
CHANGED_PLUGINS_JSON: ${{ needs.detect-changed-plugins.outputs.changed-plugins }}
run: |
result=$(node ./eng/external-plugin-pr-quality-gates.mjs --plugins-json "$CHANGED_PLUGINS_JSON")
{
echo 'quality-result<<EOF'
echo "$result"
echo 'EOF'
} >> "$GITHUB_OUTPUT"
sync-pr-state:
runs-on: ubuntu-latest
needs: [detect-changed-plugins, run-quality-gates]
if: always()
permissions:
contents: read
issues: write
pull-requests: write
steps:
- name: Checkout main branch
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
ref: main
- name: Sync labels and PR status comment
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
env:
DETECT_JOB_RESULT: ${{ needs.detect-changed-plugins.result }}
SHOULD_RUN: ${{ needs.detect-changed-plugins.outputs.should-run }}
CHANGED_COUNT: ${{ needs.detect-changed-plugins.outputs.changed-count }}
QUALITY_RESULT_JSON: ${{ needs.run-quality-gates.outputs.quality-result }}
QUALITY_JOB_RESULT: ${{ needs.run-quality-gates.result }}
with:
script: |
const path = require('path');
const { pathToFileURL } = require('url');
const intakeState = await import(pathToFileURL(path.join(process.env.GITHUB_WORKSPACE, 'eng', 'external-plugin-intake-state.mjs')).href);
const marker = '<!-- external-plugin-pr-quality -->';
const detectJobResult = process.env.DETECT_JOB_RESULT;
const shouldRun = process.env.SHOULD_RUN === 'true';
const changedCount = Number.parseInt(process.env.CHANGED_COUNT || '0', 10);
const qualityJobResult = process.env.QUALITY_JOB_RESULT;
let qualityResult = {
overall_status: 'not_run',
failure_class: 'none',
checked_plugins: [],
summary: 'No changed external plugin entries were detected in this PR.',
};
if (detectJobResult === 'failure' || detectJobResult === 'cancelled') {
qualityResult = {
overall_status: 'infra_error',
failure_class: 'infra',
checked_plugins: [],
version_match_status: 'infra_error',
canvas_structure_status: 'infra_error',
summary: 'External plugin PR change detection failed unexpectedly. Re-run this workflow.',
};
} else if (shouldRun) {
if (qualityJobResult === 'failure' || qualityJobResult === 'cancelled') {
qualityResult = {
overall_status: 'infra_error',
failure_class: 'infra',
checked_plugins: [],
version_match_status: 'infra_error',
canvas_structure_status: 'infra_error',
summary: 'External plugin PR quality checks failed unexpectedly. Re-run this workflow.',
};
} else if (process.env.QUALITY_RESULT_JSON) {
qualityResult = JSON.parse(process.env.QUALITY_RESULT_JSON);
} else {
qualityResult = {
overall_status: 'infra_error',
failure_class: 'infra',
checked_plugins: [],
version_match_status: 'infra_error',
canvas_structure_status: 'infra_error',
summary: 'External plugin PR quality checks did not return a result payload.',
};
}
}
const stateLabel = qualityResult.failure_class === 'submitter_fixes'
? 'requires-submitter-fixes'
: qualityResult.overall_status === 'pass' || !shouldRun
? 'ready-for-review'
: 'awaiting-review';
const desiredLabels = new Set(['external-plugin', stateLabel]);
await intakeState.syncExternalPluginIntakeLabels({
github,
owner: context.repo.owner,
repo: context.repo.repo,
issueNumber: context.issue.number,
desiredLabels,
});
const checkedPlugins = Array.isArray(qualityResult.checked_plugins) ? qualityResult.checked_plugins : [];
const header = qualityResult.failure_class === 'submitter_fixes'
? '## ⚠️ External plugin PR checks require submitter fixes'
: qualityResult.overall_status === 'pass' || !shouldRun
? '## ✅ External plugin PR checks passed'
: '## ⚠️ External plugin PR checks need maintainer follow-up';
const MAX_GATE_OUTPUT_CHARS = 2000;
const escapeHtml = (value) =>
String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const escapeMarkdownTableCell = (value) =>
String(value ?? '')
.replace(/\r\n?|\n/g, '\n')
.split('\n')
.map((line) =>
Array.from(line, (character) =>
/^[A-Za-z0-9 .-]$/.test(character)
? character
: `&#${character.codePointAt(0)};`
).join('')
)
.join('<br>');
const normalizeGitHubUrl = (value) => {
const raw = String(value || '').trim();
if (!raw) {
return '';
}
try {
const parsed = new URL(raw);
if (parsed.protocol !== 'https:' || parsed.hostname !== 'github.com') {
return '';
}
return parsed.toString();
} catch {
return '';
}
};
const TRUNCATED_OUTPUT_MARKER = '\n...output truncated...';
const truncateGateOutput = (rawOutput) => {
const normalized = escapeHtml(String(rawOutput || '').trim());
if (!normalized) {
return '_No output captured._';
}
if (normalized.length <= MAX_GATE_OUTPUT_CHARS) {
return normalized;
}
return `${normalized.slice(0, Math.max(0, MAX_GATE_OUTPUT_CHARS - TRUNCATED_OUTPUT_MARKER.length))}${TRUNCATED_OUTPUT_MARKER}`;
};
const formatGateOutput = (pluginName, gateName, gateStatus, rawOutput) => {
const summaryPluginName = escapeHtml(String(pluginName || 'unknown'));
const summaryGateName = escapeHtml(String(gateName || 'gate'));
const summaryGateStatus = escapeHtml(String(gateStatus || 'not_run'));
const output = truncateGateOutput(rawOutput);
return [
'<details>',
`<summary>${summaryPluginName} - ${summaryGateName} (${summaryGateStatus})</summary>`,
'',
'<pre><code>',
output,
'</code></pre>',
'</details>',
].join('\n');
};
const rows = checkedPlugins.length > 0
? checkedPlugins.map((entry) => {
const name = escapeMarkdownTableCell(entry?.name || 'unknown');
const quality = entry?.quality || {};
const sourceUrl = normalizeGitHubUrl(entry?.source_tree_url || '');
const locator = escapeMarkdownTableCell(entry?.source?.sha || entry?.source?.ref || 'repository');
const sourceCell = sourceUrl ? `[${locator}](<${sourceUrl}>)` : locator;
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} | ${refShaConsistencyStatus} | ${canvasStructureStatus} | ${overallStatus} | ${sourceCell} |`;
})
: ['| _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 = [];
if (shouldShowVally) {
details.push(formatGateOutput(name, 'vally lint', quality.vally_lint_status, quality.vally_lint_output));
}
if (shouldShowSmoke) {
details.push(formatGateOutput(name, 'install smoke test', quality.smoke_status, quality.smoke_output));
}
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));
}
return details;
});
const body = [
marker,
header,
'',
`- **Changed entries detected:** ${changedCount}`,
`- **Workflow state label:** \`${stateLabel}\``,
'',
'### Per-plugin quality summary',
'',
'| Plugin | vally lint | install smoke test | version match | ref/sha consistency | canvas structure | overall | source tree |',
'|---|---|---|---|---|---|---|---|',
...rows,
'',
...(failureDetails.length > 0
? [
'### Gate output details',
'',
...failureDetails,
'',
]
: []),
String(qualityResult.summary || '').trim()
? `<pre><code>${escapeHtml(String(qualityResult.summary).trim())}</code></pre>`
: '_No summary provided._',
].join('\n');
await intakeState.upsertExternalPluginIntakeComment({
github,
owner: context.repo.owner,
repo: context.repo.repo,
issueNumber: context.issue.number,
marker,
body,
});