|
1 | 1 | name: Validate Pull Request |
2 | 2 |
|
| 3 | +# Minimal permissions: read code, write PR comments. |
3 | 4 | permissions: |
4 | 5 | contents: read |
5 | 6 | pull-requests: write |
6 | 7 |
|
| 8 | +# Uses pull_request_target to access secrets for PR comments on forks. |
7 | 9 | on: |
8 | | - pull_request: |
| 10 | + pull_request_target: |
9 | 11 | branches: |
10 | 12 | - main |
11 | 13 | types: |
|
14 | 16 | - synchronize |
15 | 17 | - reopened |
16 | 18 |
|
| 19 | +# Avoid racing on PR comments when multiple events fire quickly. |
| 20 | +concurrency: |
| 21 | + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} |
| 22 | + cancel-in-progress: true |
| 23 | + |
17 | 24 | jobs: |
18 | 25 | validate-title-and-commits: |
19 | 26 | name: Validate Title and Commits |
20 | | - runs-on: ubuntu-latest |
21 | | - timeout-minutes: 5 |
| 27 | + runs-on: ubuntu-slim |
| 28 | + timeout-minutes: 3 |
| 29 | + |
| 30 | + # Expose context as env vars to avoid inline ${{ }} in run blocks (injection hardening). |
| 31 | + env: |
| 32 | + PR_NUMBER: ${{ github.event.pull_request.number }} |
| 33 | + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} |
| 34 | + BASE_REF: ${{ github.base_ref }} |
| 35 | + REPO: ${{ github.repository }} |
22 | 36 |
|
23 | 37 | steps: |
24 | | - - name: Checkout Code |
| 38 | + - name: Checkout base branch |
25 | 39 | uses: actions/checkout@v4 |
| 40 | + with: |
| 41 | + ref: ${{ github.base_ref }} |
| 42 | + fetch-depth: 0 |
| 43 | + |
| 44 | + - name: Fetch PR head |
| 45 | + run: git fetch origin "$PR_HEAD_SHA" |
26 | 46 |
|
27 | 47 | - name: Load valid tags |
28 | 48 | id: load-tags |
29 | 49 | run: | |
30 | | - if [ ! -f ./.github/workflows/valid-tags.txt ]; then |
31 | | - echo "::error::.github/workflows/valid-tags.txt file not found" |
| 50 | + TAGS_FILE=".github/workflows/valid-tags.txt" |
| 51 | +
|
| 52 | + if [ ! -f "$TAGS_FILE" ]; then |
| 53 | + echo "::error::$TAGS_FILE file not found" |
32 | 54 | exit 1 |
33 | 55 | fi |
34 | 56 |
|
35 | | - echo "**Valid tags**: $(cat ./.github/workflows/valid-tags.txt | tr '\n' ',' | sed 's/,/, /g' | sed 's/, $//')" >> $GITHUB_STEP_SUMMARY |
| 57 | + # Normalize line endings and remove empty lines |
| 58 | + TAGS=$(tr -d '\r' < "$TAGS_FILE" | sed '/^$/d') |
36 | 59 |
|
37 | | - TAG_REGEX=$(sed 's/[]\/$*.^|[]/\\&/g' ./.github/workflows/valid-tags.txt | paste -sd "|" -) |
| 60 | + VALID_TAGS=$(echo "$TAGS" | tr '\n' ',' | sed 's/,$//; s/,/, /g') |
| 61 | + echo "**Valid tags**: $VALID_TAGS" >> "$GITHUB_STEP_SUMMARY" |
| 62 | + echo "valid-tags=$VALID_TAGS" >> "$GITHUB_OUTPUT" |
38 | 63 |
|
39 | | - # Matches: |
40 | | - # 1) One or more valid tags at the beginning, directly adjacent, followed by at least one space, then text |
41 | | - # 2) Text that does not start with '[' (i.e. no tags) |
42 | | - echo "regex=^((($TAG_REGEX)){1,}[[:space:]]+.*|[^[]+.*)$" >> $GITHUB_OUTPUT |
| 64 | + TAG_REGEX=$(echo "$TAGS" | paste -sd "|" -) |
43 | 65 |
|
44 | | - echo "Built the regex: ^((($TAG_REGEX)){1,}[[:space:]]+.*|[^[]+.*)$" |
| 66 | + # Matches: |
| 67 | + # Conventional commit: type or type(scope) followed by colon, single space, then uppercase text |
| 68 | + REGEX="^(($TAG_REGEX)(\\([^)]+\\))?: [A-Z].*)$" |
| 69 | + echo "regex=$REGEX" >> "$GITHUB_OUTPUT" |
| 70 | + echo "Built the regex: $REGEX" |
45 | 71 |
|
46 | 72 | - name: Validate PR title |
47 | | - id: validate_title |
| 73 | + id: validate-title |
| 74 | + env: |
| 75 | + REGEX: ${{ steps.load-tags.outputs.regex }} |
48 | 76 | run: | |
49 | | - echo "### Validate PR Title" >> $GITHUB_STEP_SUMMARY |
50 | | - REGEX="${{ steps.load-tags.outputs.regex }}" |
| 77 | + echo "### Validate PR Title" >> "$GITHUB_STEP_SUMMARY" |
51 | 78 | TITLE=$(jq -r '.pull_request.title // "No title found"' "$GITHUB_EVENT_PATH") |
52 | 79 |
|
53 | | - ESCAPED_TITLE=$(echo "$TITLE" | sed 's/["\`\\$]/\\&/g') |
54 | | -
|
55 | 80 | if [[ ! "$TITLE" =~ $REGEX ]]; then |
56 | | - echo "- ❌ PR title \"$ESCAPED_TITLE\" is invalid." >> $GITHUB_STEP_SUMMARY |
57 | | - echo "TITLE_VALID=false" >> $GITHUB_OUTPUT |
| 81 | + echo "- ❌ PR title \"$TITLE\" is invalid." >> "$GITHUB_STEP_SUMMARY" |
| 82 | + echo "title-valid=false" >> "$GITHUB_OUTPUT" |
| 83 | + DELIM="TITLE_EOF_$(openssl rand -hex 8)" |
| 84 | + { |
| 85 | + echo "INVALID_TITLE<<$DELIM" |
| 86 | + echo "$TITLE" |
| 87 | + echo "$DELIM" |
| 88 | + } >> "$GITHUB_ENV" |
58 | 89 | else |
59 | | - echo "- ✅ PR title \"$ESCAPED_TITLE\" is valid." >> $GITHUB_STEP_SUMMARY |
60 | | - echo "TITLE_VALID=true" >> $GITHUB_OUTPUT |
| 90 | + echo "- ✅ PR title \"$TITLE\" is valid." >> "$GITHUB_STEP_SUMMARY" |
| 91 | + echo "title-valid=true" >> "$GITHUB_OUTPUT" |
61 | 92 | fi |
62 | 93 |
|
63 | 94 | - name: Validate PR commits |
64 | | - id: validate_commits |
| 95 | + id: validate-commits |
| 96 | + if: (success() || failure()) && steps.load-tags.outcome == 'success' |
| 97 | + env: |
| 98 | + REGEX: ${{ steps.load-tags.outputs.regex }} |
65 | 99 | run: | |
66 | | - echo "### Validate PR Commits" >> $GITHUB_STEP_SUMMARY |
67 | | - REGEX="${{ steps.load-tags.outputs.regex }}" |
68 | | - git fetch --prune --unshallow origin ${{ github.base_ref }} |
69 | | - COMMITS=$(git log origin/${{ github.base_ref }}..HEAD --pretty=format:"%s" --no-merges) |
| 100 | + echo "### Validate PR Commits" >> "$GITHUB_STEP_SUMMARY" |
| 101 | + COMMITS=$(git log "$BASE_REF".."$PR_HEAD_SHA" --pretty=format:"%s" --no-merges) |
70 | 102 |
|
71 | | - INVALID_COMMITS=0 |
72 | | -
|
73 | | - while read -r COMMIT_MSG; do |
74 | | - if [[ -z "$COMMIT_MSG" ]]; then |
75 | | - continue |
76 | | - fi |
77 | | - ESCAPED_MSG=$(echo "$COMMIT_MSG" | sed 's/["\`\\$]/\\&/g') |
78 | | - if [[ ! "$COMMIT_MSG" =~ $REGEX ]]; then |
79 | | - echo "- ❌ Commit message \"$ESCAPED_MSG\" is invalid." >> $GITHUB_STEP_SUMMARY |
80 | | - INVALID_COMMITS=$((INVALID_COMMITS+1)) |
81 | | - else |
82 | | - echo "- ✅ Commit message \"$ESCAPED_MSG\" is valid." >> $GITHUB_STEP_SUMMARY |
| 103 | + if [[ -z "$COMMITS" ]]; then |
| 104 | + echo "- ⚠️ No non-merge commits found." >> "$GITHUB_STEP_SUMMARY" |
| 105 | + echo "commits-valid=true" >> "$GITHUB_OUTPUT" |
| 106 | + exit 0 |
83 | 107 | fi |
| 108 | +
|
| 109 | + INVALID_COMMITS=0 |
| 110 | + INVALID_LIST="" |
| 111 | +
|
| 112 | + while IFS= read -r COMMIT_MSG; do |
| 113 | + if [[ -z "$COMMIT_MSG" ]]; then |
| 114 | + continue |
| 115 | + fi |
| 116 | + if [[ ! "$COMMIT_MSG" =~ $REGEX ]]; then |
| 117 | + echo "- ❌ Commit message \"$COMMIT_MSG\" is invalid." >> "$GITHUB_STEP_SUMMARY" |
| 118 | + INVALID_COMMITS=$((INVALID_COMMITS + 1)) |
| 119 | + SANITIZED_MSG=$(echo "$COMMIT_MSG" | tr -d '\`') |
| 120 | + INVALID_LIST="${INVALID_LIST}- \`${SANITIZED_MSG}\`"$'\n' |
| 121 | + else |
| 122 | + echo "- ✅ Commit message \"$COMMIT_MSG\" is valid." >> "$GITHUB_STEP_SUMMARY" |
| 123 | + fi |
84 | 124 | done <<< "$COMMITS" |
85 | 125 |
|
86 | 126 | if [[ $INVALID_COMMITS -gt 0 ]]; then |
87 | | - echo "COMMITS_VALID=false" >> $GITHUB_OUTPUT |
| 127 | + echo "commits-valid=false" >> "$GITHUB_OUTPUT" |
| 128 | + DELIM="COMMITS_EOF_$(openssl rand -hex 8)" |
| 129 | + { |
| 130 | + echo "INVALID_COMMITS_LIST<<$DELIM" |
| 131 | + printf '%s' "$INVALID_LIST" |
| 132 | + echo "$DELIM" |
| 133 | + } >> "$GITHUB_ENV" |
88 | 134 | else |
89 | | - echo "COMMITS_VALID=true" >> $GITHUB_OUTPUT |
| 135 | + echo "commits-valid=true" >> "$GITHUB_OUTPUT" |
90 | 136 | fi |
91 | 137 |
|
92 | | - - name: Validation return code |
| 138 | + # Always clean up old failure comments, even when validation now passes. |
| 139 | + - name: Delete stale bot comments |
| 140 | + if: always() && steps.load-tags.outcome == 'success' |
| 141 | + env: |
| 142 | + GH_TOKEN: ${{ github.token }} |
93 | 143 | run: | |
94 | | - if [[ "${{ steps.validate_title.outputs.TITLE_VALID }}" != "true" || "${{ steps.validate_commits.outputs.COMMITS_VALID }}" != "true" ]]; then |
95 | | - exit 1 |
| 144 | + 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' \ |
| 146 | + | while read -r comment_id; do |
| 147 | + gh api -X DELETE "repos/$REPO/issues/comments/$comment_id" || true |
| 148 | + done || true |
| 149 | +
|
| 150 | + # Post a new failure comment with details on what's wrong. |
| 151 | + - 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') |
| 153 | + env: |
| 154 | + GH_TOKEN: ${{ github.token }} |
| 155 | + VALID_TAGS_RAW: ${{ steps.load-tags.outputs.valid-tags }} |
| 156 | + run: | |
| 157 | + VALID_TAGS=$(echo "$VALID_TAGS_RAW" | sed 's/[^, ][^, ]*/`&`/g') |
| 158 | + BODY="### ⚠️ Title/Commit Validation Failed" |
| 159 | +
|
| 160 | + if [[ -n "$INVALID_TITLE" ]]; then |
| 161 | + SANITIZED_TITLE=$(echo "$INVALID_TITLE" | tr -d '\`') |
| 162 | + BODY="$BODY"$'\n\n'"**Invalid PR title:**" |
| 163 | + BODY="$BODY"$'\n'"- \`$SANITIZED_TITLE\`" |
| 164 | + fi |
| 165 | +
|
| 166 | + if [[ -n "$INVALID_COMMITS_LIST" ]]; then |
| 167 | + BODY="$BODY"$'\n\n'"**Invalid commit messages:**" |
| 168 | + BODY="$BODY"$'\n'"$INVALID_COMMITS_LIST" |
96 | 169 | fi |
| 170 | +
|
| 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." |
| 178 | +
|
| 179 | + gh pr comment "$PR_NUMBER" \ |
| 180 | + --repo "$REPO" \ |
| 181 | + --body "$BODY" |
| 182 | +
|
| 183 | + # Separate fail step so the comment is always posted before the job fails. |
| 184 | + - 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') |
| 186 | + run: exit 1 |
0 commit comments