ci: add one-click changeset commit and unify bot identity (#9019)

This commit is contained in:
Gustavo Valverde
2026-04-07 20:54:34 +01:00
committed by GitHub
parent f61ad1cab7
commit 465c1dccf6
5 changed files with 230 additions and 7 deletions

View File

@@ -3,7 +3,7 @@ name: Auto Changeset
on:
issue_comment:
types: [created]
types: [created, edited]
permissions: {}
@@ -17,6 +17,7 @@ jobs:
# making it safe for both team and fork PRs.
changeset:
if: >
github.event.action == 'created' &&
github.event.issue.pull_request &&
startsWith(github.event.comment.body, '/changeset') &&
github.repository_owner == 'better-auth' &&
@@ -185,8 +186,191 @@ jobs:
"### Changeset recommendation" "" \
"${EXISTING_NOTE}" \
"Copy into \`.changeset/pr-${PR_NUMBER}.md\`:" "" \
'```md' "$CHANGESET" '```' > "$TMPFILE"
'```md' "$CHANGESET" '```' "" \
"---" "" \
"- [ ] **Commit this changeset** to the PR" > "$TMPFILE"
gh pr comment "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --body-file "$TMPFILE"
rm -f "$TMPFILE"
echo "Posted changeset recommendation on PR #${PR_NUMBER}"
# Apply changeset — triggered when someone checks the commit checkbox
# on a bot-posted changeset recommendation comment.
apply:
if: >
github.event.action == 'edited' &&
github.event.issue.pull_request &&
contains(github.event.comment.body, '<!-- auto-changeset -->') &&
contains(github.event.comment.body, '- [x] **Commit this changeset**') &&
(github.event.comment.user.login == 'better-release[bot]' || github.event.comment.user.login == 'github-actions[bot]') &&
github.repository_owner == 'better-auth'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Generate App Token
id: app-token
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
if: vars.RELEASE_APP_ID != ''
with:
app-id: ${{ vars.RELEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
- name: Verify sender authorization
id: auth
env:
GH_TOKEN: ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }}
SENDER: ${{ github.event.sender.login }}
PR_NUMBER: ${{ github.event.issue.number }}
run: |
# Match the /changeset command's MEMBER/OWNER/COLLABORATOR gate.
# author_association is role-based (not permission-based), so we
# check repo collaborator status and org membership separately.
if gh api "repos/${GITHUB_REPOSITORY}/collaborators/${SENDER}" --silent 2>/dev/null; then
echo "authorized=true" >> "$GITHUB_OUTPUT"
elif gh api "orgs/${GITHUB_REPOSITORY_OWNER}/members/${SENDER}" --silent 2>/dev/null; then
echo "authorized=true" >> "$GITHUB_OUTPUT"
else
PR_AUTHOR=$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" \
--json author --jq '.author.login' 2>/dev/null || echo "")
if [[ -n "$PR_AUTHOR" && "$SENDER" == "$PR_AUTHOR" ]]; then
echo "authorized=true" >> "$GITHUB_OUTPUT"
else
echo "::warning::${SENDER} is not authorized"
echo "authorized=false" >> "$GITHUB_OUTPUT"
fi
fi
- name: Get PR details
if: steps.auth.outputs.authorized == 'true'
id: pr
env:
GH_TOKEN: ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.issue.number }}
run: |
PR_JSON=$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" \
--json headRefName,isCrossRepository)
HEAD_REF=$(echo "$PR_JSON" | jq -r '.headRefName')
IS_FORK=$(echo "$PR_JSON" | jq -r '.isCrossRepository')
echo "head_ref=$HEAD_REF" >> "$GITHUB_OUTPUT"
if [[ "$HEAD_REF" == "main" || "$HEAD_REF" == "next" || "$HEAD_REF" == release/* ]]; then
echo "blocked=true" >> "$GITHUB_OUTPUT"
echo "blocked_reason=Cannot commit to protected branch (${HEAD_REF}) — this would trigger release automation." >> "$GITHUB_OUTPUT"
elif [[ "$IS_FORK" == "true" ]]; then
echo "blocked=true" >> "$GITHUB_OUTPUT"
echo "blocked_reason=Cannot auto-commit to fork PRs — please copy the changeset manually." >> "$GITHUB_OUTPUT"
else
echo "blocked=false" >> "$GITHUB_OUTPUT"
fi
- name: Abort if blocked
if: steps.auth.outputs.authorized == 'true' && steps.pr.outputs.blocked == 'true'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }}
COMMENT_ID: ${{ github.event.comment.id }}
BLOCKED_REASON: ${{ steps.pr.outputs.blocked_reason }}
run: |
BODY=$(gh api "repos/${GITHUB_REPOSITORY}/issues/comments/${COMMENT_ID}" --jq '.body')
TMPFILE=$(mktemp)
awk -v reason="$BLOCKED_REASON" '{
sub(/- \[x\] \*\*Commit this changeset\*\* to the PR/,
"- [ ] **Commit this changeset** to the PR\n\n> ⚠️ " reason)
print
}' <<< "$BODY" > "$TMPFILE"
gh api "repos/${GITHUB_REPOSITORY}/issues/comments/${COMMENT_ID}" \
-X PATCH --input <(jq -n --rawfile body "$TMPFILE" '{body: $body}')
rm -f "$TMPFILE"
echo "::warning::${BLOCKED_REASON}"
- name: Commit changeset
if: steps.auth.outputs.authorized == 'true' && steps.pr.outputs.blocked != 'true'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.issue.number }}
COMMENT_ID: ${{ github.event.comment.id }}
HEAD_REF: ${{ steps.pr.outputs.head_ref }}
SENDER: ${{ github.event.sender.login }}
run: |
set -euo pipefail
# Fetch comment body via API (safer than env var for special characters)
BODY=$(gh api "repos/${GITHUB_REPOSITORY}/issues/comments/${COMMENT_ID}" --jq '.body')
# Extract changeset from the ```md code block
CHANGESET=$(awk '/^```md$/{found=1;next} found && /^```$/{exit} found{print}' <<< "$BODY")
if [ -z "$CHANGESET" ]; then
echo "::error::Could not extract changeset content from comment"
exit 1
fi
FILE_PATH=".changeset/pr-${PR_NUMBER}.md"
# ── Use Git Data API for an atomic single commit ──
# This adds pr-N.md and deletes any superseded changeset files
# in one commit, preventing duplicate changesets on the branch.
# Get branch head
COMMIT_SHA=$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/${HEAD_REF}" --jq '.object.sha')
TREE_SHA=$(gh api "repos/${GITHUB_REPOSITORY}/git/commits/${COMMIT_SHA}" --jq '.tree.sha')
# Create blob for the new changeset
BLOB_SHA=$(jq -n --arg content "$(printf '%s\n' "$CHANGESET" | base64 -w 0)" \
'{content: $content, encoding: "base64"}' | \
gh api "repos/${GITHUB_REPOSITORY}/git/blobs" --input - --jq '.sha')
# Start tree with the new changeset entry
TREE_FILE=$(mktemp)
jq -n --arg path "$FILE_PATH" --arg sha "$BLOB_SHA" \
'[{path: $path, mode: "100644", type: "blob", sha: $sha}]' > "$TREE_FILE"
# Only supersede changeset files that THIS PR introduced —
# never touch files inherited from parent branches (stacked PRs).
SUPERSEDED=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files" \
--paginate --jq '.[] | select(.filename | startswith(".changeset/")) |
select(.filename | endswith(".md")) |
select(.filename | endswith("README.md") | not) |
.filename' 2>/dev/null \
| grep -v "^${FILE_PATH}$" || true)
if [ -n "$SUPERSEDED" ]; then
while IFS= read -r old_path; do
[ -z "$old_path" ] && continue
# Setting sha to null tells the Git tree API to delete the file
jq --arg path "$old_path" \
'. + [{path: $path, mode: "100644", type: "blob", sha: null}]' \
"$TREE_FILE" > "${TREE_FILE}.tmp" && mv "${TREE_FILE}.tmp" "$TREE_FILE"
echo "Superseding: ${old_path}"
done <<< "$SUPERSEDED"
fi
# Create tree, commit, and update ref
NEW_TREE_SHA=$(jq -n --arg base "$TREE_SHA" --argjson tree "$(cat "$TREE_FILE")" \
'{base_tree: $base, tree: $tree}' | \
gh api "repos/${GITHUB_REPOSITORY}/git/trees" --input - --jq '.sha')
rm -f "$TREE_FILE"
NEW_COMMIT_SHA=$(jq -n \
--arg message "chore: add changeset for PR #${PR_NUMBER}" \
--arg tree "$NEW_TREE_SHA" \
--arg parent "$COMMIT_SHA" \
'{message: $message, tree: $tree, parents: [$parent]}' | \
gh api "repos/${GITHUB_REPOSITORY}/git/commits" --input - --jq '.sha')
gh api "repos/${GITHUB_REPOSITORY}/git/refs/heads/${HEAD_REF}" \
-X PATCH -f sha="$NEW_COMMIT_SHA"
# Update comment: replace checkbox with committed confirmation
TMPFILE=$(mktemp)
awk -v sender="$SENDER" '{
sub(/- \[x\] \*\*Commit this changeset\*\* to the PR/,
"✅ Committed by @" sender)
print
}' <<< "$BODY" > "$TMPFILE"
gh api "repos/${GITHUB_REPOSITORY}/issues/comments/${COMMENT_ID}" \
-X PATCH --input <(jq -n --rawfile body "$TMPFILE" '{body: $body}')
rm -f "$TMPFILE"
echo "Committed ${FILE_PATH} to ${HEAD_REF}"

View File

@@ -14,7 +14,16 @@ jobs:
contents: read
pull-requests: write
steps:
- name: Generate App Token
id: app-token
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
if: vars.RELEASE_APP_ID != ''
with:
app-id: ${{ vars.RELEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
- uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1
with:
repo-token: ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }}
sync-labels: false
dot: true

View File

@@ -20,11 +20,20 @@ jobs:
contents: read
pull-requests: write
steps:
- name: Generate App Token
id: app-token
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
if: vars.RELEASE_APP_ID != ''
with:
app-id: ${{ vars.RELEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
- name: Detect max bump type from changesets
id: bump
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_TOKEN: ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
MAX="patch"
FILES=$(gh pr diff "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --name-only | grep '^\.changeset/.*\.md$' | grep -v 'README.md' || true)
@@ -36,7 +45,7 @@ jobs:
while IFS= read -r f; do
[ -z "$f" ] && continue
CONTENT=$(gh api "repos/$GITHUB_REPOSITORY/contents/$f?ref=${{ github.event.pull_request.head.sha }}" --jq '.content' | base64 -d 2>/dev/null || true)
CONTENT=$(gh api "repos/$GITHUB_REPOSITORY/contents/$f?ref=${HEAD_SHA}" --jq '.content' | base64 -d 2>/dev/null || true)
[ -z "$CONTENT" ] && continue
BUMPS=$(echo "$CONTENT" | sed -n '/^---$/,/^---$/p' | grep -oE '"[^"]+"\s*:\s*(major|minor|patch)' | grep -oE '(major|minor|patch)$' | sort -u)
if echo "$BUMPS" | grep -q "major"; then
@@ -53,7 +62,7 @@ jobs:
if: steps.bump.outputs.max == 'minor' || steps.bump.outputs.max == 'major'
id: next-check
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_TOKEN: ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }}
run: |
if gh api "repos/$GITHUB_REPOSITORY/branches/next" --silent 2>/dev/null; then
echo "exists=true" >> "$GITHUB_OUTPUT"
@@ -65,7 +74,7 @@ jobs:
- name: Retarget PR to next
if: (steps.bump.outputs.max == 'minor' || steps.bump.outputs.max == 'major') && steps.next-check.outputs.exists == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_TOKEN: ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
MAX_BUMP: ${{ steps.bump.outputs.max }}
run: |

View File

@@ -1,3 +1,4 @@
# cSpell:words dessant
name: Lock Threads
on:
@@ -18,8 +19,17 @@ jobs:
pull-requests: write
if: github.repository_owner == 'better-auth'
steps:
- name: Generate App Token
id: app-token
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
if: vars.RELEASE_APP_ID != ''
with:
app-id: ${{ vars.RELEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
- uses: dessant/lock-threads@7266a7ce5c1df01b1c6db85bf8cd86c737dadbe7 # v6.0.0
with:
github-token: ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }}
process-only: 'issues, prs'
issue-inactive-days: 7
pr-inactive-days: 7

View File

@@ -1,3 +1,4 @@
# cSpell:words amannn marocchino
name: Semantic Pull Request
on:
@@ -17,6 +18,14 @@ jobs:
permissions:
pull-requests: write
steps:
- name: Generate App Token
id: app-token
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
if: vars.RELEASE_APP_ID != ''
with:
app-id: ${{ vars.RELEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
- name: Check PR title
uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6
id: lint_pr_title
@@ -27,7 +36,7 @@ jobs:
didn't match the configured pattern. Please ensure that the subject
doesn't start with an uppercase character.
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }}
# Block breaking changes (feat!, fix!, etc.) on PRs targeting main
- name: Block breaking changes on main
@@ -49,6 +58,7 @@ jobs:
if: always() && (steps.lint_pr_title.outputs.error_message != null)
with:
header: pr-title-lint-error
GITHUB_TOKEN: ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }}
message: |
Thanks for your contribution!
@@ -66,4 +76,5 @@ jobs:
if: ${{ steps.lint_pr_title.outputs.error_message == null }}
with:
header: pr-title-lint-error
GITHUB_TOKEN: ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }}
delete: true