From 10fe6076eb1a4e250a515262de9eca8803025df2 Mon Sep 17 00:00:00 2001
From: Amp
Date: Sun, 16 Aug 2026 08:04:17 +0000
Subject: [PATCH 1/5] Add GitHub PR preview deployments
Amp-Thread-ID: https://ampcode.com/threads/T-01a003f3-7142-74cd-b819-95472f4a6376
Co-authored-by: Arjun Komath
---
agent/internal/agent/handlers.go | 1 +
agent/internal/build/build.go | 98 ++-
agent/internal/build/build_test.go | 95 ++-
agent/internal/http/client.go | 1 +
docs/architecture.mdx | 17 +
docs/deployments/github.mdx | 41 ++
docs/installation.mdx | 5 +
web/actions/builds.ts | 35 +-
web/actions/compose.ts | 3 +-
web/actions/crons.ts | 6 +-
web/actions/migrations.ts | 2 +
web/actions/previews.ts | 96 +++
web/actions/projects.ts | 32 +-
web/actions/secrets.ts | 6 +-
.../[serviceId]/builds/[buildId]/page.tsx | 11 +-
.../services/[serviceId]/previews/page.tsx | 58 ++
.../[serviceId]/rollouts/[rolloutId]/page.tsx | 11 +-
web/app/api/builds/[buildId]/logs/route.ts | 24 +
web/app/api/builds/[buildId]/route.ts | 12 +-
web/app/api/inngest/route.ts | 8 +
web/app/api/navigation/route.ts | 1 +
web/app/api/projects/[id]/services/route.ts | 9 +-
.../api/rollouts/[rolloutId]/logs/route.ts | 24 +
web/app/api/services/[id]/backups/route.ts | 4 +
web/app/api/services/[id]/builds/route.ts | 4 +
web/app/api/services/[id]/commands/route.ts | 12 +-
.../api/services/[id]/github/commits/route.ts | 8 +-
web/app/api/services/[id]/revisions/route.ts | 8 +-
web/app/api/services/[id]/rollouts/route.ts | 4 +
.../[id]/secrets/[secretId]/reveal/route.ts | 4 +
web/app/api/services/[id]/secrets/route.ts | 4 +
web/app/api/v1/agent/builds/[id]/route.ts | 1 +
.../api/v1/agent/builds/[id]/status/route.ts | 115 ++--
.../[environmentId]/services/route.ts | 1 +
web/app/api/webhooks/github/route.ts | 169 ++++-
.../service/preview-deployments-page.tsx | 229 +++++++
.../service/service-layout-client.tsx | 6 +-
web/db/queries.ts | 26 +-
web/db/schema.ts | 48 ++
web/lib/backup-scheduler.ts | 8 +-
web/lib/backups/trigger-backup.ts | 4 +-
web/lib/deploy-service.ts | 4 +-
web/lib/github.ts | 169 ++++-
web/lib/inngest/events/build.ts | 3 +-
web/lib/inngest/events/index.ts | 8 +
web/lib/inngest/events/preview.ts | 22 +
.../functions/build-trigger-workflow.ts | 4 +-
web/lib/inngest/functions/build-workflow.ts | 80 +++
web/lib/inngest/functions/crons.ts | 81 ++-
web/lib/inngest/functions/index.ts | 6 +
web/lib/inngest/functions/preview-workflow.ts | 640 ++++++++++++++++++
web/lib/inngest/functions/rollout-helpers.ts | 39 +-
web/lib/inngest/functions/rollout-utils.ts | 146 ++--
web/lib/inngest/functions/rollout-workflow.ts | 45 +-
web/lib/preview-deployments.ts | 577 ++++++++++++++++
web/lib/preview-lifecycle.ts | 272 ++++++++
web/lib/public-api.ts | 8 +-
web/lib/scheduler.ts | 8 +-
web/lib/service-crons.ts | 6 +-
web/lib/service-revision-changes.ts | 67 +-
web/lib/service-revision-spec.ts | 37 +-
web/lib/service-revisions.ts | 27 +-
web/lib/trigger-build.ts | 11 +-
web/tests/autoplacement.test.ts | 4 +-
web/tests/build-assignment.test.ts | 2 +-
web/tests/build-claim-route.test.ts | 54 ++
web/tests/build-revision-source.test.ts | 1 +
web/tests/build-status-route.test.ts | 55 ++
web/tests/build-trigger-workflow.test.ts | 1 +
web/tests/build-workflow.test.ts | 11 +
web/tests/deploy-service-revision.test.ts | 2 +-
web/tests/github-webhook.test.ts | 166 +++++
web/tests/github.test.ts | 140 +++-
web/tests/inngest-route.test.ts | 6 +
web/tests/preview-actions.test.ts | 170 +++++
web/tests/preview-deployments.test.ts | 258 +++++++
web/tests/preview-policy.test.ts | 52 ++
web/tests/preview-workflow.test.ts | 465 +++++++++++++
web/tests/service-commands-route.test.ts | 1 +
web/tests/service-config.test.ts | 5 +-
web/tests/service-revision-build.test.ts | 4 +-
web/tests/service-revision-changes.test.ts | 2 +-
web/tests/service-revision-spec.test.ts | 25 +-
web/tests/service-revisions-route.test.ts | 2 +-
web/tests/trigger-build.test.ts | 49 ++
85 files changed, 4774 insertions(+), 212 deletions(-)
create mode 100644 web/actions/previews.ts
create mode 100644 web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/previews/page.tsx
create mode 100644 web/components/service/preview-deployments-page.tsx
create mode 100644 web/lib/inngest/events/preview.ts
create mode 100644 web/lib/inngest/functions/preview-workflow.ts
create mode 100644 web/lib/preview-deployments.ts
create mode 100644 web/lib/preview-lifecycle.ts
create mode 100644 web/tests/preview-actions.test.ts
create mode 100644 web/tests/preview-deployments.test.ts
create mode 100644 web/tests/preview-policy.test.ts
create mode 100644 web/tests/preview-workflow.test.ts
diff --git a/agent/internal/agent/handlers.go b/agent/internal/agent/handlers.go
index 1ffbf8a8..2c5dd279 100644
--- a/agent/internal/agent/handlers.go
+++ b/agent/internal/agent/handlers.go
@@ -199,6 +199,7 @@ func (a *Agent) ProcessBuild(item agenthttp.WorkQueueItem) error {
CloneURL: buildDetails.CloneURL,
CommitSha: buildDetails.Build.CommitSha,
Branch: buildDetails.Build.Branch,
+ GitRef: buildDetails.Build.GitRef,
ImageRepository: buildDetails.ImageRepository,
ImageURI: buildDetails.ImageURI,
ServiceID: buildDetails.Build.ServiceID,
diff --git a/agent/internal/build/build.go b/agent/internal/build/build.go
index 42d34970..f832f688 100644
--- a/agent/internal/build/build.go
+++ b/agent/internal/build/build.go
@@ -27,6 +27,7 @@ type Config struct {
CloneURL string
CommitSha string
Branch string
+ GitRef string
ImageRepository string
ImageURI string
ResolvedCommitSha string
@@ -63,6 +64,8 @@ type dockerfileConfig struct {
var managedTempArtifactPattern = regexp.MustCompile(`^(backup|restore)-[0-9a-fA-F-]{36}\.tar\.gz$|^restore-extract-[0-9a-fA-F-]{36}$`)
var windowsAbsoluteRootPattern = regexp.MustCompile(`^[A-Za-z]:[\\/]`)
var imageDigestPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
+var credentialURLPattern = regexp.MustCompile(`(?i)https?://[^\s/@]+(?::[^\s/@]*)?@`)
+var pullRequestMergeRefPattern = regexp.MustCompile(`^refs/pull/[1-9][0-9]*/merge$`)
func NewBuilder(dataDir string, logSender LogSender) *Builder {
return &Builder{
@@ -154,46 +157,43 @@ func (b *Builder) clone(ctx context.Context, config *Config, buildDir string) er
}
b.sendLog(config, fmt.Sprintf("Cloning %s", safeURL))
- branch := config.Branch
- if branch == "" {
- branch = "main"
+ if matched, _ := regexp.MatchString(`^[0-9a-fA-F]{40}$`, config.CommitSha); !matched {
+ return fmt.Errorf("invalid exact commit SHA")
+ }
+ if !validGitRef(config.GitRef) {
+ return fmt.Errorf("invalid exact Git ref")
}
- if config.CommitSha == "HEAD" {
- cmd := exec.CommandContext(ctx, "git", "clone", "--depth", "1", "--branch", branch, config.CloneURL, buildDir)
- output, err := b.runCommand(cmd, config)
- if err != nil {
- return fmt.Errorf("git clone failed: %s: %w", output, err)
- }
- b.sendLog(config, fmt.Sprintf("Cloned branch %s", branch))
- } else {
- if matched, _ := regexp.MatchString(`^[0-9a-fA-F]{40}$`, config.CommitSha); !matched {
- return fmt.Errorf("invalid exact commit SHA")
- }
- cmd := exec.CommandContext(ctx, "git", "clone", "--depth", "50", "--branch", branch, "--single-branch", config.CloneURL, buildDir)
- output, err := b.runCommand(cmd, config)
- if err != nil {
- return fmt.Errorf("git clone failed: %s: %w", output, err)
- }
-
- b.sendLog(config, fmt.Sprintf("Checking out commit %s", truncateStr(config.CommitSha, 8)))
+ cmd := exec.CommandContext(ctx, "git", "init", buildDir)
+ output, err := b.runCommand(cmd, config)
+ if err != nil {
+ return fmt.Errorf("git init failed: %s: %w", output, err)
+ }
+ cmd = exec.CommandContext(ctx, "git", "-C", buildDir, "remote", "add", "origin", config.CloneURL)
+ output, err = b.runCommand(cmd, config)
+ if err != nil {
+ return fmt.Errorf("git remote setup failed: %s: %w", output, err)
+ }
+ cmd = exec.CommandContext(ctx, "git", "-C", buildDir, "fetch", "--depth", "1", "--no-tags", "origin", config.GitRef)
+ output, err = b.runCommand(cmd, config)
+ if err != nil {
+ return fmt.Errorf("git fetch exact ref failed: %s: %w", output, err)
+ }
- cmd = exec.CommandContext(ctx, "git", "-C", buildDir, "cat-file", "-e", config.CommitSha+"^{commit}")
- _, err = b.runCommand(cmd, config)
- if err != nil {
- b.sendLog(config, "Selected commit is outside the shallow clone; fetching full branch history")
- cmd = exec.CommandContext(ctx, "git", "-C", buildDir, "fetch", "--unshallow", "origin", branch)
- output, err = b.runCommand(cmd, config)
- if err != nil {
- return fmt.Errorf("git fetch full branch history failed: %s: %w", output, err)
- }
- }
+ cmd = exec.CommandContext(ctx, "git", "-C", buildDir, "rev-parse", "FETCH_HEAD")
+ fetchedCommit, err := b.runCommand(cmd, config)
+ if err != nil {
+ return fmt.Errorf("git resolve fetched ref failed: %s: %w", fetchedCommit, err)
+ }
+ if !strings.EqualFold(strings.TrimSpace(fetchedCommit), config.CommitSha) {
+ return fmt.Errorf("fetched ref resolved to %s, expected %s", strings.TrimSpace(fetchedCommit), config.CommitSha)
+ }
- cmd = exec.CommandContext(ctx, "git", "-C", buildDir, "checkout", config.CommitSha)
- output, err = b.runCommand(cmd, config)
- if err != nil {
- return fmt.Errorf("git checkout failed: %s: %w", output, err)
- }
+ b.sendLog(config, fmt.Sprintf("Checking out commit %s", truncateStr(config.CommitSha, 8)))
+ cmd = exec.CommandContext(ctx, "git", "-C", buildDir, "checkout", "--detach", config.CommitSha)
+ output, err = b.runCommand(cmd, config)
+ if err != nil {
+ return fmt.Errorf("git checkout failed: %s: %w", output, err)
}
b.sendLog(config, "Clone completed")
@@ -209,6 +209,30 @@ func (b *Builder) clone(ctx context.Context, config *Config, buildDir string) er
return nil
}
+func validGitRef(ref string) bool {
+ if pullRequestMergeRefPattern.MatchString(ref) {
+ return true
+ }
+ if !strings.HasPrefix(ref, "refs/heads/") {
+ return false
+ }
+ branch := strings.TrimPrefix(ref, "refs/heads/")
+ if branch == "" || branch == "@" || strings.HasSuffix(branch, ".") || strings.Contains(branch, "..") || strings.Contains(branch, "@{") {
+ return false
+ }
+ for _, character := range branch {
+ if character <= 0x20 || character == 0x7f || strings.ContainsRune("~^:?*[\\", character) {
+ return false
+ }
+ }
+ for _, part := range strings.Split(branch, "/") {
+ if part == "" || strings.HasPrefix(part, ".") || strings.HasSuffix(part, ".lock") {
+ return false
+ }
+ }
+ return true
+}
+
func (b *Builder) resolveCommitSha(ctx context.Context, config *Config, buildDir string) (string, error) {
cmd := exec.CommandContext(ctx, "git", "-C", buildDir, "rev-parse", "HEAD")
output, err := b.runCommand(cmd, config)
@@ -462,7 +486,7 @@ func resolveDockerfile(contextDir string, secrets map[string]string) (dockerfile
func (b *Builder) runCommand(cmd *exec.Cmd, config *Config) (string, error) {
output, err := cmd.CombinedOutput()
- outputStr := string(output)
+ outputStr := credentialURLPattern.ReplaceAllString(string(output), "https://***@")
if len(outputStr) > 0 {
lines := strings.Split(strings.TrimSpace(outputStr), "\n")
diff --git a/agent/internal/build/build_test.go b/agent/internal/build/build_test.go
index 8f5b9bb6..c89166de 100644
--- a/agent/internal/build/build_test.go
+++ b/agent/internal/build/build_test.go
@@ -5,7 +5,6 @@ import (
"os"
"os/exec"
"path/filepath"
- "strconv"
"strings"
"testing"
"time"
@@ -81,26 +80,20 @@ func TestCleanupStaleBuildDirsRemovesOnlyOldDirectories(t *testing.T) {
assertExists(t, filePath)
}
-func TestCloneDeepensConfiguredBranchForSelectedCommit(t *testing.T) {
+func TestCloneFetchesExactPullRequestMergeRef(t *testing.T) {
workDir := filepath.Join(t.TempDir(), "work")
remoteDir := filepath.Join(t.TempDir(), "remote.git")
runGit(t, "init", "--initial-branch", "main", workDir)
runGit(t, "-C", workDir, "config", "user.name", "Test User")
runGit(t, "-C", workDir, "config", "user.email", "test@example.com")
-
- var selectedSHA string
- for i := range 60 {
- filePath := filepath.Join(workDir, "history.txt")
- if err := os.WriteFile(filePath, []byte(strconv.Itoa(i)), 0600); err != nil {
- t.Fatal(err)
- }
- runGit(t, "-C", workDir, "add", "history.txt")
- runGit(t, "-C", workDir, "commit", "-m", "commit "+strconv.Itoa(i))
- if i == 5 {
- selectedSHA = runGit(t, "-C", workDir, "rev-parse", "HEAD")
- }
+ if err := os.WriteFile(filepath.Join(workDir, "app.txt"), []byte("preview"), 0600); err != nil {
+ t.Fatal(err)
}
+ runGit(t, "-C", workDir, "add", "app.txt")
+ runGit(t, "-C", workDir, "commit", "-m", "preview merge")
+ selectedSHA := runGit(t, "-C", workDir, "rev-parse", "HEAD")
runGit(t, "clone", "--bare", workDir, remoteDir)
+ runGit(t, "--git-dir", remoteDir, "update-ref", "refs/pull/42/merge", selectedSHA)
buildDir := filepath.Join(t.TempDir(), "build")
config := &Config{
@@ -108,6 +101,7 @@ func TestCloneDeepensConfiguredBranchForSelectedCommit(t *testing.T) {
CloneURL: "file://" + remoteDir,
CommitSha: selectedSHA,
Branch: "main",
+ GitRef: "refs/pull/42/merge",
}
builder := NewBuilder(t.TempDir(), nil)
@@ -119,6 +113,79 @@ func TestCloneDeepensConfiguredBranchForSelectedCommit(t *testing.T) {
}
}
+func TestCloneRejectsMovedRef(t *testing.T) {
+ workDir := filepath.Join(t.TempDir(), "work")
+ remoteDir := filepath.Join(t.TempDir(), "remote.git")
+ runGit(t, "init", "--initial-branch", "main", workDir)
+ runGit(t, "-C", workDir, "config", "user.name", "Test User")
+ runGit(t, "-C", workDir, "config", "user.email", "test@example.com")
+ if err := os.WriteFile(filepath.Join(workDir, "app.txt"), []byte("first"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ runGit(t, "-C", workDir, "add", "app.txt")
+ runGit(t, "-C", workDir, "commit", "-m", "first")
+ expectedSHA := runGit(t, "-C", workDir, "rev-parse", "HEAD")
+ if err := os.WriteFile(filepath.Join(workDir, "app.txt"), []byte("second"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ runGit(t, "-C", workDir, "commit", "-am", "second")
+ movedSHA := runGit(t, "-C", workDir, "rev-parse", "HEAD")
+ runGit(t, "clone", "--bare", workDir, remoteDir)
+ runGit(t, "--git-dir", remoteDir, "update-ref", "refs/pull/42/merge", movedSHA)
+
+ config := &Config{
+ BuildID: "build-1",
+ CloneURL: "file://" + remoteDir,
+ CommitSha: expectedSHA,
+ Branch: "main",
+ GitRef: "refs/pull/42/merge",
+ }
+ err := NewBuilder(t.TempDir(), nil).clone(
+ context.Background(),
+ config,
+ filepath.Join(t.TempDir(), "build"),
+ )
+ if err == nil || !strings.Contains(err.Error(), "fetched ref resolved to") {
+ t.Fatalf("clone error = %v, want moved ref failure", err)
+ }
+}
+
+func TestRunCommandRedactsGitCredentials(t *testing.T) {
+ config := &Config{BuildID: "build-1"}
+ output, _ := NewBuilder(t.TempDir(), nil).runCommand(
+ exec.Command("sh", "-c", "printf '%s' 'fatal: https://x-access-token:secret-token@example.com/repo.git' && exit 1"),
+ config,
+ )
+ if strings.Contains(output, "secret-token") || !strings.Contains(output, "https://***@example.com") {
+ t.Fatalf("credential output was not redacted: %q", output)
+ }
+}
+
+func TestValidGitRef(t *testing.T) {
+ for _, ref := range []string{
+ "refs/heads/main",
+ "refs/heads/feature/preview-deployments",
+ "refs/pull/42/merge",
+ } {
+ if !validGitRef(ref) {
+ t.Errorf("validGitRef(%q) = false, want true", ref)
+ }
+ }
+ for _, ref := range []string{
+ "main",
+ "refs/heads//main",
+ "refs/heads/feature/.hidden",
+ "refs/heads/@",
+ "refs/heads/feature.lock",
+ "refs/pull/0/merge",
+ "refs/pull/42/head",
+ } {
+ if validGitRef(ref) {
+ t.Errorf("validGitRef(%q) = true, want false", ref)
+ }
+ }
+}
+
func TestResolveBuildContext(t *testing.T) {
buildDir := t.TempDir()
nestedDir := filepath.Join(buildDir, "services", "api")
diff --git a/agent/internal/http/client.go b/agent/internal/http/client.go
index ecf17577..e363f1cd 100644
--- a/agent/internal/http/client.go
+++ b/agent/internal/http/client.go
@@ -398,6 +398,7 @@ type BuildDetails struct {
CommitSha string `json:"commitSha"`
CommitMessage string `json:"commitMessage"`
Branch string `json:"branch"`
+ GitRef string `json:"gitRef"`
ServiceID string `json:"serviceId"`
ProjectID string `json:"projectId"`
} `json:"build"`
diff --git a/docs/architecture.mdx b/docs/architecture.mdx
index 14333107..4d3187a5 100644
--- a/docs/architecture.mdx
+++ b/docs/architecture.mdx
@@ -135,6 +135,23 @@ For each rollout, the control plane freezes the required target set immediately
With no configured health check, `healthy` means only that the container is running. Routing convergence prevents completion before network configuration is live, but application-level readiness remains the responsibility of a user-configured health check.
+## Pull Request Preview Isolation
+
+An enabled GitHub service represents each eligible pull request as a hidden
+stateless service copy. The copy gives the preview an independent service ID,
+revision, build, rollout, deployment set, registry path, and generated route,
+while reusing the normal runtime pipeline. This avoids allowing concurrent pull
+request revisions to compete for the base service's single rollout and routing
+state.
+
+The control plane resolves the exact synthetic merge ref, refreshes the service
+copy from current base configuration, and queues an ordinary build. A current
+revision pointer on the copy prevents superseded build and rollout callbacks
+from deploying or reporting success. GitHub reports the transient environment
+as ready only after health and routing convergence complete. Closing, merging,
+or drafting the pull request clears that pointer before runtime and registry
+cleanup.
+
## Networking
### IP Address Scheme
diff --git a/docs/deployments/github.mdx b/docs/deployments/github.mdx
index 9f7ba3cc..cbf8a2ff 100644
--- a/docs/deployments/github.mdx
+++ b/docs/deployments/github.mdx
@@ -18,6 +18,14 @@ Techulus Cloud integrates with GitHub through a [GitHub App](https://docs.github
3. Install the GitHub App on your GitHub account or organization.
+The GitHub App needs these repository permissions:
+
+- **Contents:** Read
+- **Pull requests:** Read
+- **Deployments:** Read and write
+
+Subscribe the app to both the **Push** and **Pull request** webhook events.
+
## Connecting a Repository
Once the GitHub App is installed, connect a repository to a service:
@@ -40,6 +48,39 @@ The flow:
GitHub deployment statuses are updated on the commit so you can track progress from pull requests.
+## Pull Request Preview Deployments
+
+Preview deployments are opt in from a GitHub-backed service's **Previews** tab.
+They require a configured **Automatic Subdomain Domain** and its wildcard DNS
+record. Each eligible pull request gets one hidden, single-replica copy of the
+service and a stable generated HTTPS URL beneath that domain.
+
+A pull request is eligible only when it:
+
+- comes from the same repository as the base branch (forks are skipped),
+- targets the service's configured deployment branch,
+- is open and ready for review (drafts are skipped), and
+- belongs to a stateless service.
+
+The preview builds GitHub's synthetic merge result at
+`refs/pull//merge`. This tests the change as it would merge into the
+configured branch. If GitHub cannot produce that ref because of merge
+conflicts, the preview fails rather than building the raw pull request head.
+
+Preview copies inherit the service's current source configuration, private
+ports, resource limits, placement, health check, start command, and complete
+secret set. They do not copy volumes, backups, schedules, cron jobs,
+autoscaling, serverless sleep, production custom domains, or public TCP/UDP
+routes. Additional preview-specific secret configuration is neither needed nor
+available.
+
+New commits replace the preview revision without changing its URL. Converting
+the pull request to a draft, closing it, or merging it removes the runtime and
+route and marks the GitHub deployment inactive. A daily reconciliation job
+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.
+
## Build Process
Agents build images using one of two methods:
diff --git a/docs/installation.mdx b/docs/installation.mdx
index bbcfb618..5fe2839d 100644
--- a/docs/installation.mdx
+++ b/docs/installation.mdx
@@ -297,6 +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 preview deployments additionally require the Automatic Subdomain Domain
+setting and a wildcard DNS record for that domain.
+
## Generating Secrets
```bash
diff --git a/web/actions/builds.ts b/web/actions/builds.ts
index b52dd868..845dbf49 100644
--- a/web/actions/builds.ts
+++ b/web/actions/builds.ts
@@ -15,7 +15,12 @@ import {
export async function cancelBuild(buildId: string) {
await requireDeveloperRole();
- const [build] = await db.select().from(builds).where(eq(builds.id, buildId));
+ const [result] = await db
+ .select({ build: builds })
+ .from(builds)
+ .innerJoin(services, eq(services.id, builds.serviceId))
+ .where(and(eq(builds.id, buildId), isNull(services.previewOfServiceId)));
+ const build = result?.build;
if (!build) {
throw new Error("Build not found");
@@ -86,7 +91,13 @@ export async function retryBuild(buildId: string) {
const [service] = await db
.select({ id: services.id })
.from(services)
- .where(and(eq(services.id, build.serviceId), isNull(services.deletedAt)));
+ .where(
+ and(
+ eq(services.id, build.serviceId),
+ isNull(services.deletedAt),
+ isNull(services.previewOfServiceId),
+ ),
+ );
if (!service) {
throw new Error("Service not found");
@@ -116,6 +127,18 @@ export async function triggerBuild(
trigger: "manual" | "scheduled" = "manual",
) {
const session = await requireDeveloperRole();
+ const service = await db
+ .select({ id: services.id })
+ .from(services)
+ .where(
+ and(
+ eq(services.id, serviceId),
+ isNull(services.deletedAt),
+ isNull(services.previewOfServiceId),
+ ),
+ )
+ .then((rows) => rows[0]);
+ if (!service) throw new Error("Service not found");
const actor = session
? {
type: "user" as const,
@@ -139,7 +162,13 @@ export async function triggerManualBuild(serviceId: string, commitSha: string) {
.select({ service: services, githubRepo: githubRepos })
.from(services)
.innerJoin(githubRepos, eq(githubRepos.serviceId, services.id))
- .where(and(eq(services.id, serviceId), isNull(services.deletedAt)));
+ .where(
+ and(
+ eq(services.id, serviceId),
+ isNull(services.deletedAt),
+ isNull(services.previewOfServiceId),
+ ),
+ );
if (!result) throw new Error("Active GitHub App-connected service not found");
if (result.service.sourceType !== "github") {
throw new Error("Service is not connected to GitHub");
diff --git a/web/actions/compose.ts b/web/actions/compose.ts
index ed7afa64..eecd28f7 100644
--- a/web/actions/compose.ts
+++ b/web/actions/compose.ts
@@ -1,6 +1,6 @@
"use server";
-import { and, eq } from "drizzle-orm";
+import { and, eq, isNull } from "drizzle-orm";
import { db } from "@/db";
import { services } from "@/db/schema";
import { requireDeveloperRole } from "@/lib/auth";
@@ -69,6 +69,7 @@ export async function importCompose(
and(
eq(services.projectId, projectId),
eq(services.environmentId, environmentId),
+ isNull(services.previewOfServiceId),
),
);
diff --git a/web/actions/crons.ts b/web/actions/crons.ts
index eb99c38f..05436a3f 100644
--- a/web/actions/crons.ts
+++ b/web/actions/crons.ts
@@ -14,7 +14,11 @@ export async function runServiceCron(cronId: string) {
.from(serviceCrons)
.innerJoin(
services,
- and(eq(serviceCrons.serviceId, services.id), isNull(services.deletedAt)),
+ and(
+ eq(serviceCrons.serviceId, services.id),
+ isNull(services.deletedAt),
+ isNull(services.previewOfServiceId),
+ ),
)
.where(eq(serviceCrons.id, cronId))
.limit(1)
diff --git a/web/actions/migrations.ts b/web/actions/migrations.ts
index 56ec46d0..5c15d103 100644
--- a/web/actions/migrations.ts
+++ b/web/actions/migrations.ts
@@ -3,6 +3,7 @@
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { db } from "@/db";
+import { getService } from "@/db/queries";
import { services } from "@/db/schema";
import { requireDeveloperRole } from "@/lib/auth";
import { inngest } from "@/lib/inngest/client";
@@ -10,6 +11,7 @@ import { inngestEvents } from "@/lib/inngest/events";
export async function cancelMigration(serviceId: string) {
await requireDeveloperRole();
+ if (!(await getService(serviceId))) throw new Error("Service not found");
await inngest.send(inngestEvents.migrationCancelled.create({ serviceId }));
await db
diff --git a/web/actions/previews.ts b/web/actions/previews.ts
new file mode 100644
index 00000000..58aa1d28
--- /dev/null
+++ b/web/actions/previews.ts
@@ -0,0 +1,96 @@
+"use server";
+
+import { randomUUID } from "node:crypto";
+import { and, eq, isNull } from "drizzle-orm";
+import { db } from "@/db";
+import { getService } from "@/db/queries";
+import { githubRepos, services } from "@/db/schema";
+import { requireDeveloperRole } from "@/lib/auth";
+import { inngest } from "@/lib/inngest/client";
+import { inngestEvents } from "@/lib/inngest/events";
+import { requirePreviewDomain } from "@/lib/preview-deployments";
+
+export async function setPreviewDeploymentsEnabled(
+ serviceId: string,
+ enabled: boolean,
+) {
+ await requireDeveloperRole();
+ const service = await getService(serviceId);
+ if (!service) throw new Error("Service not found");
+ if (service.sourceType !== "github") {
+ throw new Error("Preview deployments require a GitHub App service");
+ }
+ if (service.stateful) {
+ throw new Error(
+ "Preview deployments are available only for stateless services",
+ );
+ }
+ const repo = await db
+ .select({ id: githubRepos.id })
+ .from(githubRepos)
+ .where(eq(githubRepos.serviceId, serviceId))
+ .then((rows) => rows[0]);
+ if (!repo)
+ throw new Error("Preview deployments require a GitHub App service");
+ if (enabled) await requirePreviewDomain();
+
+ await db
+ .update(services)
+ .set({ previewDeploymentsEnabled: enabled })
+ .where(
+ and(
+ eq(services.id, serviceId),
+ isNull(services.deletedAt),
+ isNull(services.previewOfServiceId),
+ ),
+ );
+ await inngest.send(
+ inngestEvents.previewServiceReconcileRequested.create(
+ { baseServiceId: serviceId },
+ { id: `preview-setting:${serviceId}:${enabled}:${randomUUID()}` },
+ ),
+ );
+ return { success: true };
+}
+
+export async function redeployPreview(
+ baseServiceId: string,
+ pullRequestNumber: number,
+) {
+ await requireDeveloperRole();
+ const service = await getService(baseServiceId);
+ if (!service?.previewDeploymentsEnabled) {
+ throw new Error("Preview deployments are not enabled for this service");
+ }
+ await inngest.send(
+ inngestEvents.previewSyncRequested.create(
+ { baseServiceId, pullRequestNumber, force: true },
+ {
+ id: `preview-redeploy:${baseServiceId}:${pullRequestNumber}:${randomUUID()}`,
+ },
+ ),
+ );
+ return { success: true };
+}
+
+export async function removePreview(
+ baseServiceId: string,
+ pullRequestNumber: number,
+) {
+ await requireDeveloperRole();
+ const service = await getService(baseServiceId);
+ if (!service) throw new Error("Service not found");
+ await inngest.send(
+ inngestEvents.previewCloseRequested.create(
+ {
+ baseServiceId,
+ pullRequestNumber,
+ reason: "removed manually",
+ },
+ {
+ id: `preview-remove:${baseServiceId}:${pullRequestNumber}:${randomUUID()}`,
+ },
+ ),
+ );
+ return { success: true };
+}
diff --git a/web/actions/projects.ts b/web/actions/projects.ts
index 63319b52..7327dde1 100644
--- a/web/actions/projects.ts
+++ b/web/actions/projects.ts
@@ -54,6 +54,7 @@ import {
cleanupRegistryArtifactsForService,
prepareRegistryArtifactCleanup,
} from "@/lib/registry-retention";
+import { deletePreviewsForBaseService } from "@/lib/preview-lifecycle";
import {
containerPathSchema,
githubRepoUrlSchema,
@@ -123,7 +124,9 @@ export async function deleteProject(
const projectServices = await db
.select()
.from(services)
- .where(eq(services.projectId, id));
+ .where(
+ and(eq(services.projectId, id), isNull(services.previewOfServiceId)),
+ );
for (const service of projectServices) {
const activeDeployments = await db
@@ -255,7 +258,12 @@ export async function deleteEnvironment(environmentId: string) {
const envServices = await db
.select({ id: services.id })
.from(services)
- .where(eq(services.environmentId, environmentId));
+ .where(
+ and(
+ eq(services.environmentId, environmentId),
+ isNull(services.previewOfServiceId),
+ ),
+ );
for (const service of envServices) {
await hardDeleteService(service.id);
@@ -451,6 +459,7 @@ async function hardDeleteService(serviceId: string) {
);
}
const claimedService = service.service;
+ await deletePreviewsForBaseService(serviceId, "base service deleted");
const allDeployments = await db
.select()
@@ -639,7 +648,7 @@ export async function restoreDeletedService(serviceId: string) {
const service = await db
.select()
.from(services)
- .where(eq(services.id, serviceId))
+ .where(and(eq(services.id, serviceId), isNull(services.previewOfServiceId)))
.then((r) => r[0]);
if (!service || !service.deletedAt) {
@@ -821,6 +830,7 @@ export async function updateServiceHostname(
export async function updateServiceName(serviceId: string, name: string) {
await requireDeveloperRole();
+ if (!(await getService(serviceId))) throw new Error("Service not found");
try {
const validatedName = nameSchema.parse(name);
@@ -916,6 +926,7 @@ export async function updateServiceGithubRepo(
export async function deployService(serviceId: string) {
const session = await requireDeveloperRole();
if (!session) throw new Error("Unauthorized");
+ if (!(await getService(serviceId))) throw new Error("Service not found");
const actor = {
type: "user",
userId: session.user.id,
@@ -926,6 +937,7 @@ export async function deployService(serviceId: string) {
export async function deleteDeployments(serviceId: string) {
await requireDeveloperRole();
+ if (!(await getService(serviceId))) throw new Error("Service not found");
await db.delete(deployments).where(eq(deployments.serviceId, serviceId));
return { success: true };
}
@@ -1080,6 +1092,7 @@ export async function updateServiceServerlessSettings(
) {
await requireDeveloperRole();
const validated = serverlessSettingsSchema.parse(settings);
+ if (!(await getService(serviceId))) throw new Error("Service not found");
await db.transaction(async (tx) => {
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${serviceId}))`);
@@ -1557,6 +1570,7 @@ export async function updateServiceConfig(
export async function stopService(serviceId: string) {
await requireDeveloperRole();
+ if (!(await getService(serviceId))) throw new Error("Service not found");
const desiredDeployments = await db
.select()
.from(deployments)
@@ -1617,6 +1631,7 @@ export async function restartService(serviceId: string) {
export async function abortRollout(serviceId: string) {
await requireDeveloperRole();
+ if (!(await getService(serviceId))) throw new Error("Service not found");
const activeRolloutIds = await db.transaction(async (tx) => {
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${serviceId}))`);
const activeRollouts = await tx
@@ -1712,9 +1727,18 @@ export async function addServiceVolume(
const service = await tx
.select()
.from(services)
- .where(and(eq(services.id, serviceId), isNull(services.deletedAt)))
+ .where(
+ and(
+ eq(services.id, serviceId),
+ isNull(services.deletedAt),
+ isNull(services.previewOfServiceId),
+ ),
+ )
.then((rows) => rows[0]);
if (!service) throw new Error("Service not found");
+ if (service.previewDeploymentsEnabled) {
+ throw new Error("Disable preview deployments before adding a volume");
+ }
if (service.placementMode === "automatic") {
throw new Error("Switch to manual placement before adding a volume");
}
diff --git a/web/actions/secrets.ts b/web/actions/secrets.ts
index 6972b071..acef6cab 100644
--- a/web/actions/secrets.ts
+++ b/web/actions/secrets.ts
@@ -1,7 +1,7 @@
"use server";
import { randomUUID } from "node:crypto";
-import { and, eq, inArray } from "drizzle-orm";
+import { and, eq, inArray, isNull } from "drizzle-orm";
import { ZodError } from "zod";
import { db } from "@/db";
import { secrets, services } from "@/db/schema";
@@ -25,7 +25,9 @@ export async function createSecretsBatch(
const service = await db
.select()
.from(services)
- .where(eq(services.id, serviceId));
+ .where(
+ and(eq(services.id, serviceId), isNull(services.previewOfServiceId)),
+ );
if (!service[0]) {
throw new Error("Service not found");
diff --git a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/builds/[buildId]/page.tsx b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/builds/[buildId]/page.tsx
index bd9f9de5..350d01b5 100644
--- a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/builds/[buildId]/page.tsx
+++ b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/builds/[buildId]/page.tsx
@@ -1,4 +1,4 @@
-import { and, eq } from "drizzle-orm";
+import { and, eq, isNull } from "drizzle-orm";
import { notFound } from "next/navigation";
import { BuildDetails } from "@/components/builds/build-details";
import { SetBreadcrumbs } from "@/components/core/breadcrumb-data";
@@ -28,7 +28,14 @@ async function getBuild(
const service = await db
.select()
.from(services)
- .where(and(eq(services.id, serviceId), eq(services.projectId, project.id)))
+ .where(
+ and(
+ eq(services.id, serviceId),
+ eq(services.projectId, project.id),
+ isNull(services.deletedAt),
+ isNull(services.previewOfServiceId),
+ ),
+ )
.then((r) => r[0]);
if (!service) return null;
diff --git a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/previews/page.tsx b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/previews/page.tsx
new file mode 100644
index 00000000..4c75d128
--- /dev/null
+++ b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/previews/page.tsx
@@ -0,0 +1,58 @@
+import { eq } from "drizzle-orm";
+import { notFound } from "next/navigation";
+import { PreviewDeploymentsPage } from "@/components/service/preview-deployments-page";
+import { db } from "@/db";
+import { getService, getSetting } from "@/db/queries";
+import { githubRepos } from "@/db/schema";
+import { getGitHubPullRequest } from "@/lib/github";
+import { listPreviewDeployments } from "@/lib/preview-deployments";
+import { SETTING_KEYS } from "@/lib/settings-keys";
+
+export default async function PreviewsPage({
+ params,
+}: {
+ params: Promise<{ serviceId: string }>;
+}) {
+ const { serviceId } = await params;
+ const [service, repo, automaticDomain, previews] = await Promise.all([
+ getService(serviceId),
+ db
+ .select()
+ .from(githubRepos)
+ .where(eq(githubRepos.serviceId, serviceId))
+ .then((rows) => rows[0]),
+ getSetting(SETTING_KEYS.AUTO_SUBDOMAIN_DOMAIN),
+ listPreviewDeployments(serviceId),
+ ]);
+ if (!service || service.sourceType !== "github" || !repo) notFound();
+
+ const withPullRequests = await Promise.all(
+ previews.map(async (preview) => {
+ try {
+ const pullRequest = await getGitHubPullRequest(
+ repo.installationId,
+ repo.repoFullName,
+ preview.pullRequestNumber,
+ );
+ return {
+ ...preview,
+ title: pullRequest.title,
+ author: pullRequest.user.login,
+ };
+ } catch {
+ return { ...preview, title: null, author: null };
+ }
+ }),
+ );
+
+ return (
+
+ );
+}
diff --git a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/rollouts/[rolloutId]/page.tsx b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/rollouts/[rolloutId]/page.tsx
index eeb0efa8..1cf946bc 100644
--- a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/rollouts/[rolloutId]/page.tsx
+++ b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/rollouts/[rolloutId]/page.tsx
@@ -1,4 +1,4 @@
-import { and, eq } from "drizzle-orm";
+import { and, eq, isNull } from "drizzle-orm";
import { notFound } from "next/navigation";
import { SetBreadcrumbs } from "@/components/core/breadcrumb-data";
import { RolloutDetails } from "@/components/service/details/rollout-details";
@@ -21,7 +21,14 @@ async function getRollout(
const service = await db
.select()
.from(services)
- .where(and(eq(services.id, serviceId), eq(services.projectId, project.id)))
+ .where(
+ and(
+ eq(services.id, serviceId),
+ eq(services.projectId, project.id),
+ isNull(services.deletedAt),
+ isNull(services.previewOfServiceId),
+ ),
+ )
.then((r) => r[0]);
if (!service) return null;
diff --git a/web/app/api/builds/[buildId]/logs/route.ts b/web/app/api/builds/[buildId]/logs/route.ts
index eb68ca32..a620462e 100644
--- a/web/app/api/builds/[buildId]/logs/route.ts
+++ b/web/app/api/builds/[buildId]/logs/route.ts
@@ -1,4 +1,8 @@
+import { and, eq, isNull } from "drizzle-orm";
import { type NextRequest, NextResponse } from "next/server";
+import { db } from "@/db";
+import { builds, services } from "@/db/schema";
+import { requireRequestSession } from "@/lib/api-auth";
import { invalidLogQueryResponse, normalizeLogSearch } from "@/lib/log-query";
import { isLoggingEnabled, queryLogsByBuild } from "@/lib/victoria-logs";
@@ -6,7 +10,27 @@ export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ buildId: string }> },
) {
+ const sessionResult = await requireRequestSession(request);
+ if (!sessionResult.ok) return sessionResult.response;
+
const { buildId } = await params;
+ const build = await db
+ .select({ id: builds.id })
+ .from(builds)
+ .innerJoin(
+ services,
+ and(
+ eq(builds.serviceId, services.id),
+ isNull(services.deletedAt),
+ isNull(services.previewOfServiceId),
+ ),
+ )
+ .where(eq(builds.id, buildId))
+ .then((rows) => rows[0]);
+ if (!build) {
+ return NextResponse.json({ error: "Build not found" }, { status: 404 });
+ }
+
let search: string | undefined;
try {
search = normalizeLogSearch(request.nextUrl.searchParams.get("q"));
diff --git a/web/app/api/builds/[buildId]/route.ts b/web/app/api/builds/[buildId]/route.ts
index 16237543..78e1e063 100644
--- a/web/app/api/builds/[buildId]/route.ts
+++ b/web/app/api/builds/[buildId]/route.ts
@@ -1,7 +1,7 @@
-import { eq } from "drizzle-orm";
+import { and, eq, isNull } from "drizzle-orm";
import { type NextRequest, NextResponse } from "next/server";
import { db } from "@/db";
-import { builds, servers } from "@/db/schema";
+import { builds, servers, services } from "@/db/schema";
import { requireRequestSession } from "@/lib/api-auth";
export async function GET(
@@ -20,6 +20,14 @@ export async function GET(
})
.from(builds)
.leftJoin(servers, eq(builds.claimedBy, servers.id))
+ .innerJoin(
+ services,
+ and(
+ eq(builds.serviceId, services.id),
+ isNull(services.deletedAt),
+ isNull(services.previewOfServiceId),
+ ),
+ )
.where(eq(builds.id, buildId));
if (!buildData) {
diff --git a/web/app/api/inngest/route.ts b/web/app/api/inngest/route.ts
index 3f82d6a0..db04124a 100644
--- a/web/app/api/inngest/route.ts
+++ b/web/app/api/inngest/route.ts
@@ -16,6 +16,10 @@ import {
oldBackupsCleanup,
onDeploymentFailed,
onRestoreFailed,
+ previewCloseWorkflow,
+ previewReconciliation,
+ previewServiceReconcileWorkflow,
+ previewSyncWorkflow,
registryArtifactRetention,
restoreTriggerWorkflow,
restoreWorkflow,
@@ -52,6 +56,10 @@ export const { GET, POST, PUT } = serve({
backupWorkflow,
restoreWorkflow,
onRestoreFailed,
+ previewSyncWorkflow,
+ previewCloseWorkflow,
+ previewReconciliation,
+ previewServiceReconcileWorkflow,
buildWorkflow,
buildTriggerWorkflow,
restoreTriggerWorkflow,
diff --git a/web/app/api/navigation/route.ts b/web/app/api/navigation/route.ts
index 325eab69..b7e7fadc 100644
--- a/web/app/api/navigation/route.ts
+++ b/web/app/api/navigation/route.ts
@@ -34,6 +34,7 @@ export async function GET() {
eq(services.projectId, projects.id),
eq(services.environmentId, environments.id),
isNull(services.deletedAt),
+ isNull(services.previewOfServiceId),
),
)
.orderBy(projects.name, environments.name, services.name),
diff --git a/web/app/api/projects/[id]/services/route.ts b/web/app/api/projects/[id]/services/route.ts
index 9ad6b75f..2738f72f 100644
--- a/web/app/api/projects/[id]/services/route.ts
+++ b/web/app/api/projects/[id]/services/route.ts
@@ -80,6 +80,7 @@ export async function PATCH(
eq(services.projectId, projectId),
inArray(services.id, serviceIds),
isNull(services.deletedAt),
+ isNull(services.previewOfServiceId),
),
);
@@ -105,6 +106,7 @@ export async function PATCH(
eq(services.id, position.serviceId),
eq(services.projectId, projectId),
isNull(services.deletedAt),
+ isNull(services.previewOfServiceId),
),
)
.returning({
@@ -158,8 +160,13 @@ export async function GET(
eq(services.projectId, projectId),
eq(services.environmentId, environmentId),
isNull(services.deletedAt),
+ isNull(services.previewOfServiceId),
)
- : and(eq(services.projectId, projectId), isNull(services.deletedAt)),
+ : and(
+ eq(services.projectId, projectId),
+ isNull(services.deletedAt),
+ isNull(services.previewOfServiceId),
+ ),
)
.orderBy(services.createdAt);
const cronRows =
diff --git a/web/app/api/rollouts/[rolloutId]/logs/route.ts b/web/app/api/rollouts/[rolloutId]/logs/route.ts
index 664c12bf..0634e3d0 100644
--- a/web/app/api/rollouts/[rolloutId]/logs/route.ts
+++ b/web/app/api/rollouts/[rolloutId]/logs/route.ts
@@ -1,4 +1,8 @@
+import { and, eq, isNull } from "drizzle-orm";
import { type NextRequest, NextResponse } from "next/server";
+import { db } from "@/db";
+import { rollouts, services } from "@/db/schema";
+import { requireRequestSession } from "@/lib/api-auth";
import { invalidLogQueryResponse, normalizeLogSearch } from "@/lib/log-query";
import { isLoggingEnabled, queryLogsByRollout } from "@/lib/victoria-logs";
@@ -6,7 +10,27 @@ export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ rolloutId: string }> },
) {
+ const sessionResult = await requireRequestSession(request);
+ if (!sessionResult.ok) return sessionResult.response;
+
const { rolloutId } = await params;
+ const rollout = await db
+ .select({ id: rollouts.id })
+ .from(rollouts)
+ .innerJoin(
+ services,
+ and(
+ eq(rollouts.serviceId, services.id),
+ isNull(services.deletedAt),
+ isNull(services.previewOfServiceId),
+ ),
+ )
+ .where(eq(rollouts.id, rolloutId))
+ .then((rows) => rows[0]);
+ if (!rollout) {
+ return NextResponse.json({ error: "Rollout not found" }, { status: 404 });
+ }
+
let search: string | undefined;
try {
search = normalizeLogSearch(request.nextUrl.searchParams.get("q"));
diff --git a/web/app/api/services/[id]/backups/route.ts b/web/app/api/services/[id]/backups/route.ts
index 6d265aa0..5ee49a94 100644
--- a/web/app/api/services/[id]/backups/route.ts
+++ b/web/app/api/services/[id]/backups/route.ts
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { desc, eq } from "drizzle-orm";
import { db } from "@/db";
+import { getService } from "@/db/queries";
import { volumeBackups, servers } from "@/db/schema";
export async function GET(
@@ -9,6 +10,9 @@ export async function GET(
) {
try {
const { id: serviceId } = await params;
+ if (!(await getService(serviceId))) {
+ return NextResponse.json({ error: "Service not found" }, { status: 404 });
+ }
const backups = await db
.select({
diff --git a/web/app/api/services/[id]/builds/route.ts b/web/app/api/services/[id]/builds/route.ts
index 2e9c617e..b3988747 100644
--- a/web/app/api/services/[id]/builds/route.ts
+++ b/web/app/api/services/[id]/builds/route.ts
@@ -1,6 +1,7 @@
import { desc, eq, getTableColumns } from "drizzle-orm";
import { type NextRequest, NextResponse } from "next/server";
import { db } from "@/db";
+import { getService } from "@/db/queries";
import { builds, servers } from "@/db/schema";
import { requireRequestSession } from "@/lib/api-auth";
@@ -12,6 +13,9 @@ export async function GET(
if (!sessionResult.ok) return sessionResult.response;
const { id: serviceId } = await params;
+ if (!(await getService(serviceId))) {
+ return NextResponse.json({ error: "Service not found" }, { status: 404 });
+ }
const buildsList = await db
.select({
diff --git a/web/app/api/services/[id]/commands/route.ts b/web/app/api/services/[id]/commands/route.ts
index 96ea24f7..f992731e 100644
--- a/web/app/api/services/[id]/commands/route.ts
+++ b/web/app/api/services/[id]/commands/route.ts
@@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto";
import { and, desc, eq, inArray, isNull, lt, or, sql } from "drizzle-orm";
import { db } from "@/db";
+import { getService } from "@/db/queries";
import { deployments, servers, serviceCommands, services } from "@/db/schema";
import { requireRequestDeveloperRole } from "@/lib/api-auth";
import { observedReadyPhases } from "@/lib/deployment-status";
@@ -21,6 +22,9 @@ export async function GET(
if (!auth.ok) return auth.response;
const { id: serviceId } = await params;
+ if (!(await getService(serviceId))) {
+ return Response.json({ error: "Service not found" }, { status: 404 });
+ }
const cursorValue = new URL(request.url).searchParams.get("cursor");
const cursor = decodeTimestampCursor(cursorValue);
if (cursorValue && !cursor) {
@@ -115,7 +119,13 @@ export async function POST(
const service = await db
.select({ id: services.id })
.from(services)
- .where(and(eq(services.id, serviceId), isNull(services.deletedAt)))
+ .where(
+ and(
+ eq(services.id, serviceId),
+ isNull(services.deletedAt),
+ isNull(services.previewOfServiceId),
+ ),
+ )
.then((rows) => rows[0]);
if (!service) {
return Response.json({ error: "Service not found" }, { status: 404 });
diff --git a/web/app/api/services/[id]/github/commits/route.ts b/web/app/api/services/[id]/github/commits/route.ts
index efc39087..c0e078bf 100644
--- a/web/app/api/services/[id]/github/commits/route.ts
+++ b/web/app/api/services/[id]/github/commits/route.ts
@@ -16,7 +16,13 @@ export async function GET(
.select({ service: services, githubRepo: githubRepos })
.from(services)
.innerJoin(githubRepos, eq(githubRepos.serviceId, services.id))
- .where(and(eq(services.id, serviceId), isNull(services.deletedAt)));
+ .where(
+ and(
+ eq(services.id, serviceId),
+ isNull(services.deletedAt),
+ isNull(services.previewOfServiceId),
+ ),
+ );
if (!result) {
return Response.json(
diff --git a/web/app/api/services/[id]/revisions/route.ts b/web/app/api/services/[id]/revisions/route.ts
index c3f22b56..cdc2444f 100644
--- a/web/app/api/services/[id]/revisions/route.ts
+++ b/web/app/api/services/[id]/revisions/route.ts
@@ -25,7 +25,13 @@ export async function GET(
const service = await db
.select({ id: services.id })
.from(services)
- .where(and(eq(services.id, serviceId), isNull(services.deletedAt)))
+ .where(
+ and(
+ eq(services.id, serviceId),
+ isNull(services.deletedAt),
+ isNull(services.previewOfServiceId),
+ ),
+ )
.then((rows) => rows[0]);
if (!service) {
return Response.json({ message: "Service not found" }, { status: 404 });
diff --git a/web/app/api/services/[id]/rollouts/route.ts b/web/app/api/services/[id]/rollouts/route.ts
index e997e9ed..e396e83d 100644
--- a/web/app/api/services/[id]/rollouts/route.ts
+++ b/web/app/api/services/[id]/rollouts/route.ts
@@ -1,6 +1,7 @@
import { desc, eq, inArray } from "drizzle-orm";
import { type NextRequest, NextResponse } from "next/server";
import { db } from "@/db";
+import { getService } from "@/db/queries";
import { builds, rollouts } from "@/db/schema";
export async function GET(
@@ -8,6 +9,9 @@ export async function GET(
{ params }: { params: Promise<{ id: string }> },
) {
const { id: serviceId } = await params;
+ if (!(await getService(serviceId))) {
+ return NextResponse.json({ error: "Service not found" }, { status: 404 });
+ }
const rolloutsList = await db
.select()
diff --git a/web/app/api/services/[id]/secrets/[secretId]/reveal/route.ts b/web/app/api/services/[id]/secrets/[secretId]/reveal/route.ts
index a42d2863..121e862b 100644
--- a/web/app/api/services/[id]/secrets/[secretId]/reveal/route.ts
+++ b/web/app/api/services/[id]/secrets/[secretId]/reveal/route.ts
@@ -1,5 +1,6 @@
import { and, eq } from "drizzle-orm";
import { db } from "@/db";
+import { getService } from "@/db/queries";
import { secrets } from "@/db/schema";
import { requireRequestDeveloperRole } from "@/lib/api-auth";
import { decryptSecret } from "@/lib/crypto";
@@ -15,6 +16,9 @@ export async function POST(
}
const { id: serviceId, secretId } = await params;
+ if (!(await getService(serviceId))) {
+ return Response.json({ error: "Service not found" }, { status: 404 });
+ }
const secret = await db
.select({ encryptedValue: secrets.encryptedValue })
diff --git a/web/app/api/services/[id]/secrets/route.ts b/web/app/api/services/[id]/secrets/route.ts
index 3ac004da..df428516 100644
--- a/web/app/api/services/[id]/secrets/route.ts
+++ b/web/app/api/services/[id]/secrets/route.ts
@@ -1,6 +1,7 @@
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { db } from "@/db";
+import { getService } from "@/db/queries";
import { secrets } from "@/db/schema";
import { eq } from "drizzle-orm";
@@ -17,6 +18,9 @@ export async function GET(
}
const { id: serviceId } = await params;
+ if (!(await getService(serviceId))) {
+ return Response.json({ error: "Service not found" }, { status: 404 });
+ }
const secretsList = await db
.select({
diff --git a/web/app/api/v1/agent/builds/[id]/route.ts b/web/app/api/v1/agent/builds/[id]/route.ts
index b0ddfc97..ca20f2d2 100644
--- a/web/app/api/v1/agent/builds/[id]/route.ts
+++ b/web/app/api/v1/agent/builds/[id]/route.ts
@@ -145,6 +145,7 @@ export async function POST(
commitSha: specification.source.commitSha,
commitMessage: build.commitMessage,
branch: specification.source.branch,
+ gitRef: specification.source.gitRef,
serviceId: build.serviceId,
projectId: service.projectId,
},
diff --git a/web/app/api/v1/agent/builds/[id]/status/route.ts b/web/app/api/v1/agent/builds/[id]/status/route.ts
index 4fb68b4b..b91e3e82 100644
--- a/web/app/api/v1/agent/builds/[id]/status/route.ts
+++ b/web/app/api/v1/agent/builds/[id]/status/route.ts
@@ -14,6 +14,7 @@ import { updateGitHubDeploymentStatus } from "@/lib/github";
import { inngest } from "@/lib/inngest/client";
import { inngestEvents } from "@/lib/inngest/events";
import { notify } from "@/lib/notifications";
+import { updateCurrentPreviewGitHubStatus } from "@/lib/preview-deployments";
import { parseServiceRevisionSpec } from "@/lib/service-revision-changes";
import { enqueueWork } from "@/lib/work-queue";
@@ -118,6 +119,7 @@ export async function POST(
specification: serviceRevisions.specification,
projectSlug: projects.slug,
environmentName: environments.name,
+ previewOfServiceId: services.previewOfServiceId,
})
.from(serviceRevisions)
.innerJoin(services, eq(serviceRevisions.serviceId, services.id))
@@ -259,48 +261,67 @@ export async function POST(
) {
try {
const baseUrl = process.env.APP_URL || "https://cloud.techulus.com";
- const logUrl = `${baseUrl}/builds/${buildId}/logs`;
- const environmentUrl = `${baseUrl}/dashboard/projects/${revision.projectSlug}/${revision.environmentName}/services/${build.serviceId}`;
- const repository = revisionRepositoryFullName(
- specification.source.repository,
- );
- const installationId = specification.source.authentication.installationId;
- if (["cloning", "building", "pushing"].includes(update.status)) {
- await updateGitHubDeploymentStatus(
- installationId,
- repository,
- build.githubDeploymentId,
- "in_progress",
- {
- description: `Build ${update.status}...`,
- logUrl,
- environmentUrl,
- },
- );
- } else if (update.status === "completed") {
- await updateGitHubDeploymentStatus(
- installationId,
- repository,
- build.githubDeploymentId,
- "success",
- {
- description: "Build completed successfully",
- logUrl,
- environmentUrl,
- },
- );
+ const logUrl = revision.previewOfServiceId
+ ? `${baseUrl}/dashboard/projects/${revision.projectSlug}/${revision.environmentName}/services/${revision.previewOfServiceId}/previews`
+ : `${baseUrl}/builds/${buildId}/logs`;
+ if (revision.previewOfServiceId) {
+ await updateCurrentPreviewGitHubStatus({
+ serviceId: build.serviceId,
+ serviceRevisionId: build.serviceRevisionId,
+ expectedDeploymentId: build.githubDeploymentId,
+ state: update.status === "failed" ? "failure" : "in_progress",
+ description:
+ update.status === "completed"
+ ? "Preview image built; preparing deployment"
+ : update.status === "failed"
+ ? update.error || "Preview build failed"
+ : `Preview build ${update.status}...`,
+ logUrl,
+ });
} else {
- await updateGitHubDeploymentStatus(
- installationId,
- repository,
- build.githubDeploymentId,
- "failure",
- {
- description: update.error || "Build failed",
- logUrl,
- environmentUrl,
- },
+ const environmentUrl = `${baseUrl}/dashboard/projects/${revision.projectSlug}/${revision.environmentName}/services/${build.serviceId}`;
+ const repository = revisionRepositoryFullName(
+ specification.source.repository,
);
+ const installationId =
+ specification.source.authentication.installationId;
+ if (["cloning", "building", "pushing"].includes(update.status)) {
+ await updateGitHubDeploymentStatus(
+ installationId,
+ repository,
+ build.githubDeploymentId,
+ "in_progress",
+ {
+ description: `Build ${update.status}...`,
+ logUrl,
+ environmentUrl,
+ },
+ );
+ } else if (update.status === "completed") {
+ await updateGitHubDeploymentStatus(
+ installationId,
+ repository,
+ build.githubDeploymentId,
+ "success",
+ {
+ description: "Build completed successfully",
+ logUrl,
+ environmentUrl,
+ },
+ );
+ } else {
+ await updateGitHubDeploymentStatus(
+ installationId,
+ repository,
+ build.githubDeploymentId,
+ "failure",
+ {
+ description: update.error || "Build failed",
+ logUrl,
+ environmentUrl,
+ },
+ );
+ }
}
} catch (error) {
console.error(
@@ -371,14 +392,24 @@ export async function POST(
sql`select pg_advisory_xact_lock(hashtext(${build.serviceId}))`,
);
const activeService = await tx
- .select({ id: services.id })
+ .select({
+ id: services.id,
+ previewOfServiceId: services.previewOfServiceId,
+ previewCurrentRevisionId: services.previewCurrentRevisionId,
+ })
.from(services)
.where(
and(eq(services.id, build.serviceId), isNull(services.deletedAt)),
)
.limit(1)
.then((rows) => rows[0]);
- if (!activeService) return;
+ if (
+ !activeService ||
+ (activeService.previewOfServiceId &&
+ activeService.previewCurrentRevisionId !== build.serviceRevisionId)
+ ) {
+ return;
+ }
await enqueueWork(
auth.serverId,
"create_manifest",
diff --git a/web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/route.ts b/web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/route.ts
index 310cf593..c9e75b69 100644
--- a/web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/route.ts
+++ b/web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/route.ts
@@ -52,6 +52,7 @@ export async function GET(
eq(services.projectId, projectId),
eq(services.environmentId, environmentId),
isNull(services.deletedAt),
+ isNull(services.previewOfServiceId),
page.cursor
? or(
gt(services.name, page.cursor.name),
diff --git a/web/app/api/webhooks/github/route.ts b/web/app/api/webhooks/github/route.ts
index ca380357..3ed7de7d 100644
--- a/web/app/api/webhooks/github/route.ts
+++ b/web/app/api/webhooks/github/route.ts
@@ -1,4 +1,5 @@
-import { and, eq } from "drizzle-orm";
+import { createHash } from "node:crypto";
+import { and, eq, isNull } from "drizzle-orm";
import { type NextRequest, NextResponse } from "next/server";
import { db } from "@/db";
import {
@@ -14,6 +15,9 @@ import {
updateGitHubDeploymentStatus,
verifyWebhookSignature,
} from "@/lib/github";
+import { inngest } from "@/lib/inngest/client";
+import { inngestEvents } from "@/lib/inngest/events";
+import { deletePreviewsForGitHubInstallation } from "@/lib/preview-lifecycle";
import { triggerResolvedBuildInternal } from "@/lib/trigger-build";
type InstallationPayload = {
@@ -52,6 +56,18 @@ type PushPayload = {
sender: { id: number; login: string };
};
+type PullRequestPayload = {
+ action: string;
+ number: number;
+ pull_request: {
+ draft: boolean;
+ merged: boolean;
+ base: { ref: string; repo: { id: number; full_name: string } };
+ head: { repo: { id: number; full_name: string } | null };
+ };
+ repository: { id: number; full_name: string };
+};
+
type PushResult = {
serviceId: string;
status: "queued" | "skipped" | "failed";
@@ -84,6 +100,10 @@ async function handleInstallationEvent(payload: InstallationPayload) {
}
if (action === "deleted") {
+ await deletePreviewsForGitHubInstallation(
+ installation.id,
+ "GitHub installation deleted",
+ );
await db
.delete(githubInstallations)
.where(eq(githubInstallations.installationId, installation.id));
@@ -91,6 +111,13 @@ async function handleInstallationEvent(payload: InstallationPayload) {
return NextResponse.json({ ok: true, message: "Installation deleted" });
}
+ if (action === "suspend") {
+ await deletePreviewsForGitHubInstallation(
+ installation.id,
+ "GitHub installation suspended",
+ );
+ }
+
return NextResponse.json({ ok: true });
}
@@ -118,7 +145,12 @@ async function handlePushEvent(payload: PushPayload) {
.innerJoin(services, eq(githubRepos.serviceId, services.id))
.innerJoin(projects, eq(services.projectId, projects.id))
.innerJoin(environments, eq(services.environmentId, environments.id))
- .where(eq(githubRepos.repoId, repository.id));
+ .where(
+ and(
+ eq(githubRepos.repoId, repository.id),
+ isNull(services.previewOfServiceId),
+ ),
+ );
if (linkedServices.length === 0) {
return NextResponse.json({
@@ -255,6 +287,133 @@ async function handlePushEvent(payload: PushPayload) {
);
}
+const pullRequestSyncActions = new Set([
+ "opened",
+ "reopened",
+ "synchronize",
+ "ready_for_review",
+ "edited",
+]);
+const pullRequestCloseActions = new Set(["closed", "converted_to_draft"]);
+
+async function handlePullRequestEvent(
+ payload: PullRequestPayload,
+ deliveryId: string,
+) {
+ if (
+ !pullRequestSyncActions.has(payload.action) &&
+ !pullRequestCloseActions.has(payload.action)
+ ) {
+ return NextResponse.json({ ok: true, skipped: true });
+ }
+ if (
+ !Number.isSafeInteger(payload.number) ||
+ payload.number <= 0 ||
+ payload.repository.id !== payload.pull_request.base.repo.id
+ ) {
+ return NextResponse.json(
+ { error: "Invalid pull request payload" },
+ { status: 400 },
+ );
+ }
+
+ const linkedServices = await db
+ .select({ githubRepo: githubRepos, service: services })
+ .from(githubRepos)
+ .innerJoin(services, eq(githubRepos.serviceId, services.id))
+ .where(eq(githubRepos.repoId, payload.repository.id));
+ const sameRepository =
+ payload.pull_request.head.repo?.id === payload.pull_request.base.repo.id;
+ const shouldSync =
+ pullRequestSyncActions.has(payload.action) &&
+ !payload.pull_request.draft &&
+ sameRepository;
+ const events: Array<
+ | ReturnType
+ | ReturnType
+ > = [];
+ const syncedBaseServiceIds = new Set();
+
+ if (shouldSync) {
+ for (const { githubRepo, service } of linkedServices) {
+ if (
+ service.previewOfServiceId ||
+ service.deletedAt ||
+ service.sourceType !== "github" ||
+ service.stateful ||
+ !service.previewDeploymentsEnabled ||
+ (githubRepo.deployBranch ?? githubRepo.defaultBranch) !==
+ payload.pull_request.base.ref
+ ) {
+ continue;
+ }
+ syncedBaseServiceIds.add(service.id);
+ events.push(
+ inngestEvents.previewSyncRequested.create(
+ {
+ baseServiceId: service.id,
+ pullRequestNumber: payload.number,
+ },
+ {
+ id: `github-pr-sync:${deliveryId}:${service.id}:${payload.number}`,
+ },
+ ),
+ );
+ }
+ }
+
+ for (const { service: clone } of linkedServices) {
+ if (
+ !clone.previewOfServiceId ||
+ clone.previewPullRequestNumber !== payload.number ||
+ clone.deletedAt ||
+ syncedBaseServiceIds.has(clone.previewOfServiceId)
+ ) {
+ continue;
+ }
+ const reason =
+ payload.action === "closed"
+ ? payload.pull_request.merged
+ ? "pull_request_merged"
+ : "pull_request_closed"
+ : payload.action === "converted_to_draft"
+ ? "converted_to_draft"
+ : !sameRepository
+ ? "fork_pull_request"
+ : "pull_request_ineligible";
+ events.push(
+ inngestEvents.previewCloseRequested.create(
+ {
+ baseServiceId: clone.previewOfServiceId,
+ pullRequestNumber: payload.number,
+ reason,
+ verifyWithGitHub: true,
+ },
+ {
+ id: `github-pr-close:${deliveryId}:${clone.previewOfServiceId}:${payload.number}`,
+ },
+ ),
+ );
+ }
+
+ if (events.length > 0) {
+ try {
+ await inngest.send(events);
+ } catch (error) {
+ console.error("Failed to dispatch preview deployment events:", error);
+ return NextResponse.json(
+ { ok: false, error: "Failed to queue preview deployment work" },
+ { status: 500 },
+ );
+ }
+ }
+ return NextResponse.json({
+ ok: true,
+ queued: events.length,
+ skippedFork: !sameRepository,
+ });
+}
+
export async function POST(request: NextRequest) {
const body = await request.text();
const signature = request.headers.get("x-hub-signature-256");
@@ -274,6 +433,12 @@ export async function POST(request: NextRequest) {
return handleInstallationEvent(payload as InstallationPayload);
case "push":
return handlePushEvent(payload as PushPayload);
+ case "pull_request": {
+ const deliveryId =
+ request.headers.get("x-github-delivery") ??
+ createHash("sha256").update(body).digest("hex");
+ return handlePullRequestEvent(payload as PullRequestPayload, deliveryId);
+ }
case "ping":
return NextResponse.json({ ok: true, message: "pong" });
default:
diff --git a/web/components/service/preview-deployments-page.tsx b/web/components/service/preview-deployments-page.tsx
new file mode 100644
index 00000000..17aec6c8
--- /dev/null
+++ b/web/components/service/preview-deployments-page.tsx
@@ -0,0 +1,229 @@
+"use client";
+
+import { ExternalLinkIcon, RefreshCwIcon, Trash2Icon } from "lucide-react";
+import { useRouter } from "next/navigation";
+import { useEffect, useState, useTransition } from "react";
+import { toast } from "sonner";
+import {
+ redeployPreview,
+ removePreview,
+ setPreviewDeploymentsEnabled,
+} from "@/actions/previews";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Switch } from "@/components/ui/switch";
+
+type Preview = {
+ serviceId: string;
+ pullRequestNumber: number;
+ status: string;
+ commitSha: string | null;
+ url: string | null;
+ error: string | null;
+ updatedAt: string;
+ expiresAt: string | null;
+ title: string | null;
+ author: string | null;
+};
+
+export function PreviewDeploymentsPage({
+ serviceId,
+ enabled,
+ stateful,
+ automaticDomain,
+ repository,
+ previews,
+}: {
+ serviceId: string;
+ enabled: boolean;
+ stateful: boolean;
+ automaticDomain: string | null;
+ repository: string;
+ previews: Preview[];
+}) {
+ const router = useRouter();
+ const [isPending, startTransition] = useTransition();
+ const [isEnabled, setIsEnabled] = useState(enabled);
+
+ useEffect(() => {
+ if (
+ !previews.some((preview) => !["ready", "failed"].includes(preview.status))
+ ) {
+ return;
+ }
+ const interval = setInterval(() => router.refresh(), 10_000);
+ return () => clearInterval(interval);
+ }, [previews, router]);
+
+ const updateEnabled = (nextEnabled: boolean) => {
+ const previous = isEnabled;
+ setIsEnabled(nextEnabled);
+ startTransition(async () => {
+ try {
+ await setPreviewDeploymentsEnabled(serviceId, nextEnabled);
+ toast.success(
+ nextEnabled
+ ? "Preview deployments enabled"
+ : "Preview teardown queued",
+ );
+ router.refresh();
+ } catch (error) {
+ setIsEnabled(previous);
+ toast.error(error instanceof Error ? error.message : "Update failed");
+ }
+ });
+ };
+
+ const run = (action: () => Promise, success: string) =>
+ startTransition(async () => {
+ try {
+ await action();
+ toast.success(success);
+ router.refresh();
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "Action failed");
+ }
+ });
+
+ return (
+
+
+
+
+
Pull request previews
+
+ Build every same-repository pull request that is ready for review
+ at a stable generated URL. Each preview is one stateless replica
+ and inherits this service's secrets. Drafts and forks are
+ skipped.
+
+
+
+
+ {stateful ? (
+
+ Preview deployments are unavailable because volumes cannot be
+ replicated. Use a stateless service to enable previews.
+
+ ) : !automaticDomain ? (
+
+ Configure Automatic Subdomain Domain and wildcard DNS before
+ enabling previews.
+
+ ) : (
+
+ Preview URLs are generated beneath {automaticDomain}.
+ Closing or merging a pull request removes its preview.
+
+ )}
+
+
+
+
+
Active previews
+
+ {previews.length === 0 ? (
+
+ {isEnabled
+ ? "No eligible pull requests are open."
+ : "Enable previews to deploy pull requests."}
+
+ ) : (
+
+ {previews.map((preview) => (
+
+
+
+
+ {preview.author ? `by ${preview.author} · ` : ""}
+ {preview.commitSha?.slice(0, 7) ??
+ "waiting for merge ref"}{" "}
+ · updated {new Date(preview.updatedAt).toLocaleString()}
+
+ {preview.error ? (
+
{preview.error}
+ ) : null}
+
+
+ {preview.url ? (
+
+ }
+ >
+ Open
+
+ ) : null}
+
+ run(
+ () =>
+ redeployPreview(serviceId, preview.pullRequestNumber),
+ "Preview redeploy queued",
+ )
+ }
+ >
+ Redeploy
+
+
+ run(
+ () =>
+ removePreview(serviceId, preview.pullRequestNumber),
+ "Preview removal queued",
+ )
+ }
+ >
+ Remove
+
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/web/components/service/service-layout-client.tsx b/web/components/service/service-layout-client.tsx
index 563d82ed..ae21c59c 100644
--- a/web/components/service/service-layout-client.tsx
+++ b/web/components/service/service-layout-client.tsx
@@ -144,6 +144,7 @@ export function ServiceLayoutClient({
pathname.includes("/configuration") ||
pathname.includes("/changelog") ||
pathname.includes("/builds") ||
+ pathname.includes("/previews") ||
pathname.includes("/backups") ||
pathname.includes("/commands");
@@ -159,7 +160,10 @@ export function ServiceLayoutClient({
? [{ name: "Requests", href: `${basePath}/requests` }]
: []),
...(service?.sourceType === "github"
- ? [{ name: "Builds", href: `${basePath}/builds` }]
+ ? [
+ { name: "Builds", href: `${basePath}/builds` },
+ { name: "Previews", href: `${basePath}/previews` },
+ ]
: []),
...(service?.stateful
? [{ name: "Backups", href: `${basePath}/backups` }]
diff --git a/web/db/queries.ts b/web/db/queries.ts
index 410c79bc..8bfd49c2 100644
--- a/web/db/queries.ts
+++ b/web/db/queries.ts
@@ -40,7 +40,9 @@ export async function listProjects() {
db
.select({ projectId: services.projectId, total: count() })
.from(services)
- .where(isNull(services.deletedAt))
+ .where(
+ and(isNull(services.deletedAt), isNull(services.previewOfServiceId)),
+ )
.groupBy(services.projectId),
db
.select({
@@ -52,6 +54,7 @@ export async function listProjects() {
.where(
and(
isNull(services.deletedAt),
+ isNull(services.previewOfServiceId),
inArray(deployments.observedPhase, [...observedReadyPhases]),
),
)
@@ -94,6 +97,20 @@ export async function getProjectBySlug(slug: string) {
}
export async function getService(id: string) {
+ const results = await db
+ .select()
+ .from(services)
+ .where(
+ and(
+ eq(services.id, id),
+ isNull(services.deletedAt),
+ isNull(services.previewOfServiceId),
+ ),
+ );
+ return results[0] || null;
+}
+
+export async function getRuntimeService(id: string) {
const results = await db
.select()
.from(services)
@@ -114,8 +131,13 @@ export async function listDeletedServices(
eq(services.projectId, projectId),
eq(services.environmentId, environmentId),
isNotNull(services.deletedAt),
+ isNull(services.previewOfServiceId),
)
- : and(eq(services.projectId, projectId), isNotNull(services.deletedAt)),
+ : and(
+ eq(services.projectId, projectId),
+ isNotNull(services.deletedAt),
+ isNull(services.previewOfServiceId),
+ ),
)
.orderBy(services.deletedAt);
}
diff --git a/web/db/schema.ts b/web/db/schema.ts
index 5a80770d..53a74007 100644
--- a/web/db/schema.ts
+++ b/web/db/schema.ts
@@ -2,6 +2,7 @@ import { relations, sql } from "drizzle-orm";
import {
bigint,
boolean,
+ check,
foreignKey,
index,
integer,
@@ -586,6 +587,17 @@ export const services = pgTable(
),
migrationBackupId: text("migration_backup_id"),
migrationError: text("migration_error"),
+ previewDeploymentsEnabled: boolean("preview_deployments_enabled")
+ .notNull()
+ .default(false),
+ previewOfServiceId: text("preview_of_service_id"),
+ previewPullRequestNumber: integer("preview_pull_request_number"),
+ previewCurrentRevisionId: text("preview_current_revision_id"),
+ previewGithubDeploymentId: bigint("preview_github_deployment_id", {
+ mode: "number",
+ }),
+ previewError: text("preview_error"),
+ previewExpiresAt: timestamp("preview_expires_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
@@ -599,6 +611,42 @@ export const services = pgTable(
index("services_last_autoscale_attempt_idx").on(
table.lastAutoscaleAttemptAt,
),
+ foreignKey({
+ name: "services_preview_of_service_fk",
+ columns: [table.previewOfServiceId],
+ foreignColumns: [table.id],
+ }).onDelete("restrict"),
+ check(
+ "services_preview_identity_check",
+ sql`(${table.previewOfServiceId} is null) = (${table.previewPullRequestNumber} is null)`,
+ ),
+ check(
+ "services_preview_pull_request_number_check",
+ sql`${table.previewPullRequestNumber} is null or ${table.previewPullRequestNumber} > 0`,
+ ),
+ check(
+ "services_preview_policy_check",
+ sql`(
+ (${table.previewOfServiceId} is null and (${table.previewDeploymentsEnabled} = false or ${table.stateful} = false))
+ or
+ (${table.previewOfServiceId} is not null and ${table.previewDeploymentsEnabled} = false and ${table.stateful} = false)
+ )`,
+ ),
+ check(
+ "services_preview_metadata_check",
+ sql`${table.previewOfServiceId} is not null or (
+ ${table.previewCurrentRevisionId} is null
+ and ${table.previewGithubDeploymentId} is null
+ and ${table.previewError} is null
+ and ${table.previewExpiresAt} is null
+ )`,
+ ),
+ uniqueIndex("services_preview_base_pr_unique_idx")
+ .on(table.previewOfServiceId, table.previewPullRequestNumber)
+ .where(sql`${table.previewOfServiceId} is not null`),
+ index("services_preview_expires_at_idx")
+ .on(table.previewExpiresAt)
+ .where(sql`${table.previewOfServiceId} is not null`),
],
);
diff --git a/web/lib/backup-scheduler.ts b/web/lib/backup-scheduler.ts
index 1b92aa5f..3c75ce73 100644
--- a/web/lib/backup-scheduler.ts
+++ b/web/lib/backup-scheduler.ts
@@ -64,7 +64,13 @@ export async function runScheduledBackups() {
backupSchedule: services.backupSchedule,
})
.from(services)
- .where(and(eq(services.backupEnabled, true), isNull(services.deletedAt)));
+ .where(
+ and(
+ eq(services.backupEnabled, true),
+ isNull(services.deletedAt),
+ isNull(services.previewOfServiceId),
+ ),
+ );
for (const service of servicesWithBackup) {
if (!service.backupSchedule) {
diff --git a/web/lib/backups/trigger-backup.ts b/web/lib/backups/trigger-backup.ts
index 5b44b83f..e167bf2b 100644
--- a/web/lib/backups/trigger-backup.ts
+++ b/web/lib/backups/trigger-backup.ts
@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto";
-import { and, eq, inArray } from "drizzle-orm";
+import { and, eq, inArray, isNull } from "drizzle-orm";
import { db } from "@/db";
import { getBackupStorageConfig } from "@/db/queries";
import {
@@ -38,7 +38,7 @@ export async function triggerBackup({
const service = await db
.select()
.from(services)
- .where(eq(services.id, serviceId))
+ .where(and(eq(services.id, serviceId), isNull(services.previewOfServiceId)))
.then((r) => r[0]);
if (!service) {
diff --git a/web/lib/deploy-service.ts b/web/lib/deploy-service.ts
index 5384cad3..0caf3e58 100644
--- a/web/lib/deploy-service.ts
+++ b/web/lib/deploy-service.ts
@@ -1,7 +1,7 @@
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { db } from "@/db";
-import { getService } from "@/db/queries";
+import { getRuntimeService } from "@/db/queries";
import { serviceReplicas } from "@/db/schema";
import { startMigrationInternal } from "@/lib/migrations";
import { sendRolloutCreated } from "@/lib/rollout-enqueue";
@@ -37,7 +37,7 @@ export async function deployServiceInternal(
githubTrigger?: "manual" | "scheduled";
} = {},
) {
- const service = await getService(serviceId);
+ const service = await getRuntimeService(serviceId);
if (!service) {
throw new Error("Service not found");
}
diff --git a/web/lib/github.ts b/web/lib/github.ts
index 7ee0145f..2292d070 100644
--- a/web/lib/github.ts
+++ b/web/lib/github.ts
@@ -135,6 +135,24 @@ export type GitHubCommit = {
date: string;
};
+export type GitHubPullRequest = {
+ number: number;
+ state: "open" | "closed";
+ draft: boolean;
+ merged: boolean;
+ title: string;
+ updatedAt: string;
+ user: { id: number; login: string };
+ base: {
+ ref: string;
+ repository: { id: number; fullName: string };
+ };
+ head: {
+ sha: string;
+ repository: { id: number; fullName: string } | null;
+ };
+};
+
export function isFullCommitSha(value: string): boolean {
return /^[0-9a-f]{40}$/i.test(value);
}
@@ -235,6 +253,146 @@ export async function resolveGitHubCommit(
return mapGitHubCommit(commit);
}
+type GitHubPullRequestResponse = {
+ number: number;
+ state: string;
+ draft?: boolean | null;
+ merged?: boolean | null;
+ title: string;
+ updated_at: string;
+ user: { id: number; login: string } | null;
+ base: { ref: string; repo: { id: number; full_name: string } };
+ head: { sha: string; repo: { id: number; full_name: string } | null };
+};
+
+function mapGitHubPullRequest(
+ pullRequest: GitHubPullRequestResponse,
+): GitHubPullRequest {
+ if (
+ !pullRequest.user ||
+ (pullRequest.state !== "open" && pullRequest.state !== "closed")
+ ) {
+ throw new Error("GitHub returned an invalid pull request");
+ }
+ return {
+ number: pullRequest.number,
+ state: pullRequest.state,
+ draft: pullRequest.draft === true,
+ merged: pullRequest.merged === true,
+ title: pullRequest.title,
+ updatedAt: pullRequest.updated_at,
+ user: { id: pullRequest.user.id, login: pullRequest.user.login },
+ base: {
+ ref: pullRequest.base.ref,
+ repository: {
+ id: pullRequest.base.repo.id,
+ fullName: pullRequest.base.repo.full_name,
+ },
+ },
+ head: {
+ sha: pullRequest.head.sha,
+ repository: pullRequest.head.repo
+ ? {
+ id: pullRequest.head.repo.id,
+ fullName: pullRequest.head.repo.full_name,
+ }
+ : null,
+ },
+ };
+}
+
+async function githubPullRequestRequest(
+ installationId: number,
+ repoFullName: string,
+ suffix: string,
+): Promise {
+ validateRepoFullName(repoFullName);
+ if (!Number.isSafeInteger(installationId) || installationId <= 0) {
+ throw new Error("Invalid GitHub installation ID");
+ }
+ const token = await getInstallationToken(installationId);
+ const response = await fetch(
+ `https://api.github.com/repos/${repoFullName}/pulls${suffix}`,
+ {
+ headers: {
+ Accept: "application/vnd.github+json",
+ Authorization: `Bearer ${token}`,
+ "X-GitHub-Api-Version": "2022-11-28",
+ },
+ },
+ );
+ if (!response.ok) {
+ const detail = await response.text();
+ throw new Error(
+ `GitHub pull request failed (${response.status}): ${detail || response.statusText}`,
+ );
+ }
+ return response.json() as Promise;
+}
+
+export async function getGitHubPullRequest(
+ installationId: number,
+ repoFullName: string,
+ pullRequestNumber: number,
+): Promise {
+ if (!Number.isSafeInteger(pullRequestNumber) || pullRequestNumber <= 0) {
+ throw new Error("Invalid pull request number");
+ }
+ const pullRequest = await githubPullRequestRequest(
+ installationId,
+ repoFullName,
+ `/${pullRequestNumber}`,
+ );
+ return mapGitHubPullRequest(pullRequest);
+}
+
+export async function listOpenGitHubPullRequests(
+ installationId: number,
+ repoFullName: string,
+ baseBranch: string,
+): Promise {
+ if (!baseBranch.trim()) throw new Error("GitHub branch is not configured");
+ const pullRequests: GitHubPullRequestResponse[] = [];
+ for (let page = 1; ; page++) {
+ const batch = await githubPullRequestRequest(
+ installationId,
+ repoFullName,
+ `?state=open&base=${encodeURIComponent(baseBranch)}&per_page=100&page=${page}`,
+ );
+ pullRequests.push(...batch);
+ if (batch.length < 100) break;
+ }
+ return pullRequests.map(mapGitHubPullRequest);
+}
+
+export async function resolveGitHubPullRequestMergeRef(
+ installationId: number,
+ repoFullName: string,
+ pullRequestNumber: number,
+): Promise<{ gitRef: string; sha: string }> {
+ if (!Number.isSafeInteger(pullRequestNumber) || pullRequestNumber <= 0) {
+ throw new Error("Invalid pull request number");
+ }
+ const gitRef = `refs/pull/${pullRequestNumber}/merge`;
+ try {
+ const commits = await githubCommitRequest(
+ installationId,
+ repoFullName,
+ `?sha=${encodeURIComponent(gitRef)}&per_page=1`,
+ );
+ const sha = commits[0]?.sha;
+ if (!sha || !isFullCommitSha(sha)) {
+ throw new Error("GitHub returned no merge commit");
+ }
+ return { gitRef, sha: sha.toLowerCase() };
+ } catch (error) {
+ throw new Error(
+ `Merge ref ${gitRef} is unavailable; resolve merge conflicts and retry`,
+ { cause: error },
+ );
+ }
+}
+
export async function listGitHubCommits(
installationId: number,
repoFullName: string,
@@ -254,7 +412,8 @@ type DeploymentState =
| "in_progress"
| "success"
| "failure"
- | "error";
+ | "error"
+ | "inactive";
export async function createGitHubDeployment(
installationId: number,
@@ -262,6 +421,11 @@ export async function createGitHubDeployment(
ref: string,
environment: string,
description: string,
+ options: {
+ transientEnvironment?: boolean;
+ productionEnvironment?: boolean;
+ payload?: Record;
+ } = {},
): Promise {
validateRepoFullName(repoFullName);
const token = await getInstallationToken(installationId);
@@ -280,8 +444,11 @@ export async function createGitHubDeployment(
ref,
environment,
description,
+ payload: options.payload ?? {},
auto_merge: false,
required_contexts: [],
+ transient_environment: options.transientEnvironment,
+ production_environment: options.productionEnvironment,
}),
},
);
diff --git a/web/lib/inngest/events/build.ts b/web/lib/inngest/events/build.ts
index 1f8214fa..e159191f 100644
--- a/web/lib/inngest/events/build.ts
+++ b/web/lib/inngest/events/build.ts
@@ -6,10 +6,11 @@ export type BuildEvents = {
serviceId: string;
serviceRevisionId: string;
buildRequestId: string;
- trigger: "manual" | "scheduled" | "push";
+ trigger: "manual" | "scheduled" | "push" | "preview";
commitSha: string;
commitMessage: string;
branch: string;
+ gitRef: string;
author?: string;
actor?: ServiceRevisionActor | null;
githubDeploymentId?: number;
diff --git a/web/lib/inngest/events/index.ts b/web/lib/inngest/events/index.ts
index 4e284e71..48091f06 100644
--- a/web/lib/inngest/events/index.ts
+++ b/web/lib/inngest/events/index.ts
@@ -4,6 +4,7 @@ export type { BackupEvents } from "./backup";
export type { BuildEvents } from "./build";
export type { MigrationEvents } from "./migration";
export type { NotificationEvent, NotificationEvents } from "./notification";
+export type { PreviewEvents } from "./preview";
export type { ResourceEvents } from "./resource";
export type { RestoreEvents } from "./restore";
export type { RolloutEvents } from "./rollout";
@@ -14,6 +15,7 @@ import type { BackupEvents } from "./backup";
import type { BuildEvents } from "./build";
import type { MigrationEvents } from "./migration";
import type { NotificationEvents } from "./notification";
+import type { PreviewEvents } from "./preview";
import type { ResourceEvents } from "./resource";
import type { RestoreEvents } from "./restore";
import type { RolloutEvents } from "./rollout";
@@ -28,6 +30,7 @@ export type Events = RolloutEvents &
ServiceDeletionEvents &
ResourceEvents &
NotificationEvents &
+ PreviewEvents &
ServiceCronEvents;
type EventName = keyof Events & string;
@@ -63,6 +66,11 @@ export const inngestEvents = {
buildCompleted: defineEvent("build/completed"),
manifestCompleted: defineEvent("manifest/completed"),
manifestFailed: defineEvent("manifest/failed"),
+ previewSyncRequested: defineEvent("preview/sync-requested"),
+ previewCloseRequested: defineEvent("preview/close-requested"),
+ previewServiceReconcileRequested: defineEvent(
+ "preview/service-reconcile-requested",
+ ),
notificationRequested: defineEvent("notification/requested"),
serviceCronExecute: defineEvent("service-cron/execute"),
};
diff --git a/web/lib/inngest/events/preview.ts b/web/lib/inngest/events/preview.ts
new file mode 100644
index 00000000..fe5d0bb2
--- /dev/null
+++ b/web/lib/inngest/events/preview.ts
@@ -0,0 +1,22 @@
+export type PreviewEvents = {
+ "preview/sync-requested": {
+ data: {
+ baseServiceId: string;
+ pullRequestNumber: number;
+ force?: boolean;
+ };
+ };
+ "preview/close-requested": {
+ data: {
+ baseServiceId: string;
+ pullRequestNumber: number;
+ reason: string;
+ verifyWithGitHub?: boolean;
+ };
+ };
+ "preview/service-reconcile-requested": {
+ data: {
+ baseServiceId: string;
+ };
+ };
+};
diff --git a/web/lib/inngest/functions/build-trigger-workflow.ts b/web/lib/inngest/functions/build-trigger-workflow.ts
index acc3383f..3bd150a1 100644
--- a/web/lib/inngest/functions/build-trigger-workflow.ts
+++ b/web/lib/inngest/functions/build-trigger-workflow.ts
@@ -33,6 +33,7 @@ export const buildTriggerWorkflow = inngest.createFunction(
commitSha,
commitMessage,
branch,
+ gitRef,
author,
githubDeploymentId,
actor = null,
@@ -57,7 +58,8 @@ export const buildTriggerWorkflow = inngest.createFunction(
if (
parsed.source.type !== "github" ||
parsed.source.commitSha !== exactCommitSha ||
- parsed.source.branch !== branch
+ parsed.source.branch !== branch ||
+ parsed.source.gitRef !== gitRef
) {
throw new Error("Build trigger does not match its service revision");
}
diff --git a/web/lib/inngest/functions/build-workflow.ts b/web/lib/inngest/functions/build-workflow.ts
index 723b9762..b74145ea 100644
--- a/web/lib/inngest/functions/build-workflow.ts
+++ b/web/lib/inngest/functions/build-workflow.ts
@@ -2,6 +2,7 @@ import { and, eq, inArray } from "drizzle-orm";
import { db } from "@/db";
import { builds, workQueue } from "@/db/schema";
import { deployServiceRevisionInternal } from "@/lib/deploy-service";
+import { updateCurrentPreviewGitHubStatus } from "@/lib/preview-deployments";
import { inngest } from "../client";
import { inngestEvents } from "../events";
@@ -167,6 +168,23 @@ function validateCompletedGroup(
}
}
+async function markPreviewBuildFailed(
+ serviceId: string,
+ serviceRevisionId: string,
+ description: string,
+) {
+ try {
+ await updateCurrentPreviewGitHubStatus({
+ serviceId,
+ serviceRevisionId,
+ state: "failure",
+ description,
+ });
+ } catch (error) {
+ console.error("[build-workflow] failed to update preview status:", error);
+ }
+}
+
export const buildWorkflow = inngest.createFunction(
{
id: "build-workflow",
@@ -175,6 +193,19 @@ export const buildWorkflow = inngest.createFunction(
cancelOn: [
{ event: inngestEvents.buildCancelled, match: "data.buildGroupId" },
],
+ onFailure: async ({ event }) => {
+ const data = event.data.event.data as {
+ serviceId?: string;
+ serviceRevisionId?: string;
+ };
+ if (data.serviceId && data.serviceRevisionId) {
+ await markPreviewBuildFailed(
+ data.serviceId,
+ data.serviceRevisionId,
+ "Preview build workflow failed",
+ );
+ }
+ },
},
async ({ event, step }) => {
const { serviceId, serviceRevisionId, buildGroupId } = event.data;
@@ -183,9 +214,23 @@ export const buildWorkflow = inngest.createFunction(
let groupBuilds = await step.run("get-group-builds", readGroup);
if (groupBuilds.length === 0) {
+ await step.run("report-missing-build-group", () =>
+ markPreviewBuildFailed(
+ serviceId,
+ serviceRevisionId,
+ "Preview build group is missing",
+ ),
+ );
return { status: "failed", reason: "build_group_missing", buildGroupId };
}
if (groupFailure(groupBuilds)) {
+ await step.run("report-initial-build-failure", () =>
+ markPreviewBuildFailed(
+ serviceId,
+ serviceRevisionId,
+ "Preview build failed",
+ ),
+ );
return { status: "failed", reason: "build_failed", buildGroupId };
}
@@ -206,9 +251,23 @@ export const buildWorkflow = inngest.createFunction(
}
if (groupBuilds.length === 0) {
+ await step.run("report-missing-build-group-after-wait", () =>
+ markPreviewBuildFailed(
+ serviceId,
+ serviceRevisionId,
+ "Preview build group is missing",
+ ),
+ );
return { status: "failed", reason: "build_group_missing", buildGroupId };
}
if (groupFailure(groupBuilds)) {
+ await step.run("report-build-failure", () =>
+ markPreviewBuildFailed(
+ serviceId,
+ serviceRevisionId,
+ "Preview build failed",
+ ),
+ );
return { status: "failed", reason: "build_failed", buildGroupId };
}
if (groupBuilds.some((build) => build.status !== "completed")) {
@@ -232,6 +291,13 @@ export const buildWorkflow = inngest.createFunction(
});
groupBuilds = await step.run("refresh-group-after-timeout", readGroup);
if (groupBuilds.some((build) => build.status !== "completed")) {
+ await step.run("report-build-timeout", () =>
+ markPreviewBuildFailed(
+ serviceId,
+ serviceRevisionId,
+ "Preview build timed out",
+ ),
+ );
return { status: "failed", reason: "timeout", buildGroupId };
}
}
@@ -251,9 +317,23 @@ export const buildWorkflow = inngest.createFunction(
);
}
if (!manifest) {
+ await step.run("report-manifest-timeout", () =>
+ markPreviewBuildFailed(
+ serviceId,
+ serviceRevisionId,
+ "Preview image manifest timed out",
+ ),
+ );
return { status: "completed_no_manifest", buildGroupId };
}
if (manifest.status === "failed") {
+ await step.run("report-manifest-failure", () =>
+ markPreviewBuildFailed(
+ serviceId,
+ serviceRevisionId,
+ "Preview image manifest failed",
+ ),
+ );
return { status: "failed", reason: "manifest_failed", buildGroupId };
}
diff --git a/web/lib/inngest/functions/crons.ts b/web/lib/inngest/functions/crons.ts
index 1489b637..8abd540f 100644
--- a/web/lib/inngest/functions/crons.ts
+++ b/web/lib/inngest/functions/crons.ts
@@ -1,7 +1,7 @@
-import { and, asc, eq, isNull, lte } from "drizzle-orm";
+import { and, asc, eq, isNotNull, isNull, lte } from "drizzle-orm";
import { cron } from "inngest";
import { db } from "@/db";
-import { serviceCrons, services } from "@/db/schema";
+import { githubRepos, serviceCrons, services } from "@/db/schema";
import {
cleanupExpiredChallenges,
renewExpiringCertificates,
@@ -22,6 +22,7 @@ import {
runAutoscalingController,
} from "@/lib/scheduler";
import { inngest } from "../client";
+import { inngestEvents } from "../events";
import {
cronEventId,
latestDueOccurrence,
@@ -215,6 +216,81 @@ export const serviceCommandRetention = inngest.createFunction(
step.run("cleanup-service-commands", cleanupOldServiceCommands),
);
+export const previewReconciliation = inngest.createFunction(
+ {
+ id: "cron-preview-reconciliation",
+ triggers: [cron("0 1 * * *")],
+ singleton: { mode: "skip" },
+ },
+ async ({ step }) => {
+ const enabledServices = await step.run(
+ "reconcile-enabled-services",
+ async () => {
+ const rows = await db
+ .select({ id: services.id })
+ .from(services)
+ .innerJoin(githubRepos, eq(githubRepos.serviceId, services.id))
+ .where(
+ and(
+ eq(services.previewDeploymentsEnabled, true),
+ eq(services.sourceType, "github"),
+ isNull(services.previewOfServiceId),
+ isNull(services.deletedAt),
+ ),
+ );
+ const day = new Date().toISOString().slice(0, 10);
+ for (const service of rows) {
+ await inngest.send(
+ inngestEvents.previewServiceReconcileRequested.create(
+ { baseServiceId: service.id },
+ { id: `preview-service-daily:${service.id}:${day}` },
+ ),
+ );
+ }
+ return rows.length;
+ },
+ );
+ const expiredPreviews = await step.run(
+ "reconcile-expired-previews",
+ async () => {
+ const expired = await db
+ .select({
+ baseServiceId: services.previewOfServiceId,
+ pullRequestNumber: services.previewPullRequestNumber,
+ expiresAt: services.previewExpiresAt,
+ })
+ .from(services)
+ .where(
+ and(
+ isNotNull(services.previewOfServiceId),
+ isNotNull(services.previewPullRequestNumber),
+ isNotNull(services.previewExpiresAt),
+ isNull(services.deletedAt),
+ lte(services.previewExpiresAt, new Date()),
+ ),
+ )
+ .limit(100);
+ for (const preview of expired) {
+ if (!preview.baseServiceId || !preview.pullRequestNumber) continue;
+ await inngest.send(
+ inngestEvents.previewSyncRequested.create(
+ {
+ baseServiceId: preview.baseServiceId,
+ pullRequestNumber: preview.pullRequestNumber,
+ },
+ {
+ id: `preview-expiry:${preview.baseServiceId}:${preview.pullRequestNumber}:${preview.expiresAt?.toISOString()}`,
+ },
+ ),
+ );
+ }
+ return expired.length;
+ },
+ );
+ return { enabledServices, expiredPreviews };
+ },
+);
+
export const serviceCronDispatcher = inngest.createFunction(
{
id: "cron-service-cron-dispatcher",
@@ -232,6 +308,7 @@ export const serviceCronDispatcher = inngest.createFunction(
and(
eq(serviceCrons.serviceId, services.id),
isNull(services.deletedAt),
+ isNull(services.previewOfServiceId),
),
)
.where(lte(serviceCrons.nextScheduledFor, now))
diff --git a/web/lib/inngest/functions/index.ts b/web/lib/inngest/functions/index.ts
index 88a7b203..37152db9 100644
--- a/web/lib/inngest/functions/index.ts
+++ b/web/lib/inngest/functions/index.ts
@@ -8,6 +8,7 @@ export {
challengeCleanup,
controlPlaneUpdateCheck,
notificationRetention,
+ previewReconciliation,
serviceCommandRetention,
serviceCronDispatcher,
oldBackupsCleanup,
@@ -21,6 +22,11 @@ export { serviceCronWorkflow } from "./service-cron-workflow";
export { migrationWorkflow } from "./migration-workflow";
export { notificationDelivery } from "./notification-delivery";
export { onDeploymentFailed } from "./on-deployment-failed";
+export {
+ previewCloseWorkflow,
+ previewServiceReconcileWorkflow,
+ previewSyncWorkflow,
+} from "./preview-workflow";
export { restoreTriggerWorkflow } from "./restore-trigger-workflow";
export { onRestoreFailed, restoreWorkflow } from "./restore-workflow";
export { rolloutWorkflow } from "./rollout-workflow";
diff --git a/web/lib/inngest/functions/preview-workflow.ts b/web/lib/inngest/functions/preview-workflow.ts
new file mode 100644
index 00000000..c5eaecfc
--- /dev/null
+++ b/web/lib/inngest/functions/preview-workflow.ts
@@ -0,0 +1,640 @@
+import { and, eq, isNull, sql } from "drizzle-orm";
+import { db } from "@/db";
+import { githubRepos, serviceRevisions, services } from "@/db/schema";
+import {
+ createGitHubDeployment,
+ getGitHubPullRequest,
+ listOpenGitHubPullRequests,
+ resolveGitHubPullRequestMergeRef,
+ updateGitHubDeploymentStatus,
+} from "@/lib/github";
+import {
+ createOrRefreshPreviewClone,
+ PREVIEW_RECONCILIATION_TTL_MS,
+ updateCurrentPreviewGitHubStatus,
+} from "@/lib/preview-deployments";
+import {
+ cancelPreviewRevisionWork,
+ deactivatePreviewRuntime,
+ deletePreviewService,
+} from "@/lib/preview-lifecycle";
+import { parseServiceRevisionSpec } from "@/lib/service-revision-changes";
+import { triggerResolvedBuildInternal } from "@/lib/trigger-build";
+import { inngest } from "../client";
+import { inngestEvents } from "../events";
+
+async function loadBaseContext(baseServiceId: string) {
+ return db
+ .select({ service: services, githubRepo: githubRepos })
+ .from(services)
+ .innerJoin(githubRepos, eq(githubRepos.serviceId, services.id))
+ .where(
+ and(
+ eq(services.id, baseServiceId),
+ isNull(services.previewOfServiceId),
+ isNull(services.deletedAt),
+ ),
+ )
+ .then((rows) => rows[0]);
+}
+
+async function closePreview(
+ baseServiceId: string,
+ pullRequestNumber: number,
+ reason: string,
+) {
+ const deleted = await deletePreviewService(baseServiceId, pullRequestNumber);
+ if (deleted?.service.previewGithubDeploymentId) {
+ try {
+ await updateGitHubDeploymentStatus(
+ deleted.githubRepo.installationId,
+ deleted.githubRepo.repoFullName,
+ deleted.service.previewGithubDeploymentId,
+ "inactive",
+ { description: `Preview removed: ${reason}`.substring(0, 140) },
+ );
+ } catch (error) {
+ console.error(
+ `[preview:close] failed to mark GitHub deployment ${deleted.service.previewGithubDeploymentId} inactive:`,
+ error,
+ );
+ }
+ }
+ return deleted
+ ? { status: "deleted" as const, serviceId: deleted.service.id }
+ : { status: "not_found" as const };
+}
+
+function isEligiblePullRequest(
+ context: {
+ service: {
+ previewDeploymentsEnabled: boolean;
+ stateful: boolean;
+ sourceType: "image" | "github";
+ };
+ githubRepo: {
+ repoId: number;
+ deployBranch: string | null;
+ defaultBranch: string;
+ };
+ },
+ pullRequest: Awaited>,
+) {
+ return (
+ context.service.previewDeploymentsEnabled &&
+ !context.service.stateful &&
+ context.service.sourceType === "github" &&
+ pullRequest.state === "open" &&
+ !pullRequest.draft &&
+ pullRequest.base.repository.id === context.githubRepo.repoId &&
+ pullRequest.head.repository?.id === context.githubRepo.repoId &&
+ pullRequest.base.ref ===
+ (context.githubRepo.deployBranch ?? context.githubRepo.defaultBranch)
+ );
+}
+
+async function loadPreviewContext(
+ baseServiceId: string,
+ pullRequestNumber: number,
+) {
+ return db
+ .select({ service: services, githubRepo: githubRepos })
+ .from(services)
+ .innerJoin(githubRepos, eq(githubRepos.serviceId, services.id))
+ .where(
+ and(
+ eq(services.previewOfServiceId, baseServiceId),
+ eq(services.previewPullRequestNumber, pullRequestNumber),
+ isNull(services.deletedAt),
+ ),
+ )
+ .then((rows) => rows[0]);
+}
+
+async function closePreviewFromEvent(input: {
+ baseServiceId: string;
+ pullRequestNumber: number;
+ reason: string;
+ verifyWithGitHub?: boolean;
+}) {
+ if (input.verifyWithGitHub) {
+ const [baseContext, previewContext] = await Promise.all([
+ loadBaseContext(input.baseServiceId),
+ loadPreviewContext(input.baseServiceId, input.pullRequestNumber),
+ ]);
+ if (!previewContext) return { status: "not_found" as const };
+ if (baseContext) {
+ const pullRequest = await getGitHubPullRequest(
+ previewContext.githubRepo.installationId,
+ previewContext.githubRepo.repoFullName,
+ input.pullRequestNumber,
+ );
+ if (isEligiblePullRequest(baseContext, pullRequest)) {
+ await enqueuePreviewSync(
+ input.baseServiceId,
+ input.pullRequestNumber,
+ `stale-close:${pullRequest.updatedAt}`,
+ );
+ return { status: "stale" as const };
+ }
+ }
+ }
+ return closePreview(
+ input.baseServiceId,
+ input.pullRequestNumber,
+ input.reason,
+ );
+}
+
+async function loadCurrentPreviewRevision(serviceId: string) {
+ const service = await db
+ .select({
+ previewCurrentRevisionId: services.previewCurrentRevisionId,
+ previewGithubDeploymentId: services.previewGithubDeploymentId,
+ previewError: services.previewError,
+ })
+ .from(services)
+ .where(eq(services.id, serviceId))
+ .then((rows) => rows[0]);
+ if (!service?.previewCurrentRevisionId) {
+ return service ? { ...service, commitSha: null } : null;
+ }
+ const revision = await db
+ .select({ specification: serviceRevisions.specification })
+ .from(serviceRevisions)
+ .where(eq(serviceRevisions.id, service.previewCurrentRevisionId))
+ .then((rows) => rows[0]);
+ if (!revision) return { ...service, commitSha: null };
+ const specification = parseServiceRevisionSpec(revision.specification);
+ return {
+ ...service,
+ commitSha:
+ specification.source.type === "github"
+ ? specification.source.commitSha
+ : null,
+ };
+}
+
+async function storePreviewPreBuildError(input: {
+ baseServiceId: string;
+ pullRequestNumber: number;
+ previewServiceId: string;
+ message: string;
+}) {
+ return db.transaction(async (tx) => {
+ await tx.execute(
+ sql`select pg_advisory_xact_lock(hashtext(${input.baseServiceId}))`,
+ );
+ await tx.execute(
+ sql`select pg_advisory_xact_lock(hashtext(${input.baseServiceId}), ${input.pullRequestNumber})`,
+ );
+ await tx.execute(
+ sql`select pg_advisory_xact_lock(hashtext(${input.previewServiceId}))`,
+ );
+ const current = await tx
+ .select({
+ previewCurrentRevisionId: services.previewCurrentRevisionId,
+ previewGithubDeploymentId: services.previewGithubDeploymentId,
+ })
+ .from(services)
+ .where(
+ and(
+ eq(services.id, input.previewServiceId),
+ eq(services.previewOfServiceId, input.baseServiceId),
+ eq(services.previewPullRequestNumber, input.pullRequestNumber),
+ isNull(services.deletedAt),
+ ),
+ )
+ .then((rows) => rows[0]);
+ if (!current) return null;
+ await tx
+ .update(services)
+ .set({
+ previewCurrentRevisionId: null,
+ previewGithubDeploymentId: null,
+ previewError: input.message,
+ previewExpiresAt: new Date(Date.now() + PREVIEW_RECONCILIATION_TTL_MS),
+ })
+ .where(eq(services.id, input.previewServiceId));
+ return current;
+ });
+}
+
+export const previewSyncWorkflow = inngest.createFunction(
+ {
+ id: "preview-sync-workflow",
+ triggers: [inngestEvents.previewSyncRequested],
+ concurrency: [
+ {
+ limit: 1,
+ key: 'event.data.baseServiceId + ":" + event.data.pullRequestNumber',
+ },
+ ],
+ },
+ async ({ event, step }) => {
+ const { baseServiceId, pullRequestNumber, force = false } = event.data;
+ const context = await step.run("load-base-service", () =>
+ loadBaseContext(baseServiceId),
+ );
+ if (!context) {
+ await step.run("close-orphaned-preview", () =>
+ closePreview(
+ baseServiceId,
+ pullRequestNumber,
+ "base service unavailable",
+ ),
+ );
+ return { status: "closed", reason: "base_service_unavailable" };
+ }
+
+ const pullRequest = await step.run("load-pull-request", () =>
+ getGitHubPullRequest(
+ context.githubRepo.installationId,
+ context.githubRepo.repoFullName,
+ pullRequestNumber,
+ ),
+ );
+ if (!isEligiblePullRequest(context, pullRequest)) {
+ await step.run("close-ineligible-preview", () =>
+ closePreview(
+ baseServiceId,
+ pullRequestNumber,
+ "pull request ineligible",
+ ),
+ );
+ return { status: "closed", reason: "pull_request_ineligible" };
+ }
+
+ const clone = await step.run("refresh-preview-service", () =>
+ createOrRefreshPreviewClone({
+ baseServiceId,
+ pullRequestNumber,
+ }),
+ );
+ const previous = await step.run("load-current-preview-revision", () =>
+ loadCurrentPreviewRevision(clone.serviceId),
+ );
+ let mergeRef: { gitRef: string; sha: string };
+ try {
+ mergeRef = await step.run("resolve-merge-ref", () =>
+ resolveGitHubPullRequestMergeRef(
+ context.githubRepo.installationId,
+ context.githubRepo.repoFullName,
+ pullRequestNumber,
+ ),
+ );
+ } catch (error) {
+ const message =
+ error instanceof Error
+ ? error.message
+ : "Pull request merge ref unavailable";
+ const superseded = await step.run("store-merge-ref-error", () =>
+ storePreviewPreBuildError({
+ baseServiceId,
+ pullRequestNumber,
+ previewServiceId: clone.serviceId,
+ message,
+ }),
+ );
+ if (superseded?.previewCurrentRevisionId) {
+ await step.run("deactivate-unmergeable-preview", () =>
+ deactivatePreviewRuntime(clone.serviceId),
+ );
+ }
+ if (superseded?.previewGithubDeploymentId) {
+ await step.run("inactivate-unmergeable-deployment", () =>
+ updateGitHubDeploymentStatus(
+ context.githubRepo.installationId,
+ context.githubRepo.repoFullName,
+ superseded.previewGithubDeploymentId!,
+ "inactive",
+ { description: "Preview merge ref is unavailable" },
+ ),
+ );
+ }
+ return { status: "failed", reason: "merge_ref_unavailable" };
+ }
+
+ if (
+ !force &&
+ previous?.commitSha === mergeRef.sha &&
+ !previous.previewError
+ ) {
+ await step.run("extend-preview-expiry", () =>
+ db
+ .update(services)
+ .set({
+ previewExpiresAt: new Date(
+ Date.now() + PREVIEW_RECONCILIATION_TTL_MS,
+ ),
+ })
+ .where(eq(services.id, clone.serviceId)),
+ );
+ return { status: "unchanged", serviceId: clone.serviceId };
+ }
+
+ const deploymentId = await step.run("create-github-deployment", () =>
+ createGitHubDeployment(
+ context.githubRepo.installationId,
+ context.githubRepo.repoFullName,
+ mergeRef.sha,
+ `preview/${context.service.name}/pr-${pullRequestNumber}`,
+ `Preview PR #${pullRequestNumber}: ${pullRequest.title}`.substring(
+ 0,
+ 140,
+ ),
+ {
+ transientEnvironment: true,
+ productionEnvironment: false,
+ payload: {
+ baseServiceId,
+ previewServiceId: clone.serviceId,
+ pullRequestNumber,
+ },
+ },
+ ),
+ );
+
+ let activatedRevisionId: string | null = null;
+ let queued: Awaited>;
+ try {
+ queued = await step.run("queue-preview-build", () =>
+ triggerResolvedBuildInternal(clone.serviceId, {
+ trigger: "preview",
+ commitSha: mergeRef.sha,
+ commitMessage: `Preview PR #${pullRequestNumber}: ${pullRequest.title}`,
+ author: pullRequest.user.login,
+ actor: {
+ type: "github",
+ githubUserId: pullRequest.user.id,
+ login: pullRequest.user.login,
+ },
+ expectedRepository: `https://github.com/${context.githubRepo.repoFullName}`,
+ expectedBranch:
+ context.githubRepo.deployBranch ?? context.githubRepo.defaultBranch,
+ gitRef: mergeRef.gitRef,
+ githubDeploymentId: deploymentId,
+ idempotencyKey: force
+ ? `preview:${clone.serviceId}:${mergeRef.sha}:${event.id}`
+ : `preview:${clone.serviceId}:${mergeRef.sha}`,
+ beforeDispatch: async (serviceRevisionId) => {
+ activatedRevisionId = serviceRevisionId;
+ const activated = await db.transaction(async (tx) => {
+ await tx.execute(
+ sql`select pg_advisory_xact_lock(hashtext(${baseServiceId}))`,
+ );
+ await tx.execute(
+ sql`select pg_advisory_xact_lock(hashtext(${baseServiceId}), ${pullRequestNumber})`,
+ );
+ await tx.execute(
+ sql`select pg_advisory_xact_lock(hashtext(${clone.serviceId}))`,
+ );
+ return tx
+ .update(services)
+ .set({
+ previewCurrentRevisionId: serviceRevisionId,
+ previewGithubDeploymentId: deploymentId,
+ previewError: null,
+ previewExpiresAt: new Date(
+ Date.now() + PREVIEW_RECONCILIATION_TTL_MS,
+ ),
+ })
+ .where(
+ and(
+ eq(services.id, clone.serviceId),
+ eq(services.previewOfServiceId, baseServiceId),
+ eq(services.previewPullRequestNumber, pullRequestNumber),
+ isNull(services.deletedAt),
+ ),
+ )
+ .returning({ id: services.id });
+ });
+ if (activated.length === 0) {
+ throw new Error("Preview was closed before its build was queued");
+ }
+ },
+ }),
+ );
+ } catch (error) {
+ const message =
+ error instanceof Error
+ ? error.message
+ : "Failed to queue preview build";
+ await step.run("mark-preview-queue-failed", async () => {
+ await db.transaction(async (tx) => {
+ await tx.execute(
+ sql`select pg_advisory_xact_lock(hashtext(${baseServiceId}))`,
+ );
+ await tx.execute(
+ sql`select pg_advisory_xact_lock(hashtext(${baseServiceId}), ${pullRequestNumber})`,
+ );
+ await tx.execute(
+ sql`select pg_advisory_xact_lock(hashtext(${clone.serviceId}))`,
+ );
+ const current = await tx
+ .select({
+ previewCurrentRevisionId: services.previewCurrentRevisionId,
+ previewGithubDeploymentId: services.previewGithubDeploymentId,
+ })
+ .from(services)
+ .where(
+ and(eq(services.id, clone.serviceId), isNull(services.deletedAt)),
+ )
+ .then((rows) => rows[0]);
+ if (current) {
+ try {
+ await updateGitHubDeploymentStatus(
+ context.githubRepo.installationId,
+ context.githubRepo.repoFullName,
+ deploymentId,
+ "failure",
+ { description: message.substring(0, 140) },
+ );
+ } catch (statusError) {
+ console.error(
+ "[preview:sync] failed to report build queue failure:",
+ statusError,
+ );
+ }
+ }
+ if (
+ activatedRevisionId &&
+ current?.previewCurrentRevisionId === activatedRevisionId &&
+ current.previewGithubDeploymentId === deploymentId
+ ) {
+ await tx
+ .update(services)
+ .set({
+ previewCurrentRevisionId:
+ previous?.previewCurrentRevisionId ?? null,
+ previewGithubDeploymentId:
+ previous?.previewGithubDeploymentId ?? null,
+ previewError: message,
+ })
+ .where(eq(services.id, clone.serviceId));
+ } else if (!activatedRevisionId && current) {
+ await tx
+ .update(services)
+ .set({ previewError: message })
+ .where(eq(services.id, clone.serviceId));
+ }
+ });
+ });
+ if (activatedRevisionId) {
+ await step.run("cancel-undispatched-preview", () =>
+ cancelPreviewRevisionWork(clone.serviceId, activatedRevisionId!),
+ );
+ }
+ throw error;
+ }
+
+ await step.run("mark-preview-pending", () =>
+ updateCurrentPreviewGitHubStatus({
+ serviceId: clone.serviceId,
+ serviceRevisionId: queued.serviceRevisionId,
+ expectedDeploymentId: deploymentId,
+ state: "pending",
+ description: "Preview build queued",
+ }),
+ );
+ if (previous?.previewCurrentRevisionId) {
+ await step.run("cancel-superseded-preview", () =>
+ cancelPreviewRevisionWork(
+ clone.serviceId,
+ previous.previewCurrentRevisionId!,
+ ),
+ );
+ }
+ if (previous?.previewGithubDeploymentId) {
+ await step.run("inactivate-superseded-deployment", () =>
+ updateGitHubDeploymentStatus(
+ context.githubRepo.installationId,
+ context.githubRepo.repoFullName,
+ previous.previewGithubDeploymentId!,
+ "inactive",
+ { description: "Superseded by a newer preview revision" },
+ ),
+ );
+ }
+ return { ...queued, deploymentId };
+ },
+);
+
+export const previewCloseWorkflow = inngest.createFunction(
+ {
+ id: "preview-close-workflow",
+ triggers: [inngestEvents.previewCloseRequested],
+ concurrency: [
+ {
+ limit: 1,
+ key: 'event.data.baseServiceId + ":" + event.data.pullRequestNumber',
+ },
+ ],
+ },
+ async ({ event, step }) =>
+ step.run("delete-preview", () => closePreviewFromEvent(event.data)),
+);
+
+export const previewServiceReconcileWorkflow = inngest.createFunction(
+ {
+ id: "preview-service-reconcile-workflow",
+ triggers: [inngestEvents.previewServiceReconcileRequested],
+ concurrency: [{ limit: 1, key: "event.data.baseServiceId" }],
+ },
+ async ({ event, step }) => {
+ const context = await step.run("load-base-service", () =>
+ loadBaseContext(event.data.baseServiceId),
+ );
+ if (!context || !context.service.previewDeploymentsEnabled) {
+ const clones = await step.run("load-previews-to-close", () =>
+ db
+ .select({ pullRequestNumber: services.previewPullRequestNumber })
+ .from(services)
+ .where(
+ and(
+ eq(services.previewOfServiceId, event.data.baseServiceId),
+ isNull(services.deletedAt),
+ ),
+ ),
+ );
+ for (const clone of clones) {
+ if (!clone.pullRequestNumber) continue;
+ await step.run(`close-disabled-${clone.pullRequestNumber}`, () =>
+ closePreview(
+ event.data.baseServiceId,
+ clone.pullRequestNumber!,
+ "preview deployments disabled",
+ ),
+ );
+ }
+ return { status: "disabled", closed: clones.length };
+ }
+ const pullRequests = await step.run("list-open-pull-requests", () =>
+ listOpenGitHubPullRequests(
+ context.githubRepo.installationId,
+ context.githubRepo.repoFullName,
+ context.githubRepo.deployBranch ?? context.githubRepo.defaultBranch,
+ ),
+ );
+ const eligible = pullRequests.filter((pullRequest) =>
+ isEligiblePullRequest(context, pullRequest),
+ );
+ const eligibleNumbers = new Set(
+ eligible.map((pullRequest) => pullRequest.number),
+ );
+ const existing = await step.run("list-existing-previews", () =>
+ db
+ .select({ pullRequestNumber: services.previewPullRequestNumber })
+ .from(services)
+ .where(
+ and(
+ eq(services.previewOfServiceId, event.data.baseServiceId),
+ isNull(services.deletedAt),
+ ),
+ ),
+ );
+ const stale = existing.filter(
+ (clone) =>
+ clone.pullRequestNumber &&
+ !eligibleNumbers.has(clone.pullRequestNumber),
+ );
+ for (const clone of stale) {
+ await step.run(`close-stale-${clone.pullRequestNumber}`, () =>
+ closePreview(
+ event.data.baseServiceId,
+ clone.pullRequestNumber!,
+ "pull request no longer eligible",
+ ),
+ );
+ }
+ for (const pullRequest of eligible) {
+ await step.run(`queue-pr-${pullRequest.number}`, () =>
+ enqueuePreviewSync(
+ event.data.baseServiceId,
+ pullRequest.number,
+ `reconcile:${pullRequest.updatedAt}`,
+ ),
+ );
+ }
+ return {
+ status: "queued",
+ count: eligible.length,
+ closed: stale.length,
+ };
+ },
+);
+
+async function enqueuePreviewSync(
+ baseServiceId: string,
+ pullRequestNumber: number,
+ idSuffix: string,
+) {
+ await inngest.send(
+ inngestEvents.previewSyncRequested.create(
+ { baseServiceId, pullRequestNumber },
+ {
+ id: `preview-reconcile:${baseServiceId}:${pullRequestNumber}:${idSuffix}`,
+ },
+ ),
+ );
+}
diff --git a/web/lib/inngest/functions/rollout-helpers.ts b/web/lib/inngest/functions/rollout-helpers.ts
index e056f122..88989d52 100644
--- a/web/lib/inngest/functions/rollout-helpers.ts
+++ b/web/lib/inngest/functions/rollout-helpers.ts
@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto";
-import { and, eq, inArray, isNotNull, sql } from "drizzle-orm";
+import { and, eq, inArray, isNotNull, isNull, sql } from "drizzle-orm";
import { db } from "@/db";
import {
deploymentPorts,
@@ -391,6 +391,23 @@ export async function createDeploymentRecords(
await tx.execute(
sql`SELECT pg_advisory_xact_lock(hashtext(${serviceId}))`,
);
+ const service = await tx
+ .select({
+ previewOfServiceId: services.previewOfServiceId,
+ previewCurrentRevisionId: services.previewCurrentRevisionId,
+ })
+ .from(services)
+ .where(
+ and(eq(services.id, serviceId), isNull(services.deletedAt)),
+ )
+ .then((rows) => rows[0]);
+ if (
+ !service ||
+ (service.previewOfServiceId &&
+ service.previewCurrentRevisionId !== revisionId)
+ ) {
+ throw new Error("Preview revision is no longer current");
+ }
const [rollout] = await tx
.select({ status: rollouts.status })
.from(rollouts)
@@ -493,14 +510,30 @@ export async function createDeploymentRecords(
export async function completeRollout(
rolloutId: string,
serviceId: string,
- context: Omit,
+ context: Omit,
): Promise<{ completed: boolean; stoppedCount: number }> {
- const { placements, specification, isRollingUpdate } = context;
+ const { placements, revisionId, specification, isRollingUpdate } = context;
const lockedServerId = specification.stateful
? placements[0]?.serverId
: undefined;
return db.transaction(async (tx) => {
+ await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${serviceId}))`);
+ const service = await tx
+ .select({
+ previewOfServiceId: services.previewOfServiceId,
+ previewCurrentRevisionId: services.previewCurrentRevisionId,
+ })
+ .from(services)
+ .where(and(eq(services.id, serviceId), isNull(services.deletedAt)))
+ .then((rows) => rows[0]);
+ if (
+ !service ||
+ (service.previewOfServiceId &&
+ service.previewCurrentRevisionId !== revisionId)
+ ) {
+ return { completed: false, stoppedCount: 0 };
+ }
const rollout = await tx
.select({ status: rollouts.status })
.from(rollouts)
diff --git a/web/lib/inngest/functions/rollout-utils.ts b/web/lib/inngest/functions/rollout-utils.ts
index 3fb6efe2..3441204f 100644
--- a/web/lib/inngest/functions/rollout-utils.ts
+++ b/web/lib/inngest/functions/rollout-utils.ts
@@ -3,6 +3,7 @@ import { db } from "@/db";
import { deployments, rollouts } from "@/db/schema";
import { markDeploymentFailedRemoved } from "@/lib/deployment-status";
import { notify } from "@/lib/notifications";
+import { updateCurrentPreviewGitHubStatus } from "@/lib/preview-deployments";
import {
enqueueReconcileForAllOnlineServers,
enqueueWork,
@@ -14,74 +15,105 @@ export async function handleRolloutFailure(
reason: string,
isRollingUpdate: boolean,
): Promise {
- const { applied, rolloutDeployments } = await db.transaction(async (tx) => {
- const [rollout] = await tx
- .select({ status: rollouts.status })
- .from(rollouts)
- .where(eq(rollouts.id, rolloutId))
- .for("update");
- if (rollout?.status !== "in_progress") {
- return { applied: false, rolloutDeployments: [] };
- }
+ const { applied, rolloutDeployments, serviceRevisionId } =
+ await db.transaction(async (tx) => {
+ const [rollout] = await tx
+ .select({
+ status: rollouts.status,
+ serviceRevisionId: rollouts.serviceRevisionId,
+ })
+ .from(rollouts)
+ .where(eq(rollouts.id, rolloutId))
+ .for("update");
+ if (rollout?.status !== "in_progress") {
+ return {
+ applied: false,
+ rolloutDeployments: [],
+ serviceRevisionId: rollout?.serviceRevisionId ?? null,
+ };
+ }
- const rolloutDeployments = await tx
- .select()
- .from(deployments)
- .where(eq(deployments.rolloutId, rolloutId));
- await tx
- .update(rollouts)
- .set({
- status: rolloutDeployments.length === 0 ? "failed" : "rolled_back",
- currentStage: reason,
- completedAt: new Date(),
- })
- .where(eq(rollouts.id, rolloutId));
+ const rolloutDeployments = await tx
+ .select()
+ .from(deployments)
+ .where(eq(deployments.rolloutId, rolloutId));
+ await tx
+ .update(rollouts)
+ .set({
+ status: rolloutDeployments.length === 0 ? "failed" : "rolled_back",
+ currentStage: reason,
+ completedAt: new Date(),
+ })
+ .where(eq(rollouts.id, rolloutId));
- if (rolloutDeployments.length === 0) {
- return { applied: true, rolloutDeployments };
- }
+ if (rolloutDeployments.length === 0) {
+ return {
+ applied: true,
+ rolloutDeployments,
+ serviceRevisionId: rollout.serviceRevisionId,
+ };
+ }
- if (isRollingUpdate) {
- await tx
+ if (isRollingUpdate) {
+ await tx
+ .update(deployments)
+ .set({ trafficState: "active" })
+ .where(
+ and(
+ eq(deployments.serviceId, serviceId),
+ eq(deployments.trafficState, "draining"),
+ ),
+ );
+ }
+
+ const removedDeployments = await tx
.update(deployments)
- .set({ trafficState: "active" })
+ .set(markDeploymentFailedRemoved(reason))
.where(
and(
- eq(deployments.serviceId, serviceId),
- eq(deployments.trafficState, "draining"),
+ eq(deployments.rolloutId, rolloutId),
+ ne(deployments.runtimeDesiredState, "removed"),
),
- );
- }
+ )
+ .returning({ serverId: deployments.serverId });
- const removedDeployments = await tx
- .update(deployments)
- .set(markDeploymentFailedRemoved(reason))
- .where(
- and(
- eq(deployments.rolloutId, rolloutId),
- ne(deployments.runtimeDesiredState, "removed"),
- ),
- )
- .returning({ serverId: deployments.serverId });
-
- if (isRollingUpdate) {
- await enqueueReconcileForAllOnlineServers("rollout_rolled_back", tx);
- } else {
- for (const serverId of new Set(
- removedDeployments.map((deployment) => deployment.serverId),
- )) {
- await enqueueWork(
- serverId,
- "reconcile",
- { reason: "rollout_rolled_back" },
- { tx },
- );
+ if (isRollingUpdate) {
+ await enqueueReconcileForAllOnlineServers("rollout_rolled_back", tx);
+ } else {
+ for (const serverId of new Set(
+ removedDeployments.map((deployment) => deployment.serverId),
+ )) {
+ await enqueueWork(
+ serverId,
+ "reconcile",
+ { reason: "rollout_rolled_back" },
+ { tx },
+ );
+ }
}
- }
- return { applied: true, rolloutDeployments };
- });
+ return {
+ applied: true,
+ rolloutDeployments,
+ serviceRevisionId: rollout.serviceRevisionId,
+ };
+ });
if (!applied) return;
+ if (serviceRevisionId) {
+ try {
+ await updateCurrentPreviewGitHubStatus({
+ serviceId,
+ serviceRevisionId,
+ state: "failure",
+ description: `Preview rollout failed: ${reason}`,
+ });
+ } catch (error) {
+ console.error(
+ "[rollout:failure] failed to update preview status:",
+ error,
+ );
+ }
+ }
if (rolloutDeployments.length === 0) {
notify({
diff --git a/web/lib/inngest/functions/rollout-workflow.ts b/web/lib/inngest/functions/rollout-workflow.ts
index 3b723990..0e2dbb96 100644
--- a/web/lib/inngest/functions/rollout-workflow.ts
+++ b/web/lib/inngest/functions/rollout-workflow.ts
@@ -1,9 +1,13 @@
import { and, eq, gte, inArray, isNull, lt, ne, or, sql } from "drizzle-orm";
import { db } from "@/db";
-import { getService } from "@/db/queries";
+import { getRuntimeService } from "@/db/queries";
import { deployments, rollouts, servers } from "@/db/schema";
import { isObservedReady, observedReadyPhases } from "@/lib/deployment-status";
import { buildRoutingTargets } from "@/lib/routing-sync";
+import {
+ canDeployServiceRevision,
+ updateCurrentPreviewGitHubStatus,
+} from "@/lib/preview-deployments";
import type { ServiceRevisionSpec } from "@/lib/service-revision-spec";
import { getRolloutServiceRevision } from "@/lib/service-revisions";
import { ingestRolloutLog } from "@/lib/victoria-logs";
@@ -203,7 +207,7 @@ export const rolloutWorkflow = inngest.createFunction(
const { rolloutId, serviceId } = event.data;
await step.run("validate-service", async () => {
- const svc = await getService(serviceId);
+ const svc = await getRuntimeService(serviceId);
if (!svc) {
throw new Error("Service not found");
}
@@ -265,6 +269,23 @@ export const rolloutWorkflow = inngest.createFunction(
};
});
const specification = revision.specification;
+ const currentRevision = await step.run(
+ "validate-current-preview-revision",
+ () => canDeployServiceRevision(serviceId, revision.id),
+ );
+ if (!currentRevision) {
+ await step.run("mark-superseded-preview-rollout", () =>
+ db
+ .update(rollouts)
+ .set({
+ status: "failed",
+ currentStage: "superseded",
+ completedAt: new Date(),
+ })
+ .where(eq(rollouts.id, rolloutId)),
+ );
+ return { status: "cancelled", rolloutId };
+ }
await step.run("log-rollout-started", async () => {
await ingestRolloutLog(
@@ -420,6 +441,9 @@ export const rolloutWorkflow = inngest.createFunction(
}
const { deploymentIds } = await step.run("create-deployments", async () => {
+ if (!(await canDeployServiceRevision(serviceId, revision.id))) {
+ throw new Error("Preview revision was superseded before deployment");
+ }
await db
.update(rollouts)
.set({ currentStage: "deploying" })
@@ -692,7 +716,11 @@ export const rolloutWorkflow = inngest.createFunction(
}
const rolloutCompleted = await step.run("complete-rollout", async () => {
+ if (!(await canDeployServiceRevision(serviceId, revision.id))) {
+ return false;
+ }
const result = await completeRollout(rolloutId, serviceId, {
+ revisionId: revision.id,
specification,
placements,
totalReplicas,
@@ -713,6 +741,19 @@ export const rolloutWorkflow = inngest.createFunction(
"completed",
"Rollout completed successfully",
);
+ try {
+ await updateCurrentPreviewGitHubStatus({
+ serviceId,
+ serviceRevisionId: revision.id,
+ state: "success",
+ description: "Preview is ready",
+ });
+ } catch (error) {
+ console.error(
+ "[rollout:complete] failed to update preview status:",
+ error,
+ );
+ }
return true;
});
if (!rolloutCompleted) {
diff --git a/web/lib/preview-deployments.ts b/web/lib/preview-deployments.ts
new file mode 100644
index 00000000..1e656fea
--- /dev/null
+++ b/web/lib/preview-deployments.ts
@@ -0,0 +1,577 @@
+import { randomUUID } from "node:crypto";
+import { and, desc, eq, isNull, sql } from "drizzle-orm";
+import { db } from "@/db";
+import { getSetting } from "@/db/queries";
+import {
+ builds,
+ githubRepos,
+ rollouts,
+ secrets,
+ servers,
+ servicePorts,
+ serviceReplicas,
+ serviceRevisions,
+ services,
+} from "@/db/schema";
+import { updateGitHubDeploymentStatus } from "@/lib/github";
+import { resolveRegistryImageHost } from "@/lib/registry-reference";
+import { parseServiceRevisionSpec } from "@/lib/service-revision-changes";
+import { SETTING_KEYS } from "@/lib/settings-keys";
+
+const DNS_LABEL_MAX_LENGTH = 63;
+export const PREVIEW_RECONCILIATION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
+
+type PreviewPort = {
+ port: number;
+ isPublic: boolean;
+ domain: string | null;
+ protocol: "http" | "tcp" | "udp";
+ externalPort: number | null;
+ tlsPassthrough: boolean;
+};
+
+function dnsLabelPart(value: string) {
+ return value
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9-]+/g, "-")
+ .replace(/^-+|-+$/g, "")
+ .replace(/-+/g, "-");
+}
+
+export function previewHostname(input: {
+ serviceName: string;
+ serviceId: string;
+ pullRequestNumber: number;
+ domain: string;
+ portIndex?: number;
+}) {
+ if (
+ !Number.isInteger(input.pullRequestNumber) ||
+ input.pullRequestNumber < 1
+ ) {
+ throw new Error("Invalid pull request number");
+ }
+
+ const service = dnsLabelPart(input.serviceName) || "service";
+ const serviceId = dnsLabelPart(input.serviceId)
+ .replaceAll("-", "")
+ .slice(0, 8);
+ if (!serviceId) throw new Error("Invalid service id");
+
+ const portSuffix = input.portIndex ? `-p${input.portIndex + 1}` : "";
+ const stableSuffix = `-pr-${input.pullRequestNumber}-${serviceId}${portSuffix}`;
+ const availableServiceLength = DNS_LABEL_MAX_LENGTH - stableSuffix.length;
+ if (availableServiceLength < 1) {
+ throw new Error("Preview hostname suffix exceeds DNS label limit");
+ }
+
+ const label = `${service.slice(0, availableServiceLength).replace(/-+$/, "") || "s"}${stableSuffix}`;
+ const domain = input.domain.trim().toLowerCase().replace(/\.$/, "");
+ if (!domain) throw new Error("Automatic Subdomain Domain is not configured");
+
+ return `${label}.${domain}`;
+}
+
+export async function requirePreviewDomain() {
+ const domain = await getSetting(SETTING_KEYS.AUTO_SUBDOMAIN_DOMAIN);
+ const normalized = domain?.trim().toLowerCase().replace(/\.$/, "");
+ if (!normalized) {
+ throw new Error(
+ "Automatic Subdomain Domain must be configured before enabling preview deployments",
+ );
+ }
+ return normalized;
+}
+
+export function previewPortConfiguration(input: {
+ ports: PreviewPort[];
+ serviceName: string;
+ serviceId: string;
+ pullRequestNumber: number;
+ domain: string;
+}) {
+ let publicHttpIndex = 0;
+ return input.ports.map((port) => {
+ if (port.isPublic && port.protocol === "http") {
+ const index = publicHttpIndex++;
+ return {
+ ...port,
+ domain: previewHostname({
+ serviceName: input.serviceName,
+ serviceId: input.serviceId,
+ pullRequestNumber: input.pullRequestNumber,
+ domain: input.domain,
+ portIndex: index,
+ }),
+ externalPort: null,
+ tlsPassthrough: false,
+ };
+ }
+ return {
+ ...port,
+ isPublic: false,
+ domain: null,
+ externalPort: null,
+ tlsPassthrough: false,
+ };
+ });
+}
+
+export async function getPreviewClone(
+ baseServiceId: string,
+ pullRequestNumber: number,
+) {
+ return db
+ .select()
+ .from(services)
+ .where(
+ and(
+ eq(services.previewOfServiceId, baseServiceId),
+ eq(services.previewPullRequestNumber, pullRequestNumber),
+ isNull(services.deletedAt),
+ ),
+ )
+ .then((rows) => rows[0] ?? null);
+}
+
+export async function createOrRefreshPreviewClone(input: {
+ baseServiceId: string;
+ pullRequestNumber: number;
+ now?: Date;
+}) {
+ const domain = await requirePreviewDomain();
+ const now = input.now ?? new Date();
+ return db.transaction(async (tx) => {
+ await tx.execute(
+ sql`select pg_advisory_xact_lock(hashtext(${input.baseServiceId}))`,
+ );
+ await tx.execute(
+ sql`select pg_advisory_xact_lock(hashtext(${input.baseServiceId}), ${input.pullRequestNumber})`,
+ );
+
+ const base = await tx
+ .select()
+ .from(services)
+ .where(
+ and(
+ eq(services.id, input.baseServiceId),
+ isNull(services.previewOfServiceId),
+ isNull(services.deletedAt),
+ ),
+ )
+ .then((rows) => rows[0]);
+ if (!base || !base.previewDeploymentsEnabled) {
+ throw new Error("Preview deployments are not enabled for this service");
+ }
+ if (base.stateful) {
+ throw new Error("Preview deployments require a stateless service");
+ }
+ if (base.sourceType !== "github") {
+ throw new Error("Preview deployments require a GitHub App service");
+ }
+
+ const [repo, ports, sourceSecrets, placements] = await Promise.all([
+ tx
+ .select()
+ .from(githubRepos)
+ .where(eq(githubRepos.serviceId, base.id))
+ .then((rows) => rows[0]),
+ tx
+ .select()
+ .from(servicePorts)
+ .where(eq(servicePorts.serviceId, base.id))
+ .orderBy(servicePorts.port, servicePorts.protocol, servicePorts.id),
+ tx
+ .select()
+ .from(secrets)
+ .where(eq(secrets.serviceId, base.id))
+ .orderBy(secrets.key, secrets.id),
+ tx
+ .select({
+ serverId: serviceReplicas.serverId,
+ count: serviceReplicas.count,
+ status: servers.status,
+ wireguardIp: servers.wireguardIp,
+ })
+ .from(serviceReplicas)
+ .innerJoin(servers, eq(serviceReplicas.serverId, servers.id))
+ .where(eq(serviceReplicas.serviceId, base.id))
+ .orderBy(serviceReplicas.serverId),
+ ]);
+ if (!repo) {
+ throw new Error("Preview deployments require a GitHub App service");
+ }
+
+ const publicHttpPorts = ports.filter(
+ (port) => port.isPublic && port.protocol === "http",
+ );
+ if (publicHttpPorts.length === 0) {
+ throw new Error(
+ "Preview deployments require at least one public HTTP port",
+ );
+ }
+
+ const eligiblePlacement = placements.find(
+ (placement) =>
+ placement.count > 0 &&
+ placement.status === "online" &&
+ placement.wireguardIp,
+ );
+ if (base.placementMode === "manual" && !eligiblePlacement) {
+ throw new Error("No eligible placement exists for this preview");
+ }
+
+ const existing = await tx
+ .select()
+ .from(services)
+ .where(
+ and(
+ eq(services.previewOfServiceId, base.id),
+ eq(services.previewPullRequestNumber, input.pullRequestNumber),
+ isNull(services.deletedAt),
+ ),
+ )
+ .then((rows) => rows[0]);
+ const previewServiceId = existing?.id ?? randomUUID();
+ const configuredPorts = previewPortConfiguration({
+ ports,
+ serviceName: base.name,
+ serviceId: base.id,
+ pullRequestNumber: input.pullRequestNumber,
+ domain,
+ });
+ const primaryDomain = configuredPorts.find(
+ (port) => port.isPublic && port.protocol === "http",
+ )?.domain;
+ if (!primaryDomain)
+ throw new Error("Preview domain could not be generated");
+
+ const serviceValues = {
+ projectId: base.projectId,
+ environmentId: base.environmentId,
+ name: `${base.name} (PR #${input.pullRequestNumber})`,
+ hostname: primaryDomain.split(".")[0],
+ image: `${resolveRegistryImageHost()}/${base.projectId}/${previewServiceId}:latest`,
+ sourceType: "github" as const,
+ githubRepoUrl: base.githubRepoUrl,
+ githubBranch: base.githubBranch,
+ githubRootDir: base.githubRootDir,
+ replicas: 1,
+ autoscalingEnabled: false,
+ autoscalingMinReplicas: 1,
+ autoscalingMaxReplicas: 1,
+ placementMode: base.placementMode,
+ stateful: false,
+ lockedServerId: null,
+ healthCheckCmd: base.healthCheckCmd,
+ healthCheckInterval: base.healthCheckInterval,
+ healthCheckTimeout: base.healthCheckTimeout,
+ healthCheckRetries: base.healthCheckRetries,
+ healthCheckStartPeriod: base.healthCheckStartPeriod,
+ startCommand: base.startCommand,
+ resourceCpuLimit: base.resourceCpuLimit,
+ resourceMemoryLimitMb: base.resourceMemoryLimitMb,
+ serverlessEnabled: false,
+ deploymentSchedule: null,
+ backupEnabled: false,
+ backupSchedule: null,
+ previewDeploymentsEnabled: false,
+ previewOfServiceId: base.id,
+ previewPullRequestNumber: input.pullRequestNumber,
+ previewError: null,
+ previewExpiresAt: new Date(now.getTime() + PREVIEW_RECONCILIATION_TTL_MS),
+ };
+
+ if (existing) {
+ await tx
+ .update(services)
+ .set(serviceValues)
+ .where(eq(services.id, previewServiceId));
+ } else {
+ await tx.insert(services).values({
+ id: previewServiceId,
+ ...serviceValues,
+ });
+ }
+
+ await Promise.all([
+ tx
+ .delete(servicePorts)
+ .where(eq(servicePorts.serviceId, previewServiceId)),
+ tx
+ .delete(serviceReplicas)
+ .where(eq(serviceReplicas.serviceId, previewServiceId)),
+ tx.delete(secrets).where(eq(secrets.serviceId, previewServiceId)),
+ tx.delete(githubRepos).where(eq(githubRepos.serviceId, previewServiceId)),
+ ]);
+ await tx.insert(servicePorts).values(
+ configuredPorts.map((port) => ({
+ id: randomUUID(),
+ serviceId: previewServiceId,
+ ...port,
+ })),
+ );
+ if (base.placementMode === "manual" && eligiblePlacement) {
+ await tx.insert(serviceReplicas).values({
+ id: randomUUID(),
+ serviceId: previewServiceId,
+ serverId: eligiblePlacement.serverId,
+ count: 1,
+ });
+ }
+ if (sourceSecrets.length > 0) {
+ await tx.insert(secrets).values(
+ sourceSecrets.map((secret) => ({
+ id: randomUUID(),
+ serviceId: previewServiceId,
+ key: secret.key,
+ encryptedValue: secret.encryptedValue,
+ updatedAt: secret.updatedAt,
+ })),
+ );
+ }
+ await tx.insert(githubRepos).values({
+ id: randomUUID(),
+ installationId: repo.installationId,
+ repoId: repo.repoId,
+ repoFullName: repo.repoFullName,
+ defaultBranch: repo.defaultBranch,
+ serviceId: previewServiceId,
+ deployBranch: repo.deployBranch,
+ autoDeploy: false,
+ });
+
+ return {
+ serviceId: previewServiceId,
+ created: !existing,
+ primaryUrl: `https://${primaryDomain}`,
+ };
+ });
+}
+
+export async function isCurrentPreviewRevision(
+ serviceId: string,
+ serviceRevisionId: string,
+) {
+ const clone = await db
+ .select({ id: services.id })
+ .from(services)
+ .where(
+ and(
+ eq(services.id, serviceId),
+ eq(services.previewCurrentRevisionId, serviceRevisionId),
+ isNull(services.deletedAt),
+ ),
+ )
+ .then((rows) => rows[0]);
+ return Boolean(clone);
+}
+
+export async function canDeployServiceRevision(
+ serviceId: string,
+ serviceRevisionId: string,
+) {
+ const service = await db
+ .select({
+ previewOfServiceId: services.previewOfServiceId,
+ previewCurrentRevisionId: services.previewCurrentRevisionId,
+ })
+ .from(services)
+ .where(and(eq(services.id, serviceId), isNull(services.deletedAt)))
+ .then((rows) => rows[0]);
+ if (!service) return false;
+ return (
+ !service.previewOfServiceId ||
+ service.previewCurrentRevisionId === serviceRevisionId
+ );
+}
+
+export async function getPreviewPrimaryUrl(serviceId: string) {
+ const ports = await db
+ .select({
+ id: servicePorts.id,
+ port: servicePorts.port,
+ domain: servicePorts.domain,
+ })
+ .from(servicePorts)
+ .where(
+ and(
+ eq(servicePorts.serviceId, serviceId),
+ eq(servicePorts.protocol, "http"),
+ eq(servicePorts.isPublic, true),
+ ),
+ );
+ const primary = ports
+ .filter((port) => port.domain)
+ .sort((a, b) => a.port - b.port || a.id.localeCompare(b.id))[0];
+ return primary?.domain ? `https://${primary.domain}` : null;
+}
+
+export async function updateCurrentPreviewGitHubStatus(input: {
+ serviceId: string;
+ serviceRevisionId: string;
+ state: "pending" | "in_progress" | "success" | "failure" | "inactive";
+ description: string;
+ logUrl?: string;
+ expectedDeploymentId?: number;
+}) {
+ return db.transaction(async (tx) => {
+ await tx.execute(
+ sql`select pg_advisory_xact_lock(hashtext(${input.serviceId}))`,
+ );
+ const context = await tx
+ .select({
+ previewCurrentRevisionId: services.previewCurrentRevisionId,
+ previewGithubDeploymentId: services.previewGithubDeploymentId,
+ previewOfServiceId: services.previewOfServiceId,
+ installationId: githubRepos.installationId,
+ repoFullName: githubRepos.repoFullName,
+ })
+ .from(services)
+ .innerJoin(githubRepos, eq(githubRepos.serviceId, services.id))
+ .where(and(eq(services.id, input.serviceId), isNull(services.deletedAt)))
+ .then((rows) => rows[0]);
+ if (
+ !context?.previewOfServiceId ||
+ context.previewCurrentRevisionId !== input.serviceRevisionId ||
+ !context.previewGithubDeploymentId ||
+ (input.expectedDeploymentId !== undefined &&
+ context.previewGithubDeploymentId !== input.expectedDeploymentId)
+ ) {
+ return false;
+ }
+ const primary = await tx
+ .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 updateGitHubDeploymentStatus(
+ context.installationId,
+ context.repoFullName,
+ context.previewGithubDeploymentId,
+ input.state,
+ {
+ description: input.description.substring(0, 140),
+ logUrl: input.logUrl,
+ environmentUrl: primary?.domain
+ ? `https://${primary.domain}`
+ : undefined,
+ },
+ );
+ return true;
+ });
+}
+
+export async function listPreviewDeployments(baseServiceId: string) {
+ const clones = await db
+ .select()
+ .from(services)
+ .where(
+ and(
+ eq(services.previewOfServiceId, baseServiceId),
+ isNull(services.deletedAt),
+ ),
+ )
+ .orderBy(services.previewPullRequestNumber);
+ return Promise.all(
+ clones.map(async (clone) => {
+ const revisionId = clone.previewCurrentRevisionId;
+ const [revision, revisionBuilds, rollout, primaryUrl] = await Promise.all(
+ [
+ revisionId
+ ? db
+ .select({
+ specification: serviceRevisions.specification,
+ createdAt: serviceRevisions.createdAt,
+ })
+ .from(serviceRevisions)
+ .where(eq(serviceRevisions.id, revisionId))
+ .then((rows) => rows[0])
+ : null,
+ revisionId
+ ? db
+ .select({
+ id: builds.id,
+ status: builds.status,
+ error: builds.error,
+ })
+ .from(builds)
+ .where(eq(builds.serviceRevisionId, revisionId))
+ : [],
+ revisionId
+ ? db
+ .select({
+ id: rollouts.id,
+ status: rollouts.status,
+ currentStage: rollouts.currentStage,
+ })
+ .from(rollouts)
+ .where(eq(rollouts.serviceRevisionId, revisionId))
+ .orderBy(desc(rollouts.createdAt))
+ .then((rows) => rows[0])
+ : null,
+ getPreviewPrimaryUrl(clone.id),
+ ],
+ );
+ let commitSha: string | null = null;
+ if (revision) {
+ const specification = parseServiceRevisionSpec(revision.specification);
+ if (specification.source.type === "github") {
+ commitSha = specification.source.commitSha;
+ }
+ }
+ const failedBuild = revisionBuilds.find(
+ (build) => build.status === "failed",
+ );
+ const activeBuild = revisionBuilds.some((build) =>
+ ["pending", "claimed", "cloning", "building", "pushing"].includes(
+ build.status,
+ ),
+ );
+ const status =
+ clone.previewError || failedBuild
+ ? "failed"
+ : rollout?.status === "completed"
+ ? "ready"
+ : rollout?.status === "failed" || rollout?.status === "rolled_back"
+ ? "failed"
+ : rollout
+ ? "deploying"
+ : activeBuild || revisionBuilds.length > 0
+ ? "building"
+ : "queued";
+ return {
+ serviceId: clone.id,
+ pullRequestNumber: clone.previewPullRequestNumber!,
+ status,
+ commitSha,
+ url: primaryUrl,
+ error:
+ clone.previewError ??
+ failedBuild?.error ??
+ (rollout && ["failed", "rolled_back"].includes(rollout.status)
+ ? rollout.currentStage
+ : null),
+ updatedAt:
+ revision?.createdAt.toISOString() ?? clone.createdAt.toISOString(),
+ expiresAt: clone.previewExpiresAt?.toISOString() ?? null,
+ };
+ }),
+ );
+}
diff --git a/web/lib/preview-lifecycle.ts b/web/lib/preview-lifecycle.ts
new file mode 100644
index 00000000..50182d3d
--- /dev/null
+++ b/web/lib/preview-lifecycle.ts
@@ -0,0 +1,272 @@
+import { and, eq, inArray, sql } from "drizzle-orm";
+import { db } from "@/db";
+import {
+ builds,
+ deploymentPorts,
+ deployments,
+ githubRepos,
+ rollouts,
+ services,
+} from "@/db/schema";
+import { markDeploymentRemoved } from "@/lib/deployment-status";
+import { updateGitHubDeploymentStatus } from "@/lib/github";
+import { inngest } from "@/lib/inngest/client";
+import { inngestEvents } from "@/lib/inngest/events";
+import {
+ cleanupRegistryArtifactsForService,
+ prepareRegistryArtifactCleanup,
+} from "@/lib/registry-retention";
+import {
+ enqueueReconcileForAllOnlineServers,
+ enqueueWork,
+} from "@/lib/work-queue";
+
+const activeBuildStatuses = [
+ "pending",
+ "claimed",
+ "cloning",
+ "building",
+ "pushing",
+] as const;
+
+async function cancelBuildRows(serviceId: string, serviceRevisionId?: string) {
+ const conditions = [
+ eq(builds.serviceId, serviceId),
+ inArray(builds.status, [...activeBuildStatuses]),
+ ];
+ if (serviceRevisionId) {
+ conditions.push(eq(builds.serviceRevisionId, serviceRevisionId));
+ }
+ const cancelled = await db
+ .update(builds)
+ .set({ status: "cancelled", completedAt: new Date() })
+ .where(and(...conditions))
+ .returning({ buildGroupId: builds.buildGroupId });
+ for (const buildGroupId of new Set(
+ cancelled.map((row) => row.buildGroupId),
+ )) {
+ await inngest.send(
+ inngestEvents.buildCancelled.create(
+ { buildId: `preview-${buildGroupId}`, buildGroupId },
+ { id: `preview-build-cancelled-${buildGroupId}` },
+ ),
+ );
+ }
+}
+
+async function cancelRolloutRows(
+ serviceId: string,
+ serviceRevisionId?: string,
+) {
+ const conditions = [
+ eq(rollouts.serviceId, serviceId),
+ inArray(rollouts.status, ["queued", "in_progress"]),
+ ];
+ if (serviceRevisionId) {
+ conditions.push(eq(rollouts.serviceRevisionId, serviceRevisionId));
+ }
+ const cancelled = await db
+ .update(rollouts)
+ .set({
+ status: "failed",
+ currentStage: "superseded",
+ completedAt: new Date(),
+ })
+ .where(and(...conditions))
+ .returning({ id: rollouts.id });
+ if (cancelled.length === 0) return;
+
+ const rolloutIds = cancelled.map(({ id }) => id);
+ const rolloutDeployments = await db
+ .select()
+ .from(deployments)
+ .where(inArray(deployments.rolloutId, rolloutIds));
+ await db
+ .update(deployments)
+ .set(markDeploymentRemoved())
+ .where(inArray(deployments.rolloutId, rolloutIds));
+ for (const deployment of rolloutDeployments) {
+ if (!deployment.containerId) continue;
+ await enqueueWork(deployment.serverId, "stop", {
+ deploymentId: deployment.id,
+ containerId: deployment.containerId,
+ });
+ }
+ for (const { id } of cancelled) {
+ await inngest.send(
+ inngestEvents.rolloutCancelled.create(
+ { rolloutId: id },
+ { id: `preview-rollout-cancelled-${id}` },
+ ),
+ );
+ }
+ await db.transaction((tx) =>
+ enqueueReconcileForAllOnlineServers("preview_rollout_cancelled", tx),
+ );
+}
+
+export async function cancelPreviewRevisionWork(
+ serviceId: string,
+ serviceRevisionId: string,
+) {
+ await Promise.all([
+ cancelBuildRows(serviceId, serviceRevisionId),
+ cancelRolloutRows(serviceId, serviceRevisionId),
+ ]);
+}
+
+export async function deactivatePreviewRuntime(serviceId: string) {
+ await Promise.all([cancelBuildRows(serviceId), cancelRolloutRows(serviceId)]);
+ const runtime = await db
+ .select()
+ .from(deployments)
+ .where(eq(deployments.serviceId, serviceId));
+ await db
+ .update(deployments)
+ .set(markDeploymentRemoved())
+ .where(eq(deployments.serviceId, serviceId));
+ for (const deployment of runtime) {
+ if (!deployment.containerId) continue;
+ await enqueueWork(deployment.serverId, "stop", {
+ deploymentId: deployment.id,
+ containerId: deployment.containerId,
+ });
+ }
+ await db.transaction((tx) =>
+ enqueueReconcileForAllOnlineServers("preview_runtime_deactivated", tx),
+ );
+}
+
+export async function deletePreviewService(
+ baseServiceId: string,
+ pullRequestNumber: number,
+) {
+ const claimed = await db.transaction(async (tx) => {
+ await tx.execute(
+ sql`select pg_advisory_xact_lock(hashtext(${baseServiceId}))`,
+ );
+ await tx.execute(
+ sql`select pg_advisory_xact_lock(hashtext(${baseServiceId}), ${pullRequestNumber})`,
+ );
+ const context = await tx
+ .select({ service: services, githubRepo: githubRepos })
+ .from(services)
+ .innerJoin(githubRepos, eq(githubRepos.serviceId, services.id))
+ .where(
+ and(
+ eq(services.previewOfServiceId, baseServiceId),
+ eq(services.previewPullRequestNumber, pullRequestNumber),
+ ),
+ )
+ .then((rows) => rows[0]);
+ if (!context) return null;
+ await tx.execute(
+ sql`select pg_advisory_xact_lock(hashtext(${context.service.id}))`,
+ );
+ if (!(await prepareRegistryArtifactCleanup(tx, context.service.id))) {
+ throw new Error(
+ "Preview deletion deferred while registry manifest work is processing",
+ );
+ }
+ await tx
+ .update(services)
+ .set({ previewCurrentRevisionId: null })
+ .where(eq(services.id, context.service.id));
+ await tx
+ .update(services)
+ .set({
+ deletedAt: new Date(),
+ purgeAfter: new Date(),
+ deletionStatus: "deleting",
+ })
+ .where(eq(services.id, context.service.id));
+ return context;
+ });
+ if (!claimed) return null;
+
+ await Promise.all([
+ cancelBuildRows(claimed.service.id),
+ cancelRolloutRows(claimed.service.id),
+ ]);
+ const runtime = await db
+ .select()
+ .from(deployments)
+ .where(eq(deployments.serviceId, claimed.service.id));
+ for (const deployment of runtime) {
+ if (deployment.containerId) {
+ await enqueueWork(deployment.serverId, "stop", {
+ deploymentId: deployment.id,
+ containerId: deployment.containerId,
+ });
+ }
+ await db
+ .delete(deploymentPorts)
+ .where(eq(deploymentPorts.deploymentId, deployment.id));
+ }
+ await db
+ .delete(deployments)
+ .where(eq(deployments.serviceId, claimed.service.id));
+ await db.transaction((tx) =>
+ enqueueReconcileForAllOnlineServers("preview_deleted", tx),
+ );
+ await cleanupRegistryArtifactsForService(claimed.service.id);
+ await db.delete(services).where(eq(services.id, claimed.service.id));
+ return claimed;
+}
+
+export async function deletePreviewsForBaseService(
+ baseServiceId: string,
+ reason: string,
+) {
+ const previews = await db
+ .select({ service: services, githubRepo: githubRepos })
+ .from(services)
+ .innerJoin(githubRepos, eq(githubRepos.serviceId, services.id))
+ .where(
+ eq(services.previewOfServiceId, baseServiceId),
+ );
+ for (const preview of previews) {
+ const pullRequestNumber = preview.service.previewPullRequestNumber;
+ if (!pullRequestNumber) continue;
+ await deletePreviewService(baseServiceId, pullRequestNumber);
+ if (!preview.service.previewGithubDeploymentId) continue;
+ try {
+ await updateGitHubDeploymentStatus(
+ preview.githubRepo.installationId,
+ preview.githubRepo.repoFullName,
+ preview.service.previewGithubDeploymentId,
+ "inactive",
+ { description: `Preview removed: ${reason}`.substring(0, 140) },
+ );
+ } catch (error) {
+ console.error(
+ `[preview:delete] failed to mark GitHub deployment ${preview.service.previewGithubDeploymentId} inactive:`,
+ error,
+ );
+ }
+ }
+}
+
+export async function deletePreviewsForGitHubInstallation(
+ installationId: number,
+ reason: string,
+) {
+ const previews = await db
+ .select({
+ baseServiceId: services.previewOfServiceId,
+ pullRequestNumber: services.previewPullRequestNumber,
+ })
+ .from(services)
+ .innerJoin(githubRepos, eq(githubRepos.serviceId, services.id))
+ .where(
+ eq(githubRepos.installationId, installationId),
+ );
+ const baseServiceIds = new Set(
+ previews.flatMap((preview) =>
+ preview.baseServiceId ? [preview.baseServiceId] : [],
+ ),
+ );
+ for (const baseServiceId of baseServiceIds) {
+ await deletePreviewsForBaseService(baseServiceId, reason);
+ }
+}
diff --git a/web/lib/public-api.ts b/web/lib/public-api.ts
index e9993ea1..c4e09a7d 100644
--- a/web/lib/public-api.ts
+++ b/web/lib/public-api.ts
@@ -263,7 +263,13 @@ export async function findServiceContext(serviceId: string) {
eq(environments.projectId, projects.id),
),
)
- .where(and(eq(services.id, serviceId), isNull(services.deletedAt)))
+ .where(
+ and(
+ eq(services.id, serviceId),
+ isNull(services.deletedAt),
+ isNull(services.previewOfServiceId),
+ ),
+ )
.limit(1)
.then((rows) => rows[0] ?? null);
}
diff --git a/web/lib/scheduler.ts b/web/lib/scheduler.ts
index ab478e25..4314667e 100644
--- a/web/lib/scheduler.ts
+++ b/web/lib/scheduler.ts
@@ -79,6 +79,7 @@ export async function runAutoscalingController(
.where(
and(
isNull(services.deletedAt),
+ isNull(services.previewOfServiceId),
or(
isNull(services.lastAutoscaleAttemptAt),
lt(services.lastAutoscaleAttemptAt, cooldownCutoff),
@@ -254,6 +255,7 @@ export async function rebalanceAutomaticServices(
.where(
and(
isNull(services.deletedAt),
+ isNull(services.previewOfServiceId),
inArray(deployments.runtimeDesiredState, ["running", "stopped"]),
eq(deployments.trafficState, "active"),
eq(
@@ -716,7 +718,11 @@ export async function checkAndRunScheduledDeployments(): Promise {
})
.from(services)
.where(
- and(isNotNull(services.deploymentSchedule), isNull(services.deletedAt)),
+ and(
+ isNotNull(services.deploymentSchedule),
+ isNull(services.deletedAt),
+ isNull(services.previewOfServiceId),
+ ),
);
if (scheduledServices.length === 0) return;
diff --git a/web/lib/service-crons.ts b/web/lib/service-crons.ts
index 9b7b2c90..ec5893da 100644
--- a/web/lib/service-crons.ts
+++ b/web/lib/service-crons.ts
@@ -153,7 +153,11 @@ export async function executeServiceCron(
.from(serviceCrons)
.innerJoin(
services,
- and(eq(serviceCrons.serviceId, services.id), isNull(services.deletedAt)),
+ and(
+ eq(serviceCrons.serviceId, services.id),
+ isNull(services.deletedAt),
+ isNull(services.previewOfServiceId),
+ ),
)
.where(eq(serviceCrons.id, cronId))
.limit(1)
diff --git a/web/lib/service-revision-changes.ts b/web/lib/service-revision-changes.ts
index 52a6fb0a..043fca26 100644
--- a/web/lib/service-revision-changes.ts
+++ b/web/lib/service-revision-changes.ts
@@ -6,7 +6,30 @@ import type {
ServiceRevisionPort,
ServiceRevisionSpec,
} from "@/lib/service-revision-spec";
-import { validateServiceRevisionPorts } from "@/lib/service-revision-spec";
+import {
+ gitBranchRef,
+ isSupportedGitRef,
+ validateServiceRevisionPorts,
+} from "@/lib/service-revision-spec";
+
+const legacySourceSchema = z.discriminatedUnion("type", [
+ z.strictObject({ type: z.literal("image"), image: z.string() }),
+ z.strictObject({
+ type: z.literal("github"),
+ repository: z.string().url(),
+ repositoryId: z.number().int().positive().nullable(),
+ branch: z.string().min(1),
+ commitSha: z.string().regex(/^[0-9a-f]{40}$/),
+ rootDir: z.string().min(1).nullable(),
+ authentication: z.discriminatedUnion("type", [
+ z.strictObject({ type: z.literal("anonymous") }),
+ z.strictObject({
+ type: z.literal("github_app"),
+ installationId: z.number().int().positive(),
+ }),
+ ]),
+ }),
+]);
const serviceRevisionSpecFields = {
image: z.string(),
@@ -17,6 +40,7 @@ const serviceRevisionSpecFields = {
repository: z.string().url(),
repositoryId: z.number().int().positive().nullable(),
branch: z.string().min(1),
+ gitRef: z.string().refine(isSupportedGitRef, "Unsupported Git ref"),
commitSha: z.string().regex(/^[0-9a-f]{40}$/),
rootDir: z.string().min(1).nullable(),
authentication: z.discriminatedUnion("type", [
@@ -87,10 +111,23 @@ const serviceRevisionSpecFields = {
const serviceRevisionSpecV2Schema = z.strictObject({
schemaVersion: z.literal(2),
...serviceRevisionSpecFields,
+ source: legacySourceSchema,
+});
+const serviceRevisionSpecV3Schema = z.strictObject({
+ schemaVersion: z.literal(3),
+ placement: z.discriminatedUnion("mode", [
+ z.strictObject({ mode: z.literal("manual") }),
+ z.strictObject({
+ mode: z.literal("automatic"),
+ replicas: z.number().int().min(1).max(32),
+ }),
+ ]),
+ ...serviceRevisionSpecFields,
+ source: legacySourceSchema,
});
const serviceRevisionSpecSchema = z
.strictObject({
- schemaVersion: z.literal(3),
+ schemaVersion: z.literal(4),
placement: z.discriminatedUnion("mode", [
z.strictObject({ mode: z.literal("manual") }),
z.strictObject({
@@ -179,12 +216,35 @@ export function parseServiceRevisionSpec(value: unknown): ServiceRevisionSpec {
const legacy = serviceRevisionSpecV2Schema.parse(value);
const specification = {
...legacy,
- schemaVersion: 3 as const,
+ schemaVersion: 4 as const,
+ source:
+ legacy.source.type === "github"
+ ? {
+ ...legacy.source,
+ gitRef: gitBranchRef(legacy.source.branch),
+ }
+ : legacy.source,
placement: { mode: "manual" as const },
};
validateServiceRevisionPorts(specification.ports);
return specification;
}
+ if (version === 3) {
+ const legacy = serviceRevisionSpecV3Schema.parse(value);
+ const specification: ServiceRevisionSpec = {
+ ...legacy,
+ schemaVersion: 4,
+ source:
+ legacy.source.type === "github"
+ ? {
+ ...legacy.source,
+ gitRef: gitBranchRef(legacy.source.branch),
+ }
+ : legacy.source,
+ };
+ validateServiceRevisionPorts(specification.ports);
+ return specification;
+ }
const specification = serviceRevisionSpecSchema.parse(
value,
) as ServiceRevisionSpec;
@@ -245,6 +305,7 @@ export function diffServiceRevisionSpecs(
current.source.repository,
);
add("GitHub branch", previous.source.branch, current.source.branch);
+ add("Git ref", previous.source.gitRef, current.source.gitRef);
add("GitHub commit", previous.source.commitSha, current.source.commitSha);
add(
"GitHub root directory",
diff --git a/web/lib/service-revision-spec.ts b/web/lib/service-revision-spec.ts
index 343dd508..07d0f114 100644
--- a/web/lib/service-revision-spec.ts
+++ b/web/lib/service-revision-spec.ts
@@ -1,4 +1,32 @@
-export const SERVICE_REVISION_SCHEMA_VERSION = 3 as const;
+export const SERVICE_REVISION_SCHEMA_VERSION = 4 as const;
+
+export function isSupportedGitRef(ref: string): boolean {
+ if (/^refs\/pull\/[1-9]\d*\/merge$/.test(ref)) return true;
+ if (!ref.startsWith("refs/heads/")) return false;
+ const branch = ref.slice("refs/heads/".length);
+ const parts = branch.split("/");
+ const invalidCharacter = [...branch].some((character) => {
+ const code = character.charCodeAt(0);
+ return code <= 0x20 || code === 0x7f || "~^:?*[\\".includes(character);
+ });
+ return Boolean(
+ branch &&
+ branch !== "@" &&
+ !branch.endsWith(".") &&
+ !branch.includes("..") &&
+ !branch.includes("@{") &&
+ !invalidCharacter &&
+ parts.every(
+ (part) => part && !part.startsWith(".") && !part.endsWith(".lock"),
+ ),
+ );
+}
+
+export function gitBranchRef(branch: string): string {
+ const ref = `refs/heads/${branch.trim()}`;
+ if (!isSupportedGitRef(ref)) throw new Error("Invalid Git branch");
+ return ref;
+}
export function getDefaultServiceHostname(
name: string,
@@ -146,6 +174,7 @@ export type ServiceRevisionSource =
repository: string;
repositoryId: number | null;
branch: string;
+ gitRef: string;
commitSha: string;
rootDir: string | null;
authentication:
@@ -240,6 +269,12 @@ function validateServiceRevisionSpec(
specification: ServiceRevisionSpec,
allowNoPlacements: boolean,
) {
+ if (
+ specification.source.type === "github" &&
+ !isSupportedGitRef(specification.source.gitRef)
+ ) {
+ throw new Error("Unsupported Git ref");
+ }
validateServiceRevisionPorts(specification.ports);
const totalReplicas = getServiceRevisionTotalReplicas(specification);
diff --git a/web/lib/service-revisions.ts b/web/lib/service-revisions.ts
index 4af8aba7..8d9a0530 100644
--- a/web/lib/service-revisions.ts
+++ b/web/lib/service-revisions.ts
@@ -47,6 +47,7 @@ function assertMatchingGitHubBuildRevision(
commitSha: string;
expectedRepository: string;
expectedBranch: string;
+ gitRef: string;
},
) {
if (revision.serviceId !== input.serviceId) {
@@ -59,6 +60,7 @@ function assertMatchingGitHubBuildRevision(
specification.source.type !== "github" ||
specification.source.repository !== input.expectedRepository ||
specification.source.branch !== input.expectedBranch ||
+ specification.source.gitRef !== input.gitRef ||
specification.source.commitSha !== input.commitSha.toLowerCase()
) {
throw new Error("Service revision idempotency conflict");
@@ -185,6 +187,7 @@ export async function createGitHubBuildServiceRevision(input: {
commitSha: string;
expectedRepository: string;
expectedBranch: string;
+ gitRef: string;
actor: ServiceRevisionActor | null;
}) {
return db.transaction(async (tx) => {
@@ -234,6 +237,7 @@ export async function createGitHubBuildServiceRevision(input: {
repository: currentSource.repository,
repositoryId: repo?.repoId ?? null,
branch: currentSource.branch,
+ gitRef: input.gitRef,
commitSha: input.commitSha,
rootDir: currentSource.rootDir?.trim() || null,
authentication: repo
@@ -377,7 +381,10 @@ export async function cloneActiveRevisionAndQueueSystemRollout(
return db.transaction(async (tx) => {
await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${serviceId}))`);
const activeService = await tx
- .select({ id: services.id })
+ .select({
+ id: services.id,
+ previewOfServiceId: services.previewOfServiceId,
+ })
.from(services)
.where(and(eq(services.id, serviceId), isNull(services.deletedAt)))
.then((rows) => rows[0]);
@@ -422,6 +429,12 @@ export async function cloneActiveRevisionAndQueueSystemRollout(
specification: active.specification,
actor: { type: "system" },
});
+ if (activeService.previewOfServiceId) {
+ await tx
+ .update(services)
+ .set({ previewCurrentRevisionId: revisionId })
+ .where(eq(services.id, serviceId));
+ }
const rolloutId = randomUUID();
await tx.insert(rollouts).values({
id: rolloutId,
@@ -597,7 +610,11 @@ export async function createRolloutForServiceRevision(
)
.then((rows) => rows[0]),
tx
- .select({ id: services.id })
+ .select({
+ id: services.id,
+ previewOfServiceId: services.previewOfServiceId,
+ previewCurrentRevisionId: services.previewCurrentRevisionId,
+ })
.from(services)
.where(and(eq(services.id, serviceId), isNull(services.deletedAt)))
.then((rows) => rows[0]),
@@ -606,6 +623,12 @@ export async function createRolloutForServiceRevision(
if (!activeService) {
return { rolloutId: null, revision, created: false };
}
+ if (
+ activeService.previewOfServiceId &&
+ activeService.previewCurrentRevisionId !== serviceRevisionId
+ ) {
+ return { rolloutId: null, revision, created: false };
+ }
const specification = parseServiceRevisionSpec(revision.specification);
if (specification.source.type === "github" && revision.artifactDeletedAt) {
diff --git a/web/lib/trigger-build.ts b/web/lib/trigger-build.ts
index 06942fbb..33cf490d 100644
--- a/web/lib/trigger-build.ts
+++ b/web/lib/trigger-build.ts
@@ -12,12 +12,13 @@ import {
import { resolveRegistryImageHost } from "@/lib/registry-reference";
import type { ServiceRevisionActor } from "@/lib/service-revision-actor";
import { parseServiceRevisionSpec } from "@/lib/service-revision-changes";
+import { gitBranchRef, isSupportedGitRef } from "@/lib/service-revision-spec";
import {
cloneGitHubBuildServiceRevision,
createGitHubBuildServiceRevision,
} from "@/lib/service-revisions";
-type BuildTrigger = "manual" | "scheduled" | "push";
+type BuildTrigger = "manual" | "scheduled" | "push" | "preview";
const fullCommitSha = /^[0-9a-f]{40}$/i;
type ResolvedBuildInput = {
@@ -28,8 +29,10 @@ type ResolvedBuildInput = {
actor: ServiceRevisionActor;
expectedRepository?: string;
expectedBranch?: string;
+ gitRef?: string;
githubDeploymentId?: number;
idempotencyKey?: string;
+ beforeDispatch?: (serviceRevisionId: string) => Promise;
};
function deterministicRevisionId(key: string): string {
@@ -100,6 +103,8 @@ async function queueResolvedBuild(
? canonicalGitHubRepository(input.expectedRepository)
: source.repository;
const expectedBranch = input.expectedBranch ?? source.branch;
+ const gitRef = input.gitRef ?? gitBranchRef(expectedBranch);
+ if (!isSupportedGitRef(gitRef)) throw new Error("Unsupported Git ref");
if (
source.repository !== expectedRepository ||
source.branch !== expectedBranch
@@ -120,8 +125,10 @@ async function queueResolvedBuild(
commitSha,
expectedRepository,
expectedBranch,
+ gitRef,
actor: input.actor,
});
+ await input.beforeDispatch?.(serviceRevisionId);
const buildRequestId = randomUUID();
await sendBuildTrigger(
@@ -133,6 +140,7 @@ async function queueResolvedBuild(
commitSha,
commitMessage: input.commitMessage.substring(0, 500),
branch: expectedBranch,
+ gitRef,
author: input.author,
actor: input.actor,
githubDeploymentId: input.githubDeploymentId,
@@ -168,6 +176,7 @@ export async function requeueBuildRevisionInternal(input: {
commitSha: specification.source.commitSha,
commitMessage: input.commitMessage.substring(0, 500),
branch: specification.source.branch,
+ gitRef: specification.source.gitRef,
author: input.author,
actor: input.actor,
});
diff --git a/web/tests/autoplacement.test.ts b/web/tests/autoplacement.test.ts
index 8c46c676..7b14c361 100644
--- a/web/tests/autoplacement.test.ts
+++ b/web/tests/autoplacement.test.ts
@@ -78,7 +78,7 @@ describe("automatic placement eligibility diagnostics", () => {
});
describe("persisted revision compatibility", () => {
- it("normalizes v2 revisions to v3 manual intent", () => {
+ it("normalizes v2 revisions to the current manual placement intent", () => {
const parsed = parseServiceRevisionSpec({
schemaVersion: 2,
image: "nginx",
@@ -98,7 +98,7 @@ describe("persisted revision compatibility", () => {
secrets: [],
volumes: [],
});
- expect(parsed.schemaVersion).toBe(3);
+ expect(parsed.schemaVersion).toBe(4);
expect(parsed.placement).toEqual({ mode: "manual" });
});
});
diff --git a/web/tests/build-assignment.test.ts b/web/tests/build-assignment.test.ts
index 60530a99..2b70680d 100644
--- a/web/tests/build-assignment.test.ts
+++ b/web/tests/build-assignment.test.ts
@@ -39,7 +39,7 @@ function specification(
overrides: Partial = {},
): ServiceRevisionSpec {
return {
- schemaVersion: 3,
+ schemaVersion: 4,
placement: { mode: "manual" },
image: "registry/app:revision-1",
source: { type: "image", image: "registry/app:revision-1" },
diff --git a/web/tests/build-claim-route.test.ts b/web/tests/build-claim-route.test.ts
index 3a68a2bf..64f7455a 100644
--- a/web/tests/build-claim-route.test.ts
+++ b/web/tests/build-claim-route.test.ts
@@ -103,4 +103,58 @@ describe("agent build claim", () => {
});
expect(mocks.send).not.toHaveBeenCalled();
});
+
+ it("returns the exact snapshotted Git ref", async () => {
+ mocks.updateResults.push([build]);
+ mocks.selectResults.push(
+ [{ id: "service-1", projectId: "project-1" }],
+ [
+ {
+ specification: {
+ schemaVersion: 4,
+ image: "registry.example.com/project/service:revision-1",
+ source: {
+ type: "github",
+ repository: "https://github.com/acme/app",
+ repositoryId: null,
+ branch: "main",
+ gitRef: "refs/pull/42/merge",
+ commitSha: build.commitSha,
+ rootDir: null,
+ authentication: { type: "anonymous" },
+ },
+ hostname: "service-1",
+ stateful: false,
+ serverless: {
+ enabled: false,
+ sleepAfterSeconds: 300,
+ wakeTimeoutSeconds: 300,
+ },
+ healthCheck: null,
+ startCommand: null,
+ resourceLimits: { cpuCores: null, memoryMb: null },
+ placement: { mode: "manual" },
+ placements: [],
+ ports: [],
+ secrets: [],
+ volumes: [],
+ },
+ },
+ ],
+ );
+
+ const response = await POST(
+ new Request("http://localhost/api/v1/agent/builds/build-amd64", {
+ method: "POST",
+ }) as NextRequest,
+ { params: Promise.resolve({ id: "build-amd64" }) },
+ );
+
+ expect(response.status).toBe(200);
+ expect((await response.json()).build).toMatchObject({
+ commitSha: build.commitSha,
+ branch: "main",
+ gitRef: "refs/pull/42/merge",
+ });
+ });
});
diff --git a/web/tests/build-revision-source.test.ts b/web/tests/build-revision-source.test.ts
index c232878a..dfc1eac5 100644
--- a/web/tests/build-revision-source.test.ts
+++ b/web/tests/build-revision-source.test.ts
@@ -6,6 +6,7 @@ const baseSource = {
repository: "https://github.com/techulus/cloud",
repositoryId: 123,
branch: "main",
+ gitRef: "refs/heads/main",
commitSha: "0123456789abcdef0123456789abcdef01234567",
rootDir: "web",
};
diff --git a/web/tests/build-status-route.test.ts b/web/tests/build-status-route.test.ts
index b568207b..99934fb6 100644
--- a/web/tests/build-status-route.test.ts
+++ b/web/tests/build-status-route.test.ts
@@ -49,6 +49,7 @@ const mocks = vi.hoisted(() => {
enqueueWork: vi.fn(),
send: vi.fn(),
updateGitHubDeploymentStatus: vi.fn(),
+ updateCurrentPreviewGitHubStatus: vi.fn(),
notify: vi.fn(),
createBuildCompleted: vi.fn((data, options) => ({
name: "build/completed",
@@ -66,6 +67,9 @@ vi.mock("@/lib/notifications", () => ({ notify: mocks.notify }));
vi.mock("@/lib/github", () => ({
updateGitHubDeploymentStatus: mocks.updateGitHubDeploymentStatus,
}));
+vi.mock("@/lib/preview-deployments", () => ({
+ updateCurrentPreviewGitHubStatus: mocks.updateCurrentPreviewGitHubStatus,
+}));
vi.mock("@/lib/work-queue", () => ({ enqueueWork: mocks.enqueueWork }));
vi.mock("@/lib/inngest/client", () => ({ inngest: { send: mocks.send } }));
vi.mock("@/lib/inngest/events", () => ({
@@ -158,6 +162,7 @@ describe("agent build status transitions", () => {
mocks.enqueueWork.mockResolvedValue(undefined);
mocks.send.mockResolvedValue(undefined);
mocks.updateGitHubDeploymentStatus.mockResolvedValue(undefined);
+ mocks.updateCurrentPreviewGitHubStatus.mockResolvedValue(true);
mocks.notify.mockResolvedValue(undefined);
});
@@ -286,6 +291,56 @@ describe("agent build status transitions", () => {
);
});
+ it("keeps a completed preview build in progress until rollout readiness", async () => {
+ const completedBuild = build("completed", {
+ githubDeploymentId: 456,
+ imageUri: amd64Image,
+ });
+ const previewSpecification = {
+ ...specification,
+ source: {
+ ...specification.source,
+ authentication: { type: "github_app" as const, installationId: 123 },
+ },
+ };
+ mocks.selectResults.push(
+ [
+ build("pushing", {
+ githubDeploymentId: 456,
+ }),
+ ],
+ [
+ {
+ specification: previewSpecification,
+ projectSlug: "cloud",
+ environmentName: "production",
+ previewOfServiceId: "base-service",
+ },
+ ],
+ [completedBuild],
+ [
+ {
+ id: "service-1",
+ previewOfServiceId: "base-service",
+ previewCurrentRevisionId: "revision-1",
+ },
+ ],
+ );
+ mocks.updateResults.push([completedBuild]);
+
+ expect((await post("completed")).status).toBe(200);
+ expect(mocks.updateCurrentPreviewGitHubStatus).toHaveBeenCalledWith({
+ serviceId: "service-1",
+ serviceRevisionId: "revision-1",
+ expectedDeploymentId: 456,
+ state: "in_progress",
+ description: "Preview image built; preparing deployment",
+ logUrl:
+ "https://cloud.techulus.com/dashboard/projects/cloud/production/services/base-service/previews",
+ });
+ expect(mocks.updateGitHubDeploymentStatus).not.toHaveBeenCalled();
+ });
+
it("does not enqueue manifest work after the service is deleted", async () => {
const completedBuild = build("completed", { imageUri: amd64Image });
mocks.selectResults.push(
diff --git a/web/tests/build-trigger-workflow.test.ts b/web/tests/build-trigger-workflow.test.ts
index c7f25687..d434112c 100644
--- a/web/tests/build-trigger-workflow.test.ts
+++ b/web/tests/build-trigger-workflow.test.ts
@@ -74,6 +74,7 @@ function invoke(commitSha: string) {
commitSha,
commitMessage: "Exact source commit",
branch: "main",
+ gitRef: "refs/heads/main",
author: "octocat",
actor: { type: "system" },
},
diff --git a/web/tests/build-workflow.test.ts b/web/tests/build-workflow.test.ts
index 6de1c9e9..81fdd7da 100644
--- a/web/tests/build-workflow.test.ts
+++ b/web/tests/build-workflow.test.ts
@@ -18,6 +18,7 @@ const mocks = vi.hoisted(() => {
queryResults,
select: vi.fn(() => query(queryResults.shift() ?? [])),
deployServiceRevisionInternal: vi.fn(),
+ updateCurrentPreviewGitHubStatus: vi.fn(),
};
});
@@ -25,6 +26,9 @@ vi.mock("@/db", () => ({ db: { select: mocks.select } }));
vi.mock("@/lib/deploy-service", () => ({
deployServiceRevisionInternal: mocks.deployServiceRevisionInternal,
}));
+vi.mock("@/lib/preview-deployments", () => ({
+ updateCurrentPreviewGitHubStatus: mocks.updateCurrentPreviewGitHubStatus,
+}));
vi.mock("@/lib/inngest/client", () => ({
inngest: {
createFunction: vi.fn(
@@ -122,6 +126,7 @@ describe("revision-first build completion", () => {
rolloutId: "rollout-1",
created: true,
});
+ mocks.updateCurrentPreviewGitHubStatus.mockResolvedValue(false);
});
it("deploys each out-of-order build using its own immutable revision", async () => {
@@ -162,6 +167,12 @@ describe("revision-first build completion", () => {
buildGroupId: "group-failed",
});
expect(mocks.deployServiceRevisionInternal).not.toHaveBeenCalled();
+ expect(mocks.updateCurrentPreviewGitHubStatus).toHaveBeenCalledWith({
+ serviceId: "service-1",
+ serviceRevisionId: "revision-failed",
+ state: "failure",
+ description: "Preview build failed",
+ });
});
it("uses persisted completion when the build event was missed", async () => {
diff --git a/web/tests/deploy-service-revision.test.ts b/web/tests/deploy-service-revision.test.ts
index f13b2505..6dc6949a 100644
--- a/web/tests/deploy-service-revision.test.ts
+++ b/web/tests/deploy-service-revision.test.ts
@@ -28,7 +28,7 @@ vi.mock("@/db", () => ({
})),
},
}));
-vi.mock("@/db/queries", () => ({ getService: mocks.getService }));
+vi.mock("@/db/queries", () => ({ getRuntimeService: mocks.getService }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
vi.mock("@/lib/migrations", () => ({
startMigrationInternal: mocks.startMigrationInternal,
diff --git a/web/tests/github-webhook.test.ts b/web/tests/github-webhook.test.ts
index 8e9fc32e..32dfa64e 100644
--- a/web/tests/github-webhook.test.ts
+++ b/web/tests/github-webhook.test.ts
@@ -28,6 +28,8 @@ const mocks = vi.hoisted(() => {
updateGitHubDeploymentStatus: vi.fn(),
send: vi.fn(),
createBuildTrigger: vi.fn(),
+ createPreviewSync: vi.fn(),
+ createPreviewClose: vi.fn(),
triggerResolvedBuildInternal: vi.fn(),
};
});
@@ -44,6 +46,8 @@ vi.mock("@/lib/inngest/client", () => ({
vi.mock("@/lib/inngest/events", () => ({
inngestEvents: {
buildTrigger: { create: mocks.createBuildTrigger },
+ previewSyncRequested: { create: mocks.createPreviewSync },
+ previewCloseRequested: { create: mocks.createPreviewClose },
},
}));
vi.mock("@/lib/trigger-build", () => ({
@@ -65,6 +69,10 @@ function linkedService({
projectName = "Cloud",
projectSlug = "cloud",
environmentName = "production",
+ previewDeploymentsEnabled = false,
+ previewOfServiceId = null,
+ previewPullRequestNumber = null,
+ stateful = false,
}: {
serviceId: string;
name?: string;
@@ -76,6 +84,10 @@ function linkedService({
projectName?: string;
projectSlug?: string;
environmentName?: string;
+ previewDeploymentsEnabled?: boolean;
+ previewOfServiceId?: string | null;
+ previewPullRequestNumber?: number | null;
+ stateful?: boolean;
}) {
return {
githubRepo: {
@@ -95,6 +107,10 @@ function linkedService({
sourceType,
deletedAt,
githubRootDir: rootDir,
+ previewDeploymentsEnabled,
+ previewOfServiceId,
+ previewPullRequestNumber,
+ stateful,
},
project: { id: "project-1", name: projectName, slug: projectSlug },
environment: { id: "environment-1", name: environmentName },
@@ -126,6 +142,45 @@ function pushRequest(branch = "main") {
});
}
+function pullRequest(
+ action: string,
+ options: {
+ draft?: boolean;
+ merged?: boolean;
+ headRepoId?: number;
+ baseBranch?: string;
+ } = {},
+) {
+ return new NextRequest("http://localhost/api/webhooks/github", {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ "x-github-event": "pull_request",
+ "x-github-delivery": `delivery-${action}`,
+ "x-hub-signature-256": "sha256=test",
+ },
+ body: JSON.stringify({
+ action,
+ number: 42,
+ repository: { id: 456, full_name: "techulus/cloud" },
+ pull_request: {
+ draft: options.draft ?? false,
+ merged: options.merged ?? false,
+ base: {
+ ref: options.baseBranch ?? "main",
+ repo: { id: 456, full_name: "techulus/cloud" },
+ },
+ head: {
+ repo: {
+ id: options.headRepoId ?? 456,
+ full_name: "techulus/cloud",
+ },
+ },
+ },
+ }),
+ });
+}
+
describe("GitHub push webhook", () => {
beforeEach(() => {
mocks.queryResults.length = 0;
@@ -144,6 +199,18 @@ describe("GitHub push webhook", () => {
data,
...options,
}));
+ mocks.createPreviewSync.mockReset();
+ mocks.createPreviewSync.mockImplementation((data, options) => ({
+ name: "preview/sync-requested",
+ data,
+ ...options,
+ }));
+ mocks.createPreviewClose.mockReset();
+ mocks.createPreviewClose.mockImplementation((data, options) => ({
+ name: "preview/close-requested",
+ data,
+ ...options,
+ }));
mocks.triggerResolvedBuildInternal.mockReset();
mocks.triggerResolvedBuildInternal.mockResolvedValue({ status: "queued" });
});
@@ -350,3 +417,102 @@ describe("GitHub push webhook", () => {
);
});
});
+
+describe("GitHub pull request webhook", () => {
+ beforeEach(() => {
+ mocks.queryResults.length = 0;
+ mocks.verifyWebhookSignature.mockReturnValue(true);
+ mocks.send.mockReset();
+ mocks.send.mockResolvedValue(undefined);
+ mocks.createPreviewSync.mockImplementation((data, options) => ({
+ name: "preview/sync-requested",
+ data,
+ ...options,
+ }));
+ mocks.createPreviewClose.mockImplementation((data, options) => ({
+ name: "preview/close-requested",
+ data,
+ ...options,
+ }));
+ });
+
+ it("queues one durable sync for each eligible enabled base service", async () => {
+ mocks.queryResults.push([
+ linkedService({
+ serviceId: "service-a",
+ previewDeploymentsEnabled: true,
+ }),
+ linkedService({
+ serviceId: "service-b",
+ previewDeploymentsEnabled: true,
+ }),
+ linkedService({ serviceId: "service-disabled" }),
+ linkedService({
+ serviceId: "service-stateful",
+ previewDeploymentsEnabled: true,
+ stateful: true,
+ }),
+ ]);
+
+ const response = await POST(pullRequest("opened"));
+
+ expect(response.status).toBe(200);
+ expect(await response.json()).toMatchObject({ ok: true, queued: 2 });
+ expect(mocks.send).toHaveBeenCalledWith([
+ expect.objectContaining({
+ name: "preview/sync-requested",
+ data: { baseServiceId: "service-a", pullRequestNumber: 42 },
+ }),
+ expect.objectContaining({
+ name: "preview/sync-requested",
+ data: { baseServiceId: "service-b", pullRequestNumber: 42 },
+ }),
+ ]);
+ });
+
+ it.each([
+ ["draft", { draft: true }],
+ ["fork", { headRepoId: 999 }],
+ ])("does not deploy a %s pull request", async (_case, options) => {
+ mocks.queryResults.push([
+ linkedService({
+ serviceId: "service-a",
+ previewDeploymentsEnabled: true,
+ }),
+ ]);
+
+ const response = await POST(pullRequest("opened", options));
+
+ expect(response.status).toBe(200);
+ expect(mocks.send).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ ["closed", true, "pull_request_merged"],
+ ["closed", false, "pull_request_closed"],
+ ["converted_to_draft", false, "converted_to_draft"],
+ ])("queues teardown for %s", async (action, merged, reason) => {
+ mocks.queryResults.push([
+ linkedService({
+ serviceId: "preview-42",
+ previewOfServiceId: "service-a",
+ previewPullRequestNumber: 42,
+ }),
+ ]);
+
+ const response = await POST(pullRequest(action, { merged }));
+
+ expect(response.status).toBe(200);
+ expect(mocks.send).toHaveBeenCalledWith([
+ expect.objectContaining({
+ name: "preview/close-requested",
+ data: {
+ baseServiceId: "service-a",
+ pullRequestNumber: 42,
+ reason,
+ verifyWithGitHub: true,
+ },
+ }),
+ ]);
+ });
+});
diff --git a/web/tests/github.test.ts b/web/tests/github.test.ts
index 4a2436b5..85b3dd49 100644
--- a/web/tests/github.test.ts
+++ b/web/tests/github.test.ts
@@ -1,10 +1,48 @@
+import { generateKeyPairSync } from "node:crypto";
import { afterEach, describe, expect, it, vi } from "vitest";
-import { isFullCommitSha, resolveGitHubCommit } from "@/lib/github";
+import {
+ createGitHubDeployment,
+ isFullCommitSha,
+ listOpenGitHubPullRequests,
+ resolveGitHubCommit,
+ resolveGitHubPullRequestMergeRef,
+} from "@/lib/github";
afterEach(() => {
vi.unstubAllGlobals();
+ vi.unstubAllEnvs();
});
+function configureGitHubApp() {
+ const { privateKey } = generateKeyPairSync("rsa", {
+ modulusLength: 2048,
+ privateKeyEncoding: { type: "pkcs8", format: "pem" },
+ publicKeyEncoding: { type: "spki", format: "pem" },
+ });
+ vi.stubEnv("GITHUB_APP_ID", "123");
+ vi.stubEnv(
+ "GITHUB_APP_PRIVATE_KEY",
+ Buffer.from(privateKey).toString("base64"),
+ );
+}
+
+function pullRequest(number: number) {
+ return {
+ number,
+ state: "open",
+ draft: false,
+ merged: false,
+ title: `PR ${number}`,
+ updated_at: "2026-08-16T00:00:00Z",
+ user: { id: number, login: `user-${number}` },
+ base: { ref: "main", repo: { id: 1, full_name: "acme/app" } },
+ head: {
+ sha: number.toString(16).padStart(40, "0"),
+ repo: { id: 1, full_name: "acme/app" },
+ },
+ };
+}
+
describe("GitHub commit SHA validation", () => {
it("accepts only full hexadecimal commit SHAs", () => {
expect(isFullCommitSha("0123456789abcdef0123456789abcdef01234567")).toBe(
@@ -60,3 +98,103 @@ describe("public GitHub branch resolution", () => {
);
});
});
+
+describe("GitHub pull request deployment helpers", () => {
+ it("paginates all open pull requests targeting the configured branch", async () => {
+ configureGitHubApp();
+ const firstPage = Array.from({ length: 100 }, (_, index) =>
+ pullRequest(index + 1),
+ );
+ const fetchMock = vi.fn(
+ async (input: string | URL | Request, _init?: RequestInit) => {
+ const url = String(input);
+ if (url.includes("/access_tokens")) {
+ return Response.json({ token: "installation-token" });
+ }
+ const page = new URL(url).searchParams.get("page");
+ if (page === "1") return Response.json(firstPage);
+ if (page === "2") return Response.json([pullRequest(101)]);
+ throw new Error(`Unexpected GitHub request: ${url}`);
+ },
+ );
+ vi.stubGlobal("fetch", fetchMock);
+
+ await expect(
+ listOpenGitHubPullRequests(10, "acme/app", "main"),
+ ).resolves.toHaveLength(101);
+ expect(fetchMock).toHaveBeenCalledWith(
+ "https://api.github.com/repos/acme/app/pulls?state=open&base=main&per_page=100&page=2",
+ expect.any(Object),
+ );
+ });
+
+ it("fails when the synthetic merge ref is unavailable without using the PR head", async () => {
+ configureGitHubApp();
+ const fetchMock = vi.fn(
+ async (input: string | URL | Request, _init?: RequestInit) => {
+ const url = String(input);
+ if (url.includes("/access_tokens")) {
+ return Response.json({ token: "installation-token" });
+ }
+ return new Response("Not Found", { status: 404 });
+ },
+ );
+ vi.stubGlobal("fetch", fetchMock);
+
+ await expect(
+ resolveGitHubPullRequestMergeRef(10, "acme/app", 42),
+ ).rejects.toThrow("refs/pull/42/merge is unavailable");
+ const commitRequests = fetchMock.mock.calls
+ .map(([input]) => String(input))
+ .filter((url) => url.includes("/commits"));
+ expect(commitRequests).toEqual([
+ "https://api.github.com/repos/acme/app/commits?sha=refs%2Fpull%2F42%2Fmerge&per_page=1",
+ ]);
+ });
+
+ it("creates a transient non-production GitHub deployment", async () => {
+ configureGitHubApp();
+ const fetchMock = vi.fn(
+ async (input: string | URL | Request, _init?: RequestInit) => {
+ const url = String(input);
+ if (url.includes("/access_tokens")) {
+ return Response.json({ token: "installation-token" });
+ }
+ return Response.json({ id: 99 });
+ },
+ );
+ vi.stubGlobal("fetch", fetchMock);
+
+ await expect(
+ createGitHubDeployment(
+ 10,
+ "acme/app",
+ "a".repeat(40),
+ "preview/web/pr-42",
+ "Preview PR #42",
+ {
+ transientEnvironment: true,
+ productionEnvironment: false,
+ payload: { pullRequestNumber: 42 },
+ },
+ ),
+ ).resolves.toBe(99);
+
+ const deploymentCall = fetchMock.mock.calls.find(([input]) =>
+ String(input).endsWith("/repos/acme/app/deployments"),
+ );
+ expect(deploymentCall).toBeDefined();
+ const body = JSON.parse(
+ (deploymentCall?.[1] as RequestInit | undefined)?.body as string,
+ );
+ expect(body).toMatchObject({
+ ref: "a".repeat(40),
+ environment: "preview/web/pr-42",
+ transient_environment: true,
+ production_environment: false,
+ payload: { pullRequestNumber: 42 },
+ auto_merge: false,
+ required_contexts: [],
+ });
+ });
+});
diff --git a/web/tests/inngest-route.test.ts b/web/tests/inngest-route.test.ts
index e532acea..9f927a40 100644
--- a/web/tests/inngest-route.test.ts
+++ b/web/tests/inngest-route.test.ts
@@ -21,6 +21,12 @@ const mocks = vi.hoisted(() => {
oldBackupsCleanup: { id: "old-backups-cleanup" },
onDeploymentFailed: { id: "on-deployment-failed" },
onRestoreFailed: { id: "on-restore-failed" },
+ previewCloseWorkflow: { id: "preview-close-workflow" },
+ previewReconciliation: { id: "preview-reconciliation" },
+ previewServiceReconcileWorkflow: {
+ id: "preview-service-reconcile-workflow",
+ },
+ previewSyncWorkflow: { id: "preview-sync-workflow" },
restoreTriggerWorkflow: { id: "restore-trigger-workflow" },
restoreWorkflow: { id: "restore-workflow" },
registryArtifactRetention: { id: "registry-artifact-retention" },
diff --git a/web/tests/preview-actions.test.ts b/web/tests/preview-actions.test.ts
new file mode 100644
index 00000000..f86bed29
--- /dev/null
+++ b/web/tests/preview-actions.test.ts
@@ -0,0 +1,170 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const mocks = vi.hoisted(() => {
+ const updateValues: Array> = [];
+ const query = {
+ from: vi.fn(() => query),
+ where: vi.fn(() => query),
+ // oxlint-disable-next-line unicorn/no-thenable -- Drizzle query builders are awaitable.
+ then: (
+ resolve: (value: unknown[]) => unknown,
+ reject?: (reason: unknown) => unknown,
+ ) => Promise.resolve([{ id: "repo-1" }]).then(resolve, reject),
+ };
+ return {
+ updateValues,
+ getService: vi.fn(),
+ requireDeveloperRole: vi.fn(),
+ requirePreviewDomain: vi.fn(),
+ send: vi.fn(),
+ createReconcile: vi.fn((data, options) => ({
+ name: "preview/service-reconcile-requested",
+ data,
+ ...options,
+ })),
+ createSync: vi.fn((data, options) => ({
+ name: "preview/sync-requested",
+ data,
+ ...options,
+ })),
+ createClose: vi.fn((data, options) => ({
+ name: "preview/close-requested",
+ data,
+ ...options,
+ })),
+ db: {
+ select: vi.fn(() => query),
+ update: vi.fn(() => ({
+ set: vi.fn((values: Record) => {
+ updateValues.push(values);
+ return { where: vi.fn().mockResolvedValue(undefined) };
+ }),
+ })),
+ },
+ };
+});
+
+vi.mock("@/db", () => ({ db: mocks.db }));
+vi.mock("@/db/queries", () => ({ getService: mocks.getService }));
+vi.mock("@/lib/auth", () => ({
+ requireDeveloperRole: mocks.requireDeveloperRole,
+}));
+vi.mock("@/lib/preview-deployments", () => ({
+ requirePreviewDomain: mocks.requirePreviewDomain,
+}));
+vi.mock("@/lib/inngest/client", () => ({ inngest: { send: mocks.send } }));
+vi.mock("@/lib/inngest/events", () => ({
+ inngestEvents: {
+ previewServiceReconcileRequested: { create: mocks.createReconcile },
+ previewSyncRequested: { create: mocks.createSync },
+ previewCloseRequested: { create: mocks.createClose },
+ },
+}));
+
+import {
+ redeployPreview,
+ removePreview,
+ setPreviewDeploymentsEnabled,
+} from "@/actions/previews";
+
+const service = {
+ id: "service-1",
+ sourceType: "github",
+ stateful: false,
+ previewDeploymentsEnabled: false,
+};
+
+describe("preview deployment actions", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.updateValues.length = 0;
+ mocks.getService.mockResolvedValue(service);
+ mocks.requireDeveloperRole.mockResolvedValue({ user: { id: "user-1" } });
+ mocks.requirePreviewDomain.mockResolvedValue("apps.example.com");
+ mocks.send.mockResolvedValue(undefined);
+ });
+
+ it("requires the developer role before reading a service", async () => {
+ mocks.requireDeveloperRole.mockRejectedValue(new Error("Forbidden"));
+
+ await expect(
+ setPreviewDeploymentsEnabled("service-1", true),
+ ).rejects.toThrow("Forbidden");
+ expect(mocks.getService).not.toHaveBeenCalled();
+ expect(mocks.db.update).not.toHaveBeenCalled();
+ });
+
+ it("enables previews only after the automatic subdomain is ready", async () => {
+ await expect(
+ setPreviewDeploymentsEnabled("service-1", true),
+ ).resolves.toEqual({ success: true });
+
+ expect(mocks.requirePreviewDomain).toHaveBeenCalledOnce();
+ expect(mocks.updateValues).toEqual([{ previewDeploymentsEnabled: true }]);
+ expect(mocks.send).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: "preview/service-reconcile-requested",
+ data: { baseServiceId: "service-1" },
+ }),
+ );
+ });
+
+ it("rejects stateful services without changing configuration", async () => {
+ mocks.getService.mockResolvedValue({ ...service, stateful: true });
+
+ await expect(
+ setPreviewDeploymentsEnabled("service-1", true),
+ ).rejects.toThrow("only for stateless services");
+ expect(mocks.db.update).not.toHaveBeenCalled();
+ expect(mocks.send).not.toHaveBeenCalled();
+ });
+
+ it("does not enable previews when no automatic subdomain is configured", async () => {
+ mocks.requirePreviewDomain.mockRejectedValue(
+ new Error("Automatic Subdomain Domain must be configured"),
+ );
+
+ await expect(
+ setPreviewDeploymentsEnabled("service-1", true),
+ ).rejects.toThrow("Automatic Subdomain Domain must be configured");
+ expect(mocks.db.update).not.toHaveBeenCalled();
+ expect(mocks.send).not.toHaveBeenCalled();
+ });
+
+ it("queues a forced redeploy only for an enabled base service", async () => {
+ mocks.getService.mockResolvedValue({
+ ...service,
+ previewDeploymentsEnabled: true,
+ });
+
+ await expect(redeployPreview("service-1", 42)).resolves.toEqual({
+ success: true,
+ });
+ expect(mocks.send).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: "preview/sync-requested",
+ data: {
+ baseServiceId: "service-1",
+ pullRequestNumber: 42,
+ force: true,
+ },
+ }),
+ );
+ });
+
+ it("queues an explicit preview teardown through the base service", async () => {
+ await expect(removePreview("service-1", 42)).resolves.toEqual({
+ success: true,
+ });
+ expect(mocks.send).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: "preview/close-requested",
+ data: {
+ baseServiceId: "service-1",
+ pullRequestNumber: 42,
+ reason: "removed manually",
+ },
+ }),
+ );
+ });
+});
diff --git a/web/tests/preview-deployments.test.ts b/web/tests/preview-deployments.test.ts
new file mode 100644
index 00000000..04b71ad8
--- /dev/null
+++ b/web/tests/preview-deployments.test.ts
@@ -0,0 +1,258 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const mocks = vi.hoisted(() => {
+ const selectResults: unknown[][] = [];
+ const insertedValues: unknown[] = [];
+ const updatedValues: unknown[] = [];
+ function query(result: unknown[]) {
+ const value = {
+ from: vi.fn(() => value),
+ where: vi.fn(() => value),
+ orderBy: vi.fn(() => value),
+ innerJoin: vi.fn(() => value),
+ // oxlint-disable-next-line unicorn/no-thenable -- Drizzle query builders are awaitable.
+ then: (
+ resolve: (rows: unknown[]) => unknown,
+ reject?: (reason: unknown) => unknown,
+ ) => Promise.resolve(result).then(resolve, reject),
+ };
+ return value;
+ }
+ const tx = {
+ execute: vi.fn().mockResolvedValue(undefined),
+ select: vi.fn(() => query(selectResults.shift() ?? [])),
+ insert: vi.fn(() => ({
+ values: vi.fn((values: unknown) => {
+ insertedValues.push(values);
+ return Promise.resolve();
+ }),
+ })),
+ update: vi.fn(() => ({
+ set: vi.fn((values: unknown) => {
+ updatedValues.push(values);
+ return { where: vi.fn().mockResolvedValue(undefined) };
+ }),
+ })),
+ delete: vi.fn(() => ({ where: vi.fn().mockResolvedValue(undefined) })),
+ };
+ return {
+ selectResults,
+ insertedValues,
+ updatedValues,
+ tx,
+ getSetting: vi.fn(),
+ db: {
+ transaction: vi.fn((operation: (transaction: typeof tx) => unknown) =>
+ operation(tx),
+ ),
+ select: vi.fn(() => query([])),
+ },
+ };
+});
+
+vi.mock("@/db", () => ({ db: mocks.db }));
+vi.mock("@/db/queries", () => ({ getSetting: mocks.getSetting }));
+
+import {
+ createOrRefreshPreviewClone,
+ previewPortConfiguration,
+} from "@/lib/preview-deployments";
+
+const baseService = {
+ id: "12345678-abcd-4321-abcd-1234567890ab",
+ projectId: "project-1",
+ environmentId: "environment-1",
+ name: "Web API",
+ sourceType: "github",
+ githubRepoUrl: "https://github.com/acme/app",
+ githubBranch: "main",
+ githubRootDir: "apps/web",
+ previewDeploymentsEnabled: true,
+ previewOfServiceId: null,
+ stateful: false,
+ placementMode: "manual",
+ healthCheckCmd: "curl -f http://localhost/health",
+ healthCheckInterval: 10,
+ healthCheckTimeout: 5,
+ healthCheckRetries: 3,
+ healthCheckStartPeriod: 30,
+ startCommand: "node server.js",
+ resourceCpuLimit: 1,
+ resourceMemoryLimitMb: 512,
+};
+
+const repo = {
+ installationId: 101,
+ repoId: 202,
+ repoFullName: "acme/app",
+ defaultBranch: "main",
+ deployBranch: "main",
+};
+
+const ports = [
+ {
+ id: "port-http",
+ serviceId: baseService.id,
+ port: 3000,
+ isPublic: true,
+ domain: "app.example.com",
+ protocol: "http" as const,
+ externalPort: null,
+ tlsPassthrough: false,
+ createdAt: new Date(),
+ },
+ {
+ id: "port-tcp",
+ serviceId: baseService.id,
+ port: 5432,
+ isPublic: true,
+ domain: null,
+ protocol: "tcp" as const,
+ externalPort: 15432,
+ tlsPassthrough: true,
+ createdAt: new Date(),
+ },
+];
+
+function queueFactoryReads(existing: unknown[] = []) {
+ mocks.selectResults.push(
+ [baseService],
+ [repo],
+ ports,
+ [
+ {
+ id: "secret-1",
+ serviceId: baseService.id,
+ key: "TOKEN",
+ encryptedValue: "ciphertext",
+ createdAt: new Date(),
+ updatedAt: new Date("2026-08-01T00:00:00Z"),
+ },
+ ],
+ [
+ {
+ serverId: "server-1",
+ count: 2,
+ status: "online",
+ wireguardIp: "10.0.0.1",
+ },
+ ],
+ existing,
+ );
+}
+
+describe("preview service cloning", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.selectResults.length = 0;
+ mocks.insertedValues.length = 0;
+ mocks.updatedValues.length = 0;
+ mocks.getSetting.mockResolvedValue("apps.example.com");
+ process.env.REGISTRY_HOST = "registry.example.com";
+ });
+
+ it("copies ordinary configuration and secrets while enforcing preview policy", async () => {
+ queueFactoryReads();
+
+ const result = await createOrRefreshPreviewClone({
+ baseServiceId: baseService.id,
+ pullRequestNumber: 42,
+ now: new Date("2026-08-16T00:00:00Z"),
+ });
+
+ expect(result).toMatchObject({
+ created: true,
+ primaryUrl: "https://web-api-pr-42-12345678.apps.example.com",
+ });
+ const [service, clonedPorts, placement, clonedSecrets, clonedRepo] =
+ mocks.insertedValues as Array>;
+ expect(service).toMatchObject({
+ projectId: "project-1",
+ environmentId: "environment-1",
+ replicas: 1,
+ stateful: false,
+ autoscalingEnabled: false,
+ serverlessEnabled: false,
+ previewDeploymentsEnabled: false,
+ previewOfServiceId: baseService.id,
+ previewPullRequestNumber: 42,
+ });
+ expect(clonedPorts).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ port: 3000,
+ isPublic: true,
+ domain: "web-api-pr-42-12345678.apps.example.com",
+ }),
+ expect.objectContaining({
+ port: 5432,
+ isPublic: false,
+ externalPort: null,
+ tlsPassthrough: false,
+ }),
+ ]),
+ );
+ expect(placement).toMatchObject({ serverId: "server-1", count: 1 });
+ expect(clonedSecrets).toEqual([
+ expect.objectContaining({ key: "TOKEN", encryptedValue: "ciphertext" }),
+ ]);
+ expect(clonedRepo).toMatchObject({
+ installationId: 101,
+ repoId: 202,
+ autoDeploy: false,
+ });
+ });
+
+ it("refreshes the same clone instead of creating another service", async () => {
+ queueFactoryReads([
+ {
+ id: "preview-service-1",
+ previewOfServiceId: baseService.id,
+ previewPullRequestNumber: 42,
+ },
+ ]);
+
+ await expect(
+ createOrRefreshPreviewClone({
+ baseServiceId: baseService.id,
+ pullRequestNumber: 42,
+ }),
+ ).resolves.toMatchObject({
+ serviceId: "preview-service-1",
+ created: false,
+ });
+ expect(mocks.updatedValues[0]).toMatchObject({
+ previewOfServiceId: baseService.id,
+ previewPullRequestNumber: 42,
+ });
+ expect(mocks.insertedValues).toHaveLength(4);
+ });
+
+ it("makes non-HTTP public ports private", () => {
+ expect(
+ previewPortConfiguration({
+ ports,
+ serviceName: baseService.name,
+ serviceId: baseService.id,
+ pullRequestNumber: 42,
+ domain: "apps.example.com",
+ })[1],
+ ).toMatchObject({
+ isPublic: false,
+ domain: null,
+ externalPort: null,
+ tlsPassthrough: false,
+ });
+ });
+
+ it("rejects stateful services", async () => {
+ mocks.selectResults.push([{ ...baseService, stateful: true }]);
+ await expect(
+ createOrRefreshPreviewClone({
+ baseServiceId: baseService.id,
+ pullRequestNumber: 42,
+ }),
+ ).rejects.toThrow("require a stateless service");
+ expect(mocks.tx.insert).not.toHaveBeenCalled();
+ });
+});
diff --git a/web/tests/preview-policy.test.ts b/web/tests/preview-policy.test.ts
new file mode 100644
index 00000000..feb28a3b
--- /dev/null
+++ b/web/tests/preview-policy.test.ts
@@ -0,0 +1,52 @@
+import { describe, expect, it } from "vitest";
+import { previewHostname } from "@/lib/preview-deployments";
+
+describe("preview hostname policy", () => {
+ it("generates a stable DNS-safe hostname", () => {
+ expect(
+ previewHostname({
+ serviceName: "Web API 🚀",
+ serviceId: "12345678-abcd-4321-abcd-1234567890ab",
+ pullRequestNumber: 42,
+ domain: "Apps.Example.com.",
+ }),
+ ).toBe("web-api-pr-42-12345678.apps.example.com");
+ });
+
+ it("keeps additional public ports unique", () => {
+ const input = {
+ serviceName: "web",
+ serviceId: "12345678-abcd-4321-abcd-1234567890ab",
+ pullRequestNumber: 42,
+ domain: "apps.example.com",
+ };
+
+ expect(previewHostname(input)).toBe("web-pr-42-12345678.apps.example.com");
+ expect(previewHostname({ ...input, portIndex: 1 })).toBe(
+ "web-pr-42-12345678-p2.apps.example.com",
+ );
+ });
+
+ it("truncates only the service name to stay within one DNS label", () => {
+ const hostname = previewHostname({
+ serviceName: "a".repeat(100),
+ serviceId: "12345678-abcd-4321-abcd-1234567890ab",
+ pullRequestNumber: 123,
+ domain: "apps.example.com",
+ });
+
+ expect(hostname.split(".")[0]).toHaveLength(63);
+ expect(hostname).toMatch(/-pr-123-12345678\.apps\.example\.com$/);
+ });
+
+ it("rejects invalid pull request numbers", () => {
+ expect(() =>
+ previewHostname({
+ serviceName: "web",
+ serviceId: "12345678-abcd-4321-abcd-1234567890ab",
+ pullRequestNumber: 0,
+ domain: "apps.example.com",
+ }),
+ ).toThrow("Invalid pull request number");
+ });
+});
diff --git a/web/tests/preview-workflow.test.ts b/web/tests/preview-workflow.test.ts
new file mode 100644
index 00000000..361ec92e
--- /dev/null
+++ b/web/tests/preview-workflow.test.ts
@@ -0,0 +1,465 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const mocks = vi.hoisted(() => {
+ const selectResults: unknown[][] = [];
+ const updateResults: unknown[][] = [];
+ const updateSets: Array> = [];
+
+ function selectQuery(result: unknown[]) {
+ const query = {
+ from: vi.fn(() => query),
+ innerJoin: vi.fn(() => query),
+ where: vi.fn(() => query),
+ // oxlint-disable-next-line unicorn/no-thenable -- Drizzle query builders are awaitable.
+ then: (
+ resolve: (value: unknown[]) => unknown,
+ reject?: (reason: unknown) => unknown,
+ ) => Promise.resolve(result).then(resolve, reject),
+ };
+ return query;
+ }
+
+ function updateQuery(result: unknown[]) {
+ const query = {
+ set: vi.fn((values: Record) => {
+ updateSets.push(values);
+ return query;
+ }),
+ where: vi.fn(() => query),
+ returning: vi.fn(() => query),
+ // oxlint-disable-next-line unicorn/no-thenable -- Drizzle query builders are awaitable.
+ then: (
+ resolve: (value: unknown[]) => unknown,
+ reject?: (reason: unknown) => unknown,
+ ) => Promise.resolve(result).then(resolve, reject),
+ };
+ return query;
+ }
+
+ const db = {
+ select: vi.fn(() => selectQuery(selectResults.shift() ?? [])),
+ update: vi.fn(() => updateQuery(updateResults.shift() ?? [])),
+ execute: vi.fn().mockResolvedValue(undefined),
+ transaction: vi.fn(async (operation: (tx: typeof db) => unknown) =>
+ operation(db),
+ ),
+ };
+
+ return {
+ selectResults,
+ updateResults,
+ updateSets,
+ db,
+ getGitHubPullRequest: vi.fn(),
+ listOpenGitHubPullRequests: vi.fn(),
+ resolveGitHubPullRequestMergeRef: vi.fn(),
+ createGitHubDeployment: vi.fn(),
+ updateGitHubDeploymentStatus: vi.fn(),
+ createOrRefreshPreviewClone: vi.fn(),
+ updateCurrentPreviewGitHubStatus: vi.fn(),
+ cancelPreviewRevisionWork: vi.fn(),
+ deactivatePreviewRuntime: vi.fn(),
+ deletePreviewService: vi.fn(),
+ triggerResolvedBuildInternal: vi.fn(),
+ parseServiceRevisionSpec: vi.fn(),
+ send: vi.fn(),
+ createSyncEvent: vi.fn((data, options) => ({
+ name: "preview/sync-requested",
+ data,
+ ...options,
+ })),
+ };
+});
+
+vi.mock("@/db", () => ({ db: mocks.db }));
+vi.mock("@/lib/github", () => ({
+ getGitHubPullRequest: mocks.getGitHubPullRequest,
+ listOpenGitHubPullRequests: mocks.listOpenGitHubPullRequests,
+ resolveGitHubPullRequestMergeRef: mocks.resolveGitHubPullRequestMergeRef,
+ createGitHubDeployment: mocks.createGitHubDeployment,
+ updateGitHubDeploymentStatus: mocks.updateGitHubDeploymentStatus,
+}));
+vi.mock("@/lib/preview-deployments", () => ({
+ PREVIEW_RECONCILIATION_TTL_MS: 7 * 24 * 60 * 60 * 1000,
+ createOrRefreshPreviewClone: mocks.createOrRefreshPreviewClone,
+ updateCurrentPreviewGitHubStatus: mocks.updateCurrentPreviewGitHubStatus,
+}));
+vi.mock("@/lib/preview-lifecycle", () => ({
+ cancelPreviewRevisionWork: mocks.cancelPreviewRevisionWork,
+ deactivatePreviewRuntime: mocks.deactivatePreviewRuntime,
+ deletePreviewService: mocks.deletePreviewService,
+}));
+vi.mock("@/lib/service-revision-changes", () => ({
+ parseServiceRevisionSpec: mocks.parseServiceRevisionSpec,
+}));
+vi.mock("@/lib/trigger-build", () => ({
+ triggerResolvedBuildInternal: mocks.triggerResolvedBuildInternal,
+}));
+vi.mock("@/lib/inngest/client", () => ({
+ inngest: {
+ createFunction: vi.fn(
+ (_options: unknown, handler: (input: unknown) => unknown) => handler,
+ ),
+ send: mocks.send,
+ },
+}));
+vi.mock("@/lib/inngest/events", () => ({
+ inngestEvents: {
+ previewSyncRequested: {
+ name: "preview/sync-requested",
+ create: mocks.createSyncEvent,
+ },
+ previewCloseRequested: { name: "preview/close-requested" },
+ previewServiceReconcileRequested: {
+ name: "preview/service-reconcile-requested",
+ },
+ },
+}));
+
+import {
+ previewCloseWorkflow,
+ previewServiceReconcileWorkflow,
+ previewSyncWorkflow,
+} from "@/lib/inngest/functions/preview-workflow";
+
+const baseContext = {
+ service: {
+ id: "base-service",
+ name: "Web",
+ previewDeploymentsEnabled: true,
+ previewOfServiceId: null,
+ stateful: false,
+ sourceType: "github" as const,
+ },
+ githubRepo: {
+ installationId: 10,
+ repoId: 20,
+ repoFullName: "acme/app",
+ deployBranch: "main",
+ defaultBranch: "main",
+ },
+};
+
+const pullRequest = {
+ number: 42,
+ state: "open" as const,
+ draft: false,
+ merged: false,
+ title: "Add preview deployments",
+ updatedAt: "2026-08-16T00:00:00Z",
+ user: { id: 30, login: "octocat" },
+ base: {
+ ref: "main",
+ repository: { id: 20, fullName: "acme/app" },
+ },
+ head: {
+ sha: "1".repeat(40),
+ repository: { id: 20, fullName: "acme/app" },
+ },
+};
+
+function step() {
+ return {
+ run: vi.fn(async (_name: string, operation: () => unknown) => operation()),
+ };
+}
+
+function invoke(
+ workflow: unknown,
+ data: Record,
+ eventId = "event-1",
+) {
+ const workflowStep = step();
+ const handler = workflow as (input: {
+ event: { id: string; data: Record };
+ step: ReturnType;
+ }) => Promise;
+ return {
+ result: handler({ event: { id: eventId, data }, step: workflowStep }),
+ step: workflowStep,
+ };
+}
+
+describe("preview lifecycle workflows", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.selectResults.length = 0;
+ mocks.updateResults.length = 0;
+ mocks.updateSets.length = 0;
+ mocks.createOrRefreshPreviewClone.mockResolvedValue({
+ serviceId: "preview-service",
+ created: false,
+ primaryUrl: "https://web-pr-42.example.com",
+ });
+ mocks.deletePreviewService.mockResolvedValue({
+ service: { id: "preview-service", previewGithubDeploymentId: null },
+ githubRepo: baseContext.githubRepo,
+ });
+ mocks.updateCurrentPreviewGitHubStatus.mockResolvedValue(true);
+ mocks.updateGitHubDeploymentStatus.mockResolvedValue(undefined);
+ mocks.cancelPreviewRevisionWork.mockResolvedValue(undefined);
+ mocks.deactivatePreviewRuntime.mockResolvedValue(undefined);
+ mocks.send.mockResolvedValue(undefined);
+ });
+
+ it("ignores a delayed close after the pull request was reopened", async () => {
+ mocks.selectResults.push(
+ [baseContext],
+ [
+ {
+ service: {
+ id: "preview-service",
+ previewGithubDeploymentId: 99,
+ },
+ githubRepo: baseContext.githubRepo,
+ },
+ ],
+ );
+ mocks.getGitHubPullRequest.mockResolvedValue(pullRequest);
+
+ await expect(
+ invoke(previewCloseWorkflow, {
+ baseServiceId: "base-service",
+ pullRequestNumber: 42,
+ reason: "pull_request_closed",
+ verifyWithGitHub: true,
+ }).result,
+ ).resolves.toEqual({ status: "stale" });
+
+ expect(mocks.deletePreviewService).not.toHaveBeenCalled();
+ expect(mocks.send).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: "preview/sync-requested",
+ data: { baseServiceId: "base-service", pullRequestNumber: 42 },
+ }),
+ );
+ });
+
+ it("retries rather than deleting when the authoritative GitHub read fails", async () => {
+ mocks.selectResults.push(
+ [baseContext],
+ [
+ {
+ service: { id: "preview-service" },
+ githubRepo: baseContext.githubRepo,
+ },
+ ],
+ );
+ mocks.getGitHubPullRequest.mockRejectedValue(
+ new Error("GitHub temporarily unavailable"),
+ );
+
+ await expect(
+ invoke(previewCloseWorkflow, {
+ baseServiceId: "base-service",
+ pullRequestNumber: 42,
+ reason: "pull_request_closed",
+ verifyWithGitHub: true,
+ }).result,
+ ).rejects.toThrow("GitHub temporarily unavailable");
+ expect(mocks.deletePreviewService).not.toHaveBeenCalled();
+ });
+
+ it("finishes teardown even when GitHub cannot mark the deployment inactive", async () => {
+ mocks.deletePreviewService.mockResolvedValue({
+ service: { id: "preview-service", previewGithubDeploymentId: 99 },
+ githubRepo: baseContext.githubRepo,
+ });
+ mocks.updateGitHubDeploymentStatus.mockRejectedValue(
+ new Error("GitHub temporarily unavailable"),
+ );
+
+ await expect(
+ invoke(previewCloseWorkflow, {
+ baseServiceId: "base-service",
+ pullRequestNumber: 42,
+ reason: "pull_request_merged",
+ }).result,
+ ).resolves.toEqual({
+ status: "deleted",
+ serviceId: "preview-service",
+ });
+ expect(mocks.deletePreviewService).toHaveBeenCalledWith("base-service", 42);
+ });
+
+ it("deactivates the old runtime when the merge ref is unavailable", async () => {
+ mocks.selectResults.push(
+ [baseContext],
+ [
+ {
+ previewCurrentRevisionId: "revision-old",
+ previewGithubDeploymentId: 98,
+ previewError: null,
+ },
+ ],
+ [{ specification: { source: "old" } }],
+ [
+ {
+ previewCurrentRevisionId: "revision-old",
+ previewGithubDeploymentId: 98,
+ },
+ ],
+ );
+ mocks.getGitHubPullRequest.mockResolvedValue(pullRequest);
+ mocks.parseServiceRevisionSpec.mockReturnValue({
+ source: { type: "github", commitSha: "2".repeat(40) },
+ });
+ mocks.resolveGitHubPullRequestMergeRef.mockRejectedValue(
+ new Error("Merge ref refs/pull/42/merge is unavailable"),
+ );
+
+ await expect(
+ invoke(previewSyncWorkflow, {
+ baseServiceId: "base-service",
+ pullRequestNumber: 42,
+ }).result,
+ ).resolves.toEqual({
+ status: "failed",
+ reason: "merge_ref_unavailable",
+ });
+
+ expect(mocks.deactivatePreviewRuntime).toHaveBeenCalledWith(
+ "preview-service",
+ );
+ expect(mocks.updateGitHubDeploymentStatus).toHaveBeenCalledWith(
+ 10,
+ "acme/app",
+ 98,
+ "inactive",
+ { description: "Preview merge ref is unavailable" },
+ );
+ expect(mocks.triggerResolvedBuildInternal).not.toHaveBeenCalled();
+ });
+
+ it("does not rebuild an unchanged merge commit unless forced", async () => {
+ mocks.selectResults.push(
+ [baseContext],
+ [
+ {
+ previewCurrentRevisionId: "revision-current",
+ previewGithubDeploymentId: 99,
+ previewError: null,
+ },
+ ],
+ [{ specification: { source: "current" } }],
+ );
+ mocks.getGitHubPullRequest.mockResolvedValue(pullRequest);
+ mocks.parseServiceRevisionSpec.mockReturnValue({
+ source: { type: "github", commitSha: "3".repeat(40) },
+ });
+ mocks.resolveGitHubPullRequestMergeRef.mockResolvedValue({
+ gitRef: "refs/pull/42/merge",
+ sha: "3".repeat(40),
+ });
+
+ await expect(
+ invoke(previewSyncWorkflow, {
+ baseServiceId: "base-service",
+ pullRequestNumber: 42,
+ }).result,
+ ).resolves.toEqual({ status: "unchanged", serviceId: "preview-service" });
+ expect(mocks.createGitHubDeployment).not.toHaveBeenCalled();
+ expect(mocks.triggerResolvedBuildInternal).not.toHaveBeenCalled();
+ });
+
+ it("forces an exact merge-ref rebuild and supersedes the old revision", async () => {
+ mocks.selectResults.push(
+ [baseContext],
+ [
+ {
+ previewCurrentRevisionId: "revision-current",
+ previewGithubDeploymentId: 99,
+ previewError: null,
+ },
+ ],
+ [{ specification: { source: "current" } }],
+ );
+ mocks.updateResults.push([{ id: "preview-service" }]);
+ mocks.getGitHubPullRequest.mockResolvedValue(pullRequest);
+ mocks.parseServiceRevisionSpec.mockReturnValue({
+ source: { type: "github", commitSha: "3".repeat(40) },
+ });
+ mocks.resolveGitHubPullRequestMergeRef.mockResolvedValue({
+ gitRef: "refs/pull/42/merge",
+ sha: "3".repeat(40),
+ });
+ mocks.createGitHubDeployment.mockResolvedValue(100);
+ mocks.triggerResolvedBuildInternal.mockImplementation(
+ async (_serviceId, input) => {
+ await input.beforeDispatch("revision-forced");
+ return {
+ buildId: null,
+ serviceRevisionId: "revision-forced",
+ status: "queued",
+ };
+ },
+ );
+
+ await expect(
+ invoke(
+ previewSyncWorkflow,
+ {
+ baseServiceId: "base-service",
+ pullRequestNumber: 42,
+ force: true,
+ },
+ "redeploy-event",
+ ).result,
+ ).resolves.toMatchObject({
+ status: "queued",
+ serviceRevisionId: "revision-forced",
+ deploymentId: 100,
+ });
+
+ expect(mocks.triggerResolvedBuildInternal).toHaveBeenCalledWith(
+ "preview-service",
+ expect.objectContaining({
+ trigger: "preview",
+ commitSha: "3".repeat(40),
+ gitRef: "refs/pull/42/merge",
+ idempotencyKey: expect.stringContaining("redeploy-event"),
+ }),
+ );
+ expect(mocks.cancelPreviewRevisionWork).toHaveBeenCalledWith(
+ "preview-service",
+ "revision-current",
+ );
+ expect(mocks.updateCurrentPreviewGitHubStatus).toHaveBeenCalledWith({
+ serviceId: "preview-service",
+ serviceRevisionId: "revision-forced",
+ expectedDeploymentId: 100,
+ state: "pending",
+ description: "Preview build queued",
+ });
+ });
+
+ it("reconciliation creates missing previews and removes stale ones", async () => {
+ const secondPullRequest = {
+ ...pullRequest,
+ number: 43,
+ updatedAt: "2026-08-16T01:00:00Z",
+ };
+ mocks.selectResults.push(
+ [baseContext],
+ [{ pullRequestNumber: 42 }, { pullRequestNumber: 99 }],
+ );
+ mocks.listOpenGitHubPullRequests.mockResolvedValue([
+ pullRequest,
+ secondPullRequest,
+ ]);
+
+ await expect(
+ invoke(previewServiceReconcileWorkflow, {
+ baseServiceId: "base-service",
+ }).result,
+ ).resolves.toEqual({ status: "queued", count: 2, closed: 1 });
+
+ expect(mocks.deletePreviewService).toHaveBeenCalledWith("base-service", 99);
+ expect(mocks.send).toHaveBeenCalledTimes(2);
+ expect(mocks.send).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: { baseServiceId: "base-service", pullRequestNumber: 43 },
+ }),
+ );
+ });
+});
diff --git a/web/tests/service-commands-route.test.ts b/web/tests/service-commands-route.test.ts
index 0727dc6f..43d70a80 100644
--- a/web/tests/service-commands-route.test.ts
+++ b/web/tests/service-commands-route.test.ts
@@ -148,6 +148,7 @@ describe("service commands route", () => {
it("returns paginated history without internal actor IDs", async () => {
mocks.queryResults.push(
+ [{ id: "service-1" }],
Array.from({ length: 26 }, (_, index) => ({
id: `command-${String(26 - index).padStart(2, "0")}`,
command: "whoami",
diff --git a/web/tests/service-config.test.ts b/web/tests/service-config.test.ts
index ef0c9d33..7afce962 100644
--- a/web/tests/service-config.test.ts
+++ b/web/tests/service-config.test.ts
@@ -59,7 +59,7 @@ describe("service config", () => {
it("converts an immutable revision for pending-change comparisons", () => {
const config = revisionSpecToDeployedConfig(
{
- schemaVersion: 3,
+ schemaVersion: 4,
placement: { mode: "manual" },
image: "nginx",
source: { type: "image", image: "nginx" },
@@ -130,12 +130,13 @@ describe("service config", () => {
] as const) {
const active = revisionSpecToDeployedConfig(
{
- schemaVersion: 3,
+ schemaVersion: 4,
placement: { mode: "manual" },
image,
source: {
...currentSource,
repositoryId: 101,
+ gitRef: "refs/heads/main",
commitSha,
authentication: {
type: "github_app",
diff --git a/web/tests/service-revision-build.test.ts b/web/tests/service-revision-build.test.ts
index 46ae974c..ec4e3fc6 100644
--- a/web/tests/service-revision-build.test.ts
+++ b/web/tests/service-revision-build.test.ts
@@ -65,7 +65,7 @@ import {
function sourceSpecification(): ServiceRevisionSpec {
return {
- schemaVersion: 3,
+ schemaVersion: 4,
placement: { mode: "manual" },
image: "registry.test/project-1/service-1:revision-original",
source: {
@@ -73,6 +73,7 @@ function sourceSpecification(): ServiceRevisionSpec {
repository: "https://github.com/acme/app",
repositoryId: 101,
branch: "main",
+ gitRef: "refs/heads/main",
commitSha: "0123456789abcdef0123456789abcdef01234567",
rootDir: "apps/web",
authentication: { type: "github_app", installationId: 123 },
@@ -161,6 +162,7 @@ describe("GitHub build service revisions", () => {
commitSha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
expectedRepository: "https://github.com/acme/app",
expectedBranch: "main",
+ gitRef: "refs/heads/main",
actor: { type: "system" },
}),
).rejects.toThrow("Service revision idempotency conflict");
diff --git a/web/tests/service-revision-changes.test.ts b/web/tests/service-revision-changes.test.ts
index d28c7776..a0332c8a 100644
--- a/web/tests/service-revision-changes.test.ts
+++ b/web/tests/service-revision-changes.test.ts
@@ -7,7 +7,7 @@ import type { ServiceRevisionSpec } from "@/lib/service-revision-spec";
function spec(): ServiceRevisionSpec {
return {
- schemaVersion: 3,
+ schemaVersion: 4,
placement: { mode: "manual" },
image: "app:v1",
source: { type: "image", image: "app:v1" },
diff --git a/web/tests/service-revision-spec.test.ts b/web/tests/service-revision-spec.test.ts
index 1bae1205..0784e99a 100644
--- a/web/tests/service-revision-spec.test.ts
+++ b/web/tests/service-revision-spec.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
buildServiceRevisionSpec,
+ isSupportedGitRef,
type ServiceRevisionDraft,
} from "@/lib/service-revision-spec";
@@ -219,6 +220,7 @@ describe("service revision specification", () => {
repository: "https://github.com/techulus/cloud",
repositoryId: 123,
branch: "main",
+ gitRef: "refs/heads/main",
commitSha: "0123456789abcdef0123456789abcdef01234567",
rootDir: "web",
authentication: { type: "github_app", installationId: 456 },
@@ -226,12 +228,13 @@ describe("service revision specification", () => {
});
expect(spec).toMatchObject({
- schemaVersion: 3,
+ schemaVersion: 4,
image: "registry.test/project/service:revision-1",
source: {
type: "github",
repository: "https://github.com/techulus/cloud",
branch: "main",
+ gitRef: "refs/heads/main",
commitSha: "0123456789abcdef0123456789abcdef01234567",
rootDir: "web",
authentication: { type: "github_app", installationId: 456 },
@@ -406,4 +409,24 @@ describe("service revision specification", () => {
volumes: [{ name: "data", containerPath: "/data" }],
});
});
+
+ it("accepts only branch and pull-request merge refs that Git can fetch safely", () => {
+ expect(isSupportedGitRef("refs/heads/main")).toBe(true);
+ expect(isSupportedGitRef("refs/heads/feature/preview-deployments")).toBe(
+ true,
+ );
+ expect(isSupportedGitRef("refs/pull/42/merge")).toBe(true);
+
+ for (const ref of [
+ "main",
+ "refs/heads//main",
+ "refs/heads/feature/.hidden",
+ "refs/heads/@",
+ "refs/heads/feature.lock",
+ "refs/pull/0/merge",
+ "refs/pull/42/head",
+ ]) {
+ expect(isSupportedGitRef(ref)).toBe(false);
+ }
+ });
});
diff --git a/web/tests/service-revisions-route.test.ts b/web/tests/service-revisions-route.test.ts
index 378e331a..dcd895cb 100644
--- a/web/tests/service-revisions-route.test.ts
+++ b/web/tests/service-revisions-route.test.ts
@@ -40,7 +40,7 @@ function revisionSpec(
encryptedValue = "cipher",
): ServiceRevisionSpec {
return {
- schemaVersion: 3,
+ schemaVersion: 4,
placement: { mode: "manual" },
image,
source: { type: "image", image },
diff --git a/web/tests/trigger-build.test.ts b/web/tests/trigger-build.test.ts
index 15ed2198..433e6ff7 100644
--- a/web/tests/trigger-build.test.ts
+++ b/web/tests/trigger-build.test.ts
@@ -32,6 +32,7 @@ vi.mock("@/lib/service-revisions", () => ({
import {
requeueBuildRevisionInternal,
triggerBuildInternal,
+ triggerResolvedBuildInternal,
} from "@/lib/trigger-build";
function queryReturning(rows: unknown[]) {
@@ -104,6 +105,7 @@ describe("internal GitHub build trigger", () => {
commitSha: "0123456789abcdef0123456789abcdef01234567",
expectedRepository: "https://github.com/acme/app",
expectedBranch: "production",
+ gitRef: "refs/heads/production",
actor,
}),
);
@@ -115,6 +117,7 @@ describe("internal GitHub build trigger", () => {
commitSha: "0123456789abcdef0123456789abcdef01234567",
commitMessage: "Resolved source commit",
branch: "production",
+ gitRef: "refs/heads/production",
author: "octocat",
actor,
githubDeploymentId: undefined,
@@ -150,6 +153,7 @@ describe("internal GitHub build trigger", () => {
expect.objectContaining({
expectedRepository: "https://github.com/acme/public",
expectedBranch: "preview",
+ gitRef: "refs/heads/preview",
}),
);
expect(mocks.createBuildTrigger).toHaveBeenCalledWith({
@@ -160,6 +164,7 @@ describe("internal GitHub build trigger", () => {
commitSha: "0123456789abcdef0123456789abcdef01234567",
commitMessage: "Resolved source commit",
branch: "preview",
+ gitRef: "refs/heads/preview",
author: "octocat",
actor: { type: "system" },
githubDeploymentId: undefined,
@@ -171,6 +176,49 @@ describe("internal GitHub build trigger", () => {
);
});
+ it("snapshots and dispatches a synthetic pull request merge ref", async () => {
+ mocks.rows = [
+ [
+ {
+ id: "service-1",
+ projectId: "project-1",
+ sourceType: "github",
+ deletedAt: null,
+ },
+ ],
+ [
+ {
+ installationId: 123,
+ repoFullName: "acme/app",
+ deployBranch: "main",
+ defaultBranch: "main",
+ },
+ ],
+ ];
+
+ await triggerResolvedBuildInternal("service-1", {
+ trigger: "preview",
+ commitSha: "0123456789abcdef0123456789abcdef01234567",
+ commitMessage: "Merge pull request #42",
+ actor: { type: "system" },
+ expectedRepository: "https://github.com/acme/app",
+ expectedBranch: "main",
+ gitRef: "refs/pull/42/merge",
+ idempotencyKey: "preview:service-1:42:merge-sha",
+ });
+
+ expect(mocks.createGitHubBuildServiceRevision).toHaveBeenCalledWith(
+ expect.objectContaining({ gitRef: "refs/pull/42/merge" }),
+ );
+ expect(mocks.createBuildTrigger).toHaveBeenCalledWith(
+ expect.objectContaining({
+ trigger: "preview",
+ gitRef: "refs/pull/42/merge",
+ }),
+ { id: "preview:service-1:42:merge-sha" },
+ );
+ });
+
it("rejects a non-GitHub service before queueing work", async () => {
mocks.rows = [
[
@@ -246,6 +294,7 @@ describe("internal GitHub build trigger", () => {
serviceRevisionId: "retry-1",
commitSha: retrySpecification.source.commitSha,
branch: "main",
+ gitRef: "refs/heads/main",
}),
);
});
From 873a2fcedf7ee835bbc8c05126bf1a491363be55 Mon Sep 17 00:00:00 2001
From: Amp
Date: Mon, 17 Aug 2026 09:49:35 +0000
Subject: [PATCH 2/5] Simplify PR preview deployments
Amp-Thread-ID: https://ampcode.com/threads/T-01a003f3-7142-74cd-b819-95472f4a6376
Co-authored-by: Arjun Komath
---
agent/internal/build/build.go | 36 +-
agent/internal/build/build_test.go | 38 ++
docs/architecture.mdx | 31 +-
docs/deployments/github.mdx | 24 +-
web/actions/builds.ts | 61 +--
web/actions/compose.ts | 3 +-
web/actions/crons.ts | 6 +-
web/actions/migrations.ts | 2 -
web/actions/previews.ts | 110 ++---
web/actions/projects.ts | 68 +--
web/actions/secrets.ts | 6 +-
.../[serviceId]/builds/[buildId]/page.tsx | 11 +-
.../services/[serviceId]/builds/page.tsx | 1 +
.../[serviceId]/configuration/page.tsx | 8 +-
.../services/[serviceId]/previews/page.tsx | 58 ---
.../[serviceId]/rollouts/[rolloutId]/page.tsx | 11 +-
web/app/api/builds/[buildId]/logs/route.ts | 24 -
web/app/api/builds/[buildId]/route.ts | 12 +-
web/app/api/navigation/route.ts | 1 -
web/app/api/projects/[id]/services/route.ts | 9 +-
.../api/rollouts/[rolloutId]/logs/route.ts | 24 -
web/app/api/services/[id]/backups/route.ts | 4 -
web/app/api/services/[id]/builds/route.ts | 4 -
web/app/api/services/[id]/commands/route.ts | 12 +-
.../api/services/[id]/github/commits/route.ts | 8 +-
web/app/api/services/[id]/revisions/route.ts | 8 +-
web/app/api/services/[id]/rollouts/route.ts | 4 -
.../[id]/secrets/[secretId]/reveal/route.ts | 4 -
web/app/api/services/[id]/secrets/route.ts | 4 -
.../api/v1/agent/builds/[id]/status/route.ts | 12 +-
.../[environmentId]/services/route.ts | 1 -
web/app/api/webhooks/github/route.ts | 54 ++-
web/components/builds/builds-viewer.tsx | 4 +-
.../details/pull-request-previews-setting.tsx | 109 +++++
.../service/details/source-section.tsx | 13 +
.../service/preview-deployments-page.tsx | 229 ---------
.../service/service-layout-client.tsx | 6 +-
web/db/queries.ts | 26 +-
web/db/schema.ts | 31 +-
web/lib/backup-scheduler.ts | 8 +-
web/lib/backups/trigger-backup.ts | 4 +-
web/lib/deploy-service.ts | 4 +-
web/lib/github.ts | 6 +-
web/lib/inngest/events/preview.ts | 4 +-
web/lib/inngest/functions/crons.ts | 89 ++--
web/lib/inngest/functions/preview-workflow.ts | 439 ++++++++++--------
web/lib/inngest/functions/rollout-helpers.ts | 9 +-
web/lib/inngest/functions/rollout-workflow.ts | 21 +-
web/lib/preview-deployments.ts | 305 +++++-------
web/lib/preview-lifecycle.ts | 245 ++++++----
web/lib/public-api.ts | 8 +-
web/lib/scheduler.ts | 8 +-
web/lib/service-crons.ts | 6 +-
web/lib/service-revision-changes.ts | 4 +-
web/lib/service-revision-spec.ts | 17 +
web/lib/service-revisions.ts | 8 +-
web/lib/trigger-build.ts | 20 +
web/tests/build-status-route.test.ts | 6 +-
web/tests/deploy-service-revision.test.ts | 2 +-
web/tests/github-webhook.test.ts | 78 +++-
web/tests/github.test.ts | 93 ----
web/tests/preview-actions.test.ts | 170 -------
web/tests/preview-deployments.test.ts | 123 +++--
web/tests/preview-policy.test.ts | 52 ---
web/tests/preview-workflow.test.ts | 173 ++++---
web/tests/service-commands-route.test.ts | 1 -
web/tests/trigger-build.test.ts | 50 +-
67 files changed, 1316 insertions(+), 1714 deletions(-)
delete mode 100644 web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/previews/page.tsx
create mode 100644 web/components/service/details/pull-request-previews-setting.tsx
delete mode 100644 web/components/service/preview-deployments-page.tsx
delete mode 100644 web/tests/preview-actions.test.ts
delete mode 100644 web/tests/preview-policy.test.ts
diff --git a/agent/internal/build/build.go b/agent/internal/build/build.go
index f832f688..ac4d95f2 100644
--- a/agent/internal/build/build.go
+++ b/agent/internal/build/build.go
@@ -174,19 +174,39 @@ func (b *Builder) clone(ctx context.Context, config *Config, buildDir string) er
if err != nil {
return fmt.Errorf("git remote setup failed: %s: %w", output, err)
}
- cmd = exec.CommandContext(ctx, "git", "-C", buildDir, "fetch", "--depth", "1", "--no-tags", "origin", config.GitRef)
+ depth := "50"
+ if pullRequestMergeRefPattern.MatchString(config.GitRef) {
+ depth = "1"
+ }
+ cmd = exec.CommandContext(ctx, "git", "-C", buildDir, "fetch", "--depth", depth, "--no-tags", "origin", config.GitRef)
output, err = b.runCommand(cmd, config)
if err != nil {
return fmt.Errorf("git fetch exact ref failed: %s: %w", output, err)
}
- cmd = exec.CommandContext(ctx, "git", "-C", buildDir, "rev-parse", "FETCH_HEAD")
- fetchedCommit, err := b.runCommand(cmd, config)
- if err != nil {
- return fmt.Errorf("git resolve fetched ref failed: %s: %w", fetchedCommit, err)
- }
- if !strings.EqualFold(strings.TrimSpace(fetchedCommit), config.CommitSha) {
- return fmt.Errorf("fetched ref resolved to %s, expected %s", strings.TrimSpace(fetchedCommit), config.CommitSha)
+ if pullRequestMergeRefPattern.MatchString(config.GitRef) {
+ cmd = exec.CommandContext(ctx, "git", "-C", buildDir, "rev-parse", "FETCH_HEAD")
+ fetchedCommit, err := b.runCommand(cmd, config)
+ if err != nil {
+ return fmt.Errorf("git resolve fetched ref failed: %s: %w", fetchedCommit, err)
+ }
+ if !strings.EqualFold(strings.TrimSpace(fetchedCommit), config.CommitSha) {
+ return fmt.Errorf("fetched ref resolved to %s, expected %s", strings.TrimSpace(fetchedCommit), config.CommitSha)
+ }
+ } else {
+ cmd = exec.CommandContext(ctx, "git", "-C", buildDir, "cat-file", "-e", config.CommitSha+"^{commit}")
+ if _, err = b.runCommand(cmd, config); err != nil {
+ b.sendLog(config, "Selected commit is outside the shallow clone; fetching full branch history")
+ cmd = exec.CommandContext(ctx, "git", "-C", buildDir, "fetch", "--unshallow", "--no-tags", "origin", config.GitRef)
+ output, err = b.runCommand(cmd, config)
+ if err != nil {
+ return fmt.Errorf("git fetch full branch history failed: %s: %w", output, err)
+ }
+ cmd = exec.CommandContext(ctx, "git", "-C", buildDir, "cat-file", "-e", config.CommitSha+"^{commit}")
+ if output, err = b.runCommand(cmd, config); err != nil {
+ return fmt.Errorf("selected commit is not available from configured branch: %s: %w", output, err)
+ }
+ }
}
b.sendLog(config, fmt.Sprintf("Checking out commit %s", truncateStr(config.CommitSha, 8)))
diff --git a/agent/internal/build/build_test.go b/agent/internal/build/build_test.go
index c89166de..327fe27d 100644
--- a/agent/internal/build/build_test.go
+++ b/agent/internal/build/build_test.go
@@ -5,6 +5,7 @@ import (
"os"
"os/exec"
"path/filepath"
+ "strconv"
"strings"
"testing"
"time"
@@ -113,6 +114,43 @@ func TestCloneFetchesExactPullRequestMergeRef(t *testing.T) {
}
}
+func TestCloneDeepensConfiguredBranchForSelectedCommit(t *testing.T) {
+ workDir := filepath.Join(t.TempDir(), "work")
+ remoteDir := filepath.Join(t.TempDir(), "remote.git")
+ runGit(t, "init", "--initial-branch", "main", workDir)
+ runGit(t, "-C", workDir, "config", "user.name", "Test User")
+ runGit(t, "-C", workDir, "config", "user.email", "test@example.com")
+
+ var selectedSHA string
+ for i := range 60 {
+ filePath := filepath.Join(workDir, "history.txt")
+ if err := os.WriteFile(filePath, []byte(strconv.Itoa(i)), 0600); err != nil {
+ t.Fatal(err)
+ }
+ runGit(t, "-C", workDir, "add", "history.txt")
+ runGit(t, "-C", workDir, "commit", "-m", "commit "+strconv.Itoa(i))
+ if i == 5 {
+ selectedSHA = runGit(t, "-C", workDir, "rev-parse", "HEAD")
+ }
+ }
+ runGit(t, "clone", "--bare", workDir, remoteDir)
+
+ buildDir := filepath.Join(t.TempDir(), "build")
+ config := &Config{
+ BuildID: "build-1",
+ CloneURL: "file://" + remoteDir,
+ CommitSha: selectedSHA,
+ Branch: "main",
+ GitRef: "refs/heads/main",
+ }
+ if err := NewBuilder(t.TempDir(), nil).clone(context.Background(), config, buildDir); err != nil {
+ t.Fatal(err)
+ }
+ if config.ResolvedCommitSha != selectedSHA {
+ t.Fatalf("resolved commit = %s, want %s", config.ResolvedCommitSha, selectedSHA)
+ }
+}
+
func TestCloneRejectsMovedRef(t *testing.T) {
workDir := filepath.Join(t.TempDir(), "work")
remoteDir := filepath.Join(t.TempDir(), "remote.git")
diff --git a/docs/architecture.mdx b/docs/architecture.mdx
index 4d3187a5..7d69fc28 100644
--- a/docs/architecture.mdx
+++ b/docs/architecture.mdx
@@ -137,20 +137,23 @@ With no configured health check, `healthy` means only that the container is runn
## Pull Request Preview Isolation
-An enabled GitHub service represents each eligible pull request as a hidden
-stateless service copy. The copy gives the preview an independent service ID,
-revision, build, rollout, deployment set, registry path, and generated route,
-while reusing the normal runtime pipeline. This avoids allowing concurrent pull
-request revisions to compete for the base service's single rollout and routing
-state.
-
-The control plane resolves the exact synthetic merge ref, refreshes the service
-copy from current base configuration, and queues an ordinary build. A current
-revision pointer on the copy prevents superseded build and rollout callbacks
-from deploying or reporting success. GitHub reports the transient environment
-as ready only after health and routing convergence complete. Closing, merging,
-or drafting the pull request clears that pointer before runtime and registry
-cleanup.
+An enabled GitHub service represents each eligible pull request as an ordinary,
+visible stateless service in the project's `previews` environment. An existing
+environment with that name is reused; otherwise the control plane creates it.
+The environment remains after its last preview closes and may also contain
+unrelated user-created services.
+
+Each copy has an independent service ID, revision, build, rollout, deployment
+set, registry path, and generated route while reusing the normal runtime
+pipeline. It is copied from the base service only when first created, so later
+user edits to the visible preview service survive pull request updates.
+
+The control plane resolves the exact synthetic merge ref and queues an ordinary
+build. A current revision pointer on the copy prevents superseded build and
+rollout callbacks from deploying or reporting success. GitHub reports the
+transient environment as ready only after health and routing convergence
+complete. Closing, merging, or drafting the pull request clears that pointer
+before runtime and registry cleanup.
## Networking
diff --git a/docs/deployments/github.mdx b/docs/deployments/github.mdx
index cbf8a2ff..96520ea7 100644
--- a/docs/deployments/github.mdx
+++ b/docs/deployments/github.mdx
@@ -50,10 +50,12 @@ GitHub deployment statuses are updated on the commit so you can track progress f
## Pull Request Preview Deployments
-Preview deployments are opt in from a GitHub-backed service's **Previews** tab.
-They require a configured **Automatic Subdomain Domain** and its wildcard DNS
-record. Each eligible pull request gets one hidden, single-replica copy of the
-service and a stable generated HTTPS URL beneath that domain.
+Preview deployments are opt in from a GitHub-backed service's **Configuration**
+page. They require a configured **Automatic Subdomain Domain** and its wildcard
+DNS record. Each eligible pull request gets one visible, single-replica service
+in the project's ordinary `previews` environment and a stable generated HTTPS
+URL beneath that domain. An existing environment named `previews` is reused and
+is left in place when previews close.
A pull request is eligible only when it:
@@ -67,12 +69,14 @@ The preview builds GitHub's synthetic merge result at
configured branch. If GitHub cannot produce that ref because of merge
conflicts, the preview fails rather than building the raw pull request head.
-Preview copies inherit the service's current source configuration, private
-ports, resource limits, placement, health check, start command, and complete
-secret set. They do not copy volumes, backups, schedules, cron jobs,
-autoscaling, serverless sleep, production custom domains, or public TCP/UDP
-routes. Additional preview-specific secret configuration is neither needed nor
-available.
+When first created, preview services inherit the base service's current source
+configuration, private ports, resource limits, placement, health check, start
+command, and complete secret set. They do not copy volumes, backups, schedules,
+cron jobs, autoscaling, serverless sleep, production custom domains, or public
+TCP/UDP routes. Additional preview-specific secret configuration is neither
+needed nor available. Preview services use the normal service pages and may be
+edited like other services, except that volumes remain unavailable. Later pull
+request updates preserve those edits.
New commits replace the preview revision without changing its URL. Converting
the pull request to a draft, closing it, or merging it removes the runtime and
diff --git a/web/actions/builds.ts b/web/actions/builds.ts
index 845dbf49..b09467bc 100644
--- a/web/actions/builds.ts
+++ b/web/actions/builds.ts
@@ -15,12 +15,7 @@ import {
export async function cancelBuild(buildId: string) {
await requireDeveloperRole();
- const [result] = await db
- .select({ build: builds })
- .from(builds)
- .innerJoin(services, eq(services.id, builds.serviceId))
- .where(and(eq(builds.id, buildId), isNull(services.previewOfServiceId)));
- const build = result?.build;
+ const [build] = await db.select().from(builds).where(eq(builds.id, buildId));
if (!build) {
throw new Error("Build not found");
@@ -89,15 +84,12 @@ export async function retryBuild(buildId: string) {
}
const [service] = await db
- .select({ id: services.id })
+ .select({
+ id: services.id,
+ previewOfService: services.previewOfService,
+ })
.from(services)
- .where(
- and(
- eq(services.id, build.serviceId),
- isNull(services.deletedAt),
- isNull(services.previewOfServiceId),
- ),
- );
+ .where(and(eq(services.id, build.serviceId), isNull(services.deletedAt)));
if (!service) {
throw new Error("Service not found");
@@ -107,16 +99,22 @@ export async function retryBuild(buildId: string) {
throw new Error(`Cannot retry build in ${build.status} status`);
}
+ const actor = {
+ type: "user" as const,
+ userId: session.user.id,
+ name: session.user.name,
+ };
+ if (service.previewOfService) {
+ await triggerBuildInternal(build.serviceId, "manual", actor);
+ return { success: true };
+ }
+
await requeueBuildRevisionInternal({
serviceId: build.serviceId,
serviceRevisionId: build.serviceRevisionId,
commitMessage: build.commitMessage ?? "Retry build",
author: build.author ?? undefined,
- actor: {
- type: "user",
- userId: session.user.id,
- name: session.user.name,
- },
+ actor,
});
return { success: true };
@@ -127,18 +125,6 @@ export async function triggerBuild(
trigger: "manual" | "scheduled" = "manual",
) {
const session = await requireDeveloperRole();
- const service = await db
- .select({ id: services.id })
- .from(services)
- .where(
- and(
- eq(services.id, serviceId),
- isNull(services.deletedAt),
- isNull(services.previewOfServiceId),
- ),
- )
- .then((rows) => rows[0]);
- if (!service) throw new Error("Service not found");
const actor = session
? {
type: "user" as const,
@@ -162,17 +148,16 @@ export async function triggerManualBuild(serviceId: string, commitSha: string) {
.select({ service: services, githubRepo: githubRepos })
.from(services)
.innerJoin(githubRepos, eq(githubRepos.serviceId, services.id))
- .where(
- and(
- eq(services.id, serviceId),
- isNull(services.deletedAt),
- isNull(services.previewOfServiceId),
- ),
- );
+ .where(and(eq(services.id, serviceId), isNull(services.deletedAt)));
if (!result) throw new Error("Active GitHub App-connected service not found");
if (result.service.sourceType !== "github") {
throw new Error("Service is not connected to GitHub");
}
+ if (result.service.previewOfService) {
+ throw new Error(
+ "Preview services build their pull request merge ref; use Build to rebuild it",
+ );
+ }
const branch =
result.githubRepo.deployBranch || result.githubRepo.defaultBranch || "main";
diff --git a/web/actions/compose.ts b/web/actions/compose.ts
index eecd28f7..ed7afa64 100644
--- a/web/actions/compose.ts
+++ b/web/actions/compose.ts
@@ -1,6 +1,6 @@
"use server";
-import { and, eq, isNull } from "drizzle-orm";
+import { and, eq } from "drizzle-orm";
import { db } from "@/db";
import { services } from "@/db/schema";
import { requireDeveloperRole } from "@/lib/auth";
@@ -69,7 +69,6 @@ export async function importCompose(
and(
eq(services.projectId, projectId),
eq(services.environmentId, environmentId),
- isNull(services.previewOfServiceId),
),
);
diff --git a/web/actions/crons.ts b/web/actions/crons.ts
index 05436a3f..eb99c38f 100644
--- a/web/actions/crons.ts
+++ b/web/actions/crons.ts
@@ -14,11 +14,7 @@ export async function runServiceCron(cronId: string) {
.from(serviceCrons)
.innerJoin(
services,
- and(
- eq(serviceCrons.serviceId, services.id),
- isNull(services.deletedAt),
- isNull(services.previewOfServiceId),
- ),
+ and(eq(serviceCrons.serviceId, services.id), isNull(services.deletedAt)),
)
.where(eq(serviceCrons.id, cronId))
.limit(1)
diff --git a/web/actions/migrations.ts b/web/actions/migrations.ts
index 5c15d103..56ec46d0 100644
--- a/web/actions/migrations.ts
+++ b/web/actions/migrations.ts
@@ -3,7 +3,6 @@
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { db } from "@/db";
-import { getService } from "@/db/queries";
import { services } from "@/db/schema";
import { requireDeveloperRole } from "@/lib/auth";
import { inngest } from "@/lib/inngest/client";
@@ -11,7 +10,6 @@ import { inngestEvents } from "@/lib/inngest/events";
export async function cancelMigration(serviceId: string) {
await requireDeveloperRole();
- if (!(await getService(serviceId))) throw new Error("Service not found");
await inngest.send(inngestEvents.migrationCancelled.create({ serviceId }));
await db
diff --git a/web/actions/previews.ts b/web/actions/previews.ts
index 58aa1d28..8073b851 100644
--- a/web/actions/previews.ts
+++ b/web/actions/previews.ts
@@ -1,14 +1,17 @@
"use server";
import { randomUUID } from "node:crypto";
-import { and, eq, isNull } from "drizzle-orm";
+import { and, eq, isNull, sql } from "drizzle-orm";
import { db } from "@/db";
import { getService } from "@/db/queries";
import { githubRepos, services } from "@/db/schema";
import { requireDeveloperRole } from "@/lib/auth";
import { inngest } from "@/lib/inngest/client";
import { inngestEvents } from "@/lib/inngest/events";
-import { requirePreviewDomain } from "@/lib/preview-deployments";
+import {
+ ensurePreviewEnvironment,
+ requirePreviewDomain,
+} from "@/lib/preview-deployments";
export async function setPreviewDeploymentsEnabled(
serviceId: string,
@@ -17,6 +20,9 @@ export async function setPreviewDeploymentsEnabled(
await requireDeveloperRole();
const service = await getService(serviceId);
if (!service) throw new Error("Service not found");
+ if (service.previewOfService) {
+ throw new Error("Preview services cannot create nested previews");
+ }
if (service.sourceType !== "github") {
throw new Error("Preview deployments require a GitHub App service");
}
@@ -25,25 +31,47 @@ export async function setPreviewDeploymentsEnabled(
"Preview deployments are available only for stateless services",
);
}
- const repo = await db
- .select({ id: githubRepos.id })
- .from(githubRepos)
- .where(eq(githubRepos.serviceId, serviceId))
- .then((rows) => rows[0]);
- if (!repo)
- throw new Error("Preview deployments require a GitHub App service");
- if (enabled) await requirePreviewDomain();
+ if (enabled) {
+ await requirePreviewDomain();
+ await ensurePreviewEnvironment(service.projectId);
+ }
- await db
- .update(services)
- .set({ previewDeploymentsEnabled: enabled })
- .where(
- and(
- eq(services.id, serviceId),
- isNull(services.deletedAt),
- isNull(services.previewOfServiceId),
- ),
- );
+ await db.transaction(async (tx) => {
+ await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${serviceId}))`);
+ const current = await tx
+ .select({
+ previewOfService: services.previewOfService,
+ sourceType: services.sourceType,
+ stateful: services.stateful,
+ })
+ .from(services)
+ .where(and(eq(services.id, serviceId), isNull(services.deletedAt)))
+ .then((rows) => rows[0]);
+ if (!current) throw new Error("Service not found");
+ if (current.previewOfService) {
+ throw new Error("Preview services cannot create nested previews");
+ }
+ if (current.sourceType !== "github") {
+ throw new Error("Preview deployments require a GitHub App service");
+ }
+ if (current.stateful) {
+ throw new Error(
+ "Preview deployments are available only for stateless services",
+ );
+ }
+ const repo = await tx
+ .select({ id: githubRepos.id })
+ .from(githubRepos)
+ .where(eq(githubRepos.serviceId, serviceId))
+ .then((rows) => rows[0]);
+ if (!repo) {
+ throw new Error("Preview deployments require a GitHub App service");
+ }
+ await tx
+ .update(services)
+ .set({ previewDeploymentsEnabled: enabled })
+ .where(eq(services.id, serviceId));
+ });
await inngest.send(
inngestEvents.previewServiceReconcileRequested.create(
{ baseServiceId: serviceId },
@@ -52,45 +80,3 @@ export async function setPreviewDeploymentsEnabled(
);
return { success: true };
}
-
-export async function redeployPreview(
- baseServiceId: string,
- pullRequestNumber: number,
-) {
- await requireDeveloperRole();
- const service = await getService(baseServiceId);
- if (!service?.previewDeploymentsEnabled) {
- throw new Error("Preview deployments are not enabled for this service");
- }
- await inngest.send(
- inngestEvents.previewSyncRequested.create(
- { baseServiceId, pullRequestNumber, force: true },
- {
- id: `preview-redeploy:${baseServiceId}:${pullRequestNumber}:${randomUUID()}`,
- },
- ),
- );
- return { success: true };
-}
-
-export async function removePreview(
- baseServiceId: string,
- pullRequestNumber: number,
-) {
- await requireDeveloperRole();
- const service = await getService(baseServiceId);
- if (!service) throw new Error("Service not found");
- await inngest.send(
- inngestEvents.previewCloseRequested.create(
- {
- baseServiceId,
- pullRequestNumber,
- reason: "removed manually",
- },
- {
- id: `preview-remove:${baseServiceId}:${pullRequestNumber}:${randomUUID()}`,
- },
- ),
- );
- return { success: true };
-}
diff --git a/web/actions/projects.ts b/web/actions/projects.ts
index 7327dde1..eac09a8f 100644
--- a/web/actions/projects.ts
+++ b/web/actions/projects.ts
@@ -54,7 +54,10 @@ import {
cleanupRegistryArtifactsForService,
prepareRegistryArtifactCleanup,
} from "@/lib/registry-retention";
-import { deletePreviewsForBaseService } from "@/lib/preview-lifecycle";
+import {
+ deletePreviewService,
+ deletePreviewsForBaseService,
+} from "@/lib/preview-lifecycle";
import {
containerPathSchema,
githubRepoUrlSchema,
@@ -124,11 +127,11 @@ export async function deleteProject(
const projectServices = await db
.select()
.from(services)
- .where(
- and(eq(services.projectId, id), isNull(services.previewOfServiceId)),
- );
+ .where(eq(services.projectId, id));
- for (const service of projectServices) {
+ for (const service of projectServices.filter(
+ (service) => !service.previewOfService,
+ )) {
const activeDeployments = await db
.select()
.from(deployments)
@@ -146,7 +149,10 @@ export async function deleteProject(
}
}
- for (const service of projectServices) {
+ for (const service of projectServices.sort(
+ (a, b) =>
+ Number(Boolean(b.previewOfService)) - Number(Boolean(a.previewOfService)),
+ )) {
await hardDeleteService(service.id);
}
await db.transaction(async (tx) => {
@@ -256,16 +262,14 @@ export async function deleteEnvironment(environmentId: string) {
}
const envServices = await db
- .select({ id: services.id })
+ .select({ id: services.id, previewOfService: services.previewOfService })
.from(services)
- .where(
- and(
- eq(services.environmentId, environmentId),
- isNull(services.previewOfServiceId),
- ),
- );
+ .where(eq(services.environmentId, environmentId));
- for (const service of envServices) {
+ for (const service of envServices.sort(
+ (a, b) =>
+ Number(Boolean(b.previewOfService)) - Number(Boolean(a.previewOfService)),
+ )) {
await hardDeleteService(service.id);
}
await db.transaction(async (tx) => {
@@ -414,6 +418,23 @@ export async function createService(input: CreateServiceInput) {
}
async function hardDeleteService(serviceId: string) {
+ const preview = await db
+ .select({
+ previewOfService: services.previewOfService,
+ previewGitRef: services.previewGitRef,
+ })
+ .from(services)
+ .where(eq(services.id, serviceId))
+ .then((rows) => rows[0]);
+ if (preview?.previewOfService && preview.previewGitRef) {
+ const deleted = await deletePreviewService(
+ preview.previewOfService,
+ preview.previewGitRef,
+ );
+ if (!deleted) throw new Error("Preview service not found");
+ return { success: true };
+ }
+
const service = await db.transaction(async (tx) => {
await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${serviceId}))`);
const freshService = await tx
@@ -648,7 +669,7 @@ export async function restoreDeletedService(serviceId: string) {
const service = await db
.select()
.from(services)
- .where(and(eq(services.id, serviceId), isNull(services.previewOfServiceId)))
+ .where(eq(services.id, serviceId))
.then((r) => r[0]);
if (!service || !service.deletedAt) {
@@ -830,7 +851,6 @@ export async function updateServiceHostname(
export async function updateServiceName(serviceId: string, name: string) {
await requireDeveloperRole();
- if (!(await getService(serviceId))) throw new Error("Service not found");
try {
const validatedName = nameSchema.parse(name);
@@ -926,7 +946,6 @@ export async function updateServiceGithubRepo(
export async function deployService(serviceId: string) {
const session = await requireDeveloperRole();
if (!session) throw new Error("Unauthorized");
- if (!(await getService(serviceId))) throw new Error("Service not found");
const actor = {
type: "user",
userId: session.user.id,
@@ -937,7 +956,6 @@ export async function deployService(serviceId: string) {
export async function deleteDeployments(serviceId: string) {
await requireDeveloperRole();
- if (!(await getService(serviceId))) throw new Error("Service not found");
await db.delete(deployments).where(eq(deployments.serviceId, serviceId));
return { success: true };
}
@@ -1092,7 +1110,6 @@ export async function updateServiceServerlessSettings(
) {
await requireDeveloperRole();
const validated = serverlessSettingsSchema.parse(settings);
- if (!(await getService(serviceId))) throw new Error("Service not found");
await db.transaction(async (tx) => {
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${serviceId}))`);
@@ -1570,7 +1587,6 @@ export async function updateServiceConfig(
export async function stopService(serviceId: string) {
await requireDeveloperRole();
- if (!(await getService(serviceId))) throw new Error("Service not found");
const desiredDeployments = await db
.select()
.from(deployments)
@@ -1631,7 +1647,6 @@ export async function restartService(serviceId: string) {
export async function abortRollout(serviceId: string) {
await requireDeveloperRole();
- if (!(await getService(serviceId))) throw new Error("Service not found");
const activeRolloutIds = await db.transaction(async (tx) => {
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${serviceId}))`);
const activeRollouts = await tx
@@ -1727,15 +1742,12 @@ export async function addServiceVolume(
const service = await tx
.select()
.from(services)
- .where(
- and(
- eq(services.id, serviceId),
- isNull(services.deletedAt),
- isNull(services.previewOfServiceId),
- ),
- )
+ .where(and(eq(services.id, serviceId), isNull(services.deletedAt)))
.then((rows) => rows[0]);
if (!service) throw new Error("Service not found");
+ if (service.previewOfService) {
+ throw new Error("Preview services cannot have volumes");
+ }
if (service.previewDeploymentsEnabled) {
throw new Error("Disable preview deployments before adding a volume");
}
diff --git a/web/actions/secrets.ts b/web/actions/secrets.ts
index acef6cab..6972b071 100644
--- a/web/actions/secrets.ts
+++ b/web/actions/secrets.ts
@@ -1,7 +1,7 @@
"use server";
import { randomUUID } from "node:crypto";
-import { and, eq, inArray, isNull } from "drizzle-orm";
+import { and, eq, inArray } from "drizzle-orm";
import { ZodError } from "zod";
import { db } from "@/db";
import { secrets, services } from "@/db/schema";
@@ -25,9 +25,7 @@ export async function createSecretsBatch(
const service = await db
.select()
.from(services)
- .where(
- and(eq(services.id, serviceId), isNull(services.previewOfServiceId)),
- );
+ .where(eq(services.id, serviceId));
if (!service[0]) {
throw new Error("Service not found");
diff --git a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/builds/[buildId]/page.tsx b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/builds/[buildId]/page.tsx
index 350d01b5..bd9f9de5 100644
--- a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/builds/[buildId]/page.tsx
+++ b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/builds/[buildId]/page.tsx
@@ -1,4 +1,4 @@
-import { and, eq, isNull } from "drizzle-orm";
+import { and, eq } from "drizzle-orm";
import { notFound } from "next/navigation";
import { BuildDetails } from "@/components/builds/build-details";
import { SetBreadcrumbs } from "@/components/core/breadcrumb-data";
@@ -28,14 +28,7 @@ async function getBuild(
const service = await db
.select()
.from(services)
- .where(
- and(
- eq(services.id, serviceId),
- eq(services.projectId, project.id),
- isNull(services.deletedAt),
- isNull(services.previewOfServiceId),
- ),
- )
+ .where(and(eq(services.id, serviceId), eq(services.projectId, project.id)))
.then((r) => r[0]);
if (!service) return null;
diff --git a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/builds/page.tsx b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/builds/page.tsx
index 98938adb..94324f88 100644
--- a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/builds/page.tsx
+++ b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/builds/page.tsx
@@ -10,6 +10,7 @@ export default function BuildsPage() {
diff --git a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/configuration/page.tsx b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/configuration/page.tsx
index a97fb142..f66bf2bc 100644
--- a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/configuration/page.tsx
+++ b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/configuration/page.tsx
@@ -96,7 +96,13 @@ export default function ConfigurationPage() {
-
+
diff --git a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/previews/page.tsx b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/previews/page.tsx
deleted file mode 100644
index 4c75d128..00000000
--- a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/previews/page.tsx
+++ /dev/null
@@ -1,58 +0,0 @@
-import { eq } from "drizzle-orm";
-import { notFound } from "next/navigation";
-import { PreviewDeploymentsPage } from "@/components/service/preview-deployments-page";
-import { db } from "@/db";
-import { getService, getSetting } from "@/db/queries";
-import { githubRepos } from "@/db/schema";
-import { getGitHubPullRequest } from "@/lib/github";
-import { listPreviewDeployments } from "@/lib/preview-deployments";
-import { SETTING_KEYS } from "@/lib/settings-keys";
-
-export default async function PreviewsPage({
- params,
-}: {
- params: Promise<{ serviceId: string }>;
-}) {
- const { serviceId } = await params;
- const [service, repo, automaticDomain, previews] = await Promise.all([
- getService(serviceId),
- db
- .select()
- .from(githubRepos)
- .where(eq(githubRepos.serviceId, serviceId))
- .then((rows) => rows[0]),
- getSetting(SETTING_KEYS.AUTO_SUBDOMAIN_DOMAIN),
- listPreviewDeployments(serviceId),
- ]);
- if (!service || service.sourceType !== "github" || !repo) notFound();
-
- const withPullRequests = await Promise.all(
- previews.map(async (preview) => {
- try {
- const pullRequest = await getGitHubPullRequest(
- repo.installationId,
- repo.repoFullName,
- preview.pullRequestNumber,
- );
- return {
- ...preview,
- title: pullRequest.title,
- author: pullRequest.user.login,
- };
- } catch {
- return { ...preview, title: null, author: null };
- }
- }),
- );
-
- return (
-
- );
-}
diff --git a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/rollouts/[rolloutId]/page.tsx b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/rollouts/[rolloutId]/page.tsx
index 1cf946bc..eeb0efa8 100644
--- a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/rollouts/[rolloutId]/page.tsx
+++ b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/rollouts/[rolloutId]/page.tsx
@@ -1,4 +1,4 @@
-import { and, eq, isNull } from "drizzle-orm";
+import { and, eq } from "drizzle-orm";
import { notFound } from "next/navigation";
import { SetBreadcrumbs } from "@/components/core/breadcrumb-data";
import { RolloutDetails } from "@/components/service/details/rollout-details";
@@ -21,14 +21,7 @@ async function getRollout(
const service = await db
.select()
.from(services)
- .where(
- and(
- eq(services.id, serviceId),
- eq(services.projectId, project.id),
- isNull(services.deletedAt),
- isNull(services.previewOfServiceId),
- ),
- )
+ .where(and(eq(services.id, serviceId), eq(services.projectId, project.id)))
.then((r) => r[0]);
if (!service) return null;
diff --git a/web/app/api/builds/[buildId]/logs/route.ts b/web/app/api/builds/[buildId]/logs/route.ts
index a620462e..eb68ca32 100644
--- a/web/app/api/builds/[buildId]/logs/route.ts
+++ b/web/app/api/builds/[buildId]/logs/route.ts
@@ -1,8 +1,4 @@
-import { and, eq, isNull } from "drizzle-orm";
import { type NextRequest, NextResponse } from "next/server";
-import { db } from "@/db";
-import { builds, services } from "@/db/schema";
-import { requireRequestSession } from "@/lib/api-auth";
import { invalidLogQueryResponse, normalizeLogSearch } from "@/lib/log-query";
import { isLoggingEnabled, queryLogsByBuild } from "@/lib/victoria-logs";
@@ -10,27 +6,7 @@ export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ buildId: string }> },
) {
- const sessionResult = await requireRequestSession(request);
- if (!sessionResult.ok) return sessionResult.response;
-
const { buildId } = await params;
- const build = await db
- .select({ id: builds.id })
- .from(builds)
- .innerJoin(
- services,
- and(
- eq(builds.serviceId, services.id),
- isNull(services.deletedAt),
- isNull(services.previewOfServiceId),
- ),
- )
- .where(eq(builds.id, buildId))
- .then((rows) => rows[0]);
- if (!build) {
- return NextResponse.json({ error: "Build not found" }, { status: 404 });
- }
-
let search: string | undefined;
try {
search = normalizeLogSearch(request.nextUrl.searchParams.get("q"));
diff --git a/web/app/api/builds/[buildId]/route.ts b/web/app/api/builds/[buildId]/route.ts
index 78e1e063..16237543 100644
--- a/web/app/api/builds/[buildId]/route.ts
+++ b/web/app/api/builds/[buildId]/route.ts
@@ -1,7 +1,7 @@
-import { and, eq, isNull } from "drizzle-orm";
+import { eq } from "drizzle-orm";
import { type NextRequest, NextResponse } from "next/server";
import { db } from "@/db";
-import { builds, servers, services } from "@/db/schema";
+import { builds, servers } from "@/db/schema";
import { requireRequestSession } from "@/lib/api-auth";
export async function GET(
@@ -20,14 +20,6 @@ export async function GET(
})
.from(builds)
.leftJoin(servers, eq(builds.claimedBy, servers.id))
- .innerJoin(
- services,
- and(
- eq(builds.serviceId, services.id),
- isNull(services.deletedAt),
- isNull(services.previewOfServiceId),
- ),
- )
.where(eq(builds.id, buildId));
if (!buildData) {
diff --git a/web/app/api/navigation/route.ts b/web/app/api/navigation/route.ts
index b7e7fadc..325eab69 100644
--- a/web/app/api/navigation/route.ts
+++ b/web/app/api/navigation/route.ts
@@ -34,7 +34,6 @@ export async function GET() {
eq(services.projectId, projects.id),
eq(services.environmentId, environments.id),
isNull(services.deletedAt),
- isNull(services.previewOfServiceId),
),
)
.orderBy(projects.name, environments.name, services.name),
diff --git a/web/app/api/projects/[id]/services/route.ts b/web/app/api/projects/[id]/services/route.ts
index 2738f72f..9ad6b75f 100644
--- a/web/app/api/projects/[id]/services/route.ts
+++ b/web/app/api/projects/[id]/services/route.ts
@@ -80,7 +80,6 @@ export async function PATCH(
eq(services.projectId, projectId),
inArray(services.id, serviceIds),
isNull(services.deletedAt),
- isNull(services.previewOfServiceId),
),
);
@@ -106,7 +105,6 @@ export async function PATCH(
eq(services.id, position.serviceId),
eq(services.projectId, projectId),
isNull(services.deletedAt),
- isNull(services.previewOfServiceId),
),
)
.returning({
@@ -160,13 +158,8 @@ export async function GET(
eq(services.projectId, projectId),
eq(services.environmentId, environmentId),
isNull(services.deletedAt),
- isNull(services.previewOfServiceId),
)
- : and(
- eq(services.projectId, projectId),
- isNull(services.deletedAt),
- isNull(services.previewOfServiceId),
- ),
+ : and(eq(services.projectId, projectId), isNull(services.deletedAt)),
)
.orderBy(services.createdAt);
const cronRows =
diff --git a/web/app/api/rollouts/[rolloutId]/logs/route.ts b/web/app/api/rollouts/[rolloutId]/logs/route.ts
index 0634e3d0..664c12bf 100644
--- a/web/app/api/rollouts/[rolloutId]/logs/route.ts
+++ b/web/app/api/rollouts/[rolloutId]/logs/route.ts
@@ -1,8 +1,4 @@
-import { and, eq, isNull } from "drizzle-orm";
import { type NextRequest, NextResponse } from "next/server";
-import { db } from "@/db";
-import { rollouts, services } from "@/db/schema";
-import { requireRequestSession } from "@/lib/api-auth";
import { invalidLogQueryResponse, normalizeLogSearch } from "@/lib/log-query";
import { isLoggingEnabled, queryLogsByRollout } from "@/lib/victoria-logs";
@@ -10,27 +6,7 @@ export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ rolloutId: string }> },
) {
- const sessionResult = await requireRequestSession(request);
- if (!sessionResult.ok) return sessionResult.response;
-
const { rolloutId } = await params;
- const rollout = await db
- .select({ id: rollouts.id })
- .from(rollouts)
- .innerJoin(
- services,
- and(
- eq(rollouts.serviceId, services.id),
- isNull(services.deletedAt),
- isNull(services.previewOfServiceId),
- ),
- )
- .where(eq(rollouts.id, rolloutId))
- .then((rows) => rows[0]);
- if (!rollout) {
- return NextResponse.json({ error: "Rollout not found" }, { status: 404 });
- }
-
let search: string | undefined;
try {
search = normalizeLogSearch(request.nextUrl.searchParams.get("q"));
diff --git a/web/app/api/services/[id]/backups/route.ts b/web/app/api/services/[id]/backups/route.ts
index 5ee49a94..6d265aa0 100644
--- a/web/app/api/services/[id]/backups/route.ts
+++ b/web/app/api/services/[id]/backups/route.ts
@@ -1,7 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { desc, eq } from "drizzle-orm";
import { db } from "@/db";
-import { getService } from "@/db/queries";
import { volumeBackups, servers } from "@/db/schema";
export async function GET(
@@ -10,9 +9,6 @@ export async function GET(
) {
try {
const { id: serviceId } = await params;
- if (!(await getService(serviceId))) {
- return NextResponse.json({ error: "Service not found" }, { status: 404 });
- }
const backups = await db
.select({
diff --git a/web/app/api/services/[id]/builds/route.ts b/web/app/api/services/[id]/builds/route.ts
index b3988747..2e9c617e 100644
--- a/web/app/api/services/[id]/builds/route.ts
+++ b/web/app/api/services/[id]/builds/route.ts
@@ -1,7 +1,6 @@
import { desc, eq, getTableColumns } from "drizzle-orm";
import { type NextRequest, NextResponse } from "next/server";
import { db } from "@/db";
-import { getService } from "@/db/queries";
import { builds, servers } from "@/db/schema";
import { requireRequestSession } from "@/lib/api-auth";
@@ -13,9 +12,6 @@ export async function GET(
if (!sessionResult.ok) return sessionResult.response;
const { id: serviceId } = await params;
- if (!(await getService(serviceId))) {
- return NextResponse.json({ error: "Service not found" }, { status: 404 });
- }
const buildsList = await db
.select({
diff --git a/web/app/api/services/[id]/commands/route.ts b/web/app/api/services/[id]/commands/route.ts
index f992731e..96ea24f7 100644
--- a/web/app/api/services/[id]/commands/route.ts
+++ b/web/app/api/services/[id]/commands/route.ts
@@ -1,7 +1,6 @@
import { randomUUID } from "node:crypto";
import { and, desc, eq, inArray, isNull, lt, or, sql } from "drizzle-orm";
import { db } from "@/db";
-import { getService } from "@/db/queries";
import { deployments, servers, serviceCommands, services } from "@/db/schema";
import { requireRequestDeveloperRole } from "@/lib/api-auth";
import { observedReadyPhases } from "@/lib/deployment-status";
@@ -22,9 +21,6 @@ export async function GET(
if (!auth.ok) return auth.response;
const { id: serviceId } = await params;
- if (!(await getService(serviceId))) {
- return Response.json({ error: "Service not found" }, { status: 404 });
- }
const cursorValue = new URL(request.url).searchParams.get("cursor");
const cursor = decodeTimestampCursor(cursorValue);
if (cursorValue && !cursor) {
@@ -119,13 +115,7 @@ export async function POST(
const service = await db
.select({ id: services.id })
.from(services)
- .where(
- and(
- eq(services.id, serviceId),
- isNull(services.deletedAt),
- isNull(services.previewOfServiceId),
- ),
- )
+ .where(and(eq(services.id, serviceId), isNull(services.deletedAt)))
.then((rows) => rows[0]);
if (!service) {
return Response.json({ error: "Service not found" }, { status: 404 });
diff --git a/web/app/api/services/[id]/github/commits/route.ts b/web/app/api/services/[id]/github/commits/route.ts
index c0e078bf..efc39087 100644
--- a/web/app/api/services/[id]/github/commits/route.ts
+++ b/web/app/api/services/[id]/github/commits/route.ts
@@ -16,13 +16,7 @@ export async function GET(
.select({ service: services, githubRepo: githubRepos })
.from(services)
.innerJoin(githubRepos, eq(githubRepos.serviceId, services.id))
- .where(
- and(
- eq(services.id, serviceId),
- isNull(services.deletedAt),
- isNull(services.previewOfServiceId),
- ),
- );
+ .where(and(eq(services.id, serviceId), isNull(services.deletedAt)));
if (!result) {
return Response.json(
diff --git a/web/app/api/services/[id]/revisions/route.ts b/web/app/api/services/[id]/revisions/route.ts
index cdc2444f..c3f22b56 100644
--- a/web/app/api/services/[id]/revisions/route.ts
+++ b/web/app/api/services/[id]/revisions/route.ts
@@ -25,13 +25,7 @@ export async function GET(
const service = await db
.select({ id: services.id })
.from(services)
- .where(
- and(
- eq(services.id, serviceId),
- isNull(services.deletedAt),
- isNull(services.previewOfServiceId),
- ),
- )
+ .where(and(eq(services.id, serviceId), isNull(services.deletedAt)))
.then((rows) => rows[0]);
if (!service) {
return Response.json({ message: "Service not found" }, { status: 404 });
diff --git a/web/app/api/services/[id]/rollouts/route.ts b/web/app/api/services/[id]/rollouts/route.ts
index e396e83d..e997e9ed 100644
--- a/web/app/api/services/[id]/rollouts/route.ts
+++ b/web/app/api/services/[id]/rollouts/route.ts
@@ -1,7 +1,6 @@
import { desc, eq, inArray } from "drizzle-orm";
import { type NextRequest, NextResponse } from "next/server";
import { db } from "@/db";
-import { getService } from "@/db/queries";
import { builds, rollouts } from "@/db/schema";
export async function GET(
@@ -9,9 +8,6 @@ export async function GET(
{ params }: { params: Promise<{ id: string }> },
) {
const { id: serviceId } = await params;
- if (!(await getService(serviceId))) {
- return NextResponse.json({ error: "Service not found" }, { status: 404 });
- }
const rolloutsList = await db
.select()
diff --git a/web/app/api/services/[id]/secrets/[secretId]/reveal/route.ts b/web/app/api/services/[id]/secrets/[secretId]/reveal/route.ts
index 121e862b..a42d2863 100644
--- a/web/app/api/services/[id]/secrets/[secretId]/reveal/route.ts
+++ b/web/app/api/services/[id]/secrets/[secretId]/reveal/route.ts
@@ -1,6 +1,5 @@
import { and, eq } from "drizzle-orm";
import { db } from "@/db";
-import { getService } from "@/db/queries";
import { secrets } from "@/db/schema";
import { requireRequestDeveloperRole } from "@/lib/api-auth";
import { decryptSecret } from "@/lib/crypto";
@@ -16,9 +15,6 @@ export async function POST(
}
const { id: serviceId, secretId } = await params;
- if (!(await getService(serviceId))) {
- return Response.json({ error: "Service not found" }, { status: 404 });
- }
const secret = await db
.select({ encryptedValue: secrets.encryptedValue })
diff --git a/web/app/api/services/[id]/secrets/route.ts b/web/app/api/services/[id]/secrets/route.ts
index df428516..3ac004da 100644
--- a/web/app/api/services/[id]/secrets/route.ts
+++ b/web/app/api/services/[id]/secrets/route.ts
@@ -1,7 +1,6 @@
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { db } from "@/db";
-import { getService } from "@/db/queries";
import { secrets } from "@/db/schema";
import { eq } from "drizzle-orm";
@@ -18,9 +17,6 @@ export async function GET(
}
const { id: serviceId } = await params;
- if (!(await getService(serviceId))) {
- return Response.json({ error: "Service not found" }, { status: 404 });
- }
const secretsList = await db
.select({
diff --git a/web/app/api/v1/agent/builds/[id]/status/route.ts b/web/app/api/v1/agent/builds/[id]/status/route.ts
index b91e3e82..85965c84 100644
--- a/web/app/api/v1/agent/builds/[id]/status/route.ts
+++ b/web/app/api/v1/agent/builds/[id]/status/route.ts
@@ -119,7 +119,7 @@ export async function POST(
specification: serviceRevisions.specification,
projectSlug: projects.slug,
environmentName: environments.name,
- previewOfServiceId: services.previewOfServiceId,
+ previewOfService: services.previewOfService,
})
.from(serviceRevisions)
.innerJoin(services, eq(serviceRevisions.serviceId, services.id))
@@ -261,10 +261,10 @@ export async function POST(
) {
try {
const baseUrl = process.env.APP_URL || "https://cloud.techulus.com";
- const logUrl = revision.previewOfServiceId
- ? `${baseUrl}/dashboard/projects/${revision.projectSlug}/${revision.environmentName}/services/${revision.previewOfServiceId}/previews`
+ const logUrl = revision.previewOfService
+ ? `${baseUrl}/dashboard/projects/${revision.projectSlug}/${revision.environmentName}/services/${build.serviceId}/builds/${buildId}`
: `${baseUrl}/builds/${buildId}/logs`;
- if (revision.previewOfServiceId) {
+ if (revision.previewOfService) {
await updateCurrentPreviewGitHubStatus({
serviceId: build.serviceId,
serviceRevisionId: build.serviceRevisionId,
@@ -394,7 +394,7 @@ export async function POST(
const activeService = await tx
.select({
id: services.id,
- previewOfServiceId: services.previewOfServiceId,
+ previewOfService: services.previewOfService,
previewCurrentRevisionId: services.previewCurrentRevisionId,
})
.from(services)
@@ -405,7 +405,7 @@ export async function POST(
.then((rows) => rows[0]);
if (
!activeService ||
- (activeService.previewOfServiceId &&
+ (activeService.previewOfService &&
activeService.previewCurrentRevisionId !== build.serviceRevisionId)
) {
return;
diff --git a/web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/route.ts b/web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/route.ts
index c9e75b69..310cf593 100644
--- a/web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/route.ts
+++ b/web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/route.ts
@@ -52,7 +52,6 @@ export async function GET(
eq(services.projectId, projectId),
eq(services.environmentId, environmentId),
isNull(services.deletedAt),
- isNull(services.previewOfServiceId),
page.cursor
? or(
gt(services.name, page.cursor.name),
diff --git a/web/app/api/webhooks/github/route.ts b/web/app/api/webhooks/github/route.ts
index 3ed7de7d..233230ae 100644
--- a/web/app/api/webhooks/github/route.ts
+++ b/web/app/api/webhooks/github/route.ts
@@ -1,5 +1,5 @@
import { createHash } from "node:crypto";
-import { and, eq, isNull } from "drizzle-orm";
+import { and, eq, inArray, isNull } from "drizzle-orm";
import { type NextRequest, NextResponse } from "next/server";
import { db } from "@/db";
import {
@@ -18,6 +18,7 @@ import {
import { inngest } from "@/lib/inngest/client";
import { inngestEvents } from "@/lib/inngest/events";
import { deletePreviewsForGitHubInstallation } from "@/lib/preview-lifecycle";
+import { pullRequestMergeRef } from "@/lib/service-revision-spec";
import { triggerResolvedBuildInternal } from "@/lib/trigger-build";
type InstallationPayload = {
@@ -103,6 +104,7 @@ async function handleInstallationEvent(payload: InstallationPayload) {
await deletePreviewsForGitHubInstallation(
installation.id,
"GitHub installation deleted",
+ { removeRepositoryLinks: true },
);
await db
.delete(githubInstallations)
@@ -115,6 +117,14 @@ async function handleInstallationEvent(payload: InstallationPayload) {
await deletePreviewsForGitHubInstallation(
installation.id,
"GitHub installation suspended",
+ { githubDeploymentCleanup: "defer" },
+ );
+ }
+ if (action === "unsuspend") {
+ await deletePreviewsForGitHubInstallation(
+ installation.id,
+ "GitHub installation unsuspended",
+ { githubDeploymentCleanup: "report" },
);
}
@@ -145,12 +155,7 @@ async function handlePushEvent(payload: PushPayload) {
.innerJoin(services, eq(githubRepos.serviceId, services.id))
.innerJoin(projects, eq(services.projectId, projects.id))
.innerJoin(environments, eq(services.environmentId, environments.id))
- .where(
- and(
- eq(githubRepos.repoId, repository.id),
- isNull(services.previewOfServiceId),
- ),
- );
+ .where(eq(githubRepos.repoId, repository.id));
if (linkedServices.length === 0) {
return NextResponse.json({
@@ -328,16 +333,20 @@ async function handlePullRequestEvent(
pullRequestSyncActions.has(payload.action) &&
!payload.pull_request.draft &&
sameRepository;
+ const previewGitRef = pullRequestMergeRef(payload.number);
const events: Array<
| ReturnType
| ReturnType
> = [];
const syncedBaseServiceIds = new Set();
+ const linkedBaseServiceIds = linkedServices.flatMap(({ service }) =>
+ !service.previewOfService && !service.deletedAt ? [service.id] : [],
+ );
if (shouldSync) {
for (const { githubRepo, service } of linkedServices) {
if (
- service.previewOfServiceId ||
+ service.previewOfService ||
service.deletedAt ||
service.sourceType !== "github" ||
service.stateful ||
@@ -352,7 +361,7 @@ async function handlePullRequestEvent(
inngestEvents.previewSyncRequested.create(
{
baseServiceId: service.id,
- pullRequestNumber: payload.number,
+ previewGitRef,
},
{
id: `github-pr-sync:${deliveryId}:${service.id}:${payload.number}`,
@@ -362,12 +371,23 @@ async function handlePullRequestEvent(
}
}
- for (const { service: clone } of linkedServices) {
+ const clones =
+ linkedBaseServiceIds.length > 0
+ ? await db
+ .select()
+ .from(services)
+ .where(
+ and(
+ inArray(services.previewOfService, linkedBaseServiceIds),
+ eq(services.previewGitRef, previewGitRef),
+ isNull(services.deletedAt),
+ ),
+ )
+ : [];
+ for (const clone of clones) {
if (
- !clone.previewOfServiceId ||
- clone.previewPullRequestNumber !== payload.number ||
- clone.deletedAt ||
- syncedBaseServiceIds.has(clone.previewOfServiceId)
+ !clone.previewOfService ||
+ syncedBaseServiceIds.has(clone.previewOfService)
) {
continue;
}
@@ -384,13 +404,13 @@ async function handlePullRequestEvent(
events.push(
inngestEvents.previewCloseRequested.create(
{
- baseServiceId: clone.previewOfServiceId,
- pullRequestNumber: payload.number,
+ baseServiceId: clone.previewOfService,
+ previewGitRef,
reason,
verifyWithGitHub: true,
},
{
- id: `github-pr-close:${deliveryId}:${clone.previewOfServiceId}:${payload.number}`,
+ id: `github-pr-close:${deliveryId}:${clone.previewOfService}:${payload.number}`,
},
),
);
diff --git a/web/components/builds/builds-viewer.tsx b/web/components/builds/builds-viewer.tsx
index dcf773d6..ee3902ab 100644
--- a/web/components/builds/builds-viewer.tsx
+++ b/web/components/builds/builds-viewer.tsx
@@ -148,11 +148,13 @@ function BuildStatusBadge({
export function BuildsViewer({
serviceId,
hasGithubAppRepo,
+ isPreview,
projectSlug,
envName,
}: {
serviceId: string;
hasGithubAppRepo: boolean;
+ isPreview: boolean;
projectSlug: string;
envName: string;
}) {
@@ -188,7 +190,7 @@ export function BuildsViewer({
);
const handleTriggerBuild = async () => {
- if (hasGithubAppRepo) {
+ if (hasGithubAppRepo && !isPreview) {
setSelectedSha(null);
setIsCommitDialogOpen(true);
return;
diff --git a/web/components/service/details/pull-request-previews-setting.tsx b/web/components/service/details/pull-request-previews-setting.tsx
new file mode 100644
index 00000000..3044605f
--- /dev/null
+++ b/web/components/service/details/pull-request-previews-setting.tsx
@@ -0,0 +1,109 @@
+"use client";
+
+import Link from "next/link";
+import { useTransition } from "react";
+import { toast } from "sonner";
+import { setPreviewDeploymentsEnabled } from "@/actions/previews";
+import { Label } from "@/components/ui/label";
+import { Switch } from "@/components/ui/switch";
+import type { ServiceWithDetails as Service } from "@/db/types";
+import { pullRequestNumberFromMergeRef } from "@/lib/service-revision-spec";
+
+function previewPullRequestNumber(previewGitRef: string | null) {
+ if (!previewGitRef) return null;
+ try {
+ return pullRequestNumberFromMergeRef(previewGitRef);
+ } catch {
+ return null;
+ }
+}
+
+export function PullRequestPreviewsSetting({
+ service,
+ projectSlug,
+ autoSubdomainDomain,
+ onUpdate,
+}: {
+ service: Service;
+ projectSlug: string;
+ autoSubdomainDomain: string | null;
+ onUpdate?: () => void;
+}) {
+ const [isPending, startTransition] = useTransition();
+ const pullRequestNumber = previewPullRequestNumber(service.previewGitRef);
+
+ if (service.previewOfService && pullRequestNumber) {
+ return (
+
+ );
+ }
+ if (!service.hasGithubAppRepo) return null;
+
+ const updateEnabled = (enabled: boolean) => {
+ startTransition(async () => {
+ try {
+ await setPreviewDeploymentsEnabled(service.id, enabled);
+ toast.success(
+ enabled ? "Pull request previews enabled" : "Preview teardown queued",
+ );
+ onUpdate?.();
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "Update failed");
+ }
+ });
+ };
+
+ return (
+
+
+
Pull Request Previews
+
+ Deploy same-repository pull requests that are ready for review as
+ visible services in{" "}
+ {service.previewDeploymentsEnabled ? (
+
+ the previews environment
+
+ ) : (
+ "a previews environment"
+ )}
+ . Secrets are copied when each service is created.
+
+ {service.stateful ? (
+
+ Preview deployments require a stateless service.
+
+ ) : !autoSubdomainDomain ? (
+
+ Configure Automatic Subdomain Domain before enabling previews.
+
+ ) : null}
+
+
+
+ );
+}
diff --git a/web/components/service/details/source-section.tsx b/web/components/service/details/source-section.tsx
index 1da29e01..0331aec9 100644
--- a/web/components/service/details/source-section.tsx
+++ b/web/components/service/details/source-section.tsx
@@ -14,6 +14,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { ServiceWithDetails as Service } from "@/db/types";
import { imageNeedsProductionPinning } from "@/lib/docker-image";
+import { PullRequestPreviewsSetting } from "./pull-request-previews-setting";
function parseImageInfo(image: string): {
registry: string;
@@ -44,10 +45,16 @@ function parseImageInfo(image: string): {
export const SourceSection = memo(function SourceSection({
service,
+ projectSlug,
+ autoSubdomainDomain,
onUpdate,
+ onPreviewUpdate,
}: {
service: Service;
+ projectSlug: string;
+ autoSubdomainDomain: string | null;
onUpdate?: () => void;
+ onPreviewUpdate?: () => void;
}) {
const [isEditing, setIsEditing] = useState(false);
const [editMode, setEditMode] = useState<"github" | "image">("image");
@@ -221,6 +228,12 @@ export const SourceSection = memo(function SourceSection({
)}
+
Edit
diff --git a/web/components/service/preview-deployments-page.tsx b/web/components/service/preview-deployments-page.tsx
deleted file mode 100644
index 17aec6c8..00000000
--- a/web/components/service/preview-deployments-page.tsx
+++ /dev/null
@@ -1,229 +0,0 @@
-"use client";
-
-import { ExternalLinkIcon, RefreshCwIcon, Trash2Icon } from "lucide-react";
-import { useRouter } from "next/navigation";
-import { useEffect, useState, useTransition } from "react";
-import { toast } from "sonner";
-import {
- redeployPreview,
- removePreview,
- setPreviewDeploymentsEnabled,
-} from "@/actions/previews";
-import { Badge } from "@/components/ui/badge";
-import { Button } from "@/components/ui/button";
-import { Switch } from "@/components/ui/switch";
-
-type Preview = {
- serviceId: string;
- pullRequestNumber: number;
- status: string;
- commitSha: string | null;
- url: string | null;
- error: string | null;
- updatedAt: string;
- expiresAt: string | null;
- title: string | null;
- author: string | null;
-};
-
-export function PreviewDeploymentsPage({
- serviceId,
- enabled,
- stateful,
- automaticDomain,
- repository,
- previews,
-}: {
- serviceId: string;
- enabled: boolean;
- stateful: boolean;
- automaticDomain: string | null;
- repository: string;
- previews: Preview[];
-}) {
- const router = useRouter();
- const [isPending, startTransition] = useTransition();
- const [isEnabled, setIsEnabled] = useState(enabled);
-
- useEffect(() => {
- if (
- !previews.some((preview) => !["ready", "failed"].includes(preview.status))
- ) {
- return;
- }
- const interval = setInterval(() => router.refresh(), 10_000);
- return () => clearInterval(interval);
- }, [previews, router]);
-
- const updateEnabled = (nextEnabled: boolean) => {
- const previous = isEnabled;
- setIsEnabled(nextEnabled);
- startTransition(async () => {
- try {
- await setPreviewDeploymentsEnabled(serviceId, nextEnabled);
- toast.success(
- nextEnabled
- ? "Preview deployments enabled"
- : "Preview teardown queued",
- );
- router.refresh();
- } catch (error) {
- setIsEnabled(previous);
- toast.error(error instanceof Error ? error.message : "Update failed");
- }
- });
- };
-
- const run = (action: () => Promise, success: string) =>
- startTransition(async () => {
- try {
- await action();
- toast.success(success);
- router.refresh();
- } catch (error) {
- toast.error(error instanceof Error ? error.message : "Action failed");
- }
- });
-
- return (
-
-
-
-
-
Pull request previews
-
- Build every same-repository pull request that is ready for review
- at a stable generated URL. Each preview is one stateless replica
- and inherits this service's secrets. Drafts and forks are
- skipped.
-
-
-
-
- {stateful ? (
-
- Preview deployments are unavailable because volumes cannot be
- replicated. Use a stateless service to enable previews.
-
- ) : !automaticDomain ? (
-
- Configure Automatic Subdomain Domain and wildcard DNS before
- enabling previews.
-
- ) : (
-
- Preview URLs are generated beneath {automaticDomain}.
- Closing or merging a pull request removes its preview.
-
- )}
-
-
-
-
-
Active previews
-
- {previews.length === 0 ? (
-
- {isEnabled
- ? "No eligible pull requests are open."
- : "Enable previews to deploy pull requests."}
-
- ) : (
-
- {previews.map((preview) => (
-
-
-
-
- {preview.author ? `by ${preview.author} · ` : ""}
- {preview.commitSha?.slice(0, 7) ??
- "waiting for merge ref"}{" "}
- · updated {new Date(preview.updatedAt).toLocaleString()}
-
- {preview.error ? (
-
{preview.error}
- ) : null}
-
-
- {preview.url ? (
-
- }
- >
- Open
-
- ) : null}
-
- run(
- () =>
- redeployPreview(serviceId, preview.pullRequestNumber),
- "Preview redeploy queued",
- )
- }
- >
- Redeploy
-
-
- run(
- () =>
- removePreview(serviceId, preview.pullRequestNumber),
- "Preview removal queued",
- )
- }
- >
- Remove
-
-
-
- ))}
-
- )}
-
-
- );
-}
diff --git a/web/components/service/service-layout-client.tsx b/web/components/service/service-layout-client.tsx
index ae21c59c..563d82ed 100644
--- a/web/components/service/service-layout-client.tsx
+++ b/web/components/service/service-layout-client.tsx
@@ -144,7 +144,6 @@ export function ServiceLayoutClient({
pathname.includes("/configuration") ||
pathname.includes("/changelog") ||
pathname.includes("/builds") ||
- pathname.includes("/previews") ||
pathname.includes("/backups") ||
pathname.includes("/commands");
@@ -160,10 +159,7 @@ export function ServiceLayoutClient({
? [{ name: "Requests", href: `${basePath}/requests` }]
: []),
...(service?.sourceType === "github"
- ? [
- { name: "Builds", href: `${basePath}/builds` },
- { name: "Previews", href: `${basePath}/previews` },
- ]
+ ? [{ name: "Builds", href: `${basePath}/builds` }]
: []),
...(service?.stateful
? [{ name: "Backups", href: `${basePath}/backups` }]
diff --git a/web/db/queries.ts b/web/db/queries.ts
index 8bfd49c2..410c79bc 100644
--- a/web/db/queries.ts
+++ b/web/db/queries.ts
@@ -40,9 +40,7 @@ export async function listProjects() {
db
.select({ projectId: services.projectId, total: count() })
.from(services)
- .where(
- and(isNull(services.deletedAt), isNull(services.previewOfServiceId)),
- )
+ .where(isNull(services.deletedAt))
.groupBy(services.projectId),
db
.select({
@@ -54,7 +52,6 @@ export async function listProjects() {
.where(
and(
isNull(services.deletedAt),
- isNull(services.previewOfServiceId),
inArray(deployments.observedPhase, [...observedReadyPhases]),
),
)
@@ -97,20 +94,6 @@ export async function getProjectBySlug(slug: string) {
}
export async function getService(id: string) {
- const results = await db
- .select()
- .from(services)
- .where(
- and(
- eq(services.id, id),
- isNull(services.deletedAt),
- isNull(services.previewOfServiceId),
- ),
- );
- return results[0] || null;
-}
-
-export async function getRuntimeService(id: string) {
const results = await db
.select()
.from(services)
@@ -131,13 +114,8 @@ export async function listDeletedServices(
eq(services.projectId, projectId),
eq(services.environmentId, environmentId),
isNotNull(services.deletedAt),
- isNull(services.previewOfServiceId),
)
- : and(
- eq(services.projectId, projectId),
- isNotNull(services.deletedAt),
- isNull(services.previewOfServiceId),
- ),
+ : and(eq(services.projectId, projectId), isNotNull(services.deletedAt)),
)
.orderBy(services.deletedAt);
}
diff --git a/web/db/schema.ts b/web/db/schema.ts
index 53a74007..7c29e330 100644
--- a/web/db/schema.ts
+++ b/web/db/schema.ts
@@ -590,14 +590,12 @@ export const services = pgTable(
previewDeploymentsEnabled: boolean("preview_deployments_enabled")
.notNull()
.default(false),
- previewOfServiceId: text("preview_of_service_id"),
- previewPullRequestNumber: integer("preview_pull_request_number"),
+ previewOfService: text("preview_of_service"),
+ previewGitRef: text("preview_git_ref"),
previewCurrentRevisionId: text("preview_current_revision_id"),
previewGithubDeploymentId: bigint("preview_github_deployment_id", {
mode: "number",
}),
- previewError: text("preview_error"),
- previewExpiresAt: timestamp("preview_expires_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
@@ -613,40 +611,35 @@ export const services = pgTable(
),
foreignKey({
name: "services_preview_of_service_fk",
- columns: [table.previewOfServiceId],
+ columns: [table.previewOfService],
foreignColumns: [table.id],
}).onDelete("restrict"),
check(
"services_preview_identity_check",
- sql`(${table.previewOfServiceId} is null) = (${table.previewPullRequestNumber} is null)`,
+ sql`(${table.previewOfService} is null) = (${table.previewGitRef} is null)`,
),
check(
- "services_preview_pull_request_number_check",
- sql`${table.previewPullRequestNumber} is null or ${table.previewPullRequestNumber} > 0`,
+ "services_preview_git_ref_check",
+ sql`${table.previewGitRef} is null or ${table.previewGitRef} ~ '^refs/pull/[1-9][0-9]*/merge$'`,
),
check(
"services_preview_policy_check",
sql`(
- (${table.previewOfServiceId} is null and (${table.previewDeploymentsEnabled} = false or ${table.stateful} = false))
+ (${table.previewOfService} is null and (${table.previewDeploymentsEnabled} = false or ${table.stateful} = false))
or
- (${table.previewOfServiceId} is not null and ${table.previewDeploymentsEnabled} = false and ${table.stateful} = false)
+ (${table.previewOfService} is not null and ${table.previewDeploymentsEnabled} = false and ${table.stateful} = false)
)`,
),
check(
"services_preview_metadata_check",
- sql`${table.previewOfServiceId} is not null or (
+ sql`${table.previewOfService} is not null or (
${table.previewCurrentRevisionId} is null
and ${table.previewGithubDeploymentId} is null
- and ${table.previewError} is null
- and ${table.previewExpiresAt} is null
)`,
),
- uniqueIndex("services_preview_base_pr_unique_idx")
- .on(table.previewOfServiceId, table.previewPullRequestNumber)
- .where(sql`${table.previewOfServiceId} is not null`),
- index("services_preview_expires_at_idx")
- .on(table.previewExpiresAt)
- .where(sql`${table.previewOfServiceId} is not null`),
+ uniqueIndex("services_preview_base_ref_unique_idx")
+ .on(table.previewOfService, table.previewGitRef)
+ .where(sql`${table.previewOfService} is not null`),
],
);
diff --git a/web/lib/backup-scheduler.ts b/web/lib/backup-scheduler.ts
index 3c75ce73..1b92aa5f 100644
--- a/web/lib/backup-scheduler.ts
+++ b/web/lib/backup-scheduler.ts
@@ -64,13 +64,7 @@ export async function runScheduledBackups() {
backupSchedule: services.backupSchedule,
})
.from(services)
- .where(
- and(
- eq(services.backupEnabled, true),
- isNull(services.deletedAt),
- isNull(services.previewOfServiceId),
- ),
- );
+ .where(and(eq(services.backupEnabled, true), isNull(services.deletedAt)));
for (const service of servicesWithBackup) {
if (!service.backupSchedule) {
diff --git a/web/lib/backups/trigger-backup.ts b/web/lib/backups/trigger-backup.ts
index e167bf2b..5b44b83f 100644
--- a/web/lib/backups/trigger-backup.ts
+++ b/web/lib/backups/trigger-backup.ts
@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto";
-import { and, eq, inArray, isNull } from "drizzle-orm";
+import { and, eq, inArray } from "drizzle-orm";
import { db } from "@/db";
import { getBackupStorageConfig } from "@/db/queries";
import {
@@ -38,7 +38,7 @@ export async function triggerBackup({
const service = await db
.select()
.from(services)
- .where(and(eq(services.id, serviceId), isNull(services.previewOfServiceId)))
+ .where(eq(services.id, serviceId))
.then((r) => r[0]);
if (!service) {
diff --git a/web/lib/deploy-service.ts b/web/lib/deploy-service.ts
index 0caf3e58..5384cad3 100644
--- a/web/lib/deploy-service.ts
+++ b/web/lib/deploy-service.ts
@@ -1,7 +1,7 @@
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { db } from "@/db";
-import { getRuntimeService } from "@/db/queries";
+import { getService } from "@/db/queries";
import { serviceReplicas } from "@/db/schema";
import { startMigrationInternal } from "@/lib/migrations";
import { sendRolloutCreated } from "@/lib/rollout-enqueue";
@@ -37,7 +37,7 @@ export async function deployServiceInternal(
githubTrigger?: "manual" | "scheduled";
} = {},
) {
- const service = await getRuntimeService(serviceId);
+ const service = await getService(serviceId);
if (!service) {
throw new Error("Service not found");
}
diff --git a/web/lib/github.ts b/web/lib/github.ts
index 2292d070..2ab18f78 100644
--- a/web/lib/github.ts
+++ b/web/lib/github.ts
@@ -1,5 +1,6 @@
import { createHmac, createPrivateKey, timingSafeEqual } from "node:crypto";
import { SignJWT } from "jose";
+import { pullRequestMergeRef } from "@/lib/service-revision-spec";
function getAppId(): string {
const appId = process.env.GITHUB_APP_ID;
@@ -370,10 +371,7 @@ export async function resolveGitHubPullRequestMergeRef(
repoFullName: string,
pullRequestNumber: number,
): Promise<{ gitRef: string; sha: string }> {
- if (!Number.isSafeInteger(pullRequestNumber) || pullRequestNumber <= 0) {
- throw new Error("Invalid pull request number");
- }
- const gitRef = `refs/pull/${pullRequestNumber}/merge`;
+ const gitRef = pullRequestMergeRef(pullRequestNumber);
try {
const commits = await githubCommitRequest(
installationId,
diff --git a/web/lib/inngest/events/preview.ts b/web/lib/inngest/events/preview.ts
index fe5d0bb2..f4541be8 100644
--- a/web/lib/inngest/events/preview.ts
+++ b/web/lib/inngest/events/preview.ts
@@ -2,14 +2,14 @@ export type PreviewEvents = {
"preview/sync-requested": {
data: {
baseServiceId: string;
- pullRequestNumber: number;
+ previewGitRef: string;
force?: boolean;
};
};
"preview/close-requested": {
data: {
baseServiceId: string;
- pullRequestNumber: number;
+ previewGitRef: string;
reason: string;
verifyWithGitHub?: boolean;
};
diff --git a/web/lib/inngest/functions/crons.ts b/web/lib/inngest/functions/crons.ts
index 8abd540f..97efe4e9 100644
--- a/web/lib/inngest/functions/crons.ts
+++ b/web/lib/inngest/functions/crons.ts
@@ -223,71 +223,47 @@ export const previewReconciliation = inngest.createFunction(
singleton: { mode: "skip" },
},
async ({ step }) => {
- const enabledServices = await step.run(
- "reconcile-enabled-services",
+ const serviceCount = await step.run(
+ "queue-preview-reconciliation",
async () => {
- const rows = await db
- .select({ id: services.id })
- .from(services)
- .innerJoin(githubRepos, eq(githubRepos.serviceId, services.id))
- .where(
- and(
- eq(services.previewDeploymentsEnabled, true),
- eq(services.sourceType, "github"),
- isNull(services.previewOfServiceId),
- isNull(services.deletedAt),
+ const [enabled, children] = await Promise.all([
+ db
+ .select({ id: services.id })
+ .from(services)
+ .innerJoin(githubRepos, eq(githubRepos.serviceId, services.id))
+ .where(
+ and(
+ eq(services.previewDeploymentsEnabled, true),
+ eq(services.sourceType, "github"),
+ isNull(services.previewOfService),
+ isNull(services.deletedAt),
+ ),
),
- );
+ db
+ .select({ id: services.previewOfService })
+ .from(services)
+ .where(isNotNull(services.previewOfService))
+ .groupBy(services.previewOfService),
+ ]);
+ const serviceIds = new Set([
+ ...enabled.map(({ id }) => id),
+ ...children.flatMap(({ id }) => (id ? [id] : [])),
+ ]);
const day = new Date().toISOString().slice(0, 10);
- for (const service of rows) {
- await inngest.send(
- inngestEvents.previewServiceReconcileRequested.create(
- { baseServiceId: service.id },
- { id: `preview-service-daily:${service.id}:${day}` },
- ),
- );
- }
- return rows.length;
- },
- );
- const expiredPreviews = await step.run(
- "reconcile-expired-previews",
- async () => {
- const expired = await db
- .select({
- baseServiceId: services.previewOfServiceId,
- pullRequestNumber: services.previewPullRequestNumber,
- expiresAt: services.previewExpiresAt,
- })
- .from(services)
- .where(
- and(
- isNotNull(services.previewOfServiceId),
- isNotNull(services.previewPullRequestNumber),
- isNotNull(services.previewExpiresAt),
- isNull(services.deletedAt),
- lte(services.previewExpiresAt, new Date()),
- ),
- )
- .limit(100);
- for (const preview of expired) {
- if (!preview.baseServiceId || !preview.pullRequestNumber) continue;
+ if (serviceIds.size > 0) {
await inngest.send(
- inngestEvents.previewSyncRequested.create(
- {
- baseServiceId: preview.baseServiceId,
- pullRequestNumber: preview.pullRequestNumber,
- },
- {
- id: `preview-expiry:${preview.baseServiceId}:${preview.pullRequestNumber}:${preview.expiresAt?.toISOString()}`,
- },
+ [...serviceIds].map((baseServiceId) =>
+ inngestEvents.previewServiceReconcileRequested.create(
+ { baseServiceId },
+ { id: `preview-service-daily:${baseServiceId}:${day}` },
+ ),
),
);
}
- return expired.length;
+ return serviceIds.size;
},
);
- return { enabledServices, expiredPreviews };
+ return { serviceCount };
},
);
@@ -308,7 +284,6 @@ export const serviceCronDispatcher = inngest.createFunction(
and(
eq(serviceCrons.serviceId, services.id),
isNull(services.deletedAt),
- isNull(services.previewOfServiceId),
),
)
.where(lte(serviceCrons.nextScheduledFor, now))
diff --git a/web/lib/inngest/functions/preview-workflow.ts b/web/lib/inngest/functions/preview-workflow.ts
index c5eaecfc..1fe9906d 100644
--- a/web/lib/inngest/functions/preview-workflow.ts
+++ b/web/lib/inngest/functions/preview-workflow.ts
@@ -9,8 +9,7 @@ import {
updateGitHubDeploymentStatus,
} from "@/lib/github";
import {
- createOrRefreshPreviewClone,
- PREVIEW_RECONCILIATION_TTL_MS,
+ createPreviewClone,
updateCurrentPreviewGitHubStatus,
} from "@/lib/preview-deployments";
import {
@@ -19,6 +18,10 @@ import {
deletePreviewService,
} from "@/lib/preview-lifecycle";
import { parseServiceRevisionSpec } from "@/lib/service-revision-changes";
+import {
+ pullRequestMergeRef,
+ pullRequestNumberFromMergeRef,
+} from "@/lib/service-revision-spec";
import { triggerResolvedBuildInternal } from "@/lib/trigger-build";
import { inngest } from "../client";
import { inngestEvents } from "../events";
@@ -31,7 +34,7 @@ async function loadBaseContext(baseServiceId: string) {
.where(
and(
eq(services.id, baseServiceId),
- isNull(services.previewOfServiceId),
+ isNull(services.previewOfService),
isNull(services.deletedAt),
),
)
@@ -40,26 +43,14 @@ async function loadBaseContext(baseServiceId: string) {
async function closePreview(
baseServiceId: string,
- pullRequestNumber: number,
+ previewGitRef: string,
reason: string,
) {
- const deleted = await deletePreviewService(baseServiceId, pullRequestNumber);
- if (deleted?.service.previewGithubDeploymentId) {
- try {
- await updateGitHubDeploymentStatus(
- deleted.githubRepo.installationId,
- deleted.githubRepo.repoFullName,
- deleted.service.previewGithubDeploymentId,
- "inactive",
- { description: `Preview removed: ${reason}`.substring(0, 140) },
- );
- } catch (error) {
- console.error(
- `[preview:close] failed to mark GitHub deployment ${deleted.service.previewGithubDeploymentId} inactive:`,
- error,
- );
- }
- }
+ const deleted = await deletePreviewService(
+ baseServiceId,
+ previewGitRef,
+ reason,
+ );
return deleted
? { status: "deleted" as const, serviceId: deleted.service.id }
: { status: "not_found" as const };
@@ -95,16 +86,19 @@ function isEligiblePullRequest(
async function loadPreviewContext(
baseServiceId: string,
- pullRequestNumber: number,
+ previewGitRef: string,
) {
return db
.select({ service: services, githubRepo: githubRepos })
.from(services)
- .innerJoin(githubRepos, eq(githubRepos.serviceId, services.id))
+ .innerJoin(
+ githubRepos,
+ eq(githubRepos.serviceId, services.previewOfService),
+ )
.where(
and(
- eq(services.previewOfServiceId, baseServiceId),
- eq(services.previewPullRequestNumber, pullRequestNumber),
+ eq(services.previewOfService, baseServiceId),
+ eq(services.previewGitRef, previewGitRef),
isNull(services.deletedAt),
),
)
@@ -113,37 +107,34 @@ async function loadPreviewContext(
async function closePreviewFromEvent(input: {
baseServiceId: string;
- pullRequestNumber: number;
+ previewGitRef: string;
reason: string;
verifyWithGitHub?: boolean;
}) {
+ const pullRequestNumber = pullRequestNumberFromMergeRef(input.previewGitRef);
if (input.verifyWithGitHub) {
const [baseContext, previewContext] = await Promise.all([
loadBaseContext(input.baseServiceId),
- loadPreviewContext(input.baseServiceId, input.pullRequestNumber),
+ loadPreviewContext(input.baseServiceId, input.previewGitRef),
]);
if (!previewContext) return { status: "not_found" as const };
if (baseContext) {
const pullRequest = await getGitHubPullRequest(
previewContext.githubRepo.installationId,
previewContext.githubRepo.repoFullName,
- input.pullRequestNumber,
+ pullRequestNumber,
);
if (isEligiblePullRequest(baseContext, pullRequest)) {
await enqueuePreviewSync(
input.baseServiceId,
- input.pullRequestNumber,
+ input.previewGitRef,
`stale-close:${pullRequest.updatedAt}`,
);
return { status: "stale" as const };
}
}
}
- return closePreview(
- input.baseServiceId,
- input.pullRequestNumber,
- input.reason,
- );
+ return closePreview(input.baseServiceId, input.previewGitRef, input.reason);
}
async function loadCurrentPreviewRevision(serviceId: string) {
@@ -151,7 +142,6 @@ async function loadCurrentPreviewRevision(serviceId: string) {
.select({
previewCurrentRevisionId: services.previewCurrentRevisionId,
previewGithubDeploymentId: services.previewGithubDeploymentId,
- previewError: services.previewError,
})
.from(services)
.where(eq(services.id, serviceId))
@@ -175,18 +165,17 @@ async function loadCurrentPreviewRevision(serviceId: string) {
};
}
-async function storePreviewPreBuildError(input: {
+async function clearCurrentPreviewRevision(input: {
baseServiceId: string;
- pullRequestNumber: number;
+ previewGitRef: string;
previewServiceId: string;
- message: string;
}) {
return db.transaction(async (tx) => {
await tx.execute(
sql`select pg_advisory_xact_lock(hashtext(${input.baseServiceId}))`,
);
await tx.execute(
- sql`select pg_advisory_xact_lock(hashtext(${input.baseServiceId}), ${input.pullRequestNumber})`,
+ sql`select pg_advisory_xact_lock(hashtext(${input.baseServiceId}), hashtext(${input.previewGitRef}))`,
);
await tx.execute(
sql`select pg_advisory_xact_lock(hashtext(${input.previewServiceId}))`,
@@ -200,8 +189,8 @@ async function storePreviewPreBuildError(input: {
.where(
and(
eq(services.id, input.previewServiceId),
- eq(services.previewOfServiceId, input.baseServiceId),
- eq(services.previewPullRequestNumber, input.pullRequestNumber),
+ eq(services.previewOfService, input.baseServiceId),
+ eq(services.previewGitRef, input.previewGitRef),
isNull(services.deletedAt),
),
)
@@ -212,14 +201,46 @@ async function storePreviewPreBuildError(input: {
.set({
previewCurrentRevisionId: null,
previewGithubDeploymentId: null,
- previewError: input.message,
- previewExpiresAt: new Date(Date.now() + PREVIEW_RECONCILIATION_TTL_MS),
})
.where(eq(services.id, input.previewServiceId));
return current;
});
}
+async function storePreviewGitHubDeployment(input: {
+ baseServiceId: string;
+ previewGitRef: string;
+ previewServiceId: string;
+ githubDeploymentId: number;
+}) {
+ return db.transaction(async (tx) => {
+ await tx.execute(
+ sql`select pg_advisory_xact_lock(hashtext(${input.baseServiceId}))`,
+ );
+ await tx.execute(
+ sql`select pg_advisory_xact_lock(hashtext(${input.baseServiceId}), hashtext(${input.previewGitRef}))`,
+ );
+ await tx.execute(
+ sql`select pg_advisory_xact_lock(hashtext(${input.previewServiceId}))`,
+ );
+ return tx
+ .update(services)
+ .set({ previewGithubDeploymentId: input.githubDeploymentId })
+ .where(
+ and(
+ eq(services.id, input.previewServiceId),
+ eq(services.previewOfService, input.baseServiceId),
+ eq(services.previewGitRef, input.previewGitRef),
+ isNull(services.previewCurrentRevisionId),
+ isNull(services.previewGithubDeploymentId),
+ isNull(services.deletedAt),
+ ),
+ )
+ .returning({ id: services.id })
+ .then((rows) => rows.length > 0);
+ });
+}
+
export const previewSyncWorkflow = inngest.createFunction(
{
id: "preview-sync-workflow",
@@ -227,22 +248,19 @@ export const previewSyncWorkflow = inngest.createFunction(
concurrency: [
{
limit: 1,
- key: 'event.data.baseServiceId + ":" + event.data.pullRequestNumber',
+ key: 'event.data.baseServiceId + ":" + event.data.previewGitRef',
},
],
},
async ({ event, step }) => {
- const { baseServiceId, pullRequestNumber, force = false } = event.data;
+ const { baseServiceId, previewGitRef, force = false } = event.data;
+ const pullRequestNumber = pullRequestNumberFromMergeRef(previewGitRef);
const context = await step.run("load-base-service", () =>
loadBaseContext(baseServiceId),
);
if (!context) {
await step.run("close-orphaned-preview", () =>
- closePreview(
- baseServiceId,
- pullRequestNumber,
- "base service unavailable",
- ),
+ closePreview(baseServiceId, previewGitRef, "base service unavailable"),
);
return { status: "closed", reason: "base_service_unavailable" };
}
@@ -256,19 +274,15 @@ export const previewSyncWorkflow = inngest.createFunction(
);
if (!isEligiblePullRequest(context, pullRequest)) {
await step.run("close-ineligible-preview", () =>
- closePreview(
- baseServiceId,
- pullRequestNumber,
- "pull request ineligible",
- ),
+ closePreview(baseServiceId, previewGitRef, "pull request ineligible"),
);
return { status: "closed", reason: "pull_request_ineligible" };
}
- const clone = await step.run("refresh-preview-service", () =>
- createOrRefreshPreviewClone({
+ const clone = await step.run("create-preview-service", () =>
+ createPreviewClone({
baseServiceId,
- pullRequestNumber,
+ previewGitRef,
}),
);
const previous = await step.run("load-current-preview-revision", () =>
@@ -284,16 +298,11 @@ export const previewSyncWorkflow = inngest.createFunction(
),
);
} catch (error) {
- const message =
- error instanceof Error
- ? error.message
- : "Pull request merge ref unavailable";
- const superseded = await step.run("store-merge-ref-error", () =>
- storePreviewPreBuildError({
+ const superseded = await step.run("clear-unmergeable-preview", () =>
+ clearCurrentPreviewRevision({
baseServiceId,
- pullRequestNumber,
+ previewGitRef,
previewServiceId: clone.serviceId,
- message,
}),
);
if (superseded?.previewCurrentRevisionId) {
@@ -312,24 +321,66 @@ export const previewSyncWorkflow = inngest.createFunction(
),
);
}
+ if (!superseded) {
+ return { status: "closed", reason: "preview_service_unavailable" };
+ }
+ const message =
+ error instanceof Error
+ ? error.message
+ : "Pull request merge ref unavailable";
+ const failedDeploymentId = await step.run(
+ "create-unmergeable-deployment",
+ () =>
+ createGitHubDeployment(
+ context.githubRepo.installationId,
+ context.githubRepo.repoFullName,
+ pullRequest.head.sha,
+ `preview/${context.service.name}/pr-${pullRequestNumber}`,
+ `Preview unavailable for PR #${pullRequestNumber}`,
+ {
+ transientEnvironment: true,
+ productionEnvironment: false,
+ payload: {
+ baseServiceId,
+ previewServiceId: clone.serviceId,
+ previewGitRef,
+ },
+ },
+ ),
+ );
+ const stored = await step.run("store-unmergeable-deployment", () =>
+ storePreviewGitHubDeployment({
+ baseServiceId,
+ previewGitRef,
+ previewServiceId: clone.serviceId,
+ githubDeploymentId: failedDeploymentId,
+ }),
+ );
+ if (!stored) {
+ await step.run("inactivate-orphaned-unmergeable-deployment", () =>
+ updateGitHubDeploymentStatus(
+ context.githubRepo.installationId,
+ context.githubRepo.repoFullName,
+ failedDeploymentId,
+ "inactive",
+ { description: "Preview was removed" },
+ ),
+ );
+ return { status: "closed", reason: "preview_service_unavailable" };
+ }
+ await step.run("mark-unmergeable-deployment-failed", () =>
+ updateCurrentPreviewGitHubStatus({
+ serviceId: clone.serviceId,
+ serviceRevisionId: null,
+ expectedDeploymentId: failedDeploymentId,
+ state: "failure",
+ description: message,
+ }),
+ );
return { status: "failed", reason: "merge_ref_unavailable" };
}
- if (
- !force &&
- previous?.commitSha === mergeRef.sha &&
- !previous.previewError
- ) {
- await step.run("extend-preview-expiry", () =>
- db
- .update(services)
- .set({
- previewExpiresAt: new Date(
- Date.now() + PREVIEW_RECONCILIATION_TTL_MS,
- ),
- })
- .where(eq(services.id, clone.serviceId)),
- );
+ if (!force && previous?.commitSha === mergeRef.sha) {
return { status: "unchanged", serviceId: clone.serviceId };
}
@@ -349,7 +400,7 @@ export const previewSyncWorkflow = inngest.createFunction(
payload: {
baseServiceId,
previewServiceId: clone.serviceId,
- pullRequestNumber,
+ previewGitRef,
},
},
),
@@ -384,7 +435,7 @@ export const previewSyncWorkflow = inngest.createFunction(
sql`select pg_advisory_xact_lock(hashtext(${baseServiceId}))`,
);
await tx.execute(
- sql`select pg_advisory_xact_lock(hashtext(${baseServiceId}), ${pullRequestNumber})`,
+ sql`select pg_advisory_xact_lock(hashtext(${baseServiceId}), hashtext(${previewGitRef}))`,
);
await tx.execute(
sql`select pg_advisory_xact_lock(hashtext(${clone.serviceId}))`,
@@ -394,16 +445,12 @@ export const previewSyncWorkflow = inngest.createFunction(
.set({
previewCurrentRevisionId: serviceRevisionId,
previewGithubDeploymentId: deploymentId,
- previewError: null,
- previewExpiresAt: new Date(
- Date.now() + PREVIEW_RECONCILIATION_TTL_MS,
- ),
})
.where(
and(
eq(services.id, clone.serviceId),
- eq(services.previewOfServiceId, baseServiceId),
- eq(services.previewPullRequestNumber, pullRequestNumber),
+ eq(services.previewOfService, baseServiceId),
+ eq(services.previewGitRef, previewGitRef),
isNull(services.deletedAt),
),
)
@@ -420,71 +467,68 @@ export const previewSyncWorkflow = inngest.createFunction(
error instanceof Error
? error.message
: "Failed to queue preview build";
- await step.run("mark-preview-queue-failed", async () => {
- await db.transaction(async (tx) => {
- await tx.execute(
- sql`select pg_advisory_xact_lock(hashtext(${baseServiceId}))`,
- );
- await tx.execute(
- sql`select pg_advisory_xact_lock(hashtext(${baseServiceId}), ${pullRequestNumber})`,
- );
- await tx.execute(
- sql`select pg_advisory_xact_lock(hashtext(${clone.serviceId}))`,
- );
- const current = await tx
- .select({
- previewCurrentRevisionId: services.previewCurrentRevisionId,
- previewGithubDeploymentId: services.previewGithubDeploymentId,
- })
- .from(services)
- .where(
- and(eq(services.id, clone.serviceId), isNull(services.deletedAt)),
- )
- .then((rows) => rows[0]);
- if (current) {
- try {
- await updateGitHubDeploymentStatus(
- context.githubRepo.installationId,
- context.githubRepo.repoFullName,
- deploymentId,
- "failure",
- { description: message.substring(0, 140) },
- );
- } catch (statusError) {
- console.error(
- "[preview:sync] failed to report build queue failure:",
- statusError,
- );
- }
- }
- if (
- activatedRevisionId &&
- current?.previewCurrentRevisionId === activatedRevisionId &&
- current.previewGithubDeploymentId === deploymentId
- ) {
- await tx
- .update(services)
- .set({
- previewCurrentRevisionId:
- previous?.previewCurrentRevisionId ?? null,
- previewGithubDeploymentId:
- previous?.previewGithubDeploymentId ?? null,
- previewError: message,
+ const previewStillExists = await step.run(
+ "restore-preview-after-queue-failure",
+ () =>
+ db.transaction(async (tx) => {
+ await tx.execute(
+ sql`select pg_advisory_xact_lock(hashtext(${baseServiceId}))`,
+ );
+ await tx.execute(
+ sql`select pg_advisory_xact_lock(hashtext(${baseServiceId}), hashtext(${previewGitRef}))`,
+ );
+ await tx.execute(
+ sql`select pg_advisory_xact_lock(hashtext(${clone.serviceId}))`,
+ );
+ const current = await tx
+ .select({
+ previewCurrentRevisionId: services.previewCurrentRevisionId,
+ previewGithubDeploymentId: services.previewGithubDeploymentId,
})
- .where(eq(services.id, clone.serviceId));
- } else if (!activatedRevisionId && current) {
- await tx
- .update(services)
- .set({ previewError: message })
- .where(eq(services.id, clone.serviceId));
- }
- });
- });
+ .from(services)
+ .where(
+ and(
+ eq(services.id, clone.serviceId),
+ isNull(services.deletedAt),
+ ),
+ )
+ .then((rows) => rows[0]);
+ if (
+ activatedRevisionId &&
+ current?.previewCurrentRevisionId === activatedRevisionId &&
+ current.previewGithubDeploymentId === deploymentId
+ ) {
+ await tx
+ .update(services)
+ .set({
+ previewCurrentRevisionId:
+ previous?.previewCurrentRevisionId ?? null,
+ previewGithubDeploymentId:
+ previous?.previewGithubDeploymentId ?? null,
+ })
+ .where(eq(services.id, clone.serviceId));
+ }
+ return current != null;
+ }),
+ );
if (activatedRevisionId) {
await step.run("cancel-undispatched-preview", () =>
cancelPreviewRevisionWork(clone.serviceId, activatedRevisionId!),
);
}
+ await step.run("mark-preview-queue-failed", () =>
+ updateGitHubDeploymentStatus(
+ context.githubRepo.installationId,
+ context.githubRepo.repoFullName,
+ deploymentId,
+ previewStillExists ? "failure" : "inactive",
+ {
+ description: previewStillExists
+ ? message.substring(0, 140)
+ : "Preview was removed",
+ },
+ ),
+ );
throw error;
}
@@ -527,7 +571,7 @@ export const previewCloseWorkflow = inngest.createFunction(
concurrency: [
{
limit: 1,
- key: 'event.data.baseServiceId + ":" + event.data.pullRequestNumber',
+ key: 'event.data.baseServiceId + ":" + event.data.previewGitRef',
},
],
},
@@ -548,25 +592,25 @@ export const previewServiceReconcileWorkflow = inngest.createFunction(
if (!context || !context.service.previewDeploymentsEnabled) {
const clones = await step.run("load-previews-to-close", () =>
db
- .select({ pullRequestNumber: services.previewPullRequestNumber })
+ .select({ previewGitRef: services.previewGitRef })
.from(services)
- .where(
- and(
- eq(services.previewOfServiceId, event.data.baseServiceId),
- isNull(services.deletedAt),
- ),
- ),
+ .where(eq(services.previewOfService, event.data.baseServiceId)),
+ );
+ await Promise.all(
+ clones.flatMap((clone) =>
+ clone.previewGitRef
+ ? [
+ step.run(`close-disabled-${clone.previewGitRef}`, () =>
+ closePreview(
+ event.data.baseServiceId,
+ clone.previewGitRef!,
+ "preview deployments disabled",
+ ),
+ ),
+ ]
+ : [],
+ ),
);
- for (const clone of clones) {
- if (!clone.pullRequestNumber) continue;
- await step.run(`close-disabled-${clone.pullRequestNumber}`, () =>
- closePreview(
- event.data.baseServiceId,
- clone.pullRequestNumber!,
- "preview deployments disabled",
- ),
- );
- }
return { status: "disabled", closed: clones.length };
}
const pullRequests = await step.run("list-open-pull-requests", () =>
@@ -579,43 +623,47 @@ export const previewServiceReconcileWorkflow = inngest.createFunction(
const eligible = pullRequests.filter((pullRequest) =>
isEligiblePullRequest(context, pullRequest),
);
- const eligibleNumbers = new Set(
- eligible.map((pullRequest) => pullRequest.number),
+ const eligibleRefs = new Set(
+ eligible.map((pullRequest) => pullRequestMergeRef(pullRequest.number)),
);
const existing = await step.run("list-existing-previews", () =>
db
- .select({ pullRequestNumber: services.previewPullRequestNumber })
+ .select({
+ previewGitRef: services.previewGitRef,
+ deletedAt: services.deletedAt,
+ })
.from(services)
- .where(
- and(
- eq(services.previewOfServiceId, event.data.baseServiceId),
- isNull(services.deletedAt),
- ),
- ),
+ .where(eq(services.previewOfService, event.data.baseServiceId)),
);
const stale = existing.filter(
(clone) =>
- clone.pullRequestNumber &&
- !eligibleNumbers.has(clone.pullRequestNumber),
+ clone.previewGitRef &&
+ (clone.deletedAt != null || !eligibleRefs.has(clone.previewGitRef)),
);
- for (const clone of stale) {
- await step.run(`close-stale-${clone.pullRequestNumber}`, () =>
- closePreview(
- event.data.baseServiceId,
- clone.pullRequestNumber!,
- "pull request no longer eligible",
+ await Promise.all(
+ stale.map((clone) =>
+ step.run(`close-stale-${clone.previewGitRef}`, () =>
+ closePreview(
+ event.data.baseServiceId,
+ clone.previewGitRef!,
+ clone.deletedAt
+ ? "retrying preview deletion"
+ : "pull request no longer eligible",
+ ),
),
- );
- }
- for (const pullRequest of eligible) {
- await step.run(`queue-pr-${pullRequest.number}`, () =>
- enqueuePreviewSync(
- event.data.baseServiceId,
- pullRequest.number,
- `reconcile:${pullRequest.updatedAt}`,
+ ),
+ );
+ await Promise.all(
+ eligible.map((pullRequest) =>
+ step.run(`queue-pr-${pullRequest.number}`, () =>
+ enqueuePreviewSync(
+ event.data.baseServiceId,
+ pullRequestMergeRef(pullRequest.number),
+ `reconcile:${pullRequest.updatedAt}`,
+ ),
),
- );
- }
+ ),
+ );
return {
status: "queued",
count: eligible.length,
@@ -626,12 +674,13 @@ export const previewServiceReconcileWorkflow = inngest.createFunction(
async function enqueuePreviewSync(
baseServiceId: string,
- pullRequestNumber: number,
+ previewGitRef: string,
idSuffix: string,
) {
+ const pullRequestNumber = pullRequestNumberFromMergeRef(previewGitRef);
await inngest.send(
inngestEvents.previewSyncRequested.create(
- { baseServiceId, pullRequestNumber },
+ { baseServiceId, previewGitRef },
{
id: `preview-reconcile:${baseServiceId}:${pullRequestNumber}:${idSuffix}`,
},
diff --git a/web/lib/inngest/functions/rollout-helpers.ts b/web/lib/inngest/functions/rollout-helpers.ts
index 88989d52..9b7bb12f 100644
--- a/web/lib/inngest/functions/rollout-helpers.ts
+++ b/web/lib/inngest/functions/rollout-helpers.ts
@@ -385,6 +385,7 @@ export async function createDeploymentRecords(
for (let i = 0; i < placement.replicas; i++) {
const deploymentId = randomUUID();
+ // react-doctor-disable-next-line react-doctor/async-await-in-loop -- each allocation must observe and lock the ports claimed by the previous replica
const currentDeploymentIds = await withAllocationRetry(
() =>
db.transaction(async (tx) => {
@@ -393,7 +394,7 @@ export async function createDeploymentRecords(
);
const service = await tx
.select({
- previewOfServiceId: services.previewOfServiceId,
+ previewOfService: services.previewOfService,
previewCurrentRevisionId: services.previewCurrentRevisionId,
})
.from(services)
@@ -403,7 +404,7 @@ export async function createDeploymentRecords(
.then((rows) => rows[0]);
if (
!service ||
- (service.previewOfServiceId &&
+ (service.previewOfService &&
service.previewCurrentRevisionId !== revisionId)
) {
throw new Error("Preview revision is no longer current");
@@ -521,7 +522,7 @@ export async function completeRollout(
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${serviceId}))`);
const service = await tx
.select({
- previewOfServiceId: services.previewOfServiceId,
+ previewOfService: services.previewOfService,
previewCurrentRevisionId: services.previewCurrentRevisionId,
})
.from(services)
@@ -529,7 +530,7 @@ export async function completeRollout(
.then((rows) => rows[0]);
if (
!service ||
- (service.previewOfServiceId &&
+ (service.previewOfService &&
service.previewCurrentRevisionId !== revisionId)
) {
return { completed: false, stoppedCount: 0 };
diff --git a/web/lib/inngest/functions/rollout-workflow.ts b/web/lib/inngest/functions/rollout-workflow.ts
index 0e2dbb96..599e208f 100644
--- a/web/lib/inngest/functions/rollout-workflow.ts
+++ b/web/lib/inngest/functions/rollout-workflow.ts
@@ -1,7 +1,7 @@
import { and, eq, gte, inArray, isNull, lt, ne, or, sql } from "drizzle-orm";
import { db } from "@/db";
-import { getRuntimeService } from "@/db/queries";
-import { deployments, rollouts, servers } from "@/db/schema";
+import { getService } from "@/db/queries";
+import { deployments, rollouts, servers, services } from "@/db/schema";
import { isObservedReady, observedReadyPhases } from "@/lib/deployment-status";
import { buildRoutingTargets } from "@/lib/routing-sync";
import {
@@ -207,7 +207,7 @@ export const rolloutWorkflow = inngest.createFunction(
const { rolloutId, serviceId } = event.data;
await step.run("validate-service", async () => {
- const svc = await getRuntimeService(serviceId);
+ const svc = await getService(serviceId);
if (!svc) {
throw new Error("Service not found");
}
@@ -615,6 +615,21 @@ export const rolloutWorkflow = inngest.createFunction(
await tx.execute(
sql`SELECT pg_advisory_xact_lock(hashtext(${serviceId}))`,
);
+ const service = await tx
+ .select({
+ previewOfService: services.previewOfService,
+ previewCurrentRevisionId: services.previewCurrentRevisionId,
+ })
+ .from(services)
+ .where(and(eq(services.id, serviceId), isNull(services.deletedAt)))
+ .then((rows) => rows[0]);
+ if (
+ !service ||
+ (service.previewOfService &&
+ service.previewCurrentRevisionId !== revision.id)
+ ) {
+ throw new Error("Preview revision was superseded before routing");
+ }
const [rollout] = await tx
.select({ status: rollouts.status })
.from(rollouts)
diff --git a/web/lib/preview-deployments.ts b/web/lib/preview-deployments.ts
index 1e656fea..7bb7e7ed 100644
--- a/web/lib/preview-deployments.ts
+++ b/web/lib/preview-deployments.ts
@@ -1,25 +1,28 @@
import { randomUUID } from "node:crypto";
-import { and, desc, eq, isNull, sql } from "drizzle-orm";
+import { and, eq, isNull, sql } from "drizzle-orm";
import { db } from "@/db";
import { getSetting } from "@/db/queries";
import {
- builds,
+ environments,
githubRepos,
- rollouts,
secrets,
servers,
servicePorts,
serviceReplicas,
- serviceRevisions,
services,
} from "@/db/schema";
import { updateGitHubDeploymentStatus } from "@/lib/github";
import { resolveRegistryImageHost } from "@/lib/registry-reference";
-import { parseServiceRevisionSpec } from "@/lib/service-revision-changes";
+import {
+ pullRequestMergeRef,
+ pullRequestNumberFromMergeRef,
+} from "@/lib/service-revision-spec";
import { SETTING_KEYS } from "@/lib/settings-keys";
const DNS_LABEL_MAX_LENGTH = 63;
-export const PREVIEW_RECONCILIATION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
+export const PREVIEWS_ENVIRONMENT_NAME = "previews";
+
+type PreviewTransaction = Parameters[0]>[0];
type PreviewPort = {
port: number;
@@ -84,6 +87,59 @@ export async function requirePreviewDomain() {
return normalized;
}
+async function ensurePreviewEnvironmentInTransaction(
+ tx: PreviewTransaction,
+ projectId: string,
+) {
+ await tx.execute(
+ sql`select pg_advisory_xact_lock(hashtext(${`preview-environment:${projectId}`}))`,
+ );
+ const existing = await tx
+ .select()
+ .from(environments)
+ .where(
+ and(
+ eq(environments.projectId, projectId),
+ eq(environments.name, PREVIEWS_ENVIRONMENT_NAME),
+ ),
+ )
+ .then((rows) => rows[0]);
+ if (existing) return existing;
+
+ const created = await tx
+ .insert(environments)
+ .values({
+ id: randomUUID(),
+ projectId,
+ name: PREVIEWS_ENVIRONMENT_NAME,
+ })
+ .onConflictDoNothing({
+ target: [environments.projectId, environments.name],
+ })
+ .returning()
+ .then((rows) => rows[0]);
+ if (created) return created;
+
+ const concurrent = await tx
+ .select()
+ .from(environments)
+ .where(
+ and(
+ eq(environments.projectId, projectId),
+ eq(environments.name, PREVIEWS_ENVIRONMENT_NAME),
+ ),
+ )
+ .then((rows) => rows[0]);
+ if (!concurrent) throw new Error("Failed to create previews environment");
+ return concurrent;
+}
+
+export async function ensurePreviewEnvironment(projectId: string) {
+ return db.transaction((tx) =>
+ ensurePreviewEnvironmentInTransaction(tx, projectId),
+ );
+}
+
export function previewPortConfiguration(input: {
ports: PreviewPort[];
serviceName: string;
@@ -120,34 +176,34 @@ export function previewPortConfiguration(input: {
export async function getPreviewClone(
baseServiceId: string,
- pullRequestNumber: number,
+ previewGitRef: string,
) {
+ pullRequestNumberFromMergeRef(previewGitRef);
return db
.select()
.from(services)
.where(
and(
- eq(services.previewOfServiceId, baseServiceId),
- eq(services.previewPullRequestNumber, pullRequestNumber),
+ eq(services.previewOfService, baseServiceId),
+ eq(services.previewGitRef, previewGitRef),
isNull(services.deletedAt),
),
)
.then((rows) => rows[0] ?? null);
}
-export async function createOrRefreshPreviewClone(input: {
+export async function createPreviewClone(input: {
baseServiceId: string;
- pullRequestNumber: number;
- now?: Date;
+ previewGitRef: string;
}) {
const domain = await requirePreviewDomain();
- const now = input.now ?? new Date();
+ const pullRequestNumber = pullRequestNumberFromMergeRef(input.previewGitRef);
return db.transaction(async (tx) => {
await tx.execute(
sql`select pg_advisory_xact_lock(hashtext(${input.baseServiceId}))`,
);
await tx.execute(
- sql`select pg_advisory_xact_lock(hashtext(${input.baseServiceId}), ${input.pullRequestNumber})`,
+ sql`select pg_advisory_xact_lock(hashtext(${input.baseServiceId}), hashtext(${input.previewGitRef}))`,
);
const base = await tx
@@ -156,7 +212,7 @@ export async function createOrRefreshPreviewClone(input: {
.where(
and(
eq(services.id, input.baseServiceId),
- isNull(services.previewOfServiceId),
+ isNull(services.previewOfService),
isNull(services.deletedAt),
),
)
@@ -171,6 +227,37 @@ export async function createOrRefreshPreviewClone(input: {
throw new Error("Preview deployments require a GitHub App service");
}
+ const existing = await tx
+ .select()
+ .from(services)
+ .where(
+ and(
+ eq(services.previewOfService, base.id),
+ eq(services.previewGitRef, input.previewGitRef),
+ isNull(services.deletedAt),
+ ),
+ )
+ .then((rows) => rows[0]);
+ if (existing) {
+ const primaryDomain = await tx
+ .select({ domain: servicePorts.domain })
+ .from(servicePorts)
+ .where(
+ and(
+ eq(servicePorts.serviceId, existing.id),
+ eq(servicePorts.protocol, "http"),
+ eq(servicePorts.isPublic, true),
+ ),
+ )
+ .orderBy(servicePorts.port, servicePorts.id)
+ .then((rows) => rows.find((row) => row.domain)?.domain ?? null);
+ return {
+ serviceId: existing.id,
+ created: false,
+ primaryUrl: primaryDomain ? `https://${primaryDomain}` : null,
+ };
+ }
+
const [repo, ports, sourceSecrets, placements] = await Promise.all([
tx
.select()
@@ -222,23 +309,16 @@ export async function createOrRefreshPreviewClone(input: {
throw new Error("No eligible placement exists for this preview");
}
- const existing = await tx
- .select()
- .from(services)
- .where(
- and(
- eq(services.previewOfServiceId, base.id),
- eq(services.previewPullRequestNumber, input.pullRequestNumber),
- isNull(services.deletedAt),
- ),
- )
- .then((rows) => rows[0]);
- const previewServiceId = existing?.id ?? randomUUID();
+ const previewEnvironment = await ensurePreviewEnvironmentInTransaction(
+ tx,
+ base.projectId,
+ );
+ const previewServiceId = randomUUID();
const configuredPorts = previewPortConfiguration({
ports,
serviceName: base.name,
serviceId: base.id,
- pullRequestNumber: input.pullRequestNumber,
+ pullRequestNumber,
domain,
});
const primaryDomain = configuredPorts.find(
@@ -249,8 +329,8 @@ export async function createOrRefreshPreviewClone(input: {
const serviceValues = {
projectId: base.projectId,
- environmentId: base.environmentId,
- name: `${base.name} (PR #${input.pullRequestNumber})`,
+ environmentId: previewEnvironment.id,
+ name: `${base.name} (PR #${pullRequestNumber})`,
hostname: primaryDomain.split(".")[0],
image: `${resolveRegistryImageHost()}/${base.projectId}/${previewServiceId}:latest`,
sourceType: "github" as const,
@@ -277,34 +357,14 @@ export async function createOrRefreshPreviewClone(input: {
backupEnabled: false,
backupSchedule: null,
previewDeploymentsEnabled: false,
- previewOfServiceId: base.id,
- previewPullRequestNumber: input.pullRequestNumber,
- previewError: null,
- previewExpiresAt: new Date(now.getTime() + PREVIEW_RECONCILIATION_TTL_MS),
+ previewOfService: base.id,
+ previewGitRef: pullRequestMergeRef(pullRequestNumber),
};
- if (existing) {
- await tx
- .update(services)
- .set(serviceValues)
- .where(eq(services.id, previewServiceId));
- } else {
- await tx.insert(services).values({
- id: previewServiceId,
- ...serviceValues,
- });
- }
-
- await Promise.all([
- tx
- .delete(servicePorts)
- .where(eq(servicePorts.serviceId, previewServiceId)),
- tx
- .delete(serviceReplicas)
- .where(eq(serviceReplicas.serviceId, previewServiceId)),
- tx.delete(secrets).where(eq(secrets.serviceId, previewServiceId)),
- tx.delete(githubRepos).where(eq(githubRepos.serviceId, previewServiceId)),
- ]);
+ await tx.insert(services).values({
+ id: previewServiceId,
+ ...serviceValues,
+ });
await tx.insert(servicePorts).values(
configuredPorts.map((port) => ({
id: randomUUID(),
@@ -344,37 +404,19 @@ export async function createOrRefreshPreviewClone(input: {
return {
serviceId: previewServiceId,
- created: !existing,
+ created: true,
primaryUrl: `https://${primaryDomain}`,
};
});
}
-export async function isCurrentPreviewRevision(
- serviceId: string,
- serviceRevisionId: string,
-) {
- const clone = await db
- .select({ id: services.id })
- .from(services)
- .where(
- and(
- eq(services.id, serviceId),
- eq(services.previewCurrentRevisionId, serviceRevisionId),
- isNull(services.deletedAt),
- ),
- )
- .then((rows) => rows[0]);
- return Boolean(clone);
-}
-
export async function canDeployServiceRevision(
serviceId: string,
serviceRevisionId: string,
) {
const service = await db
.select({
- previewOfServiceId: services.previewOfServiceId,
+ previewOfService: services.previewOfService,
previewCurrentRevisionId: services.previewCurrentRevisionId,
})
.from(services)
@@ -382,7 +424,7 @@ export async function canDeployServiceRevision(
.then((rows) => rows[0]);
if (!service) return false;
return (
- !service.previewOfServiceId ||
+ !service.previewOfService ||
service.previewCurrentRevisionId === serviceRevisionId
);
}
@@ -410,7 +452,7 @@ export async function getPreviewPrimaryUrl(serviceId: string) {
export async function updateCurrentPreviewGitHubStatus(input: {
serviceId: string;
- serviceRevisionId: string;
+ serviceRevisionId: string | null;
state: "pending" | "in_progress" | "success" | "failure" | "inactive";
description: string;
logUrl?: string;
@@ -424,16 +466,19 @@ export async function updateCurrentPreviewGitHubStatus(input: {
.select({
previewCurrentRevisionId: services.previewCurrentRevisionId,
previewGithubDeploymentId: services.previewGithubDeploymentId,
- previewOfServiceId: services.previewOfServiceId,
+ previewOfService: services.previewOfService,
installationId: githubRepos.installationId,
repoFullName: githubRepos.repoFullName,
})
.from(services)
- .innerJoin(githubRepos, eq(githubRepos.serviceId, services.id))
+ .innerJoin(
+ githubRepos,
+ eq(githubRepos.serviceId, services.previewOfService),
+ )
.where(and(eq(services.id, input.serviceId), isNull(services.deletedAt)))
.then((rows) => rows[0]);
if (
- !context?.previewOfServiceId ||
+ !context?.previewOfService ||
context.previewCurrentRevisionId !== input.serviceRevisionId ||
!context.previewGithubDeploymentId ||
(input.expectedDeploymentId !== undefined &&
@@ -477,101 +522,3 @@ export async function updateCurrentPreviewGitHubStatus(input: {
return true;
});
}
-
-export async function listPreviewDeployments(baseServiceId: string) {
- const clones = await db
- .select()
- .from(services)
- .where(
- and(
- eq(services.previewOfServiceId, baseServiceId),
- isNull(services.deletedAt),
- ),
- )
- .orderBy(services.previewPullRequestNumber);
- return Promise.all(
- clones.map(async (clone) => {
- const revisionId = clone.previewCurrentRevisionId;
- const [revision, revisionBuilds, rollout, primaryUrl] = await Promise.all(
- [
- revisionId
- ? db
- .select({
- specification: serviceRevisions.specification,
- createdAt: serviceRevisions.createdAt,
- })
- .from(serviceRevisions)
- .where(eq(serviceRevisions.id, revisionId))
- .then((rows) => rows[0])
- : null,
- revisionId
- ? db
- .select({
- id: builds.id,
- status: builds.status,
- error: builds.error,
- })
- .from(builds)
- .where(eq(builds.serviceRevisionId, revisionId))
- : [],
- revisionId
- ? db
- .select({
- id: rollouts.id,
- status: rollouts.status,
- currentStage: rollouts.currentStage,
- })
- .from(rollouts)
- .where(eq(rollouts.serviceRevisionId, revisionId))
- .orderBy(desc(rollouts.createdAt))
- .then((rows) => rows[0])
- : null,
- getPreviewPrimaryUrl(clone.id),
- ],
- );
- let commitSha: string | null = null;
- if (revision) {
- const specification = parseServiceRevisionSpec(revision.specification);
- if (specification.source.type === "github") {
- commitSha = specification.source.commitSha;
- }
- }
- const failedBuild = revisionBuilds.find(
- (build) => build.status === "failed",
- );
- const activeBuild = revisionBuilds.some((build) =>
- ["pending", "claimed", "cloning", "building", "pushing"].includes(
- build.status,
- ),
- );
- const status =
- clone.previewError || failedBuild
- ? "failed"
- : rollout?.status === "completed"
- ? "ready"
- : rollout?.status === "failed" || rollout?.status === "rolled_back"
- ? "failed"
- : rollout
- ? "deploying"
- : activeBuild || revisionBuilds.length > 0
- ? "building"
- : "queued";
- return {
- serviceId: clone.id,
- pullRequestNumber: clone.previewPullRequestNumber!,
- status,
- commitSha,
- url: primaryUrl,
- error:
- clone.previewError ??
- failedBuild?.error ??
- (rollout && ["failed", "rolled_back"].includes(rollout.status)
- ? rollout.currentStage
- : null),
- updatedAt:
- revision?.createdAt.toISOString() ?? clone.createdAt.toISOString(),
- expiresAt: clone.previewExpiresAt?.toISOString() ?? null,
- };
- }),
- );
-}
diff --git a/web/lib/preview-lifecycle.ts b/web/lib/preview-lifecycle.ts
index 50182d3d..5215e95f 100644
--- a/web/lib/preview-lifecycle.ts
+++ b/web/lib/preview-lifecycle.ts
@@ -1,4 +1,4 @@
-import { and, eq, inArray, sql } from "drizzle-orm";
+import { and, eq, inArray, isNull, sql } from "drizzle-orm";
import { db } from "@/db";
import {
builds,
@@ -16,6 +16,7 @@ import {
cleanupRegistryArtifactsForService,
prepareRegistryArtifactCleanup,
} from "@/lib/registry-retention";
+import { pullRequestNumberFromMergeRef } from "@/lib/service-revision-spec";
import {
enqueueReconcileForAllOnlineServers,
enqueueWork,
@@ -29,6 +30,8 @@ const activeBuildStatuses = [
"pushing",
] as const;
+type GitHubDeploymentCleanup = "report" | "defer" | "skip";
+
async function cancelBuildRows(serviceId: string, serviceRevisionId?: string) {
const conditions = [
eq(builds.serviceId, serviceId),
@@ -58,33 +61,57 @@ async function cancelRolloutRows(
serviceId: string,
serviceRevisionId?: string,
) {
- const conditions = [
- eq(rollouts.serviceId, serviceId),
- inArray(rollouts.status, ["queued", "in_progress"]),
- ];
- if (serviceRevisionId) {
- conditions.push(eq(rollouts.serviceRevisionId, serviceRevisionId));
- }
- const cancelled = await db
- .update(rollouts)
- .set({
- status: "failed",
- currentStage: "superseded",
- completedAt: new Date(),
- })
- .where(and(...conditions))
- .returning({ id: rollouts.id });
+ const { cancelled, rolloutDeployments } = await db.transaction(async (tx) => {
+ await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${serviceId}))`);
+ const conditions = [
+ eq(rollouts.serviceId, serviceId),
+ inArray(rollouts.status, ["queued", "in_progress"]),
+ ];
+ if (serviceRevisionId) {
+ conditions.push(eq(rollouts.serviceRevisionId, serviceRevisionId));
+ }
+ const cancelled = await tx
+ .update(rollouts)
+ .set({
+ status: "failed",
+ currentStage: "superseded",
+ completedAt: new Date(),
+ })
+ .where(and(...conditions))
+ .returning({ id: rollouts.id });
+ if (cancelled.length === 0) {
+ return { cancelled, rolloutDeployments: [] };
+ }
+
+ const rolloutIds = cancelled.map(({ id }) => id);
+ const rolloutDeployments = await tx
+ .select()
+ .from(deployments)
+ .where(inArray(deployments.rolloutId, rolloutIds));
+ if (
+ rolloutDeployments.some(
+ (deployment) => deployment.trafficState === "active",
+ )
+ ) {
+ await tx
+ .update(deployments)
+ .set({ trafficState: "active" })
+ .where(
+ and(
+ eq(deployments.serviceId, serviceId),
+ eq(deployments.trafficState, "draining"),
+ ),
+ );
+ }
+ await tx
+ .update(deployments)
+ .set(markDeploymentRemoved())
+ .where(inArray(deployments.rolloutId, rolloutIds));
+ await enqueueReconcileForAllOnlineServers("preview_rollout_cancelled", tx);
+ return { cancelled, rolloutDeployments };
+ });
if (cancelled.length === 0) return;
- const rolloutIds = cancelled.map(({ id }) => id);
- const rolloutDeployments = await db
- .select()
- .from(deployments)
- .where(inArray(deployments.rolloutId, rolloutIds));
- await db
- .update(deployments)
- .set(markDeploymentRemoved())
- .where(inArray(deployments.rolloutId, rolloutIds));
for (const deployment of rolloutDeployments) {
if (!deployment.containerId) continue;
await enqueueWork(deployment.serverId, "stop", {
@@ -100,9 +127,6 @@ async function cancelRolloutRows(
),
);
}
- await db.transaction((tx) =>
- enqueueReconcileForAllOnlineServers("preview_rollout_cancelled", tx),
- );
}
export async function cancelPreviewRevisionWork(
@@ -125,13 +149,18 @@ export async function deactivatePreviewRuntime(serviceId: string) {
.update(deployments)
.set(markDeploymentRemoved())
.where(eq(deployments.serviceId, serviceId));
- for (const deployment of runtime) {
- if (!deployment.containerId) continue;
- await enqueueWork(deployment.serverId, "stop", {
- deploymentId: deployment.id,
- containerId: deployment.containerId,
- });
- }
+ await Promise.all(
+ runtime.flatMap((deployment) =>
+ deployment.containerId
+ ? [
+ enqueueWork(deployment.serverId, "stop", {
+ deploymentId: deployment.id,
+ containerId: deployment.containerId,
+ }),
+ ]
+ : [],
+ ),
+ );
await db.transaction((tx) =>
enqueueReconcileForAllOnlineServers("preview_runtime_deactivated", tx),
);
@@ -139,23 +168,29 @@ export async function deactivatePreviewRuntime(serviceId: string) {
export async function deletePreviewService(
baseServiceId: string,
- pullRequestNumber: number,
+ previewGitRef: string,
+ reason = "removed",
+ options: { githubDeploymentCleanup?: GitHubDeploymentCleanup } = {},
) {
+ pullRequestNumberFromMergeRef(previewGitRef);
const claimed = await db.transaction(async (tx) => {
await tx.execute(
sql`select pg_advisory_xact_lock(hashtext(${baseServiceId}))`,
);
await tx.execute(
- sql`select pg_advisory_xact_lock(hashtext(${baseServiceId}), ${pullRequestNumber})`,
+ sql`select pg_advisory_xact_lock(hashtext(${baseServiceId}), hashtext(${previewGitRef}))`,
);
const context = await tx
.select({ service: services, githubRepo: githubRepos })
.from(services)
- .innerJoin(githubRepos, eq(githubRepos.serviceId, services.id))
+ .leftJoin(
+ githubRepos,
+ eq(githubRepos.serviceId, services.previewOfService),
+ )
.where(
and(
- eq(services.previewOfServiceId, baseServiceId),
- eq(services.previewPullRequestNumber, pullRequestNumber),
+ eq(services.previewOfService, baseServiceId),
+ eq(services.previewGitRef, previewGitRef),
),
)
.then((rows) => rows[0]);
@@ -176,7 +211,7 @@ export async function deletePreviewService(
.update(services)
.set({
deletedAt: new Date(),
- purgeAfter: new Date(),
+ purgeAfter: null,
deletionStatus: "deleting",
})
.where(eq(services.id, context.service.id));
@@ -192,16 +227,25 @@ export async function deletePreviewService(
.select()
.from(deployments)
.where(eq(deployments.serviceId, claimed.service.id));
- for (const deployment of runtime) {
- if (deployment.containerId) {
- await enqueueWork(deployment.serverId, "stop", {
- deploymentId: deployment.id,
- containerId: deployment.containerId,
- });
- }
- await db
- .delete(deploymentPorts)
- .where(eq(deploymentPorts.deploymentId, deployment.id));
+ await Promise.all(
+ runtime.flatMap((deployment) =>
+ deployment.containerId
+ ? [
+ enqueueWork(deployment.serverId, "stop", {
+ deploymentId: deployment.id,
+ containerId: deployment.containerId,
+ }),
+ ]
+ : [],
+ ),
+ );
+ if (runtime.length > 0) {
+ await db.delete(deploymentPorts).where(
+ inArray(
+ deploymentPorts.deploymentId,
+ runtime.map((deployment) => deployment.id),
+ ),
+ );
}
await db
.delete(deployments)
@@ -210,63 +254,80 @@ export async function deletePreviewService(
enqueueReconcileForAllOnlineServers("preview_deleted", tx),
);
await cleanupRegistryArtifactsForService(claimed.service.id);
- await db.delete(services).where(eq(services.id, claimed.service.id));
+ if (
+ (options.githubDeploymentCleanup ?? "report") === "report" &&
+ claimed.githubRepo &&
+ claimed.service.previewGithubDeploymentId
+ ) {
+ await updateGitHubDeploymentStatus(
+ claimed.githubRepo.installationId,
+ claimed.githubRepo.repoFullName,
+ claimed.service.previewGithubDeploymentId,
+ "inactive",
+ { description: `Preview removed: ${reason}`.substring(0, 140) },
+ );
+ }
+ if ((options.githubDeploymentCleanup ?? "report") !== "defer") {
+ await db.delete(services).where(eq(services.id, claimed.service.id));
+ }
return claimed;
}
export async function deletePreviewsForBaseService(
baseServiceId: string,
reason: string,
+ options: { githubDeploymentCleanup?: GitHubDeploymentCleanup } = {},
) {
const previews = await db
- .select({ service: services, githubRepo: githubRepos })
+ .select({ service: services })
.from(services)
- .innerJoin(githubRepos, eq(githubRepos.serviceId, services.id))
- .where(
- eq(services.previewOfServiceId, baseServiceId),
- );
+ .where(eq(services.previewOfService, baseServiceId));
for (const preview of previews) {
- const pullRequestNumber = preview.service.previewPullRequestNumber;
- if (!pullRequestNumber) continue;
- await deletePreviewService(baseServiceId, pullRequestNumber);
- if (!preview.service.previewGithubDeploymentId) continue;
- try {
- await updateGitHubDeploymentStatus(
- preview.githubRepo.installationId,
- preview.githubRepo.repoFullName,
- preview.service.previewGithubDeploymentId,
- "inactive",
- { description: `Preview removed: ${reason}`.substring(0, 140) },
- );
- } catch (error) {
- console.error(
- `[preview:delete] failed to mark GitHub deployment ${preview.service.previewGithubDeploymentId} inactive:`,
- error,
- );
- }
+ const previewGitRef = preview.service.previewGitRef;
+ if (!previewGitRef) continue;
+ await deletePreviewService(baseServiceId, previewGitRef, reason, options);
}
}
export async function deletePreviewsForGitHubInstallation(
installationId: number,
reason: string,
+ options: {
+ removeRepositoryLinks?: boolean;
+ githubDeploymentCleanup?: GitHubDeploymentCleanup;
+ } = {},
) {
- const previews = await db
- .select({
- baseServiceId: services.previewOfServiceId,
- pullRequestNumber: services.previewPullRequestNumber,
- })
- .from(services)
- .innerJoin(githubRepos, eq(githubRepos.serviceId, services.id))
- .where(
- eq(githubRepos.installationId, installationId),
- );
- const baseServiceIds = new Set(
- previews.flatMap((preview) =>
- preview.baseServiceId ? [preview.baseServiceId] : [],
- ),
- );
+ const baseServiceIds = await db.transaction(async (tx) => {
+ const ids = await tx
+ .select({ id: services.id })
+ .from(services)
+ .innerJoin(githubRepos, eq(githubRepos.serviceId, services.id))
+ .where(
+ and(
+ eq(githubRepos.installationId, installationId),
+ isNull(services.previewOfService),
+ ),
+ )
+ .then((rows) => rows.map(({ id }) => id).sort());
+ for (const id of ids) {
+ await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${id}))`);
+ }
+ if (ids.length > 0) {
+ await tx
+ .update(services)
+ .set({ previewDeploymentsEnabled: false })
+ .where(inArray(services.id, ids));
+ }
+ if (options.removeRepositoryLinks) {
+ await tx
+ .delete(githubRepos)
+ .where(eq(githubRepos.installationId, installationId));
+ }
+ return ids;
+ });
for (const baseServiceId of baseServiceIds) {
- await deletePreviewsForBaseService(baseServiceId, reason);
+ await deletePreviewsForBaseService(baseServiceId, reason, {
+ githubDeploymentCleanup: options.githubDeploymentCleanup ?? "skip",
+ });
}
}
diff --git a/web/lib/public-api.ts b/web/lib/public-api.ts
index c4e09a7d..e9993ea1 100644
--- a/web/lib/public-api.ts
+++ b/web/lib/public-api.ts
@@ -263,13 +263,7 @@ export async function findServiceContext(serviceId: string) {
eq(environments.projectId, projects.id),
),
)
- .where(
- and(
- eq(services.id, serviceId),
- isNull(services.deletedAt),
- isNull(services.previewOfServiceId),
- ),
- )
+ .where(and(eq(services.id, serviceId), isNull(services.deletedAt)))
.limit(1)
.then((rows) => rows[0] ?? null);
}
diff --git a/web/lib/scheduler.ts b/web/lib/scheduler.ts
index 4314667e..ab478e25 100644
--- a/web/lib/scheduler.ts
+++ b/web/lib/scheduler.ts
@@ -79,7 +79,6 @@ export async function runAutoscalingController(
.where(
and(
isNull(services.deletedAt),
- isNull(services.previewOfServiceId),
or(
isNull(services.lastAutoscaleAttemptAt),
lt(services.lastAutoscaleAttemptAt, cooldownCutoff),
@@ -255,7 +254,6 @@ export async function rebalanceAutomaticServices(
.where(
and(
isNull(services.deletedAt),
- isNull(services.previewOfServiceId),
inArray(deployments.runtimeDesiredState, ["running", "stopped"]),
eq(deployments.trafficState, "active"),
eq(
@@ -718,11 +716,7 @@ export async function checkAndRunScheduledDeployments(): Promise {
})
.from(services)
.where(
- and(
- isNotNull(services.deploymentSchedule),
- isNull(services.deletedAt),
- isNull(services.previewOfServiceId),
- ),
+ and(isNotNull(services.deploymentSchedule), isNull(services.deletedAt)),
);
if (scheduledServices.length === 0) return;
diff --git a/web/lib/service-crons.ts b/web/lib/service-crons.ts
index ec5893da..9b7b2c90 100644
--- a/web/lib/service-crons.ts
+++ b/web/lib/service-crons.ts
@@ -153,11 +153,7 @@ export async function executeServiceCron(
.from(serviceCrons)
.innerJoin(
services,
- and(
- eq(serviceCrons.serviceId, services.id),
- isNull(services.deletedAt),
- isNull(services.previewOfServiceId),
- ),
+ and(eq(serviceCrons.serviceId, services.id), isNull(services.deletedAt)),
)
.where(eq(serviceCrons.id, cronId))
.limit(1)
diff --git a/web/lib/service-revision-changes.ts b/web/lib/service-revision-changes.ts
index 043fca26..af27ffdd 100644
--- a/web/lib/service-revision-changes.ts
+++ b/web/lib/service-revision-changes.ts
@@ -16,7 +16,7 @@ const legacySourceSchema = z.discriminatedUnion("type", [
z.strictObject({ type: z.literal("image"), image: z.string() }),
z.strictObject({
type: z.literal("github"),
- repository: z.string().url(),
+ repository: z.url(),
repositoryId: z.number().int().positive().nullable(),
branch: z.string().min(1),
commitSha: z.string().regex(/^[0-9a-f]{40}$/),
@@ -37,7 +37,7 @@ const serviceRevisionSpecFields = {
z.strictObject({ type: z.literal("image"), image: z.string() }),
z.strictObject({
type: z.literal("github"),
- repository: z.string().url(),
+ repository: z.url(),
repositoryId: z.number().int().positive().nullable(),
branch: z.string().min(1),
gitRef: z.string().refine(isSupportedGitRef, "Unsupported Git ref"),
diff --git a/web/lib/service-revision-spec.ts b/web/lib/service-revision-spec.ts
index 07d0f114..e0bad318 100644
--- a/web/lib/service-revision-spec.ts
+++ b/web/lib/service-revision-spec.ts
@@ -28,6 +28,23 @@ export function gitBranchRef(branch: string): string {
return ref;
}
+export function pullRequestMergeRef(pullRequestNumber: number): string {
+ if (!Number.isSafeInteger(pullRequestNumber) || pullRequestNumber <= 0) {
+ throw new Error("Invalid pull request number");
+ }
+ return `refs/pull/${pullRequestNumber}/merge`;
+}
+
+export function pullRequestNumberFromMergeRef(gitRef: string): number {
+ const match = /^refs\/pull\/([1-9]\d*)\/merge$/.exec(gitRef);
+ if (!match) throw new Error("Invalid pull request merge ref");
+ const pullRequestNumber = Number(match[1]);
+ if (!Number.isSafeInteger(pullRequestNumber)) {
+ throw new Error("Invalid pull request merge ref");
+ }
+ return pullRequestNumber;
+}
+
export function getDefaultServiceHostname(
name: string,
serviceId: string,
diff --git a/web/lib/service-revisions.ts b/web/lib/service-revisions.ts
index 8d9a0530..246671d7 100644
--- a/web/lib/service-revisions.ts
+++ b/web/lib/service-revisions.ts
@@ -383,7 +383,7 @@ export async function cloneActiveRevisionAndQueueSystemRollout(
const activeService = await tx
.select({
id: services.id,
- previewOfServiceId: services.previewOfServiceId,
+ previewOfService: services.previewOfService,
})
.from(services)
.where(and(eq(services.id, serviceId), isNull(services.deletedAt)))
@@ -429,7 +429,7 @@ export async function cloneActiveRevisionAndQueueSystemRollout(
specification: active.specification,
actor: { type: "system" },
});
- if (activeService.previewOfServiceId) {
+ if (activeService.previewOfService) {
await tx
.update(services)
.set({ previewCurrentRevisionId: revisionId })
@@ -612,7 +612,7 @@ export async function createRolloutForServiceRevision(
tx
.select({
id: services.id,
- previewOfServiceId: services.previewOfServiceId,
+ previewOfService: services.previewOfService,
previewCurrentRevisionId: services.previewCurrentRevisionId,
})
.from(services)
@@ -624,7 +624,7 @@ export async function createRolloutForServiceRevision(
return { rolloutId: null, revision, created: false };
}
if (
- activeService.previewOfServiceId &&
+ activeService.previewOfService &&
activeService.previewCurrentRevisionId !== serviceRevisionId
) {
return { rolloutId: null, revision, created: false };
diff --git a/web/lib/trigger-build.ts b/web/lib/trigger-build.ts
index 33cf490d..8a652a82 100644
--- a/web/lib/trigger-build.ts
+++ b/web/lib/trigger-build.ts
@@ -193,6 +193,26 @@ export async function triggerBuildInternal(
actor: ServiceRevisionActor,
) {
const sourceContext = await getGitHubBuildSource(serviceId);
+ if (
+ sourceContext.service.previewOfService &&
+ sourceContext.service.previewGitRef
+ ) {
+ await inngest.send(
+ inngestEvents.previewSyncRequested.create(
+ {
+ baseServiceId: sourceContext.service.previewOfService,
+ previewGitRef: sourceContext.service.previewGitRef,
+ force: true,
+ },
+ { id: `preview-user-sync:${serviceId}:${randomUUID()}` },
+ ),
+ );
+ return {
+ buildId: null,
+ serviceRevisionId: null,
+ status: "queued" as const,
+ };
+ }
const { repo, source } = sourceContext;
const repoFullName =
repo?.repoFullName ??
diff --git a/web/tests/build-status-route.test.ts b/web/tests/build-status-route.test.ts
index 99934fb6..a03d0e8a 100644
--- a/web/tests/build-status-route.test.ts
+++ b/web/tests/build-status-route.test.ts
@@ -314,14 +314,14 @@ describe("agent build status transitions", () => {
specification: previewSpecification,
projectSlug: "cloud",
environmentName: "production",
- previewOfServiceId: "base-service",
+ previewOfService: "base-service",
},
],
[completedBuild],
[
{
id: "service-1",
- previewOfServiceId: "base-service",
+ previewOfService: "base-service",
previewCurrentRevisionId: "revision-1",
},
],
@@ -336,7 +336,7 @@ describe("agent build status transitions", () => {
state: "in_progress",
description: "Preview image built; preparing deployment",
logUrl:
- "https://cloud.techulus.com/dashboard/projects/cloud/production/services/base-service/previews",
+ "https://cloud.techulus.com/dashboard/projects/cloud/production/services/service-1/builds/build-amd64",
});
expect(mocks.updateGitHubDeploymentStatus).not.toHaveBeenCalled();
});
diff --git a/web/tests/deploy-service-revision.test.ts b/web/tests/deploy-service-revision.test.ts
index 6dc6949a..f13b2505 100644
--- a/web/tests/deploy-service-revision.test.ts
+++ b/web/tests/deploy-service-revision.test.ts
@@ -28,7 +28,7 @@ vi.mock("@/db", () => ({
})),
},
}));
-vi.mock("@/db/queries", () => ({ getRuntimeService: mocks.getService }));
+vi.mock("@/db/queries", () => ({ getService: mocks.getService }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
vi.mock("@/lib/migrations", () => ({
startMigrationInternal: mocks.startMigrationInternal,
diff --git a/web/tests/github-webhook.test.ts b/web/tests/github-webhook.test.ts
index 32dfa64e..5f8a7a02 100644
--- a/web/tests/github-webhook.test.ts
+++ b/web/tests/github-webhook.test.ts
@@ -70,8 +70,8 @@ function linkedService({
projectSlug = "cloud",
environmentName = "production",
previewDeploymentsEnabled = false,
- previewOfServiceId = null,
- previewPullRequestNumber = null,
+ previewOfService = null,
+ previewGitRef = null,
stateful = false,
}: {
serviceId: string;
@@ -85,8 +85,8 @@ function linkedService({
projectSlug?: string;
environmentName?: string;
previewDeploymentsEnabled?: boolean;
- previewOfServiceId?: string | null;
- previewPullRequestNumber?: number | null;
+ previewOfService?: string | null;
+ previewGitRef?: string | null;
stateful?: boolean;
}) {
return {
@@ -108,8 +108,8 @@ function linkedService({
deletedAt,
githubRootDir: rootDir,
previewDeploymentsEnabled,
- previewOfServiceId,
- previewPullRequestNumber,
+ previewOfService,
+ previewGitRef,
stateful,
},
project: { id: "project-1", name: projectName, slug: projectSlug },
@@ -436,7 +436,7 @@ describe("GitHub pull request webhook", () => {
}));
});
- it("queues one durable sync for each eligible enabled base service", async () => {
+ it("queues one durable sync per eligible enabled base service", async () => {
mocks.queryResults.push([
linkedService({
serviceId: "service-a",
@@ -461,11 +461,52 @@ describe("GitHub pull request webhook", () => {
expect(mocks.send).toHaveBeenCalledWith([
expect.objectContaining({
name: "preview/sync-requested",
- data: { baseServiceId: "service-a", pullRequestNumber: 42 },
+ data: {
+ baseServiceId: "service-a",
+ previewGitRef: "refs/pull/42/merge",
+ },
}),
expect.objectContaining({
name: "preview/sync-requested",
- data: { baseServiceId: "service-b", pullRequestNumber: 42 },
+ data: {
+ baseServiceId: "service-b",
+ previewGitRef: "refs/pull/42/merge",
+ },
+ }),
+ ]);
+ });
+
+ it("closes an existing preview when the pull request changes base branch", async () => {
+ mocks.queryResults.push(
+ [
+ linkedService({
+ serviceId: "service-a",
+ previewDeploymentsEnabled: true,
+ }),
+ ],
+ [
+ linkedService({
+ serviceId: "preview-42",
+ previewOfService: "service-a",
+ previewGitRef: "refs/pull/42/merge",
+ }).service,
+ ],
+ );
+
+ const response = await POST(
+ pullRequest("edited", { baseBranch: "release" }),
+ );
+
+ expect(response.status).toBe(200);
+ expect(mocks.send).toHaveBeenCalledWith([
+ expect.objectContaining({
+ name: "preview/close-requested",
+ data: {
+ baseServiceId: "service-a",
+ previewGitRef: "refs/pull/42/merge",
+ reason: "pull_request_ineligible",
+ verifyWithGitHub: true,
+ },
}),
]);
});
@@ -492,13 +533,16 @@ describe("GitHub pull request webhook", () => {
["closed", false, "pull_request_closed"],
["converted_to_draft", false, "converted_to_draft"],
])("queues teardown for %s", async (action, merged, reason) => {
- mocks.queryResults.push([
- linkedService({
- serviceId: "preview-42",
- previewOfServiceId: "service-a",
- previewPullRequestNumber: 42,
- }),
- ]);
+ mocks.queryResults.push(
+ [linkedService({ serviceId: "service-a" })],
+ [
+ linkedService({
+ serviceId: "preview-42",
+ previewOfService: "service-a",
+ previewGitRef: "refs/pull/42/merge",
+ }).service,
+ ],
+ );
const response = await POST(pullRequest(action, { merged }));
@@ -508,7 +552,7 @@ describe("GitHub pull request webhook", () => {
name: "preview/close-requested",
data: {
baseServiceId: "service-a",
- pullRequestNumber: 42,
+ previewGitRef: "refs/pull/42/merge",
reason,
verifyWithGitHub: true,
},
diff --git a/web/tests/github.test.ts b/web/tests/github.test.ts
index 85b3dd49..b4e3cbe6 100644
--- a/web/tests/github.test.ts
+++ b/web/tests/github.test.ts
@@ -1,9 +1,7 @@
import { generateKeyPairSync } from "node:crypto";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
- createGitHubDeployment,
isFullCommitSha,
- listOpenGitHubPullRequests,
resolveGitHubCommit,
resolveGitHubPullRequestMergeRef,
} from "@/lib/github";
@@ -26,23 +24,6 @@ function configureGitHubApp() {
);
}
-function pullRequest(number: number) {
- return {
- number,
- state: "open",
- draft: false,
- merged: false,
- title: `PR ${number}`,
- updated_at: "2026-08-16T00:00:00Z",
- user: { id: number, login: `user-${number}` },
- base: { ref: "main", repo: { id: 1, full_name: "acme/app" } },
- head: {
- sha: number.toString(16).padStart(40, "0"),
- repo: { id: 1, full_name: "acme/app" },
- },
- };
-}
-
describe("GitHub commit SHA validation", () => {
it("accepts only full hexadecimal commit SHAs", () => {
expect(isFullCommitSha("0123456789abcdef0123456789abcdef01234567")).toBe(
@@ -100,34 +81,6 @@ describe("public GitHub branch resolution", () => {
});
describe("GitHub pull request deployment helpers", () => {
- it("paginates all open pull requests targeting the configured branch", async () => {
- configureGitHubApp();
- const firstPage = Array.from({ length: 100 }, (_, index) =>
- pullRequest(index + 1),
- );
- const fetchMock = vi.fn(
- async (input: string | URL | Request, _init?: RequestInit) => {
- const url = String(input);
- if (url.includes("/access_tokens")) {
- return Response.json({ token: "installation-token" });
- }
- const page = new URL(url).searchParams.get("page");
- if (page === "1") return Response.json(firstPage);
- if (page === "2") return Response.json([pullRequest(101)]);
- throw new Error(`Unexpected GitHub request: ${url}`);
- },
- );
- vi.stubGlobal("fetch", fetchMock);
-
- await expect(
- listOpenGitHubPullRequests(10, "acme/app", "main"),
- ).resolves.toHaveLength(101);
- expect(fetchMock).toHaveBeenCalledWith(
- "https://api.github.com/repos/acme/app/pulls?state=open&base=main&per_page=100&page=2",
- expect.any(Object),
- );
- });
-
it("fails when the synthetic merge ref is unavailable without using the PR head", async () => {
configureGitHubApp();
const fetchMock = vi.fn(
@@ -151,50 +104,4 @@ describe("GitHub pull request deployment helpers", () => {
"https://api.github.com/repos/acme/app/commits?sha=refs%2Fpull%2F42%2Fmerge&per_page=1",
]);
});
-
- it("creates a transient non-production GitHub deployment", async () => {
- configureGitHubApp();
- const fetchMock = vi.fn(
- async (input: string | URL | Request, _init?: RequestInit) => {
- const url = String(input);
- if (url.includes("/access_tokens")) {
- return Response.json({ token: "installation-token" });
- }
- return Response.json({ id: 99 });
- },
- );
- vi.stubGlobal("fetch", fetchMock);
-
- await expect(
- createGitHubDeployment(
- 10,
- "acme/app",
- "a".repeat(40),
- "preview/web/pr-42",
- "Preview PR #42",
- {
- transientEnvironment: true,
- productionEnvironment: false,
- payload: { pullRequestNumber: 42 },
- },
- ),
- ).resolves.toBe(99);
-
- const deploymentCall = fetchMock.mock.calls.find(([input]) =>
- String(input).endsWith("/repos/acme/app/deployments"),
- );
- expect(deploymentCall).toBeDefined();
- const body = JSON.parse(
- (deploymentCall?.[1] as RequestInit | undefined)?.body as string,
- );
- expect(body).toMatchObject({
- ref: "a".repeat(40),
- environment: "preview/web/pr-42",
- transient_environment: true,
- production_environment: false,
- payload: { pullRequestNumber: 42 },
- auto_merge: false,
- required_contexts: [],
- });
- });
});
diff --git a/web/tests/preview-actions.test.ts b/web/tests/preview-actions.test.ts
deleted file mode 100644
index f86bed29..00000000
--- a/web/tests/preview-actions.test.ts
+++ /dev/null
@@ -1,170 +0,0 @@
-import { beforeEach, describe, expect, it, vi } from "vitest";
-
-const mocks = vi.hoisted(() => {
- const updateValues: Array> = [];
- const query = {
- from: vi.fn(() => query),
- where: vi.fn(() => query),
- // oxlint-disable-next-line unicorn/no-thenable -- Drizzle query builders are awaitable.
- then: (
- resolve: (value: unknown[]) => unknown,
- reject?: (reason: unknown) => unknown,
- ) => Promise.resolve([{ id: "repo-1" }]).then(resolve, reject),
- };
- return {
- updateValues,
- getService: vi.fn(),
- requireDeveloperRole: vi.fn(),
- requirePreviewDomain: vi.fn(),
- send: vi.fn(),
- createReconcile: vi.fn((data, options) => ({
- name: "preview/service-reconcile-requested",
- data,
- ...options,
- })),
- createSync: vi.fn((data, options) => ({
- name: "preview/sync-requested",
- data,
- ...options,
- })),
- createClose: vi.fn((data, options) => ({
- name: "preview/close-requested",
- data,
- ...options,
- })),
- db: {
- select: vi.fn(() => query),
- update: vi.fn(() => ({
- set: vi.fn((values: Record) => {
- updateValues.push(values);
- return { where: vi.fn().mockResolvedValue(undefined) };
- }),
- })),
- },
- };
-});
-
-vi.mock("@/db", () => ({ db: mocks.db }));
-vi.mock("@/db/queries", () => ({ getService: mocks.getService }));
-vi.mock("@/lib/auth", () => ({
- requireDeveloperRole: mocks.requireDeveloperRole,
-}));
-vi.mock("@/lib/preview-deployments", () => ({
- requirePreviewDomain: mocks.requirePreviewDomain,
-}));
-vi.mock("@/lib/inngest/client", () => ({ inngest: { send: mocks.send } }));
-vi.mock("@/lib/inngest/events", () => ({
- inngestEvents: {
- previewServiceReconcileRequested: { create: mocks.createReconcile },
- previewSyncRequested: { create: mocks.createSync },
- previewCloseRequested: { create: mocks.createClose },
- },
-}));
-
-import {
- redeployPreview,
- removePreview,
- setPreviewDeploymentsEnabled,
-} from "@/actions/previews";
-
-const service = {
- id: "service-1",
- sourceType: "github",
- stateful: false,
- previewDeploymentsEnabled: false,
-};
-
-describe("preview deployment actions", () => {
- beforeEach(() => {
- vi.clearAllMocks();
- mocks.updateValues.length = 0;
- mocks.getService.mockResolvedValue(service);
- mocks.requireDeveloperRole.mockResolvedValue({ user: { id: "user-1" } });
- mocks.requirePreviewDomain.mockResolvedValue("apps.example.com");
- mocks.send.mockResolvedValue(undefined);
- });
-
- it("requires the developer role before reading a service", async () => {
- mocks.requireDeveloperRole.mockRejectedValue(new Error("Forbidden"));
-
- await expect(
- setPreviewDeploymentsEnabled("service-1", true),
- ).rejects.toThrow("Forbidden");
- expect(mocks.getService).not.toHaveBeenCalled();
- expect(mocks.db.update).not.toHaveBeenCalled();
- });
-
- it("enables previews only after the automatic subdomain is ready", async () => {
- await expect(
- setPreviewDeploymentsEnabled("service-1", true),
- ).resolves.toEqual({ success: true });
-
- expect(mocks.requirePreviewDomain).toHaveBeenCalledOnce();
- expect(mocks.updateValues).toEqual([{ previewDeploymentsEnabled: true }]);
- expect(mocks.send).toHaveBeenCalledWith(
- expect.objectContaining({
- name: "preview/service-reconcile-requested",
- data: { baseServiceId: "service-1" },
- }),
- );
- });
-
- it("rejects stateful services without changing configuration", async () => {
- mocks.getService.mockResolvedValue({ ...service, stateful: true });
-
- await expect(
- setPreviewDeploymentsEnabled("service-1", true),
- ).rejects.toThrow("only for stateless services");
- expect(mocks.db.update).not.toHaveBeenCalled();
- expect(mocks.send).not.toHaveBeenCalled();
- });
-
- it("does not enable previews when no automatic subdomain is configured", async () => {
- mocks.requirePreviewDomain.mockRejectedValue(
- new Error("Automatic Subdomain Domain must be configured"),
- );
-
- await expect(
- setPreviewDeploymentsEnabled("service-1", true),
- ).rejects.toThrow("Automatic Subdomain Domain must be configured");
- expect(mocks.db.update).not.toHaveBeenCalled();
- expect(mocks.send).not.toHaveBeenCalled();
- });
-
- it("queues a forced redeploy only for an enabled base service", async () => {
- mocks.getService.mockResolvedValue({
- ...service,
- previewDeploymentsEnabled: true,
- });
-
- await expect(redeployPreview("service-1", 42)).resolves.toEqual({
- success: true,
- });
- expect(mocks.send).toHaveBeenCalledWith(
- expect.objectContaining({
- name: "preview/sync-requested",
- data: {
- baseServiceId: "service-1",
- pullRequestNumber: 42,
- force: true,
- },
- }),
- );
- });
-
- it("queues an explicit preview teardown through the base service", async () => {
- await expect(removePreview("service-1", 42)).resolves.toEqual({
- success: true,
- });
- expect(mocks.send).toHaveBeenCalledWith(
- expect.objectContaining({
- name: "preview/close-requested",
- data: {
- baseServiceId: "service-1",
- pullRequestNumber: 42,
- reason: "removed manually",
- },
- }),
- );
- });
-});
diff --git a/web/tests/preview-deployments.test.ts b/web/tests/preview-deployments.test.ts
index 04b71ad8..986d6df4 100644
--- a/web/tests/preview-deployments.test.ts
+++ b/web/tests/preview-deployments.test.ts
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => {
const selectResults: unknown[][] = [];
+ const returningResults: unknown[][] = [];
const insertedValues: unknown[] = [];
const updatedValues: unknown[] = [];
function query(result: unknown[]) {
@@ -24,7 +25,18 @@ const mocks = vi.hoisted(() => {
insert: vi.fn(() => ({
values: vi.fn((values: unknown) => {
insertedValues.push(values);
- return Promise.resolve();
+ const result = {
+ onConflictDoNothing: vi.fn(() => result),
+ returning: vi.fn(() =>
+ Promise.resolve(returningResults.shift() ?? []),
+ ),
+ // oxlint-disable-next-line unicorn/no-thenable -- Drizzle query builders are awaitable.
+ then: (
+ resolve: (value: undefined) => unknown,
+ reject?: (reason: unknown) => unknown,
+ ) => Promise.resolve(undefined).then(resolve, reject),
+ };
+ return result;
}),
})),
update: vi.fn(() => ({
@@ -37,6 +49,7 @@ const mocks = vi.hoisted(() => {
};
return {
selectResults,
+ returningResults,
insertedValues,
updatedValues,
tx,
@@ -54,8 +67,8 @@ vi.mock("@/db", () => ({ db: mocks.db }));
vi.mock("@/db/queries", () => ({ getSetting: mocks.getSetting }));
import {
- createOrRefreshPreviewClone,
- previewPortConfiguration,
+ createPreviewClone,
+ ensurePreviewEnvironment,
} from "@/lib/preview-deployments";
const baseService = {
@@ -68,7 +81,7 @@ const baseService = {
githubBranch: "main",
githubRootDir: "apps/web",
previewDeploymentsEnabled: true,
- previewOfServiceId: null,
+ previewOfService: null,
stateful: false,
placementMode: "manual",
healthCheckCmd: "curl -f http://localhost/health",
@@ -117,6 +130,7 @@ const ports = [
function queueFactoryReads(existing: unknown[] = []) {
mocks.selectResults.push(
[baseService],
+ existing,
[repo],
ports,
[
@@ -137,7 +151,7 @@ function queueFactoryReads(existing: unknown[] = []) {
wireguardIp: "10.0.0.1",
},
],
- existing,
+ [{ id: "preview-environment", projectId: "project-1", name: "previews" }],
);
}
@@ -145,6 +159,7 @@ describe("preview service cloning", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.selectResults.length = 0;
+ mocks.returningResults.length = 0;
mocks.insertedValues.length = 0;
mocks.updatedValues.length = 0;
mocks.getSetting.mockResolvedValue("apps.example.com");
@@ -154,10 +169,9 @@ describe("preview service cloning", () => {
it("copies ordinary configuration and secrets while enforcing preview policy", async () => {
queueFactoryReads();
- const result = await createOrRefreshPreviewClone({
+ const result = await createPreviewClone({
baseServiceId: baseService.id,
- pullRequestNumber: 42,
- now: new Date("2026-08-16T00:00:00Z"),
+ previewGitRef: "refs/pull/42/merge",
});
expect(result).toMatchObject({
@@ -168,14 +182,14 @@ describe("preview service cloning", () => {
mocks.insertedValues as Array>;
expect(service).toMatchObject({
projectId: "project-1",
- environmentId: "environment-1",
+ environmentId: "preview-environment",
replicas: 1,
stateful: false,
autoscalingEnabled: false,
serverlessEnabled: false,
previewDeploymentsEnabled: false,
- previewOfServiceId: baseService.id,
- previewPullRequestNumber: 42,
+ previewOfService: baseService.id,
+ previewGitRef: "refs/pull/42/merge",
});
expect(clonedPorts).toEqual(
expect.arrayContaining([
@@ -203,56 +217,75 @@ describe("preview service cloning", () => {
});
});
- it("refreshes the same clone instead of creating another service", async () => {
- queueFactoryReads([
- {
- id: "preview-service-1",
- previewOfServiceId: baseService.id,
- previewPullRequestNumber: 42,
- },
- ]);
+ it("preserves the same visible clone instead of refreshing its configuration", async () => {
+ mocks.selectResults.push(
+ [baseService],
+ [
+ {
+ id: "preview-service-1",
+ previewOfService: baseService.id,
+ previewGitRef: "refs/pull/42/merge",
+ },
+ ],
+ [{ domain: "custom-preview.apps.example.com" }],
+ );
await expect(
- createOrRefreshPreviewClone({
+ createPreviewClone({
baseServiceId: baseService.id,
- pullRequestNumber: 42,
+ previewGitRef: "refs/pull/42/merge",
}),
).resolves.toMatchObject({
serviceId: "preview-service-1",
created: false,
+ primaryUrl: "https://custom-preview.apps.example.com",
});
- expect(mocks.updatedValues[0]).toMatchObject({
- previewOfServiceId: baseService.id,
- previewPullRequestNumber: 42,
- });
- expect(mocks.insertedValues).toHaveLength(4);
- });
-
- it("makes non-HTTP public ports private", () => {
- expect(
- previewPortConfiguration({
- ports,
- serviceName: baseService.name,
- serviceId: baseService.id,
- pullRequestNumber: 42,
- domain: "apps.example.com",
- })[1],
- ).toMatchObject({
- isPublic: false,
- domain: null,
- externalPort: null,
- tlsPassthrough: false,
- });
+ expect(mocks.updatedValues).toHaveLength(0);
+ expect(mocks.insertedValues).toHaveLength(0);
});
it("rejects stateful services", async () => {
mocks.selectResults.push([{ ...baseService, stateful: true }]);
await expect(
- createOrRefreshPreviewClone({
+ createPreviewClone({
baseServiceId: baseService.id,
- pullRequestNumber: 42,
+ previewGitRef: "refs/pull/42/merge",
}),
).rejects.toThrow("require a stateless service");
expect(mocks.tx.insert).not.toHaveBeenCalled();
});
+
+ it("creates the ordinary previews environment when it is missing", async () => {
+ mocks.selectResults.push([]);
+ mocks.returningResults.push([
+ { id: "preview-environment", projectId: "project-1", name: "previews" },
+ ]);
+
+ await expect(ensurePreviewEnvironment("project-1")).resolves.toMatchObject({
+ id: "preview-environment",
+ name: "previews",
+ });
+ expect(mocks.insertedValues).toContainEqual(
+ expect.objectContaining({ projectId: "project-1", name: "previews" }),
+ );
+ });
+
+ it("reuses an environment created concurrently", async () => {
+ mocks.selectResults.push(
+ [],
+ [
+ {
+ id: "concurrent-environment",
+ projectId: "project-1",
+ name: "previews",
+ },
+ ],
+ );
+ mocks.returningResults.push([]);
+
+ await expect(ensurePreviewEnvironment("project-1")).resolves.toMatchObject({
+ id: "concurrent-environment",
+ name: "previews",
+ });
+ });
});
diff --git a/web/tests/preview-policy.test.ts b/web/tests/preview-policy.test.ts
deleted file mode 100644
index feb28a3b..00000000
--- a/web/tests/preview-policy.test.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { previewHostname } from "@/lib/preview-deployments";
-
-describe("preview hostname policy", () => {
- it("generates a stable DNS-safe hostname", () => {
- expect(
- previewHostname({
- serviceName: "Web API 🚀",
- serviceId: "12345678-abcd-4321-abcd-1234567890ab",
- pullRequestNumber: 42,
- domain: "Apps.Example.com.",
- }),
- ).toBe("web-api-pr-42-12345678.apps.example.com");
- });
-
- it("keeps additional public ports unique", () => {
- const input = {
- serviceName: "web",
- serviceId: "12345678-abcd-4321-abcd-1234567890ab",
- pullRequestNumber: 42,
- domain: "apps.example.com",
- };
-
- expect(previewHostname(input)).toBe("web-pr-42-12345678.apps.example.com");
- expect(previewHostname({ ...input, portIndex: 1 })).toBe(
- "web-pr-42-12345678-p2.apps.example.com",
- );
- });
-
- it("truncates only the service name to stay within one DNS label", () => {
- const hostname = previewHostname({
- serviceName: "a".repeat(100),
- serviceId: "12345678-abcd-4321-abcd-1234567890ab",
- pullRequestNumber: 123,
- domain: "apps.example.com",
- });
-
- expect(hostname.split(".")[0]).toHaveLength(63);
- expect(hostname).toMatch(/-pr-123-12345678\.apps\.example\.com$/);
- });
-
- it("rejects invalid pull request numbers", () => {
- expect(() =>
- previewHostname({
- serviceName: "web",
- serviceId: "12345678-abcd-4321-abcd-1234567890ab",
- pullRequestNumber: 0,
- domain: "apps.example.com",
- }),
- ).toThrow("Invalid pull request number");
- });
-});
diff --git a/web/tests/preview-workflow.test.ts b/web/tests/preview-workflow.test.ts
index 361ec92e..7afcfc93 100644
--- a/web/tests/preview-workflow.test.ts
+++ b/web/tests/preview-workflow.test.ts
@@ -3,7 +3,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => {
const selectResults: unknown[][] = [];
const updateResults: unknown[][] = [];
- const updateSets: Array> = [];
function selectQuery(result: unknown[]) {
const query = {
@@ -21,10 +20,7 @@ const mocks = vi.hoisted(() => {
function updateQuery(result: unknown[]) {
const query = {
- set: vi.fn((values: Record) => {
- updateSets.push(values);
- return query;
- }),
+ set: vi.fn(() => query),
where: vi.fn(() => query),
returning: vi.fn(() => query),
// oxlint-disable-next-line unicorn/no-thenable -- Drizzle query builders are awaitable.
@@ -48,14 +44,13 @@ const mocks = vi.hoisted(() => {
return {
selectResults,
updateResults,
- updateSets,
db,
getGitHubPullRequest: vi.fn(),
listOpenGitHubPullRequests: vi.fn(),
resolveGitHubPullRequestMergeRef: vi.fn(),
createGitHubDeployment: vi.fn(),
updateGitHubDeploymentStatus: vi.fn(),
- createOrRefreshPreviewClone: vi.fn(),
+ createPreviewClone: vi.fn(),
updateCurrentPreviewGitHubStatus: vi.fn(),
cancelPreviewRevisionWork: vi.fn(),
deactivatePreviewRuntime: vi.fn(),
@@ -80,8 +75,7 @@ vi.mock("@/lib/github", () => ({
updateGitHubDeploymentStatus: mocks.updateGitHubDeploymentStatus,
}));
vi.mock("@/lib/preview-deployments", () => ({
- PREVIEW_RECONCILIATION_TTL_MS: 7 * 24 * 60 * 60 * 1000,
- createOrRefreshPreviewClone: mocks.createOrRefreshPreviewClone,
+ createPreviewClone: mocks.createPreviewClone,
updateCurrentPreviewGitHubStatus: mocks.updateCurrentPreviewGitHubStatus,
}));
vi.mock("@/lib/preview-lifecycle", () => ({
@@ -127,7 +121,7 @@ const baseContext = {
id: "base-service",
name: "Web",
previewDeploymentsEnabled: true,
- previewOfServiceId: null,
+ previewOfService: null,
stateful: false,
sourceType: "github" as const,
},
@@ -185,8 +179,7 @@ describe("preview lifecycle workflows", () => {
vi.clearAllMocks();
mocks.selectResults.length = 0;
mocks.updateResults.length = 0;
- mocks.updateSets.length = 0;
- mocks.createOrRefreshPreviewClone.mockResolvedValue({
+ mocks.createPreviewClone.mockResolvedValue({
serviceId: "preview-service",
created: false,
primaryUrl: "https://web-pr-42.example.com",
@@ -195,6 +188,7 @@ describe("preview lifecycle workflows", () => {
service: { id: "preview-service", previewGithubDeploymentId: null },
githubRepo: baseContext.githubRepo,
});
+ mocks.createGitHubDeployment.mockResolvedValue(100);
mocks.updateCurrentPreviewGitHubStatus.mockResolvedValue(true);
mocks.updateGitHubDeploymentStatus.mockResolvedValue(undefined);
mocks.cancelPreviewRevisionWork.mockResolvedValue(undefined);
@@ -220,7 +214,7 @@ describe("preview lifecycle workflows", () => {
await expect(
invoke(previewCloseWorkflow, {
baseServiceId: "base-service",
- pullRequestNumber: 42,
+ previewGitRef: "refs/pull/42/merge",
reason: "pull_request_closed",
verifyWithGitHub: true,
}).result,
@@ -230,7 +224,10 @@ describe("preview lifecycle workflows", () => {
expect(mocks.send).toHaveBeenCalledWith(
expect.objectContaining({
name: "preview/sync-requested",
- data: { baseServiceId: "base-service", pullRequestNumber: 42 },
+ data: {
+ baseServiceId: "base-service",
+ previewGitRef: "refs/pull/42/merge",
+ },
}),
);
});
@@ -252,7 +249,7 @@ describe("preview lifecycle workflows", () => {
await expect(
invoke(previewCloseWorkflow, {
baseServiceId: "base-service",
- pullRequestNumber: 42,
+ previewGitRef: "refs/pull/42/merge",
reason: "pull_request_closed",
verifyWithGitHub: true,
}).result,
@@ -260,28 +257,6 @@ describe("preview lifecycle workflows", () => {
expect(mocks.deletePreviewService).not.toHaveBeenCalled();
});
- it("finishes teardown even when GitHub cannot mark the deployment inactive", async () => {
- mocks.deletePreviewService.mockResolvedValue({
- service: { id: "preview-service", previewGithubDeploymentId: 99 },
- githubRepo: baseContext.githubRepo,
- });
- mocks.updateGitHubDeploymentStatus.mockRejectedValue(
- new Error("GitHub temporarily unavailable"),
- );
-
- await expect(
- invoke(previewCloseWorkflow, {
- baseServiceId: "base-service",
- pullRequestNumber: 42,
- reason: "pull_request_merged",
- }).result,
- ).resolves.toEqual({
- status: "deleted",
- serviceId: "preview-service",
- });
- expect(mocks.deletePreviewService).toHaveBeenCalledWith("base-service", 42);
- });
-
it("deactivates the old runtime when the merge ref is unavailable", async () => {
mocks.selectResults.push(
[baseContext],
@@ -289,7 +264,6 @@ describe("preview lifecycle workflows", () => {
{
previewCurrentRevisionId: "revision-old",
previewGithubDeploymentId: 98,
- previewError: null,
},
],
[{ specification: { source: "old" } }],
@@ -307,11 +281,12 @@ describe("preview lifecycle workflows", () => {
mocks.resolveGitHubPullRequestMergeRef.mockRejectedValue(
new Error("Merge ref refs/pull/42/merge is unavailable"),
);
+ mocks.updateResults.push([], [{ id: "preview-service" }]);
await expect(
invoke(previewSyncWorkflow, {
baseServiceId: "base-service",
- pullRequestNumber: 42,
+ previewGitRef: "refs/pull/42/merge",
}).result,
).resolves.toEqual({
status: "failed",
@@ -328,37 +303,24 @@ describe("preview lifecycle workflows", () => {
"inactive",
{ description: "Preview merge ref is unavailable" },
);
- expect(mocks.triggerResolvedBuildInternal).not.toHaveBeenCalled();
- });
-
- it("does not rebuild an unchanged merge commit unless forced", async () => {
- mocks.selectResults.push(
- [baseContext],
- [
- {
- previewCurrentRevisionId: "revision-current",
- previewGithubDeploymentId: 99,
- previewError: null,
- },
- ],
- [{ specification: { source: "current" } }],
+ expect(mocks.createGitHubDeployment).toHaveBeenCalledWith(
+ 10,
+ "acme/app",
+ pullRequest.head.sha,
+ "preview/Web/pr-42",
+ "Preview unavailable for PR #42",
+ expect.objectContaining({
+ transientEnvironment: true,
+ productionEnvironment: false,
+ }),
);
- mocks.getGitHubPullRequest.mockResolvedValue(pullRequest);
- mocks.parseServiceRevisionSpec.mockReturnValue({
- source: { type: "github", commitSha: "3".repeat(40) },
- });
- mocks.resolveGitHubPullRequestMergeRef.mockResolvedValue({
- gitRef: "refs/pull/42/merge",
- sha: "3".repeat(40),
+ expect(mocks.updateCurrentPreviewGitHubStatus).toHaveBeenCalledWith({
+ serviceId: "preview-service",
+ serviceRevisionId: null,
+ expectedDeploymentId: 100,
+ state: "failure",
+ description: "Merge ref refs/pull/42/merge is unavailable",
});
-
- await expect(
- invoke(previewSyncWorkflow, {
- baseServiceId: "base-service",
- pullRequestNumber: 42,
- }).result,
- ).resolves.toEqual({ status: "unchanged", serviceId: "preview-service" });
- expect(mocks.createGitHubDeployment).not.toHaveBeenCalled();
expect(mocks.triggerResolvedBuildInternal).not.toHaveBeenCalled();
});
@@ -369,7 +331,6 @@ describe("preview lifecycle workflows", () => {
{
previewCurrentRevisionId: "revision-current",
previewGithubDeploymentId: 99,
- previewError: null,
},
],
[{ specification: { source: "current" } }],
@@ -400,7 +361,7 @@ describe("preview lifecycle workflows", () => {
previewSyncWorkflow,
{
baseServiceId: "base-service",
- pullRequestNumber: 42,
+ previewGitRef: "refs/pull/42/merge",
force: true,
},
"redeploy-event",
@@ -433,7 +394,51 @@ describe("preview lifecycle workflows", () => {
});
});
- it("reconciliation creates missing previews and removes stale ones", async () => {
+ it("inactivates a GitHub deployment when its preview disappears before dispatch", async () => {
+ mocks.selectResults.push(
+ [baseContext],
+ [
+ {
+ previewCurrentRevisionId: null,
+ previewGithubDeploymentId: null,
+ },
+ ],
+ [],
+ );
+ mocks.updateResults.push([]);
+ mocks.getGitHubPullRequest.mockResolvedValue(pullRequest);
+ mocks.resolveGitHubPullRequestMergeRef.mockResolvedValue({
+ gitRef: "refs/pull/42/merge",
+ sha: "3".repeat(40),
+ });
+ mocks.triggerResolvedBuildInternal.mockImplementation(
+ async (_serviceId, input) => {
+ await input.beforeDispatch("revision-orphaned");
+ throw new Error("Preview was closed before its build was queued");
+ },
+ );
+
+ await expect(
+ invoke(previewSyncWorkflow, {
+ baseServiceId: "base-service",
+ previewGitRef: "refs/pull/42/merge",
+ }).result,
+ ).rejects.toThrow("Preview was closed before its build was queued");
+
+ expect(mocks.cancelPreviewRevisionWork).toHaveBeenCalledWith(
+ "preview-service",
+ "revision-orphaned",
+ );
+ expect(mocks.updateGitHubDeploymentStatus).toHaveBeenCalledWith(
+ 10,
+ "acme/app",
+ 100,
+ "inactive",
+ { description: "Preview was removed" },
+ );
+ });
+
+ it("reconciliation retries deleting previews before recreating missing ones", async () => {
const secondPullRequest = {
...pullRequest,
number: 43,
@@ -441,7 +446,13 @@ describe("preview lifecycle workflows", () => {
};
mocks.selectResults.push(
[baseContext],
- [{ pullRequestNumber: 42 }, { pullRequestNumber: 99 }],
+ [
+ {
+ previewGitRef: "refs/pull/42/merge",
+ deletedAt: new Date("2026-08-16T00:30:00Z"),
+ },
+ { previewGitRef: "refs/pull/99/merge", deletedAt: null },
+ ],
);
mocks.listOpenGitHubPullRequests.mockResolvedValue([
pullRequest,
@@ -452,13 +463,25 @@ describe("preview lifecycle workflows", () => {
invoke(previewServiceReconcileWorkflow, {
baseServiceId: "base-service",
}).result,
- ).resolves.toEqual({ status: "queued", count: 2, closed: 1 });
+ ).resolves.toEqual({ status: "queued", count: 2, closed: 2 });
- expect(mocks.deletePreviewService).toHaveBeenCalledWith("base-service", 99);
+ expect(mocks.deletePreviewService).toHaveBeenCalledWith(
+ "base-service",
+ "refs/pull/42/merge",
+ "retrying preview deletion",
+ );
+ expect(mocks.deletePreviewService).toHaveBeenCalledWith(
+ "base-service",
+ "refs/pull/99/merge",
+ "pull request no longer eligible",
+ );
expect(mocks.send).toHaveBeenCalledTimes(2);
expect(mocks.send).toHaveBeenCalledWith(
expect.objectContaining({
- data: { baseServiceId: "base-service", pullRequestNumber: 43 },
+ data: {
+ baseServiceId: "base-service",
+ previewGitRef: "refs/pull/43/merge",
+ },
}),
);
});
diff --git a/web/tests/service-commands-route.test.ts b/web/tests/service-commands-route.test.ts
index 43d70a80..0727dc6f 100644
--- a/web/tests/service-commands-route.test.ts
+++ b/web/tests/service-commands-route.test.ts
@@ -148,7 +148,6 @@ describe("service commands route", () => {
it("returns paginated history without internal actor IDs", async () => {
mocks.queryResults.push(
- [{ id: "service-1" }],
Array.from({ length: 26 }, (_, index) => ({
id: `command-${String(26 - index).padStart(2, "0")}`,
command: "whoami",
diff --git a/web/tests/trigger-build.test.ts b/web/tests/trigger-build.test.ts
index 433e6ff7..33430e11 100644
--- a/web/tests/trigger-build.test.ts
+++ b/web/tests/trigger-build.test.ts
@@ -6,6 +6,7 @@ const mocks = vi.hoisted(() => ({
send: vi.fn(),
resolveGitHubCommit: vi.fn(),
createBuildTrigger: vi.fn(),
+ createPreviewSync: vi.fn(),
createGitHubBuildServiceRevision: vi.fn(),
cloneGitHubBuildServiceRevision: vi.fn(),
}));
@@ -22,6 +23,7 @@ vi.mock("@/lib/github", () => ({
vi.mock("@/lib/inngest/events", () => ({
inngestEvents: {
buildTrigger: { create: mocks.createBuildTrigger },
+ previewSyncRequested: { create: mocks.createPreviewSync },
},
}));
vi.mock("@/lib/service-revisions", () => ({
@@ -32,7 +34,6 @@ vi.mock("@/lib/service-revisions", () => ({
import {
requeueBuildRevisionInternal,
triggerBuildInternal,
- triggerResolvedBuildInternal,
} from "@/lib/trigger-build";
function queryReturning(rows: unknown[]) {
@@ -56,6 +57,11 @@ describe("internal GitHub build trigger", () => {
process.env.REGISTRY_HOST = "registry.test";
mocks.rows = [];
mocks.createGitHubBuildServiceRevision.mockResolvedValue({});
+ mocks.createPreviewSync.mockImplementation((data, options) => ({
+ name: "preview/sync-requested",
+ data,
+ ...options,
+ }));
mocks.resolveGitHubCommit.mockResolvedValue({
sha: "0123456789abcdef0123456789abcdef01234567",
message: "Resolved source commit",
@@ -176,14 +182,18 @@ describe("internal GitHub build trigger", () => {
);
});
- it("snapshots and dispatches a synthetic pull request merge ref", async () => {
+ it("routes a visible preview build through its pull request merge ref", async () => {
mocks.rows = [
[
{
- id: "service-1",
+ id: "preview-service",
projectId: "project-1",
sourceType: "github",
deletedAt: null,
+ githubRepoUrl: "https://github.com/acme/app",
+ githubBranch: "main",
+ previewOfService: "base-service",
+ previewGitRef: "refs/pull/42/merge",
},
],
[
@@ -196,27 +206,25 @@ describe("internal GitHub build trigger", () => {
],
];
- await triggerResolvedBuildInternal("service-1", {
- trigger: "preview",
- commitSha: "0123456789abcdef0123456789abcdef01234567",
- commitMessage: "Merge pull request #42",
- actor: { type: "system" },
- expectedRepository: "https://github.com/acme/app",
- expectedBranch: "main",
- gitRef: "refs/pull/42/merge",
- idempotencyKey: "preview:service-1:42:merge-sha",
- });
+ await expect(
+ triggerBuildInternal("preview-service", "manual", {
+ type: "system",
+ }),
+ ).resolves.toMatchObject({ status: "queued" });
- expect(mocks.createGitHubBuildServiceRevision).toHaveBeenCalledWith(
- expect.objectContaining({ gitRef: "refs/pull/42/merge" }),
+ expect(mocks.createPreviewSync).toHaveBeenCalledWith(
+ {
+ baseServiceId: "base-service",
+ previewGitRef: "refs/pull/42/merge",
+ force: true,
+ },
+ { id: expect.stringContaining("preview-user-sync:preview-service:") },
);
- expect(mocks.createBuildTrigger).toHaveBeenCalledWith(
- expect.objectContaining({
- trigger: "preview",
- gitRef: "refs/pull/42/merge",
- }),
- { id: "preview:service-1:42:merge-sha" },
+ expect(mocks.send).toHaveBeenCalledWith(
+ expect.objectContaining({ name: "preview/sync-requested" }),
);
+ expect(mocks.resolveGitHubCommit).not.toHaveBeenCalled();
+ expect(mocks.createGitHubBuildServiceRevision).not.toHaveBeenCalled();
});
it("rejects a non-GitHub service before queueing work", async () => {
From ac8c7b7361379e38d6da2246bf37066df8df4173 Mon Sep 17 00:00:00 2001
From: Amp
Date: Mon, 17 Aug 2026 14:06:44 +0000
Subject: [PATCH 3/5] Simplify preview deployment lifecycle
Amp-Thread-ID: https://ampcode.com/threads/T-01a003f3-7142-74cd-b819-95472f4a6376
Co-authored-by: Arjun Komath
---
agent/internal/build/build.go | 128 ++---
agent/internal/build/build_test.go | 37 --
docs/architecture.mdx | 10 +-
docs/deployments/github.mdx | 26 +-
docs/installation.mdx | 4 +-
web/actions/builds.ts | 6 +-
web/actions/previews.ts | 6 +-
web/actions/projects.ts | 39 +-
web/app/api/inngest/route.ts | 2 -
web/app/api/v1/agent/builds/[id]/route.ts | 8 +-
.../api/v1/agent/builds/[id]/status/route.ts | 26 +-
web/app/api/webhooks/github/route.ts | 38 +-
.../details/pull-request-previews-setting.tsx | 4 +-
web/db/schema.ts | 11 -
web/lib/github.ts | 58 +++
web/lib/inngest/events/build.ts | 2 +-
.../functions/build-trigger-workflow.ts | 161 ++++--
web/lib/inngest/functions/build-workflow.ts | 4 +-
web/lib/inngest/functions/index.ts | 1 -
web/lib/inngest/functions/preview-workflow.ts | 460 ++++--------------
web/lib/inngest/functions/rollout-helpers.ts | 59 ++-
web/lib/inngest/functions/rollout-utils.ts | 141 +++---
web/lib/inngest/functions/rollout-workflow.ts | 104 ++--
web/lib/preview-deployments.ts | 403 +++++++++------
web/lib/preview-lifecycle.ts | 42 +-
web/lib/service-revision-changes.ts | 69 +--
web/lib/service-revision-spec.ts | 37 +-
web/lib/service-revisions.ts | 27 +-
web/lib/trigger-build.ts | 16 +-
web/tests/autoplacement.test.ts | 4 +-
web/tests/build-assignment.test.ts | 2 +-
web/tests/build-claim-route.test.ts | 13 +-
web/tests/build-revision-source.test.ts | 1 -
web/tests/build-status-route.test.ts | 11 +-
web/tests/build-trigger-workflow.test.ts | 84 +++-
web/tests/build-workflow.test.ts | 8 +-
web/tests/github-webhook.test.ts | 122 ++---
web/tests/github.test.ts | 44 ++
web/tests/inngest-route.test.ts | 1 -
web/tests/preview-deployments.test.ts | 83 +++-
web/tests/preview-workflow.test.ts | 383 +++------------
web/tests/service-config.test.ts | 5 +-
web/tests/service-revision-build.test.ts | 4 +-
web/tests/service-revision-changes.test.ts | 2 +-
web/tests/service-revision-spec.test.ts | 25 +-
web/tests/service-revisions-route.test.ts | 2 +-
web/tests/trigger-build.test.ts | 5 -
47 files changed, 1218 insertions(+), 1510 deletions(-)
diff --git a/agent/internal/build/build.go b/agent/internal/build/build.go
index ac4d95f2..acf91613 100644
--- a/agent/internal/build/build.go
+++ b/agent/internal/build/build.go
@@ -156,64 +156,50 @@ func (b *Builder) clone(ctx context.Context, config *Config, buildDir string) er
safeURL = "https://***@" + safeURL[idx+1:]
}
b.sendLog(config, fmt.Sprintf("Cloning %s", safeURL))
-
- if matched, _ := regexp.MatchString(`^[0-9a-fA-F]{40}$`, config.CommitSha); !matched {
- return fmt.Errorf("invalid exact commit SHA")
- }
- if !validGitRef(config.GitRef) {
- return fmt.Errorf("invalid exact Git ref")
- }
-
- cmd := exec.CommandContext(ctx, "git", "init", buildDir)
- output, err := b.runCommand(cmd, config)
- if err != nil {
- return fmt.Errorf("git init failed: %s: %w", output, err)
- }
- cmd = exec.CommandContext(ctx, "git", "-C", buildDir, "remote", "add", "origin", config.CloneURL)
- output, err = b.runCommand(cmd, config)
- if err != nil {
- return fmt.Errorf("git remote setup failed: %s: %w", output, err)
- }
- depth := "50"
if pullRequestMergeRefPattern.MatchString(config.GitRef) {
- depth = "1"
+ return b.clonePullRequestRef(ctx, config, buildDir)
}
- cmd = exec.CommandContext(ctx, "git", "-C", buildDir, "fetch", "--depth", depth, "--no-tags", "origin", config.GitRef)
- output, err = b.runCommand(cmd, config)
- if err != nil {
- return fmt.Errorf("git fetch exact ref failed: %s: %w", output, err)
+
+ branch := config.Branch
+ if branch == "" {
+ branch = "main"
}
- if pullRequestMergeRefPattern.MatchString(config.GitRef) {
- cmd = exec.CommandContext(ctx, "git", "-C", buildDir, "rev-parse", "FETCH_HEAD")
- fetchedCommit, err := b.runCommand(cmd, config)
+ if config.CommitSha == "HEAD" {
+ cmd := exec.CommandContext(ctx, "git", "clone", "--depth", "1", "--branch", branch, config.CloneURL, buildDir)
+ output, err := b.runCommand(cmd, config)
if err != nil {
- return fmt.Errorf("git resolve fetched ref failed: %s: %w", fetchedCommit, err)
- }
- if !strings.EqualFold(strings.TrimSpace(fetchedCommit), config.CommitSha) {
- return fmt.Errorf("fetched ref resolved to %s, expected %s", strings.TrimSpace(fetchedCommit), config.CommitSha)
+ return fmt.Errorf("git clone failed: %s: %w", output, err)
}
+ b.sendLog(config, fmt.Sprintf("Cloned branch %s", branch))
} else {
+ if matched, _ := regexp.MatchString(`^[0-9a-fA-F]{40}$`, config.CommitSha); !matched {
+ return fmt.Errorf("invalid exact commit SHA")
+ }
+ cmd := exec.CommandContext(ctx, "git", "clone", "--depth", "50", "--branch", branch, "--single-branch", config.CloneURL, buildDir)
+ output, err := b.runCommand(cmd, config)
+ if err != nil {
+ return fmt.Errorf("git clone failed: %s: %w", output, err)
+ }
+
+ b.sendLog(config, fmt.Sprintf("Checking out commit %s", truncateStr(config.CommitSha, 8)))
+
cmd = exec.CommandContext(ctx, "git", "-C", buildDir, "cat-file", "-e", config.CommitSha+"^{commit}")
- if _, err = b.runCommand(cmd, config); err != nil {
+ _, err = b.runCommand(cmd, config)
+ if err != nil {
b.sendLog(config, "Selected commit is outside the shallow clone; fetching full branch history")
- cmd = exec.CommandContext(ctx, "git", "-C", buildDir, "fetch", "--unshallow", "--no-tags", "origin", config.GitRef)
+ cmd = exec.CommandContext(ctx, "git", "-C", buildDir, "fetch", "--unshallow", "origin", branch)
output, err = b.runCommand(cmd, config)
if err != nil {
return fmt.Errorf("git fetch full branch history failed: %s: %w", output, err)
}
- cmd = exec.CommandContext(ctx, "git", "-C", buildDir, "cat-file", "-e", config.CommitSha+"^{commit}")
- if output, err = b.runCommand(cmd, config); err != nil {
- return fmt.Errorf("selected commit is not available from configured branch: %s: %w", output, err)
- }
}
- }
- b.sendLog(config, fmt.Sprintf("Checking out commit %s", truncateStr(config.CommitSha, 8)))
- cmd = exec.CommandContext(ctx, "git", "-C", buildDir, "checkout", "--detach", config.CommitSha)
- output, err = b.runCommand(cmd, config)
- if err != nil {
- return fmt.Errorf("git checkout failed: %s: %w", output, err)
+ cmd = exec.CommandContext(ctx, "git", "-C", buildDir, "checkout", config.CommitSha)
+ output, err = b.runCommand(cmd, config)
+ if err != nil {
+ return fmt.Errorf("git checkout failed: %s: %w", output, err)
+ }
}
b.sendLog(config, "Clone completed")
@@ -229,28 +215,50 @@ func (b *Builder) clone(ctx context.Context, config *Config, buildDir string) er
return nil
}
-func validGitRef(ref string) bool {
- if pullRequestMergeRefPattern.MatchString(ref) {
- return true
+func (b *Builder) clonePullRequestRef(ctx context.Context, config *Config, buildDir string) error {
+ if matched, _ := regexp.MatchString(`^[0-9a-fA-F]{40}$`, config.CommitSha); !matched {
+ return fmt.Errorf("invalid exact commit SHA")
}
- if !strings.HasPrefix(ref, "refs/heads/") {
- return false
+ cmd := exec.CommandContext(ctx, "git", "init", buildDir)
+ output, err := b.runCommand(cmd, config)
+ if err != nil {
+ return fmt.Errorf("git init failed: %s: %w", output, err)
}
- branch := strings.TrimPrefix(ref, "refs/heads/")
- if branch == "" || branch == "@" || strings.HasSuffix(branch, ".") || strings.Contains(branch, "..") || strings.Contains(branch, "@{") {
- return false
+ cmd = exec.CommandContext(ctx, "git", "-C", buildDir, "remote", "add", "origin", config.CloneURL)
+ output, err = b.runCommand(cmd, config)
+ if err != nil {
+ return fmt.Errorf("git remote setup failed: %s: %w", output, err)
}
- for _, character := range branch {
- if character <= 0x20 || character == 0x7f || strings.ContainsRune("~^:?*[\\", character) {
- return false
- }
+ cmd = exec.CommandContext(ctx, "git", "-C", buildDir, "fetch", "--depth", "1", "--no-tags", "origin", config.GitRef)
+ output, err = b.runCommand(cmd, config)
+ if err != nil {
+ return fmt.Errorf("git fetch pull request ref failed: %s: %w", output, err)
}
- for _, part := range strings.Split(branch, "/") {
- if part == "" || strings.HasPrefix(part, ".") || strings.HasSuffix(part, ".lock") {
- return false
- }
+ cmd = exec.CommandContext(ctx, "git", "-C", buildDir, "rev-parse", "FETCH_HEAD")
+ fetchedCommit, err := b.runCommand(cmd, config)
+ if err != nil {
+ return fmt.Errorf("git resolve fetched ref failed: %s: %w", fetchedCommit, err)
}
- return true
+ if !strings.EqualFold(strings.TrimSpace(fetchedCommit), config.CommitSha) {
+ return fmt.Errorf("fetched ref resolved to %s, expected %s", strings.TrimSpace(fetchedCommit), config.CommitSha)
+ }
+ b.sendLog(config, fmt.Sprintf("Checking out commit %s", truncateStr(config.CommitSha, 8)))
+ cmd = exec.CommandContext(ctx, "git", "-C", buildDir, "checkout", "--detach", config.CommitSha)
+ output, err = b.runCommand(cmd, config)
+ if err != nil {
+ return fmt.Errorf("git checkout failed: %s: %w", output, err)
+ }
+ b.sendLog(config, "Clone completed")
+ resolvedCommitSha, err := b.resolveCommitSha(ctx, config, buildDir)
+ if err != nil {
+ return err
+ }
+ if !strings.EqualFold(resolvedCommitSha, config.CommitSha) {
+ return fmt.Errorf("checked out commit %s, expected %s", resolvedCommitSha, config.CommitSha)
+ }
+ config.ResolvedCommitSha = resolvedCommitSha
+ b.sendLog(config, fmt.Sprintf("Resolved commit %s", truncateStr(resolvedCommitSha, 8)))
+ return nil
}
func (b *Builder) resolveCommitSha(ctx context.Context, config *Config, buildDir string) (string, error) {
diff --git a/agent/internal/build/build_test.go b/agent/internal/build/build_test.go
index 327fe27d..d089e9b2 100644
--- a/agent/internal/build/build_test.go
+++ b/agent/internal/build/build_test.go
@@ -141,7 +141,6 @@ func TestCloneDeepensConfiguredBranchForSelectedCommit(t *testing.T) {
CloneURL: "file://" + remoteDir,
CommitSha: selectedSHA,
Branch: "main",
- GitRef: "refs/heads/main",
}
if err := NewBuilder(t.TempDir(), nil).clone(context.Background(), config, buildDir); err != nil {
t.Fatal(err)
@@ -188,42 +187,6 @@ func TestCloneRejectsMovedRef(t *testing.T) {
}
}
-func TestRunCommandRedactsGitCredentials(t *testing.T) {
- config := &Config{BuildID: "build-1"}
- output, _ := NewBuilder(t.TempDir(), nil).runCommand(
- exec.Command("sh", "-c", "printf '%s' 'fatal: https://x-access-token:secret-token@example.com/repo.git' && exit 1"),
- config,
- )
- if strings.Contains(output, "secret-token") || !strings.Contains(output, "https://***@example.com") {
- t.Fatalf("credential output was not redacted: %q", output)
- }
-}
-
-func TestValidGitRef(t *testing.T) {
- for _, ref := range []string{
- "refs/heads/main",
- "refs/heads/feature/preview-deployments",
- "refs/pull/42/merge",
- } {
- if !validGitRef(ref) {
- t.Errorf("validGitRef(%q) = false, want true", ref)
- }
- }
- for _, ref := range []string{
- "main",
- "refs/heads//main",
- "refs/heads/feature/.hidden",
- "refs/heads/@",
- "refs/heads/feature.lock",
- "refs/pull/0/merge",
- "refs/pull/42/head",
- } {
- if validGitRef(ref) {
- t.Errorf("validGitRef(%q) = true, want false", ref)
- }
- }
-}
-
func TestResolveBuildContext(t *testing.T) {
buildDir := t.TempDir()
nestedDir := filepath.Join(buildDir, "services", "api")
diff --git a/docs/architecture.mdx b/docs/architecture.mdx
index 7d69fc28..923e725f 100644
--- a/docs/architecture.mdx
+++ b/docs/architecture.mdx
@@ -149,11 +149,11 @@ pipeline. It is copied from the base service only when first created, so later
user edits to the visible preview service survive pull request updates.
The control plane resolves the exact synthetic merge ref and queues an ordinary
-build. A current revision pointer on the copy prevents superseded build and
-rollout callbacks from deploying or reporting success. GitHub reports the
-transient environment as ready only after health and routing convergence
-complete. Closing, merging, or drafting the pull request clears that pointer
-before runtime and registry cleanup.
+build. The latest revision on the copy prevents superseded build and rollout
+callbacks from deploying or reporting success. GitHub reports the transient
+environment as ready only after health and routing convergence complete.
+Closing, merging, or drafting the pull request deletes the copied service after
+runtime and registry cleanup.
## Networking
diff --git a/docs/deployments/github.mdx b/docs/deployments/github.mdx
index 96520ea7..1107a0e6 100644
--- a/docs/deployments/github.mdx
+++ b/docs/deployments/github.mdx
@@ -51,11 +51,12 @@ GitHub deployment statuses are updated on the commit so you can track progress f
## Pull Request Preview Deployments
Preview deployments are opt in from a GitHub-backed service's **Configuration**
-page. They require a configured **Automatic Subdomain Domain** and its wildcard
-DNS record. Each eligible pull request gets one visible, single-replica service
-in the project's ordinary `previews` environment and a stable generated HTTPS
-URL beneath that domain. An existing environment named `previews` is reused and
-is left in place when previews close.
+page. Each eligible pull request gets one visible service in the project's
+ordinary `previews` environment. An existing environment named `previews` is
+reused and is left in place when previews close. If you configure an
+**Automatic Subdomain Domain** and its wildcard DNS record, each preview also
+gets a stable generated HTTPS URL beneath that domain. Without this setting,
+the preview is still created without a public URL.
A pull request is eligible only when it:
@@ -70,13 +71,14 @@ configured branch. If GitHub cannot produce that ref because of merge
conflicts, the preview fails rather than building the raw pull request head.
When first created, preview services inherit the base service's current source
-configuration, private ports, resource limits, placement, health check, start
-command, and complete secret set. They do not copy volumes, backups, schedules,
-cron jobs, autoscaling, serverless sleep, production custom domains, or public
-TCP/UDP routes. Additional preview-specific secret configuration is neither
-needed nor available. Preview services use the normal service pages and may be
-edited like other services, except that volumes remain unavailable. Later pull
-request updates preserve those edits.
+configuration, replicas, autoscaling, placement, health check, start command,
+resource limits, ports, serverless settings, and complete secret set. They do
+not copy volumes, backups, deployment schedules, cron jobs, production custom
+domains, or public TCP/UDP routes. Serverless mode is disabled when no generated
+public domain is available. Preview services use the normal service pages and
+may be edited like other services. Their source repository remains tied to the
+pull request, and volumes remain unavailable. Later pull request updates
+preserve other edits.
New commits replace the preview revision without changing its URL. Converting
the pull request to a draft, closing it, or merging it removes the runtime and
diff --git a/docs/installation.mdx b/docs/installation.mdx
index 5fe2839d..5959d27f 100644
--- a/docs/installation.mdx
+++ b/docs/installation.mdx
@@ -299,8 +299,8 @@ migrations from every replica.
Configure the app with Contents read, Pull requests read, and Deployments write
repository permissions. Subscribe it to Push and Pull request events. Pull
-request preview deployments additionally require the Automatic Subdomain Domain
-setting and a wildcard DNS record for that domain.
+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
diff --git a/web/actions/builds.ts b/web/actions/builds.ts
index b09467bc..07c940b7 100644
--- a/web/actions/builds.ts
+++ b/web/actions/builds.ts
@@ -84,10 +84,7 @@ export async function retryBuild(buildId: string) {
}
const [service] = await db
- .select({
- id: services.id,
- previewOfService: services.previewOfService,
- })
+ .select({ id: services.id, previewOfService: services.previewOfService })
.from(services)
.where(and(eq(services.id, build.serviceId), isNull(services.deletedAt)));
@@ -108,7 +105,6 @@ export async function retryBuild(buildId: string) {
await triggerBuildInternal(build.serviceId, "manual", actor);
return { success: true };
}
-
await requeueBuildRevisionInternal({
serviceId: build.serviceId,
serviceRevisionId: build.serviceRevisionId,
diff --git a/web/actions/previews.ts b/web/actions/previews.ts
index 8073b851..2436cd78 100644
--- a/web/actions/previews.ts
+++ b/web/actions/previews.ts
@@ -8,10 +8,7 @@ import { githubRepos, services } from "@/db/schema";
import { requireDeveloperRole } from "@/lib/auth";
import { inngest } from "@/lib/inngest/client";
import { inngestEvents } from "@/lib/inngest/events";
-import {
- ensurePreviewEnvironment,
- requirePreviewDomain,
-} from "@/lib/preview-deployments";
+import { ensurePreviewEnvironment } from "@/lib/preview-deployments";
export async function setPreviewDeploymentsEnabled(
serviceId: string,
@@ -32,7 +29,6 @@ export async function setPreviewDeploymentsEnabled(
);
}
if (enabled) {
- await requirePreviewDomain();
await ensurePreviewEnvironment(service.projectId);
}
diff --git a/web/actions/projects.ts b/web/actions/projects.ts
index eac09a8f..7c9bb68f 100644
--- a/web/actions/projects.ts
+++ b/web/actions/projects.ts
@@ -129,9 +129,7 @@ export async function deleteProject(
.from(services)
.where(eq(services.projectId, id));
- for (const service of projectServices.filter(
- (service) => !service.previewOfService,
- )) {
+ for (const service of projectServices) {
const activeDeployments = await db
.select()
.from(deployments)
@@ -918,10 +916,37 @@ export async function updateServiceGithubRepo(
updateData.image = `${registryHost}/${service.projectId}/${serviceId}:latest`;
}
+ let reconcilePreviews = false;
await db.transaction(async (tx) => {
await tx.execute(
sql`SELECT pg_advisory_xact_lock(hashtext(${serviceId}))`,
);
+ const current = await tx
+ .select({
+ githubRepoUrl: services.githubRepoUrl,
+ previewDeploymentsEnabled: services.previewDeploymentsEnabled,
+ previewOfService: services.previewOfService,
+ })
+ .from(services)
+ .where(and(eq(services.id, serviceId), isNull(services.deletedAt)))
+ .then((rows) => rows[0]);
+ if (!current) throw new Error("Service not found");
+ if (current.previewOfService && normalizedUrl !== current.githubRepoUrl) {
+ throw new Error(
+ "A preview service must remain linked to its pull request",
+ );
+ }
+ if (
+ current.previewDeploymentsEnabled &&
+ !current.previewOfService &&
+ normalizedUrl !== current.githubRepoUrl
+ ) {
+ throw new Error(
+ "Disable preview deployments before changing the GitHub repository",
+ );
+ }
+ reconcilePreviews =
+ current.previewDeploymentsEnabled && !current.previewOfService;
await tx
.update(services)
.set(updateData)
@@ -931,6 +956,14 @@ export async function updateServiceGithubRepo(
.set({ deployBranch: normalizedBranch })
.where(eq(githubRepos.serviceId, serviceId));
});
+ if (reconcilePreviews) {
+ await inngest.send(
+ inngestEvents.previewServiceReconcileRequested.create(
+ { baseServiceId: serviceId },
+ { id: `preview-source:${serviceId}:${randomUUID()}` },
+ ),
+ );
+ }
return { success: true };
} catch (error) {
diff --git a/web/app/api/inngest/route.ts b/web/app/api/inngest/route.ts
index db04124a..977106bd 100644
--- a/web/app/api/inngest/route.ts
+++ b/web/app/api/inngest/route.ts
@@ -16,7 +16,6 @@ import {
oldBackupsCleanup,
onDeploymentFailed,
onRestoreFailed,
- previewCloseWorkflow,
previewReconciliation,
previewServiceReconcileWorkflow,
previewSyncWorkflow,
@@ -57,7 +56,6 @@ export const { GET, POST, PUT } = serve({
restoreWorkflow,
onRestoreFailed,
previewSyncWorkflow,
- previewCloseWorkflow,
previewReconciliation,
previewServiceReconcileWorkflow,
buildWorkflow,
diff --git a/web/app/api/v1/agent/builds/[id]/route.ts b/web/app/api/v1/agent/builds/[id]/route.ts
index ca20f2d2..f54080fd 100644
--- a/web/app/api/v1/agent/builds/[id]/route.ts
+++ b/web/app/api/v1/agent/builds/[id]/route.ts
@@ -88,7 +88,11 @@ export async function POST(
const [service, revision, buildTimeoutMinutes] = await Promise.all([
db
- .select({ id: services.id, projectId: services.projectId })
+ .select({
+ id: services.id,
+ projectId: services.projectId,
+ previewGitRef: services.previewGitRef,
+ })
.from(services)
.where(eq(services.id, build.serviceId))
.then((rows) => rows[0]),
@@ -145,7 +149,7 @@ export async function POST(
commitSha: specification.source.commitSha,
commitMessage: build.commitMessage,
branch: specification.source.branch,
- gitRef: specification.source.gitRef,
+ gitRef: service.previewGitRef,
serviceId: build.serviceId,
projectId: service.projectId,
},
diff --git a/web/app/api/v1/agent/builds/[id]/status/route.ts b/web/app/api/v1/agent/builds/[id]/status/route.ts
index 85965c84..d6e48b21 100644
--- a/web/app/api/v1/agent/builds/[id]/status/route.ts
+++ b/web/app/api/v1/agent/builds/[id]/status/route.ts
@@ -1,4 +1,4 @@
-import { and, eq, inArray, isNull, sql } from "drizzle-orm";
+import { and, desc, eq, inArray, isNull, sql } from "drizzle-orm";
import { type NextRequest, NextResponse } from "next/server";
import { db } from "@/db";
import {
@@ -14,7 +14,7 @@ import { updateGitHubDeploymentStatus } from "@/lib/github";
import { inngest } from "@/lib/inngest/client";
import { inngestEvents } from "@/lib/inngest/events";
import { notify } from "@/lib/notifications";
-import { updateCurrentPreviewGitHubStatus } from "@/lib/preview-deployments";
+import { updatePreviewGitHubStatus } from "@/lib/preview-deployments";
import { parseServiceRevisionSpec } from "@/lib/service-revision-changes";
import { enqueueWork } from "@/lib/work-queue";
@@ -265,7 +265,7 @@ export async function POST(
? `${baseUrl}/dashboard/projects/${revision.projectSlug}/${revision.environmentName}/services/${build.serviceId}/builds/${buildId}`
: `${baseUrl}/builds/${buildId}/logs`;
if (revision.previewOfService) {
- await updateCurrentPreviewGitHubStatus({
+ await updatePreviewGitHubStatus({
serviceId: build.serviceId,
serviceRevisionId: build.serviceRevisionId,
expectedDeploymentId: build.githubDeploymentId,
@@ -395,7 +395,6 @@ export async function POST(
.select({
id: services.id,
previewOfService: services.previewOfService,
- previewCurrentRevisionId: services.previewCurrentRevisionId,
})
.from(services)
.where(
@@ -403,13 +402,22 @@ export async function POST(
)
.limit(1)
.then((rows) => rows[0]);
- if (
- !activeService ||
- (activeService.previewOfService &&
- activeService.previewCurrentRevisionId !== build.serviceRevisionId)
- ) {
+ if (!activeService) {
return;
}
+ if (activeService.previewOfService) {
+ const latestRevision = await tx
+ .select({ id: serviceRevisions.id })
+ .from(serviceRevisions)
+ .where(eq(serviceRevisions.serviceId, build.serviceId))
+ .orderBy(
+ desc(serviceRevisions.createdAt),
+ desc(serviceRevisions.id),
+ )
+ .limit(1)
+ .then((rows) => rows[0]);
+ if (latestRevision?.id !== build.serviceRevisionId) return;
+ }
await enqueueWork(
auth.serverId,
"create_manifest",
diff --git a/web/app/api/webhooks/github/route.ts b/web/app/api/webhooks/github/route.ts
index 233230ae..8e4efedb 100644
--- a/web/app/api/webhooks/github/route.ts
+++ b/web/app/api/webhooks/github/route.ts
@@ -1,5 +1,5 @@
import { createHash } from "node:crypto";
-import { and, eq, inArray, isNull } from "drizzle-orm";
+import { and, eq } from "drizzle-orm";
import { type NextRequest, NextResponse } from "next/server";
import { db } from "@/db";
import {
@@ -117,14 +117,6 @@ async function handleInstallationEvent(payload: InstallationPayload) {
await deletePreviewsForGitHubInstallation(
installation.id,
"GitHub installation suspended",
- { githubDeploymentCleanup: "defer" },
- );
- }
- if (action === "unsuspend") {
- await deletePreviewsForGitHubInstallation(
- installation.id,
- "GitHub installation unsuspended",
- { githubDeploymentCleanup: "report" },
);
}
@@ -339,8 +331,8 @@ async function handlePullRequestEvent(
| ReturnType
> = [];
const syncedBaseServiceIds = new Set();
- const linkedBaseServiceIds = linkedServices.flatMap(({ service }) =>
- !service.previewOfService && !service.deletedAt ? [service.id] : [],
+ const linkedBaseServices = linkedServices.filter(
+ ({ service }) => !service.previewOfService && !service.deletedAt,
);
if (shouldSync) {
@@ -371,24 +363,8 @@ async function handlePullRequestEvent(
}
}
- const clones =
- linkedBaseServiceIds.length > 0
- ? await db
- .select()
- .from(services)
- .where(
- and(
- inArray(services.previewOfService, linkedBaseServiceIds),
- eq(services.previewGitRef, previewGitRef),
- isNull(services.deletedAt),
- ),
- )
- : [];
- for (const clone of clones) {
- if (
- !clone.previewOfService ||
- syncedBaseServiceIds.has(clone.previewOfService)
- ) {
+ for (const { service } of linkedBaseServices) {
+ if (syncedBaseServiceIds.has(service.id)) {
continue;
}
const reason =
@@ -404,13 +380,13 @@ async function handlePullRequestEvent(
events.push(
inngestEvents.previewCloseRequested.create(
{
- baseServiceId: clone.previewOfService,
+ baseServiceId: service.id,
previewGitRef,
reason,
verifyWithGitHub: true,
},
{
- id: `github-pr-close:${deliveryId}:${clone.previewOfService}:${payload.number}`,
+ id: `github-pr-close:${deliveryId}:${service.id}:${payload.number}`,
},
),
);
diff --git a/web/components/service/details/pull-request-previews-setting.tsx b/web/components/service/details/pull-request-previews-setting.tsx
index 3044605f..5b322ab1 100644
--- a/web/components/service/details/pull-request-previews-setting.tsx
+++ b/web/components/service/details/pull-request-previews-setting.tsx
@@ -93,7 +93,7 @@ export function PullRequestPreviewsSetting({
) : !autoSubdomainDomain ? (
- Configure Automatic Subdomain Domain before enabling previews.
+ Configure Automatic Subdomain Domain to give previews public URLs.
) : null}
@@ -101,7 +101,7 @@ export function PullRequestPreviewsSetting({
id="pull-request-previews"
checked={service.previewDeploymentsEnabled}
onCheckedChange={updateEnabled}
- disabled={isPending || service.stateful || !autoSubdomainDomain}
+ disabled={isPending || service.stateful}
aria-label="Enable pull request preview deployments"
/>
diff --git a/web/db/schema.ts b/web/db/schema.ts
index 7c29e330..1766a0e9 100644
--- a/web/db/schema.ts
+++ b/web/db/schema.ts
@@ -592,10 +592,6 @@ export const services = pgTable(
.default(false),
previewOfService: text("preview_of_service"),
previewGitRef: text("preview_git_ref"),
- previewCurrentRevisionId: text("preview_current_revision_id"),
- previewGithubDeploymentId: bigint("preview_github_deployment_id", {
- mode: "number",
- }),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
@@ -630,13 +626,6 @@ export const services = pgTable(
(${table.previewOfService} is not null and ${table.previewDeploymentsEnabled} = false and ${table.stateful} = false)
)`,
),
- check(
- "services_preview_metadata_check",
- sql`${table.previewOfService} is not null or (
- ${table.previewCurrentRevisionId} is null
- and ${table.previewGithubDeploymentId} is null
- )`,
- ),
uniqueIndex("services_preview_base_ref_unique_idx")
.on(table.previewOfService, table.previewGitRef)
.where(sql`${table.previewOfService} is not null`),
diff --git a/web/lib/github.ts b/web/lib/github.ts
index 2ab18f78..5cb2b896 100644
--- a/web/lib/github.ts
+++ b/web/lib/github.ts
@@ -413,6 +413,64 @@ type DeploymentState =
| "error"
| "inactive";
+export async function findGitHubDeployment(
+ installationId: number,
+ repoFullName: string,
+ commitSha: string,
+ environment: string,
+ expectedPayload: Record,
+): Promise {
+ validateRepoFullName(repoFullName);
+ const token = await getInstallationToken(installationId);
+ const parameters = new URLSearchParams({
+ sha: commitSha,
+ environment,
+ per_page: "100",
+ });
+ const response = await fetch(
+ `https://api.github.com/repos/${repoFullName}/deployments?${parameters}`,
+ {
+ headers: {
+ Accept: "application/vnd.github+json",
+ Authorization: `Bearer ${token}`,
+ "X-GitHub-Api-Version": "2022-11-28",
+ },
+ },
+ );
+ if (!response.ok) {
+ const error = await response.text();
+ throw new Error(`Failed to list deployments: ${error}`);
+ }
+ const deployments = (await response.json()) as Array<{
+ id: number;
+ payload: unknown;
+ }>;
+ for (const deployment of deployments) {
+ let payload = deployment.payload;
+ if (typeof payload === "string") {
+ try {
+ payload = JSON.parse(payload);
+ } catch {
+ continue;
+ }
+ }
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
+ continue;
+ }
+ const payloadRecord = payload as Record;
+ if (
+ Number.isSafeInteger(deployment.id) &&
+ Object.entries(expectedPayload).every(
+ ([key, value]) =>
+ Object.hasOwn(payloadRecord, key) && payloadRecord[key] === value,
+ )
+ ) {
+ return deployment.id;
+ }
+ }
+ return null;
+}
+
export async function createGitHubDeployment(
installationId: number,
repoFullName: string,
diff --git a/web/lib/inngest/events/build.ts b/web/lib/inngest/events/build.ts
index e159191f..9960c997 100644
--- a/web/lib/inngest/events/build.ts
+++ b/web/lib/inngest/events/build.ts
@@ -10,7 +10,7 @@ export type BuildEvents = {
commitSha: string;
commitMessage: string;
branch: string;
- gitRef: string;
+ gitRef?: string;
author?: string;
actor?: ServiceRevisionActor | null;
githubDeploymentId?: number;
diff --git a/web/lib/inngest/functions/build-trigger-workflow.ts b/web/lib/inngest/functions/build-trigger-workflow.ts
index 3bd150a1..45911d05 100644
--- a/web/lib/inngest/functions/build-trigger-workflow.ts
+++ b/web/lib/inngest/functions/build-trigger-workflow.ts
@@ -1,12 +1,13 @@
import { createHash } from "node:crypto";
-import { and, eq, inArray } from "drizzle-orm";
+import { and, desc, eq, inArray, isNull, sql } from "drizzle-orm";
import { db } from "@/db";
-import { builds, serviceRevisions } from "@/db/schema";
+import { builds, serviceRevisions, services } from "@/db/schema";
import {
getTargetPlatformsForRevision,
selectBuildServerForRevision,
} from "@/lib/build-assignment";
import { isFullCommitSha } from "@/lib/github";
+import { createPreviewGitHubDeployment } from "@/lib/preview-deployments";
import { parseServiceRevisionSpec } from "@/lib/service-revision-changes";
import { enqueueWork } from "@/lib/work-queue";
import { inngest } from "../client";
@@ -30,6 +31,7 @@ export const buildTriggerWorkflow = inngest.createFunction(
serviceId,
serviceRevisionId,
buildRequestId,
+ trigger,
commitSha,
commitMessage,
branch,
@@ -42,10 +44,14 @@ export const buildTriggerWorkflow = inngest.createFunction(
throw new Error("Build fan-out requires a full 40-character commit SHA");
}
const exactCommitSha = commitSha.toLowerCase();
- const specification = await step.run("get-build-revision", async () => {
+ const revision = await step.run("get-build-revision", async () => {
const revision = await db
- .select({ specification: serviceRevisions.specification })
+ .select({
+ specification: serviceRevisions.specification,
+ previewGitRef: services.previewGitRef,
+ })
.from(serviceRevisions)
+ .innerJoin(services, eq(services.id, serviceRevisions.serviceId))
.where(
and(
eq(serviceRevisions.id, serviceRevisionId),
@@ -59,58 +65,84 @@ export const buildTriggerWorkflow = inngest.createFunction(
parsed.source.type !== "github" ||
parsed.source.commitSha !== exactCommitSha ||
parsed.source.branch !== branch ||
- parsed.source.gitRef !== gitRef
+ (revision.previewGitRef ?? undefined) !== gitRef
) {
throw new Error("Build trigger does not match its service revision");
}
- return parsed;
+ return {
+ specification: parsed,
+ isPreview: revision.previewGitRef != null,
+ };
});
+ const { specification, isPreview } = revision;
- const { buildIds, buildGroupId } = await step.run(
- "create-builds",
- async () => {
- const targetPlatforms =
- await getTargetPlatformsForRevision(specification);
- if (targetPlatforms.length === 0) {
- throw new Error("No target platforms configured for this build");
- }
- if (new Set(targetPlatforms).size !== targetPlatforms.length) {
- throw new Error(
- "Duplicate target platforms configured for this build",
- );
- }
+ const buildCreation = await step.run("create-builds", async () => {
+ const targetPlatforms =
+ await getTargetPlatformsForRevision(specification);
+ if (targetPlatforms.length === 0) {
+ throw new Error("No target platforms configured for this build");
+ }
+ if (new Set(targetPlatforms).size !== targetPlatforms.length) {
+ throw new Error("Duplicate target platforms configured for this build");
+ }
- const assignments = await Promise.all(
- targetPlatforms.map(async (platform) => ({
- id: buildIdForRequest(buildRequestId, platform),
- platform,
- serverId: await selectBuildServerForRevision(
- specification,
- platform,
- ),
- })),
+ const assignments = await Promise.all(
+ targetPlatforms.map(async (platform) => ({
+ id: buildIdForRequest(buildRequestId, platform),
+ platform,
+ serverId: await selectBuildServerForRevision(specification, platform),
+ })),
+ );
+ const buildRows = assignments.map(({ id, platform }) => ({
+ id,
+ serviceId,
+ serviceRevisionId,
+ commitSha: exactCommitSha,
+ commitMessage,
+ branch,
+ author,
+ targetPlatform: platform,
+ buildGroupId: buildRequestId,
+ status: "pending" as const,
+ githubDeploymentId,
+ }));
+ const persisted = await db.transaction(async (tx) => {
+ await tx.execute(
+ sql`select pg_advisory_xact_lock(hashtext(${serviceId}))`,
);
- const buildRows = assignments.map(({ id, platform }) => ({
- id,
- serviceId,
- serviceRevisionId,
- commitSha: exactCommitSha,
- commitMessage,
- branch,
- author,
- targetPlatform: platform,
- buildGroupId: buildRequestId,
- status: "pending" as const,
- githubDeploymentId,
- }));
- const inserted = await db
+ if (isPreview) {
+ const [activeService, latestRevision] = await Promise.all([
+ tx
+ .select({ id: services.id })
+ .from(services)
+ .where(
+ and(eq(services.id, serviceId), isNull(services.deletedAt)),
+ )
+ .then((rows) => rows[0]),
+ tx
+ .select({ id: serviceRevisions.id })
+ .from(serviceRevisions)
+ .where(eq(serviceRevisions.serviceId, serviceId))
+ .orderBy(
+ desc(serviceRevisions.createdAt),
+ desc(serviceRevisions.id),
+ )
+ .limit(1)
+ .then((rows) => rows[0]),
+ ]);
+ if (!activeService || latestRevision?.id !== serviceRevisionId) {
+ return false;
+ }
+ }
+
+ const inserted = await tx
.insert(builds)
.values(buildRows)
.onConflictDoNothing({ target: builds.id })
.returning({ id: builds.id });
if (inserted.length !== buildRows.length) {
- const existingRows = await db
+ const existingRows = await tx
.select({
id: builds.id,
serviceId: builds.serviceId,
@@ -145,21 +177,44 @@ export const buildTriggerWorkflow = inngest.createFunction(
}
}
}
+ return true;
+ });
- for (const assignment of assignments) {
- await enqueueWork(
+ return {
+ stale: !persisted,
+ buildIds: assignments.map((assignment) => assignment.id),
+ buildGroupId: buildRequestId,
+ assignments,
+ };
+ });
+ if (buildCreation.stale) {
+ return {
+ status: "cancelled",
+ reason: "superseded_preview_revision",
+ buildGroupId: buildRequestId,
+ };
+ }
+ const { buildIds, buildGroupId, assignments } = buildCreation;
+ if (trigger === "preview" && !githubDeploymentId) {
+ await step.run("create-preview-github-deployment", () =>
+ createPreviewGitHubDeployment({
+ serviceId,
+ serviceRevisionId,
+ commitSha: exactCommitSha,
+ }),
+ );
+ }
+ await step.run("enqueue-builds", () =>
+ Promise.all(
+ assignments.map((assignment) =>
+ enqueueWork(
assignment.serverId,
"build",
{ buildId: assignment.id },
{ id: `build-work-${assignment.id}` },
- );
- }
-
- return {
- buildIds: assignments.map((assignment) => assignment.id),
- buildGroupId: buildRequestId,
- };
- },
+ ),
+ ),
+ ),
);
await step.run("send-build-started", async () => {
diff --git a/web/lib/inngest/functions/build-workflow.ts b/web/lib/inngest/functions/build-workflow.ts
index b74145ea..16926dd6 100644
--- a/web/lib/inngest/functions/build-workflow.ts
+++ b/web/lib/inngest/functions/build-workflow.ts
@@ -2,7 +2,7 @@ import { and, eq, inArray } from "drizzle-orm";
import { db } from "@/db";
import { builds, workQueue } from "@/db/schema";
import { deployServiceRevisionInternal } from "@/lib/deploy-service";
-import { updateCurrentPreviewGitHubStatus } from "@/lib/preview-deployments";
+import { updatePreviewGitHubStatus } from "@/lib/preview-deployments";
import { inngest } from "../client";
import { inngestEvents } from "../events";
@@ -174,7 +174,7 @@ async function markPreviewBuildFailed(
description: string,
) {
try {
- await updateCurrentPreviewGitHubStatus({
+ await updatePreviewGitHubStatus({
serviceId,
serviceRevisionId,
state: "failure",
diff --git a/web/lib/inngest/functions/index.ts b/web/lib/inngest/functions/index.ts
index 37152db9..dfd95ce4 100644
--- a/web/lib/inngest/functions/index.ts
+++ b/web/lib/inngest/functions/index.ts
@@ -23,7 +23,6 @@ export { migrationWorkflow } from "./migration-workflow";
export { notificationDelivery } from "./notification-delivery";
export { onDeploymentFailed } from "./on-deployment-failed";
export {
- previewCloseWorkflow,
previewServiceReconcileWorkflow,
previewSyncWorkflow,
} from "./preview-workflow";
diff --git a/web/lib/inngest/functions/preview-workflow.ts b/web/lib/inngest/functions/preview-workflow.ts
index 1fe9906d..fb5a1836 100644
--- a/web/lib/inngest/functions/preview-workflow.ts
+++ b/web/lib/inngest/functions/preview-workflow.ts
@@ -1,16 +1,14 @@
-import { and, eq, isNull, sql } from "drizzle-orm";
+import { and, desc, eq, isNull } from "drizzle-orm";
import { db } from "@/db";
import { githubRepos, serviceRevisions, services } from "@/db/schema";
import {
- createGitHubDeployment,
getGitHubPullRequest,
listOpenGitHubPullRequests,
resolveGitHubPullRequestMergeRef,
- updateGitHubDeploymentStatus,
} from "@/lib/github";
import {
createPreviewClone,
- updateCurrentPreviewGitHubStatus,
+ inactivatePreviewGitHubDeployments,
} from "@/lib/preview-deployments";
import {
cancelPreviewRevisionWork,
@@ -137,27 +135,27 @@ async function closePreviewFromEvent(input: {
return closePreview(input.baseServiceId, input.previewGitRef, input.reason);
}
-async function loadCurrentPreviewRevision(serviceId: string) {
+async function loadLatestPreviewRevision(serviceId: string) {
const service = await db
- .select({
- previewCurrentRevisionId: services.previewCurrentRevisionId,
- previewGithubDeploymentId: services.previewGithubDeploymentId,
- })
+ .select({ previewOfService: services.previewOfService })
.from(services)
- .where(eq(services.id, serviceId))
+ .where(and(eq(services.id, serviceId), isNull(services.deletedAt)))
.then((rows) => rows[0]);
- if (!service?.previewCurrentRevisionId) {
- return service ? { ...service, commitSha: null } : null;
- }
+ if (!service?.previewOfService) return null;
const revision = await db
- .select({ specification: serviceRevisions.specification })
+ .select({
+ id: serviceRevisions.id,
+ specification: serviceRevisions.specification,
+ })
.from(serviceRevisions)
- .where(eq(serviceRevisions.id, service.previewCurrentRevisionId))
+ .where(eq(serviceRevisions.serviceId, serviceId))
+ .orderBy(desc(serviceRevisions.createdAt), desc(serviceRevisions.id))
+ .limit(1)
.then((rows) => rows[0]);
- if (!revision) return { ...service, commitSha: null };
+ if (!revision) return null;
const specification = parseServiceRevisionSpec(revision.specification);
return {
- ...service,
+ id: revision.id,
commitSha:
specification.source.type === "github"
? specification.source.commitSha
@@ -165,86 +163,13 @@ async function loadCurrentPreviewRevision(serviceId: string) {
};
}
-async function clearCurrentPreviewRevision(input: {
- baseServiceId: string;
- previewGitRef: string;
- previewServiceId: string;
-}) {
- return db.transaction(async (tx) => {
- await tx.execute(
- sql`select pg_advisory_xact_lock(hashtext(${input.baseServiceId}))`,
- );
- await tx.execute(
- sql`select pg_advisory_xact_lock(hashtext(${input.baseServiceId}), hashtext(${input.previewGitRef}))`,
- );
- await tx.execute(
- sql`select pg_advisory_xact_lock(hashtext(${input.previewServiceId}))`,
- );
- const current = await tx
- .select({
- previewCurrentRevisionId: services.previewCurrentRevisionId,
- previewGithubDeploymentId: services.previewGithubDeploymentId,
- })
- .from(services)
- .where(
- and(
- eq(services.id, input.previewServiceId),
- eq(services.previewOfService, input.baseServiceId),
- eq(services.previewGitRef, input.previewGitRef),
- isNull(services.deletedAt),
- ),
- )
- .then((rows) => rows[0]);
- if (!current) return null;
- await tx
- .update(services)
- .set({
- previewCurrentRevisionId: null,
- previewGithubDeploymentId: null,
- })
- .where(eq(services.id, input.previewServiceId));
- return current;
- });
-}
-
-async function storePreviewGitHubDeployment(input: {
- baseServiceId: string;
- previewGitRef: string;
- previewServiceId: string;
- githubDeploymentId: number;
-}) {
- return db.transaction(async (tx) => {
- await tx.execute(
- sql`select pg_advisory_xact_lock(hashtext(${input.baseServiceId}))`,
- );
- await tx.execute(
- sql`select pg_advisory_xact_lock(hashtext(${input.baseServiceId}), hashtext(${input.previewGitRef}))`,
- );
- await tx.execute(
- sql`select pg_advisory_xact_lock(hashtext(${input.previewServiceId}))`,
- );
- return tx
- .update(services)
- .set({ previewGithubDeploymentId: input.githubDeploymentId })
- .where(
- and(
- eq(services.id, input.previewServiceId),
- eq(services.previewOfService, input.baseServiceId),
- eq(services.previewGitRef, input.previewGitRef),
- isNull(services.previewCurrentRevisionId),
- isNull(services.previewGithubDeploymentId),
- isNull(services.deletedAt),
- ),
- )
- .returning({ id: services.id })
- .then((rows) => rows.length > 0);
- });
-}
-
export const previewSyncWorkflow = inngest.createFunction(
{
id: "preview-sync-workflow",
- triggers: [inngestEvents.previewSyncRequested],
+ triggers: [
+ inngestEvents.previewSyncRequested,
+ inngestEvents.previewCloseRequested,
+ ],
concurrency: [
{
limit: 1,
@@ -253,7 +178,13 @@ export const previewSyncWorkflow = inngest.createFunction(
],
},
async ({ event, step }) => {
- const { baseServiceId, previewGitRef, force = false } = event.data;
+ if (event.name === inngestEvents.previewCloseRequested.name) {
+ return step.run("delete-preview", () =>
+ closePreviewFromEvent(event.data),
+ );
+ }
+ const { baseServiceId, previewGitRef } = event.data;
+ const force = "force" in event.data && event.data.force === true;
const pullRequestNumber = pullRequestNumberFromMergeRef(previewGitRef);
const context = await step.run("load-base-service", () =>
loadBaseContext(baseServiceId),
@@ -285,8 +216,8 @@ export const previewSyncWorkflow = inngest.createFunction(
previewGitRef,
}),
);
- const previous = await step.run("load-current-preview-revision", () =>
- loadCurrentPreviewRevision(clone.serviceId),
+ const previous = await step.run("load-latest-preview-revision", () =>
+ loadLatestPreviewRevision(clone.serviceId),
);
let mergeRef: { gitRef: string; sha: string };
try {
@@ -297,84 +228,14 @@ export const previewSyncWorkflow = inngest.createFunction(
pullRequestNumber,
),
);
- } catch (error) {
- const superseded = await step.run("clear-unmergeable-preview", () =>
- clearCurrentPreviewRevision({
- baseServiceId,
- previewGitRef,
- previewServiceId: clone.serviceId,
- }),
+ } catch {
+ await step.run("deactivate-unmergeable-preview", () =>
+ deactivatePreviewRuntime(clone.serviceId),
);
- if (superseded?.previewCurrentRevisionId) {
- await step.run("deactivate-unmergeable-preview", () =>
- deactivatePreviewRuntime(clone.serviceId),
- );
- }
- if (superseded?.previewGithubDeploymentId) {
- await step.run("inactivate-unmergeable-deployment", () =>
- updateGitHubDeploymentStatus(
- context.githubRepo.installationId,
- context.githubRepo.repoFullName,
- superseded.previewGithubDeploymentId!,
- "inactive",
- { description: "Preview merge ref is unavailable" },
- ),
- );
- }
- if (!superseded) {
- return { status: "closed", reason: "preview_service_unavailable" };
- }
- const message =
- error instanceof Error
- ? error.message
- : "Pull request merge ref unavailable";
- const failedDeploymentId = await step.run(
- "create-unmergeable-deployment",
- () =>
- createGitHubDeployment(
- context.githubRepo.installationId,
- context.githubRepo.repoFullName,
- pullRequest.head.sha,
- `preview/${context.service.name}/pr-${pullRequestNumber}`,
- `Preview unavailable for PR #${pullRequestNumber}`,
- {
- transientEnvironment: true,
- productionEnvironment: false,
- payload: {
- baseServiceId,
- previewServiceId: clone.serviceId,
- previewGitRef,
- },
- },
- ),
- );
- const stored = await step.run("store-unmergeable-deployment", () =>
- storePreviewGitHubDeployment({
- baseServiceId,
- previewGitRef,
- previewServiceId: clone.serviceId,
- githubDeploymentId: failedDeploymentId,
- }),
- );
- if (!stored) {
- await step.run("inactivate-orphaned-unmergeable-deployment", () =>
- updateGitHubDeploymentStatus(
- context.githubRepo.installationId,
- context.githubRepo.repoFullName,
- failedDeploymentId,
- "inactive",
- { description: "Preview was removed" },
- ),
- );
- return { status: "closed", reason: "preview_service_unavailable" };
- }
- await step.run("mark-unmergeable-deployment-failed", () =>
- updateCurrentPreviewGitHubStatus({
+ await step.run("inactivate-unmergeable-deployments", () =>
+ inactivatePreviewGitHubDeployments({
serviceId: clone.serviceId,
- serviceRevisionId: null,
- expectedDeploymentId: failedDeploymentId,
- state: "failure",
- description: message,
+ description: "Preview merge ref is unavailable",
}),
);
return { status: "failed", reason: "merge_ref_unavailable" };
@@ -384,199 +245,37 @@ export const previewSyncWorkflow = inngest.createFunction(
return { status: "unchanged", serviceId: clone.serviceId };
}
- const deploymentId = await step.run("create-github-deployment", () =>
- createGitHubDeployment(
- context.githubRepo.installationId,
- context.githubRepo.repoFullName,
- mergeRef.sha,
- `preview/${context.service.name}/pr-${pullRequestNumber}`,
- `Preview PR #${pullRequestNumber}: ${pullRequest.title}`.substring(
- 0,
- 140,
- ),
- {
- transientEnvironment: true,
- productionEnvironment: false,
- payload: {
- baseServiceId,
- previewServiceId: clone.serviceId,
- previewGitRef,
- },
+ const queued = await step.run("queue-preview-build", () =>
+ triggerResolvedBuildInternal(clone.serviceId, {
+ trigger: "preview",
+ commitSha: mergeRef.sha,
+ commitMessage: `Preview PR #${pullRequestNumber}: ${pullRequest.title}`,
+ author: pullRequest.user.login,
+ actor: {
+ type: "github",
+ githubUserId: pullRequest.user.id,
+ login: pullRequest.user.login,
},
- ),
+ gitRef: mergeRef.gitRef,
+ idempotencyKey: force
+ ? `preview:${clone.serviceId}:${mergeRef.sha}:${event.id}`
+ : `preview:${clone.serviceId}:${mergeRef.sha}`,
+ }),
);
-
- let activatedRevisionId: string | null = null;
- let queued: Awaited>;
- try {
- queued = await step.run("queue-preview-build", () =>
- triggerResolvedBuildInternal(clone.serviceId, {
- trigger: "preview",
- commitSha: mergeRef.sha,
- commitMessage: `Preview PR #${pullRequestNumber}: ${pullRequest.title}`,
- author: pullRequest.user.login,
- actor: {
- type: "github",
- githubUserId: pullRequest.user.id,
- login: pullRequest.user.login,
- },
- expectedRepository: `https://github.com/${context.githubRepo.repoFullName}`,
- expectedBranch:
- context.githubRepo.deployBranch ?? context.githubRepo.defaultBranch,
- gitRef: mergeRef.gitRef,
- githubDeploymentId: deploymentId,
- idempotencyKey: force
- ? `preview:${clone.serviceId}:${mergeRef.sha}:${event.id}`
- : `preview:${clone.serviceId}:${mergeRef.sha}`,
- beforeDispatch: async (serviceRevisionId) => {
- activatedRevisionId = serviceRevisionId;
- const activated = await db.transaction(async (tx) => {
- await tx.execute(
- sql`select pg_advisory_xact_lock(hashtext(${baseServiceId}))`,
- );
- await tx.execute(
- sql`select pg_advisory_xact_lock(hashtext(${baseServiceId}), hashtext(${previewGitRef}))`,
- );
- await tx.execute(
- sql`select pg_advisory_xact_lock(hashtext(${clone.serviceId}))`,
- );
- return tx
- .update(services)
- .set({
- previewCurrentRevisionId: serviceRevisionId,
- previewGithubDeploymentId: deploymentId,
- })
- .where(
- and(
- eq(services.id, clone.serviceId),
- eq(services.previewOfService, baseServiceId),
- eq(services.previewGitRef, previewGitRef),
- isNull(services.deletedAt),
- ),
- )
- .returning({ id: services.id });
- });
- if (activated.length === 0) {
- throw new Error("Preview was closed before its build was queued");
- }
- },
- }),
- );
- } catch (error) {
- const message =
- error instanceof Error
- ? error.message
- : "Failed to queue preview build";
- const previewStillExists = await step.run(
- "restore-preview-after-queue-failure",
- () =>
- db.transaction(async (tx) => {
- await tx.execute(
- sql`select pg_advisory_xact_lock(hashtext(${baseServiceId}))`,
- );
- await tx.execute(
- sql`select pg_advisory_xact_lock(hashtext(${baseServiceId}), hashtext(${previewGitRef}))`,
- );
- await tx.execute(
- sql`select pg_advisory_xact_lock(hashtext(${clone.serviceId}))`,
- );
- const current = await tx
- .select({
- previewCurrentRevisionId: services.previewCurrentRevisionId,
- previewGithubDeploymentId: services.previewGithubDeploymentId,
- })
- .from(services)
- .where(
- and(
- eq(services.id, clone.serviceId),
- isNull(services.deletedAt),
- ),
- )
- .then((rows) => rows[0]);
- if (
- activatedRevisionId &&
- current?.previewCurrentRevisionId === activatedRevisionId &&
- current.previewGithubDeploymentId === deploymentId
- ) {
- await tx
- .update(services)
- .set({
- previewCurrentRevisionId:
- previous?.previewCurrentRevisionId ?? null,
- previewGithubDeploymentId:
- previous?.previewGithubDeploymentId ?? null,
- })
- .where(eq(services.id, clone.serviceId));
- }
- return current != null;
- }),
- );
- if (activatedRevisionId) {
- await step.run("cancel-undispatched-preview", () =>
- cancelPreviewRevisionWork(clone.serviceId, activatedRevisionId!),
- );
- }
- await step.run("mark-preview-queue-failed", () =>
- updateGitHubDeploymentStatus(
- context.githubRepo.installationId,
- context.githubRepo.repoFullName,
- deploymentId,
- previewStillExists ? "failure" : "inactive",
- {
- description: previewStillExists
- ? message.substring(0, 140)
- : "Preview was removed",
- },
- ),
+ if (previous) {
+ await step.run("cancel-superseded-preview", () =>
+ cancelPreviewRevisionWork(clone.serviceId, previous.id),
);
- throw error;
}
-
- await step.run("mark-preview-pending", () =>
- updateCurrentPreviewGitHubStatus({
+ await step.run("inactivate-superseded-deployments", () =>
+ inactivatePreviewGitHubDeployments({
serviceId: clone.serviceId,
- serviceRevisionId: queued.serviceRevisionId,
- expectedDeploymentId: deploymentId,
- state: "pending",
- description: "Preview build queued",
+ excludeServiceRevisionId: queued.serviceRevisionId,
+ description: "Superseded by a newer preview revision",
}),
);
- if (previous?.previewCurrentRevisionId) {
- await step.run("cancel-superseded-preview", () =>
- cancelPreviewRevisionWork(
- clone.serviceId,
- previous.previewCurrentRevisionId!,
- ),
- );
- }
- if (previous?.previewGithubDeploymentId) {
- await step.run("inactivate-superseded-deployment", () =>
- updateGitHubDeploymentStatus(
- context.githubRepo.installationId,
- context.githubRepo.repoFullName,
- previous.previewGithubDeploymentId!,
- "inactive",
- { description: "Superseded by a newer preview revision" },
- ),
- );
- }
- return { ...queued, deploymentId };
- },
-);
-
-export const previewCloseWorkflow = inngest.createFunction(
- {
- id: "preview-close-workflow",
- triggers: [inngestEvents.previewCloseRequested],
- concurrency: [
- {
- limit: 1,
- key: 'event.data.baseServiceId + ":" + event.data.previewGitRef',
- },
- ],
+ return queued;
},
- async ({ event, step }) =>
- step.run("delete-preview", () => closePreviewFromEvent(event.data)),
);
export const previewServiceReconcileWorkflow = inngest.createFunction(
@@ -635,20 +334,34 @@ export const previewServiceReconcileWorkflow = inngest.createFunction(
.from(services)
.where(eq(services.previewOfService, event.data.baseServiceId)),
);
+ const deleting = existing.filter(
+ (clone) => clone.previewGitRef && clone.deletedAt != null,
+ );
+ await Promise.all(
+ deleting.map((clone) =>
+ step.run(`finish-delete-${clone.previewGitRef}`, () =>
+ closePreview(
+ event.data.baseServiceId,
+ clone.previewGitRef!,
+ "retrying preview deletion",
+ ),
+ ),
+ ),
+ );
const stale = existing.filter(
(clone) =>
clone.previewGitRef &&
- (clone.deletedAt != null || !eligibleRefs.has(clone.previewGitRef)),
+ clone.deletedAt == null &&
+ !eligibleRefs.has(clone.previewGitRef),
);
await Promise.all(
stale.map((clone) =>
- step.run(`close-stale-${clone.previewGitRef}`, () =>
- closePreview(
+ step.run(`queue-close-${clone.previewGitRef}`, () =>
+ enqueuePreviewClose(
event.data.baseServiceId,
clone.previewGitRef!,
- clone.deletedAt
- ? "retrying preview deletion"
- : "pull request no longer eligible",
+ "pull request no longer eligible",
+ `reconcile:${event.id}`,
),
),
),
@@ -667,11 +380,28 @@ export const previewServiceReconcileWorkflow = inngest.createFunction(
return {
status: "queued",
count: eligible.length,
- closed: stale.length,
+ closed: deleting.length + stale.length,
};
},
);
+async function enqueuePreviewClose(
+ baseServiceId: string,
+ previewGitRef: string,
+ reason: string,
+ idSuffix: string,
+) {
+ const pullRequestNumber = pullRequestNumberFromMergeRef(previewGitRef);
+ await inngest.send(
+ inngestEvents.previewCloseRequested.create(
+ { baseServiceId, previewGitRef, reason, verifyWithGitHub: true },
+ {
+ id: `preview-close:${baseServiceId}:${pullRequestNumber}:${idSuffix}`,
+ },
+ ),
+ );
+}
+
async function enqueuePreviewSync(
baseServiceId: string,
previewGitRef: string,
diff --git a/web/lib/inngest/functions/rollout-helpers.ts b/web/lib/inngest/functions/rollout-helpers.ts
index 9b7bb12f..926b4a26 100644
--- a/web/lib/inngest/functions/rollout-helpers.ts
+++ b/web/lib/inngest/functions/rollout-helpers.ts
@@ -1,11 +1,12 @@
import { randomUUID } from "node:crypto";
-import { and, eq, inArray, isNotNull, isNull, sql } from "drizzle-orm";
+import { and, desc, eq, inArray, isNotNull, isNull, sql } from "drizzle-orm";
import { db } from "@/db";
import {
deploymentPorts,
deployments,
rollouts,
servers,
+ serviceRevisions,
services,
} from "@/db/schema";
import { getCertificate, issueCertificate } from "@/lib/acme-manager";
@@ -61,6 +62,7 @@ export function distributeReplicas(
export type DeploymentContext = {
revisionId: string;
+ isPreview: boolean;
specification: ServiceRevisionSpec;
placements: Placement[];
serverMap: Map<
@@ -71,6 +73,27 @@ export type DeploymentContext = {
isRollingUpdate: boolean;
};
+async function isCurrentPreviewRevision(
+ tx: RolloutTransaction,
+ serviceId: string,
+ revisionId: string,
+) {
+ const latest = await tx
+ .select({ id: serviceRevisions.id })
+ .from(serviceRevisions)
+ .innerJoin(services, eq(services.id, serviceRevisions.serviceId))
+ .where(
+ and(
+ eq(serviceRevisions.serviceId, serviceId),
+ isNull(services.deletedAt),
+ ),
+ )
+ .orderBy(desc(serviceRevisions.createdAt), desc(serviceRevisions.id))
+ .limit(1)
+ .then((rows) => rows[0]);
+ return latest?.id === revisionId;
+}
+
async function getUsedPorts(
tx: RolloutTransaction,
serverId: string,
@@ -364,7 +387,8 @@ export async function createDeploymentRecords(
serviceId: string,
context: DeploymentContext,
): Promise<{ deploymentIds: string[] }> {
- const { revisionId, specification, placements, serverMap } = context;
+ const { revisionId, isPreview, specification, placements, serverMap } =
+ context;
const requestedReplicasByServer = new Map(
placements.map((placement) => [placement.serverId, placement.replicas]),
@@ -392,20 +416,9 @@ export async function createDeploymentRecords(
await tx.execute(
sql`SELECT pg_advisory_xact_lock(hashtext(${serviceId}))`,
);
- const service = await tx
- .select({
- previewOfService: services.previewOfService,
- previewCurrentRevisionId: services.previewCurrentRevisionId,
- })
- .from(services)
- .where(
- and(eq(services.id, serviceId), isNull(services.deletedAt)),
- )
- .then((rows) => rows[0]);
if (
- !service ||
- (service.previewOfService &&
- service.previewCurrentRevisionId !== revisionId)
+ isPreview &&
+ !(await isCurrentPreviewRevision(tx, serviceId, revisionId))
) {
throw new Error("Preview revision is no longer current");
}
@@ -513,25 +526,17 @@ export async function completeRollout(
serviceId: string,
context: Omit,
): Promise<{ completed: boolean; stoppedCount: number }> {
- const { placements, revisionId, specification, isRollingUpdate } = context;
+ const { placements, revisionId, isPreview, specification, isRollingUpdate } =
+ context;
const lockedServerId = specification.stateful
? placements[0]?.serverId
: undefined;
return db.transaction(async (tx) => {
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${serviceId}))`);
- const service = await tx
- .select({
- previewOfService: services.previewOfService,
- previewCurrentRevisionId: services.previewCurrentRevisionId,
- })
- .from(services)
- .where(and(eq(services.id, serviceId), isNull(services.deletedAt)))
- .then((rows) => rows[0]);
if (
- !service ||
- (service.previewOfService &&
- service.previewCurrentRevisionId !== revisionId)
+ isPreview &&
+ !(await isCurrentPreviewRevision(tx, serviceId, revisionId))
) {
return { completed: false, stoppedCount: 0 };
}
diff --git a/web/lib/inngest/functions/rollout-utils.ts b/web/lib/inngest/functions/rollout-utils.ts
index 3441204f..065a52d1 100644
--- a/web/lib/inngest/functions/rollout-utils.ts
+++ b/web/lib/inngest/functions/rollout-utils.ts
@@ -3,7 +3,7 @@ import { db } from "@/db";
import { deployments, rollouts } from "@/db/schema";
import { markDeploymentFailedRemoved } from "@/lib/deployment-status";
import { notify } from "@/lib/notifications";
-import { updateCurrentPreviewGitHubStatus } from "@/lib/preview-deployments";
+import { updatePreviewGitHubStatus } from "@/lib/preview-deployments";
import {
enqueueReconcileForAllOnlineServers,
enqueueWork,
@@ -15,93 +15,82 @@ export async function handleRolloutFailure(
reason: string,
isRollingUpdate: boolean,
): Promise {
- const { applied, rolloutDeployments, serviceRevisionId } =
- await db.transaction(async (tx) => {
- const [rollout] = await tx
- .select({
- status: rollouts.status,
- serviceRevisionId: rollouts.serviceRevisionId,
- })
- .from(rollouts)
- .where(eq(rollouts.id, rolloutId))
- .for("update");
- if (rollout?.status !== "in_progress") {
- return {
- applied: false,
- rolloutDeployments: [],
- serviceRevisionId: rollout?.serviceRevisionId ?? null,
- };
- }
+ const result = await db.transaction(async (tx) => {
+ const [rollout] = await tx
+ .select({
+ status: rollouts.status,
+ serviceRevisionId: rollouts.serviceRevisionId,
+ })
+ .from(rollouts)
+ .where(eq(rollouts.id, rolloutId))
+ .for("update");
+ if (rollout?.status !== "in_progress") {
+ return { applied: false as const, rolloutDeployments: [] };
+ }
- const rolloutDeployments = await tx
- .select()
- .from(deployments)
- .where(eq(deployments.rolloutId, rolloutId));
- await tx
- .update(rollouts)
- .set({
- status: rolloutDeployments.length === 0 ? "failed" : "rolled_back",
- currentStage: reason,
- completedAt: new Date(),
- })
- .where(eq(rollouts.id, rolloutId));
+ const rolloutDeployments = await tx
+ .select()
+ .from(deployments)
+ .where(eq(deployments.rolloutId, rolloutId));
+ await tx
+ .update(rollouts)
+ .set({
+ status: rolloutDeployments.length === 0 ? "failed" : "rolled_back",
+ currentStage: reason,
+ completedAt: new Date(),
+ })
+ .where(eq(rollouts.id, rolloutId));
- if (rolloutDeployments.length === 0) {
- return {
- applied: true,
- rolloutDeployments,
- serviceRevisionId: rollout.serviceRevisionId,
- };
- }
-
- if (isRollingUpdate) {
- await tx
- .update(deployments)
- .set({ trafficState: "active" })
- .where(
- and(
- eq(deployments.serviceId, serviceId),
- eq(deployments.trafficState, "draining"),
- ),
- );
- }
+ if (rolloutDeployments.length === 0) {
+ return { applied: true as const, rolloutDeployments, rollout };
+ }
- const removedDeployments = await tx
+ if (isRollingUpdate) {
+ await tx
.update(deployments)
- .set(markDeploymentFailedRemoved(reason))
+ .set({ trafficState: "active" })
.where(
and(
- eq(deployments.rolloutId, rolloutId),
- ne(deployments.runtimeDesiredState, "removed"),
+ eq(deployments.serviceId, serviceId),
+ eq(deployments.trafficState, "draining"),
),
- )
- .returning({ serverId: deployments.serverId });
+ );
+ }
+
+ const removedDeployments = await tx
+ .update(deployments)
+ .set(markDeploymentFailedRemoved(reason))
+ .where(
+ and(
+ eq(deployments.rolloutId, rolloutId),
+ ne(deployments.runtimeDesiredState, "removed"),
+ ),
+ )
+ .returning({ serverId: deployments.serverId });
- if (isRollingUpdate) {
- await enqueueReconcileForAllOnlineServers("rollout_rolled_back", tx);
- } else {
- for (const serverId of new Set(
- removedDeployments.map((deployment) => deployment.serverId),
- )) {
- await enqueueWork(
- serverId,
- "reconcile",
- { reason: "rollout_rolled_back" },
- { tx },
- );
- }
+ if (isRollingUpdate) {
+ await enqueueReconcileForAllOnlineServers("rollout_rolled_back", tx);
+ } else {
+ for (const serverId of new Set(
+ removedDeployments.map((deployment) => deployment.serverId),
+ )) {
+ await enqueueWork(
+ serverId,
+ "reconcile",
+ { reason: "rollout_rolled_back" },
+ { tx },
+ );
}
+ }
- return {
- applied: true,
- rolloutDeployments,
- serviceRevisionId: rollout.serviceRevisionId,
- };
- });
- if (!applied) return;
+ return { applied: true as const, rolloutDeployments, rollout };
+ });
+ if (!result.applied) return;
+ const { rolloutDeployments } = result;
+ const serviceRevisionId = result.rollout.serviceRevisionId;
if (serviceRevisionId) {
try {
- await updateCurrentPreviewGitHubStatus({
+ await updatePreviewGitHubStatus({
serviceId,
serviceRevisionId,
state: "failure",
diff --git a/web/lib/inngest/functions/rollout-workflow.ts b/web/lib/inngest/functions/rollout-workflow.ts
index 599e208f..0c54f5c4 100644
--- a/web/lib/inngest/functions/rollout-workflow.ts
+++ b/web/lib/inngest/functions/rollout-workflow.ts
@@ -1,12 +1,29 @@
-import { and, eq, gte, inArray, isNull, lt, ne, or, sql } from "drizzle-orm";
+import {
+ and,
+ desc,
+ eq,
+ gte,
+ inArray,
+ isNull,
+ lt,
+ ne,
+ or,
+ sql,
+} from "drizzle-orm";
import { db } from "@/db";
import { getService } from "@/db/queries";
-import { deployments, rollouts, servers, services } from "@/db/schema";
+import {
+ deployments,
+ rollouts,
+ servers,
+ serviceRevisions,
+ services,
+} from "@/db/schema";
import { isObservedReady, observedReadyPhases } from "@/lib/deployment-status";
import { buildRoutingTargets } from "@/lib/routing-sync";
import {
canDeployServiceRevision,
- updateCurrentPreviewGitHubStatus,
+ updatePreviewGitHubStatus,
} from "@/lib/preview-deployments";
import type { ServiceRevisionSpec } from "@/lib/service-revision-spec";
import { getRolloutServiceRevision } from "@/lib/service-revisions";
@@ -206,11 +223,12 @@ export const rolloutWorkflow = inngest.createFunction(
async ({ event, step }) => {
const { rolloutId, serviceId } = event.data;
- await step.run("validate-service", async () => {
+ const isPreview = await step.run("validate-service", async () => {
const svc = await getService(serviceId);
if (!svc) {
throw new Error("Service not found");
}
+ return Boolean(svc.previewOfService);
});
let acquiredTurn = false;
@@ -269,10 +287,11 @@ export const rolloutWorkflow = inngest.createFunction(
};
});
const specification = revision.specification;
- const currentRevision = await step.run(
- "validate-current-preview-revision",
- () => canDeployServiceRevision(serviceId, revision.id),
- );
+ const currentRevision =
+ !isPreview ||
+ (await step.run("validate-current-preview-revision", () =>
+ canDeployServiceRevision(serviceId, revision.id),
+ ));
if (!currentRevision) {
await step.run("mark-superseded-preview-rollout", () =>
db
@@ -441,7 +460,10 @@ export const rolloutWorkflow = inngest.createFunction(
}
const { deploymentIds } = await step.run("create-deployments", async () => {
- if (!(await canDeployServiceRevision(serviceId, revision.id))) {
+ if (
+ isPreview &&
+ !(await canDeployServiceRevision(serviceId, revision.id))
+ ) {
throw new Error("Preview revision was superseded before deployment");
}
await db
@@ -456,6 +478,7 @@ export const rolloutWorkflow = inngest.createFunction(
const result = await createDeploymentRecords(rolloutId, serviceId, {
revisionId: revision.id,
+ isPreview,
specification,
placements,
serverMap,
@@ -615,20 +638,26 @@ export const rolloutWorkflow = inngest.createFunction(
await tx.execute(
sql`SELECT pg_advisory_xact_lock(hashtext(${serviceId}))`,
);
- const service = await tx
- .select({
- previewOfService: services.previewOfService,
- previewCurrentRevisionId: services.previewCurrentRevisionId,
- })
- .from(services)
- .where(and(eq(services.id, serviceId), isNull(services.deletedAt)))
- .then((rows) => rows[0]);
- if (
- !service ||
- (service.previewOfService &&
- service.previewCurrentRevisionId !== revision.id)
- ) {
- throw new Error("Preview revision was superseded before routing");
+ if (isPreview) {
+ const latestRevision = await tx
+ .select({ id: serviceRevisions.id })
+ .from(serviceRevisions)
+ .innerJoin(services, eq(services.id, serviceRevisions.serviceId))
+ .where(
+ and(
+ eq(serviceRevisions.serviceId, serviceId),
+ isNull(services.deletedAt),
+ ),
+ )
+ .orderBy(
+ desc(serviceRevisions.createdAt),
+ desc(serviceRevisions.id),
+ )
+ .limit(1)
+ .then((rows) => rows[0]);
+ if (latestRevision?.id !== revision.id) {
+ throw new Error("Preview revision was superseded before routing");
+ }
}
const [rollout] = await tx
.select({ status: rollouts.status })
@@ -731,11 +760,15 @@ export const rolloutWorkflow = inngest.createFunction(
}
const rolloutCompleted = await step.run("complete-rollout", async () => {
- if (!(await canDeployServiceRevision(serviceId, revision.id))) {
+ if (
+ isPreview &&
+ !(await canDeployServiceRevision(serviceId, revision.id))
+ ) {
return false;
}
const result = await completeRollout(rolloutId, serviceId, {
revisionId: revision.id,
+ isPreview,
specification,
placements,
totalReplicas,
@@ -756,24 +789,21 @@ export const rolloutWorkflow = inngest.createFunction(
"completed",
"Rollout completed successfully",
);
- try {
- await updateCurrentPreviewGitHubStatus({
- serviceId,
- serviceRevisionId: revision.id,
- state: "success",
- description: "Preview is ready",
- });
- } catch (error) {
- console.error(
- "[rollout:complete] failed to update preview status:",
- error,
- );
- }
return true;
});
if (!rolloutCompleted) {
return { status: "cancelled", rolloutId };
}
+ if (isPreview) {
+ await step.run("report-preview-ready", () =>
+ updatePreviewGitHubStatus({
+ serviceId,
+ serviceRevisionId: revision.id,
+ state: "success",
+ description: "Preview is ready",
+ }),
+ );
+ }
return { status: "completed", rolloutId };
},
diff --git a/web/lib/preview-deployments.ts b/web/lib/preview-deployments.ts
index 7bb7e7ed..5314f4aa 100644
--- a/web/lib/preview-deployments.ts
+++ b/web/lib/preview-deployments.ts
@@ -1,19 +1,25 @@
import { randomUUID } from "node:crypto";
-import { and, eq, isNull, sql } from "drizzle-orm";
+import { and, desc, eq, isNotNull, isNull, ne, sql } from "drizzle-orm";
import { db } from "@/db";
import { getSetting } from "@/db/queries";
import {
+ builds,
environments,
githubRepos,
secrets,
- servers,
servicePorts,
serviceReplicas,
+ serviceRevisions,
services,
} from "@/db/schema";
-import { updateGitHubDeploymentStatus } from "@/lib/github";
+import {
+ createGitHubDeployment,
+ findGitHubDeployment,
+ updateGitHubDeploymentStatus,
+} from "@/lib/github";
import { resolveRegistryImageHost } from "@/lib/registry-reference";
import {
+ getDefaultServiceHostname,
pullRequestMergeRef,
pullRequestNumberFromMergeRef,
} from "@/lib/service-revision-spec";
@@ -76,15 +82,9 @@ export function previewHostname(input: {
return `${label}.${domain}`;
}
-export async function requirePreviewDomain() {
+export async function getPreviewDomain() {
const domain = await getSetting(SETTING_KEYS.AUTO_SUBDOMAIN_DOMAIN);
- const normalized = domain?.trim().toLowerCase().replace(/\.$/, "");
- if (!normalized) {
- throw new Error(
- "Automatic Subdomain Domain must be configured before enabling preview deployments",
- );
- }
- return normalized;
+ return domain?.trim().toLowerCase().replace(/\.$/, "") || null;
}
async function ensurePreviewEnvironmentInTransaction(
@@ -145,11 +145,11 @@ export function previewPortConfiguration(input: {
serviceName: string;
serviceId: string;
pullRequestNumber: number;
- domain: string;
+ domain: string | null;
}) {
let publicHttpIndex = 0;
return input.ports.map((port) => {
- if (port.isPublic && port.protocol === "http") {
+ if (port.isPublic && port.protocol === "http" && input.domain) {
const index = publicHttpIndex++;
return {
...port,
@@ -174,29 +174,11 @@ export function previewPortConfiguration(input: {
});
}
-export async function getPreviewClone(
- baseServiceId: string,
- previewGitRef: string,
-) {
- pullRequestNumberFromMergeRef(previewGitRef);
- return db
- .select()
- .from(services)
- .where(
- and(
- eq(services.previewOfService, baseServiceId),
- eq(services.previewGitRef, previewGitRef),
- isNull(services.deletedAt),
- ),
- )
- .then((rows) => rows[0] ?? null);
-}
-
export async function createPreviewClone(input: {
baseServiceId: string;
previewGitRef: string;
}) {
- const domain = await requirePreviewDomain();
+ const domain = await getPreviewDomain();
const pullRequestNumber = pullRequestNumberFromMergeRef(input.previewGitRef);
return db.transaction(async (tx) => {
await tx.execute(
@@ -275,14 +257,8 @@ export async function createPreviewClone(input: {
.where(eq(secrets.serviceId, base.id))
.orderBy(secrets.key, secrets.id),
tx
- .select({
- serverId: serviceReplicas.serverId,
- count: serviceReplicas.count,
- status: servers.status,
- wireguardIp: servers.wireguardIp,
- })
+ .select()
.from(serviceReplicas)
- .innerJoin(servers, eq(serviceReplicas.serverId, servers.id))
.where(eq(serviceReplicas.serviceId, base.id))
.orderBy(serviceReplicas.serverId),
]);
@@ -290,25 +266,6 @@ export async function createPreviewClone(input: {
throw new Error("Preview deployments require a GitHub App service");
}
- const publicHttpPorts = ports.filter(
- (port) => port.isPublic && port.protocol === "http",
- );
- if (publicHttpPorts.length === 0) {
- throw new Error(
- "Preview deployments require at least one public HTTP port",
- );
- }
-
- const eligiblePlacement = placements.find(
- (placement) =>
- placement.count > 0 &&
- placement.status === "online" &&
- placement.wireguardIp,
- );
- if (base.placementMode === "manual" && !eligiblePlacement) {
- throw new Error("No eligible placement exists for this preview");
- }
-
const previewEnvironment = await ensurePreviewEnvironmentInTransaction(
tx,
base.projectId,
@@ -324,26 +281,29 @@ export async function createPreviewClone(input: {
const primaryDomain = configuredPorts.find(
(port) => port.isPublic && port.protocol === "http",
)?.domain;
- if (!primaryDomain)
- throw new Error("Preview domain could not be generated");
const serviceValues = {
projectId: base.projectId,
environmentId: previewEnvironment.id,
name: `${base.name} (PR #${pullRequestNumber})`,
- hostname: primaryDomain.split(".")[0],
+ hostname:
+ primaryDomain?.split(".")[0] ??
+ getDefaultServiceHostname(
+ `${base.name}-pr-${pullRequestNumber}`,
+ previewServiceId,
+ ),
image: `${resolveRegistryImageHost()}/${base.projectId}/${previewServiceId}:latest`,
sourceType: "github" as const,
githubRepoUrl: base.githubRepoUrl,
githubBranch: base.githubBranch,
githubRootDir: base.githubRootDir,
- replicas: 1,
- autoscalingEnabled: false,
- autoscalingMinReplicas: 1,
- autoscalingMaxReplicas: 1,
+ replicas: base.replicas,
+ autoscalingEnabled: base.autoscalingEnabled,
+ autoscalingMinReplicas: base.autoscalingMinReplicas,
+ autoscalingMaxReplicas: base.autoscalingMaxReplicas,
placementMode: base.placementMode,
stateful: false,
- lockedServerId: null,
+ lockedServerId: base.lockedServerId,
healthCheckCmd: base.healthCheckCmd,
healthCheckInterval: base.healthCheckInterval,
healthCheckTimeout: base.healthCheckTimeout,
@@ -352,7 +312,9 @@ export async function createPreviewClone(input: {
startCommand: base.startCommand,
resourceCpuLimit: base.resourceCpuLimit,
resourceMemoryLimitMb: base.resourceMemoryLimitMb,
- serverlessEnabled: false,
+ serverlessEnabled: base.serverlessEnabled && primaryDomain !== undefined,
+ serverlessSleepAfterSeconds: base.serverlessSleepAfterSeconds,
+ serverlessWakeTimeoutSeconds: base.serverlessWakeTimeoutSeconds,
deploymentSchedule: null,
backupEnabled: false,
backupSchedule: null,
@@ -367,18 +329,20 @@ export async function createPreviewClone(input: {
});
await tx.insert(servicePorts).values(
configuredPorts.map((port) => ({
+ ...port,
id: randomUUID(),
serviceId: previewServiceId,
- ...port,
})),
);
- if (base.placementMode === "manual" && eligiblePlacement) {
- await tx.insert(serviceReplicas).values({
- id: randomUUID(),
- serviceId: previewServiceId,
- serverId: eligiblePlacement.serverId,
- count: 1,
- });
+ if (base.placementMode === "manual" && placements.length > 0) {
+ await tx.insert(serviceReplicas).values(
+ placements.map((placement) => ({
+ id: randomUUID(),
+ serviceId: previewServiceId,
+ serverId: placement.serverId,
+ count: placement.count,
+ })),
+ );
}
if (sourceSecrets.length > 0) {
await tx.insert(secrets).values(
@@ -405,7 +369,7 @@ export async function createPreviewClone(input: {
return {
serviceId: previewServiceId,
created: true,
- primaryUrl: `https://${primaryDomain}`,
+ primaryUrl: primaryDomain ? `https://${primaryDomain}` : null,
};
});
}
@@ -414,78 +378,80 @@ export async function canDeployServiceRevision(
serviceId: string,
serviceRevisionId: string,
) {
- const service = await db
- .select({
- previewOfService: services.previewOfService,
- previewCurrentRevisionId: services.previewCurrentRevisionId,
- })
- .from(services)
- .where(and(eq(services.id, serviceId), isNull(services.deletedAt)))
- .then((rows) => rows[0]);
- if (!service) return false;
- return (
- !service.previewOfService ||
- service.previewCurrentRevisionId === serviceRevisionId
- );
-}
-
-export async function getPreviewPrimaryUrl(serviceId: string) {
- const ports = await db
- .select({
- id: servicePorts.id,
- port: servicePorts.port,
- domain: servicePorts.domain,
- })
- .from(servicePorts)
- .where(
- and(
- eq(servicePorts.serviceId, serviceId),
- eq(servicePorts.protocol, "http"),
- eq(servicePorts.isPublic, true),
- ),
- );
- const primary = ports
- .filter((port) => port.domain)
- .sort((a, b) => a.port - b.port || a.id.localeCompare(b.id))[0];
- return primary?.domain ? `https://${primary.domain}` : null;
+ return db.transaction(async (tx) => {
+ await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${serviceId}))`);
+ const service = await tx
+ .select({ previewOfService: services.previewOfService })
+ .from(services)
+ .where(and(eq(services.id, serviceId), isNull(services.deletedAt)))
+ .then((rows) => rows[0]);
+ if (!service) return false;
+ if (!service.previewOfService) return true;
+ const latest = await tx
+ .select({ id: serviceRevisions.id })
+ .from(serviceRevisions)
+ .where(eq(serviceRevisions.serviceId, serviceId))
+ .orderBy(desc(serviceRevisions.createdAt), desc(serviceRevisions.id))
+ .limit(1)
+ .then((rows) => rows[0]);
+ return latest?.id === serviceRevisionId;
+ });
}
-export async function updateCurrentPreviewGitHubStatus(input: {
+export async function updatePreviewGitHubStatus(input: {
serviceId: string;
- serviceRevisionId: string | null;
+ serviceRevisionId: string;
state: "pending" | "in_progress" | "success" | "failure" | "inactive";
description: string;
logUrl?: string;
expectedDeploymentId?: number;
}) {
- return db.transaction(async (tx) => {
+ const context = await db.transaction(async (tx) => {
await tx.execute(
sql`select pg_advisory_xact_lock(hashtext(${input.serviceId}))`,
);
- const context = await tx
- .select({
- previewCurrentRevisionId: services.previewCurrentRevisionId,
- previewGithubDeploymentId: services.previewGithubDeploymentId,
- previewOfService: services.previewOfService,
- installationId: githubRepos.installationId,
- repoFullName: githubRepos.repoFullName,
- })
+ const service = await tx
+ .select({ previewOfService: services.previewOfService })
.from(services)
- .innerJoin(
- githubRepos,
- eq(githubRepos.serviceId, services.previewOfService),
- )
.where(and(eq(services.id, input.serviceId), isNull(services.deletedAt)))
.then((rows) => rows[0]);
- if (
- !context?.previewOfService ||
- context.previewCurrentRevisionId !== input.serviceRevisionId ||
- !context.previewGithubDeploymentId ||
- (input.expectedDeploymentId !== undefined &&
- context.previewGithubDeploymentId !== input.expectedDeploymentId)
- ) {
- return false;
+ if (!service?.previewOfService) return null;
+ const latest = await tx
+ .select({ id: serviceRevisions.id })
+ .from(serviceRevisions)
+ .where(eq(serviceRevisions.serviceId, input.serviceId))
+ .orderBy(desc(serviceRevisions.createdAt), desc(serviceRevisions.id))
+ .limit(1)
+ .then((rows) => rows[0]);
+ if (latest?.id !== input.serviceRevisionId) return null;
+ const deploymentConditions = [
+ eq(builds.serviceId, input.serviceId),
+ eq(builds.serviceRevisionId, input.serviceRevisionId),
+ isNotNull(builds.githubDeploymentId),
+ ];
+ if (input.expectedDeploymentId !== undefined) {
+ deploymentConditions.push(
+ eq(builds.githubDeploymentId, input.expectedDeploymentId),
+ );
}
+ const [deployment, githubRepo] = await Promise.all([
+ tx
+ .select({ id: builds.githubDeploymentId })
+ .from(builds)
+ .where(and(...deploymentConditions))
+ .orderBy(desc(builds.createdAt), desc(builds.id))
+ .limit(1)
+ .then((rows) => rows[0]),
+ tx
+ .select({
+ installationId: githubRepos.installationId,
+ repoFullName: githubRepos.repoFullName,
+ })
+ .from(githubRepos)
+ .where(eq(githubRepos.serviceId, service.previewOfService))
+ .then((rows) => rows[0]),
+ ]);
+ if (!deployment?.id || !githubRepo) return null;
const primary = await tx
.select({
id: servicePorts.id,
@@ -506,19 +472,166 @@ export async function updateCurrentPreviewGitHubStatus(input: {
.filter((port) => port.domain)
.sort((a, b) => a.port - b.port || a.id.localeCompare(b.id))[0],
);
- await updateGitHubDeploymentStatus(
- context.installationId,
- context.repoFullName,
- context.previewGithubDeploymentId,
- input.state,
+ return {
+ ...githubRepo,
+ deploymentId: deployment.id,
+ environmentUrl: primary?.domain ? `https://${primary.domain}` : undefined,
+ };
+ });
+ if (!context) return false;
+ await updateGitHubDeploymentStatus(
+ context.installationId,
+ context.repoFullName,
+ context.deploymentId,
+ input.state,
+ {
+ description: input.description.substring(0, 140),
+ logUrl: input.logUrl,
+ environmentUrl: context.environmentUrl,
+ },
+ );
+ return true;
+}
+
+export async function createPreviewGitHubDeployment(input: {
+ serviceId: string;
+ serviceRevisionId: string;
+ commitSha: string;
+}) {
+ const preview = await db
+ .select({
+ previewOfService: services.previewOfService,
+ previewGitRef: services.previewGitRef,
+ })
+ .from(services)
+ .where(and(eq(services.id, input.serviceId), isNull(services.deletedAt)))
+ .then((rows) => rows[0]);
+ if (!preview?.previewOfService || !preview.previewGitRef) return null;
+ const existing = await db
+ .select({ id: builds.githubDeploymentId })
+ .from(builds)
+ .where(
+ and(
+ eq(builds.serviceId, input.serviceId),
+ eq(builds.serviceRevisionId, input.serviceRevisionId),
+ isNotNull(builds.githubDeploymentId),
+ ),
+ )
+ .limit(1)
+ .then((rows) => rows[0]?.id ?? null);
+ if (existing) return existing;
+ const githubRepo = await db
+ .select()
+ .from(githubRepos)
+ .where(eq(githubRepos.serviceId, preview.previewOfService))
+ .then((rows) => rows[0]);
+ if (!githubRepo) return null;
+ const pullRequestNumber = pullRequestNumberFromMergeRef(
+ preview.previewGitRef,
+ );
+ const environment = `preview/pr-${pullRequestNumber}-${preview.previewOfService.slice(0, 8)}`;
+ const payload = {
+ baseServiceId: preview.previewOfService,
+ previewServiceId: input.serviceId,
+ previewGitRef: preview.previewGitRef,
+ serviceRevisionId: input.serviceRevisionId,
+ };
+ const deploymentId =
+ (await findGitHubDeployment(
+ githubRepo.installationId,
+ githubRepo.repoFullName,
+ input.commitSha,
+ environment,
+ payload,
+ )) ??
+ (await createGitHubDeployment(
+ githubRepo.installationId,
+ githubRepo.repoFullName,
+ input.commitSha,
+ environment,
+ `Preview PR #${pullRequestNumber}`,
{
- description: input.description.substring(0, 140),
- logUrl: input.logUrl,
- environmentUrl: primary?.domain
- ? `https://${primary.domain}`
- : undefined,
+ transientEnvironment: true,
+ productionEnvironment: false,
+ payload,
},
+ ));
+ const updated = await db
+ .update(builds)
+ .set({ githubDeploymentId: deploymentId })
+ .where(
+ and(
+ eq(builds.serviceId, input.serviceId),
+ eq(builds.serviceRevisionId, input.serviceRevisionId),
+ isNull(builds.githubDeploymentId),
+ ),
+ )
+ .returning({ id: builds.id });
+ const current =
+ updated.length > 0 &&
+ (await updatePreviewGitHubStatus({
+ serviceId: input.serviceId,
+ serviceRevisionId: input.serviceRevisionId,
+ expectedDeploymentId: deploymentId,
+ state: "pending",
+ description: "Preview build queued",
+ }));
+ if (!current) {
+ await updateGitHubDeploymentStatus(
+ githubRepo.installationId,
+ githubRepo.repoFullName,
+ deploymentId,
+ "inactive",
+ { description: "Preview was superseded or removed" },
);
- return true;
- });
+ }
+ return deploymentId;
+}
+
+export async function inactivatePreviewGitHubDeployments(input: {
+ serviceId: string;
+ description: string;
+ excludeServiceRevisionId?: string;
+}) {
+ const service = await db
+ .select({ previewOfService: services.previewOfService })
+ .from(services)
+ .where(eq(services.id, input.serviceId))
+ .then((rows) => rows[0]);
+ if (!service?.previewOfService) return 0;
+ const githubRepo = await db
+ .select()
+ .from(githubRepos)
+ .where(eq(githubRepos.serviceId, service.previewOfService))
+ .then((rows) => rows[0]);
+ if (!githubRepo) return 0;
+ const conditions = [
+ eq(builds.serviceId, input.serviceId),
+ isNotNull(builds.githubDeploymentId),
+ ];
+ if (input.excludeServiceRevisionId) {
+ conditions.push(
+ ne(builds.serviceRevisionId, input.excludeServiceRevisionId),
+ );
+ }
+ const deploymentIds = await db
+ .selectDistinct({ id: builds.githubDeploymentId })
+ .from(builds)
+ .where(and(...conditions));
+ await Promise.all(
+ deploymentIds.flatMap(({ id }) =>
+ id
+ ? [
+ updateGitHubDeploymentStatus(
+ githubRepo.installationId,
+ githubRepo.repoFullName,
+ id,
+ "inactive",
+ { description: input.description.substring(0, 140) },
+ ),
+ ]
+ : [],
+ ),
+ );
+ return deploymentIds.length;
}
diff --git a/web/lib/preview-lifecycle.ts b/web/lib/preview-lifecycle.ts
index 5215e95f..f7fc10e7 100644
--- a/web/lib/preview-lifecycle.ts
+++ b/web/lib/preview-lifecycle.ts
@@ -9,9 +9,9 @@ import {
services,
} from "@/db/schema";
import { markDeploymentRemoved } from "@/lib/deployment-status";
-import { updateGitHubDeploymentStatus } from "@/lib/github";
import { inngest } from "@/lib/inngest/client";
import { inngestEvents } from "@/lib/inngest/events";
+import { inactivatePreviewGitHubDeployments } from "@/lib/preview-deployments";
import {
cleanupRegistryArtifactsForService,
prepareRegistryArtifactCleanup,
@@ -30,8 +30,6 @@ const activeBuildStatuses = [
"pushing",
] as const;
-type GitHubDeploymentCleanup = "report" | "defer" | "skip";
-
async function cancelBuildRows(serviceId: string, serviceRevisionId?: string) {
const conditions = [
eq(builds.serviceId, serviceId),
@@ -170,7 +168,7 @@ export async function deletePreviewService(
baseServiceId: string,
previewGitRef: string,
reason = "removed",
- options: { githubDeploymentCleanup?: GitHubDeploymentCleanup } = {},
+ options: { reportGitHubDeployment?: boolean } = {},
) {
pullRequestNumberFromMergeRef(previewGitRef);
const claimed = await db.transaction(async (tx) => {
@@ -181,12 +179,8 @@ export async function deletePreviewService(
sql`select pg_advisory_xact_lock(hashtext(${baseServiceId}), hashtext(${previewGitRef}))`,
);
const context = await tx
- .select({ service: services, githubRepo: githubRepos })
+ .select({ service: services })
.from(services)
- .leftJoin(
- githubRepos,
- eq(githubRepos.serviceId, services.previewOfService),
- )
.where(
and(
eq(services.previewOfService, baseServiceId),
@@ -203,10 +197,6 @@ export async function deletePreviewService(
"Preview deletion deferred while registry manifest work is processing",
);
}
- await tx
- .update(services)
- .set({ previewCurrentRevisionId: null })
- .where(eq(services.id, context.service.id));
await tx
.update(services)
.set({
@@ -254,29 +244,20 @@ export async function deletePreviewService(
enqueueReconcileForAllOnlineServers("preview_deleted", tx),
);
await cleanupRegistryArtifactsForService(claimed.service.id);
- if (
- (options.githubDeploymentCleanup ?? "report") === "report" &&
- claimed.githubRepo &&
- claimed.service.previewGithubDeploymentId
- ) {
- await updateGitHubDeploymentStatus(
- claimed.githubRepo.installationId,
- claimed.githubRepo.repoFullName,
- claimed.service.previewGithubDeploymentId,
- "inactive",
- { description: `Preview removed: ${reason}`.substring(0, 140) },
- );
- }
- if ((options.githubDeploymentCleanup ?? "report") !== "defer") {
- await db.delete(services).where(eq(services.id, claimed.service.id));
+ if (options.reportGitHubDeployment !== false) {
+ await inactivatePreviewGitHubDeployments({
+ serviceId: claimed.service.id,
+ description: `Preview removed: ${reason}`,
+ });
}
+ await db.delete(services).where(eq(services.id, claimed.service.id));
return claimed;
}
export async function deletePreviewsForBaseService(
baseServiceId: string,
reason: string,
- options: { githubDeploymentCleanup?: GitHubDeploymentCleanup } = {},
+ options: { reportGitHubDeployment?: boolean } = {},
) {
const previews = await db
.select({ service: services })
@@ -294,7 +275,6 @@ export async function deletePreviewsForGitHubInstallation(
reason: string,
options: {
removeRepositoryLinks?: boolean;
- githubDeploymentCleanup?: GitHubDeploymentCleanup;
} = {},
) {
const baseServiceIds = await db.transaction(async (tx) => {
@@ -327,7 +307,7 @@ export async function deletePreviewsForGitHubInstallation(
});
for (const baseServiceId of baseServiceIds) {
await deletePreviewsForBaseService(baseServiceId, reason, {
- githubDeploymentCleanup: options.githubDeploymentCleanup ?? "skip",
+ reportGitHubDeployment: false,
});
}
}
diff --git a/web/lib/service-revision-changes.ts b/web/lib/service-revision-changes.ts
index af27ffdd..52a6fb0a 100644
--- a/web/lib/service-revision-changes.ts
+++ b/web/lib/service-revision-changes.ts
@@ -6,30 +6,7 @@ import type {
ServiceRevisionPort,
ServiceRevisionSpec,
} from "@/lib/service-revision-spec";
-import {
- gitBranchRef,
- isSupportedGitRef,
- validateServiceRevisionPorts,
-} from "@/lib/service-revision-spec";
-
-const legacySourceSchema = z.discriminatedUnion("type", [
- z.strictObject({ type: z.literal("image"), image: z.string() }),
- z.strictObject({
- type: z.literal("github"),
- repository: z.url(),
- repositoryId: z.number().int().positive().nullable(),
- branch: z.string().min(1),
- commitSha: z.string().regex(/^[0-9a-f]{40}$/),
- rootDir: z.string().min(1).nullable(),
- authentication: z.discriminatedUnion("type", [
- z.strictObject({ type: z.literal("anonymous") }),
- z.strictObject({
- type: z.literal("github_app"),
- installationId: z.number().int().positive(),
- }),
- ]),
- }),
-]);
+import { validateServiceRevisionPorts } from "@/lib/service-revision-spec";
const serviceRevisionSpecFields = {
image: z.string(),
@@ -37,10 +14,9 @@ const serviceRevisionSpecFields = {
z.strictObject({ type: z.literal("image"), image: z.string() }),
z.strictObject({
type: z.literal("github"),
- repository: z.url(),
+ repository: z.string().url(),
repositoryId: z.number().int().positive().nullable(),
branch: z.string().min(1),
- gitRef: z.string().refine(isSupportedGitRef, "Unsupported Git ref"),
commitSha: z.string().regex(/^[0-9a-f]{40}$/),
rootDir: z.string().min(1).nullable(),
authentication: z.discriminatedUnion("type", [
@@ -111,23 +87,10 @@ const serviceRevisionSpecFields = {
const serviceRevisionSpecV2Schema = z.strictObject({
schemaVersion: z.literal(2),
...serviceRevisionSpecFields,
- source: legacySourceSchema,
-});
-const serviceRevisionSpecV3Schema = z.strictObject({
- schemaVersion: z.literal(3),
- placement: z.discriminatedUnion("mode", [
- z.strictObject({ mode: z.literal("manual") }),
- z.strictObject({
- mode: z.literal("automatic"),
- replicas: z.number().int().min(1).max(32),
- }),
- ]),
- ...serviceRevisionSpecFields,
- source: legacySourceSchema,
});
const serviceRevisionSpecSchema = z
.strictObject({
- schemaVersion: z.literal(4),
+ schemaVersion: z.literal(3),
placement: z.discriminatedUnion("mode", [
z.strictObject({ mode: z.literal("manual") }),
z.strictObject({
@@ -216,35 +179,12 @@ export function parseServiceRevisionSpec(value: unknown): ServiceRevisionSpec {
const legacy = serviceRevisionSpecV2Schema.parse(value);
const specification = {
...legacy,
- schemaVersion: 4 as const,
- source:
- legacy.source.type === "github"
- ? {
- ...legacy.source,
- gitRef: gitBranchRef(legacy.source.branch),
- }
- : legacy.source,
+ schemaVersion: 3 as const,
placement: { mode: "manual" as const },
};
validateServiceRevisionPorts(specification.ports);
return specification;
}
- if (version === 3) {
- const legacy = serviceRevisionSpecV3Schema.parse(value);
- const specification: ServiceRevisionSpec = {
- ...legacy,
- schemaVersion: 4,
- source:
- legacy.source.type === "github"
- ? {
- ...legacy.source,
- gitRef: gitBranchRef(legacy.source.branch),
- }
- : legacy.source,
- };
- validateServiceRevisionPorts(specification.ports);
- return specification;
- }
const specification = serviceRevisionSpecSchema.parse(
value,
) as ServiceRevisionSpec;
@@ -305,7 +245,6 @@ export function diffServiceRevisionSpecs(
current.source.repository,
);
add("GitHub branch", previous.source.branch, current.source.branch);
- add("Git ref", previous.source.gitRef, current.source.gitRef);
add("GitHub commit", previous.source.commitSha, current.source.commitSha);
add(
"GitHub root directory",
diff --git a/web/lib/service-revision-spec.ts b/web/lib/service-revision-spec.ts
index e0bad318..6544ea93 100644
--- a/web/lib/service-revision-spec.ts
+++ b/web/lib/service-revision-spec.ts
@@ -1,32 +1,4 @@
-export const SERVICE_REVISION_SCHEMA_VERSION = 4 as const;
-
-export function isSupportedGitRef(ref: string): boolean {
- if (/^refs\/pull\/[1-9]\d*\/merge$/.test(ref)) return true;
- if (!ref.startsWith("refs/heads/")) return false;
- const branch = ref.slice("refs/heads/".length);
- const parts = branch.split("/");
- const invalidCharacter = [...branch].some((character) => {
- const code = character.charCodeAt(0);
- return code <= 0x20 || code === 0x7f || "~^:?*[\\".includes(character);
- });
- return Boolean(
- branch &&
- branch !== "@" &&
- !branch.endsWith(".") &&
- !branch.includes("..") &&
- !branch.includes("@{") &&
- !invalidCharacter &&
- parts.every(
- (part) => part && !part.startsWith(".") && !part.endsWith(".lock"),
- ),
- );
-}
-
-export function gitBranchRef(branch: string): string {
- const ref = `refs/heads/${branch.trim()}`;
- if (!isSupportedGitRef(ref)) throw new Error("Invalid Git branch");
- return ref;
-}
+export const SERVICE_REVISION_SCHEMA_VERSION = 3 as const;
export function pullRequestMergeRef(pullRequestNumber: number): string {
if (!Number.isSafeInteger(pullRequestNumber) || pullRequestNumber <= 0) {
@@ -191,7 +163,6 @@ export type ServiceRevisionSource =
repository: string;
repositoryId: number | null;
branch: string;
- gitRef: string;
commitSha: string;
rootDir: string | null;
authentication:
@@ -286,12 +257,6 @@ function validateServiceRevisionSpec(
specification: ServiceRevisionSpec,
allowNoPlacements: boolean,
) {
- if (
- specification.source.type === "github" &&
- !isSupportedGitRef(specification.source.gitRef)
- ) {
- throw new Error("Unsupported Git ref");
- }
validateServiceRevisionPorts(specification.ports);
const totalReplicas = getServiceRevisionTotalReplicas(specification);
diff --git a/web/lib/service-revisions.ts b/web/lib/service-revisions.ts
index 246671d7..1c627083 100644
--- a/web/lib/service-revisions.ts
+++ b/web/lib/service-revisions.ts
@@ -47,7 +47,6 @@ function assertMatchingGitHubBuildRevision(
commitSha: string;
expectedRepository: string;
expectedBranch: string;
- gitRef: string;
},
) {
if (revision.serviceId !== input.serviceId) {
@@ -60,7 +59,6 @@ function assertMatchingGitHubBuildRevision(
specification.source.type !== "github" ||
specification.source.repository !== input.expectedRepository ||
specification.source.branch !== input.expectedBranch ||
- specification.source.gitRef !== input.gitRef ||
specification.source.commitSha !== input.commitSha.toLowerCase()
) {
throw new Error("Service revision idempotency conflict");
@@ -187,7 +185,6 @@ export async function createGitHubBuildServiceRevision(input: {
commitSha: string;
expectedRepository: string;
expectedBranch: string;
- gitRef: string;
actor: ServiceRevisionActor | null;
}) {
return db.transaction(async (tx) => {
@@ -237,7 +234,6 @@ export async function createGitHubBuildServiceRevision(input: {
repository: currentSource.repository,
repositoryId: repo?.repoId ?? null,
branch: currentSource.branch,
- gitRef: input.gitRef,
commitSha: input.commitSha,
rootDir: currentSource.rootDir?.trim() || null,
authentication: repo
@@ -381,10 +377,7 @@ export async function cloneActiveRevisionAndQueueSystemRollout(
return db.transaction(async (tx) => {
await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${serviceId}))`);
const activeService = await tx
- .select({
- id: services.id,
- previewOfService: services.previewOfService,
- })
+ .select({ id: services.id })
.from(services)
.where(and(eq(services.id, serviceId), isNull(services.deletedAt)))
.then((rows) => rows[0]);
@@ -429,12 +422,6 @@ export async function cloneActiveRevisionAndQueueSystemRollout(
specification: active.specification,
actor: { type: "system" },
});
- if (activeService.previewOfService) {
- await tx
- .update(services)
- .set({ previewCurrentRevisionId: revisionId })
- .where(eq(services.id, serviceId));
- }
const rolloutId = randomUUID();
await tx.insert(rollouts).values({
id: rolloutId,
@@ -598,7 +585,7 @@ export async function createRolloutForServiceRevision(
) {
return db.transaction(async (tx) => {
await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${serviceId}))`);
- const [revision, activeService] = await Promise.all([
+ const [revision, activeService, latestRevision] = await Promise.all([
tx
.select()
.from(serviceRevisions)
@@ -613,11 +600,17 @@ export async function createRolloutForServiceRevision(
.select({
id: services.id,
previewOfService: services.previewOfService,
- previewCurrentRevisionId: services.previewCurrentRevisionId,
})
.from(services)
.where(and(eq(services.id, serviceId), isNull(services.deletedAt)))
.then((rows) => rows[0]),
+ tx
+ .select({ id: serviceRevisions.id })
+ .from(serviceRevisions)
+ .where(eq(serviceRevisions.serviceId, serviceId))
+ .orderBy(desc(serviceRevisions.createdAt), desc(serviceRevisions.id))
+ .limit(1)
+ .then((rows) => rows[0]),
]);
if (!revision) throw new Error("Service revision not found");
if (!activeService) {
@@ -625,7 +618,7 @@ export async function createRolloutForServiceRevision(
}
if (
activeService.previewOfService &&
- activeService.previewCurrentRevisionId !== serviceRevisionId
+ latestRevision?.id !== serviceRevisionId
) {
return { rolloutId: null, revision, created: false };
}
diff --git a/web/lib/trigger-build.ts b/web/lib/trigger-build.ts
index 8a652a82..0f81ad93 100644
--- a/web/lib/trigger-build.ts
+++ b/web/lib/trigger-build.ts
@@ -12,7 +12,6 @@ import {
import { resolveRegistryImageHost } from "@/lib/registry-reference";
import type { ServiceRevisionActor } from "@/lib/service-revision-actor";
import { parseServiceRevisionSpec } from "@/lib/service-revision-changes";
-import { gitBranchRef, isSupportedGitRef } from "@/lib/service-revision-spec";
import {
cloneGitHubBuildServiceRevision,
createGitHubBuildServiceRevision,
@@ -32,7 +31,6 @@ type ResolvedBuildInput = {
gitRef?: string;
githubDeploymentId?: number;
idempotencyKey?: string;
- beforeDispatch?: (serviceRevisionId: string) => Promise;
};
function deterministicRevisionId(key: string): string {
@@ -103,14 +101,19 @@ async function queueResolvedBuild(
? canonicalGitHubRepository(input.expectedRepository)
: source.repository;
const expectedBranch = input.expectedBranch ?? source.branch;
- const gitRef = input.gitRef ?? gitBranchRef(expectedBranch);
- if (!isSupportedGitRef(gitRef)) throw new Error("Unsupported Git ref");
if (
source.repository !== expectedRepository ||
source.branch !== expectedBranch
) {
throw new Error("GitHub source changed before the build was queued");
}
+ if (
+ service.previewOfService
+ ? !service.previewGitRef || input.gitRef !== service.previewGitRef
+ : input.gitRef !== undefined
+ ) {
+ throw new Error("Build Git ref does not match the service");
+ }
const registryHost = resolveRegistryImageHost();
const serviceRevisionId = input.idempotencyKey
@@ -125,10 +128,8 @@ async function queueResolvedBuild(
commitSha,
expectedRepository,
expectedBranch,
- gitRef,
actor: input.actor,
});
- await input.beforeDispatch?.(serviceRevisionId);
const buildRequestId = randomUUID();
await sendBuildTrigger(
@@ -140,7 +141,7 @@ async function queueResolvedBuild(
commitSha,
commitMessage: input.commitMessage.substring(0, 500),
branch: expectedBranch,
- gitRef,
+ gitRef: input.gitRef,
author: input.author,
actor: input.actor,
githubDeploymentId: input.githubDeploymentId,
@@ -176,7 +177,6 @@ export async function requeueBuildRevisionInternal(input: {
commitSha: specification.source.commitSha,
commitMessage: input.commitMessage.substring(0, 500),
branch: specification.source.branch,
- gitRef: specification.source.gitRef,
author: input.author,
actor: input.actor,
});
diff --git a/web/tests/autoplacement.test.ts b/web/tests/autoplacement.test.ts
index 7b14c361..8c46c676 100644
--- a/web/tests/autoplacement.test.ts
+++ b/web/tests/autoplacement.test.ts
@@ -78,7 +78,7 @@ describe("automatic placement eligibility diagnostics", () => {
});
describe("persisted revision compatibility", () => {
- it("normalizes v2 revisions to the current manual placement intent", () => {
+ it("normalizes v2 revisions to v3 manual intent", () => {
const parsed = parseServiceRevisionSpec({
schemaVersion: 2,
image: "nginx",
@@ -98,7 +98,7 @@ describe("persisted revision compatibility", () => {
secrets: [],
volumes: [],
});
- expect(parsed.schemaVersion).toBe(4);
+ expect(parsed.schemaVersion).toBe(3);
expect(parsed.placement).toEqual({ mode: "manual" });
});
});
diff --git a/web/tests/build-assignment.test.ts b/web/tests/build-assignment.test.ts
index 2b70680d..60530a99 100644
--- a/web/tests/build-assignment.test.ts
+++ b/web/tests/build-assignment.test.ts
@@ -39,7 +39,7 @@ function specification(
overrides: Partial = {},
): ServiceRevisionSpec {
return {
- schemaVersion: 4,
+ schemaVersion: 3,
placement: { mode: "manual" },
image: "registry/app:revision-1",
source: { type: "image", image: "registry/app:revision-1" },
diff --git a/web/tests/build-claim-route.test.ts b/web/tests/build-claim-route.test.ts
index 64f7455a..73e6c2f4 100644
--- a/web/tests/build-claim-route.test.ts
+++ b/web/tests/build-claim-route.test.ts
@@ -104,21 +104,26 @@ describe("agent build claim", () => {
expect(mocks.send).not.toHaveBeenCalled();
});
- it("returns the exact snapshotted Git ref", async () => {
+ it("returns the preview service's exact pull request ref", async () => {
mocks.updateResults.push([build]);
mocks.selectResults.push(
- [{ id: "service-1", projectId: "project-1" }],
+ [
+ {
+ id: "service-1",
+ projectId: "project-1",
+ previewGitRef: "refs/pull/42/merge",
+ },
+ ],
[
{
specification: {
- schemaVersion: 4,
+ schemaVersion: 3,
image: "registry.example.com/project/service:revision-1",
source: {
type: "github",
repository: "https://github.com/acme/app",
repositoryId: null,
branch: "main",
- gitRef: "refs/pull/42/merge",
commitSha: build.commitSha,
rootDir: null,
authentication: { type: "anonymous" },
diff --git a/web/tests/build-revision-source.test.ts b/web/tests/build-revision-source.test.ts
index dfc1eac5..c232878a 100644
--- a/web/tests/build-revision-source.test.ts
+++ b/web/tests/build-revision-source.test.ts
@@ -6,7 +6,6 @@ const baseSource = {
repository: "https://github.com/techulus/cloud",
repositoryId: 123,
branch: "main",
- gitRef: "refs/heads/main",
commitSha: "0123456789abcdef0123456789abcdef01234567",
rootDir: "web",
};
diff --git a/web/tests/build-status-route.test.ts b/web/tests/build-status-route.test.ts
index a03d0e8a..414afb2f 100644
--- a/web/tests/build-status-route.test.ts
+++ b/web/tests/build-status-route.test.ts
@@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => {
from: vi.fn(() => query),
innerJoin: vi.fn(() => query),
where: vi.fn(() => query),
+ orderBy: vi.fn(() => query),
limit: vi.fn(() => query),
// oxlint-disable-next-line unicorn/no-thenable -- Drizzle query builders are awaitable.
then: (
@@ -49,7 +50,7 @@ const mocks = vi.hoisted(() => {
enqueueWork: vi.fn(),
send: vi.fn(),
updateGitHubDeploymentStatus: vi.fn(),
- updateCurrentPreviewGitHubStatus: vi.fn(),
+ updatePreviewGitHubStatus: vi.fn(),
notify: vi.fn(),
createBuildCompleted: vi.fn((data, options) => ({
name: "build/completed",
@@ -68,7 +69,7 @@ vi.mock("@/lib/github", () => ({
updateGitHubDeploymentStatus: mocks.updateGitHubDeploymentStatus,
}));
vi.mock("@/lib/preview-deployments", () => ({
- updateCurrentPreviewGitHubStatus: mocks.updateCurrentPreviewGitHubStatus,
+ updatePreviewGitHubStatus: mocks.updatePreviewGitHubStatus,
}));
vi.mock("@/lib/work-queue", () => ({ enqueueWork: mocks.enqueueWork }));
vi.mock("@/lib/inngest/client", () => ({ inngest: { send: mocks.send } }));
@@ -162,7 +163,7 @@ describe("agent build status transitions", () => {
mocks.enqueueWork.mockResolvedValue(undefined);
mocks.send.mockResolvedValue(undefined);
mocks.updateGitHubDeploymentStatus.mockResolvedValue(undefined);
- mocks.updateCurrentPreviewGitHubStatus.mockResolvedValue(true);
+ mocks.updatePreviewGitHubStatus.mockResolvedValue(true);
mocks.notify.mockResolvedValue(undefined);
});
@@ -322,14 +323,14 @@ describe("agent build status transitions", () => {
{
id: "service-1",
previewOfService: "base-service",
- previewCurrentRevisionId: "revision-1",
},
],
+ [{ id: "revision-1" }],
);
mocks.updateResults.push([completedBuild]);
expect((await post("completed")).status).toBe(200);
- expect(mocks.updateCurrentPreviewGitHubStatus).toHaveBeenCalledWith({
+ expect(mocks.updatePreviewGitHubStatus).toHaveBeenCalledWith({
serviceId: "service-1",
serviceRevisionId: "revision-1",
expectedDeploymentId: 456,
diff --git a/web/tests/build-trigger-workflow.test.ts b/web/tests/build-trigger-workflow.test.ts
index d434112c..713ce665 100644
--- a/web/tests/build-trigger-workflow.test.ts
+++ b/web/tests/build-trigger-workflow.test.ts
@@ -5,35 +5,75 @@ const mocks = vi.hoisted(() => ({
onConflictDoNothing: vi.fn(),
returning: vi.fn(),
revisionRows: [] as unknown[],
+ transactionSelectResults: [] as unknown[][],
+ execute: vi.fn(),
getTargetPlatformsForRevision: vi.fn(),
selectBuildServerForRevision: vi.fn(),
enqueueWork: vi.fn(),
+ createPreviewGitHubDeployment: vi.fn(),
send: vi.fn(),
createBuildStarted: vi.fn((data) => ({ name: "build/started", data })),
}));
vi.mock("@/db", () => ({
- db: {
- insert: vi.fn(() => ({ values: mocks.values })),
- select: vi.fn(() => ({
- from: vi.fn(() => ({
- where: vi.fn(() => Promise.resolve(mocks.revisionRows)),
- })),
- })),
- },
+ db: (() => {
+ function query(rows: unknown[]) {
+ const query = {
+ from: vi.fn(() => query),
+ innerJoin: vi.fn(() => query),
+ where: vi.fn(() => query),
+ orderBy: vi.fn(() => query),
+ limit: vi.fn(() => query),
+ // oxlint-disable-next-line unicorn/no-thenable -- Drizzle query builders are awaitable.
+ then: (
+ resolve: (rows: unknown[]) => unknown,
+ reject?: (reason: unknown) => unknown,
+ ) => Promise.resolve(rows).then(resolve, reject),
+ };
+ return query;
+ }
+ const tx = {
+ execute: mocks.execute,
+ insert: vi.fn(() => ({ values: mocks.values })),
+ select: vi.fn(() => query(mocks.transactionSelectResults.shift() ?? [])),
+ };
+ return {
+ select: vi.fn(() => query(mocks.revisionRows)),
+ transaction: vi.fn((operation: (transaction: typeof tx) => unknown) =>
+ operation(tx),
+ ),
+ };
+ })(),
}));
vi.mock("@/db/schema", () => ({
- builds: { id: "id" },
+ builds: {
+ id: "id",
+ serviceId: "service_id",
+ serviceRevisionId: "service_revision_id",
+ commitSha: "commit_sha",
+ branch: "branch",
+ targetPlatform: "target_platform",
+ buildGroupId: "build_group_id",
+ },
+ services: {
+ id: "id",
+ deletedAt: "deleted_at",
+ previewGitRef: "preview_git_ref",
+ },
serviceRevisions: {
id: "id",
serviceId: "service_id",
specification: "specification",
+ createdAt: "created_at",
},
}));
vi.mock("@/lib/build-assignment", () => ({
getTargetPlatformsForRevision: mocks.getTargetPlatformsForRevision,
selectBuildServerForRevision: mocks.selectBuildServerForRevision,
}));
+vi.mock("@/lib/preview-deployments", () => ({
+ createPreviewGitHubDeployment: mocks.createPreviewGitHubDeployment,
+}));
vi.mock("@/lib/work-queue", () => ({ enqueueWork: mocks.enqueueWork }));
vi.mock("@/lib/inngest/client", () => ({
inngest: {
@@ -54,7 +94,7 @@ import { buildTriggerWorkflow } from "@/lib/inngest/functions/build-trigger-work
const exactSha = "0123456789ABCDEF0123456789ABCDEF01234567";
-function invoke(commitSha: string) {
+function invoke(commitSha: string, gitRef?: string) {
const step = {
run: vi.fn(async (_name: string, operation: () => Promise) =>
operation(),
@@ -70,11 +110,11 @@ function invoke(commitSha: string) {
serviceId: "service-1",
serviceRevisionId: "revision-1",
buildRequestId: "request-1",
- trigger: "manual",
+ trigger: gitRef ? "preview" : "manual",
commitSha,
commitMessage: "Exact source commit",
branch: "main",
- gitRef: "refs/heads/main",
+ gitRef,
author: "octocat",
actor: { type: "system" },
},
@@ -87,7 +127,9 @@ describe("build trigger fan-out", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.revisionRows.length = 0;
+ mocks.transactionSelectResults.length = 0;
mocks.revisionRows.push({
+ previewGitRef: null,
specification: {
schemaVersion: 2,
image: "registry.example.com/service-1:revision-1",
@@ -160,6 +202,24 @@ describe("build trigger fan-out", () => {
expect(mocks.enqueueWork).toHaveBeenCalledTimes(2);
});
+ it("does not persist work for a superseded preview revision", async () => {
+ const previewGitRef = "refs/pull/42/merge";
+ (mocks.revisionRows[0] as Record).previewGitRef =
+ previewGitRef;
+ mocks.transactionSelectResults.push(
+ [{ id: "service-1" }],
+ [{ id: "newer-revision" }],
+ );
+
+ await expect(invoke(exactSha, previewGitRef)).resolves.toMatchObject({
+ status: "cancelled",
+ reason: "superseded_preview_revision",
+ });
+ expect(mocks.values).not.toHaveBeenCalled();
+ expect(mocks.enqueueWork).not.toHaveBeenCalled();
+ expect(mocks.createPreviewGitHubDeployment).not.toHaveBeenCalled();
+ });
+
it("rejects a moving ref before creating any platform build", async () => {
await expect(invoke("HEAD")).rejects.toThrow(
"Build fan-out requires a full 40-character commit SHA",
diff --git a/web/tests/build-workflow.test.ts b/web/tests/build-workflow.test.ts
index 81fdd7da..d2a4a174 100644
--- a/web/tests/build-workflow.test.ts
+++ b/web/tests/build-workflow.test.ts
@@ -18,7 +18,7 @@ const mocks = vi.hoisted(() => {
queryResults,
select: vi.fn(() => query(queryResults.shift() ?? [])),
deployServiceRevisionInternal: vi.fn(),
- updateCurrentPreviewGitHubStatus: vi.fn(),
+ updatePreviewGitHubStatus: vi.fn(),
};
});
@@ -27,7 +27,7 @@ vi.mock("@/lib/deploy-service", () => ({
deployServiceRevisionInternal: mocks.deployServiceRevisionInternal,
}));
vi.mock("@/lib/preview-deployments", () => ({
- updateCurrentPreviewGitHubStatus: mocks.updateCurrentPreviewGitHubStatus,
+ updatePreviewGitHubStatus: mocks.updatePreviewGitHubStatus,
}));
vi.mock("@/lib/inngest/client", () => ({
inngest: {
@@ -126,7 +126,7 @@ describe("revision-first build completion", () => {
rolloutId: "rollout-1",
created: true,
});
- mocks.updateCurrentPreviewGitHubStatus.mockResolvedValue(false);
+ mocks.updatePreviewGitHubStatus.mockResolvedValue(false);
});
it("deploys each out-of-order build using its own immutable revision", async () => {
@@ -167,7 +167,7 @@ describe("revision-first build completion", () => {
buildGroupId: "group-failed",
});
expect(mocks.deployServiceRevisionInternal).not.toHaveBeenCalled();
- expect(mocks.updateCurrentPreviewGitHubStatus).toHaveBeenCalledWith({
+ expect(mocks.updatePreviewGitHubStatus).toHaveBeenCalledWith({
serviceId: "service-1",
serviceRevisionId: "revision-failed",
state: "failure",
diff --git a/web/tests/github-webhook.test.ts b/web/tests/github-webhook.test.ts
index 5f8a7a02..d3f06ce2 100644
--- a/web/tests/github-webhook.test.ts
+++ b/web/tests/github-webhook.test.ts
@@ -457,7 +457,7 @@ describe("GitHub pull request webhook", () => {
const response = await POST(pullRequest("opened"));
expect(response.status).toBe(200);
- expect(await response.json()).toMatchObject({ ok: true, queued: 2 });
+ expect(await response.json()).toMatchObject({ ok: true, queued: 4 });
expect(mocks.send).toHaveBeenCalledWith([
expect.objectContaining({
name: "preview/sync-requested",
@@ -473,25 +473,30 @@ describe("GitHub pull request webhook", () => {
previewGitRef: "refs/pull/42/merge",
},
}),
+ expect.objectContaining({
+ name: "preview/close-requested",
+ data: expect.objectContaining({
+ baseServiceId: "service-disabled",
+ previewGitRef: "refs/pull/42/merge",
+ }),
+ }),
+ expect.objectContaining({
+ name: "preview/close-requested",
+ data: expect.objectContaining({
+ baseServiceId: "service-stateful",
+ previewGitRef: "refs/pull/42/merge",
+ }),
+ }),
]);
});
it("closes an existing preview when the pull request changes base branch", async () => {
- mocks.queryResults.push(
- [
- linkedService({
- serviceId: "service-a",
- previewDeploymentsEnabled: true,
- }),
- ],
- [
- linkedService({
- serviceId: "preview-42",
- previewOfService: "service-a",
- previewGitRef: "refs/pull/42/merge",
- }).service,
- ],
- );
+ mocks.queryResults.push([
+ linkedService({
+ serviceId: "service-a",
+ previewDeploymentsEnabled: true,
+ }),
+ ]);
const response = await POST(
pullRequest("edited", { baseBranch: "release" }),
@@ -514,49 +519,54 @@ describe("GitHub pull request webhook", () => {
it.each([
["draft", { draft: true }],
["fork", { headRepoId: 999 }],
- ])("does not deploy a %s pull request", async (_case, options) => {
- mocks.queryResults.push([
- linkedService({
- serviceId: "service-a",
- previewDeploymentsEnabled: true,
- }),
- ]);
-
- const response = await POST(pullRequest("opened", options));
-
- expect(response.status).toBe(200);
- expect(mocks.send).not.toHaveBeenCalled();
- });
+ ])(
+ "queues teardown instead of deploying a %s pull request",
+ async (_case, options) => {
+ mocks.queryResults.push([
+ linkedService({
+ serviceId: "service-a",
+ previewDeploymentsEnabled: true,
+ }),
+ ]);
+
+ const response = await POST(pullRequest("opened", options));
+
+ expect(response.status).toBe(200);
+ expect(mocks.send).toHaveBeenCalledWith([
+ expect.objectContaining({
+ name: "preview/close-requested",
+ data: expect.objectContaining({
+ baseServiceId: "service-a",
+ previewGitRef: "refs/pull/42/merge",
+ }),
+ }),
+ ]);
+ },
+ );
it.each([
["closed", true, "pull_request_merged"],
["closed", false, "pull_request_closed"],
["converted_to_draft", false, "converted_to_draft"],
- ])("queues teardown for %s", async (action, merged, reason) => {
- mocks.queryResults.push(
- [linkedService({ serviceId: "service-a" })],
- [
- linkedService({
- serviceId: "preview-42",
- previewOfService: "service-a",
- previewGitRef: "refs/pull/42/merge",
- }).service,
- ],
- );
-
- const response = await POST(pullRequest(action, { merged }));
-
- expect(response.status).toBe(200);
- expect(mocks.send).toHaveBeenCalledWith([
- expect.objectContaining({
- name: "preview/close-requested",
- data: {
- baseServiceId: "service-a",
- previewGitRef: "refs/pull/42/merge",
- reason,
- verifyWithGitHub: true,
- },
- }),
- ]);
- });
+ ])(
+ "queues teardown for %s even before a clone exists",
+ async (action, merged, reason) => {
+ mocks.queryResults.push([linkedService({ serviceId: "service-a" })]);
+
+ const response = await POST(pullRequest(action, { merged }));
+
+ expect(response.status).toBe(200);
+ expect(mocks.send).toHaveBeenCalledWith([
+ expect.objectContaining({
+ name: "preview/close-requested",
+ data: {
+ baseServiceId: "service-a",
+ previewGitRef: "refs/pull/42/merge",
+ reason,
+ verifyWithGitHub: true,
+ },
+ }),
+ ]);
+ },
+ );
});
diff --git a/web/tests/github.test.ts b/web/tests/github.test.ts
index b4e3cbe6..57056e22 100644
--- a/web/tests/github.test.ts
+++ b/web/tests/github.test.ts
@@ -1,6 +1,7 @@
import { generateKeyPairSync } from "node:crypto";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
+ findGitHubDeployment,
isFullCommitSha,
resolveGitHubCommit,
resolveGitHubPullRequestMergeRef,
@@ -81,6 +82,49 @@ describe("public GitHub branch resolution", () => {
});
describe("GitHub pull request deployment helpers", () => {
+ it("finds a deployment created for the same preview revision", async () => {
+ configureGitHubApp();
+ const fetchMock = vi.fn(
+ async (input: string | URL | Request, _init?: RequestInit) => {
+ const url = String(input);
+ if (url.includes("/access_tokens")) {
+ return Response.json({ token: "installation-token" });
+ }
+ return Response.json([
+ {
+ id: 101,
+ payload: {
+ previewServiceId: "preview-1",
+ serviceRevisionId: "revision-1",
+ },
+ },
+ ]);
+ },
+ );
+ vi.stubGlobal("fetch", fetchMock);
+
+ await expect(
+ findGitHubDeployment(
+ 10,
+ "acme/app",
+ "0123456789abcdef0123456789abcdef01234567",
+ "preview/app/pr-42",
+ {
+ previewServiceId: "preview-1",
+ serviceRevisionId: "revision-1",
+ },
+ ),
+ ).resolves.toBe(101);
+ expect(fetchMock).toHaveBeenLastCalledWith(
+ "https://api.github.com/repos/acme/app/deployments?sha=0123456789abcdef0123456789abcdef01234567&environment=preview%2Fapp%2Fpr-42&per_page=100",
+ expect.objectContaining({
+ headers: expect.objectContaining({
+ Authorization: "Bearer installation-token",
+ }),
+ }),
+ );
+ });
+
it("fails when the synthetic merge ref is unavailable without using the PR head", async () => {
configureGitHubApp();
const fetchMock = vi.fn(
diff --git a/web/tests/inngest-route.test.ts b/web/tests/inngest-route.test.ts
index 9f927a40..881d0295 100644
--- a/web/tests/inngest-route.test.ts
+++ b/web/tests/inngest-route.test.ts
@@ -21,7 +21,6 @@ const mocks = vi.hoisted(() => {
oldBackupsCleanup: { id: "old-backups-cleanup" },
onDeploymentFailed: { id: "on-deployment-failed" },
onRestoreFailed: { id: "on-restore-failed" },
- previewCloseWorkflow: { id: "preview-close-workflow" },
previewReconciliation: { id: "preview-reconciliation" },
previewServiceReconcileWorkflow: {
id: "preview-service-reconcile-workflow",
diff --git a/web/tests/preview-deployments.test.ts b/web/tests/preview-deployments.test.ts
index 986d6df4..f9ad728c 100644
--- a/web/tests/preview-deployments.test.ts
+++ b/web/tests/preview-deployments.test.ts
@@ -69,6 +69,7 @@ vi.mock("@/db/queries", () => ({ getSetting: mocks.getSetting }));
import {
createPreviewClone,
ensurePreviewEnvironment,
+ previewPortConfiguration,
} from "@/lib/preview-deployments";
const baseService = {
@@ -83,7 +84,12 @@ const baseService = {
previewDeploymentsEnabled: true,
previewOfService: null,
stateful: false,
+ replicas: 2,
+ autoscalingEnabled: false,
+ autoscalingMinReplicas: 1,
+ autoscalingMaxReplicas: 4,
placementMode: "manual",
+ lockedServerId: "server-1",
healthCheckCmd: "curl -f http://localhost/health",
healthCheckInterval: 10,
healthCheckTimeout: 5,
@@ -92,6 +98,12 @@ const baseService = {
startCommand: "node server.js",
resourceCpuLimit: 1,
resourceMemoryLimitMb: 512,
+ serverlessEnabled: true,
+ serverlessSleepAfterSeconds: 300,
+ serverlessWakeTimeoutSeconds: 60,
+ deploymentSchedule: "0 9 * * *",
+ backupEnabled: false,
+ backupSchedule: null,
};
const repo = {
@@ -127,9 +139,12 @@ const ports = [
},
];
-function queueFactoryReads(existing: unknown[] = []) {
+function queueFactoryReads(
+ existing: unknown[] = [],
+ service: typeof baseService = baseService,
+) {
mocks.selectResults.push(
- [baseService],
+ [service],
existing,
[repo],
ports,
@@ -166,7 +181,7 @@ describe("preview service cloning", () => {
process.env.REGISTRY_HOST = "registry.example.com";
});
- it("copies ordinary configuration and secrets while enforcing preview policy", async () => {
+ it("copies runtime configuration and secrets but not automation", async () => {
queueFactoryReads();
const result = await createPreviewClone({
@@ -183,10 +198,13 @@ describe("preview service cloning", () => {
expect(service).toMatchObject({
projectId: "project-1",
environmentId: "preview-environment",
- replicas: 1,
+ replicas: 2,
stateful: false,
autoscalingEnabled: false,
- serverlessEnabled: false,
+ serverlessEnabled: true,
+ lockedServerId: "server-1",
+ deploymentSchedule: null,
+ backupEnabled: false,
previewDeploymentsEnabled: false,
previewOfService: baseService.id,
previewGitRef: "refs/pull/42/merge",
@@ -194,11 +212,13 @@ describe("preview service cloning", () => {
expect(clonedPorts).toEqual(
expect.arrayContaining([
expect.objectContaining({
+ serviceId: result.serviceId,
port: 3000,
isPublic: true,
domain: "web-api-pr-42-12345678.apps.example.com",
}),
expect.objectContaining({
+ serviceId: result.serviceId,
port: 5432,
isPublic: false,
externalPort: null,
@@ -206,7 +226,15 @@ describe("preview service cloning", () => {
}),
]),
);
- expect(placement).toMatchObject({ serverId: "server-1", count: 1 });
+ expect(clonedPorts).not.toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ id: "port-http" }),
+ expect.objectContaining({ id: "port-tcp" }),
+ ]),
+ );
+ expect(placement).toEqual([
+ expect.objectContaining({ serverId: "server-1", count: 2 }),
+ ]);
expect(clonedSecrets).toEqual([
expect.objectContaining({ key: "TOKEN", encryptedValue: "ciphertext" }),
]);
@@ -270,22 +298,37 @@ describe("preview service cloning", () => {
);
});
- it("reuses an environment created concurrently", async () => {
- mocks.selectResults.push(
- [],
- [
- {
- id: "concurrent-environment",
- projectId: "project-1",
- name: "previews",
- },
- ],
+ it("keeps ports private when no automatic domain is configured", () => {
+ expect(
+ previewPortConfiguration({
+ ports,
+ serviceName: baseService.name,
+ serviceId: baseService.id,
+ pullRequestNumber: 42,
+ domain: null,
+ }),
+ ).toEqual(
+ ports.map((port) => ({
+ ...port,
+ isPublic: false,
+ domain: null,
+ externalPort: null,
+ tlsPassthrough: false,
+ })),
);
- mocks.returningResults.push([]);
+ });
- await expect(ensurePreviewEnvironment("project-1")).resolves.toMatchObject({
- id: "concurrent-environment",
- name: "previews",
+ it("disables copied serverless mode when no public preview URL exists", async () => {
+ mocks.getSetting.mockResolvedValue(null);
+ queueFactoryReads([], { ...baseService, serverlessEnabled: true });
+
+ await createPreviewClone({
+ baseServiceId: baseService.id,
+ previewGitRef: "refs/pull/42/merge",
+ });
+
+ expect(mocks.insertedValues[0]).toMatchObject({
+ serverlessEnabled: false,
});
});
});
diff --git a/web/tests/preview-workflow.test.ts b/web/tests/preview-workflow.test.ts
index 7afcfc93..c46782eb 100644
--- a/web/tests/preview-workflow.test.ts
+++ b/web/tests/preview-workflow.test.ts
@@ -2,56 +2,30 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => {
const selectResults: unknown[][] = [];
- const updateResults: unknown[][] = [];
-
- function selectQuery(result: unknown[]) {
- const query = {
- from: vi.fn(() => query),
- innerJoin: vi.fn(() => query),
- where: vi.fn(() => query),
+ function query(result: unknown[]) {
+ const value = {
+ from: vi.fn(() => value),
+ innerJoin: vi.fn(() => value),
+ where: vi.fn(() => value),
+ orderBy: vi.fn(() => value),
+ limit: vi.fn(() => value),
// oxlint-disable-next-line unicorn/no-thenable -- Drizzle query builders are awaitable.
then: (
- resolve: (value: unknown[]) => unknown,
+ resolve: (rows: unknown[]) => unknown,
reject?: (reason: unknown) => unknown,
) => Promise.resolve(result).then(resolve, reject),
};
- return query;
+ return value;
}
-
- function updateQuery(result: unknown[]) {
- const query = {
- set: vi.fn(() => query),
- where: vi.fn(() => query),
- returning: vi.fn(() => query),
- // oxlint-disable-next-line unicorn/no-thenable -- Drizzle query builders are awaitable.
- then: (
- resolve: (value: unknown[]) => unknown,
- reject?: (reason: unknown) => unknown,
- ) => Promise.resolve(result).then(resolve, reject),
- };
- return query;
- }
-
- const db = {
- select: vi.fn(() => selectQuery(selectResults.shift() ?? [])),
- update: vi.fn(() => updateQuery(updateResults.shift() ?? [])),
- execute: vi.fn().mockResolvedValue(undefined),
- transaction: vi.fn(async (operation: (tx: typeof db) => unknown) =>
- operation(db),
- ),
- };
-
+ const db = { select: vi.fn(() => query(selectResults.shift() ?? [])) };
return {
selectResults,
- updateResults,
db,
getGitHubPullRequest: vi.fn(),
listOpenGitHubPullRequests: vi.fn(),
resolveGitHubPullRequestMergeRef: vi.fn(),
- createGitHubDeployment: vi.fn(),
- updateGitHubDeploymentStatus: vi.fn(),
createPreviewClone: vi.fn(),
- updateCurrentPreviewGitHubStatus: vi.fn(),
+ inactivatePreviewGitHubDeployments: vi.fn(),
cancelPreviewRevisionWork: vi.fn(),
deactivatePreviewRuntime: vi.fn(),
deletePreviewService: vi.fn(),
@@ -71,12 +45,10 @@ vi.mock("@/lib/github", () => ({
getGitHubPullRequest: mocks.getGitHubPullRequest,
listOpenGitHubPullRequests: mocks.listOpenGitHubPullRequests,
resolveGitHubPullRequestMergeRef: mocks.resolveGitHubPullRequestMergeRef,
- createGitHubDeployment: mocks.createGitHubDeployment,
- updateGitHubDeploymentStatus: mocks.updateGitHubDeploymentStatus,
}));
vi.mock("@/lib/preview-deployments", () => ({
createPreviewClone: mocks.createPreviewClone,
- updateCurrentPreviewGitHubStatus: mocks.updateCurrentPreviewGitHubStatus,
+ inactivatePreviewGitHubDeployments: mocks.inactivatePreviewGitHubDeployments,
}));
vi.mock("@/lib/preview-lifecycle", () => ({
cancelPreviewRevisionWork: mocks.cancelPreviewRevisionWork,
@@ -110,16 +82,11 @@ vi.mock("@/lib/inngest/events", () => ({
},
}));
-import {
- previewCloseWorkflow,
- previewServiceReconcileWorkflow,
- previewSyncWorkflow,
-} from "@/lib/inngest/functions/preview-workflow";
+import { previewSyncWorkflow } from "@/lib/inngest/functions/preview-workflow";
const baseContext = {
service: {
id: "base-service",
- name: "Web",
previewDeploymentsEnabled: true,
previewOfService: null,
stateful: false,
@@ -139,350 +106,132 @@ const pullRequest = {
state: "open" as const,
draft: false,
merged: false,
- title: "Add preview deployments",
+ title: "Add previews",
updatedAt: "2026-08-16T00:00:00Z",
user: { id: 30, login: "octocat" },
- base: {
- ref: "main",
- repository: { id: 20, fullName: "acme/app" },
- },
- head: {
- sha: "1".repeat(40),
- repository: { id: 20, fullName: "acme/app" },
- },
+ base: { ref: "main", repository: { id: 20, fullName: "acme/app" } },
+ head: { sha: "1".repeat(40), repository: { id: 20, fullName: "acme/app" } },
};
-function step() {
- return {
- run: vi.fn(async (_name: string, operation: () => unknown) => operation()),
- };
-}
-
function invoke(
workflow: unknown,
data: Record,
- eventId = "event-1",
+ name = "preview/sync-requested",
) {
- const workflowStep = step();
- const handler = workflow as (input: {
- event: { id: string; data: Record };
- step: ReturnType;
- }) => Promise;
- return {
- result: handler({ event: { id: eventId, data }, step: workflowStep }),
- step: workflowStep,
- };
+ return (
+ workflow as (input: {
+ event: { id: string; name: string; data: Record };
+ step: { run: (_name: string, operation: () => unknown) => unknown };
+ }) => Promise
+ )({
+ event: { id: "event-1", name, data },
+ step: { run: async (_name, operation) => operation() },
+ });
}
describe("preview lifecycle workflows", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.selectResults.length = 0;
- mocks.updateResults.length = 0;
mocks.createPreviewClone.mockResolvedValue({
serviceId: "preview-service",
created: false,
- primaryUrl: "https://web-pr-42.example.com",
+ primaryUrl: "https://preview.example.com",
});
+ mocks.getGitHubPullRequest.mockResolvedValue(pullRequest);
mocks.deletePreviewService.mockResolvedValue({
- service: { id: "preview-service", previewGithubDeploymentId: null },
- githubRepo: baseContext.githubRepo,
+ service: { id: "preview-service" },
});
- mocks.createGitHubDeployment.mockResolvedValue(100);
- mocks.updateCurrentPreviewGitHubStatus.mockResolvedValue(true);
- mocks.updateGitHubDeploymentStatus.mockResolvedValue(undefined);
+ mocks.inactivatePreviewGitHubDeployments.mockResolvedValue(1);
mocks.cancelPreviewRevisionWork.mockResolvedValue(undefined);
mocks.deactivatePreviewRuntime.mockResolvedValue(undefined);
mocks.send.mockResolvedValue(undefined);
});
- it("ignores a delayed close after the pull request was reopened", async () => {
+ it("builds the exact merge ref and supersedes the prior revision", async () => {
mocks.selectResults.push(
[baseContext],
- [
- {
- service: {
- id: "preview-service",
- previewGithubDeploymentId: 99,
- },
- githubRepo: baseContext.githubRepo,
- },
- ],
- );
- mocks.getGitHubPullRequest.mockResolvedValue(pullRequest);
-
- await expect(
- invoke(previewCloseWorkflow, {
- baseServiceId: "base-service",
- previewGitRef: "refs/pull/42/merge",
- reason: "pull_request_closed",
- verifyWithGitHub: true,
- }).result,
- ).resolves.toEqual({ status: "stale" });
-
- expect(mocks.deletePreviewService).not.toHaveBeenCalled();
- expect(mocks.send).toHaveBeenCalledWith(
- expect.objectContaining({
- name: "preview/sync-requested",
- data: {
- baseServiceId: "base-service",
- previewGitRef: "refs/pull/42/merge",
- },
- }),
+ [{ previewOfService: "base-service" }],
+ [{ id: "revision-old", specification: {} }],
);
- });
-
- it("retries rather than deleting when the authoritative GitHub read fails", async () => {
- mocks.selectResults.push(
- [baseContext],
- [
- {
- service: { id: "preview-service" },
- githubRepo: baseContext.githubRepo,
- },
- ],
- );
- mocks.getGitHubPullRequest.mockRejectedValue(
- new Error("GitHub temporarily unavailable"),
- );
-
- await expect(
- invoke(previewCloseWorkflow, {
- baseServiceId: "base-service",
- previewGitRef: "refs/pull/42/merge",
- reason: "pull_request_closed",
- verifyWithGitHub: true,
- }).result,
- ).rejects.toThrow("GitHub temporarily unavailable");
- expect(mocks.deletePreviewService).not.toHaveBeenCalled();
- });
-
- it("deactivates the old runtime when the merge ref is unavailable", async () => {
- mocks.selectResults.push(
- [baseContext],
- [
- {
- previewCurrentRevisionId: "revision-old",
- previewGithubDeploymentId: 98,
- },
- ],
- [{ specification: { source: "old" } }],
- [
- {
- previewCurrentRevisionId: "revision-old",
- previewGithubDeploymentId: 98,
- },
- ],
- );
- mocks.getGitHubPullRequest.mockResolvedValue(pullRequest);
mocks.parseServiceRevisionSpec.mockReturnValue({
source: { type: "github", commitSha: "2".repeat(40) },
});
- mocks.resolveGitHubPullRequestMergeRef.mockRejectedValue(
- new Error("Merge ref refs/pull/42/merge is unavailable"),
- );
- mocks.updateResults.push([], [{ id: "preview-service" }]);
-
- await expect(
- invoke(previewSyncWorkflow, {
- baseServiceId: "base-service",
- previewGitRef: "refs/pull/42/merge",
- }).result,
- ).resolves.toEqual({
- status: "failed",
- reason: "merge_ref_unavailable",
- });
-
- expect(mocks.deactivatePreviewRuntime).toHaveBeenCalledWith(
- "preview-service",
- );
- expect(mocks.updateGitHubDeploymentStatus).toHaveBeenCalledWith(
- 10,
- "acme/app",
- 98,
- "inactive",
- { description: "Preview merge ref is unavailable" },
- );
- expect(mocks.createGitHubDeployment).toHaveBeenCalledWith(
- 10,
- "acme/app",
- pullRequest.head.sha,
- "preview/Web/pr-42",
- "Preview unavailable for PR #42",
- expect.objectContaining({
- transientEnvironment: true,
- productionEnvironment: false,
- }),
- );
- expect(mocks.updateCurrentPreviewGitHubStatus).toHaveBeenCalledWith({
- serviceId: "preview-service",
- serviceRevisionId: null,
- expectedDeploymentId: 100,
- state: "failure",
- description: "Merge ref refs/pull/42/merge is unavailable",
- });
- expect(mocks.triggerResolvedBuildInternal).not.toHaveBeenCalled();
- });
-
- it("forces an exact merge-ref rebuild and supersedes the old revision", async () => {
- mocks.selectResults.push(
- [baseContext],
- [
- {
- previewCurrentRevisionId: "revision-current",
- previewGithubDeploymentId: 99,
- },
- ],
- [{ specification: { source: "current" } }],
- );
- mocks.updateResults.push([{ id: "preview-service" }]);
- mocks.getGitHubPullRequest.mockResolvedValue(pullRequest);
- mocks.parseServiceRevisionSpec.mockReturnValue({
- source: { type: "github", commitSha: "3".repeat(40) },
- });
mocks.resolveGitHubPullRequestMergeRef.mockResolvedValue({
gitRef: "refs/pull/42/merge",
sha: "3".repeat(40),
});
- mocks.createGitHubDeployment.mockResolvedValue(100);
- mocks.triggerResolvedBuildInternal.mockImplementation(
- async (_serviceId, input) => {
- await input.beforeDispatch("revision-forced");
- return {
- buildId: null,
- serviceRevisionId: "revision-forced",
- status: "queued",
- };
- },
- );
-
- await expect(
- invoke(
- previewSyncWorkflow,
- {
- baseServiceId: "base-service",
- previewGitRef: "refs/pull/42/merge",
- force: true,
- },
- "redeploy-event",
- ).result,
- ).resolves.toMatchObject({
+ mocks.triggerResolvedBuildInternal.mockResolvedValue({
status: "queued",
- serviceRevisionId: "revision-forced",
- deploymentId: 100,
+ serviceRevisionId: "revision-new",
});
+ await expect(
+ invoke(previewSyncWorkflow, {
+ baseServiceId: "base-service",
+ previewGitRef: "refs/pull/42/merge",
+ }),
+ ).resolves.toMatchObject({ serviceRevisionId: "revision-new" });
expect(mocks.triggerResolvedBuildInternal).toHaveBeenCalledWith(
"preview-service",
expect.objectContaining({
trigger: "preview",
commitSha: "3".repeat(40),
gitRef: "refs/pull/42/merge",
- idempotencyKey: expect.stringContaining("redeploy-event"),
}),
);
expect(mocks.cancelPreviewRevisionWork).toHaveBeenCalledWith(
"preview-service",
- "revision-current",
+ "revision-old",
);
- expect(mocks.updateCurrentPreviewGitHubStatus).toHaveBeenCalledWith({
+ expect(mocks.inactivatePreviewGitHubDeployments).toHaveBeenCalledWith({
serviceId: "preview-service",
- serviceRevisionId: "revision-forced",
- expectedDeploymentId: 100,
- state: "pending",
- description: "Preview build queued",
+ excludeServiceRevisionId: "revision-new",
+ description: "Superseded by a newer preview revision",
});
});
- it("inactivates a GitHub deployment when its preview disappears before dispatch", async () => {
+ it("deactivates the preview when GitHub has no merge ref", async () => {
mocks.selectResults.push(
[baseContext],
- [
- {
- previewCurrentRevisionId: null,
- previewGithubDeploymentId: null,
- },
- ],
+ [{ previewOfService: "base-service" }],
[],
);
- mocks.updateResults.push([]);
- mocks.getGitHubPullRequest.mockResolvedValue(pullRequest);
- mocks.resolveGitHubPullRequestMergeRef.mockResolvedValue({
- gitRef: "refs/pull/42/merge",
- sha: "3".repeat(40),
- });
- mocks.triggerResolvedBuildInternal.mockImplementation(
- async (_serviceId, input) => {
- await input.beforeDispatch("revision-orphaned");
- throw new Error("Preview was closed before its build was queued");
- },
+ mocks.resolveGitHubPullRequestMergeRef.mockRejectedValue(
+ new Error("merge ref unavailable"),
);
await expect(
invoke(previewSyncWorkflow, {
baseServiceId: "base-service",
previewGitRef: "refs/pull/42/merge",
- }).result,
- ).rejects.toThrow("Preview was closed before its build was queued");
-
- expect(mocks.cancelPreviewRevisionWork).toHaveBeenCalledWith(
+ }),
+ ).resolves.toEqual({
+ status: "failed",
+ reason: "merge_ref_unavailable",
+ });
+ expect(mocks.deactivatePreviewRuntime).toHaveBeenCalledWith(
"preview-service",
- "revision-orphaned",
- );
- expect(mocks.updateGitHubDeploymentStatus).toHaveBeenCalledWith(
- 10,
- "acme/app",
- 100,
- "inactive",
- { description: "Preview was removed" },
);
+ expect(mocks.triggerResolvedBuildInternal).not.toHaveBeenCalled();
});
- it("reconciliation retries deleting previews before recreating missing ones", async () => {
- const secondPullRequest = {
- ...pullRequest,
- number: 43,
- updatedAt: "2026-08-16T01:00:00Z",
- };
- mocks.selectResults.push(
- [baseContext],
- [
+ it("deletes the preview when a pull request closes", async () => {
+ await expect(
+ invoke(
+ previewSyncWorkflow,
{
+ baseServiceId: "base-service",
previewGitRef: "refs/pull/42/merge",
- deletedAt: new Date("2026-08-16T00:30:00Z"),
+ reason: "pull_request_closed",
},
- { previewGitRef: "refs/pull/99/merge", deletedAt: null },
- ],
- );
- mocks.listOpenGitHubPullRequests.mockResolvedValue([
- pullRequest,
- secondPullRequest,
- ]);
-
- await expect(
- invoke(previewServiceReconcileWorkflow, {
- baseServiceId: "base-service",
- }).result,
- ).resolves.toEqual({ status: "queued", count: 2, closed: 2 });
-
+ "preview/close-requested",
+ ),
+ ).resolves.toEqual({ status: "deleted", serviceId: "preview-service" });
expect(mocks.deletePreviewService).toHaveBeenCalledWith(
"base-service",
"refs/pull/42/merge",
- "retrying preview deletion",
- );
- expect(mocks.deletePreviewService).toHaveBeenCalledWith(
- "base-service",
- "refs/pull/99/merge",
- "pull request no longer eligible",
- );
- expect(mocks.send).toHaveBeenCalledTimes(2);
- expect(mocks.send).toHaveBeenCalledWith(
- expect.objectContaining({
- data: {
- baseServiceId: "base-service",
- previewGitRef: "refs/pull/43/merge",
- },
- }),
+ "pull_request_closed",
);
});
});
diff --git a/web/tests/service-config.test.ts b/web/tests/service-config.test.ts
index 7afce962..ef0c9d33 100644
--- a/web/tests/service-config.test.ts
+++ b/web/tests/service-config.test.ts
@@ -59,7 +59,7 @@ describe("service config", () => {
it("converts an immutable revision for pending-change comparisons", () => {
const config = revisionSpecToDeployedConfig(
{
- schemaVersion: 4,
+ schemaVersion: 3,
placement: { mode: "manual" },
image: "nginx",
source: { type: "image", image: "nginx" },
@@ -130,13 +130,12 @@ describe("service config", () => {
] as const) {
const active = revisionSpecToDeployedConfig(
{
- schemaVersion: 4,
+ schemaVersion: 3,
placement: { mode: "manual" },
image,
source: {
...currentSource,
repositoryId: 101,
- gitRef: "refs/heads/main",
commitSha,
authentication: {
type: "github_app",
diff --git a/web/tests/service-revision-build.test.ts b/web/tests/service-revision-build.test.ts
index ec4e3fc6..46ae974c 100644
--- a/web/tests/service-revision-build.test.ts
+++ b/web/tests/service-revision-build.test.ts
@@ -65,7 +65,7 @@ import {
function sourceSpecification(): ServiceRevisionSpec {
return {
- schemaVersion: 4,
+ schemaVersion: 3,
placement: { mode: "manual" },
image: "registry.test/project-1/service-1:revision-original",
source: {
@@ -73,7 +73,6 @@ function sourceSpecification(): ServiceRevisionSpec {
repository: "https://github.com/acme/app",
repositoryId: 101,
branch: "main",
- gitRef: "refs/heads/main",
commitSha: "0123456789abcdef0123456789abcdef01234567",
rootDir: "apps/web",
authentication: { type: "github_app", installationId: 123 },
@@ -162,7 +161,6 @@ describe("GitHub build service revisions", () => {
commitSha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
expectedRepository: "https://github.com/acme/app",
expectedBranch: "main",
- gitRef: "refs/heads/main",
actor: { type: "system" },
}),
).rejects.toThrow("Service revision idempotency conflict");
diff --git a/web/tests/service-revision-changes.test.ts b/web/tests/service-revision-changes.test.ts
index a0332c8a..d28c7776 100644
--- a/web/tests/service-revision-changes.test.ts
+++ b/web/tests/service-revision-changes.test.ts
@@ -7,7 +7,7 @@ import type { ServiceRevisionSpec } from "@/lib/service-revision-spec";
function spec(): ServiceRevisionSpec {
return {
- schemaVersion: 4,
+ schemaVersion: 3,
placement: { mode: "manual" },
image: "app:v1",
source: { type: "image", image: "app:v1" },
diff --git a/web/tests/service-revision-spec.test.ts b/web/tests/service-revision-spec.test.ts
index 0784e99a..1bae1205 100644
--- a/web/tests/service-revision-spec.test.ts
+++ b/web/tests/service-revision-spec.test.ts
@@ -1,7 +1,6 @@
import { describe, expect, it } from "vitest";
import {
buildServiceRevisionSpec,
- isSupportedGitRef,
type ServiceRevisionDraft,
} from "@/lib/service-revision-spec";
@@ -220,7 +219,6 @@ describe("service revision specification", () => {
repository: "https://github.com/techulus/cloud",
repositoryId: 123,
branch: "main",
- gitRef: "refs/heads/main",
commitSha: "0123456789abcdef0123456789abcdef01234567",
rootDir: "web",
authentication: { type: "github_app", installationId: 456 },
@@ -228,13 +226,12 @@ describe("service revision specification", () => {
});
expect(spec).toMatchObject({
- schemaVersion: 4,
+ schemaVersion: 3,
image: "registry.test/project/service:revision-1",
source: {
type: "github",
repository: "https://github.com/techulus/cloud",
branch: "main",
- gitRef: "refs/heads/main",
commitSha: "0123456789abcdef0123456789abcdef01234567",
rootDir: "web",
authentication: { type: "github_app", installationId: 456 },
@@ -409,24 +406,4 @@ describe("service revision specification", () => {
volumes: [{ name: "data", containerPath: "/data" }],
});
});
-
- it("accepts only branch and pull-request merge refs that Git can fetch safely", () => {
- expect(isSupportedGitRef("refs/heads/main")).toBe(true);
- expect(isSupportedGitRef("refs/heads/feature/preview-deployments")).toBe(
- true,
- );
- expect(isSupportedGitRef("refs/pull/42/merge")).toBe(true);
-
- for (const ref of [
- "main",
- "refs/heads//main",
- "refs/heads/feature/.hidden",
- "refs/heads/@",
- "refs/heads/feature.lock",
- "refs/pull/0/merge",
- "refs/pull/42/head",
- ]) {
- expect(isSupportedGitRef(ref)).toBe(false);
- }
- });
});
diff --git a/web/tests/service-revisions-route.test.ts b/web/tests/service-revisions-route.test.ts
index dcd895cb..378e331a 100644
--- a/web/tests/service-revisions-route.test.ts
+++ b/web/tests/service-revisions-route.test.ts
@@ -40,7 +40,7 @@ function revisionSpec(
encryptedValue = "cipher",
): ServiceRevisionSpec {
return {
- schemaVersion: 4,
+ schemaVersion: 3,
placement: { mode: "manual" },
image,
source: { type: "image", image },
diff --git a/web/tests/trigger-build.test.ts b/web/tests/trigger-build.test.ts
index 33430e11..dae5b9ec 100644
--- a/web/tests/trigger-build.test.ts
+++ b/web/tests/trigger-build.test.ts
@@ -111,7 +111,6 @@ describe("internal GitHub build trigger", () => {
commitSha: "0123456789abcdef0123456789abcdef01234567",
expectedRepository: "https://github.com/acme/app",
expectedBranch: "production",
- gitRef: "refs/heads/production",
actor,
}),
);
@@ -123,7 +122,6 @@ describe("internal GitHub build trigger", () => {
commitSha: "0123456789abcdef0123456789abcdef01234567",
commitMessage: "Resolved source commit",
branch: "production",
- gitRef: "refs/heads/production",
author: "octocat",
actor,
githubDeploymentId: undefined,
@@ -159,7 +157,6 @@ describe("internal GitHub build trigger", () => {
expect.objectContaining({
expectedRepository: "https://github.com/acme/public",
expectedBranch: "preview",
- gitRef: "refs/heads/preview",
}),
);
expect(mocks.createBuildTrigger).toHaveBeenCalledWith({
@@ -170,7 +167,6 @@ describe("internal GitHub build trigger", () => {
commitSha: "0123456789abcdef0123456789abcdef01234567",
commitMessage: "Resolved source commit",
branch: "preview",
- gitRef: "refs/heads/preview",
author: "octocat",
actor: { type: "system" },
githubDeploymentId: undefined,
@@ -302,7 +298,6 @@ describe("internal GitHub build trigger", () => {
serviceRevisionId: "retry-1",
commitSha: retrySpecification.source.commitSha,
branch: "main",
- gitRef: "refs/heads/main",
}),
);
});
From 7d2734b983cb9846f7e9dfa5008fa5a9c8f9b408 Mon Sep 17 00:00:00 2001
From: Amp
Date: Mon, 17 Aug 2026 14:28:28 +0000
Subject: [PATCH 4/5] Harden preview deployment races
Amp-Thread-ID: https://ampcode.com/threads/T-01a003f3-7142-74cd-b819-95472f4a6376
Co-authored-by: Arjun Komath
---
docs/deployments/github.mdx | 4 +-
.../functions/build-trigger-workflow.ts | 91 +++++++++++++++++--
web/lib/inngest/functions/preview-workflow.ts | 15 ++-
web/lib/preview-deployments.ts | 34 +++----
web/tests/build-trigger-workflow.test.ts | 59 ++++++++++++
web/tests/preview-deployments.test.ts | 53 ++++++++++-
web/tests/preview-workflow.test.ts | 11 +--
7 files changed, 220 insertions(+), 47 deletions(-)
diff --git a/docs/deployments/github.mdx b/docs/deployments/github.mdx
index 1107a0e6..75c692aa 100644
--- a/docs/deployments/github.mdx
+++ b/docs/deployments/github.mdx
@@ -68,7 +68,9 @@ A pull request is eligible only when it:
The preview builds GitHub's synthetic merge result at
`refs/pull//merge`. This tests the change as it would merge into the
configured branch. If GitHub cannot produce that ref because of merge
-conflicts, the preview fails rather than building the raw pull request head.
+conflicts, the preview service is removed rather than building the raw pull
+request head. Reconciliation recreates it after GitHub can produce the merge
+ref again.
When first created, preview services inherit the base service's current source
configuration, replicas, autoscaling, placement, health check, start command,
diff --git a/web/lib/inngest/functions/build-trigger-workflow.ts b/web/lib/inngest/functions/build-trigger-workflow.ts
index 45911d05..4a80e12b 100644
--- a/web/lib/inngest/functions/build-trigger-workflow.ts
+++ b/web/lib/inngest/functions/build-trigger-workflow.ts
@@ -204,18 +204,89 @@ export const buildTriggerWorkflow = inngest.createFunction(
}),
);
}
- await step.run("enqueue-builds", () =>
- Promise.all(
- assignments.map((assignment) =>
- enqueueWork(
- assignment.serverId,
- "build",
- { buildId: assignment.id },
- { id: `build-work-${assignment.id}` },
+ const enqueueResult = await step.run("enqueue-builds", () =>
+ db.transaction(async (tx) => {
+ await tx.execute(
+ sql`select pg_advisory_xact_lock(hashtext(${serviceId}))`,
+ );
+ if (isPreview) {
+ const [activeService, latestRevision] = await Promise.all([
+ tx
+ .select({ id: services.id })
+ .from(services)
+ .where(
+ and(eq(services.id, serviceId), isNull(services.deletedAt)),
+ )
+ .then((rows) => rows[0]),
+ tx
+ .select({ id: serviceRevisions.id })
+ .from(serviceRevisions)
+ .where(eq(serviceRevisions.serviceId, serviceId))
+ .orderBy(
+ desc(serviceRevisions.createdAt),
+ desc(serviceRevisions.id),
+ )
+ .limit(1)
+ .then((rows) => rows[0]),
+ ]);
+ if (!activeService || latestRevision?.id !== serviceRevisionId) {
+ await tx
+ .update(builds)
+ .set({ status: "cancelled", completedAt: new Date() })
+ .where(
+ and(inArray(builds.id, buildIds), eq(builds.status, "pending")),
+ );
+ return "stale" as const;
+ }
+ }
+ const currentBuilds = await tx
+ .select({ id: builds.id, status: builds.status })
+ .from(builds)
+ .where(inArray(builds.id, buildIds))
+ .for("update");
+ const activeStatuses = new Set([
+ "pending",
+ "claimed",
+ "cloning",
+ "building",
+ "pushing",
+ ]);
+ if (
+ currentBuilds.length !== buildIds.length ||
+ currentBuilds.some((build) => !activeStatuses.has(build.status))
+ ) {
+ await tx
+ .update(builds)
+ .set({ status: "cancelled", completedAt: new Date() })
+ .where(
+ and(inArray(builds.id, buildIds), eq(builds.status, "pending")),
+ );
+ return "cancelled" as const;
+ }
+
+ await Promise.all(
+ assignments.map((assignment) =>
+ enqueueWork(
+ assignment.serverId,
+ "build",
+ { buildId: assignment.id },
+ { id: `build-work-${assignment.id}`, tx },
+ ),
),
- ),
- ),
+ );
+ return "enqueued" as const;
+ }),
);
+ if (enqueueResult !== "enqueued") {
+ return {
+ status: "cancelled",
+ reason:
+ enqueueResult === "stale"
+ ? "superseded_preview_revision"
+ : "build_cancelled_before_enqueue",
+ buildGroupId: buildRequestId,
+ };
+ }
await step.run("send-build-started", async () => {
await inngest.send(
diff --git a/web/lib/inngest/functions/preview-workflow.ts b/web/lib/inngest/functions/preview-workflow.ts
index fb5a1836..80d101cb 100644
--- a/web/lib/inngest/functions/preview-workflow.ts
+++ b/web/lib/inngest/functions/preview-workflow.ts
@@ -12,7 +12,6 @@ import {
} from "@/lib/preview-deployments";
import {
cancelPreviewRevisionWork,
- deactivatePreviewRuntime,
deletePreviewService,
} from "@/lib/preview-lifecycle";
import { parseServiceRevisionSpec } from "@/lib/service-revision-changes";
@@ -229,14 +228,12 @@ export const previewSyncWorkflow = inngest.createFunction(
),
);
} catch {
- await step.run("deactivate-unmergeable-preview", () =>
- deactivatePreviewRuntime(clone.serviceId),
- );
- await step.run("inactivate-unmergeable-deployments", () =>
- inactivatePreviewGitHubDeployments({
- serviceId: clone.serviceId,
- description: "Preview merge ref is unavailable",
- }),
+ await step.run("delete-unmergeable-preview", () =>
+ deletePreviewService(
+ baseServiceId,
+ previewGitRef,
+ "merge ref is unavailable",
+ ),
);
return { status: "failed", reason: "merge_ref_unavailable" };
}
diff --git a/web/lib/preview-deployments.ts b/web/lib/preview-deployments.ts
index 5314f4aa..15581257 100644
--- a/web/lib/preview-deployments.ts
+++ b/web/lib/preview-deployments.ts
@@ -406,7 +406,7 @@ export async function updatePreviewGitHubStatus(input: {
logUrl?: string;
expectedDeploymentId?: number;
}) {
- const context = await db.transaction(async (tx) => {
+ return db.transaction(async (tx) => {
await tx.execute(
sql`select pg_advisory_xact_lock(hashtext(${input.serviceId}))`,
);
@@ -472,25 +472,21 @@ export async function updatePreviewGitHubStatus(input: {
.filter((port) => port.domain)
.sort((a, b) => a.port - b.port || a.id.localeCompare(b.id))[0],
);
- return {
- ...githubRepo,
- deploymentId: deployment.id,
- environmentUrl: primary?.domain ? `https://${primary.domain}` : undefined,
- };
+ await updateGitHubDeploymentStatus(
+ githubRepo.installationId,
+ githubRepo.repoFullName,
+ deployment.id,
+ input.state,
+ {
+ description: input.description.substring(0, 140),
+ logUrl: input.logUrl,
+ environmentUrl: primary?.domain
+ ? `https://${primary.domain}`
+ : undefined,
+ },
+ );
+ return true;
});
- if (!context) return false;
- await updateGitHubDeploymentStatus(
- context.installationId,
- context.repoFullName,
- context.deploymentId,
- input.state,
- {
- description: input.description.substring(0, 140),
- logUrl: input.logUrl,
- environmentUrl: context.environmentUrl,
- },
- );
- return true;
}
export async function createPreviewGitHubDeployment(input: {
diff --git a/web/tests/build-trigger-workflow.test.ts b/web/tests/build-trigger-workflow.test.ts
index 713ce665..fdc5a9db 100644
--- a/web/tests/build-trigger-workflow.test.ts
+++ b/web/tests/build-trigger-workflow.test.ts
@@ -4,6 +4,8 @@ const mocks = vi.hoisted(() => ({
values: vi.fn(),
onConflictDoNothing: vi.fn(),
returning: vi.fn(),
+ set: vi.fn(),
+ updateWhere: vi.fn(),
revisionRows: [] as unknown[],
transactionSelectResults: [] as unknown[][],
execute: vi.fn(),
@@ -24,6 +26,7 @@ vi.mock("@/db", () => ({
where: vi.fn(() => query),
orderBy: vi.fn(() => query),
limit: vi.fn(() => query),
+ for: vi.fn(() => query),
// oxlint-disable-next-line unicorn/no-thenable -- Drizzle query builders are awaitable.
then: (
resolve: (rows: unknown[]) => unknown,
@@ -35,6 +38,7 @@ vi.mock("@/db", () => ({
const tx = {
execute: mocks.execute,
insert: vi.fn(() => ({ values: mocks.values })),
+ update: vi.fn(() => ({ set: mocks.set })),
select: vi.fn(() => query(mocks.transactionSelectResults.shift() ?? [])),
};
return {
@@ -54,6 +58,7 @@ vi.mock("@/db/schema", () => ({
branch: "branch",
targetPlatform: "target_platform",
buildGroupId: "build_group_id",
+ status: "status",
},
services: {
id: "id",
@@ -162,6 +167,7 @@ describe("build trigger fan-out", () => {
onConflictDoNothing: mocks.onConflictDoNothing,
});
mocks.onConflictDoNothing.mockReturnValue({ returning: mocks.returning });
+ mocks.set.mockReturnValue({ where: mocks.updateWhere });
mocks.returning.mockResolvedValue([{ id: "build-1" }, { id: "build-2" }]);
mocks.getTargetPlatformsForRevision.mockResolvedValue([
"linux/amd64",
@@ -171,6 +177,10 @@ describe("build trigger fan-out", () => {
});
it("persists one immutable commit for every target platform", async () => {
+ mocks.transactionSelectResults.push([
+ { id: "build-1", status: "pending" },
+ { id: "build-2", status: "pending" },
+ ]);
await invoke(exactSha);
expect(mocks.values).toHaveBeenCalledTimes(1);
@@ -220,6 +230,55 @@ describe("build trigger fan-out", () => {
expect(mocks.createPreviewGitHubDeployment).not.toHaveBeenCalled();
});
+ it("cancels a preview superseded before agent work is enqueued", async () => {
+ const previewGitRef = "refs/pull/42/merge";
+ (mocks.revisionRows[0] as Record).previewGitRef =
+ previewGitRef;
+ mocks.transactionSelectResults.push(
+ [{ id: "service-1" }],
+ [{ id: "revision-1" }],
+ [{ id: "service-1" }],
+ [{ id: "newer-revision" }],
+ );
+
+ await expect(invoke(exactSha, previewGitRef)).resolves.toMatchObject({
+ status: "cancelled",
+ reason: "superseded_preview_revision",
+ });
+ expect(mocks.values).toHaveBeenCalled();
+ expect(mocks.set).toHaveBeenCalledWith(
+ expect.objectContaining({ status: "cancelled" }),
+ );
+ expect(mocks.enqueueWork).not.toHaveBeenCalled();
+ expect(mocks.createBuildStarted).not.toHaveBeenCalled();
+ });
+
+ it("does not enqueue agent work after a build is cancelled", async () => {
+ const previewGitRef = "refs/pull/42/merge";
+ (mocks.revisionRows[0] as Record).previewGitRef =
+ previewGitRef;
+ mocks.transactionSelectResults.push(
+ [{ id: "service-1" }],
+ [{ id: "revision-1" }],
+ [{ id: "service-1" }],
+ [{ id: "revision-1" }],
+ [
+ { id: "build-1", status: "cancelled" },
+ { id: "build-2", status: "pending" },
+ ],
+ );
+
+ await expect(invoke(exactSha, previewGitRef)).resolves.toMatchObject({
+ status: "cancelled",
+ reason: "build_cancelled_before_enqueue",
+ });
+ expect(mocks.set).toHaveBeenCalledWith(
+ expect.objectContaining({ status: "cancelled" }),
+ );
+ expect(mocks.enqueueWork).not.toHaveBeenCalled();
+ expect(mocks.createBuildStarted).not.toHaveBeenCalled();
+ });
+
it("rejects a moving ref before creating any platform build", async () => {
await expect(invoke("HEAD")).rejects.toThrow(
"Build fan-out requires a full 40-character commit SHA",
diff --git a/web/tests/preview-deployments.test.ts b/web/tests/preview-deployments.test.ts
index f9ad728c..187d5379 100644
--- a/web/tests/preview-deployments.test.ts
+++ b/web/tests/preview-deployments.test.ts
@@ -5,11 +5,13 @@ const mocks = vi.hoisted(() => {
const returningResults: unknown[][] = [];
const insertedValues: unknown[] = [];
const updatedValues: unknown[] = [];
+ const transactionState = { active: false };
function query(result: unknown[]) {
const value = {
from: vi.fn(() => value),
where: vi.fn(() => value),
orderBy: vi.fn(() => value),
+ limit: vi.fn(() => value),
innerJoin: vi.fn(() => value),
// oxlint-disable-next-line unicorn/no-thenable -- Drizzle query builders are awaitable.
then: (
@@ -52,11 +54,20 @@ const mocks = vi.hoisted(() => {
returningResults,
insertedValues,
updatedValues,
+ transactionState,
tx,
getSetting: vi.fn(),
+ updateGitHubDeploymentStatus: vi.fn(),
db: {
- transaction: vi.fn((operation: (transaction: typeof tx) => unknown) =>
- operation(tx),
+ transaction: vi.fn(
+ async (operation: (transaction: typeof tx) => unknown) => {
+ transactionState.active = true;
+ try {
+ return await operation(tx);
+ } finally {
+ transactionState.active = false;
+ }
+ },
),
select: vi.fn(() => query([])),
},
@@ -65,11 +76,17 @@ const mocks = vi.hoisted(() => {
vi.mock("@/db", () => ({ db: mocks.db }));
vi.mock("@/db/queries", () => ({ getSetting: mocks.getSetting }));
+vi.mock("@/lib/github", () => ({
+ createGitHubDeployment: vi.fn(),
+ findGitHubDeployment: vi.fn(),
+ updateGitHubDeploymentStatus: mocks.updateGitHubDeploymentStatus,
+}));
import {
createPreviewClone,
ensurePreviewEnvironment,
previewPortConfiguration,
+ updatePreviewGitHubStatus,
} from "@/lib/preview-deployments";
const baseService = {
@@ -178,6 +195,7 @@ describe("preview service cloning", () => {
mocks.insertedValues.length = 0;
mocks.updatedValues.length = 0;
mocks.getSetting.mockResolvedValue("apps.example.com");
+ mocks.updateGitHubDeploymentStatus.mockResolvedValue(undefined);
process.env.REGISTRY_HOST = "registry.example.com";
});
@@ -331,4 +349,35 @@ describe("preview service cloning", () => {
serverlessEnabled: false,
});
});
+
+ it("holds the service lock while publishing the current GitHub status", async () => {
+ mocks.selectResults.push(
+ [{ previewOfService: baseService.id }],
+ [{ id: "revision-1" }],
+ [{ id: 303 }],
+ [{ installationId: 101, repoFullName: "acme/app" }],
+ [{ id: "port-1", port: 3000, domain: "preview.apps.example.com" }],
+ );
+ mocks.updateGitHubDeploymentStatus.mockImplementation(async () => {
+ expect(mocks.transactionState.active).toBe(true);
+ });
+
+ await expect(
+ updatePreviewGitHubStatus({
+ serviceId: "preview-service",
+ serviceRevisionId: "revision-1",
+ state: "success",
+ description: "Preview is ready",
+ }),
+ ).resolves.toBe(true);
+ expect(mocks.updateGitHubDeploymentStatus).toHaveBeenCalledWith(
+ 101,
+ "acme/app",
+ 303,
+ "success",
+ expect.objectContaining({
+ environmentUrl: "https://preview.apps.example.com",
+ }),
+ );
+ });
});
diff --git a/web/tests/preview-workflow.test.ts b/web/tests/preview-workflow.test.ts
index c46782eb..8fd117ae 100644
--- a/web/tests/preview-workflow.test.ts
+++ b/web/tests/preview-workflow.test.ts
@@ -27,7 +27,6 @@ const mocks = vi.hoisted(() => {
createPreviewClone: vi.fn(),
inactivatePreviewGitHubDeployments: vi.fn(),
cancelPreviewRevisionWork: vi.fn(),
- deactivatePreviewRuntime: vi.fn(),
deletePreviewService: vi.fn(),
triggerResolvedBuildInternal: vi.fn(),
parseServiceRevisionSpec: vi.fn(),
@@ -52,7 +51,6 @@ vi.mock("@/lib/preview-deployments", () => ({
}));
vi.mock("@/lib/preview-lifecycle", () => ({
cancelPreviewRevisionWork: mocks.cancelPreviewRevisionWork,
- deactivatePreviewRuntime: mocks.deactivatePreviewRuntime,
deletePreviewService: mocks.deletePreviewService,
}));
vi.mock("@/lib/service-revision-changes", () => ({
@@ -144,7 +142,6 @@ describe("preview lifecycle workflows", () => {
});
mocks.inactivatePreviewGitHubDeployments.mockResolvedValue(1);
mocks.cancelPreviewRevisionWork.mockResolvedValue(undefined);
- mocks.deactivatePreviewRuntime.mockResolvedValue(undefined);
mocks.send.mockResolvedValue(undefined);
});
@@ -191,7 +188,7 @@ describe("preview lifecycle workflows", () => {
});
});
- it("deactivates the preview when GitHub has no merge ref", async () => {
+ it("deletes the preview when GitHub has no merge ref", async () => {
mocks.selectResults.push(
[baseContext],
[{ previewOfService: "base-service" }],
@@ -210,8 +207,10 @@ describe("preview lifecycle workflows", () => {
status: "failed",
reason: "merge_ref_unavailable",
});
- expect(mocks.deactivatePreviewRuntime).toHaveBeenCalledWith(
- "preview-service",
+ expect(mocks.deletePreviewService).toHaveBeenCalledWith(
+ "base-service",
+ "refs/pull/42/merge",
+ "merge ref is unavailable",
);
expect(mocks.triggerResolvedBuildInternal).not.toHaveBeenCalled();
});
From c091cc6954d11b2d82ae7dc72eefcfe2240e3879 Mon Sep 17 00:00:00 2001
From: Amp
Date: Tue, 18 Aug 2026 09:27:00 +0000
Subject: [PATCH 5/5] Harden preview deployment cleanup
Amp-Thread-ID: https://ampcode.com/threads/T-01a003f3-7142-74cd-b819-95472f4a6376
Co-authored-by: Arjun Komath
---
web/lib/github.ts | 18 ++-
web/lib/inngest/functions/preview-workflow.ts | 9 +-
web/lib/preview-lifecycle.ts | 42 ++-----
web/tests/github.test.ts | 17 +++
web/tests/preview-lifecycle.test.ts | 116 ++++++++++++++++++
web/tests/preview-workflow.test.ts | 68 ++++++++++
6 files changed, 235 insertions(+), 35 deletions(-)
create mode 100644 web/tests/preview-lifecycle.test.ts
diff --git a/web/lib/github.ts b/web/lib/github.ts
index 5cb2b896..70d92909 100644
--- a/web/lib/github.ts
+++ b/web/lib/github.ts
@@ -2,6 +2,16 @@ import { createHmac, createPrivateKey, timingSafeEqual } from "node:crypto";
import { SignJWT } from "jose";
import { pullRequestMergeRef } from "@/lib/service-revision-spec";
+export class GitHubApiError extends Error {
+ constructor(
+ message: string,
+ public readonly status: number,
+ ) {
+ super(message);
+ this.name = "GitHubApiError";
+ }
+}
+
function getAppId(): string {
const appId = process.env.GITHUB_APP_ID;
if (!appId) {
@@ -80,7 +90,10 @@ export async function getInstallationToken(
if (!response.ok) {
const error = await response.text();
- throw new Error(`Failed to get installation token: ${error}`);
+ throw new GitHubApiError(
+ `Failed to get installation token: ${error}`,
+ response.status,
+ );
}
const data = await response.json();
@@ -324,8 +337,9 @@ async function githubPullRequestRequest(
);
if (!response.ok) {
const detail = await response.text();
- throw new Error(
+ throw new GitHubApiError(
`GitHub pull request failed (${response.status}): ${detail || response.statusText}`,
+ response.status,
);
}
return response.json() as Promise;
diff --git a/web/lib/inngest/functions/preview-workflow.ts b/web/lib/inngest/functions/preview-workflow.ts
index 80d101cb..fc800c30 100644
--- a/web/lib/inngest/functions/preview-workflow.ts
+++ b/web/lib/inngest/functions/preview-workflow.ts
@@ -2,6 +2,7 @@ import { and, desc, eq, isNull } from "drizzle-orm";
import { db } from "@/db";
import { githubRepos, serviceRevisions, services } from "@/db/schema";
import {
+ GitHubApiError,
getGitHubPullRequest,
listOpenGitHubPullRequests,
resolveGitHubPullRequestMergeRef,
@@ -120,8 +121,12 @@ async function closePreviewFromEvent(input: {
previewContext.githubRepo.installationId,
previewContext.githubRepo.repoFullName,
pullRequestNumber,
- );
- if (isEligiblePullRequest(baseContext, pullRequest)) {
+ ).catch((error: unknown) => {
+ if (error instanceof GitHubApiError && error.status === 404)
+ return null;
+ throw error;
+ });
+ if (pullRequest && isEligiblePullRequest(baseContext, pullRequest)) {
await enqueuePreviewSync(
input.baseServiceId,
input.previewGitRef,
diff --git a/web/lib/preview-lifecycle.ts b/web/lib/preview-lifecycle.ts
index f7fc10e7..22528603 100644
--- a/web/lib/preview-lifecycle.ts
+++ b/web/lib/preview-lifecycle.ts
@@ -137,33 +137,6 @@ export async function cancelPreviewRevisionWork(
]);
}
-export async function deactivatePreviewRuntime(serviceId: string) {
- await Promise.all([cancelBuildRows(serviceId), cancelRolloutRows(serviceId)]);
- const runtime = await db
- .select()
- .from(deployments)
- .where(eq(deployments.serviceId, serviceId));
- await db
- .update(deployments)
- .set(markDeploymentRemoved())
- .where(eq(deployments.serviceId, serviceId));
- await Promise.all(
- runtime.flatMap((deployment) =>
- deployment.containerId
- ? [
- enqueueWork(deployment.serverId, "stop", {
- deploymentId: deployment.id,
- containerId: deployment.containerId,
- }),
- ]
- : [],
- ),
- );
- await db.transaction((tx) =>
- enqueueReconcileForAllOnlineServers("preview_runtime_deactivated", tx),
- );
-}
-
export async function deletePreviewService(
baseServiceId: string,
previewGitRef: string,
@@ -245,10 +218,17 @@ export async function deletePreviewService(
);
await cleanupRegistryArtifactsForService(claimed.service.id);
if (options.reportGitHubDeployment !== false) {
- await inactivatePreviewGitHubDeployments({
- serviceId: claimed.service.id,
- description: `Preview removed: ${reason}`,
- });
+ try {
+ await inactivatePreviewGitHubDeployments({
+ serviceId: claimed.service.id,
+ description: `Preview removed: ${reason}`,
+ });
+ } catch (error) {
+ console.error(
+ `[preview-lifecycle] failed to inactivate GitHub deployments for ${claimed.service.id}:`,
+ error,
+ );
+ }
}
await db.delete(services).where(eq(services.id, claimed.service.id));
return claimed;
diff --git a/web/tests/github.test.ts b/web/tests/github.test.ts
index 57056e22..47b793d2 100644
--- a/web/tests/github.test.ts
+++ b/web/tests/github.test.ts
@@ -2,6 +2,8 @@ import { generateKeyPairSync } from "node:crypto";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
findGitHubDeployment,
+ getGitHubPullRequest,
+ GitHubApiError,
isFullCommitSha,
resolveGitHubCommit,
resolveGitHubPullRequestMergeRef,
@@ -82,6 +84,21 @@ describe("public GitHub branch resolution", () => {
});
describe("GitHub pull request deployment helpers", () => {
+ it("exposes pull request response status for lifecycle decisions", async () => {
+ configureGitHubApp();
+ vi.stubGlobal(
+ "fetch",
+ vi
+ .fn()
+ .mockResolvedValueOnce(Response.json({ token: "installation-token" }))
+ .mockResolvedValueOnce(new Response("Not Found", { status: 404 })),
+ );
+
+ const request = getGitHubPullRequest(10, "acme/app", 42);
+ await expect(request).rejects.toBeInstanceOf(GitHubApiError);
+ await expect(request).rejects.toMatchObject({ status: 404 });
+ });
+
it("finds a deployment created for the same preview revision", async () => {
configureGitHubApp();
const fetchMock = vi.fn(
diff --git a/web/tests/preview-lifecycle.test.ts b/web/tests/preview-lifecycle.test.ts
new file mode 100644
index 00000000..a7ceaaa0
--- /dev/null
+++ b/web/tests/preview-lifecycle.test.ts
@@ -0,0 +1,116 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { services } from "@/db/schema";
+
+const mocks = vi.hoisted(() => {
+ function query(result: unknown[]) {
+ const value = {
+ from: vi.fn(() => value),
+ where: vi.fn(() => value),
+ // oxlint-disable-next-line unicorn/no-thenable -- Drizzle query builders are awaitable.
+ then: (
+ resolve: (rows: unknown[]) => unknown,
+ reject?: (reason: unknown) => unknown,
+ ) => Promise.resolve(result).then(resolve, reject),
+ };
+ return value;
+ }
+ function mutation(returning: unknown[] = []) {
+ const value = {
+ set: vi.fn(() => value),
+ where: vi.fn(() => value),
+ returning: vi.fn(() => Promise.resolve(returning)),
+ // oxlint-disable-next-line unicorn/no-thenable -- Drizzle query builders are awaitable.
+ then: (
+ resolve: (result: undefined) => unknown,
+ reject?: (reason: unknown) => unknown,
+ ) => Promise.resolve(undefined).then(resolve, reject),
+ };
+ return value;
+ }
+
+ const txSelectResults: unknown[][] = [];
+ const dbSelectResults: unknown[][] = [];
+ const tx = {
+ execute: vi.fn().mockResolvedValue(undefined),
+ select: vi.fn(() => query(txSelectResults.shift() ?? [])),
+ update: vi.fn(() => mutation()),
+ };
+ const db = {
+ transaction: vi.fn((operation: (transaction: typeof tx) => unknown) =>
+ operation(tx),
+ ),
+ select: vi.fn(() => query(dbSelectResults.shift() ?? [])),
+ update: vi.fn(() => mutation()),
+ delete: vi.fn(() => ({ where: vi.fn().mockResolvedValue(undefined) })),
+ };
+ return {
+ txSelectResults,
+ dbSelectResults,
+ db,
+ prepareRegistryArtifactCleanup: vi.fn(),
+ cleanupRegistryArtifactsForService: vi.fn(),
+ inactivatePreviewGitHubDeployments: vi.fn(),
+ enqueueReconcileForAllOnlineServers: vi.fn(),
+ };
+});
+
+vi.mock("@/db", () => ({ db: mocks.db }));
+vi.mock("@/lib/inngest/client", () => ({
+ inngest: { send: vi.fn().mockResolvedValue(undefined) },
+}));
+vi.mock("@/lib/inngest/events", () => ({
+ inngestEvents: {
+ buildCancelled: { create: vi.fn() },
+ rolloutCancelled: { create: vi.fn() },
+ },
+}));
+vi.mock("@/lib/preview-deployments", () => ({
+ inactivatePreviewGitHubDeployments: mocks.inactivatePreviewGitHubDeployments,
+}));
+vi.mock("@/lib/registry-retention", () => ({
+ prepareRegistryArtifactCleanup: mocks.prepareRegistryArtifactCleanup,
+ cleanupRegistryArtifactsForService: mocks.cleanupRegistryArtifactsForService,
+}));
+vi.mock("@/lib/work-queue", () => ({
+ enqueueReconcileForAllOnlineServers:
+ mocks.enqueueReconcileForAllOnlineServers,
+ enqueueWork: vi.fn(),
+}));
+
+import { deletePreviewService } from "@/lib/preview-lifecycle";
+
+describe("preview deletion", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.txSelectResults.length = 0;
+ mocks.dbSelectResults.length = 0;
+ mocks.prepareRegistryArtifactCleanup.mockResolvedValue(true);
+ mocks.cleanupRegistryArtifactsForService.mockResolvedValue(undefined);
+ });
+ afterEach(() => vi.restoreAllMocks());
+
+ it("hard-deletes the service when GitHub inactivation fails", async () => {
+ mocks.txSelectResults.push([{ service: { id: "preview-service" } }]);
+ mocks.dbSelectResults.push([]);
+ mocks.inactivatePreviewGitHubDeployments.mockRejectedValue(
+ new Error("GitHub unavailable"),
+ );
+ const consoleError = vi
+ .spyOn(console, "error")
+ .mockImplementation(() => {});
+
+ await expect(
+ deletePreviewService(
+ "base-service",
+ "refs/pull/42/merge",
+ "pull request closed",
+ ),
+ ).resolves.toMatchObject({ service: { id: "preview-service" } });
+ expect(mocks.db.delete).toHaveBeenCalledTimes(2);
+ expect(mocks.db.delete).toHaveBeenLastCalledWith(services);
+ expect(consoleError).toHaveBeenCalledWith(
+ "[preview-lifecycle] failed to inactivate GitHub deployments for preview-service:",
+ expect.any(Error),
+ );
+ });
+});
diff --git a/web/tests/preview-workflow.test.ts b/web/tests/preview-workflow.test.ts
index 8fd117ae..859c36a8 100644
--- a/web/tests/preview-workflow.test.ts
+++ b/web/tests/preview-workflow.test.ts
@@ -1,6 +1,14 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => {
+ class GitHubApiError extends Error {
+ constructor(
+ message: string,
+ public readonly status: number,
+ ) {
+ super(message);
+ }
+ }
const selectResults: unknown[][] = [];
function query(result: unknown[]) {
const value = {
@@ -19,6 +27,7 @@ const mocks = vi.hoisted(() => {
}
const db = { select: vi.fn(() => query(selectResults.shift() ?? [])) };
return {
+ GitHubApiError,
selectResults,
db,
getGitHubPullRequest: vi.fn(),
@@ -41,6 +50,7 @@ const mocks = vi.hoisted(() => {
vi.mock("@/db", () => ({ db: mocks.db }));
vi.mock("@/lib/github", () => ({
+ GitHubApiError: mocks.GitHubApiError,
getGitHubPullRequest: mocks.getGitHubPullRequest,
listOpenGitHubPullRequests: mocks.listOpenGitHubPullRequests,
resolveGitHubPullRequestMergeRef: mocks.resolveGitHubPullRequestMergeRef,
@@ -233,4 +243,62 @@ describe("preview lifecycle workflows", () => {
"pull_request_closed",
);
});
+
+ it("deletes the preview when close verification returns not found", async () => {
+ mocks.selectResults.push(
+ [baseContext],
+ [
+ {
+ service: { id: "preview-service" },
+ githubRepo: baseContext.githubRepo,
+ },
+ ],
+ );
+ mocks.getGitHubPullRequest.mockRejectedValue(
+ new mocks.GitHubApiError("Pull request not found", 404),
+ );
+
+ await expect(
+ invoke(
+ previewSyncWorkflow,
+ {
+ baseServiceId: "base-service",
+ previewGitRef: "refs/pull/42/merge",
+ reason: "pull_request_closed",
+ verifyWithGitHub: true,
+ },
+ "preview/close-requested",
+ ),
+ ).resolves.toEqual({ status: "deleted", serviceId: "preview-service" });
+ expect(mocks.deletePreviewService).toHaveBeenCalled();
+ });
+
+ it("retries close verification after a transient GitHub failure", async () => {
+ mocks.selectResults.push(
+ [baseContext],
+ [
+ {
+ service: { id: "preview-service" },
+ githubRepo: baseContext.githubRepo,
+ },
+ ],
+ );
+ mocks.getGitHubPullRequest.mockRejectedValue(
+ new mocks.GitHubApiError("GitHub unavailable", 503),
+ );
+
+ await expect(
+ invoke(
+ previewSyncWorkflow,
+ {
+ baseServiceId: "base-service",
+ previewGitRef: "refs/pull/42/merge",
+ reason: "pull_request_closed",
+ verifyWithGitHub: true,
+ },
+ "preview/close-requested",
+ ),
+ ).rejects.toThrow("GitHub unavailable");
+ expect(mocks.deletePreviewService).not.toHaveBeenCalled();
+ });
});