openvibers/openvibe.events Commits

GitHub webhook ingress for pull-request follow-ups (AL5) (#298)

OP OpenVibers committed 3 days ago · 83a6a14d6b · 22 files, +870 −0

* GitHub webhook ingress for pull-request follow-ups (AL5)

* Document the GitHub ingress and plant its secret sentinel

Changes

ddiff --git a/.env.example b/.env.example
iindex f9585d9..442a80b 100644
---- a/.env.example
++++ b/.env.example
@@@ -127,6 +127,10 @@ EVENTS_RECEIPT_RETENTION_DAYS=90
# characters or more; it is the one Events signs its own deliveries with.
#EVENTS_EVENTS_URL=https://openvibe.events
#EVENTS_EVENTS_SECRET=
++# GitHub webhooks in (AL5: server/github.js): the secret GitHub signs deliveries with (repo Settings →
++# Webhooks → the webhook's Secret; 16 characters or more, comma-separated while rotating). Unset: POST
++# /api/v1/in/github answers 404 and no github.* event is ever stored.
++#EVENTS_GITHUB_WEBHOOK_SECRET=
#OV_BILLING_AUDIENCE=openvibe.billing
#OV_BILLING_TIMEOUT_MS=10000
ddiff --git a/README.md b/README.md
iindex 38e37c2..1a8663b 100644
---- a/README.md
++++ b/README.md
@@@ -132,6 +132,49 @@ Events is the bus, so two of its columns name a person: the `actor` (the Subject
The route is `POST /internal/events`, signed (v2) and loopback only, mounted by `server/app.js`; `server/index.js` creates the two subscriptions at boot with `startSubscriptions` (`EVENTS_EVENTS_URL`, `EVENTS_EVENTS_SECRET` and the service's own OAuth client). Without the secret the route is off and the account events do not reach Events. The receipts of what it applied are in `account_data_events` (migration 0010), so a redelivered export or deletion changes nothing. `test/account-data.test.js` asserts `uncovered()` is empty, so a new table with a person column fails CI until it is covered or exempted with a reason.
++### GitHub webhooks in (AL5: a ticket follows its pull request)
++
++`server/github.js` is the one receiver of GitHub's pull-request events on this estate (nothing else receives
++them). GitHub POSTs a delivery to `POST /api/v1/in/github`; the ingress verifies `X-Hub-Signature-256` against
++`EVENTS_GITHUB_WEBHOOK_SECRET` (HMAC-SHA256 of the raw body; comma-separated while rotating; 16 characters or
++more), claims `X-GitHub-Delivery` in `github_deliveries` (migration 0012) so a redelivery stores nothing, and
++normalizes it into the ordinary github.* envelopes any subscriber can consume:
++
++- `github.check.completed` — a `check_run`, `check_suite` or `workflow_run` finished: `kind`, `conclusion`,
++ `check_name`, `head_sha`, `html_url`, `details_url`, and `output` (title/summary/text — the failing job's log
++ tail when GitHub sends one). Codes decides which conclusions are red.
++- `github.pr.updated` — a `pull_request` opened/synchronize/reopened/closed: `merged`, `mergeable`,
++ `mergeable_state`, `head_sha`, `head_ref`, `base`, `changed_files`, `html_url`, `files_url`.
++- `github.pr.commented` — an `issue_comment` on a PR, a submitted `pull_request_review` or a
++ `pull_request_review_comment`: `author`, `author_type` (Bot/User), `author_association`, `state`, `body`,
++ `comment_id`, `path`, `line`, `head_sha`. A bare approval, an edited comment and a dismissed review are not
++ new instructions and store nothing.
++
++The subject is `{ type: 'pr', id: '<owner>/<name>#<number>' }` (repo lowercased), the key Codes' `pr_bindings`
++matches on. `202` answers `{ event_ids }` (or `{ ignored }` for `ping` and kinds with no pull request); a
++redelivery answers `{ event_ids, duplicate: true }`; `401` is a bad signature; `404` while
++`EVENTS_GITHUB_WEBHOOK_SECRET` is unset. The route is off by default and never stores a secret. The body limit is
++1 MB; the per-address window is 300 deliveries a minute.
++
++**What the owner adds** (this PR creates nothing on GitHub):
++
++1. `/etc/openvibe/events.env`: `EVENTS_GITHUB_WEBHOOK_SECRET=<at least 16 random characters>`; restart
++ `openvibe-events`.
++2. Apply the `/api/v1/in/github` location from [deploy/nginx/openvibe.events.conf](deploy/nginx/openvibe.events.conf)
++ and reload nginx (the path must reach 127.0.0.1:4300; the catch-all under `/api/` answers 404).
++3. On the repository (`OpenVibers/*`) → Settings → Webhooks → Add webhook: Payload URL
++ `https://openvibe.events/api/v1/in/github`, content type `application/json`, the same secret, SSL
++ verification on, and the events **Check runs**, **Check suites**, **Pull requests**, **Issue comments**,
++ **Pull request reviews**, **Pull request review comments** (optionally **Workflow runs**). A `ping` delivery
++ answers `202 {"ignored":"ping"}`.
++4. On Codes: set `CODES_PR_FOLLOW_ENABLED=1`; Codes subscribes to the github.* event types above at boot and
++ receives them at `/internal/events` under its existing `CODES_EVENTS_SECRET`, with the usual Events delivery
++ v2 signature check. The pull request the worker opened binds to the task there, and the same receiver wakes it.
++
++`test/github.test.js` covers the signature, the normalization of every event type (including the log tail and a
++review comment's path/line), a multi-PR delivery claimed once, an idempotent redelivery, and the off-by-default
++route; `test/fixtures/github/` holds the recorded payloads.
++
### Retention: three tiers
[ADR-042](https://github.com/OpenVibers/OpenVibe.Contracts/blob/main/docs/adr/ADR-042-events-fabric.md) decision 8. An event lives in one tier at a time:
ddiff --git a/deploy/nginx/openvibe.events.conf b/deploy/nginx/openvibe.events.conf
iindex dc1381a..0776e07 100644
---- a/deploy/nginx/openvibe.events.conf
++++ b/deploy/nginx/openvibe.events.conf
@@@ -150,6 +150,22 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
++ # ── GitHub webhooks in (AL5: server/github.js) ──
++ # GitHub POSTs signed deliveries here; the service verifies X-Hub-Signature-256. Off (404) while
++ # EVENTS_GITHUB_WEBHOOK_SECRET is unset. The raw body is at most 1 MB in the service.
++ location = /api/v1/in/github {
++ limit_req zone=ovevents_apex_api burst=40 nodelay;
++ client_max_body_size 2m;
++ proxy_pass http://127.0.0.1:4300;
++ proxy_http_version 1.1;
++ proxy_set_header Connection "";
++ proxy_set_header Host $host;
++ proxy_set_header X-Real-IP $remote_addr;
++ proxy_set_header X-Forwarded-For $remote_addr;
++ proxy_set_header CF-Connecting-IP $remote_addr;
++ proxy_set_header X-Forwarded-Proto $scheme;
++ }
++
# ── Everything else under /api/: a problem document, never a page; the API stays host-local ──
location /api/ {
default_type application/problem+json;
ddiff --git a/migrations/0012_github_ingress.sql b/migrations/0012_github_ingress.sql
nnew file mode 100644
iindex 0000000..e379bbc
---- /dev/null
++++ b/migrations/0012_github_ingress.sql
@@@ -0,0 +1,12 @@
++-- phase: expand
++-- GitHub webhook ingress (AL5: a ticket follows its pull request). One row per GitHub delivery GUID, holding the
++-- event ids it stored, so a redelivered webhook answers them and stores no second event. The normalized github.*
++-- events live in the hot store like any other event; this table is only the ingress's idempotency record. One row
++-- per delivery (not per event) because a delivery can name several pull requests and they must be claimed together.
++-- Nothing here holds a secret.
++CREATE TABLE IF NOT EXISTS github_deliveries (
++ delivery_id text COLLATE "C" PRIMARY KEY, -- X-GitHub-Delivery
++ event_ids jsonb NOT NULL, -- ["evt_…", …] stored for it
++ event_types jsonb NOT NULL, -- ["github.check.completed", …] in the same order
++ received_at timestamptz NOT NULL DEFAULT now()
++);
ddiff --git a/server/app.js b/server/app.js
iindex 57e81fc..06f7084 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -73,6 +73,14 @@ function createApp({ config, store, auth, keys, worker, realtime, metrics, dnsLo
app.options('/realtime/stream', realtime.cors);
app.get('/realtime/stream', realtime.cors, realtime.handler);
++ // GitHub webhooks in (AL5: server/github.js): raw body (the HMAC covers it), mounted before the JSON
++ // parser. Off (404) without EVENTS_GITHUB_WEBHOOK_SECRET.
++ {
++ const { createGithubIngress } = require('./github');
++ const ingress = createGithubIngress({ config, store, log });
++ app.post('/api/v1/in/github', ingress.raw, ingress.handler);
++ }
++
app.use('/api', express.json({ limit: '2mb', type: ['application/json', 'application/*+json'] }));
app.get('/api/health', (_req, res) => {
ddiff --git a/server/config.js b/server/config.js
iindex bcbb583..31adbbf 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -27,6 +27,8 @@ const DEFAULT_SOURCES = ['live', 'media', 'network', 'community', 'chat', 'openr
'bot',
// Watch (OpenVibe.Watch): watch.observation.recorded, watch.watch.triggered, watch.check.failed
'watch',
++ // GitHub webhooks in (AL5): the ingress stores github.check.completed|github.pr.updated|github.pr.commented.
++ 'github',
// Run (OpenVibe.Run, plan T14): run.job.queued|started|succeeded|failed|cancelled|expired
'run',
// Space (OpenVibe.Space, plan T10 step 3): space.thread.created, space.post.created, space.moderation.action
@@@ -277,6 +279,12 @@ function load(env = process.env) {
secret: String(env.EVENTS_EVENTS_SECRET || '').trim(),
},
++ // GitHub webhooks in (AL5: server/github.js): HMAC secrets for X-Hub-Signature-256, comma-separated for
++ // rotation. Empty: POST /api/v1/in/github answers 404 and no github.* event is ever stored.
++ github: {
++ secrets: String(env.EVENTS_GITHUB_WEBHOOK_SECRET || '').split(',').map((s) => s.trim()).filter((s) => s.length >= 16),
++ },
++
// Realtime (SSE) gateway
realtime: {
maxConnections: int(env.REALTIME_MAX_CONNECTIONS, 2000),
ddiff --git a/server/github.js b/server/github.js
nnew file mode 100644
iindex 0000000..e882abb
---- /dev/null
++++ b/server/github.js
@@@ -0,0 +1,352 @@
++'use strict';
++
++/**
++ * GitHub webhooks in (AL5: a ticket follows its pull request).
++ *
++ * POST /api/v1/in/github
++ * X-GitHub-Delivery: the delivery GUID (idempotency: a redelivery answers the stored event ids)
++ * X-GitHub-Event: check_run | check_suite | workflow_run | pull_request |
++ * pull_request_review | pull_request_review_comment | issue_comment | ping
++ * X-Hub-Signature-256: sha256=<hex HMAC of the raw body> with one of config.github.secrets
++ *
++ * A delivery is normalized to one source-'github' envelope per pull request and stored through
++ * store.insertBatch like any other event, so Codes (and the portal) subscribes to it with the usual
++ * Events subscription — nothing polls GitHub:
++ *
++ * github.check.completed a check_run, check_suite or workflow_run finished → { repo, kind, conclusion,
++ * check_name, head_sha, html_url, details_url, output } (Codes decides what is red;
++ * output is the failing job's log tail when GitHub sends one)
++ * github.pr.updated a pull_request synchronize|reopened|closed|opened → { repo, pr_number, action,
++ * merged, mergeable, mergeable_state, head_sha, head_ref, base, base_ref,
++ * changed_files, additions, deletions, draft, title, html_url, files_url }
++ * (mergeable_state is GitHub's, often null on the webhook: conflicts through it are
++ * best-effort; files_url is where a worker can list the changed files)
++ * github.pr.commented an issue_comment on a PR, a submitted review or a review comment →
++ * { repo, pr_number, author, author_type, author_association, state, body,
++ * comment_id, path, line, head_sha, html_url }
++ *
++ * The subject of every envelope is { type: 'pr', id: '<owner>/<name>#<number>' } with the slug lowercased,
++ * the key Codes' pr_bindings matches on. 202 answers { event_ids } (or { ignored } for ping and event kinds
++ * with no pull request); a redelivery answers { event_ids, duplicate: true }; 401 on a bad signature; 404 while
++ * EVENTS_GITHUB_WEBHOOK_SECRET is unset. Nothing here prints or stores a secret.
++ *
++ * const ingress = createGithubIngress({ config, store, log });
++ * app.post('/api/v1/in/github', ingress.raw, ingress.handler); // before the JSON body parser
++ */
++const crypto = require('crypto');
++const express = require('express');
++const { ids, validate, http } = require('openvibe-contracts');
++
++const SOURCE = 'github';
++const ACTOR = Object.freeze({ type: 'service', id: 'github' });
++const MAX_BODY = 4000; // a comment/review body kept on an envelope
++const MAX_LOG_TAIL = 8_000; // a check's output text (the log tail GitHub sends) kept on an envelope
++const MAX_DELIVERIES = 20; // a check run is fanned out per pull request, at most this many
++const MIN_SECRET = 16;
++
++const cut = (v, n) => (typeof v === 'string' && v.length > n ? v.slice(0, n) : (typeof v === 'string' ? v : undefined));
++const lower = (v) => (typeof v === 'string' ? v.toLowerCase() : null);
++const repoOf = (p) => lower(p && p.repository && p.repository.full_name);
++const num = (v) => (Number.isInteger(v) && v > 0 ? v : null);
++
++/** pull request numbers a check payload names (check_run, check_suite, workflow_run). */
++function checkPrNumbers(p) {
++ const out = [];
++ const seen = new Set();
++ const take = (n) => { const k = num(n); if (k && !seen.has(k) && out.length < MAX_DELIVERIES) { seen.add(k); out.push(k); } };
++ const run = p.check_run || p.check_suite || p.workflow_run;
++ for (const pr of (run && run.pull_requests) || []) {
++ if (num(pr.number)) take(pr.number);
++ else if (typeof pr.url === 'string') { const m = /\/pulls\/(\d+)(?:\/|$)/.exec(pr.url); if (m) take(Number(m[1])); }
++ }
++ return out;
++}
++
++/** A check payload finished: conclusion, name, links and its output log tail, or null when it did not finish. */
++function checkCompleted(ghEvent, p) {
++ const run = p.check_run || p.check_suite || p.workflow_run;
++ if (!run || run.status !== 'completed') return null;
++ const url = run.html_url || run.url || (p.check_run && p.check_run.details_url) || null;
++ const out = run.output && typeof run.output === 'object' && !Array.isArray(run.output) ? run.output : null;
++ const output = out ? {
++ title: cut(out.title, 500),
++ summary: cut(out.summary, 4_000),
++ text: cut(out.text, MAX_LOG_TAIL),
++ annotations: Array.isArray(out.annotations) ? out.annotations.length : undefined,
++ } : null;
++ return {
++ event_type: 'github.check.completed',
++ check: {
++ kind: p.check_run ? 'check_run' : p.check_suite ? 'check_suite' : 'workflow_run',
++ conclusion: typeof run.conclusion === 'string' ? run.conclusion.slice(0, 40) : null,
++ check_name: cut(run.name, 200),
++ head_sha: typeof run.head_sha === 'string' ? run.head_sha.slice(0, 64) : null,
++ html_url: typeof url === 'string' ? url.slice(0, 2000) : null,
++ details_url: cut(p.check_run && p.check_run.details_url, 2000),
++ started_at: cut(run.started_at, 40),
++ completed_at: cut(run.completed_at, 40),
++ output,
++ },
++ prNumbers: checkPrNumbers(p),
++ };
++}
++
++function prUpdated(p) {
++ const pr = p.pull_request;
++ if (!pr || !num(p.number)) return null;
++ const head = pr.head && typeof pr.head === 'object' ? pr.head : {};
++ const base = pr.base && typeof pr.base === 'object' ? pr.base : {};
++ return {
++ event_type: 'github.pr.updated',
++ update: {
++ action: typeof p.action === 'string' ? p.action.slice(0, 40) : null,
++ merged: pr.merged === true,
++ mergeable: pr.mergeable === true ? true : pr.mergeable === false ? false : null,
++ mergeable_state: typeof pr.mergeable_state === 'string' ? pr.mergeable_state.slice(0, 40) : null,
++ draft: pr.draft === true,
++ title: cut(pr.title, 500),
++ head_sha: cut(head.sha, 64),
++ head_ref: cut(head.ref, 200),
++ base: cut(base.ref, 200),
++ base_ref: cut(base.ref, 200),
++ base_sha: cut(base.sha, 64),
++ changed_files: num(pr.changed_files),
++ additions: num(pr.additions),
++ deletions: num(pr.deletions),
++ html_url: cut(pr.html_url, 2000),
++ files_url: cut(pr.url ? `${String(pr.url).replace(/\/+$/, '')}/files` : null, 2000),
++ },
++ prNumbers: [p.number],
++ };
++}
++
++const userOf = (u) => (u && typeof u.login === 'string' ? { login: u.login.slice(0, 200), type: u.type === 'Bot' ? 'Bot' : 'User', association: cut(u.author_association, 40) } : null);
++
++/** A comment id; GitHub sends it as a number. */
++const commentId = (v) => (Number.isInteger(v) && v > 0 ? v : null);
++
++function prCommented(ghEvent, p) {
++ const repo = repoOf(p);
++ if (!repo) return null;
++ if (ghEvent === 'issue_comment') {
++ // An issue_comment is a PR comment only when the issue is a pull request. Only a newly posted one wakes
++ // anyone: an edited or deleted comment is not a new instruction (and a deleted "stop" must not end a ticket).
++ if (p.action !== 'created') return null;
++ if (!p.issue || !p.issue.pull_request || !num(p.issue.number)) return null;
++ const by = userOf(p.comment && p.comment.user);
++ if (!by || typeof (p.comment && p.comment.body) !== 'string') return null;
++ const pr = p.issue.pull_request || {};
++ return {
++ event_type: 'github.pr.commented',
++ comment: {
++ author: by.login, author_type: by.type, author_association: by.association, state: 'commented',
++ body: cut(p.comment.body, MAX_BODY),
++ comment_id: commentId(p.comment.id),
++ head_sha: cut(pr.head && pr.head.sha, 64),
++ html_url: cut(p.comment.html_url, 2000),
++ },
++ prNumbers: [p.issue.number],
++ };
++ }
++ if (ghEvent === 'pull_request_review') {
++ const pr = p.pull_request;
++ if (!pr || !num(pr.number)) return null;
++ // A dismissal is not a new instruction; a bare approval is not a comment. A review that asks for changes
++ // without a summary still wakes the worker, with a note to read the review on the pull request.
++ if (p.action !== 'submitted') return null;
++ const review = p.review && typeof p.review === 'object' ? p.review : {};
++ const by = userOf(review.user);
++ if (!by) return null;
++ const state = cut(review.state, 40) || 'commented';
++ let body = cut(review.body, MAX_BODY) || '';
++ if (state === 'approved' && !body) return null;
++ if (!body && state === 'changes_requested') body = 'The reviewer requested changes without a summary; read the review on the pull request.';
++ if (!body) return null;
++ return {
++ event_type: 'github.pr.commented',
++ comment: {
++ author: by.login, author_type: by.type, author_association: by.association, state,
++ body,
++ comment_id: commentId(review.id),
++ head_sha: cut(pr.head && pr.head.sha, 64),
++ html_url: cut(review.html_url || pr.html_url, 2000),
++ },
++ prNumbers: [pr.number],
++ };
++ }
++ if (ghEvent === 'pull_request_review_comment') {
++ const pr = p.pull_request;
++ if (!pr || !num(pr.number)) return null;
++ if (p.action !== 'created') return null;
++ const by = userOf(p.comment && p.comment.user);
++ if (!by || typeof (p.comment && p.comment.body) !== 'string') return null;
++ const c = p.comment;
++ return {
++ event_type: 'github.pr.commented',
++ comment: {
++ author: by.login, author_type: by.type, author_association: by.association, state: 'commented',
++ body: cut(c.body, MAX_BODY),
++ comment_id: commentId(c.id),
++ path: cut(c.path, 500),
++ line: num(c.line) || num(c.original_line),
++ head_sha: cut(pr.head && pr.head.sha, 64),
++ html_url: cut(c.html_url, 2000),
++ },
++ prNumbers: [pr.number],
++ };
++ }
++ return null;
++}
++
++/**
++ * One normalized result per envelope: { event_type, prNumber, payload }. null: nothing to store
++ * (an unfinished check, a non-PR issue, an uninteresting action). Throws on a malformed envelope.
++ */
++function normalize(ghEvent, p) {
++ if (!p || typeof p !== 'object' || Array.isArray(p)) throw new Error('the webhook body must be a JSON object');
++ const repo = repoOf(p);
++ if (!repo) throw new Error('the webhook body names no repository');
++ if (ghEvent === 'check_run' || ghEvent === 'check_suite' || ghEvent === 'workflow_run') {
++ const c = checkCompleted(ghEvent, p);
++ if (!c) return [];
++ return c.prNumbers.map((n) => ({
++ event_type: c.event_type, prNumber: n,
++ payload: {
++ repo, kind: c.check.kind, conclusion: c.check.conclusion, check_name: c.check.check_name,
++ head_sha: c.check.head_sha, html_url: c.check.html_url, details_url: c.check.details_url,
++ started_at: c.check.started_at, completed_at: c.check.completed_at, output: c.check.output || null,
++ },
++ }));
++ }
++ if (ghEvent === 'pull_request') {
++ if (!['synchronize', 'reopened', 'closed', 'opened'].includes(p.action)) return [];
++ const u = prUpdated(p);
++ if (!u) return [];
++ return u.prNumbers.map((n) => ({
++ event_type: u.event_type, prNumber: n,
++ payload: { repo, pr_number: n, ...u.update },
++ }));
++ }
++ if (ghEvent === 'issue_comment' || ghEvent === 'pull_request_review' || ghEvent === 'pull_request_review_comment') {
++ const c = prCommented(ghEvent, p);
++ if (!c) return [];
++ return c.prNumbers.map((n) => ({
++ event_type: c.event_type, prNumber: n,
++ payload: { repo, pr_number: n, ...c.comment },
++ }));
++ }
++ return [];
++}
++
++/** sha256=<hex> over the raw body with one of the secrets, compared in constant time. */
++function verifies(raw, header, secrets) {
++ if (typeof header !== 'string') return false;
++ const m = /^sha256=([0-9a-f]{64})$/.exec(header.trim());
++ if (!m) return false;
++ const got = Buffer.from(m[1], 'hex');
++ return (secrets || []).some((s) => {
++ if (typeof s !== 'string' || !s.length) return false;
++ const want = crypto.createHmac('sha256', s).update(raw).digest();
++ return want.length === got.length && crypto.timingSafeEqual(want, got);
++ });
++}
++
++/** A tiny per-address sliding window (GitHub delivers from many addresses; this stops one stuck sender). */
++function createLimiter({ perMinute = 300 } = {}) {
++ const hits = new Map();
++ return (ip) => {
++ const now = Date.now();
++ const list = (hits.get(ip) || []).filter((t) => now - t < 60_000);
++ list.push(now);
++ if (hits.size > 10000 && !hits.has(ip)) hits.delete(hits.keys().next().value);
++ hits.set(ip, list);
++ return list.length <= perMinute;
++ };
++}
++
++function createGithubIngress({ config, store, log = console, now = () => Date.now() } = {}) {
++ const db = store.db;
++ const secrets = ((config.github && config.github.secrets) || []).filter((s) => typeof s === 'string' && s.length >= MIN_SECRET);
++ const limited = createLimiter();
++ const stats = { received: 0, stored: 0, duplicates: 0, ignored: 0, refused: 0 };
++
++ async function handler(req, res) {
++ const ctx = http.requestContext(req.headers);
++ const problem = (status, code, detail) => http.sendProblem(res, status, code, { detail, ctx });
++ if (!secrets.length) return problem(404, 'events.github_disabled', 'GitHub webhooks are not configured');
++ if (!limited(req.ip || req.socket.remoteAddress || 'unknown')) { stats.refused++; return problem(429, 'events.rate_limited', 'too many webhook deliveries'); }
++ const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
++ if (!verifies(raw, req.headers['x-hub-signature-256'], secrets)) { stats.refused++; return problem(401, 'events.bad_signature', 'X-Hub-Signature-256 does not verify'); }
++ const ghEvent = String(req.headers['x-github-event'] || '');
++ const delivery = String(req.headers['x-github-delivery'] || '').slice(0, 100);
++ if (!/^[0-9a-f-]{8,100}$/i.test(delivery)) { stats.refused++; return problem(400, 'events.bad_delivery', 'X-GitHub-Delivery is required'); }
++ stats.received++;
++ if (ghEvent === 'ping') return res.status(202).json({ ignored: 'ping' });
++ let payload;
++ try { payload = JSON.parse(raw.toString('utf8')); } catch { stats.refused++; return problem(400, 'events.bad_delivery', 'the webhook body is not JSON'); }
++ let normalized;
++ try { normalized = normalize(ghEvent, payload); } catch (e) { stats.refused++; return problem(400, 'events.bad_delivery', e.message); }
++ if (!normalized.length) { stats.ignored++; return res.status(202).json({ ignored: ghEvent || 'unknown' }); }
++ const at = new Date(now()).toISOString();
++ const trace = crypto.randomBytes(16).toString('hex');
++ const items = normalized.map((n) => ({
++ event_id: ids.newId('event', now()), event_type: n.event_type, version: 1, source: SOURCE,
++ actor: ACTOR, timestamp: at, trace_id: trace, priority: 'important', visibility: 'internal',
++ subject: { type: 'pr', id: `${repoOf(payload)}#${n.prNumber}` }, payload: n.payload,
++ }));
++ for (const env of items) {
++ const v = validate('events.event-envelope@1', env);
++ if (!v.valid) {
++ stats.refused++;
++ return problem(400, 'events.bad_delivery', 'the webhook names no pull request Codes can follow');
++ }
++ }
++ // One claim per delivery (it may name several pull requests, and they must be claimed together): a
++ // redelivery answers these ids without storing again. insertBatch runs its own transaction after the
++ // claim commits; a failure releases the claim so GitHub's retry stores the events.
++ const eventIds = items.map((i) => i.event_id);
++ const seen = await db.maybe('SELECT event_ids FROM github_deliveries WHERE delivery_id = $1', [delivery]);
++ if (seen) {
++ // A claim is written just before the batch. If the process died in that window the claimed events do
++ // not exist; release the stale claim and store them now instead of answering a duplicate for nothing.
++ const claimed = Array.isArray(seen.event_ids) ? seen.event_ids : [];
++ const present = await Promise.all(claimed.map((id) => store.getEvent(id).catch(() => null)));
++ if (claimed.length && present.every(Boolean)) {
++ stats.duplicates++;
++ return res.status(202).json({ event_ids: claimed, duplicate: true });
++ }
++ await db.exec('DELETE FROM github_deliveries WHERE delivery_id = $1', [delivery]).catch(() => {});
++ }
++ try {
++ await db.exec(
++ 'INSERT INTO github_deliveries (delivery_id, event_ids, event_types) VALUES ($1, $2::jsonb, $3::jsonb)',
++ [delivery, JSON.stringify(eventIds), JSON.stringify(items.map((i) => i.event_type))],
++ );
++ } catch (err) {
++ const twin = await db.maybe('SELECT event_ids FROM github_deliveries WHERE delivery_id = $1', [delivery]).catch(() => null);
++ if (twin) {
++ stats.duplicates++;
++ return res.status(202).json({ event_ids: twin.event_ids, duplicate: true });
++ }
++ // A concurrent twin claimed it first and has not committed yet: GitHub retries onto the committed row.
++ log.error(`[github] ingress claim failed for ${delivery}:`, err.message);
++ return problem(503, 'events.ingress_failed', 'the delivery was not stored; GitHub will retry it');
++ }
++ try {
++ await store.insertBatch(items, { publisher: 'github-ingress', requestId: `github:${delivery}` });
++ } catch (err) {
++ // The claim is released so the redelivery stores the events (a duplicate delivery onto committed
++ // envelopes answers duplicate from the receipts instead of storing twice).
++ await db.exec('DELETE FROM github_deliveries WHERE delivery_id = $1', [delivery]).catch(() => {});
++ log.error(`[github] ingress failed for ${delivery}:`, err.message);
++ return problem(503, 'events.ingress_failed', 'the delivery was not stored; GitHub will retry it');
++ }
++ stats.stored += items.length;
++ return res.status(202).json({ event_ids: eventIds });
++ }
++
++ const raw = express.raw({ type: () => true, limit: '1mb' });
++ return { handler, raw, normalize, verifies, stats: () => ({ ...stats, enabled: secrets.length > 0 }) };
++}
++
++module.exports = { createGithubIngress, normalize, verifies, SOURCE };
ddiff --git a/server/openapi.js b/server/openapi.js
iindex fb551b0..c5b4548 100644
---- a/server/openapi.js
++++ b/server/openapi.js
@@@ -102,6 +102,24 @@ route('get', '/events/{id}', {
responses: { 200: json('The event and its position', eventItem), ...errors([401, 403, 404, 429]) },
});
++// ── GitHub webhooks in (AL5) ───────────────────────────────────────────────────────────────────
++route('post', '/in/github', {
++ operationId: 'receiveGithubWebhook', summary: 'Receive a signed GitHub webhook (AL5)', tags: ['github'], security: [],
++ description: 'GitHub\'s pull request, check and comment deliveries. The body is verified with X-Hub-Signature-256 (HMAC-SHA256 with EVENTS_GITHUB_WEBHOOK_SECRET) and X-GitHub-Delivery is claimed once; a delivery is normalized to github.check.completed, github.pr.updated or github.pr.commented envelopes and stored for subscribers. Off (404) while the secret is unset; a ping answers 202 and stores nothing.',
++ parameters: [
++ { name: 'X-Hub-Signature-256', in: 'header', required: true, schema: str({ pattern: '^sha256=[0-9a-f]{64}$' }) },
++ { name: 'X-GitHub-Event', in: 'header', required: true, schema: str() },
++ { name: 'X-GitHub-Delivery', in: 'header', required: true, schema: str() },
++ ],
++ requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', additionalProperties: true } } } },
++ responses: {
++ 202: json('Stored, or ignored when the delivery carries no pull request', { type: 'object', properties: {
++ event_ids: arrayOf(str({ pattern: '^evt_[0-9A-HJKMNP-TV-Z]{26}$' })), duplicate: { type: 'boolean' }, ignored: str(),
++ } }),
++ ...errors([400, 401, 404, 429]),
++ },
++});
++
// ── Checkpoints ───────────────────────────────────────────────────────────────────────────────
route('get', '/checkpoints', {
operationId: 'getCheckpoint', summary: 'The caller\'s stored cursor for a topic pattern', tags: ['checkpoints'], security: [...SERVICE, ...APP],
ddiff --git a/test/fixtures/github/check-run-failed-two-prs.json b/test/fixtures/github/check-run-failed-two-prs.json
nnew file mode 100644
iindex 0000000..9dd67ca
---- /dev/null
++++ b/test/fixtures/github/check-run-failed-two-prs.json
@@@ -0,0 +1,18 @@
++{
++ "action": "completed",
++ "check_run": {
++ "id": 224,
++ "name": "codes-ci / tests",
++ "status": "completed",
++ "conclusion": "failure",
++ "head_sha": "def456abc123def456abc123def456abc123def4",
++ "html_url": "https://github.com/OpenVibers/OpenVibe.Codes/actions/runs/113/job/224",
++ "output": { "title": "1 failing test", "summary": "one check names two pull requests", "text": "not ok 1 - shared" },
++ "pull_requests": [
++ { "id": 888, "number": 888, "base": { "ref": "main" }, "head": { "ref": "al5-a" }, "url": "https://api.github.com/repos/OpenVibers/OpenVibe.Codes/pulls/888" },
++ { "id": 999, "number": 999, "base": { "ref": "main" }, "head": { "ref": "al5-scratch" }, "url": "https://api.github.com/repos/OpenVibers/OpenVibe.Codes/pulls/999" }
++ ]
++ },
++ "repository": { "full_name": "OpenVibers/OpenVibe.Codes" },
++ "sender": { "login": "github-actions[bot]", "type": "Bot" }
++}
ddiff --git a/test/fixtures/github/check-run-failed.json b/test/fixtures/github/check-run-failed.json
nnew file mode 100644
iindex 0000000..2e15455
---- /dev/null
++++ b/test/fixtures/github/check-run-failed.json
@@@ -0,0 +1,31 @@
++{
++ "action": "completed",
++ "check_run": {
++ "id": 222,
++ "name": "codes-ci / tests",
++ "status": "completed",
++ "conclusion": "failure",
++ "head_sha": "abc123def456abc123def456abc123def456abc1",
++ "details_url": "https://github.com/OpenVibers/OpenVibe.Codes/actions/runs/111",
++ "html_url": "https://github.com/OpenVibers/OpenVibe.Codes/actions/runs/111/job/222",
++ "started_at": "2026-11-10T10:00:00Z",
++ "completed_at": "2026-11-10T10:04:12Z",
++ "output": {
++ "title": "1 failing test",
++ "summary": "test/pr-bindings.test.js failed on the branch",
++ "text": "not ok 3 - a red check wakes the worker once\n AssertionError: expected 0 to equal 1\n at Test.<anonymous> (test/pr-bindings.test.js:88:9)\n# tests 12\n# pass 11\n# fail 1\n",
++ "annotations_count": 0
++ },
++ "pull_requests": [
++ {
++ "id": 999,
++ "number": 999,
++ "base": { "ref": "main" },
++ "head": { "ref": "al5-scratch", "sha": "abc123def456abc123def456abc123def456abc1" },
++ "url": "https://api.github.com/repos/OpenVibers/OpenVibe.Codes/pulls/999"
++ }
++ ]
++ },
++ "repository": { "full_name": "OpenVibers/OpenVibe.Codes" },
++ "sender": { "login": "github-actions[bot]", "type": "Bot" }
++}
ddiff --git a/test/fixtures/github/check-run-success.json b/test/fixtures/github/check-run-success.json
nnew file mode 100644
iindex 0000000..d5ac27a
---- /dev/null
++++ b/test/fixtures/github/check-run-success.json
@@@ -0,0 +1 @@
++{"action":"completed","check_run":{"conclusion":"success","details_url":"https://github.com/OpenVibers/OpenVibe.Codes/actions/runs/112","head_sha":"abc123def456abc123def456abc123def456abc1","html_url":"https://github.com/OpenVibers/OpenVibe.Codes/actions/runs/112/job/223","id":223,"name":"codes-ci / tests","pull_requests":[{"id":999,"number":999,"url":"https://api.github.com/repos/OpenVibers/OpenVibe.Codes/pulls/999"}],"status":"completed"},"repository":{"full_name":"OpenVibers/OpenVibe.Codes"}}
ddiff --git a/test/fixtures/github/check-suite-failed.json b/test/fixtures/github/check-suite-failed.json
nnew file mode 100644
iindex 0000000..22b1885
---- /dev/null
++++ b/test/fixtures/github/check-suite-failed.json
@@@ -0,0 +1,16 @@
++{
++ "action": "completed",
++ "check_suite": {
++ "id": 300,
++ "status": "completed",
++ "conclusion": "failure",
++ "head_branch": "al5-scratch",
++ "head_sha": "abc123def456abc123def456abc123def456abc1",
++ "html_url": "https://github.com/OpenVibers/OpenVibe.Codes/actions/runs/120",
++ "pull_requests": [
++ { "number": 999, "base": { "ref": "main" }, "head": { "ref": "al5-scratch", "sha": "abc123def456abc123def456abc123def456abc1" }, "url": "https://api.github.com/repos/OpenVibers/OpenVibe.Codes/pulls/999" }
++ ]
++ },
++ "repository": { "full_name": "OpenVibers/OpenVibe.Codes" },
++ "sender": { "login": "github-actions[bot]", "type": "Bot" }
++}
ddiff --git a/test/fixtures/github/issue-comment-bot.json b/test/fixtures/github/issue-comment-bot.json
nnew file mode 100644
iindex 0000000..062a343
---- /dev/null
++++ b/test/fixtures/github/issue-comment-bot.json
@@@ -0,0 +1 @@
++{"action":"created","comment":{"body":"All checks have passed. 2/2 successful checks.","html_url":"https://github.com/OpenVibers/OpenVibe.Codes/pull/999#issuecomment-2","id":2,"user":{"login":"github-actions[bot]","type":"Bot"}},"issue":{"html_url":"https://github.com/OpenVibers/OpenVibe.Codes/pull/999","number":999,"pull_request":{"url":"https://api.github.com/repos/OpenVibers/OpenVibe.Codes/pulls/999"}},"repository":{"full_name":"OpenVibers/OpenVibe.Codes"},"sender":{"login":"github-actions[bot]","type":"Bot"}}
ddiff --git a/test/fixtures/github/issue-comment-edited.json b/test/fixtures/github/issue-comment-edited.json
nnew file mode 100644
iindex 0000000..1c16d78
---- /dev/null
++++ b/test/fixtures/github/issue-comment-edited.json
@@@ -0,0 +1,15 @@
++{
++ "action": "edited",
++ "comment": {
++ "id": 3,
++ "body": "stop",
++ "html_url": "https://github.com/OpenVibers/OpenVibe.Codes/pull/999#issuecomment-3",
++ "user": { "login": "owner-person", "type": "User" }
++ },
++ "issue": {
++ "number": 999,
++ "pull_request": { "url": "https://api.github.com/repos/OpenVibers/OpenVibe.Codes/pulls/999" }
++ },
++ "repository": { "full_name": "OpenVibers/OpenVibe.Codes" },
++ "sender": { "login": "owner-person", "type": "User" }
++}
ddiff --git a/test/fixtures/github/issue-comment-stop.json b/test/fixtures/github/issue-comment-stop.json
nnew file mode 100644
iindex 0000000..59310c4
---- /dev/null
++++ b/test/fixtures/github/issue-comment-stop.json
@@@ -0,0 +1 @@
++{"action":"created","comment":{"body":"stop — I will take this one from here","html_url":"https://github.com/OpenVibers/OpenVibe.Codes/pull/999#issuecomment-1","id":1,"user":{"login":"owner-person","type":"User"}},"issue":{"html_url":"https://github.com/OpenVibers/OpenVibe.Codes/pull/999","number":999,"pull_request":{"url":"https://api.github.com/repos/OpenVibers/OpenVibe.Codes/pulls/999"}},"repository":{"full_name":"OpenVibers/OpenVibe.Codes"},"sender":{"login":"owner-person","type":"User"}}
ddiff --git a/test/fixtures/github/pull-request-closed-merged.json b/test/fixtures/github/pull-request-closed-merged.json
nnew file mode 100644
iindex 0000000..14917d1
---- /dev/null
++++ b/test/fixtures/github/pull-request-closed-merged.json
@@@ -0,0 +1,21 @@
++{
++ "action": "closed",
++ "number": 999,
++ "pull_request": {
++ "number": 999,
++ "title": "AL5: a ticket follows its pull request",
++ "merged": true,
++ "mergeable": true,
++ "mergeable_state": "clean",
++ "draft": false,
++ "html_url": "https://github.com/OpenVibers/OpenVibe.Codes/pull/999",
++ "url": "https://api.github.com/repos/OpenVibers/OpenVibe.Codes/pulls/999",
++ "changed_files": 7,
++ "additions": 412,
++ "deletions": 9,
++ "head": { "ref": "al5-scratch", "sha": "abc123def456abc123def456abc123def456abc1" },
++ "base": { "ref": "main", "sha": "b9354f8b9354f8b9354f8b9354f8b9354f8b9354" }
++ },
++ "repository": { "full_name": "OpenVibers/OpenVibe.Codes" },
++ "sender": { "login": "OpenVibers", "type": "User" }
++}
ddiff --git a/test/fixtures/github/pull-request-dirty.json b/test/fixtures/github/pull-request-dirty.json
nnew file mode 100644
iindex 0000000..da96632
---- /dev/null
++++ b/test/fixtures/github/pull-request-dirty.json
@@@ -0,0 +1,21 @@
++{
++ "action": "synchronize",
++ "number": 999,
++ "pull_request": {
++ "number": 999,
++ "title": "AL5: a ticket follows its pull request",
++ "merged": false,
++ "mergeable": false,
++ "mergeable_state": "dirty",
++ "draft": false,
++ "html_url": "https://github.com/OpenVibers/OpenVibe.Codes/pull/999",
++ "url": "https://api.github.com/repos/OpenVibers/OpenVibe.Codes/pulls/999",
++ "changed_files": 5,
++ "additions": 220,
++ "deletions": 4,
++ "head": { "ref": "al5-scratch", "sha": "abc123def456abc123def456abc123def456abc1" },
++ "base": { "ref": "main", "sha": "b9354f8b9354f8b9354f8b9354f8b9354f8b9354" }
++ },
++ "repository": { "full_name": "OpenVibers/OpenVibe.Codes" },
++ "sender": { "login": "owner-person", "type": "User" }
++}
ddiff --git a/test/fixtures/github/pull-request-review-approved.json b/test/fixtures/github/pull-request-review-approved.json
nnew file mode 100644
iindex 0000000..d8e9f4c
---- /dev/null
++++ b/test/fixtures/github/pull-request-review-approved.json
@@@ -0,0 +1,18 @@
++{
++ "action": "submitted",
++ "pull_request": {
++ "number": 999,
++ "html_url": "https://github.com/OpenVibers/OpenVibe.Codes/pull/999",
++ "head": { "ref": "al5-scratch", "sha": "abc123def456abc123def456abc123def456abc1" },
++ "base": { "ref": "main" }
++ },
++ "repository": { "full_name": "OpenVibers/OpenVibe.Codes" },
++ "review": {
++ "id": 2,
++ "state": "approved",
++ "body": "",
++ "html_url": "https://github.com/OpenVibers/OpenVibe.Codes/pull/999#pullrequestreview-2",
++ "user": { "login": "owner-person", "type": "User" }
++ },
++ "sender": { "login": "owner-person", "type": "User" }
++}
ddiff --git a/test/fixtures/github/pull-request-review-changes.json b/test/fixtures/github/pull-request-review-changes.json
nnew file mode 100644
iindex 0000000..d382aa2
---- /dev/null
++++ b/test/fixtures/github/pull-request-review-changes.json
@@@ -0,0 +1,18 @@
++{
++ "action": "submitted",
++ "pull_request": {
++ "number": 999,
++ "html_url": "https://github.com/OpenVibers/OpenVibe.Codes/pull/999",
++ "head": { "ref": "al5-scratch", "sha": "abc123def456abc123def456abc123def456abc1" },
++ "base": { "ref": "main" }
++ },
++ "repository": { "full_name": "OpenVibers/OpenVibe.Codes" },
++ "review": {
++ "id": 1,
++ "state": "changes_requested",
++ "body": "This helper swallows the error; return it instead.",
++ "html_url": "https://github.com/OpenVibers/OpenVibe.Codes/pull/999#pullrequestreview-1",
++ "user": { "login": "owner-person", "type": "User" }
++ },
++ "sender": { "login": "owner-person", "type": "User" }
++}
ddiff --git a/test/fixtures/github/pull-request-review-comment.json b/test/fixtures/github/pull-request-review-comment.json
nnew file mode 100644
iindex 0000000..06ed11d
---- /dev/null
++++ b/test/fixtures/github/pull-request-review-comment.json
@@@ -0,0 +1,19 @@
++{
++ "action": "created",
++ "pull_request": {
++ "number": 999,
++ "html_url": "https://github.com/OpenVibers/OpenVibe.Codes/pull/999",
++ "head": { "ref": "al5-scratch", "sha": "abc123def456abc123def456abc123def456abc1" },
++ "base": { "ref": "main" }
++ },
++ "comment": {
++ "id": 4,
++ "body": "This swallow hides the 401; return the problem instead.",
++ "path": "server/github.js",
++ "line": 212,
++ "html_url": "https://github.com/OpenVibers/OpenVibe.Codes/pull/999#discussion_r4",
++ "user": { "login": "owner-person", "type": "User" }
++ },
++ "repository": { "full_name": "OpenVibers/OpenVibe.Codes" },
++ "sender": { "login": "owner-person", "type": "User" }
++}
ddiff --git a/test/github.test.js b/test/github.test.js
nnew file mode 100644
iindex 0000000..81b9ede
---- /dev/null
++++ b/test/github.test.js
@@@ -0,0 +1,227 @@
++'use strict';
++/**
++ * GitHub webhooks in (AL5): signature verification, normalization to github.* envelopes, idempotent redelivery.
++ * The fixtures in test/fixtures/github are recorded GitHub payload shapes against a fake PR
++ * (OpenVibers/OpenVibe.Codes#999); the HMAC is computed here with the test-only secret.
++ */
++const assert = require('assert');
++const crypto = require('crypto');
++const fs = require('fs');
++const path = require('path');
++const { boot, request, serviceToken, suite } = require('./helpers');
++
++const SECRET = 'test-only-github-secret-0001';
++const OTHER_SECRET = 'test-only-github-secret-0002';
++
++const fixture = (name) => fs.readFileSync(path.join(__dirname, 'fixtures', 'github', name));
++
++function sign(raw, secret = SECRET) {
++ return `sha256=${crypto.createHmac('sha256', secret).update(raw).digest('hex')}`;
++}
++
++const t = suite('github');
++let h;
++const read = serviceToken('codes', ['events.event.read']);
++
++t('boot with the webhook secret', async () => {
++ h = await boot({ env: { EVENTS_GITHUB_WEBHOOK_SECRET: `${SECRET},${OTHER_SECRET}` } });
++});
++
++async function deliver(name, { event, delivery, secret } = {}) {
++ const raw = fixture(name);
++ return await request(h.base, 'POST', '/api/v1/in/github', {
++ raw,
++ headers: {
++ 'content-type': 'application/json',
++ 'x-github-event': event,
++ 'x-github-delivery': delivery || `deli-${name.replace(/[^0-9a-z-]/gi, '')}-${event || 'ping'}-0000`,
++ 'x-hub-signature-256': sign(raw, secret),
++ },
++ });
++}
++
++async function deliverRaw(raw, { event, delivery, secret } = {}) {
++ return await request(h.base, 'POST', '/api/v1/in/github', {
++ raw,
++ headers: {
++ 'content-type': 'application/json',
++ 'x-github-event': event,
++ 'x-github-delivery': delivery,
++ 'x-hub-signature-256': sign(raw, secret),
++ },
++ });
++}
++
++async function storedAs(eventId) {
++ const r = await request(h.base, 'GET', `/api/v1/events/${eventId}`, { token: read });
++ assert.strictEqual(r.status, 200, r.text);
++ return r.body.event;
++}
++
++t('a bad signature is refused; ping answers 202 without storing', async () => {
++ const raw = fixture('check-run-failed.json');
++ let r = await request(h.base, 'POST', '/api/v1/in/github', {
++ raw,
++ headers: { 'content-type': 'application/json', 'x-github-event': 'check_run', 'x-github-delivery': 'bad-sig-1', 'x-hub-signature-256': sign(raw, 'wrong-secret-0000000000000000') },
++ });
++ assert.strictEqual(r.status, 401, r.text);
++ assert.strictEqual(r.body.code, 'events.bad_signature');
++ r = await deliver('check-run-failed.json', { event: 'ping', delivery: 'deadbeef-0000-4000-8000-000000000000' });
++ assert.strictEqual(r.status, 202, r.text);
++ assert.strictEqual(r.body.ignored, 'ping');
++});
++
++t('a failed check_run stores one github.check.completed per pull request, with the log tail', async () => {
++ const r = await deliver('check-run-failed.json', { event: 'check_run', delivery: 'deadbeef-0000-4000-8000-000000000001' });
++ assert.strictEqual(r.status, 202, r.text);
++ assert.strictEqual(r.body.event_ids.length, 1);
++ const env = await storedAs(r.body.event_ids[0]);
++ assert.strictEqual(env.event_type, 'github.check.completed');
++ assert.strictEqual(env.source, 'github');
++ assert.deepStrictEqual(env.subject, { type: 'pr', id: 'openvibers/openvibe.codes#999' });
++ assert.strictEqual(env.payload.kind, 'check_run');
++ assert.strictEqual(env.payload.conclusion, 'failure');
++ assert.strictEqual(env.payload.check_name, 'codes-ci / tests');
++ assert.strictEqual(env.payload.repo, 'openvibers/openvibe.codes');
++ assert.strictEqual(env.payload.head_sha, 'abc123def456abc123def456abc123def456abc1');
++ assert.strictEqual(env.payload.details_url, 'https://github.com/OpenVibers/OpenVibe.Codes/actions/runs/111');
++ assert.match(env.payload.output.text, /expected 0 to equal 1/);
++ assert.strictEqual(env.payload.output.title, '1 failing test');
++});
++
++t('a redelivery answers the stored ids and stores nothing new', async () => {
++ const first = await deliver('check-run-failed.json', { event: 'check_run', delivery: 'deadbeef-0000-4000-8000-000000000001' });
++ assert.strictEqual(first.status, 202);
++ assert.strictEqual(first.body.duplicate, true);
++ assert.strictEqual(first.body.event_ids.length, 1);
++ const again = await storedAs(first.body.event_ids[0]);
++ assert.strictEqual(again.event_type, 'github.check.completed');
++});
++
++t('one delivery naming two pull requests stores both and claims the delivery once', async () => {
++ const first = await deliver('check-run-failed-two-prs.json', { event: 'check_run', delivery: 'deadbeef-0000-4000-8000-000000000007' });
++ assert.strictEqual(first.status, 202, first.text);
++ assert.strictEqual(first.body.event_ids.length, 2);
++ const one = await storedAs(first.body.event_ids[0]);
++ const two = await storedAs(first.body.event_ids[1]);
++ assert.deepStrictEqual([one.subject.id, two.subject.id], ['openvibers/openvibe.codes#888', 'openvibers/openvibe.codes#999']);
++ const again = await deliver('check-run-failed-two-prs.json', { event: 'check_run', delivery: 'deadbeef-0000-4000-8000-000000000007' });
++ assert.strictEqual(again.status, 202);
++ assert.strictEqual(again.body.duplicate, true);
++ assert.deepStrictEqual(again.body.event_ids, first.body.event_ids);
++});
++
++t('a check_suite failure normalizes to the same github.check.completed', async () => {
++ const r = await deliver('check-suite-failed.json', { event: 'check_suite', delivery: 'deadbeef-0000-4000-8000-000000000008' });
++ assert.strictEqual(r.status, 202, r.text);
++ const env = await storedAs(r.body.event_ids[0]);
++ assert.strictEqual(env.event_type, 'github.check.completed');
++ assert.strictEqual(env.payload.kind, 'check_suite');
++ assert.strictEqual(env.payload.conclusion, 'failure');
++ assert.strictEqual(env.subject.id, 'openvibers/openvibe.codes#999');
++});
++
++t('a successful check, a stop comment, a bot comment, a conflict and a review each normalize', async () => {
++ let r = await deliver('check-run-success.json', { event: 'check_run', delivery: 'deadbeef-0000-4000-8000-000000000002' });
++ assert.strictEqual(r.status, 202, r.text);
++ assert.strictEqual((await storedAs(r.body.event_ids[0])).payload.conclusion, 'success');
++
++ r = await deliver('issue-comment-stop.json', { event: 'issue_comment', delivery: 'deadbeef-0000-4000-8000-000000000003' });
++ assert.strictEqual(r.status, 202, r.text);
++ let env = await storedAs(r.body.event_ids[0]);
++ assert.strictEqual(env.event_type, 'github.pr.commented');
++ assert.strictEqual(env.payload.author_type, 'User');
++ assert.strictEqual(env.payload.comment_id, 1);
++ assert.match(env.payload.body, /^stop/);
++
++ r = await deliver('issue-comment-bot.json', { event: 'issue_comment', delivery: 'deadbeef-0000-4000-8000-000000000004' });
++ assert.strictEqual(r.status, 202, r.text);
++ env = await storedAs(r.body.event_ids[0]);
++ assert.strictEqual(env.payload.author_type, 'Bot');
++
++ r = await deliver('pull-request-dirty.json', { event: 'pull_request', delivery: 'deadbeef-0000-4000-8000-000000000005' });
++ assert.strictEqual(r.status, 202, r.text);
++ env = await storedAs(r.body.event_ids[0]);
++ assert.strictEqual(env.event_type, 'github.pr.updated');
++ assert.strictEqual(env.payload.mergeable_state, 'dirty');
++ assert.strictEqual(env.payload.mergeable, false);
++ assert.strictEqual(env.payload.changed_files, 5);
++ assert.match(env.payload.files_url, /\/pulls\/999\/files$/);
++
++ r = await deliver('pull-request-review-changes.json', { event: 'pull_request_review', delivery: 'deadbeef-0000-4000-8000-000000000006' });
++ assert.strictEqual(r.status, 202, r.text);
++ env = await storedAs(r.body.event_ids[0]);
++ assert.strictEqual(env.payload.state, 'changes_requested');
++ assert.strictEqual(env.payload.author_type, 'User');
++ assert.strictEqual(env.payload.comment_id, 1);
++
++ r = await deliver('pull-request-review-comment.json', { event: 'pull_request_review_comment', delivery: 'deadbeef-0000-4000-8000-000000000009' });
++ assert.strictEqual(r.status, 202, r.text);
++ env = await storedAs(r.body.event_ids[0]);
++ assert.strictEqual(env.payload.path, 'server/github.js');
++ assert.strictEqual(env.payload.line, 212);
++ assert.strictEqual(env.payload.comment_id, 4);
++});
++
++t('a claim left by a crash between claim and store is released, not answered as a duplicate', async () => {
++ const delivery = 'deadbeef-0000-4000-8000-00000000000d';
++ await h.store.db.exec(
++ 'INSERT INTO github_deliveries (delivery_id, event_ids, event_types) VALUES ($1, $2::jsonb, $3::jsonb)',
++ [delivery, JSON.stringify(['evt_01J8Z3Q4R5S6T7V8W9X0Y1Z2Q1']), JSON.stringify(['github.check.completed'])],
++ );
++ const r = await deliver('check-run-success.json', { event: 'check_run', delivery });
++ assert.strictEqual(r.status, 202, r.text);
++ assert.strictEqual(r.body.duplicate, undefined);
++ assert.strictEqual(r.body.event_ids.length, 1);
++ assert.notStrictEqual(r.body.event_ids[0], 'evt_01J8Z3Q4R5S6T7V8W9X0Y1Z2Q1');
++ const env = await storedAs(r.body.event_ids[0]);
++ assert.strictEqual(env.payload.conclusion, 'success');
++});
++
++t('a merged pull request ends the binding downstream: the envelope carries merged', async () => {
++ const r = await deliver('pull-request-closed-merged.json', { event: 'pull_request', delivery: 'deadbeef-0000-4000-8000-00000000000a' });
++ assert.strictEqual(r.status, 202, r.text);
++ const env = await storedAs(r.body.event_ids[0]);
++ assert.strictEqual(env.payload.action, 'closed');
++ assert.strictEqual(env.payload.merged, true);
++ assert.strictEqual(env.payload.base, 'main');
++});
++
++t('a bare approval and an edited comment are not new instructions', async () => {
++ const r = await deliver('pull-request-review-approved.json', { event: 'pull_request_review', delivery: 'deadbeef-0000-4000-8000-00000000000b' });
++ assert.strictEqual(r.status, 202, r.text);
++ assert.strictEqual(r.body.ignored, 'pull_request_review');
++ const r2 = await deliver('issue-comment-edited.json', { event: 'issue_comment', delivery: 'deadbeef-0000-4000-8000-00000000000c' });
++ assert.strictEqual(r2.status, 202, r2.text);
++ assert.strictEqual(r2.body.ignored, 'issue_comment');
++});
++
++t('an unfinished check and a non-PR issue store nothing', async () => {
++ const raw = Buffer.from(JSON.stringify({
++ action: 'created', check_run: { status: 'in_progress', name: 'x', pull_requests: [{ number: 999, url: 'https://api.github.com/repos/a/b/pulls/999' }] },
++ repository: { full_name: 'a/b' },
++ }));
++ let r = await deliverRaw(raw, { event: 'check_run', delivery: '1c1cec1c-1111-4a1a-8a1a-111111111111' });
++ assert.strictEqual(r.status, 202, r.text);
++ assert.strictEqual(r.body.ignored, 'check_run');
++ const plain = Buffer.from(JSON.stringify({
++ action: 'created', issue: { number: 7 }, comment: { body: 'hi', user: { login: 'u', type: 'User' } },
++ repository: { full_name: 'a/b' },
++ }));
++ r = await deliverRaw(plain, { event: 'issue_comment', delivery: '1cc0e7e1-1111-4a1a-8a1a-111111111111' });
++ assert.strictEqual(r.status, 202, r.text);
++ assert.strictEqual(r.body.ignored, 'issue_comment');
++});
++
++t('without a secret the route is off', async () => {
++ const { createGithubIngress } = require('../server/github');
++ const ing = createGithubIngress({ config: { github: { secrets: [] } }, store: h.store });
++ assert.strictEqual(ing.stats().enabled, false);
++ const fakeRes = { statusCode: 0, setHeader() {}, end() {} };
++ await ing.handler({ headers: {}, ip: '127.0.0.9', body: Buffer.alloc(0) }, fakeRes);
++ assert.strictEqual(fakeRes.statusCode, 404);
++});
++
++t('stop', async () => { await h.stop(); });
++
++t.run();
ddiff --git a/test/security-secrets.test.js b/test/security-secrets.test.js
iindex c64484c..e71d103 100644
---- a/test/security-secrets.test.js
++++ b/test/security-secrets.test.js
@@@ -34,6 +34,8 @@ const PLANTED = {
// The account-data consumer (ADR-033): the secret its own loopback subscription signs the two
// account topics with. It is Events' own signing secret, never echoed.
EVENTS_EVENTS_SECRET: `ovsecret-events-account-${crypto.randomBytes(12).toString('hex')}`,
++ // The GitHub webhook ingress (AL5): the HMAC secret GitHub signs deliveries with, never echoed.
++ EVENTS_GITHUB_WEBHOOK_SECRET: `ovsecret-events-github-${crypto.randomBytes(12).toString('hex')}`,
};
t('Events reads no secret from its environment but the planted ones', async () => {