feat(release): add package manager publishers for S3.2

Add publishers for distributing CLI binaries to package managers:
- npm: binary wrapper pattern with postinstall download
- Homebrew: formula generation + tap auto-commit
- Scoop: JSON manifest + bucket auto-commit
- AUR: PKGBUILD + .SRCINFO + AUR push
- Chocolatey: NuSpec + install script + optional push

Each publisher supports:
- Dry-run mode for previewing changes
- Auto-commit to own repos (tap/bucket/AUR)
- Generate files for PRs to official repos via `official` config

Also includes Docker and LinuxKit build helpers.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-01-29 00:32:04 +00:00
parent 20f25ca062
commit 513a241c1b
21 changed files with 2678 additions and 11 deletions

View file

@ -51,8 +51,13 @@ var guiTemplate embed.FS
func AddBuildCommand(app *clir.Cli) {
buildCmd := app.NewSubCommand("build", "Build projects with auto-detection and cross-compilation")
buildCmd.LongDescription("Builds the current project with automatic type detection.\n" +
"Supports Go, Wails, Node.js, and PHP projects.\n" +
"Configuration can be provided via .core/build.yaml or command-line flags.")
"Supports Go, Wails, Docker, LinuxKit, and Taskfile projects.\n" +
"Configuration can be provided via .core/build.yaml or command-line flags.\n\n" +
"Examples:\n" +
" core build # Auto-detect and build\n" +
" core build --type docker # Build Docker image\n" +
" core build --type linuxkit # Build LinuxKit image\n" +
" core build --type linuxkit --config linuxkit.yml --format qcow2-bios")
// Flags for the main build command
var buildType string
@ -62,20 +67,32 @@ func AddBuildCommand(app *clir.Cli) {
var doArchive bool
var doChecksum bool
buildCmd.StringFlag("type", "Builder type (go, wails, node, php) - auto-detected if not specified", &buildType)
// Docker/LinuxKit specific flags
var configPath string
var format string
var push bool
var imageName string
buildCmd.StringFlag("type", "Builder type (go, wails, docker, linuxkit, taskfile) - auto-detected if not specified", &buildType)
buildCmd.BoolFlag("ci", "CI mode - minimal output with JSON artifact list at the end", &ciMode)
buildCmd.StringFlag("targets", "Comma-separated OS/arch pairs (e.g., linux/amd64,darwin/arm64)", &targets)
buildCmd.StringFlag("output", "Output directory for artifacts (default: dist)", &outputDir)
buildCmd.BoolFlag("archive", "Create archives (tar.gz for linux/darwin, zip for windows) - default: true", &doArchive)
buildCmd.BoolFlag("checksum", "Generate SHA256 checksums and CHECKSUMS.txt - default: true", &doChecksum)
// Docker/LinuxKit specific
buildCmd.StringFlag("config", "Config file path (for linuxkit: YAML config, for docker: Dockerfile)", &configPath)
buildCmd.StringFlag("format", "Output format for linuxkit (iso-bios, qcow2-bios, raw, vmdk)", &format)
buildCmd.BoolFlag("push", "Push Docker image after build (default: false)", &push)
buildCmd.StringFlag("image", "Docker image name (e.g., host-uk/core-devops)", &imageName)
// Set defaults for archive and checksum (true by default)
doArchive = true
doChecksum = true
// Default action for `core build` (no subcommand)
buildCmd.Action(func() error {
return runProjectBuild(buildType, ciMode, targets, outputDir, doArchive, doChecksum)
return runProjectBuild(buildType, ciMode, targets, outputDir, doArchive, doChecksum, configPath, format, push, imageName)
})
// --- `build from-path` command (legacy PWA/GUI build) ---
@ -102,7 +119,7 @@ func AddBuildCommand(app *clir.Cli) {
}
// runProjectBuild handles the main `core build` command with auto-detection.
func runProjectBuild(buildType string, ciMode bool, targetsFlag string, outputDir string, doArchive bool, doChecksum bool) error {
func runProjectBuild(buildType string, ciMode bool, targetsFlag string, outputDir string, doArchive bool, doChecksum bool, configPath string, format string, push bool, imageName string) error {
// Get current working directory as project root
projectDir, err := os.Getwd()
if err != nil {
@ -185,6 +202,16 @@ func runProjectBuild(buildType string, ciMode bool, targetsFlag string, outputDi
Name: binaryName,
Version: buildCfg.Project.Name, // Could be enhanced with git describe
LDFlags: buildCfg.Build.LDFlags,
// Docker/LinuxKit specific
Dockerfile: configPath, // Reuse for Dockerfile path
LinuxKitConfig: configPath,
Push: push,
Image: imageName,
}
// Parse formats for LinuxKit
if format != "" {
cfg.Formats = strings.Split(format, ",")
}
// Execute build
@ -407,6 +434,12 @@ func getBuilder(projectType build.ProjectType) (build.Builder, error) {
return builders.NewWailsBuilder(), nil
case build.ProjectTypeGo:
return builders.NewGoBuilder(), nil
case build.ProjectTypeDocker:
return builders.NewDockerBuilder(), nil
case build.ProjectTypeLinuxKit:
return builders.NewLinuxKitBuilder(), nil
case build.ProjectTypeTaskfile:
return builders.NewTaskfileBuilder(), nil
case build.ProjectTypeNode:
return nil, fmt.Errorf("Node.js builder not yet implemented")
case build.ProjectTypePHP:
@ -631,8 +664,8 @@ func runBuild(fromPath string) error {
return fmt.Errorf("failed to anchor template filesystem: %w", err)
}
sod := gosod.New(templateFS)
if sod != nil {
return fmt.Errorf("failed to create new sod instance: %w", sod)
if sod == nil {
return fmt.Errorf("failed to create new sod instance")
}
templateData := map[string]string{"AppName": appName}

View file

@ -11,10 +11,13 @@ import (
type ProjectType string
const (
ProjectTypeGo ProjectType = "go"
ProjectTypeWails ProjectType = "wails"
ProjectTypeNode ProjectType = "node"
ProjectTypePHP ProjectType = "php"
ProjectTypeGo ProjectType = "go"
ProjectTypeWails ProjectType = "wails"
ProjectTypeNode ProjectType = "node"
ProjectTypePHP ProjectType = "php"
ProjectTypeDocker ProjectType = "docker"
ProjectTypeLinuxKit ProjectType = "linuxkit"
ProjectTypeTaskfile ProjectType = "taskfile"
)
// Target represents a build target platform.
@ -48,6 +51,18 @@ type Config struct {
Version string
// LDFlags are additional linker flags.
LDFlags []string
// Docker-specific config
Dockerfile string // Path to Dockerfile (default: Dockerfile)
Registry string // Container registry (default: ghcr.io)
Image string // Image name (owner/repo format)
Tags []string // Additional tags to apply
BuildArgs map[string]string // Docker build arguments
Push bool // Whether to push after build
// LinuxKit-specific config
LinuxKitConfig string // Path to LinuxKit YAML config
Formats []string // Output formats (iso, qcow2, raw, vmdk)
}
// Builder defines the interface for project-specific build implementations.

View file

@ -0,0 +1,214 @@
// Package builders provides build implementations for different project types.
package builders
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/host-uk/core/pkg/build"
)
// DockerBuilder builds Docker images.
type DockerBuilder struct{}
// NewDockerBuilder creates a new Docker builder.
func NewDockerBuilder() *DockerBuilder {
return &DockerBuilder{}
}
// Name returns the builder's identifier.
func (b *DockerBuilder) Name() string {
return "docker"
}
// Detect checks if a Dockerfile exists in the directory.
func (b *DockerBuilder) Detect(dir string) (bool, error) {
dockerfilePath := filepath.Join(dir, "Dockerfile")
if _, err := os.Stat(dockerfilePath); err == nil {
return true, nil
}
return false, nil
}
// Build builds Docker images for the specified targets.
func (b *DockerBuilder) Build(ctx context.Context, cfg *build.Config, targets []build.Target) ([]build.Artifact, error) {
// Validate docker CLI is available
if err := b.validateDockerCli(); err != nil {
return nil, err
}
// Ensure buildx is available
if err := b.ensureBuildx(ctx); err != nil {
return nil, err
}
// Determine Dockerfile path
dockerfile := cfg.Dockerfile
if dockerfile == "" {
dockerfile = filepath.Join(cfg.ProjectDir, "Dockerfile")
}
// Validate Dockerfile exists
if _, err := os.Stat(dockerfile); err != nil {
return nil, fmt.Errorf("docker.Build: Dockerfile not found: %s", dockerfile)
}
// Determine image name
imageName := cfg.Image
if imageName == "" {
imageName = cfg.Name
}
if imageName == "" {
imageName = filepath.Base(cfg.ProjectDir)
}
// Build platform string from targets
var platforms []string
for _, t := range targets {
platforms = append(platforms, fmt.Sprintf("%s/%s", t.OS, t.Arch))
}
// If no targets specified, use current platform
if len(platforms) == 0 {
platforms = []string{"linux/amd64"}
}
// Determine registry
registry := cfg.Registry
if registry == "" {
registry = "ghcr.io"
}
// Determine tags
tags := cfg.Tags
if len(tags) == 0 {
tags = []string{"latest"}
if cfg.Version != "" {
tags = append(tags, cfg.Version)
}
}
// Build full image references
var imageRefs []string
for _, tag := range tags {
// Expand version template
expandedTag := strings.ReplaceAll(tag, "{{.Version}}", cfg.Version)
expandedTag = strings.ReplaceAll(expandedTag, "{{Version}}", cfg.Version)
if registry != "" {
imageRefs = append(imageRefs, fmt.Sprintf("%s/%s:%s", registry, imageName, expandedTag))
} else {
imageRefs = append(imageRefs, fmt.Sprintf("%s:%s", imageName, expandedTag))
}
}
// Build the docker buildx command
args := []string{"buildx", "build"}
// Multi-platform support
args = append(args, "--platform", strings.Join(platforms, ","))
// Add all tags
for _, ref := range imageRefs {
args = append(args, "-t", ref)
}
// Dockerfile path
args = append(args, "-f", dockerfile)
// Build arguments
for k, v := range cfg.BuildArgs {
expandedValue := strings.ReplaceAll(v, "{{.Version}}", cfg.Version)
expandedValue = strings.ReplaceAll(expandedValue, "{{Version}}", cfg.Version)
args = append(args, "--build-arg", fmt.Sprintf("%s=%s", k, expandedValue))
}
// Always add VERSION build arg if version is set
if cfg.Version != "" {
args = append(args, "--build-arg", fmt.Sprintf("VERSION=%s", cfg.Version))
}
// Output to local docker images or push
if cfg.Push {
args = append(args, "--push")
} else {
// For multi-platform builds without push, we need to load or output somewhere
if len(platforms) == 1 {
args = append(args, "--load")
} else {
// Multi-platform builds can't use --load, output to tarball
outputPath := filepath.Join(cfg.OutputDir, fmt.Sprintf("%s.tar", imageName))
args = append(args, "--output", fmt.Sprintf("type=oci,dest=%s", outputPath))
}
}
// Build context (project directory)
args = append(args, cfg.ProjectDir)
// Create output directory
if err := os.MkdirAll(cfg.OutputDir, 0755); err != nil {
return nil, fmt.Errorf("docker.Build: failed to create output directory: %w", err)
}
// Execute build
cmd := exec.CommandContext(ctx, "docker", args...)
cmd.Dir = cfg.ProjectDir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
fmt.Printf("Building Docker image: %s\n", imageName)
fmt.Printf(" Platforms: %s\n", strings.Join(platforms, ", "))
fmt.Printf(" Tags: %s\n", strings.Join(imageRefs, ", "))
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("docker.Build: buildx build failed: %w", err)
}
// Create artifacts for each platform
var artifacts []build.Artifact
for _, t := range targets {
artifacts = append(artifacts, build.Artifact{
Path: imageRefs[0], // Primary image reference
OS: t.OS,
Arch: t.Arch,
})
}
return artifacts, nil
}
// validateDockerCli checks if the docker CLI is available.
func (b *DockerBuilder) 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
}
// ensureBuildx ensures docker buildx is available and has a builder.
func (b *DockerBuilder) 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
}

View file

@ -0,0 +1,248 @@
// Package builders provides build implementations for different project types.
package builders
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/host-uk/core/pkg/build"
)
// LinuxKitBuilder builds LinuxKit images.
type LinuxKitBuilder struct{}
// NewLinuxKitBuilder creates a new LinuxKit builder.
func NewLinuxKitBuilder() *LinuxKitBuilder {
return &LinuxKitBuilder{}
}
// Name returns the builder's identifier.
func (b *LinuxKitBuilder) Name() string {
return "linuxkit"
}
// Detect checks if a linuxkit.yml or .yml config exists in the directory.
func (b *LinuxKitBuilder) Detect(dir string) (bool, error) {
// Check for linuxkit.yml
if _, err := os.Stat(filepath.Join(dir, "linuxkit.yml")); err == nil {
return true, nil
}
// Check for .core/linuxkit/*.yml
if matches, _ := filepath.Glob(filepath.Join(dir, ".core", "linuxkit", "*.yml")); len(matches) > 0 {
return true, nil
}
return false, nil
}
// Build builds LinuxKit images for the specified targets.
func (b *LinuxKitBuilder) Build(ctx context.Context, cfg *build.Config, targets []build.Target) ([]build.Artifact, error) {
// Validate linuxkit CLI is available
if err := b.validateLinuxKitCli(); err != nil {
return nil, err
}
// Determine config file path
configPath := cfg.LinuxKitConfig
if configPath == "" {
// Auto-detect
if _, err := os.Stat(filepath.Join(cfg.ProjectDir, "linuxkit.yml")); err == nil {
configPath = filepath.Join(cfg.ProjectDir, "linuxkit.yml")
} else {
// Look in .core/linuxkit/
matches, _ := filepath.Glob(filepath.Join(cfg.ProjectDir, ".core", "linuxkit", "*.yml"))
if len(matches) > 0 {
configPath = matches[0]
}
}
}
if configPath == "" {
return nil, fmt.Errorf("linuxkit.Build: no LinuxKit config file found. Specify with --config or create linuxkit.yml")
}
// Validate config file exists
if _, err := os.Stat(configPath); err != nil {
return nil, fmt.Errorf("linuxkit.Build: config file not found: %s", configPath)
}
// Determine output formats
formats := cfg.Formats
if len(formats) == 0 {
formats = []string{"qcow2-bios"} // Default to QEMU-compatible format
}
// Create output directory
outputDir := cfg.OutputDir
if outputDir == "" {
outputDir = filepath.Join(cfg.ProjectDir, "dist")
}
if err := os.MkdirAll(outputDir, 0755); err != nil {
return nil, fmt.Errorf("linuxkit.Build: failed to create output directory: %w", err)
}
// Determine base name from config file or project name
baseName := cfg.Name
if baseName == "" {
baseName = strings.TrimSuffix(filepath.Base(configPath), ".yml")
}
// If no targets, default to linux/amd64
if len(targets) == 0 {
targets = []build.Target{{OS: "linux", Arch: "amd64"}}
}
var artifacts []build.Artifact
// Build for each target and format
for _, target := range targets {
// LinuxKit only supports Linux
if target.OS != "linux" {
fmt.Printf("Skipping %s/%s (LinuxKit only supports Linux)\n", target.OS, target.Arch)
continue
}
for _, format := range formats {
outputName := fmt.Sprintf("%s-%s", baseName, target.Arch)
args := b.buildLinuxKitArgs(configPath, format, outputName, outputDir, target.Arch)
cmd := exec.CommandContext(ctx, "linuxkit", args...)
cmd.Dir = cfg.ProjectDir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
fmt.Printf("Building LinuxKit image: %s (%s, %s)\n", outputName, format, target.Arch)
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("linuxkit.Build: build failed for %s/%s: %w", target.Arch, format, err)
}
// Determine the actual output file path
artifactPath := b.getArtifactPath(outputDir, outputName, format)
// Verify the artifact was created
if _, err := os.Stat(artifactPath); err != nil {
// Try alternate naming conventions
artifactPath = b.findArtifact(outputDir, outputName, format)
if artifactPath == "" {
return nil, fmt.Errorf("linuxkit.Build: artifact not found after build: expected %s", b.getArtifactPath(outputDir, outputName, format))
}
}
artifacts = append(artifacts, build.Artifact{
Path: artifactPath,
OS: target.OS,
Arch: target.Arch,
})
}
}
return artifacts, nil
}
// buildLinuxKitArgs builds the arguments for linuxkit build command.
func (b *LinuxKitBuilder) 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 (b *LinuxKitBuilder) getArtifactPath(outputDir, outputName, format string) string {
ext := b.getFormatExtension(format)
return filepath.Join(outputDir, outputName+ext)
}
// findArtifact searches for the built artifact with various naming conventions.
func (b *LinuxKitBuilder) findArtifact(outputDir, outputName, format string) string {
// LinuxKit can create files with different suffixes
extensions := []string{
b.getFormatExtension(format),
"-bios" + b.getFormatExtension(format),
"-efi" + b.getFormatExtension(format),
}
for _, ext := range extensions {
path := filepath.Join(outputDir, outputName+ext)
if _, err := os.Stat(path); err == nil {
return path
}
}
// Try to find any file matching the output name
matches, _ := filepath.Glob(filepath.Join(outputDir, outputName+"*"))
for _, match := range matches {
// Return first match that looks like an image
ext := filepath.Ext(match)
if ext == ".iso" || ext == ".qcow2" || ext == ".raw" || ext == ".vmdk" || ext == ".vhd" {
return match
}
}
return ""
}
// getFormatExtension returns the file extension for a LinuxKit output format.
func (b *LinuxKitBuilder) getFormatExtension(format string) string {
switch format {
case "iso", "iso-bios", "iso-efi":
return ".iso"
case "raw", "raw-bios", "raw-efi":
return ".raw"
case "qcow2", "qcow2-bios", "qcow2-efi":
return ".qcow2"
case "vmdk":
return ".vmdk"
case "vhd":
return ".vhd"
case "gcp":
return ".img.tar.gz"
case "aws":
return ".raw"
default:
return "." + strings.TrimSuffix(format, "-bios")
}
}
// validateLinuxKitCli checks if the linuxkit CLI is available.
func (b *LinuxKitBuilder) validateLinuxKitCli() error {
// Check PATH first
if _, err := exec.LookPath("linuxkit"); err == nil {
return nil
}
// Check common locations
paths := []string{
"/usr/local/bin/linuxkit",
"/opt/homebrew/bin/linuxkit",
}
for _, p := range paths {
if _, err := os.Stat(p); err == nil {
return nil
}
}
return fmt.Errorf("linuxkit: linuxkit CLI not found. Install with: brew install linuxkit (macOS) or see https://github.com/linuxkit/linuxkit")
}

View file

@ -0,0 +1,233 @@
// Package builders provides build implementations for different project types.
package builders
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/host-uk/core/pkg/build"
)
// TaskfileBuilder builds projects using Taskfile (https://taskfile.dev/).
// This is a generic builder that can handle any project type that has a Taskfile.
type TaskfileBuilder struct{}
// NewTaskfileBuilder creates a new Taskfile builder.
func NewTaskfileBuilder() *TaskfileBuilder {
return &TaskfileBuilder{}
}
// Name returns the builder's identifier.
func (b *TaskfileBuilder) Name() string {
return "taskfile"
}
// Detect checks if a Taskfile exists in the directory.
func (b *TaskfileBuilder) Detect(dir string) (bool, error) {
// Check for Taskfile.yml, Taskfile.yaml, or Taskfile
taskfiles := []string{
"Taskfile.yml",
"Taskfile.yaml",
"Taskfile",
"taskfile.yml",
"taskfile.yaml",
}
for _, tf := range taskfiles {
if _, err := os.Stat(filepath.Join(dir, tf)); err == nil {
return true, nil
}
}
return false, nil
}
// Build runs the Taskfile build task for each target platform.
func (b *TaskfileBuilder) Build(ctx context.Context, cfg *build.Config, targets []build.Target) ([]build.Artifact, error) {
// Validate task CLI is available
if err := b.validateTaskCli(); err != nil {
return nil, err
}
// Create output directory
outputDir := cfg.OutputDir
if outputDir == "" {
outputDir = filepath.Join(cfg.ProjectDir, "dist")
}
if err := os.MkdirAll(outputDir, 0755); err != nil {
return nil, fmt.Errorf("taskfile.Build: failed to create output directory: %w", err)
}
var artifacts []build.Artifact
// If no targets specified, just run the build task once
if len(targets) == 0 {
if err := b.runTask(ctx, cfg, "", ""); err != nil {
return nil, err
}
// Try to find artifacts in output directory
found := b.findArtifacts(outputDir)
artifacts = append(artifacts, found...)
} else {
// Run build task for each target
for _, target := range targets {
if err := b.runTask(ctx, cfg, target.OS, target.Arch); err != nil {
return nil, err
}
// Try to find artifacts for this target
found := b.findArtifactsForTarget(outputDir, target)
artifacts = append(artifacts, found...)
}
}
return artifacts, nil
}
// runTask executes the Taskfile build task.
func (b *TaskfileBuilder) runTask(ctx context.Context, cfg *build.Config, goos, goarch string) error {
// Build task command
args := []string{"build"}
// Pass variables if targets are specified
if goos != "" {
args = append(args, fmt.Sprintf("GOOS=%s", goos))
}
if goarch != "" {
args = append(args, fmt.Sprintf("GOARCH=%s", goarch))
}
if cfg.OutputDir != "" {
args = append(args, fmt.Sprintf("OUTPUT_DIR=%s", cfg.OutputDir))
}
if cfg.Name != "" {
args = append(args, fmt.Sprintf("NAME=%s", cfg.Name))
}
if cfg.Version != "" {
args = append(args, fmt.Sprintf("VERSION=%s", cfg.Version))
}
cmd := exec.CommandContext(ctx, "task", args...)
cmd.Dir = cfg.ProjectDir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
// Set environment variables
cmd.Env = os.Environ()
if goos != "" {
cmd.Env = append(cmd.Env, fmt.Sprintf("GOOS=%s", goos))
}
if goarch != "" {
cmd.Env = append(cmd.Env, fmt.Sprintf("GOARCH=%s", goarch))
}
if cfg.OutputDir != "" {
cmd.Env = append(cmd.Env, fmt.Sprintf("OUTPUT_DIR=%s", cfg.OutputDir))
}
if cfg.Name != "" {
cmd.Env = append(cmd.Env, fmt.Sprintf("NAME=%s", cfg.Name))
}
if cfg.Version != "" {
cmd.Env = append(cmd.Env, fmt.Sprintf("VERSION=%s", cfg.Version))
}
if goos != "" && goarch != "" {
fmt.Printf("Running task build for %s/%s\n", goos, goarch)
} else {
fmt.Println("Running task build")
}
if err := cmd.Run(); err != nil {
return fmt.Errorf("taskfile.Build: task build failed: %w", err)
}
return nil
}
// findArtifacts searches for built artifacts in the output directory.
func (b *TaskfileBuilder) findArtifacts(outputDir string) []build.Artifact {
var artifacts []build.Artifact
entries, err := os.ReadDir(outputDir)
if err != nil {
return artifacts
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
// Skip common non-artifact files
name := entry.Name()
if strings.HasPrefix(name, ".") || name == "CHECKSUMS.txt" {
continue
}
artifacts = append(artifacts, build.Artifact{
Path: filepath.Join(outputDir, name),
OS: "",
Arch: "",
})
}
return artifacts
}
// findArtifactsForTarget searches for built artifacts for a specific target.
func (b *TaskfileBuilder) findArtifactsForTarget(outputDir string, target build.Target) []build.Artifact {
var artifacts []build.Artifact
// Look for files matching the target pattern
patterns := []string{
fmt.Sprintf("*-%s-%s*", target.OS, target.Arch),
fmt.Sprintf("*_%s_%s*", target.OS, target.Arch),
fmt.Sprintf("*-%s*", target.Arch),
}
for _, pattern := range patterns {
matches, _ := filepath.Glob(filepath.Join(outputDir, pattern))
for _, match := range matches {
info, err := os.Stat(match)
if err != nil || info.IsDir() {
continue
}
artifacts = append(artifacts, build.Artifact{
Path: match,
OS: target.OS,
Arch: target.Arch,
})
}
if len(artifacts) > 0 {
break // Found matches, stop looking
}
}
return artifacts
}
// validateTaskCli checks if the task CLI is available.
func (b *TaskfileBuilder) validateTaskCli() error {
// Check PATH first
if _, err := exec.LookPath("task"); err == nil {
return nil
}
// Check common locations
paths := []string{
"/usr/local/bin/task",
"/opt/homebrew/bin/task",
}
for _, p := range paths {
if _, err := os.Stat(p); err == nil {
return nil
}
}
return fmt.Errorf("taskfile: task CLI not found. Install with: brew install go-task (macOS), go install github.com/go-task/task/v3/cmd/task@latest, or see https://taskfile.dev/installation/")
}

View file

@ -83,6 +83,42 @@ type PublisherConfig struct {
Tags []string `yaml:"tags,omitempty"`
// BuildArgs are additional Docker build arguments.
BuildArgs map[string]string `yaml:"build_args,omitempty"`
// npm-specific configuration
// Package is the npm package name (e.g., "@host-uk/core").
Package string `yaml:"package,omitempty"`
// Access is the npm access level: "public" or "restricted".
Access string `yaml:"access,omitempty"`
// Homebrew-specific configuration
// Tap is the Homebrew tap repository (e.g., "host-uk/homebrew-tap").
Tap string `yaml:"tap,omitempty"`
// Formula is the formula name (defaults to project name).
Formula string `yaml:"formula,omitempty"`
// Scoop-specific configuration
// Bucket is the Scoop bucket repository (e.g., "host-uk/scoop-bucket").
Bucket string `yaml:"bucket,omitempty"`
// AUR-specific configuration
// Maintainer is the AUR package maintainer (e.g., "Name <email>").
Maintainer string `yaml:"maintainer,omitempty"`
// Chocolatey-specific configuration
// Push determines whether to push to Chocolatey (false = generate only).
Push bool `yaml:"push,omitempty"`
// Official repo configuration (for Homebrew, Scoop)
// When enabled, generates files for PR to official repos.
Official *OfficialConfig `yaml:"official,omitempty"`
}
// OfficialConfig holds configuration for generating files for official repo PRs.
type OfficialConfig struct {
// Enabled determines whether to generate files for official repos.
Enabled bool `yaml:"enabled"`
// Output is the directory to write generated files.
Output string `yaml:"output,omitempty"`
}
// ChangelogConfig holds changelog generation settings.

View file

@ -0,0 +1,297 @@
// Package publishers provides release publishing implementations.
package publishers
import (
"bytes"
"context"
"embed"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"text/template"
"github.com/host-uk/core/pkg/build"
)
//go:embed templates/aur/*.tmpl
var aurTemplates embed.FS
// AURConfig holds AUR-specific configuration.
type AURConfig struct {
// Package is the AUR package name.
Package string
// Maintainer is the package maintainer (e.g., "Name <email>").
Maintainer string
// Official config for generating files for official repo PRs.
Official *OfficialConfig
}
// AURPublisher publishes releases to AUR.
type AURPublisher struct{}
// NewAURPublisher creates a new AUR publisher.
func NewAURPublisher() *AURPublisher {
return &AURPublisher{}
}
// Name returns the publisher's identifier.
func (p *AURPublisher) Name() string {
return "aur"
}
// Publish publishes the release to AUR.
func (p *AURPublisher) Publish(ctx context.Context, release *Release, pubCfg PublisherConfig, relCfg ReleaseConfig, dryRun bool) error {
cfg := p.parseConfig(pubCfg, relCfg)
if cfg.Maintainer == "" {
return fmt.Errorf("aur.Publish: maintainer is required (set publish.aur.maintainer in config)")
}
repo := ""
if relCfg != nil {
repo = relCfg.GetRepository()
}
if repo == "" {
detectedRepo, err := detectRepository(release.ProjectDir)
if err != nil {
return fmt.Errorf("aur.Publish: could not determine repository: %w", err)
}
repo = detectedRepo
}
projectName := ""
if relCfg != nil {
projectName = relCfg.GetProjectName()
}
if projectName == "" {
parts := strings.Split(repo, "/")
projectName = parts[len(parts)-1]
}
packageName := cfg.Package
if packageName == "" {
packageName = projectName
}
version := strings.TrimPrefix(release.Version, "v")
checksums := buildChecksumMap(release.Artifacts)
data := aurTemplateData{
PackageName: packageName,
Description: fmt.Sprintf("%s CLI", projectName),
Repository: repo,
Version: version,
License: "MIT",
BinaryName: projectName,
Maintainer: cfg.Maintainer,
Checksums: checksums,
}
if dryRun {
return p.dryRunPublish(data, cfg)
}
return p.executePublish(ctx, release.ProjectDir, data, cfg)
}
type aurTemplateData struct {
PackageName string
Description string
Repository string
Version string
License string
BinaryName string
Maintainer string
Checksums ChecksumMap
}
func (p *AURPublisher) parseConfig(pubCfg PublisherConfig, relCfg ReleaseConfig) AURConfig {
cfg := AURConfig{}
if ext, ok := pubCfg.Extended.(map[string]any); ok {
if pkg, ok := ext["package"].(string); ok && pkg != "" {
cfg.Package = pkg
}
if maintainer, ok := ext["maintainer"].(string); ok && maintainer != "" {
cfg.Maintainer = maintainer
}
if official, ok := ext["official"].(map[string]any); ok {
cfg.Official = &OfficialConfig{}
if enabled, ok := official["enabled"].(bool); ok {
cfg.Official.Enabled = enabled
}
if output, ok := official["output"].(string); ok {
cfg.Official.Output = output
}
}
}
return cfg
}
func (p *AURPublisher) dryRunPublish(data aurTemplateData, cfg AURConfig) error {
fmt.Println()
fmt.Println("=== DRY RUN: AUR Publish ===")
fmt.Println()
fmt.Printf("Package: %s-bin\n", data.PackageName)
fmt.Printf("Version: %s\n", data.Version)
fmt.Printf("Maintainer: %s\n", data.Maintainer)
fmt.Printf("Repository: %s\n", data.Repository)
fmt.Println()
pkgbuild, err := p.renderTemplate("templates/aur/PKGBUILD.tmpl", data)
if err != nil {
return fmt.Errorf("aur.dryRunPublish: %w", err)
}
fmt.Println("Generated PKGBUILD:")
fmt.Println("---")
fmt.Println(pkgbuild)
fmt.Println("---")
fmt.Println()
srcinfo, err := p.renderTemplate("templates/aur/.SRCINFO.tmpl", data)
if err != nil {
return fmt.Errorf("aur.dryRunPublish: %w", err)
}
fmt.Println("Generated .SRCINFO:")
fmt.Println("---")
fmt.Println(srcinfo)
fmt.Println("---")
fmt.Println()
fmt.Printf("Would push to AUR: ssh://aur@aur.archlinux.org/%s-bin.git\n", data.PackageName)
fmt.Println()
fmt.Println("=== END DRY RUN ===")
return nil
}
func (p *AURPublisher) executePublish(ctx context.Context, projectDir string, data aurTemplateData, cfg AURConfig) error {
pkgbuild, err := p.renderTemplate("templates/aur/PKGBUILD.tmpl", data)
if err != nil {
return fmt.Errorf("aur.Publish: failed to render PKGBUILD: %w", err)
}
srcinfo, err := p.renderTemplate("templates/aur/.SRCINFO.tmpl", data)
if err != nil {
return fmt.Errorf("aur.Publish: failed to render .SRCINFO: %w", err)
}
// If official config is enabled, write to output directory
if cfg.Official != nil && cfg.Official.Enabled {
output := cfg.Official.Output
if output == "" {
output = filepath.Join(projectDir, "dist", "aur")
} else if !filepath.IsAbs(output) {
output = filepath.Join(projectDir, output)
}
if err := os.MkdirAll(output, 0755); err != nil {
return fmt.Errorf("aur.Publish: failed to create output directory: %w", err)
}
pkgbuildPath := filepath.Join(output, "PKGBUILD")
if err := os.WriteFile(pkgbuildPath, []byte(pkgbuild), 0644); err != nil {
return fmt.Errorf("aur.Publish: failed to write PKGBUILD: %w", err)
}
srcinfoPath := filepath.Join(output, ".SRCINFO")
if err := os.WriteFile(srcinfoPath, []byte(srcinfo), 0644); err != nil {
return fmt.Errorf("aur.Publish: failed to write .SRCINFO: %w", err)
}
fmt.Printf("Wrote AUR files: %s\n", output)
}
// Push to AUR if not in official-only mode
if cfg.Official == nil || !cfg.Official.Enabled {
if err := p.pushToAUR(ctx, data, pkgbuild, srcinfo); err != nil {
return err
}
}
return nil
}
func (p *AURPublisher) pushToAUR(ctx context.Context, data aurTemplateData, pkgbuild, srcinfo string) error {
aurURL := fmt.Sprintf("ssh://aur@aur.archlinux.org/%s-bin.git", data.PackageName)
tmpDir, err := os.MkdirTemp("", "aur-package-*")
if err != nil {
return fmt.Errorf("aur.Publish: failed to create temp directory: %w", err)
}
defer os.RemoveAll(tmpDir)
// Clone existing AUR repo (or initialize new one)
fmt.Printf("Cloning AUR package %s-bin...\n", data.PackageName)
cmd := exec.CommandContext(ctx, "git", "clone", aurURL, tmpDir)
if err := cmd.Run(); err != nil {
// If clone fails, init a new repo
cmd = exec.CommandContext(ctx, "git", "init", tmpDir)
if err := cmd.Run(); err != nil {
return fmt.Errorf("aur.Publish: failed to initialize repo: %w", err)
}
cmd = exec.CommandContext(ctx, "git", "-C", tmpDir, "remote", "add", "origin", aurURL)
if err := cmd.Run(); err != nil {
return fmt.Errorf("aur.Publish: failed to add remote: %w", err)
}
}
// Write files
if err := os.WriteFile(filepath.Join(tmpDir, "PKGBUILD"), []byte(pkgbuild), 0644); err != nil {
return fmt.Errorf("aur.Publish: failed to write PKGBUILD: %w", err)
}
if err := os.WriteFile(filepath.Join(tmpDir, ".SRCINFO"), []byte(srcinfo), 0644); err != nil {
return fmt.Errorf("aur.Publish: failed to write .SRCINFO: %w", err)
}
commitMsg := fmt.Sprintf("Update to %s", data.Version)
cmd = exec.CommandContext(ctx, "git", "add", ".")
cmd.Dir = tmpDir
if err := cmd.Run(); err != nil {
return fmt.Errorf("aur.Publish: git add failed: %w", err)
}
cmd = exec.CommandContext(ctx, "git", "commit", "-m", commitMsg)
cmd.Dir = tmpDir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("aur.Publish: git commit failed: %w", err)
}
cmd = exec.CommandContext(ctx, "git", "push", "origin", "master")
cmd.Dir = tmpDir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("aur.Publish: git push failed: %w", err)
}
fmt.Printf("Published to AUR: https://aur.archlinux.org/packages/%s-bin\n", data.PackageName)
return nil
}
func (p *AURPublisher) renderTemplate(name string, data aurTemplateData) (string, error) {
content, err := aurTemplates.ReadFile(name)
if err != nil {
return "", fmt.Errorf("failed to read template %s: %w", name, err)
}
tmpl, err := template.New(filepath.Base(name)).Parse(string(content))
if err != nil {
return "", fmt.Errorf("failed to parse template %s: %w", name, err)
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return "", fmt.Errorf("failed to execute template %s: %w", name, err)
}
return buf.String(), nil
}
// Ensure build package is used
var _ = build.Artifact{}

View file

@ -0,0 +1,277 @@
// Package publishers provides release publishing implementations.
package publishers
import (
"bytes"
"context"
"embed"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"text/template"
"github.com/host-uk/core/pkg/build"
)
//go:embed templates/chocolatey/*.tmpl templates/chocolatey/tools/*.tmpl
var chocolateyTemplates embed.FS
// ChocolateyConfig holds Chocolatey-specific configuration.
type ChocolateyConfig struct {
// Package is the Chocolatey package name.
Package string
// Push determines whether to push to Chocolatey (false = generate only).
Push bool
// Official config for generating files for official repo PRs.
Official *OfficialConfig
}
// ChocolateyPublisher publishes releases to Chocolatey.
type ChocolateyPublisher struct{}
// NewChocolateyPublisher creates a new Chocolatey publisher.
func NewChocolateyPublisher() *ChocolateyPublisher {
return &ChocolateyPublisher{}
}
// Name returns the publisher's identifier.
func (p *ChocolateyPublisher) Name() string {
return "chocolatey"
}
// Publish publishes the release to Chocolatey.
func (p *ChocolateyPublisher) Publish(ctx context.Context, release *Release, pubCfg PublisherConfig, relCfg ReleaseConfig, dryRun bool) error {
cfg := p.parseConfig(pubCfg, relCfg)
repo := ""
if relCfg != nil {
repo = relCfg.GetRepository()
}
if repo == "" {
detectedRepo, err := detectRepository(release.ProjectDir)
if err != nil {
return fmt.Errorf("chocolatey.Publish: could not determine repository: %w", err)
}
repo = detectedRepo
}
projectName := ""
if relCfg != nil {
projectName = relCfg.GetProjectName()
}
if projectName == "" {
parts := strings.Split(repo, "/")
projectName = parts[len(parts)-1]
}
packageName := cfg.Package
if packageName == "" {
packageName = projectName
}
version := strings.TrimPrefix(release.Version, "v")
checksums := buildChecksumMap(release.Artifacts)
// Extract authors from repository
authors := strings.Split(repo, "/")[0]
data := chocolateyTemplateData{
PackageName: packageName,
Title: fmt.Sprintf("%s CLI", strings.Title(projectName)),
Description: fmt.Sprintf("%s CLI", projectName),
Repository: repo,
Version: version,
License: "MIT",
BinaryName: projectName,
Authors: authors,
Tags: fmt.Sprintf("cli %s", projectName),
Checksums: checksums,
}
if dryRun {
return p.dryRunPublish(data, cfg)
}
return p.executePublish(ctx, release.ProjectDir, data, cfg)
}
type chocolateyTemplateData struct {
PackageName string
Title string
Description string
Repository string
Version string
License string
BinaryName string
Authors string
Tags string
Checksums ChecksumMap
}
func (p *ChocolateyPublisher) parseConfig(pubCfg PublisherConfig, relCfg ReleaseConfig) ChocolateyConfig {
cfg := ChocolateyConfig{
Push: false, // Default to generate only
}
if ext, ok := pubCfg.Extended.(map[string]any); ok {
if pkg, ok := ext["package"].(string); ok && pkg != "" {
cfg.Package = pkg
}
if push, ok := ext["push"].(bool); ok {
cfg.Push = push
}
if official, ok := ext["official"].(map[string]any); ok {
cfg.Official = &OfficialConfig{}
if enabled, ok := official["enabled"].(bool); ok {
cfg.Official.Enabled = enabled
}
if output, ok := official["output"].(string); ok {
cfg.Official.Output = output
}
}
}
return cfg
}
func (p *ChocolateyPublisher) dryRunPublish(data chocolateyTemplateData, cfg ChocolateyConfig) error {
fmt.Println()
fmt.Println("=== DRY RUN: Chocolatey Publish ===")
fmt.Println()
fmt.Printf("Package: %s\n", data.PackageName)
fmt.Printf("Version: %s\n", data.Version)
fmt.Printf("Push: %t\n", cfg.Push)
fmt.Printf("Repository: %s\n", data.Repository)
fmt.Println()
nuspec, err := p.renderTemplate("templates/chocolatey/package.nuspec.tmpl", data)
if err != nil {
return fmt.Errorf("chocolatey.dryRunPublish: %w", err)
}
fmt.Println("Generated package.nuspec:")
fmt.Println("---")
fmt.Println(nuspec)
fmt.Println("---")
fmt.Println()
install, err := p.renderTemplate("templates/chocolatey/tools/chocolateyinstall.ps1.tmpl", data)
if err != nil {
return fmt.Errorf("chocolatey.dryRunPublish: %w", err)
}
fmt.Println("Generated chocolateyinstall.ps1:")
fmt.Println("---")
fmt.Println(install)
fmt.Println("---")
fmt.Println()
if cfg.Push {
fmt.Println("Would push to Chocolatey community repo")
} else {
fmt.Println("Would generate package files only (push=false)")
}
fmt.Println()
fmt.Println("=== END DRY RUN ===")
return nil
}
func (p *ChocolateyPublisher) executePublish(ctx context.Context, projectDir string, data chocolateyTemplateData, cfg ChocolateyConfig) error {
nuspec, err := p.renderTemplate("templates/chocolatey/package.nuspec.tmpl", data)
if err != nil {
return fmt.Errorf("chocolatey.Publish: failed to render nuspec: %w", err)
}
install, err := p.renderTemplate("templates/chocolatey/tools/chocolateyinstall.ps1.tmpl", data)
if err != nil {
return fmt.Errorf("chocolatey.Publish: failed to render install script: %w", err)
}
// Create package directory
output := filepath.Join(projectDir, "dist", "chocolatey")
if cfg.Official != nil && cfg.Official.Enabled && cfg.Official.Output != "" {
output = cfg.Official.Output
if !filepath.IsAbs(output) {
output = filepath.Join(projectDir, output)
}
}
toolsDir := filepath.Join(output, "tools")
if err := os.MkdirAll(toolsDir, 0755); err != nil {
return fmt.Errorf("chocolatey.Publish: failed to create output directory: %w", err)
}
// Write files
nuspecPath := filepath.Join(output, fmt.Sprintf("%s.nuspec", data.PackageName))
if err := os.WriteFile(nuspecPath, []byte(nuspec), 0644); err != nil {
return fmt.Errorf("chocolatey.Publish: failed to write nuspec: %w", err)
}
installPath := filepath.Join(toolsDir, "chocolateyinstall.ps1")
if err := os.WriteFile(installPath, []byte(install), 0644); err != nil {
return fmt.Errorf("chocolatey.Publish: failed to write install script: %w", err)
}
fmt.Printf("Wrote Chocolatey package files: %s\n", output)
// Push to Chocolatey if configured
if cfg.Push {
if err := p.pushToChocolatey(ctx, output, data); err != nil {
return err
}
}
return nil
}
func (p *ChocolateyPublisher) pushToChocolatey(ctx context.Context, packageDir string, data chocolateyTemplateData) error {
// Check for CHOCOLATEY_API_KEY
apiKey := os.Getenv("CHOCOLATEY_API_KEY")
if apiKey == "" {
return fmt.Errorf("chocolatey.Publish: CHOCOLATEY_API_KEY environment variable is required for push")
}
// Pack the package
nupkgPath := filepath.Join(packageDir, fmt.Sprintf("%s.%s.nupkg", data.PackageName, data.Version))
cmd := exec.CommandContext(ctx, "choco", "pack", filepath.Join(packageDir, fmt.Sprintf("%s.nuspec", data.PackageName)), "-OutputDirectory", packageDir)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("chocolatey.Publish: choco pack failed: %w", err)
}
// Push the package
cmd = exec.CommandContext(ctx, "choco", "push", nupkgPath, "--source", "https://push.chocolatey.org/", "--api-key", apiKey)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("chocolatey.Publish: choco push failed: %w", err)
}
fmt.Printf("Published to Chocolatey: https://community.chocolatey.org/packages/%s\n", data.PackageName)
return nil
}
func (p *ChocolateyPublisher) renderTemplate(name string, data chocolateyTemplateData) (string, error) {
content, err := chocolateyTemplates.ReadFile(name)
if err != nil {
return "", fmt.Errorf("failed to read template %s: %w", name, err)
}
tmpl, err := template.New(filepath.Base(name)).Parse(string(content))
if err != nil {
return "", fmt.Errorf("failed to parse template %s: %w", name, err)
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return "", fmt.Errorf("failed to execute template %s: %w", name, err)
}
return buf.String(), nil
}
// Ensure build package is used
var _ = build.Artifact{}

View file

@ -0,0 +1,355 @@
// Package publishers provides release publishing implementations.
package publishers
import (
"bytes"
"context"
"embed"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"text/template"
"github.com/host-uk/core/pkg/build"
)
//go:embed templates/homebrew/*.tmpl
var homebrewTemplates embed.FS
// HomebrewConfig holds Homebrew-specific configuration.
type HomebrewConfig struct {
// Tap is the Homebrew tap repository (e.g., "host-uk/homebrew-tap").
Tap string
// Formula is the formula name (defaults to project name).
Formula string
// Official config for generating files for official repo PRs.
Official *OfficialConfig
}
// OfficialConfig holds configuration for generating files for official repo PRs.
type OfficialConfig struct {
// Enabled determines whether to generate files for official repos.
Enabled bool
// Output is the directory to write generated files.
Output string
}
// HomebrewPublisher publishes releases to Homebrew.
type HomebrewPublisher struct{}
// NewHomebrewPublisher creates a new Homebrew publisher.
func NewHomebrewPublisher() *HomebrewPublisher {
return &HomebrewPublisher{}
}
// Name returns the publisher's identifier.
func (p *HomebrewPublisher) Name() string {
return "homebrew"
}
// Publish publishes the release to Homebrew.
func (p *HomebrewPublisher) Publish(ctx context.Context, release *Release, pubCfg PublisherConfig, relCfg ReleaseConfig, dryRun bool) error {
// Parse config
cfg := p.parseConfig(pubCfg, relCfg)
// Validate configuration
if cfg.Tap == "" && (cfg.Official == nil || !cfg.Official.Enabled) {
return fmt.Errorf("homebrew.Publish: tap is required (set publish.homebrew.tap in config)")
}
// Get repository and project info
repo := ""
if relCfg != nil {
repo = relCfg.GetRepository()
}
if repo == "" {
detectedRepo, err := detectRepository(release.ProjectDir)
if err != nil {
return fmt.Errorf("homebrew.Publish: could not determine repository: %w", err)
}
repo = detectedRepo
}
projectName := ""
if relCfg != nil {
projectName = relCfg.GetProjectName()
}
if projectName == "" {
parts := strings.Split(repo, "/")
projectName = parts[len(parts)-1]
}
formulaName := cfg.Formula
if formulaName == "" {
formulaName = projectName
}
// Strip leading 'v' from version
version := strings.TrimPrefix(release.Version, "v")
// Build checksums map from artifacts
checksums := buildChecksumMap(release.Artifacts)
// Template data
data := homebrewTemplateData{
FormulaClass: toFormulaClass(formulaName),
Description: fmt.Sprintf("%s CLI", projectName),
Repository: repo,
Version: version,
License: "MIT",
BinaryName: projectName,
Checksums: checksums,
}
if dryRun {
return p.dryRunPublish(data, cfg)
}
return p.executePublish(ctx, release.ProjectDir, data, cfg)
}
// homebrewTemplateData holds data for Homebrew templates.
type homebrewTemplateData struct {
FormulaClass string
Description string
Repository string
Version string
License string
BinaryName string
Checksums ChecksumMap
}
// ChecksumMap holds checksums for different platform/arch combinations.
type ChecksumMap struct {
DarwinAmd64 string
DarwinArm64 string
LinuxAmd64 string
LinuxArm64 string
WindowsAmd64 string
WindowsArm64 string
}
// parseConfig extracts Homebrew-specific configuration.
func (p *HomebrewPublisher) parseConfig(pubCfg PublisherConfig, relCfg ReleaseConfig) HomebrewConfig {
cfg := HomebrewConfig{
Tap: "",
Formula: "",
}
if ext, ok := pubCfg.Extended.(map[string]any); ok {
if tap, ok := ext["tap"].(string); ok && tap != "" {
cfg.Tap = tap
}
if formula, ok := ext["formula"].(string); ok && formula != "" {
cfg.Formula = formula
}
if official, ok := ext["official"].(map[string]any); ok {
cfg.Official = &OfficialConfig{}
if enabled, ok := official["enabled"].(bool); ok {
cfg.Official.Enabled = enabled
}
if output, ok := official["output"].(string); ok {
cfg.Official.Output = output
}
}
}
return cfg
}
// dryRunPublish shows what would be done.
func (p *HomebrewPublisher) dryRunPublish(data homebrewTemplateData, cfg HomebrewConfig) error {
fmt.Println()
fmt.Println("=== DRY RUN: Homebrew Publish ===")
fmt.Println()
fmt.Printf("Formula: %s\n", data.FormulaClass)
fmt.Printf("Version: %s\n", data.Version)
fmt.Printf("Tap: %s\n", cfg.Tap)
fmt.Printf("Repository: %s\n", data.Repository)
fmt.Println()
// Generate and show formula
formula, err := p.renderTemplate("templates/homebrew/formula.rb.tmpl", data)
if err != nil {
return fmt.Errorf("homebrew.dryRunPublish: %w", err)
}
fmt.Println("Generated formula.rb:")
fmt.Println("---")
fmt.Println(formula)
fmt.Println("---")
fmt.Println()
if cfg.Tap != "" {
fmt.Printf("Would commit to tap: %s\n", cfg.Tap)
}
if cfg.Official != nil && cfg.Official.Enabled {
output := cfg.Official.Output
if output == "" {
output = "dist/homebrew"
}
fmt.Printf("Would write files for official PR to: %s\n", output)
}
fmt.Println()
fmt.Println("=== END DRY RUN ===")
return nil
}
// executePublish creates the formula and commits to tap.
func (p *HomebrewPublisher) executePublish(ctx context.Context, projectDir string, data homebrewTemplateData, cfg HomebrewConfig) error {
// Generate formula
formula, err := p.renderTemplate("templates/homebrew/formula.rb.tmpl", data)
if err != nil {
return fmt.Errorf("homebrew.Publish: failed to render formula: %w", err)
}
// If official config is enabled, write to output directory
if cfg.Official != nil && cfg.Official.Enabled {
output := cfg.Official.Output
if output == "" {
output = filepath.Join(projectDir, "dist", "homebrew")
} else if !filepath.IsAbs(output) {
output = filepath.Join(projectDir, output)
}
if err := os.MkdirAll(output, 0755); err != nil {
return fmt.Errorf("homebrew.Publish: failed to create output directory: %w", err)
}
formulaPath := filepath.Join(output, fmt.Sprintf("%s.rb", strings.ToLower(data.FormulaClass)))
if err := os.WriteFile(formulaPath, []byte(formula), 0644); err != nil {
return fmt.Errorf("homebrew.Publish: failed to write formula: %w", err)
}
fmt.Printf("Wrote Homebrew formula for official PR: %s\n", formulaPath)
}
// If tap is configured, commit to it
if cfg.Tap != "" {
if err := p.commitToTap(ctx, cfg.Tap, data, formula); err != nil {
return err
}
}
return nil
}
// commitToTap commits the formula to the tap repository.
func (p *HomebrewPublisher) commitToTap(ctx context.Context, tap string, data homebrewTemplateData, formula string) error {
// Clone tap repo to temp directory
tmpDir, err := os.MkdirTemp("", "homebrew-tap-*")
if err != nil {
return fmt.Errorf("homebrew.Publish: failed to create temp directory: %w", err)
}
defer os.RemoveAll(tmpDir)
// Clone the tap
fmt.Printf("Cloning tap %s...\n", tap)
cmd := exec.CommandContext(ctx, "gh", "repo", "clone", tap, tmpDir, "--", "--depth=1")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("homebrew.Publish: failed to clone tap: %w", err)
}
// Ensure Formula directory exists
formulaDir := filepath.Join(tmpDir, "Formula")
if err := os.MkdirAll(formulaDir, 0755); err != nil {
return fmt.Errorf("homebrew.Publish: failed to create Formula directory: %w", err)
}
// Write formula
formulaPath := filepath.Join(formulaDir, fmt.Sprintf("%s.rb", strings.ToLower(data.FormulaClass)))
if err := os.WriteFile(formulaPath, []byte(formula), 0644); err != nil {
return fmt.Errorf("homebrew.Publish: failed to write formula: %w", err)
}
// Git add, commit, push
commitMsg := fmt.Sprintf("Update %s to %s", data.FormulaClass, data.Version)
cmd = exec.CommandContext(ctx, "git", "add", ".")
cmd.Dir = tmpDir
if err := cmd.Run(); err != nil {
return fmt.Errorf("homebrew.Publish: git add failed: %w", err)
}
cmd = exec.CommandContext(ctx, "git", "commit", "-m", commitMsg)
cmd.Dir = tmpDir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("homebrew.Publish: git commit failed: %w", err)
}
cmd = exec.CommandContext(ctx, "git", "push")
cmd.Dir = tmpDir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("homebrew.Publish: git push failed: %w", err)
}
fmt.Printf("Updated Homebrew tap: %s\n", tap)
return nil
}
// renderTemplate renders an embedded template with the given data.
func (p *HomebrewPublisher) renderTemplate(name string, data homebrewTemplateData) (string, error) {
content, err := homebrewTemplates.ReadFile(name)
if err != nil {
return "", fmt.Errorf("failed to read template %s: %w", name, err)
}
tmpl, err := template.New(filepath.Base(name)).Parse(string(content))
if err != nil {
return "", fmt.Errorf("failed to parse template %s: %w", name, err)
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return "", fmt.Errorf("failed to execute template %s: %w", name, err)
}
return buf.String(), nil
}
// toFormulaClass converts a package name to a Ruby class name.
func toFormulaClass(name string) string {
// Convert kebab-case to PascalCase
parts := strings.Split(name, "-")
for i, part := range parts {
if len(part) > 0 {
parts[i] = strings.ToUpper(part[:1]) + part[1:]
}
}
return strings.Join(parts, "")
}
// buildChecksumMap extracts checksums from artifacts into a structured map.
func buildChecksumMap(artifacts []build.Artifact) ChecksumMap {
checksums := ChecksumMap{}
for _, a := range artifacts {
// Parse artifact name to determine platform
name := filepath.Base(a.Path)
checksum := a.Checksum
switch {
case strings.Contains(name, "darwin-amd64"):
checksums.DarwinAmd64 = checksum
case strings.Contains(name, "darwin-arm64"):
checksums.DarwinArm64 = checksum
case strings.Contains(name, "linux-amd64"):
checksums.LinuxAmd64 = checksum
case strings.Contains(name, "linux-arm64"):
checksums.LinuxArm64 = checksum
case strings.Contains(name, "windows-amd64"):
checksums.WindowsAmd64 = checksum
case strings.Contains(name, "windows-arm64"):
checksums.WindowsArm64 = checksum
}
}
return checksums
}

View file

@ -0,0 +1,248 @@
// Package publishers provides release publishing implementations.
package publishers
import (
"bytes"
"context"
"embed"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"text/template"
)
//go:embed templates/npm/*.tmpl
var npmTemplates embed.FS
// NpmConfig holds npm-specific configuration.
type NpmConfig struct {
// Package is the npm package name (e.g., "@host-uk/core").
Package string
// Access is the npm access level: "public" or "restricted".
Access string
}
// NpmPublisher publishes releases to npm using the binary wrapper pattern.
type NpmPublisher struct{}
// NewNpmPublisher creates a new npm publisher.
func NewNpmPublisher() *NpmPublisher {
return &NpmPublisher{}
}
// Name returns the publisher's identifier.
func (p *NpmPublisher) Name() string {
return "npm"
}
// Publish publishes the release to npm.
// It generates a binary wrapper package that downloads the correct platform binary on postinstall.
func (p *NpmPublisher) Publish(ctx context.Context, release *Release, pubCfg PublisherConfig, relCfg ReleaseConfig, dryRun bool) error {
// Parse npm config
npmCfg := p.parseConfig(pubCfg, relCfg)
// Validate configuration
if npmCfg.Package == "" {
return fmt.Errorf("npm.Publish: package name is required (set publish.npm.package in config)")
}
// Get repository
repo := ""
if relCfg != nil {
repo = relCfg.GetRepository()
}
if repo == "" {
detectedRepo, err := detectRepository(release.ProjectDir)
if err != nil {
return fmt.Errorf("npm.Publish: could not determine repository: %w", err)
}
repo = detectedRepo
}
// Get project name (binary name)
projectName := ""
if relCfg != nil {
projectName = relCfg.GetProjectName()
}
if projectName == "" {
// Try to infer from package name
parts := strings.Split(npmCfg.Package, "/")
projectName = parts[len(parts)-1]
}
// Strip leading 'v' from version for npm
version := strings.TrimPrefix(release.Version, "v")
// Template data
data := npmTemplateData{
Package: npmCfg.Package,
Version: version,
Description: fmt.Sprintf("%s CLI", projectName),
License: "MIT",
Repository: repo,
BinaryName: projectName,
ProjectName: projectName,
Access: npmCfg.Access,
}
if dryRun {
return p.dryRunPublish(data, &npmCfg)
}
return p.executePublish(ctx, data, &npmCfg)
}
// parseConfig extracts npm-specific configuration from the publisher config.
func (p *NpmPublisher) parseConfig(pubCfg PublisherConfig, relCfg ReleaseConfig) NpmConfig {
cfg := NpmConfig{
Package: "",
Access: "public",
}
// Override from extended config if present
if ext, ok := pubCfg.Extended.(map[string]any); ok {
if pkg, ok := ext["package"].(string); ok && pkg != "" {
cfg.Package = pkg
}
if access, ok := ext["access"].(string); ok && access != "" {
cfg.Access = access
}
}
return cfg
}
// npmTemplateData holds data for npm templates.
type npmTemplateData struct {
Package string
Version string
Description string
License string
Repository string
BinaryName string
ProjectName string
Access string
}
// dryRunPublish shows what would be done without actually publishing.
func (p *NpmPublisher) dryRunPublish(data npmTemplateData, cfg *NpmConfig) error {
fmt.Println()
fmt.Println("=== DRY RUN: npm Publish ===")
fmt.Println()
fmt.Printf("Package: %s\n", data.Package)
fmt.Printf("Version: %s\n", data.Version)
fmt.Printf("Access: %s\n", data.Access)
fmt.Printf("Repository: %s\n", data.Repository)
fmt.Printf("Binary: %s\n", data.BinaryName)
fmt.Println()
// Generate and show package.json
pkgJSON, err := p.renderTemplate("templates/npm/package.json.tmpl", data)
if err != nil {
return fmt.Errorf("npm.dryRunPublish: %w", err)
}
fmt.Println("Generated package.json:")
fmt.Println("---")
fmt.Println(pkgJSON)
fmt.Println("---")
fmt.Println()
fmt.Println("Would run: npm publish --access", data.Access)
fmt.Println()
fmt.Println("=== END DRY RUN ===")
return nil
}
// executePublish actually creates and publishes the npm package.
func (p *NpmPublisher) executePublish(ctx context.Context, data npmTemplateData, cfg *NpmConfig) error {
// Check for NPM_TOKEN
if os.Getenv("NPM_TOKEN") == "" {
return fmt.Errorf("npm.Publish: NPM_TOKEN environment variable is required")
}
// Create temp directory for package
tmpDir, err := os.MkdirTemp("", "npm-publish-*")
if err != nil {
return fmt.Errorf("npm.Publish: failed to create temp directory: %w", err)
}
defer os.RemoveAll(tmpDir)
// Create bin directory
binDir := filepath.Join(tmpDir, "bin")
if err := os.MkdirAll(binDir, 0755); err != nil {
return fmt.Errorf("npm.Publish: failed to create bin directory: %w", err)
}
// Generate package.json
pkgJSON, err := p.renderTemplate("templates/npm/package.json.tmpl", data)
if err != nil {
return fmt.Errorf("npm.Publish: failed to render package.json: %w", err)
}
if err := os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(pkgJSON), 0644); err != nil {
return fmt.Errorf("npm.Publish: failed to write package.json: %w", err)
}
// Generate install.js
installJS, err := p.renderTemplate("templates/npm/install.js.tmpl", data)
if err != nil {
return fmt.Errorf("npm.Publish: failed to render install.js: %w", err)
}
if err := os.WriteFile(filepath.Join(tmpDir, "install.js"), []byte(installJS), 0644); err != nil {
return fmt.Errorf("npm.Publish: failed to write install.js: %w", err)
}
// Generate run.js
runJS, err := p.renderTemplate("templates/npm/run.js.tmpl", data)
if err != nil {
return fmt.Errorf("npm.Publish: failed to render run.js: %w", err)
}
if err := os.WriteFile(filepath.Join(binDir, "run.js"), []byte(runJS), 0755); err != nil {
return fmt.Errorf("npm.Publish: failed to write run.js: %w", err)
}
// Create .npmrc with token
npmrc := fmt.Sprintf("//registry.npmjs.org/:_authToken=${NPM_TOKEN}\n")
if err := os.WriteFile(filepath.Join(tmpDir, ".npmrc"), []byte(npmrc), 0600); err != nil {
return fmt.Errorf("npm.Publish: failed to write .npmrc: %w", err)
}
// Run npm publish
cmd := exec.CommandContext(ctx, "npm", "publish", "--access", data.Access)
cmd.Dir = tmpDir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = append(os.Environ(), "NPM_TOKEN="+os.Getenv("NPM_TOKEN"))
fmt.Printf("Publishing %s@%s to npm...\n", data.Package, data.Version)
if err := cmd.Run(); err != nil {
return fmt.Errorf("npm.Publish: npm publish failed: %w", err)
}
fmt.Printf("Published %s@%s to npm\n", data.Package, data.Version)
fmt.Printf(" https://www.npmjs.com/package/%s\n", data.Package)
return nil
}
// renderTemplate renders an embedded template with the given data.
func (p *NpmPublisher) renderTemplate(name string, data npmTemplateData) (string, error) {
content, err := npmTemplates.ReadFile(name)
if err != nil {
return "", fmt.Errorf("failed to read template %s: %w", name, err)
}
tmpl, err := template.New(filepath.Base(name)).Parse(string(content))
if err != nil {
return "", fmt.Errorf("failed to parse template %s: %w", name, err)
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return "", fmt.Errorf("failed to execute template %s: %w", name, err)
}
return buf.String(), nil
}

View file

@ -0,0 +1,268 @@
// Package publishers provides release publishing implementations.
package publishers
import (
"bytes"
"context"
"embed"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"text/template"
"github.com/host-uk/core/pkg/build"
)
//go:embed templates/scoop/*.tmpl
var scoopTemplates embed.FS
// ScoopConfig holds Scoop-specific configuration.
type ScoopConfig struct {
// Bucket is the Scoop bucket repository (e.g., "host-uk/scoop-bucket").
Bucket string
// Official config for generating files for official repo PRs.
Official *OfficialConfig
}
// ScoopPublisher publishes releases to Scoop.
type ScoopPublisher struct{}
// NewScoopPublisher creates a new Scoop publisher.
func NewScoopPublisher() *ScoopPublisher {
return &ScoopPublisher{}
}
// Name returns the publisher's identifier.
func (p *ScoopPublisher) Name() string {
return "scoop"
}
// Publish publishes the release to Scoop.
func (p *ScoopPublisher) Publish(ctx context.Context, release *Release, pubCfg PublisherConfig, relCfg ReleaseConfig, dryRun bool) error {
cfg := p.parseConfig(pubCfg, relCfg)
if cfg.Bucket == "" && (cfg.Official == nil || !cfg.Official.Enabled) {
return fmt.Errorf("scoop.Publish: bucket is required (set publish.scoop.bucket in config)")
}
repo := ""
if relCfg != nil {
repo = relCfg.GetRepository()
}
if repo == "" {
detectedRepo, err := detectRepository(release.ProjectDir)
if err != nil {
return fmt.Errorf("scoop.Publish: could not determine repository: %w", err)
}
repo = detectedRepo
}
projectName := ""
if relCfg != nil {
projectName = relCfg.GetProjectName()
}
if projectName == "" {
parts := strings.Split(repo, "/")
projectName = parts[len(parts)-1]
}
version := strings.TrimPrefix(release.Version, "v")
checksums := buildChecksumMap(release.Artifacts)
data := scoopTemplateData{
PackageName: projectName,
Description: fmt.Sprintf("%s CLI", projectName),
Repository: repo,
Version: version,
License: "MIT",
BinaryName: projectName,
Checksums: checksums,
}
if dryRun {
return p.dryRunPublish(data, cfg)
}
return p.executePublish(ctx, release.ProjectDir, data, cfg)
}
type scoopTemplateData struct {
PackageName string
Description string
Repository string
Version string
License string
BinaryName string
Checksums ChecksumMap
}
func (p *ScoopPublisher) parseConfig(pubCfg PublisherConfig, relCfg ReleaseConfig) ScoopConfig {
cfg := ScoopConfig{}
if ext, ok := pubCfg.Extended.(map[string]any); ok {
if bucket, ok := ext["bucket"].(string); ok && bucket != "" {
cfg.Bucket = bucket
}
if official, ok := ext["official"].(map[string]any); ok {
cfg.Official = &OfficialConfig{}
if enabled, ok := official["enabled"].(bool); ok {
cfg.Official.Enabled = enabled
}
if output, ok := official["output"].(string); ok {
cfg.Official.Output = output
}
}
}
return cfg
}
func (p *ScoopPublisher) dryRunPublish(data scoopTemplateData, cfg ScoopConfig) error {
fmt.Println()
fmt.Println("=== DRY RUN: Scoop Publish ===")
fmt.Println()
fmt.Printf("Package: %s\n", data.PackageName)
fmt.Printf("Version: %s\n", data.Version)
fmt.Printf("Bucket: %s\n", cfg.Bucket)
fmt.Printf("Repository: %s\n", data.Repository)
fmt.Println()
manifest, err := p.renderTemplate("templates/scoop/manifest.json.tmpl", data)
if err != nil {
return fmt.Errorf("scoop.dryRunPublish: %w", err)
}
fmt.Println("Generated manifest.json:")
fmt.Println("---")
fmt.Println(manifest)
fmt.Println("---")
fmt.Println()
if cfg.Bucket != "" {
fmt.Printf("Would commit to bucket: %s\n", cfg.Bucket)
}
if cfg.Official != nil && cfg.Official.Enabled {
output := cfg.Official.Output
if output == "" {
output = "dist/scoop"
}
fmt.Printf("Would write files for official PR to: %s\n", output)
}
fmt.Println()
fmt.Println("=== END DRY RUN ===")
return nil
}
func (p *ScoopPublisher) executePublish(ctx context.Context, projectDir string, data scoopTemplateData, cfg ScoopConfig) error {
manifest, err := p.renderTemplate("templates/scoop/manifest.json.tmpl", data)
if err != nil {
return fmt.Errorf("scoop.Publish: failed to render manifest: %w", err)
}
// If official config is enabled, write to output directory
if cfg.Official != nil && cfg.Official.Enabled {
output := cfg.Official.Output
if output == "" {
output = filepath.Join(projectDir, "dist", "scoop")
} else if !filepath.IsAbs(output) {
output = filepath.Join(projectDir, output)
}
if err := os.MkdirAll(output, 0755); err != nil {
return fmt.Errorf("scoop.Publish: failed to create output directory: %w", err)
}
manifestPath := filepath.Join(output, fmt.Sprintf("%s.json", data.PackageName))
if err := os.WriteFile(manifestPath, []byte(manifest), 0644); err != nil {
return fmt.Errorf("scoop.Publish: failed to write manifest: %w", err)
}
fmt.Printf("Wrote Scoop manifest for official PR: %s\n", manifestPath)
}
// If bucket is configured, commit to it
if cfg.Bucket != "" {
if err := p.commitToBucket(ctx, cfg.Bucket, data, manifest); err != nil {
return err
}
}
return nil
}
func (p *ScoopPublisher) commitToBucket(ctx context.Context, bucket string, data scoopTemplateData, manifest string) error {
tmpDir, err := os.MkdirTemp("", "scoop-bucket-*")
if err != nil {
return fmt.Errorf("scoop.Publish: failed to create temp directory: %w", err)
}
defer os.RemoveAll(tmpDir)
fmt.Printf("Cloning bucket %s...\n", bucket)
cmd := exec.CommandContext(ctx, "gh", "repo", "clone", bucket, tmpDir, "--", "--depth=1")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("scoop.Publish: failed to clone bucket: %w", err)
}
// Ensure bucket directory exists
bucketDir := filepath.Join(tmpDir, "bucket")
if _, err := os.Stat(bucketDir); os.IsNotExist(err) {
bucketDir = tmpDir // Some repos put manifests in root
}
manifestPath := filepath.Join(bucketDir, fmt.Sprintf("%s.json", data.PackageName))
if err := os.WriteFile(manifestPath, []byte(manifest), 0644); err != nil {
return fmt.Errorf("scoop.Publish: failed to write manifest: %w", err)
}
commitMsg := fmt.Sprintf("Update %s to %s", data.PackageName, data.Version)
cmd = exec.CommandContext(ctx, "git", "add", ".")
cmd.Dir = tmpDir
if err := cmd.Run(); err != nil {
return fmt.Errorf("scoop.Publish: git add failed: %w", err)
}
cmd = exec.CommandContext(ctx, "git", "commit", "-m", commitMsg)
cmd.Dir = tmpDir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("scoop.Publish: git commit failed: %w", err)
}
cmd = exec.CommandContext(ctx, "git", "push")
cmd.Dir = tmpDir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("scoop.Publish: git push failed: %w", err)
}
fmt.Printf("Updated Scoop bucket: %s\n", bucket)
return nil
}
func (p *ScoopPublisher) renderTemplate(name string, data scoopTemplateData) (string, error) {
content, err := scoopTemplates.ReadFile(name)
if err != nil {
return "", fmt.Errorf("failed to read template %s: %w", name, err)
}
tmpl, err := template.New(filepath.Base(name)).Parse(string(content))
if err != nil {
return "", fmt.Errorf("failed to parse template %s: %w", name, err)
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return "", fmt.Errorf("failed to execute template %s: %w", name, err)
}
return buf.String(), nil
}
// Ensure build package is used
var _ = build.Artifact{}

View file

@ -0,0 +1,16 @@
pkgbase = {{.PackageName}}-bin
pkgdesc = {{.Description}}
pkgver = {{.Version}}
pkgrel = 1
url = https://github.com/{{.Repository}}
arch = x86_64
arch = aarch64
license = {{.License}}
provides = {{.PackageName}}
conflicts = {{.PackageName}}
source_x86_64 = {{.PackageName}}-bin-{{.Version}}-x86_64.tar.gz::https://github.com/{{.Repository}}/releases/download/v{{.Version}}/{{.BinaryName}}-linux-amd64.tar.gz
sha256sums_x86_64 = {{.Checksums.LinuxAmd64}}
source_aarch64 = {{.PackageName}}-bin-{{.Version}}-aarch64.tar.gz::https://github.com/{{.Repository}}/releases/download/v{{.Version}}/{{.BinaryName}}-linux-arm64.tar.gz
sha256sums_aarch64 = {{.Checksums.LinuxArm64}}
pkgname = {{.PackageName}}-bin

View file

@ -0,0 +1,20 @@
# Maintainer: {{.Maintainer}}
pkgname={{.PackageName}}-bin
pkgver={{.Version}}
pkgrel=1
pkgdesc="{{.Description}}"
arch=('x86_64' 'aarch64')
url="https://github.com/{{.Repository}}"
license=('{{.License}}')
provides=('{{.PackageName}}')
conflicts=('{{.PackageName}}')
source_x86_64=("${pkgname}-${pkgver}-x86_64.tar.gz::https://github.com/{{.Repository}}/releases/download/v${pkgver}/{{.BinaryName}}-linux-amd64.tar.gz")
source_aarch64=("${pkgname}-${pkgver}-aarch64.tar.gz::https://github.com/{{.Repository}}/releases/download/v${pkgver}/{{.BinaryName}}-linux-arm64.tar.gz")
sha256sums_x86_64=('{{.Checksums.LinuxAmd64}}')
sha256sums_aarch64=('{{.Checksums.LinuxArm64}}')
package() {
install -Dm755 {{.BinaryName}} "${pkgdir}/usr/bin/{{.BinaryName}}"
}

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd">
<metadata>
<id>{{.PackageName}}</id>
<version>{{.Version}}</version>
<title>{{.Title}}</title>
<authors>{{.Authors}}</authors>
<projectUrl>https://github.com/{{.Repository}}</projectUrl>
<licenseUrl>https://github.com/{{.Repository}}/blob/main/LICENSE</licenseUrl>
<requireLicenseAcceptance>false</requireLicenseAcceptance>
<description>{{.Description}}</description>
<tags>{{.Tags}}</tags>
<releaseNotes>https://github.com/{{.Repository}}/releases/tag/v{{.Version}}</releaseNotes>
</metadata>
<files>
<file src="tools\**" target="tools" />
</files>
</package>

View file

@ -0,0 +1,13 @@
$ErrorActionPreference = 'Stop'
$toolsDir = "$(Split-Path -parent $MyInvocation.MyCommand.Definition)"
$url64 = 'https://github.com/{{.Repository}}/releases/download/v{{.Version}}/{{.BinaryName}}-windows-amd64.zip'
$packageArgs = @{
packageName = '{{.PackageName}}'
unzipLocation = $toolsDir
url64bit = $url64
checksum64 = '{{.Checksums.WindowsAmd64}}'
checksumType64 = 'sha256'
}
Install-ChocolateyZipPackage @packageArgs

View file

@ -0,0 +1,37 @@
# typed: false
# frozen_string_literal: true
class {{.FormulaClass}} < Formula
desc "{{.Description}}"
homepage "https://github.com/{{.Repository}}"
version "{{.Version}}"
license "{{.License}}"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/{{.Repository}}/releases/download/v{{.Version}}/{{.BinaryName}}-darwin-arm64.tar.gz"
sha256 "{{.Checksums.DarwinArm64}}"
else
url "https://github.com/{{.Repository}}/releases/download/v{{.Version}}/{{.BinaryName}}-darwin-amd64.tar.gz"
sha256 "{{.Checksums.DarwinAmd64}}"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/{{.Repository}}/releases/download/v{{.Version}}/{{.BinaryName}}-linux-arm64.tar.gz"
sha256 "{{.Checksums.LinuxArm64}}"
else
url "https://github.com/{{.Repository}}/releases/download/v{{.Version}}/{{.BinaryName}}-linux-amd64.tar.gz"
sha256 "{{.Checksums.LinuxAmd64}}"
end
end
def install
bin.install "{{.BinaryName}}"
end
test do
system "#{bin}/{{.BinaryName}}", "--version"
end
end

View file

@ -0,0 +1,176 @@
#!/usr/bin/env node
/**
* Binary installer for {{.Package}}
* Downloads the correct binary for the current platform from GitHub releases.
*/
const fs = require('fs');
const path = require('path');
const https = require('https');
const { spawnSync } = require('child_process');
const crypto = require('crypto');
const PACKAGE_VERSION = '{{.Version}}';
const GITHUB_REPO = '{{.Repository}}';
const BINARY_NAME = '{{.BinaryName}}';
// Platform/arch mapping
const PLATFORM_MAP = {
darwin: 'darwin',
linux: 'linux',
win32: 'windows',
};
const ARCH_MAP = {
x64: 'amd64',
arm64: 'arm64',
};
function getPlatformInfo() {
const platform = PLATFORM_MAP[process.platform];
const arch = ARCH_MAP[process.arch];
if (!platform || !arch) {
console.error(`Unsupported platform: ${process.platform}/${process.arch}`);
process.exit(1);
}
return { platform, arch };
}
function getDownloadUrl(platform, arch) {
const ext = platform === 'windows' ? '.zip' : '.tar.gz';
const name = `${BINARY_NAME}-${platform}-${arch}${ext}`;
return `https://github.com/${GITHUB_REPO}/releases/download/v${PACKAGE_VERSION}/${name}`;
}
function getChecksumsUrl() {
return `https://github.com/${GITHUB_REPO}/releases/download/v${PACKAGE_VERSION}/checksums.txt`;
}
function download(url) {
return new Promise((resolve, reject) => {
const request = (url) => {
https.get(url, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
// Follow redirect
request(res.headers.location);
return;
}
if (res.statusCode !== 200) {
reject(new Error(`Failed to download ${url}: HTTP ${res.statusCode}`));
return;
}
const chunks = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => resolve(Buffer.concat(chunks)));
res.on('error', reject);
}).on('error', reject);
};
request(url);
});
}
async function fetchChecksums() {
try {
const data = await download(getChecksumsUrl());
const checksums = {};
data.toString().split('\n').forEach((line) => {
const parts = line.trim().split(/\s+/);
if (parts.length === 2) {
checksums[parts[1]] = parts[0];
}
});
return checksums;
} catch (err) {
console.warn('Warning: Could not fetch checksums, skipping verification');
return null;
}
}
function verifyChecksum(data, expectedHash) {
const actualHash = crypto.createHash('sha256').update(data).digest('hex');
return actualHash === expectedHash;
}
function extract(data, destDir, platform) {
const tempFile = path.join(destDir, platform === 'windows' ? 'temp.zip' : 'temp.tar.gz');
fs.writeFileSync(tempFile, data);
try {
if (platform === 'windows') {
// Use PowerShell to extract zip
const result = spawnSync('powershell', [
'-command',
`Expand-Archive -Path '${tempFile}' -DestinationPath '${destDir}' -Force`
], { stdio: 'ignore' });
if (result.status !== 0) {
throw new Error('Failed to extract zip');
}
} else {
const result = spawnSync('tar', ['-xzf', tempFile, '-C', destDir], { stdio: 'ignore' });
if (result.status !== 0) {
throw new Error('Failed to extract tar.gz');
}
}
} finally {
fs.unlinkSync(tempFile);
}
}
async function main() {
const { platform, arch } = getPlatformInfo();
const binDir = path.join(__dirname, 'bin');
const binaryPath = path.join(binDir, platform === 'windows' ? `${BINARY_NAME}.exe` : BINARY_NAME);
// Skip if binary already exists
if (fs.existsSync(binaryPath)) {
console.log(`${BINARY_NAME} binary already installed`);
return;
}
console.log(`Installing ${BINARY_NAME} v${PACKAGE_VERSION} for ${platform}/${arch}...`);
// Ensure bin directory exists
if (!fs.existsSync(binDir)) {
fs.mkdirSync(binDir, { recursive: true });
}
// Fetch checksums
const checksums = await fetchChecksums();
// Download binary
const url = getDownloadUrl(platform, arch);
console.log(`Downloading from ${url}`);
const data = await download(url);
// Verify checksum if available
if (checksums) {
const ext = platform === 'windows' ? '.zip' : '.tar.gz';
const filename = `${BINARY_NAME}-${platform}-${arch}${ext}`;
const expectedHash = checksums[filename];
if (expectedHash && !verifyChecksum(data, expectedHash)) {
console.error('Checksum verification failed!');
process.exit(1);
}
console.log('Checksum verified');
}
// Extract
extract(data, binDir, platform);
// Make executable on Unix
if (platform !== 'windows') {
fs.chmodSync(binaryPath, 0o755);
}
console.log(`${BINARY_NAME} installed successfully`);
}
main().catch((err) => {
console.error(`Installation failed: ${err.message}`);
process.exit(1);
});

View file

@ -0,0 +1,34 @@
{
"name": "{{.Package}}",
"version": "{{.Version}}",
"description": "{{.Description}}",
"license": "{{.License}}",
"repository": {
"type": "git",
"url": "https://github.com/{{.Repository}}.git"
},
"homepage": "https://github.com/{{.Repository}}",
"bugs": {
"url": "https://github.com/{{.Repository}}/issues"
},
"bin": {
"{{.BinaryName}}": "./bin/run.js"
},
"scripts": {
"postinstall": "node ./install.js"
},
"files": [
"bin/",
"install.js"
],
"engines": {
"node": ">=14.0.0"
},
"keywords": [
"cli",
"{{.ProjectName}}"
],
"publishConfig": {
"access": "{{.Access}}"
}
}

View file

@ -0,0 +1,48 @@
#!/usr/bin/env node
/**
* Binary wrapper for {{.Package}}
* Executes the platform-specific binary.
*/
const { spawn } = require('child_process');
const path = require('path');
const fs = require('fs');
const BINARY_NAME = '{{.BinaryName}}';
function getBinaryPath() {
const binDir = path.join(__dirname);
const isWindows = process.platform === 'win32';
const binaryName = isWindows ? `${BINARY_NAME}.exe` : BINARY_NAME;
return path.join(binDir, binaryName);
}
function main() {
const binaryPath = getBinaryPath();
if (!fs.existsSync(binaryPath)) {
console.error(`Binary not found at ${binaryPath}`);
console.error('Try reinstalling the package: npm install -g {{.Package}}');
process.exit(1);
}
const child = spawn(binaryPath, process.argv.slice(2), {
stdio: 'inherit',
windowsHide: true,
});
child.on('error', (err) => {
console.error(`Failed to start ${BINARY_NAME}: ${err.message}`);
process.exit(1);
});
child.on('exit', (code, signal) => {
if (signal) {
process.kill(process.pid, signal);
} else {
process.exit(code ?? 0);
}
});
}
main();

View file

@ -0,0 +1,30 @@
{
"version": "{{.Version}}",
"description": "{{.Description}}",
"homepage": "https://github.com/{{.Repository}}",
"license": "{{.License}}",
"architecture": {
"64bit": {
"url": "https://github.com/{{.Repository}}/releases/download/v{{.Version}}/{{.BinaryName}}-windows-amd64.zip",
"hash": "{{.Checksums.WindowsAmd64}}"
},
"arm64": {
"url": "https://github.com/{{.Repository}}/releases/download/v{{.Version}}/{{.BinaryName}}-windows-arm64.zip",
"hash": "{{.Checksums.WindowsArm64}}"
}
},
"bin": "{{.BinaryName}}.exe",
"checkver": {
"github": "https://github.com/{{.Repository}}"
},
"autoupdate": {
"architecture": {
"64bit": {
"url": "https://github.com/{{.Repository}}/releases/download/v$version/{{.BinaryName}}-windows-amd64.zip"
},
"arm64": {
"url": "https://github.com/{{.Repository}}/releases/download/v$version/{{.BinaryName}}-windows-arm64.zip"
}
}
}
}

View file

@ -216,6 +216,16 @@ func getPublisher(pubType string) (publishers.Publisher, error) {
return publishers.NewLinuxKitPublisher(), nil
case "docker":
return publishers.NewDockerPublisher(), nil
case "npm":
return publishers.NewNpmPublisher(), nil
case "homebrew":
return publishers.NewHomebrewPublisher(), nil
case "scoop":
return publishers.NewScoopPublisher(), nil
case "aur":
return publishers.NewAURPublisher(), nil
case "chocolatey":
return publishers.NewChocolateyPublisher(), nil
default:
return nil, fmt.Errorf("unsupported publisher type: %s", pubType)
}
@ -257,6 +267,47 @@ func buildExtendedConfig(pubCfg PublisherConfig) map[string]any {
ext["build_args"] = args
}
// npm-specific config
if pubCfg.Package != "" {
ext["package"] = pubCfg.Package
}
if pubCfg.Access != "" {
ext["access"] = pubCfg.Access
}
// Homebrew-specific config
if pubCfg.Tap != "" {
ext["tap"] = pubCfg.Tap
}
if pubCfg.Formula != "" {
ext["formula"] = pubCfg.Formula
}
// Scoop-specific config
if pubCfg.Bucket != "" {
ext["bucket"] = pubCfg.Bucket
}
// AUR-specific config
if pubCfg.Maintainer != "" {
ext["maintainer"] = pubCfg.Maintainer
}
// Chocolatey-specific config
if pubCfg.Push {
ext["push"] = pubCfg.Push
}
// Official repo config (shared by multiple publishers)
if pubCfg.Official != nil {
official := make(map[string]any)
official["enabled"] = pubCfg.Official.Enabled
if pubCfg.Official.Output != "" {
official["output"] = pubCfg.Official.Output
}
ext["official"] = official
}
return ext
}