Route CLI delivery through the facade

This commit is contained in:
2026-10-06 18:35:45 +00:00
parent a67a00a733
commit 579daa9485
9 changed files with 108 additions and 69 deletions

View File

@@ -14,7 +14,6 @@ import (
"strings" "strings"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/process" "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" 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 return nil
} }
// TemporaryBuildDir allocates command-owned storage outside its source checkout.
func TemporaryBuildDir(repository string) (string, error) {
return tempdir.Outside(repository, "releasekit-delivery-")
}

View File

@@ -153,6 +153,12 @@ func runDelivery(ctx context.Context, invocation cli.Invocation, stdout, stderr
fmt.Fprintf(stderr, "load profile: %v\n", err) fmt.Fprintf(stderr, "load profile: %v\n", err)
return 2 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 delivery := settings.Delivery
if delivery == nil || strings.TrimSpace(delivery.BaseURL) == "" || strings.TrimSpace(delivery.Owner) == "" || strings.TrimSpace(delivery.Repository) == "" { 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") 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") fmt.Fprintln(stderr, "--assets requires build targets in the profile")
return 2 return 2
} }
var build *releasekit.BuildResult var buildSpec *releasekit.BuildSpec
var temporaryDir string var prebuilt *releasekit.BuildResult
if invocation.AssetsDir == "" && settings.Build != nil {
buildSpec = configuredBuildSpec(settings.Build)
}
if hasTargets { if hasTargets {
if invocation.AssetsDir != "" { if invocation.AssetsDir != "" {
assetsDir := 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") fmt.Fprintln(stderr, "build receipt does not match the selected repository, version, or build settings")
return 1 return 1
} }
build = buildResultFromReceipt(receipt) prebuilt = 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
} }
} }
@@ -225,7 +210,9 @@ func runDelivery(ctx context.Context, invocation cli.Invocation, stdout, stderr
Destination: releasekit.GiteaDestination{ Destination: releasekit.GiteaDestination{
BaseURL: delivery.BaseURL, Owner: delivery.Owner, Repository: delivery.Repository, Token: token, BaseURL: delivery.BaseURL, Owner: delivery.Owner, Repository: delivery.Repository, Token: token,
}, },
Prebuilt: build, Build: buildSpec,
Prebuilt: prebuilt,
Output: stderr,
}) })
if err != nil { 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) fmt.Fprintf(stderr, "delivery state=%s version=%s commit=%s url=%s: %v\n", result.State, result.Version, result.CandidateCommit, result.ReleaseURL, err)

View File

@@ -286,6 +286,8 @@ func TestDeliverPageOnlyCreatesAndReusesRelease(t *testing.T) {
func TestDeliverBuildsTaggedArtifactsAndReusesAssets(t *testing.T) { func TestDeliverBuildsTaggedArtifactsAndReusesAssets(t *testing.T) {
repository := newDeliveryBuildRepository(t) repository := newDeliveryBuildRepository(t)
temporaryRoot := t.TempDir()
t.Setenv("TMPDIR", temporaryRoot)
state := &deliveryServerState{} state := &deliveryServerState{}
server := newDeliveryServer(t, state) server := newDeliveryServer(t, state)
defer server.Close() defer server.Close()
@@ -305,6 +307,9 @@ func TestDeliverBuildsTaggedArtifactsAndReusesAssets(t *testing.T) {
if status != 0 { if status != 0 {
t.Fatalf("delivery attempt %d status=%d stdout=%q stderr=%q", attempt+1, status, stdout.String(), stderr.String()) 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 { if state.releaseCreates != 1 || state.assetUploads != 2 {
t.Fatalf("delivery calls = creates:%d asset uploads:%d", state.releaseCreates, state.assetUploads) 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 { if _, ok := state.assets["SHA256SUMS"]; !ok {
t.Fatalf("checksum manifest was not uploaded: %#v", state.assets) 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) { func TestDeliverFailureReportsRecoveryForPartialConflictAndUnknownStates(t *testing.T) {
@@ -478,6 +510,9 @@ func TestDeliverRejectsTamperedAssetsAndStaleReceiptBeforeRemoteWrites(t *testin
if status != 0 || state.releaseCreates != 1 || state.assetUploads != 2 { 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()) 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 return
} }
if status != 1 || state.apiRequests != 0 || state.releaseCreates != 0 || state.assetUploads != 0 { if status != 1 || state.apiRequests != 0 || state.releaseCreates != 0 || state.assetUploads != 0 {

View File

@@ -36,6 +36,7 @@ func TestReleaseWrapperPreservesNativeExitStatuses(t *testing.T) {
"internal/gitea/upload.go", "internal/gitea/upload_test.go", "internal/gitea/upload.go", "internal/gitea/upload_test.go",
"delivery_test.go", "cmd/releasekit/main_test.go", "cmd/releasekit/release_wrapper_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_test.go", "examples/releasekit.json",
"cmd/releasekit/internal/cli/config.go",
} { } {
contents, err := os.ReadFile(filepath.Join(root, path)) contents, err := os.ReadFile(filepath.Join(root, path))
if err != nil { if err != nil {

View File

@@ -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 are supported when `HEAD` is the selected tag commit; delivery does not require
the former remote `main` to match. the former remote `main` to match.
When the profile has build targets and `--assets` is omitted, `deliver` builds When the profile has build targets and `--assets` is omitted, `deliver` passes
the tagged source into temporary storage and uploads its binaries and complete the configured build directly to the public delivery facade. The facade builds
`SHA256SUMS`. When `--assets DIR` is supplied, that directory must contain the the tagged source into temporary storage, removes it when the operation ends,
private `.releasekit-build.json` receipt and matching unchanged artifacts. and uploads its binaries and complete `SHA256SUMS`. When `--assets DIR` is
Profiles without targets use page-only delivery and need no receipt, binary supplied, that directory must contain the private `.releasekit-build.json`
build, or manifest. `deliver` never runs candidate checks or publishes a tag. 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, 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 or partial delivery, 2 for usage, profile, or missing-credential errors, and 3

View File

@@ -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` 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 command uses the selected build settings and writes binaries to `dist` unless
`--output` selects another path. `--output` selects another path.
The `deliver` command uses the delivery destination and token, then either The `deliver` command uses the delivery destination and token. By default it
builds selected tagged-source targets into temporary storage or loads verified passes selected targets to the delivery facade, which builds tagged-source
assets from the directory selected by `--assets`. An absent build object or an artifacts into temporary storage and removes them when delivery returns. An
empty target set selects page-only delivery. 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's source-only profile is
[releasekit.json](../releasekit.json). [releasekit.json](../releasekit.json).

View File

@@ -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, With no build targets, delivery is page-only: no builder, version smoke check,
receipt, binary asset, or checksum manifest is involved. With selected targets, receipt, binary asset, or checksum manifest is involved. With selected targets,
the command builds the tagged source into temporary storage and delivers the one `deliver` invocation builds the tagged source through the facade into
result. To use artifacts built separately, pass `--assets DIR`; the directory temporary storage and delivers the result. To retain artifacts for a retry or
must contain the private `.releasekit-build.json` receipt created by `build`. separate credentials between build and delivery, use two invocations:
ReleaseKit checks the receipt's repository, commit, version, resolved build
settings, and file hashes before making a remote request. ```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 The result reports `complete`, `partial`, `conflict`, `unknown`, or
`not_attempted`, along with matching, uploaded, pending, conflicting, and `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 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 `CI_COMMIT_SHA`, and checks out that commit detached. Woodpecker shares the
repository workspace between sequential steps, so the installed executable, repository workspace between sequential steps, so the installed executable
Go caches, build outputs, and build receipt remain available to later steps. and Go caches remain available. The workflow keeps its `.releasekit/` directory
The workflow keeps its `.releasekit/` directory out of Git status using the out of Git status using the checkout's local exclude file.
checkout's local exclude file.
Preparation can access the Go module proxy to install the pinned ReleaseKit Preparation can access the Go module proxy to install the pinned ReleaseKit
executable. The binary workflow also runs `go mod download` before the build. executable. The binary workflow also runs `go mod download` before delivery.
The later `releasekit build` command runs with `GOPROXY=off` and The single `releasekit deliver` invocation builds selected targets through the
`GOTOOLCHAIN=local`; its declared binary targets and receipt are placed in facade and uploads them with the release page. The source-only workflow sends
`.releasekit/assets`. Delivery then passes that directory with `--assets`. the committed note page directly. Neither template runs `check` or `publish`.
The source-only workflow skips this build step and sends the committed note
page directly. Neither template runs `check` or `publish`.
Woodpecker runs delivery after the tag event, independently from the source Woodpecker runs delivery after the tag event, independently from the source
publication result. Read its workflow status as delivery status: a delivery publication result. Read its workflow status as delivery status: a delivery

View File

@@ -1,6 +1,6 @@
# ReleaseKit audit implementation plan # 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, Baseline: `e4be7fa` (2026-10-06). The [audit](audit.md) records the evidence,
product goals, and selected direction. This document owns implementation order product goals, and selected direction. This document owns implementation order
and completion criteria for that work. 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 receipt path. Reuse Stage 6 for domain failure coverage. Confirm the maintained
ordinary command exercises the facade-owned lifecycle and reports F7's results. 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 ## Stage 8 — Add reusable CI preparation operations
**Covers:** preparation ownership in F6. **Primary owners:** a new root **Covers:** preparation ownership in F6. **Primary owners:** a new root

View File

@@ -25,14 +25,6 @@ steps:
go mod download go mod download
GOBIN="$${CI_WORKSPACE}/.releasekit/bin" go install gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit@${RELEASEKIT_SOURCE} 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 - name: deliver
image: ${GO_IMAGE} image: ${GO_IMAGE}
environment: environment:
@@ -42,4 +34,4 @@ steps:
- | - |
export GOCACHE="$${CI_WORKSPACE}/.releasekit/gocache" export GOCACHE="$${CI_WORKSPACE}/.releasekit/gocache"
export GOMODCACHE="$${CI_WORKSPACE}/.releasekit/gomodcache" 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}"