diff --git a/README.md b/README.md index 0544a2e..5b4168d 100644 --- a/README.md +++ b/README.md @@ -221,7 +221,9 @@ https:///webhooks/github `/github/installations` and `/github/check-run` are internal endpoints and require the dedicated internal API key via `Authorization: Bearer ` or `x-api-key`. -`GET /` returns a minimal service descriptor for load balancers, demos, and quick smoke checks. `GET /health` returns environment, uptime, and timestamp data suitable for readiness checks. +`GET /` returns a minimal service descriptor for load balancers, demos, and quick smoke checks. `GET /health` returns environment, uptime, timestamp, and deployment metadata (`gitSha`, `buildTime`, `version`) suitable for readiness checks. + +- `GET /version` is a compact deployment verification endpoint with the same metadata. ## Registering The GitHub App diff --git a/src/routes/health.ts b/src/routes/health.ts index ab0d54a..7d79070 100644 --- a/src/routes/health.ts +++ b/src/routes/health.ts @@ -3,6 +3,9 @@ import type { AppEnv } from "../config/env"; export function createHealthRouter(env: Pick) { const router = Router(); + const buildSha = process.env.GIT_SHA || process.env.VERCEL_GIT_COMMIT_SHA || "unknown"; + const buildTime = process.env.BUILD_TIME || process.env.BUILD_TIMESTAMP || "unknown"; + const version = process.env.npm_package_version || "0.1.0"; router.get("/", (_req, res) => { res.status(200).json({ @@ -23,6 +26,20 @@ export function createHealthRouter(env: Pick { + res.status(200).json({ + status: "ok", + service: "trustsignal-github-app", + version, + gitSha: buildSha, + buildTime, + environment: env.NODE_ENV, }); }); diff --git a/src/webhooks/github.ts b/src/webhooks/github.ts index ac25742..9dff74e 100644 --- a/src/webhooks/github.ts +++ b/src/webhooks/github.ts @@ -34,7 +34,99 @@ interface GitHubCheckRunResult { id: number; } +interface WebhookTraceContext { + deliveryId: string; + phase: + | "received" + | "ignored" + | "check_run_start" + | "verification_started" + | "verification_finished" + | "check_run_complete" + | "check_run_failover_start" + | "check_run_failover_complete" + | "error"; + event: string; + action?: string; + installationId: number; + repository?: string; + checkSuiteId?: string; + checkRunId?: number; + headSha?: string; + error?: string; +} + +function toRepositoryFullName(payload: Record) { + const owner = payload?.repository?.owner?.login || payload?.repository?.owner?.name; + const repo = payload?.repository?.name; + if (!owner || !repo) return undefined; + return `${owner}/${repo}`; +} + +function toCheckSuiteId(payload: Record) { + if (payload?.check_suite?.id != null) { + return `check_suite:${payload.check_suite.id}`; + } + + return undefined; +} + +function errorSummary(error: unknown) { + if (error instanceof Error) { + return error.message || "Unknown error"; + } + + if (typeof error === "string") { + return error; + } + + return "Unknown error"; +} + +function failureCheckRunPayload(input: { + installationId: number; + owner: string; + repo: string; + headSha: string; + externalId: string; + detailsUrl?: string; + checkRunId: number; + verificationTimestamp: string; + phase: string; + error: string; + provenanceNote: string; +}) { + return { + installationId: input.installationId, + owner: input.owner, + repo: input.repo, + headSha: input.headSha, + checkRunId: input.checkRunId, + status: "completed" as const, + conclusion: "failure" as const, + externalId: input.externalId, + title: "Verification failed", + summary: `TrustSignal failed while ${input.phase}: ${input.error}.`, + detailsUrl: input.detailsUrl, + verificationTimestamp: input.verificationTimestamp, + provenanceNote: input.provenanceNote, + }; +} + export async function handleGitHubWebhook(input: HandleWebhookInput) { + const repository = toRepositoryFullName(input.payload); + const trace: WebhookTraceContext = { + deliveryId: input.parsed.deliveryId, + installationId: input.parsed.installationId, + event: input.parsed.event, + action: input.parsed.action, + repository, + checkSuiteId: toCheckSuiteId(input.payload), + phase: "received", + }; + + input.logger.info(trace, "github webhook received"); + const base = { deliveryId: input.parsed.deliveryId, installationId: input.parsed.installationId, @@ -42,92 +134,175 @@ export async function handleGitHubWebhook(input: HandleWebhookInput) { }; let verificationJob = null; + let startedCheckRunId: number | null = null; - switch (input.parsed.event) { - case "workflow_run": - verificationJob = await buildWorkflowRunJob(input, base); - break; - case "release": - verificationJob = await buildReleaseJob(input, base); - break; - case "push": - verificationJob = normalizePushEvent(base); - break; - case "check_suite": - verificationJob = normalizeCheckSuiteEvent({ - action: input.parsed.action, - payload: input.payload, - deliveryId: input.parsed.deliveryId, - installationId: input.parsed.installationId, - }); - break; - case "check_run": - normalizeCheckRunEvent(input.payload, input.appName); - break; - } + try { + switch (input.parsed.event) { + case "workflow_run": + verificationJob = await buildWorkflowRunJob(input, base); + break; + case "release": + verificationJob = await buildReleaseJob(input, base); + break; + case "push": + verificationJob = normalizePushEvent(base); + break; + case "check_suite": + verificationJob = normalizeCheckSuiteEvent({ + action: input.parsed.action, + payload: input.payload, + deliveryId: input.parsed.deliveryId, + installationId: input.parsed.installationId, + }); + break; + case "check_run": + normalizeCheckRunEvent(input.payload, input.appName); + break; + } + + if (!verificationJob) { + input.logger.info( + { + ...trace, + phase: "ignored", + repository: repository, + checkSuiteId: toCheckSuiteId(input.payload), + }, + "github event ignored" + ); - if (!verificationJob) { + return { accepted: true, ignored: true }; + } + + trace.repository = `${verificationJob.repository.owner}/${verificationJob.repository.repo}`; + trace.phase = "check_run_start"; + const startedCheckRun = (await publishCheckRun(input.githubClient, { + installationId: verificationJob.installationId, + owner: verificationJob.repository.owner, + repo: verificationJob.repository.repo, + headSha: verificationJob.headSha, + status: "in_progress", + externalId: verificationJob.externalId, + title: "Verification started", + summary: `TrustSignal accepted ${verificationJob.summaryContext}.`, + verificationTimestamp: new Date().toISOString(), + provenanceNote: `Accepted via ${verificationJob.eventName}`, + detailsUrl: verificationJob.detailsUrl, + })) as GitHubCheckRunResult; + + startedCheckRunId = startedCheckRun.id; + trace.checkRunId = startedCheckRun.id; + trace.headSha = verificationJob.headSha; + input.logger.info(trace, "check run started"); + + trace.phase = "verification_started"; + const verificationResult = await input.verificationService.verify(verificationJob); + + trace.phase = "verification_finished"; input.logger.info( { - deliveryId: input.parsed.deliveryId, - event: input.parsed.event, - action: input.parsed.action, + ...trace, + conclusion: verificationResult.conclusion, + status: verificationResult.status, }, - "github event ignored" + "verification finished" ); - return { accepted: true, ignored: true }; - } - - const startedCheckRun = await publishCheckRun(input.githubClient, { - installationId: verificationJob.installationId, - owner: verificationJob.repository.owner, - repo: verificationJob.repository.repo, - headSha: verificationJob.headSha, - status: "in_progress", - externalId: verificationJob.externalId, - title: "Verification started", - summary: `TrustSignal accepted ${verificationJob.summaryContext}.`, - verificationTimestamp: new Date().toISOString(), - provenanceNote: `Accepted via ${verificationJob.eventName}`, - detailsUrl: verificationJob.detailsUrl, - }) as GitHubCheckRunResult; - - const verificationResult = await input.verificationService.verify(verificationJob); - - await publishCheckRun(input.githubClient, { - installationId: verificationJob.installationId, - owner: verificationJob.repository.owner, - repo: verificationJob.repository.repo, - headSha: verificationJob.headSha, - checkRunId: startedCheckRun.id, - status: verificationResult.status, - conclusion: verificationResult.conclusion, - externalId: verificationJob.externalId, - title: verificationResult.title, - summary: verificationResult.summary, - verificationTimestamp: verificationResult.verificationTimestamp, - provenanceNote: verificationResult.provenanceNote, - detailsUrl: verificationResult.detailsUrl, - receiptId: verificationResult.receiptId, - }); - - input.logger.info( - { - deliveryId: input.parsed.deliveryId, + trace.phase = "check_run_complete"; + await publishCheckRun(input.githubClient, { installationId: verificationJob.installationId, - repositoryId: verificationJob.repository.id, - event: verificationJob.eventName, - repository: `${verificationJob.repository.owner}/${verificationJob.repository.repo}`, - sha: verificationJob.headSha, + owner: verificationJob.repository.owner, + repo: verificationJob.repository.repo, + headSha: verificationJob.headSha, checkRunId: startedCheckRun.id, - githubEnterpriseVersion: verificationJob.githubEnterpriseVersion, + status: verificationResult.status, + conclusion: verificationResult.conclusion, + externalId: verificationJob.externalId, + title: verificationResult.title, + summary: verificationResult.summary, + verificationTimestamp: verificationResult.verificationTimestamp, + provenanceNote: verificationResult.provenanceNote, + detailsUrl: verificationResult.detailsUrl, receiptId: verificationResult.receiptId, - }, - "github event processed" - ); + }); + + input.logger.info( + { + ...trace, + phase: "check_run_complete", + checkRunId: startedCheckRun.id, + status: verificationResult.status, + conclusion: verificationResult.conclusion, + githubEnterpriseVersion: verificationJob.githubEnterpriseVersion, + receiptId: verificationResult.receiptId, + }, + "github event processed" + ); + + return { accepted: true, ignored: false, receiptId: verificationResult.receiptId }; + } catch (err) { + const failurePhase = trace.phase; + const failedError = errorSummary(err); + input.logger.error( + { + ...trace, + phase: "error", + error: failedError, + }, + "github webhook processing failed" + ); + + if (startedCheckRunId && verificationJob) { + input.logger.info( + { + ...trace, + phase: "check_run_failover_start", + checkRunId: startedCheckRunId, + checkSuiteId: verificationJob.eventName === "check_suite" ? verificationJob.externalId : trace.checkSuiteId, + }, + "publishing failure check run" + ); - return { accepted: true, ignored: false, receiptId: verificationResult.receiptId }; + try { + await publishCheckRun( + input.githubClient, + failureCheckRunPayload({ + installationId: verificationJob.installationId, + owner: verificationJob.repository.owner, + repo: verificationJob.repository.repo, + headSha: verificationJob.headSha, + externalId: verificationJob.externalId, + detailsUrl: verificationJob.detailsUrl, + checkRunId: startedCheckRunId, + verificationTimestamp: new Date().toISOString(), + phase: failurePhase, + error: failedError, + provenanceNote: `Failure from ${failurePhase}`, + }) + ); + + input.logger.info( + { + ...trace, + phase: "check_run_failover_complete", + checkRunId: startedCheckRunId, + }, + "published failure check run" + ); + } catch (publishErr) { + input.logger.error( + { + ...trace, + phase: "error", + error: errorSummary(publishErr), + }, + "failed to publish failure check run" + ); + } + } + + return { accepted: false, ignored: false }; + } } async function buildWorkflowRunJob( diff --git a/tests/health.test.ts b/tests/health.test.ts new file mode 100644 index 0000000..437ad40 --- /dev/null +++ b/tests/health.test.ts @@ -0,0 +1,47 @@ +import express from "express"; +import request from "supertest"; +import { describe, expect, it } from "vitest"; +import { createHealthRouter } from "../src/routes/health"; + +describe("health routes", () => { + it("returns readiness metadata including deployment fields", async () => { + const originalGitSha = process.env.GIT_SHA; + const originalBuildTime = process.env.BUILD_TIME; + const originalBuildTs = process.env.BUILD_TIMESTAMP; + + process.env.GIT_SHA = "abc1234"; + process.env.BUILD_TIME = "2026-03-14T19:00:00Z"; + process.env.BUILD_TIMESTAMP = undefined; + + const app = express(); + app.use(createHealthRouter({ + NODE_ENV: "test", + GITHUB_APP_NAME: "TrustSignal", + })); + + const response = await request(app).get("/health"); + + expect(response.status).toBe(200); + expect(response.body.status).toBe("ok"); + expect(response.body.gitSha).toBe("abc1234"); + expect(response.body.buildTime).toBe("2026-03-14T19:00:00Z"); + expect(response.body.environment).toBe("test"); + expect(response.body.version).toBeDefined(); + + process.env.GIT_SHA = originalGitSha; + process.env.BUILD_TIME = originalBuildTime; + process.env.BUILD_TIMESTAMP = originalBuildTs; + }); + + it("serves /version with deployment metadata", async () => { + const app = express(); + app.use(createHealthRouter({ NODE_ENV: "production", GITHUB_APP_NAME: "TrustSignal" })); + + const response = await request(app).get("/version"); + + expect(response.status).toBe(200); + expect(response.body.status).toBe("ok"); + expect(response.body.version).toBeDefined(); + expect(response.body.environment).toBe("production"); + }); +}); diff --git a/tests/server.test.ts b/tests/server.test.ts index 1f45183..b0b84ed 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -248,7 +248,7 @@ describe("route handlers", () => { expect(services.replayStore.release).not.toHaveBeenCalled(); }); - it("releases a delivery after processing failure so retries can continue", async () => { + it("completes a delivery after processing failure so fail-closed check publish can finish", async () => { const services = createServices(); services.verificationService.verify = vi.fn().mockRejectedValue(new Error("trustsignal unavailable")); const handler = createGitHubWebhookHandler(services); @@ -271,9 +271,9 @@ describe("route handlers", () => { await handler(req, createMockResponse() as any, next); - expect(next).toHaveBeenCalledOnce(); - expect(services.replayStore.release).toHaveBeenCalledWith("delivery-failure"); - expect(services.replayStore.complete).not.toHaveBeenCalled(); + expect(next).not.toHaveBeenCalled(); + expect(services.replayStore.release).not.toHaveBeenCalled(); + expect(services.replayStore.complete).toHaveBeenCalledWith("delivery-failure"); }); it("rejects missing installation ids", async () => { diff --git a/tests/webhookFlow.test.ts b/tests/webhookFlow.test.ts index dc96cf0..d312405 100644 --- a/tests/webhookFlow.test.ts +++ b/tests/webhookFlow.test.ts @@ -290,4 +290,227 @@ describe("handleGitHubWebhook", () => { expect(result).toEqual({ accepted: true, ignored: false, receiptId: "rcpt_4" }); }); + + it("marks the check run as failure when TrustSignal verification fails", async () => { + const githubClient = { + getWorkflowRun: vi.fn(), + createCheckRun: vi + .fn() + .mockResolvedValueOnce({ id: 77, html_url: "https://github.com/acme/repo/runs/77", status: "in_progress" }), + updateCheckRun: vi.fn(), + } as any; + const verificationService = { + verify: vi.fn().mockRejectedValue(new Error("trustsignal service unavailable")), + }; + const logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as any; + + const result = await handleGitHubWebhook({ + parsed: { + deliveryId: "delivery-5", + event: "check_suite", + action: "requested", + installationId: 11, + }, + payload: { + action: "requested", + check_suite: { + id: 555, + head_sha: "def5678", + status: "queued", + conclusion: null, + head_branch: "main", + app: { + slug: "other-app", + }, + pull_requests: [], + }, + repository: { + id: 22, + name: "repo", + default_branch: "main", + html_url: "https://github.com/acme/repo", + owner: { login: "acme" }, + }, + }, + githubClient, + verificationService, + logger, + appName: "TrustSignal", + }); + + expect(result).toEqual({ accepted: false, ignored: false }); + expect(githubClient.createCheckRun).toHaveBeenCalledTimes(1); + expect(githubClient.updateCheckRun).toHaveBeenCalledTimes(1); + expect(githubClient.updateCheckRun).toHaveBeenCalledWith( + 11, + "acme", + "repo", + 77, + expect.objectContaining({ + status: "completed", + conclusion: "failure", + }) + ); + }); + + it("falls back to a completed failure check run when publishing the success result fails", async () => { + const githubClient = { + getWorkflowRun: vi.fn(), + createCheckRun: vi + .fn() + .mockResolvedValueOnce({ id: 88, html_url: "https://github.com/acme/repo/runs/88", status: "in_progress" }), + updateCheckRun: vi + .fn() + .mockRejectedValueOnce(new Error("transient failure")) + .mockResolvedValueOnce({ + id: 88, + html_url: "https://github.com/acme/repo/runs/88", + status: "completed", + conclusion: "failure", + }), + } as any; + const verificationService = { + verify: vi.fn().mockResolvedValue({ + status: "completed", + conclusion: "success", + title: "Artifact verification completed", + summary: "Verification succeeded", + detailsUrl: "https://trustsignal.example.com/receipts/rcpt_5", + receiptId: "rcpt_5", + verificationTimestamp: "2026-03-14T00:00:00.000Z", + provenanceNote: "check_suite event check_suite:700", + }), + }; + const logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as any; + + const result = await handleGitHubWebhook({ + parsed: { + deliveryId: "delivery-6", + event: "check_suite", + action: "requested", + installationId: 11, + }, + payload: { + action: "requested", + check_suite: { + id: 700, + head_sha: "feedbeef", + status: "queued", + conclusion: null, + head_branch: "main", + app: { + slug: "other-app", + }, + pull_requests: [], + }, + repository: { + id: 22, + name: "repo", + default_branch: "main", + html_url: "https://github.com/acme/repo", + owner: { login: "acme" }, + }, + }, + githubClient, + verificationService, + logger, + appName: "TrustSignal", + }); + + expect(result).toEqual({ accepted: false, ignored: false }); + expect(githubClient.createCheckRun).toHaveBeenCalledTimes(1); + expect(githubClient.updateCheckRun).toHaveBeenCalledTimes(2); + expect(githubClient.updateCheckRun).toHaveBeenNthCalledWith( + 1, + 11, + "acme", + "repo", + 88, + expect.objectContaining({ + status: "completed", + conclusion: "success", + }) + ); + expect(githubClient.updateCheckRun).toHaveBeenNthCalledWith( + 2, + 11, + "acme", + "repo", + 88, + expect.objectContaining({ + status: "completed", + conclusion: "failure", + }) + ); + }); + + it("returns accepted false when installation auth prevents check-run creation", async () => { + const githubClient = { + getWorkflowRun: vi.fn().mockResolvedValue({ + data: { + id: 7001, + status: "completed", + conclusion: "success", + head_sha: "deadbeef", + html_url: "https://github.com/acme/repo/actions/runs/7001", + name: "CI", + event: "push", + }, + }), + createCheckRun: vi.fn().mockRejectedValue(new Error("installation authentication failed")), + updateCheckRun: vi.fn(), + } as any; + const verificationService = { + verify: vi.fn(), + }; + const logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as any; + + const result = await handleGitHubWebhook({ + parsed: { + deliveryId: "delivery-7", + event: "workflow_run", + action: "completed", + installationId: 11, + }, + payload: { + action: "completed", + workflow_run: { + id: 7001, + status: "completed", + conclusion: "success", + head_sha: "deadbeef", + html_url: "https://github.com/acme/repo/actions/runs/7001", + name: "CI", + event: "push", + }, + repository: { + id: 22, + name: "repo", + default_branch: "main", + html_url: "https://github.com/acme/repo", + owner: { login: "acme" }, + }, + }, + githubClient, + verificationService, + logger, + appName: "TrustSignal", + }); + + expect(result).toEqual({ accepted: false, ignored: false }); + expect(githubClient.createCheckRun).toHaveBeenCalledTimes(1); + expect(githubClient.updateCheckRun).not.toHaveBeenCalled(); + }); });