Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,9 @@ https://<your-tunnel-host>/webhooks/github

`/github/installations` and `/github/check-run` are internal endpoints and require the dedicated internal API key via `Authorization: Bearer <INTERNAL_API_KEY>` 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

Expand Down
17 changes: 17 additions & 0 deletions src/routes/health.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import type { AppEnv } from "../config/env";

export function createHealthRouter(env: Pick<AppEnv, "NODE_ENV" | "GITHUB_APP_NAME">) {
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({
Expand All @@ -23,6 +26,20 @@ export function createHealthRouter(env: Pick<AppEnv, "NODE_ENV" | "GITHUB_APP_NA
environment: env.NODE_ENV,
uptimeSeconds: Math.round(process.uptime()),
timestamp: new Date().toISOString(),
gitSha: buildSha,
buildTime,
version,
});
});

router.get("/version", (_req, res) => {
res.status(200).json({
status: "ok",
service: "trustsignal-github-app",
version,
gitSha: buildSha,
buildTime,
environment: env.NODE_ENV,
});
});

Expand Down
325 changes: 250 additions & 75 deletions src/webhooks/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,100 +34,275 @@ 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<string, any>) {
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<string, any>) {
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,
payload: input.payload,
};

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(
Expand Down
Loading
Loading