diff --git a/.github/workflows/pull-labeler.yml b/.github/workflows/pull-labeler.yml index b27fc32cdb..ff76285d97 100644 --- a/.github/workflows/pull-labeler.yml +++ b/.github/workflows/pull-labeler.yml @@ -1,8 +1,10 @@ name: labeler on: - pull_request_target: - types: [opened, synchronize, reopened] + # pull_request_target is required to label PRs from forks; jobs only use pinned + # actions or base-branch checkout, never PR-head code. + pull_request_target: # zizmor: ignore[dangerous-triggers] + types: [opened, synchronize, reopened, edited, ready_for_review] concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} @@ -18,3 +20,28 @@ jobs: - uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0 with: sync-labels: true + + pr-title: + if: github.event.pull_request.draft == false + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + issues: write + steps: + # Base-branch checkout only: pull_request_target runs with elevated token; never run PR-head code here. + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.pull_request.base.sha }} + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 24 + # Labels are only synced after the title lints, so an invalid title never reaches the label diff. + - run: node ./tools/ci-tools.ts lint-pr-title + env: + PR_TITLE: ${{ github.event.pull_request.title }} + - run: node ./tools/ci-tools.ts set-pr-labels + env: + PR_TITLE: ${{ github.event.pull_request.title }} + PR_NUMBER: ${{ github.event.pull_request.number }} + GITHUB_TOKEN: ${{ github.token }} diff --git a/.github/workflows/pull-pr-title.yml b/.github/workflows/pull-pr-title.yml deleted file mode 100644 index 6dadcb7058..0000000000 --- a/.github/workflows/pull-pr-title.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: pr-title - -on: - pull_request: - types: - - opened - - edited - - reopened - - synchronize - - ready_for_review - -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - lint-pr-title: - if: github.event.pull_request.draft == false - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version: 24 - - run: make lint-pr-title - env: - PR_TITLE: ${{ github.event.pull_request.title }} diff --git a/Makefile b/Makefile index 52a266fa39..649465b2b4 100644 --- a/Makefile +++ b/Makefile @@ -321,10 +321,6 @@ lint-md: node_modules ## lint markdown files lint-md-fix: node_modules ## lint markdown files and fix issues pnpm exec markdownlint --fix *.md -.PHONY: lint-pr-title -lint-pr-title: ## lint PR title against Conventional Commits (set PR_TITLE=...) - @node ./tools/lint-pr-title.ts - .PHONY: lint-spell lint-spell: ## lint spelling @git ls-files $(SPELLCHECK_FILES) | xargs go run $(MISSPELL_PACKAGE) -dict assets/misspellings.csv -error diff --git a/tools/ci-tools.ts b/tools/ci-tools.ts new file mode 100644 index 0000000000..4562643247 --- /dev/null +++ b/tools/ci-tools.ts @@ -0,0 +1,133 @@ +#!/usr/bin/env node +import {argv, env, exit} from 'node:process'; + +const allowedTypes = [ + 'build', + 'chore', + 'ci', + 'docs', + 'enhance', + 'feat', + 'fix', + 'perf', + 'refactor', + 'revert', + 'style', + 'test', +] as const; +type CommitType = typeof allowedTypes[number]; + +const allowedTypesList = allowedTypes.join(', '); +const titlePattern = new RegExp(`^(${allowedTypes.join('|')})(\\([\\w/.-]+\\))?(!)?: .+$`); + +function parsePrTitle(title: string): {type: CommitType; breaking: boolean} | null { + const match = titlePattern.exec(title); + return match ? {type: match[1] as CommitType, breaking: Boolean(match[3])} : null; +} + +const breakingLabel = 'pr/breaking'; + +// Mutually exclusive type labels, fully synced with the title type (added and removed). +const typeLabels: Partial> = { + feat: 'type/feature', + enhance: 'type/enhancement', + fix: 'type/bug', + docs: 'type/docs', + test: 'type/testing', +}; + +// Non-type labels, only added, never auto-removed, so manual labeling is not clobbered. +const extraLabels: Partial> = { + chore: 'skip-changelog', + ci: 'skip-changelog', + build: 'topic/build', +}; + +// Labels this tool may remove when the title no longer implies them. +const removableLabels = [...Object.values(typeLabels), breakingLabel]; + +function labelsForPrTitle(title: string): string[] { + const parsed = parsePrTitle(title); + if (!parsed) return []; + return [typeLabels[parsed.type], extraLabels[parsed.type], parsed.breaking ? breakingLabel : undefined] + .filter((label): label is string => label !== undefined); +} + +// Command: validate PR_TITLE against the allowed Conventional Commits format. +function lintPrTitle(): void { + if (!env.PR_TITLE) { + console.error('Missing PR_TITLE'); + exit(1); + } + if (!parsePrTitle(env.PR_TITLE)) { + console.error(`Invalid PR title: ${env.PR_TITLE}`); + console.error('Expected format: type(scope): subject (scope optional, append "!" for breaking changes)'); + console.error(`Allowed types: ${allowedTypesList}`); + exit(1); + } +} + +// Command: sync the title-derived labels onto the PR via the GitHub API. +async function setPrLabels(): Promise { + if (!env.PR_TITLE || !env.GITHUB_TOKEN || !env.GITHUB_REPOSITORY || !env.PR_NUMBER) { + console.error('set-pr-labels requires PR_TITLE, GITHUB_TOKEN, GITHUB_REPOSITORY and PR_NUMBER'); + exit(1); + } + + const labelsUrl = `https://api.github.com/repos/${env.GITHUB_REPOSITORY}/issues/${env.PR_NUMBER}/labels`; + + async function request(url: string, method = 'GET', body?: unknown): Promise { + const response = await fetch(url, { + method, + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${env.GITHUB_TOKEN}`, + 'X-GitHub-Api-Version': '2022-11-28', + ...(body ? {'Content-Type': 'application/json'} : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + if (!response.ok) { + throw new Error(`GitHub API ${method} ${url} failed (${response.status}): ${await response.text()}`); + } + return response; + } + + const desired = labelsForPrTitle(env.PR_TITLE); + const response = await request(`${labelsUrl}?per_page=100`); + const current = ((await response.json()) as Array<{name: string}>).map((label) => label.name); + + const toAdd = desired.filter((name) => !current.includes(name)); + const toRemove = removableLabels.filter((name) => current.includes(name) && !desired.includes(name)); + + if (toAdd.length) { + await request(labelsUrl, 'POST', {labels: toAdd}); + console.info(`Added labels: ${toAdd.join(', ')}`); + } + for (const name of toRemove) { + await request(`${labelsUrl}/${encodeURIComponent(name)}`, 'DELETE'); + console.info(`Removed label: ${name}`); + } + if (!toAdd.length && !toRemove.length) { + console.info('PR labels already in sync'); + } +} + +const commands: Record void | Promise> = { + 'lint-pr-title': lintPrTitle, + 'set-pr-labels': setPrLabels, +}; + +const command = argv[2]; +const handler = commands[command]; +if (!handler) { + console.error(`Usage: ci-tools.ts <${Object.keys(commands).join('|')}>`); + exit(1); +} + +try { + await handler(); +} catch (error) { + console.error(error instanceof Error ? error.message : error); + exit(1); +} diff --git a/tools/lint-pr-title.ts b/tools/lint-pr-title.ts deleted file mode 100644 index a63defe972..0000000000 --- a/tools/lint-pr-title.ts +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env node -import {env, exit} from 'node:process'; - -const allowedTypes = 'build, chore, ci, docs, enhance, feat, fix, perf, refactor, revert, style, test'; -const title = env.PR_TITLE; - -if (!title) { - console.error('Missing PR_TITLE'); - exit(1); -} - -const validTitlePattern = new RegExp(`^(${allowedTypes.replaceAll(', ', '|')})(\\([\\w.-]+\\))?(!)?: .+$`); - -if (!validTitlePattern.test(title)) { - console.error(`Invalid PR title: ${title}`); - console.error('Expected format: type(scope): subject'); - console.error(`Allowed types: ${allowedTypes}`); - exit(1); -}