-
Notifications
You must be signed in to change notification settings - Fork 0
Add Railway, CI-failure, and Supabase ingestion webhooks #968
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
BigSimmo
merged 15 commits into
main
from
claude/repository-webhook-recommendations-oviufb
Jul 20, 2026
Merged
Changes from all commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
0f972e6
Add Railway, CI-failure, and Supabase ingestion webhooks
claude f139b9a
Merge branch 'main' into claude/repository-webhook-recommendations-ov…
BigSimmo d59bb8c
Merge branch 'main' into claude/repository-webhook-recommendations-ov…
BigSimmo d4c9790
Merge branch 'main' into claude/repository-webhook-recommendations-ov…
BigSimmo cb7352d
Merge branch 'main' into claude/repository-webhook-recommendations-ov…
BigSimmo af5066c
Merge branch 'main' into claude/repository-webhook-recommendations-ov…
BigSimmo ea43cd1
Address review: surface reindex-flag clear failures; harden CI-notify…
claude 9b7fcbe
Merge branch 'main' into claude/repository-webhook-recommendations-ov…
BigSimmo a11a53c
Merge branch 'main' into claude/repository-webhook-recommendations-ov…
BigSimmo d9d0a4e
Merge branch 'main' into claude/repository-webhook-recommendations-ov…
BigSimmo 96fe2af
Merge branch 'main' into claude/repository-webhook-recommendations-ov…
BigSimmo 3474a70
Merge branch 'main' into claude/repository-webhook-recommendations-ov…
BigSimmo fc03855
Merge branch 'main' into claude/repository-webhook-recommendations-ov…
BigSimmo 6839ae1
Merge branch 'main' into claude/repository-webhook-recommendations-ov…
BigSimmo 2e87c9d
test: strengthen enqueue tests per review
claude File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,95 @@ | ||
| name: Notify CI failure | ||
|
|
||
| # Fires after another workflow completes and pings chat when a run on a | ||
| # protected branch fails. This is the outbound side of recommendation #2: a | ||
| # solo-maintainer safety net so a red `main` (which auto-deploys to production) | ||
| # is noticed immediately rather than on the next manual check. | ||
| # | ||
| # Delivery uses plain `curl` to Slack / Discord incoming webhooks stored as | ||
| # repository secrets (SLACK_WEBHOOK_URL / DISCORD_WEBHOOK_URL). Set either, both, | ||
| # or neither — a missing secret simply skips that channel. No external actions are | ||
| # used, so the pinned-action allowlist (scripts/github-action-pins.mjs) is not | ||
| # involved. See docs/webhooks.md. | ||
|
|
||
| on: | ||
| workflow_run: | ||
| workflows: | ||
| - CI | ||
| - SAST | ||
| - Secret Scan | ||
| - PR Policy | ||
| - Live domain monitor | ||
| - Live drift check | ||
| - Eval Canary | ||
| - Ingestion Autopilot | ||
| - Docker image build | ||
| types: | ||
| - completed | ||
|
|
||
| permissions: | ||
| contents: read | ||
|
|
||
| concurrency: | ||
| group: notify-ci-failure-${{ github.event.workflow_run.id }} | ||
| cancel-in-progress: false | ||
|
|
||
| jobs: | ||
| notify: | ||
| name: Notify on failure | ||
| runs-on: ubuntu-24.04 | ||
| timeout-minutes: 5 | ||
| # Only alert on genuine failures of runs against protected branches. | ||
| # The head_repository guard restricts notifications to runs that originated | ||
| # in THIS repo (push, schedule, workflow_dispatch). A fork PR's triggering | ||
| # run also fires workflow_run with repo secrets in scope, and a fork could | ||
| # name its branch `main`/`release/...` to forge or spam alerts — the guard | ||
| # blocks that while preserving the scheduled production monitors (which run | ||
| # on `schedule`, so an `event == 'push'` filter would wrongly drop them). | ||
| if: >- | ||
| github.event.workflow_run.conclusion == 'failure' && | ||
| github.event.workflow_run.head_repository.full_name == github.repository && | ||
| (github.event.workflow_run.head_branch == 'main' || | ||
| startsWith(github.event.workflow_run.head_branch, 'release/')) | ||
| steps: | ||
| - name: Post failure notification | ||
| env: | ||
| SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} | ||
| DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} | ||
| WORKFLOW_NAME: ${{ github.event.workflow_run.name }} | ||
| HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} | ||
| RUN_URL: ${{ github.event.workflow_run.html_url }} | ||
| RUN_EVENT: ${{ github.event.workflow_run.event }} | ||
| REPO: ${{ github.repository }} | ||
| run: | | ||
| set -euo pipefail | ||
|
|
||
| message="🔴 *CI failure:* ${WORKFLOW_NAME} failed on \`${HEAD_BRANCH}\` (${REPO}, ${RUN_EVENT}) | ||
| ${RUN_URL}" | ||
|
|
||
| if [ -z "${SLACK_WEBHOOK_URL:-}" ] && [ -z "${DISCORD_WEBHOOK_URL:-}" ]; then | ||
| echo "No SLACK_WEBHOOK_URL or DISCORD_WEBHOOK_URL secret set; nothing to notify." | ||
| exit 0 | ||
| fi | ||
|
|
||
| json_escape() { | ||
| # Escape a string for embedding in JSON via a here-doc-free jq-less approach. | ||
| python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))' | ||
| } | ||
|
|
||
| payload_text=$(printf '%s' "$message" | json_escape) | ||
|
|
||
| if [ -n "${SLACK_WEBHOOK_URL:-}" ]; then | ||
| echo "Posting to Slack..." | ||
| curl --fail --silent --show-error --max-time 15 \ | ||
| -X POST -H 'Content-Type: application/json' \ | ||
| --data "{\"text\": ${payload_text}}" \ | ||
| "$SLACK_WEBHOOK_URL" || echo "Slack post failed (non-fatal)." | ||
| fi | ||
|
|
||
| if [ -n "${DISCORD_WEBHOOK_URL:-}" ]; then | ||
| echo "Posting to Discord..." | ||
| curl --fail --silent --show-error --max-time 15 \ | ||
| -X POST -H 'Content-Type: application/json' \ | ||
| --data "{\"content\": ${payload_text}}" \ | ||
| "$DISCORD_WEBHOOK_URL" || echo "Discord post failed (non-fatal)." | ||
| fi | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,98 @@ | ||
| # Webhooks | ||
|
|
||
| This repo ships three webhook integrations. Two are inbound receivers under | ||
| `src/app/api/webhooks/`; one is an outbound GitHub Actions notifier. All three | ||
| share the same optional chat destinations. | ||
|
|
||
| | # | Integration | Direction | Entry point | | ||
| | --- | ------------------------------------ | --------- | --------------------------------------------- | | ||
| | 1 | Railway deploy → chat | inbound | `POST /api/webhooks/railway` | | ||
| | 2 | GitHub CI failure → chat | outbound | `.github/workflows/notify-ci-failure.yml` | | ||
| | 3 | Supabase document change → ingestion | inbound | `POST /api/webhooks/supabase/document-change` | | ||
|
|
||
| ## Chat destinations (shared) | ||
|
|
||
| Set either, both, or neither. A receiver with no destination configured still | ||
| accepts the event and reports it as undelivered. | ||
|
|
||
| - `SLACK_WEBHOOK_URL` — a Slack incoming webhook (`{ "text": … }`). | ||
| - `DISCORD_WEBHOOK_URL` — a Discord webhook (`{ "content": … }`). | ||
|
|
||
| The GitHub workflow reads these from repository **secrets** of the same name; the | ||
| inbound receivers read them from server env (`src/lib/env.ts`). | ||
|
|
||
| Store every secret below in Railway/GitHub secret stores — never in the repo. | ||
|
|
||
| ## 1. Railway deploy → chat | ||
|
|
||
| `POST /api/webhooks/railway` forwards notable Railway deploy status changes | ||
| (`SUCCESS`, `FAILED`, `CRASHED`, `REMOVED`) for the app + worker services to chat. | ||
| This reports the deploy outcome GitHub cannot see. | ||
|
|
||
| **Auth.** Railway lets you configure only a target URL (no custom headers or | ||
| signing), so the shared secret `RAILWAY_WEBHOOK_SECRET` (min 16 chars) travels as | ||
| a `?token=` query parameter and is compared constant-time. The receiver fails | ||
| closed (`503`) when the secret is unset and returns `401` on a bad token. | ||
|
|
||
| **Setup.** Railway → Project → Settings → Webhooks → add: | ||
|
|
||
| ``` | ||
| https://psychiatry.tools/api/webhooks/railway?token=<RAILWAY_WEBHOOK_SECRET> | ||
| ``` | ||
|
|
||
| Transient phases (`BUILDING`, `DEPLOYING`, `QUEUED`, …) are dropped to keep the | ||
| channel quiet; the receiver answers `200 { "skipped": true }` for them. | ||
|
|
||
| > Note: the receiver runs inside the app being deployed, so a notification about | ||
| > a deploy that takes the app fully down may not be delivered. Pair it with an | ||
| > external uptime monitor for hard-down detection. | ||
|
|
||
| ## 2. GitHub CI failure → chat | ||
|
|
||
| `.github/workflows/notify-ci-failure.yml` triggers on `workflow_run: completed` | ||
| for the key workflows (CI, SAST, Secret Scan, PR Policy, and the scheduled | ||
| monitors) and pings chat when a run on `main` or `release/*` fails. | ||
|
|
||
| **Setup.** Add repository secrets `SLACK_WEBHOOK_URL` and/or `DISCORD_WEBHOOK_URL`. | ||
| No secret → the workflow logs "nothing to notify" and exits cleanly. It posts with | ||
| `curl` only (no external actions), so the pinned-action allowlist is not involved. | ||
|
|
||
| ## 3. Supabase document change → ingestion | ||
|
|
||
| `POST /api/webhooks/supabase/document-change` turns the polling ingestion path | ||
| into an event-driven one: when a `public.documents` row is inserted outside the | ||
| app upload flow, or an existing row is flagged for reindex, the receiver enqueues | ||
| one `ingestion_jobs` row that the worker then claims. | ||
|
|
||
| **Auth.** Supabase Database Webhooks can send custom headers, so the shared secret | ||
| `SUPABASE_INGESTION_WEBHOOK_SECRET` (min 16 chars) is sent as | ||
| `Authorization: Bearer <secret>` (or `x-webhook-secret`) and compared | ||
| constant-time. Fails closed (`503`) when unset, `401` on a bad secret. | ||
|
|
||
| **When it enqueues (idempotent + loop-safe):** | ||
|
|
||
| - **INSERT** of a not-yet-`indexed` document → enqueue. The app upload route also | ||
| enqueues, but the `ingestion_jobs` one-open-job-per-document unique index makes | ||
| the duplicate insert a benign no-op (`already_active`). | ||
| - **UPDATE** acts only when `record.metadata.reindex_requested === true`, then | ||
| clears that flag via `apply_document_metadata_patch`. The worker's own | ||
| completion writes (also UPDATEs) never carry the flag, so they cannot retrigger | ||
| an endless loop. If the clear itself fails, the receiver responds `500` (not | ||
| `2xx`) so Supabase retries delivery until the flag is actually cleared — the | ||
| idempotent enqueue means a retry cannot double-queue. | ||
| - `checkIngestionMutationSafety` refuses while a job is already active, and the | ||
| enqueue reports `already_active` instead of erroring on a lost race. | ||
|
|
||
| Every write is owner-scoped (`owner_id`) — the app's single tenancy layer, since | ||
| the service-role client bypasses RLS. Events without an `owner_id`, on other | ||
| tables, or of type `DELETE` are skipped with `200`. | ||
|
|
||
| **Setup (Supabase).** Create a Database Webhook (or SQL trigger via | ||
| `supabase_functions.http_request`) on `public.documents` for INSERT/UPDATE with: | ||
|
|
||
| - URL: `https://psychiatry.tools/api/webhooks/supabase/document-change` | ||
| - HTTP header: `Authorization: Bearer <SUPABASE_INGESTION_WEBHOOK_SECRET>` | ||
|
|
||
| To request a reindex of an existing document, set | ||
| `metadata.reindex_requested = true` on its row; the receiver enqueues the job and | ||
| clears the flag. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| import { NextResponse } from "next/server"; | ||
| import { z } from "zod"; | ||
| import { env } from "@/lib/env"; | ||
| import { jsonError } from "@/lib/http"; | ||
| import { logger } from "@/lib/logger"; | ||
| import { postChatNotification, type ChatSeverity } from "@/lib/webhooks/chat-notify"; | ||
| import { verifyWebhookSecret } from "@/lib/webhooks/secret-auth"; | ||
|
|
||
| export const runtime = "nodejs"; | ||
| export const dynamic = "force-dynamic"; | ||
|
|
||
| // Receiver for Railway project deploy webhooks. Railway can only be configured | ||
| // with a target URL (no custom headers or signing), so the shared secret travels | ||
| // as `?token=` on the configured webhook URL and is compared constant-time. The | ||
| // route forwards notable deploy status changes for the app + worker services to | ||
| // Slack/Discord — the piece GitHub cannot report, since it does not know Railway's | ||
| // deploy outcome. See docs/webhooks.md for setup. | ||
|
|
||
| const namedEntitySchema = z.object({ name: z.string().optional() }).passthrough(); | ||
|
|
||
| const railwayWebhookSchema = z | ||
| .object({ | ||
| type: z.string().optional(), | ||
| status: z.string().optional(), | ||
| timestamp: z.string().optional(), | ||
| project: namedEntitySchema.optional(), | ||
| environment: namedEntitySchema.optional(), | ||
| service: namedEntitySchema.optional(), | ||
| deployment: z | ||
| .object({ id: z.string().optional(), meta: z.record(z.string(), z.unknown()).optional() }) | ||
| .passthrough() | ||
| .optional(), | ||
| }) | ||
| .passthrough(); | ||
|
|
||
| // Only forward status changes worth a ping; transient build/deploy phases are | ||
| // dropped to keep the channel quiet. | ||
| const NOTABLE_STATUSES = new Set(["SUCCESS", "FAILED", "CRASHED", "REMOVED"]); | ||
|
|
||
| function severityForStatus(status: string): ChatSeverity { | ||
| if (status === "SUCCESS") return "success"; | ||
| if (status === "FAILED" || status === "CRASHED") return "error"; | ||
| if (status === "REMOVED") return "warning"; | ||
| return "info"; | ||
| } | ||
|
|
||
| function serviceName(payload: z.infer<typeof railwayWebhookSchema>): string { | ||
| const fromService = payload.service?.name; | ||
| if (typeof fromService === "string" && fromService) return fromService; | ||
| const fromMeta = payload.deployment?.meta?.["serviceName"]; | ||
| if (typeof fromMeta === "string" && fromMeta) return fromMeta; | ||
| return "unknown service"; | ||
| } | ||
|
|
||
| export async function POST(request: Request) { | ||
| try { | ||
| const auth = verifyWebhookSecret(request, env.RAILWAY_WEBHOOK_SECRET, { allowQueryToken: true }); | ||
| if (!auth.ok) { | ||
| if (auth.reason === "misconfigured") { | ||
| return NextResponse.json( | ||
| { error: "Railway webhook receiver is not configured.", code: "webhook_not_configured" }, | ||
| { status: 503 }, | ||
| ); | ||
| } | ||
| return NextResponse.json({ error: "Unauthorized." }, { status: 401 }); | ||
| } | ||
|
|
||
| let rawBody: unknown; | ||
| try { | ||
| rawBody = await request.json(); | ||
| } catch { | ||
| return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 }); | ||
| } | ||
|
|
||
| const parsed = railwayWebhookSchema.safeParse(rawBody); | ||
| if (!parsed.success) { | ||
| // Authenticated but unrecognised shape: accept so Railway does not retry, | ||
| // and record it for debugging. | ||
| logger.warn("Railway webhook payload did not match expected shape"); | ||
| return NextResponse.json({ skipped: true, reason: "unrecognized_payload" }); | ||
| } | ||
|
|
||
| const payload = parsed.data; | ||
| const status = (payload.status ?? "").toUpperCase(); | ||
| if (!NOTABLE_STATUSES.has(status)) { | ||
| return NextResponse.json({ skipped: true, reason: "status_not_notable", status }); | ||
| } | ||
|
|
||
| const project = payload.project?.name ?? "Railway project"; | ||
| const environment = payload.environment?.name ?? "unknown environment"; | ||
| const service = serviceName(payload); | ||
| const severity = severityForStatus(status); | ||
|
|
||
| const delivery = await postChatNotification({ | ||
| title: `Railway deploy ${status.toLowerCase()}: ${service}`, | ||
| text: `Deploy of *${service}* in *${project}* (${environment}) reported status *${status}*.`, | ||
| severity, | ||
| fields: [ | ||
| { label: "Project", value: project }, | ||
| { label: "Environment", value: environment }, | ||
| { label: "Service", value: service }, | ||
| { label: "Status", value: status }, | ||
| ], | ||
| }); | ||
|
|
||
| return NextResponse.json({ forwarded: delivery.delivered, delivery }); | ||
| } catch (error) { | ||
| return jsonError(error); | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.