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 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-01-28 18:42:04 +00:00
parent 0f072ad353
commit d25a86feca
8 changed files with 1134 additions and 5 deletions

View file

@ -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.

View file

@ -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
}

View file

@ -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")
})
}

View file

@ -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)
})
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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,
}
}

View file

@ -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
}