Merge branch 'staged' into aaronpowell/comparing-vally-lint

This commit is contained in:
Aaron Powell
2026-06-23 14:45:11 +10:00
committed by GitHub
260 changed files with 27188 additions and 1673 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- name: Check spelling with codespell
uses: codespell-project/actions-codespell@406322ec52dd7b488e48c1c4b82e2a8b3a1bf630 # v2.1
with:
+2 -2
View File
@@ -21,6 +21,6 @@ jobs:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install gh-aw extension
uses: github/gh-aw-actions/setup-cli@b8068426813005612b960b5ab0b8bd2c27142323 # v0.71.5
uses: github/gh-aw-actions/setup-cli@8c7d04ebf1ece56cd381446125da3e0f6896294a # v0.80.9
with:
version: v0.71.5
version: v0.80.9
File diff suppressed because one or more lines are too long
@@ -9,7 +9,8 @@ concurrency:
cancel-in-progress: false
permissions:
issues: write
pull-requests: write
contents: read
jobs:
sync-merged-pr-labels:
@@ -25,20 +26,6 @@ jobs:
const prNumber = context.payload.pull_request.number;
const staleLabels = ['awaiting-review', 'awaiting-approval', 'ready-for-review', 'rejected'];
try {
await github.rest.issues.createLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: 'approved',
color: '1D76DB',
description: 'Submission was approved by a maintainer'
});
} catch (error) {
if (error.status !== 422) {
throw error;
}
}
const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
owner: context.repo.owner,
repo: context.repo.repo,
@@ -275,49 +275,6 @@ jobs:
PR_NUMBER: ${{ steps.approval_pr.outputs.pr-number }}
with:
script: |
const managedLabels = {
'external-plugin': {
color: 'FEF2C0',
description: 'Public external plugin submission'
},
'awaiting-review': {
color: 'FBCA04',
description: 'Submission is waiting for automated intake validation'
},
'ready-for-review': {
color: '0E8A16',
description: 'Submission passed intake validation and is ready for maintainer review'
},
'requires-submitter-fixes': {
color: 'D93F0B',
description: 'Submission has quality-gate findings that submitter must fix before maintainer review'
},
'approved': {
color: '1D76DB',
description: 'Submission was approved by a maintainer'
},
'rejected': {
color: 'B60205',
description: 'Submission was rejected by a maintainer'
}
};
async function ensureLabel(name, config) {
try {
await github.rest.issues.createLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name,
color: config.color,
description: config.description
});
} catch (error) {
if (error.status !== 422) {
throw error;
}
}
}
async function removeLabel(issueNumber, name) {
try {
await github.rest.issues.removeLabel({
@@ -334,7 +291,14 @@ jobs:
}
async function syncIssueLabels(issueNumber, desiredLabels) {
await Promise.all(Object.entries(managedLabels).map(([name, config]) => ensureLabel(name, config)));
const managedLabels = {
'external-plugin': true,
'awaiting-review': true,
'ready-for-review': true,
'requires-submitter-fixes': true,
'approved': true,
'rejected': true
};
const currentLabels = await github.paginate(github.rest.issues.listLabelsOnIssue, {
owner: context.repo.owner,
@@ -438,49 +402,6 @@ jobs:
PLUGIN_NAME: ${{ steps.parse.outputs.plugin-name }}
with:
script: |
const managedLabels = {
'external-plugin': {
color: 'FEF2C0',
description: 'Public external plugin submission'
},
'awaiting-review': {
color: 'FBCA04',
description: 'Submission is waiting for automated intake validation'
},
'ready-for-review': {
color: '0E8A16',
description: 'Submission passed intake validation and is ready for maintainer review'
},
'requires-submitter-fixes': {
color: 'D93F0B',
description: 'Submission has quality-gate findings that submitter must fix before maintainer review'
},
'approved': {
color: '1D76DB',
description: 'Submission was approved by a maintainer'
},
'rejected': {
color: 'B60205',
description: 'Submission was rejected by a maintainer'
}
};
async function ensureLabel(name, config) {
try {
await github.rest.issues.createLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name,
color: config.color,
description: config.description
});
} catch (error) {
if (error.status !== 422) {
throw error;
}
}
}
async function removeLabel(name) {
try {
await github.rest.issues.removeLabel({
@@ -496,7 +417,6 @@ jobs:
}
}
await Promise.all(Object.entries(managedLabels).map(([name, config]) => ensureLabel(name, config)));
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
@@ -0,0 +1,235 @@
name: External Plugin PR Quality Gates
on:
pull_request_target:
branches: [staged]
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 staged branch
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
ref: staged
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: 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 staged branch
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
ref: staged
- 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: [],
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: [],
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: [],
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 rows = checkedPlugins.length > 0
? checkedPlugins.map((entry) => {
const name = String(entry?.name || 'unknown');
const quality = entry?.quality || {};
const sourceUrl = String(entry?.source_tree_url || '');
const locator = String(entry?.source?.sha || entry?.source?.ref || 'repository');
const sourceCell = sourceUrl ? `[${locator}](${sourceUrl})` : locator;
return `| ${name} | ${quality.skill_validator_status || 'not_run'} | ${quality.smoke_status || 'not_run'} | ${quality.overall_status || 'not_run'} | ${sourceCell} |`;
})
: ['| _none_ | not_run | not_run | not_run | _n/a_ |'];
const body = [
marker,
header,
'',
`- **Changed entries detected:** ${changedCount}`,
`- **Workflow state label:** \`${stateLabel}\``,
'',
'### Per-plugin quality summary',
'',
'| Plugin | skill-validator | install smoke test | overall | source tree |',
'|---|---|---|---|---|',
...rows,
'',
String(qualityResult.summary || '').trim() || '_No summary provided._',
].join('\n');
await intakeState.upsertExternalPluginIntakeComment({
github,
owner: context.repo.owner,
repo: context.repo.repo,
issueNumber: context.issue.number,
marker,
body,
});
@@ -180,34 +180,6 @@ jobs:
PLUGIN_NAME: ${{ steps.parse.outputs.plugin-name }}
with:
script: |
const managedLabels = {
're-review-due': {
color: 'FBCA04',
description: 'Approved external plugin is due for six-month re-review'
},
're-review-follow-up': {
color: 'D4C5F9',
description: 'Six-month re-review needs maintainer follow-up before a final decision'
}
};
async function ensureLabel(name, config) {
try {
await github.rest.issues.createLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name,
color: config.color,
description: config.description
});
} catch (error) {
if (error.status !== 422) {
throw error;
}
}
}
await Promise.all(Object.entries(managedLabels).map(([name, config]) => ensureLabel(name, config)));
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
@@ -26,37 +26,6 @@ jobs:
const rereview = await import(pathToFileURL(path.join(process.env.GITHUB_WORKSPACE, 'eng', 'external-plugin-rereview.mjs')).href);
const validation = await import(pathToFileURL(path.join(process.env.GITHUB_WORKSPACE, 'eng', 'external-plugin-validation.mjs')).href);
const managedLabels = {
[rereview.REREVIEW_LABELS.due]: {
color: 'FBCA04',
description: 'Approved external plugin is due for six-month re-review'
},
[rereview.REREVIEW_LABELS.followUp]: {
color: 'D4C5F9',
description: 'Six-month re-review needs maintainer follow-up before a final decision'
},
[rereview.REREVIEW_LABELS.removed]: {
color: 'B60205',
description: 'External plugin was removed from the marketplace after re-review'
}
};
async function ensureLabel(name, config) {
try {
await github.rest.issues.createLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name,
color: config.color,
description: config.description
});
} catch (error) {
if (error.status !== 422) {
throw error;
}
}
}
async function removeLabel(issueNumber, label) {
try {
await github.rest.issues.removeLabel({
@@ -90,8 +59,6 @@ jobs:
return Math.max(0, Math.floor(Math.abs(diff) / (1000 * 60 * 60 * 24)));
}
await Promise.all(Object.entries(managedLabels).map(([name, config]) => ensureLabel(name, config)));
const { plugins, errors } = validation.readExternalPlugins({ policy: 'marketplace' });
if (errors.length > 0) {
core.setFailed(errors.join('\n'));
+20 -64
View File
@@ -20,50 +20,18 @@ jobs:
with:
script: |
const managedLabels = {
'targets-main': {
color: 'B60205',
description: 'PR targets main instead of staged'
},
'branched-main': {
color: 'D93F0B',
description: 'PR appears to include plugin files materialized from main'
},
'skills': {
color: '1D76DB',
description: 'PR touches skills'
},
'plugin': {
color: '5319E7',
description: 'PR touches plugins'
},
'agent': {
color: '0E8A16',
description: 'PR touches agents'
},
'instructions': {
color: 'FBCA04',
description: 'PR touches instructions'
},
'new-submission': {
color: '006B75',
description: 'PR adds at least one new contribution'
},
'website-update': {
color: '0052CC',
description: 'PR touches website content or code'
},
'external-plugin': {
color: 'FEF2C0',
description: 'PR updates plugins/external.json'
},
'hooks': {
color: 'C2E0C6',
description: 'PR touches hooks'
},
'workflow': {
color: 'BFD4F2',
description: 'PR touches workflow automation'
}
'targets-main': true,
'branched-main': true,
'skills': true,
'plugin': true,
'agent': true,
'instructions': true,
'new-submission': true,
'website-update': true,
'external-plugin': true,
'hooks': true,
'workflow': true,
'canvas-extension': true
};
const matchesAny = (filename, patterns) => patterns.some((pattern) => pattern.test(filename));
@@ -91,22 +59,6 @@ jobs:
}
}
async function ensureLabel(name, { color, description }) {
try {
await github.rest.issues.createLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name,
color,
description
});
} catch (error) {
if (error.status !== 422) {
throw error;
}
}
}
const files = await listAllFiles();
const filenames = files.map((file) => file.filename);
@@ -139,12 +91,16 @@ jobs:
/^workflows\/.+\.md$/,
/^\.github\/workflows\/.+\.(?:ya?ml|md)$/
],
canvasExtension: [
/^extensions\/[^/]+\//
],
newSubmission: [
/^agents\/.+\.agent\.md$/,
/^instructions\/.+\.instructions\.md$/,
/^skills\/[^/]+\/SKILL\.md$/,
/^hooks\/[^/]+\/(?:README\.md|hooks\.json)$/,
/^plugins\/[^/]+\/\.github\/plugin\/plugin\.json$/,
/^extensions\/[^/]+\/extension\.mjs$/,
/^workflows\/.+\.md$/,
/^\.github\/workflows\/.+\.(?:ya?ml|md)$/,
/^website\//
@@ -197,15 +153,15 @@ jobs:
desiredLabels.add('workflow');
}
if (filenames.some((filename) => matchesAny(filename, patterns.canvasExtension))) {
desiredLabels.add('canvas-extension');
}
if (hasNewSubmission) {
desiredLabels.add('new-submission');
}
}
await Promise.all(
Object.entries(managedLabels).map(([name, config]) => ensureLabel(name, config))
);
const currentLabels = await github.paginate(github.rest.issues.listLabelsOnIssue, {
owner: context.repo.owner,
repo: context.repo.repo,
File diff suppressed because one or more lines are too long
+3 -1
View File
@@ -4,6 +4,8 @@ description: "Daily check for new GitHub Copilot features and updates. Opens a P
on:
schedule: daily
workflow_dispatch:
permissions:
contents: read
tools:
bash: ["curl", "gh"]
edit:
@@ -83,4 +85,4 @@ Create a pull request with your changes, using the `staged` branch as the base b
2. What sections of the guide were updated
3. Links to the source announcements
The PR should target the `staged` branch and include the labels `automated-update` and `copilot-updates`.
The PR should target the `staged` branch and include the labels `automated-update` and `copilot-updates`.
File diff suppressed because one or more lines are too long
+8
View File
@@ -133,6 +133,14 @@ jobs:
"${main_publish_ref}:${LEGACY_PUBLISHED_BRANCH}" \
"${marketplace_publish_ref}:${MARKETPLACE_BRANCH}"
git fetch origin "${LEGACY_PUBLISHED_BRANCH}" "${MARKETPLACE_BRANCH}"
if ! git diff --quiet "origin/${LEGACY_PUBLISHED_BRANCH}" "origin/${MARKETPLACE_BRANCH}"; then
echo "Published branch mismatch detected between ${LEGACY_PUBLISHED_BRANCH} and ${MARKETPLACE_BRANCH}"
git --no-pager diff --stat "origin/${LEGACY_PUBLISHED_BRANCH}" "origin/${MARKETPLACE_BRANCH}"
exit 1
fi
echo "Verified published outputs are in sync across ${LEGACY_PUBLISHED_BRANCH} and ${MARKETPLACE_BRANCH}"
- name: Dispatch website deployment
run: gh workflow run deploy-website.yml --ref "${WEBSITE_DEPLOY_REF}"
env:
File diff suppressed because one or more lines are too long
+148
View File
@@ -0,0 +1,148 @@
name: Setup Repository Labels
on:
workflow_dispatch
permissions:
issues: write
jobs:
setup-labels:
runs-on: ubuntu-latest
steps:
- name: Create or update labels
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
with:
script: |
const labels = {
// Intent labels for PR categorization
'targets-main': {
color: 'B60205',
description: 'PR targets main instead of staged'
},
'branched-main': {
color: 'D93F0B',
description: 'PR appears to include plugin files materialized from main'
},
'skills': {
color: '1D76DB',
description: 'PR touches skills'
},
'plugin': {
color: '5319E7',
description: 'PR touches plugins'
},
'agent': {
color: '0E8A16',
description: 'PR touches agents'
},
'instructions': {
color: 'FBCA04',
description: 'PR touches instructions'
},
'new-submission': {
color: '006B75',
description: 'PR adds at least one new contribution'
},
'website-update': {
color: '0052CC',
description: 'PR touches website content or code'
},
'external-plugin': {
color: 'FEF2C0',
description: 'Public external plugin submission'
},
'hooks': {
color: 'C2E0C6',
description: 'PR touches hooks'
},
'workflow': {
color: 'BFD4F2',
description: 'PR touches workflow automation'
},
// External plugin intake state labels
'awaiting-review': {
color: 'FBCA04',
description: 'Submission is waiting for automated intake validation'
},
'ready-for-review': {
color: '0E8A16',
description: 'Submission passed intake validation and is ready for maintainer review'
},
'requires-submitter-fixes': {
color: 'D93F0B',
description: 'Submission has quality-gate findings that submitter must fix before maintainer review'
},
'approved': {
color: '1D76DB',
description: 'Submission was approved by a maintainer'
},
'rejected': {
color: 'B60205',
description: 'Submission was rejected by a maintainer'
},
// Re-review labels
'removed': {
color: 'B60205',
description: 'External plugin was removed from the marketplace after re-review'
},
're-review-follow-up': {
color: 'D4C5F9',
description: 'Six-month re-review needs maintainer follow-up before a final decision'
},
'awaiting-approval': {
color: 'FBCA04',
description: 'External plugin awaiting maintainer approval'
}
};
let created = 0;
let updated = 0;
let failed = 0;
for (const [name, config] of Object.entries(labels)) {
try {
await github.rest.issues.createLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name,
color: config.color,
description: config.description
});
created++;
core.info(`✓ Created label: ${name}`);
} catch (error) {
if (error.status === 422) {
// Label already exists, try to update it
try {
await github.rest.issues.updateLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name,
color: config.color,
description: config.description
});
updated++;
core.info(`✓ Updated label: ${name}`);
} catch (updateError) {
failed++;
core.error(`✗ Failed to update label ${name}: ${updateError.message}`);
}
} else {
failed++;
core.error(`✗ Failed to create label ${name}: ${error.message}`);
}
}
}
core.info(`
Label setup complete:
- Created: ${created}
- Updated: ${updated}
- Failed: ${failed}
- Total: ${Object.keys(labels).length}
`);
if (failed > 0) {
throw new Error(`Failed to setup ${failed} label(s)`);
}
-20
View File
@@ -42,27 +42,7 @@ jobs:
}
};
async function ensureLabel(name, { color, description }) {
try {
await github.rest.issues.createLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name,
color,
description
});
} catch (error) {
if (error.status !== 422) {
throw error;
}
}
}
async function syncManagedLabels(issueNumber, desiredLabels) {
await Promise.all(
Object.entries(managedLabels).map(([name, config]) => ensureLabel(name, config))
);
const currentLabels = await github.paginate(github.rest.issues.listLabelsOnIssue, {
owner: context.repo.owner,
repo: context.repo.repo,
@@ -0,0 +1,135 @@
name: Validate Canvas Extensions
on:
pull_request:
branches: [staged]
types: [opened, synchronize, reopened]
paths:
- "extensions/**"
permissions:
contents: read
pull-requests: write
jobs:
validate:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
fetch-depth: 0
- name: Validate changed canvas extensions
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
with:
script: |
const fs = require('fs');
const path = require('path');
// Collect changed extension directories from the PR diff
const { execSync } = require('child_process');
const changedFiles = execSync(
`git diff --name-only origin/${{ github.base_ref }}...HEAD`
).toString().trim().split('\n').filter(Boolean);
const EXTENSIONS_DIR = 'extensions';
const EXTERNAL_ASSETS_DIR = 'external-assets';
const changedExtDirs = new Set();
for (const file of changedFiles) {
const parts = file.split('/');
if (parts[0] === EXTENSIONS_DIR && parts.length >= 2) {
const extName = parts[1];
// Skip the external-assets directory — it's not a canvas extension
// Also skip external.json and other files at extensions root level
if (extName !== EXTERNAL_ASSETS_DIR && !extName.includes('.')) {
changedExtDirs.add(path.join(EXTENSIONS_DIR, extName));
}
}
}
if (changedExtDirs.size === 0) {
console.log('No canvas extension directories changed — skipping validation.');
return;
}
console.log(`Validating ${changedExtDirs.size} extension(s): ${[...changedExtDirs].join(', ')}`);
const errors = [];
for (const extDir of changedExtDirs) {
if (!fs.existsSync(extDir)) {
// Directory was deleted — skip
console.log(`${extDir} no longer exists (deleted?), skipping.`);
continue;
}
const extName = path.basename(extDir);
// Rule 1: must contain extension.mjs
const mainFile = path.join(extDir, 'extension.mjs');
if (!fs.existsSync(mainFile)) {
errors.push(
`**\`${extDir}\`**: missing required \`extension.mjs\`. ` +
`Canvas extensions must have their entry point named \`extension.mjs\`.`
);
}
// Rule 2: must contain assets/preview.png
const previewFile = path.join(extDir, 'assets', 'preview.png');
if (!fs.existsSync(previewFile)) {
errors.push(
`**\`${extDir}\`**: missing required \`assets/preview.png\`. ` +
`Canvas extensions must include a screenshot at \`assets/preview.png\` ` +
`so reviewers and users can preview the extension before installing it.`
);
}
}
if (errors.length === 0) {
console.log('✅ All changed canvas extensions pass validation.');
return;
}
const isFork = context.payload.pull_request.head.repo.fork;
const body = [
'❌ **Canvas extension validation failed**',
'',
'The following issue(s) were found in changed canvas extension(s):',
'',
...errors.map(e => `- ${e}`),
'',
'---',
'',
'### Required structure for canvas extensions',
'',
'Each extension folder under `extensions/` must contain:',
'',
'| Path | Required | Description |',
'|------|----------|-------------|',
'| `extension.mjs` | ✅ | Entry point for the canvas extension |',
'| `assets/preview.png` | ✅ | Screenshot shown on the website and in the marketplace |',
'',
'Please add the missing file(s) and push an update to this PR.',
].join('\n');
if (!isFork) {
try {
await github.rest.pulls.createReview({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
event: 'REQUEST_CHANGES',
body
});
} catch (error) {
core.warning(`Could not post PR review: ${error.message}`);
core.warning(body);
}
} else {
core.warning('PR is from a fork — skipping createReview to avoid permission errors.');
core.warning(body);
}
core.setFailed(`Canvas extension validation failed with ${errors.length} error(s).`);