Skip to content

Commit f3e54ca

Browse files
chuongld20claude
andcommitted
Merge ISS-53-ci-cd-preview-workspaces into main
Resolve conflicts in main.go — keep plugin, registry, and CI commands. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2 parents 0ab73ef + 631855b commit f3e54ca

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"
@@ -76,6 +77,7 @@ func main() {
7677
rootCmd.AddCommand(snapshotCmd())
7778
rootCmd.AddCommand(restoreCmd())
7879
rootCmd.AddCommand(pluginCmd())
80+
rootCmd.AddCommand(ciCmd(wm))
7981

8082
if err := rootCmd.Execute(); err != nil {
8183
printError(err)
@@ -1489,3 +1491,217 @@ func copyDir(src, dst string) error {
14891491
}
14901492
return nil
14911493
}
1494+
1495+
func ciCmd(wm workspace.Manager) *cobra.Command {
1496+
cmd := &cobra.Command{
1497+
Use: "ci",
1498+
Short: "CI/CD integration commands",
1499+
Long: "Commands for CI/CD platform integration.\nUsed by GitHub Actions to manage PR preview workspaces.",
1500+
}
1501+
cmd.AddCommand(ciPreviewUpCmd(wm))
1502+
cmd.AddCommand(ciPreviewDownCmd(wm))
1503+
return cmd
1504+
}
1505+
1506+
func ciPreviewUpCmd(wm workspace.Manager) *cobra.Command {
1507+
cmd := &cobra.Command{
1508+
Use: "preview-up",
1509+
Short: "Create a preview workspace for a PR",
1510+
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.",
1511+
RunE: func(cmd *cobra.Command, args []string) error {
1512+
pr, _ := cmd.Flags().GetInt("pr")
1513+
repo, _ := cmd.Flags().GetString("repo")
1514+
sha, _ := cmd.Flags().GetString("sha")
1515+
serverFlag, _ := cmd.Flags().GetString("server")
1516+
templateFlag, _ := cmd.Flags().GetString("template")
1517+
1518+
token := os.Getenv("GITHUB_TOKEN")
1519+
if token == "" {
1520+
return fmt.Errorf("devbox ci preview-up: GITHUB_TOKEN environment variable is required")
1521+
}
1522+
1523+
parts := strings.SplitN(repo, "/", 2)
1524+
if len(parts) != 2 {
1525+
return fmt.Errorf("devbox ci preview-up: --repo must be in owner/repo format")
1526+
}
1527+
owner, repoName := parts[0], parts[1]
1528+
1529+
provider := ci.NewGitHubProvider(owner, repoName, token)
1530+
ctx := cmd.Context()
1531+
1532+
// Set pending status.
1533+
if err := provider.SetCommitStatus(ctx, sha, ci.StatusPending, "", "Creating preview workspace..."); err != nil {
1534+
fmt.Fprintf(os.Stderr, "Warning: failed to set pending status: %v\n", err)
1535+
}
1536+
1537+
// Build workspace name scoped by repo to avoid collisions.
1538+
wsName := fmt.Sprintf("pr-%s-%d", repoName, pr)
1539+
branch := fmt.Sprintf("pr-%d", pr)
1540+
1541+
// Load config for defaults.
1542+
var cfg *config.DevboxConfig
1543+
if templateFlag != "" {
1544+
registry, err := tmpl.NewDefaultRegistry()
1545+
if err != nil {
1546+
provider.SetCommitStatus(ctx, sha, ci.StatusFailure, "", "Failed to load template")
1547+
return fmt.Errorf("devbox ci preview-up: %w", err)
1548+
}
1549+
t, err := registry.Get(templateFlag)
1550+
if err != nil {
1551+
provider.SetCommitStatus(ctx, sha, ci.StatusFailure, "", "Template not found")
1552+
return fmt.Errorf("devbox ci preview-up: %w", err)
1553+
}
1554+
cfg = t.ToDevboxConfig(wsName, "")
1555+
} else {
1556+
var err error
1557+
cfg, err = config.LoadFromDir(".")
1558+
if err != nil {
1559+
// No config file — create minimal config.
1560+
cfg = &config.DevboxConfig{Name: wsName}
1561+
}
1562+
}
1563+
1564+
if serverFlag != "" {
1565+
cfg.Server = serverFlag
1566+
}
1567+
if cfg.Server == "" {
1568+
provider.SetCommitStatus(ctx, sha, ci.StatusFailure, "", "No server specified")
1569+
return fmt.Errorf("devbox ci preview-up: --server is required")
1570+
}
1571+
1572+
// Create workspace.
1573+
ws, err := wm.Create(workspace.CreateParams{
1574+
Name: wsName,
1575+
User: "ci",
1576+
Server: cfg.Server,
1577+
Repo: cfg.Repo,
1578+
Branch: branch,
1579+
Services: cfg.Services,
1580+
Ports: cfg.Ports,
1581+
Env: cfg.Env,
1582+
})
1583+
if err != nil {
1584+
// If already exists, start it instead.
1585+
var wsErr *workspace.WorkspaceError
1586+
if errors.As(err, &wsErr) && strings.Contains(wsErr.Message, "already exists") {
1587+
if startErr := wm.Start(wsName); startErr != nil {
1588+
provider.SetCommitStatus(ctx, sha, ci.StatusFailure, "", "Failed to start workspace")
1589+
return fmt.Errorf("devbox ci preview-up: %w", startErr)
1590+
}
1591+
ws, err = wm.Get(wsName)
1592+
if err != nil {
1593+
provider.SetCommitStatus(ctx, sha, ci.StatusFailure, "", "Failed to get workspace")
1594+
return fmt.Errorf("devbox ci preview-up: %w", err)
1595+
}
1596+
} else {
1597+
provider.SetCommitStatus(ctx, sha, ci.StatusFailure, "", "Failed to create workspace")
1598+
return fmt.Errorf("devbox ci preview-up: %w", err)
1599+
}
1600+
}
1601+
1602+
// Get workspace URL via Tailscale.
1603+
sshExec, err := devboxssh.New()
1604+
if err != nil {
1605+
provider.SetCommitStatus(ctx, sha, ci.StatusFailure, "", "SSH connection failed")
1606+
return fmt.Errorf("devbox ci preview-up: %w", err)
1607+
}
1608+
defer sshExec.Close()
1609+
1610+
tm := tailscale.NewManager(remoteRunner(sshExec, cfg.Server))
1611+
for name, port := range ws.Ports {
1612+
if err := tm.Serve(port, ws.Name); err != nil {
1613+
fmt.Fprintf(os.Stderr, "Warning: failed to expose port %s (%d): %v\n", name, port, err)
1614+
}
1615+
}
1616+
1617+
wsURL := ""
1618+
if tsStatus, err := tm.Status(); err == nil && tsStatus != nil {
1619+
wsURL = tailscale.WorkspaceURL(tsStatus.Hostname, tsStatus.TailnetName)
1620+
}
1621+
1622+
// Post PR comment with workspace URL.
1623+
if wsURL != "" {
1624+
if err := provider.CommentWorkspaceURL(ctx, pr, wsURL); err != nil {
1625+
fmt.Fprintf(os.Stderr, "Warning: failed to post PR comment: %v\n", err)
1626+
}
1627+
}
1628+
1629+
// Set success status.
1630+
if err := provider.SetCommitStatus(ctx, sha, ci.StatusSuccess, wsURL, "Preview workspace ready"); err != nil {
1631+
fmt.Fprintf(os.Stderr, "Warning: failed to set success status: %v\n", err)
1632+
}
1633+
1634+
fmt.Printf("Preview workspace %q ready\n", wsName)
1635+
if wsURL != "" {
1636+
fmt.Printf("URL: %s\n", wsURL)
1637+
}
1638+
1639+
return nil
1640+
},
1641+
}
1642+
cmd.Flags().Int("pr", 0, "Pull request number (required)")
1643+
cmd.Flags().String("repo", "", "Repository in owner/repo format (required)")
1644+
cmd.Flags().String("sha", "", "Commit SHA for status check (required)")
1645+
cmd.Flags().String("server", "", "Target server (required)")
1646+
cmd.Flags().String("template", "", "Workspace template to use")
1647+
cmd.MarkFlagRequired("pr")
1648+
cmd.MarkFlagRequired("repo")
1649+
cmd.MarkFlagRequired("sha")
1650+
return cmd
1651+
}
1652+
1653+
func ciPreviewDownCmd(wm workspace.Manager) *cobra.Command {
1654+
cmd := &cobra.Command{
1655+
Use: "preview-down",
1656+
Short: "Destroy a PR preview workspace",
1657+
Long: "Destroy a preview workspace created for a pull request and clean up the PR comment.",
1658+
RunE: func(cmd *cobra.Command, args []string) error {
1659+
pr, _ := cmd.Flags().GetInt("pr")
1660+
repo, _ := cmd.Flags().GetString("repo")
1661+
1662+
token := os.Getenv("GITHUB_TOKEN")
1663+
if token == "" {
1664+
return fmt.Errorf("devbox ci preview-down: GITHUB_TOKEN environment variable is required")
1665+
}
1666+
1667+
parts := strings.SplitN(repo, "/", 2)
1668+
if len(parts) != 2 {
1669+
return fmt.Errorf("devbox ci preview-down: --repo must be in owner/repo format")
1670+
}
1671+
owner, repoName := parts[0], parts[1]
1672+
1673+
wsName := fmt.Sprintf("pr-%s-%d", repoName, pr)
1674+
ctx := cmd.Context()
1675+
1676+
// Destroy workspace.
1677+
ws, err := wm.Get(wsName)
1678+
if err != nil {
1679+
fmt.Fprintf(os.Stderr, "Warning: workspace %q not found, cleaning up PR comment only\n", wsName)
1680+
} else {
1681+
unservePorts(ws)
1682+
if err := wm.Destroy(wsName); err != nil {
1683+
return fmt.Errorf("devbox ci preview-down: %w", err)
1684+
}
1685+
}
1686+
1687+
// Clean up PR comment.
1688+
provider := ci.NewGitHubProvider(owner, repoName, token)
1689+
commentID, err := provider.FindBotComment(ctx, pr)
1690+
if err != nil {
1691+
fmt.Fprintf(os.Stderr, "Warning: failed to find bot comment: %v\n", err)
1692+
} else if commentID != 0 {
1693+
if err := provider.DeleteComment(ctx, commentID); err != nil {
1694+
fmt.Fprintf(os.Stderr, "Warning: failed to delete bot comment: %v\n", err)
1695+
}
1696+
}
1697+
1698+
fmt.Printf("Preview workspace %q destroyed\n", wsName)
1699+
return nil
1700+
},
1701+
}
1702+
cmd.Flags().Int("pr", 0, "Pull request number (required)")
1703+
cmd.Flags().String("repo", "", "Repository in owner/repo format (required)")
1704+
cmd.MarkFlagRequired("pr")
1705+
cmd.MarkFlagRequired("repo")
1706+
return cmd
1707+
}

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)