diff --git a/.github/comment-on-release/README.md b/.github/comment-on-release/README.md new file mode 100644 index 00000000..28427970 --- /dev/null +++ b/.github/comment-on-release/README.md @@ -0,0 +1,83 @@ +# Comment on Release Action + +A reusable GitHub Action that automatically comments on PRs when they are included in a release. + +## What It Does + +When packages are published via Changesets: + +1. Parses each published package's CHANGELOG to find PR numbers in the latest version +2. Groups PRs by number (handling cases where one PR affects multiple packages) +3. Posts a comment on each PR with release info and CHANGELOG links + +## Example Comment + +``` +🎉 This PR has been released! + +- [@tanstack/query-core@5.0.0](https://github.com/TanStack/query/blob/main/packages/query-core/CHANGELOG.md#500) +- [@tanstack/react-query@5.0.0](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md#500) + +Thank you for your contribution! +``` + +## Usage + +Add this step to your `.github/workflows/release.yml` file after the `changesets/action` step: + +```yaml +- name: Run Changesets (version or publish) + id: changesets + uses: changesets/action@v1.5.3 + with: + version: pnpm run changeset:version + publish: pnpm run changeset:publish + commit: 'ci: Version Packages' + title: 'ci: Version Packages' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + +- name: Comment on PRs about release + if: steps.changesets.outputs.published == 'true' + uses: tanstack/config/.github/comment-on-release@main + with: + published-packages: ${{ steps.changesets.outputs.publishedPackages }} +``` + +## Requirements + +- Must be using [Changesets](https://github.com/changesets/changesets) for releases +- CHANGELOGs must include PR links in the format: `[#123](https://github.com/org/repo/pull/123)` +- Requires `pull-requests: write` permission in the workflow +- The `gh` CLI must be available (automatically available in GitHub Actions) + +## Inputs + +| Input | Required | Description | +| -------------------- | -------- | ------------------------------------------------------------------ | +| `published-packages` | Yes | JSON string of published packages from `changesets/action` outputs | + +## How It Works + +The action: + +1. Receives the list of published packages from the Changesets action +2. For each package, reads its CHANGELOG at `packages/{package-name}/CHANGELOG.md` +3. Extracts PR numbers from the latest version section using regex +4. Groups all PRs and tracks which packages they contributed to +5. Posts a single comment per PR listing all packages it was released in +6. Uses the `gh` CLI to post comments via the GitHub API + +## Troubleshooting + +**No comments are posted:** + +- Verify your CHANGELOGs have PR links in the correct format +- Check that `steps.changesets.outputs.published` is `true` +- Ensure the workflow has `pull-requests: write` permission + +**Script fails to find CHANGELOGs:** + +- The script expects packages at `packages/{package-name}/CHANGELOG.md` +- Package name should match after removing the scope (e.g., `@tanstack/query-core` → `query-core`) diff --git a/.github/comment-on-release/action.yml b/.github/comment-on-release/action.yml new file mode 100644 index 00000000..0ea680b4 --- /dev/null +++ b/.github/comment-on-release/action.yml @@ -0,0 +1,15 @@ +name: Comment on PRs about release +description: Automatically comments on PRs when they are included in a release +inputs: + published-packages: + description: 'JSON string of published packages from changesets/action' + required: true +runs: + using: composite + steps: + - name: Comment on PRs + shell: bash + env: + PUBLISHED_PACKAGES: ${{ inputs.published-packages }} + REPOSITORY: ${{ github.repository }} + run: node {{ github.action_path }}/comment-on-release.ts diff --git a/.github/comment-on-release/comment-on-release.ts b/.github/comment-on-release/comment-on-release.ts new file mode 100644 index 00000000..8a7d088a --- /dev/null +++ b/.github/comment-on-release/comment-on-release.ts @@ -0,0 +1,184 @@ +#!/usr/bin/env node + +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { execSync } from 'node:child_process' + +interface PublishedPackage { + name: string + version: string +} + +interface PRInfo { + number: number + packages: Array<{ name: string; pkgPath: string; version: string }> +} + +/** + * Parse CHANGELOG.md to extract PR numbers from the latest version entry + */ +function extractPRsFromChangelog( + changelogPath: string, + version: string, +): Array { + try { + const content = readFileSync(changelogPath, 'utf-8') + const lines = content.split('\n') + + let inTargetVersion = false + let foundVersion = false + const prNumbers = new Set() + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + + // Check for version header (e.g., "## 0.21.0") + if (line.startsWith('## ')) { + const versionMatch = line.match(/^## (\d+\.\d+\.\d+)/) + if (versionMatch) { + if (versionMatch[1] === version) { + inTargetVersion = true + foundVersion = true + } else if (inTargetVersion) { + // We've moved to the next version, stop processing + break + } + } + } + + // Extract PR numbers from links like [#302](https://github.com/TanStack/config/pull/302) + if (inTargetVersion) { + const prMatches = line.matchAll( + /\[#(\d+)\]\(https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+\)/g, + ) + for (const match of prMatches) { + prNumbers.add(parseInt(match[1], 10)) + } + } + } + + if (!foundVersion) { + console.warn( + `Warning: Could not find version ${version} in ${changelogPath}`, + ) + } + + return Array.from(prNumbers) + } catch (error) { + console.error(`Error reading changelog at ${changelogPath}:`, error) + return [] + } +} + +/** + * Group PRs by their numbers and collect all packages they contributed to + */ +function groupPRsByNumber( + publishedPackages: Array, +): Map { + const prMap = new Map() + + for (const pkg of publishedPackages) { + const pkgPath = `packages/${pkg.name.replace('@tanstack/', '')}` + const changelogPath = resolve(process.cwd(), pkgPath, 'CHANGELOG.md') + + const prNumbers = extractPRsFromChangelog(changelogPath, pkg.version) + + for (const prNumber of prNumbers) { + if (!prMap.has(prNumber)) { + prMap.set(prNumber, { number: prNumber, packages: [] }) + } + prMap.get(prNumber)!.packages.push({ + name: pkg.name, + pkgPath: pkgPath, + version: pkg.version, + }) + } + } + + return prMap +} + +/** + * Post a comment on a GitHub PR using gh CLI + */ +async function commentOnPR(pr: PRInfo, repository: string): Promise { + const { number, packages } = pr + + // Build the comment body + let comment = `🎉 This PR has been released!\n\n` + + for (const pkg of packages) { + // Link to the package's changelog and version anchor + const changelogUrl = `https://github.com/${repository}/blob/main/${pkg.pkgPath}/CHANGELOG.md#${pkg.version.replaceAll('.', '')}` + comment += `- [${pkg.name}@${pkg.version}](${changelogUrl})\n` + } + + comment += `\nThank you for your contribution!` + + try { + // Use gh CLI to post the comment + execSync(`gh pr comment ${number} --body ${JSON.stringify(comment)}`, { + stdio: 'inherit', + }) + console.log(`✓ Commented on PR #${number}`) + } catch (error) { + console.error(`✗ Failed to comment on PR #${number}:`, error) + } +} + +/** + * Main function + */ +async function main() { + // Read published packages from environment variable (set by GitHub Actions) + const publishedPackagesJson = process.env.PUBLISHED_PACKAGES + const repository = process.env.REPOSITORY + + if (!publishedPackagesJson) { + console.log('No packages were published. Skipping PR comments.') + return + } + + if (!repository) { + console.log('Repository is missing. Skipping PR comments.') + return + } + + let publishedPackages: Array + try { + publishedPackages = JSON.parse(publishedPackagesJson) + } catch (error) { + console.error('Failed to parse PUBLISHED_PACKAGES:', error) + process.exit(1) + } + + if (publishedPackages.length === 0) { + console.log('No packages were published. Skipping PR comments.') + return + } + + console.log(`Processing ${publishedPackages.length} published package(s)...`) + + // Group PRs by number + const prMap = groupPRsByNumber(publishedPackages) + + if (prMap.size === 0) { + console.log('No PRs found in CHANGELOGs. Nothing to comment on.') + return + } + + console.log(`Found ${prMap.size} PR(s) to comment on...`) + + // Comment on each PR + for (const pr of prMap.values()) { + await commentOnPR(pr, repository) + } + + console.log('✓ Done!') +} + +main().catch((error) => { + console.error('Fatal error:', error) + process.exit(1) +}) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 10caf9e6..df9d9017 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,6 +31,7 @@ jobs: - name: Run Tests run: pnpm run test:ci - name: Run Changesets (version or publish) + id: changesets uses: changesets/action@v1.5.3 with: version: pnpm run changeset:version @@ -40,3 +41,8 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Comment on PRs about release + if: steps.changesets.outputs.published == 'true' + uses: ./.github/comment-on-release + with: + published-packages: ${{ steps.changesets.outputs.publishedPackages }}