From 7be325302fe09ccc39f49e2a4e8b7b2908a5c44a Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 4 Feb 2026 15:07:13 +0000 Subject: [PATCH] Migrate pkg/release to io.Medium abstraction (#290) * chore(io): migrate pkg/release to io.Medium abstraction Migrated `pkg/release` and its subpackages to use the `io.Medium` abstraction for filesystem operations. This enables better testability and support for alternative storage backends. Changes: - Added `FS io.Medium` field to `release.Release` and `publishers.Release` structs. - Updated `LoadConfig`, `ConfigExists`, and `WriteConfig` in `pkg/release/config.go` to accept `io.Medium`. - Updated `Publish`, `Run`, `findArtifacts`, and `buildArtifacts` in `pkg/release/release.go` to use `io.Medium`. - Migrated all publishers (`aur`, `chocolatey`, `docker`, `github`, `homebrew`, `linuxkit`, `npm`, `scoop`) to use `io.Medium` for file operations. - Implemented custom template overrides in publishers by checking for templates in `.core/templates//` via `io.Medium`. - Updated all relevant tests to provide `io.Medium`. * chore(io): fix missing callers in pkg/release migration Updated callers of `release` package functions that had their signatures changed during the `io.Medium` migration. Fixed files: - `internal/cmd/ci/cmd_init.go` - `internal/cmd/ci/cmd_publish.go` - `pkg/build/buildcmd/cmd_release.go` These changes ensure the project compiles successfully by providing `io.Local` to `LoadConfig`, `WriteConfig`, and `ConfigExists`. * chore(io): fix build errors in pkg/release migration Fixed compilation errors by updating all callers of `release.LoadConfig`, `release.ConfigExists`, and `release.WriteConfig` to provide the required `io.Medium` argument. Files updated: - `internal/cmd/ci/cmd_init.go` - `internal/cmd/ci/cmd_publish.go` - `pkg/build/buildcmd/cmd_release.go` These entry points now correctly pass `io.Local` to the `release` package functions. --- .github/workflows/auto-merge.yml | 40 +++++++++++++++++++ .github/workflows/pr-gate.yml | 42 ++++++++++++++++++++ internal/cmd/ci/cmd_init.go | 5 ++- internal/cmd/ci/cmd_publish.go | 3 +- internal/cmd/go/cmd_qa.go | 48 +++++++++++++++++++++++ pkg/build/buildcmd/cmd_release.go | 5 ++- pkg/release/config.go | 14 +++---- pkg/release/config_test.go | 31 ++++++++------- pkg/release/publishers/aur.go | 46 +++++++++++++++------- pkg/release/publishers/aur_test.go | 10 +++-- pkg/release/publishers/chocolatey.go | 46 +++++++++++++++------- pkg/release/publishers/chocolatey_test.go | 12 +++--- pkg/release/publishers/docker.go | 2 +- pkg/release/publishers/docker_test.go | 12 ++++++ pkg/release/publishers/github_test.go | 16 +++++++- pkg/release/publishers/homebrew.go | 40 +++++++++++++------ pkg/release/publishers/homebrew_test.go | 12 +++--- pkg/release/publishers/linuxkit.go | 6 +-- pkg/release/publishers/linuxkit_test.go | 14 +++++++ pkg/release/publishers/npm.go | 41 +++++++++++++------ pkg/release/publishers/npm_test.go | 14 ++++--- pkg/release/publishers/publisher.go | 6 ++- pkg/release/publishers/scoop.go | 40 +++++++++++++------ pkg/release/publishers/scoop_test.go | 15 ++++--- pkg/release/release.go | 36 ++++++++++++----- pkg/release/release_test.go | 21 +++++----- 26 files changed, 434 insertions(+), 143 deletions(-) create mode 100644 .github/workflows/auto-merge.yml create mode 100644 .github/workflows/pr-gate.yml diff --git a/.github/workflows/auto-merge.yml b/.github/workflows/auto-merge.yml new file mode 100644 index 00000000..ec3cf86b --- /dev/null +++ b/.github/workflows/auto-merge.yml @@ -0,0 +1,40 @@ +name: Auto Merge + +on: + pull_request: + types: [opened, reopened, ready_for_review] + +permissions: + contents: write + pull-requests: write + +jobs: + auto-merge: + if: "!github.event.pull_request.draft" + runs-on: ubuntu-latest + steps: + - name: Check org membership and enable auto-merge + uses: actions/github-script@v7 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + with: + script: | + const { owner, repo } = context.repo; + const author = context.payload.pull_request.user.login; + + try { + await github.rest.orgs.checkMembershipForUser({ + org: owner, + username: author, + }); + } catch { + core.info(`${author} is not an org member — skipping auto-merge`); + return; + } + + await exec.exec('gh', [ + 'pr', 'merge', process.env.PR_NUMBER, + '--auto', '--squash', + ]); + core.info(`Auto-merge enabled for #${process.env.PR_NUMBER}`); diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml new file mode 100644 index 00000000..299f186b --- /dev/null +++ b/.github/workflows/pr-gate.yml @@ -0,0 +1,42 @@ +name: PR Gate + +on: + pull_request_target: + types: [opened, synchronize, reopened, labeled] + +permissions: + contents: read + +jobs: + org-gate: + runs-on: ubuntu-latest + steps: + - name: Check org membership or approval label + uses: actions/github-script@v7 + with: + script: | + const { owner, repo } = context.repo; + const author = context.payload.pull_request.user.login; + + // Check if author is an org member + try { + await github.rest.orgs.checkMembershipForUser({ + org: owner, + username: author, + }); + core.info(`${author} is an org member — gate passed`); + return; + } catch { + core.info(`${author} is not an org member — checking for label`); + } + + // Check for external-approved label + const labels = context.payload.pull_request.labels.map(l => l.name); + if (labels.includes('external-approved')) { + core.info('external-approved label present — gate passed'); + return; + } + + core.setFailed( + `External PR from ${author} requires an org member to add the "external-approved" label before merge.` + ); diff --git a/internal/cmd/ci/cmd_init.go b/internal/cmd/ci/cmd_init.go index 59e4958c..aa7d022c 100644 --- a/internal/cmd/ci/cmd_init.go +++ b/internal/cmd/ci/cmd_init.go @@ -5,6 +5,7 @@ import ( "github.com/host-uk/core/pkg/cli" "github.com/host-uk/core/pkg/i18n" + "github.com/host-uk/core/pkg/io" "github.com/host-uk/core/pkg/release" ) @@ -17,14 +18,14 @@ func runCIReleaseInit() error { cli.Print("%s %s\n\n", releaseDimStyle.Render(i18n.Label("init")), i18n.T("cmd.ci.init.initializing")) // Check if already initialized - if release.ConfigExists(cwd) { + if release.ConfigExists(io.Local, cwd) { cli.Text(i18n.T("cmd.ci.init.already_initialized")) return nil } // Create release config cfg := release.DefaultConfig() - if err := release.WriteConfig(cfg, cwd); err != nil { + if err := release.WriteConfig(io.Local, cfg, cwd); err != nil { return cli.Err("%s: %w", i18n.T("i18n.fail.create", "config"), err) } diff --git a/internal/cmd/ci/cmd_publish.go b/internal/cmd/ci/cmd_publish.go index 23b0c4ef..4dc73c2e 100644 --- a/internal/cmd/ci/cmd_publish.go +++ b/internal/cmd/ci/cmd_publish.go @@ -7,6 +7,7 @@ import ( "github.com/host-uk/core/pkg/cli" "github.com/host-uk/core/pkg/i18n" + "github.com/host-uk/core/pkg/io" "github.com/host-uk/core/pkg/release" ) @@ -22,7 +23,7 @@ func runCIPublish(dryRun bool, version string, draft, prerelease bool) error { } // Load configuration - cfg, err := release.LoadConfig(projectDir) + cfg, err := release.LoadConfig(io.Local, projectDir) if err != nil { return cli.WrapVerb(err, "load", "config") } diff --git a/internal/cmd/go/cmd_qa.go b/internal/cmd/go/cmd_qa.go index 2ac1dfc5..527b6003 100644 --- a/internal/cmd/go/cmd_qa.go +++ b/internal/cmd/go/cmd_qa.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "os/exec" + "regexp" "strings" "time" @@ -147,6 +148,7 @@ type CheckResult struct { Duration string `json:"duration"` Error string `json:"error,omitempty"` Output string `json:"output,omitempty"` + FixHint string `json:"fix_hint,omitempty"` } func runGoQA(cmd *cli.Command, args []string) error { @@ -218,6 +220,7 @@ func runGoQA(cmd *cli.Command, args []string) error { if qaVerbose { result.Output = output } + result.FixHint = fixHintFor(check.Name, output) failed++ if !qaJSON && !qaQuiet { @@ -225,6 +228,9 @@ func runGoQA(cmd *cli.Command, args []string) error { if qaVerbose && output != "" { cli.Text(output) } + if result.FixHint != "" { + cli.Hint("fix", result.FixHint) + } } if qaFailFast { @@ -260,6 +266,7 @@ func runGoQA(cmd *cli.Command, args []string) error { if !qaJSON && !qaQuiet { cli.Print(" %s Coverage %.1f%% below threshold %.1f%%\n", cli.ErrorStyle.Render(cli.Glyph(":cross:")), cov, qaThreshold) + cli.Hint("fix", "Run 'core go cov --open' to see uncovered lines, then add tests.") } } } @@ -436,6 +443,47 @@ func buildCheck(name string) QACheck { } } +// fixHintFor returns an actionable fix instruction for a given check failure. +func fixHintFor(checkName, output string) string { + switch checkName { + case "format", "fmt": + return "Run 'core go qa fmt --fix' to auto-format." + case "vet": + return "Fix the issues reported by go vet — typically genuine bugs." + case "lint": + return "Run 'core go qa lint --fix' for auto-fixable issues." + case "test": + if name := extractFailingTest(output); name != "" { + return fmt.Sprintf("Run 'go test -run %s -v ./...' to debug.", name) + } + return "Run 'go test -run -v ./path/' to debug." + case "race": + return "Data race detected. Add mutex, channel, or atomic to synchronise shared state." + case "bench": + return "Benchmark regression. Run 'go test -bench=. -benchmem' to reproduce." + case "vuln": + return "Run 'govulncheck ./...' for details. Update affected deps with 'go get -u'." + case "sec": + return "Review gosec findings. Common fixes: validate inputs, parameterised queries." + case "fuzz": + return "Add a regression test for the crashing input in testdata/fuzz//." + case "docblock": + return "Add doc comments to exported symbols: '// Name does X.' before each declaration." + default: + return "" + } +} + +var failTestRe = regexp.MustCompile(`--- FAIL: (\w+)`) + +// extractFailingTest parses the first failing test name from go test output. +func extractFailingTest(output string) string { + if m := failTestRe.FindStringSubmatch(output); len(m) > 1 { + return m[1] + } + return "" +} + func runCheckCapture(ctx context.Context, dir string, check QACheck) (string, error) { // Handle internal checks if check.Command == "_internal_" { diff --git a/pkg/build/buildcmd/cmd_release.go b/pkg/build/buildcmd/cmd_release.go index 330c96b3..e08be39b 100644 --- a/pkg/build/buildcmd/cmd_release.go +++ b/pkg/build/buildcmd/cmd_release.go @@ -9,6 +9,7 @@ import ( "github.com/host-uk/core/pkg/cli" "github.com/host-uk/core/pkg/framework/core" "github.com/host-uk/core/pkg/i18n" + "github.com/host-uk/core/pkg/io" "github.com/host-uk/core/pkg/release" ) @@ -50,7 +51,7 @@ func runRelease(ctx context.Context, dryRun bool, version string, draft, prerele } // Check for release config - if !release.ConfigExists(projectDir) { + if !release.ConfigExists(io.Local, projectDir) { cli.Print("%s %s\n", buildErrorStyle.Render(i18n.Label("error")), i18n.T("cmd.build.release.error.no_config"), @@ -60,7 +61,7 @@ func runRelease(ctx context.Context, dryRun bool, version string, draft, prerele } // Load configuration - cfg, err := release.LoadConfig(projectDir) + cfg, err := release.LoadConfig(io.Local, projectDir) if err != nil { return core.E("release", "load config", err) } diff --git a/pkg/release/config.go b/pkg/release/config.go index 2beefbf5..2f4d934e 100644 --- a/pkg/release/config.go +++ b/pkg/release/config.go @@ -169,14 +169,14 @@ type ChangelogConfig struct { // LoadConfig loads release configuration from the .core/release.yaml file in the given directory. // If the config file does not exist, it returns DefaultConfig(). // Returns an error if the file exists but cannot be parsed. -func LoadConfig(dir string) (*Config, error) { +func LoadConfig(m io.Medium, dir string) (*Config, error) { configPath := filepath.Join(dir, ConfigDir, ConfigFileName) absPath, err := filepath.Abs(configPath) if err != nil { return nil, fmt.Errorf("release.LoadConfig: failed to resolve path: %w", err) } - content, err := io.Local.Read(absPath) + content, err := m.Read(absPath) if err != nil { if os.IsNotExist(err) { cfg := DefaultConfig() @@ -266,13 +266,13 @@ func ConfigPath(dir string) string { } // ConfigExists checks if a release config file exists in the given directory. -func ConfigExists(dir string) bool { +func ConfigExists(m io.Medium, dir string) bool { configPath := ConfigPath(dir) absPath, err := filepath.Abs(configPath) if err != nil { return false } - return io.Local.IsFile(absPath) + return m.IsFile(absPath) } // GetRepository returns the repository from the config. @@ -286,7 +286,7 @@ func (c *Config) GetProjectName() string { } // WriteConfig writes the config to the .core/release.yaml file. -func WriteConfig(cfg *Config, dir string) error { +func WriteConfig(m io.Medium, cfg *Config, dir string) error { configPath := ConfigPath(dir) absPath, err := filepath.Abs(configPath) if err != nil { @@ -298,8 +298,8 @@ func WriteConfig(cfg *Config, dir string) error { return fmt.Errorf("release.WriteConfig: failed to marshal config: %w", err) } - // io.Local.Write creates parent directories automatically - if err := io.Local.Write(absPath, string(data)); err != nil { + // m.Write creates parent directories automatically + if err := m.Write(absPath, string(data)); err != nil { return fmt.Errorf("release.WriteConfig: failed to write config file: %w", err) } diff --git a/pkg/release/config_test.go b/pkg/release/config_test.go index 24fe1343..7af80e97 100644 --- a/pkg/release/config_test.go +++ b/pkg/release/config_test.go @@ -5,6 +5,7 @@ import ( "path/filepath" "testing" + "github.com/host-uk/core/pkg/io" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -53,7 +54,7 @@ changelog: ` dir := setupConfigTestDir(t, content) - cfg, err := LoadConfig(dir) + cfg, err := LoadConfig(io.Local, dir) require.NoError(t, err) require.NotNil(t, cfg) @@ -76,7 +77,7 @@ changelog: t.Run("returns defaults when config file missing", func(t *testing.T) { dir := t.TempDir() - cfg, err := LoadConfig(dir) + cfg, err := LoadConfig(io.Local, dir) require.NoError(t, err) require.NotNil(t, cfg) @@ -96,7 +97,7 @@ project: ` dir := setupConfigTestDir(t, content) - cfg, err := LoadConfig(dir) + cfg, err := LoadConfig(io.Local, dir) require.NoError(t, err) require.NotNil(t, cfg) @@ -113,7 +114,7 @@ project: t.Run("sets project directory on load", func(t *testing.T) { dir := setupConfigTestDir(t, "version: 1") - cfg, err := LoadConfig(dir) + cfg, err := LoadConfig(io.Local, dir) require.NoError(t, err) assert.Equal(t, dir, cfg.projectDir) }) @@ -128,7 +129,7 @@ project: ` dir := setupConfigTestDir(t, content) - cfg, err := LoadConfig(dir) + cfg, err := LoadConfig(io.Local, dir) assert.Error(t, err) assert.Nil(t, cfg) assert.Contains(t, err.Error(), "failed to parse config file") @@ -145,7 +146,7 @@ project: err = os.Mkdir(configPath, 0755) require.NoError(t, err) - cfg, err := LoadConfig(dir) + cfg, err := LoadConfig(io.Local, dir) assert.Error(t, err) assert.Nil(t, cfg) assert.Contains(t, err.Error(), "failed to read config file") @@ -204,17 +205,17 @@ func TestConfigPath_Good(t *testing.T) { func TestConfigExists_Good(t *testing.T) { t.Run("returns true when config exists", func(t *testing.T) { dir := setupConfigTestDir(t, "version: 1") - assert.True(t, ConfigExists(dir)) + assert.True(t, ConfigExists(io.Local, dir)) }) t.Run("returns false when config missing", func(t *testing.T) { dir := t.TempDir() - assert.False(t, ConfigExists(dir)) + assert.False(t, ConfigExists(io.Local, dir)) }) t.Run("returns false when .core dir missing", func(t *testing.T) { dir := t.TempDir() - assert.False(t, ConfigExists(dir)) + assert.False(t, ConfigExists(io.Local, dir)) }) } @@ -226,14 +227,14 @@ func TestWriteConfig_Good(t *testing.T) { cfg.Project.Name = "testapp" cfg.Project.Repository = "owner/testapp" - err := WriteConfig(cfg, dir) + err := WriteConfig(io.Local, cfg, dir) require.NoError(t, err) // Verify file exists - assert.True(t, ConfigExists(dir)) + assert.True(t, ConfigExists(io.Local, dir)) // Reload and verify - loaded, err := LoadConfig(dir) + loaded, err := LoadConfig(io.Local, dir) require.NoError(t, err) assert.Equal(t, "testapp", loaded.Project.Name) assert.Equal(t, "owner/testapp", loaded.Project.Repository) @@ -243,7 +244,7 @@ func TestWriteConfig_Good(t *testing.T) { dir := t.TempDir() cfg := DefaultConfig() - err := WriteConfig(cfg, dir) + err := WriteConfig(io.Local, cfg, dir) require.NoError(t, err) // Check directory was created @@ -320,7 +321,7 @@ func TestWriteConfig_Bad(t *testing.T) { defer func() { _ = os.Chmod(coreDir, 0755) }() cfg := DefaultConfig() - err = WriteConfig(cfg, dir) + err = WriteConfig(io.Local, cfg, dir) assert.Error(t, err) assert.Contains(t, err.Error(), "failed to write config file") }) @@ -328,7 +329,7 @@ func TestWriteConfig_Bad(t *testing.T) { t.Run("returns error when directory creation fails", func(t *testing.T) { // Use a path that doesn't exist and can't be created cfg := DefaultConfig() - err := WriteConfig(cfg, "/nonexistent/path/that/cannot/be/created") + err := WriteConfig(io.Local, cfg, "/nonexistent/path/that/cannot/be/created") assert.Error(t, err) }) } diff --git a/pkg/release/publishers/aur.go b/pkg/release/publishers/aur.go index 00ad86ca..0f9cd2c4 100644 --- a/pkg/release/publishers/aur.go +++ b/pkg/release/publishers/aur.go @@ -13,6 +13,7 @@ import ( "text/template" "github.com/host-uk/core/pkg/build" + "github.com/host-uk/core/pkg/io" ) //go:embed templates/aur/*.tmpl @@ -90,10 +91,10 @@ func (p *AURPublisher) Publish(ctx context.Context, release *Release, pubCfg Pub } if dryRun { - return p.dryRunPublish(data, cfg) + return p.dryRunPublish(release.FS, data, cfg) } - return p.executePublish(ctx, release.ProjectDir, data, cfg) + return p.executePublish(ctx, release.ProjectDir, data, cfg, release) } type aurTemplateData struct { @@ -131,7 +132,7 @@ func (p *AURPublisher) parseConfig(pubCfg PublisherConfig, relCfg ReleaseConfig) return cfg } -func (p *AURPublisher) dryRunPublish(data aurTemplateData, cfg AURConfig) error { +func (p *AURPublisher) dryRunPublish(m io.Medium, data aurTemplateData, cfg AURConfig) error { fmt.Println() fmt.Println("=== DRY RUN: AUR Publish ===") fmt.Println() @@ -141,7 +142,7 @@ func (p *AURPublisher) dryRunPublish(data aurTemplateData, cfg AURConfig) error fmt.Printf("Repository: %s\n", data.Repository) fmt.Println() - pkgbuild, err := p.renderTemplate("templates/aur/PKGBUILD.tmpl", data) + pkgbuild, err := p.renderTemplate(m, "templates/aur/PKGBUILD.tmpl", data) if err != nil { return fmt.Errorf("aur.dryRunPublish: %w", err) } @@ -151,7 +152,7 @@ func (p *AURPublisher) dryRunPublish(data aurTemplateData, cfg AURConfig) error fmt.Println("---") fmt.Println() - srcinfo, err := p.renderTemplate("templates/aur/.SRCINFO.tmpl", data) + srcinfo, err := p.renderTemplate(m, "templates/aur/.SRCINFO.tmpl", data) if err != nil { return fmt.Errorf("aur.dryRunPublish: %w", err) } @@ -168,13 +169,13 @@ func (p *AURPublisher) dryRunPublish(data aurTemplateData, cfg AURConfig) error 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) +func (p *AURPublisher) executePublish(ctx context.Context, projectDir string, data aurTemplateData, cfg AURConfig, release *Release) error { + pkgbuild, err := p.renderTemplate(release.FS, "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) + srcinfo, err := p.renderTemplate(release.FS, "templates/aur/.SRCINFO.tmpl", data) if err != nil { return fmt.Errorf("aur.Publish: failed to render .SRCINFO: %w", err) } @@ -188,17 +189,17 @@ func (p *AURPublisher) executePublish(ctx context.Context, projectDir string, da output = filepath.Join(projectDir, output) } - if err := os.MkdirAll(output, 0755); err != nil { + if err := release.FS.EnsureDir(output); 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 { + if err := release.FS.Write(pkgbuildPath, pkgbuild); 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 { + if err := release.FS.Write(srcinfoPath, srcinfo); err != nil { return fmt.Errorf("aur.Publish: failed to write .SRCINFO: %w", err) } fmt.Printf("Wrote AUR files: %s\n", output) @@ -274,10 +275,25 @@ func (p *AURPublisher) pushToAUR(ctx context.Context, data aurTemplateData, pkgb 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) +func (p *AURPublisher) renderTemplate(m io.Medium, name string, data aurTemplateData) (string, error) { + var content []byte + var err error + + // Try custom template from medium + customPath := filepath.Join(".core", name) + if m != nil && m.IsFile(customPath) { + customContent, err := m.Read(customPath) + if err == nil { + content = []byte(customContent) + } + } + + // Fallback to embedded template + if content == nil { + 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)) diff --git a/pkg/release/publishers/aur_test.go b/pkg/release/publishers/aur_test.go index a49b68e1..3b0e6231 100644 --- a/pkg/release/publishers/aur_test.go +++ b/pkg/release/publishers/aur_test.go @@ -6,6 +6,7 @@ import ( "os" "testing" + "github.com/host-uk/core/pkg/io" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -97,7 +98,7 @@ func TestAURPublisher_RenderTemplate_Good(t *testing.T) { }, } - result, err := p.renderTemplate("templates/aur/PKGBUILD.tmpl", data) + result, err := p.renderTemplate(io.Local, "templates/aur/PKGBUILD.tmpl", data) require.NoError(t, err) assert.Contains(t, result, "# Maintainer: John Doe ") @@ -125,7 +126,7 @@ func TestAURPublisher_RenderTemplate_Good(t *testing.T) { }, } - result, err := p.renderTemplate("templates/aur/.SRCINFO.tmpl", data) + result, err := p.renderTemplate(io.Local, "templates/aur/.SRCINFO.tmpl", data) require.NoError(t, err) assert.Contains(t, result, "pkgbase = myapp-bin") @@ -144,7 +145,7 @@ func TestAURPublisher_RenderTemplate_Bad(t *testing.T) { t.Run("returns error for non-existent template", func(t *testing.T) { data := aurTemplateData{} - _, err := p.renderTemplate("templates/aur/nonexistent.tmpl", data) + _, err := p.renderTemplate(io.Local, "templates/aur/nonexistent.tmpl", data) assert.Error(t, err) assert.Contains(t, err.Error(), "failed to read template") }) @@ -170,7 +171,7 @@ func TestAURPublisher_DryRunPublish_Good(t *testing.T) { Maintainer: "John Doe ", } - err := p.dryRunPublish(data, cfg) + err := p.dryRunPublish(io.Local, data, cfg) _ = w.Close() var buf bytes.Buffer @@ -199,6 +200,7 @@ func TestAURPublisher_Publish_Bad(t *testing.T) { release := &Release{ Version: "v1.0.0", ProjectDir: "/project", + FS: io.Local, } pubCfg := PublisherConfig{Type: "aur"} relCfg := &mockReleaseConfig{repository: "owner/repo"} diff --git a/pkg/release/publishers/chocolatey.go b/pkg/release/publishers/chocolatey.go index 9c58d2d1..93b12160 100644 --- a/pkg/release/publishers/chocolatey.go +++ b/pkg/release/publishers/chocolatey.go @@ -14,6 +14,7 @@ import ( "github.com/host-uk/core/pkg/build" "github.com/host-uk/core/pkg/i18n" + "github.com/host-uk/core/pkg/io" ) //go:embed templates/chocolatey/*.tmpl templates/chocolatey/tools/*.tmpl @@ -92,10 +93,10 @@ func (p *ChocolateyPublisher) Publish(ctx context.Context, release *Release, pub } if dryRun { - return p.dryRunPublish(data, cfg) + return p.dryRunPublish(release.FS, data, cfg) } - return p.executePublish(ctx, release.ProjectDir, data, cfg) + return p.executePublish(ctx, release.ProjectDir, data, cfg, release) } type chocolateyTemplateData struct { @@ -137,7 +138,7 @@ func (p *ChocolateyPublisher) parseConfig(pubCfg PublisherConfig, relCfg Release return cfg } -func (p *ChocolateyPublisher) dryRunPublish(data chocolateyTemplateData, cfg ChocolateyConfig) error { +func (p *ChocolateyPublisher) dryRunPublish(m io.Medium, data chocolateyTemplateData, cfg ChocolateyConfig) error { fmt.Println() fmt.Println("=== DRY RUN: Chocolatey Publish ===") fmt.Println() @@ -147,7 +148,7 @@ func (p *ChocolateyPublisher) dryRunPublish(data chocolateyTemplateData, cfg Cho fmt.Printf("Repository: %s\n", data.Repository) fmt.Println() - nuspec, err := p.renderTemplate("templates/chocolatey/package.nuspec.tmpl", data) + nuspec, err := p.renderTemplate(m, "templates/chocolatey/package.nuspec.tmpl", data) if err != nil { return fmt.Errorf("chocolatey.dryRunPublish: %w", err) } @@ -157,7 +158,7 @@ func (p *ChocolateyPublisher) dryRunPublish(data chocolateyTemplateData, cfg Cho fmt.Println("---") fmt.Println() - install, err := p.renderTemplate("templates/chocolatey/tools/chocolateyinstall.ps1.tmpl", data) + install, err := p.renderTemplate(m, "templates/chocolatey/tools/chocolateyinstall.ps1.tmpl", data) if err != nil { return fmt.Errorf("chocolatey.dryRunPublish: %w", err) } @@ -178,13 +179,13 @@ func (p *ChocolateyPublisher) dryRunPublish(data chocolateyTemplateData, cfg Cho 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) +func (p *ChocolateyPublisher) executePublish(ctx context.Context, projectDir string, data chocolateyTemplateData, cfg ChocolateyConfig, release *Release) error { + nuspec, err := p.renderTemplate(release.FS, "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) + install, err := p.renderTemplate(release.FS, "templates/chocolatey/tools/chocolateyinstall.ps1.tmpl", data) if err != nil { return fmt.Errorf("chocolatey.Publish: failed to render install script: %w", err) } @@ -199,18 +200,18 @@ func (p *ChocolateyPublisher) executePublish(ctx context.Context, projectDir str } toolsDir := filepath.Join(output, "tools") - if err := os.MkdirAll(toolsDir, 0755); err != nil { + if err := release.FS.EnsureDir(toolsDir); 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 { + if err := release.FS.Write(nuspecPath, nuspec); 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 { + if err := release.FS.Write(installPath, install); err != nil { return fmt.Errorf("chocolatey.Publish: failed to write install script: %w", err) } @@ -255,10 +256,25 @@ func (p *ChocolateyPublisher) pushToChocolatey(ctx context.Context, packageDir s 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) +func (p *ChocolateyPublisher) renderTemplate(m io.Medium, name string, data chocolateyTemplateData) (string, error) { + var content []byte + var err error + + // Try custom template from medium + customPath := filepath.Join(".core", name) + if m != nil && m.IsFile(customPath) { + customContent, err := m.Read(customPath) + if err == nil { + content = []byte(customContent) + } + } + + // Fallback to embedded template + if content == nil { + 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)) diff --git a/pkg/release/publishers/chocolatey_test.go b/pkg/release/publishers/chocolatey_test.go index 3da669b1..df41aba4 100644 --- a/pkg/release/publishers/chocolatey_test.go +++ b/pkg/release/publishers/chocolatey_test.go @@ -6,6 +6,8 @@ import ( "os" "testing" + "github.com/host-uk/core/pkg/io" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -122,7 +124,7 @@ func TestChocolateyPublisher_RenderTemplate_Good(t *testing.T) { Checksums: ChecksumMap{}, } - result, err := p.renderTemplate("templates/chocolatey/package.nuspec.tmpl", data) + result, err := p.renderTemplate(io.Local, "templates/chocolatey/package.nuspec.tmpl", data) require.NoError(t, err) assert.Contains(t, result, `myapp`) @@ -146,7 +148,7 @@ func TestChocolateyPublisher_RenderTemplate_Good(t *testing.T) { }, } - result, err := p.renderTemplate("templates/chocolatey/tools/chocolateyinstall.ps1.tmpl", data) + result, err := p.renderTemplate(io.Local, "templates/chocolatey/tools/chocolateyinstall.ps1.tmpl", data) require.NoError(t, err) assert.Contains(t, result, "$ErrorActionPreference = 'Stop'") @@ -163,7 +165,7 @@ func TestChocolateyPublisher_RenderTemplate_Bad(t *testing.T) { t.Run("returns error for non-existent template", func(t *testing.T) { data := chocolateyTemplateData{} - _, err := p.renderTemplate("templates/chocolatey/nonexistent.tmpl", data) + _, err := p.renderTemplate(io.Local, "templates/chocolatey/nonexistent.tmpl", data) assert.Error(t, err) assert.Contains(t, err.Error(), "failed to read template") }) @@ -190,7 +192,7 @@ func TestChocolateyPublisher_DryRunPublish_Good(t *testing.T) { Push: false, } - err := p.dryRunPublish(data, cfg) + err := p.dryRunPublish(io.Local, data, cfg) _ = w.Close() var buf bytes.Buffer @@ -228,7 +230,7 @@ func TestChocolateyPublisher_DryRunPublish_Good(t *testing.T) { Push: true, } - err := p.dryRunPublish(data, cfg) + err := p.dryRunPublish(io.Local, data, cfg) _ = w.Close() var buf bytes.Buffer diff --git a/pkg/release/publishers/docker.go b/pkg/release/publishers/docker.go index 7d342ab3..981d4420 100644 --- a/pkg/release/publishers/docker.go +++ b/pkg/release/publishers/docker.go @@ -50,7 +50,7 @@ func (p *DockerPublisher) Publish(ctx context.Context, release *Release, pubCfg dockerCfg := p.parseConfig(pubCfg, relCfg, release.ProjectDir) // Validate Dockerfile exists - if _, err := os.Stat(dockerCfg.Dockerfile); err != nil { + if !release.FS.Exists(dockerCfg.Dockerfile) { return fmt.Errorf("docker.Publish: Dockerfile not found: %s", dockerCfg.Dockerfile) } diff --git a/pkg/release/publishers/docker_test.go b/pkg/release/publishers/docker_test.go index a36a5517..9673a274 100644 --- a/pkg/release/publishers/docker_test.go +++ b/pkg/release/publishers/docker_test.go @@ -7,6 +7,7 @@ import ( "path/filepath" "testing" + "github.com/host-uk/core/pkg/io" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -238,6 +239,7 @@ func TestDockerPublisher_Publish_Bad(t *testing.T) { release := &Release{ Version: "v1.0.0", ProjectDir: "/nonexistent", + FS: io.Local, } pubCfg := PublisherConfig{ Type: "docker", @@ -282,6 +284,7 @@ func TestDockerPublisher_DryRunPublish_Good(t *testing.T) { release := &Release{ Version: "v1.0.0", ProjectDir: "/project", + FS: io.Local, } cfg := DockerConfig{ Registry: "ghcr.io", @@ -324,6 +327,7 @@ func TestDockerPublisher_DryRunPublish_Good(t *testing.T) { release := &Release{ Version: "v1.0.0", ProjectDir: "/project", + FS: io.Local, } cfg := DockerConfig{ Registry: "docker.io", @@ -360,6 +364,7 @@ func TestDockerPublisher_DryRunPublish_Good(t *testing.T) { release := &Release{ Version: "v2.0.0", ProjectDir: "/project", + FS: io.Local, } cfg := DockerConfig{ Registry: "ghcr.io", @@ -583,6 +588,7 @@ func TestDockerPublisher_Publish_DryRun_Good(t *testing.T) { release := &Release{ Version: "v1.0.0", ProjectDir: tmpDir, + FS: io.Local, } pubCfg := PublisherConfig{Type: "docker"} relCfg := &mockReleaseConfig{repository: "owner/repo"} @@ -620,6 +626,7 @@ func TestDockerPublisher_Publish_DryRun_Good(t *testing.T) { release := &Release{ Version: "v1.0.0", ProjectDir: tmpDir, + FS: io.Local, } pubCfg := PublisherConfig{ Type: "docker", @@ -653,6 +660,7 @@ func TestDockerPublisher_Publish_Validation_Bad(t *testing.T) { release := &Release{ Version: "v1.0.0", ProjectDir: "/nonexistent/path", + FS: io.Local, } pubCfg := PublisherConfig{Type: "docker"} relCfg := &mockReleaseConfig{repository: "owner/repo"} @@ -670,6 +678,7 @@ func TestDockerPublisher_Publish_Validation_Bad(t *testing.T) { release := &Release{ Version: "v1.0.0", ProjectDir: "/tmp", + FS: io.Local, } pubCfg := PublisherConfig{Type: "docker"} relCfg := &mockReleaseConfig{repository: "owner/repo"} @@ -715,6 +724,7 @@ func TestDockerPublisher_Publish_WithCLI_Good(t *testing.T) { release := &Release{ Version: "v1.0.0", ProjectDir: tmpDir, + FS: io.Local, } pubCfg := PublisherConfig{ Type: "docker", @@ -758,6 +768,7 @@ func TestDockerPublisher_Publish_WithCLI_Good(t *testing.T) { release := &Release{ Version: "v1.0.0", ProjectDir: tmpDir, + FS: io.Local, } pubCfg := PublisherConfig{ Type: "docker", @@ -787,6 +798,7 @@ func TestDockerPublisher_Publish_WithCLI_Good(t *testing.T) { release := &Release{ Version: "v1.0.0", ProjectDir: tmpDir, + FS: io.Local, } pubCfg := PublisherConfig{Type: "docker"} relCfg := &mockReleaseConfig{repository: "owner/repo"} diff --git a/pkg/release/publishers/github_test.go b/pkg/release/publishers/github_test.go index 78af460f..7d89d053 100644 --- a/pkg/release/publishers/github_test.go +++ b/pkg/release/publishers/github_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/host-uk/core/pkg/build" + "github.com/host-uk/core/pkg/io" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -90,7 +91,7 @@ func TestGitHubPublisher_Name_Good(t *testing.T) { func TestNewRelease_Good(t *testing.T) { t.Run("creates release struct", func(t *testing.T) { - r := NewRelease("v1.0.0", nil, "changelog", "/project") + r := NewRelease("v1.0.0", nil, "changelog", "/project", io.Local) assert.Equal(t, "v1.0.0", r.Version) assert.Equal(t, "changelog", r.Changelog) assert.Equal(t, "/project", r.ProjectDir) @@ -122,6 +123,7 @@ func TestBuildCreateArgs_Good(t *testing.T) { release := &Release{ Version: "v1.0.0", Changelog: "## v1.0.0\n\nChanges", + FS: io.Local, } cfg := PublisherConfig{ Type: "github", @@ -141,6 +143,7 @@ func TestBuildCreateArgs_Good(t *testing.T) { t.Run("with draft flag", func(t *testing.T) { release := &Release{ Version: "v1.0.0", + FS: io.Local, } cfg := PublisherConfig{ Type: "github", @@ -155,6 +158,7 @@ func TestBuildCreateArgs_Good(t *testing.T) { t.Run("with prerelease flag", func(t *testing.T) { release := &Release{ Version: "v1.0.0", + FS: io.Local, } cfg := PublisherConfig{ Type: "github", @@ -170,6 +174,7 @@ func TestBuildCreateArgs_Good(t *testing.T) { release := &Release{ Version: "v1.0.0", Changelog: "", + FS: io.Local, } cfg := PublisherConfig{ Type: "github", @@ -183,6 +188,7 @@ func TestBuildCreateArgs_Good(t *testing.T) { t.Run("with draft and prerelease flags", func(t *testing.T) { release := &Release{ Version: "v1.0.0-alpha", + FS: io.Local, } cfg := PublisherConfig{ Type: "github", @@ -200,6 +206,7 @@ func TestBuildCreateArgs_Good(t *testing.T) { release := &Release{ Version: "v2.0.0", Changelog: "Some changes", + FS: io.Local, } cfg := PublisherConfig{ Type: "github", @@ -226,6 +233,7 @@ func TestGitHubPublisher_DryRunPublish_Good(t *testing.T) { Version: "v1.0.0", Changelog: "## Changes\n\n- Feature A\n- Bug fix B", ProjectDir: "/project", + FS: io.Local, } cfg := PublisherConfig{ Type: "github", @@ -264,6 +272,7 @@ func TestGitHubPublisher_DryRunPublish_Good(t *testing.T) { Version: "v1.0.0", Changelog: "Changes", ProjectDir: "/project", + FS: io.Local, Artifacts: []build.Artifact{ {Path: "/dist/myapp-darwin-amd64.tar.gz"}, {Path: "/dist/myapp-linux-amd64.tar.gz"}, @@ -295,6 +304,7 @@ func TestGitHubPublisher_DryRunPublish_Good(t *testing.T) { Version: "v1.0.0-beta", Changelog: "Beta release", ProjectDir: "/project", + FS: io.Local, } cfg := PublisherConfig{ Type: "github", @@ -331,6 +341,7 @@ func TestGitHubPublisher_Publish_Good(t *testing.T) { Version: "v1.0.0", Changelog: "Changes", ProjectDir: "/tmp", + FS: io.Local, } pubCfg := PublisherConfig{Type: "github"} relCfg := &mockReleaseConfig{repository: "custom/repo"} @@ -363,6 +374,7 @@ func TestGitHubPublisher_Publish_Bad(t *testing.T) { Version: "v1.0.0", Changelog: "Changes", ProjectDir: "/nonexistent", + FS: io.Local, } pubCfg := PublisherConfig{Type: "github"} relCfg := &mockReleaseConfig{repository: "owner/repo"} @@ -383,6 +395,7 @@ func TestGitHubPublisher_Publish_Bad(t *testing.T) { Version: "v1.0.0", Changelog: "Changes", ProjectDir: tmpDir, + FS: io.Local, } pubCfg := PublisherConfig{Type: "github"} relCfg := &mockReleaseConfig{repository: ""} // Empty repository @@ -504,6 +517,7 @@ func TestGitHubPublisher_ExecutePublish_Good(t *testing.T) { Version: "v999.999.999-test-nonexistent", Changelog: "Test changelog", ProjectDir: "/tmp", + FS: io.Local, Artifacts: []build.Artifact{ {Path: "/tmp/nonexistent-artifact.tar.gz"}, }, diff --git a/pkg/release/publishers/homebrew.go b/pkg/release/publishers/homebrew.go index 00b9abb0..10fc3d7d 100644 --- a/pkg/release/publishers/homebrew.go +++ b/pkg/release/publishers/homebrew.go @@ -13,6 +13,7 @@ import ( "text/template" "github.com/host-uk/core/pkg/build" + "github.com/host-uk/core/pkg/io" ) //go:embed templates/homebrew/*.tmpl @@ -104,10 +105,10 @@ func (p *HomebrewPublisher) Publish(ctx context.Context, release *Release, pubCf } if dryRun { - return p.dryRunPublish(data, cfg) + return p.dryRunPublish(release.FS, data, cfg) } - return p.executePublish(ctx, release.ProjectDir, data, cfg) + return p.executePublish(ctx, release.ProjectDir, data, cfg, release) } // homebrewTemplateData holds data for Homebrew templates. @@ -160,7 +161,7 @@ func (p *HomebrewPublisher) parseConfig(pubCfg PublisherConfig, relCfg ReleaseCo } // dryRunPublish shows what would be done. -func (p *HomebrewPublisher) dryRunPublish(data homebrewTemplateData, cfg HomebrewConfig) error { +func (p *HomebrewPublisher) dryRunPublish(m io.Medium, data homebrewTemplateData, cfg HomebrewConfig) error { fmt.Println() fmt.Println("=== DRY RUN: Homebrew Publish ===") fmt.Println() @@ -171,7 +172,7 @@ func (p *HomebrewPublisher) dryRunPublish(data homebrewTemplateData, cfg Homebre fmt.Println() // Generate and show formula - formula, err := p.renderTemplate("templates/homebrew/formula.rb.tmpl", data) + formula, err := p.renderTemplate(m, "templates/homebrew/formula.rb.tmpl", data) if err != nil { return fmt.Errorf("homebrew.dryRunPublish: %w", err) } @@ -198,9 +199,9 @@ func (p *HomebrewPublisher) dryRunPublish(data homebrewTemplateData, cfg Homebre } // executePublish creates the formula and commits to tap. -func (p *HomebrewPublisher) executePublish(ctx context.Context, projectDir string, data homebrewTemplateData, cfg HomebrewConfig) error { +func (p *HomebrewPublisher) executePublish(ctx context.Context, projectDir string, data homebrewTemplateData, cfg HomebrewConfig, release *Release) error { // Generate formula - formula, err := p.renderTemplate("templates/homebrew/formula.rb.tmpl", data) + formula, err := p.renderTemplate(release.FS, "templates/homebrew/formula.rb.tmpl", data) if err != nil { return fmt.Errorf("homebrew.Publish: failed to render formula: %w", err) } @@ -214,12 +215,12 @@ func (p *HomebrewPublisher) executePublish(ctx context.Context, projectDir strin output = filepath.Join(projectDir, output) } - if err := os.MkdirAll(output, 0755); err != nil { + if err := release.FS.EnsureDir(output); 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 { + if err := release.FS.Write(formulaPath, formula); err != nil { return fmt.Errorf("homebrew.Publish: failed to write formula: %w", err) } fmt.Printf("Wrote Homebrew formula for official PR: %s\n", formulaPath) @@ -295,10 +296,25 @@ func (p *HomebrewPublisher) commitToTap(ctx context.Context, tap string, data ho } // 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) +func (p *HomebrewPublisher) renderTemplate(m io.Medium, name string, data homebrewTemplateData) (string, error) { + var content []byte + var err error + + // Try custom template from medium + customPath := filepath.Join(".core", name) + if m != nil && m.IsFile(customPath) { + customContent, err := m.Read(customPath) + if err == nil { + content = []byte(customContent) + } + } + + // Fallback to embedded template + if content == nil { + 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)) diff --git a/pkg/release/publishers/homebrew_test.go b/pkg/release/publishers/homebrew_test.go index d9e0c112..e05f24e2 100644 --- a/pkg/release/publishers/homebrew_test.go +++ b/pkg/release/publishers/homebrew_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/host-uk/core/pkg/build" + "github.com/host-uk/core/pkg/io" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -185,7 +186,7 @@ func TestHomebrewPublisher_RenderTemplate_Good(t *testing.T) { }, } - result, err := p.renderTemplate("templates/homebrew/formula.rb.tmpl", data) + result, err := p.renderTemplate(io.Local, "templates/homebrew/formula.rb.tmpl", data) require.NoError(t, err) assert.Contains(t, result, "class MyApp < Formula") @@ -206,7 +207,7 @@ func TestHomebrewPublisher_RenderTemplate_Bad(t *testing.T) { t.Run("returns error for non-existent template", func(t *testing.T) { data := homebrewTemplateData{} - _, err := p.renderTemplate("templates/homebrew/nonexistent.tmpl", data) + _, err := p.renderTemplate(io.Local, "templates/homebrew/nonexistent.tmpl", data) assert.Error(t, err) assert.Contains(t, err.Error(), "failed to read template") }) @@ -234,7 +235,7 @@ func TestHomebrewPublisher_DryRunPublish_Good(t *testing.T) { Tap: "owner/homebrew-tap", } - err := p.dryRunPublish(data, cfg) + err := p.dryRunPublish(io.Local, data, cfg) _ = w.Close() var buf bytes.Buffer @@ -271,7 +272,7 @@ func TestHomebrewPublisher_DryRunPublish_Good(t *testing.T) { }, } - err := p.dryRunPublish(data, cfg) + err := p.dryRunPublish(io.Local, data, cfg) _ = w.Close() var buf bytes.Buffer @@ -300,7 +301,7 @@ func TestHomebrewPublisher_DryRunPublish_Good(t *testing.T) { }, } - err := p.dryRunPublish(data, cfg) + err := p.dryRunPublish(io.Local, data, cfg) _ = w.Close() var buf bytes.Buffer @@ -320,6 +321,7 @@ func TestHomebrewPublisher_Publish_Bad(t *testing.T) { release := &Release{ Version: "v1.0.0", ProjectDir: "/project", + FS: io.Local, } pubCfg := PublisherConfig{Type: "homebrew"} relCfg := &mockReleaseConfig{repository: "owner/repo"} diff --git a/pkg/release/publishers/linuxkit.go b/pkg/release/publishers/linuxkit.go index 2a5ca828..4905575d 100644 --- a/pkg/release/publishers/linuxkit.go +++ b/pkg/release/publishers/linuxkit.go @@ -47,7 +47,7 @@ func (p *LinuxKitPublisher) Publish(ctx context.Context, release *Release, pubCf lkCfg := p.parseConfig(pubCfg, release.ProjectDir) // Validate config file exists - if _, err := os.Stat(lkCfg.Config); err != nil { + if !release.FS.Exists(lkCfg.Config) { return fmt.Errorf("linuxkit.Publish: config file not found: %s", lkCfg.Config) } @@ -169,7 +169,7 @@ func (p *LinuxKitPublisher) executePublish(ctx context.Context, release *Release outputDir := filepath.Join(release.ProjectDir, "dist", "linuxkit") // Create output directory - if err := os.MkdirAll(outputDir, 0755); err != nil { + if err := release.FS.EnsureDir(outputDir); err != nil { return fmt.Errorf("linuxkit.Publish: failed to create output directory: %w", err) } @@ -207,7 +207,7 @@ func (p *LinuxKitPublisher) executePublish(ctx context.Context, release *Release // Upload artifacts to GitHub release for _, artifactPath := range artifacts { - if _, err := os.Stat(artifactPath); err != nil { + if !release.FS.Exists(artifactPath) { return fmt.Errorf("linuxkit.Publish: artifact not found after build: %s", artifactPath) } diff --git a/pkg/release/publishers/linuxkit_test.go b/pkg/release/publishers/linuxkit_test.go index 361d1fa3..7def1da4 100644 --- a/pkg/release/publishers/linuxkit_test.go +++ b/pkg/release/publishers/linuxkit_test.go @@ -8,6 +8,7 @@ import ( "path/filepath" "testing" + "github.com/host-uk/core/pkg/io" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -192,6 +193,7 @@ func TestLinuxKitPublisher_Publish_Bad(t *testing.T) { release := &Release{ Version: "v1.0.0", ProjectDir: "/nonexistent", + FS: io.Local, } pubCfg := PublisherConfig{ Type: "linuxkit", @@ -214,6 +216,7 @@ func TestLinuxKitPublisher_Publish_Bad(t *testing.T) { release := &Release{ Version: "v1.0.0", ProjectDir: "/tmp", + FS: io.Local, } pubCfg := PublisherConfig{Type: "linuxkit"} relCfg := &mockReleaseConfig{repository: "owner/repo"} @@ -241,6 +244,7 @@ func TestLinuxKitPublisher_Publish_Bad(t *testing.T) { release := &Release{ Version: "v1.0.0", ProjectDir: tmpDir, + FS: io.Local, } pubCfg := PublisherConfig{ Type: "linuxkit", @@ -296,6 +300,7 @@ func TestLinuxKitPublisher_Publish_WithCLI_Good(t *testing.T) { release := &Release{ Version: "v1.0.0", ProjectDir: tmpDir, + FS: io.Local, } pubCfg := PublisherConfig{Type: "linuxkit"} relCfg := &mockReleaseConfig{repository: "owner/repo"} @@ -320,6 +325,7 @@ func TestLinuxKitPublisher_Publish_WithCLI_Good(t *testing.T) { release := &Release{ Version: "v1.0.0", ProjectDir: tmpDir, + FS: io.Local, } pubCfg := PublisherConfig{Type: "linuxkit"} relCfg := &mockReleaseConfig{repository: "owner/repo"} @@ -349,6 +355,7 @@ func TestLinuxKitPublisher_Publish_WithCLI_Good(t *testing.T) { release := &Release{ Version: "v1.0.0", ProjectDir: tmpDir, + FS: io.Local, } pubCfg := PublisherConfig{Type: "linuxkit"} relCfg := &mockReleaseConfig{repository: "custom-owner/custom-repo"} @@ -395,6 +402,7 @@ func TestLinuxKitPublisher_Publish_WithCLI_Good(t *testing.T) { release := &Release{ Version: "v1.0.0", ProjectDir: tmpDir, + FS: io.Local, } pubCfg := PublisherConfig{Type: "linuxkit"} relCfg := &mockReleaseConfig{repository: ""} // Empty to trigger detection @@ -490,6 +498,7 @@ func TestLinuxKitPublisher_DryRunPublish_Good(t *testing.T) { release := &Release{ Version: "v1.0.0", ProjectDir: "/project", + FS: io.Local, } cfg := LinuxKitConfig{ Config: "/project/.core/linuxkit/server.yml", @@ -531,6 +540,7 @@ func TestLinuxKitPublisher_DryRunPublish_Good(t *testing.T) { release := &Release{ Version: "v1.0.0", ProjectDir: "/project", + FS: io.Local, } cfg := LinuxKitConfig{ Config: "/config.yml", @@ -560,6 +570,7 @@ func TestLinuxKitPublisher_DryRunPublish_Good(t *testing.T) { release := &Release{ Version: "v2.0.0", ProjectDir: "/project", + FS: io.Local, } cfg := LinuxKitConfig{ Config: "/config.yml", @@ -823,6 +834,7 @@ func TestLinuxKitPublisher_Publish_DryRun_Good(t *testing.T) { release := &Release{ Version: "v1.0.0", ProjectDir: tmpDir, + FS: io.Local, } pubCfg := PublisherConfig{Type: "linuxkit"} relCfg := &mockReleaseConfig{repository: "owner/repo"} @@ -855,6 +867,7 @@ func TestLinuxKitPublisher_Publish_DryRun_Good(t *testing.T) { release := &Release{ Version: "v1.0.0", ProjectDir: tmpDir, + FS: io.Local, } pubCfg := PublisherConfig{ Type: "linuxkit", @@ -892,6 +905,7 @@ func TestLinuxKitPublisher_Publish_DryRun_Good(t *testing.T) { release := &Release{ Version: "v2.0.0", ProjectDir: tmpDir, + FS: io.Local, } pubCfg := PublisherConfig{ Type: "linuxkit", diff --git a/pkg/release/publishers/npm.go b/pkg/release/publishers/npm.go index 314b8e02..85df9283 100644 --- a/pkg/release/publishers/npm.go +++ b/pkg/release/publishers/npm.go @@ -11,6 +11,8 @@ import ( "path/filepath" "strings" "text/template" + + "github.com/host-uk/core/pkg/io" ) //go:embed templates/npm/*.tmpl @@ -88,10 +90,10 @@ func (p *NpmPublisher) Publish(ctx context.Context, release *Release, pubCfg Pub } if dryRun { - return p.dryRunPublish(data, &npmCfg) + return p.dryRunPublish(release.FS, data, &npmCfg) } - return p.executePublish(ctx, data, &npmCfg) + return p.executePublish(ctx, release.FS, data, &npmCfg) } // parseConfig extracts npm-specific configuration from the publisher config. @@ -127,7 +129,7 @@ type npmTemplateData struct { } // dryRunPublish shows what would be done without actually publishing. -func (p *NpmPublisher) dryRunPublish(data npmTemplateData, cfg *NpmConfig) error { +func (p *NpmPublisher) dryRunPublish(m io.Medium, data npmTemplateData, cfg *NpmConfig) error { fmt.Println() fmt.Println("=== DRY RUN: npm Publish ===") fmt.Println() @@ -139,7 +141,7 @@ func (p *NpmPublisher) dryRunPublish(data npmTemplateData, cfg *NpmConfig) error fmt.Println() // Generate and show package.json - pkgJSON, err := p.renderTemplate("templates/npm/package.json.tmpl", data) + pkgJSON, err := p.renderTemplate(m, "templates/npm/package.json.tmpl", data) if err != nil { return fmt.Errorf("npm.dryRunPublish: %w", err) } @@ -157,7 +159,7 @@ func (p *NpmPublisher) dryRunPublish(data npmTemplateData, cfg *NpmConfig) error } // executePublish actually creates and publishes the npm package. -func (p *NpmPublisher) executePublish(ctx context.Context, data npmTemplateData, cfg *NpmConfig) error { +func (p *NpmPublisher) executePublish(ctx context.Context, m io.Medium, 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") @@ -177,7 +179,7 @@ func (p *NpmPublisher) executePublish(ctx context.Context, data npmTemplateData, } // Generate package.json - pkgJSON, err := p.renderTemplate("templates/npm/package.json.tmpl", data) + pkgJSON, err := p.renderTemplate(m, "templates/npm/package.json.tmpl", data) if err != nil { return fmt.Errorf("npm.Publish: failed to render package.json: %w", err) } @@ -186,7 +188,7 @@ func (p *NpmPublisher) executePublish(ctx context.Context, data npmTemplateData, } // Generate install.js - installJS, err := p.renderTemplate("templates/npm/install.js.tmpl", data) + installJS, err := p.renderTemplate(m, "templates/npm/install.js.tmpl", data) if err != nil { return fmt.Errorf("npm.Publish: failed to render install.js: %w", err) } @@ -195,7 +197,7 @@ func (p *NpmPublisher) executePublish(ctx context.Context, data npmTemplateData, } // Generate run.js - runJS, err := p.renderTemplate("templates/npm/run.js.tmpl", data) + runJS, err := p.renderTemplate(m, "templates/npm/run.js.tmpl", data) if err != nil { return fmt.Errorf("npm.Publish: failed to render run.js: %w", err) } @@ -228,10 +230,25 @@ func (p *NpmPublisher) executePublish(ctx context.Context, data npmTemplateData, } // 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) +func (p *NpmPublisher) renderTemplate(m io.Medium, name string, data npmTemplateData) (string, error) { + var content []byte + var err error + + // Try custom template from medium + customPath := filepath.Join(".core", name) + if m != nil && m.IsFile(customPath) { + customContent, err := m.Read(customPath) + if err == nil { + content = []byte(customContent) + } + } + + // Fallback to embedded template + if content == nil { + 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)) diff --git a/pkg/release/publishers/npm_test.go b/pkg/release/publishers/npm_test.go index 29ffbcf2..6122788c 100644 --- a/pkg/release/publishers/npm_test.go +++ b/pkg/release/publishers/npm_test.go @@ -6,6 +6,8 @@ import ( "os" "testing" + "github.com/host-uk/core/pkg/io" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -101,7 +103,7 @@ func TestNpmPublisher_RenderTemplate_Good(t *testing.T) { Access: "public", } - result, err := p.renderTemplate("templates/npm/package.json.tmpl", data) + result, err := p.renderTemplate(io.Local, "templates/npm/package.json.tmpl", data) require.NoError(t, err) assert.Contains(t, result, `"name": "@myorg/mycli"`) @@ -125,7 +127,7 @@ func TestNpmPublisher_RenderTemplate_Good(t *testing.T) { Access: "restricted", } - result, err := p.renderTemplate("templates/npm/package.json.tmpl", data) + result, err := p.renderTemplate(io.Local, "templates/npm/package.json.tmpl", data) require.NoError(t, err) assert.Contains(t, result, `"access": "restricted"`) @@ -137,7 +139,7 @@ func TestNpmPublisher_RenderTemplate_Bad(t *testing.T) { t.Run("returns error for non-existent template", func(t *testing.T) { data := npmTemplateData{} - _, err := p.renderTemplate("templates/npm/nonexistent.tmpl", data) + _, err := p.renderTemplate(io.Local, "templates/npm/nonexistent.tmpl", data) assert.Error(t, err) assert.Contains(t, err.Error(), "failed to read template") }) @@ -164,7 +166,7 @@ func TestNpmPublisher_DryRunPublish_Good(t *testing.T) { Access: "public", } - err := p.dryRunPublish(data, cfg) + err := p.dryRunPublish(io.Local, data, cfg) _ = w.Close() var buf bytes.Buffer @@ -202,7 +204,7 @@ func TestNpmPublisher_DryRunPublish_Good(t *testing.T) { Access: "restricted", } - err := p.dryRunPublish(data, cfg) + err := p.dryRunPublish(io.Local, data, cfg) _ = w.Close() var buf bytes.Buffer @@ -224,6 +226,7 @@ func TestNpmPublisher_Publish_Bad(t *testing.T) { release := &Release{ Version: "v1.0.0", ProjectDir: "/project", + FS: io.Local, } pubCfg := PublisherConfig{Type: "npm"} relCfg := &mockReleaseConfig{repository: "owner/repo"} @@ -246,6 +249,7 @@ func TestNpmPublisher_Publish_Bad(t *testing.T) { release := &Release{ Version: "v1.0.0", ProjectDir: "/project", + FS: io.Local, } pubCfg := PublisherConfig{ Type: "npm", diff --git a/pkg/release/publishers/publisher.go b/pkg/release/publishers/publisher.go index f91de234..99e45f69 100644 --- a/pkg/release/publishers/publisher.go +++ b/pkg/release/publishers/publisher.go @@ -5,6 +5,7 @@ import ( "context" "github.com/host-uk/core/pkg/build" + "github.com/host-uk/core/pkg/io" ) // Release represents a release to be published. @@ -17,6 +18,8 @@ type Release struct { Changelog string // ProjectDir is the root directory of the project. ProjectDir string + // FS is the medium for file operations. + FS io.Medium } // PublisherConfig holds configuration for a publisher. @@ -48,12 +51,13 @@ type Publisher interface { // NewRelease creates a Release from the release package's Release type. // This is a helper to convert between packages. -func NewRelease(version string, artifacts []build.Artifact, changelog, projectDir string) *Release { +func NewRelease(version string, artifacts []build.Artifact, changelog, projectDir string, fs io.Medium) *Release { return &Release{ Version: version, Artifacts: artifacts, Changelog: changelog, ProjectDir: projectDir, + FS: fs, } } diff --git a/pkg/release/publishers/scoop.go b/pkg/release/publishers/scoop.go index 190fa78a..d0a46d7b 100644 --- a/pkg/release/publishers/scoop.go +++ b/pkg/release/publishers/scoop.go @@ -13,6 +13,7 @@ import ( "text/template" "github.com/host-uk/core/pkg/build" + "github.com/host-uk/core/pkg/io" ) //go:embed templates/scoop/*.tmpl @@ -82,10 +83,10 @@ func (p *ScoopPublisher) Publish(ctx context.Context, release *Release, pubCfg P } if dryRun { - return p.dryRunPublish(data, cfg) + return p.dryRunPublish(release.FS, data, cfg) } - return p.executePublish(ctx, release.ProjectDir, data, cfg) + return p.executePublish(ctx, release.ProjectDir, data, cfg, release) } type scoopTemplateData struct { @@ -119,7 +120,7 @@ func (p *ScoopPublisher) parseConfig(pubCfg PublisherConfig, relCfg ReleaseConfi return cfg } -func (p *ScoopPublisher) dryRunPublish(data scoopTemplateData, cfg ScoopConfig) error { +func (p *ScoopPublisher) dryRunPublish(m io.Medium, data scoopTemplateData, cfg ScoopConfig) error { fmt.Println() fmt.Println("=== DRY RUN: Scoop Publish ===") fmt.Println() @@ -129,7 +130,7 @@ func (p *ScoopPublisher) dryRunPublish(data scoopTemplateData, cfg ScoopConfig) fmt.Printf("Repository: %s\n", data.Repository) fmt.Println() - manifest, err := p.renderTemplate("templates/scoop/manifest.json.tmpl", data) + manifest, err := p.renderTemplate(m, "templates/scoop/manifest.json.tmpl", data) if err != nil { return fmt.Errorf("scoop.dryRunPublish: %w", err) } @@ -155,8 +156,8 @@ func (p *ScoopPublisher) dryRunPublish(data scoopTemplateData, cfg ScoopConfig) 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) +func (p *ScoopPublisher) executePublish(ctx context.Context, projectDir string, data scoopTemplateData, cfg ScoopConfig, release *Release) error { + manifest, err := p.renderTemplate(release.FS, "templates/scoop/manifest.json.tmpl", data) if err != nil { return fmt.Errorf("scoop.Publish: failed to render manifest: %w", err) } @@ -170,12 +171,12 @@ func (p *ScoopPublisher) executePublish(ctx context.Context, projectDir string, output = filepath.Join(projectDir, output) } - if err := os.MkdirAll(output, 0755); err != nil { + if err := release.FS.EnsureDir(output); 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 { + if err := release.FS.Write(manifestPath, manifest); err != nil { return fmt.Errorf("scoop.Publish: failed to write manifest: %w", err) } fmt.Printf("Wrote Scoop manifest for official PR: %s\n", manifestPath) @@ -245,10 +246,25 @@ func (p *ScoopPublisher) commitToBucket(ctx context.Context, bucket string, data 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) +func (p *ScoopPublisher) renderTemplate(m io.Medium, name string, data scoopTemplateData) (string, error) { + var content []byte + var err error + + // Try custom template from medium + customPath := filepath.Join(".core", name) + if m != nil && m.IsFile(customPath) { + customContent, err := m.Read(customPath) + if err == nil { + content = []byte(customContent) + } + } + + // Fallback to embedded template + if content == nil { + 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)) diff --git a/pkg/release/publishers/scoop_test.go b/pkg/release/publishers/scoop_test.go index ef84b20d..3dc6e780 100644 --- a/pkg/release/publishers/scoop_test.go +++ b/pkg/release/publishers/scoop_test.go @@ -6,6 +6,8 @@ import ( "os" "testing" + "github.com/host-uk/core/pkg/io" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -105,7 +107,7 @@ func TestScoopPublisher_RenderTemplate_Good(t *testing.T) { }, } - result, err := p.renderTemplate("templates/scoop/manifest.json.tmpl", data) + result, err := p.renderTemplate(io.Local, "templates/scoop/manifest.json.tmpl", data) require.NoError(t, err) assert.Contains(t, result, `"version": "1.2.3"`) @@ -132,7 +134,7 @@ func TestScoopPublisher_RenderTemplate_Good(t *testing.T) { Checksums: ChecksumMap{}, } - result, err := p.renderTemplate("templates/scoop/manifest.json.tmpl", data) + result, err := p.renderTemplate(io.Local, "templates/scoop/manifest.json.tmpl", data) require.NoError(t, err) assert.Contains(t, result, `"checkver"`) @@ -146,7 +148,7 @@ func TestScoopPublisher_RenderTemplate_Bad(t *testing.T) { t.Run("returns error for non-existent template", func(t *testing.T) { data := scoopTemplateData{} - _, err := p.renderTemplate("templates/scoop/nonexistent.tmpl", data) + _, err := p.renderTemplate(io.Local, "templates/scoop/nonexistent.tmpl", data) assert.Error(t, err) assert.Contains(t, err.Error(), "failed to read template") }) @@ -171,7 +173,7 @@ func TestScoopPublisher_DryRunPublish_Good(t *testing.T) { Bucket: "owner/scoop-bucket", } - err := p.dryRunPublish(data, cfg) + err := p.dryRunPublish(io.Local, data, cfg) _ = w.Close() var buf bytes.Buffer @@ -209,7 +211,7 @@ func TestScoopPublisher_DryRunPublish_Good(t *testing.T) { }, } - err := p.dryRunPublish(data, cfg) + err := p.dryRunPublish(io.Local, data, cfg) _ = w.Close() var buf bytes.Buffer @@ -238,7 +240,7 @@ func TestScoopPublisher_DryRunPublish_Good(t *testing.T) { }, } - err := p.dryRunPublish(data, cfg) + err := p.dryRunPublish(io.Local, data, cfg) _ = w.Close() var buf bytes.Buffer @@ -258,6 +260,7 @@ func TestScoopPublisher_Publish_Bad(t *testing.T) { release := &Release{ Version: "v1.0.0", ProjectDir: "/project", + FS: io.Local, } pubCfg := PublisherConfig{Type: "scoop"} relCfg := &mockReleaseConfig{repository: "owner/repo"} diff --git a/pkg/release/release.go b/pkg/release/release.go index 97328fa7..2538f8a5 100644 --- a/pkg/release/release.go +++ b/pkg/release/release.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "path/filepath" + "sort" "strings" "github.com/host-uk/core/pkg/build" @@ -25,6 +26,8 @@ type Release struct { Changelog string // ProjectDir is the root directory of the project. ProjectDir string + // FS is the medium for file operations. + FS io.Medium } // Publish publishes pre-built artifacts from dist/ to configured targets. @@ -35,6 +38,8 @@ func Publish(ctx context.Context, cfg *Config, dryRun bool) (*Release, error) { return nil, fmt.Errorf("release.Publish: config is nil") } + m := io.Local + projectDir := cfg.projectDir if projectDir == "" { projectDir = "." @@ -57,7 +62,7 @@ func Publish(ctx context.Context, cfg *Config, dryRun bool) (*Release, error) { // Step 2: Find pre-built artifacts in dist/ distDir := filepath.Join(absProjectDir, "dist") - artifacts, err := findArtifacts(distDir) + artifacts, err := findArtifacts(m, distDir) if err != nil { return nil, fmt.Errorf("release.Publish: %w", err) } @@ -78,11 +83,12 @@ func Publish(ctx context.Context, cfg *Config, dryRun bool) (*Release, error) { Artifacts: artifacts, Changelog: changelog, ProjectDir: absProjectDir, + FS: m, } // Step 4: Publish to configured targets 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 { publisher, err := getPublisher(pubCfg.Type) @@ -102,14 +108,14 @@ func Publish(ctx context.Context, cfg *Config, dryRun bool) (*Release, error) { } // findArtifacts discovers pre-built artifacts in the dist directory. -func findArtifacts(distDir string) ([]build.Artifact, error) { - if !io.Local.IsDir(distDir) { +func findArtifacts(m io.Medium, distDir string) ([]build.Artifact, error) { + if !m.IsDir(distDir) { return nil, fmt.Errorf("dist/ directory not found") } var artifacts []build.Artifact - entries, err := io.Local.List(distDir) + entries, err := m.List(distDir) if err != nil { return nil, fmt.Errorf("failed to read dist/: %w", err) } @@ -143,6 +149,8 @@ func Run(ctx context.Context, cfg *Config, dryRun bool) (*Release, error) { return nil, fmt.Errorf("release.Run: config is nil") } + m := io.Local + projectDir := cfg.projectDir if projectDir == "" { projectDir = "." @@ -171,7 +179,7 @@ func Run(ctx context.Context, cfg *Config, dryRun bool) (*Release, error) { } // Step 3: Build artifacts - artifacts, err := buildArtifacts(ctx, cfg, absProjectDir, version) + artifacts, err := buildArtifacts(ctx, m, cfg, absProjectDir, version) if err != nil { return nil, fmt.Errorf("release.Run: build failed: %w", err) } @@ -181,12 +189,13 @@ func Run(ctx context.Context, cfg *Config, dryRun bool) (*Release, error) { Artifacts: artifacts, Changelog: changelog, ProjectDir: absProjectDir, + FS: m, } // Step 4: Publish to configured targets if len(cfg.Publishers) > 0 { // 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 { publisher, err := getPublisher(pubCfg.Type) @@ -207,7 +216,7 @@ func Run(ctx context.Context, cfg *Config, dryRun bool) (*Release, error) { } // 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, m io.Medium, cfg *Config, projectDir, version string) ([]build.Artifact, error) { // Load build configuration buildCfg, err := build.LoadConfig(projectDir) if err != nil { @@ -287,7 +296,16 @@ func buildArtifacts(ctx context.Context, cfg *Config, projectDir, version string // Write CHECKSUMS.txt checksumPath := filepath.Join(outputDir, "CHECKSUMS.txt") - if err := build.WriteChecksumFile(checksummedArtifacts, checksumPath); err != nil { + var lines []string + for _, artifact := range checksummedArtifacts { + if artifact.Checksum != "" { + lines = append(lines, fmt.Sprintf("%s %s", artifact.Checksum, filepath.Base(artifact.Path))) + } + } + sort.Strings(lines) + content := strings.Join(lines, "\n") + "\n" + + if err := m.Write(checksumPath, content); err != nil { return nil, fmt.Errorf("failed to write checksums file: %w", err) } diff --git a/pkg/release/release_test.go b/pkg/release/release_test.go index 4eb3ac5c..d768e929 100644 --- a/pkg/release/release_test.go +++ b/pkg/release/release_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/host-uk/core/pkg/build" + "github.com/host-uk/core/pkg/io" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -22,7 +23,7 @@ func TestFindArtifacts_Good(t *testing.T) { require.NoError(t, os.WriteFile(filepath.Join(distDir, "app-linux-amd64.tar.gz"), []byte("test"), 0644)) require.NoError(t, os.WriteFile(filepath.Join(distDir, "app-darwin-arm64.tar.gz"), []byte("test"), 0644)) - artifacts, err := findArtifacts(distDir) + artifacts, err := findArtifacts(io.Local, distDir) require.NoError(t, err) assert.Len(t, artifacts, 2) @@ -35,7 +36,7 @@ func TestFindArtifacts_Good(t *testing.T) { require.NoError(t, os.WriteFile(filepath.Join(distDir, "app-windows-amd64.zip"), []byte("test"), 0644)) - artifacts, err := findArtifacts(distDir) + artifacts, err := findArtifacts(io.Local, distDir) require.NoError(t, err) assert.Len(t, artifacts, 1) @@ -49,7 +50,7 @@ func TestFindArtifacts_Good(t *testing.T) { require.NoError(t, os.WriteFile(filepath.Join(distDir, "CHECKSUMS.txt"), []byte("checksums"), 0644)) - artifacts, err := findArtifacts(distDir) + artifacts, err := findArtifacts(io.Local, distDir) require.NoError(t, err) assert.Len(t, artifacts, 1) @@ -63,7 +64,7 @@ func TestFindArtifacts_Good(t *testing.T) { require.NoError(t, os.WriteFile(filepath.Join(distDir, "app.tar.gz.sig"), []byte("signature"), 0644)) - artifacts, err := findArtifacts(distDir) + artifacts, err := findArtifacts(io.Local, distDir) require.NoError(t, err) assert.Len(t, artifacts, 1) @@ -79,7 +80,7 @@ func TestFindArtifacts_Good(t *testing.T) { require.NoError(t, os.WriteFile(filepath.Join(distDir, "CHECKSUMS.txt"), []byte("checksums"), 0644)) require.NoError(t, os.WriteFile(filepath.Join(distDir, "app.sig"), []byte("sig"), 0644)) - artifacts, err := findArtifacts(distDir) + artifacts, err := findArtifacts(io.Local, distDir) require.NoError(t, err) assert.Len(t, artifacts, 4) @@ -94,7 +95,7 @@ func TestFindArtifacts_Good(t *testing.T) { require.NoError(t, os.WriteFile(filepath.Join(distDir, "app.exe"), []byte("binary"), 0644)) require.NoError(t, os.WriteFile(filepath.Join(distDir, "app.tar.gz"), []byte("artifact"), 0644)) - artifacts, err := findArtifacts(distDir) + artifacts, err := findArtifacts(io.Local, distDir) require.NoError(t, err) assert.Len(t, artifacts, 1) @@ -110,7 +111,7 @@ func TestFindArtifacts_Good(t *testing.T) { require.NoError(t, os.WriteFile(filepath.Join(distDir, "app.tar.gz"), []byte("artifact"), 0644)) require.NoError(t, os.WriteFile(filepath.Join(distDir, "subdir", "nested.tar.gz"), []byte("nested"), 0644)) - artifacts, err := findArtifacts(distDir) + artifacts, err := findArtifacts(io.Local, distDir) require.NoError(t, err) // Should only find the top-level artifact @@ -122,7 +123,7 @@ func TestFindArtifacts_Good(t *testing.T) { distDir := filepath.Join(dir, "dist") require.NoError(t, os.MkdirAll(distDir, 0755)) - artifacts, err := findArtifacts(distDir) + artifacts, err := findArtifacts(io.Local, distDir) require.NoError(t, err) assert.Empty(t, artifacts) @@ -134,7 +135,7 @@ func TestFindArtifacts_Bad(t *testing.T) { dir := t.TempDir() distDir := filepath.Join(dir, "dist") - _, err := findArtifacts(distDir) + _, err := findArtifacts(io.Local, distDir) assert.Error(t, err) assert.Contains(t, err.Error(), "dist/ directory not found") }) @@ -149,7 +150,7 @@ func TestFindArtifacts_Bad(t *testing.T) { require.NoError(t, os.Chmod(distDir, 0000)) defer func() { _ = os.Chmod(distDir, 0755) }() - _, err := findArtifacts(distDir) + _, err := findArtifacts(io.Local, distDir) assert.Error(t, err) assert.Contains(t, err.Error(), "failed to read dist/") })