diff --git a/docs/GITHUB-APP.md b/docs/GITHUB-APP.md
index 06163fcf..abb54db8 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.
diff --git a/src/app/api/deployment-inventory/[slug]/route.ts b/src/app/api/deployment-inventory/[slug]/route.ts
index d27de5b4..44202314 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,
},
};
}
diff --git a/src/app/api/github/webhooks/route.ts b/src/app/api/github/webhooks/route.ts
index 83ddd8c7..70620eb8 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({
diff --git a/src/components/DeploymentDetail.tsx b/src/components/DeploymentDetail.tsx
index 8eceeb62..4c7eccbe 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"
/>
+