From 2e617db23cb342a3273f8df5601451aba87d000e Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sun, 1 Nov 2026 18:20:01 +0000 Subject: [PATCH] Connect neutral delivery to the Gitea CLI --- build_test.go | 2 +- cmd/releasekit/assets_test.go | 8 +- cmd/releasekit/committed_identity_test.go | 2 +- cmd/releasekit/internal/cli/config.go | 3 +- cmd/releasekit/local_module_test.go | 2 +- cmd/releasekit/main.go | 53 +++++++-- cmd/releasekit/main_test.go | 45 +++++--- .../multi_module_woodpecker_test.go | 7 +- cmd/releasekit/release_wrapper_test.go | 1 + .../releasekit_delivery_workflow_test.go | 2 +- cmd/releasekit/token_file_test.go | 6 +- cmd/releasekit/woodpecker_test.go | 4 +- delivery.go | 109 ++++++++++++++++-- delivery_test.go | 29 +++-- docs/roadmap/implementation.md | 4 +- examples/releasekit-binary.json | 1 + examples/releasekit.json | 1 + internal/delivery/delivery.go | 81 ++++++++----- performance_delivery_test.go | 9 +- releasekit.json | 1 + repository_test.go | 2 +- 21 files changed, 275 insertions(+), 97 deletions(-) diff --git a/build_test.go b/build_test.go index 6ffd474..adf3ca0 100644 --- a/build_test.go +++ b/build_test.go @@ -815,7 +815,7 @@ func main(){ if len(os.Args)>1 && os.Args[1]=="version" {fmt.Println("fixture",b } buildGit(t, repository, "restore", "cmd/app/main.go") buildGit(t, repository, "checkout", "--detach", "v1.2.3") - delivered, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{RepositoryDir: repository, Product: "Fixture", Version: "v1.2.3", Prebuilt: &result}) + delivered, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{RepositoryDir: repository, Product: "Fixture", Version: "v1.2.3", Destination: releasekit.DeliveryDestination{Forge: releasekit.ForgeGitea}, Prebuilt: &result}) if err == nil || !strings.Contains(err.Error(), "build source") || delivered.State != releasekit.DeliveryNotAttempted { t.Fatalf("dirty source delivered: %#v %v", delivered, err) } diff --git a/cmd/releasekit/assets_test.go b/cmd/releasekit/assets_test.go index d970da3..2d2f9e8 100644 --- a/cmd/releasekit/assets_test.go +++ b/cmd/releasekit/assets_test.go @@ -42,7 +42,7 @@ func TestBuildAndPrebuiltDeliveryUseSameDirectoryBase(t *testing.T) { defer server.Close() settings := cli.Settings{Product: "Example Product", ModuleDir: tt.module, Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}}, - Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}, + Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"}, } writeDeliveryProfile(t, repository, settings) cliGitRun(t, repository, "add", ".") @@ -94,9 +94,9 @@ func TestBuildAndPrebuiltDeliveryUseSameDirectoryBase(t *testing.T) { if err := os.WriteFile(receipt.Artifacts[0].Path, []byte("changed"), 0o600); err != nil { t.Fatal(err) } - requestsBefore := state.snapshot().apiRequests - if status := run(context.Background(), args, &stdout, &stderr); status != 1 || state.snapshot().apiRequests != requestsBefore { - t.Fatalf("changed content status=%d, requests=%d->%d, stderr=%s", status, requestsBefore, state.snapshot().apiRequests, stderr.String()) + writesBefore := state.snapshot().writes + if status := run(context.Background(), args, &stdout, &stderr); status != 1 || state.snapshot().writes != writesBefore { + t.Fatalf("changed content status=%d, writes=%d->%d, stderr=%s", status, writesBefore, state.snapshot().writes, stderr.String()) } }) } diff --git a/cmd/releasekit/committed_identity_test.go b/cmd/releasekit/committed_identity_test.go index c7dc007..9940cc2 100644 --- a/cmd/releasekit/committed_identity_test.go +++ b/cmd/releasekit/committed_identity_test.go @@ -40,7 +40,7 @@ func TestPageOnlyDeliveryUsesCommittedModuleInSparseCheckout(t *testing.T) { server := newDeliveryServer(t, state) defer server.Close() writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", ModuleDir: module, - Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}, + Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"}, }) state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") }) cliGitRun(t, repository, "tag", state.snapshot().tag) diff --git a/cmd/releasekit/internal/cli/config.go b/cmd/releasekit/internal/cli/config.go index 983a057..8d2afca 100644 --- a/cmd/releasekit/internal/cli/config.go +++ b/cmd/releasekit/internal/cli/config.go @@ -196,8 +196,9 @@ type GoValidationSettings struct { CompileTargets []string `json:"compile_targets,omitempty"` } -// DeliverySettings selects the hosted Gitea release destination. +// DeliverySettings selects the hosted forge release destination. type DeliverySettings struct { + Forge string `json:"forge,omitempty"` BaseURL string `json:"base_url"` Owner string `json:"owner"` Repository string `json:"repository"` diff --git a/cmd/releasekit/local_module_test.go b/cmd/releasekit/local_module_test.go index 6a825ff..c38267d 100644 --- a/cmd/releasekit/local_module_test.go +++ b/cmd/releasekit/local_module_test.go @@ -20,7 +20,7 @@ func TestMainModuleWithoutDomainSupportsCheckBuildAndPageDelivery(t *testing.T) defer server.Close() settings := cli.Settings{Product: "Example Product", Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}}, - Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}, + Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"}, } writeDeliveryProfile(t, repository, settings) for _, args := range [][]string{ diff --git a/cmd/releasekit/main.go b/cmd/releasekit/main.go index 500c97c..42843e3 100644 --- a/cmd/releasekit/main.go +++ b/cmd/releasekit/main.go @@ -296,6 +296,10 @@ func runCIDelivery(ctx context.Context, invocation cli.Invocation, stdout, stder return status } if settings.Build != nil && len(settings.Build.Targets) != 0 { + if err := releasekit.PreflightDelivery(ctx, destination); err != nil { + fmt.Fprintf(stderr, "delivery credential preflight: %v\n", err) + return deliveryPreflightExitStatus(err) + } if err := releasekit.PrepareBuildDependencies(ctx, releasekit.DependencyPreparationRequest{ RepositoryDir: invocation.Repository, ModuleDir: settings.ModuleDir, @@ -317,11 +321,24 @@ func deliverWithSettings(ctx context.Context, invocation cli.Invocation, setting return deliverWithDestination(ctx, invocation, settings, destination, stdout, stderr) } -func deliveryDestination(repositoryDir string, settings cli.Settings, stderr io.Writer) (releasekit.GiteaDestination, int) { +func deliveryDestination(repositoryDir string, settings cli.Settings, stderr io.Writer) (releasekit.DeliveryDestination, int) { delivery := settings.Delivery - if delivery == nil || strings.TrimSpace(delivery.BaseURL) == "" || strings.TrimSpace(delivery.Owner) == "" || strings.TrimSpace(delivery.Repository) == "" { + if delivery == nil { + fmt.Fprintln(stderr, "deliver requires an explicit delivery.forge selection in the profile") + return releasekit.DeliveryDestination{}, 2 + } + switch strings.TrimSpace(delivery.Forge) { + case "": + fmt.Fprintln(stderr, "deliver requires delivery.forge in the profile") + return releasekit.DeliveryDestination{}, 2 + case string(releasekit.ForgeGitea): + default: + fmt.Fprintf(stderr, "unsupported delivery forge %q\n", delivery.Forge) + return releasekit.DeliveryDestination{}, 2 + } + if strings.TrimSpace(delivery.BaseURL) == "" || strings.TrimSpace(delivery.Owner) == "" || strings.TrimSpace(delivery.Repository) == "" { fmt.Fprintln(stderr, "deliver requires base_url, owner, and repository in the profile delivery settings") - return releasekit.GiteaDestination{}, 2 + return releasekit.DeliveryDestination{}, 2 } tokenEnv := delivery.TokenEnv if strings.TrimSpace(tokenEnv) == "" { @@ -336,22 +353,22 @@ func deliveryDestination(repositoryDir string, settings cli.Settings, stderr io. contents, err := os.ReadFile(name) if err != nil { fmt.Fprintf(stderr, "read delivery token file: %v\n", err) - return releasekit.GiteaDestination{}, 2 + return releasekit.DeliveryDestination{}, 2 } token = strings.TrimSpace(string(contents)) if token == "" { fmt.Fprintln(stderr, "delivery token file is empty or blank") - return releasekit.GiteaDestination{}, 2 + return releasekit.DeliveryDestination{}, 2 } } if strings.TrimSpace(token) == "" { fmt.Fprintf(stderr, "deliver requires a non-empty token in environment variable %s\n", tokenEnv) - return releasekit.GiteaDestination{}, 2 + return releasekit.DeliveryDestination{}, 2 } - return releasekit.GiteaDestination{BaseURL: delivery.BaseURL, Owner: delivery.Owner, Repository: delivery.Repository, Token: token}, 0 + return releasekit.DeliveryDestination{Forge: releasekit.ForgeGitea, BaseURL: delivery.BaseURL, Owner: delivery.Owner, Repository: delivery.Repository, Token: token}, 0 } -func deliverWithDestination(ctx context.Context, invocation cli.Invocation, settings cli.Settings, destination releasekit.GiteaDestination, stdout, stderr io.Writer) int { +func deliverWithDestination(ctx context.Context, invocation cli.Invocation, settings cli.Settings, destination releasekit.DeliveryDestination, stdout, stderr io.Writer) int { hasTargets := settings.Build != nil && len(settings.Build.Targets) != 0 if invocation.AssetsDir != "" && !hasTargets { fmt.Fprintln(stderr, "--assets requires build targets in the profile") @@ -406,6 +423,10 @@ func deliverWithDestination(ctx context.Context, invocation cli.Invocation, sett if errors.Is(err, releasekit.ErrInvalidVersion) { return 2 } + var preflightErr *releasekit.DeliveryPreflightError + if errors.As(err, &preflightErr) { + return deliveryPreflightExitStatus(preflightErr) + } if result.State == releasekit.DeliveryUnknown { return 3 } @@ -419,6 +440,22 @@ func deliverWithDestination(ctx context.Context, invocation cli.Invocation, sett return 0 } +func deliveryPreflightExitStatus(err error) int { + var preflightErr *releasekit.DeliveryPreflightError + if !errors.As(err, &preflightErr) { + return 1 + } + switch preflightErr.Kind { + case releasekit.DeliveryPreflightMissingCredential, + releasekit.DeliveryPreflightInvalidCredential, + releasekit.DeliveryPreflightInsufficientPermissions, + releasekit.DeliveryPreflightInaccessibleDestination: + return 2 + default: + return 1 + } +} + func writeDeliveryAssets(output io.Writer, result releasekit.DeliveryResult) { for _, group := range []struct { label string diff --git a/cmd/releasekit/main_test.go b/cmd/releasekit/main_test.go index b1bb237..831a4ec 100644 --- a/cmd/releasekit/main_test.go +++ b/cmd/releasekit/main_test.go @@ -639,7 +639,7 @@ func TestDeliverPageOnlyCreatesAndReusesRelease(t *testing.T) { settings := cli.Settings{ Product: "Example Product", Validation: &cli.ValidationSettings{Go: cli.GoValidationSettings{CompileTargets: []string{"linux/amd64"}}}, - Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}, + Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"}, } writeDeliveryProfile(t, repository, settings) state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") }) @@ -673,7 +673,7 @@ func TestDeliverBuildsTaggedArtifactsAndReusesAssets(t *testing.T) { settings := cli.Settings{ Product: "Example Product", Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}}, - Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}, + Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"}, } writeDeliveryProfile(t, repository, settings) state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") }) @@ -716,7 +716,7 @@ func TestCIDeliverLoadsTaggedProfileAndSkipsUnavailablePageOnlyDependencies(t *t defer server.Close() writeDeliveryProfileAt(t, repository, "profiles/ci-delivery.json", cli.Settings{ Product: "Example Product", - Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}, + Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"}, }) expectedCommit := pushTaggedCLIRepository(t, repository) state.update(func(state *deliveryServerData) { state.commit = expectedCommit }) @@ -751,7 +751,7 @@ func TestCIDeliverRejectsWrongCommitBeforeGoOrHostedRequests(t *testing.T) { writeDeliveryProfile(t, repository, cli.Settings{ Product: "Example Product", Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}}, - Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}, + Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"}, }) expectedCommit := pushTaggedCLIRepository(t, repository) state.update(func(state *deliveryServerData) { state.commit = expectedCommit }) @@ -802,7 +802,7 @@ func TestCIDeliverRejectsExpectedModuleMismatchBeforeDependencyPreparation(t *te 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"}, + Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"}, }) expectedCommit := pushTaggedCLIRepository(t, repository) state.update(func(state *deliveryServerData) { state.commit = expectedCommit }) @@ -839,7 +839,7 @@ func TestCIDeliverPreparesDependenciesAndBuildsConfiguredTargets(t *testing.T) { writeDeliveryProfile(t, repository, cli.Settings{ Product: "Example Product", Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH)}}, - Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}, + Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"}, }) expectedCommit := pushTaggedCLIRepository(t, repository) state.update(func(state *deliveryServerData) { state.commit = expectedCommit }) @@ -865,7 +865,7 @@ func TestCIDeliverValidatesCredentialsBeforePreparingDependencies(t *testing.T) writeDeliveryProfile(t, repository, cli.Settings{ Product: "Example Product", Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}}, - Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}, + Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"}, }) expectedCommit := pushTaggedCLIRepository(t, repository) state.update(func(state *deliveryServerData) { state.commit = expectedCommit }) @@ -903,7 +903,7 @@ func TestDeliverRejectsInvalidTaggedSourceBeforeManagedBuild(t *testing.T) { settings := cli.Settings{ Product: "Example Product", Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}}, - Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}, + Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"}, } writeDeliveryProfile(t, repository, settings) state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") }) @@ -958,7 +958,7 @@ func TestDeliverFailureReportsRecoveryForPartialConflictAndUnknownStates(t *test writeDeliveryProfile(t, repository, cli.Settings{ Product: "Example Product", Build: &build, - Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}, + Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"}, }) state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") }) cliGitRun(t, repository, "tag", "v1.2.3") @@ -1002,7 +1002,7 @@ func TestDeliverRejectsWrongHostedTagBeforeReleaseWrite(t *testing.T) { state := &deliveryServerState{data: deliveryServerData{remoteCommit: strings.Repeat("0", 40)}} server := newDeliveryServer(t, state) defer server.Close() - writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}}) + writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"}}) state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") }) cliGitRun(t, repository, "tag", "v1.2.3") t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token") @@ -1018,7 +1018,7 @@ func TestDeliverRequiresCredentialBeforeRemoteRequests(t *testing.T) { state := &deliveryServerState{} server := newDeliveryServer(t, state) defer server.Close() - writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example", TokenEnv: "RELEASEKIT_TEST_DELIVERY_TOKEN"}}) + writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example", TokenEnv: "RELEASEKIT_TEST_DELIVERY_TOKEN"}}) state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") }) cliGitRun(t, repository, "tag", "v1.2.3") t.Setenv("RELEASEKIT_TEST_DELIVERY_TOKEN", "") @@ -1034,7 +1034,7 @@ func TestDeliverRejectsCheckoutThatDiffersFromLocalTag(t *testing.T) { state := &deliveryServerState{} server := newDeliveryServer(t, state) defer server.Close() - writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}}) + writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"}}) state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") }) cliGitRun(t, repository, "tag", "v1.2.3") if err := os.WriteFile(filepath.Join(repository, "later.txt"), []byte("later commit"), 0o600); err != nil { @@ -1061,7 +1061,7 @@ func TestDeliverRejectsTamperedAssetsAndStaleReceiptBeforeRemoteWrites(t *testin server := newDeliveryServer(t, state) defer server.Close() current := cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}} - settings := cli.Settings{Product: "Example Product", Build: ¤t, Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}} + settings := cli.Settings{Product: "Example Product", Build: ¤t, Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"}} writeDeliveryProfile(t, repository, settings) state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") }) cliGitRun(t, repository, "tag", "v1.2.3") @@ -1097,7 +1097,7 @@ func TestDeliverRejectsTamperedAssetsAndStaleReceiptBeforeRemoteWrites(t *testin } return } - if status != 1 || state.snapshot().apiRequests != 0 || state.snapshot().releaseCreates != 0 || state.snapshot().assetUploads != 0 { + if status != 1 || state.snapshot().writes != 0 { t.Fatalf("%s receipt status=%d requests=%d stdout=%q stderr=%q", mode, status, state.snapshot().apiRequests, stdout.String(), stderr.String()) } if mode == "tampered" || mode == "missing artifact" { @@ -1174,6 +1174,7 @@ type deliveryServerData struct { product string remoteCommit string apiRequests int + writes int pageExists bool releaseCreates int assetUploads int @@ -1196,11 +1197,19 @@ func newDeliveryServer(t *testing.T, state *deliveryServerState) *httptest.Serve defer state.mu.Unlock() if strings.HasPrefix(r.URL.Path, "/api/") { data.apiRequests++ + if r.Method != http.MethodGet { + data.writes++ + } if r.Header.Get("Authorization") != "token fixture-token" { t.Errorf("authorization = %q", r.Header.Get("Authorization")) } } switch r.URL.Path { + case "/api/v1/repos/owner/example", "/api/v1/repositories/1": + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": 1, "name": "example", "full_name": "owner/example", + "owner": map[string]string{"login": "owner"}, + }) case "/api/v1/repos/owner/example/tags/" + data.tag: if data.commit == "" { t.Error("tag source commit was not initialized") @@ -1445,7 +1454,7 @@ func TestDeliverRejectsReceiptBuiltFromDirtySourceAfterCleanup(t *testing.T) { state := &deliveryServerState{} server := newDeliveryServer(t, state) defer server.Close() - settings := cli.Settings{Product: "Example Product", Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}}, Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}} + settings := cli.Settings{Product: "Example Product", Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}}, Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"}} writeDeliveryProfile(t, repository, settings) state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") }) cliGitRun(t, repository, "tag", "v1.2.3") @@ -1485,7 +1494,7 @@ func TestRecoveredPageCreatePreservesPartialDeliveryOnInspectionFailure(t *testi server := newDeliveryServer(t, state) defer server.Close() build := cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}} - writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", Build: &build, Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}}) + writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", Build: &build, Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"}}) state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") }) cliGitRun(t, repository, "tag", "v1.2.3") assets, _ := writeDeliveryReceipt(t, repository, build) @@ -1505,7 +1514,7 @@ func TestUnreadableAssetsDoNotClaimAnUnknownWrite(t *testing.T) { server := newDeliveryServer(t, state) defer server.Close() build := cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}} - writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", Build: &build, Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}}) + writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", Build: &build, Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"}}) state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") }) cliGitRun(t, repository, "tag", "v1.2.3") assets, _ := writeDeliveryReceipt(t, repository, build) @@ -1561,7 +1570,7 @@ func TestTemporaryDeliveryBuildDoesNotRequireWritableRepositoryParent(t *testing state := &deliveryServerState{} server := newDeliveryServer(t, state) defer server.Close() - writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}}, Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}}) + writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}}, Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"}}) state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") }) cliGitRun(t, repository, "tag", "v1.2.3") parent := t.TempDir() diff --git a/cmd/releasekit/multi_module_woodpecker_test.go b/cmd/releasekit/multi_module_woodpecker_test.go index ff1e908..dd8e686 100644 --- a/cmd/releasekit/multi_module_woodpecker_test.go +++ b/cmd/releasekit/multi_module_woodpecker_test.go @@ -209,6 +209,11 @@ func newMappedDeliveryFixture(t *testing.T, tag string) *mappedDeliveryFixture { apiRoot := "/api/v1/repos/owner/example/" escapedTag := url.PathEscape(tag) switch r.URL.EscapedPath() { + case "/api/v1/repos/owner/example", "/api/v1/repositories/1": + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": 1, "name": "example", "full_name": "owner/example", + "owner": map[string]string{"login": "owner"}, + }) case apiRoot + "tags/" + escapedTag: _ = json.NewEncoder(w).Encode(map[string]any{"name": tag, "commit": map[string]string{"sha": data.commit}}) case apiRoot + "releases/tags/" + escapedTag: @@ -366,7 +371,7 @@ func workflowModuleFile(module, version string) string { } func workflowDeliverySettings(baseURL string) *cli.DeliverySettings { - return &cli.DeliverySettings{BaseURL: baseURL, Owner: "owner", Repository: "example", TokenEnv: "RELEASEKIT_GITEA_TOKEN"} + return &cli.DeliverySettings{Forge: "gitea", BaseURL: baseURL, Owner: "owner", Repository: "example", TokenEnv: "RELEASEKIT_GITEA_TOKEN"} } func writeMappedProfile(t *testing.T, repository, name string, settings cli.Settings) { diff --git a/cmd/releasekit/release_wrapper_test.go b/cmd/releasekit/release_wrapper_test.go index a8fe9b2..f7ed6f3 100644 --- a/cmd/releasekit/release_wrapper_test.go +++ b/cmd/releasekit/release_wrapper_test.go @@ -79,6 +79,7 @@ func TestReleaseCandidate(t *testing.T) { "product": "ReleaseKit", "checks": []map[string]any{{"args": []string{"sh", "-c", "if [ -n \"$RELEASEKIT_TEST_ARM_GIT_SHIM\" ]; then : > \"$RELEASEKIT_TEST_ARM_GIT_SHIM\"; fi"}}}, "delivery": map[string]any{ + "forge": "gitea", "base_url": "https://forge.maximumdirect.net", "owner": "base-layer", "repository": "releasekit", diff --git a/cmd/releasekit/releasekit_delivery_workflow_test.go b/cmd/releasekit/releasekit_delivery_workflow_test.go index 64354a6..2aae7be 100644 --- a/cmd/releasekit/releasekit_delivery_workflow_test.go +++ b/cmd/releasekit/releasekit_delivery_workflow_test.go @@ -55,7 +55,7 @@ func TestReleaseKitSourceDeliveryWorkflowFromTaggedCheckout(t *testing.T) { defer server.Close() writeDeliveryProfile(t, repository, cli.Settings{ Product: "ReleaseKit", - Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}, + Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"}, }) state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") }) cliGitRun(t, repository, "tag", "-a", version, "-m", "ReleaseKit v1.2.3") diff --git a/cmd/releasekit/token_file_test.go b/cmd/releasekit/token_file_test.go index ed3db8e..fc60b0b 100644 --- a/cmd/releasekit/token_file_test.go +++ b/cmd/releasekit/token_file_test.go @@ -37,7 +37,7 @@ func TestDeliveryTokenFileSelection(t *testing.T) { } t.Setenv("RELEASEKIT_TEST_TOKEN", test.env) var stderr bytes.Buffer - destination, status := deliveryDestination(repository, cli.Settings{Delivery: &cli.DeliverySettings{ + destination, status := deliveryDestination(repository, cli.Settings{Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: "https://gitea.example", Owner: "owner", Repository: "example", TokenEnv: "RELEASEKIT_TEST_TOKEN", TokenFile: name, }}, &stderr) @@ -58,7 +58,7 @@ func TestCheckReadsForbiddenFilesSelectionFromProfile(t *testing.T) { writeDeliveryProfile(t, repository, cli.Settings{ Product: "Example Product", Validation: &cli.ValidationSettings{ForbiddenFiles: []string{"token-file"}}, - Delivery: &cli.DeliverySettings{BaseURL: "https://gitea.example", Owner: "owner", Repository: "example", TokenFile: "missing-token-file"}, + Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: "https://gitea.example", Owner: "owner", Repository: "example", TokenFile: "missing-token-file"}, }) writeRepositoryFile(t, repository, "token-file", "fixture-secret\n") cliGitRun(t, repository, "add", "token-file") @@ -82,7 +82,7 @@ func TestDeliverAndCIDeliverReadTokenFileFromRepositoryRoot(t *testing.T) { defer server.Close() writeDeliveryProfile(t, repository, cli.Settings{ Product: "Example Product", - Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example", TokenFile: ".releasekit/token"}, + Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example", TokenFile: ".releasekit/token"}, }) writeRepositoryFile(t, repository, ".gitignore", ".releasekit/\n") cliGitRun(t, repository, "add", ".gitignore") diff --git a/cmd/releasekit/woodpecker_test.go b/cmd/releasekit/woodpecker_test.go index b7748da..02ebe6c 100644 --- a/cmd/releasekit/woodpecker_test.go +++ b/cmd/releasekit/woodpecker_test.go @@ -39,7 +39,7 @@ func TestWoodpeckerTagDeliveryTemplate(t *testing.T) { defer server.Close() writeDeliveryProfile(t, repository, cli.Settings{ Product: "Example Product", - Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}, + Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"}, }) commit := pushTaggedCLIRepository(t, repository) state.update(func(state *deliveryServerData) { state.commit = commit }) @@ -70,7 +70,7 @@ func TestWoodpeckerTagDeliveryTemplate(t *testing.T) { BinaryName: "helper", Targets: []string{fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH)}, }, - Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}, + Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"}, }) commit := pushTaggedCLIRepository(t, repository) state.update(func(state *deliveryServerData) { state.commit = commit }) diff --git a/delivery.go b/delivery.go index f31ab4e..98dfbb9 100644 --- a/delivery.go +++ b/delivery.go @@ -2,10 +2,14 @@ package releasekit import ( "context" + "errors" + "fmt" "io" + "strings" internalbuild "go.maximumdirect.net/releasekit/internal/build" internaldelivery "go.maximumdirect.net/releasekit/internal/delivery" + "go.maximumdirect.net/releasekit/internal/forge" "go.maximumdirect.net/releasekit/internal/gitea" ) @@ -25,21 +29,57 @@ const ( DeliveryUnknown DeliveryState = "unknown" ) -// GiteaDestination supplies the hosted repository and explicit access token. -type GiteaDestination struct { +// Forge selects the hosted delivery backend. +type Forge string + +const ( + // ForgeGitea selects Gitea for hosted delivery. + ForgeGitea Forge = "gitea" +) + +// DeliveryDestination supplies an explicitly selected hosted destination. +type DeliveryDestination struct { + Forge Forge BaseURL string Owner string Repository string Token string } +// DeliveryPreflightFailureKind classifies a failure while checking a delivery +// destination's credentials and repository access. +type DeliveryPreflightFailureKind string + +const ( + DeliveryPreflightMissingCredential DeliveryPreflightFailureKind = "missing_credential" + DeliveryPreflightInvalidCredential DeliveryPreflightFailureKind = "invalid_credential" + DeliveryPreflightInsufficientPermissions DeliveryPreflightFailureKind = "insufficient_permissions" + DeliveryPreflightInaccessibleDestination DeliveryPreflightFailureKind = "inaccessible_destination" + DeliveryPreflightUnavailable DeliveryPreflightFailureKind = "unavailable" +) + +// DeliveryPreflightError reports a classified failure to verify a destination. +type DeliveryPreflightError struct { + Kind DeliveryPreflightFailureKind + Err error +} + +func (err *DeliveryPreflightError) Error() string { + if err.Err == nil { + return fmt.Sprintf("delivery preflight %s", err.Kind) + } + return fmt.Sprintf("delivery preflight %s: %v", err.Kind, err.Err) +} + +func (err *DeliveryPreflightError) Unwrap() error { return err.Err } + // DeliveryAsset identifies a hosted asset by filename and SHA-256 digest. type DeliveryAsset struct { Filename string SHA256 string } -// DeliveryRequest identifies a local tagged checkout and its explicit Gitea +// DeliveryRequest identifies a local tagged checkout and its explicit // destination. Build selects a managed build; Prebuilt supplies caller-owned // build output instead. They cannot both be set. Either may be nil for // page-only delivery when both are nil. Build with no targets, or an empty @@ -54,7 +94,7 @@ type DeliveryRequest struct { // ExpectedTag optionally requires the full CI event tag to match the // selected module's derived tag before building or making hosted requests. ExpectedTag string - Destination GiteaDestination + Destination DeliveryDestination // Build selects binaries to build in temporary storage outside the checkout. Build *BuildSpec // Prebuilt supplies an existing build whose files remain caller-owned. @@ -98,9 +138,9 @@ type DeliveryResult struct { // managed builds require their selected source files in the working tree. func Deliver(ctx context.Context, request DeliveryRequest) (DeliveryResult, error) { result := DeliveryResult{Version: request.Version, State: DeliveryNotAttempted} - destination := gitea.Destination{ - BaseURL: request.Destination.BaseURL, Owner: request.Destination.Owner, - Repository: request.Destination.Repository, Token: request.Destination.Token, + backend, err := deliveryBackend(request.Destination) + if err != nil { + return result, err } var prebuilt *internalbuild.Result if request.Prebuilt != nil { @@ -112,7 +152,7 @@ func Deliver(ctx context.Context, request DeliveryRequest) (DeliveryResult, erro Product: request.Product, Version: request.Version, ExpectedTag: request.ExpectedTag, - Destination: destination, + Backend: backend, Build: internalBuildSpec(request.Build), Prebuilt: prebuilt, Output: request.Output, @@ -129,10 +169,59 @@ func Deliver(ctx context.Context, request DeliveryRequest) (DeliveryResult, erro result.Pending = deliveryAssets(internalResult.Pending) result.Conflicting = deliveryAssets(internalResult.Conflicting) result.Uncertain = deliveryAssets(internalResult.Uncertain) - return result, err + return result, publicPreflightError(err) } -func deliveryAssets(assets []gitea.AssetIdentity) []DeliveryAsset { +// PreflightDelivery checks destination credentials and repository access using +// read-only backend operations. It does not require a checkout or published tag. +func PreflightDelivery(ctx context.Context, destination DeliveryDestination) error { + backend, err := deliveryBackend(destination) + if err != nil { + return err + } + return publicPreflightError(backend.Preflight(ctx)) +} + +func deliveryBackend(destination DeliveryDestination) (forge.Backend, error) { + selection := Forge(strings.TrimSpace(string(destination.Forge))) + switch selection { + case ForgeGitea: + return gitea.NewBackend(gitea.Destination{ + BaseURL: destination.BaseURL, Owner: destination.Owner, + Repository: destination.Repository, Token: destination.Token, + }, nil), nil + case "": + return nil, errors.New("delivery forge must be selected explicitly") + default: + return nil, fmt.Errorf("unsupported delivery forge %q", destination.Forge) + } +} + +func publicPreflightError(err error) error { + if err == nil { + return nil + } + var failure *forge.PreflightFailure + if !errors.As(err, &failure) { + return err + } + kind := DeliveryPreflightUnavailable + switch failure.Kind { + case forge.PreflightMissingCredential: + kind = DeliveryPreflightMissingCredential + case forge.PreflightInvalidCredential: + kind = DeliveryPreflightInvalidCredential + case forge.PreflightInsufficientPermissions: + kind = DeliveryPreflightInsufficientPermissions + case forge.PreflightInaccessibleDestination: + kind = DeliveryPreflightInaccessibleDestination + case forge.PreflightUnavailable: + kind = DeliveryPreflightUnavailable + } + return &DeliveryPreflightError{Kind: kind, Err: failure.Err} +} + +func deliveryAssets(assets []internaldelivery.AssetIdentity) []DeliveryAsset { result := make([]DeliveryAsset, 0, len(assets)) for _, asset := range assets { result = append(result, DeliveryAsset{Filename: asset.Filename, SHA256: asset.SHA256}) diff --git a/delivery_test.go b/delivery_test.go index d831771..54dd2a8 100644 --- a/delivery_test.go +++ b/delivery_test.go @@ -177,8 +177,8 @@ func TestDeliverRejectsSameSizePrebuiltTamperingBeforeHostedWrites(t *testing.T) if err == nil || !strings.Contains(err.Error(), "verify build assets") || result.State != releasekit.DeliveryNotAttempted { t.Fatalf("Deliver() = %#v, %v; want local asset verification failure", result, err) } - if remote.snapshot().requests != 0 { - t.Fatalf("hosted requests = %d, want none before local verification", remote.snapshot().requests) + if remote.snapshot().writes != 0 { + t.Fatalf("hosted writes = %d, want none before local verification", remote.snapshot().writes) } } @@ -463,8 +463,8 @@ func TestDeliverManagedBuildRejectsCheckoutMutationBeforeHostedWrites(t *testing if err == nil || !strings.Contains(err.Error(), "build source") || result.State != releasekit.DeliveryNotAttempted { t.Fatalf("Deliver() = %#v, %v; want build-source eligibility failure", result, err) } - if remote.snapshot().requests != 0 { - t.Fatalf("hosted requests = %d, want none", remote.snapshot().requests) + if remote.snapshot().writes != 0 { + t.Fatalf("hosted writes = %d, want none after preflight", remote.snapshot().writes) } if entries, err := os.ReadDir(temporaryRoot); err != nil || len(entries) != 0 { t.Fatalf("ineligible managed build output remains: entries=%v error=%v", entries, err) @@ -492,8 +492,8 @@ func TestDeliverManagedBuildFailureCleansTemporaryOutputBeforeHostedWrites(t *te if err == nil || result.State != releasekit.DeliveryNotAttempted || result.CandidateCommit != remote.snapshot().commit { t.Fatalf("Deliver() = %#v, %v; want managed build failure before hosted writes", result, err) } - if remote.snapshot().requests != 0 { - t.Fatalf("hosted requests = %d, want none", remote.snapshot().requests) + if remote.snapshot().writes != 0 { + t.Fatalf("hosted writes = %d, want none after preflight", remote.snapshot().writes) } if entries, err := os.ReadDir(temporaryRoot); err != nil || len(entries) != 0 { t.Fatalf("failed managed build output remains: entries=%v error=%v", entries, err) @@ -525,8 +525,8 @@ func TestDeliverManagedBuildCancellationCleansTemporaryOutput(t *testing.T) { if !errors.Is(err, context.Canceled) || result.State != releasekit.DeliveryNotAttempted { t.Fatalf("Deliver() = %#v, %v; want cancellation before hosted writes", result, err) } - if remote.snapshot().requests != 0 { - t.Fatalf("hosted requests = %d, want none", remote.snapshot().requests) + if remote.snapshot().writes != 0 { + t.Fatalf("hosted writes = %d, want none after preflight", remote.snapshot().writes) } if entries, err := os.ReadDir(temporaryRoot); err != nil || len(entries) != 0 { t.Fatalf("canceled managed build output remains: entries=%v error=%v", entries, err) @@ -575,8 +575,8 @@ func main() { if len(os.Args) == 2 && os.Args[1] == "version" { fmt.Println("fix return repository } -func facadeDeliveryDestination(baseURL string) releasekit.GiteaDestination { - return releasekit.GiteaDestination{BaseURL: baseURL, Owner: "owner", Repository: "example", Token: "fixture-token"} +func facadeDeliveryDestination(baseURL string) releasekit.DeliveryDestination { + return releasekit.DeliveryDestination{Forge: releasekit.ForgeGitea, BaseURL: baseURL, Owner: "owner", Repository: "example", Token: "fixture-token"} } type deliveryCancelWriter struct{ cancel context.CancelFunc } @@ -623,6 +623,7 @@ type facadeDeliveryData struct { noteBody string pages map[string]bool requests int + writes int assets map[string][]byte url string } @@ -642,11 +643,19 @@ func (state *facadeDeliveryServer) serveHTTP(w http.ResponseWriter, r *http.Requ data := &state.data if strings.HasPrefix(r.URL.Path, "/api/") { data.requests++ + if r.Method != http.MethodGet { + data.writes++ + } if r.Header.Get("Authorization") != "token fixture-token" { data.t.Errorf("authorization = %q", r.Header.Get("Authorization")) } } switch r.URL.EscapedPath() { + case "/api/v1/repos/owner/example", "/api/v1/repositories/1": + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": 1, "name": "example", "full_name": "owner/example", + "owner": map[string]string{"login": "owner"}, + }) case "/api/v1/repos/owner/example/tags/" + url.PathEscape(data.tag): _, _ = fmt.Fprintf(w, `{"name":%q,"commit":{"sha":%q}}`, data.tag, data.commit) case "/api/v1/repos/owner/example/releases/tags/" + url.PathEscape(data.tag): diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 3a51f35..4d36904 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Implementation plan: neutral forge delivery and credential preflight -Status: implementation in progress; Stages 1–7 are complete and Stages 8–10 +Status: implementation in progress; Stages 1–8 are complete and Stages 9–10 are pending. ## Purpose and execution @@ -434,7 +434,7 @@ internal entry points have no public compatibility obligation. ## Stage 8: Connect the neutral facade and CLI -Status: pending. +Status: complete. 1. Implement the public declarations/conversions in Selected design. Replace current `GiteaDestination` uses. The private facade factory switches on the diff --git a/examples/releasekit-binary.json b/examples/releasekit-binary.json index 5e97426..6656ff5 100644 --- a/examples/releasekit-binary.json +++ b/examples/releasekit-binary.json @@ -7,6 +7,7 @@ "targets": ["linux/amd64", "linux/arm64", "darwin/arm64"] }, "delivery": { + "forge": "gitea", "base_url": "https://gitea.example", "owner": "example", "repository": "example", diff --git a/examples/releasekit.json b/examples/releasekit.json index bbdf66c..20ebaac 100644 --- a/examples/releasekit.json +++ b/examples/releasekit.json @@ -2,6 +2,7 @@ "product": "Example", "remote": "origin", "delivery": { + "forge": "gitea", "base_url": "https://gitea.example", "owner": "example", "repository": "example", diff --git a/internal/delivery/delivery.go b/internal/delivery/delivery.go index 1c789f1..7cf6236 100644 --- a/internal/delivery/delivery.go +++ b/internal/delivery/delivery.go @@ -12,8 +12,8 @@ import ( "go.maximumdirect.net/releasekit/internal/artifact" internalbuild "go.maximumdirect.net/releasekit/internal/build" "go.maximumdirect.net/releasekit/internal/candidate" + "go.maximumdirect.net/releasekit/internal/forge" "go.maximumdirect.net/releasekit/internal/git" - "go.maximumdirect.net/releasekit/internal/gitea" "go.maximumdirect.net/releasekit/internal/gotool" "go.maximumdirect.net/releasekit/internal/releaseidentity" "go.maximumdirect.net/releasekit/internal/tempdir" @@ -36,7 +36,7 @@ type Request struct { Product string Version string ExpectedTag string - Destination gitea.Destination + Backend forge.Backend Build *internalbuild.Spec Prebuilt *internalbuild.Result Output io.Writer @@ -50,11 +50,11 @@ type Result struct { CandidateCommit string ReleaseURL string State State - Matching []gitea.AssetIdentity - Uploaded []gitea.AssetIdentity - Pending []gitea.AssetIdentity - Conflicting []gitea.AssetIdentity - Uncertain []gitea.AssetIdentity + Matching []AssetIdentity + Uploaded []AssetIdentity + Pending []AssetIdentity + Conflicting []AssetIdentity + Uncertain []AssetIdentity } func Deliver(ctx context.Context, request Request) (Result, error) { @@ -68,6 +68,9 @@ func Deliver(ctx context.Context, request Request) (Result, error) { if request.Build != nil && request.Prebuilt != nil { return result, errors.New("delivery cannot use both a managed build and prebuilt artifacts") } + if request.Backend == nil { + return result, errors.New("delivery backend is required") + } repositoryDir, err := git.RepositoryRoot(ctx, request.RepositoryDir) if err != nil { return result, fmt.Errorf("resolve repository directory: %w", err) @@ -112,6 +115,14 @@ func Deliver(ctx context.Context, request Request) (Result, error) { if err := candidate.ValidateReleaseNote(note, request.Product, request.Version); err != nil { return result, fmt.Errorf("release note %q: %w", identity.NotePath, err) } + if request.Prebuilt != nil { + if err := validateBuildIdentity(identity, commit, request.Prebuilt); err != nil { + return result, err + } + } + if err := request.Backend.Preflight(ctx); err != nil { + return result, err + } build := request.Prebuilt if request.Build != nil && len(request.Build.Targets) != 0 { @@ -144,11 +155,11 @@ func Deliver(ctx context.Context, request Request) (Result, error) { } if len(assets) > 0 { if err := artifact.VerifyLocalAssets(assets); err != nil { - kind := gitea.FailureInvalid + kind := forge.FailureInvalid if errors.Is(err, artifact.ErrAssetConflict) { - kind = gitea.FailureConflict + kind = forge.FailureConflict } - return result, &gitea.Failure{Kind: kind, Operation: "verify build assets", Err: err} + return result, &forge.Failure{Kind: kind, Err: fmt.Errorf("verify build assets: %w", err)} } } if build != nil && len(build.Settings.Targets) != 0 { @@ -164,38 +175,33 @@ func Deliver(ctx context.Context, request Request) (Result, error) { } } - releaseResult, err := (gitea.Client{}).EnsureRelease(ctx, gitea.Request{ - Destination: request.Destination, - Tag: identity.Tag, - Commit: commit, - Note: string(note), - }) + release, pageEvidence, err := coordinateRelease(ctx, request.Backend, identity.Tag, commit, string(note)) if err != nil { result.State = stateForError(err, false) return result, err } - result.ReleaseURL = releaseResult.Release.URL + result.ReleaseURL = release.URL if len(assets) == 0 { result.State = Complete return result, nil } - inspection, err := (gitea.Client{}).InspectAssets(ctx, request.Destination, releaseResult.Release.ID, assets) + inspection, err := inspectRemoteAssets(ctx, request.Backend, release.Handle, assets) if err != nil { result.State = NotAttempted - if releaseResult.WriteAttempted { + if pageEvidence.Attempted { result.State = Partial } result.Uncertain = assetIdentities(assets) return result, err } - uploadResult, uploadErr := (gitea.Client{}).UploadVerifiedMissing(ctx, request.Destination, releaseResult.Release.ID, inspection) + uploadResult, uploadErr := uploadMissingAssets(ctx, request.Backend, release.Handle, inspection) result.Matching = uploadResult.Matching result.Uploaded = uploadResult.Uploaded result.Pending = uploadResult.Pending result.Conflicting = uploadResult.Conflicting result.Uncertain = uploadResult.Uncertain result.State = State(uploadResult.State) - if result.State == NotAttempted && releaseResult.WriteAttempted { + if result.State == NotAttempted && pageEvidence.Attempted { result.State = Partial } return result, uploadErr @@ -208,6 +214,22 @@ func validateBuild(identity releaseidentity.Identity, commit string, build *inte } return nil } + if err := validateBuildIdentity(identity, commit, build); err != nil { + return err + } + if len(build.Settings.Targets) == 0 { + if len(assets) != 0 { + return errors.New("page-only delivery cannot include binary assets") + } + return nil + } + if len(assets) != len(build.Settings.Targets)+1 || assets[len(assets)-1].Filename != artifact.ManifestFilename { + return errors.New("build result does not contain the selected artifacts and complete checksum manifest") + } + return nil +} + +func validateBuildIdentity(identity releaseidentity.Identity, commit string, build *internalbuild.Result) error { buildRepository, err := filepath.Abs(build.RepositoryDir) if err != nil { return fmt.Errorf("resolve build repository: %w", err) @@ -220,27 +242,24 @@ func validateBuild(identity releaseidentity.Identity, commit string, build *inte return errors.New("build result does not identify the selected tagged module") } if len(build.Settings.Targets) == 0 { - if len(assets) != 0 { - return errors.New("page-only delivery cannot include binary assets") - } return nil } if !build.SourceVerified { return errors.New("build source was not verified as clean and unchanged during compilation") } - if strings.TrimSpace(build.Settings.MainPackage) == "" || strings.TrimSpace(build.Settings.BinaryName) == "" || len(assets) != len(build.Settings.Targets)+1 || assets[len(assets)-1].Filename != artifact.ManifestFilename { + if strings.TrimSpace(build.Settings.MainPackage) == "" || strings.TrimSpace(build.Settings.BinaryName) == "" { return errors.New("build result does not contain the selected artifacts and complete checksum manifest") } return nil } func stateForError(err error, pageCreated bool) State { - var failure *gitea.Failure + var failure *forge.Failure if errors.As(err, &failure) { switch failure.Kind { - case gitea.FailureConflict: + case forge.FailureConflict: return Conflict - case gitea.FailureUnknown: + case forge.FailureUnknown: return Unknown } } @@ -250,10 +269,10 @@ func stateForError(err error, pageCreated bool) State { return NotAttempted } -func assetIdentities(assets []artifact.ExpectedAsset) []gitea.AssetIdentity { - result := make([]gitea.AssetIdentity, 0, len(assets)) +func assetIdentities(assets []artifact.ExpectedAsset) []AssetIdentity { + result := make([]AssetIdentity, 0, len(assets)) for _, asset := range assets { - result = append(result, gitea.AssetIdentity{Filename: asset.Filename, SHA256: asset.SHA256}) + result = append(result, AssetIdentity{Filename: asset.Filename, SHA256: asset.SHA256}) } return result } diff --git a/performance_delivery_test.go b/performance_delivery_test.go index e49230a..9eb9c6c 100644 --- a/performance_delivery_test.go +++ b/performance_delivery_test.go @@ -19,7 +19,7 @@ import ( "testing" releasekit "go.maximumdirect.net/releasekit" - "go.maximumdirect.net/releasekit/internal/gitea" + "go.maximumdirect.net/releasekit/internal/artifact" ) const performanceDeliveryArtifactSize = 16 << 20 @@ -150,7 +150,7 @@ func newDeliveryBenchmarkFixture(b testing.TB) (string, releasekit.BuildResult, } manifest := performanceDeliveryManifest(build.Artifacts) manifestHash := sha256.Sum256(manifest) - assets = append(assets, deliveryBenchmarkAsset{name: gitea.ManifestFilename, data: manifest, digest: hex.EncodeToString(manifestHash[:])}) + assets = append(assets, deliveryBenchmarkAsset{name: artifact.ManifestFilename, data: manifest, digest: hex.EncodeToString(manifestHash[:])}) return repository, build, assets } @@ -193,6 +193,11 @@ func (state *deliveryBenchmarkServer) verifyUploads(b testing.TB) { func (state *deliveryBenchmarkServer) serveHTTP(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { + case "/api/v1/repos/owner/example", "/api/v1/repositories/1": + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": 1, "name": "example", "full_name": "owner/example", + "owner": map[string]string{"login": "owner"}, + }) case "/api/v1/repos/owner/example/tags/v1.2.3": _, _ = fmt.Fprintf(w, `{"name":"v1.2.3","commit":{"sha":%q}}`, state.commit) case "/api/v1/repos/owner/example/releases/tags/v1.2.3": diff --git a/releasekit.json b/releasekit.json index f2e3313..2070c32 100644 --- a/releasekit.json +++ b/releasekit.json @@ -5,6 +5,7 @@ "forbidden_files": [".releasekit/gitea-token"] }, "delivery": { + "forge": "gitea", "base_url": "https://forge.maximumdirect.net", "owner": "base-layer", "repository": "releasekit", diff --git a/repository_test.go b/repository_test.go index 4a666f1..004aead 100644 --- a/repository_test.go +++ b/repository_test.go @@ -80,7 +80,7 @@ func TestSourceOperationsResolveContainingRepository(t *testing.T) { if err != nil || built.RepositoryDir != repo.work || built.ModuleDir != wantModule || built.ModulePath != wantPath || built.Tag != wantTag { t.Fatalf("build %q = %#v, error = %v", selected, built, err) } - delivered, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{RepositoryDir: child, ModuleDir: selected, Product: request.Product, Version: request.Version, ExpectedTag: "mismatch"}) + delivered, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{RepositoryDir: child, ModuleDir: selected, Product: request.Product, Version: request.Version, ExpectedTag: "mismatch", Destination: releasekit.DeliveryDestination{Forge: releasekit.ForgeGitea}}) if err == nil || !strings.Contains(err.Error(), "expected delivery tag") || delivered.RepositoryDir != repo.work || delivered.ModuleDir != wantModule || delivered.ModulePath != wantPath || delivered.Tag != wantTag { t.Fatalf("delivery %q = %#v, error = %v", selected, delivered, err) }