GitOps Commit Bot
Clones a private GitHub repo, creates a feature branch, writes a Kubernetes deployment manifest, commits it, and pushes. This is the foundational pattern for GitOps config automation where infrastructure changes are driven by Git commits.
Input: GITHUB_TOKEN env var, repo URL and service name as CLI arguments.
Output: A new branch pushed to the remote with an automated config commit.
Setup
mkdir gitops-commit-bot
cd gitops-commit-bot
go mod init github.com/yourorg/gitops-commit-bot
go get github.com/go-git/go-git/v5@latestmain.go
package main
import (
"fmt"
"log"
"os"
"time"
git "github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
)
func main() {
if len(os.Args) != 3 {
fmt.Fprintln(os.Stderr, "usage: gitops-commit-bot <repo-url> <service-name>")
fmt.Fprintln(os.Stderr, "example: gitops-commit-bot https://github.com/org/infra-config.git api")
fmt.Fprintln(os.Stderr, "\nrequires: GITHUB_TOKEN env var")
os.Exit(1)
}
repoURL := os.Args[1]
service := os.Args[2]
token := os.Getenv("GITHUB_TOKEN")
if token == "" {
log.Fatal("GITHUB_TOKEN env var is required")
}
auth := &githttp.BasicAuth{
Username: "x-access-token", // GitHub convention — username is ignored, token is the password
Password: token,
}
// Clone into a temp directory — cleaned up on exit
dir, err := os.MkdirTemp("", "gitops-*")
if err != nil {
log.Fatalf("create temp dir: %v", err)
}
defer os.RemoveAll(dir)
fmt.Printf("cloning %s...\n", repoURL)
repo, err := git.PlainClone(dir, false, &git.CloneOptions{
URL: repoURL,
Auth: auth,
Depth: 1, // shallow clone — we don't need history
Progress: os.Stdout,
})
if err != nil {
log.Fatalf("clone: %v", err)
}
// Create and checkout a new branch
wt, err := repo.Worktree()
if err != nil {
log.Fatalf("worktree: %v", err)
}
branchName := fmt.Sprintf("auto/update-%s-%d", service, time.Now().Unix())
if err := wt.Checkout(&git.CheckoutOptions{
Branch: plumbing.NewBranchReferenceName(branchName),
Create: true,
}); err != nil {
log.Fatalf("checkout branch: %v", err)
}
fmt.Printf("created branch: %s\n", branchName)
// Write the generated Kubernetes manifest
configDir := dir + "/k8s"
if err := os.MkdirAll(configDir, 0o755); err != nil {
log.Fatalf("mkdir k8s: %v", err)
}
configContent := fmt.Sprintf(`apiVersion: apps/v1
kind: Deployment
metadata:
name: %s
spec:
replicas: 3
template:
spec:
containers:
- name: %s
image: myregistry/%s:v2.1.0
`, service, service, service)
configFile := "k8s/" + service + ".yaml"
if err := os.WriteFile(dir+"/"+configFile, []byte(configContent), 0o644); err != nil {
log.Fatalf("write config: %v", err)
}
// Stage only the specific file — never wt.Add(".")
if _, err := wt.Add(configFile); err != nil {
log.Fatalf("stage file: %v", err)
}
// Verify there are actually changes to commit
status, err := wt.Status()
if err != nil {
log.Fatalf("status: %v", err)
}
if status.IsClean() {
fmt.Println("nothing to commit — file is already up to date")
return
}
// Commit
hash, err := wt.Commit(
fmt.Sprintf("chore: update %s deployment config", service),
&git.CommitOptions{
Author: &object.Signature{
Name: "GitOps Bot",
Email: "[email protected]",
When: time.Now(),
},
},
)
if err != nil {
log.Fatalf("commit: %v", err)
}
fmt.Printf("committed: %s\n", hash.String()[:8])
// Push the branch to remote
if err := repo.Push(&git.PushOptions{Auth: auth}); err != nil {
log.Fatalf("push: %v", err)
}
fmt.Printf("\ndone — branch %q pushed to %s\n", branchName, repoURL)
fmt.Println("open a pull request to merge the config change.")
}Running It
export GITHUB_TOKEN=ghp_your_token_here
go run main.go https://github.com/yourorg/infra-config.git apiExpected output:
cloning https://github.com/yourorg/infra-config.git...
Enumerating objects: 5, done.
created branch: auto/update-api-1754900000
committed: a3f2c1d9
done — branch "auto/update-api-1754900000" pushed to https://github.com/yourorg/infra-config.git
open a pull request to merge the config change.After running, go to your repo on GitHub and you'll see the new branch with the commit. Open a pull request from there to merge the config change.
Notes
- The token needs
reposcope on GitHub to clone private repos and push branches Depth: 1clones only the latest commit — faster for automation that doesn't need historywt.Add(configFile)stages only the specific file. Never usewt.Add(".")— it stages everything in the working tree including files you didn't intend to commit- The branch name includes a Unix timestamp so each run creates a unique branch