diff --git a/cmd/releasekit/internal/cli/config.go b/cmd/releasekit/internal/cli/config.go index 71c1e35..8c6863b 100644 --- a/cmd/releasekit/internal/cli/config.go +++ b/cmd/releasekit/internal/cli/config.go @@ -14,7 +14,6 @@ import ( "strings" "gitea.maximumdirect.net/eric/ReleaseKit/internal/process" - "gitea.maximumdirect.net/eric/ReleaseKit/internal/tempdir" ) 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" @@ -203,8 +202,3 @@ func RunChecks(ctx context.Context, repoDir string, checks []CheckCommand, owned } return nil } - -// TemporaryBuildDir allocates command-owned storage outside its source checkout. -func TemporaryBuildDir(repository string) (string, error) { - return tempdir.Outside(repository, "releasekit-delivery-") -} diff --git a/cmd/releasekit/main.go b/cmd/releasekit/main.go index 0fecfdf..404073c 100644 --- a/cmd/releasekit/main.go +++ b/cmd/releasekit/main.go @@ -153,6 +153,12 @@ func runDelivery(ctx context.Context, invocation cli.Invocation, stdout, stderr fmt.Fprintf(stderr, "load profile: %v\n", err) return 2 } + return deliverWithSettings(ctx, invocation, settings, stdout, stderr) +} + +// deliverWithSettings adapts prepared CLI state to the public delivery facade. +// CI entry points can reuse it after preparing the checkout and loading its profile. +func deliverWithSettings(ctx context.Context, invocation cli.Invocation, settings cli.Settings, stdout, stderr io.Writer) int { 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") @@ -173,8 +179,11 @@ func runDelivery(ctx context.Context, invocation cli.Invocation, stdout, stderr fmt.Fprintln(stderr, "--assets requires build targets in the profile") return 2 } - var build *releasekit.BuildResult - var temporaryDir string + var buildSpec *releasekit.BuildSpec + var prebuilt *releasekit.BuildResult + if invocation.AssetsDir == "" && settings.Build != nil { + buildSpec = configuredBuildSpec(settings.Build) + } if hasTargets { if invocation.AssetsDir != "" { assetsDir := invocation.AssetsDir @@ -190,31 +199,7 @@ func runDelivery(ctx context.Context, invocation cli.Invocation, stdout, stderr 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 = cli.TemporaryBuildDir(repositoryDir) - 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 + prebuilt = buildResultFromReceipt(receipt) } } @@ -225,7 +210,9 @@ func runDelivery(ctx context.Context, invocation cli.Invocation, stdout, stderr Destination: releasekit.GiteaDestination{ BaseURL: delivery.BaseURL, Owner: delivery.Owner, Repository: delivery.Repository, Token: token, }, - Prebuilt: build, + Build: buildSpec, + Prebuilt: prebuilt, + Output: stderr, }) 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) diff --git a/cmd/releasekit/main_test.go b/cmd/releasekit/main_test.go index 45c5780..005841e 100644 --- a/cmd/releasekit/main_test.go +++ b/cmd/releasekit/main_test.go @@ -286,6 +286,8 @@ func TestDeliverPageOnlyCreatesAndReusesRelease(t *testing.T) { func TestDeliverBuildsTaggedArtifactsAndReusesAssets(t *testing.T) { repository := newDeliveryBuildRepository(t) + temporaryRoot := t.TempDir() + t.Setenv("TMPDIR", temporaryRoot) state := &deliveryServerState{} server := newDeliveryServer(t, state) defer server.Close() @@ -305,6 +307,9 @@ func TestDeliverBuildsTaggedArtifactsAndReusesAssets(t *testing.T) { if status != 0 { t.Fatalf("delivery attempt %d status=%d stdout=%q stderr=%q", attempt+1, status, stdout.String(), stderr.String()) } + if attempt == 1 && (!strings.Contains(stdout.String(), "matching helper-v1.2.3-linux-amd64") || !strings.Contains(stdout.String(), "matching SHA256SUMS")) { + t.Fatalf("second delivery did not report matching assets: %q", stdout.String()) + } } if state.releaseCreates != 1 || state.assetUploads != 2 { t.Fatalf("delivery calls = creates:%d asset uploads:%d", state.releaseCreates, state.assetUploads) @@ -315,6 +320,33 @@ func TestDeliverBuildsTaggedArtifactsAndReusesAssets(t *testing.T) { if _, ok := state.assets["SHA256SUMS"]; !ok { t.Fatalf("checksum manifest was not uploaded: %#v", state.assets) } + if entries, err := os.ReadDir(temporaryRoot); err != nil || len(entries) != 0 { + t.Fatalf("facade-owned build output remains after delivery: entries=%v error=%v", entries, err) + } +} + +func TestDeliverRejectsInvalidTaggedSourceBeforeManagedBuild(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") + if err := os.WriteFile(filepath.Join(repository, "uncommitted.txt"), []byte("dirty"), 0600); 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, "v1.2.3"}, &stdout, &stderr) + if status != 1 || state.apiRequests != 0 || strings.Contains(stderr.String(), "building ") { + t.Fatalf("invalid-source delivery status=%d requests=%d stdout=%q stderr=%q", status, state.apiRequests, stdout.String(), stderr.String()) + } } func TestDeliverFailureReportsRecoveryForPartialConflictAndUnknownStates(t *testing.T) { @@ -478,6 +510,9 @@ func TestDeliverRejectsTamperedAssetsAndStaleReceiptBeforeRemoteWrites(t *testin 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()) } + if _, err := os.Stat(artifactPath); err != nil { + t.Fatalf("prebuilt artifact was not retained: %v", err) + } return } if status != 1 || state.apiRequests != 0 || state.releaseCreates != 0 || state.assetUploads != 0 { diff --git a/cmd/releasekit/release_wrapper_test.go b/cmd/releasekit/release_wrapper_test.go index 0cfaa56..46b697e 100644 --- a/cmd/releasekit/release_wrapper_test.go +++ b/cmd/releasekit/release_wrapper_test.go @@ -36,6 +36,7 @@ func TestReleaseWrapperPreservesNativeExitStatuses(t *testing.T) { "internal/gitea/upload.go", "internal/gitea/upload_test.go", "delivery_test.go", "cmd/releasekit/main_test.go", "cmd/releasekit/release_wrapper_test.go", "cmd/releasekit/internal/cli/config_test.go", "examples/releasekit.json", + "cmd/releasekit/internal/cli/config.go", } { contents, err := os.ReadFile(filepath.Join(root, path)) if err != nil { diff --git a/docs/cli.md b/docs/cli.md index 0cca8fb..d29ec96 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -95,12 +95,21 @@ 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. +When the profile has build targets and `--assets` is omitted, `deliver` passes +the configured build directly to the public delivery facade. The facade builds +the tagged source into temporary storage, removes it when the operation ends, +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; this keeps the caller's original +bytes available for retries. Profiles without targets use page-only delivery +and need no receipt, binary build, or manifest. `deliver` never runs candidate +checks or publishes a tag. + +Use one `deliver` invocation for the normal workflow. To build without delivery +credentials, or to retain build output across retries, run `build --output DIR` +first and then run `deliver --assets DIR` with the token available. The ordinary +`deliver` command does not download application dependencies or change the +checkout. 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 diff --git a/docs/config.md b/docs/config.md index 64e3d84..a5d96ff 100644 --- a/docs/config.md +++ b/docs/config.md @@ -39,10 +39,14 @@ as an additional product check. Selected-target builds run after both. Do not repeat the standard suite in `checks`; doing so runs it twice. 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. +The `deliver` command uses the delivery destination and token. By default it +passes selected targets to the delivery facade, which builds tagged-source +artifacts into temporary storage and removes them when delivery returns. An +absent build object or an empty target set selects page-only delivery. Use +`build --output DIR` followed by `deliver --assets DIR` when artifacts must be +retained for retries or when build and delivery credentials need separate +invocations. Ordinary `deliver` does not download application dependencies or +change the checkout. ReleaseKit's source-only profile is [releasekit.json](../releasekit.json). diff --git a/docs/consumers/delivery.md b/docs/consumers/delivery.md index 6110b26..d9a8a16 100644 --- a/docs/consumers/delivery.md +++ b/docs/consumers/delivery.md @@ -38,11 +38,21 @@ 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. +one `deliver` invocation builds the tagged source through the facade into +temporary storage and delivers the result. To retain artifacts for a retry or +separate credentials between build and delivery, use two invocations: + +```sh +releasekit build --output dist v1.2.3 +RELEASEKIT_GITEA_TOKEN="$GITEA_TOKEN" releasekit deliver --assets dist v1.2.3 +``` + +The build command does not need delivery credentials. The `--assets DIR` +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; caller-owned +files remain available for retries. Ordinary `deliver` does not download +application dependencies or change the checkout. The result reports `complete`, `partial`, `conflict`, `unknown`, or `not_attempted`, along with matching, uploaded, pending, conflicting, and @@ -123,18 +133,15 @@ The binary profile must declare its build targets. The tag workflow fetches the event tag, verifies that the tag resolves to `CI_COMMIT_SHA`, and checks out that commit detached. Woodpecker shares the -repository workspace between sequential steps, so the installed executable, -Go caches, build outputs, and build receipt remain available to later steps. -The workflow keeps its `.releasekit/` directory out of Git status using the -checkout's local exclude file. +repository workspace between sequential steps, so the installed executable +and Go caches remain available. The workflow keeps its `.releasekit/` directory +out of Git status using the checkout's local exclude file. Preparation can access the Go module proxy to install the pinned ReleaseKit -executable. The binary workflow also runs `go mod download` before the build. -The later `releasekit build` command runs with `GOPROXY=off` and -`GOTOOLCHAIN=local`; its declared binary targets and receipt are placed in -`.releasekit/assets`. Delivery then passes that directory with `--assets`. -The source-only workflow skips this build step and sends the committed note -page directly. Neither template runs `check` or `publish`. +executable. The binary workflow also runs `go mod download` before delivery. +The single `releasekit deliver` invocation builds selected targets through the +facade and uploads them with the release page. The source-only workflow sends +the committed note page directly. Neither template runs `check` or `publish`. Woodpecker runs delivery after the tag event, independently from the source publication result. Read its workflow status as delivery status: a delivery diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 2c8809e..0602635 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # ReleaseKit audit implementation plan -Status: Stages 1–6 complete; Stage 7 next. +Status: Stages 1–7 complete; Stage 8 next. Baseline: `e4be7fa` (2026-10-06). The [audit](audit.md) records the evidence, product goals, and selected direction. This document owns implementation order and completion criteria for that work. @@ -364,6 +364,16 @@ preflight failure before compilation, token/profile errors, and the explicit receipt path. Reuse Stage 6 for domain failure coverage. Confirm the maintained ordinary command exercises the facade-owned lifecycle and reports F7's results. +**Completed:** Ordinary `deliver` now passes configured builds and progress +output directly to the public facade; the CLI retains profile, credential, +receipt, presentation, and exit-status handling. `--assets` still validates +the private receipt and supplies caller-owned artifacts. CLI tests cover +page-only and binary profiles, preflight rejection before build progress, +retained prebuilt artifacts, retry observations, and temporary-output cleanup. +The binary workflow and consumer guidance use one `deliver` invocation by +default and explain the separate `build`/`--assets` path. The full offline suite, +API boundary check, and documentation whitespace checks pass. + ## Stage 8 — Add reusable CI preparation operations **Covers:** preparation ownership in F6. **Primary owners:** a new root diff --git a/examples/woodpecker/tag-delivery-binary.yml b/examples/woodpecker/tag-delivery-binary.yml index 2d143ab..a608468 100644 --- a/examples/woodpecker/tag-delivery-binary.yml +++ b/examples/woodpecker/tag-delivery-binary.yml @@ -25,14 +25,6 @@ steps: go mod download GOBIN="$${CI_WORKSPACE}/.releasekit/bin" go install gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit@${RELEASEKIT_SOURCE} - - name: build - image: ${GO_IMAGE} - commands: - - | - export GOCACHE="$${CI_WORKSPACE}/.releasekit/gocache" - export GOMODCACHE="$${CI_WORKSPACE}/.releasekit/gomodcache" - GOPROXY=off GOTOOLCHAIN=local "$${CI_WORKSPACE}/.releasekit/bin/releasekit" build --repo "$${CI_WORKSPACE}" --output "$${CI_WORKSPACE}/.releasekit/assets" "$${CI_COMMIT_TAG}" - - name: deliver image: ${GO_IMAGE} environment: @@ -42,4 +34,4 @@ steps: - | export GOCACHE="$${CI_WORKSPACE}/.releasekit/gocache" export GOMODCACHE="$${CI_WORKSPACE}/.releasekit/gomodcache" - "$${CI_WORKSPACE}/.releasekit/bin/releasekit" deliver --repo "$${CI_WORKSPACE}" --assets "$${CI_WORKSPACE}/.releasekit/assets" "$${CI_COMMIT_TAG}" + "$${CI_WORKSPACE}/.releasekit/bin/releasekit" deliver --repo "$${CI_WORKSPACE}" "$${CI_COMMIT_TAG}"