Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 7d033c5ca1 | |||
| f076da99b6 | |||
| 3f2a1a1052 | |||
| 2d5a8110c6 | |||
| d42727b038 | |||
| 059a79525a | |||
| 6176971307 | |||
| 8f6e72b0b8 |
@@ -8,6 +8,12 @@ tagged-source delivery API, and the `version`, `check`, `publish`, `build`,
|
||||
maintainer procedure and prepared Woodpecker workflow for hosted note-page
|
||||
delivery are documented. ReleaseKit does not declare binary deliverables.
|
||||
|
||||
The Go module is `go.maximumdirect.net/releasekit`. The
|
||||
[adoption guide](docs/consumers/adoption.md#migrating-from-the-original-module-path)
|
||||
explains the v0.5.0 migration from the original Gitea module path.
|
||||
The [v0.6.0 release note](docs/releases/v0.6.0.md) describes the delivery API
|
||||
and profile migration.
|
||||
|
||||
Release operations can select one Go module within a repository. The repository
|
||||
root remains the default; a selected module's declared import path and physical
|
||||
directory determine its complete Git tag and module-local release note. See the
|
||||
@@ -75,6 +81,8 @@ failure recovery.
|
||||
components and their responsibilities.
|
||||
- [Architecture policy](docs/policy/architecture.md): responsibility and dependency
|
||||
boundaries for development.
|
||||
- [ADR 0006](docs/adr/0006-neutral-forge-delivery-and-preflight.md): neutral
|
||||
delivery policy and credential preflight.
|
||||
- [ADR 0001](docs/adr/0001-public-facade.md): the accepted public facade decision.
|
||||
- [Policy provenance](docs/policy/meta.md): adopted policy sources and update
|
||||
guidance.
|
||||
|
||||
10
build.go
10
build.go
@@ -5,11 +5,11 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact"
|
||||
internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
|
||||
"go.maximumdirect.net/releasekit/internal/artifact"
|
||||
internalbuild "go.maximumdirect.net/releasekit/internal/build"
|
||||
"go.maximumdirect.net/releasekit/internal/git"
|
||||
"go.maximumdirect.net/releasekit/internal/releaseidentity"
|
||||
"go.maximumdirect.net/releasekit/internal/versioncontract"
|
||||
)
|
||||
|
||||
// Target identifies one explicit Go operating-system and architecture pair.
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
releasekit "gitea.maximumdirect.net/eric/ReleaseKit"
|
||||
releasekit "go.maximumdirect.net/releasekit"
|
||||
)
|
||||
|
||||
func TestBuildPreservesInvalidVersionAndDeclarationFailureEvidence(t *testing.T) {
|
||||
@@ -48,7 +48,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo"
|
||||
"go.maximumdirect.net/releasekit/buildinfo"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -127,7 +127,7 @@ func TestBuildUsesGoHostDespiteAmbientTargetEnvironment(t *testing.T) {
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo"
|
||||
"go.maximumdirect.net/releasekit/buildinfo"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -159,7 +159,7 @@ func TestBuildIgnoresAmbientAndPersistentArchitectureTuning(t *testing.T) {
|
||||
import (
|
||||
"fmt"
|
||||
"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()) } }
|
||||
`)
|
||||
@@ -272,7 +272,7 @@ func TestBuildUsesNestedWorkingTreeModuleAndModuleRelativeOutput(t *testing.T) {
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo"
|
||||
"go.maximumdirect.net/releasekit/buildinfo"
|
||||
)
|
||||
func main() {
|
||||
if len(os.Args) == 2 && os.Args[1] == "version" {
|
||||
@@ -406,7 +406,7 @@ func TestBuildResolvesDirtyWorkingTreeDeclaration(t *testing.T) {
|
||||
|
||||
func TestBuildRegeneratesOwnedOutputsAndPreservesUnrelatedFiles(t *testing.T) {
|
||||
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())}
|
||||
`
|
||||
repository := newBuildConsumer(t, source)
|
||||
@@ -616,7 +616,7 @@ func TestCheckBuildsAfterValidationAndKeepsOutputOutsideWorktree(t *testing.T) {
|
||||
import (
|
||||
"fmt"
|
||||
"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()) } }
|
||||
`)
|
||||
@@ -713,7 +713,7 @@ func newBuildConsumer(t *testing.T, mainSource string) string {
|
||||
if err != nil {
|
||||
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, "cmd/app/main.go", mainSource)
|
||||
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 {
|
||||
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, "cmd/app/main.go")), mainSource)
|
||||
return filepath.Join(repository, filepath.FromSlash(moduleDir))
|
||||
@@ -743,7 +743,7 @@ func prepareCandidateBuild(t *testing.T, repository, mainSource string) {
|
||||
if err != nil {
|
||||
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, ".gitignore", "dist/\n")
|
||||
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) {
|
||||
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())} }
|
||||
`
|
||||
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, "checkout", "--detach", "v1.2.3")
|
||||
delivered, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{RepositoryDir: repository, Product: "Fixture", Version: "v1.2.3", Prebuilt: &result})
|
||||
delivered, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{RepositoryDir: repository, Product: "Fixture", Version: "v1.2.3", Destination: releasekit.DeliveryDestination{Forge: releasekit.ForgeGitea}, Prebuilt: &result})
|
||||
if err == nil || !strings.Contains(err.Error(), "build source") || delivered.State != releasekit.DeliveryNotAttempted {
|
||||
t.Fatalf("dirty source delivered: %#v %v", delivered, err)
|
||||
}
|
||||
@@ -838,7 +838,7 @@ func TestCandidateBuildDoesNotRequireWritableRepositoryParent(t *testing.T) {
|
||||
t.Skip("requires ordinary Unix permission checks")
|
||||
}
|
||||
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())}
|
||||
`)
|
||||
writeBuildFixture(t, repository, "docs/releases/v1.2.3.md", "# Fixture v1.2.3\n")
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// built with ReleaseKit.
|
||||
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,
|
||||
// it returns the executable's main module version, or "dev" when that metadata
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
|
||||
"go.maximumdirect.net/releasekit/internal/versioncontract"
|
||||
)
|
||||
|
||||
func TestVersionInConsumerExecutable(t *testing.T) {
|
||||
@@ -21,9 +21,9 @@ func TestVersionInConsumerExecutable(t *testing.T) {
|
||||
|
||||
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))
|
||||
if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte(module), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -32,7 +32,7 @@ replace gitea.maximumdirect.net/eric/ReleaseKit => %s
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo"
|
||||
"go.maximumdirect.net/releasekit/buildinfo"
|
||||
)
|
||||
|
||||
func main() { fmt.Print(buildinfo.Version()) }
|
||||
|
||||
19
check.go
19
check.go
@@ -4,10 +4,10 @@ import (
|
||||
"context"
|
||||
"io"
|
||||
|
||||
internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/publish"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/validation"
|
||||
internalbuild "go.maximumdirect.net/releasekit/internal/build"
|
||||
"go.maximumdirect.net/releasekit/internal/candidate"
|
||||
"go.maximumdirect.net/releasekit/internal/publish"
|
||||
"go.maximumdirect.net/releasekit/internal/validation"
|
||||
)
|
||||
|
||||
// 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
|
||||
// input boundaries; vendored files remain included. Referenced destinations
|
||||
// remain available for targeted inspection without expanding the input set.
|
||||
// ForbiddenFiles checks explicitly selected repository paths independently of
|
||||
// those file inputs.
|
||||
type ValidationSpec struct {
|
||||
// SharedFiles adds repository-relative committed files to optional file
|
||||
// checks. Nil selects the optional root COPYRIGHT, LICENSE, and
|
||||
@@ -66,7 +68,11 @@ type ValidationSpec struct {
|
||||
// Explicit files replace the defaults and must exist even when file checks
|
||||
// are off.
|
||||
SharedFiles []string
|
||||
Go GoValidationSpec
|
||||
// ForbiddenFiles lists literal repository-relative file paths that must be
|
||||
// absent from the captured commit, independently of module/shared inputs.
|
||||
// An empty list disables the check. File contents and history are not read.
|
||||
ForbiddenFiles []string
|
||||
Go GoValidationSpec
|
||||
// GoFormat checks the formatting of committed regular Go source files. It
|
||||
// does not modify repository contents and is disabled when false.
|
||||
GoFormat bool
|
||||
@@ -137,6 +143,8 @@ type GoValidationSpec struct {
|
||||
// of test-time vet, and Validation.Go.CompileTargets compiles all packages for
|
||||
// each selected GOOS/GOARCH pair after tests and vet with cgo disabled. Package
|
||||
// compilation produces no artifacts and does not execute target binaries.
|
||||
// Validation.ForbiddenFiles checks that selected repository-relative files are
|
||||
// absent from the captured commit after Go checks and before other file checks.
|
||||
// Validation.GoFormat checks regular committed Go source after those selected
|
||||
// Go checks and before AdditionalChecks. Its zero value disables formatting.
|
||||
// Validation.ModuleDistribution.WorkspaceFiles rejects committed workspace
|
||||
@@ -189,6 +197,7 @@ func candidateRequest(request ReleaseRequest) candidate.Request {
|
||||
FullVet: request.Validation.Go.FullVet,
|
||||
CompileTargets: make([]validation.Target, len(request.Validation.Go.CompileTargets)),
|
||||
SharedFiles: copyStringSlice(request.Validation.SharedFiles),
|
||||
ForbiddenFiles: copyStringSlice(request.Validation.ForbiddenFiles),
|
||||
GoFormat: request.Validation.GoFormat,
|
||||
Markdown: internalMarkdownSettings(request.Validation.Markdown),
|
||||
ModuleDistribution: validation.ModuleDistributionSettings{
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli"
|
||||
"go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli"
|
||||
)
|
||||
|
||||
func TestBuildAndPrebuiltDeliveryUseSameDirectoryBase(t *testing.T) {
|
||||
@@ -37,17 +37,17 @@ func TestBuildAndPrebuiltDeliveryUseSameDirectoryBase(t *testing.T) {
|
||||
}
|
||||
}
|
||||
writeRepositoryFile(t, repository, ".gitignore", "dist/\n")
|
||||
state := &deliveryServerState{tag: tag}
|
||||
state := &deliveryServerState{data: deliveryServerData{tag: tag}}
|
||||
server := newDeliveryServer(t, state)
|
||||
defer server.Close()
|
||||
settings := cli.Settings{Product: "Example Product", ModuleDir: tt.module,
|
||||
Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}},
|
||||
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||
Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||
}
|
||||
writeDeliveryProfile(t, repository, settings)
|
||||
cliGitRun(t, repository, "add", ".")
|
||||
cliGitRun(t, repository, "commit", "-m", "Prepare module source and output exclusion")
|
||||
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", tag)
|
||||
output, resolved := "dist", filepath.Join(moduleDirectory, "dist")
|
||||
if tt.absolute {
|
||||
@@ -73,12 +73,12 @@ func TestBuildAndPrebuiltDeliveryUseSameDirectoryBase(t *testing.T) {
|
||||
args := []string{"deliver", "--repo", invocationRepository, "--assets", output, "v1.2.3"}
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
if status := run(context.Background(), args, &stdout, &stderr); status != 0 || state.releaseCreates != 1 || state.assetUploads != 2 {
|
||||
t.Fatalf("delivery status=%d, creates=%d, uploads=%d, stderr=%s", status, state.releaseCreates, state.assetUploads, stderr.String())
|
||||
if status := run(context.Background(), args, &stdout, &stderr); status != 0 || state.snapshot().releaseCreates != 1 || state.snapshot().assetUploads != 2 {
|
||||
t.Fatalf("delivery status=%d, creates=%d, uploads=%d, stderr=%s", status, state.snapshot().releaseCreates, state.snapshot().assetUploads, stderr.String())
|
||||
}
|
||||
if tt.alias {
|
||||
if status := run(context.Background(), []string{"deliver", "--repo", repository, "--assets", output, "v1.2.3"}, &stdout, &stderr); status != 0 || state.assetUploads != 2 {
|
||||
t.Fatalf("physical-path retry status=%d, uploads=%d, stderr=%s", status, state.assetUploads, stderr.String())
|
||||
if status := run(context.Background(), []string{"deliver", "--repo", repository, "--assets", output, "v1.2.3"}, &stdout, &stderr); status != 0 || state.snapshot().assetUploads != 2 {
|
||||
t.Fatalf("physical-path retry status=%d, uploads=%d, stderr=%s", status, state.snapshot().assetUploads, stderr.String())
|
||||
}
|
||||
}
|
||||
if tt.module != "" {
|
||||
@@ -86,17 +86,17 @@ func TestBuildAndPrebuiltDeliveryUseSameDirectoryBase(t *testing.T) {
|
||||
wrongModule := settings
|
||||
wrongModule.ModuleDir = ""
|
||||
invocation := cli.Invocation{Repository: repository, AssetsDir: resolved, Version: "v1.2.3"}
|
||||
requestsBefore := state.apiRequests
|
||||
if status := deliverWithSettings(context.Background(), invocation, wrongModule, &stdout, &stderr); status != 1 || state.apiRequests != requestsBefore {
|
||||
t.Fatalf("wrong module status=%d, requests=%d->%d, stderr=%s", status, requestsBefore, state.apiRequests, stderr.String())
|
||||
requestsBefore := state.snapshot().apiRequests
|
||||
if status := deliverWithSettings(context.Background(), invocation, wrongModule, &stdout, &stderr); status != 1 || state.snapshot().apiRequests != requestsBefore {
|
||||
t.Fatalf("wrong module status=%d, requests=%d->%d, stderr=%s", status, requestsBefore, state.snapshot().apiRequests, stderr.String())
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(receipt.Artifacts[0].Path, []byte("changed"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requestsBefore := state.apiRequests
|
||||
if status := run(context.Background(), args, &stdout, &stderr); status != 1 || state.apiRequests != requestsBefore {
|
||||
t.Fatalf("changed content status=%d, requests=%d->%d, stderr=%s", status, requestsBefore, state.apiRequests, stderr.String())
|
||||
writesBefore := state.snapshot().writes
|
||||
if status := run(context.Background(), args, &stdout, &stderr); status != 1 || state.snapshot().writes != writesBefore {
|
||||
t.Fatalf("changed content status=%d, writes=%d->%d, stderr=%s", status, writesBefore, state.snapshot().writes, stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
60
cmd/releasekit/ci_identity_test.go
Normal file
60
cmd/releasekit/ci_identity_test.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli"
|
||||
)
|
||||
|
||||
func TestCIDeliveryRejectsModuleTagMismatchBeforePreparation(t *testing.T) {
|
||||
for _, binary := range []bool{false, true} {
|
||||
name := "page-only"
|
||||
if binary {
|
||||
name = "binary"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
repository := newDeliveryBuildRepository(t)
|
||||
writeRepositoryFile(t, repository, "child/go.mod", "module example.com/tagged-app/child\n\ngo 1.26.0\n")
|
||||
writeRepositoryFile(t, repository, "child/docs/releases/v1.2.3.md", "# Example Product v1.2.3\n\nChild release.\n")
|
||||
cliGitRun(t, repository, "add", "child")
|
||||
cliGitRun(t, repository, "commit", "-m", "add child module")
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Errorf("mismatched module tag reached forge: %s", r.URL.Path)
|
||||
http.Error(w, "unexpected request", http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
settings := cli.Settings{
|
||||
Product: "Example Product", ModuleDir: "child",
|
||||
Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||
}
|
||||
if binary {
|
||||
settings.Build = &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}}
|
||||
}
|
||||
writeDeliveryProfile(t, repository, settings)
|
||||
commit := pushTaggedCLIRepository(t, repository)
|
||||
t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token")
|
||||
shimDir := t.TempDir()
|
||||
marker := filepath.Join(shimDir, "go-invoked")
|
||||
if err := os.WriteFile(filepath.Join(shimDir, "go"), []byte("#!/bin/sh\n: > \"$RELEASEKIT_GO_MARKER\"\nexit 99\n"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("RELEASEKIT_GO_MARKER", marker)
|
||||
t.Setenv("PATH", shimDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
var stdout, stderr bytes.Buffer
|
||||
status := run(context.Background(), []string{"ci-deliver", "--repo", repository, "--prepared", "--commit", commit, "--expected-module", "child", "v1.2.3"}, &stdout, &stderr)
|
||||
if status != 1 || !strings.Contains(stderr.String(), `expected delivery tag "v1.2.3"`) || !strings.Contains(stderr.String(), `selected module tag "child/v1.2.3"`) {
|
||||
t.Fatalf("status=%d stderr=%q, want module tag mismatch", status, stderr.String())
|
||||
}
|
||||
if _, err := os.Stat(marker); !os.IsNotExist(err) {
|
||||
t.Fatalf("dependency/build work ran before tag comparison: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli"
|
||||
"go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli"
|
||||
)
|
||||
|
||||
func TestCandidateCheckUsesCommittedDeclarationSymlink(t *testing.T) {
|
||||
@@ -36,15 +36,15 @@ func TestPageOnlyDeliveryUsesCommittedModuleInSparseCheckout(t *testing.T) {
|
||||
writeRepositoryFile(t, repository, module+"/docs/releases/v1.2.3.md", "# Example Product v1.2.3\n\nNested release note.\n")
|
||||
cliGitRun(t, repository, "add", ".")
|
||||
cliGitRun(t, repository, "commit", "-m", "Add sparse release module")
|
||||
state := &deliveryServerState{tag: module + "/v1.2.3"}
|
||||
state := &deliveryServerState{data: deliveryServerData{tag: module + "/v1.2.3"}}
|
||||
server := newDeliveryServer(t, state)
|
||||
defer server.Close()
|
||||
writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", ModuleDir: module,
|
||||
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||
Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||
})
|
||||
state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD")
|
||||
cliGitRun(t, repository, "tag", state.tag)
|
||||
cliGitRun(t, repository, "checkout", "--detach", state.tag)
|
||||
state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
|
||||
cliGitRun(t, repository, "tag", state.snapshot().tag)
|
||||
cliGitRun(t, repository, "checkout", "--detach", state.snapshot().tag)
|
||||
cliGitRun(t, repository, "sparse-checkout", "set", "--cone", "docs")
|
||||
if _, err := os.Stat(filepath.Join(repository, filepath.FromSlash(module))); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("module fixture is materialized: %v", err)
|
||||
@@ -61,11 +61,11 @@ func TestPageOnlyDeliveryUsesCommittedModuleInSparseCheckout(t *testing.T) {
|
||||
t.Setenv("PATH", gitOnly)
|
||||
for _, args := range [][]string{
|
||||
{"deliver", "--repo", repository, "v1.2.3"},
|
||||
{"ci-deliver", "--repo", repository, "--prepared", "--expected-module", module, "--commit", state.commit, state.tag},
|
||||
{"ci-deliver", "--repo", repository, "--prepared", "--expected-module", module, "--commit", state.snapshot().commit, state.snapshot().tag},
|
||||
} {
|
||||
var stdout, stderr bytes.Buffer
|
||||
if status := run(context.Background(), args, &stdout, &stderr); status != 0 || state.releaseCreates != 1 || state.assetUploads != 0 {
|
||||
t.Fatalf("%s sparse delivery status=%d creates=%d uploads=%d stderr=%s", args[0], status, state.releaseCreates, state.assetUploads, stderr.String())
|
||||
if status := run(context.Background(), args, &stdout, &stderr); status != 0 || state.snapshot().releaseCreates != 1 || state.snapshot().assetUploads != 0 {
|
||||
t.Fatalf("%s sparse delivery status=%d creates=%d uploads=%d stderr=%s", args[0], status, state.snapshot().releaseCreates, state.snapshot().assetUploads, stderr.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
repository := newCLIRepository(t)
|
||||
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=")
|
||||
goCommand := func(directory string, args ...string) string {
|
||||
t.Helper()
|
||||
|
||||
@@ -13,8 +13,8 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/process"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
|
||||
"go.maximumdirect.net/releasekit/internal/process"
|
||||
"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"
|
||||
@@ -153,8 +153,9 @@ type Settings struct {
|
||||
type ValidationSettings struct {
|
||||
// SharedFiles selects committed files relative to the repository root.
|
||||
// Nil uses optional defaults; a non-nil empty slice selects no shared files.
|
||||
SharedFiles []string `json:"shared_files"`
|
||||
Go GoValidationSettings `json:"go,omitempty"`
|
||||
SharedFiles []string `json:"shared_files"`
|
||||
ForbiddenFiles []string `json:"forbidden_files,omitempty"`
|
||||
Go GoValidationSettings `json:"go,omitempty"`
|
||||
// GoFormat checks the formatting of committed regular Go source files.
|
||||
// Omitted or false disables the check.
|
||||
GoFormat bool `json:"go_format,omitempty"`
|
||||
@@ -195,12 +196,14 @@ type GoValidationSettings struct {
|
||||
CompileTargets []string `json:"compile_targets,omitempty"`
|
||||
}
|
||||
|
||||
// DeliverySettings selects the hosted Gitea release destination.
|
||||
// DeliverySettings selects the hosted forge release destination.
|
||||
type DeliverySettings struct {
|
||||
Forge string `json:"forge,omitempty"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Owner string `json:"owner"`
|
||||
Repository string `json:"repository"`
|
||||
TokenEnv string `json:"token_env,omitempty"`
|
||||
TokenFile string `json:"token_file,omitempty"`
|
||||
}
|
||||
|
||||
// BuildSettings selects the Go application package, artifact name, and targets.
|
||||
|
||||
@@ -10,9 +10,9 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
|
||||
"go.maximumdirect.net/releasekit/internal/artifact"
|
||||
"go.maximumdirect.net/releasekit/internal/releaseidentity"
|
||||
"go.maximumdirect.net/releasekit/internal/versioncontract"
|
||||
)
|
||||
|
||||
const BuildReceiptName = ".releasekit-build.json"
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli"
|
||||
"go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli"
|
||||
)
|
||||
|
||||
func TestMainModuleWithoutDomainSupportsCheckBuildAndPageDelivery(t *testing.T) {
|
||||
@@ -20,7 +20,7 @@ func TestMainModuleWithoutDomainSupportsCheckBuildAndPageDelivery(t *testing.T)
|
||||
defer server.Close()
|
||||
settings := cli.Settings{Product: "Example Product",
|
||||
Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}},
|
||||
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||
Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||
}
|
||||
writeDeliveryProfile(t, repository, settings)
|
||||
for _, args := range [][]string{
|
||||
@@ -34,11 +34,11 @@ func TestMainModuleWithoutDomainSupportsCheckBuildAndPageDelivery(t *testing.T)
|
||||
}
|
||||
settings.Build = nil
|
||||
writeDeliveryProfile(t, repository, settings)
|
||||
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")
|
||||
t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token")
|
||||
var stdout, stderr bytes.Buffer
|
||||
if status := run(context.Background(), []string{"deliver", "--repo", repository, "v1.2.3"}, &stdout, &stderr); status != 0 || state.releaseCreates != 1 || state.assetUploads != 0 {
|
||||
t.Fatalf("page delivery status=%d creates=%d uploads=%d stderr=%s", status, state.releaseCreates, state.assetUploads, stderr.String())
|
||||
if status := run(context.Background(), []string{"deliver", "--repo", repository, "v1.2.3"}, &stdout, &stderr); status != 0 || state.snapshot().releaseCreates != 1 || state.snapshot().assetUploads != 0 {
|
||||
t.Fatalf("page delivery status=%d creates=%d uploads=%d stderr=%s", status, state.snapshot().releaseCreates, state.snapshot().assetUploads, stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,12 +11,12 @@ import (
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity"
|
||||
"go.maximumdirect.net/releasekit"
|
||||
"go.maximumdirect.net/releasekit/buildinfo"
|
||||
"go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli"
|
||||
"go.maximumdirect.net/releasekit/internal/artifact"
|
||||
"go.maximumdirect.net/releasekit/internal/git"
|
||||
"go.maximumdirect.net/releasekit/internal/releaseidentity"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -181,8 +181,9 @@ func validationSpec(settings *cli.ValidationSettings) releasekit.ValidationSpec
|
||||
return releasekit.ValidationSpec{}
|
||||
}
|
||||
result := releasekit.ValidationSpec{
|
||||
SharedFiles: copyStringsPreservingEmpty(settings.SharedFiles),
|
||||
GoFormat: settings.GoFormat,
|
||||
SharedFiles: copyStringsPreservingEmpty(settings.SharedFiles),
|
||||
ForbiddenFiles: copyStringsPreservingEmpty(settings.ForbiddenFiles),
|
||||
GoFormat: settings.GoFormat,
|
||||
ModuleDistribution: releasekit.ModuleDistributionValidationSpec{
|
||||
WorkspaceFiles: settings.ModuleDistribution.WorkspaceFiles,
|
||||
LocalReplacements: settings.ModuleDistribution.LocalReplacements,
|
||||
@@ -290,11 +291,24 @@ func runCIDelivery(ctx context.Context, invocation cli.Invocation, stdout, stder
|
||||
fmt.Fprintf(stderr, "profile module selection: %v\n", err)
|
||||
return 2
|
||||
}
|
||||
destination, status := deliveryDestination(settings, stderr)
|
||||
identity, err := releaseidentity.FromCommitted(ctx, invocation.Repository, settings.ModuleDir, invocation.Version, invocation.Commit)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "resolve CI release identity: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
if invocation.ExpectedTag != identity.Tag {
|
||||
fmt.Fprintf(stderr, "expected delivery tag %q does not match selected module tag %q\n", invocation.ExpectedTag, identity.Tag)
|
||||
return 1
|
||||
}
|
||||
destination, status := deliveryDestination(invocation.Repository, settings, stderr)
|
||||
if status != 0 {
|
||||
return status
|
||||
}
|
||||
if settings.Build != nil && len(settings.Build.Targets) != 0 {
|
||||
if err := releasekit.PreflightDelivery(ctx, destination); err != nil {
|
||||
fmt.Fprintf(stderr, "delivery credential preflight: %v\n", err)
|
||||
return deliveryPreflightExitStatus(err)
|
||||
}
|
||||
if err := releasekit.PrepareBuildDependencies(ctx, releasekit.DependencyPreparationRequest{
|
||||
RepositoryDir: invocation.Repository,
|
||||
ModuleDir: settings.ModuleDir,
|
||||
@@ -309,32 +323,61 @@ func runCIDelivery(ctx context.Context, invocation cli.Invocation, stdout, stder
|
||||
// deliverWithSettings adapts prepared CLI state to the public delivery facade.
|
||||
// CI entry points can reuse it after preparing the checkout and loading its profile.
|
||||
func deliverWithSettings(ctx context.Context, invocation cli.Invocation, settings cli.Settings, stdout, stderr io.Writer) int {
|
||||
destination, status := deliveryDestination(settings, stderr)
|
||||
destination, status := deliveryDestination(invocation.Repository, settings, stderr)
|
||||
if status != 0 {
|
||||
return status
|
||||
}
|
||||
return deliverWithDestination(ctx, invocation, settings, destination, stdout, stderr)
|
||||
}
|
||||
|
||||
func deliveryDestination(settings cli.Settings, stderr io.Writer) (releasekit.GiteaDestination, int) {
|
||||
func deliveryDestination(repositoryDir string, settings cli.Settings, stderr io.Writer) (releasekit.DeliveryDestination, int) {
|
||||
delivery := settings.Delivery
|
||||
if delivery == nil || strings.TrimSpace(delivery.BaseURL) == "" || strings.TrimSpace(delivery.Owner) == "" || strings.TrimSpace(delivery.Repository) == "" {
|
||||
if delivery == nil {
|
||||
fmt.Fprintln(stderr, "deliver requires an explicit delivery.forge selection in the profile")
|
||||
return releasekit.DeliveryDestination{}, 2
|
||||
}
|
||||
switch strings.TrimSpace(delivery.Forge) {
|
||||
case "":
|
||||
fmt.Fprintln(stderr, "deliver requires delivery.forge in the profile")
|
||||
return releasekit.DeliveryDestination{}, 2
|
||||
case string(releasekit.ForgeGitea):
|
||||
default:
|
||||
fmt.Fprintf(stderr, "unsupported delivery forge %q\n", delivery.Forge)
|
||||
return releasekit.DeliveryDestination{}, 2
|
||||
}
|
||||
if strings.TrimSpace(delivery.BaseURL) == "" || strings.TrimSpace(delivery.Owner) == "" || strings.TrimSpace(delivery.Repository) == "" {
|
||||
fmt.Fprintln(stderr, "deliver requires base_url, owner, and repository in the profile delivery settings")
|
||||
return releasekit.GiteaDestination{}, 2
|
||||
return releasekit.DeliveryDestination{}, 2
|
||||
}
|
||||
tokenEnv := delivery.TokenEnv
|
||||
if strings.TrimSpace(tokenEnv) == "" {
|
||||
tokenEnv = "RELEASEKIT_GITEA_TOKEN"
|
||||
}
|
||||
token, ok := os.LookupEnv(tokenEnv)
|
||||
if !ok || strings.TrimSpace(token) == "" {
|
||||
fmt.Fprintf(stderr, "deliver requires a non-empty token in environment variable %s\n", tokenEnv)
|
||||
return releasekit.GiteaDestination{}, 2
|
||||
if (!ok || strings.TrimSpace(token) == "") && delivery.TokenFile != "" {
|
||||
name := delivery.TokenFile
|
||||
if !filepath.IsAbs(name) {
|
||||
name = filepath.Join(repositoryDir, name)
|
||||
}
|
||||
contents, err := os.ReadFile(name)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "read delivery token file: %v\n", err)
|
||||
return releasekit.DeliveryDestination{}, 2
|
||||
}
|
||||
token = strings.TrimSpace(string(contents))
|
||||
if token == "" {
|
||||
fmt.Fprintln(stderr, "delivery token file is empty or blank")
|
||||
return releasekit.DeliveryDestination{}, 2
|
||||
}
|
||||
}
|
||||
return releasekit.GiteaDestination{BaseURL: delivery.BaseURL, Owner: delivery.Owner, Repository: delivery.Repository, Token: token}, 0
|
||||
if strings.TrimSpace(token) == "" {
|
||||
fmt.Fprintf(stderr, "deliver requires a non-empty token in environment variable %s\n", tokenEnv)
|
||||
return releasekit.DeliveryDestination{}, 2
|
||||
}
|
||||
return releasekit.DeliveryDestination{Forge: releasekit.ForgeGitea, BaseURL: delivery.BaseURL, Owner: delivery.Owner, Repository: delivery.Repository, Token: token}, 0
|
||||
}
|
||||
|
||||
func deliverWithDestination(ctx context.Context, invocation cli.Invocation, settings cli.Settings, destination releasekit.GiteaDestination, stdout, stderr io.Writer) int {
|
||||
func deliverWithDestination(ctx context.Context, invocation cli.Invocation, settings cli.Settings, destination releasekit.DeliveryDestination, stdout, stderr io.Writer) int {
|
||||
hasTargets := settings.Build != nil && len(settings.Build.Targets) != 0
|
||||
if invocation.AssetsDir != "" && !hasTargets {
|
||||
fmt.Fprintln(stderr, "--assets requires build targets in the profile")
|
||||
@@ -389,6 +432,10 @@ func deliverWithDestination(ctx context.Context, invocation cli.Invocation, sett
|
||||
if errors.Is(err, releasekit.ErrInvalidVersion) {
|
||||
return 2
|
||||
}
|
||||
var preflightErr *releasekit.DeliveryPreflightError
|
||||
if errors.As(err, &preflightErr) {
|
||||
return deliveryPreflightExitStatus(preflightErr)
|
||||
}
|
||||
if result.State == releasekit.DeliveryUnknown {
|
||||
return 3
|
||||
}
|
||||
@@ -402,6 +449,23 @@ func deliverWithDestination(ctx context.Context, invocation cli.Invocation, sett
|
||||
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) {
|
||||
for _, group := range []struct {
|
||||
label string
|
||||
|
||||
@@ -15,11 +15,12 @@ import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
releasekit "gitea.maximumdirect.net/eric/ReleaseKit"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli"
|
||||
releasekit "go.maximumdirect.net/releasekit"
|
||||
"go.maximumdirect.net/releasekit/buildinfo"
|
||||
"go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli"
|
||||
)
|
||||
|
||||
func TestVersionCommand(t *testing.T) {
|
||||
@@ -471,7 +472,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo"
|
||||
"go.maximumdirect.net/releasekit/buildinfo"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -638,10 +639,10 @@ func TestDeliverPageOnlyCreatesAndReusesRelease(t *testing.T) {
|
||||
settings := cli.Settings{
|
||||
Product: "Example Product",
|
||||
Validation: &cli.ValidationSettings{Go: cli.GoValidationSettings{CompileTargets: []string{"linux/amd64"}}},
|
||||
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||
Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||
}
|
||||
writeDeliveryProfile(t, repository, settings)
|
||||
state.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") // Historical lightweight tags are valid delivery inputs.
|
||||
t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token")
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
@@ -654,11 +655,11 @@ func TestDeliverPageOnlyCreatesAndReusesRelease(t *testing.T) {
|
||||
t.Fatalf("delivery output = %q", stdout.String())
|
||||
}
|
||||
}
|
||||
if state.releaseCreates != 1 || state.assetUploads != 0 || state.assetListReads != 0 {
|
||||
t.Fatalf("page-only calls = creates:%d asset uploads:%d asset reads:%d", state.releaseCreates, state.assetUploads, state.assetListReads)
|
||||
if state.snapshot().releaseCreates != 1 || state.snapshot().assetUploads != 0 || state.snapshot().assetListReads != 0 {
|
||||
t.Fatalf("page-only calls = creates:%d asset uploads:%d asset reads:%d", state.snapshot().releaseCreates, state.snapshot().assetUploads, state.snapshot().assetListReads)
|
||||
}
|
||||
if state.noteBody != "# Example Product v1.2.3\n\nRelease note.\n" {
|
||||
t.Fatalf("hosted note body = %q", state.noteBody)
|
||||
if state.snapshot().noteBody != "# Example Product v1.2.3\n\nRelease note.\n" {
|
||||
t.Fatalf("hosted note body = %q", state.snapshot().noteBody)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -672,10 +673,10 @@ func TestDeliverBuildsTaggedArtifactsAndReusesAssets(t *testing.T) {
|
||||
settings := cli.Settings{
|
||||
Product: "Example Product",
|
||||
Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}},
|
||||
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||
Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||
}
|
||||
writeDeliveryProfile(t, repository, settings)
|
||||
state.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, "checkout", "--detach", "v1.2.3")
|
||||
t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token")
|
||||
@@ -689,14 +690,15 @@ func TestDeliverBuildsTaggedArtifactsAndReusesAssets(t *testing.T) {
|
||||
t.Fatalf("second delivery did not report matching assets: %q", stdout.String())
|
||||
}
|
||||
}
|
||||
if state.releaseCreates != 1 || state.assetUploads != 2 {
|
||||
t.Fatalf("delivery calls = creates:%d asset uploads:%d", state.releaseCreates, state.assetUploads)
|
||||
observed := state.snapshot()
|
||||
if observed.releaseCreates != 1 || observed.assetUploads != 2 {
|
||||
t.Fatalf("delivery calls = creates:%d asset uploads:%d", observed.releaseCreates, observed.assetUploads)
|
||||
}
|
||||
if _, ok := state.assets["helper-v1.2.3-linux-amd64"]; !ok {
|
||||
t.Fatalf("binary was not uploaded: %#v", state.assets)
|
||||
if _, ok := observed.assets["helper-v1.2.3-linux-amd64"]; !ok {
|
||||
t.Fatalf("binary was not uploaded: %#v", observed.assets)
|
||||
}
|
||||
if _, ok := state.assets["SHA256SUMS"]; !ok {
|
||||
t.Fatalf("checksum manifest was not uploaded: %#v", state.assets)
|
||||
if _, ok := observed.assets["SHA256SUMS"]; !ok {
|
||||
t.Fatalf("checksum manifest was not uploaded: %#v", observed.assets)
|
||||
}
|
||||
if entries, err := os.ReadDir(temporaryRoot); err != nil || len(entries) != 0 {
|
||||
t.Fatalf("facade-owned build output remains after delivery: entries=%v error=%v", entries, err)
|
||||
@@ -714,10 +716,10 @@ func TestCIDeliverLoadsTaggedProfileAndSkipsUnavailablePageOnlyDependencies(t *t
|
||||
defer server.Close()
|
||||
writeDeliveryProfileAt(t, repository, "profiles/ci-delivery.json", cli.Settings{
|
||||
Product: "Example Product",
|
||||
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||
Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||
})
|
||||
expectedCommit := pushTaggedCLIRepository(t, repository)
|
||||
state.commit = expectedCommit
|
||||
state.update(func(state *deliveryServerData) { state.commit = expectedCommit })
|
||||
writeDeliveryProfileAt(t, repository, "profiles/ci-delivery.json", cli.Settings{Product: "Later Branch Profile"})
|
||||
cliGitRun(t, repository, "checkout", "--detach", expectedCommit)
|
||||
cliGitRun(t, repository, "remote", "remove", "origin")
|
||||
@@ -730,8 +732,8 @@ func TestCIDeliverLoadsTaggedProfileAndSkipsUnavailablePageOnlyDependencies(t *t
|
||||
if status := run(context.Background(), args, &stdout, &stderr); status != 0 {
|
||||
t.Fatalf("run() status=%d stdout=%q stderr=%q", status, stdout.String(), stderr.String())
|
||||
}
|
||||
if state.releaseCreates != 1 || state.apiRequests == 0 {
|
||||
t.Fatalf("page delivery did not reach the server: creates=%d requests=%d", state.releaseCreates, state.apiRequests)
|
||||
if state.snapshot().releaseCreates != 1 || state.snapshot().apiRequests == 0 {
|
||||
t.Fatalf("page delivery did not reach the server: creates=%d requests=%d", state.snapshot().releaseCreates, state.snapshot().apiRequests)
|
||||
}
|
||||
if got := cliGitOutput(t, repository, "rev-parse", "HEAD"); got != expectedCommit {
|
||||
t.Fatalf("prepared HEAD = %s, want %s", got, expectedCommit)
|
||||
@@ -749,10 +751,10 @@ func TestCIDeliverRejectsWrongCommitBeforeGoOrHostedRequests(t *testing.T) {
|
||||
writeDeliveryProfile(t, repository, cli.Settings{
|
||||
Product: "Example Product",
|
||||
Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}},
|
||||
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||
Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||
})
|
||||
expectedCommit := pushTaggedCLIRepository(t, repository)
|
||||
state.commit = expectedCommit
|
||||
state.update(func(state *deliveryServerData) { state.commit = expectedCommit })
|
||||
|
||||
shimDir := t.TempDir()
|
||||
markerPath := filepath.Join(shimDir, "go-invoked")
|
||||
@@ -775,8 +777,8 @@ func TestCIDeliverRejectsWrongCommitBeforeGoOrHostedRequests(t *testing.T) {
|
||||
if _, err := os.Stat(markerPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("Go was invoked before commit agreement: %v", err)
|
||||
}
|
||||
if state.apiRequests != 0 {
|
||||
t.Fatalf("hosted API requests = %d, want 0", state.apiRequests)
|
||||
if state.snapshot().apiRequests != 0 {
|
||||
t.Fatalf("hosted API requests = %d, want 0", state.snapshot().apiRequests)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -800,10 +802,10 @@ func TestCIDeliverRejectsExpectedModuleMismatchBeforeDependencyPreparation(t *te
|
||||
Product: "Example Product",
|
||||
ModuleDir: "integrations/other",
|
||||
Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}},
|
||||
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||
Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||
})
|
||||
expectedCommit := pushTaggedCLIRepository(t, repository)
|
||||
state.commit = expectedCommit
|
||||
state.update(func(state *deliveryServerData) { state.commit = expectedCommit })
|
||||
shimDir := t.TempDir()
|
||||
markerPath := filepath.Join(shimDir, "go-invoked")
|
||||
goShim := "#!/bin/sh\n: > \"$RELEASEKIT_GO_MARKER\"\nexit 99\n"
|
||||
@@ -824,8 +826,8 @@ func TestCIDeliverRejectsExpectedModuleMismatchBeforeDependencyPreparation(t *te
|
||||
if _, err := os.Stat(markerPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("Go was invoked before profile agreement: %v", err)
|
||||
}
|
||||
if state.apiRequests != 0 {
|
||||
t.Fatalf("hosted API requests = %d, want 0", state.apiRequests)
|
||||
if state.snapshot().apiRequests != 0 {
|
||||
t.Fatalf("hosted API requests = %d, want 0", state.snapshot().apiRequests)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -837,21 +839,21 @@ func TestCIDeliverPreparesDependenciesAndBuildsConfiguredTargets(t *testing.T) {
|
||||
writeDeliveryProfile(t, repository, cli.Settings{
|
||||
Product: "Example Product",
|
||||
Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH)}},
|
||||
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||
Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||
})
|
||||
expectedCommit := pushTaggedCLIRepository(t, repository)
|
||||
state.commit = expectedCommit
|
||||
state.update(func(state *deliveryServerData) { state.commit = expectedCommit })
|
||||
t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token")
|
||||
var stdout, stderr bytes.Buffer
|
||||
args := []string{"ci-deliver", "--repo", repository, "--commit", expectedCommit, "v1.2.3"}
|
||||
if status := run(context.Background(), args, &stdout, &stderr); status != 0 {
|
||||
t.Fatalf("run() status=%d stdout=%q stderr=%q", status, stdout.String(), stderr.String())
|
||||
}
|
||||
if state.releaseCreates != 1 || state.assetUploads != 2 {
|
||||
t.Fatalf("delivery counts = creates:%d uploads:%d", state.releaseCreates, state.assetUploads)
|
||||
if state.snapshot().releaseCreates != 1 || state.snapshot().assetUploads != 2 {
|
||||
t.Fatalf("delivery counts = creates:%d uploads:%d", state.snapshot().releaseCreates, state.snapshot().assetUploads)
|
||||
}
|
||||
if _, ok := state.assets["helper-v1.2.3-"+runtime.GOOS+"-"+runtime.GOARCH]; !ok {
|
||||
t.Fatalf("binary not uploaded: %#v", state.assets)
|
||||
if _, ok := state.snapshot().assets["helper-v1.2.3-"+runtime.GOOS+"-"+runtime.GOARCH]; !ok {
|
||||
t.Fatalf("binary not uploaded: %#v", state.snapshot().assets)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -863,10 +865,10 @@ func TestCIDeliverValidatesCredentialsBeforePreparingDependencies(t *testing.T)
|
||||
writeDeliveryProfile(t, repository, cli.Settings{
|
||||
Product: "Example Product",
|
||||
Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}},
|
||||
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||
Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||
})
|
||||
expectedCommit := pushTaggedCLIRepository(t, repository)
|
||||
state.commit = expectedCommit
|
||||
state.update(func(state *deliveryServerData) { state.commit = expectedCommit })
|
||||
|
||||
shimDir := t.TempDir()
|
||||
markerPath := filepath.Join(shimDir, "go-invoked")
|
||||
@@ -888,8 +890,8 @@ func TestCIDeliverValidatesCredentialsBeforePreparingDependencies(t *testing.T)
|
||||
if _, err := os.Stat(markerPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("Go was invoked before credential validation: %v", err)
|
||||
}
|
||||
if state.apiRequests != 0 {
|
||||
t.Fatalf("hosted API requests = %d, want 0", state.apiRequests)
|
||||
if state.snapshot().apiRequests != 0 {
|
||||
t.Fatalf("hosted API requests = %d, want 0", state.snapshot().apiRequests)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -901,10 +903,10 @@ func TestDeliverRejectsInvalidTaggedSourceBeforeManagedBuild(t *testing.T) {
|
||||
settings := cli.Settings{
|
||||
Product: "Example Product",
|
||||
Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}},
|
||||
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||
Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||
}
|
||||
writeDeliveryProfile(t, repository, settings)
|
||||
state.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")
|
||||
if err := os.WriteFile(filepath.Join(repository, "uncommitted.txt"), []byte("dirty"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -912,8 +914,8 @@ func TestDeliverRejectsInvalidTaggedSourceBeforeManagedBuild(t *testing.T) {
|
||||
t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token")
|
||||
var stdout, stderr bytes.Buffer
|
||||
status := run(context.Background(), []string{"deliver", "--repo", repository, "v1.2.3"}, &stdout, &stderr)
|
||||
if status != 1 || state.apiRequests != 0 || strings.Contains(stderr.String(), "building ") {
|
||||
t.Fatalf("invalid-source delivery status=%d requests=%d stdout=%q stderr=%q", status, state.apiRequests, stdout.String(), stderr.String())
|
||||
if status != 1 || state.snapshot().apiRequests != 0 || strings.Contains(stderr.String(), "building ") {
|
||||
t.Fatalf("invalid-source delivery status=%d requests=%d stdout=%q stderr=%q", status, state.snapshot().apiRequests, stdout.String(), stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -956,18 +958,20 @@ func TestDeliverFailureReportsRecoveryForPartialConflictAndUnknownStates(t *test
|
||||
writeDeliveryProfile(t, repository, cli.Settings{
|
||||
Product: "Example Product",
|
||||
Build: &build,
|
||||
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||
Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||
})
|
||||
state.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")
|
||||
assetsDir, _ := writeDeliveryReceipt(t, repository, build)
|
||||
switch test.name {
|
||||
case "partial":
|
||||
state.failAssetName = "helper-v1.2.3-linux-amd64"
|
||||
state.update(func(state *deliveryServerData) { state.failAssetName = "helper-v1.2.3-linux-amd64" })
|
||||
case "conflict":
|
||||
state.assets["helper-v1.2.3-linux-amd64"] = []byte("different hosted bytes")
|
||||
state.update(func(state *deliveryServerData) {
|
||||
state.assets["helper-v1.2.3-linux-amd64"] = []byte("different hosted bytes")
|
||||
})
|
||||
case "unknown":
|
||||
state.ambiguousAssetName = "helper-v1.2.3-linux-amd64"
|
||||
state.update(func(state *deliveryServerData) { state.ambiguousAssetName = "helper-v1.2.3-linux-amd64" })
|
||||
}
|
||||
t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token")
|
||||
var stdout, stderr bytes.Buffer
|
||||
@@ -995,17 +999,17 @@ func TestDeliverFailureReportsRecoveryForPartialConflictAndUnknownStates(t *test
|
||||
|
||||
func TestDeliverRejectsWrongHostedTagBeforeReleaseWrite(t *testing.T) {
|
||||
repository := newCLIRepository(t)
|
||||
state := &deliveryServerState{remoteCommit: strings.Repeat("0", 40)}
|
||||
state := &deliveryServerState{data: deliveryServerData{remoteCommit: strings.Repeat("0", 40)}}
|
||||
server := newDeliveryServer(t, state)
|
||||
defer server.Close()
|
||||
writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}})
|
||||
state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD")
|
||||
writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"}})
|
||||
state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
|
||||
cliGitRun(t, repository, "tag", "v1.2.3")
|
||||
t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token")
|
||||
var stdout, stderr bytes.Buffer
|
||||
status := run(context.Background(), []string{"deliver", "--repo", repository, "v1.2.3"}, &stdout, &stderr)
|
||||
if status != 1 || state.releaseCreates != 0 || state.assetUploads != 0 {
|
||||
t.Fatalf("wrong hosted tag status=%d creates=%d uploads=%d stdout=%q stderr=%q", status, state.releaseCreates, state.assetUploads, stdout.String(), stderr.String())
|
||||
if status != 1 || state.snapshot().releaseCreates != 0 || state.snapshot().assetUploads != 0 {
|
||||
t.Fatalf("wrong hosted tag status=%d creates=%d uploads=%d stdout=%q stderr=%q", status, state.snapshot().releaseCreates, state.snapshot().assetUploads, stdout.String(), stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1014,14 +1018,235 @@ func TestDeliverRequiresCredentialBeforeRemoteRequests(t *testing.T) {
|
||||
state := &deliveryServerState{}
|
||||
server := newDeliveryServer(t, state)
|
||||
defer server.Close()
|
||||
writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example", TokenEnv: "RELEASEKIT_TEST_DELIVERY_TOKEN"}})
|
||||
state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD")
|
||||
writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example", TokenEnv: "RELEASEKIT_TEST_DELIVERY_TOKEN"}})
|
||||
state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
|
||||
cliGitRun(t, repository, "tag", "v1.2.3")
|
||||
t.Setenv("RELEASEKIT_TEST_DELIVERY_TOKEN", "")
|
||||
var stdout, stderr bytes.Buffer
|
||||
status := run(context.Background(), []string{"deliver", "--repo", repository, "v1.2.3"}, &stdout, &stderr)
|
||||
if status != 2 || state.apiRequests != 0 || !strings.Contains(stderr.String(), "RELEASEKIT_TEST_DELIVERY_TOKEN") {
|
||||
t.Fatalf("missing credential status=%d requests=%d stdout=%q stderr=%q", status, state.apiRequests, stdout.String(), stderr.String())
|
||||
if status != 2 || state.snapshot().apiRequests != 0 || !strings.Contains(stderr.String(), "RELEASEKIT_TEST_DELIVERY_TOKEN") {
|
||||
t.Fatalf("missing credential status=%d requests=%d stdout=%q stderr=%q", status, state.snapshot().apiRequests, stdout.String(), stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1030,8 +1255,8 @@ func TestDeliverRejectsCheckoutThatDiffersFromLocalTag(t *testing.T) {
|
||||
state := &deliveryServerState{}
|
||||
server := newDeliveryServer(t, state)
|
||||
defer server.Close()
|
||||
writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}})
|
||||
state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD")
|
||||
writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"}})
|
||||
state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
|
||||
cliGitRun(t, repository, "tag", "v1.2.3")
|
||||
if err := os.WriteFile(filepath.Join(repository, "later.txt"), []byte("later commit"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -1041,11 +1266,11 @@ func TestDeliverRejectsCheckoutThatDiffersFromLocalTag(t *testing.T) {
|
||||
t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token")
|
||||
var stdout, stderr bytes.Buffer
|
||||
status := run(context.Background(), []string{"deliver", "--repo", repository, "v1.2.3"}, &stdout, &stderr)
|
||||
if status != 1 || state.apiRequests != 0 || state.releaseCreates != 0 {
|
||||
t.Fatalf("mismatched checkout status=%d requests=%d creates=%d stdout=%q stderr=%q", status, state.apiRequests, state.releaseCreates, stdout.String(), stderr.String())
|
||||
if status != 1 || state.snapshot().apiRequests != 0 || state.snapshot().releaseCreates != 0 {
|
||||
t.Fatalf("mismatched checkout status=%d requests=%d creates=%d stdout=%q stderr=%q", status, state.snapshot().apiRequests, state.snapshot().releaseCreates, stdout.String(), stderr.String())
|
||||
}
|
||||
if got := cliGitOutput(t, repository, "rev-parse", "refs/tags/v1.2.3"); got != state.commit {
|
||||
t.Fatalf("delivery moved tag from %s to %s", state.commit, got)
|
||||
if got := cliGitOutput(t, repository, "rev-parse", "refs/tags/v1.2.3"); got != state.snapshot().commit {
|
||||
t.Fatalf("delivery moved tag from %s to %s", state.snapshot().commit, got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1057,9 +1282,9 @@ func TestDeliverRejectsTamperedAssetsAndStaleReceiptBeforeRemoteWrites(t *testin
|
||||
server := newDeliveryServer(t, state)
|
||||
defer server.Close()
|
||||
current := cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}}
|
||||
settings := cli.Settings{Product: "Example Product", Build: ¤t, Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}}
|
||||
settings := cli.Settings{Product: "Example Product", Build: ¤t, Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"}}
|
||||
writeDeliveryProfile(t, repository, settings)
|
||||
state.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")
|
||||
receiptSettings := current
|
||||
if mode == "stale settings" {
|
||||
@@ -1085,16 +1310,16 @@ func TestDeliverRejectsTamperedAssetsAndStaleReceiptBeforeRemoteWrites(t *testin
|
||||
var stdout, stderr bytes.Buffer
|
||||
status := run(context.Background(), []string{"deliver", "--repo", repository, "--assets", assetsDir, "v1.2.3"}, &stdout, &stderr)
|
||||
if mode == "valid receipt" {
|
||||
if status != 0 || state.releaseCreates != 1 || state.assetUploads != 2 {
|
||||
t.Fatalf("valid receipt status=%d creates=%d uploads=%d stdout=%q stderr=%q", status, state.releaseCreates, state.assetUploads, stdout.String(), stderr.String())
|
||||
if status != 0 || state.snapshot().releaseCreates != 1 || state.snapshot().assetUploads != 2 {
|
||||
t.Fatalf("valid receipt status=%d creates=%d uploads=%d stdout=%q stderr=%q", status, state.snapshot().releaseCreates, state.snapshot().assetUploads, stdout.String(), stderr.String())
|
||||
}
|
||||
if _, err := os.Stat(artifactPath); err != nil {
|
||||
t.Fatalf("prebuilt artifact was not retained: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if status != 1 || state.apiRequests != 0 || state.releaseCreates != 0 || state.assetUploads != 0 {
|
||||
t.Fatalf("%s receipt status=%d requests=%d stdout=%q stderr=%q", mode, status, state.apiRequests, stdout.String(), stderr.String())
|
||||
if status != 1 || state.snapshot().writes != 0 {
|
||||
t.Fatalf("%s receipt status=%d requests=%d stdout=%q stderr=%q", mode, status, state.snapshot().apiRequests, stdout.String(), stderr.String())
|
||||
}
|
||||
if mode == "tampered" || mode == "missing artifact" {
|
||||
if strings.Contains(stderr.String(), "load verified build receipt") {
|
||||
@@ -1136,6 +1361,30 @@ func writeDeliveryReceipt(t *testing.T, repository string, settings cli.BuildSet
|
||||
}
|
||||
|
||||
type deliveryServerState struct {
|
||||
mu sync.Mutex
|
||||
data deliveryServerData
|
||||
}
|
||||
|
||||
func (state *deliveryServerState) update(change func(*deliveryServerData)) {
|
||||
state.mu.Lock()
|
||||
defer state.mu.Unlock()
|
||||
change(&state.data)
|
||||
}
|
||||
|
||||
func (state *deliveryServerState) snapshot() deliveryServerData {
|
||||
state.mu.Lock()
|
||||
defer state.mu.Unlock()
|
||||
result := state.data
|
||||
|
||||
result.assets = make(map[string][]byte, len(state.data.assets))
|
||||
for key, value := range state.data.assets {
|
||||
result.assets[key] = append([]byte(nil), value...)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
type deliveryServerData struct {
|
||||
tag string
|
||||
malformedCreate bool
|
||||
failAssetListing bool
|
||||
@@ -1146,6 +1395,7 @@ type deliveryServerState struct {
|
||||
product string
|
||||
remoteCommit string
|
||||
apiRequests int
|
||||
writes int
|
||||
pageExists bool
|
||||
releaseCreates int
|
||||
assetUploads int
|
||||
@@ -1156,36 +1406,46 @@ type deliveryServerState struct {
|
||||
|
||||
func newDeliveryServer(t *testing.T, state *deliveryServerState) *httptest.Server {
|
||||
t.Helper()
|
||||
if state.tag == "" {
|
||||
state.tag = "v1.2.3"
|
||||
data := &state.data
|
||||
if data.tag == "" {
|
||||
data.tag = "v1.2.3"
|
||||
}
|
||||
if state.assets == nil {
|
||||
state.assets = make(map[string][]byte)
|
||||
if data.assets == nil {
|
||||
data.assets = make(map[string][]byte)
|
||||
}
|
||||
serverURL := ""
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
state.mu.Lock()
|
||||
defer state.mu.Unlock()
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
state.apiRequests++
|
||||
data.apiRequests++
|
||||
if r.Method != http.MethodGet {
|
||||
data.writes++
|
||||
}
|
||||
if r.Header.Get("Authorization") != "token fixture-token" {
|
||||
t.Errorf("authorization = %q", r.Header.Get("Authorization"))
|
||||
}
|
||||
}
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/repos/owner/example/tags/" + state.tag:
|
||||
if state.commit == "" {
|
||||
case "/api/v1/repos/owner/example", "/api/v1/repositories/1":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": 1, "name": "example", "full_name": "owner/example",
|
||||
"owner": map[string]string{"login": "owner"},
|
||||
})
|
||||
case "/api/v1/repos/owner/example/tags/" + data.tag:
|
||||
if data.commit == "" {
|
||||
t.Error("tag source commit was not initialized")
|
||||
}
|
||||
commit := state.remoteCommit
|
||||
commit := data.remoteCommit
|
||||
if commit == "" {
|
||||
commit = state.commit
|
||||
commit = data.commit
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, `{"name":%q,"commit":{"sha":%q}}`, state.tag, commit)
|
||||
case "/api/v1/repos/owner/example/releases/tags/" + state.tag:
|
||||
if !state.pageExists {
|
||||
_, _ = fmt.Fprintf(w, `{"name":%q,"commit":{"sha":%q}}`, data.tag, commit)
|
||||
case "/api/v1/repos/owner/example/releases/tags/" + data.tag:
|
||||
if !data.pageExists {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
writeDeliveryRelease(w, serverURL, state)
|
||||
writeDeliveryRelease(w, serverURL(r), data)
|
||||
case "/api/v1/repos/owner/example/releases":
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("release request method = %s", r.Method)
|
||||
@@ -1201,26 +1461,26 @@ func newDeliveryServer(t *testing.T, state *deliveryServerState) *httptest.Serve
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
t.Errorf("decode create payload: %v", err)
|
||||
}
|
||||
if payload.Tag != state.tag || payload.Target != state.commit {
|
||||
if payload.Tag != data.tag || payload.Target != data.commit {
|
||||
t.Errorf("release source payload = %#v", payload)
|
||||
}
|
||||
state.pageExists, state.releaseCreates, state.noteBody = true, state.releaseCreates+1, payload.Body
|
||||
if state.malformedCreate {
|
||||
data.pageExists, data.releaseCreates, data.noteBody = true, data.releaseCreates+1, payload.Body
|
||||
if data.malformedCreate {
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"id": 10, "tag_name": state.tag, "name": "inconsistent response", "body": payload.Body})
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"id": 10, "tag_name": data.tag, "name": "inconsistent response", "body": payload.Body})
|
||||
return
|
||||
}
|
||||
writeDeliveryRelease(w, serverURL, state)
|
||||
writeDeliveryRelease(w, serverURL(r), data)
|
||||
case "/api/v1/repos/owner/example/releases/10/assets":
|
||||
if r.Method == http.MethodGet {
|
||||
state.assetListReads++
|
||||
if state.failAssetListing {
|
||||
data.assetListReads++
|
||||
if data.failAssetListing {
|
||||
http.Error(w, "unavailable", 503)
|
||||
return
|
||||
}
|
||||
list := make([]map[string]any, 0, len(state.assets))
|
||||
for name, content := range state.assets {
|
||||
list = append(list, map[string]any{"id": len(list) + 1, "name": name, "size": len(content), "browser_download_url": serverURL + "/files/" + name})
|
||||
list := make([]map[string]any, 0, len(data.assets))
|
||||
for name, content := range data.assets {
|
||||
list = append(list, map[string]any{"id": len(list) + 1, "name": name, "size": len(content), "browser_download_url": serverURL(r) + "/files/" + name})
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(list)
|
||||
return
|
||||
@@ -1242,28 +1502,28 @@ func newDeliveryServer(t *testing.T, state *deliveryServerState) *httptest.Serve
|
||||
if err != nil {
|
||||
t.Errorf("read asset bytes: %v", err)
|
||||
}
|
||||
if _, exists := state.assets[name]; exists {
|
||||
if _, exists := data.assets[name]; exists {
|
||||
t.Errorf("asset %q uploaded more than once", name)
|
||||
}
|
||||
if name == state.failAssetName {
|
||||
if name == data.failAssetName {
|
||||
http.Error(w, "upload failed", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if name == state.ambiguousAssetName {
|
||||
if name == data.ambiguousAssetName {
|
||||
http.Error(w, "upload outcome ambiguous", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
state.assets[name] = content
|
||||
state.assetUploads++
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"id": state.assetUploads, "name": name, "size": len(content)})
|
||||
data.assets[name] = content
|
||||
data.assetUploads++
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"id": data.assetUploads, "name": name, "size": len(content)})
|
||||
default:
|
||||
if strings.HasPrefix(r.URL.Path, "/files/") {
|
||||
if state.failAssetDownloads {
|
||||
if data.failAssetDownloads {
|
||||
http.Error(w, "unavailable", 503)
|
||||
return
|
||||
}
|
||||
name := strings.TrimPrefix(r.URL.Path, "/files/")
|
||||
content, ok := state.assets[name]
|
||||
content, ok := data.assets[name]
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
@@ -1276,17 +1536,17 @@ func newDeliveryServer(t *testing.T, state *deliveryServerState) *httptest.Serve
|
||||
}
|
||||
})
|
||||
server := httptest.NewServer(handler)
|
||||
serverURL = server.URL
|
||||
return server
|
||||
}
|
||||
|
||||
func writeDeliveryRelease(w http.ResponseWriter, serverURL string, state *deliveryServerState) {
|
||||
// writeDeliveryRelease reads data while the request handler holds state.mu.
|
||||
func writeDeliveryRelease(w http.ResponseWriter, baseURL string, data *deliveryServerData) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
product := state.product
|
||||
product := data.product
|
||||
if product == "" {
|
||||
product = "Example Product"
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"id": 10, "html_url": serverURL + "/releases/10", "tag_name": state.tag, "target_commitish": state.commit, "name": product + " v1.2.3", "body": state.noteBody, "draft": false, "prerelease": false})
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"id": 10, "html_url": baseURL + "/releases/10", "tag_name": data.tag, "target_commitish": data.commit, "name": product + " v1.2.3", "body": data.noteBody, "draft": false, "prerelease": false})
|
||||
}
|
||||
|
||||
func writeDeliveryProfile(t *testing.T, repository string, settings cli.Settings) {
|
||||
@@ -1345,7 +1605,7 @@ func newCLIBuildRepository(t *testing.T, mainSource string) string {
|
||||
if err != nil {
|
||||
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) {
|
||||
t.Helper()
|
||||
path := filepath.Join(repository, filepath.FromSlash(name))
|
||||
@@ -1415,9 +1675,9 @@ func TestDeliverRejectsReceiptBuiltFromDirtySourceAfterCleanup(t *testing.T) {
|
||||
state := &deliveryServerState{}
|
||||
server := newDeliveryServer(t, state)
|
||||
defer server.Close()
|
||||
settings := cli.Settings{Product: "Example Product", Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}}, Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}}
|
||||
settings := cli.Settings{Product: "Example Product", Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}}, Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"}}
|
||||
writeDeliveryProfile(t, repository, settings)
|
||||
state.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")
|
||||
mainPath := filepath.Join(repository, "main.go")
|
||||
original, err := os.ReadFile(mainPath)
|
||||
@@ -1444,26 +1704,26 @@ func TestDeliverRejectsReceiptBuiltFromDirtySourceAfterCleanup(t *testing.T) {
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
status := run(context.Background(), []string{"deliver", "--repo", repository, "--assets", assets, "v1.2.3"}, &stdout, &stderr)
|
||||
if status != 1 || state.apiRequests != 0 || !strings.Contains(stderr.String(), "build source") {
|
||||
t.Fatalf("status=%d requests=%d %s", status, state.apiRequests, stderr.String())
|
||||
if status != 1 || state.snapshot().apiRequests != 0 || !strings.Contains(stderr.String(), "build source") {
|
||||
t.Fatalf("status=%d requests=%d %s", status, state.snapshot().apiRequests, stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoveredPageCreatePreservesPartialDeliveryOnInspectionFailure(t *testing.T) {
|
||||
repository := newCLIRepository(t)
|
||||
state := &deliveryServerState{malformedCreate: true, failAssetListing: true}
|
||||
state := &deliveryServerState{data: deliveryServerData{malformedCreate: true, failAssetListing: true}}
|
||||
server := newDeliveryServer(t, state)
|
||||
defer server.Close()
|
||||
build := cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}}
|
||||
writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", Build: &build, Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}})
|
||||
state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD")
|
||||
writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", Build: &build, Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"}})
|
||||
state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
|
||||
cliGitRun(t, repository, "tag", "v1.2.3")
|
||||
assets, _ := writeDeliveryReceipt(t, repository, build)
|
||||
t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token")
|
||||
var stdout, stderr bytes.Buffer
|
||||
status := run(context.Background(), []string{"deliver", "--repo", repository, "--assets", assets, "v1.2.3"}, &stdout, &stderr)
|
||||
if status != 1 || !strings.Contains(stderr.String(), "state=partial") || state.releaseCreates != 1 || state.assetListReads != 1 || state.assetUploads != 0 {
|
||||
t.Fatalf("status=%d state=%#v %s", status, state, stderr.String())
|
||||
if status != 1 || !strings.Contains(stderr.String(), "state=partial") || state.snapshot().releaseCreates != 1 || state.snapshot().assetListReads != 1 || state.snapshot().assetUploads != 0 {
|
||||
t.Fatalf("status=%d state=%#v %s", status, state.snapshot(), stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1471,12 +1731,12 @@ func TestUnreadableAssetsDoNotClaimAnUnknownWrite(t *testing.T) {
|
||||
for _, existingPage := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("existing-page-%t", existingPage), func(t *testing.T) {
|
||||
repository := newCLIRepository(t)
|
||||
state := &deliveryServerState{pageExists: existingPage, noteBody: "# Example Product v1.2.3\n\nRelease note.\n", failAssetDownloads: true, assets: map[string][]byte{"helper-v1.2.3-linux-amd64": []byte("fixture artifact")}}
|
||||
state := &deliveryServerState{data: deliveryServerData{pageExists: existingPage, noteBody: "# Example Product v1.2.3\n\nRelease note.\n", failAssetDownloads: true, assets: map[string][]byte{"helper-v1.2.3-linux-amd64": []byte("fixture artifact")}}}
|
||||
server := newDeliveryServer(t, state)
|
||||
defer server.Close()
|
||||
build := cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}}
|
||||
writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", Build: &build, Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}})
|
||||
state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD")
|
||||
writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", Build: &build, Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"}})
|
||||
state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
|
||||
cliGitRun(t, repository, "tag", "v1.2.3")
|
||||
assets, _ := writeDeliveryReceipt(t, repository, build)
|
||||
t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token")
|
||||
@@ -1488,8 +1748,8 @@ func TestUnreadableAssetsDoNotClaimAnUnknownWrite(t *testing.T) {
|
||||
expectedState = "not_attempted"
|
||||
expectedCreates = 0
|
||||
}
|
||||
if status != 1 || !strings.Contains(stderr.String(), "state="+expectedState) || state.assetUploads != 0 || state.releaseCreates != expectedCreates {
|
||||
t.Fatalf("status=%d creates=%d uploads=%d %s", status, state.releaseCreates, state.assetUploads, stderr.String())
|
||||
if status != 1 || !strings.Contains(stderr.String(), "state="+expectedState) || state.snapshot().assetUploads != 0 || state.snapshot().releaseCreates != expectedCreates {
|
||||
t.Fatalf("status=%d creates=%d uploads=%d %s", status, state.snapshot().releaseCreates, state.snapshot().assetUploads, stderr.String())
|
||||
}
|
||||
if existingPage && !strings.Contains(stderr.String(), "recovery: fix the local or preflight failure") {
|
||||
t.Fatalf("not-attempted recovery guidance missing: %s", stderr.String())
|
||||
@@ -1500,7 +1760,7 @@ func TestUnreadableAssetsDoNotClaimAnUnknownWrite(t *testing.T) {
|
||||
|
||||
func TestFailedRebuildInvalidatesPreviousReceipt(t *testing.T) {
|
||||
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())}
|
||||
`
|
||||
repository := newCLIBuildRepository(t, source)
|
||||
@@ -1531,8 +1791,8 @@ func TestTemporaryDeliveryBuildDoesNotRequireWritableRepositoryParent(t *testing
|
||||
state := &deliveryServerState{}
|
||||
server := newDeliveryServer(t, state)
|
||||
defer server.Close()
|
||||
writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}}, Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}})
|
||||
state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD")
|
||||
writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}}, Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"}})
|
||||
state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
|
||||
cliGitRun(t, repository, "tag", "v1.2.3")
|
||||
parent := t.TempDir()
|
||||
moved := filepath.Join(parent, "repo")
|
||||
|
||||
@@ -59,11 +59,11 @@ func TestMultiModuleLauncherUsesSelectedModuleAndProfile(t *testing.T) {
|
||||
repository := newCLIRepository(t)
|
||||
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"
|
||||
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"
|
||||
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 go.maximumdirect.net/releasekit v0.2.198\n"
|
||||
writeRepositoryFile(t, repository, "go.mod", rootModule)
|
||||
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, "profiles/core release.json", `{"product":"Example Consumer Integration","module_dir":"integrations/ck config"}`)
|
||||
cliGitRun(t, repository, "add", ".")
|
||||
@@ -138,8 +138,8 @@ func TestMultiModuleLauncherBuildsToolFromSelectedDependency(t *testing.T) {
|
||||
example := filepath.Join(cliRepositoryRoot(t), "examples", "multi-module")
|
||||
repository := newCLIRepository(t)
|
||||
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, "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, "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 go.maximumdirect.net/releasekit v0.2.198\n")
|
||||
cliGitRun(t, repository, "add", ".")
|
||||
cliGitRun(t, repository, "commit", "-m", "prepare selected tool module")
|
||||
|
||||
|
||||
@@ -12,9 +12,10 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli"
|
||||
"go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli"
|
||||
)
|
||||
|
||||
func TestMultiModuleWoodpeckerDelivery(t *testing.T) {
|
||||
@@ -43,18 +44,19 @@ func TestMultiModuleWoodpeckerDelivery(t *testing.T) {
|
||||
repository := newWorkflowRepository(t, example)
|
||||
writeMappedProfile(t, repository, "releasekit-ckconfig.json", cli.Settings{
|
||||
Product: "Example Consumer Integration", ModuleDir: "integrations/ckconfig",
|
||||
Delivery: workflowDeliverySettings(delivery.URL),
|
||||
Delivery: workflowDeliverySettings(delivery.snapshot().URL),
|
||||
})
|
||||
writeRepositoryFile(t, repository, "integrations/ckconfig/docs/releases/v1.2.3.md", "# Example Consumer Integration v1.2.3\n\nTagged integration note.\n")
|
||||
writeRepositoryFile(t, repository, "integrations/ckconfig/go.mod", workflowModuleFile("example.com/multi-module-consumer/integrations/ckconfig", "v0.2.197"))
|
||||
commit := tagAndPushWorkflowRepository(t, repository, tag)
|
||||
delivery.commit = commit
|
||||
delivery.update(func(delivery *mappedDeliveryData) { delivery.commit = commit })
|
||||
env := multiModuleWorkflowEnvironment(t, repository, commit, tag, toolBinary, proxy)
|
||||
env["CI_COMMIT_SHA"] = cliGitOutput(t, repository, "rev-parse", "refs/tags/"+tag)
|
||||
if output := runWoodpeckerBlock(t, command, repository, env); !strings.Contains(output, "state complete") || !strings.Contains(output, "tool-version=v0.2.197") {
|
||||
t.Fatalf("tagged page delivery output = %q", output)
|
||||
}
|
||||
if delivery.creates != 1 || delivery.requests == 0 || delivery.tag != tag {
|
||||
t.Fatalf("page delivery fixture = %#v", delivery)
|
||||
if delivery.snapshot().creates != 1 || delivery.snapshot().requests == 0 || delivery.snapshot().tag != tag {
|
||||
t.Fatalf("page delivery fixture = %#v", delivery.snapshot())
|
||||
}
|
||||
|
||||
cliGitRun(t, repository, "checkout", "main")
|
||||
@@ -68,8 +70,8 @@ func TestMultiModuleWoodpeckerDelivery(t *testing.T) {
|
||||
if !strings.Contains(output, "tool-version=v0.2.197") || strings.Contains(output, "tool-version=v0.2.198") {
|
||||
t.Fatalf("tag retry did not use its committed tool version: %q", output)
|
||||
}
|
||||
if delivery.creates != 1 || delivery.tag != tag {
|
||||
t.Fatalf("tag retry did not reuse the tagged mapping: %#v", delivery)
|
||||
if delivery.snapshot().creates != 1 || delivery.snapshot().tag != tag {
|
||||
t.Fatalf("tag retry did not reuse the tagged mapping: %#v", delivery.snapshot())
|
||||
}
|
||||
})
|
||||
|
||||
@@ -80,25 +82,26 @@ func TestMultiModuleWoodpeckerDelivery(t *testing.T) {
|
||||
writeMappedProfile(t, repository, "releasekit-core-v2.json", cli.Settings{
|
||||
Product: "Example Consumer Core v2", ModuleDir: "integrations/core/v2",
|
||||
Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "core", Targets: []string{"linux/amd64"}},
|
||||
Delivery: workflowDeliverySettings(delivery.URL),
|
||||
Delivery: workflowDeliverySettings(delivery.snapshot().URL),
|
||||
})
|
||||
writeRepositoryFile(t, repository, "integrations/core/v2/docs/releases/v2.1.0.md", "# Example Consumer Core v2 v2.1.0\n\nTagged core note.\n")
|
||||
prepareWorkflowModuleSum(t, repository, "integrations/core/v2", proxy)
|
||||
commit := tagAndPushWorkflowRepository(t, repository, tag)
|
||||
delivery.commit = commit
|
||||
delivery.update(func(delivery *mappedDeliveryData) { delivery.commit = commit })
|
||||
env := multiModuleWorkflowEnvironment(t, repository, commit, tag, toolBinary, proxy)
|
||||
output := runWoodpeckerBlock(t, command, repository, env)
|
||||
if !strings.Contains(output, "tool-version=v0.2.197") || !strings.Contains(output, "state complete") {
|
||||
t.Fatalf("binary delivery output = %q", output)
|
||||
}
|
||||
if delivery.creates != 1 || delivery.uploads != 2 || delivery.tag != tag {
|
||||
t.Fatalf("binary delivery fixture = %#v", delivery)
|
||||
observed := delivery.snapshot()
|
||||
if observed.creates != 1 || observed.uploads != 2 || observed.tag != tag {
|
||||
t.Fatalf("binary delivery fixture = %#v", observed)
|
||||
}
|
||||
if _, ok := delivery.assets["core-v2.1.0-linux-amd64"]; !ok {
|
||||
t.Fatalf("binary asset missing: %#v", delivery.assets)
|
||||
if _, ok := observed.assets["core-v2.1.0-linux-amd64"]; !ok {
|
||||
t.Fatalf("binary asset missing: %#v", observed.assets)
|
||||
}
|
||||
if _, ok := delivery.assets["SHA256SUMS"]; !ok {
|
||||
t.Fatalf("checksum asset missing: %#v", delivery.assets)
|
||||
if _, ok := observed.assets["SHA256SUMS"]; !ok {
|
||||
t.Fatalf("checksum asset missing: %#v", observed.assets)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -109,21 +112,21 @@ func TestMultiModuleWoodpeckerDelivery(t *testing.T) {
|
||||
writeRepositoryFile(t, repository, "release-modules.tsv", strings.ReplaceAll(readWorkflowFile(t, repository, "release-modules.tsv"), "integrations/core/v2\treleasekit-core-v2.json", "integrations/core\treleasekit-core-v2.json"))
|
||||
writeMappedProfile(t, repository, "releasekit-core-v2.json", cli.Settings{
|
||||
Product: "Example Consumer Core v2", ModuleDir: "integrations/core",
|
||||
Delivery: workflowDeliverySettings(delivery.URL),
|
||||
Delivery: workflowDeliverySettings(delivery.snapshot().URL),
|
||||
})
|
||||
writeRepositoryFile(t, repository, "integrations/core/go.mod", workflowModuleFile("example.com/multi-module-consumer/integrations/core/v2", "v0.2.197"))
|
||||
writeRepositoryFile(t, repository, "integrations/core/main.go", readWorkflowFile(t, example, "integrations/core/v2/main.go"))
|
||||
writeRepositoryFile(t, repository, "integrations/core/docs/releases/v2.1.0.md", "# Example Consumer Core v2 v2.1.0\n\nTagged core note.\n")
|
||||
prepareWorkflowModuleSum(t, repository, "integrations/core", proxy)
|
||||
commit := tagAndPushWorkflowRepository(t, repository, tag)
|
||||
delivery.commit = commit
|
||||
delivery.update(func(delivery *mappedDeliveryData) { delivery.commit = commit })
|
||||
env := multiModuleWorkflowEnvironment(t, repository, commit, tag, toolBinary, proxy)
|
||||
output := runWoodpeckerBlock(t, command, repository, env)
|
||||
if !strings.Contains(output, "tool-version=v0.2.197") || !strings.Contains(output, "state complete") {
|
||||
t.Fatalf("virtual-major delivery output = %q", output)
|
||||
}
|
||||
if delivery.creates != 1 || delivery.tag != tag {
|
||||
t.Fatalf("virtual-major delivery fixture = %#v", delivery)
|
||||
if delivery.snapshot().creates != 1 || delivery.snapshot().tag != tag {
|
||||
t.Fatalf("virtual-major delivery fixture = %#v", delivery.snapshot())
|
||||
}
|
||||
})
|
||||
|
||||
@@ -138,24 +141,48 @@ func TestMultiModuleWoodpeckerDelivery(t *testing.T) {
|
||||
writeRepositoryFile(t, repository, "release-modules.tsv", mapping)
|
||||
writeMappedProfile(t, repository, "releasekit-core-v2.json", cli.Settings{
|
||||
Product: "Example Consumer Core v2", ModuleDir: "integrations/core/v2",
|
||||
Delivery: workflowDeliverySettings(delivery.URL),
|
||||
Delivery: workflowDeliverySettings(delivery.snapshot().URL),
|
||||
})
|
||||
writeRepositoryFile(t, repository, "integrations/core/v2/docs/releases/v2.1.0.md", "# Example Consumer Core v2 v2.1.0\n\nMismatched mapped note.\n")
|
||||
commit := tagAndPushWorkflowRepository(t, repository, tag)
|
||||
delivery.commit = commit
|
||||
delivery.update(func(delivery *mappedDeliveryData) { delivery.commit = commit })
|
||||
env := multiModuleWorkflowEnvironment(t, repository, commit, tag, toolBinary, proxy)
|
||||
if output, err := executeWoodpeckerBlock(command, repository, env); err == nil {
|
||||
t.Fatalf("inconsistent mapping succeeded: %s", output)
|
||||
} else if !strings.Contains(output, tag) || !strings.Contains(output, "integrations/core/v2.1.0") {
|
||||
t.Fatalf("inconsistent mapping did not fail the exact tag comparison: %s", output)
|
||||
}
|
||||
if delivery.requests != 0 {
|
||||
t.Fatalf("exact event tag mismatch reached hosted delivery: %#v", delivery)
|
||||
if delivery.snapshot().requests != 0 {
|
||||
t.Fatalf("exact event tag mismatch reached hosted delivery: %#v", delivery.snapshot())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type mappedDeliveryFixture struct {
|
||||
mu sync.Mutex
|
||||
data mappedDeliveryData
|
||||
}
|
||||
|
||||
func (state *mappedDeliveryFixture) update(change func(*mappedDeliveryData)) {
|
||||
state.mu.Lock()
|
||||
defer state.mu.Unlock()
|
||||
change(&state.data)
|
||||
}
|
||||
|
||||
func (state *mappedDeliveryFixture) snapshot() mappedDeliveryData {
|
||||
state.mu.Lock()
|
||||
defer state.mu.Unlock()
|
||||
result := state.data
|
||||
|
||||
result.assets = make(map[string][]byte, len(state.data.assets))
|
||||
for key, value := range state.data.assets {
|
||||
result.assets[key] = append([]byte(nil), value...)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
type mappedDeliveryData struct {
|
||||
URL string
|
||||
tag string
|
||||
commit string
|
||||
@@ -170,23 +197,31 @@ type mappedDeliveryFixture struct {
|
||||
|
||||
func newMappedDeliveryFixture(t *testing.T, tag string) *mappedDeliveryFixture {
|
||||
t.Helper()
|
||||
state := &mappedDeliveryFixture{tag: tag, assets: make(map[string][]byte)}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
state.requests++
|
||||
state := &mappedDeliveryFixture{data: mappedDeliveryData{tag: tag, assets: make(map[string][]byte)}}
|
||||
data := &state.data
|
||||
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
state.mu.Lock()
|
||||
defer state.mu.Unlock()
|
||||
data.requests++
|
||||
if r.Header.Get("Authorization") != "token fixture-token" {
|
||||
t.Errorf("delivery authorization = %q", r.Header.Get("Authorization"))
|
||||
}
|
||||
apiRoot := "/api/v1/repos/owner/example/"
|
||||
escapedTag := url.PathEscape(tag)
|
||||
switch r.URL.EscapedPath() {
|
||||
case "/api/v1/repos/owner/example", "/api/v1/repositories/1":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": 1, "name": "example", "full_name": "owner/example",
|
||||
"owner": map[string]string{"login": "owner"},
|
||||
})
|
||||
case apiRoot + "tags/" + escapedTag:
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"name": tag, "commit": map[string]string{"sha": state.commit}})
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"name": tag, "commit": map[string]string{"sha": data.commit}})
|
||||
case apiRoot + "releases/tags/" + escapedTag:
|
||||
if !state.page {
|
||||
if !data.page {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"id": 10, "html_url": serverURL(r) + "/releases/10", "tag_name": tag, "target_commitish": state.commit, "name": state.name, "body": state.body, "draft": false, "prerelease": false})
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"id": 10, "html_url": serverURL(r) + "/releases/10", "tag_name": tag, "target_commitish": data.commit, "name": data.name, "body": data.body, "draft": false, "prerelease": false})
|
||||
case apiRoot + "releases":
|
||||
var payload struct {
|
||||
Tag string `json:"tag_name"`
|
||||
@@ -197,18 +232,18 @@ func newMappedDeliveryFixture(t *testing.T, tag string) *mappedDeliveryFixture {
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
t.Errorf("decode release create: %v", err)
|
||||
}
|
||||
if payload.Tag != tag || payload.Commit != state.commit {
|
||||
if payload.Tag != tag || payload.Commit != data.commit {
|
||||
t.Errorf("release payload = %#v", payload)
|
||||
}
|
||||
state.page = true
|
||||
state.name = payload.Name
|
||||
state.body = payload.Body
|
||||
state.creates++
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"id": 10, "html_url": serverURL(r) + "/releases/10", "tag_name": tag, "target_commitish": state.commit, "name": payload.Name, "body": payload.Body, "draft": false, "prerelease": false})
|
||||
data.page = true
|
||||
data.name = payload.Name
|
||||
data.body = payload.Body
|
||||
data.creates++
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"id": 10, "html_url": serverURL(r) + "/releases/10", "tag_name": tag, "target_commitish": data.commit, "name": payload.Name, "body": payload.Body, "draft": false, "prerelease": false})
|
||||
case apiRoot + "releases/10/assets":
|
||||
if r.Method == http.MethodGet {
|
||||
listing := make([]map[string]any, 0, len(state.assets))
|
||||
for name, content := range state.assets {
|
||||
listing := make([]map[string]any, 0, len(data.assets))
|
||||
for name, content := range data.assets {
|
||||
listing = append(listing, map[string]any{"id": len(listing) + 1, "name": name, "size": len(content), "browser_download_url": serverURL(r) + "/files/" + name})
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(listing)
|
||||
@@ -231,13 +266,13 @@ func newMappedDeliveryFixture(t *testing.T, tag string) *mappedDeliveryFixture {
|
||||
if err != nil {
|
||||
t.Errorf("read asset bytes: %v", err)
|
||||
}
|
||||
state.assets[name] = content
|
||||
state.uploads++
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"id": state.uploads, "name": name, "size": len(content)})
|
||||
data.assets[name] = content
|
||||
data.uploads++
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"id": data.uploads, "name": name, "size": len(content)})
|
||||
default:
|
||||
if strings.HasPrefix(r.URL.Path, "/files/") {
|
||||
name := strings.TrimPrefix(r.URL.Path, "/files/")
|
||||
if content, ok := state.assets[name]; ok {
|
||||
if content, ok := data.assets[name]; ok {
|
||||
_, _ = w.Write(content)
|
||||
return
|
||||
}
|
||||
@@ -248,8 +283,9 @@ func newMappedDeliveryFixture(t *testing.T, tag string) *mappedDeliveryFixture {
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
data.URL = "http://" + server.Listener.Addr().String()
|
||||
server.Start()
|
||||
t.Cleanup(server.Close)
|
||||
state.URL = server.URL
|
||||
return state
|
||||
}
|
||||
|
||||
@@ -271,7 +307,7 @@ func buildWorkflowCLI(t *testing.T, root string) string {
|
||||
|
||||
func writeWorkflowToolProxy(t *testing.T) string {
|
||||
t.Helper()
|
||||
const module = "gitea.maximumdirect.net/eric/ReleaseKit"
|
||||
const module = "go.maximumdirect.net/releasekit"
|
||||
const helper = "example.com/releasekit-helper"
|
||||
proxyDir := t.TempDir()
|
||||
moduleFile := "module " + module + "\n\ngo 1.25.0\n\nrequire " + helper + " v1.0.0\n"
|
||||
@@ -300,7 +336,7 @@ func main() {
|
||||
`, helper, version)
|
||||
files := map[string]string{
|
||||
"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",
|
||||
}
|
||||
writeModuleProxyVersion(t, proxyDir, module, version, moduleFile, files)
|
||||
@@ -331,11 +367,11 @@ func newWorkflowRepository(t *testing.T, example 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 {
|
||||
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) {
|
||||
@@ -352,7 +388,7 @@ func tagAndPushWorkflowRepository(t *testing.T, repository, tag string) string {
|
||||
cliGitRun(t, repository, "add", "--all")
|
||||
cliGitRun(t, repository, "commit", "-m", "prepare mapped delivery")
|
||||
commit := cliGitOutput(t, repository, "rev-parse", "HEAD")
|
||||
cliGitRun(t, repository, "tag", tag)
|
||||
cliGitRun(t, repository, "tag", "-a", tag, "-m", "mapped release")
|
||||
remote := filepath.Join(filepath.Dir(repository), "origin.git")
|
||||
cliGitRun(t, repository, "init", "--bare", remote)
|
||||
cliGitRun(t, repository, "remote", "add", "origin", remote)
|
||||
@@ -390,7 +426,7 @@ func prepareWorkflowModuleSum(t *testing.T, repository, moduleDir, proxy string)
|
||||
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.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 {
|
||||
|
||||
@@ -79,9 +79,10 @@ func TestReleaseCandidate(t *testing.T) {
|
||||
"product": "ReleaseKit",
|
||||
"checks": []map[string]any{{"args": []string{"sh", "-c", "if [ -n \"$RELEASEKIT_TEST_ARM_GIT_SHIM\" ]; then : > \"$RELEASEKIT_TEST_ARM_GIT_SHIM\"; fi"}}},
|
||||
"delivery": map[string]any{
|
||||
"base_url": "https://gitea.maximumdirect.net",
|
||||
"owner": "eric",
|
||||
"repository": "ReleaseKit",
|
||||
"forge": "gitea",
|
||||
"base_url": "https://forge.maximumdirect.net",
|
||||
"owner": "base-layer",
|
||||
"repository": "releasekit",
|
||||
"token_env": "RELEASEKIT_GITEA_TOKEN",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli"
|
||||
"go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli"
|
||||
)
|
||||
|
||||
func TestReleaseKitSourceDeliveryWorkflowFromTaggedCheckout(t *testing.T) {
|
||||
@@ -24,7 +24,7 @@ func TestReleaseKitSourceDeliveryWorkflowFromTaggedCheckout(t *testing.T) {
|
||||
if profile.Build != nil && len(profile.Build.Targets) != 0 {
|
||||
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)
|
||||
}
|
||||
repository := filepath.Join(t.TempDir(), "tagged ReleaseKit")
|
||||
@@ -37,9 +37,9 @@ func TestReleaseKitSourceDeliveryWorkflowFromTaggedCheckout(t *testing.T) {
|
||||
|
||||
const version = "v1.2.3"
|
||||
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" +
|
||||
"[LICENSE](https://gitea.maximumdirect.net/eric/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"
|
||||
"[COPYRIGHT](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v1.2.3/COPYRIGHT) |\n" +
|
||||
"[LICENSE](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v1.2.3/LICENSE) |\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")
|
||||
if err := os.MkdirAll(filepath.Dir(notePath), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -50,14 +50,14 @@ func TestReleaseKitSourceDeliveryWorkflowFromTaggedCheckout(t *testing.T) {
|
||||
cliGitRun(t, repository, "add", "docs/releases/"+version+".md")
|
||||
cliGitRun(t, repository, "commit", "-m", "prepare source-only delivery")
|
||||
|
||||
state := &deliveryServerState{product: "ReleaseKit"}
|
||||
state := &deliveryServerState{data: deliveryServerData{product: "ReleaseKit"}}
|
||||
server := newDeliveryServer(t, state)
|
||||
defer server.Close()
|
||||
writeDeliveryProfile(t, repository, cli.Settings{
|
||||
Product: "ReleaseKit",
|
||||
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||
Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||
})
|
||||
state.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")
|
||||
|
||||
remote := filepath.Join(t.TempDir(), "origin.git")
|
||||
@@ -78,7 +78,7 @@ func TestReleaseKitSourceDeliveryWorkflowFromTaggedCheckout(t *testing.T) {
|
||||
}
|
||||
env := map[string]string{
|
||||
"CI_WORKSPACE": repository,
|
||||
"CI_COMMIT_SHA": state.commit,
|
||||
"CI_COMMIT_SHA": cliGitOutput(t, repository, "rev-parse", "refs/tags/"+version),
|
||||
"CI_COMMIT_TAG": version,
|
||||
"RELEASEKIT_GITEA_TOKEN": "fixture-token",
|
||||
"GOTOOLCHAIN": "local",
|
||||
@@ -87,14 +87,14 @@ func TestReleaseKitSourceDeliveryWorkflowFromTaggedCheckout(t *testing.T) {
|
||||
}
|
||||
command := strings.ReplaceAll(block, "$${", "${")
|
||||
|
||||
state.remoteCommit = strings.Repeat("f", 40)
|
||||
state.update(func(state *deliveryServerData) { state.remoteCommit = strings.Repeat("f", 40) })
|
||||
if output, err := executeWoodpeckerBlock(command, repository, env); err == nil {
|
||||
t.Fatalf("delivery with a mismatched hosted tag succeeded: %s", output)
|
||||
}
|
||||
if state.releaseCreates != 0 || cliGitOutput(t, repository, "rev-parse", "refs/tags/"+version+"^{commit}") != state.commit {
|
||||
if state.snapshot().releaseCreates != 0 || cliGitOutput(t, repository, "rev-parse", "refs/tags/"+version+"^{commit}") != state.snapshot().commit {
|
||||
t.Fatal("delivery failure changed hosted release state or source tag")
|
||||
}
|
||||
state.remoteCommit = ""
|
||||
state.update(func(state *deliveryServerData) { state.remoteCommit = "" })
|
||||
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
output := runWoodpeckerBlock(t, command, repository, env)
|
||||
@@ -102,14 +102,14 @@ func TestReleaseKitSourceDeliveryWorkflowFromTaggedCheckout(t *testing.T) {
|
||||
t.Fatalf("delivery attempt %d output = %q", attempt+1, output)
|
||||
}
|
||||
}
|
||||
if state.releaseCreates != 1 || state.assetUploads != 0 || state.assetListReads != 0 {
|
||||
t.Fatalf("page-only workflow calls = creates:%d uploads:%d asset reads:%d", state.releaseCreates, state.assetUploads, state.assetListReads)
|
||||
if state.snapshot().releaseCreates != 1 || state.snapshot().assetUploads != 0 || state.snapshot().assetListReads != 0 {
|
||||
t.Fatalf("page-only workflow calls = creates:%d uploads:%d asset reads:%d", state.snapshot().releaseCreates, state.snapshot().assetUploads, state.snapshot().assetListReads)
|
||||
}
|
||||
if state.noteBody != note {
|
||||
t.Fatalf("hosted note body = %q, want complete note %q", state.noteBody, note)
|
||||
if state.snapshot().noteBody != note {
|
||||
t.Fatalf("hosted note body = %q, want complete note %q", state.snapshot().noteBody, note)
|
||||
}
|
||||
if got := cliGitOutput(t, repository, "rev-parse", "refs/tags/"+version+"^{commit}"); got != state.commit {
|
||||
t.Fatalf("source tag moved after delivery: %s, want %s", got, state.commit)
|
||||
if got := cliGitOutput(t, repository, "rev-parse", "refs/tags/"+version+"^{commit}"); got != state.snapshot().commit {
|
||||
t.Fatalf("source tag moved after delivery: %s, want %s", got, state.snapshot().commit)
|
||||
}
|
||||
if status := cliGitOutput(t, repository, "status", "--porcelain"); status != "" {
|
||||
t.Fatalf("tagged checkout became dirty after delivery: %s", status)
|
||||
|
||||
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"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli"
|
||||
"go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli"
|
||||
)
|
||||
|
||||
func TestWoodpeckerTagDeliveryTemplate(t *testing.T) {
|
||||
@@ -39,10 +39,10 @@ func TestWoodpeckerTagDeliveryTemplate(t *testing.T) {
|
||||
defer server.Close()
|
||||
writeDeliveryProfile(t, repository, cli.Settings{
|
||||
Product: "Example Product",
|
||||
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||
Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||
})
|
||||
commit := pushTaggedCLIRepository(t, repository)
|
||||
state.commit = commit
|
||||
state.update(func(state *deliveryServerData) { state.commit = commit })
|
||||
|
||||
goDir, goLog := installGoBootstrapShim(t)
|
||||
env := templateEnvironment(repository, commit, goDir, goLog)
|
||||
@@ -51,8 +51,8 @@ func TestWoodpeckerTagDeliveryTemplate(t *testing.T) {
|
||||
if !strings.Contains(output, "state complete") {
|
||||
t.Fatalf("delivery output=%q", output)
|
||||
}
|
||||
if state.releaseCreates != 1 || state.assetUploads != 0 {
|
||||
t.Fatalf("source-only calls = creates:%d uploads:%d", state.releaseCreates, state.assetUploads)
|
||||
if state.snapshot().releaseCreates != 1 || state.snapshot().assetUploads != 0 {
|
||||
t.Fatalf("source-only calls = creates:%d uploads:%d", state.snapshot().releaseCreates, state.snapshot().assetUploads)
|
||||
}
|
||||
assertModuleBootstrap(t, repository, goLog, moduleBefore)
|
||||
})
|
||||
@@ -70,10 +70,10 @@ func TestWoodpeckerTagDeliveryTemplate(t *testing.T) {
|
||||
BinaryName: "helper",
|
||||
Targets: []string{fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH)},
|
||||
},
|
||||
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||
Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||
})
|
||||
commit := pushTaggedCLIRepository(t, repository)
|
||||
state.commit = commit
|
||||
state.update(func(state *deliveryServerData) { state.commit = commit })
|
||||
|
||||
goDir, goLog := installGoBootstrapShim(t)
|
||||
env := templateEnvironment(repository, commit, goDir, goLog)
|
||||
@@ -88,14 +88,14 @@ func TestWoodpeckerTagDeliveryTemplate(t *testing.T) {
|
||||
t.Fatalf("second delivery did not reuse matching assets: %q", output)
|
||||
}
|
||||
}
|
||||
if state.releaseCreates != 1 || state.assetUploads != 2 {
|
||||
t.Fatalf("binary calls = creates:%d uploads:%d", state.releaseCreates, state.assetUploads)
|
||||
if state.snapshot().releaseCreates != 1 || state.snapshot().assetUploads != 2 {
|
||||
t.Fatalf("binary calls = creates:%d uploads:%d", state.snapshot().releaseCreates, state.snapshot().assetUploads)
|
||||
}
|
||||
if _, ok := state.assets["helper-v1.2.3-"+runtime.GOOS+"-"+runtime.GOARCH]; !ok {
|
||||
t.Fatalf("binary asset was not delivered: %#v", state.assets)
|
||||
if _, ok := state.snapshot().assets["helper-v1.2.3-"+runtime.GOOS+"-"+runtime.GOARCH]; !ok {
|
||||
t.Fatalf("binary asset was not delivered: %#v", state.snapshot().assets)
|
||||
}
|
||||
if _, ok := state.assets["SHA256SUMS"]; !ok {
|
||||
t.Fatalf("checksum manifest was not delivered: %#v", state.assets)
|
||||
if _, ok := state.snapshot().assets["SHA256SUMS"]; !ok {
|
||||
t.Fatalf("checksum manifest was not delivered: %#v", state.snapshot().assets)
|
||||
}
|
||||
assertModuleBootstrap(t, repository, goLog, moduleBefore, sumBefore)
|
||||
})
|
||||
@@ -104,10 +104,14 @@ func TestWoodpeckerTagDeliveryTemplate(t *testing.T) {
|
||||
repository := newCLIRepository(t)
|
||||
selectReleaseKitModule(t, repository, root, false)
|
||||
commit := pushTaggedCLIRepository(t, repository)
|
||||
cliGitRun(t, repository, "commit", "--allow-empty", "-m", "unrelated event commit")
|
||||
unrelated := cliGitOutput(t, repository, "rev-parse", "HEAD")
|
||||
goDir, goLog := installControlledGoBootstrapShim(t)
|
||||
env := templateEnvironment(repository, strings.Repeat("f", len(commit)), goDir, goLog)
|
||||
env := templateEnvironment(repository, unrelated, goDir, goLog)
|
||||
if output, err := executeWoodpeckerBlock(command, repository, env); err == nil {
|
||||
t.Fatalf("wrong tag succeeded: %s", output)
|
||||
} else if !strings.Contains(output, commit) || !strings.Contains(output, unrelated) {
|
||||
t.Fatalf("tag mismatch omitted commit identities: %s", output)
|
||||
}
|
||||
assertNoCommands(t, goLog)
|
||||
})
|
||||
@@ -126,26 +130,49 @@ func TestWoodpeckerTagDeliveryTemplate(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("selected executable receives event arguments and its status", func(t *testing.T) {
|
||||
repository := newCLIRepository(t)
|
||||
selectReleaseKitModule(t, repository, root, false)
|
||||
commit := pushTaggedCLIRepository(t, repository)
|
||||
goDir, goLog := installControlledGoBootstrapShim(t)
|
||||
toolArgs := filepath.Join(t.TempDir(), "tool-arguments")
|
||||
env := templateEnvironment(repository, strings.ToUpper(commit), goDir, goLog)
|
||||
env["RELEASEKIT_TOOL_ARGS_LOG"] = toolArgs
|
||||
env["RELEASEKIT_TOOL_EXIT"] = "37"
|
||||
output, err := executeWoodpeckerBlock(command, repository, env)
|
||||
var exitError *exec.ExitError
|
||||
if !errors.As(err, &exitError) || exitError.ExitCode() != 37 {
|
||||
t.Fatalf("template error = %v, want propagated exit 37; output=%q", err, output)
|
||||
for _, event := range []struct {
|
||||
name, tag string
|
||||
tagObject bool
|
||||
}{
|
||||
{"lightweight commit", "v1.2.3", false},
|
||||
{"annotated commit", "v1.2.4", false},
|
||||
{"annotated tag object", "v1.2.4", true},
|
||||
} {
|
||||
t.Run(event.name, func(t *testing.T) {
|
||||
repository := newCLIRepository(t)
|
||||
selectReleaseKitModule(t, repository, root, false)
|
||||
commit := pushTaggedCLIRepository(t, repository)
|
||||
if event.tag == "v1.2.4" {
|
||||
cliGitRun(t, repository, "tag", "-a", event.tag, "-m", "annotated release")
|
||||
cliGitRun(t, repository, "push", "origin", "refs/tags/"+event.tag)
|
||||
}
|
||||
eventID := commit
|
||||
if event.tagObject {
|
||||
eventID = cliGitOutput(t, repository, "rev-parse", "refs/tags/"+event.tag)
|
||||
}
|
||||
goDir, goLog := installControlledGoBootstrapShim(t)
|
||||
toolArgs := filepath.Join(t.TempDir(), "tool-arguments")
|
||||
env := templateEnvironment(repository, strings.ToUpper(eventID), goDir, goLog)
|
||||
env["CI_COMMIT_TAG"] = event.tag
|
||||
env["RELEASEKIT_TOOL_ARGS_LOG"] = toolArgs
|
||||
env["RELEASEKIT_TOOL_EXIT"] = "37"
|
||||
output, err := executeWoodpeckerBlock(command, repository, env)
|
||||
var exitError *exec.ExitError
|
||||
if !errors.As(err, &exitError) || exitError.ExitCode() != 37 {
|
||||
t.Fatalf("template error = %v, want propagated exit 37; output=%q", err, output)
|
||||
}
|
||||
args := readWoodpeckerFixtureFile(t, toolArgs)
|
||||
for _, expected := range []string{"<ci-deliver>", "<--repo>", "<" + repository + ">", "<--prepared>", "<" + event.tag + ">"} {
|
||||
if !strings.Contains(args, expected) {
|
||||
t.Errorf("selected executable arguments %q omit %q", args, expected)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(args), "<--commit>\n<"+commit+">") {
|
||||
t.Errorf("selected executable did not receive source commit %s: %q", commit, args)
|
||||
}
|
||||
assertGoSelection(t, repository, goLog)
|
||||
})
|
||||
}
|
||||
args := readWoodpeckerFixtureFile(t, toolArgs)
|
||||
for _, expected := range []string{"<ci-deliver>", "<--repo>", "<" + repository + ">", "<--prepared>", "<--commit>", "<" + strings.ToUpper(commit) + ">", "<v1.2.3>"} {
|
||||
if !strings.Contains(args, expected) {
|
||||
t.Errorf("selected executable arguments %q omit %q", args, expected)
|
||||
}
|
||||
}
|
||||
assertGoSelection(t, repository, goLog)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -178,7 +205,7 @@ func TestWoodpeckerTagDeliveryUsesTaggedModuleVersion(t *testing.T) {
|
||||
t.Run("older tag retry uses its committed module selection", func(t *testing.T) {
|
||||
goDir, goLog := installGoBootstrapShim(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")
|
||||
cliGitRun(t, repository, "add", "go.mod")
|
||||
cliGitRun(t, repository, "commit", "-m", "select the earlier ReleaseKit version")
|
||||
@@ -212,7 +239,7 @@ func TestWoodpeckerTagDeliveryUsesTaggedModuleVersion(t *testing.T) {
|
||||
t.Run("versioned replacement selects its declared source", func(t *testing.T) {
|
||||
goDir, goLog := installGoBootstrapShim(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"
|
||||
writeRepositoryFile(t, repository, "go.mod", goMod)
|
||||
cliGitRun(t, repository, "add", "go.mod")
|
||||
@@ -453,7 +480,7 @@ func templateEnvironment(repository, commit, shimDir, goLog string) map[string]s
|
||||
"CI_COMMIT_SHA": commit,
|
||||
"CI_COMMIT_TAG": "v1.2.3",
|
||||
"RELEASEKIT_GITEA_TOKEN": "fixture-token",
|
||||
"RELEASEKIT_MODULE": "gitea.maximumdirect.net/eric/ReleaseKit",
|
||||
"RELEASEKIT_MODULE": "go.maximumdirect.net/releasekit",
|
||||
"RELEASEKIT_REAL_GO": realGo,
|
||||
"RELEASEKIT_GO_LOG": goLog,
|
||||
"GOTOOLCHAIN": "local",
|
||||
@@ -486,7 +513,7 @@ func executeWoodpeckerBlock(block, repository string, environment map[string]str
|
||||
|
||||
func selectReleaseKitModule(t *testing.T, repository, releaseKitSource string, includeSum bool) {
|
||||
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))
|
||||
writeRepositoryFile(t, repository, "go.mod", goMod)
|
||||
cliGitRun(t, repository, "add", "go.mod")
|
||||
@@ -499,7 +526,7 @@ func selectReleaseKitModule(t *testing.T, repository, releaseKitSource string, i
|
||||
|
||||
func writeReleaseKitModuleProxy(t *testing.T) string {
|
||||
t.Helper()
|
||||
const module = "gitea.maximumdirect.net/eric/ReleaseKit"
|
||||
const module = "go.maximumdirect.net/releasekit"
|
||||
const toolDependency = "example.com/releasekit-helper"
|
||||
proxyDir := t.TempDir()
|
||||
toolModule := "module " + module + "\n\ngo 1.25.0\n\nrequire " + toolDependency + " v1.0.0\n"
|
||||
@@ -607,7 +634,7 @@ func assertGoSelection(t *testing.T, repository, goLog string) {
|
||||
log := readWoodpeckerFixtureFile(t, goLog)
|
||||
list := "list -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) {
|
||||
t.Fatalf("Go module selection command is missing required settings: %q", log)
|
||||
}
|
||||
|
||||
143
delivery.go
143
delivery.go
@@ -2,11 +2,15 @@ package releasekit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build"
|
||||
internaldelivery "gitea.maximumdirect.net/eric/ReleaseKit/internal/delivery"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea"
|
||||
internalbuild "go.maximumdirect.net/releasekit/internal/build"
|
||||
internaldelivery "go.maximumdirect.net/releasekit/internal/delivery"
|
||||
"go.maximumdirect.net/releasekit/internal/forge"
|
||||
"go.maximumdirect.net/releasekit/internal/gitea"
|
||||
)
|
||||
|
||||
// DeliveryState describes the result of one delivery invocation.
|
||||
@@ -25,21 +29,66 @@ const (
|
||||
DeliveryUnknown DeliveryState = "unknown"
|
||||
)
|
||||
|
||||
// GiteaDestination supplies the hosted repository and explicit access token.
|
||||
type GiteaDestination struct {
|
||||
// Forge selects the hosted delivery backend. A delivery request must select a
|
||||
// supported value explicitly; the destination URL does not select a backend.
|
||||
type Forge string
|
||||
|
||||
const (
|
||||
// ForgeGitea selects Gitea for hosted delivery.
|
||||
ForgeGitea Forge = "gitea"
|
||||
)
|
||||
|
||||
// DeliveryDestination supplies an explicitly selected hosted destination.
|
||||
type DeliveryDestination struct {
|
||||
// Forge selects the adapter that interprets the destination fields.
|
||||
Forge Forge
|
||||
BaseURL string
|
||||
Owner string
|
||||
Repository string
|
||||
Token string
|
||||
}
|
||||
|
||||
// DeliveryPreflightFailureKind classifies a failure while checking destination
|
||||
// settings, credentials, and repository access.
|
||||
type DeliveryPreflightFailureKind string
|
||||
|
||||
const (
|
||||
// DeliveryPreflightInvalidDestination means the destination settings fail local validation.
|
||||
DeliveryPreflightInvalidDestination DeliveryPreflightFailureKind = "invalid_destination"
|
||||
// DeliveryPreflightMissingCredential means no nonblank credential was supplied.
|
||||
DeliveryPreflightMissingCredential DeliveryPreflightFailureKind = "missing_credential"
|
||||
// DeliveryPreflightInvalidCredential means the selected service rejected the credential.
|
||||
DeliveryPreflightInvalidCredential DeliveryPreflightFailureKind = "invalid_credential"
|
||||
// DeliveryPreflightInsufficientPermissions means a required delivery permission is known to be absent.
|
||||
DeliveryPreflightInsufficientPermissions DeliveryPreflightFailureKind = "insufficient_permissions"
|
||||
// DeliveryPreflightInaccessibleDestination means the credential cannot access the selected repository.
|
||||
DeliveryPreflightInaccessibleDestination DeliveryPreflightFailureKind = "inaccessible_destination"
|
||||
// DeliveryPreflightUnavailable means the service response could not verify access.
|
||||
DeliveryPreflightUnavailable DeliveryPreflightFailureKind = "unavailable"
|
||||
)
|
||||
|
||||
// DeliveryPreflightError reports a classified failure to verify a destination.
|
||||
type DeliveryPreflightError struct {
|
||||
Kind DeliveryPreflightFailureKind
|
||||
Err error
|
||||
}
|
||||
|
||||
func (err *DeliveryPreflightError) Error() string {
|
||||
if err.Err == nil {
|
||||
return fmt.Sprintf("delivery preflight %s", err.Kind)
|
||||
}
|
||||
return fmt.Sprintf("delivery preflight %s: %v", err.Kind, err.Err)
|
||||
}
|
||||
|
||||
func (err *DeliveryPreflightError) Unwrap() error { return err.Err }
|
||||
|
||||
// DeliveryAsset identifies a hosted asset by filename and SHA-256 digest.
|
||||
type DeliveryAsset struct {
|
||||
Filename string
|
||||
SHA256 string
|
||||
}
|
||||
|
||||
// DeliveryRequest identifies a local tagged checkout and its explicit Gitea
|
||||
// DeliveryRequest identifies a local tagged checkout and its explicit
|
||||
// destination. Build selects a managed build; Prebuilt supplies caller-owned
|
||||
// build output instead. They cannot both be set. Either may be nil for
|
||||
// page-only delivery when both are nil. Build with no targets, or an empty
|
||||
@@ -54,7 +103,7 @@ type DeliveryRequest struct {
|
||||
// ExpectedTag optionally requires the full CI event tag to match the
|
||||
// selected module's derived tag before building or making hosted requests.
|
||||
ExpectedTag string
|
||||
Destination GiteaDestination
|
||||
Destination DeliveryDestination
|
||||
// Build selects binaries to build in temporary storage outside the checkout.
|
||||
Build *BuildSpec
|
||||
// Prebuilt supplies an existing build whose files remain caller-owned.
|
||||
@@ -88,19 +137,22 @@ type DeliveryResult struct {
|
||||
Uncertain []DeliveryAsset
|
||||
}
|
||||
|
||||
// Deliver verifies the selected local tag and hosted source, then creates or
|
||||
// finds its release page and adds any missing declared build assets. Build
|
||||
// requests are compiled into temporary storage that Deliver removes on return.
|
||||
// Prebuilt artifacts remain caller-owned and are not removed. Both paths
|
||||
// validate source identity and artifact contents before hosted writes.
|
||||
// Deliver requires an explicit supported destination forge. It checks the local
|
||||
// request and source, then performs read-only credential preflight before a
|
||||
// managed build or full artifact verification. It verifies the selected local
|
||||
// tag and hosted source, then creates or finds its release page and adds any
|
||||
// missing declared build assets. Prebuilt artifacts remain caller-owned. Local
|
||||
// artifact contents are verified after preflight and before hosted writes.
|
||||
// Module selection and declaration links resolve from the captured commit.
|
||||
// Page-only delivery can use a sparse checkout with available committed objects;
|
||||
// managed builds require their selected source files in the working tree.
|
||||
// Preflight failures return a [DeliveryPreflightError] and leave State at
|
||||
// [DeliveryNotAttempted].
|
||||
func Deliver(ctx context.Context, request DeliveryRequest) (DeliveryResult, error) {
|
||||
result := DeliveryResult{Version: request.Version, State: DeliveryNotAttempted}
|
||||
destination := gitea.Destination{
|
||||
BaseURL: request.Destination.BaseURL, Owner: request.Destination.Owner,
|
||||
Repository: request.Destination.Repository, Token: request.Destination.Token,
|
||||
backend, err := deliveryBackend(request.Destination)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
var prebuilt *internalbuild.Result
|
||||
if request.Prebuilt != nil {
|
||||
@@ -112,7 +164,7 @@ func Deliver(ctx context.Context, request DeliveryRequest) (DeliveryResult, erro
|
||||
Product: request.Product,
|
||||
Version: request.Version,
|
||||
ExpectedTag: request.ExpectedTag,
|
||||
Destination: destination,
|
||||
Backend: backend,
|
||||
Build: internalBuildSpec(request.Build),
|
||||
Prebuilt: prebuilt,
|
||||
Output: request.Output,
|
||||
@@ -129,10 +181,65 @@ func Deliver(ctx context.Context, request DeliveryRequest) (DeliveryResult, erro
|
||||
result.Pending = deliveryAssets(internalResult.Pending)
|
||||
result.Conflicting = deliveryAssets(internalResult.Conflicting)
|
||||
result.Uncertain = deliveryAssets(internalResult.Uncertain)
|
||||
return result, err
|
||||
return result, publicPreflightError(err)
|
||||
}
|
||||
|
||||
func deliveryAssets(assets []gitea.AssetIdentity) []DeliveryAsset {
|
||||
// PreflightDelivery checks destination credentials and repository access using
|
||||
// read-only backend operations. It does not require a checkout or published tag.
|
||||
// The result verifies only the access and permission evidence exposed by the
|
||||
// selected backend; it does not guarantee that a later write will succeed.
|
||||
// Failures are returned as [DeliveryPreflightError] values when classified by
|
||||
// the backend, and retain their underlying cause for errors.Is and errors.As.
|
||||
func PreflightDelivery(ctx context.Context, destination DeliveryDestination) error {
|
||||
backend, err := deliveryBackend(destination)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return publicPreflightError(backend.Preflight(ctx))
|
||||
}
|
||||
|
||||
func deliveryBackend(destination DeliveryDestination) (forge.Backend, error) {
|
||||
selection := Forge(strings.TrimSpace(string(destination.Forge)))
|
||||
switch selection {
|
||||
case ForgeGitea:
|
||||
return gitea.NewBackend(gitea.Destination{
|
||||
BaseURL: destination.BaseURL, Owner: destination.Owner,
|
||||
Repository: destination.Repository, Token: destination.Token,
|
||||
}, nil), nil
|
||||
case "":
|
||||
return nil, errors.New("delivery forge must be selected explicitly")
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported delivery forge %q", destination.Forge)
|
||||
}
|
||||
}
|
||||
|
||||
func publicPreflightError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var failure *forge.PreflightFailure
|
||||
if !errors.As(err, &failure) {
|
||||
return err
|
||||
}
|
||||
kind := DeliveryPreflightUnavailable
|
||||
switch failure.Kind {
|
||||
case forge.PreflightInvalidDestination:
|
||||
kind = DeliveryPreflightInvalidDestination
|
||||
case forge.PreflightMissingCredential:
|
||||
kind = DeliveryPreflightMissingCredential
|
||||
case forge.PreflightInvalidCredential:
|
||||
kind = DeliveryPreflightInvalidCredential
|
||||
case forge.PreflightInsufficientPermissions:
|
||||
kind = DeliveryPreflightInsufficientPermissions
|
||||
case forge.PreflightInaccessibleDestination:
|
||||
kind = DeliveryPreflightInaccessibleDestination
|
||||
case forge.PreflightUnavailable:
|
||||
kind = DeliveryPreflightUnavailable
|
||||
}
|
||||
return &DeliveryPreflightError{Kind: kind, Err: failure.Err}
|
||||
}
|
||||
|
||||
func deliveryAssets(assets []internaldelivery.AssetIdentity) []DeliveryAsset {
|
||||
result := make([]DeliveryAsset, 0, len(assets))
|
||||
for _, asset := range assets {
|
||||
result = append(result, DeliveryAsset{Filename: asset.Filename, SHA256: asset.SHA256})
|
||||
|
||||
63
delivery_causes_test.go
Normal file
63
delivery_causes_test.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package releasekit_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
releasekit "go.maximumdirect.net/releasekit"
|
||||
)
|
||||
|
||||
func TestDeliveryPreservesAssetInspectionCauses(t *testing.T) {
|
||||
repository := newTaggedDeliveryConsumer(t)
|
||||
prebuilt, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
|
||||
RepositoryDir: repository, Version: "v1.2.3", OutputDir: t.TempDir(),
|
||||
Spec: releasekit.BuildSpec{MainPackage: "./cmd/app", BinaryName: "fixture", Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
filename := prebuilt.Artifacts[0].Filename
|
||||
for _, cause := range []error{context.Canceled, io.ErrUnexpectedEOF} {
|
||||
t.Run(cause.Error(), func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
state := &facadeDeliveryServer{data: facadeDeliveryData{
|
||||
t: t, commit: prebuilt.CandidateCommit, tag: prebuilt.Tag, version: prebuilt.Version,
|
||||
noteBody: "# Fixture v1.2.3\n\nDelivery fixture.\n",
|
||||
pages: map[string]bool{prebuilt.Tag: true}, assets: map[string][]byte{filename: []byte("data")},
|
||||
}}
|
||||
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/files/") {
|
||||
if cause == context.Canceled {
|
||||
cancel()
|
||||
<-r.Context().Done()
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Length", "100")
|
||||
_, _ = w.Write([]byte("truncated"))
|
||||
return
|
||||
}
|
||||
state.serveHTTP(w, r)
|
||||
}))
|
||||
state.data.url = "http://" + server.Listener.Addr().String()
|
||||
server.Start()
|
||||
defer server.Close()
|
||||
result, err := releasekit.Deliver(ctx, releasekit.DeliveryRequest{
|
||||
RepositoryDir: repository, Product: "Fixture", Version: "v1.2.3",
|
||||
Destination: facadeDeliveryDestination(server.URL), Prebuilt: &prebuilt,
|
||||
})
|
||||
if !errors.Is(err, cause) || !strings.Contains(err.Error(), filename) {
|
||||
t.Fatalf("Deliver() error = %v, want cause %v and asset context", err, cause)
|
||||
}
|
||||
if result.State != releasekit.DeliveryNotAttempted || len(result.Uncertain) != 1 || result.Uncertain[0].Filename != filename || state.snapshot().writes != 0 {
|
||||
t.Fatalf("result = %#v, want read-only uncertain asset without write", result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
53
delivery_destination_test.go
Normal file
53
delivery_destination_test.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package releasekit_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
releasekit "go.maximumdirect.net/releasekit"
|
||||
)
|
||||
|
||||
func TestInvalidDestinationFailsStandaloneAndAutomaticPreflight(t *testing.T) {
|
||||
repository := newTaggedDeliveryConsumer(t)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Errorf("invalid destination made a hosted request: %s", r.URL.Path)
|
||||
http.Error(w, "unexpected request", http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
for _, test := range []struct {
|
||||
name, setting string
|
||||
change func(*releasekit.DeliveryDestination)
|
||||
}{
|
||||
{"URL", "base URL", func(d *releasekit.DeliveryDestination) { d.BaseURL = "ssh://forge.example" }},
|
||||
{"owner", "owner", func(d *releasekit.DeliveryDestination) { d.Owner = "owner/child" }},
|
||||
{"repository", "repository", func(d *releasekit.DeliveryDestination) { d.Repository = "repo/child" }},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
destination := facadeDeliveryDestination(server.URL)
|
||||
test.change(&destination)
|
||||
assertFailure := func(err error) {
|
||||
t.Helper()
|
||||
var preflight *releasekit.DeliveryPreflightError
|
||||
if !errors.As(err, &preflight) || preflight.Kind != releasekit.DeliveryPreflightInvalidDestination || preflight.Err == nil {
|
||||
t.Fatalf("error = %#v, want invalid destination with cause", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), test.setting) {
|
||||
t.Fatalf("error = %v, want invalid setting %q", err, test.setting)
|
||||
}
|
||||
}
|
||||
assertFailure(releasekit.PreflightDelivery(context.Background(), destination))
|
||||
result, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{
|
||||
RepositoryDir: repository, Product: "Fixture", Version: "v1.2.3", Destination: destination,
|
||||
Build: &releasekit.BuildSpec{MainPackage: "./missing", BinaryName: "fixture", Targets: []releasekit.Target{{OS: "linux", Arch: "amd64"}}},
|
||||
})
|
||||
assertFailure(err)
|
||||
if result.State != releasekit.DeliveryNotAttempted {
|
||||
t.Fatalf("delivery state = %s, want not attempted", result.State)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
releasekit "gitea.maximumdirect.net/eric/ReleaseKit"
|
||||
releasekit "go.maximumdirect.net/releasekit"
|
||||
)
|
||||
|
||||
func TestDeliverReadsLocalBinaryOnceForVerificationAndOnceForUpload(t *testing.T) {
|
||||
@@ -24,9 +24,10 @@ func TestDeliverReadsLocalBinaryOnceForVerificationAndOnceForUpload(t *testing.T
|
||||
}
|
||||
state := &deliveryBenchmarkServer{commit: build.CandidateCommit, assets: expected}
|
||||
state.reset(false)
|
||||
server := httptest.NewServer(http.HandlerFunc(state.serveHTTP))
|
||||
server := httptest.NewUnstartedServer(http.HandlerFunc(state.serveHTTP))
|
||||
state.baseURL = "http://" + server.Listener.Addr().String()
|
||||
server.Start()
|
||||
defer server.Close()
|
||||
state.baseURL = server.URL
|
||||
result, err := releasekit.Deliver(t.Context(), releasekit.DeliveryRequest{
|
||||
RepositoryDir: repository, Product: "Sample App", Version: build.Version,
|
||||
Destination: facadeDeliveryDestination(server.URL), Prebuilt: &build,
|
||||
|
||||
358
delivery_test.go
358
delivery_test.go
@@ -13,11 +13,181 @@ import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"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) {
|
||||
repository := newTaggedDeliveryConsumer(t)
|
||||
temporaryRoot := t.TempDir()
|
||||
@@ -41,11 +211,12 @@ func TestDeliverManagedBuildUploadsArtifactsAndCleansTemporaryOutput(t *testing.
|
||||
if err != nil {
|
||||
t.Fatalf("Deliver() error = %v; result = %#v", err, result)
|
||||
}
|
||||
if result.State != releasekit.DeliveryComplete || result.CandidateCommit != remote.commit {
|
||||
observed := remote.snapshot()
|
||||
if result.State != releasekit.DeliveryComplete || result.CandidateCommit != observed.commit {
|
||||
t.Fatalf("delivery result = %#v", result)
|
||||
}
|
||||
if len(result.Uploaded) != 2 || len(remote.assets) != 2 {
|
||||
t.Fatalf("uploaded = %#v; hosted assets = %v", result.Uploaded, mapKeys(remote.assets))
|
||||
if len(result.Uploaded) != 2 || len(observed.assets) != 2 {
|
||||
t.Fatalf("uploaded = %#v; hosted assets = %v", result.Uploaded, mapKeys(observed.assets))
|
||||
}
|
||||
if !strings.Contains(progress.String(), "checking fixture version output") {
|
||||
t.Fatalf("build progress = %q", progress.String())
|
||||
@@ -79,7 +250,8 @@ func TestDeliverBinaryConsumerWithBuildinfoAndSelectedTargets(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Deliver() error = %v; result = %#v", err, result)
|
||||
}
|
||||
if result.State != releasekit.DeliveryComplete || result.CandidateCommit != remote.commit {
|
||||
observed := remote.snapshot()
|
||||
if result.State != releasekit.DeliveryComplete || result.CandidateCommit != observed.commit {
|
||||
t.Fatalf("delivery result = %#v", result)
|
||||
}
|
||||
wantAssets := []string{
|
||||
@@ -87,12 +259,12 @@ func TestDeliverBinaryConsumerWithBuildinfoAndSelectedTargets(t *testing.T) {
|
||||
"fixture-v1.2.3-linux-arm64",
|
||||
"SHA256SUMS",
|
||||
}
|
||||
if len(result.Uploaded) != len(wantAssets) || len(remote.assets) != len(wantAssets) {
|
||||
t.Fatalf("uploaded = %#v; hosted assets = %v", result.Uploaded, mapKeys(remote.assets))
|
||||
if len(result.Uploaded) != len(wantAssets) || len(observed.assets) != len(wantAssets) {
|
||||
t.Fatalf("uploaded = %#v; hosted assets = %v", result.Uploaded, mapKeys(observed.assets))
|
||||
}
|
||||
for _, name := range wantAssets {
|
||||
if _, ok := remote.assets[name]; !ok {
|
||||
t.Errorf("hosted assets omit %q: %v", name, mapKeys(remote.assets))
|
||||
if _, ok := observed.assets[name]; !ok {
|
||||
t.Errorf("hosted assets omit %q: %v", name, mapKeys(observed.assets))
|
||||
}
|
||||
}
|
||||
if !strings.Contains(progress.String(), "checking fixture version output") {
|
||||
@@ -174,8 +346,8 @@ func TestDeliverRejectsSameSizePrebuiltTamperingBeforeHostedWrites(t *testing.T)
|
||||
if err == nil || !strings.Contains(err.Error(), "verify build assets") || result.State != releasekit.DeliveryNotAttempted {
|
||||
t.Fatalf("Deliver() = %#v, %v; want local asset verification failure", result, err)
|
||||
}
|
||||
if remote.requests != 0 {
|
||||
t.Fatalf("hosted requests = %d, want none before local verification", remote.requests)
|
||||
if remote.snapshot().writes != 0 {
|
||||
t.Fatalf("hosted writes = %d, want none before local verification", remote.snapshot().writes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,8 +389,8 @@ func TestDeliverPageOnlySkipsBuildAndHostedAssets(t *testing.T) {
|
||||
if err != nil || result.State != releasekit.DeliveryComplete {
|
||||
t.Fatalf("Deliver() = %#v, %v", result, err)
|
||||
}
|
||||
if len(remote.assets) != 0 || len(result.Uploaded) != 0 || len(result.Pending) != 0 {
|
||||
t.Fatalf("page-only delivery included assets: result=%#v assets=%v", result, mapKeys(remote.assets))
|
||||
if len(remote.snapshot().assets) != 0 || len(result.Uploaded) != 0 || len(result.Pending) != 0 {
|
||||
t.Fatalf("page-only delivery included assets: result=%#v assets=%v", result, mapKeys(remote.snapshot().assets))
|
||||
}
|
||||
if progress.Len() != 0 {
|
||||
t.Fatalf("page-only delivery emitted build progress: %q", progress.String())
|
||||
@@ -242,8 +414,8 @@ func TestDeliverUsesCommittedModuleIdentityForSeparatePages(t *testing.T) {
|
||||
ExpectedTag: "integrations/wrong/v1.2.3",
|
||||
Destination: facadeDeliveryDestination(server.URL),
|
||||
})
|
||||
if err == nil || wrongTag.Tag != "v1.2.3" || remote.requests != 0 {
|
||||
t.Fatalf("delivery with inconsistent expected tag = %#v, %v; hosted requests %d", wrongTag, err, remote.requests)
|
||||
if err == nil || wrongTag.Tag != "v1.2.3" || remote.snapshot().requests != 0 {
|
||||
t.Fatalf("delivery with inconsistent expected tag = %#v, %v; hosted requests %d", wrongTag, err, remote.snapshot().requests)
|
||||
}
|
||||
|
||||
rootResult, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{
|
||||
@@ -258,8 +430,10 @@ func TestDeliverUsesCommittedModuleIdentityForSeparatePages(t *testing.T) {
|
||||
}
|
||||
|
||||
nestedTag := "integrations/ckconfig/v1.2.3"
|
||||
remote.tag = nestedTag
|
||||
remote.noteBody = "# Fixture v1.2.3\n\nNested delivery fixture.\n"
|
||||
remote.update(func(remote *facadeDeliveryData) {
|
||||
remote.tag = nestedTag
|
||||
remote.noteBody = "# Fixture v1.2.3\n\nNested delivery fixture.\n"
|
||||
})
|
||||
nestedResult, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{
|
||||
RepositoryDir: repository,
|
||||
ModuleDir: "integrations/ckconfig",
|
||||
@@ -271,8 +445,8 @@ func TestDeliverUsesCommittedModuleIdentityForSeparatePages(t *testing.T) {
|
||||
if err != nil || nestedResult.State != releasekit.DeliveryComplete || nestedResult.Tag != nestedTag || nestedResult.ModulePath != "example.com/ckconfig" {
|
||||
t.Fatalf("nested delivery = %#v, %v", nestedResult, err)
|
||||
}
|
||||
if len(remote.pages) != 2 || !remote.pages["v1.2.3"] || !remote.pages[nestedTag] {
|
||||
t.Fatalf("created hosted pages = %#v", remote.pages)
|
||||
if len(remote.snapshot().pages) != 2 || !remote.snapshot().pages["v1.2.3"] || !remote.snapshot().pages[nestedTag] {
|
||||
t.Fatalf("created hosted pages = %#v", remote.snapshot().pages)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,21 +467,21 @@ func TestDeliverRejectsPrebuiltArtifactsForAnotherModuleBeforeHostedRequests(t *
|
||||
}
|
||||
server, remote := newFacadeDeliveryServer(t, repository)
|
||||
defer server.Close()
|
||||
remote.tag = "integrations/ckconfig/v1.2.3"
|
||||
remote.update(func(remote *facadeDeliveryData) { remote.tag = "integrations/ckconfig/v1.2.3" })
|
||||
result, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{
|
||||
RepositoryDir: repository,
|
||||
ModuleDir: "integrations/ckconfig",
|
||||
Product: "Fixture",
|
||||
Version: "v1.2.3",
|
||||
ExpectedTag: remote.tag,
|
||||
ExpectedTag: remote.snapshot().tag,
|
||||
Destination: facadeDeliveryDestination(server.URL),
|
||||
Prebuilt: &prebuilt,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "selected tagged module") || result.Tag != remote.tag {
|
||||
if err == nil || !strings.Contains(err.Error(), "selected tagged module") || result.Tag != remote.snapshot().tag {
|
||||
t.Fatalf("nested delivery with root artifacts = %#v, %v", result, err)
|
||||
}
|
||||
if remote.requests != 0 {
|
||||
t.Fatalf("hosted requests before prebuilt identity rejection = %d", remote.requests)
|
||||
if remote.snapshot().requests != 0 {
|
||||
t.Fatalf("hosted requests before prebuilt identity rejection = %d", remote.snapshot().requests)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,7 +492,7 @@ func newTwoModuleDeliveryConsumer(t *testing.T) string {
|
||||
import (
|
||||
"fmt"
|
||||
"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()) } }
|
||||
`)
|
||||
@@ -351,14 +525,14 @@ func TestDeliverRejectsDirtyTaggedSourceBeforeBuildOrHostedWrites(t *testing.T)
|
||||
},
|
||||
Output: &progress,
|
||||
})
|
||||
if err == nil || result.State != releasekit.DeliveryNotAttempted || result.CandidateCommit != remote.commit {
|
||||
if err == nil || result.State != releasekit.DeliveryNotAttempted || result.CandidateCommit != remote.snapshot().commit {
|
||||
t.Fatalf("Deliver() = %#v, %v; want preflight failure for known commit", result, err)
|
||||
}
|
||||
if progress.Len() != 0 {
|
||||
t.Fatalf("build started before source eligibility passed: %q", progress.String())
|
||||
}
|
||||
if remote.requests != 0 {
|
||||
t.Fatalf("hosted requests = %d, want none", remote.requests)
|
||||
if remote.snapshot().requests != 0 {
|
||||
t.Fatalf("hosted requests = %d, want none", remote.snapshot().requests)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -392,8 +566,8 @@ func TestDeliverRejectsCheckoutAwayFromTagBeforeBuildOrHostedWrites(t *testing.T
|
||||
if progress.Len() != 0 {
|
||||
t.Fatalf("build started before tag/HEAD agreement: %q", progress.String())
|
||||
}
|
||||
if remote.requests != 0 {
|
||||
t.Fatalf("hosted requests = %d, want none", remote.requests)
|
||||
if remote.snapshot().requests != 0 {
|
||||
t.Fatalf("hosted requests = %d, want none", remote.snapshot().requests)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -417,14 +591,14 @@ func TestDeliverRejectsInvalidCommittedNoteBeforeBuildOrHostedWrites(t *testing.
|
||||
},
|
||||
Output: &progress,
|
||||
})
|
||||
if err == nil || result.State != releasekit.DeliveryNotAttempted || result.CandidateCommit != remote.commit {
|
||||
if err == nil || result.State != releasekit.DeliveryNotAttempted || result.CandidateCommit != remote.snapshot().commit {
|
||||
t.Fatalf("Deliver() = %#v, %v; want committed-note preflight failure", result, err)
|
||||
}
|
||||
if progress.Len() != 0 {
|
||||
t.Fatalf("build started before committed-note validation: %q", progress.String())
|
||||
}
|
||||
if remote.requests != 0 {
|
||||
t.Fatalf("hosted requests = %d, want none", remote.requests)
|
||||
if remote.snapshot().requests != 0 {
|
||||
t.Fatalf("hosted requests = %d, want none", remote.snapshot().requests)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -458,8 +632,8 @@ func TestDeliverManagedBuildRejectsCheckoutMutationBeforeHostedWrites(t *testing
|
||||
if err == nil || !strings.Contains(err.Error(), "build source") || result.State != releasekit.DeliveryNotAttempted {
|
||||
t.Fatalf("Deliver() = %#v, %v; want build-source eligibility failure", result, err)
|
||||
}
|
||||
if remote.requests != 0 {
|
||||
t.Fatalf("hosted requests = %d, want none", remote.requests)
|
||||
if remote.snapshot().writes != 0 {
|
||||
t.Fatalf("hosted writes = %d, want none after preflight", remote.snapshot().writes)
|
||||
}
|
||||
if entries, err := os.ReadDir(temporaryRoot); err != nil || len(entries) != 0 {
|
||||
t.Fatalf("ineligible managed build output remains: entries=%v error=%v", entries, err)
|
||||
@@ -484,11 +658,11 @@ func TestDeliverManagedBuildFailureCleansTemporaryOutputBeforeHostedWrites(t *te
|
||||
Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}},
|
||||
},
|
||||
})
|
||||
if err == nil || result.State != releasekit.DeliveryNotAttempted || result.CandidateCommit != remote.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)
|
||||
}
|
||||
if remote.requests != 0 {
|
||||
t.Fatalf("hosted requests = %d, want none", remote.requests)
|
||||
if remote.snapshot().writes != 0 {
|
||||
t.Fatalf("hosted writes = %d, want none after preflight", remote.snapshot().writes)
|
||||
}
|
||||
if entries, err := os.ReadDir(temporaryRoot); err != nil || len(entries) != 0 {
|
||||
t.Fatalf("failed managed build output remains: entries=%v error=%v", entries, err)
|
||||
@@ -520,8 +694,8 @@ func TestDeliverManagedBuildCancellationCleansTemporaryOutput(t *testing.T) {
|
||||
if !errors.Is(err, context.Canceled) || result.State != releasekit.DeliveryNotAttempted {
|
||||
t.Fatalf("Deliver() = %#v, %v; want cancellation before hosted writes", result, err)
|
||||
}
|
||||
if remote.requests != 0 {
|
||||
t.Fatalf("hosted requests = %d, want none", remote.requests)
|
||||
if remote.snapshot().writes != 0 {
|
||||
t.Fatalf("hosted writes = %d, want none after preflight", remote.snapshot().writes)
|
||||
}
|
||||
if entries, err := os.ReadDir(temporaryRoot); err != nil || len(entries) != 0 {
|
||||
t.Fatalf("canceled managed build output remains: entries=%v error=%v", entries, err)
|
||||
@@ -544,8 +718,8 @@ func TestDeliverRejectsBothBuildSourcesBeforeHostedWrites(t *testing.T) {
|
||||
if err == nil || result.State != releasekit.DeliveryNotAttempted || !strings.Contains(err.Error(), "both") {
|
||||
t.Fatalf("Deliver() = %#v, %v; want invalid build-source combination", result, err)
|
||||
}
|
||||
if remote.requests != 0 {
|
||||
t.Fatalf("hosted requests = %d, want none", remote.requests)
|
||||
if remote.snapshot().requests != 0 {
|
||||
t.Fatalf("hosted requests = %d, want none", remote.snapshot().requests)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -559,7 +733,7 @@ func newTaggedDeliveryConsumerWithNote(t *testing.T, note string) string {
|
||||
import (
|
||||
"fmt"
|
||||
"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()) } }
|
||||
`)
|
||||
@@ -570,8 +744,8 @@ func main() { if len(os.Args) == 2 && os.Args[1] == "version" { fmt.Println("fix
|
||||
return repository
|
||||
}
|
||||
|
||||
func facadeDeliveryDestination(baseURL string) releasekit.GiteaDestination {
|
||||
return releasekit.GiteaDestination{BaseURL: baseURL, Owner: "owner", Repository: "example", Token: "fixture-token"}
|
||||
func facadeDeliveryDestination(baseURL string) releasekit.DeliveryDestination {
|
||||
return releasekit.DeliveryDestination{Forge: releasekit.ForgeGitea, BaseURL: baseURL, Owner: "owner", Repository: "example", Token: "fixture-token"}
|
||||
}
|
||||
|
||||
type deliveryCancelWriter struct{ cancel context.CancelFunc }
|
||||
@@ -582,6 +756,35 @@ func (writer deliveryCancelWriter) Write(content []byte) (int, error) {
|
||||
}
|
||||
|
||||
type facadeDeliveryServer struct {
|
||||
mu sync.Mutex
|
||||
data facadeDeliveryData
|
||||
}
|
||||
|
||||
func (state *facadeDeliveryServer) update(change func(*facadeDeliveryData)) {
|
||||
state.mu.Lock()
|
||||
defer state.mu.Unlock()
|
||||
change(&state.data)
|
||||
}
|
||||
|
||||
func (state *facadeDeliveryServer) snapshot() facadeDeliveryData {
|
||||
state.mu.Lock()
|
||||
defer state.mu.Unlock()
|
||||
result := state.data
|
||||
|
||||
result.assets = make(map[string][]byte, len(state.data.assets))
|
||||
for key, value := range state.data.assets {
|
||||
result.assets[key] = append([]byte(nil), value...)
|
||||
}
|
||||
|
||||
result.pages = make(map[string]bool, len(state.data.pages))
|
||||
for key, value := range state.data.pages {
|
||||
result.pages[key] = value
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
type facadeDeliveryData struct {
|
||||
t *testing.T
|
||||
commit string
|
||||
tag string
|
||||
@@ -589,30 +792,43 @@ type facadeDeliveryServer struct {
|
||||
noteBody string
|
||||
pages map[string]bool
|
||||
requests int
|
||||
writes int
|
||||
assets map[string][]byte
|
||||
url string
|
||||
}
|
||||
|
||||
func newFacadeDeliveryServer(t *testing.T, repository string) (*httptest.Server, *facadeDeliveryServer) {
|
||||
t.Helper()
|
||||
state := &facadeDeliveryServer{t: t, commit: buildGit(t, repository, "rev-parse", "HEAD"), tag: "v1.2.3", version: "v1.2.3", noteBody: "# Fixture v1.2.3\n\nDelivery fixture.\n", pages: make(map[string]bool), assets: make(map[string][]byte)}
|
||||
server := httptest.NewServer(http.HandlerFunc(state.serveHTTP))
|
||||
state.url = server.URL
|
||||
state := &facadeDeliveryServer{data: facadeDeliveryData{t: t, commit: buildGit(t, repository, "rev-parse", "HEAD"), tag: "v1.2.3", version: "v1.2.3", noteBody: "# Fixture v1.2.3\n\nDelivery fixture.\n", pages: make(map[string]bool), assets: make(map[string][]byte)}}
|
||||
server := httptest.NewUnstartedServer(http.HandlerFunc(state.serveHTTP))
|
||||
state.data.url = "http://" + server.Listener.Addr().String()
|
||||
server.Start()
|
||||
return server, state
|
||||
}
|
||||
|
||||
func (state *facadeDeliveryServer) serveHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
state.mu.Lock()
|
||||
defer state.mu.Unlock()
|
||||
data := &state.data
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
state.requests++
|
||||
data.requests++
|
||||
if r.Method != http.MethodGet {
|
||||
data.writes++
|
||||
}
|
||||
if r.Header.Get("Authorization") != "token fixture-token" {
|
||||
state.t.Errorf("authorization = %q", r.Header.Get("Authorization"))
|
||||
data.t.Errorf("authorization = %q", r.Header.Get("Authorization"))
|
||||
}
|
||||
}
|
||||
switch r.URL.EscapedPath() {
|
||||
case "/api/v1/repos/owner/example/tags/" + url.PathEscape(state.tag):
|
||||
_, _ = fmt.Fprintf(w, `{"name":%q,"commit":{"sha":%q}}`, state.tag, state.commit)
|
||||
case "/api/v1/repos/owner/example/releases/tags/" + url.PathEscape(state.tag):
|
||||
if !state.pages[state.tag] {
|
||||
case "/api/v1/repos/owner/example", "/api/v1/repositories/1":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": 1, "name": "example", "full_name": "owner/example",
|
||||
"owner": map[string]string{"login": "owner"},
|
||||
})
|
||||
case "/api/v1/repos/owner/example/tags/" + url.PathEscape(data.tag):
|
||||
_, _ = fmt.Fprintf(w, `{"name":%q,"commit":{"sha":%q}}`, data.tag, data.commit)
|
||||
case "/api/v1/repos/owner/example/releases/tags/" + url.PathEscape(data.tag):
|
||||
if !data.pages[data.tag] {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
@@ -625,20 +841,20 @@ func (state *facadeDeliveryServer) serveHTTP(w http.ResponseWriter, r *http.Requ
|
||||
Body string `json:"body"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
state.t.Errorf("decode release payload: %v", err)
|
||||
data.t.Errorf("decode release payload: %v", err)
|
||||
http.Error(w, "invalid payload", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if payload.Tag != state.tag || payload.Target != state.commit || payload.Name != "Fixture "+state.version {
|
||||
state.t.Errorf("release payload = %#v", payload)
|
||||
if payload.Tag != data.tag || payload.Target != data.commit || payload.Name != "Fixture "+data.version {
|
||||
data.t.Errorf("release payload = %#v", payload)
|
||||
}
|
||||
state.pages[state.tag] = true
|
||||
data.pages[data.tag] = true
|
||||
state.writeRelease(w)
|
||||
case "/api/v1/repos/owner/example/releases/10/assets":
|
||||
if r.Method == http.MethodGet {
|
||||
list := make([]map[string]any, 0, len(state.assets))
|
||||
for name, content := range state.assets {
|
||||
list = append(list, map[string]any{"id": len(list) + 1, "name": name, "size": len(content), "browser_download_url": state.url + "/files/" + name})
|
||||
list := make([]map[string]any, 0, len(data.assets))
|
||||
for name, content := range data.assets {
|
||||
list = append(list, map[string]any{"id": len(list) + 1, "name": name, "size": len(content), "browser_download_url": data.url + "/files/" + name})
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(list)
|
||||
return
|
||||
@@ -646,22 +862,22 @@ func (state *facadeDeliveryServer) serveHTTP(w http.ResponseWriter, r *http.Requ
|
||||
name := r.URL.Query().Get("name")
|
||||
file, _, err := r.FormFile("attachment")
|
||||
if err != nil {
|
||||
state.t.Errorf("read upload %q: %v", name, err)
|
||||
data.t.Errorf("read upload %q: %v", name, err)
|
||||
http.Error(w, "invalid multipart upload", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
content, err := io.ReadAll(file)
|
||||
_ = file.Close()
|
||||
if err != nil {
|
||||
state.t.Errorf("read upload %q: %v", name, err)
|
||||
data.t.Errorf("read upload %q: %v", name, err)
|
||||
}
|
||||
state.assets[name] = content
|
||||
data.assets[name] = content
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"id": len(state.assets), "name": name, "size": len(content)})
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"id": len(data.assets), "name": name, "size": len(content)})
|
||||
default:
|
||||
if strings.HasPrefix(r.URL.Path, "/files/") {
|
||||
name := strings.TrimPrefix(r.URL.Path, "/files/")
|
||||
content, ok := state.assets[name]
|
||||
content, ok := data.assets[name]
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
@@ -669,17 +885,19 @@ func (state *facadeDeliveryServer) serveHTTP(w http.ResponseWriter, r *http.Requ
|
||||
_, _ = w.Write(content)
|
||||
return
|
||||
}
|
||||
state.t.Errorf("unexpected delivery request %s %s", r.Method, r.URL)
|
||||
data.t.Errorf("unexpected delivery request %s %s", r.Method, r.URL)
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (state *facadeDeliveryServer) writeRelease(w http.ResponseWriter) {
|
||||
// The request handler holds state.mu.
|
||||
data := &state.data
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": 10, "html_url": state.url + "/releases/10", "tag_name": state.tag,
|
||||
"target_commitish": state.commit, "name": "Fixture " + state.version,
|
||||
"body": state.noteBody, "draft": false, "prerelease": false,
|
||||
"id": 10, "html_url": data.url + "/releases/10", "tag_name": data.tag,
|
||||
"target_commitish": data.commit, "name": "Fixture " + data.version,
|
||||
"body": data.noteBody, "draft": false, "prerelease": false,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
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.
|
||||
56
docs/cli.md
56
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
|
||||
validation, using temporary output outside the worktree.
|
||||
|
||||
Deliver a tagged source release to Gitea:
|
||||
Deliver a tagged source release:
|
||||
|
||||
```sh
|
||||
go run ./cmd/releasekit deliver [--repo DIR] [--config FILE] [--assets DIR] VERSION
|
||||
```
|
||||
|
||||
`deliver` requires a `delivery` profile object and its configured token
|
||||
environment variable, which defaults to `RELEASEKIT_GITEA_TOKEN`. It verifies
|
||||
`deliver` requires a `delivery` profile object with an explicit
|
||||
`"forge": "gitea"` selection and a token loaded from its configured environment
|
||||
variable or token file. The destination URL does not select a forge. Selection
|
||||
errors are reported before token access or delivery work. Credential selection
|
||||
and errors are defined in the [configuration reference](config.md). It verifies
|
||||
the local tag, clean tagged checkout, hosted tag, and committed release note,
|
||||
then creates or finds the release page and reports delivery state separately
|
||||
from source publication. Historical lightweight tags and detached checkouts
|
||||
@@ -194,16 +197,27 @@ Profiles without targets use page-only delivery
|
||||
and need no receipt, binary build, or manifest. `deliver` never runs candidate
|
||||
checks or publishes a tag.
|
||||
|
||||
After cheap local checks, delivery performs authenticated, read-only repository
|
||||
preflight before a managed build or full artifact size and digest verification.
|
||||
An unavailable preflight leaves delivery `not_attempted`; after preflight
|
||||
succeeds, local artifact verification still finishes before any hosted write.
|
||||
|
||||
Use one `deliver` invocation for the normal workflow. To build without delivery
|
||||
credentials, or to retain build output across retries, run `build --output DIR`
|
||||
first and then run `deliver --assets DIR` with the token available. The ordinary
|
||||
`deliver` command does not download application dependencies or change the
|
||||
checkout.
|
||||
|
||||
The command returns 0 for complete delivery, 1 for a known failure, conflict,
|
||||
or partial delivery, 2 for usage, profile, or missing-credential errors, and 3
|
||||
when a remote write remains uncertain. The result lists matching, uploaded,
|
||||
pending, conflicting, and uncertain assets as disjoint current observations;
|
||||
The command returns 0 for complete delivery, 1 for an unavailable preflight or
|
||||
a known delivery failure, conflict, or partial delivery, and 2 for usage,
|
||||
profile, forge-selection, local credential-loading, or classified preflight
|
||||
configuration/access errors. A remote write whose outcome remains uncertain
|
||||
returns 3, including when cancellation prevents reconciliation. Cancellation
|
||||
without an unresolved write returns 1, including during preflight or
|
||||
preparation. Later authorization failures follow
|
||||
the delivery outcome: known failure returns 1; an unresolved write returns 3.
|
||||
The result lists matching, uploaded, pending, conflicting, and uncertain assets
|
||||
as disjoint current observations;
|
||||
early failures may leave some declared assets unreported. On failure, the CLI
|
||||
prints state-based recovery guidance. Fix local or preflight failures before
|
||||
retrying; fix the reported cause before retrying partial delivery; resolve an
|
||||
@@ -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.
|
||||
In either mode, it loads the profile from the verified source. `--expected-module`
|
||||
optionally checks that the profile selects the module chosen by a launcher;
|
||||
module selection itself remains profile-owned. The command validates delivery
|
||||
settings and the configured token, then downloads application dependencies only
|
||||
when the profile selects build targets. Binary profiles are built and delivered
|
||||
through the same facade path as `deliver`; source-only profiles skip dependency
|
||||
module selection itself remains profile-owned. The command compares the selected
|
||||
module's derived release tag with the full event tag before forge access or
|
||||
dependency preparation, then validates explicit forge selection and resolves
|
||||
the configured token. For binary profiles, it performs a read-only repository
|
||||
preflight before downloading application
|
||||
dependencies; delivery performs a fresh check after dependency preparation.
|
||||
Source-only profiles rely on delivery's automatic preflight and skip dependency
|
||||
downloads and binary builds.
|
||||
`--config` follows the existing profile path rules: relative paths are resolved
|
||||
from `--repo`, and absolute paths are used as-is. The default mode prepares and
|
||||
@@ -241,13 +258,18 @@ changes the selected checkout; `--prepared` reuses a verified checkout.
|
||||
Ordinary `deliver` continues to operate on already prepared source without
|
||||
fetching, checking out, or downloading dependencies.
|
||||
|
||||
In Woodpecker, pass `CI_WORKSPACE`, `CI_COMMIT_SHA`, and `CI_COMMIT_TAG` as the
|
||||
repository, expected commit, and full tag arguments. The CLI does not read
|
||||
In Woodpecker, pass `CI_WORKSPACE`, the source commit resolved from
|
||||
`CI_COMMIT_SHA`, and `CI_COMMIT_TAG` as the repository, expected commit, and full
|
||||
tag arguments. The event ID may identify an annotated tag object; see the
|
||||
[delivery guide](consumers/delivery.md#woodpecker-tag-workflows) for resolution
|
||||
and workflow preparation. The CLI does not read
|
||||
provider-specific environment variable names. Checkout and dependency
|
||||
preparation failures return status 1; usage, profile, credential, and invalid
|
||||
version errors return status 2. Final delivery uses the same statuses as
|
||||
`deliver`: 0 for complete, 1 for known failure, conflict, or partial delivery,
|
||||
and 3 for uncertain remote state.
|
||||
preparation failures, unavailable preflight, and cancellation during those
|
||||
steps return status 1. Usage, profile, forge-selection, local credential-loading,
|
||||
classified preflight configuration/access, and invalid-version errors return
|
||||
status 2. Final delivery uses the same statuses as `deliver`: 0 for complete,
|
||||
1 for known failure, conflict, or partial delivery, and 3 for uncertain remote
|
||||
state, including when cancellation prevents reconciliation.
|
||||
|
||||
The maintained module-based tag workflow verifies and checks out the event tag
|
||||
before resolving the consumer module. It builds the ReleaseKit version already
|
||||
|
||||
@@ -16,7 +16,7 @@ The current profile fields are:
|
||||
| `checks` | No | Ordered additional child commands, each with an argument vector and optional environment map. |
|
||||
| `validation` | No | Optional shared checks selected by ReleaseKit. |
|
||||
| `build` | No | Optional main package, binary name, and selected `GOOS/GOARCH` target strings. |
|
||||
| `delivery` | No | Optional Gitea `base_url`, `owner`, `repository`, and `token_env` settings for tagged-source delivery. |
|
||||
| `delivery` | No | Optional selected forge and destination fields for tagged-source delivery. |
|
||||
|
||||
`module_dir` names the selected module's physical location in the repository;
|
||||
ReleaseKit reads its declared import path from that module's own `go.mod`. The
|
||||
@@ -141,12 +141,52 @@ symlink size is the committed link text. Gitlinks have no measured size. Only
|
||||
the captured candidate is checked, one offending file is reported at a time,
|
||||
and historical or aggregate repository size is not measured.
|
||||
|
||||
The `delivery` object contains `base_url`, `owner`, `repository`, and optional
|
||||
`token_env`. Its values are required when running `deliver`, but credentials are
|
||||
not required for `check`, `publish`, or `build`. `token_env` names the
|
||||
environment variable containing the Gitea token; it defaults to
|
||||
`RELEASEKIT_GITEA_TOKEN`. The CLI reads this variable only for `deliver` and
|
||||
passes its value explicitly to the library operation.
|
||||
`validation.forbidden_files` is an optional list of literal repository-relative
|
||||
file paths that must be absent from the captured commit. It runs after the Go
|
||||
checks and before other file checks, using the `forbidden-files` identity and
|
||||
reporting selected committed paths. Omission, `null`, or `[]` disables it.
|
||||
Paths use slash separators and ordinary relative normalization and must stay
|
||||
within the repository. They are checked against the whole captured repository,
|
||||
independently of the selected module and `shared_files`. The check reads path
|
||||
metadata only; it does not read token contents, follow symlink targets, require
|
||||
local files to exist, or inspect Git history. Existing clean-index/worktree
|
||||
requirements still apply to candidate operations. An ignore rule alone does
|
||||
not remove a previously committed file from the candidate.
|
||||
|
||||
The `delivery` object contains `forge`, `base_url`, `owner`, `repository`, and
|
||||
optional `token_env` and `token_file`. A delivery request must set `forge` to
|
||||
`"gitea"`; omission, an empty or whitespace-only value, and unsupported values
|
||||
are errors when `deliver` or `ci-deliver` is requested. The URL does not select
|
||||
the forge. These semantic checks run only for delivery, so `check`, `publish`,
|
||||
and `build` do not require a delivery object, selection, or credentials.
|
||||
|
||||
`token_env` names the environment variable containing the Gitea token and
|
||||
defaults to `RELEASEKIT_GITEA_TOKEN`. A nonblank environment value takes
|
||||
precedence. When it is unset or blank, `token_file` selects a plain
|
||||
token-containing file; relative paths resolve from the Git repository root,
|
||||
and absolute paths are used as supplied. The CLI trims leading and trailing
|
||||
whitespace from file contents. An unreadable or blank file, or the absence of
|
||||
both credential sources, returns status 2. Omission of `token_file` retains
|
||||
environment-only loading.
|
||||
|
||||
After resolving the token, delivery performs an authenticated, read-only
|
||||
repository access check. Invalid destination settings, missing or rejected
|
||||
credentials, known insufficient permissions, and inaccessible destinations are
|
||||
configuration/credential failures. An unavailable service or canceled check is
|
||||
an operational failure.
|
||||
See the [CLI contract](cli.md) for exact exit statuses. Successful preflight
|
||||
confirms authentication and repository access and accounts for any exposed
|
||||
delivery-permission deficiency. It does not guarantee later release or asset
|
||||
writes. Local artifact size and digest checks follow preflight and still finish
|
||||
before hosted writes.
|
||||
|
||||
Credential loading runs only for `deliver` and `ci-deliver`. `check`, `publish`,
|
||||
and `build` do not read the token file or require a token. The CLI passes the
|
||||
resolved value explicitly to the library operation. `ci-deliver` checks the
|
||||
destination before downloading application dependencies for a binary build;
|
||||
the later delivery performs a fresh check. Page-only CI relies on delivery's
|
||||
automatic check. Selecting a token file and selecting the optional
|
||||
forbidden-file validator are independent settings.
|
||||
|
||||
`expected_push_url`, when nonempty, is compared exactly with Git's resolved
|
||||
effective push URL during `publish`, before candidate validation. It applies
|
||||
|
||||
@@ -10,12 +10,14 @@ From the consumer module's root, select the ReleaseKit version to adopt:
|
||||
|
||||
```sh
|
||||
: "${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 tool releasekit version
|
||||
```
|
||||
|
||||
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;
|
||||
pre-feature versions through v0.3.0 use their corresponding historical interface.
|
||||
Go records the executable package in
|
||||
@@ -43,13 +45,47 @@ required imports and select the module version:
|
||||
|
||||
```sh
|
||||
: "${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
|
||||
```
|
||||
|
||||
API consumers can also register the CLI with `go get -tool` as above. Both uses
|
||||
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
|
||||
|
||||
The maintained [consumer tag-delivery workflow](delivery.md#woodpecker-tag-workflows)
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
releasekit "gitea.maximumdirect.net/eric/ReleaseKit"
|
||||
releasekit "go.maximumdirect.net/releasekit"
|
||||
)
|
||||
|
||||
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
|
||||
shared validation when supplied.
|
||||
|
||||
For local token files, set `Validation.ForbiddenFiles` to the repository-relative
|
||||
paths that should stay out of the committed candidate, such as
|
||||
`[]string{".releasekit/gitea-token"}`. Failures use `forbidden-files` and carry the
|
||||
selected committed paths in `ValidationError.Findings`. This selection is
|
||||
independent of module and shared-file inputs; see [GoDoc](../../check.go) for
|
||||
the API contract and the [configuration reference](../config.md) for CLI use.
|
||||
|
||||
`ValidationSpec.SharedFiles` adds committed, repository-relative files to
|
||||
ReleaseKit's optional file checks without enabling any checks. A nil slice
|
||||
selects the optional defaults `COPYRIGHT`, `LICENSE`, and
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Deliver a tagged source release
|
||||
|
||||
Use `releasekit.Deliver` or the `deliver` command after a source tag exists.
|
||||
Select the delivery backend explicitly in the profile or
|
||||
`DeliveryDestination`; the destination URL does not infer a backend.
|
||||
Delivery status is separate from source publication: a delivery problem does
|
||||
not move or remove the tag, and a retry can use the same immutable version.
|
||||
The operation accepts historical lightweight and annotated tags, including a
|
||||
@@ -62,6 +64,7 @@ Add a Gitea destination to the profile:
|
||||
{
|
||||
"product": "Example",
|
||||
"delivery": {
|
||||
"forge": "gitea",
|
||||
"base_url": "https://gitea.example",
|
||||
"owner": "example",
|
||||
"repository": "example",
|
||||
@@ -77,11 +80,37 @@ git checkout --detach v1.2.3
|
||||
RELEASEKIT_GITEA_TOKEN="$GITEA_TOKEN" releasekit deliver v1.2.3
|
||||
```
|
||||
|
||||
The CLI reads the named token variable only for `deliver`; its default name is
|
||||
`RELEASEKIT_GITEA_TOKEN`. It verifies the local checkout and hosted tag before
|
||||
The CLI resolves the token for delivery using the configured environment
|
||||
variable or token file. It verifies the local checkout and hosted tag before
|
||||
creating or accepting a hosted release page. The full checked-in note is the
|
||||
hosted body. A retry accepts an identical page and matching attachments, adds
|
||||
only missing assets, and reports conflicts without replacing content.
|
||||
|
||||
For local delivery, save a Gitea personal access token in a git-ignored file such
|
||||
as `.releasekit/gitea-token` and configure:
|
||||
|
||||
```json
|
||||
{
|
||||
"product": "Example",
|
||||
"validation": {
|
||||
"forbidden_files": [".releasekit/gitea-token"]
|
||||
},
|
||||
"delivery": {
|
||||
"forge": "gitea",
|
||||
"base_url": "https://gitea.example",
|
||||
"owner": "example",
|
||||
"repository": "example",
|
||||
"token_file": ".releasekit/gitea-token"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Add `.releasekit/` to the consumer's ignore rules and use owner-only file
|
||||
permissions for the saved token. Then run `releasekit deliver v1.2.3` from the
|
||||
tagged checkout. CI can continue supplying the environment token. See the
|
||||
[configuration reference](../config.md) for precedence, path resolution, and the
|
||||
optional candidate check. The validator checks the selected committed paths;
|
||||
it does not scan for secrets in other files or past commits.
|
||||
For nested modules, the note comes from that module's committed
|
||||
`docs/releases/<version>.md`, and the hosted page is keyed by its complete tag.
|
||||
|
||||
@@ -104,8 +133,9 @@ directory must contain the private `.releasekit-build.json` receipt created by
|
||||
`build`. ReleaseKit checks its schema, repository, commit, version, resolved
|
||||
module directory and path, full tag, normalized build settings, and artifact
|
||||
declarations. Old receipts must be regenerated with the updated `build`
|
||||
command. Delivery verifies the artifact bytes and sizes before making a hosted
|
||||
request; caller-owned files remain available for retries.
|
||||
command. Delivery verifies the artifact bytes and sizes after read-only
|
||||
preflight and before any hosted write; caller-owned files remain available for
|
||||
retries.
|
||||
Ordinary `deliver` does not download application dependencies or change the
|
||||
checkout.
|
||||
|
||||
@@ -125,13 +155,58 @@ published content needs correction. If rebuilding could change bytes already
|
||||
on the server, retain the original build output and retry with `--assets DIR`.
|
||||
The command does not run candidate checks or publish tags.
|
||||
|
||||
## Credential preflight
|
||||
|
||||
Both `Deliver` and the CLI perform a read-only destination preflight after
|
||||
cheap local request and source checks. It happens before managed builds and
|
||||
full artifact size and digest verification. If it succeeds, delivery verifies
|
||||
the local artifact contents before making any hosted write. A failed preflight
|
||||
leaves delivery `not_attempted`, including when the service is unavailable.
|
||||
|
||||
Call `PreflightDelivery` directly when another caller-controlled operation
|
||||
should follow credential verification. The check needs no checkout or published
|
||||
tag and performs no test release or upload. For Gitea it verifies authenticated
|
||||
repository access; exposed metadata can identify known deficiencies, but
|
||||
successful preflight does not guarantee later release or asset writes. A
|
||||
`DeliveryPreflightError` identifies invalid destination settings, missing or
|
||||
rejected credentials, known insufficient permissions, an inaccessible
|
||||
destination, or unavailable verification. Its `Unwrap` method preserves the
|
||||
cause for `errors.Is` and
|
||||
`errors.As`. A 403 means access was denied, not necessarily that a token
|
||||
expired; a 404 can mean a missing repository or hidden access.
|
||||
|
||||
```go
|
||||
err := releasekit.PreflightDelivery(ctx, releasekit.DeliveryDestination{
|
||||
Forge: releasekit.ForgeGitea,
|
||||
BaseURL: "https://gitea.example",
|
||||
Owner: "example",
|
||||
Repository: "example",
|
||||
Token: token,
|
||||
})
|
||||
if err != nil {
|
||||
var preflight *releasekit.DeliveryPreflightError
|
||||
if errors.As(err, &preflight) {
|
||||
log.Printf("delivery preflight %s: %v", preflight.Kind, preflight.Err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
The CLI performs the same check automatically. For binary `ci-deliver`, it
|
||||
checks access before application dependency downloads, then `Deliver` checks
|
||||
again after preparation. Page-only CI needs no separate preflight call.
|
||||
`check`, `build`, and source `publish` remain independent of hosted delivery
|
||||
credentials. Calling preflight before source publication is optional and does
|
||||
not add a publication gate.
|
||||
|
||||
## Go API
|
||||
|
||||
Pass the Gitea credential explicitly. Set `Build` to a `BuildSpec` to compile
|
||||
selected targets as part of delivery. The facade verifies the local tag, clean
|
||||
checkout, and committed release note before compiling. Managed output is stored
|
||||
outside the checkout and removed when delivery returns. `Output` receives build
|
||||
progress. With no build, or with an empty target set, delivery is page-only.
|
||||
Pass the Gitea credential explicitly and select `ForgeGitea`. Set `Build` to a
|
||||
`BuildSpec` to compile selected targets as part of delivery. The facade verifies
|
||||
the local tag, clean checkout, and committed release note before compiling.
|
||||
Managed output is stored outside the checkout and removed when delivery
|
||||
returns. `Output` receives build progress. With no build, or with an empty
|
||||
target set, delivery is page-only.
|
||||
|
||||
To deliver binaries built separately, set `Prebuilt` to the `BuildResult`.
|
||||
ReleaseKit checks its repository, module directory, declared module path, full
|
||||
@@ -151,7 +226,8 @@ result, err := releasekit.Deliver(ctx, releasekit.DeliveryRequest{
|
||||
Product: "Example",
|
||||
Version: "v1.2.3",
|
||||
ExpectedTag: "integrations/example/v1.2.3", // optional CI event check
|
||||
Destination: releasekit.GiteaDestination{
|
||||
Destination: releasekit.DeliveryDestination{
|
||||
Forge: releasekit.ForgeGitea,
|
||||
BaseURL: "https://gitea.example",
|
||||
Owner: "example",
|
||||
Repository: "example",
|
||||
@@ -196,14 +272,16 @@ a `delivery` profile. Page-only and binary behavior both use this template; the
|
||||
profile's build target list selects whether application binaries are built and
|
||||
delivered.
|
||||
|
||||
Before running Go, the template fetches the explicit event tag, compares its
|
||||
peeled commit with the provider's full commit ID without regard to hex case,
|
||||
checks out that commit detached, and confirms the source tree is clean. It then
|
||||
copies the tagged `go.mod` and any `go.sum` to a temporary directory outside
|
||||
the checkout. `go list -m` confirms that the module selects ReleaseKit; Go uses
|
||||
Before running Go, the template fetches the explicit event tag and independently
|
||||
resolves both the tag and `CI_COMMIT_SHA` to source commits. The event ID may
|
||||
identify an annotated tag object or a commit. The template requires the resolved
|
||||
commits to match, checks out that commit detached, and confirms the source tree
|
||||
is clean. It then copies the tagged `go.mod` and any `go.sum` to a temporary
|
||||
directory outside the checkout. `go list -m` confirms that the module selects
|
||||
ReleaseKit; Go uses
|
||||
an alternate `-modfile` to prepare dependencies and build the selected
|
||||
`cmd/releasekit` package without changing the tagged module files. The template
|
||||
invokes that executable with `CI_WORKSPACE`, `CI_COMMIT_SHA`, and
|
||||
invokes that executable with `CI_WORKSPACE`, the resolved event commit, and
|
||||
`CI_COMMIT_TAG`. A cleanup trap removes the temporary metadata and executable.
|
||||
|
||||
Tool bootstrap and application artifact preparation are separate. The bootstrap
|
||||
@@ -242,11 +320,13 @@ and application artifact compilation retain their separate environments.
|
||||
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
|
||||
the template. A separately installed executable can be installed with
|
||||
`go install gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit@PIN`, then run
|
||||
with the same arguments:
|
||||
`go install go.maximumdirect.net/releasekit/cmd/releasekit@PIN`, then run
|
||||
with the same arguments. With the event object available in the CI checkout,
|
||||
resolve it to its source commit before invocation:
|
||||
|
||||
```sh
|
||||
releasekit ci-deliver --repo "$CI_WORKSPACE" --commit "$CI_COMMIT_SHA" "$CI_COMMIT_TAG"
|
||||
expected_commit=$(git -C "$CI_WORKSPACE" rev-parse --verify "${CI_COMMIT_SHA}^{commit}")
|
||||
releasekit ci-deliver --repo "$CI_WORKSPACE" --commit "$expected_commit" "$CI_COMMIT_TAG"
|
||||
```
|
||||
|
||||
The module-based workflow follows the ReleaseKit version selected by the tagged
|
||||
@@ -255,6 +335,12 @@ consumer module configuration when adopting a newer ReleaseKit version and
|
||||
verify it with a tag fixture. The command and profile behavior are documented
|
||||
in the [CLI contract](../cli.md) and [configuration reference](../config.md).
|
||||
|
||||
Update copied workflow YAML when adopting corrected examples. A dependency
|
||||
upgrade alone leaves that copy unchanged. An older tagged workflow that passes
|
||||
a raw annotated tag object ID still fails on retry. Recover delivery from the
|
||||
original tagged source with the resolved event commit through the command above,
|
||||
preserving the published tag and existing content.
|
||||
|
||||
Woodpecker runs delivery after the tag event, independently from the source
|
||||
publication result. Read its workflow status as delivery status: a delivery
|
||||
failure does not undo source publication. A failed, partial, or uncertain run
|
||||
@@ -276,7 +362,9 @@ describes these checks.
|
||||
|
||||
To isolate a public ReleaseKit download from application settings and the
|
||||
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.
|
||||
|
||||
```sh
|
||||
@@ -288,7 +376,7 @@ Go proxy and checksum database and retains its temporary directory for inspectio
|
||||
GOPRIVATE= GONOPROXY= GONOSUMDB= \
|
||||
GOPROXY=https://proxy.golang.org GOSUMDB=sum.golang.org \
|
||||
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:
|
||||
|
||||
```go
|
||||
import "gitea.maximumdirect.net/eric/ReleaseKit/buildinfo"
|
||||
import "go.maximumdirect.net/releasekit/buildinfo"
|
||||
|
||||
version := buildinfo.Version()
|
||||
```
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
# Gitea release pages
|
||||
|
||||
ReleaseKit's public `Deliver` operation and `deliver` command verify tagged
|
||||
source, find or create the selected module's release page, inspect existing assets, and
|
||||
upload missing assets through the internal Gitea client. Page-only and
|
||||
binary-producing profiles use the same delivery path. The
|
||||
ReleaseKit's public `Deliver` operation and `deliver` command select Gitea
|
||||
explicitly, verify tagged source, find or create the selected module's release
|
||||
page, inspect existing assets, and upload missing assets. Shared delivery code
|
||||
owns content comparison and recovery; the Gitea adapter owns the HTTP protocol.
|
||||
Page-only and binary-producing profiles use the same delivery path. The
|
||||
[consumer delivery guide](../consumers/delivery.md) covers caller workflows;
|
||||
this guide describes the Gitea protocol and its failure behavior.
|
||||
|
||||
@@ -13,6 +14,23 @@ the `Authorization: token …` request header and is not included in errors or
|
||||
results. The base URL must use HTTP or HTTPS and cannot contain URL credentials,
|
||||
a query, or a fragment.
|
||||
|
||||
Before a build or full local artifact verification, Gitea preflight makes two
|
||||
authenticated, read-only repository requests: `GET /api/v1/repos/{owner}/{repo}`
|
||||
resolves the repository, then `GET /api/v1/repositories/{id}` confirms access
|
||||
through the authenticated repository route. The response identity must match
|
||||
the configured owner and repository. This check also works without a checkout
|
||||
or a published tag and makes no release or asset writes.
|
||||
|
||||
A 401 response identifies a rejected credential. A 403 identifies denied
|
||||
access but does not establish that the credential is invalid. A 404 can mean a
|
||||
missing repository or access hidden by the service. Network failures, service
|
||||
errors, and malformed responses that prevent verification are reported as
|
||||
unavailable. Repository `permissions.push` describes Git code-push access; it
|
||||
does not establish permission to create release pages or upload assets. Gitea
|
||||
does not expose enough evidence through this probe to verify those write scopes,
|
||||
so they remain unverified and later writes remain authoritative. Preflight does
|
||||
not request unrelated user scopes or create a test release or asset.
|
||||
|
||||
The selected module derives a complete tag from its semantic version, declared
|
||||
module path, and physical repository-relative directory. For example, version
|
||||
`v1.2.3` in `integrations/ckconfig` uses
|
||||
@@ -27,34 +45,37 @@ The default shared validation convention uses repository-root `COPYRIGHT`,
|
||||
layout and release-note links. Use the complete derived tag as the immutable
|
||||
source ref and the actual repository-relative file paths in those links.
|
||||
Shared-file selection controls validation inputs; delivery preserves the
|
||||
committed note and its footer links. The client first reads
|
||||
committed note and its footer links. After successful preflight, the Gitea
|
||||
adapter reads
|
||||
`/api/v1/repos/{owner}/{repo}/tags/{tag}` and requires the hosted tag's commit
|
||||
SHA to equal the captured source commit. It then reads
|
||||
`/api/v1/repos/{owner}/{repo}/releases/tags/{tag}`. A matching existing page
|
||||
must have that tag, the complete committed release note as its body, and stable
|
||||
is accepted by shared delivery policy only when it has that tag, the complete
|
||||
committed release note as its body, and stable
|
||||
non-draft state. Its existing title is retained. ReleaseKit does not edit a
|
||||
conflicting page. Slash-containing tags are escaped as one URL path segment.
|
||||
|
||||
If the page is absent, the client posts to `/api/v1/repos/{owner}/{repo}/releases`
|
||||
with the selected full tag and commit, the release note's H1 as title, the complete
|
||||
note as body, and draft and prerelease disabled. A create conflict, interrupted
|
||||
response, or malformed create response (including inconsistent decoded fields)
|
||||
triggers one lookup. An exact page found
|
||||
there confirms the existing content; a differing page is a conflict. If that
|
||||
lookup cannot determine the result after a possibly successful write, the
|
||||
client returns an unknown outcome. Other HTTP failures retain their structured
|
||||
failure kind and status code. A recovered create retains its write history;
|
||||
a later asset-inspection failure reports partial page progress.
|
||||
If the page is absent, the adapter posts to
|
||||
`/api/v1/repos/{owner}/{repo}/releases` with the selected full tag and commit,
|
||||
the release note's H1 as title, the complete note as body, and draft and
|
||||
prerelease disabled. A create conflict, interrupted response, or malformed
|
||||
create response (including inconsistent decoded fields) triggers one lookup by
|
||||
the shared workflow. An exact page found there confirms the existing content;
|
||||
a differing page is a conflict. If that lookup cannot determine the result
|
||||
after a possibly successful write, delivery returns an unknown outcome. Other
|
||||
HTTP failures retain their structured failure kind and status code. A recovered
|
||||
create retains its write history; a later asset-inspection failure reports
|
||||
partial page progress.
|
||||
|
||||
For a binary-producing release, the client can build a deterministic
|
||||
For a binary-producing release, shared delivery builds a deterministic
|
||||
`SHA256SUMS` file from the declared artifact names and SHA-256 digests. Entries
|
||||
are sorted by filename and use the standard two-space separator. A source-only
|
||||
release produces no manifest.
|
||||
|
||||
Asset inspection lists the release attachments, streams each declared existing
|
||||
file, and compares its SHA-256 digest. It reports each asset as matching,
|
||||
missing, conflicting, or unverifiable. Same-name attachments with different
|
||||
bytes are conflicts even when their reported size matches. An existing
|
||||
The adapter lists release attachments and opens their download streams. Shared
|
||||
delivery compares each declared existing file by SHA-256 digest and reports it
|
||||
as matching, missing, conflicting, or unverifiable. Same-name attachments with
|
||||
different bytes are conflicts even when their reported size matches. An existing
|
||||
`SHA256SUMS` must exactly match the complete expected manifest; a subset or
|
||||
different manifest is a conflict. An unreadable attachment is unverifiable,
|
||||
not missing. A failed read-only inspection reports `not_attempted`; if this
|
||||
@@ -65,10 +86,10 @@ Attachment downloads and redirects carry the Gitea authorization token only
|
||||
when their scheme, host, and port match the configured service. Cross-origin
|
||||
content is downloaded without that token and verified by its digest.
|
||||
|
||||
The upload operation accepts only an inspection result without conflicts or
|
||||
Shared delivery uploads only after inspection finds no conflicts or
|
||||
unverifiable assets. It uploads missing binaries in filename order and then the
|
||||
missing `SHA256SUMS`, using multipart field `attachment` and the filename query
|
||||
parameter `name`. Matching attachments are retained; unrelated and existing
|
||||
missing `SHA256SUMS`, using the adapter's multipart field `attachment` and
|
||||
filename query parameter `name`. Matching attachments are retained; unrelated and existing
|
||||
attachments are never overwritten or deleted. Results use disjoint current
|
||||
observations: matching, uploaded, pending, conflicting, and uncertain. An asset
|
||||
moved from known missing work to another category is removed from `pending`;
|
||||
|
||||
@@ -24,7 +24,7 @@ Go declarations and GoDoc remain the owners of exact exported contracts.
|
||||
|
||||
## 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
|
||||
validation; public facades do not expose either dependency. `go list ./...`
|
||||
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/build` | Resolved-identity builds, normalized settings, selected-module compilation, version injection, external native smoke output, and artifact digests. |
|
||||
| `internal/candidate` | Stable-version, note, repository-state, candidate capture, and post-validation checks. |
|
||||
| `internal/delivery` | Captured-commit module identity and note selection, literal-tag checkout checks, prebuilt identity binding, managed builds in disposable storage outside the checkout, structural artifact declaration, manifest assembly, one local digest-and-size preflight before hosted requests, and delivery coordination. |
|
||||
| `internal/delivery` | Read-only backend preflight, captured-commit module identity and note selection, literal-tag checkout checks, prebuilt identity binding, managed builds in disposable storage outside the checkout, artifact declaration, manifest assembly, shared page and asset policy, streamed comparisons, write recovery, and delivery outcomes. |
|
||||
| `internal/forge` | Backend contract, neutral observations and failures, opaque handles, preflight classifications, and write evidence shared by delivery policy and adapters. |
|
||||
| `internal/git` | Git command boundary, committed-tree reads and shared reference traversal, checkout-root and commit capture, lazy local-only batched blob reads, push-destination resolution, exact ref inspection, tagged checkout preparation, and tag operations, including captured-endpoint verification during recovery. |
|
||||
| `internal/gitea` | Gitea API transport, full-tag hosted release pages, asset inspection, manifests, and uploads. |
|
||||
| `internal/gitea` | Gitea destination rules, authenticated repository preflight, HTTP operations and codecs, opaque handle interpretation, response validation, redirects, multipart streaming, and protocol-to-neutral failure conversion. |
|
||||
| `internal/gotool` | Operation-local Go configuration discovery and reuse, shared native-validation and static-build environments, and network-capable dependency preparation; see [Go command environments](gotool.md). |
|
||||
| `internal/tempdir` | Disposable system temporary storage outside source worktrees. |
|
||||
| `internal/process` | Cancellable child-process execution, including owned stdin/stdout pipes for serialized exchanges, with per-command environment overlays and removals. |
|
||||
@@ -74,11 +75,16 @@ it does not consult neighboring files or traverse submodule checkouts.
|
||||
See the [shared validation guide](validation.md) for checker, Go command, and
|
||||
candidate-file integration mechanics.
|
||||
|
||||
Delivery verifies all local binary sizes and digests before hosted requests.
|
||||
Its Gitea upload path reuses that verification and streams missing files once
|
||||
for transport. Independent calls to the internal client's `UploadMissing`
|
||||
verify missing local files before writing. Both paths share remote inspection,
|
||||
conflict handling, and reconciliation.
|
||||
The root facade selects the backend explicitly and passes a configured
|
||||
`internal/forge.Backend` into `internal/delivery`. Delivery performs cheap
|
||||
request and source checks, then read-only backend preflight before managed
|
||||
builds or full local artifact verification. Artifact size and digest
|
||||
verification completes before hosted writes and is reused for upload streams.
|
||||
Shared delivery code owns release-page matching and creation recovery, remote
|
||||
asset comparison, upload sequencing, and partial or uncertain outcomes.
|
||||
`internal/gitea` implements only the Gitea protocol operations behind the
|
||||
neutral contract. `internal/artifact` and `internal/forge` do not depend on the
|
||||
adapter.
|
||||
|
||||
## Capability owners and behavioral evidence
|
||||
|
||||
@@ -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` |
|
||||
| Publication partial and uncertain recovery | `internal/publish`, `internal/git` | `TestPublishRejectedPushKeepsLocalTagAndReportsNotPublished`, `TestPublishLostAcknowledgmentConfirmsExactRemoteObject`, `TestPublishLostAcknowledgmentWithChangedEndpointRemainsUnknown`, `TestPublishUnavailableReconciliationRemainsUnknownAndPreservesPushError` |
|
||||
| Cross-builds, version smoke checks, module paths, empty target sets, and partial outputs | `releasekit.Build`, `internal/build`, `internal/artifact`, `internal/tempdir`, `buildinfo` | `TestBuildCreatesSelectedTargetsAndInjectsVersion`, `TestBuildUsesNestedWorkingTreeModuleAndModuleRelativeOutput`, `TestBuildResolvesDirtyWorkingTreeDeclaration`, `TestBuildWithNoTargetsDoesNotInvokeCompiler`, `TestBuildReturnsPartialArtifactsAfterTargetFailure`, `TestNormalizeSettingsKeepsBuildInputsAndNormalizesTargets`, `TestOutsideFallsBackWhenTemporaryDirectoryIsInsideWorktree` |
|
||||
| Page-only and managed or prebuilt binary delivery | `releasekit.Deliver`, `internal/delivery`, `internal/gitea`, CLI | `TestDeliverPageOnlySkipsBuildAndHostedAssets`, `TestDeliverUsesCommittedModuleIdentityForSeparatePages`, `TestDeliverRejectsPrebuiltArtifactsForAnotherModuleBeforeHostedRequests`, `TestDeliverManagedBuildUploadsArtifactsAndCleansTemporaryOutput`, `TestDeliverBinaryConsumerWithBuildinfoAndSelectedTargets`, `TestDeliverPrebuiltArtifactsRemainCallerOwned`, `TestDeliverRejectsSameSizePrebuiltTamperingBeforeHostedWrites`, `TestDeliverManagedBuildFailureCleansTemporaryOutputBeforeHostedWrites`, `TestDeliverManagedBuildCancellationCleansTemporaryOutput`, `TestDeliverBuildsTaggedArtifactsAndReusesAssets`, `TestDeliverRejectsTamperedAssetsAndStaleReceiptBeforeRemoteWrites`, `TestEnsureReleaseEscapesSlashContainingTag` |
|
||||
| Explicit destination selection, standalone and automatic preflight, and delivery ordering | `releasekit`, `internal/delivery`, `internal/gitea`, CLI | `TestDeliveryForgeSelectionIsExplicit`, `TestPreflightDeliveryChecksAuthenticatedRepositoryWithoutCheckout`, `TestPreflightDeliveryPreservesPublicFailureKindsAndCauses`, `TestAutomaticPreflightFailurePrecedesPrebuiltArtifactHashing`, `TestDeliverUnavailablePreflightStopsBeforeManagedBuild`, `TestCIDeliverUnavailablePreflightStopsBeforeDependencyPreparation`, `TestCIDeliverPageOnlyReportsUnavailablePreflightWithStatusOne` |
|
||||
| Page-only and managed or prebuilt binary delivery | `releasekit.Deliver`, `internal/delivery`, `internal/gitea`, CLI | `TestDeliverPageOnlySkipsBuildAndHostedAssets`, `TestDeliverUsesCommittedModuleIdentityForSeparatePages`, `TestDeliverRejectsPrebuiltArtifactsForAnotherModuleBeforeHostedRequests`, `TestDeliverManagedBuildUploadsArtifactsAndCleansTemporaryOutput`, `TestDeliverBinaryConsumerWithBuildinfoAndSelectedTargets`, `TestDeliverPrebuiltArtifactsRemainCallerOwned`, `TestDeliverRejectsSameSizePrebuiltTamperingBeforeHostedWrites`, `TestDeliverManagedBuildFailureCleansTemporaryOutputBeforeHostedWrites`, `TestDeliverManagedBuildCancellationCleansTemporaryOutput`, `TestDeliverBuildsTaggedArtifactsAndReusesAssets`, `TestDeliverRejectsTamperedAssetsAndStaleReceiptBeforeRemoteWrites` |
|
||||
| Tagged checkout and build dependency preparation | `releasekit.PrepareTaggedCheckout`, `releasekit.PrepareBuildDependencies`, `internal/preparation`, `internal/git`, `internal/gotool` | `TestPrepareTaggedCheckoutFetchesExpectedCommitAndDetaches`, `TestPrepareTaggedCheckoutRejectsWrongCommitBeforeCheckout`, `TestPrepareTaggedCheckoutReportsLocalCheckoutConflict`, `TestPrepareTaggedCheckoutReportsConflictingLocalTag`, `TestPrepareBuildDependenciesUsesSelectedModuleDirectory`, `TestPrepareBuildDependenciesPreservesPreparationEnvironment`, `TestPrepareBuildDependenciesPropagatesCommandFailure`, `TestPrepareBuildDependenciesPropagatesCancellation` |
|
||||
| Shared module identity and tag derivation | `internal/releaseidentity`, `internal/candidate`, `internal/build`, `internal/delivery` | `TestDeriveUsesModuleDeclarationAndPhysicalLayout`, `TestCommittedAndWorkingTreeDeclarationsUseTheirOwnSources`, `TestSelectedNestedModuleDoesNotRequireRootDeclaration`, `TestBuildResolvesDirtyWorkingTreeDeclaration`, `TestDeliverUsesCommittedModuleIdentityForSeparatePages` |
|
||||
| Page, manifest, asset comparison, and additive retry outcomes | `internal/gitea` | `TestEnsureReleaseCreatesMissingPageFromCommittedNote`, `TestInspectAssetsDetectsSameNameAndSizeWithDifferentBytes`, `TestUploadMissingPartialFailureThenRerunOnlyMissingAssets` |
|
||||
| Shared page, manifest, asset comparison, and additive retry policy | `internal/delivery` | `TestCoordinateReleaseCreatesWithFullNoteAndVerifiesRequestedTitle`, `TestCoordinateReleaseReconcilesCreateOutcomes`, `TestInspectRemoteAssetsComparesContentAndPreservesUnrelatedAssets`, `TestUploadMissingAssetsReconcilesOnceAndKeepsLaterAssetsPending` |
|
||||
| CLI profiles and prepared Woodpecker sequences | `cmd/releasekit`, `cmd/releasekit/internal/cli`, `examples/woodpecker/`, `examples/multi-module/` | `TestLoadSettingsRejectsInvalidProfiles`, `TestWoodpeckerTagDeliveryTemplate`, `TestWoodpeckerTagDeliveryUsesTaggedModuleVersion`, `TestWoodpeckerSourceDeliveryTemplateSelfBootstraps`, `TestReleaseKitSourceDeliveryWorkflowFromTaggedCheckout`, `TestMultiModuleSelectorMapsSelectorsAndTags`, `TestMultiModuleLauncherUsesSelectedModuleAndProfile`, `TestMultiModuleWoodpeckerDelivery` |
|
||||
|
||||
Run the complete suite with `go test ./...`. The public API and import boundary
|
||||
|
||||
@@ -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
|
||||
and line numbers remain cached. Each declaration path receives findings from
|
||||
that summary in its own path context.
|
||||
The optional forbidden-file check uses the captured tree's full path lookup
|
||||
before the other file checks. It normalizes the explicitly selected
|
||||
repository-relative names and reports matching entries through `forbidden-files`
|
||||
without loading their blobs. Candidate-tree cleanup covers this failure path.
|
||||
The optional file-size check applies normalized literal file/directory
|
||||
exclusions with path-boundary matching, then requests local Git sizes for the
|
||||
remaining selected blobs. Sizes include symlink link-text bytes; gitlinks are
|
||||
|
||||
@@ -77,6 +77,17 @@ and orchestration bookkeeping belong behind that boundary. Additional public
|
||||
packages and extension interfaces require a deliberate decision grounded in a
|
||||
durable consumer need.
|
||||
|
||||
Hosted delivery follows a neutral internal contract. The root facade selects a
|
||||
backend explicitly and constructs it; `internal/forge` defines the backend
|
||||
operations and neutral observations, failures, and write evidence;
|
||||
`internal/delivery` owns shared page and asset policy; and `internal/gitea`
|
||||
adapts the Gitea protocol. `internal/delivery`, `internal/forge`, and
|
||||
`internal/artifact` must not depend on the Gitea adapter. Artifact declaration
|
||||
and local content verification remain independent of delivery policy and forge
|
||||
protocols. The compiled dependency check protects this transitive boundary.
|
||||
See [ADR 0006](../adr/0006-neutral-forge-delivery-and-preflight.md) for the
|
||||
selected delivery and preflight design.
|
||||
|
||||
Go declarations and GoDoc own exact API contracts. Compiled-export checks,
|
||||
dependency checks, and external-package behavioral tests protect the selected
|
||||
facade boundaries.
|
||||
|
||||
@@ -5,14 +5,25 @@ the complete checked-in release note. The page is delivered asynchronously by
|
||||
the prepared [Woodpecker workflow](../examples/woodpecker/releasekit-source-delivery.yml);
|
||||
it is not a source-release completion check and carries no binary assets. The
|
||||
authoritative repository is `origin` at
|
||||
`https://gitea.maximumdirect.net/eric/ReleaseKit.git`, and the release branch
|
||||
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
|
||||
succeeded. CI and consumer installation are not completion checks.
|
||||
|
||||
## 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
|
||||
executable, and candidate validation:
|
||||
|
||||
@@ -43,9 +54,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:
|
||||
|
||||
```markdown
|
||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/vX.Y.Z/COPYRIGHT) |
|
||||
[LICENSE](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/vX.Y.Z/LICENSE) |
|
||||
[ATTRIBUTION](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/vX.Y.Z/docs/oss/attribution.md)
|
||||
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/vX.Y.Z/COPYRIGHT) |
|
||||
[LICENSE](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/vX.Y.Z/LICENSE) |
|
||||
[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
|
||||
@@ -148,7 +159,15 @@ git checkout --detach "vX.Y.Z"
|
||||
GOTOOLCHAIN=local go mod download
|
||||
mkdir -p .releasekit/bin
|
||||
GOTOOLCHAIN=local GOPROXY=off GOSUMDB=off go build -o .releasekit/bin/releasekit ./cmd/releasekit
|
||||
RELEASEKIT_GITEA_TOKEN="$GITEA_TOKEN" .releasekit/bin/releasekit deliver "vX.Y.Z"
|
||||
.releasekit/bin/releasekit deliver "vX.Y.Z"
|
||||
```
|
||||
|
||||
For an older tag whose CLI predates `token_file`, supply the token through its
|
||||
environment variable when retrying that tagged tool:
|
||||
|
||||
```sh
|
||||
RELEASEKIT_GITEA_TOKEN="$(cat .releasekit/gitea-token)" \
|
||||
.releasekit/bin/releasekit deliver "vX.Y.Z"
|
||||
```
|
||||
|
||||
The root ignore file excludes `.releasekit/`. Delivery uses the committed note
|
||||
|
||||
29
docs/releases/v0.4.1.md
Normal file
29
docs/releases/v0.4.1.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# ReleaseKit v0.4.1
|
||||
|
||||
## Summary
|
||||
|
||||
This patch synchronizes shared state in the delivery and Gitea HTTP test
|
||||
fixtures. It makes the test suite reliable under concurrent request handling
|
||||
and the Go race detector. Production code and public behavior are unchanged.
|
||||
|
||||
## Changes
|
||||
|
||||
- Protect delivery fixture state with mutexes and independent snapshots; use
|
||||
atomic observations for independent Gitea fixture counters and flags.
|
||||
- Keep fatal test assertions in the test goroutine and ensure release test
|
||||
servers close with their owning tests.
|
||||
- Preserve existing delivery, conflict, retry, and source-tag assertions while
|
||||
exercising the synchronized fixtures.
|
||||
|
||||
## Compatibility
|
||||
|
||||
There are no API, configuration, or runtime behavior changes, and no consumer
|
||||
migration is required. The Go minimum remains 1.26.0. The published v0.4.0 tag
|
||||
and its contents are unchanged.
|
||||
|
||||
Consumers may upgrade to v0.4.1 with their existing API, profile, and workflow
|
||||
configuration.
|
||||
|
||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.4.1/COPYRIGHT) |
|
||||
[LICENSE](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.4.1/LICENSE) |
|
||||
[ATTRIBUTION](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.4.1/docs/oss/attribution.md)
|
||||
36
docs/releases/v0.4.2.md
Normal file
36
docs/releases/v0.4.2.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# ReleaseKit v0.4.2
|
||||
|
||||
## Summary
|
||||
|
||||
This patch corrects Woodpecker delivery bootstrap for events whose
|
||||
`CI_COMMIT_SHA` identifies an annotated tag object. The maintained workflows
|
||||
resolve the event object to its source commit before verification and delivery.
|
||||
|
||||
## Changes
|
||||
|
||||
- Correct module-selected, multi-module, and ReleaseKit source delivery
|
||||
workflows to resolve event and tag commits independently and pass the
|
||||
resolved event commit to `ci-deliver`.
|
||||
- Report both commit identities when an event and its selected tag disagree.
|
||||
- Extend executable-workflow regression coverage for annotated tag-object
|
||||
events, annotated and lightweight commit events, resolved delivery arguments,
|
||||
and rejection of unrelated commits.
|
||||
|
||||
## Compatibility and upgrade guidance
|
||||
|
||||
Public APIs, CLI contracts, configuration formats, and the Go 1.26.0 consumer
|
||||
minimum retain their v0.4 definitions. `ci-deliver --commit` continues to accept
|
||||
the expected source commit ID.
|
||||
|
||||
Update copied workflow YAML from the maintained examples when adopting this
|
||||
patch. Upgrading the ReleaseKit dependency alone leaves copied workflows
|
||||
unchanged. The [delivery guide](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.4.2/docs/consumers/delivery.md#woodpecker-tag-workflows)
|
||||
describes event resolution, workflow adoption, and delivery recovery.
|
||||
|
||||
Existing published tags and contents remain unchanged. Recover a failed older
|
||||
workflow from its original tagged source using the resolved event commit through
|
||||
the documented delivery command, preserving existing hosted content.
|
||||
|
||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.4.2/COPYRIGHT) |
|
||||
[LICENSE](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.4.2/LICENSE) |
|
||||
[ATTRIBUTION](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.4.2/docs/oss/attribution.md)
|
||||
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)
|
||||
62
docs/releases/v0.6.0.md
Normal file
62
docs/releases/v0.6.0.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# ReleaseKit v0.6.0
|
||||
|
||||
## Summary
|
||||
|
||||
ReleaseKit adds neutral delivery coordination behind an explicit Gitea backend
|
||||
selection and introduces authenticated, read-only credential preflight.
|
||||
|
||||
## Changes
|
||||
|
||||
- `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.
|
||||
|
||||
## Upgrade guidance
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
The module files are setup skeletons. Set `RELEASEKIT_TOOL_VERSION` to a released
|
||||
ReleaseKit version containing module-release support, then register that tool in
|
||||
each consumer module before running the launcher or workflow:
|
||||
ReleaseKit version at or after the v0.5.0 vanity-path migration, then register
|
||||
that tool in each consumer module before running the launcher or workflow:
|
||||
|
||||
```sh
|
||||
: "${RELEASEKIT_TOOL_VERSION:?Select a ReleaseKit release with module-release support}"
|
||||
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
|
||||
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.
|
||||
The `core-v2` profile demonstrates binary delivery; the other profiles are
|
||||
page-only. Before enabling the workflow, configure each selected profile's
|
||||
`delivery` destination and add the Woodpecker repository secret
|
||||
`delivery` object with `"forge": "gitea"`, its `base_url`, owner, and repository,
|
||||
then add the Woodpecker repository secret
|
||||
`releasekit_gitea_token`; the workflow exposes it as
|
||||
`RELEASEKIT_GITEA_TOKEN` only in the delivery step.
|
||||
|
||||
@@ -108,9 +109,9 @@ modules:
|
||||
: "${RELEASEKIT_SOURCE:?Set the absolute ReleaseKit checkout path}"
|
||||
for module_dir in . integrations/ckconfig integrations/core/v2; do
|
||||
go -C "$module_dir" mod edit \
|
||||
-require=gitea.maximumdirect.net/eric/ReleaseKit@v0.0.0 \
|
||||
-replace="gitea.maximumdirect.net/eric/ReleaseKit=$RELEASEKIT_SOURCE" \
|
||||
-tool=gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit
|
||||
-require=go.maximumdirect.net/releasekit@v0.0.0 \
|
||||
-replace="go.maximumdirect.net/releasekit=$RELEASEKIT_SOURCE" \
|
||||
-tool=go.maximumdirect.net/releasekit/cmd/releasekit
|
||||
GOTOOLCHAIN=local GOWORK=off go -C "$module_dir" mod tidy
|
||||
done
|
||||
```
|
||||
|
||||
@@ -3,7 +3,7 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo"
|
||||
"go.maximumdirect.net/releasekit/buildinfo"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
@@ -31,7 +31,7 @@ else
|
||||
: > "$bootstrap_dir/releasekit.sum"
|
||||
fi
|
||||
|
||||
releasekit_module=gitea.maximumdirect.net/eric/ReleaseKit
|
||||
releasekit_module=go.maximumdirect.net/releasekit
|
||||
(
|
||||
cd "$module_path"
|
||||
export GOWORK=off
|
||||
|
||||
@@ -8,7 +8,7 @@ when:
|
||||
|
||||
steps:
|
||||
- name: deliver
|
||||
image: golang:1.26.8-bookworm
|
||||
image: golang:1.26.10-bookworm
|
||||
environment:
|
||||
RELEASEKIT_GITEA_TOKEN:
|
||||
from_secret: releasekit_gitea_token
|
||||
@@ -20,8 +20,12 @@ steps:
|
||||
git check-ref-format "refs/tags/$${CI_COMMIT_TAG}"
|
||||
git fetch origin "refs/tags/$${CI_COMMIT_TAG}:refs/tags/$${CI_COMMIT_TAG}"
|
||||
tag_commit=$(git rev-parse --verify "refs/tags/$${CI_COMMIT_TAG}^{commit}")
|
||||
expected_commit=$(printf '%s' "$${CI_COMMIT_SHA}" | tr '[:upper:]' '[:lower:]')
|
||||
test "$(printf '%s' "$${tag_commit}" | tr '[:upper:]' '[:lower:]')" = "$${expected_commit}"
|
||||
expected_commit=$(git rev-parse --verify "$${CI_COMMIT_SHA}^{commit}")
|
||||
if [ "$${tag_commit}" != "$${expected_commit}" ]; then
|
||||
printf 'Tag %s resolves to commit %s; event %s resolves to commit %s\n' \
|
||||
"$${CI_COMMIT_TAG}" "$${tag_commit}" "$${CI_COMMIT_SHA}" "$${expected_commit}" >&2
|
||||
exit 1
|
||||
fi
|
||||
git checkout --detach "$${tag_commit}"
|
||||
test "$(git rev-parse HEAD | tr '[:upper:]' '[:lower:]')" = "$${expected_commit}"
|
||||
test -z "$(git status --porcelain --untracked-files=all)"
|
||||
@@ -47,7 +51,7 @@ steps:
|
||||
|
||||
export GOWORK=off
|
||||
export GOTOOLCHAIN=local
|
||||
releasekit_module=gitea.maximumdirect.net/eric/ReleaseKit
|
||||
releasekit_module=go.maximumdirect.net/releasekit
|
||||
(
|
||||
cd "$${module_path}"
|
||||
go list -mod=mod -modfile="$${bootstrap_dir}/releasekit.mod" -m "$${releasekit_module}"
|
||||
@@ -59,5 +63,5 @@ steps:
|
||||
--config "$${profile_path}" \
|
||||
--expected-module "$${module_dir}" \
|
||||
--prepared \
|
||||
--commit "$${CI_COMMIT_SHA}" \
|
||||
--commit "$${expected_commit}" \
|
||||
"$${CI_COMMIT_TAG}"
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"targets": ["linux/amd64", "linux/arm64", "darwin/arm64"]
|
||||
},
|
||||
"delivery": {
|
||||
"forge": "gitea",
|
||||
"base_url": "https://gitea.example",
|
||||
"owner": "example",
|
||||
"repository": "example",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"product": "Example",
|
||||
"remote": "origin",
|
||||
"delivery": {
|
||||
"forge": "gitea",
|
||||
"base_url": "https://gitea.example",
|
||||
"owner": "example",
|
||||
"repository": "example",
|
||||
|
||||
@@ -4,7 +4,7 @@ when:
|
||||
|
||||
steps:
|
||||
- name: deliver
|
||||
image: golang:1.26.8-bookworm
|
||||
image: golang:1.26.10-bookworm
|
||||
environment:
|
||||
RELEASEKIT_GITEA_TOKEN:
|
||||
from_secret: releasekit_gitea_token
|
||||
@@ -15,9 +15,13 @@ steps:
|
||||
test -n "$${CI_COMMIT_SHA}"
|
||||
git check-ref-format "refs/tags/$${CI_COMMIT_TAG}"
|
||||
git fetch origin "refs/tags/$${CI_COMMIT_TAG}:refs/tags/$${CI_COMMIT_TAG}"
|
||||
tag_commit=$(git rev-parse "refs/tags/$${CI_COMMIT_TAG}^{commit}")
|
||||
expected_commit=$(printf '%s' "$${CI_COMMIT_SHA}" | tr '[:upper:]' '[:lower:]')
|
||||
test "$(printf '%s' "$${tag_commit}" | tr '[:upper:]' '[:lower:]')" = "$${expected_commit}"
|
||||
tag_commit=$(git rev-parse --verify "refs/tags/$${CI_COMMIT_TAG}^{commit}")
|
||||
expected_commit=$(git rev-parse --verify "$${CI_COMMIT_SHA}^{commit}")
|
||||
if [ "$${tag_commit}" != "$${expected_commit}" ]; then
|
||||
printf 'Tag %s resolves to commit %s; event %s resolves to commit %s\n' \
|
||||
"$${CI_COMMIT_TAG}" "$${tag_commit}" "$${CI_COMMIT_SHA}" "$${expected_commit}" >&2
|
||||
exit 1
|
||||
fi
|
||||
git checkout --detach "$${tag_commit}"
|
||||
test "$(git rev-parse HEAD | tr '[:upper:]' '[:lower:]')" = "$${expected_commit}"
|
||||
test -z "$(git status --porcelain --untracked-files=all)"
|
||||
@@ -30,5 +34,5 @@ steps:
|
||||
"$${bootstrap_dir}/releasekit" ci-deliver \
|
||||
--repo "$${CI_WORKSPACE}" \
|
||||
--prepared \
|
||||
--commit "$${CI_COMMIT_SHA}" \
|
||||
--commit "$${expected_commit}" \
|
||||
"$${CI_COMMIT_TAG}"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
matrix:
|
||||
include:
|
||||
- GO_IMAGE: golang:1.26.8-bookworm
|
||||
- GO_IMAGE: golang:1.26.10-bookworm
|
||||
|
||||
when:
|
||||
- event: tag
|
||||
@@ -19,9 +19,13 @@ steps:
|
||||
test -n "$${CI_COMMIT_SHA}"
|
||||
git check-ref-format "refs/tags/$${CI_COMMIT_TAG}"
|
||||
git fetch origin "refs/tags/$${CI_COMMIT_TAG}:refs/tags/$${CI_COMMIT_TAG}"
|
||||
tag_commit=$(git rev-parse "refs/tags/$${CI_COMMIT_TAG}^{commit}")
|
||||
expected_commit=$(printf '%s' "$${CI_COMMIT_SHA}" | tr '[:upper:]' '[:lower:]')
|
||||
test "$(printf '%s' "$${tag_commit}" | tr '[:upper:]' '[:lower:]')" = "$${expected_commit}"
|
||||
tag_commit=$(git rev-parse --verify "refs/tags/$${CI_COMMIT_TAG}^{commit}")
|
||||
expected_commit=$(git rev-parse --verify "$${CI_COMMIT_SHA}^{commit}")
|
||||
if [ "$${tag_commit}" != "$${expected_commit}" ]; then
|
||||
printf 'Tag %s resolves to commit %s; event %s resolves to commit %s\n' \
|
||||
"$${CI_COMMIT_TAG}" "$${tag_commit}" "$${CI_COMMIT_SHA}" "$${expected_commit}" >&2
|
||||
exit 1
|
||||
fi
|
||||
git checkout --detach "$${tag_commit}"
|
||||
test "$(git rev-parse HEAD | tr '[:upper:]' '[:lower:]')" = "$${expected_commit}"
|
||||
test -z "$(git status --porcelain --untracked-files=all)"
|
||||
@@ -39,12 +43,12 @@ steps:
|
||||
|
||||
export GOWORK=off
|
||||
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 build -mod=mod -modfile="$${bootstrap_dir}/releasekit.mod" \
|
||||
-o "$${bootstrap_dir}/releasekit" "$${releasekit_module}/cmd/releasekit"
|
||||
"$${bootstrap_dir}/releasekit" ci-deliver \
|
||||
--repo "$${CI_WORKSPACE}" \
|
||||
--prepared \
|
||||
--commit "$${CI_COMMIT_SHA}" \
|
||||
--commit "$${expected_commit}" \
|
||||
"$${CI_COMMIT_TAG}"
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
const modulePath = "gitea.maximumdirect.net/eric/ReleaseKit"
|
||||
const modulePath = "go.maximumdirect.net/releasekit"
|
||||
|
||||
var facades = []string{modulePath, modulePath + "/buildinfo"}
|
||||
|
||||
@@ -226,6 +226,33 @@ func checkImportDirections(packages []listedPackage) error {
|
||||
}
|
||||
visit(modulePath + "/buildinfo")
|
||||
|
||||
for _, root := range []string{
|
||||
modulePath + "/internal/delivery",
|
||||
modulePath + "/internal/forge",
|
||||
modulePath + "/internal/artifact",
|
||||
} {
|
||||
seen := make(map[string]bool)
|
||||
var visitDelivery func(string)
|
||||
visitDelivery = func(path string) {
|
||||
if seen[path] {
|
||||
return
|
||||
}
|
||||
seen[path] = true
|
||||
if path == modulePath+"/internal/gitea" {
|
||||
violations = append(violations, fmt.Sprintf("shared package %s depends on Gitea adapter", root))
|
||||
return
|
||||
}
|
||||
pkg, ok := byPath[path]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for _, imported := range pkg.Imports {
|
||||
visitDelivery(imported)
|
||||
}
|
||||
}
|
||||
visitDelivery(root)
|
||||
}
|
||||
|
||||
if len(violations) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -254,7 +281,7 @@ func commandInternalRoot(path string) string {
|
||||
func forbiddenRuntimeDependency(path string) bool {
|
||||
for _, part := range strings.Split(strings.ToLower(path), "/") {
|
||||
switch part {
|
||||
case "orchestration", "orchestrator", "engine", "release", "process", "build", "builder", "gitea", "delivery", "cli":
|
||||
case "orchestration", "orchestrator", "engine", "release", "process", "build", "builder", "gitea", "forge", "delivery", "cli":
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,26 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRuntimeFacadeCannotDependOnForge(t *testing.T) {
|
||||
for _, indirect := range []bool{false, true} {
|
||||
name := "direct"
|
||||
dependency := modulePath + "/internal/forge"
|
||||
packages := []listedPackage{{ImportPath: dependency}}
|
||||
if indirect {
|
||||
name = "transitive"
|
||||
packages = append(packages, listedPackage{ImportPath: modulePath + "/internal/runtime", Imports: []string{dependency}})
|
||||
dependency = modulePath + "/internal/runtime"
|
||||
}
|
||||
packages = append(packages, listedPackage{ImportPath: modulePath + "/buildinfo", Imports: []string{dependency}})
|
||||
t.Run(name, func(t *testing.T) {
|
||||
err := checkImportDirections(packages)
|
||||
if err == nil || !strings.Contains(err.Error(), "buildinfo") || !strings.Contains(err.Error(), "/internal/forge") {
|
||||
t.Fatalf("dependency check = %v, want excluded forge runtime dependency", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportedContractBoundaries(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -162,6 +182,30 @@ func TestRuntimeFacadeCannotDependOnExternalPackages(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedDeliveryPackagesCannotDependOnGitea(t *testing.T) {
|
||||
allowed := []listedPackage{
|
||||
{ImportPath: modulePath + "/internal/delivery", Imports: []string{modulePath + "/internal/forge", modulePath + "/internal/artifact"}},
|
||||
{ImportPath: modulePath + "/internal/forge"},
|
||||
{ImportPath: modulePath + "/internal/artifact"},
|
||||
{ImportPath: modulePath + "/internal/gitea"},
|
||||
}
|
||||
if err := checkImportDirections(allowed); err != nil {
|
||||
t.Fatalf("shared delivery boundary rejected neutral dependencies: %v", err)
|
||||
}
|
||||
|
||||
violating := []listedPackage{
|
||||
{ImportPath: modulePath + "/internal/delivery", Imports: []string{modulePath + "/internal/policy"}},
|
||||
{ImportPath: modulePath + "/internal/policy", Imports: []string{modulePath + "/internal/gitea"}},
|
||||
{ImportPath: modulePath + "/internal/forge"},
|
||||
{ImportPath: modulePath + "/internal/artifact"},
|
||||
{ImportPath: modulePath + "/internal/gitea"},
|
||||
}
|
||||
err := checkImportDirections(violating)
|
||||
if err == nil || !strings.Contains(err.Error(), "shared package "+modulePath+"/internal/delivery depends on Gitea adapter") {
|
||||
t.Fatalf("checkImportDirections() error = %v, want transitive shared-delivery violation", err)
|
||||
}
|
||||
}
|
||||
|
||||
func checkFixture(t *testing.T, facadeSource string) []string {
|
||||
t.Helper()
|
||||
fset := token.NewFileSet()
|
||||
|
||||
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"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/tempdir"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
|
||||
"go.maximumdirect.net/releasekit/internal/artifact"
|
||||
"go.maximumdirect.net/releasekit/internal/candidate"
|
||||
"go.maximumdirect.net/releasekit/internal/gotool"
|
||||
"go.maximumdirect.net/releasekit/internal/releaseidentity"
|
||||
"go.maximumdirect.net/releasekit/internal/tempdir"
|
||||
"go.maximumdirect.net/releasekit/internal/versioncontract"
|
||||
)
|
||||
|
||||
type Target = artifact.Target
|
||||
|
||||
@@ -7,10 +7,10 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/tempdir"
|
||||
"go.maximumdirect.net/releasekit/internal/candidate"
|
||||
"go.maximumdirect.net/releasekit/internal/gotool"
|
||||
"go.maximumdirect.net/releasekit/internal/releaseidentity"
|
||||
"go.maximumdirect.net/releasekit/internal/tempdir"
|
||||
)
|
||||
|
||||
func CandidateCallback(spec *Spec, output io.Writer) func(context.Context, candidate.Candidate, *gotool.Owner) error {
|
||||
|
||||
@@ -10,12 +10,12 @@ import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/process"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/validation"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
|
||||
"go.maximumdirect.net/releasekit/internal/git"
|
||||
"go.maximumdirect.net/releasekit/internal/gotool"
|
||||
"go.maximumdirect.net/releasekit/internal/process"
|
||||
"go.maximumdirect.net/releasekit/internal/releaseidentity"
|
||||
"go.maximumdirect.net/releasekit/internal/validation"
|
||||
"go.maximumdirect.net/releasekit/internal/versioncontract"
|
||||
)
|
||||
|
||||
var ErrInvalidVersion = errors.New("invalid stable version")
|
||||
|
||||
@@ -13,8 +13,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/validation"
|
||||
"go.maximumdirect.net/releasekit/internal/gotool"
|
||||
"go.maximumdirect.net/releasekit/internal/validation"
|
||||
)
|
||||
|
||||
const testVersion = "v1.2.3"
|
||||
|
||||
@@ -5,12 +5,11 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact"
|
||||
internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea"
|
||||
"go.maximumdirect.net/releasekit/internal/artifact"
|
||||
internalbuild "go.maximumdirect.net/releasekit/internal/build"
|
||||
)
|
||||
|
||||
func declaredBuildAssets(result internalbuild.Result) ([]gitea.ExpectedAsset, error) {
|
||||
func declaredBuildAssets(result internalbuild.Result) ([]artifact.ExpectedAsset, error) {
|
||||
settings := result.Settings
|
||||
if len(settings.Targets) == 0 {
|
||||
if len(result.Artifacts) != 0 {
|
||||
@@ -33,32 +32,32 @@ func declaredBuildAssets(result internalbuild.Result) ([]gitea.ExpectedAsset, er
|
||||
}
|
||||
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))
|
||||
for _, artifact := range result.Artifacts {
|
||||
wantName, ok := wantByTarget[artifact.Target]
|
||||
if !ok || seenTargets[artifact.Target] || artifact.Filename != wantName {
|
||||
return nil, fmt.Errorf("build artifact %q does not match a selected target and its declared name", artifact.Filename)
|
||||
for _, builtArtifact := range result.Artifacts {
|
||||
wantName, ok := wantByTarget[builtArtifact.Target]
|
||||
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", builtArtifact.Filename)
|
||||
}
|
||||
seenTargets[artifact.Target] = true
|
||||
if filepath.Base(artifact.Path) != artifact.Filename {
|
||||
return nil, fmt.Errorf("build artifact path %q does not end with filename %q", artifact.Path, artifact.Filename)
|
||||
seenTargets[builtArtifact.Target] = true
|
||||
if filepath.Base(builtArtifact.Path) != builtArtifact.Filename {
|
||||
return nil, fmt.Errorf("build artifact path %q does not end with filename %q", builtArtifact.Path, builtArtifact.Filename)
|
||||
}
|
||||
info, err := os.Stat(artifact.Path)
|
||||
info, err := os.Stat(builtArtifact.Path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("inspect build artifact %q: %w", artifact.Filename, err)
|
||||
return nil, fmt.Errorf("inspect build artifact %q: %w", builtArtifact.Filename, err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("build artifact %q is not a regular file", artifact.Filename)
|
||||
return nil, fmt.Errorf("build artifact %q is not a regular file", builtArtifact.Filename)
|
||||
}
|
||||
assets = append(assets, gitea.ExpectedAsset{
|
||||
Filename: artifact.Filename,
|
||||
SHA256: artifact.SHA256,
|
||||
assets = append(assets, artifact.ExpectedAsset{
|
||||
Filename: builtArtifact.Filename,
|
||||
SHA256: builtArtifact.SHA256,
|
||||
Size: info.Size(),
|
||||
Path: artifact.Path,
|
||||
Path: builtArtifact.Path,
|
||||
})
|
||||
}
|
||||
manifest, err := gitea.BuildManifest(assets)
|
||||
manifest, err := artifact.BuildManifest(assets)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build checksum manifest: %w", err)
|
||||
}
|
||||
|
||||
@@ -8,9 +8,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact"
|
||||
internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea"
|
||||
"go.maximumdirect.net/releasekit/internal/artifact"
|
||||
internalbuild "go.maximumdirect.net/releasekit/internal/build"
|
||||
)
|
||||
|
||||
func TestDeclaredBuildAssetsGenerateManifestFromBuildResult(t *testing.T) {
|
||||
@@ -38,7 +37,7 @@ func TestDeclaredBuildAssetsGenerateManifestFromBuildResult(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result) != 3 || result[0].Filename != filepath.Base(firstPath) || result[1].Filename != filepath.Base(secondPath) || result[2].Filename != gitea.ManifestFilename {
|
||||
if len(result) != 3 || result[0].Filename != filepath.Base(firstPath) || result[1].Filename != filepath.Base(secondPath) || result[2].Filename != artifact.ManifestFilename {
|
||||
t.Fatalf("assets = %#v", result)
|
||||
}
|
||||
want := assetDigest(second) + " app-v1.2.3-darwin-arm64\n" + assetDigest(first) + " app-v1.2.3-linux-amd64\n"
|
||||
|
||||
@@ -9,14 +9,15 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/tempdir"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
|
||||
"go.maximumdirect.net/releasekit/internal/artifact"
|
||||
internalbuild "go.maximumdirect.net/releasekit/internal/build"
|
||||
"go.maximumdirect.net/releasekit/internal/candidate"
|
||||
"go.maximumdirect.net/releasekit/internal/forge"
|
||||
"go.maximumdirect.net/releasekit/internal/git"
|
||||
"go.maximumdirect.net/releasekit/internal/gotool"
|
||||
"go.maximumdirect.net/releasekit/internal/releaseidentity"
|
||||
"go.maximumdirect.net/releasekit/internal/tempdir"
|
||||
"go.maximumdirect.net/releasekit/internal/versioncontract"
|
||||
)
|
||||
|
||||
type State string
|
||||
@@ -35,7 +36,7 @@ type Request struct {
|
||||
Product string
|
||||
Version string
|
||||
ExpectedTag string
|
||||
Destination gitea.Destination
|
||||
Backend forge.Backend
|
||||
Build *internalbuild.Spec
|
||||
Prebuilt *internalbuild.Result
|
||||
Output io.Writer
|
||||
@@ -49,11 +50,11 @@ type Result struct {
|
||||
CandidateCommit string
|
||||
ReleaseURL string
|
||||
State State
|
||||
Matching []gitea.AssetIdentity
|
||||
Uploaded []gitea.AssetIdentity
|
||||
Pending []gitea.AssetIdentity
|
||||
Conflicting []gitea.AssetIdentity
|
||||
Uncertain []gitea.AssetIdentity
|
||||
Matching []AssetIdentity
|
||||
Uploaded []AssetIdentity
|
||||
Pending []AssetIdentity
|
||||
Conflicting []AssetIdentity
|
||||
Uncertain []AssetIdentity
|
||||
}
|
||||
|
||||
func Deliver(ctx context.Context, request Request) (Result, error) {
|
||||
@@ -67,6 +68,9 @@ func Deliver(ctx context.Context, request Request) (Result, error) {
|
||||
if request.Build != nil && request.Prebuilt != nil {
|
||||
return result, errors.New("delivery cannot use both a managed build and prebuilt artifacts")
|
||||
}
|
||||
if request.Backend == nil {
|
||||
return result, errors.New("delivery backend is required")
|
||||
}
|
||||
repositoryDir, err := git.RepositoryRoot(ctx, request.RepositoryDir)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("resolve repository directory: %w", err)
|
||||
@@ -111,6 +115,14 @@ func Deliver(ctx context.Context, request Request) (Result, error) {
|
||||
if err := candidate.ValidateReleaseNote(note, request.Product, request.Version); err != nil {
|
||||
return result, fmt.Errorf("release note %q: %w", identity.NotePath, err)
|
||||
}
|
||||
if request.Prebuilt != nil {
|
||||
if err := validateBuildIdentity(identity, commit, request.Prebuilt); err != nil {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
if err := request.Backend.Preflight(ctx); err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
build := request.Prebuilt
|
||||
if request.Build != nil && len(request.Build.Targets) != 0 {
|
||||
@@ -131,7 +143,7 @@ func Deliver(ctx context.Context, request Request) (Result, error) {
|
||||
}
|
||||
build = &built
|
||||
}
|
||||
var assets []gitea.ExpectedAsset
|
||||
var assets []artifact.ExpectedAsset
|
||||
if build != nil {
|
||||
assets, err = declaredBuildAssets(*build)
|
||||
if err != nil {
|
||||
@@ -142,8 +154,12 @@ func Deliver(ctx context.Context, request Request) (Result, error) {
|
||||
return result, err
|
||||
}
|
||||
if len(assets) > 0 {
|
||||
if err := gitea.VerifyLocalAssets(assets); err != nil {
|
||||
return result, fmt.Errorf("verify build assets: %w", err)
|
||||
if err := artifact.VerifyLocalAssets(assets); err != nil {
|
||||
kind := forge.FailureInvalid
|
||||
if errors.Is(err, artifact.ErrAssetConflict) {
|
||||
kind = forge.FailureConflict
|
||||
}
|
||||
return result, &forge.Failure{Kind: kind, Err: fmt.Errorf("verify build assets: %w", err)}
|
||||
}
|
||||
}
|
||||
if build != nil && len(build.Settings.Targets) != 0 {
|
||||
@@ -159,50 +175,61 @@ func Deliver(ctx context.Context, request Request) (Result, error) {
|
||||
}
|
||||
}
|
||||
|
||||
releaseResult, err := (gitea.Client{}).EnsureRelease(ctx, gitea.Request{
|
||||
Destination: request.Destination,
|
||||
Tag: identity.Tag,
|
||||
Commit: commit,
|
||||
Note: string(note),
|
||||
})
|
||||
release, pageEvidence, err := coordinateRelease(ctx, request.Backend, identity.Tag, commit, string(note))
|
||||
if err != nil {
|
||||
result.State = stateForError(err, false)
|
||||
return result, err
|
||||
}
|
||||
result.ReleaseURL = releaseResult.Release.URL
|
||||
result.ReleaseURL = release.URL
|
||||
if len(assets) == 0 {
|
||||
result.State = Complete
|
||||
return result, nil
|
||||
}
|
||||
inspection, err := (gitea.Client{}).InspectAssets(ctx, request.Destination, releaseResult.Release.ID, assets)
|
||||
inspection, err := inspectRemoteAssets(ctx, request.Backend, release.Handle, assets)
|
||||
if err != nil {
|
||||
result.State = NotAttempted
|
||||
if releaseResult.WriteAttempted {
|
||||
if pageEvidence.Attempted {
|
||||
result.State = Partial
|
||||
}
|
||||
result.Uncertain = assetIdentities(assets)
|
||||
return result, err
|
||||
}
|
||||
uploadResult, uploadErr := (gitea.Client{}).UploadVerifiedMissing(ctx, request.Destination, releaseResult.Release.ID, inspection)
|
||||
uploadResult, uploadErr := uploadMissingAssets(ctx, request.Backend, release.Handle, inspection)
|
||||
result.Matching = uploadResult.Matching
|
||||
result.Uploaded = uploadResult.Uploaded
|
||||
result.Pending = uploadResult.Pending
|
||||
result.Conflicting = uploadResult.Conflicting
|
||||
result.Uncertain = uploadResult.Uncertain
|
||||
result.State = State(uploadResult.State)
|
||||
if result.State == NotAttempted && releaseResult.WriteAttempted {
|
||||
if result.State == NotAttempted && pageEvidence.Attempted {
|
||||
result.State = Partial
|
||||
}
|
||||
return result, uploadErr
|
||||
}
|
||||
|
||||
func validateBuild(identity releaseidentity.Identity, commit string, build *internalbuild.Result, assets []gitea.ExpectedAsset) error {
|
||||
func validateBuild(identity releaseidentity.Identity, commit string, build *internalbuild.Result, assets []artifact.ExpectedAsset) error {
|
||||
if build == nil {
|
||||
if len(assets) != 0 {
|
||||
return errors.New("assets require a build identity")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := validateBuildIdentity(identity, commit, build); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(build.Settings.Targets) == 0 {
|
||||
if len(assets) != 0 {
|
||||
return errors.New("page-only delivery cannot include binary assets")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if len(assets) != len(build.Settings.Targets)+1 || assets[len(assets)-1].Filename != artifact.ManifestFilename {
|
||||
return errors.New("build result does not contain the selected artifacts and complete checksum manifest")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateBuildIdentity(identity releaseidentity.Identity, commit string, build *internalbuild.Result) error {
|
||||
buildRepository, err := filepath.Abs(build.RepositoryDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve build repository: %w", err)
|
||||
@@ -215,27 +242,24 @@ func validateBuild(identity releaseidentity.Identity, commit string, build *inte
|
||||
return errors.New("build result does not identify the selected tagged module")
|
||||
}
|
||||
if len(build.Settings.Targets) == 0 {
|
||||
if len(assets) != 0 {
|
||||
return errors.New("page-only delivery cannot include binary assets")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !build.SourceVerified {
|
||||
return errors.New("build source was not verified as clean and unchanged during compilation")
|
||||
}
|
||||
if strings.TrimSpace(build.Settings.MainPackage) == "" || strings.TrimSpace(build.Settings.BinaryName) == "" || len(assets) != len(build.Settings.Targets)+1 || assets[len(assets)-1].Filename != gitea.ManifestFilename {
|
||||
if strings.TrimSpace(build.Settings.MainPackage) == "" || strings.TrimSpace(build.Settings.BinaryName) == "" {
|
||||
return errors.New("build result does not contain the selected artifacts and complete checksum manifest")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func stateForError(err error, pageCreated bool) State {
|
||||
var failure *gitea.Failure
|
||||
var failure *forge.Failure
|
||||
if errors.As(err, &failure) {
|
||||
switch failure.Kind {
|
||||
case gitea.FailureConflict:
|
||||
case forge.FailureConflict:
|
||||
return Conflict
|
||||
case gitea.FailureUnknown:
|
||||
case forge.FailureUnknown:
|
||||
return Unknown
|
||||
}
|
||||
}
|
||||
@@ -245,10 +269,10 @@ func stateForError(err error, pageCreated bool) State {
|
||||
return NotAttempted
|
||||
}
|
||||
|
||||
func assetIdentities(assets []gitea.ExpectedAsset) []gitea.AssetIdentity {
|
||||
result := make([]gitea.AssetIdentity, 0, len(assets))
|
||||
func assetIdentities(assets []artifact.ExpectedAsset) []AssetIdentity {
|
||||
result := make([]AssetIdentity, 0, len(assets))
|
||||
for _, asset := range assets {
|
||||
result = append(result, gitea.AssetIdentity{Filename: asset.Filename, SHA256: asset.SHA256})
|
||||
result = append(result, AssetIdentity{Filename: asset.Filename, SHA256: asset.SHA256})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
91
internal/delivery/page.go
Normal file
91
internal/delivery/page.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"go.maximumdirect.net/releasekit/internal/forge"
|
||||
)
|
||||
|
||||
func coordinateRelease(ctx context.Context, backend forge.Backend, tag, commit, note string) (forge.Release, forge.WriteEvidence, error) {
|
||||
observedCommit, err := backend.TagCommit(ctx, tag)
|
||||
if err != nil {
|
||||
return forge.Release{}, forge.WriteEvidence{Disposition: forge.WriteNotAttempted}, err
|
||||
}
|
||||
if observedCommit != commit {
|
||||
return forge.Release{}, forge.WriteEvidence{Disposition: forge.WriteNotAttempted}, &forge.Failure{
|
||||
Kind: forge.FailureConflict,
|
||||
Err: fmt.Errorf("hosted tag %q targets %q, want %q", tag, observedCommit, commit),
|
||||
}
|
||||
}
|
||||
|
||||
existing, err := backend.FindRelease(ctx, tag)
|
||||
if err == nil {
|
||||
if !matchesExistingRelease(existing, tag, note) {
|
||||
return forge.Release{}, forge.WriteEvidence{Disposition: forge.WriteNotAttempted}, &forge.Failure{
|
||||
Kind: forge.FailureConflict,
|
||||
Err: errors.New("existing release does not match the committed note and stable tag"),
|
||||
}
|
||||
}
|
||||
return existing, forge.WriteEvidence{Disposition: forge.WriteNotAttempted}, nil
|
||||
}
|
||||
if !isForgeFailure(err, forge.FailureNotFound) {
|
||||
return forge.Release{}, forge.WriteEvidence{Disposition: forge.WriteNotAttempted}, err
|
||||
}
|
||||
|
||||
request := forge.CreateReleaseRequest{
|
||||
Tag: tag, Commit: commit, Title: releaseNoteTitle(note), Body: note,
|
||||
}
|
||||
created, evidence, createErr := backend.CreateRelease(ctx, request)
|
||||
if createErr == nil && matchesCreatedRelease(created, request) {
|
||||
return created, evidence, nil
|
||||
}
|
||||
if createErr == nil {
|
||||
createErr = &forge.Failure{Kind: forge.FailureMalformed, Err: errors.New("created release does not match the requested stable release")}
|
||||
}
|
||||
if evidence.Disposition != forge.WriteConflict && evidence.Disposition != forge.WriteUncertain && !isForgeFailure(createErr, forge.FailureMalformed) {
|
||||
return forge.Release{}, evidence, createErr
|
||||
}
|
||||
|
||||
return reconcileReleaseCreate(ctx, backend, tag, note, evidence, createErr)
|
||||
}
|
||||
|
||||
func reconcileReleaseCreate(ctx context.Context, backend forge.Backend, tag, note string, evidence forge.WriteEvidence, createErr error) (forge.Release, forge.WriteEvidence, error) {
|
||||
existing, lookupErr := backend.FindRelease(ctx, tag)
|
||||
if lookupErr == nil {
|
||||
if matchesExistingRelease(existing, tag, note) {
|
||||
return existing, evidence, nil
|
||||
}
|
||||
return forge.Release{}, evidence, &forge.Failure{Kind: forge.FailureConflict, Err: errors.Join(createErr, errors.New("reconciled release does not match the committed note and stable tag"))}
|
||||
}
|
||||
if isForgeFailure(lookupErr, forge.FailureNotFound) {
|
||||
if evidence.Disposition == forge.WriteConflict {
|
||||
return forge.Release{}, evidence, &forge.Failure{Kind: forge.FailureConflict, Err: createErr}
|
||||
}
|
||||
return forge.Release{}, evidence, &forge.Failure{Kind: forge.FailureUnknown, Err: createErr}
|
||||
}
|
||||
return forge.Release{}, evidence, &forge.Failure{Kind: forge.FailureUnknown, Err: errors.Join(createErr, lookupErr)}
|
||||
}
|
||||
|
||||
func matchesExistingRelease(release forge.Release, tag, note string) bool {
|
||||
return release.Tag == tag && release.Body == note && !release.Draft && !release.Prerelease
|
||||
}
|
||||
|
||||
func matchesCreatedRelease(release forge.Release, request forge.CreateReleaseRequest) bool {
|
||||
return release.Tag == request.Tag && release.Body == request.Body && release.Title == request.Title && !release.Draft && !release.Prerelease
|
||||
}
|
||||
|
||||
func releaseNoteTitle(note string) string {
|
||||
first := strings.SplitN(note, "\n", 2)[0]
|
||||
if !strings.HasPrefix(first, "# ") {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(strings.TrimPrefix(first, "# "))
|
||||
}
|
||||
|
||||
func isForgeFailure(err error, kind forge.FailureKind) bool {
|
||||
var failure *forge.Failure
|
||||
return errors.As(err, &failure) && failure.Kind == kind
|
||||
}
|
||||
204
internal/delivery/page_test.go
Normal file
204
internal/delivery/page_test.go
Normal file
@@ -0,0 +1,204 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go.maximumdirect.net/releasekit/internal/forge"
|
||||
)
|
||||
|
||||
func TestCoordinateReleaseAcceptsExistingPageAndRetainsItsTitle(t *testing.T) {
|
||||
backend := &pageBackend{
|
||||
commit: "commit",
|
||||
findResults: []pageFindResult{{release: forge.Release{
|
||||
Tag: "v1.2.3", Title: "Maintained title", Body: "# Release\n\nNotes", Handle: "release",
|
||||
}}},
|
||||
}
|
||||
|
||||
release, evidence, err := coordinateRelease(context.Background(), backend, "v1.2.3", "commit", "# Release\n\nNotes")
|
||||
if err != nil || release.Title != "Maintained title" || evidence.Attempted {
|
||||
t.Fatalf("coordinateRelease() = %#v, evidence %#v, error %v", release, evidence, err)
|
||||
}
|
||||
if backend.createCalls != 0 || backend.findCalls != 1 {
|
||||
t.Fatalf("calls = create %d, find %d", backend.createCalls, backend.findCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoordinateReleaseRejectsWrongHostedTagAndExistingPage(t *testing.T) {
|
||||
t.Run("wrong hosted tag", func(t *testing.T) {
|
||||
backend := &pageBackend{commit: "other"}
|
||||
_, evidence, err := coordinateRelease(context.Background(), backend, "v1.2.3", "commit", "# Release")
|
||||
if !isForgeFailure(err, forge.FailureConflict) || evidence.Attempted || backend.findCalls != 0 || backend.createCalls != 0 {
|
||||
t.Fatalf("coordinateRelease() evidence %#v, error %v, calls find=%d create=%d", evidence, err, backend.findCalls, backend.createCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing hosted tag", func(t *testing.T) {
|
||||
backend := &pageBackend{tagErr: &forge.Failure{Kind: forge.FailureNotFound, Err: errors.New("tag missing")}}
|
||||
_, _, err := coordinateRelease(context.Background(), backend, "v1.2.3", "commit", "# Release")
|
||||
if !isForgeFailure(err, forge.FailureNotFound) || backend.findCalls != 0 || backend.createCalls != 0 {
|
||||
t.Fatalf("coordinateRelease() error = %v, calls find=%d create=%d", err, backend.findCalls, backend.createCalls)
|
||||
}
|
||||
})
|
||||
|
||||
for _, release := range []forge.Release{
|
||||
{Tag: "v9.9.9", Body: "# Release", Title: "Release"},
|
||||
{Tag: "v1.2.3", Body: "different", Title: "Release"},
|
||||
{Tag: "v1.2.3", Body: "# Release", Title: "Release", Draft: true},
|
||||
{Tag: "v1.2.3", Body: "# Release", Title: "Release", Prerelease: true},
|
||||
} {
|
||||
backend := &pageBackend{commit: "commit", findResults: []pageFindResult{{release: release}}}
|
||||
_, _, err := coordinateRelease(context.Background(), backend, "v1.2.3", "commit", "# Release")
|
||||
if !isForgeFailure(err, forge.FailureConflict) || backend.createCalls != 0 {
|
||||
t.Fatalf("coordinateRelease(%#v) error = %v, create calls = %d", release, err, backend.createCalls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoordinateReleaseCreatesWithFullNoteAndVerifiesRequestedTitle(t *testing.T) {
|
||||
backend := &pageBackend{
|
||||
commit: "commit",
|
||||
findResults: []pageFindResult{{err: &forge.Failure{Kind: forge.FailureNotFound}}},
|
||||
createResult: pageCreateResult{
|
||||
release: forge.Release{Tag: "v1.2.3", Title: "Release", Body: "# Release\n\nFull note", Handle: "release"},
|
||||
evidence: forge.WriteEvidence{Attempted: true, Disposition: forge.WriteAccepted},
|
||||
},
|
||||
}
|
||||
|
||||
release, evidence, err := coordinateRelease(context.Background(), backend, "v1.2.3", "commit", "# Release\n\nFull note")
|
||||
if err != nil || release.Handle != "release" || evidence.Disposition != forge.WriteAccepted || !evidence.Attempted {
|
||||
t.Fatalf("coordinateRelease() = %#v, evidence %#v, error %v", release, evidence, err)
|
||||
}
|
||||
if got := backend.createRequest; got.Tag != "v1.2.3" || got.Commit != "commit" || got.Title != "Release" || got.Body != "# Release\n\nFull note" || got.Draft || got.Prerelease {
|
||||
t.Fatalf("CreateRelease request = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoordinateReleaseReconcilesCreateOutcomes(t *testing.T) {
|
||||
createErr := &forge.Failure{Kind: forge.FailureTransport, Err: errors.New("connection lost")}
|
||||
lookupErr := errors.New("lookup unavailable")
|
||||
matching := forge.Release{Tag: "v1.2.3", Title: "Original", Body: "# Release\n\nNotes", Handle: "release"}
|
||||
wrong := forge.Release{Tag: "v1.2.3", Body: "different", Handle: "other"}
|
||||
notFound := &forge.Failure{Kind: forge.FailureNotFound}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
create pageCreateResult
|
||||
followup pageFindResult
|
||||
wantKind forge.FailureKind
|
||||
wantRecovered bool
|
||||
wantCause error
|
||||
}{
|
||||
{
|
||||
name: "matching page after conflict",
|
||||
create: pageCreateResult{err: createErr, evidence: forge.WriteEvidence{Attempted: true, Disposition: forge.WriteConflict}},
|
||||
followup: pageFindResult{release: matching}, wantRecovered: true,
|
||||
},
|
||||
{
|
||||
name: "different page after uncertain write",
|
||||
create: pageCreateResult{err: createErr, evidence: forge.WriteEvidence{Attempted: true, Disposition: forge.WriteUncertain}},
|
||||
followup: pageFindResult{release: wrong}, wantKind: forge.FailureConflict,
|
||||
},
|
||||
{
|
||||
name: "missing after conflict",
|
||||
create: pageCreateResult{err: createErr, evidence: forge.WriteEvidence{Attempted: true, Disposition: forge.WriteConflict}},
|
||||
followup: pageFindResult{err: notFound}, wantKind: forge.FailureConflict,
|
||||
},
|
||||
{
|
||||
name: "missing after uncertain write",
|
||||
create: pageCreateResult{err: createErr, evidence: forge.WriteEvidence{Attempted: true, Disposition: forge.WriteUncertain}},
|
||||
followup: pageFindResult{err: notFound}, wantKind: forge.FailureUnknown, wantCause: createErr,
|
||||
},
|
||||
{
|
||||
name: "unavailable follow-up",
|
||||
create: pageCreateResult{err: createErr, evidence: forge.WriteEvidence{Attempted: true, Disposition: forge.WriteUncertain}},
|
||||
followup: pageFindResult{err: lookupErr}, wantKind: forge.FailureUnknown, wantCause: lookupErr,
|
||||
},
|
||||
{
|
||||
name: "inconsistent accepted response reconciled",
|
||||
create: pageCreateResult{release: wrong, evidence: forge.WriteEvidence{Attempted: true, Disposition: forge.WriteAccepted}},
|
||||
followup: pageFindResult{release: matching}, wantRecovered: true,
|
||||
},
|
||||
{
|
||||
name: "inconsistent accepted response missing",
|
||||
create: pageCreateResult{release: wrong, evidence: forge.WriteEvidence{Attempted: true, Disposition: forge.WriteAccepted}},
|
||||
followup: pageFindResult{err: notFound}, wantKind: forge.FailureUnknown,
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
backend := &pageBackend{
|
||||
commit: "commit",
|
||||
findResults: []pageFindResult{{err: notFound}, test.followup},
|
||||
createResult: test.create,
|
||||
}
|
||||
release, evidence, err := coordinateRelease(context.Background(), backend, "v1.2.3", "commit", "# Release\n\nNotes")
|
||||
if test.wantRecovered {
|
||||
if err != nil || release.Handle != "release" || !evidence.Attempted || backend.findCalls != 2 {
|
||||
t.Fatalf("recovered result = %#v, evidence %#v, error %v, find calls %d", release, evidence, err, backend.findCalls)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !isForgeFailure(err, test.wantKind) || !evidence.Attempted {
|
||||
t.Fatalf("result evidence %#v, error %v; want kind %s", evidence, err, test.wantKind)
|
||||
}
|
||||
if test.wantCause != nil && !errors.Is(err, test.wantCause) {
|
||||
t.Fatalf("error %v does not retain cause %v", err, test.wantCause)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type pageFindResult struct {
|
||||
release forge.Release
|
||||
err error
|
||||
}
|
||||
|
||||
type pageCreateResult struct {
|
||||
release forge.Release
|
||||
evidence forge.WriteEvidence
|
||||
err error
|
||||
}
|
||||
|
||||
type pageBackend struct {
|
||||
commit string
|
||||
tagErr error
|
||||
findResults []pageFindResult
|
||||
findCalls int
|
||||
createResult pageCreateResult
|
||||
createCalls int
|
||||
createRequest forge.CreateReleaseRequest
|
||||
}
|
||||
|
||||
func (backend *pageBackend) Preflight(context.Context) error { return nil }
|
||||
|
||||
func (backend *pageBackend) TagCommit(context.Context, string) (string, error) {
|
||||
return backend.commit, backend.tagErr
|
||||
}
|
||||
|
||||
func (backend *pageBackend) FindRelease(context.Context, string) (forge.Release, error) {
|
||||
index := backend.findCalls
|
||||
backend.findCalls++
|
||||
if index >= len(backend.findResults) {
|
||||
return forge.Release{}, &forge.Failure{Kind: forge.FailureNotFound}
|
||||
}
|
||||
result := backend.findResults[index]
|
||||
return result.release, result.err
|
||||
}
|
||||
|
||||
func (backend *pageBackend) CreateRelease(_ context.Context, request forge.CreateReleaseRequest) (forge.Release, forge.WriteEvidence, error) {
|
||||
backend.createCalls++
|
||||
backend.createRequest = request
|
||||
return backend.createResult.release, backend.createResult.evidence, backend.createResult.err
|
||||
}
|
||||
|
||||
func (*pageBackend) ListAssets(context.Context, any) ([]forge.Asset, error) { return nil, nil }
|
||||
|
||||
func (*pageBackend) OpenAsset(context.Context, any) (io.ReadCloser, error) {
|
||||
return io.NopCloser(strings.NewReader("")), nil
|
||||
}
|
||||
|
||||
func (*pageBackend) UploadAsset(context.Context, any, string, io.Reader) (forge.WriteEvidence, error) {
|
||||
return forge.WriteEvidence{}, nil
|
||||
}
|
||||
@@ -8,9 +8,8 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact"
|
||||
internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea"
|
||||
"go.maximumdirect.net/releasekit/internal/artifact"
|
||||
internalbuild "go.maximumdirect.net/releasekit/internal/build"
|
||||
)
|
||||
|
||||
const performanceArtifactSize = 16 << 20
|
||||
@@ -45,7 +44,7 @@ func BenchmarkPerformanceAssetPreflight(b *testing.B) {
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
if err := gitea.VerifyLocalAssets(assets); err != nil {
|
||||
if err := artifact.VerifyLocalAssets(assets); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
"sync"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/process"
|
||||
"go.maximumdirect.net/releasekit/internal/process"
|
||||
)
|
||||
|
||||
var errBlobObjectMissing = errors.New("Git object is missing")
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/process"
|
||||
"go.maximumdirect.net/releasekit/internal/process"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/process"
|
||||
"go.maximumdirect.net/releasekit/internal/process"
|
||||
)
|
||||
|
||||
// TreeEntry records one committed path and its Git object metadata.
|
||||
|
||||
@@ -2,43 +2,14 @@ package gitea
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"sort"
|
||||
"strconv"
|
||||
"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 {
|
||||
ID int64
|
||||
Filename string
|
||||
@@ -46,18 +17,6 @@ type RemoteAsset struct {
|
||||
DownloadURL string
|
||||
}
|
||||
|
||||
type AssetInspection struct {
|
||||
Expected ExpectedAsset
|
||||
State AssetState
|
||||
Existing []RemoteAsset
|
||||
Err error
|
||||
}
|
||||
|
||||
type Inspection struct {
|
||||
Assets []AssetInspection
|
||||
Unrelated []RemoteAsset
|
||||
}
|
||||
|
||||
type assetResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -65,118 +24,47 @@ type assetResponse struct {
|
||||
BrowserDownloadURL string `json:"browser_download_url"`
|
||||
}
|
||||
|
||||
// BuildManifest creates the deterministic checksum file for the declared
|
||||
// binaries. An empty binary set produces no manifest.
|
||||
func BuildManifest(binaries []ExpectedAsset) (ExpectedAsset, error) {
|
||||
if len(binaries) == 0 {
|
||||
return ExpectedAsset{}, nil
|
||||
}
|
||||
ordered := append([]ExpectedAsset(nil), binaries...)
|
||||
sort.Slice(ordered, func(i, j int) bool { return ordered[i].Filename < ordered[j].Filename })
|
||||
var body strings.Builder
|
||||
seen := make(map[string]struct{}, len(ordered))
|
||||
for _, binary := range ordered {
|
||||
if !validAssetName(binary.Filename) || binary.Filename == ManifestFilename || !validSHA256(binary.SHA256) {
|
||||
return ExpectedAsset{}, failure(FailureInvalid, "build checksum manifest", 0, fmt.Errorf("invalid binary identity for %q", binary.Filename))
|
||||
}
|
||||
if _, exists := seen[binary.Filename]; exists {
|
||||
return ExpectedAsset{}, failure(FailureInvalid, "build checksum manifest", 0, fmt.Errorf("duplicate binary filename %q", binary.Filename))
|
||||
}
|
||||
seen[binary.Filename] = struct{}{}
|
||||
fmt.Fprintf(&body, "%s %s\n", binary.SHA256, binary.Filename)
|
||||
}
|
||||
content := []byte(body.String())
|
||||
return ExpectedAsset{Filename: ManifestFilename, SHA256: digest(content), Size: int64(len(content)), Content: content}, nil
|
||||
}
|
||||
|
||||
// InspectAssets lists the release's attachments and compares every declared
|
||||
// asset by streamed SHA-256 content. It does not create or modify remote data.
|
||||
func (c Client) InspectAssets(ctx context.Context, destination Destination, releaseID int64, expected []ExpectedAsset) (Inspection, error) {
|
||||
func (c Client) listAssets(ctx context.Context, client *http.Client, token string, releaseID int64, apiRoot string) ([]RemoteAsset, error) {
|
||||
if releaseID <= 0 {
|
||||
return Inspection{}, failure(FailureInvalid, "inspect release assets", 0, errors.New("release ID must be positive"))
|
||||
return nil, failure(FailureInvalid, "list release assets", 0, errors.New("release ID must be positive"))
|
||||
}
|
||||
if strings.TrimSpace(destination.Token) == "" {
|
||||
return Inspection{}, failure(FailureInvalid, "inspect release assets", 0, errors.New("Gitea token is required"))
|
||||
}
|
||||
base, err := parseBaseURL(destination.BaseURL)
|
||||
if err != nil {
|
||||
return Inspection{}, failure(FailureInvalid, "validate destination", 0, err)
|
||||
}
|
||||
if strings.TrimSpace(destination.Owner) == "" || strings.ContainsAny(destination.Owner, "/\\") || strings.TrimSpace(destination.Repository) == "" || strings.ContainsAny(destination.Repository, "/\\") {
|
||||
return Inspection{}, failure(FailureInvalid, "inspect release assets", 0, errors.New("Gitea owner and repository must be path segments"))
|
||||
}
|
||||
if err := validateExpectedAssets(expected); err != nil {
|
||||
return Inspection{}, err
|
||||
}
|
||||
apiRoot := strings.TrimRight(base.String(), "/") + "/api/v1/repos/" + url.PathEscape(destination.Owner) + "/" + url.PathEscape(destination.Repository)
|
||||
assetsURL := apiRoot + "/releases/" + strconv.FormatInt(releaseID, 10) + "/assets"
|
||||
var listed []assetResponse
|
||||
if err := c.getJSON(ctx, c.httpClient(), destination.Token, "list release assets", assetsURL, &listed); err != nil {
|
||||
return Inspection{}, err
|
||||
if err := c.getJSON(ctx, client, token, "list release assets", assetsURL, &listed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if listed == nil {
|
||||
return Inspection{}, failure(FailureMalformed, "decode release assets", http.StatusOK, errors.New("attachment response must be a JSON array"))
|
||||
return nil, failure(FailureMalformed, "decode release assets", http.StatusOK, errors.New("attachment response must be a JSON array"))
|
||||
}
|
||||
|
||||
remoteByName := make(map[string][]RemoteAsset, len(listed))
|
||||
assets := make([]RemoteAsset, 0, len(listed))
|
||||
for _, item := range listed {
|
||||
if item.ID <= 0 || item.Name == "" || item.Size < 0 {
|
||||
return Inspection{}, failure(FailureMalformed, "decode release assets", http.StatusOK, errors.New("attachment entry is missing its ID, name, or size"))
|
||||
return nil, failure(FailureMalformed, "decode release assets", http.StatusOK, errors.New("attachment entry is missing its ID, name, or size"))
|
||||
}
|
||||
remote := RemoteAsset{ID: item.ID, Filename: item.Name, Size: item.Size, DownloadURL: item.BrowserDownloadURL}
|
||||
remoteByName[item.Name] = append(remoteByName[item.Name], remote)
|
||||
assets = append(assets, RemoteAsset{ID: item.ID, Filename: item.Name, Size: item.Size, DownloadURL: item.BrowserDownloadURL})
|
||||
}
|
||||
inspection := Inspection{Assets: make([]AssetInspection, 0, len(expected))}
|
||||
declared := make(map[string]struct{}, len(expected))
|
||||
for _, item := range expected {
|
||||
declared[item.Filename] = struct{}{}
|
||||
matches := remoteByName[item.Filename]
|
||||
observation := AssetInspection{Expected: item, Existing: matches}
|
||||
switch len(matches) {
|
||||
case 0:
|
||||
observation.State = AssetMissing
|
||||
case 1:
|
||||
remoteHash, _, err := c.downloadHash(ctx, c.httpClient(), base, destination.Token, matches[0].DownloadURL)
|
||||
if err != nil {
|
||||
observation.State = AssetUnverifiable
|
||||
observation.Err = err
|
||||
} else if remoteHash == item.SHA256 {
|
||||
observation.State = AssetMatching
|
||||
} else {
|
||||
observation.State = AssetConflicting
|
||||
}
|
||||
default:
|
||||
observation.State = AssetConflicting
|
||||
}
|
||||
inspection.Assets = append(inspection.Assets, observation)
|
||||
}
|
||||
for _, item := range listed {
|
||||
if _, expected := declared[item.Name]; !expected {
|
||||
inspection.Unrelated = append(inspection.Unrelated, RemoteAsset{ID: item.ID, Filename: item.Name, Size: item.Size, DownloadURL: item.BrowserDownloadURL})
|
||||
}
|
||||
}
|
||||
return inspection, nil
|
||||
return assets, nil
|
||||
}
|
||||
|
||||
func (c Client) downloadHash(ctx context.Context, client *http.Client, base *url.URL, token, rawURL string) (string, int64, error) {
|
||||
func (c Client) openAsset(ctx context.Context, client *http.Client, base *url.URL, token, rawURL string) (io.ReadCloser, error) {
|
||||
if strings.TrimSpace(rawURL) == "" {
|
||||
return "", 0, failure(FailureUnverifiable, "download release asset", 0, errors.New("attachment has no download URL"))
|
||||
return nil, failure(FailureUnverifiable, "download release asset", 0, errors.New("attachment has no download URL"))
|
||||
}
|
||||
assetURL, err := base.Parse(rawURL)
|
||||
if err != nil || assetURL.User != nil {
|
||||
return "", 0, failure(FailureUnverifiable, "download release asset", 0, errors.New("invalid attachment URL"))
|
||||
return nil, failure(FailureUnverifiable, "download release asset", 0, errors.New("invalid attachment URL"))
|
||||
}
|
||||
assetURL.Fragment = ""
|
||||
visited := make(map[string]struct{})
|
||||
for {
|
||||
key := assetURL.String()
|
||||
if _, exists := visited[key]; exists {
|
||||
return "", 0, failure(FailureUnverifiable, "download release asset", 0, errors.New("asset redirect loop"))
|
||||
return nil, failure(FailureUnverifiable, "download release asset", 0, errors.New("asset redirect loop"))
|
||||
}
|
||||
visited[key] = struct{}{}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, key, nil)
|
||||
if err != nil {
|
||||
return "", 0, failure(FailureUnverifiable, "download release asset", 0, err)
|
||||
return nil, failure(FailureUnverifiable, "download release asset", 0, err)
|
||||
}
|
||||
if sameOrigin(base, assetURL) {
|
||||
request.Header.Set("Authorization", "token "+token)
|
||||
@@ -184,65 +72,26 @@ func (c Client) downloadHash(ctx context.Context, client *http.Client, base *url
|
||||
request.Header.Set("Accept", "application/octet-stream")
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return "", 0, failure(FailureUnverifiable, "download release asset", 0, err)
|
||||
return nil, failure(FailureUnverifiable, "download release asset", 0, err)
|
||||
}
|
||||
if isRedirect(response.StatusCode) {
|
||||
location, locationErr := response.Location()
|
||||
_ = response.Body.Close()
|
||||
if locationErr != nil || location.User != nil {
|
||||
return "", 0, failure(FailureUnverifiable, "redirect release asset", response.StatusCode, errors.New("invalid attachment redirect"))
|
||||
return nil, failure(FailureUnverifiable, "redirect release asset", response.StatusCode, errors.New("invalid attachment redirect"))
|
||||
}
|
||||
location.Fragment = ""
|
||||
assetURL = location
|
||||
continue
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return "", 0, responseFailure("download release asset", response.StatusCode)
|
||||
_ = response.Body.Close()
|
||||
return nil, responseFailure("download release asset", response.StatusCode)
|
||||
}
|
||||
hasher := sha256.New()
|
||||
size, err := io.Copy(hasher, response.Body)
|
||||
if err != nil {
|
||||
return "", size, failure(FailureUnverifiable, "read release asset", response.StatusCode, err)
|
||||
}
|
||||
return hex.EncodeToString(hasher.Sum(nil)), size, nil
|
||||
return response.Body, nil
|
||||
}
|
||||
}
|
||||
|
||||
func validateExpectedAssets(expected []ExpectedAsset) error {
|
||||
seen := make(map[string]struct{}, len(expected))
|
||||
for _, item := range expected {
|
||||
if !validAssetName(item.Filename) || !validSHA256(item.SHA256) || item.Size < 0 {
|
||||
return failure(FailureInvalid, "inspect release assets", 0, fmt.Errorf("invalid expected asset %q", item.Filename))
|
||||
}
|
||||
if _, duplicate := seen[item.Filename]; duplicate {
|
||||
return failure(FailureInvalid, "inspect release assets", 0, fmt.Errorf("duplicate expected asset %q", item.Filename))
|
||||
}
|
||||
seen[item.Filename] = struct{}{}
|
||||
if len(item.Content) > 0 && (int64(len(item.Content)) != item.Size || digest(item.Content) != item.SHA256) {
|
||||
return failure(FailureInvalid, "inspect release assets", 0, fmt.Errorf("expected content does not match identity for %q", item.Filename))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validAssetName(name string) bool {
|
||||
return name != "" && name != "." && name != ".." && path.Base(name) == name && !strings.ContainsAny(name, "\\\r\n\x00")
|
||||
}
|
||||
|
||||
func validSHA256(value string) bool {
|
||||
if len(value) != sha256.Size*2 {
|
||||
return false
|
||||
}
|
||||
_, err := hex.DecodeString(value)
|
||||
return err == nil && strings.ToLower(value) == value
|
||||
}
|
||||
|
||||
func digest(content []byte) string {
|
||||
sum := sha256.Sum256(content)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func sameOrigin(base, target *url.URL) bool {
|
||||
if target == nil || target.User != nil || target.Host == "" {
|
||||
return false
|
||||
|
||||
@@ -2,324 +2,115 @@ package gitea
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"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) {
|
||||
manifest, err := BuildManifest([]ExpectedAsset{
|
||||
assetFor("zeta.bin", []byte("zeta")),
|
||||
assetFor("alpha.bin", []byte("alpha")),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := fmt.Sprintf("%s alpha.bin\n%s zeta.bin\n", digest([]byte("alpha")), digest([]byte("zeta")))
|
||||
if string(manifest.Content) != want || manifest.Filename != ManifestFilename || manifest.SHA256 != digest([]byte(want)) || manifest.Size != int64(len(want)) {
|
||||
t.Fatalf("manifest = %#v, content %q", manifest, manifest.Content)
|
||||
}
|
||||
empty, err := BuildManifest(nil)
|
||||
if err != nil || empty.Filename != "" || len(empty.Content) != 0 {
|
||||
t.Fatalf("empty manifest = %#v, error = %v", empty, err)
|
||||
_, err := NewBackend(testDestination(server.URL), server.Client()).ListAssets(context.Background(), releaseHandle{id: 10})
|
||||
var failure *forge.Failure
|
||||
if !errors.As(err, &failure) || failure.Kind != forge.FailureMalformed {
|
||||
t.Fatalf("ListAssets() error = %v, want malformed attachment response", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectAssetsAcceptsMatchingContentAndPreservesUnrelatedFiles(t *testing.T) {
|
||||
content := []byte("selected binary")
|
||||
func TestBackendOpenAssetFollowsSameOriginRedirectWithAuthorization(t *testing.T) {
|
||||
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 {
|
||||
case assetListPath:
|
||||
writeAssetList(t, w, []assetResponse{
|
||||
{ID: 4, Name: "app-v1.2.3-linux-amd64", Size: int64(len(content)), BrowserDownloadURL: "/files/app"},
|
||||
{ID: 8, Name: "notes.txt", Size: 9, BrowserDownloadURL: "/files/notes"},
|
||||
})
|
||||
case "/files/app":
|
||||
_, _ = w.Write(content)
|
||||
case "/files/notes":
|
||||
_, _ = w.Write([]byte("unrelated"))
|
||||
default:
|
||||
t.Fatalf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
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.Fatalf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
_, _ = 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" {
|
||||
case "/files/redirect":
|
||||
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.Fatalf("unexpected path %s", r.URL.Path)
|
||||
}))
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if inspection.Assets[0].State != AssetMatching {
|
||||
t.Fatalf("inspection = %#v", inspection)
|
||||
content, readErr := io.ReadAll(stream)
|
||||
closeErr := stream.Close()
|
||||
if readErr != nil || closeErr != nil || string(content) != "streamed asset" {
|
||||
t.Fatalf("OpenAsset() content = %q, read error = %v, close error = %v", content, readErr, closeErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectAssetsVerifiesExternalContentWithoutGiteaToken(t *testing.T) {
|
||||
for _, mode := range []string{"direct", "redirect", "conflicting"} {
|
||||
func TestBackendOpenAssetDoesNotForwardAuthorizationAcrossOrigin(t *testing.T) {
|
||||
for _, mode := range []string{"direct", "redirect"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
expected := assetFor("app.bin", []byte("matching bytes"))
|
||||
externalGets := 0
|
||||
var externalRequests atomic.Int32
|
||||
external := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
externalGets++
|
||||
if r.Header.Get("Authorization") != "" {
|
||||
t.Error("Gitea token reached external host")
|
||||
externalRequests.Add(1)
|
||||
if got := r.Header.Get("Authorization"); got != "" {
|
||||
t.Errorf("external authorization = %q, want empty", got)
|
||||
}
|
||||
content := []byte("matching bytes")
|
||||
if mode == "conflicting" {
|
||||
content = []byte("different bytes")
|
||||
}
|
||||
w.Write(content)
|
||||
_, _ = io.WriteString(w, "external asset")
|
||||
}))
|
||||
defer external.Close()
|
||||
|
||||
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertAssetGET(t, r, "")
|
||||
if r.URL.Path == assetListPath {
|
||||
download := external.URL + "/content"
|
||||
if mode == "redirect" {
|
||||
download = "/redirect"
|
||||
}
|
||||
writeAssetList(t, w, []assetResponse{{ID: 1, Name: expected.Filename, Size: expected.Size, BrowserDownloadURL: download}})
|
||||
return
|
||||
if got, want := r.Header.Get("Authorization"), "token fixture-secret-token"; got != want {
|
||||
t.Errorf("origin authorization = %q, want %q", got, want)
|
||||
}
|
||||
http.Redirect(w, r, external.URL+"/content", http.StatusFound)
|
||||
}))
|
||||
defer origin.Close()
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := AssetMatching
|
||||
if mode == "conflicting" {
|
||||
want = AssetConflicting
|
||||
}
|
||||
if inspection.Assets[0].State != want || externalGets != 1 {
|
||||
t.Fatalf("inspection=%#v downloads=%d", inspection, externalGets)
|
||||
content, readErr := io.ReadAll(stream)
|
||||
closeErr := stream.Close()
|
||||
if readErr != nil || closeErr != nil || string(content) != "external asset" || externalRequests.Load() != 1 {
|
||||
t.Fatalf("OpenAsset() content = %q, read error = %v, close error = %v, external requests = %d", content, readErr, closeErr, externalRequests.Load())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assetFor(filename string, content []byte) ExpectedAsset {
|
||||
sum := sha256.Sum256(content)
|
||||
return ExpectedAsset{Filename: filename, SHA256: hex.EncodeToString(sum[:]), Size: int64(len(content)), Content: append([]byte(nil), content...)}
|
||||
}
|
||||
func TestBackendListAssetsDecodesAttachmentMetadata(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode([]assetResponse{{ID: 4, Name: "app.bin", Size: 4, BrowserDownloadURL: "/files/app"}})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
func writeAssetList(t *testing.T, w http.ResponseWriter, assets []assetResponse) {
|
||||
t.Helper()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(assets); err != nil {
|
||||
t.Error(err)
|
||||
assets, err := NewBackend(testDestination(server.URL), server.Client()).ListAssets(context.Background(), releaseHandle{id: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(assets) != 1 || assets[0].Filename != "app.bin" || assets[0].Size != 4 || assets[0].Handle == nil {
|
||||
t.Fatalf("ListAssets() = %#v", assets)
|
||||
}
|
||||
}
|
||||
|
||||
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}
|
||||
}
|
||||
|
||||
328
internal/gitea/backend.go
Normal file
328
internal/gitea/backend.go
Normal file
@@ -0,0 +1,328 @@
|
||||
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, ok := handle.(releaseHandle)
|
||||
if !ok || release.id <= 0 {
|
||||
return forge.WriteEvidence{Disposition: forge.WriteNotAttempted}, &forge.Failure{Kind: forge.FailureInvalid, Err: errors.New("release handle was not created by the Gitea backend")}
|
||||
}
|
||||
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, ok := handle.(releaseHandle)
|
||||
if !ok {
|
||||
return nil, &forge.Failure{Kind: forge.FailureInvalid, Err: errors.New("release handle was not created by the Gitea backend")}
|
||||
}
|
||||
_, 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 (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)
|
||||
461
internal/gitea/backend_test.go
Normal file
461
internal/gitea/backend_test.go
Normal file
@@ -0,0 +1,461 @@
|
||||
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 TestBackendListsMetadataAndOpensCallerOwnedAssetStream(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got, want := r.Header.Get("Authorization"), "token fixture-secret-token"; got != want {
|
||||
t.Errorf("authorization = %q, want %q", got, want)
|
||||
}
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/repos/owner/project/releases/tags/v1.2.3":
|
||||
_ = json.NewEncoder(w).Encode(releaseResponse{ID: 12, TagName: "v1.2.3"})
|
||||
case "/api/v1/repos/owner/project/releases/12/assets":
|
||||
_ = json.NewEncoder(w).Encode([]assetResponse{{ID: 4, Name: "app.bin", Size: 4, BrowserDownloadURL: "/files/app"}})
|
||||
case "/files/app":
|
||||
_, _ = io.WriteString(w, "data")
|
||||
default:
|
||||
t.Errorf("unexpected path %q", r.URL.Path)
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
backend := NewBackend(testDestination(server.URL), server.Client())
|
||||
|
||||
release, err := backend.FindRelease(context.Background(), "v1.2.3")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assets, err := backend.ListAssets(context.Background(), release.Handle)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(assets) != 1 || assets[0].Filename != "app.bin" || assets[0].Size != 4 || assets[0].Handle == nil {
|
||||
t.Fatalf("ListAssets() = %#v", assets)
|
||||
}
|
||||
stream, err := backend.OpenAsset(context.Background(), assets[0].Handle)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content, readErr := io.ReadAll(stream)
|
||||
closeErr := stream.Close()
|
||||
if readErr != nil || closeErr != nil || string(content) != "data" {
|
||||
t.Fatalf("OpenAsset() content = %q, read error = %v, close error = %v", content, readErr, closeErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendRejectsAssetHandleFromAnotherBackendType(t *testing.T) {
|
||||
backend := NewBackend(testDestination("http://example.test"), nil)
|
||||
_, err := backend.OpenAsset(context.Background(), strings.NewReader("asset"))
|
||||
var failure *forge.Failure
|
||||
if !errors.As(err, &failure) || failure.Kind != forge.FailureInvalid {
|
||||
t.Fatalf("OpenAsset() error = %v, want invalid handle", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendPreflightUsesAuthenticatedRepositoryReadsAndIgnoresCodePushPermission(t *testing.T) {
|
||||
var requests []string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
t.Errorf("method = %q, want GET", r.Method)
|
||||
}
|
||||
if got, want := r.Header.Get("Authorization"), "token fixture-secret-token"; got != want {
|
||||
t.Errorf("authorization = %q, want %q", got, want)
|
||||
}
|
||||
requests = append(requests, r.URL.Path)
|
||||
writeRepository := func() {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": 31, "name": "project", "full_name": "owner/project",
|
||||
"owner": map[string]string{"login": "owner"},
|
||||
"permissions": map[string]bool{"admin": false, "push": false, "pull": true},
|
||||
})
|
||||
}
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/repos/owner/project", "/api/v1/repositories/31":
|
||||
writeRepository()
|
||||
default:
|
||||
t.Errorf("unexpected path %q", r.URL.Path)
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
if err := NewBackend(testDestination(server.URL), server.Client()).Preflight(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(requests) != 2 || requests[0] != "/api/v1/repos/owner/project" || requests[1] != "/api/v1/repositories/31" {
|
||||
t.Fatalf("preflight requests = %v, want both repository reads", requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendPreflightRequiresCredentialWithoutMakingRequests(t *testing.T) {
|
||||
var requests int
|
||||
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { requests++ }))
|
||||
defer server.Close()
|
||||
destination := testDestination(server.URL)
|
||||
destination.Token = " \t"
|
||||
|
||||
err := NewBackend(destination, server.Client()).Preflight(context.Background())
|
||||
var failure *forge.PreflightFailure
|
||||
if !errors.As(err, &failure) || failure.Kind != forge.PreflightMissingCredential {
|
||||
t.Fatalf("Preflight() error = %v, want missing credential", err)
|
||||
}
|
||||
if requests != 0 {
|
||||
t.Fatalf("made %d requests without a credential", requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendPreflightDoesNotAcceptPublicLookupWithoutAuthenticatedAccess(t *testing.T) {
|
||||
var requests []string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests = append(requests, r.URL.Path)
|
||||
if r.URL.Path == "/api/v1/repos/owner/project" {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"id": 31, "name": "project", "full_name": "owner/project", "owner": map[string]string{"login": "owner"}})
|
||||
return
|
||||
}
|
||||
http.Error(w, "token rejected", http.StatusUnauthorized)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := NewBackend(testDestination(server.URL), server.Client()).Preflight(context.Background())
|
||||
var failure *forge.PreflightFailure
|
||||
if !errors.As(err, &failure) || failure.Kind != forge.PreflightInvalidCredential {
|
||||
t.Fatalf("Preflight() error = %v, want invalid credential", err)
|
||||
}
|
||||
if len(requests) != 2 || requests[1] != "/api/v1/repositories/31" {
|
||||
t.Fatalf("preflight requests = %v, want authenticated repository read", requests)
|
||||
}
|
||||
if strings.Contains(err.Error(), testDestination(server.URL).Token) {
|
||||
t.Fatalf("preflight error exposed the credential: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendPreflightClassifiesDeniedAndHiddenRepositoriesAsInaccessible(t *testing.T) {
|
||||
for _, status := range []int{http.StatusForbidden, http.StatusNotFound} {
|
||||
t.Run(http.StatusText(status), func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "repository unavailable", status)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := NewBackend(testDestination(server.URL), server.Client()).Preflight(context.Background())
|
||||
var failure *forge.PreflightFailure
|
||||
if !errors.As(err, &failure) || failure.Kind != forge.PreflightInaccessibleDestination {
|
||||
t.Fatalf("Preflight() error = %v, want inaccessible destination", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendPreflightClassifiesUnverifiableResponsesAsUnavailable(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
respond func(http.ResponseWriter)
|
||||
}{
|
||||
{name: "malformed JSON", respond: func(w http.ResponseWriter) { _, _ = io.WriteString(w, "{") }},
|
||||
{name: "service failure", respond: func(w http.ResponseWriter) { http.Error(w, "temporarily unavailable", http.StatusServiceUnavailable) }},
|
||||
{name: "wrong repository identity", respond: func(w http.ResponseWriter) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"id": 31, "name": "other", "full_name": "owner/other", "owner": map[string]string{"login": "owner"}})
|
||||
}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { test.respond(w) }))
|
||||
defer server.Close()
|
||||
|
||||
err := NewBackend(testDestination(server.URL), server.Client()).Preflight(context.Background())
|
||||
var failure *forge.PreflightFailure
|
||||
if !errors.As(err, &failure) || failure.Kind != forge.PreflightUnavailable {
|
||||
t.Fatalf("Preflight() error = %v, want unavailable", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendPreflightPreservesCancellation(t *testing.T) {
|
||||
server := httptest.NewServer(http.NotFoundHandler())
|
||||
defer server.Close()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
err := NewBackend(testDestination(server.URL), server.Client()).Preflight(ctx)
|
||||
var failure *forge.PreflightFailure
|
||||
if !errors.As(err, &failure) || failure.Kind != forge.PreflightUnavailable || !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Preflight() error = %v, want unavailable and context cancellation", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendCreateReleaseSendsRequestedFieldsAndAcceptsProtocolAcknowledgment(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/repos/owner/project/releases" {
|
||||
t.Errorf("request = %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
if got, want := r.Header.Get("Authorization"), "token fixture-secret-token"; got != want {
|
||||
t.Errorf("authorization = %q, want %q", got, want)
|
||||
}
|
||||
var request createReleaseRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
t.Errorf("decode request: %v", err)
|
||||
}
|
||||
if request.TagName != "v1.2.3" || request.TargetCommitish != strings.Repeat("a", 40) || request.Name != "Release title" || request.Body != "# Release title\n\nComplete notes." || !request.Draft || !request.Prerelease {
|
||||
t.Errorf("release request = %#v", request)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(releaseResponse{ID: 12, TagName: "server tag", Name: "server title", Body: "server body", Draft: true, Prerelease: true})
|
||||
}))
|
||||
defer server.Close()
|
||||
backend := NewBackend(testDestination(server.URL), server.Client())
|
||||
|
||||
release, evidence, err := backend.CreateRelease(context.Background(), forge.CreateReleaseRequest{
|
||||
Tag: "v1.2.3", Commit: strings.Repeat("a", 40), Title: "Release title",
|
||||
Body: "# Release title\n\nComplete notes.", Draft: true, Prerelease: true,
|
||||
})
|
||||
if err != nil || evidence != (forge.WriteEvidence{Attempted: true, Disposition: forge.WriteAccepted}) {
|
||||
t.Fatalf("CreateRelease() evidence = %#v, error = %v", evidence, err)
|
||||
}
|
||||
if release.Handle == nil || release.Tag != "server tag" || release.Title != "server title" || release.Body != "server body" || !release.Draft || !release.Prerelease {
|
||||
t.Fatalf("CreateRelease() release = %#v", release)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendCreateReleaseMarksInvalidAcknowledgmentUncertain(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(releaseResponse{TagName: "v1.2.3"})
|
||||
}))
|
||||
defer server.Close()
|
||||
backend := NewBackend(testDestination(server.URL), server.Client())
|
||||
|
||||
_, evidence, err := backend.CreateRelease(context.Background(), validCreateReleaseRequest())
|
||||
var failure *forge.Failure
|
||||
if !errors.As(err, &failure) || failure.Kind != forge.FailureMalformed || evidence != (forge.WriteEvidence{Attempted: true, Disposition: forge.WriteUncertain}) {
|
||||
t.Fatalf("CreateRelease() evidence = %#v, error = %v", evidence, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendCreateReleaseClassifiesConflictAndServerFailure(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
status int
|
||||
disposition forge.WriteDisposition
|
||||
}{
|
||||
{status: http.StatusBadRequest, disposition: forge.WriteRejected},
|
||||
{status: http.StatusConflict, disposition: forge.WriteConflict},
|
||||
{status: http.StatusServiceUnavailable, disposition: forge.WriteUncertain},
|
||||
} {
|
||||
t.Run(http.StatusText(test.status), func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(test.status) }))
|
||||
defer server.Close()
|
||||
backend := NewBackend(testDestination(server.URL), server.Client())
|
||||
|
||||
_, evidence, err := backend.CreateRelease(context.Background(), validCreateReleaseRequest())
|
||||
if err == nil || evidence != (forge.WriteEvidence{Attempted: true, Disposition: test.disposition}) {
|
||||
t.Fatalf("CreateRelease() evidence = %#v, error = %v", evidence, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendUploadAssetBorrowsReaderAndValidatesAcknowledgment(t *testing.T) {
|
||||
var uploaded string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/repos/owner/project/releases/12/assets" || r.URL.Query().Get("name") != "app.bin" {
|
||||
t.Errorf("request = %s %s?%s", r.Method, r.URL.Path, r.URL.RawQuery)
|
||||
}
|
||||
if got, want := r.Header.Get("Authorization"), "token fixture-secret-token"; got != want {
|
||||
t.Errorf("authorization = %q, want %q", got, want)
|
||||
}
|
||||
file, header, err := r.FormFile("attachment")
|
||||
if err != nil {
|
||||
t.Errorf("attachment: %v", err)
|
||||
} else {
|
||||
defer file.Close()
|
||||
content, readErr := io.ReadAll(file)
|
||||
if readErr != nil {
|
||||
t.Errorf("read attachment: %v", readErr)
|
||||
}
|
||||
if header.Filename != "app.bin" {
|
||||
t.Errorf("multipart filename = %q", header.Filename)
|
||||
}
|
||||
uploaded = string(content)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(assetResponse{ID: 50, Name: "app.bin"})
|
||||
}))
|
||||
defer server.Close()
|
||||
backend := NewBackend(testDestination(server.URL), server.Client())
|
||||
reader := &trackedReader{Reader: strings.NewReader("streamed bytes")}
|
||||
|
||||
evidence, err := backend.UploadAsset(context.Background(), releaseHandle{id: 12}, "app.bin", reader)
|
||||
if err != nil || evidence != (forge.WriteEvidence{Attempted: true, Disposition: forge.WriteAccepted}) {
|
||||
t.Fatalf("UploadAsset() evidence = %#v, error = %v", evidence, err)
|
||||
}
|
||||
if uploaded != "streamed bytes" || reader.closed {
|
||||
t.Fatalf("uploaded = %q, caller reader closed = %v", uploaded, reader.closed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendUploadAssetMarksBadAcknowledgmentUncertain(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(assetResponse{ID: 50, Name: "different.bin"})
|
||||
}))
|
||||
defer server.Close()
|
||||
backend := NewBackend(testDestination(server.URL), server.Client())
|
||||
|
||||
evidence, err := backend.UploadAsset(context.Background(), releaseHandle{id: 12}, "app.bin", strings.NewReader("data"))
|
||||
var failure *forge.Failure
|
||||
if !errors.As(err, &failure) || failure.Kind != forge.FailureMalformed || evidence != (forge.WriteEvidence{Attempted: true, Disposition: forge.WriteUncertain}) {
|
||||
t.Fatalf("UploadAsset() evidence = %#v, error = %v", evidence, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendUploadAssetClassifiesKnownRejectionAndConflict(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
status int
|
||||
disposition forge.WriteDisposition
|
||||
}{
|
||||
{status: http.StatusBadRequest, disposition: forge.WriteRejected},
|
||||
{status: http.StatusConflict, disposition: forge.WriteConflict},
|
||||
{status: http.StatusInternalServerError, disposition: forge.WriteUncertain},
|
||||
} {
|
||||
t.Run(http.StatusText(test.status), func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(test.status) }))
|
||||
defer server.Close()
|
||||
backend := NewBackend(testDestination(server.URL), server.Client())
|
||||
|
||||
evidence, err := backend.UploadAsset(context.Background(), releaseHandle{id: 12}, "app.bin", strings.NewReader("data"))
|
||||
if err == nil || evidence != (forge.WriteEvidence{Attempted: true, Disposition: test.disposition}) {
|
||||
t.Fatalf("UploadAsset() evidence = %#v, error = %v", evidence, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendUploadAssetPreservesCancellationAsUncertain(t *testing.T) {
|
||||
server := httptest.NewServer(http.NotFoundHandler())
|
||||
defer server.Close()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
evidence, err := NewBackend(testDestination(server.URL), server.Client()).UploadAsset(ctx, releaseHandle{id: 12}, "app.bin", strings.NewReader("data"))
|
||||
if evidence != (forge.WriteEvidence{Attempted: true, Disposition: forge.WriteUncertain}) || !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("UploadAsset() evidence = %#v, error = %v", evidence, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendUploadAssetWaitsForMultipartWriterAfterReaderFailure(t *testing.T) {
|
||||
readerErr := errors.New("asset reader failed")
|
||||
readDone := make(chan struct{})
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/owner/project/releases/tags/v1.2.3" {
|
||||
_ = json.NewEncoder(w).Encode(releaseResponse{ID: 10, TagName: "v1.2.3"})
|
||||
return
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, r.Body)
|
||||
_ = json.NewEncoder(w).Encode(assetResponse{ID: 50, Name: "app.bin", Size: 0})
|
||||
}))
|
||||
defer server.Close()
|
||||
backend := NewBackend(testDestination(server.URL), server.Client())
|
||||
release, err := backend.FindRelease(context.Background(), "v1.2.3")
|
||||
if err != nil {
|
||||
t.Fatalf("FindRelease() error = %v", err)
|
||||
}
|
||||
|
||||
evidence, err := backend.UploadAsset(context.Background(), release.Handle, "app.bin", failingUploadReader{err: readerErr, done: readDone})
|
||||
select {
|
||||
case <-readDone:
|
||||
default:
|
||||
t.Fatal("UploadAsset() returned before the multipart worker finished reading")
|
||||
}
|
||||
if !errors.Is(err, readerErr) {
|
||||
t.Fatalf("UploadAsset() error = %v, want reader cause %v", err, readerErr)
|
||||
}
|
||||
if !evidence.Attempted || evidence.Disposition != forge.WriteUncertain {
|
||||
t.Fatalf("UploadAsset() evidence = %#v, want uncertain attempted write", evidence)
|
||||
}
|
||||
}
|
||||
|
||||
type failingUploadReader struct {
|
||||
err error
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func (reader failingUploadReader) Read([]byte) (int, error) {
|
||||
close(reader.done)
|
||||
return 0, reader.err
|
||||
}
|
||||
|
||||
func validCreateReleaseRequest() forge.CreateReleaseRequest {
|
||||
return forge.CreateReleaseRequest{Tag: "v1.2.3", Commit: strings.Repeat("a", 40), Title: "Release", Body: "# Release"}
|
||||
}
|
||||
|
||||
func testDestination(baseURL string) Destination {
|
||||
return Destination{BaseURL: baseURL, Owner: "owner", Repository: "project", Token: "fixture-secret-token"}
|
||||
}
|
||||
|
||||
type trackedReader struct {
|
||||
io.Reader
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (reader *trackedReader) Close() error {
|
||||
reader.closed = true
|
||||
return nil
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type FailureKind string
|
||||
@@ -55,14 +54,7 @@ type Destination struct {
|
||||
Token string
|
||||
}
|
||||
|
||||
type Request struct {
|
||||
Destination Destination
|
||||
Tag string
|
||||
Commit string
|
||||
Note string
|
||||
}
|
||||
|
||||
type Release struct {
|
||||
type release struct {
|
||||
ID int64
|
||||
URL string
|
||||
TagName string
|
||||
@@ -72,12 +64,6 @@ type Release struct {
|
||||
Prerelease bool
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
Release Release
|
||||
Created bool
|
||||
WriteAttempted bool // a create request was issued, including reconciled creates.
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
@@ -108,109 +94,29 @@ type createReleaseRequest struct {
|
||||
Prerelease bool `json:"prerelease"`
|
||||
}
|
||||
|
||||
// EnsureRelease verifies the hosted tag and finds or creates its stable release
|
||||
// page. It never edits a release that already exists.
|
||||
func (c Client) EnsureRelease(ctx context.Context, request Request) (Result, error) {
|
||||
if err := validateRequest(request); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
base, err := parseBaseURL(request.Destination.BaseURL)
|
||||
if err != nil {
|
||||
return Result{}, failure(FailureInvalid, "validate destination", 0, err)
|
||||
}
|
||||
client := c.httpClient()
|
||||
apiRoot := strings.TrimRight(base.String(), "/") + "/api/v1/repos/" + url.PathEscape(request.Destination.Owner) + "/" + url.PathEscape(request.Destination.Repository)
|
||||
tagURL := apiRoot + "/tags/" + url.PathEscape(request.Tag)
|
||||
|
||||
var tag tagResponse
|
||||
if err := c.getJSON(ctx, client, request.Destination.Token, "get hosted tag", tagURL, &tag); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if tag.Commit.SHA != request.Commit {
|
||||
return Result{}, failure(FailureConflict, "verify hosted tag", 0, fmt.Errorf("tag %q targets %q, want %q", request.Tag, tag.Commit.SHA, request.Commit))
|
||||
}
|
||||
|
||||
releaseURL := apiRoot + "/releases/tags/" + url.PathEscape(request.Tag)
|
||||
existing, status, err := c.getRelease(ctx, client, request.Destination.Token, releaseURL)
|
||||
if err == nil {
|
||||
return acceptExisting(existing, request)
|
||||
}
|
||||
if status != http.StatusNotFound {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
title := noteTitle(request.Note)
|
||||
body, err := json.Marshal(createReleaseRequest{
|
||||
TagName: request.Tag, TargetCommitish: request.Commit,
|
||||
Name: title, Body: request.Note,
|
||||
})
|
||||
if err != nil {
|
||||
return Result{}, failure(FailureInvalid, "encode release", 0, err)
|
||||
}
|
||||
created, postStatus, postErr := c.postRelease(ctx, client, request.Destination.Token, apiRoot+"/releases", body)
|
||||
if postErr == nil {
|
||||
result, verifyErr := acceptCreated(created, request)
|
||||
if verifyErr == nil {
|
||||
result.WriteAttempted = true
|
||||
return result, nil
|
||||
}
|
||||
postErr = verifyErr
|
||||
}
|
||||
if !shouldReconcileWrite(postStatus, postErr) {
|
||||
return Result{}, postErr
|
||||
}
|
||||
|
||||
// A conflict or interrupted server response may have followed a successful
|
||||
// create. One lookup resolves only a page with the exact selected content.
|
||||
retry, retryStatus, retryErr := c.getRelease(ctx, client, request.Destination.Token, releaseURL)
|
||||
if retryErr == nil {
|
||||
result, matchErr := acceptExisting(retry, request)
|
||||
if matchErr == nil {
|
||||
result.WriteAttempted = true
|
||||
return result, nil
|
||||
}
|
||||
return Result{}, failure(FailureConflict, "resolve release creation", postStatus, matchErr)
|
||||
}
|
||||
if retryStatus == http.StatusNotFound {
|
||||
if postStatus == http.StatusConflict {
|
||||
return Result{}, failure(FailureConflict, "create release", postStatus, postErr)
|
||||
}
|
||||
return Result{}, failure(FailureUnknown, "resolve release creation", postStatus, postErr)
|
||||
}
|
||||
return Result{}, failure(FailureUnknown, "resolve release creation", postStatus, errors.Join(postErr, retryErr))
|
||||
}
|
||||
|
||||
func shouldReconcileWrite(status int, err error) bool {
|
||||
if status == http.StatusConflict || status >= 500 || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return true
|
||||
}
|
||||
var failure *Failure
|
||||
return errors.As(err, &failure) && (failure.Kind == FailureTransport || failure.Kind == FailureMalformed)
|
||||
}
|
||||
|
||||
func (c Client) getRelease(ctx context.Context, client *http.Client, token, endpoint string) (Release, int, error) {
|
||||
func (c Client) getRelease(ctx context.Context, client *http.Client, token, endpoint string) (release, int, error) {
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return Release{}, 0, failure(FailureInvalid, "find release", 0, err)
|
||||
return release{}, 0, failure(FailureInvalid, "find release", 0, err)
|
||||
}
|
||||
request.Header.Set("Authorization", "token "+token)
|
||||
request.Header.Set("Accept", "application/json")
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return Release{}, 0, failure(FailureTransport, "find release", 0, err)
|
||||
return release{}, 0, failure(FailureTransport, "find release", 0, err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode == http.StatusNotFound {
|
||||
return Release{}, response.StatusCode, failure(FailureNotFound, "find release", response.StatusCode, nil)
|
||||
return release{}, response.StatusCode, failure(FailureNotFound, "find release", response.StatusCode, nil)
|
||||
}
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return Release{}, response.StatusCode, responseFailure("find release", response.StatusCode)
|
||||
return release{}, response.StatusCode, responseFailure("find release", response.StatusCode)
|
||||
}
|
||||
var decoded releaseResponse
|
||||
if err := decodeJSON(response.Body, &decoded); err != nil {
|
||||
return Release{}, response.StatusCode, failure(FailureMalformed, "decode release", response.StatusCode, err)
|
||||
return release{}, response.StatusCode, failure(FailureMalformed, "decode release", response.StatusCode, err)
|
||||
}
|
||||
return decoded.Release(), response.StatusCode, nil
|
||||
return decoded.release(), response.StatusCode, nil
|
||||
}
|
||||
|
||||
func (c Client) getJSON(ctx context.Context, client *http.Client, token, operation, endpoint string, destination any) error {
|
||||
@@ -234,27 +140,27 @@ func (c Client) getJSON(ctx context.Context, client *http.Client, token, operati
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c Client) postRelease(ctx context.Context, client *http.Client, token, endpoint string, body []byte) (Release, int, error) {
|
||||
func (c Client) postRelease(ctx context.Context, client *http.Client, token, endpoint string, body []byte) (release, int, error) {
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return Release{}, 0, failure(FailureInvalid, "create release", 0, err)
|
||||
return release{}, 0, failure(FailureInvalid, "create release", 0, err)
|
||||
}
|
||||
request.Header.Set("Authorization", "token "+token)
|
||||
request.Header.Set("Accept", "application/json")
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return Release{}, 0, failure(FailureTransport, "create release", 0, err)
|
||||
return release{}, 0, failure(FailureTransport, "create release", 0, err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return Release{}, response.StatusCode, responseFailure("create release", response.StatusCode)
|
||||
return release{}, response.StatusCode, responseFailure("create release", response.StatusCode)
|
||||
}
|
||||
var decoded releaseResponse
|
||||
if err := decodeJSON(response.Body, &decoded); err != nil {
|
||||
return Release{}, response.StatusCode, failure(FailureMalformed, "decode created release", response.StatusCode, err)
|
||||
return release{}, response.StatusCode, failure(FailureMalformed, "decode created release", response.StatusCode, err)
|
||||
}
|
||||
return decoded.Release(), response.StatusCode, nil
|
||||
return decoded.release(), response.StatusCode, nil
|
||||
}
|
||||
|
||||
func (c Client) httpClient() *http.Client {
|
||||
@@ -269,28 +175,6 @@ func (c Client) httpClient() *http.Client {
|
||||
return &clientCopy
|
||||
}
|
||||
|
||||
func validateRequest(request Request) error {
|
||||
if strings.TrimSpace(request.Tag) == "" {
|
||||
return failure(FailureInvalid, "validate request", 0, errors.New("full release tag is required"))
|
||||
}
|
||||
if !validObjectID(request.Commit) {
|
||||
return failure(FailureInvalid, "validate request", 0, errors.New("source commit must be a lowercase Git object ID"))
|
||||
}
|
||||
if strings.TrimSpace(request.Destination.Owner) == "" || strings.ContainsAny(request.Destination.Owner, "/\\") {
|
||||
return failure(FailureInvalid, "validate request", 0, errors.New("Gitea owner is required as one path segment"))
|
||||
}
|
||||
if strings.TrimSpace(request.Destination.Repository) == "" || strings.ContainsAny(request.Destination.Repository, "/\\") {
|
||||
return failure(FailureInvalid, "validate request", 0, errors.New("Gitea repository is required as one path segment"))
|
||||
}
|
||||
if strings.TrimSpace(request.Destination.Token) == "" {
|
||||
return failure(FailureInvalid, "validate request", 0, errors.New("Gitea token is required"))
|
||||
}
|
||||
if noteTitle(request.Note) == "" {
|
||||
return failure(FailureInvalid, "validate request", 0, errors.New("release note must begin with a level-one heading"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseBaseURL(raw string) (*url.URL, error) {
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
@@ -302,30 +186,8 @@ func parseBaseURL(raw string) (*url.URL, error) {
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func acceptExisting(release Release, request Request) (Result, error) {
|
||||
if release.TagName != request.Tag || release.Body != request.Note || release.Draft || release.Prerelease {
|
||||
return Result{}, failure(FailureConflict, "verify existing release", 0, errors.New("existing release does not match the committed note and stable tag"))
|
||||
}
|
||||
return Result{Release: release}, nil
|
||||
}
|
||||
|
||||
func acceptCreated(release Release, request Request) (Result, error) {
|
||||
if release.ID <= 0 || release.TagName != request.Tag || release.Body != request.Note || release.Title != noteTitle(request.Note) || release.Draft || release.Prerelease {
|
||||
return Result{}, failure(FailureMalformed, "verify created release", 0, errors.New("created release response does not match the requested stable release"))
|
||||
}
|
||||
return Result{Release: release, Created: true}, nil
|
||||
}
|
||||
|
||||
func (response releaseResponse) Release() Release {
|
||||
return Release{ID: response.ID, URL: response.HTMLURL, TagName: response.TagName, Title: response.Name, Body: response.Body, Draft: response.Draft, Prerelease: response.Prerelease}
|
||||
}
|
||||
|
||||
func noteTitle(note string) string {
|
||||
first := strings.SplitN(note, "\n", 2)[0]
|
||||
if !strings.HasPrefix(first, "# ") {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(strings.TrimPrefix(first, "# "))
|
||||
func (response releaseResponse) release() release {
|
||||
return release{ID: response.ID, URL: response.HTMLURL, TagName: response.TagName, Title: response.Name, Body: response.Body, Draft: response.Draft, Prerelease: response.Prerelease}
|
||||
}
|
||||
|
||||
func validObjectID(value string) bool {
|
||||
|
||||
@@ -1,434 +0,0 @@
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"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++
|
||||
var payload createReleaseRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload.TagName != tag || payload.Name != "Example v1.2.3" || payload.Body != testNote {
|
||||
t.Fatalf("create payload = %#v", payload)
|
||||
}
|
||||
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.Fatal(err)
|
||||
}
|
||||
default:
|
||||
t.Fatalf("unexpected escaped request %s %s", r.Method, r.URL.EscapedPath())
|
||||
}
|
||||
}))
|
||||
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 != 1 {
|
||||
t.Fatalf("nested tag release = %#v, posts %d, error %v", result, state.posts, 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; 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) {
|
||||
posts := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
posts++
|
||||
}
|
||||
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 != 0 {
|
||||
t.Fatalf("error = %v, posts = %d", err, posts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureReleaseWrongHostedTagStopsBeforeCreate(t *testing.T) {
|
||||
posts := 0
|
||||
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++
|
||||
}
|
||||
_, _ = 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 != 0 {
|
||||
t.Fatalf("error = %v, posts = %d", err, posts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureReleaseMissingHostedTagStopsBeforeCreate(t *testing.T) {
|
||||
posts := 0
|
||||
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++
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer server.Close()
|
||||
_, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
||||
if failureKind(t, err) != FailureNotFound || posts != 0 {
|
||||
t.Fatalf("error = %v, posts = %d", err, posts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureReleaseHonorsContextCancellation(t *testing.T) {
|
||||
called := false
|
||||
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = 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 {
|
||||
t.Fatalf("error = %v, server called = %t", err, called)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureReleaseResolvesLostCreateAcknowledgmentWithOneLookup(t *testing.T) {
|
||||
created := false
|
||||
lookups := 0
|
||||
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++
|
||||
if !created {
|
||||
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.Fatal(err)
|
||||
}
|
||||
if payload.TagName != "v1.2.3" || payload.TargetCommitish != testCommit || payload.Name != "Example v1.2.3" || payload.Body != testNote || payload.Draft || payload.Prerelease {
|
||||
t.Fatalf("create payload = %#v", payload)
|
||||
}
|
||||
created = true
|
||||
hijacker, ok := w.(http.Hijacker)
|
||||
if !ok {
|
||||
t.Fatal("HTTP writer does not support hijacking")
|
||||
}
|
||||
connection, _, err := hijacker.Hijack()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = connection.Close()
|
||||
default:
|
||||
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
result, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Created || lookups != 2 {
|
||||
t.Fatalf("result = %#v, release lookups = %d", result, lookups)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureReleaseResolvesCreateConflictWithOneLookup(t *testing.T) {
|
||||
lookups := 0
|
||||
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++
|
||||
if lookups == 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.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
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 != 2 {
|
||||
t.Fatalf("result = %#v, release lookups = %d", result, lookups)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureReleaseReportsUnknownWhenAmbiguousCreateCannotBeInspected(t *testing.T) {
|
||||
lookups := 0
|
||||
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++
|
||||
if lookups == 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.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
_, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
||||
if failureKind(t, err) != FailureUnknown || lookups != 2 {
|
||||
t.Fatalf("error = %v, release lookups = %d", err, lookups)
|
||||
}
|
||||
}
|
||||
|
||||
type releaseFixture struct {
|
||||
title string
|
||||
body string
|
||||
draft bool
|
||||
pre bool
|
||||
}
|
||||
|
||||
type fixtureState struct{ posts int }
|
||||
|
||||
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++
|
||||
var payload createReleaseRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload.TagName != "v1.2.3" || payload.TargetCommitish != testCommit || payload.Name != "Example v1.2.3" || payload.Body != testNote || payload.Draft || payload.Prerelease {
|
||||
t.Fatalf("create payload = %#v", payload)
|
||||
}
|
||||
if !createMissing {
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
return
|
||||
}
|
||||
writeRelease(t, w, releaseFixture{title: payload.Name})
|
||||
default:
|
||||
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
server := httptest.NewServer(handler)
|
||||
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.Fatalf("request = %s %s, want %s %s", request.Method, request.URL.Path, method, path)
|
||||
}
|
||||
assertAuth(t, request)
|
||||
}
|
||||
|
||||
func assertAuth(t *testing.T, request *http.Request) {
|
||||
t.Helper()
|
||||
if got := request.Header.Get("Authorization"); got != "token "+testToken {
|
||||
t.Fatalf("Authorization = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
posts, lookups := 0, 0
|
||||
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++
|
||||
if posts == 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++
|
||||
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 != 1 || lookups != 2 {
|
||||
t.Fatalf("posts=%d lookups=%d", posts, lookups)
|
||||
}
|
||||
switch mode {
|
||||
case "matching":
|
||||
if err != nil || !result.WriteAttempted {
|
||||
t.Fatalf("result=%#v err=%v", result, err)
|
||||
}
|
||||
case "conflicting":
|
||||
if failureKind(t, err) != FailureConflict {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case "unavailable":
|
||||
if failureKind(t, err) != FailureUnknown {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,242 +1,34 @@
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type DeliveryState string
|
||||
|
||||
const (
|
||||
DeliveryNotAttempted DeliveryState = "not_attempted"
|
||||
DeliveryComplete DeliveryState = "complete"
|
||||
DeliveryPartial DeliveryState = "partial"
|
||||
DeliveryConflict DeliveryState = "conflict"
|
||||
DeliveryUnknown DeliveryState = "unknown"
|
||||
)
|
||||
|
||||
type AssetIdentity struct {
|
||||
Filename string
|
||||
SHA256 string
|
||||
}
|
||||
|
||||
// UploadResult reports the current asset observations from one upload attempt.
|
||||
// Each identity appears in at most one category; early failures can leave some
|
||||
// declared assets unreported.
|
||||
type UploadResult struct {
|
||||
State DeliveryState
|
||||
// Matching contains existing attachments with the declared content.
|
||||
Matching []AssetIdentity
|
||||
// Uploaded contains assets whose upload was confirmed during this invocation.
|
||||
Uploaded []AssetIdentity
|
||||
// Pending contains assets known to be missing.
|
||||
Pending []AssetIdentity
|
||||
// Conflicting contains assets with detected content conflicts.
|
||||
Conflicting []AssetIdentity
|
||||
// Uncertain contains assets whose remote state could not be resolved.
|
||||
Uncertain []AssetIdentity
|
||||
}
|
||||
|
||||
// VerifyLocalAssets checks that every expected file still matches its declared
|
||||
// size and digest before delivery begins.
|
||||
func VerifyLocalAssets(assets []ExpectedAsset) error {
|
||||
if err := validateExpectedAssets(assets); err != nil {
|
||||
return err
|
||||
func (c Client) uploadAsset(ctx context.Context, client *http.Client, token, apiRoot string, releaseID int64, filename string, content io.Reader) (int, error) {
|
||||
if content == nil {
|
||||
return 0, failure(FailureInvalid, "upload release asset", 0, errors.New("asset content reader is required"))
|
||||
}
|
||||
for _, asset := range assets {
|
||||
if err := verifyLocalAsset(asset); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UploadMissing uploads assets classified missing by InspectAssets. It stops
|
||||
// before the first write if any declared existing asset conflicts or cannot be
|
||||
// verified, and never replaces an existing attachment.
|
||||
func (c Client) UploadMissing(ctx context.Context, destination Destination, releaseID int64, inspection Inspection) (UploadResult, error) {
|
||||
return c.uploadMissing(ctx, destination, releaseID, inspection, true)
|
||||
}
|
||||
|
||||
// UploadVerifiedMissing uploads missing assets whose local contents have already
|
||||
// passed VerifyLocalAssets. It reuses that verification while retaining remote
|
||||
// inspection, conflict handling, and upload reconciliation.
|
||||
func (c Client) UploadVerifiedMissing(ctx context.Context, destination Destination, releaseID int64, inspection Inspection) (UploadResult, error) {
|
||||
return c.uploadMissing(ctx, destination, releaseID, inspection, false)
|
||||
}
|
||||
|
||||
func (c Client) uploadMissing(ctx context.Context, destination Destination, releaseID int64, inspection Inspection, verifyLocal bool) (UploadResult, error) {
|
||||
if releaseID <= 0 {
|
||||
return UploadResult{State: DeliveryPartial}, failure(FailureInvalid, "upload release assets", 0, errors.New("release ID must be positive"))
|
||||
}
|
||||
if strings.TrimSpace(destination.Token) == "" {
|
||||
return UploadResult{State: DeliveryPartial}, failure(FailureInvalid, "upload release assets", 0, errors.New("Gitea token is required"))
|
||||
}
|
||||
base, err := parseBaseURL(destination.BaseURL)
|
||||
if err != nil {
|
||||
return UploadResult{State: DeliveryPartial}, failure(FailureInvalid, "validate destination", 0, err)
|
||||
}
|
||||
if strings.TrimSpace(destination.Owner) == "" || strings.ContainsAny(destination.Owner, "/\\") || strings.TrimSpace(destination.Repository) == "" || strings.ContainsAny(destination.Repository, "/\\") {
|
||||
return UploadResult{State: DeliveryPartial}, failure(FailureInvalid, "upload release assets", 0, errors.New("Gitea owner and repository must be path segments"))
|
||||
}
|
||||
expected := orderedExpected(inspection.Assets)
|
||||
if err := validateExpectedAssets(expected); err != nil {
|
||||
return UploadResult{State: DeliveryPartial}, err
|
||||
}
|
||||
result := UploadResult{State: DeliveryPartial}
|
||||
preflightConflict := false
|
||||
preflightUnknown := false
|
||||
for _, observation := range inspection.Assets {
|
||||
switch observation.State {
|
||||
case AssetMatching:
|
||||
result.Matching = append(result.Matching, identity(observation.Expected))
|
||||
case AssetMissing:
|
||||
result.Pending = append(result.Pending, identity(observation.Expected))
|
||||
case AssetConflicting:
|
||||
preflightConflict = true
|
||||
result.Conflicting = append(result.Conflicting, identity(observation.Expected))
|
||||
case AssetUnverifiable:
|
||||
preflightUnknown = true
|
||||
result.Uncertain = append(result.Uncertain, identity(observation.Expected))
|
||||
default:
|
||||
return result, failure(FailureInvalid, "preflight release assets", 0, fmt.Errorf("asset %q has no inspection result", observation.Expected.Filename))
|
||||
}
|
||||
}
|
||||
if preflightConflict {
|
||||
result.State = DeliveryConflict
|
||||
return result, failure(FailureConflict, "preflight release assets", 0, errors.New("one or more existing attachments conflict"))
|
||||
}
|
||||
if preflightUnknown {
|
||||
result.State = DeliveryNotAttempted
|
||||
return result, failure(FailureUnverifiable, "preflight release assets", 0, errors.New("one or more existing attachments could not be verified"))
|
||||
}
|
||||
if len(expected) == 0 {
|
||||
result.State = DeliveryComplete
|
||||
return result, nil
|
||||
}
|
||||
if len(result.Pending) == 0 {
|
||||
result.State = DeliveryComplete
|
||||
return result, nil
|
||||
}
|
||||
|
||||
missing := make(map[string]ExpectedAsset, len(result.Pending))
|
||||
for _, observation := range inspection.Assets {
|
||||
if observation.State == AssetMissing {
|
||||
missing[observation.Expected.Filename] = observation.Expected
|
||||
}
|
||||
}
|
||||
if verifyLocal {
|
||||
for _, item := range expected {
|
||||
if _, needsUpload := missing[item.Filename]; !needsUpload {
|
||||
continue
|
||||
}
|
||||
if err := verifyLocalAsset(item); err != nil {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
baseAPI := strings.TrimRight(base.String(), "/") + "/api/v1/repos/" + url.PathEscape(destination.Owner) + "/" + url.PathEscape(destination.Repository)
|
||||
client := c.httpClient()
|
||||
for _, item := range expected {
|
||||
if _, needsUpload := missing[item.Filename]; !needsUpload {
|
||||
continue
|
||||
}
|
||||
status, uploadErr := c.uploadAsset(ctx, client, destination.Token, baseAPI, releaseID, item)
|
||||
if uploadErr == nil {
|
||||
result.Uploaded = append(result.Uploaded, identity(item))
|
||||
result.Pending = removeIdentity(result.Pending, item.Filename)
|
||||
continue
|
||||
}
|
||||
if !shouldReconcileWrite(status, uploadErr) {
|
||||
return result, uploadErr
|
||||
}
|
||||
inspection, inspectErr := c.InspectAssets(ctx, destination, releaseID, []ExpectedAsset{item})
|
||||
if inspectErr != nil || len(inspection.Assets) != 1 {
|
||||
result.State = DeliveryUnknown
|
||||
result.Pending = removeIdentity(result.Pending, item.Filename)
|
||||
result.Uncertain = append(result.Uncertain, identity(item))
|
||||
return result, failure(FailureUnknown, "resolve asset upload", status, errors.Join(uploadErr, inspectErr))
|
||||
}
|
||||
observation := inspection.Assets[0]
|
||||
switch observation.State {
|
||||
case AssetMatching:
|
||||
result.Matching = append(result.Matching, identity(item))
|
||||
result.Pending = removeIdentity(result.Pending, item.Filename)
|
||||
case AssetConflicting:
|
||||
result.State = DeliveryConflict
|
||||
result.Pending = removeIdentity(result.Pending, item.Filename)
|
||||
result.Conflicting = append(result.Conflicting, identity(item))
|
||||
return result, failure(FailureConflict, "resolve asset upload", status, uploadErr)
|
||||
case AssetMissing:
|
||||
if status == http.StatusConflict {
|
||||
result.State = DeliveryConflict
|
||||
result.Pending = removeIdentity(result.Pending, item.Filename)
|
||||
result.Conflicting = append(result.Conflicting, identity(item))
|
||||
return result, failure(FailureConflict, "resolve asset upload", status, uploadErr)
|
||||
}
|
||||
result.State = DeliveryUnknown
|
||||
result.Pending = removeIdentity(result.Pending, item.Filename)
|
||||
result.Uncertain = append(result.Uncertain, identity(item))
|
||||
return result, failure(FailureUnknown, "resolve asset upload", status, uploadErr)
|
||||
default:
|
||||
result.State = DeliveryUnknown
|
||||
result.Pending = removeIdentity(result.Pending, item.Filename)
|
||||
result.Uncertain = append(result.Uncertain, identity(item))
|
||||
return result, failure(FailureUnknown, "resolve asset upload", status, errors.Join(uploadErr, observation.Err))
|
||||
}
|
||||
}
|
||||
if len(result.Pending) == 0 {
|
||||
result.State = DeliveryComplete
|
||||
} else {
|
||||
result.State = DeliveryPartial
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c Client) uploadAsset(ctx context.Context, client *http.Client, token, apiRoot string, releaseID int64, asset ExpectedAsset) (int, error) {
|
||||
uploadURL := apiRoot + "/releases/" + strconv.FormatInt(releaseID, 10) + "/assets"
|
||||
parsed, err := url.Parse(uploadURL)
|
||||
if err != nil {
|
||||
return 0, failure(FailureInvalid, "prepare asset upload", 0, err)
|
||||
}
|
||||
query := parsed.Query()
|
||||
query.Set("name", asset.Filename)
|
||||
query.Set("name", filename)
|
||||
parsed.RawQuery = query.Encode()
|
||||
reader, writer := io.Pipe()
|
||||
multipartWriter := multipart.NewWriter(writer)
|
||||
writeDone := make(chan error, 1)
|
||||
go func() {
|
||||
part, partErr := multipartWriter.CreateFormFile("attachment", asset.Filename)
|
||||
part, partErr := multipartWriter.CreateFormFile("attachment", filename)
|
||||
if partErr == nil {
|
||||
var source io.Reader
|
||||
if len(asset.Content) > 0 {
|
||||
source = bytes.NewReader(asset.Content)
|
||||
} else {
|
||||
file, openErr := os.Open(asset.Path)
|
||||
if openErr != nil {
|
||||
partErr = openErr
|
||||
} else {
|
||||
defer file.Close()
|
||||
source = file
|
||||
}
|
||||
}
|
||||
if partErr == nil {
|
||||
_, partErr = io.Copy(part, source)
|
||||
}
|
||||
_, partErr = io.Copy(part, content)
|
||||
}
|
||||
closeErr := multipartWriter.Close()
|
||||
if partErr == nil {
|
||||
@@ -270,7 +62,7 @@ func (c Client) uploadAsset(ctx context.Context, client *http.Client, token, api
|
||||
if err := decodeJSON(response.Body, &uploaded); err != nil {
|
||||
return response.StatusCode, failure(FailureMalformed, "decode uploaded asset", response.StatusCode, err)
|
||||
}
|
||||
if uploaded.ID <= 0 || uploaded.Name != asset.Filename {
|
||||
if uploaded.ID <= 0 || uploaded.Name != filename {
|
||||
return response.StatusCode, failure(FailureMalformed, "verify uploaded asset", response.StatusCode, errors.New("upload response does not identify the selected asset"))
|
||||
}
|
||||
if writeErr != nil {
|
||||
@@ -278,66 +70,3 @@ func (c Client) uploadAsset(ctx context.Context, client *http.Client, token, api
|
||||
}
|
||||
return response.StatusCode, nil
|
||||
}
|
||||
|
||||
func verifyLocalAsset(asset ExpectedAsset) error {
|
||||
if len(asset.Content) > 0 {
|
||||
if int64(len(asset.Content)) != asset.Size || digest(asset.Content) != asset.SHA256 {
|
||||
return failure(FailureConflict, "verify local asset", 0, fmt.Errorf("local content does not match declared asset %q", asset.Filename))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if asset.Path == "" || filepath.Base(asset.Path) != asset.Filename {
|
||||
return failure(FailureInvalid, "verify local asset", 0, fmt.Errorf("local path for %q does not match its filename", asset.Filename))
|
||||
}
|
||||
file, err := os.Open(asset.Path)
|
||||
if err != nil {
|
||||
return failure(FailureInvalid, "verify local asset", 0, fmt.Errorf("open %q: %w", asset.Filename, err))
|
||||
}
|
||||
defer file.Close()
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return failure(FailureInvalid, "verify local asset", 0, fmt.Errorf("stat %q: %w", asset.Filename, err))
|
||||
}
|
||||
if !info.Mode().IsRegular() || info.Size() != asset.Size {
|
||||
return failure(FailureConflict, "verify local asset", 0, fmt.Errorf("local file does not match declared size for %q", asset.Filename))
|
||||
}
|
||||
hasher := sha256.New()
|
||||
size, err := io.Copy(hasher, file)
|
||||
if err != nil {
|
||||
return failure(FailureInvalid, "verify local asset", 0, fmt.Errorf("read %q: %w", asset.Filename, err))
|
||||
}
|
||||
if size != asset.Size || hex.EncodeToString(hasher.Sum(nil)) != asset.SHA256 {
|
||||
return failure(FailureConflict, "verify local asset", 0, fmt.Errorf("local content does not match declared asset %q", asset.Filename))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func orderedExpected(observations []AssetInspection) []ExpectedAsset {
|
||||
result := make([]ExpectedAsset, 0, len(observations))
|
||||
for _, observation := range observations {
|
||||
result = append(result, observation.Expected)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
if result[i].Filename == ManifestFilename {
|
||||
return false
|
||||
}
|
||||
if result[j].Filename == ManifestFilename {
|
||||
return true
|
||||
}
|
||||
return result[i].Filename < result[j].Filename
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
func identity(asset ExpectedAsset) AssetIdentity {
|
||||
return AssetIdentity{Filename: asset.Filename, SHA256: asset.SHA256}
|
||||
}
|
||||
|
||||
func removeIdentity(identities []AssetIdentity, filename string) []AssetIdentity {
|
||||
for index, item := range identities {
|
||||
if item.Filename == filename {
|
||||
return append(identities[:index], identities[index+1:]...)
|
||||
}
|
||||
}
|
||||
return identities
|
||||
}
|
||||
|
||||
@@ -1,482 +0,0 @@
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUploadMissingVerifiesEveryMissingLocalFileBeforeWriting(t *testing.T) {
|
||||
fixture := &uploadFixture{files: make(map[string][]byte)}
|
||||
server := httptest.NewServer(fixture)
|
||||
defer server.Close()
|
||||
client := testClient(server)
|
||||
destination := testDestination(server.URL)
|
||||
assets := localAssets(t, []string{"alpha.bin", "beta.bin"})
|
||||
inspection, err := client.InspectAssets(t.Context(), destination, 10, assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
contents := readExpectedFile(t, assets[1])
|
||||
contents[0] ^= 0xff
|
||||
if err := os.WriteFile(assets[1].Path, contents, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := client.UploadMissing(t.Context(), destination, 10, inspection)
|
||||
if err == nil || result.State != DeliveryPartial {
|
||||
t.Fatalf("upload = %#v, error = %v; want local verification failure", result, err)
|
||||
}
|
||||
if posts := fixture.postNames(); len(posts) != 0 {
|
||||
t.Fatalf("uploaded %v before rejecting local content", posts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadMissingPartialFailureThenRerunOnlyMissingAssets(t *testing.T) {
|
||||
fixture := &uploadFixture{files: make(map[string][]byte), failName: "beta.bin"}
|
||||
server := httptest.NewServer(fixture)
|
||||
defer server.Close()
|
||||
client := testClient(server)
|
||||
destination := testDestination(server.URL)
|
||||
assets := localAssets(t, []string{"zeta.bin", "alpha.bin", "beta.bin"})
|
||||
manifest, err := BuildManifest(assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assets = append(assets, manifest)
|
||||
|
||||
initial, err := client.InspectAssets(context.Background(), destination, 10, assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := client.UploadMissing(context.Background(), destination, 10, initial)
|
||||
if err == nil || first.State != DeliveryPartial {
|
||||
t.Fatalf("first upload result = %#v, error = %v", first, err)
|
||||
}
|
||||
if got, want := names(first.Uploaded), []string{"alpha.bin"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("uploaded = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := names(first.Pending), []string{"zeta.bin", "beta.bin", "SHA256SUMS"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("pending = %v, want %v", got, want)
|
||||
}
|
||||
assertAssetCategoriesDisjoint(t, first)
|
||||
|
||||
secondInspection, err := client.InspectAssets(context.Background(), destination, 10, assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := client.UploadMissing(context.Background(), destination, 10, secondInspection)
|
||||
if err != nil || second.State != DeliveryComplete {
|
||||
t.Fatalf("second upload result = %#v, error = %v", second, err)
|
||||
}
|
||||
if got, want := names(second.Uploaded), []string{"beta.bin", "zeta.bin", "SHA256SUMS"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("second uploaded = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := fixture.postNames(), []string{"alpha.bin", "beta.bin", "beta.bin", "zeta.bin", "SHA256SUMS"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("POST order = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadMissingAcceptsConcurrentMatchingAttachmentWithoutReplacement(t *testing.T) {
|
||||
fixture := &uploadFixture{files: make(map[string][]byte), conflictName: "app.bin"}
|
||||
server := httptest.NewServer(fixture)
|
||||
defer server.Close()
|
||||
client := testClient(server)
|
||||
destination := testDestination(server.URL)
|
||||
assets := localAssets(t, []string{"app.bin"})
|
||||
initial, err := client.InspectAssets(context.Background(), destination, 10, assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := client.UploadMissing(context.Background(), destination, 10, initial)
|
||||
if err != nil || result.State != DeliveryComplete {
|
||||
t.Fatalf("result = %#v, error = %v", result, err)
|
||||
}
|
||||
if got, want := names(result.Matching), []string{"app.bin"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("matching = %v, want %v", got, want)
|
||||
}
|
||||
if len(result.Uploaded) != 0 || len(fixture.postNames()) != 1 {
|
||||
t.Fatalf("result = %#v, POST names = %v", result, fixture.postNames())
|
||||
}
|
||||
assertAssetCategoriesDisjoint(t, result)
|
||||
}
|
||||
|
||||
func TestUploadMissingReconcilesLostManifestAcknowledgment(t *testing.T) {
|
||||
assets := localAssets(t, []string{"app.bin"})
|
||||
manifest, err := BuildManifest(assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fixture := &uploadFixture{files: make(map[string][]byte), dropAckName: ManifestFilename}
|
||||
// The local binary is represented by its file path; the server starts with
|
||||
// that exact content so only the checksum manifest needs uploading.
|
||||
fixture.files["app.bin"] = readExpectedFile(t, assets[0])
|
||||
server := httptest.NewServer(fixture)
|
||||
defer server.Close()
|
||||
client := testClient(server)
|
||||
destination := testDestination(server.URL)
|
||||
expected := append(assets, manifest)
|
||||
initial, err := client.InspectAssets(context.Background(), destination, 10, expected)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := client.UploadMissing(context.Background(), destination, 10, initial)
|
||||
if err != nil || result.State != DeliveryComplete {
|
||||
t.Fatalf("result = %#v, error = %v", result, err)
|
||||
}
|
||||
if got, want := names(result.Matching), []string{"app.bin", ManifestFilename}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("matching = %v, want %v", got, want)
|
||||
}
|
||||
if len(result.Uploaded) != 0 || !reflect.DeepEqual(fixture.postNames(), []string{ManifestFilename}) {
|
||||
t.Fatalf("result = %#v, POST names = %v", result, fixture.postNames())
|
||||
}
|
||||
assertAssetCategoriesDisjoint(t, result)
|
||||
}
|
||||
|
||||
func TestUploadMissingReportsUnknownWhenReconciliationReadFails(t *testing.T) {
|
||||
fixture := &uploadFixture{files: make(map[string][]byte), dropAckName: "app.bin", failListAfter: 1}
|
||||
server := httptest.NewServer(fixture)
|
||||
defer server.Close()
|
||||
client := testClient(server)
|
||||
destination := testDestination(server.URL)
|
||||
assets := localAssets(t, []string{"app.bin", "later.bin"})
|
||||
initial, err := client.InspectAssets(context.Background(), destination, 10, assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := client.UploadMissing(context.Background(), destination, 10, initial)
|
||||
if err == nil || result.State != DeliveryUnknown {
|
||||
t.Fatalf("result = %#v, error = %v", result, err)
|
||||
}
|
||||
if got, want := names(result.Pending), []string{"later.bin"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("pending = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := names(result.Uncertain), []string{"app.bin"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("uncertain = %v, want %v", got, want)
|
||||
}
|
||||
assertAssetCategoriesDisjoint(t, result)
|
||||
if got := fixture.postNames(); !reflect.DeepEqual(got, []string{"app.bin"}) {
|
||||
t.Fatalf("POST names = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadMissingPreflightConflictPreventsEveryWrite(t *testing.T) {
|
||||
fixture := &uploadFixture{files: map[string][]byte{"app.bin": []byte("different bytes")}}
|
||||
server := httptest.NewServer(fixture)
|
||||
defer server.Close()
|
||||
client := testClient(server)
|
||||
destination := testDestination(server.URL)
|
||||
assets := localAssets(t, []string{"app.bin", "other.bin"})
|
||||
inspection, err := client.InspectAssets(context.Background(), destination, 10, assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := client.UploadMissing(context.Background(), destination, 10, inspection)
|
||||
if err == nil || result.State != DeliveryConflict {
|
||||
t.Fatalf("result = %#v, error = %v", result, err)
|
||||
}
|
||||
if len(fixture.postNames()) != 0 {
|
||||
t.Fatalf("uploads happened before conflict review: %v", fixture.postNames())
|
||||
}
|
||||
if got, want := names(result.Conflicting), []string{"app.bin"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("conflicting = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := names(result.Pending), []string{"other.bin"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("pending = %v, want %v", got, want)
|
||||
}
|
||||
assertAssetCategoriesDisjoint(t, result)
|
||||
}
|
||||
|
||||
func TestUploadMissingKeepsLaterPendingAfterAmbiguousMissingUpload(t *testing.T) {
|
||||
fixture := &uploadFixture{files: make(map[string][]byte), dropAckMissingName: "alpha.bin"}
|
||||
server := httptest.NewServer(fixture)
|
||||
defer server.Close()
|
||||
client := testClient(server)
|
||||
destination := testDestination(server.URL)
|
||||
assets := localAssets(t, []string{"alpha.bin", "zeta.bin"})
|
||||
initial, err := client.InspectAssets(context.Background(), destination, 10, assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := client.UploadMissing(context.Background(), destination, 10, initial)
|
||||
if err == nil || result.State != DeliveryUnknown {
|
||||
t.Fatalf("result = %#v, error = %v", result, err)
|
||||
}
|
||||
if got, want := names(result.Uncertain), []string{"alpha.bin"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("uncertain = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := names(result.Pending), []string{"zeta.bin"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("pending = %v, want %v", got, want)
|
||||
}
|
||||
assertAssetCategoriesDisjoint(t, result)
|
||||
if got, want := fixture.postNames(), []string{"alpha.bin"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("POST names = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadMissingMovesReconciledConflictOutOfPending(t *testing.T) {
|
||||
fixture := &uploadFixture{
|
||||
files: make(map[string][]byte),
|
||||
conflictName: "alpha.bin",
|
||||
conflictDifferentContent: true,
|
||||
}
|
||||
server := httptest.NewServer(fixture)
|
||||
defer server.Close()
|
||||
client := testClient(server)
|
||||
destination := testDestination(server.URL)
|
||||
assets := localAssets(t, []string{"alpha.bin", "zeta.bin"})
|
||||
initial, err := client.InspectAssets(context.Background(), destination, 10, assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := client.UploadMissing(context.Background(), destination, 10, initial)
|
||||
if err == nil || result.State != DeliveryConflict {
|
||||
t.Fatalf("result = %#v, error = %v", result, err)
|
||||
}
|
||||
if got, want := names(result.Conflicting), []string{"alpha.bin"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("conflicting = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := names(result.Pending), []string{"zeta.bin"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("pending = %v, want %v", got, want)
|
||||
}
|
||||
assertAssetCategoriesDisjoint(t, result)
|
||||
}
|
||||
|
||||
func TestUploadMissingTreatsUnresolvedNameConflictAsConflict(t *testing.T) {
|
||||
fixture := &uploadFixture{files: make(map[string][]byte), conflictMissingName: "alpha.bin"}
|
||||
server := httptest.NewServer(fixture)
|
||||
defer server.Close()
|
||||
client := testClient(server)
|
||||
destination := testDestination(server.URL)
|
||||
assets := localAssets(t, []string{"alpha.bin", "zeta.bin"})
|
||||
initial, err := client.InspectAssets(context.Background(), destination, 10, assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := client.UploadMissing(context.Background(), destination, 10, initial)
|
||||
if err == nil || result.State != DeliveryConflict {
|
||||
t.Fatalf("result = %#v, error = %v", result, err)
|
||||
}
|
||||
if got, want := names(result.Conflicting), []string{"alpha.bin"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("conflicting = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := names(result.Pending), []string{"zeta.bin"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("pending = %v, want %v", got, want)
|
||||
}
|
||||
assertAssetCategoriesDisjoint(t, result)
|
||||
}
|
||||
|
||||
type uploadFixture struct {
|
||||
mu sync.Mutex
|
||||
files map[string][]byte
|
||||
posts []string
|
||||
failName string
|
||||
conflictName string
|
||||
conflictDifferentContent bool
|
||||
conflictMissingName string
|
||||
dropAckName string
|
||||
dropAckMissingName string
|
||||
failListAfter int
|
||||
listCount int
|
||||
}
|
||||
|
||||
func (fixture *uploadFixture) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "token "+testToken {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodGet && r.URL.Path == assetListPath {
|
||||
fixture.mu.Lock()
|
||||
fixture.listCount++
|
||||
if fixture.failListAfter > 0 && fixture.listCount > fixture.failListAfter {
|
||||
fixture.mu.Unlock()
|
||||
http.Error(w, "read failed", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
assets := make([]assetResponse, 0, len(fixture.files))
|
||||
for name, content := range fixture.files {
|
||||
assets = append(assets, assetResponse{ID: int64(len(assets) + 1), Name: name, Size: int64(len(content)), BrowserDownloadURL: "/files/" + urlPathEscape(name)})
|
||||
}
|
||||
fixture.mu.Unlock()
|
||||
_ = json.NewEncoder(w).Encode(assets)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/files/") {
|
||||
name := strings.TrimPrefix(r.URL.Path, "/files/")
|
||||
fixture.mu.Lock()
|
||||
content, ok := fixture.files[name]
|
||||
fixture.mu.Unlock()
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(content)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodPost && r.URL.Path == assetListPath {
|
||||
fixture.receiveUpload(w, r)
|
||||
return
|
||||
}
|
||||
http.Error(w, "unexpected request", http.StatusMethodNotAllowed)
|
||||
}
|
||||
|
||||
func (fixture *uploadFixture) receiveUpload(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data; boundary=") {
|
||||
http.Error(w, "multipart required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
||||
http.Error(w, "invalid multipart body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
file, header, err := r.FormFile("attachment")
|
||||
if err != nil {
|
||||
http.Error(w, "missing attachment field", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
content, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
http.Error(w, "read multipart file", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if header.Filename != r.URL.Query().Get("name") {
|
||||
http.Error(w, "filename query mismatch", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
name := header.Filename
|
||||
fixture.mu.Lock()
|
||||
fixture.posts = append(fixture.posts, name)
|
||||
attempts := 0
|
||||
for _, posted := range fixture.posts {
|
||||
if posted == name {
|
||||
attempts++
|
||||
}
|
||||
}
|
||||
if name == fixture.failName && attempts == 1 {
|
||||
fixture.mu.Unlock()
|
||||
http.Error(w, "write failed", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if name == fixture.dropAckMissingName {
|
||||
fixture.mu.Unlock()
|
||||
hijacker, ok := w.(http.Hijacker)
|
||||
if !ok {
|
||||
http.Error(w, "hijacking unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
connection, _, err := hijacker.Hijack()
|
||||
if err == nil {
|
||||
_ = connection.Close()
|
||||
}
|
||||
return
|
||||
}
|
||||
if name == fixture.conflictMissingName {
|
||||
fixture.mu.Unlock()
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
return
|
||||
}
|
||||
if name == fixture.conflictName && fixture.conflictDifferentContent {
|
||||
fixture.files[name] = []byte("conflicting bytes")
|
||||
fixture.mu.Unlock()
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
return
|
||||
}
|
||||
fixture.files[name] = append([]byte(nil), content...)
|
||||
fixture.mu.Unlock()
|
||||
if name == fixture.conflictName {
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
return
|
||||
}
|
||||
if name == fixture.dropAckName {
|
||||
hijacker, ok := w.(http.Hijacker)
|
||||
if !ok {
|
||||
http.Error(w, "hijacking unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
connection, _, err := hijacker.Hijack()
|
||||
if err == nil {
|
||||
_ = connection.Close()
|
||||
}
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(assetResponse{ID: 50, Name: name, Size: int64(len(content))})
|
||||
}
|
||||
|
||||
func localAssets(t *testing.T, names []string) []ExpectedAsset {
|
||||
t.Helper()
|
||||
output := t.TempDir()
|
||||
assets := make([]ExpectedAsset, 0, len(names))
|
||||
for _, name := range names {
|
||||
content := []byte("binary-" + name)
|
||||
path := filepath.Join(output, name)
|
||||
if err := os.WriteFile(path, content, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sum := assetFor(name, content)
|
||||
sum.Content = nil
|
||||
sum.Path = path
|
||||
assets = append(assets, sum)
|
||||
}
|
||||
return assets
|
||||
}
|
||||
|
||||
func readExpectedFile(t *testing.T, asset ExpectedAsset) []byte {
|
||||
t.Helper()
|
||||
content, err := os.ReadFile(asset.Path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
func (fixture *uploadFixture) postNames() []string {
|
||||
fixture.mu.Lock()
|
||||
defer fixture.mu.Unlock()
|
||||
return append([]string(nil), fixture.posts...)
|
||||
}
|
||||
|
||||
func names(identities []AssetIdentity) []string {
|
||||
result := make([]string, 0, len(identities))
|
||||
for _, identity := range identities {
|
||||
result = append(result, identity.Filename)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func assertAssetCategoriesDisjoint(t *testing.T, result UploadResult) {
|
||||
t.Helper()
|
||||
groups := []struct {
|
||||
name string
|
||||
identities []AssetIdentity
|
||||
}{
|
||||
{name: "matching", identities: result.Matching},
|
||||
{name: "uploaded", identities: result.Uploaded},
|
||||
{name: "pending", identities: result.Pending},
|
||||
{name: "conflicting", identities: result.Conflicting},
|
||||
{name: "uncertain", identities: result.Uncertain},
|
||||
}
|
||||
seen := make(map[string]string)
|
||||
for _, group := range groups {
|
||||
for _, item := range group.identities {
|
||||
if previous, ok := seen[item.Filename]; ok {
|
||||
t.Errorf("asset %q appears in both %s and %s", item.Filename, previous, group.name)
|
||||
}
|
||||
seen[item.Filename] = group.name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func urlPathEscape(value string) string {
|
||||
return (&url.URL{Path: value}).EscapedPath()
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/process"
|
||||
"go.maximumdirect.net/releasekit/internal/process"
|
||||
)
|
||||
|
||||
// Mode selects the target environment for a Go command.
|
||||
|
||||
@@ -8,10 +8,10 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
|
||||
"go.maximumdirect.net/releasekit/internal/candidate"
|
||||
"go.maximumdirect.net/releasekit/internal/git"
|
||||
"go.maximumdirect.net/releasekit/internal/gotool"
|
||||
"go.maximumdirect.net/releasekit/internal/versioncontract"
|
||||
)
|
||||
|
||||
type TaggedCheckoutRequest struct {
|
||||
|
||||
@@ -8,10 +8,10 @@ import (
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/validation"
|
||||
"go.maximumdirect.net/releasekit/internal/candidate"
|
||||
"go.maximumdirect.net/releasekit/internal/git"
|
||||
"go.maximumdirect.net/releasekit/internal/gotool"
|
||||
"go.maximumdirect.net/releasekit/internal/validation"
|
||||
)
|
||||
|
||||
type State string
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
|
||||
"go.maximumdirect.net/releasekit/internal/git"
|
||||
"go.maximumdirect.net/releasekit/internal/versioncontract"
|
||||
"golang.org/x/mod/modfile"
|
||||
"golang.org/x/mod/module"
|
||||
)
|
||||
|
||||
@@ -27,6 +27,8 @@ const (
|
||||
MarkdownLinks Check = "markdown-links"
|
||||
// SharedFiles identifies a requested shared file that is unavailable.
|
||||
SharedFiles Check = "shared-files"
|
||||
// ForbiddenFiles identifies selected paths present in the committed source.
|
||||
ForbiddenFiles Check = "forbidden-files"
|
||||
)
|
||||
|
||||
// Finding describes a validation observation at an optional source location.
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
|
||||
"go.maximumdirect.net/releasekit/internal/git"
|
||||
)
|
||||
|
||||
const defaultFileSizeLimit int64 = 1_000_000
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
|
||||
"go.maximumdirect.net/releasekit/internal/git"
|
||||
)
|
||||
|
||||
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"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
|
||||
"go.maximumdirect.net/releasekit/internal/git"
|
||||
)
|
||||
|
||||
func TestCheckGoFormatUsesCapturedRegularGoBlobs(t *testing.T) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
|
||||
"go.maximumdirect.net/releasekit/internal/git"
|
||||
"golang.org/x/mod/modfile"
|
||||
)
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"path"
|
||||
"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 {
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
|
||||
"go.maximumdirect.net/releasekit/internal/git"
|
||||
)
|
||||
|
||||
func BenchmarkSelectInputEntriesByModuleCount(b *testing.B) {
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
|
||||
"go.maximumdirect.net/releasekit/internal/git"
|
||||
)
|
||||
|
||||
// CandidateTree is an inventory of one captured commit with blob contents
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool"
|
||||
"go.maximumdirect.net/releasekit/internal/gotool"
|
||||
)
|
||||
|
||||
// Settings selects shared validation behavior.
|
||||
@@ -21,6 +21,7 @@ type Settings struct {
|
||||
FullVet bool
|
||||
CompileTargets []Target
|
||||
SharedFiles []string
|
||||
ForbiddenFiles []string
|
||||
GoFormat bool
|
||||
Markdown *MarkdownSettings
|
||||
ModuleDistribution ModuleDistributionSettings
|
||||
@@ -134,7 +135,7 @@ func Run(ctx context.Context, request Request) (result error) {
|
||||
}
|
||||
}
|
||||
needsSharedSelection := len(request.Settings.SharedFiles) != 0
|
||||
if request.Settings.GoFormat || request.Settings.ModuleDistribution.WorkspaceFiles || request.Settings.ModuleDistribution.LocalReplacements || request.Settings.FileSize != nil || request.Settings.Markdown != nil || needsSharedSelection {
|
||||
if request.Settings.GoFormat || request.Settings.ModuleDistribution.WorkspaceFiles || request.Settings.ModuleDistribution.LocalReplacements || request.Settings.FileSize != nil || request.Settings.Markdown != nil || needsSharedSelection || len(request.Settings.ForbiddenFiles) != 0 {
|
||||
tree, err := request.ReadCandidateTree(ctx)
|
||||
if err != nil {
|
||||
var failure *Failure
|
||||
@@ -154,6 +155,9 @@ func Run(ctx context.Context, request Request) (result error) {
|
||||
if request.Settings.GoFormat {
|
||||
check = GoFormat
|
||||
}
|
||||
if len(request.Settings.ForbiddenFiles) != 0 {
|
||||
check = ForbiddenFiles
|
||||
}
|
||||
return &Failure{Check: check, Cause: err}
|
||||
}
|
||||
lastContentCheck := Check("")
|
||||
@@ -166,6 +170,9 @@ func Run(ctx context.Context, request Request) (result error) {
|
||||
}
|
||||
}
|
||||
}()
|
||||
if err := checkForbiddenFiles(tree, request.Settings.ForbiddenFiles); err != nil {
|
||||
return err
|
||||
}
|
||||
if request.Settings.GoFormat {
|
||||
readsBefore := tree.blobReads
|
||||
if err := checkGoFormat(ctx, tree); err != nil {
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
|
||||
"go.maximumdirect.net/releasekit/internal/git"
|
||||
)
|
||||
|
||||
func TestCheckWorkspaceFilesReportsCommittedBlobNames(t *testing.T) {
|
||||
|
||||
@@ -4,4 +4,4 @@ package versioncontract
|
||||
|
||||
// InjectedVersionLinkerSymbol is the -X linker target used to set the runtime
|
||||
// version. Keep it in sync with runtimeinfo.injectedVersion.
|
||||
const InjectedVersionLinkerSymbol = "gitea.maximumdirect.net/eric/ReleaseKit/internal/runtimeinfo.injectedVersion"
|
||||
const InjectedVersionLinkerSymbol = "go.maximumdirect.net/releasekit/internal/runtimeinfo.injectedVersion"
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit"
|
||||
"go.maximumdirect.net/releasekit"
|
||||
)
|
||||
|
||||
func BenchmarkPerformanceFacadeCheck(b *testing.B) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user