Skip to content

Commit 0dc04ef

Browse files
edgeheroclaude
andcommitted
chore(plan-exec): 2026-07-16-github-trigger-on-pluggable-app-free-auth — phase "Phase F: CI, deploy units" passed gates
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NGgfho2J6YfRSQN1bSDhj6
1 parent 91a9be3 commit 0dc04ef

4 files changed

Lines changed: 212 additions & 1 deletion

File tree

.github/workflows/pi-upgrade-check.yml

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,15 @@ on:
1919
paths:
2020
- "image/**"
2121
- "worker/**"
22+
- "receiver/**"
2223
- "guardrails/**"
2324
- "package.json"
2425
- ".github/workflows/pi-upgrade-check.yml"
2526
pull_request:
2627
paths:
2728
- "image/**"
2829
- "worker/**"
30+
- "receiver/**"
2931
- "guardrails/**"
3032
- "package.json"
3133
- ".github/workflows/pi-upgrade-check.yml"
@@ -61,6 +63,22 @@ jobs:
6163
fi
6264
echo "OK: base pinned by digest"
6365
66+
no-automatic-merge:
67+
name: no automatic merge (CONST-MERGE-NEVER-AUTOMATIC)
68+
runs-on: ubuntu-latest
69+
steps:
70+
- uses: actions/checkout@v4
71+
72+
- name: No merge API call anywhere
73+
run: |
74+
# CONST-MERGE-NEVER-AUTOMATIC: "grep is the test". A match is a merge symbol reaching
75+
# the code — the human review step is the last line and is not negotiable.
76+
if grep -rnE 'pulls\.merge|gh pr merge|mergePullRequest|merge_pull_request|[Aa]utoMerge|/pulls/.*/merge' worker/src image/runner receiver/src; then
77+
echo "::error::Merge symbol found. CONST-MERGE-NEVER-AUTOMATIC forbids any merge call."
78+
exit 1
79+
fi
80+
echo "OK: no merge symbols"
81+
6482
contract-tests:
6583
name: pinned assumptions still hold (offline, no API key)
6684
runs-on: ubuntu-latest
@@ -89,10 +107,11 @@ jobs:
89107
# PI_DISPATCH_REQUIRE_*_TESTS=1 turns a skip into a hard failure. A skipped assertion is an
90108
# UNVERIFIED assertion, and "skipped = pass" is precisely the reasoning that lets a
91109
# guardrail-less agent ship green. VALKEY_TEST_URL activates the queue integration test.
92-
- name: Contract tests -- guardrails, -nc, exit codes, env allowlist, queue (all required)
110+
- name: Contract tests -- guardrails, -nc, exit codes, env allowlist, queue, receiver enqueue (all required)
93111
env:
94112
PI_DISPATCH_REQUIRE_LOADER_TESTS: "1"
95113
PI_DISPATCH_REQUIRE_WORKER_TESTS: "1"
114+
PI_DISPATCH_REQUIRE_RECEIVER_TESTS: "1"
96115
VALKEY_TEST_URL: "redis://127.0.0.1:6379"
97116
run: npm test
98117

deploy/receiver.service

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# UNTESTED EXAMPLE (design.md:483-484) -- a starting point, not a shipped, verified unit. Adapt it.
2+
#
3+
# The receiver runs on the HOST as the public edge: it verifies GitHub deliveries and enqueues jobs.
4+
# Requires node >=22.19.0 on PATH for User=pi -- or pin an absolute `ExecStart=/usr/bin/node ...`.
5+
# This is a Linux/systemd unit; a launchd (macOS) / nssm (Windows) equivalent is left to the operator.
6+
#
7+
# NAT / tunnel: the receiver binds `RECEIVER_BIND` (default 0.0.0.0) and must be reachable by GitHub's
8+
# webhook delivery. On a home machine behind NAT, put it behind a tunnel (cloudflared / ngrok /
9+
# tailscale funnel) or a reverse proxy with TLS -- do not port-forward it raw without one.
10+
# `WEBHOOK_SECRET` is what authenticates deliveries; without a public URL GitHub cannot deliver.
11+
#
12+
# Env vars come from your `.env` (see `.env.example`) via EnvironmentFile -- never commit real secrets.
13+
# WorkingDirectory / EnvironmentFile / User / node path below are PLACEHOLDERS: set them to wherever
14+
# you cloned the repo and whoever owns it.
15+
16+
[Unit]
17+
Description=pi-dispatch webhook receiver (public edge: verifies GitHub deliveries and enqueues jobs)
18+
After=network-online.target
19+
Wants=network-online.target
20+
21+
[Service]
22+
Type=simple
23+
User=pi
24+
WorkingDirectory=/opt/pi-dispatch
25+
EnvironmentFile=/opt/pi-dispatch/.env
26+
ExecStart=/usr/bin/node receiver/src/start.mjs
27+
Restart=on-failure
28+
RestartSec=5
29+
# The receiver handles SIGTERM: it closes the HTTP server and the queue connection, then exits.
30+
KillSignal=SIGTERM
31+
TimeoutStopSec=30
32+
33+
[Install]
34+
WantedBy=multi-user.target

deploy/worker.service

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# UNTESTED EXAMPLE (design.md:483-484) -- a starting point, not a shipped, verified unit. Adapt it.
2+
#
3+
# The worker runs on the HOST, not in a container (DES-WORKER-ON-HOST): it drives the `docker` CLI to
4+
# launch one job container per job, and the CLI is what translates bind-mount paths cross-platform.
5+
# Compose runs only Valkey; this unit runs the worker beside it.
6+
#
7+
# Requires node >=22.19.0 (worker/package.json engines) on PATH for User=pi -- or pin an absolute
8+
# `ExecStart=/usr/bin/node ...` if PATH is not reliable under systemd. This is a Linux/systemd unit;
9+
# a launchd (macOS) / nssm (Windows) equivalent is left to the operator.
10+
#
11+
# Env vars come from your `.env` (see `.env.example`) via EnvironmentFile -- never commit real secrets.
12+
# WorkingDirectory / EnvironmentFile / User / node path below are PLACEHOLDERS: set them to wherever
13+
# you cloned the repo and whoever owns it.
14+
15+
[Unit]
16+
Description=pi-dispatch worker (drains the job queue on the host; launches job containers via docker)
17+
After=network-online.target docker.service
18+
Wants=network-online.target
19+
# Valkey must be reachable (docker compose -f deploy/docker-compose.yml up -d), but it is a separate
20+
# unit/container -- not ordered here since it may be remote.
21+
22+
[Service]
23+
Type=simple
24+
User=pi
25+
WorkingDirectory=/opt/pi-dispatch
26+
EnvironmentFile=/opt/pi-dispatch/.env
27+
ExecStart=/usr/bin/node worker/src/cli.mjs worker
28+
Restart=on-failure
29+
RestartSec=5
30+
# The worker handles SIGTERM: it stops accepting new jobs and lets the in-flight container finish or
31+
# abort cleanly. Give it room before SIGKILL.
32+
KillSignal=SIGTERM
33+
TimeoutStopSec=30
34+
35+
[Install]
36+
WantedBy=multi-user.target
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import assert from "node:assert/strict";
2+
import crypto from "node:crypto";
3+
import { EventEmitter } from "node:events";
4+
import { test } from "node:test";
5+
import { makeReceiver } from "../src/receiver.mjs";
6+
7+
// Integration against a real Valkey: the receiver's verify->filter->enqueue path must land a real job
8+
// in Valkey under its exact-per-delivery GUID jobId (REQ-DEDUP-BY-DELIVERY-GUID). The fake-queue tests
9+
// in receiver.test.mjs prove the routing; this proves the wire -- a real BullMQ add against a real
10+
// Redis-protocol server, so a regression in the shared enqueue contract cannot pass unnoticed.
11+
//
12+
// Runs when VALKEY_TEST_URL is set (CI provides a service). PI_DISPATCH_REQUIRE_RECEIVER_TESTS=1 turns
13+
// a skip into a hard failure: a skipped assertion is an UNVERIFIED assertion, so where the flag is set
14+
// a missing Valkey is a red build, never a silent green.
15+
const url = process.env.VALKEY_TEST_URL;
16+
const required = process.env.PI_DISPATCH_REQUIRE_RECEIVER_TESTS === "1";
17+
if (!url && required) {
18+
throw new Error("receiver enqueue integration test is REQUIRED here (PI_DISPATCH_REQUIRE_RECEIVER_TESTS=1) but VALKEY_TEST_URL is not set");
19+
}
20+
const skip = url ? false : "VALKEY_TEST_URL not set; receiver enqueue integration test skipped (CI sets it)";
21+
22+
const SECRET = "test-webhook-secret";
23+
const SELF_ID = 999;
24+
const cfg = {
25+
webhookSecret: SECRET,
26+
labelFlows: { "pi:frontend": "frontend-fix" },
27+
commentTrigger: { phrase: "@pi", defaultFlow: null },
28+
};
29+
30+
/** GitHub's `X-Hub-Signature-256` shape, computed the same way GitHub computes it. */
31+
function sign(secret, raw) {
32+
return "sha256=" + crypto.createHmac("sha256", secret).update(raw).digest("hex");
33+
}
34+
35+
/** EventEmitter-backed request mock: real streams are EventEmitters, so `on`/`emit` come for free. */
36+
function mockReq({ method = "POST", headers = {} } = {}) {
37+
const req = new EventEmitter();
38+
req.method = method;
39+
req.headers = headers;
40+
req.destroyed = false;
41+
req.destroy = () => {
42+
req.destroyed = true;
43+
};
44+
return req;
45+
}
46+
47+
/** Plain object response mock recording writeHead/statusCode/end -- no real socket. */
48+
function mockRes() {
49+
return {
50+
statusCode: 0,
51+
headersSent: false,
52+
body: undefined,
53+
writeHead(status, headers) {
54+
this.statusCode = status;
55+
this.headers = headers;
56+
this.headersSent = true;
57+
return this;
58+
},
59+
end(body) {
60+
this.body = body;
61+
this.ended = true;
62+
return this;
63+
},
64+
};
65+
}
66+
67+
/** Drive a handler: attach synchronously, then feed the raw bytes and await completion. */
68+
async function drive(handler, req, res, raw) {
69+
const done = handler(req, res);
70+
if (raw !== undefined) {
71+
req.emit("data", Buffer.from(raw, "utf8"));
72+
req.emit("end");
73+
}
74+
await done;
75+
}
76+
77+
test("verify->filter->enqueue lands a github job in a real Valkey under the GUID jobId", { skip }, async () => {
78+
const { Queue } = await import("bullmq");
79+
const { parseConnection } = await import("@pi-dispatch/worker/connection");
80+
81+
// A uniquely-named queue per run so parallel/repeated Valkey tests cannot see each other's jobs.
82+
// `parseConnection` returns connection OPTIONS (not a shared ioredis instance), so this Queue owns
83+
// the client it builds from them and `queue.close()` closes it -- no separate connection to quit.
84+
const connection = parseConnection(url);
85+
const queue = new Queue(`recv-it-${crypto.randomUUID()}`, { connection });
86+
try {
87+
await queue.obliterate({ force: true }).catch(() => {});
88+
89+
const delivery = crypto.randomUUID();
90+
const payload = {
91+
action: "labeled",
92+
sender: { id: 1 },
93+
repository: { full_name: "octo/repo" },
94+
issue: { number: 42, title: "T", body: "B", labels: [{ name: "pi:frontend" }] },
95+
};
96+
const raw = JSON.stringify(payload);
97+
98+
const handler = makeReceiver({ queue, selfId: SELF_ID, cfg, log: () => {} });
99+
const req = mockReq({
100+
headers: {
101+
"content-type": "application/json",
102+
"x-hub-signature-256": sign(SECRET, raw),
103+
"x-github-event": "issues",
104+
"x-github-delivery": delivery,
105+
},
106+
});
107+
const res = mockRes();
108+
await drive(handler, req, res, raw);
109+
110+
assert.equal(res.statusCode, 202);
111+
112+
// The job is really in Valkey, under the exact-per-delivery GUID jobId, with the github shape.
113+
const job = await queue.getJob("gh-" + delivery);
114+
assert.ok(job, "the enqueued job must be readable back from Valkey under gh-<delivery>");
115+
assert.equal(job.id, "gh-" + delivery);
116+
assert.equal(job.data.kind, "github");
117+
assert.equal(job.data.flow, "frontend-fix");
118+
} finally {
119+
await queue.obliterate({ force: true }).catch(() => {});
120+
await queue.close();
121+
}
122+
});

0 commit comments

Comments
 (0)