@@ -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.\n Used 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.\n Sets 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+ }
0 commit comments