go/pkg/build/builders/taskfile.go
Snider 26b47ee073
Migrate pkg/build to io.Medium abstraction (#287)
* chore(io): Migrate pkg/build to Medium abstraction

- Updated io.Medium interface with Open() and Create() methods to support streaming.
- Migrated pkg/build, pkg/build/builders, and pkg/build/signing to use io.Medium.
- Added FS field to build.Config and updated build.Builder interface.
- Refactored checksum and archive logic to use io.Medium streaming.
- Updated pkg/release and pkg/build/buildcmd to use io.Local.
- Updated unit tests to match new signatures.

* chore(io): Migrate pkg/build to Medium abstraction (fix CI)

- Fixed formatting in pkg/build/builders/wails.go.
- Fixed TestLoadConfig_Testdata and TestDiscover_Testdata to use absolute paths with io.Local to ensure compatibility with GitHub CI.
- Verified that all build and release tests pass.

* chore(io): Migrate pkg/build to Medium abstraction (fix CI paths)

- Ensured that outputDir and configPath are absolute in runProjectBuild.
- Fixed TestLoadConfig_Testdata and TestDiscover_Testdata to use absolute paths correctly.
- Verified that all build and release tests pass locally.

* chore(io): Migrate pkg/build to Medium abstraction (final fix)

- Improved io.Local to handle relative paths relative to CWD when rooted at "/".
- This makes io.Local a drop-in replacement for the 'os' package for most use cases.
- Ensured absolute paths are used in build logic and tests where appropriate.
- Fixed formatting and cleaned up debug prints.

* chore(io): address code review and fix CI

- Fix MockFile.Read to return io.EOF
- Use filepath.Match in TaskfileBuilder for precise globbing
- Stream xz data in createTarXzArchive to avoid in-memory string conversion
- Fix TestPath_RootFilesystem in local medium tests
- Fix formatting in pkg/build/buildcmd/cmd_project.go

* chore(io): resolve merge conflicts and final migration of pkg/build

- Resolved merge conflicts in pkg/io/io.go, pkg/io/local/client.go, and pkg/release/release.go.
- Reconciled io.Medium interface with upstream changes (unifying to fs.File for Open).
- Integrated upstream validatePath logic into the local medium.
- Completed migration of pkg/build and related packages to io.Medium.
- Addressed previous code review feedback on MockMedium and TaskfileBuilder.

* chore(io): resolve merge conflicts and finalize migration

- Resolved merge conflicts with dev branch.
- Unified io.Medium interface (Open returns fs.File, Create returns io.WriteCloser).
- Integrated upstream validatePath logic.
- Ensured all tests pass across pkg/io, pkg/build, and pkg/release.

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 17:59:10 +00:00

275 lines
7 KiB
Go

// 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"
"github.com/host-uk/core/pkg/io"
)
// 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(fs io.Medium, 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 fs.IsFile(filepath.Join(dir, tf)) {
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 := cfg.FS.EnsureDir(outputDir); 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(cfg.FS, 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(cfg.FS, 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(fs io.Medium, outputDir string) []build.Artifact {
var artifacts []build.Artifact
entries, err := fs.List(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(fs io.Medium, outputDir string, target build.Target) []build.Artifact {
var artifacts []build.Artifact
// 1. Look for platform-specific subdirectory: output/os_arch/
platformSubdir := filepath.Join(outputDir, fmt.Sprintf("%s_%s", target.OS, target.Arch))
if fs.IsDir(platformSubdir) {
entries, _ := fs.List(platformSubdir)
for _, entry := range entries {
if entry.IsDir() {
// Handle .app bundles on macOS
if target.OS == "darwin" && strings.HasSuffix(entry.Name(), ".app") {
artifacts = append(artifacts, build.Artifact{
Path: filepath.Join(platformSubdir, entry.Name()),
OS: target.OS,
Arch: target.Arch,
})
}
continue
}
// Skip hidden files
if strings.HasPrefix(entry.Name(), ".") {
continue
}
artifacts = append(artifacts, build.Artifact{
Path: filepath.Join(platformSubdir, entry.Name()),
OS: target.OS,
Arch: target.Arch,
})
}
if len(artifacts) > 0 {
return artifacts
}
}
// 2. Look for files matching the target pattern in the root output dir
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 {
entries, _ := fs.List(outputDir)
for _, entry := range entries {
match := entry.Name()
// Simple glob matching
if b.matchPattern(match, pattern) {
fullPath := filepath.Join(outputDir, match)
if fs.IsDir(fullPath) {
continue
}
artifacts = append(artifacts, build.Artifact{
Path: fullPath,
OS: target.OS,
Arch: target.Arch,
})
}
}
if len(artifacts) > 0 {
break // Found matches, stop looking
}
}
return artifacts
}
// matchPattern implements glob matching for Taskfile artifacts.
func (b *TaskfileBuilder) matchPattern(name, pattern string) bool {
matched, _ := filepath.Match(pattern, name)
return matched
}
// 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/")
}