diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index 6ede163..b869565 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -1,15 +1,10 @@ name: Canary — plugin vs Codex main # Codex main moves fast and ships a prerelease cut (@alpha) near-daily, so the -# host can break us even when we don't push. Run the host-interface contract -# tests against the freshest main prerelease twice a day and alert on failure. -# The live channel suite chains off this run, so the canary leads and live -# follows on the same cadence. +# host can break us even when we don't push. This reusable check exercises the +# host interface against the freshest main prerelease before full-stack suites. on: - schedule: - # 2x/day at 6 AM and 6 PM America/Los_Angeles (PDT/UTC-7 basis; cron is UTC). - - cron: "13 13 * * *" # 06:13 PT - - cron: "13 1 * * *" # 18:13 PT + workflow_call: workflow_dispatch: {} permissions: @@ -46,19 +41,3 @@ jobs: # only after this latest-host canary succeeds. - name: Full offline suite vs real Codex run: pytest -q - - notify: - needs: canary - if: always() && github.event_name == 'schedule' && needs.canary.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\": \"🚨 codex-plugin canary FAILED against Codex main: ${{ 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 14d9fe6..e8a5617 100644 --- a/.github/workflows/live-stack.yml +++ b/.github/workflows/live-stack.yml @@ -1,12 +1,12 @@ name: Full stack e2e on: + schedule: + - cron: "13 13 * * *" + - cron: "13 1 * * *" pull_request: types: [opened, synchronize, reopened, ready_for_review] workflow_dispatch: - workflow_run: - workflows: ["Canary — plugin vs Codex main"] - types: [completed] concurrency: group: inkbox-live-aut-tunnel @@ -14,15 +14,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 @@ -36,9 +45,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 @@ -52,9 +59,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 @@ -71,9 +76,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 @@ -88,9 +91,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 @@ -108,29 +109,3 @@ jobs: if [ "$result" != "success" ]; then failed=1; fi done exit "$failed" - - 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\": \"🚨 codex-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..e0ce3ca --- /dev/null +++ b/.github/workflows/scheduled-failure-report.yml @@ -0,0 +1,64 @@ +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 00e0f70..a34ae1b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "codex-plugin" -version = "0.2.10" +version = "0.2.11" description = "Inkbox bridge for Codex — 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