Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions agent/internal/agent/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
54 changes: 53 additions & 1 deletion agent/internal/build/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ type Config struct {
CloneURL string
CommitSha string
Branch string
GitRef string
ImageRepository string
ImageURI string
ResolvedCommitSha string
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -153,6 +156,9 @@ 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 pullRequestMergeRefPattern.MatchString(config.GitRef) {
return b.clonePullRequestRef(ctx, config, buildDir)
}

branch := config.Branch
if branch == "" {
Expand Down Expand Up @@ -209,6 +215,52 @@ func (b *Builder) clone(ctx context.Context, config *Config, buildDir string) er
return nil
}

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")
}
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 pull request 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)
}
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) {
cmd := exec.CommandContext(ctx, "git", "-C", buildDir, "rev-parse", "HEAD")
output, err := b.runCommand(cmd, config)
Expand Down Expand Up @@ -462,7 +514,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")
Expand Down
74 changes: 71 additions & 3 deletions agent/internal/build/build_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,39 @@ func TestCleanupStaleBuildDirsRemovesOnlyOldDirectories(t *testing.T) {
assertExists(t, filePath)
}

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")
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{
BuildID: "build-1",
CloneURL: "file://" + remoteDir,
CommitSha: selectedSHA,
Branch: "main",
GitRef: "refs/pull/42/merge",
}
builder := NewBuilder(t.TempDir(), nil)

if err := builder.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 TestCloneDeepensConfiguredBranchForSelectedCommit(t *testing.T) {
workDir := filepath.Join(t.TempDir(), "work")
remoteDir := filepath.Join(t.TempDir(), "remote.git")
Expand Down Expand Up @@ -109,16 +142,51 @@ func TestCloneDeepensConfiguredBranchForSelectedCommit(t *testing.T) {
CommitSha: selectedSHA,
Branch: "main",
}
builder := NewBuilder(t.TempDir(), nil)

if err := builder.clone(context.Background(), config, buildDir); err != nil {
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")
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 TestResolveBuildContext(t *testing.T) {
buildDir := t.TempDir()
nestedDir := filepath.Join(buildDir, "services", "api")
Expand Down
1 change: 1 addition & 0 deletions agent/internal/http/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
20 changes: 20 additions & 0 deletions docs/architecture.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,26 @@ 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 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. 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

### IP Address Scheme
Expand Down
49 changes: 49 additions & 0 deletions docs/deployments/github.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -40,6 +48,47 @@ 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 **Configuration**
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:

- 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/<number>/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 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,
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
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:
Expand Down
5 changes: 5 additions & 0 deletions docs/installation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 previews use the Automatic Subdomain Domain setting and its wildcard DNS
record when configured. Without it, previews are created without public URLs.

## Generating Secrets

```bash
Expand Down
22 changes: 16 additions & 6 deletions web/actions/builds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ 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)));

Expand All @@ -96,16 +96,21 @@ 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 };
Expand Down Expand Up @@ -144,6 +149,11 @@ export async function triggerManualBuild(serviceId: string, commitSha: string) {
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";
Expand Down
Loading
Loading