Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions .github/comment-on-release/README.md
Original file line number Diff line number Diff line change
@@ -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`)
15 changes: 15 additions & 0 deletions .github/comment-on-release/action.yml
Original file line number Diff line number Diff line change
@@ -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
184 changes: 184 additions & 0 deletions .github/comment-on-release/comment-on-release.ts
Original file line number Diff line number Diff line change
@@ -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<number> {
try {
const content = readFileSync(changelogPath, 'utf-8')
const lines = content.split('\n')

let inTargetVersion = false
let foundVersion = false
const prNumbers = new Set<number>()

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<PublishedPackage>,
): Map<number, PRInfo> {
const prMap = new Map<number, PRInfo>()

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<void> {
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<PublishedPackage>
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)
})
6 changes: 6 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 }}
Loading