Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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.
|
||||||
|
|||||||
10
build.go
10
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.
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
releasekit "gitea.maximumdirect.net/eric/ReleaseKit"
|
releasekit "go.maximumdirect.net/releasekit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestBuildPreservesInvalidVersionAndDeclarationFailureEvidence(t *testing.T) {
|
func TestBuildPreservesInvalidVersionAndDeclarationFailureEvidence(t *testing.T) {
|
||||||
@@ -48,7 +48,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo"
|
"go.maximumdirect.net/releasekit/buildinfo"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -127,7 +127,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 +159,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 +272,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 +406,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)
|
||||||
@@ -616,7 +616,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 +713,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 +731,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 +743,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 +777,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 +815,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 +838,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()) }
|
||||||
|
|||||||
19
check.go
19
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.
|
||||||
@@ -59,6 +59,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,7 +68,11 @@ 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
|
||||||
Go GoValidationSpec
|
// ForbiddenFiles lists literal repository-relative file paths that must be
|
||||||
|
// absent from the captured commit, independently of module/shared inputs.
|
||||||
|
// An empty list disables the check. File contents and history are not read.
|
||||||
|
ForbiddenFiles []string
|
||||||
|
Go GoValidationSpec
|
||||||
// GoFormat checks the formatting of committed regular Go source files. It
|
// 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.
|
||||||
GoFormat bool
|
GoFormat bool
|
||||||
@@ -137,6 +143,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 +197,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,8 +13,8 @@ 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] [--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"
|
||||||
@@ -153,8 +153,9 @@ type Settings struct {
|
|||||||
type ValidationSettings struct {
|
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"`
|
||||||
Go GoValidationSettings `json:"go,omitempty"`
|
ForbiddenFiles []string `json:"forbidden_files,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.
|
||||||
GoFormat bool `json:"go_format,omitempty"`
|
GoFormat bool `json:"go_format,omitempty"`
|
||||||
@@ -195,12 +196,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.
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -11,12 +11,12 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"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/releaseidentity"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -181,8 +181,9 @@ func validationSpec(settings *cli.ValidationSettings) releasekit.ValidationSpec
|
|||||||
return releasekit.ValidationSpec{}
|
return releasekit.ValidationSpec{}
|
||||||
}
|
}
|
||||||
result := releasekit.ValidationSpec{
|
result := releasekit.ValidationSpec{
|
||||||
SharedFiles: copyStringsPreservingEmpty(settings.SharedFiles),
|
SharedFiles: copyStringsPreservingEmpty(settings.SharedFiles),
|
||||||
GoFormat: settings.GoFormat,
|
ForbiddenFiles: copyStringsPreservingEmpty(settings.ForbiddenFiles),
|
||||||
|
GoFormat: settings.GoFormat,
|
||||||
ModuleDistribution: releasekit.ModuleDistributionValidationSpec{
|
ModuleDistribution: releasekit.ModuleDistributionValidationSpec{
|
||||||
WorkspaceFiles: settings.ModuleDistribution.WorkspaceFiles,
|
WorkspaceFiles: settings.ModuleDistribution.WorkspaceFiles,
|
||||||
LocalReplacements: settings.ModuleDistribution.LocalReplacements,
|
LocalReplacements: settings.ModuleDistribution.LocalReplacements,
|
||||||
@@ -290,11 +291,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 +323,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)
|
||||||
|
}
|
||||||
|
contents, err := os.ReadFile(name)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(stderr, "read delivery token file: %v\n", err)
|
||||||
|
return releasekit.DeliveryDestination{}, 2
|
||||||
|
}
|
||||||
|
token = strings.TrimSpace(string(contents))
|
||||||
|
if token == "" {
|
||||||
|
fmt.Fprintln(stderr, "delivery token file is empty or blank")
|
||||||
|
return releasekit.DeliveryDestination{}, 2
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return releasekit.GiteaDestination{BaseURL: delivery.BaseURL, Owner: delivery.Owner, Repository: delivery.Repository, Token: token}, 0
|
if strings.TrimSpace(token) == "" {
|
||||||
|
fmt.Fprintf(stderr, "deliver requires a non-empty token in environment variable %s\n", tokenEnv)
|
||||||
|
return releasekit.DeliveryDestination{}, 2
|
||||||
|
}
|
||||||
|
return releasekit.DeliveryDestination{Forge: releasekit.ForgeGitea, BaseURL: delivery.BaseURL, Owner: delivery.Owner, Repository: delivery.Repository, Token: token}, 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func deliverWithDestination(ctx context.Context, invocation cli.Invocation, settings cli.Settings, destination releasekit.GiteaDestination, stdout, stderr io.Writer) int {
|
func deliverWithDestination(ctx context.Context, invocation cli.Invocation, settings cli.Settings, destination releasekit.DeliveryDestination, stdout, stderr io.Writer) int {
|
||||||
hasTargets := settings.Build != nil && len(settings.Build.Targets) != 0
|
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")
|
||||||
@@ -389,6 +432,10 @@ func deliverWithDestination(ctx context.Context, invocation cli.Invocation, sett
|
|||||||
if errors.Is(err, releasekit.ErrInvalidVersion) {
|
if errors.Is(err, releasekit.ErrInvalidVersion) {
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
|
var preflightErr *releasekit.DeliveryPreflightError
|
||||||
|
if errors.As(err, &preflightErr) {
|
||||||
|
return deliveryPreflightExitStatus(preflightErr)
|
||||||
|
}
|
||||||
if result.State == releasekit.DeliveryUnknown {
|
if result.State == releasekit.DeliveryUnknown {
|
||||||
return 3
|
return 3
|
||||||
}
|
}
|
||||||
@@ -402,6 +449,23 @@ func deliverWithDestination(ctx context.Context, invocation cli.Invocation, sett
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
@@ -18,9 +18,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 +36,64 @@ func TestVersionCommand(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
@@ -472,7 +530,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo"
|
"go.maximumdirect.net/releasekit/buildinfo"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -639,7 +697,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 +731,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 +774,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 +809,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 +860,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 +897,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 +923,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 +961,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") })
|
||||||
@@ -958,7 +1016,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 +1060,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 +1076,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 +1087,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 +1340,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 +1376,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 +1453,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 +1476,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 +1663,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 +1733,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 +1773,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 +1793,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 +1818,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 +1849,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()
|
||||||
|
|||||||
@@ -59,11 +59,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", ".")
|
||||||
@@ -138,8 +138,8 @@ func TestMultiModuleLauncherBuildsToolFromSelectedDependency(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")
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -239,7 +239,7 @@ func TestWoodpeckerTagDeliveryUsesTaggedModuleVersion(t *testing.T) {
|
|||||||
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")
|
||||||
@@ -480,7 +480,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 +513,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,7 +526,7 @@ 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"
|
||||||
@@ -634,7 +634,7 @@ func assertGoSelection(t *testing.T, repository, goLog string) {
|
|||||||
log := readWoodpeckerFixtureFile(t, goLog)
|
log := readWoodpeckerFixtureFile(t, goLog)
|
||||||
list := "list -mod=mod -modfile="
|
list := "list -mod=mod -modfile="
|
||||||
build := "build -mod=mod -modfile="
|
build := "build -mod=mod -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)
|
||||||
}
|
}
|
||||||
|
|||||||
152
delivery.go
152
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.
|
||||||
@@ -88,19 +140,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 +167,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 +184,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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
releasekit "gitea.maximumdirect.net/eric/ReleaseKit"
|
releasekit "go.maximumdirect.net/releasekit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDeliverReadsLocalBinaryOnceForVerificationAndOnceForUpload(t *testing.T) {
|
func TestDeliverReadsLocalBinaryOnceForVerificationAndOnceForUpload(t *testing.T) {
|
||||||
|
|||||||
260
delivery_test.go
260
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()
|
||||||
@@ -177,8 +346,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,7 +492,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 +632,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 +661,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 +694,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 +733,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 +744,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 +755,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
|
||||||
@@ -616,15 +812,17 @@ func (state *facadeDeliveryServer) snapshot() facadeDeliveryData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type facadeDeliveryData struct {
|
type facadeDeliveryData struct {
|
||||||
t *testing.T
|
t *testing.T
|
||||||
commit string
|
commit string
|
||||||
tag string
|
tag string
|
||||||
version string
|
version string
|
||||||
noteBody string
|
noteBody string
|
||||||
pages map[string]bool
|
pages map[string]bool
|
||||||
requests int
|
requests int
|
||||||
assets map[string][]byte
|
writes int
|
||||||
url string
|
omitReleaseID bool
|
||||||
|
assets map[string][]byte
|
||||||
|
url string
|
||||||
}
|
}
|
||||||
|
|
||||||
func newFacadeDeliveryServer(t *testing.T, repository string) (*httptest.Server, *facadeDeliveryServer) {
|
func newFacadeDeliveryServer(t *testing.T, repository string) (*httptest.Server, *facadeDeliveryServer) {
|
||||||
@@ -642,11 +840,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):
|
||||||
@@ -716,11 +922,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.
|
||||||
49
docs/cli.md
49
docs/cli.md
@@ -168,14 +168,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 +197,27 @@ 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, 1 for an unavailable preflight or
|
||||||
or partial delivery, 2 for usage, profile, or missing-credential errors, and 3
|
a known delivery failure, conflict, or partial delivery, and 2 for usage,
|
||||||
when a remote write remains uncertain. The result lists matching, uploaded,
|
profile, forge-selection, local credential-loading, or classified preflight
|
||||||
pending, conflicting, and uncertain assets as disjoint current observations;
|
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
|
||||||
@@ -230,10 +244,13 @@ 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.
|
||||||
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,10 +264,12 @@ 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 builds the ReleaseKit version already
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ The current profile fields are:
|
|||||||
| `checks` | No | Ordered additional child commands, each with an argument vector and optional environment map. |
|
| `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
|
||||||
@@ -43,13 +45,47 @@ 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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
@@ -125,24 +161,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 +237,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",
|
||||||
@@ -244,7 +331,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 +373,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 +387,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
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -65,10 +89,10 @@ 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.
|
||||||
|
|
||||||
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`;
|
||||||
|
|||||||
@@ -33,7 +33,9 @@ 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
|
||||||
|
`go mod download -modfile=` command overrides an alternate module file selected
|
||||||
|
through `GOFLAGS`, preserving ordinary module discovery. Its completed capture
|
||||||
uses the same ownership transfer when formatting a failure.
|
uses the same ownership transfer when formatting a failure.
|
||||||
|
|
||||||
## Configuration discovery
|
## Configuration discovery
|
||||||
|
|||||||
@@ -24,7 +24,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,9 +39,10 @@ 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. |
|
||||||
@@ -74,11 +75,16 @@ it does not consult neighboring files or traverse submodule checkouts.
|
|||||||
See the [shared validation guide](validation.md) for checker, Go command, and
|
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.
|
The root facade selects the backend explicitly and passes a configured
|
||||||
Its Gitea upload path reuses that verification and streams missing files once
|
`internal/forge.Backend` into `internal/delivery`. Delivery performs cheap
|
||||||
for transport. Independent calls to the internal client's `UploadMissing`
|
request and source checks, then read-only backend preflight before managed
|
||||||
verify missing local files before writing. Both paths share remote inspection,
|
builds or full local artifact verification. Artifact size and digest
|
||||||
conflict handling, and reconciliation.
|
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
|
||||||
|
|
||||||
@@ -97,10 +103,11 @@ not a separate release matrix:
|
|||||||
| 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` |
|
||||||
| 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
|
||||||
|
|||||||
@@ -59,6 +59,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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,6 +108,22 @@ for ReleaseKit's first release before any released ReleaseKit executable
|
|||||||
exists. It uses the local Go toolchain. Preparation must finish before the
|
exists. It uses the local Go toolchain. Preparation must finish before the
|
||||||
offline validation begins.
|
offline validation begins.
|
||||||
|
|
||||||
|
## Deliver the hosted release page
|
||||||
|
|
||||||
|
After source publication succeeds, deliver the hosted page locally when
|
||||||
|
Woodpecker automation is inactive. From the same repository root, keep the
|
||||||
|
worktree clean and `HEAD` at the published tag's commit. Reuse the executable
|
||||||
|
built by the publication command:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
.releasekit/bin/releasekit deliver vX.Y.Z
|
||||||
|
```
|
||||||
|
|
||||||
|
The command uses the configured Gitea destination and token source to deliver
|
||||||
|
the complete checked-in release note. Report its result separately from the
|
||||||
|
completed source release. For failed or uncertain delivery, follow the recovery
|
||||||
|
instructions below using the same immutable version.
|
||||||
|
|
||||||
## Resolve failures
|
## Resolve failures
|
||||||
|
|
||||||
A failed push triggers a URL-resolution check against the captured endpoint,
|
A failed push triggers a URL-resolution check against the captured endpoint,
|
||||||
@@ -148,7 +176,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)
|
||||||
@@ -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
|
||||||
```
|
```
|
||||||
@@ -90,7 +90,8 @@ outside the checkout, and builds the ReleaseKit version selected there. It then
|
|||||||
uses `ci-deliver --prepared` with the mapped profile and expected physical module.
|
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 +109,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,7 +31,7 @@ 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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -51,7 +51,7 @@ steps:
|
|||||||
|
|
||||||
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}"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -43,7 +43,7 @@ steps:
|
|||||||
|
|
||||||
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 build -mod=mod -modfile="$${bootstrap_dir}/releasekit.mod" \
|
||||||
-o "$${bootstrap_dir}/releasekit" "$${releasekit_module}/cmd/releasekit"
|
-o "$${bootstrap_dir}/releasekit" "$${releasekit_module}/cmd/releasekit"
|
||||||
|
|||||||
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()
|
||||||
|
|||||||
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[:])
|
||||||
|
}
|
||||||
@@ -13,12 +13,12 @@ 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/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"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
|
"go.maximumdirect.net/releasekit/internal/versioncontract"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Target = artifact.Target
|
type Target = artifact.Target
|
||||||
@@ -257,6 +257,9 @@ func cleanSource(ctx context.Context, repositoryDir string) (bool, error) {
|
|||||||
command := exec.CommandContext(ctx, "git", "-C", repositoryDir, "status", "--porcelain", "--untracked-files=all")
|
command := exec.CommandContext(ctx, "git", "-C", repositoryDir, "status", "--porcelain", "--untracked-files=all")
|
||||||
output, err := command.Output()
|
output, err := command.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return false, fmt.Errorf("inspect build source: %w", ctx.Err())
|
||||||
|
}
|
||||||
return false, fmt.Errorf("inspect build source: %w", err)
|
return false, fmt.Errorf("inspect build source: %w", err)
|
||||||
}
|
}
|
||||||
return len(output) == 0, nil
|
return len(output) == 0, nil
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import (
|
|||||||
"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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -33,32 +32,32 @@ func declaredBuildAssets(result internalbuild.Result) ([]gitea.ExpectedAsset, er
|
|||||||
}
|
}
|
||||||
wantByTarget[target] = declaration.Filename
|
wantByTarget[target] = declaration.Filename
|
||||||
}
|
}
|
||||||
assets := make([]gitea.ExpectedAsset, 0, len(result.Artifacts)+1)
|
assets := make([]artifact.ExpectedAsset, 0, len(result.Artifacts)+1)
|
||||||
seenTargets := make(map[artifact.Target]bool, len(result.Artifacts))
|
seenTargets := make(map[artifact.Target]bool, len(result.Artifacts))
|
||||||
for _, artifact := range result.Artifacts {
|
for _, builtArtifact := range result.Artifacts {
|
||||||
wantName, ok := wantByTarget[artifact.Target]
|
wantName, ok := wantByTarget[builtArtifact.Target]
|
||||||
if !ok || seenTargets[artifact.Target] || artifact.Filename != wantName {
|
if !ok || seenTargets[builtArtifact.Target] || builtArtifact.Filename != wantName {
|
||||||
return nil, fmt.Errorf("build artifact %q does not match a selected target and its declared name", artifact.Filename)
|
return nil, fmt.Errorf("build artifact %q does not match a selected target and its declared name", builtArtifact.Filename)
|
||||||
}
|
}
|
||||||
seenTargets[artifact.Target] = true
|
seenTargets[builtArtifact.Target] = true
|
||||||
if filepath.Base(artifact.Path) != artifact.Filename {
|
if filepath.Base(builtArtifact.Path) != builtArtifact.Filename {
|
||||||
return nil, fmt.Errorf("build artifact path %q does not end with filename %q", artifact.Path, artifact.Filename)
|
return nil, fmt.Errorf("build artifact path %q does not end with filename %q", builtArtifact.Path, builtArtifact.Filename)
|
||||||
}
|
}
|
||||||
info, err := os.Stat(artifact.Path)
|
info, err := os.Stat(builtArtifact.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"
|
||||||
|
|||||||
@@ -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,11 +50,11 @@ 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) {
|
||||||
@@ -67,6 +68,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 +115,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 {
|
||||||
@@ -131,7 +143,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 +154,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 +175,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 +242,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 +269,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
257
internal/delivery/remote_assets.go
Normal file
257
internal/delivery/remote_assets.go
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
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, err := remoteAssetDigest(ctx, backend, matches[0].Handle)
|
||||||
|
if err != nil {
|
||||||
|
observation.State = AssetUnverifiable
|
||||||
|
observation.Err = err
|
||||||
|
} else if 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) (string, error) {
|
||||||
|
stream, err := backend.OpenAsset(ctx, handle)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
hasher := sha256.New()
|
||||||
|
_, readErr := io.Copy(hasher, stream)
|
||||||
|
closeErr := stream.Close()
|
||||||
|
if err := errors.Join(readErr, closeErr); err != nil {
|
||||||
|
return "", &forge.Failure{Kind: forge.FailureUnverifiable, Err: err}
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(hasher.Sum(nil)), 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}
|
||||||
|
}
|
||||||
456
internal/delivery/remote_assets_test.go
Normal file
456
internal/delivery/remote_assets_test.go
Normal file
@@ -0,0 +1,456 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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: "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")
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
@@ -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,7 +9,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/process"
|
"go.maximumdirect.net/releasekit/internal/process"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -43,7 +43,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
|
||||||
@@ -100,7 +100,7 @@ func (endpoint Endpoint) Recheck(ctx context.Context, repositoryDir string) erro
|
|||||||
// Query reads exact main and tag refs. A command failure is unreadable,
|
// 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"
|
||||||
@@ -130,7 +130,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
|
||||||
@@ -294,7 +294,15 @@ 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 ref.State == RefPresent && ref.Object == "" && ref.Peeled != "" {
|
||||||
|
ref.State = RefConflict
|
||||||
|
}
|
||||||
|
return ref
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckTagRef validates a complete tag name using Git ref rules.
|
||||||
|
func CheckTagRef(ctx context.Context, repositoryDir, tag string) error {
|
||||||
if tag == "" {
|
if tag == "" {
|
||||||
return fmt.Errorf("invalid Git tag name %q", tag)
|
return fmt.Errorf("invalid Git tag name %q", tag)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -184,6 +184,52 @@ func TestMergeRefMarksDuplicateExactResultsConflicting(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTagQueryFinalizesCompleteResponse(t *testing.T) {
|
||||||
|
repo := newRemoteFixture(t)
|
||||||
|
endpoint := resolveFixture(t, repo)
|
||||||
|
realGit, err := exec.LookPath("git")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
bin := t.TempDir()
|
||||||
|
response := filepath.Join(t.TempDir(), "response")
|
||||||
|
script := "#!/bin/sh\ncase \" $* \" in *' ls-remote '* ) case \" $* \" in *' --get-url '* ) ;; *) cat \"$RESPONSE\"; exit 0;; esac;; esac\nexec \"$REAL_GIT\" \"$@\"\n"
|
||||||
|
if err := os.WriteFile(filepath.Join(bin, "git"), []byte(script), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
t.Setenv("REAL_GIT", realGit)
|
||||||
|
t.Setenv("RESPONSE", response)
|
||||||
|
object, peeled := strings.Repeat("a", 40), strings.Repeat("b", 40)
|
||||||
|
exact := object + "\trefs/tags/v1.2.3\n"
|
||||||
|
peel := peeled + "\trefs/tags/v1.2.3^{}\n"
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, output string
|
||||||
|
want RemoteRef
|
||||||
|
}{
|
||||||
|
{"empty", "", RemoteRef{State: RefAbsent}},
|
||||||
|
{"exact", exact, RemoteRef{State: RefPresent, Object: object}},
|
||||||
|
{"exact then peeled", exact + peel, RemoteRef{State: RefPresent, Object: object, Peeled: peeled}},
|
||||||
|
{"peeled then exact", peel + exact, RemoteRef{State: RefPresent, Object: object, Peeled: peeled}},
|
||||||
|
{"peeled only", peel, RemoteRef{State: RefConflict, Peeled: peeled}},
|
||||||
|
{"duplicate", exact + exact, RemoteRef{State: RefConflict, Object: object}},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if err := os.WriteFile(response, []byte(tc.output), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
snapshot, err := endpoint.Query(context.Background(), repo.work, "v1.2.3")
|
||||||
|
if err != nil || snapshot.Tag != tc.want {
|
||||||
|
t.Fatalf("Query tag = %#v, error = %v, want %#v", snapshot.Tag, err, tc.want)
|
||||||
|
}
|
||||||
|
got, err := endpoint.QueryTag(context.Background(), repo.work, "v1.2.3")
|
||||||
|
if err != nil || got != tc.want {
|
||||||
|
t.Fatalf("QueryTag = %#v, error = %v, want %#v", got, err, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type remoteFixture struct {
|
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,135 @@ func RepositoryRoot(ctx context.Context, directory string) (string, error) {
|
|||||||
return resolved, nil
|
return resolved, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DiscoverRepositoryRoot finds a containing checkout or confirms standalone use.
|
||||||
|
func DiscoverRepositoryRoot(ctx context.Context, directory string) (string, bool, error) {
|
||||||
|
if directory == "" {
|
||||||
|
directory = "."
|
||||||
|
}
|
||||||
|
var stdout, stderr strings.Builder
|
||||||
|
err := process.Run(ctx, process.Invocation{
|
||||||
|
Args: []string{"git", "rev-parse", "--show-toplevel"}, Dir: directory,
|
||||||
|
Env: map[string]string{"LC_ALL": "C"}, 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) {
|
||||||
@@ -66,7 +202,7 @@ func CaptureHead(ctx context.Context, repositoryDir string) (string, error) {
|
|||||||
// 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
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ 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 {
|
||||||
@@ -57,8 +57,8 @@ func TestTagOperationsAcceptFullSlashContainingRefs(t *testing.T) {
|
|||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
// 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 and returns the tag object's ID.
|
||||||
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 {
|
if err := CheckTagRef(ctx, repositoryDir, tag); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if !validObjectID(commit) {
|
if !validObjectID(commit) {
|
||||||
@@ -71,7 +71,7 @@ func CreateAnnotatedTag(ctx context.Context, repositoryDir, tag, commit string,
|
|||||||
// InspectLocalTag confirms the selected tag still points to the exact
|
// InspectLocalTag confirms the selected tag still points to the exact
|
||||||
// annotated tag object captured before publication.
|
// annotated tag object captured before publication.
|
||||||
func InspectLocalTag(ctx context.Context, repositoryDir, tag, expectedObject string) error {
|
func InspectLocalTag(ctx context.Context, repositoryDir, tag, expectedObject string) error {
|
||||||
if err := checkTagRef(ctx, repositoryDir, tag); err != nil {
|
if err := CheckTagRef(ctx, repositoryDir, tag); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !validObjectID(expectedObject) {
|
if !validObjectID(expectedObject) {
|
||||||
@@ -99,7 +99,7 @@ func InspectLocalTag(ctx context.Context, repositoryDir, tag, expectedObject str
|
|||||||
// 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 {
|
||||||
@@ -124,12 +124,12 @@ 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) {
|
||||||
|
|||||||
@@ -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,47 @@ 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{})
|
||||||
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,65 +72,26 @@ 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) {
|
||||||
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
|
||||||
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 {
|
||||||
if target == nil || target.User != nil || target.Host == "" {
|
if target == nil || target.User != nil || target.Host == "" {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -2,327 +2,115 @@ package gitea
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"errors"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/releasekit/internal/forge"
|
||||||
)
|
)
|
||||||
|
|
||||||
const assetListPath = "/api/v1/repos/owner/project/releases/10/assets"
|
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()
|
||||||
|
|
||||||
func TestBuildManifestSortsNamesAndOmitsEmptySourceOnlyManifest(t *testing.T) {
|
_, err := NewBackend(testDestination(server.URL), server.Client()).ListAssets(context.Background(), releaseHandle{id: 10})
|
||||||
manifest, err := BuildManifest([]ExpectedAsset{
|
var failure *forge.Failure
|
||||||
assetFor("zeta.bin", []byte("zeta")),
|
if !errors.As(err, &failure) || failure.Kind != forge.FailureMalformed {
|
||||||
assetFor("alpha.bin", []byte("alpha")),
|
t.Fatalf("ListAssets() error = %v, want malformed attachment response", err)
|
||||||
})
|
|
||||||
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 TestBackendOpenAssetFollowsSameOriginRedirectWithAuthorization(t *testing.T) {
|
||||||
content := []byte("selected binary")
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
assertAssetGET(t, r, "")
|
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 {
|
switch r.URL.Path {
|
||||||
case assetListPath:
|
case "/files/redirect":
|
||||||
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)
|
http.Redirect(w, r, "/files/content", http.StatusFound)
|
||||||
return
|
case "/files/content":
|
||||||
|
_, _ = io.WriteString(w, "streamed asset")
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected path %q", r.URL.Path)
|
||||||
|
http.NotFound(w, r)
|
||||||
}
|
}
|
||||||
if r.URL.Path == "/files/content" {
|
|
||||||
_, _ = w.Write(content)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
t.Errorf("unexpected path %s", r.URL.Path)
|
|
||||||
return
|
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
inspection, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, []ExpectedAsset{expected})
|
|
||||||
|
backend := NewBackend(testDestination(server.URL), server.Client())
|
||||||
|
stream, err := backend.OpenAsset(context.Background(), assetHandle{downloadURL: "/files/redirect"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if inspection.Assets[0].State != AssetMatching {
|
content, readErr := io.ReadAll(stream)
|
||||||
t.Fatalf("inspection = %#v", inspection)
|
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 TestInspectAssetsVerifiesExternalContentWithoutGiteaToken(t *testing.T) {
|
func TestBackendOpenAssetDoesNotForwardAuthorizationAcrossOrigin(t *testing.T) {
|
||||||
for _, mode := range []string{"direct", "redirect", "conflicting"} {
|
for _, mode := range []string{"direct", "redirect"} {
|
||||||
t.Run(mode, func(t *testing.T) {
|
t.Run(mode, func(t *testing.T) {
|
||||||
expected := assetFor("app.bin", []byte("matching bytes"))
|
var externalRequests atomic.Int32
|
||||||
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)
|
externalRequests.Add(1)
|
||||||
if r.Header.Get("Authorization") != "" {
|
if got := r.Header.Get("Authorization"); got != "" {
|
||||||
t.Error("Gitea token reached external host")
|
t.Errorf("external authorization = %q, want empty", got)
|
||||||
}
|
}
|
||||||
content := []byte("matching bytes")
|
_, _ = io.WriteString(w, "external asset")
|
||||||
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, want := r.Header.Get("Authorization"), "token fixture-secret-token"; got != want {
|
||||||
if r.URL.Path == assetListPath {
|
t.Errorf("origin authorization = %q, want %q", got, want)
|
||||||
download := external.URL + "/content"
|
|
||||||
if mode == "redirect" {
|
|
||||||
download = "/redirect"
|
|
||||||
}
|
|
||||||
writeAssetList(t, w, []assetResponse{{ID: 1, Name: expected.Filename, Size: expected.Size, BrowserDownloadURL: download}})
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
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)
|
||||||
525
internal/gitea/backend_test.go
Normal file
525
internal/gitea/backend_test.go
Normal file
@@ -0,0 +1,525 @@
|
|||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/releasekit/internal/forge"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBackendTagCommitUsesEscapedFullTagAndAuthentication(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if got, want := r.RequestURI, "/api/v1/repos/owner/project/tags/v1.2.3%2Frc.1"; got != want {
|
||||||
|
t.Errorf("request URI = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if got, want := r.Header.Get("Authorization"), "token fixture-secret-token"; got != want {
|
||||||
|
t.Errorf("authorization = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"commit": map[string]string{"sha": "abcdef"}})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
backend := NewBackend(testDestination(server.URL), server.Client())
|
||||||
|
commit, err := backend.TagCommit(context.Background(), "v1.2.3/rc.1")
|
||||||
|
if err != nil || commit != "abcdef" {
|
||||||
|
t.Fatalf("TagCommit() = %q, %v", commit, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackendFindReleaseReturnsNeutralNotFound(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.NotFoundHandler())
|
||||||
|
defer server.Close()
|
||||||
|
backend := NewBackend(testDestination(server.URL), server.Client())
|
||||||
|
|
||||||
|
_, err := backend.FindRelease(context.Background(), "v1.2.3")
|
||||||
|
var failure *forge.Failure
|
||||||
|
if !errors.As(err, &failure) || failure.Kind != forge.FailureNotFound {
|
||||||
|
t.Fatalf("FindRelease() error = %v, want neutral not-found failure", err)
|
||||||
|
}
|
||||||
|
var cause *Failure
|
||||||
|
if !errors.As(err, &cause) || cause.StatusCode != http.StatusNotFound {
|
||||||
|
t.Fatalf("FindRelease() error cause = %v, want Gitea 404 diagnostic", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackendFindReleaseDoesNotRequireWriteResponseFields(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_ = json.NewEncoder(w).Encode(releaseResponse{TagName: "v1.2.3", Name: "Release"})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
backend := NewBackend(testDestination(server.URL), server.Client())
|
||||||
|
|
||||||
|
release, err := backend.FindRelease(context.Background(), "v1.2.3")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if release.Tag != "v1.2.3" || release.Title != "Release" || release.Handle == nil {
|
||||||
|
t.Fatalf("FindRelease() = %#v", release)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackendAssetOperationsRequireObservedReleaseID(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, body string
|
||||||
|
valid bool
|
||||||
|
}{
|
||||||
|
{"missing", `{"tag_name":"v1.2.3"}`, false},
|
||||||
|
{"zero", `{"id":0,"tag_name":"v1.2.3"}`, false},
|
||||||
|
{"negative", `{"id":-1,"tag_name":"v1.2.3"}`, false},
|
||||||
|
{"valid", `{"id":12,"tag_name":"v1.2.3"}`, true},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
requests := 0
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if strings.Contains(r.URL.Path, "/releases/tags/") {
|
||||||
|
_, _ = io.WriteString(w, tc.body)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
requests++
|
||||||
|
if r.Method == http.MethodGet {
|
||||||
|
_, _ = io.WriteString(w, `[]`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_, _ = io.WriteString(w, `{"id":1,"name":"file"}`)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
backend := NewBackend(testDestination(server.URL), server.Client())
|
||||||
|
release, err := backend.FindRelease(context.Background(), "v1.2.3")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, listErr := backend.ListAssets(context.Background(), release.Handle)
|
||||||
|
evidence, uploadErr := backend.UploadAsset(context.Background(), release.Handle, "file", strings.NewReader("data"))
|
||||||
|
if tc.valid {
|
||||||
|
if listErr != nil || uploadErr != nil || requests != 2 {
|
||||||
|
t.Fatalf("valid: list %v, upload %v, requests %d", listErr, uploadErr, requests)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, err := range []error{listErr, uploadErr} {
|
||||||
|
var failure *forge.Failure
|
||||||
|
if !errors.As(err, &failure) || failure.Kind != forge.FailureMalformed {
|
||||||
|
t.Fatalf("failure = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if requests != 0 || evidence.Attempted || evidence.Disposition != forge.WriteNotAttempted {
|
||||||
|
t.Fatalf("requests = %d, evidence = %#v", requests, evidence)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
backend := NewBackend(Destination{}, nil)
|
||||||
|
_, listErr := backend.ListAssets(context.Background(), "wrong")
|
||||||
|
evidence, uploadErr := backend.UploadAsset(context.Background(), "wrong", "file", strings.NewReader("data"))
|
||||||
|
for _, err := range []error{listErr, uploadErr} {
|
||||||
|
var failure *forge.Failure
|
||||||
|
if !errors.As(err, &failure) || failure.Kind != forge.FailureInvalid {
|
||||||
|
t.Fatalf("wrong handle failure = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if evidence.Attempted || evidence.Disposition != forge.WriteNotAttempted {
|
||||||
|
t.Fatalf("wrong handle evidence = %#v", evidence)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackendListsMetadataAndOpensCallerOwnedAssetStream(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if got, want := r.Header.Get("Authorization"), "token fixture-secret-token"; got != want {
|
||||||
|
t.Errorf("authorization = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/v1/repos/owner/project/releases/tags/v1.2.3":
|
||||||
|
_ = json.NewEncoder(w).Encode(releaseResponse{ID: 12, TagName: "v1.2.3"})
|
||||||
|
case "/api/v1/repos/owner/project/releases/12/assets":
|
||||||
|
_ = json.NewEncoder(w).Encode([]assetResponse{{ID: 4, Name: "app.bin", Size: 4, BrowserDownloadURL: "/files/app"}})
|
||||||
|
case "/files/app":
|
||||||
|
_, _ = io.WriteString(w, "data")
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected path %q", r.URL.Path)
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
backend := NewBackend(testDestination(server.URL), server.Client())
|
||||||
|
|
||||||
|
release, err := backend.FindRelease(context.Background(), "v1.2.3")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
assets, err := backend.ListAssets(context.Background(), release.Handle)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(assets) != 1 || assets[0].Filename != "app.bin" || assets[0].Size != 4 || assets[0].Handle == nil {
|
||||||
|
t.Fatalf("ListAssets() = %#v", assets)
|
||||||
|
}
|
||||||
|
stream, err := backend.OpenAsset(context.Background(), assets[0].Handle)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
content, readErr := io.ReadAll(stream)
|
||||||
|
closeErr := stream.Close()
|
||||||
|
if readErr != nil || closeErr != nil || string(content) != "data" {
|
||||||
|
t.Fatalf("OpenAsset() content = %q, read error = %v, close error = %v", content, readErr, closeErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackendRejectsAssetHandleFromAnotherBackendType(t *testing.T) {
|
||||||
|
backend := NewBackend(testDestination("http://example.test"), nil)
|
||||||
|
_, err := backend.OpenAsset(context.Background(), strings.NewReader("asset"))
|
||||||
|
var failure *forge.Failure
|
||||||
|
if !errors.As(err, &failure) || failure.Kind != forge.FailureInvalid {
|
||||||
|
t.Fatalf("OpenAsset() error = %v, want invalid handle", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackendPreflightUsesAuthenticatedRepositoryReadsAndIgnoresCodePushPermission(t *testing.T) {
|
||||||
|
var requests []string
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
t.Errorf("method = %q, want GET", r.Method)
|
||||||
|
}
|
||||||
|
if got, want := r.Header.Get("Authorization"), "token fixture-secret-token"; got != want {
|
||||||
|
t.Errorf("authorization = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
requests = append(requests, r.URL.Path)
|
||||||
|
writeRepository := func() {
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"id": 31, "name": "project", "full_name": "owner/project",
|
||||||
|
"owner": map[string]string{"login": "owner"},
|
||||||
|
"permissions": map[string]bool{"admin": false, "push": false, "pull": true},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/v1/repos/owner/project", "/api/v1/repositories/31":
|
||||||
|
writeRepository()
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected path %q", r.URL.Path)
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
if err := NewBackend(testDestination(server.URL), server.Client()).Preflight(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(requests) != 2 || requests[0] != "/api/v1/repos/owner/project" || requests[1] != "/api/v1/repositories/31" {
|
||||||
|
t.Fatalf("preflight requests = %v, want both repository reads", requests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackendPreflightRequiresCredentialWithoutMakingRequests(t *testing.T) {
|
||||||
|
var requests int
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { requests++ }))
|
||||||
|
defer server.Close()
|
||||||
|
destination := testDestination(server.URL)
|
||||||
|
destination.Token = " \t"
|
||||||
|
|
||||||
|
err := NewBackend(destination, server.Client()).Preflight(context.Background())
|
||||||
|
var failure *forge.PreflightFailure
|
||||||
|
if !errors.As(err, &failure) || failure.Kind != forge.PreflightMissingCredential {
|
||||||
|
t.Fatalf("Preflight() error = %v, want missing credential", err)
|
||||||
|
}
|
||||||
|
if requests != 0 {
|
||||||
|
t.Fatalf("made %d requests without a credential", requests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackendPreflightDoesNotAcceptPublicLookupWithoutAuthenticatedAccess(t *testing.T) {
|
||||||
|
var requests []string
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
requests = append(requests, r.URL.Path)
|
||||||
|
if r.URL.Path == "/api/v1/repos/owner/project" {
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"id": 31, "name": "project", "full_name": "owner/project", "owner": map[string]string{"login": "owner"}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "token rejected", http.StatusUnauthorized)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := NewBackend(testDestination(server.URL), server.Client()).Preflight(context.Background())
|
||||||
|
var failure *forge.PreflightFailure
|
||||||
|
if !errors.As(err, &failure) || failure.Kind != forge.PreflightInvalidCredential {
|
||||||
|
t.Fatalf("Preflight() error = %v, want invalid credential", err)
|
||||||
|
}
|
||||||
|
if len(requests) != 2 || requests[1] != "/api/v1/repositories/31" {
|
||||||
|
t.Fatalf("preflight requests = %v, want authenticated repository read", requests)
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), testDestination(server.URL).Token) {
|
||||||
|
t.Fatalf("preflight error exposed the credential: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackendPreflightClassifiesDeniedAndHiddenRepositoriesAsInaccessible(t *testing.T) {
|
||||||
|
for _, status := range []int{http.StatusForbidden, http.StatusNotFound} {
|
||||||
|
t.Run(http.StatusText(status), func(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, "repository unavailable", status)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := NewBackend(testDestination(server.URL), server.Client()).Preflight(context.Background())
|
||||||
|
var failure *forge.PreflightFailure
|
||||||
|
if !errors.As(err, &failure) || failure.Kind != forge.PreflightInaccessibleDestination {
|
||||||
|
t.Fatalf("Preflight() error = %v, want inaccessible destination", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackendPreflightClassifiesUnverifiableResponsesAsUnavailable(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
respond func(http.ResponseWriter)
|
||||||
|
}{
|
||||||
|
{name: "malformed JSON", respond: func(w http.ResponseWriter) { _, _ = io.WriteString(w, "{") }},
|
||||||
|
{name: "service failure", respond: func(w http.ResponseWriter) { http.Error(w, "temporarily unavailable", http.StatusServiceUnavailable) }},
|
||||||
|
{name: "wrong repository identity", respond: func(w http.ResponseWriter) {
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"id": 31, "name": "other", "full_name": "owner/other", "owner": map[string]string{"login": "owner"}})
|
||||||
|
}},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { test.respond(w) }))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := NewBackend(testDestination(server.URL), server.Client()).Preflight(context.Background())
|
||||||
|
var failure *forge.PreflightFailure
|
||||||
|
if !errors.As(err, &failure) || failure.Kind != forge.PreflightUnavailable {
|
||||||
|
t.Fatalf("Preflight() error = %v, want unavailable", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackendPreflightPreservesCancellation(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.NotFoundHandler())
|
||||||
|
defer server.Close()
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
err := NewBackend(testDestination(server.URL), server.Client()).Preflight(ctx)
|
||||||
|
var failure *forge.PreflightFailure
|
||||||
|
if !errors.As(err, &failure) || failure.Kind != forge.PreflightUnavailable || !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("Preflight() error = %v, want unavailable and context cancellation", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackendCreateReleaseSendsRequestedFieldsAndAcceptsProtocolAcknowledgment(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/repos/owner/project/releases" {
|
||||||
|
t.Errorf("request = %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
if got, want := r.Header.Get("Authorization"), "token fixture-secret-token"; got != want {
|
||||||
|
t.Errorf("authorization = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
var request createReleaseRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||||
|
t.Errorf("decode request: %v", err)
|
||||||
|
}
|
||||||
|
if request.TagName != "v1.2.3" || request.TargetCommitish != strings.Repeat("a", 40) || request.Name != "Release title" || request.Body != "# Release title\n\nComplete notes." || !request.Draft || !request.Prerelease {
|
||||||
|
t.Errorf("release request = %#v", request)
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(releaseResponse{ID: 12, TagName: "server tag", Name: "server title", Body: "server body", Draft: true, Prerelease: true})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
backend := NewBackend(testDestination(server.URL), server.Client())
|
||||||
|
|
||||||
|
release, evidence, err := backend.CreateRelease(context.Background(), forge.CreateReleaseRequest{
|
||||||
|
Tag: "v1.2.3", Commit: strings.Repeat("a", 40), Title: "Release title",
|
||||||
|
Body: "# Release title\n\nComplete notes.", Draft: true, Prerelease: true,
|
||||||
|
})
|
||||||
|
if err != nil || evidence != (forge.WriteEvidence{Attempted: true, Disposition: forge.WriteAccepted}) {
|
||||||
|
t.Fatalf("CreateRelease() evidence = %#v, error = %v", evidence, err)
|
||||||
|
}
|
||||||
|
if release.Handle == nil || release.Tag != "server tag" || release.Title != "server title" || release.Body != "server body" || !release.Draft || !release.Prerelease {
|
||||||
|
t.Fatalf("CreateRelease() release = %#v", release)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackendCreateReleaseMarksInvalidAcknowledgmentUncertain(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_ = json.NewEncoder(w).Encode(releaseResponse{TagName: "v1.2.3"})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
backend := NewBackend(testDestination(server.URL), server.Client())
|
||||||
|
|
||||||
|
_, evidence, err := backend.CreateRelease(context.Background(), validCreateReleaseRequest())
|
||||||
|
var failure *forge.Failure
|
||||||
|
if !errors.As(err, &failure) || failure.Kind != forge.FailureMalformed || evidence != (forge.WriteEvidence{Attempted: true, Disposition: forge.WriteUncertain}) {
|
||||||
|
t.Fatalf("CreateRelease() evidence = %#v, error = %v", evidence, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackendCreateReleaseClassifiesConflictAndServerFailure(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
status int
|
||||||
|
disposition forge.WriteDisposition
|
||||||
|
}{
|
||||||
|
{status: http.StatusBadRequest, disposition: forge.WriteRejected},
|
||||||
|
{status: http.StatusConflict, disposition: forge.WriteConflict},
|
||||||
|
{status: http.StatusServiceUnavailable, disposition: forge.WriteUncertain},
|
||||||
|
} {
|
||||||
|
t.Run(http.StatusText(test.status), func(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(test.status) }))
|
||||||
|
defer server.Close()
|
||||||
|
backend := NewBackend(testDestination(server.URL), server.Client())
|
||||||
|
|
||||||
|
_, evidence, err := backend.CreateRelease(context.Background(), validCreateReleaseRequest())
|
||||||
|
if err == nil || evidence != (forge.WriteEvidence{Attempted: true, Disposition: test.disposition}) {
|
||||||
|
t.Fatalf("CreateRelease() evidence = %#v, error = %v", evidence, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackendUploadAssetBorrowsReaderAndValidatesAcknowledgment(t *testing.T) {
|
||||||
|
var uploaded string
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/repos/owner/project/releases/12/assets" || r.URL.Query().Get("name") != "app.bin" {
|
||||||
|
t.Errorf("request = %s %s?%s", r.Method, r.URL.Path, r.URL.RawQuery)
|
||||||
|
}
|
||||||
|
if got, want := r.Header.Get("Authorization"), "token fixture-secret-token"; got != want {
|
||||||
|
t.Errorf("authorization = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
file, header, err := r.FormFile("attachment")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("attachment: %v", err)
|
||||||
|
} else {
|
||||||
|
defer file.Close()
|
||||||
|
content, readErr := io.ReadAll(file)
|
||||||
|
if readErr != nil {
|
||||||
|
t.Errorf("read attachment: %v", readErr)
|
||||||
|
}
|
||||||
|
if header.Filename != "app.bin" {
|
||||||
|
t.Errorf("multipart filename = %q", header.Filename)
|
||||||
|
}
|
||||||
|
uploaded = string(content)
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(assetResponse{ID: 50, Name: "app.bin"})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
backend := NewBackend(testDestination(server.URL), server.Client())
|
||||||
|
reader := &trackedReader{Reader: strings.NewReader("streamed bytes")}
|
||||||
|
|
||||||
|
evidence, err := backend.UploadAsset(context.Background(), releaseHandle{id: 12}, "app.bin", reader)
|
||||||
|
if err != nil || evidence != (forge.WriteEvidence{Attempted: true, Disposition: forge.WriteAccepted}) {
|
||||||
|
t.Fatalf("UploadAsset() evidence = %#v, error = %v", evidence, err)
|
||||||
|
}
|
||||||
|
if uploaded != "streamed bytes" || reader.closed {
|
||||||
|
t.Fatalf("uploaded = %q, caller reader closed = %v", uploaded, reader.closed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackendUploadAssetMarksBadAcknowledgmentUncertain(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_ = json.NewEncoder(w).Encode(assetResponse{ID: 50, Name: "different.bin"})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
backend := NewBackend(testDestination(server.URL), server.Client())
|
||||||
|
|
||||||
|
evidence, err := backend.UploadAsset(context.Background(), releaseHandle{id: 12}, "app.bin", strings.NewReader("data"))
|
||||||
|
var failure *forge.Failure
|
||||||
|
if !errors.As(err, &failure) || failure.Kind != forge.FailureMalformed || evidence != (forge.WriteEvidence{Attempted: true, Disposition: forge.WriteUncertain}) {
|
||||||
|
t.Fatalf("UploadAsset() evidence = %#v, error = %v", evidence, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackendUploadAssetClassifiesKnownRejectionAndConflict(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
status int
|
||||||
|
disposition forge.WriteDisposition
|
||||||
|
}{
|
||||||
|
{status: http.StatusBadRequest, disposition: forge.WriteRejected},
|
||||||
|
{status: http.StatusConflict, disposition: forge.WriteConflict},
|
||||||
|
{status: http.StatusInternalServerError, disposition: forge.WriteUncertain},
|
||||||
|
} {
|
||||||
|
t.Run(http.StatusText(test.status), func(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(test.status) }))
|
||||||
|
defer server.Close()
|
||||||
|
backend := NewBackend(testDestination(server.URL), server.Client())
|
||||||
|
|
||||||
|
evidence, err := backend.UploadAsset(context.Background(), releaseHandle{id: 12}, "app.bin", strings.NewReader("data"))
|
||||||
|
if err == nil || evidence != (forge.WriteEvidence{Attempted: true, Disposition: test.disposition}) {
|
||||||
|
t.Fatalf("UploadAsset() evidence = %#v, error = %v", evidence, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackendUploadAssetPreservesCancellationAsUncertain(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.NotFoundHandler())
|
||||||
|
defer server.Close()
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
evidence, err := NewBackend(testDestination(server.URL), server.Client()).UploadAsset(ctx, releaseHandle{id: 12}, "app.bin", strings.NewReader("data"))
|
||||||
|
if evidence != (forge.WriteEvidence{Attempted: true, Disposition: forge.WriteUncertain}) || !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("UploadAsset() evidence = %#v, error = %v", evidence, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackendUploadAssetWaitsForMultipartWriterAfterReaderFailure(t *testing.T) {
|
||||||
|
readerErr := errors.New("asset reader failed")
|
||||||
|
readDone := make(chan struct{})
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/owner/project/releases/tags/v1.2.3" {
|
||||||
|
_ = json.NewEncoder(w).Encode(releaseResponse{ID: 10, TagName: "v1.2.3"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = io.Copy(io.Discard, r.Body)
|
||||||
|
_ = json.NewEncoder(w).Encode(assetResponse{ID: 50, Name: "app.bin", Size: 0})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
backend := NewBackend(testDestination(server.URL), server.Client())
|
||||||
|
release, err := backend.FindRelease(context.Background(), "v1.2.3")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FindRelease() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
evidence, err := backend.UploadAsset(context.Background(), release.Handle, "app.bin", failingUploadReader{err: readerErr, done: readDone})
|
||||||
|
select {
|
||||||
|
case <-readDone:
|
||||||
|
default:
|
||||||
|
t.Fatal("UploadAsset() returned before the multipart worker finished reading")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, readerErr) {
|
||||||
|
t.Fatalf("UploadAsset() error = %v, want reader cause %v", err, readerErr)
|
||||||
|
}
|
||||||
|
if !evidence.Attempted || evidence.Disposition != forge.WriteUncertain {
|
||||||
|
t.Fatalf("UploadAsset() evidence = %#v, want uncertain attempted write", evidence)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type failingUploadReader struct {
|
||||||
|
err error
|
||||||
|
done chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (reader failingUploadReader) Read([]byte) (int, error) {
|
||||||
|
close(reader.done)
|
||||||
|
return 0, reader.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func validCreateReleaseRequest() forge.CreateReleaseRequest {
|
||||||
|
return forge.CreateReleaseRequest{Tag: "v1.2.3", Commit: strings.Repeat("a", 40), Title: "Release", Body: "# Release"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDestination(baseURL string) Destination {
|
||||||
|
return Destination{BaseURL: baseURL, Owner: "owner", Repository: "project", Token: "fixture-secret-token"}
|
||||||
|
}
|
||||||
|
|
||||||
|
type trackedReader struct {
|
||||||
|
io.Reader
|
||||||
|
closed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (reader *trackedReader) Close() error {
|
||||||
|
reader.closed = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type FailureKind string
|
type FailureKind string
|
||||||
@@ -55,14 +54,7 @@ type Destination struct {
|
|||||||
Token string
|
Token string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Request struct {
|
type release struct {
|
||||||
Destination Destination
|
|
||||||
Tag string
|
|
||||||
Commit string
|
|
||||||
Note string
|
|
||||||
}
|
|
||||||
|
|
||||||
type Release struct {
|
|
||||||
ID int64
|
ID int64
|
||||||
URL string
|
URL string
|
||||||
TagName string
|
TagName string
|
||||||
@@ -72,12 +64,6 @@ type Release struct {
|
|||||||
Prerelease bool
|
Prerelease bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type Result struct {
|
|
||||||
Release Release
|
|
||||||
Created bool
|
|
||||||
WriteAttempted bool // a create request was issued, including reconciled creates.
|
|
||||||
}
|
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
HTTPClient *http.Client
|
HTTPClient *http.Client
|
||||||
}
|
}
|
||||||
@@ -108,109 +94,29 @@ type createReleaseRequest struct {
|
|||||||
Prerelease bool `json:"prerelease"`
|
Prerelease bool `json:"prerelease"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnsureRelease verifies the hosted tag and finds or creates its stable release
|
func (c Client) getRelease(ctx context.Context, client *http.Client, token, endpoint string) (release, int, error) {
|
||||||
// page. It never edits a release that already exists.
|
|
||||||
func (c Client) EnsureRelease(ctx context.Context, request Request) (Result, error) {
|
|
||||||
if err := validateRequest(request); err != nil {
|
|
||||||
return Result{}, err
|
|
||||||
}
|
|
||||||
base, err := parseBaseURL(request.Destination.BaseURL)
|
|
||||||
if err != nil {
|
|
||||||
return Result{}, failure(FailureInvalid, "validate destination", 0, err)
|
|
||||||
}
|
|
||||||
client := c.httpClient()
|
|
||||||
apiRoot := strings.TrimRight(base.String(), "/") + "/api/v1/repos/" + url.PathEscape(request.Destination.Owner) + "/" + url.PathEscape(request.Destination.Repository)
|
|
||||||
tagURL := apiRoot + "/tags/" + url.PathEscape(request.Tag)
|
|
||||||
|
|
||||||
var tag tagResponse
|
|
||||||
if err := c.getJSON(ctx, client, request.Destination.Token, "get hosted tag", tagURL, &tag); err != nil {
|
|
||||||
return Result{}, err
|
|
||||||
}
|
|
||||||
if tag.Commit.SHA != request.Commit {
|
|
||||||
return Result{}, failure(FailureConflict, "verify hosted tag", 0, fmt.Errorf("tag %q targets %q, want %q", request.Tag, tag.Commit.SHA, request.Commit))
|
|
||||||
}
|
|
||||||
|
|
||||||
releaseURL := apiRoot + "/releases/tags/" + url.PathEscape(request.Tag)
|
|
||||||
existing, status, err := c.getRelease(ctx, client, request.Destination.Token, releaseURL)
|
|
||||||
if err == nil {
|
|
||||||
return acceptExisting(existing, request)
|
|
||||||
}
|
|
||||||
if status != http.StatusNotFound {
|
|
||||||
return Result{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
title := noteTitle(request.Note)
|
|
||||||
body, err := json.Marshal(createReleaseRequest{
|
|
||||||
TagName: request.Tag, TargetCommitish: request.Commit,
|
|
||||||
Name: title, Body: request.Note,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return Result{}, failure(FailureInvalid, "encode release", 0, err)
|
|
||||||
}
|
|
||||||
created, postStatus, postErr := c.postRelease(ctx, client, request.Destination.Token, apiRoot+"/releases", body)
|
|
||||||
if postErr == nil {
|
|
||||||
result, verifyErr := acceptCreated(created, request)
|
|
||||||
if verifyErr == nil {
|
|
||||||
result.WriteAttempted = true
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
postErr = verifyErr
|
|
||||||
}
|
|
||||||
if !shouldReconcileWrite(postStatus, postErr) {
|
|
||||||
return Result{}, postErr
|
|
||||||
}
|
|
||||||
|
|
||||||
// A conflict or interrupted server response may have followed a successful
|
|
||||||
// create. One lookup resolves only a page with the exact selected content.
|
|
||||||
retry, retryStatus, retryErr := c.getRelease(ctx, client, request.Destination.Token, releaseURL)
|
|
||||||
if retryErr == nil {
|
|
||||||
result, matchErr := acceptExisting(retry, request)
|
|
||||||
if matchErr == nil {
|
|
||||||
result.WriteAttempted = true
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
return Result{}, failure(FailureConflict, "resolve release creation", postStatus, matchErr)
|
|
||||||
}
|
|
||||||
if retryStatus == http.StatusNotFound {
|
|
||||||
if postStatus == http.StatusConflict {
|
|
||||||
return Result{}, failure(FailureConflict, "create release", postStatus, postErr)
|
|
||||||
}
|
|
||||||
return Result{}, failure(FailureUnknown, "resolve release creation", postStatus, postErr)
|
|
||||||
}
|
|
||||||
return Result{}, failure(FailureUnknown, "resolve release creation", postStatus, errors.Join(postErr, retryErr))
|
|
||||||
}
|
|
||||||
|
|
||||||
func shouldReconcileWrite(status int, err error) bool {
|
|
||||||
if status == http.StatusConflict || status >= 500 || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
var failure *Failure
|
|
||||||
return errors.As(err, &failure) && (failure.Kind == FailureTransport || failure.Kind == FailureMalformed)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c Client) getRelease(ctx context.Context, client *http.Client, token, endpoint string) (Release, int, error) {
|
|
||||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Release{}, 0, failure(FailureInvalid, "find release", 0, err)
|
return release{}, 0, failure(FailureInvalid, "find release", 0, err)
|
||||||
}
|
}
|
||||||
request.Header.Set("Authorization", "token "+token)
|
request.Header.Set("Authorization", "token "+token)
|
||||||
request.Header.Set("Accept", "application/json")
|
request.Header.Set("Accept", "application/json")
|
||||||
response, err := client.Do(request)
|
response, err := client.Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Release{}, 0, failure(FailureTransport, "find release", 0, err)
|
return release{}, 0, failure(FailureTransport, "find release", 0, err)
|
||||||
}
|
}
|
||||||
defer response.Body.Close()
|
defer response.Body.Close()
|
||||||
if response.StatusCode == http.StatusNotFound {
|
if response.StatusCode == http.StatusNotFound {
|
||||||
return Release{}, response.StatusCode, failure(FailureNotFound, "find release", response.StatusCode, nil)
|
return release{}, response.StatusCode, failure(FailureNotFound, "find release", response.StatusCode, nil)
|
||||||
}
|
}
|
||||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||||
return Release{}, response.StatusCode, responseFailure("find release", response.StatusCode)
|
return release{}, response.StatusCode, responseFailure("find release", response.StatusCode)
|
||||||
}
|
}
|
||||||
var decoded releaseResponse
|
var decoded releaseResponse
|
||||||
if err := decodeJSON(response.Body, &decoded); err != nil {
|
if err := decodeJSON(response.Body, &decoded); err != nil {
|
||||||
return Release{}, response.StatusCode, failure(FailureMalformed, "decode release", response.StatusCode, err)
|
return release{}, response.StatusCode, failure(FailureMalformed, "decode release", response.StatusCode, err)
|
||||||
}
|
}
|
||||||
return decoded.Release(), response.StatusCode, nil
|
return decoded.release(), response.StatusCode, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c Client) getJSON(ctx context.Context, client *http.Client, token, operation, endpoint string, destination any) error {
|
func (c Client) getJSON(ctx context.Context, client *http.Client, token, operation, endpoint string, destination any) error {
|
||||||
@@ -234,27 +140,27 @@ func (c Client) getJSON(ctx context.Context, client *http.Client, token, operati
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c Client) postRelease(ctx context.Context, client *http.Client, token, endpoint string, body []byte) (Release, int, error) {
|
func (c Client) postRelease(ctx context.Context, client *http.Client, token, endpoint string, body []byte) (release, int, error) {
|
||||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Release{}, 0, failure(FailureInvalid, "create release", 0, err)
|
return release{}, 0, failure(FailureInvalid, "create release", 0, err)
|
||||||
}
|
}
|
||||||
request.Header.Set("Authorization", "token "+token)
|
request.Header.Set("Authorization", "token "+token)
|
||||||
request.Header.Set("Accept", "application/json")
|
request.Header.Set("Accept", "application/json")
|
||||||
request.Header.Set("Content-Type", "application/json")
|
request.Header.Set("Content-Type", "application/json")
|
||||||
response, err := client.Do(request)
|
response, err := client.Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Release{}, 0, failure(FailureTransport, "create release", 0, err)
|
return release{}, 0, failure(FailureTransport, "create release", 0, err)
|
||||||
}
|
}
|
||||||
defer response.Body.Close()
|
defer response.Body.Close()
|
||||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||||
return Release{}, response.StatusCode, responseFailure("create release", response.StatusCode)
|
return release{}, response.StatusCode, responseFailure("create release", response.StatusCode)
|
||||||
}
|
}
|
||||||
var decoded releaseResponse
|
var decoded releaseResponse
|
||||||
if err := decodeJSON(response.Body, &decoded); err != nil {
|
if err := decodeJSON(response.Body, &decoded); err != nil {
|
||||||
return Release{}, response.StatusCode, failure(FailureMalformed, "decode created release", response.StatusCode, err)
|
return release{}, response.StatusCode, failure(FailureMalformed, "decode created release", response.StatusCode, err)
|
||||||
}
|
}
|
||||||
return decoded.Release(), response.StatusCode, nil
|
return decoded.release(), response.StatusCode, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c Client) httpClient() *http.Client {
|
func (c Client) httpClient() *http.Client {
|
||||||
@@ -269,28 +175,6 @@ func (c Client) httpClient() *http.Client {
|
|||||||
return &clientCopy
|
return &clientCopy
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateRequest(request Request) error {
|
|
||||||
if strings.TrimSpace(request.Tag) == "" {
|
|
||||||
return failure(FailureInvalid, "validate request", 0, errors.New("full release tag is required"))
|
|
||||||
}
|
|
||||||
if !validObjectID(request.Commit) {
|
|
||||||
return failure(FailureInvalid, "validate request", 0, errors.New("source commit must be a lowercase Git object ID"))
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(request.Destination.Owner) == "" || strings.ContainsAny(request.Destination.Owner, "/\\") {
|
|
||||||
return failure(FailureInvalid, "validate request", 0, errors.New("Gitea owner is required as one path segment"))
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(request.Destination.Repository) == "" || strings.ContainsAny(request.Destination.Repository, "/\\") {
|
|
||||||
return failure(FailureInvalid, "validate request", 0, errors.New("Gitea repository is required as one path segment"))
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(request.Destination.Token) == "" {
|
|
||||||
return failure(FailureInvalid, "validate request", 0, errors.New("Gitea token is required"))
|
|
||||||
}
|
|
||||||
if noteTitle(request.Note) == "" {
|
|
||||||
return failure(FailureInvalid, "validate request", 0, errors.New("release note must begin with a level-one heading"))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseBaseURL(raw string) (*url.URL, error) {
|
func parseBaseURL(raw string) (*url.URL, error) {
|
||||||
parsed, err := url.Parse(raw)
|
parsed, err := url.Parse(raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -302,30 +186,8 @@ func parseBaseURL(raw string) (*url.URL, error) {
|
|||||||
return parsed, nil
|
return parsed, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func acceptExisting(release Release, request Request) (Result, error) {
|
func (response releaseResponse) release() release {
|
||||||
if release.TagName != request.Tag || release.Body != request.Note || release.Draft || release.Prerelease {
|
return release{ID: response.ID, URL: response.HTMLURL, TagName: response.TagName, Title: response.Name, Body: response.Body, Draft: response.Draft, Prerelease: response.Prerelease}
|
||||||
return Result{}, failure(FailureConflict, "verify existing release", 0, errors.New("existing release does not match the committed note and stable tag"))
|
|
||||||
}
|
|
||||||
return Result{Release: release}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func acceptCreated(release Release, request Request) (Result, error) {
|
|
||||||
if release.ID <= 0 || release.TagName != request.Tag || release.Body != request.Note || release.Title != noteTitle(request.Note) || release.Draft || release.Prerelease {
|
|
||||||
return Result{}, failure(FailureMalformed, "verify created release", 0, errors.New("created release response does not match the requested stable release"))
|
|
||||||
}
|
|
||||||
return Result{Release: release, Created: true}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (response releaseResponse) Release() Release {
|
|
||||||
return Release{ID: response.ID, URL: response.HTMLURL, TagName: response.TagName, Title: response.Name, Body: response.Body, Draft: response.Draft, Prerelease: response.Prerelease}
|
|
||||||
}
|
|
||||||
|
|
||||||
func noteTitle(note string) string {
|
|
||||||
first := strings.SplitN(note, "\n", 2)[0]
|
|
||||||
if !strings.HasPrefix(first, "# ") {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(strings.TrimPrefix(first, "# "))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func validObjectID(value string) bool {
|
func validObjectID(value string) bool {
|
||||||
|
|||||||
@@ -1,452 +0,0 @@
|
|||||||
package gitea
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
const testCommit = "0123456789012345678901234567890123456789"
|
|
||||||
const testNote = "# Example v1.2.3\n\nRelease details.\n"
|
|
||||||
const testToken = "fixture-secret-token"
|
|
||||||
|
|
||||||
func TestEnsureReleaseFindsMatchingReleaseAndPreservesTitle(t *testing.T) {
|
|
||||||
server, _ := newReleaseServer(t, releaseFixture{title: "Historical title"}, false)
|
|
||||||
result, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if result.Created || result.Release.Title != "Historical title" {
|
|
||||||
t.Fatalf("result = %#v", result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEnsureReleaseEscapesSlashContainingTag(t *testing.T) {
|
|
||||||
tag := "integrations/ckconfig/v1.2.3"
|
|
||||||
state := &fixtureState{}
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assertAuth(t, r)
|
|
||||||
switch r.URL.EscapedPath() {
|
|
||||||
case "/api/v1/repos/owner/project/tags/integrations%2Fckconfig%2Fv1.2.3":
|
|
||||||
assertRequest(t, r, http.MethodGet, r.URL.Path)
|
|
||||||
writeTag(t, w, testCommit)
|
|
||||||
case "/api/v1/repos/owner/project/releases/tags/integrations%2Fckconfig%2Fv1.2.3":
|
|
||||||
assertRequest(t, r, http.MethodGet, r.URL.Path)
|
|
||||||
http.NotFound(w, r)
|
|
||||||
case "/api/v1/repos/owner/project/releases":
|
|
||||||
assertRequest(t, r, http.MethodPost, r.URL.Path)
|
|
||||||
state.posts.Add(1)
|
|
||||||
var payload createReleaseRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
||||||
t.Error(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if payload.TagName != tag || payload.Name != "Example v1.2.3" || payload.Body != testNote {
|
|
||||||
t.Errorf("create payload = %#v", payload)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := json.NewEncoder(w).Encode(releaseResponse{ID: 7, HTMLURL: "https://example.test/release", TagName: tag, TargetCommitish: testCommit, Name: payload.Name, Body: payload.Body}); err != nil {
|
|
||||||
t.Error(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
t.Errorf("unexpected escaped request %s %s", r.Method, r.URL.EscapedPath())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
request := testRequest(server.URL)
|
|
||||||
request.Tag = tag
|
|
||||||
result, err := testClient(server).EnsureRelease(context.Background(), request)
|
|
||||||
if err != nil || !result.Created || state.posts.Load() != 1 {
|
|
||||||
t.Fatalf("nested tag release = %#v, posts %d, error %v", result, state.posts.Load(), err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEnsureReleaseCreatesMissingPageFromCommittedNote(t *testing.T) {
|
|
||||||
server, _ := newReleaseServer(t, releaseFixture{}, true)
|
|
||||||
result, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if !result.Created || result.Release.Title != "Example v1.2.3" || result.Release.Body != testNote {
|
|
||||||
t.Fatalf("result = %#v", result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEnsureReleaseConflictDoesNotEditExistingPage(t *testing.T) {
|
|
||||||
server, state := newReleaseServer(t, releaseFixture{title: "Keep this title", body: "Different body"}, false)
|
|
||||||
_, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
|
||||||
if failureKind(t, err) != FailureConflict {
|
|
||||||
t.Fatalf("error = %v", err)
|
|
||||||
}
|
|
||||||
if got := state.posts.Load(); got != 0 {
|
|
||||||
t.Fatalf("release creates = %d, want 0", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEnsureReleaseUnauthorizedIsStructuredAndHidesToken(t *testing.T) {
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assertRequest(t, r, http.MethodGet, "/api/v1/repos/owner/project/tags/v1.2.3")
|
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
_, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
|
||||||
if failureKind(t, err) != FailureUnauthorized {
|
|
||||||
t.Fatalf("error = %v", err)
|
|
||||||
}
|
|
||||||
if fmt.Sprint(err) == "" || contains(fmt.Sprint(err), testToken) {
|
|
||||||
t.Fatalf("error exposes unexpected content: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEnsureReleaseMalformedTagResponseStopsBeforeCreate(t *testing.T) {
|
|
||||||
var posts atomic.Int64
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method == http.MethodPost {
|
|
||||||
posts.Add(1)
|
|
||||||
}
|
|
||||||
assertRequest(t, r, http.MethodGet, "/api/v1/repos/owner/project/tags/v1.2.3")
|
|
||||||
_, _ = w.Write([]byte("{"))
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
_, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
|
||||||
if failureKind(t, err) != FailureMalformed || posts.Load() != 0 {
|
|
||||||
t.Fatalf("error = %v, posts = %d", err, posts.Load())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEnsureReleaseWrongHostedTagStopsBeforeCreate(t *testing.T) {
|
|
||||||
var posts atomic.Int64
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assertRequest(t, r, http.MethodGet, "/api/v1/repos/owner/project/tags/v1.2.3")
|
|
||||||
if r.Method == http.MethodPost {
|
|
||||||
posts.Add(1)
|
|
||||||
}
|
|
||||||
_, _ = fmt.Fprintf(w, `{"commit":{"sha":"%040d"}}`, 7)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
_, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
|
||||||
if failureKind(t, err) != FailureConflict || posts.Load() != 0 {
|
|
||||||
t.Fatalf("error = %v, posts = %d", err, posts.Load())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEnsureReleaseMissingHostedTagStopsBeforeCreate(t *testing.T) {
|
|
||||||
var posts atomic.Int64
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assertRequest(t, r, http.MethodGet, "/api/v1/repos/owner/project/tags/v1.2.3")
|
|
||||||
if r.Method == http.MethodPost {
|
|
||||||
posts.Add(1)
|
|
||||||
}
|
|
||||||
http.NotFound(w, r)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
_, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
|
||||||
if failureKind(t, err) != FailureNotFound || posts.Load() != 0 {
|
|
||||||
t.Fatalf("error = %v, posts = %d", err, posts.Load())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEnsureReleaseHonorsContextCancellation(t *testing.T) {
|
|
||||||
var called atomic.Bool
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called.Store(true) }))
|
|
||||||
defer server.Close()
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
cancel()
|
|
||||||
_, err := testClient(server).EnsureRelease(ctx, testRequest(server.URL))
|
|
||||||
if !errors.Is(err, context.Canceled) || called.Load() {
|
|
||||||
t.Fatalf("error = %v, server called = %t", err, called.Load())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEnsureReleaseResolvesLostCreateAcknowledgmentWithOneLookup(t *testing.T) {
|
|
||||||
var created atomic.Bool
|
|
||||||
var lookups atomic.Int64
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assertAuth(t, r)
|
|
||||||
switch r.URL.Path {
|
|
||||||
case "/api/v1/repos/owner/project/tags/v1.2.3":
|
|
||||||
assertRequest(t, r, http.MethodGet, r.URL.Path)
|
|
||||||
writeTag(t, w, testCommit)
|
|
||||||
case "/api/v1/repos/owner/project/releases/tags/v1.2.3":
|
|
||||||
assertRequest(t, r, http.MethodGet, r.URL.Path)
|
|
||||||
lookups.Add(1)
|
|
||||||
if !created.Load() {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeRelease(t, w, releaseFixture{title: "Example v1.2.3"})
|
|
||||||
case "/api/v1/repos/owner/project/releases":
|
|
||||||
assertRequest(t, r, http.MethodPost, r.URL.Path)
|
|
||||||
var payload createReleaseRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
||||||
t.Error(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if payload.TagName != "v1.2.3" || payload.TargetCommitish != testCommit || payload.Name != "Example v1.2.3" || payload.Body != testNote || payload.Draft || payload.Prerelease {
|
|
||||||
t.Errorf("create payload = %#v", payload)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
created.Store(true)
|
|
||||||
hijacker, ok := w.(http.Hijacker)
|
|
||||||
if !ok {
|
|
||||||
t.Error("HTTP writer does not support hijacking")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
connection, _, err := hijacker.Hijack()
|
|
||||||
if err != nil {
|
|
||||||
t.Error(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = connection.Close()
|
|
||||||
default:
|
|
||||||
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
result, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if result.Created || lookups.Load() != 2 {
|
|
||||||
t.Fatalf("result = %#v, release lookups = %d", result, lookups.Load())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEnsureReleaseResolvesCreateConflictWithOneLookup(t *testing.T) {
|
|
||||||
var lookups atomic.Int64
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assertAuth(t, r)
|
|
||||||
switch r.URL.Path {
|
|
||||||
case "/api/v1/repos/owner/project/tags/v1.2.3":
|
|
||||||
assertRequest(t, r, http.MethodGet, r.URL.Path)
|
|
||||||
writeTag(t, w, testCommit)
|
|
||||||
case "/api/v1/repos/owner/project/releases/tags/v1.2.3":
|
|
||||||
assertRequest(t, r, http.MethodGet, r.URL.Path)
|
|
||||||
lookups.Add(1)
|
|
||||||
if lookups.Load() == 1 {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeRelease(t, w, releaseFixture{title: "Created by another request"})
|
|
||||||
case "/api/v1/repos/owner/project/releases":
|
|
||||||
assertRequest(t, r, http.MethodPost, r.URL.Path)
|
|
||||||
w.WriteHeader(http.StatusConflict)
|
|
||||||
default:
|
|
||||||
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
result, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if result.Created || result.Release.Title != "Created by another request" || lookups.Load() != 2 {
|
|
||||||
t.Fatalf("result = %#v, release lookups = %d", result, lookups.Load())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEnsureReleaseReportsUnknownWhenAmbiguousCreateCannotBeInspected(t *testing.T) {
|
|
||||||
var lookups atomic.Int64
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assertAuth(t, r)
|
|
||||||
switch r.URL.Path {
|
|
||||||
case "/api/v1/repos/owner/project/tags/v1.2.3":
|
|
||||||
assertRequest(t, r, http.MethodGet, r.URL.Path)
|
|
||||||
writeTag(t, w, testCommit)
|
|
||||||
case "/api/v1/repos/owner/project/releases/tags/v1.2.3":
|
|
||||||
assertRequest(t, r, http.MethodGet, r.URL.Path)
|
|
||||||
lookups.Add(1)
|
|
||||||
if lookups.Load() == 1 {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
http.Error(w, "temporarily unavailable", http.StatusServiceUnavailable)
|
|
||||||
case "/api/v1/repos/owner/project/releases":
|
|
||||||
assertRequest(t, r, http.MethodPost, r.URL.Path)
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
default:
|
|
||||||
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
_, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
|
||||||
if failureKind(t, err) != FailureUnknown || lookups.Load() != 2 {
|
|
||||||
t.Fatalf("error = %v, release lookups = %d", err, lookups.Load())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type releaseFixture struct {
|
|
||||||
title string
|
|
||||||
body string
|
|
||||||
draft bool
|
|
||||||
pre bool
|
|
||||||
}
|
|
||||||
|
|
||||||
type fixtureState struct{ posts atomic.Int64 }
|
|
||||||
|
|
||||||
func newReleaseServer(t *testing.T, existing releaseFixture, createMissing bool) (*httptest.Server, *fixtureState) {
|
|
||||||
t.Helper()
|
|
||||||
state := &fixtureState{}
|
|
||||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assertAuth(t, r)
|
|
||||||
switch r.URL.Path {
|
|
||||||
case "/api/v1/repos/owner/project/tags/v1.2.3":
|
|
||||||
assertRequest(t, r, http.MethodGet, r.URL.Path)
|
|
||||||
writeTag(t, w, testCommit)
|
|
||||||
case "/api/v1/repos/owner/project/releases/tags/v1.2.3":
|
|
||||||
assertRequest(t, r, http.MethodGet, r.URL.Path)
|
|
||||||
if existing.title == "" && existing.body == "" {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeRelease(t, w, existing)
|
|
||||||
case "/api/v1/repos/owner/project/releases":
|
|
||||||
assertRequest(t, r, http.MethodPost, r.URL.Path)
|
|
||||||
state.posts.Add(1)
|
|
||||||
var payload createReleaseRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
||||||
t.Error(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if payload.TagName != "v1.2.3" || payload.TargetCommitish != testCommit || payload.Name != "Example v1.2.3" || payload.Body != testNote || payload.Draft || payload.Prerelease {
|
|
||||||
t.Errorf("create payload = %#v", payload)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !createMissing {
|
|
||||||
w.WriteHeader(http.StatusConflict)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeRelease(t, w, releaseFixture{title: payload.Name})
|
|
||||||
default:
|
|
||||||
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
})
|
|
||||||
server := httptest.NewServer(handler)
|
|
||||||
t.Cleanup(server.Close)
|
|
||||||
return server, state
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeTag(t *testing.T, w http.ResponseWriter, commit string) {
|
|
||||||
t.Helper()
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_, _ = fmt.Fprintf(w, `{"name":"v1.2.3","commit":{"sha":%q}}`, commit)
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeRelease(t *testing.T, w http.ResponseWriter, fixture releaseFixture) {
|
|
||||||
t.Helper()
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
_ = json.NewEncoder(w).Encode(releaseResponse{ID: 10, HTMLURL: "https://gitea.example/releases/10", TagName: "v1.2.3", Name: fixture.title, Body: fixture.bodyOrDefault(), Draft: fixture.draft, Prerelease: fixture.pre})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (fixture releaseFixture) bodyOrDefault() string {
|
|
||||||
if fixture.body == "" {
|
|
||||||
return testNote
|
|
||||||
}
|
|
||||||
return fixture.body
|
|
||||||
}
|
|
||||||
|
|
||||||
func assertRequest(t *testing.T, request *http.Request, method, path string) {
|
|
||||||
t.Helper()
|
|
||||||
if request.Method != method || request.URL.Path != path {
|
|
||||||
t.Errorf("request = %s %s, want %s %s", request.Method, request.URL.Path, method, path)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
assertAuth(t, request)
|
|
||||||
}
|
|
||||||
|
|
||||||
func assertAuth(t *testing.T, request *http.Request) {
|
|
||||||
t.Helper()
|
|
||||||
if got := request.Header.Get("Authorization"); got != "token "+testToken {
|
|
||||||
t.Errorf("Authorization = %q", got)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func testClient(server *httptest.Server) Client {
|
|
||||||
return Client{HTTPClient: server.Client()}
|
|
||||||
}
|
|
||||||
|
|
||||||
func testRequest(baseURL string) Request {
|
|
||||||
return Request{Destination: Destination{BaseURL: baseURL, Owner: "owner", Repository: "project", Token: testToken}, Tag: "v1.2.3", Commit: testCommit, Note: testNote}
|
|
||||||
}
|
|
||||||
|
|
||||||
func failureKind(t *testing.T, err error) FailureKind {
|
|
||||||
t.Helper()
|
|
||||||
var failure *Failure
|
|
||||||
if !errors.As(err, &failure) {
|
|
||||||
t.Fatalf("error type = %T (%v), want *Failure", err, err)
|
|
||||||
}
|
|
||||||
return failure.Kind
|
|
||||||
}
|
|
||||||
|
|
||||||
func contains(value, substring string) bool {
|
|
||||||
return len(substring) > 0 && len(value) >= len(substring) && strings.Contains(value, substring)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEnsureReleaseReconcilesInconsistentSuccessfulCreate(t *testing.T) {
|
|
||||||
for _, mode := range []string{"matching", "conflicting", "unavailable"} {
|
|
||||||
t.Run(mode, func(t *testing.T) {
|
|
||||||
var posts, lookups atomic.Int64
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
switch {
|
|
||||||
case strings.Contains(r.URL.Path, "/tags/") && !strings.Contains(r.URL.Path, "/releases/"):
|
|
||||||
writeTag(t, w, testCommit)
|
|
||||||
case strings.Contains(r.URL.Path, "/releases/tags/"):
|
|
||||||
lookups.Add(1)
|
|
||||||
if posts.Load() == 0 {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if mode == "unavailable" {
|
|
||||||
http.Error(w, "unavailable", 503)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
body := testNote
|
|
||||||
if mode == "conflicting" {
|
|
||||||
body = "other content"
|
|
||||||
}
|
|
||||||
writeRelease(t, w, releaseFixture{title: "Example v1.2.3", body: body})
|
|
||||||
case r.Method == http.MethodPost:
|
|
||||||
posts.Add(1)
|
|
||||||
w.WriteHeader(http.StatusCreated)
|
|
||||||
json.NewEncoder(w).Encode(releaseResponse{ID: 10, TagName: "v1.2.3", Name: "inconsistent title", Body: testNote})
|
|
||||||
default:
|
|
||||||
t.Errorf("unexpected request %s", r.URL.Path)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
result, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
|
||||||
if posts.Load() != 1 || lookups.Load() != 2 {
|
|
||||||
t.Fatalf("posts=%d lookups=%d", posts.Load(), lookups.Load())
|
|
||||||
}
|
|
||||||
switch mode {
|
|
||||||
case "matching":
|
|
||||||
if err != nil || !result.WriteAttempted {
|
|
||||||
t.Fatalf("result=%#v err=%v", result, err)
|
|
||||||
}
|
|
||||||
case "conflicting":
|
|
||||||
if failureKind(t, err) != FailureConflict {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
case "unavailable":
|
|
||||||
if failureKind(t, err) != FailureUnknown {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,242 +1,34 @@
|
|||||||
package gitea
|
package gitea
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type DeliveryState string
|
func (c Client) uploadAsset(ctx context.Context, client *http.Client, token, apiRoot string, releaseID int64, filename string, content io.Reader) (int, error) {
|
||||||
|
if content == nil {
|
||||||
const (
|
return 0, failure(FailureInvalid, "upload release asset", 0, errors.New("asset content reader is required"))
|
||||||
DeliveryNotAttempted DeliveryState = "not_attempted"
|
|
||||||
DeliveryComplete DeliveryState = "complete"
|
|
||||||
DeliveryPartial DeliveryState = "partial"
|
|
||||||
DeliveryConflict DeliveryState = "conflict"
|
|
||||||
DeliveryUnknown DeliveryState = "unknown"
|
|
||||||
)
|
|
||||||
|
|
||||||
type AssetIdentity struct {
|
|
||||||
Filename string
|
|
||||||
SHA256 string
|
|
||||||
}
|
|
||||||
|
|
||||||
// UploadResult reports the current asset observations from one upload attempt.
|
|
||||||
// Each identity appears in at most one category; early failures can leave some
|
|
||||||
// declared assets unreported.
|
|
||||||
type UploadResult struct {
|
|
||||||
State DeliveryState
|
|
||||||
// Matching contains existing attachments with the declared content.
|
|
||||||
Matching []AssetIdentity
|
|
||||||
// Uploaded contains assets whose upload was confirmed during this invocation.
|
|
||||||
Uploaded []AssetIdentity
|
|
||||||
// Pending contains assets known to be missing.
|
|
||||||
Pending []AssetIdentity
|
|
||||||
// Conflicting contains assets with detected content conflicts.
|
|
||||||
Conflicting []AssetIdentity
|
|
||||||
// Uncertain contains assets whose remote state could not be resolved.
|
|
||||||
Uncertain []AssetIdentity
|
|
||||||
}
|
|
||||||
|
|
||||||
// VerifyLocalAssets checks that every expected file still matches its declared
|
|
||||||
// size and digest before delivery begins.
|
|
||||||
func VerifyLocalAssets(assets []ExpectedAsset) error {
|
|
||||||
if err := validateExpectedAssets(assets); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
for _, asset := range assets {
|
|
||||||
if err := verifyLocalAsset(asset); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UploadMissing uploads assets classified missing by InspectAssets. It stops
|
|
||||||
// before the first write if any declared existing asset conflicts or cannot be
|
|
||||||
// verified, and never replaces an existing attachment.
|
|
||||||
func (c Client) UploadMissing(ctx context.Context, destination Destination, releaseID int64, inspection Inspection) (UploadResult, error) {
|
|
||||||
return c.uploadMissing(ctx, destination, releaseID, inspection, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// UploadVerifiedMissing uploads missing assets whose local contents have already
|
|
||||||
// passed VerifyLocalAssets. It reuses that verification while retaining remote
|
|
||||||
// inspection, conflict handling, and upload reconciliation.
|
|
||||||
func (c Client) UploadVerifiedMissing(ctx context.Context, destination Destination, releaseID int64, inspection Inspection) (UploadResult, error) {
|
|
||||||
return c.uploadMissing(ctx, destination, releaseID, inspection, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c Client) uploadMissing(ctx context.Context, destination Destination, releaseID int64, inspection Inspection, verifyLocal bool) (UploadResult, error) {
|
|
||||||
if releaseID <= 0 {
|
|
||||||
return UploadResult{State: DeliveryPartial}, failure(FailureInvalid, "upload release assets", 0, errors.New("release ID must be positive"))
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(destination.Token) == "" {
|
|
||||||
return UploadResult{State: DeliveryPartial}, failure(FailureInvalid, "upload release assets", 0, errors.New("Gitea token is required"))
|
|
||||||
}
|
|
||||||
base, err := parseBaseURL(destination.BaseURL)
|
|
||||||
if err != nil {
|
|
||||||
return UploadResult{State: DeliveryPartial}, failure(FailureInvalid, "validate destination", 0, err)
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(destination.Owner) == "" || strings.ContainsAny(destination.Owner, "/\\") || strings.TrimSpace(destination.Repository) == "" || strings.ContainsAny(destination.Repository, "/\\") {
|
|
||||||
return UploadResult{State: DeliveryPartial}, failure(FailureInvalid, "upload release assets", 0, errors.New("Gitea owner and repository must be path segments"))
|
|
||||||
}
|
|
||||||
expected := orderedExpected(inspection.Assets)
|
|
||||||
if err := validateExpectedAssets(expected); err != nil {
|
|
||||||
return UploadResult{State: DeliveryPartial}, err
|
|
||||||
}
|
|
||||||
result := UploadResult{State: DeliveryPartial}
|
|
||||||
preflightConflict := false
|
|
||||||
preflightUnknown := false
|
|
||||||
for _, observation := range inspection.Assets {
|
|
||||||
switch observation.State {
|
|
||||||
case AssetMatching:
|
|
||||||
result.Matching = append(result.Matching, identity(observation.Expected))
|
|
||||||
case AssetMissing:
|
|
||||||
result.Pending = append(result.Pending, identity(observation.Expected))
|
|
||||||
case AssetConflicting:
|
|
||||||
preflightConflict = true
|
|
||||||
result.Conflicting = append(result.Conflicting, identity(observation.Expected))
|
|
||||||
case AssetUnverifiable:
|
|
||||||
preflightUnknown = true
|
|
||||||
result.Uncertain = append(result.Uncertain, identity(observation.Expected))
|
|
||||||
default:
|
|
||||||
return result, failure(FailureInvalid, "preflight release assets", 0, fmt.Errorf("asset %q has no inspection result", observation.Expected.Filename))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if preflightConflict {
|
|
||||||
result.State = DeliveryConflict
|
|
||||||
return result, failure(FailureConflict, "preflight release assets", 0, errors.New("one or more existing attachments conflict"))
|
|
||||||
}
|
|
||||||
if preflightUnknown {
|
|
||||||
result.State = DeliveryNotAttempted
|
|
||||||
return result, failure(FailureUnverifiable, "preflight release assets", 0, errors.New("one or more existing attachments could not be verified"))
|
|
||||||
}
|
|
||||||
if len(expected) == 0 {
|
|
||||||
result.State = DeliveryComplete
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
if len(result.Pending) == 0 {
|
|
||||||
result.State = DeliveryComplete
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
missing := make(map[string]ExpectedAsset, len(result.Pending))
|
|
||||||
for _, observation := range inspection.Assets {
|
|
||||||
if observation.State == AssetMissing {
|
|
||||||
missing[observation.Expected.Filename] = observation.Expected
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if verifyLocal {
|
|
||||||
for _, item := range expected {
|
|
||||||
if _, needsUpload := missing[item.Filename]; !needsUpload {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := verifyLocalAsset(item); err != nil {
|
|
||||||
return result, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
baseAPI := strings.TrimRight(base.String(), "/") + "/api/v1/repos/" + url.PathEscape(destination.Owner) + "/" + url.PathEscape(destination.Repository)
|
|
||||||
client := c.httpClient()
|
|
||||||
for _, item := range expected {
|
|
||||||
if _, needsUpload := missing[item.Filename]; !needsUpload {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
status, uploadErr := c.uploadAsset(ctx, client, destination.Token, baseAPI, releaseID, item)
|
|
||||||
if uploadErr == nil {
|
|
||||||
result.Uploaded = append(result.Uploaded, identity(item))
|
|
||||||
result.Pending = removeIdentity(result.Pending, item.Filename)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !shouldReconcileWrite(status, uploadErr) {
|
|
||||||
return result, uploadErr
|
|
||||||
}
|
|
||||||
inspection, inspectErr := c.InspectAssets(ctx, destination, releaseID, []ExpectedAsset{item})
|
|
||||||
if inspectErr != nil || len(inspection.Assets) != 1 {
|
|
||||||
result.State = DeliveryUnknown
|
|
||||||
result.Pending = removeIdentity(result.Pending, item.Filename)
|
|
||||||
result.Uncertain = append(result.Uncertain, identity(item))
|
|
||||||
return result, failure(FailureUnknown, "resolve asset upload", status, errors.Join(uploadErr, inspectErr))
|
|
||||||
}
|
|
||||||
observation := inspection.Assets[0]
|
|
||||||
switch observation.State {
|
|
||||||
case AssetMatching:
|
|
||||||
result.Matching = append(result.Matching, identity(item))
|
|
||||||
result.Pending = removeIdentity(result.Pending, item.Filename)
|
|
||||||
case AssetConflicting:
|
|
||||||
result.State = DeliveryConflict
|
|
||||||
result.Pending = removeIdentity(result.Pending, item.Filename)
|
|
||||||
result.Conflicting = append(result.Conflicting, identity(item))
|
|
||||||
return result, failure(FailureConflict, "resolve asset upload", status, uploadErr)
|
|
||||||
case AssetMissing:
|
|
||||||
if status == http.StatusConflict {
|
|
||||||
result.State = DeliveryConflict
|
|
||||||
result.Pending = removeIdentity(result.Pending, item.Filename)
|
|
||||||
result.Conflicting = append(result.Conflicting, identity(item))
|
|
||||||
return result, failure(FailureConflict, "resolve asset upload", status, uploadErr)
|
|
||||||
}
|
|
||||||
result.State = DeliveryUnknown
|
|
||||||
result.Pending = removeIdentity(result.Pending, item.Filename)
|
|
||||||
result.Uncertain = append(result.Uncertain, identity(item))
|
|
||||||
return result, failure(FailureUnknown, "resolve asset upload", status, uploadErr)
|
|
||||||
default:
|
|
||||||
result.State = DeliveryUnknown
|
|
||||||
result.Pending = removeIdentity(result.Pending, item.Filename)
|
|
||||||
result.Uncertain = append(result.Uncertain, identity(item))
|
|
||||||
return result, failure(FailureUnknown, "resolve asset upload", status, errors.Join(uploadErr, observation.Err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(result.Pending) == 0 {
|
|
||||||
result.State = DeliveryComplete
|
|
||||||
} else {
|
|
||||||
result.State = DeliveryPartial
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c Client) uploadAsset(ctx context.Context, client *http.Client, token, apiRoot string, releaseID int64, asset ExpectedAsset) (int, error) {
|
|
||||||
uploadURL := apiRoot + "/releases/" + strconv.FormatInt(releaseID, 10) + "/assets"
|
uploadURL := apiRoot + "/releases/" + strconv.FormatInt(releaseID, 10) + "/assets"
|
||||||
parsed, err := url.Parse(uploadURL)
|
parsed, err := url.Parse(uploadURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, failure(FailureInvalid, "prepare asset upload", 0, err)
|
return 0, failure(FailureInvalid, "prepare asset upload", 0, err)
|
||||||
}
|
}
|
||||||
query := parsed.Query()
|
query := parsed.Query()
|
||||||
query.Set("name", asset.Filename)
|
query.Set("name", filename)
|
||||||
parsed.RawQuery = query.Encode()
|
parsed.RawQuery = query.Encode()
|
||||||
reader, writer := io.Pipe()
|
reader, writer := io.Pipe()
|
||||||
multipartWriter := multipart.NewWriter(writer)
|
multipartWriter := multipart.NewWriter(writer)
|
||||||
writeDone := make(chan error, 1)
|
writeDone := make(chan error, 1)
|
||||||
go func() {
|
go func() {
|
||||||
part, partErr := multipartWriter.CreateFormFile("attachment", asset.Filename)
|
part, partErr := multipartWriter.CreateFormFile("attachment", filename)
|
||||||
if partErr == nil {
|
if partErr == nil {
|
||||||
var source io.Reader
|
_, partErr = io.Copy(part, content)
|
||||||
if len(asset.Content) > 0 {
|
|
||||||
source = bytes.NewReader(asset.Content)
|
|
||||||
} else {
|
|
||||||
file, openErr := os.Open(asset.Path)
|
|
||||||
if openErr != nil {
|
|
||||||
partErr = openErr
|
|
||||||
} else {
|
|
||||||
defer file.Close()
|
|
||||||
source = file
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if partErr == nil {
|
|
||||||
_, partErr = io.Copy(part, source)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
closeErr := multipartWriter.Close()
|
closeErr := multipartWriter.Close()
|
||||||
if partErr == nil {
|
if partErr == nil {
|
||||||
@@ -270,7 +62,7 @@ func (c Client) uploadAsset(ctx context.Context, client *http.Client, token, api
|
|||||||
if err := decodeJSON(response.Body, &uploaded); err != nil {
|
if err := decodeJSON(response.Body, &uploaded); err != nil {
|
||||||
return response.StatusCode, failure(FailureMalformed, "decode uploaded asset", response.StatusCode, err)
|
return response.StatusCode, failure(FailureMalformed, "decode uploaded asset", response.StatusCode, err)
|
||||||
}
|
}
|
||||||
if uploaded.ID <= 0 || uploaded.Name != asset.Filename {
|
if uploaded.ID <= 0 || uploaded.Name != filename {
|
||||||
return response.StatusCode, failure(FailureMalformed, "verify uploaded asset", response.StatusCode, errors.New("upload response does not identify the selected asset"))
|
return response.StatusCode, failure(FailureMalformed, "verify uploaded asset", response.StatusCode, errors.New("upload response does not identify the selected asset"))
|
||||||
}
|
}
|
||||||
if writeErr != nil {
|
if writeErr != nil {
|
||||||
@@ -278,66 +70,3 @@ func (c Client) uploadAsset(ctx context.Context, client *http.Client, token, api
|
|||||||
}
|
}
|
||||||
return response.StatusCode, nil
|
return response.StatusCode, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func verifyLocalAsset(asset ExpectedAsset) error {
|
|
||||||
if len(asset.Content) > 0 {
|
|
||||||
if int64(len(asset.Content)) != asset.Size || digest(asset.Content) != asset.SHA256 {
|
|
||||||
return failure(FailureConflict, "verify local asset", 0, fmt.Errorf("local content does not match declared asset %q", asset.Filename))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if asset.Path == "" || filepath.Base(asset.Path) != asset.Filename {
|
|
||||||
return failure(FailureInvalid, "verify local asset", 0, fmt.Errorf("local path for %q does not match its filename", asset.Filename))
|
|
||||||
}
|
|
||||||
file, err := os.Open(asset.Path)
|
|
||||||
if err != nil {
|
|
||||||
return failure(FailureInvalid, "verify local asset", 0, fmt.Errorf("open %q: %w", asset.Filename, err))
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
info, err := file.Stat()
|
|
||||||
if err != nil {
|
|
||||||
return failure(FailureInvalid, "verify local asset", 0, fmt.Errorf("stat %q: %w", asset.Filename, err))
|
|
||||||
}
|
|
||||||
if !info.Mode().IsRegular() || info.Size() != asset.Size {
|
|
||||||
return failure(FailureConflict, "verify local asset", 0, fmt.Errorf("local file does not match declared size for %q", asset.Filename))
|
|
||||||
}
|
|
||||||
hasher := sha256.New()
|
|
||||||
size, err := io.Copy(hasher, file)
|
|
||||||
if err != nil {
|
|
||||||
return failure(FailureInvalid, "verify local asset", 0, fmt.Errorf("read %q: %w", asset.Filename, err))
|
|
||||||
}
|
|
||||||
if size != asset.Size || hex.EncodeToString(hasher.Sum(nil)) != asset.SHA256 {
|
|
||||||
return failure(FailureConflict, "verify local asset", 0, fmt.Errorf("local content does not match declared asset %q", asset.Filename))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func orderedExpected(observations []AssetInspection) []ExpectedAsset {
|
|
||||||
result := make([]ExpectedAsset, 0, len(observations))
|
|
||||||
for _, observation := range observations {
|
|
||||||
result = append(result, observation.Expected)
|
|
||||||
}
|
|
||||||
sort.Slice(result, func(i, j int) bool {
|
|
||||||
if result[i].Filename == ManifestFilename {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if result[j].Filename == ManifestFilename {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return result[i].Filename < result[j].Filename
|
|
||||||
})
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func identity(asset ExpectedAsset) AssetIdentity {
|
|
||||||
return AssetIdentity{Filename: asset.Filename, SHA256: asset.SHA256}
|
|
||||||
}
|
|
||||||
|
|
||||||
func removeIdentity(identities []AssetIdentity, filename string) []AssetIdentity {
|
|
||||||
for index, item := range identities {
|
|
||||||
if item.Filename == filename {
|
|
||||||
return append(identities[:index], identities[index+1:]...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return identities
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,482 +0,0 @@
|
|||||||
package gitea
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"reflect"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestUploadMissingVerifiesEveryMissingLocalFileBeforeWriting(t *testing.T) {
|
|
||||||
fixture := &uploadFixture{files: make(map[string][]byte)}
|
|
||||||
server := httptest.NewServer(fixture)
|
|
||||||
defer server.Close()
|
|
||||||
client := testClient(server)
|
|
||||||
destination := testDestination(server.URL)
|
|
||||||
assets := localAssets(t, []string{"alpha.bin", "beta.bin"})
|
|
||||||
inspection, err := client.InspectAssets(t.Context(), destination, 10, assets)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
contents := readExpectedFile(t, assets[1])
|
|
||||||
contents[0] ^= 0xff
|
|
||||||
if err := os.WriteFile(assets[1].Path, contents, 0o600); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
result, err := client.UploadMissing(t.Context(), destination, 10, inspection)
|
|
||||||
if err == nil || result.State != DeliveryPartial {
|
|
||||||
t.Fatalf("upload = %#v, error = %v; want local verification failure", result, err)
|
|
||||||
}
|
|
||||||
if posts := fixture.postNames(); len(posts) != 0 {
|
|
||||||
t.Fatalf("uploaded %v before rejecting local content", posts)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUploadMissingPartialFailureThenRerunOnlyMissingAssets(t *testing.T) {
|
|
||||||
fixture := &uploadFixture{files: make(map[string][]byte), failName: "beta.bin"}
|
|
||||||
server := httptest.NewServer(fixture)
|
|
||||||
defer server.Close()
|
|
||||||
client := testClient(server)
|
|
||||||
destination := testDestination(server.URL)
|
|
||||||
assets := localAssets(t, []string{"zeta.bin", "alpha.bin", "beta.bin"})
|
|
||||||
manifest, err := BuildManifest(assets)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
assets = append(assets, manifest)
|
|
||||||
|
|
||||||
initial, err := client.InspectAssets(context.Background(), destination, 10, assets)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
first, err := client.UploadMissing(context.Background(), destination, 10, initial)
|
|
||||||
if err == nil || first.State != DeliveryPartial {
|
|
||||||
t.Fatalf("first upload result = %#v, error = %v", first, err)
|
|
||||||
}
|
|
||||||
if got, want := names(first.Uploaded), []string{"alpha.bin"}; !reflect.DeepEqual(got, want) {
|
|
||||||
t.Fatalf("uploaded = %v, want %v", got, want)
|
|
||||||
}
|
|
||||||
if got, want := names(first.Pending), []string{"zeta.bin", "beta.bin", "SHA256SUMS"}; !reflect.DeepEqual(got, want) {
|
|
||||||
t.Fatalf("pending = %v, want %v", got, want)
|
|
||||||
}
|
|
||||||
assertAssetCategoriesDisjoint(t, first)
|
|
||||||
|
|
||||||
secondInspection, err := client.InspectAssets(context.Background(), destination, 10, assets)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
second, err := client.UploadMissing(context.Background(), destination, 10, secondInspection)
|
|
||||||
if err != nil || second.State != DeliveryComplete {
|
|
||||||
t.Fatalf("second upload result = %#v, error = %v", second, err)
|
|
||||||
}
|
|
||||||
if got, want := names(second.Uploaded), []string{"beta.bin", "zeta.bin", "SHA256SUMS"}; !reflect.DeepEqual(got, want) {
|
|
||||||
t.Fatalf("second uploaded = %v, want %v", got, want)
|
|
||||||
}
|
|
||||||
if got, want := fixture.postNames(), []string{"alpha.bin", "beta.bin", "beta.bin", "zeta.bin", "SHA256SUMS"}; !reflect.DeepEqual(got, want) {
|
|
||||||
t.Fatalf("POST order = %v, want %v", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUploadMissingAcceptsConcurrentMatchingAttachmentWithoutReplacement(t *testing.T) {
|
|
||||||
fixture := &uploadFixture{files: make(map[string][]byte), conflictName: "app.bin"}
|
|
||||||
server := httptest.NewServer(fixture)
|
|
||||||
defer server.Close()
|
|
||||||
client := testClient(server)
|
|
||||||
destination := testDestination(server.URL)
|
|
||||||
assets := localAssets(t, []string{"app.bin"})
|
|
||||||
initial, err := client.InspectAssets(context.Background(), destination, 10, assets)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
result, err := client.UploadMissing(context.Background(), destination, 10, initial)
|
|
||||||
if err != nil || result.State != DeliveryComplete {
|
|
||||||
t.Fatalf("result = %#v, error = %v", result, err)
|
|
||||||
}
|
|
||||||
if got, want := names(result.Matching), []string{"app.bin"}; !reflect.DeepEqual(got, want) {
|
|
||||||
t.Fatalf("matching = %v, want %v", got, want)
|
|
||||||
}
|
|
||||||
if len(result.Uploaded) != 0 || len(fixture.postNames()) != 1 {
|
|
||||||
t.Fatalf("result = %#v, POST names = %v", result, fixture.postNames())
|
|
||||||
}
|
|
||||||
assertAssetCategoriesDisjoint(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUploadMissingReconcilesLostManifestAcknowledgment(t *testing.T) {
|
|
||||||
assets := localAssets(t, []string{"app.bin"})
|
|
||||||
manifest, err := BuildManifest(assets)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
fixture := &uploadFixture{files: make(map[string][]byte), dropAckName: ManifestFilename}
|
|
||||||
// The local binary is represented by its file path; the server starts with
|
|
||||||
// that exact content so only the checksum manifest needs uploading.
|
|
||||||
fixture.files["app.bin"] = readExpectedFile(t, assets[0])
|
|
||||||
server := httptest.NewServer(fixture)
|
|
||||||
defer server.Close()
|
|
||||||
client := testClient(server)
|
|
||||||
destination := testDestination(server.URL)
|
|
||||||
expected := append(assets, manifest)
|
|
||||||
initial, err := client.InspectAssets(context.Background(), destination, 10, expected)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
result, err := client.UploadMissing(context.Background(), destination, 10, initial)
|
|
||||||
if err != nil || result.State != DeliveryComplete {
|
|
||||||
t.Fatalf("result = %#v, error = %v", result, err)
|
|
||||||
}
|
|
||||||
if got, want := names(result.Matching), []string{"app.bin", ManifestFilename}; !reflect.DeepEqual(got, want) {
|
|
||||||
t.Fatalf("matching = %v, want %v", got, want)
|
|
||||||
}
|
|
||||||
if len(result.Uploaded) != 0 || !reflect.DeepEqual(fixture.postNames(), []string{ManifestFilename}) {
|
|
||||||
t.Fatalf("result = %#v, POST names = %v", result, fixture.postNames())
|
|
||||||
}
|
|
||||||
assertAssetCategoriesDisjoint(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUploadMissingReportsUnknownWhenReconciliationReadFails(t *testing.T) {
|
|
||||||
fixture := &uploadFixture{files: make(map[string][]byte), dropAckName: "app.bin", failListAfter: 1}
|
|
||||||
server := httptest.NewServer(fixture)
|
|
||||||
defer server.Close()
|
|
||||||
client := testClient(server)
|
|
||||||
destination := testDestination(server.URL)
|
|
||||||
assets := localAssets(t, []string{"app.bin", "later.bin"})
|
|
||||||
initial, err := client.InspectAssets(context.Background(), destination, 10, assets)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
result, err := client.UploadMissing(context.Background(), destination, 10, initial)
|
|
||||||
if err == nil || result.State != DeliveryUnknown {
|
|
||||||
t.Fatalf("result = %#v, error = %v", result, err)
|
|
||||||
}
|
|
||||||
if got, want := names(result.Pending), []string{"later.bin"}; !reflect.DeepEqual(got, want) {
|
|
||||||
t.Fatalf("pending = %v, want %v", got, want)
|
|
||||||
}
|
|
||||||
if got, want := names(result.Uncertain), []string{"app.bin"}; !reflect.DeepEqual(got, want) {
|
|
||||||
t.Fatalf("uncertain = %v, want %v", got, want)
|
|
||||||
}
|
|
||||||
assertAssetCategoriesDisjoint(t, result)
|
|
||||||
if got := fixture.postNames(); !reflect.DeepEqual(got, []string{"app.bin"}) {
|
|
||||||
t.Fatalf("POST names = %v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUploadMissingPreflightConflictPreventsEveryWrite(t *testing.T) {
|
|
||||||
fixture := &uploadFixture{files: map[string][]byte{"app.bin": []byte("different bytes")}}
|
|
||||||
server := httptest.NewServer(fixture)
|
|
||||||
defer server.Close()
|
|
||||||
client := testClient(server)
|
|
||||||
destination := testDestination(server.URL)
|
|
||||||
assets := localAssets(t, []string{"app.bin", "other.bin"})
|
|
||||||
inspection, err := client.InspectAssets(context.Background(), destination, 10, assets)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
result, err := client.UploadMissing(context.Background(), destination, 10, inspection)
|
|
||||||
if err == nil || result.State != DeliveryConflict {
|
|
||||||
t.Fatalf("result = %#v, error = %v", result, err)
|
|
||||||
}
|
|
||||||
if len(fixture.postNames()) != 0 {
|
|
||||||
t.Fatalf("uploads happened before conflict review: %v", fixture.postNames())
|
|
||||||
}
|
|
||||||
if got, want := names(result.Conflicting), []string{"app.bin"}; !reflect.DeepEqual(got, want) {
|
|
||||||
t.Fatalf("conflicting = %v, want %v", got, want)
|
|
||||||
}
|
|
||||||
if got, want := names(result.Pending), []string{"other.bin"}; !reflect.DeepEqual(got, want) {
|
|
||||||
t.Fatalf("pending = %v, want %v", got, want)
|
|
||||||
}
|
|
||||||
assertAssetCategoriesDisjoint(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUploadMissingKeepsLaterPendingAfterAmbiguousMissingUpload(t *testing.T) {
|
|
||||||
fixture := &uploadFixture{files: make(map[string][]byte), dropAckMissingName: "alpha.bin"}
|
|
||||||
server := httptest.NewServer(fixture)
|
|
||||||
defer server.Close()
|
|
||||||
client := testClient(server)
|
|
||||||
destination := testDestination(server.URL)
|
|
||||||
assets := localAssets(t, []string{"alpha.bin", "zeta.bin"})
|
|
||||||
initial, err := client.InspectAssets(context.Background(), destination, 10, assets)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
result, err := client.UploadMissing(context.Background(), destination, 10, initial)
|
|
||||||
if err == nil || result.State != DeliveryUnknown {
|
|
||||||
t.Fatalf("result = %#v, error = %v", result, err)
|
|
||||||
}
|
|
||||||
if got, want := names(result.Uncertain), []string{"alpha.bin"}; !reflect.DeepEqual(got, want) {
|
|
||||||
t.Fatalf("uncertain = %v, want %v", got, want)
|
|
||||||
}
|
|
||||||
if got, want := names(result.Pending), []string{"zeta.bin"}; !reflect.DeepEqual(got, want) {
|
|
||||||
t.Fatalf("pending = %v, want %v", got, want)
|
|
||||||
}
|
|
||||||
assertAssetCategoriesDisjoint(t, result)
|
|
||||||
if got, want := fixture.postNames(), []string{"alpha.bin"}; !reflect.DeepEqual(got, want) {
|
|
||||||
t.Fatalf("POST names = %v, want %v", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUploadMissingMovesReconciledConflictOutOfPending(t *testing.T) {
|
|
||||||
fixture := &uploadFixture{
|
|
||||||
files: make(map[string][]byte),
|
|
||||||
conflictName: "alpha.bin",
|
|
||||||
conflictDifferentContent: true,
|
|
||||||
}
|
|
||||||
server := httptest.NewServer(fixture)
|
|
||||||
defer server.Close()
|
|
||||||
client := testClient(server)
|
|
||||||
destination := testDestination(server.URL)
|
|
||||||
assets := localAssets(t, []string{"alpha.bin", "zeta.bin"})
|
|
||||||
initial, err := client.InspectAssets(context.Background(), destination, 10, assets)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
result, err := client.UploadMissing(context.Background(), destination, 10, initial)
|
|
||||||
if err == nil || result.State != DeliveryConflict {
|
|
||||||
t.Fatalf("result = %#v, error = %v", result, err)
|
|
||||||
}
|
|
||||||
if got, want := names(result.Conflicting), []string{"alpha.bin"}; !reflect.DeepEqual(got, want) {
|
|
||||||
t.Fatalf("conflicting = %v, want %v", got, want)
|
|
||||||
}
|
|
||||||
if got, want := names(result.Pending), []string{"zeta.bin"}; !reflect.DeepEqual(got, want) {
|
|
||||||
t.Fatalf("pending = %v, want %v", got, want)
|
|
||||||
}
|
|
||||||
assertAssetCategoriesDisjoint(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUploadMissingTreatsUnresolvedNameConflictAsConflict(t *testing.T) {
|
|
||||||
fixture := &uploadFixture{files: make(map[string][]byte), conflictMissingName: "alpha.bin"}
|
|
||||||
server := httptest.NewServer(fixture)
|
|
||||||
defer server.Close()
|
|
||||||
client := testClient(server)
|
|
||||||
destination := testDestination(server.URL)
|
|
||||||
assets := localAssets(t, []string{"alpha.bin", "zeta.bin"})
|
|
||||||
initial, err := client.InspectAssets(context.Background(), destination, 10, assets)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
result, err := client.UploadMissing(context.Background(), destination, 10, initial)
|
|
||||||
if err == nil || result.State != DeliveryConflict {
|
|
||||||
t.Fatalf("result = %#v, error = %v", result, err)
|
|
||||||
}
|
|
||||||
if got, want := names(result.Conflicting), []string{"alpha.bin"}; !reflect.DeepEqual(got, want) {
|
|
||||||
t.Fatalf("conflicting = %v, want %v", got, want)
|
|
||||||
}
|
|
||||||
if got, want := names(result.Pending), []string{"zeta.bin"}; !reflect.DeepEqual(got, want) {
|
|
||||||
t.Fatalf("pending = %v, want %v", got, want)
|
|
||||||
}
|
|
||||||
assertAssetCategoriesDisjoint(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
type uploadFixture struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
files map[string][]byte
|
|
||||||
posts []string
|
|
||||||
failName string
|
|
||||||
conflictName string
|
|
||||||
conflictDifferentContent bool
|
|
||||||
conflictMissingName string
|
|
||||||
dropAckName string
|
|
||||||
dropAckMissingName string
|
|
||||||
failListAfter int
|
|
||||||
listCount int
|
|
||||||
}
|
|
||||||
|
|
||||||
func (fixture *uploadFixture) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Header.Get("Authorization") != "token "+testToken {
|
|
||||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if r.Method == http.MethodGet && r.URL.Path == assetListPath {
|
|
||||||
fixture.mu.Lock()
|
|
||||||
fixture.listCount++
|
|
||||||
if fixture.failListAfter > 0 && fixture.listCount > fixture.failListAfter {
|
|
||||||
fixture.mu.Unlock()
|
|
||||||
http.Error(w, "read failed", http.StatusServiceUnavailable)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
assets := make([]assetResponse, 0, len(fixture.files))
|
|
||||||
for name, content := range fixture.files {
|
|
||||||
assets = append(assets, assetResponse{ID: int64(len(assets) + 1), Name: name, Size: int64(len(content)), BrowserDownloadURL: "/files/" + urlPathEscape(name)})
|
|
||||||
}
|
|
||||||
fixture.mu.Unlock()
|
|
||||||
_ = json.NewEncoder(w).Encode(assets)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/files/") {
|
|
||||||
name := strings.TrimPrefix(r.URL.Path, "/files/")
|
|
||||||
fixture.mu.Lock()
|
|
||||||
content, ok := fixture.files[name]
|
|
||||||
fixture.mu.Unlock()
|
|
||||||
if !ok {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_, _ = w.Write(content)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if r.Method == http.MethodPost && r.URL.Path == assetListPath {
|
|
||||||
fixture.receiveUpload(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
http.Error(w, "unexpected request", http.StatusMethodNotAllowed)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (fixture *uploadFixture) receiveUpload(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if !strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data; boundary=") {
|
|
||||||
http.Error(w, "multipart required", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
|
||||||
http.Error(w, "invalid multipart body", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
file, header, err := r.FormFile("attachment")
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "missing attachment field", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
content, err := io.ReadAll(file)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "read multipart file", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if header.Filename != r.URL.Query().Get("name") {
|
|
||||||
http.Error(w, "filename query mismatch", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
name := header.Filename
|
|
||||||
fixture.mu.Lock()
|
|
||||||
fixture.posts = append(fixture.posts, name)
|
|
||||||
attempts := 0
|
|
||||||
for _, posted := range fixture.posts {
|
|
||||||
if posted == name {
|
|
||||||
attempts++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if name == fixture.failName && attempts == 1 {
|
|
||||||
fixture.mu.Unlock()
|
|
||||||
http.Error(w, "write failed", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if name == fixture.dropAckMissingName {
|
|
||||||
fixture.mu.Unlock()
|
|
||||||
hijacker, ok := w.(http.Hijacker)
|
|
||||||
if !ok {
|
|
||||||
http.Error(w, "hijacking unavailable", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
connection, _, err := hijacker.Hijack()
|
|
||||||
if err == nil {
|
|
||||||
_ = connection.Close()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if name == fixture.conflictMissingName {
|
|
||||||
fixture.mu.Unlock()
|
|
||||||
w.WriteHeader(http.StatusConflict)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if name == fixture.conflictName && fixture.conflictDifferentContent {
|
|
||||||
fixture.files[name] = []byte("conflicting bytes")
|
|
||||||
fixture.mu.Unlock()
|
|
||||||
w.WriteHeader(http.StatusConflict)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fixture.files[name] = append([]byte(nil), content...)
|
|
||||||
fixture.mu.Unlock()
|
|
||||||
if name == fixture.conflictName {
|
|
||||||
w.WriteHeader(http.StatusConflict)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if name == fixture.dropAckName {
|
|
||||||
hijacker, ok := w.(http.Hijacker)
|
|
||||||
if !ok {
|
|
||||||
http.Error(w, "hijacking unavailable", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
connection, _, err := hijacker.Hijack()
|
|
||||||
if err == nil {
|
|
||||||
_ = connection.Close()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusCreated)
|
|
||||||
_ = json.NewEncoder(w).Encode(assetResponse{ID: 50, Name: name, Size: int64(len(content))})
|
|
||||||
}
|
|
||||||
|
|
||||||
func localAssets(t *testing.T, names []string) []ExpectedAsset {
|
|
||||||
t.Helper()
|
|
||||||
output := t.TempDir()
|
|
||||||
assets := make([]ExpectedAsset, 0, len(names))
|
|
||||||
for _, name := range names {
|
|
||||||
content := []byte("binary-" + name)
|
|
||||||
path := filepath.Join(output, name)
|
|
||||||
if err := os.WriteFile(path, content, 0o600); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
sum := assetFor(name, content)
|
|
||||||
sum.Content = nil
|
|
||||||
sum.Path = path
|
|
||||||
assets = append(assets, sum)
|
|
||||||
}
|
|
||||||
return assets
|
|
||||||
}
|
|
||||||
|
|
||||||
func readExpectedFile(t *testing.T, asset ExpectedAsset) []byte {
|
|
||||||
t.Helper()
|
|
||||||
content, err := os.ReadFile(asset.Path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return content
|
|
||||||
}
|
|
||||||
|
|
||||||
func (fixture *uploadFixture) postNames() []string {
|
|
||||||
fixture.mu.Lock()
|
|
||||||
defer fixture.mu.Unlock()
|
|
||||||
return append([]string(nil), fixture.posts...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func names(identities []AssetIdentity) []string {
|
|
||||||
result := make([]string, 0, len(identities))
|
|
||||||
for _, identity := range identities {
|
|
||||||
result = append(result, identity.Filename)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func assertAssetCategoriesDisjoint(t *testing.T, result UploadResult) {
|
|
||||||
t.Helper()
|
|
||||||
groups := []struct {
|
|
||||||
name string
|
|
||||||
identities []AssetIdentity
|
|
||||||
}{
|
|
||||||
{name: "matching", identities: result.Matching},
|
|
||||||
{name: "uploaded", identities: result.Uploaded},
|
|
||||||
{name: "pending", identities: result.Pending},
|
|
||||||
{name: "conflicting", identities: result.Conflicting},
|
|
||||||
{name: "uncertain", identities: result.Uncertain},
|
|
||||||
}
|
|
||||||
seen := make(map[string]string)
|
|
||||||
for _, group := range groups {
|
|
||||||
for _, item := range group.identities {
|
|
||||||
if previous, ok := seen[item.Filename]; ok {
|
|
||||||
t.Errorf("asset %q appears in both %s and %s", item.Filename, previous, group.name)
|
|
||||||
}
|
|
||||||
seen[item.Filename] = group.name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func urlPathEscape(value string) string {
|
|
||||||
return (&url.URL{Path: value}).EscapedPath()
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/process"
|
"go.maximumdirect.net/releasekit/internal/process"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Mode selects the target environment for a Go command.
|
// Mode selects the target environment for a Go command.
|
||||||
@@ -97,7 +97,7 @@ func New(ctx context.Context, directory string) (*Tool, error) {
|
|||||||
func PrepareBuildDependencies(ctx context.Context, directory string) error {
|
func PrepareBuildDependencies(ctx context.Context, directory string) error {
|
||||||
captured := &combinedWriter{}
|
captured := &combinedWriter{}
|
||||||
err := process.Run(ctx, process.Invocation{
|
err := process.Run(ctx, process.Invocation{
|
||||||
Args: []string{"go", "mod", "download"},
|
Args: []string{"go", "mod", "download", "-modfile="},
|
||||||
Dir: directory,
|
Dir: directory,
|
||||||
Env: map[string]string{
|
Env: map[string]string{
|
||||||
"GOWORK": "off",
|
"GOWORK": "off",
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ func TestPrepareBuildDependenciesPreservesPreparationEnvironment(t *testing.T) {
|
|||||||
if err := json.Unmarshal(contents, &record); err != nil {
|
if err := json.Unmarshal(contents, &record); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if strings.Join(record.Arguments, " ") != "mod download" || record.Directory != repository {
|
if strings.Join(record.Arguments, " ") != "mod download -modfile=" || record.Directory != repository {
|
||||||
t.Fatalf("Go invocation = %#v", record)
|
t.Fatalf("Go invocation = %#v", record)
|
||||||
}
|
}
|
||||||
if record.Workspace != "off" || record.Toolchain != "local" {
|
if record.Workspace != "off" || record.Toolchain != "local" {
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate"
|
"go.maximumdirect.net/releasekit/internal/candidate"
|
||||||
"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/versioncontract"
|
"go.maximumdirect.net/releasekit/internal/versioncontract"
|
||||||
)
|
)
|
||||||
|
|
||||||
type TaggedCheckoutRequest struct {
|
type TaggedCheckoutRequest struct {
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate"
|
"go.maximumdirect.net/releasekit/internal/candidate"
|
||||||
"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/validation"
|
"go.maximumdirect.net/releasekit/internal/validation"
|
||||||
)
|
)
|
||||||
|
|
||||||
type State string
|
type State string
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
|
"go.maximumdirect.net/releasekit/internal/git"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
|
"go.maximumdirect.net/releasekit/internal/versioncontract"
|
||||||
"golang.org/x/mod/modfile"
|
"golang.org/x/mod/modfile"
|
||||||
"golang.org/x/mod/module"
|
"golang.org/x/mod/module"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ const (
|
|||||||
MarkdownLinks Check = "markdown-links"
|
MarkdownLinks Check = "markdown-links"
|
||||||
// SharedFiles identifies a requested shared file that is unavailable.
|
// SharedFiles identifies a requested shared file that is unavailable.
|
||||||
SharedFiles Check = "shared-files"
|
SharedFiles Check = "shared-files"
|
||||||
|
// ForbiddenFiles identifies selected paths present in the committed source.
|
||||||
|
ForbiddenFiles Check = "forbidden-files"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Finding describes a validation observation at an optional source location.
|
// Finding describes a validation observation at an optional source location.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"path"
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
|
"go.maximumdirect.net/releasekit/internal/git"
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultFileSizeLimit int64 = 1_000_000
|
const defaultFileSizeLimit int64 = 1_000_000
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
|
"go.maximumdirect.net/releasekit/internal/git"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCheckFileSizeUsesStrictLimitsAndDefault(t *testing.T) {
|
func TestCheckFileSizeUsesStrictLimitsAndDefault(t *testing.T) {
|
||||||
|
|||||||
24
internal/validation/forbidden_files.go
Normal file
24
internal/validation/forbidden_files.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package validation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func checkForbiddenFiles(tree *CandidateTree, names []string) error {
|
||||||
|
var findings []Finding
|
||||||
|
for _, name := range names {
|
||||||
|
clean := path.Clean(name)
|
||||||
|
if name == "" || path.IsAbs(name) || clean == "." || clean == ".." || strings.HasPrefix(clean, "../") {
|
||||||
|
findings = append(findings, Finding{Path: name, Message: "expected a repository-relative file path"})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := tree.Entry(clean); exists {
|
||||||
|
findings = append(findings, Finding{Path: clean, Message: "file must be absent from the committed candidate"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(findings) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &Failure{Check: ForbiddenFiles, Findings: findings}
|
||||||
|
}
|
||||||
49
internal/validation/forbidden_files_test.go
Normal file
49
internal/validation/forbidden_files_test.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package validation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestForbiddenFilesUsesCapturedRepositoryPaths(t *testing.T) {
|
||||||
|
repository := newTreeRepository(t)
|
||||||
|
writeTreeFile(t, repository, "go.mod", []byte("module example.com/root\n"))
|
||||||
|
writeTreeFile(t, repository, "nested/go.mod", []byte("module example.com/nested\n"))
|
||||||
|
writeTreeFile(t, repository, "nested/token", []byte("fixture-secret\n"))
|
||||||
|
writeTreeFile(t, repository, ".gitignore", []byte(".releasekit/\n"))
|
||||||
|
gitTree(t, repository, "add", ".")
|
||||||
|
gitTree(t, repository, "commit", "-m", "capture a committed credential path")
|
||||||
|
commit := strings.TrimSpace(gitTree(t, repository, "rev-parse", "HEAD"))
|
||||||
|
writeTreeFile(t, repository, ".releasekit/token", []byte("local-secret\n"))
|
||||||
|
if err := os.Remove(filepath.Join(repository, "nested/token")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
installGoShim(t, "", false)
|
||||||
|
request := Request{RepositoryDir: repository, Commit: commit}
|
||||||
|
if err := Run(context.Background(), request); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
request.Settings.ForbiddenFiles = []string{".releasekit/token", "absent-token"}
|
||||||
|
if err := Run(context.Background(), request); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
request.Settings.ForbiddenFiles = []string{"nested/./token"}
|
||||||
|
err := Run(context.Background(), request)
|
||||||
|
failure, ok := err.(*Failure)
|
||||||
|
if !ok || failure.Check != ForbiddenFiles || len(failure.Findings) != 1 || failure.Findings[0].Path != "nested/token" {
|
||||||
|
t.Fatalf("forbidden-files error = %v, want committed nested path despite worktree deletion", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "fixture-secret") {
|
||||||
|
t.Fatal("validation exposed committed token bytes")
|
||||||
|
}
|
||||||
|
for _, invalid := range []string{"", "../token", "/token"} {
|
||||||
|
request.Settings.ForbiddenFiles = []string{invalid}
|
||||||
|
if err := Run(context.Background(), request); err == nil {
|
||||||
|
t.Fatalf("invalid path %q was accepted", invalid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
|
"go.maximumdirect.net/releasekit/internal/git"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCheckGoFormatUsesCapturedRegularGoBlobs(t *testing.T) {
|
func TestCheckGoFormatUsesCapturedRegularGoBlobs(t *testing.T) {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"path"
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
|
"go.maximumdirect.net/releasekit/internal/git"
|
||||||
"golang.org/x/mod/modfile"
|
"golang.org/x/mod/modfile"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"path"
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
|
"go.maximumdirect.net/releasekit/internal/git"
|
||||||
)
|
)
|
||||||
|
|
||||||
func checkMarkdownLinks(ctx context.Context, tree *CandidateTree, enforceCheckoutBoundary bool, output io.Writer) error {
|
func checkMarkdownLinks(ctx context.Context, tree *CandidateTree, enforceCheckoutBoundary bool, output io.Writer) error {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user