From 4cca9df4442089f32c4dc0512143505c179e7bad Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 22 Jul 2026 18:28:52 -0500 Subject: [PATCH 1/3] feat(ci): add devsy ci command for devcontainer CI Add a top-level `devsy ci [flags] [source] -- ` command that builds an ephemeral devcontainer, runs a command inside it, and tears the workspace down afterwards. A non-zero exit from the command propagates as the exit code of `devsy ci`, making it a CI gate on any runner (GitHub Actions, GitLab, Jenkins). - Build+start via a headless `up` (no IDE, no host SSH config), then run the command through the exec path so it runs as the devcontainer's remote user. Pre-build/push stays the responsibility of `devsy build`. - Ephemeral by default: the workspace is always torn down (even on failure) unless --keep is passed or it already existed. SIGINT/SIGTERM is converted to context cancellation so a cancelled CI job still tears down; teardown runs on its own bounded context and its failure is surfaced. - Flags: --run-cmd (shell string via sh -c), --remote-env, --keep, --devcontainer, --cache-from, --workspace-env(-file), --init-env, --secrets-file, --feature-secrets-file, --platform, git/no-cache/machine, registered via the pkg/flags builder. - Extract shared devcontainer source resolution into devcontainer.ResolveSourceSpec, used by both up and ci. - Tests: cmd/ci unit tests, an e2e suite (build/run/teardown, exit-code, --keep, --remote-env, --workspace-env, --run-cmd, --secrets-file, --cache-from) wired into the pr-ci matrix, and docs with GitHub/GitLab examples. --- .github/workflows/pr-ci.yml | 6 + cmd/ci/ci.go | 299 ++++++++++++++++++ cmd/ci/ci_test.go | 113 +++++++ cmd/root.go | 4 + cmd/workspace/build.go | 10 +- cmd/workspace/up/up.go | 51 +++ cmd/workspace/up/up_validate.go | 20 +- e2e/e2e_suite_test.go | 1 + e2e/tests/ci/ci.go | 176 +++++++++++ .../ci/.devcontainer/devcontainer.json | 4 + .../secrets/.devcontainer/devcontainer.json | 7 + pkg/devcontainer/source.go | 34 ++ pkg/devcontainer/source_test.go | 48 ++- .../continuous-integration.mdx | 146 +++++++++ sites/docs-devsy-sh/sidebars.js | 4 + 15 files changed, 897 insertions(+), 26 deletions(-) create mode 100644 cmd/ci/ci.go create mode 100644 cmd/ci/ci_test.go create mode 100644 e2e/tests/ci/ci.go create mode 100644 e2e/tests/ci/testdata/ci/.devcontainer/devcontainer.json create mode 100644 e2e/tests/ci/testdata/secrets/.devcontainer/devcontainer.json create mode 100644 sites/docs-devsy-sh/pages/developing-in-workspaces/continuous-integration.mdx diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index fbaff0e0c..055b1a5cd 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -265,6 +265,12 @@ jobs: install-kind: false requires-secret: false + - label: ci + runner: ubuntu-latest + free-disk-space: false + install-kind: false + requires-secret: false + - label: features runner: ubuntu-latest free-disk-space: false diff --git a/cmd/ci/ci.go b/cmd/ci/ci.go new file mode 100644 index 000000000..0d8d2507f --- /dev/null +++ b/cmd/ci/ci.go @@ -0,0 +1,299 @@ +package ci + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/devsy-org/devsy/cmd/flags" + workspacecmd "github.com/devsy-org/devsy/cmd/workspace" + "github.com/devsy-org/devsy/cmd/workspace/up" + "github.com/devsy-org/devsy/pkg/client" + "github.com/devsy-org/devsy/pkg/config" + "github.com/devsy-org/devsy/pkg/devcontainer" + cliflags "github.com/devsy-org/devsy/pkg/flags" + "github.com/devsy-org/devsy/pkg/flags/names" + "github.com/devsy-org/devsy/pkg/log" + "github.com/devsy-org/devsy/pkg/provider" + workspace2 "github.com/devsy-org/devsy/pkg/workspace" + "github.com/spf13/cobra" +) + +// teardownTimeout bounds ephemeral-workspace deletion, which runs on its own +// context so it completes even after the command context is cancelled. +const teardownTimeout = 2 * time.Minute + +// CICmd holds the ci command flags. +type CICmd struct { + *flags.GlobalFlags + provider.CLIOptions + + ProviderOptions []string + + // RunCmd is the argv executed in the container, resolved from either the + // tokens after "--" or a --run-cmd shell string. + RunCmd []string + // RunCmdString is the raw --run-cmd shell command, run via "sh -c". + RunCmdString string + RemoteEnv []string + Keep bool + Machine string + SecretsFile string + FeatureSecretsFile string +} + +// NewCICmd creates a new ci command. +func NewCICmd(flags *flags.GlobalFlags) *cobra.Command { + cmd := &CICmd{GlobalFlags: flags} + ciCmd := &cobra.Command{ + Use: "ci [flags] [workspace-path|workspace-name] -- [args...]", + Short: "Build a devcontainer, run a command inside it, then tear it down", + Long: `Builds an ephemeral devcontainer, runs the given command inside it, and +deletes the workspace afterwards. Useful for validating that a devcontainer +builds and behaves correctly in CI. The command runs on any CI system by +invoking the Devsy binary directly. + +The command to run is given after "--" or via --run-cmd. A non-zero exit from +that command propagates as the exit code of "devsy ci".`, + RunE: func(cobraCmd *cobra.Command, args []string) error { + source, runCmd, err := splitArgs(cobraCmd, args, cmd.RunCmdString) + if err != nil { + return err + } + cmd.RunCmd = runCmd + return cmd.execute(cobraCmd.Context(), source) + }, + } + + cmd.registerFlags(ciCmd) + return ciCmd +} + +// runCmdFlag is ci-specific and has no shared name constant. +const runCmdFlag = "run-cmd" + +// keepFlag is ci-specific and has no shared name constant. +const keepFlag = "keep" + +func (cmd *CICmd) registerFlags(ciCmd *cobra.Command) { + cliflags.Add(ciCmd, + cliflags.String(&cmd.RunCmdString, runCmdFlag, "", + "Shell command to run inside the container, executed via sh -c "+ + "(alternative to specifying an argv after --)"), + cliflags.StringSlice(&cmd.RemoteEnv, names.RemoteEnv, nil, + "Environment variables to set in the container at run time (KEY=VALUE, repeatable)"), + cliflags.Bool(&cmd.Keep, keepFlag, false, + "Keep the workspace after running instead of tearing it down (useful for debugging)"), + cliflags.String(&cmd.DevContainerSource, names.DevContainer, "", + "Select the devcontainer config source, overriding project discovery: "+ + `"none" (ignore the project config), "image:" (use only that image), `+ + `"id:" (a named .devcontainer/ profile), or a path to a devcontainer.json`), + cliflags.StringSlice(&cmd.ProviderOptions, names.ProviderOption, nil, + "Provider option in the form KEY=VALUE"), + cliflags.String(&cmd.Machine, names.Machine, "", + "The machine to use for this workspace. The machine needs to exist beforehand"), + cliflags.Bool(&cmd.NoCache, names.NoCache, false, + "Do not use the build cache when building the image"), + cliflags.String(&cmd.RunPlatform, names.Platform, "", + "Run the container under a specific platform via emulation (e.g. linux/amd64)"), + cliflags.Value(&cmd.GitCloneStrategy, names.GitCloneStrategy, + "The git clone strategy Devsy uses to checkout git based workspaces "+ + "(full, blobless, treeless or shallow)"), + cliflags.Bool(&cmd.GitCloneRecursiveSubmodules, names.GitRecurseSubmodules, false, + "Clone submodules recursively"), + cliflags.StringArray(&cmd.CacheFrom, names.CacheFrom, nil, + "Cache sources for the build (e.g., myregistry.io/cache:latest). "+ + "Reuse a pre-built image to warm the build"), + cliflags.StringArray(&cmd.WorkspaceEnv, names.WorkspaceEnv, nil, + "Env variables available at build/lifecycle time (KEY=VALUE, repeatable)"), + cliflags.StringSlice(&cmd.WorkspaceEnvFile, names.WorkspaceEnvFile, nil, + "Path to files containing a list of extra env variables to put into the workspace"), + cliflags.StringArray( + &cmd.InitEnv, + names.InitEnv, + nil, + "Extra env variables to inject during workspace initialization (KEY=VALUE, repeatable)", + ), + cliflags.String( + &cmd.SecretsFile, + names.SecretsFile, + "", + "Path to a dotenv-style file containing KEY=VALUE secrets injected into lifecycle commands", + ), + cliflags.String(&cmd.FeatureSecretsFile, names.FeatureSecretsFile, "", + "Path to a JSON file containing secret values for features, format: "+ + `{"featureId": {"optionName": "value"}}`), + ) + cliflags.RegisterDevContainerModifierFlags(ciCmd.Flags(), cliflags.DevContainerModifierFlags{ + Features: &cmd.AdditionalFeatures, + }) +} + +// splitArgs separates the optional workspace source from the run command. The +// command is the explicit argv after "--", or, when that is absent, the +// --run-cmd shell string wrapped in "sh -c". +func splitArgs( + cobraCmd *cobra.Command, args []string, runCmdString string, +) (string, []string, error) { + dash := cobraCmd.ArgsLenAtDash() + + var sourceArgs, cmdArgs []string + if dash >= 0 { + sourceArgs = args[:dash] + cmdArgs = args[dash:] + } else { + sourceArgs = args + } + + if len(sourceArgs) > 1 { + return "", nil, fmt.Errorf("expected at most one workspace source, got %d", len(sourceArgs)) + } + + if len(cmdArgs) == 0 { + if runCmdString != "" { + cmdArgs = []string{"sh", "-c", runCmdString} + } + } else if runCmdString != "" { + return "", nil, fmt.Errorf("specify the command after -- or via --run-cmd, not both") + } + if len(cmdArgs) == 0 { + return "", nil, fmt.Errorf("a command to run is required after -- or via --run-cmd") + } + + source := "" + if len(sourceArgs) == 1 { + source = sourceArgs[0] + } + return source, cmdArgs, nil +} + +func (cmd *CICmd) execute(ctx context.Context, source string) (err error) { + if err := cmd.validateEnv(); err != nil { + return err + } + if err := devcontainer.ResolveSourceSpec(&cmd.CLIOptions); err != nil { + return err + } + + // Convert SIGINT/SIGTERM (e.g. a cancelled CI job) into context + // cancellation so the deferred teardown still runs instead of leaking the + // workspace. + ctx, cancel := up.WithSignals(ctx) + defer cancel() + + devsyConfig, cfgErr := config.LoadConfig(cmd.Context, cmd.Provider) + if cfgErr != nil { + return cfgErr + } + if devsyConfig.ContextOption(config.ContextOptionSSHStrictHostKeyChecking) == config.BoolTrue { + cmd.StrictHostKeyChecking = true + } + + workspaceClient, cleanup, resolveErr := cmd.resolveWorkspace(ctx, devsyConfig, source) + if resolveErr != nil { + return resolveErr + } + // cleanup tears down the ephemeral workspace; its error is joined with the + // run result so a cleanup failure surfaces as a failed CI run. + defer func() { err = errors.Join(err, cleanup()) }() + + if _, ok := workspaceClient.(client.WorkspaceClient); !ok { + return fmt.Errorf("ci is currently not supported for proxy providers") + } + + if _, err := up.RunHeadless(ctx, workspaceClient, up.HeadlessOptions{ + GlobalFlags: cmd.GlobalFlags, + DevsyConfig: devsyConfig, + CLIOptions: cmd.CLIOptions, + ProviderOptions: cmd.ProviderOptions, + SecretsFile: cmd.SecretsFile, + FeatureSecretsFile: cmd.FeatureSecretsFile, + }); err != nil { + return fmt.Errorf("start devcontainer: %w", err) + } + + log.Infof("running command in devcontainer: %s", strings.Join(cmd.RunCmd, " ")) + return cmd.runInContainer(ctx, workspaceClient) +} + +// resolveWorkspace resolves (creating if needed) the workspace to run in. The +// returned cleanup tears down only workspaces this command created, and only +// when --keep was not passed. +func (cmd *CICmd) resolveWorkspace( + ctx context.Context, + devsyConfig *config.Config, + source string, +) (client.BaseWorkspaceClient, func() error, error) { + var args []string + if source != "" { + args = []string{source} + } + exists := workspace2.Exists(ctx, devsyConfig, args, "", cmd.Owner) + + sshConfigPath, cleanupSSH, err := workspacecmd.NewTempSSHConfig() + if err != nil { + return nil, nil, err + } + + workspaceClient, err := workspace2.Resolve(ctx, devsyConfig, workspace2.ResolveParams{ + Args: args, + DesiredMachine: cmd.Machine, + ProviderUserOptions: cmd.ProviderOptions, + DevContainerImage: cmd.DevContainerImage, + DevContainerPath: cmd.DevContainerPath, + SSHConfigPath: sshConfigPath, + UID: cmd.UID, + Owner: cmd.Owner, + }) + if err != nil { + cleanupSSH() + return nil, nil, err + } + + teardown := exists == "" && !cmd.Keep + cleanup := func() error { + defer cleanupSSH() + if teardown { + return cmd.teardown(workspaceClient) + } + return nil + } + return workspaceClient, cleanup, nil +} + +// runInContainer runs the command inside the started container, reusing the +// exec command so it runs as the devcontainer's remote user. +func (cmd *CICmd) runInContainer(ctx context.Context, c client.BaseWorkspaceClient) error { + execCmd := &workspacecmd.ExecCmd{ + GlobalFlags: cmd.GlobalFlags, + WorkspaceName: c.Workspace(), + RemoteEnv: cmd.RemoteEnv, + } + return execCmd.Run(ctx, cmd.RunCmd) +} + +// teardown deletes the ephemeral workspace. It uses its own bounded context so +// teardown still runs after the command context was cancelled (e.g. by a +// cancelled CI job), which is the whole point of running it on the way out. +func (cmd *CICmd) teardown(c client.BaseWorkspaceClient) error { + log.Infof("tearing down workspace") + ctx, cancel := context.WithTimeout(context.Background(), teardownTimeout) + defer cancel() + if err := c.Delete(ctx, client.DeleteOptions{Force: true}); err != nil { + return fmt.Errorf("delete workspace: %w", err) + } + return nil +} + +func (cmd *CICmd) validateEnv() error { + for _, env := range cmd.RemoteEnv { + parts := strings.SplitN(env, "=", 2) + if len(parts) != 2 || parts[0] == "" { + return fmt.Errorf("invalid %s value %q: must be KEY=VALUE format", + names.Flag(names.RemoteEnv), env) + } + } + return nil +} diff --git a/cmd/ci/ci_test.go b/cmd/ci/ci_test.go new file mode 100644 index 000000000..c216657b0 --- /dev/null +++ b/cmd/ci/ci_test.go @@ -0,0 +1,113 @@ +package ci + +import ( + "testing" + + "github.com/devsy-org/devsy/cmd/flags" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + cmdMake = "make" + cmdTest = "test" + cmdEcho = "echo" +) + +func TestValidateEnv(t *testing.T) { + tests := []struct { + name string + env []string + wantErr bool + }{ + {name: "valid", env: []string{"FOO=bar", "BAZ=qux=extra"}, wantErr: false}, + {name: "empty slice", env: nil, wantErr: false}, + {name: "missing equals", env: []string{"INVALID"}, wantErr: true}, + {name: "empty key", env: []string{"=value"}, wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := &CICmd{GlobalFlags: &flags.GlobalFlags{}, RemoteEnv: tt.env} + err := cmd.validateEnv() + if tt.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), "must be KEY=VALUE format") + } else { + assert.NoError(t, err) + } + }) + } +} + +// runSplit exercises splitArgs through a cobra command so ArgsLenAtDash is +// populated the same way it is at runtime. +func runSplit(t *testing.T, args []string, runCmdString string) (string, []string, error) { + t.Helper() + var ( + gotSource string + gotCmd []string + gotErr error + ) + cobraCmd := &cobra.Command{ + Use: "ci", + RunE: func(c *cobra.Command, a []string) error { + gotSource, gotCmd, gotErr = splitArgs(c, a, runCmdString) + return nil + }, + } + cobraCmd.SetArgs(args) + require.NoError(t, cobraCmd.Execute()) + return gotSource, gotCmd, gotErr +} + +func TestSplitArgs_CommandAfterDash(t *testing.T) { + source, cmd, err := runSplit(t, []string{"--", cmdMake, cmdTest}, "") + require.NoError(t, err) + assert.Empty(t, source) + assert.Equal(t, []string{cmdMake, cmdTest}, cmd) +} + +func TestSplitArgs_SourceAndCommand(t *testing.T) { + source, cmd, err := runSplit(t, []string{"my-ws", "--", cmdEcho, "hi"}, "") + require.NoError(t, err) + assert.Equal(t, "my-ws", source) + assert.Equal(t, []string{cmdEcho, "hi"}, cmd) +} + +func TestSplitArgs_RunCmdWrapsInShell(t *testing.T) { + source, cmd, err := runSplit(t, nil, "make ci") + require.NoError(t, err) + assert.Empty(t, source) + assert.Equal(t, []string{"sh", "-c", "make ci"}, cmd) +} + +func TestSplitArgs_MissingCommand(t *testing.T) { + _, _, err := runSplit(t, []string{"my-ws"}, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "a command to run is required") +} + +func TestSplitArgs_DashAndRunCmdConflict(t *testing.T) { + _, _, err := runSplit(t, []string{"--", cmdMake, cmdTest}, "make ci") + require.Error(t, err) + assert.Contains(t, err.Error(), "not both") +} + +func TestSplitArgs_TooManySources(t *testing.T) { + _, _, err := runSplit(t, []string{"ws-one", "ws-two", "--", cmdEcho}, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "at most one workspace source") +} + +func TestNewCICmd_Flags(t *testing.T) { + ciCmd := NewCICmd(&flags.GlobalFlags{}) + for _, name := range []string{ + "run-cmd", "remote-env", "keep", "devcontainer", "no-cache", "platform", + "cache-from", "workspace-env", "workspace-env-file", "init-env", + "secrets-file", "feature-secrets-file", "features", + } { + assert.NotNil(t, ciCmd.Flags().Lookup(name), "expected flag %q to be registered", name) + } + assert.Equal(t, "false", ciCmd.Flags().Lookup("keep").DefValue) +} diff --git a/cmd/root.go b/cmd/root.go index 29ac60e98..e29691ec0 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -9,6 +9,7 @@ import ( "runtime/debug" "strings" + "github.com/devsy-org/devsy/cmd/ci" "github.com/devsy-org/devsy/cmd/completion" cliconfig "github.com/devsy-org/devsy/cmd/config" "github.com/devsy-org/devsy/cmd/context" @@ -340,5 +341,8 @@ func registerSubcommands(rootCmd *cobra.Command, globalFlags *flags.GlobalFlags) templateCmd := template.NewTemplateCmd(globalFlags) templateCmd.GroupID = groupDevcontainer rootCmd.AddCommand(templateCmd) + ciCmd := ci.NewCICmd(globalFlags) + ciCmd.GroupID = groupDevcontainer + rootCmd.AddCommand(ciCmd) rootCmd.AddCommand(cmdinternal.NewInternalCmd(globalFlags)) } diff --git a/cmd/workspace/build.go b/cmd/workspace/build.go index b59802212..4dc227634 100644 --- a/cmd/workspace/build.go +++ b/cmd/workspace/build.go @@ -138,7 +138,7 @@ func (cmd *BuildCmd) execute(ctx context.Context, args []string) error { } exists := workspace2.Exists(ctx, devsyConfig, args, "", cmd.Owner) - sshConfigPath, cleanup, err := newTempSSHConfig() + sshConfigPath, cleanup, err := NewTempSSHConfig() if err != nil { return err } @@ -226,13 +226,17 @@ func (cmd *BuildCmd) cleanupTempWorkspace( } } -// newTempSSHConfig creates a temporary ssh config file and returns its path and a cleanup func. -func newTempSSHConfig() (string, func(), error) { +// NewTempSSHConfig creates a temporary ssh config file and returns its path and a cleanup func. +func NewTempSSHConfig() (string, func(), error) { f, err := os.CreateTemp("", config.BinaryName+"ssh.config") if err != nil { return "", nil, err } path := f.Name() + if err := f.Close(); err != nil { + _ = os.Remove(path) + return "", nil, err + } return path, func() { _ = os.Remove(path) }, nil } diff --git a/cmd/workspace/up/up.go b/cmd/workspace/up/up.go index 3f8b7d44e..8c39f0082 100644 --- a/cmd/workspace/up/up.go +++ b/cmd/workspace/up/up.go @@ -67,6 +67,57 @@ type Options struct { DevcontainerPath string // path to devcontainer.json, relative to project } +// HeadlessOptions configures a non-interactive `up` for automation such as CI. +type HeadlessOptions struct { + GlobalFlags *flags.GlobalFlags + DevsyConfig *config.Config + CLIOptions provider2.CLIOptions + ProviderOptions []string + SecretsFile string + FeatureSecretsFile string +} + +// RunHeadless builds and starts the devcontainer for an already-resolved client +// without launching an IDE or configuring host SSH. The caller owns workspace +// teardown. +func RunHeadless( + ctx context.Context, + client client2.BaseWorkspaceClient, + opts HeadlessOptions, +) (*config2.Result, error) { + gCopy := *opts.GlobalFlags + cmd := &UpCmd{ + GlobalFlags: &gCopy, + CLIOptions: opts.CLIOptions, + ProviderOptions: opts.ProviderOptions, + SecretsFile: opts.SecretsFile, + FeatureSecretsFile: opts.FeatureSecretsFile, + ConfigureSSH: false, + IDELaunch: opener.LaunchSkip, + Out: io.Discard, + } + cmd.IDE = string(config.IDENone) + mountGitRootDefault := true + cmd.MountWorkspaceGitRoot = &mountGitRootDefault + + if err := cmd.validate(); err != nil { + return nil, err + } + if err := cmd.prepareSecrets(); err != nil { + return nil, err + } + cmd.prepareWorkspace(client) + + wctx, err := cmd.executeDevsyUp(ctx, opts.DevsyConfig, client) + if err != nil { + return nil, err + } + if wctx == nil { + return nil, fmt.Errorf("up returned no workspace context") + } + return wctx.result, nil +} + // RunFromOptions runs the up logic without cobra. Callers own ctx cancellation; // WithSignals is intentionally skipped. func RunFromOptions(ctx context.Context, g *flags.GlobalFlags, opts Options) error { diff --git a/cmd/workspace/up/up_validate.go b/cmd/workspace/up/up_validate.go index 88795ab34..ee4ef07a7 100644 --- a/cmd/workspace/up/up_validate.go +++ b/cmd/workspace/up/up_validate.go @@ -49,25 +49,7 @@ func (cmd *UpCmd) validate() error { } func (cmd *UpCmd) resolveDevContainerSource() error { - spec, err := devcontainer.ParseSourceSpec(cmd.DevContainerSource) - if err != nil { - return err - } - if spec == nil { - return nil - } - switch spec.Kind { - case devcontainer.SourceID: - cmd.DevContainerID = spec.ID - cmd.DevContainerSource = "" - case devcontainer.SourcePath: - if !filepath.IsAbs(spec.Path) { - cmd.DevContainerPath = spec.Path - cmd.DevContainerSource = "" - } - case devcontainer.SourceNone, devcontainer.SourceImage: - } - return nil + return devcontainer.ResolveSourceSpec(&cmd.CLIOptions) } func (cmd *UpCmd) resolveExtraDevContainerPath() error { diff --git a/e2e/e2e_suite_test.go b/e2e/e2e_suite_test.go index 8585ee664..4a91fa3b4 100644 --- a/e2e/e2e_suite_test.go +++ b/e2e/e2e_suite_test.go @@ -9,6 +9,7 @@ import ( "github.com/devsy-org/devsy/e2e/framework" // Register tests. _ "github.com/devsy-org/devsy/e2e/tests/build" + _ "github.com/devsy-org/devsy/e2e/tests/ci" _ "github.com/devsy-org/devsy/e2e/tests/configapply" _ "github.com/devsy-org/devsy/e2e/tests/configread" _ "github.com/devsy-org/devsy/e2e/tests/context" diff --git a/e2e/tests/ci/ci.go b/e2e/tests/ci/ci.go new file mode 100644 index 000000000..10f88077e --- /dev/null +++ b/e2e/tests/ci/ci.go @@ -0,0 +1,176 @@ +package ci + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/devsy-org/devsy/e2e/framework" + "github.com/devsy-org/devsy/pkg/workspace" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +const ciCommand = "ci" + +// expectWorkspaceGone lists workspaces (asserting the list itself succeeds) and +// verifies the given workspace is absent, so teardown failures aren't masked by +// a generic list error. +func expectWorkspaceGone(ctx context.Context, f *framework.Framework, name string) { + list, err := f.DevsyListParsed(ctx) + framework.ExpectNoError(err) + wantID := workspace.ToID(name) + for _, w := range list { + gomega.Expect(w.ID).ToNot(gomega.Equal(wantID), + "workspace %s should be torn down", wantID) + } +} + +var _ = ginkgo.Describe("devsy ci test suite", ginkgo.Label("ci"), ginkgo.Ordered, func() { + var initialDir string + + ginkgo.BeforeEach(func() { + var err error + initialDir, err = os.Getwd() + framework.ExpectNoError(err) + }) + + ginkgo.It("should build, run a command, and tear the workspace down", + func(ctx context.Context) { + tempDir, f := setupCI(initialDir) + + stdout, _, err := f.ExecCommandCapture(ctx, []string{ + ciCommand, tempDir, + "--", "sh", "-c", "echo -n hello-ci", + }) + framework.ExpectNoError(err) + gomega.Expect(stdout).To(gomega.ContainSubstring("hello-ci")) + + expectWorkspaceGone(ctx, f, tempDir) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("should propagate a non-zero exit code from the run command", + func(ctx context.Context) { + tempDir, f := setupCI(initialDir) + + _, _, err := f.ExecCommandCapture(ctx, []string{ + ciCommand, tempDir, + "--", "sh", "-c", "exit 3", + }) + var exitErr *exec.ExitError + gomega.Expect(errors.As(err, &exitErr)).To(gomega.BeTrue(), + "expected an exec.ExitError, got %v", err) + gomega.Expect(exitErr.ExitCode()).To(gomega.Equal(3)) + + expectWorkspaceGone(ctx, f, tempDir) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("should retain the workspace when --keep is passed", + func(ctx context.Context) { + tempDir, f := setupCI(initialDir) + ginkgo.DeferCleanup(f.DevsyWorkspaceDelete, tempDir) + + stdout, _, err := f.ExecCommandCapture(ctx, []string{ + ciCommand, tempDir, "--keep", + "--", "sh", "-c", "echo -n kept", + }) + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(stdout)).To(gomega.ContainSubstring("kept")) + + _, err = f.FindWorkspace(ctx, tempDir) + framework.ExpectNoError(err) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("should pass --remote-env to the run command", + func(ctx context.Context) { + tempDir, f := setupCI(initialDir) + + stdout, _, err := f.ExecCommandCapture(ctx, []string{ + ciCommand, tempDir, + "--remote-env", "MY_CI_VAR=ci_value", + "--", "sh", "-c", "echo -n $MY_CI_VAR", + }) + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(stdout)).To(gomega.ContainSubstring("ci_value")) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("should pass --workspace-env into the container", + func(ctx context.Context) { + tempDir, f := setupCI(initialDir) + + // --workspace-env is persisted to /etc/envfile.json during setup; + // assert it landed there rather than relying on shell env-loading, + // which varies between login and non-login (docker exec) shells. + stdout, _, err := f.ExecCommandCapture(ctx, []string{ + ciCommand, tempDir, + "--workspace-env", "WS_CI_VAR=ws_value", + "--", "cat", "/etc/envfile.json", + }) + framework.ExpectNoError(err) + gomega.Expect(stdout).To(gomega.ContainSubstring(`"WS_CI_VAR":"ws_value"`)) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("should accept the run command via --run-cmd", + func(ctx context.Context) { + tempDir, f := setupCI(initialDir) + + stdout, _, err := f.ExecCommandCapture(ctx, []string{ + ciCommand, tempDir, + "--run-cmd", "echo -n from-run-cmd", + }) + framework.ExpectNoError(err) + gomega.Expect(stdout).To(gomega.ContainSubstring("from-run-cmd")) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("should inject secrets from --secrets-file into lifecycle commands", + func(ctx context.Context) { + // The postCreateCommand in this testdata writes $MY_SECRET to a file; + // secrets are injected into lifecycle commands, not the run command's + // own environment, so the run command reads it back from the file. + tempDir, f := setupCIFrom(initialDir, "tests/ci/testdata/secrets") + + secretsFile := filepath.Join(tempDir, "ci.secrets") + err := os.WriteFile(secretsFile, []byte("MY_SECRET=s3cr3t\n"), 0o600) + framework.ExpectNoError(err) + + stdout, _, err := f.ExecCommandCapture(ctx, []string{ + ciCommand, tempDir, + "--secrets-file", secretsFile, + "--", "cat", "/tmp/ci-secret.txt", + }) + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(stdout)).To(gomega.ContainSubstring("s3cr3t")) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("should accept --cache-from and complete the run", + func(ctx context.Context) { + tempDir, f := setupCI(initialDir) + + stdout, _, err := f.ExecCommandCapture(ctx, []string{ + ciCommand, tempDir, + "--cache-from", "ghcr.io/devsy-org/test-images/base:ubuntu", + "--", "sh", "-c", "echo -n cached-run", + }) + framework.ExpectNoError(err) + gomega.Expect(stdout).To(gomega.ContainSubstring("cached-run")) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) +}) + +func setupCI(initialDir string) (string, *framework.Framework) { + return setupCIFrom(initialDir, "tests/ci/testdata/ci") +} + +func setupCIFrom(initialDir, testdataPath string) (string, *framework.Framework) { + tempDir, err := framework.CopyToTempDir(testdataPath) + framework.ExpectNoError(err) + ginkgo.DeferCleanup(framework.CleanupTempDir, initialDir, tempDir) + + f, err := framework.SetupDockerProvider(initialDir+"/bin", "docker") + framework.ExpectNoError(err) + ginkgo.DeferCleanup(f.DevsyWorkspaceDelete, tempDir) + + return tempDir, f +} diff --git a/e2e/tests/ci/testdata/ci/.devcontainer/devcontainer.json b/e2e/tests/ci/testdata/ci/.devcontainer/devcontainer.json new file mode 100644 index 000000000..ac422024e --- /dev/null +++ b/e2e/tests/ci/testdata/ci/.devcontainer/devcontainer.json @@ -0,0 +1,4 @@ +{ + "name": "CI Test", + "image": "ghcr.io/devsy-org/test-images/base:ubuntu" +} diff --git a/e2e/tests/ci/testdata/secrets/.devcontainer/devcontainer.json b/e2e/tests/ci/testdata/secrets/.devcontainer/devcontainer.json new file mode 100644 index 000000000..f916bdaa9 --- /dev/null +++ b/e2e/tests/ci/testdata/secrets/.devcontainer/devcontainer.json @@ -0,0 +1,7 @@ +{ + "name": "CI Secrets Test", + "image": "ghcr.io/devsy-org/test-images/base:ubuntu", + "postCreateCommand": { + "capture-secret": "printf '%s' \"$MY_SECRET\" > /tmp/ci-secret.txt" + } +} diff --git a/pkg/devcontainer/source.go b/pkg/devcontainer/source.go index 4f2efdbf1..9ea23266f 100644 --- a/pkg/devcontainer/source.go +++ b/pkg/devcontainer/source.go @@ -2,9 +2,11 @@ package devcontainer import ( "fmt" + "path/filepath" "strings" "github.com/devsy-org/devsy/pkg/flags/names" + "github.com/devsy-org/devsy/pkg/provider" ) type SourceSpec struct { @@ -61,3 +63,35 @@ func ParseSourceSpec(value string) (*SourceSpec, error) { return &SourceSpec{Kind: SourcePath, Path: value}, nil } } + +// ResolveSourceSpec parses opts.DevContainerSource (the --devcontainer selector) +// and applies it to opts: an "id:" source and an in-repo relative path are +// consumed into DevContainerID / DevContainerPath and the source string is +// cleared. "none", "image:", and an external (absolute) path are left in +// DevContainerSource so the runner can handle them (the external path is +// imported into the workspace so its sibling assets resolve). A no-op when no +// source is set. +func ResolveSourceSpec(opts *provider.CLIOptions) error { + spec, err := ParseSourceSpec(opts.DevContainerSource) + if err != nil { + return err + } + if spec == nil { + return nil + } + switch spec.Kind { + case SourceID: + opts.DevContainerID = spec.ID + opts.DevContainerSource = "" + case SourcePath: + // An in-repo relative path is just a config path; an external absolute + // path is left in the source so the runner imports it (bringing its + // sibling assets — Dockerfile, features — into the workspace). + if !filepath.IsAbs(spec.Path) { + opts.DevContainerPath = spec.Path + opts.DevContainerSource = "" + } + case SourceNone, SourceImage: + } + return nil +} diff --git a/pkg/devcontainer/source_test.go b/pkg/devcontainer/source_test.go index 87baecba5..20a72913b 100644 --- a/pkg/devcontainer/source_test.go +++ b/pkg/devcontainer/source_test.go @@ -3,10 +3,16 @@ package devcontainer import ( "testing" + "github.com/devsy-org/devsy/pkg/provider" "gotest.tools/assert" ) -const testImage = "python" +const ( + testImage = "python" + testID = "default" + testPath = ".devcontainer/custom.json" + testImgSrc = "image:python" +) func TestParseSourceSpec(t *testing.T) { cases := []struct { @@ -28,12 +34,12 @@ func TestParseSourceSpec(t *testing.T) { wantImage: "mcr.microsoft.com/devcontainers/python:3", }, {in: "image: python ", wantKind: SourceImage, wantImage: "python"}, - {in: "id:default", wantKind: SourceID, wantID: "default"}, + {in: sourceIDPrefix + testID, wantKind: SourceID, wantID: testID}, {in: "id: claude ", wantKind: SourceID, wantID: "claude"}, { - in: ".devcontainer/custom.json", + in: testPath, wantKind: SourcePath, - wantPath: ".devcontainer/custom.json", + wantPath: testPath, }, {in: "/abs/path.json", wantKind: SourcePath, wantPath: "/abs/path.json"}, {in: "image:", wantErr: true}, @@ -56,3 +62,37 @@ func TestParseSourceSpec(t *testing.T) { assert.Equal(t, c.wantPath, spec.Path, "input %q path", c.in) } } + +func TestResolveSourceSpec(t *testing.T) { + cases := []struct { + in string + wantID string + wantPath string + wantImage string + wantSrc string + wantErr bool + }{ + {in: ""}, + {in: sourceIDPrefix + testID, wantID: testID}, + {in: testPath, wantPath: testPath}, + // An external (absolute) path stays in the source for the runner to import. + {in: "/abs/external/devcontainer.json", wantSrc: "/abs/external/devcontainer.json"}, + // none/image stay in DevContainerSource for agent-side handling. + {in: string(SourceNone), wantSrc: string(SourceNone)}, + {in: testImgSrc, wantSrc: testImgSrc}, + {in: "image:", wantErr: true}, + } + for _, c := range cases { + opts := &provider.CLIOptions{DevContainerSource: c.in} + err := ResolveSourceSpec(opts) + if c.wantErr { + assert.Assert(t, err != nil, "input %q should error", c.in) + continue + } + assert.NilError(t, err, "input %q", c.in) + assert.Equal(t, c.wantID, opts.DevContainerID, "input %q id", c.in) + assert.Equal(t, c.wantPath, opts.DevContainerPath, "input %q path", c.in) + assert.Equal(t, c.wantImage, opts.DevContainerImage, "input %q image", c.in) + assert.Equal(t, c.wantSrc, opts.DevContainerSource, "input %q source", c.in) + } +} diff --git a/sites/docs-devsy-sh/pages/developing-in-workspaces/continuous-integration.mdx b/sites/docs-devsy-sh/pages/developing-in-workspaces/continuous-integration.mdx new file mode 100644 index 000000000..9f3621fce --- /dev/null +++ b/sites/docs-devsy-sh/pages/developing-in-workspaces/continuous-integration.mdx @@ -0,0 +1,146 @@ +--- +title: Continuous Integration +sidebar_label: Continuous Integration +--- + +## Continuous Integration + +`devsy ci` builds an ephemeral devcontainer, runs a command inside it, and tears +the workspace down afterwards. It lets you verify — on every push or pull +request — that your `devcontainer.json` still builds and that your project +behaves correctly inside it. + +Because the logic lives in the Devsy CLI, the same command works on any CI +system (GitHub Actions, GitLab CI, Jenkins, and others) — you install the binary +and call `devsy ci`. + +### How it works + +1. Resolves an ephemeral workspace from the current directory (or a given + source). +2. Builds and starts the devcontainer (equivalent to `devsy up`, without + launching an IDE or touching your SSH config). +3. Runs the given command inside the running container. +4. Deletes the workspace — always, even when the command fails — unless + `--keep` is passed. + +A non-zero exit from the command propagates as the exit code of `devsy ci`, so a +failing test or build fails the CI job. + +### Usage + +```sh +devsy ci [flags] [workspace-path|workspace-name] -- [args...] +``` + +Run the test suite inside the devcontainer for the current project: + +```sh +devsy ci -- make test +``` + +Target a specific project folder and pass environment variables: + +```sh +devsy ci ./service -- npm test +devsy ci --remote-env CI=true --remote-env NODE_ENV=test -- npm test +``` + +Select a specific devcontainer config (e.g. a named `.devcontainer/` profile): + +```sh +devsy ci --devcontainer id:ci -- make test +``` + +Keep the workspace around for debugging when the command fails: + +```sh +devsy ci --keep -- ./run-integration-tests.sh +``` + +### Common flags + +| Flag | Description | +| --- | --- | +| `--run-cmd` | Shell command run inside the container via `sh -c` (alternative to an explicit argv after `--`). | +| `--remote-env KEY=VALUE` | Set an environment variable in the container at run time. Repeatable. | +| `--keep` | Keep the workspace instead of tearing it down. | +| `--devcontainer` | Select the devcontainer config source: `none`, `image:`, `id:`, or a path to a `devcontainer.json`. | +| `--no-cache` | Build without using the cache. | +| `--cache-from` | Reuse a pre-built image as a build cache source. Repeatable. | +| `--platform` | Run the container under a specific platform via emulation (e.g. `linux/amd64`). | +| `--workspace-env KEY=VALUE` | Env variables available at build and lifecycle time (not just at run time). Repeatable. | +| `--workspace-env-file` | File(s) of `KEY=VALUE` build/lifecycle env variables. | +| `--init-env KEY=VALUE` | Env variables injected during workspace initialization. Repeatable. | +| `--secrets-file` | Dotenv-style file of secrets injected into lifecycle commands. | +| `--feature-secrets-file` | JSON file of secret values for features. | + +### Pre-building and pushing images + +`devsy ci` focuses on running a command. To pre-build and publish a devcontainer +image for reuse as a build cache, use `devsy build`: + +```sh +devsy build --repository ghcr.io/my-org/my-devcontainer --tag latest --push +``` + +Downstream CI jobs can then reference that image via `--cache-from` to speed up +builds. + +### GitHub Actions + +Install the CLI, configure the docker provider, then run `devsy ci`: + +```yaml +name: Devcontainer CI +on: + push: + branches: [main] + pull_request: + +jobs: + devcontainer: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Devsy + run: | + curl -fsSL https://github.com/devsy-org/devsy/releases/latest/download/devsy-linux-amd64 -o /usr/local/bin/devsy + chmod +x /usr/local/bin/devsy + - name: Run CI in devcontainer + run: | + devsy provider add docker && devsy provider use docker + devsy ci -- make test +``` + +### GitLab CI + +```yaml +devcontainer: + image: docker:27 + services: + - docker:27-dind + variables: + DOCKER_HOST: tcp://docker:2376 + script: + - apk add --no-cache curl bash + - curl -fsSL https://github.com/devsy-org/devsy/releases/latest/download/devsy-linux-amd64 -o /usr/local/bin/devsy + - chmod +x /usr/local/bin/devsy + - devsy provider add docker && devsy provider use docker + - devsy ci -- make test +``` + +The same approach works for Jenkins and any other runner with Docker access. + +For reproducible CI, pin a specific release instead of `latest` — replace +`releases/latest/download` with `releases/download/` (e.g. `v1.2.3`). + +### Limitations + +- **Local providers only.** `devsy ci` runs against local providers (docker, + podman). Proxy/Pro providers are not supported. +- **No built-in timeout.** A hung build or command relies on your CI system's + job timeout to be interrupted; `devsy ci` tears the workspace down when it + receives the resulting cancellation signal. +- **`--platform` is run-time emulation,** not a multi-architecture build target. + To build and publish multi-arch images, use `devsy build --platform`. diff --git a/sites/docs-devsy-sh/sidebars.js b/sites/docs-devsy-sh/sidebars.js index a39a69540..a38b7028f 100644 --- a/sites/docs-devsy-sh/sidebars.js +++ b/sites/docs-devsy-sh/sidebars.js @@ -57,6 +57,10 @@ module.exports = { type: "doc", id: "developing-in-workspaces/prebuild-a-workspace", }, + { + type: "doc", + id: "developing-in-workspaces/continuous-integration", + }, { type: "doc", id: "developing-in-workspaces/dotfiles-in-a-workspace", From b0e5a9f40607ff172b6ff45dfc20411370884065 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 22 Jul 2026 18:47:56 -0500 Subject: [PATCH 2/3] chore: cleanup comments --- cmd/ci/ci.go | 46 +++++-------------- cmd/ci/ci_test.go | 2 - cmd/workspace/up/up.go | 4 -- cmd/workspace/up/up_validate.go | 6 +-- e2e/tests/ci/ci.go | 9 ---- pkg/devcontainer/source.go | 12 +---- pkg/devcontainer/source_test.go | 2 - .../continuous-integration.mdx | 44 ++---------------- 8 files changed, 18 insertions(+), 107 deletions(-) diff --git a/cmd/ci/ci.go b/cmd/ci/ci.go index 0d8d2507f..c1aa9ebed 100644 --- a/cmd/ci/ci.go +++ b/cmd/ci/ci.go @@ -21,21 +21,19 @@ import ( "github.com/spf13/cobra" ) -// teardownTimeout bounds ephemeral-workspace deletion, which runs on its own -// context so it completes even after the command context is cancelled. -const teardownTimeout = 2 * time.Minute +const ( + teardownTimeout = 2 * time.Minute + runCmdFlag = "run-cmd" + keepFlag = "keep" +) // CICmd holds the ci command flags. type CICmd struct { *flags.GlobalFlags provider.CLIOptions - ProviderOptions []string - - // RunCmd is the argv executed in the container, resolved from either the - // tokens after "--" or a --run-cmd shell string. - RunCmd []string - // RunCmdString is the raw --run-cmd shell command, run via "sh -c". + ProviderOptions []string + RunCmd []string RunCmdString string RemoteEnv []string Keep bool @@ -51,12 +49,8 @@ func NewCICmd(flags *flags.GlobalFlags) *cobra.Command { Use: "ci [flags] [workspace-path|workspace-name] -- [args...]", Short: "Build a devcontainer, run a command inside it, then tear it down", Long: `Builds an ephemeral devcontainer, runs the given command inside it, and -deletes the workspace afterwards. Useful for validating that a devcontainer -builds and behaves correctly in CI. The command runs on any CI system by -invoking the Devsy binary directly. - -The command to run is given after "--" or via --run-cmd. A non-zero exit from -that command propagates as the exit code of "devsy ci".`, +deletes the workspace afterwards. A non-zero exit from that command propagates as the +exit code of "devsy ci".`, RunE: func(cobraCmd *cobra.Command, args []string) error { source, runCmd, err := splitArgs(cobraCmd, args, cmd.RunCmdString) if err != nil { @@ -71,12 +65,6 @@ that command propagates as the exit code of "devsy ci".`, return ciCmd } -// runCmdFlag is ci-specific and has no shared name constant. -const runCmdFlag = "run-cmd" - -// keepFlag is ci-specific and has no shared name constant. -const keepFlag = "keep" - func (cmd *CICmd) registerFlags(ciCmd *cobra.Command) { cliflags.Add(ciCmd, cliflags.String(&cmd.RunCmdString, runCmdFlag, "", @@ -131,9 +119,6 @@ func (cmd *CICmd) registerFlags(ciCmd *cobra.Command) { }) } -// splitArgs separates the optional workspace source from the run command. The -// command is the explicit argv after "--", or, when that is absent, the -// --run-cmd shell string wrapped in "sh -c". func splitArgs( cobraCmd *cobra.Command, args []string, runCmdString string, ) (string, []string, error) { @@ -195,8 +180,7 @@ func (cmd *CICmd) execute(ctx context.Context, source string) (err error) { if resolveErr != nil { return resolveErr } - // cleanup tears down the ephemeral workspace; its error is joined with the - // run result so a cleanup failure surfaces as a failed CI run. + // cleanup tears down the workspace; join error with the result. defer func() { err = errors.Join(err, cleanup()) }() if _, ok := workspaceClient.(client.WorkspaceClient); !ok { @@ -218,9 +202,7 @@ func (cmd *CICmd) execute(ctx context.Context, source string) (err error) { return cmd.runInContainer(ctx, workspaceClient) } -// resolveWorkspace resolves (creating if needed) the workspace to run in. The -// returned cleanup tears down only workspaces this command created, and only -// when --keep was not passed. +// resolveWorkspace resolves (creating if needed) the workspace to run in. func (cmd *CICmd) resolveWorkspace( ctx context.Context, devsyConfig *config.Config, @@ -263,8 +245,7 @@ func (cmd *CICmd) resolveWorkspace( return workspaceClient, cleanup, nil } -// runInContainer runs the command inside the started container, reusing the -// exec command so it runs as the devcontainer's remote user. +// runInContainer runs the command inside the started container. func (cmd *CICmd) runInContainer(ctx context.Context, c client.BaseWorkspaceClient) error { execCmd := &workspacecmd.ExecCmd{ GlobalFlags: cmd.GlobalFlags, @@ -274,9 +255,6 @@ func (cmd *CICmd) runInContainer(ctx context.Context, c client.BaseWorkspaceClie return execCmd.Run(ctx, cmd.RunCmd) } -// teardown deletes the ephemeral workspace. It uses its own bounded context so -// teardown still runs after the command context was cancelled (e.g. by a -// cancelled CI job), which is the whole point of running it on the way out. func (cmd *CICmd) teardown(c client.BaseWorkspaceClient) error { log.Infof("tearing down workspace") ctx, cancel := context.WithTimeout(context.Background(), teardownTimeout) diff --git a/cmd/ci/ci_test.go b/cmd/ci/ci_test.go index c216657b0..c2d04d960 100644 --- a/cmd/ci/ci_test.go +++ b/cmd/ci/ci_test.go @@ -40,8 +40,6 @@ func TestValidateEnv(t *testing.T) { } } -// runSplit exercises splitArgs through a cobra command so ArgsLenAtDash is -// populated the same way it is at runtime. func runSplit(t *testing.T, args []string, runCmdString string) (string, []string, error) { t.Helper() var ( diff --git a/cmd/workspace/up/up.go b/cmd/workspace/up/up.go index 8c39f0082..9d010bad5 100644 --- a/cmd/workspace/up/up.go +++ b/cmd/workspace/up/up.go @@ -67,7 +67,6 @@ type Options struct { DevcontainerPath string // path to devcontainer.json, relative to project } -// HeadlessOptions configures a non-interactive `up` for automation such as CI. type HeadlessOptions struct { GlobalFlags *flags.GlobalFlags DevsyConfig *config.Config @@ -77,9 +76,6 @@ type HeadlessOptions struct { FeatureSecretsFile string } -// RunHeadless builds and starts the devcontainer for an already-resolved client -// without launching an IDE or configuring host SSH. The caller owns workspace -// teardown. func RunHeadless( ctx context.Context, client client2.BaseWorkspaceClient, diff --git a/cmd/workspace/up/up_validate.go b/cmd/workspace/up/up_validate.go index ee4ef07a7..9e5e75f99 100644 --- a/cmd/workspace/up/up_validate.go +++ b/cmd/workspace/up/up_validate.go @@ -21,7 +21,7 @@ const ( ) func (cmd *UpCmd) validate() error { - if err := cmd.resolveDevContainerSource(); err != nil { + if err := devcontainer.ResolveSourceSpec(&cmd.CLIOptions); err != nil { return err } if err := validatePodmanFlags(cmd); err != nil { @@ -48,10 +48,6 @@ func (cmd *UpCmd) validate() error { return validateRemoteUserUID(cmd.UpdateRemoteUserUIDDefault) } -func (cmd *UpCmd) resolveDevContainerSource() error { - return devcontainer.ResolveSourceSpec(&cmd.CLIOptions) -} - func (cmd *UpCmd) resolveExtraDevContainerPath() error { if cmd.ExtraDevContainerPath == "" { return nil diff --git a/e2e/tests/ci/ci.go b/e2e/tests/ci/ci.go index 10f88077e..aababba78 100644 --- a/e2e/tests/ci/ci.go +++ b/e2e/tests/ci/ci.go @@ -16,9 +16,6 @@ import ( const ciCommand = "ci" -// expectWorkspaceGone lists workspaces (asserting the list itself succeeds) and -// verifies the given workspace is absent, so teardown failures aren't masked by -// a generic list error. func expectWorkspaceGone(ctx context.Context, f *framework.Framework, name string) { list, err := f.DevsyListParsed(ctx) framework.ExpectNoError(err) @@ -101,9 +98,6 @@ var _ = ginkgo.Describe("devsy ci test suite", ginkgo.Label("ci"), ginkgo.Ordere func(ctx context.Context) { tempDir, f := setupCI(initialDir) - // --workspace-env is persisted to /etc/envfile.json during setup; - // assert it landed there rather than relying on shell env-loading, - // which varies between login and non-login (docker exec) shells. stdout, _, err := f.ExecCommandCapture(ctx, []string{ ciCommand, tempDir, "--workspace-env", "WS_CI_VAR=ws_value", @@ -127,9 +121,6 @@ var _ = ginkgo.Describe("devsy ci test suite", ginkgo.Label("ci"), ginkgo.Ordere ginkgo.It("should inject secrets from --secrets-file into lifecycle commands", func(ctx context.Context) { - // The postCreateCommand in this testdata writes $MY_SECRET to a file; - // secrets are injected into lifecycle commands, not the run command's - // own environment, so the run command reads it back from the file. tempDir, f := setupCIFrom(initialDir, "tests/ci/testdata/secrets") secretsFile := filepath.Join(tempDir, "ci.secrets") diff --git a/pkg/devcontainer/source.go b/pkg/devcontainer/source.go index 9ea23266f..7d09b6422 100644 --- a/pkg/devcontainer/source.go +++ b/pkg/devcontainer/source.go @@ -64,13 +64,6 @@ func ParseSourceSpec(value string) (*SourceSpec, error) { } } -// ResolveSourceSpec parses opts.DevContainerSource (the --devcontainer selector) -// and applies it to opts: an "id:" source and an in-repo relative path are -// consumed into DevContainerID / DevContainerPath and the source string is -// cleared. "none", "image:", and an external (absolute) path are left in -// DevContainerSource so the runner can handle them (the external path is -// imported into the workspace so its sibling assets resolve). A no-op when no -// source is set. func ResolveSourceSpec(opts *provider.CLIOptions) error { spec, err := ParseSourceSpec(opts.DevContainerSource) if err != nil { @@ -84,9 +77,8 @@ func ResolveSourceSpec(opts *provider.CLIOptions) error { opts.DevContainerID = spec.ID opts.DevContainerSource = "" case SourcePath: - // An in-repo relative path is just a config path; an external absolute - // path is left in the source so the runner imports it (bringing its - // sibling assets — Dockerfile, features — into the workspace). + // An in-repo relative path is a config path; an external absolute + // path is left in the source so the runner imports it. if !filepath.IsAbs(spec.Path) { opts.DevContainerPath = spec.Path opts.DevContainerSource = "" diff --git a/pkg/devcontainer/source_test.go b/pkg/devcontainer/source_test.go index 20a72913b..8a91b942f 100644 --- a/pkg/devcontainer/source_test.go +++ b/pkg/devcontainer/source_test.go @@ -75,9 +75,7 @@ func TestResolveSourceSpec(t *testing.T) { {in: ""}, {in: sourceIDPrefix + testID, wantID: testID}, {in: testPath, wantPath: testPath}, - // An external (absolute) path stays in the source for the runner to import. {in: "/abs/external/devcontainer.json", wantSrc: "/abs/external/devcontainer.json"}, - // none/image stay in DevContainerSource for agent-side handling. {in: string(SourceNone), wantSrc: string(SourceNone)}, {in: testImgSrc, wantSrc: testImgSrc}, {in: "image:", wantErr: true}, diff --git a/sites/docs-devsy-sh/pages/developing-in-workspaces/continuous-integration.mdx b/sites/docs-devsy-sh/pages/developing-in-workspaces/continuous-integration.mdx index 9f3621fce..641d5a3c5 100644 --- a/sites/docs-devsy-sh/pages/developing-in-workspaces/continuous-integration.mdx +++ b/sites/docs-devsy-sh/pages/developing-in-workspaces/continuous-integration.mdx @@ -6,18 +6,12 @@ sidebar_label: Continuous Integration ## Continuous Integration `devsy ci` builds an ephemeral devcontainer, runs a command inside it, and tears -the workspace down afterwards. It lets you verify — on every push or pull -request — that your `devcontainer.json` still builds and that your project -behaves correctly inside it. - -Because the logic lives in the Devsy CLI, the same command works on any CI -system (GitHub Actions, GitLab CI, Jenkins, and others) — you install the binary -and call `devsy ci`. +the workspace down afterwards. It lets you verify that your `devcontainer.json` +still builds. ### How it works -1. Resolves an ephemeral workspace from the current directory (or a given - source). +1. Resolves an workspace from the current directory (or a given source). 2. Builds and starts the devcontainer (equivalent to `devsy up`, without launching an IDE or touching your SSH config). 3. Runs the given command inside the running container. @@ -112,35 +106,3 @@ jobs: devsy provider add docker && devsy provider use docker devsy ci -- make test ``` - -### GitLab CI - -```yaml -devcontainer: - image: docker:27 - services: - - docker:27-dind - variables: - DOCKER_HOST: tcp://docker:2376 - script: - - apk add --no-cache curl bash - - curl -fsSL https://github.com/devsy-org/devsy/releases/latest/download/devsy-linux-amd64 -o /usr/local/bin/devsy - - chmod +x /usr/local/bin/devsy - - devsy provider add docker && devsy provider use docker - - devsy ci -- make test -``` - -The same approach works for Jenkins and any other runner with Docker access. - -For reproducible CI, pin a specific release instead of `latest` — replace -`releases/latest/download` with `releases/download/` (e.g. `v1.2.3`). - -### Limitations - -- **Local providers only.** `devsy ci` runs against local providers (docker, - podman). Proxy/Pro providers are not supported. -- **No built-in timeout.** A hung build or command relies on your CI system's - job timeout to be interrupted; `devsy ci` tears the workspace down when it - receives the resulting cancellation signal. -- **`--platform` is run-time emulation,** not a multi-architecture build target. - To build and publish multi-arch images, use `devsy build --platform`. From 29f2f6299498fbdfe54bf4df303a3b67028c019f Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 22 Jul 2026 18:52:20 -0500 Subject: [PATCH 3/3] test: remove file --- .../up/up_devcontainer_source_test.go | 59 ------------------- 1 file changed, 59 deletions(-) delete mode 100644 cmd/workspace/up/up_devcontainer_source_test.go diff --git a/cmd/workspace/up/up_devcontainer_source_test.go b/cmd/workspace/up/up_devcontainer_source_test.go deleted file mode 100644 index 41abb6791..000000000 --- a/cmd/workspace/up/up_devcontainer_source_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package up - -import ( - "testing" - - "github.com/devsy-org/devsy/cmd/flags" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -const srcNone = "none" - -func TestResolveDevContainerSource_ID(t *testing.T) { - cmd := &UpCmd{GlobalFlags: &flags.GlobalFlags{}} - cmd.DevContainerSource = "id:default" - - require.NoError(t, cmd.resolveDevContainerSource()) - assert.Equal(t, "default", cmd.DevContainerID) - assert.Empty(t, cmd.DevContainerSource) -} - -func TestResolveDevContainerSource_Path(t *testing.T) { - cmd := &UpCmd{GlobalFlags: &flags.GlobalFlags{}} - cmd.DevContainerSource = ".devcontainer/custom.json" - - require.NoError(t, cmd.resolveDevContainerSource()) - assert.Equal(t, ".devcontainer/custom.json", cmd.DevContainerPath) - assert.Empty(t, cmd.DevContainerSource) -} - -func TestResolveDevContainerSource_ExternalPathPassThrough(t *testing.T) { - cmd := &UpCmd{GlobalFlags: &flags.GlobalFlags{}} - cmd.DevContainerSource = "/abs/external/devcontainer.json" - - require.NoError(t, cmd.resolveDevContainerSource()) - assert.Equal(t, "/abs/external/devcontainer.json", cmd.DevContainerSource) - assert.Empty(t, cmd.DevContainerPath) - assert.Empty(t, cmd.DevContainerID) -} - -func TestResolveDevContainerSource_NoneAndImagePassThrough(t *testing.T) { - for _, spec := range []string{srcNone, "image:python"} { - cmd := &UpCmd{GlobalFlags: &flags.GlobalFlags{}} - cmd.DevContainerSource = spec - - require.NoError(t, cmd.resolveDevContainerSource()) - // none/image specs stay for agent-side handling. - assert.Equal(t, spec, cmd.DevContainerSource) - assert.Empty(t, cmd.DevContainerID) - assert.Empty(t, cmd.DevContainerPath) - } -} - -func TestResolveDevContainerSource_Invalid(t *testing.T) { - cmd := &UpCmd{GlobalFlags: &flags.GlobalFlags{}} - cmd.DevContainerSource = "image:" - - require.Error(t, cmd.resolveDevContainerSource()) -}