Expose tagged-source delivery through facade and CLI

This commit is contained in:
2026-10-05 20:39:33 +00:00
parent eb2cab78bb
commit bdf6a155b5
23 changed files with 1215 additions and 40 deletions

View File

@@ -3,9 +3,10 @@
ReleaseKit is an opinionated Go library and executable for a shared release ReleaseKit is an opinionated Go library and executable for a shared release
process. The Go module, root `releasekit` facade, `buildinfo` runtime package, process. The Go module, root `releasekit` facade, `buildinfo` runtime package,
local candidate check, source-tag publication API, selected-target build API, local candidate check, source-tag publication API, selected-target build API,
and `version`, `check`, `publish`, and `build` commands are implemented. tagged-source delivery API, and `version`, `check`, `publish`, `build`, and
ReleaseKit's source-only maintainer procedure is documented. Hosted delivery `deliver` commands are implemented. ReleaseKit's source-only maintainer
and ReleaseKit's own binary artifacts remain future work. The procedure is documented. Its own hosted-page wiring and executable artifacts
remain future work. The
[product roadmap](docs/roadmap/releasekit.md) and [product roadmap](docs/roadmap/releasekit.md) and
[implementation plan](docs/roadmap/implementation.md) describe their selected [implementation plan](docs/roadmap/implementation.md) describe their selected
behavior and sequence. behavior and sequence.
@@ -44,13 +45,15 @@ failure recovery.
version injection, and delivery, with the initial implementation plan. version injection, and delivery, with the initial implementation plan.
- [Contributor guide](docs/development.md): orientation, validation, and selective - [Contributor guide](docs/development.md): orientation, validation, and selective
policy reading. policy reading.
- [CLI contract](docs/cli.md): the `version`, `check`, `publish`, and `build` - [CLI contract](docs/cli.md): the `version`, `check`, `publish`, `build`, and
commands. `deliver` commands.
- [Configuration](docs/config.md): profile fields and child-command settings. - [Configuration](docs/config.md): profile fields and child-command settings.
- [Runtime version guidance](docs/consumers/runtime-version.md): how applications - [Runtime version guidance](docs/consumers/runtime-version.md): how applications
read their embedded version through `buildinfo`. read their embedded version through `buildinfo`.
- [Build guidance](docs/consumers/build.md): compile selected Go targets with an - [Build guidance](docs/consumers/build.md): compile selected Go targets with an
injected application version. 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 - [Gitea integration](docs/integrations/gitea.md): hosted release-page
operations and their failure behavior. operations and their failure behavior.
- [Candidate check guidance](docs/consumers/check.md): local candidate validation - [Candidate check guidance](docs/consumers/check.md): local candidate validation

View File

@@ -16,7 +16,7 @@ import (
"gitea.maximumdirect.net/eric/ReleaseKit/internal/process" "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 type Command string
@@ -25,6 +25,7 @@ const (
CheckAction Command = "check" CheckAction Command = "check"
PublishAction Command = "publish" PublishAction Command = "publish"
BuildAction Command = "build" BuildAction Command = "build"
DeliveryAction Command = "deliver"
) )
// Invocation contains parsed command settings owned by the CLI adapter. // Invocation contains parsed command settings owned by the CLI adapter.
@@ -33,6 +34,7 @@ type Invocation struct {
Repository string Repository string
ConfigPath string ConfigPath string
OutputDir string OutputDir string
AssetsDir string
Version string Version string
} }
@@ -47,7 +49,7 @@ func Parse(args []string) (Invocation, error) {
return Invocation{}, errors.New("version takes no arguments") return Invocation{}, errors.New("version takes no arguments")
} }
return Invocation{Command: VersionCommand}, nil 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]) action := Command(args[0])
flags := flag.NewFlagSet(string(action), flag.ContinueOnError) flags := flag.NewFlagSet(string(action), flag.ContinueOnError)
flags.SetOutput(io.Discard) flags.SetOutput(io.Discard)
@@ -57,13 +59,17 @@ func Parse(args []string) (Invocation, error) {
if action == BuildAction { if action == BuildAction {
flags.StringVar(&outputDir, "output", "", "build output directory") 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 { if err := flags.Parse(args[1:]); err != nil {
return Invocation{}, err return Invocation{}, err
} }
if flags.NArg() != 1 { if flags.NArg() != 1 {
return Invocation{}, fmt.Errorf("%s requires one version argument", action) 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: default:
return Invocation{}, fmt.Errorf("unknown command %q", args[0]) 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 // Settings contains the product, remote, and check commands read from a
// releasekit.json profile. // releasekit.json profile.
type Settings struct { type Settings struct {
Product string `json:"product"` Product string `json:"product"`
Remote string `json:"remote,omitempty"` Remote string `json:"remote,omitempty"`
Checks []CheckCommand `json:"checks,omitempty"` Checks []CheckCommand `json:"checks,omitempty"`
Build *BuildSettings `json:"build,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. // BuildSettings selects the Go application package, artifact name, and targets.

View File

@@ -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" { 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) 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 { if _, err := Parse([]string{"check", "v1.2.3", "--repo", "repository"}); err == nil {
t.Fatal("Parse accepted a flag after the version") t.Fatal("Parse accepted a flag after the version")
} }
@@ -161,7 +168,7 @@ func TestLoadSettingsRejectsInvalidProfiles(t *testing.T) {
want string want string
}{ }{
{name: "missing product", content: `{}`, want: "product is required"}, {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 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: "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"}, {name: "unsafe binary name", content: `{"product":"Example","build":{"main_package":".","binary_name":"../example","targets":["linux/amd64"]}}`, want: "filename component"},

View File

@@ -7,6 +7,7 @@ import (
"io" "io"
"os" "os"
"os/signal" "os/signal"
"path/filepath"
"strings" "strings"
"syscall" "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) fmt.Fprintf(stdout, "build receipt: %s\n", receiptPath)
return 0 return 0
case cli.DeliveryAction:
return runDelivery(ctx, invocation, stdout, stderr)
} }
return 2 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 { func configuredBuildSpec(settings *cli.BuildSettings) *releasekit.BuildSpec {
if settings == nil { if settings == nil {
return nil return nil

View File

@@ -3,8 +3,13 @@ package main
import ( import (
"bytes" "bytes"
"context" "context"
"crypto/sha256"
"encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"net/http"
"net/http/httptest"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@@ -234,6 +239,348 @@ func newCLIRepository(t *testing.T) string {
return repository 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: &current, 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 { func newCLIBuildRepository(t *testing.T, mainSource string) string {
t.Helper() t.Helper()
repository := newCLIRepository(t) repository := newCLIRepository(t)

125
delivery.go Normal file
View File

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

View File

@@ -1,16 +1,51 @@
package releasekit package releasekit
import ( import (
"crypto/sha256"
"encoding/hex"
"fmt" "fmt"
"io"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea" "gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea"
) )
func declaredBuildAssets(result BuildResult) ([]gitea.ExpectedAsset, error) { 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) assets := make([]gitea.ExpectedAsset, 0, len(result.Artifacts)+1)
seenTargets := make(map[string]bool, len(result.Artifacts))
for _, artifact := range 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 { 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) 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() { if !info.Mode().IsRegular() {
return nil, fmt.Errorf("build artifact %q is not a regular file", artifact.Filename) 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{ assets = append(assets, gitea.ExpectedAsset{
Filename: artifact.Filename, Filename: artifact.Filename,
SHA256: artifact.SHA256, SHA256: artifact.SHA256,

View File

@@ -5,6 +5,7 @@ import (
"encoding/hex" "encoding/hex"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea" "gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea"
@@ -14,25 +15,29 @@ func TestDeclaredBuildAssetsGenerateManifestFromBuildResult(t *testing.T) {
output := t.TempDir() output := t.TempDir()
first := []byte("first artifact") first := []byte("first artifact")
second := []byte("second artifact") second := []byte("second artifact")
firstPath := filepath.Join(output, "zeta.bin") firstPath := filepath.Join(output, "app-v1.2.3-linux-amd64")
secondPath := filepath.Join(output, "alpha.bin") secondPath := filepath.Join(output, "app-v1.2.3-darwin-arm64")
if err := os.WriteFile(firstPath, first, 0o600); err != nil { if err := os.WriteFile(firstPath, first, 0o600); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := os.WriteFile(secondPath, second, 0o600); err != nil { if err := os.WriteFile(secondPath, second, 0o600); err != nil {
t.Fatal(err) t.Fatal(err)
} }
result, err := declaredBuildAssets(BuildResult{Artifacts: []BuildArtifact{ result, err := declaredBuildAssets(BuildResult{
{Path: firstPath, Filename: "zeta.bin", SHA256: assetDigest(first)}, Version: "v1.2.3", BinaryName: "app",
{Path: secondPath, Filename: "alpha.bin", SHA256: assetDigest(second)}, 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 { if err != nil {
t.Fatal(err) 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) 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 { if string(result[2].Content) != want {
t.Fatalf("manifest = %q, want %q", 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) { func TestDeclaredBuildAssetsKeepsSourceOnlyBuildManifestFree(t *testing.T) {
assets, err := declaredBuildAssets(BuildResult{}) assets, err := declaredBuildAssets(BuildResult{})
if err != nil || len(assets) != 0 { if err != nil || len(assets) != 0 {

View File

@@ -1,6 +1,6 @@
# Command line # Command line
ReleaseKit currently provides four commands. ReleaseKit currently provides five commands.
Print the executable version: 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` tree and does not perform candidate validation or remote publication. `check`
and `publish` run the same configured build after their one candidate and `publish` run the same configured build after their one candidate
validation, using temporary output outside the worktree. 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).

View File

@@ -12,6 +12,7 @@ The current profile fields are:
| `remote` | No | Selected Git remote; defaults to `origin`. | | `remote` | No | Selected Git remote; defaults to `origin`. |
| `checks` | No | Ordered child commands, each with an argument vector and optional environment map. | | `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. | | `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 Each check requires a nonempty `args` array whose first element names the
executable. Arguments are passed directly to the child process; ReleaseKit does 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 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. `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 The `check` and `publish` commands use `product`, `remote`, and `checks`, and
build selected targets after the single validation callback. If `checks` is 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 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 and toolchain network access disabled. If checks are configured, they replace
that default and run in order. The `build` command uses the selected build that default and run in order. The `build` command uses the selected build
settings and writes binaries to `dist` unless `--output` selects another path. 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's source-only publication profile is
[releasekit.json](../releasekit.json). [releasekit.json](../releasekit.json).

View File

@@ -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 `publish` run selected builds after their one validation callback using
temporary output outside the worktree; those candidate checks do not write a temporary output outside the worktree; those candidate checks do not write a
receipt or leave build files in the repository. 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.

View File

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

View File

@@ -1,9 +1,11 @@
# Gitea release pages # Gitea release pages
ReleaseKit's internal Gitea client verifies a hosted source tag, finds or ReleaseKit's public `Deliver` operation and `deliver` command verify tagged
creates its stable release page, inspects existing assets, and uploads missing source, find or create its stable release page, inspect existing assets, and
assets. The public delivery facade and CLI workflow are planned separately; upload missing assets through the internal Gitea client. Page-only and
this guide describes the implemented internal operations. 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 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 explicitly. The client does not read ambient credentials. The token is sent in

View File

@@ -26,13 +26,15 @@ Implemented internal packages:
creates and pushes exact annotated tag objects. creates and pushes exact annotated tag objects.
- `internal/publish` coordinates local and remote guards around one validation - `internal/publish` coordinates local and remote guards around one validation
run and reports the publication state reached by that invocation. 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 - `cmd/releasekit/internal/cli` parses implemented commands, loads strict JSON
profiles, adapts configured checks, and writes and validates private local 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 The executable controller uses the runtime facade and the command-owned CLI
adapter. The adapter uses the process runner and imports neither public facade. adapter. The adapter uses the process runner and imports neither public facade.
The publication operation coordinates the candidate and Git packages. The The publication operation coordinates the candidate and Git packages. The
internal Gitea client is documented in the internal Gitea client is documented in the
[Gitea integration guide](../integrations/gitea.md); delivery orchestration will [Gitea integration guide](../integrations/gitea.md); tagged-source orchestration
use it when that facade and CLI work lands. is exposed through the public delivery facade and CLI.

View File

@@ -1,6 +1,6 @@
# ReleaseKit staged implementation plan # 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 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 ## 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. 1. Implement `Deliver`, its facade-owned request/result values, and conversions.
Bind source note, resolved build settings, tag commit, and hosted repository 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 Candidate tests and Git publication are not run by delivery. A delivery failure
leaves the existing source tag intact and reports only 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 ## Stage 16: Provide the common Woodpecker integration
Status: Pending. Depends on Stage 15. Status: Pending. Depends on Stage 15.

View File

@@ -56,9 +56,9 @@ cleanup.
The [architecture policy](../policy/architecture.md) owns the adopted The [architecture policy](../policy/architecture.md) owns the adopted
responsibility and dependency rules, including the runtime version boundary. responsibility and dependency rules, including the runtime version boundary.
The root `releasekit` package provides the public facade for candidate The root `releasekit` package provides the public facade for candidate
validation, source publication, and selected-target builds. Internal Gitea validation, source publication, selected-target builds, and tagged-source
page and asset-delivery operations are implemented; facade and CLI orchestration delivery. The Gitea page and asset-delivery client is integrated through that
remain planned. The separate public `buildinfo` package provides lightweight facade and the CLI. The separate public `buildinfo` package provides lightweight
runtime version access. runtime version access.
Each facade owns its public named types and documented contracts. Public Each facade owns its public named types and documented contracts. Public

View File

@@ -5,5 +5,11 @@
"main_package": "./cmd/example", "main_package": "./cmd/example",
"binary_name": "example", "binary_name": "example",
"targets": ["linux/amd64", "linux/arm64", "darwin/arm64"] "targets": ["linux/amd64", "linux/arm64", "darwin/arm64"]
},
"delivery": {
"base_url": "https://gitea.example",
"owner": "example",
"repository": "example",
"token_env": "RELEASEKIT_GITEA_TOKEN"
} }
} }

View File

@@ -8,5 +8,11 @@
"RELEASE_MODE": "candidate" "RELEASE_MODE": "candidate"
} }
} }
] ],
"delivery": {
"base_url": "https://gitea.example",
"owner": "example",
"repository": "example",
"token_env": "RELEASEKIT_GITEA_TOKEN"
}
} }

View File

@@ -268,6 +268,11 @@ func validateNote(note []byte, product, version string) error {
return nil 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 { func containsSequence(elements, sequence []string) bool {
if len(sequence) == 0 || len(sequence) > len(elements) { if len(sequence) == 0 || len(sequence) > len(elements) {
return false return false

View File

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

76
internal/git/source.go Normal file
View File

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

View File

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

View File

@@ -33,12 +33,26 @@ type AssetIdentity struct {
} }
type UploadResult struct { type UploadResult struct {
State DeliveryState State DeliveryState
Matching []AssetIdentity Matching []AssetIdentity
Uploaded []AssetIdentity Uploaded []AssetIdentity
Pending []AssetIdentity Pending []AssetIdentity
Conflicting []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 // UploadMissing uploads assets classified missing by InspectAssets. It stops