使用说明

GitHub Actions 构建失败,如何通知手机

复制完整 GitHub Actions YAML:构建失败仍保留失败状态,通过私有 HTTPS 发送运行链接,并在手机核对对应消息。

更新于

先确定需要什么报告

#

GitHub Mobile 已提供工作流完成通知、仅失败提醒和运行查看,能满足需求时可直接使用。需要自定义摘要,或把 CI、备份与 n8n 结果归集到同一个手机收件箱时,可以使用 Ntfyx。本例发送简短状态及 GitHub 运行链接;私有日志仍由 GitHub 控制访问,通知不会授予仓库权限。

先确认一条 HTTPS 通知能收到

#

截至 2026-10-29 已确认公开版为 iOS 1.8.3、Android 1.8.2。在商店更新后,按手机上的公开 Topic 或私密连接入口继续;Android 后台推送需要 Google Play 服务。 在 Ntfyx 打开 HTTPS 推送,发送 hello 测试,确认对应消息进入收件箱,再复制完整私有发送 URL。本文向固定 URL POST JSON,不需要 CLI 配对。先确认自己的手机实际收到,再配置自动化。首次设置受阻时,核对安装版本并按排障页继续。

1. 将发送 URL 存为仓库 Secret

#

在仓库进入 Settings → Secrets and variables → Actions → New repository secret,名称填写 NTFYX_WEBHOOK_URL,值为手机复制的完整 URL。不要写入 YAML 或任务日志。本例通过环境变量读取密钥,消息字段由 JSON 编码器构造,不把事件文本拼入 Shell 代码。

2. 添加完整工作流

#

在测试仓库默认分支保存为 .github/workflows/phone-notification.yml,无需安装依赖。在 Actions 中手动运行并勾选 fail_demo:无副作用的演示任务以 1 退出,随后运行仅失败时通知的步骤。首次测试完成后,再将演示步骤换成项目自己的检出、环境准备与构建步骤。

终端
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. 分别核对任务失败、消息收到与系统提醒

#

手机预期显示标题“CI failure”,正文包含仓库、运行 ID、尝试次数和运行 URL。打开链接,确认对应刚才故意失败的运行。发送成功后,工作流仍应保持失败。发送日志的“accepted”只证明服务受理;必须在收件箱找到匹配消息才算完成接入,再单独检查 App 在后台时是否有横幅或声音。

  1. 运行一次 fail_demo=true:预期任务失败,并收到一条匹配的收件箱消息。
  2. 再次运行 fail_demo=false:预期任务通过,跳过仅失败时发送的步骤。
  3. 对照运行 ID 与尝试次数,不只看标题;完成后移除演示任务。

区分通知失败与构建失败

#

只有通知步骤设置 continue-on-error。构建失败仍是失败;发送失败会留下警告,不覆盖原始结果。构建步骤应保持严格失败。若也要通知成功,将通知步骤条件改为下方表达式,TASK_RESULT 会提供发送前的任务状态。它覆盖已执行步骤,不保证取消、执行器失联或任务超时后一定通知。

终端
if: ${{ !cancelled() }}

重试继续同一条逻辑消息

#

发送器固定正文,并按仓库、运行 ID、尝试次数和任务生成请求标识。临时错误最多尝试三次,复用原标识,并在等待预算内遵守 Retry-After;要求等待过久时停止发送,不提前重试。鉴权或输入错误立即停止。重新运行 GitHub 任务会增加尝试次数、生成新报告,不是对未知发送结果的原样重试。多个任务各自发送时,应设置不同且稳定的任务后缀。复用标识时不能修改正文。

验证范围与下一步

#

下载示例在本地检查 YAML/Python 语法、成功与失败分支、JSON 构造、重试输入稳定性,并以模拟 HTTP 响应检查发送失败处理。这不是 GitHub 托管执行或实体手机收件记录;使用前请在自己的仓库完成上述步骤。下一篇将同一个 HTTPS 接收入口用于 n8n 错误。