Skip to content

Commit d85643b

Browse files
joshuaellisclaude
andcommitted
feat: auto-generate changesets from PR descriptions
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent b704592 commit d85643b

File tree

3 files changed

+231
-17
lines changed

3 files changed

+231
-17
lines changed

.github/PULL_REQUEST_TEMPLATE.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
### Description
2+
3+
<!--
4+
What changes are introduced?
5+
Why are these changes introduced?
6+
What issue(s) does this solve? (with link, if possible)
7+
-->
8+
9+
### What to review
10+
11+
<!--
12+
What steps should the reviewer take in order to review?
13+
What parts/flows of the application/packages/tooling is affected?
14+
-->
15+
16+
### Testing
17+
18+
<!--
19+
Did you add sufficient testing for this change?
20+
If not, please explain how you tested this change and why it was not
21+
possible/practical for writing an automated test
22+
-->
23+
24+
### Notes for release
25+
26+
<!--
27+
A changeset is auto-generated from this section. Leave empty to use the PR title, or start with "N/A" to skip. For example, "N/A: Internal only"
28+
If you ran `pnpm changeset` manually, write "N/A" here to avoid duplicates.
29+
30+
Engineers do not need to worry about the final copy,
31+
but they must provide the docs team with enough context on:
32+
33+
* What changed
34+
* How does one use it (code snippets, etc)
35+
* Are there limitations we should be aware of
36+
* [internal] Does this affect the docs team? If so, please ask a member of that team for a review
37+
38+
If this PR is a partial implementation of a feature and is not enabled by default or if
39+
this PR does not contain changes that needs mention in the release notes (tooling chores etc),
40+
please call this out explicitly by writing "N/A – Part of feature X" in this section.
41+
-->
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
name: Generate changeset from PR
2+
3+
on:
4+
pull_request_target:
5+
types: [opened, edited, synchronize]
6+
7+
concurrency:
8+
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
9+
cancel-in-progress: true
10+
11+
permissions:
12+
contents: write
13+
pull-requests: read
14+
15+
jobs:
16+
generate-changeset:
17+
name: Generate changeset
18+
runs-on: ubuntu-latest
19+
if: >-
20+
github.event.pull_request.user.login != 'renovate[bot]' &&
21+
github.event.pull_request.user.login != 'dependabot[bot]' &&
22+
github.event.pull_request.user.login != 'ecospark[bot]'
23+
steps:
24+
- name: Generate GitHub App Token
25+
id: generate_token
26+
uses: actions/create-github-app-token@v2
27+
with:
28+
app-id: ${{ secrets.ECOSPARK_APP_ID }}
29+
private-key: ${{ secrets.ECOSPARK_APP_PRIVATE_KEY }}
30+
31+
- name: Checkout PR branch
32+
uses: actions/checkout@v6
33+
with:
34+
ref: ${{ github.event.pull_request.head.ref }}
35+
repository: ${{ github.event.pull_request.head.repo.full_name }}
36+
token: ${{ steps.generate_token.outputs.token }}
37+
fetch-depth: 2
38+
persist-credentials: false
39+
40+
- name: Generate or remove changeset
41+
env:
42+
GH_TOKEN: ${{ steps.generate_token.outputs.token }}
43+
PR_NUMBER: ${{ github.event.pull_request.number }}
44+
PR_TITLE: ${{ github.event.pull_request.title }}
45+
PR_BODY: ${{ github.event.pull_request.body }}
46+
PR_REPO: ${{ github.event.pull_request.head.repo.full_name }}
47+
run: |
48+
set -euo pipefail
49+
50+
CHANGESET_FILE=".changeset/pr-${PR_NUMBER}.md"
51+
52+
# Configure git for pushing (persist-credentials is off)
53+
git config user.name "ecospark[bot]"
54+
git config user.email "ecospark[bot]@users.noreply.github.com"
55+
git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${PR_REPO}.git"
56+
57+
remove_changeset() {
58+
if [ -f "$CHANGESET_FILE" ]; then
59+
git rm "$CHANGESET_FILE"
60+
git commit -m "chore: remove auto-generated changeset for PR #${PR_NUMBER}"
61+
git push --force-with-lease
62+
fi
63+
}
64+
65+
# --- Parse conventional commit type from PR title ---
66+
if [[ "$PR_TITLE" =~ ^([a-z]+)(\(.+\))?(!)?\:\ .+ ]]; then
67+
TYPE="${BASH_REMATCH[1]}"
68+
BREAKING="${BASH_REMATCH[3]}"
69+
else
70+
echo "::warning::PR title does not match conventional commit format"
71+
remove_changeset
72+
exit 0
73+
fi
74+
75+
# --- Determine bump type ---
76+
BUMP=""
77+
case "$TYPE" in
78+
feat) BUMP="minor" ;;
79+
fix|perf|revert) BUMP="patch" ;;
80+
*) BUMP="" ;;
81+
esac
82+
83+
# Breaking change overrides to major
84+
if [ "$BREAKING" = "!" ]; then
85+
BUMP="major"
86+
elif [ -n "$PR_BODY" ] && echo "$PR_BODY" | grep -qE '^BREAKING CHANGE:'; then
87+
BUMP="major"
88+
fi
89+
90+
# Non-bump type — clean up and exit
91+
if [ -z "$BUMP" ]; then
92+
echo "PR type '${TYPE}' does not require a changeset"
93+
remove_changeset
94+
exit 0
95+
fi
96+
97+
# --- Parse release notes from PR body ---
98+
RELEASE_NOTES=""
99+
if [ -n "$PR_BODY" ]; then
100+
RELEASE_NOTES=$(echo "$PR_BODY" | \
101+
sed -n '/^### Notes for release/,/^### /{/^### /!p}' | \
102+
sed '/^<!--/,/-->$/d' | \
103+
sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' | \
104+
sed '/^$/d')
105+
fi
106+
107+
# Explicit opt-out (N/A, N/A: reason, N/A – reason)
108+
if echo "$RELEASE_NOTES" | grep -qiE '^N/A'; then
109+
echo "Release notes set to N/A"
110+
remove_changeset
111+
exit 0
112+
fi
113+
114+
# Fall back to PR title (strip type prefix)
115+
if [ -z "$RELEASE_NOTES" ]; then
116+
RELEASE_NOTES=$(echo "$PR_TITLE" | sed 's/^[a-z]*\(([^)]*)\)\?!\?:[[:space:]]*//')
117+
fi
118+
119+
# --- Detect affected packages from changed files ---
120+
CHANGED_FILES=$(gh api \
121+
"repos/${{ github.repository }}/pulls/${PR_NUMBER}/files" \
122+
--paginate \
123+
--jq '.[].filename')
124+
125+
# Build package map dynamically from workspace
126+
declare -A PKG_MAP
127+
for pkg_json in packages/*/package.json packages/@*/*/package.json; do
128+
[ -f "$pkg_json" ] || continue
129+
pkg_private=$(jq -r '.private // false' "$pkg_json")
130+
[ "$pkg_private" = "true" ] && continue
131+
pkg_name=$(jq -r '.name' "$pkg_json")
132+
pkg_dir=$(dirname "$pkg_json")/
133+
PKG_MAP["$pkg_dir"]="$pkg_name"
134+
done
135+
136+
# Match changed files to packages
137+
declare -A AFFECTED
138+
while IFS= read -r file; do
139+
[ -z "$file" ] && continue
140+
for prefix in "${!PKG_MAP[@]}"; do
141+
if [[ "$file" == ${prefix}* ]]; then
142+
AFFECTED["${PKG_MAP[$prefix]}"]=1
143+
fi
144+
done
145+
done <<< "$CHANGED_FILES"
146+
147+
# No packages affected — skip
148+
if [ ${#AFFECTED[@]} -eq 0 ]; then
149+
echo "No public packages affected by changed files"
150+
remove_changeset
151+
exit 0
152+
fi
153+
154+
# --- Generate changeset file ---
155+
{
156+
echo "---"
157+
for pkg in "${!AFFECTED[@]}"; do
158+
echo "'${pkg}': ${BUMP}"
159+
done
160+
echo "---"
161+
echo ""
162+
echo "${RELEASE_NOTES}"
163+
} > "$CHANGESET_FILE"
164+
165+
echo "Generated changeset:"
166+
cat "$CHANGESET_FILE"
167+
168+
# --- Commit and push ---
169+
git add "$CHANGESET_FILE"
170+
171+
if git diff --cached --quiet; then
172+
echo "No changes to changeset file"
173+
exit 0
174+
fi
175+
176+
git commit -m "chore: update auto-generated changeset for PR #${PR_NUMBER}"
177+
git push --force-with-lease

CONTRIBUTING.md

Lines changed: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -778,34 +778,30 @@ npx sanity dev
778778

779779
This project uses [Changesets](https://github.com/changesets/changesets) for version management and publishing.
780780

781-
### Automatic Changesets (Conventional Commits)
781+
### Automatic Changesets
782782

783-
For **bot PRs** (Renovate, Dependabot), changesets are **automatically generated** from conventional commit messages — no manual steps needed. The `changesets-from-conventional-commits` workflow parses commit messages and creates the appropriate changeset files:
783+
Changesets are **automatically generated** — you never need to run `pnpm changeset` manually.
784784

785-
- `feat:` → minor bump
786-
- `fix:` → patch bump
787-
- `feat!:` or `BREAKING CHANGE:` → major bump
785+
**For human PRs**, the `generate-changeset` workflow:
788786

789-
This also works for any PR that follows [Conventional Commits](https://www.conventionalcommits.org/) format.
787+
1. Reads the **Notes for release** section from your PR description (the template pre-fills this)
788+
2. Derives the bump type from your PR title (`feat:` → minor, `fix:` → patch, `feat!:` → major)
789+
3. Detects affected packages from changed files
790+
4. Commits a changeset file to your PR branch
790791

791-
### Manual Changesets
792+
If you leave the Notes for release section empty, the PR title is used as the changelog entry. Write `N/A` to explicitly skip the changeset (e.g., `N/A: Internal only`).
793+
794+
**For bot PRs** (Renovate, Dependabot), the `changesets-from-conventional-commits` workflow generates changesets from commit messages automatically.
792795

793-
For human-authored PRs, you can either:
796+
### Manual Changesets
794797

795-
1. **Rely on conventional commits** — if your PR commit messages follow the conventional format, changesets will be auto-generated
796-
2. **Manually add a changeset** for more control over the changelog entry:
798+
In rare cases where you need full control (e.g., targeting specific packages), you can still run:
797799

798800
```bash
799801
pnpm changeset
800802
```
801803

802-
This will prompt you to:
803-
804-
1. **Select packages** that are affected by your change
805-
2. **Choose a bump type** (patch, minor, or major)
806-
3. **Write a summary** of the change (this becomes the changelog entry)
807-
808-
A markdown file will be created in the `.changeset/` directory. Commit this file with your PR.
804+
This creates a changeset file in `.changeset/`. If you do this, write `N/A` in the PR Notes for release section to prevent the auto-generated changeset from duplicating it.
809805

810806
### When a Changeset is Needed
811807

0 commit comments

Comments
 (0)