go/pkg/release/publishers/scoop.go
Snider 7be325302f
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/<publisher>/` 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.
2026-02-04 15:07:13 +00:00

284 lines
7.8 KiB
Go

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