Support full-tag prepared CI delivery

This commit is contained in:
2026-10-10 03:40:10 +00:00
parent f837871b77
commit c0cc50b127
11 changed files with 272 additions and 55 deletions

View File

@@ -14,9 +14,10 @@ import (
"strings" "strings"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/process" "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 type Command string
@@ -40,6 +41,8 @@ type Invocation struct {
Commit string Commit string
Version string Version string
ExpectedModule string ExpectedModule string
ExpectedTag string
Prepared bool
} }
// Parse accepts only commands that have been implemented by the executable. // Parse accepts only commands that have been implemented by the executable.
@@ -69,13 +72,15 @@ func Parse(args []string) (Invocation, error) {
} }
var remote *string var remote *string
expectedModule := "" expectedModule := ""
if action == PublishAction { if action == PublishAction || action == CIDeliveryAction {
flags.StringVar(&expectedModule, "expected-module", "", "expected module directory from a launcher mapping") flags.StringVar(&expectedModule, "expected-module", "", "expected module directory from a launcher mapping")
} }
expectedCommit := "" expectedCommit := ""
prepared := false
if action == CIDeliveryAction { if action == CIDeliveryAction {
remote = flags.String("remote", "origin", "Git remote") remote = flags.String("remote", "origin", "Git remote")
flags.StringVar(&expectedCommit, "commit", "", "expected tagged commit") 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 { if err := flags.Parse(args[1:]); err != nil {
return Invocation{}, err return Invocation{}, err
@@ -89,11 +94,22 @@ func Parse(args []string) (Invocation, error) {
if action == CIDeliveryAction && !validGitObjectID(expectedCommit) { if action == CIDeliveryAction && !validGitObjectID(expectedCommit) {
return Invocation{}, errors.New("ci-deliver --commit must be a full Git object ID") 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 := "" parsedRemote := ""
if remote != nil { if remote != nil {
parsedRemote = *remote 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: default:
return Invocation{}, fmt.Errorf("unknown command %q", args[0]) return Invocation{}, fmt.Errorf("unknown command %q", args[0])
} }

View File

@@ -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" { 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) 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 { if err != nil {
t.Fatal(err) 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) t.Fatalf("Parse(ci-deliver) = %#v", ciDelivery)
} }
ciDefault, err := Parse([]string{"ci-deliver", "--commit", "0123456789abcdef0123456789abcdef01234567", "v1.2.8"}) 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") { 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) 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 { if _, err := Parse([]string{"check", "v1.2.3", "--repo", "repository"}); err == nil {
t.Fatal("Parse accepted a flag after the version") t.Fatal("Parse accepted a flag after the version")
} }

View File

@@ -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 { func runCIDelivery(ctx context.Context, invocation cli.Invocation, stdout, stderr io.Writer) int {
err := releasekit.PrepareTaggedCheckout(ctx, releasekit.TaggedCheckoutRequest{ err := releasekit.PrepareTaggedCheckout(ctx, releasekit.TaggedCheckoutRequest{
RepositoryDir: invocation.Repository, RepositoryDir: invocation.Repository,
Version: invocation.Version, Tag: invocation.ExpectedTag,
Remote: invocation.Remote, Remote: invocation.Remote,
ExpectedCommit: invocation.Commit, ExpectedCommit: invocation.Commit,
VerifyOnly: invocation.Prepared,
}) })
if err != nil { if err != nil {
fmt.Fprintf(stderr, "prepare tagged checkout: %v\n", err) 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) fmt.Fprintf(stderr, "load profile: %v\n", err)
return 2 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) destination, status := deliveryDestination(settings, stderr)
if status != 0 { if status != 0 {
return status return status
@@ -351,6 +356,7 @@ func deliverWithDestination(ctx context.Context, invocation cli.Invocation, sett
ModuleDir: settings.ModuleDir, ModuleDir: settings.ModuleDir,
Product: settings.Product, Product: settings.Product,
Version: invocation.Version, Version: invocation.Version,
ExpectedTag: invocation.ExpectedTag,
Destination: destination, Destination: destination,
Build: buildSpec, Build: buildSpec,
Prebuilt: prebuilt, Prebuilt: prebuilt,

View File

@@ -719,12 +719,14 @@ func TestCIDeliverLoadsTaggedProfileAndSkipsUnavailablePageOnlyDependencies(t *t
expectedCommit := pushTaggedCLIRepository(t, repository) expectedCommit := pushTaggedCLIRepository(t, repository)
state.commit = expectedCommit state.commit = expectedCommit
writeDeliveryProfileAt(t, repository, "profiles/ci-delivery.json", cli.Settings{Product: "Later Branch Profile"}) 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("RELEASEKIT_GITEA_TOKEN", "fixture-token")
t.Setenv("GOPROXY", "off") t.Setenv("GOPROXY", "off")
t.Setenv("GOSUMDB", "off") t.Setenv("GOSUMDB", "off")
var stdout, stderr bytes.Buffer 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 { if status := run(context.Background(), args, &stdout, &stderr); status != 0 {
t.Fatalf("run() status=%d stdout=%q stderr=%q", status, stdout.String(), stderr.String()) 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) { func TestCIDeliverPreparesDependenciesAndBuildsConfiguredTargets(t *testing.T) {
repository := newDeliveryBuildRepository(t) repository := newDeliveryBuildRepository(t)
state := &deliveryServerState{} state := &deliveryServerState{}

View File

@@ -212,24 +212,31 @@ known missing assets are added. See the
Prepare a tagged CI checkout and deliver it: Prepare a tagged CI checkout and deliver it:
```sh ```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`), `TAG` is the complete Git tag name, including any module path prefix. Its last
requires the tag's peeled commit to match the required full `--commit` object path component must be a stable semantic version; other commands continue to
ID, and checks out that commit detached. It then loads the profile from the take a semantic `VERSION`. By default, `ci-deliver` fetches the exact `TAG` from
prepared checkout, validates delivery settings and the configured token, and the selected Git remote (default `origin`), requires its peeled commit to match
downloads application dependencies only when the profile selects build targets. the required full `--commit` object ID, checks out that commit detached, then
Binary profiles are built and delivered through the same facade path as verifies the tag, HEAD, and clean worktree. With `--prepared`, it performs those
`deliver`; source-only profiles skip dependency downloads and binary builds. 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 `--config` follows the existing profile path rules: relative paths are resolved
from `--repo`, and absolute paths are used as-is. This command explicitly from `--repo`, and absolute paths are used as-is. The default mode prepares and
prepares and changes the selected checkout. Ordinary `deliver` continues to changes the selected checkout; `--prepared` reuses a verified checkout.
operate on already prepared source without fetching, checking out, or Ordinary `deliver` continues to operate on already prepared source without
downloading dependencies. fetching, checking out, or downloading dependencies.
In Woodpecker, pass `CI_WORKSPACE`, `CI_COMMIT_SHA`, and `CI_COMMIT_TAG` as the 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 provider-specific environment variable names. Checkout and dependency
preparation failures return status 1; usage, profile, credential, and invalid preparation failures return status 1; usage, profile, credential, and invalid
version errors return status 2. Final delivery uses the same statuses as version errors return status 2. Final delivery uses the same statuses as

View File

@@ -180,12 +180,17 @@ retained for retries or when build and delivery credentials need separate
invocations. Ordinary `deliver` does not download application dependencies or invocations. Ordinary `deliver` does not download application dependencies or
change the checkout. change the checkout.
`ci-deliver` uses the same profile after preparing the selected tag in the `ci-deliver` takes a full Git `TAG`; its final path component is the stable
checkout. It checks out the tag only after its peeled commit matches the semantic version and the complete value is checked against the profile's
provider-supplied commit ID, then loads the profile and validates delivery derived tag. By default it fetches the selected tag, confirms its peeled commit
settings and credentials. It runs `go mod download` only when the profile has matches the provider-supplied commit ID, checks it out detached, and verifies
selected build targets; source-only delivery does not need application the tag, HEAD, and clean worktree. With `--prepared`, it performs the same
dependencies. This preparation may access the network and may update 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 `go.sum`. The command takes CI values as arguments, so profile settings remain
independent of Woodpecker environment variable names. See the independent of Woodpecker environment variable names. See the
[CLI contract](cli.md) for the invocation and exit statuses. [CLI contract](cli.md) for the invocation and exit statuses.

View File

@@ -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 historical delivery, module-targeted dependencies for binaries, and page-only
application-preparation omission. Run preparation and CLI delivery tests. 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 ## Stage 12: Preserve the small root-module workflow
**Outcome:** Existing root-only consumers retain a small launcher, profile, and **Outcome:** Existing root-only consumers retain a small launcher, profile, and

View File

@@ -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 { 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) 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}") return VerifyTaggedCheckout(ctx, repositoryDir, tag, expectedCommit)
if err != nil { }
return fmt.Errorf("verify prepared checkout HEAD: %w", err)
// 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) { if !validObjectID(expectedCommit) {
return fmt.Errorf("prepared checkout HEAD is %s, expected %s", strings.TrimSpace(head), 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 return nil
} }

View File

@@ -16,23 +16,29 @@ import (
type TaggedCheckoutRequest struct { type TaggedCheckoutRequest struct {
RepositoryDir string RepositoryDir string
Version string Tag string
Remote string Remote string
ExpectedCommit string ExpectedCommit string
VerifyOnly bool
} }
func PrepareTaggedCheckout(ctx context.Context, request TaggedCheckoutRequest) error { func PrepareTaggedCheckout(ctx context.Context, request TaggedCheckoutRequest) error {
if !versioncontract.ValidStableVersion(request.Version) { lastSlash := strings.LastIndex(request.Tag, "/")
return fmt.Errorf("%w: %q", candidate.ErrInvalidVersion, request.Version) 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) == "" { if strings.TrimSpace(request.ExpectedCommit) == "" {
return errors.New("expected commit is required") return errors.New("expected commit is required")
} }
if request.VerifyOnly {
return git.VerifyTaggedCheckout(ctx, request.RepositoryDir, request.Tag, request.ExpectedCommit)
}
remote := request.Remote remote := request.Remote
if remote == "" { if remote == "" {
remote = "origin" 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 { func PrepareBuildDependencies(ctx context.Context, moduleDir string) error {

View File

@@ -10,26 +10,29 @@ import (
"gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity" "gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity"
) )
// TaggedCheckoutRequest selects a stable tag and the full Git object ID // TaggedCheckoutRequest selects a full Git tag and the Git object ID expected
// expected by an external build or CI event. An empty Remote selects origin. // by an external build or CI event. An empty Remote selects origin.
type TaggedCheckoutRequest struct { type TaggedCheckoutRequest struct {
RepositoryDir string RepositoryDir string
Version string Tag string
Remote string Remote string
ExpectedCommit 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 // PrepareTaggedCheckout fetches Tag from Remote, verifies that its peeled
// commit matches ExpectedCommit, and checks out that commit detached. This // commit matches ExpectedCommit, and checks out that commit detached. When
// operation mutates the selected local repository; use it for disposable // VerifyOnly is true, it performs the same tag, HEAD, and cleanliness checks
// checkouts where tag-fetch and checkout conflicts can be resolved by the // without fetching or changing the checkout. It does not publish a tag or
// caller. It does not publish a tag or hosted content. // hosted content.
func PrepareTaggedCheckout(ctx context.Context, request TaggedCheckoutRequest) error { func PrepareTaggedCheckout(ctx context.Context, request TaggedCheckoutRequest) error {
return internalpreparation.PrepareTaggedCheckout(ctx, internalpreparation.TaggedCheckoutRequest{ return internalpreparation.PrepareTaggedCheckout(ctx, internalpreparation.TaggedCheckoutRequest{
RepositoryDir: request.RepositoryDir, RepositoryDir: request.RepositoryDir,
Version: request.Version, Tag: request.Tag,
Remote: request.Remote, Remote: request.Remote,
ExpectedCommit: request.ExpectedCommit, ExpectedCommit: request.ExpectedCommit,
VerifyOnly: request.VerifyOnly,
}) })
} }

View File

@@ -6,6 +6,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
releasekit "gitea.maximumdirect.net/eric/ReleaseKit" releasekit "gitea.maximumdirect.net/eric/ReleaseKit"
@@ -18,7 +19,7 @@ func TestPrepareTaggedCheckoutFetchesExpectedCommitAndDetaches(t *testing.T) {
if err := releasekit.PrepareTaggedCheckout(context.Background(), releasekit.TaggedCheckoutRequest{ if err := releasekit.PrepareTaggedCheckout(context.Background(), releasekit.TaggedCheckoutRequest{
RepositoryDir: repository, RepositoryDir: repository,
Version: "v1.2.3", Tag: "v1.2.3",
ExpectedCommit: tagCommit, ExpectedCommit: tagCommit,
}); err != nil { }); err != nil {
t.Fatal(err) 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) { func TestPrepareTaggedCheckoutRejectsWrongCommitBeforeCheckout(t *testing.T) {
repository := newTaggedDeliveryConsumer(t) repository := newTaggedDeliveryConsumer(t)
tagCommit := publishPreparationTag(t, repository) tagCommit := publishPreparationTag(t, repository)
@@ -40,7 +57,7 @@ func TestPrepareTaggedCheckoutRejectsWrongCommitBeforeCheckout(t *testing.T) {
err := releasekit.PrepareTaggedCheckout(context.Background(), releasekit.TaggedCheckoutRequest{ err := releasekit.PrepareTaggedCheckout(context.Background(), releasekit.TaggedCheckoutRequest{
RepositoryDir: repository, RepositoryDir: repository,
Version: "v1.2.3", Tag: "v1.2.3",
Remote: "candidate", Remote: "candidate",
ExpectedCommit: currentCommit, ExpectedCommit: currentCommit,
}) })
@@ -67,7 +84,7 @@ func TestPrepareTaggedCheckoutReportsLocalCheckoutConflict(t *testing.T) {
err := releasekit.PrepareTaggedCheckout(context.Background(), releasekit.TaggedCheckoutRequest{ err := releasekit.PrepareTaggedCheckout(context.Background(), releasekit.TaggedCheckoutRequest{
RepositoryDir: repository, RepositoryDir: repository,
Version: "v1.2.3", Tag: "v1.2.3",
ExpectedCommit: tagCommit, ExpectedCommit: tagCommit,
}) })
if err == nil { if err == nil {
@@ -90,7 +107,7 @@ func TestPrepareTaggedCheckoutReportsConflictingLocalTag(t *testing.T) {
err := releasekit.PrepareTaggedCheckout(context.Background(), releasekit.TaggedCheckoutRequest{ err := releasekit.PrepareTaggedCheckout(context.Background(), releasekit.TaggedCheckoutRequest{
RepositoryDir: repository, RepositoryDir: repository,
Version: "v1.2.3", Tag: "v1.2.3",
ExpectedCommit: buildGit(t, repository, "rev-parse", "HEAD~1"), ExpectedCommit: buildGit(t, repository, "rev-parse", "HEAD~1"),
}) })
if err == nil { if err == nil {
@@ -108,9 +125,9 @@ func TestPrepareTaggedCheckoutValidatesInputsBeforeFetching(t *testing.T) {
repository := newTaggedDeliveryConsumer(t) repository := newTaggedDeliveryConsumer(t)
publishPreparationTag(t, repository) publishPreparationTag(t, repository)
for _, request := range []releasekit.TaggedCheckoutRequest{ for _, request := range []releasekit.TaggedCheckoutRequest{
{RepositoryDir: repository, Version: "not-a-version", ExpectedCommit: buildGit(t, repository, "rev-parse", "HEAD")}, {RepositoryDir: repository, Tag: "not-a-version", ExpectedCommit: buildGit(t, repository, "rev-parse", "HEAD")},
{RepositoryDir: repository, Version: "v1.2.3"}, {RepositoryDir: repository, Tag: "v1.2.3"},
{RepositoryDir: repository, Version: "v1.2.3", ExpectedCommit: "not-an-object-id"}, {RepositoryDir: repository, Tag: "v1.2.3", ExpectedCommit: "not-an-object-id"},
} { } {
err := releasekit.PrepareTaggedCheckout(context.Background(), request) err := releasekit.PrepareTaggedCheckout(context.Background(), request)
if err == nil { if err == nil {
@@ -121,12 +138,77 @@ func TestPrepareTaggedCheckoutValidatesInputsBeforeFetching(t *testing.T) {
} }
} }
if err := releasekit.PrepareTaggedCheckout(context.Background(), releasekit.TaggedCheckoutRequest{ 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) { }); !errors.Is(err, releasekit.ErrInvalidVersion) {
t.Fatalf("invalid-version error = %v, want ErrInvalidVersion", err) 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) { func TestPrepareBuildDependenciesRequiresRepositoryDirectory(t *testing.T) {
if err := releasekit.PrepareBuildDependencies(context.Background(), releasekit.DependencyPreparationRequest{RepositoryDir: " "}); err == nil { if err := releasekit.PrepareBuildDependencies(context.Background(), releasekit.DependencyPreparationRequest{RepositoryDir: " "}); err == nil {
t.Fatal("PrepareBuildDependencies() accepted an empty repository directory") 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 { func publishPreparationTag(t *testing.T, repository string) string {
t.Helper() 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() remoteDir := t.TempDir()
remote := filepath.Join(remoteDir, "origin.git") remote := filepath.Join(remoteDir, "origin.git")
buildGit(t, remoteDir, "init", "--bare", remote) buildGit(t, remoteDir, "init", "--bare", remote)
buildGit(t, repository, "remote", "add", "origin", remote) buildGit(t, repository, "remote", "add", "origin", remote)
commit := buildGit(t, repository, "rev-parse", "refs/tags/v1.2.3^{commit}") commit := buildGit(t, repository, "rev-parse", ref+"^{commit}")
buildGit(t, repository, "push", "origin", "refs/tags/v1.2.3") buildGit(t, repository, "push", "origin", ref)
buildGit(t, repository, "tag", "-d", "v1.2.3") buildGit(t, repository, "tag", "-d", tag)
return commit return commit
} }