Add reusable CI preparation operations

This commit is contained in:
2026-10-06 18:59:29 +00:00
parent 579daa9485
commit d970aac709
9 changed files with 511 additions and 4 deletions

37
internal/git/prepare.go Normal file
View File

@@ -0,0 +1,37 @@
package git
import (
"context"
"fmt"
"strings"
)
// PrepareTaggedCheckout fetches one explicit tag ref, checks its peeled commit
// against expectedCommit, and checks out that commit detached.
func PrepareTaggedCheckout(ctx context.Context, repositoryDir, version, remote, expectedCommit string) error {
if !validVersionRef(version) || !validObjectID(expectedCommit) {
return fmt.Errorf("invalid tagged checkout identity %q at %q", expectedCommit, version)
}
ref := "refs/tags/" + version
if _, err := run(ctx, repositoryDir, nil, "fetch", "--no-tags", remote, ref+":"+ref); err != nil {
return fmt.Errorf("fetch tag %s from remote %s: %w", version, remote, err)
}
source, err := InspectTaggedSource(ctx, repositoryDir, version)
if err != nil {
return err
}
if !strings.EqualFold(source.TagCommit, expectedCommit) {
return fmt.Errorf("tag %s resolves to commit %s, expected %s", version, source.TagCommit, expectedCommit)
}
if _, err := run(ctx, repositoryDir, nil, "checkout", "--detach", source.TagCommit); err != nil {
return fmt.Errorf("check out tag %s at %s: %w", version, source.TagCommit, err)
}
head, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", "HEAD^{commit}")
if err != nil {
return fmt.Errorf("verify prepared checkout HEAD: %w", err)
}
if !strings.EqualFold(strings.TrimSpace(head), expectedCommit) {
return fmt.Errorf("prepared checkout HEAD is %s, expected %s", strings.TrimSpace(head), expectedCommit)
}
return nil
}

View File

@@ -89,6 +89,30 @@ func New(ctx context.Context, directory string) (*Tool, error) {
return tool, nil
}
// PrepareBuildDependencies downloads module dependencies with the available
// local Go toolchain. It preserves caller module-network settings and caches,
// while disabling workspace discovery and automatic toolchain downloads.
func PrepareBuildDependencies(ctx context.Context, directory string) error {
captured := &combinedWriter{}
err := process.Run(ctx, process.Invocation{
Args: []string{"go", "mod", "download"},
Dir: directory,
Env: map[string]string{
"GOWORK": "off",
"GOTOOLCHAIN": "local",
},
Stdout: captured,
Stderr: captured,
})
if err != nil {
if output := strings.TrimSpace(string(captured.Bytes())); output != "" {
return fmt.Errorf("go mod download: %w: %s", err, output)
}
return fmt.Errorf("go mod download: %w", err)
}
return nil
}
// HostTarget reports the Go command's host operating system and architecture.
func (tool *Tool) HostTarget() Target {
return tool.host

View File

@@ -3,13 +3,167 @@ package gotool
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
type goPreparationRecord struct {
Arguments []string
Directory string
Workspace string
Toolchain string
Proxy string
Private string
NoSumDB string
ModuleCache string
}
func TestMain(m *testing.M) {
if os.Getenv("RELEASEKIT_GO_FIXTURE") == "1" {
os.Exit(runGoPreparationFixture())
}
os.Exit(m.Run())
}
func runGoPreparationFixture() int {
record := goPreparationRecord{
Arguments: os.Args[1:], Directory: mustGetwd(),
Workspace: os.Getenv("GOWORK"), Toolchain: os.Getenv("GOTOOLCHAIN"),
Proxy: os.Getenv("GOPROXY"), Private: os.Getenv("GOPRIVATE"),
NoSumDB: os.Getenv("GONOSUMDB"), ModuleCache: os.Getenv("GOMODCACHE"),
}
contents, err := json.Marshal(record)
if err != nil {
return 10
}
if err := os.WriteFile(os.Getenv("RELEASEKIT_GO_FIXTURE_RECORD"), contents, 0o600); err != nil {
return 11
}
switch os.Getenv("RELEASEKIT_GO_FIXTURE_MODE") {
case "fail":
fmt.Fprintln(os.Stderr, "fixture dependency failure")
return 23
case "wait":
for {
time.Sleep(time.Second)
}
default:
return 0
}
}
func mustGetwd() string {
directory, err := os.Getwd()
if err != nil {
return ""
}
return directory
}
func installGoPreparationFixture(t *testing.T) (repository, recordPath string) {
t.Helper()
directory := t.TempDir()
repository = t.TempDir()
executable, err := os.Executable()
if err != nil {
t.Fatal(err)
}
contents, err := os.ReadFile(executable)
if err != nil {
t.Fatal(err)
}
name := "go"
if runtime.GOOS == "windows" {
name += ".exe"
}
if err := os.WriteFile(filepath.Join(directory, name), contents, 0o755); err != nil {
t.Fatal(err)
}
recordPath = filepath.Join(t.TempDir(), "go-command.json")
t.Setenv("PATH", directory+string(os.PathListSeparator)+os.Getenv("PATH"))
t.Setenv("RELEASEKIT_GO_FIXTURE", "1")
t.Setenv("RELEASEKIT_GO_FIXTURE_RECORD", recordPath)
return repository, recordPath
}
func TestPrepareBuildDependenciesPreservesPreparationEnvironment(t *testing.T) {
repository, recordPath := installGoPreparationFixture(t)
moduleCache := filepath.Join(t.TempDir(), "module-cache")
t.Setenv("GOWORK", filepath.Join(t.TempDir(), "go.work"))
t.Setenv("GOTOOLCHAIN", "auto")
t.Setenv("GOPROXY", "https://proxy.example.invalid")
t.Setenv("GOPRIVATE", "example.invalid/private")
t.Setenv("GONOSUMDB", "example.invalid/private")
t.Setenv("GOMODCACHE", moduleCache)
if err := PrepareBuildDependencies(context.Background(), repository); err != nil {
t.Fatal(err)
}
contents, err := os.ReadFile(recordPath)
if err != nil {
t.Fatal(err)
}
var record goPreparationRecord
if err := json.Unmarshal(contents, &record); err != nil {
t.Fatal(err)
}
if strings.Join(record.Arguments, " ") != "mod download" || record.Directory != repository {
t.Fatalf("Go invocation = %#v", record)
}
if record.Workspace != "off" || record.Toolchain != "local" {
t.Fatalf("preparation Go settings = %#v", record)
}
if record.Proxy != "https://proxy.example.invalid" || record.Private != "example.invalid/private" || record.NoSumDB != "example.invalid/private" || record.ModuleCache != moduleCache {
t.Fatalf("caller dependency settings were not preserved: %#v", record)
}
}
func TestPrepareBuildDependenciesPropagatesCommandFailure(t *testing.T) {
repository, _ := installGoPreparationFixture(t)
t.Setenv("RELEASEKIT_GO_FIXTURE_MODE", "fail")
err := PrepareBuildDependencies(context.Background(), repository)
var exitError *exec.ExitError
if !errors.As(err, &exitError) || exitError.ExitCode() != 23 || !strings.Contains(err.Error(), "fixture dependency failure") {
t.Fatalf("PrepareBuildDependencies() error = %v", err)
}
}
func TestPrepareBuildDependenciesPropagatesCancellation(t *testing.T) {
repository, recordPath := installGoPreparationFixture(t)
t.Setenv("RELEASEKIT_GO_FIXTURE_MODE", "wait")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan error, 1)
go func() { done <- PrepareBuildDependencies(ctx, repository) }()
deadline := time.Now().Add(5 * time.Second)
for {
if _, err := os.Stat(recordPath); err == nil {
break
}
if time.Now().After(deadline) {
t.Fatal("Go fixture did not start")
}
time.Sleep(10 * time.Millisecond)
}
cancel()
select {
case err := <-done:
if !errors.Is(err, context.Canceled) {
t.Fatalf("PrepareBuildDependencies() error = %v; want cancellation", err)
}
case <-time.After(5 * time.Second):
t.Fatal("Go fixture did not stop after cancellation")
}
}
func TestNativeAndStaticCommandsUseOwnedBaseline(t *testing.T) {
clearGoSettings(t)
t.Setenv("GOENV", "off")

View File

@@ -0,0 +1,43 @@
// Package preparation coordinates explicit local checkout and Go module
// preparation operations for consumers and CI adapters.
package preparation
import (
"context"
"errors"
"fmt"
"strings"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
)
type TaggedCheckoutRequest struct {
RepositoryDir string
Version string
Remote string
ExpectedCommit string
}
func PrepareTaggedCheckout(ctx context.Context, request TaggedCheckoutRequest) error {
if !versioncontract.ValidStableVersion(request.Version) {
return fmt.Errorf("%w: %q", candidate.ErrInvalidVersion, request.Version)
}
if strings.TrimSpace(request.ExpectedCommit) == "" {
return errors.New("expected commit is required")
}
remote := request.Remote
if remote == "" {
remote = "origin"
}
return git.PrepareTaggedCheckout(ctx, request.RepositoryDir, request.Version, remote, request.ExpectedCommit)
}
func PrepareBuildDependencies(ctx context.Context, repositoryDir string) error {
if strings.TrimSpace(repositoryDir) == "" {
return errors.New("repository directory is required")
}
return gotool.PrepareBuildDependencies(ctx, repositoryDir)
}