From f95676c20b6590e1f81b1a3387cc5391fd307d09 Mon Sep 17 00:00:00 2001 From: Samuel Stegall Date: Sun, 31 May 2026 16:48:55 -0500 Subject: [PATCH] ci: announce raised + merged pull requests to Discord MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add .github/workflows/announce-pull-request.yml: posts to the PR- announcements Discord channel when a PR is raised (opened/reopened) and when a PR is merged, pinging the maintainer role with the title, source/ destination branch labels, and the PR link. Port of the garlemald-server workflow; only the project name differs ("Garlemald Client"). Design notes: - pull_request_target (not pull_request) so the webhook secret is available for fork PRs and no first-time-contributor approval gate blocks the announcement. The workflow never checks out or runs PR code — it only reads event metadata and POSTs to Discord — so the usual pull_request_target code-execution risk does not apply. - Webhook from the DISCORD_PR_WEBHOOK_URL repo secret (never hardcoded: a public webhook URL gets auto-revoked by Discord's scanner). - Payload built with jq --arg (no raw interpolation -> no invalid-JSON on quoted titles), and no literal ${{ }} appears inside the run block. - allowed_mentions {parse: [], roles: []} so an attacker- controlled PR title can only ever ping the one maintainer role. - permissions: {} (GITHUB_TOKEN unused); Discord failure is non-fatal. Validated: yaml parse, no ${{ in run block, hostile-title payload is valid JSON with inert mentions, and actionlint clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/announce-pull-request.yml | 84 +++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 .github/workflows/announce-pull-request.yml diff --git a/.github/workflows/announce-pull-request.yml b/.github/workflows/announce-pull-request.yml new file mode 100644 index 0000000..c6774a9 --- /dev/null +++ b/.github/workflows/announce-pull-request.yml @@ -0,0 +1,84 @@ +name: Announce pull request + +# Posts a Discord announcement when a pull request is RAISED (opened/reopened) +# and when it is MERGED, to the PR-announcements channel via webhook. +# +# Why `pull_request_target` (NOT `pull_request`): the contribution model here is +# fork -> PR. A `pull_request` run triggered from a fork runs WITHOUT repo +# secrets and, for first-time contributors, requires a maintainer to approve the +# run — so the webhook secret would be missing and the announcement would +# silently never fire. `pull_request_target` runs in the BASE-repo context, so +# it HAS the secret and needs no approval. +# +# SAFETY: this workflow NEVER checks out or executes the PR head/code. It only +# reads event metadata (title, head/base label, URL, merged flag) and POSTs to +# Discord. The usual `pull_request_target` code-execution risk (running +# attacker-controlled code with secrets in scope) therefore does not apply. +on: + pull_request_target: + types: [opened, reopened, closed] + +# Maintainer role pinged for PR announcements. Defined once here so it's +# easy to change in a single place. +env: + MAINTAINER_ROLE_ID: "1510751664347283556" + +# Serialize duplicate events for the same PR + action so we never double-post. +concurrency: + group: announce-pr-${{ github.event.pull_request.number }}-${{ github.event.action }} + cancel-in-progress: false + +# Least privilege: the GITHUB_TOKEN is never used (we only read the event +# payload and POST to an external webhook), so request no scopes at all. +permissions: {} + +jobs: + announce: + # Announce on raise (opened/reopened) and on merge only. A closed-WITHOUT-merge + # PR must NOT be announced. + if: github.event.action == 'opened' || github.event.action == 'reopened' || (github.event.action == 'closed' && github.event.pull_request.merged == true) + runs-on: ubuntu-latest + steps: + - name: Announce the pull request on Discord + env: + DISCORD_WEBHOOK: ${{ secrets.DISCORD_PR_WEBHOOK_URL }} + ROLE_ID: ${{ env.MAINTAINER_ROLE_ID }} + PR_TITLE: ${{ github.event.pull_request.title }} + # GitHub already formats head/base label as `owner:ref`. + SOURCE_LABEL: ${{ github.event.pull_request.head.label }} + DEST_LABEL: ${{ github.event.pull_request.base.label }} + PR_URL: ${{ github.event.pull_request.html_url }} + ACTION: ${{ github.event.action }} + MERGED: ${{ github.event.pull_request.merged }} + run: | + set -euo pipefail + if [ -z "${DISCORD_WEBHOOK:-}" ]; then + echo "::warning::DISCORD_PR_WEBHOOK_URL secret is not set — skipping the Discord announcement." + exit 0 + fi + # Verb: "merged" on a merged close, "raised" otherwise (opened/reopened). + # The job-level if already filters out closed-without-merge events. + if [ "$ACTION" = "closed" ] && [ "$MERGED" = "true" ]; then + verb="merged" + else + verb="raised" + fi + # Build the content text. Branch labels are already `username:branchname`. + content="$(printf '<@&%s>, Pull Request %s for Garlemald Client.\nTitle: %s\nSource Branch: %s\nDestination Branch: %s\n%s' \ + "$ROLE_ID" "$verb" "$PR_TITLE" "$SOURCE_LABEL" "$DEST_LABEL" "$PR_URL")" + # Build the JSON with jq (--arg escapes every value) so an attacker-controlled + # title can never break out of the shell or the JSON payload (a raw + # interpolated title broke the issue notifier with Discord error 50109). + # allowed_mentions hardening: parse [] disables @everyone/@here and all + # user/role auto-parsing; roles is the lone explicit allow-list, so a + # hostile title can ONLY ever ping the one maintainer role and nothing else. + payload="$(jq -n \ + --arg content "$content" \ + --arg role "$ROLE_ID" \ + '{content: $content, allowed_mentions: {parse: [], roles: [$role]}}')" + # A notification failure must not fail the job (matches the release announcer). + if curl -fsS -X POST "$DISCORD_WEBHOOK" -H "Content-Type: application/json" -d "$payload"; then + echo "Announced PR $verb to Discord." + else + echo "::warning::Discord PR announcement failed (webhook unreachable or rejected)." + fi