Skip to content

Commit 3cb6871

Browse files
committed
ci(metadata): Add merge method labels and description-based auto-labeling
1 parent 39647c3 commit 3cb6871

1 file changed

Lines changed: 227 additions & 24 deletions

File tree

.github/workflows/validate-pull-request.yml

Lines changed: 227 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
name: Validate Pull Request
22

3-
# Minimal permissions: read code, write PR comments.
43
permissions:
54
contents: read
65
pull-requests: write
76

8-
# Uses pull_request_target to access secrets for PR comments on forks.
97
on:
108
pull_request_target:
119
branches:
@@ -15,37 +13,168 @@ on:
1513
- edited
1614
- synchronize
1715
- reopened
16+
- labeled
17+
- unlabeled
1818

19-
# Avoid racing on PR comments when multiple events fire quickly.
2019
concurrency:
2120
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
2221
cancel-in-progress: true
2322

2423
jobs:
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: |
@@ -83,7 +216,7 @@ jobs:
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

Comments
 (0)