feat: add public external plugin workflows (#1723)

* feat: add external plugin submission workflows

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* minor adjustment to contributing guide

* fix: address external plugin review feedback

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Reverting some changes to the readme.agents.md file

* fix: address follow-up review feedback

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: tighten external plugin workflows

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Aaron Powell
2026-05-15 15:37:18 +10:00
committed by GitHub
parent ca8412356a
commit e66aa80240
13 changed files with 2767 additions and 102 deletions
+127
View File
@@ -0,0 +1,127 @@
name: External plugin submission
description: Submit a public GitHub-hosted external plugin for marketplace review.
title: "[External Plugin]: "
labels:
- external-plugin
- awaiting-review
body:
- type: markdown
attributes:
value: |
<!-- external-plugin-submission -->
Thanks for submitting a public external plugin.
Before you continue:
- Public submissions are **GitHub-only** in v1.
- The plugin must live in a **public GitHub repository**.
- Use an **immutable ref** for review: a release tag or full 40-character commit SHA.
- Do **not** open a PR that edits `plugins/external.json` directly.
- type: input
id: plugin-name
attributes:
label: Plugin name
description: Lowercase letters, numbers, and hyphens only.
placeholder: my-plugin
validations:
required: true
- type: textarea
id: short-description
attributes:
label: Short description
description: One or two sentences describing the plugin.
placeholder: Helps developers...
validations:
required: true
- type: input
id: github-repository
attributes:
label: GitHub repository
description: Public GitHub repository in owner/repo format.
placeholder: owner/repo
validations:
required: true
- type: input
id: plugin-path
attributes:
label: Plugin path inside the repository
description: Optional if the plugin lives at the repository root.
placeholder: .github/plugins/my-plugin
validations:
required: false
- type: input
id: immutable-ref
attributes:
label: Immutable ref to review
description: Release tag or full 40-character commit SHA.
placeholder: refs/tags/v1.2.3 or 0123456789abcdef0123456789abcdef01234567
validations:
required: true
- type: input
id: version
attributes:
label: Version
placeholder: 1.0.0
validations:
required: true
- type: input
id: license
attributes:
label: License identifier
description: SPDX identifier or other license string.
placeholder: MIT
validations:
required: true
- type: input
id: author-name
attributes:
label: Author name
placeholder: Example Maintainers
validations:
required: true
- type: input
id: author-url
attributes:
label: Author URL
description: Optional HTTPS URL.
placeholder: https://example.com
validations:
required: false
- type: input
id: homepage-url
attributes:
label: Homepage URL
description: Optional HTTPS URL if different from the repository URL.
placeholder: https://example.com/plugin
validations:
required: false
- type: textarea
id: keywords
attributes:
label: Keywords
description: Comma-separated or newline-separated lowercase tags.
placeholder: |
automation
github
copilot
validations:
required: true
- type: textarea
id: additional-notes
attributes:
label: Additional notes for reviewers
description: Optional context that helps maintainers review the plugin.
validations:
required: false
- type: checkboxes
id: submission-checklist
attributes:
label: Submission checklist
options:
- label: The plugin lives in a public GitHub repository.
required: true
- label: The ref I provided is an immutable release tag or full 40-character commit SHA, not a branch.
required: true
- label: This submission follows this repository's contribution, security, and responsible AI policies.
required: true
- label: This plugin is not already listed in the Awesome Copilot marketplace.
required: true
@@ -0,0 +1,534 @@
name: External Plugin Approval Commands
on:
issue_comment:
types: [created]
permissions:
contents: write
issues: write
pull-requests: write
jobs:
handle-command:
runs-on: ubuntu-latest
if: >-
!github.event.issue.pull_request &&
(contains(github.event.comment.body, '/approve') || contains(github.event.comment.body, '/reject'))
steps:
- name: Checkout staged branch
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
ref: staged
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22
cache: npm
- name: Parse decision command
id: parse
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
with:
script: |
const path = require('path');
const { pathToFileURL } = require('url');
const approval = await import(pathToFileURL(path.join(process.env.GITHUB_WORKSPACE, 'eng', 'external-plugin-approval.mjs')).href);
const intake = await import(pathToFileURL(path.join(process.env.GITHUB_WORKSPACE, 'eng', 'external-plugin-intake.mjs')).href);
const parsedCommand = approval.parseDecisionCommand(context.payload.comment.body);
core.setOutput('should-run', 'false');
if (!parsedCommand) {
core.info('No supported external plugin approval command was found.');
return;
}
const permission = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username: context.payload.comment.user.login
});
const hasWriteAccess = ['admin', 'write', 'maintain'].includes(permission.data.permission);
if (!hasWriteAccess) {
core.info(`Ignoring ${parsedCommand.command} because ${context.payload.comment.user.login} does not have write access.`);
return;
}
const currentIssue = await github.rest.issues.get({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number
});
const labelNames = new Set((currentIssue.data.labels || []).map((label) => label.name));
if (!labelNames.has('external-plugin')) {
core.info('Ignoring command because the issue is not an external plugin submission.');
return;
}
const evaluation = await intake.evaluateExternalPluginIssue({
issue: currentIssue.data,
token: process.env.GITHUB_TOKEN
});
const fallbackName = evaluation.plugin?.name ?? `issue-${context.issue.number}`;
const canApprove = labelNames.has('ready-for-review') || labelNames.has('approved');
const canReject = !labelNames.has('approved');
if (parsedCommand.command === 'approve' && !canApprove) {
core.info('Ignoring /approve because the issue is not ready for review.');
return;
}
if (parsedCommand.command === 'reject' && !canReject) {
core.info('Ignoring /reject because the issue is already approved.');
return;
}
core.setOutput('should-run', 'true');
core.setOutput('command', parsedCommand.command);
core.setOutput('reason', parsedCommand.reason ?? '');
core.setOutput('validation-valid', evaluation.valid ? 'true' : 'false');
core.setOutput('validation-errors', JSON.stringify(evaluation.errors));
core.setOutput('plugin-name', fallbackName);
core.setOutput('plugin-slug', approval.slugifyPluginName(fallbackName));
core.setOutput('source-repo', evaluation.plugin?.source?.repo ?? '');
- name: Comment blocked approval
if: steps.parse.outputs.should-run == 'true' && steps.parse.outputs.command == 'approve' && steps.parse.outputs.validation-valid != 'true'
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
env:
VALIDATION_ERRORS: ${{ steps.parse.outputs.validation-errors }}
PLUGIN_NAME: ${{ steps.parse.outputs.plugin-name }}
with:
script: |
const marker = '<!-- external-plugin-approval-blocked -->';
const errors = JSON.parse(process.env.VALIDATION_ERRORS || '[]');
const body = [
marker,
'## ⚠️ External plugin approval blocked',
'',
`The current issue form for **${process.env.PLUGIN_NAME}** no longer passes automated intake validation, so \`/approve\` was not applied.`,
'',
'### Required fixes',
'',
...(errors.length > 0 ? errors.map((error) => `- ${error}`) : ['- Re-run intake validation by updating the issue details.'])
].join('\n');
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
per_page: 100
});
const existingComment = comments.find((comment) =>
comment.user?.login === 'github-actions[bot]' &&
comment.body?.includes(marker)
);
if (existingComment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existingComment.id,
body
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body
});
}
- name: Install dependencies
if: steps.parse.outputs.should-run == 'true' && steps.parse.outputs.command == 'approve' && steps.parse.outputs.validation-valid == 'true'
run: npm ci
- name: Update external plugin catalog and PR
id: approval_pr
if: steps.parse.outputs.should-run == 'true' && steps.parse.outputs.command == 'approve' && steps.parse.outputs.validation-valid == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
result=$(node ./eng/external-plugin-approval.mjs approve "$GITHUB_EVENT_PATH" --file ./plugins/external.json)
{
echo 'result<<EOF'
echo "$result"
echo 'EOF'
} >> "$GITHUB_OUTPUT"
plugin_name=$(node -e "const data = JSON.parse(process.argv[1]); process.stdout.write(data.plugin.name);" "$result")
action=$(node -e "const data = JSON.parse(process.argv[1]); process.stdout.write(data.action);" "$result")
source_repo=$(node -e "const data = JSON.parse(process.argv[1]); process.stdout.write(data.plugin.source.repo);" "$result")
plugin_slug='${{ steps.parse.outputs.plugin-slug }}'
issue_number='${{ github.event.issue.number }}'
branch="automation/external-plugin-approve-${issue_number}-${plugin_slug}"
if [ "$action" = "inserted" ]; then
title_action="Add"
summary_action="add"
else
title_action="Update"
summary_action="update"
fi
npm run build
bash eng/fix-line-endings.sh
pr_url=""
pr_number=""
if git diff --quiet; then
pr_number=$(gh pr list --head "$branch" --base staged --json number --jq '.[0].number')
if [ -n "$pr_number" ]; then
pr_url=$(gh pr view "$pr_number" --json url --jq '.url')
fi
echo "changed=false" >> "$GITHUB_OUTPUT"
echo "plugin-name=$plugin_name" >> "$GITHUB_OUTPUT"
echo "action=$action" >> "$GITHUB_OUTPUT"
echo "source-repo=$source_repo" >> "$GITHUB_OUTPUT"
echo "pr-url=$pr_url" >> "$GITHUB_OUTPUT"
echo "pr-number=$pr_number" >> "$GITHUB_OUTPUT"
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -B "$branch"
git add -A
git commit -m "${title_action} external plugin ${plugin_name}"
git push --force-with-lease origin "$branch"
pr_number=$(gh pr list --head "$branch" --base staged --json number --jq '.[0].number')
pr_body=$(cat <<EOF
## Summary
- ${summary_action} \`${plugin_name}\` in \`plugins/external.json\`
- regenerate marketplace outputs for the approved external plugin submission
- source repository: \`${source_repo}\`
- approved from #${issue_number}
## Validation
- npm run build
EOF
)
if [ -n "$pr_number" ]; then
gh pr edit "$pr_number" \
--title "[external-plugin] ${title_action} ${plugin_name}" \
--body "$pr_body"
pr_url=$(gh pr view "$pr_number" --json url --jq '.url')
else
pr_url=$(gh pr create \
--base staged \
--head "$branch" \
--title "[external-plugin] ${title_action} ${plugin_name}" \
--body "$pr_body")
pr_number=$(gh pr view "$branch" --json number --jq '.number')
fi
echo "changed=true" >> "$GITHUB_OUTPUT"
echo "plugin-name=$plugin_name" >> "$GITHUB_OUTPUT"
echo "action=$action" >> "$GITHUB_OUTPUT"
echo "source-repo=$source_repo" >> "$GITHUB_OUTPUT"
echo "pr-url=$pr_url" >> "$GITHUB_OUTPUT"
echo "pr-number=$pr_number" >> "$GITHUB_OUTPUT"
- name: Finalize approval
if: steps.parse.outputs.should-run == 'true' && steps.parse.outputs.command == 'approve' && steps.parse.outputs.validation-valid == 'true'
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
env:
CHANGED: ${{ steps.approval_pr.outputs.changed }}
ACTION: ${{ steps.approval_pr.outputs.action }}
PLUGIN_NAME: ${{ steps.approval_pr.outputs.plugin-name }}
SOURCE_REPO: ${{ steps.approval_pr.outputs.source-repo }}
PR_URL: ${{ steps.approval_pr.outputs.pr-url }}
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'
},
'approved': {
color: '1D76DB',
description: 'Submission was approved by a maintainer'
},
'rejected': {
color: 'B60205',
description: 'Submission was rejected or failed intake validation'
}
};
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({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
name
});
} catch (error) {
if (error.status !== 404) {
throw error;
}
}
}
async function syncIssueLabels(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,
issue_number: issueNumber,
per_page: 100
});
const currentManagedLabels = currentLabels
.map((label) => label.name)
.filter((name) => Object.prototype.hasOwnProperty.call(managedLabels, name));
const labelsToAdd = [...desiredLabels].filter((name) => !currentManagedLabels.includes(name));
const labelsToRemove = currentManagedLabels.filter((name) => !desiredLabels.has(name));
if (labelsToAdd.length > 0) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
labels: labelsToAdd
});
}
for (const name of labelsToRemove) {
await removeLabel(issueNumber, name);
}
}
const issueNumber = context.issue.number;
const prNumber = Number(process.env.PR_NUMBER || 0);
const marker = '<!-- external-plugin-approval -->';
const action = process.env.ACTION === 'updated' ? 'updated' : 'added';
const prUrl = process.env.PR_URL;
const body = [
marker,
'## ✅ External plugin approved',
'',
`A maintainer approved **${process.env.PLUGIN_NAME}**, and the submission issue has been closed.`,
'',
`- **Catalog action:** ${action}`,
`- **Source repository:** \`${process.env.SOURCE_REPO}\``,
prUrl
? `- **PR against \`staged\`:** ${prUrl}`
: '- **PR against `staged`:** No new PR was needed because the approved listing is already present.'
].join('\n');
await syncIssueLabels(issueNumber, new Set(['external-plugin', 'approved']));
if (prNumber > 0) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
labels: ['external-plugin', 'awaiting-review']
});
}
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
per_page: 100
});
const existingComment = comments.find((comment) =>
comment.user?.login === 'github-actions[bot]' &&
comment.body?.includes(marker)
);
if (existingComment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existingComment.id,
body
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
body
});
}
if (context.payload.issue.state !== 'closed') {
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
state: 'closed'
});
}
- name: Finalize rejection
if: steps.parse.outputs.should-run == 'true' && steps.parse.outputs.command == 'reject'
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
env:
REASON: ${{ steps.parse.outputs.reason }}
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'
},
'approved': {
color: '1D76DB',
description: 'Submission was approved by a maintainer'
},
'rejected': {
color: 'B60205',
description: 'Submission was rejected or failed intake validation'
}
};
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({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
name
});
} catch (error) {
if (error.status !== 404) {
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,
issue_number: context.issue.number,
labels: ['external-plugin', 'rejected']
});
await removeLabel('awaiting-review');
await removeLabel('ready-for-review');
await removeLabel('approved');
const marker = '<!-- external-plugin-rejection -->';
const reason = process.env.REASON || 'No additional reason was provided.';
const body = [
marker,
'## ❌ External plugin rejected',
'',
`A maintainer rejected **${process.env.PLUGIN_NAME}**, and the submission issue has been closed.`,
'',
'### Reason',
'',
reason,
'',
'If you address the feedback, open a new external plugin submission issue with the updated details.'
].join('\n');
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
per_page: 100
});
const existingComment = comments.find((comment) =>
comment.user?.login === 'github-actions[bot]' &&
comment.body?.includes(marker)
);
if (existingComment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existingComment.id,
body
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body
});
}
if (context.payload.issue.state !== 'closed') {
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
state: 'closed'
});
}
@@ -0,0 +1,172 @@
name: External Plugin Intake
on:
issues:
types: [opened, edited, reopened]
permissions:
contents: read
issues: write
jobs:
validate-submission:
runs-on: ubuntu-latest
if: >-
contains(github.event.issue.labels.*.name, 'external-plugin') ||
contains(github.event.issue.body, '<!-- external-plugin-submission -->')
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- name: Evaluate submission
id: evaluation
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
result=$(node ./eng/external-plugin-intake.mjs "$GITHUB_EVENT_PATH")
{
echo 'result<<EOF'
echo "$result"
echo 'EOF'
} >> "$GITHUB_OUTPUT"
- name: Sync labels and comment
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
env:
RESULT_JSON: ${{ steps.evaluation.outputs.result }}
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'
},
'approved': {
color: '1D76DB',
description: 'Submission was approved by a maintainer'
},
'rejected': {
color: 'B60205',
description: 'Submission was rejected or failed intake validation'
}
};
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 syncManagedLabels(issueNumber, desiredLabels) {
await Promise.all(Object.entries(managedLabels).map(([name, config]) => ensureLabel(name, config)));
const managedForSync = ['external-plugin', 'awaiting-review', 'ready-for-review', 'rejected'];
const currentLabels = await github.paginate(github.rest.issues.listLabelsOnIssue, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
per_page: 100
});
const currentManagedLabels = currentLabels
.map((label) => label.name)
.filter((name) => managedForSync.includes(name));
const labelsToAdd = [...desiredLabels].filter((name) => !currentManagedLabels.includes(name));
const labelsToRemove = currentManagedLabels.filter((name) => !desiredLabels.has(name));
if (labelsToAdd.length > 0) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
labels: labelsToAdd
});
}
for (const name of labelsToRemove) {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
name
});
}
}
const result = JSON.parse(process.env.RESULT_JSON);
const issueNumber = context.issue.number;
const issueState = context.payload.issue.state;
const action = context.payload.action;
const existingLabelNames = (context.payload.issue.labels || []).map((label) => label.name);
if (existingLabelNames.includes('approved')) {
core.info('Issue is already approved; skipping intake synchronization.');
return;
}
if (issueState === 'closed' && action !== 'reopened') {
core.info('Issue is closed; waiting for reopen before rerunning intake synchronization.');
return;
}
const desiredLabels = result.valid
? new Set(['external-plugin', 'ready-for-review'])
: new Set(['external-plugin', 'rejected']);
await syncManagedLabels(issueNumber, desiredLabels);
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
per_page: 100
});
const existingComment = comments.find((comment) =>
comment.user?.login === 'github-actions[bot]' &&
comment.body?.includes(result.commentMarker)
);
if (existingComment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existingComment.id,
body: result.commentBody
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
body: result.commentBody
});
}
if (!result.valid && issueState === 'open') {
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
state: 'closed'
});
}
@@ -0,0 +1,333 @@
name: External Plugin Re-review Commands
on:
issue_comment:
types: [created]
permissions:
contents: write
issues: write
pull-requests: write
jobs:
handle-command:
runs-on: ubuntu-latest
if: >-
!github.event.issue.pull_request &&
contains(github.event.comment.body, '/re-review-')
steps:
- name: Checkout staged branch
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
ref: staged
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22
cache: npm
- name: Parse re-review command
id: parse
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
with:
script: |
const path = require('path');
const { pathToFileURL } = require('url');
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 command = rereview.parseRereviewCommand(context.payload.comment.body);
core.setOutput('should-run', 'false');
if (!command) {
core.info('No supported re-review command was found.');
return;
}
const permission = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username: context.payload.comment.user.login
});
const hasWriteAccess = ['admin', 'write', 'maintain'].includes(permission.data.permission);
if (!hasWriteAccess) {
core.info(`Ignoring ${command} because ${context.payload.comment.user.login} does not have write access.`);
return;
}
const labelNames = new Set((context.payload.issue.labels || []).map((label) => label.name));
if (!labelNames.has('external-plugin') || !labelNames.has('approved')) {
core.info('Ignoring command because the issue is not an approved external plugin submission.');
return;
}
const inRereviewQueue =
labelNames.has('re-review-due') ||
labelNames.has('re-review-follow-up');
if (!inRereviewQueue) {
core.info(`Ignoring ${command} because the issue is not currently in the six-month re-review queue.`);
return;
}
const { plugins, errors } = validation.readExternalPlugins({ policy: 'marketplace' });
if (errors.length > 0) {
core.setFailed(errors.join('\n'));
return;
}
const currentIssue = await github.rest.issues.get({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number
});
const match = rereview.matchExternalPluginForIssue(currentIssue.data, plugins);
const plugin = match.plugin;
const fallbackName = match.submission.pluginName ?? `issue-${context.issue.number}`;
core.setOutput('should-run', 'true');
core.setOutput('command', command);
core.setOutput('has-plugin', plugin ? 'true' : 'false');
core.setOutput('plugin-name', plugin?.name ?? fallbackName);
core.setOutput('plugin-slug', rereview.slugifyPluginName(plugin?.name ?? fallbackName));
core.setOutput('source-repo', plugin?.source?.repo ?? match.submission.sourceRepo ?? '');
- name: Renew six-month review window
if: steps.parse.outputs.should-run == 'true' && steps.parse.outputs.command == 'keep'
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
env:
PLUGIN_NAME: ${{ steps.parse.outputs.plugin-name }}
HAS_PLUGIN: ${{ steps.parse.outputs.has-plugin }}
with:
script: |
const pluginName = process.env.PLUGIN_NAME;
const hasPlugin = process.env.HAS_PLUGIN === 'true';
async function removeLabel(name) {
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
name
});
} catch (error) {
if (error.status !== 404) {
throw error;
}
}
}
if (!hasPlugin) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: `Could not find a current \`plugins/external.json\` entry for **${pluginName}**, so the six-month re-review window was not reset. Review the listing manually before retrying.`
});
return;
}
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
state: 'open'
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
state: 'closed'
});
await removeLabel('re-review-due');
await removeLabel('re-review-follow-up');
await removeLabel('removed');
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: `Renewed **${pluginName}** for another six months by reopening and reclosing this approved submission issue.`
});
- name: Mark follow-up needed
if: steps.parse.outputs.should-run == 'true' && steps.parse.outputs.command == 'needs-changes'
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
env:
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,
issue_number: context.issue.number,
labels: ['re-review-due', 're-review-follow-up']
});
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: `Marked **${process.env.PLUGIN_NAME}** as needing follow-up. The plugin will stay in the six-month re-review queue until a maintainer comments \`/re-review-keep\` or \`/re-review-remove\`.`
});
- name: Install dependencies
if: steps.parse.outputs.should-run == 'true' && steps.parse.outputs.command == 'remove' && steps.parse.outputs.has-plugin == 'true'
run: npm ci
- name: Remove plugin and create PR
id: remove_pr
if: steps.parse.outputs.should-run == 'true' && steps.parse.outputs.command == 'remove' && steps.parse.outputs.has-plugin == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
plugin_name='${{ steps.parse.outputs.plugin-name }}'
plugin_slug='${{ steps.parse.outputs.plugin-slug }}'
source_repo='${{ steps.parse.outputs.source-repo }}'
issue_number='${{ github.event.issue.number }}'
branch="automation/external-plugin-rereview-remove-${issue_number}-${plugin_slug}"
node ./eng/external-plugin-rereview.mjs remove --plugin-name "$plugin_name" --source-repo "$source_repo" --file ./plugins/external.json
npm run build
bash eng/fix-line-endings.sh
if git diff --quiet; then
echo "changed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -B "$branch"
git add -A
git commit -m "Remove external plugin ${plugin_name} after six-month re-review"
git push --force-with-lease origin "$branch"
pr_url=$(gh pr list --head "$branch" --base staged --json url --jq '.[0].url')
if [ -z "$pr_url" ]; then
pr_body=$(printf '%s\n' \
'## Summary' \
'' \
"- remove \`${plugin_name}\` from \`plugins/external.json\`" \
'- regenerate marketplace outputs after the six-month re-review decision' \
"- closes #${issue_number} review follow-up for this listing")
pr_url=$(gh pr create \
--base staged \
--head "$branch" \
--title "[external-plugin] Remove ${plugin_name} after re-review" \
--body "$pr_body")
fi
echo "changed=true" >> "$GITHUB_OUTPUT"
echo "pr-url=$pr_url" >> "$GITHUB_OUTPUT"
- name: Finalize removal
if: steps.parse.outputs.should-run == 'true' && steps.parse.outputs.command == 'remove'
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
env:
CHANGED: ${{ steps.remove_pr.outputs.changed }}
PR_URL: ${{ steps.remove_pr.outputs.pr-url }}
PLUGIN_NAME: ${{ steps.parse.outputs.plugin-name }}
HAS_PLUGIN: ${{ steps.parse.outputs.has-plugin }}
with:
script: |
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 removeLabel(name) {
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
name
});
} catch (error) {
if (error.status !== 404) {
throw error;
}
}
}
const changed = process.env.CHANGED === 'true';
const prUrl = process.env.PR_URL;
const pluginName = process.env.PLUGIN_NAME;
const hasPlugin = process.env.HAS_PLUGIN === 'true';
let body;
if (!hasPlugin || !changed) {
await ensureLabel('removed', 'B60205', 'External plugin was removed from the marketplace after re-review');
await removeLabel('approved');
await removeLabel('re-review-due');
await removeLabel('re-review-follow-up');
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: ['removed']
});
body = `Marked **${pluginName}** as removed. No new PR was needed because the listing is already absent from \`plugins/external.json\`.`;
} else {
await ensureLabel('re-review-follow-up', 'D4C5F9', 'Six-month re-review needs maintainer follow-up before a final decision');
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: ['re-review-due', 're-review-follow-up']
});
body = `Opened the removal PR for **${pluginName}**: ${prUrl}. The issue remains approved and due for re-review until that removal lands in \`staged\`.`;
}
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body
});
@@ -0,0 +1,271 @@
name: External Plugin Re-review
on:
schedule:
- cron: "23 4 * * *"
workflow_dispatch:
permissions:
contents: read
issues: write
jobs:
sync-rereview:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- name: Sync six-month re-review queue
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
with:
script: |
const path = require('path');
const { pathToFileURL } = require('url');
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({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
name: label
});
} catch (error) {
if (error.status !== 404) {
throw error;
}
}
}
async function addLabel(issueNumber, label) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
labels: [label]
});
}
function formatDate(dateValue) {
return new Date(dateValue).toISOString().slice(0, 10);
}
function daysPastThreshold(closedAt, threshold) {
const diff = Date.parse(threshold.toISOString()) - Date.parse(closedAt);
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'));
return;
}
const threshold = new Date();
threshold.setUTCDate(threshold.getUTCDate() - 183);
const approvedIssues = await github.paginate(github.rest.issues.listForRepo, {
owner: context.repo.owner,
repo: context.repo.repo,
state: 'closed',
labels: 'external-plugin,approved',
per_page: 100
});
const issueRecords = approvedIssues
.filter((issue) => !issue.pull_request && issue.closed_at)
.map((issue) => {
const match = rereview.matchExternalPluginForIssue(issue, plugins);
return {
issue,
match
};
});
const dueRecords = issueRecords.filter(({ issue, match }) => {
if (!match.plugin) {
return false;
}
return Date.parse(issue.closed_at) <= threshold.getTime();
});
const unmatchedDueRecords = issueRecords.filter(({ issue, match }) => {
if (match.plugin) {
return false;
}
return Date.parse(issue.closed_at) <= threshold.getTime();
});
const dueIssueNumbers = new Set([
...dueRecords.map((record) => record.issue.number),
...unmatchedDueRecords.map((record) => record.issue.number)
]);
for (const { issue, match } of issueRecords) {
const labelNames = new Set((issue.labels || []).map((label) => label.name));
const shouldHaveDueLabel = dueIssueNumbers.has(issue.number);
if (shouldHaveDueLabel && !labelNames.has(rereview.REREVIEW_LABELS.due)) {
await addLabel(issue.number, rereview.REREVIEW_LABELS.due);
}
if (!shouldHaveDueLabel && labelNames.has(rereview.REREVIEW_LABELS.due)) {
await removeLabel(issue.number, rereview.REREVIEW_LABELS.due);
}
if (shouldHaveDueLabel && match.plugin && labelNames.has(rereview.REREVIEW_LABELS.removed)) {
await removeLabel(issue.number, rereview.REREVIEW_LABELS.removed);
}
}
const openIssues = await github.paginate(github.rest.issues.listForRepo, {
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
per_page: 100
});
const existingTrackerIssues = openIssues
.filter((issue) => !issue.pull_request && issue.body?.includes(rereview.REREVIEW_REPORT_MARKER))
.sort((left, right) => left.number - right.number);
if (dueRecords.length === 0 && unmatchedDueRecords.length === 0) {
for (const tracker of existingTrackerIssues) {
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: tracker.number,
state: 'closed'
});
}
core.info('No external plugins are currently due for six-month re-review.');
return;
}
const dueRows = dueRecords
.sort((left, right) => Date.parse(left.issue.closed_at) - Date.parse(right.issue.closed_at))
.map(({ issue, match }) => {
const labelNames = new Set((issue.labels || []).map((label) => label.name));
const status = labelNames.has(rereview.REREVIEW_LABELS.followUp) ? 'Needs follow-up' : 'Awaiting decision';
const repo = match.plugin.source?.repo ?? match.submission.sourceRepo ?? '_unknown_';
return `| ${match.plugin.name} | ${match.plugin.version} | \`${repo}\` | #${issue.number} | ${formatDate(issue.closed_at)} | ${daysPastThreshold(issue.closed_at, threshold)} | ${status} |`;
});
const unmatchedRows = unmatchedDueRecords
.sort((left, right) => Date.parse(left.issue.closed_at) - Date.parse(right.issue.closed_at))
.map(({ issue, match }) => {
const pluginName = match.submission.pluginName ?? '_unknown_';
const repo = match.submission.sourceRepo ? `\`${match.submission.sourceRepo}\`` : '_unknown_';
return `| #${issue.number} | ${pluginName} | ${repo} | ${formatDate(issue.closed_at)} |`;
});
const body = [
rereview.REREVIEW_REPORT_MARKER,
'## 🔁 External plugin six-month re-review queue',
'',
'The following approved external plugin submissions have reached the six-month re-review threshold.',
'Review the linked plugin, then comment on the **original approved submission issue** with one of:',
'',
`- \`${rereview.REREVIEW_COMMANDS.keep}\` — renew the plugin for another six months`,
`- \`${rereview.REREVIEW_COMMANDS.needsChanges}\` — keep the plugin in the due queue while follow-up work happens`,
`- \`${rereview.REREVIEW_COMMANDS.remove}\` — open or update a PR against \`staged\` that removes the plugin from the marketplace`,
'',
`- **Threshold date used by this run:** ${formatDate(threshold.toISOString())}`,
'',
'### Plugins due now',
'',
dueRows.length > 0
? [
'| Plugin | Version | Source repo | Submission issue | Closed at | Days past threshold | Status |',
'|---|---|---|---:|---|---:|---|',
...dueRows
].join('\n')
: '_No currently listed plugins are due right now._',
unmatchedRows.length > 0
? [
'',
'### Approved issues without a current marketplace match',
'',
'These closed approved issues are older than six months, but no matching entry was found in `plugins/external.json`. Review them manually if the listing was renamed or removed outside the re-review flow.',
'',
'| Submission issue | Parsed plugin name | Parsed repo | Closed at |',
'|---:|---|---|---|',
...unmatchedRows
].join('\n')
: '',
].filter(Boolean).join('\n');
if (existingTrackerIssues.length > 0) {
const [primary, ...duplicates] = existingTrackerIssues;
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: primary.number,
title: '🔁 External Plugin Six-Month Review',
body,
labels: [rereview.REREVIEW_LABELS.due]
});
for (const duplicate of duplicates) {
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: duplicate.number,
state: 'closed'
});
}
core.info(`Updated re-review tracker issue #${primary.number}.`);
return;
}
const created = await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: '🔁 External Plugin Six-Month Review',
body,
labels: [rereview.REREVIEW_LABELS.due]
});
core.info(`Created re-review tracker issue #${created.data.number}.`);
+9 -4
View File
@@ -162,10 +162,15 @@ When adding a new agent, instruction, skill, hook, workflow, or plugin:
**For External Plugins:**
1. Edit `plugins/external.json` and add an entry with `name`, `source`, `description`, and `version`
2. The `source` field should be an object specifying a GitHub repo, git URL, npm package, or pip package (see [CONTRIBUTING.md](CONTRIBUTING.md#adding-external-plugins))
3. Run `npm run build` to regenerate marketplace.json
4. Verify the external plugin appears in `.github/plugin/marketplace.json`
1. Do not open a direct PR that edits `plugins/external.json` for a public third-party plugin submission
2. Public external plugin submissions use the external plugin issue workflow documented in [CONTRIBUTING.md](CONTRIBUTING.md#adding-external-plugins)
3. In v1, only GitHub-hosted plugins are accepted for public submission, using a public repo plus an immutable `ref`
4. The shared validator in `eng/external-plugin-validation.mjs` is the canonical source of truth for external plugin data rules; reuse it instead of duplicating checks in scripts or workflows
5. Submission issues move through `external-plugin` + `awaiting-review` -> `ready-for-review` -> `approved` or `rejected`
6. Maintainers make the decision with `/approve` or `/reject <reason>` issue comments; approved issues are closed and used as the six-month re-review anchor
7. Approval automation creates or updates the PR against `staged`, updates `plugins/external.json`, and regenerates marketplace outputs
8. Nightly re-review automation finds closed `external-plugin` + `approved` issues that are at least six months old, applies `re-review-due`, and opens or updates a tracking issue for maintainers
9. Maintainers complete re-review on the original approved submission issue with `/re-review-keep`, `/re-review-needs-changes`, or `/re-review-remove`; keep resets the issue `closed_at`, and remove opens a PR against `staged`
### Testing Instructions
+89 -16
View File
@@ -45,7 +45,7 @@ To maintain a safe, responsible, and high-signal collection, we will **not accep
- **Promote Harmful Content**: Guidance that could lead to the creation of harmful, discriminatory, or inappropriate content
- **Circumvent Platform Policies**: Attempts to work around GitHub, Microsoft, or other platform terms of service
- **Duplicate Existing Model Strengths Without Meaningful Uplift**: Submissions that mainly tell Copilot to do work frontier models already handle well (for example, generic TypeScript, HTML, or other broadly-supported coding tasks) without addressing a clear gap, specialized workflow, or domain-specific constraint. These contributions are often lower value for users and can introduce weaker or conflicting guidance than the model's default behavior.
- **Plugins from remote sources**: While the plugin design allows us to support plugins from other GitHub repos, or other Git endpoints, we are not accepting contributions that simply add plugins from external sources. Plugins from remote sources represent a security risk as we are unable to verify their content for the policies we enforce on this repository. This policy does not apply to repositories that are managed by Microsoft or GitHub.
- **Unreviewed remote-source plugins**: Do not open a pull request that directly adds a third-party plugin to `plugins/external.json`. Public external plugins must use the review workflow documented below. In v1, that workflow only accepts plugins hosted in public GitHub repositories; non-GitHub sources such as generic git URLs are not accepted for public submissions.
## Quality Guidelines
@@ -189,33 +189,106 @@ plugins/my-plugin-id/
#### Adding External Plugins
External plugins are plugins hosted outside this repository (e.g., in a GitHub repo, npm package, or git URL). They are listed in `plugins/external.json` and merged into the generated `marketplace.json` during build.
External plugins are plugins hosted outside this repository and listed in `plugins/external.json`. Public contributors should **not** open a PR that edits `plugins/external.json` directly. Instead, submit external plugins through the public review workflow below.
To add an external plugin, append an entry to `plugins/external.json` following the [Claude Code plugin marketplace spec](https://code.claude.com/docs/en/plugin-marketplaces#plugin-entries). Each entry requires `name`, `source`, `description`, and `version`:
> [!IMPORTANT]
> Public external plugin submissions are GitHub-only in v1. The submitted plugin must live in a public GitHub repository and use `source.source: "github"`.
##### Submission fields
The external plugin issue form will collect these fields:
- Plugin name
- Short description
- GitHub repository in `owner/repo` format
- Plugin path inside the repository (optional when the plugin is at the repository root)
- Immutable ref to review (`ref`), using a release tag or full commit SHA rather than a branch
- Plugin version
- License identifier
- Author name
- Author URL (optional)
- Homepage URL (optional)
- Keywords/tags
- Additional notes for reviewers (optional)
- Confirmation checkboxes that the repository is public, the ref is immutable, the submission follows this repository's policies, and the plugin is not a duplicate listing
The repository's canonical validation rules live in `eng/external-plugin-validation.mjs`. Build scripts reuse the `marketplace` policy from that module, and the issue intake automation uses the stricter `publicSubmission` policy so the JSON contract and workflow checks stay aligned.
For entries committed to `plugins/external.json`, the current marketplace validation requires:
- `name`, `description`, and `version`
- `author.name`
- `repository` as an HTTPS GitHub URL
- `keywords` as lowercase hyphenated tags
- `source.source: "github"` plus `source.repo` in `owner/repo` format
- optional `source.path` values to stay relative to the repository root
The public-submission policy builds on those rules and also requires `license` plus an immutable `source.ref`.
##### Review workflow
1. **Open an issue** using the external plugin issue form. Automation applies the `external-plugin` and `awaiting-review` labels.
2. **Automated intake validation** checks that the required fields are present and correctly formatted for a GitHub-hosted plugin. Invalid submissions are closed with a comment explaining what must be fixed before resubmitting.
3. **Ready for maintainer review**: if the issue passes intake validation, automation removes `awaiting-review` and adds `ready-for-review`.
4. **Maintainer decision**: a maintainer with write access performs the manual review, then comments `/approve` or `/reject <reason>` on the issue. Commands from non-maintainers are ignored.
5. **Approval path**: on `/approve`, automation removes `ready-for-review`, adds `approved`, closes the issue, and opens or updates a PR against `staged` that updates `plugins/external.json` and generated marketplace outputs.
6. **Rejection path**: on `/reject <reason>`, automation removes `ready-for-review`, adds `rejected`, closes the issue, and records the reason in an issue comment. Submitters can open a new issue after addressing the feedback.
##### Maintainer review responsibilities
Maintainers are responsible for confirming that the submission:
- Clearly fits the Awesome Copilot collection and adds value beyond existing listings
- Uses a public GitHub repository and an immutable ref that can be reviewed reliably
- Includes the required metadata for `plugins/external.json` (`name`, `description`, `version`, `author.name`, `repository`, `keywords`, and `source`), plus any supplied homepage/license fields
- Does not obviously duplicate an existing marketplace entry
- Continues to meet this repository's content, security, and responsible AI policies
##### Review cadence and label semantics
- `external-plugin`: applied to every public external plugin submission and retained on approved issues so scheduled review automation can find them later
- `awaiting-review`: initial intake state before automation finishes validating the issue
- `ready-for-review`: the issue passed automated intake checks and is waiting on a maintainer decision
- `approved`: the issue was approved, closed, and can be used as the source of truth for six-month re-review
- `rejected`: the issue was rejected and closed without being added to the marketplace
- `re-review-due`: the approved issue reached the six-month review threshold and is waiting on a maintainer re-review decision
- `re-review-follow-up`: a maintainer reviewed the plugin and requested more follow-up before renewing or removing it
- `removed`: the plugin was removed from `plugins/external.json` after re-review and should no longer be considered active
The six-month re-review window starts when an approved submission issue is **closed**. A nightly workflow looks for closed issues labeled `external-plugin` and `approved` whose `closed_at` is at least six months old, applies `re-review-due`, and opens or updates a maintainer-facing tracking issue that links every plugin currently due.
Maintainers complete the re-review on the **original approved submission issue** with one of these issue-comment commands:
- `/re-review-keep` — renew the listing for another six months by reopening and reclosing the approved issue, which resets the `closed_at` review anchor and removes the due labels
- `/re-review-needs-changes` — keep the listing in the due queue while adding `re-review-follow-up` so maintainers can track extra investigation or remediation work
- `/re-review-remove` — open or update a PR against `staged` that removes the plugin from `plugins/external.json` and regenerates marketplace outputs; the issue stays in the due queue until that removal lands
Approved submissions are converted into `plugins/external.json` entries following the [Claude Code plugin marketplace spec](https://code.claude.com/docs/en/plugin-marketplaces#plugin-entries). A typical GitHub-hosted entry looks like this:
```json
[
{
"name": "my-external-plugin",
"description": "Description of the external plugin",
"version": "1.0.0",
"author": {
"name": "Plugin Author",
"url": "https://github.com/plugin-author"
},
"homepage": "https://github.com/owner/plugin-repo",
"keywords": ["category", "workflow"],
"license": "MIT",
"repository": "https://github.com/owner/plugin-repo",
"source": {
"source": "github",
"repo": "owner/plugin-repo"
},
"description": "Description of the external plugin",
"version": "1.0.0"
"repo": "owner/plugin-repo",
"path": ".github/plugins/my-external-plugin",
"ref": "v1.0.0"
}
}
]
```
Supported source types:
- **GitHub**: `{ "source": "github", "repo": "owner/repo", "ref": "v1.0.0" }`
- **Git URL**: `{ "source": "url", "url": "https://gitlab.com/team/plugin.git" }`
- **npm**: `{ "source": "npm", "package": "@scope/package", "version": "1.0.0" }`
- **pip**: `{ "source": "pip", "package": "package-name", "version": "1.0.0" }`
After editing `plugins/external.json`, run `npm run build` to regenerate `marketplace.json`.
### Adding Hooks
Hooks enable automated workflows triggered by specific events during GitHub Copilot coding agent sessions, such as session start, session end, user prompts, and tool usage.
+188
View File
@@ -0,0 +1,188 @@
#!/usr/bin/env node
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import { ROOT_FOLDER } from "./constants.mjs";
import {
EXTERNAL_PLUGINS_FILE,
readExternalPlugins,
validateExternalPlugins,
} from "./external-plugin-validation.mjs";
import { evaluateExternalPluginIssue } from "./external-plugin-intake.mjs";
export const DECISION_COMMANDS = Object.freeze({
approve: "/approve",
reject: "/reject",
});
function normalizeValue(value) {
return String(value ?? "").trim().toLowerCase();
}
function normalizeRepositoryUrl(value) {
const normalized = normalizeValue(value);
if (!normalized) {
return undefined;
}
return normalized
.replace(/^https:\/\/github\.com\//, "")
.replace(/\.git$/i, "")
.replace(/^\/+|\/+$/g, "");
}
function normalizePathValue(value) {
return String(value ?? "")
.trim()
.replace(/^\/+|\/+$/g, "")
.toLowerCase();
}
export function parseDecisionCommand(body) {
const match = String(body ?? "").match(/(?:^|\n)\s*\/(approve|reject)(?=\s|$)([\s\S]*)$/i);
if (!match) {
return undefined;
}
const command = match[1].toLowerCase();
const reason = match[2]?.trim() || undefined;
return {
command,
reason: command === "reject" ? reason : undefined,
};
}
export function slugifyPluginName(value) {
const slug = String(value ?? "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return slug || "external-plugin";
}
function readLocalPluginNames() {
const pluginsDir = path.join(ROOT_FOLDER, "plugins");
if (!fs.existsSync(pluginsDir)) {
return [];
}
return fs.readdirSync(pluginsDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name);
}
function pluginsMatch(left, right) {
const leftName = normalizeValue(left?.name);
const rightName = normalizeValue(right?.name);
const leftRepo = normalizeValue(left?.source?.repo);
const rightRepo = normalizeValue(right?.source?.repo);
const leftPath = normalizePathValue(left?.source?.path);
const rightPath = normalizePathValue(right?.source?.path);
const leftRepository = normalizeRepositoryUrl(left?.repository);
const rightRepository = normalizeRepositoryUrl(right?.repository);
if (leftName && rightName && leftName === rightName) {
return true;
}
const repoMatches = leftRepo && rightRepo && leftRepo === rightRepo;
const repositoryMatches = leftRepository && rightRepository && leftRepository === rightRepository;
const pathKnown = Boolean(leftPath || rightPath);
const pathMatches = leftPath === rightPath;
if ((repoMatches || repositoryMatches) && pathKnown && pathMatches) {
return true;
}
return false;
}
export function upsertExternalPlugin(plugin, { filePath = EXTERNAL_PLUGINS_FILE } = {}) {
const { plugins, errors } = readExternalPlugins({
filePath,
localPluginNames: readLocalPluginNames(),
policy: "marketplace",
});
if (errors.length > 0) {
throw new Error(errors.join("\n"));
}
const updatedPlugins = [...plugins];
const existingIndex = updatedPlugins.findIndex((existingPlugin) => pluginsMatch(existingPlugin, plugin));
const action = existingIndex === -1 ? "inserted" : "updated";
if (existingIndex === -1) {
updatedPlugins.push(plugin);
} else {
updatedPlugins[existingIndex] = plugin;
}
updatedPlugins.sort((left, right) => left.name.localeCompare(right.name, undefined, { sensitivity: "base" }));
const { errors: validationErrors } = validateExternalPlugins(updatedPlugins, {
localPluginNames: readLocalPluginNames(),
policy: "marketplace",
});
if (validationErrors.length > 0) {
throw new Error(validationErrors.join("\n"));
}
const changed = JSON.stringify(updatedPlugins) !== JSON.stringify(plugins);
if (changed) {
fs.writeFileSync(filePath, `${JSON.stringify(updatedPlugins, null, 2)}\n`);
}
return {
action,
changed,
plugin,
};
}
function readCliArgs(argv) {
const args = {};
for (let index = 0; index < argv.length; index += 1) {
const key = argv[index];
if (!key.startsWith("--")) {
continue;
}
args[key.slice(2)] = argv[index + 1];
index += 1;
}
return args;
}
const isCli = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
if (isCli) {
const [command, eventPath] = process.argv.slice(2);
if (command !== "approve" || !eventPath) {
console.error("Usage: node ./eng/external-plugin-approval.mjs approve <github-event.json> [--file <path>]");
process.exit(1);
}
const args = readCliArgs(process.argv.slice(4));
const event = JSON.parse(fs.readFileSync(eventPath, "utf8"));
const evaluation = await evaluateExternalPluginIssue({
issue: event.issue,
token: process.env.GITHUB_TOKEN,
});
if (!evaluation.valid) {
console.error(evaluation.errors.join("\n"));
process.exit(1);
}
const result = upsertExternalPlugin(evaluation.plugin, { filePath: args.file });
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
}
+369
View File
@@ -0,0 +1,369 @@
#!/usr/bin/env node
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import { ROOT_FOLDER } from "./constants.mjs";
import { readExternalPlugins, validateExternalPlugin } from "./external-plugin-validation.mjs";
const ISSUE_FORM_MARKER = "<!-- external-plugin-submission -->";
const PLUGINS_DIR = path.join(ROOT_FOLDER, "plugins");
const REQUIRED_CHECKLIST_ITEMS = [
"The plugin lives in a public GitHub repository.",
"The ref I provided is an immutable release tag or full 40-character commit SHA, not a branch.",
"This submission follows this repository's contribution, security, and responsible AI policies.",
"This plugin is not already listed in the Awesome Copilot marketplace.",
];
const FIELD_TITLES = Object.freeze({
pluginName: "Plugin name",
shortDescription: "Short description",
githubRepository: "GitHub repository",
pluginPath: "Plugin path inside the repository",
immutableRef: "Immutable ref to review",
version: "Version",
license: "License identifier",
authorName: "Author name",
authorUrl: "Author URL",
homepageUrl: "Homepage URL",
keywords: "Keywords",
additionalNotes: "Additional notes for reviewers",
submissionChecklist: "Submission checklist",
});
function normalizeMultilineText(value) {
return String(value ?? "").replace(/\r\n/g, "\n");
}
function stripNoResponse(value) {
if (value === undefined) {
return undefined;
}
const normalized = normalizeMultilineText(value).trim();
if (!normalized || normalized === "_No response_") {
return undefined;
}
return normalized;
}
function parseIssueFormSections(body) {
const normalized = normalizeMultilineText(body);
const sections = new Map();
const matches = [...normalized.matchAll(/^###\s+(.+)$/gm)];
for (let index = 0; index < matches.length; index += 1) {
const heading = matches[index][1].trim();
const start = matches[index].index + matches[index][0].length;
const end = index + 1 < matches.length ? matches[index + 1].index : normalized.length;
const content = normalized.slice(start, end).trim();
sections.set(heading, content);
}
return sections;
}
function normalizeGitHubRepo(value) {
if (!value) {
return undefined;
}
const trimmed = value.trim();
const urlMatch = trimmed.match(/^https:\/\/github\.com\/([^/]+\/[^/]+?)(?:\.git)?\/?$/i);
if (urlMatch) {
return urlMatch[1];
}
return trimmed.replace(/^github\.com\//i, "").replace(/\.git$/i, "").replace(/^\/+|\/+$/g, "");
}
function parseKeywords(value) {
const normalized = stripNoResponse(value);
if (!normalized) {
return undefined;
}
const keywords = normalized
.split(/[\n,]/)
.map((entry) => entry.trim())
.filter(Boolean);
return keywords.length > 0 ? keywords : undefined;
}
function parseChecklist(value) {
const checked = new Set();
const normalized = normalizeMultilineText(value);
for (const match of normalized.matchAll(/^- \[(x|X)\] (.+)$/gm)) {
checked.add(match[2].trim());
}
return checked;
}
function readLocalPluginNames() {
if (!fs.existsSync(PLUGINS_DIR)) {
return [];
}
return fs.readdirSync(PLUGINS_DIR, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name);
}
function toSubmissionError(message) {
return message.replace(/^external\.json\[0\]:\s*/, "submission: ");
}
async function fetchGitHubJson(apiPath, token) {
const response = await fetch(`https://api.github.com${apiPath}`, {
headers: {
Accept: "application/vnd.github+json",
"User-Agent": "awesome-copilot-external-plugin-intake",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
});
if (response.status === 404) {
return { ok: false, status: 404, data: null };
}
let data = null;
try {
data = await response.json();
} catch {
data = null;
}
return {
ok: response.ok,
status: response.status,
data,
};
}
function encodeRepoPath(repo) {
const [owner, name] = String(repo).split("/");
return `${encodeURIComponent(owner ?? "")}/${encodeURIComponent(name ?? "")}`;
}
async function validateRemoteRepository(repo, ref, errors, warnings, token) {
const encodedRepo = encodeRepoPath(repo);
const repositoryResponse = await fetchGitHubJson(`/repos/${encodedRepo}`, token);
if (!repositoryResponse.ok) {
if (repositoryResponse.status === 404) {
errors.push(`submission: GitHub repository "${repo}" was not found`);
} else {
errors.push(`submission: could not inspect GitHub repository "${repo}" (HTTP ${repositoryResponse.status})`);
}
return;
}
if (repositoryResponse.data?.private) {
errors.push(`submission: GitHub repository "${repo}" must be public`);
}
if (repositoryResponse.data?.archived) {
warnings.push(`submission: GitHub repository "${repo}" is archived`);
}
if (!ref) {
return;
}
if (/^[0-9a-f]{40}$/i.test(ref)) {
const commitResponse = await fetchGitHubJson(`/repos/${encodedRepo}/commits/${encodeURIComponent(ref)}`, token);
if (!commitResponse.ok) {
errors.push(`submission: commit "${ref}" was not found in GitHub repository "${repo}"`);
}
return;
}
const tagName = ref.startsWith("refs/tags/") ? ref.slice("refs/tags/".length) : ref;
const tagResponse = await fetchGitHubJson(`/repos/${encodedRepo}/git/ref/tags/${encodeURIComponent(tagName)}`, token);
if (tagResponse.ok) {
return;
}
if (/^[0-9a-f]+$/i.test(ref) && ref.length !== 40) {
errors.push('submission: commit SHAs in "Immutable ref to review" must use the full 40-character SHA');
return;
}
if (!tagResponse.ok) {
errors.push(`submission: tag "${ref}" was not found in GitHub repository "${repo}"`);
}
}
export function parseExternalPluginIssueBody(body) {
const sections = parseIssueFormSections(body);
const errors = [];
function requiredField(title) {
const value = stripNoResponse(sections.get(title));
if (!value) {
errors.push(`submission: "${title}" is required`);
}
return value;
}
const pluginName = requiredField(FIELD_TITLES.pluginName);
const shortDescription = requiredField(FIELD_TITLES.shortDescription);
const repoInput = normalizeGitHubRepo(requiredField(FIELD_TITLES.githubRepository));
const immutableRef = requiredField(FIELD_TITLES.immutableRef);
const version = requiredField(FIELD_TITLES.version);
const license = requiredField(FIELD_TITLES.license);
const authorName = requiredField(FIELD_TITLES.authorName);
const pluginPath = stripNoResponse(sections.get(FIELD_TITLES.pluginPath));
const authorUrl = stripNoResponse(sections.get(FIELD_TITLES.authorUrl));
const homepageUrl = stripNoResponse(sections.get(FIELD_TITLES.homepageUrl));
const keywords = parseKeywords(sections.get(FIELD_TITLES.keywords));
const additionalNotes = stripNoResponse(sections.get(FIELD_TITLES.additionalNotes));
const checkedItems = parseChecklist(sections.get(FIELD_TITLES.submissionChecklist));
for (const item of REQUIRED_CHECKLIST_ITEMS) {
if (!checkedItems.has(item)) {
errors.push(`submission: checklist item must be checked: "${item}"`);
}
}
const plugin = {
name: pluginName,
description: shortDescription,
version,
author: {
name: authorName,
...(authorUrl ? { url: authorUrl } : {}),
},
repository: repoInput ? `https://github.com/${repoInput}` : undefined,
...(homepageUrl ? { homepage: homepageUrl } : {}),
...(license ? { license } : {}),
...(keywords ? { keywords } : {}),
source: {
source: "github",
repo: repoInput,
...(pluginPath ? { path: pluginPath } : {}),
...(immutableRef ? { ref: immutableRef } : {}),
},
};
return {
markerPresent: normalizeMultilineText(body).includes(ISSUE_FORM_MARKER),
errors,
plugin,
additionalNotes,
};
}
export async function evaluateExternalPluginIssue({ issue, token } = {}) {
const issueBody = issue?.body ?? "";
const parsed = parseExternalPluginIssueBody(issueBody);
const errors = [...parsed.errors];
const warnings = [];
const localPluginNames = readLocalPluginNames();
const { plugins: existingExternalPlugins } = readExternalPlugins({ policy: "marketplace" });
const duplicateNames = [
...localPluginNames,
...existingExternalPlugins.map((plugin) => plugin.name).filter(Boolean),
];
const validationResult = validateExternalPlugin(parsed.plugin, 0, { policy: "publicSubmission" });
errors.push(...validationResult.errors.map(toSubmissionError));
warnings.push(...validationResult.warnings.map(toSubmissionError));
if (parsed.plugin?.name) {
const matchingName = duplicateNames.find(
(name) => String(name).toLowerCase() === String(parsed.plugin.name).toLowerCase(),
);
if (matchingName) {
errors.push(`submission: plugin name "${parsed.plugin.name}" conflicts with existing plugin "${matchingName}"`);
}
}
if (parsed.plugin?.source?.repo && parsed.plugin?.source?.ref) {
await validateRemoteRepository(parsed.plugin.source.repo, parsed.plugin.source.ref, errors, warnings, token);
}
const dedupedErrors = [...new Set(errors)];
const dedupedWarnings = [...new Set(warnings)];
const valid = dedupedErrors.length === 0;
const marker = "<!-- external-plugin-intake -->";
const normalizedKeywords = parsed.plugin?.keywords?.length ? parsed.plugin.keywords.join(", ") : "_None provided_";
const notes = parsed.additionalNotes ?? "_No additional notes provided._";
const payload = parsed.plugin
? [
"```json",
JSON.stringify(parsed.plugin, null, 2),
"```",
].join("\n")
: "```json\n{}\n```";
const commentBody = valid
? [
marker,
"## ✅ External plugin intake passed",
"",
`This submission passed automated intake validation and is ready for maintainer review.`,
"",
`- **Plugin:** ${parsed.plugin.name}`,
`- **Repository:** ${parsed.plugin.repository}`,
`- **Ref:** ${parsed.plugin.source.ref}`,
`- **Keywords:** ${normalizedKeywords}`,
"",
"### Canonical external.json payload",
"",
payload,
"",
"### Reviewer notes",
"",
notes,
dedupedWarnings.length > 0
? ["", "### Warnings", "", ...dedupedWarnings.map((warning) => `- ${warning}`)].join("\n")
: "",
].filter(Boolean).join("\n")
: [
marker,
"## ❌ External plugin intake failed",
"",
"This submission did not pass automated intake validation, so the issue has been closed.",
"Update the issue form, then reopen the issue to run intake validation again.",
"",
"### Required fixes",
"",
...dedupedErrors.map((error) => `- ${error}`),
dedupedWarnings.length > 0
? ["", "### Warnings", "", ...dedupedWarnings.map((warning) => `- ${warning}`)].join("\n")
: "",
].filter(Boolean).join("\n");
return {
valid,
markerPresent: parsed.markerPresent,
errors: dedupedErrors,
warnings: dedupedWarnings,
plugin: parsed.plugin,
commentBody,
commentMarker: marker,
};
}
const isCli = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
if (isCli) {
const eventPath = process.argv[2];
if (!eventPath) {
console.error("Usage: node ./eng/external-plugin-intake.mjs <github-event.json>");
process.exit(1);
}
const event = JSON.parse(fs.readFileSync(eventPath, "utf8"));
const result = await evaluateExternalPluginIssue({ issue: event.issue, token: process.env.GITHUB_TOKEN });
process.stdout.write(JSON.stringify(result));
}
+268
View File
@@ -0,0 +1,268 @@
#!/usr/bin/env node
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import { EXTERNAL_PLUGINS_FILE, readExternalPlugins } from "./external-plugin-validation.mjs";
import { parseExternalPluginIssueBody } from "./external-plugin-intake.mjs";
export const REREVIEW_REPORT_MARKER = "<!-- external-plugin-rereview-report -->";
export const REREVIEW_LABELS = Object.freeze({
due: "re-review-due",
followUp: "re-review-follow-up",
removed: "removed",
});
export const REREVIEW_COMMANDS = Object.freeze({
keep: "/re-review-keep",
needsChanges: "/re-review-needs-changes",
remove: "/re-review-remove",
});
function normalizeValue(value) {
return String(value ?? "").trim().toLowerCase();
}
function normalizeRepositoryUrl(value) {
const normalized = normalizeValue(value);
if (!normalized) {
return undefined;
}
return normalized
.replace(/^https:\/\/github\.com\//, "")
.replace(/\.git$/i, "")
.replace(/^\/+|\/+$/g, "");
}
function normalizePathValue(value) {
return String(value ?? "")
.trim()
.replace(/^\/+|\/+$/g, "")
.toLowerCase();
}
function stripIssueTitlePrefix(title) {
return String(title ?? "")
.trim()
.replace(/^\[\s*external plugin\s*\]\s*:\s*/i, "")
.replace(/^(external plugin(?: submission)?|public external plugin)(?:\s*[:-]\s*|\s+)/i, "")
.trim();
}
function firstMatch(body, patterns) {
for (const pattern of patterns) {
const match = body.match(pattern);
if (match?.[1]) {
return match[1].trim();
}
}
return undefined;
}
function fallbackSubmissionData(issue) {
const body = String(issue?.body ?? "");
const title = stripIssueTitlePrefix(issue?.title);
const sourceRepo = firstMatch(body, [
/https:\/\/github\.com\/([^/\s]+\/[^/\s)]+)/i,
/\b([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)\b/,
]);
return {
pluginName: title || undefined,
sourceRepo: sourceRepo ? normalizeRepositoryUrl(sourceRepo) : undefined,
repository: sourceRepo ? `https://github.com/${normalizeRepositoryUrl(sourceRepo)}` : undefined,
};
}
export function extractSubmissionData(issue) {
const parsed = parseExternalPluginIssueBody(issue?.body ?? "");
const fallback = fallbackSubmissionData(issue);
const plugin = parsed.plugin ?? {};
return {
pluginName: plugin.name ?? fallback.pluginName,
sourceRepo: plugin.source?.repo ?? fallback.sourceRepo,
sourcePath: plugin.source?.path,
repository: plugin.repository ?? fallback.repository,
ref: plugin.source?.ref,
};
}
function pluginMatchesSubmission(plugin, submission) {
const pluginName = normalizeValue(plugin?.name);
const submissionName = normalizeValue(submission.pluginName);
const pluginRepo = normalizeValue(plugin?.source?.repo);
const submissionRepo = normalizeValue(submission.sourceRepo);
const pluginPath = normalizePathValue(plugin?.source?.path);
const submissionPath = normalizePathValue(submission.sourcePath);
const pluginRepository = normalizeRepositoryUrl(plugin?.repository);
const submissionRepository = normalizeRepositoryUrl(submission.repository);
const nameMatch = pluginName && submissionName && pluginName === submissionName;
const repoMatch = pluginRepo && submissionRepo && pluginRepo === submissionRepo;
const repositoryMatch = pluginRepository && submissionRepository && pluginRepository === submissionRepository;
const pathProvided = Boolean(submissionPath);
const pathMatch = pluginPath === submissionPath;
if (nameMatch && pathProvided) {
return pathMatch && (repoMatch || repositoryMatch || !submissionRepo);
}
if (nameMatch && (repoMatch || repositoryMatch || !submissionRepo)) {
return true;
}
if ((repoMatch || repositoryMatch) && pathProvided) {
return pathMatch && (!submissionName || nameMatch);
}
if ((repoMatch || repositoryMatch) && submissionName && nameMatch) {
return true;
}
return false;
}
export function matchExternalPluginForIssue(issue, plugins) {
const submission = extractSubmissionData(issue);
const exactMatch = plugins.find((plugin) => pluginMatchesSubmission(plugin, submission));
if (exactMatch) {
return {
plugin: exactMatch,
submission,
matchReason: "exact",
};
}
const byName = submission.pluginName
? plugins.find((plugin) => normalizeValue(plugin?.name) === normalizeValue(submission.pluginName))
: undefined;
if (byName) {
return {
plugin: byName,
submission,
matchReason: "name",
};
}
const repoMatches = submission.sourceRepo
? plugins.filter((plugin) => normalizeValue(plugin?.source?.repo) === normalizeValue(submission.sourceRepo))
: [];
if (repoMatches.length === 1) {
return {
plugin: repoMatches[0],
submission,
matchReason: "repo",
};
}
return {
plugin: undefined,
submission,
matchReason: "none",
};
}
export function parseRereviewCommand(body) {
const match = String(body ?? "").match(/(?:^|\n)\s*\/re-review-(keep|needs-changes|remove)(?=\s|$)/i);
if (!match) {
return undefined;
}
switch (match[1].toLowerCase()) {
case "keep":
return "keep";
case "needs-changes":
return "needs-changes";
case "remove":
return "remove";
default:
return undefined;
}
}
export function slugifyPluginName(value) {
const slug = String(value ?? "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return slug || "external-plugin";
}
export function removePluginFromExternalJson({ pluginName, sourceRepo, filePath = EXTERNAL_PLUGINS_FILE } = {}) {
const { plugins, errors } = readExternalPlugins({ filePath, policy: "marketplace" });
if (errors.length > 0) {
throw new Error(errors.join("\n"));
}
const normalizedPluginName = normalizeValue(pluginName);
const normalizedSourceRepo = normalizeValue(sourceRepo);
const matchIndex = plugins.findIndex((plugin) => {
const nameMatches = normalizedPluginName && normalizeValue(plugin?.name) === normalizedPluginName;
const repoMatches = normalizedSourceRepo && normalizeValue(plugin?.source?.repo) === normalizedSourceRepo;
if (normalizedPluginName && normalizedSourceRepo) {
return nameMatches && repoMatches;
}
return Boolean(nameMatches || repoMatches);
});
if (matchIndex === -1) {
throw new Error(`Could not find external plugin "${pluginName || sourceRepo}" in ${path.relative(process.cwd(), filePath)}`);
}
const updatedPlugins = [...plugins];
const [removedPlugin] = updatedPlugins.splice(matchIndex, 1);
fs.writeFileSync(filePath, `${JSON.stringify(updatedPlugins, null, 2)}\n`);
return removedPlugin;
}
function readCliArgs(argv) {
const args = {};
for (let index = 0; index < argv.length; index += 1) {
const key = argv[index];
if (!key.startsWith("--")) {
continue;
}
const value = argv[index + 1];
args[key.slice(2)] = value;
index += 1;
}
return args;
}
const isCli = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
if (isCli) {
const [command] = process.argv.slice(2);
if (command !== "remove") {
console.error("Usage: node ./eng/external-plugin-rereview.mjs remove --plugin-name <name> [--source-repo <owner/repo>] [--file <path>]");
process.exit(1);
}
const args = readCliArgs(process.argv.slice(3));
if (!args["plugin-name"] && !args["source-repo"]) {
console.error("Provide --plugin-name or --source-repo when removing an external plugin.");
process.exit(1);
}
const removedPlugin = removePluginFromExternalJson({
pluginName: args["plugin-name"],
sourceRepo: args["source-repo"],
filePath: args.file,
});
process.stdout.write(`${JSON.stringify(removedPlugin, null, 2)}\n`);
}
+377
View File
@@ -0,0 +1,377 @@
import fs from "fs";
import path from "path";
import { ROOT_FOLDER } from "./constants.mjs";
export const EXTERNAL_PLUGINS_FILE = path.join(ROOT_FOLDER, "plugins", "external.json");
export const EXTERNAL_PLUGIN_POLICIES = Object.freeze({
marketplace: Object.freeze({
allowedSourceTypes: ["github"],
requireAuthor: true,
requireRepository: true,
requireKeywords: true,
requireLicense: false,
requireImmutableRef: false,
}),
publicSubmission: Object.freeze({
allowedSourceTypes: ["github"],
requireAuthor: true,
requireRepository: true,
requireKeywords: true,
requireLicense: true,
requireImmutableRef: true,
}),
});
function resolvePolicy(policy) {
if (!policy) {
return EXTERNAL_PLUGIN_POLICIES.marketplace;
}
if (typeof policy === "string") {
const resolved = EXTERNAL_PLUGIN_POLICIES[policy];
if (!resolved) {
throw new Error(`Unknown external plugin validation policy "${policy}"`);
}
return resolved;
}
return {
...EXTERNAL_PLUGIN_POLICIES.marketplace,
...policy,
};
}
function isNonEmptyString(value) {
return typeof value === "string" && value.trim().length > 0;
}
function validatePluginName(name, prefix, errors) {
if (!isNonEmptyString(name)) {
errors.push(`${prefix}: "name" is required and must be a non-empty string`);
return;
}
if (name.length > 50) {
errors.push(`${prefix}: "name" must be 50 characters or fewer`);
}
if (!/^[a-z0-9-]+$/.test(name)) {
errors.push(`${prefix}: "name" must contain only lowercase letters, numbers, and hyphens`);
}
}
function validateDescription(description, prefix, errors) {
if (!isNonEmptyString(description)) {
errors.push(`${prefix}: "description" is required and must be a non-empty string`);
return;
}
if (description.length > 500) {
errors.push(`${prefix}: "description" must be 500 characters or fewer`);
}
}
function validateVersion(version, prefix, errors) {
if (!isNonEmptyString(version)) {
errors.push(`${prefix}: "version" is required and must be a non-empty string`);
return;
}
if (version.length > 100) {
errors.push(`${prefix}: "version" must be 100 characters or fewer`);
}
}
function validateKeywords(keywords, prefix, errors, warnings, required) {
if (keywords === undefined) {
if (required) {
errors.push(`${prefix}: "keywords" is required and must be an array of lowercase tags`);
}
return;
}
if (!Array.isArray(keywords)) {
errors.push(`${prefix}: "keywords" must be an array`);
return;
}
if (keywords.length > 10) {
errors.push(`${prefix}: "keywords" must contain no more than 10 entries`);
}
for (let i = 0; i < keywords.length; i++) {
const keyword = keywords[i];
if (!isNonEmptyString(keyword)) {
errors.push(`${prefix}: "keywords[${i}]" must be a non-empty string`);
continue;
}
if (!/^[a-z0-9-]+$/.test(keyword)) {
errors.push(`${prefix}: "keywords[${i}]" must contain only lowercase letters, numbers, and hyphens`);
}
if (keyword.length > 30) {
errors.push(`${prefix}: "keywords[${i}]" must be 30 characters or fewer`);
}
}
if (keywords.length === 0) {
if (required) {
errors.push(`${prefix}: "keywords" must contain at least one entry`);
} else {
warnings.push(`${prefix}: "keywords" is empty; at least one keyword is recommended for discovery`);
}
}
}
function validateHttpsUrl(value, fieldName, prefix, errors, options = {}) {
if (!isNonEmptyString(value)) {
errors.push(`${prefix}: "${fieldName}" must be a non-empty string`);
return;
}
let parsed;
try {
parsed = new URL(value);
} catch {
errors.push(`${prefix}: "${fieldName}" must be a valid URL`);
return;
}
if (parsed.protocol !== "https:") {
errors.push(`${prefix}: "${fieldName}" must use https`);
}
if (options.githubOnly && parsed.hostname !== "github.com") {
errors.push(`${prefix}: "${fieldName}" must point to https://github.com/...`);
}
}
function validateAuthor(author, prefix, errors, required) {
if (author === undefined) {
if (required) {
errors.push(`${prefix}: "author" is required`);
}
return;
}
if (!author || typeof author !== "object" || Array.isArray(author)) {
errors.push(`${prefix}: "author" must be an object`);
return;
}
if (!isNonEmptyString(author.name)) {
errors.push(`${prefix}: "author.name" is required and must be a non-empty string`);
}
if (author.url !== undefined) {
validateHttpsUrl(author.url, "author.url", prefix, errors);
}
}
function validateLicense(license, prefix, errors, required) {
if (license === undefined) {
if (required) {
errors.push(`${prefix}: "license" is required`);
}
return;
}
if (!isNonEmptyString(license)) {
errors.push(`${prefix}: "license" must be a non-empty string`);
}
}
function validateRepository(repository, prefix, errors, required) {
if (repository === undefined) {
if (required) {
errors.push(`${prefix}: "repository" is required`);
}
return;
}
validateHttpsUrl(repository, "repository", prefix, errors, { githubOnly: true });
}
function validateHomepage(homepage, prefix, errors) {
if (homepage === undefined) {
return;
}
validateHttpsUrl(homepage, "homepage", prefix, errors);
}
function validateRelativePath(pathValue, prefix, errors) {
if (!isNonEmptyString(pathValue)) {
errors.push(`${prefix}: "source.path" must be a non-empty string when provided`);
return;
}
const normalized = path.posix.normalize(pathValue);
const segments = pathValue.split("/");
if (pathValue.startsWith("/") || pathValue.startsWith("../") || normalized !== pathValue || segments.includes("..")) {
errors.push(`${prefix}: "source.path" must be a safe relative path inside the repository`);
}
if (pathValue.includes("\\")) {
errors.push(`${prefix}: "source.path" must use forward slashes`);
}
}
function validateImmutableRef(ref, prefix, errors) {
if (!isNonEmptyString(ref)) {
errors.push(`${prefix}: "source.ref" must be a non-empty string when provided`);
return;
}
if (ref.startsWith("refs/heads/")) {
errors.push(`${prefix}: "source.ref" must be a tag or commit SHA, not a branch ref`);
return;
}
if (["main", "master", "develop", "development", "dev", "trunk"].includes(ref)) {
errors.push(`${prefix}: "source.ref" must be a tag or commit SHA, not a branch name`);
}
if (ref.startsWith("refs/") && !ref.startsWith("refs/tags/")) {
errors.push(`${prefix}: "source.ref" must be a tag ref or commit SHA`);
}
}
function validateGitHubSource(source, prefix, errors, requireImmutableRef) {
if (!source || typeof source !== "object" || Array.isArray(source)) {
errors.push(`${prefix}: "source" must be an object`);
return;
}
if (source.source !== "github") {
errors.push(`${prefix}: "source.source" must be "github"`);
}
if (!isNonEmptyString(source.repo)) {
errors.push(`${prefix}: "source.repo" is required and must be a non-empty string`);
} else if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(source.repo)) {
errors.push(`${prefix}: "source.repo" must be in "owner/repo" format`);
}
if (source.path !== undefined) {
validateRelativePath(source.path, prefix, errors);
}
if (source.ref !== undefined) {
validateImmutableRef(source.ref, prefix, errors);
} else if (requireImmutableRef) {
errors.push(`${prefix}: "source.ref" is required for public external plugin submissions`);
}
}
export function validateExternalPlugin(plugin, index, options = {}) {
const policy = resolvePolicy(options.policy ?? options);
const errors = [];
const warnings = [];
const prefix = `external.json[${index}]`;
if (!plugin || typeof plugin !== "object" || Array.isArray(plugin)) {
return {
errors: [`${prefix}: entry must be an object`],
warnings,
};
}
validatePluginName(plugin.name, prefix, errors);
validateDescription(plugin.description, prefix, errors);
validateVersion(plugin.version, prefix, errors);
validateAuthor(plugin.author, prefix, errors, policy.requireAuthor);
validateRepository(plugin.repository, prefix, errors, policy.requireRepository);
validateHomepage(plugin.homepage, prefix, errors);
validateLicense(plugin.license, prefix, errors, policy.requireLicense);
validateKeywords(plugin.keywords ?? plugin.tags, prefix, errors, warnings, policy.requireKeywords);
if (plugin.tags !== undefined && plugin.keywords === undefined) {
warnings.push(`${prefix}: prefer "keywords" over legacy "tags"`);
}
if (!plugin.source) {
errors.push(`${prefix}: "source" is required`);
} else if (typeof plugin.source === "string") {
errors.push(`${prefix}: "source" must be an object (local file paths are not allowed for external plugins)`);
} else if (!policy.allowedSourceTypes.includes(plugin.source.source)) {
errors.push(`${prefix}: "source.source" must be one of: ${policy.allowedSourceTypes.join(", ")}`);
} else if (plugin.source.source === "github") {
validateGitHubSource(plugin.source, prefix, errors, policy.requireImmutableRef);
}
return { errors, warnings };
}
export function validateExternalPlugins(plugins, options = {}) {
const policy = resolvePolicy(options.policy ?? options);
const errors = [];
const warnings = [];
const localNames = new Map(
(options.localPluginNames ?? []).map((name) => [String(name).toLowerCase(), String(name)])
);
const seenExternalNames = new Map();
if (!Array.isArray(plugins)) {
return {
errors: ["external.json must contain an array"],
warnings,
};
}
plugins.forEach((plugin, index) => {
const result = validateExternalPlugin(plugin, index, { policy });
errors.push(...result.errors);
warnings.push(...result.warnings);
if (!isNonEmptyString(plugin?.name)) {
return;
}
const normalizedName = plugin.name.toLowerCase();
const duplicateIndex = seenExternalNames.get(normalizedName);
if (duplicateIndex !== undefined) {
errors.push(`external.json[${index}]: duplicate plugin name "${plugin.name}" already used by external.json[${duplicateIndex}]`);
} else {
seenExternalNames.set(normalizedName, index);
}
const localDuplicate = localNames.get(normalizedName);
if (localDuplicate) {
errors.push(`external.json[${index}]: plugin name "${plugin.name}" conflicts with local plugin "${localDuplicate}"`);
}
});
return { errors, warnings };
}
export function readExternalPlugins(options = {}) {
const filePath = options.filePath ?? EXTERNAL_PLUGINS_FILE;
if (!fs.existsSync(filePath)) {
return {
plugins: [],
errors: [],
warnings: [],
};
}
let plugins;
try {
const content = fs.readFileSync(filePath, "utf8");
plugins = JSON.parse(content);
} catch (error) {
return {
plugins: [],
errors: [`Error reading ${path.basename(filePath)}: ${error.message}`],
warnings: [],
};
}
const { errors, warnings } = validateExternalPlugins(plugins, options);
return { plugins, errors, warnings };
}
+12 -81
View File
@@ -3,84 +3,11 @@
import fs from "fs";
import path from "path";
import { ROOT_FOLDER } from "./constants.mjs";
import { readExternalPlugins } from "./external-plugin-validation.mjs";
const PLUGINS_DIR = path.join(ROOT_FOLDER, "plugins");
const EXTERNAL_PLUGINS_FILE = path.join(ROOT_FOLDER, "plugins", "external.json");
const MARKETPLACE_FILE = path.join(ROOT_FOLDER, ".github/plugin", "marketplace.json");
/**
* Validate an external plugin entry has required fields and a non-local source
* @param {object} plugin - External plugin entry
* @param {number} index - Index in the array (for error messages)
* @returns {string[]} - Array of validation error messages
*/
function validateExternalPlugin(plugin, index) {
const errors = [];
const prefix = `external.json[${index}]`;
if (!plugin.name || typeof plugin.name !== "string") {
errors.push(`${prefix}: "name" is required and must be a string`);
}
if (!plugin.description || typeof plugin.description !== "string") {
errors.push(`${prefix}: "description" is required and must be a string`);
}
if (!plugin.version || typeof plugin.version !== "string") {
errors.push(`${prefix}: "version" is required and must be a string`);
}
if (!plugin.source) {
errors.push(`${prefix}: "source" is required`);
} else if (typeof plugin.source === "string") {
errors.push(`${prefix}: "source" must be an object (local file paths are not allowed for external plugins)`);
} else if (typeof plugin.source === "object") {
if (!plugin.source.source) {
errors.push(`${prefix}: "source.source" is required (e.g. "github", "url", "npm", "pip")`);
}
} else {
errors.push(`${prefix}: "source" must be an object`);
}
return errors;
}
/**
* Read external plugin entries from external.json
* @returns {Array} - Array of external plugin entries (merged as-is)
*/
function readExternalPlugins() {
if (!fs.existsSync(EXTERNAL_PLUGINS_FILE)) {
return [];
}
try {
const content = fs.readFileSync(EXTERNAL_PLUGINS_FILE, "utf8");
const plugins = JSON.parse(content);
if (!Array.isArray(plugins)) {
console.warn("Warning: external.json must contain an array");
return [];
}
// Validate each entry
let hasErrors = false;
for (let i = 0; i < plugins.length; i++) {
const errors = validateExternalPlugin(plugins[i], i);
if (errors.length > 0) {
errors.forEach(e => console.error(`Error: ${e}`));
hasErrors = true;
}
}
if (hasErrors) {
console.error("Error: external.json contains invalid entries");
process.exit(1);
}
return plugins;
} catch (error) {
console.error(`Error reading external.json: ${error.message}`);
return [];
}
}
/**
* Read plugin metadata from plugin.json file
* @param {string} pluginDir - Path to plugin directory
@@ -142,16 +69,20 @@ function generateMarketplace() {
}
// Read external plugins and merge as-is
const externalPlugins = readExternalPlugins();
const { plugins: externalPlugins, errors: externalErrors, warnings: externalWarnings } = readExternalPlugins({
localPluginNames: plugins.map((plugin) => plugin.name),
policy: "marketplace",
});
externalWarnings.forEach((warning) => console.warn(`Warning: ${warning}`));
if (externalErrors.length > 0) {
externalErrors.forEach((error) => console.error(`Error: ${error}`));
console.error("Error: external.json contains invalid entries");
process.exit(1);
}
if (externalPlugins.length > 0) {
console.log(`\nFound ${externalPlugins.length} external plugins`);
// Warn on duplicate names
const localNames = new Set(plugins.map(p => p.name));
for (const ext of externalPlugins) {
if (localNames.has(ext.name)) {
console.warn(`Warning: external plugin "${ext.name}" has the same name as a local plugin`);
}
plugins.push(ext);
console.log(`✓ Added external plugin: ${ext.name}`);
}
+18 -1
View File
@@ -3,6 +3,7 @@
import fs from "fs";
import path from "path";
import { ROOT_FOLDER } from "./constants.mjs";
import { readExternalPlugins } from "./external-plugin-validation.mjs";
const PLUGINS_DIR = path.join(ROOT_FOLDER, "plugins");
@@ -222,8 +223,24 @@ function validatePlugins() {
}
}
console.log("\nValidating external plugin catalog...");
const { plugins: externalPlugins, errors: externalErrors, warnings: externalWarnings } = readExternalPlugins({
localPluginNames: pluginDirs,
policy: "marketplace",
});
externalWarnings.forEach((warning) => console.warn(`⚠️ ${warning}`));
if (externalErrors.length > 0) {
console.error("❌ external.json:");
externalErrors.forEach((error) => console.error(` - ${error}`));
hasErrors = true;
} else {
console.log(`✅ external.json is valid (${externalPlugins.length} external plugins)`);
}
if (!hasErrors) {
console.log(`\n✅ All ${pluginDirs.length} plugins are valid`);
console.log(`\n✅ All ${pluginDirs.length} plugins and the external catalog are valid`);
}
return !hasErrors;