From 302df33baaf1da4dcaf55d55458fe28f31a701ad Mon Sep 17 00:00:00 2001 From: Ondrej Vlach Date: Fri, 30 May 2025 19:23:34 +0200 Subject: [PATCH] initial commit --- .gitea/workflows/test.yaml | 19 +++++ action.yaml | 25 +++++++ go.mod | 5 ++ go.sum | 2 + main.go | 143 +++++++++++++++++++++++++++++++++++++ main_test.go | 103 ++++++++++++++++++++++++++ 6 files changed, 297 insertions(+) create mode 100644 .gitea/workflows/test.yaml create mode 100644 action.yaml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 main_test.go diff --git a/.gitea/workflows/test.yaml b/.gitea/workflows/test.yaml new file mode 100644 index 0000000..a6da2d1 --- /dev/null +++ b/.gitea/workflows/test.yaml @@ -0,0 +1,19 @@ +name: Go Test + +on: + push: + branches: [ main ] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: https://github.com/actions/checkout@v4 + - name: Set up Go + uses: https://github.com/actions/setup-go@v5 + with: + go-version: '1.24.3' + - name: Run tests + run: go test -v ./... diff --git a/action.yaml b/action.yaml new file mode 100644 index 0000000..2764078 --- /dev/null +++ b/action.yaml @@ -0,0 +1,25 @@ +name: 'Build docker image' +description: 'Docker builder for images + pushing, secret management, automatic versioning, and more' +inputs: + path: + description: 'Path to image registry (e.g., ghcr.io/owner/repo)' + required: true + image-name: + description: 'The name of the image to build' + required: true + secrets: + desction: 'Secrets lists to pass to the build' + required: false + default: '' + dockerfile: + description: 'Path to the Dockerfile (default: Dockerfile)' + required: false + default: 'Dockerfile' +outputs: + full_image_name: + description: 'Full image name' + image_tag: + description: 'Version of image' +runs: + using: 'go' + main: 'main.go' diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e2d28e3 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module git.nanobyte.cz/actions/go-dockerbuild + +go 1.24.3 + +require github.com/sethvargo/go-githubactions v1.3.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..afd638a --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/sethvargo/go-githubactions v1.3.1 h1:rlwwLRUaunWLQ1aN2o5Y+3s0xhaTC30YObCnilRx448= +github.com/sethvargo/go-githubactions v1.3.1/go.mod h1:7/4WeHgYfSz9U5vwuToCK9KPnELVHAhGtRwLREOQV80= diff --git a/main.go b/main.go new file mode 100644 index 0000000..437e8c2 --- /dev/null +++ b/main.go @@ -0,0 +1,143 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "os/exec" + "strings" + "time" + + gha "github.com/sethvargo/go-githubactions" +) + +func getShortHash() string { + // Get short git commit hash + gitCmd := exec.Command("git", "rev-parse", "--short", "HEAD") + gitCmd.Dir = "." + gitCmd.Stderr = os.Stderr + hashBytes, err := gitCmd.Output() + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to get git hash: %v\n", err) + os.Exit(1) + } + shortHash := string(hashBytes) + shortHash = shortHash[:len(shortHash)-1] // remove trailing newline + return shortHash +} + +func parseSecrets(secretsInput string) map[string]string { + secrets := make(map[string]string) + if secretsInput != "" { + scanner := bufio.NewScanner(strings.NewReader(secretsInput)) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + parts := strings.SplitN(line, "=", 2) + if len(parts) == 2 { + key := strings.TrimSpace(parts[0]) + val := strings.Trim(strings.TrimSpace(parts[1]), `"`) + secrets[key] = val + } + } + } + + return secrets + +} + +func buildDockerCommand(context string, dockerFile string, secrets map[string]string, tagLatest string, tagNightly string) *exec.Cmd { + // Parse secrets and write to .secrets file + buildArgs := []string{} + if len(secrets) > 0 { + f, err := os.CreateTemp("", ".secrets") + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to create .secrets file: %v\n", err) + os.Exit(1) + } + defer f.Close() + for secretKey, secretVal := range secrets { + _, err := fmt.Fprintf(f, "%s=%s\n", secretKey, secretVal) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to write secret: %v\n", err) + os.Exit(1) + } + } + + buildArgs = append(buildArgs, "--secret", "id=build_secrets,src="+f.Name()) + } + + // Build command + buildArgsExpanded := append([]string{"build", context, "-f", dockerFile, "-t", tagLatest, "-t", tagNightly}, buildArgs...) + buildCmd := exec.Command("docker", buildArgsExpanded...) + buildCmd.Env = append(os.Environ(), "DOCKER_BUILDKIT=1") + buildCmd.Stdout = os.Stdout + buildCmd.Stderr = os.Stderr + return buildCmd +} + +func pushDockerCommand(tag string) *exec.Cmd { + // Push nightly tag + pushNightlyCmd := exec.Command("docker", "push", tag) + pushNightlyCmd.Stdout = os.Stdout + pushNightlyCmd.Stderr = os.Stderr + return pushNightlyCmd +} + +func main() { + basePath := gha.GetInput("path") + imageName := gha.GetInput("image-name") + dockerFile := gha.GetInput("dockerfile") + context := gha.GetInput("context") + secrets := parseSecrets(gha.GetInput("secrets")) + date := time.Now().Format("2006-01-02") + shortHash := getShortHash() + + if basePath == "" { + fmt.Fprintln(os.Stderr, "Error: 'path' input is required") + os.Exit(1) + } + + if imageName == "" { + fmt.Fprintln(os.Stderr, "Error: 'image-name' input is required") + os.Exit(1) + } + + if basePath[len(basePath)-1] != '/' { + basePath += "/" + } + + // Construct image names + imageBase := fmt.Sprintf("%s%s", basePath, imageName) + tagLatest := fmt.Sprintf("%s:latest", imageBase) + tagNightly := fmt.Sprintf("%s:nightly-%s.%s", imageBase, date, shortHash) + + // Build Docker command + buildCmd := buildDockerCommand(context, dockerFile, secrets, tagLatest, tagNightly) + fmt.Println("Running:", buildCmd.String()) + if err := buildCmd.Run(); err != nil { + fmt.Fprintf(os.Stderr, "Build failed: %v\n", err) + os.Exit(1) + } + + // Push nightly tag + pushNightlyCmd := pushDockerCommand(tagNightly) + fmt.Println("Running:", pushNightlyCmd.String()) + if err := pushNightlyCmd.Run(); err != nil { + fmt.Fprintf(os.Stderr, "Push nightly failed: %v\n", err) + os.Exit(1) + } + + // Push latest tag + pushLatestCmd := pushDockerCommand(tagLatest) + fmt.Println("Running:", pushLatestCmd.String()) + if err := pushLatestCmd.Run(); err != nil { + fmt.Fprintf(os.Stderr, "Push latest failed: %v\n", err) + os.Exit(1) + } + + gha.SetOutput("image_tag", fmt.Sprintf("%s:%s", imageBase, shortHash)) + gha.SetOutput("full_image_name", tagLatest) +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..14ae508 --- /dev/null +++ b/main_test.go @@ -0,0 +1,103 @@ +package main + +import ( + "os/exec" + "strings" + "testing" +) + +// Test parseSecrets with various input formats +func TestParseSecrets(t *testing.T) { + input := ` +SECRET1=foo +SECRET2="bar" +# This is a comment +SECRET3= baz +` + expected := map[string]string{ + "SECRET1": "foo", + "SECRET2": "bar", + "SECRET3": "baz", + } + result := parseSecrets(input) + if len(result) != len(expected) { + t.Fatalf("expected %d secrets, got %d", len(expected), len(result)) + } + for k, v := range expected { + if result[k] != v { + t.Errorf("expected %s=%s, got %s", k, v, result[k]) + } + } +} + +// Test getShortHash (requires git repo) +func TestGetShortHash(t *testing.T) { + cmd := exec.Command("git", "rev-parse", "--short", "HEAD") + out, err := cmd.Output() + if err != nil { + t.Skip("Skipping getShortHash test: not a git repo", err) + } + expected := strings.TrimSpace(string(out)) + got := getShortHash() + if got != expected { + t.Errorf("expected hash %s, got %s", expected, got) + } +} + +// Test buildDockerCommand returns a valid *exec.Cmd with context and dockerfile +func TestBuildDockerCommandWithContextAndDockerfile(t *testing.T) { + secrets := map[string]string{ + "FOO": "bar", + } + context := "./foo" + dockerfile := "Dockerfile" + tagLatest := "test:latest" + tagNightly := "test:nightly" + cmd := buildDockerCommand(context, dockerfile, secrets, tagLatest, tagNightly) + if cmd == nil { + t.Fatal("expected non-nil *exec.Cmd") + } + args := strings.Join(cmd.Args, " ") + if !strings.Contains(args, context) { + t.Errorf("docker build args missing context: %v", cmd.Args) + } + if !strings.Contains(args, "-f") || !strings.Contains(args, dockerfile) { + t.Errorf("docker build args missing dockerfile: %v", cmd.Args) + } + if !strings.Contains(args, tagLatest) || !strings.Contains(args, tagNightly) { + t.Errorf("docker build args missing tags: %v", cmd.Args) + } + if !strings.Contains(args, "--secret") { + t.Errorf("docker build args missing --secret: %v", cmd.Args) + } +} + +// Test buildDockerCommand returns a valid *exec.Cmd without secrets +func TestBuildDockerCommandNoSecrets(t *testing.T) { + secrets := map[string]string{} + context := "." + dockerfile := "Dockerfile" + tagLatest := "test:latest" + tagNightly := "test:nightly" + cmd := buildDockerCommand(context, dockerfile, secrets, tagLatest, tagNightly) + if cmd == nil { + t.Fatal("expected non-nil *exec.Cmd") + } + args := strings.Join(cmd.Args, " ") + if strings.Contains(args, "--secret") { + t.Errorf("docker build args should not contain --secret when no secrets are provided: %v", cmd.Args) + } +} + +// Test pushDockerCommand returns a valid *exec.Cmd +func TestPushDockerCommand(t *testing.T) { + tag := "test:latest" + cmd := pushDockerCommand(tag) + if cmd == nil { + t.Fatal("expected non-nil *exec.Cmd") + } + args := strings.Join(cmd.Args, " ") + if !strings.Contains(args, "push") || !strings.Contains(args, tag) { + t.Errorf("docker push args missing: %v", cmd.Args) + } +}