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

View File

@@ -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 detached checkout, when the checked-out `HEAD` identifies the selected tag
commit. It does not require the old remote `main` to match. 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 ## Command line
Add a Gitea destination to the profile: Add a Gitea destination to the profile:

View File

@@ -6,7 +6,8 @@ two Go facades and a command-line interface:
- The root `releasekit` package is the orchestration facade. `Check` validates - The root `releasekit` package is the orchestration facade. `Check` validates
one local candidate, `Publish` validates and publishes its exact annotated one local candidate, `Publish` validates and publishes its exact annotated
source tag, `Build` compiles selected application targets, and `Deliver` 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 - `buildinfo` is the small runtime facade. `Version` reports the injected
application version, the main-module version when available, or `dev`. application version, the main-module version when available, or `dev`.
- `cmd/releasekit` is the executable interface. Its `version`, `check`, - `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/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/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/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/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/tempdir` | Disposable system temporary storage outside source worktrees. |
| `internal/process` | Cancellable child-process execution with per-command environment overlays and removals. | | `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/publish` | Publication guards, one validation run, annotated tag creation, push, and recovery state. |
| `internal/runtimeinfo` | Private version lookup used by the runtime facade. | | `internal/runtimeinfo` | Private version lookup used by the runtime facade. |
| `internal/versioncontract` | Shared stable-version rules and linker-symbol identity. | | `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` | | 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` | | 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` | | 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` | | 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` | | CLI profiles and prepared Woodpecker sequences | `cmd/releasekit`, `cmd/releasekit/internal/cli`, `examples/woodpecker/` | `TestLoadSettingsRejectsInvalidProfiles`, `TestWoodpeckerDeliveryCommandSequences`, `TestReleaseKitSourceDeliveryWorkflowFromTaggedCheckout` |

View File

@@ -1,6 +1,6 @@
# ReleaseKit audit implementation plan # 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, Baseline: `e4be7fa` (2026-10-06). The [audit](audit.md) records the evidence,
product goals, and selected direction. This document owns implementation order product goals, and selected direction. This document owns implementation order
and completion criteria for that work. 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 cancellation. Keep default tests offline with a local module cache or a small
Go-command fixture; no live module server is needed. 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 ## Stage 9 — Add the shared CI delivery command
**Covers:** the shared command portion of F6. **Covers:** the shared command portion of F6.

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 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. // HostTarget reports the Go command's host operating system and architecture.
func (tool *Tool) HostTarget() Target { func (tool *Tool) HostTarget() Target {
return tool.host return tool.host

View File

@@ -3,13 +3,167 @@ package gotool
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strings" "strings"
"testing" "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) { func TestNativeAndStaticCommandsUseOwnedBaseline(t *testing.T) {
clearGoSettings(t) clearGoSettings(t)
t.Setenv("GOENV", "off") 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)
}

40
preparation.go Normal file
View File

@@ -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)
}

163
preparation_test.go Normal file
View File

@@ -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()
}