Skip to content

Commit 02db10c

Browse files
authored
Merge pull request #294 from techulus/automation/release/v0.58.0
Release v0.58.0
2 parents c251416 + ca5ad17 commit 02db10c

50 files changed

Lines changed: 4735 additions & 168 deletions

Some content is hidden

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

‎agent/internal/agent/handlers.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,7 @@ func (a *Agent) ProcessBuild(item agenthttp.WorkQueueItem) error {
199199
CloneURL: buildDetails.CloneURL,
200200
CommitSha: buildDetails.Build.CommitSha,
201201
Branch: buildDetails.Build.Branch,
202+
GitRef: buildDetails.Build.GitRef,
202203
ImageRepository: buildDetails.ImageRepository,
203204
ImageURI: buildDetails.ImageURI,
204205
ServiceID: buildDetails.Build.ServiceID,

‎agent/internal/build/build.go‎

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ type Config struct {
2727
CloneURL string
2828
CommitSha string
2929
Branch string
30+
GitRef string
3031
ImageRepository string
3132
ImageURI string
3233
ResolvedCommitSha string
@@ -63,6 +64,8 @@ type dockerfileConfig struct {
6364
var managedTempArtifactPattern = regexp.MustCompile(`^(backup|restore)-[0-9a-fA-F-]{36}\.tar\.gz$|^restore-extract-[0-9a-fA-F-]{36}$`)
6465
var windowsAbsoluteRootPattern = regexp.MustCompile(`^[A-Za-z]:[\\/]`)
6566
var imageDigestPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
67+
var credentialURLPattern = regexp.MustCompile(`(?i)https?://[^\s/@]+(?::[^\s/@]*)?@`)
68+
var pullRequestMergeRefPattern = regexp.MustCompile(`^refs/pull/[1-9][0-9]*/merge$`)
6669

6770
func NewBuilder(dataDir string, logSender LogSender) *Builder {
6871
return &Builder{
@@ -153,6 +156,9 @@ func (b *Builder) clone(ctx context.Context, config *Config, buildDir string) er
153156
safeURL = "https://***@" + safeURL[idx+1:]
154157
}
155158
b.sendLog(config, fmt.Sprintf("Cloning %s", safeURL))
159+
if pullRequestMergeRefPattern.MatchString(config.GitRef) {
160+
return b.clonePullRequestRef(ctx, config, buildDir)
161+
}
156162

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

218+
func (b *Builder) clonePullRequestRef(ctx context.Context, config *Config, buildDir string) error {
219+
if matched, _ := regexp.MatchString(`^[0-9a-fA-F]{40}$`, config.CommitSha); !matched {
220+
return fmt.Errorf("invalid exact commit SHA")
221+
}
222+
cmd := exec.CommandContext(ctx, "git", "init", buildDir)
223+
output, err := b.runCommand(cmd, config)
224+
if err != nil {
225+
return fmt.Errorf("git init failed: %s: %w", output, err)
226+
}
227+
cmd = exec.CommandContext(ctx, "git", "-C", buildDir, "remote", "add", "origin", config.CloneURL)
228+
output, err = b.runCommand(cmd, config)
229+
if err != nil {
230+
return fmt.Errorf("git remote setup failed: %s: %w", output, err)
231+
}
232+
cmd = exec.CommandContext(ctx, "git", "-C", buildDir, "fetch", "--depth", "1", "--no-tags", "origin", config.GitRef)
233+
output, err = b.runCommand(cmd, config)
234+
if err != nil {
235+
return fmt.Errorf("git fetch pull request ref failed: %s: %w", output, err)
236+
}
237+
cmd = exec.CommandContext(ctx, "git", "-C", buildDir, "rev-parse", "FETCH_HEAD")
238+
fetchedCommit, err := b.runCommand(cmd, config)
239+
if err != nil {
240+
return fmt.Errorf("git resolve fetched ref failed: %s: %w", fetchedCommit, err)
241+
}
242+
if !strings.EqualFold(strings.TrimSpace(fetchedCommit), config.CommitSha) {
243+
return fmt.Errorf("fetched ref resolved to %s, expected %s", strings.TrimSpace(fetchedCommit), config.CommitSha)
244+
}
245+
b.sendLog(config, fmt.Sprintf("Checking out commit %s", truncateStr(config.CommitSha, 8)))
246+
cmd = exec.CommandContext(ctx, "git", "-C", buildDir, "checkout", "--detach", config.CommitSha)
247+
output, err = b.runCommand(cmd, config)
248+
if err != nil {
249+
return fmt.Errorf("git checkout failed: %s: %w", output, err)
250+
}
251+
b.sendLog(config, "Clone completed")
252+
resolvedCommitSha, err := b.resolveCommitSha(ctx, config, buildDir)
253+
if err != nil {
254+
return err
255+
}
256+
if !strings.EqualFold(resolvedCommitSha, config.CommitSha) {
257+
return fmt.Errorf("checked out commit %s, expected %s", resolvedCommitSha, config.CommitSha)
258+
}
259+
config.ResolvedCommitSha = resolvedCommitSha
260+
b.sendLog(config, fmt.Sprintf("Resolved commit %s", truncateStr(resolvedCommitSha, 8)))
261+
return nil
262+
}
263+
212264
func (b *Builder) resolveCommitSha(ctx context.Context, config *Config, buildDir string) (string, error) {
213265
cmd := exec.CommandContext(ctx, "git", "-C", buildDir, "rev-parse", "HEAD")
214266
output, err := b.runCommand(cmd, config)
@@ -462,7 +514,7 @@ func resolveDockerfile(contextDir string, secrets map[string]string) (dockerfile
462514

463515
func (b *Builder) runCommand(cmd *exec.Cmd, config *Config) (string, error) {
464516
output, err := cmd.CombinedOutput()
465-
outputStr := string(output)
517+
outputStr := credentialURLPattern.ReplaceAllString(string(output), "https://***@")
466518

467519
if len(outputStr) > 0 {
468520
lines := strings.Split(strings.TrimSpace(outputStr), "\n")

‎agent/internal/build/build_test.go‎

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,39 @@ func TestCleanupStaleBuildDirsRemovesOnlyOldDirectories(t *testing.T) {
8181
assertExists(t, filePath)
8282
}
8383

84+
func TestCloneFetchesExactPullRequestMergeRef(t *testing.T) {
85+
workDir := filepath.Join(t.TempDir(), "work")
86+
remoteDir := filepath.Join(t.TempDir(), "remote.git")
87+
runGit(t, "init", "--initial-branch", "main", workDir)
88+
runGit(t, "-C", workDir, "config", "user.name", "Test User")
89+
runGit(t, "-C", workDir, "config", "user.email", "test@example.com")
90+
if err := os.WriteFile(filepath.Join(workDir, "app.txt"), []byte("preview"), 0600); err != nil {
91+
t.Fatal(err)
92+
}
93+
runGit(t, "-C", workDir, "add", "app.txt")
94+
runGit(t, "-C", workDir, "commit", "-m", "preview merge")
95+
selectedSHA := runGit(t, "-C", workDir, "rev-parse", "HEAD")
96+
runGit(t, "clone", "--bare", workDir, remoteDir)
97+
runGit(t, "--git-dir", remoteDir, "update-ref", "refs/pull/42/merge", selectedSHA)
98+
99+
buildDir := filepath.Join(t.TempDir(), "build")
100+
config := &Config{
101+
BuildID: "build-1",
102+
CloneURL: "file://" + remoteDir,
103+
CommitSha: selectedSHA,
104+
Branch: "main",
105+
GitRef: "refs/pull/42/merge",
106+
}
107+
builder := NewBuilder(t.TempDir(), nil)
108+
109+
if err := builder.clone(context.Background(), config, buildDir); err != nil {
110+
t.Fatal(err)
111+
}
112+
if config.ResolvedCommitSha != selectedSHA {
113+
t.Fatalf("resolved commit = %s, want %s", config.ResolvedCommitSha, selectedSHA)
114+
}
115+
}
116+
84117
func TestCloneDeepensConfiguredBranchForSelectedCommit(t *testing.T) {
85118
workDir := filepath.Join(t.TempDir(), "work")
86119
remoteDir := filepath.Join(t.TempDir(), "remote.git")
@@ -109,16 +142,51 @@ func TestCloneDeepensConfiguredBranchForSelectedCommit(t *testing.T) {
109142
CommitSha: selectedSHA,
110143
Branch: "main",
111144
}
112-
builder := NewBuilder(t.TempDir(), nil)
113-
114-
if err := builder.clone(context.Background(), config, buildDir); err != nil {
145+
if err := NewBuilder(t.TempDir(), nil).clone(context.Background(), config, buildDir); err != nil {
115146
t.Fatal(err)
116147
}
117148
if config.ResolvedCommitSha != selectedSHA {
118149
t.Fatalf("resolved commit = %s, want %s", config.ResolvedCommitSha, selectedSHA)
119150
}
120151
}
121152

153+
func TestCloneRejectsMovedRef(t *testing.T) {
154+
workDir := filepath.Join(t.TempDir(), "work")
155+
remoteDir := filepath.Join(t.TempDir(), "remote.git")
156+
runGit(t, "init", "--initial-branch", "main", workDir)
157+
runGit(t, "-C", workDir, "config", "user.name", "Test User")
158+
runGit(t, "-C", workDir, "config", "user.email", "test@example.com")
159+
if err := os.WriteFile(filepath.Join(workDir, "app.txt"), []byte("first"), 0600); err != nil {
160+
t.Fatal(err)
161+
}
162+
runGit(t, "-C", workDir, "add", "app.txt")
163+
runGit(t, "-C", workDir, "commit", "-m", "first")
164+
expectedSHA := runGit(t, "-C", workDir, "rev-parse", "HEAD")
165+
if err := os.WriteFile(filepath.Join(workDir, "app.txt"), []byte("second"), 0600); err != nil {
166+
t.Fatal(err)
167+
}
168+
runGit(t, "-C", workDir, "commit", "-am", "second")
169+
movedSHA := runGit(t, "-C", workDir, "rev-parse", "HEAD")
170+
runGit(t, "clone", "--bare", workDir, remoteDir)
171+
runGit(t, "--git-dir", remoteDir, "update-ref", "refs/pull/42/merge", movedSHA)
172+
173+
config := &Config{
174+
BuildID: "build-1",
175+
CloneURL: "file://" + remoteDir,
176+
CommitSha: expectedSHA,
177+
Branch: "main",
178+
GitRef: "refs/pull/42/merge",
179+
}
180+
err := NewBuilder(t.TempDir(), nil).clone(
181+
context.Background(),
182+
config,
183+
filepath.Join(t.TempDir(), "build"),
184+
)
185+
if err == nil || !strings.Contains(err.Error(), "fetched ref resolved to") {
186+
t.Fatalf("clone error = %v, want moved ref failure", err)
187+
}
188+
}
189+
122190
func TestResolveBuildContext(t *testing.T) {
123191
buildDir := t.TempDir()
124192
nestedDir := filepath.Join(buildDir, "services", "api")

‎agent/internal/http/client.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,7 @@ type BuildDetails struct {
398398
CommitSha string `json:"commitSha"`
399399
CommitMessage string `json:"commitMessage"`
400400
Branch string `json:"branch"`
401+
GitRef string `json:"gitRef"`
401402
ServiceID string `json:"serviceId"`
402403
ProjectID string `json:"projectId"`
403404
} `json:"build"`

‎docs/architecture.mdx‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,26 @@ For each rollout, the control plane freezes the required target set immediately
135135

136136
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.
137137

138+
## Pull Request Preview Isolation
139+
140+
An enabled GitHub service represents each eligible pull request as an ordinary,
141+
visible stateless service in the project's `previews` environment. An existing
142+
environment with that name is reused; otherwise the control plane creates it.
143+
The environment remains after its last preview closes and may also contain
144+
unrelated user-created services.
145+
146+
Each copy has an independent service ID, revision, build, rollout, deployment
147+
set, registry path, and generated route while reusing the normal runtime
148+
pipeline. It is copied from the base service only when first created, so later
149+
user edits to the visible preview service survive pull request updates.
150+
151+
The control plane resolves the exact synthetic merge ref and queues an ordinary
152+
build. The latest revision on the copy prevents superseded build and rollout
153+
callbacks from deploying or reporting success. GitHub reports the transient
154+
environment as ready only after health and routing convergence complete.
155+
Closing, merging, or drafting the pull request deletes the copied service after
156+
runtime and registry cleanup.
157+
138158
## Networking
139159

140160
### IP Address Scheme

‎docs/deployments/github.mdx‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,16 @@ Techulus Cloud integrates with GitHub through a [GitHub App](https://docs.github
1818

1919
3. Install the GitHub App on your GitHub account or organization.
2020

21+
The GitHub App needs these repository permissions:
22+
23+
- **Contents:** Read
24+
- **Pull requests:** Read and write
25+
- **Deployments:** Read and write
26+
27+
Subscribe the app to both the **Push** and **Pull request** webhook events.
28+
If you increase permissions for an existing app, approve the new permissions on
29+
each installation before enabling previews.
30+
2131
## Connecting a Repository
2232

2333
Once the GitHub App is installed, connect a repository to a service:
@@ -40,6 +50,51 @@ The flow:
4050

4151
GitHub deployment statuses are updated on the commit so you can track progress from pull requests.
4252

53+
## Pull Request Preview Deployments
54+
55+
Preview deployments are opt in from a GitHub-backed service's **Configuration**
56+
page. Each eligible pull request gets one visible service in the project's
57+
ordinary `previews` environment. An existing environment named `previews` is
58+
reused and is left in place when previews close. If you configure an
59+
**Automatic Subdomain Domain** and its wildcard DNS record, each preview also
60+
gets a stable generated HTTPS URL beneath that domain. Without this setting,
61+
the preview is still created without a public URL.
62+
63+
A pull request is eligible only when it:
64+
65+
- comes from the same repository as the base branch (forks are skipped),
66+
- targets the service's configured deployment branch,
67+
- is open and ready for review (drafts are skipped), and
68+
- belongs to a stateless service.
69+
70+
The preview builds GitHub's synthetic merge result at
71+
`refs/pull/<number>/merge`. This tests the change as it would merge into the
72+
configured branch. If GitHub cannot produce that ref because of merge
73+
conflicts, the preview service is removed rather than building the raw pull
74+
request head. Reconciliation recreates it after GitHub can produce the merge
75+
ref again.
76+
77+
When first created, preview services inherit the base service's current source
78+
configuration, replicas, autoscaling, placement, health check, start command,
79+
resource limits, ports, serverless settings, and complete secret set. They do
80+
not copy volumes, backups, deployment schedules, cron jobs, production custom
81+
domains, or public TCP/UDP routes. Serverless mode is disabled when no generated
82+
public domain is available. Preview services use the normal service pages and
83+
may be edited like other services. Their source repository remains tied to the
84+
pull request, and volumes remain unavailable. Later pull request updates
85+
preserve other edits.
86+
87+
New commits replace the preview revision without changing its URL. Converting
88+
the pull request to a draft, closing it, or merging it removes the runtime and
89+
route and marks the GitHub deployment inactive. A daily reconciliation job
90+
rechecks previews in case a webhook was missed. Generated hosts use the normal
91+
HTTP-01 certificate path, so installations with high pull-request volume should
92+
monitor their certificate authority's issuance limits.
93+
94+
Each preview service maintains one status comment on the pull request. The
95+
comment shows the current deployment status and preview URL. Status and commit
96+
updates replace the same comment instead of adding new comments.
97+
4398
## Build Process
4499

45100
Agents build images using one of two methods:

‎docs/installation.mdx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,12 @@ migrations from every replica.
297297
| `GITHUB_APP_PRIVATE_KEY` | GitHub App private key (base64-encoded) |
298298
| `GITHUB_WEBHOOK_SECRET` | Webhook secret |
299299

300+
Configure the app with Contents read, Pull requests read and write, and
301+
Deployments read and write repository permissions. Subscribe it to Push and
302+
Pull request events. Pull request previews use the Automatic Subdomain Domain
303+
setting and its wildcard DNS record when configured. Without it, previews are
304+
created without public URLs.
305+
300306
## Generating Secrets
301307

302308
```bash

‎web/actions/builds.ts‎

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ export async function retryBuild(buildId: string) {
8484
}
8585

8686
const [service] = await db
87-
.select({ id: services.id })
87+
.select({ id: services.id, previewOfService: services.previewOfService })
8888
.from(services)
8989
.where(and(eq(services.id, build.serviceId), isNull(services.deletedAt)));
9090

@@ -96,16 +96,21 @@ export async function retryBuild(buildId: string) {
9696
throw new Error(`Cannot retry build in ${build.status} status`);
9797
}
9898

99+
const actor = {
100+
type: "user" as const,
101+
userId: session.user.id,
102+
name: session.user.name,
103+
};
104+
if (service.previewOfService) {
105+
await triggerBuildInternal(build.serviceId, "manual", actor);
106+
return { success: true };
107+
}
99108
await requeueBuildRevisionInternal({
100109
serviceId: build.serviceId,
101110
serviceRevisionId: build.serviceRevisionId,
102111
commitMessage: build.commitMessage ?? "Retry build",
103112
author: build.author ?? undefined,
104-
actor: {
105-
type: "user",
106-
userId: session.user.id,
107-
name: session.user.name,
108-
},
113+
actor,
109114
});
110115

111116
return { success: true };
@@ -144,6 +149,11 @@ export async function triggerManualBuild(serviceId: string, commitSha: string) {
144149
if (result.service.sourceType !== "github") {
145150
throw new Error("Service is not connected to GitHub");
146151
}
152+
if (result.service.previewOfService) {
153+
throw new Error(
154+
"Preview services build their pull request merge ref; use Build to rebuild it",
155+
);
156+
}
147157

148158
const branch =
149159
result.githubRepo.deployBranch || result.githubRepo.defaultBranch || "main";

0 commit comments

Comments
 (0)