Documentation
GitHub Actions failure notifications on your phone
Copy a complete GitHub Actions workflow: keep failed builds failed, send a private HTTPS notification with the run link, and verify receipt on your phone.
Updated
Choose a useful report
#GitHub Mobile already offers workflow completion notifications, including failures only, and access to runs. Use that when it meets your needs. Ntfyx is useful when you want to write a specific summary or collect CI, backup and n8n results in the same phone Inbox. This walkthrough sends a short status and the GitHub run link.
GitHub still controls access to private logs; a notification does not grant repository access.
Start with one working HTTPS notification
#As of 2026-10-29, confirmed public releases are iOS 1.8.3 and Android 1.8.2. Update from your store, then use the public Topic or private connection entry on your phone. Android background push requires Google Play services. Open HTTPS Push in Ntfyx, send its hello test and confirm the matching message in your Inbox.
Copy the complete private sending URL. These tutorials POST JSON to that fixed URL and need no CLI pairing. First verify your own phone receipt before configuring an automation. If setup is blocked, follow troubleshooting and check the installed version.
1. Save the sending URL as a repository secret
#In your repository, open Settings → Secrets and variables → Actions → New repository secret. Name it NTFYX_WEBHOOK_URL and paste the complete URL from the phone. Keep it out of the YAML and job output. The example receives this secret through an environment variable and JSON-encodes all message fields; it does not interpolate event text into shell code.
2. Add this complete workflow
#Save this as .github/workflows/phone-notification.yml on the default branch of a test repository. It needs no dependency installation. Run it from the Actions tab with fail_demo enabled: the harmless task exits 1 and the failure-only notification step runs. Replace the demo step with your own checkout, setup and build steps after the first test.
name: CI phone notification demo
on:
workflow_dispatch:
inputs:
fail_demo:
description: Fail the harmless demo task
type: boolean
default: true
permissions: {}
jobs:
task:
runs-on: ubuntu-latest
steps:
- name: Demo task (replace with your build or test steps)
env:
FAIL_DEMO: ${{ inputs.fail_demo }}
shell: bash
run: |
if [[ "$FAIL_DEMO" == "true" ]]; then
echo "Intentional demo failure"
exit 1
fi
echo "Demo succeeded"
- name: Notify phone on failure
if: ${{ failure() }}
continue-on-error: true
timeout-minutes: 3
env:
NTFYX_WEBHOOK_URL: ${{ secrets.NTFYX_WEBHOOK_URL }}
TASK_RESULT: ${{ job.status }}
shell: bash
run: |
python3 - <<'PY'
import datetime, email.utils, hashlib, json, os, sys, time
import urllib.error, urllib.parse, urllib.request
def stop(message):
print("::warning::Ntfyx: " + message)
sys.exit(1)
url = os.environ.get("NTFYX_WEBHOOK_URL", "")
parsed = urllib.parse.urlsplit(url)
if parsed.scheme != "https" or parsed.hostname != "api.ntfyx.me":
stop("Set NTFYX_WEBHOOK_URL to the complete production HTTPS URL copied from the app.")
run_id = os.environ["GITHUB_RUN_ID"]
attempt = os.environ["GITHUB_RUN_ATTEMPT"]
repository = os.environ["GITHUB_REPOSITORY"]
run_url = f"{os.environ['GITHUB_SERVER_URL']}/{repository}/actions/runs/{run_id}"
payload = json.dumps({
"title": "CI " + os.environ["TASK_RESULT"],
"text": f"{repository}\nRun {run_id}, attempt {attempt}\n{run_url}",
"session_id": hashlib.sha256(f"github-{repository}-{run_id}".encode()).hexdigest(),
"session_name": "GitHub Actions",
}).encode("utf-8")
key = hashlib.sha256(f"github-{repository}-{run_id}-{attempt}-task-result".encode()).hexdigest()
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *args, **kwargs):
return None
opener = urllib.request.build_opener(NoRedirect)
for index in range(3):
delay = 5 * (index + 1)
request = urllib.request.Request(url, data=payload, method="POST", headers={
"Content-Type": "application/json", "Idempotency-Key": key,
})
try:
with opener.open(request, timeout=15) as response:
if not 200 <= response.status < 300:
stop("Unexpected response; inspect the sender execution.")
print("Ntfyx accepted the message; verify receipt separately on your phone.")
break
except urllib.error.HTTPError as error:
if error.code not in (408, 429, 500, 502, 503, 504):
stop(f"HTTP {error.code}; check configuration before retrying.")
retry_after = error.headers.get("Retry-After")
if retry_after:
try:
delay = max(delay, float(retry_after))
except ValueError:
try:
retry_at = email.utils.parsedate_to_datetime(retry_after)
delay = max(delay, (retry_at - datetime.datetime.now(datetime.timezone.utc)).total_seconds())
except (TypeError, ValueError):
stop("Invalid Retry-After; inspect the response before retrying.")
except (urllib.error.URLError, TimeoutError, OSError):
pass # Outcome unknown: preserve this payload and key.
if index == 2 or delay > 60:
stop("Send unconfirmed. Inspect the phone and sender before a retry; retain the original key and body.")
time.sleep(delay)
PY
3. Verify the failure, the receipt and the alert separately
#The expected phone message is titled “CI failure” and contains the repository, run ID, attempt number and run URL. Open that URL and confirm it points to the intentionally failed run. The workflow must remain failed even if sending succeeds. A sender log saying “accepted” proves service acceptance only; confirm the matching Inbox message before treating setup as complete.
Check banner or sound separately with the app in the background.
- Run once with fail_demo=true: one failed task and one matching Inbox message are expected.
- Run again with fail_demo=false: the task should pass and the failure-only step should be skipped.
- Compare run ID and attempt, not just the message title. Remove the demo when finished.
Keep notification failures separate from build failures
#Only the notification step has continue-on-error. A failed build stays failed; a failed sender produces a warning without replacing the original outcome. Keep the build step strict. To also report success, change the notification condition to the expression below; TASK_RESULT then supplies the task status before sending.
This covers completed steps, not a guarantee of notification after cancellation, runner loss or a job timeout.
if: ${{ !cancelled() }}Retries reuse the same logical message
#The sender freezes the body and derives its request key from repository, run ID, attempt and task. It makes at most three attempts for transient errors, keeps the key unchanged, and honors Retry-After within its wait budget. A long requested wait stops the sender instead of retrying early. Authentication and validation errors stop immediately.
Re-running the GitHub job increments the attempt and creates a new report; it is not a retry of an uncertain send. For multiple jobs, give each sender its own stable task suffix. Do not change a body while reusing its key.
Validation scope and the next task
#The downloadable example is checked locally for YAML/Python syntax, failure and success branches, JSON construction, stable retry inputs and sender-error handling with simulated HTTP responses. This is not a record of a GitHub-hosted execution or receipt on a physical phone. Complete the steps above in your own repository before relying on it.
The next tutorial applies the same HTTPS receiver to n8n errors.