diff --git a/.github/workflows/auto-changeset.yml b/.github/workflows/auto-changeset.yml index bacac6dcdb..181c622638 100644 --- a/.github/workflows/auto-changeset.yml +++ b/.github/workflows/auto-changeset.yml @@ -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, '') && + 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}" diff --git a/.github/workflows/auto-label.yml b/.github/workflows/auto-label.yml index cf773147c8..e89428ed38 100644 --- a/.github/workflows/auto-label.yml +++ b/.github/workflows/auto-label.yml @@ -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 diff --git a/.github/workflows/auto-retarget.yml b/.github/workflows/auto-retarget.yml index ce1cec232a..171572a9a3 100644 --- a/.github/workflows/auto-retarget.yml +++ b/.github/workflows/auto-retarget.yml @@ -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: | diff --git a/.github/workflows/lock-threads.yml b/.github/workflows/lock-threads.yml index 1719ade375..f5380c91d4 100644 --- a/.github/workflows/lock-threads.yml +++ b/.github/workflows/lock-threads.yml @@ -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 diff --git a/.github/workflows/semantic-pull-request.yml b/.github/workflows/semantic-pull-request.yml index eb9eb8a945..0e3feb5a7c 100644 --- a/.github/workflows/semantic-pull-request.yml +++ b/.github/workflows/semantic-pull-request.yml @@ -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