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