Skip to content

Commit 873a2fc

Browse files
ampagentarjunkomath
andcommitted
Simplify PR preview deployments
Amp-Thread-ID: https://ampcode.com/threads/T-01a003f3-7142-74cd-b819-95472f4a6376 Co-authored-by: Arjun Komath <arjunkomath@gmail.com>
1 parent 10fe607 commit 873a2fc

67 files changed

Lines changed: 1316 additions & 1714 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎agent/internal/build/build.go‎

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -174,19 +174,39 @@ func (b *Builder) clone(ctx context.Context, config *Config, buildDir string) er
174174
if err != nil {
175175
return fmt.Errorf("git remote setup failed: %s: %w", output, err)
176176
}
177-
cmd = exec.CommandContext(ctx, "git", "-C", buildDir, "fetch", "--depth", "1", "--no-tags", "origin", config.GitRef)
177+
depth := "50"
178+
if pullRequestMergeRefPattern.MatchString(config.GitRef) {
179+
depth = "1"
180+
}
181+
cmd = exec.CommandContext(ctx, "git", "-C", buildDir, "fetch", "--depth", depth, "--no-tags", "origin", config.GitRef)
178182
output, err = b.runCommand(cmd, config)
179183
if err != nil {
180184
return fmt.Errorf("git fetch exact ref failed: %s: %w", output, err)
181185
}
182186

183-
cmd = exec.CommandContext(ctx, "git", "-C", buildDir, "rev-parse", "FETCH_HEAD")
184-
fetchedCommit, err := b.runCommand(cmd, config)
185-
if err != nil {
186-
return fmt.Errorf("git resolve fetched ref failed: %s: %w", fetchedCommit, err)
187-
}
188-
if !strings.EqualFold(strings.TrimSpace(fetchedCommit), config.CommitSha) {
189-
return fmt.Errorf("fetched ref resolved to %s, expected %s", strings.TrimSpace(fetchedCommit), config.CommitSha)
187+
if pullRequestMergeRefPattern.MatchString(config.GitRef) {
188+
cmd = exec.CommandContext(ctx, "git", "-C", buildDir, "rev-parse", "FETCH_HEAD")
189+
fetchedCommit, err := b.runCommand(cmd, config)
190+
if err != nil {
191+
return fmt.Errorf("git resolve fetched ref failed: %s: %w", fetchedCommit, err)
192+
}
193+
if !strings.EqualFold(strings.TrimSpace(fetchedCommit), config.CommitSha) {
194+
return fmt.Errorf("fetched ref resolved to %s, expected %s", strings.TrimSpace(fetchedCommit), config.CommitSha)
195+
}
196+
} else {
197+
cmd = exec.CommandContext(ctx, "git", "-C", buildDir, "cat-file", "-e", config.CommitSha+"^{commit}")
198+
if _, err = b.runCommand(cmd, config); err != nil {
199+
b.sendLog(config, "Selected commit is outside the shallow clone; fetching full branch history")
200+
cmd = exec.CommandContext(ctx, "git", "-C", buildDir, "fetch", "--unshallow", "--no-tags", "origin", config.GitRef)
201+
output, err = b.runCommand(cmd, config)
202+
if err != nil {
203+
return fmt.Errorf("git fetch full branch history failed: %s: %w", output, err)
204+
}
205+
cmd = exec.CommandContext(ctx, "git", "-C", buildDir, "cat-file", "-e", config.CommitSha+"^{commit}")
206+
if output, err = b.runCommand(cmd, config); err != nil {
207+
return fmt.Errorf("selected commit is not available from configured branch: %s: %w", output, err)
208+
}
209+
}
190210
}
191211

192212
b.sendLog(config, fmt.Sprintf("Checking out commit %s", truncateStr(config.CommitSha, 8)))

‎agent/internal/build/build_test.go‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"os"
66
"os/exec"
77
"path/filepath"
8+
"strconv"
89
"strings"
910
"testing"
1011
"time"
@@ -113,6 +114,43 @@ func TestCloneFetchesExactPullRequestMergeRef(t *testing.T) {
113114
}
114115
}
115116

117+
func TestCloneDeepensConfiguredBranchForSelectedCommit(t *testing.T) {
118+
workDir := filepath.Join(t.TempDir(), "work")
119+
remoteDir := filepath.Join(t.TempDir(), "remote.git")
120+
runGit(t, "init", "--initial-branch", "main", workDir)
121+
runGit(t, "-C", workDir, "config", "user.name", "Test User")
122+
runGit(t, "-C", workDir, "config", "user.email", "test@example.com")
123+
124+
var selectedSHA string
125+
for i := range 60 {
126+
filePath := filepath.Join(workDir, "history.txt")
127+
if err := os.WriteFile(filePath, []byte(strconv.Itoa(i)), 0600); err != nil {
128+
t.Fatal(err)
129+
}
130+
runGit(t, "-C", workDir, "add", "history.txt")
131+
runGit(t, "-C", workDir, "commit", "-m", "commit "+strconv.Itoa(i))
132+
if i == 5 {
133+
selectedSHA = runGit(t, "-C", workDir, "rev-parse", "HEAD")
134+
}
135+
}
136+
runGit(t, "clone", "--bare", workDir, remoteDir)
137+
138+
buildDir := filepath.Join(t.TempDir(), "build")
139+
config := &Config{
140+
BuildID: "build-1",
141+
CloneURL: "file://" + remoteDir,
142+
CommitSha: selectedSHA,
143+
Branch: "main",
144+
GitRef: "refs/heads/main",
145+
}
146+
if err := NewBuilder(t.TempDir(), nil).clone(context.Background(), config, buildDir); err != nil {
147+
t.Fatal(err)
148+
}
149+
if config.ResolvedCommitSha != selectedSHA {
150+
t.Fatalf("resolved commit = %s, want %s", config.ResolvedCommitSha, selectedSHA)
151+
}
152+
}
153+
116154
func TestCloneRejectsMovedRef(t *testing.T) {
117155
workDir := filepath.Join(t.TempDir(), "work")
118156
remoteDir := filepath.Join(t.TempDir(), "remote.git")

‎docs/architecture.mdx‎

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -137,20 +137,23 @@ With no configured health check, `healthy` means only that the container is runn
137137

138138
## Pull Request Preview Isolation
139139

140-
An enabled GitHub service represents each eligible pull request as a hidden
141-
stateless service copy. The copy gives the preview an independent service ID,
142-
revision, build, rollout, deployment set, registry path, and generated route,
143-
while reusing the normal runtime pipeline. This avoids allowing concurrent pull
144-
request revisions to compete for the base service's single rollout and routing
145-
state.
146-
147-
The control plane resolves the exact synthetic merge ref, refreshes the service
148-
copy from current base configuration, and queues an ordinary build. A current
149-
revision pointer on the copy prevents superseded build and rollout callbacks
150-
from deploying or reporting success. GitHub reports the transient environment
151-
as ready only after health and routing convergence complete. Closing, merging,
152-
or drafting the pull request clears that pointer before runtime and registry
153-
cleanup.
140+
An enabled GitHub service represents each eligible pull request as an ordinary,
141+
visible stateless service in the project's `previews` environment. An existing
142+
environment with that name is reused; otherwise the control plane creates it.
143+
The environment remains after its last preview closes and may also contain
144+
unrelated user-created services.
145+
146+
Each copy has an independent service ID, revision, build, rollout, deployment
147+
set, registry path, and generated route while reusing the normal runtime
148+
pipeline. It is copied from the base service only when first created, so later
149+
user edits to the visible preview service survive pull request updates.
150+
151+
The control plane resolves the exact synthetic merge ref and queues an ordinary
152+
build. A current revision pointer on the copy prevents superseded build and
153+
rollout callbacks from deploying or reporting success. GitHub reports the
154+
transient environment as ready only after health and routing convergence
155+
complete. Closing, merging, or drafting the pull request clears that pointer
156+
before runtime and registry cleanup.
154157

155158
## Networking
156159

‎docs/deployments/github.mdx‎

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,12 @@ GitHub deployment statuses are updated on the commit so you can track progress f
5050

5151
## Pull Request Preview Deployments
5252

53-
Preview deployments are opt in from a GitHub-backed service's **Previews** tab.
54-
They require a configured **Automatic Subdomain Domain** and its wildcard DNS
55-
record. Each eligible pull request gets one hidden, single-replica copy of the
56-
service and a stable generated HTTPS URL beneath that domain.
53+
Preview deployments are opt in from a GitHub-backed service's **Configuration**
54+
page. They require a configured **Automatic Subdomain Domain** and its wildcard
55+
DNS record. Each eligible pull request gets one visible, single-replica service
56+
in the project's ordinary `previews` environment and a stable generated HTTPS
57+
URL beneath that domain. An existing environment named `previews` is reused and
58+
is left in place when previews close.
5759

5860
A pull request is eligible only when it:
5961

@@ -67,12 +69,14 @@ The preview builds GitHub's synthetic merge result at
6769
configured branch. If GitHub cannot produce that ref because of merge
6870
conflicts, the preview fails rather than building the raw pull request head.
6971

70-
Preview copies inherit the service's current source configuration, private
71-
ports, resource limits, placement, health check, start command, and complete
72-
secret set. They do not copy volumes, backups, schedules, cron jobs,
73-
autoscaling, serverless sleep, production custom domains, or public TCP/UDP
74-
routes. Additional preview-specific secret configuration is neither needed nor
75-
available.
72+
When first created, preview services inherit the base service's current source
73+
configuration, private ports, resource limits, placement, health check, start
74+
command, and complete secret set. They do not copy volumes, backups, schedules,
75+
cron jobs, autoscaling, serverless sleep, production custom domains, or public
76+
TCP/UDP routes. Additional preview-specific secret configuration is neither
77+
needed nor available. Preview services use the normal service pages and may be
78+
edited like other services, except that volumes remain unavailable. Later pull
79+
request updates preserve those edits.
7680

7781
New commits replace the preview revision without changing its URL. Converting
7882
the pull request to a draft, closing it, or merging it removes the runtime and

‎web/actions/builds.ts‎

Lines changed: 23 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,7 @@ import {
1515

1616
export async function cancelBuild(buildId: string) {
1717
await requireDeveloperRole();
18-
const [result] = await db
19-
.select({ build: builds })
20-
.from(builds)
21-
.innerJoin(services, eq(services.id, builds.serviceId))
22-
.where(and(eq(builds.id, buildId), isNull(services.previewOfServiceId)));
23-
const build = result?.build;
18+
const [build] = await db.select().from(builds).where(eq(builds.id, buildId));
2419

2520
if (!build) {
2621
throw new Error("Build not found");
@@ -89,15 +84,12 @@ export async function retryBuild(buildId: string) {
8984
}
9085

9186
const [service] = await db
92-
.select({ id: services.id })
87+
.select({
88+
id: services.id,
89+
previewOfService: services.previewOfService,
90+
})
9391
.from(services)
94-
.where(
95-
and(
96-
eq(services.id, build.serviceId),
97-
isNull(services.deletedAt),
98-
isNull(services.previewOfServiceId),
99-
),
100-
);
92+
.where(and(eq(services.id, build.serviceId), isNull(services.deletedAt)));
10193

10294
if (!service) {
10395
throw new Error("Service not found");
@@ -107,16 +99,22 @@ export async function retryBuild(buildId: string) {
10799
throw new Error(`Cannot retry build in ${build.status} status`);
108100
}
109101

102+
const actor = {
103+
type: "user" as const,
104+
userId: session.user.id,
105+
name: session.user.name,
106+
};
107+
if (service.previewOfService) {
108+
await triggerBuildInternal(build.serviceId, "manual", actor);
109+
return { success: true };
110+
}
111+
110112
await requeueBuildRevisionInternal({
111113
serviceId: build.serviceId,
112114
serviceRevisionId: build.serviceRevisionId,
113115
commitMessage: build.commitMessage ?? "Retry build",
114116
author: build.author ?? undefined,
115-
actor: {
116-
type: "user",
117-
userId: session.user.id,
118-
name: session.user.name,
119-
},
117+
actor,
120118
});
121119

122120
return { success: true };
@@ -127,18 +125,6 @@ export async function triggerBuild(
127125
trigger: "manual" | "scheduled" = "manual",
128126
) {
129127
const session = await requireDeveloperRole();
130-
const service = await db
131-
.select({ id: services.id })
132-
.from(services)
133-
.where(
134-
and(
135-
eq(services.id, serviceId),
136-
isNull(services.deletedAt),
137-
isNull(services.previewOfServiceId),
138-
),
139-
)
140-
.then((rows) => rows[0]);
141-
if (!service) throw new Error("Service not found");
142128
const actor = session
143129
? {
144130
type: "user" as const,
@@ -162,17 +148,16 @@ export async function triggerManualBuild(serviceId: string, commitSha: string) {
162148
.select({ service: services, githubRepo: githubRepos })
163149
.from(services)
164150
.innerJoin(githubRepos, eq(githubRepos.serviceId, services.id))
165-
.where(
166-
and(
167-
eq(services.id, serviceId),
168-
isNull(services.deletedAt),
169-
isNull(services.previewOfServiceId),
170-
),
171-
);
151+
.where(and(eq(services.id, serviceId), isNull(services.deletedAt)));
172152
if (!result) throw new Error("Active GitHub App-connected service not found");
173153
if (result.service.sourceType !== "github") {
174154
throw new Error("Service is not connected to GitHub");
175155
}
156+
if (result.service.previewOfService) {
157+
throw new Error(
158+
"Preview services build their pull request merge ref; use Build to rebuild it",
159+
);
160+
}
176161

177162
const branch =
178163
result.githubRepo.deployBranch || result.githubRepo.defaultBranch || "main";

‎web/actions/compose.ts‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use server";
22

3-
import { and, eq, isNull } from "drizzle-orm";
3+
import { and, eq } from "drizzle-orm";
44
import { db } from "@/db";
55
import { services } from "@/db/schema";
66
import { requireDeveloperRole } from "@/lib/auth";
@@ -69,7 +69,6 @@ export async function importCompose(
6969
and(
7070
eq(services.projectId, projectId),
7171
eq(services.environmentId, environmentId),
72-
isNull(services.previewOfServiceId),
7372
),
7473
);
7574

‎web/actions/crons.ts‎

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,7 @@ export async function runServiceCron(cronId: string) {
1414
.from(serviceCrons)
1515
.innerJoin(
1616
services,
17-
and(
18-
eq(serviceCrons.serviceId, services.id),
19-
isNull(services.deletedAt),
20-
isNull(services.previewOfServiceId),
21-
),
17+
and(eq(serviceCrons.serviceId, services.id), isNull(services.deletedAt)),
2218
)
2319
.where(eq(serviceCrons.id, cronId))
2420
.limit(1)

‎web/actions/migrations.ts‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,13 @@
33
import { eq } from "drizzle-orm";
44
import { revalidatePath } from "next/cache";
55
import { db } from "@/db";
6-
import { getService } from "@/db/queries";
76
import { services } from "@/db/schema";
87
import { requireDeveloperRole } from "@/lib/auth";
98
import { inngest } from "@/lib/inngest/client";
109
import { inngestEvents } from "@/lib/inngest/events";
1110

1211
export async function cancelMigration(serviceId: string) {
1312
await requireDeveloperRole();
14-
if (!(await getService(serviceId))) throw new Error("Service not found");
1513
await inngest.send(inngestEvents.migrationCancelled.create({ serviceId }));
1614

1715
await db

0 commit comments

Comments
 (0)