diff --git a/README.md b/README.md index c884cc5..45f320d 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,10 @@ ReleaseKit is an opinionated Go library and executable for a shared release process. The Go module, root `releasekit` facade, `buildinfo` runtime package, local candidate check, source-tag publication API, selected-target build API, -and `version`, `check`, `publish`, and `build` commands are implemented. -ReleaseKit's source-only maintainer procedure is documented. Hosted delivery -and ReleaseKit's own binary artifacts remain future work. The +tagged-source delivery API, and `version`, `check`, `publish`, `build`, and +`deliver` commands are implemented. ReleaseKit's source-only maintainer +procedure is documented. Its own hosted-page wiring and executable artifacts +remain future work. The [product roadmap](docs/roadmap/releasekit.md) and [implementation plan](docs/roadmap/implementation.md) describe their selected behavior and sequence. @@ -44,13 +45,15 @@ failure recovery. version injection, and delivery, with the initial implementation plan. - [Contributor guide](docs/development.md): orientation, validation, and selective policy reading. -- [CLI contract](docs/cli.md): the `version`, `check`, `publish`, and `build` - commands. +- [CLI contract](docs/cli.md): the `version`, `check`, `publish`, `build`, and + `deliver` commands. - [Configuration](docs/config.md): profile fields and child-command settings. - [Runtime version guidance](docs/consumers/runtime-version.md): how applications read their embedded version through `buildinfo`. - [Build guidance](docs/consumers/build.md): compile selected Go targets with an injected application version. +- [Delivery guidance](docs/consumers/delivery.md): deliver tagged source and + report hosted page and asset status. - [Gitea integration](docs/integrations/gitea.md): hosted release-page operations and their failure behavior. - [Candidate check guidance](docs/consumers/check.md): local candidate validation diff --git a/cmd/releasekit/internal/cli/config.go b/cmd/releasekit/internal/cli/config.go index 0a77701..8c6863b 100644 --- a/cmd/releasekit/internal/cli/config.go +++ b/cmd/releasekit/internal/cli/config.go @@ -16,7 +16,7 @@ import ( "gitea.maximumdirect.net/eric/ReleaseKit/internal/process" ) -const Usage = "usage: releasekit version\n releasekit check [--repo DIR] [--config FILE] VERSION\n releasekit publish [--repo DIR] [--config FILE] VERSION\n releasekit build [--repo DIR] [--config FILE] [--output DIR] VERSION" +const Usage = "usage: releasekit version\n releasekit check [--repo DIR] [--config FILE] VERSION\n releasekit publish [--repo DIR] [--config FILE] VERSION\n releasekit build [--repo DIR] [--config FILE] [--output DIR] VERSION\n releasekit deliver [--repo DIR] [--config FILE] [--assets DIR] VERSION" type Command string @@ -25,6 +25,7 @@ const ( CheckAction Command = "check" PublishAction Command = "publish" BuildAction Command = "build" + DeliveryAction Command = "deliver" ) // Invocation contains parsed command settings owned by the CLI adapter. @@ -33,6 +34,7 @@ type Invocation struct { Repository string ConfigPath string OutputDir string + AssetsDir string Version string } @@ -47,7 +49,7 @@ func Parse(args []string) (Invocation, error) { return Invocation{}, errors.New("version takes no arguments") } return Invocation{Command: VersionCommand}, nil - case string(CheckAction), string(PublishAction), string(BuildAction): + case string(CheckAction), string(PublishAction), string(BuildAction), string(DeliveryAction): action := Command(args[0]) flags := flag.NewFlagSet(string(action), flag.ContinueOnError) flags.SetOutput(io.Discard) @@ -57,13 +59,17 @@ func Parse(args []string) (Invocation, error) { if action == BuildAction { flags.StringVar(&outputDir, "output", "", "build output directory") } + var assetsDir string + if action == DeliveryAction { + flags.StringVar(&assetsDir, "assets", "", "verified build assets directory") + } if err := flags.Parse(args[1:]); err != nil { return Invocation{}, err } if flags.NArg() != 1 { return Invocation{}, fmt.Errorf("%s requires one version argument", action) } - return Invocation{Command: action, Repository: *repository, ConfigPath: *configPath, OutputDir: outputDir, Version: flags.Arg(0)}, nil + return Invocation{Command: action, Repository: *repository, ConfigPath: *configPath, OutputDir: outputDir, AssetsDir: assetsDir, Version: flags.Arg(0)}, nil default: return Invocation{}, fmt.Errorf("unknown command %q", args[0]) } @@ -78,10 +84,19 @@ type CheckCommand struct { // Settings contains the product, remote, and check commands read from a // releasekit.json profile. type Settings struct { - Product string `json:"product"` - Remote string `json:"remote,omitempty"` - Checks []CheckCommand `json:"checks,omitempty"` - Build *BuildSettings `json:"build,omitempty"` + Product string `json:"product"` + Remote string `json:"remote,omitempty"` + Checks []CheckCommand `json:"checks,omitempty"` + Build *BuildSettings `json:"build,omitempty"` + Delivery *DeliverySettings `json:"delivery,omitempty"` +} + +// DeliverySettings selects the hosted Gitea release destination. +type DeliverySettings struct { + BaseURL string `json:"base_url"` + Owner string `json:"owner"` + Repository string `json:"repository"` + TokenEnv string `json:"token_env,omitempty"` } // BuildSettings selects the Go application package, artifact name, and targets. diff --git a/cmd/releasekit/internal/cli/config_test.go b/cmd/releasekit/internal/cli/config_test.go index 8d6307c..fd9aeff 100644 --- a/cmd/releasekit/internal/cli/config_test.go +++ b/cmd/releasekit/internal/cli/config_test.go @@ -40,6 +40,13 @@ func TestParse(t *testing.T) { if build.Command != BuildAction || build.Repository != "candidate" || build.ConfigPath != "releasekit.json" || build.OutputDir != "output with spaces" || build.Version != "v1.2.5" { t.Fatalf("Parse(build) = %#v", build) } + deliver, err := Parse([]string{"deliver", "--repo", "candidate", "--config", "releasekit.json", "--assets", "assets with spaces", "v1.2.6"}) + if err != nil { + t.Fatal(err) + } + 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) + } if _, err := Parse([]string{"check", "v1.2.3", "--repo", "repository"}); err == nil { t.Fatal("Parse accepted a flag after the version") } @@ -161,7 +168,7 @@ func TestLoadSettingsRejectsInvalidProfiles(t *testing.T) { want string }{ {name: "missing product", content: `{}`, want: "product is required"}, - {name: "unknown top-level field", content: `{"product":"Example","delivery":{}}`, want: "unknown field"}, + {name: "unknown top-level field", content: `{"product":"Example","future":{}}`, want: "unknown field"}, {name: "build package required", content: `{"product":"Example","build":{"binary_name":"example","targets":["linux/amd64"]}}`, want: "build.main_package is required"}, {name: "build target format", content: `{"product":"Example","build":{"main_package":".","binary_name":"example","targets":["linux"]}}`, want: "must be GOOS/GOARCH"}, {name: "unsafe binary name", content: `{"product":"Example","build":{"main_package":".","binary_name":"../example","targets":["linux/amd64"]}}`, want: "filename component"}, diff --git a/cmd/releasekit/main.go b/cmd/releasekit/main.go index fb30a45..d9201f8 100644 --- a/cmd/releasekit/main.go +++ b/cmd/releasekit/main.go @@ -7,6 +7,7 @@ import ( "io" "os" "os/signal" + "path/filepath" "strings" "syscall" @@ -134,10 +135,140 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int { } fmt.Fprintf(stdout, "build receipt: %s\n", receiptPath) return 0 + case cli.DeliveryAction: + return runDelivery(ctx, invocation, stdout, stderr) } return 2 } +func runDelivery(ctx context.Context, invocation cli.Invocation, stdout, stderr io.Writer) int { + settings, err := cli.LoadSettings(invocation.Repository, invocation.ConfigPath) + if err != nil { + fmt.Fprintf(stderr, "load profile: %v\n", err) + return 2 + } + delivery := settings.Delivery + if delivery == nil || 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 2 + } + tokenEnv := delivery.TokenEnv + if strings.TrimSpace(tokenEnv) == "" { + tokenEnv = "RELEASEKIT_GITEA_TOKEN" + } + token, ok := os.LookupEnv(tokenEnv) + if !ok || strings.TrimSpace(token) == "" { + fmt.Fprintf(stderr, "deliver requires a non-empty token in environment variable %s\n", tokenEnv) + return 2 + } + + hasTargets := settings.Build != nil && len(settings.Build.Targets) != 0 + if invocation.AssetsDir != "" && !hasTargets { + fmt.Fprintln(stderr, "--assets requires build targets in the profile") + return 2 + } + var build *releasekit.BuildResult + var temporaryDir string + if hasTargets { + if invocation.AssetsDir != "" { + assetsDir := invocation.AssetsDir + if !filepath.IsAbs(assetsDir) { + assetsDir = filepath.Join(invocation.Repository, assetsDir) + } + receipt, err := cli.LoadBuildReceipt(filepath.Join(assetsDir, cli.BuildReceiptName)) + if err != nil { + fmt.Fprintf(stderr, "load verified build receipt: %v\n", err) + return 1 + } + if !receipt.MatchesBuild(invocation.Repository, receipt.CandidateCommit, invocation.Version, *settings.Build) { + fmt.Fprintln(stderr, "build receipt does not match the selected repository, version, or build settings") + return 1 + } + build = buildResultFromReceipt(receipt) + } else { + repositoryDir, resolveErr := filepath.Abs(invocation.Repository) + if resolveErr != nil { + fmt.Fprintf(stderr, "resolve repository directory: %v\n", resolveErr) + return 1 + } + temporaryDir, err = os.MkdirTemp(filepath.Dir(repositoryDir), ".releasekit-delivery-") + if err != nil { + fmt.Fprintf(stderr, "create temporary build directory: %v\n", err) + return 1 + } + defer os.RemoveAll(temporaryDir) + result, buildErr := releasekit.Build(ctx, releasekit.BuildRequest{ + RepositoryDir: invocation.Repository, + Version: invocation.Version, + Spec: *configuredBuildSpec(settings.Build), + OutputDir: filepath.Join(temporaryDir, "assets"), + Output: stderr, + }) + if buildErr != nil { + fmt.Fprintf(stderr, "build tagged-source assets: %v\n", buildErr) + return 1 + } + build = &result + } + } + + result, err := releasekit.Deliver(ctx, releasekit.DeliveryRequest{ + RepositoryDir: invocation.Repository, + Product: settings.Product, + Version: invocation.Version, + Destination: releasekit.GiteaDestination{ + BaseURL: delivery.BaseURL, Owner: delivery.Owner, Repository: delivery.Repository, Token: token, + }, + Build: build, + }) + if err != nil { + fmt.Fprintf(stderr, "delivery state=%s version=%s commit=%s url=%s: %v\n", result.State, result.Version, result.CandidateCommit, result.ReleaseURL, err) + writeDeliveryAssets(stderr, result) + if errors.Is(err, releasekit.ErrInvalidVersion) { + return 2 + } + if result.State == releasekit.DeliveryUnknown { + return 3 + } + return 1 + } + fmt.Fprintf(stdout, "delivered %s at %s (state %s)\n", result.Version, result.CandidateCommit, result.State) + if result.ReleaseURL != "" { + fmt.Fprintf(stdout, "release: %s\n", result.ReleaseURL) + } + writeDeliveryAssets(stdout, result) + return 0 +} + +func writeDeliveryAssets(output io.Writer, result releasekit.DeliveryResult) { + for _, group := range []struct { + label string + assets []releasekit.DeliveryAsset + }{{"matching", result.Matching}, {"uploaded", result.Uploaded}, {"pending", result.Pending}, {"conflicting", result.Conflicting}, {"uncertain", result.Uncertain}} { + for _, asset := range group.assets { + fmt.Fprintf(output, "%s %s %s\n", group.label, asset.Filename, asset.SHA256) + } + } +} + +func buildResultFromReceipt(receipt cli.BuildReceipt) *releasekit.BuildResult { + result := &releasekit.BuildResult{ + RepositoryDir: receipt.RepositoryDir, CandidateCommit: receipt.CandidateCommit, + Version: receipt.Version, MainPackage: receipt.MainPackage, BinaryName: receipt.BinaryName, + OutputDir: receipt.OutputDir, Compiler: receipt.Compiler, + } + for _, target := range receipt.Targets { + result.Targets = append(result.Targets, releasekit.Target{OS: target.OS, Arch: target.Arch}) + } + for _, artifact := range receipt.Artifacts { + result.Artifacts = append(result.Artifacts, releasekit.BuildArtifact{ + Path: artifact.Path, Filename: artifact.Filename, + Target: releasekit.Target{OS: artifact.Target.OS, Arch: artifact.Target.Arch}, SHA256: artifact.SHA256, + }) + } + return result +} + func configuredBuildSpec(settings *cli.BuildSettings) *releasekit.BuildSpec { if settings == nil { return nil diff --git a/cmd/releasekit/main_test.go b/cmd/releasekit/main_test.go index 11f26af..0dd9878 100644 --- a/cmd/releasekit/main_test.go +++ b/cmd/releasekit/main_test.go @@ -3,8 +3,13 @@ package main import ( "bytes" "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" + "io" + "net/http" + "net/http/httptest" "os" "os/exec" "path/filepath" @@ -234,6 +239,348 @@ func newCLIRepository(t *testing.T) string { return repository } +func TestDeliverPageOnlyCreatesAndReusesRelease(t *testing.T) { + repository := newCLIRepository(t) + state := &deliveryServerState{} + server := newDeliveryServer(t, state) + defer server.Close() + settings := cli.Settings{Product: "Example Product", Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}} + writeDeliveryProfile(t, repository, settings) + state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") + cliGitRun(t, repository, "tag", "v1.2.3") // Historical lightweight tags are valid delivery inputs. + t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token") + for attempt := 0; attempt < 2; attempt++ { + var stdout, stderr bytes.Buffer + status := run(context.Background(), []string{"deliver", "--repo", repository, "v1.2.3"}, &stdout, &stderr) + if status != 0 { + t.Fatalf("delivery attempt %d status=%d stdout=%q stderr=%q", attempt+1, status, stdout.String(), stderr.String()) + } + if !strings.Contains(stdout.String(), "state complete") || !strings.Contains(stdout.String(), "release: "+server.URL+"/releases/10") { + t.Fatalf("delivery output = %q", stdout.String()) + } + } + if state.releaseCreates != 1 || state.assetUploads != 0 || state.assetListReads != 0 { + t.Fatalf("page-only calls = creates:%d asset uploads:%d asset reads:%d", state.releaseCreates, state.assetUploads, state.assetListReads) + } + if state.noteBody != "# Example Product v1.2.3\n\nRelease note.\n" { + t.Fatalf("hosted note body = %q", state.noteBody) + } +} + +func TestDeliverBuildsTaggedArtifactsAndReusesAssets(t *testing.T) { + repository := newDeliveryBuildRepository(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"}, + } + writeDeliveryProfile(t, repository, settings) + state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") + cliGitRun(t, repository, "tag", "v1.2.3") + cliGitRun(t, repository, "checkout", "--detach", "v1.2.3") + t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token") + for attempt := 0; attempt < 2; attempt++ { + var stdout, stderr bytes.Buffer + status := run(context.Background(), []string{"deliver", "--repo", repository, "v1.2.3"}, &stdout, &stderr) + if status != 0 { + t.Fatalf("delivery attempt %d status=%d stdout=%q stderr=%q", attempt+1, status, stdout.String(), stderr.String()) + } + } + if state.releaseCreates != 1 || state.assetUploads != 2 { + t.Fatalf("delivery calls = creates:%d asset uploads:%d", state.releaseCreates, state.assetUploads) + } + if _, ok := state.assets["helper-v1.2.3-linux-amd64"]; !ok { + t.Fatalf("binary was not uploaded: %#v", state.assets) + } + if _, ok := state.assets["SHA256SUMS"]; !ok { + t.Fatalf("checksum manifest was not uploaded: %#v", state.assets) + } +} + +func TestDeliverRejectsWrongHostedTagBeforeReleaseWrite(t *testing.T) { + repository := newCLIRepository(t) + state := &deliveryServerState{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"}}) + state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") + cliGitRun(t, repository, "tag", "v1.2.3") + t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token") + var stdout, stderr bytes.Buffer + status := run(context.Background(), []string{"deliver", "--repo", repository, "v1.2.3"}, &stdout, &stderr) + if status != 1 || state.releaseCreates != 0 || state.assetUploads != 0 { + t.Fatalf("wrong hosted tag status=%d creates=%d uploads=%d stdout=%q stderr=%q", status, state.releaseCreates, state.assetUploads, stdout.String(), stderr.String()) + } +} + +func TestDeliverRequiresCredentialBeforeRemoteRequests(t *testing.T) { + repository := newCLIRepository(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"}}) + state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") + cliGitRun(t, repository, "tag", "v1.2.3") + t.Setenv("RELEASEKIT_TEST_DELIVERY_TOKEN", "") + var stdout, stderr bytes.Buffer + status := run(context.Background(), []string{"deliver", "--repo", repository, "v1.2.3"}, &stdout, &stderr) + if status != 2 || state.apiRequests != 0 || !strings.Contains(stderr.String(), "RELEASEKIT_TEST_DELIVERY_TOKEN") { + t.Fatalf("missing credential status=%d requests=%d stdout=%q stderr=%q", status, state.apiRequests, stdout.String(), stderr.String()) + } +} + +func TestDeliverRejectsCheckoutThatDiffersFromLocalTag(t *testing.T) { + repository := newCLIRepository(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"}}) + 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 { + t.Fatal(err) + } + cliGitRun(t, repository, "add", "later.txt") + cliGitRun(t, repository, "commit", "-m", "advance checkout") + t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token") + var stdout, stderr bytes.Buffer + status := run(context.Background(), []string{"deliver", "--repo", repository, "v1.2.3"}, &stdout, &stderr) + if status != 1 || state.apiRequests != 0 || state.releaseCreates != 0 { + t.Fatalf("mismatched checkout status=%d requests=%d creates=%d stdout=%q stderr=%q", status, state.apiRequests, state.releaseCreates, stdout.String(), stderr.String()) + } + if got := cliGitOutput(t, repository, "rev-parse", "refs/tags/v1.2.3"); got != state.commit { + t.Fatalf("delivery moved tag from %s to %s", state.commit, got) + } +} + +func TestDeliverRejectsTamperedAssetsAndStaleReceiptBeforeRemoteWrites(t *testing.T) { + for _, mode := range []string{"valid receipt", "tampered", "stale settings"} { + t.Run(mode, func(t *testing.T) { + repository := newCLIRepository(t) + state := &deliveryServerState{} + 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"}} + writeDeliveryProfile(t, repository, settings) + state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") + cliGitRun(t, repository, "tag", "v1.2.3") + receiptSettings := current + if mode == "stale settings" { + receiptSettings.BinaryName = "previous" + } + assetsDir, artifactPath := writeDeliveryReceipt(t, repository, receiptSettings) + if mode == "tampered" { + if err := os.WriteFile(artifactPath, []byte("tampered artifact"), 0o600); err != nil { + t.Fatal(err) + } + } + t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token") + var stdout, stderr bytes.Buffer + status := run(context.Background(), []string{"deliver", "--repo", repository, "--assets", assetsDir, "v1.2.3"}, &stdout, &stderr) + if mode == "valid receipt" { + if status != 0 || state.releaseCreates != 1 || state.assetUploads != 2 { + t.Fatalf("valid receipt status=%d creates=%d uploads=%d stdout=%q stderr=%q", status, state.releaseCreates, state.assetUploads, stdout.String(), stderr.String()) + } + return + } + if status != 1 || state.apiRequests != 0 || state.releaseCreates != 0 || state.assetUploads != 0 { + t.Fatalf("%s receipt status=%d requests=%d stdout=%q stderr=%q", mode, status, state.apiRequests, stdout.String(), stderr.String()) + } + }) + } +} + +func writeDeliveryReceipt(t *testing.T, repository string, settings cli.BuildSettings) (string, string) { + t.Helper() + assetsDir := t.TempDir() + filename := settings.BinaryName + "-v1.2.3-linux-amd64" + artifactPath := filepath.Join(assetsDir, filename) + content := []byte("fixture artifact") + if err := os.WriteFile(artifactPath, content, 0o600); err != nil { + t.Fatal(err) + } + resolvedRepository, err := filepath.Abs(repository) + if err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(content) + target := cli.ReceiptTarget{OS: "linux", Arch: "amd64"} + receipt := cli.BuildReceipt{ + RepositoryDir: resolvedRepository, CandidateCommit: cliGitOutput(t, repository, "rev-parse", "HEAD"), + Version: "v1.2.3", MainPackage: settings.MainPackage, BinaryName: settings.BinaryName, + OutputDir: assetsDir, Targets: []cli.ReceiptTarget{target}, Compiler: "go version fixture", + Artifacts: []cli.ReceiptArtifact{{Path: artifactPath, Filename: filename, Target: target, SHA256: hex.EncodeToString(digest[:])}}, + } + if _, err := cli.WriteBuildReceipt(assetsDir, receipt); err != nil { + t.Fatal(err) + } + return assetsDir, artifactPath +} + +type deliveryServerState struct { + commit string + remoteCommit string + apiRequests int + pageExists bool + releaseCreates int + assetUploads int + assetListReads int + noteBody string + assets map[string][]byte +} + +func newDeliveryServer(t *testing.T, state *deliveryServerState) *httptest.Server { + t.Helper() + if state.assets == nil { + state.assets = make(map[string][]byte) + } + serverURL := "" + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/api/") { + state.apiRequests++ + 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/tags/v1.2.3": + if state.commit == "" { + t.Error("tag source commit was not initialized") + } + commit := state.remoteCommit + if commit == "" { + commit = state.commit + } + _, _ = fmt.Fprintf(w, `{"name":"v1.2.3","commit":{"sha":%q}}`, commit) + case "/api/v1/repos/owner/example/releases/tags/v1.2.3": + if !state.pageExists { + http.NotFound(w, r) + return + } + writeDeliveryRelease(w, serverURL, state) + case "/api/v1/repos/owner/example/releases": + if r.Method != http.MethodPost { + t.Errorf("release request method = %s", r.Method) + http.Error(w, "method", 405) + return + } + var payload struct { + Tag string `json:"tag_name"` + Target string `json:"target_commitish"` + Name string `json:"name"` + Body string `json:"body"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Errorf("decode create payload: %v", err) + } + if payload.Tag != "v1.2.3" || payload.Target != state.commit { + t.Errorf("release source payload = %#v", payload) + } + state.pageExists, state.releaseCreates, state.noteBody = true, state.releaseCreates+1, payload.Body + writeDeliveryRelease(w, serverURL, state) + case "/api/v1/repos/owner/example/releases/10/assets": + if r.Method == http.MethodGet { + state.assetListReads++ + list := make([]map[string]any, 0, len(state.assets)) + for name, content := range state.assets { + list = append(list, map[string]any{"id": len(list) + 1, "name": name, "size": len(content), "browser_download_url": serverURL + "/files/" + name}) + } + _ = json.NewEncoder(w).Encode(list) + return + } + if r.Method != http.MethodPost { + t.Errorf("asset request method = %s", r.Method) + http.Error(w, "method", 405) + return + } + name := r.URL.Query().Get("name") + file, _, err := r.FormFile("attachment") + if err != nil { + t.Errorf("read asset upload %q: %v", name, err) + http.Error(w, "multipart", 400) + return + } + content, err := io.ReadAll(file) + _ = file.Close() + if err != nil { + t.Errorf("read asset bytes: %v", err) + } + if _, exists := state.assets[name]; exists { + t.Errorf("asset %q uploaded more than once", name) + } + state.assets[name] = content + state.assetUploads++ + _ = json.NewEncoder(w).Encode(map[string]any{"id": state.assetUploads, "name": name, "size": len(content)}) + default: + if strings.HasPrefix(r.URL.Path, "/files/") { + name := strings.TrimPrefix(r.URL.Path, "/files/") + content, ok := state.assets[name] + if !ok { + http.NotFound(w, r) + return + } + _, _ = w.Write(content) + return + } + t.Errorf("unexpected delivery request %s %s", r.Method, r.URL.String()) + http.NotFound(w, r) + } + }) + server := httptest.NewServer(handler) + serverURL = server.URL + return server +} + +func writeDeliveryRelease(w http.ResponseWriter, serverURL string, state *deliveryServerState) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"id": 10, "html_url": serverURL + "/releases/10", "tag_name": "v1.2.3", "target_commitish": state.commit, "name": "Example Product v1.2.3", "body": state.noteBody, "draft": false, "prerelease": false}) +} + +func writeDeliveryProfile(t *testing.T, repository string, settings cli.Settings) { + t.Helper() + contents, err := json.Marshal(settings) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repository, "releasekit.json"), contents, 0o600); err != nil { + t.Fatal(err) + } + cliGitRun(t, repository, "add", "releasekit.json") + cliGitRun(t, repository, "commit", "-m", "configure delivery") +} + +func newDeliveryBuildRepository(t *testing.T) string { + t.Helper() + repository := filepath.Join(t.TempDir(), "tagged application") + if err := os.Mkdir(repository, 0o700); err != nil { + t.Fatal(err) + } + cliGitRun(t, repository, "init", "--initial-branch=main") + cliGitRun(t, repository, "config", "user.name", "ReleaseKit Test") + cliGitRun(t, repository, "config", "user.email", "releasekit@example.test") + files := map[string]string{ + "go.mod": "module example.com/tagged-app\n\ngo 1.25.0\n", + "main.go": "package main\nimport \"fmt\"\nfunc main() { fmt.Println(\"helper v1.2.3\") }\n", + "docs/releases/v1.2.3.md": "# Example Product v1.2.3\n\nRelease note.\n", + } + for name, contents := range files { + path := filepath.Join(repository, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { + t.Fatal(err) + } + } + cliGitRun(t, repository, "add", "--all") + cliGitRun(t, repository, "commit", "-m", "tagged candidate") + return repository +} + func newCLIBuildRepository(t *testing.T, mainSource string) string { t.Helper() repository := newCLIRepository(t) diff --git a/delivery.go b/delivery.go new file mode 100644 index 0000000..1bf540a --- /dev/null +++ b/delivery.go @@ -0,0 +1,125 @@ +package releasekit + +import ( + "context" + "fmt" + + internaldelivery "gitea.maximumdirect.net/eric/ReleaseKit/internal/delivery" + "gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea" +) + +// DeliveryState describes the result of one delivery invocation. +type DeliveryState string + +const ( + // DeliveryNotAttempted means this invocation made no remote write. + DeliveryNotAttempted DeliveryState = "not_attempted" + // DeliveryComplete means the release page and all declared assets match. + DeliveryComplete DeliveryState = "complete" + // DeliveryPartial means a page or some assets were delivered, with work pending. + DeliveryPartial DeliveryState = "partial" + // DeliveryConflict means existing hosted content differs from the declaration. + DeliveryConflict DeliveryState = "conflict" + // DeliveryUnknown means a remote write may have succeeded but could not be resolved. + DeliveryUnknown DeliveryState = "unknown" +) + +// GiteaDestination supplies the hosted repository and explicit access token. +type GiteaDestination struct { + BaseURL string + Owner string + Repository string + Token string +} + +// 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 +// destination. Build is nil for page-only delivery. +type DeliveryRequest struct { + RepositoryDir string + Product string + Version string + Destination GiteaDestination + Build *BuildResult +} + +// DeliveryResult reports hosted content and the state reached by this +// invocation. A partial result may accompany an error. +type DeliveryResult struct { + Version string + CandidateCommit string + ReleaseURL string + State DeliveryState + Matching []DeliveryAsset + Uploaded []DeliveryAsset + Pending []DeliveryAsset + Conflicting []DeliveryAsset + Uncertain []DeliveryAsset +} + +// Deliver verifies the selected local tag and hosted source, then creates or +// finds its release page and adds any missing declared build assets. +func Deliver(ctx context.Context, request DeliveryRequest) (DeliveryResult, error) { + result := DeliveryResult{Version: request.Version, State: DeliveryNotAttempted} + var build *internaldelivery.BuildIdentity + var assets []gitea.ExpectedAsset + if request.Build != nil { + if len(request.Build.Targets) == 0 { + if len(request.Build.Artifacts) != 0 { + return result, fmt.Errorf("page-only build result contains artifacts") + } + } else { + var err error + assets, err = declaredBuildAssets(*request.Build) + if err != nil { + return result, err + } + targets := make([]string, 0, len(request.Build.Targets)) + for _, target := range request.Build.Targets { + targets = append(targets, target.OS+"/"+target.Arch) + } + build = &internaldelivery.BuildIdentity{ + RepositoryDir: request.Build.RepositoryDir, + CandidateCommit: request.Build.CandidateCommit, + Version: request.Build.Version, + MainPackage: request.Build.MainPackage, + BinaryName: request.Build.BinaryName, + Targets: targets, + } + } + } + destination := gitea.Destination{ + BaseURL: request.Destination.BaseURL, Owner: request.Destination.Owner, + Repository: request.Destination.Repository, Token: request.Destination.Token, + } + internalResult, err := internaldelivery.Deliver(ctx, internaldelivery.Request{ + RepositoryDir: request.RepositoryDir, + Product: request.Product, + Version: request.Version, + Destination: destination, + Build: build, + Assets: assets, + }) + result.CandidateCommit = internalResult.CandidateCommit + result.ReleaseURL = internalResult.ReleaseURL + result.State = DeliveryState(internalResult.State) + result.Matching = deliveryAssets(internalResult.Matching) + result.Uploaded = deliveryAssets(internalResult.Uploaded) + result.Pending = deliveryAssets(internalResult.Pending) + result.Conflicting = deliveryAssets(internalResult.Conflicting) + result.Uncertain = deliveryAssets(internalResult.Uncertain) + return result, err +} + +func deliveryAssets(assets []gitea.AssetIdentity) []DeliveryAsset { + result := make([]DeliveryAsset, 0, len(assets)) + for _, asset := range assets { + result = append(result, DeliveryAsset{Filename: asset.Filename, SHA256: asset.SHA256}) + } + return result +} diff --git a/delivery_assets.go b/delivery_assets.go index 4fc08dd..1029aaf 100644 --- a/delivery_assets.go +++ b/delivery_assets.go @@ -1,16 +1,51 @@ package releasekit import ( + "crypto/sha256" + "encoding/hex" "fmt" + "io" "os" "path/filepath" + "strings" "gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea" ) func declaredBuildAssets(result BuildResult) ([]gitea.ExpectedAsset, error) { + if len(result.Targets) == 0 { + if len(result.Artifacts) != 0 { + return nil, fmt.Errorf("source-only build result contains artifacts") + } + return nil, nil + } + if len(result.Artifacts) != len(result.Targets) { + return nil, fmt.Errorf("build result must contain one artifact for each selected target") + } + wantByTarget := make(map[string]string, len(result.Targets)) + for _, target := range result.Targets { + if target.OS == "" || target.Arch == "" || strings.ContainsAny(target.OS+target.Arch, "/\\\r\n") { + return nil, fmt.Errorf("build result contains an invalid target") + } + key := target.OS + "/" + target.Arch + if _, exists := wantByTarget[key]; exists { + return nil, fmt.Errorf("build result repeats target %q", key) + } + name := result.BinaryName + "-" + result.Version + "-" + target.OS + "-" + target.Arch + if target.OS == "windows" { + name += ".exe" + } + wantByTarget[key] = name + } assets := make([]gitea.ExpectedAsset, 0, len(result.Artifacts)+1) + seenTargets := make(map[string]bool, len(result.Artifacts)) for _, artifact := range result.Artifacts { + key := artifact.Target.OS + "/" + artifact.Target.Arch + wantName, ok := wantByTarget[key] + if !ok || seenTargets[key] || artifact.Filename != wantName { + return nil, fmt.Errorf("build artifact %q does not match a selected target and its declared name", artifact.Filename) + } + seenTargets[key] = true if filepath.Base(artifact.Path) != artifact.Filename { return nil, fmt.Errorf("build artifact path %q does not end with filename %q", artifact.Path, artifact.Filename) } @@ -21,6 +56,22 @@ func declaredBuildAssets(result BuildResult) ([]gitea.ExpectedAsset, error) { if !info.Mode().IsRegular() { return nil, fmt.Errorf("build artifact %q is not a regular file", artifact.Filename) } + file, err := os.Open(artifact.Path) + if err != nil { + return nil, fmt.Errorf("open build artifact %q: %w", artifact.Filename, err) + } + hash := sha256.New() + _, copyErr := io.Copy(hash, file) + closeErr := file.Close() + if copyErr != nil { + return nil, fmt.Errorf("hash build artifact %q: %w", artifact.Filename, copyErr) + } + if closeErr != nil { + return nil, fmt.Errorf("close build artifact %q: %w", artifact.Filename, closeErr) + } + if artifact.SHA256 != hex.EncodeToString(hash.Sum(nil)) { + return nil, fmt.Errorf("build artifact %q does not match its declared SHA-256 digest", artifact.Filename) + } assets = append(assets, gitea.ExpectedAsset{ Filename: artifact.Filename, SHA256: artifact.SHA256, diff --git a/delivery_assets_test.go b/delivery_assets_test.go index e08f7ea..d3b7061 100644 --- a/delivery_assets_test.go +++ b/delivery_assets_test.go @@ -5,6 +5,7 @@ import ( "encoding/hex" "os" "path/filepath" + "strings" "testing" "gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea" @@ -14,25 +15,29 @@ func TestDeclaredBuildAssetsGenerateManifestFromBuildResult(t *testing.T) { output := t.TempDir() first := []byte("first artifact") second := []byte("second artifact") - firstPath := filepath.Join(output, "zeta.bin") - secondPath := filepath.Join(output, "alpha.bin") + firstPath := filepath.Join(output, "app-v1.2.3-linux-amd64") + secondPath := filepath.Join(output, "app-v1.2.3-darwin-arm64") if err := os.WriteFile(firstPath, first, 0o600); err != nil { t.Fatal(err) } if err := os.WriteFile(secondPath, second, 0o600); err != nil { t.Fatal(err) } - result, err := declaredBuildAssets(BuildResult{Artifacts: []BuildArtifact{ - {Path: firstPath, Filename: "zeta.bin", SHA256: assetDigest(first)}, - {Path: secondPath, Filename: "alpha.bin", SHA256: assetDigest(second)}, - }}) + result, err := declaredBuildAssets(BuildResult{ + Version: "v1.2.3", BinaryName: "app", + Targets: []Target{{OS: "linux", Arch: "amd64"}, {OS: "darwin", Arch: "arm64"}}, + Artifacts: []BuildArtifact{ + {Path: firstPath, Filename: filepath.Base(firstPath), Target: Target{OS: "linux", Arch: "amd64"}, SHA256: assetDigest(first)}, + {Path: secondPath, Filename: filepath.Base(secondPath), Target: Target{OS: "darwin", Arch: "arm64"}, SHA256: assetDigest(second)}, + }, + }) if err != nil { t.Fatal(err) } - if len(result) != 3 || result[0].Filename != "zeta.bin" || result[1].Filename != "alpha.bin" || result[2].Filename != gitea.ManifestFilename { + if len(result) != 3 || result[0].Filename != filepath.Base(firstPath) || result[1].Filename != filepath.Base(secondPath) || result[2].Filename != gitea.ManifestFilename { t.Fatalf("assets = %#v", result) } - want := assetDigest(second) + " alpha.bin\n" + assetDigest(first) + " zeta.bin\n" + want := assetDigest(second) + " app-v1.2.3-darwin-arm64\n" + assetDigest(first) + " app-v1.2.3-linux-amd64\n" if string(result[2].Content) != want { t.Fatalf("manifest = %q, want %q", result[2].Content, want) } @@ -41,6 +46,20 @@ func TestDeclaredBuildAssetsGenerateManifestFromBuildResult(t *testing.T) { } } +func TestDeclaredBuildAssetsRejectsTamperedArtifact(t *testing.T) { + path := filepath.Join(t.TempDir(), "app-v1.2.3-linux-amd64") + if err := os.WriteFile(path, []byte("changed bytes"), 0o600); err != nil { + t.Fatal(err) + } + _, err := declaredBuildAssets(BuildResult{ + Version: "v1.2.3", BinaryName: "app", Targets: []Target{{OS: "linux", Arch: "amd64"}}, + Artifacts: []BuildArtifact{{Path: path, Filename: filepath.Base(path), Target: Target{OS: "linux", Arch: "amd64"}, SHA256: assetDigest([]byte("original bytes"))}}, + }) + if err == nil || !strings.Contains(err.Error(), "SHA-256") { + t.Fatalf("declaredBuildAssets() error = %v, want digest mismatch", err) + } +} + func TestDeclaredBuildAssetsKeepsSourceOnlyBuildManifestFree(t *testing.T) { assets, err := declaredBuildAssets(BuildResult{}) if err != nil || len(assets) != 0 { diff --git a/docs/cli.md b/docs/cli.md index 764c9a6..9e3fa66 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,6 +1,6 @@ # Command line -ReleaseKit currently provides four commands. +ReleaseKit currently provides five commands. Print the executable version: @@ -72,3 +72,32 @@ The command returns status 0 on completion, 1 for build or output failure, and tree and does not perform candidate validation or remote publication. `check` and `publish` run the same configured build after their one candidate validation, using temporary output outside the worktree. + +Deliver a tagged source release to Gitea: + +```sh +go run ./cmd/releasekit deliver [--repo DIR] [--config FILE] [--assets DIR] VERSION +``` + +`deliver` requires a `delivery` profile object and its configured token +environment variable, which defaults to `RELEASEKIT_GITEA_TOKEN`. It verifies +the local tag, clean tagged checkout, hosted tag, and committed release note, +then creates or finds the release page and reports delivery state separately +from source publication. Historical lightweight tags and detached checkouts +are supported when `HEAD` is the selected tag commit; delivery does not require +the former remote `main` to match. + +When the profile has build targets and `--assets` is omitted, `deliver` builds +the tagged source into temporary storage and uploads its binaries and complete +`SHA256SUMS`. When `--assets DIR` is supplied, that directory must contain the +private `.releasekit-build.json` receipt and matching unchanged artifacts. +Profiles without targets use page-only delivery and need no receipt, binary +build, or manifest. `deliver` never runs candidate checks or publishes a tag. + +The command returns 0 for complete delivery, 1 for a known failure, conflict, +or partial delivery, 2 for usage, profile, or missing-credential errors, and 3 +when a remote write remains uncertain. The result lists matching, uploaded, +pending, conflicting, and uncertain assets as available. Retry the same version +with the same tagged source and destination; matching content is kept and only +missing assets are added. See the [delivery consumer guide](consumers/delivery.md) +and [Gitea integration guide](integrations/gitea.md). diff --git a/docs/config.md b/docs/config.md index b13dc32..015879b 100644 --- a/docs/config.md +++ b/docs/config.md @@ -12,6 +12,7 @@ The current profile fields are: | `remote` | No | Selected Git remote; defaults to `origin`. | | `checks` | No | Ordered child commands, each with an argument vector and optional environment map. | | `build` | No | Optional main package, binary name, and selected `GOOS/GOARCH` target strings. | +| `delivery` | No | Optional Gitea `base_url`, `owner`, `repository`, and `token_env` settings for tagged-source delivery. | Each check requires a nonempty `args` array whose first element names the executable. Arguments are passed directly to the child process; ReleaseKit does @@ -24,12 +25,23 @@ Unknown fields are rejected so a setting cannot be silently ignored. The target requires the package and binary name; each target is written as one `GOOS/GOARCH` pair. An omitted or empty target list means no binary work. +The `delivery` object contains `base_url`, `owner`, `repository`, and optional +`token_env`. Its values are required when running `deliver`, but credentials are +not required for `check`, `publish`, or `build`. `token_env` names the +environment variable containing the Gitea token; it defaults to +`RELEASEKIT_GITEA_TOKEN`. The CLI reads this variable only for `deliver` and +passes its value explicitly to the library operation. + The `check` and `publish` commands use `product`, `remote`, and `checks`, and build selected targets after the single validation callback. If `checks` is omitted or empty, it runs `go test -mod=readonly -count=1 ./...` with Go module and toolchain network access disabled. If checks are configured, they replace that default and run in order. The `build` command uses the selected build settings and writes binaries to `dist` unless `--output` selects another path. +The `deliver` command uses the delivery destination and token, then either +builds selected tagged-source targets into temporary storage or loads verified +assets from the directory selected by `--assets`. An absent build object or an +empty target set selects page-only delivery. ReleaseKit's source-only publication profile is [releasekit.json](../releasekit.json). diff --git a/docs/consumers/build.md b/docs/consumers/build.md index 793a8b3..2340671 100644 --- a/docs/consumers/build.md +++ b/docs/consumers/build.md @@ -66,3 +66,9 @@ profile and writes a private receipt alongside its artifacts. `check` and `publish` run selected builds after their one validation callback using temporary output outside the worktree; those candidate checks do not write a receipt or leave build files in the repository. + +For delivery from a tagged checkout, `deliver` builds selected tagged-source +targets into temporary storage by default. Use `--assets DIR` to deliver a +separate build output; ReleaseKit requires its receipt and verifies the source, +build settings, and artifact hashes before the first Gitea write. See the +[delivery consumer guide](delivery.md) for page-only delivery and retry states. diff --git a/docs/consumers/delivery.md b/docs/consumers/delivery.md new file mode 100644 index 0000000..535ef15 --- /dev/null +++ b/docs/consumers/delivery.md @@ -0,0 +1,81 @@ +# Deliver a tagged source release + +Use `releasekit.Deliver` or the `deliver` command after a source tag exists. +Delivery status is separate from source publication: a delivery problem does +not move or remove the tag, and a retry can use the same immutable version. +The operation accepts historical lightweight and annotated tags, including a +detached checkout, when the checked-out `HEAD` identifies the selected tag +commit. It does not require the old remote `main` to match. + +## Command line + +Add a Gitea destination to the profile: + +```json +{ + "product": "Example", + "delivery": { + "base_url": "https://gitea.example", + "owner": "example", + "repository": "example", + "token_env": "RELEASEKIT_GITEA_TOKEN" + } +} +``` + +Fetch and check out the immutable tag, then deliver it: + +```sh +git checkout --detach v1.2.3 +RELEASEKIT_GITEA_TOKEN="$GITEA_TOKEN" releasekit deliver v1.2.3 +``` + +The CLI reads the named token variable only for `deliver`; its default name is +`RELEASEKIT_GITEA_TOKEN`. It verifies the local checkout and hosted tag before +creating or accepting a hosted release page. The full checked-in note is the +hosted body. A retry accepts an identical page and matching attachments, adds +only missing assets, and reports conflicts without replacing content. + +With no build targets, delivery is page-only: no builder, version smoke check, +receipt, binary asset, or checksum manifest is involved. With selected targets, +the command builds the tagged source into temporary storage and delivers the +result. To use artifacts built separately, pass `--assets DIR`; the directory +must contain the private `.releasekit-build.json` receipt created by `build`. +ReleaseKit checks the receipt's repository, commit, version, resolved build +settings, and file hashes before making a remote request. + +The result reports `complete`, `partial`, `conflict`, `unknown`, or +`not_attempted`, along with matching, uploaded, pending, conflicting, and +uncertain assets when known. Exit status 3 means a remote write could not be +resolved; inspect the same version and retry only after the remote state is +understood. The command does not run candidate checks or publish tags. + +## Go API + +Pass the Gitea credential explicitly. A `BuildResult` may be supplied for a +binary-producing release; it must identify the same tagged repository commit +and selected version, and its local artifacts must still match their digests. +Leave `Build` nil for a source-only release. + +```go +result, err := releasekit.Deliver(ctx, releasekit.DeliveryRequest{ + RepositoryDir: "/work/example", + Product: "Example", + Version: "v1.2.3", + Destination: releasekit.GiteaDestination{ + BaseURL: "https://gitea.example", + Owner: "example", + Repository: "example", + Token: token, + }, +}) +if err != nil { + // Keep the returned state and any completed asset identities for recovery. + return err +} +fmt.Println(result.State, result.ReleaseURL) +``` + +See the [CLI contract](../cli.md), [configuration reference](../config.md), +and [Gitea integration guide](../integrations/gitea.md) for exact settings, +exit codes, and transport behavior. diff --git a/docs/integrations/gitea.md b/docs/integrations/gitea.md index b8b8385..3df78cb 100644 --- a/docs/integrations/gitea.md +++ b/docs/integrations/gitea.md @@ -1,9 +1,11 @@ # Gitea release pages -ReleaseKit's internal Gitea client verifies a hosted source tag, finds or -creates its stable release page, inspects existing assets, and uploads missing -assets. The public delivery facade and CLI workflow are planned separately; -this guide describes the implemented internal operations. +ReleaseKit's public `Deliver` operation and `deliver` command verify tagged +source, find or create its stable release page, inspect existing assets, and +upload missing assets through the internal Gitea client. Page-only and +binary-producing profiles use the same delivery path. The +[consumer delivery guide](../consumers/delivery.md) covers caller workflows; +this guide describes the Gitea protocol and its failure behavior. The caller supplies the Gitea base URL, owner, repository name, and token explicitly. The client does not read ambient credentials. The token is sent in diff --git a/docs/internal/overview.md b/docs/internal/overview.md index 72eb90d..dfb96ad 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -26,13 +26,15 @@ Implemented internal packages: creates and pushes exact annotated tag objects. - `internal/publish` coordinates local and remote guards around one validation run and reports the publication state reached by that invocation. +- `internal/delivery` verifies a tagged checkout and coordinates note, build + identity, hosted page, asset inspection, and additive upload outcomes. - `cmd/releasekit/internal/cli` parses implemented commands, loads strict JSON profiles, adapts configured checks, and writes and validates private local - build receipts. + build receipts used for delivery. The executable controller uses the runtime facade and the command-owned CLI adapter. The adapter uses the process runner and imports neither public facade. The publication operation coordinates the candidate and Git packages. The internal Gitea client is documented in the -[Gitea integration guide](../integrations/gitea.md); delivery orchestration will -use it when that facade and CLI work lands. +[Gitea integration guide](../integrations/gitea.md); tagged-source orchestration +is exposed through the public delivery facade and CLI. diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index da0d197..3f4c2a7 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # ReleaseKit staged implementation plan -Status: In progress. Stages 1–14 are complete; later stages remain pending. +Status: In progress. Stages 1–15 are complete; later stages remain pending. Date: 2026-10-05 @@ -794,7 +794,7 @@ an ambiguous acknowledgment. Published manifest constraints hold across reruns. ## Stage 15: Expose tagged-source delivery through the facade and CLI -Status: Pending. Depends on Stage 14. +Status: Complete. Depends on Stage 14. 1. Implement `Deliver`, its facade-owned request/result values, and conversions. Bind source note, resolved build settings, tag commit, and hosted repository @@ -818,6 +818,21 @@ receipt, tampered assets, and missing credentials prevent relevant writes. Candidate tests and Git publication are not run by delivery. A delivery failure leaves the existing source tag intact and reports only delivery failure. +Evidence: `releasekit.Deliver` and `releasekit deliver` now verify lightweight or +annotated local tags from clean, attached or detached checkouts, compare the +hosted tag commit, and use the note read from the tagged commit. The CLI loads +delivery credentials only for delivery; it builds selected tagged-source +artifacts in temporary storage or verifies a supplied build receipt and its +artifact hashes before calling the facade. Page-only fixtures create and reuse +the complete note without a main package, receipt, manifest, build, or upload. +Binary fixtures build a tagged-source executable, upload it with `SHA256SUMS`, +and accept matching page and asset content on rerun. Local server tests verify +that wrong hosted and local commits, stale build settings, tampered receipt +assets, and missing credentials prevent remote writes; the tag remains intact +after delivery failure. `go test ./...`, `go run ./internal/apicheck`, and the +Markdown whitespace check pass. All delivery checks use a local HTTP fixture; +no live Gitea service or credentials were used. + ## Stage 16: Provide the common Woodpecker integration Status: Pending. Depends on Stage 15. diff --git a/docs/roadmap/releasekit.md b/docs/roadmap/releasekit.md index 935afa6..0e15720 100644 --- a/docs/roadmap/releasekit.md +++ b/docs/roadmap/releasekit.md @@ -56,9 +56,9 @@ cleanup. The [architecture policy](../policy/architecture.md) owns the adopted responsibility and dependency rules, including the runtime version boundary. The root `releasekit` package provides the public facade for candidate -validation, source publication, and selected-target builds. Internal Gitea -page and asset-delivery operations are implemented; facade and CLI orchestration -remain planned. The separate public `buildinfo` package provides lightweight +validation, source publication, selected-target builds, and tagged-source +delivery. The Gitea page and asset-delivery client is integrated through that +facade and the CLI. The separate public `buildinfo` package provides lightweight runtime version access. Each facade owns its public named types and documented contracts. Public diff --git a/examples/releasekit-binary.json b/examples/releasekit-binary.json index eb71fa9..5e97426 100644 --- a/examples/releasekit-binary.json +++ b/examples/releasekit-binary.json @@ -5,5 +5,11 @@ "main_package": "./cmd/example", "binary_name": "example", "targets": ["linux/amd64", "linux/arm64", "darwin/arm64"] + }, + "delivery": { + "base_url": "https://gitea.example", + "owner": "example", + "repository": "example", + "token_env": "RELEASEKIT_GITEA_TOKEN" } } diff --git a/examples/releasekit.json b/examples/releasekit.json index b974019..e4217a1 100644 --- a/examples/releasekit.json +++ b/examples/releasekit.json @@ -8,5 +8,11 @@ "RELEASE_MODE": "candidate" } } - ] + ], + "delivery": { + "base_url": "https://gitea.example", + "owner": "example", + "repository": "example", + "token_env": "RELEASEKIT_GITEA_TOKEN" + } } diff --git a/internal/candidate/check.go b/internal/candidate/check.go index fde2b8b..70b4843 100644 --- a/internal/candidate/check.go +++ b/internal/candidate/check.go @@ -268,6 +268,11 @@ func validateNote(note []byte, product, version string) error { return nil } +// ValidateReleaseNote checks the title contract for a committed release note. +func ValidateReleaseNote(note []byte, product, version string) error { + return validateNote(note, product, version) +} + func containsSequence(elements, sequence []string) bool { if len(sequence) == 0 || len(sequence) > len(elements) { return false diff --git a/internal/delivery/delivery.go b/internal/delivery/delivery.go new file mode 100644 index 0000000..9362ed5 --- /dev/null +++ b/internal/delivery/delivery.go @@ -0,0 +1,177 @@ +package delivery + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "strings" + + "gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate" + "gitea.maximumdirect.net/eric/ReleaseKit/internal/git" + "gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea" + "gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract" +) + +type State string + +const ( + NotAttempted State = "not_attempted" + Complete State = "complete" + Partial State = "partial" + Conflict State = "conflict" + Unknown State = "unknown" +) + +type BuildIdentity struct { + RepositoryDir string + CandidateCommit string + Version string + MainPackage string + BinaryName string + Targets []string +} + +type Request struct { + RepositoryDir string + Product string + Version string + Destination gitea.Destination + Build *BuildIdentity + Assets []gitea.ExpectedAsset +} + +type Result struct { + CandidateCommit string + ReleaseURL string + State State + Matching []gitea.AssetIdentity + Uploaded []gitea.AssetIdentity + Pending []gitea.AssetIdentity + Conflicting []gitea.AssetIdentity + Uncertain []gitea.AssetIdentity +} + +func Deliver(ctx context.Context, request Request) (Result, error) { + result := Result{State: NotAttempted} + if !versioncontract.ValidStableVersion(request.Version) { + return result, candidate.ErrInvalidVersion + } + if strings.TrimSpace(request.Product) == "" { + return result, errors.New("product is required") + } + repositoryDir, err := filepath.Abs(request.RepositoryDir) + if err != nil { + return result, fmt.Errorf("resolve repository directory: %w", err) + } + source, err := git.InspectTaggedSource(ctx, repositoryDir, request.Version) + if err != nil { + return result, fmt.Errorf("inspect tagged source: %w", err) + } + result.CandidateCommit = source.TagCommit + if source.Status != "" { + return result, fmt.Errorf("tagged checkout must be clean:\n%s", source.Status) + } + if source.Head != source.TagCommit { + return result, fmt.Errorf("checkout HEAD %s does not match tag %s commit %s", source.Head, request.Version, source.TagCommit) + } + if err := validateBuild(repositoryDir, source.TagCommit, request.Version, request.Build, request.Assets); err != nil { + return result, err + } + if len(request.Assets) > 0 { + if err := gitea.VerifyLocalAssets(request.Assets); err != nil { + return result, fmt.Errorf("verify build assets: %w", err) + } + } + notePath := "docs/releases/" + request.Version + ".md" + note, err := git.ReadCommitFile(ctx, repositoryDir, source.TagCommit, notePath) + if err != nil { + return result, err + } + if err := candidate.ValidateReleaseNote(note, request.Product, request.Version); err != nil { + return result, fmt.Errorf("release note %q: %w", notePath, err) + } + + releaseResult, err := (gitea.Client{}).EnsureRelease(ctx, gitea.Request{ + Destination: request.Destination, + Version: request.Version, + Commit: source.TagCommit, + Note: string(note), + }) + if err != nil { + result.State = stateForError(err, false) + return result, err + } + result.ReleaseURL = releaseResult.Release.URL + if len(request.Assets) == 0 { + result.State = Complete + return result, nil + } + inspection, err := (gitea.Client{}).InspectAssets(ctx, request.Destination, releaseResult.Release.ID, request.Assets) + if err != nil { + result.State = NotAttempted + if releaseResult.Created { + result.State = Partial + } + result.Uncertain = assetIdentities(request.Assets) + return result, err + } + uploadResult, uploadErr := (gitea.Client{}).UploadMissing(ctx, request.Destination, releaseResult.Release.ID, 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) + return result, uploadErr +} + +func validateBuild(repositoryDir, commit, version string, build *BuildIdentity, assets []gitea.ExpectedAsset) error { + if build == nil { + if len(assets) != 0 { + return errors.New("assets require a build identity") + } + return nil + } + if len(build.Targets) == 0 { + if len(assets) != 0 { + return errors.New("page-only delivery cannot include binary assets") + } + return nil + } + buildRepository, err := filepath.Abs(build.RepositoryDir) + if err != nil { + return fmt.Errorf("resolve build repository: %w", err) + } + if buildRepository != repositoryDir || build.CandidateCommit != commit || build.Version != version { + return errors.New("build result does not identify the selected tagged source") + } + if strings.TrimSpace(build.MainPackage) == "" || strings.TrimSpace(build.BinaryName) == "" || len(assets) != len(build.Targets)+1 || assets[len(assets)-1].Filename != gitea.ManifestFilename { + 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 + if errors.As(err, &failure) { + switch failure.Kind { + case gitea.FailureConflict: + return Conflict + case gitea.FailureUnknown: + return Unknown + } + } + if pageCreated { + return Partial + } + return NotAttempted +} + +func assetIdentities(assets []gitea.ExpectedAsset) []gitea.AssetIdentity { + result := make([]gitea.AssetIdentity, 0, len(assets)) + for _, asset := range assets { + result = append(result, gitea.AssetIdentity{Filename: asset.Filename, SHA256: asset.SHA256}) + } + return result +} diff --git a/internal/git/source.go b/internal/git/source.go new file mode 100644 index 0000000..7af7a33 --- /dev/null +++ b/internal/git/source.go @@ -0,0 +1,76 @@ +package git + +import ( + "context" + "fmt" + "path" + "strings" +) + +type TaggedSource struct { + Head string + Status string + TagObject string + TagCommit string + TagObjectType string +} + +// InspectTaggedSource reads the local HEAD, worktree state, and selected tag. +// Both lightweight historical tags and annotated tags are supported. +func InspectTaggedSource(ctx context.Context, repositoryDir, version string) (TaggedSource, error) { + if !validVersionRef(version) { + return TaggedSource{}, fmt.Errorf("invalid tag version %q", version) + } + var source TaggedSource + head, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", "HEAD^{commit}") + if err != nil { + return TaggedSource{}, fmt.Errorf("read checkout HEAD: %w", err) + } + source.Head = strings.TrimSpace(head) + if !validObjectID(source.Head) { + return TaggedSource{}, fmt.Errorf("Git returned an invalid checkout commit %q", source.Head) + } + status, err := run(ctx, repositoryDir, nil, "status", "--porcelain", "--untracked-files=all") + if err != nil { + return TaggedSource{}, fmt.Errorf("inspect tagged checkout state: %w", err) + } + source.Status = strings.TrimSpace(status) + ref := "refs/tags/" + version + object, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref) + if err != nil { + return TaggedSource{}, fmt.Errorf("read local tag %s: %w", version, err) + } + source.TagObject = strings.TrimSpace(object) + if !validObjectID(source.TagObject) { + return TaggedSource{}, fmt.Errorf("Git returned an invalid tag object %q", source.TagObject) + } + peeled, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref+"^{commit}") + if err != nil { + return TaggedSource{}, fmt.Errorf("resolve local tag %s to a commit: %w", version, err) + } + source.TagCommit = strings.TrimSpace(peeled) + if !validObjectID(source.TagCommit) { + return TaggedSource{}, fmt.Errorf("Git returned an invalid tagged commit %q", source.TagCommit) + } + tagType, err := run(ctx, repositoryDir, nil, "cat-file", "-t", ref) + if err != nil { + return TaggedSource{}, fmt.Errorf("inspect local tag %s: %w", version, err) + } + source.TagObjectType = strings.TrimSpace(tagType) + if source.TagObjectType != "tag" && source.TagObjectType != "commit" { + return TaggedSource{}, fmt.Errorf("local tag %s has unsupported object type %q", version, source.TagObjectType) + } + return source, nil +} + +// ReadCommitFile reads a repository-relative file from a specific commit. +func ReadCommitFile(ctx context.Context, repositoryDir, commit, name string) ([]byte, error) { + if !validObjectID(commit) || name == "" || path.IsAbs(name) || path.Clean(name) != name || strings.HasPrefix(name, "../") || name == ".." || strings.Contains(name, "\\") { + return nil, fmt.Errorf("invalid committed file identity %q at %q", name, commit) + } + content, err := run(ctx, repositoryDir, nil, "show", commit+":"+name) + if err != nil { + return nil, fmt.Errorf("read committed file %q: %w", name, err) + } + return []byte(content), nil +} diff --git a/internal/git/source_test.go b/internal/git/source_test.go new file mode 100644 index 0000000..0f1e5fd --- /dev/null +++ b/internal/git/source_test.go @@ -0,0 +1,46 @@ +package git + +import ( + "context" + "strings" + "testing" +) + +func TestInspectTaggedSourceSupportsLightweightAndAnnotatedTags(t *testing.T) { + fixture := newRemoteFixture(t) + gitRun(t, fixture.work, "tag", "v1.2.3") + gitRun(t, fixture.work, "checkout", "--detach", "HEAD") + lightweight, err := InspectTaggedSource(context.Background(), fixture.work, "v1.2.3") + if err != nil { + t.Fatal(err) + } + if lightweight.Head != fixture.commit || lightweight.TagCommit != fixture.commit || lightweight.TagObject != fixture.commit || lightweight.TagObjectType != "commit" || lightweight.Status != "" { + t.Fatalf("lightweight source = %#v", lightweight) + } + gitRun(t, fixture.work, "tag", "-d", "v1.2.3") + gitRun(t, fixture.work, "tag", "-a", "v1.2.3", "-m", "Historical release") + annotated, err := InspectTaggedSource(context.Background(), fixture.work, "v1.2.3") + if err != nil { + t.Fatal(err) + } + if annotated.Head != fixture.commit || annotated.TagCommit != fixture.commit || annotated.TagObject == fixture.commit || annotated.TagObjectType != "tag" || annotated.Status != "" { + t.Fatalf("annotated source = %#v", annotated) + } +} + +func TestInspectTaggedSourceReportsDirtyCheckoutAndReadCommitFile(t *testing.T) { + fixture := newRemoteFixture(t) + gitRun(t, fixture.work, "tag", "v1.2.3") + content, err := ReadCommitFile(context.Background(), fixture.work, fixture.commit, "source.txt") + if err != nil || string(content) != "candidate" { + t.Fatalf("committed content = %q, error = %v", content, err) + } + writeRepoFile(t, fixture.work, "untracked.txt", "dirty") + source, err := InspectTaggedSource(context.Background(), fixture.work, "v1.2.3") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(source.Status, "untracked.txt") { + t.Fatalf("dirty status = %q", source.Status) + } +} diff --git a/internal/gitea/upload.go b/internal/gitea/upload.go index db779ff..04d88d5 100644 --- a/internal/gitea/upload.go +++ b/internal/gitea/upload.go @@ -33,12 +33,26 @@ type AssetIdentity struct { } type UploadResult struct { - State DeliveryState - Matching []AssetIdentity - Uploaded []AssetIdentity - Pending []AssetIdentity + State DeliveryState + Matching []AssetIdentity + Uploaded []AssetIdentity + Pending []AssetIdentity Conflicting []AssetIdentity - Uncertain []AssetIdentity + Uncertain []AssetIdentity +} + +// VerifyLocalAssets checks that every expected file still matches its declared +// size and digest before delivery begins. +func VerifyLocalAssets(assets []ExpectedAsset) error { + if err := validateExpectedAssets(assets); err != nil { + return err + } + for _, asset := range assets { + if err := verifyLocalAsset(asset); err != nil { + return err + } + } + return nil } // UploadMissing uploads assets classified missing by InspectAssets. It stops