feat: add git pipeline steps for GitOps workflows#168
Conversation
Adds five new native git operation steps to the CI/CD plugin: - step.git_clone: clone repos with HTTPS token or SSH key auth - step.git_commit: stage files and create commits with author metadata - step.git_push: push to remotes with optional force/tags and token injection - step.git_tag: create lightweight or annotated tags, optionally push - step.git_checkout: checkout or create branches All steps resolve Go templates in config fields, use an injectable execCommand for testability, and include 49 unit tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This pull request adds five new native Git operation pipeline steps to the cicd plugin to enable GitOps workflows: step.git_clone, step.git_commit, step.git_push, step.git_tag, and step.git_checkout. Each step follows established patterns from the codebase including injectable execCommand for testability (matching step.build_from_config) and Go template resolution for all configuration fields. The implementation includes 49 unit tests providing comprehensive coverage of factory validation, template resolution, authentication injection, error handling, and behavior flags.
Changes:
- Adds five new git operation step types with full HTTPS token auth and SSH key support
- Updates plugin manifest and registration to include the new step types (13→18 total steps)
- Provides comprehensive unit test coverage following existing test patterns
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| plugins/cicd/plugin.go | Registers five new git step factories and updates plugin descriptions |
| plugins/cicd/plugin_test.go | Updates test expectations for 18 total step factories (was 13) |
| module/pipeline_step_git_clone.go | Implements git clone with token/SSH auth, branch, and depth support |
| module/pipeline_step_git_clone_test.go | 10 unit tests covering factory, execution, auth injection, and templates |
| module/pipeline_step_git_commit.go | Implements git commit with selective staging and author metadata |
| module/pipeline_step_git_commit_test.go | 10 unit tests including "nothing to commit" handling and file staging |
| module/pipeline_step_git_push.go | Implements git push with force/tags flags, token injection, and branch inference |
| module/pipeline_step_git_push_test.go | 9 unit tests covering push options and branch resolution |
| module/pipeline_step_git_tag.go | Implements annotated/lightweight tags with optional auto-push |
| module/pipeline_step_git_tag_test.go | 10 unit tests verifying tag creation and push behavior |
| module/pipeline_step_git_checkout.go | Implements branch checkout with create flag support |
| module/pipeline_step_git_checkout_test.go | 10 unit tests covering existing and new branch checkout |
| // getCurrentBranch returns the current branch name for the given directory. | ||
| func (s *GitPushStep) getCurrentBranch(ctx context.Context, dir string) (string, error) { | ||
| var stdout bytes.Buffer | ||
| cmd := s.execCommand(ctx, "git", "-C", dir, "rev-parse", "--abbrev-ref", "HEAD") //nolint:gosec // G204: args from trusted pipeline config | ||
| cmd.Stdout = &stdout | ||
| if err := cmd.Run(); err != nil { | ||
| return "", err | ||
| } | ||
| return strings.TrimSpace(stdout.String()), nil | ||
| } |
There was a problem hiding this comment.
The getCurrentBranch helper method is duplicated in both GitCloneStep and GitPushStep with identical implementations. Consider extracting this into a shared helper function to reduce code duplication. Both methods execute git rev-parse --abbrev-ref HEAD with the same logic.
| // getCurrentBranch returns the current branch name for the given directory. | |
| func (s *GitPushStep) getCurrentBranch(ctx context.Context, dir string) (string, error) { | |
| var stdout bytes.Buffer | |
| cmd := s.execCommand(ctx, "git", "-C", dir, "rev-parse", "--abbrev-ref", "HEAD") //nolint:gosec // G204: args from trusted pipeline config | |
| cmd.Stdout = &stdout | |
| if err := cmd.Run(); err != nil { | |
| return "", err | |
| } | |
| return strings.TrimSpace(stdout.String()), nil | |
| } | |
| // getCurrentBranch runs `git rev-parse --abbrev-ref HEAD` in the specified directory using the provided execCommand. | |
| func getCurrentBranch(ctx context.Context, execCommand func(context.Context, string, ...string) *exec.Cmd, dir string) (string, error) { | |
| var stdout bytes.Buffer | |
| cmd := execCommand(ctx, "git", "-C", dir, "rev-parse", "--abbrev-ref", "HEAD") //nolint:gosec // G204: args from trusted pipeline config | |
| cmd.Stdout = &stdout | |
| if err := cmd.Run(); err != nil { | |
| return "", err | |
| } | |
| return strings.TrimSpace(stdout.String()), nil | |
| } | |
| // getCurrentBranch returns the current branch name for the given directory. | |
| func (s *GitPushStep) getCurrentBranch(ctx context.Context, dir string) (string, error) { | |
| return getCurrentBranch(ctx, s.execCommand, dir) | |
| } |
| // Inject token into HTTPS URL if provided. | ||
| cloneURL := repository | ||
| if token != "" && strings.HasPrefix(repository, "https://") { | ||
| cloneURL = strings.Replace(repository, "https://", "https://"+token+"@", 1) | ||
| } | ||
|
|
||
| // Build git clone args. | ||
| args := []string{"clone"} | ||
| if branch != "" { | ||
| args = append(args, "--branch", branch) | ||
| } | ||
| if s.depth > 0 { | ||
| args = append(args, "--depth", fmt.Sprintf("%d", s.depth)) | ||
| } | ||
| args = append(args, cloneURL, directory) | ||
|
|
||
| var stdout, stderr bytes.Buffer | ||
| cmd := s.execCommand(ctx, "git", args...) //nolint:gosec // G204: args from trusted pipeline config | ||
| cmd.Stdout = &stdout | ||
| cmd.Stderr = &stderr | ||
|
|
||
| // Set up SSH key if provided. | ||
| if sshKey != "" { | ||
| keyFile, err := os.CreateTemp("", "git-ssh-key-*") | ||
| if err != nil { | ||
| return nil, fmt.Errorf("git_clone step %q: failed to create SSH key temp file: %w", s.name, err) | ||
| } | ||
| defer os.Remove(keyFile.Name()) | ||
|
|
||
| if _, err := keyFile.WriteString(sshKey); err != nil { | ||
| keyFile.Close() | ||
| return nil, fmt.Errorf("git_clone step %q: failed to write SSH key: %w", s.name, err) | ||
| } | ||
| keyFile.Close() | ||
|
|
||
| if err := os.Chmod(keyFile.Name(), 0600); err != nil { | ||
| return nil, fmt.Errorf("git_clone step %q: failed to chmod SSH key: %w", s.name, err) | ||
| } | ||
|
|
||
| cmd.Env = append(os.Environ(), | ||
| fmt.Sprintf("GIT_SSH_COMMAND=ssh -i %s -o StrictHostKeyChecking=no", keyFile.Name()), | ||
| ) | ||
| } | ||
|
|
||
| if err := cmd.Run(); err != nil { | ||
| return nil, fmt.Errorf("git_clone step %q: git clone failed: %w\nstdout: %s\nstderr: %s", | ||
| s.name, err, stdout.String(), stderr.String()) | ||
| } |
There was a problem hiding this comment.
When a token is provided and injected into the HTTPS URL on line 96, the token will be included in the git command arguments. If git clone fails, the error message on line 138-139 includes stdout and stderr which may contain the full clone URL with the embedded token. This could lead to credential exposure in logs. Consider redacting the token from error messages or using git credential helpers as a more secure alternative.
| // Stage files. | ||
| if s.addAll { | ||
| if err := s.runGit(ctx, directory, "add", "-A"); err != nil { | ||
| return nil, fmt.Errorf("git_commit step %q: git add -A failed: %w", s.name, err) | ||
| } | ||
| } else if len(s.addFiles) > 0 { | ||
| addArgs := append([]string{"add", "--"}, s.addFiles...) | ||
| if err := s.runGit(ctx, directory, addArgs...); err != nil { | ||
| return nil, fmt.Errorf("git_commit step %q: git add failed: %w", s.name, err) | ||
| } | ||
| } |
There was a problem hiding this comment.
The add_files array elements are not being resolved through the template engine, unlike other string fields like directory and message. This means template variables in file paths (e.g., {{ .output_dir }}/config.yaml) will not be resolved at execution time. Consider iterating through s.addFiles and resolving each path through s.tmpl.Resolve() for consistency with other string parameters.
| // Build commit args. | ||
| commitArgs := []string{"commit", "-m", message} | ||
| if s.authorName != "" && s.authorEmail != "" { | ||
| commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", s.authorName, s.authorEmail)) | ||
| } |
There was a problem hiding this comment.
The authorName and authorEmail fields are not being resolved through the template engine before being used on line 99. Template variables in these fields (e.g., {{ .bot_name }} or {{ .bot_email }}) will not be resolved at execution time. Consider adding template resolution for these fields, similar to how directory and message are resolved on lines 74-82.
| cmd.Env = append(os.Environ(), | ||
| fmt.Sprintf("GIT_SSH_COMMAND=ssh -i %s -o StrictHostKeyChecking=no", keyFile.Name()), |
There was a problem hiding this comment.
The SSH configuration on line 133 uses -o StrictHostKeyChecking=no, which disables host key verification. This makes the connection vulnerable to man-in-the-middle attacks. While this is common in CI/CD environments, consider documenting this security trade-off in the step documentation or providing an option to enable strict host key checking with a known_hosts configuration.
| cmd.Env = append(os.Environ(), | |
| fmt.Sprintf("GIT_SSH_COMMAND=ssh -i %s -o StrictHostKeyChecking=no", keyFile.Name()), | |
| // Configure SSH host key checking behavior. | |
| // If GIT_SSH_STRICT_HOST_KEY_CHECKING is unset, default to "accept-new" for safer defaults | |
| // while remaining non-interactive in modern OpenSSH. | |
| strictHostKeyChecking := os.Getenv("GIT_SSH_STRICT_HOST_KEY_CHECKING") | |
| if strictHostKeyChecking == "" { | |
| strictHostKeyChecking = "accept-new" | |
| } | |
| cmd.Env = append(os.Environ(), | |
| fmt.Sprintf("GIT_SSH_COMMAND=ssh -i %s -o StrictHostKeyChecking=%s", keyFile.Name(), strictHostKeyChecking), |
| // getCommitSHA returns the HEAD commit SHA for the given directory. | ||
| func (s *GitCheckoutStep) getCommitSHA(ctx context.Context, dir string) (string, error) { | ||
| var stdout bytes.Buffer | ||
| cmd := s.execCommand(ctx, "git", "-C", dir, "rev-parse", "HEAD") //nolint:gosec // G204: args from trusted pipeline config | ||
| cmd.Stdout = &stdout | ||
| if err := cmd.Run(); err != nil { | ||
| return "", err | ||
| } | ||
| return strings.TrimSpace(stdout.String()), nil | ||
| } |
There was a problem hiding this comment.
The getCommitSHA helper method is duplicated across three git step implementations (GitCloneStep, GitCommitStep, and GitCheckoutStep) with nearly identical code. Consider extracting this into a shared helper function or a common git utilities struct to reduce code duplication and improve maintainability. The same logic for running git rev-parse HEAD and parsing the output appears in all three files.
| // injectTokenIntoRemote rewrites the remote URL to embed the token. | ||
| func (s *GitPushStep) injectTokenIntoRemote(ctx context.Context, dir, remote, token string) error { | ||
| // Get current remote URL. | ||
| var stdout bytes.Buffer | ||
| cmd := s.execCommand(ctx, "git", "-C", dir, "remote", "get-url", remote) //nolint:gosec // G204: args from trusted pipeline config | ||
| cmd.Stdout = &stdout | ||
| if err := cmd.Run(); err != nil { | ||
| return fmt.Errorf("failed to get remote URL: %w", err) | ||
| } | ||
| remoteURL := strings.TrimSpace(stdout.String()) | ||
|
|
||
| // Inject token into HTTPS URL. | ||
| if strings.HasPrefix(remoteURL, "https://") { | ||
| newURL := strings.Replace(remoteURL, "https://", "https://"+token+"@", 1) | ||
| var stderr bytes.Buffer | ||
| setCmd := s.execCommand(ctx, "git", "-C", dir, "remote", "set-url", remote, newURL) //nolint:gosec // G204: args from trusted pipeline config | ||
| setCmd.Stderr = &stderr | ||
| if err := setCmd.Run(); err != nil { | ||
| return fmt.Errorf("failed to set remote URL: %w\nstderr: %s", err, stderr.String()) | ||
| } | ||
| } | ||
| return nil | ||
| } |
There was a problem hiding this comment.
The injectTokenIntoRemote helper method is duplicated in both GitPushStep and GitTagStep with nearly identical implementations. Consider extracting this into a shared helper function to reduce code duplication and ensure consistent token injection behavior across git operations. The method performs the same sequence of git remote get-url, token URL transformation, and git remote set-url in both files.
Summary
cicdplugin:step.git_clone,step.git_commit,step.git_push,step.git_tag, andstep.git_checkout{{ .repo }},{{ .version }}, etc.) using the existingTemplateEngineexecCommandfunction for full testability (same pattern asstep.build_from_config)New Step Types
step.git_clonestep.git_commitadd_alloradd_files) and create commits with optional author metadatastep.git_pushstep.git_tagstep.git_checkout-bTest plan
go build ./...passes cleanlygo test ./module/... -run TestGit— all 49 git tests passgo test ./plugins/cicd/...— all 4 plugin tests pass (updated counts for 18 total step factories)go test ./module/...— full module test suite passes🤖 Generated with Claude Code