|
| 1 | +name: External Plugin PR Quality Gates |
| 2 | + |
| 3 | +on: |
| 4 | + pull_request: |
| 5 | + branches: [staged] |
| 6 | + paths: |
| 7 | + - "plugins/external.json" |
| 8 | + types: [opened, synchronize, reopened, edited, ready_for_review] |
| 9 | + |
| 10 | +concurrency: |
| 11 | + group: external-plugin-pr-quality-${{ github.event.pull_request.number }} |
| 12 | + cancel-in-progress: true |
| 13 | + |
| 14 | +permissions: |
| 15 | + contents: read |
| 16 | + |
| 17 | +jobs: |
| 18 | + detect-changed-plugins: |
| 19 | + runs-on: ubuntu-latest |
| 20 | + outputs: |
| 21 | + changed-plugins: ${{ steps.detect.outputs.changed-plugins }} |
| 22 | + changed-count: ${{ steps.detect.outputs.changed-count }} |
| 23 | + should-run: ${{ steps.detect.outputs.should-run }} |
| 24 | + steps: |
| 25 | + - name: Detect changed external plugins |
| 26 | + id: detect |
| 27 | + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 |
| 28 | + with: |
| 29 | + script: | |
| 30 | + const filePath = 'plugins/external.json'; |
| 31 | + const baseRef = context.payload.pull_request.base.sha; |
| 32 | + const headRef = context.payload.pull_request.head.sha; |
| 33 | +
|
| 34 | + function normalizePath(value) { |
| 35 | + if (!value || value === '/') { |
| 36 | + return ''; |
| 37 | + } |
| 38 | + return String(value).trim().replace(/^\/+|\/+$/g, '').toLowerCase(); |
| 39 | + } |
| 40 | +
|
| 41 | + function toIdentity(plugin) { |
| 42 | + return [ |
| 43 | + String(plugin?.name ?? '').trim().toLowerCase(), |
| 44 | + String(plugin?.source?.repo ?? '').trim().toLowerCase(), |
| 45 | + normalizePath(plugin?.source?.path), |
| 46 | + ].join('|'); |
| 47 | + } |
| 48 | +
|
| 49 | + async function readExternalJson(ref) { |
| 50 | + const response = await github.rest.repos.getContent({ |
| 51 | + owner: context.repo.owner, |
| 52 | + repo: context.repo.repo, |
| 53 | + path: filePath, |
| 54 | + ref, |
| 55 | + }); |
| 56 | +
|
| 57 | + const encoded = response.data?.content ?? ''; |
| 58 | + const decoded = Buffer.from(encoded, 'base64').toString('utf8'); |
| 59 | + return JSON.parse(decoded); |
| 60 | + } |
| 61 | +
|
| 62 | + const basePlugins = await readExternalJson(baseRef); |
| 63 | + const headPlugins = await readExternalJson(headRef); |
| 64 | + const baseByIdentity = new Map(basePlugins.map((plugin) => [toIdentity(plugin), plugin])); |
| 65 | +
|
| 66 | + const changedPlugins = headPlugins.filter((plugin) => { |
| 67 | + const identity = toIdentity(plugin); |
| 68 | + const basePlugin = baseByIdentity.get(identity); |
| 69 | + return !basePlugin || JSON.stringify(basePlugin) !== JSON.stringify(plugin); |
| 70 | + }); |
| 71 | +
|
| 72 | + core.setOutput('changed-plugins', JSON.stringify(changedPlugins)); |
| 73 | + core.setOutput('changed-count', String(changedPlugins.length)); |
| 74 | + core.setOutput('should-run', changedPlugins.length > 0 ? 'true' : 'false'); |
| 75 | +
|
| 76 | + run-quality-gates: |
| 77 | + runs-on: ubuntu-latest |
| 78 | + needs: detect-changed-plugins |
| 79 | + if: needs.detect-changed-plugins.outputs.should-run == 'true' |
| 80 | + outputs: |
| 81 | + quality-result: ${{ steps.quality.outputs.quality-result }} |
| 82 | + steps: |
| 83 | + - name: Checkout staged branch |
| 84 | + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 |
| 85 | + with: |
| 86 | + ref: staged |
| 87 | + persist-credentials: false |
| 88 | + submodules: false |
| 89 | + |
| 90 | + - name: Setup Node.js |
| 91 | + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 |
| 92 | + with: |
| 93 | + node-version: 22 |
| 94 | + |
| 95 | + - name: Install GitHub Copilot CLI |
| 96 | + run: npm install -g @github/copilot |
| 97 | + |
| 98 | + - name: Run external plugin PR quality gates |
| 99 | + id: quality |
| 100 | + env: |
| 101 | + CHANGED_PLUGINS_JSON: ${{ needs.detect-changed-plugins.outputs.changed-plugins }} |
| 102 | + run: | |
| 103 | + result=$(node ./eng/external-plugin-pr-quality-gates.mjs --plugins-json "$CHANGED_PLUGINS_JSON") |
| 104 | + { |
| 105 | + echo 'quality-result<<EOF' |
| 106 | + echo "$result" |
| 107 | + echo 'EOF' |
| 108 | + } >> "$GITHUB_OUTPUT" |
| 109 | +
|
| 110 | + sync-pr-state: |
| 111 | + runs-on: ubuntu-latest |
| 112 | + needs: [detect-changed-plugins, run-quality-gates] |
| 113 | + if: always() |
| 114 | + permissions: |
| 115 | + contents: read |
| 116 | + issues: write |
| 117 | + pull-requests: write |
| 118 | + steps: |
| 119 | + - name: Checkout staged branch |
| 120 | + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 |
| 121 | + with: |
| 122 | + ref: staged |
| 123 | + |
| 124 | + - name: Sync labels and PR status comment |
| 125 | + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 |
| 126 | + env: |
| 127 | + DETECT_JOB_RESULT: ${{ needs.detect-changed-plugins.result }} |
| 128 | + SHOULD_RUN: ${{ needs.detect-changed-plugins.outputs.should-run }} |
| 129 | + CHANGED_COUNT: ${{ needs.detect-changed-plugins.outputs.changed-count }} |
| 130 | + QUALITY_RESULT_JSON: ${{ needs.run-quality-gates.outputs.quality-result }} |
| 131 | + QUALITY_JOB_RESULT: ${{ needs.run-quality-gates.result }} |
| 132 | + with: |
| 133 | + script: | |
| 134 | + const path = require('path'); |
| 135 | + const { pathToFileURL } = require('url'); |
| 136 | +
|
| 137 | + const intakeState = await import(pathToFileURL(path.join(process.env.GITHUB_WORKSPACE, 'eng', 'external-plugin-intake-state.mjs')).href); |
| 138 | + const marker = '<!-- external-plugin-pr-quality -->'; |
| 139 | +
|
| 140 | + const detectJobResult = process.env.DETECT_JOB_RESULT; |
| 141 | + const shouldRun = process.env.SHOULD_RUN === 'true'; |
| 142 | + const changedCount = Number.parseInt(process.env.CHANGED_COUNT || '0', 10); |
| 143 | + const qualityJobResult = process.env.QUALITY_JOB_RESULT; |
| 144 | +
|
| 145 | + let qualityResult = { |
| 146 | + overall_status: 'not_run', |
| 147 | + failure_class: 'none', |
| 148 | + checked_plugins: [], |
| 149 | + summary: 'No changed external plugin entries were detected in this PR.', |
| 150 | + }; |
| 151 | +
|
| 152 | + if (detectJobResult === 'failure' || detectJobResult === 'cancelled') { |
| 153 | + qualityResult = { |
| 154 | + overall_status: 'infra_error', |
| 155 | + failure_class: 'infra', |
| 156 | + checked_plugins: [], |
| 157 | + summary: 'External plugin PR change detection failed unexpectedly. Re-run this workflow.', |
| 158 | + }; |
| 159 | + } else if (shouldRun) { |
| 160 | + if (qualityJobResult === 'failure' || qualityJobResult === 'cancelled') { |
| 161 | + qualityResult = { |
| 162 | + overall_status: 'infra_error', |
| 163 | + failure_class: 'infra', |
| 164 | + checked_plugins: [], |
| 165 | + summary: 'External plugin PR quality checks failed unexpectedly. Re-run this workflow.', |
| 166 | + }; |
| 167 | + } else if (process.env.QUALITY_RESULT_JSON) { |
| 168 | + qualityResult = JSON.parse(process.env.QUALITY_RESULT_JSON); |
| 169 | + } else { |
| 170 | + qualityResult = { |
| 171 | + overall_status: 'infra_error', |
| 172 | + failure_class: 'infra', |
| 173 | + checked_plugins: [], |
| 174 | + summary: 'External plugin PR quality checks did not return a result payload.', |
| 175 | + }; |
| 176 | + } |
| 177 | + } |
| 178 | +
|
| 179 | + const stateLabel = qualityResult.failure_class === 'submitter_fixes' |
| 180 | + ? 'requires-submitter-fixes' |
| 181 | + : qualityResult.overall_status === 'pass' || !shouldRun |
| 182 | + ? 'ready-for-review' |
| 183 | + : 'awaiting-review'; |
| 184 | +
|
| 185 | + const desiredLabels = new Set(['external-plugin', stateLabel]); |
| 186 | + await intakeState.syncExternalPluginIntakeLabels({ |
| 187 | + github, |
| 188 | + owner: context.repo.owner, |
| 189 | + repo: context.repo.repo, |
| 190 | + issueNumber: context.issue.number, |
| 191 | + desiredLabels, |
| 192 | + }); |
| 193 | +
|
| 194 | + const checkedPlugins = Array.isArray(qualityResult.checked_plugins) ? qualityResult.checked_plugins : []; |
| 195 | + const header = qualityResult.failure_class === 'submitter_fixes' |
| 196 | + ? '## ⚠️ External plugin PR checks require submitter fixes' |
| 197 | + : qualityResult.overall_status === 'pass' || !shouldRun |
| 198 | + ? '## ✅ External plugin PR checks passed' |
| 199 | + : '## ⚠️ External plugin PR checks need maintainer follow-up'; |
| 200 | +
|
| 201 | + const rows = checkedPlugins.length > 0 |
| 202 | + ? checkedPlugins.map((entry) => { |
| 203 | + const name = String(entry?.name || 'unknown'); |
| 204 | + const quality = entry?.quality || {}; |
| 205 | + const sourceUrl = String(entry?.source_tree_url || ''); |
| 206 | + const locator = String(entry?.source?.sha || entry?.source?.ref || 'repository'); |
| 207 | + const sourceCell = sourceUrl ? `[${locator}](${sourceUrl})` : locator; |
| 208 | + return `| ${name} | ${quality.skill_validator_status || 'not_run'} | ${quality.smoke_status || 'not_run'} | ${quality.overall_status || 'not_run'} | ${sourceCell} |`; |
| 209 | + }) |
| 210 | + : ['| _none_ | not_run | not_run | not_run | _n/a_ |']; |
| 211 | +
|
| 212 | + const body = [ |
| 213 | + marker, |
| 214 | + header, |
| 215 | + '', |
| 216 | + `- **Changed entries detected:** ${changedCount}`, |
| 217 | + `- **Workflow state label:** \`${stateLabel}\``, |
| 218 | + '', |
| 219 | + '### Per-plugin quality summary', |
| 220 | + '', |
| 221 | + '| Plugin | skill-validator | install smoke test | overall | source tree |', |
| 222 | + '|---|---|---|---|---|', |
| 223 | + ...rows, |
| 224 | + '', |
| 225 | + String(qualityResult.summary || '').trim() || '_No summary provided._', |
| 226 | + ].join('\n'); |
| 227 | +
|
| 228 | + await intakeState.upsertExternalPluginIntakeComment({ |
| 229 | + github, |
| 230 | + owner: context.repo.owner, |
| 231 | + repo: context.repo.repo, |
| 232 | + issueNumber: context.issue.number, |
| 233 | + marker, |
| 234 | + body, |
| 235 | + }); |
0 commit comments