Repository: pulumi/esc
Pull request #552 (2025-06-26 22:00:44)

[approvals] Add draft flag to env set, edit, version rollback
Automatically submit change request for simplicity. The console does the same currently.

The flag is hidden for now while the feature and APIs are in preview.

Closes https://github.com/pulumi/pulumi-service/issues/28046

Examples:
```
> esc env set example/g values.example abc2 --draft
Change request created: a9b6ab9b-54b6-4ad6-8236-0d0d1e4eba48
Change request URL: https://app.pulumi.com/pulumipus-org/esc/example/g/change-requests/a9b6ab9b-54b6-4ad6-8236-0d0d1e4eba48
Change request submitted

> esc env edit example/g --draft
Change request created: bc15bae8-2afb-4a63-8d50-ffefef7fbe77
Change request URL: https://app.pulumi.com/pulumipus-org/esc/example/g/change-requests/bc15bae8-2afb-4a63-8d50-ffefef7fbe77
Change request submitted

> esc env version rollback example/g@1 --draft
Change request created: a0f6c39d-c249-44c4-b9e7-2cdf26397705
Change request URL: https://app.pulumi.com/pulumipus-org/esc/example/g/change-requests/a0f6c39d-c249-44c4-b9e7-2cdf26397705
Change request submitted
```


Patch:
diff --git a/CHANGELOG_PENDING.md b/CHANGELOG_PENDING.md
index 954dcdf..c0bba8b 100644
--- a/CHANGELOG_PENDING.md
+++ b/CHANGELOG_PENDING.md
@@ -1,8 +1,11 @@
 ### Improvements
 
+- `--draft` flag for `esc env set`, `esc env edit`, `esc env versions rollback` to create a change request rather than updating directly. **Warning: this feature is in preview, limited to specific orgs, and subject to change.**
+  [#552](https://github.com/pulumi/esc/pull/552)
+
 ### Bug Fixes
 
 - Fix decryption error with keys with dashes
- [#559](https://github.com/pulumi/esc/pull/559)
+  [#559](https://github.com/pulumi/esc/pull/559)
 
 ### Breaking changes
diff --git a/cmd/esc/cli/client/apitype.go b/cmd/esc/cli/client/apitype.go
index ac69d8a..9a8077c 100644
--- a/cmd/esc/cli/client/apitype.go
+++ b/cmd/esc/cli/client/apitype.go
@@ -139,6 +139,15 @@ type UpdateEnvironmentRevisionTagRequest struct {
 	Revision *int `json:"revision,omitempty"`
 }
 
+type CreateEnvironmentDraftResponse struct {
+	ChangeRequestID      string `json:"changeRequestId"`
+	LatestRevisionNumber int    `json:"latestRevisionNumber"`
+}
+
+type SubmitChangeRequestRequest struct {
+	Description *string `json:"description,omitempty"`
+}
+
 type EnvironmentTag struct {
 	ID          string    `json:"id"`
 	Name        string    `json:"name"`
diff --git a/cmd/esc/cli/client/client.go b/cmd/esc/cli/client/client.go
index ed64679..5e9a424 100644
--- a/cmd/esc/cli/client/client.go
+++ b/cmd/esc/cli/client/client.go
@@ -152,6 +152,32 @@ type Client interface {
 		etag string,
 	) ([]EnvironmentDiagnostic, error)
 
+	// CreateEnvironmentDraft creates a draft update of the YAML for the environment envName in org orgName.
+	// The resulting ChangeRequestID is returned.
+	//
+	// If the new environment definition contains errors, the creation will fail with diagnostics.
+	//
+	// If etag is not the empty string and the environment's current etag does not match the provided etag
+	// (i.e. because a different entity has called UpdateEnvironment), the creation will fail with a 409
+	// error.
+	CreateEnvironmentDraft(
+		ctx context.Context,
+		orgName string,
+		projectName string,
+		envName string,
+		yaml []byte,
+		etag string,
+	) (string, []EnvironmentDiagnostic, error)
+
+	// SubmitChangeRequest submits the change request with the specified ID in org orgName.
+	// Optionally provide a description for the change request.
+	SubmitChangeRequest(
+		ctx context.Context,
+		orgName string,
+		changeRequestID string,
+		description *string,
+	) error
+
 	// DeleteEnvironment deletes the environment envName in org orgName.
 	DeleteEnvironment(ctx context.Context, orgName, projectName, envName string) error
 
@@ -627,6 +653,49 @@ func (pc *client) UpdateEnvironmentWithRevision(
 	return nil, revision, nil
 }
 
+func (pc *client) CreateEnvironmentDraft(
+	ctx context.Context,
+	orgName string,
+	projectName string,
+	envName string,
+	yaml []byte,
+	tag string,
+) (string, []EnvironmentDiagnostic, error) {
+	header := http.Header{}
+	if tag != "" {
+		header.Set(etagHeader, tag)
+	}
+
+	var errResp EnvironmentErrorResponse
+	path := fmt.Sprintf("/api/preview/esc/environments/%v/%v/%v/drafts", orgName, projectName, envName)
+	var resp CreateEnvironmentDraftResponse
+	err := pc.restCallWithOptions(ctx, http.MethodPost, path, nil, json.RawMessage(yaml), &resp, httpCallOptions{
+		Header:        header,
+		ErrorResponse: &errResp,
+	})
+	if err != nil {
+		var diags *EnvironmentErrorResponse
+		if errors.As(err, &diags) && len(diags.Diagnostics) != 0 {
+			return "", diags.Diagnostics, nil
+		}
+		return "", nil, err
+	}
+
+	return resp.ChangeRequestID, nil, nil
+}
+
+func (pc *client) SubmitChangeRequest(
+	ctx context.Context,
+	orgName string,
+	changeRequestID string,
+	description *string,
+) error {
+	req := SubmitChangeRequestRequest{Description: description}
+	path := fmt.Sprintf("/api/preview/change-requests/%v/%v/submit", orgName, changeRequestID)
+	err := pc.restCall(ctx, http.MethodPost, path, nil, &req, nil)
+	return err
+}
+
 func (pc *client) DeleteEnvironment(ctx context.Context, orgName, projectName, envName string) error {
 	path := fmt.Sprintf("/api/esc/environments/%v/%v/%v", orgName, projectName, envName)
 	return pc.restCall(ctx, http.MethodDelete, path, nil, nil, nil)
diff --git a/cmd/esc/cli/env.go b/cmd/esc/cli/env.go
index 3ae5823..df1be50 100644
--- a/cmd/esc/cli/env.go
+++ b/cmd/esc/cli/env.go
@@ -389,3 +389,55 @@ func (cmd *envCommand) writePropertyEnvironmentDiagnostics(out io.Writer, diags
 
 	return nil
 }
+
+// changeRequestURL returns a URL to the change request in the Pulumi Cloud Console, rooted at cloudURL.
+// If there is an error, returns "".
+func (esc *escCommand) changeRequestURL(ref environmentRef, changeRequestID string) string {
+	return esc.cloudConsoleURL(
+		esc.client.URL(),
+		ref.orgName,
+		"esc",
+		ref.projectName,
+		ref.envName,
+		"change-requests",
+		changeRequestID,
+	)
+}
+
+// updateEnvironment updates an environment. If draft is true, a change request is created and submitted.
+// Progress is logged to stdout. However, diagnostics are not logged, that is left up to the caller.
+func (esc *escCommand) updateEnvironment(
+	ctx context.Context,
+	ref environmentRef,
+	draft bool,
+	yaml []byte,
+	tag string,
+	envUpdateSuccessMessage string,
+) ([]client.EnvironmentDiagnostic, error) {
+	if draft {
+		changeRequestID, diags, err := esc.client.CreateEnvironmentDraft(ctx, ref.orgName, ref.projectName, ref.envName, yaml, tag)
+		if err != nil {
+			return nil, fmt.Errorf("creating environment draft: %w", err)
+		}
+		if len(diags) == 0 {
+			fmt.Fprintf(esc.stdout, "Change request created: %v\n", changeRequestID)
+			fmt.Fprintf(esc.stdout, "Change request URL: %v\n", esc.changeRequestURL(ref, changeRequestID))
+
+			err = esc.client.SubmitChangeRequest(ctx, ref.orgName, changeRequestID, nil)
+			if err != nil {
+				return nil, fmt.Errorf("submitting change request: %w", err)
+			}
+			fmt.Fprintln(esc.stdout, "Change request submitted")
+		}
+		return diags, nil
+	} else {
+		diags, err := esc.client.UpdateEnvironmentWithProject(ctx, ref.orgName, ref.projectName, ref.envName, yaml, tag)
+		if err != nil {
+			return nil, fmt.Errorf("updating environment definition: %w", err)
+		}
+		if len(diags) == 0 && envUpdateSuccessMessage != "" {
+			fmt.Fprintln(esc.stdout, envUpdateSuccessMessage)
+		}
+		return diags, nil
+	}
+}
diff --git a/cmd/esc/cli/env_edit.go b/cmd/esc/cli/env_edit.go
index 819c213..e754edb 100644
--- a/cmd/esc/cli/env_edit.go
+++ b/cmd/esc/cli/env_edit.go
@@ -30,6 +30,7 @@ type envEditCommand struct {
 func newEnvEditCmd(env *envCommand) *cobra.Command {
 	var file string
 	var showSecrets bool
+	var draft bool
 
 	edit := &envEditCommand{env: env}
 
@@ -74,16 +75,16 @@ func newEnvEditCmd(env *envCommand) *cobra.Command {
 					return fmt.Errorf("reading environment definition: %w", err)
 				}
 
-				diags, err := edit.env.esc.client.UpdateEnvironmentWithProject(ctx, ref.orgName, ref.projectName, ref.envName, yaml, "")
+				diags, err := edit.env.esc.updateEnvironment(ctx, ref, draft, yaml, "", "Environment updated.")
 				if err != nil {
-					return fmt.Errorf("updating environment definition: %w", err)
+					return err
 				}
-				if len(diags) == 0 {
-					fmt.Fprintln(edit.env.esc.stdout, "Environment updated.")
-					return nil
+
+				if len(diags) != 0 {
+					return edit.env.writeYAMLEnvironmentDiagnostics(edit.env.esc.stderr, ref.envName, yaml, diags)
 				}
 
-				return edit.env.writeYAMLEnvironmentDiagnostics(edit.env.esc.stderr, ref.envName, yaml, diags)
+				return nil
 			}
 
 			editor, err := edit.getEditor()
@@ -112,12 +113,12 @@ func newEnvEditCmd(env *envCommand) *cobra.Command {
 					return nil
 				}
 
-				diags, err = edit.env.esc.client.UpdateEnvironmentWithProject(ctx, ref.orgName, ref.projectName, ref.envName, newYAML, tag)
+				diags, err := edit.env.esc.updateEnvironment(ctx, ref, draft, newYAML, tag, "Environment updated.")
 				if err != nil {
-					return fmt.Errorf("updating environment definition: %w", err)
+					return err
 				}
+
 				if len(diags) == 0 {
-					fmt.Fprintln(edit.env.esc.stdout, "Environment updated.")
 					return nil
 				}
 
@@ -150,6 +151,14 @@ func newEnvEditCmd(env *envCommand) *cobra.Command {
 		&showSecrets, "show-secrets", false,
 		"Show static secrets in plaintext rather than ciphertext")
 
+	cmd.Flags().BoolVar(
+		&draft, "draft", false,
+		"true to create a draft rather than saving changes directly, returns a submitted Change Request ID and its URL")
+	err := cmd.Flags().MarkHidden("draft") // hide while in preview
+	if err != nil {
+		panic(err)
+	}
+
 	return cmd
 }
 
diff --git a/cmd/esc/cli/env_set.go b/cmd/esc/cli/env_set.go
index c5eb8b4..6caf57e 100644
--- a/cmd/esc/cli/env_set.go
+++ b/cmd/esc/cli/env_set.go
@@ -22,6 +22,7 @@ func newEnvSetCmd(env *envCommand) *cobra.Command {
 	var secret bool
 	var plaintext bool
 	var rawString bool
+	var draft bool
 
 	cmd := &cobra.Command{
 		Use:   "set [<org-name>/][<project-name>/]<environment-name> <path> <value>",
@@ -140,13 +141,15 @@ func newEnvSetCmd(env *envCommand) *cobra.Command {
 				return fmt.Errorf("marshaling definition: %w", err)
 			}
 
-			diags, err := env.esc.client.UpdateEnvironmentWithProject(ctx, ref.orgName, ref.projectName, ref.envName, newYAML, tag)
+			diags, err := env.esc.updateEnvironment(ctx, ref, draft, newYAML, tag, "")
 			if err != nil {
-				return fmt.Errorf("updating environment definition: %w", err)
+				return err
 			}
+
 			if len(diags) != 0 {
 				return env.writePropertyEnvironmentDiagnostics(env.esc.stderr, diags)
 			}
+
 			return nil
 		},
 	}
@@ -160,6 +163,13 @@ func newEnvSetCmd(env *envCommand) *cobra.Command {
 	cmd.Flags().BoolVar(
 		&rawString, "string", false,
 		"true to treat the value as a string rather than attempting to parse it as YAML")
+	cmd.Flags().BoolVar(
+		&draft, "draft", false,
+		"true to create a draft rather than saving changes directly, returns a submitted Change Request ID and its URL")
+	err := cmd.Flags().MarkHidden("draft") // hide while in preview
+	if err != nil {
+		panic(err)
+	}
 
 	return cmd
 }
diff --git a/cmd/esc/cli/env_version_rollback.go b/cmd/esc/cli/env_version_rollback.go
index f099443..b1a2d70 100644
--- a/cmd/esc/cli/env_version_rollback.go
+++ b/cmd/esc/cli/env_version_rollback.go
@@ -5,13 +5,14 @@ package cli
 import (
 	"context"
 	"errors"
-	"fmt"
 
 	"github.com/pulumi/pulumi/sdk/v3/go/common/util/contract"
 	"github.com/spf13/cobra"
 )
 
 func newEnvVersionRollbackCmd(env *envCommand) *cobra.Command {
+	var draft bool
+
 	cmd := &cobra.Command{
 		Use:   "rollback [<org-name>/][<project-name>/]<environment-name>@<version>",
 		Args:  cobra.ExactArgs(1),
@@ -42,19 +43,29 @@ func newEnvVersionRollbackCmd(env *envCommand) *cobra.Command {
 			if err != nil {
 				return err
 			}
-			diags, err := env.esc.client.UpdateEnvironmentWithProject(ctx, ref.orgName, ref.projectName, ref.envName, yaml, "")
+
+			diags, err := env.esc.updateEnvironment(ctx, ref, draft, yaml, "", "Environment updated.")
 			if err != nil {
 				return err
 			}
+
 			if len(diags) != 0 {
 				err = env.writeYAMLEnvironmentDiagnostics(env.esc.stderr, ref.envName, yaml, diags)
 				contract.IgnoreError(err)
 				return errors.New("could not roll back: too many errors")
 			}
-			fmt.Fprintln(env.esc.stdout, "Environment updated.")
+
 			return nil
 		},
 	}
 
+	cmd.Flags().BoolVar(
+		&draft, "draft", false,
+		"true to create a draft rather than saving changes directly, returns a submitted Change Request ID and its URL")
+	err := cmd.Flags().MarkHidden("draft") // hide while in preview
+	if err != nil {
+		panic(err)
+	}
+
 	return cmd
 }


Test patch:
diff --git a/cmd/esc/cli/cli_test.go b/cmd/esc/cli/cli_test.go
index 6675f06..0a224e8 100644
--- a/cmd/esc/cli/cli_test.go
+++ b/cmd/esc/cli/cli_test.go
@@ -458,7 +458,7 @@ func (c *testPulumiClient) Insecure() bool {
 
 // URL returns the URL of the API endpoint this client interacts with
 func (c *testPulumiClient) URL() string {
-	return "http://fake.pulumi.api"
+	return "https://api.fake.pulumi.com"
 }
 
 // GetPulumiAccountDetails returns the user implied by the API token associated with this client.
@@ -662,6 +662,39 @@ func (c *testPulumiClient) UpdateEnvironmentWithRevision(
 	return diags, env.revisionTags["latest"], err
 }
 
+func (c *testPulumiClient) CreateEnvironmentDraft(
+	ctx context.Context,
+	orgName string,
+	projectName string,
+	envName string,
+	yaml []byte,
+	etag string,
+) (string, []client.EnvironmentDiagnostic, error) {
+	_, latest, err := c.getEnvironment(orgName, projectName, envName, "")
+	if err != nil {
+		return "", nil, err
+	}
+
+	if etag != "" && etag != latest.etag {
+		return "", nil, errors.New("etag mismatch")
+	}
+
+	_, diags, err := c.checkEnvironment(ctx, orgName, envName, yaml, nil)
+	if err == nil && len(diags) == 0 {
+		return "00000000-0000-0000-0000-000000000000", []client.EnvironmentDiagnostic{}, nil
+	}
+	return "", diags, err
+}
+
+func (c *testPulumiClient) SubmitChangeRequest(
+	ctx context.Context,
+	orgName string,
+	changeRequestID string,
+	description *string,
+) error {
+	return nil
+}
+
 func (c *testPulumiClient) DeleteEnvironment(ctx context.Context, orgName, projectName, envName string) error {
 	name := path.Join(orgName, projectName, envName)
 	if _, ok := c.environments[name]; !ok {
@@ -1362,13 +1395,13 @@ func loadTestcase(path string) (*cliTestcaseYAML, *cliTestcase, error) {
 	}
 
 	creds := workspace.Credentials{
-		Current: "http://fake.pulumi.api",
+		Current: "https://api.fake.pulumi.com",
 		Accounts: map[string]workspace.Account{
 			"https://api.pulumi.com": {
 				Username:    "test-user",
 				AccessToken: "access-token",
 			},
-			"http://fake.pulumi.api": {
+			"https://api.fake.pulumi.com": {
 				Username:    "test-user",
 				AccessToken: "access-token",
 			},
diff --git a/cmd/esc/cli/client/client_test.go b/cmd/esc/cli/client/client_test.go
index d31e72e..7dc4e3a 100644
--- a/cmd/esc/cli/client/client_test.go
+++ b/cmd/esc/cli/client/client_test.go
@@ -387,6 +387,185 @@ func TestUpdateEnvironment(t *testing.T) {
 	})
 }
 
+func TestCreateEnvironmentDraft(t *testing.T) {
+	t.Run("OK", func(t *testing.T) {
+		yaml := []byte("new definition")
+		tag := "old tag"
+
+		client := newTestClient(t, http.MethodPost, "/api/preview/esc/environments/test-org/test-project/test-env/drafts", func(w http.ResponseWriter, r *http.Request) {
+			assert.Equal(t, tag, r.Header.Get("ETag"))
+
+			body, err := io.ReadAll(r.Body)
+			require.NoError(t, err)
+			assert.Equal(t, yaml, body)
+
+			err = json.NewEncoder(w).Encode(CreateEnvironmentDraftResponse{
+				ChangeRequestID:      "EXAMPLE",
+				LatestRevisionNumber: 32423432,
+			})
+			require.NoError(t, err)
+			w.WriteHeader(http.StatusOK)
+		})
+
+		changeRequestID, diags, err := client.CreateEnvironmentDraft(context.Background(), "test-org", "test-project", "test-env", yaml, tag)
+		require.NoError(t, err)
+		assert.Equal(t, "EXAMPLE", changeRequestID)
+		assert.Len(t, diags, 0)
+	})
+
+	t.Run("Diags", func(t *testing.T) {
+		expected := []EnvironmentDiagnostic{
+			{
+				Range: &esc.Range{
+					Environment: "test-env",
+					Begin:       esc.Pos{Line: 42, Column: 1},
+					End:         esc.Pos{Line: 42, Column: 42},
+				},
+				Summary: "diag 1",
+			},
+			{
+				Range: &esc.Range{
+					Environment: "import-env",
+					Begin:       esc.Pos{Line: 1, Column: 2},
+					End:         esc.Pos{Line: 3, Column: 4},
+				},
+				Summary: "diag 2",
+			},
+		}
+
+		client := newTestClient(t, http.MethodPost, "/api/preview/esc/environments/test-org/test-project/test-env/drafts", func(w http.ResponseWriter, r *http.Request) {
+			w.WriteHeader(http.StatusBadRequest)
+
+			err := json.NewEncoder(w).Encode(EnvironmentErrorResponse{
+				Code:        400,
+				Message:     "bad request",
+				Diagnostics: expected,
+			})
+			require.NoError(t, err)
+		})
+
+		changeRequestID, diags, err := client.CreateEnvironmentDraft(context.Background(), "test-org", "test-project", "test-env", nil, "")
+		require.Equal(t, "", changeRequestID)
+		assert.Equal(t, expected, diags)
+		require.NoError(t, err)
+	})
+
+	t.Run("Diags only", func(t *testing.T) {
+		expected := []EnvironmentDiagnostic{
+			{
+				Range: &esc.Range{
+					Environment: "test-env",
+					Begin:       esc.Pos{Line: 42, Column: 1},
+					End:         esc.Pos{Line: 42, Column: 42},
+				},
+				Summary: "diag 1",
+			},
+			{
+				Range: &esc.Range{
+					Environment: "import-env",
+					Begin:       esc.Pos{Line: 1, Column: 2},
+					End:         esc.Pos{Line: 3, Column: 4},
+				},
+				Summary: "diag 2",
+			},
+		}
+
+		client := newTestClient(t, http.MethodPost, "/api/preview/esc/environments/test-org/test-project/test-env/drafts", func(w http.ResponseWriter, r *http.Request) {
+			w.WriteHeader(http.StatusBadRequest)
+
+			err := json.NewEncoder(w).Encode(EnvironmentErrorResponse{Diagnostics: expected})
+			require.NoError(t, err)
+		})
+
+		changeRequestID, diags, err := client.CreateEnvironmentDraft(context.Background(), "test-org", "test-project", "test-env", nil, "")
+		require.Equal(t, "", changeRequestID)
+		assert.Equal(t, expected, diags)
+		require.NoError(t, err)
+	})
+
+	t.Run("Conflict", func(t *testing.T) {
+		client := newTestClient(t, http.MethodPost, "/api/preview/esc/environments/test-org/test-project/test-env/drafts", func(w http.ResponseWriter, r *http.Request) {
+			w.WriteHeader(http.StatusConflict)
+
+			err := json.NewEncoder(w).Encode(apitype.ErrorResponse{
+				Code:    409,
+				Message: "conflict",
+			})
+			require.NoError(t, err)
+		})
+		changeRequestID, _, err := client.CreateEnvironmentDraft(context.Background(), "test-org", "test-project", "test-env", nil, "")
+		require.Equal(t, "", changeRequestID)
+		assert.ErrorContains(t, err, "conflict")
+	})
+}
+
+func TestSubmitChangeRequest(t *testing.T) {
+	t.Run("OK - nil description", func(t *testing.T) {
+		client := newTestClient(t, http.MethodPost, "/api/preview/change-requests/test-org/EXAMPLE/submit", func(w http.ResponseWriter, r *http.Request) {
+			expectedBody := SubmitChangeRequestRequest{}
+			expectedBodyJSON, err := json.Marshal(expectedBody)
+			require.NoError(t, err)
+			body, err := io.ReadAll(r.Body)
+			require.NoError(t, err)
+			assert.Equal(t, expectedBodyJSON, body)
+
+			w.WriteHeader(http.StatusOK)
+		})
+
+		err := client.SubmitChangeRequest(context.Background(), "test-org", "EXAMPLE", nil)
+		require.NoError(t, err)
+	})
+
+	t.Run("OK - empty description", func(t *testing.T) {
+		client := newTestClient(t, http.MethodPost, "/api/preview/change-requests/test-org/EXAMPLE/submit", func(w http.ResponseWriter, r *http.Request) {
+			expectedBody := SubmitChangeRequestRequest{}
+			expectedBodyJSON, err := json.Marshal(expectedBody)
+			require.NoError(t, err)
+			body, err := io.ReadAll(r.Body)
+			require.NoError(t, err)
+			assert.Equal(t, expectedBodyJSON, body)
+
+			w.WriteHeader(http.StatusOK)
+		})
+
+		err := client.SubmitChangeRequest(context.Background(), "test-org", "EXAMPLE", nil)
+		require.NoError(t, err)
+	})
+
+	t.Run("OK - description provided", func(t *testing.T) {
+		client := newTestClient(t, http.MethodPost, "/api/preview/change-requests/test-org/EXAMPLE/submit", func(w http.ResponseWriter, r *http.Request) {
+			description := "test description"
+			expectedBody := SubmitChangeRequestRequest{Description: &description}
+			expectedBodyJSON, err := json.Marshal(expectedBody)
+			require.NoError(t, err)
+			body, err := io.ReadAll(r.Body)
+			require.NoError(t, err)
+			assert.Equal(t, expectedBodyJSON, body)
+
+			w.WriteHeader(http.StatusOK)
+		})
+
+		description := "test description"
+		err := client.SubmitChangeRequest(context.Background(), "test-org", "EXAMPLE", &description)
+		require.NoError(t, err)
+	})
+
+	t.Run("Already submitted", func(t *testing.T) {
+		client := newTestClient(t, http.MethodPost, "/api/preview/change-requests/test-org/EXAMPLE/submit", func(w http.ResponseWriter, r *http.Request) {
+			w.WriteHeader(http.StatusBadRequest)
+
+			err := json.NewEncoder(w).Encode(apitype.ErrorResponse{
+				Code:    400,
+				Message: "Bad Request: change request must be in draft status to submit (current status: ready): invalid request",
+			})
+			require.NoError(t, err)
+		})
+
+		err := client.SubmitChangeRequest(context.Background(), "test-org", "EXAMPLE", nil)
+		assert.ErrorContains(t, err, "change request must be in draft status to submit ")
+	})
+}
+
 func TestDeleteEnvironment(t *testing.T) {
 	t.Run("OK", func(t *testing.T) {
 		client := newTestClient(t, http.MethodDelete, "/api/esc/environments/test-org/test-project/test-env", func(w http.ResponseWriter, r *http.Request) {
diff --git a/cmd/esc/cli/testdata/env-edit-editor-invalid-draft.yaml b/cmd/esc/cli/testdata/env-edit-editor-invalid-draft.yaml
new file mode 100644
index 0000000..e721a45
--- /dev/null
+++ b/cmd/esc/cli/testdata/env-edit-editor-invalid-draft.yaml
@@ -0,0 +1,24 @@
+run: esc env edit default/test --draft
+process:
+  environ:
+    EDITOR: my-editor
+  commands:
+    my-editor: |
+      echo -e "imports:\n" >$1
+environments:
+  test-user/default/test:
+    values:
+      foo: bar
+
+---
+> esc env edit default/test --draft
+
+---
+> esc env edit default/test --draft
+Error: imports must be a list
+
+  on test line 1:
+   1: imports:
+
+Press ENTER to continue editing or ^D to exit
+Aborting edit.
diff --git a/cmd/esc/cli/testdata/env-edit-editor-ok-draft.yaml b/cmd/esc/cli/testdata/env-edit-editor-ok-draft.yaml
new file mode 100644
index 0000000..5cf5834
--- /dev/null
+++ b/cmd/esc/cli/testdata/env-edit-editor-ok-draft.yaml
@@ -0,0 +1,21 @@
+run: |
+  esc env edit default/test --draft
+process:
+  environ:
+    EDITOR: my-editor
+  commands:
+    my-editor: |
+      echo -e "values:\n  foo: baz\n" >$1
+environments:
+  test-user/default/test:
+    values:
+      foo: bar
+
+---
+> esc env edit default/test --draft
+Change request created: 00000000-0000-0000-0000-000000000000
+Change request URL: https://app.fake.pulumi.com/test-user/esc/default/test/change-requests/00000000-0000-0000-0000-000000000000
+Change request submitted
+
+---
+> esc env edit default/test --draft
diff --git a/cmd/esc/cli/testdata/env-set-draft.yaml b/cmd/esc/cli/testdata/env-set-draft.yaml
new file mode 100644
index 0000000..3398876
--- /dev/null
+++ b/cmd/esc/cli/testdata/env-set-draft.yaml
@@ -0,0 +1,20 @@
+run: |
+  esc env init default/test
+  esc env set default/test foo.bar.baz qux --draft
+  esc env set default/test path "[a" --draft
+error: exit status 1
+
+---
+> esc env init default/test
+Environment created: test-user/default/test
+> esc env set default/test foo.bar.baz qux --draft
+Change request created: 00000000-0000-0000-0000-000000000000
+Change request URL: https://app.fake.pulumi.com/test-user/esc/default/test/change-requests/00000000-0000-0000-0000-000000000000
+Change request submitted
+> esc env set default/test path [a --draft
+
+---
+> esc env init default/test
+> esc env set default/test foo.bar.baz qux --draft
+> esc env set default/test path [a --draft
+Error: invalid value: yaml: line 1: did not find expected ',' or ']'
diff --git a/cmd/esc/cli/testdata/env-version-rollback-draft.yaml b/cmd/esc/cli/testdata/env-version-rollback-draft.yaml
new file mode 100644
index 0000000..506ddca
--- /dev/null
+++ b/cmd/esc/cli/testdata/env-version-rollback-draft.yaml
@@ -0,0 +1,52 @@
+run: |
+  esc env version rollback default/test@stable --draft
+  esc env version rollback default/test@2 --draft
+error: exit status 1
+environments:
+  test-user/default/a: {}
+  test-user/default/b: {}
+  test-user/default/test:
+    revisions:
+      - yaml:
+          imports:
+            - c
+          values: {}
+      - yaml:
+          values:
+            string: hello, world!
+        tags:
+          - stable
+      - yaml:
+          imports:
+            - a
+            - b
+          values:
+            # comment
+            "null": null
+            boolean: true
+            number: 42
+            string: esc
+            array: [hello, world]
+            object: {hello: world}
+            open:
+              fn::open::test: echo
+            secret:
+              fn::secret:
+                ciphertext: ZXNjeAAAAAHz5ePy5fTB4+Pl8/PL5fnJxPD7
+
+---
+> esc env version rollback default/test@stable --draft
+Change request created: 00000000-0000-0000-0000-000000000000
+Change request URL: https://app.fake.pulumi.com/test-user/esc/default/test/change-requests/00000000-0000-0000-0000-000000000000
+Change request submitted
+> esc env version rollback default/test@2 --draft
+
+---
+> esc env version rollback default/test@stable --draft
+> esc env version rollback default/test@2 --draft
+Error: not found
+
+  on test line 2:
+   2:     - c
+
+Error: could not roll back: too many errors
diff --git a/cmd/esc/cli/testdata/login-logout.yaml b/cmd/esc/cli/testdata/login-logout.yaml
index aac37c4..4e35487 100644
--- a/cmd/esc/cli/testdata/login-logout.yaml
+++ b/cmd/esc/cli/testdata/login-logout.yaml
@@ -1,7 +1,7 @@
 run: |
   esc login
   esc login https://api.pulumi.com
-  esc login http://fake.pulumi.api
+  esc login https://api.fake.pulumi.com
   esc logout
   esc login || echo ok
   esc login https://api.pulumi.com
@@ -10,13 +10,13 @@ run: |
 
 ---
 > esc login
-Logged in to http://fake.pulumi.api as test-user ()
+Logged in to https://api.fake.pulumi.com as test-user (https://app.fake.pulumi.com/test-user)
 > esc login https://api.pulumi.com
 Logged in to pulumi.com as test-user (https://app.pulumi.com/test-user)
-> esc login http://fake.pulumi.api
-Logged in to http://fake.pulumi.api as test-user ()
+> esc login https://api.fake.pulumi.com
+Logged in to https://api.fake.pulumi.com as test-user (https://app.fake.pulumi.com/test-user)
 > esc logout
-Logged out of http://fake.pulumi.api
+Logged out of https://api.fake.pulumi.com
 > esc login
 Logged in to pulumi.com as test-user (https://app.pulumi.com/test-user)
 > esc login https://api.pulumi.com
@@ -29,7 +29,7 @@ Logged in to pulumi.com as test-user (https://app.pulumi.com/test-user)
 ---
 > esc login
 > esc login https://api.pulumi.com
-> esc login http://fake.pulumi.api
+> esc login https://api.fake.pulumi.com
 > esc logout
 > esc login
 > esc login https://api.pulumi.com