- Published on
GitHub Actions Slack Notifications on Workflow Failure
- Authors

- Name
- Maruan
Nobody reads the Slack message that says "build passed" for the four-hundredth time. That is the problem with the first CI alert most teams wire up: it fires on every run, the channel fills with green checkmarks, and within a month people mute it. Then a red build lands at 4:50 on a Friday and nobody sees it.
The fix is boring and precise: send a Slack message only when a GitHub Actions workflow fails. This runbook builds that alert on the official slack-github-action with an incoming webhook, and it answers the two questions that trip people up. When do you use failure() in the same job, and when do you need needs.build.result in a separate job? It also covers what happens on fork pull requests, how to test the alert without breaking your main branch, and how to debug an alert that is either silent or noisy.
Every step here is something you can paste into a repository and verify in under an hour. Where the official GitHub app is the better tool, the first section says so.
Two ways to get workflow alerts in Slack: the GitHub app filters versus a failure-only step
You have two real options, and they solve different problems.
The first is the official GitHub app for Slack. You subscribe a channel to a repository and turn on the workflows feature, and you can narrow what arrives with filters. GitHub workflow syntax supports event and branch filters, but actor and conclusion are not workflow trigger filters, so selecting a failed run on main requires a workflow_run workflow triggered on completion and a condition checking the conclusion and branch in YAML. If you want one channel to show failed workflow runs for a repo and you don't need a custom message, start here. It takes a few minutes and nothing lives in your repository.
The second is a failure-only step inside the workflow itself, using slack-github-action and an incoming webhook. Pick this when you need control the app filters don't give you: a message body that includes the failing job name and a link to the exact run, routing different workflows to different channels, or an alert tied to a specific step inside a job rather than the whole run.
My position: use the app filter for "tell me when anything on main is red" and use the workflow step for anything custom. Don't run both into the same channel. You will get two alerts per failure and rebuild the noise you were trying to remove.
The rest of this runbook covers the second option, because it is the one that needs care. The if-condition logic is where most failure-only alerts quietly break.
Set up the incoming webhook and store the URL as a repository secret
Start in Slack. Create an app at api.slack.com/apps, pick "From scratch", name it something obvious like ci-alerts, and choose your workspace. Under Features, open Incoming Webhooks, switch it on, then click Add New Webhook to Workspace and pick the channel. Slack gives you a URL that begins with https://hooks.slack.com/services/.
Treat that URL as a password. Anyone who has it can post to your channel as the app. Never paste it into a workflow file, a pull request description or a chat message. If it leaks, revoke it in the app settings and generate a new one. (We wrote a related checklist for rotating leaked URLs in GitLab Slack Webhook Security: Rotate Leaked URLs Safely, and the same hygiene applies here.)
In GitHub, open the repository, go to Settings, Secrets and variables, Actions, and click New repository secret. Name it SLACK_WEBHOOK_URL and paste the URL as the value.
If you have many repositories, use an organization secret and scope it to the repos that need it. Then rotating the webhook is one edit instead of forty.
One decision to make now: create a dedicated channel such as #ci-failures for these alerts. Mixing them into #engineering is the fastest route to a muted channel. A channel that only ever contains red builds is a channel people keep unmuted, because every message in it means something.
Same-job alerts: a Slack step with if: failure()
The simplest failure-only alert is a final step in the same job as your build and tests. It runs only if an earlier step failed.
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm test
- name: Notify Slack on failure
if: failure()
uses: slackapi/slack-github-action@v2.0.0
with:
webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
webhook-type: incoming-webhook
payload: |
text: "CI failed on ${{ github.repository }} (${{ github.ref_name }}): ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"The key line is if: failure(). By default, a step is skipped once any previous step in the job has failed. failure() overrides that: it returns true when a previous step failed, so the Slack step runs exactly when you want it and stays skipped on green runs.
Two details save debugging time. First, pin the action to a specific version (check the repository's releases for the current tag) instead of a floating branch. Second, the v2 action takes webhook and webhook-type inputs. Older v1 examples that set SLACK_WEBHOOK_URL in env use a different input shape, so don't mix snippets from different major versions.
Use this pattern when the alert covers one job. It has one real limit: failure() only sees steps in its own job. If your pipeline has a build job and a separate deploy job, a Slack step in deploy can't tell you that build failed. For that you need a separate job.
Separate-job alerts: needs.build.result and why always() matters
When a pipeline has several jobs, add one dedicated notification job that depends on the others and checks their results.
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm test
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- run: ./deploy.sh
notify:
needs: [build, deploy]
if: always() && (needs.build.result == 'failure' || needs.deploy.result == 'failure')
runs-on: ubuntu-latest
steps:
- uses: slackapi/slack-github-action@v2.0.0
with:
webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
webhook-type: incoming-webhook
payload: |
text: "Workflow failed in ${{ github.repository }}: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"The always() isn't decoration. A job with needs is skipped by default when any job it depends on fails. That is the opposite of what you want: the notify job would be skipped on exactly the runs you care about, and you would get silence on every failure. always() tells GitHub to evaluate the job regardless of upstream results, and the needs.<job>.result comparison then narrows it back to failures.
needs.<job>.result returns one of success, failure, cancelled or skipped. Be deliberate about which you match. If build fails, deploy is skipped, so checking only needs.deploy.result == 'failure' would miss it. Checking each upstream job for failure covers both.
Should cancelled runs alert? Usually no. A human pressing cancel is not an incident. Matching failure explicitly, rather than != 'success', keeps cancellations and skips out of your channel.
If you have many jobs, contains(needs.*.result, 'failure') is a compact way to say "any dependency failed".
Fork PRs and secrets: what the webhook can and cannot see (and why not to use pull_request_target)
Here is the case that makes people think their alert is broken. A contributor opens a pull request from a fork, the tests fail, and Slack stays quiet.
That is GitHub working as designed. For workflows triggered by pull_request from a fork, repository secrets are not passed to the runner (GitHub Docs, "Using secrets in GitHub Actions"). secrets.SLACK_WEBHOOK_URL resolves to an empty string, so the Slack step has nothing to post to. The step may show an error about a missing webhook, or it may appear to succeed while doing nothing, depending on the action version and inputs.
This is on purpose. A fork PR runs code you didn't write. If it could read your webhook secret, any drive-by contributor could exfiltrate it with one line in a test file.
The tempting workaround is switching the trigger to pull_request_target, which does run with secrets and write permissions. Don't do it for a notification. pull_request_target runs in the context of the base repository, and if the workflow checks out and executes the PR's code, you have handed untrusted code your secrets. GitHub Security Lab has documented this pattern as a repeated source of repository compromises. A Slack alert is not worth that exposure.
The practical answer: accept that fork PR failures won't alert Slack from the fork's own run. Same-repo branches and pushes to main alert normally, and those are the failures your team owns. Reviewers still see the red check on the PR itself.
If you must alert on fork failures, use a separate workflow_run workflow that reads only the conclusion and never checks out or executes fork code.
Test the alert safely on a controlled branch
Don't test a failure alert by breaking main. Do it on a throwaway branch with a trigger that can't reach production.
Create a branch called ci-alert-test. Add a temporary workflow file, .github/workflows/alert-test.yml, that triggers only on that branch and fails on purpose:
name: Alert test
on:
push:
branches: [ci-alert-test]
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: exit 1
- name: Notify Slack on failure
if: failure()
uses: slackapi/slack-github-action@v2.0.0
with:
webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
webhook-type: incoming-webhook
payload: |
text: "TEST ALERT: ignore. ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"Push the branch and run through this checklist:
Failure path: the run goes red and one message arrives in the channel. One, not two.
Success path: change
exit 1toexit 0, push again, and confirm nothing posts. People skip this half, and it is the whole point of the alert.Separate-job path: if you use a notify job, make
buildfail and confirmdeployis skipped and the notify job still runs.Secret scoping: confirm the message doesn't echo the webhook URL anywhere in the run log.
Point the test at a private test channel first. Once both paths behave, move the real workflow into place, delete alert-test.yml and the branch, and switch the webhook to #ci-failures.
Prefer not to push commits to test? Add workflow_dispatch: to the trigger list so you can run it manually from the Actions tab.
Troubleshooting silent or noisy alerts, and where PR cards fit
Work through silent alerts first, because they are the dangerous kind.
Silent on failure. In a separate notify job, a missing always() is the cause nine times out of ten. Next, check the secret name: SLACK_WEBHOOK_URL in the workflow must match the repository secret exactly, and an environment-scoped secret is invisible to jobs that don't declare that environment. Then check whether the run came from a fork (see above). Finally, open the Slack step's log: an invalid_payload or no_service response means the payload shape or the webhook URL is wrong.
Noisy. Duplicates usually mean the same workflow triggers on both push and pull_request for the same branch, so one commit produces two runs. Restrict push to main. The second cause is overlap with the GitHub app's workflow subscription, covered earlier. Pick one source per channel. A third is matching != 'success', which fires on cancelled and skipped jobs.
Alert fires but is useless. Include the repository, branch and a run link in every message. An alert that says only "CI failed" sends people hunting.
A failure alert tells you the pipeline broke. It doesn't tell you which pull request is waiting on a review. That is a separate stream and it belongs in a separate place. MergeMe posts GitHub pull requests and GitLab merge requests to Slack as one updating card per PR/MR. Keep #ci-failures for red builds and let PR cards live in your review channel.
Conclusion
Ship the smallest version today: one SLACK_WEBHOOK_URL secret, one if: failure() step on your main workflow, and one dedicated #ci-failures channel that only ever contains red builds. Prove both the failing and passing paths on a throwaway branch before you trust it.
Then separate the two kinds of signal. Build failures belong in the failure-only channel. Review traffic belongs on pull request cards. MergeMe's free Hobby plan gives you one channel mapping and five user mappings, enough to put your team's GitHub pull requests into a single Slack review channel as cards that update in place, so the build alerts stay quiet and meaningful. Set that up next to your failure alert and you will have one channel that means "fix this now" and one that means "review this next".
FAQs
How do I send a Slack message only when a GitHub Actions workflow fails?
Add a Slack step using slackapi/slack-github-action with `if: failure()` as the last step of the job, passing an incoming webhook URL from a repository secret. The step is skipped on successful runs. For multi-job pipelines, use a separate notify job with `always()` and `needs.<job>.result == 'failure'`.
Why does my GitHub Actions Slack notification not run when the build fails?
In a separate job, GitHub skips any job whose `needs` dependency failed, so your notify job never runs. Add `always()` to its `if` condition and check `needs.build.result == 'failure'`. Also confirm the secret name matches exactly and that the run did not come from a fork, where secrets are unavailable.
Should I use the GitHub app for Slack or slack-github-action for failure alerts?
Use the official GitHub app with workflow filters for simple alerts such as failed runs on main. Use slack-github-action when you need a custom message, per-workflow channels or step-level control. Avoid sending both into one channel. MergeMe handles a different job: pull request and merge request cards in Slack.
Do GitHub Actions Slack notifications work on pull requests from forks?
Not by default. Workflows triggered by `pull_request` from a fork do not receive repository secrets, so the webhook URL is empty and the Slack step cannot post. Avoid switching to `pull_request_target` just for alerts, since it can expose secrets to untrusted code. Same-repo branches and pushes alert normally.
How can I test a failure-only Slack alert without breaking main?
Create a throwaway branch with a temporary workflow that triggers only on that branch and fails with `exit 1`. Confirm one Slack message arrives, then change it to `exit 0` and confirm nothing posts. Point the webhook at a private test channel first, then delete the test workflow and branch.