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