|
| 1 | +name: Authorize PR |
| 2 | +description: > |
| 3 | + Security gate for pull_request_target workflows only. Checks actor |
| 4 | + write permission, author association, and label presence for external |
| 5 | + fork PRs. Strips the gate label on synchronize events from non-writers |
| 6 | + to force re-review after new commits. |
| 7 | +
|
| 8 | +inputs: |
| 9 | + label: |
| 10 | + description: "Label name required for external fork PRs (default: verify)" |
| 11 | + required: false |
| 12 | + default: "verify" |
| 13 | + github-token: |
| 14 | + description: "GitHub token for permission check and label API calls" |
| 15 | + required: true |
| 16 | + |
| 17 | +outputs: |
| 18 | + allowed: |
| 19 | + description: "true when the workflow is authorized to proceed" |
| 20 | + value: ${{ steps.check.outputs.allowed }} |
| 21 | + |
| 22 | +runs: |
| 23 | + using: composite |
| 24 | + steps: |
| 25 | + # ----------------------------------------------------------------- |
| 26 | + # 1. Resolve actor permission level |
| 27 | + # ----------------------------------------------------------------- |
| 28 | + - name: Check actor write permission |
| 29 | + id: perm |
| 30 | + uses: scherermichael-oss/action-has-permission@136e061bfe093832d87f090dd768e14e27a740d3 # 1.0.6 |
| 31 | + with: |
| 32 | + required-permission: write |
| 33 | + env: |
| 34 | + GITHUB_TOKEN: ${{ inputs.github-token }} |
| 35 | + |
| 36 | + # ----------------------------------------------------------------- |
| 37 | + # 2. Decide whether the run is authorized |
| 38 | + # ----------------------------------------------------------------- |
| 39 | + - name: Check authorization |
| 40 | + id: check |
| 41 | + shell: bash |
| 42 | + env: |
| 43 | + EVENT: ${{ github.event_name }} |
| 44 | + ACTION: ${{ github.event.action }} |
| 45 | + HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} |
| 46 | + BASE_REPO: ${{ github.repository }} |
| 47 | + AUTHOR_ASSOC: ${{ github.event.pull_request.author_association }} |
| 48 | + HAS_WRITE: ${{ steps.perm.outputs.has-permission }} |
| 49 | + LABEL_NAME: ${{ inputs.label }} |
| 50 | + LABELS_JSON: ${{ toJSON(github.event.pull_request.labels.*.name) }} |
| 51 | + run: | |
| 52 | + # Non-PR events (push, workflow_dispatch, workflow_call) are always trusted |
| 53 | + if [ "$EVENT" != "pull_request_target" ]; then |
| 54 | + echo "::notice::Non-PR event ($EVENT) — authorized" |
| 55 | + echo "allowed=true" >> "$GITHUB_OUTPUT" |
| 56 | + exit 0 |
| 57 | + fi |
| 58 | +
|
| 59 | + # Same-repo PRs are always trusted |
| 60 | + if [ "$HEAD_REPO" = "$BASE_REPO" ]; then |
| 61 | + echo "::notice::Same-repo PR — authorized" |
| 62 | + echo "allowed=true" >> "$GITHUB_OUTPUT" |
| 63 | + exit 0 |
| 64 | + fi |
| 65 | +
|
| 66 | + echo "::notice::Actor '$GITHUB_ACTOR' write-or-higher: $HAS_WRITE" |
| 67 | +
|
| 68 | + # Actor with write (or higher) is trusted even from a fork |
| 69 | + if [ "$HAS_WRITE" = "1" ]; then |
| 70 | + echo "::notice::Actor has write-or-higher permission — authorized" |
| 71 | + echo "allowed=true" >> "$GITHUB_OUTPUT" |
| 72 | + exit 0 |
| 73 | + fi |
| 74 | +
|
| 75 | + # Org members / collaborators are trusted even from forks |
| 76 | + if [[ "$AUTHOR_ASSOC" =~ ^(MEMBER|OWNER|COLLABORATOR)$ ]]; then |
| 77 | + echo "::notice::Trusted author ($AUTHOR_ASSOC) — authorized" |
| 78 | + echo "allowed=true" >> "$GITHUB_OUTPUT" |
| 79 | + exit 0 |
| 80 | + fi |
| 81 | +
|
| 82 | + # External fork: require the gate label |
| 83 | + HAS_LABEL=$(echo "$LABELS_JSON" | jq -r --arg l "$LABEL_NAME" \ |
| 84 | + 'if . == null then "false" elif any(. == $l) then "true" else "false" end') |
| 85 | +
|
| 86 | + # On synchronize from a non-writer, reject even if the label is still |
| 87 | + # present. The label was granted for the previous commit; new unreviewed |
| 88 | + # code must not run with secrets. The strip step below removes the label |
| 89 | + # so that a re-review + re-label is required for the next run. |
| 90 | + if [ "$ACTION" = "synchronize" ]; then |
| 91 | + echo "::warning::External fork synchronize from non-writer — blocking until re-review" |
| 92 | + echo "allowed=false" >> "$GITHUB_OUTPUT" |
| 93 | + exit 0 |
| 94 | + fi |
| 95 | +
|
| 96 | + if [ "$HAS_LABEL" = "true" ]; then |
| 97 | + echo "::notice::Fork PR with '$LABEL_NAME' label — authorized" |
| 98 | + echo "allowed=true" >> "$GITHUB_OUTPUT" |
| 99 | + else |
| 100 | + echo "::warning::External fork PR without '$LABEL_NAME' label — skipping" |
| 101 | + echo "::warning::A team member must review the code and apply the '$LABEL_NAME' label" |
| 102 | + echo "allowed=false" >> "$GITHUB_OUTPUT" |
| 103 | + fi |
| 104 | +
|
| 105 | + # ----------------------------------------------------------------- |
| 106 | + # 3. Strip the gate label on synchronize from non-writers |
| 107 | + # This forces a maintainer to re-review before the NEXT run. |
| 108 | + # ----------------------------------------------------------------- |
| 109 | + - name: Strip label on new pushes from non-writers |
| 110 | + if: | |
| 111 | + steps.perm.outputs.has-permission != '1' && |
| 112 | + github.event.action == 'synchronize' && |
| 113 | + github.event.pull_request.head.repo.full_name != github.repository |
| 114 | + shell: bash |
| 115 | + env: |
| 116 | + GH_TOKEN: ${{ inputs.github-token }} |
| 117 | + PR_NUMBER: ${{ github.event.pull_request.number }} |
| 118 | + LABEL: ${{ inputs.label }} |
| 119 | + run: | |
| 120 | + gh api -X DELETE \ |
| 121 | + "repos/${{ github.repository }}/issues/${PR_NUMBER}/labels/${LABEL}" \ |
| 122 | + 2>/dev/null || true |
| 123 | + echo "::warning::Removed '${LABEL}' label — new commits from non-writer. Re-review required." |
0 commit comments