From d25a86fecae24280dcf0307428802ea03564400d Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 28 Jan 2026 18:42:04 +0000 Subject: [PATCH] feat(release): add LinuxKit and Docker publishers LinuxKit publisher: - Build images via linuxkit CLI (iso, qcow2, vmdk, raw) - Multi-platform support (linux/amd64, linux/arm64) - Upload artifacts to GitHub release Docker publisher: - Multi-arch builds via docker buildx - Push to registry (default: ghcr.io) - Tag templates with {{.Version}} expansion - Build args support Co-Authored-By: Claude Opus 4.5 --- pkg/release/config.go | 22 +- pkg/release/publishers/docker.go | 278 +++++++++++++++++++++++ pkg/release/publishers/docker_test.go | 267 ++++++++++++++++++++++ pkg/release/publishers/github_test.go | 10 +- pkg/release/publishers/linuxkit.go | 282 ++++++++++++++++++++++++ pkg/release/publishers/linuxkit_test.go | 217 ++++++++++++++++++ pkg/release/publishers/publisher.go | 7 +- pkg/release/release.go | 56 ++++- 8 files changed, 1134 insertions(+), 5 deletions(-) create mode 100644 pkg/release/publishers/docker.go create mode 100644 pkg/release/publishers/docker_test.go create mode 100644 pkg/release/publishers/linuxkit.go create mode 100644 pkg/release/publishers/linuxkit_test.go diff --git a/pkg/release/config.go b/pkg/release/config.go index 15322e35..756678bc 100644 --- a/pkg/release/config.go +++ b/pkg/release/config.go @@ -57,12 +57,32 @@ type TargetConfig struct { // PublisherConfig holds configuration for a publisher. type PublisherConfig struct { - // Type is the publisher type (e.g., "github"). + // Type is the publisher type (e.g., "github", "linuxkit", "docker"). Type string `yaml:"type"` // Prerelease marks the release as a prerelease. Prerelease bool `yaml:"prerelease"` // Draft creates the release as a draft. Draft bool `yaml:"draft"` + + // LinuxKit-specific configuration + // Config is the path to the LinuxKit YAML configuration file. + Config string `yaml:"config,omitempty"` + // Formats are the output formats to build (iso, raw, qcow2, vmdk). + Formats []string `yaml:"formats,omitempty"` + // Platforms are the target platforms (linux/amd64, linux/arm64). + Platforms []string `yaml:"platforms,omitempty"` + + // Docker-specific configuration + // Registry is the container registry (default: ghcr.io). + Registry string `yaml:"registry,omitempty"` + // Image is the image name in owner/repo format. + Image string `yaml:"image,omitempty"` + // Dockerfile is the path to the Dockerfile (default: Dockerfile). + Dockerfile string `yaml:"dockerfile,omitempty"` + // Tags are the image tags to apply. + Tags []string `yaml:"tags,omitempty"` + // BuildArgs are additional Docker build arguments. + BuildArgs map[string]string `yaml:"build_args,omitempty"` } // ChangelogConfig holds changelog generation settings. diff --git a/pkg/release/publishers/docker.go b/pkg/release/publishers/docker.go new file mode 100644 index 00000000..7d342ab3 --- /dev/null +++ b/pkg/release/publishers/docker.go @@ -0,0 +1,278 @@ +// Package publishers provides release publishing implementations. +package publishers + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// DockerConfig holds configuration for the Docker publisher. +type DockerConfig struct { + // Registry is the container registry (default: ghcr.io). + Registry string `yaml:"registry"` + // Image is the image name in owner/repo format. + Image string `yaml:"image"` + // Dockerfile is the path to the Dockerfile (default: Dockerfile). + Dockerfile string `yaml:"dockerfile"` + // Platforms are the target platforms (linux/amd64, linux/arm64). + Platforms []string `yaml:"platforms"` + // Tags are additional tags to apply (supports {{.Version}} template). + Tags []string `yaml:"tags"` + // BuildArgs are additional build arguments. + BuildArgs map[string]string `yaml:"build_args"` +} + +// DockerPublisher builds and publishes Docker images. +type DockerPublisher struct{} + +// NewDockerPublisher creates a new Docker publisher. +func NewDockerPublisher() *DockerPublisher { + return &DockerPublisher{} +} + +// Name returns the publisher's identifier. +func (p *DockerPublisher) Name() string { + return "docker" +} + +// Publish builds and pushes Docker images. +func (p *DockerPublisher) Publish(ctx context.Context, release *Release, pubCfg PublisherConfig, relCfg ReleaseConfig, dryRun bool) error { + // Validate docker CLI is available + if err := validateDockerCli(); err != nil { + return err + } + + // Parse Docker-specific config from publisher config + dockerCfg := p.parseConfig(pubCfg, relCfg, release.ProjectDir) + + // Validate Dockerfile exists + if _, err := os.Stat(dockerCfg.Dockerfile); err != nil { + return fmt.Errorf("docker.Publish: Dockerfile not found: %s", dockerCfg.Dockerfile) + } + + if dryRun { + return p.dryRunPublish(release, dockerCfg) + } + + return p.executePublish(ctx, release, dockerCfg) +} + +// parseConfig extracts Docker-specific configuration. +func (p *DockerPublisher) parseConfig(pubCfg PublisherConfig, relCfg ReleaseConfig, projectDir string) DockerConfig { + cfg := DockerConfig{ + Registry: "ghcr.io", + Image: "", + Dockerfile: filepath.Join(projectDir, "Dockerfile"), + Platforms: []string{"linux/amd64", "linux/arm64"}, + Tags: []string{"latest", "{{.Version}}"}, + BuildArgs: make(map[string]string), + } + + // Try to get image from repository config + if relCfg != nil && relCfg.GetRepository() != "" { + cfg.Image = relCfg.GetRepository() + } + + // Override from extended config if present + if ext, ok := pubCfg.Extended.(map[string]any); ok { + if registry, ok := ext["registry"].(string); ok && registry != "" { + cfg.Registry = registry + } + if image, ok := ext["image"].(string); ok && image != "" { + cfg.Image = image + } + if dockerfile, ok := ext["dockerfile"].(string); ok && dockerfile != "" { + if filepath.IsAbs(dockerfile) { + cfg.Dockerfile = dockerfile + } else { + cfg.Dockerfile = filepath.Join(projectDir, dockerfile) + } + } + if platforms, ok := ext["platforms"].([]any); ok && len(platforms) > 0 { + cfg.Platforms = make([]string, 0, len(platforms)) + for _, plat := range platforms { + if s, ok := plat.(string); ok { + cfg.Platforms = append(cfg.Platforms, s) + } + } + } + if tags, ok := ext["tags"].([]any); ok && len(tags) > 0 { + cfg.Tags = make([]string, 0, len(tags)) + for _, tag := range tags { + if s, ok := tag.(string); ok { + cfg.Tags = append(cfg.Tags, s) + } + } + } + if buildArgs, ok := ext["build_args"].(map[string]any); ok { + for k, v := range buildArgs { + if s, ok := v.(string); ok { + cfg.BuildArgs[k] = s + } + } + } + } + + return cfg +} + +// dryRunPublish shows what would be done without actually building. +func (p *DockerPublisher) dryRunPublish(release *Release, cfg DockerConfig) error { + fmt.Println() + fmt.Println("=== DRY RUN: Docker Build & Push ===") + fmt.Println() + fmt.Printf("Version: %s\n", release.Version) + fmt.Printf("Registry: %s\n", cfg.Registry) + fmt.Printf("Image: %s\n", cfg.Image) + fmt.Printf("Dockerfile: %s\n", cfg.Dockerfile) + fmt.Printf("Platforms: %s\n", strings.Join(cfg.Platforms, ", ")) + fmt.Println() + + // Resolve tags + tags := p.resolveTags(cfg.Tags, release.Version) + fmt.Println("Tags to be applied:") + for _, tag := range tags { + fullTag := p.buildFullTag(cfg.Registry, cfg.Image, tag) + fmt.Printf(" - %s\n", fullTag) + } + fmt.Println() + + fmt.Println("Would execute command:") + args := p.buildBuildxArgs(cfg, tags, release.Version) + fmt.Printf(" docker %s\n", strings.Join(args, " ")) + + if len(cfg.BuildArgs) > 0 { + fmt.Println() + fmt.Println("Build arguments:") + for k, v := range cfg.BuildArgs { + fmt.Printf(" %s=%s\n", k, v) + } + } + + fmt.Println() + fmt.Println("=== END DRY RUN ===") + + return nil +} + +// executePublish builds and pushes Docker images. +func (p *DockerPublisher) executePublish(ctx context.Context, release *Release, cfg DockerConfig) error { + // Ensure buildx is available and builder is set up + if err := p.ensureBuildx(ctx); err != nil { + return err + } + + // Resolve tags + tags := p.resolveTags(cfg.Tags, release.Version) + + // Build the docker buildx command + args := p.buildBuildxArgs(cfg, tags, release.Version) + + cmd := exec.CommandContext(ctx, "docker", args...) + cmd.Dir = release.ProjectDir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + fmt.Printf("Building and pushing Docker image: %s\n", cfg.Image) + if err := cmd.Run(); err != nil { + return fmt.Errorf("docker.Publish: buildx build failed: %w", err) + } + + return nil +} + +// resolveTags expands template variables in tags. +func (p *DockerPublisher) resolveTags(tags []string, version string) []string { + resolved := make([]string, 0, len(tags)) + for _, tag := range tags { + // Replace {{.Version}} with actual version + resolvedTag := strings.ReplaceAll(tag, "{{.Version}}", version) + // Also support simpler {{Version}} syntax + resolvedTag = strings.ReplaceAll(resolvedTag, "{{Version}}", version) + resolved = append(resolved, resolvedTag) + } + return resolved +} + +// buildFullTag builds the full image tag including registry. +func (p *DockerPublisher) buildFullTag(registry, image, tag string) string { + if registry != "" { + return fmt.Sprintf("%s/%s:%s", registry, image, tag) + } + return fmt.Sprintf("%s:%s", image, tag) +} + +// buildBuildxArgs builds the arguments for docker buildx build command. +func (p *DockerPublisher) buildBuildxArgs(cfg DockerConfig, tags []string, version string) []string { + args := []string{"buildx", "build"} + + // Multi-platform support + if len(cfg.Platforms) > 0 { + args = append(args, "--platform", strings.Join(cfg.Platforms, ",")) + } + + // Add all tags + for _, tag := range tags { + fullTag := p.buildFullTag(cfg.Registry, cfg.Image, tag) + args = append(args, "-t", fullTag) + } + + // Dockerfile path + dockerfilePath := cfg.Dockerfile + args = append(args, "-f", dockerfilePath) + + // Build arguments + for k, v := range cfg.BuildArgs { + // Expand version in build args + expandedValue := strings.ReplaceAll(v, "{{.Version}}", version) + expandedValue = strings.ReplaceAll(expandedValue, "{{Version}}", version) + args = append(args, "--build-arg", fmt.Sprintf("%s=%s", k, expandedValue)) + } + + // Always add VERSION build arg + args = append(args, "--build-arg", fmt.Sprintf("VERSION=%s", version)) + + // Push the image + args = append(args, "--push") + + // Build context (current directory) + args = append(args, ".") + + return args +} + +// ensureBuildx ensures docker buildx is available and has a builder. +func (p *DockerPublisher) ensureBuildx(ctx context.Context) error { + // Check if buildx is available + cmd := exec.CommandContext(ctx, "docker", "buildx", "version") + if err := cmd.Run(); err != nil { + return fmt.Errorf("docker: buildx is not available. Install it from https://docs.docker.com/buildx/working-with-buildx/") + } + + // Check if we have a builder, create one if not + cmd = exec.CommandContext(ctx, "docker", "buildx", "inspect", "--bootstrap") + if err := cmd.Run(); err != nil { + // Try to create a builder + cmd = exec.CommandContext(ctx, "docker", "buildx", "create", "--use", "--bootstrap") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("docker: failed to create buildx builder: %w", err) + } + } + + return nil +} + +// validateDockerCli checks if the docker CLI is available. +func validateDockerCli() error { + cmd := exec.Command("docker", "--version") + if err := cmd.Run(); err != nil { + return fmt.Errorf("docker: docker CLI not found. Install it from https://docs.docker.com/get-docker/") + } + return nil +} diff --git a/pkg/release/publishers/docker_test.go b/pkg/release/publishers/docker_test.go new file mode 100644 index 00000000..3c30210c --- /dev/null +++ b/pkg/release/publishers/docker_test.go @@ -0,0 +1,267 @@ +package publishers + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDockerPublisher_Name_Good(t *testing.T) { + t.Run("returns docker", func(t *testing.T) { + p := NewDockerPublisher() + assert.Equal(t, "docker", p.Name()) + }) +} + +func TestDockerPublisher_ParseConfig_Good(t *testing.T) { + p := NewDockerPublisher() + + t.Run("uses defaults when no extended config", func(t *testing.T) { + pubCfg := PublisherConfig{Type: "docker"} + relCfg := &mockReleaseConfig{repository: "owner/repo"} + cfg := p.parseConfig(pubCfg, relCfg, "/project") + + assert.Equal(t, "ghcr.io", cfg.Registry) + assert.Equal(t, "owner/repo", cfg.Image) + assert.Equal(t, "/project/Dockerfile", cfg.Dockerfile) + assert.Equal(t, []string{"linux/amd64", "linux/arm64"}, cfg.Platforms) + assert.Equal(t, []string{"latest", "{{.Version}}"}, cfg.Tags) + }) + + t.Run("parses extended config", func(t *testing.T) { + pubCfg := PublisherConfig{ + Type: "docker", + Extended: map[string]any{ + "registry": "docker.io", + "image": "myorg/myimage", + "dockerfile": "docker/Dockerfile.prod", + "platforms": []any{"linux/amd64"}, + "tags": []any{"latest", "stable", "{{.Version}}"}, + "build_args": map[string]any{ + "GO_VERSION": "1.21", + }, + }, + } + relCfg := &mockReleaseConfig{repository: "owner/repo"} + cfg := p.parseConfig(pubCfg, relCfg, "/project") + + assert.Equal(t, "docker.io", cfg.Registry) + assert.Equal(t, "myorg/myimage", cfg.Image) + assert.Equal(t, "/project/docker/Dockerfile.prod", cfg.Dockerfile) + assert.Equal(t, []string{"linux/amd64"}, cfg.Platforms) + assert.Equal(t, []string{"latest", "stable", "{{.Version}}"}, cfg.Tags) + assert.Equal(t, "1.21", cfg.BuildArgs["GO_VERSION"]) + }) + + t.Run("handles absolute dockerfile path", func(t *testing.T) { + pubCfg := PublisherConfig{ + Type: "docker", + Extended: map[string]any{ + "dockerfile": "/absolute/path/Dockerfile", + }, + } + relCfg := &mockReleaseConfig{repository: "owner/repo"} + cfg := p.parseConfig(pubCfg, relCfg, "/project") + + assert.Equal(t, "/absolute/path/Dockerfile", cfg.Dockerfile) + }) +} + +func TestDockerPublisher_ResolveTags_Good(t *testing.T) { + p := NewDockerPublisher() + + t.Run("resolves version template", func(t *testing.T) { + tags := p.resolveTags([]string{"latest", "{{.Version}}", "stable"}, "v1.2.3") + + assert.Equal(t, []string{"latest", "v1.2.3", "stable"}, tags) + }) + + t.Run("handles simple version syntax", func(t *testing.T) { + tags := p.resolveTags([]string{"{{Version}}"}, "v1.0.0") + + assert.Equal(t, []string{"v1.0.0"}, tags) + }) + + t.Run("handles no templates", func(t *testing.T) { + tags := p.resolveTags([]string{"latest", "stable"}, "v1.2.3") + + assert.Equal(t, []string{"latest", "stable"}, tags) + }) +} + +func TestDockerPublisher_BuildFullTag_Good(t *testing.T) { + p := NewDockerPublisher() + + tests := []struct { + name string + registry string + image string + tag string + expected string + }{ + { + name: "with registry", + registry: "ghcr.io", + image: "owner/repo", + tag: "v1.0.0", + expected: "ghcr.io/owner/repo:v1.0.0", + }, + { + name: "without registry", + registry: "", + image: "myimage", + tag: "latest", + expected: "myimage:latest", + }, + { + name: "docker hub", + registry: "docker.io", + image: "library/nginx", + tag: "alpine", + expected: "docker.io/library/nginx:alpine", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tag := p.buildFullTag(tc.registry, tc.image, tc.tag) + assert.Equal(t, tc.expected, tag) + }) + } +} + +func TestDockerPublisher_BuildBuildxArgs_Good(t *testing.T) { + p := NewDockerPublisher() + + t.Run("builds basic args", func(t *testing.T) { + cfg := DockerConfig{ + Registry: "ghcr.io", + Image: "owner/repo", + Dockerfile: "/project/Dockerfile", + Platforms: []string{"linux/amd64", "linux/arm64"}, + BuildArgs: make(map[string]string), + } + tags := []string{"latest", "v1.0.0"} + + args := p.buildBuildxArgs(cfg, tags, "v1.0.0") + + assert.Contains(t, args, "buildx") + assert.Contains(t, args, "build") + assert.Contains(t, args, "--platform") + assert.Contains(t, args, "linux/amd64,linux/arm64") + assert.Contains(t, args, "-t") + assert.Contains(t, args, "ghcr.io/owner/repo:latest") + assert.Contains(t, args, "ghcr.io/owner/repo:v1.0.0") + assert.Contains(t, args, "-f") + assert.Contains(t, args, "/project/Dockerfile") + assert.Contains(t, args, "--push") + assert.Contains(t, args, ".") + }) + + t.Run("includes build args", func(t *testing.T) { + cfg := DockerConfig{ + Registry: "ghcr.io", + Image: "owner/repo", + Dockerfile: "/project/Dockerfile", + Platforms: []string{"linux/amd64"}, + BuildArgs: map[string]string{ + "GO_VERSION": "1.21", + "APP_NAME": "myapp", + }, + } + tags := []string{"latest"} + + args := p.buildBuildxArgs(cfg, tags, "v1.0.0") + + assert.Contains(t, args, "--build-arg") + // Check that build args are present (order may vary) + foundGoVersion := false + foundAppName := false + foundVersion := false + for i, arg := range args { + if arg == "--build-arg" && i+1 < len(args) { + if args[i+1] == "GO_VERSION=1.21" { + foundGoVersion = true + } + if args[i+1] == "APP_NAME=myapp" { + foundAppName = true + } + if args[i+1] == "VERSION=v1.0.0" { + foundVersion = true + } + } + } + assert.True(t, foundGoVersion, "GO_VERSION build arg not found") + assert.True(t, foundAppName, "APP_NAME build arg not found") + assert.True(t, foundVersion, "VERSION build arg not found") + }) + + t.Run("expands version in build args", func(t *testing.T) { + cfg := DockerConfig{ + Registry: "ghcr.io", + Image: "owner/repo", + Dockerfile: "/project/Dockerfile", + Platforms: []string{"linux/amd64"}, + BuildArgs: map[string]string{ + "APP_VERSION": "{{.Version}}", + }, + } + tags := []string{"latest"} + + args := p.buildBuildxArgs(cfg, tags, "v2.0.0") + + foundExpandedVersion := false + for i, arg := range args { + if arg == "--build-arg" && i+1 < len(args) { + if args[i+1] == "APP_VERSION=v2.0.0" { + foundExpandedVersion = true + } + } + } + assert.True(t, foundExpandedVersion, "APP_VERSION should be expanded to v2.0.0") + }) +} + +func TestDockerPublisher_Publish_Bad(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + p := NewDockerPublisher() + + t.Run("fails when dockerfile not found", func(t *testing.T) { + release := &Release{ + Version: "v1.0.0", + ProjectDir: "/nonexistent", + } + pubCfg := PublisherConfig{ + Type: "docker", + Extended: map[string]any{ + "dockerfile": "/nonexistent/Dockerfile", + }, + } + relCfg := &mockReleaseConfig{repository: "owner/repo"} + + err := p.Publish(nil, release, pubCfg, relCfg, false) + assert.Error(t, err) + assert.Contains(t, err.Error(), "Dockerfile not found") + }) +} + +func TestDockerConfig_Defaults_Good(t *testing.T) { + t.Run("has sensible defaults", func(t *testing.T) { + p := NewDockerPublisher() + pubCfg := PublisherConfig{Type: "docker"} + relCfg := &mockReleaseConfig{repository: "owner/repo"} + + cfg := p.parseConfig(pubCfg, relCfg, "/project") + + // Verify defaults + assert.Equal(t, "ghcr.io", cfg.Registry) + assert.Equal(t, "owner/repo", cfg.Image) + assert.Len(t, cfg.Platforms, 2) + assert.Contains(t, cfg.Platforms, "linux/amd64") + assert.Contains(t, cfg.Platforms, "linux/arm64") + assert.Contains(t, cfg.Tags, "latest") + }) +} diff --git a/pkg/release/publishers/github_test.go b/pkg/release/publishers/github_test.go index 0f5170c2..06c22087 100644 --- a/pkg/release/publishers/github_test.go +++ b/pkg/release/publishers/github_test.go @@ -93,10 +93,18 @@ func TestNewRelease_Good(t *testing.T) { func TestNewPublisherConfig_Good(t *testing.T) { t.Run("creates config struct", func(t *testing.T) { - cfg := NewPublisherConfig("github", true, false) + cfg := NewPublisherConfig("github", true, false, nil) assert.Equal(t, "github", cfg.Type) assert.True(t, cfg.Prerelease) assert.False(t, cfg.Draft) + assert.Nil(t, cfg.Extended) + }) + + t.Run("creates config with extended", func(t *testing.T) { + ext := map[string]any{"key": "value"} + cfg := NewPublisherConfig("docker", false, false, ext) + assert.Equal(t, "docker", cfg.Type) + assert.Equal(t, ext, cfg.Extended) }) } diff --git a/pkg/release/publishers/linuxkit.go b/pkg/release/publishers/linuxkit.go new file mode 100644 index 00000000..53628824 --- /dev/null +++ b/pkg/release/publishers/linuxkit.go @@ -0,0 +1,282 @@ +// Package publishers provides release publishing implementations. +package publishers + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// LinuxKitConfig holds configuration for the LinuxKit publisher. +type LinuxKitConfig struct { + // Config is the path to the LinuxKit YAML configuration file. + Config string `yaml:"config"` + // Formats are the output formats to build (iso, raw, qcow2, vmdk). + Formats []string `yaml:"formats"` + // Platforms are the target platforms (linux/amd64, linux/arm64). + Platforms []string `yaml:"platforms"` +} + +// LinuxKitPublisher builds and publishes LinuxKit images. +type LinuxKitPublisher struct{} + +// NewLinuxKitPublisher creates a new LinuxKit publisher. +func NewLinuxKitPublisher() *LinuxKitPublisher { + return &LinuxKitPublisher{} +} + +// Name returns the publisher's identifier. +func (p *LinuxKitPublisher) Name() string { + return "linuxkit" +} + +// Publish builds LinuxKit images and uploads them to the GitHub release. +func (p *LinuxKitPublisher) Publish(ctx context.Context, release *Release, pubCfg PublisherConfig, relCfg ReleaseConfig, dryRun bool) error { + // Validate linuxkit CLI is available + if err := validateLinuxKitCli(); err != nil { + return err + } + + // Parse LinuxKit-specific config from publisher config + lkCfg := p.parseConfig(pubCfg, release.ProjectDir) + + // Validate config file exists + if _, err := os.Stat(lkCfg.Config); err != nil { + return fmt.Errorf("linuxkit.Publish: config file not found: %s", lkCfg.Config) + } + + // Determine repository for artifact upload + repo := "" + if relCfg != nil { + repo = relCfg.GetRepository() + } + if repo == "" { + detectedRepo, err := detectRepository(release.ProjectDir) + if err != nil { + return fmt.Errorf("linuxkit.Publish: could not determine repository: %w", err) + } + repo = detectedRepo + } + + if dryRun { + return p.dryRunPublish(release, lkCfg, repo) + } + + return p.executePublish(ctx, release, lkCfg, repo) +} + +// parseConfig extracts LinuxKit-specific configuration. +func (p *LinuxKitPublisher) parseConfig(pubCfg PublisherConfig, projectDir string) LinuxKitConfig { + cfg := LinuxKitConfig{ + Config: filepath.Join(projectDir, ".core", "linuxkit", "server.yml"), + Formats: []string{"iso"}, + Platforms: []string{"linux/amd64"}, + } + + // Override from extended config if present + if ext, ok := pubCfg.Extended.(map[string]any); ok { + if configPath, ok := ext["config"].(string); ok && configPath != "" { + if filepath.IsAbs(configPath) { + cfg.Config = configPath + } else { + cfg.Config = filepath.Join(projectDir, configPath) + } + } + if formats, ok := ext["formats"].([]any); ok && len(formats) > 0 { + cfg.Formats = make([]string, 0, len(formats)) + for _, f := range formats { + if s, ok := f.(string); ok { + cfg.Formats = append(cfg.Formats, s) + } + } + } + if platforms, ok := ext["platforms"].([]any); ok && len(platforms) > 0 { + cfg.Platforms = make([]string, 0, len(platforms)) + for _, p := range platforms { + if s, ok := p.(string); ok { + cfg.Platforms = append(cfg.Platforms, s) + } + } + } + } + + return cfg +} + +// dryRunPublish shows what would be done without actually building. +func (p *LinuxKitPublisher) dryRunPublish(release *Release, cfg LinuxKitConfig, repo string) error { + fmt.Println() + fmt.Println("=== DRY RUN: LinuxKit Build & Publish ===") + fmt.Println() + fmt.Printf("Repository: %s\n", repo) + fmt.Printf("Version: %s\n", release.Version) + fmt.Printf("Config: %s\n", cfg.Config) + fmt.Printf("Formats: %s\n", strings.Join(cfg.Formats, ", ")) + fmt.Printf("Platforms: %s\n", strings.Join(cfg.Platforms, ", ")) + fmt.Println() + + outputDir := filepath.Join(release.ProjectDir, "dist", "linuxkit") + baseName := p.buildBaseName(release.Version) + + fmt.Println("Would execute commands:") + for _, platform := range cfg.Platforms { + parts := strings.Split(platform, "/") + arch := "amd64" + if len(parts) == 2 { + arch = parts[1] + } + + for _, format := range cfg.Formats { + outputName := fmt.Sprintf("%s-%s", baseName, arch) + args := p.buildLinuxKitArgs(cfg.Config, format, outputName, outputDir, arch) + fmt.Printf(" linuxkit %s\n", strings.Join(args, " ")) + } + } + fmt.Println() + + fmt.Println("Would upload artifacts to release:") + for _, platform := range cfg.Platforms { + parts := strings.Split(platform, "/") + arch := "amd64" + if len(parts) == 2 { + arch = parts[1] + } + + for _, format := range cfg.Formats { + outputName := fmt.Sprintf("%s-%s", baseName, arch) + artifactPath := p.getArtifactPath(outputDir, outputName, format) + fmt.Printf(" - %s\n", filepath.Base(artifactPath)) + } + } + + fmt.Println() + fmt.Println("=== END DRY RUN ===") + + return nil +} + +// executePublish builds LinuxKit images and uploads them. +func (p *LinuxKitPublisher) executePublish(ctx context.Context, release *Release, cfg LinuxKitConfig, repo string) error { + outputDir := filepath.Join(release.ProjectDir, "dist", "linuxkit") + + // Create output directory + if err := os.MkdirAll(outputDir, 0755); err != nil { + return fmt.Errorf("linuxkit.Publish: failed to create output directory: %w", err) + } + + baseName := p.buildBaseName(release.Version) + var artifacts []string + + // Build for each platform and format + for _, platform := range cfg.Platforms { + parts := strings.Split(platform, "/") + arch := "amd64" + if len(parts) == 2 { + arch = parts[1] + } + + for _, format := range cfg.Formats { + outputName := fmt.Sprintf("%s-%s", baseName, arch) + + // Build the image + args := p.buildLinuxKitArgs(cfg.Config, format, outputName, outputDir, arch) + cmd := exec.CommandContext(ctx, "linuxkit", args...) + cmd.Dir = release.ProjectDir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + fmt.Printf("Building LinuxKit image: %s (%s)\n", outputName, format) + if err := cmd.Run(); err != nil { + return fmt.Errorf("linuxkit.Publish: build failed for %s/%s: %w", platform, format, err) + } + + // Track artifact for upload + artifactPath := p.getArtifactPath(outputDir, outputName, format) + artifacts = append(artifacts, artifactPath) + } + } + + // Upload artifacts to GitHub release + for _, artifactPath := range artifacts { + if _, err := os.Stat(artifactPath); err != nil { + return fmt.Errorf("linuxkit.Publish: artifact not found after build: %s", artifactPath) + } + + if err := UploadArtifact(ctx, repo, release.Version, artifactPath); err != nil { + return fmt.Errorf("linuxkit.Publish: failed to upload %s: %w", filepath.Base(artifactPath), err) + } + } + + return nil +} + +// buildBaseName creates the base name for output files. +func (p *LinuxKitPublisher) buildBaseName(version string) string { + // Strip leading 'v' if present for cleaner filenames + name := strings.TrimPrefix(version, "v") + return fmt.Sprintf("linuxkit-%s", name) +} + +// buildLinuxKitArgs builds the arguments for linuxkit build command. +func (p *LinuxKitPublisher) buildLinuxKitArgs(configPath, format, outputName, outputDir, arch string) []string { + args := []string{"build"} + + // Output format + args = append(args, "-format", format) + + // Output name + args = append(args, "-name", outputName) + + // Output directory + args = append(args, "-dir", outputDir) + + // Architecture (if not amd64) + if arch != "amd64" { + args = append(args, "-arch", arch) + } + + // Config file + args = append(args, configPath) + + return args +} + +// getArtifactPath returns the expected path of the built artifact. +func (p *LinuxKitPublisher) getArtifactPath(outputDir, outputName, format string) string { + ext := p.getFormatExtension(format) + return filepath.Join(outputDir, outputName+ext) +} + +// getFormatExtension returns the file extension for a LinuxKit output format. +func (p *LinuxKitPublisher) getFormatExtension(format string) string { + switch format { + case "iso": + return ".iso" + case "raw": + return ".raw" + case "qcow2": + return ".qcow2" + case "vmdk": + return ".vmdk" + case "vhd": + return ".vhd" + case "gcp": + return ".img.tar.gz" + case "aws": + return ".raw" + default: + return "." + format + } +} + +// validateLinuxKitCli checks if the linuxkit CLI is available. +func validateLinuxKitCli() error { + cmd := exec.Command("linuxkit", "version") + if err := cmd.Run(); err != nil { + return fmt.Errorf("linuxkit: linuxkit CLI not found. Install it from https://github.com/linuxkit/linuxkit") + } + return nil +} diff --git a/pkg/release/publishers/linuxkit_test.go b/pkg/release/publishers/linuxkit_test.go new file mode 100644 index 00000000..1b7e2057 --- /dev/null +++ b/pkg/release/publishers/linuxkit_test.go @@ -0,0 +1,217 @@ +package publishers + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestLinuxKitPublisher_Name_Good(t *testing.T) { + t.Run("returns linuxkit", func(t *testing.T) { + p := NewLinuxKitPublisher() + assert.Equal(t, "linuxkit", p.Name()) + }) +} + +func TestLinuxKitPublisher_ParseConfig_Good(t *testing.T) { + p := NewLinuxKitPublisher() + + t.Run("uses defaults when no extended config", func(t *testing.T) { + pubCfg := PublisherConfig{Type: "linuxkit"} + cfg := p.parseConfig(pubCfg, "/project") + + assert.Equal(t, "/project/.core/linuxkit/server.yml", cfg.Config) + assert.Equal(t, []string{"iso"}, cfg.Formats) + assert.Equal(t, []string{"linux/amd64"}, cfg.Platforms) + }) + + t.Run("parses extended config", func(t *testing.T) { + pubCfg := PublisherConfig{ + Type: "linuxkit", + Extended: map[string]any{ + "config": ".core/linuxkit/custom.yml", + "formats": []any{"iso", "qcow2", "vmdk"}, + "platforms": []any{"linux/amd64", "linux/arm64"}, + }, + } + cfg := p.parseConfig(pubCfg, "/project") + + assert.Equal(t, "/project/.core/linuxkit/custom.yml", cfg.Config) + assert.Equal(t, []string{"iso", "qcow2", "vmdk"}, cfg.Formats) + assert.Equal(t, []string{"linux/amd64", "linux/arm64"}, cfg.Platforms) + }) + + t.Run("handles absolute config path", func(t *testing.T) { + pubCfg := PublisherConfig{ + Type: "linuxkit", + Extended: map[string]any{ + "config": "/absolute/path/to/config.yml", + }, + } + cfg := p.parseConfig(pubCfg, "/project") + + assert.Equal(t, "/absolute/path/to/config.yml", cfg.Config) + }) +} + +func TestLinuxKitPublisher_BuildLinuxKitArgs_Good(t *testing.T) { + p := NewLinuxKitPublisher() + + t.Run("builds basic args for amd64", func(t *testing.T) { + args := p.buildLinuxKitArgs("/config/server.yml", "iso", "linuxkit-1.0.0-amd64", "/output", "amd64") + + assert.Contains(t, args, "build") + assert.Contains(t, args, "-format") + assert.Contains(t, args, "iso") + assert.Contains(t, args, "-name") + assert.Contains(t, args, "linuxkit-1.0.0-amd64") + assert.Contains(t, args, "-dir") + assert.Contains(t, args, "/output") + assert.Contains(t, args, "/config/server.yml") + // Should not contain -arch for amd64 (default) + assert.NotContains(t, args, "-arch") + }) + + t.Run("builds args with arch for arm64", func(t *testing.T) { + args := p.buildLinuxKitArgs("/config/server.yml", "qcow2", "linuxkit-1.0.0-arm64", "/output", "arm64") + + assert.Contains(t, args, "-arch") + assert.Contains(t, args, "arm64") + assert.Contains(t, args, "qcow2") + }) +} + +func TestLinuxKitPublisher_BuildBaseName_Good(t *testing.T) { + p := NewLinuxKitPublisher() + + t.Run("strips v prefix", func(t *testing.T) { + name := p.buildBaseName("v1.2.3") + assert.Equal(t, "linuxkit-1.2.3", name) + }) + + t.Run("handles version without v prefix", func(t *testing.T) { + name := p.buildBaseName("1.2.3") + assert.Equal(t, "linuxkit-1.2.3", name) + }) +} + +func TestLinuxKitPublisher_GetArtifactPath_Good(t *testing.T) { + p := NewLinuxKitPublisher() + + tests := []struct { + name string + outputDir string + outputName string + format string + expected string + }{ + { + name: "ISO format", + outputDir: "/dist/linuxkit", + outputName: "linuxkit-1.0.0-amd64", + format: "iso", + expected: "/dist/linuxkit/linuxkit-1.0.0-amd64.iso", + }, + { + name: "raw format", + outputDir: "/dist/linuxkit", + outputName: "linuxkit-1.0.0-amd64", + format: "raw", + expected: "/dist/linuxkit/linuxkit-1.0.0-amd64.raw", + }, + { + name: "qcow2 format", + outputDir: "/dist/linuxkit", + outputName: "linuxkit-1.0.0-arm64", + format: "qcow2", + expected: "/dist/linuxkit/linuxkit-1.0.0-arm64.qcow2", + }, + { + name: "vmdk format", + outputDir: "/dist/linuxkit", + outputName: "linuxkit-1.0.0-amd64", + format: "vmdk", + expected: "/dist/linuxkit/linuxkit-1.0.0-amd64.vmdk", + }, + { + name: "gcp format", + outputDir: "/dist/linuxkit", + outputName: "linuxkit-1.0.0-amd64", + format: "gcp", + expected: "/dist/linuxkit/linuxkit-1.0.0-amd64.img.tar.gz", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + path := p.getArtifactPath(tc.outputDir, tc.outputName, tc.format) + assert.Equal(t, tc.expected, path) + }) + } +} + +func TestLinuxKitPublisher_GetFormatExtension_Good(t *testing.T) { + p := NewLinuxKitPublisher() + + tests := []struct { + format string + expected string + }{ + {"iso", ".iso"}, + {"raw", ".raw"}, + {"qcow2", ".qcow2"}, + {"vmdk", ".vmdk"}, + {"vhd", ".vhd"}, + {"gcp", ".img.tar.gz"}, + {"aws", ".raw"}, + {"unknown", ".unknown"}, + } + + for _, tc := range tests { + t.Run(tc.format, func(t *testing.T) { + ext := p.getFormatExtension(tc.format) + assert.Equal(t, tc.expected, ext) + }) + } +} + +func TestLinuxKitPublisher_Publish_Bad(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + p := NewLinuxKitPublisher() + + t.Run("fails when config file not found", func(t *testing.T) { + release := &Release{ + Version: "v1.0.0", + ProjectDir: "/nonexistent", + } + pubCfg := PublisherConfig{ + Type: "linuxkit", + Extended: map[string]any{ + "config": "/nonexistent/config.yml", + }, + } + relCfg := &mockReleaseConfig{repository: "owner/repo"} + + // Note: This test requires linuxkit to NOT be installed + // or uses a non-existent config path + err := p.Publish(nil, release, pubCfg, relCfg, false) + assert.Error(t, err) + }) +} + +// mockReleaseConfig implements ReleaseConfig for testing. +type mockReleaseConfig struct { + repository string + projectName string +} + +func (m *mockReleaseConfig) GetRepository() string { + return m.repository +} + +func (m *mockReleaseConfig) GetProjectName() string { + return m.projectName +} diff --git a/pkg/release/publishers/publisher.go b/pkg/release/publishers/publisher.go index fe3e6fd0..f91de234 100644 --- a/pkg/release/publishers/publisher.go +++ b/pkg/release/publishers/publisher.go @@ -21,12 +21,14 @@ type Release struct { // PublisherConfig holds configuration for a publisher. type PublisherConfig struct { - // Type is the publisher type (e.g., "github"). + // Type is the publisher type (e.g., "github", "linuxkit", "docker"). Type string // Prerelease marks the release as a prerelease. Prerelease bool // Draft creates the release as a draft. Draft bool + // Extended holds publisher-specific configuration. + Extended any } // ReleaseConfig holds release configuration needed by publishers. @@ -56,10 +58,11 @@ func NewRelease(version string, artifacts []build.Artifact, changelog, projectDi } // NewPublisherConfig creates a PublisherConfig. -func NewPublisherConfig(pubType string, prerelease, draft bool) PublisherConfig { +func NewPublisherConfig(pubType string, prerelease, draft bool, extended any) PublisherConfig { return PublisherConfig{ Type: pubType, Prerelease: prerelease, Draft: draft, + Extended: extended, } } diff --git a/pkg/release/release.go b/pkg/release/release.go index 0f0bca47..880d725f 100644 --- a/pkg/release/release.go +++ b/pkg/release/release.go @@ -84,7 +84,9 @@ func Run(ctx context.Context, cfg *Config, dryRun bool) (*Release, error) { return release, fmt.Errorf("release.Run: %w", err) } - publisherCfg := publishers.NewPublisherConfig(pubCfg.Type, pubCfg.Prerelease, pubCfg.Draft) + // Build extended config for publisher-specific settings + extendedCfg := buildExtendedConfig(pubCfg) + publisherCfg := publishers.NewPublisherConfig(pubCfg.Type, pubCfg.Prerelease, pubCfg.Draft, extendedCfg) if err := publisher.Publish(ctx, pubRelease, publisherCfg, cfg, dryRun); err != nil { return release, fmt.Errorf("release.Run: publish to %s failed: %w", pubCfg.Type, err) } @@ -210,7 +212,59 @@ func getPublisher(pubType string) (publishers.Publisher, error) { switch pubType { case "github": return publishers.NewGitHubPublisher(), nil + case "linuxkit": + return publishers.NewLinuxKitPublisher(), nil + case "docker": + return publishers.NewDockerPublisher(), nil default: return nil, fmt.Errorf("unsupported publisher type: %s", pubType) } } + +// buildExtendedConfig builds a map of extended configuration for a publisher. +func buildExtendedConfig(pubCfg PublisherConfig) map[string]any { + ext := make(map[string]any) + + // LinuxKit-specific config + if pubCfg.Config != "" { + ext["config"] = pubCfg.Config + } + if len(pubCfg.Formats) > 0 { + ext["formats"] = toAnySlice(pubCfg.Formats) + } + if len(pubCfg.Platforms) > 0 { + ext["platforms"] = toAnySlice(pubCfg.Platforms) + } + + // Docker-specific config + if pubCfg.Registry != "" { + ext["registry"] = pubCfg.Registry + } + if pubCfg.Image != "" { + ext["image"] = pubCfg.Image + } + if pubCfg.Dockerfile != "" { + ext["dockerfile"] = pubCfg.Dockerfile + } + if len(pubCfg.Tags) > 0 { + ext["tags"] = toAnySlice(pubCfg.Tags) + } + if len(pubCfg.BuildArgs) > 0 { + args := make(map[string]any) + for k, v := range pubCfg.BuildArgs { + args[k] = v + } + ext["build_args"] = args + } + + return ext +} + +// toAnySlice converts a string slice to an any slice. +func toAnySlice(s []string) []any { + result := make([]any, len(s)) + for i, v := range s { + result[i] = v + } + return result +}