41 Commits
v0.4.0 ... main

Author SHA1 Message Date
3f00ad2754 Remove completed release delivery plans and audit records 2026-11-01 19:37:40 +00:00
e42cad7b1a Preserve asset inspection error causes through delivery 2026-11-01 19:33:44 +00:00
eb61b40f08 Sort only missing release assets once 2026-11-01 19:29:50 +00:00
b72af69baf Make pending asset bookkeeping linear 2026-11-01 19:29:17 +00:00
078db6cbcb Validate CI module tags before delivery preparation 2026-11-01 19:27:56 +00:00
9376bafaa2 Align completed forge roadmap status records 2026-11-01 19:26:52 +00:00
6bed1cd691 Clarify delivery exit statuses by failure phase 2026-11-01 19:26:30 +00:00
7b5c1285a2 Exclude forge contracts from runtime dependencies 2026-11-01 19:25:15 +00:00
e5512d50b0 Classify invalid delivery destinations during preflight 2026-11-01 19:24:52 +00:00
627d524aa2 Record algorithmic delivery audit findings 2026-11-01 19:22:04 +00:00
370fe3a9f5 Record delivery integration audit findings 2026-11-01 19:11:51 +00:00
72de18744b Record forge delivery review findings 2026-11-01 18:51:43 +00:00
cbdba90014 Document neutral forge delivery and preflight 2026-11-01 18:38:03 +00:00
c3f6eeb6df Complete forge delivery regression coverage 2026-11-01 18:30:52 +00:00
2e617db23c Connect neutral delivery to the Gitea CLI 2026-11-01 18:20:01 +00:00
b350c71246 Move asset reconciliation into shared delivery 2026-11-01 18:07:55 +00:00
88a79c3561 Move release page recovery into shared delivery 2026-11-01 18:04:30 +00:00
ddac9e3c73 Return write evidence from Gitea operations 2026-11-01 18:02:05 +00:00
02bd120aed Preflight Gitea credentials with repository reads 2026-11-01 17:59:43 +00:00
201af83d95 Add Gitea read operations to the forge boundary 2026-11-01 17:57:57 +00:00
61a07bc950 Move asset verification into the artifact package 2026-11-01 17:55:53 +00:00
aa03a78476 Define the internal forge delivery contract 2026-11-01 17:52:38 +00:00
9d6952a772 Plan credential preflight for forge delivery 2026-11-01 17:44:47 +00:00
44823ce323 Plan the neutral forge delivery refactor 2026-11-01 16:37:50 +00:00
71746f437e Require explicit forge selection in the neutral destination plan 2026-11-01 16:25:54 +00:00
2700bd18cb Plan the internal forge delivery boundary 2026-11-01 16:23:58 +00:00
ac78e7db73 Rename the candidate file validator to forbidden files 2026-11-01 16:02:51 +00:00
792bfc4eca Read delivery tokens from files and check selected paths before release 2026-11-01 15:39:36 +00:00
6650b639e1 Prepare v0.5.0 with the current Go toolchain and retire the migration roadmap 2026-11-01 14:44:40 +00:00
a1cac0898e Record completion and verification of the module migration 2026-11-01 14:41:36 +00:00
3b803d558b Migrate ReleaseKit to the vanity module path and new Gitea host 2026-11-01 14:37:41 +00:00
56a86d6d49 Clarify the Gitea hostname migration in the roadmap 2026-11-01 14:28:10 +00:00
f97edeb830 Plan the ReleaseKit vanity module migration 2026-11-01 14:24:43 +00:00
7d033c5ca1 Prepare v0.4.2 release notes and retire the completed bug report 2026-10-11 02:10:48 +00:00
f076da99b6 Resolve annotated tag events in Woodpecker delivery workflows 2026-10-11 02:07:54 +00:00
3f2a1a1052 Document Audita's annotated tag delivery failure 2026-10-11 01:54:26 +00:00
2d5a8110c6 Prepare v0.4.1 release notes 2026-10-11 00:57:29 +00:00
d42727b038 Map every concurrency audit finding to its roadmap correction 2026-10-11 00:17:06 +00:00
059a79525a Synchronize delivery test fixtures and record the concurrency audit 2026-10-11 00:07:25 +00:00
6176971307 Plan synchronization of delivery test fixtures 2026-10-10 23:56:12 +00:00
8f6e72b0b8 Report source delivery test fixture races 2026-10-10 23:52:34 +00:00
113 changed files with 4888 additions and 2461 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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")

View File

@@ -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

View File

@@ -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()) }

View File

@@ -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{

View File

@@ -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())
}
})
}

View 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)
}
})
}
}

View File

@@ -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())
}
}
}

View 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())
}
})
}
}

View 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)
}
})
}
}
}

View File

@@ -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()

View File

@@ -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.

View File

@@ -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"

View File

@@ -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())
}
}

View File

@@ -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

View File

@@ -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: &current, Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}}
settings := cli.Settings{Product: "Example Product", Build: &current, 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")

View File

@@ -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")

View File

@@ -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 {

View File

@@ -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",
},
}

View File

@@ -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)

View 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")
}
})
}
}

View File

@@ -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)
}

View File

@@ -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
View 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)
}
})
}
}

View 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)
}
})
}
}

View File

@@ -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,

View File

@@ -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,
})
}

View 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.

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -8,7 +8,7 @@ import (
"context"
"fmt"
releasekit "gitea.maximumdirect.net/eric/ReleaseKit"
releasekit "go.maximumdirect.net/releasekit"
)
func build(ctx context.Context) error {

View File

@@ -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

View File

@@ -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
)
```

View File

@@ -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()
```

View File

@@ -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`;

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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
View 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
View 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
View 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
View 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)

View File

@@ -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
```

View File

@@ -3,7 +3,7 @@ package main
import (
"fmt"
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo"
"go.maximumdirect.net/releasekit/buildinfo"
)
func main() {

View File

@@ -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

View File

@@ -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}"

View File

@@ -7,6 +7,7 @@
"targets": ["linux/amd64", "linux/arm64", "darwin/arm64"]
},
"delivery": {
"forge": "gitea",
"base_url": "https://gitea.example",
"owner": "example",
"repository": "example",

View File

@@ -2,6 +2,7 @@
"product": "Example",
"remote": "origin",
"delivery": {
"forge": "gitea",
"base_url": "https://gitea.example",
"owner": "example",
"repository": "example",

View File

@@ -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}"

View File

@@ -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
View File

@@ -1,4 +1,4 @@
module gitea.maximumdirect.net/eric/ReleaseKit
module go.maximumdirect.net/releasekit
go 1.26.0

View File

@@ -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
}
}

View File

@@ -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
View 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[:])
}

View 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[:])
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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")

View File

@@ -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"

View File

@@ -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)
}

View File

@@ -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"

View File

@@ -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
View 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
}

View 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
}

View File

@@ -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)
}
}

View 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}
}

View 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
View 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
}

View File

@@ -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")

View File

@@ -9,7 +9,7 @@ import (
"fmt"
"strings"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/process"
"go.maximumdirect.net/releasekit/internal/process"
)
var (

View File

@@ -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.

View File

@@ -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

View File

@@ -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
View 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)

View 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
}

View File

@@ -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 {

View File

@@ -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)
}
}
})
}
}

View File

@@ -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
}

View File

@@ -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()
}

View File

@@ -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.

View File

@@ -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 {

View File

@@ -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

View File

@@ -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"
)

View File

@@ -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.

View File

@@ -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

View File

@@ -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) {

View 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}
}

View 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)
}
}
}

View File

@@ -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) {

View File

@@ -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"
)

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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"

View File

@@ -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