From 2a8227840b64e7f5729e41f26ab8fd84e5165aca Mon Sep 17 00:00:00 2001 From: Arya Soni Date: Fri, 17 Oct 2025 19:47:54 +0530 Subject: [PATCH 1/4] allow force push option on git-push promotion step Signed-off-by: Arya Soni --- .../30-promotion-steps/git-push.md | 28 ++++ pkg/promotion/runner/builtin/git_pusher.go | 3 + .../runner/builtin/git_pusher_test.go | 139 ++++++++++++++++++ .../builtin/schemas/git-push-config.json | 5 + .../runner/builtin/zz_config_types.go | 5 + ui/src/gen/directives/git-push-config.json | 5 + 6 files changed, 185 insertions(+) diff --git a/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/git-push.md b/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/git-push.md index 18b8783394..96de627252 100644 --- a/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/git-push.md +++ b/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/git-push.md @@ -34,6 +34,7 @@ Stages that write to the same branch do not write to the same files. | `targetBranch` | `string` | N | The branch to push to in the remote repository. Mutually exclusive with `generateTargetBranch=true`. If neither of these is provided, the target branch will be the same as the branch currently checked out in the working tree. | | `maxAttempts` | `int32` | N | The maximum number of attempts to make when pushing to the remote repository. Default is 50. | | `generateTargetBranch` | `boolean` | N | Whether to push to a remote branch named like `kargo/promotion/`. If such a branch does not already exist, it will be created. A value of 'true' is mutually exclusive with `targetBranch`. If neither of these is provided, the target branch will be the currently checked out branch. This option is useful when a subsequent promotion step will open a pull request against a Stage-specific branch. In such a case, the generated target branch pushed to by the `git-push` step can later be utilized as the source branch of the pull request. | +| `force` | `boolean` | N | Whether to force push to the target branch, overwriting any existing history. This is useful for scenarios where you want to completely replace the branch content (e.g., pushing rendered manifests that don't depend on previous state). **Use with caution** as this will overwrite any commits that exist on the remote branch but not in your local branch. Default is `false`. | | `provider` | `string` | N | The name of the Git provider to use. Currently 'azure', 'bitbucket', 'gitea', 'github', and 'gitlab' are supported. Kargo will try to infer the provider if it is not explicitly specified. This setting does not affect the push operation but helps generate the correct [`commitURL` output](#output) when working with repositories where the provider cannot be automatically determined, such as self-hosted instances. | ## Output @@ -95,3 +96,30 @@ steps: generateTargetBranch: true # Open a PR and wait for it to be merged or closed... ``` + +### Force Push for Complete Branch Replacement + +In this example, the step force pushes to completely replace the content of a +target branch. This is useful when pushing rendered manifests or generated +content that doesn't depend on previous state and you want to ignore the git +history. + +:::warning +**Use force push with caution!** This will overwrite any commits that exist on +the remote branch but not in your local branch. This can cause data loss if +not used carefully. +::: + +```yaml +steps: +# Clone, prepare the contents of ./out, etc... +- uses: git-commit + config: + path: ./out + message: rendered updated manifests +- uses: git-push + config: + path: ./out + targetBranch: main + force: true +``` diff --git a/pkg/promotion/runner/builtin/git_pusher.go b/pkg/promotion/runner/builtin/git_pusher.go index 1aa543a07a..f2f83f0b5b 100644 --- a/pkg/promotion/runner/builtin/git_pusher.go +++ b/pkg/promotion/runner/builtin/git_pusher.go @@ -134,12 +134,15 @@ func (g *gitPushPusher) run( // Attempt to rebase on top of the state of the remote branch to help // avoid conflicts. PullRebase: true, + // Allow force push if explicitly requested in config + Force: cfg.Force, } // If we're supposed to generate a target branch name, do so. if cfg.GenerateTargetBranch { // TargetBranch and GenerateTargetBranch are mutually exclusive, so we're // never overwriting a user-specified target branch here. pushOpts.TargetBranch = fmt.Sprintf("kargo/promotion/%s", stepCtx.Promotion) + // Always force push for generated branches to ensure they can be overwritten pushOpts.Force = true } if pushOpts.TargetBranch == "" { diff --git a/pkg/promotion/runner/builtin/git_pusher_test.go b/pkg/promotion/runner/builtin/git_pusher_test.go index 2908ef6bc0..847900866d 100644 --- a/pkg/promotion/runner/builtin/git_pusher_test.go +++ b/pkg/promotion/runner/builtin/git_pusher_test.go @@ -126,6 +126,28 @@ func Test_gitPusher_convert(t *testing.T) { "targetBranch": "fake-branch", }, }, + { + name: "force is true", + config: promotion.Config{ // Should be completely valid + "path": "/fake/path", + "force": true, + }, + }, + { + name: "force is false", + config: promotion.Config{ // Should be completely valid + "path": "/fake/path", + "force": false, + }, + }, + { + name: "force with targetBranch", + config: promotion.Config{ // Should be completely valid + "path": "/fake/path", + "targetBranch": "fake-branch", + "force": true, + }, + }, } r := newGitPusher(promotion.StepRunnerCapabilities{}) @@ -250,3 +272,120 @@ func Test_gitPusher_run(t *testing.T) { actualCommitURL := res.Output[stateKeyCommitURL] require.Equal(t, expectedCommitURL, actualCommitURL) } + +func Test_gitPusher_run_withForcePush(t *testing.T) { + // Set up a test Git server in-process + service := gitkit.New( + gitkit.Config{ + Dir: t.TempDir(), + AutoCreate: true, + }, + ) + require.NoError(t, service.Setup()) + server := httptest.NewServer(service) + defer server.Close() + + // This is the URL of the "remote" repository + testRepoURL := fmt.Sprintf("%s/test.git", server.URL) + + workDir := t.TempDir() + + // Finagle a local bare repo and working tree into place the way that + // gitCloner might have so we can verify gitPusher's ability to reload the + // working tree from the file system. + repo, err := git.CloneBare( + testRepoURL, + nil, + &git.BareCloneOptions{ + BaseDir: workDir, + }, + ) + require.NoError(t, err) + defer repo.Close() + // "master" is still the default branch name for a new repository + // unless you configure it otherwise. + workTreePath := filepath.Join(workDir, "master") + workTree, err := repo.AddWorkTree( + workTreePath, + &git.AddWorkTreeOptions{Orphan: true}, + ) + require.NoError(t, err) + // `git worktree add` doesn't give much control over the branch name when you + // create an orphaned working tree, so we have to follow up with this to make + // the branch name look like what we wanted. gitCloner does this internally as + // well. + err = workTree.CreateOrphanedBranch("master") + require.NoError(t, err) + + // Write a file. + err = os.WriteFile(filepath.Join(workTree.Dir(), "test.txt"), []byte("foo"), 0600) + require.NoError(t, err) + + // Commit the changes similarly to how gitCommitter would + // have. It will be gitPushStepRunner's job to push this commit. + err = workTree.AddAllAndCommit("Initial commit", nil) + require.NoError(t, err) + + // Set up a fake git provider + // Cannot register multiple providers with the same name, so this takes + // care of that problem + fakeGitProviderName := uuid.NewString() + gitprovider.Register( + fakeGitProviderName, + gitprovider.Registration{ + Predicate: func(_ string) bool { + return true + }, + NewProvider: func( + string, + *gitprovider.Options, + ) (gitprovider.Interface, error) { + return &gitprovider.Fake{ + GetCommitURLFn: func( + repoURL string, + sha string, + ) (string, error) { + return fmt.Sprintf("%s/commit/%s", repoURL, sha), nil + }, + }, nil + }, + }, + ) + + // Now we can proceed to test gitPusher with force push... + + r := newGitPusher(promotion.StepRunnerCapabilities{ + CredsDB: &credentials.FakeDB{}, + }) + runner, ok := r.(*gitPushPusher) + require.True(t, ok) + require.NotNil(t, runner.branchMus) + + res, err := runner.run( + context.Background(), + &promotion.StepContext{ + Project: "fake-project", + Stage: "fake-stage", + Promotion: "fake-promotion", + WorkDir: workDir, + }, + builtin.GitPushConfig{ + Path: "master", + TargetBranch: "main", + Force: true, + Provider: ptr.To(builtin.Provider(fakeGitProviderName)), + }, + ) + require.NoError(t, err) + branchName, ok := res.Output[stateKeyBranch] + require.True(t, ok) + require.Equal(t, "main", branchName) + expectedCommit, err := workTree.LastCommitID() + require.NoError(t, err) + actualCommit, ok := res.Output[stateKeyCommit] + require.True(t, ok) + require.Equal(t, expectedCommit, actualCommit) + expectedCommitURL := fmt.Sprintf("%s/commit/%s", testRepoURL, expectedCommit) + actualCommitURL := res.Output[stateKeyCommitURL] + require.Equal(t, expectedCommitURL, actualCommitURL) +} diff --git a/pkg/promotion/runner/builtin/schemas/git-push-config.json b/pkg/promotion/runner/builtin/schemas/git-push-config.json index 94a6b650dc..7e6064d647 100644 --- a/pkg/promotion/runner/builtin/schemas/git-push-config.json +++ b/pkg/promotion/runner/builtin/schemas/git-push-config.json @@ -28,6 +28,11 @@ "type": "string", "description": "The name of the Git provider to use. Currently 'azure', 'bitbucket', 'gitea', 'github', and 'gitlab' are supported. Kargo will try to infer the provider if it is not explicitly specified.", "enum": ["azure", "bitbucket", "gitea", "github", "gitlab"] + }, + "force": { + "type": "boolean", + "description": "Whether to force push to the target branch, overwriting any existing history. This is useful for scenarios where you want to completely replace the branch content (e.g., pushing rendered manifests that don't depend on previous state). Use with caution as this will overwrite any commits that exist on the remote branch but not in your local branch.", + "default": false } }, "oneOf": [ diff --git a/pkg/x/promotion/runner/builtin/zz_config_types.go b/pkg/x/promotion/runner/builtin/zz_config_types.go index 1c196e0980..212e56480a 100644 --- a/pkg/x/promotion/runner/builtin/zz_config_types.go +++ b/pkg/x/promotion/runner/builtin/zz_config_types.go @@ -238,6 +238,11 @@ type GitPushConfig struct { // The target branch to push to. Mutually exclusive with 'generateTargetBranch=true'. If // neither of these is provided, the target branch will be the currently checked out branch. TargetBranch string `json:"targetBranch,omitempty"` + // Whether to force push to the target branch, overwriting any existing history. This is + // useful for scenarios where you want to completely replace the branch content (e.g., + // pushing rendered manifests that don't depend on previous state). Use with caution as this + // will overwrite any commits that exist on the remote branch but not in your local branch. + Force bool `json:"force,omitempty"` } type GitWaitForPRConfig struct { diff --git a/ui/src/gen/directives/git-push-config.json b/ui/src/gen/directives/git-push-config.json index b38aaa1177..2892c7053e 100644 --- a/ui/src/gen/directives/git-push-config.json +++ b/ui/src/gen/directives/git-push-config.json @@ -33,6 +33,11 @@ "github", "gitlab" ] + }, + "force": { + "type": "boolean", + "description": "Whether to force push to the target branch, overwriting any existing history. This is useful for scenarios where you want to completely replace the branch content (e.g., pushing rendered manifests that don't depend on previous state). Use with caution as this will overwrite any commits that exist on the remote branch but not in your local branch.", + "default": false } } } \ No newline at end of file From dc8a3076e8ede85eac2dc82a9480313db4856a23 Mon Sep 17 00:00:00 2001 From: Arya Soni Date: Fri, 17 Oct 2025 19:56:36 +0530 Subject: [PATCH 2/4] feat: allow force push option on git-push promotion step Signed-off-by: Arya Soni --- .../60-reference-docs/30-promotion-steps/git-push.md | 2 +- pkg/promotion/runner/builtin/git_pusher.go | 5 +++++ pkg/promotion/runner/builtin/schemas/git-push-config.json | 2 +- ui/src/gen/directives/git-push-config.json | 2 +- 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/git-push.md b/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/git-push.md index 96de627252..1e908c21b3 100644 --- a/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/git-push.md +++ b/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/git-push.md @@ -34,7 +34,7 @@ Stages that write to the same branch do not write to the same files. | `targetBranch` | `string` | N | The branch to push to in the remote repository. Mutually exclusive with `generateTargetBranch=true`. If neither of these is provided, the target branch will be the same as the branch currently checked out in the working tree. | | `maxAttempts` | `int32` | N | The maximum number of attempts to make when pushing to the remote repository. Default is 50. | | `generateTargetBranch` | `boolean` | N | Whether to push to a remote branch named like `kargo/promotion/`. If such a branch does not already exist, it will be created. A value of 'true' is mutually exclusive with `targetBranch`. If neither of these is provided, the target branch will be the currently checked out branch. This option is useful when a subsequent promotion step will open a pull request against a Stage-specific branch. In such a case, the generated target branch pushed to by the `git-push` step can later be utilized as the source branch of the pull request. | -| `force` | `boolean` | N | Whether to force push to the target branch, overwriting any existing history. This is useful for scenarios where you want to completely replace the branch content (e.g., pushing rendered manifests that don't depend on previous state). **Use with caution** as this will overwrite any commits that exist on the remote branch but not in your local branch. Default is `false`. | +| `force` | `boolean` | N | Whether to force push to the target branch, overwriting any existing history. This is useful for scenarios where you want to completely replace the branch content (e.g., pushing rendered manifests that don't depend on previous state). **Use with caution** as this will overwrite any commits that exist on the remote branch but not in your local branch. When `force: true`, the step will skip the pull/rebase operation to allow overwriting remote history. Default is `false`. | | `provider` | `string` | N | The name of the Git provider to use. Currently 'azure', 'bitbucket', 'gitea', 'github', and 'gitlab' are supported. Kargo will try to infer the provider if it is not explicitly specified. This setting does not affect the push operation but helps generate the correct [`commitURL` output](#output) when working with repositories where the provider cannot be automatically determined, such as self-hosted instances. | ## Output diff --git a/pkg/promotion/runner/builtin/git_pusher.go b/pkg/promotion/runner/builtin/git_pusher.go index f2f83f0b5b..763a598cec 100644 --- a/pkg/promotion/runner/builtin/git_pusher.go +++ b/pkg/promotion/runner/builtin/git_pusher.go @@ -145,6 +145,11 @@ func (g *gitPushPusher) run( // Always force push for generated branches to ensure they can be overwritten pushOpts.Force = true } + + // Disable pull/rebase when force pushing to allow overwriting remote history + if pushOpts.Force { + pushOpts.PullRebase = false + } if pushOpts.TargetBranch == "" { // If targetBranch is still empty, we want to set it to the current branch // because we will want to return the branch that was pushed to, but we diff --git a/pkg/promotion/runner/builtin/schemas/git-push-config.json b/pkg/promotion/runner/builtin/schemas/git-push-config.json index 7e6064d647..cedd00abdf 100644 --- a/pkg/promotion/runner/builtin/schemas/git-push-config.json +++ b/pkg/promotion/runner/builtin/schemas/git-push-config.json @@ -31,7 +31,7 @@ }, "force": { "type": "boolean", - "description": "Whether to force push to the target branch, overwriting any existing history. This is useful for scenarios where you want to completely replace the branch content (e.g., pushing rendered manifests that don't depend on previous state). Use with caution as this will overwrite any commits that exist on the remote branch but not in your local branch.", + "description": "Whether to force push to the target branch, overwriting any existing history. This is useful for scenarios where you want to completely replace the branch content (e.g., pushing rendered manifests that don't depend on previous state). Use with caution as this will overwrite any commits that exist on the remote branch but not in your local branch. When true, the step will skip the pull/rebase operation to allow overwriting remote history.", "default": false } }, diff --git a/ui/src/gen/directives/git-push-config.json b/ui/src/gen/directives/git-push-config.json index 2892c7053e..03b791bdf7 100644 --- a/ui/src/gen/directives/git-push-config.json +++ b/ui/src/gen/directives/git-push-config.json @@ -36,7 +36,7 @@ }, "force": { "type": "boolean", - "description": "Whether to force push to the target branch, overwriting any existing history. This is useful for scenarios where you want to completely replace the branch content (e.g., pushing rendered manifests that don't depend on previous state). Use with caution as this will overwrite any commits that exist on the remote branch but not in your local branch.", + "description": "Whether to force push to the target branch, overwriting any existing history. This is useful for scenarios where you want to completely replace the branch content (e.g., pushing rendered manifests that don't depend on previous state). Use with caution as this will overwrite any commits that exist on the remote branch but not in your local branch. When true, the step will skip the pull/rebase operation to allow overwriting remote history.", "default": false } } From b9f2f04ffa1611c78be321ddf201a063acc27b9f Mon Sep 17 00:00:00 2001 From: Arya Soni Date: Sun, 19 Oct 2025 18:29:54 +0530 Subject: [PATCH 3/4] feat: allow force push option on git-push promotion step Signed-off-by: Arya Soni --- .../30-promotion-steps/git-push.md | 28 +--- .../runner/builtin/git_pusher_test.go | 125 ------------------ 2 files changed, 1 insertion(+), 152 deletions(-) diff --git a/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/git-push.md b/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/git-push.md index 1e908c21b3..a78c1d8e14 100644 --- a/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/git-push.md +++ b/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/git-push.md @@ -34,7 +34,7 @@ Stages that write to the same branch do not write to the same files. | `targetBranch` | `string` | N | The branch to push to in the remote repository. Mutually exclusive with `generateTargetBranch=true`. If neither of these is provided, the target branch will be the same as the branch currently checked out in the working tree. | | `maxAttempts` | `int32` | N | The maximum number of attempts to make when pushing to the remote repository. Default is 50. | | `generateTargetBranch` | `boolean` | N | Whether to push to a remote branch named like `kargo/promotion/`. If such a branch does not already exist, it will be created. A value of 'true' is mutually exclusive with `targetBranch`. If neither of these is provided, the target branch will be the currently checked out branch. This option is useful when a subsequent promotion step will open a pull request against a Stage-specific branch. In such a case, the generated target branch pushed to by the `git-push` step can later be utilized as the source branch of the pull request. | -| `force` | `boolean` | N | Whether to force push to the target branch, overwriting any existing history. This is useful for scenarios where you want to completely replace the branch content (e.g., pushing rendered manifests that don't depend on previous state). **Use with caution** as this will overwrite any commits that exist on the remote branch but not in your local branch. When `force: true`, the step will skip the pull/rebase operation to allow overwriting remote history. Default is `false`. | +| `force` | `boolean` | N | Whether to force push to the target branch, overwriting any existing history. This is useful for scenarios where you need to rewrite commit history (e.g., after cleaning up commits, removing sensitive data, or synchronizing divergent histories). **Use with caution** as this will overwrite any commits that exist on the remote branch but not in your local branch. When `force: true`, the step will skip the pull/rebase operation to allow overwriting remote history. Default is `false`. | | `provider` | `string` | N | The name of the Git provider to use. Currently 'azure', 'bitbucket', 'gitea', 'github', and 'gitlab' are supported. Kargo will try to infer the provider if it is not explicitly specified. This setting does not affect the push operation but helps generate the correct [`commitURL` output](#output) when working with repositories where the provider cannot be automatically determined, such as self-hosted instances. | ## Output @@ -97,29 +97,3 @@ steps: # Open a PR and wait for it to be merged or closed... ``` -### Force Push for Complete Branch Replacement - -In this example, the step force pushes to completely replace the content of a -target branch. This is useful when pushing rendered manifests or generated -content that doesn't depend on previous state and you want to ignore the git -history. - -:::warning -**Use force push with caution!** This will overwrite any commits that exist on -the remote branch but not in your local branch. This can cause data loss if -not used carefully. -::: - -```yaml -steps: -# Clone, prepare the contents of ./out, etc... -- uses: git-commit - config: - path: ./out - message: rendered updated manifests -- uses: git-push - config: - path: ./out - targetBranch: main - force: true -``` diff --git a/pkg/promotion/runner/builtin/git_pusher_test.go b/pkg/promotion/runner/builtin/git_pusher_test.go index 847900866d..1b67f8be28 100644 --- a/pkg/promotion/runner/builtin/git_pusher_test.go +++ b/pkg/promotion/runner/builtin/git_pusher_test.go @@ -140,14 +140,6 @@ func Test_gitPusher_convert(t *testing.T) { "force": false, }, }, - { - name: "force with targetBranch", - config: promotion.Config{ // Should be completely valid - "path": "/fake/path", - "targetBranch": "fake-branch", - "force": true, - }, - }, } r := newGitPusher(promotion.StepRunnerCapabilities{}) @@ -272,120 +264,3 @@ func Test_gitPusher_run(t *testing.T) { actualCommitURL := res.Output[stateKeyCommitURL] require.Equal(t, expectedCommitURL, actualCommitURL) } - -func Test_gitPusher_run_withForcePush(t *testing.T) { - // Set up a test Git server in-process - service := gitkit.New( - gitkit.Config{ - Dir: t.TempDir(), - AutoCreate: true, - }, - ) - require.NoError(t, service.Setup()) - server := httptest.NewServer(service) - defer server.Close() - - // This is the URL of the "remote" repository - testRepoURL := fmt.Sprintf("%s/test.git", server.URL) - - workDir := t.TempDir() - - // Finagle a local bare repo and working tree into place the way that - // gitCloner might have so we can verify gitPusher's ability to reload the - // working tree from the file system. - repo, err := git.CloneBare( - testRepoURL, - nil, - &git.BareCloneOptions{ - BaseDir: workDir, - }, - ) - require.NoError(t, err) - defer repo.Close() - // "master" is still the default branch name for a new repository - // unless you configure it otherwise. - workTreePath := filepath.Join(workDir, "master") - workTree, err := repo.AddWorkTree( - workTreePath, - &git.AddWorkTreeOptions{Orphan: true}, - ) - require.NoError(t, err) - // `git worktree add` doesn't give much control over the branch name when you - // create an orphaned working tree, so we have to follow up with this to make - // the branch name look like what we wanted. gitCloner does this internally as - // well. - err = workTree.CreateOrphanedBranch("master") - require.NoError(t, err) - - // Write a file. - err = os.WriteFile(filepath.Join(workTree.Dir(), "test.txt"), []byte("foo"), 0600) - require.NoError(t, err) - - // Commit the changes similarly to how gitCommitter would - // have. It will be gitPushStepRunner's job to push this commit. - err = workTree.AddAllAndCommit("Initial commit", nil) - require.NoError(t, err) - - // Set up a fake git provider - // Cannot register multiple providers with the same name, so this takes - // care of that problem - fakeGitProviderName := uuid.NewString() - gitprovider.Register( - fakeGitProviderName, - gitprovider.Registration{ - Predicate: func(_ string) bool { - return true - }, - NewProvider: func( - string, - *gitprovider.Options, - ) (gitprovider.Interface, error) { - return &gitprovider.Fake{ - GetCommitURLFn: func( - repoURL string, - sha string, - ) (string, error) { - return fmt.Sprintf("%s/commit/%s", repoURL, sha), nil - }, - }, nil - }, - }, - ) - - // Now we can proceed to test gitPusher with force push... - - r := newGitPusher(promotion.StepRunnerCapabilities{ - CredsDB: &credentials.FakeDB{}, - }) - runner, ok := r.(*gitPushPusher) - require.True(t, ok) - require.NotNil(t, runner.branchMus) - - res, err := runner.run( - context.Background(), - &promotion.StepContext{ - Project: "fake-project", - Stage: "fake-stage", - Promotion: "fake-promotion", - WorkDir: workDir, - }, - builtin.GitPushConfig{ - Path: "master", - TargetBranch: "main", - Force: true, - Provider: ptr.To(builtin.Provider(fakeGitProviderName)), - }, - ) - require.NoError(t, err) - branchName, ok := res.Output[stateKeyBranch] - require.True(t, ok) - require.Equal(t, "main", branchName) - expectedCommit, err := workTree.LastCommitID() - require.NoError(t, err) - actualCommit, ok := res.Output[stateKeyCommit] - require.True(t, ok) - require.Equal(t, expectedCommit, actualCommit) - expectedCommitURL := fmt.Sprintf("%s/commit/%s", testRepoURL, expectedCommit) - actualCommitURL := res.Output[stateKeyCommitURL] - require.Equal(t, expectedCommitURL, actualCommitURL) -} From 27472284c90edc046b147d9c593218e8e46ad1de Mon Sep 17 00:00:00 2001 From: Arya Soni Date: Tue, 21 Oct 2025 02:11:43 +0530 Subject: [PATCH 4/4] feat: allow force push option on git-push promotion step Signed-off-by: Arya Soni --- .../30-promotion-steps/git-push.md | 29 +++++++++++++++++-- pkg/promotion/runner/builtin/git_pusher.go | 11 ++++++- .../builtin/schemas/git-push-config.json | 2 +- ui/src/gen/directives/git-push-config.json | 2 +- 4 files changed, 38 insertions(+), 6 deletions(-) diff --git a/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/git-push.md b/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/git-push.md index a78c1d8e14..634c051dbd 100644 --- a/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/git-push.md +++ b/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/git-push.md @@ -34,7 +34,7 @@ Stages that write to the same branch do not write to the same files. | `targetBranch` | `string` | N | The branch to push to in the remote repository. Mutually exclusive with `generateTargetBranch=true`. If neither of these is provided, the target branch will be the same as the branch currently checked out in the working tree. | | `maxAttempts` | `int32` | N | The maximum number of attempts to make when pushing to the remote repository. Default is 50. | | `generateTargetBranch` | `boolean` | N | Whether to push to a remote branch named like `kargo/promotion/`. If such a branch does not already exist, it will be created. A value of 'true' is mutually exclusive with `targetBranch`. If neither of these is provided, the target branch will be the currently checked out branch. This option is useful when a subsequent promotion step will open a pull request against a Stage-specific branch. In such a case, the generated target branch pushed to by the `git-push` step can later be utilized as the source branch of the pull request. | -| `force` | `boolean` | N | Whether to force push to the target branch, overwriting any existing history. This is useful for scenarios where you need to rewrite commit history (e.g., after cleaning up commits, removing sensitive data, or synchronizing divergent histories). **Use with caution** as this will overwrite any commits that exist on the remote branch but not in your local branch. When `force: true`, the step will skip the pull/rebase operation to allow overwriting remote history. Default is `false`. | +| `force` | `boolean` | N | Whether to force push to the target branch, overwriting any existing history. This is useful for scenarios where you want to completely replace the branch content (e.g., pushing rendered manifests that don't depend on previous state). **Use with caution** as this will overwrite any commits that exist on the remote branch but not in your local branch. Default is `false`. | | `provider` | `string` | N | The name of the Git provider to use. Currently 'azure', 'bitbucket', 'gitea', 'github', and 'gitlab' are supported. Kargo will try to infer the provider if it is not explicitly specified. This setting does not affect the push operation but helps generate the correct [`commitURL` output](#output) when working with repositories where the provider cannot be automatically determined, such as self-hosted instances. | ## Output @@ -45,7 +45,6 @@ Stages that write to the same branch do not write to the same files. | `commit` | `string` | The ID (SHA) of the commit pushed by this step. | | `commitURL` | `string` | The URL of the commit that was pushed to the remote repository. | - ## Examples ### Common Usage @@ -75,7 +74,7 @@ steps: In this example, changes are pushed to a generated branch name that follows the pattern `kargo/promotion/`. By setting `generateTargetBranch: true`, the step creates a unique branch name that can -be referenced by subsequent steps. +be referenced by subsequent steps. This is commonly used as part of a pull request workflow, where changes are first pushed to an intermediate branch before being proposed as a pull request. @@ -97,3 +96,27 @@ steps: # Open a PR and wait for it to be merged or closed... ``` +### Force Push for Rendered Manifests + +In this example, rendered manifests are pushed with force enabled. This is useful when the rendered output completely replaces the previous state and doesn't depend on any previous commits in the branch. + +This pattern is common when using tools like `helm-template` or `kustomize-build` to generate Kubernetes manifests, where each promotion generates a fresh set of manifests that should completely replace what was previously in the branch. + +```yaml +steps: +# Clone, render manifests, etc... +- uses: helm-template + config: + chart: ./charts/my-app + values: ./values/staging.yaml + outPath: ./out +- uses: git-commit + config: + path: ./out + message: rendered updated manifests for staging +- uses: git-push + config: + path: ./out + targetBranch: staging + force: true # Force push to completely replace branch content +``` diff --git a/pkg/promotion/runner/builtin/git_pusher.go b/pkg/promotion/runner/builtin/git_pusher.go index 763a598cec..5c251f09f3 100644 --- a/pkg/promotion/runner/builtin/git_pusher.go +++ b/pkg/promotion/runner/builtin/git_pusher.go @@ -142,7 +142,16 @@ func (g *gitPushPusher) run( // TargetBranch and GenerateTargetBranch are mutually exclusive, so we're // never overwriting a user-specified target branch here. pushOpts.TargetBranch = fmt.Sprintf("kargo/promotion/%s", stepCtx.Promotion) - // Always force push for generated branches to ensure they can be overwritten + // Since the name of the generated branch incorporates the Promotion's + // name, which itself incorporates a UUID, we assume this branch did not exist + // in the remote repository prior to this Promotion. If it somehow does, the + // only practical explanation for that would be that, for some reason, the + // entire promotion process has restarted from step zero AFTER having + // executed this step successfully on a prior attempt. (This can happen, + // for instance, if the controller were restarted mid-promotion.) Enabling + // the force push option here prevents this step from failing under those + // circumstances, and as long as the reasonable assumption that this + // branch is specific to this Promotion only holds, it is also safe to do this. pushOpts.Force = true } diff --git a/pkg/promotion/runner/builtin/schemas/git-push-config.json b/pkg/promotion/runner/builtin/schemas/git-push-config.json index cedd00abdf..7e6064d647 100644 --- a/pkg/promotion/runner/builtin/schemas/git-push-config.json +++ b/pkg/promotion/runner/builtin/schemas/git-push-config.json @@ -31,7 +31,7 @@ }, "force": { "type": "boolean", - "description": "Whether to force push to the target branch, overwriting any existing history. This is useful for scenarios where you want to completely replace the branch content (e.g., pushing rendered manifests that don't depend on previous state). Use with caution as this will overwrite any commits that exist on the remote branch but not in your local branch. When true, the step will skip the pull/rebase operation to allow overwriting remote history.", + "description": "Whether to force push to the target branch, overwriting any existing history. This is useful for scenarios where you want to completely replace the branch content (e.g., pushing rendered manifests that don't depend on previous state). Use with caution as this will overwrite any commits that exist on the remote branch but not in your local branch.", "default": false } }, diff --git a/ui/src/gen/directives/git-push-config.json b/ui/src/gen/directives/git-push-config.json index 03b791bdf7..2892c7053e 100644 --- a/ui/src/gen/directives/git-push-config.json +++ b/ui/src/gen/directives/git-push-config.json @@ -36,7 +36,7 @@ }, "force": { "type": "boolean", - "description": "Whether to force push to the target branch, overwriting any existing history. This is useful for scenarios where you want to completely replace the branch content (e.g., pushing rendered manifests that don't depend on previous state). Use with caution as this will overwrite any commits that exist on the remote branch but not in your local branch. When true, the step will skip the pull/rebase operation to allow overwriting remote history.", + "description": "Whether to force push to the target branch, overwriting any existing history. This is useful for scenarios where you want to completely replace the branch content (e.g., pushing rendered manifests that don't depend on previous state). Use with caution as this will overwrite any commits that exist on the remote branch but not in your local branch.", "default": false } }