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 7f78e2bb11..346084da2b 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 @@ -5,10 +5,10 @@ description: Pushes the committed changes in a specified working tree to a speci # `git-push` -`git-push` pushes the committed changes in a specified working tree to a -specified branch in the remote repository. This step typically follows a -[`git-commit` step](git-commit.md) and is often followed by a -[`git-open-pr` step](git-open-pr.md). +`git-push` can push committed changes or new tags from specified working tree to +the remote repository. This step typically follows a +[`git-commit` step](git-commit.md) and/or [`git-tag` step](git-tag.md) and is +often followed by a [`git-open-pr` step](git-open-pr.md). This step also implements its own, internal retry logic. If a push fails, with the cause determined to be the presence of new commits in the remote branch that @@ -28,14 +28,24 @@ Stages that write to the same branch do not write to the same files. ::: + +:::note + +For a tag push, an attempt to pull/rebase first is not made as tags are +immutable and any existing tag with the same name on the remote would cause +the pull/rebase to fail. + +::: + ## Configuration | Name | Type | Required | Description | |------|------|----------|-------------| | `path` | `string` | Y | Path to a Git working tree containing committed changes. | -| `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. | +| `targetBranch` | `string` | N | The branch to push to in the remote repository. Mutually exclusive with `generateTargetBranch=true` and `tag`. If none of these are 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. | +| `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` and `tag`. If none of these are 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. | +| `tag` | `string` | N | An optional tag to push to the remote repository. Mutually exclusive with `generateTargetBranch` `targetBranch`. | | `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. | @@ -46,6 +56,8 @@ Stages that write to the same branch do not write to the same files. | `branch` | `string` | The name of the remote branch pushed to by this step. This is especially useful when the `generateTargetBranch=true` option has been used, in which case a subsequent [`git-open-pr`](git-open-pr.md) will typically reference this output to learn what branch to use as the head branch of a new pull request. | | `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. | +| `tag.name` | `string` | If applicable, the tag that was pushed to the remote repository. | +| `tag.url` | `string` | If applicable, the URL of the tag that was pushed to the remote repository. | ## Examples @@ -97,3 +109,19 @@ steps: generateTargetBranch: true # Open a PR and wait for it to be merged or closed... ``` + +### Pushing Tags + +In this example, a new tag is pushed to the remote repository. + +```yaml +# Create a new tag +- uses: git-tag + config: + path: ./out + tag: v1.0.0 +- uses: git-push + config: + path: ./out + tag: v1.0.0 +``` \ No newline at end of file diff --git a/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/git-tag.md b/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/git-tag.md new file mode 100644 index 0000000000..38464f8326 --- /dev/null +++ b/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/git-tag.md @@ -0,0 +1,77 @@ +--- +sidebar_label: git-tag +description: Creates a new tag for the latest committed changes. +--- + +# `git-tag` + +The `git-tag` step creates a new tag in a local Git repository. This step is commonly used to mark specific commits with a tag, which can be useful for versioning or tracking changes in a repository. + +## Configuration + +| Name | Type | Required | Description | +|--------|----------|----------|-----------------------------------------------------------------------------| +| `path` | `string` | Y | Path to a working directory of a local repository. This path is relative to the temporary workspace that Kargo provisions for use by the promotion process. | +| `tag` | `string` | Y | The name of the tag to create. | +| `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 + +| Name | Type | Description | +|-------|----------|-----------------------------------------------------------------------------| +| `tag` | `string` | The name of the tag that was created by this step. This can be referenced in subsequent steps. | +| `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. | + +:::caution + +If the specified tag already exists in the remote repository, the `git-tag` step +will fail. + +::: + +## Examples + +### Basic Usage + +In this example, the `git-tag` step creates a tag named `v1.0.0` in a local Git repository. + +```yaml +steps: +- uses: git-tag + config: + path: ./out + tag: v1.0.0 +``` + +### Tagging After a Commit + +This example demonstrates how to use the git-tag step after a git-commit step to tag the latest commit with a version number. + +```yaml +steps: +- uses: git-commit + config: + path: ./out + message: "Committing changes for release v1.0.0" +- uses: git-tag + config: + path: ./out + tag: v1.0.0 +``` + +### Pushing After Tagging + +In this example, the `git-tag` step creates a tag, and the `git-push` step pushes the tag to the remote repository. + +```yaml +steps: +- uses: git-tag + config: + path: ./out + tag: v1.0.0 +- uses: git-push + config: + path: ./out + tag: v1.0.0 +``` \ No newline at end of file diff --git a/pkg/controller/git/mock_repo.go b/pkg/controller/git/mock_repo.go index 40277fe399..7540cfbe56 100644 --- a/pkg/controller/git/mock_repo.go +++ b/pkg/controller/git/mock_repo.go @@ -13,6 +13,7 @@ type MockRepo struct { CommitFn func(message string, opts *CommitOptions) error CreateChildBranchFn func(branch string) error CreateOrphanedBranchFn func(branch string) error + CreateTagFn func(tag string) error CurrentBranchFn func() (string, error) DeleteBranchFn func(branch string) error DirFn func() string @@ -75,6 +76,10 @@ func (m *MockRepo) CreateOrphanedBranch(branch string) error { return m.CreateOrphanedBranchFn(branch) } +func (m *MockRepo) CreateTag(tag string) error { + return m.CreateTagFn(tag) +} + func (m *MockRepo) CurrentBranch() (string, error) { return m.CurrentBranchFn() } diff --git a/pkg/controller/git/work_tree.go b/pkg/controller/git/work_tree.go index 00aa55eafc..38ae0af803 100644 --- a/pkg/controller/git/work_tree.go +++ b/pkg/controller/git/work_tree.go @@ -42,6 +42,8 @@ type WorkTree interface { // CreateOrphanedBranch creates a new branch that shares no commit history // with any other branch. CreateOrphanedBranch(branch string) error + // CreateTag creates a new tag with the specified name. + CreateTag(name string) error // CurrentBranch returns the current branch CurrentBranch() (string, error) // DeleteBranch deletes the specified branch @@ -296,6 +298,13 @@ func (w *workTree) CreateOrphanedBranch(branch string) error { return w.Clean() } +func (w *workTree) CreateTag(tag string) error { + if _, err := libExec.Exec(w.buildGitCommand("tag", tag)); err != nil { + return fmt.Errorf("error creating tag %q", err) + } + return nil +} + func (w *workTree) CurrentBranch() (string, error) { res, err := libExec.Exec(w.buildGitCommand("branch", "--show-current")) if err != nil { @@ -584,6 +593,11 @@ type PushOptions struct { // be useful when pushing changes to a remote branch that has been updated // in the time since the local branch was last pulled. PullRebase bool + // Tag specifies an optional tag to push to the remote repository. Mutually + // exclusive with TargetBranch. Because tags are immutable and any existing + // tag with the same name on the remote would cause the pull/rebase to fail, + // PullRebase will be ignored if Tag is specified. + Tag string } // https://regex101.com/r/f7kTjs/1 @@ -596,41 +610,50 @@ func (w *workTree) Push(opts *PushOptions) error { opts = &PushOptions{} } targetBranch := opts.TargetBranch - if targetBranch == "" { + if targetBranch == "" && opts.Tag == "" { var err error if targetBranch, err = w.CurrentBranch(); err != nil { return err } } - if opts.PullRebase { - exists, err := w.RemoteBranchExists(targetBranch) - if err != nil { - return err - } - // We only want to pull and rebase if the remote branch exists. - if exists { - if _, err = libExec.Exec(w.buildGitCommand("pull", "--rebase", "origin", targetBranch)); err != nil { - // The error we're most concerned with is a merge conflict requiring - // manual resolution, because it's an error that no amount of retries - // will fix. If we find that a rebase is in progress, this is what - // has happened. - if isRebasing, isRebasingErr := w.IsRebasing(); isRebasingErr == nil && isRebasing { - return ErrMergeConflict + var artifact string + args := []string{"push", "origin"} + switch { + case opts.Tag != "": + artifact = "tag" + args = append(args, "tag", opts.Tag) + default: + artifact = "branch" + args = append(args, fmt.Sprintf("HEAD:%s", targetBranch)) + if opts.PullRebase && targetBranch != "" { + exists, err := w.RemoteBranchExists(targetBranch) + if err != nil { + return err + } + // We only want to pull and rebase if the remote branch exists. + if exists { + if _, err = libExec.Exec(w.buildGitCommand("pull", "--rebase", "origin", targetBranch)); err != nil { + // The error we're most concerned with is a merge conflict requiring + // manual resolution, because it's an error that no amount of retries + // will fix. If we find that a rebase is in progress, this is what + // has happened. + if isRebasing, isRebasingErr := w.IsRebasing(); isRebasingErr == nil && isRebasing { + return ErrMergeConflict + } + // If we get to here, the error isn't a merge conflict. + return fmt.Errorf("error pulling and rebasing branch: %w", err) } - // If we get to here, the error isn't a merge conflict. - return fmt.Errorf("error pulling and rebasing branch: %w", err) } } } - args := []string{"push", "origin", fmt.Sprintf("HEAD:%s", targetBranch)} if opts.Force { args = append(args, "--force") } if res, err := libExec.Exec(w.buildGitCommand(args...)); err != nil { if nonFastForwardRegex.MatchString(string(res)) { - return fmt.Errorf("error pushing branch: %w", ErrNonFastForward) + return fmt.Errorf("error pushing %s: %w", artifact, ErrNonFastForward) } - return fmt.Errorf("error pushing branch: %w", err) + return fmt.Errorf("error pushing %s: %w", artifact, err) } return nil } diff --git a/pkg/gitprovider/registry.go b/pkg/gitprovider/registry.go index ea0a33133a..c697e0dfb4 100644 --- a/pkg/gitprovider/registry.go +++ b/pkg/gitprovider/registry.go @@ -33,8 +33,10 @@ func New(repoURL string, opts *Options) (Interface, error) { return nil, fmt.Errorf("no registered providers with name %q", opts.Name) } for _, reg := range registeredProviders { - if reg.Predicate(repoURL) { - return reg.NewProvider(repoURL, opts) + if reg.Predicate != nil && reg.NewProvider != nil { + if reg.Predicate(repoURL) { + return reg.NewProvider(repoURL, opts) + } } } return nil, fmt.Errorf("no registered providers for %s", repoURL) diff --git a/pkg/promotion/runner/builtin/git_pusher.go b/pkg/promotion/runner/builtin/git_pusher.go index 174be16c9f..ed5b78138b 100644 --- a/pkg/promotion/runner/builtin/git_pusher.go +++ b/pkg/promotion/runner/builtin/git_pusher.go @@ -30,6 +30,9 @@ const ( // stateKeyCommitURL is the key used to store the URL of the commit that was // pushed to in the shared State. stateKeyCommitURL = "commitURL" + + // stateKeyCommit is the key used to store the tag that was pushed in the shared State. + stateKeyTag = "tag" ) func init() { @@ -47,7 +50,7 @@ func init() { } // gitPushPusher is an implementation of the promotion.StepRunner interface that -// pushes commits from a local Git repository to a remote Git repository. +// pushes commits and tags from a local Git repository to a remote Git repository. type gitPushPusher struct { schemaLoader gojsonschema.JSONLoader credsDB credentials.Database @@ -154,7 +157,13 @@ func (g *gitPushPusher) run( // branch is specific to this Promotion only holds, it is also safe to do this. pushOpts.Force = true } - + if cfg.Tag != "" { + pushOpts.Tag = cfg.Tag + // If we're pushing a tag, we should not attempt to pull/rebase first as + // tags are immutable and any existing tag with the same name on the remote + // would cause the pull/rebase to fail. + pushOpts.PullRebase = false + } // Disable pull/rebase when force pushing to allow overwriting remote history if pushOpts.Force { pushOpts.PullRebase = false @@ -224,21 +233,27 @@ func (g *gitPushPusher) run( if cfg.Provider != nil { gpOpts.Name = string(*cfg.Provider) } + + output := map[string]any{ + stateKeyBranch: pushOpts.TargetBranch, + stateKeyCommit: commitID, + } + gitProvider, err := gitprovider.New(workTree.URL(), &gpOpts) var commitURL string if err == nil { if commitURL, err = gitProvider.GetCommitURL(workTree.URL(), commitID); err != nil { logger.Error(err, "unable to get commit URL from Git provider") + } else { + output[stateKeyCommitURL] = commitURL } } - + if pushOpts.Tag != "" { + output[stateKeyTag] = pushOpts.Tag + } return promotion.StepResult{ Status: kargoapi.PromotionStepStatusSucceeded, - Output: map[string]any{ - stateKeyBranch: pushOpts.TargetBranch, - stateKeyCommit: commitID, - stateKeyCommitURL: commitURL, - }, + Output: output, }, nil } diff --git a/pkg/promotion/runner/builtin/git_pusher_test.go b/pkg/promotion/runner/builtin/git_pusher_test.go index 1b67f8be28..4d7e30cfe4 100644 --- a/pkg/promotion/runner/builtin/git_pusher_test.go +++ b/pkg/promotion/runner/builtin/git_pusher_test.go @@ -1,7 +1,6 @@ package builtin import ( - "context" "fmt" "math" "net/http/httptest" @@ -85,7 +84,7 @@ func Test_gitPusher_convert(t *testing.T) { }, }, { - name: "generateTargetBranch not specified and targetBranch not specified", + name: "generateTargetBranch, targetBranch, and tag not specified", config: promotion.Config{ // Should be completely valid "path": "/fake/path", }, @@ -140,6 +139,28 @@ func Test_gitPusher_convert(t *testing.T) { "force": false, }, }, + { + name: "tag and generateTargetBranch both specified", + config: promotion.Config{ + "path": "/fake/path", + "generateTargetBranch": true, + "tag": "v1.0.0", + }, + expectedProblems: []string{ + "(root): Must validate one and only one schema", + }, + }, + { + name: "tag and targetBranch both specified", + config: promotion.Config{ + "path": "/fake/path", + "targetBranch": "fake-branch", + "tag": "v1.0.0", + }, + expectedProblems: []string{ + "(root): Must validate one and only one schema", + }, + }, } r := newGitPusher(promotion.StepRunnerCapabilities{}) @@ -202,6 +223,9 @@ func Test_gitPusher_run(t *testing.T) { err = workTree.AddAllAndCommit("Initial commit", nil) require.NoError(t, err) + // Tag the commit so we can also test pushing tags later. + require.NoError(t, workTree.CreateTag("v1.0.0")) + // Set up a fake git provider // Cannot register multiple providers with the same name, so this takes // care of that problem @@ -229,7 +253,6 @@ func Test_gitPusher_run(t *testing.T) { ) // Now we can proceed to test gitPusher... - r := newGitPusher(promotion.StepRunnerCapabilities{ CredsDB: &credentials.FakeDB{}, }) @@ -237,30 +260,60 @@ func Test_gitPusher_run(t *testing.T) { 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", - GenerateTargetBranch: true, - Provider: ptr.To(builtin.Provider(fakeGitProviderName)), - }, - ) - require.NoError(t, err) - branchName, ok := res.Output[stateKeyBranch] - require.True(t, ok) - require.Equal(t, "kargo/promotion/fake-promotion", 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) + t.Run("push commit to generated branch", func(t *testing.T) { + res, err := runner.run( + t.Context(), + &promotion.StepContext{ + Project: "fake-project", + Stage: "fake-stage", + Promotion: "fake-promotion", + WorkDir: workDir, + }, + builtin.GitPushConfig{ + Path: "master", + GenerateTargetBranch: true, + Provider: ptr.To(builtin.Provider(fakeGitProviderName)), + }, + ) + require.NoError(t, err) + branchName, ok := res.Output[stateKeyBranch] + require.True(t, ok) + require.Equal(t, "kargo/promotion/fake-promotion", 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) + }) + t.Run("push tag", func(t *testing.T) { + res, err := runner.run( + t.Context(), + &promotion.StepContext{ + Project: "fake-project", + Stage: "fake-stage", + Promotion: "fake-promotion", + WorkDir: workDir, + }, + builtin.GitPushConfig{ + Path: "master", + Tag: "v1.0.0", + }, + ) + require.NoError(t, err) + actualTag, ok := res.Output[stateKeyTag] + require.True(t, ok) + require.Equal(t, "v1.0.0", actualTag) + require.NoError(t, workTree.Checkout("v1.0.0")) + 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/git_tagger.go b/pkg/promotion/runner/builtin/git_tagger.go new file mode 100644 index 0000000000..e6ebfea269 --- /dev/null +++ b/pkg/promotion/runner/builtin/git_tagger.go @@ -0,0 +1,121 @@ +package builtin + +import ( + "context" + "fmt" + + securejoin "github.com/cyphar/filepath-securejoin" + "github.com/xeipuuv/gojsonschema" + + kargoapi "github.com/akuity/kargo/api/v1alpha1" + "github.com/akuity/kargo/pkg/controller/git" + "github.com/akuity/kargo/pkg/gitprovider" + "github.com/akuity/kargo/pkg/logging" + "github.com/akuity/kargo/pkg/promotion" + "github.com/akuity/kargo/pkg/x/promotion/runner/builtin" +) + +const ( + stepKindGitTag = "git-tag" +) + +func init() { + promotion.DefaultStepRunnerRegistry.MustRegister( + promotion.StepRunnerRegistration{ + Name: stepKindGitTag, + Value: newGitTagger, + }, + ) +} + +// gitTagger is an implementation of the promotion.StepRunner interface that +// creates a tag in a local Git repository. +type gitTagTagger struct { + schemaLoader gojsonschema.JSONLoader +} + +// newGitTagger returns an implementation of the promotion.StepRunner +// interface that creates a tag in a local Git repository. +func newGitTagger(promotion.StepRunnerCapabilities) promotion.StepRunner { + return &gitTagTagger{schemaLoader: getConfigSchemaLoader(stepKindGitTag)} +} + +// Run implements the promotion.StepRunner interface. +func (g *gitTagTagger) Run( + ctx context.Context, + stepCtx *promotion.StepContext, +) (promotion.StepResult, error) { + cfg, err := g.convert(stepCtx.Config) + if err != nil { + return promotion.StepResult{ + Status: kargoapi.PromotionStepStatusFailed, + }, &promotion.TerminalError{Err: err} + } + return g.run(ctx, stepCtx, cfg) +} + +// convert validates the configuration against a JSON schema and converts it +// into a builtin.GitTagConfig struct. +func (g *gitTagTagger) convert(cfg promotion.Config) (builtin.GitTagConfig, error) { + return validateAndConvert[builtin.GitTagConfig](g.schemaLoader, cfg, stepKindGitTag) +} + +func (g *gitTagTagger) run( + ctx context.Context, + stepCtx *promotion.StepContext, + cfg builtin.GitTagConfig, +) (promotion.StepResult, error) { + logger := logging.LoggerFromContext(ctx) + + path, err := securejoin.SecureJoin(stepCtx.WorkDir, cfg.Path) + if err != nil { + return promotion.StepResult{Status: kargoapi.PromotionStepStatusErrored}, fmt.Errorf( + "error joining path %s with work dir %s: %w", + cfg.Path, stepCtx.WorkDir, err, + ) + } + + workTree, err := git.LoadWorkTree(path, nil) + if err != nil { + return promotion.StepResult{Status: kargoapi.PromotionStepStatusErrored}, + fmt.Errorf("error loading working tree from %s: %w", cfg.Path, err) + } + + if err = workTree.CreateTag(cfg.Tag); err != nil { + return promotion.StepResult{Status: kargoapi.PromotionStepStatusErrored}, + fmt.Errorf("error creating tag %s: %w", cfg.Tag, err) + } + + commitID, err := workTree.LastCommitID() + if err != nil { + return promotion.StepResult{Status: kargoapi.PromotionStepStatusErrored}, + fmt.Errorf("error getting last commit ID: %w", err) + } + + // Use the Git provider to get the commit URL, if possible. We continue + // even if the provider or URL cannot be determined, as the push will + // still have succeeded which is the primary goal of this step. + gpOpts := gitprovider.Options{} + if cfg.Provider != nil { + gpOpts.Name = string(*cfg.Provider) + } + + output := map[string]any{ + stateKeyTag: cfg.Tag, + stateKeyCommit: commitID, + } + + gitProvider, err := gitprovider.New(workTree.URL(), &gpOpts) + var commitURL string + if err == nil { + if commitURL, err = gitProvider.GetCommitURL(workTree.URL(), commitID); err != nil { + logger.Error(err, "unable to get commit URL from Git provider") + } else { + output[stateKeyCommitURL] = commitURL + } + } + return promotion.StepResult{ + Status: kargoapi.PromotionStepStatusSucceeded, + Output: output, + }, nil +} diff --git a/pkg/promotion/runner/builtin/git_tagger_test.go b/pkg/promotion/runner/builtin/git_tagger_test.go new file mode 100644 index 0000000000..1e5f2ed6bb --- /dev/null +++ b/pkg/promotion/runner/builtin/git_tagger_test.go @@ -0,0 +1,190 @@ +package builtin + +import ( + "context" + "fmt" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/google/uuid" + "github.com/sosedoff/gitkit" + "github.com/stretchr/testify/require" + "k8s.io/utils/ptr" + + kargoapi "github.com/akuity/kargo/api/v1alpha1" + "github.com/akuity/kargo/pkg/controller/git" + "github.com/akuity/kargo/pkg/gitprovider" + "github.com/akuity/kargo/pkg/promotion" + "github.com/akuity/kargo/pkg/x/promotion/runner/builtin" +) + +func Test_gitTagger_convert(t *testing.T) { + tests := []validationTestCase{ + { + name: "path not specified", + config: promotion.Config{ + "tag": "v1.0.0", + }, + expectedProblems: []string{ + "(root): path is required", + }, + }, + { + name: "path is empty string", + config: promotion.Config{ + "path": "", + "tag": "v1.0.0", + }, + expectedProblems: []string{ + "path: String length must be greater than or equal to 1", + }, + }, + { + name: "tag not specified", + config: promotion.Config{ + "path": "/tmp/foo", + }, + expectedProblems: []string{ + "(root): tag is required", + }, + }, + { + name: "tag is empty string", + config: promotion.Config{ + "path": "/tmp/foo", + "tag": "", + }, + expectedProblems: []string{ + "tag: String length must be greater than or equal to 1", + }, + }, + { + name: "valid config", + config: promotion.Config{ + "path": "/tmp/foo", + "tag": "v1.0.0", + }, + }, + } + + r := newGitTagger(promotion.StepRunnerCapabilities{}) + runner, ok := r.(*gitTagTagger) + require.True(t, ok) + + runValidationTests(t, runner.convert, tests) +} + +func Test_gitTagger_run(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 the + // gitCloner might have so we can verify gitTagger'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. It will be gitTagger's job to tag the current commit. + err = os.WriteFile( + filepath.Join(workTree.Dir(), "test.txt"), + []byte("foo"), 0600, + ) + require.NoError(t, err) + + // Commit the file + err = workTree.AddAll() + require.NoError(t, err) + err = workTree.Commit("Initial commit", nil) + require.NoError(t, err) + + // Now we can proceed to test gitTagger... + r := newGitTagger(promotion.StepRunnerCapabilities{}) + runner, ok := r.(*gitTagTagger) + require.True(t, ok) + + // 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 + }, + }, + ) + + // Test creating a tag + res, err := runner.run( + context.Background(), + &promotion.StepContext{WorkDir: workDir}, + builtin.GitTagConfig{ + Provider: ptr.To(builtin.Provider(fakeGitProviderName)), + Path: "master", + Tag: "v1.0.0", + }, + ) + // Verify + require.NoError(t, err) + require.Equal(t, kargoapi.PromotionStepStatusSucceeded, res.Status) + require.Equal(t, "v1.0.0", res.Output[stateKeyTag]) + 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 7e6064d647..705d5f413b 100644 --- a/pkg/promotion/runner/builtin/schemas/git-push-config.json +++ b/pkg/promotion/runner/builtin/schemas/git-push-config.json @@ -7,7 +7,7 @@ "properties": { "generateTargetBranch": { "type": "boolean", - "description": "Indicates whether to push to a new remote branch. 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." + "description": "Indicates whether to push to a new remote branch. A value of 'true' is mutually exclusive with 'targetBranch' and 'tag'. If neither of these is provided, the target branch will be the currently checked out branch." }, "maxAttempts": { "type": "integer", @@ -22,7 +22,7 @@ }, "targetBranch": { "type": "string", - "description": "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." + "description": "The target branch to push to. Mutually exclusive with 'generateTargetBranch=true' and 'tag'. If none of these are provided, the target branch will be the currently checked out branch." }, "provider": { "type": "string", @@ -33,27 +33,42 @@ "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 + }, + "tag": { + "type": "string", + "description": "An optional tag to push to the remote repository. Mutually exclusive with 'generateTargetBranch=true' and 'targetBranch'." } }, "oneOf": [ { "properties": { "generateTargetBranch": { "const": true }, - "targetBranch": { "enum": ["", null] } + "targetBranch": { "enum": ["", null] }, + "tag": { "enum": ["", null] } }, "required": ["generateTargetBranch"] }, { "properties": { "generateTargetBranch": { "enum": [false, null] }, - "targetBranch": { "minLength": 1 } + "targetBranch": { "minLength": 1 }, + "tag": { "enum": ["", null] } }, "required": ["targetBranch"] }, { "properties": { "generateTargetBranch": { "enum": [false, null] }, - "targetBranch": { "enum": ["", null] } + "targetBranch": { "enum": ["", null] }, + "tag": { "minLength": 1 } + }, + "required": ["tag"] + }, + { + "properties": { + "generateTargetBranch": { "enum": [false, null] }, + "targetBranch": { "enum": ["", null] }, + "tag": { "enum": ["", null] } } } ] diff --git a/pkg/promotion/runner/builtin/schemas/git-tag-config.json b/pkg/promotion/runner/builtin/schemas/git-tag-config.json new file mode 100644 index 0000000000..037da074b2 --- /dev/null +++ b/pkg/promotion/runner/builtin/schemas/git-tag-config.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "GitTagConfig", + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "The path to a working directory of a local repository.", + "minLength": 1 + }, + "tag": { + "type": "string", + "description": "The tag to create in the repository.", + "minLength": 1 + }, + "provider": { + "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"] + } + }, + "required": ["path", "tag"] +} diff --git a/pkg/x/promotion/runner/builtin/zz_config_types.go b/pkg/x/promotion/runner/builtin/zz_config_types.go index 2f6ada5b8a..2064c3ee4c 100644 --- a/pkg/x/promotion/runner/builtin/zz_config_types.go +++ b/pkg/x/promotion/runner/builtin/zz_config_types.go @@ -262,8 +262,8 @@ type GitPushConfig struct { // will overwrite any commits that exist on the remote branch but not in your local branch. Force bool `json:"force,omitempty"` // Indicates whether to push to a new remote branch. 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. + // with 'targetBranch' and 'tag'. If neither of these is provided, the target branch will be + // the currently checked out branch. GenerateTargetBranch bool `json:"generateTargetBranch,omitempty"` // This step implements its own internal retry logic for cases where a push is determined to // have failed due to the remote branch having commits that that are not present locally. @@ -277,11 +277,26 @@ type GitPushConfig struct { // and 'gitlab' are supported. Kargo will try to infer the provider if it is not explicitly // specified. Provider *Provider `json:"provider,omitempty"` - // 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. + // An optional tag to push to the remote repository. Mutually exclusive with + // 'generateTargetBranch=true' and 'targetBranch'. + Tag string `json:"tag,omitempty"` + // The target branch to push to. Mutually exclusive with 'generateTargetBranch=true' and + // 'tag'. If none of these are provided, the target branch will be the currently checked out + // branch. TargetBranch string `json:"targetBranch,omitempty"` } +type GitTagConfig struct { + // The path to a working directory of a local repository. + Path string `json:"path"` + // 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. + Provider *Provider `json:"provider,omitempty"` + // The tag to create in the repository. + Tag string `json:"tag"` +} + type GitWaitForPRConfig struct { // Indicates whether to skip TLS verification when cloning the repository. Default is false. InsecureSkipTLSVerify bool `json:"insecureSkipTLSVerify,omitempty"` diff --git a/ui/src/gen/directives/git-push-config.json b/ui/src/gen/directives/git-push-config.json index 2892c7053e..329aead006 100644 --- a/ui/src/gen/directives/git-push-config.json +++ b/ui/src/gen/directives/git-push-config.json @@ -6,7 +6,7 @@ "properties": { "generateTargetBranch": { "type": "boolean", - "description": "Indicates whether to push to a new remote branch. 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." + "description": "Indicates whether to push to a new remote branch. A value of 'true' is mutually exclusive with 'targetBranch' and 'tag'. If neither of these is provided, the target branch will be the currently checked out branch." }, "maxAttempts": { "type": "integer", @@ -21,7 +21,7 @@ }, "targetBranch": { "type": "string", - "description": "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." + "description": "The target branch to push to. Mutually exclusive with 'generateTargetBranch=true' and 'tag'. If none of these are provided, the target branch will be the currently checked out branch." }, "provider": { "type": "string", @@ -38,6 +38,10 @@ "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 + }, + "tag": { + "type": "string", + "description": "An optional tag to push to the remote repository. Mutually exclusive with 'generateTargetBranch=true' and 'targetBranch'." } } } \ No newline at end of file diff --git a/ui/src/gen/directives/git-tag-config.json b/ui/src/gen/directives/git-tag-config.json new file mode 100644 index 0000000000..f59b3de6a5 --- /dev/null +++ b/ui/src/gen/directives/git-tag-config.json @@ -0,0 +1,28 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "GitTagConfig", + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "The path to a working directory of a local repository.", + "minLength": 1 + }, + "tag": { + "type": "string", + "description": "The tag to create in the repository.", + "minLength": 1 + }, + "provider": { + "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" + ] + } + } +} \ No newline at end of file