From c0cc50b1276103ed98d317e1902ee1515cdc1b64 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 10 Oct 2026 03:40:10 +0000 Subject: [PATCH] Support full-tag prepared CI delivery --- cmd/releasekit/internal/cli/config.go | 22 +++- cmd/releasekit/internal/cli/config_test.go | 7 +- cmd/releasekit/main.go | 8 +- cmd/releasekit/main_test.go | 53 +++++++++- docs/cli.md | 33 +++--- docs/config.md | 17 ++-- docs/roadmap/implementation.md | 11 ++ internal/git/prepare.go | 28 ++++- internal/preparation/preparation.go | 14 ++- preparation.go | 21 ++-- preparation_test.go | 113 +++++++++++++++++++-- 11 files changed, 272 insertions(+), 55 deletions(-) diff --git a/cmd/releasekit/internal/cli/config.go b/cmd/releasekit/internal/cli/config.go index 506198b..dc8842a 100644 --- a/cmd/releasekit/internal/cli/config.go +++ b/cmd/releasekit/internal/cli/config.go @@ -14,9 +14,10 @@ import ( "strings" "gitea.maximumdirect.net/eric/ReleaseKit/internal/process" + "gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract" ) -const Usage = "usage: releasekit version\n releasekit check [--repo DIR] [--config FILE] VERSION\n releasekit publish [--repo DIR] [--config FILE] [--expected-module DIR] VERSION\n releasekit build [--repo DIR] [--config FILE] [--output DIR] VERSION\n releasekit deliver [--repo DIR] [--config FILE] [--assets DIR] VERSION\n releasekit ci-deliver [--repo DIR] [--config FILE] [--remote NAME] --commit OID VERSION" +const Usage = "usage: releasekit version\n releasekit check [--repo DIR] [--config FILE] VERSION\n releasekit publish [--repo DIR] [--config FILE] [--expected-module DIR] VERSION\n releasekit build [--repo DIR] [--config FILE] [--output DIR] VERSION\n releasekit deliver [--repo DIR] [--config FILE] [--assets DIR] VERSION\n releasekit ci-deliver [--repo DIR] [--config FILE] [--remote NAME] [--prepared] [--expected-module DIR] --commit OID TAG" type Command string @@ -40,6 +41,8 @@ type Invocation struct { Commit string Version string ExpectedModule string + ExpectedTag string + Prepared bool } // Parse accepts only commands that have been implemented by the executable. @@ -69,13 +72,15 @@ func Parse(args []string) (Invocation, error) { } var remote *string expectedModule := "" - if action == PublishAction { + if action == PublishAction || action == CIDeliveryAction { flags.StringVar(&expectedModule, "expected-module", "", "expected module directory from a launcher mapping") } expectedCommit := "" + prepared := false if action == CIDeliveryAction { remote = flags.String("remote", "origin", "Git remote") flags.StringVar(&expectedCommit, "commit", "", "expected tagged commit") + flags.BoolVar(&prepared, "prepared", false, "verify an already prepared tagged checkout without fetching or checkout") } if err := flags.Parse(args[1:]); err != nil { return Invocation{}, err @@ -89,11 +94,22 @@ func Parse(args []string) (Invocation, error) { if action == CIDeliveryAction && !validGitObjectID(expectedCommit) { return Invocation{}, errors.New("ci-deliver --commit must be a full Git object ID") } + version := flags.Arg(0) + expectedTag := "" + if action == CIDeliveryAction { + expectedTag = version + if slash := strings.LastIndex(version, "/"); slash >= 0 { + version = version[slash+1:] + } + if !versioncontract.ValidStableVersion(version) { + return Invocation{}, errors.New("ci-deliver TAG must end in a stable vMAJOR.MINOR.PATCH version") + } + } parsedRemote := "" if remote != nil { parsedRemote = *remote } - return Invocation{Command: action, Repository: *repository, ConfigPath: *configPath, OutputDir: outputDir, AssetsDir: assetsDir, Remote: parsedRemote, Commit: expectedCommit, Version: flags.Arg(0), ExpectedModule: expectedModule}, nil + return Invocation{Command: action, Repository: *repository, ConfigPath: *configPath, OutputDir: outputDir, AssetsDir: assetsDir, Remote: parsedRemote, Commit: expectedCommit, Version: version, ExpectedModule: expectedModule, ExpectedTag: expectedTag, Prepared: prepared}, nil default: return Invocation{}, fmt.Errorf("unknown command %q", args[0]) } diff --git a/cmd/releasekit/internal/cli/config_test.go b/cmd/releasekit/internal/cli/config_test.go index f2ea4ca..d144e5c 100644 --- a/cmd/releasekit/internal/cli/config_test.go +++ b/cmd/releasekit/internal/cli/config_test.go @@ -51,11 +51,11 @@ func TestParse(t *testing.T) { if deliver.Command != DeliveryAction || deliver.Repository != "candidate" || deliver.ConfigPath != "releasekit.json" || deliver.AssetsDir != "assets with spaces" || deliver.Version != "v1.2.6" { t.Fatalf("Parse(deliver) = %#v", deliver) } - ciDelivery, err := Parse([]string{"ci-deliver", "--repo", "candidate", "--config", "profiles/release config.json", "--remote", "upstream", "--commit", "0123456789abcdef0123456789abcdef01234567", "v1.2.7"}) + ciDelivery, err := Parse([]string{"ci-deliver", "--repo", "candidate", "--config", "profiles/release config.json", "--remote", "upstream", "--prepared", "--expected-module", "integrations/ckconfig", "--commit", "0123456789abcdef0123456789abcdef01234567", "integrations/ckconfig/v1.2.7"}) if err != nil { t.Fatal(err) } - if ciDelivery.Command != CIDeliveryAction || ciDelivery.Repository != "candidate" || ciDelivery.ConfigPath != "profiles/release config.json" || ciDelivery.Remote != "upstream" || ciDelivery.Commit != "0123456789abcdef0123456789abcdef01234567" || ciDelivery.Version != "v1.2.7" { + if ciDelivery.Command != CIDeliveryAction || ciDelivery.Repository != "candidate" || ciDelivery.ConfigPath != "profiles/release config.json" || ciDelivery.Remote != "upstream" || ciDelivery.Commit != "0123456789abcdef0123456789abcdef01234567" || ciDelivery.Version != "v1.2.7" || ciDelivery.ExpectedTag != "integrations/ckconfig/v1.2.7" || ciDelivery.ExpectedModule != "integrations/ckconfig" || !ciDelivery.Prepared { t.Fatalf("Parse(ci-deliver) = %#v", ciDelivery) } ciDefault, err := Parse([]string{"ci-deliver", "--commit", "0123456789abcdef0123456789abcdef01234567", "v1.2.8"}) @@ -68,6 +68,9 @@ func TestParse(t *testing.T) { if _, err := Parse([]string{"ci-deliver", "--commit", "abc123", "v1.2.3"}); err == nil || !strings.Contains(err.Error(), "full Git object ID") { t.Fatalf("Parse(ci-deliver with abbreviated commit) error = %v, want full object ID error", err) } + if _, err := Parse([]string{"ci-deliver", "--commit", "0123456789abcdef0123456789abcdef01234567", "integrations/ckconfig/latest"}); err == nil || !strings.Contains(err.Error(), "stable vMAJOR.MINOR.PATCH") { + t.Fatalf("Parse(ci-deliver unstable terminal tag) error = %v", err) + } if _, err := Parse([]string{"check", "v1.2.3", "--repo", "repository"}); err == nil { t.Fatal("Parse accepted a flag after the version") } diff --git a/cmd/releasekit/main.go b/cmd/releasekit/main.go index d33b5b3..e55021f 100644 --- a/cmd/releasekit/main.go +++ b/cmd/releasekit/main.go @@ -255,9 +255,10 @@ func runDelivery(ctx context.Context, invocation cli.Invocation, stdout, stderr func runCIDelivery(ctx context.Context, invocation cli.Invocation, stdout, stderr io.Writer) int { err := releasekit.PrepareTaggedCheckout(ctx, releasekit.TaggedCheckoutRequest{ RepositoryDir: invocation.Repository, - Version: invocation.Version, + Tag: invocation.ExpectedTag, Remote: invocation.Remote, ExpectedCommit: invocation.Commit, + VerifyOnly: invocation.Prepared, }) if err != nil { fmt.Fprintf(stderr, "prepare tagged checkout: %v\n", err) @@ -272,6 +273,10 @@ func runCIDelivery(ctx context.Context, invocation cli.Invocation, stdout, stder fmt.Fprintf(stderr, "load profile: %v\n", err) return 2 } + if err := checkExpectedModule(invocation.Repository, invocation.ExpectedModule, settings.ModuleDir); err != nil { + fmt.Fprintf(stderr, "profile module selection: %v\n", err) + return 2 + } destination, status := deliveryDestination(settings, stderr) if status != 0 { return status @@ -351,6 +356,7 @@ func deliverWithDestination(ctx context.Context, invocation cli.Invocation, sett ModuleDir: settings.ModuleDir, Product: settings.Product, Version: invocation.Version, + ExpectedTag: invocation.ExpectedTag, Destination: destination, Build: buildSpec, Prebuilt: prebuilt, diff --git a/cmd/releasekit/main_test.go b/cmd/releasekit/main_test.go index 48d2191..4b7aeac 100644 --- a/cmd/releasekit/main_test.go +++ b/cmd/releasekit/main_test.go @@ -719,12 +719,14 @@ func TestCIDeliverLoadsTaggedProfileAndSkipsUnavailablePageOnlyDependencies(t *t expectedCommit := pushTaggedCLIRepository(t, repository) state.commit = expectedCommit writeDeliveryProfileAt(t, repository, "profiles/ci-delivery.json", cli.Settings{Product: "Later Branch Profile"}) + cliGitRun(t, repository, "checkout", "--detach", expectedCommit) + cliGitRun(t, repository, "remote", "remove", "origin") t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token") t.Setenv("GOPROXY", "off") t.Setenv("GOSUMDB", "off") var stdout, stderr bytes.Buffer - args := []string{"ci-deliver", "--repo", repository, "--config", "profiles/ci-delivery.json", "--commit", expectedCommit, "v1.2.3"} + args := []string{"ci-deliver", "--repo", repository, "--config", "profiles/ci-delivery.json", "--prepared", "--commit", expectedCommit, "v1.2.3"} if status := run(context.Background(), args, &stdout, &stderr); status != 0 { t.Fatalf("run() status=%d stdout=%q stderr=%q", status, stdout.String(), stderr.String()) } @@ -778,6 +780,55 @@ func TestCIDeliverRejectsWrongCommitBeforeGoOrHostedRequests(t *testing.T) { } } +func TestCIDeliverRejectsExpectedModuleMismatchBeforeDependencyPreparation(t *testing.T) { + repository := newCLIRepository(t) + if err := os.MkdirAll(filepath.Join(repository, "integrations", "other"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(repository, "integrations", "expected"), 0o700); err != nil { + t.Fatal(err) + } + writeRepositoryFile(t, repository, "integrations/other/go.mod", "module example.com/other\n\ngo 1.25.0\n") + writeRepositoryFile(t, repository, "integrations/expected/go.mod", "module example.com/expected\n\ngo 1.25.0\n") + cliGitRun(t, repository, "add", "integrations") + cliGitRun(t, repository, "commit", "-m", "add module profiles") + + state := &deliveryServerState{} + server := newDeliveryServer(t, state) + defer server.Close() + writeDeliveryProfile(t, repository, cli.Settings{ + Product: "Example Product", + ModuleDir: "integrations/other", + Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}}, + Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}, + }) + expectedCommit := pushTaggedCLIRepository(t, repository) + state.commit = expectedCommit + shimDir := t.TempDir() + markerPath := filepath.Join(shimDir, "go-invoked") + goShim := "#!/bin/sh\n: > \"$RELEASEKIT_GO_MARKER\"\nexit 99\n" + if err := os.WriteFile(filepath.Join(shimDir, "go"), []byte(goShim), 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("RELEASEKIT_GO_MARKER", markerPath) + t.Setenv("PATH", shimDir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token") + var stdout, stderr bytes.Buffer + args := []string{"ci-deliver", "--repo", repository, "--expected-module", "integrations/expected", "--commit", expectedCommit, "v1.2.3"} + if status := run(context.Background(), args, &stdout, &stderr); status != 2 { + t.Fatalf("run() status=%d stdout=%q stderr=%q, want profile-selection error", status, stdout.String(), stderr.String()) + } + if !strings.Contains(stderr.String(), "profile module selection") { + t.Fatalf("stderr=%q, want profile-selection diagnostic", stderr.String()) + } + if _, err := os.Stat(markerPath); !os.IsNotExist(err) { + t.Fatalf("Go was invoked before profile agreement: %v", err) + } + if state.apiRequests != 0 { + t.Fatalf("hosted API requests = %d, want 0", state.apiRequests) + } +} + func TestCIDeliverPreparesDependenciesAndBuildsConfiguredTargets(t *testing.T) { repository := newDeliveryBuildRepository(t) state := &deliveryServerState{} diff --git a/docs/cli.md b/docs/cli.md index 0adb091..d06783f 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -212,24 +212,31 @@ known missing assets are added. See the Prepare a tagged CI checkout and deliver it: ```sh -releasekit ci-deliver [--repo DIR] [--config FILE] [--remote NAME] --commit OID VERSION +releasekit ci-deliver [--repo DIR] [--config FILE] [--remote NAME] [--prepared] [--expected-module DIR] --commit OID TAG ``` -`ci-deliver` fetches `VERSION` from the selected Git remote (default `origin`), -requires the tag's peeled commit to match the required full `--commit` object -ID, and checks out that commit detached. It then loads the profile from the -prepared checkout, validates delivery settings and the configured token, and -downloads application dependencies only when the profile selects build targets. -Binary profiles are built and delivered through the same facade path as -`deliver`; source-only profiles skip dependency downloads and binary builds. +`TAG` is the complete Git tag name, including any module path prefix. Its last +path component must be a stable semantic version; other commands continue to +take a semantic `VERSION`. By default, `ci-deliver` fetches the exact `TAG` from +the selected Git remote (default `origin`), requires its peeled commit to match +the required full `--commit` object ID, checks out that commit detached, then +verifies the tag, HEAD, and clean worktree. With `--prepared`, it performs those +checks read-only against the existing checkout and does not fetch or check out. +In either mode, it loads the profile from the verified source. `--expected-module` +optionally checks that the profile selects the module chosen by a launcher; +module selection itself remains profile-owned. The command validates delivery +settings and the configured token, then downloads application dependencies only +when the profile selects build targets. Binary profiles are built and delivered +through the same facade path as `deliver`; source-only profiles skip dependency +downloads and binary builds. `--config` follows the existing profile path rules: relative paths are resolved -from `--repo`, and absolute paths are used as-is. This command explicitly -prepares and changes the selected checkout. Ordinary `deliver` continues to -operate on already prepared source without fetching, checking out, or -downloading dependencies. +from `--repo`, and absolute paths are used as-is. The default mode prepares and +changes the selected checkout; `--prepared` reuses a verified checkout. +Ordinary `deliver` continues to operate on already prepared source without +fetching, checking out, or downloading dependencies. In Woodpecker, pass `CI_WORKSPACE`, `CI_COMMIT_SHA`, and `CI_COMMIT_TAG` as the -repository, expected commit, and version arguments. The CLI does not read +repository, expected commit, and full tag arguments. The CLI does not read provider-specific environment variable names. Checkout and dependency preparation failures return status 1; usage, profile, credential, and invalid version errors return status 2. Final delivery uses the same statuses as diff --git a/docs/config.md b/docs/config.md index 1dc4d12..5af98b7 100644 --- a/docs/config.md +++ b/docs/config.md @@ -180,12 +180,17 @@ retained for retries or when build and delivery credentials need separate invocations. Ordinary `deliver` does not download application dependencies or change the checkout. -`ci-deliver` uses the same profile after preparing the selected tag in the -checkout. It checks out the tag only after its peeled commit matches the -provider-supplied commit ID, then loads the profile and validates delivery -settings and credentials. It runs `go mod download` only when the profile has -selected build targets; source-only delivery does not need application -dependencies. This preparation may access the network and may update +`ci-deliver` takes a full Git `TAG`; its final path component is the stable +semantic version and the complete value is checked against the profile's +derived tag. By default it fetches the selected tag, confirms its peeled commit +matches the provider-supplied commit ID, checks it out detached, and verifies +the tag, HEAD, and clean worktree. With `--prepared`, it performs the same +checks against an existing checkout without fetching or checking out. It then +loads the profile from that source and validates delivery settings and +credentials. An optional `--expected-module` checks a launcher's module +selection against the profile. It runs `go mod download` only when the profile +has selected build targets; source-only delivery does not need application +dependencies. Default preparation may access the network and may update `go.sum`. The command takes CI values as arguments, so profile settings remain independent of Woodpecker environment variable names. See the [CLI contract](cli.md) for the invocation and exit statuses. diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index d0860de..28df4f0 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -585,6 +585,17 @@ dirty source, missing/moved tag, and inconsistent profile module. Verify detache historical delivery, module-targeted dependencies for binaries, and page-only application-preparation omission. Run preparation and CLI delivery tests. +**Completed:** CI delivery now accepts literal full tags and derives the stable +semantic version from the final path component. Tagged checkout preparation +shares read-only tag, HEAD, expected-commit, and cleanliness verification with +the offline `--prepared` mode. CI profiles load from that checkout, optional +`--expected-module` is compared before application dependency preparation, and +the event tag is passed through delivery for exact identity checks. Binary +dependency preparation remains module-targeted and page-only profiles skip it. +Verified nested tag preparation, prepared CLI delivery without a remote, wrong +commit and dirty/missing/moved tag rejection, profile parsing, full `go test +./...`, `go run ./internal/apicheck`, and `git diff --check`. + ## Stage 12: Preserve the small root-module workflow **Outcome:** Existing root-only consumers retain a small launcher, profile, and diff --git a/internal/git/prepare.go b/internal/git/prepare.go index 3ac7a48..34e1604 100644 --- a/internal/git/prepare.go +++ b/internal/git/prepare.go @@ -29,12 +29,30 @@ func PrepareTaggedCheckout(ctx context.Context, repositoryDir, tag, remote, expe if _, err := run(ctx, repositoryDir, nil, "checkout", "--detach", source.TagCommit); err != nil { return fmt.Errorf("check out tag %s at %s: %w", tag, 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) + return VerifyTaggedCheckout(ctx, repositoryDir, tag, expectedCommit) +} + +// VerifyTaggedCheckout confirms that the existing local tag, HEAD, and clean +// worktree all identify the expected commit. It does not fetch or check out. +func VerifyTaggedCheckout(ctx context.Context, repositoryDir, tag, expectedCommit string) error { + if err := checkTagRef(ctx, repositoryDir, tag); err != nil { + return err } - if !strings.EqualFold(strings.TrimSpace(head), expectedCommit) { - return fmt.Errorf("prepared checkout HEAD is %s, expected %s", strings.TrimSpace(head), expectedCommit) + if !validObjectID(expectedCommit) { + return fmt.Errorf("invalid tagged checkout commit %q for tag %q", expectedCommit, tag) + } + source, err := InspectTaggedSource(ctx, repositoryDir, tag) + if err != nil { + return err + } + if source.Status != "" { + return fmt.Errorf("tagged checkout must be clean:\n%s", source.Status) + } + if !strings.EqualFold(source.TagCommit, expectedCommit) { + return fmt.Errorf("tag %s resolves to commit %s, expected %s", tag, source.TagCommit, expectedCommit) + } + if !strings.EqualFold(source.Head, expectedCommit) { + return fmt.Errorf("checkout HEAD is %s, expected %s", source.Head, expectedCommit) } return nil } diff --git a/internal/preparation/preparation.go b/internal/preparation/preparation.go index 4c5cf81..3374456 100644 --- a/internal/preparation/preparation.go +++ b/internal/preparation/preparation.go @@ -16,23 +16,29 @@ import ( type TaggedCheckoutRequest struct { RepositoryDir string - Version string + Tag string Remote string ExpectedCommit string + VerifyOnly bool } func PrepareTaggedCheckout(ctx context.Context, request TaggedCheckoutRequest) error { - if !versioncontract.ValidStableVersion(request.Version) { - return fmt.Errorf("%w: %q", candidate.ErrInvalidVersion, request.Version) + lastSlash := strings.LastIndex(request.Tag, "/") + version := request.Tag[lastSlash+1:] + if !versioncontract.ValidStableVersion(version) { + return fmt.Errorf("%w: tag %q must end in a stable semantic version", candidate.ErrInvalidVersion, request.Tag) } if strings.TrimSpace(request.ExpectedCommit) == "" { return errors.New("expected commit is required") } + if request.VerifyOnly { + return git.VerifyTaggedCheckout(ctx, request.RepositoryDir, request.Tag, request.ExpectedCommit) + } remote := request.Remote if remote == "" { remote = "origin" } - return git.PrepareTaggedCheckout(ctx, request.RepositoryDir, request.Version, remote, request.ExpectedCommit) + return git.PrepareTaggedCheckout(ctx, request.RepositoryDir, request.Tag, remote, request.ExpectedCommit) } func PrepareBuildDependencies(ctx context.Context, moduleDir string) error { diff --git a/preparation.go b/preparation.go index a777f50..e005da9 100644 --- a/preparation.go +++ b/preparation.go @@ -10,26 +10,29 @@ import ( "gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity" ) -// TaggedCheckoutRequest selects a stable tag and the full Git object ID -// expected by an external build or CI event. An empty Remote selects origin. +// TaggedCheckoutRequest selects a full Git tag and the Git object ID expected +// by an external build or CI event. An empty Remote selects origin. type TaggedCheckoutRequest struct { RepositoryDir string - Version string + Tag string Remote string ExpectedCommit string + // VerifyOnly checks an already prepared checkout without fetching or checking out. + VerifyOnly bool } -// 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. +// PrepareTaggedCheckout fetches Tag from Remote, verifies that its peeled +// commit matches ExpectedCommit, and checks out that commit detached. When +// VerifyOnly is true, it performs the same tag, HEAD, and cleanliness checks +// without fetching or changing the checkout. 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, + Tag: request.Tag, Remote: request.Remote, ExpectedCommit: request.ExpectedCommit, + VerifyOnly: request.VerifyOnly, }) } diff --git a/preparation_test.go b/preparation_test.go index 5933f4f..7badd23 100644 --- a/preparation_test.go +++ b/preparation_test.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "testing" releasekit "gitea.maximumdirect.net/eric/ReleaseKit" @@ -18,7 +19,7 @@ func TestPrepareTaggedCheckoutFetchesExpectedCommitAndDetaches(t *testing.T) { if err := releasekit.PrepareTaggedCheckout(context.Background(), releasekit.TaggedCheckoutRequest{ RepositoryDir: repository, - Version: "v1.2.3", + Tag: "v1.2.3", ExpectedCommit: tagCommit, }); err != nil { t.Fatal(err) @@ -31,6 +32,22 @@ func TestPrepareTaggedCheckoutFetchesExpectedCommitAndDetaches(t *testing.T) { } } +func TestPrepareTaggedCheckoutAcceptsNestedFullTag(t *testing.T) { + repository := newTaggedDeliveryConsumer(t) + tag := "integrations/ckconfig/v1.2.3" + tagCommit := publishPreparationTagNamed(t, repository, tag) + advancePreparationCheckout(t, repository, "later local commit") + + if err := releasekit.PrepareTaggedCheckout(context.Background(), releasekit.TaggedCheckoutRequest{ + RepositoryDir: repository, Tag: tag, 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) + } +} + func TestPrepareTaggedCheckoutRejectsWrongCommitBeforeCheckout(t *testing.T) { repository := newTaggedDeliveryConsumer(t) tagCommit := publishPreparationTag(t, repository) @@ -40,7 +57,7 @@ func TestPrepareTaggedCheckoutRejectsWrongCommitBeforeCheckout(t *testing.T) { err := releasekit.PrepareTaggedCheckout(context.Background(), releasekit.TaggedCheckoutRequest{ RepositoryDir: repository, - Version: "v1.2.3", + Tag: "v1.2.3", Remote: "candidate", ExpectedCommit: currentCommit, }) @@ -67,7 +84,7 @@ func TestPrepareTaggedCheckoutReportsLocalCheckoutConflict(t *testing.T) { err := releasekit.PrepareTaggedCheckout(context.Background(), releasekit.TaggedCheckoutRequest{ RepositoryDir: repository, - Version: "v1.2.3", + Tag: "v1.2.3", ExpectedCommit: tagCommit, }) if err == nil { @@ -90,7 +107,7 @@ func TestPrepareTaggedCheckoutReportsConflictingLocalTag(t *testing.T) { err := releasekit.PrepareTaggedCheckout(context.Background(), releasekit.TaggedCheckoutRequest{ RepositoryDir: repository, - Version: "v1.2.3", + Tag: "v1.2.3", ExpectedCommit: buildGit(t, repository, "rev-parse", "HEAD~1"), }) if err == nil { @@ -108,9 +125,9 @@ 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"}, + {RepositoryDir: repository, Tag: "not-a-version", ExpectedCommit: buildGit(t, repository, "rev-parse", "HEAD")}, + {RepositoryDir: repository, Tag: "v1.2.3"}, + {RepositoryDir: repository, Tag: "v1.2.3", ExpectedCommit: "not-an-object-id"}, } { err := releasekit.PrepareTaggedCheckout(context.Background(), request) if err == nil { @@ -121,12 +138,77 @@ func TestPrepareTaggedCheckoutValidatesInputsBeforeFetching(t *testing.T) { } } if err := releasekit.PrepareTaggedCheckout(context.Background(), releasekit.TaggedCheckoutRequest{ - RepositoryDir: repository, Version: "invalid", ExpectedCommit: "bad", + RepositoryDir: repository, Tag: "invalid", ExpectedCommit: "bad", }); !errors.Is(err, releasekit.ErrInvalidVersion) { t.Fatalf("invalid-version error = %v, want ErrInvalidVersion", err) } } +func TestPrepareTaggedCheckoutVerifyOnlyIsReadOnly(t *testing.T) { + for _, test := range []struct { + name string + wrong bool + mutate func(t *testing.T, repository string) + }{ + {name: "wrong expected commit", wrong: true}, + {name: "dirty checkout", mutate: func(t *testing.T, repository string) { + if err := os.WriteFile(filepath.Join(repository, "dirty.txt"), []byte("dirty"), 0o600); err != nil { + t.Fatal(err) + } + }}, + {name: "missing tag", mutate: func(t *testing.T, repository string) { + buildGit(t, repository, "tag", "-d", "v1.2.3") + }}, + {name: "moved tag", mutate: func(t *testing.T, repository string) { + advancePreparationCheckout(t, repository, "moved tagged commit") + buildGit(t, repository, "tag", "--force", "v1.2.3") + }}, + } { + t.Run(test.name, func(t *testing.T) { + repository := newTaggedDeliveryConsumer(t) + tagCommit := publishPreparationTag(t, repository) + if err := releasekit.PrepareTaggedCheckout(context.Background(), releasekit.TaggedCheckoutRequest{ + RepositoryDir: repository, Tag: "v1.2.3", ExpectedCommit: tagCommit, + }); err != nil { + t.Fatal(err) + } + if test.mutate != nil { + test.mutate(t, repository) + } + commit := tagCommit + if test.wrong { + commit = strings.Repeat("0", len(tagCommit)) + } + if err := releasekit.PrepareTaggedCheckout(context.Background(), releasekit.TaggedCheckoutRequest{ + RepositoryDir: repository, Tag: "v1.2.3", Remote: "missing-remote", + ExpectedCommit: commit, VerifyOnly: true, + }); err == nil { + t.Fatal("VerifyOnly accepted an inconsistent prepared checkout") + } + }) + } + + repository := newTaggedDeliveryConsumer(t) + tagCommit := publishPreparationTag(t, repository) + if err := releasekit.PrepareTaggedCheckout(context.Background(), releasekit.TaggedCheckoutRequest{ + RepositoryDir: repository, Tag: "v1.2.3", ExpectedCommit: tagCommit, + }); err != nil { + t.Fatal(err) + } + if err := releasekit.PrepareTaggedCheckout(context.Background(), releasekit.TaggedCheckoutRequest{ + RepositoryDir: repository, Tag: "v1.2.3", Remote: "missing-remote", + ExpectedCommit: tagCommit, VerifyOnly: true, + }); err != nil { + t.Fatalf("VerifyOnly rejected a prepared checkout: %v", err) + } + if head := buildGit(t, repository, "rev-parse", "HEAD"); head != tagCommit { + t.Fatalf("VerifyOnly changed HEAD to %s, want %s", head, tagCommit) + } + if branch := buildGit(t, repository, "rev-parse", "--abbrev-ref", "HEAD"); branch != "HEAD" { + t.Fatalf("VerifyOnly changed checkout branch to %q", branch) + } +} + func TestPrepareBuildDependenciesRequiresRepositoryDirectory(t *testing.T) { if err := releasekit.PrepareBuildDependencies(context.Background(), releasekit.DependencyPreparationRequest{RepositoryDir: " "}); err == nil { t.Fatal("PrepareBuildDependencies() accepted an empty repository directory") @@ -161,13 +243,22 @@ func TestPrepareBuildDependenciesUsesSelectedModuleDirectory(t *testing.T) { func publishPreparationTag(t *testing.T, repository string) string { t.Helper() + return publishPreparationTagNamed(t, repository, "v1.2.3") +} + +func publishPreparationTagNamed(t *testing.T, repository, tag string) string { + t.Helper() + ref := "refs/tags/" + tag + if _, err := buildGitOutput(repository, "show-ref", "--verify", "--quiet", ref); err != nil { + buildGit(t, repository, "tag", tag) + } 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") + commit := buildGit(t, repository, "rev-parse", ref+"^{commit}") + buildGit(t, repository, "push", "origin", ref) + buildGit(t, repository, "tag", "-d", tag) return commit }