Compare commits
88 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6fab30337a | |||
| e736166a39 | |||
| a2d75c462c | |||
| 31f9e48219 | |||
| 473670185c | |||
| c8fa165dc9 | |||
| 2f95753d9c | |||
| 9dc87ae996 | |||
| d9c97fd915 | |||
| 1d40fb7ac0 | |||
| 03ee83e8a5 | |||
| 9cb48a70b7 | |||
| 2b23574ece | |||
| 76676ee5a6 | |||
| 23b3a3d340 | |||
| ef47356541 | |||
| 0666b68ff6 | |||
| 6f2e62bea3 | |||
| aa57fee4de | |||
| 92c95ef031 | |||
| 595745c7b1 | |||
| 9c02ed769b | |||
| 94aedacf05 | |||
| 7e6e0cc7d1 | |||
| aecab00ff6 | |||
| a1dc705be7 | |||
| b5b3f09599 | |||
| 6f7e43b097 | |||
| 91f25c452c | |||
| 083bc83331 | |||
| 5e6db4de76 | |||
| b345bc0ec4 | |||
| 3d7a691338 | |||
| 3754242969 | |||
| 9545bb0bfc | |||
| 620c832477 | |||
| 812c24de9e | |||
| 1b39e71e81 | |||
| d884ad731d | |||
| dd0a557ad3 | |||
| b633ee8b4c | |||
| fcd2e57887 | |||
| 66f5884ab4 | |||
| 5d5653bfc3 | |||
| 1ad6ddc198 | |||
| 67963102d9 | |||
| d368bb32f9 | |||
| 9fa440726e | |||
| 28a4144b49 | |||
| b91a0d1914 | |||
| abbab3c8ce | |||
| 05f1b472fc | |||
| 5aba4c6c87 | |||
| b47202a2e9 | |||
| 0632aa5901 | |||
| 730e6380c8 | |||
| 59bb0745e7 | |||
| 303385a5ee | |||
| 899e7f1bfc | |||
| 31f5327255 | |||
| 3f00ad2754 | |||
| e42cad7b1a | |||
| eb61b40f08 | |||
| b72af69baf | |||
| 078db6cbcb | |||
| 9376bafaa2 | |||
| 6bed1cd691 | |||
| 7b5c1285a2 | |||
| e5512d50b0 | |||
| 627d524aa2 | |||
| 370fe3a9f5 | |||
| 72de18744b | |||
| cbdba90014 | |||
| c3f6eeb6df | |||
| 2e617db23c | |||
| b350c71246 | |||
| 88a79c3561 | |||
| ddac9e3c73 | |||
| 02bd120aed | |||
| 201af83d95 | |||
| 61a07bc950 | |||
| aa03a78476 | |||
| 9d6952a772 | |||
| 44823ce323 | |||
| 71746f437e | |||
| 2700bd18cb | |||
| ac78e7db73 | |||
| 792bfc4eca |
@@ -11,6 +11,8 @@ delivery are documented. ReleaseKit does not declare binary deliverables.
|
||||
The Go module is `go.maximumdirect.net/releasekit`. The
|
||||
[adoption guide](docs/consumers/adoption.md#migrating-from-the-original-module-path)
|
||||
explains the v0.5.0 migration from the original Gitea module path.
|
||||
The [v0.6.0 release note](docs/releases/v0.6.0.md) describes the delivery API
|
||||
and profile migration.
|
||||
|
||||
Release operations can select one Go module within a repository. The repository
|
||||
root remains the default; a selected module's declared import path and physical
|
||||
@@ -79,6 +81,8 @@ failure recovery.
|
||||
components and their responsibilities.
|
||||
- [Architecture policy](docs/policy/architecture.md): responsibility and dependency
|
||||
boundaries for development.
|
||||
- [ADR 0006](docs/adr/0006-neutral-forge-delivery-and-preflight.md): neutral
|
||||
delivery policy and credential preflight.
|
||||
- [ADR 0001](docs/adr/0001-public-facade.md): the accepted public facade decision.
|
||||
- [Policy provenance](docs/policy/meta.md): adopted policy sources and update
|
||||
guidance.
|
||||
|
||||
5
build.go
5
build.go
@@ -145,10 +145,7 @@ func internalBuildSpec(spec *BuildSpec) *internalbuild.Spec {
|
||||
func internalBuildResult(result BuildResult) *internalbuild.Result {
|
||||
input := internalBuildSpec(&BuildSpec{MainPackage: result.MainPackage, BinaryName: result.BinaryName, Targets: result.Targets})
|
||||
settings := artifact.NormalizeSettings(input.MainPackage, input.BinaryName, input.Targets)
|
||||
converted := &internalbuild.Result{RepositoryDir: result.RepositoryDir, ModuleDir: result.ModuleDir, ModulePath: result.ModulePath, Tag: result.Tag, CandidateCommit: result.CandidateCommit, SourceVerified: result.SourceVerified, Settings: settings, Version: result.Version, MainPackage: settings.MainPackage, BinaryName: settings.BinaryName, OutputDir: result.OutputDir, Compiler: result.Compiler}
|
||||
for _, target := range settings.Targets {
|
||||
converted.Targets = append(converted.Targets, internalbuild.Target{OS: target.OS, Arch: target.Arch})
|
||||
}
|
||||
converted := &internalbuild.Result{RepositoryDir: result.RepositoryDir, ModuleDir: result.ModuleDir, ModulePath: result.ModulePath, Tag: result.Tag, CandidateCommit: result.CandidateCommit, SourceVerified: result.SourceVerified, Settings: settings, Version: result.Version, OutputDir: result.OutputDir, Compiler: result.Compiler}
|
||||
for _, artifact := range result.Artifacts {
|
||||
converted.Artifacts = append(converted.Artifacts, internalbuild.Artifact{Path: artifact.Path, Filename: artifact.Filename, Target: internalbuild.Target{OS: artifact.Target.OS, Arch: artifact.Target.Arch}, SHA256: artifact.SHA256})
|
||||
}
|
||||
|
||||
150
build_source_cancellation_test.go
Normal file
150
build_source_cancellation_test.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package releasekit_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
releasekit "go.maximumdirect.net/releasekit"
|
||||
)
|
||||
|
||||
// inspectionDeadlineContext models deadline expiration at the readiness signal,
|
||||
// so unrelated setup time cannot consume the source-inspection test's deadline.
|
||||
type inspectionDeadlineContext struct{ context.Context }
|
||||
|
||||
func (ctx inspectionDeadlineContext) Err() error {
|
||||
return context.Cause(ctx.Context)
|
||||
}
|
||||
|
||||
func TestBuildSourceInspectionCancellation(t *testing.T) {
|
||||
const source = `package main
|
||||
import("fmt";"os";"go.maximumdirect.net/releasekit/buildinfo")
|
||||
func main(){ if len(os.Args)>1 && os.Args[1]=="version" {fmt.Println("fixture",buildinfo.Version())} }
|
||||
`
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
status int
|
||||
deadline bool
|
||||
binary bool
|
||||
}{
|
||||
{"initial", 1, false, false}, {"deadline", 1, true, false}, {"final", 2, false, true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
repo := newBuildConsumer(t, source)
|
||||
realGit, err := exec.LookPath("git")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bin := t.TempDir()
|
||||
count := filepath.Join(t.TempDir(), "count")
|
||||
ready := filepath.Join(t.TempDir(), "ready")
|
||||
script := `#!/bin/sh
|
||||
for arg in "$@"; do
|
||||
if [ "$arg" = status ]; then
|
||||
count=0
|
||||
if [ -f "$INSPECT_COUNT" ]; then count=$(/bin/cat "$INSPECT_COUNT"); fi
|
||||
count=$((count+1)); printf '%s' "$count" > "$INSPECT_COUNT"
|
||||
if [ "$count" = "$INSPECT_BLOCK" ]; then : > "$INSPECT_READY"; exec /bin/sleep 60; fi
|
||||
fi
|
||||
done
|
||||
exec "$INSPECT_GIT" "$@"
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(bin, "git"), []byte(script), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
t.Setenv("INSPECT_GIT", realGit)
|
||||
t.Setenv("INSPECT_COUNT", count)
|
||||
t.Setenv("INSPECT_READY", ready)
|
||||
t.Setenv("INSPECT_BLOCK", fmt.Sprint(tc.status))
|
||||
var ctx context.Context
|
||||
var cancel context.CancelFunc
|
||||
if tc.deadline {
|
||||
parent, expire := context.WithCancelCause(context.Background())
|
||||
ctx = inspectionDeadlineContext{parent}
|
||||
cancel = func() { expire(context.DeadlineExceeded) }
|
||||
} else {
|
||||
ctx, cancel = context.WithCancel(context.Background())
|
||||
}
|
||||
defer cancel()
|
||||
request := releasekit.BuildRequest{RepositoryDir: repo, Version: "v1.2.3", OutputDir: t.TempDir()}
|
||||
if tc.binary {
|
||||
request.Spec = releasekit.BuildSpec{MainPackage: "./cmd/app", BinaryName: "fixture", Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}}}
|
||||
}
|
||||
type outcome struct {
|
||||
result releasekit.BuildResult
|
||||
err error
|
||||
}
|
||||
done := make(chan outcome, 1)
|
||||
go func() { result, err := releasekit.Build(ctx, request); done <- outcome{result, err} }()
|
||||
timer := time.NewTimer(45 * time.Second)
|
||||
defer timer.Stop()
|
||||
ticker := time.NewTicker(10 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
waiting := true
|
||||
for waiting {
|
||||
if _, err := os.Stat(ready); err == nil {
|
||||
waiting = false
|
||||
break
|
||||
}
|
||||
select {
|
||||
case result := <-done:
|
||||
t.Fatalf("build ended before inspection: %#v, %v", result.result, result.err)
|
||||
case <-timer.C:
|
||||
cancel()
|
||||
result := <-done
|
||||
t.Fatalf("inspection did not start: %#v, %v", result.result, result.err)
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
cancel()
|
||||
var result outcome
|
||||
select {
|
||||
case result = <-done:
|
||||
case <-timer.C:
|
||||
t.Fatal("build did not stop after source inspection interruption")
|
||||
}
|
||||
want := context.Canceled
|
||||
if tc.deadline {
|
||||
want = context.DeadlineExceeded
|
||||
}
|
||||
if !errors.Is(result.err, want) || result.result.SourceVerified {
|
||||
t.Fatalf("result = %#v, error = %v", result.result, result.err)
|
||||
}
|
||||
if tc.binary {
|
||||
if len(result.result.Artifacts) != 1 || result.result.Artifacts[0].SHA256 == "" {
|
||||
t.Fatalf("completed artifact missing: %#v", result.result.Artifacts)
|
||||
}
|
||||
if _, err := os.Stat(result.result.Artifacts[0].Path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSourceInspectionPreservesGitFailure(t *testing.T) {
|
||||
repo := newBuildConsumer(t, "package main\nfunc main() {}\n")
|
||||
realGit, err := exec.LookPath("git")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bin := t.TempDir()
|
||||
script := "#!/bin/sh\nfor arg do if [ \"$arg\" = status ]; then exit 37; fi; done\nexec \"$INSPECT_GIT\" \"$@\"\n"
|
||||
if err := os.WriteFile(filepath.Join(bin, "git"), []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
t.Setenv("INSPECT_GIT", realGit)
|
||||
result, err := releasekit.Build(context.Background(), releasekit.BuildRequest{RepositoryDir: repo, Version: "v1.2.3"})
|
||||
var exitErr *exec.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.ExitCode() != 37 || result.SourceVerified {
|
||||
t.Fatalf("result = %#v, error = %v", result, err)
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,41 @@ import (
|
||||
releasekit "go.maximumdirect.net/releasekit"
|
||||
)
|
||||
|
||||
func TestBuildReplacementDerivedSourceIsNotEligibleForDelivery(t *testing.T) {
|
||||
repository := newTaggedDeliveryConsumer(t)
|
||||
original := buildGit(t, repository, "rev-parse", "HEAD")
|
||||
mainPath := filepath.Join(repository, "cmd/app/main.go")
|
||||
main, err := os.ReadFile(mainPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeBuildFixture(t, repository, "cmd/app/main.go", string(main)+"\n// replacement source\n")
|
||||
buildGit(t, repository, "add", "cmd/app/main.go")
|
||||
buildGit(t, repository, "commit", "-m", "Replacement source")
|
||||
replacement := buildGit(t, repository, "rev-parse", "HEAD")
|
||||
buildGit(t, repository, "reset", "--hard", original)
|
||||
buildGit(t, repository, "replace", original, replacement)
|
||||
buildGit(t, repository, "reset", "--hard", original)
|
||||
result, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
|
||||
RepositoryDir: repository, Version: "v1.2.3", OutputDir: t.TempDir(),
|
||||
Spec: releasekit.BuildSpec{MainPackage: "./cmd/app", BinaryName: "fixture", Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}}},
|
||||
})
|
||||
if err != nil || result.SourceVerified || len(result.Artifacts) != 1 || result.CandidateCommit != original {
|
||||
t.Fatalf("replacement build = %#v, error = %v", result, err)
|
||||
}
|
||||
buildGit(t, repository, "replace", "-d", original)
|
||||
buildGit(t, repository, "reset", "--hard", original)
|
||||
server, remote := newFacadeDeliveryServer(t, repository)
|
||||
defer server.Close()
|
||||
delivery, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{
|
||||
RepositoryDir: repository, Product: "Fixture", Version: "v1.2.3",
|
||||
Destination: facadeDeliveryDestination(server.URL), Prebuilt: &result,
|
||||
})
|
||||
if err == nil || delivery.State != releasekit.DeliveryNotAttempted || remote.snapshot().writes != 0 {
|
||||
t.Fatalf("delivery of replacement build = %#v, error = %v", delivery, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPreservesInvalidVersionAndDeclarationFailureEvidence(t *testing.T) {
|
||||
spec := releasekit.BuildSpec{MainPackage: "./cmd/app", BinaryName: "widget", Targets: []releasekit.Target{{OS: "linux", Arch: "amd64"}, {OS: "linux", Arch: "amd64"}}}
|
||||
invalid, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
|
||||
@@ -486,6 +521,61 @@ func TestBuildReportsMissingMainPackage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRetainsOnlyHashedArtifactsAfterReadFailure(t *testing.T) {
|
||||
repository := newBuildConsumer(t, "package main\nfunc main() {}\n")
|
||||
realGo, err := exec.LookPath("go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bin := t.TempDir()
|
||||
count := filepath.Join(t.TempDir(), "count")
|
||||
script := `#!/bin/sh
|
||||
if [ "$1" != build ]; then exec "$RELEASEKIT_REAL_GO" "$@"; fi
|
||||
output=
|
||||
previous=
|
||||
for arg in "$@"; do
|
||||
if [ "$previous" = -o ]; then output=$arg; fi
|
||||
previous=$arg
|
||||
done
|
||||
"$RELEASEKIT_REAL_GO" "$@" || exit $?
|
||||
if [ -f "$RELEASEKIT_BUILD_COUNT" ]; then
|
||||
rm "$output"
|
||||
mkdir "$output"
|
||||
else
|
||||
: > "$RELEASEKIT_BUILD_COUNT"
|
||||
fi
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(bin, "go"), []byte(script), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
t.Setenv("RELEASEKIT_REAL_GO", realGo)
|
||||
t.Setenv("RELEASEKIT_BUILD_COUNT", count)
|
||||
secondArch := "arm64"
|
||||
if runtime.GOARCH == secondArch {
|
||||
secondArch = "amd64"
|
||||
}
|
||||
result, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
|
||||
RepositoryDir: repository, Version: "v1.2.3", OutputDir: t.TempDir(),
|
||||
Spec: releasekit.BuildSpec{MainPackage: "./cmd/app", BinaryName: "fixture", Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}, {OS: runtime.GOOS, Arch: secondArch}}},
|
||||
})
|
||||
var pathErr *os.PathError
|
||||
if !errors.As(err, &pathErr) || !strings.Contains(err.Error(), "hash build output") || len(result.Artifacts) != 1 || len(result.Targets) != 2 || result.MainPackage != "./cmd/app" || result.BinaryName != "fixture" || result.CandidateCommit == "" || result.SourceVerified {
|
||||
t.Fatalf("partial build = %#v, error = %v", result, err)
|
||||
}
|
||||
contents, readErr := os.ReadFile(result.Artifacts[0].Path)
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
digest := sha256.Sum256(contents)
|
||||
if result.Artifacts[0].SHA256 != hex.EncodeToString(digest[:]) {
|
||||
t.Fatalf("completed artifact digest = %q", result.Artifacts[0].SHA256)
|
||||
}
|
||||
if info, statErr := os.Stat(pathErr.Path); statErr != nil || !info.IsDir() {
|
||||
t.Fatalf("failed output was not retained: %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildReportsIncorrectVersionOutputAndRetainsBinary(t *testing.T) {
|
||||
repository := newBuildConsumer(t, `package main
|
||||
import (
|
||||
@@ -815,7 +905,7 @@ func main(){ if len(os.Args)>1 && os.Args[1]=="version" {fmt.Println("fixture",b
|
||||
}
|
||||
buildGit(t, repository, "restore", "cmd/app/main.go")
|
||||
buildGit(t, repository, "checkout", "--detach", "v1.2.3")
|
||||
delivered, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{RepositoryDir: repository, Product: "Fixture", Version: "v1.2.3", Prebuilt: &result})
|
||||
delivered, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{RepositoryDir: repository, Product: "Fixture", Version: "v1.2.3", Destination: releasekit.DeliveryDestination{Forge: releasekit.ForgeGitea}, Prebuilt: &result})
|
||||
if err == nil || !strings.Contains(err.Error(), "build source") || delivered.State != releasekit.DeliveryNotAttempted {
|
||||
t.Fatalf("dirty source delivered: %#v %v", delivered, err)
|
||||
}
|
||||
|
||||
11
check.go
11
check.go
@@ -59,6 +59,8 @@ type ReleaseRequest struct {
|
||||
// module and shared-file inputs. Independent nested modules form automatic
|
||||
// input boundaries; vendored files remain included. Referenced destinations
|
||||
// remain available for targeted inspection without expanding the input set.
|
||||
// ForbiddenFiles checks explicitly selected repository paths independently of
|
||||
// those file inputs.
|
||||
type ValidationSpec struct {
|
||||
// SharedFiles adds repository-relative committed files to optional file
|
||||
// checks. Nil selects the optional root COPYRIGHT, LICENSE, and
|
||||
@@ -66,7 +68,11 @@ type ValidationSpec struct {
|
||||
// Explicit files replace the defaults and must exist even when file checks
|
||||
// are off.
|
||||
SharedFiles []string
|
||||
Go GoValidationSpec
|
||||
// ForbiddenFiles lists literal repository-relative file paths that must be
|
||||
// absent from the captured commit, independently of module/shared inputs.
|
||||
// An empty list disables the check. File contents and history are not read.
|
||||
ForbiddenFiles []string
|
||||
Go GoValidationSpec
|
||||
// GoFormat checks the formatting of committed regular Go source files. It
|
||||
// does not modify repository contents and is disabled when false.
|
||||
GoFormat bool
|
||||
@@ -137,6 +143,8 @@ type GoValidationSpec struct {
|
||||
// of test-time vet, and Validation.Go.CompileTargets compiles all packages for
|
||||
// each selected GOOS/GOARCH pair after tests and vet with cgo disabled. Package
|
||||
// compilation produces no artifacts and does not execute target binaries.
|
||||
// Validation.ForbiddenFiles checks that selected repository-relative files are
|
||||
// absent from the captured commit after Go checks and before other file checks.
|
||||
// Validation.GoFormat checks regular committed Go source after those selected
|
||||
// Go checks and before AdditionalChecks. Its zero value disables formatting.
|
||||
// Validation.ModuleDistribution.WorkspaceFiles rejects committed workspace
|
||||
@@ -189,6 +197,7 @@ func candidateRequest(request ReleaseRequest) candidate.Request {
|
||||
FullVet: request.Validation.Go.FullVet,
|
||||
CompileTargets: make([]validation.Target, len(request.Validation.Go.CompileTargets)),
|
||||
SharedFiles: copyStringSlice(request.Validation.SharedFiles),
|
||||
ForbiddenFiles: copyStringSlice(request.Validation.ForbiddenFiles),
|
||||
GoFormat: request.Validation.GoFormat,
|
||||
Markdown: internalMarkdownSettings(request.Validation.Markdown),
|
||||
ModuleDistribution: validation.ModuleDistributionSettings{
|
||||
|
||||
@@ -42,7 +42,7 @@ func TestBuildAndPrebuiltDeliveryUseSameDirectoryBase(t *testing.T) {
|
||||
defer server.Close()
|
||||
settings := cli.Settings{Product: "Example Product", ModuleDir: tt.module,
|
||||
Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}},
|
||||
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||
Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||
}
|
||||
writeDeliveryProfile(t, repository, settings)
|
||||
cliGitRun(t, repository, "add", ".")
|
||||
@@ -94,9 +94,9 @@ func TestBuildAndPrebuiltDeliveryUseSameDirectoryBase(t *testing.T) {
|
||||
if err := os.WriteFile(receipt.Artifacts[0].Path, []byte("changed"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requestsBefore := state.snapshot().apiRequests
|
||||
if status := run(context.Background(), args, &stdout, &stderr); status != 1 || state.snapshot().apiRequests != requestsBefore {
|
||||
t.Fatalf("changed content status=%d, requests=%d->%d, stderr=%s", status, requestsBefore, state.snapshot().apiRequests, stderr.String())
|
||||
writesBefore := state.snapshot().writes
|
||||
if status := run(context.Background(), args, &stdout, &stderr); status != 1 || state.snapshot().writes != writesBefore {
|
||||
t.Fatalf("changed content status=%d, writes=%d->%d, stderr=%s", status, writesBefore, state.snapshot().writes, stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
60
cmd/releasekit/ci_identity_test.go
Normal file
60
cmd/releasekit/ci_identity_test.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli"
|
||||
)
|
||||
|
||||
func TestCIDeliveryRejectsModuleTagMismatchBeforePreparation(t *testing.T) {
|
||||
for _, binary := range []bool{false, true} {
|
||||
name := "page-only"
|
||||
if binary {
|
||||
name = "binary"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
repository := newDeliveryBuildRepository(t)
|
||||
writeRepositoryFile(t, repository, "child/go.mod", "module example.com/tagged-app/child\n\ngo 1.26.0\n")
|
||||
writeRepositoryFile(t, repository, "child/docs/releases/v1.2.3.md", "# Example Product v1.2.3\n\nChild release.\n")
|
||||
cliGitRun(t, repository, "add", "child")
|
||||
cliGitRun(t, repository, "commit", "-m", "add child module")
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Errorf("mismatched module tag reached forge: %s", r.URL.Path)
|
||||
http.Error(w, "unexpected request", http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
settings := cli.Settings{
|
||||
Product: "Example Product", ModuleDir: "child",
|
||||
Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||
}
|
||||
if binary {
|
||||
settings.Build = &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}}
|
||||
}
|
||||
writeDeliveryProfile(t, repository, settings)
|
||||
commit := pushTaggedCLIRepository(t, repository)
|
||||
t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token")
|
||||
shimDir := t.TempDir()
|
||||
marker := filepath.Join(shimDir, "go-invoked")
|
||||
if err := os.WriteFile(filepath.Join(shimDir, "go"), []byte("#!/bin/sh\n: > \"$RELEASEKIT_GO_MARKER\"\nexit 99\n"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("RELEASEKIT_GO_MARKER", marker)
|
||||
t.Setenv("PATH", shimDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
var stdout, stderr bytes.Buffer
|
||||
status := run(context.Background(), []string{"ci-deliver", "--repo", repository, "--prepared", "--commit", commit, "--expected-module", "child", "v1.2.3"}, &stdout, &stderr)
|
||||
if status != 1 || !strings.Contains(stderr.String(), `expected delivery tag "v1.2.3"`) || !strings.Contains(stderr.String(), `selected module tag "child/v1.2.3"`) {
|
||||
t.Fatalf("status=%d stderr=%q, want module tag mismatch", status, stderr.String())
|
||||
}
|
||||
if _, err := os.Stat(marker); !os.IsNotExist(err) {
|
||||
t.Fatalf("dependency/build work ran before tag comparison: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,7 @@ func TestPageOnlyDeliveryUsesCommittedModuleInSparseCheckout(t *testing.T) {
|
||||
server := newDeliveryServer(t, state)
|
||||
defer server.Close()
|
||||
writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", ModuleDir: module,
|
||||
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||
Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||
})
|
||||
state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
|
||||
cliGitRun(t, repository, "tag", state.snapshot().tag)
|
||||
|
||||
71
cmd/releasekit/delivery_status_test.go
Normal file
71
cmd/releasekit/delivery_status_test.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli"
|
||||
)
|
||||
|
||||
func TestDeliveryFailureStatusesRespectPhase(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name, rejectedPath, state string
|
||||
cancelWrite bool
|
||||
status int
|
||||
}{
|
||||
{name: "preflight authorization", rejectedPath: "/api/v1/repos/owner/example", state: "not_attempted", status: 2},
|
||||
{name: "later authorization", rejectedPath: "/api/v1/repos/owner/example/tags/v1.2.3", state: "not_attempted", status: 1},
|
||||
{name: "canceled unresolved write", cancelWrite: true, state: "unknown", status: 3},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
repository := newCLIRepository(t)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
var commit string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == test.rejectedPath {
|
||||
http.Error(w, "rejected credential", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/repos/owner/example", "/api/v1/repositories/1":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"id": 1, "name": "example", "full_name": "owner/example", "owner": map[string]string{"login": "owner"}})
|
||||
case "/api/v1/repos/owner/example/tags/v1.2.3":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"commit": map[string]string{"sha": commit}})
|
||||
case "/api/v1/repos/owner/example/releases/tags/v1.2.3":
|
||||
http.NotFound(w, r)
|
||||
case "/api/v1/repos/owner/example/releases":
|
||||
if r.Method != http.MethodPost || !test.cancelWrite {
|
||||
t.Errorf("unexpected release write: %s", r.Method)
|
||||
}
|
||||
cancel()
|
||||
http.Error(w, "unacknowledged write", http.StatusServiceUnavailable)
|
||||
default:
|
||||
t.Errorf("unexpected request: %s", r.URL.Path)
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
writeDeliveryProfile(t, repository, cli.Settings{
|
||||
Product: "Example Product",
|
||||
Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||
})
|
||||
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(ctx, []string{"deliver", "--repo", repository, "v1.2.3"}, &stdout, &stderr)
|
||||
if status != test.status || !strings.Contains(stderr.String(), "state="+test.state) {
|
||||
t.Fatalf("status=%d stderr=%q, want status %d and state %s", status, stderr.String(), test.status, test.state)
|
||||
}
|
||||
if test.cancelWrite && !strings.Contains(stderr.String(), context.Canceled.Error()) {
|
||||
t.Fatalf("cancellation cause missing: %s", stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
64
cmd/releasekit/destination_test.go
Normal file
64
cmd/releasekit/destination_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli"
|
||||
)
|
||||
|
||||
func TestInvalidDeliveryDestinationStopsBeforePreparation(t *testing.T) {
|
||||
for _, command := range []string{"deliver", "ci-deliver"} {
|
||||
for _, setting := range []string{"URL", "owner", "repository"} {
|
||||
t.Run(command+"/"+setting, func(t *testing.T) {
|
||||
repository := newDeliveryBuildRepository(t)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Errorf("invalid destination made a hosted request: %s", r.URL.Path)
|
||||
http.Error(w, "unexpected request", http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
destination := &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example", TokenEnv: "RELEASEKIT_TEST_DELIVERY_TOKEN"}
|
||||
switch setting {
|
||||
case "URL":
|
||||
destination.BaseURL = "ssh://forge.example"
|
||||
case "owner":
|
||||
destination.Owner = "owner/child"
|
||||
case "repository":
|
||||
destination.Repository = "repo/child"
|
||||
}
|
||||
writeDeliveryProfile(t, repository, cli.Settings{
|
||||
Product: "Example Product", Delivery: destination,
|
||||
Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}},
|
||||
})
|
||||
commit := pushTaggedCLIRepository(t, repository)
|
||||
t.Setenv("RELEASEKIT_TEST_DELIVERY_TOKEN", "fixture-token")
|
||||
shimDir := t.TempDir()
|
||||
marker := filepath.Join(shimDir, "go-invoked")
|
||||
if err := os.WriteFile(filepath.Join(shimDir, "go"), []byte("#!/bin/sh\n: > \"$RELEASEKIT_GO_MARKER\"\nexit 99\n"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("RELEASEKIT_GO_MARKER", marker)
|
||||
t.Setenv("PATH", shimDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
args := []string{command, "--repo", repository}
|
||||
if command == "ci-deliver" {
|
||||
args = append(args, "--prepared", "--commit", commit)
|
||||
}
|
||||
args = append(args, "v1.2.3")
|
||||
var stdout, stderr bytes.Buffer
|
||||
status := run(context.Background(), args, &stdout, &stderr)
|
||||
if status != 2 || !strings.Contains(stderr.String(), "invalid_destination") {
|
||||
t.Fatalf("status=%d stderr=%q, want destination configuration error", status, stderr.String())
|
||||
}
|
||||
if _, err := os.Stat(marker); !os.IsNotExist(err) {
|
||||
t.Fatalf("preparation/build ran for invalid destination: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -153,8 +153,9 @@ type Settings struct {
|
||||
type ValidationSettings struct {
|
||||
// SharedFiles selects committed files relative to the repository root.
|
||||
// Nil uses optional defaults; a non-nil empty slice selects no shared files.
|
||||
SharedFiles []string `json:"shared_files"`
|
||||
Go GoValidationSettings `json:"go,omitempty"`
|
||||
SharedFiles []string `json:"shared_files"`
|
||||
ForbiddenFiles []string `json:"forbidden_files,omitempty"`
|
||||
Go GoValidationSettings `json:"go,omitempty"`
|
||||
// GoFormat checks the formatting of committed regular Go source files.
|
||||
// Omitted or false disables the check.
|
||||
GoFormat bool `json:"go_format,omitempty"`
|
||||
@@ -195,12 +196,14 @@ type GoValidationSettings struct {
|
||||
CompileTargets []string `json:"compile_targets,omitempty"`
|
||||
}
|
||||
|
||||
// DeliverySettings selects the hosted Gitea release destination.
|
||||
// DeliverySettings selects the hosted forge release destination.
|
||||
type DeliverySettings struct {
|
||||
Forge string `json:"forge,omitempty"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Owner string `json:"owner"`
|
||||
Repository string `json:"repository"`
|
||||
TokenEnv string `json:"token_env,omitempty"`
|
||||
TokenFile string `json:"token_file,omitempty"`
|
||||
}
|
||||
|
||||
// BuildSettings selects the Go application package, artifact name, and targets.
|
||||
|
||||
@@ -49,6 +49,10 @@ type BuildReceipt struct {
|
||||
}
|
||||
|
||||
func WriteBuildReceipt(outputDir string, receipt BuildReceipt) (string, error) {
|
||||
return writeBuildReceipt(outputDir, receipt, os.Remove)
|
||||
}
|
||||
|
||||
func writeBuildReceipt(outputDir string, receipt BuildReceipt, remove func(string) error) (resultPath string, resultErr error) {
|
||||
resolvedDir, err := filepath.Abs(outputDir)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve build receipt directory: %w", err)
|
||||
@@ -67,7 +71,11 @@ func WriteBuildReceipt(outputDir string, receipt BuildReceipt) (string, error) {
|
||||
return "", fmt.Errorf("create temporary build receipt: %w", err)
|
||||
}
|
||||
temporaryPath := temporary.Name()
|
||||
defer os.Remove(temporaryPath)
|
||||
defer func() {
|
||||
if err := remove(temporaryPath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
resultErr = errors.Join(resultErr, fmt.Errorf("remove temporary build receipt %q: %w", temporaryPath, err))
|
||||
}
|
||||
}()
|
||||
if _, err := temporary.Write(contents); err != nil {
|
||||
_ = temporary.Close()
|
||||
return "", fmt.Errorf("write build receipt: %w", err)
|
||||
@@ -182,32 +190,15 @@ func validateBuildReceipt(receipt BuildReceipt) error {
|
||||
if len(receipt.Targets) == 0 || len(receipt.Artifacts) != len(receipt.Targets) {
|
||||
return errors.New("build receipt target and artifact counts do not match")
|
||||
}
|
||||
seenTargets := make(map[ReceiptTarget]bool, len(receipt.Targets))
|
||||
seenArtifacts := make(map[ReceiptTarget]bool, len(receipt.Artifacts))
|
||||
targets := make([]artifact.Target, 0, len(receipt.Targets))
|
||||
for _, target := range receipt.Targets {
|
||||
if !artifact.ValidComponent(target.OS) || !artifact.ValidComponent(target.Arch) {
|
||||
return fmt.Errorf("build receipt has unsafe target %q/%q", target.OS, target.Arch)
|
||||
}
|
||||
if seenTargets[target] {
|
||||
return fmt.Errorf("build receipt repeats target %s/%s", target.OS, target.Arch)
|
||||
}
|
||||
seenTargets[target] = true
|
||||
targets = append(targets, artifact.Target{OS: target.OS, Arch: target.Arch})
|
||||
declarations := make([]artifact.Declaration, 0, len(receipt.Artifacts))
|
||||
for _, item := range receipt.Artifacts {
|
||||
declarations = append(declarations, artifact.Declaration{Target: artifact.Target{OS: item.Target.OS, Arch: item.Target.Arch}, Filename: item.Filename})
|
||||
}
|
||||
declarations := artifact.ExpectedBuild(receipt.BinaryName, receipt.Version, targets)
|
||||
wantByTarget := make(map[ReceiptTarget]string, len(declarations))
|
||||
for _, declaration := range declarations {
|
||||
target := ReceiptTarget{OS: declaration.Target.OS, Arch: declaration.Target.Arch}
|
||||
wantByTarget[target] = declaration.Filename
|
||||
if err := artifact.ValidateDeclarations(receipt.BinaryName, receipt.Version, parseStoredTargets(receipt.Targets), declarations); err != nil {
|
||||
return fmt.Errorf("build receipt: %w", err)
|
||||
}
|
||||
for _, artifact := range receipt.Artifacts {
|
||||
if !seenTargets[artifact.Target] || seenArtifacts[artifact.Target] {
|
||||
return fmt.Errorf("build receipt has an unexpected artifact target %s/%s", artifact.Target.OS, artifact.Target.Arch)
|
||||
}
|
||||
seenArtifacts[artifact.Target] = true
|
||||
wantName := wantByTarget[artifact.Target]
|
||||
if artifact.Filename != wantName || filepath.Base(artifact.Filename) != artifact.Filename {
|
||||
if filepath.Base(artifact.Filename) != artifact.Filename {
|
||||
return fmt.Errorf("build receipt has invalid artifact filename %q", artifact.Filename)
|
||||
}
|
||||
wantPath := filepath.Join(receipt.OutputDir, artifact.Filename)
|
||||
|
||||
@@ -4,12 +4,68 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWriteBuildReceiptReportsTemporaryCleanup(t *testing.T) {
|
||||
for _, mode := range []string{"renamed", "cleanup failure", "install and cleanup failure"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
repository := t.TempDir()
|
||||
output := t.TempDir()
|
||||
name := "app-v1.2.3-linux-amd64"
|
||||
receipt := BuildReceipt{
|
||||
SchemaVersion: BuildReceiptSchemaVersion, RepositoryDir: repository, ModuleDir: repository,
|
||||
ModulePath: "example.com/receipt", Tag: "v1.2.3", CandidateCommit: strings.Repeat("a", 40),
|
||||
Version: "v1.2.3", MainPackage: ".", BinaryName: "app", Compiler: "go fixture",
|
||||
Targets: []ReceiptTarget{{OS: "linux", Arch: "amd64"}},
|
||||
Artifacts: []ReceiptArtifact{{Path: filepath.Join(output, name), Filename: name, Target: ReceiptTarget{OS: "linux", Arch: "amd64"}, SHA256: strings.Repeat("a", 64)}},
|
||||
}
|
||||
if mode == "install and cleanup failure" {
|
||||
blocked := filepath.Join(output, BuildReceiptName)
|
||||
if err := os.Mkdir(blocked, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(blocked, "keep"), nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
cleanupErr := errors.New("cleanup failed")
|
||||
var temporaryPath string
|
||||
remove := func(path string) error {
|
||||
temporaryPath = path
|
||||
err := os.Remove(path)
|
||||
if mode == "renamed" {
|
||||
return err
|
||||
}
|
||||
return cleanupErr
|
||||
}
|
||||
path, err := writeBuildReceipt(output, receipt, remove)
|
||||
if mode == "renamed" {
|
||||
if err != nil || path != filepath.Join(output, BuildReceiptName) {
|
||||
t.Fatalf("renamed receipt = %q, %v", path, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !errors.Is(err, cleanupErr) || temporaryPath == "" || !strings.Contains(err.Error(), temporaryPath) {
|
||||
t.Fatalf("cleanup path = %q, error = %v", temporaryPath, err)
|
||||
}
|
||||
if mode == "cleanup failure" && path != filepath.Join(output, BuildReceiptName) {
|
||||
t.Fatalf("installed receipt path = %q", path)
|
||||
}
|
||||
if mode == "install and cleanup failure" {
|
||||
var pathErr *os.PathError
|
||||
if !errors.As(err, &pathErr) {
|
||||
t.Fatalf("install cause missing: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildReceiptRoundTripBindsRootAndNestedModuleIdentity(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
|
||||
@@ -20,7 +20,7 @@ func TestMainModuleWithoutDomainSupportsCheckBuildAndPageDelivery(t *testing.T)
|
||||
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"},
|
||||
Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||
}
|
||||
writeDeliveryProfile(t, repository, settings)
|
||||
for _, args := range [][]string{
|
||||
|
||||
@@ -45,102 +45,9 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
||||
fmt.Fprintf(stdout, "releasekit %s\n", buildinfo.Version())
|
||||
return 0
|
||||
case cli.CheckAction, cli.PublishAction:
|
||||
settings, err := cli.LoadSettings(invocation.Repository, invocation.ConfigPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "load profile: %v\n", err)
|
||||
return 2
|
||||
}
|
||||
request := candidateRequest(invocation, settings, stderr)
|
||||
if invocation.Command == cli.PublishAction {
|
||||
if err := checkExpectedModule(ctx, invocation.Repository, invocation.ExpectedModule, settings.ModuleDir); err != nil {
|
||||
fmt.Fprintf(stderr, "profile module selection: %v\n", err)
|
||||
return 2
|
||||
}
|
||||
request.ExpectedPushURL = settings.ExpectedPushURL
|
||||
fmt.Fprintf(stderr, "validating and publishing module %s version %s\n", selectedModuleLabel(settings.ModuleDir), invocation.Version)
|
||||
result, err := releasekit.Publish(ctx, request)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "publication state=%s phase=%s module=%s module-dir=%s version=%s tag=%s commit=%s tag-object=%s endpoint=%q: %s\n", result.State, result.Phase, result.ModulePath, result.ModuleDir, result.Version, result.Tag, result.CandidateCommit, result.TagObject, result.Endpoint, candidateFailureText(err))
|
||||
if errors.Is(err, releasekit.ErrInvalidVersion) {
|
||||
return 2
|
||||
}
|
||||
if result.State == releasekit.PublicationUnknown {
|
||||
return 3
|
||||
}
|
||||
return 1
|
||||
}
|
||||
fmt.Fprintf(stdout, "published %s as %s at %s (tag object %s)\n", result.Version, result.Tag, result.CandidateCommit, result.TagObject)
|
||||
return 0
|
||||
}
|
||||
candidate, err := releasekit.Check(ctx, request)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "check failed module=%s module-dir=%s version=%s tag=%s commit=%s: %s\n", candidate.ModulePath, candidate.ModuleDir, candidate.Version, candidate.Tag, candidate.Commit, candidateFailureText(err))
|
||||
if errors.Is(err, releasekit.ErrInvalidVersion) {
|
||||
return 2
|
||||
}
|
||||
return 1
|
||||
}
|
||||
fmt.Fprintf(stdout, "local candidate module %s version %s tag %s at %s is valid\n", candidate.ModulePath, candidate.Version, candidate.Tag, candidate.Commit)
|
||||
return 0
|
||||
return runCheckOrPublish(ctx, invocation, stdout, stderr)
|
||||
case cli.BuildAction:
|
||||
settings, err := cli.LoadSettings(invocation.Repository, invocation.ConfigPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "load profile: %v\n", err)
|
||||
return 2
|
||||
}
|
||||
if settings.Build == nil {
|
||||
fmt.Fprintln(stderr, "build requires build settings in the profile")
|
||||
return 2
|
||||
}
|
||||
result, err := releasekit.Build(ctx, releasekit.BuildRequest{
|
||||
RepositoryDir: invocation.Repository,
|
||||
ModuleDir: settings.ModuleDir,
|
||||
Version: invocation.Version,
|
||||
Spec: *configuredBuildSpec(settings.Build),
|
||||
OutputDir: invocation.OutputDir,
|
||||
Output: stderr,
|
||||
})
|
||||
if result.OutputDir != "" && len(result.Targets) != 0 {
|
||||
if receiptErr := cli.RemoveBuildReceipt(result.OutputDir); receiptErr != nil {
|
||||
fmt.Fprintf(stderr, "%v\n", receiptErr)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "build failed module=%s module-dir=%s version=%s tag=%s commit=%s: %v\n", result.ModulePath, result.ModuleDir, result.Version, result.Tag, result.CandidateCommit, err)
|
||||
for _, artifact := range result.Artifacts {
|
||||
fmt.Fprintf(stderr, "built %s %s\n", artifact.Filename, artifact.SHA256)
|
||||
}
|
||||
if errors.Is(err, releasekit.ErrInvalidVersion) {
|
||||
return 2
|
||||
}
|
||||
return 1
|
||||
}
|
||||
if len(result.Targets) == 0 {
|
||||
fmt.Fprintf(stdout, "no build targets selected for module %s version %s tag %s at %s\n", result.ModulePath, result.Version, result.Tag, result.CandidateCommit)
|
||||
return 0
|
||||
}
|
||||
receipt := receiptFromBuild(result)
|
||||
receiptPath, err := cli.WriteBuildReceipt(result.OutputDir, receipt)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "write build receipt: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
stored, err := cli.LoadBuildReceipt(receiptPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "verify build receipt: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
if !stored.MatchesBuild(result.RepositoryDir, result.CandidateCommit, result.Version, *settings.Build) {
|
||||
fmt.Fprintln(stderr, "build receipt does not match the selected source and build settings")
|
||||
return 1
|
||||
}
|
||||
fmt.Fprintf(stdout, "built %d artifacts for module %s version %s tag %s at %s with %s\n", len(result.Artifacts), result.ModulePath, result.Version, result.Tag, result.CandidateCommit, result.Compiler)
|
||||
for _, artifact := range result.Artifacts {
|
||||
fmt.Fprintf(stdout, "%s %s\n", artifact.Filename, artifact.SHA256)
|
||||
}
|
||||
fmt.Fprintf(stdout, "build receipt: %s\n", receiptPath)
|
||||
return 0
|
||||
return runBuild(ctx, invocation, stdout, stderr)
|
||||
case cli.DeliveryAction:
|
||||
return runDelivery(ctx, invocation, stdout, stderr)
|
||||
case cli.CIDeliveryAction:
|
||||
@@ -149,6 +56,107 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
||||
return 2
|
||||
}
|
||||
|
||||
func runCheckOrPublish(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
|
||||
}
|
||||
request := candidateRequest(invocation, settings, stderr)
|
||||
if invocation.Command == cli.PublishAction {
|
||||
if err := checkExpectedModule(ctx, invocation.Repository, invocation.ExpectedModule, settings.ModuleDir); err != nil {
|
||||
fmt.Fprintf(stderr, "profile module selection: %v\n", err)
|
||||
return 2
|
||||
}
|
||||
request.ExpectedPushURL = settings.ExpectedPushURL
|
||||
fmt.Fprintf(stderr, "validating and publishing module %s version %s\n", selectedModuleLabel(settings.ModuleDir), invocation.Version)
|
||||
result, err := releasekit.Publish(ctx, request)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "publication state=%s phase=%s module=%s module-dir=%s version=%s tag=%s commit=%s tag-object=%s endpoint=%q: %s\n", result.State, result.Phase, result.ModulePath, result.ModuleDir, result.Version, result.Tag, result.CandidateCommit, result.TagObject, git.DisplayURL(result.Endpoint), candidateFailureText(err))
|
||||
if errors.Is(err, releasekit.ErrInvalidVersion) {
|
||||
return 2
|
||||
}
|
||||
if result.State == releasekit.PublicationUnknown {
|
||||
return 3
|
||||
}
|
||||
return 1
|
||||
}
|
||||
fmt.Fprintf(stdout, "published %s as %s at %s (tag object %s)\n", result.Version, result.Tag, result.CandidateCommit, result.TagObject)
|
||||
return 0
|
||||
}
|
||||
candidate, err := releasekit.Check(ctx, request)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "check failed module=%s module-dir=%s version=%s tag=%s commit=%s: %s\n", candidate.ModulePath, candidate.ModuleDir, candidate.Version, candidate.Tag, candidate.Commit, candidateFailureText(err))
|
||||
if errors.Is(err, releasekit.ErrInvalidVersion) {
|
||||
return 2
|
||||
}
|
||||
return 1
|
||||
}
|
||||
fmt.Fprintf(stdout, "local candidate module %s version %s tag %s at %s is valid\n", candidate.ModulePath, candidate.Version, candidate.Tag, candidate.Commit)
|
||||
return 0
|
||||
}
|
||||
|
||||
func runBuild(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
|
||||
}
|
||||
if settings.Build == nil {
|
||||
fmt.Fprintln(stderr, "build requires build settings in the profile")
|
||||
return 2
|
||||
}
|
||||
result, err := releasekit.Build(ctx, releasekit.BuildRequest{
|
||||
RepositoryDir: invocation.Repository,
|
||||
ModuleDir: settings.ModuleDir,
|
||||
Version: invocation.Version,
|
||||
Spec: *configuredBuildSpec(settings.Build),
|
||||
OutputDir: invocation.OutputDir,
|
||||
Output: stderr,
|
||||
})
|
||||
if result.OutputDir != "" && len(result.Targets) != 0 {
|
||||
if receiptErr := cli.RemoveBuildReceipt(result.OutputDir); receiptErr != nil {
|
||||
fmt.Fprintf(stderr, "%v\n", receiptErr)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "build failed module=%s module-dir=%s version=%s tag=%s commit=%s: %v\n", result.ModulePath, result.ModuleDir, result.Version, result.Tag, result.CandidateCommit, err)
|
||||
for _, artifact := range result.Artifacts {
|
||||
fmt.Fprintf(stderr, "built %s %s\n", artifact.Filename, artifact.SHA256)
|
||||
}
|
||||
if errors.Is(err, releasekit.ErrInvalidVersion) {
|
||||
return 2
|
||||
}
|
||||
return 1
|
||||
}
|
||||
if len(result.Targets) == 0 {
|
||||
fmt.Fprintf(stdout, "no build targets selected for module %s version %s tag %s at %s\n", result.ModulePath, result.Version, result.Tag, result.CandidateCommit)
|
||||
return 0
|
||||
}
|
||||
receipt := receiptFromBuild(result)
|
||||
receiptPath, err := cli.WriteBuildReceipt(result.OutputDir, receipt)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "write build receipt: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
stored, err := cli.LoadBuildReceipt(receiptPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "verify build receipt: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
if !stored.MatchesBuild(result.RepositoryDir, result.CandidateCommit, result.Version, *settings.Build) {
|
||||
fmt.Fprintln(stderr, "build receipt does not match the selected source and build settings")
|
||||
return 1
|
||||
}
|
||||
fmt.Fprintf(stdout, "built %d artifacts for module %s version %s tag %s at %s with %s\n", len(result.Artifacts), result.ModulePath, result.Version, result.Tag, result.CandidateCommit, result.Compiler)
|
||||
for _, artifact := range result.Artifacts {
|
||||
fmt.Fprintf(stdout, "%s %s\n", artifact.Filename, artifact.SHA256)
|
||||
}
|
||||
fmt.Fprintf(stdout, "build receipt: %s\n", receiptPath)
|
||||
return 0
|
||||
}
|
||||
|
||||
func candidateRequest(invocation cli.Invocation, settings cli.Settings, output io.Writer) releasekit.ReleaseRequest {
|
||||
request := releasekit.ReleaseRequest{
|
||||
RepositoryDir: invocation.Repository,
|
||||
@@ -181,8 +189,9 @@ func validationSpec(settings *cli.ValidationSettings) releasekit.ValidationSpec
|
||||
return releasekit.ValidationSpec{}
|
||||
}
|
||||
result := releasekit.ValidationSpec{
|
||||
SharedFiles: copyStringsPreservingEmpty(settings.SharedFiles),
|
||||
GoFormat: settings.GoFormat,
|
||||
SharedFiles: copyStringsPreservingEmpty(settings.SharedFiles),
|
||||
ForbiddenFiles: copyStringsPreservingEmpty(settings.ForbiddenFiles),
|
||||
GoFormat: settings.GoFormat,
|
||||
ModuleDistribution: releasekit.ModuleDistributionValidationSpec{
|
||||
WorkspaceFiles: settings.ModuleDistribution.WorkspaceFiles,
|
||||
LocalReplacements: settings.ModuleDistribution.LocalReplacements,
|
||||
@@ -290,11 +299,24 @@ func runCIDelivery(ctx context.Context, invocation cli.Invocation, stdout, stder
|
||||
fmt.Fprintf(stderr, "profile module selection: %v\n", err)
|
||||
return 2
|
||||
}
|
||||
destination, status := deliveryDestination(settings, stderr)
|
||||
identity, err := releaseidentity.FromCommitted(ctx, invocation.Repository, settings.ModuleDir, invocation.Version, invocation.Commit)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "resolve CI release identity: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
if invocation.ExpectedTag != identity.Tag {
|
||||
fmt.Fprintf(stderr, "expected delivery tag %q does not match selected module tag %q\n", invocation.ExpectedTag, identity.Tag)
|
||||
return 1
|
||||
}
|
||||
destination, status := deliveryDestination(invocation.Repository, settings, stderr)
|
||||
if status != 0 {
|
||||
return status
|
||||
}
|
||||
if settings.Build != nil && len(settings.Build.Targets) != 0 {
|
||||
if err := releasekit.PreflightDelivery(ctx, destination); err != nil {
|
||||
fmt.Fprintf(stderr, "delivery credential preflight: %v\n", err)
|
||||
return deliveryPreflightExitStatus(err)
|
||||
}
|
||||
if err := releasekit.PrepareBuildDependencies(ctx, releasekit.DependencyPreparationRequest{
|
||||
RepositoryDir: invocation.Repository,
|
||||
ModuleDir: settings.ModuleDir,
|
||||
@@ -309,32 +331,61 @@ func runCIDelivery(ctx context.Context, invocation cli.Invocation, stdout, stder
|
||||
// deliverWithSettings adapts prepared CLI state to the public delivery facade.
|
||||
// CI entry points can reuse it after preparing the checkout and loading its profile.
|
||||
func deliverWithSettings(ctx context.Context, invocation cli.Invocation, settings cli.Settings, stdout, stderr io.Writer) int {
|
||||
destination, status := deliveryDestination(settings, stderr)
|
||||
destination, status := deliveryDestination(invocation.Repository, settings, stderr)
|
||||
if status != 0 {
|
||||
return status
|
||||
}
|
||||
return deliverWithDestination(ctx, invocation, settings, destination, stdout, stderr)
|
||||
}
|
||||
|
||||
func deliveryDestination(settings cli.Settings, stderr io.Writer) (releasekit.GiteaDestination, int) {
|
||||
func deliveryDestination(repositoryDir string, settings cli.Settings, stderr io.Writer) (releasekit.DeliveryDestination, int) {
|
||||
delivery := settings.Delivery
|
||||
if delivery == nil || strings.TrimSpace(delivery.BaseURL) == "" || strings.TrimSpace(delivery.Owner) == "" || strings.TrimSpace(delivery.Repository) == "" {
|
||||
if delivery == nil {
|
||||
fmt.Fprintln(stderr, "deliver requires an explicit delivery.forge selection in the profile")
|
||||
return releasekit.DeliveryDestination{}, 2
|
||||
}
|
||||
switch strings.TrimSpace(delivery.Forge) {
|
||||
case "":
|
||||
fmt.Fprintln(stderr, "deliver requires delivery.forge in the profile")
|
||||
return releasekit.DeliveryDestination{}, 2
|
||||
case string(releasekit.ForgeGitea):
|
||||
default:
|
||||
fmt.Fprintf(stderr, "unsupported delivery forge %q\n", delivery.Forge)
|
||||
return releasekit.DeliveryDestination{}, 2
|
||||
}
|
||||
if 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 releasekit.GiteaDestination{}, 2
|
||||
return releasekit.DeliveryDestination{}, 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 releasekit.GiteaDestination{}, 2
|
||||
if (!ok || strings.TrimSpace(token) == "") && delivery.TokenFile != "" {
|
||||
name := delivery.TokenFile
|
||||
if !filepath.IsAbs(name) {
|
||||
name = filepath.Join(repositoryDir, name)
|
||||
}
|
||||
contents, err := os.ReadFile(name)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "read delivery token file: %v\n", err)
|
||||
return releasekit.DeliveryDestination{}, 2
|
||||
}
|
||||
token = strings.TrimSpace(string(contents))
|
||||
if token == "" {
|
||||
fmt.Fprintln(stderr, "delivery token file is empty or blank")
|
||||
return releasekit.DeliveryDestination{}, 2
|
||||
}
|
||||
}
|
||||
return releasekit.GiteaDestination{BaseURL: delivery.BaseURL, Owner: delivery.Owner, Repository: delivery.Repository, Token: token}, 0
|
||||
if strings.TrimSpace(token) == "" {
|
||||
fmt.Fprintf(stderr, "deliver requires a non-empty token in environment variable %s\n", tokenEnv)
|
||||
return releasekit.DeliveryDestination{}, 2
|
||||
}
|
||||
return releasekit.DeliveryDestination{Forge: releasekit.ForgeGitea, BaseURL: delivery.BaseURL, Owner: delivery.Owner, Repository: delivery.Repository, Token: token}, 0
|
||||
}
|
||||
|
||||
func deliverWithDestination(ctx context.Context, invocation cli.Invocation, settings cli.Settings, destination releasekit.GiteaDestination, stdout, stderr io.Writer) int {
|
||||
func deliverWithDestination(ctx context.Context, invocation cli.Invocation, settings cli.Settings, destination releasekit.DeliveryDestination, stdout, stderr io.Writer) int {
|
||||
hasTargets := settings.Build != nil && len(settings.Build.Targets) != 0
|
||||
if invocation.AssetsDir != "" && !hasTargets {
|
||||
fmt.Fprintln(stderr, "--assets requires build targets in the profile")
|
||||
@@ -383,16 +434,7 @@ func deliverWithDestination(ctx context.Context, invocation cli.Invocation, sett
|
||||
Output: stderr,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "delivery state=%s module=%s module-dir=%s version=%s tag=%s commit=%s url=%s: %v\n", result.State, result.ModulePath, result.ModuleDir, result.Version, result.Tag, result.CandidateCommit, result.ReleaseURL, err)
|
||||
writeDeliveryAssets(stderr, result)
|
||||
writeDeliveryRecovery(stderr, result)
|
||||
if errors.Is(err, releasekit.ErrInvalidVersion) {
|
||||
return 2
|
||||
}
|
||||
if result.State == releasekit.DeliveryUnknown {
|
||||
return 3
|
||||
}
|
||||
return 1
|
||||
return reportDeliveryFailure(stderr, result, err)
|
||||
}
|
||||
fmt.Fprintf(stdout, "delivered module %s version %s tag %s at %s (state %s)\n", result.ModulePath, result.Version, result.Tag, result.CandidateCommit, result.State)
|
||||
if result.ReleaseURL != "" {
|
||||
@@ -402,6 +444,46 @@ func deliverWithDestination(ctx context.Context, invocation cli.Invocation, sett
|
||||
return 0
|
||||
}
|
||||
|
||||
func reportDeliveryFailure(output io.Writer, result releasekit.DeliveryResult, err error) int {
|
||||
if result.State == releasekit.DeliveryComplete {
|
||||
fmt.Fprintln(output, "hosted delivery completed; local cleanup failed")
|
||||
}
|
||||
fmt.Fprintf(output, "delivery state=%s module=%s module-dir=%s version=%s tag=%s commit=%s url=%s: %v\n", result.State, result.ModulePath, result.ModuleDir, result.Version, result.Tag, result.CandidateCommit, result.ReleaseURL, err)
|
||||
writeDeliveryAssets(output, result)
|
||||
writeDeliveryRecovery(output, result)
|
||||
if result.State == releasekit.DeliveryComplete {
|
||||
return 1
|
||||
}
|
||||
if errors.Is(err, releasekit.ErrInvalidVersion) {
|
||||
return 2
|
||||
}
|
||||
var preflightErr *releasekit.DeliveryPreflightError
|
||||
if errors.As(err, &preflightErr) {
|
||||
return deliveryPreflightExitStatus(preflightErr)
|
||||
}
|
||||
if result.State == releasekit.DeliveryUnknown {
|
||||
return 3
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func deliveryPreflightExitStatus(err error) int {
|
||||
var preflightErr *releasekit.DeliveryPreflightError
|
||||
if !errors.As(err, &preflightErr) {
|
||||
return 1
|
||||
}
|
||||
switch preflightErr.Kind {
|
||||
case releasekit.DeliveryPreflightInvalidDestination,
|
||||
releasekit.DeliveryPreflightMissingCredential,
|
||||
releasekit.DeliveryPreflightInvalidCredential,
|
||||
releasekit.DeliveryPreflightInsufficientPermissions,
|
||||
releasekit.DeliveryPreflightInaccessibleDestination:
|
||||
return 2
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
func writeDeliveryAssets(output io.Writer, result releasekit.DeliveryResult) {
|
||||
for _, group := range []struct {
|
||||
label string
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -36,6 +37,87 @@ func TestVersionCommand(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCIDeliverRedactsRemoteCredentialsOnPreparationFailure(t *testing.T) {
|
||||
t.Setenv("NO_PROXY", "*")
|
||||
t.Setenv("no_proxy", "*")
|
||||
t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull)
|
||||
t.Setenv("GIT_CONFIG_NOSYSTEM", "1")
|
||||
t.Setenv("GIT_TERMINAL_PROMPT", "0")
|
||||
repository := newCLIRepository(t)
|
||||
commit := cliGitOutput(t, repository, "rev-parse", "HEAD")
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer server.Close()
|
||||
remote := strings.Replace(server.URL, "http://", "http://review-user:review-password@", 1) + "/repo.git"
|
||||
var stdout, stderr bytes.Buffer
|
||||
status := run(t.Context(), []string{"ci-deliver", "--repo", repository, "--remote", remote, "--commit", commit, "v1.2.3"}, &stdout, &stderr)
|
||||
if status != 1 || stdout.Len() != 0 || !strings.Contains(stderr.String(), "prepare tagged checkout") || !strings.Contains(stderr.String(), "repo.git") {
|
||||
t.Fatalf("status = %d, stdout = %q, stderr = %q", status, stdout.String(), stderr.String())
|
||||
}
|
||||
if strings.Contains(stderr.String(), "review-user") || strings.Contains(stderr.String(), "review-password") {
|
||||
t.Fatalf("CLI preparation diagnostic exposes credentials: %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishCommandReportsUnknownForPeeledOnlyRecovery(t *testing.T) {
|
||||
repository := newCLIRepository(t)
|
||||
writeRepositoryFile(t, repository, "docs/releases/v1.2.3.md", "# Example Product v1.2.3\n\nRelease note.\n")
|
||||
profile, err := json.Marshal(cli.Settings{Product: "Example Product", Remote: "origin"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeRepositoryFile(t, repository, "releasekit.json", string(profile))
|
||||
cliGitRun(t, repository, "add", "docs/releases/v1.2.3.md", "releasekit.json")
|
||||
cliGitRun(t, repository, "commit", "-m", "configure release")
|
||||
remote := filepath.Join(t.TempDir(), "origin.git")
|
||||
cliGitRun(t, repository, "init", "--bare", remote)
|
||||
cliGitRun(t, repository, "remote", "add", "origin", remote)
|
||||
cliGitRun(t, repository, "push", "--set-upstream", "origin", "main")
|
||||
realGit, err := exec.LookPath("git")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bin := t.TempDir()
|
||||
marker := filepath.Join(t.TempDir(), "pushed")
|
||||
script := `#!/bin/sh
|
||||
is_push=0
|
||||
is_ls=0
|
||||
for arg in "$@"; do
|
||||
[ "$arg" = push ] && is_push=1
|
||||
[ "$arg" = ls-remote ] && is_ls=1
|
||||
done
|
||||
if [ "$is_ls" = 1 ] && [ -f "$PUSH_MARKER" ]; then
|
||||
case " $* " in *refs/tags/v1.2.3*)
|
||||
object=$("$REAL_GIT" rev-parse 'refs/tags/v1.2.3^{}')
|
||||
printf '%s\trefs/tags/v1.2.3^{}\n' "$object"
|
||||
exit 0;; esac
|
||||
fi
|
||||
if [ "$is_push" = 1 ]; then
|
||||
"$REAL_GIT" "$@" || exit $?
|
||||
: > "$PUSH_MARKER"
|
||||
echo 'lost acknowledgment' >&2
|
||||
exit 77
|
||||
fi
|
||||
exec "$REAL_GIT" "$@"
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(bin, "git"), []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
t.Setenv("REAL_GIT", realGit)
|
||||
t.Setenv("PUSH_MARKER", marker)
|
||||
var stdout, stderr bytes.Buffer
|
||||
status := run(context.Background(), []string{"publish", "--repo", repository, "v1.2.3"}, &stdout, &stderr)
|
||||
if status != 3 || !strings.Contains(stderr.String(), "unknown") || strings.Contains(stdout.String(), "published") {
|
||||
t.Fatalf("status = %d, stdout = %q, stderr = %q", status, stdout.String(), stderr.String())
|
||||
}
|
||||
local := cliGitOutput(t, repository, "rev-parse", "refs/tags/v1.2.3")
|
||||
if got := cliGitOutput(t, remote, "rev-parse", "refs/tags/v1.2.3"); got != local {
|
||||
t.Fatalf("remote tag = %s, want %s", got, local)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownCommand(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
if status := run(context.Background(), []string{"unknown"}, &stdout, &stderr); status != 2 {
|
||||
@@ -345,6 +427,30 @@ func TestCandidateFailurePresentationRetainsCallbackContext(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishCommandRedactsCredentialsInEndpointMismatch(t *testing.T) {
|
||||
repository := newCLIRepository(t)
|
||||
writeRepositoryFile(t, repository, "docs/releases/v1.2.3.md", "# Example Product v1.2.3\n\nRelease note.\n")
|
||||
settings := cli.Settings{Product: "Example Product", Remote: "origin", ExpectedPushURL: "https://expected-token@example.invalid/project.git"}
|
||||
profile, err := json.Marshal(settings)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeRepositoryFile(t, repository, "releasekit.json", string(profile))
|
||||
cliGitRun(t, repository, "add", ".")
|
||||
cliGitRun(t, repository, "commit", "-m", "configure release")
|
||||
cliGitRun(t, repository, "remote", "add", "origin", "https://actual-user:actual-secret@example.invalid/project.git")
|
||||
var stdout, stderr bytes.Buffer
|
||||
status := run(context.Background(), []string{"publish", "--repo", repository, "v1.2.3"}, &stdout, &stderr)
|
||||
if status != 1 || !strings.Contains(stderr.String(), "unexpected publication push URL") || !strings.Contains(stderr.String(), "example.invalid/project.git") {
|
||||
t.Fatalf("status = %d, stdout = %q, stderr = %q", status, stdout.String(), stderr.String())
|
||||
}
|
||||
for _, secret := range []string{"expected-token", "actual-user", "actual-secret"} {
|
||||
if strings.Contains(stderr.String(), secret) || strings.Contains(stdout.String(), secret) {
|
||||
t.Fatalf("CLI output exposes %q: %q %q", secret, stdout.String(), stderr.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishCommandPublishesConfiguredCandidate(t *testing.T) {
|
||||
raceSelected := racePrerequisitesAvailable()
|
||||
repository := newCLIRepository(t)
|
||||
@@ -506,6 +612,65 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCommandReportsOnlyHashedArtifactsAfterReadFailure(t *testing.T) {
|
||||
repository := newCLIBuildRepository(t, "package main\nfunc main() {}\n")
|
||||
secondArch := "arm64"
|
||||
if runtime.GOARCH == secondArch {
|
||||
secondArch = "amd64"
|
||||
}
|
||||
settings := cli.Settings{Product: "Example Product", Build: &cli.BuildSettings{MainPackage: "./cmd/app", BinaryName: "fixture", Targets: []string{runtime.GOOS + "/" + runtime.GOARCH, runtime.GOOS + "/" + secondArch}}}
|
||||
profile, err := json.Marshal(settings)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeRepositoryFile(t, repository, "releasekit.json", string(profile))
|
||||
realGo, err := exec.LookPath("go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bin := t.TempDir()
|
||||
script := `#!/bin/sh
|
||||
if [ "$1" != build ]; then exec "$RELEASEKIT_REAL_GO" "$@"; fi
|
||||
output=
|
||||
previous=
|
||||
for arg in "$@"; do
|
||||
if [ "$previous" = -o ]; then output=$arg; fi
|
||||
previous=$arg
|
||||
done
|
||||
"$RELEASEKIT_REAL_GO" "$@" || exit $?
|
||||
if [ -f "$RELEASEKIT_BUILD_COUNT" ]; then
|
||||
rm "$output"
|
||||
mkdir "$output"
|
||||
else
|
||||
: > "$RELEASEKIT_BUILD_COUNT"
|
||||
fi
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(bin, "go"), []byte(script), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
t.Setenv("RELEASEKIT_REAL_GO", realGo)
|
||||
t.Setenv("RELEASEKIT_BUILD_COUNT", filepath.Join(t.TempDir(), "count"))
|
||||
var stdout, stderr bytes.Buffer
|
||||
status := run(context.Background(), []string{"build", "--repo", repository, "v1.2.3"}, &stdout, &stderr)
|
||||
first := fmt.Sprintf("fixture-v1.2.3-%s-%s", runtime.GOOS, runtime.GOARCH)
|
||||
second := fmt.Sprintf("fixture-v1.2.3-%s-%s", runtime.GOOS, secondArch)
|
||||
if runtime.GOOS == "windows" {
|
||||
first, second = first+".exe", second+".exe"
|
||||
}
|
||||
content, err := os.ReadFile(filepath.Join(repository, "dist", first))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
digest := sha256.Sum256(content)
|
||||
if status != 1 || stdout.Len() != 0 || !strings.Contains(stderr.String(), fmt.Sprintf("built %s %x", first, digest)) || strings.Contains(stderr.String(), "built "+second) || !strings.Contains(stderr.String(), "hash build output") || !strings.Contains(stderr.String(), second) {
|
||||
t.Fatalf("status = %d, stdout = %q, stderr = %q", status, stdout.String(), stderr.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(repository, "dist", cli.BuildReceiptName)); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("partial build left receipt: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCommandLeavesNoReceiptAfterSmokeFailure(t *testing.T) {
|
||||
repository := newCLIBuildRepository(t, `package main
|
||||
import (
|
||||
@@ -639,7 +804,7 @@ func TestDeliverPageOnlyCreatesAndReusesRelease(t *testing.T) {
|
||||
settings := cli.Settings{
|
||||
Product: "Example Product",
|
||||
Validation: &cli.ValidationSettings{Go: cli.GoValidationSettings{CompileTargets: []string{"linux/amd64"}}},
|
||||
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||
Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||
}
|
||||
writeDeliveryProfile(t, repository, settings)
|
||||
state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
|
||||
@@ -673,7 +838,7 @@ func TestDeliverBuildsTaggedArtifactsAndReusesAssets(t *testing.T) {
|
||||
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"},
|
||||
Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||
}
|
||||
writeDeliveryProfile(t, repository, settings)
|
||||
state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
|
||||
@@ -716,7 +881,7 @@ func TestCIDeliverLoadsTaggedProfileAndSkipsUnavailablePageOnlyDependencies(t *t
|
||||
defer server.Close()
|
||||
writeDeliveryProfileAt(t, repository, "profiles/ci-delivery.json", cli.Settings{
|
||||
Product: "Example Product",
|
||||
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||
Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||
})
|
||||
expectedCommit := pushTaggedCLIRepository(t, repository)
|
||||
state.update(func(state *deliveryServerData) { state.commit = expectedCommit })
|
||||
@@ -751,7 +916,7 @@ func TestCIDeliverRejectsWrongCommitBeforeGoOrHostedRequests(t *testing.T) {
|
||||
writeDeliveryProfile(t, repository, 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"},
|
||||
Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||
})
|
||||
expectedCommit := pushTaggedCLIRepository(t, repository)
|
||||
state.update(func(state *deliveryServerData) { state.commit = expectedCommit })
|
||||
@@ -802,7 +967,7 @@ func TestCIDeliverRejectsExpectedModuleMismatchBeforeDependencyPreparation(t *te
|
||||
Product: "Example Product",
|
||||
ModuleDir: "integrations/other",
|
||||
Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}},
|
||||
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||
Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||
})
|
||||
expectedCommit := pushTaggedCLIRepository(t, repository)
|
||||
state.update(func(state *deliveryServerData) { state.commit = expectedCommit })
|
||||
@@ -839,7 +1004,7 @@ func TestCIDeliverPreparesDependenciesAndBuildsConfiguredTargets(t *testing.T) {
|
||||
writeDeliveryProfile(t, repository, cli.Settings{
|
||||
Product: "Example Product",
|
||||
Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH)}},
|
||||
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||
Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||
})
|
||||
expectedCommit := pushTaggedCLIRepository(t, repository)
|
||||
state.update(func(state *deliveryServerData) { state.commit = expectedCommit })
|
||||
@@ -865,7 +1030,7 @@ func TestCIDeliverValidatesCredentialsBeforePreparingDependencies(t *testing.T)
|
||||
writeDeliveryProfile(t, repository, 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"},
|
||||
Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||
})
|
||||
expectedCommit := pushTaggedCLIRepository(t, repository)
|
||||
state.update(func(state *deliveryServerData) { state.commit = expectedCommit })
|
||||
@@ -903,7 +1068,7 @@ func TestDeliverRejectsInvalidTaggedSourceBeforeManagedBuild(t *testing.T) {
|
||||
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"},
|
||||
Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||
}
|
||||
writeDeliveryProfile(t, repository, settings)
|
||||
state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
|
||||
@@ -919,6 +1084,15 @@ func TestDeliverRejectsInvalidTaggedSourceBeforeManagedBuild(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompletedDeliveryCleanupFailureReportsCompletedState(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
result := releasekit.DeliveryResult{State: releasekit.DeliveryComplete, ModulePath: "example.com/app", Tag: "v1.2.3", ReleaseURL: "https://example.invalid/release", Uploaded: []releasekit.DeliveryAsset{{Filename: "app", SHA256: "digest"}}}
|
||||
status := reportDeliveryFailure(&output, result, errors.New("remove temporary directory: failed"))
|
||||
if status != 1 || !strings.Contains(output.String(), "hosted delivery completed; local cleanup failed") || !strings.Contains(output.String(), "uploaded app digest") || strings.Contains(output.String(), "retry") {
|
||||
t.Fatalf("status = %d, output = %q", status, output.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliverFailureReportsRecoveryForPartialConflictAndUnknownStates(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
@@ -958,7 +1132,7 @@ func TestDeliverFailureReportsRecoveryForPartialConflictAndUnknownStates(t *test
|
||||
writeDeliveryProfile(t, repository, cli.Settings{
|
||||
Product: "Example Product",
|
||||
Build: &build,
|
||||
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||
Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||
})
|
||||
state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
|
||||
cliGitRun(t, repository, "tag", "v1.2.3")
|
||||
@@ -1002,7 +1176,7 @@ func TestDeliverRejectsWrongHostedTagBeforeReleaseWrite(t *testing.T) {
|
||||
state := &deliveryServerState{data: deliveryServerData{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"}})
|
||||
writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"}})
|
||||
state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
|
||||
cliGitRun(t, repository, "tag", "v1.2.3")
|
||||
t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token")
|
||||
@@ -1018,7 +1192,7 @@ func TestDeliverRequiresCredentialBeforeRemoteRequests(t *testing.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"}})
|
||||
writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example", TokenEnv: "RELEASEKIT_TEST_DELIVERY_TOKEN"}})
|
||||
state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
|
||||
cliGitRun(t, repository, "tag", "v1.2.3")
|
||||
t.Setenv("RELEASEKIT_TEST_DELIVERY_TOKEN", "")
|
||||
@@ -1029,12 +1203,233 @@ func TestDeliverRequiresCredentialBeforeRemoteRequests(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliverRejectsMissingBlankAndUnsupportedForgeBeforeTokenOrBuild(t *testing.T) {
|
||||
for _, forge := range []string{"", " \t", "unknown"} {
|
||||
t.Run(forge, func(t *testing.T) {
|
||||
repository := newCLIRepository(t)
|
||||
state := &deliveryServerState{}
|
||||
server := newDeliveryServer(t, state)
|
||||
defer server.Close()
|
||||
writeDeliveryProfile(t, repository, cli.Settings{
|
||||
Product: "Example Product",
|
||||
Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}},
|
||||
Delivery: &cli.DeliverySettings{
|
||||
Forge: forge, BaseURL: server.URL, Owner: "owner", Repository: "example",
|
||||
TokenFile: filepath.Join(repository, "missing-token"),
|
||||
},
|
||||
})
|
||||
state.update(func(data *deliveryServerData) { data.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
|
||||
cliGitRun(t, repository, "tag", "v1.2.3")
|
||||
|
||||
shimDir := t.TempDir()
|
||||
markerPath := filepath.Join(shimDir, "go-invoked")
|
||||
if err := os.WriteFile(filepath.Join(shimDir, "go"), []byte("#!/bin/sh\n: > \"$RELEASEKIT_GO_MARKER\"\nexit 99\n"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("RELEASEKIT_GO_MARKER", markerPath)
|
||||
t.Setenv("PATH", shimDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
var stdout, stderr bytes.Buffer
|
||||
status := run(context.Background(), []string{"deliver", "--repo", repository, "v1.2.3"}, &stdout, &stderr)
|
||||
if status != 2 || !strings.Contains(stderr.String(), "forge") || strings.Contains(stderr.String(), "missing-token") {
|
||||
t.Fatalf("deliver status=%d stdout=%q stderr=%q; want selection diagnostic before token access", status, stdout.String(), stderr.String())
|
||||
}
|
||||
if _, err := os.Stat(markerPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("Go was invoked before forge validation: %v", err)
|
||||
}
|
||||
if got := state.snapshot().apiRequests; got != 0 {
|
||||
t.Fatalf("hosted API requests = %d, want 0", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCIDeliverRejectsUnsupportedForgeBeforeTokenOrDependencyPreparation(t *testing.T) {
|
||||
repository := newDeliveryBuildRepository(t)
|
||||
state := &deliveryServerState{}
|
||||
server := newDeliveryServer(t, state)
|
||||
defer server.Close()
|
||||
writeDeliveryProfile(t, repository, cli.Settings{
|
||||
Product: "Example Product",
|
||||
Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}},
|
||||
Delivery: &cli.DeliverySettings{
|
||||
Forge: "unsupported", BaseURL: server.URL, Owner: "owner", Repository: "example",
|
||||
TokenFile: filepath.Join(repository, "missing-token"),
|
||||
},
|
||||
})
|
||||
expectedCommit := pushTaggedCLIRepository(t, repository)
|
||||
state.update(func(data *deliveryServerData) { data.commit = expectedCommit })
|
||||
|
||||
shimDir := t.TempDir()
|
||||
markerPath := filepath.Join(shimDir, "go-invoked")
|
||||
if err := os.WriteFile(filepath.Join(shimDir, "go"), []byte("#!/bin/sh\n: > \"$RELEASEKIT_GO_MARKER\"\nexit 99\n"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("RELEASEKIT_GO_MARKER", markerPath)
|
||||
t.Setenv("PATH", shimDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
var stdout, stderr bytes.Buffer
|
||||
status := run(context.Background(), []string{"ci-deliver", "--repo", repository, "--commit", expectedCommit, "v1.2.3"}, &stdout, &stderr)
|
||||
if status != 2 || !strings.Contains(stderr.String(), "forge") || strings.Contains(stderr.String(), "missing-token") {
|
||||
t.Fatalf("ci-deliver status=%d stdout=%q stderr=%q; want selection diagnostic before token access", status, stdout.String(), stderr.String())
|
||||
}
|
||||
if _, err := os.Stat(markerPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("Go was invoked before forge validation: %v", err)
|
||||
}
|
||||
if got := state.snapshot().apiRequests; got != 0 {
|
||||
t.Fatalf("hosted API requests = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCIDeliverPageOnlyReportsUnavailablePreflightWithStatusOne(t *testing.T) {
|
||||
repository := newCLIRepository(t)
|
||||
var mu sync.Mutex
|
||||
var requests, writes int
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if r.Method != http.MethodGet {
|
||||
writes++
|
||||
http.Error(w, "unexpected write", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
requests++
|
||||
if r.URL.Path == "/api/v1/repos/owner/example" {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": 1, "name": "example", "full_name": "owner/example",
|
||||
"owner": map[string]string{"login": "owner"},
|
||||
})
|
||||
return
|
||||
}
|
||||
http.Error(w, "verification unavailable", http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer server.Close()
|
||||
writeDeliveryProfile(t, repository, cli.Settings{
|
||||
Product: "Example Product",
|
||||
Delivery: &cli.DeliverySettings{
|
||||
Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example", TokenEnv: "RELEASEKIT_TEST_DELIVERY_TOKEN",
|
||||
},
|
||||
})
|
||||
expectedCommit := pushTaggedCLIRepository(t, repository)
|
||||
t.Setenv("RELEASEKIT_TEST_DELIVERY_TOKEN", "fixture-token")
|
||||
var stdout, stderr bytes.Buffer
|
||||
status := run(context.Background(), []string{"ci-deliver", "--repo", repository, "--commit", expectedCommit, "v1.2.3"}, &stdout, &stderr)
|
||||
if status != 1 || !strings.Contains(stderr.String(), "unavailable") {
|
||||
t.Fatalf("page-only ci-deliver status=%d stdout=%q stderr=%q; want unavailable status 1", status, stdout.String(), stderr.String())
|
||||
}
|
||||
if requests != 2 || writes != 0 {
|
||||
t.Fatalf("page-only preflight requests=%d writes=%d, want two reads and no writes", requests, writes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliverUnavailablePreflightStopsBeforeManagedBuild(t *testing.T) {
|
||||
repository := newCLIRepository(t)
|
||||
var mu sync.Mutex
|
||||
var requests, writes int
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if r.Method != http.MethodGet {
|
||||
writes++
|
||||
http.Error(w, "unexpected write", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
requests++
|
||||
if r.URL.Path == "/api/v1/repos/owner/example" {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": 1, "name": "example", "full_name": "owner/example",
|
||||
"owner": map[string]string{"login": "owner"},
|
||||
})
|
||||
return
|
||||
}
|
||||
http.Error(w, "verification unavailable", http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer server.Close()
|
||||
writeDeliveryProfile(t, repository, cli.Settings{
|
||||
Product: "Example Product",
|
||||
Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}},
|
||||
Delivery: &cli.DeliverySettings{
|
||||
Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example", TokenEnv: "RELEASEKIT_TEST_DELIVERY_TOKEN",
|
||||
},
|
||||
})
|
||||
cliGitRun(t, repository, "tag", "v1.2.3")
|
||||
t.Setenv("RELEASEKIT_TEST_DELIVERY_TOKEN", "fixture-token")
|
||||
shimDir := t.TempDir()
|
||||
markerPath := filepath.Join(shimDir, "go-invoked")
|
||||
if err := os.WriteFile(filepath.Join(shimDir, "go"), []byte("#!/bin/sh\n: > \"$RELEASEKIT_GO_MARKER\"\nexit 99\n"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("RELEASEKIT_GO_MARKER", markerPath)
|
||||
t.Setenv("PATH", shimDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
var stdout, stderr bytes.Buffer
|
||||
status := run(context.Background(), []string{"deliver", "--repo", repository, "v1.2.3"}, &stdout, &stderr)
|
||||
if status != 1 || !strings.Contains(stderr.String(), "unavailable") {
|
||||
t.Fatalf("deliver status=%d stdout=%q stderr=%q; want unavailable status 1", status, stdout.String(), stderr.String())
|
||||
}
|
||||
if _, err := os.Stat(markerPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("managed build ran before successful preflight: %v", err)
|
||||
}
|
||||
if requests != 2 || writes != 0 {
|
||||
t.Fatalf("preflight requests=%d writes=%d, want two reads and no writes", requests, writes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCIDeliverUnavailablePreflightStopsBeforeDependencyPreparation(t *testing.T) {
|
||||
repository := newDeliveryBuildRepository(t)
|
||||
var mu sync.Mutex
|
||||
var requests, writes int
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if r.Method != http.MethodGet {
|
||||
writes++
|
||||
http.Error(w, "unexpected write", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
requests++
|
||||
if r.URL.Path == "/api/v1/repos/owner/example" {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": 1, "name": "example", "full_name": "owner/example",
|
||||
"owner": map[string]string{"login": "owner"},
|
||||
})
|
||||
return
|
||||
}
|
||||
http.Error(w, "verification unavailable", http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer server.Close()
|
||||
writeDeliveryProfile(t, repository, cli.Settings{
|
||||
Product: "Example Product",
|
||||
Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}},
|
||||
Delivery: &cli.DeliverySettings{
|
||||
Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example", TokenEnv: "RELEASEKIT_TEST_DELIVERY_TOKEN",
|
||||
},
|
||||
})
|
||||
expectedCommit := pushTaggedCLIRepository(t, repository)
|
||||
t.Setenv("RELEASEKIT_TEST_DELIVERY_TOKEN", "fixture-token")
|
||||
shimDir := t.TempDir()
|
||||
markerPath := filepath.Join(shimDir, "go-invoked")
|
||||
if err := os.WriteFile(filepath.Join(shimDir, "go"), []byte("#!/bin/sh\n: > \"$RELEASEKIT_GO_MARKER\"\nexit 99\n"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("RELEASEKIT_GO_MARKER", markerPath)
|
||||
t.Setenv("PATH", shimDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
var stdout, stderr bytes.Buffer
|
||||
status := run(context.Background(), []string{"ci-deliver", "--repo", repository, "--commit", expectedCommit, "v1.2.3"}, &stdout, &stderr)
|
||||
if status != 1 || !strings.Contains(stderr.String(), "unavailable") {
|
||||
t.Fatalf("ci-deliver status=%d stdout=%q stderr=%q; want unavailable status 1", status, stdout.String(), stderr.String())
|
||||
}
|
||||
if _, err := os.Stat(markerPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("dependency preparation ran before successful preflight: %v", err)
|
||||
}
|
||||
if requests != 2 || writes != 0 {
|
||||
t.Fatalf("preflight requests=%d writes=%d, want two reads and no writes", requests, writes)
|
||||
}
|
||||
}
|
||||
|
||||
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"}})
|
||||
writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"}})
|
||||
state.update(func(state *deliveryServerData) { 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 {
|
||||
@@ -1061,7 +1456,7 @@ func TestDeliverRejectsTamperedAssetsAndStaleReceiptBeforeRemoteWrites(t *testin
|
||||
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"}}
|
||||
settings := cli.Settings{Product: "Example Product", Build: ¤t, Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"}}
|
||||
writeDeliveryProfile(t, repository, settings)
|
||||
state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
|
||||
cliGitRun(t, repository, "tag", "v1.2.3")
|
||||
@@ -1097,7 +1492,7 @@ func TestDeliverRejectsTamperedAssetsAndStaleReceiptBeforeRemoteWrites(t *testin
|
||||
}
|
||||
return
|
||||
}
|
||||
if status != 1 || state.snapshot().apiRequests != 0 || state.snapshot().releaseCreates != 0 || state.snapshot().assetUploads != 0 {
|
||||
if status != 1 || state.snapshot().writes != 0 {
|
||||
t.Fatalf("%s receipt status=%d requests=%d stdout=%q stderr=%q", mode, status, state.snapshot().apiRequests, stdout.String(), stderr.String())
|
||||
}
|
||||
if mode == "tampered" || mode == "missing artifact" {
|
||||
@@ -1174,6 +1569,7 @@ type deliveryServerData struct {
|
||||
product string
|
||||
remoteCommit string
|
||||
apiRequests int
|
||||
writes int
|
||||
pageExists bool
|
||||
releaseCreates int
|
||||
assetUploads int
|
||||
@@ -1196,11 +1592,19 @@ func newDeliveryServer(t *testing.T, state *deliveryServerState) *httptest.Serve
|
||||
defer state.mu.Unlock()
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
data.apiRequests++
|
||||
if r.Method != http.MethodGet {
|
||||
data.writes++
|
||||
}
|
||||
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", "/api/v1/repositories/1":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": 1, "name": "example", "full_name": "owner/example",
|
||||
"owner": map[string]string{"login": "owner"},
|
||||
})
|
||||
case "/api/v1/repos/owner/example/tags/" + data.tag:
|
||||
if data.commit == "" {
|
||||
t.Error("tag source commit was not initialized")
|
||||
@@ -1445,7 +1849,7 @@ func TestDeliverRejectsReceiptBuiltFromDirtySourceAfterCleanup(t *testing.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"}}
|
||||
settings := cli.Settings{Product: "Example Product", Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}}, Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"}}
|
||||
writeDeliveryProfile(t, repository, settings)
|
||||
state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
|
||||
cliGitRun(t, repository, "tag", "v1.2.3")
|
||||
@@ -1485,7 +1889,7 @@ func TestRecoveredPageCreatePreservesPartialDeliveryOnInspectionFailure(t *testi
|
||||
server := newDeliveryServer(t, state)
|
||||
defer server.Close()
|
||||
build := cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}}
|
||||
writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", Build: &build, Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}})
|
||||
writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", Build: &build, Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"}})
|
||||
state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
|
||||
cliGitRun(t, repository, "tag", "v1.2.3")
|
||||
assets, _ := writeDeliveryReceipt(t, repository, build)
|
||||
@@ -1505,7 +1909,7 @@ func TestUnreadableAssetsDoNotClaimAnUnknownWrite(t *testing.T) {
|
||||
server := newDeliveryServer(t, state)
|
||||
defer server.Close()
|
||||
build := cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}}
|
||||
writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", Build: &build, Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}})
|
||||
writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", Build: &build, Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"}})
|
||||
state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
|
||||
cliGitRun(t, repository, "tag", "v1.2.3")
|
||||
assets, _ := writeDeliveryReceipt(t, repository, build)
|
||||
@@ -1561,7 +1965,7 @@ func TestTemporaryDeliveryBuildDoesNotRequireWritableRepositoryParent(t *testing
|
||||
state := &deliveryServerState{}
|
||||
server := newDeliveryServer(t, state)
|
||||
defer server.Close()
|
||||
writeDeliveryProfile(t, repository, 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, cli.Settings{Product: "Example Product", Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}}, Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"}})
|
||||
state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
|
||||
cliGitRun(t, repository, "tag", "v1.2.3")
|
||||
parent := t.TempDir()
|
||||
|
||||
@@ -209,6 +209,11 @@ func newMappedDeliveryFixture(t *testing.T, tag string) *mappedDeliveryFixture {
|
||||
apiRoot := "/api/v1/repos/owner/example/"
|
||||
escapedTag := url.PathEscape(tag)
|
||||
switch r.URL.EscapedPath() {
|
||||
case "/api/v1/repos/owner/example", "/api/v1/repositories/1":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": 1, "name": "example", "full_name": "owner/example",
|
||||
"owner": map[string]string{"login": "owner"},
|
||||
})
|
||||
case apiRoot + "tags/" + escapedTag:
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"name": tag, "commit": map[string]string{"sha": data.commit}})
|
||||
case apiRoot + "releases/tags/" + escapedTag:
|
||||
@@ -366,7 +371,7 @@ func workflowModuleFile(module, version string) string {
|
||||
}
|
||||
|
||||
func workflowDeliverySettings(baseURL string) *cli.DeliverySettings {
|
||||
return &cli.DeliverySettings{BaseURL: baseURL, Owner: "owner", Repository: "example", TokenEnv: "RELEASEKIT_GITEA_TOKEN"}
|
||||
return &cli.DeliverySettings{Forge: "gitea", BaseURL: baseURL, Owner: "owner", Repository: "example", TokenEnv: "RELEASEKIT_GITEA_TOKEN"}
|
||||
}
|
||||
|
||||
func writeMappedProfile(t *testing.T, repository, name string, settings cli.Settings) {
|
||||
|
||||
@@ -79,6 +79,7 @@ func TestReleaseCandidate(t *testing.T) {
|
||||
"product": "ReleaseKit",
|
||||
"checks": []map[string]any{{"args": []string{"sh", "-c", "if [ -n \"$RELEASEKIT_TEST_ARM_GIT_SHIM\" ]; then : > \"$RELEASEKIT_TEST_ARM_GIT_SHIM\"; fi"}}},
|
||||
"delivery": map[string]any{
|
||||
"forge": "gitea",
|
||||
"base_url": "https://forge.maximumdirect.net",
|
||||
"owner": "base-layer",
|
||||
"repository": "releasekit",
|
||||
|
||||
@@ -55,7 +55,7 @@ func TestReleaseKitSourceDeliveryWorkflowFromTaggedCheckout(t *testing.T) {
|
||||
defer server.Close()
|
||||
writeDeliveryProfile(t, repository, cli.Settings{
|
||||
Product: "ReleaseKit",
|
||||
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||
Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||
})
|
||||
state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
|
||||
cliGitRun(t, repository, "tag", "-a", version, "-m", "ReleaseKit v1.2.3")
|
||||
|
||||
111
cmd/releasekit/token_file_test.go
Normal file
111
cmd/releasekit/token_file_test.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli"
|
||||
)
|
||||
|
||||
func TestDeliveryTokenFileSelection(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name, env, contents, file, want string
|
||||
status int
|
||||
}{
|
||||
{name: "relative file", contents: "fixture-token\n", file: "token", want: "fixture-token"},
|
||||
{name: "absolute file", contents: "fixture-token\n", file: "absolute", want: "fixture-token"},
|
||||
{name: "environment overrides missing file", env: "env-token", file: "missing", want: "env-token"},
|
||||
{name: "blank environment falls back", env: " \t", contents: "fixture-token\n", file: "token", want: "fixture-token"},
|
||||
{name: "missing file", file: "missing", status: 2},
|
||||
{name: "blank file", contents: " \n\t", file: "token", status: 2},
|
||||
{name: "empty file", file: "token", status: 2},
|
||||
{name: "unreadable as file", file: ".", status: 2},
|
||||
{name: "no credential", status: 2},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
repository := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(repository, "token"), []byte(test.contents), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
name := test.file
|
||||
if name == "absolute" {
|
||||
name = filepath.Join(repository, "token")
|
||||
}
|
||||
t.Setenv("RELEASEKIT_TEST_TOKEN", test.env)
|
||||
var stderr bytes.Buffer
|
||||
destination, status := deliveryDestination(repository, cli.Settings{Delivery: &cli.DeliverySettings{Forge: "gitea",
|
||||
BaseURL: "https://gitea.example", Owner: "owner", Repository: "example",
|
||||
TokenEnv: "RELEASEKIT_TEST_TOKEN", TokenFile: name,
|
||||
}}, &stderr)
|
||||
if status != test.status || destination.Token != test.want {
|
||||
t.Fatalf("status=%d token matches=%t, want status=%d; diagnostics=%s", status, destination.Token == test.want, test.status, &stderr)
|
||||
}
|
||||
for _, secret := range []string{"fixture-token", "env-token"} {
|
||||
if strings.Contains(stderr.String(), secret) {
|
||||
t.Fatal("diagnostics exposed a token")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckReadsForbiddenFilesSelectionFromProfile(t *testing.T) {
|
||||
repository := newCLIRepository(t)
|
||||
writeDeliveryProfile(t, repository, cli.Settings{
|
||||
Product: "Example Product",
|
||||
Validation: &cli.ValidationSettings{ForbiddenFiles: []string{"token-file"}},
|
||||
Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: "https://gitea.example", Owner: "owner", Repository: "example", TokenFile: "missing-token-file"},
|
||||
})
|
||||
writeRepositoryFile(t, repository, "token-file", "fixture-secret\n")
|
||||
cliGitRun(t, repository, "add", "token-file")
|
||||
cliGitRun(t, repository, "commit", "-m", "capture a selected token path")
|
||||
var stdout, stderr bytes.Buffer
|
||||
status := run(context.Background(), []string{"check", "--repo", repository, "v1.2.3"}, &stdout, &stderr)
|
||||
if status != 1 || !strings.Contains(stderr.String(), "forbidden-files") || !strings.Contains(stderr.String(), "token-file") {
|
||||
t.Fatalf("check status=%d output=%s diagnostics=%s", status, &stdout, &stderr)
|
||||
}
|
||||
if strings.Contains(stdout.String()+stderr.String(), "fixture-secret") {
|
||||
t.Fatal("check exposed the token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliverAndCIDeliverReadTokenFileFromRepositoryRoot(t *testing.T) {
|
||||
for _, command := range []string{"deliver", "ci-deliver"} {
|
||||
t.Run(command, func(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{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example", TokenFile: ".releasekit/token"},
|
||||
})
|
||||
writeRepositoryFile(t, repository, ".gitignore", ".releasekit/\n")
|
||||
cliGitRun(t, repository, "add", ".gitignore")
|
||||
cliGitRun(t, repository, "commit", "-m", "ignore local delivery credentials")
|
||||
commit := pushTaggedCLIRepository(t, repository)
|
||||
state.update(func(data *deliveryServerData) { data.commit = commit })
|
||||
writeRepositoryFile(t, repository, ".releasekit/token", "fixture-token\n")
|
||||
t.Setenv("RELEASEKIT_GITEA_TOKEN", "")
|
||||
var stdout, stderr bytes.Buffer
|
||||
args := []string{command, "--repo", filepath.Join(repository, ".releasekit")}
|
||||
if command == "ci-deliver" {
|
||||
args = append(args, "--commit", commit)
|
||||
}
|
||||
args = append(args, "v1.2.3")
|
||||
if status := run(context.Background(), args, &stdout, &stderr); status != 0 {
|
||||
t.Fatalf("delivery status=%d output=%s diagnostics=%s", status, &stdout, &stderr)
|
||||
}
|
||||
if state.snapshot().releaseCreates != 1 {
|
||||
t.Fatal("token-file delivery did not create the release page")
|
||||
}
|
||||
if strings.Contains(stdout.String()+stderr.String(), "fixture-token") {
|
||||
t.Fatal("delivery exposed the token")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ func TestWoodpeckerTagDeliveryTemplate(t *testing.T) {
|
||||
defer server.Close()
|
||||
writeDeliveryProfile(t, repository, cli.Settings{
|
||||
Product: "Example Product",
|
||||
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||
Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||
})
|
||||
commit := pushTaggedCLIRepository(t, repository)
|
||||
state.update(func(state *deliveryServerData) { state.commit = commit })
|
||||
@@ -70,7 +70,7 @@ func TestWoodpeckerTagDeliveryTemplate(t *testing.T) {
|
||||
BinaryName: "helper",
|
||||
Targets: []string{fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH)},
|
||||
},
|
||||
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||
Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||
})
|
||||
commit := pushTaggedCLIRepository(t, repository)
|
||||
state.update(func(state *deliveryServerData) { state.commit = commit })
|
||||
|
||||
149
delivery.go
149
delivery.go
@@ -2,10 +2,14 @@ package releasekit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
internalbuild "go.maximumdirect.net/releasekit/internal/build"
|
||||
internaldelivery "go.maximumdirect.net/releasekit/internal/delivery"
|
||||
"go.maximumdirect.net/releasekit/internal/forge"
|
||||
"go.maximumdirect.net/releasekit/internal/gitea"
|
||||
)
|
||||
|
||||
@@ -25,25 +29,73 @@ const (
|
||||
DeliveryUnknown DeliveryState = "unknown"
|
||||
)
|
||||
|
||||
// GiteaDestination supplies the hosted repository and explicit access token.
|
||||
type GiteaDestination struct {
|
||||
// Forge selects the hosted delivery backend. A delivery request must select a
|
||||
// supported value explicitly; the destination URL does not select a backend.
|
||||
type Forge string
|
||||
|
||||
const (
|
||||
// ForgeGitea selects Gitea for hosted delivery.
|
||||
ForgeGitea Forge = "gitea"
|
||||
)
|
||||
|
||||
// DeliveryDestination supplies an explicitly selected hosted destination.
|
||||
type DeliveryDestination struct {
|
||||
// Forge selects the adapter that interprets the destination fields.
|
||||
Forge Forge
|
||||
BaseURL string
|
||||
Owner string
|
||||
Repository string
|
||||
Token string
|
||||
}
|
||||
|
||||
// DeliveryPreflightFailureKind classifies a failure while checking destination
|
||||
// settings, credentials, and repository access.
|
||||
type DeliveryPreflightFailureKind string
|
||||
|
||||
const (
|
||||
// DeliveryPreflightInvalidDestination means the destination settings fail local validation.
|
||||
DeliveryPreflightInvalidDestination DeliveryPreflightFailureKind = "invalid_destination"
|
||||
// DeliveryPreflightMissingCredential means no nonblank credential was supplied.
|
||||
DeliveryPreflightMissingCredential DeliveryPreflightFailureKind = "missing_credential"
|
||||
// DeliveryPreflightInvalidCredential means the selected service rejected the credential.
|
||||
DeliveryPreflightInvalidCredential DeliveryPreflightFailureKind = "invalid_credential"
|
||||
// DeliveryPreflightInsufficientPermissions means a required delivery permission is known to be absent.
|
||||
DeliveryPreflightInsufficientPermissions DeliveryPreflightFailureKind = "insufficient_permissions"
|
||||
// DeliveryPreflightInaccessibleDestination means the credential cannot access the selected repository.
|
||||
DeliveryPreflightInaccessibleDestination DeliveryPreflightFailureKind = "inaccessible_destination"
|
||||
// DeliveryPreflightUnavailable means the service response could not verify access.
|
||||
DeliveryPreflightUnavailable DeliveryPreflightFailureKind = "unavailable"
|
||||
)
|
||||
|
||||
// DeliveryPreflightError reports a classified failure to verify a destination.
|
||||
type DeliveryPreflightError struct {
|
||||
Kind DeliveryPreflightFailureKind
|
||||
Err error
|
||||
}
|
||||
|
||||
func (err *DeliveryPreflightError) Error() string {
|
||||
if err.Err == nil {
|
||||
return fmt.Sprintf("delivery preflight %s", err.Kind)
|
||||
}
|
||||
return fmt.Sprintf("delivery preflight %s: %v", err.Kind, err.Err)
|
||||
}
|
||||
|
||||
func (err *DeliveryPreflightError) Unwrap() error { return err.Err }
|
||||
|
||||
// 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
|
||||
// DeliveryRequest identifies a local tagged checkout and its explicit
|
||||
// destination. Build selects a managed build; Prebuilt supplies caller-owned
|
||||
// build output instead. They cannot both be set. Either may be nil for
|
||||
// page-only delivery when both are nil. Build with no targets, or an empty
|
||||
// Prebuilt result, also performs page-only delivery.
|
||||
// build output instead. They cannot both be set. The ordinary page-only request
|
||||
// leaves both nil. A Build with no targets or a matching Prebuilt result with
|
||||
// no targets or artifacts also performs page-only delivery. Any supplied
|
||||
// Prebuilt must match the repository, module directory and path, tag, commit,
|
||||
// and version; a zero BuildResult does not supply that identity. Source and
|
||||
// artifact requirements for binaries apply when binary work is selected.
|
||||
type DeliveryRequest struct {
|
||||
RepositoryDir string // directory within the tagged source Git checkout.
|
||||
// ModuleDir selects a repository-relative module directory; empty selects
|
||||
@@ -54,7 +106,7 @@ type DeliveryRequest struct {
|
||||
// ExpectedTag optionally requires the full CI event tag to match the
|
||||
// selected module's derived tag before building or making hosted requests.
|
||||
ExpectedTag string
|
||||
Destination GiteaDestination
|
||||
Destination DeliveryDestination
|
||||
// Build selects binaries to build in temporary storage outside the checkout.
|
||||
Build *BuildSpec
|
||||
// Prebuilt supplies an existing build whose files remain caller-owned.
|
||||
@@ -64,7 +116,8 @@ type DeliveryRequest struct {
|
||||
}
|
||||
|
||||
// DeliveryResult reports hosted content and the state reached by this
|
||||
// invocation. A partial result may accompany an error. The asset slices are
|
||||
// invocation. A result may accompany an error, including Complete when hosted
|
||||
// delivery succeeded but local managed-build cleanup failed. The asset slices are
|
||||
// current observations: an asset appears in at most one slice, and early
|
||||
// failures may leave some declared assets unreported.
|
||||
type DeliveryResult struct {
|
||||
@@ -88,19 +141,22 @@ type DeliveryResult struct {
|
||||
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. Build
|
||||
// requests are compiled into temporary storage that Deliver removes on return.
|
||||
// Prebuilt artifacts remain caller-owned and are not removed. Both paths
|
||||
// validate source identity and artifact contents before hosted writes.
|
||||
// Deliver requires an explicit supported destination forge. It checks the local
|
||||
// request and source, then performs read-only credential preflight before a
|
||||
// managed build or full artifact verification. It verifies the selected local
|
||||
// tag and hosted source, then creates or finds its release page and adds any
|
||||
// missing declared build assets. Prebuilt artifacts remain caller-owned. Local
|
||||
// artifact contents are verified after preflight and before hosted writes.
|
||||
// Module selection and declaration links resolve from the captured commit.
|
||||
// Page-only delivery can use a sparse checkout with available committed objects;
|
||||
// managed builds require their selected source files in the working tree.
|
||||
// Preflight failures return a [DeliveryPreflightError] and leave State at
|
||||
// [DeliveryNotAttempted].
|
||||
func Deliver(ctx context.Context, request DeliveryRequest) (DeliveryResult, error) {
|
||||
result := DeliveryResult{Version: request.Version, State: DeliveryNotAttempted}
|
||||
destination := gitea.Destination{
|
||||
BaseURL: request.Destination.BaseURL, Owner: request.Destination.Owner,
|
||||
Repository: request.Destination.Repository, Token: request.Destination.Token,
|
||||
backend, err := deliveryBackend(request.Destination)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
var prebuilt *internalbuild.Result
|
||||
if request.Prebuilt != nil {
|
||||
@@ -112,7 +168,7 @@ func Deliver(ctx context.Context, request DeliveryRequest) (DeliveryResult, erro
|
||||
Product: request.Product,
|
||||
Version: request.Version,
|
||||
ExpectedTag: request.ExpectedTag,
|
||||
Destination: destination,
|
||||
Backend: backend,
|
||||
Build: internalBuildSpec(request.Build),
|
||||
Prebuilt: prebuilt,
|
||||
Output: request.Output,
|
||||
@@ -129,10 +185,65 @@ func Deliver(ctx context.Context, request DeliveryRequest) (DeliveryResult, erro
|
||||
result.Pending = deliveryAssets(internalResult.Pending)
|
||||
result.Conflicting = deliveryAssets(internalResult.Conflicting)
|
||||
result.Uncertain = deliveryAssets(internalResult.Uncertain)
|
||||
return result, err
|
||||
return result, publicPreflightError(err)
|
||||
}
|
||||
|
||||
func deliveryAssets(assets []gitea.AssetIdentity) []DeliveryAsset {
|
||||
// PreflightDelivery checks destination credentials and repository access using
|
||||
// read-only backend operations. It does not require a checkout or published tag.
|
||||
// The result verifies only the access and permission evidence exposed by the
|
||||
// selected backend; it does not guarantee that a later write will succeed.
|
||||
// Failures are returned as [DeliveryPreflightError] values when classified by
|
||||
// the backend, and retain their underlying cause for errors.Is and errors.As.
|
||||
func PreflightDelivery(ctx context.Context, destination DeliveryDestination) error {
|
||||
backend, err := deliveryBackend(destination)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return publicPreflightError(backend.Preflight(ctx))
|
||||
}
|
||||
|
||||
func deliveryBackend(destination DeliveryDestination) (forge.Backend, error) {
|
||||
selection := Forge(strings.TrimSpace(string(destination.Forge)))
|
||||
switch selection {
|
||||
case ForgeGitea:
|
||||
return gitea.NewBackend(gitea.Destination{
|
||||
BaseURL: destination.BaseURL, Owner: destination.Owner,
|
||||
Repository: destination.Repository, Token: destination.Token,
|
||||
}, nil), nil
|
||||
case "":
|
||||
return nil, errors.New("delivery forge must be selected explicitly")
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported delivery forge %q", destination.Forge)
|
||||
}
|
||||
}
|
||||
|
||||
func publicPreflightError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var failure *forge.PreflightFailure
|
||||
if !errors.As(err, &failure) {
|
||||
return err
|
||||
}
|
||||
kind := DeliveryPreflightUnavailable
|
||||
switch failure.Kind {
|
||||
case forge.PreflightInvalidDestination:
|
||||
kind = DeliveryPreflightInvalidDestination
|
||||
case forge.PreflightMissingCredential:
|
||||
kind = DeliveryPreflightMissingCredential
|
||||
case forge.PreflightInvalidCredential:
|
||||
kind = DeliveryPreflightInvalidCredential
|
||||
case forge.PreflightInsufficientPermissions:
|
||||
kind = DeliveryPreflightInsufficientPermissions
|
||||
case forge.PreflightInaccessibleDestination:
|
||||
kind = DeliveryPreflightInaccessibleDestination
|
||||
case forge.PreflightUnavailable:
|
||||
kind = DeliveryPreflightUnavailable
|
||||
}
|
||||
return &DeliveryPreflightError{Kind: kind, Err: failure.Err}
|
||||
}
|
||||
|
||||
func deliveryAssets(assets []internaldelivery.AssetIdentity) []DeliveryAsset {
|
||||
result := make([]DeliveryAsset, 0, len(assets))
|
||||
for _, asset := range assets {
|
||||
result = append(result, DeliveryAsset{Filename: asset.Filename, SHA256: asset.SHA256})
|
||||
|
||||
63
delivery_causes_test.go
Normal file
63
delivery_causes_test.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package releasekit_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
releasekit "go.maximumdirect.net/releasekit"
|
||||
)
|
||||
|
||||
func TestDeliveryPreservesAssetInspectionCauses(t *testing.T) {
|
||||
repository := newTaggedDeliveryConsumer(t)
|
||||
prebuilt, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
|
||||
RepositoryDir: repository, Version: "v1.2.3", OutputDir: t.TempDir(),
|
||||
Spec: releasekit.BuildSpec{MainPackage: "./cmd/app", BinaryName: "fixture", Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
filename := prebuilt.Artifacts[0].Filename
|
||||
for _, cause := range []error{context.Canceled, io.ErrUnexpectedEOF} {
|
||||
t.Run(cause.Error(), func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
state := &facadeDeliveryServer{data: facadeDeliveryData{
|
||||
t: t, commit: prebuilt.CandidateCommit, tag: prebuilt.Tag, version: prebuilt.Version,
|
||||
noteBody: "# Fixture v1.2.3\n\nDelivery fixture.\n",
|
||||
pages: map[string]bool{prebuilt.Tag: true}, assets: map[string][]byte{filename: []byte("data")},
|
||||
}}
|
||||
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/files/") {
|
||||
if cause == context.Canceled {
|
||||
cancel()
|
||||
<-r.Context().Done()
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Length", "100")
|
||||
_, _ = w.Write([]byte("truncated"))
|
||||
return
|
||||
}
|
||||
state.serveHTTP(w, r)
|
||||
}))
|
||||
state.data.url = "http://" + server.Listener.Addr().String()
|
||||
server.Start()
|
||||
defer server.Close()
|
||||
result, err := releasekit.Deliver(ctx, releasekit.DeliveryRequest{
|
||||
RepositoryDir: repository, Product: "Fixture", Version: "v1.2.3",
|
||||
Destination: facadeDeliveryDestination(server.URL), Prebuilt: &prebuilt,
|
||||
})
|
||||
if !errors.Is(err, cause) || !strings.Contains(err.Error(), filename) {
|
||||
t.Fatalf("Deliver() error = %v, want cause %v and asset context", err, cause)
|
||||
}
|
||||
if result.State != releasekit.DeliveryNotAttempted || len(result.Uncertain) != 1 || result.Uncertain[0].Filename != filename || state.snapshot().writes != 0 {
|
||||
t.Fatalf("result = %#v, want read-only uncertain asset without write", result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
53
delivery_destination_test.go
Normal file
53
delivery_destination_test.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package releasekit_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
releasekit "go.maximumdirect.net/releasekit"
|
||||
)
|
||||
|
||||
func TestInvalidDestinationFailsStandaloneAndAutomaticPreflight(t *testing.T) {
|
||||
repository := newTaggedDeliveryConsumer(t)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Errorf("invalid destination made a hosted request: %s", r.URL.Path)
|
||||
http.Error(w, "unexpected request", http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
for _, test := range []struct {
|
||||
name, setting string
|
||||
change func(*releasekit.DeliveryDestination)
|
||||
}{
|
||||
{"URL", "base URL", func(d *releasekit.DeliveryDestination) { d.BaseURL = "ssh://forge.example" }},
|
||||
{"owner", "owner", func(d *releasekit.DeliveryDestination) { d.Owner = "owner/child" }},
|
||||
{"repository", "repository", func(d *releasekit.DeliveryDestination) { d.Repository = "repo/child" }},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
destination := facadeDeliveryDestination(server.URL)
|
||||
test.change(&destination)
|
||||
assertFailure := func(err error) {
|
||||
t.Helper()
|
||||
var preflight *releasekit.DeliveryPreflightError
|
||||
if !errors.As(err, &preflight) || preflight.Kind != releasekit.DeliveryPreflightInvalidDestination || preflight.Err == nil {
|
||||
t.Fatalf("error = %#v, want invalid destination with cause", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), test.setting) {
|
||||
t.Fatalf("error = %v, want invalid setting %q", err, test.setting)
|
||||
}
|
||||
}
|
||||
assertFailure(releasekit.PreflightDelivery(context.Background(), destination))
|
||||
result, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{
|
||||
RepositoryDir: repository, Product: "Fixture", Version: "v1.2.3", Destination: destination,
|
||||
Build: &releasekit.BuildSpec{MainPackage: "./missing", BinaryName: "fixture", Targets: []releasekit.Target{{OS: "linux", Arch: "amd64"}}},
|
||||
})
|
||||
assertFailure(err)
|
||||
if result.State != releasekit.DeliveryNotAttempted {
|
||||
t.Fatalf("delivery state = %s, want not attempted", result.State)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
package releasekit_test
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
releasekit "go.maximumdirect.net/releasekit"
|
||||
)
|
||||
|
||||
func TestDeliverReadsLocalBinaryOnceForVerificationAndOnceForUpload(t *testing.T) {
|
||||
repository, build, expected := newDeliveryBenchmarkFixture(t)
|
||||
watcher, err := syscall.InotifyInit1(syscall.IN_NONBLOCK | syscall.IN_CLOEXEC)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer syscall.Close(watcher)
|
||||
// Close events separate successive opens so inotify cannot coalesce them.
|
||||
if _, err := syscall.InotifyAddWatch(watcher, build.Artifacts[0].Path, syscall.IN_OPEN|syscall.IN_CLOSE_NOWRITE); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state := &deliveryBenchmarkServer{commit: build.CandidateCommit, assets: expected}
|
||||
state.reset(false)
|
||||
server := httptest.NewUnstartedServer(http.HandlerFunc(state.serveHTTP))
|
||||
state.baseURL = "http://" + server.Listener.Addr().String()
|
||||
server.Start()
|
||||
defer server.Close()
|
||||
result, err := releasekit.Deliver(t.Context(), releasekit.DeliveryRequest{
|
||||
RepositoryDir: repository, Product: "Sample App", Version: build.Version,
|
||||
Destination: facadeDeliveryDestination(server.URL), Prebuilt: &build,
|
||||
})
|
||||
if err != nil || result.State != releasekit.DeliveryComplete || len(result.Uploaded) != len(expected) {
|
||||
t.Fatalf("delivery = %#v, error = %v", result, err)
|
||||
}
|
||||
state.verifyUploads(t)
|
||||
opens := 0
|
||||
var events [256]byte
|
||||
for {
|
||||
n, err := syscall.Read(watcher, events[:])
|
||||
if errors.Is(err, syscall.EAGAIN) {
|
||||
break
|
||||
}
|
||||
if errors.Is(err, syscall.EINTR) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for offset := 0; offset < n; {
|
||||
mask := binary.NativeEndian.Uint32(events[offset+4:])
|
||||
if mask&syscall.IN_Q_OVERFLOW != 0 {
|
||||
t.Fatal("artifact event queue overflowed")
|
||||
}
|
||||
if mask&syscall.IN_OPEN != 0 {
|
||||
opens++
|
||||
}
|
||||
nameLength := binary.NativeEndian.Uint32(events[offset+12:])
|
||||
offset += syscall.SizeofInotifyEvent + int(nameLength)
|
||||
}
|
||||
}
|
||||
if opens != 2 {
|
||||
t.Fatalf("local binary opened %d times; want one verification read and one upload read", opens)
|
||||
}
|
||||
}
|
||||
307
delivery_test.go
307
delivery_test.go
@@ -19,6 +19,175 @@ import (
|
||||
releasekit "go.maximumdirect.net/releasekit"
|
||||
)
|
||||
|
||||
func TestDeliveryForgeSelectionIsExplicit(t *testing.T) {
|
||||
var requests int
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests++
|
||||
http.Error(w, "unexpected request", http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
for _, forge := range []releasekit.Forge{"", " \t", "unknown"} {
|
||||
t.Run(string(forge), func(t *testing.T) {
|
||||
destination := releasekit.DeliveryDestination{
|
||||
Forge: forge, BaseURL: server.URL, Owner: "owner", Repository: "example", Token: "fixture-token",
|
||||
}
|
||||
if err := releasekit.PreflightDelivery(context.Background(), destination); err == nil || !strings.Contains(err.Error(), "forge") {
|
||||
t.Fatalf("PreflightDelivery() error = %v, want explicit forge selection error", err)
|
||||
}
|
||||
result, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{
|
||||
Version: "v1.2.3", Destination: destination,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "forge") || result.State != releasekit.DeliveryNotAttempted || result.Version != "v1.2.3" {
|
||||
t.Fatalf("Deliver() = %#v, %v; want selection error before delivery", result, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
if requests != 0 {
|
||||
t.Fatalf("hosted requests = %d, want 0 for invalid forge selection", requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightDeliveryChecksAuthenticatedRepositoryWithoutCheckout(t *testing.T) {
|
||||
var requests, writes int
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writes++
|
||||
t.Errorf("preflight method = %q, want GET", r.Method)
|
||||
http.Error(w, "unexpected write", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if got, want := r.Header.Get("Authorization"), "token fixture-token"; got != want {
|
||||
t.Errorf("authorization = %q, want %q", got, want)
|
||||
}
|
||||
requests++
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/repos/owner/example":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"id": 31, "name": "example", "full_name": "owner/example", "owner": map[string]string{"login": "owner"}})
|
||||
case "/api/v1/repositories/31":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"id": 31, "name": "example", "full_name": "owner/example", "owner": map[string]string{"login": "owner"}})
|
||||
default:
|
||||
t.Errorf("preflight path = %q", r.URL.Path)
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
if err := releasekit.PreflightDelivery(context.Background(), facadeDeliveryDestination(server.URL)); err != nil {
|
||||
t.Fatalf("PreflightDelivery() error = %v", err)
|
||||
}
|
||||
if requests != 2 || writes != 0 {
|
||||
t.Fatalf("preflight requests=%d writes=%d, want two reads and no writes", requests, writes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightDeliveryPreservesPublicFailureKindsAndCauses(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
token string
|
||||
status int
|
||||
wantKind releasekit.DeliveryPreflightFailureKind
|
||||
wantReads int
|
||||
canceled bool
|
||||
}{
|
||||
{name: "missing credential", wantKind: releasekit.DeliveryPreflightMissingCredential},
|
||||
{name: "invalid credential", token: "fixture-token", status: http.StatusUnauthorized, wantKind: releasekit.DeliveryPreflightInvalidCredential, wantReads: 1},
|
||||
{name: "inaccessible destination", token: "fixture-token", status: http.StatusForbidden, wantKind: releasekit.DeliveryPreflightInaccessibleDestination, wantReads: 1},
|
||||
{name: "unavailable service", token: "fixture-token", status: http.StatusServiceUnavailable, wantKind: releasekit.DeliveryPreflightUnavailable, wantReads: 1},
|
||||
{name: "canceled request", token: "fixture-token", wantKind: releasekit.DeliveryPreflightUnavailable, canceled: true},
|
||||
}
|
||||
for _, test := range cases {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
reads := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
reads++
|
||||
if test.status != 0 {
|
||||
http.Error(w, "controlled failure", test.status)
|
||||
return
|
||||
}
|
||||
t.Errorf("unexpected request to %q", r.URL.Path)
|
||||
http.Error(w, "unexpected request", http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
destination := facadeDeliveryDestination(server.URL)
|
||||
destination.Token = test.token
|
||||
ctx := context.Background()
|
||||
if test.canceled {
|
||||
canceled, cancel := context.WithCancel(ctx)
|
||||
cancel()
|
||||
ctx = canceled
|
||||
}
|
||||
err := releasekit.PreflightDelivery(ctx, destination)
|
||||
var preflight *releasekit.DeliveryPreflightError
|
||||
if !errors.As(err, &preflight) || preflight.Kind != test.wantKind || preflight.Err == nil {
|
||||
t.Fatalf("PreflightDelivery() error = %#v, want kind %q with cause", err, test.wantKind)
|
||||
}
|
||||
if test.canceled && !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("PreflightDelivery() error = %v, want cancellation identity", err)
|
||||
}
|
||||
if reads != test.wantReads {
|
||||
t.Fatalf("preflight reads = %d, want %d", reads, test.wantReads)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomaticPreflightFailurePrecedesPrebuiltArtifactHashing(t *testing.T) {
|
||||
repository := newTaggedDeliveryConsumer(t)
|
||||
prebuilt, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
|
||||
RepositoryDir: repository,
|
||||
Version: "v1.2.3",
|
||||
Spec: releasekit.BuildSpec{
|
||||
MainPackage: "./cmd/app",
|
||||
BinaryName: "fixture",
|
||||
Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}},
|
||||
},
|
||||
OutputDir: t.TempDir(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Build() error = %v", err)
|
||||
}
|
||||
contents, err := os.ReadFile(prebuilt.Artifacts[0].Path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
contents[0] ^= 0xff
|
||||
if err := os.WriteFile(prebuilt.Artifacts[0].Path, contents, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var reads, writes int
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writes++
|
||||
http.Error(w, "unexpected write", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
reads++
|
||||
if r.URL.Path == "/api/v1/repos/owner/example" {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"id": 31, "name": "example", "full_name": "owner/example", "owner": map[string]string{"login": "owner"}})
|
||||
return
|
||||
}
|
||||
http.Error(w, "verification unavailable", http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
result, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{
|
||||
RepositoryDir: repository,
|
||||
Product: "Fixture",
|
||||
Version: "v1.2.3",
|
||||
Destination: facadeDeliveryDestination(server.URL),
|
||||
Prebuilt: &prebuilt,
|
||||
})
|
||||
var preflight *releasekit.DeliveryPreflightError
|
||||
if !errors.As(err, &preflight) || preflight.Kind != releasekit.DeliveryPreflightUnavailable || result.State != releasekit.DeliveryNotAttempted {
|
||||
t.Fatalf("Deliver() = %#v, %v; want unavailable preflight before tamper verification", result, err)
|
||||
}
|
||||
if reads != 2 || writes != 0 {
|
||||
t.Fatalf("preflight reads=%d writes=%d, want two reads and no writes", reads, writes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliverManagedBuildUploadsArtifactsAndCleansTemporaryOutput(t *testing.T) {
|
||||
repository := newTaggedDeliveryConsumer(t)
|
||||
temporaryRoot := t.TempDir()
|
||||
@@ -138,6 +307,34 @@ func TestDeliverPrebuiltArtifactsRemainCallerOwned(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliverRejectsOversizedExistingPrebuiltAsset(t *testing.T) {
|
||||
repository := newTaggedDeliveryConsumer(t)
|
||||
prebuilt, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
|
||||
RepositoryDir: repository, Version: "v1.2.3", OutputDir: t.TempDir(),
|
||||
Spec: releasekit.BuildSpec{MainPackage: "./cmd/app", BinaryName: "fixture", Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content, err := os.ReadFile(prebuilt.Artifacts[0].Path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server, remote := newFacadeDeliveryServer(t, repository)
|
||||
defer server.Close()
|
||||
remote.update(func(data *facadeDeliveryData) {
|
||||
data.pages["v1.2.3"] = true
|
||||
data.assets[prebuilt.Artifacts[0].Filename] = append(content, 'x')
|
||||
})
|
||||
result, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{
|
||||
RepositoryDir: repository, Product: "Fixture", Version: "v1.2.3",
|
||||
Destination: facadeDeliveryDestination(server.URL), Prebuilt: &prebuilt,
|
||||
})
|
||||
if err == nil || result.State != releasekit.DeliveryConflict || len(result.Conflicting) != 1 || result.Conflicting[0].Filename != prebuilt.Artifacts[0].Filename || remote.snapshot().writes != 0 {
|
||||
t.Fatalf("delivery = %#v, error = %v, writes = %d", result, err, remote.snapshot().writes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliverRejectsSameSizePrebuiltTamperingBeforeHostedWrites(t *testing.T) {
|
||||
repository := newTaggedDeliveryConsumer(t)
|
||||
prebuilt, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
|
||||
@@ -177,8 +374,8 @@ func TestDeliverRejectsSameSizePrebuiltTamperingBeforeHostedWrites(t *testing.T)
|
||||
if err == nil || !strings.Contains(err.Error(), "verify build assets") || result.State != releasekit.DeliveryNotAttempted {
|
||||
t.Fatalf("Deliver() = %#v, %v; want local asset verification failure", result, err)
|
||||
}
|
||||
if remote.snapshot().requests != 0 {
|
||||
t.Fatalf("hosted requests = %d, want none before local verification", remote.snapshot().requests)
|
||||
if remote.snapshot().writes != 0 {
|
||||
t.Fatalf("hosted writes = %d, want none before local verification", remote.snapshot().writes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,6 +430,29 @@ func TestDeliverPageOnlySkipsBuildAndHostedAssets(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliverUsesOriginalNoteWithBlobReplacement(t *testing.T) {
|
||||
repository := newTaggedDeliveryConsumer(t)
|
||||
original := buildGit(t, repository, "rev-parse", "HEAD:docs/releases/v1.2.3.md")
|
||||
replacementPath := filepath.Join(t.TempDir(), "note.md")
|
||||
if err := os.WriteFile(replacementPath, []byte("# Fixture v1.2.3\n\nReplacement note.\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
replacement := buildGit(t, repository, "hash-object", "-w", replacementPath)
|
||||
buildGit(t, repository, "replace", original, replacement)
|
||||
server, remote := newFacadeDeliveryServer(t, repository)
|
||||
defer server.Close()
|
||||
result, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{
|
||||
RepositoryDir: repository, Product: "Fixture", Version: "v1.2.3",
|
||||
Destination: facadeDeliveryDestination(server.URL),
|
||||
})
|
||||
if err != nil || result.State != releasekit.DeliveryComplete {
|
||||
t.Fatalf("delivery = %#v, error = %v", result, err)
|
||||
}
|
||||
if got := remote.snapshot().createdBody; got != "# Fixture v1.2.3\n\nDelivery fixture.\n" {
|
||||
t.Fatalf("delivered note = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliverUsesCommittedModuleIdentityForSeparatePages(t *testing.T) {
|
||||
repository := newTwoModuleDeliveryConsumer(t)
|
||||
server, remote := newFacadeDeliveryServer(t, repository)
|
||||
@@ -463,8 +683,8 @@ func TestDeliverManagedBuildRejectsCheckoutMutationBeforeHostedWrites(t *testing
|
||||
if err == nil || !strings.Contains(err.Error(), "build source") || result.State != releasekit.DeliveryNotAttempted {
|
||||
t.Fatalf("Deliver() = %#v, %v; want build-source eligibility failure", result, err)
|
||||
}
|
||||
if remote.snapshot().requests != 0 {
|
||||
t.Fatalf("hosted requests = %d, want none", remote.snapshot().requests)
|
||||
if remote.snapshot().writes != 0 {
|
||||
t.Fatalf("hosted writes = %d, want none after preflight", remote.snapshot().writes)
|
||||
}
|
||||
if entries, err := os.ReadDir(temporaryRoot); err != nil || len(entries) != 0 {
|
||||
t.Fatalf("ineligible managed build output remains: entries=%v error=%v", entries, err)
|
||||
@@ -492,8 +712,8 @@ func TestDeliverManagedBuildFailureCleansTemporaryOutputBeforeHostedWrites(t *te
|
||||
if err == nil || result.State != releasekit.DeliveryNotAttempted || result.CandidateCommit != remote.snapshot().commit {
|
||||
t.Fatalf("Deliver() = %#v, %v; want managed build failure before hosted writes", result, err)
|
||||
}
|
||||
if remote.snapshot().requests != 0 {
|
||||
t.Fatalf("hosted requests = %d, want none", remote.snapshot().requests)
|
||||
if remote.snapshot().writes != 0 {
|
||||
t.Fatalf("hosted writes = %d, want none after preflight", remote.snapshot().writes)
|
||||
}
|
||||
if entries, err := os.ReadDir(temporaryRoot); err != nil || len(entries) != 0 {
|
||||
t.Fatalf("failed managed build output remains: entries=%v error=%v", entries, err)
|
||||
@@ -525,8 +745,8 @@ func TestDeliverManagedBuildCancellationCleansTemporaryOutput(t *testing.T) {
|
||||
if !errors.Is(err, context.Canceled) || result.State != releasekit.DeliveryNotAttempted {
|
||||
t.Fatalf("Deliver() = %#v, %v; want cancellation before hosted writes", result, err)
|
||||
}
|
||||
if remote.snapshot().requests != 0 {
|
||||
t.Fatalf("hosted requests = %d, want none", remote.snapshot().requests)
|
||||
if remote.snapshot().writes != 0 {
|
||||
t.Fatalf("hosted writes = %d, want none after preflight", remote.snapshot().writes)
|
||||
}
|
||||
if entries, err := os.ReadDir(temporaryRoot); err != nil || len(entries) != 0 {
|
||||
t.Fatalf("canceled managed build output remains: entries=%v error=%v", entries, err)
|
||||
@@ -575,8 +795,8 @@ func main() { if len(os.Args) == 2 && os.Args[1] == "version" { fmt.Println("fix
|
||||
return repository
|
||||
}
|
||||
|
||||
func facadeDeliveryDestination(baseURL string) releasekit.GiteaDestination {
|
||||
return releasekit.GiteaDestination{BaseURL: baseURL, Owner: "owner", Repository: "example", Token: "fixture-token"}
|
||||
func facadeDeliveryDestination(baseURL string) releasekit.DeliveryDestination {
|
||||
return releasekit.DeliveryDestination{Forge: releasekit.ForgeGitea, BaseURL: baseURL, Owner: "owner", Repository: "example", Token: "fixture-token"}
|
||||
}
|
||||
|
||||
type deliveryCancelWriter struct{ cancel context.CancelFunc }
|
||||
@@ -586,6 +806,33 @@ func (writer deliveryCancelWriter) Write(content []byte) (int, error) {
|
||||
return len(content), nil
|
||||
}
|
||||
|
||||
func TestDeliverExistingPageWithoutReleaseID(t *testing.T) {
|
||||
repository := newTaggedDeliveryConsumer(t)
|
||||
prebuilt, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
|
||||
RepositoryDir: repository, Version: "v1.2.3", OutputDir: t.TempDir(),
|
||||
Spec: releasekit.BuildSpec{MainPackage: "./cmd/app", BinaryName: "fixture", Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server, remote := newFacadeDeliveryServer(t, repository)
|
||||
defer server.Close()
|
||||
remote.update(func(data *facadeDeliveryData) { data.pages[data.tag] = true; data.omitReleaseID = true })
|
||||
request := releasekit.DeliveryRequest{RepositoryDir: repository, Product: "Fixture", Version: "v1.2.3", Destination: facadeDeliveryDestination(server.URL)}
|
||||
page, err := releasekit.Deliver(context.Background(), request)
|
||||
if err != nil || page.State != releasekit.DeliveryComplete {
|
||||
t.Fatalf("page result = %#v, error = %v", page, err)
|
||||
}
|
||||
request.Prebuilt = &prebuilt
|
||||
result, err := releasekit.Deliver(context.Background(), request)
|
||||
if err == nil || result.State != releasekit.DeliveryNotAttempted || !strings.Contains(err.Error(), "release ID") {
|
||||
t.Fatalf("asset result = %#v, error = %v", result, err)
|
||||
}
|
||||
if snapshot := remote.snapshot(); snapshot.writes != 0 {
|
||||
t.Fatalf("unexpected hosted writes: %#v", snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
type facadeDeliveryServer struct {
|
||||
mu sync.Mutex
|
||||
data facadeDeliveryData
|
||||
@@ -616,15 +863,18 @@ func (state *facadeDeliveryServer) snapshot() facadeDeliveryData {
|
||||
}
|
||||
|
||||
type facadeDeliveryData struct {
|
||||
t *testing.T
|
||||
commit string
|
||||
tag string
|
||||
version string
|
||||
noteBody string
|
||||
pages map[string]bool
|
||||
requests int
|
||||
assets map[string][]byte
|
||||
url string
|
||||
t *testing.T
|
||||
commit string
|
||||
tag string
|
||||
version string
|
||||
noteBody string
|
||||
createdBody string
|
||||
pages map[string]bool
|
||||
requests int
|
||||
writes int
|
||||
omitReleaseID bool
|
||||
assets map[string][]byte
|
||||
url string
|
||||
}
|
||||
|
||||
func newFacadeDeliveryServer(t *testing.T, repository string) (*httptest.Server, *facadeDeliveryServer) {
|
||||
@@ -642,11 +892,19 @@ func (state *facadeDeliveryServer) serveHTTP(w http.ResponseWriter, r *http.Requ
|
||||
data := &state.data
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
data.requests++
|
||||
if r.Method != http.MethodGet {
|
||||
data.writes++
|
||||
}
|
||||
if r.Header.Get("Authorization") != "token fixture-token" {
|
||||
data.t.Errorf("authorization = %q", r.Header.Get("Authorization"))
|
||||
}
|
||||
}
|
||||
switch r.URL.EscapedPath() {
|
||||
case "/api/v1/repos/owner/example", "/api/v1/repositories/1":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": 1, "name": "example", "full_name": "owner/example",
|
||||
"owner": map[string]string{"login": "owner"},
|
||||
})
|
||||
case "/api/v1/repos/owner/example/tags/" + url.PathEscape(data.tag):
|
||||
_, _ = fmt.Fprintf(w, `{"name":%q,"commit":{"sha":%q}}`, data.tag, data.commit)
|
||||
case "/api/v1/repos/owner/example/releases/tags/" + url.PathEscape(data.tag):
|
||||
@@ -670,6 +928,7 @@ func (state *facadeDeliveryServer) serveHTTP(w http.ResponseWriter, r *http.Requ
|
||||
if payload.Tag != data.tag || payload.Target != data.commit || payload.Name != "Fixture "+data.version {
|
||||
data.t.Errorf("release payload = %#v", payload)
|
||||
}
|
||||
data.createdBody = payload.Body
|
||||
data.pages[data.tag] = true
|
||||
state.writeRelease(w)
|
||||
case "/api/v1/repos/owner/example/releases/10/assets":
|
||||
@@ -716,11 +975,15 @@ func (state *facadeDeliveryServer) writeRelease(w http.ResponseWriter) {
|
||||
// The request handler holds state.mu.
|
||||
data := &state.data
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": 10, "html_url": data.url + "/releases/10", "tag_name": data.tag,
|
||||
release := map[string]any{
|
||||
"html_url": data.url + "/releases/10", "tag_name": data.tag,
|
||||
"target_commitish": data.commit, "name": "Fixture " + data.version,
|
||||
"body": data.noteBody, "draft": false, "prerelease": false,
|
||||
})
|
||||
}
|
||||
if !data.omitReleaseID {
|
||||
release["id"] = 10
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(release)
|
||||
}
|
||||
|
||||
func mapKeys(values map[string][]byte) []string {
|
||||
|
||||
92
docs/adr/0006-neutral-forge-delivery-and-preflight.md
Normal file
92
docs/adr/0006-neutral-forge-delivery-and-preflight.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# ADR 0006: Separate delivery policy from forge protocols
|
||||
|
||||
## Status
|
||||
|
||||
Accepted.
|
||||
|
||||
## Date
|
||||
|
||||
2026-11-01
|
||||
|
||||
## Context
|
||||
|
||||
ReleaseKit's delivery workflow handles release-page matching and creation
|
||||
recovery, remote asset comparison, upload ordering, retries, and partial or
|
||||
uncertain outcomes. Those policies are useful independently of the Gitea HTTP
|
||||
protocol, but the earlier client-owned flow coupled them to Gitea types and
|
||||
operations.
|
||||
|
||||
Delivery also benefits from checking credentials and repository access before
|
||||
managed builds, dependency preparation, or complete artifact hashing. Gitea
|
||||
exposes authenticated repository reads, but its `permissions.push` metadata and
|
||||
basic token evidence do not establish permission to write release pages or
|
||||
assets. A read-only check must represent that evidence limit without claiming
|
||||
that later writes are guaranteed.
|
||||
|
||||
The root `releasekit` facade owns public types, while implementation packages
|
||||
remain internal. The CLI owns credential loading and process exit status.
|
||||
Source publication has separate Git authentication and completion semantics.
|
||||
|
||||
## Decision
|
||||
|
||||
The root facade owns `Forge`, `DeliveryDestination`, explicit backend selection,
|
||||
and public preflight errors. `internal/forge` defines the internal backend
|
||||
contract and neutral release, asset, failure, and write-evidence values.
|
||||
`internal/delivery` owns shared delivery coordination and policy.
|
||||
`internal/gitea` owns Gitea destination rules, authenticated preflight requests,
|
||||
protocol operations, codecs, handles, streaming, and conversion to neutral
|
||||
failures. `internal/artifact` owns local asset declarations and verification.
|
||||
Shared delivery, forge, and artifact packages have no production dependency
|
||||
path to the Gitea adapter.
|
||||
|
||||
Delivery selection is explicit. The supported value is `gitea`; neither a URL
|
||||
nor an omitted selection chooses a backend. There is no compatibility alias for
|
||||
the removed public Gitea destination type.
|
||||
|
||||
The facade exposes `PreflightDelivery` and runs the same read-only backend
|
||||
preflight automatically during `Deliver`. Gitea preflight authenticates through
|
||||
repository-scoped reads and classifies missing or rejected credentials,
|
||||
inaccessible destinations, and unavailable verification. It does not issue a
|
||||
test release or upload, require extra user/admin token scopes, or treat
|
||||
unexposed write permission evidence as a failure. Success confirms only the
|
||||
authentication, repository access, and permission evidence available through
|
||||
that check; ordinary writes remain authoritative. Preflight failures preserve
|
||||
their causes and leave delivery `not_attempted`.
|
||||
|
||||
The CLI requires `delivery.forge` only for `deliver` and `ci-deliver`. Binary
|
||||
CI delivery performs a standalone preflight after checkout/profile/token
|
||||
preparation and before application dependency downloads, then `Deliver`
|
||||
rechecks. Page-only CI relies on automatic delivery preflight. `check`,
|
||||
`build`, and source `publish` do not require delivery configuration or hosted
|
||||
credentials. Local artifact verification follows preflight but completes before
|
||||
hosted writes.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- Keep delivery policy in the Gitea client. This retains protocol coupling and
|
||||
makes shared recovery behavior harder to exercise without the adapter.
|
||||
- Select Gitea from the URL or preserve the old Gitea-specific facade type.
|
||||
This hides backend selection and keeps the public contract tied to one
|
||||
adapter.
|
||||
- Probe permissions with a test release or asset, or require additional user
|
||||
scopes. Those operations can create remote state or demand evidence unrelated
|
||||
to release delivery; the repository read does not expose the required
|
||||
release-write proof.
|
||||
- Skip preflight and let the first write discover credential problems. That
|
||||
delays a known failure until after costly local work and provides no
|
||||
standalone check for callers preparing their own delivery.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Shared policy can be tested with controlled backends, while local HTTP tests
|
||||
focus on Gitea protocol behavior and assembled integration flows.
|
||||
- Consumers must replace the removed Gitea-specific destination type, set
|
||||
`ForgeGitea` in API calls, and add `"forge": "gitea"` to delivery profiles.
|
||||
- Delivery may make additional authenticated read requests before local
|
||||
artifact hashing. It still verifies full local content before any hosted
|
||||
write.
|
||||
- Credential, access, and availability failures have distinct public error
|
||||
kinds and CLI classifications. Successful preflight does not certify future
|
||||
release or asset writes.
|
||||
- Source publication retains native Git authentication and is not gated by
|
||||
hosted delivery preflight.
|
||||
54
docs/cli.md
54
docs/cli.md
@@ -91,7 +91,8 @@ and pushes only its exact full tag object. It prints the confirmed semantic
|
||||
version, full tag, candidate commit, and tag object
|
||||
on success. Validation
|
||||
progress and failed publication state, including the captured publication
|
||||
endpoint for recovery, are written to standard error. A signal
|
||||
endpoint for recovery, are written to standard error. HTTP(S) endpoint
|
||||
userinfo is masked in diagnostics. A signal
|
||||
cancels the active operation; if it arrives during a push, the library's
|
||||
bounded reconciliation determines whether the exact tag was accepted.
|
||||
Shared validation failures use the same check and finding presentation as
|
||||
@@ -168,14 +169,17 @@ 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:
|
||||
Deliver a tagged source release:
|
||||
|
||||
```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
|
||||
`deliver` requires a `delivery` profile object with an explicit
|
||||
`"forge": "gitea"` selection and a token loaded from its configured environment
|
||||
variable or token file. The destination URL does not select a forge. Selection
|
||||
errors are reported before token access or delivery work. Credential selection
|
||||
and errors are defined in the [configuration reference](config.md). 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
|
||||
@@ -194,16 +198,28 @@ Profiles without targets use page-only delivery
|
||||
and need no receipt, binary build, or manifest. `deliver` never runs candidate
|
||||
checks or publishes a tag.
|
||||
|
||||
After cheap local checks, delivery performs authenticated, read-only repository
|
||||
preflight before a managed build or full artifact size and digest verification.
|
||||
An unavailable preflight leaves delivery `not_attempted`; after preflight
|
||||
succeeds, local artifact verification still finishes before any hosted write.
|
||||
|
||||
Use one `deliver` invocation for the normal workflow. To build without delivery
|
||||
credentials, or to retain build output across retries, run `build --output DIR`
|
||||
first and then run `deliver --assets DIR` with the token available. The ordinary
|
||||
`deliver` command does not download application dependencies or change the
|
||||
checkout.
|
||||
|
||||
The command returns 0 for complete delivery, 1 for a known failure, conflict,
|
||||
or partial delivery, 2 for usage, profile, or missing-credential errors, and 3
|
||||
when a remote write remains uncertain. The result lists matching, uploaded,
|
||||
pending, conflicting, and uncertain assets as disjoint current observations;
|
||||
The command returns 0 for complete delivery without a local error, 1 for an
|
||||
unavailable preflight, a known delivery failure, conflict, partial delivery, or
|
||||
local cleanup failure after hosted completion, and 2 for usage,
|
||||
profile, forge-selection, local credential-loading, or classified preflight
|
||||
configuration/access errors. A remote write whose outcome remains uncertain
|
||||
returns 3, including when cancellation prevents reconciliation. Cancellation
|
||||
without an unresolved write returns 1, including during preflight or
|
||||
preparation. Later authorization failures follow
|
||||
the delivery outcome: known failure returns 1; an unresolved write returns 3.
|
||||
The result lists matching, uploaded, pending, conflicting, and uncertain assets
|
||||
as disjoint current observations;
|
||||
early failures may leave some declared assets unreported. On failure, the CLI
|
||||
prints state-based recovery guidance. Fix local or preflight failures before
|
||||
retrying; fix the reported cause before retrying partial delivery; resolve an
|
||||
@@ -228,12 +244,16 @@ the selected Git remote (default `origin`), requires its peeled commit to match
|
||||
the required full `--commit` object ID, checks out that commit detached, then
|
||||
verifies the tag, HEAD, and clean worktree. With `--prepared`, it performs those
|
||||
checks read-only against the existing checkout and does not fetch or check out.
|
||||
Preparation diagnostics mask userinfo in HTTP(S) remote URLs.
|
||||
In either mode, it loads the profile from the verified source. `--expected-module`
|
||||
optionally checks that the profile selects the module chosen by a launcher;
|
||||
module selection itself remains profile-owned. The command validates delivery
|
||||
settings and the configured token, then downloads application dependencies only
|
||||
when the profile selects build targets. Binary profiles are built and delivered
|
||||
through the same facade path as `deliver`; source-only profiles skip dependency
|
||||
module selection itself remains profile-owned. The command compares the selected
|
||||
module's derived release tag with the full event tag before forge access or
|
||||
dependency preparation, then validates explicit forge selection and resolves
|
||||
the configured token. For binary profiles, it performs a read-only repository
|
||||
preflight before downloading application
|
||||
dependencies; delivery performs a fresh check after dependency preparation.
|
||||
Source-only profiles rely on delivery's automatic preflight and skip dependency
|
||||
downloads and binary builds.
|
||||
`--config` follows the existing profile path rules: relative paths are resolved
|
||||
from `--repo`, and absolute paths are used as-is. The default mode prepares and
|
||||
@@ -247,10 +267,12 @@ tag arguments. The event ID may identify an annotated tag object; see the
|
||||
[delivery guide](consumers/delivery.md#woodpecker-tag-workflows) for resolution
|
||||
and workflow preparation. The CLI does not read
|
||||
provider-specific environment variable names. Checkout and dependency
|
||||
preparation failures return status 1; usage, profile, credential, and invalid
|
||||
version errors return status 2. Final delivery uses the same statuses as
|
||||
`deliver`: 0 for complete, 1 for known failure, conflict, or partial delivery,
|
||||
and 3 for uncertain remote state.
|
||||
preparation failures, unavailable preflight, and cancellation during those
|
||||
steps return status 1. Usage, profile, forge-selection, local credential-loading,
|
||||
classified preflight configuration/access, and invalid-version errors return
|
||||
status 2. Final delivery uses the same statuses as `deliver`: 0 for complete,
|
||||
1 for known failure, conflict, or partial delivery, and 3 for uncertain remote
|
||||
state, including when cancellation prevents reconciliation.
|
||||
|
||||
The maintained module-based tag workflow verifies and checks out the event tag
|
||||
before resolving the consumer module. It builds the ReleaseKit version already
|
||||
|
||||
@@ -16,7 +16,7 @@ The current profile fields are:
|
||||
| `checks` | No | Ordered additional child commands, each with an argument vector and optional environment map. |
|
||||
| `validation` | No | Optional shared checks selected by ReleaseKit. |
|
||||
| `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. |
|
||||
| `delivery` | No | Optional selected forge and destination fields for tagged-source delivery. |
|
||||
|
||||
`module_dir` names the selected module's physical location in the repository;
|
||||
ReleaseKit reads its declared import path from that module's own `go.mod`. The
|
||||
@@ -141,12 +141,52 @@ symlink size is the committed link text. Gitlinks have no measured size. Only
|
||||
the captured candidate is checked, one offending file is reported at a time,
|
||||
and historical or aggregate repository size is not measured.
|
||||
|
||||
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.
|
||||
`validation.forbidden_files` is an optional list of literal repository-relative
|
||||
file paths that must be absent from the captured commit. It runs after the Go
|
||||
checks and before other file checks, using the `forbidden-files` identity and
|
||||
reporting selected committed paths. Omission, `null`, or `[]` disables it.
|
||||
Paths use slash separators and ordinary relative normalization and must stay
|
||||
within the repository. They are checked against the whole captured repository,
|
||||
independently of the selected module and `shared_files`. The check reads path
|
||||
metadata only; it does not read token contents, follow symlink targets, require
|
||||
local files to exist, or inspect Git history. Existing clean-index/worktree
|
||||
requirements still apply to candidate operations. An ignore rule alone does
|
||||
not remove a previously committed file from the candidate.
|
||||
|
||||
The `delivery` object contains `forge`, `base_url`, `owner`, `repository`, and
|
||||
optional `token_env` and `token_file`. A delivery request must set `forge` to
|
||||
`"gitea"`; omission, an empty or whitespace-only value, and unsupported values
|
||||
are errors when `deliver` or `ci-deliver` is requested. The URL does not select
|
||||
the forge. These semantic checks run only for delivery, so `check`, `publish`,
|
||||
and `build` do not require a delivery object, selection, or credentials.
|
||||
|
||||
`token_env` names the environment variable containing the Gitea token and
|
||||
defaults to `RELEASEKIT_GITEA_TOKEN`. A nonblank environment value takes
|
||||
precedence. When it is unset or blank, `token_file` selects a plain
|
||||
token-containing file; relative paths resolve from the Git repository root,
|
||||
and absolute paths are used as supplied. The CLI trims leading and trailing
|
||||
whitespace from file contents. An unreadable or blank file, or the absence of
|
||||
both credential sources, returns status 2. Omission of `token_file` retains
|
||||
environment-only loading.
|
||||
|
||||
After resolving the token, delivery performs an authenticated, read-only
|
||||
repository access check. Invalid destination settings, missing or rejected
|
||||
credentials, known insufficient permissions, and inaccessible destinations are
|
||||
configuration/credential failures. An unavailable service or canceled check is
|
||||
an operational failure.
|
||||
See the [CLI contract](cli.md) for exact exit statuses. Successful preflight
|
||||
confirms authentication and repository access and accounts for any exposed
|
||||
delivery-permission deficiency. It does not guarantee later release or asset
|
||||
writes. Local artifact size and digest checks follow preflight and still finish
|
||||
before hosted writes.
|
||||
|
||||
Credential loading runs only for `deliver` and `ci-deliver`. `check`, `publish`,
|
||||
and `build` do not read the token file or require a token. The CLI passes the
|
||||
resolved value explicitly to the library operation. `ci-deliver` checks the
|
||||
destination before downloading application dependencies for a binary build;
|
||||
the later delivery performs a fresh check. Page-only CI relies on delivery's
|
||||
automatic check. Selecting a token file and selecting the optional
|
||||
forbidden-file validator are independent settings.
|
||||
|
||||
`expected_push_url`, when nonempty, is compared exactly with Git's resolved
|
||||
effective push URL during `publish`, before candidate validation. It applies
|
||||
|
||||
@@ -53,7 +53,9 @@ module. The default output directory is `<module>/dist`; a relative `OutputDir`
|
||||
is resolved from the module, while an absolute path is used as supplied.
|
||||
Selected artifact filenames are regenerated. Other files in the output
|
||||
directory are left in place. A failed later build or native version check
|
||||
returns the binaries already generated in `BuildResult.Artifacts`.
|
||||
returns completed, hashed binaries in `BuildResult.Artifacts`. If hashing a
|
||||
newly built file fails, the error identifies its path and earlier completed
|
||||
artifacts remain in the result.
|
||||
|
||||
Builds use the available compatible Go toolchain selected locally; ReleaseKit
|
||||
does not download a different toolchain automatically. It disables workspace
|
||||
|
||||
@@ -98,6 +98,13 @@ The `Candidate` supplies both `RepositoryDir` and `ModuleDir`, plus the declared
|
||||
module-local product checks. ReleaseKit calls `AdditionalChecks` once after
|
||||
shared validation when supplied.
|
||||
|
||||
For local token files, set `Validation.ForbiddenFiles` to the repository-relative
|
||||
paths that should stay out of the committed candidate, such as
|
||||
`[]string{".releasekit/gitea-token"}`. Failures use `forbidden-files` and carry the
|
||||
selected committed paths in `ValidationError.Findings`. This selection is
|
||||
independent of module and shared-file inputs; see [GoDoc](../../check.go) for
|
||||
the API contract and the [configuration reference](../config.md) for CLI use.
|
||||
|
||||
`ValidationSpec.SharedFiles` adds committed, repository-relative files to
|
||||
ReleaseKit's optional file checks without enabling any checks. A nil slice
|
||||
selects the optional defaults `COPYRIGHT`, `LICENSE`, and
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Deliver a tagged source release
|
||||
|
||||
Use `releasekit.Deliver` or the `deliver` command after a source tag exists.
|
||||
Select the delivery backend explicitly in the profile or
|
||||
`DeliveryDestination`; the destination URL does not infer a backend.
|
||||
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
|
||||
@@ -39,9 +41,15 @@ For binary profiles, call `PrepareBuildDependencies` with a
|
||||
`DependencyPreparationRequest` selecting the repository and module after
|
||||
checkout preparation and before delivery. It runs `go mod download` in that
|
||||
module with workspace discovery disabled and the available compatible local Go
|
||||
toolchain.
|
||||
The operation may use the network and updates the Go module cache while
|
||||
preserving the caller's proxy, private-module, credential, and cache settings.
|
||||
toolchain. Inside a Git checkout, `RepositoryDir` can name a subdirectory;
|
||||
`ModuleDir` is interpreted relative to the containing checkout root, and an
|
||||
empty selection chooses that root. Previously, a supplied checkout subdirectory
|
||||
acted as the selection base. Outside a checkout, the supplied directory remains
|
||||
the base for `ModuleDir`.
|
||||
Preparation overrides an alternate module file selected by `GOFLAGS`, while
|
||||
retaining other effective Go settings. The operation may use the network and
|
||||
updates the Go module cache while preserving the caller's proxy, private-module,
|
||||
credential, and cache settings.
|
||||
If checksums are missing, Go may update `go.sum`. For binary CI delivery, prepare
|
||||
and commit the selected module's `go.sum` before tagging: delivery verifies the
|
||||
tagged source remains clean after dependency preparation. See the
|
||||
@@ -62,6 +70,7 @@ Add a Gitea destination to the profile:
|
||||
{
|
||||
"product": "Example",
|
||||
"delivery": {
|
||||
"forge": "gitea",
|
||||
"base_url": "https://gitea.example",
|
||||
"owner": "example",
|
||||
"repository": "example",
|
||||
@@ -77,11 +86,37 @@ 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
|
||||
The CLI resolves the token for delivery using the configured environment
|
||||
variable or token file. 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.
|
||||
|
||||
For local delivery, save a Gitea personal access token in a git-ignored file such
|
||||
as `.releasekit/gitea-token` and configure:
|
||||
|
||||
```json
|
||||
{
|
||||
"product": "Example",
|
||||
"validation": {
|
||||
"forbidden_files": [".releasekit/gitea-token"]
|
||||
},
|
||||
"delivery": {
|
||||
"forge": "gitea",
|
||||
"base_url": "https://gitea.example",
|
||||
"owner": "example",
|
||||
"repository": "example",
|
||||
"token_file": ".releasekit/gitea-token"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Add `.releasekit/` to the consumer's ignore rules and use owner-only file
|
||||
permissions for the saved token. Then run `releasekit deliver v1.2.3` from the
|
||||
tagged checkout. CI can continue supplying the environment token. See the
|
||||
[configuration reference](../config.md) for precedence, path resolution, and the
|
||||
optional candidate check. The validator checks the selected committed paths;
|
||||
it does not scan for secrets in other files or past commits.
|
||||
For nested modules, the note comes from that module's committed
|
||||
`docs/releases/<version>.md`, and the hosted page is keyed by its complete tag.
|
||||
|
||||
@@ -104,8 +139,9 @@ directory must contain the private `.releasekit-build.json` receipt created by
|
||||
`build`. ReleaseKit checks its schema, repository, commit, version, resolved
|
||||
module directory and path, full tag, normalized build settings, and artifact
|
||||
declarations. Old receipts must be regenerated with the updated `build`
|
||||
command. Delivery verifies the artifact bytes and sizes before making a hosted
|
||||
request; caller-owned files remain available for retries.
|
||||
command. Delivery verifies the artifact bytes and sizes after read-only
|
||||
preflight and before any hosted write; caller-owned files remain available for
|
||||
retries.
|
||||
Ordinary `deliver` does not download application dependencies or change the
|
||||
checkout.
|
||||
|
||||
@@ -118,6 +154,10 @@ an upload was confirmed during this invocation; `Pending` means known missing
|
||||
work; `Conflicting` means different hosted content was found; `Uncertain` means
|
||||
the remote state could not be resolved.
|
||||
|
||||
An error can accompany `complete` when hosted delivery succeeded but removal
|
||||
of managed build files failed. Address the reported local cleanup failure;
|
||||
the completed hosted delivery does not need to be repeated.
|
||||
|
||||
Fix local or preflight failures before retrying. For partial delivery, fix the
|
||||
reported cause and retry the same version. Resolve an unknown remote outcome
|
||||
before retrying. Investigate conflicts and use a new version if the source or
|
||||
@@ -125,24 +165,74 @@ published content needs correction. If rebuilding could change bytes already
|
||||
on the server, retain the original build output and retry with `--assets DIR`.
|
||||
The command does not run candidate checks or publish tags.
|
||||
|
||||
## Credential preflight
|
||||
|
||||
Both `Deliver` and the CLI perform a read-only destination preflight after
|
||||
cheap local request and source checks. It happens before managed builds and
|
||||
full artifact size and digest verification. If it succeeds, delivery verifies
|
||||
the local artifact contents before making any hosted write. A failed preflight
|
||||
leaves delivery `not_attempted`, including when the service is unavailable.
|
||||
|
||||
Call `PreflightDelivery` directly when another caller-controlled operation
|
||||
should follow credential verification. The check needs no checkout or published
|
||||
tag and performs no test release or upload. For Gitea it verifies authenticated
|
||||
repository access; exposed metadata can identify known deficiencies, but
|
||||
successful preflight does not guarantee later release or asset writes. A
|
||||
`DeliveryPreflightError` identifies invalid destination settings, missing or
|
||||
rejected credentials, known insufficient permissions, an inaccessible
|
||||
destination, or unavailable verification. Its `Unwrap` method preserves the
|
||||
cause for `errors.Is` and
|
||||
`errors.As`. A 403 means access was denied, not necessarily that a token
|
||||
expired; a 404 can mean a missing repository or hidden access.
|
||||
|
||||
```go
|
||||
err := releasekit.PreflightDelivery(ctx, releasekit.DeliveryDestination{
|
||||
Forge: releasekit.ForgeGitea,
|
||||
BaseURL: "https://gitea.example",
|
||||
Owner: "example",
|
||||
Repository: "example",
|
||||
Token: token,
|
||||
})
|
||||
if err != nil {
|
||||
var preflight *releasekit.DeliveryPreflightError
|
||||
if errors.As(err, &preflight) {
|
||||
log.Printf("delivery preflight %s: %v", preflight.Kind, preflight.Err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
The CLI performs the same check automatically. For binary `ci-deliver`, it
|
||||
checks access before application dependency downloads, then `Deliver` checks
|
||||
again after preparation. Page-only CI needs no separate preflight call.
|
||||
`check`, `build`, and source `publish` remain independent of hosted delivery
|
||||
credentials. Calling preflight before source publication is optional and does
|
||||
not add a publication gate.
|
||||
|
||||
## Go API
|
||||
|
||||
Pass the Gitea credential explicitly. Set `Build` to a `BuildSpec` to compile
|
||||
selected targets as part of delivery. The facade verifies the local tag, clean
|
||||
checkout, and committed release note before compiling. Managed output is stored
|
||||
outside the checkout and removed when delivery returns. `Output` receives build
|
||||
progress. With no build, or with an empty target set, delivery is page-only.
|
||||
Pass the Gitea credential explicitly and select `ForgeGitea`. Set `Build` to a
|
||||
`BuildSpec` to compile selected targets as part of delivery. The facade verifies
|
||||
the local tag, clean checkout, and committed release note before compiling.
|
||||
Managed output is stored outside the checkout and removed when delivery
|
||||
returns. `Output` receives build progress. With no build, or with an empty
|
||||
target set, delivery is page-only.
|
||||
|
||||
To deliver binaries built separately, set `Prebuilt` to the `BuildResult`.
|
||||
ReleaseKit checks its repository, module directory, declared module path, full
|
||||
tag, semantic version, tagged commit, normalized settings, and artifact evidence
|
||||
against the selected committed module. Binary results must record verified
|
||||
source. The current files are checked against their recorded digests before any
|
||||
hosted request. The files remain caller-owned, so callers can retain the
|
||||
original bytes for retries and are responsible for cleanup. With neither
|
||||
artifact source, an empty managed target set, or a matching empty prebuilt
|
||||
result, delivery is page-only. Do not set both `Build` and `Prebuilt` in one
|
||||
request.
|
||||
source. After cheap local and source checks, delivery performs read-only
|
||||
credential preflight, verifies the local artifacts in full, and then makes
|
||||
hosted writes. The current files are checked against their recorded digests
|
||||
before those writes. Preflight confirms only the access evidence available from
|
||||
the service, as described above. The files remain caller-owned, so callers can
|
||||
retain the original bytes for retries and are responsible for cleanup. With
|
||||
neither artifact source, an empty managed target set, or a matching empty
|
||||
prebuilt result, delivery is page-only. A prebuilt result with no targets or artifacts
|
||||
must still identify the matching repository, module directory and path, tag,
|
||||
commit, and version; a zero `BuildResult` does not. Do not set both `Build` and
|
||||
`Prebuilt` in one request.
|
||||
|
||||
```go
|
||||
result, err := releasekit.Deliver(ctx, releasekit.DeliveryRequest{
|
||||
@@ -151,7 +241,8 @@ result, err := releasekit.Deliver(ctx, releasekit.DeliveryRequest{
|
||||
Product: "Example",
|
||||
Version: "v1.2.3",
|
||||
ExpectedTag: "integrations/example/v1.2.3", // optional CI event check
|
||||
Destination: releasekit.GiteaDestination{
|
||||
Destination: releasekit.DeliveryDestination{
|
||||
Forge: releasekit.ForgeGitea,
|
||||
BaseURL: "https://gitea.example",
|
||||
Owner: "example",
|
||||
Repository: "example",
|
||||
|
||||
@@ -114,9 +114,12 @@ candidate and remote guards, creates an annotated tag for the captured commit,
|
||||
and pushes only that tag object.
|
||||
|
||||
A successful result means Git confirmed the push or an exact authoritative tag
|
||||
query confirmed it after a push error. The local tag is retained after a failed
|
||||
push. `PublicationState` distinguishes a known failure, conflict, confirmed
|
||||
publication, and unresolved outcome. Resolve an unknown result against the
|
||||
captured endpoint before retrying; see the [maintainer procedure](../release.md)
|
||||
for the recovery steps. The [CLI contract](../cli.md) documents command output
|
||||
and exit statuses.
|
||||
query confirmed it after a push error. A failed inspection after local tag
|
||||
creation can leave the tag ref even when its object ID is unavailable; when
|
||||
the ID was established it remains in `TagObject`. The local tag is retained
|
||||
after a failed push. `PublicationState` distinguishes a known failure, conflict,
|
||||
confirmed publication, and unresolved outcome. Resolve an unknown result
|
||||
against the captured endpoint before retrying; see the [maintainer procedure](../release.md)
|
||||
for the recovery steps. The result's endpoint is the exact URL and can contain
|
||||
credentials; handle it accordingly. The [CLI contract](../cli.md) documents
|
||||
command output and exit statuses.
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
# Gitea release pages
|
||||
|
||||
ReleaseKit's public `Deliver` operation and `deliver` command verify tagged
|
||||
source, find or create the selected module's 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
|
||||
ReleaseKit's public `Deliver` operation and `deliver` command select Gitea
|
||||
explicitly, verify tagged source, find or create the selected module's release
|
||||
page, inspect existing assets, and upload missing assets. Shared delivery code
|
||||
owns content comparison and recovery; the Gitea adapter owns the HTTP protocol.
|
||||
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.
|
||||
|
||||
@@ -13,6 +14,23 @@ the `Authorization: token …` request header and is not included in errors or
|
||||
results. The base URL must use HTTP or HTTPS and cannot contain URL credentials,
|
||||
a query, or a fragment.
|
||||
|
||||
Before a build or full local artifact verification, Gitea preflight makes two
|
||||
authenticated, read-only repository requests: `GET /api/v1/repos/{owner}/{repo}`
|
||||
resolves the repository, then `GET /api/v1/repositories/{id}` confirms access
|
||||
through the authenticated repository route. The response identity must match
|
||||
the configured owner and repository. This check also works without a checkout
|
||||
or a published tag and makes no release or asset writes.
|
||||
|
||||
A 401 response identifies a rejected credential. A 403 identifies denied
|
||||
access but does not establish that the credential is invalid. A 404 can mean a
|
||||
missing repository or access hidden by the service. Network failures, service
|
||||
errors, and malformed responses that prevent verification are reported as
|
||||
unavailable. Repository `permissions.push` describes Git code-push access; it
|
||||
does not establish permission to create release pages or upload assets. Gitea
|
||||
does not expose enough evidence through this probe to verify those write scopes,
|
||||
so they remain unverified and later writes remain authoritative. Preflight does
|
||||
not request unrelated user scopes or create a test release or asset.
|
||||
|
||||
The selected module derives a complete tag from its semantic version, declared
|
||||
module path, and physical repository-relative directory. For example, version
|
||||
`v1.2.3` in `integrations/ckconfig` uses
|
||||
@@ -27,34 +45,40 @@ The default shared validation convention uses repository-root `COPYRIGHT`,
|
||||
layout and release-note links. Use the complete derived tag as the immutable
|
||||
source ref and the actual repository-relative file paths in those links.
|
||||
Shared-file selection controls validation inputs; delivery preserves the
|
||||
committed note and its footer links. The client first reads
|
||||
committed note and its footer links. After successful preflight, the Gitea
|
||||
adapter reads
|
||||
`/api/v1/repos/{owner}/{repo}/tags/{tag}` and requires the hosted tag's commit
|
||||
SHA to equal the captured source commit. It then reads
|
||||
`/api/v1/repos/{owner}/{repo}/releases/tags/{tag}`. A matching existing page
|
||||
must have that tag, the complete committed release note as its body, and stable
|
||||
is accepted by shared delivery policy only when it has that tag, the complete
|
||||
committed release note as its body, and stable
|
||||
non-draft state. Its existing title is retained. ReleaseKit does not edit a
|
||||
conflicting page. Slash-containing tags are escaped as one URL path segment.
|
||||
Page-only delivery does not require a release ID in this lookup response. Asset
|
||||
operations require a positive release ID; a missing or nonpositive ID is
|
||||
reported as a malformed service observation before an asset request is made.
|
||||
|
||||
If the page is absent, the client posts to `/api/v1/repos/{owner}/{repo}/releases`
|
||||
with the selected full tag and commit, the release note's H1 as title, the complete
|
||||
note as body, and draft and prerelease disabled. A create conflict, interrupted
|
||||
response, or malformed create response (including inconsistent decoded fields)
|
||||
triggers one lookup. An exact page found
|
||||
there confirms the existing content; a differing page is a conflict. If that
|
||||
lookup cannot determine the result after a possibly successful write, the
|
||||
client returns an unknown outcome. Other HTTP failures retain their structured
|
||||
failure kind and status code. A recovered create retains its write history;
|
||||
a later asset-inspection failure reports partial page progress.
|
||||
If the page is absent, the adapter posts to
|
||||
`/api/v1/repos/{owner}/{repo}/releases` with the selected full tag and commit,
|
||||
the release note's H1 as title, the complete note as body, and draft and
|
||||
prerelease disabled. A create conflict, interrupted response, or malformed
|
||||
create response (including inconsistent decoded fields) triggers one lookup by
|
||||
the shared workflow. An exact page found there confirms the existing content;
|
||||
a differing page is a conflict. If that lookup cannot determine the result
|
||||
after a possibly successful write, delivery returns an unknown outcome. Other
|
||||
HTTP failures retain their structured failure kind and status code. A recovered
|
||||
create retains its write history; a later asset-inspection failure reports
|
||||
partial page progress.
|
||||
|
||||
For a binary-producing release, the client can build a deterministic
|
||||
For a binary-producing release, shared delivery builds a deterministic
|
||||
`SHA256SUMS` file from the declared artifact names and SHA-256 digests. Entries
|
||||
are sorted by filename and use the standard two-space separator. A source-only
|
||||
release produces no manifest.
|
||||
|
||||
Asset inspection lists the release attachments, streams each declared existing
|
||||
file, and compares its SHA-256 digest. It reports each asset as matching,
|
||||
missing, conflicting, or unverifiable. Same-name attachments with different
|
||||
bytes are conflicts even when their reported size matches. An existing
|
||||
The adapter lists release attachments and opens their download streams. Shared
|
||||
delivery compares each declared existing file by SHA-256 digest and reports it
|
||||
as matching, missing, conflicting, or unverifiable. Same-name attachments with
|
||||
different bytes are conflicts even when their reported size matches. An existing
|
||||
`SHA256SUMS` must exactly match the complete expected manifest; a subset or
|
||||
different manifest is a conflict. An unreadable attachment is unverifiable,
|
||||
not missing. A failed read-only inspection reports `not_attempted`; if this
|
||||
@@ -63,12 +87,15 @@ Unknown-write outcomes are reserved for unresolved writes. Unrelated attachments
|
||||
|
||||
Attachment downloads and redirects carry the Gitea authorization token only
|
||||
when their scheme, host, and port match the configured service. Cross-origin
|
||||
content is downloaded without that token and verified by its digest.
|
||||
content is downloaded without that token and verified by its digest. Downloads
|
||||
follow at most ten redirects; an additional redirect is unverifiable.
|
||||
Comparison reads no more than the declared local size plus one byte; shorter
|
||||
or longer content conflicts, regardless of the server's reported size.
|
||||
|
||||
The upload operation accepts only an inspection result without conflicts or
|
||||
Shared delivery uploads only after inspection finds no conflicts or
|
||||
unverifiable assets. It uploads missing binaries in filename order and then the
|
||||
missing `SHA256SUMS`, using multipart field `attachment` and the filename query
|
||||
parameter `name`. Matching attachments are retained; unrelated and existing
|
||||
missing `SHA256SUMS`, using the adapter's multipart field `attachment` and
|
||||
filename query parameter `name`. Matching attachments are retained; unrelated and existing
|
||||
attachments are never overwritten or deleted. Results use disjoint current
|
||||
observations: matching, uploaded, pending, conflicting, and uncertain. An asset
|
||||
moved from known missing work to another category is removed from `pending`;
|
||||
|
||||
@@ -11,18 +11,20 @@ errors.
|
||||
## Command output ownership
|
||||
|
||||
`Tool.Run` executes a status-only command. It forwards combined stdout and
|
||||
stderr to the optional writer while the child runs, then releases its capture
|
||||
on success. Failures include the captured diagnostic text and preserve the
|
||||
underlying process or forwarding error.
|
||||
stderr to the optional writer while the child runs, retaining only the last
|
||||
1,048,576 bytes for failure diagnostics. A truncation notice identifies omitted
|
||||
earlier bytes. Successful calls release their capture; failures preserve the
|
||||
underlying process or forwarding error. Dependency preparation uses the same
|
||||
bounded diagnostic capture.
|
||||
|
||||
`Tool.Output` returns combined stdout and stderr for commands whose output is
|
||||
needed by the caller, such as compiler identification. After the child stops,
|
||||
the capture's backing bytes transfer to the caller without a second copy. A
|
||||
failed output command returns both those bytes and an error containing the
|
||||
diagnostic text. The capture remains in memory for the duration of the child;
|
||||
the component benchmark compares one 8 MiB write with the same output in
|
||||
32 KiB writes, forwarding to `io.Discard`. Smaller writes incur buffer-growth
|
||||
allocations. The [output benchmark](../../internal/gotool/performance_test.go)
|
||||
the component benchmark compares full and bounded capture for modest output,
|
||||
one 8 MiB write, and 8 MiB in 32 KiB writes, forwarding to `io.Discard`.
|
||||
The [output benchmark](../../internal/gotool/performance_test.go)
|
||||
measures total allocated bytes per operation, excluding subprocess overhead and
|
||||
diagnostic strings. These totals count all allocations, including superseded
|
||||
buffers. The returned slice retains the final buffer holding the complete output.
|
||||
@@ -33,8 +35,10 @@ coexist. Disk-backed diagnostics could reduce large failure peaks, but add file
|
||||
lifecycle and cleanup work and remain a follow-up option.
|
||||
|
||||
Dependency preparation uses a separate command environment so proxy, private
|
||||
module, checksum, and cache settings remain available. Its completed capture
|
||||
uses the same ownership transfer when formatting a failure.
|
||||
module, checksum, cache, and other effective settings remain available. Its
|
||||
`go mod download -modfile=` command overrides an alternate module file selected
|
||||
through `GOFLAGS`, preserving ordinary module discovery. Its bounded capture
|
||||
is formatted into the error on failure.
|
||||
|
||||
## Configuration discovery
|
||||
|
||||
|
||||
@@ -39,9 +39,10 @@ reports these implemented packages:
|
||||
| `internal/artifact` | Shared build-target normalization, artifact declarations, filename rules, and checksum-manifest naming. |
|
||||
| `internal/build` | Resolved-identity builds, normalized settings, selected-module compilation, version injection, external native smoke output, and artifact digests. |
|
||||
| `internal/candidate` | Stable-version, note, repository-state, candidate capture, and post-validation checks. |
|
||||
| `internal/delivery` | Captured-commit module identity and note selection, literal-tag checkout checks, prebuilt identity binding, managed builds in disposable storage outside the checkout, structural artifact declaration, manifest assembly, one local digest-and-size preflight before hosted requests, and delivery coordination. |
|
||||
| `internal/delivery` | Read-only backend preflight, captured-commit module identity and note selection, literal-tag checkout checks, prebuilt identity binding, managed builds in disposable storage outside the checkout, artifact declaration, manifest assembly, shared page and asset policy, streamed comparisons, write recovery, and delivery outcomes. |
|
||||
| `internal/forge` | Backend contract, neutral observations and failures, opaque handles, preflight classifications, and write evidence shared by delivery policy and adapters. |
|
||||
| `internal/git` | Git command boundary, committed-tree reads and shared reference traversal, checkout-root and commit capture, lazy local-only batched blob reads, push-destination resolution, exact ref inspection, tagged checkout preparation, and tag operations, including captured-endpoint verification during recovery. |
|
||||
| `internal/gitea` | Gitea API transport, full-tag hosted release pages, asset inspection, manifests, and uploads. |
|
||||
| `internal/gitea` | Gitea destination rules, authenticated repository preflight, HTTP operations and codecs, opaque handle interpretation, response validation, redirects, multipart streaming, and protocol-to-neutral failure conversion. |
|
||||
| `internal/gotool` | Operation-local Go configuration discovery and reuse, shared native-validation and static-build environments, and network-capable dependency preparation; see [Go command environments](gotool.md). |
|
||||
| `internal/tempdir` | Disposable system temporary storage outside source worktrees. |
|
||||
| `internal/process` | Cancellable child-process execution, including owned stdin/stdout pipes for serialized exchanges, with per-command environment overlays and removals. |
|
||||
@@ -74,11 +75,16 @@ it does not consult neighboring files or traverse submodule checkouts.
|
||||
See the [shared validation guide](validation.md) for checker, Go command, and
|
||||
candidate-file integration mechanics.
|
||||
|
||||
Delivery verifies all local binary sizes and digests before hosted requests.
|
||||
Its Gitea upload path reuses that verification and streams missing files once
|
||||
for transport. Independent calls to the internal client's `UploadMissing`
|
||||
verify missing local files before writing. Both paths share remote inspection,
|
||||
conflict handling, and reconciliation.
|
||||
The root facade selects the backend explicitly and passes a configured
|
||||
`internal/forge.Backend` into `internal/delivery`. Delivery performs cheap
|
||||
request and source checks, then read-only backend preflight before managed
|
||||
builds or full local artifact verification. Artifact size and digest
|
||||
verification completes before hosted writes and is reused for upload streams.
|
||||
Shared delivery code owns release-page matching and creation recovery, remote
|
||||
asset comparison, upload sequencing, and partial or uncertain outcomes.
|
||||
`internal/gitea` implements only the Gitea protocol operations behind the
|
||||
neutral contract. `internal/artifact` and `internal/forge` do not depend on the
|
||||
adapter.
|
||||
|
||||
## Capability owners and behavioral evidence
|
||||
|
||||
@@ -88,19 +94,20 @@ not a separate release matrix:
|
||||
| Capability | Implementation owner | Existing evidence |
|
||||
| --- | --- | --- |
|
||||
| Runtime version lookup and injected application version | `buildinfo`, `internal/runtimeinfo`, `internal/versioncontract`, `internal/build` | `TestVersionInConsumerExecutable`, `TestVersion` |
|
||||
| Candidate capture, shared validation, public diagnostics, additional checks, and rechecks | `releasekit.Check`, `releasekit.Publish`, `internal/candidate`, `internal/validation`, `cmd/releasekit/internal/cli` | `TestRunUsesOneSuiteWithSelectedRaceSetting`, `TestRunReportsRacePrerequisiteFailureWithoutFallback`, `TestRunExecutesRaceEnabledSuiteOnHost`, `TestRunCompilesTargetsAfterTestAndVetInSelectionOrder`, `TestRunAttributesTargetCompileFailureAndStops`, `TestRunCompilesSourceOnlyAndExecutablePackagesWithoutArtifacts`, `TestCandidateRequestMapsGoValidation`, `TestCandidateRequestPreservesMarkdownObjectSelection`, `TestLoadSettingsReadsGoValidation`, `TestLoadSettingsPreservesMarkdownObjectSelection`, `TestCheckUsesSelectedRaceSuiteBeforeAdditionalChecks`, `TestCheckCapturesCandidateAndCallsAdditionalChecksOnce`, `TestCheckReturnsPublicValidationError`, `TestMarkdownValidationRunsThroughCheckAndPublish`, `TestCheckStopsBeforeProductAndBuildCallbacksWhenMarkdownBoundaryFails`, `TestCheckCommandBindsConfiguredChecksToCapturedCandidate`, `TestPublishCommandPublishesConfiguredCandidate`, `TestCandidateCommandsPresentValidationDiagnosticsOnce`, `TestDeliverPageOnlyCreatesAndReusesRelease` |
|
||||
| Candidate capture, shared validation, public diagnostics, additional checks, and rechecks | `releasekit.Check`, `releasekit.Publish`, `internal/candidate`, `internal/validation`, `cmd/releasekit/internal/cli` | `TestRunUsesOneSuiteWithGoValidationSettings`, `TestRunReportsRacePrerequisiteFailureWithoutFallback`, `TestRunExecutesRaceEnabledSuiteOnHost`, `TestRunCompilesTargetsAfterTestAndVetInSelectionOrder`, `TestRunAttributesTargetCompileFailureAndStops`, `TestRunCompilesSourceOnlyAndExecutablePackagesWithoutArtifacts`, `TestCandidateRequestMapsGoValidation`, `TestCandidateRequestPreservesMarkdownObjectSelection`, `TestLoadSettingsReadsGoValidation`, `TestLoadSettingsPreservesMarkdownObjectSelection`, `TestCheckUsesSelectedRaceSuiteBeforeAdditionalChecks`, `TestCheckCapturesCandidateAndCallsAdditionalChecksOnce`, `TestCheckReturnsPublicValidationError`, `TestMarkdownValidationRunsThroughCheckAndPublish`, `TestCheckStopsBeforeProductAndBuildCallbacksWhenMarkdownBoundaryFails`, `TestCheckCommandBindsConfiguredChecksToCapturedCandidate`, `TestPublishCommandPublishesConfiguredCandidate`, `TestCandidateCommandsPresentValidationDiagnosticsOnce`, `TestDeliverPageOnlyCreatesAndReusesRelease` |
|
||||
| Committed-file validation: formatting, workspace files, local replacements, and file size | `internal/validation`, `internal/candidate`, `releasekit.Check`, `releasekit.Publish`, CLI profile conversion | `TestCheckGoFormatUsesCapturedRegularGoBlobs`, `TestCheckWorkspaceFilesReportsCommittedBlobNames`, `TestCheckLocalReplacementsParsesCommittedModuleDeclarations`, `TestCheckLocalReplacementsFollowsCommittedModuleSymlinks`, `TestCheckFileSizeUsesStrictLimitsAndDefault`, `TestCheckFileSizeAppliesLiteralPathExclusions`, `TestCheckFileSizeUsesCommittedBlobSizes`, `TestCheckFileSizeUsesOnlyCandidateFilesIndividually` |
|
||||
| Internal Markdown parsing, committed destinations, fragments, boundary observations, and public selection | `releasekit.Check`, `releasekit.Publish`, `internal/candidate`, `internal/validation`, `cmd/releasekit/internal/cli` | `TestParseMarkdownLinksAndSourceLocations`, `TestParseMarkdownDecodesEntitiesInDestinations`, `TestParseMarkdownHeadingAnchors`, `TestMarkdownCacheKeepsParsedDocumentsPerCandidatePath`, `TestCheckMarkdownLinksResolvesCommittedDestinationsAndFragments`, `TestCheckMarkdownLinksReportsMissingPathsAnchorsAndCycles`, `TestCheckMarkdownLinksUsesResolvedMarkdownIdentity`, `TestCheckMarkdownLinksReportsBoundaryAndSubmoduleObservations`, `TestCheckMarkdownLinksPreservesResolverCauseAndCancellation`, `TestCheckMarkdownLinksPreservesAnchorReadCause`, `TestRunSelectsMarkdownWithCombinedGoChecksAndReportsOutOfScopeLinks`, `TestMarkdownValidationRunsThroughCheckAndPublish`, `TestLoadSettingsPreservesMarkdownObjectSelection`, `TestValidationSpecPreservesMarkdownObjectSelection` |
|
||||
| Internal Markdown parsing, committed destinations, fragments, boundary observations, and public selection | `releasekit.Check`, `releasekit.Publish`, `internal/candidate`, `internal/validation`, `cmd/releasekit/internal/cli` | `TestParseMarkdownLinksAndSourceLocations`, `TestParseMarkdownDecodesEntitiesInDestinations`, `TestParseMarkdownHeadingAnchors`, `TestMarkdownDocumentsParseIdenticalAndDistinctBlobs`, `TestCheckMarkdownLinksResolvesCommittedDestinationsAndFragments`, `TestCheckMarkdownLinksReportsMissingPathsAnchorsAndCycles`, `TestCheckMarkdownLinksUsesResolvedMarkdownIdentity`, `TestCheckMarkdownLinksReportsBoundaryAndSubmoduleObservations`, `TestCheckMarkdownLinksPreservesResolverCauseAndCancellation`, `TestCheckMarkdownLinksPreservesAnchorReadCause`, `TestRunSelectsMarkdownWithCombinedGoChecksAndReportsOutOfScopeLinks`, `TestMarkdownValidationRunsThroughCheckAndPublish`, `TestLoadSettingsPreservesMarkdownObjectSelection`, `TestValidationSpecPreservesMarkdownObjectSelection` |
|
||||
| Shared Go settings for validation and builds | `internal/gotool`, `internal/process`, `internal/candidate`, `internal/build` | `TestDefaultValidationDoesNotInheritGOFLAGSRunFilter`, `TestNativeAndStaticCommandsUseOwnedBaseline`, `TestPersistentSettingsPreserveCachesAndNativeCTools`, `TestOwnerReusesSettingsUntilInheritedEnvironmentChanges`, `TestOwnerRefreshesWhenPersistentSettingsChange`, `TestCheckSharesGoDiscoveryWithAfterValidateCallback`, `TestCheckBuildsAfterValidationAndKeepsOutputOutsideWorktree`, `TestRunValidatesOnlyTheSelectedModule`, `TestBuildIgnoresAmbientAndPersistentArchitectureTuning` |
|
||||
| Batched local Git blob reads | `internal/git`, `internal/process` | `TestBlobReaderReadsLooseAndPackedObjects`, `TestBlobReaderMissingObjectKeepsBatchSessionUsable`, `TestCandidateObjectReadsStayLocalInPartialClones` |
|
||||
| Piped child-process exchanges and cancellation cleanup | `internal/process` | `TestStartPipedExchangesBeforeInputEOFAndPreservesSettings`, `TestStartPipedCancellationAfterReadinessReapsChild`, `TestStartPipedPreservesNonzeroExit` |
|
||||
| Authoritative endpoint and exact annotated tag publication | `releasekit.Publish`, `internal/publish`, `internal/git` | `TestPublishPushesOnlyTheCapturedAnnotatedTag`, `TestPublishRejectsUnexpectedPushURLBeforeValidation`, `TestPublishComparesExpectedURLWithResolvedInsteadOfDestination`, `TestPublishExpectedPushURLUsesPushURLWhenFetchAndPushDiffer`, `TestCheckIgnoresExpectedPushURL`, `TestResolveUsesPushURLAndQueriesWithoutMutation` |
|
||||
| Publication partial and uncertain recovery | `internal/publish`, `internal/git` | `TestPublishRejectedPushKeepsLocalTagAndReportsNotPublished`, `TestPublishLostAcknowledgmentConfirmsExactRemoteObject`, `TestPublishLostAcknowledgmentWithChangedEndpointRemainsUnknown`, `TestPublishUnavailableReconciliationRemainsUnknownAndPreservesPushError` |
|
||||
| Cross-builds, version smoke checks, module paths, empty target sets, and partial outputs | `releasekit.Build`, `internal/build`, `internal/artifact`, `internal/tempdir`, `buildinfo` | `TestBuildCreatesSelectedTargetsAndInjectsVersion`, `TestBuildUsesNestedWorkingTreeModuleAndModuleRelativeOutput`, `TestBuildResolvesDirtyWorkingTreeDeclaration`, `TestBuildWithNoTargetsDoesNotInvokeCompiler`, `TestBuildReturnsPartialArtifactsAfterTargetFailure`, `TestNormalizeSettingsKeepsBuildInputsAndNormalizesTargets`, `TestOutsideFallsBackWhenTemporaryDirectoryIsInsideWorktree` |
|
||||
| Page-only and managed or prebuilt binary delivery | `releasekit.Deliver`, `internal/delivery`, `internal/gitea`, CLI | `TestDeliverPageOnlySkipsBuildAndHostedAssets`, `TestDeliverUsesCommittedModuleIdentityForSeparatePages`, `TestDeliverRejectsPrebuiltArtifactsForAnotherModuleBeforeHostedRequests`, `TestDeliverManagedBuildUploadsArtifactsAndCleansTemporaryOutput`, `TestDeliverBinaryConsumerWithBuildinfoAndSelectedTargets`, `TestDeliverPrebuiltArtifactsRemainCallerOwned`, `TestDeliverRejectsSameSizePrebuiltTamperingBeforeHostedWrites`, `TestDeliverManagedBuildFailureCleansTemporaryOutputBeforeHostedWrites`, `TestDeliverManagedBuildCancellationCleansTemporaryOutput`, `TestDeliverBuildsTaggedArtifactsAndReusesAssets`, `TestDeliverRejectsTamperedAssetsAndStaleReceiptBeforeRemoteWrites`, `TestEnsureReleaseEscapesSlashContainingTag` |
|
||||
| Explicit destination selection, standalone and automatic preflight, and delivery ordering | `releasekit`, `internal/delivery`, `internal/gitea`, CLI | `TestDeliveryForgeSelectionIsExplicit`, `TestPreflightDeliveryChecksAuthenticatedRepositoryWithoutCheckout`, `TestPreflightDeliveryPreservesPublicFailureKindsAndCauses`, `TestAutomaticPreflightFailurePrecedesPrebuiltArtifactHashing`, `TestDeliverUnavailablePreflightStopsBeforeManagedBuild`, `TestCIDeliverUnavailablePreflightStopsBeforeDependencyPreparation`, `TestCIDeliverPageOnlyReportsUnavailablePreflightWithStatusOne` |
|
||||
| Page-only and managed or prebuilt binary delivery | `releasekit.Deliver`, `internal/delivery`, `internal/gitea`, CLI | `TestDeliverPageOnlySkipsBuildAndHostedAssets`, `TestDeliverUsesCommittedModuleIdentityForSeparatePages`, `TestDeliverRejectsPrebuiltArtifactsForAnotherModuleBeforeHostedRequests`, `TestDeliverManagedBuildUploadsArtifactsAndCleansTemporaryOutput`, `TestDeliverBinaryConsumerWithBuildinfoAndSelectedTargets`, `TestDeliverPrebuiltArtifactsRemainCallerOwned`, `TestDeliverRejectsSameSizePrebuiltTamperingBeforeHostedWrites`, `TestDeliverManagedBuildFailureCleansTemporaryOutputBeforeHostedWrites`, `TestDeliverManagedBuildCancellationCleansTemporaryOutput`, `TestDeliverBuildsTaggedArtifactsAndReusesAssets`, `TestDeliverRejectsTamperedAssetsAndStaleReceiptBeforeRemoteWrites` |
|
||||
| Tagged checkout and build dependency preparation | `releasekit.PrepareTaggedCheckout`, `releasekit.PrepareBuildDependencies`, `internal/preparation`, `internal/git`, `internal/gotool` | `TestPrepareTaggedCheckoutFetchesExpectedCommitAndDetaches`, `TestPrepareTaggedCheckoutRejectsWrongCommitBeforeCheckout`, `TestPrepareTaggedCheckoutReportsLocalCheckoutConflict`, `TestPrepareTaggedCheckoutReportsConflictingLocalTag`, `TestPrepareBuildDependenciesUsesSelectedModuleDirectory`, `TestPrepareBuildDependenciesPreservesPreparationEnvironment`, `TestPrepareBuildDependenciesPropagatesCommandFailure`, `TestPrepareBuildDependenciesPropagatesCancellation` |
|
||||
| Shared module identity and tag derivation | `internal/releaseidentity`, `internal/candidate`, `internal/build`, `internal/delivery` | `TestDeriveUsesModuleDeclarationAndPhysicalLayout`, `TestCommittedAndWorkingTreeDeclarationsUseTheirOwnSources`, `TestSelectedNestedModuleDoesNotRequireRootDeclaration`, `TestBuildResolvesDirtyWorkingTreeDeclaration`, `TestDeliverUsesCommittedModuleIdentityForSeparatePages` |
|
||||
| Page, manifest, asset comparison, and additive retry outcomes | `internal/gitea` | `TestEnsureReleaseCreatesMissingPageFromCommittedNote`, `TestInspectAssetsDetectsSameNameAndSizeWithDifferentBytes`, `TestUploadMissingPartialFailureThenRerunOnlyMissingAssets` |
|
||||
| Shared page, manifest, asset comparison, and additive retry policy | `internal/delivery` | `TestCoordinateReleaseCreatesWithFullNoteAndVerifiesRequestedTitle`, `TestCoordinateReleaseReconcilesCreateOutcomes`, `TestInspectRemoteAssetsComparesContentAndPreservesUnrelatedAssets`, `TestUploadMissingAssetsReconcilesOnceAndKeepsLaterAssetsPending` |
|
||||
| CLI profiles and prepared Woodpecker sequences | `cmd/releasekit`, `cmd/releasekit/internal/cli`, `examples/woodpecker/`, `examples/multi-module/` | `TestLoadSettingsRejectsInvalidProfiles`, `TestWoodpeckerTagDeliveryTemplate`, `TestWoodpeckerTagDeliveryUsesTaggedModuleVersion`, `TestWoodpeckerSourceDeliveryTemplateSelfBootstraps`, `TestReleaseKitSourceDeliveryWorkflowFromTaggedCheckout`, `TestMultiModuleSelectorMapsSelectorsAndTags`, `TestMultiModuleLauncherUsesSelectedModuleAndProfile`, `TestMultiModuleWoodpeckerDelivery` |
|
||||
|
||||
Run the complete suite with `go test ./...`. The public API and import boundary
|
||||
|
||||
@@ -19,11 +19,12 @@ artifact or executing a target binary. Test, vet, and package-compilation
|
||||
failures retain their `go-test`, `go-vet`, and `package-compile` identities,
|
||||
selected target where applicable, and underlying causes. Its
|
||||
request carries the captured repository directory and commit, output writer,
|
||||
and internal settings. When selected, the Go-format check loads the candidate
|
||||
tree once, reads regular Go blobs from the captured commit, and compares them
|
||||
with go/format output. It uses the selected inputs, including vendor and explicit
|
||||
shared files, and skips
|
||||
symlink blobs, reports all unformatted paths, and stops with a contextual cause
|
||||
and internal settings. The Go commands run before the committed-tree sequence,
|
||||
which owns its reader's lifetime and close-error attribution. When selected,
|
||||
the Go-format check loads the candidate tree once, reads regular Go blobs from
|
||||
the captured commit, and compares them with go/format output. It uses the
|
||||
selected inputs, including vendor and explicit shared files, skips symlink
|
||||
blobs, reports all unformatted paths, and stops with a contextual cause
|
||||
on parse or inspection errors. It runs before product checks and build callbacks.
|
||||
`Request.ReadCandidateTree` lets selected file checks request candidate
|
||||
contents; the Go-only path does not enumerate the tree.
|
||||
@@ -59,6 +60,10 @@ workspace-file check using the same candidate inventory. Resolved module bodies
|
||||
are parsed once per blob object ID; only owned filesystem-replacement targets
|
||||
and line numbers remain cached. Each declaration path receives findings from
|
||||
that summary in its own path context.
|
||||
The optional forbidden-file check uses the captured tree's full path lookup
|
||||
before the other file checks. It normalizes the explicitly selected
|
||||
repository-relative names and reports matching entries through `forbidden-files`
|
||||
without loading their blobs. Candidate-tree cleanup covers this failure path.
|
||||
The optional file-size check applies normalized literal file/directory
|
||||
exclusions with path-boundary matching, then requests local Git sizes for the
|
||||
remaining selected blobs. Sizes include symlink link-text bytes; gitlinks are
|
||||
@@ -105,9 +110,11 @@ observations remain out of scope under either setting. Observations go to the
|
||||
provided writer; a nil writer keeps library use quiet. Public selection is
|
||||
defined in the [configuration reference](../config.md) and GoDoc.
|
||||
|
||||
Committed-tree metadata and blob reads disable lazy fetching and Git transports
|
||||
for those child commands. Missing local objects produce inspection errors;
|
||||
source preparation retains its separate network-capable operations. See Git's
|
||||
ReleaseKit-owned Git commands inspect original objects even when replacement
|
||||
refs exist. Committed-tree metadata and blob reads also disable lazy fetching
|
||||
and Git transports for those child commands. Missing local objects produce
|
||||
inspection errors; source preparation retains its separate network-capable
|
||||
operations. See Git's
|
||||
[lazy-fetch controls](https://git-scm.com/docs/git) for the command mechanism.
|
||||
Candidate and delivery release-note reads use the same local object boundary.
|
||||
|
||||
|
||||
@@ -77,6 +77,17 @@ and orchestration bookkeeping belong behind that boundary. Additional public
|
||||
packages and extension interfaces require a deliberate decision grounded in a
|
||||
durable consumer need.
|
||||
|
||||
Hosted delivery follows a neutral internal contract. The root facade selects a
|
||||
backend explicitly and constructs it; `internal/forge` defines the backend
|
||||
operations and neutral observations, failures, and write evidence;
|
||||
`internal/delivery` owns shared page and asset policy; and `internal/gitea`
|
||||
adapts the Gitea protocol. `internal/delivery`, `internal/forge`, and
|
||||
`internal/artifact` must not depend on the Gitea adapter. Artifact declaration
|
||||
and local content verification remain independent of delivery policy and forge
|
||||
protocols. The compiled dependency check protects this transitive boundary.
|
||||
See [ADR 0006](../adr/0006-neutral-forge-delivery-and-preflight.md) for the
|
||||
selected delivery and preflight design.
|
||||
|
||||
Go declarations and GoDoc own exact API contracts. Compiled-export checks,
|
||||
dependency checks, and external-package behavioral tests protect the selected
|
||||
facade boundaries.
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
# ReleaseKit release procedure
|
||||
|
||||
ReleaseKit's deliverables are its source tag and a hosted Gitea page containing
|
||||
the complete checked-in release note. The page is delivered asynchronously by
|
||||
the prepared [Woodpecker workflow](../examples/woodpecker/releasekit-source-delivery.yml);
|
||||
it is not a source-release completion check and carries no binary assets. The
|
||||
authoritative repository is `origin` at
|
||||
the complete checked-in release note. Deliver the page locally after publication
|
||||
or asynchronously through the prepared
|
||||
[Woodpecker workflow](../examples/woodpecker/releasekit-source-delivery.yml).
|
||||
Hosted-page delivery has a separate status from source-release completion and
|
||||
carries no binary assets. The authoritative repository is `origin` at
|
||||
`ssh://git@forge.maximumdirect.net:22222/base-layer/releasekit.git`, and the release
|
||||
branch is `main`. The hosted Gitea destination is
|
||||
`https://forge.maximumdirect.net/base-layer/releasekit`. A release completes
|
||||
@@ -14,6 +15,15 @@ succeeded. CI and consumer installation are not completion checks.
|
||||
|
||||
## Prepare a candidate
|
||||
|
||||
For local hosted-page delivery, save a Gitea personal access token in
|
||||
`.releasekit/gitea-token` with owner-only file permissions. The file contains
|
||||
only the token. ReleaseKit's profile selects this file as a fallback to
|
||||
`RELEASEKIT_GITEA_TOKEN`; `.releasekit/` is already ignored. Its optional
|
||||
`validation.forbidden_files` selection checks that this path is absent from the
|
||||
committed candidate. Source publication requires no delivery token. The
|
||||
[configuration reference](config.md) owns credential selection and validator
|
||||
semantics.
|
||||
|
||||
Use a clean clone on `main`. The chosen build toolchain is Go 1.26.10. Make that
|
||||
local installation the active `go` command for preparation, building the release
|
||||
executable, and candidate validation:
|
||||
@@ -98,6 +108,22 @@ for ReleaseKit's first release before any released ReleaseKit executable
|
||||
exists. It uses the local Go toolchain. Preparation must finish before the
|
||||
offline validation begins.
|
||||
|
||||
## Deliver the hosted release page
|
||||
|
||||
After source publication succeeds, deliver the hosted page locally when
|
||||
Woodpecker automation is inactive. From the same repository root, keep the
|
||||
worktree clean and `HEAD` at the published tag's commit. Reuse the executable
|
||||
built by the publication command:
|
||||
|
||||
```sh
|
||||
.releasekit/bin/releasekit deliver vX.Y.Z
|
||||
```
|
||||
|
||||
The command uses the configured Gitea destination and token source to deliver
|
||||
the complete checked-in release note. Report its result separately from the
|
||||
completed source release. For failed or uncertain delivery, follow the recovery
|
||||
instructions below using the same immutable version.
|
||||
|
||||
## Resolve failures
|
||||
|
||||
A failed push triggers a URL-resolution check against the captured endpoint,
|
||||
@@ -133,6 +159,9 @@ with `git tag -d vX.Y.Z` before correcting the candidate or retrying. Do not
|
||||
move, overwrite, or delete a tag that exists remotely. If a different object
|
||||
uses the version, choose a new version after resolving the conflict.
|
||||
|
||||
If local tag inspection fails after creation, the reported ref may remain even
|
||||
when no tag object ID was obtained. Inspect that ref before recovery.
|
||||
|
||||
If validation or a guard fails before this invocation pushes, correct and
|
||||
commit the candidate, publish `main`, and run the command again. A retained
|
||||
local tag still requires the authoritative-state check above before removal;
|
||||
@@ -150,7 +179,15 @@ git checkout --detach "vX.Y.Z"
|
||||
GOTOOLCHAIN=local go mod download
|
||||
mkdir -p .releasekit/bin
|
||||
GOTOOLCHAIN=local GOPROXY=off GOSUMDB=off go build -o .releasekit/bin/releasekit ./cmd/releasekit
|
||||
RELEASEKIT_GITEA_TOKEN="$GITEA_TOKEN" .releasekit/bin/releasekit deliver "vX.Y.Z"
|
||||
.releasekit/bin/releasekit deliver "vX.Y.Z"
|
||||
```
|
||||
|
||||
For an older tag whose CLI predates `token_file`, supply the token through its
|
||||
environment variable when retrying that tagged tool:
|
||||
|
||||
```sh
|
||||
RELEASEKIT_GITEA_TOKEN="$(cat .releasekit/gitea-token)" \
|
||||
.releasekit/bin/releasekit deliver "vX.Y.Z"
|
||||
```
|
||||
|
||||
The root ignore file excludes `.releasekit/`. Delivery uses the committed note
|
||||
|
||||
125
docs/releases/v0.6.0.md
Normal file
125
docs/releases/v0.6.0.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# ReleaseKit v0.6.0
|
||||
|
||||
## Summary
|
||||
|
||||
ReleaseKit adds file-based delivery credentials, optional checks for forbidden
|
||||
committed files, neutral delivery coordination with explicit Gitea selection, and
|
||||
authenticated, read-only credential preflight. It also corrects dependency
|
||||
preparation, publication recovery, and build cancellation handling.
|
||||
|
||||
## Changes
|
||||
|
||||
- Delivery profiles can select a plain token file through `token_file`.
|
||||
Relative paths resolve from the checkout root; a nonblank token environment
|
||||
value takes precedence. The CLI trims file contents and reports unreadable
|
||||
or blank files as credential-loading failures. Library callers continue to
|
||||
supply the token explicitly through the destination.
|
||||
- `ValidationSpec.ForbiddenFiles` and `validation.forbidden_files` select
|
||||
literal repository-relative paths that must be absent from the committed
|
||||
candidate. This optional check can protect a local token-file path. It
|
||||
inspects committed path metadata independently of module/shared-file inputs;
|
||||
it does not inspect file contents or history.
|
||||
- `releasekit.DeliveryRequest.Destination` now uses the facade-owned
|
||||
`releasekit.DeliveryDestination`. Set `Forge: releasekit.ForgeGitea` for
|
||||
Gitea delivery. The removed `GiteaDestination` type has no compatibility
|
||||
alias.
|
||||
- Delivery profiles must include `"forge": "gitea"`. The selection is
|
||||
required only when `deliver` or `ci-deliver` is requested; the URL does not
|
||||
infer a backend. `check`, `build`, and source `publish` remain independent of
|
||||
delivery selection and credentials.
|
||||
- `releasekit.PreflightDelivery` allows callers to check a destination without
|
||||
a checkout or tag. `Deliver` performs the same read-only preflight
|
||||
automatically. For Gitea, the check authenticates through repository-scoped
|
||||
reads and makes no test release or asset writes.
|
||||
- `DeliveryPreflightError` exposes classified failures and retains the
|
||||
underlying cause. Successful preflight confirms authentication and repository
|
||||
access plus available permission evidence; it does not guarantee future
|
||||
release or asset writes. Gitea's code-push permission metadata does not
|
||||
establish release-write or asset-write permission.
|
||||
- The CLI keeps `RELEASEKIT_GITEA_TOKEN` as its default token environment
|
||||
setting and preserves configured environment/file precedence. Selection,
|
||||
local credential-loading, and classified preflight configuration/access
|
||||
errors return status 2; unavailable preflight and cancellation during
|
||||
preflight/preparation return status 1. Later authorization failures follow
|
||||
delivery outcomes. An unresolved write returns status 3, including when
|
||||
cancellation prevents reconciliation; known delivery failures return 1.
|
||||
- Binary `ci-deliver` checks credentials before application dependency
|
||||
downloads and checks again during delivery. The automatic check now precedes
|
||||
full local artifact size and digest verification; those checks still finish
|
||||
before hosted writes.
|
||||
|
||||
## Correctness and efficiency
|
||||
|
||||
- Dependency preparation now resolves the containing checkout root, including
|
||||
when called from a subdirectory or filesystem alias. `ModuleDir` is relative
|
||||
to that root, and empty selection prepares the root module. Standalone use
|
||||
retains the supplied directory as its base, including Go-only use when Git
|
||||
is unavailable. Damaged checkout metadata and operational discovery failures
|
||||
remain errors before dependency downloads.
|
||||
- Preparation overrides alternate module-file selection from inherited or
|
||||
persistent `GOFLAGS`, while retaining other effective Go settings. Downloads
|
||||
therefore use the selected module's ordinary metadata.
|
||||
- Candidate checking rejects a derived release tag that Git cannot create
|
||||
before running validation. CI delivery also validates the selected module's
|
||||
complete tag before dependency preparation.
|
||||
- Publication recovery treats a peeled-only tag observation as unresolved,
|
||||
retains the original push failure, and reports `PublicationUnknown` with CLI
|
||||
status 3. Complete tag observations retain their existing recovery behavior.
|
||||
- Cancellation and deadline expiration during build source inspection remain
|
||||
identifiable through `errors.Is`; partial results retain completed artifacts.
|
||||
Local artifact inspection errors also retain their underlying causes through
|
||||
delivery.
|
||||
- Gitea asset operations classify a missing or nonpositive service release ID
|
||||
as a malformed observation before making an asset request. Page-only release
|
||||
lookup retains its existing minimal response requirements.
|
||||
- Asset delivery uses linear pending-asset bookkeeping and sorts only missing
|
||||
assets once, reducing repeated work during uploads and reconciliation.
|
||||
|
||||
## Compatibility
|
||||
|
||||
This pre-1.0 minor release changes the public destination type and requires
|
||||
explicit forge selection for delivery. Gitea is the supported backend.
|
||||
Preparation callers that previously used a checkout subdirectory as their
|
||||
implicit module selection must now select that module with `ModuleDir`.
|
||||
Ambient `GOFLAGS` no longer redirects preparation to an alternate module file.
|
||||
|
||||
The module identifier remains `go.maximumdirect.net/releasekit`, and the Go
|
||||
consumer minimum remains 1.26.0. Receipt schemas and the separate source
|
||||
publication and hosted-delivery outcomes retain their existing definitions.
|
||||
|
||||
## Upgrade guidance
|
||||
|
||||
Select v0.6.0 for library and CLI dependencies, then update integrations as
|
||||
follows:
|
||||
|
||||
1. Replace `releasekit.GiteaDestination` with
|
||||
`releasekit.DeliveryDestination` and set
|
||||
`Forge: releasekit.ForgeGitea` in each API request.
|
||||
2. Add `"forge": "gitea"` to every profile used for `deliver` or
|
||||
`ci-deliver`, including profiles loaded from a tagged CI checkout. Keep the
|
||||
existing `base_url`, owner, repository, token environment, and token file
|
||||
settings.
|
||||
3. If handling preflight failures in Go, use `errors.As` to inspect
|
||||
`*releasekit.DeliveryPreflightError` and use `errors.Is` or `errors.As` on
|
||||
its unwrapped cause as needed.
|
||||
4. Continue to treat successful preflight as evidence of repository access,
|
||||
not a guarantee that a later write will be accepted. Source publication
|
||||
continues to use Git authentication and does not require hosted delivery
|
||||
credentials.
|
||||
5. For dependency preparation of a nested module, supply its repository-relative
|
||||
path through `DependencyPreparationRequest.ModuleDir`, even when
|
||||
`RepositoryDir` already points inside that module. Root-module consumers can
|
||||
keep `ModuleDir` empty.
|
||||
6. To adopt file-based credentials, store the token in an ignored local file
|
||||
and configure `delivery.token_file`. Optionally add that same
|
||||
repository-relative path to `validation.forbidden_files`. Credential loading
|
||||
and candidate file validation are independent selections; configuring the
|
||||
file alone does not enable the validator.
|
||||
|
||||
See the [delivery guide](../consumers/delivery.md), [configuration reference](../config.md),
|
||||
[CLI contract](../cli.md), and [Gitea integration guide](../integrations/gitea.md)
|
||||
for current behavior.
|
||||
|
||||
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.6.0/COPYRIGHT) |
|
||||
[LICENSE](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.6.0/LICENSE) |
|
||||
[ATTRIBUTION](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.6.0/docs/oss/attribution.md)
|
||||
106
docs/releases/v0.7.0.md
Normal file
106
docs/releases/v0.7.0.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# ReleaseKit v0.7.0
|
||||
|
||||
## Summary
|
||||
|
||||
ReleaseKit improves source-publication integrity, cancellation, credential-safe
|
||||
diagnostics, partial-result reporting, and temporary-file cleanup. It also bounds
|
||||
retained Go failure diagnostics and remote attachment comparison work while
|
||||
preserving the existing public facades and configuration formats.
|
||||
|
||||
## Publication and Git operations
|
||||
|
||||
- Exact tag pushes disable submodule recursion, correcting inherited
|
||||
`push.recurseSubmodules=only` settings that could report success without
|
||||
publishing the release tag.
|
||||
- ReleaseKit-owned Git inspection and preparation use original objects when
|
||||
replacement refs exist. Committed module identity, release notes, validation
|
||||
inputs, and build source eligibility use that consistent view. Ordinary dirty
|
||||
builds remain supported, with their source eligibility reported separately.
|
||||
- A verified local tag object remains available in `PublicationResult.TagObject`
|
||||
when later inspection fails. Errors identify a possibly retained local ref
|
||||
when its object ID could not be established. Failed verification stops before
|
||||
a push.
|
||||
- HTTP(S) userinfo is masked in owned Git diagnostics and CLI output, including
|
||||
literal apostrophes in credentials and failed CI checkout preparation. Native
|
||||
Git authentication, exact endpoint comparisons, and the raw captured
|
||||
`PublicationResult.Endpoint` value retain their existing behavior.
|
||||
- Child environment overlays preserve the working-directory-derived `PWD` on
|
||||
POSIX systems. Native Git hooks now receive the expected directory environment
|
||||
when ReleaseKit is invoked from another checkout or module subdirectory.
|
||||
|
||||
## Cancellation and diagnostic retention
|
||||
|
||||
- Buffered subprocess output has bounded I/O teardown after cancellation or
|
||||
child exit, including when descendants retain inherited output descriptors.
|
||||
Piped Git-reader cancellation closes owned pipes and joins child cleanup.
|
||||
Cancellation remains recognizable through `errors.Is`; ordinary execution
|
||||
failures preserve their underlying causes.
|
||||
- A queued blob read rechecks cancellation after acquiring the reader mutex,
|
||||
preserving a healthy batch session for later reads.
|
||||
- Status-only Go commands and dependency preparation retain a 1 MiB diagnostic
|
||||
tail. Failed commands identify omitted earlier output. Existing streaming
|
||||
output continues to be forwarded in full, and commands requiring returned
|
||||
output keep their complete capture.
|
||||
|
||||
## Builds and hosted delivery
|
||||
|
||||
- Completed build artifacts are reported only after hashing succeeds. A hashing
|
||||
failure identifies the affected output and preserves earlier completed
|
||||
artifacts and their digests.
|
||||
- Temporary allocation and cleanup failures retain their paths and underlying
|
||||
causes, including candidate builds, native version checks, delivery builds,
|
||||
tag-message files, and build receipts.
|
||||
- A managed-build cleanup failure after confirmed hosted delivery returns an
|
||||
error alongside `DeliveryComplete` and the confirmed asset observations. The
|
||||
CLI reports completed remote work and the local cleanup failure, returning
|
||||
operational status 1.
|
||||
- Gitea attachment downloads follow at most ten redirects, retaining cycle
|
||||
detection and per-origin authorization handling. Limit exhaustion is
|
||||
unverifiable content.
|
||||
- Remote attachment comparison reads at most the locally verified expected size
|
||||
plus one byte. Shorter or longer content conflicts; equal-length content still
|
||||
requires the expected digest. Read, close, and cancellation errors remain
|
||||
inspection failures, with existing attempted-write reconciliation preserved.
|
||||
|
||||
Shared Git observations, validation sequencing, artifact declaration matching,
|
||||
and CLI workflows were consolidated internally. Consumer-facing types continue
|
||||
to belong to the root `releasekit` and runtime `buildinfo` facades.
|
||||
|
||||
## Compatibility and upgrade guidance
|
||||
|
||||
The module identifier remains `go.maximumdirect.net/releasekit`, and the Go
|
||||
consumer minimum remains 1.26.0. Existing v0.6.0 API calls, profile fields, and
|
||||
build receipt formats remain supported. This minor release changes operational
|
||||
behavior through the diagnostic and redirect budgets and cleanup-error reporting
|
||||
described above.
|
||||
|
||||
When adopting v0.7.0:
|
||||
|
||||
1. Update library and CLI dependencies to v0.7.0 using the existing import paths
|
||||
and profiles.
|
||||
2. Handle a delivery error together with `DeliveryResult.State`. For
|
||||
`DeliveryComplete`, address the reported local cleanup path; the confirmed
|
||||
hosted delivery is already complete. Continue to resolve partial, conflicting,
|
||||
and unknown outcomes through their existing recovery guidance.
|
||||
3. Retain candidate validation's streamed `Output` when complete test diagnostics
|
||||
are needed. Status-only failure text now contains the retained tail and an
|
||||
omission notice when applicable.
|
||||
4. When using Git replacement refs, expect release evidence to be evaluated
|
||||
against original committed objects. A build of dirty or replacement-derived
|
||||
working content can succeed while remaining ineligible for binary delivery.
|
||||
5. Inspect the reported local tag ref after a tag-creation or verification
|
||||
failure, including when `TagObject` is empty. Continue to treat the raw
|
||||
`PublicationResult.Endpoint` as sensitive when handling it in application
|
||||
output or logs.
|
||||
|
||||
See the [publication guide](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.7.0/docs/consumers/publish.md),
|
||||
[build guide](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.7.0/docs/consumers/build.md),
|
||||
[delivery guide](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.7.0/docs/consumers/delivery.md),
|
||||
[CLI contract](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.7.0/docs/cli.md),
|
||||
and [Gitea integration guide](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.7.0/docs/integrations/gitea.md)
|
||||
for current behavior. Source publication and hosted delivery retain their
|
||||
separate outcomes under the [maintainer procedure](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.7.0/docs/release.md).
|
||||
|
||||
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.7.0/COPYRIGHT) |
|
||||
[LICENSE](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.7.0/LICENSE) |
|
||||
[ATTRIBUTION](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.7.0/docs/oss/attribution.md)
|
||||
67
docs/roadmap/deferred-resources.md
Normal file
67
docs/roadmap/deferred-resources.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# Deferred resource work
|
||||
|
||||
These two proposals remain deferred after the completed audit remediation for
|
||||
v0.7.0. Their assessments are complete; production limits and exclusion indexing
|
||||
await a future decision supported by consumer needs. The assessment evidence
|
||||
was recorded in commit `9dc87ae`.
|
||||
|
||||
## Forge JSON response budgets
|
||||
|
||||
**Status:** Deferred; local sizing assessment complete.
|
||||
|
||||
Forge JSON decoding can consume memory proportional to response size. Release
|
||||
notes and unpaginated attachment lists have no established supported maximum.
|
||||
A future proposal should establish an appropriate response envelope before
|
||||
selecting byte/time budgets or configuration. Any limit must retain context
|
||||
cancellation, useful error classification, and uncertain-write reconciliation:
|
||||
a rejected response does not establish that a create or upload failed remotely.
|
||||
|
||||
The [Gitea fixtures](../../internal/gitea/backend_test.go) construct repository,
|
||||
release, tag, and attachment responses. Representative encoded fixtures measured
|
||||
81 bytes for repository identity, 136 with permissions, 125 for a sparse release,
|
||||
144 for a populated release, 73 for a one-item attachment list, and 62 for an
|
||||
upload response, including the encoder's newline. Four literal release-ID
|
||||
fixtures measured 21, 28, 29, and 29 bytes without a newline. Generated URLs,
|
||||
notes, and lists vary with fixture inputs.
|
||||
|
||||
The checked-in v0.1.0–v0.6.0 release notes measured 1,235–7,899 bytes during the
|
||||
assessment. These observations describe local examples and establish no
|
||||
deployment or protocol maximum. Production response caps remain deferred.
|
||||
|
||||
## File-size exclusion indexing
|
||||
|
||||
**Status:** Deferred; benchmark assessment complete.
|
||||
|
||||
The current [exclusion scan](../../internal/validation/file_size.go) compares
|
||||
each selected file with the normalized exclusions until a match is found.
|
||||
The adoption decision weighs measured speedups against allocation and
|
||||
path-walking costs for consumer workloads. Preserve literal component boundaries,
|
||||
module-relative normalization, shared inputs, and the first oversized-file result.
|
||||
|
||||
The [benchmark-only prototype](../../internal/validation/file_size_benchmark_test.go)
|
||||
compares the scan with a normalized set and ancestor lookups. It covers 1,000
|
||||
and 10,000 paths, 1/10/100/1,000 exclusions, shallow/deep paths, early matches,
|
||||
and nonmatches. Each measured operation includes normalization and one index
|
||||
construction where applicable; fixture strings are prepared outside timing.
|
||||
|
||||
On Linux/amd64, Go 1.26.8, Intel Core i5-7260U, 64 combinations ran with three
|
||||
samples each using:
|
||||
|
||||
```sh
|
||||
GOTOOLCHAIN=local GOPROXY=off GOSUMDB=off go test ./internal/validation \
|
||||
-run '^$' -bench '^BenchmarkFileSizeExclusions$' -benchtime=100ms -count=3
|
||||
```
|
||||
|
||||
Representative median times and allocations were:
|
||||
|
||||
| Paths / exclusions / case | Scan | Index |
|
||||
| --- | --- | --- |
|
||||
| 1,000 / 1 / shallow early match | 24.5 µs, 16 B, 1 allocation | 44.0 µs, 16 B, 1 allocation |
|
||||
| 1,000 / 1 / deep nonmatch | 25.7 µs, 16 B, 1 allocation | 119.0 µs, 16 B, 1 allocation |
|
||||
| 10,000 / 1,000 / shallow early match | 270 µs, 16,384 B, 1 allocation | 564 µs, 70,992 B, 6 allocations |
|
||||
| 10,000 / 1,000 / shallow nonmatch | 227.0 ms, 16,384 B, 1 allocation | 521 µs, 70,992 B, 6 allocations |
|
||||
| 10,000 / 1,000 / deep nonmatch | 233.4 ms, 16,384 B, 1 allocation | 1.36 ms, 70,992 B, 6 allocations |
|
||||
|
||||
The prototype was slower for small lists and early matches, and faster for
|
||||
large nonmatching lists with higher allocation costs. Production matching
|
||||
remains unchanged pending an adoption decision.
|
||||
@@ -90,7 +90,8 @@ outside the checkout, and builds the ReleaseKit version selected there. It then
|
||||
uses `ci-deliver --prepared` with the mapped profile and expected physical module.
|
||||
The `core-v2` profile demonstrates binary delivery; the other profiles are
|
||||
page-only. Before enabling the workflow, configure each selected profile's
|
||||
`delivery` destination and add the Woodpecker repository secret
|
||||
`delivery` object with `"forge": "gitea"`, its `base_url`, owner, and repository,
|
||||
then add the Woodpecker repository secret
|
||||
`releasekit_gitea_token`; the workflow exposes it as
|
||||
`RELEASEKIT_GITEA_TOKEN` only in the delivery step.
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"targets": ["linux/amd64", "linux/arm64", "darwin/arm64"]
|
||||
},
|
||||
"delivery": {
|
||||
"forge": "gitea",
|
||||
"base_url": "https://gitea.example",
|
||||
"owner": "example",
|
||||
"repository": "example",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"product": "Example",
|
||||
"remote": "origin",
|
||||
"delivery": {
|
||||
"forge": "gitea",
|
||||
"base_url": "https://gitea.example",
|
||||
"owner": "example",
|
||||
"repository": "example",
|
||||
|
||||
@@ -226,6 +226,33 @@ func checkImportDirections(packages []listedPackage) error {
|
||||
}
|
||||
visit(modulePath + "/buildinfo")
|
||||
|
||||
for _, root := range []string{
|
||||
modulePath + "/internal/delivery",
|
||||
modulePath + "/internal/forge",
|
||||
modulePath + "/internal/artifact",
|
||||
} {
|
||||
seen := make(map[string]bool)
|
||||
var visitDelivery func(string)
|
||||
visitDelivery = func(path string) {
|
||||
if seen[path] {
|
||||
return
|
||||
}
|
||||
seen[path] = true
|
||||
if path == modulePath+"/internal/gitea" {
|
||||
violations = append(violations, fmt.Sprintf("shared package %s depends on Gitea adapter", root))
|
||||
return
|
||||
}
|
||||
pkg, ok := byPath[path]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for _, imported := range pkg.Imports {
|
||||
visitDelivery(imported)
|
||||
}
|
||||
}
|
||||
visitDelivery(root)
|
||||
}
|
||||
|
||||
if len(violations) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -254,7 +281,7 @@ func commandInternalRoot(path string) string {
|
||||
func forbiddenRuntimeDependency(path string) bool {
|
||||
for _, part := range strings.Split(strings.ToLower(path), "/") {
|
||||
switch part {
|
||||
case "orchestration", "orchestrator", "engine", "release", "process", "build", "builder", "gitea", "delivery", "cli":
|
||||
case "orchestration", "orchestrator", "engine", "release", "process", "build", "builder", "gitea", "forge", "delivery", "cli":
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,26 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRuntimeFacadeCannotDependOnForge(t *testing.T) {
|
||||
for _, indirect := range []bool{false, true} {
|
||||
name := "direct"
|
||||
dependency := modulePath + "/internal/forge"
|
||||
packages := []listedPackage{{ImportPath: dependency}}
|
||||
if indirect {
|
||||
name = "transitive"
|
||||
packages = append(packages, listedPackage{ImportPath: modulePath + "/internal/runtime", Imports: []string{dependency}})
|
||||
dependency = modulePath + "/internal/runtime"
|
||||
}
|
||||
packages = append(packages, listedPackage{ImportPath: modulePath + "/buildinfo", Imports: []string{dependency}})
|
||||
t.Run(name, func(t *testing.T) {
|
||||
err := checkImportDirections(packages)
|
||||
if err == nil || !strings.Contains(err.Error(), "buildinfo") || !strings.Contains(err.Error(), "/internal/forge") {
|
||||
t.Fatalf("dependency check = %v, want excluded forge runtime dependency", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportedContractBoundaries(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -162,6 +182,30 @@ func TestRuntimeFacadeCannotDependOnExternalPackages(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedDeliveryPackagesCannotDependOnGitea(t *testing.T) {
|
||||
allowed := []listedPackage{
|
||||
{ImportPath: modulePath + "/internal/delivery", Imports: []string{modulePath + "/internal/forge", modulePath + "/internal/artifact"}},
|
||||
{ImportPath: modulePath + "/internal/forge"},
|
||||
{ImportPath: modulePath + "/internal/artifact"},
|
||||
{ImportPath: modulePath + "/internal/gitea"},
|
||||
}
|
||||
if err := checkImportDirections(allowed); err != nil {
|
||||
t.Fatalf("shared delivery boundary rejected neutral dependencies: %v", err)
|
||||
}
|
||||
|
||||
violating := []listedPackage{
|
||||
{ImportPath: modulePath + "/internal/delivery", Imports: []string{modulePath + "/internal/policy"}},
|
||||
{ImportPath: modulePath + "/internal/policy", Imports: []string{modulePath + "/internal/gitea"}},
|
||||
{ImportPath: modulePath + "/internal/forge"},
|
||||
{ImportPath: modulePath + "/internal/artifact"},
|
||||
{ImportPath: modulePath + "/internal/gitea"},
|
||||
}
|
||||
err := checkImportDirections(violating)
|
||||
if err == nil || !strings.Contains(err.Error(), "shared package "+modulePath+"/internal/delivery depends on Gitea adapter") {
|
||||
t.Fatalf("checkImportDirections() error = %v, want transitive shared-delivery violation", err)
|
||||
}
|
||||
}
|
||||
|
||||
func checkFixture(t *testing.T, facadeSource string) []string {
|
||||
t.Helper()
|
||||
fset := token.NewFileSet()
|
||||
|
||||
@@ -90,3 +90,30 @@ func ExpectedBuild(binaryName, version string, targets []Target) []Declaration {
|
||||
}
|
||||
return declarations
|
||||
}
|
||||
|
||||
// ValidateDeclarations matches recorded artifact identities to selected targets.
|
||||
func ValidateDeclarations(binaryName, version string, targets []Target, recorded []Declaration) error {
|
||||
if len(recorded) != len(targets) {
|
||||
return fmt.Errorf("artifact and target counts do not match")
|
||||
}
|
||||
want := make(map[Target]string, len(targets))
|
||||
for _, declaration := range ExpectedBuild(binaryName, version, targets) {
|
||||
target := declaration.Target
|
||||
if !ValidComponent(target.OS) || !ValidComponent(target.Arch) {
|
||||
return fmt.Errorf("invalid target %q/%q", target.OS, target.Arch)
|
||||
}
|
||||
if _, exists := want[target]; exists {
|
||||
return fmt.Errorf("repeats target %q/%q", target.OS, target.Arch)
|
||||
}
|
||||
want[target] = declaration.Filename
|
||||
}
|
||||
seen := make(map[Target]bool, len(recorded))
|
||||
for _, declaration := range recorded {
|
||||
name, ok := want[declaration.Target]
|
||||
if !ok || seen[declaration.Target] || declaration.Filename != name {
|
||||
return fmt.Errorf("artifact %q does not match a selected target and its declared name", declaration.Filename)
|
||||
}
|
||||
seen[declaration.Target] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -56,3 +56,31 @@ func TestValidComponentPreservesBuildAndReceiptRules(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDeclarationsMatchesTargetsAndNames(t *testing.T) {
|
||||
linux := Target{OS: "linux", Arch: "amd64"}
|
||||
windows := Target{OS: "windows", Arch: "arm64"}
|
||||
first := Declaration{Target: linux, Filename: "app-v1.2.3-linux-amd64"}
|
||||
second := Declaration{Target: windows, Filename: "app-v1.2.3-windows-arm64.exe"}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
targets []Target
|
||||
recorded []Declaration
|
||||
valid bool
|
||||
}{
|
||||
{"reordered", []Target{linux, windows}, []Declaration{second, first}, true},
|
||||
{"count", []Target{linux, windows}, []Declaration{first}, false},
|
||||
{"duplicate target", []Target{linux, linux}, []Declaration{first, first}, false},
|
||||
{"duplicate artifact", []Target{linux, windows}, []Declaration{first, first}, false},
|
||||
{"incorrect name", []Target{linux}, []Declaration{{Target: linux, Filename: "other"}}, false},
|
||||
{"unknown target", []Target{linux}, []Declaration{second}, false},
|
||||
{"unsafe target", []Target{{OS: "linux/other", Arch: "amd64"}}, []Declaration{{Target: linux, Filename: first.Filename}}, false},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
err := ValidateDeclarations("app", "v1.2.3", test.targets, test.recorded)
|
||||
if (err == nil) != test.valid {
|
||||
t.Fatalf("ValidateDeclarations() error = %v, valid = %t", err, test.valid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
141
internal/artifact/assets.go
Normal file
141
internal/artifact/assets.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package artifact
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrInvalidAsset identifies an invalid asset declaration or unreadable
|
||||
// local path.
|
||||
ErrInvalidAsset = errors.New("invalid asset")
|
||||
// ErrAssetConflict identifies local content that does not match its
|
||||
// declared identity.
|
||||
ErrAssetConflict = errors.New("asset content conflict")
|
||||
)
|
||||
|
||||
// ExpectedAsset describes the local content declared for hosted delivery.
|
||||
// Content is populated for inline assets such as the generated checksum
|
||||
// manifest; Path identifies file content for later streaming uploads.
|
||||
type ExpectedAsset struct {
|
||||
Filename string
|
||||
SHA256 string
|
||||
Size int64
|
||||
Path string
|
||||
Content []byte
|
||||
}
|
||||
|
||||
// BuildManifest creates the deterministic checksum file for the declared
|
||||
// binaries. An empty binary set produces no manifest.
|
||||
func BuildManifest(binaries []ExpectedAsset) (ExpectedAsset, error) {
|
||||
if len(binaries) == 0 {
|
||||
return ExpectedAsset{}, nil
|
||||
}
|
||||
ordered := append([]ExpectedAsset(nil), binaries...)
|
||||
sort.Slice(ordered, func(i, j int) bool { return ordered[i].Filename < ordered[j].Filename })
|
||||
var body strings.Builder
|
||||
seen := make(map[string]struct{}, len(ordered))
|
||||
for _, binary := range ordered {
|
||||
if !validAssetName(binary.Filename) || binary.Filename == ManifestFilename || !validSHA256(binary.SHA256) {
|
||||
return ExpectedAsset{}, fmt.Errorf("%w: invalid binary identity for %q", ErrInvalidAsset, binary.Filename)
|
||||
}
|
||||
if _, exists := seen[binary.Filename]; exists {
|
||||
return ExpectedAsset{}, fmt.Errorf("%w: duplicate binary filename %q", ErrInvalidAsset, binary.Filename)
|
||||
}
|
||||
seen[binary.Filename] = struct{}{}
|
||||
fmt.Fprintf(&body, "%s %s\n", binary.SHA256, binary.Filename)
|
||||
}
|
||||
content := []byte(body.String())
|
||||
return ExpectedAsset{Filename: ManifestFilename, SHA256: digest(content), Size: int64(len(content)), Content: content}, nil
|
||||
}
|
||||
|
||||
// ValidateExpectedAssets checks the declarations before they are compared
|
||||
// with remote content or uploaded.
|
||||
func ValidateExpectedAssets(expected []ExpectedAsset) error {
|
||||
seen := make(map[string]struct{}, len(expected))
|
||||
for _, item := range expected {
|
||||
if !validAssetName(item.Filename) || !validSHA256(item.SHA256) || item.Size < 0 {
|
||||
return fmt.Errorf("%w: %q", ErrInvalidAsset, item.Filename)
|
||||
}
|
||||
if _, duplicate := seen[item.Filename]; duplicate {
|
||||
return fmt.Errorf("%w: duplicate expected asset %q", ErrInvalidAsset, item.Filename)
|
||||
}
|
||||
seen[item.Filename] = struct{}{}
|
||||
if len(item.Content) > 0 && (int64(len(item.Content)) != item.Size || digest(item.Content) != item.SHA256) {
|
||||
return fmt.Errorf("%w: expected content does not match identity for %q", ErrInvalidAsset, item.Filename)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerifyLocalAssets checks that every expected file still matches its declared
|
||||
// size and digest before hosted writes begin.
|
||||
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
|
||||
}
|
||||
|
||||
func verifyLocalAsset(asset ExpectedAsset) error {
|
||||
if len(asset.Content) > 0 {
|
||||
if int64(len(asset.Content)) != asset.Size || digest(asset.Content) != asset.SHA256 {
|
||||
return fmt.Errorf("%w: local content does not match declared asset %q", ErrAssetConflict, asset.Filename)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if asset.Path == "" || filepath.Base(asset.Path) != asset.Filename {
|
||||
return fmt.Errorf("%w: local path for %q does not match its filename", ErrInvalidAsset, asset.Filename)
|
||||
}
|
||||
file, err := os.Open(asset.Path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: open %q: %w", ErrInvalidAsset, asset.Filename, err)
|
||||
}
|
||||
defer file.Close()
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: stat %q: %w", ErrInvalidAsset, asset.Filename, err)
|
||||
}
|
||||
if !info.Mode().IsRegular() || info.Size() != asset.Size {
|
||||
return fmt.Errorf("%w: local file does not match declared size for %q", ErrAssetConflict, asset.Filename)
|
||||
}
|
||||
hasher := sha256.New()
|
||||
size, err := io.Copy(hasher, file)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: read %q: %w", ErrInvalidAsset, asset.Filename, err)
|
||||
}
|
||||
if size != asset.Size || hex.EncodeToString(hasher.Sum(nil)) != asset.SHA256 {
|
||||
return fmt.Errorf("%w: local content does not match declared asset %q", ErrAssetConflict, asset.Filename)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validAssetName(name string) bool {
|
||||
return name != "" && name != "." && name != ".." && path.Base(name) == name && !strings.ContainsAny(name, "\\\r\n\x00")
|
||||
}
|
||||
|
||||
func validSHA256(value string) bool {
|
||||
if len(value) != sha256.Size*2 {
|
||||
return false
|
||||
}
|
||||
_, err := hex.DecodeString(value)
|
||||
return err == nil && strings.ToLower(value) == value
|
||||
}
|
||||
|
||||
func digest(content []byte) string {
|
||||
sum := sha256.Sum256(content)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
93
internal/artifact/assets_test.go
Normal file
93
internal/artifact/assets_test.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package artifact
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildManifestSortsNamesAndOmitsEmptySourceOnlyManifest(t *testing.T) {
|
||||
manifest, err := BuildManifest([]ExpectedAsset{
|
||||
assetFor("zeta.bin", []byte("zeta")),
|
||||
assetFor("alpha.bin", []byte("alpha")),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := assetDigest([]byte("alpha")) + " alpha.bin\n" + assetDigest([]byte("zeta")) + " zeta.bin\n"
|
||||
if string(manifest.Content) != want || manifest.Filename != ManifestFilename || manifest.SHA256 != assetDigest([]byte(want)) || manifest.Size != int64(len(want)) {
|
||||
t.Fatalf("manifest = %#v, want content %q", manifest, want)
|
||||
}
|
||||
empty, err := BuildManifest(nil)
|
||||
if err != nil || empty.Filename != "" || len(empty.Content) != 0 {
|
||||
t.Fatalf("empty manifest = %#v, error = %v", empty, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildManifestRejectsInvalidOrDuplicateDeclarations(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
assets []ExpectedAsset
|
||||
}{
|
||||
{name: "invalid filename", assets: []ExpectedAsset{assetFor("../app.bin", []byte("app"))}},
|
||||
{name: "manifest filename", assets: []ExpectedAsset{assetFor(ManifestFilename, []byte("app"))}},
|
||||
{name: "invalid digest", assets: []ExpectedAsset{{Filename: "app.bin", SHA256: "not-a-digest"}}},
|
||||
{name: "duplicate filename", assets: []ExpectedAsset{assetFor("app.bin", []byte("one")), assetFor("app.bin", []byte("two"))}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if _, err := BuildManifest(test.assets); err == nil {
|
||||
t.Fatal("BuildManifest() succeeded for invalid declarations")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyLocalAssetsRejectsSameSizeTampering(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "app.bin")
|
||||
original := []byte("alpha")
|
||||
if err := os.WriteFile(path, original, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
asset := assetFor(filepath.Base(path), original)
|
||||
asset.Content = nil
|
||||
asset.Path = path
|
||||
if err := VerifyLocalAssets([]ExpectedAsset{asset}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tampered := []byte("bravo")
|
||||
if err := os.WriteFile(path, tampered, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := VerifyLocalAssets([]ExpectedAsset{asset}); err == nil || !strings.Contains(err.Error(), "does not match declared asset") {
|
||||
t.Fatalf("VerifyLocalAssets() error = %v, want content mismatch", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateExpectedAssetsRejectsDuplicateAndMismatchedInlineContent(t *testing.T) {
|
||||
asset := assetFor("app.bin", []byte("alpha"))
|
||||
if err := ValidateExpectedAssets([]ExpectedAsset{asset, asset}); err == nil || !strings.Contains(err.Error(), "duplicate") {
|
||||
t.Fatalf("ValidateExpectedAssets() error = %v, want duplicate", err)
|
||||
}
|
||||
asset.Content = []byte("bravo")
|
||||
if err := ValidateExpectedAssets([]ExpectedAsset{asset}); err == nil || !strings.Contains(err.Error(), "does not match identity") {
|
||||
t.Fatalf("ValidateExpectedAssets() error = %v, want content mismatch", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assetFor(filename string, content []byte) ExpectedAsset {
|
||||
return ExpectedAsset{
|
||||
Filename: filename,
|
||||
SHA256: assetDigest(content),
|
||||
Size: int64(len(content)),
|
||||
Content: append([]byte(nil), content...),
|
||||
}
|
||||
}
|
||||
|
||||
func assetDigest(content []byte) string {
|
||||
sum := sha256.Sum256(content)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
package build
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
@@ -9,13 +10,14 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"go.maximumdirect.net/releasekit/internal/artifact"
|
||||
"go.maximumdirect.net/releasekit/internal/candidate"
|
||||
"go.maximumdirect.net/releasekit/internal/git"
|
||||
"go.maximumdirect.net/releasekit/internal/gotool"
|
||||
"go.maximumdirect.net/releasekit/internal/process"
|
||||
"go.maximumdirect.net/releasekit/internal/releaseidentity"
|
||||
"go.maximumdirect.net/releasekit/internal/tempdir"
|
||||
"go.maximumdirect.net/releasekit/internal/versioncontract"
|
||||
@@ -48,27 +50,25 @@ type Result struct {
|
||||
SourceVerified bool
|
||||
Settings artifact.Settings
|
||||
Version string
|
||||
MainPackage string
|
||||
BinaryName string
|
||||
OutputDir string
|
||||
Targets []Target
|
||||
Artifacts []Artifact
|
||||
Compiler string
|
||||
}
|
||||
|
||||
func BuildResolved(ctx context.Context, request Request) (Result, error) {
|
||||
return buildResolved(ctx, request, os.RemoveAll)
|
||||
}
|
||||
|
||||
func buildResolved(ctx context.Context, request Request, remove func(string) error) (result Result, resultErr error) {
|
||||
identity := request.Identity
|
||||
settings := artifact.NormalizeSettings(request.Spec.MainPackage, request.Spec.BinaryName, request.Spec.Targets)
|
||||
result := Result{
|
||||
result = Result{
|
||||
RepositoryDir: identity.RepositoryDir,
|
||||
ModuleDir: identity.ModuleDir,
|
||||
ModulePath: identity.ModulePath,
|
||||
Tag: identity.Tag,
|
||||
Settings: settings,
|
||||
Version: identity.Version,
|
||||
MainPackage: settings.MainPackage,
|
||||
BinaryName: settings.BinaryName,
|
||||
Targets: settings.Targets,
|
||||
}
|
||||
if !versioncontract.ValidStableVersion(identity.Version) {
|
||||
return result, fmt.Errorf("%w: %q", candidate.ErrInvalidVersion, identity.Version)
|
||||
@@ -76,14 +76,14 @@ func BuildResolved(ctx context.Context, request Request) (Result, error) {
|
||||
if identity.RepositoryDir == "" || identity.ModuleDir == "" || identity.ModulePath == "" {
|
||||
return result, errors.New("build requires a resolved module identity")
|
||||
}
|
||||
if len(result.Targets) != 0 {
|
||||
if len(settings.Targets) != 0 {
|
||||
if settings.MainPackage == "" {
|
||||
return result, errors.New("build requires a main package when targets are selected")
|
||||
}
|
||||
if !artifact.ValidComponent(settings.BinaryName) {
|
||||
return result, fmt.Errorf("unsafe binary name %q", settings.BinaryName)
|
||||
}
|
||||
for _, target := range result.Targets {
|
||||
for _, target := range settings.Targets {
|
||||
if !artifact.ValidComponent(target.OS) || !artifact.ValidComponent(target.Arch) {
|
||||
return result, fmt.Errorf("unsafe target %q/%q", target.OS, target.Arch)
|
||||
}
|
||||
@@ -102,17 +102,17 @@ func BuildResolved(ctx context.Context, request Request) (Result, error) {
|
||||
return result, fmt.Errorf("resolve module directory: %w", err)
|
||||
}
|
||||
result.ModuleDir = moduleDir
|
||||
commit, err := gitCommit(ctx, repositoryDir)
|
||||
commit, err := git.CaptureHead(ctx, repositoryDir)
|
||||
if err != nil {
|
||||
return result, err
|
||||
return result, fmt.Errorf("read source commit: %w", err)
|
||||
}
|
||||
result.CandidateCommit = commit
|
||||
cleanBefore, err := cleanSource(ctx, repositoryDir)
|
||||
statusBefore, err := git.WorktreeStatus(ctx, repositoryDir)
|
||||
if err != nil {
|
||||
return result, err
|
||||
return result, fmt.Errorf("inspect build source: %w", err)
|
||||
}
|
||||
|
||||
if len(result.Targets) == 0 {
|
||||
if len(settings.Targets) == 0 {
|
||||
result.OutputDir = artifact.OutputDirectory(moduleDir, request.OutputDir)
|
||||
return result, nil
|
||||
}
|
||||
@@ -143,7 +143,7 @@ func BuildResolved(ctx context.Context, request Request) (Result, error) {
|
||||
goHost := goTool.HostTarget()
|
||||
host := Target{OS: goHost.OS, Arch: goHost.Arch}
|
||||
|
||||
for index, target := range result.Targets {
|
||||
for index, target := range settings.Targets {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return result, fmt.Errorf("build canceled: %w", err)
|
||||
}
|
||||
@@ -156,10 +156,10 @@ func BuildResolved(ctx context.Context, request Request) (Result, error) {
|
||||
return result, fmt.Errorf("build target %s/%s: %w", target.OS, target.Arch, err)
|
||||
}
|
||||
artifact := Artifact{Path: path, Filename: filename, Target: target}
|
||||
result.Artifacts = append(result.Artifacts, artifact)
|
||||
if err := hashArtifact(&result.Artifacts[len(result.Artifacts)-1]); err != nil {
|
||||
if err := hashArtifact(&artifact); err != nil {
|
||||
return result, fmt.Errorf("hash build output %q: %w", path, err)
|
||||
}
|
||||
result.Artifacts = append(result.Artifacts, artifact)
|
||||
}
|
||||
|
||||
var smokePath string
|
||||
@@ -174,7 +174,11 @@ func BuildResolved(ctx context.Context, request Request) (Result, error) {
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("create native smoke directory: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(temporaryDir)
|
||||
defer func() {
|
||||
if err := remove(temporaryDir); err != nil {
|
||||
resultErr = errors.Join(resultErr, fmt.Errorf("remove native smoke directory %q: %w", temporaryDir, err))
|
||||
}
|
||||
}()
|
||||
smokePath = filepath.Join(temporaryDir, settings.BinaryName)
|
||||
if host.OS == "windows" {
|
||||
smokePath += ".exe"
|
||||
@@ -192,30 +196,18 @@ func BuildResolved(ctx context.Context, request Request) (Result, error) {
|
||||
if err := checkVersion(ctx, smokePath, settings.BinaryName, identity.Version); err != nil {
|
||||
return result, err
|
||||
}
|
||||
commitAfter, err := gitCommit(ctx, repositoryDir)
|
||||
commitAfter, err := git.CaptureHead(ctx, repositoryDir)
|
||||
if err != nil {
|
||||
return result, err
|
||||
return result, fmt.Errorf("read source commit: %w", err)
|
||||
}
|
||||
cleanAfter, err := cleanSource(ctx, repositoryDir)
|
||||
statusAfter, err := git.WorktreeStatus(ctx, repositoryDir)
|
||||
if err != nil {
|
||||
return result, err
|
||||
return result, fmt.Errorf("inspect build source: %w", err)
|
||||
}
|
||||
result.SourceVerified = cleanBefore && cleanAfter && commitAfter == commit
|
||||
result.SourceVerified = statusBefore == "" && statusAfter == "" && commitAfter == commit
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func gitCommit(ctx context.Context, repositoryDir string) (string, error) {
|
||||
command := exec.CommandContext(ctx, "git", "-C", repositoryDir, "rev-parse", "--verify", "HEAD^{commit}")
|
||||
output, err := command.Output()
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return "", fmt.Errorf("read source commit: %w", ctx.Err())
|
||||
}
|
||||
return "", fmt.Errorf("read source commit: %w", err)
|
||||
}
|
||||
return strings.TrimSpace(string(output)), nil
|
||||
}
|
||||
|
||||
func buildTarget(ctx context.Context, goTool *gotool.Tool, moduleDir, mainPackage, outputPath, version string, target Target) error {
|
||||
ldflags := "-s -w -X " + versioncontract.InjectedVersionLinkerSymbol + "=" + version
|
||||
err := goTool.Run(ctx, moduleDir, gotool.Static, gotool.Target{OS: target.OS, Arch: target.Arch}, nil,
|
||||
@@ -238,26 +230,14 @@ func hashArtifact(artifact *Artifact) error {
|
||||
}
|
||||
|
||||
func checkVersion(ctx context.Context, binaryPath, binaryName, version string) error {
|
||||
command := exec.CommandContext(ctx, binaryPath, "version")
|
||||
output, err := command.CombinedOutput()
|
||||
var output bytes.Buffer
|
||||
err := process.Run(ctx, process.Invocation{Args: []string{binaryPath, "version"}, Stdout: &output, Stderr: &output})
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return fmt.Errorf("run native version check: %w", ctx.Err())
|
||||
}
|
||||
return fmt.Errorf("run native version check: %w: %s", err, strings.TrimSpace(string(output)))
|
||||
return fmt.Errorf("run native version check: %w: %s", err, strings.TrimSpace(output.String()))
|
||||
}
|
||||
want := binaryName + " " + version + "\n"
|
||||
if string(output) != want {
|
||||
return fmt.Errorf("native version output = %q, want %q", output, want)
|
||||
if output.String() != want {
|
||||
return fmt.Errorf("native version output = %q, want %q", output.String(), want)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cleanSource(ctx context.Context, repositoryDir string) (bool, error) {
|
||||
command := exec.CommandContext(ctx, "git", "-C", repositoryDir, "status", "--porcelain", "--untracked-files=all")
|
||||
output, err := command.Output()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("inspect build source: %w", err)
|
||||
}
|
||||
return len(output) == 0, nil
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package build
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -14,12 +15,16 @@ import (
|
||||
)
|
||||
|
||||
func CandidateCallback(spec *Spec, output io.Writer) func(context.Context, candidate.Candidate, *gotool.Owner) error {
|
||||
return candidateCallback(spec, output, os.RemoveAll)
|
||||
}
|
||||
|
||||
func candidateCallback(spec *Spec, output io.Writer, remove func(string) error) func(context.Context, candidate.Candidate, *gotool.Owner) error {
|
||||
if spec == nil || len(spec.Targets) == 0 {
|
||||
return nil
|
||||
}
|
||||
selected := *spec
|
||||
selected.Targets = append([]Target(nil), spec.Targets...)
|
||||
return func(ctx context.Context, captured candidate.Candidate, owner *gotool.Owner) error {
|
||||
return func(ctx context.Context, captured candidate.Candidate, owner *gotool.Owner) (resultErr error) {
|
||||
moduleSubdir, err := filepath.Rel(captured.RepositoryDir, captured.ModuleDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve captured module path: %w", err)
|
||||
@@ -39,7 +44,11 @@ func CandidateCallback(spec *Spec, output io.Writer) func(context.Context, candi
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temporary candidate build output: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(temporaryDir)
|
||||
defer func() {
|
||||
if err := remove(temporaryDir); err != nil {
|
||||
resultErr = errors.Join(resultErr, fmt.Errorf("remove candidate build directory %q: %w", temporaryDir, err))
|
||||
}
|
||||
}()
|
||||
_, err = BuildResolved(ctx, Request{
|
||||
Identity: identity,
|
||||
Spec: selected,
|
||||
|
||||
124
internal/build/cleanup_test.go
Normal file
124
internal/build/cleanup_test.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package build
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go.maximumdirect.net/releasekit/internal/candidate"
|
||||
"go.maximumdirect.net/releasekit/internal/releaseidentity"
|
||||
)
|
||||
|
||||
func cleanupFixture(t *testing.T, output string) releaseidentity.Identity {
|
||||
t.Helper()
|
||||
repo := t.TempDir()
|
||||
files := map[string]string{
|
||||
"go.mod": "module example.com/cleanup\n\ngo 1.25.0\n",
|
||||
"main.go": fmt.Sprintf("package main\nimport \"fmt\"\nfunc main() { fmt.Println(%q) }\n", output),
|
||||
}
|
||||
for name, contents := range files {
|
||||
if err := os.WriteFile(filepath.Join(repo, name), []byte(contents), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, args := range [][]string{{"init", "-b", "main"}, {"config", "user.name", "Fixture"}, {"config", "user.email", "fixture@example.invalid"}, {"add", "."}, {"commit", "-m", "fixture"}} {
|
||||
command := exec.Command("git", args...)
|
||||
command.Dir = repo
|
||||
if data, err := command.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v: %v: %s", args, err, data)
|
||||
}
|
||||
}
|
||||
return releaseidentity.Identity{Selection: releaseidentity.Selection{RepositoryDir: repo, ModuleDir: repo}, ModulePath: "example.com/cleanup", Version: "v1.2.3", Tag: "v1.2.3"}
|
||||
}
|
||||
|
||||
type cancelWriter struct{ cancel context.CancelFunc }
|
||||
|
||||
func (writer cancelWriter) Write(p []byte) (int, error) {
|
||||
if strings.Contains(string(p), "checking ") {
|
||||
writer.cancel()
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func TestNativeSmokeCleanupPreservesBuildOutcome(t *testing.T) {
|
||||
for _, mode := range []string{"success", "smoke error", "cancellation"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
output := "fixture v1.2.3"
|
||||
if mode == "smoke error" {
|
||||
output = "wrong"
|
||||
}
|
||||
identity := cleanupFixture(t, output)
|
||||
otherArch := "arm64"
|
||||
if runtime.GOARCH == otherArch {
|
||||
otherArch = "amd64"
|
||||
}
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
request := Request{Identity: identity, OutputDir: t.TempDir(), Spec: Spec{MainPackage: ".", BinaryName: "fixture", Targets: []Target{{OS: runtime.GOOS, Arch: otherArch}}}}
|
||||
if mode == "cancellation" {
|
||||
request.Output = cancelWriter{cancel}
|
||||
}
|
||||
cleanupErr := errors.New("cleanup failed")
|
||||
var removed string
|
||||
result, err := buildResolved(ctx, request, func(path string) error {
|
||||
removed = path
|
||||
_ = os.RemoveAll(path)
|
||||
return cleanupErr
|
||||
})
|
||||
if !errors.Is(err, cleanupErr) || removed == "" || !strings.Contains(err.Error(), removed) || len(result.Artifacts) != 1 || result.Artifacts[0].SHA256 == "" || result.CandidateCommit == "" {
|
||||
t.Fatalf("build = %#v, cleanup = %q, error = %v", result, removed, err)
|
||||
}
|
||||
if mode == "success" && !result.SourceVerified {
|
||||
t.Fatal("completed build lost verified source")
|
||||
}
|
||||
if mode == "smoke error" && !strings.Contains(err.Error(), "native version output") {
|
||||
t.Fatalf("smoke failure missing: %v", err)
|
||||
}
|
||||
if mode == "cancellation" && !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("cancellation missing: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCandidateBuildCleanupPreservesOutcome(t *testing.T) {
|
||||
for _, mode := range []string{"success", "build failure", "cancellation"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
identity := cleanupFixture(t, "fixture v1.2.3")
|
||||
cleanupErr := errors.New("cleanup failed")
|
||||
var removed string
|
||||
spec := &Spec{MainPackage: ".", BinaryName: "fixture", Targets: []Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}}}
|
||||
if mode == "build failure" {
|
||||
spec.MainPackage = "./missing"
|
||||
}
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
var output io.Writer
|
||||
if mode == "cancellation" {
|
||||
output = cancelWriter{cancel}
|
||||
}
|
||||
callback := candidateCallback(spec, output, func(path string) error {
|
||||
removed = path
|
||||
_ = os.RemoveAll(path)
|
||||
return cleanupErr
|
||||
})
|
||||
err := callback(ctx, candidate.Candidate{RepositoryDir: identity.RepositoryDir, ModuleDir: identity.ModuleDir, ModulePath: identity.ModulePath, Version: identity.Version, Tag: identity.Tag}, nil)
|
||||
if !errors.Is(err, cleanupErr) || removed == "" || !strings.Contains(err.Error(), removed) {
|
||||
t.Fatalf("candidate cleanup = %q, error = %v", removed, err)
|
||||
}
|
||||
if mode == "build failure" && !strings.Contains(err.Error(), "build candidate") {
|
||||
t.Fatalf("build failure missing: %v", err)
|
||||
}
|
||||
if mode == "cancellation" && !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("cancellation missing: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -105,6 +105,9 @@ func Capture(ctx context.Context, request Request) (Candidate, error) {
|
||||
if err := validateNote(note, request.Product, request.Version); err != nil {
|
||||
return captured, fmt.Errorf("release note %q: %w", identity.NotePath, err)
|
||||
}
|
||||
if err := git.CheckTagRef(ctx, repositoryDir, identity.Tag); err != nil {
|
||||
return captured, fmt.Errorf("validate local candidate tag: %w", err)
|
||||
}
|
||||
if exists, err := hasLocalTag(ctx, repositoryDir, identity.Tag); err != nil {
|
||||
return captured, fmt.Errorf("inspect local tag %q: %w", identity.Tag, err)
|
||||
} else if exists {
|
||||
@@ -191,18 +194,18 @@ func inspectLocalState(ctx context.Context, repositoryDir string) (localState, e
|
||||
if err != nil {
|
||||
return localState{}, fmt.Errorf("inspect current branch: %w", err)
|
||||
}
|
||||
commitOutput, err := gitOutput(ctx, repositoryDir, "rev-parse", "--verify", "HEAD^{commit}")
|
||||
commit, err := git.CaptureHead(ctx, repositoryDir)
|
||||
if err != nil {
|
||||
return localState{}, fmt.Errorf("capture HEAD commit: %w", err)
|
||||
}
|
||||
statusOutput, err := gitOutput(ctx, repositoryDir, "status", "--porcelain=v1", "--untracked-files=all")
|
||||
status, err := git.WorktreeStatus(ctx, repositoryDir)
|
||||
if err != nil {
|
||||
return localState{}, fmt.Errorf("inspect worktree and index: %w", err)
|
||||
}
|
||||
return localState{
|
||||
branch: strings.TrimSpace(string(branchOutput)),
|
||||
commit: strings.TrimSpace(string(commitOutput)),
|
||||
status: strings.TrimSpace(string(statusOutput)),
|
||||
commit: commit,
|
||||
status: status,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -212,6 +215,7 @@ func gitOutput(ctx context.Context, repositoryDir string, args ...string) ([]byt
|
||||
err := process.Run(ctx, process.Invocation{
|
||||
Args: commandArgs,
|
||||
Dir: repositoryDir,
|
||||
Env: map[string]string{"GIT_NO_REPLACE_OBJECTS": "1"},
|
||||
Stdout: &stdout,
|
||||
Stderr: &stderr,
|
||||
})
|
||||
@@ -230,6 +234,7 @@ func hasLocalTag(ctx context.Context, repositoryDir, tag string) (bool, error) {
|
||||
err := process.Run(ctx, process.Invocation{
|
||||
Args: []string{"git", "show-ref", "--verify", "--quiet", "refs/tags/" + tag},
|
||||
Dir: repositoryDir,
|
||||
Env: map[string]string{"GIT_NO_REPLACE_OBJECTS": "1"},
|
||||
Stderr: &stderr,
|
||||
})
|
||||
if err == nil {
|
||||
|
||||
@@ -7,10 +7,9 @@ import (
|
||||
|
||||
"go.maximumdirect.net/releasekit/internal/artifact"
|
||||
internalbuild "go.maximumdirect.net/releasekit/internal/build"
|
||||
"go.maximumdirect.net/releasekit/internal/gitea"
|
||||
)
|
||||
|
||||
func declaredBuildAssets(result internalbuild.Result) ([]gitea.ExpectedAsset, error) {
|
||||
func declaredBuildAssets(result internalbuild.Result) ([]artifact.ExpectedAsset, error) {
|
||||
settings := result.Settings
|
||||
if len(settings.Targets) == 0 {
|
||||
if len(result.Artifacts) != 0 {
|
||||
@@ -18,47 +17,33 @@ func declaredBuildAssets(result internalbuild.Result) ([]gitea.ExpectedAsset, er
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
if len(result.Artifacts) != len(settings.Targets) {
|
||||
return nil, fmt.Errorf("build result must contain one artifact for each selected target")
|
||||
declarations := make([]artifact.Declaration, 0, len(result.Artifacts))
|
||||
for _, built := range result.Artifacts {
|
||||
declarations = append(declarations, artifact.Declaration{Target: built.Target, Filename: built.Filename})
|
||||
}
|
||||
declarations := artifact.ExpectedBuild(settings.BinaryName, result.Version, settings.Targets)
|
||||
wantByTarget := make(map[artifact.Target]string, len(declarations))
|
||||
for _, declaration := range declarations {
|
||||
target := declaration.Target
|
||||
if !artifact.ValidComponent(target.OS) || !artifact.ValidComponent(target.Arch) {
|
||||
return nil, fmt.Errorf("build result contains an invalid target")
|
||||
}
|
||||
if _, exists := wantByTarget[target]; exists {
|
||||
return nil, fmt.Errorf("build result repeats target %q", target.OS+"/"+target.Arch)
|
||||
}
|
||||
wantByTarget[target] = declaration.Filename
|
||||
if err := artifact.ValidateDeclarations(settings.BinaryName, result.Version, settings.Targets, declarations); err != nil {
|
||||
return nil, fmt.Errorf("build result: %w", err)
|
||||
}
|
||||
assets := make([]gitea.ExpectedAsset, 0, len(result.Artifacts)+1)
|
||||
seenTargets := make(map[artifact.Target]bool, len(result.Artifacts))
|
||||
for _, artifact := range result.Artifacts {
|
||||
wantName, ok := wantByTarget[artifact.Target]
|
||||
if !ok || seenTargets[artifact.Target] || artifact.Filename != wantName {
|
||||
return nil, fmt.Errorf("build artifact %q does not match a selected target and its declared name", artifact.Filename)
|
||||
assets := make([]artifact.ExpectedAsset, 0, len(result.Artifacts)+1)
|
||||
for _, builtArtifact := range result.Artifacts {
|
||||
if filepath.Base(builtArtifact.Path) != builtArtifact.Filename {
|
||||
return nil, fmt.Errorf("build artifact path %q does not end with filename %q", builtArtifact.Path, builtArtifact.Filename)
|
||||
}
|
||||
seenTargets[artifact.Target] = 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)
|
||||
}
|
||||
info, err := os.Stat(artifact.Path)
|
||||
info, err := os.Stat(builtArtifact.Path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("inspect build artifact %q: %w", artifact.Filename, err)
|
||||
return nil, fmt.Errorf("inspect build artifact %q: %w", builtArtifact.Filename, err)
|
||||
}
|
||||
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", builtArtifact.Filename)
|
||||
}
|
||||
assets = append(assets, gitea.ExpectedAsset{
|
||||
Filename: artifact.Filename,
|
||||
SHA256: artifact.SHA256,
|
||||
assets = append(assets, artifact.ExpectedAsset{
|
||||
Filename: builtArtifact.Filename,
|
||||
SHA256: builtArtifact.SHA256,
|
||||
Size: info.Size(),
|
||||
Path: artifact.Path,
|
||||
Path: builtArtifact.Path,
|
||||
})
|
||||
}
|
||||
manifest, err := gitea.BuildManifest(assets)
|
||||
manifest, err := artifact.BuildManifest(assets)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build checksum manifest: %w", err)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
|
||||
"go.maximumdirect.net/releasekit/internal/artifact"
|
||||
internalbuild "go.maximumdirect.net/releasekit/internal/build"
|
||||
"go.maximumdirect.net/releasekit/internal/gitea"
|
||||
)
|
||||
|
||||
func TestDeclaredBuildAssetsGenerateManifestFromBuildResult(t *testing.T) {
|
||||
@@ -38,7 +37,7 @@ func TestDeclaredBuildAssetsGenerateManifestFromBuildResult(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result) != 3 || result[0].Filename != filepath.Base(firstPath) || result[1].Filename != filepath.Base(secondPath) || result[2].Filename != gitea.ManifestFilename {
|
||||
if len(result) != 3 || result[0].Filename != filepath.Base(firstPath) || result[1].Filename != filepath.Base(secondPath) || result[2].Filename != artifact.ManifestFilename {
|
||||
t.Fatalf("assets = %#v", result)
|
||||
}
|
||||
want := assetDigest(second) + " app-v1.2.3-darwin-arm64\n" + assetDigest(first) + " app-v1.2.3-linux-amd64\n"
|
||||
|
||||
102
internal/delivery/cleanup_test.go
Normal file
102
internal/delivery/cleanup_test.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go.maximumdirect.net/releasekit/internal/artifact"
|
||||
internalbuild "go.maximumdirect.net/releasekit/internal/build"
|
||||
"go.maximumdirect.net/releasekit/internal/forge"
|
||||
)
|
||||
|
||||
func deliveryCleanupFixture(t *testing.T) (string, string) {
|
||||
t.Helper()
|
||||
repo := t.TempDir()
|
||||
files := map[string]string{
|
||||
"go.mod": "module example.com/delivery\n\ngo 1.25.0\n",
|
||||
"main.go": "package main\nimport \"fmt\"\nfunc main() { fmt.Println(\"fixture v1.2.3\") }\n",
|
||||
"docs/releases/v1.2.3.md": "# Fixture v1.2.3\n\nNote.\n",
|
||||
}
|
||||
for name, contents := range files {
|
||||
path := filepath.Join(repo, 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)
|
||||
}
|
||||
}
|
||||
for _, args := range [][]string{{"init", "-b", "main"}, {"config", "user.name", "Fixture"}, {"config", "user.email", "fixture@example.invalid"}, {"add", "."}, {"commit", "-m", "fixture"}, {"tag", "v1.2.3"}} {
|
||||
command := exec.Command("git", args...)
|
||||
command.Dir = repo
|
||||
if data, err := command.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v: %v: %s", args, err, data)
|
||||
}
|
||||
}
|
||||
command := exec.Command("git", "rev-parse", "HEAD")
|
||||
command.Dir = repo
|
||||
commit, err := command.Output()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return repo, strings.TrimSpace(string(commit))
|
||||
}
|
||||
|
||||
type cancelBuildOutput struct{ cancel context.CancelFunc }
|
||||
|
||||
func (writer cancelBuildOutput) Write(p []byte) (int, error) {
|
||||
if strings.Contains(string(p), "building ") {
|
||||
writer.cancel()
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func TestManagedDeliveryCleanupPreservesOutcome(t *testing.T) {
|
||||
for _, mode := range []string{"complete", "build failure", "cancellation"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
repo, commit := deliveryCleanupFixture(t)
|
||||
note := "# Fixture v1.2.3\n\nNote.\n"
|
||||
backend := &assetBackend{pageBackend: pageBackend{commit: commit, findResults: []pageFindResult{{release: forge.Release{Tag: "v1.2.3", Body: note, Handle: "release", URL: "https://example.invalid/release"}}}}, lists: []assetListResult{{}}}
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
spec := internalbuild.Spec{MainPackage: ".", BinaryName: "fixture", Targets: []artifact.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}}}
|
||||
if mode == "build failure" {
|
||||
spec.MainPackage = "./missing"
|
||||
}
|
||||
request := Request{RepositoryDir: repo, Product: "Fixture", Version: "v1.2.3", Backend: backend, Build: &spec}
|
||||
if mode == "cancellation" {
|
||||
request.Output = cancelBuildOutput{cancel}
|
||||
}
|
||||
cleanupErr := errors.New("cleanup failed")
|
||||
var removed string
|
||||
result, err := deliver(ctx, request, func(path string) error {
|
||||
removed = path
|
||||
_ = os.RemoveAll(path)
|
||||
return cleanupErr
|
||||
})
|
||||
if !errors.Is(err, cleanupErr) || removed == "" || !strings.Contains(err.Error(), removed) || result.CandidateCommit != commit {
|
||||
t.Fatalf("delivery = %#v, cleanup = %q, error = %v", result, removed, err)
|
||||
}
|
||||
switch mode {
|
||||
case "complete":
|
||||
if result.State != Complete || len(result.Uploaded) != 2 || result.ReleaseURL == "" {
|
||||
t.Fatalf("completed delivery lost observations: %#v", result)
|
||||
}
|
||||
case "build failure":
|
||||
if result.State != NotAttempted || !strings.Contains(err.Error(), "build delivery assets") {
|
||||
t.Fatalf("build failure lost: %#v, %v", result, err)
|
||||
}
|
||||
case "cancellation":
|
||||
if result.State != NotAttempted || !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("cancellation lost: %#v, %v", result, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -9,10 +9,11 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"go.maximumdirect.net/releasekit/internal/artifact"
|
||||
internalbuild "go.maximumdirect.net/releasekit/internal/build"
|
||||
"go.maximumdirect.net/releasekit/internal/candidate"
|
||||
"go.maximumdirect.net/releasekit/internal/forge"
|
||||
"go.maximumdirect.net/releasekit/internal/git"
|
||||
"go.maximumdirect.net/releasekit/internal/gitea"
|
||||
"go.maximumdirect.net/releasekit/internal/gotool"
|
||||
"go.maximumdirect.net/releasekit/internal/releaseidentity"
|
||||
"go.maximumdirect.net/releasekit/internal/tempdir"
|
||||
@@ -35,7 +36,7 @@ type Request struct {
|
||||
Product string
|
||||
Version string
|
||||
ExpectedTag string
|
||||
Destination gitea.Destination
|
||||
Backend forge.Backend
|
||||
Build *internalbuild.Spec
|
||||
Prebuilt *internalbuild.Result
|
||||
Output io.Writer
|
||||
@@ -49,15 +50,19 @@ type Result struct {
|
||||
CandidateCommit string
|
||||
ReleaseURL string
|
||||
State State
|
||||
Matching []gitea.AssetIdentity
|
||||
Uploaded []gitea.AssetIdentity
|
||||
Pending []gitea.AssetIdentity
|
||||
Conflicting []gitea.AssetIdentity
|
||||
Uncertain []gitea.AssetIdentity
|
||||
Matching []AssetIdentity
|
||||
Uploaded []AssetIdentity
|
||||
Pending []AssetIdentity
|
||||
Conflicting []AssetIdentity
|
||||
Uncertain []AssetIdentity
|
||||
}
|
||||
|
||||
func Deliver(ctx context.Context, request Request) (Result, error) {
|
||||
result := Result{State: NotAttempted}
|
||||
return deliver(ctx, request, os.RemoveAll)
|
||||
}
|
||||
|
||||
func deliver(ctx context.Context, request Request, remove func(string) error) (result Result, resultErr error) {
|
||||
result = Result{State: NotAttempted}
|
||||
if !versioncontract.ValidStableVersion(request.Version) {
|
||||
return result, candidate.ErrInvalidVersion
|
||||
}
|
||||
@@ -67,6 +72,9 @@ func Deliver(ctx context.Context, request Request) (Result, error) {
|
||||
if request.Build != nil && request.Prebuilt != nil {
|
||||
return result, errors.New("delivery cannot use both a managed build and prebuilt artifacts")
|
||||
}
|
||||
if request.Backend == nil {
|
||||
return result, errors.New("delivery backend is required")
|
||||
}
|
||||
repositoryDir, err := git.RepositoryRoot(ctx, request.RepositoryDir)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("resolve repository directory: %w", err)
|
||||
@@ -111,6 +119,14 @@ func Deliver(ctx context.Context, request Request) (Result, error) {
|
||||
if err := candidate.ValidateReleaseNote(note, request.Product, request.Version); err != nil {
|
||||
return result, fmt.Errorf("release note %q: %w", identity.NotePath, err)
|
||||
}
|
||||
if request.Prebuilt != nil {
|
||||
if err := validateBuildIdentity(identity, commit, request.Prebuilt); err != nil {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
if err := request.Backend.Preflight(ctx); err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
build := request.Prebuilt
|
||||
if request.Build != nil && len(request.Build.Targets) != 0 {
|
||||
@@ -118,7 +134,11 @@ func Deliver(ctx context.Context, request Request) (Result, error) {
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("create temporary delivery build directory: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(temporaryDir)
|
||||
defer func() {
|
||||
if err := remove(temporaryDir); err != nil {
|
||||
resultErr = errors.Join(resultErr, fmt.Errorf("remove delivery build directory %q: %w", temporaryDir, err))
|
||||
}
|
||||
}()
|
||||
built, err := internalbuild.BuildResolved(ctx, internalbuild.Request{
|
||||
Identity: identity,
|
||||
Spec: *request.Build,
|
||||
@@ -131,7 +151,7 @@ func Deliver(ctx context.Context, request Request) (Result, error) {
|
||||
}
|
||||
build = &built
|
||||
}
|
||||
var assets []gitea.ExpectedAsset
|
||||
var assets []artifact.ExpectedAsset
|
||||
if build != nil {
|
||||
assets, err = declaredBuildAssets(*build)
|
||||
if err != nil {
|
||||
@@ -142,8 +162,12 @@ func Deliver(ctx context.Context, request Request) (Result, error) {
|
||||
return result, err
|
||||
}
|
||||
if len(assets) > 0 {
|
||||
if err := gitea.VerifyLocalAssets(assets); err != nil {
|
||||
return result, fmt.Errorf("verify build assets: %w", err)
|
||||
if err := artifact.VerifyLocalAssets(assets); err != nil {
|
||||
kind := forge.FailureInvalid
|
||||
if errors.Is(err, artifact.ErrAssetConflict) {
|
||||
kind = forge.FailureConflict
|
||||
}
|
||||
return result, &forge.Failure{Kind: kind, Err: fmt.Errorf("verify build assets: %w", err)}
|
||||
}
|
||||
}
|
||||
if build != nil && len(build.Settings.Targets) != 0 {
|
||||
@@ -159,50 +183,61 @@ func Deliver(ctx context.Context, request Request) (Result, error) {
|
||||
}
|
||||
}
|
||||
|
||||
releaseResult, err := (gitea.Client{}).EnsureRelease(ctx, gitea.Request{
|
||||
Destination: request.Destination,
|
||||
Tag: identity.Tag,
|
||||
Commit: commit,
|
||||
Note: string(note),
|
||||
})
|
||||
release, pageEvidence, err := coordinateRelease(ctx, request.Backend, identity.Tag, commit, string(note))
|
||||
if err != nil {
|
||||
result.State = stateForError(err, false)
|
||||
return result, err
|
||||
}
|
||||
result.ReleaseURL = releaseResult.Release.URL
|
||||
result.ReleaseURL = release.URL
|
||||
if len(assets) == 0 {
|
||||
result.State = Complete
|
||||
return result, nil
|
||||
}
|
||||
inspection, err := (gitea.Client{}).InspectAssets(ctx, request.Destination, releaseResult.Release.ID, assets)
|
||||
inspection, err := inspectRemoteAssets(ctx, request.Backend, release.Handle, assets)
|
||||
if err != nil {
|
||||
result.State = NotAttempted
|
||||
if releaseResult.WriteAttempted {
|
||||
if pageEvidence.Attempted {
|
||||
result.State = Partial
|
||||
}
|
||||
result.Uncertain = assetIdentities(assets)
|
||||
return result, err
|
||||
}
|
||||
uploadResult, uploadErr := (gitea.Client{}).UploadVerifiedMissing(ctx, request.Destination, releaseResult.Release.ID, inspection)
|
||||
uploadResult, uploadErr := uploadMissingAssets(ctx, request.Backend, release.Handle, 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)
|
||||
if result.State == NotAttempted && releaseResult.WriteAttempted {
|
||||
if result.State == NotAttempted && pageEvidence.Attempted {
|
||||
result.State = Partial
|
||||
}
|
||||
return result, uploadErr
|
||||
}
|
||||
|
||||
func validateBuild(identity releaseidentity.Identity, commit string, build *internalbuild.Result, assets []gitea.ExpectedAsset) error {
|
||||
func validateBuild(identity releaseidentity.Identity, commit string, build *internalbuild.Result, assets []artifact.ExpectedAsset) error {
|
||||
if build == nil {
|
||||
if len(assets) != 0 {
|
||||
return errors.New("assets require a build identity")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := validateBuildIdentity(identity, commit, build); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(build.Settings.Targets) == 0 {
|
||||
if len(assets) != 0 {
|
||||
return errors.New("page-only delivery cannot include binary assets")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if len(assets) != len(build.Settings.Targets)+1 || assets[len(assets)-1].Filename != artifact.ManifestFilename {
|
||||
return errors.New("build result does not contain the selected artifacts and complete checksum manifest")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateBuildIdentity(identity releaseidentity.Identity, commit string, build *internalbuild.Result) error {
|
||||
buildRepository, err := filepath.Abs(build.RepositoryDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve build repository: %w", err)
|
||||
@@ -215,27 +250,24 @@ func validateBuild(identity releaseidentity.Identity, commit string, build *inte
|
||||
return errors.New("build result does not identify the selected tagged module")
|
||||
}
|
||||
if len(build.Settings.Targets) == 0 {
|
||||
if len(assets) != 0 {
|
||||
return errors.New("page-only delivery cannot include binary assets")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !build.SourceVerified {
|
||||
return errors.New("build source was not verified as clean and unchanged during compilation")
|
||||
}
|
||||
if strings.TrimSpace(build.Settings.MainPackage) == "" || strings.TrimSpace(build.Settings.BinaryName) == "" || len(assets) != len(build.Settings.Targets)+1 || assets[len(assets)-1].Filename != gitea.ManifestFilename {
|
||||
if strings.TrimSpace(build.Settings.MainPackage) == "" || strings.TrimSpace(build.Settings.BinaryName) == "" {
|
||||
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
|
||||
var failure *forge.Failure
|
||||
if errors.As(err, &failure) {
|
||||
switch failure.Kind {
|
||||
case gitea.FailureConflict:
|
||||
case forge.FailureConflict:
|
||||
return Conflict
|
||||
case gitea.FailureUnknown:
|
||||
case forge.FailureUnknown:
|
||||
return Unknown
|
||||
}
|
||||
}
|
||||
@@ -245,10 +277,10 @@ func stateForError(err error, pageCreated bool) State {
|
||||
return NotAttempted
|
||||
}
|
||||
|
||||
func assetIdentities(assets []gitea.ExpectedAsset) []gitea.AssetIdentity {
|
||||
result := make([]gitea.AssetIdentity, 0, len(assets))
|
||||
func assetIdentities(assets []artifact.ExpectedAsset) []AssetIdentity {
|
||||
result := make([]AssetIdentity, 0, len(assets))
|
||||
for _, asset := range assets {
|
||||
result = append(result, gitea.AssetIdentity{Filename: asset.Filename, SHA256: asset.SHA256})
|
||||
result = append(result, AssetIdentity{Filename: asset.Filename, SHA256: asset.SHA256})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
91
internal/delivery/page.go
Normal file
91
internal/delivery/page.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"go.maximumdirect.net/releasekit/internal/forge"
|
||||
)
|
||||
|
||||
func coordinateRelease(ctx context.Context, backend forge.Backend, tag, commit, note string) (forge.Release, forge.WriteEvidence, error) {
|
||||
observedCommit, err := backend.TagCommit(ctx, tag)
|
||||
if err != nil {
|
||||
return forge.Release{}, forge.WriteEvidence{Disposition: forge.WriteNotAttempted}, err
|
||||
}
|
||||
if observedCommit != commit {
|
||||
return forge.Release{}, forge.WriteEvidence{Disposition: forge.WriteNotAttempted}, &forge.Failure{
|
||||
Kind: forge.FailureConflict,
|
||||
Err: fmt.Errorf("hosted tag %q targets %q, want %q", tag, observedCommit, commit),
|
||||
}
|
||||
}
|
||||
|
||||
existing, err := backend.FindRelease(ctx, tag)
|
||||
if err == nil {
|
||||
if !matchesExistingRelease(existing, tag, note) {
|
||||
return forge.Release{}, forge.WriteEvidence{Disposition: forge.WriteNotAttempted}, &forge.Failure{
|
||||
Kind: forge.FailureConflict,
|
||||
Err: errors.New("existing release does not match the committed note and stable tag"),
|
||||
}
|
||||
}
|
||||
return existing, forge.WriteEvidence{Disposition: forge.WriteNotAttempted}, nil
|
||||
}
|
||||
if !isForgeFailure(err, forge.FailureNotFound) {
|
||||
return forge.Release{}, forge.WriteEvidence{Disposition: forge.WriteNotAttempted}, err
|
||||
}
|
||||
|
||||
request := forge.CreateReleaseRequest{
|
||||
Tag: tag, Commit: commit, Title: releaseNoteTitle(note), Body: note,
|
||||
}
|
||||
created, evidence, createErr := backend.CreateRelease(ctx, request)
|
||||
if createErr == nil && matchesCreatedRelease(created, request) {
|
||||
return created, evidence, nil
|
||||
}
|
||||
if createErr == nil {
|
||||
createErr = &forge.Failure{Kind: forge.FailureMalformed, Err: errors.New("created release does not match the requested stable release")}
|
||||
}
|
||||
if evidence.Disposition != forge.WriteConflict && evidence.Disposition != forge.WriteUncertain && !isForgeFailure(createErr, forge.FailureMalformed) {
|
||||
return forge.Release{}, evidence, createErr
|
||||
}
|
||||
|
||||
return reconcileReleaseCreate(ctx, backend, tag, note, evidence, createErr)
|
||||
}
|
||||
|
||||
func reconcileReleaseCreate(ctx context.Context, backend forge.Backend, tag, note string, evidence forge.WriteEvidence, createErr error) (forge.Release, forge.WriteEvidence, error) {
|
||||
existing, lookupErr := backend.FindRelease(ctx, tag)
|
||||
if lookupErr == nil {
|
||||
if matchesExistingRelease(existing, tag, note) {
|
||||
return existing, evidence, nil
|
||||
}
|
||||
return forge.Release{}, evidence, &forge.Failure{Kind: forge.FailureConflict, Err: errors.Join(createErr, errors.New("reconciled release does not match the committed note and stable tag"))}
|
||||
}
|
||||
if isForgeFailure(lookupErr, forge.FailureNotFound) {
|
||||
if evidence.Disposition == forge.WriteConflict {
|
||||
return forge.Release{}, evidence, &forge.Failure{Kind: forge.FailureConflict, Err: createErr}
|
||||
}
|
||||
return forge.Release{}, evidence, &forge.Failure{Kind: forge.FailureUnknown, Err: createErr}
|
||||
}
|
||||
return forge.Release{}, evidence, &forge.Failure{Kind: forge.FailureUnknown, Err: errors.Join(createErr, lookupErr)}
|
||||
}
|
||||
|
||||
func matchesExistingRelease(release forge.Release, tag, note string) bool {
|
||||
return release.Tag == tag && release.Body == note && !release.Draft && !release.Prerelease
|
||||
}
|
||||
|
||||
func matchesCreatedRelease(release forge.Release, request forge.CreateReleaseRequest) bool {
|
||||
return release.Tag == request.Tag && release.Body == request.Body && release.Title == request.Title && !release.Draft && !release.Prerelease
|
||||
}
|
||||
|
||||
func releaseNoteTitle(note string) string {
|
||||
first := strings.SplitN(note, "\n", 2)[0]
|
||||
if !strings.HasPrefix(first, "# ") {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(strings.TrimPrefix(first, "# "))
|
||||
}
|
||||
|
||||
func isForgeFailure(err error, kind forge.FailureKind) bool {
|
||||
var failure *forge.Failure
|
||||
return errors.As(err, &failure) && failure.Kind == kind
|
||||
}
|
||||
204
internal/delivery/page_test.go
Normal file
204
internal/delivery/page_test.go
Normal file
@@ -0,0 +1,204 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go.maximumdirect.net/releasekit/internal/forge"
|
||||
)
|
||||
|
||||
func TestCoordinateReleaseAcceptsExistingPageAndRetainsItsTitle(t *testing.T) {
|
||||
backend := &pageBackend{
|
||||
commit: "commit",
|
||||
findResults: []pageFindResult{{release: forge.Release{
|
||||
Tag: "v1.2.3", Title: "Maintained title", Body: "# Release\n\nNotes", Handle: "release",
|
||||
}}},
|
||||
}
|
||||
|
||||
release, evidence, err := coordinateRelease(context.Background(), backend, "v1.2.3", "commit", "# Release\n\nNotes")
|
||||
if err != nil || release.Title != "Maintained title" || evidence.Attempted {
|
||||
t.Fatalf("coordinateRelease() = %#v, evidence %#v, error %v", release, evidence, err)
|
||||
}
|
||||
if backend.createCalls != 0 || backend.findCalls != 1 {
|
||||
t.Fatalf("calls = create %d, find %d", backend.createCalls, backend.findCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoordinateReleaseRejectsWrongHostedTagAndExistingPage(t *testing.T) {
|
||||
t.Run("wrong hosted tag", func(t *testing.T) {
|
||||
backend := &pageBackend{commit: "other"}
|
||||
_, evidence, err := coordinateRelease(context.Background(), backend, "v1.2.3", "commit", "# Release")
|
||||
if !isForgeFailure(err, forge.FailureConflict) || evidence.Attempted || backend.findCalls != 0 || backend.createCalls != 0 {
|
||||
t.Fatalf("coordinateRelease() evidence %#v, error %v, calls find=%d create=%d", evidence, err, backend.findCalls, backend.createCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing hosted tag", func(t *testing.T) {
|
||||
backend := &pageBackend{tagErr: &forge.Failure{Kind: forge.FailureNotFound, Err: errors.New("tag missing")}}
|
||||
_, _, err := coordinateRelease(context.Background(), backend, "v1.2.3", "commit", "# Release")
|
||||
if !isForgeFailure(err, forge.FailureNotFound) || backend.findCalls != 0 || backend.createCalls != 0 {
|
||||
t.Fatalf("coordinateRelease() error = %v, calls find=%d create=%d", err, backend.findCalls, backend.createCalls)
|
||||
}
|
||||
})
|
||||
|
||||
for _, release := range []forge.Release{
|
||||
{Tag: "v9.9.9", Body: "# Release", Title: "Release"},
|
||||
{Tag: "v1.2.3", Body: "different", Title: "Release"},
|
||||
{Tag: "v1.2.3", Body: "# Release", Title: "Release", Draft: true},
|
||||
{Tag: "v1.2.3", Body: "# Release", Title: "Release", Prerelease: true},
|
||||
} {
|
||||
backend := &pageBackend{commit: "commit", findResults: []pageFindResult{{release: release}}}
|
||||
_, _, err := coordinateRelease(context.Background(), backend, "v1.2.3", "commit", "# Release")
|
||||
if !isForgeFailure(err, forge.FailureConflict) || backend.createCalls != 0 {
|
||||
t.Fatalf("coordinateRelease(%#v) error = %v, create calls = %d", release, err, backend.createCalls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoordinateReleaseCreatesWithFullNoteAndVerifiesRequestedTitle(t *testing.T) {
|
||||
backend := &pageBackend{
|
||||
commit: "commit",
|
||||
findResults: []pageFindResult{{err: &forge.Failure{Kind: forge.FailureNotFound}}},
|
||||
createResult: pageCreateResult{
|
||||
release: forge.Release{Tag: "v1.2.3", Title: "Release", Body: "# Release\n\nFull note", Handle: "release"},
|
||||
evidence: forge.WriteEvidence{Attempted: true, Disposition: forge.WriteAccepted},
|
||||
},
|
||||
}
|
||||
|
||||
release, evidence, err := coordinateRelease(context.Background(), backend, "v1.2.3", "commit", "# Release\n\nFull note")
|
||||
if err != nil || release.Handle != "release" || evidence.Disposition != forge.WriteAccepted || !evidence.Attempted {
|
||||
t.Fatalf("coordinateRelease() = %#v, evidence %#v, error %v", release, evidence, err)
|
||||
}
|
||||
if got := backend.createRequest; got.Tag != "v1.2.3" || got.Commit != "commit" || got.Title != "Release" || got.Body != "# Release\n\nFull note" || got.Draft || got.Prerelease {
|
||||
t.Fatalf("CreateRelease request = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoordinateReleaseReconcilesCreateOutcomes(t *testing.T) {
|
||||
createErr := &forge.Failure{Kind: forge.FailureTransport, Err: errors.New("connection lost")}
|
||||
lookupErr := errors.New("lookup unavailable")
|
||||
matching := forge.Release{Tag: "v1.2.3", Title: "Original", Body: "# Release\n\nNotes", Handle: "release"}
|
||||
wrong := forge.Release{Tag: "v1.2.3", Body: "different", Handle: "other"}
|
||||
notFound := &forge.Failure{Kind: forge.FailureNotFound}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
create pageCreateResult
|
||||
followup pageFindResult
|
||||
wantKind forge.FailureKind
|
||||
wantRecovered bool
|
||||
wantCause error
|
||||
}{
|
||||
{
|
||||
name: "matching page after conflict",
|
||||
create: pageCreateResult{err: createErr, evidence: forge.WriteEvidence{Attempted: true, Disposition: forge.WriteConflict}},
|
||||
followup: pageFindResult{release: matching}, wantRecovered: true,
|
||||
},
|
||||
{
|
||||
name: "different page after uncertain write",
|
||||
create: pageCreateResult{err: createErr, evidence: forge.WriteEvidence{Attempted: true, Disposition: forge.WriteUncertain}},
|
||||
followup: pageFindResult{release: wrong}, wantKind: forge.FailureConflict,
|
||||
},
|
||||
{
|
||||
name: "missing after conflict",
|
||||
create: pageCreateResult{err: createErr, evidence: forge.WriteEvidence{Attempted: true, Disposition: forge.WriteConflict}},
|
||||
followup: pageFindResult{err: notFound}, wantKind: forge.FailureConflict,
|
||||
},
|
||||
{
|
||||
name: "missing after uncertain write",
|
||||
create: pageCreateResult{err: createErr, evidence: forge.WriteEvidence{Attempted: true, Disposition: forge.WriteUncertain}},
|
||||
followup: pageFindResult{err: notFound}, wantKind: forge.FailureUnknown, wantCause: createErr,
|
||||
},
|
||||
{
|
||||
name: "unavailable follow-up",
|
||||
create: pageCreateResult{err: createErr, evidence: forge.WriteEvidence{Attempted: true, Disposition: forge.WriteUncertain}},
|
||||
followup: pageFindResult{err: lookupErr}, wantKind: forge.FailureUnknown, wantCause: lookupErr,
|
||||
},
|
||||
{
|
||||
name: "inconsistent accepted response reconciled",
|
||||
create: pageCreateResult{release: wrong, evidence: forge.WriteEvidence{Attempted: true, Disposition: forge.WriteAccepted}},
|
||||
followup: pageFindResult{release: matching}, wantRecovered: true,
|
||||
},
|
||||
{
|
||||
name: "inconsistent accepted response missing",
|
||||
create: pageCreateResult{release: wrong, evidence: forge.WriteEvidence{Attempted: true, Disposition: forge.WriteAccepted}},
|
||||
followup: pageFindResult{err: notFound}, wantKind: forge.FailureUnknown,
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
backend := &pageBackend{
|
||||
commit: "commit",
|
||||
findResults: []pageFindResult{{err: notFound}, test.followup},
|
||||
createResult: test.create,
|
||||
}
|
||||
release, evidence, err := coordinateRelease(context.Background(), backend, "v1.2.3", "commit", "# Release\n\nNotes")
|
||||
if test.wantRecovered {
|
||||
if err != nil || release.Handle != "release" || !evidence.Attempted || backend.findCalls != 2 {
|
||||
t.Fatalf("recovered result = %#v, evidence %#v, error %v, find calls %d", release, evidence, err, backend.findCalls)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !isForgeFailure(err, test.wantKind) || !evidence.Attempted {
|
||||
t.Fatalf("result evidence %#v, error %v; want kind %s", evidence, err, test.wantKind)
|
||||
}
|
||||
if test.wantCause != nil && !errors.Is(err, test.wantCause) {
|
||||
t.Fatalf("error %v does not retain cause %v", err, test.wantCause)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type pageFindResult struct {
|
||||
release forge.Release
|
||||
err error
|
||||
}
|
||||
|
||||
type pageCreateResult struct {
|
||||
release forge.Release
|
||||
evidence forge.WriteEvidence
|
||||
err error
|
||||
}
|
||||
|
||||
type pageBackend struct {
|
||||
commit string
|
||||
tagErr error
|
||||
findResults []pageFindResult
|
||||
findCalls int
|
||||
createResult pageCreateResult
|
||||
createCalls int
|
||||
createRequest forge.CreateReleaseRequest
|
||||
}
|
||||
|
||||
func (backend *pageBackend) Preflight(context.Context) error { return nil }
|
||||
|
||||
func (backend *pageBackend) TagCommit(context.Context, string) (string, error) {
|
||||
return backend.commit, backend.tagErr
|
||||
}
|
||||
|
||||
func (backend *pageBackend) FindRelease(context.Context, string) (forge.Release, error) {
|
||||
index := backend.findCalls
|
||||
backend.findCalls++
|
||||
if index >= len(backend.findResults) {
|
||||
return forge.Release{}, &forge.Failure{Kind: forge.FailureNotFound}
|
||||
}
|
||||
result := backend.findResults[index]
|
||||
return result.release, result.err
|
||||
}
|
||||
|
||||
func (backend *pageBackend) CreateRelease(_ context.Context, request forge.CreateReleaseRequest) (forge.Release, forge.WriteEvidence, error) {
|
||||
backend.createCalls++
|
||||
backend.createRequest = request
|
||||
return backend.createResult.release, backend.createResult.evidence, backend.createResult.err
|
||||
}
|
||||
|
||||
func (*pageBackend) ListAssets(context.Context, any) ([]forge.Asset, error) { return nil, nil }
|
||||
|
||||
func (*pageBackend) OpenAsset(context.Context, any) (io.ReadCloser, error) {
|
||||
return io.NopCloser(strings.NewReader("")), nil
|
||||
}
|
||||
|
||||
func (*pageBackend) UploadAsset(context.Context, any, string, io.Reader) (forge.WriteEvidence, error) {
|
||||
return forge.WriteEvidence{}, nil
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
|
||||
"go.maximumdirect.net/releasekit/internal/artifact"
|
||||
internalbuild "go.maximumdirect.net/releasekit/internal/build"
|
||||
"go.maximumdirect.net/releasekit/internal/gitea"
|
||||
)
|
||||
|
||||
const performanceArtifactSize = 16 << 20
|
||||
@@ -45,7 +44,7 @@ func BenchmarkPerformanceAssetPreflight(b *testing.B) {
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
if err := gitea.VerifyLocalAssets(assets); err != nil {
|
||||
if err := artifact.VerifyLocalAssets(assets); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
279
internal/delivery/remote_assets.go
Normal file
279
internal/delivery/remote_assets.go
Normal file
@@ -0,0 +1,279 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sort"
|
||||
|
||||
"go.maximumdirect.net/releasekit/internal/artifact"
|
||||
"go.maximumdirect.net/releasekit/internal/forge"
|
||||
)
|
||||
|
||||
type AssetState string
|
||||
|
||||
const (
|
||||
AssetMatching AssetState = "matching"
|
||||
AssetMissing AssetState = "missing"
|
||||
AssetConflicting AssetState = "conflicting"
|
||||
AssetUnverifiable AssetState = "unverifiable"
|
||||
)
|
||||
|
||||
type AssetIdentity struct {
|
||||
Filename string
|
||||
SHA256 string
|
||||
}
|
||||
|
||||
type AssetInspection struct {
|
||||
Expected artifact.ExpectedAsset
|
||||
State AssetState
|
||||
Existing []forge.Asset
|
||||
Err error
|
||||
}
|
||||
|
||||
type Inspection struct {
|
||||
Assets []AssetInspection
|
||||
Unrelated []forge.Asset
|
||||
}
|
||||
|
||||
type UploadResult struct {
|
||||
State State
|
||||
Matching []AssetIdentity
|
||||
Uploaded []AssetIdentity
|
||||
Pending []AssetIdentity
|
||||
Conflicting []AssetIdentity
|
||||
Uncertain []AssetIdentity
|
||||
}
|
||||
|
||||
func inspectRemoteAssets(ctx context.Context, backend forge.Backend, releaseHandle any, expected []artifact.ExpectedAsset) (Inspection, error) {
|
||||
if err := artifact.ValidateExpectedAssets(expected); err != nil {
|
||||
return Inspection{}, &forge.Failure{Kind: forge.FailureInvalid, Err: err}
|
||||
}
|
||||
listed, err := backend.ListAssets(ctx, releaseHandle)
|
||||
if err != nil {
|
||||
inspection := Inspection{Assets: make([]AssetInspection, 0, len(expected))}
|
||||
for _, item := range expected {
|
||||
inspection.Assets = append(inspection.Assets, AssetInspection{Expected: item, State: AssetUnverifiable, Err: err})
|
||||
}
|
||||
return inspection, err
|
||||
}
|
||||
|
||||
byName := make(map[string][]forge.Asset, len(listed))
|
||||
for _, item := range listed {
|
||||
byName[item.Filename] = append(byName[item.Filename], item)
|
||||
}
|
||||
inspection := Inspection{Assets: make([]AssetInspection, 0, len(expected))}
|
||||
declared := make(map[string]struct{}, len(expected))
|
||||
for _, item := range expected {
|
||||
declared[item.Filename] = struct{}{}
|
||||
matches := byName[item.Filename]
|
||||
observation := AssetInspection{Expected: item, Existing: matches}
|
||||
switch len(matches) {
|
||||
case 0:
|
||||
observation.State = AssetMissing
|
||||
case 1:
|
||||
digest, sizeMatches, err := remoteAssetDigest(ctx, backend, matches[0].Handle, item.Size)
|
||||
if err != nil {
|
||||
observation.State = AssetUnverifiable
|
||||
observation.Err = err
|
||||
} else if sizeMatches && digest == item.SHA256 {
|
||||
observation.State = AssetMatching
|
||||
} else {
|
||||
observation.State = AssetConflicting
|
||||
}
|
||||
default:
|
||||
observation.State = AssetConflicting
|
||||
}
|
||||
inspection.Assets = append(inspection.Assets, observation)
|
||||
}
|
||||
for _, item := range listed {
|
||||
if _, ok := declared[item.Filename]; !ok {
|
||||
inspection.Unrelated = append(inspection.Unrelated, item)
|
||||
}
|
||||
}
|
||||
return inspection, nil
|
||||
}
|
||||
|
||||
func remoteAssetDigest(ctx context.Context, backend forge.Backend, handle any, expectedSize int64) (string, bool, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
stream, err := backend.OpenAsset(ctx, handle)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
hasher := sha256.New()
|
||||
read, readErr := io.Copy(hasher, io.LimitReader(stream, expectedSize))
|
||||
excess := false
|
||||
if readErr == nil && read == expectedSize {
|
||||
var probe [1]byte
|
||||
for attempts := 0; ; attempts++ {
|
||||
var n int
|
||||
n, readErr = stream.Read(probe[:])
|
||||
excess = n != 0
|
||||
if readErr != nil || excess {
|
||||
if readErr == io.EOF {
|
||||
readErr = nil
|
||||
}
|
||||
break
|
||||
}
|
||||
if attempts == 99 {
|
||||
readErr = io.ErrNoProgress
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
closeErr := stream.Close()
|
||||
if err := errors.Join(readErr, closeErr, ctx.Err()); err != nil {
|
||||
return "", false, &forge.Failure{Kind: forge.FailureUnverifiable, Err: err}
|
||||
}
|
||||
return hex.EncodeToString(hasher.Sum(nil)), read == expectedSize && !excess, nil
|
||||
}
|
||||
|
||||
func uploadMissingAssets(ctx context.Context, backend forge.Backend, releaseHandle any, inspection Inspection) (result UploadResult, err error) {
|
||||
expected := make([]artifact.ExpectedAsset, len(inspection.Assets))
|
||||
for index, observation := range inspection.Assets {
|
||||
expected[index] = observation.Expected
|
||||
}
|
||||
if err := artifact.ValidateExpectedAssets(expected); err != nil {
|
||||
return UploadResult{State: Partial}, &forge.Failure{Kind: forge.FailureInvalid, Err: err}
|
||||
}
|
||||
result = UploadResult{State: Partial}
|
||||
preflightConflict := false
|
||||
preflightUnverifiable := false
|
||||
var inspectionErrors []error
|
||||
var missing []artifact.ExpectedAsset
|
||||
pending := make(map[string]struct{}, len(expected))
|
||||
// Keep the initial observation order and compact pending identities once,
|
||||
// including on early returns after partial or unresolved uploads.
|
||||
defer func() {
|
||||
remaining := result.Pending[:0]
|
||||
for _, item := range result.Pending {
|
||||
if _, ok := pending[item.Filename]; ok {
|
||||
remaining = append(remaining, item)
|
||||
}
|
||||
}
|
||||
result.Pending = remaining
|
||||
}()
|
||||
for _, observation := range inspection.Assets {
|
||||
item := identity(observation.Expected)
|
||||
switch observation.State {
|
||||
case AssetMatching:
|
||||
result.Matching = append(result.Matching, item)
|
||||
case AssetMissing:
|
||||
result.Pending = append(result.Pending, item)
|
||||
pending[observation.Expected.Filename] = struct{}{}
|
||||
missing = append(missing, observation.Expected)
|
||||
case AssetConflicting:
|
||||
preflightConflict = true
|
||||
result.Conflicting = append(result.Conflicting, item)
|
||||
case AssetUnverifiable:
|
||||
preflightUnverifiable = true
|
||||
result.Uncertain = append(result.Uncertain, item)
|
||||
if observation.Err != nil {
|
||||
inspectionErrors = append(inspectionErrors, fmt.Errorf("verify existing attachment %q: %w", item.Filename, observation.Err))
|
||||
}
|
||||
default:
|
||||
return result, &forge.Failure{Kind: forge.FailureInvalid, Err: fmt.Errorf("asset %q has no inspection result", observation.Expected.Filename)}
|
||||
}
|
||||
}
|
||||
if preflightConflict {
|
||||
result.State = Conflict
|
||||
return result, &forge.Failure{Kind: forge.FailureConflict, Err: errors.Join(errors.New("one or more existing attachments conflict"), errors.Join(inspectionErrors...))}
|
||||
}
|
||||
if preflightUnverifiable {
|
||||
result.State = NotAttempted
|
||||
return result, &forge.Failure{Kind: forge.FailureUnverifiable, Err: errors.Join(errors.New("one or more existing attachments could not be verified"), errors.Join(inspectionErrors...))}
|
||||
}
|
||||
if len(pending) == 0 {
|
||||
result.State = Complete
|
||||
return result, nil
|
||||
}
|
||||
|
||||
orderMissing(missing)
|
||||
for _, item := range missing {
|
||||
content, err := assetContent(item)
|
||||
if err != nil {
|
||||
return result, &forge.Failure{Kind: forge.FailureInvalid, Err: fmt.Errorf("open local release asset %q: %w", item.Filename, err)}
|
||||
}
|
||||
evidence, uploadErr := backend.UploadAsset(ctx, releaseHandle, item.Filename, content)
|
||||
closeErr := content.Close()
|
||||
if uploadErr == nil {
|
||||
result.Uploaded = append(result.Uploaded, identity(item))
|
||||
delete(pending, item.Filename)
|
||||
continue
|
||||
}
|
||||
operationErr := errors.Join(uploadErr, closeErr)
|
||||
if evidence.Disposition != forge.WriteConflict && evidence.Disposition != forge.WriteUncertain {
|
||||
return result, operationErr
|
||||
}
|
||||
delete(pending, item.Filename)
|
||||
if err := reconcileAssetUpload(ctx, backend, releaseHandle, item, evidence, operationErr, &result); err != nil {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
if len(pending) == 0 {
|
||||
result.State = Complete
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func reconcileAssetUpload(ctx context.Context, backend forge.Backend, releaseHandle any, item artifact.ExpectedAsset, evidence forge.WriteEvidence, uploadErr error, result *UploadResult) error {
|
||||
inspection, inspectErr := inspectRemoteAssets(ctx, backend, releaseHandle, []artifact.ExpectedAsset{item})
|
||||
if inspectErr != nil || len(inspection.Assets) != 1 {
|
||||
result.State = Unknown
|
||||
result.Uncertain = append(result.Uncertain, identity(item))
|
||||
return &forge.Failure{Kind: forge.FailureUnknown, Err: errors.Join(uploadErr, inspectErr)}
|
||||
}
|
||||
observation := inspection.Assets[0]
|
||||
switch observation.State {
|
||||
case AssetMatching:
|
||||
result.Matching = append(result.Matching, identity(item))
|
||||
return nil
|
||||
case AssetConflicting:
|
||||
result.State = Conflict
|
||||
result.Conflicting = append(result.Conflicting, identity(item))
|
||||
return &forge.Failure{Kind: forge.FailureConflict, Err: uploadErr}
|
||||
case AssetMissing:
|
||||
if evidence.Disposition == forge.WriteConflict {
|
||||
result.State = Conflict
|
||||
result.Conflicting = append(result.Conflicting, identity(item))
|
||||
return &forge.Failure{Kind: forge.FailureConflict, Err: uploadErr}
|
||||
}
|
||||
result.State = Unknown
|
||||
result.Uncertain = append(result.Uncertain, identity(item))
|
||||
return &forge.Failure{Kind: forge.FailureUnknown, Err: uploadErr}
|
||||
default:
|
||||
result.State = Unknown
|
||||
result.Uncertain = append(result.Uncertain, identity(item))
|
||||
return &forge.Failure{Kind: forge.FailureUnknown, Err: errors.Join(uploadErr, observation.Err)}
|
||||
}
|
||||
}
|
||||
|
||||
func orderMissing(missing []artifact.ExpectedAsset) {
|
||||
sort.Slice(missing, func(i, j int) bool {
|
||||
if missing[i].Filename == artifact.ManifestFilename {
|
||||
return false
|
||||
}
|
||||
if missing[j].Filename == artifact.ManifestFilename {
|
||||
return true
|
||||
}
|
||||
return missing[i].Filename < missing[j].Filename
|
||||
})
|
||||
}
|
||||
|
||||
func assetContent(asset artifact.ExpectedAsset) (io.ReadCloser, error) {
|
||||
if len(asset.Content) > 0 {
|
||||
return io.NopCloser(bytes.NewReader(asset.Content)), nil
|
||||
}
|
||||
return os.Open(asset.Path)
|
||||
}
|
||||
|
||||
func identity(asset artifact.ExpectedAsset) AssetIdentity {
|
||||
return AssetIdentity{Filename: asset.Filename, SHA256: asset.SHA256}
|
||||
}
|
||||
543
internal/delivery/remote_assets_test.go
Normal file
543
internal/delivery/remote_assets_test.go
Normal file
@@ -0,0 +1,543 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go.maximumdirect.net/releasekit/internal/artifact"
|
||||
"go.maximumdirect.net/releasekit/internal/forge"
|
||||
)
|
||||
|
||||
func TestInspectRemoteAssetsComparesContentAndPreservesUnrelatedAssets(t *testing.T) {
|
||||
backend := &assetBackend{
|
||||
lists: []assetListResult{{assets: []forge.Asset{
|
||||
{Filename: "same.bin", Size: 4, Handle: "same"},
|
||||
{Filename: "different.bin", Size: 4, Handle: "different"},
|
||||
{Filename: "duplicate.bin", Size: 4, Handle: "first"},
|
||||
{Filename: "duplicate.bin", Size: 4, Handle: "second"},
|
||||
{Filename: "unrelated.txt", Size: 3, Handle: "unrelated"},
|
||||
}}},
|
||||
content: map[string][]byte{
|
||||
"same": []byte("same"),
|
||||
"different": []byte("diff"),
|
||||
},
|
||||
}
|
||||
expected := []artifact.ExpectedAsset{
|
||||
expectedAsset("same.bin", []byte("same")),
|
||||
expectedAsset("different.bin", []byte("else")),
|
||||
expectedAsset("duplicate.bin", []byte("dupe")),
|
||||
expectedAsset("missing.bin", []byte("gone")),
|
||||
}
|
||||
|
||||
inspection, err := inspectRemoteAssets(context.Background(), backend, "release", expected)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantStates := []AssetState{AssetMatching, AssetConflicting, AssetConflicting, AssetMissing}
|
||||
for i, observation := range inspection.Assets {
|
||||
if observation.State != wantStates[i] {
|
||||
t.Errorf("asset %q state = %s, want %s", observation.Expected.Filename, observation.State, wantStates[i])
|
||||
}
|
||||
}
|
||||
if len(inspection.Unrelated) != 1 || inspection.Unrelated[0].Filename != "unrelated.txt" {
|
||||
t.Fatalf("unrelated assets = %#v", inspection.Unrelated)
|
||||
}
|
||||
if len(backend.opened) != 2 || !backend.opened[0].closed || !backend.opened[1].closed {
|
||||
t.Fatalf("opened asset streams = %#v", backend.opened)
|
||||
}
|
||||
}
|
||||
|
||||
type controlledAssetStream struct {
|
||||
*bytes.Reader
|
||||
readErr error
|
||||
errorAfter int
|
||||
closeErr error
|
||||
cancel context.CancelFunc
|
||||
read int
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (stream *controlledAssetStream) Read(p []byte) (int, error) {
|
||||
n, err := stream.Reader.Read(p)
|
||||
stream.read += n
|
||||
if stream.cancel != nil {
|
||||
stream.cancel()
|
||||
stream.cancel = nil
|
||||
}
|
||||
if stream.readErr != nil && n > 0 && stream.read > stream.errorAfter {
|
||||
err, stream.readErr = stream.readErr, nil
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (stream *controlledAssetStream) Close() error {
|
||||
stream.closed = true
|
||||
return stream.closeErr
|
||||
}
|
||||
|
||||
type controlledAssetBackend struct {
|
||||
pageBackend
|
||||
stream *controlledAssetStream
|
||||
}
|
||||
|
||||
func (backend *controlledAssetBackend) ListAssets(context.Context, any) ([]forge.Asset, error) {
|
||||
return []forge.Asset{{Filename: "asset.bin", Size: 999, Handle: "asset"}}, nil
|
||||
}
|
||||
|
||||
func (backend *controlledAssetBackend) OpenAsset(context.Context, any) (io.ReadCloser, error) {
|
||||
return backend.stream, nil
|
||||
}
|
||||
|
||||
func TestInspectRemoteAssetBoundsContentAndPreservesErrors(t *testing.T) {
|
||||
readErr := errors.New("read failed")
|
||||
closeErr := errors.New("close failed")
|
||||
for _, test := range []struct {
|
||||
name, expected, actual string
|
||||
readErr, closeErr error
|
||||
cancel bool
|
||||
errorAfter int
|
||||
state AssetState
|
||||
}{
|
||||
{"empty", "", "", nil, nil, false, 0, AssetMatching},
|
||||
{"shorter", "abcd", "abc", nil, nil, false, 0, AssetConflicting},
|
||||
{"matching", "abcd", "abcd", nil, nil, false, 0, AssetMatching},
|
||||
{"different", "abcd", "wxyz", nil, nil, false, 0, AssetConflicting},
|
||||
{"oversized", "abcd", "abcdefghijk", nil, nil, false, 0, AssetConflicting},
|
||||
{"empty oversized", "", "abc", nil, nil, false, 0, AssetConflicting},
|
||||
{"data and error", "abcd", "abcd", readErr, nil, false, 0, AssetUnverifiable},
|
||||
{"probe data and error", "abcd", "abcde", readErr, nil, false, 4, AssetUnverifiable},
|
||||
{"close failure", "abcd", "abcdefgh", nil, closeErr, false, 0, AssetUnverifiable},
|
||||
{"cancellation", "abcd", "abcd", nil, nil, true, 0, AssetUnverifiable},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
stream := &controlledAssetStream{Reader: bytes.NewReader([]byte(test.actual)), readErr: test.readErr, errorAfter: test.errorAfter, closeErr: test.closeErr}
|
||||
if test.cancel {
|
||||
stream.cancel = cancel
|
||||
}
|
||||
backend := &controlledAssetBackend{stream: stream}
|
||||
inspection, err := inspectRemoteAssets(ctx, backend, "release", []artifact.ExpectedAsset{expectedAsset("asset.bin", []byte(test.expected))})
|
||||
if err != nil || len(inspection.Assets) != 1 || inspection.Assets[0].State != test.state || !stream.closed || stream.read > len(test.expected)+1 {
|
||||
t.Fatalf("inspection = %#v, error = %v, bytes = %d, closed = %t", inspection, err, stream.read, stream.closed)
|
||||
}
|
||||
cause := inspection.Assets[0].Err
|
||||
if test.readErr != nil && !errors.Is(cause, test.readErr) || test.closeErr != nil && !errors.Is(cause, test.closeErr) || test.cancel && !errors.Is(cause, context.Canceled) {
|
||||
t.Fatalf("inspection lost cause: %v", cause)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectRemoteAssetsListingFailureDoesNotBecomeUnknownWrite(t *testing.T) {
|
||||
assets := []artifact.ExpectedAsset{expectedAsset("a.bin", []byte("a")), expectedAsset("b.bin", []byte("b"))}
|
||||
listErr := &forge.Failure{Kind: forge.FailureTransport, Err: errors.New("service unavailable")}
|
||||
backend := &assetBackend{lists: []assetListResult{{err: listErr}}}
|
||||
inspection, err := inspectRemoteAssets(context.Background(), backend, "release", assets)
|
||||
if !errors.Is(err, listErr) || len(inspection.Assets) != len(assets) {
|
||||
t.Fatalf("inspection = %#v, error %v", inspection, err)
|
||||
}
|
||||
for _, observation := range inspection.Assets {
|
||||
if observation.State != AssetUnverifiable || !errors.Is(observation.Err, listErr) {
|
||||
t.Fatalf("listing failure observation = %#v", observation)
|
||||
}
|
||||
}
|
||||
result, err := uploadMissingAssets(context.Background(), backend, "release", inspection)
|
||||
if !isForgeFailure(err, forge.FailureUnverifiable) || result.State != NotAttempted || len(result.Uncertain) != len(assets) || len(backend.uploadNames) != 0 {
|
||||
t.Fatalf("uploadMissingAssets() = %#v, error %v, uploads %v", result, err, backend.uploadNames)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectRemoteAssetsKeepsMatchingManifestWhenBinariesAreMissing(t *testing.T) {
|
||||
binary := expectedAsset("app-linux-amd64", []byte("binary"))
|
||||
manifest, err := artifact.BuildManifest([]artifact.ExpectedAsset{binary})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backend := &assetBackend{
|
||||
lists: []assetListResult{{assets: []forge.Asset{{Filename: manifest.Filename, Size: int64(len(manifest.Content)), Handle: "manifest"}}}},
|
||||
content: map[string][]byte{"manifest": manifest.Content},
|
||||
}
|
||||
inspection, err := inspectRemoteAssets(context.Background(), backend, "release", []artifact.ExpectedAsset{binary, manifest})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if inspection.Assets[0].State != AssetMissing || inspection.Assets[1].State != AssetMatching {
|
||||
t.Fatalf("manifest inspection = %#v", inspection.Assets)
|
||||
}
|
||||
result, err := uploadMissingAssets(context.Background(), backend, "release", inspection)
|
||||
if err != nil || result.State != Complete || !reflect.DeepEqual(result.Uploaded, []AssetIdentity{identity(binary)}) {
|
||||
t.Fatalf("uploadMissingAssets() = %#v, error %v", result, err)
|
||||
}
|
||||
if !reflect.DeepEqual(backend.uploadNames, []string{binary.Filename}) {
|
||||
t.Fatalf("uploaded names = %v", backend.uploadNames)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectRemoteAssetsRejectsSubsetOrDifferentManifestContent(t *testing.T) {
|
||||
binaryA := expectedAsset("a", []byte("a"))
|
||||
binaryB := expectedAsset("b", []byte("b"))
|
||||
manifest, err := artifact.BuildManifest([]artifact.ExpectedAsset{binaryA, binaryB})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
content []byte
|
||||
}{
|
||||
{name: "subset", content: []byte("a a\n")},
|
||||
{name: "different", content: []byte("different manifest\n")},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
backend := &assetBackend{
|
||||
lists: []assetListResult{{assets: []forge.Asset{{Filename: artifact.ManifestFilename, Handle: "manifest"}}}},
|
||||
content: map[string][]byte{"manifest": test.content},
|
||||
}
|
||||
inspection, err := inspectRemoteAssets(context.Background(), backend, "release", []artifact.ExpectedAsset{binaryA, binaryB, manifest})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if inspection.Assets[2].State != AssetConflicting {
|
||||
t.Fatalf("manifest state = %s, want conflicting", inspection.Assets[2].State)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadMissingAssetsPreflightsAndOrdersWrites(t *testing.T) {
|
||||
assets := []artifact.ExpectedAsset{
|
||||
expectedAsset("z.bin", []byte("z")),
|
||||
expectedAsset(artifact.ManifestFilename, []byte("manifest")),
|
||||
expectedAsset("a.bin", []byte("a")),
|
||||
}
|
||||
inspection := inspectionFor(assets, map[string]AssetState{
|
||||
"z.bin": AssetMissing,
|
||||
artifact.ManifestFilename: AssetMissing,
|
||||
"a.bin": AssetMissing,
|
||||
})
|
||||
backend := &assetBackend{uploads: []assetUploadResult{
|
||||
{evidence: forge.WriteEvidence{Attempted: true, Disposition: forge.WriteAccepted}},
|
||||
{evidence: forge.WriteEvidence{Attempted: true, Disposition: forge.WriteAccepted}},
|
||||
{evidence: forge.WriteEvidence{Attempted: true, Disposition: forge.WriteAccepted}},
|
||||
}}
|
||||
result, err := uploadMissingAssets(context.Background(), backend, "release", inspection)
|
||||
if err != nil || result.State != Complete {
|
||||
t.Fatalf("uploadMissingAssets() = %#v, error %v", result, err)
|
||||
}
|
||||
want := []string{"a.bin", "z.bin", artifact.ManifestFilename}
|
||||
if !reflect.DeepEqual(backend.uploadNames, want) {
|
||||
t.Fatalf("upload order = %v, want %v", backend.uploadNames, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadMissingAssetsStopsBeforeWritesForConflictOrUnverifiableContent(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
state AssetState
|
||||
want State
|
||||
}{
|
||||
{name: "conflict", state: AssetConflicting, want: Conflict},
|
||||
{name: "unverifiable", state: AssetUnverifiable, want: NotAttempted},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
assets := []artifact.ExpectedAsset{expectedAsset("existing", []byte("old")), expectedAsset("missing", []byte("new"))}
|
||||
inspection := inspectionFor(assets, map[string]AssetState{"existing": test.state, "missing": AssetMissing})
|
||||
result, err := uploadMissingAssets(context.Background(), &assetBackend{}, "release", inspection)
|
||||
if err == nil || result.State != test.want || len(result.Pending) != 1 || len(result.Conflicting)+len(result.Uncertain) != 1 {
|
||||
t.Fatalf("uploadMissingAssets() = %#v, error %v", result, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadMissingAssetsPreservesInspectionCausesAndConflictPrecedence(t *testing.T) {
|
||||
readErr := errors.New("asset download failed")
|
||||
for _, conflict := range []bool{false, true} {
|
||||
name := "unverifiable"
|
||||
if conflict {
|
||||
name = "conflict"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
backend := &assetBackend{}
|
||||
inspection := Inspection{Assets: []AssetInspection{
|
||||
{Expected: expectedAsset("canceled.bin", []byte("a")), State: AssetUnverifiable, Err: context.Canceled},
|
||||
{Expected: expectedAsset("unreadable.bin", []byte("b")), State: AssetUnverifiable, Err: readErr},
|
||||
}}
|
||||
wantState, wantKind := NotAttempted, forge.FailureUnverifiable
|
||||
if conflict {
|
||||
inspection.Assets = append(inspection.Assets, AssetInspection{Expected: expectedAsset("conflicting.bin", []byte("c")), State: AssetConflicting})
|
||||
wantState, wantKind = Conflict, forge.FailureConflict
|
||||
}
|
||||
result, err := uploadMissingAssets(context.Background(), backend, "release", inspection)
|
||||
if !isForgeFailure(err, wantKind) || result.State != wantState || len(result.Uncertain) != 2 || len(backend.uploadNames) != 0 {
|
||||
t.Fatalf("result = %#v, error %v", result, err)
|
||||
}
|
||||
if !errors.Is(err, context.Canceled) || !errors.Is(err, readErr) || !strings.Contains(err.Error(), "canceled.bin") || !strings.Contains(err.Error(), "unreadable.bin") {
|
||||
t.Fatalf("aggregate lost cause or asset context: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadMissingAssetsReconcilesOnceAndKeepsLaterAssetsPending(t *testing.T) {
|
||||
assets := []artifact.ExpectedAsset{
|
||||
expectedAsset("a.bin", []byte("a")),
|
||||
expectedAsset("b.bin", []byte("b")),
|
||||
expectedAsset("c.bin", []byte("c")),
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
disposition forge.WriteDisposition
|
||||
followupAssets []forge.Asset
|
||||
followupData map[string][]byte
|
||||
openErrors map[string]error
|
||||
followupErr error
|
||||
wantState State
|
||||
wantCategory string
|
||||
wantContinue bool
|
||||
}{
|
||||
{
|
||||
name: "matching bytes resolve uncertain upload", disposition: forge.WriteUncertain,
|
||||
followupAssets: []forge.Asset{{Filename: "a.bin", Handle: "a"}}, followupData: map[string][]byte{"a": []byte("a")},
|
||||
wantState: Complete, wantCategory: "matching", wantContinue: true,
|
||||
},
|
||||
{
|
||||
name: "different bytes conflict", disposition: forge.WriteUncertain,
|
||||
followupAssets: []forge.Asset{{Filename: "a.bin", Handle: "a"}}, followupData: map[string][]byte{"a": []byte("x")},
|
||||
wantState: Conflict, wantCategory: "conflicting",
|
||||
},
|
||||
{
|
||||
name: "oversized bytes conflict", disposition: forge.WriteUncertain,
|
||||
followupAssets: []forge.Asset{{Filename: "a.bin", Handle: "a"}}, followupData: map[string][]byte{"a": []byte("oversized")},
|
||||
wantState: Conflict, wantCategory: "conflicting",
|
||||
},
|
||||
{
|
||||
name: "missing after conflict remains conflict", disposition: forge.WriteConflict,
|
||||
wantState: Conflict, wantCategory: "conflicting",
|
||||
},
|
||||
{
|
||||
name: "missing after uncertain upload is unknown", disposition: forge.WriteUncertain,
|
||||
wantState: Unknown, wantCategory: "uncertain",
|
||||
},
|
||||
{
|
||||
name: "unreadable follow-up is unknown", disposition: forge.WriteUncertain,
|
||||
followupAssets: []forge.Asset{{Filename: "a.bin", Handle: "a"}}, openErrors: map[string]error{"a": errors.New("download failed")},
|
||||
wantState: Unknown, wantCategory: "uncertain",
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
first := assetListResult{assets: []forge.Asset{}}
|
||||
followup := assetListResult{assets: test.followupAssets, err: test.followupErr}
|
||||
backend := &assetBackend{
|
||||
lists: []assetListResult{first, followup},
|
||||
content: test.followupData,
|
||||
openErrors: test.openErrors,
|
||||
uploads: []assetUploadResult{{
|
||||
evidence: forge.WriteEvidence{Attempted: true, Disposition: test.disposition},
|
||||
err: &forge.Failure{Kind: failureKindForDisposition(test.disposition), Err: errors.New("upload outcome unresolved")},
|
||||
}},
|
||||
}
|
||||
inspection, err := inspectRemoteAssets(context.Background(), backend, "release", assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, uploadErr := uploadMissingAssets(context.Background(), backend, "release", inspection)
|
||||
if result.State != test.wantState || backend.listCalls != 2 {
|
||||
t.Fatalf("result = %#v, error %v, list calls %d, uploads %v", result, uploadErr, backend.listCalls, backend.uploadNames)
|
||||
}
|
||||
if test.wantContinue {
|
||||
if uploadErr != nil || !reflect.DeepEqual(backend.uploadNames, []string{"a.bin", "b.bin", "c.bin"}) || len(result.Pending) != 0 {
|
||||
t.Fatalf("continued result = %#v, error %v, uploads %v", result, uploadErr, backend.uploadNames)
|
||||
}
|
||||
} else {
|
||||
if uploadErr == nil || !reflect.DeepEqual(backend.uploadNames, []string{"a.bin"}) {
|
||||
t.Fatalf("stopped result = %#v, error %v, uploads %v", result, uploadErr, backend.uploadNames)
|
||||
}
|
||||
if !reflect.DeepEqual(result.Pending, []AssetIdentity{identity(assets[1]), identity(assets[2])}) {
|
||||
t.Fatalf("pending = %#v", result.Pending)
|
||||
}
|
||||
}
|
||||
var category []AssetIdentity
|
||||
switch test.wantCategory {
|
||||
case "matching":
|
||||
category = result.Matching
|
||||
case "conflicting":
|
||||
category = result.Conflicting
|
||||
case "uncertain":
|
||||
category = result.Uncertain
|
||||
}
|
||||
if len(category) != 1 || category[0].Filename != "a.bin" {
|
||||
t.Fatalf("%s assets = %#v", test.wantCategory, category)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadMissingAssetsPreservesAcknowledgedProgressOnDefinitiveFailure(t *testing.T) {
|
||||
assets := []artifact.ExpectedAsset{expectedAsset("a.bin", []byte("a")), expectedAsset("b.bin", []byte("b")), expectedAsset("c.bin", []byte("c"))}
|
||||
backend := &assetBackend{uploads: []assetUploadResult{
|
||||
{evidence: forge.WriteEvidence{Attempted: true, Disposition: forge.WriteAccepted}},
|
||||
{evidence: forge.WriteEvidence{Attempted: true, Disposition: forge.WriteRejected}, err: &forge.Failure{Kind: forge.FailureRejected, Err: errors.New("denied")}},
|
||||
}}
|
||||
result, err := uploadMissingAssets(context.Background(), backend, "release", inspectionFor(assets, map[string]AssetState{
|
||||
"a.bin": AssetMissing, "b.bin": AssetMissing, "c.bin": AssetMissing,
|
||||
}))
|
||||
if err == nil || result.State != Partial || !reflect.DeepEqual(result.Uploaded, []AssetIdentity{identity(assets[0])}) || !reflect.DeepEqual(result.Pending, []AssetIdentity{identity(assets[1]), identity(assets[2])}) {
|
||||
t.Fatalf("uploadMissingAssets() = %#v, error %v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadMissingAssetsRetainsPendingObservationOrderOnEarlyReturn(t *testing.T) {
|
||||
assets := []artifact.ExpectedAsset{
|
||||
expectedAsset("z.bin", []byte("z")),
|
||||
expectedAsset("a.bin", []byte("a")),
|
||||
expectedAsset("c.bin", []byte("c")),
|
||||
expectedAsset("b.bin", []byte("b")),
|
||||
}
|
||||
for _, disposition := range []forge.WriteDisposition{forge.WriteRejected, forge.WriteUncertain} {
|
||||
t.Run(string(disposition), func(t *testing.T) {
|
||||
backend := &assetBackend{uploads: []assetUploadResult{
|
||||
{evidence: forge.WriteEvidence{Attempted: true, Disposition: forge.WriteAccepted}},
|
||||
{evidence: forge.WriteEvidence{Attempted: true, Disposition: disposition}, err: errors.New("upload stopped")},
|
||||
}}
|
||||
result, err := uploadMissingAssets(context.Background(), backend, "release", inspectionFor(assets, map[string]AssetState{
|
||||
"z.bin": AssetMissing, "a.bin": AssetMissing, "c.bin": AssetMissing, "b.bin": AssetMissing,
|
||||
}))
|
||||
wantPending := []AssetIdentity{identity(assets[0]), identity(assets[2]), identity(assets[3])}
|
||||
wantState := Partial
|
||||
if disposition == forge.WriteUncertain {
|
||||
wantPending = wantPending[:2]
|
||||
wantState = Unknown
|
||||
if !reflect.DeepEqual(result.Uncertain, []AssetIdentity{identity(assets[3])}) {
|
||||
t.Fatalf("uncertain = %#v", result.Uncertain)
|
||||
}
|
||||
}
|
||||
if err == nil || result.State != wantState || !reflect.DeepEqual(result.Pending, wantPending) || !reflect.DeepEqual(result.Uploaded, []AssetIdentity{identity(assets[1])}) {
|
||||
t.Fatalf("result = %#v, error %v; want pending %#v", result, err, wantPending)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadMissingAssetsBorrowsReaderAndClosesItAfterCall(t *testing.T) {
|
||||
filePath := t.TempDir() + "/asset.bin"
|
||||
if err := os.WriteFile(filePath, []byte("asset"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
asset := artifact.ExpectedAsset{Filename: "asset.bin", SHA256: assetDigest([]byte("asset")), Size: 5, Path: filePath}
|
||||
backend := &assetBackend{uploads: []assetUploadResult{{evidence: forge.WriteEvidence{Attempted: true, Disposition: forge.WriteAccepted}}}}
|
||||
result, err := uploadMissingAssets(context.Background(), backend, "release", inspectionFor([]artifact.ExpectedAsset{asset}, map[string]AssetState{"asset.bin": AssetMissing}))
|
||||
if err != nil || result.State != Complete || !backend.readerWasOpen {
|
||||
t.Fatalf("uploadMissingAssets() = %#v, error %v, reader open during call %v", result, err, backend.readerWasOpen)
|
||||
}
|
||||
if backend.borrowed == nil {
|
||||
t.Fatal("backend did not receive the file reader")
|
||||
}
|
||||
if _, err := backend.borrowed.Stat(); !errors.Is(err, os.ErrClosed) {
|
||||
t.Fatalf("borrowed file stat error = %v, want closed file", err)
|
||||
}
|
||||
}
|
||||
|
||||
type assetListResult struct {
|
||||
assets []forge.Asset
|
||||
err error
|
||||
}
|
||||
|
||||
type assetUploadResult struct {
|
||||
evidence forge.WriteEvidence
|
||||
err error
|
||||
}
|
||||
|
||||
type assetBackend struct {
|
||||
pageBackend
|
||||
lists []assetListResult
|
||||
listCalls int
|
||||
content map[string][]byte
|
||||
openErrors map[string]error
|
||||
opened []*trackedAssetStream
|
||||
uploads []assetUploadResult
|
||||
uploadNames []string
|
||||
uploadContent [][]byte
|
||||
borrowed *os.File
|
||||
readerWasOpen bool
|
||||
}
|
||||
|
||||
func (backend *assetBackend) ListAssets(context.Context, any) ([]forge.Asset, error) {
|
||||
index := backend.listCalls
|
||||
backend.listCalls++
|
||||
if index >= len(backend.lists) {
|
||||
return nil, errors.New("unexpected asset listing")
|
||||
}
|
||||
result := backend.lists[index]
|
||||
return result.assets, result.err
|
||||
}
|
||||
|
||||
func (backend *assetBackend) OpenAsset(_ context.Context, handle any) (io.ReadCloser, error) {
|
||||
key, ok := handle.(string)
|
||||
if !ok {
|
||||
return nil, errors.New("unexpected asset handle")
|
||||
}
|
||||
if err := backend.openErrors[key]; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stream := &trackedAssetStream{Reader: bytes.NewReader(backend.content[key])}
|
||||
backend.opened = append(backend.opened, stream)
|
||||
return stream, nil
|
||||
}
|
||||
|
||||
func (backend *assetBackend) UploadAsset(_ context.Context, _ any, filename string, content io.Reader) (forge.WriteEvidence, error) {
|
||||
backend.uploadNames = append(backend.uploadNames, filename)
|
||||
if file, ok := content.(*os.File); ok {
|
||||
backend.borrowed = file
|
||||
}
|
||||
data, err := io.ReadAll(content)
|
||||
backend.uploadContent = append(backend.uploadContent, data)
|
||||
if backend.borrowed != nil {
|
||||
_, statErr := backend.borrowed.Stat()
|
||||
backend.readerWasOpen = statErr == nil
|
||||
}
|
||||
index := len(backend.uploadNames) - 1
|
||||
if err != nil {
|
||||
return forge.WriteEvidence{Attempted: true, Disposition: forge.WriteUncertain}, err
|
||||
}
|
||||
if index >= len(backend.uploads) {
|
||||
return forge.WriteEvidence{Attempted: true, Disposition: forge.WriteAccepted}, nil
|
||||
}
|
||||
result := backend.uploads[index]
|
||||
return result.evidence, result.err
|
||||
}
|
||||
|
||||
type trackedAssetStream struct {
|
||||
*bytes.Reader
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (stream *trackedAssetStream) Close() error {
|
||||
stream.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func expectedAsset(filename string, content []byte) artifact.ExpectedAsset {
|
||||
return artifact.ExpectedAsset{Filename: filename, SHA256: assetDigest(content), Size: int64(len(content)), Content: content}
|
||||
}
|
||||
|
||||
func inspectionFor(assets []artifact.ExpectedAsset, states map[string]AssetState) Inspection {
|
||||
inspection := Inspection{Assets: make([]AssetInspection, 0, len(assets))}
|
||||
for _, item := range assets {
|
||||
inspection.Assets = append(inspection.Assets, AssetInspection{Expected: item, State: states[item.Filename]})
|
||||
}
|
||||
return inspection
|
||||
}
|
||||
|
||||
func failureKindForDisposition(disposition forge.WriteDisposition) forge.FailureKind {
|
||||
if disposition == forge.WriteConflict {
|
||||
return forge.FailureConflict
|
||||
}
|
||||
return forge.FailureTransport
|
||||
}
|
||||
153
internal/forge/forge.go
Normal file
153
internal/forge/forge.go
Normal file
@@ -0,0 +1,153 @@
|
||||
// Package forge defines the backend-neutral operations used by hosted delivery.
|
||||
package forge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
)
|
||||
|
||||
// Backend provides the hosted operations needed by the shared delivery
|
||||
// workflow. Implementations own destination-specific rules and protocol
|
||||
// details; the shared workflow uses only the logical values and evidence in
|
||||
// this package.
|
||||
type Backend interface {
|
||||
// Preflight verifies credentials and access to the configured destination
|
||||
// using read-only operations. It does not depend on a source tag or release.
|
||||
Preflight(ctx context.Context) error
|
||||
// TagCommit returns the commit observed for the complete tag.
|
||||
TagCommit(ctx context.Context, tag string) (string, error)
|
||||
// FindRelease returns the release for the complete tag. A missing release
|
||||
// is reported with FailureNotFound.
|
||||
FindRelease(ctx context.Context, tag string) (Release, error)
|
||||
// CreateRelease creates the requested release and returns the protocol
|
||||
// evidence even when an error occurs.
|
||||
CreateRelease(ctx context.Context, request CreateReleaseRequest) (Release, WriteEvidence, error)
|
||||
// ListAssets returns the assets attached to the release identified by handle.
|
||||
ListAssets(ctx context.Context, releaseHandle any) ([]Asset, error)
|
||||
// OpenAsset opens the remote asset identified by handle. The caller owns and
|
||||
// must close the returned stream, including when reading it fails.
|
||||
OpenAsset(ctx context.Context, assetHandle any) (io.ReadCloser, error)
|
||||
// UploadAsset uploads content to the release identified by handle. The
|
||||
// backend borrows content for the duration of this call and must finish any
|
||||
// request or multipart work before returning; it must not close content.
|
||||
UploadAsset(ctx context.Context, releaseHandle any, filename string, content io.Reader) (WriteEvidence, error)
|
||||
}
|
||||
|
||||
// Release contains the logical release properties used by shared delivery.
|
||||
// Handle is opaque to shared code and is interpreted only by the backend that
|
||||
// produced it.
|
||||
type Release struct {
|
||||
URL string
|
||||
Tag string
|
||||
Title string
|
||||
Body string
|
||||
Draft bool
|
||||
Prerelease bool
|
||||
Handle any
|
||||
}
|
||||
|
||||
// CreateReleaseRequest contains the release properties requested by shared
|
||||
// delivery.
|
||||
type CreateReleaseRequest struct {
|
||||
Tag string
|
||||
Commit string
|
||||
Title string
|
||||
Body string
|
||||
Draft bool
|
||||
Prerelease bool
|
||||
}
|
||||
|
||||
// Asset contains the remote properties needed to compare an attachment with a
|
||||
// declared local asset. Handle is opaque to shared code and is interpreted only
|
||||
// by the backend that produced it.
|
||||
type Asset struct {
|
||||
Filename string
|
||||
Size int64
|
||||
Handle any
|
||||
}
|
||||
|
||||
// PreflightFailureKind classifies a failure to verify destination settings,
|
||||
// credentials, or repository access.
|
||||
type PreflightFailureKind string
|
||||
|
||||
const (
|
||||
PreflightInvalidDestination PreflightFailureKind = "invalid_destination"
|
||||
PreflightMissingCredential PreflightFailureKind = "missing_credential"
|
||||
PreflightInvalidCredential PreflightFailureKind = "invalid_credential"
|
||||
PreflightInsufficientPermissions PreflightFailureKind = "insufficient_permissions"
|
||||
PreflightInaccessibleDestination PreflightFailureKind = "inaccessible_destination"
|
||||
PreflightUnavailable PreflightFailureKind = "unavailable"
|
||||
)
|
||||
|
||||
// PreflightFailure reports a classified credential or destination access
|
||||
// failure. Err retains the underlying cause for diagnostics and cancellation
|
||||
// identity.
|
||||
type PreflightFailure struct {
|
||||
Kind PreflightFailureKind
|
||||
Err error
|
||||
}
|
||||
|
||||
func (f *PreflightFailure) Error() string {
|
||||
if f.Err != nil {
|
||||
return string(f.Kind) + ": " + f.Err.Error()
|
||||
}
|
||||
return string(f.Kind)
|
||||
}
|
||||
|
||||
// Unwrap returns the cause of the preflight failure, if any.
|
||||
func (f *PreflightFailure) Unwrap() error { return f.Err }
|
||||
|
||||
// FailureKind classifies a backend-neutral delivery operation failure.
|
||||
type FailureKind string
|
||||
|
||||
const (
|
||||
FailureInvalid FailureKind = "invalid_request"
|
||||
FailureNotFound FailureKind = "not_found"
|
||||
FailureUnauthorized FailureKind = "unauthorized"
|
||||
FailureConflict FailureKind = "conflict"
|
||||
FailureMalformed FailureKind = "malformed_response"
|
||||
FailureUnverifiable FailureKind = "unverifiable"
|
||||
FailureTransport FailureKind = "transport"
|
||||
FailureRejected FailureKind = "rejected"
|
||||
FailureUnknown FailureKind = "unknown"
|
||||
)
|
||||
|
||||
// Failure reports a backend-neutral operation failure. Backend-specific
|
||||
// status and operation details belong in Err. Err is retained for errors.Is
|
||||
// and errors.As inspection.
|
||||
type Failure struct {
|
||||
Kind FailureKind
|
||||
Err error
|
||||
}
|
||||
|
||||
func (f *Failure) Error() string {
|
||||
if f.Err != nil {
|
||||
return string(f.Kind) + ": " + f.Err.Error()
|
||||
}
|
||||
return string(f.Kind)
|
||||
}
|
||||
|
||||
// Unwrap returns the cause of the operation failure, if any.
|
||||
func (f *Failure) Unwrap() error { return f.Err }
|
||||
|
||||
// WriteDisposition describes the backend's evidence about a write operation.
|
||||
type WriteDisposition string
|
||||
|
||||
const (
|
||||
WriteNotAttempted WriteDisposition = "not_attempted"
|
||||
WriteAccepted WriteDisposition = "accepted"
|
||||
WriteRejected WriteDisposition = "rejected"
|
||||
WriteConflict WriteDisposition = "conflict"
|
||||
WriteUncertain WriteDisposition = "uncertain"
|
||||
)
|
||||
|
||||
// WriteEvidence records whether a write was handed to the transport and what
|
||||
// its acknowledgment establishes. Accepted means the backend received and
|
||||
// decoded the required protocol acknowledgment; shared content checks still
|
||||
// apply. Uncertain means the outcome could not be established. Attempted marks
|
||||
// handoff to the transport and does not claim the server received the request.
|
||||
// Evidence remains meaningful when the operation also returns an error.
|
||||
type WriteEvidence struct {
|
||||
Attempted bool
|
||||
Disposition WriteDisposition
|
||||
}
|
||||
@@ -51,6 +51,9 @@ func (reader *BlobReader) Read(ctx context.Context, objectID string) ([]byte, er
|
||||
|
||||
reader.mu.Lock()
|
||||
defer reader.mu.Unlock()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, fmt.Errorf("read blob %s: %w", objectID, err)
|
||||
}
|
||||
if reader.terminal != nil {
|
||||
return nil, reader.terminal
|
||||
}
|
||||
@@ -128,8 +131,9 @@ func (reader *BlobReader) startLocked() error {
|
||||
Args: []string{"git", "cat-file", "--batch"},
|
||||
Dir: reader.repositoryDir,
|
||||
Env: map[string]string{
|
||||
"GIT_NO_LAZY_FETCH": "1",
|
||||
"GIT_ALLOW_PROTOCOL": "",
|
||||
"GIT_NO_LAZY_FETCH": "1",
|
||||
"GIT_ALLOW_PROTOCOL": "",
|
||||
"GIT_NO_REPLACE_OBJECTS": "1",
|
||||
},
|
||||
Stderr: &reader.stderr,
|
||||
})
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -156,6 +157,46 @@ exec sleep 60`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlobReaderCanceledQueuedReadKeepsSessionUsable(t *testing.T) {
|
||||
fixture := newRemoteFixture(t)
|
||||
object := gitText(t, fixture.work, "rev-parse", "HEAD:source.txt")
|
||||
reader := NewBlobReader(t.Context(), fixture.work)
|
||||
defer reader.Close()
|
||||
if body, err := reader.Read(t.Context(), object); err != nil || string(body) != "candidate" {
|
||||
t.Fatalf("first read = %q, %v", body, err)
|
||||
}
|
||||
reader.mu.Lock()
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
observed := make(chan struct{})
|
||||
queued := &observedContext{Context: ctx, observed: observed}
|
||||
result := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := reader.Read(queued, object)
|
||||
result <- err
|
||||
}()
|
||||
<-observed
|
||||
cancel()
|
||||
reader.mu.Unlock()
|
||||
if err := <-result; !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("queued read error = %v", err)
|
||||
}
|
||||
if body, err := reader.Read(t.Context(), object); err != nil || string(body) != "candidate" {
|
||||
t.Fatalf("later read = %q, %v", body, err)
|
||||
}
|
||||
}
|
||||
|
||||
type observedContext struct {
|
||||
context.Context
|
||||
once sync.Once
|
||||
observed chan struct{}
|
||||
}
|
||||
|
||||
func (ctx *observedContext) Err() error {
|
||||
err := ctx.Context.Err()
|
||||
ctx.once.Do(func() { close(ctx.observed) })
|
||||
return err
|
||||
}
|
||||
|
||||
func TestBlobReaderPreservesGitFailureAndStderr(t *testing.T) {
|
||||
installFakeGit(t, `printf 'fixture Git failure\n' >&2
|
||||
exit 23`)
|
||||
|
||||
215
internal/git/discovery_test.go
Normal file
215
internal/git/discovery_test.go
Normal file
@@ -0,0 +1,215 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDiscoverRepositoryRoot(t *testing.T) {
|
||||
repo := newRemoteFixture(t)
|
||||
child := filepath.Join(repo.work, "child")
|
||||
if err := os.Mkdir(child, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
alias := filepath.Join(t.TempDir(), "alias")
|
||||
if err := os.Symlink(repo.work, alias); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
worktree := filepath.Join(t.TempDir(), "worktree")
|
||||
gitRun(t, repo.work, "worktree", "add", "--detach", worktree)
|
||||
for _, tc := range []struct{ dir, want string }{{repo.work, repo.work}, {child, repo.work}, {alias, repo.work}, {worktree, worktree}} {
|
||||
root, found, err := DiscoverRepositoryRoot(context.Background(), tc.dir)
|
||||
want, e := filepath.EvalSymlinks(tc.want)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if err != nil || !found || root != want {
|
||||
t.Fatalf("Discover(%s) = %q, %t, %v; want %q", tc.dir, root, found, err, want)
|
||||
}
|
||||
}
|
||||
standalone := t.TempDir()
|
||||
if root, found, err := DiscoverRepositoryRoot(context.Background(), standalone); err != nil || found || root != "" {
|
||||
t.Fatalf("standalone = %q, %t, %v", root, found, err)
|
||||
}
|
||||
if _, err := RepositoryRoot(context.Background(), standalone); err == nil {
|
||||
t.Fatal("strict lookup accepted standalone")
|
||||
}
|
||||
if _, found, err := DiscoverRepositoryRoot(context.Background(), repo.bare); err == nil || found {
|
||||
t.Fatalf("bare repository: found %t, error %v", found, err)
|
||||
}
|
||||
broken := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(broken, ".git"), []byte("gitdir: missing\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, found, err := DiscoverRepositoryRoot(context.Background(), broken); err == nil || found {
|
||||
t.Fatalf("broken marker: found %t, error %v", found, err)
|
||||
}
|
||||
if _, found, err := DiscoverRepositoryRoot(context.Background(), filepath.Join(standalone, "missing")); err == nil || found {
|
||||
t.Fatalf("missing directory: found %t, error %v", found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoRepositoryBoundary(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
message string
|
||||
want bool
|
||||
}{
|
||||
{"fatal: not a git repository (or any of the parent directories): .git\n", true},
|
||||
{"fatal: not a git repository (or any parent up to mount point /some path)\nStopping at filesystem boundary (GIT_DISCOVERY_ACROSS_FILESYSTEM not set).\n", true},
|
||||
{"fatal: not a git repository: '/bad'", false},
|
||||
{"fatal: detected dubious ownership in repository", false},
|
||||
} {
|
||||
if _, got := noRepositoryBoundary(tc.message); got != tc.want {
|
||||
t.Errorf("diagnostic %q = %t", tc.message, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverRepositoryRootHonorsDiscoveryBoundaries(t *testing.T) {
|
||||
repo := newRemoteFixture(t)
|
||||
child := filepath.Join(repo.work, "child")
|
||||
if err := os.Mkdir(child, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
alias := filepath.Join(t.TempDir(), "alias")
|
||||
if err := os.Symlink(repo.work, alias); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Remove(filepath.Join(repo.work, ".git", "HEAD")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name, directory, ceiling string
|
||||
standalone bool
|
||||
}{
|
||||
{"damaged ancestor", child, "", false},
|
||||
{"excluded ancestor", child, repo.work, true},
|
||||
{"physical ceiling alias", child, alias, true},
|
||||
{"literal ceiling alias", child, string(os.PathListSeparator) + alias, false},
|
||||
{"ceiling includes current directory", repo.work, repo.work, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv("GIT_CEILING_DIRECTORIES", tc.ceiling)
|
||||
_, found, err := DiscoverRepositoryRoot(t.Context(), tc.directory)
|
||||
if tc.standalone {
|
||||
if err != nil || found {
|
||||
t.Fatalf("excluded checkout: found %t, error %v", found, err)
|
||||
}
|
||||
} else {
|
||||
var exitErr *exec.ExitError
|
||||
if found || !errors.As(err, &exitErr) || exitErr.ExitCode() != 128 {
|
||||
t.Fatalf("damaged checkout: found %t, error %v", found, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
bin := t.TempDir()
|
||||
script := "#!/bin/sh\nprintf 'fatal: not a git repository (or any parent up to mount point %s)\\nStopping at filesystem boundary (GIT_DISCOVERY_ACROSS_FILESYSTEM not set).\\n' \"$DISCOVERY_BOUNDARY\" >&2\nexit 128\n"
|
||||
if err := os.WriteFile(filepath.Join(bin, "git"), []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", bin)
|
||||
t.Setenv("GIT_CEILING_DIRECTORIES", "")
|
||||
t.Setenv("DISCOVERY_BOUNDARY", repo.work)
|
||||
if _, found, err := DiscoverRepositoryRoot(t.Context(), child); err != nil || found {
|
||||
t.Fatalf("filesystem boundary: found %t, error %v", found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverRepositoryRootFailures(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
bin := t.TempDir()
|
||||
script := "#!/bin/sh\nprintf '%s\\n' 'fatal: detected dubious ownership in repository' >&2\nexit 128\n"
|
||||
if err := os.WriteFile(filepath.Join(bin, "git"), []byte(script), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", bin)
|
||||
_, found, err := DiscoverRepositoryRoot(context.Background(), dir)
|
||||
var exitErr *exec.ExitError
|
||||
if found || !errors.As(err, &exitErr) || exitErr.ExitCode() != 128 || !strings.Contains(err.Error(), "dubious ownership") {
|
||||
t.Fatalf("found %t, error %v", found, err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(bin, "git"), []byte("#!/bin/sh\nprintf '%s\\n' 'fatal: permission denied' >&2\nexit 128\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, found, err = DiscoverRepositoryRoot(context.Background(), dir)
|
||||
if found || !errors.As(err, &exitErr) || exitErr.ExitCode() != 128 || !strings.Contains(err.Error(), "permission denied") {
|
||||
t.Fatalf("found %t, error %v", found, err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, _, err := DiscoverRepositoryRoot(ctx, dir); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("cancellation = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverRepositoryRootInterruptedGit(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
bin := t.TempDir()
|
||||
ready := filepath.Join(t.TempDir(), "ready")
|
||||
script := "#!/bin/sh\n[ \"$LC_ALL\" = C ] || exit 42\n: > \"$DISCOVERY_READY\"\nexec /bin/sleep 60\n"
|
||||
if err := os.WriteFile(filepath.Join(bin, "git"), []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", bin)
|
||||
t.Setenv("DISCOVERY_READY", ready)
|
||||
t.Setenv("LC_ALL", "different-locale")
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
done := make(chan error, 1)
|
||||
go func() { _, _, err := DiscoverRepositoryRoot(ctx, dir); done <- err }()
|
||||
timer := time.NewTimer(5 * time.Second)
|
||||
defer timer.Stop()
|
||||
ticker := time.NewTicker(10 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
if _, err := os.Stat(ready); err == nil {
|
||||
break
|
||||
}
|
||||
select {
|
||||
case err := <-done:
|
||||
t.Fatalf("Git ended before readiness: %v", err)
|
||||
case <-timer.C:
|
||||
cancel()
|
||||
err := <-done
|
||||
t.Fatalf("Git did not become ready: %v", err)
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
cancel()
|
||||
if err := <-done; !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("cancellation = %v", err)
|
||||
}
|
||||
if got := os.Getenv("LC_ALL"); got != "different-locale" {
|
||||
t.Fatalf("parent locale changed to %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverRepositoryRootWithoutGit(t *testing.T) {
|
||||
repo := newRemoteFixture(t)
|
||||
standalone := t.TempDir()
|
||||
t.Setenv("PATH", t.TempDir())
|
||||
if _, found, err := DiscoverRepositoryRoot(context.Background(), standalone); found || err != nil {
|
||||
t.Fatalf("standalone: found %t, error %v", found, err)
|
||||
}
|
||||
if _, found, err := DiscoverRepositoryRoot(context.Background(), repo.work); found || !errors.Is(err, exec.ErrNotFound) {
|
||||
t.Fatalf("checkout: found %t, error %v", found, err)
|
||||
}
|
||||
broken := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(broken, ".git"), []byte("broken"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, found, err := DiscoverRepositoryRoot(context.Background(), broken); found || !errors.Is(err, exec.ErrNotFound) {
|
||||
t.Fatalf("broken marker: found %t, error %v", found, err)
|
||||
}
|
||||
t.Setenv("GIT_DIR", "invalid")
|
||||
if _, found, err := DiscoverRepositoryRoot(context.Background(), standalone); found || !errors.Is(err, exec.ErrNotFound) {
|
||||
t.Fatalf("explicit context: found %t, error %v", found, err)
|
||||
}
|
||||
}
|
||||
40
internal/git/display.go
Normal file
40
internal/git/display.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Match userinfo through @ before treating quotes as diagnostic delimiters.
|
||||
var httpURLSpan = regexp.MustCompile(`(?i)https?://(?:[^\s/?#<>]*@)?[^\s"'<>]*`)
|
||||
|
||||
// DisplayURL hides HTTP userinfo without changing the endpoint used by Git.
|
||||
func DisplayURL(raw string) string {
|
||||
lower := strings.ToLower(raw)
|
||||
if !strings.HasPrefix(lower, "http://") && !strings.HasPrefix(lower, "https://") {
|
||||
return raw
|
||||
}
|
||||
authority := raw[strings.Index(raw, "://")+3:]
|
||||
if end := strings.IndexAny(authority, "/?#"); end >= 0 {
|
||||
authority = authority[:end]
|
||||
}
|
||||
if !strings.Contains(authority, "@") {
|
||||
return raw
|
||||
}
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed.User == nil || parsed.Host == "" {
|
||||
return "[redacted URL]"
|
||||
}
|
||||
parsed.User = url.User("redacted")
|
||||
return parsed.String()
|
||||
}
|
||||
|
||||
func displayDiagnostic(message string) string {
|
||||
return httpURLSpan.ReplaceAllStringFunc(message, DisplayURL)
|
||||
}
|
||||
|
||||
type displayError struct{ cause error }
|
||||
|
||||
func (err displayError) Error() string { return displayDiagnostic(err.cause.Error()) }
|
||||
func (err displayError) Unwrap() error { return err.cause }
|
||||
103
internal/git/display_test.go
Normal file
103
internal/git/display_test.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDisplayURL(t *testing.T) {
|
||||
for _, test := range []struct{ input, want string }{
|
||||
{"https://user:password@example.test/repo", "https://redacted@example.test/repo"},
|
||||
{"https://token@example.test/repo", "https://redacted@example.test/repo"},
|
||||
{"https://user:secret'part@example.test/repo", "https://redacted@example.test/repo"},
|
||||
{"https://user%40example:secret%2Fvalue@example.test/repo", "https://redacted@example.test/repo"},
|
||||
{"http://broken%zz:secret@example.test/repo", "[redacted URL]"},
|
||||
{"https://example.test/repo", "https://example.test/repo"},
|
||||
{"git@example.test:owner/repo", "git@example.test:owner/repo"},
|
||||
} {
|
||||
if got := DisplayURL(test.input); got != test.want {
|
||||
t.Errorf("DisplayURL(%q) = %q, want %q", test.input, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisplayDiagnosticRedactsUserinfoBeforeQuotedContext(t *testing.T) {
|
||||
for _, test := range []struct{ input, want string }{
|
||||
{"fatal: 'https://user:secret'part@example.test/repo': denied", "fatal: 'https://redacted@example.test/repo': denied"},
|
||||
{"failed at https://token'part@example.test", "failed at https://redacted@example.test"},
|
||||
{"fatal: 'https://user:secret%27part@example.test/repo': denied", "fatal: 'https://redacted@example.test/repo': denied"},
|
||||
{"fatal: 'https://broken%zz:secret'part@example.test/repo': denied", "fatal: '[redacted URL]': denied"},
|
||||
{"fatal: 'https://token'part@': denied", "fatal: '[redacted URL]': denied"},
|
||||
{"see 'https://example.test/path' and git@example.test:repo", "see 'https://example.test/path' and git@example.test:repo"},
|
||||
} {
|
||||
if got := displayDiagnostic(test.input); got != test.want {
|
||||
t.Errorf("displayDiagnostic(%q) = %q, want %q", test.input, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRedactsCredentialsInNativeGitErrors(t *testing.T) {
|
||||
fixture := newRemoteFixture(t)
|
||||
for _, remote := range []string{"https://user:secret'part@example.test/repo", "https://user:secret%27part@example.test/repo"} {
|
||||
_, err := Resolve(t.Context(), fixture.work, remote)
|
||||
var exit *exec.ExitError
|
||||
if !errors.As(err, &exit) || strings.Contains(err.Error(), "secret") || strings.Contains(err.Error(), "user:") || !strings.Contains(err.Error(), "example.test/repo") {
|
||||
t.Fatalf("unsafe or incomplete native Git diagnostic: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitFailuresRedactCredentialsAndPreserveCause(t *testing.T) {
|
||||
installFakeGit(t, `case "$*" in
|
||||
*check-ref-format*) exit 0 ;;
|
||||
esac
|
||||
printf '%s\n' "fatal: unable to access 'https://user:secret@example.test/repo': https://token@example.test/path" >&2
|
||||
exit 23`)
|
||||
endpoint := Endpoint{PushURL: "https://user:secret@example.test/repo", ScopedName: "fixture"}
|
||||
for _, operation := range []struct {
|
||||
name string
|
||||
run func() error
|
||||
}{
|
||||
{"query", func() error { _, err := endpoint.Query(context.Background(), t.TempDir(), "v1.2.3"); return err }},
|
||||
{"push", func() error {
|
||||
return endpoint.PushTag(context.Background(), t.TempDir(), "v1.2.3", strings.Repeat("a", 40))
|
||||
}},
|
||||
} {
|
||||
t.Run(operation.name, func(t *testing.T) {
|
||||
err := operation.run()
|
||||
var exit *exec.ExitError
|
||||
if !errors.As(err, &exit) || exit.ExitCode() != 23 {
|
||||
t.Fatalf("error = %v, want Git exit 23", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "secret") || strings.Contains(err.Error(), "token") || strings.Contains(err.Error(), "user") || !strings.Contains(err.Error(), "example.test/repo") {
|
||||
t.Fatalf("unsafe or unhelpful diagnostic: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisplayErrorPreservesWrappedCause(t *testing.T) {
|
||||
for _, userinfo := range []string{"token", "token'part", "token%27part"} {
|
||||
cause := errors.New("failed at https://" + userinfo + "@example.test/path")
|
||||
err := displayError{cause: cause}
|
||||
if !errors.Is(err, cause) || strings.Contains(err.Error(), "token") {
|
||||
t.Fatalf("sanitized error = %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndpointChangeRedactsNewDestination(t *testing.T) {
|
||||
fixture := newRemoteFixture(t)
|
||||
endpoint, err := Resolve(t.Context(), fixture.work, "origin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gitRun(t, fixture.work, "remote", "set-url", "--push", "origin", "https://new-token@example.test/repo")
|
||||
err = endpoint.Recheck(t.Context(), fixture.work)
|
||||
if !errors.Is(err, ErrEndpointChanged) || strings.Contains(err.Error(), "new-token") || !strings.Contains(err.Error(), "example.test/repo") {
|
||||
t.Fatalf("endpoint change error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
// PrepareTaggedCheckout fetches one explicit tag ref, checks its peeled commit
|
||||
// against expectedCommit, and checks out that commit detached.
|
||||
func PrepareTaggedCheckout(ctx context.Context, repositoryDir, tag, remote, expectedCommit string) error {
|
||||
if err := checkTagRef(ctx, repositoryDir, tag); err != nil {
|
||||
if err := CheckTagRef(ctx, repositoryDir, tag); err != nil {
|
||||
return err
|
||||
}
|
||||
if !validObjectID(expectedCommit) {
|
||||
@@ -17,7 +17,7 @@ func PrepareTaggedCheckout(ctx context.Context, repositoryDir, tag, remote, expe
|
||||
}
|
||||
ref := "refs/tags/" + tag
|
||||
if _, err := run(ctx, repositoryDir, nil, "fetch", "--no-tags", remote, ref+":"+ref); err != nil {
|
||||
return fmt.Errorf("fetch tag %s from remote %s: %w", tag, remote, err)
|
||||
return fmt.Errorf("fetch tag %s from remote %s: %w", tag, DisplayURL(remote), err)
|
||||
}
|
||||
source, err := InspectTaggedSource(ctx, repositoryDir, tag)
|
||||
if err != nil {
|
||||
@@ -35,7 +35,7 @@ func PrepareTaggedCheckout(ctx context.Context, repositoryDir, tag, remote, expe
|
||||
// VerifyTaggedCheckout confirms that the existing local tag, HEAD, and clean
|
||||
// worktree all identify the expected commit. It does not fetch or check out.
|
||||
func VerifyTaggedCheckout(ctx context.Context, repositoryDir, tag, expectedCommit string) error {
|
||||
if err := checkTagRef(ctx, repositoryDir, tag); err != nil {
|
||||
if err := CheckTagRef(ctx, repositoryDir, tag); err != nil {
|
||||
return err
|
||||
}
|
||||
if !validObjectID(expectedCommit) {
|
||||
|
||||
@@ -43,7 +43,7 @@ const (
|
||||
)
|
||||
|
||||
// RemoteRef holds an exact ref's object and optional peeled commit. State is
|
||||
// RefConflict only when more than one exact result or malformed data is found.
|
||||
// RefConflict when records are inconsistent or incomplete.
|
||||
type RemoteRef struct {
|
||||
State RefState
|
||||
Object string
|
||||
@@ -60,14 +60,14 @@ type Snapshot struct {
|
||||
// command-scoped remote whose read and push URLs resolve to that same endpoint.
|
||||
func Resolve(ctx context.Context, repositoryDir, remoteName string) (Endpoint, error) {
|
||||
if strings.TrimSpace(remoteName) == "" || strings.HasPrefix(remoteName, "-") {
|
||||
return Endpoint{}, fmt.Errorf("%w: %q", ErrInvalidRemote, remoteName)
|
||||
return Endpoint{}, fmt.Errorf("%w: %q", ErrInvalidRemote, DisplayURL(remoteName))
|
||||
}
|
||||
urls, err := pushURLs(ctx, repositoryDir, remoteName)
|
||||
if err != nil {
|
||||
return Endpoint{}, err
|
||||
}
|
||||
if len(urls) != 1 {
|
||||
return Endpoint{}, fmt.Errorf("%w: %s resolves to %d distinct destinations", ErrMultiplePushURLs, remoteName, len(urls))
|
||||
return Endpoint{}, fmt.Errorf("%w: %s resolves to %d distinct destinations", ErrMultiplePushURLs, DisplayURL(remoteName), len(urls))
|
||||
}
|
||||
scopedName, err := newScopedName()
|
||||
if err != nil {
|
||||
@@ -89,10 +89,10 @@ func Resolve(ctx context.Context, repositoryDir, remoteName string) (Endpoint, e
|
||||
func (endpoint Endpoint) Recheck(ctx context.Context, repositoryDir string) error {
|
||||
urls, err := pushURLs(ctx, repositoryDir, endpoint.RemoteName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("re-resolve remote %q: %w", endpoint.RemoteName, err)
|
||||
return fmt.Errorf("re-resolve remote %q: %w", DisplayURL(endpoint.RemoteName), err)
|
||||
}
|
||||
if len(urls) != 1 || urls[0] != endpoint.PushURL {
|
||||
return fmt.Errorf("%w: remote %q now resolves to %q", ErrEndpointChanged, endpoint.RemoteName, strings.Join(urls, ", "))
|
||||
return fmt.Errorf("%w: remote %q now resolves to %q", ErrEndpointChanged, DisplayURL(endpoint.RemoteName), displayDiagnostic(strings.Join(urls, ", ")))
|
||||
}
|
||||
return endpoint.verifyScopedURLs(ctx, repositoryDir)
|
||||
}
|
||||
@@ -100,7 +100,7 @@ func (endpoint Endpoint) Recheck(ctx context.Context, repositoryDir string) erro
|
||||
// Query reads exact main and tag refs. A command failure is unreadable,
|
||||
// never evidence that a ref is absent.
|
||||
func (endpoint Endpoint) Query(ctx context.Context, repositoryDir, tag string) (Snapshot, error) {
|
||||
if err := checkTagRef(ctx, repositoryDir, tag); err != nil {
|
||||
if err := CheckTagRef(ctx, repositoryDir, tag); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
mainRef := "refs/heads/main"
|
||||
@@ -109,7 +109,7 @@ func (endpoint Endpoint) Query(ctx context.Context, repositoryDir, tag string) (
|
||||
output, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "ls-remote", endpoint.ScopedName, mainRef, tagRef, peeledRef)
|
||||
if err != nil {
|
||||
snapshot := Snapshot{Main: RemoteRef{State: RefUnreadable}, Tag: RemoteRef{State: RefUnreadable}}
|
||||
return snapshot, fmt.Errorf("%w: query authoritative refs at %s: %w", ErrRemoteStateUnreadable, endpoint.PushURL, err)
|
||||
return snapshot, fmt.Errorf("%w: query authoritative refs at %s: %w", ErrRemoteStateUnreadable, DisplayURL(endpoint.PushURL), err)
|
||||
}
|
||||
main := RemoteRef{State: RefAbsent}
|
||||
remoteTag := RemoteRef{State: RefAbsent}
|
||||
@@ -119,7 +119,7 @@ func (endpoint Endpoint) Query(ctx context.Context, repositoryDir, tag string) (
|
||||
}
|
||||
object, name, ok := strings.Cut(line, "\t")
|
||||
if !ok || !validObjectID(object) {
|
||||
return Snapshot{Main: RemoteRef{State: RefUnreadable}, Tag: RemoteRef{State: RefUnreadable}}, fmt.Errorf("%w: malformed ls-remote output %q", ErrRemoteStateUnreadable, line)
|
||||
return Snapshot{Main: RemoteRef{State: RefUnreadable}, Tag: RemoteRef{State: RefUnreadable}}, fmt.Errorf("%w: malformed ls-remote output %q", ErrRemoteStateUnreadable, displayDiagnostic(line))
|
||||
}
|
||||
switch name {
|
||||
case mainRef:
|
||||
@@ -130,7 +130,7 @@ func (endpoint Endpoint) Query(ctx context.Context, repositoryDir, tag string) (
|
||||
remoteTag = mergeRef(remoteTag, object, true)
|
||||
}
|
||||
}
|
||||
return Snapshot{Main: main, Tag: remoteTag}, nil
|
||||
return Snapshot{Main: main, Tag: finalizeRef(remoteTag)}, nil
|
||||
}
|
||||
|
||||
// RequireCandidate requires exact main equality and an absent remote
|
||||
@@ -165,7 +165,7 @@ func (endpoint Endpoint) verifyScopedURLs(ctx context.Context, repositoryDir str
|
||||
}
|
||||
resolved := outputLines(readURL)
|
||||
if len(resolved) != 1 || resolved[0] != endpoint.PushURL {
|
||||
return fmt.Errorf("%w: scoped URL resolves to %q, expected %q", ErrEndpointChanged, resolved, endpoint.PushURL)
|
||||
return fmt.Errorf("%w: scoped URL resolves to %q, expected %q", ErrEndpointChanged, displayDiagnostic(strings.Join(resolved, ", ")), DisplayURL(endpoint.PushURL))
|
||||
}
|
||||
// The scoped remote sets its push URL explicitly to the same captured value.
|
||||
// Git applies the same insteadOf rules to both explicit URLs; pushInsteadOf
|
||||
@@ -197,7 +197,7 @@ func transportConfig(ctx context.Context, repositoryDir, remoteName, scopedName
|
||||
if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("read transport setting remote.%s.%s: %w", remoteName, key, err)
|
||||
return nil, fmt.Errorf("read transport setting remote.%s.%s: %w", DisplayURL(remoteName), key, err)
|
||||
}
|
||||
for _, value := range outputLines(output) {
|
||||
config = append(config, "remote."+scopedName+"."+key+"="+value)
|
||||
@@ -209,7 +209,7 @@ func transportConfig(ctx context.Context, repositoryDir, remoteName, scopedName
|
||||
func pushURLs(ctx context.Context, repositoryDir, remoteName string) ([]string, error) {
|
||||
output, err := run(ctx, repositoryDir, nil, "remote", "get-url", "--push", "--all", remoteName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve push URL for remote %q: %w", remoteName, err)
|
||||
return nil, fmt.Errorf("resolve push URL for remote %q: %w", DisplayURL(remoteName), err)
|
||||
}
|
||||
values := outputLines(output)
|
||||
unique := make([]string, 0, len(values))
|
||||
@@ -234,13 +234,15 @@ func run(ctx context.Context, dir string, config []string, args ...string) (stri
|
||||
err := process.Run(ctx, process.Invocation{
|
||||
Args: append([]string{"git"}, commandArgs...),
|
||||
Dir: dir,
|
||||
Env: map[string]string{"GIT_NO_REPLACE_OBJECTS": "1"},
|
||||
Stdout: &stdout,
|
||||
Stderr: &stderr,
|
||||
})
|
||||
if err != nil {
|
||||
err = displayError{cause: err}
|
||||
message := strings.TrimSpace(stderr.String())
|
||||
if message != "" {
|
||||
return stdout.String(), fmt.Errorf("%w: %s", err, message)
|
||||
return stdout.String(), fmt.Errorf("%w: %s", err, displayDiagnostic(message))
|
||||
}
|
||||
return stdout.String(), err
|
||||
}
|
||||
@@ -294,7 +296,15 @@ func mergeRef(ref RemoteRef, object string, peeled bool) RemoteRef {
|
||||
return ref
|
||||
}
|
||||
|
||||
func checkTagRef(ctx context.Context, repositoryDir, tag string) error {
|
||||
func finalizeRef(ref RemoteRef) RemoteRef {
|
||||
if ref.State == RefPresent && ref.Object == "" && ref.Peeled != "" {
|
||||
ref.State = RefConflict
|
||||
}
|
||||
return ref
|
||||
}
|
||||
|
||||
// CheckTagRef validates a complete tag name using Git ref rules.
|
||||
func CheckTagRef(ctx context.Context, repositoryDir, tag string) error {
|
||||
if tag == "" {
|
||||
return fmt.Errorf("invalid Git tag name %q", tag)
|
||||
}
|
||||
|
||||
@@ -184,6 +184,52 @@ func TestMergeRefMarksDuplicateExactResultsConflicting(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTagQueryFinalizesCompleteResponse(t *testing.T) {
|
||||
repo := newRemoteFixture(t)
|
||||
endpoint := resolveFixture(t, repo)
|
||||
realGit, err := exec.LookPath("git")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bin := t.TempDir()
|
||||
response := filepath.Join(t.TempDir(), "response")
|
||||
script := "#!/bin/sh\ncase \" $* \" in *' ls-remote '* ) case \" $* \" in *' --get-url '* ) ;; *) cat \"$RESPONSE\"; exit 0;; esac;; esac\nexec \"$REAL_GIT\" \"$@\"\n"
|
||||
if err := os.WriteFile(filepath.Join(bin, "git"), []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
t.Setenv("REAL_GIT", realGit)
|
||||
t.Setenv("RESPONSE", response)
|
||||
object, peeled := strings.Repeat("a", 40), strings.Repeat("b", 40)
|
||||
exact := object + "\trefs/tags/v1.2.3\n"
|
||||
peel := peeled + "\trefs/tags/v1.2.3^{}\n"
|
||||
for _, tc := range []struct {
|
||||
name, output string
|
||||
want RemoteRef
|
||||
}{
|
||||
{"empty", "", RemoteRef{State: RefAbsent}},
|
||||
{"exact", exact, RemoteRef{State: RefPresent, Object: object}},
|
||||
{"exact then peeled", exact + peel, RemoteRef{State: RefPresent, Object: object, Peeled: peeled}},
|
||||
{"peeled then exact", peel + exact, RemoteRef{State: RefPresent, Object: object, Peeled: peeled}},
|
||||
{"peeled only", peel, RemoteRef{State: RefConflict, Peeled: peeled}},
|
||||
{"duplicate", exact + exact, RemoteRef{State: RefConflict, Object: object}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if err := os.WriteFile(response, []byte(tc.output), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snapshot, err := endpoint.Query(context.Background(), repo.work, "v1.2.3")
|
||||
if err != nil || snapshot.Tag != tc.want {
|
||||
t.Fatalf("Query tag = %#v, error = %v, want %#v", snapshot.Tag, err, tc.want)
|
||||
}
|
||||
got, err := endpoint.QueryTag(context.Background(), repo.work, "v1.2.3")
|
||||
if err != nil || got != tc.want {
|
||||
t.Fatalf("QueryTag = %#v, error = %v, want %#v", got, err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type remoteFixture struct {
|
||||
work string
|
||||
bare string
|
||||
|
||||
@@ -3,8 +3,11 @@ package git
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -41,6 +44,10 @@ func RepositoryRoot(ctx context.Context, directory string) (string, error) {
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve Git repository root: %w", err)
|
||||
}
|
||||
return normalizeRepositoryRoot(root)
|
||||
}
|
||||
|
||||
func normalizeRepositoryRoot(root string) (string, error) {
|
||||
root = strings.TrimSuffix(root, "\n")
|
||||
resolved, err := filepath.EvalSymlinks(root)
|
||||
if err != nil {
|
||||
@@ -49,6 +56,135 @@ func RepositoryRoot(ctx context.Context, directory string) (string, error) {
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
// DiscoverRepositoryRoot finds a containing checkout or confirms standalone use.
|
||||
func DiscoverRepositoryRoot(ctx context.Context, directory string) (string, bool, error) {
|
||||
if directory == "" {
|
||||
directory = "."
|
||||
}
|
||||
var stdout, stderr strings.Builder
|
||||
err := process.Run(ctx, process.Invocation{
|
||||
Args: []string{"git", "rev-parse", "--show-toplevel"}, Dir: directory,
|
||||
Env: map[string]string{"LC_ALL": "C", "GIT_NO_REPLACE_OBJECTS": "1"}, Stdout: &stdout, Stderr: &stderr,
|
||||
})
|
||||
if err == nil {
|
||||
root, err := normalizeRepositoryRoot(stdout.String())
|
||||
return root, err == nil, err
|
||||
}
|
||||
if errors.Is(err, exec.ErrNotFound) {
|
||||
standalone, inspectErr := standaloneWithoutGit(directory)
|
||||
if inspectErr != nil {
|
||||
return "", false, inspectErr
|
||||
}
|
||||
if standalone {
|
||||
return "", false, nil
|
||||
}
|
||||
}
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) && exitErr.ExitCode() == 128 {
|
||||
if boundary, noRepository := noRepositoryBoundary(stderr.String()); noRepository {
|
||||
boundaries := discoveryCeilings()
|
||||
if boundary != "" {
|
||||
boundaries = append(boundaries, boundary)
|
||||
}
|
||||
standalone, inspectErr := standaloneDirectory(directory, boundaries)
|
||||
if inspectErr != nil {
|
||||
return "", false, errors.Join(inspectErr, err)
|
||||
}
|
||||
if standalone {
|
||||
return "", false, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if message := strings.TrimSpace(stderr.String()); message != "" {
|
||||
return "", false, fmt.Errorf("resolve Git repository root: %s: %w", message, err)
|
||||
}
|
||||
return "", false, fmt.Errorf("resolve Git repository root: %w", err)
|
||||
}
|
||||
|
||||
func noRepositoryBoundary(message string) (string, bool) {
|
||||
message = strings.TrimSpace(message)
|
||||
if message == "fatal: not a git repository (or any of the parent directories): .git" {
|
||||
return "", true
|
||||
}
|
||||
const prefix = "fatal: not a git repository (or any parent up to mount point "
|
||||
const suffix = ")\nStopping at filesystem boundary (GIT_DISCOVERY_ACROSS_FILESYSTEM not set)."
|
||||
if strings.HasPrefix(message, prefix) && strings.HasSuffix(message, suffix) && len(message) > len(prefix)+len(suffix) {
|
||||
return filepath.Clean(message[len(prefix) : len(message)-len(suffix)]), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func discoveryCeilings() []string {
|
||||
var ceilings []string
|
||||
resolveLinks := true
|
||||
for _, entry := range filepath.SplitList(os.Getenv("GIT_CEILING_DIRECTORIES")) {
|
||||
// Git uses an empty entry to mark subsequent paths as already physical.
|
||||
if entry == "" {
|
||||
resolveLinks = false
|
||||
continue
|
||||
}
|
||||
if !filepath.IsAbs(entry) {
|
||||
continue
|
||||
}
|
||||
if resolveLinks {
|
||||
resolved, err := filepath.EvalSymlinks(entry)
|
||||
if err != nil {
|
||||
continue // Git ignores ceiling entries it cannot resolve.
|
||||
}
|
||||
entry = resolved
|
||||
}
|
||||
ceilings = append(ceilings, filepath.Clean(entry))
|
||||
}
|
||||
return ceilings
|
||||
}
|
||||
|
||||
func standaloneWithoutGit(directory string) (bool, error) {
|
||||
_, gitDirSet := os.LookupEnv("GIT_DIR")
|
||||
_, workTreeSet := os.LookupEnv("GIT_WORK_TREE")
|
||||
if gitDirSet || workTreeSet {
|
||||
return false, nil
|
||||
}
|
||||
return standaloneDirectory(directory, nil)
|
||||
}
|
||||
|
||||
// standaloneDirectory checks only locations Git could have searched. Boundaries
|
||||
// are excluded parents; the supplied directory itself is always inspected.
|
||||
func standaloneDirectory(directory string, boundaries []string) (bool, error) {
|
||||
abs, err := filepath.Abs(directory)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("inspect repository directory: %w", err)
|
||||
}
|
||||
physical, err := filepath.EvalSymlinks(abs)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("inspect repository directory: %w", err)
|
||||
}
|
||||
info, err := os.Stat(physical)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("inspect repository directory: %w", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return false, fmt.Errorf("inspect repository directory %q: not a directory", directory)
|
||||
}
|
||||
for dir := physical; ; dir = filepath.Dir(dir) {
|
||||
_, err := os.Lstat(filepath.Join(dir, ".git"))
|
||||
if err == nil {
|
||||
return false, nil
|
||||
}
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return false, fmt.Errorf("inspect Git marker in %s: %w", dir, err)
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
return true, nil
|
||||
}
|
||||
for _, boundary := range boundaries {
|
||||
if parent == boundary {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CaptureHead records the checkout commit before identity derived from files
|
||||
// in that commit is inspected.
|
||||
func CaptureHead(ctx context.Context, repositoryDir string) (string, error) {
|
||||
@@ -63,22 +199,28 @@ func CaptureHead(ctx context.Context, repositoryDir string) (string, error) {
|
||||
return head, nil
|
||||
}
|
||||
|
||||
// WorktreeStatus observes repository-wide worktree and index changes.
|
||||
func WorktreeStatus(ctx context.Context, repositoryDir string) (string, error) {
|
||||
status, err := run(ctx, repositoryDir, nil, "status", "--porcelain=v1", "--untracked-files=all")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(status), nil
|
||||
}
|
||||
|
||||
// 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, tag string) (TaggedSource, error) {
|
||||
if err := checkTagRef(ctx, repositoryDir, tag); err != nil {
|
||||
if err := CheckTagRef(ctx, repositoryDir, tag); err != nil {
|
||||
return TaggedSource{}, err
|
||||
}
|
||||
var source TaggedSource
|
||||
head, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", "HEAD^{commit}")
|
||||
head, err := CaptureHead(ctx, repositoryDir)
|
||||
if err != nil {
|
||||
return TaggedSource{}, fmt.Errorf("read checkout HEAD: %w", err)
|
||||
return TaggedSource{}, 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")
|
||||
source.Head = head
|
||||
status, err := WorktreeStatus(ctx, repositoryDir)
|
||||
if err != nil {
|
||||
return TaggedSource{}, fmt.Errorf("inspect tagged checkout state: %w", err)
|
||||
}
|
||||
@@ -275,7 +417,7 @@ func readLocalObjects(ctx context.Context, repositoryDir string, input io.Reader
|
||||
Args: append([]string{"git"}, args...),
|
||||
Dir: repositoryDir,
|
||||
// Disable transports as well for Git versions predating lazy-fetch control.
|
||||
Env: map[string]string{"GIT_NO_LAZY_FETCH": "1", "GIT_ALLOW_PROTOCOL": ""},
|
||||
Env: map[string]string{"GIT_NO_LAZY_FETCH": "1", "GIT_ALLOW_PROTOCOL": "", "GIT_NO_REPLACE_OBJECTS": "1"},
|
||||
Stdin: input,
|
||||
Stdout: &stdout,
|
||||
Stderr: &stderr,
|
||||
|
||||
@@ -35,16 +35,13 @@ func TestInspectTaggedSourceSupportsLightweightAndAnnotatedTags(t *testing.T) {
|
||||
func TestTagOperationsAcceptFullSlashContainingRefs(t *testing.T) {
|
||||
fixture := newRemoteFixture(t)
|
||||
tag := "integrations/check/v2.3.4"
|
||||
if err := checkTagRef(context.Background(), fixture.work, tag); err != nil {
|
||||
t.Fatalf("checkTagRef(%q): %v", tag, err)
|
||||
if err := CheckTagRef(context.Background(), fixture.work, tag); err != nil {
|
||||
t.Fatalf("CheckTagRef(%q): %v", tag, err)
|
||||
}
|
||||
object, err := CreateAnnotatedTag(context.Background(), fixture.work, tag, fixture.commit, []byte("release note\n"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := InspectLocalTag(context.Background(), fixture.work, tag, object); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source, err := InspectTaggedSource(context.Background(), fixture.work, tag)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -54,11 +51,36 @@ func TestTagOperationsAcceptFullSlashContainingRefs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAnnotatedTagReportsTemporaryCleanupFailure(t *testing.T) {
|
||||
fixture := newRemoteFixture(t)
|
||||
cleanupErr := errors.New("cleanup failed")
|
||||
var removed string
|
||||
remove := func(path string) error {
|
||||
removed = path
|
||||
if err := os.Remove(path); err != nil {
|
||||
return err
|
||||
}
|
||||
return cleanupErr
|
||||
}
|
||||
object, err := createAnnotatedTag(t.Context(), fixture.work, "v1.2.3", fixture.commit, []byte("note\n"), remove)
|
||||
if !errors.Is(err, cleanupErr) || object == "" || removed == "" || !strings.Contains(err.Error(), removed) {
|
||||
t.Fatalf("tag object = %q, cleanup path = %q, error = %v", object, removed, err)
|
||||
}
|
||||
if got := gitText(t, fixture.work, "rev-parse", "refs/tags/v1.2.3"); got != object {
|
||||
t.Fatalf("local tag = %q, want %q", got, object)
|
||||
}
|
||||
_, err = createAnnotatedTag(t.Context(), fixture.work, "v1.2.3", fixture.commit, []byte("note\n"), remove)
|
||||
var exit *exec.ExitError
|
||||
if !errors.As(err, &exit) || !errors.Is(err, cleanupErr) {
|
||||
t.Fatalf("tag creation and cleanup error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTagReferenceValidationUsesGitRules(t *testing.T) {
|
||||
fixture := newRemoteFixture(t)
|
||||
for _, tag := range []string{"", "v1..2.3", "v1.2.3.lock", "v1.2.3/", "v1.2.3\\bad"} {
|
||||
if err := checkTagRef(context.Background(), fixture.work, tag); err == nil {
|
||||
t.Errorf("checkTagRef(%q) succeeded", tag)
|
||||
if err := CheckTagRef(context.Background(), fixture.work, tag); err == nil {
|
||||
t.Errorf("CheckTagRef(%q) succeeded", tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -142,3 +164,52 @@ func TestInspectTaggedSourceReportsDirtyCheckoutAndReadCommitFile(t *testing.T)
|
||||
t.Fatalf("dirty status = %q", source.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommittedReadsIgnoreReplacementObjects(t *testing.T) {
|
||||
for _, kind := range []string{"commit", "tree", "blob"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
fixture := newRemoteFixture(t)
|
||||
original := map[string]string{
|
||||
"commit": fixture.commit,
|
||||
"tree": gitText(t, fixture.work, "rev-parse", "HEAD^{tree}"),
|
||||
"blob": gitText(t, fixture.work, "rev-parse", "HEAD:source.txt"),
|
||||
}
|
||||
writeRepoFile(t, fixture.work, "source.txt", "replacement")
|
||||
gitRun(t, fixture.work, "add", "source.txt")
|
||||
gitRun(t, fixture.work, "commit", "-m", "Replacement")
|
||||
replacement := map[string]string{
|
||||
"commit": gitText(t, fixture.work, "rev-parse", "HEAD"),
|
||||
"tree": gitText(t, fixture.work, "rev-parse", "HEAD^{tree}"),
|
||||
"blob": gitText(t, fixture.work, "rev-parse", "HEAD:source.txt"),
|
||||
}
|
||||
gitRun(t, fixture.work, "reset", "--hard", fixture.commit)
|
||||
gitRun(t, fixture.work, "replace", original[kind], replacement[kind])
|
||||
|
||||
content, err := ReadCommitFile(t.Context(), fixture.work, fixture.commit, "source.txt")
|
||||
if err != nil || string(content) != "candidate" {
|
||||
t.Fatalf("committed content = %q, error = %v", content, err)
|
||||
}
|
||||
entries, err := ReadTree(t.Context(), fixture.work, fixture.commit)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var object string
|
||||
for _, entry := range entries {
|
||||
if entry.Path == "source.txt" {
|
||||
object = entry.ObjectID
|
||||
}
|
||||
}
|
||||
if object != original["blob"] {
|
||||
t.Fatalf("tree blob = %q, want %q", object, original["blob"])
|
||||
}
|
||||
reader := NewBlobReader(t.Context(), fixture.work)
|
||||
content, err = reader.Read(t.Context(), object)
|
||||
if err != nil || string(content) != "candidate" {
|
||||
t.Fatalf("batch content = %q, error = %v", content, err)
|
||||
}
|
||||
if err := reader.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,15 +3,21 @@ package git
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CreateAnnotatedTag records the captured note as an unsigned annotated tag
|
||||
// on the captured commit and returns the tag object's ID.
|
||||
// on the captured commit. Once its type and ID are established, the ID is
|
||||
// returned even if a later verification fails.
|
||||
func CreateAnnotatedTag(ctx context.Context, repositoryDir, tag, commit string, note []byte) (string, error) {
|
||||
if err := checkTagRef(ctx, repositoryDir, tag); err != nil {
|
||||
return createAnnotatedTag(ctx, repositoryDir, tag, commit, note, os.Remove)
|
||||
}
|
||||
|
||||
func createAnnotatedTag(ctx context.Context, repositoryDir, tag, commit string, note []byte, remove func(string) error) (objectID string, resultErr error) {
|
||||
if err := CheckTagRef(ctx, repositoryDir, tag); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !validObjectID(commit) {
|
||||
@@ -22,7 +28,11 @@ func CreateAnnotatedTag(ctx context.Context, repositoryDir, tag, commit string,
|
||||
return "", fmt.Errorf("create temporary release note: %w", err)
|
||||
}
|
||||
messagePath := message.Name()
|
||||
defer os.Remove(messagePath)
|
||||
defer func() {
|
||||
if err := remove(messagePath); err != nil {
|
||||
resultErr = errors.Join(resultErr, fmt.Errorf("remove temporary release note %q: %w", messagePath, err))
|
||||
}
|
||||
}()
|
||||
if _, err := message.Write(note); err != nil {
|
||||
_ = message.Close()
|
||||
return "", fmt.Errorf("write temporary release note: %w", err)
|
||||
@@ -35,71 +45,52 @@ func CreateAnnotatedTag(ctx context.Context, repositoryDir, tag, commit string,
|
||||
}
|
||||
|
||||
ref := "refs/tags/" + tag
|
||||
object, err := inspectCreatedTag(ctx, repositoryDir, ref, commit, note)
|
||||
if err != nil {
|
||||
return object, fmt.Errorf("verify created tag %s; local ref may remain: %w", ref, err)
|
||||
}
|
||||
return object, nil
|
||||
}
|
||||
|
||||
func inspectCreatedTag(ctx context.Context, repositoryDir, ref, commit string, note []byte) (string, error) {
|
||||
tagType, err := run(ctx, repositoryDir, nil, "cat-file", "-t", ref)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("inspect created tag %s: %w", tag, err)
|
||||
return "", fmt.Errorf("inspect created tag %s: %w", ref, err)
|
||||
}
|
||||
if strings.TrimSpace(tagType) != "tag" {
|
||||
return "", fmt.Errorf("created tag %s has object type %q, want tag", tag, strings.TrimSpace(tagType))
|
||||
return "", fmt.Errorf("created tag %s has object type %q, want tag", ref, strings.TrimSpace(tagType))
|
||||
}
|
||||
tagObject, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read created tag object %s: %w", tag, err)
|
||||
return "", fmt.Errorf("read created tag object %s: %w", ref, err)
|
||||
}
|
||||
tagObject = strings.TrimSpace(tagObject)
|
||||
if !validObjectID(tagObject) {
|
||||
return "", fmt.Errorf("created tag %s has invalid object ID %q", tag, tagObject)
|
||||
return "", fmt.Errorf("created tag %s has invalid object ID %q", ref, tagObject)
|
||||
}
|
||||
peeled, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref+"^{}")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read peeled commit for tag %s: %w", tag, err)
|
||||
return tagObject, fmt.Errorf("read peeled commit for tag %s: %w", ref, err)
|
||||
}
|
||||
if strings.TrimSpace(peeled) != commit {
|
||||
return "", fmt.Errorf("created tag %s peels to %s, want candidate %s", tag, strings.TrimSpace(peeled), commit)
|
||||
return tagObject, fmt.Errorf("created tag %s peels to %s, want candidate %s", ref, strings.TrimSpace(peeled), commit)
|
||||
}
|
||||
tagContents, err := run(ctx, repositoryDir, nil, "cat-file", "tag", ref)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read annotated tag %s: %w", tag, err)
|
||||
return tagObject, fmt.Errorf("read annotated tag %s: %w", ref, err)
|
||||
}
|
||||
separator := bytes.Index([]byte(tagContents), []byte("\n\n"))
|
||||
if separator < 0 || !bytes.Equal([]byte(tagContents[separator+2:]), note) {
|
||||
return "", fmt.Errorf("annotated tag %s message does not match the captured release note", tag)
|
||||
return tagObject, fmt.Errorf("annotated tag %s message does not match the captured release note", ref)
|
||||
}
|
||||
return tagObject, nil
|
||||
}
|
||||
|
||||
// InspectLocalTag confirms the selected tag still points to the exact
|
||||
// annotated tag object captured before publication.
|
||||
func InspectLocalTag(ctx context.Context, repositoryDir, tag, expectedObject string) error {
|
||||
if err := checkTagRef(ctx, repositoryDir, tag); err != nil {
|
||||
return err
|
||||
}
|
||||
if !validObjectID(expectedObject) {
|
||||
return fmt.Errorf("invalid expected tag object identity %q at %q", expectedObject, tag)
|
||||
}
|
||||
ref := "refs/tags/" + tag
|
||||
tagType, err := run(ctx, repositoryDir, nil, "cat-file", "-t", ref)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect local tag %s: %w", tag, err)
|
||||
}
|
||||
if strings.TrimSpace(tagType) != "tag" {
|
||||
return fmt.Errorf("local tag %s has type %q, want annotated tag", tag, strings.TrimSpace(tagType))
|
||||
}
|
||||
object, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read local tag %s: %w", tag, err)
|
||||
}
|
||||
if strings.TrimSpace(object) != expectedObject {
|
||||
return fmt.Errorf("local tag %s identifies %s, want %s", tag, strings.TrimSpace(object), expectedObject)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// QueryTag verifies the captured endpoint still resolves to itself, then reads
|
||||
// one exact authoritative version tag and its peeled commit. Failed
|
||||
// verification or reads are unreadable and never interpreted as absence.
|
||||
func (endpoint Endpoint) QueryTag(ctx context.Context, repositoryDir, tag string) (RemoteRef, error) {
|
||||
if err := checkTagRef(ctx, repositoryDir, tag); err != nil {
|
||||
if err := CheckTagRef(ctx, repositoryDir, tag); err != nil {
|
||||
return RemoteRef{}, err
|
||||
}
|
||||
if err := endpoint.verifyScopedURLs(ctx, repositoryDir); err != nil {
|
||||
@@ -109,13 +100,13 @@ func (endpoint Endpoint) QueryTag(ctx context.Context, repositoryDir, tag string
|
||||
peeledRef := ref + "^{}"
|
||||
output, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "ls-remote", endpoint.ScopedName, ref, peeledRef)
|
||||
if err != nil {
|
||||
return RemoteRef{State: RefUnreadable}, fmt.Errorf("%w: query authoritative tag %s at %s: %w", ErrRemoteStateUnreadable, tag, endpoint.PushURL, err)
|
||||
return RemoteRef{State: RefUnreadable}, fmt.Errorf("%w: query authoritative tag %s at %s: %w", ErrRemoteStateUnreadable, tag, DisplayURL(endpoint.PushURL), err)
|
||||
}
|
||||
remoteTag := RemoteRef{State: RefAbsent}
|
||||
for _, line := range outputLines(output) {
|
||||
object, name, ok := strings.Cut(line, "\t")
|
||||
if !ok || !validObjectID(object) {
|
||||
return RemoteRef{State: RefUnreadable}, fmt.Errorf("%w: malformed ls-remote output %q", ErrRemoteStateUnreadable, line)
|
||||
return RemoteRef{State: RefUnreadable}, fmt.Errorf("%w: malformed ls-remote output %q", ErrRemoteStateUnreadable, displayDiagnostic(line))
|
||||
}
|
||||
switch name {
|
||||
case ref:
|
||||
@@ -124,20 +115,20 @@ func (endpoint Endpoint) QueryTag(ctx context.Context, repositoryDir, tag string
|
||||
remoteTag = mergeRef(remoteTag, object, true)
|
||||
}
|
||||
}
|
||||
return remoteTag, nil
|
||||
return finalizeRef(remoteTag), nil
|
||||
}
|
||||
|
||||
// PushTag publishes only the captured tag object to its exact tag ref.
|
||||
func (endpoint Endpoint) PushTag(ctx context.Context, repositoryDir, tag, tagObject string) error {
|
||||
if err := checkTagRef(ctx, repositoryDir, tag); err != nil {
|
||||
if err := CheckTagRef(ctx, repositoryDir, tag); err != nil {
|
||||
return err
|
||||
}
|
||||
if !validObjectID(tagObject) {
|
||||
return fmt.Errorf("invalid tag publication object %q at %q", tagObject, tag)
|
||||
}
|
||||
refspec := tagObject + ":refs/tags/" + tag
|
||||
if _, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "push", "--no-follow-tags", "--porcelain", endpoint.ScopedName, refspec); err != nil {
|
||||
return fmt.Errorf("push annotated tag %s to %s: %w", tag, endpoint.PushURL, err)
|
||||
if _, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "push", "--no-follow-tags", "--recurse-submodules=no", "--porcelain", endpoint.ScopedName, refspec); err != nil {
|
||||
return fmt.Errorf("push annotated tag %s to %s: %w", tag, DisplayURL(endpoint.PushURL), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2,43 +2,14 @@ package gitea
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"go.maximumdirect.net/releasekit/internal/artifact"
|
||||
)
|
||||
|
||||
const ManifestFilename = artifact.ManifestFilename
|
||||
|
||||
type AssetState string
|
||||
|
||||
const (
|
||||
AssetMatching AssetState = "matching"
|
||||
AssetMissing AssetState = "missing"
|
||||
AssetConflicting AssetState = "conflicting"
|
||||
AssetUnverifiable AssetState = "unverifiable"
|
||||
)
|
||||
|
||||
// ExpectedAsset describes one declared file. Content is populated for the
|
||||
// generated checksum manifest; Path identifies local binary content for later
|
||||
// upload operations.
|
||||
type ExpectedAsset struct {
|
||||
Filename string
|
||||
SHA256 string
|
||||
Size int64
|
||||
Path string
|
||||
Content []byte
|
||||
}
|
||||
|
||||
type RemoteAsset struct {
|
||||
ID int64
|
||||
Filename string
|
||||
@@ -46,18 +17,6 @@ type RemoteAsset struct {
|
||||
DownloadURL string
|
||||
}
|
||||
|
||||
type AssetInspection struct {
|
||||
Expected ExpectedAsset
|
||||
State AssetState
|
||||
Existing []RemoteAsset
|
||||
Err error
|
||||
}
|
||||
|
||||
type Inspection struct {
|
||||
Assets []AssetInspection
|
||||
Unrelated []RemoteAsset
|
||||
}
|
||||
|
||||
type assetResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -65,118 +24,48 @@ type assetResponse struct {
|
||||
BrowserDownloadURL string `json:"browser_download_url"`
|
||||
}
|
||||
|
||||
// BuildManifest creates the deterministic checksum file for the declared
|
||||
// binaries. An empty binary set produces no manifest.
|
||||
func BuildManifest(binaries []ExpectedAsset) (ExpectedAsset, error) {
|
||||
if len(binaries) == 0 {
|
||||
return ExpectedAsset{}, nil
|
||||
}
|
||||
ordered := append([]ExpectedAsset(nil), binaries...)
|
||||
sort.Slice(ordered, func(i, j int) bool { return ordered[i].Filename < ordered[j].Filename })
|
||||
var body strings.Builder
|
||||
seen := make(map[string]struct{}, len(ordered))
|
||||
for _, binary := range ordered {
|
||||
if !validAssetName(binary.Filename) || binary.Filename == ManifestFilename || !validSHA256(binary.SHA256) {
|
||||
return ExpectedAsset{}, failure(FailureInvalid, "build checksum manifest", 0, fmt.Errorf("invalid binary identity for %q", binary.Filename))
|
||||
}
|
||||
if _, exists := seen[binary.Filename]; exists {
|
||||
return ExpectedAsset{}, failure(FailureInvalid, "build checksum manifest", 0, fmt.Errorf("duplicate binary filename %q", binary.Filename))
|
||||
}
|
||||
seen[binary.Filename] = struct{}{}
|
||||
fmt.Fprintf(&body, "%s %s\n", binary.SHA256, binary.Filename)
|
||||
}
|
||||
content := []byte(body.String())
|
||||
return ExpectedAsset{Filename: ManifestFilename, SHA256: digest(content), Size: int64(len(content)), Content: content}, nil
|
||||
}
|
||||
|
||||
// InspectAssets lists the release's attachments and compares every declared
|
||||
// asset by streamed SHA-256 content. It does not create or modify remote data.
|
||||
func (c Client) InspectAssets(ctx context.Context, destination Destination, releaseID int64, expected []ExpectedAsset) (Inspection, error) {
|
||||
func (c Client) listAssets(ctx context.Context, client *http.Client, token string, releaseID int64, apiRoot string) ([]RemoteAsset, error) {
|
||||
if releaseID <= 0 {
|
||||
return Inspection{}, failure(FailureInvalid, "inspect release assets", 0, errors.New("release ID must be positive"))
|
||||
return nil, failure(FailureInvalid, "list release assets", 0, errors.New("release ID must be positive"))
|
||||
}
|
||||
if strings.TrimSpace(destination.Token) == "" {
|
||||
return Inspection{}, failure(FailureInvalid, "inspect release assets", 0, errors.New("Gitea token is required"))
|
||||
}
|
||||
base, err := parseBaseURL(destination.BaseURL)
|
||||
if err != nil {
|
||||
return Inspection{}, failure(FailureInvalid, "validate destination", 0, err)
|
||||
}
|
||||
if strings.TrimSpace(destination.Owner) == "" || strings.ContainsAny(destination.Owner, "/\\") || strings.TrimSpace(destination.Repository) == "" || strings.ContainsAny(destination.Repository, "/\\") {
|
||||
return Inspection{}, failure(FailureInvalid, "inspect release assets", 0, errors.New("Gitea owner and repository must be path segments"))
|
||||
}
|
||||
if err := validateExpectedAssets(expected); err != nil {
|
||||
return Inspection{}, err
|
||||
}
|
||||
apiRoot := strings.TrimRight(base.String(), "/") + "/api/v1/repos/" + url.PathEscape(destination.Owner) + "/" + url.PathEscape(destination.Repository)
|
||||
assetsURL := apiRoot + "/releases/" + strconv.FormatInt(releaseID, 10) + "/assets"
|
||||
var listed []assetResponse
|
||||
if err := c.getJSON(ctx, c.httpClient(), destination.Token, "list release assets", assetsURL, &listed); err != nil {
|
||||
return Inspection{}, err
|
||||
if err := c.getJSON(ctx, client, token, "list release assets", assetsURL, &listed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if listed == nil {
|
||||
return Inspection{}, failure(FailureMalformed, "decode release assets", http.StatusOK, errors.New("attachment response must be a JSON array"))
|
||||
return nil, failure(FailureMalformed, "decode release assets", http.StatusOK, errors.New("attachment response must be a JSON array"))
|
||||
}
|
||||
|
||||
remoteByName := make(map[string][]RemoteAsset, len(listed))
|
||||
assets := make([]RemoteAsset, 0, len(listed))
|
||||
for _, item := range listed {
|
||||
if item.ID <= 0 || item.Name == "" || item.Size < 0 {
|
||||
return Inspection{}, failure(FailureMalformed, "decode release assets", http.StatusOK, errors.New("attachment entry is missing its ID, name, or size"))
|
||||
return nil, failure(FailureMalformed, "decode release assets", http.StatusOK, errors.New("attachment entry is missing its ID, name, or size"))
|
||||
}
|
||||
remote := RemoteAsset{ID: item.ID, Filename: item.Name, Size: item.Size, DownloadURL: item.BrowserDownloadURL}
|
||||
remoteByName[item.Name] = append(remoteByName[item.Name], remote)
|
||||
assets = append(assets, RemoteAsset{ID: item.ID, Filename: item.Name, Size: item.Size, DownloadURL: item.BrowserDownloadURL})
|
||||
}
|
||||
inspection := Inspection{Assets: make([]AssetInspection, 0, len(expected))}
|
||||
declared := make(map[string]struct{}, len(expected))
|
||||
for _, item := range expected {
|
||||
declared[item.Filename] = struct{}{}
|
||||
matches := remoteByName[item.Filename]
|
||||
observation := AssetInspection{Expected: item, Existing: matches}
|
||||
switch len(matches) {
|
||||
case 0:
|
||||
observation.State = AssetMissing
|
||||
case 1:
|
||||
remoteHash, _, err := c.downloadHash(ctx, c.httpClient(), base, destination.Token, matches[0].DownloadURL)
|
||||
if err != nil {
|
||||
observation.State = AssetUnverifiable
|
||||
observation.Err = err
|
||||
} else if remoteHash == item.SHA256 {
|
||||
observation.State = AssetMatching
|
||||
} else {
|
||||
observation.State = AssetConflicting
|
||||
}
|
||||
default:
|
||||
observation.State = AssetConflicting
|
||||
}
|
||||
inspection.Assets = append(inspection.Assets, observation)
|
||||
}
|
||||
for _, item := range listed {
|
||||
if _, expected := declared[item.Name]; !expected {
|
||||
inspection.Unrelated = append(inspection.Unrelated, RemoteAsset{ID: item.ID, Filename: item.Name, Size: item.Size, DownloadURL: item.BrowserDownloadURL})
|
||||
}
|
||||
}
|
||||
return inspection, nil
|
||||
return assets, nil
|
||||
}
|
||||
|
||||
func (c Client) downloadHash(ctx context.Context, client *http.Client, base *url.URL, token, rawURL string) (string, int64, error) {
|
||||
func (c Client) openAsset(ctx context.Context, client *http.Client, base *url.URL, token, rawURL string) (io.ReadCloser, error) {
|
||||
if strings.TrimSpace(rawURL) == "" {
|
||||
return "", 0, failure(FailureUnverifiable, "download release asset", 0, errors.New("attachment has no download URL"))
|
||||
return nil, failure(FailureUnverifiable, "download release asset", 0, errors.New("attachment has no download URL"))
|
||||
}
|
||||
assetURL, err := base.Parse(rawURL)
|
||||
if err != nil || assetURL.User != nil {
|
||||
return "", 0, failure(FailureUnverifiable, "download release asset", 0, errors.New("invalid attachment URL"))
|
||||
return nil, failure(FailureUnverifiable, "download release asset", 0, errors.New("invalid attachment URL"))
|
||||
}
|
||||
assetURL.Fragment = ""
|
||||
visited := make(map[string]struct{})
|
||||
followed := 0
|
||||
for {
|
||||
key := assetURL.String()
|
||||
if _, exists := visited[key]; exists {
|
||||
return "", 0, failure(FailureUnverifiable, "download release asset", 0, errors.New("asset redirect loop"))
|
||||
return nil, failure(FailureUnverifiable, "download release asset", 0, errors.New("asset redirect loop"))
|
||||
}
|
||||
visited[key] = struct{}{}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, key, nil)
|
||||
if err != nil {
|
||||
return "", 0, failure(FailureUnverifiable, "download release asset", 0, err)
|
||||
return nil, failure(FailureUnverifiable, "download release asset", 0, err)
|
||||
}
|
||||
if sameOrigin(base, assetURL) {
|
||||
request.Header.Set("Authorization", "token "+token)
|
||||
@@ -184,65 +73,31 @@ func (c Client) downloadHash(ctx context.Context, client *http.Client, base *url
|
||||
request.Header.Set("Accept", "application/octet-stream")
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return "", 0, failure(FailureUnverifiable, "download release asset", 0, err)
|
||||
return nil, failure(FailureUnverifiable, "download release asset", 0, err)
|
||||
}
|
||||
if isRedirect(response.StatusCode) {
|
||||
if followed == 10 {
|
||||
_ = response.Body.Close()
|
||||
return nil, failure(FailureUnverifiable, "redirect release asset: redirect limit exceeded", response.StatusCode, errors.New("at most 10 redirects are allowed"))
|
||||
}
|
||||
location, locationErr := response.Location()
|
||||
_ = response.Body.Close()
|
||||
if locationErr != nil || location.User != nil {
|
||||
return "", 0, failure(FailureUnverifiable, "redirect release asset", response.StatusCode, errors.New("invalid attachment redirect"))
|
||||
return nil, failure(FailureUnverifiable, "redirect release asset", response.StatusCode, errors.New("invalid attachment redirect"))
|
||||
}
|
||||
location.Fragment = ""
|
||||
assetURL = location
|
||||
followed++
|
||||
continue
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return "", 0, responseFailure("download release asset", response.StatusCode)
|
||||
_ = response.Body.Close()
|
||||
return nil, responseFailure("download release asset", response.StatusCode)
|
||||
}
|
||||
hasher := sha256.New()
|
||||
size, err := io.Copy(hasher, response.Body)
|
||||
if err != nil {
|
||||
return "", size, failure(FailureUnverifiable, "read release asset", response.StatusCode, err)
|
||||
}
|
||||
return hex.EncodeToString(hasher.Sum(nil)), size, nil
|
||||
return response.Body, nil
|
||||
}
|
||||
}
|
||||
|
||||
func validateExpectedAssets(expected []ExpectedAsset) error {
|
||||
seen := make(map[string]struct{}, len(expected))
|
||||
for _, item := range expected {
|
||||
if !validAssetName(item.Filename) || !validSHA256(item.SHA256) || item.Size < 0 {
|
||||
return failure(FailureInvalid, "inspect release assets", 0, fmt.Errorf("invalid expected asset %q", item.Filename))
|
||||
}
|
||||
if _, duplicate := seen[item.Filename]; duplicate {
|
||||
return failure(FailureInvalid, "inspect release assets", 0, fmt.Errorf("duplicate expected asset %q", item.Filename))
|
||||
}
|
||||
seen[item.Filename] = struct{}{}
|
||||
if len(item.Content) > 0 && (int64(len(item.Content)) != item.Size || digest(item.Content) != item.SHA256) {
|
||||
return failure(FailureInvalid, "inspect release assets", 0, fmt.Errorf("expected content does not match identity for %q", item.Filename))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validAssetName(name string) bool {
|
||||
return name != "" && name != "." && name != ".." && path.Base(name) == name && !strings.ContainsAny(name, "\\\r\n\x00")
|
||||
}
|
||||
|
||||
func validSHA256(value string) bool {
|
||||
if len(value) != sha256.Size*2 {
|
||||
return false
|
||||
}
|
||||
_, err := hex.DecodeString(value)
|
||||
return err == nil && strings.ToLower(value) == value
|
||||
}
|
||||
|
||||
func digest(content []byte) string {
|
||||
sum := sha256.Sum256(content)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func sameOrigin(base, target *url.URL) bool {
|
||||
if target == nil || target.User != nil || target.Host == "" {
|
||||
return false
|
||||
|
||||
@@ -2,327 +2,204 @@ package gitea
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"go.maximumdirect.net/releasekit/internal/forge"
|
||||
)
|
||||
|
||||
const assetListPath = "/api/v1/repos/owner/project/releases/10/assets"
|
||||
|
||||
func TestBuildManifestSortsNamesAndOmitsEmptySourceOnlyManifest(t *testing.T) {
|
||||
manifest, err := BuildManifest([]ExpectedAsset{
|
||||
assetFor("zeta.bin", []byte("zeta")),
|
||||
assetFor("alpha.bin", []byte("alpha")),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := fmt.Sprintf("%s alpha.bin\n%s zeta.bin\n", digest([]byte("alpha")), digest([]byte("zeta")))
|
||||
if string(manifest.Content) != want || manifest.Filename != ManifestFilename || manifest.SHA256 != digest([]byte(want)) || manifest.Size != int64(len(want)) {
|
||||
t.Fatalf("manifest = %#v, content %q", manifest, manifest.Content)
|
||||
}
|
||||
empty, err := BuildManifest(nil)
|
||||
if err != nil || empty.Filename != "" || len(empty.Content) != 0 {
|
||||
t.Fatalf("empty manifest = %#v, error = %v", empty, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectAssetsAcceptsMatchingContentAndPreservesUnrelatedFiles(t *testing.T) {
|
||||
content := []byte("selected binary")
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertAssetGET(t, r, "")
|
||||
switch r.URL.Path {
|
||||
case assetListPath:
|
||||
writeAssetList(t, w, []assetResponse{
|
||||
{ID: 4, Name: "app-v1.2.3-linux-amd64", Size: int64(len(content)), BrowserDownloadURL: "/files/app"},
|
||||
{ID: 8, Name: "notes.txt", Size: 9, BrowserDownloadURL: "/files/notes"},
|
||||
})
|
||||
case "/files/app":
|
||||
_, _ = w.Write(content)
|
||||
case "/files/notes":
|
||||
_, _ = w.Write([]byte("unrelated"))
|
||||
default:
|
||||
t.Errorf("unexpected path %s", r.URL.Path)
|
||||
return
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
inspection, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, []ExpectedAsset{assetFor("app-v1.2.3-linux-amd64", content)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(inspection.Assets) != 1 || inspection.Assets[0].State != AssetMatching {
|
||||
t.Fatalf("inspection = %#v", inspection)
|
||||
}
|
||||
if len(inspection.Unrelated) != 1 || inspection.Unrelated[0].Filename != "notes.txt" {
|
||||
t.Fatalf("unrelated = %#v", inspection.Unrelated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectAssetsDetectsSameNameAndSizeWithDifferentBytes(t *testing.T) {
|
||||
expected := []ExpectedAsset{assetFor("app.bin", []byte("alpha")), assetFor("other.bin", []byte("beta"))}
|
||||
var appDownloads, otherDownloads atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertAssetGET(t, r, "")
|
||||
if r.URL.Path == assetListPath {
|
||||
writeAssetList(t, w, []assetResponse{
|
||||
{ID: 2, Name: expected[0].Filename, Size: 5, BrowserDownloadURL: "/files/app"},
|
||||
{ID: 3, Name: expected[1].Filename, Size: 4, BrowserDownloadURL: "/files/other"},
|
||||
})
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/files/app" {
|
||||
appDownloads.Add(1)
|
||||
_, _ = w.Write([]byte("bravo"))
|
||||
return
|
||||
}
|
||||
otherDownloads.Add(1)
|
||||
_, _ = w.Write([]byte("beta"))
|
||||
}))
|
||||
defer server.Close()
|
||||
inspection, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, expected)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if inspection.Assets[0].State != AssetConflicting || inspection.Assets[1].State != AssetMatching || appDownloads.Load() != 1 || otherDownloads.Load() != 1 {
|
||||
t.Fatalf("inspection = %#v, downloads = %d and %d", inspection, appDownloads.Load(), otherDownloads.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectAssetsAcceptsFullManifestWhenBinariesAreMissing(t *testing.T) {
|
||||
binaries := []ExpectedAsset{assetFor("alpha.bin", []byte("alpha")), assetFor("beta.bin", []byte("beta"))}
|
||||
manifest, err := BuildManifest(binaries)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertAssetGET(t, r, "")
|
||||
if r.URL.Path == assetListPath {
|
||||
writeAssetList(t, w, []assetResponse{{ID: 3, Name: manifest.Filename, Size: manifest.Size, BrowserDownloadURL: "/files/manifest"}})
|
||||
return
|
||||
}
|
||||
if r.URL.Path != "/files/manifest" {
|
||||
t.Errorf("unexpected path %s", r.URL.Path)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(manifest.Content)
|
||||
}))
|
||||
defer server.Close()
|
||||
inspection, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, append(binaries, manifest))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(inspection.Assets) != 3 || inspection.Assets[0].State != AssetMissing || inspection.Assets[1].State != AssetMissing || inspection.Assets[2].State != AssetMatching {
|
||||
t.Fatalf("inspection = %#v", inspection)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectAssetsRejectsSubsetManifest(t *testing.T) {
|
||||
binaries := []ExpectedAsset{assetFor("alpha.bin", []byte("alpha")), assetFor("beta.bin", []byte("beta"))}
|
||||
full, err := BuildManifest(binaries)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
subset, err := BuildManifest(binaries[:1])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertAssetGET(t, r, "")
|
||||
if r.URL.Path == assetListPath {
|
||||
writeAssetList(t, w, []assetResponse{{ID: 3, Name: ManifestFilename, Size: subset.Size, BrowserDownloadURL: "/files/manifest"}})
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(subset.Content)
|
||||
}))
|
||||
defer server.Close()
|
||||
inspection, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, []ExpectedAsset{full})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if inspection.Assets[0].State != AssetConflicting {
|
||||
t.Fatalf("inspection = %#v", inspection)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectAssetsRejectsDifferentManifest(t *testing.T) {
|
||||
expected, err := BuildManifest([]ExpectedAsset{assetFor("alpha.bin", []byte("alpha"))})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
different := []byte("not the declared checksum list\n")
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertAssetGET(t, r, "")
|
||||
if r.URL.Path == assetListPath {
|
||||
writeAssetList(t, w, []assetResponse{{ID: 3, Name: ManifestFilename, Size: int64(len(different)), BrowserDownloadURL: "/files/manifest"}})
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(different)
|
||||
}))
|
||||
defer server.Close()
|
||||
inspection, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, []ExpectedAsset{expected})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if inspection.Assets[0].State != AssetConflicting {
|
||||
t.Fatalf("inspection = %#v", inspection)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectAssetsMarksUnreadableContentUnverifiable(t *testing.T) {
|
||||
expected := assetFor("app.bin", []byte("alpha"))
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertAssetGET(t, r, "")
|
||||
if r.URL.Path == assetListPath {
|
||||
writeAssetList(t, w, []assetResponse{{ID: 2, Name: expected.Filename, Size: expected.Size, BrowserDownloadURL: "/files/app"}})
|
||||
return
|
||||
}
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
}))
|
||||
defer server.Close()
|
||||
inspection, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, []ExpectedAsset{expected})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if inspection.Assets[0].State != AssetUnverifiable || inspection.Assets[0].Err == nil {
|
||||
t.Fatalf("inspection = %#v", inspection)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectAssetsRejectsMalformedAttachmentList(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertAssetGET(t, r, assetListPath)
|
||||
_, _ = w.Write([]byte("null"))
|
||||
}))
|
||||
defer server.Close()
|
||||
_, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, []ExpectedAsset{assetFor("app.bin", []byte("alpha"))})
|
||||
if failureKind(t, err) != FailureMalformed {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectAssetsSourceOnlyDoesNotDeclareManifest(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertAssetGET(t, r, assetListPath)
|
||||
writeAssetList(t, w, []assetResponse{{ID: 7, Name: "historical.txt", Size: 3}})
|
||||
}))
|
||||
defer server.Close()
|
||||
inspection, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(inspection.Assets) != 0 || len(inspection.Unrelated) != 1 || inspection.Unrelated[0].Filename != "historical.txt" {
|
||||
t.Fatalf("inspection = %#v", inspection)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectAssetsFollowsSameOriginRedirectWithAuthorization(t *testing.T) {
|
||||
content := []byte("alpha")
|
||||
expected := assetFor("app.bin", content)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertAssetGET(t, r, "")
|
||||
if r.URL.Path == assetListPath {
|
||||
writeAssetList(t, w, []assetResponse{{ID: 1, Name: expected.Filename, Size: expected.Size, BrowserDownloadURL: "/files/redirect"}})
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/files/redirect" {
|
||||
http.Redirect(w, r, "/files/content", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/files/content" {
|
||||
_, _ = w.Write(content)
|
||||
return
|
||||
}
|
||||
t.Errorf("unexpected path %s", r.URL.Path)
|
||||
return
|
||||
}))
|
||||
defer server.Close()
|
||||
inspection, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, []ExpectedAsset{expected})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if inspection.Assets[0].State != AssetMatching {
|
||||
t.Fatalf("inspection = %#v", inspection)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectAssetsVerifiesExternalContentWithoutGiteaToken(t *testing.T) {
|
||||
for _, mode := range []string{"direct", "redirect", "conflicting"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
expected := assetFor("app.bin", []byte("matching bytes"))
|
||||
var externalGets atomic.Int64
|
||||
external := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
externalGets.Add(1)
|
||||
if r.Header.Get("Authorization") != "" {
|
||||
t.Error("Gitea token reached external host")
|
||||
func TestBackendOpenAssetBoundsRedirects(t *testing.T) {
|
||||
for _, hops := range []int{0, 10, 11} {
|
||||
t.Run(strconv.Itoa(hops), func(t *testing.T) {
|
||||
var requests atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests.Add(1)
|
||||
if got := r.Header.Get("Authorization"); got != "token fixture-secret-token" {
|
||||
t.Errorf("authorization = %q", got)
|
||||
}
|
||||
content := []byte("matching bytes")
|
||||
if mode == "conflicting" {
|
||||
content = []byte("different bytes")
|
||||
}
|
||||
w.Write(content)
|
||||
}))
|
||||
defer external.Close()
|
||||
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertAssetGET(t, r, "")
|
||||
if r.URL.Path == assetListPath {
|
||||
download := external.URL + "/content"
|
||||
if mode == "redirect" {
|
||||
download = "/redirect"
|
||||
}
|
||||
writeAssetList(t, w, []assetResponse{{ID: 1, Name: expected.Filename, Size: expected.Size, BrowserDownloadURL: download}})
|
||||
step, err := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/"))
|
||||
if err != nil {
|
||||
t.Errorf("invalid path %q", r.URL.Path)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, external.URL+"/content", http.StatusFound)
|
||||
if step < hops {
|
||||
http.Redirect(w, r, fmt.Sprintf("/%d", step+1), http.StatusFound)
|
||||
return
|
||||
}
|
||||
_, _ = io.WriteString(w, "asset")
|
||||
}))
|
||||
defer origin.Close()
|
||||
inspection, err := testClient(origin).InspectAssets(context.Background(), testDestination(origin.URL), 10, []ExpectedAsset{expected})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
defer server.Close()
|
||||
backend := NewBackend(testDestination(server.URL), server.Client())
|
||||
stream, err := backend.OpenAsset(t.Context(), assetHandle{downloadURL: "/0"})
|
||||
if hops <= 10 {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, readErr := io.ReadAll(stream)
|
||||
closeErr := stream.Close()
|
||||
if readErr != nil || closeErr != nil || string(body) != "asset" || requests.Load() != int32(hops+1) {
|
||||
t.Fatalf("body = %q, read = %v, close = %v, requests = %d", body, readErr, closeErr, requests.Load())
|
||||
}
|
||||
return
|
||||
}
|
||||
want := AssetMatching
|
||||
if mode == "conflicting" {
|
||||
want = AssetConflicting
|
||||
}
|
||||
if inspection.Assets[0].State != want || externalGets.Load() != 1 {
|
||||
t.Fatalf("inspection=%#v downloads=%d", inspection, externalGets.Load())
|
||||
var failure *forge.Failure
|
||||
if !errors.As(err, &failure) || failure.Kind != forge.FailureUnverifiable || !strings.Contains(err.Error(), "redirect limit") || requests.Load() != 11 || stream != nil {
|
||||
t.Fatalf("stream = %v, error = %v, requests = %d", stream, err, requests.Load())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assetFor(filename string, content []byte) ExpectedAsset {
|
||||
sum := sha256.Sum256(content)
|
||||
return ExpectedAsset{Filename: filename, SHA256: hex.EncodeToString(sum[:]), Size: int64(len(content)), Content: append([]byte(nil), content...)}
|
||||
}
|
||||
|
||||
func writeAssetList(t *testing.T, w http.ResponseWriter, assets []assetResponse) {
|
||||
t.Helper()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(assets); err != nil {
|
||||
t.Error(err)
|
||||
func TestBackendOpenAssetRedirectReturnsToOrigin(t *testing.T) {
|
||||
var origin *httptest.Server
|
||||
external := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("Authorization"); got != "" {
|
||||
t.Errorf("cross-origin authorization = %q", got)
|
||||
}
|
||||
http.Redirect(w, r, origin.URL+"/final", http.StatusFound)
|
||||
}))
|
||||
defer external.Close()
|
||||
origin = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("Authorization"); got != "token fixture-secret-token" {
|
||||
t.Errorf("origin authorization = %q", got)
|
||||
}
|
||||
if r.URL.Path == "/start" {
|
||||
http.Redirect(w, r, external.URL+"/away", http.StatusFound)
|
||||
return
|
||||
}
|
||||
_, _ = io.WriteString(w, "asset")
|
||||
}))
|
||||
defer origin.Close()
|
||||
stream, err := NewBackend(testDestination(origin.URL), origin.Client()).OpenAsset(t.Context(), assetHandle{downloadURL: "/start"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer stream.Close()
|
||||
if body, err := io.ReadAll(stream); err != nil || string(body) != "asset" {
|
||||
t.Fatalf("body = %q, error = %v", body, err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertAssetGET(t *testing.T, r *http.Request, expectedPath string) {
|
||||
t.Helper()
|
||||
if r.Method != http.MethodGet {
|
||||
t.Errorf("method = %q, want GET", r.Method)
|
||||
}
|
||||
if expectedPath != "" && r.URL.Path != expectedPath {
|
||||
t.Errorf("path = %q, want %q", r.URL.Path, expectedPath)
|
||||
}
|
||||
if got := r.Header.Get("Authorization"); got != "token "+testToken {
|
||||
t.Errorf("Authorization = %q", got)
|
||||
func TestBackendOpenAssetRejectsRedirectCycle(t *testing.T) {
|
||||
var requests atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests.Add(1)
|
||||
http.Redirect(w, r, "/loop#different-fragment", http.StatusFound)
|
||||
}))
|
||||
defer server.Close()
|
||||
stream, err := NewBackend(testDestination(server.URL), server.Client()).OpenAsset(t.Context(), assetHandle{downloadURL: "/loop"})
|
||||
var failure *forge.Failure
|
||||
if !errors.As(err, &failure) || failure.Kind != forge.FailureUnverifiable || !strings.Contains(err.Error(), "loop") || requests.Load() != 1 || stream != nil {
|
||||
t.Fatalf("stream = %v, error = %v, requests = %d", stream, err, requests.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func testDestination(base string) Destination {
|
||||
return Destination{BaseURL: base, Owner: "owner", Repository: "project", Token: testToken}
|
||||
func TestBackendListAssetsRejectsMalformedAttachmentList(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet || r.URL.Path != "/api/v1/repos/owner/project/releases/10/assets" {
|
||||
t.Errorf("request = %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
if got, want := r.Header.Get("Authorization"), "token fixture-secret-token"; got != want {
|
||||
t.Errorf("authorization = %q, want %q", got, want)
|
||||
}
|
||||
_, _ = io.WriteString(w, "null")
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
_, err := NewBackend(testDestination(server.URL), server.Client()).ListAssets(context.Background(), releaseHandle{id: 10})
|
||||
var failure *forge.Failure
|
||||
if !errors.As(err, &failure) || failure.Kind != forge.FailureMalformed {
|
||||
t.Fatalf("ListAssets() error = %v, want malformed attachment response", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendOpenAssetFollowsSameOriginRedirectWithAuthorization(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got, want := r.Header.Get("Authorization"), "token fixture-secret-token"; got != want {
|
||||
t.Errorf("authorization at %s = %q, want %q", r.URL.Path, got, want)
|
||||
}
|
||||
switch r.URL.Path {
|
||||
case "/files/redirect":
|
||||
http.Redirect(w, r, "/files/content", http.StatusFound)
|
||||
case "/files/content":
|
||||
_, _ = io.WriteString(w, "streamed asset")
|
||||
default:
|
||||
t.Errorf("unexpected path %q", r.URL.Path)
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
backend := NewBackend(testDestination(server.URL), server.Client())
|
||||
stream, err := backend.OpenAsset(context.Background(), assetHandle{downloadURL: "/files/redirect"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content, readErr := io.ReadAll(stream)
|
||||
closeErr := stream.Close()
|
||||
if readErr != nil || closeErr != nil || string(content) != "streamed asset" {
|
||||
t.Fatalf("OpenAsset() content = %q, read error = %v, close error = %v", content, readErr, closeErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendOpenAssetDoesNotForwardAuthorizationAcrossOrigin(t *testing.T) {
|
||||
for _, mode := range []string{"direct", "redirect"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
var externalRequests atomic.Int32
|
||||
external := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
externalRequests.Add(1)
|
||||
if got := r.Header.Get("Authorization"); got != "" {
|
||||
t.Errorf("external authorization = %q, want empty", got)
|
||||
}
|
||||
_, _ = io.WriteString(w, "external asset")
|
||||
}))
|
||||
defer external.Close()
|
||||
|
||||
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got, want := r.Header.Get("Authorization"), "token fixture-secret-token"; got != want {
|
||||
t.Errorf("origin authorization = %q, want %q", got, want)
|
||||
}
|
||||
http.Redirect(w, r, external.URL+"/content", http.StatusFound)
|
||||
}))
|
||||
defer origin.Close()
|
||||
|
||||
downloadURL := external.URL + "/content"
|
||||
if mode == "redirect" {
|
||||
downloadURL = "/redirect"
|
||||
}
|
||||
backend := NewBackend(testDestination(origin.URL), origin.Client())
|
||||
stream, err := backend.OpenAsset(context.Background(), assetHandle{downloadURL: downloadURL})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content, readErr := io.ReadAll(stream)
|
||||
closeErr := stream.Close()
|
||||
if readErr != nil || closeErr != nil || string(content) != "external asset" || externalRequests.Load() != 1 {
|
||||
t.Fatalf("OpenAsset() content = %q, read error = %v, close error = %v, external requests = %d", content, readErr, closeErr, externalRequests.Load())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendListAssetsDecodesAttachmentMetadata(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode([]assetResponse{{ID: 4, Name: "app.bin", Size: 4, BrowserDownloadURL: "/files/app"}})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
assets, err := NewBackend(testDestination(server.URL), server.Client()).ListAssets(context.Background(), releaseHandle{id: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(assets) != 1 || assets[0].Filename != "app.bin" || assets[0].Size != 4 || assets[0].Handle == nil {
|
||||
t.Fatalf("ListAssets() = %#v", assets)
|
||||
}
|
||||
}
|
||||
|
||||
339
internal/gitea/backend.go
Normal file
339
internal/gitea/backend.go
Normal file
@@ -0,0 +1,339 @@
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"go.maximumdirect.net/releasekit/internal/forge"
|
||||
)
|
||||
|
||||
// Backend adapts a configured Gitea destination to the hosted delivery
|
||||
// operations. Its destination and client are private so callers cannot bypass
|
||||
// Gitea's validation or replace opaque handles.
|
||||
type Backend struct {
|
||||
destination Destination
|
||||
client Client
|
||||
}
|
||||
|
||||
// NewBackend creates a Gitea backend with an optional HTTP client.
|
||||
func NewBackend(destination Destination, client *http.Client) *Backend {
|
||||
return &Backend{destination: destination, client: Client{HTTPClient: client}}
|
||||
}
|
||||
|
||||
type releaseHandle struct {
|
||||
id int64
|
||||
}
|
||||
|
||||
type assetHandle struct {
|
||||
id int64
|
||||
downloadURL string
|
||||
}
|
||||
|
||||
type repositoryResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
FullName string `json:"full_name"`
|
||||
Owner struct {
|
||||
Login string `json:"login"`
|
||||
} `json:"owner"`
|
||||
}
|
||||
|
||||
func (b *Backend) Preflight(ctx context.Context) error {
|
||||
if strings.TrimSpace(b.destination.Token) == "" {
|
||||
return &forge.PreflightFailure{Kind: forge.PreflightMissingCredential, Err: errors.New("Gitea token is required")}
|
||||
}
|
||||
base, apiRoot, err := b.destinationAPI("preflight repository access")
|
||||
if err != nil {
|
||||
return &forge.PreflightFailure{Kind: forge.PreflightInvalidDestination, Err: err}
|
||||
}
|
||||
client := b.client.httpClient()
|
||||
var resolved repositoryResponse
|
||||
endpoint := apiRoot
|
||||
if err := b.client.getJSON(ctx, client, b.destination.Token, "resolve repository", endpoint, &resolved); err != nil {
|
||||
return preflightFailure(err)
|
||||
}
|
||||
if !resolved.matches(b.destination) {
|
||||
return &forge.PreflightFailure{Kind: forge.PreflightUnavailable, Err: errors.New("repository response does not identify the configured destination")}
|
||||
}
|
||||
|
||||
var authenticated repositoryResponse
|
||||
apiPrefix := strings.TrimRight(base.String(), "/") + "/api/v1"
|
||||
endpoint = apiPrefix + "/repositories/" + strconv.FormatInt(resolved.ID, 10)
|
||||
if err := b.client.getJSON(ctx, client, b.destination.Token, "verify authenticated repository access", endpoint, &authenticated); err != nil {
|
||||
return preflightFailure(err)
|
||||
}
|
||||
if !authenticated.matches(b.destination) || authenticated.ID != resolved.ID {
|
||||
return &forge.PreflightFailure{Kind: forge.PreflightUnavailable, Err: errors.New("authenticated repository response does not match the resolved destination")}
|
||||
}
|
||||
// Gitea's permissions.push describes Git code pushes, not release or asset
|
||||
// writes. That metadata cannot establish a delivery-permission deficiency.
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Backend) CreateRelease(ctx context.Context, request forge.CreateReleaseRequest) (forge.Release, forge.WriteEvidence, error) {
|
||||
if strings.TrimSpace(request.Tag) == "" || !validObjectID(request.Commit) {
|
||||
return forge.Release{}, forge.WriteEvidence{Disposition: forge.WriteNotAttempted}, &forge.Failure{
|
||||
Kind: forge.FailureInvalid, Err: errors.New("a full tag and lowercase Git commit ID are required"),
|
||||
}
|
||||
}
|
||||
_, apiRoot, err := b.destinationAPI("create release")
|
||||
if err != nil {
|
||||
return forge.Release{}, forge.WriteEvidence{Disposition: forge.WriteNotAttempted}, err
|
||||
}
|
||||
body, err := json.Marshal(createReleaseRequest{
|
||||
TagName: request.Tag, TargetCommitish: request.Commit,
|
||||
Name: request.Title, Body: request.Body,
|
||||
Draft: request.Draft, Prerelease: request.Prerelease,
|
||||
})
|
||||
if err != nil {
|
||||
return forge.Release{}, forge.WriteEvidence{Disposition: forge.WriteNotAttempted}, &forge.Failure{Kind: forge.FailureInvalid, Err: err}
|
||||
}
|
||||
created, status, err := b.client.postRelease(ctx, b.client.httpClient(), b.destination.Token, apiRoot+"/releases", body)
|
||||
if err == nil && created.ID <= 0 {
|
||||
err = failure(FailureMalformed, "verify created release", status, errors.New("release response does not contain a positive ID"))
|
||||
}
|
||||
evidence := writeEvidence(status, err)
|
||||
if err != nil {
|
||||
return forge.Release{}, evidence, neutralFailure(err)
|
||||
}
|
||||
return forge.Release{
|
||||
URL: created.URL,
|
||||
Tag: created.TagName,
|
||||
Title: created.Title,
|
||||
Body: created.Body,
|
||||
Draft: created.Draft,
|
||||
Prerelease: created.Prerelease,
|
||||
Handle: releaseHandle{id: created.ID},
|
||||
}, evidence, nil
|
||||
}
|
||||
|
||||
func (b *Backend) UploadAsset(ctx context.Context, handle any, filename string, content io.Reader) (forge.WriteEvidence, error) {
|
||||
release, err := assetReleaseHandle(handle)
|
||||
if err != nil {
|
||||
return forge.WriteEvidence{Disposition: forge.WriteNotAttempted}, err
|
||||
}
|
||||
if strings.TrimSpace(filename) == "" || content == nil {
|
||||
return forge.WriteEvidence{Disposition: forge.WriteNotAttempted}, &forge.Failure{Kind: forge.FailureInvalid, Err: errors.New("asset filename and content reader are required")}
|
||||
}
|
||||
_, apiRoot, err := b.destinationAPI("upload release asset")
|
||||
if err != nil {
|
||||
return forge.WriteEvidence{Disposition: forge.WriteNotAttempted}, err
|
||||
}
|
||||
status, err := b.client.uploadAsset(ctx, b.client.httpClient(), b.destination.Token, apiRoot, release.id, filename, content)
|
||||
evidence := writeEvidence(status, err)
|
||||
if err != nil {
|
||||
return evidence, neutralFailure(err)
|
||||
}
|
||||
return evidence, nil
|
||||
}
|
||||
|
||||
func writeEvidence(status int, err error) forge.WriteEvidence {
|
||||
evidence := forge.WriteEvidence{Attempted: status != 0, Disposition: forge.WriteNotAttempted}
|
||||
var backendFailure *Failure
|
||||
if err != nil && errors.As(err, &backendFailure) && backendFailure.Kind == FailureTransport {
|
||||
evidence.Attempted = true
|
||||
}
|
||||
if !evidence.Attempted {
|
||||
return evidence
|
||||
}
|
||||
if err == nil {
|
||||
evidence.Disposition = forge.WriteAccepted
|
||||
return evidence
|
||||
}
|
||||
if status == http.StatusConflict {
|
||||
evidence.Disposition = forge.WriteConflict
|
||||
return evidence
|
||||
}
|
||||
if status >= http.StatusInternalServerError || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
evidence.Disposition = forge.WriteUncertain
|
||||
return evidence
|
||||
}
|
||||
if errors.As(err, &backendFailure) && (backendFailure.Kind == FailureTransport || backendFailure.Kind == FailureMalformed) {
|
||||
evidence.Disposition = forge.WriteUncertain
|
||||
return evidence
|
||||
}
|
||||
evidence.Disposition = forge.WriteRejected
|
||||
return evidence
|
||||
}
|
||||
|
||||
func (repository repositoryResponse) matches(destination Destination) bool {
|
||||
if repository.ID <= 0 || !strings.EqualFold(repository.Name, destination.Repository) {
|
||||
return false
|
||||
}
|
||||
if !strings.EqualFold(repository.FullName, destination.Owner+"/"+destination.Repository) {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(repository.Owner.Login, destination.Owner)
|
||||
}
|
||||
|
||||
func preflightFailure(err error) error {
|
||||
var backendFailure *Failure
|
||||
if !errors.As(err, &backendFailure) {
|
||||
return &forge.PreflightFailure{Kind: forge.PreflightUnavailable, Err: err}
|
||||
}
|
||||
kind := forge.PreflightUnavailable
|
||||
switch backendFailure.Kind {
|
||||
case FailureUnauthorized:
|
||||
if backendFailure.StatusCode == http.StatusUnauthorized {
|
||||
kind = forge.PreflightInvalidCredential
|
||||
} else {
|
||||
kind = forge.PreflightInaccessibleDestination
|
||||
}
|
||||
case FailureNotFound:
|
||||
kind = forge.PreflightInaccessibleDestination
|
||||
case FailureRejected:
|
||||
if backendFailure.StatusCode >= http.StatusInternalServerError {
|
||||
kind = forge.PreflightUnavailable
|
||||
}
|
||||
}
|
||||
return &forge.PreflightFailure{Kind: kind, Err: neutralFailure(err)}
|
||||
}
|
||||
|
||||
func (b *Backend) TagCommit(ctx context.Context, tag string) (string, error) {
|
||||
if strings.TrimSpace(tag) == "" {
|
||||
return "", &forge.Failure{Kind: forge.FailureInvalid, Err: errors.New("full tag is required")}
|
||||
}
|
||||
_, apiRoot, err := b.destinationAPI("get hosted tag")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var observed tagResponse
|
||||
endpoint := apiRoot + "/tags/" + url.PathEscape(tag)
|
||||
if err := b.client.getJSON(ctx, b.client.httpClient(), b.destination.Token, "get hosted tag", endpoint, &observed); err != nil {
|
||||
return "", neutralFailure(err)
|
||||
}
|
||||
if observed.Commit.SHA == "" {
|
||||
return "", &forge.Failure{Kind: forge.FailureMalformed, Err: errors.New("tag response does not identify a commit")}
|
||||
}
|
||||
return observed.Commit.SHA, nil
|
||||
}
|
||||
|
||||
func (b *Backend) FindRelease(ctx context.Context, tag string) (forge.Release, error) {
|
||||
if strings.TrimSpace(tag) == "" {
|
||||
return forge.Release{}, &forge.Failure{Kind: forge.FailureInvalid, Err: errors.New("full tag is required")}
|
||||
}
|
||||
_, apiRoot, err := b.destinationAPI("find release")
|
||||
if err != nil {
|
||||
return forge.Release{}, err
|
||||
}
|
||||
endpoint := apiRoot + "/releases/tags/" + url.PathEscape(tag)
|
||||
release, _, err := b.client.getRelease(ctx, b.client.httpClient(), b.destination.Token, endpoint)
|
||||
if err != nil {
|
||||
return forge.Release{}, neutralFailure(err)
|
||||
}
|
||||
return forge.Release{
|
||||
URL: release.URL,
|
||||
Tag: release.TagName,
|
||||
Title: release.Title,
|
||||
Body: release.Body,
|
||||
Draft: release.Draft,
|
||||
Prerelease: release.Prerelease,
|
||||
Handle: releaseHandle{id: release.ID},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (b *Backend) ListAssets(ctx context.Context, handle any) ([]forge.Asset, error) {
|
||||
release, err := assetReleaseHandle(handle)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, apiRoot, err := b.destinationAPI("list release assets")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
remote, err := b.client.listAssets(ctx, b.client.httpClient(), b.destination.Token, release.id, apiRoot)
|
||||
if err != nil {
|
||||
return nil, neutralFailure(err)
|
||||
}
|
||||
assets := make([]forge.Asset, 0, len(remote))
|
||||
for _, item := range remote {
|
||||
assets = append(assets, forge.Asset{
|
||||
Filename: item.Filename,
|
||||
Size: item.Size,
|
||||
Handle: assetHandle{id: item.ID, downloadURL: item.DownloadURL},
|
||||
})
|
||||
}
|
||||
return assets, nil
|
||||
}
|
||||
|
||||
func assetReleaseHandle(handle any) (releaseHandle, error) {
|
||||
release, ok := handle.(releaseHandle)
|
||||
if !ok {
|
||||
return releaseHandle{}, &forge.Failure{Kind: forge.FailureInvalid, Err: errors.New("release handle was not created by the Gitea backend")}
|
||||
}
|
||||
if release.id <= 0 {
|
||||
return releaseHandle{}, &forge.Failure{Kind: forge.FailureMalformed, Err: errors.New("release response does not contain a positive release ID")}
|
||||
}
|
||||
return release, nil
|
||||
}
|
||||
|
||||
func (b *Backend) OpenAsset(ctx context.Context, handle any) (io.ReadCloser, error) {
|
||||
asset, ok := handle.(assetHandle)
|
||||
if !ok {
|
||||
return nil, &forge.Failure{Kind: forge.FailureInvalid, Err: errors.New("asset handle was not created by the Gitea backend")}
|
||||
}
|
||||
base, _, err := b.destinationAPI("download release asset")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stream, err := b.client.openAsset(ctx, b.client.httpClient(), base, b.destination.Token, asset.downloadURL)
|
||||
if err != nil {
|
||||
return nil, neutralFailure(err)
|
||||
}
|
||||
return stream, nil
|
||||
}
|
||||
|
||||
func (b *Backend) destinationAPI(operation string) (*url.URL, string, error) {
|
||||
base, err := parseBaseURL(b.destination.BaseURL)
|
||||
if err != nil {
|
||||
return nil, "", neutralFailure(failure(FailureInvalid, operation, 0, err))
|
||||
}
|
||||
if strings.TrimSpace(b.destination.Token) == "" {
|
||||
return nil, "", neutralFailure(failure(FailureInvalid, operation, 0, errors.New("Gitea token is required")))
|
||||
}
|
||||
if strings.TrimSpace(b.destination.Owner) == "" || strings.ContainsAny(b.destination.Owner, "/\\") {
|
||||
return nil, "", neutralFailure(failure(FailureInvalid, operation, 0, errors.New("Gitea owner must be one path segment")))
|
||||
}
|
||||
if strings.TrimSpace(b.destination.Repository) == "" || strings.ContainsAny(b.destination.Repository, "/\\") {
|
||||
return nil, "", neutralFailure(failure(FailureInvalid, operation, 0, errors.New("Gitea repository must be one path segment")))
|
||||
}
|
||||
apiRoot := strings.TrimRight(base.String(), "/") + "/api/v1/repos/" + url.PathEscape(b.destination.Owner) + "/" + url.PathEscape(b.destination.Repository)
|
||||
return base, apiRoot, nil
|
||||
}
|
||||
|
||||
func neutralFailure(err error) error {
|
||||
var giteaFailure *Failure
|
||||
if !errors.As(err, &giteaFailure) {
|
||||
return err
|
||||
}
|
||||
kind := forge.FailureUnknown
|
||||
switch giteaFailure.Kind {
|
||||
case FailureInvalid:
|
||||
kind = forge.FailureInvalid
|
||||
case FailureNotFound:
|
||||
kind = forge.FailureNotFound
|
||||
case FailureUnauthorized:
|
||||
kind = forge.FailureUnauthorized
|
||||
case FailureConflict:
|
||||
kind = forge.FailureConflict
|
||||
case FailureMalformed:
|
||||
kind = forge.FailureMalformed
|
||||
case FailureUnverifiable:
|
||||
kind = forge.FailureUnverifiable
|
||||
case FailureTransport:
|
||||
kind = forge.FailureTransport
|
||||
case FailureRejected:
|
||||
kind = forge.FailureRejected
|
||||
case FailureUnknown:
|
||||
kind = forge.FailureUnknown
|
||||
}
|
||||
return &forge.Failure{Kind: kind, Err: err}
|
||||
}
|
||||
|
||||
var _ forge.Backend = (*Backend)(nil)
|
||||
525
internal/gitea/backend_test.go
Normal file
525
internal/gitea/backend_test.go
Normal file
@@ -0,0 +1,525 @@
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go.maximumdirect.net/releasekit/internal/forge"
|
||||
)
|
||||
|
||||
func TestBackendTagCommitUsesEscapedFullTagAndAuthentication(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got, want := r.RequestURI, "/api/v1/repos/owner/project/tags/v1.2.3%2Frc.1"; got != want {
|
||||
t.Errorf("request URI = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := r.Header.Get("Authorization"), "token fixture-secret-token"; got != want {
|
||||
t.Errorf("authorization = %q, want %q", got, want)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"commit": map[string]string{"sha": "abcdef"}})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
backend := NewBackend(testDestination(server.URL), server.Client())
|
||||
commit, err := backend.TagCommit(context.Background(), "v1.2.3/rc.1")
|
||||
if err != nil || commit != "abcdef" {
|
||||
t.Fatalf("TagCommit() = %q, %v", commit, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendFindReleaseReturnsNeutralNotFound(t *testing.T) {
|
||||
server := httptest.NewServer(http.NotFoundHandler())
|
||||
defer server.Close()
|
||||
backend := NewBackend(testDestination(server.URL), server.Client())
|
||||
|
||||
_, err := backend.FindRelease(context.Background(), "v1.2.3")
|
||||
var failure *forge.Failure
|
||||
if !errors.As(err, &failure) || failure.Kind != forge.FailureNotFound {
|
||||
t.Fatalf("FindRelease() error = %v, want neutral not-found failure", err)
|
||||
}
|
||||
var cause *Failure
|
||||
if !errors.As(err, &cause) || cause.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("FindRelease() error cause = %v, want Gitea 404 diagnostic", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendFindReleaseDoesNotRequireWriteResponseFields(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(releaseResponse{TagName: "v1.2.3", Name: "Release"})
|
||||
}))
|
||||
defer server.Close()
|
||||
backend := NewBackend(testDestination(server.URL), server.Client())
|
||||
|
||||
release, err := backend.FindRelease(context.Background(), "v1.2.3")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if release.Tag != "v1.2.3" || release.Title != "Release" || release.Handle == nil {
|
||||
t.Fatalf("FindRelease() = %#v", release)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendAssetOperationsRequireObservedReleaseID(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, body string
|
||||
valid bool
|
||||
}{
|
||||
{"missing", `{"tag_name":"v1.2.3"}`, false},
|
||||
{"zero", `{"id":0,"tag_name":"v1.2.3"}`, false},
|
||||
{"negative", `{"id":-1,"tag_name":"v1.2.3"}`, false},
|
||||
{"valid", `{"id":12,"tag_name":"v1.2.3"}`, true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
requests := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Contains(r.URL.Path, "/releases/tags/") {
|
||||
_, _ = io.WriteString(w, tc.body)
|
||||
return
|
||||
}
|
||||
requests++
|
||||
if r.Method == http.MethodGet {
|
||||
_, _ = io.WriteString(w, `[]`)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = io.WriteString(w, `{"id":1,"name":"file"}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
backend := NewBackend(testDestination(server.URL), server.Client())
|
||||
release, err := backend.FindRelease(context.Background(), "v1.2.3")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, listErr := backend.ListAssets(context.Background(), release.Handle)
|
||||
evidence, uploadErr := backend.UploadAsset(context.Background(), release.Handle, "file", strings.NewReader("data"))
|
||||
if tc.valid {
|
||||
if listErr != nil || uploadErr != nil || requests != 2 {
|
||||
t.Fatalf("valid: list %v, upload %v, requests %d", listErr, uploadErr, requests)
|
||||
}
|
||||
return
|
||||
}
|
||||
for _, err := range []error{listErr, uploadErr} {
|
||||
var failure *forge.Failure
|
||||
if !errors.As(err, &failure) || failure.Kind != forge.FailureMalformed {
|
||||
t.Fatalf("failure = %v", err)
|
||||
}
|
||||
}
|
||||
if requests != 0 || evidence.Attempted || evidence.Disposition != forge.WriteNotAttempted {
|
||||
t.Fatalf("requests = %d, evidence = %#v", requests, evidence)
|
||||
}
|
||||
})
|
||||
}
|
||||
backend := NewBackend(Destination{}, nil)
|
||||
_, listErr := backend.ListAssets(context.Background(), "wrong")
|
||||
evidence, uploadErr := backend.UploadAsset(context.Background(), "wrong", "file", strings.NewReader("data"))
|
||||
for _, err := range []error{listErr, uploadErr} {
|
||||
var failure *forge.Failure
|
||||
if !errors.As(err, &failure) || failure.Kind != forge.FailureInvalid {
|
||||
t.Fatalf("wrong handle failure = %v", err)
|
||||
}
|
||||
}
|
||||
if evidence.Attempted || evidence.Disposition != forge.WriteNotAttempted {
|
||||
t.Fatalf("wrong handle evidence = %#v", evidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendListsMetadataAndOpensCallerOwnedAssetStream(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got, want := r.Header.Get("Authorization"), "token fixture-secret-token"; got != want {
|
||||
t.Errorf("authorization = %q, want %q", got, want)
|
||||
}
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/repos/owner/project/releases/tags/v1.2.3":
|
||||
_ = json.NewEncoder(w).Encode(releaseResponse{ID: 12, TagName: "v1.2.3"})
|
||||
case "/api/v1/repos/owner/project/releases/12/assets":
|
||||
_ = json.NewEncoder(w).Encode([]assetResponse{{ID: 4, Name: "app.bin", Size: 4, BrowserDownloadURL: "/files/app"}})
|
||||
case "/files/app":
|
||||
_, _ = io.WriteString(w, "data")
|
||||
default:
|
||||
t.Errorf("unexpected path %q", r.URL.Path)
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
backend := NewBackend(testDestination(server.URL), server.Client())
|
||||
|
||||
release, err := backend.FindRelease(context.Background(), "v1.2.3")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assets, err := backend.ListAssets(context.Background(), release.Handle)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(assets) != 1 || assets[0].Filename != "app.bin" || assets[0].Size != 4 || assets[0].Handle == nil {
|
||||
t.Fatalf("ListAssets() = %#v", assets)
|
||||
}
|
||||
stream, err := backend.OpenAsset(context.Background(), assets[0].Handle)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content, readErr := io.ReadAll(stream)
|
||||
closeErr := stream.Close()
|
||||
if readErr != nil || closeErr != nil || string(content) != "data" {
|
||||
t.Fatalf("OpenAsset() content = %q, read error = %v, close error = %v", content, readErr, closeErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendRejectsAssetHandleFromAnotherBackendType(t *testing.T) {
|
||||
backend := NewBackend(testDestination("http://example.test"), nil)
|
||||
_, err := backend.OpenAsset(context.Background(), strings.NewReader("asset"))
|
||||
var failure *forge.Failure
|
||||
if !errors.As(err, &failure) || failure.Kind != forge.FailureInvalid {
|
||||
t.Fatalf("OpenAsset() error = %v, want invalid handle", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendPreflightUsesAuthenticatedRepositoryReadsAndIgnoresCodePushPermission(t *testing.T) {
|
||||
var requests []string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
t.Errorf("method = %q, want GET", r.Method)
|
||||
}
|
||||
if got, want := r.Header.Get("Authorization"), "token fixture-secret-token"; got != want {
|
||||
t.Errorf("authorization = %q, want %q", got, want)
|
||||
}
|
||||
requests = append(requests, r.URL.Path)
|
||||
writeRepository := func() {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": 31, "name": "project", "full_name": "owner/project",
|
||||
"owner": map[string]string{"login": "owner"},
|
||||
"permissions": map[string]bool{"admin": false, "push": false, "pull": true},
|
||||
})
|
||||
}
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/repos/owner/project", "/api/v1/repositories/31":
|
||||
writeRepository()
|
||||
default:
|
||||
t.Errorf("unexpected path %q", r.URL.Path)
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
if err := NewBackend(testDestination(server.URL), server.Client()).Preflight(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(requests) != 2 || requests[0] != "/api/v1/repos/owner/project" || requests[1] != "/api/v1/repositories/31" {
|
||||
t.Fatalf("preflight requests = %v, want both repository reads", requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendPreflightRequiresCredentialWithoutMakingRequests(t *testing.T) {
|
||||
var requests int
|
||||
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { requests++ }))
|
||||
defer server.Close()
|
||||
destination := testDestination(server.URL)
|
||||
destination.Token = " \t"
|
||||
|
||||
err := NewBackend(destination, server.Client()).Preflight(context.Background())
|
||||
var failure *forge.PreflightFailure
|
||||
if !errors.As(err, &failure) || failure.Kind != forge.PreflightMissingCredential {
|
||||
t.Fatalf("Preflight() error = %v, want missing credential", err)
|
||||
}
|
||||
if requests != 0 {
|
||||
t.Fatalf("made %d requests without a credential", requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendPreflightDoesNotAcceptPublicLookupWithoutAuthenticatedAccess(t *testing.T) {
|
||||
var requests []string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests = append(requests, r.URL.Path)
|
||||
if r.URL.Path == "/api/v1/repos/owner/project" {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"id": 31, "name": "project", "full_name": "owner/project", "owner": map[string]string{"login": "owner"}})
|
||||
return
|
||||
}
|
||||
http.Error(w, "token rejected", http.StatusUnauthorized)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := NewBackend(testDestination(server.URL), server.Client()).Preflight(context.Background())
|
||||
var failure *forge.PreflightFailure
|
||||
if !errors.As(err, &failure) || failure.Kind != forge.PreflightInvalidCredential {
|
||||
t.Fatalf("Preflight() error = %v, want invalid credential", err)
|
||||
}
|
||||
if len(requests) != 2 || requests[1] != "/api/v1/repositories/31" {
|
||||
t.Fatalf("preflight requests = %v, want authenticated repository read", requests)
|
||||
}
|
||||
if strings.Contains(err.Error(), testDestination(server.URL).Token) {
|
||||
t.Fatalf("preflight error exposed the credential: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendPreflightClassifiesDeniedAndHiddenRepositoriesAsInaccessible(t *testing.T) {
|
||||
for _, status := range []int{http.StatusForbidden, http.StatusNotFound} {
|
||||
t.Run(http.StatusText(status), func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "repository unavailable", status)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := NewBackend(testDestination(server.URL), server.Client()).Preflight(context.Background())
|
||||
var failure *forge.PreflightFailure
|
||||
if !errors.As(err, &failure) || failure.Kind != forge.PreflightInaccessibleDestination {
|
||||
t.Fatalf("Preflight() error = %v, want inaccessible destination", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendPreflightClassifiesUnverifiableResponsesAsUnavailable(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
respond func(http.ResponseWriter)
|
||||
}{
|
||||
{name: "malformed JSON", respond: func(w http.ResponseWriter) { _, _ = io.WriteString(w, "{") }},
|
||||
{name: "service failure", respond: func(w http.ResponseWriter) { http.Error(w, "temporarily unavailable", http.StatusServiceUnavailable) }},
|
||||
{name: "wrong repository identity", respond: func(w http.ResponseWriter) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"id": 31, "name": "other", "full_name": "owner/other", "owner": map[string]string{"login": "owner"}})
|
||||
}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { test.respond(w) }))
|
||||
defer server.Close()
|
||||
|
||||
err := NewBackend(testDestination(server.URL), server.Client()).Preflight(context.Background())
|
||||
var failure *forge.PreflightFailure
|
||||
if !errors.As(err, &failure) || failure.Kind != forge.PreflightUnavailable {
|
||||
t.Fatalf("Preflight() error = %v, want unavailable", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendPreflightPreservesCancellation(t *testing.T) {
|
||||
server := httptest.NewServer(http.NotFoundHandler())
|
||||
defer server.Close()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
err := NewBackend(testDestination(server.URL), server.Client()).Preflight(ctx)
|
||||
var failure *forge.PreflightFailure
|
||||
if !errors.As(err, &failure) || failure.Kind != forge.PreflightUnavailable || !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Preflight() error = %v, want unavailable and context cancellation", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendCreateReleaseSendsRequestedFieldsAndAcceptsProtocolAcknowledgment(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/repos/owner/project/releases" {
|
||||
t.Errorf("request = %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
if got, want := r.Header.Get("Authorization"), "token fixture-secret-token"; got != want {
|
||||
t.Errorf("authorization = %q, want %q", got, want)
|
||||
}
|
||||
var request createReleaseRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
t.Errorf("decode request: %v", err)
|
||||
}
|
||||
if request.TagName != "v1.2.3" || request.TargetCommitish != strings.Repeat("a", 40) || request.Name != "Release title" || request.Body != "# Release title\n\nComplete notes." || !request.Draft || !request.Prerelease {
|
||||
t.Errorf("release request = %#v", request)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(releaseResponse{ID: 12, TagName: "server tag", Name: "server title", Body: "server body", Draft: true, Prerelease: true})
|
||||
}))
|
||||
defer server.Close()
|
||||
backend := NewBackend(testDestination(server.URL), server.Client())
|
||||
|
||||
release, evidence, err := backend.CreateRelease(context.Background(), forge.CreateReleaseRequest{
|
||||
Tag: "v1.2.3", Commit: strings.Repeat("a", 40), Title: "Release title",
|
||||
Body: "# Release title\n\nComplete notes.", Draft: true, Prerelease: true,
|
||||
})
|
||||
if err != nil || evidence != (forge.WriteEvidence{Attempted: true, Disposition: forge.WriteAccepted}) {
|
||||
t.Fatalf("CreateRelease() evidence = %#v, error = %v", evidence, err)
|
||||
}
|
||||
if release.Handle == nil || release.Tag != "server tag" || release.Title != "server title" || release.Body != "server body" || !release.Draft || !release.Prerelease {
|
||||
t.Fatalf("CreateRelease() release = %#v", release)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendCreateReleaseMarksInvalidAcknowledgmentUncertain(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(releaseResponse{TagName: "v1.2.3"})
|
||||
}))
|
||||
defer server.Close()
|
||||
backend := NewBackend(testDestination(server.URL), server.Client())
|
||||
|
||||
_, evidence, err := backend.CreateRelease(context.Background(), validCreateReleaseRequest())
|
||||
var failure *forge.Failure
|
||||
if !errors.As(err, &failure) || failure.Kind != forge.FailureMalformed || evidence != (forge.WriteEvidence{Attempted: true, Disposition: forge.WriteUncertain}) {
|
||||
t.Fatalf("CreateRelease() evidence = %#v, error = %v", evidence, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendCreateReleaseClassifiesConflictAndServerFailure(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
status int
|
||||
disposition forge.WriteDisposition
|
||||
}{
|
||||
{status: http.StatusBadRequest, disposition: forge.WriteRejected},
|
||||
{status: http.StatusConflict, disposition: forge.WriteConflict},
|
||||
{status: http.StatusServiceUnavailable, disposition: forge.WriteUncertain},
|
||||
} {
|
||||
t.Run(http.StatusText(test.status), func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(test.status) }))
|
||||
defer server.Close()
|
||||
backend := NewBackend(testDestination(server.URL), server.Client())
|
||||
|
||||
_, evidence, err := backend.CreateRelease(context.Background(), validCreateReleaseRequest())
|
||||
if err == nil || evidence != (forge.WriteEvidence{Attempted: true, Disposition: test.disposition}) {
|
||||
t.Fatalf("CreateRelease() evidence = %#v, error = %v", evidence, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendUploadAssetBorrowsReaderAndValidatesAcknowledgment(t *testing.T) {
|
||||
var uploaded string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/repos/owner/project/releases/12/assets" || r.URL.Query().Get("name") != "app.bin" {
|
||||
t.Errorf("request = %s %s?%s", r.Method, r.URL.Path, r.URL.RawQuery)
|
||||
}
|
||||
if got, want := r.Header.Get("Authorization"), "token fixture-secret-token"; got != want {
|
||||
t.Errorf("authorization = %q, want %q", got, want)
|
||||
}
|
||||
file, header, err := r.FormFile("attachment")
|
||||
if err != nil {
|
||||
t.Errorf("attachment: %v", err)
|
||||
} else {
|
||||
defer file.Close()
|
||||
content, readErr := io.ReadAll(file)
|
||||
if readErr != nil {
|
||||
t.Errorf("read attachment: %v", readErr)
|
||||
}
|
||||
if header.Filename != "app.bin" {
|
||||
t.Errorf("multipart filename = %q", header.Filename)
|
||||
}
|
||||
uploaded = string(content)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(assetResponse{ID: 50, Name: "app.bin"})
|
||||
}))
|
||||
defer server.Close()
|
||||
backend := NewBackend(testDestination(server.URL), server.Client())
|
||||
reader := &trackedReader{Reader: strings.NewReader("streamed bytes")}
|
||||
|
||||
evidence, err := backend.UploadAsset(context.Background(), releaseHandle{id: 12}, "app.bin", reader)
|
||||
if err != nil || evidence != (forge.WriteEvidence{Attempted: true, Disposition: forge.WriteAccepted}) {
|
||||
t.Fatalf("UploadAsset() evidence = %#v, error = %v", evidence, err)
|
||||
}
|
||||
if uploaded != "streamed bytes" || reader.closed {
|
||||
t.Fatalf("uploaded = %q, caller reader closed = %v", uploaded, reader.closed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendUploadAssetMarksBadAcknowledgmentUncertain(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(assetResponse{ID: 50, Name: "different.bin"})
|
||||
}))
|
||||
defer server.Close()
|
||||
backend := NewBackend(testDestination(server.URL), server.Client())
|
||||
|
||||
evidence, err := backend.UploadAsset(context.Background(), releaseHandle{id: 12}, "app.bin", strings.NewReader("data"))
|
||||
var failure *forge.Failure
|
||||
if !errors.As(err, &failure) || failure.Kind != forge.FailureMalformed || evidence != (forge.WriteEvidence{Attempted: true, Disposition: forge.WriteUncertain}) {
|
||||
t.Fatalf("UploadAsset() evidence = %#v, error = %v", evidence, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendUploadAssetClassifiesKnownRejectionAndConflict(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
status int
|
||||
disposition forge.WriteDisposition
|
||||
}{
|
||||
{status: http.StatusBadRequest, disposition: forge.WriteRejected},
|
||||
{status: http.StatusConflict, disposition: forge.WriteConflict},
|
||||
{status: http.StatusInternalServerError, disposition: forge.WriteUncertain},
|
||||
} {
|
||||
t.Run(http.StatusText(test.status), func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(test.status) }))
|
||||
defer server.Close()
|
||||
backend := NewBackend(testDestination(server.URL), server.Client())
|
||||
|
||||
evidence, err := backend.UploadAsset(context.Background(), releaseHandle{id: 12}, "app.bin", strings.NewReader("data"))
|
||||
if err == nil || evidence != (forge.WriteEvidence{Attempted: true, Disposition: test.disposition}) {
|
||||
t.Fatalf("UploadAsset() evidence = %#v, error = %v", evidence, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendUploadAssetPreservesCancellationAsUncertain(t *testing.T) {
|
||||
server := httptest.NewServer(http.NotFoundHandler())
|
||||
defer server.Close()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
evidence, err := NewBackend(testDestination(server.URL), server.Client()).UploadAsset(ctx, releaseHandle{id: 12}, "app.bin", strings.NewReader("data"))
|
||||
if evidence != (forge.WriteEvidence{Attempted: true, Disposition: forge.WriteUncertain}) || !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("UploadAsset() evidence = %#v, error = %v", evidence, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendUploadAssetWaitsForMultipartWriterAfterReaderFailure(t *testing.T) {
|
||||
readerErr := errors.New("asset reader failed")
|
||||
readDone := make(chan struct{})
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/owner/project/releases/tags/v1.2.3" {
|
||||
_ = json.NewEncoder(w).Encode(releaseResponse{ID: 10, TagName: "v1.2.3"})
|
||||
return
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, r.Body)
|
||||
_ = json.NewEncoder(w).Encode(assetResponse{ID: 50, Name: "app.bin", Size: 0})
|
||||
}))
|
||||
defer server.Close()
|
||||
backend := NewBackend(testDestination(server.URL), server.Client())
|
||||
release, err := backend.FindRelease(context.Background(), "v1.2.3")
|
||||
if err != nil {
|
||||
t.Fatalf("FindRelease() error = %v", err)
|
||||
}
|
||||
|
||||
evidence, err := backend.UploadAsset(context.Background(), release.Handle, "app.bin", failingUploadReader{err: readerErr, done: readDone})
|
||||
select {
|
||||
case <-readDone:
|
||||
default:
|
||||
t.Fatal("UploadAsset() returned before the multipart worker finished reading")
|
||||
}
|
||||
if !errors.Is(err, readerErr) {
|
||||
t.Fatalf("UploadAsset() error = %v, want reader cause %v", err, readerErr)
|
||||
}
|
||||
if !evidence.Attempted || evidence.Disposition != forge.WriteUncertain {
|
||||
t.Fatalf("UploadAsset() evidence = %#v, want uncertain attempted write", evidence)
|
||||
}
|
||||
}
|
||||
|
||||
type failingUploadReader struct {
|
||||
err error
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func (reader failingUploadReader) Read([]byte) (int, error) {
|
||||
close(reader.done)
|
||||
return 0, reader.err
|
||||
}
|
||||
|
||||
func validCreateReleaseRequest() forge.CreateReleaseRequest {
|
||||
return forge.CreateReleaseRequest{Tag: "v1.2.3", Commit: strings.Repeat("a", 40), Title: "Release", Body: "# Release"}
|
||||
}
|
||||
|
||||
func testDestination(baseURL string) Destination {
|
||||
return Destination{BaseURL: baseURL, Owner: "owner", Repository: "project", Token: "fixture-secret-token"}
|
||||
}
|
||||
|
||||
type trackedReader struct {
|
||||
io.Reader
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (reader *trackedReader) Close() error {
|
||||
reader.closed = true
|
||||
return nil
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type FailureKind string
|
||||
@@ -55,14 +54,7 @@ type Destination struct {
|
||||
Token string
|
||||
}
|
||||
|
||||
type Request struct {
|
||||
Destination Destination
|
||||
Tag string
|
||||
Commit string
|
||||
Note string
|
||||
}
|
||||
|
||||
type Release struct {
|
||||
type release struct {
|
||||
ID int64
|
||||
URL string
|
||||
TagName string
|
||||
@@ -72,12 +64,6 @@ type Release struct {
|
||||
Prerelease bool
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
Release Release
|
||||
Created bool
|
||||
WriteAttempted bool // a create request was issued, including reconciled creates.
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
@@ -108,109 +94,29 @@ type createReleaseRequest struct {
|
||||
Prerelease bool `json:"prerelease"`
|
||||
}
|
||||
|
||||
// EnsureRelease verifies the hosted tag and finds or creates its stable release
|
||||
// page. It never edits a release that already exists.
|
||||
func (c Client) EnsureRelease(ctx context.Context, request Request) (Result, error) {
|
||||
if err := validateRequest(request); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
base, err := parseBaseURL(request.Destination.BaseURL)
|
||||
if err != nil {
|
||||
return Result{}, failure(FailureInvalid, "validate destination", 0, err)
|
||||
}
|
||||
client := c.httpClient()
|
||||
apiRoot := strings.TrimRight(base.String(), "/") + "/api/v1/repos/" + url.PathEscape(request.Destination.Owner) + "/" + url.PathEscape(request.Destination.Repository)
|
||||
tagURL := apiRoot + "/tags/" + url.PathEscape(request.Tag)
|
||||
|
||||
var tag tagResponse
|
||||
if err := c.getJSON(ctx, client, request.Destination.Token, "get hosted tag", tagURL, &tag); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if tag.Commit.SHA != request.Commit {
|
||||
return Result{}, failure(FailureConflict, "verify hosted tag", 0, fmt.Errorf("tag %q targets %q, want %q", request.Tag, tag.Commit.SHA, request.Commit))
|
||||
}
|
||||
|
||||
releaseURL := apiRoot + "/releases/tags/" + url.PathEscape(request.Tag)
|
||||
existing, status, err := c.getRelease(ctx, client, request.Destination.Token, releaseURL)
|
||||
if err == nil {
|
||||
return acceptExisting(existing, request)
|
||||
}
|
||||
if status != http.StatusNotFound {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
title := noteTitle(request.Note)
|
||||
body, err := json.Marshal(createReleaseRequest{
|
||||
TagName: request.Tag, TargetCommitish: request.Commit,
|
||||
Name: title, Body: request.Note,
|
||||
})
|
||||
if err != nil {
|
||||
return Result{}, failure(FailureInvalid, "encode release", 0, err)
|
||||
}
|
||||
created, postStatus, postErr := c.postRelease(ctx, client, request.Destination.Token, apiRoot+"/releases", body)
|
||||
if postErr == nil {
|
||||
result, verifyErr := acceptCreated(created, request)
|
||||
if verifyErr == nil {
|
||||
result.WriteAttempted = true
|
||||
return result, nil
|
||||
}
|
||||
postErr = verifyErr
|
||||
}
|
||||
if !shouldReconcileWrite(postStatus, postErr) {
|
||||
return Result{}, postErr
|
||||
}
|
||||
|
||||
// A conflict or interrupted server response may have followed a successful
|
||||
// create. One lookup resolves only a page with the exact selected content.
|
||||
retry, retryStatus, retryErr := c.getRelease(ctx, client, request.Destination.Token, releaseURL)
|
||||
if retryErr == nil {
|
||||
result, matchErr := acceptExisting(retry, request)
|
||||
if matchErr == nil {
|
||||
result.WriteAttempted = true
|
||||
return result, nil
|
||||
}
|
||||
return Result{}, failure(FailureConflict, "resolve release creation", postStatus, matchErr)
|
||||
}
|
||||
if retryStatus == http.StatusNotFound {
|
||||
if postStatus == http.StatusConflict {
|
||||
return Result{}, failure(FailureConflict, "create release", postStatus, postErr)
|
||||
}
|
||||
return Result{}, failure(FailureUnknown, "resolve release creation", postStatus, postErr)
|
||||
}
|
||||
return Result{}, failure(FailureUnknown, "resolve release creation", postStatus, errors.Join(postErr, retryErr))
|
||||
}
|
||||
|
||||
func shouldReconcileWrite(status int, err error) bool {
|
||||
if status == http.StatusConflict || status >= 500 || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return true
|
||||
}
|
||||
var failure *Failure
|
||||
return errors.As(err, &failure) && (failure.Kind == FailureTransport || failure.Kind == FailureMalformed)
|
||||
}
|
||||
|
||||
func (c Client) getRelease(ctx context.Context, client *http.Client, token, endpoint string) (Release, int, error) {
|
||||
func (c Client) getRelease(ctx context.Context, client *http.Client, token, endpoint string) (release, int, error) {
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return Release{}, 0, failure(FailureInvalid, "find release", 0, err)
|
||||
return release{}, 0, failure(FailureInvalid, "find release", 0, err)
|
||||
}
|
||||
request.Header.Set("Authorization", "token "+token)
|
||||
request.Header.Set("Accept", "application/json")
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return Release{}, 0, failure(FailureTransport, "find release", 0, err)
|
||||
return release{}, 0, failure(FailureTransport, "find release", 0, err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode == http.StatusNotFound {
|
||||
return Release{}, response.StatusCode, failure(FailureNotFound, "find release", response.StatusCode, nil)
|
||||
return release{}, response.StatusCode, failure(FailureNotFound, "find release", response.StatusCode, nil)
|
||||
}
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return Release{}, response.StatusCode, responseFailure("find release", response.StatusCode)
|
||||
return release{}, response.StatusCode, responseFailure("find release", response.StatusCode)
|
||||
}
|
||||
var decoded releaseResponse
|
||||
if err := decodeJSON(response.Body, &decoded); err != nil {
|
||||
return Release{}, response.StatusCode, failure(FailureMalformed, "decode release", response.StatusCode, err)
|
||||
return release{}, response.StatusCode, failure(FailureMalformed, "decode release", response.StatusCode, err)
|
||||
}
|
||||
return decoded.Release(), response.StatusCode, nil
|
||||
return decoded.release(), response.StatusCode, nil
|
||||
}
|
||||
|
||||
func (c Client) getJSON(ctx context.Context, client *http.Client, token, operation, endpoint string, destination any) error {
|
||||
@@ -234,27 +140,27 @@ func (c Client) getJSON(ctx context.Context, client *http.Client, token, operati
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c Client) postRelease(ctx context.Context, client *http.Client, token, endpoint string, body []byte) (Release, int, error) {
|
||||
func (c Client) postRelease(ctx context.Context, client *http.Client, token, endpoint string, body []byte) (release, int, error) {
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return Release{}, 0, failure(FailureInvalid, "create release", 0, err)
|
||||
return release{}, 0, failure(FailureInvalid, "create release", 0, err)
|
||||
}
|
||||
request.Header.Set("Authorization", "token "+token)
|
||||
request.Header.Set("Accept", "application/json")
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return Release{}, 0, failure(FailureTransport, "create release", 0, err)
|
||||
return release{}, 0, failure(FailureTransport, "create release", 0, err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return Release{}, response.StatusCode, responseFailure("create release", response.StatusCode)
|
||||
return release{}, response.StatusCode, responseFailure("create release", response.StatusCode)
|
||||
}
|
||||
var decoded releaseResponse
|
||||
if err := decodeJSON(response.Body, &decoded); err != nil {
|
||||
return Release{}, response.StatusCode, failure(FailureMalformed, "decode created release", response.StatusCode, err)
|
||||
return release{}, response.StatusCode, failure(FailureMalformed, "decode created release", response.StatusCode, err)
|
||||
}
|
||||
return decoded.Release(), response.StatusCode, nil
|
||||
return decoded.release(), response.StatusCode, nil
|
||||
}
|
||||
|
||||
func (c Client) httpClient() *http.Client {
|
||||
@@ -269,28 +175,6 @@ func (c Client) httpClient() *http.Client {
|
||||
return &clientCopy
|
||||
}
|
||||
|
||||
func validateRequest(request Request) error {
|
||||
if strings.TrimSpace(request.Tag) == "" {
|
||||
return failure(FailureInvalid, "validate request", 0, errors.New("full release tag is required"))
|
||||
}
|
||||
if !validObjectID(request.Commit) {
|
||||
return failure(FailureInvalid, "validate request", 0, errors.New("source commit must be a lowercase Git object ID"))
|
||||
}
|
||||
if strings.TrimSpace(request.Destination.Owner) == "" || strings.ContainsAny(request.Destination.Owner, "/\\") {
|
||||
return failure(FailureInvalid, "validate request", 0, errors.New("Gitea owner is required as one path segment"))
|
||||
}
|
||||
if strings.TrimSpace(request.Destination.Repository) == "" || strings.ContainsAny(request.Destination.Repository, "/\\") {
|
||||
return failure(FailureInvalid, "validate request", 0, errors.New("Gitea repository is required as one path segment"))
|
||||
}
|
||||
if strings.TrimSpace(request.Destination.Token) == "" {
|
||||
return failure(FailureInvalid, "validate request", 0, errors.New("Gitea token is required"))
|
||||
}
|
||||
if noteTitle(request.Note) == "" {
|
||||
return failure(FailureInvalid, "validate request", 0, errors.New("release note must begin with a level-one heading"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseBaseURL(raw string) (*url.URL, error) {
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
@@ -302,30 +186,8 @@ func parseBaseURL(raw string) (*url.URL, error) {
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func acceptExisting(release Release, request Request) (Result, error) {
|
||||
if release.TagName != request.Tag || release.Body != request.Note || release.Draft || release.Prerelease {
|
||||
return Result{}, failure(FailureConflict, "verify existing release", 0, errors.New("existing release does not match the committed note and stable tag"))
|
||||
}
|
||||
return Result{Release: release}, nil
|
||||
}
|
||||
|
||||
func acceptCreated(release Release, request Request) (Result, error) {
|
||||
if release.ID <= 0 || release.TagName != request.Tag || release.Body != request.Note || release.Title != noteTitle(request.Note) || release.Draft || release.Prerelease {
|
||||
return Result{}, failure(FailureMalformed, "verify created release", 0, errors.New("created release response does not match the requested stable release"))
|
||||
}
|
||||
return Result{Release: release, Created: true}, nil
|
||||
}
|
||||
|
||||
func (response releaseResponse) Release() Release {
|
||||
return Release{ID: response.ID, URL: response.HTMLURL, TagName: response.TagName, Title: response.Name, Body: response.Body, Draft: response.Draft, Prerelease: response.Prerelease}
|
||||
}
|
||||
|
||||
func noteTitle(note string) string {
|
||||
first := strings.SplitN(note, "\n", 2)[0]
|
||||
if !strings.HasPrefix(first, "# ") {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(strings.TrimPrefix(first, "# "))
|
||||
func (response releaseResponse) release() release {
|
||||
return release{ID: response.ID, URL: response.HTMLURL, TagName: response.TagName, Title: response.Name, Body: response.Body, Draft: response.Draft, Prerelease: response.Prerelease}
|
||||
}
|
||||
|
||||
func validObjectID(value string) bool {
|
||||
|
||||
@@ -1,452 +0,0 @@
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const testCommit = "0123456789012345678901234567890123456789"
|
||||
const testNote = "# Example v1.2.3\n\nRelease details.\n"
|
||||
const testToken = "fixture-secret-token"
|
||||
|
||||
func TestEnsureReleaseFindsMatchingReleaseAndPreservesTitle(t *testing.T) {
|
||||
server, _ := newReleaseServer(t, releaseFixture{title: "Historical title"}, false)
|
||||
result, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Created || result.Release.Title != "Historical title" {
|
||||
t.Fatalf("result = %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureReleaseEscapesSlashContainingTag(t *testing.T) {
|
||||
tag := "integrations/ckconfig/v1.2.3"
|
||||
state := &fixtureState{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertAuth(t, r)
|
||||
switch r.URL.EscapedPath() {
|
||||
case "/api/v1/repos/owner/project/tags/integrations%2Fckconfig%2Fv1.2.3":
|
||||
assertRequest(t, r, http.MethodGet, r.URL.Path)
|
||||
writeTag(t, w, testCommit)
|
||||
case "/api/v1/repos/owner/project/releases/tags/integrations%2Fckconfig%2Fv1.2.3":
|
||||
assertRequest(t, r, http.MethodGet, r.URL.Path)
|
||||
http.NotFound(w, r)
|
||||
case "/api/v1/repos/owner/project/releases":
|
||||
assertRequest(t, r, http.MethodPost, r.URL.Path)
|
||||
state.posts.Add(1)
|
||||
var payload createReleaseRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
if payload.TagName != tag || payload.Name != "Example v1.2.3" || payload.Body != testNote {
|
||||
t.Errorf("create payload = %#v", payload)
|
||||
return
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(releaseResponse{ID: 7, HTMLURL: "https://example.test/release", TagName: tag, TargetCommitish: testCommit, Name: payload.Name, Body: payload.Body}); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
default:
|
||||
t.Errorf("unexpected escaped request %s %s", r.Method, r.URL.EscapedPath())
|
||||
return
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
request := testRequest(server.URL)
|
||||
request.Tag = tag
|
||||
result, err := testClient(server).EnsureRelease(context.Background(), request)
|
||||
if err != nil || !result.Created || state.posts.Load() != 1 {
|
||||
t.Fatalf("nested tag release = %#v, posts %d, error %v", result, state.posts.Load(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureReleaseCreatesMissingPageFromCommittedNote(t *testing.T) {
|
||||
server, _ := newReleaseServer(t, releaseFixture{}, true)
|
||||
result, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Created || result.Release.Title != "Example v1.2.3" || result.Release.Body != testNote {
|
||||
t.Fatalf("result = %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureReleaseConflictDoesNotEditExistingPage(t *testing.T) {
|
||||
server, state := newReleaseServer(t, releaseFixture{title: "Keep this title", body: "Different body"}, false)
|
||||
_, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
||||
if failureKind(t, err) != FailureConflict {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
if got := state.posts.Load(); got != 0 {
|
||||
t.Fatalf("release creates = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureReleaseUnauthorizedIsStructuredAndHidesToken(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertRequest(t, r, http.MethodGet, "/api/v1/repos/owner/project/tags/v1.2.3")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
}))
|
||||
defer server.Close()
|
||||
_, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
||||
if failureKind(t, err) != FailureUnauthorized {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
if fmt.Sprint(err) == "" || contains(fmt.Sprint(err), testToken) {
|
||||
t.Fatalf("error exposes unexpected content: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureReleaseMalformedTagResponseStopsBeforeCreate(t *testing.T) {
|
||||
var posts atomic.Int64
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
posts.Add(1)
|
||||
}
|
||||
assertRequest(t, r, http.MethodGet, "/api/v1/repos/owner/project/tags/v1.2.3")
|
||||
_, _ = w.Write([]byte("{"))
|
||||
}))
|
||||
defer server.Close()
|
||||
_, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
||||
if failureKind(t, err) != FailureMalformed || posts.Load() != 0 {
|
||||
t.Fatalf("error = %v, posts = %d", err, posts.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureReleaseWrongHostedTagStopsBeforeCreate(t *testing.T) {
|
||||
var posts atomic.Int64
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertRequest(t, r, http.MethodGet, "/api/v1/repos/owner/project/tags/v1.2.3")
|
||||
if r.Method == http.MethodPost {
|
||||
posts.Add(1)
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, `{"commit":{"sha":"%040d"}}`, 7)
|
||||
}))
|
||||
defer server.Close()
|
||||
_, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
||||
if failureKind(t, err) != FailureConflict || posts.Load() != 0 {
|
||||
t.Fatalf("error = %v, posts = %d", err, posts.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureReleaseMissingHostedTagStopsBeforeCreate(t *testing.T) {
|
||||
var posts atomic.Int64
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertRequest(t, r, http.MethodGet, "/api/v1/repos/owner/project/tags/v1.2.3")
|
||||
if r.Method == http.MethodPost {
|
||||
posts.Add(1)
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer server.Close()
|
||||
_, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
||||
if failureKind(t, err) != FailureNotFound || posts.Load() != 0 {
|
||||
t.Fatalf("error = %v, posts = %d", err, posts.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureReleaseHonorsContextCancellation(t *testing.T) {
|
||||
var called atomic.Bool
|
||||
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called.Store(true) }))
|
||||
defer server.Close()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
_, err := testClient(server).EnsureRelease(ctx, testRequest(server.URL))
|
||||
if !errors.Is(err, context.Canceled) || called.Load() {
|
||||
t.Fatalf("error = %v, server called = %t", err, called.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureReleaseResolvesLostCreateAcknowledgmentWithOneLookup(t *testing.T) {
|
||||
var created atomic.Bool
|
||||
var lookups atomic.Int64
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertAuth(t, r)
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/repos/owner/project/tags/v1.2.3":
|
||||
assertRequest(t, r, http.MethodGet, r.URL.Path)
|
||||
writeTag(t, w, testCommit)
|
||||
case "/api/v1/repos/owner/project/releases/tags/v1.2.3":
|
||||
assertRequest(t, r, http.MethodGet, r.URL.Path)
|
||||
lookups.Add(1)
|
||||
if !created.Load() {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
writeRelease(t, w, releaseFixture{title: "Example v1.2.3"})
|
||||
case "/api/v1/repos/owner/project/releases":
|
||||
assertRequest(t, r, http.MethodPost, r.URL.Path)
|
||||
var payload createReleaseRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
if payload.TagName != "v1.2.3" || payload.TargetCommitish != testCommit || payload.Name != "Example v1.2.3" || payload.Body != testNote || payload.Draft || payload.Prerelease {
|
||||
t.Errorf("create payload = %#v", payload)
|
||||
return
|
||||
}
|
||||
created.Store(true)
|
||||
hijacker, ok := w.(http.Hijacker)
|
||||
if !ok {
|
||||
t.Error("HTTP writer does not support hijacking")
|
||||
return
|
||||
}
|
||||
connection, _, err := hijacker.Hijack()
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
_ = connection.Close()
|
||||
default:
|
||||
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||
return
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
result, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Created || lookups.Load() != 2 {
|
||||
t.Fatalf("result = %#v, release lookups = %d", result, lookups.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureReleaseResolvesCreateConflictWithOneLookup(t *testing.T) {
|
||||
var lookups atomic.Int64
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertAuth(t, r)
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/repos/owner/project/tags/v1.2.3":
|
||||
assertRequest(t, r, http.MethodGet, r.URL.Path)
|
||||
writeTag(t, w, testCommit)
|
||||
case "/api/v1/repos/owner/project/releases/tags/v1.2.3":
|
||||
assertRequest(t, r, http.MethodGet, r.URL.Path)
|
||||
lookups.Add(1)
|
||||
if lookups.Load() == 1 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
writeRelease(t, w, releaseFixture{title: "Created by another request"})
|
||||
case "/api/v1/repos/owner/project/releases":
|
||||
assertRequest(t, r, http.MethodPost, r.URL.Path)
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
default:
|
||||
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||
return
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
result, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Created || result.Release.Title != "Created by another request" || lookups.Load() != 2 {
|
||||
t.Fatalf("result = %#v, release lookups = %d", result, lookups.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureReleaseReportsUnknownWhenAmbiguousCreateCannotBeInspected(t *testing.T) {
|
||||
var lookups atomic.Int64
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertAuth(t, r)
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/repos/owner/project/tags/v1.2.3":
|
||||
assertRequest(t, r, http.MethodGet, r.URL.Path)
|
||||
writeTag(t, w, testCommit)
|
||||
case "/api/v1/repos/owner/project/releases/tags/v1.2.3":
|
||||
assertRequest(t, r, http.MethodGet, r.URL.Path)
|
||||
lookups.Add(1)
|
||||
if lookups.Load() == 1 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
http.Error(w, "temporarily unavailable", http.StatusServiceUnavailable)
|
||||
case "/api/v1/repos/owner/project/releases":
|
||||
assertRequest(t, r, http.MethodPost, r.URL.Path)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
default:
|
||||
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||
return
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
_, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
||||
if failureKind(t, err) != FailureUnknown || lookups.Load() != 2 {
|
||||
t.Fatalf("error = %v, release lookups = %d", err, lookups.Load())
|
||||
}
|
||||
}
|
||||
|
||||
type releaseFixture struct {
|
||||
title string
|
||||
body string
|
||||
draft bool
|
||||
pre bool
|
||||
}
|
||||
|
||||
type fixtureState struct{ posts atomic.Int64 }
|
||||
|
||||
func newReleaseServer(t *testing.T, existing releaseFixture, createMissing bool) (*httptest.Server, *fixtureState) {
|
||||
t.Helper()
|
||||
state := &fixtureState{}
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertAuth(t, r)
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/repos/owner/project/tags/v1.2.3":
|
||||
assertRequest(t, r, http.MethodGet, r.URL.Path)
|
||||
writeTag(t, w, testCommit)
|
||||
case "/api/v1/repos/owner/project/releases/tags/v1.2.3":
|
||||
assertRequest(t, r, http.MethodGet, r.URL.Path)
|
||||
if existing.title == "" && existing.body == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
writeRelease(t, w, existing)
|
||||
case "/api/v1/repos/owner/project/releases":
|
||||
assertRequest(t, r, http.MethodPost, r.URL.Path)
|
||||
state.posts.Add(1)
|
||||
var payload createReleaseRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
if payload.TagName != "v1.2.3" || payload.TargetCommitish != testCommit || payload.Name != "Example v1.2.3" || payload.Body != testNote || payload.Draft || payload.Prerelease {
|
||||
t.Errorf("create payload = %#v", payload)
|
||||
return
|
||||
}
|
||||
if !createMissing {
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
return
|
||||
}
|
||||
writeRelease(t, w, releaseFixture{title: payload.Name})
|
||||
default:
|
||||
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||
return
|
||||
}
|
||||
})
|
||||
server := httptest.NewServer(handler)
|
||||
t.Cleanup(server.Close)
|
||||
return server, state
|
||||
}
|
||||
|
||||
func writeTag(t *testing.T, w http.ResponseWriter, commit string) {
|
||||
t.Helper()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = fmt.Fprintf(w, `{"name":"v1.2.3","commit":{"sha":%q}}`, commit)
|
||||
}
|
||||
|
||||
func writeRelease(t *testing.T, w http.ResponseWriter, fixture releaseFixture) {
|
||||
t.Helper()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(releaseResponse{ID: 10, HTMLURL: "https://gitea.example/releases/10", TagName: "v1.2.3", Name: fixture.title, Body: fixture.bodyOrDefault(), Draft: fixture.draft, Prerelease: fixture.pre})
|
||||
}
|
||||
|
||||
func (fixture releaseFixture) bodyOrDefault() string {
|
||||
if fixture.body == "" {
|
||||
return testNote
|
||||
}
|
||||
return fixture.body
|
||||
}
|
||||
|
||||
func assertRequest(t *testing.T, request *http.Request, method, path string) {
|
||||
t.Helper()
|
||||
if request.Method != method || request.URL.Path != path {
|
||||
t.Errorf("request = %s %s, want %s %s", request.Method, request.URL.Path, method, path)
|
||||
return
|
||||
}
|
||||
assertAuth(t, request)
|
||||
}
|
||||
|
||||
func assertAuth(t *testing.T, request *http.Request) {
|
||||
t.Helper()
|
||||
if got := request.Header.Get("Authorization"); got != "token "+testToken {
|
||||
t.Errorf("Authorization = %q", got)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func testClient(server *httptest.Server) Client {
|
||||
return Client{HTTPClient: server.Client()}
|
||||
}
|
||||
|
||||
func testRequest(baseURL string) Request {
|
||||
return Request{Destination: Destination{BaseURL: baseURL, Owner: "owner", Repository: "project", Token: testToken}, Tag: "v1.2.3", Commit: testCommit, Note: testNote}
|
||||
}
|
||||
|
||||
func failureKind(t *testing.T, err error) FailureKind {
|
||||
t.Helper()
|
||||
var failure *Failure
|
||||
if !errors.As(err, &failure) {
|
||||
t.Fatalf("error type = %T (%v), want *Failure", err, err)
|
||||
}
|
||||
return failure.Kind
|
||||
}
|
||||
|
||||
func contains(value, substring string) bool {
|
||||
return len(substring) > 0 && len(value) >= len(substring) && strings.Contains(value, substring)
|
||||
}
|
||||
|
||||
func TestEnsureReleaseReconcilesInconsistentSuccessfulCreate(t *testing.T) {
|
||||
for _, mode := range []string{"matching", "conflicting", "unavailable"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
var posts, lookups atomic.Int64
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.Contains(r.URL.Path, "/tags/") && !strings.Contains(r.URL.Path, "/releases/"):
|
||||
writeTag(t, w, testCommit)
|
||||
case strings.Contains(r.URL.Path, "/releases/tags/"):
|
||||
lookups.Add(1)
|
||||
if posts.Load() == 0 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if mode == "unavailable" {
|
||||
http.Error(w, "unavailable", 503)
|
||||
return
|
||||
}
|
||||
body := testNote
|
||||
if mode == "conflicting" {
|
||||
body = "other content"
|
||||
}
|
||||
writeRelease(t, w, releaseFixture{title: "Example v1.2.3", body: body})
|
||||
case r.Method == http.MethodPost:
|
||||
posts.Add(1)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(releaseResponse{ID: 10, TagName: "v1.2.3", Name: "inconsistent title", Body: testNote})
|
||||
default:
|
||||
t.Errorf("unexpected request %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
result, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
||||
if posts.Load() != 1 || lookups.Load() != 2 {
|
||||
t.Fatalf("posts=%d lookups=%d", posts.Load(), lookups.Load())
|
||||
}
|
||||
switch mode {
|
||||
case "matching":
|
||||
if err != nil || !result.WriteAttempted {
|
||||
t.Fatalf("result=%#v err=%v", result, err)
|
||||
}
|
||||
case "conflicting":
|
||||
if failureKind(t, err) != FailureConflict {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case "unavailable":
|
||||
if failureKind(t, err) != FailureUnknown {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,242 +1,34 @@
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type DeliveryState string
|
||||
|
||||
const (
|
||||
DeliveryNotAttempted DeliveryState = "not_attempted"
|
||||
DeliveryComplete DeliveryState = "complete"
|
||||
DeliveryPartial DeliveryState = "partial"
|
||||
DeliveryConflict DeliveryState = "conflict"
|
||||
DeliveryUnknown DeliveryState = "unknown"
|
||||
)
|
||||
|
||||
type AssetIdentity struct {
|
||||
Filename string
|
||||
SHA256 string
|
||||
}
|
||||
|
||||
// UploadResult reports the current asset observations from one upload attempt.
|
||||
// Each identity appears in at most one category; early failures can leave some
|
||||
// declared assets unreported.
|
||||
type UploadResult struct {
|
||||
State DeliveryState
|
||||
// Matching contains existing attachments with the declared content.
|
||||
Matching []AssetIdentity
|
||||
// Uploaded contains assets whose upload was confirmed during this invocation.
|
||||
Uploaded []AssetIdentity
|
||||
// Pending contains assets known to be missing.
|
||||
Pending []AssetIdentity
|
||||
// Conflicting contains assets with detected content conflicts.
|
||||
Conflicting []AssetIdentity
|
||||
// Uncertain contains assets whose remote state could not be resolved.
|
||||
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
|
||||
func (c Client) uploadAsset(ctx context.Context, client *http.Client, token, apiRoot string, releaseID int64, filename string, content io.Reader) (int, error) {
|
||||
if content == nil {
|
||||
return 0, failure(FailureInvalid, "upload release asset", 0, errors.New("asset content reader is required"))
|
||||
}
|
||||
for _, asset := range assets {
|
||||
if err := verifyLocalAsset(asset); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UploadMissing uploads assets classified missing by InspectAssets. It stops
|
||||
// before the first write if any declared existing asset conflicts or cannot be
|
||||
// verified, and never replaces an existing attachment.
|
||||
func (c Client) UploadMissing(ctx context.Context, destination Destination, releaseID int64, inspection Inspection) (UploadResult, error) {
|
||||
return c.uploadMissing(ctx, destination, releaseID, inspection, true)
|
||||
}
|
||||
|
||||
// UploadVerifiedMissing uploads missing assets whose local contents have already
|
||||
// passed VerifyLocalAssets. It reuses that verification while retaining remote
|
||||
// inspection, conflict handling, and upload reconciliation.
|
||||
func (c Client) UploadVerifiedMissing(ctx context.Context, destination Destination, releaseID int64, inspection Inspection) (UploadResult, error) {
|
||||
return c.uploadMissing(ctx, destination, releaseID, inspection, false)
|
||||
}
|
||||
|
||||
func (c Client) uploadMissing(ctx context.Context, destination Destination, releaseID int64, inspection Inspection, verifyLocal bool) (UploadResult, error) {
|
||||
if releaseID <= 0 {
|
||||
return UploadResult{State: DeliveryPartial}, failure(FailureInvalid, "upload release assets", 0, errors.New("release ID must be positive"))
|
||||
}
|
||||
if strings.TrimSpace(destination.Token) == "" {
|
||||
return UploadResult{State: DeliveryPartial}, failure(FailureInvalid, "upload release assets", 0, errors.New("Gitea token is required"))
|
||||
}
|
||||
base, err := parseBaseURL(destination.BaseURL)
|
||||
if err != nil {
|
||||
return UploadResult{State: DeliveryPartial}, failure(FailureInvalid, "validate destination", 0, err)
|
||||
}
|
||||
if strings.TrimSpace(destination.Owner) == "" || strings.ContainsAny(destination.Owner, "/\\") || strings.TrimSpace(destination.Repository) == "" || strings.ContainsAny(destination.Repository, "/\\") {
|
||||
return UploadResult{State: DeliveryPartial}, failure(FailureInvalid, "upload release assets", 0, errors.New("Gitea owner and repository must be path segments"))
|
||||
}
|
||||
expected := orderedExpected(inspection.Assets)
|
||||
if err := validateExpectedAssets(expected); err != nil {
|
||||
return UploadResult{State: DeliveryPartial}, err
|
||||
}
|
||||
result := UploadResult{State: DeliveryPartial}
|
||||
preflightConflict := false
|
||||
preflightUnknown := false
|
||||
for _, observation := range inspection.Assets {
|
||||
switch observation.State {
|
||||
case AssetMatching:
|
||||
result.Matching = append(result.Matching, identity(observation.Expected))
|
||||
case AssetMissing:
|
||||
result.Pending = append(result.Pending, identity(observation.Expected))
|
||||
case AssetConflicting:
|
||||
preflightConflict = true
|
||||
result.Conflicting = append(result.Conflicting, identity(observation.Expected))
|
||||
case AssetUnverifiable:
|
||||
preflightUnknown = true
|
||||
result.Uncertain = append(result.Uncertain, identity(observation.Expected))
|
||||
default:
|
||||
return result, failure(FailureInvalid, "preflight release assets", 0, fmt.Errorf("asset %q has no inspection result", observation.Expected.Filename))
|
||||
}
|
||||
}
|
||||
if preflightConflict {
|
||||
result.State = DeliveryConflict
|
||||
return result, failure(FailureConflict, "preflight release assets", 0, errors.New("one or more existing attachments conflict"))
|
||||
}
|
||||
if preflightUnknown {
|
||||
result.State = DeliveryNotAttempted
|
||||
return result, failure(FailureUnverifiable, "preflight release assets", 0, errors.New("one or more existing attachments could not be verified"))
|
||||
}
|
||||
if len(expected) == 0 {
|
||||
result.State = DeliveryComplete
|
||||
return result, nil
|
||||
}
|
||||
if len(result.Pending) == 0 {
|
||||
result.State = DeliveryComplete
|
||||
return result, nil
|
||||
}
|
||||
|
||||
missing := make(map[string]ExpectedAsset, len(result.Pending))
|
||||
for _, observation := range inspection.Assets {
|
||||
if observation.State == AssetMissing {
|
||||
missing[observation.Expected.Filename] = observation.Expected
|
||||
}
|
||||
}
|
||||
if verifyLocal {
|
||||
for _, item := range expected {
|
||||
if _, needsUpload := missing[item.Filename]; !needsUpload {
|
||||
continue
|
||||
}
|
||||
if err := verifyLocalAsset(item); err != nil {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
baseAPI := strings.TrimRight(base.String(), "/") + "/api/v1/repos/" + url.PathEscape(destination.Owner) + "/" + url.PathEscape(destination.Repository)
|
||||
client := c.httpClient()
|
||||
for _, item := range expected {
|
||||
if _, needsUpload := missing[item.Filename]; !needsUpload {
|
||||
continue
|
||||
}
|
||||
status, uploadErr := c.uploadAsset(ctx, client, destination.Token, baseAPI, releaseID, item)
|
||||
if uploadErr == nil {
|
||||
result.Uploaded = append(result.Uploaded, identity(item))
|
||||
result.Pending = removeIdentity(result.Pending, item.Filename)
|
||||
continue
|
||||
}
|
||||
if !shouldReconcileWrite(status, uploadErr) {
|
||||
return result, uploadErr
|
||||
}
|
||||
inspection, inspectErr := c.InspectAssets(ctx, destination, releaseID, []ExpectedAsset{item})
|
||||
if inspectErr != nil || len(inspection.Assets) != 1 {
|
||||
result.State = DeliveryUnknown
|
||||
result.Pending = removeIdentity(result.Pending, item.Filename)
|
||||
result.Uncertain = append(result.Uncertain, identity(item))
|
||||
return result, failure(FailureUnknown, "resolve asset upload", status, errors.Join(uploadErr, inspectErr))
|
||||
}
|
||||
observation := inspection.Assets[0]
|
||||
switch observation.State {
|
||||
case AssetMatching:
|
||||
result.Matching = append(result.Matching, identity(item))
|
||||
result.Pending = removeIdentity(result.Pending, item.Filename)
|
||||
case AssetConflicting:
|
||||
result.State = DeliveryConflict
|
||||
result.Pending = removeIdentity(result.Pending, item.Filename)
|
||||
result.Conflicting = append(result.Conflicting, identity(item))
|
||||
return result, failure(FailureConflict, "resolve asset upload", status, uploadErr)
|
||||
case AssetMissing:
|
||||
if status == http.StatusConflict {
|
||||
result.State = DeliveryConflict
|
||||
result.Pending = removeIdentity(result.Pending, item.Filename)
|
||||
result.Conflicting = append(result.Conflicting, identity(item))
|
||||
return result, failure(FailureConflict, "resolve asset upload", status, uploadErr)
|
||||
}
|
||||
result.State = DeliveryUnknown
|
||||
result.Pending = removeIdentity(result.Pending, item.Filename)
|
||||
result.Uncertain = append(result.Uncertain, identity(item))
|
||||
return result, failure(FailureUnknown, "resolve asset upload", status, uploadErr)
|
||||
default:
|
||||
result.State = DeliveryUnknown
|
||||
result.Pending = removeIdentity(result.Pending, item.Filename)
|
||||
result.Uncertain = append(result.Uncertain, identity(item))
|
||||
return result, failure(FailureUnknown, "resolve asset upload", status, errors.Join(uploadErr, observation.Err))
|
||||
}
|
||||
}
|
||||
if len(result.Pending) == 0 {
|
||||
result.State = DeliveryComplete
|
||||
} else {
|
||||
result.State = DeliveryPartial
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c Client) uploadAsset(ctx context.Context, client *http.Client, token, apiRoot string, releaseID int64, asset ExpectedAsset) (int, error) {
|
||||
uploadURL := apiRoot + "/releases/" + strconv.FormatInt(releaseID, 10) + "/assets"
|
||||
parsed, err := url.Parse(uploadURL)
|
||||
if err != nil {
|
||||
return 0, failure(FailureInvalid, "prepare asset upload", 0, err)
|
||||
}
|
||||
query := parsed.Query()
|
||||
query.Set("name", asset.Filename)
|
||||
query.Set("name", filename)
|
||||
parsed.RawQuery = query.Encode()
|
||||
reader, writer := io.Pipe()
|
||||
multipartWriter := multipart.NewWriter(writer)
|
||||
writeDone := make(chan error, 1)
|
||||
go func() {
|
||||
part, partErr := multipartWriter.CreateFormFile("attachment", asset.Filename)
|
||||
part, partErr := multipartWriter.CreateFormFile("attachment", filename)
|
||||
if partErr == nil {
|
||||
var source io.Reader
|
||||
if len(asset.Content) > 0 {
|
||||
source = bytes.NewReader(asset.Content)
|
||||
} else {
|
||||
file, openErr := os.Open(asset.Path)
|
||||
if openErr != nil {
|
||||
partErr = openErr
|
||||
} else {
|
||||
defer file.Close()
|
||||
source = file
|
||||
}
|
||||
}
|
||||
if partErr == nil {
|
||||
_, partErr = io.Copy(part, source)
|
||||
}
|
||||
_, partErr = io.Copy(part, content)
|
||||
}
|
||||
closeErr := multipartWriter.Close()
|
||||
if partErr == nil {
|
||||
@@ -270,7 +62,7 @@ func (c Client) uploadAsset(ctx context.Context, client *http.Client, token, api
|
||||
if err := decodeJSON(response.Body, &uploaded); err != nil {
|
||||
return response.StatusCode, failure(FailureMalformed, "decode uploaded asset", response.StatusCode, err)
|
||||
}
|
||||
if uploaded.ID <= 0 || uploaded.Name != asset.Filename {
|
||||
if uploaded.ID <= 0 || uploaded.Name != filename {
|
||||
return response.StatusCode, failure(FailureMalformed, "verify uploaded asset", response.StatusCode, errors.New("upload response does not identify the selected asset"))
|
||||
}
|
||||
if writeErr != nil {
|
||||
@@ -278,66 +70,3 @@ func (c Client) uploadAsset(ctx context.Context, client *http.Client, token, api
|
||||
}
|
||||
return response.StatusCode, nil
|
||||
}
|
||||
|
||||
func verifyLocalAsset(asset ExpectedAsset) error {
|
||||
if len(asset.Content) > 0 {
|
||||
if int64(len(asset.Content)) != asset.Size || digest(asset.Content) != asset.SHA256 {
|
||||
return failure(FailureConflict, "verify local asset", 0, fmt.Errorf("local content does not match declared asset %q", asset.Filename))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if asset.Path == "" || filepath.Base(asset.Path) != asset.Filename {
|
||||
return failure(FailureInvalid, "verify local asset", 0, fmt.Errorf("local path for %q does not match its filename", asset.Filename))
|
||||
}
|
||||
file, err := os.Open(asset.Path)
|
||||
if err != nil {
|
||||
return failure(FailureInvalid, "verify local asset", 0, fmt.Errorf("open %q: %w", asset.Filename, err))
|
||||
}
|
||||
defer file.Close()
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return failure(FailureInvalid, "verify local asset", 0, fmt.Errorf("stat %q: %w", asset.Filename, err))
|
||||
}
|
||||
if !info.Mode().IsRegular() || info.Size() != asset.Size {
|
||||
return failure(FailureConflict, "verify local asset", 0, fmt.Errorf("local file does not match declared size for %q", asset.Filename))
|
||||
}
|
||||
hasher := sha256.New()
|
||||
size, err := io.Copy(hasher, file)
|
||||
if err != nil {
|
||||
return failure(FailureInvalid, "verify local asset", 0, fmt.Errorf("read %q: %w", asset.Filename, err))
|
||||
}
|
||||
if size != asset.Size || hex.EncodeToString(hasher.Sum(nil)) != asset.SHA256 {
|
||||
return failure(FailureConflict, "verify local asset", 0, fmt.Errorf("local content does not match declared asset %q", asset.Filename))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func orderedExpected(observations []AssetInspection) []ExpectedAsset {
|
||||
result := make([]ExpectedAsset, 0, len(observations))
|
||||
for _, observation := range observations {
|
||||
result = append(result, observation.Expected)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
if result[i].Filename == ManifestFilename {
|
||||
return false
|
||||
}
|
||||
if result[j].Filename == ManifestFilename {
|
||||
return true
|
||||
}
|
||||
return result[i].Filename < result[j].Filename
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
func identity(asset ExpectedAsset) AssetIdentity {
|
||||
return AssetIdentity{Filename: asset.Filename, SHA256: asset.SHA256}
|
||||
}
|
||||
|
||||
func removeIdentity(identities []AssetIdentity, filename string) []AssetIdentity {
|
||||
for index, item := range identities {
|
||||
if item.Filename == filename {
|
||||
return append(identities[:index], identities[index+1:]...)
|
||||
}
|
||||
}
|
||||
return identities
|
||||
}
|
||||
|
||||
@@ -1,482 +0,0 @@
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUploadMissingVerifiesEveryMissingLocalFileBeforeWriting(t *testing.T) {
|
||||
fixture := &uploadFixture{files: make(map[string][]byte)}
|
||||
server := httptest.NewServer(fixture)
|
||||
defer server.Close()
|
||||
client := testClient(server)
|
||||
destination := testDestination(server.URL)
|
||||
assets := localAssets(t, []string{"alpha.bin", "beta.bin"})
|
||||
inspection, err := client.InspectAssets(t.Context(), destination, 10, assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
contents := readExpectedFile(t, assets[1])
|
||||
contents[0] ^= 0xff
|
||||
if err := os.WriteFile(assets[1].Path, contents, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := client.UploadMissing(t.Context(), destination, 10, inspection)
|
||||
if err == nil || result.State != DeliveryPartial {
|
||||
t.Fatalf("upload = %#v, error = %v; want local verification failure", result, err)
|
||||
}
|
||||
if posts := fixture.postNames(); len(posts) != 0 {
|
||||
t.Fatalf("uploaded %v before rejecting local content", posts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadMissingPartialFailureThenRerunOnlyMissingAssets(t *testing.T) {
|
||||
fixture := &uploadFixture{files: make(map[string][]byte), failName: "beta.bin"}
|
||||
server := httptest.NewServer(fixture)
|
||||
defer server.Close()
|
||||
client := testClient(server)
|
||||
destination := testDestination(server.URL)
|
||||
assets := localAssets(t, []string{"zeta.bin", "alpha.bin", "beta.bin"})
|
||||
manifest, err := BuildManifest(assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assets = append(assets, manifest)
|
||||
|
||||
initial, err := client.InspectAssets(context.Background(), destination, 10, assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := client.UploadMissing(context.Background(), destination, 10, initial)
|
||||
if err == nil || first.State != DeliveryPartial {
|
||||
t.Fatalf("first upload result = %#v, error = %v", first, err)
|
||||
}
|
||||
if got, want := names(first.Uploaded), []string{"alpha.bin"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("uploaded = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := names(first.Pending), []string{"zeta.bin", "beta.bin", "SHA256SUMS"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("pending = %v, want %v", got, want)
|
||||
}
|
||||
assertAssetCategoriesDisjoint(t, first)
|
||||
|
||||
secondInspection, err := client.InspectAssets(context.Background(), destination, 10, assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := client.UploadMissing(context.Background(), destination, 10, secondInspection)
|
||||
if err != nil || second.State != DeliveryComplete {
|
||||
t.Fatalf("second upload result = %#v, error = %v", second, err)
|
||||
}
|
||||
if got, want := names(second.Uploaded), []string{"beta.bin", "zeta.bin", "SHA256SUMS"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("second uploaded = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := fixture.postNames(), []string{"alpha.bin", "beta.bin", "beta.bin", "zeta.bin", "SHA256SUMS"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("POST order = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadMissingAcceptsConcurrentMatchingAttachmentWithoutReplacement(t *testing.T) {
|
||||
fixture := &uploadFixture{files: make(map[string][]byte), conflictName: "app.bin"}
|
||||
server := httptest.NewServer(fixture)
|
||||
defer server.Close()
|
||||
client := testClient(server)
|
||||
destination := testDestination(server.URL)
|
||||
assets := localAssets(t, []string{"app.bin"})
|
||||
initial, err := client.InspectAssets(context.Background(), destination, 10, assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := client.UploadMissing(context.Background(), destination, 10, initial)
|
||||
if err != nil || result.State != DeliveryComplete {
|
||||
t.Fatalf("result = %#v, error = %v", result, err)
|
||||
}
|
||||
if got, want := names(result.Matching), []string{"app.bin"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("matching = %v, want %v", got, want)
|
||||
}
|
||||
if len(result.Uploaded) != 0 || len(fixture.postNames()) != 1 {
|
||||
t.Fatalf("result = %#v, POST names = %v", result, fixture.postNames())
|
||||
}
|
||||
assertAssetCategoriesDisjoint(t, result)
|
||||
}
|
||||
|
||||
func TestUploadMissingReconcilesLostManifestAcknowledgment(t *testing.T) {
|
||||
assets := localAssets(t, []string{"app.bin"})
|
||||
manifest, err := BuildManifest(assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fixture := &uploadFixture{files: make(map[string][]byte), dropAckName: ManifestFilename}
|
||||
// The local binary is represented by its file path; the server starts with
|
||||
// that exact content so only the checksum manifest needs uploading.
|
||||
fixture.files["app.bin"] = readExpectedFile(t, assets[0])
|
||||
server := httptest.NewServer(fixture)
|
||||
defer server.Close()
|
||||
client := testClient(server)
|
||||
destination := testDestination(server.URL)
|
||||
expected := append(assets, manifest)
|
||||
initial, err := client.InspectAssets(context.Background(), destination, 10, expected)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := client.UploadMissing(context.Background(), destination, 10, initial)
|
||||
if err != nil || result.State != DeliveryComplete {
|
||||
t.Fatalf("result = %#v, error = %v", result, err)
|
||||
}
|
||||
if got, want := names(result.Matching), []string{"app.bin", ManifestFilename}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("matching = %v, want %v", got, want)
|
||||
}
|
||||
if len(result.Uploaded) != 0 || !reflect.DeepEqual(fixture.postNames(), []string{ManifestFilename}) {
|
||||
t.Fatalf("result = %#v, POST names = %v", result, fixture.postNames())
|
||||
}
|
||||
assertAssetCategoriesDisjoint(t, result)
|
||||
}
|
||||
|
||||
func TestUploadMissingReportsUnknownWhenReconciliationReadFails(t *testing.T) {
|
||||
fixture := &uploadFixture{files: make(map[string][]byte), dropAckName: "app.bin", failListAfter: 1}
|
||||
server := httptest.NewServer(fixture)
|
||||
defer server.Close()
|
||||
client := testClient(server)
|
||||
destination := testDestination(server.URL)
|
||||
assets := localAssets(t, []string{"app.bin", "later.bin"})
|
||||
initial, err := client.InspectAssets(context.Background(), destination, 10, assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := client.UploadMissing(context.Background(), destination, 10, initial)
|
||||
if err == nil || result.State != DeliveryUnknown {
|
||||
t.Fatalf("result = %#v, error = %v", result, err)
|
||||
}
|
||||
if got, want := names(result.Pending), []string{"later.bin"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("pending = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := names(result.Uncertain), []string{"app.bin"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("uncertain = %v, want %v", got, want)
|
||||
}
|
||||
assertAssetCategoriesDisjoint(t, result)
|
||||
if got := fixture.postNames(); !reflect.DeepEqual(got, []string{"app.bin"}) {
|
||||
t.Fatalf("POST names = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadMissingPreflightConflictPreventsEveryWrite(t *testing.T) {
|
||||
fixture := &uploadFixture{files: map[string][]byte{"app.bin": []byte("different bytes")}}
|
||||
server := httptest.NewServer(fixture)
|
||||
defer server.Close()
|
||||
client := testClient(server)
|
||||
destination := testDestination(server.URL)
|
||||
assets := localAssets(t, []string{"app.bin", "other.bin"})
|
||||
inspection, err := client.InspectAssets(context.Background(), destination, 10, assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := client.UploadMissing(context.Background(), destination, 10, inspection)
|
||||
if err == nil || result.State != DeliveryConflict {
|
||||
t.Fatalf("result = %#v, error = %v", result, err)
|
||||
}
|
||||
if len(fixture.postNames()) != 0 {
|
||||
t.Fatalf("uploads happened before conflict review: %v", fixture.postNames())
|
||||
}
|
||||
if got, want := names(result.Conflicting), []string{"app.bin"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("conflicting = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := names(result.Pending), []string{"other.bin"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("pending = %v, want %v", got, want)
|
||||
}
|
||||
assertAssetCategoriesDisjoint(t, result)
|
||||
}
|
||||
|
||||
func TestUploadMissingKeepsLaterPendingAfterAmbiguousMissingUpload(t *testing.T) {
|
||||
fixture := &uploadFixture{files: make(map[string][]byte), dropAckMissingName: "alpha.bin"}
|
||||
server := httptest.NewServer(fixture)
|
||||
defer server.Close()
|
||||
client := testClient(server)
|
||||
destination := testDestination(server.URL)
|
||||
assets := localAssets(t, []string{"alpha.bin", "zeta.bin"})
|
||||
initial, err := client.InspectAssets(context.Background(), destination, 10, assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := client.UploadMissing(context.Background(), destination, 10, initial)
|
||||
if err == nil || result.State != DeliveryUnknown {
|
||||
t.Fatalf("result = %#v, error = %v", result, err)
|
||||
}
|
||||
if got, want := names(result.Uncertain), []string{"alpha.bin"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("uncertain = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := names(result.Pending), []string{"zeta.bin"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("pending = %v, want %v", got, want)
|
||||
}
|
||||
assertAssetCategoriesDisjoint(t, result)
|
||||
if got, want := fixture.postNames(), []string{"alpha.bin"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("POST names = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadMissingMovesReconciledConflictOutOfPending(t *testing.T) {
|
||||
fixture := &uploadFixture{
|
||||
files: make(map[string][]byte),
|
||||
conflictName: "alpha.bin",
|
||||
conflictDifferentContent: true,
|
||||
}
|
||||
server := httptest.NewServer(fixture)
|
||||
defer server.Close()
|
||||
client := testClient(server)
|
||||
destination := testDestination(server.URL)
|
||||
assets := localAssets(t, []string{"alpha.bin", "zeta.bin"})
|
||||
initial, err := client.InspectAssets(context.Background(), destination, 10, assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := client.UploadMissing(context.Background(), destination, 10, initial)
|
||||
if err == nil || result.State != DeliveryConflict {
|
||||
t.Fatalf("result = %#v, error = %v", result, err)
|
||||
}
|
||||
if got, want := names(result.Conflicting), []string{"alpha.bin"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("conflicting = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := names(result.Pending), []string{"zeta.bin"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("pending = %v, want %v", got, want)
|
||||
}
|
||||
assertAssetCategoriesDisjoint(t, result)
|
||||
}
|
||||
|
||||
func TestUploadMissingTreatsUnresolvedNameConflictAsConflict(t *testing.T) {
|
||||
fixture := &uploadFixture{files: make(map[string][]byte), conflictMissingName: "alpha.bin"}
|
||||
server := httptest.NewServer(fixture)
|
||||
defer server.Close()
|
||||
client := testClient(server)
|
||||
destination := testDestination(server.URL)
|
||||
assets := localAssets(t, []string{"alpha.bin", "zeta.bin"})
|
||||
initial, err := client.InspectAssets(context.Background(), destination, 10, assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := client.UploadMissing(context.Background(), destination, 10, initial)
|
||||
if err == nil || result.State != DeliveryConflict {
|
||||
t.Fatalf("result = %#v, error = %v", result, err)
|
||||
}
|
||||
if got, want := names(result.Conflicting), []string{"alpha.bin"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("conflicting = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := names(result.Pending), []string{"zeta.bin"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("pending = %v, want %v", got, want)
|
||||
}
|
||||
assertAssetCategoriesDisjoint(t, result)
|
||||
}
|
||||
|
||||
type uploadFixture struct {
|
||||
mu sync.Mutex
|
||||
files map[string][]byte
|
||||
posts []string
|
||||
failName string
|
||||
conflictName string
|
||||
conflictDifferentContent bool
|
||||
conflictMissingName string
|
||||
dropAckName string
|
||||
dropAckMissingName string
|
||||
failListAfter int
|
||||
listCount int
|
||||
}
|
||||
|
||||
func (fixture *uploadFixture) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "token "+testToken {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodGet && r.URL.Path == assetListPath {
|
||||
fixture.mu.Lock()
|
||||
fixture.listCount++
|
||||
if fixture.failListAfter > 0 && fixture.listCount > fixture.failListAfter {
|
||||
fixture.mu.Unlock()
|
||||
http.Error(w, "read failed", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
assets := make([]assetResponse, 0, len(fixture.files))
|
||||
for name, content := range fixture.files {
|
||||
assets = append(assets, assetResponse{ID: int64(len(assets) + 1), Name: name, Size: int64(len(content)), BrowserDownloadURL: "/files/" + urlPathEscape(name)})
|
||||
}
|
||||
fixture.mu.Unlock()
|
||||
_ = json.NewEncoder(w).Encode(assets)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/files/") {
|
||||
name := strings.TrimPrefix(r.URL.Path, "/files/")
|
||||
fixture.mu.Lock()
|
||||
content, ok := fixture.files[name]
|
||||
fixture.mu.Unlock()
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(content)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodPost && r.URL.Path == assetListPath {
|
||||
fixture.receiveUpload(w, r)
|
||||
return
|
||||
}
|
||||
http.Error(w, "unexpected request", http.StatusMethodNotAllowed)
|
||||
}
|
||||
|
||||
func (fixture *uploadFixture) receiveUpload(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data; boundary=") {
|
||||
http.Error(w, "multipart required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
||||
http.Error(w, "invalid multipart body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
file, header, err := r.FormFile("attachment")
|
||||
if err != nil {
|
||||
http.Error(w, "missing attachment field", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
content, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
http.Error(w, "read multipart file", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if header.Filename != r.URL.Query().Get("name") {
|
||||
http.Error(w, "filename query mismatch", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
name := header.Filename
|
||||
fixture.mu.Lock()
|
||||
fixture.posts = append(fixture.posts, name)
|
||||
attempts := 0
|
||||
for _, posted := range fixture.posts {
|
||||
if posted == name {
|
||||
attempts++
|
||||
}
|
||||
}
|
||||
if name == fixture.failName && attempts == 1 {
|
||||
fixture.mu.Unlock()
|
||||
http.Error(w, "write failed", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if name == fixture.dropAckMissingName {
|
||||
fixture.mu.Unlock()
|
||||
hijacker, ok := w.(http.Hijacker)
|
||||
if !ok {
|
||||
http.Error(w, "hijacking unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
connection, _, err := hijacker.Hijack()
|
||||
if err == nil {
|
||||
_ = connection.Close()
|
||||
}
|
||||
return
|
||||
}
|
||||
if name == fixture.conflictMissingName {
|
||||
fixture.mu.Unlock()
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
return
|
||||
}
|
||||
if name == fixture.conflictName && fixture.conflictDifferentContent {
|
||||
fixture.files[name] = []byte("conflicting bytes")
|
||||
fixture.mu.Unlock()
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
return
|
||||
}
|
||||
fixture.files[name] = append([]byte(nil), content...)
|
||||
fixture.mu.Unlock()
|
||||
if name == fixture.conflictName {
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
return
|
||||
}
|
||||
if name == fixture.dropAckName {
|
||||
hijacker, ok := w.(http.Hijacker)
|
||||
if !ok {
|
||||
http.Error(w, "hijacking unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
connection, _, err := hijacker.Hijack()
|
||||
if err == nil {
|
||||
_ = connection.Close()
|
||||
}
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(assetResponse{ID: 50, Name: name, Size: int64(len(content))})
|
||||
}
|
||||
|
||||
func localAssets(t *testing.T, names []string) []ExpectedAsset {
|
||||
t.Helper()
|
||||
output := t.TempDir()
|
||||
assets := make([]ExpectedAsset, 0, len(names))
|
||||
for _, name := range names {
|
||||
content := []byte("binary-" + name)
|
||||
path := filepath.Join(output, name)
|
||||
if err := os.WriteFile(path, content, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sum := assetFor(name, content)
|
||||
sum.Content = nil
|
||||
sum.Path = path
|
||||
assets = append(assets, sum)
|
||||
}
|
||||
return assets
|
||||
}
|
||||
|
||||
func readExpectedFile(t *testing.T, asset ExpectedAsset) []byte {
|
||||
t.Helper()
|
||||
content, err := os.ReadFile(asset.Path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
func (fixture *uploadFixture) postNames() []string {
|
||||
fixture.mu.Lock()
|
||||
defer fixture.mu.Unlock()
|
||||
return append([]string(nil), fixture.posts...)
|
||||
}
|
||||
|
||||
func names(identities []AssetIdentity) []string {
|
||||
result := make([]string, 0, len(identities))
|
||||
for _, identity := range identities {
|
||||
result = append(result, identity.Filename)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func assertAssetCategoriesDisjoint(t *testing.T, result UploadResult) {
|
||||
t.Helper()
|
||||
groups := []struct {
|
||||
name string
|
||||
identities []AssetIdentity
|
||||
}{
|
||||
{name: "matching", identities: result.Matching},
|
||||
{name: "uploaded", identities: result.Uploaded},
|
||||
{name: "pending", identities: result.Pending},
|
||||
{name: "conflicting", identities: result.Conflicting},
|
||||
{name: "uncertain", identities: result.Uncertain},
|
||||
}
|
||||
seen := make(map[string]string)
|
||||
for _, group := range groups {
|
||||
for _, item := range group.identities {
|
||||
if previous, ok := seen[item.Filename]; ok {
|
||||
t.Errorf("asset %q appears in both %s and %s", item.Filename, previous, group.name)
|
||||
}
|
||||
seen[item.Filename] = group.name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func urlPathEscape(value string) string {
|
||||
return (&url.URL{Path: value}).EscapedPath()
|
||||
}
|
||||
@@ -95,9 +95,9 @@ func New(ctx context.Context, directory string) (*Tool, error) {
|
||||
// local Go toolchain. It preserves caller module-network settings and caches,
|
||||
// while disabling workspace discovery and automatic toolchain downloads.
|
||||
func PrepareBuildDependencies(ctx context.Context, directory string) error {
|
||||
captured := &combinedWriter{}
|
||||
captured := &combinedWriter{mode: captureStatus}
|
||||
err := process.Run(ctx, process.Invocation{
|
||||
Args: []string{"go", "mod", "download"},
|
||||
Args: []string{"go", "mod", "download", "-modfile="},
|
||||
Dir: directory,
|
||||
Env: map[string]string{
|
||||
"GOWORK": "off",
|
||||
@@ -107,7 +107,8 @@ func PrepareBuildDependencies(ctx context.Context, directory string) error {
|
||||
Stderr: captured,
|
||||
})
|
||||
if err != nil {
|
||||
if output := strings.TrimSpace(string(captured.take())); output != "" {
|
||||
omitted := captured.omitted()
|
||||
if output := diagnosticText(captured.take(), omitted); output != "" {
|
||||
return fmt.Errorf("go mod download: %w: %s", err, output)
|
||||
}
|
||||
return fmt.Errorf("go mod download: %w", err)
|
||||
@@ -140,7 +141,11 @@ func (tool *Tool) execute(ctx context.Context, directory string, mode Mode, targ
|
||||
return nil, errors.New("Go command requires arguments")
|
||||
}
|
||||
environment := tool.environment(mode, target)
|
||||
captured := &combinedWriter{output: output}
|
||||
modeCapture := captureStatus
|
||||
if keepOutput {
|
||||
modeCapture = captureFull
|
||||
}
|
||||
captured := &combinedWriter{output: output, mode: modeCapture}
|
||||
commandArgs := append([]string{"go"}, args...)
|
||||
err := process.Run(ctx, process.Invocation{
|
||||
Args: commandArgs,
|
||||
@@ -152,16 +157,28 @@ func (tool *Tool) execute(ctx context.Context, directory string, mode Mode, targ
|
||||
if forwardErr := captured.forwardingError(); forwardErr != nil {
|
||||
err = errors.Join(err, fmt.Errorf("forward Go output: %w", forwardErr))
|
||||
}
|
||||
if err == nil && !keepOutput {
|
||||
return nil, nil
|
||||
}
|
||||
omitted := captured.omitted()
|
||||
contents := captured.take()
|
||||
if err != nil {
|
||||
if len(contents) != 0 {
|
||||
return outputBytes(contents, keepOutput), fmt.Errorf("go %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(string(contents)))
|
||||
if diagnostic := diagnosticText(contents, omitted); diagnostic != "" {
|
||||
return outputBytes(contents, keepOutput), fmt.Errorf("go %s: %w: %s", strings.Join(args, " "), err, diagnostic)
|
||||
}
|
||||
return outputBytes(contents, keepOutput), fmt.Errorf("go %s: %w", strings.Join(args, " "), err)
|
||||
}
|
||||
return outputBytes(contents, keepOutput), nil
|
||||
}
|
||||
|
||||
func diagnosticText(contents []byte, omitted bool) string {
|
||||
text := strings.TrimSpace(string(contents))
|
||||
if omitted {
|
||||
return "[earlier Go output omitted]\n" + text
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func outputBytes(contents []byte, keep bool) []byte {
|
||||
if keep {
|
||||
return contents
|
||||
@@ -232,17 +249,63 @@ type combinedWriter struct {
|
||||
mu sync.Mutex
|
||||
output io.Writer
|
||||
buffer bytes.Buffer
|
||||
mode captureMode
|
||||
tail []byte
|
||||
head int
|
||||
truncated bool
|
||||
forwardErr error
|
||||
}
|
||||
|
||||
type captureMode uint8
|
||||
|
||||
const (
|
||||
captureFull captureMode = iota
|
||||
captureStatus
|
||||
statusDiagnosticLimit = 1 << 20
|
||||
)
|
||||
|
||||
func (writer *combinedWriter) capture(contents []byte) {
|
||||
if writer.mode == captureFull {
|
||||
_, _ = writer.buffer.Write(contents)
|
||||
return
|
||||
}
|
||||
if len(contents) >= statusDiagnosticLimit {
|
||||
writer.truncated = writer.truncated || len(writer.tail) != 0 || len(contents) > statusDiagnosticLimit
|
||||
if cap(writer.tail) < statusDiagnosticLimit {
|
||||
writer.tail = make([]byte, statusDiagnosticLimit)
|
||||
} else {
|
||||
writer.tail = writer.tail[:statusDiagnosticLimit]
|
||||
}
|
||||
copy(writer.tail, contents[len(contents)-statusDiagnosticLimit:])
|
||||
writer.head = 0
|
||||
return
|
||||
}
|
||||
if available := statusDiagnosticLimit - len(writer.tail); available > 0 {
|
||||
n := min(available, len(contents))
|
||||
if needed := len(writer.tail) + n; needed > cap(writer.tail) {
|
||||
capacity := min(statusDiagnosticLimit, max(needed, 2*cap(writer.tail)))
|
||||
grown := make([]byte, len(writer.tail), capacity)
|
||||
copy(grown, writer.tail)
|
||||
writer.tail = grown
|
||||
}
|
||||
writer.tail = append(writer.tail, contents[:n]...)
|
||||
contents = contents[n:]
|
||||
}
|
||||
if len(contents) == 0 {
|
||||
return
|
||||
}
|
||||
writer.truncated = true
|
||||
n := copy(writer.tail[writer.head:], contents)
|
||||
copy(writer.tail, contents[n:])
|
||||
writer.head = (writer.head + len(contents)) % statusDiagnosticLimit
|
||||
}
|
||||
|
||||
func (writer *combinedWriter) Write(contents []byte) (int, error) {
|
||||
writer.mu.Lock()
|
||||
defer writer.mu.Unlock()
|
||||
writer.capture(contents)
|
||||
if writer.output == nil {
|
||||
return writer.buffer.Write(contents)
|
||||
}
|
||||
if _, err := writer.buffer.Write(contents); err != nil {
|
||||
return 0, err
|
||||
return len(contents), nil
|
||||
}
|
||||
written, err := writer.output.Write(contents)
|
||||
if err == nil && written != len(contents) {
|
||||
@@ -254,6 +317,12 @@ func (writer *combinedWriter) Write(contents []byte) (int, error) {
|
||||
return written, err
|
||||
}
|
||||
|
||||
func (writer *combinedWriter) omitted() bool {
|
||||
writer.mu.Lock()
|
||||
defer writer.mu.Unlock()
|
||||
return writer.truncated
|
||||
}
|
||||
|
||||
func (writer *combinedWriter) forwardingError() error {
|
||||
writer.mu.Lock()
|
||||
defer writer.mu.Unlock()
|
||||
@@ -265,5 +334,18 @@ func (writer *combinedWriter) forwardingError() error {
|
||||
func (writer *combinedWriter) take() []byte {
|
||||
writer.mu.Lock()
|
||||
defer writer.mu.Unlock()
|
||||
return writer.buffer.Bytes()
|
||||
if writer.mode == captureFull {
|
||||
contents := writer.buffer.Bytes()
|
||||
writer.buffer = bytes.Buffer{}
|
||||
return contents
|
||||
}
|
||||
contents := writer.tail
|
||||
if writer.head != 0 {
|
||||
contents = make([]byte, 0, len(writer.tail))
|
||||
contents = append(contents, writer.tail[writer.head:]...)
|
||||
contents = append(contents, writer.tail[:writer.head]...)
|
||||
}
|
||||
writer.tail = nil
|
||||
writer.head = 0
|
||||
return contents
|
||||
}
|
||||
|
||||
@@ -6,11 +6,13 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -159,7 +161,7 @@ func TestPrepareBuildDependenciesPreservesPreparationEnvironment(t *testing.T) {
|
||||
if err := json.Unmarshal(contents, &record); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(record.Arguments, " ") != "mod download" || record.Directory != repository {
|
||||
if strings.Join(record.Arguments, " ") != "mod download -modfile=" || record.Directory != repository {
|
||||
t.Fatalf("Go invocation = %#v", record)
|
||||
}
|
||||
if record.Workspace != "off" || record.Toolchain != "local" {
|
||||
@@ -453,6 +455,96 @@ func TestOutputReturnsLargeFailureDiagnosticsAndCause(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusCaptureRetainsTailAndForwardsAll(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
size int
|
||||
chunkSize int
|
||||
}{
|
||||
{"below", statusDiagnosticLimit - 1, 8191},
|
||||
{"exact", statusDiagnosticLimit, 8191},
|
||||
{"above", statusDiagnosticLimit + 1, 8191},
|
||||
{"single oversized", statusDiagnosticLimit*2 + 3, statusDiagnosticLimit*2 + 3},
|
||||
{"wrapped small writes", statusDiagnosticLimit*3 + 17, 113},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
input := make([]byte, test.size)
|
||||
for i := range input {
|
||||
input[i] = byte(i % 251)
|
||||
}
|
||||
var forwarded bytes.Buffer
|
||||
writer := &combinedWriter{mode: captureStatus, output: &forwarded}
|
||||
for start := 0; start < len(input); start += test.chunkSize {
|
||||
chunk := input[start:min(start+test.chunkSize, len(input))]
|
||||
if n, err := writer.Write(chunk); n != len(chunk) || err != nil {
|
||||
t.Fatalf("write = %d, %v", n, err)
|
||||
}
|
||||
}
|
||||
if writer.omitted() != (len(input) > statusDiagnosticLimit) || cap(writer.tail) > statusDiagnosticLimit {
|
||||
t.Fatalf("omitted = %t, capacity = %d", writer.omitted(), cap(writer.tail))
|
||||
}
|
||||
want := input[max(0, len(input)-statusDiagnosticLimit):]
|
||||
if got := writer.take(); !bytes.Equal(got, want) || !bytes.Equal(forwarded.Bytes(), input) {
|
||||
t.Fatalf("capture size = %d, forwarded size = %d", len(got), forwarded.Len())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusCaptureSerializesConcurrentWriters(t *testing.T) {
|
||||
var forwarded bytes.Buffer
|
||||
writer := &combinedWriter{mode: captureStatus, output: &forwarded}
|
||||
var group sync.WaitGroup
|
||||
for _, value := range []byte{'a', 'b'} {
|
||||
group.Add(1)
|
||||
go func() {
|
||||
defer group.Done()
|
||||
chunk := bytes.Repeat([]byte{value}, 4096)
|
||||
for i := 0; i < 300; i++ {
|
||||
_, _ = writer.Write(chunk)
|
||||
}
|
||||
}()
|
||||
}
|
||||
group.Wait()
|
||||
contents := forwarded.Bytes()
|
||||
if !writer.omitted() || !bytes.Equal(writer.take(), contents[len(contents)-statusDiagnosticLimit:]) {
|
||||
t.Fatal("concurrent capture differs from forwarded tail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusCommandsRetainBoundedFailureDiagnostics(t *testing.T) {
|
||||
repository, _ := installGoPreparationFixture(t)
|
||||
t.Setenv("RELEASEKIT_GO_FIXTURE_MODE", "large-fail")
|
||||
var forwarded bytes.Buffer
|
||||
err := fixtureTool().Run(context.Background(), repository, Native, Target{}, &forwarded, "version")
|
||||
var exitError *exec.ExitError
|
||||
if !errors.As(err, &exitError) || exitError.ExitCode() != 23 || !strings.Contains(err.Error(), "earlier Go output omitted") || !strings.Contains(err.Error(), "fixture large failure") || len(err.Error()) > statusDiagnosticLimit+200 || forwarded.Len() != (8<<20)+len("fixture large failure\n") {
|
||||
t.Fatalf("Run() error = %v, forwarded = %d bytes", err, forwarded.Len())
|
||||
}
|
||||
err = PrepareBuildDependencies(context.Background(), repository)
|
||||
if !errors.As(err, &exitError) || exitError.ExitCode() != 23 || !strings.Contains(err.Error(), "earlier Go output omitted") || !strings.Contains(err.Error(), "fixture large failure") || len(err.Error()) > statusDiagnosticLimit+200 {
|
||||
t.Fatalf("preparation error did not retain bounded diagnostics: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusCommandPreservesForwardingError(t *testing.T) {
|
||||
repository, _ := installGoPreparationFixture(t)
|
||||
t.Setenv("RELEASEKIT_GO_FIXTURE_MODE", "output")
|
||||
writerErr := errors.New("forwarding failed")
|
||||
err := fixtureTool().Run(context.Background(), repository, Native, Target{}, failingOutputWriter{err: writerErr}, "version")
|
||||
if !errors.Is(err, writerErr) {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
err = fixtureTool().Run(context.Background(), repository, Native, Target{}, shortOutputWriter{}, "version")
|
||||
if !errors.Is(err, io.ErrShortWrite) {
|
||||
t.Fatalf("Run() short-write error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type shortOutputWriter struct{}
|
||||
|
||||
func (shortOutputWriter) Write([]byte) (int, error) { return 0, nil }
|
||||
|
||||
func TestOutputReturnsCancellation(t *testing.T) {
|
||||
repository, recordPath := installGoPreparationFixture(t)
|
||||
t.Setenv("RELEASEKIT_GO_FIXTURE_MODE", "wait")
|
||||
|
||||
@@ -20,36 +20,45 @@ func BenchmarkPerformanceCombinedOutput(b *testing.B) {
|
||||
} {
|
||||
input := bytes.Repeat([]byte("go tool diagnostic output\n"), (testCase.size+25)/26)
|
||||
input = input[:testCase.size]
|
||||
b.Run(testCase.name+"/discard-forwarding", func(b *testing.B) {
|
||||
// Verify capture and forwarding for this workload outside the measurement.
|
||||
var forwarded bytes.Buffer
|
||||
verification := &combinedWriter{output: &forwarded}
|
||||
for offset := 0; offset < len(input); offset += testCase.chunkSize {
|
||||
chunk := input[offset:min(offset+testCase.chunkSize, len(input))]
|
||||
if written, err := verification.Write(chunk); err != nil || written != len(chunk) {
|
||||
b.Fatalf("verify output write = %d, err %v; want %d bytes", written, err, len(chunk))
|
||||
}
|
||||
}
|
||||
if !bytes.Equal(verification.take(), input) || !bytes.Equal(forwarded.Bytes(), input) {
|
||||
b.Fatal("captured and forwarded output must equal the complete input")
|
||||
}
|
||||
b.ReportAllocs()
|
||||
b.SetBytes(int64(len(input)))
|
||||
b.ResetTimer()
|
||||
b.ReportMetric(float64(len(input)), "bytes/op")
|
||||
for i := 0; i < b.N; i++ {
|
||||
writer := &combinedWriter{output: io.Discard}
|
||||
for _, mode := range []struct {
|
||||
name string
|
||||
mode captureMode
|
||||
}{{"full", captureFull}, {"status", captureStatus}} {
|
||||
b.Run(testCase.name+"/"+mode.name, func(b *testing.B) {
|
||||
// Verify capture and forwarding for this workload outside the measurement.
|
||||
var forwarded bytes.Buffer
|
||||
verification := &combinedWriter{output: &forwarded, mode: mode.mode}
|
||||
for offset := 0; offset < len(input); offset += testCase.chunkSize {
|
||||
chunk := input[offset:min(offset+testCase.chunkSize, len(input))]
|
||||
if written, err := writer.Write(chunk); err != nil || written != len(chunk) {
|
||||
b.Fatalf("write captured output = %d, err %v; want %d bytes", written, err, len(chunk))
|
||||
if written, err := verification.Write(chunk); err != nil || written != len(chunk) {
|
||||
b.Fatalf("verify output write = %d, err %v; want %d bytes", written, err, len(chunk))
|
||||
}
|
||||
}
|
||||
captured := writer.take()
|
||||
if len(captured) != len(input) {
|
||||
b.Fatalf("captured output = %d bytes, want %d", len(captured), len(input))
|
||||
want := input
|
||||
if mode.mode == captureStatus && len(want) > statusDiagnosticLimit {
|
||||
want = want[len(want)-statusDiagnosticLimit:]
|
||||
}
|
||||
}
|
||||
})
|
||||
if !bytes.Equal(verification.take(), want) || !bytes.Equal(forwarded.Bytes(), input) {
|
||||
b.Fatal("captured output or forwarding did not match the selected mode")
|
||||
}
|
||||
b.ReportAllocs()
|
||||
b.SetBytes(int64(len(input)))
|
||||
b.ResetTimer()
|
||||
b.ReportMetric(float64(len(input)), "bytes/op")
|
||||
for i := 0; i < b.N; i++ {
|
||||
writer := &combinedWriter{output: io.Discard, mode: mode.mode}
|
||||
for offset := 0; offset < len(input); offset += testCase.chunkSize {
|
||||
chunk := input[offset:min(offset+testCase.chunkSize, len(input))]
|
||||
if written, err := writer.Write(chunk); err != nil || written != len(chunk) {
|
||||
b.Fatalf("write captured output = %d, err %v; want %d bytes", written, err, len(chunk))
|
||||
}
|
||||
}
|
||||
captured := writer.take()
|
||||
if len(captured) != len(want) {
|
||||
b.Fatalf("captured output = %d bytes, want %d", len(captured), len(want))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,11 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Invocation describes one child process. UnsetEnv removes inherited values
|
||||
@@ -54,6 +54,8 @@ type PipedProcess struct {
|
||||
stdout io.ReadCloser
|
||||
parent context.Context
|
||||
cancel context.CancelFunc
|
||||
stopIO func() bool
|
||||
ioDone <-chan struct{}
|
||||
waitOnce sync.Once
|
||||
waitErr error
|
||||
}
|
||||
@@ -79,6 +81,9 @@ func (child *PipedProcess) Cancel() {
|
||||
func (child *PipedProcess) Wait() error {
|
||||
child.waitOnce.Do(func() {
|
||||
err := child.command.Wait()
|
||||
if !child.stopIO() {
|
||||
<-child.ioDone
|
||||
}
|
||||
child.cancel()
|
||||
if err == nil {
|
||||
return
|
||||
@@ -120,12 +125,20 @@ func StartPiped(ctx context.Context, invocation Invocation) (*PipedProcess, erro
|
||||
cancel()
|
||||
return nil, fmt.Errorf("start %q: %w", invocation.Args[0], err)
|
||||
}
|
||||
done := make(chan struct{})
|
||||
stopIO := context.AfterFunc(childContext, func() {
|
||||
defer close(done)
|
||||
_ = stdin.Close()
|
||||
_ = stdout.Close()
|
||||
})
|
||||
return &PipedProcess{
|
||||
command: command,
|
||||
stdin: stdin,
|
||||
stdout: stdout,
|
||||
parent: ctx,
|
||||
cancel: cancel,
|
||||
stopIO: stopIO,
|
||||
ioDone: done,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -135,15 +148,17 @@ func newCommand(ctx context.Context, invocation Invocation) (*exec.Cmd, error) {
|
||||
}
|
||||
command := exec.CommandContext(ctx, invocation.Args[0], invocation.Args[1:]...)
|
||||
command.Dir = invocation.Dir
|
||||
command.WaitDelay = time.Second
|
||||
if len(invocation.Env) != 0 || len(invocation.UnsetEnv) != 0 {
|
||||
command.Env = environmentEntries(invocation.Env, invocation.UnsetEnv)
|
||||
// Dir contributes PWD on POSIX before explicit overrides are applied.
|
||||
command.Env = environmentEntries(command.Environ(), invocation.Env, invocation.UnsetEnv)
|
||||
}
|
||||
return command, nil
|
||||
}
|
||||
|
||||
func environmentEntries(overlay map[string]string, unset []string) []string {
|
||||
environment := make(map[string]string, len(os.Environ())+len(overlay))
|
||||
for _, entry := range os.Environ() {
|
||||
func environmentEntries(base []string, overlay map[string]string, unset []string) []string {
|
||||
environment := make(map[string]string, len(base)+len(overlay))
|
||||
for _, entry := range base {
|
||||
key, value, ok := strings.Cut(entry, "=")
|
||||
if ok {
|
||||
environment[key] = value
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -78,6 +80,50 @@ func TestRunCanRemoveInheritedEnvironment(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDirectoryEnvironmentPreservesExplicitPWDSelection(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("directory-derived PWD is a POSIX behavior")
|
||||
}
|
||||
parent := mustGetwd(t)
|
||||
t.Setenv("PWD", parent)
|
||||
workdir := t.TempDir()
|
||||
for _, test := range []struct {
|
||||
name, override, want string
|
||||
unset bool
|
||||
present bool
|
||||
}{
|
||||
{name: "directory", want: workdir, present: true},
|
||||
{name: "override", override: "selected PWD", want: "selected PWD", present: true},
|
||||
{name: "removed", unset: true},
|
||||
{name: "override after removal", unset: true, override: "selected PWD", want: "selected PWD", present: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
invocation := Invocation{
|
||||
Args: []string{helperExecutable(t), "-test.run=TestProcessHelper", "--", "pwd"},
|
||||
Dir: workdir,
|
||||
Env: map[string]string{"RELEASEKIT_PROCESS_HELPER": "1"},
|
||||
}
|
||||
if test.override != "" {
|
||||
invocation.Env["PWD"] = test.override
|
||||
}
|
||||
if test.unset {
|
||||
invocation.UnsetEnv = []string{"PWD"}
|
||||
}
|
||||
var stdout bytes.Buffer
|
||||
invocation.Stdout = &stdout
|
||||
if err := Run(t.Context(), invocation); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := stdout.String(), fmt.Sprintf("%s|%t", test.want, test.present); got != want {
|
||||
t.Fatalf("child PWD = %q, want %q", got, want)
|
||||
}
|
||||
if os.Getenv("PWD") != parent || mustGetwd(t) != parent {
|
||||
t.Fatal("child invocation changed the parent's directory or PWD")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunReportsMissingExecutable(t *testing.T) {
|
||||
err := Run(context.Background(), Invocation{Args: []string{"releasekit-command-that-does-not-exist"}})
|
||||
if err == nil || !strings.Contains(err.Error(), "releasekit-command-that-does-not-exist") {
|
||||
@@ -119,6 +165,68 @@ func TestRunReturnsCancellation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBoundsInheritedOutputPipes(t *testing.T) {
|
||||
for _, mode := range []string{"cancel-descendant", "exit-descendant"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
ready := filepath.Join(t.TempDir(), "descendant")
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
var output bytes.Buffer
|
||||
result := make(chan error, 1)
|
||||
go func() {
|
||||
result <- Run(ctx, Invocation{
|
||||
Args: []string{helperExecutable(t), "-test.run=TestProcessHelper", "--", "unused", mode},
|
||||
Env: map[string]string{"RELEASEKIT_PROCESS_HELPER": "1", "RELEASEKIT_DESCENDANT_READY": ready},
|
||||
Stdout: &output, Stderr: &output,
|
||||
})
|
||||
}()
|
||||
deadline := time.After(10 * time.Second)
|
||||
var pid int
|
||||
for pid == 0 {
|
||||
data, err := os.ReadFile(ready)
|
||||
if err == nil {
|
||||
pid, _ = strconv.Atoi(string(data))
|
||||
}
|
||||
select {
|
||||
case <-deadline:
|
||||
t.Fatal("descendant did not become ready")
|
||||
default:
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if process, err := os.FindProcess(pid); err == nil {
|
||||
_ = process.Kill()
|
||||
}
|
||||
})
|
||||
if mode == "cancel-descendant" {
|
||||
cancel()
|
||||
}
|
||||
select {
|
||||
case err := <-result:
|
||||
if mode == "cancel-descendant" && !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("canceled command error = %v", err)
|
||||
}
|
||||
if mode == "exit-descendant" && !errors.Is(err, exec.ErrWaitDelay) {
|
||||
t.Fatalf("exited command error = %v, want pipe teardown error", err)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("command did not release inherited output pipes")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDoesNotLimitHealthyCommandLifetime(t *testing.T) {
|
||||
err := Run(context.Background(), Invocation{
|
||||
Args: []string{helperExecutable(t), "-test.run=TestProcessHelper", "--", "unused", "slow-success"},
|
||||
Env: map[string]string{"RELEASEKIT_PROCESS_HELPER": "1"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("healthy command failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartPipedExchangesBeforeInputEOFAndPreservesSettings(t *testing.T) {
|
||||
workdir := filepath.Join(t.TempDir(), "piped directory")
|
||||
if err := os.Mkdir(workdir, 0o700); err != nil {
|
||||
@@ -197,6 +305,52 @@ func TestStartPipedCancellationAfterReadinessReapsChild(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartPipedCancellationInterruptsInheritedOutputDrain(t *testing.T) {
|
||||
ready := filepath.Join(t.TempDir(), "descendant")
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
child, err := StartPiped(ctx, Invocation{
|
||||
Args: []string{helperExecutable(t), "-test.run=TestProcessHelper", "--", "unused", "exit-descendant"},
|
||||
Env: map[string]string{"RELEASEKIT_PROCESS_HELPER": "1", "RELEASEKIT_DESCENDANT_READY": ready},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = child.Stdin().Close()
|
||||
drained := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := io.Copy(io.Discard, child.Stdout())
|
||||
drained <- err
|
||||
}()
|
||||
deadline := time.After(10 * time.Second)
|
||||
var pid int
|
||||
for pid == 0 {
|
||||
if data, err := os.ReadFile(ready); err == nil {
|
||||
pid, _ = strconv.Atoi(string(data))
|
||||
}
|
||||
select {
|
||||
case <-deadline:
|
||||
t.Fatal("descendant did not become ready")
|
||||
default:
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if descendant, err := os.FindProcess(pid); err == nil {
|
||||
_ = descendant.Kill()
|
||||
}
|
||||
})
|
||||
cancel()
|
||||
select {
|
||||
case <-drained:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("inherited stdout did not close after cancellation")
|
||||
}
|
||||
if err := child.Wait(); err != nil && !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("wait after cancellation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartPipedCancelDoesNotCancelParentContext(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
child, err := StartPiped(ctx, Invocation{
|
||||
@@ -269,6 +423,10 @@ func TestProcessHelper(t *testing.T) {
|
||||
}
|
||||
mode := args[len(args)-1]
|
||||
switch mode {
|
||||
case "pwd":
|
||||
value, present := os.LookupEnv("PWD")
|
||||
fmt.Fprintf(os.Stdout, "%s|%t", value, present)
|
||||
os.Exit(0)
|
||||
case "success":
|
||||
workdir, err := os.Getwd()
|
||||
if err != nil {
|
||||
@@ -279,11 +437,39 @@ func TestProcessHelper(t *testing.T) {
|
||||
os.Exit(0)
|
||||
case "exit":
|
||||
os.Exit(7)
|
||||
case "slow-success":
|
||||
time.Sleep(1500 * time.Millisecond)
|
||||
os.Exit(0)
|
||||
case "wait":
|
||||
fmt.Fprintln(os.Stdout, "started")
|
||||
for {
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
case "cancel-descendant", "exit-descendant":
|
||||
child := exec.Command(os.Args[0], "-test.run=TestProcessHelper", "--", "unused", "descendant")
|
||||
child.Stdout, child.Stderr = os.Stdout, os.Stderr
|
||||
if err := child.Start(); err != nil {
|
||||
os.Exit(14)
|
||||
}
|
||||
for {
|
||||
if _, err := os.Stat(os.Getenv("RELEASEKIT_DESCENDANT_READY")); err == nil {
|
||||
break
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if mode == "exit-descendant" {
|
||||
os.Exit(0)
|
||||
}
|
||||
for {
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
case "descendant":
|
||||
if err := os.WriteFile(os.Getenv("RELEASEKIT_DESCENDANT_READY"), []byte(strconv.Itoa(os.Getpid())), 0o600); err != nil {
|
||||
os.Exit(15)
|
||||
}
|
||||
for {
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
case "exchange":
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
request, err := reader.ReadString('\n')
|
||||
|
||||
@@ -105,7 +105,7 @@ func Publish(ctx context.Context, request Request) (Result, error) {
|
||||
}
|
||||
result.Endpoint = endpoint.PushURL
|
||||
if request.ExpectedPushURL != "" && request.ExpectedPushURL != endpoint.PushURL {
|
||||
return result, fmt.Errorf("%w: expected %q, resolved %q", ErrUnexpectedPushURL, request.ExpectedPushURL, endpoint.PushURL)
|
||||
return result, fmt.Errorf("%w: expected %q, resolved %q", ErrUnexpectedPushURL, git.DisplayURL(request.ExpectedPushURL), git.DisplayURL(endpoint.PushURL))
|
||||
}
|
||||
if err := endpoint.Recheck(ctx, captured.RepositoryDir); err != nil {
|
||||
return result, fmt.Errorf("pre-validation endpoint guard: %w", err)
|
||||
@@ -147,10 +147,10 @@ func Publish(ctx context.Context, request Request) (Result, error) {
|
||||
result.Phase = TagCreation
|
||||
result.State = NotPublished
|
||||
tagObject, err := git.CreateAnnotatedTag(ctx, captured.RepositoryDir, captured.Tag, captured.Commit, []byte(captured.Note))
|
||||
result.TagObject = tagObject
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.TagObject = tagObject
|
||||
if err := candidate.Recheck(ctx, captured, tagObject); err != nil {
|
||||
return result, fmt.Errorf("pre-push local guard: %w", err)
|
||||
}
|
||||
@@ -188,11 +188,11 @@ func reconcilePush(endpoint git.Endpoint, captured candidate.Candidate, tagObjec
|
||||
queryErr = git.ErrRemoteStateUnreadable
|
||||
}
|
||||
}
|
||||
return result, fmt.Errorf("push annotated tag %s to %s failed; authoritative reconciliation failed (%w): %w", captured.Tag, endpoint.PushURL, queryErr, pushErr)
|
||||
return result, fmt.Errorf("push annotated tag %s to %s failed; authoritative reconciliation failed (%w): %w", captured.Tag, git.DisplayURL(endpoint.PushURL), queryErr, pushErr)
|
||||
}
|
||||
if remoteTag.State == git.RefAbsent {
|
||||
result.State = NotPublished
|
||||
return result, fmt.Errorf("push annotated tag %s failed and the authoritative tag is absent at %s: %w", captured.Tag, endpoint.PushURL, pushErr)
|
||||
return result, fmt.Errorf("push annotated tag %s failed and the authoritative tag is absent at %s: %w", captured.Tag, git.DisplayURL(endpoint.PushURL), pushErr)
|
||||
}
|
||||
if remoteTag.State == git.RefPresent && remoteTag.Object == tagObject && (remoteTag.Peeled == "" || remoteTag.Peeled == captured.Commit) {
|
||||
result.Phase = Complete
|
||||
@@ -200,7 +200,7 @@ func reconcilePush(endpoint git.Endpoint, captured candidate.Candidate, tagObjec
|
||||
return result, nil
|
||||
}
|
||||
result.State = Conflict
|
||||
return result, fmt.Errorf("push annotated tag %s failed and the authoritative tag identifies object %s, not %s at %s: %w", captured.Tag, remoteTag.Object, tagObject, endpoint.PushURL, pushErr)
|
||||
return result, fmt.Errorf("push annotated tag %s failed and the authoritative tag identifies object %s, not %s at %s: %w", captured.Tag, remoteTag.Object, tagObject, git.DisplayURL(endpoint.PushURL), pushErr)
|
||||
}
|
||||
|
||||
func isRemoteConflict(err error) bool {
|
||||
|
||||
@@ -126,12 +126,6 @@ func selectCommittedDirectory(ctx context.Context, resolver git.PathResolver, mo
|
||||
return selection, nil
|
||||
}
|
||||
|
||||
// FromCommittedSelection derives identity from a previously resolved module
|
||||
// selection and the selected module declaration at a captured commit.
|
||||
func FromCommittedSelection(ctx context.Context, selection Selection, version, commit string) (Identity, error) {
|
||||
return fromCommittedSelection(ctx, selection, version, commit, git.CommittedPaths(selection.RepositoryDir, commit))
|
||||
}
|
||||
|
||||
func fromCommittedSelection(ctx context.Context, selection Selection, version, commit string, resolver git.PathResolver) (Identity, error) {
|
||||
identity := Identity{Selection: selection, Version: version}
|
||||
if err := ctx.Err(); err != nil {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
package tempdir
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -11,11 +12,15 @@ import (
|
||||
// Outside uses system temporary storage and falls back to the repository's
|
||||
// parent when TMPDIR resolves inside the worktree. Cleanup belongs to the caller.
|
||||
func Outside(repositoryDir, pattern string) (string, error) {
|
||||
repository, err := filepath.Abs(repositoryDir)
|
||||
return outside(repositoryDir, pattern, filepath.Abs, filepath.EvalSymlinks, os.RemoveAll)
|
||||
}
|
||||
|
||||
func outside(repositoryDir, pattern string, abs, resolve func(string) (string, error), remove func(string) error) (string, error) {
|
||||
repository, err := abs(repositoryDir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
repository, err = filepath.EvalSymlinks(repository)
|
||||
repository, err = resolve(repository)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -24,54 +29,54 @@ func Outside(repositoryDir, pattern string) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
created := directory
|
||||
directory, err = filepath.Abs(created)
|
||||
directory, err = abs(created)
|
||||
if err != nil {
|
||||
os.RemoveAll(created)
|
||||
return "", err
|
||||
return "", cleanupError(created, err, remove)
|
||||
}
|
||||
directory, err = filepath.EvalSymlinks(directory)
|
||||
directory, err = resolve(directory)
|
||||
if err != nil {
|
||||
os.RemoveAll(directory)
|
||||
return "", err
|
||||
return "", cleanupError(created, err, remove)
|
||||
}
|
||||
outside, err := isOutside(repository, directory)
|
||||
if err != nil {
|
||||
os.RemoveAll(directory)
|
||||
return "", err
|
||||
return "", cleanupError(created, err, remove)
|
||||
}
|
||||
if outside {
|
||||
return directory, nil
|
||||
}
|
||||
if err := os.RemoveAll(directory); err != nil {
|
||||
return "", fmt.Errorf("remove temporary storage inside repository %q: %w", repository, err)
|
||||
if err := remove(created); err != nil {
|
||||
return "", fmt.Errorf("remove temporary storage %q: %w", created, err)
|
||||
}
|
||||
directory, err = os.MkdirTemp(filepath.Dir(repository), pattern)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create temporary storage outside repository %q: %w", repository, err)
|
||||
}
|
||||
created = directory
|
||||
directory, err = filepath.Abs(created)
|
||||
directory, err = abs(created)
|
||||
if err != nil {
|
||||
os.RemoveAll(created)
|
||||
return "", err
|
||||
return "", cleanupError(created, err, remove)
|
||||
}
|
||||
directory, err = filepath.EvalSymlinks(directory)
|
||||
directory, err = resolve(directory)
|
||||
if err != nil {
|
||||
os.RemoveAll(directory)
|
||||
return "", err
|
||||
return "", cleanupError(created, err, remove)
|
||||
}
|
||||
outside, err = isOutside(repository, directory)
|
||||
if err != nil {
|
||||
os.RemoveAll(directory)
|
||||
return "", err
|
||||
return "", cleanupError(created, err, remove)
|
||||
}
|
||||
if !outside {
|
||||
os.RemoveAll(directory)
|
||||
return "", fmt.Errorf("temporary storage %q is inside repository %q", directory, repository)
|
||||
return "", cleanupError(created, fmt.Errorf("temporary storage %q is inside repository %q", directory, repository), remove)
|
||||
}
|
||||
return directory, nil
|
||||
}
|
||||
|
||||
func cleanupError(path string, cause error, remove func(string) error) error {
|
||||
if err := remove(path); err != nil {
|
||||
return errors.Join(cause, fmt.Errorf("remove temporary storage %q: %w", path, err))
|
||||
}
|
||||
return cause
|
||||
}
|
||||
|
||||
func isOutside(repository, directory string) (bool, error) {
|
||||
if !strings.EqualFold(filepath.VolumeName(repository), filepath.VolumeName(directory)) {
|
||||
return true, nil
|
||||
|
||||
@@ -1,12 +1,81 @@
|
||||
package tempdir
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOutsideRemovesAllocatedPathWhenResolutionFails(t *testing.T) {
|
||||
for _, fallback := range []bool{false, true} {
|
||||
for _, operation := range []string{"absolute", "symlinks"} {
|
||||
name := operation
|
||||
if fallback {
|
||||
name += "-fallback"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
repository := filepath.Join(parent, "repository")
|
||||
local := filepath.Join(repository, "tmp")
|
||||
if err := os.MkdirAll(local, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fallback {
|
||||
setTemporaryDirectory(t, local)
|
||||
} else {
|
||||
setTemporaryDirectory(t, parent)
|
||||
}
|
||||
resolutionErr := errors.New("resolution failed")
|
||||
removalErr := errors.New("removal failed")
|
||||
calls := 0
|
||||
failed := false
|
||||
var removed string
|
||||
failAt := 2
|
||||
if fallback {
|
||||
failAt = 3
|
||||
}
|
||||
abs := func(path string) (string, error) {
|
||||
if operation == "absolute" {
|
||||
calls++
|
||||
if calls == failAt {
|
||||
failed = true
|
||||
return "", resolutionErr
|
||||
}
|
||||
}
|
||||
return filepath.Abs(path)
|
||||
}
|
||||
resolve := func(path string) (string, error) {
|
||||
if operation == "symlinks" {
|
||||
calls++
|
||||
if calls == failAt {
|
||||
failed = true
|
||||
return "", resolutionErr
|
||||
}
|
||||
}
|
||||
return filepath.EvalSymlinks(path)
|
||||
}
|
||||
remove := func(path string) error {
|
||||
if err := os.RemoveAll(path); err != nil {
|
||||
return err
|
||||
}
|
||||
if failed {
|
||||
removed = path
|
||||
return removalErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
_, err := outside(repository, "owned-", abs, resolve, remove)
|
||||
if !errors.Is(err, resolutionErr) || !errors.Is(err, removalErr) || removed == "" || !strings.Contains(err.Error(), removed) || !strings.HasPrefix(filepath.Base(removed), "owned-") {
|
||||
t.Fatalf("error = %v, removed = %q", err, removed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutsideHonorsRelativeTemporaryDirectory(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
repository := t.TempDir()
|
||||
|
||||
@@ -27,6 +27,8 @@ const (
|
||||
MarkdownLinks Check = "markdown-links"
|
||||
// SharedFiles identifies a requested shared file that is unavailable.
|
||||
SharedFiles Check = "shared-files"
|
||||
// ForbiddenFiles identifies selected paths present in the committed source.
|
||||
ForbiddenFiles Check = "forbidden-files"
|
||||
)
|
||||
|
||||
// Finding describes a validation observation at an optional source location.
|
||||
|
||||
109
internal/validation/file_size_benchmark_test.go
Normal file
109
internal/validation/file_size_benchmark_test.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package validation
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// benchmarkExclusionIndex is an assessment-only alternative, not production logic.
|
||||
func benchmarkExclusionIndex(exclusions []string) map[string]struct{} {
|
||||
index := make(map[string]struct{}, len(exclusions))
|
||||
for _, exclusion := range normalizedExclusions(exclusions) {
|
||||
index[exclusion] = struct{}{}
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
func benchmarkExcluded(filePath string, index map[string]struct{}) bool {
|
||||
if _, ok := index["."]; ok {
|
||||
return true
|
||||
}
|
||||
if _, ok := index[filePath]; ok {
|
||||
return true
|
||||
}
|
||||
for pos := 0; pos < len(filePath); pos++ {
|
||||
if filePath[pos] == '/' {
|
||||
if _, ok := index[filePath[:pos]]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var benchmarkExclusionMatches int
|
||||
|
||||
func BenchmarkFileSizeExclusions(b *testing.B) {
|
||||
for _, pathCount := range []int{1000, 10000} {
|
||||
for _, exclusionCount := range []int{1, 10, 100, 1000} {
|
||||
for _, depth := range []string{"shallow", "deep"} {
|
||||
for _, match := range []bool{true, false} {
|
||||
exclusions := make([]string, exclusionCount)
|
||||
for i := range exclusions {
|
||||
exclusions[i] = fmt.Sprintf("excluded-%04d", i)
|
||||
}
|
||||
paths := make([]string, pathCount)
|
||||
prefix := "other"
|
||||
if match {
|
||||
prefix = exclusions[0]
|
||||
}
|
||||
if depth == "deep" {
|
||||
prefix += "/nested/one/two/three/four"
|
||||
}
|
||||
for i := range paths {
|
||||
paths[i] = fmt.Sprintf("%s/file-%05d", prefix, i)
|
||||
}
|
||||
name := fmt.Sprintf("paths=%d/exclusions=%d/%s/match=%t", pathCount, exclusionCount, depth, match)
|
||||
for _, indexed := range []bool{false, true} {
|
||||
method := "scan"
|
||||
if indexed {
|
||||
method = "index"
|
||||
}
|
||||
b.Run(name+"/"+method, func(b *testing.B) {
|
||||
normalized := normalizedExclusions(exclusions)
|
||||
index := benchmarkExclusionIndex(exclusions)
|
||||
for _, filePath := range paths {
|
||||
if excludedByPath(filePath, normalized) != benchmarkExcluded(filePath, index) {
|
||||
b.Fatalf("index disagrees for %q", filePath)
|
||||
}
|
||||
}
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
count := 0
|
||||
if indexed {
|
||||
index := benchmarkExclusionIndex(exclusions)
|
||||
for _, filePath := range paths {
|
||||
if benchmarkExcluded(filePath, index) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
} else {
|
||||
normalized := normalizedExclusions(exclusions)
|
||||
for _, filePath := range paths {
|
||||
if excludedByPath(filePath, normalized) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
benchmarkExclusionMatches = count
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBenchmarkExclusionIndexPreservesLiteralMatching(t *testing.T) {
|
||||
for _, exclusions := range [][]string{{"."}, {"/"}, {"a//b", "../x", "", "a/b"}, {"a", "a/b"}} {
|
||||
index := benchmarkExclusionIndex(exclusions)
|
||||
for _, filePath := range []string{"a", "a/b", "a/b/c", "ab/c", "/", "//file", "../x/file", "other"} {
|
||||
if got, want := benchmarkExcluded(filePath, index), excludedByPath(filePath, normalizedExclusions(exclusions)); got != want {
|
||||
t.Errorf("path %q exclusions %s: indexed %t, scan %t", filePath, strings.Join(exclusions, ","), got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
24
internal/validation/forbidden_files.go
Normal file
24
internal/validation/forbidden_files.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package validation
|
||||
|
||||
import (
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func checkForbiddenFiles(tree *CandidateTree, names []string) error {
|
||||
var findings []Finding
|
||||
for _, name := range names {
|
||||
clean := path.Clean(name)
|
||||
if name == "" || path.IsAbs(name) || clean == "." || clean == ".." || strings.HasPrefix(clean, "../") {
|
||||
findings = append(findings, Finding{Path: name, Message: "expected a repository-relative file path"})
|
||||
continue
|
||||
}
|
||||
if _, exists := tree.Entry(clean); exists {
|
||||
findings = append(findings, Finding{Path: clean, Message: "file must be absent from the committed candidate"})
|
||||
}
|
||||
}
|
||||
if len(findings) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &Failure{Check: ForbiddenFiles, Findings: findings}
|
||||
}
|
||||
49
internal/validation/forbidden_files_test.go
Normal file
49
internal/validation/forbidden_files_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package validation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestForbiddenFilesUsesCapturedRepositoryPaths(t *testing.T) {
|
||||
repository := newTreeRepository(t)
|
||||
writeTreeFile(t, repository, "go.mod", []byte("module example.com/root\n"))
|
||||
writeTreeFile(t, repository, "nested/go.mod", []byte("module example.com/nested\n"))
|
||||
writeTreeFile(t, repository, "nested/token", []byte("fixture-secret\n"))
|
||||
writeTreeFile(t, repository, ".gitignore", []byte(".releasekit/\n"))
|
||||
gitTree(t, repository, "add", ".")
|
||||
gitTree(t, repository, "commit", "-m", "capture a committed credential path")
|
||||
commit := strings.TrimSpace(gitTree(t, repository, "rev-parse", "HEAD"))
|
||||
writeTreeFile(t, repository, ".releasekit/token", []byte("local-secret\n"))
|
||||
if err := os.Remove(filepath.Join(repository, "nested/token")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
installGoShim(t, "", false)
|
||||
request := Request{RepositoryDir: repository, Commit: commit}
|
||||
if err := Run(context.Background(), request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request.Settings.ForbiddenFiles = []string{".releasekit/token", "absent-token"}
|
||||
if err := Run(context.Background(), request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request.Settings.ForbiddenFiles = []string{"nested/./token"}
|
||||
err := Run(context.Background(), request)
|
||||
failure, ok := err.(*Failure)
|
||||
if !ok || failure.Check != ForbiddenFiles || len(failure.Findings) != 1 || failure.Findings[0].Path != "nested/token" {
|
||||
t.Fatalf("forbidden-files error = %v, want committed nested path despite worktree deletion", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "fixture-secret") {
|
||||
t.Fatal("validation exposed committed token bytes")
|
||||
}
|
||||
for _, invalid := range []string{"", "../token", "/token"} {
|
||||
request.Settings.ForbiddenFiles = []string{invalid}
|
||||
if err := Run(context.Background(), request); err == nil {
|
||||
t.Fatalf("invalid path %q was accepted", invalid)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,7 +87,7 @@ func TestParseMarkdownHeadingAnchors(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownCacheSharesParsedDocumentsByBlobObjectID(t *testing.T) {
|
||||
func TestMarkdownDocumentsParseIdenticalAndDistinctBlobs(t *testing.T) {
|
||||
repository := newTreeRepository(t)
|
||||
shared := []byte("# Guide\n[other](other.md \"title\")\n")
|
||||
writeTreeFile(t, repository, "one/guide.md", shared)
|
||||
@@ -111,20 +111,17 @@ func TestMarkdownCacheSharesParsedDocumentsByBlobObjectID(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first != second {
|
||||
t.Fatal("identical Markdown blobs returned different parsed documents")
|
||||
}
|
||||
other, err := cache.document(context.Background(), "one/other.md")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if other == first {
|
||||
t.Fatal("different Markdown blobs shared a parsed document")
|
||||
}
|
||||
if _, ok := other.anchors["other"]; !ok {
|
||||
t.Fatalf("other document anchors = %#v", other.anchors)
|
||||
}
|
||||
if got := first.links[0]; got.Destination != "other.md" || got.Title != "title" || got.Location.Line != 2 {
|
||||
t.Fatalf("shared Markdown link = %#v, want content-relative destination, title, and location", got)
|
||||
}
|
||||
if got := second.links[0]; got.Destination != "other.md" || got.Title != "title" || got.Location.Line != 2 {
|
||||
t.Fatalf("second Markdown link = %#v, want content-relative destination, title, and location", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ type Settings struct {
|
||||
FullVet bool
|
||||
CompileTargets []Target
|
||||
SharedFiles []string
|
||||
ForbiddenFiles []string
|
||||
GoFormat bool
|
||||
Markdown *MarkdownSettings
|
||||
ModuleDistribution ModuleDistributionSettings
|
||||
@@ -70,7 +71,7 @@ func (request Request) ReadCandidateTree(ctx context.Context) (*CandidateTree, e
|
||||
}
|
||||
|
||||
// Run executes shared validation for the captured candidate.
|
||||
func Run(ctx context.Context, request Request) (result error) {
|
||||
func Run(ctx context.Context, request Request) error {
|
||||
if request.Settings.FileSize != nil && request.Settings.FileSize.LimitBytes != nil && *request.Settings.FileSize.LimitBytes < 0 {
|
||||
return &Failure{
|
||||
Check: FileSize,
|
||||
@@ -88,6 +89,13 @@ func Run(ctx context.Context, request Request) (result error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := runGoChecks(ctx, request); err != nil {
|
||||
return err
|
||||
}
|
||||
return runTreeChecks(ctx, request)
|
||||
}
|
||||
|
||||
func runGoChecks(ctx context.Context, request Request) error {
|
||||
goDirectory := request.ModuleDir
|
||||
if goDirectory == "" {
|
||||
goDirectory = request.RepositoryDir
|
||||
@@ -133,8 +141,12 @@ func Run(ctx context.Context, request Request) (result error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runTreeChecks(ctx context.Context, request Request) (result error) {
|
||||
needsSharedSelection := len(request.Settings.SharedFiles) != 0
|
||||
if request.Settings.GoFormat || request.Settings.ModuleDistribution.WorkspaceFiles || request.Settings.ModuleDistribution.LocalReplacements || request.Settings.FileSize != nil || request.Settings.Markdown != nil || needsSharedSelection {
|
||||
if request.Settings.GoFormat || request.Settings.ModuleDistribution.WorkspaceFiles || request.Settings.ModuleDistribution.LocalReplacements || request.Settings.FileSize != nil || request.Settings.Markdown != nil || needsSharedSelection || len(request.Settings.ForbiddenFiles) != 0 {
|
||||
tree, err := request.ReadCandidateTree(ctx)
|
||||
if err != nil {
|
||||
var failure *Failure
|
||||
@@ -154,6 +166,9 @@ func Run(ctx context.Context, request Request) (result error) {
|
||||
if request.Settings.GoFormat {
|
||||
check = GoFormat
|
||||
}
|
||||
if len(request.Settings.ForbiddenFiles) != 0 {
|
||||
check = ForbiddenFiles
|
||||
}
|
||||
return &Failure{Check: check, Cause: err}
|
||||
}
|
||||
lastContentCheck := Check("")
|
||||
@@ -166,6 +181,9 @@ func Run(ctx context.Context, request Request) (result error) {
|
||||
}
|
||||
}
|
||||
}()
|
||||
if err := checkForbiddenFiles(tree, request.Settings.ForbiddenFiles); err != nil {
|
||||
return err
|
||||
}
|
||||
if request.Settings.GoFormat {
|
||||
readsBefore := tree.blobReads
|
||||
if err := checkGoFormat(ctx, tree); err != nil {
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
"testing"
|
||||
|
||||
releasekit "go.maximumdirect.net/releasekit"
|
||||
"go.maximumdirect.net/releasekit/internal/gitea"
|
||||
"go.maximumdirect.net/releasekit/internal/artifact"
|
||||
)
|
||||
|
||||
const performanceDeliveryArtifactSize = 16 << 20
|
||||
@@ -150,7 +150,7 @@ func newDeliveryBenchmarkFixture(b testing.TB) (string, releasekit.BuildResult,
|
||||
}
|
||||
manifest := performanceDeliveryManifest(build.Artifacts)
|
||||
manifestHash := sha256.Sum256(manifest)
|
||||
assets = append(assets, deliveryBenchmarkAsset{name: gitea.ManifestFilename, data: manifest, digest: hex.EncodeToString(manifestHash[:])})
|
||||
assets = append(assets, deliveryBenchmarkAsset{name: artifact.ManifestFilename, data: manifest, digest: hex.EncodeToString(manifestHash[:])})
|
||||
return repository, build, assets
|
||||
}
|
||||
|
||||
@@ -193,6 +193,11 @@ func (state *deliveryBenchmarkServer) verifyUploads(b testing.TB) {
|
||||
|
||||
func (state *deliveryBenchmarkServer) serveHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/repos/owner/example", "/api/v1/repositories/1":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": 1, "name": "example", "full_name": "owner/example",
|
||||
"owner": map[string]string{"login": "owner"},
|
||||
})
|
||||
case "/api/v1/repos/owner/example/tags/v1.2.3":
|
||||
_, _ = fmt.Fprintf(w, `{"name":"v1.2.3","commit":{"sha":%q}}`, state.commit)
|
||||
case "/api/v1/repos/owner/example/releases/tags/v1.2.3":
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user