From 2aed1a8adbbe01e1cbb4bb1272e0a2c6e4dfdd76 Mon Sep 17 00:00:00 2001 From: Edward Twumasi <45181928+teckedd-code2save@users.noreply.github.com> Date: Mon, 21 Sep 2026 04:30:00 +0000 Subject: [PATCH 1/7] feat: add GitHub auto deploy (1/7) --- docs/GITHUB-APP.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/GITHUB-APP.md b/docs/GITHUB-APP.md index 06163fc..abb54db 100644 --- a/docs/GITHUB-APP.md +++ b/docs/GITHUB-APP.md @@ -32,3 +32,9 @@ Repositories are linked automatically to enrolled deployments when their normali The Settings experience remains one GitHub connector. Repository access uses the GitHub App, while private GHCR pulls can be enabled as an optional capability inside the same connector because GitHub's container registry does not accept App installation tokens. GroundControl stores the package credential encrypted, sends it to Docker through standard input, and keeps Docker authentication in `$HOME/.groundcontrol/docker` on the active VPS. Managed Compose operations use that isolated configuration automatically. When a recent GHCR image is known, setup verifies access to its manifest instead of treating registry login alone as proof of package access. Disconnecting GitHub removes both the App credentials and the managed GHCR login. + +## Autopilot after merge + +A managed deployment with an explicitly linked repository can opt into **Autopilot after merge**. A signed `push` webhook for its configured default branch queues the same durable `deployment.source.deploy` operation used by agents: sync source, build on the target host, reconcile managed environment, recreate Compose, and verify the public result. + +The policy is off by default, branch-scoped, deployment-scoped, and webhook-delivery-idempotent. It also requires an active operator-approved grant with `deployment:redeploy` for that workload. If source sync or build fails, GroundControl records the failure and does not replace the running workload. From 131f04a447f9855f2f3d3ccc7ab966eacd2c9b6f Mon Sep 17 00:00:00 2001 From: Edward Twumasi <45181928+teckedd-code2save@users.noreply.github.com> Date: Mon, 21 Sep 2026 04:30:01 +0000 Subject: [PATCH 2/7] feat: add GitHub auto deploy (2/7) --- src/app/api/deployment-inventory/[slug]/route.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/api/deployment-inventory/[slug]/route.ts b/src/app/api/deployment-inventory/[slug]/route.ts index d27de5b..4420231 100644 --- a/src/app/api/deployment-inventory/[slug]/route.ts +++ b/src/app/api/deployment-inventory/[slug]/route.ts @@ -176,6 +176,7 @@ function parseSourceRepair(value: unknown) { daytonaConnectorId: cleanOptionalText(input.daytonaConnectorId, 80) || "daytona", validationCommand: cleanOptionalText(input.validationCommand, 500), regressionCommand: cleanOptionalText(input.regressionCommand, 500), + autoDeployEnabled: input.autoDeployEnabled === true, }, }; } From 213d6b4cc807642fa4eb7a83cf90bc469a02ec81 Mon Sep 17 00:00:00 2001 From: Edward Twumasi <45181928+teckedd-code2save@users.noreply.github.com> Date: Mon, 21 Sep 2026 04:30:02 +0000 Subject: [PATCH 3/7] feat: add GitHub auto deploy (3/7) --- src/app/api/github/webhooks/route.ts | 65 +++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 2 deletions(-) diff --git a/src/app/api/github/webhooks/route.ts b/src/app/api/github/webhooks/route.ts index 83ddd8c..70620eb 100644 --- a/src/app/api/github/webhooks/route.ts +++ b/src/app/api/github/webhooks/route.ts @@ -4,6 +4,12 @@ import { verifyGithubWebhookSignature } from "@/lib/github-app"; import { syncGithubInstallation, upsertGithubInstallation } from "@/lib/github-app-service"; import { getLoopEngine, ingestEvents, setLoopEngine, type OperationalEvent } from "@/lib/intelligence"; import { prisma } from "@/lib/prisma"; +import { + branchFromPushRef, + githubAutoDeployIdempotencyKey, + grantCanDeploy, + readGithubAutoDeployPolicy, +} from "@/lib/github-auto-deploy"; export const runtime = "nodejs"; @@ -79,6 +85,56 @@ async function recordLoopChange(deliveryId: string, event: string, payload: Webh setLoopEngine(ingestEvents(getLoopEngine(), [change])); } +async function queueGithubAutoDeploys(deliveryId: string, event: string, payload: WebhookPayload) { + if (event !== "push") return []; + const branch = branchFromPushRef(payload.ref); + const repositoryFullName = payload.repository?.full_name || ""; + if (!branch || !repositoryFullName || !payload.after || /^0+$/.test(payload.after)) return []; + + const repository = await prisma.githubRepository.findUnique({ + where: { fullName: repositoryFullName }, + include: { deployments: { include: { deployment: true } } }, + }); + if (!repository) return []; + + const grants = await prisma.oAuthGrant.findMany({ + where: { revokedAt: null }, + orderBy: { updatedAt: "desc" }, + select: { id: true, scope: true, resources: true }, + }); + const queued: Array<{ deploymentId: number; operationId: string; commitSha: string }> = []; + for (const link of repository.deployments) { + const deployment = link.deployment; + const policy = readGithubAutoDeployPolicy(deployment.metadataJson); + if (!policy.enabled || policy.branch !== branch || deployment.managementMode !== "managed") continue; + const grant = grants.find((candidate) => grantCanDeploy(candidate.scope, candidate.resources, deployment.id)); + if (!grant) { + throw new Error(`Auto deploy for ${deployment.slug} needs an active deployment:redeploy grant for this workload.`); + } + const idempotencyKey = githubAutoDeployIdempotencyKey(deliveryId, deployment.id); + const operation = await prisma.agentOperation.upsert({ + where: { grantId_idempotencyKey: { grantId: grant.id, idempotencyKey } }, + create: { + grantId: grant.id, + deploymentId: deployment.id, + type: "deployment.source.deploy", + idempotencyKey, + inputJson: JSON.stringify({ + branch, + reason: `GitHub push ${payload.after.slice(0, 12)} to ${repositoryFullName}:${branch}`, + trigger: "github_push", + deliveryId, + commitSha: payload.after, + }), + }, + update: {}, + select: { id: true }, + }); + queued.push({ deploymentId: deployment.id, operationId: operation.id, commitSha: payload.after }); + } + return queued; +} + export async function POST(req: NextRequest) { const rawBody = await req.text(); const deliveryId = req.headers.get("x-github-delivery") || ""; @@ -139,11 +195,16 @@ export async function POST(req: NextRequest) { await syncGithubInstallation(installationId); } await recordLoopChange(deliveryId, event, payload); + const autoDeploys = await queueGithubAutoDeploys(deliveryId, event, payload); await prisma.githubWebhookDelivery.update({ where: { id: deliveryId }, - data: { status: "processed", processedAt: new Date() }, + data: { + status: "processed", + processedAt: new Date(), + summaryJson: JSON.stringify({ ...safeSummary(event, payload), autoDeploys }), + }, }); - return NextResponse.json({ ok: true }, { status: 202 }); + return NextResponse.json({ ok: true, autoDeploys }, { status: 202 }); } catch (error) { console.error("[github-webhook]", error); await prisma.githubWebhookDelivery.update({ From cf66758095584684803e02085b0eed3b26d70ce5 Mon Sep 17 00:00:00 2001 From: Edward Twumasi <45181928+teckedd-code2save@users.noreply.github.com> Date: Mon, 21 Sep 2026 04:30:03 +0000 Subject: [PATCH 4/7] feat: add GitHub auto deploy (5/7) --- src/lib/deployment-evidence.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/lib/deployment-evidence.ts b/src/lib/deployment-evidence.ts index f131eeb..f7241b7 100644 --- a/src/lib/deployment-evidence.ts +++ b/src/lib/deployment-evidence.ts @@ -106,6 +106,7 @@ export function readDeploymentOverrides(metadataJson?: string | null) { daytonaConnectorId?: string; validationCommand?: string; regressionCommand?: string; + autoDeployEnabled?: boolean; }; }; return { @@ -119,6 +120,7 @@ export function readDeploymentOverrides(metadataJson?: string | null) { daytonaConnectorId: typeof parsed.sourceRepair.daytonaConnectorId === "string" ? parsed.sourceRepair.daytonaConnectorId : "daytona", validationCommand: typeof parsed.sourceRepair.validationCommand === "string" ? parsed.sourceRepair.validationCommand : "", regressionCommand: typeof parsed.sourceRepair.regressionCommand === "string" ? parsed.sourceRepair.regressionCommand : "", + autoDeployEnabled: parsed.sourceRepair.autoDeployEnabled === true, } : null, }; } catch { From a544c3e5af454bb6467fc95ed3ebed2123d5691f Mon Sep 17 00:00:00 2001 From: Edward Twumasi <45181928+teckedd-code2save@users.noreply.github.com> Date: Mon, 21 Sep 2026 04:30:04 +0000 Subject: [PATCH 5/7] feat: add GitHub auto deploy (4/7) --- src/components/DeploymentDetail.tsx | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/components/DeploymentDetail.tsx b/src/components/DeploymentDetail.tsx index 8eceeb6..4c7eccb 100644 --- a/src/components/DeploymentDetail.tsx +++ b/src/components/DeploymentDetail.tsx @@ -83,6 +83,7 @@ type DeploymentDetailRecord = { daytonaConnectorId?: string; validationCommand?: string; regressionCommand?: string; + autoDeployEnabled?: boolean; } | null; releases: Release[]; envProfile?: { @@ -155,6 +156,7 @@ export default function DeploymentDetail({ const [daytonaConnectorId, setDaytonaConnectorId] = useState("daytona"); const [validationCommand, setValidationCommand] = useState(""); const [regressionCommand, setRegressionCommand] = useState(""); + const [autoDeployEnabled, setAutoDeployEnabled] = useState(false); const [composeContent, setComposeContent] = useState(""); const [composeLoading, setComposeLoading] = useState(false); const [imageSourceInput, setImageSourceInput] = useState(""); @@ -241,6 +243,7 @@ export default function DeploymentDetail({ setDaytonaConnectorId(source.daytonaConnectorId || "daytona"); setValidationCommand(source.validationCommand || ""); setRegressionCommand(source.regressionCommand || ""); + setAutoDeployEnabled(source.autoDeployEnabled === true); }, [deployment, liveUrl]); useEffect(() => { @@ -420,6 +423,7 @@ export default function DeploymentDetail({ daytonaConnectorId, validationCommand, regressionCommand, + autoDeployEnabled, }, }), }); @@ -973,6 +977,22 @@ export default function DeploymentDetail({ className="gc-field mt-2 w-full font-mono" /> +