diff --git a/.github/scripts/classify-release-notes.mjs b/.github/scripts/classify-release-notes.mjs index ac85d32900..ef839e8875 100644 --- a/.github/scripts/classify-release-notes.mjs +++ b/.github/scripts/classify-release-notes.mjs @@ -1,6 +1,7 @@ #!/usr/bin/env node import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -55,28 +56,8 @@ export function shouldAutoSkipChangelog({ title, labels = [], files = [] }) { ); } -function main() { - const repo = process.env.GITHUB_REPOSITORY || ''; - const number = process.env.PR_NUMBER || ''; - if ( - !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) || - !/^[1-9]\d*$/.test(number) - ) { - throw new Error( - 'GITHUB_REPOSITORY and PR_NUMBER must identify a pull request.', - ); - } - - const metadata = JSON.parse( - execFileSync( - 'gh', - ['pr', 'view', number, '--repo', repo, '--json', 'title,labels'], - { - encoding: 'utf8', - }, - ), - ); - const files = execFileSync( +function fetchFiles(repo, number) { + return execFileSync( 'gh', [ 'api', @@ -89,9 +70,67 @@ function main() { ) .split(/\r?\n/) .filter(Boolean); - process.stdout.write( - `${shouldAutoSkipChangelog({ ...metadata, files }) ? 'skip' : 'include'}\n`, - ); +} + +function main() { + const repo = process.env.GITHUB_REPOSITORY || ''; + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) { + throw new Error('GITHUB_REPOSITORY must be set to owner/repo.'); + } + + const input = JSON.parse(readFileSync(0, 'utf8')); + const prs = Array.isArray(input) ? input : []; + const labeled = []; + const unlabeled = []; + + for (const pr of prs) { + const number = String(pr.number); + if (!/^[1-9]\d*$/.test(number)) continue; + try { + const files = fetchFiles(repo, number); + const shouldSkip = shouldAutoSkipChangelog({ ...pr, files }); + const hasAutoLabel = pr.labels.some( + (label) => + (typeof label === 'string' ? label : label.name).toLowerCase() === + AUTO_LABEL, + ); + if (shouldSkip && !hasAutoLabel) { + execFileSync('gh', [ + 'pr', + 'edit', + number, + '--repo', + repo, + '--add-label', + AUTO_LABEL, + ]); + labeled.push(number); + } else if (!shouldSkip && hasAutoLabel) { + execFileSync('gh', [ + 'pr', + 'edit', + number, + '--repo', + repo, + '--remove-label', + AUTO_LABEL, + ]); + unlabeled.push(number); + } + } catch (error) { + process.exitCode = 1; + process.stderr.write( + `::warning::Failed to process PR #${number}: ${error.message}; skipping.\n`, + ); + } + } + + if (labeled.length > 0) { + process.stdout.write(`Labeled: ${labeled.join(', ')}\n`); + } + if (unlabeled.length > 0) { + process.stdout.write(`Unlabeled: ${unlabeled.join(', ')}\n`); + } } if ( diff --git a/.github/scripts/classify-release-notes.test.mjs b/.github/scripts/classify-release-notes.test.mjs index b25fb00564..f53258824c 100644 --- a/.github/scripts/classify-release-notes.test.mjs +++ b/.github/scripts/classify-release-notes.test.mjs @@ -1,8 +1,7 @@ import assert from 'node:assert/strict'; -import { execFileSync } from 'node:child_process'; +import { spawnSync } from 'node:child_process'; import { chmodSync, - existsSync, mkdtempSync, readFileSync, rmSync, @@ -11,6 +10,7 @@ import { import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, it } from 'node:test'; +import { parse } from 'yaml'; import { shouldAutoSkipChangelog } from './classify-release-notes.mjs'; describe('release note classification', () => { @@ -194,137 +194,92 @@ describe('release note classification', () => { } }); - it('rejects invalid pull request environment before invoking gh', () => { - const dir = mkdtempSync(join(tmpdir(), 'release-note-classifier-')); - try { - const marker = join(dir, 'gh-invoked'); - const gh = join(dir, 'gh'); - writeFileSync( - gh, - [ - '#!/usr/bin/env node', - `require('node:fs').writeFileSync(${JSON.stringify(marker)}, '1');`, - 'process.exit(1);', - ].join('\n'), - ); - chmodSync(gh, 0o755); - - for (const env of [ - { GITHUB_REPOSITORY: 'QwenLM/qwen-code/extra', PR_NUMBER: '1' }, - { GITHUB_REPOSITORY: 'QwenLM/qwen-code', PR_NUMBER: 'abc' }, - ]) { - assert.throws(() => - execFileSync( - process.execPath, - [join(import.meta.dirname, 'classify-release-notes.mjs')], - { - env: { - ...process.env, - ...env, - PATH: `${dir}:${process.env.PATH}`, - }, - }, - ), - ); - assert.equal(existsSync(marker), false); - } - } finally { - rmSync(dir, { recursive: true, force: true }); - } - }); - - it('keeps renamed production files by checking their previous paths', () => { - const dir = mkdtempSync(join(tmpdir(), 'release-note-classifier-')); - try { - const gh = join(dir, 'gh'); - writeFileSync( - gh, - [ - '#!/usr/bin/env node', - 'const args = process.argv.slice(2);', - "if (args[0] === 'pr') { process.stdout.write(JSON.stringify({ title: 'ci: move runtime', labels: [] })); process.exit(0); }", - "if (args.includes('.[] | .filename, (.previous_filename // empty)')) { process.stdout.write('.github/scripts/runtime.ts\\npackages/core/src/runtime.ts\\n'); process.exit(0); }", - 'process.exit(1);', - ].join('\n'), - ); - chmodSync(gh, 0o755); - - const decision = execFileSync( - process.execPath, - [join(import.meta.dirname, 'classify-release-notes.mjs')], - { - encoding: 'utf8', - env: { - ...process.env, - GITHUB_REPOSITORY: 'QwenLM/qwen-code', - PATH: `${dir}:${process.env.PATH}`, - PR_NUMBER: '1', - }, - }, - ); - - assert.equal(decision, 'include\n'); - } finally { - rmSync(dir, { recursive: true, force: true }); - } - }); - - it('prints skip for internal CI changes through main', () => { - const dir = mkdtempSync(join(tmpdir(), 'release-note-classifier-')); - try { - const gh = join(dir, 'gh'); - writeFileSync( - gh, - [ - '#!/usr/bin/env node', - 'const args = process.argv.slice(2);', - "if (args[0] === 'pr') { process.stdout.write(JSON.stringify({ title: 'ci: speed up checks', labels: [] })); process.exit(0); }", - "if (args.includes('.[] | .filename, (.previous_filename // empty)')) { process.stdout.write('.github/workflows/ci.yml\\n'); process.exit(0); }", - 'process.exit(1);', - ].join('\n'), - ); - chmodSync(gh, 0o755); - - const decision = execFileSync( - process.execPath, - [join(import.meta.dirname, 'classify-release-notes.mjs')], - { - encoding: 'utf8', - env: { - ...process.env, - GITHUB_REPOSITORY: 'QwenLM/qwen-code', - PATH: `${dir}:${process.env.PATH}`, - PR_NUMBER: '1', - }, - }, - ); - - assert.equal(decision, 'skip\n'); - } finally { - rmSync(dir, { recursive: true, force: true }); - } - }); - - it('wires reclassification and exclusion to the same automatic label', () => { - const workflow = readFileSync( - join(import.meta.dirname, '../workflows/classify-release-notes.yml'), + it('wires batch labeling and exclusion through release.yml', () => { + const release = readFileSync( + join(import.meta.dirname, '../workflows/release.yml'), 'utf8', ); - const release = readFileSync( + const changelog = readFileSync( join(import.meta.dirname, '../release.yml'), 'utf8', ); - - for (const action of ['synchronize', 'edited', 'labeled', 'unlabeled']) { - assert.match(workflow, new RegExp(`- '${action}'`)); - } - assert.match(workflow, /AUTO_LABEL: 'skip-changelog-auto'/); - assert.match( - workflow, - /github\.event\.label\.name != 'skip-changelog-auto'/, + const workflow = parse(release); + const publish = workflow.jobs.publish; + const autoLabel = publish.steps.find( + (step) => + step.name === 'Auto-label internal CI PRs for release notes exclusion', ); - assert.match(workflow, /classification failed; including this PR/); - assert.doesNotMatch(workflow, /decision=unchanged/); - assert.match(release, /- 'skip-changelog-auto'/); + + assert.match(changelog, /- 'skip-changelog-auto'/); + assert.equal(autoLabel['continue-on-error'], true); + assert.equal(autoLabel.env.GITHUB_TOKEN, '${{ github.token }}'); + assert.equal(publish.permissions.issues, 'write'); + assert.equal(publish.permissions['pull-requests'], 'write'); + assert.match(autoLabel.run, /classify-release-notes\.mjs/); + assert.match(autoLabel.run, /commits="\$\(git rev-list/); + assert.match(autoLabel.run, /Cannot enumerate commits/); + assert.match(autoLabel.run, /Failed to fetch PRs for commit/); + }); + + it('updates labels after a lookup failure and exits non-zero', () => { + const dir = mkdtempSync(join(tmpdir(), 'release-note-classifier-')); + try { + const updates = join(dir, 'updates.txt'); + const gh = join(dir, 'gh'); + writeFileSync( + gh, + [ + '#!/usr/bin/env node', + 'const args = process.argv.slice(2);', + "if (args[0] === 'api' && args.includes('.[] | .filename, (.previous_filename // empty)')) {", + " if (args.some((arg) => arg.endsWith('/pulls/11/files'))) { process.stderr.write('lookup failed\\n'); process.exit(1); }", + " process.stdout.write('.github/workflows/ci.yml\\n');", + ' process.exit(0);', + '}', + "if (args[0] === 'pr' && args[1] === 'edit') {", + ` const action = args.includes('--remove-label') ? 'remove' : 'add';`, + ` require('node:fs').appendFileSync(${JSON.stringify(updates)}, args[2] + ' ' + action + '\\n');`, + ' process.exit(0);', + '}', + 'process.exit(1);', + ].join('\n'), + ); + chmodSync(gh, 0o755); + + const input = JSON.stringify([ + { number: 10, title: 'ci: speed up checks', labels: [] }, + { number: 11, title: 'ci: broken lookup', labels: [] }, + { + number: 12, + title: 'fix: user-visible bug', + labels: [{ name: 'skip-changelog-auto' }], + }, + { number: 13, title: 'feat: new feature', labels: [] }, + ]); + + const result = spawnSync( + process.execPath, + [join(import.meta.dirname, 'classify-release-notes.mjs')], + { + encoding: 'utf8', + input, + env: { + ...process.env, + GITHUB_REPOSITORY: 'QwenLM/qwen-code', + PATH: `${dir}:${process.env.PATH}`, + }, + }, + ); + + assert.equal(result.status, 1); + assert.match(result.stdout, /Labeled: 10/); + assert.match(result.stdout, /Unlabeled: 12/); + assert.match(result.stderr, /Failed to process PR #11/); + assert.match(result.stderr, /lookup failed/); + const updateContent = readFileSync(updates, 'utf8').trim(); + assert.equal(updateContent, '10 add\n12 remove'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } }); }); diff --git a/.github/workflows/classify-release-notes.yml b/.github/workflows/classify-release-notes.yml deleted file mode 100644 index 1be7963a77..0000000000 --- a/.github/workflows/classify-release-notes.yml +++ /dev/null @@ -1,66 +0,0 @@ -name: 'Classify Release Notes' - -on: - pull_request_target: - branches: - - 'main' - - 'release/**' - types: - - 'opened' - - 'reopened' - - 'synchronize' - - 'edited' - - 'labeled' - - 'unlabeled' - - 'ready_for_review' - -permissions: - contents: 'read' - issues: 'write' - pull-requests: 'write' - -concurrency: - group: '${{ github.workflow }}-${{ github.event.pull_request.number }}' - cancel-in-progress: true - -jobs: - classify: - if: "${{ github.repository == 'QwenLM/qwen-code' && github.event.label.name != 'skip-changelog-auto' }}" - runs-on: 'ubuntu-latest' - timeout-minutes: 5 - steps: - # pull_request_target checks out the trusted base branch; PR code is never - # executed with this workflow's label-write permission. - - name: 'Checkout trusted classifier' - uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 - - - name: 'Classify pull request' - id: 'classify' - env: - GH_TOKEN: '${{ github.token }}' - PR_NUMBER: '${{ github.event.pull_request.number }}' - run: |- - if decision="$(node .github/scripts/classify-release-notes.mjs)"; then - echo "decision=${decision}" >> "${GITHUB_OUTPUT}" - else - echo "::warning::Release-note classification failed; including this PR." - echo "decision=include" >> "${GITHUB_OUTPUT}" - fi - - - name: 'Update automatic changelog label' - env: - AUTO_LABEL: 'skip-changelog-auto' - DECISION: '${{ steps.classify.outputs.decision }}' - GH_TOKEN: '${{ github.token }}' - HAS_AUTO_LABEL: "${{ contains(github.event.pull_request.labels.*.name, 'skip-changelog-auto') }}" - PR_NUMBER: '${{ github.event.pull_request.number }}' - run: |- - set -euo pipefail - if [[ "${DECISION}" == "skip" ]]; then - gh label create "${AUTO_LABEL}" --repo "${GITHUB_REPOSITORY}" --color 'ededed' --description 'Automatically exclude internal CI changes from release notes' --force - if [[ "${HAS_AUTO_LABEL}" != "true" ]]; then - gh pr edit "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --add-label "${AUTO_LABEL}" - fi - elif [[ "${DECISION}" == "include" && "${HAS_AUTO_LABEL}" == "true" ]]; then - gh pr edit "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --remove-label "${AUTO_LABEL}" - fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 48d60793e6..e303ea9a76 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -340,8 +340,10 @@ jobs: url: '${{ github.server_url }}/${{ github.repository }}/releases/tag/${{ needs.prepare.outputs.release_tag }}' permissions: contents: 'write' + issues: 'write' packages: 'write' id-token: 'write' + pull-requests: 'write' steps: - name: 'Checkout' @@ -458,6 +460,27 @@ jobs: run: |- npm run verify:installation-release -- --dir dist/standalone + - name: 'Auto-label internal CI PRs for release notes exclusion' + continue-on-error: true + if: |- + ${{ needs.prepare.outputs.is_dry_run == 'false' }} + env: + GITHUB_TOKEN: '${{ github.token }}' + PREVIOUS_RELEASE_TAG: '${{ needs.prepare.outputs.previous_release_tag }}' + run: |- + gh label create 'skip-changelog-auto' --repo "${GITHUB_REPOSITORY}" --color 'ededed' --description 'Automatically exclude internal CI changes from release notes' --force + commits="$(git rev-list "${PREVIOUS_RELEASE_TAG}..HEAD")" || { + echo "::error::Cannot enumerate commits since ${PREVIOUS_RELEASE_TAG}; skipping auto-labeling." + exit 1 + } + while read -r commit; do + [[ -z "${commit}" ]] && continue + gh api "repos/${GITHUB_REPOSITORY}/commits/${commit}/pulls" \ + --jq '.[] | select(.merged_at != null) | {number, title, labels}' \ + || echo "::warning::Failed to fetch PRs for commit ${commit}; skipping." >&2 + done <<< "${commits}" | jq -s 'unique_by(.number)' | \ + node .github/scripts/classify-release-notes.mjs + - name: 'Create GitHub Release and Tag' if: |- ${{ needs.prepare.outputs.is_dry_run == 'false' }}