Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/workflows/announce-pull-requests.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
name: 'Announce pull requests'

# Posts each pull request that needs action to Slack through this repository's own announcer on
# the pull request's branch (README.md).
on:
pull_request:
types: [opened, reopened, ready_for_review]

permissions: {}

jobs:
slack:
name: Announce to Slack
uses: ./.github/workflows/slack-on-pull-request.yaml
with:
channel: '#l337-org'
secrets:
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
2 changes: 1 addition & 1 deletion .github/workflows/claude-review.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: 'Claude review'

# A reusable review job, called by each repository pinned to a commit of this one. This
# A reusable review job, called by each repository at a version tag of this one. This
# repository is public because a public caller cannot use a reusable workflow from a private one.
#
# Claude reads and returns a verdict; it never writes to GitHub. It gets read-only tools, and
Expand Down
20 changes: 20 additions & 0 deletions .github/workflows/premerge.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,23 @@ jobs:
# SC2016 is shellcheck noting that `$x` does not expand inside single quotes. Here that
# is always Markdown backticks in a printf format, which is the intent.
run: uvx --from actionlint-py==1.7.12.25 actionlint -ignore 'SC2016'

shared-behaviour:
# What the Slack workflows do on a refusal, a fork or a draft cannot be made to happen from a
# pull request here, and the action-pins check needs references this repository does not
# contain, so both are run against stand-ins and sample input instead.
runs-on: [ubuntu-latest]
name: Shared workflows and actions behave
timeout-minutes: 5
env:
# The tests import the hygiene script, and a checkout is left as it was found.
PYTHONDONTWRITEBYTECODE: '1'
steps:
- name: Check out source repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Set up uv
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0

- name: Tests pass
run: uvx --with pyyaml==6.0.3 pytest==8.3.5 -p no:cacheprovider -q tests
4 changes: 3 additions & 1 deletion .github/workflows/report-failures.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ on:
workflows:
- 'Check inbound changes'
- 'Code review'
- 'Announce pull requests'
types: [completed]

permissions:
Expand All @@ -19,6 +20,7 @@ jobs:
name: Report to Slack
uses: ./.github/workflows/slack-on-failure.yaml
with:
channel: '#l337-org'
events: '["schedule", "push", "release", "pull_request"]'
secrets:
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
70 changes: 50 additions & 20 deletions .github/workflows/slack-on-failure.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,31 +2,35 @@ name: 'Report a failed run to Slack'

# Called from a repository's watcher workflow, which is started by `workflow_run` when one of the
# workflows it names completes. A failed run is otherwise one red entry in a list nobody opens;
# this posts it to the channel behind `SLACK_WEBHOOK` instead.
# this posts it to the caller's channel instead.
#
# The caller says which triggers to report with `events`. A push is reported on the default
# branch and on a tag, which is how a release is often started; a push to any other branch has
# its pusher watching. A pull request from a fork is not reported: its run uses the fork's own
# workflow file, so its author writes the job names and branch the post would carry, and could
# put text of their choosing in the channel through this webhook. The same reasoning has the
# put text of their choosing in the channel through this token. The same reasoning has the
# review refuse forks. Only a completed run is reported, whatever `types:` the caller gives
# `workflow_run`, and with every conclusion but success, skipped and neutral, so a conclusion
# nobody anticipated is posted rather than passed over. The filters are here, not in each
# caller, so no caller can get them wrong.
#
# A post Slack does not accept fails this job, with Slack's answer in the error. Nothing
# reports the watcher's own run, so a webhook that stops working is noticed only by the absence
# reports the watcher's own run, so a token that stops working is noticed only by the absence
# of posts: an accepted limitation (README.md).
on:
workflow_call:
inputs:
channel:
description: 'The Slack channel to post to, by name, such as `#l337-org`. The app the token belongs to must be a member of it.'
type: string
required: true
events:
description: 'The triggers whose failed runs are reported, as a JSON list of event names, such as `["schedule", "push", "release", "pull_request"]`. A manual run, `workflow_dispatch`, is best left out, because whoever started it is watching.'
type: string
required: true
secrets:
SLACK_WEBHOOK:
description: 'A Slack incoming webhook URL, which posts to the one channel it was issued for. Not required, so that a missing secret fails with a message naming it rather than GitHub refusing the call.'
SLACK_BOT_TOKEN:
description: 'A Slack bot token (`xoxb-`) with the `chat:write` scope. Not required, so that a missing secret fails with a message naming it rather than GitHub refusing the call.'
required: false

jobs:
Expand All @@ -52,7 +56,8 @@ jobs:
steps:
- name: Post the failed run
env:
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
CHANNEL: ${{ inputs.channel }}
GH_TOKEN: ${{ github.token }}
EVENT_NAME: ${{ github.event_name }}
REPO: ${{ github.repository }}
Expand Down Expand Up @@ -106,8 +111,12 @@ jobs:
fi
fi

if [ -z "$SLACK_WEBHOOK" ]; then
echo "::error::No SLACK_WEBHOOK secret reached this workflow, so the failure of $WORKFLOW (run $RUN_URL) was not posted. Set the secret on the repository or the organisation, and pass it in the caller's \`secrets:\`." >&2
if [ -z "$SLACK_BOT_TOKEN" ]; then
echo "::error::No SLACK_BOT_TOKEN secret reached this workflow, so the failure of $WORKFLOW (run $RUN_URL) was not posted. Set it on the organisation as an Actions secret and as a Dependabot secret, and pass it in the caller's \`secrets:\`." >&2
exit 1
fi
if [ -z "$CHANNEL" ]; then
echo "::error::No channel was given, so the failure of $WORKFLOW (run $RUN_URL) was not posted. Pass the channel name in the caller's \`with: channel:\`." >&2
exit 1
fi

Expand All @@ -127,21 +136,42 @@ jobs:
Jobs: $(escape "$jobs")
<$RUN_URL|Run $RUN_NUMBER, attempt $RUN_ATTEMPT> on \`$(escape "$BRANCH")\` at \`${SHA:0:12}\`"

# The body goes to a file because a retry truncates a file, where to standard output it
# would follow the refused attempt's body.
# The token goes to curl in a file, not on its command line, where any process on the
# runner could read it. The body goes to a file because a retry truncates a file,
# where to standard output it would follow the refused attempt's body.
umask 077
header_file=$(mktemp)
body_file=$(mktemp)
status=$(jq -n --arg text "$text" '{text: $text}' \
trap 'rm -f "$header_file" "$body_file"' EXIT
printf 'Authorization: Bearer %s\n' "$SLACK_BOT_TOKEN" > "$header_file"
# Unfurling is off so the post stays text rather than gaining a preview card.
status=$(jq -n --arg channel "$CHANNEL" --arg text "$text" \
'{channel: $channel, text: $text, unfurl_links: false, unfurl_media: false}' \
| curl --silent --show-error --max-time 30 --retry 3 \
--header 'Content-Type: application/json' --data @- \
--output "$body_file" --write-out '%{http_code}' "$SLACK_WEBHOOK") || {
echo "::error::Posting the failure of $WORKFLOW (run $RUN_URL) to Slack failed: curl could not complete the request." >&2
--header @"$header_file" --header 'Content-Type: application/json; charset=utf-8' \
--data @- --output "$body_file" --write-out '%{http_code}' \
https://slack.com/api/chat.postMessage) || {
echo "::error::Posting the failure of $WORKFLOW (run $RUN_URL) to $CHANNEL failed: curl could not complete the request." >&2
exit 1
}
body=$(cat "$body_file")
# Slack answers a post it accepts with 200 and the body `ok`, and anything else with an
# error code in the body, such as `invalid_token` or `channel_not_found`.
if [ "$status" != 200 ] || [ "$body" != ok ]; then
echo "::error::Slack refused the post about $WORKFLOW (run $RUN_URL): HTTP $status, $body" >&2
# Slack answers 200 with `"ok": false` and an `error` code for most refusals, such as
# `not_in_channel`, `channel_not_found` or `invalid_auth`. An accepted post echoes the
# message back, so the cap for reading the answer is generous; a refusal that is not
# Slack's JSON, such as a proxy's error page, is quoted shorter.
body=$(head -c 65536 "$body_file")
if [ "$status" != 200 ] || [ "$(jq -r '.ok // empty' <<<"$body" 2>/dev/null)" != true ]; then
error=$(jq -r '.error // empty' <<<"$body" 2>/dev/null || true)
if [ "$(wc -c < "$body_file")" -gt 500 ]; then
body="$(head -c 500 "$body_file") ... (truncated at 500 bytes)"
fi
case "$error" in
not_in_channel) hint=" Invite the Slack app to $CHANNEL." ;;
channel_not_found) hint=" Check the channel name; a private channel needs its ID rather than its name." ;;
invalid_auth|not_authed|token_revoked|account_inactive) hint=" Replace SLACK_BOT_TOKEN with the app's current bot token, in both the Actions and the Dependabot secret." ;;
missing_scope) hint=" Give the app the chat:write scope and reinstall it." ;;
*) hint="" ;;
esac
echo "::error::Slack refused the post about $WORKFLOW (run $RUN_URL) in $CHANNEL: HTTP $status, ${error:-$body}.$hint" >&2
exit 1
fi
echo "Posted the failure of $WORKFLOW (run $RUN_URL) to Slack."
echo "Posted the failure of $WORKFLOW (run $RUN_URL) to $CHANNEL."
134 changes: 134 additions & 0 deletions .github/workflows/slack-on-pull-request.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
name: 'Announce a pull request to Slack'

# Called from a repository's announcer workflow, which is started by `pull_request` with
# `types: [opened, reopened, ready_for_review]`. A pull request nobody has been told about waits
# until someone happens to open the repository, which is how a stack of Dependabot pull requests
# goes unnoticed; this posts each one that needs action to the caller's channel instead.
#
# Posted: one opened or reopened that is not a draft, and a draft marked ready for review, so a
# draft is announced once it asks for review rather than when it is started. Dependabot's are
# posted like anyone else's. Not posted: one from a fork, whose title and author a stranger
# chooses and would reach the channel through this token, and every other `pull_request` action,
# such as a push to the branch, which is the activity the GitHub app for Slack makes too noisy.
# The filters are in the step rather than the job's `if:` so each skip leaves a notice saying
# why, and so `tests/` can run them.
#
# A post Slack does not accept fails this job, with Slack's answer in the error.
on:
workflow_call:
inputs:
channel:
description: 'The Slack channel to post to, by name, such as `#l337-org`. The app the token belongs to must be a member of it.'
type: string
required: true
secrets:
SLACK_BOT_TOKEN:
description: 'A Slack bot token (`xoxb-`) with the `chat:write` scope. Not required, so that a missing secret fails with a message naming it rather than GitHub refusing the call. A run Dependabot starts sees only Dependabot secrets, so it is set there too (README.md).'
required: false

# The step reads only the event payload.
permissions: {}

jobs:
announce:
runs-on: [ubuntu-latest]
name: Announce the pull request to Slack
timeout-minutes: 5
steps:
- name: Post the pull request
env:
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
CHANNEL: ${{ inputs.channel }}
EVENT_NAME: ${{ github.event_name }}
ACTION: ${{ github.event.action }}
REPO: ${{ github.repository }}
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
DRAFT: ${{ github.event.pull_request.draft }}
NUMBER: ${{ github.event.pull_request.number }}
TITLE: ${{ github.event.pull_request.title }}
AUTHOR: ${{ github.event.pull_request.user.login }}
BASE: ${{ github.event.pull_request.base.ref }}
PR_URL: ${{ github.event.pull_request.html_url }}
run: |
set -euo pipefail
# `pull_request_target` is refused along with everything else: it would hand the token
# to a run over a fork's pull request, which is the case the fork filter exists for.
if [ "$EVENT_NAME" != pull_request ]; then
echo "::error::This workflow announces a pull request, and was called from a \`$EVENT_NAME\` event. Call it from a workflow triggered by \`pull_request\` with \`types: [opened, reopened, ready_for_review]\`." >&2
exit 1
fi
case "$ACTION" in
opened|reopened|ready_for_review) ;;
*)
echo "::notice::Not posted: pull request #$NUMBER was $ACTION, which is not one of opened, reopened or ready_for_review. Narrow the caller's \`types:\` to those three."
exit 0
;;
esac
# A fork that has since been deleted has no head repository, and is skipped with the rest.
if [ "$HEAD_REPO" != "$REPO" ]; then
echo "::notice::Not posted: pull request #$NUMBER comes from ${HEAD_REPO:-a deleted fork}, not $REPO."
exit 0
fi
if [ "$DRAFT" = true ]; then
echo "::notice::Not posted: pull request #$NUMBER is a draft. It is posted when it is marked ready for review."
exit 0
fi

if [ -z "$SLACK_BOT_TOKEN" ]; then
echo "::error::No SLACK_BOT_TOKEN secret reached this workflow, so pull request #$NUMBER ($PR_URL) was not announced. Set it on the organisation as an Actions secret and, for Dependabot's pull requests, as a Dependabot secret, and pass it in the caller's \`secrets:\`." >&2
exit 1
fi
if [ -z "$CHANNEL" ]; then
echo "::error::No channel was given, so pull request #$NUMBER ($PR_URL) was not announced. Pass the channel name in the caller's \`with: channel:\`." >&2
exit 1
fi

case "$ACTION" in
opened) what=opened ;;
reopened) what=reopened ;;
ready_for_review) what="ready for review" ;;
esac
# Slack reads &, < and > as markup in message text.
escape() { sed -e 's/&/\&amp;/g' -e 's/</\&lt;/g' -e 's/>/\&gt;/g' <<<"$1"; }
text="Pull request $what in $(escape "$REPO"): <$PR_URL|#$NUMBER $(escape "$TITLE")>
By \`$(escape "$AUTHOR")\`, into \`$(escape "$BASE")\`"

# The token goes to curl in a file, not on its command line, where any process on the
# runner could read it. The body goes to a file because a retry truncates a file,
# where to standard output it would follow the refused attempt's body.
umask 077
header_file=$(mktemp)
body_file=$(mktemp)
trap 'rm -f "$header_file" "$body_file"' EXIT
printf 'Authorization: Bearer %s\n' "$SLACK_BOT_TOKEN" > "$header_file"
# Unfurling is off so the post stays one line of text rather than a preview card.
status=$(jq -n --arg channel "$CHANNEL" --arg text "$text" \
'{channel: $channel, text: $text, unfurl_links: false, unfurl_media: false}' \
| curl --silent --show-error --max-time 30 --retry 3 \
--header @"$header_file" --header 'Content-Type: application/json; charset=utf-8' \
--data @- --output "$body_file" --write-out '%{http_code}' \
https://slack.com/api/chat.postMessage) || {
echo "::error::Announcing pull request #$NUMBER ($PR_URL) in $CHANNEL failed: curl could not complete the request." >&2
exit 1
}
# Slack answers 200 with `"ok": false` and an `error` code for most refusals, such as
# `not_in_channel`, `channel_not_found` or `invalid_auth`. An accepted post echoes the
# message back, so the cap for reading the answer is generous; a refusal that is not
# Slack's JSON, such as a proxy's error page, is quoted shorter.
body=$(head -c 65536 "$body_file")
if [ "$status" != 200 ] || [ "$(jq -r '.ok // empty' <<<"$body" 2>/dev/null)" != true ]; then
error=$(jq -r '.error // empty' <<<"$body" 2>/dev/null || true)
if [ "$(wc -c < "$body_file")" -gt 500 ]; then
body="$(head -c 500 "$body_file") ... (truncated at 500 bytes)"
fi
case "$error" in
not_in_channel) hint=" Invite the Slack app to $CHANNEL." ;;
channel_not_found) hint=" Check the channel name; a private channel needs its ID rather than its name." ;;
invalid_auth|not_authed|token_revoked|account_inactive) hint=" Replace SLACK_BOT_TOKEN with the app's current bot token, in both the Actions and the Dependabot secret." ;;
missing_scope) hint=" Give the app the chat:write scope and reinstall it." ;;
*) hint="" ;;
esac
echo "::error::Slack refused the announcement of pull request #$NUMBER ($PR_URL) in $CHANNEL: HTTP $status, ${error:-$body}.$hint" >&2
exit 1
fi
echo "Announced pull request #$NUMBER ($PR_URL) in $CHANNEL."
Loading
Loading