Support full-tag prepared CI delivery
This commit is contained in:
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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{}
|
||||
|
||||
33
docs/cli.md
33
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user