Skip to content

Commit 631855b

Browse files
chuongld20claude
andcommitted
feat: add CI/CD integration for PR preview workspaces (ISS-53)
Add `devbox ci preview-up` and `devbox ci preview-down` subcommands that orchestrate workspace lifecycle with GitHub notifications. New `internal/ci` package wraps GitHub REST API for PR comments (with idempotent bot marker) and commit status checks. Ship composite GitHub Action (`action.yml`) and example workflow for automated PR preview workspace creation/destruction. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent c0b0718 commit 631855b

6 files changed

Lines changed: 735 additions & 0 deletions

File tree

.github/workflows/preview.yml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
name: PR Preview
2+
3+
on:
4+
pull_request:
5+
types: [opened, synchronize, reopened, closed]
6+
7+
permissions:
8+
pull-requests: write
9+
statuses: write
10+
11+
jobs:
12+
preview-up:
13+
if: github.event.action != 'closed'
14+
runs-on: ubuntu-latest
15+
steps:
16+
- uses: actions/checkout@v4
17+
- uses: junixlabs/devbox-preview@v1
18+
with:
19+
action: up
20+
server: ${{ secrets.DEVBOX_SERVER }}
21+
env:
22+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
23+
24+
preview-down:
25+
if: github.event.action == 'closed'
26+
runs-on: ubuntu-latest
27+
steps:
28+
- uses: junixlabs/devbox-preview@v1
29+
with:
30+
action: down
31+
server: ${{ secrets.DEVBOX_SERVER }}
32+
env:
33+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

action.yml

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
name: devbox-preview
2+
description: Create and destroy preview workspaces for pull requests
3+
4+
inputs:
5+
action:
6+
description: 'Action to perform: up or down'
7+
required: true
8+
server:
9+
description: 'Target server hostname or pool name'
10+
required: true
11+
template:
12+
description: 'Workspace template to use (optional)'
13+
required: false
14+
default: ''
15+
devbox-version:
16+
description: 'devbox version to install'
17+
required: false
18+
default: 'latest'
19+
20+
runs:
21+
using: composite
22+
steps:
23+
- name: Install devbox
24+
shell: bash
25+
env:
26+
DEVBOX_VERSION: ${{ inputs.devbox-version }}
27+
run: |
28+
if [ "$DEVBOX_VERSION" = "latest" ]; then
29+
DOWNLOAD_URL="https://github.com/junixlabs/devbox/releases/latest/download/devbox-linux-amd64"
30+
else
31+
DOWNLOAD_URL="https://github.com/junixlabs/devbox/releases/download/${DEVBOX_VERSION}/devbox-linux-amd64"
32+
fi
33+
curl -fsSL "$DOWNLOAD_URL" -o /usr/local/bin/devbox
34+
chmod +x /usr/local/bin/devbox
35+
devbox --version
36+
37+
- name: Preview Up
38+
if: inputs.action == 'up'
39+
shell: bash
40+
env:
41+
GITHUB_TOKEN: ${{ github.token }}
42+
INPUT_PR: ${{ github.event.pull_request.number }}
43+
INPUT_REPO: ${{ github.repository }}
44+
INPUT_SHA: ${{ github.event.pull_request.head.sha }}
45+
INPUT_SERVER: ${{ inputs.server }}
46+
INPUT_TEMPLATE: ${{ inputs.template }}
47+
run: |
48+
ARGS="--pr $INPUT_PR"
49+
ARGS="$ARGS --repo $INPUT_REPO"
50+
ARGS="$ARGS --sha $INPUT_SHA"
51+
ARGS="$ARGS --server $INPUT_SERVER"
52+
if [ -n "$INPUT_TEMPLATE" ]; then
53+
ARGS="$ARGS --template $INPUT_TEMPLATE"
54+
fi
55+
devbox ci preview-up $ARGS
56+
57+
- name: Preview Down
58+
if: inputs.action == 'down'
59+
shell: bash
60+
env:
61+
GITHUB_TOKEN: ${{ github.token }}
62+
INPUT_PR: ${{ github.event.pull_request.number }}
63+
INPUT_REPO: ${{ github.repository }}
64+
run: |
65+
devbox ci preview-down \
66+
--pr "$INPUT_PR" \
67+
--repo "$INPUT_REPO"

cmd/devbox/main.go

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"text/tabwriter"
1313
"time"
1414

15+
"github.com/junixlabs/devbox/internal/ci"
1516
"github.com/junixlabs/devbox/internal/config"
1617
"github.com/junixlabs/devbox/internal/doctor"
1718
devboxerr "github.com/junixlabs/devbox/internal/errors"
@@ -72,6 +73,7 @@ func main() {
7273
rootCmd.AddCommand(tuiCmd(wm))
7374
rootCmd.AddCommand(snapshotCmd())
7475
rootCmd.AddCommand(restoreCmd())
76+
rootCmd.AddCommand(ciCmd(wm))
7577

7678
if err := rootCmd.Execute(); err != nil {
7779
printError(err)
@@ -1235,3 +1237,217 @@ func formatBytes(b int64) string {
12351237
}
12361238
return formatBytesShort(uint64(b))
12371239
}
1240+
1241+
func ciCmd(wm workspace.Manager) *cobra.Command {
1242+
cmd := &cobra.Command{
1243+
Use: "ci",
1244+
Short: "CI/CD integration commands",
1245+
Long: "Commands for CI/CD platform integration.\nUsed by GitHub Actions to manage PR preview workspaces.",
1246+
}
1247+
cmd.AddCommand(ciPreviewUpCmd(wm))
1248+
cmd.AddCommand(ciPreviewDownCmd(wm))
1249+
return cmd
1250+
}
1251+
1252+
func ciPreviewUpCmd(wm workspace.Manager) *cobra.Command {
1253+
cmd := &cobra.Command{
1254+
Use: "preview-up",
1255+
Short: "Create a preview workspace for a PR",
1256+
Long: "Create a preview workspace for a pull request and post the workspace URL as a PR comment.\nSets a commit status check on the PR head SHA.",
1257+
RunE: func(cmd *cobra.Command, args []string) error {
1258+
pr, _ := cmd.Flags().GetInt("pr")
1259+
repo, _ := cmd.Flags().GetString("repo")
1260+
sha, _ := cmd.Flags().GetString("sha")
1261+
serverFlag, _ := cmd.Flags().GetString("server")
1262+
templateFlag, _ := cmd.Flags().GetString("template")
1263+
1264+
token := os.Getenv("GITHUB_TOKEN")
1265+
if token == "" {
1266+
return fmt.Errorf("devbox ci preview-up: GITHUB_TOKEN environment variable is required")
1267+
}
1268+
1269+
parts := strings.SplitN(repo, "/", 2)
1270+
if len(parts) != 2 {
1271+
return fmt.Errorf("devbox ci preview-up: --repo must be in owner/repo format")
1272+
}
1273+
owner, repoName := parts[0], parts[1]
1274+
1275+
provider := ci.NewGitHubProvider(owner, repoName, token)
1276+
ctx := cmd.Context()
1277+
1278+
// Set pending status.
1279+
if err := provider.SetCommitStatus(ctx, sha, ci.StatusPending, "", "Creating preview workspace..."); err != nil {
1280+
fmt.Fprintf(os.Stderr, "Warning: failed to set pending status: %v\n", err)
1281+
}
1282+
1283+
// Build workspace name scoped by repo to avoid collisions.
1284+
wsName := fmt.Sprintf("pr-%s-%d", repoName, pr)
1285+
branch := fmt.Sprintf("pr-%d", pr)
1286+
1287+
// Load config for defaults.
1288+
var cfg *config.DevboxConfig
1289+
if templateFlag != "" {
1290+
registry, err := tmpl.NewDefaultRegistry()
1291+
if err != nil {
1292+
provider.SetCommitStatus(ctx, sha, ci.StatusFailure, "", "Failed to load template")
1293+
return fmt.Errorf("devbox ci preview-up: %w", err)
1294+
}
1295+
t, err := registry.Get(templateFlag)
1296+
if err != nil {
1297+
provider.SetCommitStatus(ctx, sha, ci.StatusFailure, "", "Template not found")
1298+
return fmt.Errorf("devbox ci preview-up: %w", err)
1299+
}
1300+
cfg = t.ToDevboxConfig(wsName, "")
1301+
} else {
1302+
var err error
1303+
cfg, err = config.LoadFromDir(".")
1304+
if err != nil {
1305+
// No config file — create minimal config.
1306+
cfg = &config.DevboxConfig{Name: wsName}
1307+
}
1308+
}
1309+
1310+
if serverFlag != "" {
1311+
cfg.Server = serverFlag
1312+
}
1313+
if cfg.Server == "" {
1314+
provider.SetCommitStatus(ctx, sha, ci.StatusFailure, "", "No server specified")
1315+
return fmt.Errorf("devbox ci preview-up: --server is required")
1316+
}
1317+
1318+
// Create workspace.
1319+
ws, err := wm.Create(workspace.CreateParams{
1320+
Name: wsName,
1321+
User: "ci",
1322+
Server: cfg.Server,
1323+
Repo: cfg.Repo,
1324+
Branch: branch,
1325+
Services: cfg.Services,
1326+
Ports: cfg.Ports,
1327+
Env: cfg.Env,
1328+
})
1329+
if err != nil {
1330+
// If already exists, start it instead.
1331+
var wsErr *workspace.WorkspaceError
1332+
if errors.As(err, &wsErr) && strings.Contains(wsErr.Message, "already exists") {
1333+
if startErr := wm.Start(wsName); startErr != nil {
1334+
provider.SetCommitStatus(ctx, sha, ci.StatusFailure, "", "Failed to start workspace")
1335+
return fmt.Errorf("devbox ci preview-up: %w", startErr)
1336+
}
1337+
ws, err = wm.Get(wsName)
1338+
if err != nil {
1339+
provider.SetCommitStatus(ctx, sha, ci.StatusFailure, "", "Failed to get workspace")
1340+
return fmt.Errorf("devbox ci preview-up: %w", err)
1341+
}
1342+
} else {
1343+
provider.SetCommitStatus(ctx, sha, ci.StatusFailure, "", "Failed to create workspace")
1344+
return fmt.Errorf("devbox ci preview-up: %w", err)
1345+
}
1346+
}
1347+
1348+
// Get workspace URL via Tailscale.
1349+
sshExec, err := devboxssh.New()
1350+
if err != nil {
1351+
provider.SetCommitStatus(ctx, sha, ci.StatusFailure, "", "SSH connection failed")
1352+
return fmt.Errorf("devbox ci preview-up: %w", err)
1353+
}
1354+
defer sshExec.Close()
1355+
1356+
tm := tailscale.NewManager(remoteRunner(sshExec, cfg.Server))
1357+
for name, port := range ws.Ports {
1358+
if err := tm.Serve(port, ws.Name); err != nil {
1359+
fmt.Fprintf(os.Stderr, "Warning: failed to expose port %s (%d): %v\n", name, port, err)
1360+
}
1361+
}
1362+
1363+
wsURL := ""
1364+
if tsStatus, err := tm.Status(); err == nil && tsStatus != nil {
1365+
wsURL = tailscale.WorkspaceURL(tsStatus.Hostname, tsStatus.TailnetName)
1366+
}
1367+
1368+
// Post PR comment with workspace URL.
1369+
if wsURL != "" {
1370+
if err := provider.CommentWorkspaceURL(ctx, pr, wsURL); err != nil {
1371+
fmt.Fprintf(os.Stderr, "Warning: failed to post PR comment: %v\n", err)
1372+
}
1373+
}
1374+
1375+
// Set success status.
1376+
if err := provider.SetCommitStatus(ctx, sha, ci.StatusSuccess, wsURL, "Preview workspace ready"); err != nil {
1377+
fmt.Fprintf(os.Stderr, "Warning: failed to set success status: %v\n", err)
1378+
}
1379+
1380+
fmt.Printf("Preview workspace %q ready\n", wsName)
1381+
if wsURL != "" {
1382+
fmt.Printf("URL: %s\n", wsURL)
1383+
}
1384+
1385+
return nil
1386+
},
1387+
}
1388+
cmd.Flags().Int("pr", 0, "Pull request number (required)")
1389+
cmd.Flags().String("repo", "", "Repository in owner/repo format (required)")
1390+
cmd.Flags().String("sha", "", "Commit SHA for status check (required)")
1391+
cmd.Flags().String("server", "", "Target server (required)")
1392+
cmd.Flags().String("template", "", "Workspace template to use")
1393+
cmd.MarkFlagRequired("pr")
1394+
cmd.MarkFlagRequired("repo")
1395+
cmd.MarkFlagRequired("sha")
1396+
return cmd
1397+
}
1398+
1399+
func ciPreviewDownCmd(wm workspace.Manager) *cobra.Command {
1400+
cmd := &cobra.Command{
1401+
Use: "preview-down",
1402+
Short: "Destroy a PR preview workspace",
1403+
Long: "Destroy a preview workspace created for a pull request and clean up the PR comment.",
1404+
RunE: func(cmd *cobra.Command, args []string) error {
1405+
pr, _ := cmd.Flags().GetInt("pr")
1406+
repo, _ := cmd.Flags().GetString("repo")
1407+
1408+
token := os.Getenv("GITHUB_TOKEN")
1409+
if token == "" {
1410+
return fmt.Errorf("devbox ci preview-down: GITHUB_TOKEN environment variable is required")
1411+
}
1412+
1413+
parts := strings.SplitN(repo, "/", 2)
1414+
if len(parts) != 2 {
1415+
return fmt.Errorf("devbox ci preview-down: --repo must be in owner/repo format")
1416+
}
1417+
owner, repoName := parts[0], parts[1]
1418+
1419+
wsName := fmt.Sprintf("pr-%s-%d", repoName, pr)
1420+
ctx := cmd.Context()
1421+
1422+
// Destroy workspace.
1423+
ws, err := wm.Get(wsName)
1424+
if err != nil {
1425+
fmt.Fprintf(os.Stderr, "Warning: workspace %q not found, cleaning up PR comment only\n", wsName)
1426+
} else {
1427+
unservePorts(ws)
1428+
if err := wm.Destroy(wsName); err != nil {
1429+
return fmt.Errorf("devbox ci preview-down: %w", err)
1430+
}
1431+
}
1432+
1433+
// Clean up PR comment.
1434+
provider := ci.NewGitHubProvider(owner, repoName, token)
1435+
commentID, err := provider.FindBotComment(ctx, pr)
1436+
if err != nil {
1437+
fmt.Fprintf(os.Stderr, "Warning: failed to find bot comment: %v\n", err)
1438+
} else if commentID != 0 {
1439+
if err := provider.DeleteComment(ctx, commentID); err != nil {
1440+
fmt.Fprintf(os.Stderr, "Warning: failed to delete bot comment: %v\n", err)
1441+
}
1442+
}
1443+
1444+
fmt.Printf("Preview workspace %q destroyed\n", wsName)
1445+
return nil
1446+
},
1447+
}
1448+
cmd.Flags().Int("pr", 0, "Pull request number (required)")
1449+
cmd.Flags().String("repo", "", "Repository in owner/repo format (required)")
1450+
cmd.MarkFlagRequired("pr")
1451+
cmd.MarkFlagRequired("repo")
1452+
return cmd
1453+
}

internal/ci/ci.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package ci
2+
3+
import "context"
4+
5+
// botCommentMarker is a hidden HTML comment embedded in PR comments
6+
// to identify and update bot-created comments idempotently.
7+
const botCommentMarker = "<!-- devbox-preview -->"
8+
9+
// StatusState represents a GitHub commit status state.
10+
type StatusState string
11+
12+
const (
13+
StatusPending StatusState = "pending"
14+
StatusSuccess StatusState = "success"
15+
StatusFailure StatusState = "failure"
16+
StatusError StatusState = "error"
17+
)
18+
19+
// CIProvider defines the interface for CI/CD platform integration.
20+
type CIProvider interface {
21+
// CommentWorkspaceURL posts or updates a PR comment with the workspace URL.
22+
CommentWorkspaceURL(ctx context.Context, prNumber int, url string) error
23+
24+
// FindBotComment returns the comment ID of an existing bot comment on a PR.
25+
// Returns 0 if no bot comment exists.
26+
FindBotComment(ctx context.Context, prNumber int) (int64, error)
27+
28+
// UpdateComment updates an existing PR comment by ID.
29+
UpdateComment(ctx context.Context, commentID int64, body string) error
30+
31+
// DeleteComment deletes a PR comment by ID.
32+
DeleteComment(ctx context.Context, commentID int64) error
33+
34+
// SetCommitStatus sets a commit status check on a given SHA.
35+
SetCommitStatus(ctx context.Context, sha string, state StatusState, targetURL, description string) error
36+
}

0 commit comments

Comments
 (0)