|
| 1 | +name: Lint PR |
| 2 | + |
| 3 | +on: |
| 4 | + pull_request: |
| 5 | + types: |
| 6 | + - opened |
| 7 | + - edited |
| 8 | + - synchronize |
| 9 | + - reopened |
| 10 | + - labeled |
| 11 | + - unlabeled |
| 12 | + |
| 13 | +jobs: |
| 14 | + validate-pr-title: |
| 15 | + name: Validate PR title (Conventional Commits) |
| 16 | + runs-on: ubuntu-latest |
| 17 | + steps: |
| 18 | + - name: Check Conventional Commits format |
| 19 | + env: |
| 20 | + PR_TITLE: ${{ github.event.pull_request.title }} |
| 21 | + PR_AUTHOR: ${{ github.event.pull_request.user.login }} |
| 22 | + run: | |
| 23 | + # Exempt automated PRs (Stainless codegen, release-please, dependabot, etc.). |
| 24 | + # These bots may not always emit Conventional-Commits-formatted titles |
| 25 | + # (dependabot's default "Bump foo from 1.0 to 1.1" doesn't match) and we |
| 26 | + # don't want their PRs blocked by this check. Mirrors validate-pr-base. |
| 27 | + case "$PR_AUTHOR" in |
| 28 | + stainless-app|stainless-app\[bot\]|release-please\[bot\]|github-actions\[bot\]|dependabot\[bot\]) |
| 29 | + echo "PR is from automation ($PR_AUTHOR); skipping title check." |
| 30 | + exit 0 |
| 31 | + ;; |
| 32 | + esac |
| 33 | +
|
| 34 | + # Conventional Commits: <type>(<optional-scope>)(!): <subject> |
| 35 | + PATTERN='^(feat|fix|docs|style|refactor|test|chore|ci|build|perf|revert)(\([^)]+\))?!?: .+' |
| 36 | +
|
| 37 | + if printf '%s' "$PR_TITLE" | grep -qE "$PATTERN"; then |
| 38 | + echo "PR title is a valid Conventional Commit: $PR_TITLE" |
| 39 | + exit 0 |
| 40 | + fi |
| 41 | +
|
| 42 | + # ::error must be on stdout for GitHub Actions to surface it as an annotation. |
| 43 | + echo "::error title=Invalid PR title::PR title must follow Conventional Commits format. Got: $PR_TITLE" |
| 44 | + { |
| 45 | + echo " Got: $PR_TITLE" |
| 46 | + echo " Expected: <type>(<optional-scope>)(!): <subject>" |
| 47 | + echo " Types: feat, fix, docs, style, refactor, test, chore, ci, build, perf, revert" |
| 48 | + echo "" |
| 49 | + echo " Examples:" |
| 50 | + echo " feat: add new endpoint" |
| 51 | + echo " fix(client): handle empty response" |
| 52 | + echo " chore!: drop python 3.11 support" |
| 53 | + } >&2 |
| 54 | + exit 1 |
| 55 | +
|
| 56 | + validate-pr-base: |
| 57 | + name: Validate PR base branch |
| 58 | + runs-on: ubuntu-latest |
| 59 | + permissions: |
| 60 | + pull-requests: write |
| 61 | + steps: |
| 62 | + - name: Validate base branch and manage PR comment |
| 63 | + env: |
| 64 | + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
| 65 | + REPO: ${{ github.repository }} |
| 66 | + PR_NUMBER: ${{ github.event.pull_request.number }} |
| 67 | + PR_AUTHOR: ${{ github.event.pull_request.user.login }} |
| 68 | + PR_BASE: ${{ github.event.pull_request.base.ref }} |
| 69 | + HAS_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'target-main') }} |
| 70 | + run: | |
| 71 | + MARKER='<!-- lint-pr-validate-base -->' |
| 72 | +
|
| 73 | + # Look up an existing marker comment so we can update/delete it. |
| 74 | + # --paginate handles PRs with >30 comments. If the lookup fails |
| 75 | + # (transient API error, fork PR token without read scope), continue |
| 76 | + # with no existing_id so we still emit the failure annotation. |
| 77 | + existing_id=$(gh api --paginate "repos/$REPO/issues/$PR_NUMBER/comments" \ |
| 78 | + --jq ".[] | select(.body | contains(\"$MARKER\")) | .id" 2>/dev/null \ |
| 79 | + | head -n1) || existing_id="" |
| 80 | +
|
| 81 | + delete_comment() { |
| 82 | + if [ -n "$existing_id" ]; then |
| 83 | + gh api -X DELETE "repos/$REPO/issues/comments/$existing_id" >/dev/null 2>&1 || true |
| 84 | + fi |
| 85 | + } |
| 86 | +
|
| 87 | + # PR doesn't target main — nothing to enforce. |
| 88 | + if [ "$PR_BASE" != "main" ]; then |
| 89 | + delete_comment |
| 90 | + echo "PR base is '$PR_BASE'; check passes." |
| 91 | + exit 0 |
| 92 | + fi |
| 93 | +
|
| 94 | + # Exempt automated PRs (must mirror validate-pr-title's list). |
| 95 | + case "$PR_AUTHOR" in |
| 96 | + stainless-app|stainless-app\[bot\]|release-please\[bot\]|github-actions\[bot\]|dependabot\[bot\]) |
| 97 | + delete_comment |
| 98 | + echo "PR is from automation ($PR_AUTHOR); allowing PR targeting main." |
| 99 | + exit 0 |
| 100 | + ;; |
| 101 | + esac |
| 102 | +
|
| 103 | + # Per-PR opt-out via label. |
| 104 | + if [ "$HAS_LABEL" = "true" ]; then |
| 105 | + delete_comment |
| 106 | + echo "Found 'target-main' label; allowing PR targeting main." |
| 107 | + exit 0 |
| 108 | + fi |
| 109 | +
|
| 110 | + # Failure path: try to post or update an explanatory comment. |
| 111 | + # The write may fail on fork PRs (GITHUB_TOKEN has read-only scope |
| 112 | + # upstream) or due to transient API errors. Guard each gh call so |
| 113 | + # the ::error annotation and exit 1 still run regardless. |
| 114 | + body_file=$(mktemp) |
| 115 | + { |
| 116 | + echo "$MARKER" |
| 117 | + echo |
| 118 | + echo "**This PR is targeting \`main\`, but PRs should target the \`next\` branch by default.**" |
| 119 | + echo |
| 120 | + echo "The \`main\` branch is reserved for release-please and Stainless automation. To resolve, pick one of:" |
| 121 | + echo |
| 122 | + echo "- **Re-target the PR to \`next\`** (recommended). On the PR page, click **Edit** next to the title and change the base branch to \`next\`." |
| 123 | + echo "- **Add the \`target-main\` label** if this is an intentional exception (e.g. an urgent hotfix). The check will re-run and pass." |
| 124 | + echo |
| 125 | + echo "See \`CONTRIBUTING.md\` for the full branch model." |
| 126 | + } > "$body_file" |
| 127 | +
|
| 128 | + comment_status="ok" |
| 129 | + if [ -n "$existing_id" ]; then |
| 130 | + gh api -X PATCH "repos/$REPO/issues/comments/$existing_id" \ |
| 131 | + -F body=@"$body_file" >/dev/null 2>&1 || comment_status="failed" |
| 132 | + [ "$comment_status" = "ok" ] && echo "Updated existing PR comment ($existing_id)." |
| 133 | + else |
| 134 | + gh pr comment "$PR_NUMBER" --repo "$REPO" --body-file "$body_file" >/dev/null 2>&1 || comment_status="failed" |
| 135 | + [ "$comment_status" = "ok" ] && echo "Posted new PR comment." |
| 136 | + fi |
| 137 | +
|
| 138 | + if [ "$comment_status" = "failed" ]; then |
| 139 | + echo "::warning title=Could not write PR comment::Likely a fork PR (no upstream write scope) or a transient API error. The check still fails — see the next annotation for resolution steps." |
| 140 | + fi |
| 141 | +
|
| 142 | + # ::error must be on stdout to surface as an annotation. |
| 143 | + echo "::error title=PR should target 'next'::Re-target to 'next' or add the 'target-main' label. See the PR comment for full details." |
| 144 | + exit 1 |
0 commit comments