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
6 changes: 6 additions & 0 deletions docs/GITHUB-APP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1 change: 1 addition & 0 deletions src/app/api/deployment-inventory/[slug]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
};
}
Expand Down
65 changes: 63 additions & 2 deletions src/app/api/github/webhooks/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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") || "";
Expand Down Expand Up @@ -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({
Expand Down
20 changes: 20 additions & 0 deletions src/components/DeploymentDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ type DeploymentDetailRecord = {
daytonaConnectorId?: string;
validationCommand?: string;
regressionCommand?: string;
autoDeployEnabled?: boolean;
} | null;
releases: Release[];
envProfile?: {
Expand Down Expand Up @@ -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("");
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -420,6 +423,7 @@ export default function DeploymentDetail({
daytonaConnectorId,
validationCommand,
regressionCommand,
autoDeployEnabled,
},
}),
});
Expand Down Expand Up @@ -973,6 +977,22 @@ export default function DeploymentDetail({
className="gc-field mt-2 w-full font-mono"
/>
</label>
<label className="flex items-start justify-between gap-4 rounded-lg bg-background/55 p-4 lg:col-span-2">
<span>
<span className="block text-xs font-medium">Autopilot after merge</span>
<span className="mt-1 block text-[10px] leading-relaxed text-muted">
A signed GitHub push to the default branch queues a source build, managed-environment reconciliation, Compose recreation, and release verification. A failed build leaves the running workload untouched.
</span>
</span>
<input
type="checkbox"
checked={autoDeployEnabled}
onChange={(event) => setAutoDeployEnabled(event.target.checked)}
disabled={deployment.managementMode !== "managed" || !selectedRepositoryId}
className="mt-1 h-4 w-4 accent-[var(--accent)]"
aria-label="Autopilot after merge"
/>
</label>
<label className="block">
<span className="text-xs font-medium text-muted">Deployed commit</span>
<input
Expand Down
2 changes: 2 additions & 0 deletions src/lib/deployment-evidence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ export function readDeploymentOverrides(metadataJson?: string | null) {
daytonaConnectorId?: string;
validationCommand?: string;
regressionCommand?: string;
autoDeployEnabled?: boolean;
};
};
return {
Expand All @@ -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 {
Expand Down
26 changes: 26 additions & 0 deletions src/lib/github-auto-deploy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { branchFromPushRef, githubAutoDeployIdempotencyKey, grantCanDeploy, readGithubAutoDeployPolicy } from "./github-auto-deploy";

describe("GitHub auto deploy policy", () => {
it("accepts branch pushes and ignores tag pushes", () => {
expect(branchFromPushRef("refs/heads/main")).toBe("main");
expect(branchFromPushRef("refs/heads/release/next")).toBe("release/next");
expect(branchFromPushRef("refs/tags/v1.0.0")).toBeNull();
});

it("defaults to disabled and reads an explicit allowlisted branch", () => {
expect(readGithubAutoDeployPolicy("{}")).toEqual({ enabled: false, branch: "main" });
expect(readGithubAutoDeployPolicy(JSON.stringify({ sourceRepair: { defaultBranch: "production", autoDeployEnabled: true } })))
.toEqual({ enabled: true, branch: "production" });
});

it("requires both redeploy scope and the deployment resource", () => {
expect(grantCanDeploy("deployment:read deployment:redeploy", "[3,19]", 3)).toBe(true);
expect(grantCanDeploy("deployment:read", "[3]", 3)).toBe(false);
expect(grantCanDeploy("deployment:redeploy", "[19]", 3)).toBe(false);
});

it("makes webhook retries idempotent per deployment", () => {
expect(githubAutoDeployIdempotencyKey("delivery-1", 3)).toBe("github-push:delivery-1:3");
});
});
42 changes: 42 additions & 0 deletions src/lib/github-auto-deploy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
export type GithubAutoDeployPolicy = {
enabled: boolean;
branch: string;
};

export function branchFromPushRef(ref: unknown): string | null {
if (typeof ref !== "string" || !ref.startsWith("refs/heads/")) return null;
const branch = ref.slice("refs/heads/".length).trim();
return branch && branch.length <= 200 ? branch : null;
}

export function readGithubAutoDeployPolicy(metadataJson: string | null | undefined): GithubAutoDeployPolicy {
try {
const metadata = JSON.parse(metadataJson || "{}") as {
sourceRepair?: { defaultBranch?: unknown; autoDeployEnabled?: unknown };
};
const source = metadata.sourceRepair;
return {
enabled: source?.autoDeployEnabled === true,
branch: typeof source?.defaultBranch === "string" && source.defaultBranch.trim()
? source.defaultBranch.trim()
: "main",
};
} catch {
return { enabled: false, branch: "main" };
}
}

export function grantCanDeploy(scope: string, resources: string, deploymentId: number): boolean {
const scopes = new Set(String(scope || "").split(/\s+/).filter(Boolean));
if (!scopes.has("deployment:redeploy")) return false;
try {
const parsed = JSON.parse(resources || "[]");
return Array.isArray(parsed) && parsed.map(Number).includes(deploymentId);
} catch {
return false;
}
}

export function githubAutoDeployIdempotencyKey(deliveryId: string, deploymentId: number): string {
return `github-push:${deliveryId}:${deploymentId}`;
}
Loading