diff --git a/docs/consumers/delivery.md b/docs/consumers/delivery.md index d9a8a16..bc1bf4f 100644 --- a/docs/consumers/delivery.md +++ b/docs/consumers/delivery.md @@ -7,6 +7,37 @@ The operation accepts historical lightweight and annotated tags, including a detached checkout, when the checked-out `HEAD` identifies the selected tag commit. It does not require the old remote `main` to match. +## CI preparation + +Use `PrepareTaggedCheckout` when a disposable CI checkout must be verified +against the commit that triggered the job. It fetches only the selected tag +from the chosen remote (default `origin`), compares the peeled tag commit with +the expected commit, and checks out that commit detached. Fetch or checkout +conflicts are returned so the caller can replace or repair the workspace. +Cancellation is returned to the caller; the operation does not publish a tag +or hosted content. + +```go +err := releasekit.PrepareTaggedCheckout(ctx, releasekit.TaggedCheckoutRequest{ + RepositoryDir: repositoryDir, + Version: version, + ExpectedCommit: ciCommit, +}) +if err != nil { + return err +} +``` + +For binary profiles, call `PrepareBuildDependencies(ctx, repositoryDir)` after +checkout preparation and before delivery. It runs `go mod download` with +workspace discovery disabled and the available compatible local Go toolchain. +The operation may use the network and updates the Go module cache while +preserving the caller's proxy, private-module, credential, and cache settings. +If checksums are missing, Go may update `go.sum`; see the +[Go Modules Reference](https://go.dev/ref/mod#go-mod-download). Cancellation +and command errors are returned. Page-only profiles do not need dependency +preparation. + ## Command line Add a Gitea destination to the profile: diff --git a/docs/internal/overview.md b/docs/internal/overview.md index d8f331f..3c532ea 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -6,7 +6,8 @@ two Go facades and a command-line interface: - The root `releasekit` package is the orchestration facade. `Check` validates one local candidate, `Publish` validates and publishes its exact annotated source tag, `Build` compiles selected application targets, and `Deliver` - delivers a tagged note page and any declared assets. + delivers a tagged note page and any declared assets. `PrepareTaggedCheckout` + and `PrepareBuildDependencies` provide explicit local CI preparation. - `buildinfo` is the small runtime facade. `Version` reports the injected application version, the main-module version when available, or `dev`. - `cmd/releasekit` is the executable interface. Its `version`, `check`, @@ -39,11 +40,12 @@ these implemented packages: | `internal/build` | Candidate-build coordination, selected Go target compilation, version injection, native smoke check, and artifact digests. | | `internal/candidate` | Stable-version, note, repository-state, candidate capture, and post-validation checks. | | `internal/delivery` | Tagged-source and release-note checks, managed builds in disposable storage outside the checkout, prebuilt artifact validation, manifest assembly, and hosted delivery coordination. | -| `internal/git` | Git command boundary, push-destination resolution, exact ref inspection, and tag operations, including captured-endpoint verification during recovery. | +| `internal/git` | Git command boundary, push-destination resolution, exact ref inspection, tagged checkout preparation, and tag operations, including captured-endpoint verification during recovery. | | `internal/gitea` | Gitea API transport, hosted release pages, asset inspection, manifests, and uploads. | -| `internal/gotool` | Effective Go configuration discovery and shared native-validation and static-build environments; see [Go command environments](gotool.md). | +| `internal/gotool` | Effective Go configuration discovery, shared native-validation and static-build environments, and network-capable dependency preparation; see [Go command environments](gotool.md). | | `internal/tempdir` | Disposable system temporary storage outside source worktrees. | | `internal/process` | Cancellable child-process execution with per-command environment overlays and removals. | +| `internal/preparation` | Coordination for expected-commit tag checkout and Go module dependency preparation. | | `internal/publish` | Publication guards, one validation run, annotated tag creation, push, and recovery state. | | `internal/runtimeinfo` | Private version lookup used by the runtime facade. | | `internal/versioncontract` | Shared stable-version rules and linker-symbol identity. | @@ -70,6 +72,7 @@ not a separate release matrix: | Publication partial and uncertain recovery | `internal/publish`, `internal/git` | `TestPublishRejectedPushKeepsLocalTagAndReportsNotPublished`, `TestPublishLostAcknowledgmentConfirmsExactRemoteObject`, `TestPublishLostAcknowledgmentWithChangedEndpointRemainsUnknown`, `TestPublishUnavailableReconciliationRemainsUnknownAndPreservesPushError` | | Cross-builds, version smoke checks, empty target sets, and partial outputs | `releasekit.Build`, `internal/build`, `buildinfo` | `TestBuildCreatesSelectedTargetsAndInjectsVersion`, `TestBuildWithNoTargetsDoesNotInvokeCompiler`, `TestBuildReturnsPartialArtifactsAfterTargetFailure` | | Page-only and managed or prebuilt binary delivery | `releasekit.Deliver`, `internal/delivery`, `internal/gitea`, CLI | `TestDeliverPageOnlySkipsBuildAndHostedAssets`, `TestDeliverManagedBuildUploadsArtifactsAndCleansTemporaryOutput`, `TestDeliverPrebuiltArtifactsRemainCallerOwned`, `TestDeliverManagedBuildFailureCleansTemporaryOutputBeforeHostedWrites`, `TestDeliverManagedBuildCancellationCleansTemporaryOutput`, `TestDeliverBuildsTaggedArtifactsAndReusesAssets`, `TestDeliverRejectsTamperedAssetsAndStaleReceiptBeforeRemoteWrites` | +| Tagged checkout and build dependency preparation | `releasekit.PrepareTaggedCheckout`, `releasekit.PrepareBuildDependencies`, `internal/preparation`, `internal/git`, `internal/gotool` | `TestPrepareTaggedCheckoutFetchesExpectedCommitAndDetaches`, `TestPrepareTaggedCheckoutRejectsWrongCommitBeforeCheckout`, `TestPrepareTaggedCheckoutReportsLocalCheckoutConflict`, `TestPrepareTaggedCheckoutReportsConflictingLocalTag`, `TestPrepareBuildDependenciesPreservesPreparationEnvironment`, `TestPrepareBuildDependenciesPropagatesCommandFailure`, `TestPrepareBuildDependenciesPropagatesCancellation` | | Page, manifest, asset comparison, and additive retry outcomes | `internal/gitea` | `TestEnsureReleaseCreatesMissingPageFromCommittedNote`, `TestInspectAssetsDetectsSameNameAndSizeWithDifferentBytes`, `TestUploadMissingPartialFailureThenRerunOnlyMissingAssets` | | CLI profiles and prepared Woodpecker sequences | `cmd/releasekit`, `cmd/releasekit/internal/cli`, `examples/woodpecker/` | `TestLoadSettingsRejectsInvalidProfiles`, `TestWoodpeckerDeliveryCommandSequences`, `TestReleaseKitSourceDeliveryWorkflowFromTaggedCheckout` | diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 0602635..9905533 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # ReleaseKit audit implementation plan -Status: Stages 1–7 complete; Stage 8 next. +Status: Stages 1–8 complete; Stage 9 next. Baseline: `e4be7fa` (2026-10-06). The [audit](audit.md) records the evidence, product goals, and selected direction. This document owns implementation order and completion criteria for that work. @@ -412,6 +412,18 @@ uses the repository and preparation environment and propagates failure and cancellation. Keep default tests offline with a local module cache or a small Go-command fixture; no live module server is needed. +**Completed:** The root facade now exposes `PrepareTaggedCheckout` and +`PrepareBuildDependencies`. Checkout preparation fetches an explicit tag +refspec, compares the peeled commit with the expected full object ID, and +detaches only after a match; tests cover default and selected remotes, wrong +commits, conflicting local edits and tags, and input rejection before fetch. +Dependency preparation runs `go mod download` with workspace discovery off and +the local Go toolchain, preserving caller proxy, private-module, and cache +settings; subprocess fixtures verify repository selection, failure details, +and cancellation without network access. GoDoc, the delivery consumer guide, +and the internal inventory describe effects and ownership. The full offline +suite, API boundary check, and documentation whitespace checks pass. + ## Stage 9 — Add the shared CI delivery command **Covers:** the shared command portion of F6. diff --git a/internal/git/prepare.go b/internal/git/prepare.go new file mode 100644 index 0000000..09d9825 --- /dev/null +++ b/internal/git/prepare.go @@ -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 +} diff --git a/internal/gotool/gotool.go b/internal/gotool/gotool.go index 134f9cd..51436f7 100644 --- a/internal/gotool/gotool.go +++ b/internal/gotool/gotool.go @@ -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 diff --git a/internal/gotool/gotool_test.go b/internal/gotool/gotool_test.go index 21404e9..1e003d9 100644 --- a/internal/gotool/gotool_test.go +++ b/internal/gotool/gotool_test.go @@ -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") diff --git a/internal/preparation/preparation.go b/internal/preparation/preparation.go new file mode 100644 index 0000000..f78ceb9 --- /dev/null +++ b/internal/preparation/preparation.go @@ -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) +} diff --git a/preparation.go b/preparation.go new file mode 100644 index 0000000..4554f25 --- /dev/null +++ b/preparation.go @@ -0,0 +1,40 @@ +package releasekit + +import ( + "context" + + internalpreparation "gitea.maximumdirect.net/eric/ReleaseKit/internal/preparation" +) + +// TaggedCheckoutRequest selects a stable tag and the full Git object ID +// expected by an external build or CI event. An empty Remote selects origin. +type TaggedCheckoutRequest struct { + RepositoryDir string + Version string + Remote string + ExpectedCommit string +} + +// PrepareTaggedCheckout fetches Version from Remote, verifies that its peeled +// commit matches ExpectedCommit, and checks out that commit detached. This +// operation mutates the selected local repository; use it for disposable +// checkouts where tag-fetch and checkout conflicts can be resolved by the +// caller. It does not publish a tag or hosted content. +func PrepareTaggedCheckout(ctx context.Context, request TaggedCheckoutRequest) error { + return internalpreparation.PrepareTaggedCheckout(ctx, internalpreparation.TaggedCheckoutRequest{ + RepositoryDir: request.RepositoryDir, + Version: request.Version, + Remote: request.Remote, + ExpectedCommit: request.ExpectedCommit, + }) +} + +// PrepareBuildDependencies runs `go mod download` in repositoryDir with +// workspace discovery disabled and the locally available compatible Go +// toolchain selected. It may access module networks and updates the Go module +// cache using the caller's Go proxy, private-module, credential, and cache +// settings. If required checksums are missing, the Go command may update +// go.sum. Cancellation and Go command failures are returned to the caller. +func PrepareBuildDependencies(ctx context.Context, repositoryDir string) error { + return internalpreparation.PrepareBuildDependencies(ctx, repositoryDir) +} diff --git a/preparation_test.go b/preparation_test.go new file mode 100644 index 0000000..3694d26 --- /dev/null +++ b/preparation_test.go @@ -0,0 +1,163 @@ +package releasekit_test + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "testing" + + releasekit "gitea.maximumdirect.net/eric/ReleaseKit" +) + +func TestPrepareTaggedCheckoutFetchesExpectedCommitAndDetaches(t *testing.T) { + repository := newTaggedDeliveryConsumer(t) + tagCommit := publishPreparationTag(t, repository) + advancePreparationCheckout(t, repository, "later local commit") + + if err := releasekit.PrepareTaggedCheckout(context.Background(), releasekit.TaggedCheckoutRequest{ + RepositoryDir: repository, + Version: "v1.2.3", + ExpectedCommit: tagCommit, + }); err != nil { + t.Fatal(err) + } + if head := buildGit(t, repository, "rev-parse", "HEAD"); head != tagCommit { + t.Fatalf("prepared HEAD = %s, want %s", head, tagCommit) + } + if branch := buildGit(t, repository, "rev-parse", "--abbrev-ref", "HEAD"); branch != "HEAD" { + t.Fatalf("prepared checkout branch = %q, want detached HEAD", branch) + } +} + +func TestPrepareTaggedCheckoutRejectsWrongCommitBeforeCheckout(t *testing.T) { + repository := newTaggedDeliveryConsumer(t) + tagCommit := publishPreparationTag(t, repository) + currentCommit := advancePreparationCheckout(t, repository, "later local commit") + remote := buildGit(t, repository, "remote", "get-url", "origin") + buildGit(t, repository, "remote", "add", "candidate", remote) + + err := releasekit.PrepareTaggedCheckout(context.Background(), releasekit.TaggedCheckoutRequest{ + RepositoryDir: repository, + Version: "v1.2.3", + Remote: "candidate", + ExpectedCommit: currentCommit, + }) + if err == nil { + t.Fatal("PrepareTaggedCheckout() succeeded for a tag that resolves to another commit") + } + if head := buildGit(t, repository, "rev-parse", "HEAD"); head != currentCommit { + t.Fatalf("wrong-tag rejection changed HEAD to %s, want %s (tag commit %s)", head, currentCommit, tagCommit) + } + if branch := buildGit(t, repository, "rev-parse", "--abbrev-ref", "HEAD"); branch == "HEAD" { + t.Fatal("wrong-tag rejection detached the checkout") + } +} + +func TestPrepareTaggedCheckoutReportsLocalCheckoutConflict(t *testing.T) { + repository := newTaggedDeliveryConsumer(t) + tagCommit := publishPreparationTag(t, repository) + advancePreparationCheckout(t, repository, "later committed content") + path := filepath.Join(repository, "cmd/app/main.go") + if err := os.WriteFile(path, []byte("uncommitted conflicting content"), 0o600); err != nil { + t.Fatal(err) + } + currentCommit := buildGit(t, repository, "rev-parse", "HEAD") + + err := releasekit.PrepareTaggedCheckout(context.Background(), releasekit.TaggedCheckoutRequest{ + RepositoryDir: repository, + Version: "v1.2.3", + ExpectedCommit: tagCommit, + }) + if err == nil { + t.Fatal("PrepareTaggedCheckout() succeeded despite a conflicting local edit") + } + if head := buildGit(t, repository, "rev-parse", "HEAD"); head != currentCommit { + t.Fatalf("conflicting checkout changed HEAD to %s, want %s", head, currentCommit) + } + contents, err := os.ReadFile(path) + if err != nil || string(contents) != "uncommitted conflicting content" { + t.Fatalf("conflicting local file = %q, %v", contents, err) + } +} + +func TestPrepareTaggedCheckoutReportsConflictingLocalTag(t *testing.T) { + repository := newTaggedDeliveryConsumer(t) + publishPreparationTag(t, repository) + currentCommit := advancePreparationCheckout(t, repository, "later local commit") + buildGit(t, repository, "tag", "v1.2.3") + + err := releasekit.PrepareTaggedCheckout(context.Background(), releasekit.TaggedCheckoutRequest{ + RepositoryDir: repository, + Version: "v1.2.3", + ExpectedCommit: buildGit(t, repository, "rev-parse", "HEAD~1"), + }) + if err == nil { + t.Fatal("PrepareTaggedCheckout() replaced a conflicting local tag") + } + if head := buildGit(t, repository, "rev-parse", "HEAD"); head != currentCommit { + t.Fatalf("conflicting tag fetch changed HEAD to %s, want %s", head, currentCommit) + } + if tagCommit := buildGit(t, repository, "rev-parse", "refs/tags/v1.2.3^{commit}"); tagCommit != currentCommit { + t.Fatalf("conflicting local tag changed to %s, want %s", tagCommit, currentCommit) + } +} + +func TestPrepareTaggedCheckoutValidatesInputsBeforeFetching(t *testing.T) { + repository := newTaggedDeliveryConsumer(t) + publishPreparationTag(t, repository) + for _, request := range []releasekit.TaggedCheckoutRequest{ + {RepositoryDir: repository, Version: "not-a-version", ExpectedCommit: buildGit(t, repository, "rev-parse", "HEAD")}, + {RepositoryDir: repository, Version: "v1.2.3"}, + {RepositoryDir: repository, Version: "v1.2.3", ExpectedCommit: "not-an-object-id"}, + } { + err := releasekit.PrepareTaggedCheckout(context.Background(), request) + if err == nil { + t.Fatalf("PrepareTaggedCheckout(%#v) succeeded", request) + } + if _, err := buildGitOutput(repository, "show-ref", "--verify", "refs/tags/v1.2.3"); err == nil { + t.Fatal("input validation fetched the tag before rejecting the request") + } + } + if err := releasekit.PrepareTaggedCheckout(context.Background(), releasekit.TaggedCheckoutRequest{ + RepositoryDir: repository, Version: "invalid", ExpectedCommit: "bad", + }); !errors.Is(err, releasekit.ErrInvalidVersion) { + t.Fatalf("invalid-version error = %v, want ErrInvalidVersion", err) + } +} + +func TestPrepareBuildDependenciesRequiresRepositoryDirectory(t *testing.T) { + if err := releasekit.PrepareBuildDependencies(context.Background(), " "); err == nil { + t.Fatal("PrepareBuildDependencies() accepted an empty repository directory") + } +} + +func publishPreparationTag(t *testing.T, repository string) string { + t.Helper() + remoteDir := t.TempDir() + remote := filepath.Join(remoteDir, "origin.git") + buildGit(t, remoteDir, "init", "--bare", remote) + buildGit(t, repository, "remote", "add", "origin", remote) + commit := buildGit(t, repository, "rev-parse", "refs/tags/v1.2.3^{commit}") + buildGit(t, repository, "push", "origin", "refs/tags/v1.2.3") + buildGit(t, repository, "tag", "-d", "v1.2.3") + return commit +} + +func advancePreparationCheckout(t *testing.T, repository, source string) string { + t.Helper() + path := filepath.Join(repository, "cmd/app/main.go") + if err := os.WriteFile(path, []byte("package main\n// "+source+"\nfunc main() {}\n"), 0o600); err != nil { + t.Fatal(err) + } + buildGit(t, repository, "add", "cmd/app/main.go") + buildGit(t, repository, "commit", "-m", source) + return buildGit(t, repository, "rev-parse", "HEAD") +} + +func buildGitOutput(repository string, args ...string) ([]byte, error) { + command := exec.Command("git", args...) + command.Dir = repository + return command.Output() +}