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"
"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-")
}

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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 {

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
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

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`
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).

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,
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

View File

@@ -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

View File

@@ -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}"