Merge branch 'chore/io-migrate-build-8873543635510272463' into new

# Conflicts:
#	pkg/build/checksum.go
#	pkg/build/config.go
#	pkg/build/discovery.go
#	pkg/build/discovery_test.go
#	pkg/io/io.go
#	pkg/io/local/client.go
#	pkg/release/release.go
This commit is contained in:
Snider 2026-02-08 21:29:14 +00:00
commit d44ca11e39
5 changed files with 54 additions and 84 deletions

View file

@ -6,26 +6,25 @@ import (
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"io" "io"
"os"
"path/filepath" "path/filepath"
io_interface "github.com/host-uk/core/pkg/io"
"sort" "sort"
"strings" "strings"
coreio "github.com/host-uk/core/pkg/io"
) )
// Checksum computes SHA256 for an artifact and returns the artifact with the Checksum field filled. // Checksum computes SHA256 for an artifact and returns the artifact with the Checksum field filled.
func Checksum(artifact Artifact) (Artifact, error) { func Checksum(fs io_interface.Medium, artifact Artifact) (Artifact, error) {
if artifact.Path == "" { if artifact.Path == "" {
return Artifact{}, fmt.Errorf("build.Checksum: artifact path is empty") return Artifact{}, fmt.Errorf("build.Checksum: artifact path is empty")
} }
// Open the file // Open the file
file, err := os.Open(artifact.Path) file, err := fs.Open(artifact.Path)
if err != nil { if err != nil {
return Artifact{}, fmt.Errorf("build.Checksum: failed to open file: %w", err) return Artifact{}, fmt.Errorf("build.Checksum: failed to open file: %w", err)
} }
defer file.Close() defer func() { _ = file.Close() }()
// Compute SHA256 hash // Compute SHA256 hash
hasher := sha256.New() hasher := sha256.New()
@ -45,14 +44,14 @@ func Checksum(artifact Artifact) (Artifact, error) {
// ChecksumAll computes checksums for all artifacts. // ChecksumAll computes checksums for all artifacts.
// Returns a slice of artifacts with their Checksum fields filled. // Returns a slice of artifacts with their Checksum fields filled.
func ChecksumAll(artifacts []Artifact) ([]Artifact, error) { func ChecksumAll(fs io_interface.Medium, artifacts []Artifact) ([]Artifact, error) {
if len(artifacts) == 0 { if len(artifacts) == 0 {
return nil, nil return nil, nil
} }
var checksummed []Artifact var checksummed []Artifact
for _, artifact := range artifacts { for _, artifact := range artifacts {
cs, err := Checksum(artifact) cs, err := Checksum(fs, artifact)
if err != nil { if err != nil {
return checksummed, fmt.Errorf("build.ChecksumAll: failed to checksum %s: %w", artifact.Path, err) return checksummed, fmt.Errorf("build.ChecksumAll: failed to checksum %s: %w", artifact.Path, err)
} }
@ -69,7 +68,7 @@ func ChecksumAll(artifacts []Artifact) ([]Artifact, error) {
// //
// The artifacts should have their Checksum fields filled (call ChecksumAll first). // The artifacts should have their Checksum fields filled (call ChecksumAll first).
// Filenames are relative to the output directory (just the basename). // Filenames are relative to the output directory (just the basename).
func WriteChecksumFile(artifacts []Artifact, path string) error { func WriteChecksumFile(fs io_interface.Medium, artifacts []Artifact, path string) error {
if len(artifacts) == 0 { if len(artifacts) == 0 {
return nil return nil
} }
@ -89,14 +88,8 @@ func WriteChecksumFile(artifacts []Artifact, path string) error {
content := strings.Join(lines, "\n") + "\n" content := strings.Join(lines, "\n") + "\n"
// Ensure directory exists // Write the file using the medium (which handles directory creation in Write)
dir := filepath.Dir(path) if err := fs.Write(path, content); err != nil {
if err := coreio.Local.EnsureDir(dir); err != nil {
return fmt.Errorf("build.WriteChecksumFile: failed to create directory: %w", err)
}
// Write the file
if err := coreio.Local.Write(path, content); err != nil {
return fmt.Errorf("build.WriteChecksumFile: failed to write file: %w", err) return fmt.Errorf("build.WriteChecksumFile: failed to write file: %w", err)
} }

View file

@ -69,16 +69,10 @@ type TargetConfig struct {
// LoadConfig loads build configuration from the .core/build.yaml file in the given directory. // LoadConfig loads build configuration from the .core/build.yaml file in the given directory.
// If the config file does not exist, it returns DefaultConfig(). // If the config file does not exist, it returns DefaultConfig().
// Returns an error if the file exists but cannot be parsed. // Returns an error if the file exists but cannot be parsed.
func LoadConfig(dir string) (*BuildConfig, error) { func LoadConfig(fs io.Medium, dir string) (*BuildConfig, error) {
configPath := filepath.Join(dir, ConfigDir, ConfigFileName) configPath := filepath.Join(dir, ConfigDir, ConfigFileName)
// Convert to absolute path for io.Local content, err := fs.Read(configPath)
absPath, err := filepath.Abs(configPath)
if err != nil {
return nil, fmt.Errorf("build.LoadConfig: failed to resolve path: %w", err)
}
content, err := io.Local.Read(absPath)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
return DefaultConfig(), nil return DefaultConfig(), nil
@ -87,7 +81,8 @@ func LoadConfig(dir string) (*BuildConfig, error) {
} }
var cfg BuildConfig var cfg BuildConfig
if err := yaml.Unmarshal([]byte(content), &cfg); err != nil { data := []byte(content)
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("build.LoadConfig: failed to parse config file: %w", err) return nil, fmt.Errorf("build.LoadConfig: failed to parse config file: %w", err)
} }
@ -115,7 +110,6 @@ func DefaultConfig() *BuildConfig {
Targets: []TargetConfig{ Targets: []TargetConfig{
{OS: "linux", Arch: "amd64"}, {OS: "linux", Arch: "amd64"},
{OS: "linux", Arch: "arm64"}, {OS: "linux", Arch: "arm64"},
{OS: "darwin", Arch: "amd64"},
{OS: "darwin", Arch: "arm64"}, {OS: "darwin", Arch: "arm64"},
{OS: "windows", Arch: "amd64"}, {OS: "windows", Arch: "amd64"},
}, },
@ -161,8 +155,8 @@ func ConfigPath(dir string) string {
} }
// ConfigExists checks if a build config file exists in the given directory. // ConfigExists checks if a build config file exists in the given directory.
func ConfigExists(dir string) bool { func ConfigExists(fs io.Medium, dir string) bool {
return fileExists(ConfigPath(dir)) return fileExists(fs, ConfigPath(dir))
} }
// ToTargets converts TargetConfig slice to Target slice for use with builders. // ToTargets converts TargetConfig slice to Target slice for use with builders.

View file

@ -33,12 +33,12 @@ var markers = []projectMarker{
// Discover detects project types in the given directory by checking for marker files. // Discover detects project types in the given directory by checking for marker files.
// Returns a slice of detected project types, ordered by priority (most specific first). // Returns a slice of detected project types, ordered by priority (most specific first).
// For example, a Wails project returns [wails, go] since it has both wails.json and go.mod. // For example, a Wails project returns [wails, go] since it has both wails.json and go.mod.
func Discover(dir string) ([]ProjectType, error) { func Discover(fs io.Medium, dir string) ([]ProjectType, error) {
var detected []ProjectType var detected []ProjectType
for _, m := range markers { for _, m := range markers {
path := filepath.Join(dir, m.file) path := filepath.Join(dir, m.file)
if fileExists(path) { if fileExists(fs, path) {
// Avoid duplicates (shouldn't happen with current markers, but defensive) // Avoid duplicates (shouldn't happen with current markers, but defensive)
if !slices.Contains(detected, m.projectType) { if !slices.Contains(detected, m.projectType) {
detected = append(detected, m.projectType) detected = append(detected, m.projectType)
@ -51,8 +51,8 @@ func Discover(dir string) ([]ProjectType, error) {
// PrimaryType returns the most specific project type detected in the directory. // PrimaryType returns the most specific project type detected in the directory.
// Returns empty string if no project type is detected. // Returns empty string if no project type is detected.
func PrimaryType(dir string) (ProjectType, error) { func PrimaryType(fs io.Medium, dir string) (ProjectType, error) {
types, err := Discover(dir) types, err := Discover(fs, dir)
if err != nil { if err != nil {
return "", err return "", err
} }
@ -63,31 +63,27 @@ func PrimaryType(dir string) (ProjectType, error) {
} }
// IsGoProject checks if the directory contains a Go project (go.mod or wails.json). // IsGoProject checks if the directory contains a Go project (go.mod or wails.json).
func IsGoProject(dir string) bool { func IsGoProject(fs io.Medium, dir string) bool {
return fileExists(filepath.Join(dir, markerGoMod)) || return fileExists(fs, filepath.Join(dir, markerGoMod)) ||
fileExists(filepath.Join(dir, markerWails)) fileExists(fs, filepath.Join(dir, markerWails))
} }
// IsWailsProject checks if the directory contains a Wails project. // IsWailsProject checks if the directory contains a Wails project.
func IsWailsProject(dir string) bool { func IsWailsProject(fs io.Medium, dir string) bool {
return fileExists(filepath.Join(dir, markerWails)) return fileExists(fs, filepath.Join(dir, markerWails))
} }
// IsNodeProject checks if the directory contains a Node.js project. // IsNodeProject checks if the directory contains a Node.js project.
func IsNodeProject(dir string) bool { func IsNodeProject(fs io.Medium, dir string) bool {
return fileExists(filepath.Join(dir, markerNodePackage)) return fileExists(fs, filepath.Join(dir, markerNodePackage))
} }
// IsPHPProject checks if the directory contains a PHP project. // IsPHPProject checks if the directory contains a PHP project.
func IsPHPProject(dir string) bool { func IsPHPProject(fs io.Medium, dir string) bool {
return fileExists(filepath.Join(dir, markerComposer)) return fileExists(fs, filepath.Join(dir, markerComposer))
} }
// fileExists checks if a file exists and is not a directory. // fileExists checks if a file exists and is not a directory.
func fileExists(path string) bool { func fileExists(fs io.Medium, path string) bool {
absPath, err := filepath.Abs(path) return fs.IsFile(path)
if err != nil {
return false
}
return io.Local.IsFile(absPath)
} }

View file

@ -52,13 +52,6 @@ func TestDiscover_Good(t *testing.T) {
assert.Equal(t, []ProjectType{ProjectTypePHP}, types) assert.Equal(t, []ProjectType{ProjectTypePHP}, types)
}) })
t.Run("detects C++ project", func(t *testing.T) {
dir := setupTestDir(t, "CMakeLists.txt")
types, err := Discover(fs, dir)
assert.NoError(t, err)
assert.Equal(t, []ProjectType{ProjectTypeCPP}, types)
})
t.Run("detects multiple project types", func(t *testing.T) { t.Run("detects multiple project types", func(t *testing.T) {
dir := setupTestDir(t, "go.mod", "package.json") dir := setupTestDir(t, "go.mod", "package.json")
types, err := Discover(fs, dir) types, err := Discover(fs, dir)
@ -162,19 +155,6 @@ func TestIsNodeProject_Good(t *testing.T) {
}) })
} }
func TestIsCPPProject_Good(t *testing.T) {
fs := io.Local
t.Run("true with CMakeLists.txt", func(t *testing.T) {
dir := setupTestDir(t, "CMakeLists.txt")
assert.True(t, IsCPPProject(fs, dir))
})
t.Run("false without CMakeLists.txt", func(t *testing.T) {
dir := t.TempDir()
assert.False(t, IsCPPProject(fs, dir))
})
}
func TestIsPHPProject_Good(t *testing.T) { func TestIsPHPProject_Good(t *testing.T) {
fs := io.Local fs := io.Local
t.Run("true with composer.json", func(t *testing.T) { t.Run("true with composer.json", func(t *testing.T) {
@ -229,7 +209,6 @@ func TestDiscover_Testdata(t *testing.T) {
{"wails-project", "wails-project", []ProjectType{ProjectTypeWails, ProjectTypeGo}}, {"wails-project", "wails-project", []ProjectType{ProjectTypeWails, ProjectTypeGo}},
{"node-project", "node-project", []ProjectType{ProjectTypeNode}}, {"node-project", "node-project", []ProjectType{ProjectTypeNode}},
{"php-project", "php-project", []ProjectType{ProjectTypePHP}}, {"php-project", "php-project", []ProjectType{ProjectTypePHP}},
{"cpp-project", "cpp-project", []ProjectType{ProjectTypeCPP}},
{"multi-project", "multi-project", []ProjectType{ProjectTypeGo, ProjectTypeNode}}, {"multi-project", "multi-project", []ProjectType{ProjectTypeGo, ProjectTypeNode}},
{"empty-project", "empty-project", []ProjectType{}}, {"empty-project", "empty-project", []ProjectType{}},
} }

View file

@ -25,6 +25,8 @@ type Release struct {
Changelog string Changelog string
// ProjectDir is the root directory of the project. // ProjectDir is the root directory of the project.
ProjectDir string ProjectDir string
// FS is the medium for file operations.
FS io.Medium
} }
// Publish publishes pre-built artifacts from dist/ to configured targets. // Publish publishes pre-built artifacts from dist/ to configured targets.
@ -35,6 +37,8 @@ func Publish(ctx context.Context, cfg *Config, dryRun bool) (*Release, error) {
return nil, fmt.Errorf("release.Publish: config is nil") return nil, fmt.Errorf("release.Publish: config is nil")
} }
m := io.Local
projectDir := cfg.projectDir projectDir := cfg.projectDir
if projectDir == "" { if projectDir == "" {
projectDir = "." projectDir = "."
@ -57,7 +61,7 @@ func Publish(ctx context.Context, cfg *Config, dryRun bool) (*Release, error) {
// Step 2: Find pre-built artifacts in dist/ // Step 2: Find pre-built artifacts in dist/
distDir := filepath.Join(absProjectDir, "dist") distDir := filepath.Join(absProjectDir, "dist")
artifacts, err := findArtifacts(distDir) artifacts, err := findArtifacts(m, distDir)
if err != nil { if err != nil {
return nil, fmt.Errorf("release.Publish: %w", err) return nil, fmt.Errorf("release.Publish: %w", err)
} }
@ -78,11 +82,12 @@ func Publish(ctx context.Context, cfg *Config, dryRun bool) (*Release, error) {
Artifacts: artifacts, Artifacts: artifacts,
Changelog: changelog, Changelog: changelog,
ProjectDir: absProjectDir, ProjectDir: absProjectDir,
FS: m,
} }
// Step 4: Publish to configured targets // Step 4: Publish to configured targets
if len(cfg.Publishers) > 0 { if len(cfg.Publishers) > 0 {
pubRelease := publishers.NewRelease(release.Version, release.Artifacts, release.Changelog, release.ProjectDir) pubRelease := publishers.NewRelease(release.Version, release.Artifacts, release.Changelog, release.ProjectDir, release.FS)
for _, pubCfg := range cfg.Publishers { for _, pubCfg := range cfg.Publishers {
publisher, err := getPublisher(pubCfg.Type) publisher, err := getPublisher(pubCfg.Type)
@ -102,14 +107,14 @@ func Publish(ctx context.Context, cfg *Config, dryRun bool) (*Release, error) {
} }
// findArtifacts discovers pre-built artifacts in the dist directory. // findArtifacts discovers pre-built artifacts in the dist directory.
func findArtifacts(distDir string) ([]build.Artifact, error) { func findArtifacts(m io.Medium, distDir string) ([]build.Artifact, error) {
if !io.Local.IsDir(distDir) { if !m.IsDir(distDir) {
return nil, fmt.Errorf("dist/ directory not found") return nil, fmt.Errorf("dist/ directory not found")
} }
var artifacts []build.Artifact var artifacts []build.Artifact
entries, err := io.Local.List(distDir) entries, err := m.List(distDir)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read dist/: %w", err) return nil, fmt.Errorf("failed to read dist/: %w", err)
} }
@ -143,6 +148,8 @@ func Run(ctx context.Context, cfg *Config, dryRun bool) (*Release, error) {
return nil, fmt.Errorf("release.Run: config is nil") return nil, fmt.Errorf("release.Run: config is nil")
} }
m := io.Local
projectDir := cfg.projectDir projectDir := cfg.projectDir
if projectDir == "" { if projectDir == "" {
projectDir = "." projectDir = "."
@ -171,7 +178,7 @@ func Run(ctx context.Context, cfg *Config, dryRun bool) (*Release, error) {
} }
// Step 3: Build artifacts // Step 3: Build artifacts
artifacts, err := buildArtifacts(ctx, cfg, absProjectDir, version) artifacts, err := buildArtifacts(ctx, m, cfg, absProjectDir, version)
if err != nil { if err != nil {
return nil, fmt.Errorf("release.Run: build failed: %w", err) return nil, fmt.Errorf("release.Run: build failed: %w", err)
} }
@ -181,12 +188,13 @@ func Run(ctx context.Context, cfg *Config, dryRun bool) (*Release, error) {
Artifacts: artifacts, Artifacts: artifacts,
Changelog: changelog, Changelog: changelog,
ProjectDir: absProjectDir, ProjectDir: absProjectDir,
FS: m,
} }
// Step 4: Publish to configured targets // Step 4: Publish to configured targets
if len(cfg.Publishers) > 0 { if len(cfg.Publishers) > 0 {
// Convert to publisher types // Convert to publisher types
pubRelease := publishers.NewRelease(release.Version, release.Artifacts, release.Changelog, release.ProjectDir) pubRelease := publishers.NewRelease(release.Version, release.Artifacts, release.Changelog, release.ProjectDir, release.FS)
for _, pubCfg := range cfg.Publishers { for _, pubCfg := range cfg.Publishers {
publisher, err := getPublisher(pubCfg.Type) publisher, err := getPublisher(pubCfg.Type)
@ -207,9 +215,9 @@ func Run(ctx context.Context, cfg *Config, dryRun bool) (*Release, error) {
} }
// buildArtifacts builds all artifacts for the release. // buildArtifacts builds all artifacts for the release.
func buildArtifacts(ctx context.Context, cfg *Config, projectDir, version string) ([]build.Artifact, error) { func buildArtifacts(ctx context.Context, fs io.Medium, cfg *Config, projectDir, version string) ([]build.Artifact, error) {
// Load build configuration // Load build configuration
buildCfg, err := build.LoadConfig(projectDir) buildCfg, err := build.LoadConfig(fs, projectDir)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to load build config: %w", err) return nil, fmt.Errorf("failed to load build config: %w", err)
} }
@ -227,7 +235,6 @@ func buildArtifacts(ctx context.Context, cfg *Config, projectDir, version string
targets = []build.Target{ targets = []build.Target{
{OS: "linux", Arch: "amd64"}, {OS: "linux", Arch: "amd64"},
{OS: "linux", Arch: "arm64"}, {OS: "linux", Arch: "arm64"},
{OS: "darwin", Arch: "amd64"},
{OS: "darwin", Arch: "arm64"}, {OS: "darwin", Arch: "arm64"},
{OS: "windows", Arch: "amd64"}, {OS: "windows", Arch: "amd64"},
} }
@ -249,7 +256,7 @@ func buildArtifacts(ctx context.Context, cfg *Config, projectDir, version string
outputDir := filepath.Join(projectDir, "dist") outputDir := filepath.Join(projectDir, "dist")
// Get builder (detect project type) // Get builder (detect project type)
projectType, err := build.PrimaryType(projectDir) projectType, err := build.PrimaryType(fs, projectDir)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to detect project type: %w", err) return nil, fmt.Errorf("failed to detect project type: %w", err)
} }
@ -261,6 +268,7 @@ func buildArtifacts(ctx context.Context, cfg *Config, projectDir, version string
// Build configuration // Build configuration
buildConfig := &build.Config{ buildConfig := &build.Config{
FS: fs,
ProjectDir: projectDir, ProjectDir: projectDir,
OutputDir: outputDir, OutputDir: outputDir,
Name: binaryName, Name: binaryName,
@ -275,20 +283,20 @@ func buildArtifacts(ctx context.Context, cfg *Config, projectDir, version string
} }
// Archive artifacts // Archive artifacts
archivedArtifacts, err := build.ArchiveAll(artifacts) archivedArtifacts, err := build.ArchiveAll(fs, artifacts)
if err != nil { if err != nil {
return nil, fmt.Errorf("archive failed: %w", err) return nil, fmt.Errorf("archive failed: %w", err)
} }
// Compute checksums // Compute checksums
checksummedArtifacts, err := build.ChecksumAll(archivedArtifacts) checksummedArtifacts, err := build.ChecksumAll(fs, archivedArtifacts)
if err != nil { if err != nil {
return nil, fmt.Errorf("checksum failed: %w", err) return nil, fmt.Errorf("checksum failed: %w", err)
} }
// Write CHECKSUMS.txt // Write CHECKSUMS.txt
checksumPath := filepath.Join(outputDir, "CHECKSUMS.txt") checksumPath := filepath.Join(outputDir, "CHECKSUMS.txt")
if err := build.WriteChecksumFile(checksummedArtifacts, checksumPath); err != nil { if err := build.WriteChecksumFile(fs, checksummedArtifacts, checksumPath); err != nil {
return nil, fmt.Errorf("failed to write checksums file: %w", err) return nil, fmt.Errorf("failed to write checksums file: %w", err)
} }
@ -309,7 +317,7 @@ func getBuilder(projectType build.ProjectType) (build.Builder, error) {
case build.ProjectTypeGo: case build.ProjectTypeGo:
return builders.NewGoBuilder(), nil return builders.NewGoBuilder(), nil
case build.ProjectTypeNode: case build.ProjectTypeNode:
return nil, fmt.Errorf("Node.js builder not yet implemented") return nil, fmt.Errorf("node.js builder not yet implemented")
case build.ProjectTypePHP: case build.ProjectTypePHP:
return nil, fmt.Errorf("PHP builder not yet implemented") return nil, fmt.Errorf("PHP builder not yet implemented")
default: default: