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
8 changes: 7 additions & 1 deletion docs/deployments/github.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,12 @@ Techulus Cloud integrates with GitHub through a [GitHub App](https://docs.github
The GitHub App needs these repository permissions:

- **Contents:** Read
- **Pull requests:** Read
- **Pull requests:** Read and write
- **Deployments:** Read and write

Subscribe the app to both the **Push** and **Pull request** webhook events.
If you increase permissions for an existing app, approve the new permissions on
each installation before enabling previews.

## Connecting a Repository

Expand Down Expand Up @@ -89,6 +91,10 @@ rechecks previews in case a webhook was missed. Generated hosts use the normal
HTTP-01 certificate path, so installations with high pull-request volume should
monitor their certificate authority's issuance limits.

Each preview service maintains one status comment on the pull request. The
comment shows the current deployment status and preview URL. Status and commit
updates replace the same comment instead of adding new comments.

## Build Process

Agents build images using one of two methods:
Expand Down
9 changes: 5 additions & 4 deletions docs/installation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -297,10 +297,11 @@ migrations from every replica.
| `GITHUB_APP_PRIVATE_KEY` | GitHub App private key (base64-encoded) |
| `GITHUB_WEBHOOK_SECRET` | Webhook secret |

Configure the app with Contents read, Pull requests read, and Deployments write
repository permissions. Subscribe it to Push and Pull request events. Pull
request previews use the Automatic Subdomain Domain setting and its wildcard DNS
record when configured. Without it, previews are created without public URLs.
Configure the app with Contents read, Pull requests read and write, and
Deployments read and write repository permissions. Subscribe it to Push and
Pull request events. Pull request previews use the Automatic Subdomain Domain
setting and its wildcard DNS record when configured. Without it, previews are
created without public URLs.

## Generating Secrets

Expand Down
91 changes: 91 additions & 0 deletions web/lib/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,97 @@ export async function getGitHubPullRequest(
return mapGitHubPullRequest(pullRequest);
}

export async function upsertGitHubPullRequestComment(
installationId: number,
repoFullName: string,
pullRequestNumber: number,
marker: string,
content: string,
): Promise<number> {
validateRepoFullName(repoFullName);
if (!Number.isSafeInteger(pullRequestNumber) || pullRequestNumber <= 0) {
throw new Error("Invalid pull request number");
}
const markerContent = marker.slice("<!-- ".length, -" -->".length);
if (
!marker.startsWith("<!-- ") ||
!marker.endsWith(" -->") ||
!markerContent ||
marker.includes("\r") ||
marker.includes("\n")
) {
throw new Error("Invalid pull request comment marker");
}
const body = `${marker}\n${content.trim()}`;
if (body.length > 65_536) {
throw new Error("Pull request comment is too long");
}

const token = await getInstallationToken(installationId);
const headers = {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${token}`,
"X-GitHub-Api-Version": "2022-11-28",
};
let existingCommentId: number | null = null;
for (let page = 1; ; page++) {
const response = await fetch(
`https://api.github.com/repos/${repoFullName}/issues/${pullRequestNumber}/comments?per_page=100&page=${page}`,
{ headers },
);
if (!response.ok) {
const detail = await response.text();
throw new GitHubApiError(
`Failed to list pull request comments (${response.status}): ${detail || response.statusText}`,
response.status,
);
}
const comments = (await response.json()) as unknown;
if (!Array.isArray(comments)) {
throw new Error("GitHub returned invalid pull request comments");
}
const existing = comments.find(
(comment) =>
comment !== null &&
typeof comment === "object" &&
"body" in comment &&
typeof comment.body === "string" &&
(comment.body === marker || comment.body.startsWith(`${marker}\n`)),
) as { id?: unknown } | undefined;
if (existing) {
if (!Number.isSafeInteger(existing.id) || Number(existing.id) <= 0) {
throw new Error("GitHub returned an invalid pull request comment ID");
}
existingCommentId = Number(existing.id);
break;
}
if (comments.length < 100) break;
}

const response = await fetch(
existingCommentId
? `https://api.github.com/repos/${repoFullName}/issues/comments/${existingCommentId}`
: `https://api.github.com/repos/${repoFullName}/issues/${pullRequestNumber}/comments`,
{
method: existingCommentId ? "PATCH" : "POST",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify({ body }),
},
);
if (!response.ok) {
const detail = await response.text();
throw new GitHubApiError(
`Failed to ${existingCommentId ? "update" : "create"} pull request comment (${response.status}): ${detail || response.statusText}`,
response.status,
);
}
const comment = (await response.json()) as { id?: unknown };
if (!Number.isSafeInteger(comment.id) || Number(comment.id) <= 0) {
throw new Error("GitHub returned an invalid pull request comment ID");
}
return Number(comment.id);
}

export async function listOpenGitHubPullRequests(
installationId: number,
repoFullName: string,
Expand Down
130 changes: 119 additions & 11 deletions web/lib/preview-deployments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
createGitHubDeployment,
findGitHubDeployment,
updateGitHubDeploymentStatus,
upsertGitHubPullRequestComment,
} from "@/lib/github";
import { resolveRegistryImageHost } from "@/lib/registry-reference";
import {
Expand All @@ -39,6 +40,62 @@ type PreviewPort = {
tlsPassthrough: boolean;
};

type PreviewDeploymentState =
| "pending"
| "in_progress"
| "success"
| "failure"
| "inactive";

const previewStateLabels: Record<PreviewDeploymentState, string> = {
pending: "⏳ Queued",
in_progress: "🚧 Deploying",
success: "✅ Ready",
failure: "❌ Failed",
inactive: "🗑️ Removed",
};

function escapeGitHubCommentText(value: string) {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll("@", "&#64;")
.replace(/\s+/g, " ")
.trim();
}

async function updatePreviewPullRequestComment(input: {
installationId: number;
repoFullName: string;
baseServiceId: string;
previewGitRef: string;
serviceName: string;
state: PreviewDeploymentState;
description: string;
previewUrl: string | null;
}) {
const pullRequestNumber = pullRequestNumberFromMergeRef(input.previewGitRef);
const content = [
"### Preview deployment",
"",
`**Service:** <code>${escapeGitHubCommentText(input.serviceName)}</code>`,
`**Status:** ${previewStateLabels[input.state]}`,
input.previewUrl
? `**Preview:** [Open preview](${input.previewUrl})`
: "**Preview:** No public URL configured",
"",
`<sub>${escapeGitHubCommentText(input.description.substring(0, 500))}</sub>`,
].join("\n");
await upsertGitHubPullRequestComment(
input.installationId,
input.repoFullName,
pullRequestNumber,
`<!-- techulus-preview:${input.baseServiceId} -->`,
content,
);
}

function dnsLabelPart(value: string) {
return value
.trim()
Expand Down Expand Up @@ -401,7 +458,7 @@ export async function canDeployServiceRevision(
export async function updatePreviewGitHubStatus(input: {
serviceId: string;
serviceRevisionId: string;
state: "pending" | "in_progress" | "success" | "failure" | "inactive";
state: PreviewDeploymentState;
description: string;
logUrl?: string;
expectedDeploymentId?: number;
Expand All @@ -411,11 +468,15 @@ export async function updatePreviewGitHubStatus(input: {
sql`select pg_advisory_xact_lock(hashtext(${input.serviceId}))`,
);
const service = await tx
.select({ previewOfService: services.previewOfService })
.select({
name: services.name,
previewOfService: services.previewOfService,
previewGitRef: services.previewGitRef,
})
.from(services)
.where(and(eq(services.id, input.serviceId), isNull(services.deletedAt)))
.then((rows) => rows[0]);
if (!service?.previewOfService) return null;
if (!service?.previewOfService || !service.previewGitRef) return null;
const latest = await tx
.select({ id: serviceRevisions.id })
.from(serviceRevisions)
Expand Down Expand Up @@ -472,6 +533,7 @@ export async function updatePreviewGitHubStatus(input: {
.filter((port) => port.domain)
.sort((a, b) => a.port - b.port || a.id.localeCompare(b.id))[0],
);
const previewUrl = primary?.domain ? `https://${primary.domain}` : null;
await updateGitHubDeploymentStatus(
githubRepo.installationId,
githubRepo.repoFullName,
Expand All @@ -480,11 +542,19 @@ export async function updatePreviewGitHubStatus(input: {
{
description: input.description.substring(0, 140),
logUrl: input.logUrl,
environmentUrl: primary?.domain
? `https://${primary.domain}`
: undefined,
environmentUrl: previewUrl ?? undefined,
},
);
await updatePreviewPullRequestComment({
installationId: githubRepo.installationId,
repoFullName: githubRepo.repoFullName,
baseServiceId: service.previewOfService,
previewGitRef: service.previewGitRef,
serviceName: service.name,
state: input.state,
description: input.description,
previewUrl,
});
return true;
});
}
Expand Down Expand Up @@ -590,7 +660,11 @@ export async function inactivatePreviewGitHubDeployments(input: {
excludeServiceRevisionId?: string;
}) {
const service = await db
.select({ previewOfService: services.previewOfService })
.select({
name: services.name,
previewOfService: services.previewOfService,
previewGitRef: services.previewGitRef,
})
.from(services)
.where(eq(services.id, input.serviceId))
.then((rows) => rows[0]);
Expand All @@ -610,10 +684,32 @@ export async function inactivatePreviewGitHubDeployments(input: {
ne(builds.serviceRevisionId, input.excludeServiceRevisionId),
);
}
const deploymentIds = await db
.selectDistinct({ id: builds.githubDeploymentId })
.from(builds)
.where(and(...conditions));
const [deploymentIds, primary] = await Promise.all([
db
.selectDistinct({ id: builds.githubDeploymentId })
.from(builds)
.where(and(...conditions)),
db
.select({
id: servicePorts.id,
port: servicePorts.port,
domain: servicePorts.domain,
})
.from(servicePorts)
.where(
and(
eq(servicePorts.serviceId, input.serviceId),
eq(servicePorts.protocol, "http"),
eq(servicePorts.isPublic, true),
),
)
.then(
(ports) =>
ports
.filter((port) => port.domain)
.sort((a, b) => a.port - b.port || a.id.localeCompare(b.id))[0],
),
]);
await Promise.all(
deploymentIds.flatMap(({ id }) =>
id
Expand All @@ -629,5 +725,17 @@ export async function inactivatePreviewGitHubDeployments(input: {
: [],
),
);
if (!input.excludeServiceRevisionId && service.previewGitRef) {
await updatePreviewPullRequestComment({
installationId: githubRepo.installationId,
repoFullName: githubRepo.repoFullName,
baseServiceId: service.previewOfService,
previewGitRef: service.previewGitRef,
serviceName: service.name,
state: "inactive",
description: input.description,
previewUrl: primary?.domain ? `https://${primary.domain}` : null,
});
}
return deploymentIds.length;
}
Loading
Loading