diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index bbf56fb..4fb1031 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -1,12 +1,7 @@ name: Canary — plugin vs Claude Code latest on: - schedule: - # 05:13 & 17:13 America/Los_Angeles (PDT/UTC-7 basis; cron is UTC) — - # leads the fleet's hour-staggered canary waves so upstream breakage - # surfaces here first. - - cron: "13 12 * * *" - - cron: "13 0 * * *" + workflow_call: workflow_dispatch: jobs: @@ -40,21 +35,3 @@ jobs: # this canary passes. - name: Full offline suite vs latest host run: pytest -q - - # A separate always-evaluated job catches failures, cancellations, and other - # non-success results from the unattended twice-daily canary. - notify: - needs: contract - if: always() && github.event_name == 'schedule' && needs.contract.result != 'success' - runs-on: ubuntu-latest - timeout-minutes: 2 - steps: - - name: Notify Google Chat - env: - WEBHOOK_URL: ${{ secrets.GOOGLE_CHAT_WEBHOOK_URL }} - run: | - [ -n "$WEBHOOK_URL" ] || { echo "GOOGLE_CHAT_WEBHOOK_URL is missing"; exit 1; } - curl --fail-with-body --retry 3 --retry-all-errors --max-time 20 \ - -sS -X POST -H 'Content-Type: application/json' \ - -d "{\"text\": \"🚨 claude-code-plugin canary FAILED against the latest Claude Code host: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\"}" \ - "$WEBHOOK_URL" diff --git a/.github/workflows/live-stack.yml b/.github/workflows/live-stack.yml index 5b1fb56..394cea1 100644 --- a/.github/workflows/live-stack.yml +++ b/.github/workflows/live-stack.yml @@ -7,12 +7,12 @@ name: Full stack e2e # missing, skipped, cancelled, or failed. on: + schedule: + - cron: "13 12 * * *" + - cron: "13 0 * * *" pull_request: types: [opened, synchronize, reopened, ready_for_review] workflow_dispatch: - workflow_run: - workflows: ["Canary — plugin vs Claude Code latest"] - types: [completed] concurrency: group: inkbox-live-aut-tunnel @@ -20,15 +20,24 @@ concurrency: queue: max jobs: - channels: + canary: if: > + github.event_name == 'schedule' || + github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && + github.event.pull_request.draft == false && + github.event.pull_request.head.repo.full_name == github.repository) + uses: ./.github/workflows/canary.yml + + channels: + needs: canary + if: > + !cancelled() && needs.canary.result == 'success' && + ((github.event_name == 'pull_request' && github.event.pull_request.draft == false && github.event.pull_request.head.repo.full_name == github.repository) || github.event_name == 'workflow_dispatch' || - (github.event_name == 'workflow_run' && - github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.head_branch == 'main') + github.event_name == 'schedule') uses: ./.github/workflows/live-channels.yml with: orchestrated: true @@ -42,9 +51,7 @@ jobs: github.event.pull_request.draft == false && github.event.pull_request.head.repo.full_name == github.repository) || github.event_name == 'workflow_dispatch' || - (github.event_name == 'workflow_run' && - github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.head_branch == 'main')) + github.event_name == 'schedule') uses: ./.github/workflows/live-a2a.yml with: orchestrated: true @@ -58,9 +65,7 @@ jobs: github.event.pull_request.draft == false && github.event.pull_request.head.repo.full_name == github.repository) || github.event_name == 'workflow_dispatch' || - (github.event_name == 'workflow_run' && - github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.head_branch == 'main')) + github.event_name == 'schedule') uses: ./.github/workflows/live-voice.yml with: orchestrated: true @@ -74,9 +79,7 @@ jobs: github.event.pull_request.draft == false && github.event.pull_request.head.repo.full_name == github.repository) || github.event_name == 'workflow_dispatch' || - (github.event_name == 'workflow_run' && - github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.head_branch == 'main')) + github.event_name == 'schedule') uses: ./.github/workflows/live-external-events.yml with: orchestrated: true @@ -91,9 +94,7 @@ jobs: github.event.pull_request.draft == false && github.event.pull_request.head.repo.full_name == github.repository) || github.event_name == 'workflow_dispatch' || - (github.event_name == 'workflow_run' && - github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.head_branch == 'main')) + github.event_name == 'schedule') runs-on: ubuntu-latest steps: - name: Require every live suite to pass @@ -113,31 +114,3 @@ jobs: fi done exit "$failed" - - # One unattended page covers failures from any component or from the gate - # itself. PR authors and manual dispatchers can see their run directly. - notify: - needs: [channels, a2a, voice, external-events, full-stack] - if: > - always() && - github.event_name == 'workflow_run' && - github.event.workflow_run.event == 'schedule' && - github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.head_branch == 'main' && - (needs.channels.result != 'success' || - needs.a2a.result != 'success' || - needs.voice.result != 'success' || - needs.external-events.result != 'success' || - needs.full-stack.result != 'success') - runs-on: ubuntu-latest - timeout-minutes: 2 - steps: - - name: Notify Google Chat - env: - WEBHOOK_URL: ${{ secrets.GOOGLE_CHAT_WEBHOOK_URL }} - run: | - [ -n "$WEBHOOK_URL" ] || { echo "GOOGLE_CHAT_WEBHOOK_URL is missing"; exit 1; } - curl --fail-with-body --retry 3 --retry-all-errors --max-time 20 \ - -sS -X POST -H 'Content-Type: application/json' \ - -d "{\"text\": \"🚨 claude-code-plugin full-stack e2e FAILED (chained off the canary): ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\"}" \ - "$WEBHOOK_URL" diff --git a/.github/workflows/scheduled-failure-report.yml b/.github/workflows/scheduled-failure-report.yml new file mode 100644 index 0000000..7d2f93a --- /dev/null +++ b/.github/workflows/scheduled-failure-report.yml @@ -0,0 +1,95 @@ +name: Scheduled failure reporting + +on: + workflow_run: + workflows: ["Full stack e2e"] + types: [completed] + +permissions: + actions: read + +jobs: + report: + if: >- + ${{ + github.event.workflow_run.event == 'schedule' && + ( + github.event.workflow_run.conclusion == 'failure' || + github.event.workflow_run.conclusion == 'timed_out' || + github.event.workflow_run.conclusion == 'startup_failure' + ) + }} + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Post failure notification + env: + NOTIFICATION_URL: ${{ secrets.SCHEDULED_FAILURE_NOTIFICATION_URL }} + REPOSITORY: ${{ github.repository }} + WORKFLOW_NAME: ${{ github.event.workflow_run.name }} + RUN_ID: ${{ github.event.workflow_run.id }} + RUN_ATTEMPT: ${{ github.event.workflow_run.run_attempt }} + RUN_URL: ${{ github.event.workflow_run.html_url }} + run: | + set -euo pipefail + [ -n "${NOTIFICATION_URL}" ] || { echo "::error::Missing failure notification URL"; exit 1; } + repo_slug="${REPOSITORY//\//-}" + thread_key="ci-${repo_slug}-${RUN_ID}-${RUN_ATTEMPT}" + payload="$(jq -n \ + --arg repository "${REPOSITORY}" \ + --arg workflow "${WORKFLOW_NAME}" \ + --arg run_url "${RUN_URL}" \ + '{text: ("Scheduled integration checks failed\n\nRepository: " + $repository + "\nWorkflow: " + $workflow + "\nRun: " + $run_url)}')" + curl --fail-with-body --silent --show-error \ + --retry 3 --retry-connrefused --retry-delay 2 --retry-max-time 90 \ + --connect-timeout 10 --max-time 30 \ + -X POST "${NOTIFICATION_URL}&threadKey=${thread_key}&messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD" \ + -H 'Content-Type: application/json' \ + --data "${payload}" + + - name: Send signed failure event + if: always() + env: + RECEIVER_URL: ${{ secrets.SCHEDULED_FAILURE_RECEIVER_URL }} + SIGNING_SECRET: ${{ secrets.SCHEDULED_FAILURE_SIGNING_SECRET }} + FAILURE_ENVIRONMENT: ${{ secrets.SCHEDULED_FAILURE_ENVIRONMENT }} + REPOSITORY: ${{ github.repository }} + WORKFLOW_NAME: ${{ github.event.workflow_run.name }} + RUN_ID: ${{ github.event.workflow_run.id }} + RUN_ATTEMPT: ${{ github.event.workflow_run.run_attempt }} + RUN_URL: ${{ github.event.workflow_run.html_url }} + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + run: | + set -euo pipefail + [ -n "${RECEIVER_URL}" ] || { echo "::error::Missing failure receiver URL"; exit 1; } + [ -n "${SIGNING_SECRET}" ] || { echo "::error::Missing failure signing secret"; exit 1; } + [ -n "${FAILURE_ENVIRONMENT}" ] || { echo "::error::Missing failure environment"; exit 1; } + echo "::add-mask::${SIGNING_SECRET}" + repo_slug="${REPOSITORY//\//-}" + thread_key="ci-${repo_slug}-${RUN_ID}-${RUN_ATTEMPT}" + payload="$(jq -c -n \ + --arg event_type "scheduled_ci_failure" \ + --arg source "${REPOSITORY}" \ + --arg repository "${REPOSITORY}" \ + --arg workflow "${WORKFLOW_NAME}" \ + --arg source_job "${WORKFLOW_NAME}" \ + --arg environment "${FAILURE_ENVIRONMENT}" \ + --argjson run_id "${RUN_ID}" \ + --argjson run_attempt "${RUN_ATTEMPT}" \ + --arg run_url "${RUN_URL}" \ + --arg head_sha "${HEAD_SHA}" \ + --arg chat_thread_key "${thread_key}" \ + '{$event_type, $source, $repository, $workflow, $source_job, $environment, $run_id, $run_attempt, $run_url, $head_sha, $chat_thread_key}')" + signature="$(printf '%s' "${payload}" \ + | openssl dgst -sha256 -hmac "${SIGNING_SECRET}" -binary \ + | xxd -p -c 256)" + request_id="ci:${REPOSITORY}:${RUN_ID}:${RUN_ATTEMPT}:${FAILURE_ENVIRONMENT}" + curl --fail-with-body --silent --show-error \ + --retry 3 --retry-connrefused --retry-delay 2 --retry-max-time 90 \ + --connect-timeout 10 --max-time 30 \ + -X POST "${RECEIVER_URL}" \ + -H 'Content-Type: application/json' \ + -H 'X-GitHub-Event: workflow_run' \ + -H "X-Hub-Signature-256: sha256=${signature}" \ + -H "X-Inkbox-Request-Id: ${request_id}" \ + --data-binary "${payload}" diff --git a/pyproject.toml b/pyproject.toml index be617b8..78b3e31 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "claude-code-plugin" -version = "0.2.10" +version = "0.2.11" description = "Inkbox bridge for Claude Code — talk to your coding agent over email, SMS, iMessage, and voice" requires-python = ">=3.11" dependencies = [ diff --git a/tests/test_scheduled_failure_reporting.py b/tests/test_scheduled_failure_reporting.py new file mode 100644 index 0000000..bfca6f1 --- /dev/null +++ b/tests/test_scheduled_failure_reporting.py @@ -0,0 +1,25 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def test_scheduled_failures_use_one_verified_reporting_path(): + canary = (ROOT / ".github/workflows/canary.yml").read_text() + stack = (ROOT / ".github/workflows/live-stack.yml").read_text() + report = (ROOT / ".github/workflows/scheduled-failure-report.yml").read_text() + + assert "workflow_call:" in canary + assert "schedule:" not in canary + assert "notify:" not in canary + assert "schedule:" in stack + assert "uses: ./.github/workflows/canary.yml" in stack + assert "workflow_run:" not in stack + assert "notify:" not in stack + assert 'workflows: ["Full stack e2e"]' in report + assert "github.event.workflow_run.event == 'schedule'" in report + assert "failure" in report and "timed_out" in report and "startup_failure" in report + assert "if: always()" in report + assert "actions: read" in report + assert "X-Hub-Signature-256" in report + assert "chat_thread_key" in report diff --git a/uv.lock b/uv.lock index afffcf2..7f43567 100644 --- a/uv.lock +++ b/uv.lock @@ -349,7 +349,7 @@ wheels = [ [[package]] name = "claude-code-plugin" -version = "0.2.10" +version = "0.2.11" source = { virtual = "." } dependencies = [ { name = "aiohttp" },