Repository navigation
design: unpublish a plugin from the registry #10790
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: PR Maintenance | ||
|
Check warning on line 1 in .github/workflows/pr-triage.yml
|
||
| 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, | ||
| }); | ||
| } | ||