11name : Validate Pull Request
22
3- # Minimal permissions: read code, write PR comments.
43permissions :
54 contents : read
65 pull-requests : write
76
8- # Uses pull_request_target to access secrets for PR comments on forks.
97on :
108 pull_request_target :
119 branches :
1513 - edited
1614 - synchronize
1715 - reopened
16+ - labeled
17+ - unlabeled
1818
19- # Avoid racing on PR comments when multiple events fire quickly.
2019concurrency :
2120 group : ${{ github.workflow }}-${{ github.event.pull_request.number }}
2221 cancel-in-progress : true
2322
2423jobs :
2524 validate-title-and-commits :
2625 name : Validate Title and Commits
26+ if : >-
27+ !contains(fromJSON('["labeled", "unlabeled"]'), github.event.action)
28+ || contains(fromJSON('["Squash", "Rebase"]'), github.event.label.name)
29+
2730 runs-on : ubuntu-slim
28- timeout-minutes : 3
31+ timeout-minutes : 2
2932
30- # Expose context as env vars to avoid inline ${{ }} in run blocks (injection hardening).
3133 env :
3234 PR_NUMBER : ${{ github.event.pull_request.number }}
3335 PR_HEAD_SHA : ${{ github.event.pull_request.head.sha }}
3436 BASE_REF : ${{ github.base_ref }}
3537 REPO : ${{ github.repository }}
38+ # Optional repository/organization variable used to disable bot PR comments.
39+ POST_VALIDATE_COMMENT : ${{ vars.POST_VALIDATE_COMMENT }}
3640
3741 steps :
42+ # Apply merge method labels from the PR description.
43+ # Requires the "Squash" and "Rebase" labels to exist in the repository settings.
44+ - name : Apply merge method labels from PR description
45+ id : apply-merge-labels
46+ env :
47+ GH_TOKEN : ${{ github.token }}
48+ EVENT_ACTION : ${{ github.event.action }}
49+ run : |
50+ set +e
51+ LABEL_LINES=$(jq -r '.pull_request.labels[].name' "$GITHUB_EVENT_PATH")
52+ BODY=$(jq -r '.pull_request.body // ""' "$GITHUB_EVENT_PATH")
53+
54+ HAS_SQUASH="false"
55+ HAS_REBASE="false"
56+ if printf '%s\n' "$LABEL_LINES" | grep -qx "Squash"; then
57+ HAS_SQUASH="true"
58+ fi
59+ if printf '%s\n' "$LABEL_LINES" | grep -qx "Rebase"; then
60+ HAS_REBASE="true"
61+ fi
62+
63+ if [ "$EVENT_ACTION" = "labeled" ] || [ "$EVENT_ACTION" = "unlabeled" ]; then
64+ echo "Manual label event detected. Skipping description auto-labeling." >> "$GITHUB_STEP_SUMMARY"
65+ exit 0
66+ fi
67+
68+ if [ "$EVENT_ACTION" = "synchronize" ] && { [ "$HAS_SQUASH" = "true" ] || [ "$HAS_REBASE" = "true" ]; }; then
69+ echo "Labels already present during synchronize event. Skipping auto-labeling to respect potential manual overrides." >> "$GITHUB_STEP_SUMMARY"
70+ exit 0
71+ fi
72+
73+ SQUASH_PATTERN='squash[- ]?merge|squash and merge|merge by squash|merge with squash'
74+ REBASE_PATTERN='rebase[- ]?merge|rebase and merge|merge by rebase|merge with rebase'
75+ NEGATE_SQUASH_PATTERN="don't[ -]?squash|dont[ -]?squash|do not[ -]?squash|not[ -]?squash|avoid[ -]?squash"
76+ NEGATE_REBASE_PATTERN="don't[ -]?rebase|dont[ -]?rebase|do not[ -]?rebase|not[ -]?rebase|avoid[ -]?rebase"
77+
78+ if printf '%s\n' "$BODY" | grep -qiE "$SQUASH_PATTERN" && ! printf '%s\n' "$BODY" | grep -qiE "$NEGATE_SQUASH_PATTERN"; then
79+ if [ "$HAS_SQUASH" = "true" ] && [ "$HAS_REBASE" != "true" ]; then
80+ echo "Squash label already present and matches description." >> "$GITHUB_STEP_SUMMARY"
81+ exit 0
82+ fi
83+
84+ if [ "$HAS_REBASE" = "true" ]; then
85+ if ! gh pr edit "$PR_NUMBER" --repo "$REPO" --remove-label "Rebase" > /dev/null 2>&1; then
86+ echo "Could not remove **Rebase** label; skipping **Squash** application to avoid conflict." >> "$GITHUB_STEP_SUMMARY"
87+ exit 0
88+ fi
89+ fi
90+
91+ if gh pr edit "$PR_NUMBER" --repo "$REPO" --add-label "Squash" > /dev/null 2>&1; then
92+ echo "Applied **Squash** label from PR description." >> "$GITHUB_STEP_SUMMARY"
93+ else
94+ echo "Could not apply **Squash** label." >> "$GITHUB_STEP_SUMMARY"
95+ fi
96+ elif printf '%s\n' "$BODY" | grep -qiE "$REBASE_PATTERN" && ! printf '%s\n' "$BODY" | grep -qiE "$NEGATE_REBASE_PATTERN"; then
97+ if [ "$HAS_REBASE" = "true" ] && [ "$HAS_SQUASH" != "true" ]; then
98+ echo "Rebase label already present and matches description." >> "$GITHUB_STEP_SUMMARY"
99+ exit 0
100+ fi
101+
102+ if [ "$HAS_SQUASH" = "true" ]; then
103+ if ! gh pr edit "$PR_NUMBER" --repo "$REPO" --remove-label "Squash" > /dev/null 2>&1; then
104+ echo "Could not remove **Squash** label; skipping **Rebase** application to avoid conflict." >> "$GITHUB_STEP_SUMMARY"
105+ exit 0
106+ fi
107+ fi
108+
109+ if gh pr edit "$PR_NUMBER" --repo "$REPO" --add-label "Rebase" > /dev/null 2>&1; then
110+ echo "Applied **Rebase** label from PR description." >> "$GITHUB_STEP_SUMMARY"
111+ else
112+ echo "Could not apply **Rebase** label." >> "$GITHUB_STEP_SUMMARY"
113+ fi
114+ fi
115+
116+ exit 0
117+
118+ - name : Detect merge method labels
119+ id : labels
120+ if : ${{ !cancelled() }}
121+ env :
122+ GH_TOKEN : ${{ github.token }}
123+ run : |
124+ if ! LABEL_LINES=$(gh api "repos/$REPO/issues/$PR_NUMBER/labels" --jq '.[].name'); then
125+ echo "::warning::Failed to fetch labels from GitHub API; treating as no merge method label."
126+ echo "squash=false" >> "$GITHUB_OUTPUT"
127+ echo "rebase=false" >> "$GITHUB_OUTPUT"
128+ echo "conflict=false" >> "$GITHUB_OUTPUT"
129+ exit 0
130+ fi
131+ echo "Labels detected: $(echo "$LABEL_LINES" | tr '\n' ',')" >> "$GITHUB_STEP_SUMMARY"
132+
133+ CONFLICT="false"
134+ if printf '%s\n' "$LABEL_LINES" | grep -qx "Squash" && printf '%s\n' "$LABEL_LINES" | grep -qx "Rebase"; then
135+ echo "::error::Both Squash and Rebase labels are present. Remove one."
136+ CONFLICT="true"
137+ fi
138+
139+ if [ "$CONFLICT" = "false" ] && printf '%s\n' "$LABEL_LINES" | grep -qx "Squash"; then
140+ echo "squash=true" >> "$GITHUB_OUTPUT"
141+ echo "Detected **Squash** label: only PR title will be validated." >> "$GITHUB_STEP_SUMMARY"
142+ else
143+ echo "squash=false" >> "$GITHUB_OUTPUT"
144+ fi
145+
146+ if printf '%s\n' "$LABEL_LINES" | grep -qx "Rebase"; then
147+ echo "rebase=true" >> "$GITHUB_OUTPUT"
148+ if [ "$CONFLICT" = "false" ]; then
149+ echo "Detected **Rebase** label: all commits and PR title will be validated." >> "$GITHUB_STEP_SUMMARY"
150+ fi
151+ else
152+ echo "rebase=false" >> "$GITHUB_OUTPUT"
153+ fi
154+
155+ if [ "$CONFLICT" = "true" ]; then
156+ echo "conflict=true" >> "$GITHUB_OUTPUT"
157+ exit 1
158+ else
159+ echo "conflict=false" >> "$GITHUB_OUTPUT"
160+ fi
161+
38162 - name : Checkout base branch
163+ id : checkout
164+ if : ${{ !cancelled() }}
39165 uses : actions/checkout@v4
40166 with :
41167 ref : ${{ github.base_ref }}
42168 fetch-depth : 0
43169
44170 - name : Fetch PR head
45- run : git fetch origin "$PR_HEAD_SHA"
171+ id : fetch-pr-head
172+ if : ${{ !cancelled() && steps.checkout.outcome == 'success' && steps.labels.outputs.squash != 'true' }}
173+ run : git fetch origin "refs/pull/$PR_NUMBER/head"
46174
47175 - name : Load valid tags
48176 id : load-tags
177+ if : ${{ !cancelled() }}
49178 run : |
50179 TAGS_FILE=".github/workflows/valid-tags.txt"
51180
@@ -71,6 +200,10 @@ jobs:
71200
72201 - name : Validate PR title
73202 id : validate-title
203+ if : >-
204+ !cancelled()
205+ && steps.load-tags.outcome == 'success'
206+ && steps.labels.outputs.conflict != 'true'
74207 env :
75208 REGEX : ${{ steps.load-tags.outputs.regex }}
76209 run : |
83216 DELIM="TITLE_EOF_$(openssl rand -hex 8)"
84217 {
85218 echo "INVALID_TITLE<<$DELIM"
86- echo "$TITLE"
219+ printf '%s\n' "$TITLE"
87220 echo "$DELIM"
88221 } >> "$GITHUB_ENV"
89222 else
@@ -93,7 +226,13 @@ jobs:
93226
94227 - name : Validate PR commits
95228 id : validate-commits
96- if : (success() || failure()) && steps.load-tags.outcome == 'success'
229+ if : >-
230+ !cancelled()
231+ && steps.load-tags.outcome == 'success'
232+ && steps.labels.outputs.squash != 'true'
233+ && steps.labels.outputs.conflict != 'true'
234+ && steps.checkout.outcome == 'success'
235+ && steps.fetch-pr-head.outcome == 'success'
97236 env :
98237 REGEX : ${{ steps.load-tags.outputs.regex }}
99238 run : |
@@ -116,7 +255,7 @@ jobs:
116255 if [[ ! "$COMMIT_MSG" =~ $REGEX ]]; then
117256 echo "- ❌ Commit message \"$COMMIT_MSG\" is invalid." >> "$GITHUB_STEP_SUMMARY"
118257 INVALID_COMMITS=$((INVALID_COMMITS + 1))
119- SANITIZED_MSG=$(echo "$COMMIT_MSG" | tr -d '\`')
258+ SANITIZED_MSG=$(printf '%s\n' "$COMMIT_MSG" | tr -d '\`')
120259 INVALID_LIST="${INVALID_LIST}- \`${SANITIZED_MSG}\`"$'\n'
121260 else
122261 echo "- ✅ Commit message \"$COMMIT_MSG\" is valid." >> "$GITHUB_STEP_SUMMARY"
@@ -135,27 +274,72 @@ jobs:
135274 echo "commits-valid=true" >> "$GITHUB_OUTPUT"
136275 fi
137276
138- # Always clean up old failure comments, even when validation now passes.
277+ - name : Determine validation result
278+ id : result
279+ if : ${{ !cancelled() }}
280+ env :
281+ LABEL_CONFLICT : ${{ steps.labels.outputs.conflict }}
282+ TITLE_OUTCOME : ${{ steps.validate-title.outcome }}
283+ TITLE_VALID : ${{ steps.validate-title.outputs.title-valid }}
284+ COMMITS_OUTCOME : ${{ steps.validate-commits.outcome }}
285+ COMMITS_VALID : ${{ steps.validate-commits.outputs.commits-valid }}
286+ CHECKOUT_OUTCOME : ${{ steps.checkout.outcome }}
287+ FETCH_OUTCOME : ${{ steps.fetch-pr-head.outcome }}
288+ LOAD_TAGS_OUTCOME : ${{ steps.load-tags.outcome }}
289+ run : |
290+ TITLE_FAILED="false"
291+ COMMITS_FAILED="false"
292+ INFRA_FAILED="false"
293+
294+ if [[ "$CHECKOUT_OUTCOME" != "success" || "$LOAD_TAGS_OUTCOME" != "success" || ( "$FETCH_OUTCOME" != "success" && "$FETCH_OUTCOME" != "skipped" ) ]]; then
295+ INFRA_FAILED="true"
296+ fi
297+
298+ if [[ "$TITLE_OUTCOME" == "failure" || ( "$TITLE_OUTCOME" == "success" && "$TITLE_VALID" != "true" ) ]]; then
299+ TITLE_FAILED="true"
300+ fi
301+
302+ if [[ "$COMMITS_OUTCOME" == "failure" || ( "$COMMITS_OUTCOME" == "success" && "$COMMITS_VALID" != "true" ) ]]; then
303+ COMMITS_FAILED="true"
304+ fi
305+
306+ if [[ "$TITLE_FAILED" == "true" || "$COMMITS_FAILED" == "true" || "$LABEL_CONFLICT" == "true" || "$INFRA_FAILED" == "true" ]]; then
307+ echo "failed=true" >> "$GITHUB_OUTPUT"
308+ else
309+ echo "failed=false" >> "$GITHUB_OUTPUT"
310+ fi
311+ echo "conflict=$LABEL_CONFLICT" >> "$GITHUB_OUTPUT"
312+
139313 - name : Delete stale bot comments
140- if : always() && steps.load-tags.outcome == 'success'
314+ if : >-
315+ !cancelled()
316+ && env.POST_VALIDATE_COMMENT != 'false'
317+ && steps.result.outcome == 'success'
141318 env :
142319 GH_TOKEN : ${{ github.token }}
143320 run : |
144321 gh api --paginate "repos/$REPO/issues/$PR_NUMBER/comments" \
145- --jq '.[] | select(.user.login == "github-actions[bot]" and (.body | startswith("### ⚠️ Title/Commit Validation Failed"))) | .id' \
322+ --jq '.[] | select(.user.login == "github-actions[bot]" and (.body | startswith("### 🚩 PR Validation Failed"))) | .id' \
146323 | while read -r comment_id; do
147324 gh api -X DELETE "repos/$REPO/issues/comments/$comment_id" || true
148325 done || true
149326
150- # Post a new failure comment with details on what's wrong.
151327 - name : Comment on PR if validation failed
152- if : always() && steps.load-tags.outcome == 'success' && (steps.validate-title.outputs.title-valid != 'true' || steps.validate-commits.outputs.commits-valid != 'true')
328+ if : >-
329+ !cancelled()
330+ && env.POST_VALIDATE_COMMENT != 'false'
331+ && steps.result.outcome == 'success'
332+ && steps.result.outputs.failed == 'true'
153333 env :
154334 GH_TOKEN : ${{ github.token }}
155335 VALID_TAGS_RAW : ${{ steps.load-tags.outputs.valid-tags }}
336+ IS_SQUASH : ${{ steps.labels.outputs.squash }}
337+ IS_REBASE : ${{ steps.labels.outputs.rebase }}
338+ IS_CONFLICT : ${{ steps.result.outputs.conflict }}
339+ RUN_URL : ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
156340 run : |
157341 VALID_TAGS=$(echo "$VALID_TAGS_RAW" | sed 's/[^, ][^, ]*/`&`/g')
158- BODY="### ⚠️ Title/Commit Validation Failed"
342+ BODY="### 🚩 PR Validation Failed"
159343
160344 if [[ -n "$INVALID_TITLE" ]]; then
161345 SANITIZED_TITLE=$(echo "$INVALID_TITLE" | tr -d '\`')
@@ -168,19 +352,38 @@ jobs:
168352 BODY="$BODY"$'\n'"$INVALID_COMMITS_LIST"
169353 fi
170354
171- BODY="$BODY"$'\n'"PR titles and commit messages must follow [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/) format:"
172- BODY="$BODY"$'\n'"\`\`\`"
173- BODY="$BODY"$'\n'"type: Description"
174- BODY="$BODY"$'\n'"type(scope): Description"
175- BODY="$BODY"$'\n'"\`\`\`"
176- BODY="$BODY"$'\n\n'"**Allowed types:** $VALID_TAGS"
177- BODY="$BODY"$'\n\n'"See [CONTRIBUTING.md](https://github.com/$REPO/blob/$BASE_REF/CONTRIBUTING.md#pull-request-documentation) for details."
355+ if [[ -n "$INVALID_TITLE" || -n "$INVALID_COMMITS_LIST" ]]; then
356+ BODY="$BODY"$'\n\n'"PR titles and commit messages must follow [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/) format:"
357+ BODY="$BODY"$'\n'"\`\`\`"
358+ BODY="$BODY"$'\n'"type: Description"
359+ BODY="$BODY"$'\n'"type(scope): Description"
360+ BODY="$BODY"$'\n'"\`\`\`"
361+ BODY="$BODY"$'\n\n'"**Allowed types:** $VALID_TAGS"
362+ fi
363+
364+ if [[ "$IS_CONFLICT" == "true" ]]; then
365+ BODY="$BODY"$'\n\n'"⚠️ Both \`Squash\` and \`Rebase\` labels are present. Remove one to proceed."
366+ fi
367+
368+ if [[ "$IS_CONFLICT" != "true" && "$IS_SQUASH" != "true" && "$IS_REBASE" != "true" && -n "$INVALID_COMMITS_LIST" ]]; then
369+ BODY="$BODY"$'\n\n'"ℹ️ Add the \`Squash\` label to skip commit validation."
370+ fi
371+
372+ if [[ -z "$INVALID_TITLE" && -z "$INVALID_COMMITS_LIST" && "$IS_CONFLICT" != "true" ]]; then
373+ BODY="$BODY"$'\n\n'"🚨 An internal error occurred during validation. Check the [workflow run logs]($RUN_URL) for details."
374+ fi
375+
376+ if [[ -n "$INVALID_TITLE" || -n "$INVALID_COMMITS_LIST" ]]; then
377+ BODY="$BODY"$'\n\n'"See [CONTRIBUTING.md](https://github.com/$REPO/blob/$BASE_REF/CONTRIBUTING.md#pull-request-documentation) for details."
378+ fi
178379
179380 gh pr comment "$PR_NUMBER" \
180381 --repo "$REPO" \
181382 --body "$BODY"
182383
183- # Separate fail step so the comment is always posted before the job fails.
184384 - name : Fail if validation did not pass
185- if : always() && steps.load-tags.outcome == 'success' && (steps.validate-title.outputs.title-valid != 'true' || steps.validate-commits.outputs.commits-valid != 'true')
385+ if : >-
386+ !cancelled()
387+ && steps.result.outcome == 'success'
388+ && steps.result.outputs.failed == 'true'
186389 run : exit 1
0 commit comments