mirror of
https://github.com/better-auth/better-auth.git
synced 2026-05-23 07:18:56 -05:00
ci: add one-click changeset commit and unify bot identity (#9019)
This commit is contained in:
188
.github/workflows/auto-changeset.yml
vendored
188
.github/workflows/auto-changeset.yml
vendored
@@ -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}"
|
||||
|
||||
9
.github/workflows/auto-label.yml
vendored
9
.github/workflows/auto-label.yml
vendored
@@ -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
|
||||
|
||||
17
.github/workflows/auto-retarget.yml
vendored
17
.github/workflows/auto-retarget.yml
vendored
@@ -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: |
|
||||
|
||||
10
.github/workflows/lock-threads.yml
vendored
10
.github/workflows/lock-threads.yml
vendored
@@ -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
|
||||
|
||||
13
.github/workflows/semantic-pull-request.yml
vendored
13
.github/workflows/semantic-pull-request.yml
vendored
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user