Compare commits
133 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 23203f235c | |||
| 37d6209aec | |||
| ee72566263 | |||
| e0019abe31 | |||
| 2a7bed87db | |||
| dfaa652098 | |||
| cd08a2892a | |||
| 16d1c072e3 | |||
| 363f7cd513 | |||
| cd274512e7 | |||
| 3b8bacf114 | |||
| 781341a0ba | |||
| 109596bc3b | |||
| 6fc140d8dc | |||
| 993866c1d7 | |||
| 19a14bfeb6 | |||
| 648c87bb8a | |||
| 04db23705a | |||
| 9a7b9b9301 | |||
| d2b1329095 | |||
| 0d19a5546f | |||
| da1839c6d2 | |||
| 957e75524c | |||
| 0ef135bfda | |||
| 3280f734ef | |||
| 599ad81bec | |||
| 6915a86132 | |||
| 4afdd9b826 | |||
| fb5947ea3a | |||
| 2a04938a96 | |||
| b11c6d1788 | |||
| 1ddeb2c6dc | |||
| 4dff8086ab | |||
| 447b42a1cd | |||
| 58656dea45 | |||
| 58c20fa863 | |||
| f73f0c1b6a | |||
| bff4ed8a8b | |||
| e0bf5ca57a | |||
| 691926d839 | |||
| 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 | |||
| 6650b639e1 | |||
| a1cac0898e | |||
| 3b803d558b | |||
| 56a86d6d49 | |||
| f97edeb830 |
@@ -8,6 +8,12 @@ tagged-source delivery API, and the `version`, `check`, `publish`, `build`,
|
|||||||
maintainer procedure and prepared Woodpecker workflow for hosted note-page
|
maintainer procedure and prepared Woodpecker workflow for hosted note-page
|
||||||
delivery are documented. ReleaseKit does not declare binary deliverables.
|
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
|
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
|
root remains the default; a selected module's declared import path and physical
|
||||||
directory determine its complete Git tag and module-local release note. See the
|
directory determine its complete Git tag and module-local release note. See the
|
||||||
@@ -75,6 +81,8 @@ failure recovery.
|
|||||||
components and their responsibilities.
|
components and their responsibilities.
|
||||||
- [Architecture policy](docs/policy/architecture.md): responsibility and dependency
|
- [Architecture policy](docs/policy/architecture.md): responsibility and dependency
|
||||||
boundaries for development.
|
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.
|
- [ADR 0001](docs/adr/0001-public-facade.md): the accepted public facade decision.
|
||||||
- [Policy provenance](docs/policy/meta.md): adopted policy sources and update
|
- [Policy provenance](docs/policy/meta.md): adopted policy sources and update
|
||||||
guidance.
|
guidance.
|
||||||
|
|||||||
15
build.go
15
build.go
@@ -5,11 +5,11 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact"
|
"go.maximumdirect.net/releasekit/internal/artifact"
|
||||||
internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build"
|
internalbuild "go.maximumdirect.net/releasekit/internal/build"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
|
"go.maximumdirect.net/releasekit/internal/git"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity"
|
"go.maximumdirect.net/releasekit/internal/releaseidentity"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
|
"go.maximumdirect.net/releasekit/internal/versioncontract"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Target identifies one explicit Go operating-system and architecture pair.
|
// Target identifies one explicit Go operating-system and architecture pair.
|
||||||
@@ -145,10 +145,7 @@ func internalBuildSpec(spec *BuildSpec) *internalbuild.Spec {
|
|||||||
func internalBuildResult(result BuildResult) *internalbuild.Result {
|
func internalBuildResult(result BuildResult) *internalbuild.Result {
|
||||||
input := internalBuildSpec(&BuildSpec{MainPackage: result.MainPackage, BinaryName: result.BinaryName, Targets: result.Targets})
|
input := internalBuildSpec(&BuildSpec{MainPackage: result.MainPackage, BinaryName: result.BinaryName, Targets: result.Targets})
|
||||||
settings := artifact.NormalizeSettings(input.MainPackage, input.BinaryName, input.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}
|
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 _, target := range settings.Targets {
|
|
||||||
converted.Targets = append(converted.Targets, internalbuild.Target{OS: target.OS, Arch: target.Arch})
|
|
||||||
}
|
|
||||||
for _, artifact := range result.Artifacts {
|
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})
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
116
build_test.go
116
build_test.go
@@ -15,9 +15,44 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
releasekit "gitea.maximumdirect.net/eric/ReleaseKit"
|
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) {
|
func TestBuildPreservesInvalidVersionAndDeclarationFailureEvidence(t *testing.T) {
|
||||||
spec := releasekit.BuildSpec{MainPackage: "./cmd/app", BinaryName: "widget", Targets: []releasekit.Target{{OS: "linux", Arch: "amd64"}, {OS: "linux", Arch: "amd64"}}}
|
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{
|
invalid, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
|
||||||
@@ -48,7 +83,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo"
|
"go.maximumdirect.net/releasekit/buildinfo"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -127,7 +162,7 @@ func TestBuildUsesGoHostDespiteAmbientTargetEnvironment(t *testing.T) {
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo"
|
"go.maximumdirect.net/releasekit/buildinfo"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -159,7 +194,7 @@ func TestBuildIgnoresAmbientAndPersistentArchitectureTuning(t *testing.T) {
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo"
|
"go.maximumdirect.net/releasekit/buildinfo"
|
||||||
)
|
)
|
||||||
func main() { if len(os.Args) == 2 && os.Args[1] == "version" { fmt.Printf("fixture %s\n", buildinfo.Version()) } }
|
func main() { if len(os.Args) == 2 && os.Args[1] == "version" { fmt.Printf("fixture %s\n", buildinfo.Version()) } }
|
||||||
`)
|
`)
|
||||||
@@ -272,7 +307,7 @@ func TestBuildUsesNestedWorkingTreeModuleAndModuleRelativeOutput(t *testing.T) {
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo"
|
"go.maximumdirect.net/releasekit/buildinfo"
|
||||||
)
|
)
|
||||||
func main() {
|
func main() {
|
||||||
if len(os.Args) == 2 && os.Args[1] == "version" {
|
if len(os.Args) == 2 && os.Args[1] == "version" {
|
||||||
@@ -406,7 +441,7 @@ func TestBuildResolvesDirtyWorkingTreeDeclaration(t *testing.T) {
|
|||||||
|
|
||||||
func TestBuildRegeneratesOwnedOutputsAndPreservesUnrelatedFiles(t *testing.T) {
|
func TestBuildRegeneratesOwnedOutputsAndPreservesUnrelatedFiles(t *testing.T) {
|
||||||
const source = `package main
|
const source = `package main
|
||||||
import("fmt";"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo")
|
import("fmt";"go.maximumdirect.net/releasekit/buildinfo")
|
||||||
func main(){fmt.Println("fixture",buildinfo.Version())}
|
func main(){fmt.Println("fixture",buildinfo.Version())}
|
||||||
`
|
`
|
||||||
repository := newBuildConsumer(t, source)
|
repository := newBuildConsumer(t, source)
|
||||||
@@ -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) {
|
func TestBuildReportsIncorrectVersionOutputAndRetainsBinary(t *testing.T) {
|
||||||
repository := newBuildConsumer(t, `package main
|
repository := newBuildConsumer(t, `package main
|
||||||
import (
|
import (
|
||||||
@@ -616,7 +706,7 @@ func TestCheckBuildsAfterValidationAndKeepsOutputOutsideWorktree(t *testing.T) {
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo"
|
"go.maximumdirect.net/releasekit/buildinfo"
|
||||||
)
|
)
|
||||||
func main() { if len(os.Args) == 2 && os.Args[1] == "version" { fmt.Printf("fixture %s\n", buildinfo.Version()) } }
|
func main() { if len(os.Args) == 2 && os.Args[1] == "version" { fmt.Printf("fixture %s\n", buildinfo.Version()) } }
|
||||||
`)
|
`)
|
||||||
@@ -713,7 +803,7 @@ func newBuildConsumer(t *testing.T, mainSource string) string {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
module := fmt.Sprintf("module example.com/buildfixture\n\ngo 1.26.0\n\nrequire gitea.maximumdirect.net/eric/ReleaseKit v0.0.0\n\nreplace gitea.maximumdirect.net/eric/ReleaseKit => %s\n", filepath.ToSlash(root))
|
module := fmt.Sprintf("module example.com/buildfixture\n\ngo 1.26.0\n\nrequire go.maximumdirect.net/releasekit v0.0.0\n\nreplace go.maximumdirect.net/releasekit => %s\n", filepath.ToSlash(root))
|
||||||
writeBuildFixture(t, repository, "go.mod", module)
|
writeBuildFixture(t, repository, "go.mod", module)
|
||||||
writeBuildFixture(t, repository, "cmd/app/main.go", mainSource)
|
writeBuildFixture(t, repository, "cmd/app/main.go", mainSource)
|
||||||
writeBuildFixture(t, repository, "cmd/app/main_test.go", "package main\nimport \"testing\"\nfunc TestCandidateFixture(t *testing.T) {}\n")
|
writeBuildFixture(t, repository, "cmd/app/main_test.go", "package main\nimport \"testing\"\nfunc TestCandidateFixture(t *testing.T) {}\n")
|
||||||
@@ -731,7 +821,7 @@ func writeNestedBuildConsumer(t *testing.T, repository, moduleDir, modulePath, m
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
module := fmt.Sprintf("module %s\n\ngo 1.26.0\n\nrequire gitea.maximumdirect.net/eric/ReleaseKit v0.0.0\n\nreplace gitea.maximumdirect.net/eric/ReleaseKit => %s\n", modulePath, filepath.ToSlash(root))
|
module := fmt.Sprintf("module %s\n\ngo 1.26.0\n\nrequire go.maximumdirect.net/releasekit v0.0.0\n\nreplace go.maximumdirect.net/releasekit => %s\n", modulePath, filepath.ToSlash(root))
|
||||||
writeBuildFixture(t, repository, filepath.ToSlash(filepath.Join(moduleDir, "go.mod")), module)
|
writeBuildFixture(t, repository, filepath.ToSlash(filepath.Join(moduleDir, "go.mod")), module)
|
||||||
writeBuildFixture(t, repository, filepath.ToSlash(filepath.Join(moduleDir, "cmd/app/main.go")), mainSource)
|
writeBuildFixture(t, repository, filepath.ToSlash(filepath.Join(moduleDir, "cmd/app/main.go")), mainSource)
|
||||||
return filepath.Join(repository, filepath.FromSlash(moduleDir))
|
return filepath.Join(repository, filepath.FromSlash(moduleDir))
|
||||||
@@ -743,7 +833,7 @@ func prepareCandidateBuild(t *testing.T, repository, mainSource string) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
module := fmt.Sprintf("module example.com/candidatebuild\n\ngo 1.26.0\n\nrequire gitea.maximumdirect.net/eric/ReleaseKit v0.0.0\n\nreplace gitea.maximumdirect.net/eric/ReleaseKit => %s\n", filepath.ToSlash(root))
|
module := fmt.Sprintf("module example.com/candidatebuild\n\ngo 1.26.0\n\nrequire go.maximumdirect.net/releasekit v0.0.0\n\nreplace go.maximumdirect.net/releasekit => %s\n", filepath.ToSlash(root))
|
||||||
writeFile(t, repository, "go.mod", module)
|
writeFile(t, repository, "go.mod", module)
|
||||||
writeFile(t, repository, ".gitignore", "dist/\n")
|
writeFile(t, repository, ".gitignore", "dist/\n")
|
||||||
writeFile(t, repository, "cmd/app/main.go", mainSource)
|
writeFile(t, repository, "cmd/app/main.go", mainSource)
|
||||||
@@ -777,7 +867,7 @@ func buildGit(t *testing.T, directory string, args ...string) string {
|
|||||||
|
|
||||||
func TestBuildSourceEligibilitySurvivesLaterCheckoutCleanup(t *testing.T) {
|
func TestBuildSourceEligibilitySurvivesLaterCheckoutCleanup(t *testing.T) {
|
||||||
const source = `package main
|
const source = `package main
|
||||||
import("fmt";"os";"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo")
|
import("fmt";"os";"go.maximumdirect.net/releasekit/buildinfo")
|
||||||
func main(){ if len(os.Args)>1 && os.Args[1]=="version" {fmt.Println("fixture",buildinfo.Version())} }
|
func main(){ if len(os.Args)>1 && os.Args[1]=="version" {fmt.Println("fixture",buildinfo.Version())} }
|
||||||
`
|
`
|
||||||
for _, mode := range []string{"clean", "dirty before build", "changed during build", "commit during build"} {
|
for _, mode := range []string{"clean", "dirty before build", "changed during build", "commit during build"} {
|
||||||
@@ -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, "restore", "cmd/app/main.go")
|
||||||
buildGit(t, repository, "checkout", "--detach", "v1.2.3")
|
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 {
|
if err == nil || !strings.Contains(err.Error(), "build source") || delivered.State != releasekit.DeliveryNotAttempted {
|
||||||
t.Fatalf("dirty source delivered: %#v %v", delivered, err)
|
t.Fatalf("dirty source delivered: %#v %v", delivered, err)
|
||||||
}
|
}
|
||||||
@@ -838,7 +928,7 @@ func TestCandidateBuildDoesNotRequireWritableRepositoryParent(t *testing.T) {
|
|||||||
t.Skip("requires ordinary Unix permission checks")
|
t.Skip("requires ordinary Unix permission checks")
|
||||||
}
|
}
|
||||||
repository := newBuildConsumer(t, `package main
|
repository := newBuildConsumer(t, `package main
|
||||||
import("fmt";"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo")
|
import("fmt";"go.maximumdirect.net/releasekit/buildinfo")
|
||||||
func main(){fmt.Println("fixture",buildinfo.Version())}
|
func main(){fmt.Println("fixture",buildinfo.Version())}
|
||||||
`)
|
`)
|
||||||
writeBuildFixture(t, repository, "docs/releases/v1.2.3.md", "# Fixture v1.2.3\n")
|
writeBuildFixture(t, repository, "docs/releases/v1.2.3.md", "# Fixture v1.2.3\n")
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// built with ReleaseKit.
|
// built with ReleaseKit.
|
||||||
package buildinfo
|
package buildinfo
|
||||||
|
|
||||||
import "gitea.maximumdirect.net/eric/ReleaseKit/internal/runtimeinfo"
|
import "go.maximumdirect.net/releasekit/internal/runtimeinfo"
|
||||||
|
|
||||||
// Version returns the injected release identifier when available. Otherwise,
|
// Version returns the injected release identifier when available. Otherwise,
|
||||||
// it returns the executable's main module version, or "dev" when that metadata
|
// it returns the executable's main module version, or "dev" when that metadata
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
|
"go.maximumdirect.net/releasekit/internal/versioncontract"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestVersionInConsumerExecutable(t *testing.T) {
|
func TestVersionInConsumerExecutable(t *testing.T) {
|
||||||
@@ -21,9 +21,9 @@ func TestVersionInConsumerExecutable(t *testing.T) {
|
|||||||
|
|
||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require gitea.maximumdirect.net/eric/ReleaseKit v0.0.0
|
require go.maximumdirect.net/releasekit v0.0.0
|
||||||
|
|
||||||
replace gitea.maximumdirect.net/eric/ReleaseKit => %s
|
replace go.maximumdirect.net/releasekit => %s
|
||||||
`, filepath.ToSlash(repo))
|
`, filepath.ToSlash(repo))
|
||||||
if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte(module), 0o600); err != nil {
|
if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte(module), 0o600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -32,7 +32,7 @@ replace gitea.maximumdirect.net/eric/ReleaseKit => %s
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo"
|
"go.maximumdirect.net/releasekit/buildinfo"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() { fmt.Print(buildinfo.Version()) }
|
func main() { fmt.Print(buildinfo.Version()) }
|
||||||
|
|||||||
22
check.go
22
check.go
@@ -4,10 +4,10 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"io"
|
"io"
|
||||||
|
|
||||||
internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build"
|
internalbuild "go.maximumdirect.net/releasekit/internal/build"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate"
|
"go.maximumdirect.net/releasekit/internal/candidate"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/publish"
|
"go.maximumdirect.net/releasekit/internal/publish"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/validation"
|
"go.maximumdirect.net/releasekit/internal/validation"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrInvalidVersion reports a version that is not stable vMAJOR.MINOR.PATCH.
|
// ErrInvalidVersion reports a version that is not stable vMAJOR.MINOR.PATCH.
|
||||||
@@ -46,6 +46,11 @@ type ReleaseRequest struct {
|
|||||||
// this string exactly before validation or tag creation. Check ignores this
|
// this string exactly before validation or tag creation. Check ignores this
|
||||||
// publication-only expectation. A mismatch returns ErrUnexpectedPushURL.
|
// publication-only expectation. A mismatch returns ErrUnexpectedPushURL.
|
||||||
ExpectedPushURL string
|
ExpectedPushURL string
|
||||||
|
// PublicationRecordDir optionally overrides Publish's private attempt-record
|
||||||
|
// directory. Relative paths are resolved from the source repository root;
|
||||||
|
// Check ignores this publication-only setting. Recording failure is returned
|
||||||
|
// separately on PublicationResult and does not change publication status.
|
||||||
|
PublicationRecordDir string
|
||||||
// Validation selects optional checks run against the captured candidate.
|
// Validation selects optional checks run against the captured candidate.
|
||||||
Validation ValidationSpec
|
Validation ValidationSpec
|
||||||
// AdditionalChecks optionally runs product checks after shared validation.
|
// AdditionalChecks optionally runs product checks after shared validation.
|
||||||
@@ -59,6 +64,8 @@ type ReleaseRequest struct {
|
|||||||
// module and shared-file inputs. Independent nested modules form automatic
|
// module and shared-file inputs. Independent nested modules form automatic
|
||||||
// input boundaries; vendored files remain included. Referenced destinations
|
// input boundaries; vendored files remain included. Referenced destinations
|
||||||
// remain available for targeted inspection without expanding the input set.
|
// remain available for targeted inspection without expanding the input set.
|
||||||
|
// ForbiddenFiles checks explicitly selected repository paths independently of
|
||||||
|
// those file inputs.
|
||||||
type ValidationSpec struct {
|
type ValidationSpec struct {
|
||||||
// SharedFiles adds repository-relative committed files to optional file
|
// SharedFiles adds repository-relative committed files to optional file
|
||||||
// checks. Nil selects the optional root COPYRIGHT, LICENSE, and
|
// checks. Nil selects the optional root COPYRIGHT, LICENSE, and
|
||||||
@@ -66,6 +73,10 @@ type ValidationSpec struct {
|
|||||||
// Explicit files replace the defaults and must exist even when file checks
|
// Explicit files replace the defaults and must exist even when file checks
|
||||||
// are off.
|
// are off.
|
||||||
SharedFiles []string
|
SharedFiles []string
|
||||||
|
// 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
|
Go GoValidationSpec
|
||||||
// GoFormat checks the formatting of committed regular Go source files. It
|
// GoFormat checks the formatting of committed regular Go source files. It
|
||||||
// does not modify repository contents and is disabled when false.
|
// does not modify repository contents and is disabled when false.
|
||||||
@@ -137,6 +148,8 @@ type GoValidationSpec struct {
|
|||||||
// of test-time vet, and Validation.Go.CompileTargets compiles all packages for
|
// 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
|
// each selected GOOS/GOARCH pair after tests and vet with cgo disabled. Package
|
||||||
// compilation produces no artifacts and does not execute target binaries.
|
// 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
|
// Validation.GoFormat checks regular committed Go source after those selected
|
||||||
// Go checks and before AdditionalChecks. Its zero value disables formatting.
|
// Go checks and before AdditionalChecks. Its zero value disables formatting.
|
||||||
// Validation.ModuleDistribution.WorkspaceFiles rejects committed workspace
|
// Validation.ModuleDistribution.WorkspaceFiles rejects committed workspace
|
||||||
@@ -189,6 +202,7 @@ func candidateRequest(request ReleaseRequest) candidate.Request {
|
|||||||
FullVet: request.Validation.Go.FullVet,
|
FullVet: request.Validation.Go.FullVet,
|
||||||
CompileTargets: make([]validation.Target, len(request.Validation.Go.CompileTargets)),
|
CompileTargets: make([]validation.Target, len(request.Validation.Go.CompileTargets)),
|
||||||
SharedFiles: copyStringSlice(request.Validation.SharedFiles),
|
SharedFiles: copyStringSlice(request.Validation.SharedFiles),
|
||||||
|
ForbiddenFiles: copyStringSlice(request.Validation.ForbiddenFiles),
|
||||||
GoFormat: request.Validation.GoFormat,
|
GoFormat: request.Validation.GoFormat,
|
||||||
Markdown: internalMarkdownSettings(request.Validation.Markdown),
|
Markdown: internalMarkdownSettings(request.Validation.Markdown),
|
||||||
ModuleDistribution: validation.ModuleDistributionSettings{
|
ModuleDistribution: validation.ModuleDistributionSettings{
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli"
|
"go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestBuildAndPrebuiltDeliveryUseSameDirectoryBase(t *testing.T) {
|
func TestBuildAndPrebuiltDeliveryUseSameDirectoryBase(t *testing.T) {
|
||||||
@@ -42,7 +42,7 @@ func TestBuildAndPrebuiltDeliveryUseSameDirectoryBase(t *testing.T) {
|
|||||||
defer server.Close()
|
defer server.Close()
|
||||||
settings := cli.Settings{Product: "Example Product", ModuleDir: tt.module,
|
settings := cli.Settings{Product: "Example Product", ModuleDir: tt.module,
|
||||||
Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}},
|
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)
|
writeDeliveryProfile(t, repository, settings)
|
||||||
cliGitRun(t, repository, "add", ".")
|
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 {
|
if err := os.WriteFile(receipt.Artifacts[0].Path, []byte("changed"), 0o600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
requestsBefore := state.snapshot().apiRequests
|
writesBefore := state.snapshot().writes
|
||||||
if status := run(context.Background(), args, &stdout, &stderr); status != 1 || state.snapshot().apiRequests != requestsBefore {
|
if status := run(context.Background(), args, &stdout, &stderr); status != 1 || state.snapshot().writes != writesBefore {
|
||||||
t.Fatalf("changed content status=%d, requests=%d->%d, stderr=%s", status, requestsBefore, state.snapshot().apiRequests, stderr.String())
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli"
|
"go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCandidateCheckUsesCommittedDeclarationSymlink(t *testing.T) {
|
func TestCandidateCheckUsesCommittedDeclarationSymlink(t *testing.T) {
|
||||||
@@ -40,7 +40,7 @@ func TestPageOnlyDeliveryUsesCommittedModuleInSparseCheckout(t *testing.T) {
|
|||||||
server := newDeliveryServer(t, state)
|
server := newDeliveryServer(t, state)
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", ModuleDir: module,
|
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") })
|
state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
|
||||||
cliGitRun(t, repository, "tag", state.snapshot().tag)
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ func TestMultiModuleExampleSetupUsesCurrentSourceTool(t *testing.T) {
|
|||||||
root := cliRepositoryRoot(t)
|
root := cliRepositoryRoot(t)
|
||||||
repository := newCLIRepository(t)
|
repository := newCLIRepository(t)
|
||||||
copyExampleTree(t, filepath.Join(root, "examples", "multi-module"), repository)
|
copyExampleTree(t, filepath.Join(root, "examples", "multi-module"), repository)
|
||||||
const module = "gitea.maximumdirect.net/eric/ReleaseKit"
|
const module = "go.maximumdirect.net/releasekit"
|
||||||
environment := append(os.Environ(), "GOTOOLCHAIN=local", "GOWORK=off", "GOPROXY=off", "GOSUMDB=off", "GOFLAGS=")
|
environment := append(os.Environ(), "GOTOOLCHAIN=local", "GOWORK=off", "GOPROXY=off", "GOSUMDB=off", "GOFLAGS=")
|
||||||
goCommand := func(directory string, args ...string) string {
|
goCommand := func(directory string, args ...string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|||||||
@@ -13,11 +13,11 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/process"
|
"go.maximumdirect.net/releasekit/internal/process"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
|
"go.maximumdirect.net/releasekit/internal/versioncontract"
|
||||||
)
|
)
|
||||||
|
|
||||||
const Usage = "usage: releasekit version\n releasekit check [--repo DIR] [--config FILE] VERSION\n releasekit publish [--repo DIR] [--config FILE] [--expected-module DIR] VERSION\n releasekit build [--repo DIR] [--config FILE] [--output DIR] VERSION\n releasekit deliver [--repo DIR] [--config FILE] [--assets DIR] VERSION\n releasekit ci-deliver [--repo DIR] [--config FILE] [--remote NAME] [--prepared] [--expected-module DIR] --commit OID TAG"
|
const Usage = "usage: releasekit version\n releasekit check [--repo DIR] [--config FILE] VERSION\n releasekit publish [--repo DIR] [--config FILE] [--record-dir DIR] [--expected-module DIR] VERSION\n releasekit build [--repo DIR] [--config FILE] [--output DIR] VERSION\n releasekit deliver [--repo DIR] [--config FILE] [--assets DIR] VERSION\n releasekit ci-deliver [--repo DIR] [--config FILE] [--remote NAME] [--prepared] [--expected-module DIR] --commit OID TAG\n releasekit reconcile --record FILE [--json]\n releasekit publication-attempts [--repo DIR] [--record-dir DIR] [--json]"
|
||||||
|
|
||||||
type Command string
|
type Command string
|
||||||
|
|
||||||
@@ -28,6 +28,8 @@ const (
|
|||||||
BuildAction Command = "build"
|
BuildAction Command = "build"
|
||||||
DeliveryAction Command = "deliver"
|
DeliveryAction Command = "deliver"
|
||||||
CIDeliveryAction Command = "ci-deliver"
|
CIDeliveryAction Command = "ci-deliver"
|
||||||
|
ReconcileAction Command = "reconcile"
|
||||||
|
PublicationAttemptsAction Command = "publication-attempts"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Invocation contains parsed command settings owned by the CLI adapter.
|
// Invocation contains parsed command settings owned by the CLI adapter.
|
||||||
@@ -43,6 +45,9 @@ type Invocation struct {
|
|||||||
ExpectedModule string
|
ExpectedModule string
|
||||||
ExpectedTag string
|
ExpectedTag string
|
||||||
Prepared bool
|
Prepared bool
|
||||||
|
RecordPath string
|
||||||
|
RecordDir string
|
||||||
|
JSON bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse accepts only commands that have been implemented by the executable.
|
// Parse accepts only commands that have been implemented by the executable.
|
||||||
@@ -51,6 +56,30 @@ func Parse(args []string) (Invocation, error) {
|
|||||||
return Invocation{}, errors.New("a command is required")
|
return Invocation{}, errors.New("a command is required")
|
||||||
}
|
}
|
||||||
switch args[0] {
|
switch args[0] {
|
||||||
|
case string(ReconcileAction), string(PublicationAttemptsAction):
|
||||||
|
action := Command(args[0])
|
||||||
|
flags := flag.NewFlagSet(string(action), flag.ContinueOnError)
|
||||||
|
flags.SetOutput(io.Discard)
|
||||||
|
record := ""
|
||||||
|
recordDir := ""
|
||||||
|
repository := "."
|
||||||
|
if action == ReconcileAction {
|
||||||
|
flags.StringVar(&record, "record", "", "attempt record file")
|
||||||
|
} else {
|
||||||
|
flags.StringVar(&repository, "repo", ".", "repository directory")
|
||||||
|
flags.StringVar(&recordDir, "record-dir", "", "attempt-record directory")
|
||||||
|
}
|
||||||
|
structured := flags.Bool("json", false, "write structured JSON")
|
||||||
|
if err := flags.Parse(args[1:]); err != nil {
|
||||||
|
return Invocation{}, err
|
||||||
|
}
|
||||||
|
if flags.NArg() != 0 {
|
||||||
|
return Invocation{}, fmt.Errorf("%s takes no positional arguments", action)
|
||||||
|
}
|
||||||
|
if action == ReconcileAction && record == "" {
|
||||||
|
return Invocation{}, errors.New("reconcile requires --record FILE")
|
||||||
|
}
|
||||||
|
return Invocation{Command: action, Repository: repository, RecordPath: record, RecordDir: recordDir, JSON: *structured}, nil
|
||||||
case string(VersionCommand):
|
case string(VersionCommand):
|
||||||
if len(args) != 1 {
|
if len(args) != 1 {
|
||||||
return Invocation{}, errors.New("version takes no arguments")
|
return Invocation{}, errors.New("version takes no arguments")
|
||||||
@@ -75,6 +104,10 @@ func Parse(args []string) (Invocation, error) {
|
|||||||
if action == PublishAction || action == CIDeliveryAction {
|
if action == PublishAction || action == CIDeliveryAction {
|
||||||
flags.StringVar(&expectedModule, "expected-module", "", "expected module directory from a launcher mapping")
|
flags.StringVar(&expectedModule, "expected-module", "", "expected module directory from a launcher mapping")
|
||||||
}
|
}
|
||||||
|
recordDir := ""
|
||||||
|
if action == PublishAction {
|
||||||
|
flags.StringVar(&recordDir, "record-dir", "", "publication attempt-record directory")
|
||||||
|
}
|
||||||
expectedCommit := ""
|
expectedCommit := ""
|
||||||
prepared := false
|
prepared := false
|
||||||
if action == CIDeliveryAction {
|
if action == CIDeliveryAction {
|
||||||
@@ -112,7 +145,7 @@ func Parse(args []string) (Invocation, error) {
|
|||||||
if remote != nil {
|
if remote != nil {
|
||||||
parsedRemote = *remote
|
parsedRemote = *remote
|
||||||
}
|
}
|
||||||
return Invocation{Command: action, Repository: *repository, ConfigPath: *configPath, OutputDir: outputDir, AssetsDir: assetsDir, Remote: parsedRemote, Commit: expectedCommit, Version: version, ExpectedModule: expectedModule, ExpectedTag: expectedTag, Prepared: prepared}, nil
|
return Invocation{Command: action, Repository: *repository, ConfigPath: *configPath, OutputDir: outputDir, AssetsDir: assetsDir, Remote: parsedRemote, Commit: expectedCommit, Version: version, ExpectedModule: expectedModule, ExpectedTag: expectedTag, Prepared: prepared, RecordDir: recordDir}, nil
|
||||||
default:
|
default:
|
||||||
return Invocation{}, fmt.Errorf("unknown command %q", args[0])
|
return Invocation{}, fmt.Errorf("unknown command %q", args[0])
|
||||||
}
|
}
|
||||||
@@ -154,6 +187,7 @@ type ValidationSettings struct {
|
|||||||
// SharedFiles selects committed files relative to the repository root.
|
// SharedFiles selects committed files relative to the repository root.
|
||||||
// Nil uses optional defaults; a non-nil empty slice selects no shared files.
|
// Nil uses optional defaults; a non-nil empty slice selects no shared files.
|
||||||
SharedFiles []string `json:"shared_files"`
|
SharedFiles []string `json:"shared_files"`
|
||||||
|
ForbiddenFiles []string `json:"forbidden_files,omitempty"`
|
||||||
Go GoValidationSettings `json:"go,omitempty"`
|
Go GoValidationSettings `json:"go,omitempty"`
|
||||||
// GoFormat checks the formatting of committed regular Go source files.
|
// GoFormat checks the formatting of committed regular Go source files.
|
||||||
// Omitted or false disables the check.
|
// Omitted or false disables the check.
|
||||||
@@ -195,12 +229,14 @@ type GoValidationSettings struct {
|
|||||||
CompileTargets []string `json:"compile_targets,omitempty"`
|
CompileTargets []string `json:"compile_targets,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeliverySettings selects the hosted Gitea release destination.
|
// DeliverySettings selects the hosted forge release destination.
|
||||||
type DeliverySettings struct {
|
type DeliverySettings struct {
|
||||||
|
Forge string `json:"forge,omitempty"`
|
||||||
BaseURL string `json:"base_url"`
|
BaseURL string `json:"base_url"`
|
||||||
Owner string `json:"owner"`
|
Owner string `json:"owner"`
|
||||||
Repository string `json:"repository"`
|
Repository string `json:"repository"`
|
||||||
TokenEnv string `json:"token_env,omitempty"`
|
TokenEnv string `json:"token_env,omitempty"`
|
||||||
|
TokenFile string `json:"token_file,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildSettings selects the Go application package, artifact name, and targets.
|
// BuildSettings selects the Go application package, artifact name, and targets.
|
||||||
|
|||||||
@@ -19,6 +19,33 @@ func TestParse(t *testing.T) {
|
|||||||
if invocation.Command != VersionCommand {
|
if invocation.Command != VersionCommand {
|
||||||
t.Fatalf("Parse() command = %q, want %q", invocation.Command, VersionCommand)
|
t.Fatalf("Parse() command = %q, want %q", invocation.Command, VersionCommand)
|
||||||
}
|
}
|
||||||
|
reconcile, err := Parse([]string{"reconcile", "--record", "records/release record's.json", "--json"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if reconcile.Command != ReconcileAction || reconcile.RecordPath != "records/release record's.json" || !reconcile.JSON {
|
||||||
|
t.Fatalf("Parse(reconcile) = %#v", reconcile)
|
||||||
|
}
|
||||||
|
attempts, err := Parse([]string{"publication-attempts", "--repo", "repository with spaces", "--record-dir", "records with spaces", "--json"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if attempts.Command != PublicationAttemptsAction || attempts.Repository != "repository with spaces" || attempts.RecordDir != "records with spaces" || !attempts.JSON {
|
||||||
|
t.Fatalf("Parse(publication-attempts) = %#v", attempts)
|
||||||
|
}
|
||||||
|
for _, args := range [][]string{
|
||||||
|
{"reconcile"},
|
||||||
|
{"reconcile", "record.json"},
|
||||||
|
{"reconcile", "--record", "record.json", "--repo", "."},
|
||||||
|
{"reconcile", "--record", "record.json", "--record-dir", "records"},
|
||||||
|
{"reconcile", "--record", "record.json", "VERSION"},
|
||||||
|
{"publication-attempts", "--record", "record.json"},
|
||||||
|
{"publication-attempts", "VERSION"},
|
||||||
|
} {
|
||||||
|
if _, err := Parse(args); err == nil {
|
||||||
|
t.Errorf("Parse(%q) succeeded, want a usage error", args)
|
||||||
|
}
|
||||||
|
}
|
||||||
check, err := Parse([]string{"check", "--repo", "repository with spaces", "--config", "profiles/release config.json", "v1.2.3"})
|
check, err := Parse([]string{"check", "--repo", "repository with spaces", "--config", "profiles/release config.json", "v1.2.3"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -26,13 +53,16 @@ func TestParse(t *testing.T) {
|
|||||||
if check.Command != CheckAction || check.Repository != "repository with spaces" || check.ConfigPath != "profiles/release config.json" || check.Version != "v1.2.3" {
|
if check.Command != CheckAction || check.Repository != "repository with spaces" || check.ConfigPath != "profiles/release config.json" || check.Version != "v1.2.3" {
|
||||||
t.Fatalf("Parse(check) = %#v", check)
|
t.Fatalf("Parse(check) = %#v", check)
|
||||||
}
|
}
|
||||||
publish, err := Parse([]string{"publish", "--repo", "candidate", "--config", "releasekit.json", "v1.2.4"})
|
publish, err := Parse([]string{"publish", "--repo", "candidate", "--config", "releasekit.json", "--record-dir", "attempt records", "v1.2.4"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if publish.Command != PublishAction || publish.Repository != "candidate" || publish.ConfigPath != "releasekit.json" || publish.Version != "v1.2.4" {
|
if publish.Command != PublishAction || publish.Repository != "candidate" || publish.ConfigPath != "releasekit.json" || publish.RecordDir != "attempt records" || publish.Version != "v1.2.4" {
|
||||||
t.Fatalf("Parse(publish) = %#v", publish)
|
t.Fatalf("Parse(publish) = %#v", publish)
|
||||||
}
|
}
|
||||||
|
if _, err := Parse([]string{"check", "--record-dir", "attempt records", "v1.2.4"}); err == nil {
|
||||||
|
t.Fatal("Parse(check) accepted the publication-only record directory")
|
||||||
|
}
|
||||||
publishMapped, err := Parse([]string{"publish", "--repo", "candidate", "--expected-module", "integrations/ckconfig", "v1.2.4"})
|
publishMapped, err := Parse([]string{"publish", "--repo", "candidate", "--expected-module", "integrations/ckconfig", "v1.2.4"})
|
||||||
if err != nil || publishMapped.ExpectedModule != "integrations/ckconfig" {
|
if err != nil || publishMapped.ExpectedModule != "integrations/ckconfig" {
|
||||||
t.Fatalf("Parse(publish --expected-module) = %#v, error = %v", publishMapped, err)
|
t.Fatalf("Parse(publish --expected-module) = %#v, error = %v", publishMapped, err)
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact"
|
"go.maximumdirect.net/releasekit/internal/artifact"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity"
|
"go.maximumdirect.net/releasekit/internal/releaseidentity"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
|
"go.maximumdirect.net/releasekit/internal/versioncontract"
|
||||||
)
|
)
|
||||||
|
|
||||||
const BuildReceiptName = ".releasekit-build.json"
|
const BuildReceiptName = ".releasekit-build.json"
|
||||||
@@ -49,6 +49,10 @@ type BuildReceipt struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func WriteBuildReceipt(outputDir string, receipt BuildReceipt) (string, error) {
|
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)
|
resolvedDir, err := filepath.Abs(outputDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("resolve build receipt directory: %w", err)
|
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)
|
return "", fmt.Errorf("create temporary build receipt: %w", err)
|
||||||
}
|
}
|
||||||
temporaryPath := temporary.Name()
|
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 {
|
if _, err := temporary.Write(contents); err != nil {
|
||||||
_ = temporary.Close()
|
_ = temporary.Close()
|
||||||
return "", fmt.Errorf("write build receipt: %w", err)
|
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) {
|
if len(receipt.Targets) == 0 || len(receipt.Artifacts) != len(receipt.Targets) {
|
||||||
return errors.New("build receipt target and artifact counts do not match")
|
return errors.New("build receipt target and artifact counts do not match")
|
||||||
}
|
}
|
||||||
seenTargets := make(map[ReceiptTarget]bool, len(receipt.Targets))
|
declarations := make([]artifact.Declaration, 0, len(receipt.Artifacts))
|
||||||
seenArtifacts := make(map[ReceiptTarget]bool, len(receipt.Artifacts))
|
for _, item := range receipt.Artifacts {
|
||||||
targets := make([]artifact.Target, 0, len(receipt.Targets))
|
declarations = append(declarations, artifact.Declaration{Target: artifact.Target{OS: item.Target.OS, Arch: item.Target.Arch}, Filename: item.Filename})
|
||||||
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] {
|
if err := artifact.ValidateDeclarations(receipt.BinaryName, receipt.Version, parseStoredTargets(receipt.Targets), declarations); err != nil {
|
||||||
return fmt.Errorf("build receipt repeats target %s/%s", target.OS, target.Arch)
|
return fmt.Errorf("build receipt: %w", err)
|
||||||
}
|
|
||||||
seenTargets[target] = true
|
|
||||||
targets = append(targets, artifact.Target{OS: target.OS, Arch: target.Arch})
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
for _, artifact := range receipt.Artifacts {
|
for _, artifact := range receipt.Artifacts {
|
||||||
if !seenTargets[artifact.Target] || seenArtifacts[artifact.Target] {
|
if filepath.Base(artifact.Filename) != artifact.Filename {
|
||||||
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 {
|
|
||||||
return fmt.Errorf("build receipt has invalid artifact filename %q", artifact.Filename)
|
return fmt.Errorf("build receipt has invalid artifact filename %q", artifact.Filename)
|
||||||
}
|
}
|
||||||
wantPath := filepath.Join(receipt.OutputDir, artifact.Filename)
|
wantPath := filepath.Join(receipt.OutputDir, artifact.Filename)
|
||||||
|
|||||||
@@ -4,12 +4,68 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"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) {
|
func TestBuildReceiptRoundTripBindsRootAndNestedModuleIdentity(t *testing.T) {
|
||||||
for _, test := range []struct {
|
for _, test := range []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli"
|
"go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMainModuleWithoutDomainSupportsCheckBuildAndPageDelivery(t *testing.T) {
|
func TestMainModuleWithoutDomainSupportsCheckBuildAndPageDelivery(t *testing.T) {
|
||||||
@@ -20,7 +20,7 @@ func TestMainModuleWithoutDomainSupportsCheckBuildAndPageDelivery(t *testing.T)
|
|||||||
defer server.Close()
|
defer server.Close()
|
||||||
settings := cli.Settings{Product: "Example Product",
|
settings := cli.Settings{Product: "Example Product",
|
||||||
Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}},
|
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)
|
writeDeliveryProfile(t, repository, settings)
|
||||||
for _, args := range [][]string{
|
for _, args := range [][]string{
|
||||||
|
|||||||
@@ -10,13 +10,15 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit"
|
"go.maximumdirect.net/releasekit"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo"
|
"go.maximumdirect.net/releasekit/buildinfo"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli"
|
"go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact"
|
"go.maximumdirect.net/releasekit/internal/artifact"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
|
"go.maximumdirect.net/releasekit/internal/git"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity"
|
"go.maximumdirect.net/releasekit/internal/gotool"
|
||||||
|
"go.maximumdirect.net/releasekit/internal/releaseidentity"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -32,6 +34,9 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
|||||||
fmt.Fprintf(stderr, "%v\n%s\n", err, cli.Usage)
|
fmt.Fprintf(stderr, "%v\n%s\n", err, cli.Usage)
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
|
if invocation.Command == cli.ReconcileAction || invocation.Command == cli.PublicationAttemptsAction {
|
||||||
|
return runRecovery(ctx, invocation, stdout, stderr)
|
||||||
|
}
|
||||||
if invocation.Command != cli.VersionCommand {
|
if invocation.Command != cli.VersionCommand {
|
||||||
invocation.Repository, err = git.RepositoryRoot(ctx, invocation.Repository)
|
invocation.Repository, err = git.RepositoryRoot(ctx, invocation.Repository)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -45,6 +50,18 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
|||||||
fmt.Fprintf(stdout, "releasekit %s\n", buildinfo.Version())
|
fmt.Fprintf(stdout, "releasekit %s\n", buildinfo.Version())
|
||||||
return 0
|
return 0
|
||||||
case cli.CheckAction, cli.PublishAction:
|
case cli.CheckAction, cli.PublishAction:
|
||||||
|
return runCheckOrPublish(ctx, invocation, stdout, stderr)
|
||||||
|
case cli.BuildAction:
|
||||||
|
return runBuild(ctx, invocation, stdout, stderr)
|
||||||
|
case cli.DeliveryAction:
|
||||||
|
return runDelivery(ctx, invocation, stdout, stderr)
|
||||||
|
case cli.CIDeliveryAction:
|
||||||
|
return runCIDelivery(ctx, invocation, stdout, stderr)
|
||||||
|
}
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCheckOrPublish(ctx context.Context, invocation cli.Invocation, stdout, stderr io.Writer) int {
|
||||||
settings, err := cli.LoadSettings(invocation.Repository, invocation.ConfigPath)
|
settings, err := cli.LoadSettings(invocation.Repository, invocation.ConfigPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "load profile: %v\n", err)
|
fmt.Fprintf(stderr, "load profile: %v\n", err)
|
||||||
@@ -57,10 +74,12 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
|||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
request.ExpectedPushURL = settings.ExpectedPushURL
|
request.ExpectedPushURL = settings.ExpectedPushURL
|
||||||
|
request.PublicationRecordDir = invocation.RecordDir
|
||||||
fmt.Fprintf(stderr, "validating and publishing module %s version %s\n", selectedModuleLabel(settings.ModuleDir), invocation.Version)
|
fmt.Fprintf(stderr, "validating and publishing module %s version %s\n", selectedModuleLabel(settings.ModuleDir), invocation.Version)
|
||||||
result, err := releasekit.Publish(ctx, request)
|
result, err := releasekit.Publish(ctx, request)
|
||||||
|
reportPublicationRecord(ctx, result, err, stderr)
|
||||||
if err != nil {
|
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))
|
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) {
|
if errors.Is(err, releasekit.ErrInvalidVersion) {
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
@@ -82,7 +101,96 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
|||||||
}
|
}
|
||||||
fmt.Fprintf(stdout, "local candidate module %s version %s tag %s at %s is valid\n", candidate.ModulePath, candidate.Version, candidate.Tag, candidate.Commit)
|
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 0
|
||||||
case cli.BuildAction:
|
}
|
||||||
|
|
||||||
|
func reportPublicationRecord(ctx context.Context, result releasekit.PublicationResult, publicationErr error, stderr io.Writer) {
|
||||||
|
if result.RecordError != nil {
|
||||||
|
fmt.Fprintf(stderr, "publication record warning: %s\n", git.DisplayError(result.RecordError))
|
||||||
|
}
|
||||||
|
if result.RecordPath == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprintf(stderr, "publication record saved: %s\n", git.DisplayDiagnostic(result.RecordPath))
|
||||||
|
if publicationErr == nil || !publicationOffersRecovery(result.Phase) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Handoff preparation follows the failed publication and has its own bounded
|
||||||
|
// lifetime, including when publication's context was canceled.
|
||||||
|
handoffContext, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
command, err := selectedPublicationRecoveryCommand(handoffContext, result)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(stderr, "recovery tool preparation warning: %s\n", git.DisplayError(err))
|
||||||
|
command, err = publicationRecoveryCommand(result.RecordPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(stderr, "recovery command unavailable: %s\n", git.DisplayError(err))
|
||||||
|
fmt.Fprintln(stderr, "Use your selected supported ReleaseKit CLI with this record if the original executable is unavailable.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Fprintf(stderr, "recovery command: %s\n", git.DisplayDiagnostic(command))
|
||||||
|
fmt.Fprintln(stderr, "If this entry point is unavailable, use your selected supported ReleaseKit CLI with the same record.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectedPublicationRecoveryCommand(ctx context.Context, result releasekit.PublicationResult) (string, error) {
|
||||||
|
if result.Evidence.ModuleDir != "" {
|
||||||
|
common, err := git.CommonDirectory(ctx, result.RepositoryDir)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
args, err := gotool.RecoveryTool(ctx, result.Evidence.ModuleDir, filepath.Join(common, "releasekit", "publication-recovery"))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if len(args) > 0 {
|
||||||
|
command := "env GOWORK=off GOTOOLCHAIN=local go"
|
||||||
|
for _, arg := range args {
|
||||||
|
command += " " + shellQuote(git.DisplayDiagnostic(arg))
|
||||||
|
}
|
||||||
|
path, err := filepath.Abs(git.DisplayDiagnostic(result.RecordPath))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return command + " reconcile --record " + shellQuote(path), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return publicationRecoveryCommand(result.RecordPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func publicationOffersRecovery(phase releasekit.PublicationPhase) bool {
|
||||||
|
switch phase {
|
||||||
|
case releasekit.PublicationTagCreation, releasekit.PublicationPush, releasekit.PublicationReconciliation:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func publicationRecoveryCommand(recordPath string) (string, error) {
|
||||||
|
executable, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("find current executable: %w", err)
|
||||||
|
}
|
||||||
|
executable, err = filepath.Abs(executable)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("resolve current executable path: %w", err)
|
||||||
|
}
|
||||||
|
if gotool.TemporaryExecutable(executable) {
|
||||||
|
return "", fmt.Errorf("current executable belongs to a temporary Go build; select a supported ReleaseKit CLI for the saved record")
|
||||||
|
}
|
||||||
|
// Redact before path cleaning can change a pasted URL's recognizable syntax.
|
||||||
|
recordPath, err = filepath.Abs(git.DisplayDiagnostic(recordPath))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("resolve publication record path: %w", err)
|
||||||
|
}
|
||||||
|
return shellQuote(executable) + " reconcile --record " + shellQuote(recordPath), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func shellQuote(argument string) string {
|
||||||
|
return "'" + strings.ReplaceAll(argument, "'", "'\"'\"'") + "'"
|
||||||
|
}
|
||||||
|
|
||||||
|
func runBuild(ctx context.Context, invocation cli.Invocation, stdout, stderr io.Writer) int {
|
||||||
settings, err := cli.LoadSettings(invocation.Repository, invocation.ConfigPath)
|
settings, err := cli.LoadSettings(invocation.Repository, invocation.ConfigPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "load profile: %v\n", err)
|
fmt.Fprintf(stderr, "load profile: %v\n", err)
|
||||||
@@ -141,12 +249,6 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
|||||||
}
|
}
|
||||||
fmt.Fprintf(stdout, "build receipt: %s\n", receiptPath)
|
fmt.Fprintf(stdout, "build receipt: %s\n", receiptPath)
|
||||||
return 0
|
return 0
|
||||||
case cli.DeliveryAction:
|
|
||||||
return runDelivery(ctx, invocation, stdout, stderr)
|
|
||||||
case cli.CIDeliveryAction:
|
|
||||||
return runCIDelivery(ctx, invocation, stdout, stderr)
|
|
||||||
}
|
|
||||||
return 2
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func candidateRequest(invocation cli.Invocation, settings cli.Settings, output io.Writer) releasekit.ReleaseRequest {
|
func candidateRequest(invocation cli.Invocation, settings cli.Settings, output io.Writer) releasekit.ReleaseRequest {
|
||||||
@@ -182,6 +284,7 @@ func validationSpec(settings *cli.ValidationSettings) releasekit.ValidationSpec
|
|||||||
}
|
}
|
||||||
result := releasekit.ValidationSpec{
|
result := releasekit.ValidationSpec{
|
||||||
SharedFiles: copyStringsPreservingEmpty(settings.SharedFiles),
|
SharedFiles: copyStringsPreservingEmpty(settings.SharedFiles),
|
||||||
|
ForbiddenFiles: copyStringsPreservingEmpty(settings.ForbiddenFiles),
|
||||||
GoFormat: settings.GoFormat,
|
GoFormat: settings.GoFormat,
|
||||||
ModuleDistribution: releasekit.ModuleDistributionValidationSpec{
|
ModuleDistribution: releasekit.ModuleDistributionValidationSpec{
|
||||||
WorkspaceFiles: settings.ModuleDistribution.WorkspaceFiles,
|
WorkspaceFiles: settings.ModuleDistribution.WorkspaceFiles,
|
||||||
@@ -290,11 +393,24 @@ func runCIDelivery(ctx context.Context, invocation cli.Invocation, stdout, stder
|
|||||||
fmt.Fprintf(stderr, "profile module selection: %v\n", err)
|
fmt.Fprintf(stderr, "profile module selection: %v\n", err)
|
||||||
return 2
|
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 {
|
if status != 0 {
|
||||||
return status
|
return status
|
||||||
}
|
}
|
||||||
if settings.Build != nil && len(settings.Build.Targets) != 0 {
|
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{
|
if err := releasekit.PrepareBuildDependencies(ctx, releasekit.DependencyPreparationRequest{
|
||||||
RepositoryDir: invocation.Repository,
|
RepositoryDir: invocation.Repository,
|
||||||
ModuleDir: settings.ModuleDir,
|
ModuleDir: settings.ModuleDir,
|
||||||
@@ -309,32 +425,61 @@ func runCIDelivery(ctx context.Context, invocation cli.Invocation, stdout, stder
|
|||||||
// deliverWithSettings adapts prepared CLI state to the public delivery facade.
|
// deliverWithSettings adapts prepared CLI state to the public delivery facade.
|
||||||
// CI entry points can reuse it after preparing the checkout and loading its profile.
|
// 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 {
|
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 {
|
if status != 0 {
|
||||||
return status
|
return status
|
||||||
}
|
}
|
||||||
return deliverWithDestination(ctx, invocation, settings, destination, stdout, stderr)
|
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
|
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")
|
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
|
tokenEnv := delivery.TokenEnv
|
||||||
if strings.TrimSpace(tokenEnv) == "" {
|
if strings.TrimSpace(tokenEnv) == "" {
|
||||||
tokenEnv = "RELEASEKIT_GITEA_TOKEN"
|
tokenEnv = "RELEASEKIT_GITEA_TOKEN"
|
||||||
}
|
}
|
||||||
token, ok := os.LookupEnv(tokenEnv)
|
token, ok := os.LookupEnv(tokenEnv)
|
||||||
if !ok || strings.TrimSpace(token) == "" {
|
if (!ok || strings.TrimSpace(token) == "") && delivery.TokenFile != "" {
|
||||||
fmt.Fprintf(stderr, "deliver requires a non-empty token in environment variable %s\n", tokenEnv)
|
name := delivery.TokenFile
|
||||||
return releasekit.GiteaDestination{}, 2
|
if !filepath.IsAbs(name) {
|
||||||
|
name = filepath.Join(repositoryDir, name)
|
||||||
}
|
}
|
||||||
return releasekit.GiteaDestination{BaseURL: delivery.BaseURL, Owner: delivery.Owner, Repository: delivery.Repository, Token: token}, 0
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
hasTargets := settings.Build != nil && len(settings.Build.Targets) != 0
|
||||||
if invocation.AssetsDir != "" && !hasTargets {
|
if invocation.AssetsDir != "" && !hasTargets {
|
||||||
fmt.Fprintln(stderr, "--assets requires build targets in the profile")
|
fmt.Fprintln(stderr, "--assets requires build targets in the profile")
|
||||||
@@ -383,16 +528,7 @@ func deliverWithDestination(ctx context.Context, invocation cli.Invocation, sett
|
|||||||
Output: stderr,
|
Output: stderr,
|
||||||
})
|
})
|
||||||
if err != nil {
|
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)
|
return reportDeliveryFailure(stderr, result, err)
|
||||||
writeDeliveryAssets(stderr, result)
|
|
||||||
writeDeliveryRecovery(stderr, result)
|
|
||||||
if errors.Is(err, releasekit.ErrInvalidVersion) {
|
|
||||||
return 2
|
|
||||||
}
|
|
||||||
if result.State == releasekit.DeliveryUnknown {
|
|
||||||
return 3
|
|
||||||
}
|
|
||||||
return 1
|
|
||||||
}
|
}
|
||||||
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)
|
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 != "" {
|
if result.ReleaseURL != "" {
|
||||||
@@ -402,6 +538,46 @@ func deliverWithDestination(ctx context.Context, invocation cli.Invocation, sett
|
|||||||
return 0
|
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) {
|
func writeDeliveryAssets(output io.Writer, result releasekit.DeliveryResult) {
|
||||||
for _, group := range []struct {
|
for _, group := range []struct {
|
||||||
label string
|
label string
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -18,9 +19,9 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
releasekit "gitea.maximumdirect.net/eric/ReleaseKit"
|
releasekit "go.maximumdirect.net/releasekit"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo"
|
"go.maximumdirect.net/releasekit/buildinfo"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli"
|
"go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestVersionCommand(t *testing.T) {
|
func TestVersionCommand(t *testing.T) {
|
||||||
@@ -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) {
|
func TestUnknownCommand(t *testing.T) {
|
||||||
var stdout, stderr bytes.Buffer
|
var stdout, stderr bytes.Buffer
|
||||||
if status := run(context.Background(), []string{"unknown"}, &stdout, &stderr); status != 2 {
|
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) {
|
func TestPublishCommandPublishesConfiguredCandidate(t *testing.T) {
|
||||||
raceSelected := racePrerequisitesAvailable()
|
raceSelected := racePrerequisitesAvailable()
|
||||||
repository := newCLIRepository(t)
|
repository := newCLIRepository(t)
|
||||||
@@ -407,6 +513,26 @@ printf '%s %s\n' "$version" "$candidate" >> "$RELEASEKIT_COMPATIBILITY_LOG"
|
|||||||
if !bytes.Contains(stderr.Bytes(), []byte("validating and publishing module . version v1.2.3")) || !bytes.Contains(stderr.Bytes(), []byte("example.com/cli-candidate")) {
|
if !bytes.Contains(stderr.Bytes(), []byte("validating and publishing module . version v1.2.3")) || !bytes.Contains(stderr.Bytes(), []byte("example.com/cli-candidate")) {
|
||||||
t.Fatalf("stderr does not show publication progress and validation: %q", stderr.String())
|
t.Fatalf("stderr does not show publication progress and validation: %q", stderr.String())
|
||||||
}
|
}
|
||||||
|
const recordPrefix = "publication record saved: "
|
||||||
|
var recordPath string
|
||||||
|
for _, line := range strings.Split(stderr.String(), "\n") {
|
||||||
|
if strings.HasPrefix(line, recordPrefix) {
|
||||||
|
recordPath = strings.TrimPrefix(line, recordPrefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(recordPath) {
|
||||||
|
t.Fatalf("default publication record path = %q, stderr = %q", recordPath, stderr.String())
|
||||||
|
}
|
||||||
|
recorded, err := releasekit.LoadPublicationEvidence(recordPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load default publication record: %v", err)
|
||||||
|
}
|
||||||
|
if recorded.Tag != "v1.2.3" || recorded.CandidateCommit != commit || recorded.TagObject != tagObject || recorded.Endpoint != remote {
|
||||||
|
t.Fatalf("default publication evidence = %#v", recorded)
|
||||||
|
}
|
||||||
|
if strings.Contains(stderr.String(), "recovery command:") {
|
||||||
|
t.Fatalf("successful publication prescribed a recovery command: %q", stderr.String())
|
||||||
|
}
|
||||||
if got := cliGitOutput(t, remote, "rev-parse", "refs/tags/v1.2.3"); got != tagObject {
|
if got := cliGitOutput(t, remote, "rev-parse", "refs/tags/v1.2.3"); got != tagObject {
|
||||||
t.Fatalf("remote tag object = %s, want %s", got, tagObject)
|
t.Fatalf("remote tag object = %s, want %s", got, tagObject)
|
||||||
}
|
}
|
||||||
@@ -472,7 +598,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo"
|
"go.maximumdirect.net/releasekit/buildinfo"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -506,6 +632,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) {
|
func TestBuildCommandLeavesNoReceiptAfterSmokeFailure(t *testing.T) {
|
||||||
repository := newCLIBuildRepository(t, `package main
|
repository := newCLIBuildRepository(t, `package main
|
||||||
import (
|
import (
|
||||||
@@ -639,7 +824,7 @@ func TestDeliverPageOnlyCreatesAndReusesRelease(t *testing.T) {
|
|||||||
settings := cli.Settings{
|
settings := cli.Settings{
|
||||||
Product: "Example Product",
|
Product: "Example Product",
|
||||||
Validation: &cli.ValidationSettings{Go: cli.GoValidationSettings{CompileTargets: []string{"linux/amd64"}}},
|
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)
|
writeDeliveryProfile(t, repository, settings)
|
||||||
state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
|
state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
|
||||||
@@ -673,7 +858,7 @@ func TestDeliverBuildsTaggedArtifactsAndReusesAssets(t *testing.T) {
|
|||||||
settings := cli.Settings{
|
settings := cli.Settings{
|
||||||
Product: "Example Product",
|
Product: "Example Product",
|
||||||
Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}},
|
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)
|
writeDeliveryProfile(t, repository, settings)
|
||||||
state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
|
state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
|
||||||
@@ -716,7 +901,7 @@ func TestCIDeliverLoadsTaggedProfileAndSkipsUnavailablePageOnlyDependencies(t *t
|
|||||||
defer server.Close()
|
defer server.Close()
|
||||||
writeDeliveryProfileAt(t, repository, "profiles/ci-delivery.json", cli.Settings{
|
writeDeliveryProfileAt(t, repository, "profiles/ci-delivery.json", cli.Settings{
|
||||||
Product: "Example Product",
|
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)
|
expectedCommit := pushTaggedCLIRepository(t, repository)
|
||||||
state.update(func(state *deliveryServerData) { state.commit = expectedCommit })
|
state.update(func(state *deliveryServerData) { state.commit = expectedCommit })
|
||||||
@@ -751,7 +936,7 @@ func TestCIDeliverRejectsWrongCommitBeforeGoOrHostedRequests(t *testing.T) {
|
|||||||
writeDeliveryProfile(t, repository, cli.Settings{
|
writeDeliveryProfile(t, repository, cli.Settings{
|
||||||
Product: "Example Product",
|
Product: "Example Product",
|
||||||
Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}},
|
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)
|
expectedCommit := pushTaggedCLIRepository(t, repository)
|
||||||
state.update(func(state *deliveryServerData) { state.commit = expectedCommit })
|
state.update(func(state *deliveryServerData) { state.commit = expectedCommit })
|
||||||
@@ -802,7 +987,7 @@ func TestCIDeliverRejectsExpectedModuleMismatchBeforeDependencyPreparation(t *te
|
|||||||
Product: "Example Product",
|
Product: "Example Product",
|
||||||
ModuleDir: "integrations/other",
|
ModuleDir: "integrations/other",
|
||||||
Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}},
|
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)
|
expectedCommit := pushTaggedCLIRepository(t, repository)
|
||||||
state.update(func(state *deliveryServerData) { state.commit = expectedCommit })
|
state.update(func(state *deliveryServerData) { state.commit = expectedCommit })
|
||||||
@@ -839,7 +1024,7 @@ func TestCIDeliverPreparesDependenciesAndBuildsConfiguredTargets(t *testing.T) {
|
|||||||
writeDeliveryProfile(t, repository, cli.Settings{
|
writeDeliveryProfile(t, repository, cli.Settings{
|
||||||
Product: "Example Product",
|
Product: "Example Product",
|
||||||
Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH)}},
|
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)
|
expectedCommit := pushTaggedCLIRepository(t, repository)
|
||||||
state.update(func(state *deliveryServerData) { state.commit = expectedCommit })
|
state.update(func(state *deliveryServerData) { state.commit = expectedCommit })
|
||||||
@@ -865,7 +1050,7 @@ func TestCIDeliverValidatesCredentialsBeforePreparingDependencies(t *testing.T)
|
|||||||
writeDeliveryProfile(t, repository, cli.Settings{
|
writeDeliveryProfile(t, repository, cli.Settings{
|
||||||
Product: "Example Product",
|
Product: "Example Product",
|
||||||
Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}},
|
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)
|
expectedCommit := pushTaggedCLIRepository(t, repository)
|
||||||
state.update(func(state *deliveryServerData) { state.commit = expectedCommit })
|
state.update(func(state *deliveryServerData) { state.commit = expectedCommit })
|
||||||
@@ -903,7 +1088,7 @@ func TestDeliverRejectsInvalidTaggedSourceBeforeManagedBuild(t *testing.T) {
|
|||||||
settings := cli.Settings{
|
settings := cli.Settings{
|
||||||
Product: "Example Product",
|
Product: "Example Product",
|
||||||
Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}},
|
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)
|
writeDeliveryProfile(t, repository, settings)
|
||||||
state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
|
state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
|
||||||
@@ -919,6 +1104,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) {
|
func TestDeliverFailureReportsRecoveryForPartialConflictAndUnknownStates(t *testing.T) {
|
||||||
for _, test := range []struct {
|
for _, test := range []struct {
|
||||||
name string
|
name string
|
||||||
@@ -958,7 +1152,7 @@ func TestDeliverFailureReportsRecoveryForPartialConflictAndUnknownStates(t *test
|
|||||||
writeDeliveryProfile(t, repository, cli.Settings{
|
writeDeliveryProfile(t, repository, cli.Settings{
|
||||||
Product: "Example Product",
|
Product: "Example Product",
|
||||||
Build: &build,
|
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") })
|
state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
|
||||||
cliGitRun(t, repository, "tag", "v1.2.3")
|
cliGitRun(t, repository, "tag", "v1.2.3")
|
||||||
@@ -1002,7 +1196,7 @@ func TestDeliverRejectsWrongHostedTagBeforeReleaseWrite(t *testing.T) {
|
|||||||
state := &deliveryServerState{data: deliveryServerData{remoteCommit: strings.Repeat("0", 40)}}
|
state := &deliveryServerState{data: deliveryServerData{remoteCommit: strings.Repeat("0", 40)}}
|
||||||
server := newDeliveryServer(t, state)
|
server := newDeliveryServer(t, state)
|
||||||
defer server.Close()
|
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") })
|
state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
|
||||||
cliGitRun(t, repository, "tag", "v1.2.3")
|
cliGitRun(t, repository, "tag", "v1.2.3")
|
||||||
t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token")
|
t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token")
|
||||||
@@ -1018,7 +1212,7 @@ func TestDeliverRequiresCredentialBeforeRemoteRequests(t *testing.T) {
|
|||||||
state := &deliveryServerState{}
|
state := &deliveryServerState{}
|
||||||
server := newDeliveryServer(t, state)
|
server := newDeliveryServer(t, state)
|
||||||
defer server.Close()
|
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") })
|
state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
|
||||||
cliGitRun(t, repository, "tag", "v1.2.3")
|
cliGitRun(t, repository, "tag", "v1.2.3")
|
||||||
t.Setenv("RELEASEKIT_TEST_DELIVERY_TOKEN", "")
|
t.Setenv("RELEASEKIT_TEST_DELIVERY_TOKEN", "")
|
||||||
@@ -1029,12 +1223,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) {
|
func TestDeliverRejectsCheckoutThatDiffersFromLocalTag(t *testing.T) {
|
||||||
repository := newCLIRepository(t)
|
repository := newCLIRepository(t)
|
||||||
state := &deliveryServerState{}
|
state := &deliveryServerState{}
|
||||||
server := newDeliveryServer(t, state)
|
server := newDeliveryServer(t, state)
|
||||||
defer server.Close()
|
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") })
|
state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
|
||||||
cliGitRun(t, repository, "tag", "v1.2.3")
|
cliGitRun(t, repository, "tag", "v1.2.3")
|
||||||
if err := os.WriteFile(filepath.Join(repository, "later.txt"), []byte("later commit"), 0o600); err != nil {
|
if err := os.WriteFile(filepath.Join(repository, "later.txt"), []byte("later commit"), 0o600); err != nil {
|
||||||
@@ -1061,7 +1476,7 @@ func TestDeliverRejectsTamperedAssetsAndStaleReceiptBeforeRemoteWrites(t *testin
|
|||||||
server := newDeliveryServer(t, state)
|
server := newDeliveryServer(t, state)
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
current := cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}}
|
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)
|
writeDeliveryProfile(t, repository, settings)
|
||||||
state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
|
state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
|
||||||
cliGitRun(t, repository, "tag", "v1.2.3")
|
cliGitRun(t, repository, "tag", "v1.2.3")
|
||||||
@@ -1097,7 +1512,7 @@ func TestDeliverRejectsTamperedAssetsAndStaleReceiptBeforeRemoteWrites(t *testin
|
|||||||
}
|
}
|
||||||
return
|
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())
|
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" {
|
if mode == "tampered" || mode == "missing artifact" {
|
||||||
@@ -1174,6 +1589,7 @@ type deliveryServerData struct {
|
|||||||
product string
|
product string
|
||||||
remoteCommit string
|
remoteCommit string
|
||||||
apiRequests int
|
apiRequests int
|
||||||
|
writes int
|
||||||
pageExists bool
|
pageExists bool
|
||||||
releaseCreates int
|
releaseCreates int
|
||||||
assetUploads int
|
assetUploads int
|
||||||
@@ -1196,11 +1612,19 @@ func newDeliveryServer(t *testing.T, state *deliveryServerState) *httptest.Serve
|
|||||||
defer state.mu.Unlock()
|
defer state.mu.Unlock()
|
||||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||||
data.apiRequests++
|
data.apiRequests++
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
data.writes++
|
||||||
|
}
|
||||||
if r.Header.Get("Authorization") != "token fixture-token" {
|
if r.Header.Get("Authorization") != "token fixture-token" {
|
||||||
t.Errorf("authorization = %q", r.Header.Get("Authorization"))
|
t.Errorf("authorization = %q", r.Header.Get("Authorization"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
switch r.URL.Path {
|
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:
|
case "/api/v1/repos/owner/example/tags/" + data.tag:
|
||||||
if data.commit == "" {
|
if data.commit == "" {
|
||||||
t.Error("tag source commit was not initialized")
|
t.Error("tag source commit was not initialized")
|
||||||
@@ -1375,7 +1799,7 @@ func newCLIBuildRepository(t *testing.T, mainSource string) string {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
module := fmt.Sprintf("module example.com/cli-candidate\n\ngo 1.26.0\n\nrequire gitea.maximumdirect.net/eric/ReleaseKit v0.0.0\n\nreplace gitea.maximumdirect.net/eric/ReleaseKit => %s\n", filepath.ToSlash(root))
|
module := fmt.Sprintf("module example.com/cli-candidate\n\ngo 1.26.0\n\nrequire go.maximumdirect.net/releasekit v0.0.0\n\nreplace go.maximumdirect.net/releasekit => %s\n", filepath.ToSlash(root))
|
||||||
write := func(name, contents string) {
|
write := func(name, contents string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
path := filepath.Join(repository, filepath.FromSlash(name))
|
path := filepath.Join(repository, filepath.FromSlash(name))
|
||||||
@@ -1445,7 +1869,7 @@ func TestDeliverRejectsReceiptBuiltFromDirtySourceAfterCleanup(t *testing.T) {
|
|||||||
state := &deliveryServerState{}
|
state := &deliveryServerState{}
|
||||||
server := newDeliveryServer(t, state)
|
server := newDeliveryServer(t, state)
|
||||||
defer server.Close()
|
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)
|
writeDeliveryProfile(t, repository, settings)
|
||||||
state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
|
state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
|
||||||
cliGitRun(t, repository, "tag", "v1.2.3")
|
cliGitRun(t, repository, "tag", "v1.2.3")
|
||||||
@@ -1485,7 +1909,7 @@ func TestRecoveredPageCreatePreservesPartialDeliveryOnInspectionFailure(t *testi
|
|||||||
server := newDeliveryServer(t, state)
|
server := newDeliveryServer(t, state)
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
build := cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}}
|
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") })
|
state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
|
||||||
cliGitRun(t, repository, "tag", "v1.2.3")
|
cliGitRun(t, repository, "tag", "v1.2.3")
|
||||||
assets, _ := writeDeliveryReceipt(t, repository, build)
|
assets, _ := writeDeliveryReceipt(t, repository, build)
|
||||||
@@ -1505,7 +1929,7 @@ func TestUnreadableAssetsDoNotClaimAnUnknownWrite(t *testing.T) {
|
|||||||
server := newDeliveryServer(t, state)
|
server := newDeliveryServer(t, state)
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
build := cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}}
|
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") })
|
state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
|
||||||
cliGitRun(t, repository, "tag", "v1.2.3")
|
cliGitRun(t, repository, "tag", "v1.2.3")
|
||||||
assets, _ := writeDeliveryReceipt(t, repository, build)
|
assets, _ := writeDeliveryReceipt(t, repository, build)
|
||||||
@@ -1530,7 +1954,7 @@ func TestUnreadableAssetsDoNotClaimAnUnknownWrite(t *testing.T) {
|
|||||||
|
|
||||||
func TestFailedRebuildInvalidatesPreviousReceipt(t *testing.T) {
|
func TestFailedRebuildInvalidatesPreviousReceipt(t *testing.T) {
|
||||||
const source = `package main
|
const source = `package main
|
||||||
import("fmt";"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo")
|
import("fmt";"go.maximumdirect.net/releasekit/buildinfo")
|
||||||
func main(){fmt.Println("fixture",buildinfo.Version())}
|
func main(){fmt.Println("fixture",buildinfo.Version())}
|
||||||
`
|
`
|
||||||
repository := newCLIBuildRepository(t, source)
|
repository := newCLIBuildRepository(t, source)
|
||||||
@@ -1561,7 +1985,7 @@ func TestTemporaryDeliveryBuildDoesNotRequireWritableRepositoryParent(t *testing
|
|||||||
state := &deliveryServerState{}
|
state := &deliveryServerState{}
|
||||||
server := newDeliveryServer(t, state)
|
server := newDeliveryServer(t, state)
|
||||||
defer server.Close()
|
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") })
|
state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
|
||||||
cliGitRun(t, repository, "tag", "v1.2.3")
|
cliGitRun(t, repository, "tag", "v1.2.3")
|
||||||
parent := t.TempDir()
|
parent := t.TempDir()
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -59,11 +61,11 @@ func TestMultiModuleLauncherUsesSelectedModuleAndProfile(t *testing.T) {
|
|||||||
repository := newCLIRepository(t)
|
repository := newCLIRepository(t)
|
||||||
copyExampleTree(t, example, repository)
|
copyExampleTree(t, example, repository)
|
||||||
|
|
||||||
rootModule := "module example.com/multi-module-consumer\n\ngo 1.25.0\n\nrequire gitea.maximumdirect.net/eric/ReleaseKit v0.2.197\n"
|
rootModule := "module example.com/multi-module-consumer\n\ngo 1.25.0\n\nrequire go.maximumdirect.net/releasekit v0.2.197\n"
|
||||||
selectedModule := "module example.com/multi-module-consumer/integrations/ckconfig\n\ngo 1.25.0\n\nrequire gitea.maximumdirect.net/eric/ReleaseKit v0.2.198\n"
|
selectedModule := "module example.com/multi-module-consumer/integrations/ckconfig\n\ngo 1.25.0\n\nrequire go.maximumdirect.net/releasekit v0.2.198\n"
|
||||||
writeRepositoryFile(t, repository, "go.mod", rootModule)
|
writeRepositoryFile(t, repository, "go.mod", rootModule)
|
||||||
writeRepositoryFile(t, repository, "integrations/ckconfig/go.mod", selectedModule)
|
writeRepositoryFile(t, repository, "integrations/ckconfig/go.mod", selectedModule)
|
||||||
spaceModule := "module example.com/multi-module-consumer/integrations/ckconfig\n\ngo 1.25.0\n\nrequire gitea.maximumdirect.net/eric/ReleaseKit v0.2.299\n"
|
spaceModule := "module example.com/multi-module-consumer/integrations/ckconfig\n\ngo 1.25.0\n\nrequire go.maximumdirect.net/releasekit v0.2.299\n"
|
||||||
writeRepositoryFile(t, repository, "integrations/ck config/go.mod", spaceModule)
|
writeRepositoryFile(t, repository, "integrations/ck config/go.mod", spaceModule)
|
||||||
writeRepositoryFile(t, repository, "profiles/core release.json", `{"product":"Example Consumer Integration","module_dir":"integrations/ck config"}`)
|
writeRepositoryFile(t, repository, "profiles/core release.json", `{"product":"Example Consumer Integration","module_dir":"integrations/ck config"}`)
|
||||||
cliGitRun(t, repository, "add", ".")
|
cliGitRun(t, repository, "add", ".")
|
||||||
@@ -80,20 +82,13 @@ func TestMultiModuleLauncherUsesSelectedModuleAndProfile(t *testing.T) {
|
|||||||
set -eu
|
set -eu
|
||||||
printf 'cwd=%s args=%s\n' "$PWD" "$*" >> "$RELEASEKIT_TEST_GO_LOG"
|
printf 'cwd=%s args=%s\n' "$PWD" "$*" >> "$RELEASEKIT_TEST_GO_LOG"
|
||||||
modfile=
|
modfile=
|
||||||
output=
|
|
||||||
previous=
|
|
||||||
for argument in "$@"; do
|
for argument in "$@"; do
|
||||||
case "$argument" in -modfile=*) modfile=${argument#-modfile=} ;; esac
|
case "$argument" in -modfile=*) modfile=${argument#-modfile=} ;; esac
|
||||||
if [ "$previous" = -o ]; then output=$argument; fi
|
|
||||||
previous=$argument
|
|
||||||
done
|
done
|
||||||
if [ -n "$modfile" ]; then cat "$modfile" >> "$RELEASEKIT_TEST_GO_LOG"; fi
|
if [ -n "$modfile" ]; then cat "$modfile" >> "$RELEASEKIT_TEST_GO_LOG"; fi
|
||||||
if [ "$1" = build ]; then
|
if [ "$1" = tool ]; then
|
||||||
cat > "$output" <<'TOOL'
|
shift 3
|
||||||
#!/bin/sh
|
printf '<%s>\n' "$@" > "$RELEASEKIT_TEST_TOOL_ARGS"
|
||||||
printf '<%s>\n' "$@" > "$RELEASEKIT_TEST_TOOL_ARGS"
|
|
||||||
TOOL
|
|
||||||
chmod +x "$output"
|
|
||||||
fi
|
fi
|
||||||
`
|
`
|
||||||
if err := os.WriteFile(filepath.Join(shimDir, "go"), []byte(shim), 0o700); err != nil {
|
if err := os.WriteFile(filepath.Join(shimDir, "go"), []byte(shim), 0o700); err != nil {
|
||||||
@@ -134,12 +129,12 @@ fi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMultiModuleLauncherBuildsToolFromSelectedDependency(t *testing.T) {
|
func TestMultiModuleLauncherRunsToolFromSelectedDependency(t *testing.T) {
|
||||||
example := filepath.Join(cliRepositoryRoot(t), "examples", "multi-module")
|
example := filepath.Join(cliRepositoryRoot(t), "examples", "multi-module")
|
||||||
repository := newCLIRepository(t)
|
repository := newCLIRepository(t)
|
||||||
copyExampleTree(t, example, repository)
|
copyExampleTree(t, example, repository)
|
||||||
writeRepositoryFile(t, repository, "go.mod", "module example.com/multi-module-consumer\n\ngo 1.25.0\n\nrequire gitea.maximumdirect.net/eric/ReleaseKit v0.2.197\n")
|
writeRepositoryFile(t, repository, "go.mod", "module example.com/multi-module-consumer\n\ngo 1.25.0\n\nrequire go.maximumdirect.net/releasekit v0.2.197\n")
|
||||||
writeRepositoryFile(t, repository, "integrations/ckconfig/go.mod", "module example.com/multi-module-consumer/integrations/ckconfig\n\ngo 1.25.0\n\nrequire gitea.maximumdirect.net/eric/ReleaseKit v0.2.198\n")
|
writeRepositoryFile(t, repository, "integrations/ckconfig/go.mod", "module example.com/multi-module-consumer/integrations/ckconfig\n\ngo 1.25.0\n\nrequire go.maximumdirect.net/releasekit v0.2.198\n")
|
||||||
cliGitRun(t, repository, "add", ".")
|
cliGitRun(t, repository, "add", ".")
|
||||||
cliGitRun(t, repository, "commit", "-m", "prepare selected tool module")
|
cliGitRun(t, repository, "commit", "-m", "prepare selected tool module")
|
||||||
|
|
||||||
@@ -161,11 +156,20 @@ func TestMultiModuleLauncherBuildsToolFromSelectedDependency(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
for _, status := range []int{0, 1, 2, 3} {
|
||||||
|
t.Run(strconv.Itoa(status), func(t *testing.T) {
|
||||||
command := exec.Command(filepath.Join(repository, "release.sh"), "ckconfig", "v1.2.3")
|
command := exec.Command(filepath.Join(repository, "release.sh"), "ckconfig", "v1.2.3")
|
||||||
|
command.Env = setEnvironment(os.Environ(), "RELEASEKIT_TOOL_EXIT", strconv.Itoa(status))
|
||||||
output, err := command.CombinedOutput()
|
output, err := command.CombinedOutput()
|
||||||
if err != nil {
|
if status == 0 && err != nil {
|
||||||
t.Fatalf("launcher error: %v output=%q", err, output)
|
t.Fatalf("launcher error: %v output=%q", err, output)
|
||||||
}
|
}
|
||||||
|
if status != 0 {
|
||||||
|
var exitError *exec.ExitError
|
||||||
|
if !errors.As(err, &exitError) || exitError.ExitCode() != status {
|
||||||
|
t.Fatalf("launcher error = %v, want exit %d; output=%q", err, status, output)
|
||||||
|
}
|
||||||
|
}
|
||||||
for _, expected := range []string{
|
for _, expected := range []string{
|
||||||
"tool-version=v0.2.198 helper=tool-only dependency",
|
"tool-version=v0.2.198 helper=tool-only dependency",
|
||||||
`"publish"`, `"--repo"`, `"` + repository + `"`,
|
`"publish"`, `"--repo"`, `"` + repository + `"`,
|
||||||
@@ -177,12 +181,26 @@ func TestMultiModuleLauncherBuildsToolFromSelectedDependency(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if strings.Contains(string(output), "tool-version=v0.2.197") {
|
if strings.Contains(string(output), "tool-version=v0.2.197") {
|
||||||
t.Fatalf("launcher built the root-selected ReleaseKit version: %s", output)
|
t.Fatalf("launcher ran the root-selected ReleaseKit version: %s", output)
|
||||||
}
|
}
|
||||||
moduleAfter, err := os.ReadFile(filepath.Join(repository, "integrations", "ckconfig", "go.mod"))
|
moduleAfter, err := os.ReadFile(filepath.Join(repository, "integrations", "ckconfig", "go.mod"))
|
||||||
if err != nil || !bytes.Equal(moduleAfter, moduleBefore) {
|
if err != nil || !bytes.Equal(moduleAfter, moduleBefore) {
|
||||||
t.Fatalf("selected module go.mod changed: got %q want %q error=%v", moduleAfter, moduleBefore, err)
|
t.Fatalf("selected module go.mod changed: got %q want %q error=%v", moduleAfter, moduleBefore, err)
|
||||||
}
|
}
|
||||||
|
if status := cliGitOutput(t, repository, "status", "--porcelain", "--untracked-files=all"); status != "" {
|
||||||
|
t.Fatalf("launcher changed source: %q", status)
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(filepath.Dir(repository))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if strings.HasPrefix(entry.Name(), ".releasekit-bootstrap.") {
|
||||||
|
t.Fatalf("temporary bootstrap directory remains: %s", entry.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func runModuleSelector(t *testing.T, helper, mode, value, mapPath string) string {
|
func runModuleSelector(t *testing.T, helper, mode, value, mapPath string) string {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli"
|
"go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMultiModuleWoodpeckerDelivery(t *testing.T) {
|
func TestMultiModuleWoodpeckerDelivery(t *testing.T) {
|
||||||
@@ -209,6 +209,11 @@ func newMappedDeliveryFixture(t *testing.T, tag string) *mappedDeliveryFixture {
|
|||||||
apiRoot := "/api/v1/repos/owner/example/"
|
apiRoot := "/api/v1/repos/owner/example/"
|
||||||
escapedTag := url.PathEscape(tag)
|
escapedTag := url.PathEscape(tag)
|
||||||
switch r.URL.EscapedPath() {
|
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:
|
case apiRoot + "tags/" + escapedTag:
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{"name": tag, "commit": map[string]string{"sha": data.commit}})
|
_ = json.NewEncoder(w).Encode(map[string]any{"name": tag, "commit": map[string]string{"sha": data.commit}})
|
||||||
case apiRoot + "releases/tags/" + escapedTag:
|
case apiRoot + "releases/tags/" + escapedTag:
|
||||||
@@ -302,7 +307,7 @@ func buildWorkflowCLI(t *testing.T, root string) string {
|
|||||||
|
|
||||||
func writeWorkflowToolProxy(t *testing.T) string {
|
func writeWorkflowToolProxy(t *testing.T) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
const module = "gitea.maximumdirect.net/eric/ReleaseKit"
|
const module = "go.maximumdirect.net/releasekit"
|
||||||
const helper = "example.com/releasekit-helper"
|
const helper = "example.com/releasekit-helper"
|
||||||
proxyDir := t.TempDir()
|
proxyDir := t.TempDir()
|
||||||
moduleFile := "module " + module + "\n\ngo 1.25.0\n\nrequire " + helper + " v1.0.0\n"
|
moduleFile := "module " + module + "\n\ngo 1.25.0\n\nrequire " + helper + " v1.0.0\n"
|
||||||
@@ -331,7 +336,7 @@ func main() {
|
|||||||
`, helper, version)
|
`, helper, version)
|
||||||
files := map[string]string{
|
files := map[string]string{
|
||||||
"cmd/releasekit/main.go": main,
|
"cmd/releasekit/main.go": main,
|
||||||
"buildinfo/version.go": "package buildinfo\nimport \"gitea.maximumdirect.net/eric/ReleaseKit/internal/runtimeinfo\"\nfunc Version() string { return runtimeinfo.Version() }\n",
|
"buildinfo/version.go": "package buildinfo\nimport \"go.maximumdirect.net/releasekit/internal/runtimeinfo\"\nfunc Version() string { return runtimeinfo.Version() }\n",
|
||||||
"internal/runtimeinfo/version.go": "package runtimeinfo\nvar injectedVersion string\nfunc Version() string { if injectedVersion != \"\" { return injectedVersion }; return \"dev\" }\n",
|
"internal/runtimeinfo/version.go": "package runtimeinfo\nvar injectedVersion string\nfunc Version() string { if injectedVersion != \"\" { return injectedVersion }; return \"dev\" }\n",
|
||||||
}
|
}
|
||||||
writeModuleProxyVersion(t, proxyDir, module, version, moduleFile, files)
|
writeModuleProxyVersion(t, proxyDir, module, version, moduleFile, files)
|
||||||
@@ -362,11 +367,11 @@ func newWorkflowRepository(t *testing.T, example string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func workflowModuleFile(module, version string) string {
|
func workflowModuleFile(module, version string) string {
|
||||||
return fmt.Sprintf("module %s\n\ngo 1.25.0\n\nrequire gitea.maximumdirect.net/eric/ReleaseKit %s\n", module, version)
|
return fmt.Sprintf("module %s\n\ngo 1.25.0\n\nrequire go.maximumdirect.net/releasekit %s\n", module, version)
|
||||||
}
|
}
|
||||||
|
|
||||||
func workflowDeliverySettings(baseURL string) *cli.DeliverySettings {
|
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) {
|
func writeMappedProfile(t *testing.T, repository, name string, settings cli.Settings) {
|
||||||
@@ -421,7 +426,7 @@ func prepareWorkflowModuleSum(t *testing.T, repository, moduleDir, proxy string)
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
command := exec.Command("go", "mod", "download", "gitea.maximumdirect.net/eric/ReleaseKit@v0.2.197")
|
command := exec.Command("go", "mod", "download", "go.maximumdirect.net/releasekit@v0.2.197")
|
||||||
command.Dir = filepath.Join(repository, filepath.FromSlash(moduleDir))
|
command.Dir = filepath.Join(repository, filepath.FromSlash(moduleDir))
|
||||||
command.Env = append(os.Environ(), "GOMODCACHE="+cache, "GOPROXY=file://"+filepath.ToSlash(proxy), "GOSUMDB=off", "GOWORK=off", "GOTOOLCHAIN=local", "GOFLAGS=-modcacherw")
|
command.Env = append(os.Environ(), "GOMODCACHE="+cache, "GOPROXY=file://"+filepath.ToSlash(proxy), "GOSUMDB=off", "GOWORK=off", "GOTOOLCHAIN=local", "GOFLAGS=-modcacherw")
|
||||||
if output, err := command.CombinedOutput(); err != nil {
|
if output, err := command.CombinedOutput(); err != nil {
|
||||||
|
|||||||
520
cmd/releasekit/publication_handoff_test.go
Normal file
520
cmd/releasekit/publication_handoff_test.go
Normal file
@@ -0,0 +1,520 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli"
|
||||||
|
"go.maximumdirect.net/releasekit/internal/gotool"
|
||||||
|
"go.maximumdirect.net/releasekit/internal/publish"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestVendoredAPIOnlyPublicationRecovery(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("fixture executes POSIX recovery commands")
|
||||||
|
}
|
||||||
|
repo, remote := newSimplePublishRepository(t, false)
|
||||||
|
source := repositoryRoot(t)
|
||||||
|
relative, err := filepath.Rel(repo, source)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeRepositoryFile(t, repo, "go.mod", "module example.com/cli-candidate\n\ngo 1.26.0\n\nrequire go.maximumdirect.net/releasekit v0.0.0\n\nreplace go.maximumdirect.net/releasekit => "+strconv.Quote(relative)+"\n")
|
||||||
|
writeRepositoryFile(t, repo, "uses_releasekit.go", "package candidate\nimport _ \"go.maximumdirect.net/releasekit\"\n")
|
||||||
|
sums, err := os.ReadFile(filepath.Join(source, "go.sum"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeRepositoryFile(t, repo, "go.sum", string(sums))
|
||||||
|
goBinary := filepath.Join(runtime.GOROOT(), "bin", "go")
|
||||||
|
for _, args := range [][]string{{"mod", "tidy"}, {"mod", "vendor"}} {
|
||||||
|
command := exec.Command(goBinary, args...)
|
||||||
|
command.Dir = repo
|
||||||
|
if out, err := command.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("prepare vendored API: %v: %s", err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cliGitRun(t, repo, "add", ".")
|
||||||
|
cliGitRun(t, repo, "commit", "-m", "Vendor API dependency")
|
||||||
|
cliGitRun(t, repo, "push", "origin", "main")
|
||||||
|
before := cliGitOutput(t, repo, "rev-parse", "HEAD^{tree}")
|
||||||
|
if err := os.WriteFile(filepath.Join(remote, "hooks", "pre-receive"), []byte("#!/bin/sh\nexit 1\n"), 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
launcherStore := t.TempDir()
|
||||||
|
launcherArgs, err := gotool.RecoveryTool(t.Context(), repo, launcherStore)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
launcher := exec.Command(goBinary, append(launcherArgs, "publish", "--repo", repo, "v1.2.3")...)
|
||||||
|
launcher.Dir = t.TempDir()
|
||||||
|
publicationOutput, err := launcher.CombinedOutput()
|
||||||
|
var exitError *exec.ExitError
|
||||||
|
if !errors.As(err, &exitError) || exitError.ExitCode() != 1 {
|
||||||
|
t.Fatalf("vendored publication: %v: %s", err, publicationOutput)
|
||||||
|
}
|
||||||
|
// The later handoff owns different launch metadata from the original driver.
|
||||||
|
if err := os.RemoveAll(launcherStore); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
command := outputLineValue(string(publicationOutput), "recovery command: ")
|
||||||
|
if command == "" {
|
||||||
|
t.Fatalf("no handoff: %s", publicationOutput)
|
||||||
|
}
|
||||||
|
recovery := exec.Command("sh", "-c", command)
|
||||||
|
recovery.Dir = t.TempDir()
|
||||||
|
recovered, err := recovery.CombinedOutput()
|
||||||
|
if !errors.As(err, &exitError) || exitError.ExitCode() != 1 || !strings.Contains(string(recovered), "module=example.com/cli-candidate tag=v1.2.3 state=not_published") {
|
||||||
|
t.Fatalf("vendored recovery: %v: %s", err, recovered)
|
||||||
|
}
|
||||||
|
if status := cliGitOutput(t, repo, "status", "--porcelain"); status != "" {
|
||||||
|
t.Fatalf("handoff changed consumer files: %s", status)
|
||||||
|
}
|
||||||
|
if after := cliGitOutput(t, repo, "write-tree"); after != before {
|
||||||
|
t.Fatalf("handoff changed consumer index: %s != %s", after, before)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecoveryToolPreservesVersionedReplacement(t *testing.T) {
|
||||||
|
proxy := writeReleaseKitModuleProxy(t)
|
||||||
|
environment := make(map[string]string)
|
||||||
|
useSyntheticModuleProxy(t, environment, "file://"+filepath.ToSlash(proxy))
|
||||||
|
for key, value := range environment {
|
||||||
|
t.Setenv(key, value)
|
||||||
|
}
|
||||||
|
module := t.TempDir()
|
||||||
|
data := "module example.com/recovery-consumer\n\ngo 1.26.0\n\nrequire go.maximumdirect.net/releasekit v0.2.197\n\nreplace go.maximumdirect.net/releasekit v0.2.197 => go.maximumdirect.net/releasekit v0.2.198\n"
|
||||||
|
if err := os.WriteFile(filepath.Join(module, "go.mod"), []byte(data), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
args, err := gotool.RecoveryTool(t.Context(), module, t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
command := exec.Command(filepath.Join(runtime.GOROOT(), "bin", "go"), args...)
|
||||||
|
command.Dir = t.TempDir()
|
||||||
|
output, err := command.CombinedOutput()
|
||||||
|
if err != nil || !strings.Contains(string(output), "tool-version=v0.2.198 helper=tool-only dependency") {
|
||||||
|
t.Fatalf("versioned recovery selection: %v: %s", err, output)
|
||||||
|
}
|
||||||
|
after, err := os.ReadFile(filepath.Join(module, "go.mod"))
|
||||||
|
if err != nil || string(after) != data {
|
||||||
|
t.Fatalf("consumer metadata changed: %q, %v", after, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecoveryToolSupportsReleaseKitSourceModule(t *testing.T) {
|
||||||
|
args, err := gotool.RecoveryTool(t.Context(), repositoryRoot(t), t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
command := exec.Command(filepath.Join(runtime.GOROOT(), "bin", "go"), append(args, "version")...)
|
||||||
|
command.Dir = t.TempDir()
|
||||||
|
output, err := command.CombinedOutput()
|
||||||
|
if err != nil || !strings.HasPrefix(string(output), "releasekit ") {
|
||||||
|
t.Fatalf("source module recovery entry point: %v: %s", err, output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestColdGoToolRecoverySurvivesLauncherExit(t *testing.T) {
|
||||||
|
if runtime.GOOS != "linux" {
|
||||||
|
t.Skip("fixture observes the cold executable through /proc")
|
||||||
|
}
|
||||||
|
for _, mode := range []struct{ registered, canceled bool }{{true, false}, {false, false}, {true, true}, {false, true}} {
|
||||||
|
registered := mode.registered
|
||||||
|
t.Run(fmt.Sprintf("registered=%t/canceled=%t", registered, mode.canceled), func(t *testing.T) {
|
||||||
|
repo, remote := newSimplePublishRepository(t, false)
|
||||||
|
source := repositoryRoot(t)
|
||||||
|
// A distinct replacement identity gives this tool a fresh link action
|
||||||
|
// while retaining the shared compilation cache for its dependencies.
|
||||||
|
selectedSource := filepath.Join(t.TempDir(), "ReleaseKit source")
|
||||||
|
if err := os.Symlink(source, selectedSource); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !registered {
|
||||||
|
relative, err := filepath.Rel(repo, selectedSource)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
selectedSource = relative
|
||||||
|
}
|
||||||
|
module := "module example.com/cli-candidate\n\ngo 1.26.0\n\nrequire go.maximumdirect.net/releasekit v0.0.0\n\nreplace go.maximumdirect.net/releasekit => " + strconv.Quote(selectedSource) + "\n"
|
||||||
|
if registered {
|
||||||
|
module += "\ntool go.maximumdirect.net/releasekit/cmd/releasekit\n"
|
||||||
|
}
|
||||||
|
writeRepositoryFile(t, repo, "go.mod", module)
|
||||||
|
sums, err := os.ReadFile(filepath.Join(source, "go.sum"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeRepositoryFile(t, repo, "go.sum", string(sums))
|
||||||
|
if !registered {
|
||||||
|
for _, name := range []string{"release.sh", "select-module.sh"} {
|
||||||
|
data, err := os.ReadFile(filepath.Join(source, "examples", "multi-module", name))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(repo, name), data, 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeRepositoryFile(t, repo, "release-modules.tsv", "root\tv*\t.\treleasekit.json\n")
|
||||||
|
}
|
||||||
|
goBinary := filepath.Join(runtime.GOROOT(), "bin", "go")
|
||||||
|
if registered {
|
||||||
|
prepare := exec.Command(goBinary, "list", "-mod=mod", "-deps", "go.maximumdirect.net/releasekit/cmd/releasekit")
|
||||||
|
prepare.Dir = repo
|
||||||
|
if out, err := prepare.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("prepare tool: %v: %s", err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cliGitRun(t, repo, "add", ".")
|
||||||
|
cliGitRun(t, repo, "commit", "-m", "Select release tool")
|
||||||
|
cliGitRun(t, repo, "push", "origin", "main")
|
||||||
|
if err := os.WriteFile(filepath.Join(remote, "hooks", "pre-receive"), []byte("#!/bin/sh\nexit 1\n"), 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
realGit, err := exec.LookPath("git")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
bin := t.TempDir()
|
||||||
|
executed := filepath.Join(t.TempDir(), "executed-tool")
|
||||||
|
wrapper := "#!/bin/sh\nparent=$(readlink \"/proc/$PPID/exe\")\ncase \"$parent\" in */exe/releasekit) printf '%s' \"$parent\" > " + shellQuote(executed) + ";; esac\n" + cancelPushScript + "exec " + shellQuote(realGit) + " \"$@\"\n"
|
||||||
|
if err := os.WriteFile(filepath.Join(bin, "git"), []byte(wrapper), 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
var launch *exec.Cmd
|
||||||
|
if registered {
|
||||||
|
launch = exec.Command(goBinary, "tool", "releasekit", "publish", "--repo", repo, "v1.2.3")
|
||||||
|
} else {
|
||||||
|
launch = exec.Command(filepath.Join(repo, "release.sh"), "root", "v1.2.3")
|
||||||
|
}
|
||||||
|
launch.Dir = repo
|
||||||
|
var out []byte
|
||||||
|
if mode.canceled {
|
||||||
|
out, err = canceledPublicationOutput(t, launch, repo, "")
|
||||||
|
} else {
|
||||||
|
out, err = launch.CombinedOutput()
|
||||||
|
}
|
||||||
|
var exitError *exec.ExitError
|
||||||
|
if !errors.As(err, &exitError) || exitError.ExitCode() != 1 {
|
||||||
|
t.Fatalf("publication: %v: %s", err, out)
|
||||||
|
}
|
||||||
|
text := string(out)
|
||||||
|
launchedPath, err := os.ReadFile(executed)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("did not exercise a cold temporary executable: %v: %s", err, text)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(string(launchedPath)); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("cold executable still exists: %s: %v", launchedPath, err)
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(filepath.Dir(repo))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if strings.HasPrefix(entry.Name(), ".releasekit-bootstrap.") {
|
||||||
|
t.Fatalf("launcher bootstrap survived: %s", entry.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
command := outputLineValue(text, "recovery command: ")
|
||||||
|
if !strings.Contains(command, "go ") || strings.Contains(command, "/exe/releasekit") {
|
||||||
|
t.Fatalf("unstable handoff: %s", command)
|
||||||
|
}
|
||||||
|
if !registered && !strings.Contains(command, "publication-recovery") {
|
||||||
|
t.Fatalf("API-only handoff lacks durable metadata: %s", command)
|
||||||
|
}
|
||||||
|
// Execute from another directory after the original driver and the
|
||||||
|
// API-only launcher's temporary files are gone.
|
||||||
|
evidence, err := publish.LoadEvidence(outputLineValue(text, "publication record saved: "))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
evidence.Endpoint = filepath.Join(t.TempDir(), "unavailable.git")
|
||||||
|
unknownRecord, err := publish.SaveEvidence(t.Context(), repo, t.TempDir(), evidence)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, scenario := range []struct {
|
||||||
|
record string
|
||||||
|
status int
|
||||||
|
}{{"", 1}, {filepath.Join(t.TempDir(), "missing.json"), 2}, {unknownRecord, 3}, {"", 0}} {
|
||||||
|
if scenario.status == 0 {
|
||||||
|
if err := os.Remove(filepath.Join(remote, "hooks", "pre-receive")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cliGitRun(t, repo, "push", "origin", "refs/tags/v1.2.3")
|
||||||
|
}
|
||||||
|
selected := command
|
||||||
|
if scenario.record != "" {
|
||||||
|
selected += " --record " + shellQuote(scenario.record)
|
||||||
|
}
|
||||||
|
recovery := exec.Command("sh", "-c", selected)
|
||||||
|
recovery.Dir = t.TempDir()
|
||||||
|
recovered, err := recovery.CombinedOutput()
|
||||||
|
if scenario.status == 0 && err != nil || scenario.status != 0 && (!errors.As(err, &exitError) || exitError.ExitCode() != scenario.status) {
|
||||||
|
t.Fatalf("recovery status want %d: %v: %s", scenario.status, err, recovered)
|
||||||
|
}
|
||||||
|
if scenario.status == 1 && !strings.Contains(string(recovered), "state=not_published") {
|
||||||
|
t.Fatalf("wrong observation: %s", recovered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if status := cliGitOutput(t, repo, "status", "--porcelain"); status != "" {
|
||||||
|
t.Fatalf("handoff modified candidate: %s", status)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishRecordOverrideWarningDoesNotChangeSuccess(t *testing.T) {
|
||||||
|
repository, _ := newSimplePublishRepository(t, true)
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
status := run(t.Context(), []string{"publish", "--repo", repository, "--record-dir", "record-blocker", "v1.2.3"}, &stdout, &stderr)
|
||||||
|
if status != 0 || !strings.Contains(stdout.String(), "published v1.2.3") {
|
||||||
|
t.Fatalf("status = %d, stdout = %q, stderr = %q", status, stdout.String(), stderr.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "publication record warning:") || strings.Contains(stderr.String(), "publication record saved:") || strings.Contains(stderr.String(), "recovery command:") {
|
||||||
|
t.Fatalf("recording warning or success handoff output = %q", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuiltPublishRecoveryCommandSelectsItsRecordAmongOtherAttempts(t *testing.T) {
|
||||||
|
for _, mode := range []string{"rejected", "canceled", "preparation failure"} {
|
||||||
|
t.Run(mode, func(t *testing.T) {
|
||||||
|
t.Setenv("GIT_CONFIG_NOSYSTEM", "1")
|
||||||
|
t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull)
|
||||||
|
t.Setenv("GIT_TERMINAL_PROMPT", "0")
|
||||||
|
repository, remote := newSimplePublishRepository(t, false)
|
||||||
|
store := filepath.Join(t.TempDir(), "attempt records with ' apostrophe")
|
||||||
|
hook := filepath.Join(remote, "hooks", "pre-receive")
|
||||||
|
if err := os.WriteFile(hook, []byte("#!/bin/sh\nexit 1\n"), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
goBinary := filepath.Join(runtime.GOROOT(), "bin", "go")
|
||||||
|
executable := filepath.Join(t.TempDir(), "releasekit's CLI")
|
||||||
|
build := exec.Command(goBinary, "build", "-o", executable, "./cmd/releasekit")
|
||||||
|
build.Dir = repositoryRoot(t)
|
||||||
|
if output, err := build.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("build CLI: %v\n%s", err, output)
|
||||||
|
}
|
||||||
|
|
||||||
|
if mode == "canceled" {
|
||||||
|
realGit, err := exec.LookPath("git")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
bin := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(bin, "git"), []byte("#!/bin/sh\n"+cancelPushScript+"exec "+shellQuote(realGit)+" \"$@\"\n"), 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
}
|
||||||
|
if mode == "preparation failure" {
|
||||||
|
realGo, err := exec.LookPath("go")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
bin := t.TempDir()
|
||||||
|
script := "#!/bin/sh\nif [ \"$1\" = list ] && [ \"$2\" = -e ]; then exit 73; fi\nexec " + shellQuote(realGo) + " \"$@\"\n"
|
||||||
|
if err := os.WriteFile(filepath.Join(bin, "go"), []byte(script), 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
}
|
||||||
|
command := exec.Command(executable, "publish", "--repo", repository, "--record-dir", store, "v1.2.3")
|
||||||
|
command.Dir = repositoryRoot(t)
|
||||||
|
var output []byte
|
||||||
|
var err error
|
||||||
|
if mode == "canceled" {
|
||||||
|
output, err = canceledPublicationOutput(t, command, repository, store)
|
||||||
|
} else {
|
||||||
|
output, err = command.CombinedOutput()
|
||||||
|
}
|
||||||
|
var exitError *exec.ExitError
|
||||||
|
if !errors.As(err, &exitError) || exitError.ExitCode() != 1 {
|
||||||
|
t.Fatalf("publication exit = %v, output = %q; want status 1", err, output)
|
||||||
|
}
|
||||||
|
text := string(output)
|
||||||
|
if mode == "preparation failure" && !strings.Contains(text, "recovery tool preparation warning:") {
|
||||||
|
t.Fatalf("preparation warning missing: %s", text)
|
||||||
|
}
|
||||||
|
if !strings.Contains(text, "publication state=not_published phase=reconciliation") {
|
||||||
|
t.Fatalf("publication failure classification changed: %q", text)
|
||||||
|
}
|
||||||
|
recordPath := outputLineValue(text, "publication record saved: ")
|
||||||
|
if !filepath.IsAbs(recordPath) || !strings.Contains(recordPath, "attempt records with ' apostrophe") {
|
||||||
|
t.Fatalf("saved record path = %q, output = %q", recordPath, text)
|
||||||
|
}
|
||||||
|
recoveryCommand := outputLineValue(text, "recovery command: ")
|
||||||
|
if recoveryCommand == "" || !strings.Contains(recoveryCommand, shellQuote(executable)) || !strings.Contains(recoveryCommand, shellQuote(recordPath)) {
|
||||||
|
t.Fatalf("copyable recovery command = %q, output = %q", recoveryCommand, text)
|
||||||
|
}
|
||||||
|
if !strings.Contains(text, "selected supported ReleaseKit CLI") {
|
||||||
|
t.Fatalf("output omits guidance for an unavailable original executable: %q", text)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, decoy := range []publish.Evidence{
|
||||||
|
{RepositoryDir: repository, ModulePath: "example.com/other-module", Version: "v8.8.8", Tag: "v8.8.8", Endpoint: remote, Remote: "origin"},
|
||||||
|
{RepositoryDir: repository, ModulePath: "example.com/retry", Version: "v1.2.4", Tag: "v1.2.4", Endpoint: remote, Remote: "origin"},
|
||||||
|
} {
|
||||||
|
if _, err := publish.SaveEvidence(t.Context(), repository, store, decoy); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
recovery := exec.Command("/bin/sh", "-c", recoveryCommand)
|
||||||
|
recovery.Dir = t.TempDir()
|
||||||
|
recoveryOutput, recoveryErr := recovery.CombinedOutput()
|
||||||
|
if !errors.As(recoveryErr, &exitError) || exitError.ExitCode() != 1 {
|
||||||
|
t.Fatalf("recovery command exit = %v, output = %q; want known absence status 1", recoveryErr, recoveryOutput)
|
||||||
|
}
|
||||||
|
recoveryText := string(recoveryOutput)
|
||||||
|
if !strings.Contains(recoveryText, "module=example.com/cli-candidate tag=v1.2.3 state=not_published") || strings.Contains(recoveryText, "example.com/other-module") || strings.Contains(recoveryText, "example.com/retry") {
|
||||||
|
t.Fatalf("printed command selected the wrong attempt: %q", recoveryText)
|
||||||
|
}
|
||||||
|
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShellQuoteHandlesApostrophesAndSpaces(t *testing.T) {
|
||||||
|
got := shellQuote("/tmp/ReleaseKit's recovery record.json")
|
||||||
|
if got != "'/tmp/ReleaseKit'\"'\"'s recovery record.json'" {
|
||||||
|
t.Fatalf("shellQuote() = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSimplePublishRepository(t *testing.T, recordBlocker bool) (string, string) {
|
||||||
|
t.Helper()
|
||||||
|
repository := newCLIRepository(t)
|
||||||
|
settings, err := json.Marshal(cli.Settings{Product: "Example Product", Remote: "origin"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeRepositoryFile(t, repository, "releasekit.json", string(settings))
|
||||||
|
files := []string{"releasekit.json"}
|
||||||
|
if recordBlocker {
|
||||||
|
writeRepositoryFile(t, repository, "record-blocker", "tracked file\n")
|
||||||
|
files = append(files, "record-blocker")
|
||||||
|
}
|
||||||
|
cliGitRun(t, repository, append([]string{"add"}, files...)...)
|
||||||
|
cliGitRun(t, repository, "commit", "-m", "configure source release")
|
||||||
|
remote := filepath.Join(t.TempDir(), "origin.git")
|
||||||
|
cliGitRun(t, repository, "init", "--bare", "--initial-branch=main", remote)
|
||||||
|
cliGitRun(t, repository, "remote", "add", "origin", remote)
|
||||||
|
cliGitRun(t, repository, "push", "--set-upstream", "origin", "main")
|
||||||
|
return repository, remote
|
||||||
|
}
|
||||||
|
|
||||||
|
func outputLineValue(output, prefix string) string {
|
||||||
|
for _, line := range strings.Split(output, "\n") {
|
||||||
|
if strings.HasPrefix(line, prefix) {
|
||||||
|
return strings.TrimPrefix(line, prefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func repositoryRoot(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
directory, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return filepath.Clean(filepath.Join(directory, "..", ".."))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The shim blocks exactly when Git would start the recorded tag push. The CLI
|
||||||
|
// PID lets the test cancel the actual operation even behind a Go driver/launcher.
|
||||||
|
const cancelPushScript = `if [ -n "$RELEASEKIT_CANCEL_PUSH_READY" ]; then
|
||||||
|
for arg in "$@"; do
|
||||||
|
if [ "$arg" = push ]; then
|
||||||
|
printf '%s' "$PPID" > "$RELEASEKIT_CANCEL_PUSH_READY"
|
||||||
|
exec sleep 600
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
`
|
||||||
|
|
||||||
|
func canceledPublicationOutput(t *testing.T, command *exec.Cmd, repo, store string) ([]byte, error) {
|
||||||
|
t.Helper()
|
||||||
|
ready := filepath.Join(t.TempDir(), "push-ready")
|
||||||
|
command.Env = append(os.Environ(), "RELEASEKIT_CANCEL_PUSH_READY="+ready)
|
||||||
|
var output bytes.Buffer
|
||||||
|
command.Stdout = &output
|
||||||
|
command.Stderr = &output
|
||||||
|
if err := command.Start(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer command.Process.Kill()
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- command.Wait() }()
|
||||||
|
ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
ticker := time.NewTicker(5 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
var operation *os.Process
|
||||||
|
waiting:
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
t.Fatalf("publication finished before push interception: %v: %s", err, &output)
|
||||||
|
case <-ctx.Done():
|
||||||
|
_ = command.Process.Kill()
|
||||||
|
<-done
|
||||||
|
t.Fatal("publication never reached push interception")
|
||||||
|
case <-ticker.C:
|
||||||
|
data, err := os.ReadFile(ready)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pid, err := strconv.Atoi(string(data))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
operation, err = os.FindProcess(pid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer operation.Release()
|
||||||
|
break waiting
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Check the handoff identity before allowing cancellation to reach publication.
|
||||||
|
listed, err := publish.ListAttempts(t.Context(), repo, store)
|
||||||
|
if err != nil || len(listed.Attempts) != 1 || listed.Attempts[0].Evidence.TagObject == "" {
|
||||||
|
_ = operation.Signal(os.Interrupt)
|
||||||
|
<-done
|
||||||
|
t.Fatalf("pre-push record is incomplete: %#v, %v", listed, err)
|
||||||
|
}
|
||||||
|
if err := operation.Signal(os.Interrupt); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if !strings.Contains(output.String(), "context canceled") {
|
||||||
|
t.Fatalf("publication lost its cancellation diagnostic: %s", &output)
|
||||||
|
}
|
||||||
|
return output.Bytes(), err
|
||||||
|
case <-ctx.Done():
|
||||||
|
_ = operation.Kill()
|
||||||
|
_ = command.Process.Kill()
|
||||||
|
<-done
|
||||||
|
t.Fatal("canceled publication did not return")
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
351
cmd/releasekit/publication_recovery_workflow_test.go
Normal file
351
cmd/releasekit/publication_recovery_workflow_test.go
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/releasekit"
|
||||||
|
"go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPublicationPushGateHelper(t *testing.T) {
|
||||||
|
address := os.Getenv("RELEASEKIT_PUSH_GATE_ADDR")
|
||||||
|
if address == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
connection, err := net.DialTimeout("tcp", address, 10*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connect to pre-receive gate: %v", err)
|
||||||
|
}
|
||||||
|
defer connection.Close()
|
||||||
|
if _, err := fmt.Fprintln(connection, "ready"); err != nil {
|
||||||
|
t.Fatalf("announce pre-receive readiness: %v", err)
|
||||||
|
}
|
||||||
|
response, err := bufio.NewReader(connection).ReadString('\n')
|
||||||
|
if err != nil || strings.TrimSpace(response) != "reject" {
|
||||||
|
t.Fatalf("pre-receive gate response = %q, %v", response, err)
|
||||||
|
}
|
||||||
|
// The test process is the pre-receive hook's gate. Reject only after the
|
||||||
|
// parent has observed the pre-push record and explicitly released it.
|
||||||
|
t.Fatal("controlled rejection after pre-push record observation")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLostPublicationOutputCanBeRecoveredFromExplicitRecords(t *testing.T) {
|
||||||
|
setIsolatedGitEnvironment(t)
|
||||||
|
repository := newCLIRepository(t)
|
||||||
|
store := filepath.Join(t.TempDir(), "publication records")
|
||||||
|
checkLog := filepath.Join(t.TempDir(), "candidate checks.log")
|
||||||
|
var hostedRequests atomic.Int64
|
||||||
|
hostedServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
hostedRequests.Add(1)
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}))
|
||||||
|
defer hostedServer.Close()
|
||||||
|
|
||||||
|
settings := cli.Settings{
|
||||||
|
Product: "Example Product",
|
||||||
|
Remote: "origin",
|
||||||
|
Checks: []cli.CheckCommand{{
|
||||||
|
Args: []string{"/bin/sh", "-c", "printf 'candidate\\n' >> \"$RELEASEKIT_CANDIDATE_LOG\""},
|
||||||
|
Env: map[string]string{"RELEASEKIT_CANDIDATE_LOG": checkLog},
|
||||||
|
}},
|
||||||
|
Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: hostedServer.URL, Owner: "example", Repository: "candidate"},
|
||||||
|
}
|
||||||
|
writeSettings := func() {
|
||||||
|
t.Helper()
|
||||||
|
contents, err := json.Marshal(settings)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeRepositoryFile(t, repository, "releasekit.json", string(contents))
|
||||||
|
cliGitRun(t, repository, "add", "releasekit.json")
|
||||||
|
cliGitRun(t, repository, "commit", "-m", "update publication profile")
|
||||||
|
}
|
||||||
|
writeSettings()
|
||||||
|
remote := filepath.Join(t.TempDir(), "original.git")
|
||||||
|
cliGitRun(t, repository, "init", "--bare", "--initial-branch=main", remote)
|
||||||
|
cliGitRun(t, repository, "remote", "add", "origin", remote)
|
||||||
|
cliGitRun(t, repository, "push", "--set-upstream", "origin", "main")
|
||||||
|
|
||||||
|
nestedDir := "integrations/recovery"
|
||||||
|
writeRepositoryFile(t, repository, filepath.ToSlash(filepath.Join(nestedDir, "go.mod")), "module example.com/cli-candidate/recovery\n\ngo 1.25.0\n")
|
||||||
|
writeRepositoryFile(t, repository, filepath.ToSlash(filepath.Join(nestedDir, "candidate_test.go")), "package recovery\nimport \"testing\"\nfunc TestFixture(t *testing.T) {}\n")
|
||||||
|
writeRepositoryFile(t, repository, filepath.ToSlash(filepath.Join(nestedDir, "docs/releases/v1.2.3.md")), "# Example Product v1.2.3\n\nRecovery fixture.\n")
|
||||||
|
cliGitRun(t, repository, "add", nestedDir)
|
||||||
|
cliGitRun(t, repository, "commit", "-m", "add nested module")
|
||||||
|
cliGitRun(t, repository, "push", "origin", "main")
|
||||||
|
installPushGate(t, remote)
|
||||||
|
|
||||||
|
goBinary := filepath.Join(runtime.GOROOT(), "bin", "go")
|
||||||
|
executable := filepath.Join(t.TempDir(), "releasekit recovery workflow")
|
||||||
|
build := exec.Command(goBinary, "build", "-o", executable, "./cmd/releasekit")
|
||||||
|
build.Dir = repositoryRoot(t)
|
||||||
|
if output, err := build.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("build CLI: %v\n%s", err, output)
|
||||||
|
}
|
||||||
|
testBinary, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rootTag := "v1.2.3"
|
||||||
|
rootRecord := rejectPublicationAfterRecording(t, executable, testBinary, repository, store, rootTag, "")
|
||||||
|
if rootRecord.Evidence.ModuleDir != repository || rootRecord.Evidence.Tag != rootTag {
|
||||||
|
t.Fatalf("root attempt evidence = %#v", rootRecord.Evidence)
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.ModuleDir = nestedDir
|
||||||
|
writeSettings()
|
||||||
|
cliGitRun(t, repository, "push", "origin", "main")
|
||||||
|
nestedTag := nestedDir + "/v1.2.3"
|
||||||
|
nestedRecord := rejectPublicationAfterRecording(t, executable, testBinary, repository, store, nestedTag, nestedDir)
|
||||||
|
if nestedRecord.Evidence.ModuleDir != filepath.Join(repository, nestedDir) || nestedRecord.Evidence.Tag != nestedTag {
|
||||||
|
t.Fatalf("nested attempt evidence = %#v", nestedRecord.Evidence)
|
||||||
|
}
|
||||||
|
if rootRecord.RecordPath == nestedRecord.RecordPath {
|
||||||
|
t.Fatalf("publication attempts reused record path %q", rootRecord.RecordPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace the rejected root tag on the original endpoint with a different
|
||||||
|
// annotated object, then make the current checkout point somewhere else.
|
||||||
|
hook := filepath.Join(remote, "hooks", "pre-receive")
|
||||||
|
if err := os.Remove(hook); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cliGitRun(t, repository, "tag", "-f", "-a", rootTag, "-m", "remote replacement after failed attempt", "HEAD")
|
||||||
|
cliGitRun(t, repository, "push", "origin", "refs/tags/"+rootTag)
|
||||||
|
decoyRemote := filepath.Join(t.TempDir(), "replacement-origin.git")
|
||||||
|
cliGitRun(t, repository, "init", "--bare", "--initial-branch=main", decoyRemote)
|
||||||
|
cliGitRun(t, repository, "remote", "set-url", "origin", decoyRemote)
|
||||||
|
cliGitRun(t, repository, "switch", "-c", "recovery-inspection")
|
||||||
|
cliGitRun(t, repository, "commit", "--allow-empty", "-m", "move recovery checkout")
|
||||||
|
cliGitRun(t, repository, "tag", "-d", rootTag, nestedTag)
|
||||||
|
writeRepositoryFile(t, repository, "untracked recovery marker", "preserve this file\n")
|
||||||
|
|
||||||
|
before := snapshotRecoveryRepository(t, repository, remote, rootRecord.RecordPath, nestedRecord.RecordPath)
|
||||||
|
listing := runCLIJSON(t, executable, t.TempDir(), "publication-attempts", "--repo", repository, "--record-dir", store, "--json")
|
||||||
|
var listed struct {
|
||||||
|
Attempts []struct {
|
||||||
|
RecordPath string `json:"record_path"`
|
||||||
|
Evidence struct {
|
||||||
|
Tag string `json:"tag"`
|
||||||
|
ModuleDir string `json:"module_dir"`
|
||||||
|
} `json:"evidence"`
|
||||||
|
} `json:"attempts"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(listing, &listed); err != nil {
|
||||||
|
t.Fatalf("decode publication-attempts output: %v\n%s", err, listing)
|
||||||
|
}
|
||||||
|
selectedRoot := ""
|
||||||
|
selectedNested := ""
|
||||||
|
for _, attempt := range listed.Attempts {
|
||||||
|
switch attempt.Evidence.Tag {
|
||||||
|
case rootTag:
|
||||||
|
selectedRoot = attempt.RecordPath
|
||||||
|
case nestedTag:
|
||||||
|
selectedNested = attempt.RecordPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if selectedRoot != rootRecord.RecordPath || selectedNested != nestedRecord.RecordPath {
|
||||||
|
t.Fatalf("discovery selected root=%q nested=%q; want %q and %q; listing=%s", selectedRoot, selectedNested, rootRecord.RecordPath, nestedRecord.RecordPath, listing)
|
||||||
|
}
|
||||||
|
|
||||||
|
rootResult := runCLIStatusJSON(t, executable, t.TempDir(), "reconcile", "--record", selectedRoot, "--json")
|
||||||
|
if rootResult.status != 1 || rootResult.document["state"] != "conflict" {
|
||||||
|
t.Fatalf("explicit root reconciliation status=%d document=%s; want conflict on the recorded original endpoint", rootResult.status, rootResult.output)
|
||||||
|
}
|
||||||
|
nestedResult := runCLIStatusJSON(t, executable, t.TempDir(), "reconcile", "--record", selectedNested, "--json")
|
||||||
|
if nestedResult.status != 1 || nestedResult.document["state"] != "not_published" {
|
||||||
|
t.Fatalf("explicit nested reconciliation status=%d document=%s; want not_published", nestedResult.status, nestedResult.output)
|
||||||
|
}
|
||||||
|
if got := hostedRequests.Load(); got != 0 {
|
||||||
|
t.Fatalf("later recovery made %d hosted delivery requests", got)
|
||||||
|
}
|
||||||
|
after := snapshotRecoveryRepository(t, repository, remote, rootRecord.RecordPath, nestedRecord.RecordPath)
|
||||||
|
if before != after {
|
||||||
|
t.Fatalf("reconciliation changed local or saved state:\nbefore: %#v\nafter: %#v", before, after)
|
||||||
|
}
|
||||||
|
checkBytes, err := os.ReadFile(checkLog)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := strings.Count(string(checkBytes), "candidate\n"); got != 2 {
|
||||||
|
t.Fatalf("candidate checks ran %d times; want once per publication and none during recovery: %q", got, checkBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type listedAttempt struct {
|
||||||
|
RecordPath string
|
||||||
|
Evidence releasekit.PublicationEvidence
|
||||||
|
}
|
||||||
|
|
||||||
|
func rejectPublicationAfterRecording(t *testing.T, executable, testBinary, repository, store, tag, expectedModule string) listedAttempt {
|
||||||
|
t.Helper()
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer listener.Close()
|
||||||
|
commandArgs := []string{"publish", "--repo", repository, "--record-dir", store}
|
||||||
|
if expectedModule != "" {
|
||||||
|
commandArgs = append(commandArgs, "--expected-module", expectedModule)
|
||||||
|
}
|
||||||
|
commandArgs = append(commandArgs, "v1.2.3")
|
||||||
|
command := exec.Command(executable, commandArgs...)
|
||||||
|
command.Dir = repositoryRoot(t)
|
||||||
|
command.Env = append(os.Environ(), "RELEASEKIT_PUSH_GATE_ADDR="+listener.Addr().String(), "RELEASEKIT_PUSH_GATE_TEST_BINARY="+testBinary)
|
||||||
|
command.Stdout = io.Discard
|
||||||
|
command.Stderr = io.Discard
|
||||||
|
if err := command.Start(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if command.Process != nil {
|
||||||
|
_ = command.Process.Kill()
|
||||||
|
_ = command.Wait()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if tcpListener, ok := listener.(*net.TCPListener); ok {
|
||||||
|
if err := tcpListener.SetDeadline(time.Now().Add(90 * time.Second)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
connection, err := listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("wait for pre-receive gate for %s: %v", tag, err)
|
||||||
|
}
|
||||||
|
defer connection.Close()
|
||||||
|
if tcpConnection, ok := connection.(*net.TCPConn); ok {
|
||||||
|
if err := tcpConnection.SetDeadline(time.Now().Add(30 * time.Second)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
line, err := bufio.NewReader(connection).ReadString('\n')
|
||||||
|
if err != nil || strings.TrimSpace(line) != "ready" {
|
||||||
|
t.Fatalf("pre-receive gate readiness for %s = %q, %v", tag, line, err)
|
||||||
|
}
|
||||||
|
listed, err := releasekit.ListPublicationAttempts(context.Background(), releasekit.PublicationAttemptsRequest{RepositoryDir: repository, RecordDir: store})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list attempts while tag push is held: %v", err)
|
||||||
|
}
|
||||||
|
var selected *releasekit.PublicationAttempt
|
||||||
|
for i := range listed.Attempts {
|
||||||
|
if listed.Attempts[i].Evidence.Tag == tag {
|
||||||
|
selected = &listed.Attempts[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if selected == nil {
|
||||||
|
t.Fatalf("pre-push record for %s was not visible before the hook continued: %#v", tag, listed.Attempts)
|
||||||
|
}
|
||||||
|
if selected.Evidence.CandidateCommit == "" || selected.Evidence.TagObject == "" || selected.Evidence.Endpoint == "" {
|
||||||
|
t.Fatalf("pre-push evidence for %s is incomplete: %#v", tag, selected.Evidence)
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintln(connection, "reject"); err != nil {
|
||||||
|
t.Fatalf("release pre-receive gate for %s: %v", tag, err)
|
||||||
|
}
|
||||||
|
waitErr := command.Wait()
|
||||||
|
command.Process = nil
|
||||||
|
if waitErr == nil {
|
||||||
|
t.Fatalf("publish %s succeeded despite the controlled rejection", tag)
|
||||||
|
} else {
|
||||||
|
var exitError *exec.ExitError
|
||||||
|
if !errors.As(waitErr, &exitError) || exitError.ExitCode() != 1 {
|
||||||
|
t.Fatalf("publish %s exit = %v; want publication failure status 1", tag, waitErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return listedAttempt{RecordPath: selected.RecordPath, Evidence: selected.Evidence}
|
||||||
|
}
|
||||||
|
|
||||||
|
func installPushGate(t *testing.T, remote string) {
|
||||||
|
t.Helper()
|
||||||
|
hook := filepath.Join(remote, "hooks", "pre-receive")
|
||||||
|
script := "#!/bin/sh\nwhile read old new ref; do\n case \"$ref\" in\n refs/tags/*) \"$RELEASEKIT_PUSH_GATE_TEST_BINARY\" -test.run='^TestPublicationPushGateHelper$'; exit $? ;;\n esac\ndone\nexit 0\n"
|
||||||
|
if err := os.WriteFile(hook, []byte(script), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setIsolatedGitEnvironment(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
t.Setenv("GIT_CONFIG_NOSYSTEM", "1")
|
||||||
|
t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull)
|
||||||
|
t.Setenv("GIT_TERMINAL_PROMPT", "0")
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCLIJSON(t *testing.T, executable, directory string, args ...string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
result := runCLIStatusJSON(t, executable, directory, args...)
|
||||||
|
if result.status != 0 {
|
||||||
|
t.Fatalf("CLI %v exited with status %d: %s", args, result.status, result.output)
|
||||||
|
}
|
||||||
|
return result.output
|
||||||
|
}
|
||||||
|
|
||||||
|
type cliJSONResult struct {
|
||||||
|
status int
|
||||||
|
output []byte
|
||||||
|
document map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCLIStatusJSON(t *testing.T, executable, directory string, args ...string) cliJSONResult {
|
||||||
|
t.Helper()
|
||||||
|
command := exec.Command(executable, args...)
|
||||||
|
command.Dir = directory
|
||||||
|
output, err := command.CombinedOutput()
|
||||||
|
status := 0
|
||||||
|
if err != nil {
|
||||||
|
var exitError *exec.ExitError
|
||||||
|
if !errors.As(err, &exitError) {
|
||||||
|
t.Fatalf("run CLI %v: %v\n%s", args, err, output)
|
||||||
|
}
|
||||||
|
status = exitError.ExitCode()
|
||||||
|
}
|
||||||
|
var document map[string]any
|
||||||
|
if err := json.Unmarshal(output, &document); err != nil {
|
||||||
|
t.Fatalf("decode CLI JSON for %v: %v\n%s", args, err, output)
|
||||||
|
}
|
||||||
|
return cliJSONResult{status: status, output: output, document: document}
|
||||||
|
}
|
||||||
|
|
||||||
|
type recoverySnapshot struct {
|
||||||
|
Branch string
|
||||||
|
Head string
|
||||||
|
Worktree string
|
||||||
|
LocalRefs string
|
||||||
|
RemoteRefs string
|
||||||
|
Records string
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshotRecoveryRepository(t *testing.T, repository, remote string, recordPaths ...string) recoverySnapshot {
|
||||||
|
t.Helper()
|
||||||
|
branch := cliGitOutput(t, repository, "branch", "--show-current")
|
||||||
|
head := cliGitOutput(t, repository, "rev-parse", "HEAD")
|
||||||
|
worktree := cliGitOutput(t, repository, "status", "--porcelain=v1", "--untracked-files=all")
|
||||||
|
localRefs := cliGitOutput(t, repository, "show-ref")
|
||||||
|
remoteRefs := cliGitOutput(t, remote, "show-ref")
|
||||||
|
var records strings.Builder
|
||||||
|
for _, path := range recordPaths {
|
||||||
|
contents, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read saved record %s: %v", path, err)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&records, "%s\x00%s\n", path, contents)
|
||||||
|
}
|
||||||
|
return recoverySnapshot{Branch: branch, Head: head, Worktree: worktree, LocalRefs: localRefs, RemoteRefs: remoteRefs, Records: records.String()}
|
||||||
|
}
|
||||||
259
cmd/releasekit/recovery.go
Normal file
259
cmd/releasekit/recovery.go
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/releasekit"
|
||||||
|
"go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli"
|
||||||
|
"go.maximumdirect.net/releasekit/internal/git"
|
||||||
|
)
|
||||||
|
|
||||||
|
type recoveryDiagnostic struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
RecordPath string `json:"record_path,omitempty"`
|
||||||
|
MissingFields []string `json:"missing_fields,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type safeEvidence struct {
|
||||||
|
RepositoryDir string `json:"repository_dir,omitempty"`
|
||||||
|
ModuleDir string `json:"module_dir,omitempty"`
|
||||||
|
ModulePath string `json:"module_path,omitempty"`
|
||||||
|
Version string `json:"version,omitempty"`
|
||||||
|
Tag string `json:"tag,omitempty"`
|
||||||
|
CandidateCommit string `json:"candidate_commit,omitempty"`
|
||||||
|
TagObject string `json:"tag_object,omitempty"`
|
||||||
|
Endpoint string `json:"endpoint,omitempty"`
|
||||||
|
Remote string `json:"remote,omitempty"`
|
||||||
|
RecordedAt string `json:"recorded_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type attemptDocument struct {
|
||||||
|
RecordPath string `json:"record_path"`
|
||||||
|
Evidence safeEvidence `json:"evidence"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type reconciliationDocument struct {
|
||||||
|
SchemaVersion int `json:"schema_version"`
|
||||||
|
Command string `json:"command"`
|
||||||
|
RecordPath string `json:"record_path"`
|
||||||
|
Evidence *safeEvidence `json:"evidence,omitempty"`
|
||||||
|
State string `json:"state,omitempty"`
|
||||||
|
ObservedTagObject string `json:"observed_tag_object,omitempty"`
|
||||||
|
ObservedCommit string `json:"observed_commit,omitempty"`
|
||||||
|
Diagnostic *recoveryDiagnostic `json:"diagnostic,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type attemptsDocument struct {
|
||||||
|
SchemaVersion int `json:"schema_version"`
|
||||||
|
Command string `json:"command"`
|
||||||
|
RecordDir string `json:"record_dir"`
|
||||||
|
Attempts []attemptDocument `json:"attempts"`
|
||||||
|
Diagnostics []*recoveryDiagnostic `json:"diagnostics"`
|
||||||
|
Diagnostic *recoveryDiagnostic `json:"diagnostic,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func safe(e releasekit.PublicationEvidence) safeEvidence {
|
||||||
|
return safeEvidence{
|
||||||
|
RepositoryDir: git.DisplayDiagnostic(e.RepositoryDir),
|
||||||
|
ModuleDir: git.DisplayDiagnostic(e.ModuleDir),
|
||||||
|
ModulePath: git.DisplayDiagnostic(e.ModulePath),
|
||||||
|
Version: git.DisplayDiagnostic(e.Version),
|
||||||
|
Tag: git.DisplayDiagnostic(e.Tag),
|
||||||
|
CandidateCommit: git.DisplayDiagnostic(e.CandidateCommit),
|
||||||
|
TagObject: git.DisplayDiagnostic(e.TagObject),
|
||||||
|
Endpoint: git.DisplayDiagnostic(e.Endpoint),
|
||||||
|
Remote: git.DisplayDiagnostic(e.Remote),
|
||||||
|
RecordedAt: git.DisplayDiagnostic(e.RecordedAt),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func diagnostic(err error) *recoveryDiagnostic {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var typed *releasekit.PublicationRecoveryError
|
||||||
|
if errors.As(err, &typed) {
|
||||||
|
missing := make([]string, len(typed.MissingFields))
|
||||||
|
for i, field := range typed.MissingFields {
|
||||||
|
missing[i] = git.DisplayDiagnostic(field)
|
||||||
|
}
|
||||||
|
return &recoveryDiagnostic{Kind: git.DisplayDiagnostic(typed.Kind), Message: git.DisplayError(err).Error(), RecordPath: git.DisplayDiagnostic(typed.RecordPath), MissingFields: missing}
|
||||||
|
}
|
||||||
|
return &recoveryDiagnostic{Kind: "invocation", Message: git.DisplayError(err).Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runRecovery(ctx context.Context, invocation cli.Invocation, stdout, stderr io.Writer) int {
|
||||||
|
if invocation.Command == cli.ReconcileAction {
|
||||||
|
return runReconcile(ctx, invocation, stdout, stderr)
|
||||||
|
}
|
||||||
|
listed, err := releasekit.ListPublicationAttempts(ctx, releasekit.PublicationAttemptsRequest{RepositoryDir: invocation.Repository, RecordDir: invocation.RecordDir})
|
||||||
|
return reportAttempts(listed, err, invocation.JSON, stdout, stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func reportAttempts(listed releasekit.PublicationAttemptsResult, err error, jsonOutput bool, stdout, stderr io.Writer) int {
|
||||||
|
if jsonOutput {
|
||||||
|
attempts := make([]attemptDocument, 0, len(listed.Attempts))
|
||||||
|
for _, item := range listed.Attempts {
|
||||||
|
attempts = append(attempts, attemptDocument{RecordPath: git.DisplayDiagnostic(item.RecordPath), Evidence: safe(item.Evidence)})
|
||||||
|
}
|
||||||
|
diagnostics := make([]*recoveryDiagnostic, 0, len(listed.Diagnostics))
|
||||||
|
for _, item := range listed.Diagnostics {
|
||||||
|
diagnostics = append(diagnostics, diagnostic(item))
|
||||||
|
}
|
||||||
|
if writeErr := writeJSON(stdout, attemptsDocument{
|
||||||
|
SchemaVersion: 1,
|
||||||
|
Command: "publication-attempts",
|
||||||
|
RecordDir: git.DisplayDiagnostic(listed.RecordDir),
|
||||||
|
Attempts: attempts,
|
||||||
|
Diagnostics: diagnostics,
|
||||||
|
Diagnostic: diagnostic(err),
|
||||||
|
}); writeErr != nil {
|
||||||
|
fmt.Fprintf(stderr, "write recovery output: %v\n", git.DisplayError(writeErr))
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(stdout, "publication records in %s\n", git.DisplayDiagnostic(listed.RecordDir))
|
||||||
|
for _, item := range listed.Attempts {
|
||||||
|
fmt.Fprintf(stdout, "%s module=%s tag=%s commit=%s recorded-at=%s\n", git.DisplayDiagnostic(item.RecordPath), git.DisplayDiagnostic(item.Evidence.ModulePath), git.DisplayDiagnostic(item.Evidence.Tag), git.DisplayDiagnostic(item.Evidence.CandidateCommit), git.DisplayDiagnostic(item.Evidence.RecordedAt))
|
||||||
|
}
|
||||||
|
if err != nil || len(listed.Diagnostics) > 0 {
|
||||||
|
fmt.Fprintln(stderr, "discovery is incomplete")
|
||||||
|
}
|
||||||
|
for _, item := range listed.Diagnostics {
|
||||||
|
fmt.Fprintf(stderr, "record %s: %s\n", git.DisplayDiagnostic(item.RecordPath), humanDiagnostic(item))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(stderr, "discovery failed: %v\n", git.DisplayError(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil || len(listed.Diagnostics) > 0 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func runReconcile(ctx context.Context, invocation cli.Invocation, stdout, stderr io.Writer) int {
|
||||||
|
evidence, loadErr := releasekit.LoadPublicationEvidence(invocation.RecordPath)
|
||||||
|
var result releasekit.PublicationReconciliationResult
|
||||||
|
err := loadErr
|
||||||
|
if err == nil {
|
||||||
|
result, err = releasekit.ReconcilePublication(ctx, evidence)
|
||||||
|
}
|
||||||
|
status := reconciliationStatus(result.State, err)
|
||||||
|
if invocation.JSON {
|
||||||
|
dto := reconciliationDocument{SchemaVersion: 1, Command: "reconcile", RecordPath: git.DisplayDiagnostic(invocation.RecordPath)}
|
||||||
|
if loadErr == nil {
|
||||||
|
safeEvidence := safe(evidence)
|
||||||
|
dto.Evidence = &safeEvidence
|
||||||
|
dto.State = string(result.State)
|
||||||
|
dto.ObservedTagObject = result.ObservedTagObject
|
||||||
|
dto.ObservedCommit = result.ObservedCommit
|
||||||
|
}
|
||||||
|
dto.Diagnostic = diagnostic(err)
|
||||||
|
if writeErr := writeJSON(stdout, dto); writeErr != nil {
|
||||||
|
fmt.Fprintf(stderr, "write recovery output: %v\n", git.DisplayError(writeErr))
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
} else if err == nil {
|
||||||
|
fmt.Fprintf(stdout, "reconciled %s module=%s tag=%s state=%s\n", git.DisplayDiagnostic(invocation.RecordPath), git.DisplayDiagnostic(evidence.ModulePath), git.DisplayDiagnostic(evidence.Tag), result.State)
|
||||||
|
if result.State == releasekit.PublicationConflict {
|
||||||
|
fmt.Fprintf(stdout, "expected tag-object=%s commit=%s; observed tag-object=%s commit=%s\n", git.DisplayDiagnostic(evidence.TagObject), git.DisplayDiagnostic(evidence.CandidateCommit), result.ObservedTagObject, result.ObservedCommit)
|
||||||
|
} else if result.ObservedTagObject != "" || result.ObservedCommit != "" {
|
||||||
|
fmt.Fprintf(stdout, "observed tag-object=%s commit=%s\n", result.ObservedTagObject, result.ObservedCommit)
|
||||||
|
}
|
||||||
|
if guidance := reconciliationGuidance(result.State); guidance != "" {
|
||||||
|
fmt.Fprintf(stdout, "%s\n", guidance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil && !invocation.JSON {
|
||||||
|
if loadErr == nil {
|
||||||
|
fmt.Fprintf(stdout, "reconciled %s module=%s tag=%s state=%s\n", git.DisplayDiagnostic(invocation.RecordPath), git.DisplayDiagnostic(evidence.ModulePath), git.DisplayDiagnostic(evidence.Tag), result.State)
|
||||||
|
if result.ObservedTagObject != "" || result.ObservedCommit != "" {
|
||||||
|
fmt.Fprintf(stdout, "observed tag-object=%s commit=%s\n", result.ObservedTagObject, result.ObservedCommit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Fprintf(stderr, "reconcile %s: %s\n", git.DisplayDiagnostic(invocation.RecordPath), humanDiagnostic(err))
|
||||||
|
if guidance := recoveryGuidance(err); guidance != "" {
|
||||||
|
fmt.Fprintf(stderr, "%s\n", guidance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
func reconciliationStatus(state releasekit.PublicationState, err error) int {
|
||||||
|
if err == nil {
|
||||||
|
switch state {
|
||||||
|
case releasekit.PublicationPublished:
|
||||||
|
return 0
|
||||||
|
case releasekit.PublicationNotPublished, releasekit.PublicationConflict:
|
||||||
|
return 1
|
||||||
|
case releasekit.PublicationUnknown:
|
||||||
|
return 3
|
||||||
|
default:
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var typed *releasekit.PublicationRecoveryError
|
||||||
|
if errors.As(err, &typed) {
|
||||||
|
switch typed.Kind {
|
||||||
|
case "record_read", "record_format":
|
||||||
|
return 2
|
||||||
|
default:
|
||||||
|
return 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
func recoveryGuidance(err error) string {
|
||||||
|
var typed *releasekit.PublicationRecoveryError
|
||||||
|
if !errors.As(err, &typed) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch typed.Kind {
|
||||||
|
case "missing_evidence":
|
||||||
|
return "Select a record with the missing identity fields, or provide complete typed evidence through the Go API."
|
||||||
|
case "invalid_evidence":
|
||||||
|
return "Use a record with a valid full tag and Git object ID."
|
||||||
|
case "endpoint_changed":
|
||||||
|
return "Review Git URL rewrite settings and restore the captured endpoint mapping before retrying."
|
||||||
|
case "transport_configuration":
|
||||||
|
return "Review Git configuration and the supported transport settings, then retry observation."
|
||||||
|
case "canceled":
|
||||||
|
return "Retry observation with a context that allows it to complete."
|
||||||
|
case "remote_observation", "inconsistent_observation":
|
||||||
|
return "Review the Git diagnostic and the remote tag refs before taking any corrective action."
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func reconciliationGuidance(state releasekit.PublicationState) string {
|
||||||
|
switch state {
|
||||||
|
case releasekit.PublicationNotPublished:
|
||||||
|
return "This reports current absence only; review publication history and version policy before retrying."
|
||||||
|
case releasekit.PublicationConflict:
|
||||||
|
return "Do not replace a conflicting remote tag; verify the observed IDs and follow the repository's immutable-tag procedure."
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func humanDiagnostic(err error) string {
|
||||||
|
var typed *releasekit.PublicationRecoveryError
|
||||||
|
if errors.As(err, &typed) && len(typed.MissingFields) > 0 {
|
||||||
|
return git.DisplayDiagnostic(fmt.Sprintf("%s (missing fields: %s): %s", typed.Kind, strings.Join(typed.MissingFields, ", "), err.Error()))
|
||||||
|
}
|
||||||
|
return git.DisplayError(err).Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(writer io.Writer, value any) error {
|
||||||
|
encoder := json.NewEncoder(writer)
|
||||||
|
return encoder.Encode(value)
|
||||||
|
}
|
||||||
487
cmd/releasekit/recovery_test.go
Normal file
487
cmd/releasekit/recovery_test.go
Normal file
@@ -0,0 +1,487 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
releasekit "go.maximumdirect.net/releasekit"
|
||||||
|
"go.maximumdirect.net/releasekit/internal/publish"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRecoveryEndpointSetupFailureDiagnostics(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("fixture injects Git failures through a POSIX launcher")
|
||||||
|
}
|
||||||
|
for _, test := range []struct {
|
||||||
|
name, remote, kind, guidance string
|
||||||
|
exitCode int
|
||||||
|
}{
|
||||||
|
{"unnamed configuration", "", "transport_configuration", "Git configuration", 128},
|
||||||
|
{"named configuration", "origin", "transport_configuration", "Git configuration", 128},
|
||||||
|
{"rewrite", "", "endpoint_changed", "URL rewrite", 0},
|
||||||
|
{"tooling", "", "remote_observation", "Git diagnostic", 71},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
repo := newCLIRepository(t)
|
||||||
|
evidence := publish.Evidence{RepositoryDir: repo, Endpoint: repo, Tag: "v1.2.3", Remote: test.remote}
|
||||||
|
path, err := publish.SaveEvidence(t.Context(), repo, t.TempDir(), evidence)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if test.kind == "transport_configuration" {
|
||||||
|
file, err := os.OpenFile(filepath.Join(repo, ".git", "config"), os.O_APPEND|os.O_WRONLY, 0600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := file.WriteString("\n[broken\n"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := file.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
} else if test.kind == "endpoint_changed" {
|
||||||
|
cliGitRun(t, repo, "config", "url."+t.TempDir()+".insteadOf", repo)
|
||||||
|
}
|
||||||
|
realGit, err := exec.LookPath("git")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
marker := filepath.Join(t.TempDir(), "queried")
|
||||||
|
bin := t.TempDir()
|
||||||
|
failValidation := ""
|
||||||
|
if test.name == "tooling" {
|
||||||
|
failValidation = "[ \"$arg\" = check-ref-format ] && exit 71\n"
|
||||||
|
}
|
||||||
|
script := "#!/bin/sh\nquery=0\nget_url=0\nfor arg in \"$@\"; do\n" + failValidation + "[ \"$arg\" = ls-remote ] && query=1\n[ \"$arg\" = --get-url ] && get_url=1\ndone\nif [ \"$query\" = 1 ] && [ \"$get_url\" = 0 ]; then : > " + shellQuote(marker) + "; exit 72; fi\nexec " + shellQuote(realGit) + " \"$@\"\n"
|
||||||
|
if err := os.WriteFile(filepath.Join(bin, "git"), []byte(script), 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
loaded, err := releasekit.LoadPublicationEvidence(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
result, err := releasekit.ReconcilePublication(t.Context(), loaded)
|
||||||
|
var typed *releasekit.PublicationRecoveryError
|
||||||
|
if !errors.As(err, &typed) || typed.Kind != test.kind || result.State != releasekit.PublicationUnknown {
|
||||||
|
t.Fatalf("setup classification = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
if test.exitCode != 0 {
|
||||||
|
var exitErr *exec.ExitError
|
||||||
|
if !errors.As(err, &exitErr) || exitErr.ExitCode() != test.exitCode {
|
||||||
|
t.Fatalf("lost Git cause: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, structured := range []bool{false, true} {
|
||||||
|
args := []string{"reconcile", "--record", path}
|
||||||
|
if structured {
|
||||||
|
args = append(args, "--json")
|
||||||
|
}
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
if status := run(t.Context(), args, &stdout, &stderr); status != 3 {
|
||||||
|
t.Fatalf("setup status=%d: %s%s", status, &stdout, &stderr)
|
||||||
|
}
|
||||||
|
if structured {
|
||||||
|
var document reconciliationDocument
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &document); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if document.State != "unknown" || document.Diagnostic == nil || document.Diagnostic.Kind != test.kind {
|
||||||
|
t.Fatalf("setup JSON: %s", &stdout)
|
||||||
|
}
|
||||||
|
} else if !strings.Contains(stderr.String(), test.kind) || !strings.Contains(stderr.String(), test.guidance) {
|
||||||
|
t.Fatalf("setup guidance: %s", &stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(marker); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("setup failure reached remote query: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type failingRecoveryWriter struct{ err error }
|
||||||
|
|
||||||
|
func (writer failingRecoveryWriter) Write([]byte) (int, error) { return 0, writer.err }
|
||||||
|
|
||||||
|
func TestRecoveryCommandsReportJSONWriteFailure(t *testing.T) {
|
||||||
|
repo := newCLIRepository(t)
|
||||||
|
cliGitRun(t, repo, "tag", "-a", "v1.2.3", "-m", "Release")
|
||||||
|
store := t.TempDir()
|
||||||
|
path, err := publish.SaveEvidence(t.Context(), repo, store, publish.Evidence{
|
||||||
|
RepositoryDir: repo, Endpoint: repo, Tag: "v1.2.3",
|
||||||
|
CandidateCommit: cliGitOutput(t, repo, "rev-parse", "HEAD"),
|
||||||
|
TagObject: cliGitOutput(t, repo, "rev-parse", "v1.2.3"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
failure := errors.New("output unavailable: https://user:audit-secret@example.invalid/output")
|
||||||
|
for _, args := range [][]string{{"reconcile", "--record", path, "--json"}, {"publication-attempts", "--record-dir", store, "--json"}} {
|
||||||
|
var normal, stderr bytes.Buffer
|
||||||
|
if status := run(t.Context(), args, &normal, &stderr); status != 0 {
|
||||||
|
t.Fatalf("normal output status=%d: %s", status, &stderr)
|
||||||
|
}
|
||||||
|
stderr.Reset()
|
||||||
|
if status := run(t.Context(), args, failingRecoveryWriter{failure}, &stderr); status != 1 {
|
||||||
|
t.Fatalf("failed output status=%d: %s", status, &stderr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "output unavailable") || strings.Contains(stderr.String(), "audit-secret") {
|
||||||
|
t.Fatalf("output diagnostic: %s", &stderr)
|
||||||
|
}
|
||||||
|
if args[0] == "reconcile" && !strings.Contains(normal.String(), `"state":"published"`) {
|
||||||
|
t.Fatalf("observation changed: %s", &normal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiscoveryReportsPartialAttemptsAndEnumerationFailure(t *testing.T) {
|
||||||
|
listed := releasekit.PublicationAttemptsResult{
|
||||||
|
RecordDir: "records",
|
||||||
|
Attempts: []releasekit.PublicationAttempt{{RecordPath: "good.json", Evidence: releasekit.PublicationEvidence{Tag: "v1.2.3"}}},
|
||||||
|
Diagnostics: []*releasekit.PublicationRecoveryError{{Kind: "record_format", RecordPath: "broken.json"}},
|
||||||
|
}
|
||||||
|
failure := &releasekit.PublicationRecoveryError{Kind: "storage_enumeration"}
|
||||||
|
for _, jsonOutput := range []bool{false, true} {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
if status := reportAttempts(listed, failure, jsonOutput, &stdout, &stderr); status != 1 {
|
||||||
|
t.Fatalf("partial discovery status=%d", status)
|
||||||
|
}
|
||||||
|
if jsonOutput {
|
||||||
|
var document attemptsDocument
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &document); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(document.Attempts) != 1 || document.Attempts[0].RecordPath != "good.json" || len(document.Diagnostics) != 1 || document.Diagnostics[0].RecordPath != "broken.json" || document.Diagnostic == nil || document.Diagnostic.Kind != "storage_enumeration" {
|
||||||
|
t.Fatalf("partial JSON lost data: %s", &stdout)
|
||||||
|
}
|
||||||
|
} else if !strings.Contains(stdout.String(), "good.json") || !strings.Contains(stderr.String(), "broken.json") || !strings.Contains(stderr.String(), "storage_enumeration") {
|
||||||
|
t.Fatalf("partial human output lost data: %s%s", &stdout, &stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecoveryDisplaysRedactCredentialPathsAndMetadata(t *testing.T) {
|
||||||
|
secret := "audit-secret"
|
||||||
|
raw := "https://audit-user:" + secret + "@example.invalid/attempt.json"
|
||||||
|
for _, jsonOutput := range []bool{false, true} {
|
||||||
|
args := []string{"reconcile", "--record", raw}
|
||||||
|
if jsonOutput {
|
||||||
|
args = append(args, "--json")
|
||||||
|
}
|
||||||
|
var out, diagnostics bytes.Buffer
|
||||||
|
if status := run(t.Context(), args, &out, &diagnostics); status != 2 {
|
||||||
|
t.Fatalf("load status=%d", status)
|
||||||
|
}
|
||||||
|
if strings.Contains(out.String()+diagnostics.String(), secret) {
|
||||||
|
t.Fatalf("credential leaked: %s%s", &out, &diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
repo := newCLIRepository(t)
|
||||||
|
store := t.TempDir()
|
||||||
|
evidence := publish.Evidence{RepositoryDir: raw, ModuleDir: raw, ModulePath: raw, Version: raw, Tag: raw, CandidateCommit: raw, TagObject: raw, Remote: raw, RecordedAt: raw}
|
||||||
|
path, err := publish.SaveEvidence(t.Context(), repo, store, evidence)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, command := range [][]string{{"reconcile", "--record", path}, {"publication-attempts", "--record-dir", store}} {
|
||||||
|
for _, jsonOutput := range []bool{false, true} {
|
||||||
|
args := append([]string{}, command...)
|
||||||
|
if jsonOutput {
|
||||||
|
args = append(args, "--json")
|
||||||
|
}
|
||||||
|
var out, diagnostics bytes.Buffer
|
||||||
|
run(t.Context(), args, &out, &diagnostics)
|
||||||
|
if strings.Contains(out.String()+diagnostics.String(), secret) {
|
||||||
|
t.Fatalf("metadata leaked: %s%s", &out, &diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loaded, err := publish.LoadEvidence(path)
|
||||||
|
if err != nil || loaded != evidence {
|
||||||
|
t.Fatalf("private metadata changed: %#v, %v", loaded, err)
|
||||||
|
}
|
||||||
|
var out bytes.Buffer
|
||||||
|
reportPublicationRecord(t.Context(), releasekit.PublicationResult{RecordPath: raw, Phase: releasekit.PublicationPush}, fmt.Errorf("push failed"), &out)
|
||||||
|
if strings.Contains(out.String(), secret) {
|
||||||
|
t.Fatalf("handoff leaked credentials: %s", &out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReconcileCommandClassifiesRecordsFromAnotherWorkingDirectory(t *testing.T) {
|
||||||
|
repository := newCLIRepository(t)
|
||||||
|
remote := filepath.Join(t.TempDir(), "bare remote.git")
|
||||||
|
cliGitRun(t, repository, "init", "--bare", "--initial-branch=main", remote)
|
||||||
|
cliGitRun(t, repository, "remote", "add", "origin", remote)
|
||||||
|
cliGitRun(t, repository, "tag", "-a", "v1.2.3", "-m", "first tag object")
|
||||||
|
cliGitRun(t, repository, "push", "origin", "refs/tags/v1.2.3")
|
||||||
|
commit := cliGitOutput(t, repository, "rev-parse", "HEAD")
|
||||||
|
tagObject := cliGitOutput(t, repository, "rev-parse", "refs/tags/v1.2.3")
|
||||||
|
|
||||||
|
store := filepath.Join(t.TempDir(), "records with ' punctuation")
|
||||||
|
evidence := publish.Evidence{
|
||||||
|
RepositoryDir: repository,
|
||||||
|
ModuleDir: repository,
|
||||||
|
ModulePath: "example.com/cli-candidate",
|
||||||
|
Version: "v1.2.3",
|
||||||
|
Tag: "v1.2.3",
|
||||||
|
CandidateCommit: commit,
|
||||||
|
TagObject: tagObject,
|
||||||
|
Endpoint: remote,
|
||||||
|
Remote: "origin",
|
||||||
|
RecordedAt: "2026-11-02T00:00:00Z",
|
||||||
|
}
|
||||||
|
record, err := publish.SaveEvidence(t.Context(), repository, store, evidence)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cliGitRun(t, repository, "switch", "-c", "changed-head")
|
||||||
|
cliGitRun(t, repository, "commit", "--allow-empty", "-m", "change current HEAD")
|
||||||
|
cliGitRun(t, repository, "tag", "-d", "v1.2.3")
|
||||||
|
if err := os.WriteFile(filepath.Join(repository, "uncommitted recovery check.txt"), []byte("dirty"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
otherDirectory := t.TempDir()
|
||||||
|
relativeRecord, err := filepath.Rel(otherDirectory, record)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
previousDirectory, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Chdir(otherDirectory); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := os.Chdir(previousDirectory); err != nil {
|
||||||
|
t.Errorf("restore working directory: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
status := run(context.Background(), []string{"reconcile", "--record", relativeRecord, "--json"}, &stdout, &stderr)
|
||||||
|
if status != 0 || stderr.Len() != 0 {
|
||||||
|
t.Fatalf("published status = %d, stdout = %q, stderr = %q", status, stdout.String(), stderr.String())
|
||||||
|
}
|
||||||
|
var published map[string]any
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &published); err != nil {
|
||||||
|
t.Fatalf("decode published JSON: %v; output = %q", err, stdout.String())
|
||||||
|
}
|
||||||
|
if published["state"] != string("published") || published["record_path"] != relativeRecord {
|
||||||
|
t.Fatalf("published JSON = %#v", published)
|
||||||
|
}
|
||||||
|
if _, ok := published["diagnostic"]; ok {
|
||||||
|
t.Fatalf("successful reconciliation includes a diagnostic: %#v", published)
|
||||||
|
}
|
||||||
|
evidenceJSON, ok := published["evidence"].(map[string]any)
|
||||||
|
if !ok || evidenceJSON["candidate_commit"] != commit || evidenceJSON["tag_object"] != tagObject {
|
||||||
|
t.Fatalf("safe evidence = %#v", published["evidence"])
|
||||||
|
}
|
||||||
|
if evidenceJSON["module_path"] != evidence.ModulePath || evidenceJSON["candidateCommit"] != nil {
|
||||||
|
t.Fatalf("evidence does not use the versioned snake-case shape: %#v", evidenceJSON)
|
||||||
|
}
|
||||||
|
|
||||||
|
cliGitRun(t, repository, "push", "origin", ":refs/tags/v1.2.3")
|
||||||
|
cliGitRun(t, repository, "tag", "-f", "-a", "v1.2.3", "-m", "replacement tag object")
|
||||||
|
cliGitRun(t, repository, "push", "origin", "refs/tags/v1.2.3")
|
||||||
|
var conflictOut, conflictErr bytes.Buffer
|
||||||
|
status = run(context.Background(), []string{"reconcile", "--record", record, "--json"}, &conflictOut, &conflictErr)
|
||||||
|
if status != 1 || conflictErr.Len() != 0 {
|
||||||
|
t.Fatalf("conflict status = %d, stdout = %q, stderr = %q", status, conflictOut.String(), conflictErr.String())
|
||||||
|
}
|
||||||
|
var conflict map[string]any
|
||||||
|
if err := json.Unmarshal(conflictOut.Bytes(), &conflict); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if conflict["state"] != string("conflict") || conflict["observed_tag_object"] == tagObject {
|
||||||
|
t.Fatalf("conflict JSON = %#v", conflict)
|
||||||
|
}
|
||||||
|
|
||||||
|
var humanOut, humanErr bytes.Buffer
|
||||||
|
status = run(context.Background(), []string{"reconcile", "--record", record}, &humanOut, &humanErr)
|
||||||
|
if status != 1 || !strings.Contains(humanOut.String(), "expected tag-object="+tagObject) || !strings.Contains(humanOut.String(), "observed tag-object=") || !strings.Contains(humanOut.String(), "Do not replace a conflicting remote tag") {
|
||||||
|
t.Fatalf("human conflict status = %d, stdout = %q, stderr = %q", status, humanOut.String(), humanErr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
partialEvidence := evidence
|
||||||
|
partialEvidence.TagObject = cliGitOutput(t, repository, "rev-parse", "refs/tags/v1.2.3")
|
||||||
|
partialEvidence.CandidateCommit = ""
|
||||||
|
partialRecord, err := publish.SaveEvidence(t.Context(), repository, store, partialEvidence)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var unknownOut, unknownErr bytes.Buffer
|
||||||
|
status = run(context.Background(), []string{"reconcile", "--record", partialRecord, "--json"}, &unknownOut, &unknownErr)
|
||||||
|
if status != 3 || unknownErr.Len() != 0 {
|
||||||
|
t.Fatalf("unknown status = %d, stdout = %q, stderr = %q", status, unknownOut.String(), unknownErr.String())
|
||||||
|
}
|
||||||
|
var unknown map[string]any
|
||||||
|
if err := json.Unmarshal(unknownOut.Bytes(), &unknown); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
unknownDiagnostic, ok := unknown["diagnostic"].(map[string]any)
|
||||||
|
if unknown["state"] != "unknown" || !ok || unknownDiagnostic["kind"] != "missing_evidence" {
|
||||||
|
t.Fatalf("unknown JSON = %#v", unknown)
|
||||||
|
}
|
||||||
|
var unknownHumanOut, unknownHumanErr bytes.Buffer
|
||||||
|
status = run(context.Background(), []string{"reconcile", "--record", partialRecord}, &unknownHumanOut, &unknownHumanErr)
|
||||||
|
if status != 3 || !strings.Contains(unknownHumanOut.String(), "state=unknown") || !strings.Contains(unknownHumanErr.String(), "candidate_commit") {
|
||||||
|
t.Fatalf("human unknown status = %d, stdout = %q, stderr = %q", status, unknownHumanOut.String(), unknownHumanErr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
cliGitRun(t, repository, "push", "origin", ":refs/tags/v1.2.3")
|
||||||
|
var absentOut, absentErr bytes.Buffer
|
||||||
|
status = run(context.Background(), []string{"reconcile", "--record", record, "--json"}, &absentOut, &absentErr)
|
||||||
|
if status != 1 || absentErr.Len() != 0 {
|
||||||
|
t.Fatalf("absent status = %d, stdout = %q, stderr = %q", status, absentOut.String(), absentErr.String())
|
||||||
|
}
|
||||||
|
var absent map[string]any
|
||||||
|
if err := json.Unmarshal(absentOut.Bytes(), &absent); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if absent["state"] != "not_published" {
|
||||||
|
t.Fatalf("absence JSON = %#v", absent)
|
||||||
|
}
|
||||||
|
if _, ok := absent["diagnostic"]; ok {
|
||||||
|
t.Fatalf("known absence includes an unresolved diagnostic: %#v", absent)
|
||||||
|
}
|
||||||
|
var absentHumanOut, absentHumanErr bytes.Buffer
|
||||||
|
status = run(context.Background(), []string{"reconcile", "--record", record}, &absentHumanOut, &absentHumanErr)
|
||||||
|
if status != 1 || !strings.Contains(absentHumanOut.String(), "state=not_published") || !strings.Contains(absentHumanOut.String(), "current absence only") {
|
||||||
|
t.Fatalf("human absence status = %d, stdout = %q, stderr = %q", status, absentHumanOut.String(), absentHumanErr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
brokenRecord := filepath.Join(store, "broken record's.json")
|
||||||
|
if err := os.WriteFile(brokenRecord, []byte("{"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var brokenOut, brokenErr bytes.Buffer
|
||||||
|
status = run(context.Background(), []string{"reconcile", "--record", brokenRecord, "--json"}, &brokenOut, &brokenErr)
|
||||||
|
if status != 2 || brokenErr.Len() != 0 {
|
||||||
|
t.Fatalf("record-format status = %d, stdout = %q, stderr = %q", status, brokenOut.String(), brokenErr.String())
|
||||||
|
}
|
||||||
|
var broken map[string]any
|
||||||
|
if err := json.Unmarshal(brokenOut.Bytes(), &broken); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
brokenDiagnostic, ok := broken["diagnostic"].(map[string]any)
|
||||||
|
if !ok || brokenDiagnostic["kind"] != "record_format" || broken["record_path"] != brokenRecord {
|
||||||
|
t.Fatalf("record-format JSON = %#v", broken)
|
||||||
|
}
|
||||||
|
for _, field := range []string{"evidence", "state", "observed_tag_object", "observed_commit"} {
|
||||||
|
if _, ok := broken[field]; ok {
|
||||||
|
t.Errorf("record-format JSON includes %q: %#v", field, broken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublicationAttemptsCommandRedactsCredentialsAndReportsPartialDiscovery(t *testing.T) {
|
||||||
|
repository := newCLIRepository(t)
|
||||||
|
store := filepath.Join(t.TempDir(), "external records")
|
||||||
|
evidence := publish.Evidence{
|
||||||
|
RepositoryDir: repository,
|
||||||
|
ModulePath: "example.com/cli-candidate",
|
||||||
|
Version: "v1.2.3",
|
||||||
|
Tag: "v1.2.3",
|
||||||
|
Endpoint: "https://cli-user:cli-secret@example.test/repository.git",
|
||||||
|
Remote: "origin",
|
||||||
|
RecordedAt: "2026-11-02T00:00:00Z",
|
||||||
|
}
|
||||||
|
if _, err := publish.SaveEvidence(t.Context(), repository, store, evidence); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
brokenPath := filepath.Join(store, "damaged.json")
|
||||||
|
if err := os.WriteFile(brokenPath, []byte(`{"format_version":99,"evidence":{}}`), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
status := run(context.Background(), []string{"publication-attempts", "--record-dir", store, "--json"}, &stdout, &stderr)
|
||||||
|
if status != 1 || stderr.Len() != 0 {
|
||||||
|
t.Fatalf("partial discovery status = %d, stdout = %q, stderr = %q", status, stdout.String(), stderr.String())
|
||||||
|
}
|
||||||
|
output := stdout.String()
|
||||||
|
if strings.Contains(output, "cli-user") || strings.Contains(output, "cli-secret") || !strings.Contains(output, "https://redacted@example.test/repository.git") {
|
||||||
|
t.Fatalf("structured discovery exposed or failed to redact endpoint credentials: %q", output)
|
||||||
|
}
|
||||||
|
var listed map[string]any
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &listed); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
attempts, ok := listed["attempts"].([]any)
|
||||||
|
if !ok || len(attempts) != 1 {
|
||||||
|
t.Fatalf("attempts = %#v", listed["attempts"])
|
||||||
|
}
|
||||||
|
diagnostics, ok := listed["diagnostics"].([]any)
|
||||||
|
if !ok || len(diagnostics) != 1 {
|
||||||
|
t.Fatalf("diagnostics = %#v", listed["diagnostics"])
|
||||||
|
}
|
||||||
|
if listed["schema_version"] != float64(1) || listed["command"] != "publication-attempts" || listed["diagnostic"] != nil {
|
||||||
|
t.Fatalf("listing document = %#v", listed)
|
||||||
|
}
|
||||||
|
entry := attempts[0].(map[string]any)
|
||||||
|
if entry["record_path"] == "" {
|
||||||
|
t.Fatalf("attempt is missing record_path: %#v", entry)
|
||||||
|
}
|
||||||
|
entryEvidence := entry["evidence"].(map[string]any)
|
||||||
|
if entryEvidence["module_path"] != evidence.ModulePath || entryEvidence["recorded_at"] != evidence.RecordedAt {
|
||||||
|
t.Fatalf("attempt evidence = %#v", entryEvidence)
|
||||||
|
}
|
||||||
|
diagnostic := diagnostics[0].(map[string]any)
|
||||||
|
if diagnostic["kind"] != "record_format" || diagnostic["record_path"] != brokenPath {
|
||||||
|
t.Fatalf("record diagnostic = %#v", diagnostic)
|
||||||
|
}
|
||||||
|
|
||||||
|
emptyStore := filepath.Join(t.TempDir(), "empty store")
|
||||||
|
if err := os.Mkdir(emptyStore, 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var emptyOut, emptyErr bytes.Buffer
|
||||||
|
status = run(context.Background(), []string{"publication-attempts", "--record-dir", emptyStore, "--json"}, &emptyOut, &emptyErr)
|
||||||
|
if status != 0 || emptyErr.Len() != 0 {
|
||||||
|
t.Fatalf("empty discovery status = %d, stdout = %q, stderr = %q", status, emptyOut.String(), emptyErr.String())
|
||||||
|
}
|
||||||
|
var empty map[string]any
|
||||||
|
if err := json.Unmarshal(emptyOut.Bytes(), &empty); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(empty["attempts"].([]any)) != 0 || len(empty["diagnostics"].([]any)) != 0 || empty["diagnostic"] != nil {
|
||||||
|
t.Fatalf("empty listing document = %#v", empty)
|
||||||
|
}
|
||||||
|
|
||||||
|
var failedOut, failedErr bytes.Buffer
|
||||||
|
status = run(context.Background(), []string{"publication-attempts", "--record-dir", filepath.Join(emptyStore, "missing"), "--json"}, &failedOut, &failedErr)
|
||||||
|
if status != 1 || failedErr.Len() != 0 {
|
||||||
|
t.Fatalf("enumeration failure status = %d, stdout = %q, stderr = %q", status, failedOut.String(), failedErr.String())
|
||||||
|
}
|
||||||
|
var failed map[string]any
|
||||||
|
if err := json.Unmarshal(failedOut.Bytes(), &failed); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
failedDiagnostic, ok := failed["diagnostic"].(map[string]any)
|
||||||
|
if !ok || failedDiagnostic["kind"] != "storage_enumeration" {
|
||||||
|
t.Fatalf("enumeration failure JSON = %#v", failed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecoveryArgumentErrorsUseExistingUsagePath(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
status := run(context.Background(), []string{"reconcile", "--json"}, &stdout, &stderr)
|
||||||
|
if status != 2 || stdout.Len() != 0 || !strings.Contains(stderr.String(), "reconcile requires --record FILE") {
|
||||||
|
t.Fatalf("argument failure status = %d, stdout = %q, stderr = %q", status, stdout.String(), stderr.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(stdout.String(), "schema_version") || strings.Contains(stderr.String(), fmt.Sprint("\"command\"")) {
|
||||||
|
t.Fatalf("argument failure unexpectedly used structured output: stdout=%q stderr=%q", stdout.String(), stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -79,9 +79,10 @@ func TestReleaseCandidate(t *testing.T) {
|
|||||||
"product": "ReleaseKit",
|
"product": "ReleaseKit",
|
||||||
"checks": []map[string]any{{"args": []string{"sh", "-c", "if [ -n \"$RELEASEKIT_TEST_ARM_GIT_SHIM\" ]; then : > \"$RELEASEKIT_TEST_ARM_GIT_SHIM\"; fi"}}},
|
"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{
|
"delivery": map[string]any{
|
||||||
"base_url": "https://gitea.maximumdirect.net",
|
"forge": "gitea",
|
||||||
"owner": "eric",
|
"base_url": "https://forge.maximumdirect.net",
|
||||||
"repository": "ReleaseKit",
|
"owner": "base-layer",
|
||||||
|
"repository": "releasekit",
|
||||||
"token_env": "RELEASEKIT_GITEA_TOKEN",
|
"token_env": "RELEASEKIT_GITEA_TOKEN",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli"
|
"go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestReleaseKitSourceDeliveryWorkflowFromTaggedCheckout(t *testing.T) {
|
func TestReleaseKitSourceDeliveryWorkflowFromTaggedCheckout(t *testing.T) {
|
||||||
@@ -24,7 +24,7 @@ func TestReleaseKitSourceDeliveryWorkflowFromTaggedCheckout(t *testing.T) {
|
|||||||
if profile.Build != nil && len(profile.Build.Targets) != 0 {
|
if profile.Build != nil && len(profile.Build.Targets) != 0 {
|
||||||
t.Fatalf("ReleaseKit profile declares binary targets: %#v", profile.Build.Targets)
|
t.Fatalf("ReleaseKit profile declares binary targets: %#v", profile.Build.Targets)
|
||||||
}
|
}
|
||||||
if profile.Delivery == nil || profile.Delivery.BaseURL != "https://gitea.maximumdirect.net" || profile.Delivery.Owner != "eric" || profile.Delivery.Repository != "ReleaseKit" || profile.Delivery.TokenEnv != "RELEASEKIT_GITEA_TOKEN" {
|
if profile.Delivery == nil || profile.Delivery.BaseURL != "https://forge.maximumdirect.net" || profile.Delivery.Owner != "base-layer" || profile.Delivery.Repository != "releasekit" || profile.Delivery.TokenEnv != "RELEASEKIT_GITEA_TOKEN" {
|
||||||
t.Fatalf("ReleaseKit delivery profile = %#v", profile.Delivery)
|
t.Fatalf("ReleaseKit delivery profile = %#v", profile.Delivery)
|
||||||
}
|
}
|
||||||
repository := filepath.Join(t.TempDir(), "tagged ReleaseKit")
|
repository := filepath.Join(t.TempDir(), "tagged ReleaseKit")
|
||||||
@@ -37,9 +37,9 @@ func TestReleaseKitSourceDeliveryWorkflowFromTaggedCheckout(t *testing.T) {
|
|||||||
|
|
||||||
const version = "v1.2.3"
|
const version = "v1.2.3"
|
||||||
note := "# ReleaseKit v1.2.3\n\nSource-only delivery fixture.\n\n" +
|
note := "# ReleaseKit v1.2.3\n\nSource-only delivery fixture.\n\n" +
|
||||||
"[COPYRIGHT](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v1.2.3/COPYRIGHT) |\n" +
|
"[COPYRIGHT](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v1.2.3/COPYRIGHT) |\n" +
|
||||||
"[LICENSE](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v1.2.3/LICENSE) |\n" +
|
"[LICENSE](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v1.2.3/LICENSE) |\n" +
|
||||||
"[ATTRIBUTION](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v1.2.3/docs/oss/attribution.md)\n"
|
"[ATTRIBUTION](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v1.2.3/docs/oss/attribution.md)\n"
|
||||||
notePath := filepath.Join(repository, "docs", "releases", version+".md")
|
notePath := filepath.Join(repository, "docs", "releases", version+".md")
|
||||||
if err := os.MkdirAll(filepath.Dir(notePath), 0o700); err != nil {
|
if err := os.MkdirAll(filepath.Dir(notePath), 0o700); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -55,7 +55,7 @@ func TestReleaseKitSourceDeliveryWorkflowFromTaggedCheckout(t *testing.T) {
|
|||||||
defer server.Close()
|
defer server.Close()
|
||||||
writeDeliveryProfile(t, repository, cli.Settings{
|
writeDeliveryProfile(t, repository, cli.Settings{
|
||||||
Product: "ReleaseKit",
|
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") })
|
state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
|
||||||
cliGitRun(t, repository, "tag", "-a", version, "-m", "ReleaseKit v1.2.3")
|
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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli"
|
"go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestWoodpeckerTagDeliveryTemplate(t *testing.T) {
|
func TestWoodpeckerTagDeliveryTemplate(t *testing.T) {
|
||||||
@@ -39,7 +39,7 @@ func TestWoodpeckerTagDeliveryTemplate(t *testing.T) {
|
|||||||
defer server.Close()
|
defer server.Close()
|
||||||
writeDeliveryProfile(t, repository, cli.Settings{
|
writeDeliveryProfile(t, repository, cli.Settings{
|
||||||
Product: "Example Product",
|
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)
|
commit := pushTaggedCLIRepository(t, repository)
|
||||||
state.update(func(state *deliveryServerData) { state.commit = commit })
|
state.update(func(state *deliveryServerData) { state.commit = commit })
|
||||||
@@ -70,7 +70,7 @@ func TestWoodpeckerTagDeliveryTemplate(t *testing.T) {
|
|||||||
BinaryName: "helper",
|
BinaryName: "helper",
|
||||||
Targets: []string{fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH)},
|
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)
|
commit := pushTaggedCLIRepository(t, repository)
|
||||||
state.update(func(state *deliveryServerData) { state.commit = commit })
|
state.update(func(state *deliveryServerData) { state.commit = commit })
|
||||||
@@ -205,7 +205,7 @@ func TestWoodpeckerTagDeliveryUsesTaggedModuleVersion(t *testing.T) {
|
|||||||
t.Run("older tag retry uses its committed module selection", func(t *testing.T) {
|
t.Run("older tag retry uses its committed module selection", func(t *testing.T) {
|
||||||
goDir, goLog := installGoBootstrapShim(t)
|
goDir, goLog := installGoBootstrapShim(t)
|
||||||
repository := newCLIRepository(t)
|
repository := newCLIRepository(t)
|
||||||
const module = "gitea.maximumdirect.net/eric/ReleaseKit"
|
const module = "go.maximumdirect.net/releasekit"
|
||||||
writeRepositoryFile(t, repository, "go.mod", "module example.com/cli-candidate\n\ngo 1.25.0\n\nrequire "+module+" v0.2.197\n")
|
writeRepositoryFile(t, repository, "go.mod", "module example.com/cli-candidate\n\ngo 1.25.0\n\nrequire "+module+" v0.2.197\n")
|
||||||
cliGitRun(t, repository, "add", "go.mod")
|
cliGitRun(t, repository, "add", "go.mod")
|
||||||
cliGitRun(t, repository, "commit", "-m", "select the earlier ReleaseKit version")
|
cliGitRun(t, repository, "commit", "-m", "select the earlier ReleaseKit version")
|
||||||
@@ -236,10 +236,30 @@ func TestWoodpeckerTagDeliveryUsesTaggedModuleVersion(t *testing.T) {
|
|||||||
assertToolOnlyChecksums(t, sumLog)
|
assertToolOnlyChecksums(t, sumLog)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("vendored tool dependency", func(t *testing.T) {
|
||||||
|
goDir, goLog := installGoBootstrapShim(t)
|
||||||
|
repository := newCLIRepository(t)
|
||||||
|
const module = "go.maximumdirect.net/releasekit"
|
||||||
|
writeRepositoryFile(t, repository, "go.mod", "module example.com/cli-candidate\n\ngo 1.25.0\n\nrequire "+module+" v0.2.197\n\ntool "+module+"/cmd/releasekit\n")
|
||||||
|
env := templateEnvironment(repository, "", goDir, goLog)
|
||||||
|
useSyntheticModuleProxy(t, env, "file://"+filepath.ToSlash(proxy))
|
||||||
|
runWoodpeckerBlock(t, "go mod tidy\ngo mod vendor", repository, env)
|
||||||
|
cliGitRun(t, repository, "add", "go.mod", "go.sum", "vendor")
|
||||||
|
cliGitRun(t, repository, "commit", "-m", "vendor the selected release tool")
|
||||||
|
env["CI_COMMIT_SHA"] = pushTaggedCLIRepository(t, repository)
|
||||||
|
moduleBefore := readWoodpeckerFixtureFile(t, filepath.Join(repository, "go.mod"))
|
||||||
|
sumBefore := readWoodpeckerFixtureFile(t, filepath.Join(repository, "go.sum"))
|
||||||
|
output := runWoodpeckerBlock(t, command, repository, env)
|
||||||
|
if !strings.Contains(output, "tool-version=v0.2.197 helper=tool-only dependency") {
|
||||||
|
t.Fatalf("vendored tool was not selected: %q", output)
|
||||||
|
}
|
||||||
|
assertModuleBootstrap(t, repository, goLog, moduleBefore, sumBefore)
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("versioned replacement selects its declared source", func(t *testing.T) {
|
t.Run("versioned replacement selects its declared source", func(t *testing.T) {
|
||||||
goDir, goLog := installGoBootstrapShim(t)
|
goDir, goLog := installGoBootstrapShim(t)
|
||||||
repository := newCLIRepository(t)
|
repository := newCLIRepository(t)
|
||||||
const module = "gitea.maximumdirect.net/eric/ReleaseKit"
|
const module = "go.maximumdirect.net/releasekit"
|
||||||
goMod := "module example.com/cli-candidate\n\ngo 1.25.0\n\nrequire " + module + " v0.2.197\n\nreplace " + module + " v0.2.197 => " + module + " v0.2.198\n"
|
goMod := "module example.com/cli-candidate\n\ngo 1.25.0\n\nrequire " + module + " v0.2.197\n\nreplace " + module + " v0.2.197 => " + module + " v0.2.198\n"
|
||||||
writeRepositoryFile(t, repository, "go.mod", goMod)
|
writeRepositoryFile(t, repository, "go.mod", goMod)
|
||||||
cliGitRun(t, repository, "add", "go.mod")
|
cliGitRun(t, repository, "add", "go.mod")
|
||||||
@@ -400,7 +420,7 @@ func installGoBootstrapShim(t *testing.T) (string, string) {
|
|||||||
printf 'cwd=%s GOWORK=%s GOTOOLCHAIN=%s %s\n' "$PWD" "$GOWORK" "$GOTOOLCHAIN" "$*" >> "$RELEASEKIT_GO_LOG"
|
printf 'cwd=%s GOWORK=%s GOTOOLCHAIN=%s %s\n' "$PWD" "$GOWORK" "$GOTOOLCHAIN" "$*" >> "$RELEASEKIT_GO_LOG"
|
||||||
"$RELEASEKIT_REAL_GO" "$@"
|
"$RELEASEKIT_REAL_GO" "$@"
|
||||||
status=$?
|
status=$?
|
||||||
if [ "$status" -eq 0 ] && [ "$1" = "build" ] && [ -n "${RELEASEKIT_SUM_LOG:-}" ]; then
|
if [ "$status" -eq 0 ] && [ "$1" = "tool" ] && [ -n "${RELEASEKIT_SUM_LOG:-}" ]; then
|
||||||
modfile=
|
modfile=
|
||||||
for argument in "$@"; do
|
for argument in "$@"; do
|
||||||
case "$argument" in -modfile=*) modfile=${argument#-modfile=} ;; esac
|
case "$argument" in -modfile=*) modfile=${argument#-modfile=} ;; esac
|
||||||
@@ -433,20 +453,12 @@ case "$1" in
|
|||||||
list)
|
list)
|
||||||
printf '%s v0.2.0\n' "$RELEASEKIT_MODULE"
|
printf '%s v0.2.0\n' "$RELEASEKIT_MODULE"
|
||||||
;;
|
;;
|
||||||
build)
|
mod)
|
||||||
output=
|
;;
|
||||||
previous=
|
tool)
|
||||||
for argument in "$@"; do
|
shift 3
|
||||||
if [ "$previous" = "-o" ]; then output=$argument; break; fi
|
printf '<%s>\n' "$@" > "$RELEASEKIT_TOOL_ARGS_LOG"
|
||||||
previous=$argument
|
exit "${RELEASEKIT_TOOL_EXIT:-0}"
|
||||||
done
|
|
||||||
[ -n "$output" ] || { echo 'go build omitted output path' >&2; exit 90; }
|
|
||||||
cat > "$output" <<'TOOL'
|
|
||||||
#!/bin/sh
|
|
||||||
printf '<%s>\n' "$@" > "$RELEASEKIT_TOOL_ARGS_LOG"
|
|
||||||
exit "${RELEASEKIT_TOOL_EXIT:-0}"
|
|
||||||
TOOL
|
|
||||||
chmod +x "$output"
|
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "unexpected Go command: $1" >&2
|
echo "unexpected Go command: $1" >&2
|
||||||
@@ -480,7 +492,7 @@ func templateEnvironment(repository, commit, shimDir, goLog string) map[string]s
|
|||||||
"CI_COMMIT_SHA": commit,
|
"CI_COMMIT_SHA": commit,
|
||||||
"CI_COMMIT_TAG": "v1.2.3",
|
"CI_COMMIT_TAG": "v1.2.3",
|
||||||
"RELEASEKIT_GITEA_TOKEN": "fixture-token",
|
"RELEASEKIT_GITEA_TOKEN": "fixture-token",
|
||||||
"RELEASEKIT_MODULE": "gitea.maximumdirect.net/eric/ReleaseKit",
|
"RELEASEKIT_MODULE": "go.maximumdirect.net/releasekit",
|
||||||
"RELEASEKIT_REAL_GO": realGo,
|
"RELEASEKIT_REAL_GO": realGo,
|
||||||
"RELEASEKIT_GO_LOG": goLog,
|
"RELEASEKIT_GO_LOG": goLog,
|
||||||
"GOTOOLCHAIN": "local",
|
"GOTOOLCHAIN": "local",
|
||||||
@@ -513,7 +525,7 @@ func executeWoodpeckerBlock(block, repository string, environment map[string]str
|
|||||||
|
|
||||||
func selectReleaseKitModule(t *testing.T, repository, releaseKitSource string, includeSum bool) {
|
func selectReleaseKitModule(t *testing.T, repository, releaseKitSource string, includeSum bool) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
const module = "gitea.maximumdirect.net/eric/ReleaseKit"
|
const module = "go.maximumdirect.net/releasekit"
|
||||||
goMod := fmt.Sprintf("module example.com/tagged-app\n\ngo 1.26.0\n\nrequire %s v0.2.0\n\nreplace %s => %s\n", module, module, strconv.Quote(releaseKitSource))
|
goMod := fmt.Sprintf("module example.com/tagged-app\n\ngo 1.26.0\n\nrequire %s v0.2.0\n\nreplace %s => %s\n", module, module, strconv.Quote(releaseKitSource))
|
||||||
writeRepositoryFile(t, repository, "go.mod", goMod)
|
writeRepositoryFile(t, repository, "go.mod", goMod)
|
||||||
cliGitRun(t, repository, "add", "go.mod")
|
cliGitRun(t, repository, "add", "go.mod")
|
||||||
@@ -526,13 +538,13 @@ func selectReleaseKitModule(t *testing.T, repository, releaseKitSource string, i
|
|||||||
|
|
||||||
func writeReleaseKitModuleProxy(t *testing.T) string {
|
func writeReleaseKitModuleProxy(t *testing.T) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
const module = "gitea.maximumdirect.net/eric/ReleaseKit"
|
const module = "go.maximumdirect.net/releasekit"
|
||||||
const toolDependency = "example.com/releasekit-helper"
|
const toolDependency = "example.com/releasekit-helper"
|
||||||
proxyDir := t.TempDir()
|
proxyDir := t.TempDir()
|
||||||
toolModule := "module " + module + "\n\ngo 1.25.0\n\nrequire " + toolDependency + " v1.0.0\n"
|
toolModule := "module " + module + "\n\ngo 1.25.0\n\nrequire " + toolDependency + " v1.0.0\n"
|
||||||
for _, version := range []string{"v0.2.197", "v0.2.198"} {
|
for _, version := range []string{"v0.2.197", "v0.2.198"} {
|
||||||
files := map[string]string{
|
files := map[string]string{
|
||||||
"cmd/releasekit/main.go": "package main\n\nimport (\"fmt\"; \"os\"; tooldep \"example.com/releasekit-helper\")\n\nfunc main() {\n\tfmt.Println(\"tool-version=" + version + " helper=\" + tooldep.Version)\n\tfmt.Printf(\"tool-args=%q\\n\", os.Args[1:])\n}\n",
|
"cmd/releasekit/main.go": "package main\n\nimport (\"fmt\"; \"os\"; \"strconv\"; tooldep \"example.com/releasekit-helper\")\n\nfunc main() {\n\tfmt.Println(\"tool-version=" + version + " helper=\" + tooldep.Version)\n\tfmt.Printf(\"tool-args=%q\\n\", os.Args[1:])\n\tstatus, _ := strconv.Atoi(os.Getenv(\"RELEASEKIT_TOOL_EXIT\"))\n\tos.Exit(status)\n}\n",
|
||||||
}
|
}
|
||||||
writeModuleProxyVersion(t, proxyDir, module, version, toolModule, files)
|
writeModuleProxyVersion(t, proxyDir, module, version, toolModule, files)
|
||||||
}
|
}
|
||||||
@@ -633,16 +645,16 @@ func assertGoSelection(t *testing.T, repository, goLog string) {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
log := readWoodpeckerFixtureFile(t, goLog)
|
log := readWoodpeckerFixtureFile(t, goLog)
|
||||||
list := "list -mod=mod -modfile="
|
list := "list -mod=mod -modfile="
|
||||||
build := "build -mod=mod -modfile="
|
tool := "tool -modfile="
|
||||||
module := "gitea.maximumdirect.net/eric/ReleaseKit"
|
module := "go.maximumdirect.net/releasekit"
|
||||||
if !strings.Contains(log, "GOWORK=off GOTOOLCHAIN=local") || !strings.Contains(log, list) || !strings.Contains(log, "-m "+module) {
|
if !strings.Contains(log, "GOWORK=off GOTOOLCHAIN=local") || !strings.Contains(log, list) || !strings.Contains(log, "-m "+module) {
|
||||||
t.Fatalf("Go module selection command is missing required settings: %q", log)
|
t.Fatalf("Go module selection command is missing required settings: %q", log)
|
||||||
}
|
}
|
||||||
if !strings.Contains(log, build) || !strings.Contains(log, module+"/cmd/releasekit") || strings.Contains(log, "@v0.2.0") {
|
if !strings.Contains(log, tool) || !strings.Contains(log, " releasekit ci-deliver ") || strings.Contains(log, "@v0.2.0") {
|
||||||
t.Fatalf("Go build did not use the selected module without a version suffix: %q", log)
|
t.Fatalf("Go tool did not run the module-selected ReleaseKit: %q", log)
|
||||||
}
|
}
|
||||||
if strings.Index(log, list) > strings.Index(log, build) {
|
if strings.Index(log, list) > strings.Index(log, tool) {
|
||||||
t.Fatalf("Go build ran before module selection: %q", log)
|
t.Fatalf("Go tool ran before module selection: %q", log)
|
||||||
}
|
}
|
||||||
var modfile string
|
var modfile string
|
||||||
for _, field := range strings.Fields(log) {
|
for _, field := range strings.Fields(log) {
|
||||||
|
|||||||
155
delivery.go
155
delivery.go
@@ -2,11 +2,15 @@ package releasekit
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"strings"
|
||||||
|
|
||||||
internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build"
|
internalbuild "go.maximumdirect.net/releasekit/internal/build"
|
||||||
internaldelivery "gitea.maximumdirect.net/eric/ReleaseKit/internal/delivery"
|
internaldelivery "go.maximumdirect.net/releasekit/internal/delivery"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea"
|
"go.maximumdirect.net/releasekit/internal/forge"
|
||||||
|
"go.maximumdirect.net/releasekit/internal/gitea"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DeliveryState describes the result of one delivery invocation.
|
// DeliveryState describes the result of one delivery invocation.
|
||||||
@@ -25,25 +29,73 @@ const (
|
|||||||
DeliveryUnknown DeliveryState = "unknown"
|
DeliveryUnknown DeliveryState = "unknown"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GiteaDestination supplies the hosted repository and explicit access token.
|
// Forge selects the hosted delivery backend. A delivery request must select a
|
||||||
type GiteaDestination struct {
|
// 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
|
BaseURL string
|
||||||
Owner string
|
Owner string
|
||||||
Repository string
|
Repository string
|
||||||
Token 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.
|
// DeliveryAsset identifies a hosted asset by filename and SHA-256 digest.
|
||||||
type DeliveryAsset struct {
|
type DeliveryAsset struct {
|
||||||
Filename string
|
Filename string
|
||||||
SHA256 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
|
// destination. Build selects a managed build; Prebuilt supplies caller-owned
|
||||||
// build output instead. They cannot both be set. Either may be nil for
|
// build output instead. They cannot both be set. The ordinary page-only request
|
||||||
// page-only delivery when both are nil. Build with no targets, or an empty
|
// leaves both nil. A Build with no targets or a matching Prebuilt result with
|
||||||
// Prebuilt result, also performs page-only delivery.
|
// 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 {
|
type DeliveryRequest struct {
|
||||||
RepositoryDir string // directory within the tagged source Git checkout.
|
RepositoryDir string // directory within the tagged source Git checkout.
|
||||||
// ModuleDir selects a repository-relative module directory; empty selects
|
// 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
|
// ExpectedTag optionally requires the full CI event tag to match the
|
||||||
// selected module's derived tag before building or making hosted requests.
|
// selected module's derived tag before building or making hosted requests.
|
||||||
ExpectedTag string
|
ExpectedTag string
|
||||||
Destination GiteaDestination
|
Destination DeliveryDestination
|
||||||
// Build selects binaries to build in temporary storage outside the checkout.
|
// Build selects binaries to build in temporary storage outside the checkout.
|
||||||
Build *BuildSpec
|
Build *BuildSpec
|
||||||
// Prebuilt supplies an existing build whose files remain caller-owned.
|
// 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
|
// 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
|
// current observations: an asset appears in at most one slice, and early
|
||||||
// failures may leave some declared assets unreported.
|
// failures may leave some declared assets unreported.
|
||||||
type DeliveryResult struct {
|
type DeliveryResult struct {
|
||||||
@@ -88,19 +141,22 @@ type DeliveryResult struct {
|
|||||||
Uncertain []DeliveryAsset
|
Uncertain []DeliveryAsset
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deliver verifies the selected local tag and hosted source, then creates or
|
// Deliver requires an explicit supported destination forge. It checks the local
|
||||||
// finds its release page and adds any missing declared build assets. Build
|
// request and source, then performs read-only credential preflight before a
|
||||||
// requests are compiled into temporary storage that Deliver removes on return.
|
// managed build or full artifact verification. It verifies the selected local
|
||||||
// Prebuilt artifacts remain caller-owned and are not removed. Both paths
|
// tag and hosted source, then creates or finds its release page and adds any
|
||||||
// validate source identity and artifact contents before hosted writes.
|
// 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.
|
// Module selection and declaration links resolve from the captured commit.
|
||||||
// Page-only delivery can use a sparse checkout with available committed objects;
|
// Page-only delivery can use a sparse checkout with available committed objects;
|
||||||
// managed builds require their selected source files in the working tree.
|
// 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) {
|
func Deliver(ctx context.Context, request DeliveryRequest) (DeliveryResult, error) {
|
||||||
result := DeliveryResult{Version: request.Version, State: DeliveryNotAttempted}
|
result := DeliveryResult{Version: request.Version, State: DeliveryNotAttempted}
|
||||||
destination := gitea.Destination{
|
backend, err := deliveryBackend(request.Destination)
|
||||||
BaseURL: request.Destination.BaseURL, Owner: request.Destination.Owner,
|
if err != nil {
|
||||||
Repository: request.Destination.Repository, Token: request.Destination.Token,
|
return result, err
|
||||||
}
|
}
|
||||||
var prebuilt *internalbuild.Result
|
var prebuilt *internalbuild.Result
|
||||||
if request.Prebuilt != nil {
|
if request.Prebuilt != nil {
|
||||||
@@ -112,7 +168,7 @@ func Deliver(ctx context.Context, request DeliveryRequest) (DeliveryResult, erro
|
|||||||
Product: request.Product,
|
Product: request.Product,
|
||||||
Version: request.Version,
|
Version: request.Version,
|
||||||
ExpectedTag: request.ExpectedTag,
|
ExpectedTag: request.ExpectedTag,
|
||||||
Destination: destination,
|
Backend: backend,
|
||||||
Build: internalBuildSpec(request.Build),
|
Build: internalBuildSpec(request.Build),
|
||||||
Prebuilt: prebuilt,
|
Prebuilt: prebuilt,
|
||||||
Output: request.Output,
|
Output: request.Output,
|
||||||
@@ -129,10 +185,65 @@ func Deliver(ctx context.Context, request DeliveryRequest) (DeliveryResult, erro
|
|||||||
result.Pending = deliveryAssets(internalResult.Pending)
|
result.Pending = deliveryAssets(internalResult.Pending)
|
||||||
result.Conflicting = deliveryAssets(internalResult.Conflicting)
|
result.Conflicting = deliveryAssets(internalResult.Conflicting)
|
||||||
result.Uncertain = deliveryAssets(internalResult.Uncertain)
|
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))
|
result := make([]DeliveryAsset, 0, len(assets))
|
||||||
for _, asset := range assets {
|
for _, asset := range assets {
|
||||||
result = append(result, DeliveryAsset{Filename: asset.Filename, SHA256: asset.SHA256})
|
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 "gitea.maximumdirect.net/eric/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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
295
delivery_test.go
295
delivery_test.go
@@ -16,9 +16,178 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
releasekit "gitea.maximumdirect.net/eric/ReleaseKit"
|
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) {
|
func TestDeliverManagedBuildUploadsArtifactsAndCleansTemporaryOutput(t *testing.T) {
|
||||||
repository := newTaggedDeliveryConsumer(t)
|
repository := newTaggedDeliveryConsumer(t)
|
||||||
temporaryRoot := t.TempDir()
|
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) {
|
func TestDeliverRejectsSameSizePrebuiltTamperingBeforeHostedWrites(t *testing.T) {
|
||||||
repository := newTaggedDeliveryConsumer(t)
|
repository := newTaggedDeliveryConsumer(t)
|
||||||
prebuilt, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
|
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 {
|
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)
|
t.Fatalf("Deliver() = %#v, %v; want local asset verification failure", result, err)
|
||||||
}
|
}
|
||||||
if remote.snapshot().requests != 0 {
|
if remote.snapshot().writes != 0 {
|
||||||
t.Fatalf("hosted requests = %d, want none before local verification", remote.snapshot().requests)
|
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) {
|
func TestDeliverUsesCommittedModuleIdentityForSeparatePages(t *testing.T) {
|
||||||
repository := newTwoModuleDeliveryConsumer(t)
|
repository := newTwoModuleDeliveryConsumer(t)
|
||||||
server, remote := newFacadeDeliveryServer(t, repository)
|
server, remote := newFacadeDeliveryServer(t, repository)
|
||||||
@@ -323,7 +543,7 @@ func newTwoModuleDeliveryConsumer(t *testing.T) string {
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo"
|
"go.maximumdirect.net/releasekit/buildinfo"
|
||||||
)
|
)
|
||||||
func main() { if len(os.Args) == 2 && os.Args[1] == "version" { fmt.Println("fixture", buildinfo.Version()) } }
|
func main() { if len(os.Args) == 2 && os.Args[1] == "version" { fmt.Println("fixture", buildinfo.Version()) } }
|
||||||
`)
|
`)
|
||||||
@@ -463,8 +683,8 @@ func TestDeliverManagedBuildRejectsCheckoutMutationBeforeHostedWrites(t *testing
|
|||||||
if err == nil || !strings.Contains(err.Error(), "build source") || result.State != releasekit.DeliveryNotAttempted {
|
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)
|
t.Fatalf("Deliver() = %#v, %v; want build-source eligibility failure", result, err)
|
||||||
}
|
}
|
||||||
if remote.snapshot().requests != 0 {
|
if remote.snapshot().writes != 0 {
|
||||||
t.Fatalf("hosted requests = %d, want none", remote.snapshot().requests)
|
t.Fatalf("hosted writes = %d, want none after preflight", remote.snapshot().writes)
|
||||||
}
|
}
|
||||||
if entries, err := os.ReadDir(temporaryRoot); err != nil || len(entries) != 0 {
|
if entries, err := os.ReadDir(temporaryRoot); err != nil || len(entries) != 0 {
|
||||||
t.Fatalf("ineligible managed build output remains: entries=%v error=%v", entries, err)
|
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 {
|
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)
|
t.Fatalf("Deliver() = %#v, %v; want managed build failure before hosted writes", result, err)
|
||||||
}
|
}
|
||||||
if remote.snapshot().requests != 0 {
|
if remote.snapshot().writes != 0 {
|
||||||
t.Fatalf("hosted requests = %d, want none", remote.snapshot().requests)
|
t.Fatalf("hosted writes = %d, want none after preflight", remote.snapshot().writes)
|
||||||
}
|
}
|
||||||
if entries, err := os.ReadDir(temporaryRoot); err != nil || len(entries) != 0 {
|
if entries, err := os.ReadDir(temporaryRoot); err != nil || len(entries) != 0 {
|
||||||
t.Fatalf("failed managed build output remains: entries=%v error=%v", entries, err)
|
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 {
|
if !errors.Is(err, context.Canceled) || result.State != releasekit.DeliveryNotAttempted {
|
||||||
t.Fatalf("Deliver() = %#v, %v; want cancellation before hosted writes", result, err)
|
t.Fatalf("Deliver() = %#v, %v; want cancellation before hosted writes", result, err)
|
||||||
}
|
}
|
||||||
if remote.snapshot().requests != 0 {
|
if remote.snapshot().writes != 0 {
|
||||||
t.Fatalf("hosted requests = %d, want none", remote.snapshot().requests)
|
t.Fatalf("hosted writes = %d, want none after preflight", remote.snapshot().writes)
|
||||||
}
|
}
|
||||||
if entries, err := os.ReadDir(temporaryRoot); err != nil || len(entries) != 0 {
|
if entries, err := os.ReadDir(temporaryRoot); err != nil || len(entries) != 0 {
|
||||||
t.Fatalf("canceled managed build output remains: entries=%v error=%v", entries, err)
|
t.Fatalf("canceled managed build output remains: entries=%v error=%v", entries, err)
|
||||||
@@ -564,7 +784,7 @@ func newTaggedDeliveryConsumerWithNote(t *testing.T, note string) string {
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo"
|
"go.maximumdirect.net/releasekit/buildinfo"
|
||||||
)
|
)
|
||||||
func main() { if len(os.Args) == 2 && os.Args[1] == "version" { fmt.Println("fixture", buildinfo.Version()) } }
|
func main() { if len(os.Args) == 2 && os.Args[1] == "version" { fmt.Println("fixture", buildinfo.Version()) } }
|
||||||
`)
|
`)
|
||||||
@@ -575,8 +795,8 @@ func main() { if len(os.Args) == 2 && os.Args[1] == "version" { fmt.Println("fix
|
|||||||
return repository
|
return repository
|
||||||
}
|
}
|
||||||
|
|
||||||
func facadeDeliveryDestination(baseURL string) releasekit.GiteaDestination {
|
func facadeDeliveryDestination(baseURL string) releasekit.DeliveryDestination {
|
||||||
return releasekit.GiteaDestination{BaseURL: baseURL, Owner: "owner", Repository: "example", Token: "fixture-token"}
|
return releasekit.DeliveryDestination{Forge: releasekit.ForgeGitea, BaseURL: baseURL, Owner: "owner", Repository: "example", Token: "fixture-token"}
|
||||||
}
|
}
|
||||||
|
|
||||||
type deliveryCancelWriter struct{ cancel context.CancelFunc }
|
type deliveryCancelWriter struct{ cancel context.CancelFunc }
|
||||||
@@ -586,6 +806,33 @@ func (writer deliveryCancelWriter) Write(content []byte) (int, error) {
|
|||||||
return len(content), nil
|
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 {
|
type facadeDeliveryServer struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
data facadeDeliveryData
|
data facadeDeliveryData
|
||||||
@@ -621,8 +868,11 @@ type facadeDeliveryData struct {
|
|||||||
tag string
|
tag string
|
||||||
version string
|
version string
|
||||||
noteBody string
|
noteBody string
|
||||||
|
createdBody string
|
||||||
pages map[string]bool
|
pages map[string]bool
|
||||||
requests int
|
requests int
|
||||||
|
writes int
|
||||||
|
omitReleaseID bool
|
||||||
assets map[string][]byte
|
assets map[string][]byte
|
||||||
url string
|
url string
|
||||||
}
|
}
|
||||||
@@ -642,11 +892,19 @@ func (state *facadeDeliveryServer) serveHTTP(w http.ResponseWriter, r *http.Requ
|
|||||||
data := &state.data
|
data := &state.data
|
||||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||||
data.requests++
|
data.requests++
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
data.writes++
|
||||||
|
}
|
||||||
if r.Header.Get("Authorization") != "token fixture-token" {
|
if r.Header.Get("Authorization") != "token fixture-token" {
|
||||||
data.t.Errorf("authorization = %q", r.Header.Get("Authorization"))
|
data.t.Errorf("authorization = %q", r.Header.Get("Authorization"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
switch r.URL.EscapedPath() {
|
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):
|
case "/api/v1/repos/owner/example/tags/" + url.PathEscape(data.tag):
|
||||||
_, _ = fmt.Fprintf(w, `{"name":%q,"commit":{"sha":%q}}`, data.tag, data.commit)
|
_, _ = fmt.Fprintf(w, `{"name":%q,"commit":{"sha":%q}}`, data.tag, data.commit)
|
||||||
case "/api/v1/repos/owner/example/releases/tags/" + url.PathEscape(data.tag):
|
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 {
|
if payload.Tag != data.tag || payload.Target != data.commit || payload.Name != "Fixture "+data.version {
|
||||||
data.t.Errorf("release payload = %#v", payload)
|
data.t.Errorf("release payload = %#v", payload)
|
||||||
}
|
}
|
||||||
|
data.createdBody = payload.Body
|
||||||
data.pages[data.tag] = true
|
data.pages[data.tag] = true
|
||||||
state.writeRelease(w)
|
state.writeRelease(w)
|
||||||
case "/api/v1/repos/owner/example/releases/10/assets":
|
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.
|
// The request handler holds state.mu.
|
||||||
data := &state.data
|
data := &state.data
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
release := map[string]any{
|
||||||
"id": 10, "html_url": data.url + "/releases/10", "tag_name": data.tag,
|
"html_url": data.url + "/releases/10", "tag_name": data.tag,
|
||||||
"target_commitish": data.commit, "name": "Fixture " + data.version,
|
"target_commitish": data.commit, "name": "Fixture " + data.version,
|
||||||
"body": data.noteBody, "draft": false, "prerelease": false,
|
"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 {
|
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.
|
||||||
129
docs/cli.md
129
docs/cli.md
@@ -1,6 +1,6 @@
|
|||||||
# Command line
|
# Command line
|
||||||
|
|
||||||
ReleaseKit currently provides six commands. The exit statuses below belong
|
ReleaseKit currently provides eight commands. The exit statuses below belong
|
||||||
to the native executable. `go run` examples are convenient for source execution,
|
to the native executable. `go run` examples are convenient for source execution,
|
||||||
but the Go driver maps a nonzero child exit status to 1. Use the built executable
|
but the Go driver maps a nonzero child exit status to 1. Use the built executable
|
||||||
or `./scripts/release` for publication when distinct statuses are needed.
|
or `./scripts/release` for publication when distinct statuses are needed.
|
||||||
@@ -75,7 +75,7 @@ Unsupported argument sequences print usage to standard error.
|
|||||||
Publish a source tag:
|
Publish a source tag:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go run ./cmd/releasekit publish [--repo DIR] [--config FILE] [--expected-module DIR] VERSION
|
go run ./cmd/releasekit publish [--repo DIR] [--config FILE] [--expected-module DIR] [--record-dir DIR] VERSION
|
||||||
```
|
```
|
||||||
|
|
||||||
`publish` reads the same product, remote, validation, and additional-check
|
`publish` reads the same product, remote, validation, and additional-check
|
||||||
@@ -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
|
version, full tag, candidate commit, and tag object
|
||||||
on success. Validation
|
on success. Validation
|
||||||
progress and failed publication state, including the captured publication
|
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
|
cancels the active operation; if it arrives during a push, the library's
|
||||||
bounded reconciliation determines whether the exact tag was accepted.
|
bounded reconciliation determines whether the exact tag was accepted.
|
||||||
Shared validation failures use the same check and finding presentation as
|
Shared validation failures use the same check and finding presentation as
|
||||||
@@ -137,13 +138,76 @@ mismatches fail during preflight before Go validation or tag creation. Git URL
|
|||||||
rewrites are resolved first, and the push URL is used when fetch and push URLs
|
rewrites are resolved first, and the push URL is used when fetch and push URLs
|
||||||
differ. `check` ignores this field.
|
differ. `check` ignores this field.
|
||||||
|
|
||||||
|
The command automatically saves available publication evidence in the repository's
|
||||||
|
private attempt store. Use `--record-dir DIR` to select another directory; a
|
||||||
|
relative override is resolved from the source repository root. Standard error
|
||||||
|
reports the absolute saved record path or a recording warning separately from
|
||||||
|
the publication result. Recording failure does not change publication status.
|
||||||
|
When publication fails and a record was saved, the CLI also prints a copyable
|
||||||
|
`reconcile --record` command for that exact record. The command selects the
|
||||||
|
consumer module's registered Go tool when available. API-only consumers receive
|
||||||
|
a Go-tool invocation with durable private module files; see
|
||||||
|
[recovery storage](consumers/publish.md#retain-and-reconcile-publication-evidence).
|
||||||
|
Consumers without a module-selected ReleaseKit dependency use the running
|
||||||
|
executable's absolute path. A module-preparation failure reports a warning and
|
||||||
|
uses a persistent native executable when available. Recognized temporary Go-build
|
||||||
|
executables provide no native fallback after their driver exits. Command
|
||||||
|
preparation has its own bounded context after a failed publication, so publication
|
||||||
|
cancellation permits preparing the later invocation. Each entry point preserves
|
||||||
|
the recovery exit status.
|
||||||
|
If the selected entry point becomes unavailable, use another supported ReleaseKit
|
||||||
|
CLI with the saved record.
|
||||||
|
|
||||||
The command returns status 0 for confirmed publication, 1 for a known
|
The command returns status 0 for confirmed publication, 1 for a known
|
||||||
publication failure or conflict, 2 for usage, profile, or invalid-version
|
publication failure or conflict, 2 for usage, profile, or invalid-version
|
||||||
errors, and 3 when the push outcome remains unknown. Unknown status must be
|
errors, and 3 when the push outcome remains unknown. Unknown status must be
|
||||||
resolved against the authoritative remote before retrying. The
|
resolved with the explicit-record recovery operation below before retrying. The
|
||||||
[maintainer procedure](release.md) selects the repository profile and gives
|
[maintainer procedure](release.md) selects the repository profile and gives
|
||||||
the source-release workflow.
|
the source-release workflow.
|
||||||
|
|
||||||
|
## Recover a saved publication attempt or discover local attempts
|
||||||
|
|
||||||
|
```sh
|
||||||
|
releasekit reconcile --record FILE [--json]
|
||||||
|
releasekit publication-attempts [--repo DIR] [--record-dir DIR] [--json]
|
||||||
|
```
|
||||||
|
|
||||||
|
`reconcile` requires one explicitly selected record and reads a relative `FILE`
|
||||||
|
from the invoking directory. It observes the endpoint and tag stored in that
|
||||||
|
record; it does not load a profile, inspect the current candidate or local tag,
|
||||||
|
run checks, or change repository state. `publication-attempts` lists records in
|
||||||
|
the selected store without querying remotes or choosing an attempt. An omitted
|
||||||
|
`--repo` means the current directory. Its relative `--record-dir` is resolved
|
||||||
|
from the selected repository root; an absolute directory can be listed outside
|
||||||
|
a checkout. The selected directory must already exist. Discovery reports usable
|
||||||
|
records and per-record diagnostics together when some records cannot be read.
|
||||||
|
|
||||||
|
By default, both operations print human-readable output. `--json` writes one
|
||||||
|
structured document to standard output after valid arguments, including for
|
||||||
|
reconciliation and discovery failures. Recovery does not run candidate checks
|
||||||
|
or make hosted delivery requests. It reads the record's captured endpoint, while
|
||||||
|
using current supported transport settings for its recorded remote name.
|
||||||
|
|
||||||
|
| Recovery command result | Status |
|
||||||
|
| --- | --- |
|
||||||
|
| Reconciliation observes the intended publication | 0 |
|
||||||
|
| Reconciliation observes absence or conflict | 1 |
|
||||||
|
| Reconciliation cannot read or decode the selected record, or has an invocation error | 2 |
|
||||||
|
| Reconciliation remains unknown, including missing evidence or cancellation | 3 |
|
||||||
|
| Discovery completes without record diagnostics, including an existing empty store | 0 |
|
||||||
|
| Discovery is incomplete or fails, including cancellation | 1 |
|
||||||
|
| Discovery argument or usage error | 2 |
|
||||||
|
| Either command cannot write its JSON output | 1 |
|
||||||
|
|
||||||
|
A JSON write failure reports a local output diagnostic on standard error. This
|
||||||
|
status describes reporting failure and leaves the observed publication state
|
||||||
|
unchanged.
|
||||||
|
|
||||||
|
The CLI JSON schema and safe evidence fields are documented in the
|
||||||
|
[publication record format](integrations/publication-records.md). Recovery
|
||||||
|
guidance for storage, older evidence, and retry decisions is in the
|
||||||
|
[maintainer procedure](release.md#resolve-publication-failures).
|
||||||
|
|
||||||
Build selected application targets:
|
Build selected application targets:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -168,14 +232,17 @@ tree and does not perform candidate validation or remote publication. `check`
|
|||||||
and `publish` run the same configured build after their one candidate
|
and `publish` run the same configured build after their one candidate
|
||||||
validation, using temporary output outside the worktree.
|
validation, using temporary output outside the worktree.
|
||||||
|
|
||||||
Deliver a tagged source release to Gitea:
|
Deliver a tagged source release:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go run ./cmd/releasekit deliver [--repo DIR] [--config FILE] [--assets DIR] VERSION
|
go run ./cmd/releasekit deliver [--repo DIR] [--config FILE] [--assets DIR] VERSION
|
||||||
```
|
```
|
||||||
|
|
||||||
`deliver` requires a `delivery` profile object and its configured token
|
`deliver` requires a `delivery` profile object with an explicit
|
||||||
environment variable, which defaults to `RELEASEKIT_GITEA_TOKEN`. It verifies
|
`"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,
|
the local tag, clean tagged checkout, hosted tag, and committed release note,
|
||||||
then creates or finds the release page and reports delivery state separately
|
then creates or finds the release page and reports delivery state separately
|
||||||
from source publication. Historical lightweight tags and detached checkouts
|
from source publication. Historical lightweight tags and detached checkouts
|
||||||
@@ -194,16 +261,28 @@ Profiles without targets use page-only delivery
|
|||||||
and need no receipt, binary build, or manifest. `deliver` never runs candidate
|
and need no receipt, binary build, or manifest. `deliver` never runs candidate
|
||||||
checks or publishes a tag.
|
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
|
Use one `deliver` invocation for the normal workflow. To build without delivery
|
||||||
credentials, or to retain build output across retries, run `build --output DIR`
|
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
|
first and then run `deliver --assets DIR` with the token available. The ordinary
|
||||||
`deliver` command does not download application dependencies or change the
|
`deliver` command does not download application dependencies or change the
|
||||||
checkout.
|
checkout.
|
||||||
|
|
||||||
The command returns 0 for complete delivery, 1 for a known failure, conflict,
|
The command returns 0 for complete delivery without a local error, 1 for an
|
||||||
or partial delivery, 2 for usage, profile, or missing-credential errors, and 3
|
unavailable preflight, a known delivery failure, conflict, partial delivery, or
|
||||||
when a remote write remains uncertain. The result lists matching, uploaded,
|
local cleanup failure after hosted completion, and 2 for usage,
|
||||||
pending, conflicting, and uncertain assets as disjoint current observations;
|
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
|
early failures may leave some declared assets unreported. On failure, the CLI
|
||||||
prints state-based recovery guidance. Fix local or preflight failures before
|
prints state-based recovery guidance. Fix local or preflight failures before
|
||||||
retrying; fix the reported cause before retrying partial delivery; resolve an
|
retrying; fix the reported cause before retrying partial delivery; resolve an
|
||||||
@@ -228,12 +307,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
|
the required full `--commit` object ID, checks out that commit detached, then
|
||||||
verifies the tag, HEAD, and clean worktree. With `--prepared`, it performs those
|
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.
|
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`
|
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;
|
optionally checks that the profile selects the module chosen by a launcher;
|
||||||
module selection itself remains profile-owned. The command validates delivery
|
module selection itself remains profile-owned. The command compares the selected
|
||||||
settings and the configured token, then downloads application dependencies only
|
module's derived release tag with the full event tag before forge access or
|
||||||
when the profile selects build targets. Binary profiles are built and delivered
|
dependency preparation, then validates explicit forge selection and resolves
|
||||||
through the same facade path as `deliver`; source-only profiles skip dependency
|
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.
|
downloads and binary builds.
|
||||||
`--config` follows the existing profile path rules: relative paths are resolved
|
`--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
|
from `--repo`, and absolute paths are used as-is. The default mode prepares and
|
||||||
@@ -247,15 +330,17 @@ tag arguments. The event ID may identify an annotated tag object; see the
|
|||||||
[delivery guide](consumers/delivery.md#woodpecker-tag-workflows) for resolution
|
[delivery guide](consumers/delivery.md#woodpecker-tag-workflows) for resolution
|
||||||
and workflow preparation. The CLI does not read
|
and workflow preparation. The CLI does not read
|
||||||
provider-specific environment variable names. Checkout and dependency
|
provider-specific environment variable names. Checkout and dependency
|
||||||
preparation failures return status 1; usage, profile, credential, and invalid
|
preparation failures, unavailable preflight, and cancellation during those
|
||||||
version errors return status 2. Final delivery uses the same statuses as
|
steps return status 1. Usage, profile, forge-selection, local credential-loading,
|
||||||
`deliver`: 0 for complete, 1 for known failure, conflict, or partial delivery,
|
classified preflight configuration/access, and invalid-version errors return
|
||||||
and 3 for uncertain remote state.
|
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
|
The maintained module-based tag workflow verifies and checks out the event tag
|
||||||
before resolving the consumer module. It builds the ReleaseKit version already
|
before resolving the consumer module. It runs `go tool releasekit` for the version
|
||||||
selected by that tagged `go.mod`, using temporary alternate module files outside
|
already selected by that tagged `go.mod`, using temporary alternate module files
|
||||||
the source checkout. This tool bootstrap is separate from `ci-deliver`'s
|
outside the source checkout. This tool bootstrap is separate from `ci-deliver`'s
|
||||||
application dependency preparation for binary profiles. ReleaseKit's own
|
application dependency preparation for binary profiles. ReleaseKit's own
|
||||||
source workflow self-bootstraps `./cmd/releasekit`; consumers without a declared
|
source workflow self-bootstraps `./cmd/releasekit`; consumers without a declared
|
||||||
ReleaseKit module dependency can use the separately installed-tool command.
|
ReleaseKit module dependency can use the separately installed-tool command.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ The current profile fields are:
|
|||||||
| `checks` | No | Ordered additional child commands, each with an argument vector and optional environment map. |
|
| `checks` | No | Ordered additional child commands, each with an argument vector and optional environment map. |
|
||||||
| `validation` | No | Optional shared checks selected by ReleaseKit. |
|
| `validation` | No | Optional shared checks selected by ReleaseKit. |
|
||||||
| `build` | No | Optional main package, binary name, and selected `GOOS/GOARCH` target strings. |
|
| `build` | No | Optional main package, binary name, and selected `GOOS/GOARCH` target strings. |
|
||||||
| `delivery` | No | Optional Gitea `base_url`, `owner`, `repository`, and `token_env` settings for tagged-source delivery. |
|
| `delivery` | No | Optional selected forge and destination fields for tagged-source delivery. |
|
||||||
|
|
||||||
`module_dir` names the selected module's physical location in the repository;
|
`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
|
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,
|
the captured candidate is checked, one offending file is reported at a time,
|
||||||
and historical or aggregate repository size is not measured.
|
and historical or aggregate repository size is not measured.
|
||||||
|
|
||||||
The `delivery` object contains `base_url`, `owner`, `repository`, and optional
|
`validation.forbidden_files` is an optional list of literal repository-relative
|
||||||
`token_env`. Its values are required when running `deliver`, but credentials are
|
file paths that must be absent from the captured commit. It runs after the Go
|
||||||
not required for `check`, `publish`, or `build`. `token_env` names the
|
checks and before other file checks, using the `forbidden-files` identity and
|
||||||
environment variable containing the Gitea token; it defaults to
|
reporting selected committed paths. Omission, `null`, or `[]` disables it.
|
||||||
`RELEASEKIT_GITEA_TOKEN`. The CLI reads this variable only for `deliver` and
|
Paths use slash separators and ordinary relative normalization and must stay
|
||||||
passes its value explicitly to the library operation.
|
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
|
`expected_push_url`, when nonempty, is compared exactly with Git's resolved
|
||||||
effective push URL during `publish`, before candidate validation. It applies
|
effective push URL during `publish`, before candidate validation. It applies
|
||||||
|
|||||||
@@ -10,12 +10,14 @@ From the consumer module's root, select the ReleaseKit version to adopt:
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
: "${RELEASEKIT_TOOL_VERSION:?Set the released ReleaseKit version to adopt}"
|
: "${RELEASEKIT_TOOL_VERSION:?Set the released ReleaseKit version to adopt}"
|
||||||
go get -tool "gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit@${RELEASEKIT_TOOL_VERSION}"
|
go get -tool "go.maximumdirect.net/releasekit/cmd/releasekit@${RELEASEKIT_TOOL_VERSION}"
|
||||||
go mod tidy
|
go mod tidy
|
||||||
go tool releasekit version
|
go tool releasekit version
|
||||||
```
|
```
|
||||||
|
|
||||||
Select a release that implements the API, profile, and workflow you are adopting.
|
Select a release that implements the API, profile, and workflow you are adopting.
|
||||||
|
The vanity module path starts with v0.5.0. Versions through v0.4.2 declare the
|
||||||
|
original module path and use their corresponding historical instructions.
|
||||||
The module-release interface requires a release containing that feature;
|
The module-release interface requires a release containing that feature;
|
||||||
pre-feature versions through v0.3.0 use their corresponding historical interface.
|
pre-feature versions through v0.3.0 use their corresponding historical interface.
|
||||||
Go records the executable package in
|
Go records the executable package in
|
||||||
@@ -32,8 +34,21 @@ Go minimum. See the
|
|||||||
[Go tool dependency reference](https://go.dev/ref/mod#go-mod-file-tool).
|
[Go tool dependency reference](https://go.dev/ref/mod#go-mod-file-tool).
|
||||||
|
|
||||||
Run `go tool releasekit` from within the consumer module to use its selected
|
Run `go tool releasekit` from within the consumer module to use its selected
|
||||||
version. Follow the [CLI contract](../cli.md) for commands and exit statuses,
|
version. Go builds and caches the executable and preserves its exit status.
|
||||||
and the [configuration reference](../config.md) for the release profile.
|
A launcher invoked from that module can forward commands directly:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
#!/bin/sh
|
||||||
|
exec go tool releasekit "$@"
|
||||||
|
```
|
||||||
|
|
||||||
|
For a launcher callable from other directories, first change to its consumer
|
||||||
|
module directory. Follow the [CLI contract](../cli.md) for commands and exit
|
||||||
|
statuses, and the [configuration reference](../config.md) for the release profile.
|
||||||
|
|
||||||
|
For consumers using vendoring, register the CLI before running `go mod vendor`
|
||||||
|
so the vendor tree includes its dependencies. `go tool` follows Go's normal
|
||||||
|
vendor selection.
|
||||||
|
|
||||||
## Go API consumers
|
## Go API consumers
|
||||||
|
|
||||||
@@ -43,19 +58,54 @@ required imports and select the module version:
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
: "${RELEASEKIT_TOOL_VERSION:?Set the released ReleaseKit version to adopt}"
|
: "${RELEASEKIT_TOOL_VERSION:?Set the released ReleaseKit version to adopt}"
|
||||||
go get "gitea.maximumdirect.net/eric/ReleaseKit@${RELEASEKIT_TOOL_VERSION}"
|
go get "go.maximumdirect.net/releasekit@${RELEASEKIT_TOOL_VERSION}"
|
||||||
go mod tidy
|
go mod tidy
|
||||||
```
|
```
|
||||||
|
|
||||||
API consumers can also register the CLI with `go get -tool` as above. Both uses
|
API consumers can also register the CLI with `go get -tool` as above. Both uses
|
||||||
share the module-selected ReleaseKit version.
|
share the module-selected ReleaseKit version.
|
||||||
|
|
||||||
|
## Migrating from the original module path
|
||||||
|
|
||||||
|
Starting with v0.5.0, ReleaseKit's module is `go.maximumdirect.net/releasekit`.
|
||||||
|
Earlier releases declare `gitea.maximumdirect.net/eric/ReleaseKit`. The migration
|
||||||
|
is a direct cutover with the existing facade roles and command behavior.
|
||||||
|
|
||||||
|
For API consumers, update imports of the root facade and `buildinfo` to
|
||||||
|
`go.maximumdirect.net/releasekit` and
|
||||||
|
`go.maximumdirect.net/releasekit/buildinfo`, including generated code and tests.
|
||||||
|
Select v0.5.0 or a later compatible release using the package-dependency command
|
||||||
|
above. Packages imported through the old and new paths have distinct Go
|
||||||
|
identities; coordinate updates in packages that exchange ReleaseKit facade
|
||||||
|
types. Builders and applications using `buildinfo` should adopt the new path
|
||||||
|
together so version injection addresses the runtime implementation they use.
|
||||||
|
|
||||||
|
For CLI tool consumers, remove the old tool declaration before registering the
|
||||||
|
new CLI package with the tool-dependency command above:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go mod edit -droptool=gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit
|
||||||
|
```
|
||||||
|
|
||||||
|
Update copied release launchers and Woodpecker workflows so their
|
||||||
|
`releasekit_module` value is `go.maximumdirect.net/releasekit`. Use the maintained
|
||||||
|
examples matching the selected tool version. Run `go mod tidy` after updating
|
||||||
|
imports and tool declarations, review any old-path dependencies retained
|
||||||
|
transitively, and commit the resulting module files with the workflow changes.
|
||||||
|
|
||||||
|
Existing consumer tags retain their recorded module files, tool selection, and
|
||||||
|
matching workflows for retries. Apply these migration steps to new consumer
|
||||||
|
revisions. Source repository and hosted release URLs use
|
||||||
|
`https://forge.maximumdirect.net/base-layer/releasekit`; the vanity path is the
|
||||||
|
Go module identity. The Go consumer minimum remains 1.26.0.
|
||||||
|
|
||||||
## CI adoption
|
## CI adoption
|
||||||
|
|
||||||
The maintained [consumer tag-delivery workflow](delivery.md#woodpecker-tag-workflows)
|
The maintained [consumer tag-delivery workflow](delivery.md#woodpecker-tag-workflows)
|
||||||
supports both tool and ordinary package dependencies. It builds ReleaseKit from
|
supports both tool and ordinary package dependencies. It runs `go tool releasekit`
|
||||||
the tagged consumer's module selection, so the committed module files also
|
with temporary module metadata derived from the tagged consumer's module
|
||||||
govern CI delivery and retries. Registering the CLI as a tool is sufficient for
|
selection, so the committed module files also govern CI delivery and retries.
|
||||||
|
Registering the CLI as a tool is sufficient for
|
||||||
consumers that use ReleaseKit only for maintainer commands.
|
consumers that use ReleaseKit only for maintainer commands.
|
||||||
|
|
||||||
Upgrade the module-selected tool and workflow together. For a tag created before
|
Upgrade the module-selected tool and workflow together. For a tag created before
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
releasekit "gitea.maximumdirect.net/eric/ReleaseKit"
|
releasekit "go.maximumdirect.net/releasekit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func build(ctx context.Context) error {
|
func build(ctx context.Context) error {
|
||||||
@@ -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.
|
is resolved from the module, while an absolute path is used as supplied.
|
||||||
Selected artifact filenames are regenerated. Other files in the output
|
Selected artifact filenames are regenerated. Other files in the output
|
||||||
directory are left in place. A failed later build or native version check
|
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
|
Builds use the available compatible Go toolchain selected locally; ReleaseKit
|
||||||
does not download a different toolchain automatically. It disables workspace
|
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
|
module-local product checks. ReleaseKit calls `AdditionalChecks` once after
|
||||||
shared validation when supplied.
|
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
|
`ValidationSpec.SharedFiles` adds committed, repository-relative files to
|
||||||
ReleaseKit's optional file checks without enabling any checks. A nil slice
|
ReleaseKit's optional file checks without enabling any checks. A nil slice
|
||||||
selects the optional defaults `COPYRIGHT`, `LICENSE`, and
|
selects the optional defaults `COPYRIGHT`, `LICENSE`, and
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
# Deliver a tagged source release
|
# Deliver a tagged source release
|
||||||
|
|
||||||
Use `releasekit.Deliver` or the `deliver` command after a source tag exists.
|
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
|
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.
|
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
|
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
|
`DependencyPreparationRequest` selecting the repository and module after
|
||||||
checkout preparation and before delivery. It runs `go mod download` in that
|
checkout preparation and before delivery. It runs `go mod download` in that
|
||||||
module with workspace discovery disabled and the available compatible local Go
|
module with workspace discovery disabled and the available compatible local Go
|
||||||
toolchain.
|
toolchain. Inside a Git checkout, `RepositoryDir` can name a subdirectory;
|
||||||
The operation may use the network and updates the Go module cache while
|
`ModuleDir` is interpreted relative to the containing checkout root, and an
|
||||||
preserving the caller's proxy, private-module, credential, and cache settings.
|
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
|
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
|
and commit the selected module's `go.sum` before tagging: delivery verifies the
|
||||||
tagged source remains clean after dependency preparation. See the
|
tagged source remains clean after dependency preparation. See the
|
||||||
@@ -62,6 +70,7 @@ Add a Gitea destination to the profile:
|
|||||||
{
|
{
|
||||||
"product": "Example",
|
"product": "Example",
|
||||||
"delivery": {
|
"delivery": {
|
||||||
|
"forge": "gitea",
|
||||||
"base_url": "https://gitea.example",
|
"base_url": "https://gitea.example",
|
||||||
"owner": "example",
|
"owner": "example",
|
||||||
"repository": "example",
|
"repository": "example",
|
||||||
@@ -77,11 +86,37 @@ git checkout --detach v1.2.3
|
|||||||
RELEASEKIT_GITEA_TOKEN="$GITEA_TOKEN" releasekit deliver 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
|
The CLI resolves the token for delivery using the configured environment
|
||||||
`RELEASEKIT_GITEA_TOKEN`. It verifies the local checkout and hosted tag before
|
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
|
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
|
hosted body. A retry accepts an identical page and matching attachments, adds
|
||||||
only missing assets, and reports conflicts without replacing content.
|
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
|
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.
|
`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
|
`build`. ReleaseKit checks its schema, repository, commit, version, resolved
|
||||||
module directory and path, full tag, normalized build settings, and artifact
|
module directory and path, full tag, normalized build settings, and artifact
|
||||||
declarations. Old receipts must be regenerated with the updated `build`
|
declarations. Old receipts must be regenerated with the updated `build`
|
||||||
command. Delivery verifies the artifact bytes and sizes before making a hosted
|
command. Delivery verifies the artifact bytes and sizes after read-only
|
||||||
request; caller-owned files remain available for retries.
|
preflight and before any hosted write; caller-owned files remain available for
|
||||||
|
retries.
|
||||||
Ordinary `deliver` does not download application dependencies or change the
|
Ordinary `deliver` does not download application dependencies or change the
|
||||||
checkout.
|
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
|
work; `Conflicting` means different hosted content was found; `Uncertain` means
|
||||||
the remote state could not be resolved.
|
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
|
Fix local or preflight failures before retrying. For partial delivery, fix the
|
||||||
reported cause and retry the same version. Resolve an unknown remote outcome
|
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
|
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`.
|
on the server, retain the original build output and retry with `--assets DIR`.
|
||||||
The command does not run candidate checks or publish tags.
|
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
|
## Go API
|
||||||
|
|
||||||
Pass the Gitea credential explicitly. Set `Build` to a `BuildSpec` to compile
|
Pass the Gitea credential explicitly and select `ForgeGitea`. Set `Build` to a
|
||||||
selected targets as part of delivery. The facade verifies the local tag, clean
|
`BuildSpec` to compile selected targets as part of delivery. The facade verifies
|
||||||
checkout, and committed release note before compiling. Managed output is stored
|
the local tag, clean checkout, and committed release note before compiling.
|
||||||
outside the checkout and removed when delivery returns. `Output` receives build
|
Managed output is stored outside the checkout and removed when delivery
|
||||||
progress. With no build, or with an empty target set, delivery is page-only.
|
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`.
|
To deliver binaries built separately, set `Prebuilt` to the `BuildResult`.
|
||||||
ReleaseKit checks its repository, module directory, declared module path, full
|
ReleaseKit checks its repository, module directory, declared module path, full
|
||||||
tag, semantic version, tagged commit, normalized settings, and artifact evidence
|
tag, semantic version, tagged commit, normalized settings, and artifact evidence
|
||||||
against the selected committed module. Binary results must record verified
|
against the selected committed module. Binary results must record verified
|
||||||
source. The current files are checked against their recorded digests before any
|
source. After cheap local and source checks, delivery performs read-only
|
||||||
hosted request. The files remain caller-owned, so callers can retain the
|
credential preflight, verifies the local artifacts in full, and then makes
|
||||||
original bytes for retries and are responsible for cleanup. With neither
|
hosted writes. The current files are checked against their recorded digests
|
||||||
artifact source, an empty managed target set, or a matching empty prebuilt
|
before those writes. Preflight confirms only the access evidence available from
|
||||||
result, delivery is page-only. Do not set both `Build` and `Prebuilt` in one
|
the service, as described above. The files remain caller-owned, so callers can
|
||||||
request.
|
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
|
```go
|
||||||
result, err := releasekit.Deliver(ctx, releasekit.DeliveryRequest{
|
result, err := releasekit.Deliver(ctx, releasekit.DeliveryRequest{
|
||||||
@@ -151,7 +241,8 @@ result, err := releasekit.Deliver(ctx, releasekit.DeliveryRequest{
|
|||||||
Product: "Example",
|
Product: "Example",
|
||||||
Version: "v1.2.3",
|
Version: "v1.2.3",
|
||||||
ExpectedTag: "integrations/example/v1.2.3", // optional CI event check
|
ExpectedTag: "integrations/example/v1.2.3", // optional CI event check
|
||||||
Destination: releasekit.GiteaDestination{
|
Destination: releasekit.DeliveryDestination{
|
||||||
|
Forge: releasekit.ForgeGitea,
|
||||||
BaseURL: "https://gitea.example",
|
BaseURL: "https://gitea.example",
|
||||||
Owner: "example",
|
Owner: "example",
|
||||||
Repository: "example",
|
Repository: "example",
|
||||||
@@ -202,11 +293,13 @@ identify an annotated tag object or a commit. The template requires the resolved
|
|||||||
commits to match, checks out that commit detached, and confirms the source tree
|
commits to match, checks out that commit detached, and confirms the source tree
|
||||||
is clean. It then copies the tagged `go.mod` and any `go.sum` to a temporary
|
is clean. It then copies the tagged `go.mod` and any `go.sum` to a temporary
|
||||||
directory outside the checkout. `go list -m` confirms that the module selects
|
directory outside the checkout. `go list -m` confirms that the module selects
|
||||||
ReleaseKit; Go uses
|
ReleaseKit. The template adds its CLI tool declaration to that temporary copy
|
||||||
an alternate `-modfile` to prepare dependencies and build the selected
|
and prepares tool dependencies with `go list -deps`. It then uses
|
||||||
`cmd/releasekit` package without changing the tagged module files. The template
|
`go tool -modfile=... releasekit` to build, cache, and run the selected tool with
|
||||||
invokes that executable with `CI_WORKSPACE`, the resolved event commit, and
|
`CI_WORKSPACE`, the resolved event commit, and `CI_COMMIT_TAG`. The tagged module
|
||||||
`CI_COMMIT_TAG`. A cleanup trap removes the temporary metadata and executable.
|
files stay unchanged, including for consumers with an ordinary package
|
||||||
|
dependency. A cleanup trap removes the temporary metadata; Go manages the
|
||||||
|
cached executable. The command preserves the tool's exit status.
|
||||||
|
|
||||||
Tool bootstrap and application artifact preparation are separate. The bootstrap
|
Tool bootstrap and application artifact preparation are separate. The bootstrap
|
||||||
resolves and builds ReleaseKit from the tagged consumer's module configuration.
|
resolves and builds ReleaseKit from the tagged consumer's module configuration.
|
||||||
@@ -244,7 +337,7 @@ and application artifact compilation retain their separate environments.
|
|||||||
The normal Go module and build caches are sufficient. Cache mounts may be added
|
The normal Go module and build caches are sufficient. Cache mounts may be added
|
||||||
as deployment configuration when they help the runner; they are not needed by
|
as deployment configuration when they help the runner; they are not needed by
|
||||||
the template. A separately installed executable can be installed with
|
the template. A separately installed executable can be installed with
|
||||||
`go install gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit@PIN`, then run
|
`go install go.maximumdirect.net/releasekit/cmd/releasekit@PIN`, then run
|
||||||
with the same arguments. With the event object available in the CI checkout,
|
with the same arguments. With the event object available in the CI checkout,
|
||||||
resolve it to its source commit before invocation:
|
resolve it to its source commit before invocation:
|
||||||
|
|
||||||
@@ -286,7 +379,9 @@ describes these checks.
|
|||||||
|
|
||||||
To isolate a public ReleaseKit download from application settings and the
|
To isolate a public ReleaseKit download from application settings and the
|
||||||
existing module cache, run the following with a compatible local Go toolchain.
|
existing module cache, run the following with a compatible local Go toolchain.
|
||||||
Replace `v0.2.0` with the affected version. This diagnostic accesses the public
|
Replace `v0.5.0` with the affected vanity-path version. For an earlier release,
|
||||||
|
use its original `gitea.maximumdirect.net/eric/ReleaseKit` module path instead.
|
||||||
|
This diagnostic accesses the public
|
||||||
Go proxy and checksum database and retains its temporary directory for inspection.
|
Go proxy and checksum database and retains its temporary directory for inspection.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -298,7 +393,7 @@ Go proxy and checksum database and retains its temporary directory for inspectio
|
|||||||
GOPRIVATE= GONOPROXY= GONOSUMDB= \
|
GOPRIVATE= GONOPROXY= GONOSUMDB= \
|
||||||
GOPROXY=https://proxy.golang.org GOSUMDB=sum.golang.org \
|
GOPROXY=https://proxy.golang.org GOSUMDB=sum.golang.org \
|
||||||
GOMODCACHE="$checksum_dir/modules" \
|
GOMODCACHE="$checksum_dir/modules" \
|
||||||
go mod download -json gitea.maximumdirect.net/eric/ReleaseKit@v0.2.0
|
go mod download -json go.maximumdirect.net/releasekit@v0.5.0
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -114,9 +114,81 @@ candidate and remote guards, creates an annotated tag for the captured commit,
|
|||||||
and pushes only that tag object.
|
and pushes only that tag object.
|
||||||
|
|
||||||
A successful result means Git confirmed the push or an exact authoritative tag
|
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
|
query confirmed it after a push error. A failed inspection after local tag
|
||||||
push. `PublicationState` distinguishes a known failure, conflict, confirmed
|
creation can leave the tag ref even when its object ID is unavailable; when
|
||||||
publication, and unresolved outcome. Resolve an unknown result against the
|
the ID was established it remains in `TagObject`. The local tag is retained
|
||||||
captured endpoint before retrying; see the [maintainer procedure](../release.md)
|
after a failed push. `PublicationState` distinguishes a known failure, conflict,
|
||||||
for the recovery steps. The [CLI contract](../cli.md) documents command output
|
confirmed publication, and unresolved outcome. Resolve an unknown result
|
||||||
and exit statuses.
|
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.
|
||||||
|
|
||||||
|
## Retain and reconcile publication evidence
|
||||||
|
|
||||||
|
`Publish` automatically attempts to save one private record for each invocation.
|
||||||
|
The default store is under the repository's Git common directory, so linked
|
||||||
|
worktrees share it. `ReleaseRequest.PublicationRecordDir` can override the
|
||||||
|
directory; relative overrides resolve from the source repository root. The CLI
|
||||||
|
equivalent is `publish --record-dir DIR`. ReleaseKit creates its owned storage
|
||||||
|
directories privately and creates record files with owner-only permissions;
|
||||||
|
existing directory permissions are left as they are. The caller owns storage
|
||||||
|
retention and removal.
|
||||||
|
|
||||||
|
The CLI prepares a recovery entry point when a failed publication has a saved
|
||||||
|
record. Registered-tool consumers use their selected module's Go tool directly.
|
||||||
|
Command preparation uses a separate bounded context after publication failure,
|
||||||
|
including cancellation. A module-preparation warning can accompany a persistent
|
||||||
|
native executable fallback; the saved record and publication result retain their
|
||||||
|
original meaning.
|
||||||
|
For API-only consumers, ReleaseKit retains private bootstrap module and checksum files
|
||||||
|
under `<Git common directory>/releasekit/publication-recovery/`, with private
|
||||||
|
directory and file permissions. These files add the CLI tool declaration and
|
||||||
|
prepare its module graph without changing consumer source. The bootstrap has
|
||||||
|
its own module root, so tool resolution is independent of consumer vendoring;
|
||||||
|
local replacements retain their original source locations. These files survive
|
||||||
|
a launcher's temporary module-file cleanup. Registered-tool commands run from
|
||||||
|
the selected module; API-only commands run from the private bootstrap root.
|
||||||
|
Retain the source checkout and private recovery files while the entry point is
|
||||||
|
needed. Remove the private recovery directories when
|
||||||
|
their recovery value expires. Another supported CLI can read the saved record
|
||||||
|
independently of these launch files.
|
||||||
|
|
||||||
|
The saved endpoint is retained exactly for later observation and may include
|
||||||
|
credentials. Treat records, `PublicationEvidence`, and `PublicationResult` as
|
||||||
|
sensitive. Store overrides in a location with suitable access controls, avoid
|
||||||
|
printing raw endpoint values, and remove records when their recovery value has
|
||||||
|
expired. Diagnostic errors and CLI output apply credential redaction; the
|
||||||
|
private record and typed evidence preserve the raw endpoint.
|
||||||
|
|
||||||
|
Recording failure is reported in `PublicationResult.RecordError`, separately
|
||||||
|
from `Publish`'s return error and publication state. A successful push remains
|
||||||
|
successful if storage fails, but the saved handoff is unavailable. Callers can
|
||||||
|
still pass `result.Evidence` directly to `ReconcilePublication` when it contains
|
||||||
|
the required identity. For example:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func observe(ctx context.Context, result releasekit.PublicationResult) (releasekit.PublicationReconciliationResult, error) {
|
||||||
|
return releasekit.ReconcilePublication(ctx, result.Evidence)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The evidence is populated as publication identity becomes available, including
|
||||||
|
partial evidence on some earlier failures. An abrupt process termination before
|
||||||
|
the save point can leave no record; removed storage also removes the handoff.
|
||||||
|
Records from older attempts may lack the tag object, endpoint, or other identity
|
||||||
|
needed for a conclusive observation.
|
||||||
|
|
||||||
|
Use `LoadPublicationEvidence(path)` to read one explicit record,
|
||||||
|
`ListPublicationAttempts(ctx, request)` to discover readable records and
|
||||||
|
per-record diagnostics in a local directory, or `ReconcilePublication(ctx,
|
||||||
|
evidence)` to observe a selected endpoint. Listing does not query remotes, and
|
||||||
|
neither listing nor reconciliation changes saved records. Recovery does not
|
||||||
|
infer the attempt from current HEAD, local tags, module files, or profiles.
|
||||||
|
`ReconcilePublication` returns a nil error for a completed `published`,
|
||||||
|
`not_published`, or `conflict` observation; unresolved `unknown` results include
|
||||||
|
a typed error that retains the underlying cause. See the
|
||||||
|
[GoDoc](../../recovery.go) for the exported fields and error kinds, the
|
||||||
|
[CLI guide](../cli.md) for command statuses, and the
|
||||||
|
[record format guide](../integrations/publication-records.md) for supported
|
||||||
|
stored and structured formats.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Applications can read their embedded version through the public `buildinfo`
|
|||||||
package:
|
package:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
import "gitea.maximumdirect.net/eric/ReleaseKit/buildinfo"
|
import "go.maximumdirect.net/releasekit/buildinfo"
|
||||||
|
|
||||||
version := buildinfo.Version()
|
version := buildinfo.Version()
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
# Gitea release pages
|
# Gitea release pages
|
||||||
|
|
||||||
ReleaseKit's public `Deliver` operation and `deliver` command verify tagged
|
ReleaseKit's public `Deliver` operation and `deliver` command select Gitea
|
||||||
source, find or create the selected module's release page, inspect existing assets, and
|
explicitly, verify tagged source, find or create the selected module's release
|
||||||
upload missing assets through the internal Gitea client. Page-only and
|
page, inspect existing assets, and upload missing assets. Shared delivery code
|
||||||
binary-producing profiles use the same delivery path. The
|
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;
|
[consumer delivery guide](../consumers/delivery.md) covers caller workflows;
|
||||||
this guide describes the Gitea protocol and its failure behavior.
|
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,
|
results. The base URL must use HTTP or HTTPS and cannot contain URL credentials,
|
||||||
a query, or a fragment.
|
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
|
The selected module derives a complete tag from its semantic version, declared
|
||||||
module path, and physical repository-relative directory. For example, version
|
module path, and physical repository-relative directory. For example, version
|
||||||
`v1.2.3` in `integrations/ckconfig` uses
|
`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
|
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.
|
source ref and the actual repository-relative file paths in those links.
|
||||||
Shared-file selection controls validation inputs; delivery preserves the
|
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
|
`/api/v1/repos/{owner}/{repo}/tags/{tag}` and requires the hosted tag's commit
|
||||||
SHA to equal the captured source commit. It then reads
|
SHA to equal the captured source commit. It then reads
|
||||||
`/api/v1/repos/{owner}/{repo}/releases/tags/{tag}`. A matching existing page
|
`/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
|
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.
|
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`
|
If the page is absent, the adapter posts to
|
||||||
with the selected full tag and commit, the release note's H1 as title, the complete
|
`/api/v1/repos/{owner}/{repo}/releases` with the selected full tag and commit,
|
||||||
note as body, and draft and prerelease disabled. A create conflict, interrupted
|
the release note's H1 as title, the complete note as body, and draft and
|
||||||
response, or malformed create response (including inconsistent decoded fields)
|
prerelease disabled. A create conflict, interrupted response, or malformed
|
||||||
triggers one lookup. An exact page found
|
create response (including inconsistent decoded fields) triggers one lookup by
|
||||||
there confirms the existing content; a differing page is a conflict. If that
|
the shared workflow. An exact page found there confirms the existing content;
|
||||||
lookup cannot determine the result after a possibly successful write, the
|
a differing page is a conflict. If that lookup cannot determine the result
|
||||||
client returns an unknown outcome. Other HTTP failures retain their structured
|
after a possibly successful write, delivery returns an unknown outcome. Other
|
||||||
failure kind and status code. A recovered create retains its write history;
|
HTTP failures retain their structured failure kind and status code. A recovered
|
||||||
a later asset-inspection failure reports partial page progress.
|
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
|
`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
|
are sorted by filename and use the standard two-space separator. A source-only
|
||||||
release produces no manifest.
|
release produces no manifest.
|
||||||
|
|
||||||
Asset inspection lists the release attachments, streams each declared existing
|
The adapter lists release attachments and opens their download streams. Shared
|
||||||
file, and compares its SHA-256 digest. It reports each asset as matching,
|
delivery compares each declared existing file by SHA-256 digest and reports it
|
||||||
missing, conflicting, or unverifiable. Same-name attachments with different
|
as matching, missing, conflicting, or unverifiable. Same-name attachments with
|
||||||
bytes are conflicts even when their reported size matches. An existing
|
different bytes are conflicts even when their reported size matches. An existing
|
||||||
`SHA256SUMS` must exactly match the complete expected manifest; a subset or
|
`SHA256SUMS` must exactly match the complete expected manifest; a subset or
|
||||||
different manifest is a conflict. An unreadable attachment is unverifiable,
|
different manifest is a conflict. An unreadable attachment is unverifiable,
|
||||||
not missing. A failed read-only inspection reports `not_attempted`; if this
|
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
|
Attachment downloads and redirects carry the Gitea authorization token only
|
||||||
when their scheme, host, and port match the configured service. Cross-origin
|
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
|
unverifiable assets. It uploads missing binaries in filename order and then the
|
||||||
missing `SHA256SUMS`, using multipart field `attachment` and the filename query
|
missing `SHA256SUMS`, using the adapter's multipart field `attachment` and
|
||||||
parameter `name`. Matching attachments are retained; unrelated and existing
|
filename query parameter `name`. Matching attachments are retained; unrelated and existing
|
||||||
attachments are never overwritten or deleted. Results use disjoint current
|
attachments are never overwritten or deleted. Results use disjoint current
|
||||||
observations: matching, uploaded, pending, conflicting, and uncertain. An asset
|
observations: matching, uploaded, pending, conflicting, and uncertain. An asset
|
||||||
moved from known missing work to another category is removed from `pending`;
|
moved from known missing work to another category is removed from `pending`;
|
||||||
|
|||||||
123
docs/integrations/publication-records.md
Normal file
123
docs/integrations/publication-records.md
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
# Publication records and recovery output
|
||||||
|
|
||||||
|
This guide describes the supported private publication-record envelope and the
|
||||||
|
versioned JSON documents emitted by the recovery CLI. The
|
||||||
|
[publication consumer guide](../consumers/publish.md) owns storage placement,
|
||||||
|
permissions, retention, and sensitive-data handling. The
|
||||||
|
[CLI reference](../cli.md) owns command behavior, path selection, and exit
|
||||||
|
statuses; the exported Go contract is documented in [GoDoc](../../recovery.go).
|
||||||
|
|
||||||
|
## Private attempt record
|
||||||
|
|
||||||
|
Each saved attempt is one complete JSON document with this versioned envelope:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"format_version": 1,
|
||||||
|
"evidence": {
|
||||||
|
"repository_dir": "/work/project",
|
||||||
|
"module_dir": "/work/project",
|
||||||
|
"module_path": "example.com/project",
|
||||||
|
"version": "v1.2.3",
|
||||||
|
"tag": "v1.2.3",
|
||||||
|
"candidate_commit": "<full Git object ID>",
|
||||||
|
"tag_object": "<full Git object ID>",
|
||||||
|
"endpoint": "ssh://git.example/owner/project.git",
|
||||||
|
"remote": "origin",
|
||||||
|
"recorded_at": "2026-11-02T12:00:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The format version is an integer. Evidence fields may be absent when publication
|
||||||
|
stopped before they were captured; loading preserves that partial evidence and
|
||||||
|
does not fill fields from the current checkout. Additional fields in a
|
||||||
|
supported version are ignored. A missing or unsupported version, malformed or
|
||||||
|
multiple JSON documents, or fields with invalid structural types are reported
|
||||||
|
as record-format errors. The codec supports versions 1 and 2; no migration from
|
||||||
|
an unsupported version is performed.
|
||||||
|
|
||||||
|
Ordinary UTF-8 evidence uses version 1. Evidence containing other pathname or
|
||||||
|
endpoint bytes uses version 2 with the same `evidence` object and an additional
|
||||||
|
`evidence_bytes` object. Each entry in `evidence_bytes` uses an evidence field
|
||||||
|
name and a standard base64 string containing its exact bytes. That entry
|
||||||
|
supplies the field's value when loading; the writer omits its lossy string form
|
||||||
|
from `evidence`. Invalid known-field types or base64 encodings produce a
|
||||||
|
record-format error with the available decoding cause. Unknown field names are
|
||||||
|
ignored. This representation applies
|
||||||
|
to every evidence field and preserves the same identity as typed Go evidence.
|
||||||
|
Older CLIs that support only version 1 report an unsupported version for these
|
||||||
|
byte-containing records; use a CLI supporting version 2 to load them.
|
||||||
|
|
||||||
|
The `endpoint` value is stored exactly as captured and may contain credentials.
|
||||||
|
Private records are sensitive. Do not copy them into public logs or source
|
||||||
|
control. For their location, permissions, and lifecycle, see the
|
||||||
|
[publication consumer guide](../consumers/publish.md#retain-and-reconcile-publication-evidence).
|
||||||
|
|
||||||
|
## Recovery CLI JSON
|
||||||
|
|
||||||
|
After valid command arguments, `--json` emits one JSON document to standard
|
||||||
|
output for both successful and failed recovery operations. Each document has
|
||||||
|
`schema_version: 1` and a `command` name. These are CLI presentation documents,
|
||||||
|
not private attempt records or a Go serialization of public structs.
|
||||||
|
|
||||||
|
`reconcile` includes `record_path`, safe `evidence`, and `state` when a record
|
||||||
|
was read. Available `observed_tag_object` and `observed_commit` fields are
|
||||||
|
included when present. A `diagnostic` is included for unresolved observation
|
||||||
|
or record loading failure. Loading failure omits evidence, state, and observed
|
||||||
|
IDs.
|
||||||
|
|
||||||
|
`publication-attempts` includes `record_dir`, an `attempts` array with each
|
||||||
|
`record_path` and safe `evidence`, and a `diagnostics` array. A top-level
|
||||||
|
`diagnostic` describes enumeration or cancellation failure. Both arrays are
|
||||||
|
present when empty so consumers can decode the same shape for empty stores.
|
||||||
|
|
||||||
|
For example, an unresolved reconciliation can have this shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"command": "reconcile",
|
||||||
|
"record_path": "/private/attempt-abc.json",
|
||||||
|
"evidence": {
|
||||||
|
"repository_dir": "/work/project",
|
||||||
|
"module_path": "example.com/project",
|
||||||
|
"version": "v1.2.3",
|
||||||
|
"tag": "v1.2.3",
|
||||||
|
"candidate_commit": "<full Git object ID>",
|
||||||
|
"tag_object": "<full Git object ID>",
|
||||||
|
"endpoint": "ssh://git.example/owner/project.git",
|
||||||
|
"remote": "origin"
|
||||||
|
},
|
||||||
|
"state": "unknown",
|
||||||
|
"diagnostic": {
|
||||||
|
"kind": "remote_observation",
|
||||||
|
"message": "<credential-safe diagnostic>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A discovery document always contains both arrays, including for an empty store:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"command": "publication-attempts",
|
||||||
|
"record_dir": "/private/publication-attempts",
|
||||||
|
"attempts": [],
|
||||||
|
"diagnostics": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
CLI evidence uses the private record's snake-case field names. Endpoint strings
|
||||||
|
in CLI output are credential-redacted; the private record and typed
|
||||||
|
`PublicationEvidence` retain the exact endpoint. A diagnostic contains `kind`
|
||||||
|
and safe `message`, with `record_path` and `missing_fields` when applicable.
|
||||||
|
Raw Go errors and private record contents are not emitted as JSON fields.
|
||||||
|
|
||||||
|
Consumers should select attempts by their returned record paths and evidence.
|
||||||
|
The listing order is deterministic by path, not a recommendation to choose the
|
||||||
|
first or newest attempt. Unknown fields may be added in later CLI schema
|
||||||
|
versions; reject unsupported `schema_version` values rather than interpreting
|
||||||
|
them as version 1. See the [CLI reference](../cli.md#recover-a-saved-publication-attempt-or-discover-local-attempts)
|
||||||
|
for invocation and status behavior.
|
||||||
@@ -8,21 +8,37 @@ Standalone and delivery builds create their own owners. The package uses
|
|||||||
`internal/process` for cancellation, the working directory, output, and wrapped
|
`internal/process` for cancellation, the working directory, output, and wrapped
|
||||||
errors.
|
errors.
|
||||||
|
|
||||||
|
For CLI publication recovery, the package selects a registered module tool or
|
||||||
|
prepares a durable private module root for an API-only consumer. Go's module
|
||||||
|
graph query includes transitive dependencies; preparation retains module
|
||||||
|
requirements, versioned replacements, and checksums. Local replacements resolve
|
||||||
|
against their original module directory before relocation. The original main
|
||||||
|
module remains a local replacement, including when releasing ReleaseKit itself.
|
||||||
|
The private root provides tool resolution independently of consumer vendoring.
|
||||||
|
The CLI formats the later invocation using a separate bounded context after
|
||||||
|
publication failure. It falls back to a persistent native executable when module
|
||||||
|
selection is unavailable or preparation fails, and excludes recognized temporary
|
||||||
|
Go-work executables from that fallback. The
|
||||||
|
[publication consumer guide](../consumers/publish.md#retain-and-reconcile-publication-evidence)
|
||||||
|
owns recovery-file placement and retention.
|
||||||
|
|
||||||
## Command output ownership
|
## Command output ownership
|
||||||
|
|
||||||
`Tool.Run` executes a status-only command. It forwards combined stdout and
|
`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
|
stderr to the optional writer while the child runs, retaining only the last
|
||||||
on success. Failures include the captured diagnostic text and preserve the
|
1,048,576 bytes for failure diagnostics. A truncation notice identifies omitted
|
||||||
underlying process or forwarding error.
|
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
|
`Tool.Output` returns combined stdout and stderr for commands whose output is
|
||||||
needed by the caller, such as compiler identification. After the child stops,
|
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
|
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
|
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;
|
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
|
the component benchmark compares full and bounded capture for modest output,
|
||||||
32 KiB writes, forwarding to `io.Discard`. Smaller writes incur buffer-growth
|
one 8 MiB write, and 8 MiB in 32 KiB writes, forwarding to `io.Discard`.
|
||||||
allocations. The [output benchmark](../../internal/gotool/performance_test.go)
|
The [output benchmark](../../internal/gotool/performance_test.go)
|
||||||
measures total allocated bytes per operation, excluding subprocess overhead and
|
measures total allocated bytes per operation, excluding subprocess overhead and
|
||||||
diagnostic strings. These totals count all allocations, including superseded
|
diagnostic strings. These totals count all allocations, including superseded
|
||||||
buffers. The returned slice retains the final buffer holding the complete output.
|
buffers. The returned slice retains the final buffer holding the complete output.
|
||||||
@@ -33,8 +49,10 @@ coexist. Disk-backed diagnostics could reduce large failure peaks, but add file
|
|||||||
lifecycle and cleanup work and remain a follow-up option.
|
lifecycle and cleanup work and remain a follow-up option.
|
||||||
|
|
||||||
Dependency preparation uses a separate command environment so proxy, private
|
Dependency preparation uses a separate command environment so proxy, private
|
||||||
module, checksum, and cache settings remain available. Its completed capture
|
module, checksum, cache, and other effective settings remain available. Its
|
||||||
uses the same ownership transfer when formatting a failure.
|
`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
|
## Configuration discovery
|
||||||
|
|
||||||
|
|||||||
@@ -11,12 +11,14 @@ two Go facades and a command-line interface:
|
|||||||
- `buildinfo` is the small runtime facade. `Version` reports the injected
|
- `buildinfo` is the small runtime facade. `Version` reports the injected
|
||||||
application version, the main-module version when available, or `dev`.
|
application version, the main-module version when available, or `dev`.
|
||||||
- `cmd/releasekit` is the executable interface. Its `version`, `check`,
|
- `cmd/releasekit` is the executable interface. Its `version`, `check`,
|
||||||
`publish`, `build`, `deliver`, and `ci-deliver` commands adapt profiles and
|
`publish`, `reconcile`, `publication-attempts`, `build`, `deliver`, and
|
||||||
command output to the supported library operations.
|
`ci-deliver` commands adapt profiles and command output to the supported
|
||||||
|
library operations.
|
||||||
|
|
||||||
See the [CLI contract](../cli.md), [configuration reference](../config.md),
|
See the [CLI contract](../cli.md), [configuration reference](../config.md),
|
||||||
[candidate check guide](../consumers/check.md),
|
[candidate check guide](../consumers/check.md),
|
||||||
[source publication guide](../consumers/publish.md),
|
[source publication guide](../consumers/publish.md),
|
||||||
|
[publication record format](../integrations/publication-records.md),
|
||||||
[build guide](../consumers/build.md),
|
[build guide](../consumers/build.md),
|
||||||
[delivery guide](../consumers/delivery.md), and
|
[delivery guide](../consumers/delivery.md), and
|
||||||
[runtime version guide](../consumers/runtime-version.md) for task-oriented use.
|
[runtime version guide](../consumers/runtime-version.md) for task-oriented use.
|
||||||
@@ -24,7 +26,7 @@ Go declarations and GoDoc remain the owners of exact exported contracts.
|
|||||||
|
|
||||||
## Module and dependencies
|
## Module and dependencies
|
||||||
|
|
||||||
The module is `gitea.maximumdirect.net/eric/ReleaseKit`, with Go language
|
The module is `go.maximumdirect.net/releasekit`, with Go language
|
||||||
version `1.26.0`. `go.mod` requires `golang.org/x/mod` and Goldmark for internal
|
version `1.26.0`. `go.mod` requires `golang.org/x/mod` and Goldmark for internal
|
||||||
validation; public facades do not expose either dependency. `go list ./...`
|
validation; public facades do not expose either dependency. `go list ./...`
|
||||||
reports these implemented packages:
|
reports these implemented packages:
|
||||||
@@ -39,16 +41,17 @@ reports these implemented packages:
|
|||||||
| `internal/artifact` | Shared build-target normalization, artifact declarations, filename rules, and checksum-manifest naming. |
|
| `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/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/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/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/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/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. |
|
| `internal/process` | Cancellable child-process execution, including owned stdin/stdout pipes for serialized exchanges, with per-command environment overlays and removals. |
|
||||||
| `internal/preparation` | Coordination for expected-commit tag checkout and Go module dependency preparation. |
|
| `internal/preparation` | Coordination for expected-commit tag checkout and Go module dependency preparation. |
|
||||||
| `internal/releaseidentity` | Committed-object and working-tree module selection, declaration reading, and semantic-version-to-tag derivation. |
|
| `internal/releaseidentity` | Committed-object and working-tree module selection, declaration reading, and semantic-version-to-tag derivation. |
|
||||||
| `internal/validation` | Shared Go validation with optional race, full-vet, and static package-compilation target selections, formatting, workspace-file and filesystem-replacement checks, per-blob size limits, and selected Markdown destination and fragment checking; `internal/candidate` retains capture, callback sequencing, and local-state rechecks. |
|
| `internal/validation` | Shared Go validation with optional race, full-vet, and static package-compilation target selections, formatting, workspace-file and filesystem-replacement checks, per-blob size limits, and selected Markdown destination and fragment checking; `internal/candidate` retains capture, callback sequencing, and local-state rechecks. |
|
||||||
| `internal/publish` | Publication guards, one validation run, annotated tag creation, push, and recovery state. |
|
| `internal/publish` | Publication guards, one validation run, annotated tag creation, push, pre-push evidence recording, saved-attempt discovery, later observation, and recovery state. |
|
||||||
| `internal/runtimeinfo` | Injected version selection and cached fallback metadata lookup for the runtime facade. |
|
| `internal/runtimeinfo` | Injected version selection and cached fallback metadata lookup for the runtime facade. |
|
||||||
| `internal/versioncontract` | Shared stable-version rules and linker-symbol identity. |
|
| `internal/versioncontract` | Shared stable-version rules and linker-symbol identity. |
|
||||||
|
|
||||||
@@ -74,11 +77,28 @@ it does not consult neighboring files or traverse submodule checkouts.
|
|||||||
See the [shared validation guide](validation.md) for checker, Go command, and
|
See the [shared validation guide](validation.md) for checker, Go command, and
|
||||||
candidate-file integration mechanics.
|
candidate-file integration mechanics.
|
||||||
|
|
||||||
Delivery verifies all local binary sizes and digests before hosted requests.
|
Publication records and recovery follow a separate path from candidate
|
||||||
Its Gitea upload path reuses that verification and streams missing files once
|
validation. `internal/publish` captures and saves attempt evidence before the
|
||||||
for transport. Independent calls to the internal client's `UploadMissing`
|
tag push, lists local records, and observes explicitly selected evidence through
|
||||||
verify missing local files before writing. Both paths share remote inspection,
|
`internal/git`; the root facade owns the reusable Go evidence and result types.
|
||||||
conflict handling, and reconciliation.
|
The CLI's `reconcile` and `publication-attempts` commands load, present, and
|
||||||
|
select records without loading a profile or choosing an attempt automatically.
|
||||||
|
These operations do not rerun candidate checks or hosted delivery and do not
|
||||||
|
change local or remote state. The [publication consumer guide](../consumers/publish.md)
|
||||||
|
owns storage and API use; the [record format guide](../integrations/publication-records.md)
|
||||||
|
owns the private and CLI JSON formats; the [CLI contract](../cli.md) owns command
|
||||||
|
semantics and statuses.
|
||||||
|
|
||||||
|
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
|
## Capability owners and behavioral evidence
|
||||||
|
|
||||||
@@ -88,19 +108,21 @@ not a separate release matrix:
|
|||||||
| Capability | Implementation owner | Existing evidence |
|
| Capability | Implementation owner | Existing evidence |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Runtime version lookup and injected application version | `buildinfo`, `internal/runtimeinfo`, `internal/versioncontract`, `internal/build` | `TestVersionInConsumerExecutable`, `TestVersion` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| Batched local Git blob reads | `internal/git`, `internal/process` | `TestBlobReaderReadsLooseAndPackedObjects`, `TestBlobReaderMissingObjectKeepsBatchSessionUsable`, `TestCandidateObjectReadsStayLocalInPartialClones` |
|
||||||
| Piped child-process exchanges and cancellation cleanup | `internal/process` | `TestStartPipedExchangesBeforeInputEOFAndPreservesSettings`, `TestStartPipedCancellationAfterReadinessReapsChild`, `TestStartPipedPreservesNonzeroExit` |
|
| 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` |
|
| 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` |
|
| Publication partial and uncertain recovery | `internal/publish`, `internal/git` | `TestPublishRejectedPushKeepsLocalTagAndReportsNotPublished`, `TestPublishLostAcknowledgmentConfirmsExactRemoteObject`, `TestPublishLostAcknowledgmentWithChangedEndpointRemainsUnknown`, `TestPublishUnavailableReconciliationRemainsUnknownAndPreservesPushError` |
|
||||||
|
| Saved attempt recording, discovery, explicit later reconciliation, and read-only recovery | `releasekit`, `internal/publish`, `internal/git`, `cmd/releasekit` | `TestLostPublicationOutputCanBeRecoveredFromExplicitRecords`, `TestBuiltPublishRecoveryCommandSelectsItsRecordAmongOtherAttempts`, `TestRecoveryFacadeLoadsPartialEvidenceAndPreservesCause`, `TestRecoveryFacadeErrorUnwrapsCancellation`, `TestListPublicationAttemptsReadsExplicitExternalStore` |
|
||||||
| 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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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
|
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,
|
failures retain their `go-test`, `go-vet`, and `package-compile` identities,
|
||||||
selected target where applicable, and underlying causes. Its
|
selected target where applicable, and underlying causes. Its
|
||||||
request carries the captured repository directory and commit, output writer,
|
request carries the captured repository directory and commit, output writer,
|
||||||
and internal settings. When selected, the Go-format check loads the candidate
|
and internal settings. The Go commands run before the committed-tree sequence,
|
||||||
tree once, reads regular Go blobs from the captured commit, and compares them
|
which owns its reader's lifetime and close-error attribution. When selected,
|
||||||
with go/format output. It uses the selected inputs, including vendor and explicit
|
the Go-format check loads the candidate tree once, reads regular Go blobs from
|
||||||
shared files, and skips
|
the captured commit, and compares them with go/format output. It uses the
|
||||||
symlink blobs, reports all unformatted paths, and stops with a contextual cause
|
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.
|
on parse or inspection errors. It runs before product checks and build callbacks.
|
||||||
`Request.ReadCandidateTree` lets selected file checks request candidate
|
`Request.ReadCandidateTree` lets selected file checks request candidate
|
||||||
contents; the Go-only path does not enumerate the tree.
|
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
|
are parsed once per blob object ID; only owned filesystem-replacement targets
|
||||||
and line numbers remain cached. Each declaration path receives findings from
|
and line numbers remain cached. Each declaration path receives findings from
|
||||||
that summary in its own path context.
|
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
|
The optional file-size check applies normalized literal file/directory
|
||||||
exclusions with path-boundary matching, then requests local Git sizes for the
|
exclusions with path-boundary matching, then requests local Git sizes for the
|
||||||
remaining selected blobs. Sizes include symlink link-text bytes; gitlinks are
|
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
|
provided writer; a nil writer keeps library use quiet. Public selection is
|
||||||
defined in the [configuration reference](../config.md) and GoDoc.
|
defined in the [configuration reference](../config.md) and GoDoc.
|
||||||
|
|
||||||
Committed-tree metadata and blob reads disable lazy fetching and Git transports
|
ReleaseKit-owned Git commands inspect original objects even when replacement
|
||||||
for those child commands. Missing local objects produce inspection errors;
|
refs exist. Committed-tree metadata and blob reads also disable lazy fetching
|
||||||
source preparation retains its separate network-capable operations. See Git's
|
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.
|
[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.
|
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
|
packages and extension interfaces require a deliberate decision grounded in a
|
||||||
durable consumer need.
|
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,
|
Go declarations and GoDoc own exact API contracts. Compiled-export checks,
|
||||||
dependency checks, and external-package behavioral tests protect the selected
|
dependency checks, and external-package behavioral tests protect the selected
|
||||||
facade boundaries.
|
facade boundaries.
|
||||||
|
|||||||
138
docs/release.md
138
docs/release.md
@@ -1,18 +1,30 @@
|
|||||||
# ReleaseKit release procedure
|
# ReleaseKit release procedure
|
||||||
|
|
||||||
ReleaseKit's deliverables are its source tag and a hosted Gitea page containing
|
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 complete checked-in release note. Deliver the page locally after publication
|
||||||
the prepared [Woodpecker workflow](../examples/woodpecker/releasekit-source-delivery.yml);
|
or asynchronously through the prepared
|
||||||
it is not a source-release completion check and carries no binary assets. The
|
[Woodpecker workflow](../examples/woodpecker/releasekit-source-delivery.yml).
|
||||||
authoritative repository is `origin` at
|
Hosted-page delivery has a separate status from source-release completion and
|
||||||
`https://gitea.maximumdirect.net/eric/ReleaseKit.git`, and the release branch
|
carries no binary assets. The authoritative repository is `origin` at
|
||||||
is `main`. A release completes when the intended annotated tag is successfully
|
`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
|
||||||
|
when the intended annotated tag is successfully
|
||||||
pushed, or when one exact-object recovery query confirms that the push
|
pushed, or when one exact-object recovery query confirms that the push
|
||||||
succeeded. CI and consumer installation are not completion checks.
|
succeeded. CI and consumer installation are not completion checks.
|
||||||
|
|
||||||
## Prepare a candidate
|
## Prepare a candidate
|
||||||
|
|
||||||
Use a clean clone on `main`. The chosen build toolchain is Go 1.26.8. Make that
|
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
|
local installation the active `go` command for preparation, building the release
|
||||||
executable, and candidate validation:
|
executable, and candidate validation:
|
||||||
|
|
||||||
@@ -43,9 +55,9 @@ the exact version. Summarize the change and any compatibility or upgrade impact.
|
|||||||
End the note with the three license links at the immutable source tag:
|
End the note with the three license links at the immutable source tag:
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/vX.Y.Z/COPYRIGHT) |
|
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/vX.Y.Z/COPYRIGHT) |
|
||||||
[LICENSE](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/vX.Y.Z/LICENSE) |
|
[LICENSE](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/vX.Y.Z/LICENSE) |
|
||||||
[ATTRIBUTION](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/vX.Y.Z/docs/oss/attribution.md)
|
[ATTRIBUTION](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/vX.Y.Z/docs/oss/attribution.md)
|
||||||
```
|
```
|
||||||
|
|
||||||
Replace every `vX.Y.Z` with the selected version. These are links to the same
|
Replace every `vX.Y.Z` with the selected version. These are links to the same
|
||||||
@@ -96,45 +108,81 @@ for ReleaseKit's first release before any released ReleaseKit executable
|
|||||||
exists. It uses the local Go toolchain. Preparation must finish before the
|
exists. It uses the local Go toolchain. Preparation must finish before the
|
||||||
offline validation begins.
|
offline validation begins.
|
||||||
|
|
||||||
## Resolve failures
|
## Deliver the hosted release page
|
||||||
|
|
||||||
A failed push triggers a URL-resolution check against the captured endpoint,
|
After source publication succeeds, deliver the hosted page locally when
|
||||||
then at most one exact authoritative tag query. If the endpoint no longer
|
Woodpecker automation is inactive. From the same repository root, keep the
|
||||||
resolves to itself, recovery reports `unknown` without querying the rewritten
|
worktree clean and `HEAD` at the published tag's commit. Reuse the executable
|
||||||
destination. An exact tag-object match confirms publication even when Git
|
built by the publication command:
|
||||||
reported an error. A proven absent tag is `not_published`; a different object
|
|
||||||
is a conflict; an unreadable query is `unknown`. The command retains its local
|
|
||||||
tag and reports the state, phase,
|
|
||||||
candidate commit, tag object, and captured publication endpoint when available.
|
|
||||||
Do not retry an unknown result until you resolve the authoritative ref at that
|
|
||||||
exact endpoint. Copy the reported endpoint value into the variable below using
|
|
||||||
normal shell quoting; retain the version and tag object from the same result.
|
|
||||||
The command checks that Git URL rewriting still resolves this value to itself;
|
|
||||||
the manual check below applies the same rule before querying:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
releasekit_endpoint='COPY_CAPTURED_ENDPOINT'
|
.releasekit/bin/releasekit deliver vX.Y.Z
|
||||||
releasekit_version='vX.Y.Z'
|
|
||||||
test "$(git ls-remote --get-url -- "$releasekit_endpoint")" = "$releasekit_endpoint" &&
|
|
||||||
git ls-remote --refs -- "$releasekit_endpoint" "refs/tags/$releasekit_version"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
A failed URL-equality check or remote query leaves the outcome unresolved.
|
The command uses the configured Gitea destination and token source to deliver
|
||||||
Correct transport configuration before continuing. The reported endpoint may
|
the complete checked-in release note. Report its result separately from the
|
||||||
be different from `origin`'s fetch URL or its current push URL; querying either
|
completed source release. For failed or uncertain delivery, follow the recovery
|
||||||
of those does not establish absence at the captured endpoint.
|
instructions below using the same immutable version.
|
||||||
|
|
||||||
Compare the returned object ID with the tag object reported by the command.
|
## Resolve publication failures
|
||||||
An exact match confirms publication. If the ref is absent, the version was not
|
|
||||||
published; only then may you deliberately remove the unpublished local tag
|
Each publication invocation attempts to save its captured identity before the
|
||||||
with `git tag -d vX.Y.Z` before correcting the candidate or retrying. Do not
|
tag push. The CLI reports the saved record path separately from publication
|
||||||
move, overwrite, or delete a tag that exists remotely. If a different object
|
success or failure and, after a push or immediate-reconciliation error, prints a
|
||||||
uses the version, choose a new version after resolving the conflict.
|
copyable command that selects that exact record. Run that command, or use the
|
||||||
|
source-built executable prepared above, to make a separate later observation:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./.releasekit/bin/releasekit reconcile --record '/absolute/path/to/attempt-record.json'
|
||||||
|
```
|
||||||
|
|
||||||
|
The saved evidence fixes the original endpoint and tag identity. Later recovery
|
||||||
|
uses current supported transport settings for the recorded remote name, but it
|
||||||
|
does not substitute the current remote URL, inspect the local tag or current
|
||||||
|
HEAD, load a profile, rerun checks, or change repository state. A completed
|
||||||
|
observation reports `published`, `not_published`, or `conflict`; an incomplete
|
||||||
|
observation reports `unknown` with diagnostic context. Resolve an unknown result
|
||||||
|
by correcting the identified endpoint rewrite, transport configuration, or
|
||||||
|
missing evidence. For incomplete saved identity, select another record with the
|
||||||
|
required fields or use complete typed evidence through the Go API.
|
||||||
|
|
||||||
|
If command output was lost, list local attempts and explicitly select the
|
||||||
|
matching record by its module, full tag, and saved identity:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./.releasekit/bin/releasekit publication-attempts --repo "$PWD"
|
||||||
|
./.releasekit/bin/releasekit reconcile --record '/absolute/path/from-the-list/attempt-record.json'
|
||||||
|
```
|
||||||
|
|
||||||
|
Discovery only reads the local store. It neither queries remotes nor chooses an
|
||||||
|
attempt automatically. If the original recovery entry point is unavailable,
|
||||||
|
select another supported ReleaseKit CLI and pass the same record path explicitly.
|
||||||
|
An older record may lack the endpoint, tag-object ID, candidate commit, or
|
||||||
|
another field required for a conclusive result.
|
||||||
|
|
||||||
|
A later observation that the tag is absent describes the remote at that time;
|
||||||
|
it does not prove the version was never published. Never reuse a version known
|
||||||
|
to have been published, and preserve published tags and contents as immutable.
|
||||||
|
Keep removal of an unpublished local tag, candidate correction, and retry as
|
||||||
|
explicit operator decisions after reviewing the record and authoritative
|
||||||
|
remote state. A conflicting object requires resolving the conflict and selecting
|
||||||
|
an unused version when appropriate; do not move, overwrite, or delete a
|
||||||
|
published tag.
|
||||||
|
|
||||||
|
If record storage fails, ReleaseKit reports the recording warning separately;
|
||||||
|
the warning does not change the actual publication outcome. The saved handoff is
|
||||||
|
then unavailable. A Go caller that retained `PublicationResult.Evidence` can
|
||||||
|
still pass that typed evidence directly to `ReconcilePublication`. Process
|
||||||
|
termination before the record is written, removed storage, and older incomplete
|
||||||
|
records can leave no sufficient saved evidence for later recovery.
|
||||||
|
|
||||||
|
Later reconciliation adds no successful-push read-back, polling, CI, consumer
|
||||||
|
installation, or hosted-delivery gate. Hosted page delivery remains separate.
|
||||||
|
|
||||||
If validation or a guard fails before this invocation pushes, correct and
|
If validation or a guard fails before this invocation pushes, correct and
|
||||||
commit the candidate, publish `main`, and run the command again. A retained
|
commit the candidate, publish `main`, and run the command again. A retained
|
||||||
local tag still requires the authoritative-state check above before removal;
|
local tag still requires reviewing the selected record and authoritative state
|
||||||
a concurrent publisher may already have published the version. The prepared
|
before deciding whether to remove it. The prepared
|
||||||
Woodpecker workflow builds the CLI from the tagged checkout, then uses
|
Woodpecker workflow builds the CLI from the tagged checkout, then uses
|
||||||
`ci-deliver` for page-only delivery. To activate it after configuring the
|
`ci-deliver` for page-only delivery. To activate it after configuring the
|
||||||
destination and secret, copy it to `.woodpecker/releasekit-source-delivery.yml`
|
destination and secret, copy it to `.woodpecker/releasekit-source-delivery.yml`
|
||||||
@@ -148,7 +196,15 @@ git checkout --detach "vX.Y.Z"
|
|||||||
GOTOOLCHAIN=local go mod download
|
GOTOOLCHAIN=local go mod download
|
||||||
mkdir -p .releasekit/bin
|
mkdir -p .releasekit/bin
|
||||||
GOTOOLCHAIN=local GOPROXY=off GOSUMDB=off go build -o .releasekit/bin/releasekit ./cmd/releasekit
|
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
|
The root ignore file excludes `.releasekit/`. Delivery uses the committed note
|
||||||
|
|||||||
59
docs/releases/v0.5.0.md
Normal file
59
docs/releases/v0.5.0.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# ReleaseKit v0.5.0
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
ReleaseKit adopts `go.maximumdirect.net/releasekit` as its Go module identifier.
|
||||||
|
The vanity path resolves to the Gitea repository at
|
||||||
|
`https://forge.maximumdirect.net/base-layer/releasekit`.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- The orchestration facade is `go.maximumdirect.net/releasekit`, the runtime
|
||||||
|
facade is `go.maximumdirect.net/releasekit/buildinfo`, and the executable
|
||||||
|
package is `go.maximumdirect.net/releasekit/cmd/releasekit`.
|
||||||
|
- Imports, version-injection linkage, compiled API checks, consumer fixtures,
|
||||||
|
and maintained release launchers and Woodpecker workflows use the new module
|
||||||
|
identity.
|
||||||
|
- ReleaseKit's hosted-page delivery profile and maintainer guidance use Gitea
|
||||||
|
at `forge.maximumdirect.net`, owner `base-layer`, repository `releasekit`.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
This pre-1.0 minor release changes package import paths and requires consumer
|
||||||
|
migration. Facade roles, command behavior, configuration formats, and the Go
|
||||||
|
1.26.0 consumer minimum retain their existing definitions. The runtime version
|
||||||
|
accessor and builder use the new module's runtime implementation for injection.
|
||||||
|
|
||||||
|
Packages imported through different module paths have distinct Go identities.
|
||||||
|
Coordinate import changes in packages that exchange ReleaseKit facade types,
|
||||||
|
and update builders and applications using `buildinfo` together.
|
||||||
|
|
||||||
|
Published versions through v0.4.2 retain the original
|
||||||
|
`gitea.maximumdirect.net/eric/ReleaseKit` module declaration. Those versions
|
||||||
|
continue to describe their original source and use their matching historical
|
||||||
|
instructions. A vanity-path download of an older tag does not change its module
|
||||||
|
declaration.
|
||||||
|
|
||||||
|
## Upgrade guidance
|
||||||
|
|
||||||
|
1. Update application, library, test, and generated imports for the root facade
|
||||||
|
and `buildinfo` to the new paths. Select v0.5.0 or a later compatible release
|
||||||
|
using the [adoption guide](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.5.0/docs/consumers/adoption.md#migrating-from-the-original-module-path).
|
||||||
|
2. For CLI tool dependencies, remove the old tool declaration and register
|
||||||
|
`go.maximumdirect.net/releasekit/cmd/releasekit` at the selected version.
|
||||||
|
3. Update copied launchers and Woodpecker workflows together with the module
|
||||||
|
selection. The maintained examples use
|
||||||
|
`releasekit_module=go.maximumdirect.net/releasekit`.
|
||||||
|
4. Run `go mod tidy`, review any old-path dependencies retained transitively,
|
||||||
|
and commit module and workflow changes together. Keep existing consumer tags
|
||||||
|
intact; their delivery retries use their recorded module files and matching
|
||||||
|
workflows.
|
||||||
|
|
||||||
|
Source repository and hosted release URLs use the Gitea host; Go imports and
|
||||||
|
tool dependencies use the vanity path. ReleaseKit's source publication and
|
||||||
|
hosted-page delivery retain their separate outcomes under the
|
||||||
|
[maintainer procedure](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.5.0/docs/release.md).
|
||||||
|
|
||||||
|
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.5.0/COPYRIGHT) |
|
||||||
|
[LICENSE](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.5.0/LICENSE) |
|
||||||
|
[ATTRIBUTION](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.5.0/docs/oss/attribution.md)
|
||||||
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)
|
||||||
54
docs/releases/v0.8.0.md
Normal file
54
docs/releases/v0.8.0.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# ReleaseKit v0.8.0
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
ReleaseKit adds read-only recovery for source-publication attempts. It attempts
|
||||||
|
to retain evidence before a tag push, lets operators discover and explicitly
|
||||||
|
select a saved attempt, and observes the original destination without repeating
|
||||||
|
candidate validation or changing tags. Source publication and hosted delivery
|
||||||
|
continue to have separate outcomes.
|
||||||
|
|
||||||
|
## Publication recovery
|
||||||
|
|
||||||
|
- `Publish` returns reusable `PublicationEvidence` and attempts to save a
|
||||||
|
private record. The result reports the saved path or a recording error
|
||||||
|
separately from the publication outcome. Go callers can load or list records
|
||||||
|
and reconcile retained evidence through the root facade.
|
||||||
|
- The CLI adds `publication-attempts` for local discovery and `reconcile` for
|
||||||
|
an explicitly selected record. Both support human-readable and structured
|
||||||
|
output. `publish --record-dir` selects an alternate record directory, and
|
||||||
|
failed publication can print a recovery command for the saved attempt.
|
||||||
|
- Recovery uses the captured endpoint and full tag with current supported Git
|
||||||
|
transport settings. It reports publication, current absence, conflict, or an
|
||||||
|
unresolved observation. Current absence does not prove that a version was
|
||||||
|
never published; published versions and their contents remain immutable.
|
||||||
|
- Records preserve exact endpoint and path bytes. Ordinary UTF-8 evidence uses
|
||||||
|
record format 1; format 2 preserves other bytes. Recovery diagnostics redact
|
||||||
|
HTTP(S) URL userinfo, while private records and typed evidence can contain
|
||||||
|
sensitive endpoints. Operators control record retention and removal.
|
||||||
|
|
||||||
|
## Consumer tools and compatibility
|
||||||
|
|
||||||
|
The module path remains `go.maximumdirect.net/releasekit`, and the Go consumer
|
||||||
|
minimum remains 1.26.0. Existing publication calls and release profiles remain
|
||||||
|
supported. Publication now attempts to write a private record by default; a
|
||||||
|
storage failure is reported separately and does not change the publication
|
||||||
|
result. Applications handling results should inspect `RecordError` when a saved
|
||||||
|
recovery handoff matters.
|
||||||
|
|
||||||
|
Consumers with the CLI registered as a Go tool in their module can run it with
|
||||||
|
`go tool releasekit`.
|
||||||
|
The maintained multi-module launcher and Woodpecker examples use the selected
|
||||||
|
module's tool version. Register the CLI before generating a vendor tree when
|
||||||
|
using a vendored tool. Older recovery CLIs that only support record format 1
|
||||||
|
cannot load format 2 records; use a CLI that supports format 2.
|
||||||
|
|
||||||
|
See the [publication guide](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.8.0/docs/consumers/publish.md),
|
||||||
|
[CLI reference](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.8.0/docs/cli.md),
|
||||||
|
[publication record formats](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.8.0/docs/integrations/publication-records.md),
|
||||||
|
and [consumer adoption guide](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.8.0/docs/consumers/adoption.md)
|
||||||
|
for usage and recovery details.
|
||||||
|
|
||||||
|
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.8.0/COPYRIGHT) |
|
||||||
|
[LICENSE](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.8.0/LICENSE) |
|
||||||
|
[ATTRIBUTION](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.8.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.
|
||||||
@@ -15,13 +15,13 @@ minimum.
|
|||||||
## Select the release tool
|
## Select the release tool
|
||||||
|
|
||||||
The module files are setup skeletons. Set `RELEASEKIT_TOOL_VERSION` to a released
|
The module files are setup skeletons. Set `RELEASEKIT_TOOL_VERSION` to a released
|
||||||
ReleaseKit version containing module-release support, then register that tool in
|
ReleaseKit version at or after the v0.5.0 vanity-path migration, then register
|
||||||
each consumer module before running the launcher or workflow:
|
that tool in each consumer module before running the launcher or workflow:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
: "${RELEASEKIT_TOOL_VERSION:?Select a ReleaseKit release with module-release support}"
|
: "${RELEASEKIT_TOOL_VERSION:?Select a ReleaseKit release with module-release support}"
|
||||||
for module_dir in . integrations/ckconfig integrations/core/v2; do
|
for module_dir in . integrations/ckconfig integrations/core/v2; do
|
||||||
go -C "$module_dir" get -tool "gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit@${RELEASEKIT_TOOL_VERSION}"
|
go -C "$module_dir" get -tool "go.maximumdirect.net/releasekit/cmd/releasekit@${RELEASEKIT_TOOL_VERSION}"
|
||||||
go -C "$module_dir" mod tidy
|
go -C "$module_dir" mod tidy
|
||||||
done
|
done
|
||||||
```
|
```
|
||||||
@@ -72,11 +72,14 @@ Then select a module and semantic version with the shared launcher:
|
|||||||
|
|
||||||
The launcher copies the selected module's `go.mod` and `go.sum` to temporary
|
The launcher copies the selected module's `go.mod` and `go.sum` to temporary
|
||||||
alternate module files outside the checkout, resolves ReleaseKit from that
|
alternate module files outside the checkout, resolves ReleaseKit from that
|
||||||
module's local dependency selection, and builds the tool there. It invokes
|
module's local dependency selection, adds the CLI tool declaration to that
|
||||||
`publish` with the repository profile and `--expected-module` so the library
|
temporary copy, and prepares its dependencies. It uses `go tool releasekit`
|
||||||
can verify that the profile agrees with the selected table row. The temporary
|
with the alternate module file to invoke `publish` with the repository profile
|
||||||
files and tool are removed on exit. As with any `publish` invocation, commit the
|
and `--expected-module` so the library can verify that the profile agrees with
|
||||||
selected module's release note and prepare its repository before publishing.
|
the selected table row. Go builds and caches the executable and preserves its
|
||||||
|
exit status; the launcher removes the temporary metadata on exit. As with any
|
||||||
|
`publish` invocation, commit the selected module's release note and prepare its
|
||||||
|
repository before publishing.
|
||||||
|
|
||||||
If an integration depends on a sibling release, publish the dependency first,
|
If an integration depends on a sibling release, publish the dependency first,
|
||||||
then publish the integration with the version that consumes it. For example,
|
then publish the integration with the version that consumes it. For example,
|
||||||
@@ -86,11 +89,13 @@ This declaration does not infer dependency order or rerun the standard suite.
|
|||||||
The maintained [Woodpecker example](woodpecker.yml) uses the same map and
|
The maintained [Woodpecker example](woodpecker.yml) uses the same map and
|
||||||
selector for root, `ckconfig`, and major-version tags. It verifies and detaches
|
selector for root, `ckconfig`, and major-version tags. It verifies and detaches
|
||||||
the literal event tag before selecting the module, copies that module's metadata
|
the literal event tag before selecting the module, copies that module's metadata
|
||||||
outside the checkout, and builds the ReleaseKit version selected there. It then
|
outside the checkout, and runs the ReleaseKit version selected there through
|
||||||
uses `ci-deliver --prepared` with the mapped profile and expected physical module.
|
`go tool releasekit`. 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
|
The `core-v2` profile demonstrates binary delivery; the other profiles are
|
||||||
page-only. Before enabling the workflow, configure each selected profile's
|
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`; the workflow exposes it as
|
||||||
`RELEASEKIT_GITEA_TOKEN` only in the delivery step.
|
`RELEASEKIT_GITEA_TOKEN` only in the delivery step.
|
||||||
|
|
||||||
@@ -108,9 +113,9 @@ modules:
|
|||||||
: "${RELEASEKIT_SOURCE:?Set the absolute ReleaseKit checkout path}"
|
: "${RELEASEKIT_SOURCE:?Set the absolute ReleaseKit checkout path}"
|
||||||
for module_dir in . integrations/ckconfig integrations/core/v2; do
|
for module_dir in . integrations/ckconfig integrations/core/v2; do
|
||||||
go -C "$module_dir" mod edit \
|
go -C "$module_dir" mod edit \
|
||||||
-require=gitea.maximumdirect.net/eric/ReleaseKit@v0.0.0 \
|
-require=go.maximumdirect.net/releasekit@v0.0.0 \
|
||||||
-replace="gitea.maximumdirect.net/eric/ReleaseKit=$RELEASEKIT_SOURCE" \
|
-replace="go.maximumdirect.net/releasekit=$RELEASEKIT_SOURCE" \
|
||||||
-tool=gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit
|
-tool=go.maximumdirect.net/releasekit/cmd/releasekit
|
||||||
GOTOOLCHAIN=local GOWORK=off go -C "$module_dir" mod tidy
|
GOTOOLCHAIN=local GOWORK=off go -C "$module_dir" mod tidy
|
||||||
done
|
done
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo"
|
"go.maximumdirect.net/releasekit/buildinfo"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|||||||
@@ -31,18 +31,17 @@ else
|
|||||||
: > "$bootstrap_dir/releasekit.sum"
|
: > "$bootstrap_dir/releasekit.sum"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
releasekit_module=gitea.maximumdirect.net/eric/ReleaseKit
|
releasekit_module=go.maximumdirect.net/releasekit
|
||||||
(
|
(
|
||||||
cd "$module_path"
|
cd "$module_path"
|
||||||
export GOWORK=off
|
export GOWORK=off
|
||||||
export GOTOOLCHAIN=local
|
export GOTOOLCHAIN=local
|
||||||
go list -mod=mod -modfile="$bootstrap_dir/releasekit.mod" -m "$releasekit_module"
|
go list -mod=mod -modfile="$bootstrap_dir/releasekit.mod" -m "$releasekit_module"
|
||||||
go build -mod=mod -modfile="$bootstrap_dir/releasekit.mod" \
|
go mod edit -modfile="$bootstrap_dir/releasekit.mod" -tool="$releasekit_module/cmd/releasekit"
|
||||||
-o "$bootstrap_dir/releasekit" "$releasekit_module/cmd/releasekit"
|
go list -mod=mod -modfile="$bootstrap_dir/releasekit.mod" -deps "$releasekit_module/cmd/releasekit" > /dev/null
|
||||||
)
|
go tool -modfile="$bootstrap_dir/releasekit.mod" releasekit publish \
|
||||||
|
|
||||||
"$bootstrap_dir/releasekit" publish \
|
|
||||||
--repo "$repository_dir" \
|
--repo "$repository_dir" \
|
||||||
--config "$profile_path" \
|
--config "$profile_path" \
|
||||||
--expected-module "$module_dir" \
|
--expected-module "$module_dir" \
|
||||||
"$version"
|
"$version"
|
||||||
|
)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ when:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: deliver
|
- name: deliver
|
||||||
image: golang:1.26.8-bookworm
|
image: golang:1.26.10-bookworm
|
||||||
environment:
|
environment:
|
||||||
RELEASEKIT_GITEA_TOKEN:
|
RELEASEKIT_GITEA_TOKEN:
|
||||||
from_secret: releasekit_gitea_token
|
from_secret: releasekit_gitea_token
|
||||||
@@ -40,28 +40,26 @@ steps:
|
|||||||
if [ "$${module_dir}" != . ]; then
|
if [ "$${module_dir}" != . ]; then
|
||||||
module_path="$${CI_WORKSPACE}/$${module_dir}"
|
module_path="$${CI_WORKSPACE}/$${module_dir}"
|
||||||
fi
|
fi
|
||||||
cp "$${module_path}/go.mod" "$${bootstrap_dir}/go.mod"
|
cp "$${module_path}/go.mod" "$${bootstrap_dir}/releasekit.mod"
|
||||||
if [ -f "$${module_path}/go.sum" ]; then
|
if [ -f "$${module_path}/go.sum" ]; then
|
||||||
cp "$${module_path}/go.sum" "$${bootstrap_dir}/go.sum"
|
cp "$${module_path}/go.sum" "$${bootstrap_dir}/releasekit.sum"
|
||||||
else
|
else
|
||||||
: > "$${bootstrap_dir}/go.sum"
|
: > "$${bootstrap_dir}/releasekit.sum"
|
||||||
fi
|
fi
|
||||||
cp "$${bootstrap_dir}/go.mod" "$${bootstrap_dir}/releasekit.mod"
|
|
||||||
cp "$${bootstrap_dir}/go.sum" "$${bootstrap_dir}/releasekit.sum"
|
|
||||||
|
|
||||||
export GOWORK=off
|
export GOWORK=off
|
||||||
export GOTOOLCHAIN=local
|
export GOTOOLCHAIN=local
|
||||||
releasekit_module=gitea.maximumdirect.net/eric/ReleaseKit
|
releasekit_module=go.maximumdirect.net/releasekit
|
||||||
(
|
(
|
||||||
cd "$${module_path}"
|
cd "$${module_path}"
|
||||||
go list -mod=mod -modfile="$${bootstrap_dir}/releasekit.mod" -m "$${releasekit_module}"
|
go list -mod=mod -modfile="$${bootstrap_dir}/releasekit.mod" -m "$${releasekit_module}"
|
||||||
go build -mod=mod -modfile="$${bootstrap_dir}/releasekit.mod" \
|
go mod edit -modfile="$${bootstrap_dir}/releasekit.mod" -tool="$${releasekit_module}/cmd/releasekit"
|
||||||
-o "$${bootstrap_dir}/releasekit" "$${releasekit_module}/cmd/releasekit"
|
go list -mod=mod -modfile="$${bootstrap_dir}/releasekit.mod" -deps "$${releasekit_module}/cmd/releasekit" > /dev/null
|
||||||
)
|
go tool -modfile="$${bootstrap_dir}/releasekit.mod" releasekit ci-deliver \
|
||||||
"$${bootstrap_dir}/releasekit" ci-deliver \
|
|
||||||
--repo "$${CI_WORKSPACE}" \
|
--repo "$${CI_WORKSPACE}" \
|
||||||
--config "$${profile_path}" \
|
--config "$${profile_path}" \
|
||||||
--expected-module "$${module_dir}" \
|
--expected-module "$${module_dir}" \
|
||||||
--prepared \
|
--prepared \
|
||||||
--commit "$${expected_commit}" \
|
--commit "$${expected_commit}" \
|
||||||
"$${CI_COMMIT_TAG}"
|
"$${CI_COMMIT_TAG}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"targets": ["linux/amd64", "linux/arm64", "darwin/arm64"]
|
"targets": ["linux/amd64", "linux/arm64", "darwin/arm64"]
|
||||||
},
|
},
|
||||||
"delivery": {
|
"delivery": {
|
||||||
|
"forge": "gitea",
|
||||||
"base_url": "https://gitea.example",
|
"base_url": "https://gitea.example",
|
||||||
"owner": "example",
|
"owner": "example",
|
||||||
"repository": "example",
|
"repository": "example",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"product": "Example",
|
"product": "Example",
|
||||||
"remote": "origin",
|
"remote": "origin",
|
||||||
"delivery": {
|
"delivery": {
|
||||||
|
"forge": "gitea",
|
||||||
"base_url": "https://gitea.example",
|
"base_url": "https://gitea.example",
|
||||||
"owner": "example",
|
"owner": "example",
|
||||||
"repository": "example",
|
"repository": "example",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ when:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: deliver
|
- name: deliver
|
||||||
image: golang:1.26.8-bookworm
|
image: golang:1.26.10-bookworm
|
||||||
environment:
|
environment:
|
||||||
RELEASEKIT_GITEA_TOKEN:
|
RELEASEKIT_GITEA_TOKEN:
|
||||||
from_secret: releasekit_gitea_token
|
from_secret: releasekit_gitea_token
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- GO_IMAGE: golang:1.26.8-bookworm
|
- GO_IMAGE: golang:1.26.10-bookworm
|
||||||
|
|
||||||
when:
|
when:
|
||||||
- event: tag
|
- event: tag
|
||||||
@@ -32,22 +32,20 @@ steps:
|
|||||||
|
|
||||||
bootstrap_dir=$(mktemp -d "$${CI_WORKSPACE%/*}/.releasekit-bootstrap.XXXXXX")
|
bootstrap_dir=$(mktemp -d "$${CI_WORKSPACE%/*}/.releasekit-bootstrap.XXXXXX")
|
||||||
trap 'rm -rf "$${bootstrap_dir}"' EXIT
|
trap 'rm -rf "$${bootstrap_dir}"' EXIT
|
||||||
cp go.mod "$${bootstrap_dir}/go.mod"
|
cp go.mod "$${bootstrap_dir}/releasekit.mod"
|
||||||
if [ -f go.sum ]; then
|
if [ -f go.sum ]; then
|
||||||
cp go.sum "$${bootstrap_dir}/go.sum"
|
cp go.sum "$${bootstrap_dir}/releasekit.sum"
|
||||||
else
|
else
|
||||||
: > "$${bootstrap_dir}/go.sum"
|
: > "$${bootstrap_dir}/releasekit.sum"
|
||||||
fi
|
fi
|
||||||
cp "$${bootstrap_dir}/go.mod" "$${bootstrap_dir}/releasekit.mod"
|
|
||||||
cp "$${bootstrap_dir}/go.sum" "$${bootstrap_dir}/releasekit.sum"
|
|
||||||
|
|
||||||
export GOWORK=off
|
export GOWORK=off
|
||||||
export GOTOOLCHAIN=local
|
export GOTOOLCHAIN=local
|
||||||
releasekit_module=gitea.maximumdirect.net/eric/ReleaseKit
|
releasekit_module=go.maximumdirect.net/releasekit
|
||||||
go list -mod=mod -modfile="$${bootstrap_dir}/releasekit.mod" -m "$${releasekit_module}"
|
go list -mod=mod -modfile="$${bootstrap_dir}/releasekit.mod" -m "$${releasekit_module}"
|
||||||
go build -mod=mod -modfile="$${bootstrap_dir}/releasekit.mod" \
|
go mod edit -modfile="$${bootstrap_dir}/releasekit.mod" -tool="$${releasekit_module}/cmd/releasekit"
|
||||||
-o "$${bootstrap_dir}/releasekit" "$${releasekit_module}/cmd/releasekit"
|
go list -mod=mod -modfile="$${bootstrap_dir}/releasekit.mod" -deps "$${releasekit_module}/cmd/releasekit" > /dev/null
|
||||||
"$${bootstrap_dir}/releasekit" ci-deliver \
|
go tool -modfile="$${bootstrap_dir}/releasekit.mod" releasekit ci-deliver \
|
||||||
--repo "$${CI_WORKSPACE}" \
|
--repo "$${CI_WORKSPACE}" \
|
||||||
--prepared \
|
--prepared \
|
||||||
--commit "$${expected_commit}" \
|
--commit "$${expected_commit}" \
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -1,4 +1,4 @@
|
|||||||
module gitea.maximumdirect.net/eric/ReleaseKit
|
module go.maximumdirect.net/releasekit
|
||||||
|
|
||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
const modulePath = "gitea.maximumdirect.net/eric/ReleaseKit"
|
const modulePath = "go.maximumdirect.net/releasekit"
|
||||||
|
|
||||||
var facades = []string{modulePath, modulePath + "/buildinfo"}
|
var facades = []string{modulePath, modulePath + "/buildinfo"}
|
||||||
|
|
||||||
@@ -226,6 +226,33 @@ func checkImportDirections(packages []listedPackage) error {
|
|||||||
}
|
}
|
||||||
visit(modulePath + "/buildinfo")
|
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 {
|
if len(violations) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -254,7 +281,7 @@ func commandInternalRoot(path string) string {
|
|||||||
func forbiddenRuntimeDependency(path string) bool {
|
func forbiddenRuntimeDependency(path string) bool {
|
||||||
for _, part := range strings.Split(strings.ToLower(path), "/") {
|
for _, part := range strings.Split(strings.ToLower(path), "/") {
|
||||||
switch part {
|
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
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,26 @@ import (
|
|||||||
"testing"
|
"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) {
|
func TestExportedContractBoundaries(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
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 {
|
func checkFixture(t *testing.T, facadeSource string) []string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
fset := token.NewFileSet()
|
fset := token.NewFileSet()
|
||||||
|
|||||||
@@ -90,3 +90,30 @@ func ExpectedBuild(binaryName, version string, targets []Target) []Declaration {
|
|||||||
}
|
}
|
||||||
return declarations
|
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
|
package build
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
@@ -9,16 +10,17 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact"
|
"go.maximumdirect.net/releasekit/internal/artifact"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate"
|
"go.maximumdirect.net/releasekit/internal/candidate"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool"
|
"go.maximumdirect.net/releasekit/internal/git"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity"
|
"go.maximumdirect.net/releasekit/internal/gotool"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/tempdir"
|
"go.maximumdirect.net/releasekit/internal/process"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
|
"go.maximumdirect.net/releasekit/internal/releaseidentity"
|
||||||
|
"go.maximumdirect.net/releasekit/internal/tempdir"
|
||||||
|
"go.maximumdirect.net/releasekit/internal/versioncontract"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Target = artifact.Target
|
type Target = artifact.Target
|
||||||
@@ -48,27 +50,25 @@ type Result struct {
|
|||||||
SourceVerified bool
|
SourceVerified bool
|
||||||
Settings artifact.Settings
|
Settings artifact.Settings
|
||||||
Version string
|
Version string
|
||||||
MainPackage string
|
|
||||||
BinaryName string
|
|
||||||
OutputDir string
|
OutputDir string
|
||||||
Targets []Target
|
|
||||||
Artifacts []Artifact
|
Artifacts []Artifact
|
||||||
Compiler string
|
Compiler string
|
||||||
}
|
}
|
||||||
|
|
||||||
func BuildResolved(ctx context.Context, request Request) (Result, error) {
|
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
|
identity := request.Identity
|
||||||
settings := artifact.NormalizeSettings(request.Spec.MainPackage, request.Spec.BinaryName, request.Spec.Targets)
|
settings := artifact.NormalizeSettings(request.Spec.MainPackage, request.Spec.BinaryName, request.Spec.Targets)
|
||||||
result := Result{
|
result = Result{
|
||||||
RepositoryDir: identity.RepositoryDir,
|
RepositoryDir: identity.RepositoryDir,
|
||||||
ModuleDir: identity.ModuleDir,
|
ModuleDir: identity.ModuleDir,
|
||||||
ModulePath: identity.ModulePath,
|
ModulePath: identity.ModulePath,
|
||||||
Tag: identity.Tag,
|
Tag: identity.Tag,
|
||||||
Settings: settings,
|
Settings: settings,
|
||||||
Version: identity.Version,
|
Version: identity.Version,
|
||||||
MainPackage: settings.MainPackage,
|
|
||||||
BinaryName: settings.BinaryName,
|
|
||||||
Targets: settings.Targets,
|
|
||||||
}
|
}
|
||||||
if !versioncontract.ValidStableVersion(identity.Version) {
|
if !versioncontract.ValidStableVersion(identity.Version) {
|
||||||
return result, fmt.Errorf("%w: %q", candidate.ErrInvalidVersion, 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 == "" {
|
if identity.RepositoryDir == "" || identity.ModuleDir == "" || identity.ModulePath == "" {
|
||||||
return result, errors.New("build requires a resolved module identity")
|
return result, errors.New("build requires a resolved module identity")
|
||||||
}
|
}
|
||||||
if len(result.Targets) != 0 {
|
if len(settings.Targets) != 0 {
|
||||||
if settings.MainPackage == "" {
|
if settings.MainPackage == "" {
|
||||||
return result, errors.New("build requires a main package when targets are selected")
|
return result, errors.New("build requires a main package when targets are selected")
|
||||||
}
|
}
|
||||||
if !artifact.ValidComponent(settings.BinaryName) {
|
if !artifact.ValidComponent(settings.BinaryName) {
|
||||||
return result, fmt.Errorf("unsafe binary name %q", 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) {
|
if !artifact.ValidComponent(target.OS) || !artifact.ValidComponent(target.Arch) {
|
||||||
return result, fmt.Errorf("unsafe target %q/%q", target.OS, 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)
|
return result, fmt.Errorf("resolve module directory: %w", err)
|
||||||
}
|
}
|
||||||
result.ModuleDir = moduleDir
|
result.ModuleDir = moduleDir
|
||||||
commit, err := gitCommit(ctx, repositoryDir)
|
commit, err := git.CaptureHead(ctx, repositoryDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result, err
|
return result, fmt.Errorf("read source commit: %w", err)
|
||||||
}
|
}
|
||||||
result.CandidateCommit = commit
|
result.CandidateCommit = commit
|
||||||
cleanBefore, err := cleanSource(ctx, repositoryDir)
|
statusBefore, err := git.WorktreeStatus(ctx, repositoryDir)
|
||||||
if err != nil {
|
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)
|
result.OutputDir = artifact.OutputDirectory(moduleDir, request.OutputDir)
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
@@ -143,7 +143,7 @@ func BuildResolved(ctx context.Context, request Request) (Result, error) {
|
|||||||
goHost := goTool.HostTarget()
|
goHost := goTool.HostTarget()
|
||||||
host := Target{OS: goHost.OS, Arch: goHost.Arch}
|
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 {
|
if err := ctx.Err(); err != nil {
|
||||||
return result, fmt.Errorf("build canceled: %w", err)
|
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)
|
return result, fmt.Errorf("build target %s/%s: %w", target.OS, target.Arch, err)
|
||||||
}
|
}
|
||||||
artifact := Artifact{Path: path, Filename: filename, Target: target}
|
artifact := Artifact{Path: path, Filename: filename, Target: target}
|
||||||
result.Artifacts = append(result.Artifacts, artifact)
|
if err := hashArtifact(&artifact); err != nil {
|
||||||
if err := hashArtifact(&result.Artifacts[len(result.Artifacts)-1]); err != nil {
|
|
||||||
return result, fmt.Errorf("hash build output %q: %w", path, err)
|
return result, fmt.Errorf("hash build output %q: %w", path, err)
|
||||||
}
|
}
|
||||||
|
result.Artifacts = append(result.Artifacts, artifact)
|
||||||
}
|
}
|
||||||
|
|
||||||
var smokePath string
|
var smokePath string
|
||||||
@@ -174,7 +174,11 @@ func BuildResolved(ctx context.Context, request Request) (Result, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return result, fmt.Errorf("create native smoke directory: %w", err)
|
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)
|
smokePath = filepath.Join(temporaryDir, settings.BinaryName)
|
||||||
if host.OS == "windows" {
|
if host.OS == "windows" {
|
||||||
smokePath += ".exe"
|
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 {
|
if err := checkVersion(ctx, smokePath, settings.BinaryName, identity.Version); err != nil {
|
||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
commitAfter, err := gitCommit(ctx, repositoryDir)
|
commitAfter, err := git.CaptureHead(ctx, repositoryDir)
|
||||||
if err != nil {
|
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 {
|
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
|
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 {
|
func buildTarget(ctx context.Context, goTool *gotool.Tool, moduleDir, mainPackage, outputPath, version string, target Target) error {
|
||||||
ldflags := "-s -w -X " + versioncontract.InjectedVersionLinkerSymbol + "=" + version
|
ldflags := "-s -w -X " + versioncontract.InjectedVersionLinkerSymbol + "=" + version
|
||||||
err := goTool.Run(ctx, moduleDir, gotool.Static, gotool.Target{OS: target.OS, Arch: target.Arch}, nil,
|
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 {
|
func checkVersion(ctx context.Context, binaryPath, binaryName, version string) error {
|
||||||
command := exec.CommandContext(ctx, binaryPath, "version")
|
var output bytes.Buffer
|
||||||
output, err := command.CombinedOutput()
|
err := process.Run(ctx, process.Invocation{Args: []string{binaryPath, "version"}, Stdout: &output, Stderr: &output})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if ctx.Err() != nil {
|
return fmt.Errorf("run native version check: %w: %s", err, strings.TrimSpace(output.String()))
|
||||||
return fmt.Errorf("run native version check: %w", ctx.Err())
|
|
||||||
}
|
|
||||||
return fmt.Errorf("run native version check: %w: %s", err, strings.TrimSpace(string(output)))
|
|
||||||
}
|
}
|
||||||
want := binaryName + " " + version + "\n"
|
want := binaryName + " " + version + "\n"
|
||||||
if string(output) != want {
|
if output.String() != want {
|
||||||
return fmt.Errorf("native version output = %q, want %q", output, want)
|
return fmt.Errorf("native version output = %q, want %q", output.String(), want)
|
||||||
}
|
}
|
||||||
return nil
|
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,24 +2,29 @@ package build
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate"
|
"go.maximumdirect.net/releasekit/internal/candidate"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool"
|
"go.maximumdirect.net/releasekit/internal/gotool"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity"
|
"go.maximumdirect.net/releasekit/internal/releaseidentity"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/tempdir"
|
"go.maximumdirect.net/releasekit/internal/tempdir"
|
||||||
)
|
)
|
||||||
|
|
||||||
func CandidateCallback(spec *Spec, output io.Writer) func(context.Context, candidate.Candidate, *gotool.Owner) error {
|
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 {
|
if spec == nil || len(spec.Targets) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
selected := *spec
|
selected := *spec
|
||||||
selected.Targets = append([]Target(nil), spec.Targets...)
|
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)
|
moduleSubdir, err := filepath.Rel(captured.RepositoryDir, captured.ModuleDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("resolve captured module path: %w", err)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("create temporary candidate build output: %w", err)
|
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{
|
_, err = BuildResolved(ctx, Request{
|
||||||
Identity: identity,
|
Identity: identity,
|
||||||
Spec: selected,
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,12 +10,12 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
|
"go.maximumdirect.net/releasekit/internal/git"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool"
|
"go.maximumdirect.net/releasekit/internal/gotool"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/process"
|
"go.maximumdirect.net/releasekit/internal/process"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity"
|
"go.maximumdirect.net/releasekit/internal/releaseidentity"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/validation"
|
"go.maximumdirect.net/releasekit/internal/validation"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
|
"go.maximumdirect.net/releasekit/internal/versioncontract"
|
||||||
)
|
)
|
||||||
|
|
||||||
var ErrInvalidVersion = errors.New("invalid stable version")
|
var ErrInvalidVersion = errors.New("invalid stable version")
|
||||||
@@ -105,6 +105,9 @@ func Capture(ctx context.Context, request Request) (Candidate, error) {
|
|||||||
if err := validateNote(note, request.Product, request.Version); err != nil {
|
if err := validateNote(note, request.Product, request.Version); err != nil {
|
||||||
return captured, fmt.Errorf("release note %q: %w", identity.NotePath, err)
|
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 {
|
if exists, err := hasLocalTag(ctx, repositoryDir, identity.Tag); err != nil {
|
||||||
return captured, fmt.Errorf("inspect local tag %q: %w", identity.Tag, err)
|
return captured, fmt.Errorf("inspect local tag %q: %w", identity.Tag, err)
|
||||||
} else if exists {
|
} else if exists {
|
||||||
@@ -191,18 +194,18 @@ func inspectLocalState(ctx context.Context, repositoryDir string) (localState, e
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return localState{}, fmt.Errorf("inspect current branch: %w", err)
|
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 {
|
if err != nil {
|
||||||
return localState{}, fmt.Errorf("capture HEAD commit: %w", err)
|
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 {
|
if err != nil {
|
||||||
return localState{}, fmt.Errorf("inspect worktree and index: %w", err)
|
return localState{}, fmt.Errorf("inspect worktree and index: %w", err)
|
||||||
}
|
}
|
||||||
return localState{
|
return localState{
|
||||||
branch: strings.TrimSpace(string(branchOutput)),
|
branch: strings.TrimSpace(string(branchOutput)),
|
||||||
commit: strings.TrimSpace(string(commitOutput)),
|
commit: commit,
|
||||||
status: strings.TrimSpace(string(statusOutput)),
|
status: status,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,6 +215,7 @@ func gitOutput(ctx context.Context, repositoryDir string, args ...string) ([]byt
|
|||||||
err := process.Run(ctx, process.Invocation{
|
err := process.Run(ctx, process.Invocation{
|
||||||
Args: commandArgs,
|
Args: commandArgs,
|
||||||
Dir: repositoryDir,
|
Dir: repositoryDir,
|
||||||
|
Env: map[string]string{"GIT_NO_REPLACE_OBJECTS": "1"},
|
||||||
Stdout: &stdout,
|
Stdout: &stdout,
|
||||||
Stderr: &stderr,
|
Stderr: &stderr,
|
||||||
})
|
})
|
||||||
@@ -230,6 +234,7 @@ func hasLocalTag(ctx context.Context, repositoryDir, tag string) (bool, error) {
|
|||||||
err := process.Run(ctx, process.Invocation{
|
err := process.Run(ctx, process.Invocation{
|
||||||
Args: []string{"git", "show-ref", "--verify", "--quiet", "refs/tags/" + tag},
|
Args: []string{"git", "show-ref", "--verify", "--quiet", "refs/tags/" + tag},
|
||||||
Dir: repositoryDir,
|
Dir: repositoryDir,
|
||||||
|
Env: map[string]string{"GIT_NO_REPLACE_OBJECTS": "1"},
|
||||||
Stderr: &stderr,
|
Stderr: &stderr,
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool"
|
"go.maximumdirect.net/releasekit/internal/gotool"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/validation"
|
"go.maximumdirect.net/releasekit/internal/validation"
|
||||||
)
|
)
|
||||||
|
|
||||||
const testVersion = "v1.2.3"
|
const testVersion = "v1.2.3"
|
||||||
|
|||||||
@@ -5,12 +5,11 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact"
|
"go.maximumdirect.net/releasekit/internal/artifact"
|
||||||
internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build"
|
internalbuild "go.maximumdirect.net/releasekit/internal/build"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func declaredBuildAssets(result internalbuild.Result) ([]gitea.ExpectedAsset, error) {
|
func declaredBuildAssets(result internalbuild.Result) ([]artifact.ExpectedAsset, error) {
|
||||||
settings := result.Settings
|
settings := result.Settings
|
||||||
if len(settings.Targets) == 0 {
|
if len(settings.Targets) == 0 {
|
||||||
if len(result.Artifacts) != 0 {
|
if len(result.Artifacts) != 0 {
|
||||||
@@ -18,47 +17,33 @@ func declaredBuildAssets(result internalbuild.Result) ([]gitea.ExpectedAsset, er
|
|||||||
}
|
}
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
if len(result.Artifacts) != len(settings.Targets) {
|
declarations := make([]artifact.Declaration, 0, len(result.Artifacts))
|
||||||
return nil, fmt.Errorf("build result must contain one artifact for each selected target")
|
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)
|
if err := artifact.ValidateDeclarations(settings.BinaryName, result.Version, settings.Targets, declarations); err != nil {
|
||||||
wantByTarget := make(map[artifact.Target]string, len(declarations))
|
return nil, fmt.Errorf("build result: %w", err)
|
||||||
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 {
|
assets := make([]artifact.ExpectedAsset, 0, len(result.Artifacts)+1)
|
||||||
return nil, fmt.Errorf("build result repeats target %q", target.OS+"/"+target.Arch)
|
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)
|
||||||
}
|
}
|
||||||
wantByTarget[target] = declaration.Filename
|
info, err := os.Stat(builtArtifact.Path)
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
if err != nil {
|
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() {
|
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{
|
assets = append(assets, artifact.ExpectedAsset{
|
||||||
Filename: artifact.Filename,
|
Filename: builtArtifact.Filename,
|
||||||
SHA256: artifact.SHA256,
|
SHA256: builtArtifact.SHA256,
|
||||||
Size: info.Size(),
|
Size: info.Size(),
|
||||||
Path: artifact.Path,
|
Path: builtArtifact.Path,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
manifest, err := gitea.BuildManifest(assets)
|
manifest, err := artifact.BuildManifest(assets)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("build checksum manifest: %w", err)
|
return nil, fmt.Errorf("build checksum manifest: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact"
|
"go.maximumdirect.net/releasekit/internal/artifact"
|
||||||
internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build"
|
internalbuild "go.maximumdirect.net/releasekit/internal/build"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDeclaredBuildAssetsGenerateManifestFromBuildResult(t *testing.T) {
|
func TestDeclaredBuildAssetsGenerateManifestFromBuildResult(t *testing.T) {
|
||||||
@@ -38,7 +37,7 @@ func TestDeclaredBuildAssetsGenerateManifestFromBuildResult(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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)
|
t.Fatalf("assets = %#v", result)
|
||||||
}
|
}
|
||||||
want := assetDigest(second) + " app-v1.2.3-darwin-arm64\n" + assetDigest(first) + " app-v1.2.3-linux-amd64\n"
|
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,14 +9,15 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build"
|
"go.maximumdirect.net/releasekit/internal/artifact"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate"
|
internalbuild "go.maximumdirect.net/releasekit/internal/build"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
|
"go.maximumdirect.net/releasekit/internal/candidate"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea"
|
"go.maximumdirect.net/releasekit/internal/forge"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool"
|
"go.maximumdirect.net/releasekit/internal/git"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity"
|
"go.maximumdirect.net/releasekit/internal/gotool"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/tempdir"
|
"go.maximumdirect.net/releasekit/internal/releaseidentity"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
|
"go.maximumdirect.net/releasekit/internal/tempdir"
|
||||||
|
"go.maximumdirect.net/releasekit/internal/versioncontract"
|
||||||
)
|
)
|
||||||
|
|
||||||
type State string
|
type State string
|
||||||
@@ -35,7 +36,7 @@ type Request struct {
|
|||||||
Product string
|
Product string
|
||||||
Version string
|
Version string
|
||||||
ExpectedTag string
|
ExpectedTag string
|
||||||
Destination gitea.Destination
|
Backend forge.Backend
|
||||||
Build *internalbuild.Spec
|
Build *internalbuild.Spec
|
||||||
Prebuilt *internalbuild.Result
|
Prebuilt *internalbuild.Result
|
||||||
Output io.Writer
|
Output io.Writer
|
||||||
@@ -49,15 +50,19 @@ type Result struct {
|
|||||||
CandidateCommit string
|
CandidateCommit string
|
||||||
ReleaseURL string
|
ReleaseURL string
|
||||||
State State
|
State State
|
||||||
Matching []gitea.AssetIdentity
|
Matching []AssetIdentity
|
||||||
Uploaded []gitea.AssetIdentity
|
Uploaded []AssetIdentity
|
||||||
Pending []gitea.AssetIdentity
|
Pending []AssetIdentity
|
||||||
Conflicting []gitea.AssetIdentity
|
Conflicting []AssetIdentity
|
||||||
Uncertain []gitea.AssetIdentity
|
Uncertain []AssetIdentity
|
||||||
}
|
}
|
||||||
|
|
||||||
func Deliver(ctx context.Context, request Request) (Result, error) {
|
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) {
|
if !versioncontract.ValidStableVersion(request.Version) {
|
||||||
return result, candidate.ErrInvalidVersion
|
return result, candidate.ErrInvalidVersion
|
||||||
}
|
}
|
||||||
@@ -67,6 +72,9 @@ func Deliver(ctx context.Context, request Request) (Result, error) {
|
|||||||
if request.Build != nil && request.Prebuilt != nil {
|
if request.Build != nil && request.Prebuilt != nil {
|
||||||
return result, errors.New("delivery cannot use both a managed build and prebuilt artifacts")
|
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)
|
repositoryDir, err := git.RepositoryRoot(ctx, request.RepositoryDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result, fmt.Errorf("resolve repository directory: %w", err)
|
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 {
|
if err := candidate.ValidateReleaseNote(note, request.Product, request.Version); err != nil {
|
||||||
return result, fmt.Errorf("release note %q: %w", identity.NotePath, err)
|
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
|
build := request.Prebuilt
|
||||||
if request.Build != nil && len(request.Build.Targets) != 0 {
|
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 {
|
if err != nil {
|
||||||
return result, fmt.Errorf("create temporary delivery build directory: %w", err)
|
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{
|
built, err := internalbuild.BuildResolved(ctx, internalbuild.Request{
|
||||||
Identity: identity,
|
Identity: identity,
|
||||||
Spec: *request.Build,
|
Spec: *request.Build,
|
||||||
@@ -131,7 +151,7 @@ func Deliver(ctx context.Context, request Request) (Result, error) {
|
|||||||
}
|
}
|
||||||
build = &built
|
build = &built
|
||||||
}
|
}
|
||||||
var assets []gitea.ExpectedAsset
|
var assets []artifact.ExpectedAsset
|
||||||
if build != nil {
|
if build != nil {
|
||||||
assets, err = declaredBuildAssets(*build)
|
assets, err = declaredBuildAssets(*build)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -142,8 +162,12 @@ func Deliver(ctx context.Context, request Request) (Result, error) {
|
|||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
if len(assets) > 0 {
|
if len(assets) > 0 {
|
||||||
if err := gitea.VerifyLocalAssets(assets); err != nil {
|
if err := artifact.VerifyLocalAssets(assets); err != nil {
|
||||||
return result, fmt.Errorf("verify build assets: %w", err)
|
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 {
|
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{
|
release, pageEvidence, err := coordinateRelease(ctx, request.Backend, identity.Tag, commit, string(note))
|
||||||
Destination: request.Destination,
|
|
||||||
Tag: identity.Tag,
|
|
||||||
Commit: commit,
|
|
||||||
Note: string(note),
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result.State = stateForError(err, false)
|
result.State = stateForError(err, false)
|
||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
result.ReleaseURL = releaseResult.Release.URL
|
result.ReleaseURL = release.URL
|
||||||
if len(assets) == 0 {
|
if len(assets) == 0 {
|
||||||
result.State = Complete
|
result.State = Complete
|
||||||
return result, nil
|
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 {
|
if err != nil {
|
||||||
result.State = NotAttempted
|
result.State = NotAttempted
|
||||||
if releaseResult.WriteAttempted {
|
if pageEvidence.Attempted {
|
||||||
result.State = Partial
|
result.State = Partial
|
||||||
}
|
}
|
||||||
result.Uncertain = assetIdentities(assets)
|
result.Uncertain = assetIdentities(assets)
|
||||||
return result, err
|
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.Matching = uploadResult.Matching
|
||||||
result.Uploaded = uploadResult.Uploaded
|
result.Uploaded = uploadResult.Uploaded
|
||||||
result.Pending = uploadResult.Pending
|
result.Pending = uploadResult.Pending
|
||||||
result.Conflicting = uploadResult.Conflicting
|
result.Conflicting = uploadResult.Conflicting
|
||||||
result.Uncertain = uploadResult.Uncertain
|
result.Uncertain = uploadResult.Uncertain
|
||||||
result.State = State(uploadResult.State)
|
result.State = State(uploadResult.State)
|
||||||
if result.State == NotAttempted && releaseResult.WriteAttempted {
|
if result.State == NotAttempted && pageEvidence.Attempted {
|
||||||
result.State = Partial
|
result.State = Partial
|
||||||
}
|
}
|
||||||
return result, uploadErr
|
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 build == nil {
|
||||||
if len(assets) != 0 {
|
if len(assets) != 0 {
|
||||||
return errors.New("assets require a build identity")
|
return errors.New("assets require a build identity")
|
||||||
}
|
}
|
||||||
return nil
|
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)
|
buildRepository, err := filepath.Abs(build.RepositoryDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("resolve build repository: %w", err)
|
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")
|
return errors.New("build result does not identify the selected tagged module")
|
||||||
}
|
}
|
||||||
if len(build.Settings.Targets) == 0 {
|
if len(build.Settings.Targets) == 0 {
|
||||||
if len(assets) != 0 {
|
|
||||||
return errors.New("page-only delivery cannot include binary assets")
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if !build.SourceVerified {
|
if !build.SourceVerified {
|
||||||
return errors.New("build source was not verified as clean and unchanged during compilation")
|
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 errors.New("build result does not contain the selected artifacts and complete checksum manifest")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func stateForError(err error, pageCreated bool) State {
|
func stateForError(err error, pageCreated bool) State {
|
||||||
var failure *gitea.Failure
|
var failure *forge.Failure
|
||||||
if errors.As(err, &failure) {
|
if errors.As(err, &failure) {
|
||||||
switch failure.Kind {
|
switch failure.Kind {
|
||||||
case gitea.FailureConflict:
|
case forge.FailureConflict:
|
||||||
return Conflict
|
return Conflict
|
||||||
case gitea.FailureUnknown:
|
case forge.FailureUnknown:
|
||||||
return Unknown
|
return Unknown
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -245,10 +277,10 @@ func stateForError(err error, pageCreated bool) State {
|
|||||||
return NotAttempted
|
return NotAttempted
|
||||||
}
|
}
|
||||||
|
|
||||||
func assetIdentities(assets []gitea.ExpectedAsset) []gitea.AssetIdentity {
|
func assetIdentities(assets []artifact.ExpectedAsset) []AssetIdentity {
|
||||||
result := make([]gitea.AssetIdentity, 0, len(assets))
|
result := make([]AssetIdentity, 0, len(assets))
|
||||||
for _, asset := range 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
|
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
|
||||||
|
}
|
||||||
@@ -8,9 +8,8 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact"
|
"go.maximumdirect.net/releasekit/internal/artifact"
|
||||||
internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build"
|
internalbuild "go.maximumdirect.net/releasekit/internal/build"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const performanceArtifactSize = 16 << 20
|
const performanceArtifactSize = 16 << 20
|
||||||
@@ -45,7 +44,7 @@ func BenchmarkPerformanceAssetPreflight(b *testing.B) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
b.Fatal(err)
|
b.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := gitea.VerifyLocalAssets(assets); err != nil {
|
if err := artifact.VerifyLocalAssets(assets); err != nil {
|
||||||
b.Fatal(err)
|
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
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/process"
|
"go.maximumdirect.net/releasekit/internal/process"
|
||||||
)
|
)
|
||||||
|
|
||||||
var errBlobObjectMissing = errors.New("Git object is missing")
|
var errBlobObjectMissing = errors.New("Git object is missing")
|
||||||
@@ -51,6 +51,9 @@ func (reader *BlobReader) Read(ctx context.Context, objectID string) ([]byte, er
|
|||||||
|
|
||||||
reader.mu.Lock()
|
reader.mu.Lock()
|
||||||
defer reader.mu.Unlock()
|
defer reader.mu.Unlock()
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("read blob %s: %w", objectID, err)
|
||||||
|
}
|
||||||
if reader.terminal != nil {
|
if reader.terminal != nil {
|
||||||
return nil, reader.terminal
|
return nil, reader.terminal
|
||||||
}
|
}
|
||||||
@@ -130,6 +133,7 @@ func (reader *BlobReader) startLocked() error {
|
|||||||
Env: map[string]string{
|
Env: map[string]string{
|
||||||
"GIT_NO_LAZY_FETCH": "1",
|
"GIT_NO_LAZY_FETCH": "1",
|
||||||
"GIT_ALLOW_PROTOCOL": "",
|
"GIT_ALLOW_PROTOCOL": "",
|
||||||
|
"GIT_NO_REPLACE_OBJECTS": "1",
|
||||||
},
|
},
|
||||||
Stderr: &reader.stderr,
|
Stderr: &reader.stderr,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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) {
|
func TestBlobReaderPreservesGitFailureAndStderr(t *testing.T) {
|
||||||
installFakeGit(t, `printf 'fixture Git failure\n' >&2
|
installFakeGit(t, `printf 'fixture Git failure\n' >&2
|
||||||
exit 23`)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
43
internal/git/display.go
Normal file
43
internal/git/display.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DisplayDiagnostic redacts recognized HTTP credentials in a display string.
|
||||||
|
func DisplayDiagnostic(message string) string { return displayDiagnostic(message) }
|
||||||
|
|
||||||
|
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
|
// PrepareTaggedCheckout fetches one explicit tag ref, checks its peeled commit
|
||||||
// against expectedCommit, and checks out that commit detached.
|
// against expectedCommit, and checks out that commit detached.
|
||||||
func PrepareTaggedCheckout(ctx context.Context, repositoryDir, tag, remote, expectedCommit string) error {
|
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
|
return err
|
||||||
}
|
}
|
||||||
if !validObjectID(expectedCommit) {
|
if !validObjectID(expectedCommit) {
|
||||||
@@ -17,7 +17,7 @@ func PrepareTaggedCheckout(ctx context.Context, repositoryDir, tag, remote, expe
|
|||||||
}
|
}
|
||||||
ref := "refs/tags/" + tag
|
ref := "refs/tags/" + tag
|
||||||
if _, err := run(ctx, repositoryDir, nil, "fetch", "--no-tags", remote, ref+":"+ref); err != nil {
|
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)
|
source, err := InspectTaggedSource(ctx, repositoryDir, tag)
|
||||||
if err != nil {
|
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
|
// VerifyTaggedCheckout confirms that the existing local tag, HEAD, and clean
|
||||||
// worktree all identify the expected commit. It does not fetch or check out.
|
// worktree all identify the expected commit. It does not fetch or check out.
|
||||||
func VerifyTaggedCheckout(ctx context.Context, repositoryDir, tag, expectedCommit string) error {
|
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
|
return err
|
||||||
}
|
}
|
||||||
if !validObjectID(expectedCommit) {
|
if !validObjectID(expectedCommit) {
|
||||||
|
|||||||
@@ -9,13 +9,14 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/process"
|
"go.maximumdirect.net/releasekit/internal/process"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrInvalidRemote = errors.New("invalid Git remote")
|
ErrInvalidRemote = errors.New("invalid Git remote")
|
||||||
ErrMultiplePushURLs = errors.New("Git remote has multiple push URLs")
|
ErrMultiplePushURLs = errors.New("Git remote has multiple push URLs")
|
||||||
ErrEndpointChanged = errors.New("Git publication endpoint changed")
|
ErrEndpointChanged = errors.New("Git publication endpoint changed")
|
||||||
|
ErrTransportConfiguration = errors.New("Git transport configuration is unreadable")
|
||||||
ErrRemoteMainMissing = errors.New("authoritative remote has no main branch")
|
ErrRemoteMainMissing = errors.New("authoritative remote has no main branch")
|
||||||
ErrRemoteMainConflict = errors.New("authoritative main does not identify the candidate commit")
|
ErrRemoteMainConflict = errors.New("authoritative main does not identify the candidate commit")
|
||||||
ErrRemoteTagConflict = errors.New("tag already exists on the authoritative remote")
|
ErrRemoteTagConflict = errors.New("tag already exists on the authoritative remote")
|
||||||
@@ -43,7 +44,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// RemoteRef holds an exact ref's object and optional peeled commit. State is
|
// 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 {
|
type RemoteRef struct {
|
||||||
State RefState
|
State RefState
|
||||||
Object string
|
Object string
|
||||||
@@ -60,14 +61,14 @@ type Snapshot struct {
|
|||||||
// command-scoped remote whose read and push URLs resolve to that same endpoint.
|
// command-scoped remote whose read and push URLs resolve to that same endpoint.
|
||||||
func Resolve(ctx context.Context, repositoryDir, remoteName string) (Endpoint, error) {
|
func Resolve(ctx context.Context, repositoryDir, remoteName string) (Endpoint, error) {
|
||||||
if strings.TrimSpace(remoteName) == "" || strings.HasPrefix(remoteName, "-") {
|
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)
|
urls, err := pushURLs(ctx, repositoryDir, remoteName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Endpoint{}, err
|
return Endpoint{}, err
|
||||||
}
|
}
|
||||||
if len(urls) != 1 {
|
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()
|
scopedName, err := newScopedName()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -84,15 +85,56 @@ func Resolve(ctx context.Context, repositoryDir, remoteName string) (Endpoint, e
|
|||||||
return endpoint, nil
|
return endpoint, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ObserveEndpoint creates a read-only command-scoped endpoint from previously
|
||||||
|
// captured publication evidence. The recorded URL is used for both directions;
|
||||||
|
// current transport settings are copied only when the original remote exists.
|
||||||
|
func ObserveEndpoint(ctx context.Context, repositoryDir, endpointURL, remoteName string) (Endpoint, error) {
|
||||||
|
if endpointURL == "" {
|
||||||
|
return Endpoint{}, fmt.Errorf("empty captured endpoint")
|
||||||
|
}
|
||||||
|
scopedName, err := newScopedName()
|
||||||
|
if err != nil {
|
||||||
|
return Endpoint{}, fmt.Errorf("create command-scoped remote name: %w", err)
|
||||||
|
}
|
||||||
|
endpoint := Endpoint{RemoteName: remoteName, PushURL: endpointURL, ScopedName: scopedName}
|
||||||
|
if remoteName != "" {
|
||||||
|
config, configErr := transportConfig(ctx, repositoryDir, remoteName, scopedName)
|
||||||
|
if configErr != nil {
|
||||||
|
return Endpoint{}, configErr
|
||||||
|
}
|
||||||
|
endpoint.transport = config
|
||||||
|
}
|
||||||
|
if err := endpoint.verifyScopedURLs(ctx, repositoryDir); err != nil {
|
||||||
|
return Endpoint{}, err
|
||||||
|
}
|
||||||
|
return endpoint, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EqualObjectID compares supported full object IDs without regard to hex case.
|
||||||
|
func EqualObjectID(left, right string) bool {
|
||||||
|
return validObjectID(left) && validObjectID(right) && strings.EqualFold(left, right)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidObjectID reports whether value is a full supported Git object ID.
|
||||||
|
func ValidObjectID(value string) bool { return validObjectID(value) }
|
||||||
|
|
||||||
|
// DisplayError exposes credential-safe rendering for shared recovery errors.
|
||||||
|
func DisplayError(err error) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return displayError{cause: err}
|
||||||
|
}
|
||||||
|
|
||||||
// Recheck confirms both the selected remote's effective push destination and
|
// Recheck confirms both the selected remote's effective push destination and
|
||||||
// the scoped remote's effective read and push URLs still identify this endpoint.
|
// the scoped remote's effective read and push URLs still identify this endpoint.
|
||||||
func (endpoint Endpoint) Recheck(ctx context.Context, repositoryDir string) error {
|
func (endpoint Endpoint) Recheck(ctx context.Context, repositoryDir string) error {
|
||||||
urls, err := pushURLs(ctx, repositoryDir, endpoint.RemoteName)
|
urls, err := pushURLs(ctx, repositoryDir, endpoint.RemoteName)
|
||||||
if err != nil {
|
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 {
|
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)
|
return endpoint.verifyScopedURLs(ctx, repositoryDir)
|
||||||
}
|
}
|
||||||
@@ -100,7 +142,7 @@ func (endpoint Endpoint) Recheck(ctx context.Context, repositoryDir string) erro
|
|||||||
// Query reads exact main and tag refs. A command failure is unreadable,
|
// Query reads exact main and tag refs. A command failure is unreadable,
|
||||||
// never evidence that a ref is absent.
|
// never evidence that a ref is absent.
|
||||||
func (endpoint Endpoint) Query(ctx context.Context, repositoryDir, tag string) (Snapshot, error) {
|
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
|
return Snapshot{}, err
|
||||||
}
|
}
|
||||||
mainRef := "refs/heads/main"
|
mainRef := "refs/heads/main"
|
||||||
@@ -109,7 +151,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)
|
output, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "ls-remote", endpoint.ScopedName, mainRef, tagRef, peeledRef)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
snapshot := Snapshot{Main: RemoteRef{State: RefUnreadable}, Tag: RemoteRef{State: RefUnreadable}}
|
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}
|
main := RemoteRef{State: RefAbsent}
|
||||||
remoteTag := RemoteRef{State: RefAbsent}
|
remoteTag := RemoteRef{State: RefAbsent}
|
||||||
@@ -119,7 +161,7 @@ func (endpoint Endpoint) Query(ctx context.Context, repositoryDir, tag string) (
|
|||||||
}
|
}
|
||||||
object, name, ok := strings.Cut(line, "\t")
|
object, name, ok := strings.Cut(line, "\t")
|
||||||
if !ok || !validObjectID(object) {
|
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 {
|
switch name {
|
||||||
case mainRef:
|
case mainRef:
|
||||||
@@ -130,7 +172,7 @@ func (endpoint Endpoint) Query(ctx context.Context, repositoryDir, tag string) (
|
|||||||
remoteTag = mergeRef(remoteTag, object, true)
|
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
|
// RequireCandidate requires exact main equality and an absent remote
|
||||||
@@ -161,11 +203,11 @@ func (endpoint Endpoint) RequireCandidate(ctx context.Context, repositoryDir, ta
|
|||||||
func (endpoint Endpoint) verifyScopedURLs(ctx context.Context, repositoryDir string) error {
|
func (endpoint Endpoint) verifyScopedURLs(ctx context.Context, repositoryDir string) error {
|
||||||
readURL, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "ls-remote", "--get-url", endpoint.ScopedName)
|
readURL, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "ls-remote", "--get-url", endpoint.ScopedName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("resolve scoped read URL: %w", err)
|
return fmt.Errorf("%w: resolve scoped read URL: %w", ErrTransportConfiguration, err)
|
||||||
}
|
}
|
||||||
resolved := outputLines(readURL)
|
resolved := outputLines(readURL)
|
||||||
if len(resolved) != 1 || resolved[0] != endpoint.PushURL {
|
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.
|
// The scoped remote sets its push URL explicitly to the same captured value.
|
||||||
// Git applies the same insteadOf rules to both explicit URLs; pushInsteadOf
|
// Git applies the same insteadOf rules to both explicit URLs; pushInsteadOf
|
||||||
@@ -197,7 +239,7 @@ func transportConfig(ctx context.Context, repositoryDir, remoteName, scopedName
|
|||||||
if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
|
if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("read transport setting remote.%s.%s: %w", remoteName, key, err)
|
return nil, fmt.Errorf("%w: read transport setting remote.%s.%s: %w", ErrTransportConfiguration, DisplayURL(remoteName), key, err)
|
||||||
}
|
}
|
||||||
for _, value := range outputLines(output) {
|
for _, value := range outputLines(output) {
|
||||||
config = append(config, "remote."+scopedName+"."+key+"="+value)
|
config = append(config, "remote."+scopedName+"."+key+"="+value)
|
||||||
@@ -209,7 +251,7 @@ func transportConfig(ctx context.Context, repositoryDir, remoteName, scopedName
|
|||||||
func pushURLs(ctx context.Context, repositoryDir, remoteName string) ([]string, error) {
|
func pushURLs(ctx context.Context, repositoryDir, remoteName string) ([]string, error) {
|
||||||
output, err := run(ctx, repositoryDir, nil, "remote", "get-url", "--push", "--all", remoteName)
|
output, err := run(ctx, repositoryDir, nil, "remote", "get-url", "--push", "--all", remoteName)
|
||||||
if err != nil {
|
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)
|
values := outputLines(output)
|
||||||
unique := make([]string, 0, len(values))
|
unique := make([]string, 0, len(values))
|
||||||
@@ -234,13 +276,15 @@ func run(ctx context.Context, dir string, config []string, args ...string) (stri
|
|||||||
err := process.Run(ctx, process.Invocation{
|
err := process.Run(ctx, process.Invocation{
|
||||||
Args: append([]string{"git"}, commandArgs...),
|
Args: append([]string{"git"}, commandArgs...),
|
||||||
Dir: dir,
|
Dir: dir,
|
||||||
|
Env: map[string]string{"GIT_NO_REPLACE_OBJECTS": "1"},
|
||||||
Stdout: &stdout,
|
Stdout: &stdout,
|
||||||
Stderr: &stderr,
|
Stderr: &stderr,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
err = displayError{cause: err}
|
||||||
message := strings.TrimSpace(stderr.String())
|
message := strings.TrimSpace(stderr.String())
|
||||||
if message != "" {
|
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
|
return stdout.String(), err
|
||||||
}
|
}
|
||||||
@@ -294,16 +338,42 @@ func mergeRef(ref RemoteRef, object string, peeled bool) RemoteRef {
|
|||||||
return ref
|
return ref
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkTagRef(ctx context.Context, repositoryDir, tag string) error {
|
func finalizeRef(ref RemoteRef) RemoteRef {
|
||||||
if tag == "" {
|
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 {
|
||||||
|
valid, err := ValidateTagRef(ctx, repositoryDir, tag)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !valid {
|
||||||
return fmt.Errorf("invalid Git tag name %q", tag)
|
return fmt.Errorf("invalid Git tag name %q", tag)
|
||||||
}
|
}
|
||||||
if _, err := run(ctx, repositoryDir, nil, "check-ref-format", "refs/tags/"+tag); err != nil {
|
|
||||||
return fmt.Errorf("invalid Git tag name %q: %w", tag, err)
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidateTagRef distinguishes a rejected ref from failures to run Git or
|
||||||
|
// access the selected repository.
|
||||||
|
func ValidateTagRef(ctx context.Context, repositoryDir, tag string) (bool, error) {
|
||||||
|
if tag == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
_, err := run(ctx, repositoryDir, nil, "check-ref-format", "refs/tags/"+tag)
|
||||||
|
if err == nil {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
var exitErr interface{ ExitCode() int }
|
||||||
|
if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, fmt.Errorf("check Git tag name: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
func validObjectID(object string) bool {
|
func validObjectID(object string) bool {
|
||||||
if len(object) != 40 && len(object) != 64 {
|
if len(object) != 40 && len(object) != 64 {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -42,6 +42,46 @@ func TestResolveUsesPushURLAndQueriesWithoutMutation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestObserveEndpointUsesCapturedURLAndCurrentNamedTransportSettings(t *testing.T) {
|
||||||
|
repo := newRemoteFixture(t)
|
||||||
|
gitRun(t, repo.work, "remote", "remove", "origin")
|
||||||
|
gitRun(t, repo.work, "config", "remote.renamed.receivepack", "git-receive-pack")
|
||||||
|
endpoint, err := ObserveEndpoint(context.Background(), repo.work, repo.bare, "renamed")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if endpoint.PushURL != repo.bare || len(endpoint.transport) != 1 || endpoint.transport[0] != "remote."+endpoint.ScopedName+".receivepack=git-receive-pack" {
|
||||||
|
t.Fatalf("observed endpoint = %#v", endpoint)
|
||||||
|
}
|
||||||
|
ref, err := endpoint.QueryTag(context.Background(), repo.work, "v1.2.3")
|
||||||
|
if err != nil || ref.State != RefAbsent {
|
||||||
|
t.Fatalf("QueryTag() = %#v, %v", ref, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestObserveEndpointDoesNotSelectOriginWithoutAName(t *testing.T) {
|
||||||
|
repo := newRemoteFixture(t)
|
||||||
|
gitRun(t, repo.work, "config", "remote.origin.receivepack", "custom-receive-pack")
|
||||||
|
endpoint, err := ObserveEndpoint(context.Background(), repo.work, repo.bare, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(endpoint.transport) != 0 {
|
||||||
|
t.Fatalf("transport settings = %v; unnamed endpoint must not select origin", endpoint.transport)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestObserveEndpointRejectsCapturedURLRewriting(t *testing.T) {
|
||||||
|
repo := newRemoteFixture(t)
|
||||||
|
replacement := filepath.Join(t.TempDir(), "replacement.git")
|
||||||
|
gitRun(t, repo.work, "init", "--bare", replacement)
|
||||||
|
gitRun(t, repo.work, "config", "url."+replacement+".insteadOf", repo.bare)
|
||||||
|
_, err := ObserveEndpoint(context.Background(), repo.work, repo.bare, "")
|
||||||
|
if !errors.Is(err, ErrEndpointChanged) {
|
||||||
|
t.Fatalf("ObserveEndpoint() error = %v, want ErrEndpointChanged", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRemoteTagOperationsAcceptSlashContainingRefs(t *testing.T) {
|
func TestRemoteTagOperationsAcceptSlashContainingRefs(t *testing.T) {
|
||||||
fixture := newRemoteFixture(t)
|
fixture := newRemoteFixture(t)
|
||||||
endpoint, err := Resolve(context.Background(), fixture.work, "origin")
|
endpoint, err := Resolve(context.Background(), fixture.work, "origin")
|
||||||
@@ -184,6 +224,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 {
|
type remoteFixture struct {
|
||||||
work string
|
work string
|
||||||
bare string
|
bare string
|
||||||
|
|||||||
@@ -3,14 +3,17 @@ package git
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/process"
|
"go.maximumdirect.net/releasekit/internal/process"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TreeEntry records one committed path and its Git object metadata.
|
// TreeEntry records one committed path and its Git object metadata.
|
||||||
@@ -41,6 +44,10 @@ func RepositoryRoot(ctx context.Context, directory string) (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("resolve Git repository root: %w", err)
|
return "", fmt.Errorf("resolve Git repository root: %w", err)
|
||||||
}
|
}
|
||||||
|
return normalizeRepositoryRoot(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRepositoryRoot(root string) (string, error) {
|
||||||
root = strings.TrimSuffix(root, "\n")
|
root = strings.TrimSuffix(root, "\n")
|
||||||
resolved, err := filepath.EvalSymlinks(root)
|
resolved, err := filepath.EvalSymlinks(root)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -49,6 +56,174 @@ func RepositoryRoot(ctx context.Context, directory string) (string, error) {
|
|||||||
return resolved, nil
|
return resolved, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MetadataDirectories returns the checkout's private and shared Git directories.
|
||||||
|
func MetadataDirectories(ctx context.Context, directory string) ([]string, error) {
|
||||||
|
var directories []string
|
||||||
|
for _, flag := range []string{"--absolute-git-dir", "--git-common-dir"} {
|
||||||
|
output, err := run(ctx, directory, nil, "rev-parse", "--path-format=absolute", flag)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("resolve Git metadata directory: %w", err)
|
||||||
|
}
|
||||||
|
resolved, err := normalizeRepositoryRoot(output)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
directories = append(directories, resolved)
|
||||||
|
}
|
||||||
|
return directories, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommonDirectory resolves the shared Git metadata directory.
|
||||||
|
func CommonDirectory(ctx context.Context, directory string) (string, error) {
|
||||||
|
output, err := run(ctx, directory, nil, "rev-parse", "--path-format=absolute", "--git-common-dir")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("resolve shared Git metadata directory: %w", err)
|
||||||
|
}
|
||||||
|
return normalizeRepositoryRoot(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsIgnored checks whether Git excludes the selected worktree path.
|
||||||
|
func IsIgnored(ctx context.Context, directory, path string) (bool, error) {
|
||||||
|
_, err := run(ctx, directory, nil, "check-ignore", "-q", "--", path)
|
||||||
|
if err == nil {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
var exitErr *exec.ExitError
|
||||||
|
if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
// CaptureHead records the checkout commit before identity derived from files
|
||||||
// in that commit is inspected.
|
// in that commit is inspected.
|
||||||
func CaptureHead(ctx context.Context, repositoryDir string) (string, error) {
|
func CaptureHead(ctx context.Context, repositoryDir string) (string, error) {
|
||||||
@@ -63,22 +238,28 @@ func CaptureHead(ctx context.Context, repositoryDir string) (string, error) {
|
|||||||
return head, nil
|
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.
|
// InspectTaggedSource reads the local HEAD, worktree state, and selected tag.
|
||||||
// Both lightweight historical tags and annotated tags are supported.
|
// Both lightweight historical tags and annotated tags are supported.
|
||||||
func InspectTaggedSource(ctx context.Context, repositoryDir, tag string) (TaggedSource, error) {
|
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
|
return TaggedSource{}, err
|
||||||
}
|
}
|
||||||
var source TaggedSource
|
var source TaggedSource
|
||||||
head, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", "HEAD^{commit}")
|
head, err := CaptureHead(ctx, repositoryDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return TaggedSource{}, fmt.Errorf("read checkout HEAD: %w", err)
|
return TaggedSource{}, err
|
||||||
}
|
}
|
||||||
source.Head = strings.TrimSpace(head)
|
source.Head = head
|
||||||
if !validObjectID(source.Head) {
|
status, err := WorktreeStatus(ctx, repositoryDir)
|
||||||
return TaggedSource{}, fmt.Errorf("Git returned an invalid checkout commit %q", source.Head)
|
|
||||||
}
|
|
||||||
status, err := run(ctx, repositoryDir, nil, "status", "--porcelain", "--untracked-files=all")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return TaggedSource{}, fmt.Errorf("inspect tagged checkout state: %w", err)
|
return TaggedSource{}, fmt.Errorf("inspect tagged checkout state: %w", err)
|
||||||
}
|
}
|
||||||
@@ -275,7 +456,7 @@ func readLocalObjects(ctx context.Context, repositoryDir string, input io.Reader
|
|||||||
Args: append([]string{"git"}, args...),
|
Args: append([]string{"git"}, args...),
|
||||||
Dir: repositoryDir,
|
Dir: repositoryDir,
|
||||||
// Disable transports as well for Git versions predating lazy-fetch control.
|
// 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,
|
Stdin: input,
|
||||||
Stdout: &stdout,
|
Stdout: &stdout,
|
||||||
Stderr: &stderr,
|
Stderr: &stderr,
|
||||||
|
|||||||
@@ -35,16 +35,13 @@ func TestInspectTaggedSourceSupportsLightweightAndAnnotatedTags(t *testing.T) {
|
|||||||
func TestTagOperationsAcceptFullSlashContainingRefs(t *testing.T) {
|
func TestTagOperationsAcceptFullSlashContainingRefs(t *testing.T) {
|
||||||
fixture := newRemoteFixture(t)
|
fixture := newRemoteFixture(t)
|
||||||
tag := "integrations/check/v2.3.4"
|
tag := "integrations/check/v2.3.4"
|
||||||
if err := checkTagRef(context.Background(), fixture.work, tag); err != nil {
|
if err := CheckTagRef(context.Background(), fixture.work, tag); err != nil {
|
||||||
t.Fatalf("checkTagRef(%q): %v", tag, err)
|
t.Fatalf("CheckTagRef(%q): %v", tag, err)
|
||||||
}
|
}
|
||||||
object, err := CreateAnnotatedTag(context.Background(), fixture.work, tag, fixture.commit, []byte("release note\n"))
|
object, err := CreateAnnotatedTag(context.Background(), fixture.work, tag, fixture.commit, []byte("release note\n"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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)
|
source, err := InspectTaggedSource(context.Background(), fixture.work, tag)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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) {
|
func TestTagReferenceValidationUsesGitRules(t *testing.T) {
|
||||||
fixture := newRemoteFixture(t)
|
fixture := newRemoteFixture(t)
|
||||||
for _, tag := range []string{"", "v1..2.3", "v1.2.3.lock", "v1.2.3/", "v1.2.3\\bad"} {
|
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 {
|
if err := CheckTagRef(context.Background(), fixture.work, tag); err == nil {
|
||||||
t.Errorf("checkTagRef(%q) succeeded", tag)
|
t.Errorf("CheckTagRef(%q) succeeded", tag)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -142,3 +164,52 @@ func TestInspectTaggedSourceReportsDirtyCheckoutAndReadCommitFile(t *testing.T)
|
|||||||
t.Fatalf("dirty status = %q", source.Status)
|
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 (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CreateAnnotatedTag records the captured note as an unsigned annotated tag
|
// 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) {
|
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
|
return "", err
|
||||||
}
|
}
|
||||||
if !validObjectID(commit) {
|
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)
|
return "", fmt.Errorf("create temporary release note: %w", err)
|
||||||
}
|
}
|
||||||
messagePath := message.Name()
|
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 {
|
if _, err := message.Write(note); err != nil {
|
||||||
_ = message.Close()
|
_ = message.Close()
|
||||||
return "", fmt.Errorf("write temporary release note: %w", err)
|
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
|
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)
|
tagType, err := run(ctx, repositoryDir, nil, "cat-file", "-t", ref)
|
||||||
if err != nil {
|
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" {
|
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)
|
tagObject, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref)
|
||||||
if err != nil {
|
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)
|
tagObject = strings.TrimSpace(tagObject)
|
||||||
if !validObjectID(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+"^{}")
|
peeled, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref+"^{}")
|
||||||
if err != nil {
|
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 {
|
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)
|
tagContents, err := run(ctx, repositoryDir, nil, "cat-file", "tag", ref)
|
||||||
if err != nil {
|
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"))
|
separator := bytes.Index([]byte(tagContents), []byte("\n\n"))
|
||||||
if separator < 0 || !bytes.Equal([]byte(tagContents[separator+2:]), note) {
|
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
|
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
|
// QueryTag verifies the captured endpoint still resolves to itself, then reads
|
||||||
// one exact authoritative version tag and its peeled commit. Failed
|
// one exact authoritative version tag and its peeled commit. Failed
|
||||||
// verification or reads are unreadable and never interpreted as absence.
|
// verification or reads are unreadable and never interpreted as absence.
|
||||||
func (endpoint Endpoint) QueryTag(ctx context.Context, repositoryDir, tag string) (RemoteRef, error) {
|
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
|
return RemoteRef{}, err
|
||||||
}
|
}
|
||||||
if err := endpoint.verifyScopedURLs(ctx, repositoryDir); err != nil {
|
if err := endpoint.verifyScopedURLs(ctx, repositoryDir); err != nil {
|
||||||
@@ -109,13 +100,13 @@ func (endpoint Endpoint) QueryTag(ctx context.Context, repositoryDir, tag string
|
|||||||
peeledRef := ref + "^{}"
|
peeledRef := ref + "^{}"
|
||||||
output, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "ls-remote", endpoint.ScopedName, ref, peeledRef)
|
output, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "ls-remote", endpoint.ScopedName, ref, peeledRef)
|
||||||
if err != nil {
|
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}
|
remoteTag := RemoteRef{State: RefAbsent}
|
||||||
for _, line := range outputLines(output) {
|
for _, line := range outputLines(output) {
|
||||||
object, name, ok := strings.Cut(line, "\t")
|
object, name, ok := strings.Cut(line, "\t")
|
||||||
if !ok || !validObjectID(object) {
|
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 {
|
switch name {
|
||||||
case ref:
|
case ref:
|
||||||
@@ -124,20 +115,20 @@ func (endpoint Endpoint) QueryTag(ctx context.Context, repositoryDir, tag string
|
|||||||
remoteTag = mergeRef(remoteTag, object, true)
|
remoteTag = mergeRef(remoteTag, object, true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return remoteTag, nil
|
return finalizeRef(remoteTag), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// PushTag publishes only the captured tag object to its exact tag ref.
|
// PushTag publishes only the captured tag object to its exact tag ref.
|
||||||
func (endpoint Endpoint) PushTag(ctx context.Context, repositoryDir, tag, tagObject string) error {
|
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
|
return err
|
||||||
}
|
}
|
||||||
if !validObjectID(tagObject) {
|
if !validObjectID(tagObject) {
|
||||||
return fmt.Errorf("invalid tag publication object %q at %q", tagObject, tag)
|
return fmt.Errorf("invalid tag publication object %q at %q", tagObject, tag)
|
||||||
}
|
}
|
||||||
refspec := tagObject + ":refs/tags/" + tag
|
refspec := tagObject + ":refs/tags/" + tag
|
||||||
if _, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "push", "--no-follow-tags", "--porcelain", endpoint.ScopedName, refspec); err != nil {
|
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, endpoint.PushURL, err)
|
return fmt.Errorf("push annotated tag %s to %s: %w", tag, DisplayURL(endpoint.PushURL), err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,43 +2,14 @@ package gitea
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"path"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/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 {
|
type RemoteAsset struct {
|
||||||
ID int64
|
ID int64
|
||||||
Filename string
|
Filename string
|
||||||
@@ -46,18 +17,6 @@ type RemoteAsset struct {
|
|||||||
DownloadURL string
|
DownloadURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
type AssetInspection struct {
|
|
||||||
Expected ExpectedAsset
|
|
||||||
State AssetState
|
|
||||||
Existing []RemoteAsset
|
|
||||||
Err error
|
|
||||||
}
|
|
||||||
|
|
||||||
type Inspection struct {
|
|
||||||
Assets []AssetInspection
|
|
||||||
Unrelated []RemoteAsset
|
|
||||||
}
|
|
||||||
|
|
||||||
type assetResponse struct {
|
type assetResponse struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -65,118 +24,48 @@ type assetResponse struct {
|
|||||||
BrowserDownloadURL string `json:"browser_download_url"`
|
BrowserDownloadURL string `json:"browser_download_url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildManifest creates the deterministic checksum file for the declared
|
func (c Client) listAssets(ctx context.Context, client *http.Client, token string, releaseID int64, apiRoot string) ([]RemoteAsset, error) {
|
||||||
// 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) {
|
|
||||||
if releaseID <= 0 {
|
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"
|
assetsURL := apiRoot + "/releases/" + strconv.FormatInt(releaseID, 10) + "/assets"
|
||||||
var listed []assetResponse
|
var listed []assetResponse
|
||||||
if err := c.getJSON(ctx, c.httpClient(), destination.Token, "list release assets", assetsURL, &listed); err != nil {
|
if err := c.getJSON(ctx, client, token, "list release assets", assetsURL, &listed); err != nil {
|
||||||
return Inspection{}, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if listed == nil {
|
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"))
|
||||||
}
|
}
|
||||||
|
assets := make([]RemoteAsset, 0, len(listed))
|
||||||
remoteByName := make(map[string][]RemoteAsset, len(listed))
|
|
||||||
for _, item := range listed {
|
for _, item := range listed {
|
||||||
if item.ID <= 0 || item.Name == "" || item.Size < 0 {
|
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}
|
assets = append(assets, RemoteAsset{ID: item.ID, Filename: item.Name, Size: item.Size, DownloadURL: item.BrowserDownloadURL})
|
||||||
remoteByName[item.Name] = append(remoteByName[item.Name], remote)
|
|
||||||
}
|
}
|
||||||
inspection := Inspection{Assets: make([]AssetInspection, 0, len(expected))}
|
return assets, nil
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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) == "" {
|
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)
|
assetURL, err := base.Parse(rawURL)
|
||||||
if err != nil || assetURL.User != nil {
|
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 = ""
|
assetURL.Fragment = ""
|
||||||
visited := make(map[string]struct{})
|
visited := make(map[string]struct{})
|
||||||
|
followed := 0
|
||||||
for {
|
for {
|
||||||
key := assetURL.String()
|
key := assetURL.String()
|
||||||
if _, exists := visited[key]; exists {
|
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{}{}
|
visited[key] = struct{}{}
|
||||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, key, nil)
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, key, nil)
|
||||||
if err != 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) {
|
if sameOrigin(base, assetURL) {
|
||||||
request.Header.Set("Authorization", "token "+token)
|
request.Header.Set("Authorization", "token "+token)
|
||||||
@@ -184,63 +73,29 @@ func (c Client) downloadHash(ctx context.Context, client *http.Client, base *url
|
|||||||
request.Header.Set("Accept", "application/octet-stream")
|
request.Header.Set("Accept", "application/octet-stream")
|
||||||
response, err := client.Do(request)
|
response, err := client.Do(request)
|
||||||
if err != nil {
|
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 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()
|
location, locationErr := response.Location()
|
||||||
_ = response.Body.Close()
|
_ = response.Body.Close()
|
||||||
if locationErr != nil || location.User != nil {
|
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 = ""
|
location.Fragment = ""
|
||||||
assetURL = location
|
assetURL = location
|
||||||
|
followed++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
defer response.Body.Close()
|
|
||||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
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()
|
return response.Body, nil
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
func sameOrigin(base, target *url.URL) bool {
|
||||||
|
|||||||
@@ -2,327 +2,204 @@ package gitea
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/releasekit/internal/forge"
|
||||||
)
|
)
|
||||||
|
|
||||||
const assetListPath = "/api/v1/repos/owner/project/releases/10/assets"
|
func TestBackendOpenAssetBoundsRedirects(t *testing.T) {
|
||||||
|
for _, hops := range []int{0, 10, 11} {
|
||||||
func TestBuildManifestSortsNamesAndOmitsEmptySourceOnlyManifest(t *testing.T) {
|
t.Run(strconv.Itoa(hops), func(t *testing.T) {
|
||||||
manifest, err := BuildManifest([]ExpectedAsset{
|
var requests atomic.Int32
|
||||||
assetFor("zeta.bin", []byte("zeta")),
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
assetFor("alpha.bin", []byte("alpha")),
|
requests.Add(1)
|
||||||
|
if got := r.Header.Get("Authorization"); got != "token fixture-secret-token" {
|
||||||
|
t.Errorf("authorization = %q", got)
|
||||||
|
}
|
||||||
|
step, err := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/"))
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("invalid path %q", r.URL.Path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if step < hops {
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/%d", step+1), http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = io.WriteString(w, "asset")
|
||||||
|
}))
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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())
|
||||||
|
}
|
||||||
})
|
})
|
||||||
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) {
|
func TestBackendOpenAssetRedirectReturnsToOrigin(t *testing.T) {
|
||||||
content := []byte("selected binary")
|
var origin *httptest.Server
|
||||||
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) {
|
external := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
externalGets.Add(1)
|
if got := r.Header.Get("Authorization"); got != "" {
|
||||||
if r.Header.Get("Authorization") != "" {
|
t.Errorf("cross-origin authorization = %q", got)
|
||||||
t.Error("Gitea token reached external host")
|
|
||||||
}
|
}
|
||||||
content := []byte("matching bytes")
|
http.Redirect(w, r, origin.URL+"/final", http.StatusFound)
|
||||||
if mode == "conflicting" {
|
|
||||||
content = []byte("different bytes")
|
|
||||||
}
|
|
||||||
w.Write(content)
|
|
||||||
}))
|
}))
|
||||||
defer external.Close()
|
defer external.Close()
|
||||||
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
origin = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
assertAssetGET(t, r, "")
|
if got := r.Header.Get("Authorization"); got != "token fixture-secret-token" {
|
||||||
if r.URL.Path == assetListPath {
|
t.Errorf("origin authorization = %q", got)
|
||||||
download := external.URL + "/content"
|
|
||||||
if mode == "redirect" {
|
|
||||||
download = "/redirect"
|
|
||||||
}
|
}
|
||||||
writeAssetList(t, w, []assetResponse{{ID: 1, Name: expected.Filename, Size: expected.Size, BrowserDownloadURL: download}})
|
if r.URL.Path == "/start" {
|
||||||
|
http.Redirect(w, r, external.URL+"/away", http.StatusFound)
|
||||||
return
|
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 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 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)
|
http.Redirect(w, r, external.URL+"/content", http.StatusFound)
|
||||||
}))
|
}))
|
||||||
defer origin.Close()
|
defer origin.Close()
|
||||||
inspection, err := testClient(origin).InspectAssets(context.Background(), testDestination(origin.URL), 10, []ExpectedAsset{expected})
|
|
||||||
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
want := AssetMatching
|
content, readErr := io.ReadAll(stream)
|
||||||
if mode == "conflicting" {
|
closeErr := stream.Close()
|
||||||
want = AssetConflicting
|
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())
|
||||||
if inspection.Assets[0].State != want || externalGets.Load() != 1 {
|
|
||||||
t.Fatalf("inspection=%#v downloads=%d", inspection, externalGets.Load())
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func assetFor(filename string, content []byte) ExpectedAsset {
|
func TestBackendListAssetsDecodesAttachmentMetadata(t *testing.T) {
|
||||||
sum := sha256.Sum256(content)
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
return ExpectedAsset{Filename: filename, SHA256: hex.EncodeToString(sum[:]), Size: int64(len(content)), Content: append([]byte(nil), content...)}
|
_ = json.NewEncoder(w).Encode([]assetResponse{{ID: 4, Name: "app.bin", Size: 4, BrowserDownloadURL: "/files/app"}})
|
||||||
}
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
func writeAssetList(t *testing.T, w http.ResponseWriter, assets []assetResponse) {
|
assets, err := NewBackend(testDestination(server.URL), server.Client()).ListAssets(context.Background(), releaseHandle{id: 10})
|
||||||
t.Helper()
|
if err != nil {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
t.Fatal(err)
|
||||||
if err := json.NewEncoder(w).Encode(assets); err != nil {
|
}
|
||||||
t.Error(err)
|
if len(assets) != 1 || assets[0].Filename != "app.bin" || assets[0].Size != 4 || assets[0].Handle == nil {
|
||||||
|
t.Fatalf("ListAssets() = %#v", assets)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 testDestination(base string) Destination {
|
|
||||||
return Destination{BaseURL: base, Owner: "owner", Repository: "project", Token: testToken}
|
|
||||||
}
|
|
||||||
|
|||||||
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)
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user