diff --git a/apptrust/commands/flags.go b/apptrust/commands/flags.go index a454568..1347225 100644 --- a/apptrust/commands/flags.go +++ b/apptrust/commands/flags.go @@ -12,6 +12,7 @@ const ( VersionCreate = "version-create" VersionPromote = "version-promote" VersionDelete = "version-delete" + VersionRelease = "version-release" PackageBind = "package-bind" PackageUnbind = "package-unbind" AppCreate = "app-create" @@ -47,6 +48,7 @@ const ( DryRunFlag = "dry-run" ExcludeReposFlag = "exclude-repos" IncludeReposFlag = "include-repos" + PropsFlag = "props" ) // Flag keys mapped to their corresponding components.Flag definition. @@ -79,6 +81,7 @@ var flagsMap = map[string]components.Flag{ DryRunFlag: components.NewBoolFlag(DryRunFlag, "Perform a simulation of the operation.", components.WithBoolDefaultValueFalse()), ExcludeReposFlag: components.NewStringFlag(ExcludeReposFlag, "Semicolon-separated list of repositories to exclude.", func(f *components.StringFlag) { f.Mandatory = false }), IncludeReposFlag: components.NewStringFlag(IncludeReposFlag, "Semicolon-separated list of repositories to include.", func(f *components.StringFlag) { f.Mandatory = false }), + PropsFlag: components.NewStringFlag(PropsFlag, "Semicolon-separated list of properties in the form of 'key1=value1;key2=value2;...' to be added to each artifact.", func(f *components.StringFlag) { f.Mandatory = false }), } var commandFlags = map[string][]string{ @@ -105,6 +108,18 @@ var commandFlags = map[string][]string{ DryRunFlag, ExcludeReposFlag, IncludeReposFlag, + PropsFlag, + }, + VersionRelease: { + url, + user, + accessToken, + serverId, + SyncFlag, + PromotionTypeFlag, + ExcludeReposFlag, + IncludeReposFlag, + PropsFlag, }, VersionDelete: { url, diff --git a/apptrust/commands/version/promote_app_version_cmd.go b/apptrust/commands/version/promote_app_version_cmd.go index 9b2b7a2..197c544 100644 --- a/apptrust/commands/version/promote_app_version_cmd.go +++ b/apptrust/commands/version/promote_app_version_cmd.go @@ -70,35 +70,24 @@ func (pv *promoteAppVersionCommand) prepareAndRunCommand(ctx *components.Context func (pv *promoteAppVersionCommand) buildRequestPayload(ctx *components.Context) (*model.PromoteAppVersionRequest, error) { stage := ctx.Arguments[2] - var includedRepos []string - var excludedRepos []string - - if includeReposStr := ctx.GetStringFlagValue(commands.IncludeReposFlag); includeReposStr != "" { - includedRepos = utils.ParseSliceFlag(includeReposStr) - } - - if excludeReposStr := ctx.GetStringFlagValue(commands.ExcludeReposFlag); excludeReposStr != "" { - excludedRepos = utils.ParseSliceFlag(excludeReposStr) - } - - // Validate promotion type flag - promotionType := ctx.GetStringFlagValue(commands.PromotionTypeFlag) - validatedPromotionType, err := utils.ValidateEnumFlag(commands.PromotionTypeFlag, promotionType, model.PromotionTypeCopy, model.PromotionTypeValues) + promotionType, includedRepos, excludedRepos, err := BuildPromotionParams(ctx) if err != nil { return nil, err } - // If dry-run is true, override with dry_run - dryRun := ctx.GetBoolFlagValue(commands.DryRunFlag) - if dryRun { - validatedPromotionType = model.PromotionTypeDryRun + artifactProps, err := ParseArtifactProps(ctx) + if err != nil { + return nil, err } return &model.PromoteAppVersionRequest{ - Stage: stage, - PromotionType: validatedPromotionType, - IncludedRepositoryKeys: includedRepos, - ExcludedRepositoryKeys: excludedRepos, + Stage: stage, + CommonPromoteAppVersion: model.CommonPromoteAppVersion{ + PromotionType: promotionType, + IncludedRepositoryKeys: includedRepos, + ExcludedRepositoryKeys: excludedRepos, + ArtifactAdditionalProperties: artifactProps, + }, }, nil } diff --git a/apptrust/commands/version/promote_app_version_cmd_test.go b/apptrust/commands/version/promote_app_version_cmd_test.go index 83905c5..7bf8a9a 100644 --- a/apptrust/commands/version/promote_app_version_cmd_test.go +++ b/apptrust/commands/version/promote_app_version_cmd_test.go @@ -4,7 +4,6 @@ import ( "errors" "testing" - "github.com/jfrog/jfrog-cli-application/apptrust/commands" mockversions "github.com/jfrog/jfrog-cli-application/apptrust/service/versions/mocks" "go.uber.org/mock/gomock" @@ -89,25 +88,3 @@ func TestPromoteAppVersionCommand_Run_Error(t *testing.T) { assert.Error(t, err) assert.Contains(t, err.Error(), "service error occurred") } - -func TestPromoteAppVersionCommand_ServerDetails(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - serverDetails := &config.ServerDetails{} - cmd := &promoteAppVersionCommand{ - serverDetails: serverDetails, - } - - details, err := cmd.ServerDetails() - assert.NoError(t, err) - assert.Equal(t, serverDetails, details) -} - -func TestPromoteAppVersionCommand_CommandName(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - cmd := &promoteAppVersionCommand{} - assert.Equal(t, commands.VersionPromote, cmd.CommandName()) -} diff --git a/apptrust/commands/version/release_app_version_cmd.go b/apptrust/commands/version/release_app_version_cmd.go new file mode 100644 index 0000000..eb07ce9 --- /dev/null +++ b/apptrust/commands/version/release_app_version_cmd.go @@ -0,0 +1,113 @@ +package version + +//go:generate ${PROJECT_DIR}/scripts/mockgen.sh ${GOFILE} + +import ( + "github.com/jfrog/jfrog-cli-application/apptrust/app" + "github.com/jfrog/jfrog-cli-application/apptrust/commands" + "github.com/jfrog/jfrog-cli-application/apptrust/commands/utils" + "github.com/jfrog/jfrog-cli-application/apptrust/common" + "github.com/jfrog/jfrog-cli-application/apptrust/model" + "github.com/jfrog/jfrog-cli-application/apptrust/service" + "github.com/jfrog/jfrog-cli-application/apptrust/service/versions" + commonCLiCommands "github.com/jfrog/jfrog-cli-core/v2/common/commands" + pluginsCommon "github.com/jfrog/jfrog-cli-core/v2/plugins/common" + "github.com/jfrog/jfrog-cli-core/v2/plugins/components" + coreConfig "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/jfrog/jfrog-client-go/utils/errorutils" +) + +type releaseAppVersionCommand struct { + versionService versions.VersionService + serverDetails *coreConfig.ServerDetails + applicationKey string + version string + requestPayload *model.ReleaseAppVersionRequest + sync bool +} + +func (rv *releaseAppVersionCommand) Run() error { + ctx, err := service.NewContext(*rv.serverDetails) + if err != nil { + return err + } + + return rv.versionService.ReleaseAppVersion(ctx, rv.applicationKey, rv.version, rv.requestPayload, rv.sync) +} + +func (rv *releaseAppVersionCommand) ServerDetails() (*coreConfig.ServerDetails, error) { + return rv.serverDetails, nil +} + +func (rv *releaseAppVersionCommand) CommandName() string { + return commands.VersionRelease +} + +func (rv *releaseAppVersionCommand) prepareAndRunCommand(ctx *components.Context) error { + if len(ctx.Arguments) != 2 { + return pluginsCommon.WrongNumberOfArgumentsHandler(ctx) + } + + // Extract from arguments + rv.applicationKey = ctx.Arguments[0] + rv.version = ctx.Arguments[1] + + // Extract sync flag value + rv.sync = ctx.GetBoolTFlagValue(commands.SyncFlag) + + serverDetails, err := utils.ServerDetailsByFlags(ctx) + if err != nil { + return err + } + rv.serverDetails = serverDetails + rv.requestPayload, err = rv.buildRequestPayload(ctx) + if errorutils.CheckError(err) != nil { + return err + } + return commonCLiCommands.Exec(rv) +} + +func (rv *releaseAppVersionCommand) buildRequestPayload(ctx *components.Context) (*model.ReleaseAppVersionRequest, error) { + promotionType, includedRepos, excludedRepos, err := BuildPromotionParams(ctx) + if err != nil { + return nil, err + } + + artifactProps, err := ParseArtifactProps(ctx) + if err != nil { + return nil, err + } + + return model.NewReleaseAppVersionRequest( + promotionType, + includedRepos, + excludedRepos, + artifactProps, + ), nil +} + +func GetReleaseAppVersionCommand(appContext app.Context) components.Command { + cmd := &releaseAppVersionCommand{ + versionService: appContext.GetVersionService(), + } + return components.Command{ + Name: commands.VersionRelease, + Description: "Release application version.", + Category: common.CategoryVersion, + Aliases: []string{"vr"}, + Arguments: []components.Argument{ + { + Name: "application-key", + Description: "The application key.", + Optional: false, + }, + { + Name: "version", + Description: "The version to release.", + Optional: false, + }, + }, + Flags: commands.GetCommandFlags(commands.VersionRelease), + Action: cmd.prepareAndRunCommand, + } +} diff --git a/apptrust/commands/version/release_app_version_cmd_test.go b/apptrust/commands/version/release_app_version_cmd_test.go new file mode 100644 index 0000000..ea5fc2e --- /dev/null +++ b/apptrust/commands/version/release_app_version_cmd_test.go @@ -0,0 +1,95 @@ +package version + +import ( + "errors" + "testing" + + mockversions "github.com/jfrog/jfrog-cli-application/apptrust/service/versions/mocks" + "go.uber.org/mock/gomock" + + "github.com/jfrog/jfrog-cli-application/apptrust/model" + "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/stretchr/testify/assert" +) + +func TestReleaseAppVersionCommand_Run(t *testing.T) { + tests := []struct { + name string + sync bool + }{ + { + name: "sync flag true", + sync: true, + }, + { + name: "sync flag false", + sync: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + serverDetails := &config.ServerDetails{Url: "https://example.com"} + applicationKey := "app-key" + version := "1.0.0" + requestPayload := model.NewReleaseAppVersionRequest( + model.PromotionTypeCopy, + nil, // includedRepos + nil, // excludedRepos + nil, // artifactProps + ) + + mockVersionService := mockversions.NewMockVersionService(ctrl) + mockVersionService.EXPECT().ReleaseAppVersion(gomock.Any(), applicationKey, version, requestPayload, tt.sync). + Return(nil).Times(1) + + cmd := &releaseAppVersionCommand{ + versionService: mockVersionService, + serverDetails: serverDetails, + applicationKey: applicationKey, + version: version, + requestPayload: requestPayload, + sync: tt.sync, + } + + err := cmd.Run() + assert.NoError(t, err) + }) + } +} + +func TestReleaseAppVersionCommand_Run_Error(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + serverDetails := &config.ServerDetails{Url: "https://example.com"} + applicationKey := "app-key" + version := "1.0.0" + requestPayload := model.NewReleaseAppVersionRequest( + model.PromotionTypeCopy, + nil, // includedRepos + nil, // excludedRepos + nil, // artifactProps + ) + expectedError := errors.New("service error occurred") + + mockVersionService := mockversions.NewMockVersionService(ctrl) + mockVersionService.EXPECT().ReleaseAppVersion(gomock.Any(), applicationKey, version, requestPayload, false). + Return(expectedError).Times(1) + + cmd := &releaseAppVersionCommand{ + versionService: mockVersionService, + serverDetails: serverDetails, + applicationKey: applicationKey, + version: version, + requestPayload: requestPayload, + sync: false, + } + + err := cmd.Run() + assert.Error(t, err) + assert.Contains(t, err.Error(), "service error occurred") +} diff --git a/apptrust/commands/version/version_utils.go b/apptrust/commands/version/version_utils.go new file mode 100644 index 0000000..a14c2b0 --- /dev/null +++ b/apptrust/commands/version/version_utils.go @@ -0,0 +1,51 @@ +package version + +import ( + "github.com/jfrog/jfrog-cli-application/apptrust/commands" + "github.com/jfrog/jfrog-cli-application/apptrust/commands/utils" + "github.com/jfrog/jfrog-cli-application/apptrust/model" + "github.com/jfrog/jfrog-cli-core/v2/plugins/components" + "github.com/jfrog/jfrog-client-go/utils/errorutils" +) + +// BuildPromotionParams extracts common promotion parameters from command context +// Used by both promote and release commands +func BuildPromotionParams(ctx *components.Context) (string, []string, []string, error) { + var includedRepos []string + var excludedRepos []string + + if includeReposStr := ctx.GetStringFlagValue(commands.IncludeReposFlag); includeReposStr != "" { + includedRepos = utils.ParseSliceFlag(includeReposStr) + } + + if excludeReposStr := ctx.GetStringFlagValue(commands.ExcludeReposFlag); excludeReposStr != "" { + excludedRepos = utils.ParseSliceFlag(excludeReposStr) + } + + promotionType := ctx.GetStringFlagValue(commands.PromotionTypeFlag) + + validatedPromotionType, err := utils.ValidateEnumFlag(commands.PromotionTypeFlag, promotionType, model.PromotionTypeCopy, model.PromotionTypeValues) + if err != nil { + return "", nil, nil, err + } + + // If dry-run is true, override with dry_run + dryRun := ctx.GetBoolFlagValue(commands.DryRunFlag) + if dryRun { + validatedPromotionType = model.PromotionTypeDryRun + } + + return validatedPromotionType, includedRepos, excludedRepos, nil +} + +// ParseArtifactProps extracts artifact properties from command context +func ParseArtifactProps(ctx *components.Context) (map[string]string, error) { + if propsStr := ctx.GetStringFlagValue(commands.PropsFlag); propsStr != "" { + props, err := utils.ParseMapFlag(propsStr) + if err != nil { + return nil, errorutils.CheckErrorf("failed to parse properties: %s", err.Error()) + } + return props, nil + } + return nil, nil +} diff --git a/apptrust/model/promote_app_version_request.go b/apptrust/model/promote_app_version_request.go index 6b27dec..2cc398c 100644 --- a/apptrust/model/promote_app_version_request.go +++ b/apptrust/model/promote_app_version_request.go @@ -14,9 +14,14 @@ var PromotionTypeValues = []string{ PromotionTypeMove, } +type CommonPromoteAppVersion struct { + PromotionType string `json:"promotion_type,omitempty"` + IncludedRepositoryKeys []string `json:"included_repository_keys,omitempty"` + ExcludedRepositoryKeys []string `json:"excluded_repository_keys,omitempty"` + ArtifactAdditionalProperties map[string]string `json:"artifact_additional_properties,omitempty"` +} + type PromoteAppVersionRequest struct { - Stage string `json:"stage"` - PromotionType string `json:"promotion_type,omitempty"` - IncludedRepositoryKeys []string `json:"included_repository_keys,omitempty"` - ExcludedRepositoryKeys []string `json:"excluded_repository_keys,omitempty"` + CommonPromoteAppVersion + Stage string `json:"stage"` } diff --git a/apptrust/model/release_app_version_request.go b/apptrust/model/release_app_version_request.go new file mode 100644 index 0000000..f0887f2 --- /dev/null +++ b/apptrust/model/release_app_version_request.go @@ -0,0 +1,26 @@ +package model + +// ReleaseAppVersionRequest represents a request to release an application version to production. +// This struct reuses CommonPromoteAppVersion for consistency with PromoteAppVersionRequest. +// A release is functionally promoted with a hardcoded stage ("prod") set by the backend, +// so the stage is not included here. +// This separation improves readability and intent in the codebase. +type ReleaseAppVersionRequest struct { + CommonPromoteAppVersion +} + +func NewReleaseAppVersionRequest( + promotionType string, + includedRepositoryKeys []string, + excludedRepositoryKeys []string, + artifactProperties map[string]string, +) *ReleaseAppVersionRequest { + return &ReleaseAppVersionRequest{ + CommonPromoteAppVersion: CommonPromoteAppVersion{ + PromotionType: promotionType, + IncludedRepositoryKeys: includedRepositoryKeys, + ExcludedRepositoryKeys: excludedRepositoryKeys, + ArtifactAdditionalProperties: artifactProperties, + }, + } +} diff --git a/apptrust/service/versions/version_service.go b/apptrust/service/versions/version_service.go index 4336678..5f3363e 100644 --- a/apptrust/service/versions/version_service.go +++ b/apptrust/service/versions/version_service.go @@ -14,6 +14,7 @@ import ( type VersionService interface { CreateAppVersion(ctx service.Context, request *model.CreateAppVersionRequest) error PromoteAppVersion(ctx service.Context, applicationKey string, version string, payload *model.PromoteAppVersionRequest, sync bool) error + ReleaseAppVersion(ctx service.Context, applicationKey string, version string, request *model.ReleaseAppVersionRequest, sync bool) error DeleteAppVersion(ctx service.Context, applicationKey string, version string) error } @@ -52,6 +53,21 @@ func (vs *versionService) PromoteAppVersion(ctx service.Context, applicationKey, return nil } +func (vs *versionService) ReleaseAppVersion(ctx service.Context, applicationKey, version string, request *model.ReleaseAppVersionRequest, sync bool) error { + endpoint := fmt.Sprintf("/v1/applications/%s/versions/%s/release", applicationKey, version) + response, responseBody, err := ctx.GetHttpClient().Post(endpoint, request, map[string]string{"async": strconv.FormatBool(!sync)}) + if err != nil { + return err + } + + if response.StatusCode >= 400 { + return fmt.Errorf("failed to release app version. Status code: %d. \n%s", + response.StatusCode, responseBody) + } + + return nil +} + func (vs *versionService) DeleteAppVersion(ctx service.Context, applicationKey, version string) error { url := fmt.Sprintf("/v1/applications/%s/versions/%s", applicationKey, version) response, responseBody, err := ctx.GetHttpClient().Delete(url, nil) diff --git a/apptrust/service/versions/version_service_test.go b/apptrust/service/versions/version_service_test.go index 45aa9b5..dfd660f 100644 --- a/apptrust/service/versions/version_service_test.go +++ b/apptrust/service/versions/version_service_test.go @@ -97,10 +97,12 @@ func TestPromoteAppVersion(t *testing.T) { applicationKey: "test-app", version: "1.0.0", payload: &model.PromoteAppVersionRequest{ - Stage: "prod", - PromotionType: model.PromotionTypeCopy, - IncludedRepositoryKeys: []string{"repo1", "repo2"}, - ExcludedRepositoryKeys: []string{"repo3"}, + Stage: "prod", + CommonPromoteAppVersion: model.CommonPromoteAppVersion{ + PromotionType: model.PromotionTypeCopy, + IncludedRepositoryKeys: []string{"repo1", "repo2"}, + ExcludedRepositoryKeys: []string{"repo3"}, + }, }, sync: true, expectedEndpoint: "/v1/applications/test-app/versions/1.0.0/promote", @@ -114,10 +116,12 @@ func TestPromoteAppVersion(t *testing.T) { applicationKey: "test-app", version: "1.0.0", payload: &model.PromoteAppVersionRequest{ - Stage: "prod", - PromotionType: model.PromotionTypeCopy, - IncludedRepositoryKeys: []string{"repo1", "repo2"}, - ExcludedRepositoryKeys: []string{"repo3"}, + Stage: "prod", + CommonPromoteAppVersion: model.CommonPromoteAppVersion{ + PromotionType: model.PromotionTypeCopy, + IncludedRepositoryKeys: []string{"repo1", "repo2"}, + ExcludedRepositoryKeys: []string{"repo3"}, + }, }, sync: false, expectedEndpoint: "/v1/applications/test-app/versions/1.0.0/promote", @@ -131,8 +135,10 @@ func TestPromoteAppVersion(t *testing.T) { applicationKey: "test-app", version: "1.0.0", payload: &model.PromoteAppVersionRequest{ - Stage: "prod", - PromotionType: model.PromotionTypeCopy, + Stage: "prod", + CommonPromoteAppVersion: model.CommonPromoteAppVersion{ + PromotionType: model.PromotionTypeCopy, + }, }, sync: true, expectedEndpoint: "/v1/applications/test-app/versions/1.0.0/promote", @@ -146,8 +152,10 @@ func TestPromoteAppVersion(t *testing.T) { applicationKey: "test-app", version: "1.0.0", payload: &model.PromoteAppVersionRequest{ - Stage: "prod", - PromotionType: model.PromotionTypeCopy, + Stage: "prod", + CommonPromoteAppVersion: model.CommonPromoteAppVersion{ + PromotionType: model.PromotionTypeCopy, + }, }, sync: false, expectedEndpoint: "/v1/applications/test-app/versions/1.0.0/promote", @@ -177,3 +185,111 @@ func TestPromoteAppVersion(t *testing.T) { }) } } + +func TestReleaseAppVersion(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + service := NewVersionService() + + tests := []struct { + name string + applicationKey string + version string + payload *model.ReleaseAppVersionRequest + sync bool + expectedEndpoint string + mockResponse *http.Response + mockResponseBody string + mockError error + expectedError string + }{ + { + name: "success with sync=true", + applicationKey: "test-app", + version: "1.0.0", + payload: model.NewReleaseAppVersionRequest( + model.PromotionTypeCopy, + []string{"repo1", "repo2"}, + []string{"repo3"}, + map[string]string{"key1": "value1"}, + ), + sync: true, + expectedEndpoint: "/v1/applications/test-app/versions/1.0.0/release", + mockResponse: &http.Response{StatusCode: 200}, + mockResponseBody: "{}", + mockError: nil, + expectedError: "", + }, + { + name: "success with sync=false", + applicationKey: "test-app", + version: "1.0.0", + payload: model.NewReleaseAppVersionRequest( + model.PromotionTypeCopy, + []string{"repo1", "repo2"}, + []string{"repo3"}, + map[string]string{"key1": "value1"}, + ), + sync: false, + expectedEndpoint: "/v1/applications/test-app/versions/1.0.0/release", + mockResponse: &http.Response{StatusCode: 202}, + mockResponseBody: "{}", + mockError: nil, + expectedError: "", + }, + { + name: "failure", + applicationKey: "test-app", + version: "1.0.0", + payload: model.NewReleaseAppVersionRequest( + model.PromotionTypeCopy, + nil, + nil, + nil, + ), + sync: true, + expectedEndpoint: "/v1/applications/test-app/versions/1.0.0/release", + mockResponse: &http.Response{StatusCode: 400}, + mockResponseBody: "error", + mockError: nil, + expectedError: "failed to release app version", + }, + { + name: "http client error", + applicationKey: "test-app", + version: "1.0.0", + payload: model.NewReleaseAppVersionRequest( + model.PromotionTypeCopy, + nil, + nil, + nil, + ), + sync: false, + expectedEndpoint: "/v1/applications/test-app/versions/1.0.0/release", + mockResponse: nil, + mockResponseBody: "", + mockError: errors.New("http client error"), + expectedError: "http client error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockHttpClient := mockhttp.NewMockApptrustHttpClient(ctrl) + mockHttpClient.EXPECT().Post(tt.expectedEndpoint, tt.payload, map[string]string{"async": strconv.FormatBool(!tt.sync)}). + Return(tt.mockResponse, []byte(tt.mockResponseBody), tt.mockError).Times(1) + + mockCtx := mockservice.NewMockContext(ctrl) + mockCtx.EXPECT().GetHttpClient().Return(mockHttpClient).Times(1) + + err := service.ReleaseAppVersion(mockCtx, tt.applicationKey, tt.version, tt.payload, tt.sync) + if tt.expectedError == "" { + assert.NoError(t, err) + } else { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedError) + } + }) + } +} diff --git a/cli/cli.go b/cli/cli.go index cfe79dc..01b6dc7 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -45,6 +45,7 @@ func GetJfrogCliApptrustApp() components.App { system.GetPingCommand(appContext), version.GetCreateAppVersionCommand(appContext), version.GetPromoteAppVersionCommand(appContext), + version.GetReleaseAppVersionCommand(appContext), version.GetDeleteAppVersionCommand(appContext), packagecmds.GetBindPackageCommand(appContext), packagecmds.GetUnbindPackageCommand(appContext),