Skip to content

design: unpublish a plugin from the registry #10790

design: unpublish a plugin from the registry

design: unpublish a plugin from the registry #10790

Workflow file for this run

name: PR Maintenance

Check warning on line 1 in .github/workflows/pr-triage.yml

View workflow run for this annotation

GitHub Actions / PR Maintenance

Workflow execution policy warning (evaluate mode)

On November 2, 2026, GitHub will restrict `pull_request_target` on public repositories by default. To continue allowing the event trigger, configure an Actions policy. Learn more: https://gh.io/securely-using-pull_request_target#default-policy-for-pull_request_target
on:
pull_request_target:
types: [opened, edited, synchronize, reopened]
permissions:
contents: read
pull-requests: write
jobs:
label:
name: Maintain PR
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
sparse-checkout: .github/scripts
persist-credentials: false
- name: Check PR template
if: >-
github.event.pull_request.user.login != 'dependabot[bot]' &&
github.event.pull_request.user.login != 'renovate[bot]' &&
github.event.pull_request.user.login != 'emdashbot[bot]'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const body = context.payload.pull_request.body || '';
const pr = context.payload.pull_request;
const errors = [];
const isDesign = body.includes('<!-- design-pr -->') || /^design:/i.test(pr.title);
const hasImplementationTemplate = body.includes('## What does this PR do?') && body.includes('## Type of change');
const hasDesignTemplate = body.includes('<!-- design-pr -->') && body.includes('## Design proposal');
if (isDesign && !hasDesignTemplate) {
errors.push('Design PRs must use the [design PR template](https://github.com/emdash-cms/emdash/blob/main/.github/PULL_REQUEST_TEMPLATE/design.md).');
} else if (!isDesign && !hasImplementationTemplate) {
errors.push('This PR does not use the required [implementation PR template](https://github.com/emdash-cms/emdash/blob/main/.github/PULL_REQUEST_TEMPLATE.md). Copy it into the PR description and fill out all sections.');
} else if (isDesign) {
if (!/^design:/i.test(pr.title)) {
errors.push('Prefix a design PR title with `design:`.');
}
const files = await github.paginate(github.rest.pulls.listFiles, {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
per_page: 100,
});
const proposalLink = body.match(/Proposal:\s*\[[^\]]*\]\(https:\/\/github\.com\/[^/\s)]+\/[^/\s)]+\/blob\/[^\s)]+?\/(proposals\/[a-z0-9][a-z0-9-]*\.md)\)/i);
const proposalPaths = files
.filter(file => file.status !== 'removed')
.map(file => file.filename);
if (!proposalLink) {
errors.push('In the "Design proposal" section, link the `proposals/*.md` file on your branch so reviewers can open the rendered proposal, for example `Proposal: [proposals/my-feature.md](https://github.com/you/emdash/blob/my-branch/proposals/my-feature.md)`.');
} else if (!proposalPaths.includes(proposalLink[1])) {
errors.push(`The proposal link points to \`${proposalLink[1]}\`, which this PR does not add or change.`);
}
const implementationFiles = files.filter(file => !file.filename.startsWith('proposals/'));
if (implementationFiles.length > 0) {
errors.push(`Design PRs contain proposal files only. Move implementation changes out of this PR: ${implementationFiles.map(file => `\`${file.filename}\``).join(', ')}`);
}
} else {
const descriptionSection = body.match(/## What does this PR do\?\s*\n([\s\S]*?)(?=\n## )/);
const description = descriptionSection?.[1]?.replace(/<!--[\s\S]*?-->/g, '').trim() || '';
if (!description || description === 'Closes #') {
errors.push('Fill out the "What does this PR do?" section with a description of your change.');
}
const typeChecks = [
/- \[x\] Bug fix/i,
/- \[x\] Feature/i,
/- \[x\] Refactor/i,
/- \[x\] Translation/i,
/- \[x\] Documentation/i,
/- \[x\] Performance/i,
/- \[x\] Tests/i,
/- \[x\] Chore/i,
];
if (!typeChecks.some(re => re.test(body))) {
errors.push('Check at least one "Type of change" checkbox.');
}
const isFeature = /- \[x\] Feature/i.test(body) || /^feat(\([^)]*\))?!?:/i.test(pr.title);
// PRs opened before the process was proposed, or from the template used
// before design PRs, are not checked for a design PR link.
const designProcessApplies =
Date.parse(pr.created_at) >= Date.parse('2026-10-03T00:00:00Z') &&
!/New features link to an approved Discussion/i.test(body);
if (isFeature && designProcessApplies) {
const designMatch = body.match(/New features link to their merged design PR:\s*https:\/\/github\.com\/emdash-cms\/emdash\/pull\/(\d+)/i);
if (!designMatch) {
errors.push('Feature PRs require a link to their merged design PR. Follow the feature-plan or RFC process in `proposals/README.md` first.');
} else {
const designNumber = Number(designMatch[1]);
if (designNumber === pr.number) {
errors.push('The feature link must point to an earlier merged design PR, not this implementation PR.');
} else {
try {
const { data: designPr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: designNumber,
});
if (!designPr.merged_at) {
errors.push(`Design PR #${designNumber} has not been merged.`);
} else {
const designFiles = await github.paginate(github.rest.pulls.listFiles, {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: designNumber,
per_page: 100,
});
if (
designFiles.length === 0 ||
designFiles.some(file => !file.filename.startsWith('proposals/'))
) {
errors.push(`PR #${designNumber} is merged but is not a design-only PR under \`proposals/\`.`);
}
}
} catch (error) {
if (error.status === 404) {
errors.push(`Design PR #${designNumber} does not exist in emdash-cms/emdash.`);
} else {
throw error;
}
}
}
}
}
if (!/- \[x\][^\n]*\bI have read\b[^\n]*CONTRIBUTING/i.test(body)) {
errors.push('Check the "I have read CONTRIBUTING.md" checkbox.');
}
}
const marker = '<!-- pr-compliance-check -->';
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
});
const existing = comments.find(c =>
c.user?.login === 'github-actions[bot]' && c.body?.includes(marker)
);
if (errors.length > 0) {
const message = [
'## PR template validation failed',
'',
'Please fix the following issues by editing your PR description:',
'',
...errors.map(error => `- ${error}`),
'',
'See [CONTRIBUTING.md](https://github.com/emdash-cms/emdash/blob/main/CONTRIBUTING.md) for the full contribution policy.',
].join('\n');
const commentBody = `${marker}\n${message}`;
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body: commentBody,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
body: commentBody,
});
}
core.setFailed(message);
} else if (existing) {
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
});
}
- name: Triage PR
if: always() && github.event.action != 'edited'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const pr = context.payload.pull_request;
const labels = new Set();
const body = pr.body || '';
const isDesign = body.includes('<!-- design-pr -->') || /^design:/i.test(pr.title);
// --- Bot detection ---
const botLogins = ['dependabot[bot]', 'renovate[bot]', 'emdashbot[bot]'];
if (botLogins.includes(pr.user.login)) {
labels.add('bot');
}
if (isDesign) {
labels.add('type/design');
}
// --- Size labels ---
const { data: files } = await github.rest.pulls.listFiles({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
per_page: 100,
});
const linesChanged = files.reduce((sum, f) => sum + f.additions + f.deletions, 0);
const sizeLabels = ['size/XS', 'size/S', 'size/M', 'size/L', 'size/XL'];
let sizeLabel;
if (linesChanged < 10) sizeLabel = 'size/XS';
else if (linesChanged < 50) sizeLabel = 'size/S';
else if (linesChanged < 200) sizeLabel = 'size/M';
else if (linesChanged < 500) sizeLabel = 'size/L';
else sizeLabel = 'size/XL';
labels.add(sizeLabel);
// --- Area labels ---
const areaMap = {
'area/core': (f) => f.startsWith('packages/core/'),
'area/admin': (f) => f.startsWith('packages/admin/'),
'area/plugins': (f) => f.startsWith('packages/plugins/'),
'area/docs': (f) => f.startsWith('docs/'),
'area/templates': (f) => f.startsWith('templates/'),
'area/ci': (f) => f.startsWith('.github/'),
'area/auth': (f) => f.startsWith('packages/auth/'),
'area/cloudflare': (f) => f.startsWith('packages/cloudflare/'),
};
for (const file of files) {
for (const [label, matcher] of Object.entries(areaMap)) {
if (matcher(file.filename)) {
labels.add(label);
}
}
}
// --- Merge conflict detection ---
// mergeable is available on the full PR object (may need a separate fetch
// since the webhook payload sometimes has mergeable=null while computing)
try {
const { data: fullPr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
});
if (fullPr.mergeable === false) {
labels.add('needs-rebase');
}
} catch {
// Ignore -- mergeable state may not be computed yet
}
// CLA labels are managed by the CLA workflow (cla.yml), not here.
// --- Ensure all labels exist, then apply ---
const labelColors = {
'bot': 'ededed',
'type/design': '5319e7',
'size/XS': '3cbf00',
'size/S': '5dba3f',
'size/M': 'fbca04',
'size/L': 'ee9b00',
'size/XL': 'd93f0b',
'area/core': '0052cc',
'area/admin': '7057ff',
'area/plugins': '008672',
'area/docs': '0075ca',
'area/templates': 'bfdadc',
'area/ci': '000000',
'area/auth': 'd4c5f9',
'area/cloudflare': 'f9a825',
'needs-rebase': 'e11d48',
};
// Get existing labels on the repo
const existingLabels = new Set();
for await (const response of github.paginate.iterator(
github.rest.issues.listLabelsForRepo,
{ owner: context.repo.owner, repo: context.repo.repo, per_page: 100 }
)) {
for (const label of response.data) {
existingLabels.add(label.name);
}
}
// Create any missing labels
for (const label of labels) {
if (!existingLabels.has(label) && labelColors[label]) {
await github.rest.issues.createLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: label,
color: labelColors[label],
});
}
}
// Get current labels on the PR to remove stale ones
const currentLabels = new Set(pr.labels.map(l => l.name));
// Helper: remove a label, ignoring 404 if it's already gone
async function safeRemoveLabel(name) {
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
name,
});
} catch (e) {
if (e.status !== 404) throw e;
}
}
// Remove stale size labels (only one size label at a time)
for (const sl of sizeLabels) {
if (sl !== sizeLabel && currentLabels.has(sl)) {
await safeRemoveLabel(sl);
}
}
// Remove needs-rebase if PR is now mergeable
if (!labels.has('needs-rebase') && currentLabels.has('needs-rebase')) {
await safeRemoveLabel('needs-rebase');
}
// Add new labels
const toAdd = [...labels].filter(l => !currentLabels.has(l));
if (toAdd.length > 0) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
labels: toAdd,
});
}
core.info(`Applied labels: ${[...labels].join(', ')}`);
// --- Translation scope ---
const { unexpectedTranslationFiles } = await import(
`${process.env.GITHUB_WORKSPACE}/.github/scripts/translation-scope.mjs`
);
const unexpected = unexpectedTranslationFiles(files.map(f => f.filename));
const scopeMarker = '<!-- translation-scope -->';
const comments = await github.paginate(github.rest.issues.listComments, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
per_page: 100,
});
const scopeComment = comments.find(c =>
c.user?.login === 'github-actions[bot]' && c.body?.includes(scopeMarker)
);
if (unexpected.length > 0) {
const body = [
scopeMarker,
'## Translation PR scope',
'',
'This PR changes translation catalogs (`messages.po`) and also changes:',
'',
...unexpected.map(path => `- \`${path}\``),
'',
'A translation PR should change only catalogs, a changeset, and `locales.ts` and `day-picker.ts` when adding a language. Move other changes to a separate PR.',
'',
"If this isn't a translation PR, remove the catalog changes: catalogs are extracted automatically after a PR merges to `main`.",
].join('\n');
if (scopeComment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: scopeComment.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
body,
});
}
} else if (scopeComment) {
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: scopeComment.id,
});
}