From 2c67536cd217e41eb0545ff8dd39fd2e9cf6fdac Mon Sep 17 00:00:00 2001 From: Rahul Raj Date: Mon, 3 Aug 2026 15:15:49 +0200 Subject: [PATCH] ci: make the trust gate a job that says why it declined MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A MEMBER opened three issues and all three runs came back "skipped". As a one-line `if:` on the work itself there is no way to tell a correctly-declined run from a broken expression — the log is empty either way, and the only evidence is that nothing happened. The gate is now its own job. It prints the event, the author association, and the decision as a notice, so a skipped run explains itself. The rule is unchanged: same-repository pull requests, and comments or issues from OWNER, MEMBER or COLLABORATOR. --- .github/workflows/forge-issues.yml | 40 ++++++++++++++++----- .github/workflows/forge.yml | 56 ++++++++++++++++++++++++------ 2 files changed, 77 insertions(+), 19 deletions(-) diff --git a/.github/workflows/forge-issues.yml b/.github/workflows/forge-issues.yml index 7098400..a96674b 100644 --- a/.github/workflows/forge-issues.yml +++ b/.github/workflows/forge-issues.yml @@ -53,15 +53,39 @@ concurrency: cancel-in-progress: false jobs: + # The gate is its own job so it can say why it declined. As a one-line `if:` + # on the work itself, a skipped run is indistinguishable from a broken + # expression — which is exactly what happened: a MEMBER's issue was skipped + # and there was nothing anywhere saying which clause was false. + gate: + runs-on: ubuntu-latest + outputs: + allowed: ${{ steps.check.outputs.allowed }} + steps: + - id: check + env: + EVENT: ${{ github.event_name }} + ASSOC: ${{ github.event.issue.author_association }} + COMMENT_ASSOC: ${{ github.event.comment.author_association }} + IS_PR: ${{ github.event.issue.pull_request != null }} + run: | + assoc="$ASSOC" + [ "$EVENT" = "issue_comment" ] && assoc="$COMMENT_ASSOC" + + allowed=false + case "$assoc" in OWNER|MEMBER|COLLABORATOR) allowed=true ;; esac + # Comments on pull requests belong to the PR workflow. + [ "$IS_PR" = "true" ] && allowed=false + + echo "allowed=$allowed" >> "$GITHUB_OUTPUT" + echo "::notice::event=$EVENT author_association=$assoc on_pull_request=$IS_PR → allowed=$allowed" + if [ "$allowed" != "true" ]; then + echo "::notice::Declined. Only OWNER, MEMBER or COLLABORATOR can start a run on a public repository." + fi + analyze: - # Issues only — comments on pull requests belong to the PR workflow — and - # only from an author the repository already trusts. - if: >- - ${{ !github.event.issue.pull_request - && ((github.event_name == 'issues' - && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.issue.author_association)) - || (github.event_name == 'issue_comment' - && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association))) }} + needs: gate + if: needs.gate.outputs.allowed == 'true' runs-on: ubuntu-latest steps: diff --git a/.github/workflows/forge.yml b/.github/workflows/forge.yml index 01ab5f7..ab2c076 100644 --- a/.github/workflows/forge.yml +++ b/.github/workflows/forge.yml @@ -81,18 +81,52 @@ concurrency: cancel-in-progress: false jobs: + # The gate is its own job so it can say why it declined. As a one-line `if:` + # on the work itself, a skipped run is indistinguishable from a broken + # expression, and there is nothing in the log to tell them apart. + gate: + runs-on: ubuntu-latest + outputs: + allowed: ${{ steps.check.outputs.allowed }} + steps: + - id: check + env: + EVENT: ${{ github.event_name }} + ASSOC: ${{ github.event.comment.author_association }} + HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + THIS_REPO: ${{ github.repository }} + IS_PR_COMMENT: ${{ github.event.issue.pull_request != null }} + run: | + allowed=false + case "$EVENT" in + pull_request) + # A fork PR gets no secrets anyway; failing on every drive-by PR + # is noise, so decline it quietly here instead. + [ "$HEAD_REPO" = "$THIS_REPO" ] && allowed=true + reason="head=$HEAD_REPO this=$THIS_REPO" + ;; + pull_request_review_comment) + case "$ASSOC" in OWNER|MEMBER|COLLABORATOR) allowed=true ;; esac + reason="association=$ASSOC" + ;; + issue_comment) + # Only comments on pull requests; plain issues are the other workflow. + if [ "$IS_PR_COMMENT" = "true" ]; then + case "$ASSOC" in OWNER|MEMBER|COLLABORATOR) allowed=true ;; esac + fi + reason="association=$ASSOC on_pull_request=$IS_PR_COMMENT" + ;; + esac + + echo "allowed=$allowed" >> "$GITHUB_OUTPUT" + echo "::notice::event=$EVENT $reason → allowed=$allowed" + if [ "$allowed" != "true" ]; then + echo "::notice::Declined. Same-repository pull requests, and comments from OWNER, MEMBER or COLLABORATOR only." + fi + review: - # Two gates, both required on a public repository (see the note above): - # 1. same-repository PRs only — a fork PR gets no secrets anyway, and - # failing loudly on every drive-by PR is just noise. - # 2. trusted authors only — OWNER, MEMBER or COLLABORATOR. Anyone else - # commenting "/review" is a stranger spending your credits. - if: >- - ${{ (github.event_name == 'pull_request' - && github.event.pull_request.head.repo.full_name == github.repository) - || ((github.event_name == 'pull_request_review_comment' - || (github.event_name == 'issue_comment' && github.event.issue.pull_request)) - && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)) }} + needs: gate + if: needs.gate.outputs.allowed == 'true' runs-on: ubuntu-latest steps: