From 4dee2841e010a5e10efba36d3a2d76abbe501d83 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Tue, 6 Oct 2026 00:58:22 +0000 Subject: [PATCH] Move release mechanics behind the public facade --- build.go | 36 +++++++++++++------ check.go | 3 +- delivery.go | 31 ++-------------- docs/internal/overview.md | 4 +-- docs/roadmap/remediation.md | 14 ++++++-- .../build/candidate.go | 6 ++-- .../delivery/assets.go | 5 +-- .../delivery/assets_test.go | 21 +++++------ internal/delivery/delivery.go | 35 +++++++++--------- publish.go | 3 +- 10 files changed, 78 insertions(+), 80 deletions(-) rename candidate_build.go => internal/build/candidate.go (87%) rename delivery_assets.go => internal/delivery/assets.go (94%) rename delivery_assets_test.go => internal/delivery/assets_test.go (70%) diff --git a/build.go b/build.go index d27c237..b3c0127 100644 --- a/build.go +++ b/build.go @@ -59,20 +59,12 @@ type BuildResult struct { // empty target set records source identity without running the Go toolchain. // Errors return partial build facts in the result. func Build(ctx context.Context, request BuildRequest) (BuildResult, error) { - targets := make([]internalbuild.Target, 0, len(request.Spec.Targets)) - for _, target := range request.Spec.Targets { - targets = append(targets, internalbuild.Target{OS: target.OS, Arch: target.Arch}) - } result, err := internalbuild.Build(ctx, internalbuild.Request{ RepositoryDir: request.RepositoryDir, Version: request.Version, - Spec: internalbuild.Spec{ - MainPackage: request.Spec.MainPackage, - BinaryName: request.Spec.BinaryName, - Targets: targets, - }, - OutputDir: request.OutputDir, - Output: request.Output, + Spec: *internalBuildSpec(&request.Spec), + OutputDir: request.OutputDir, + Output: request.Output, }) public := BuildResult{ RepositoryDir: result.RepositoryDir, @@ -99,3 +91,25 @@ func Build(ctx context.Context, request BuildRequest) (BuildResult, error) { } return public, err } + +func internalBuildSpec(spec *BuildSpec) *internalbuild.Spec { + if spec == nil { + return nil + } + result := &internalbuild.Spec{MainPackage: spec.MainPackage, BinaryName: spec.BinaryName} + for _, target := range spec.Targets { + result.Targets = append(result.Targets, internalbuild.Target{OS: target.OS, Arch: target.Arch}) + } + return result +} + +func internalBuildResult(result BuildResult) *internalbuild.Result { + converted := &internalbuild.Result{RepositoryDir: result.RepositoryDir, CandidateCommit: result.CandidateCommit, SourceVerified: result.SourceVerified, Version: result.Version, MainPackage: result.MainPackage, BinaryName: result.BinaryName, OutputDir: result.OutputDir, Compiler: result.Compiler} + for _, target := range result.Targets { + converted.Targets = append(converted.Targets, internalbuild.Target{OS: target.OS, Arch: target.Arch}) + } + for _, artifact := range result.Artifacts { + converted.Artifacts = append(converted.Artifacts, internalbuild.Artifact{Path: artifact.Path, Filename: artifact.Filename, Target: internalbuild.Target{OS: artifact.Target.OS, Arch: artifact.Target.Arch}, SHA256: artifact.SHA256}) + } + return converted +} diff --git a/check.go b/check.go index af76f01..a7d45aa 100644 --- a/check.go +++ b/check.go @@ -4,6 +4,7 @@ import ( "context" "io" + internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build" "gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate" ) @@ -54,7 +55,7 @@ func Check(ctx context.Context, request ReleaseRequest) (Candidate, error) { Product: request.Product, Version: request.Version, Validate: validate, - AfterValidate: candidateBuildCallback(request.Build, request.Output), + AfterValidate: internalbuild.CandidateCallback(internalBuildSpec(request.Build), request.Output), Output: request.Output, }) if err != nil { diff --git a/delivery.go b/delivery.go index 34a19e4..ef1b54a 100644 --- a/delivery.go +++ b/delivery.go @@ -2,8 +2,8 @@ package releasekit import ( "context" - "fmt" + internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build" internaldelivery "gitea.maximumdirect.net/eric/ReleaseKit/internal/delivery" "gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea" ) @@ -66,33 +66,9 @@ type DeliveryResult struct { // finds its release page and adds any missing declared build assets. func Deliver(ctx context.Context, request DeliveryRequest) (DeliveryResult, error) { result := DeliveryResult{Version: request.Version, State: DeliveryNotAttempted} - var build *internaldelivery.BuildIdentity - var assets []gitea.ExpectedAsset + var build *internalbuild.Result if request.Build != nil { - if len(request.Build.Targets) == 0 { - if len(request.Build.Artifacts) != 0 { - return result, fmt.Errorf("page-only build result contains artifacts") - } - } else { - var err error - assets, err = declaredBuildAssets(*request.Build) - if err != nil { - return result, err - } - targets := make([]string, 0, len(request.Build.Targets)) - for _, target := range request.Build.Targets { - targets = append(targets, target.OS+"/"+target.Arch) - } - build = &internaldelivery.BuildIdentity{ - RepositoryDir: request.Build.RepositoryDir, - CandidateCommit: request.Build.CandidateCommit, - SourceVerified: request.Build.SourceVerified, - Version: request.Build.Version, - MainPackage: request.Build.MainPackage, - BinaryName: request.Build.BinaryName, - Targets: targets, - } - } + build = internalBuildResult(*request.Build) } destination := gitea.Destination{ BaseURL: request.Destination.BaseURL, Owner: request.Destination.Owner, @@ -104,7 +80,6 @@ func Deliver(ctx context.Context, request DeliveryRequest) (DeliveryResult, erro Version: request.Version, Destination: destination, Build: build, - Assets: assets, }) result.CandidateCommit = internalResult.CandidateCommit result.ReleaseURL = internalResult.ReleaseURL diff --git a/docs/internal/overview.md b/docs/internal/overview.md index 0e904d7..93c7bf5 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -34,9 +34,9 @@ these implemented packages: | `cmd/releasekit` | Executable entry point, command routing, output, and exit status. | | `cmd/releasekit/internal/cli` | Command parsing, strict JSON profile loading, configured checks, and private build receipts. | | `internal/apicheck` | Compiled-export and import-boundary checks run during development. | -| `internal/build` | Selected Go target compilation, version injection, native smoke check, and artifact digests. | +| `internal/build` | Candidate-build coordination, selected Go target compilation, version injection, native smoke check, and artifact digests. | | `internal/candidate` | Stable-version, note, repository-state, candidate capture, and post-validation checks. | -| `internal/delivery` | Tagged-checkout verification and coordination of release pages, assets, and delivery results. | +| `internal/delivery` | Tagged-checkout verification, artifact inventory/hash validation, manifest assembly, and coordination of hosted delivery results. | | `internal/git` | Git command boundary, push-destination resolution, exact ref inspection, and tag operations. | | `internal/gitea` | Gitea API transport, hosted release pages, asset inspection, manifests, and uploads. | | `internal/process` | Cancellable child-process execution with per-command environment overlays. | diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index 50dd507..a62c16c 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -198,11 +198,13 @@ removes the previous receipt. Affected suites pass. ### R5 — Medium: The root facade still owns release mechanics +Status: Resolved. + Category: Architectural ownership gap and maintainability. -**Evidence:** [candidate_build.go](../../candidate_build.go) implements temporary +**Evidence:** [candidate_build.go](../../internal/build/candidate.go) implements temporary storage allocation, containment checking, cleanup, and candidate-build -coordination in the public package. [delivery_assets.go](../../delivery_assets.go) +coordination in the public package. [delivery_assets.go](../../internal/delivery/assets.go) implements artifact inventory validation, file opening/stat/hash checks, filename derivation, and manifest assembly there. These are substantial implementation responsibilities beyond forwarding and type conversion. @@ -220,11 +222,17 @@ assembly API or generic framework. checks remain valid after the move. Confirm filesystem and artifact algorithms are owned internally without introducing inward imports of public facades. +**Resolution:** Candidate-build coordination now belongs to `internal/build`; +artifact inventory, hashing, and manifest assembly belong to `internal/delivery`. +The root facade performs conversions and forwarding only. Existing artifact +tests moved with their implementation; external API and CLI tests, the complete +offline suite, and compiled-export/import checks pass. + ### R6 — Medium: Builds require a writable parent of the repository Category: Inadvertently strengthened filesystem prerequisite. -**Evidence:** [Candidate builds](../../candidate_build.go), around line 21, +**Evidence:** [Candidate builds](../../internal/build/candidate.go), around line 21, and [CLI delivery builds](../../cmd/releasekit/main.go), around line 194, allocate temporary storage with `os.MkdirTemp(filepath.Dir(repositoryDir), ...)`. The requirement is disposable output outside the worktree, not sibling output. diff --git a/candidate_build.go b/internal/build/candidate.go similarity index 87% rename from candidate_build.go rename to internal/build/candidate.go index 8c68844..828c09f 100644 --- a/candidate_build.go +++ b/internal/build/candidate.go @@ -1,4 +1,4 @@ -package releasekit +package build import ( "context" @@ -11,7 +11,7 @@ import ( "gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate" ) -func candidateBuildCallback(spec *BuildSpec, output io.Writer) func(context.Context, candidate.Candidate) error { +func CandidateCallback(spec *Spec, output io.Writer) func(context.Context, candidate.Candidate) error { if spec == nil || len(spec.Targets) == 0 { return nil } @@ -30,7 +30,7 @@ func candidateBuildCallback(spec *BuildSpec, output io.Writer) func(context.Cont if relative == "." || relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) { return fmt.Errorf("temporary candidate build output %q is inside the repository", temporaryDir) } - _, err = Build(ctx, BuildRequest{ + _, err = Build(ctx, Request{ RepositoryDir: captured.RepositoryDir, Version: captured.Version, Spec: selected, diff --git a/delivery_assets.go b/internal/delivery/assets.go similarity index 94% rename from delivery_assets.go rename to internal/delivery/assets.go index 1029aaf..b04708d 100644 --- a/delivery_assets.go +++ b/internal/delivery/assets.go @@ -1,4 +1,4 @@ -package releasekit +package delivery import ( "crypto/sha256" @@ -9,10 +9,11 @@ import ( "path/filepath" "strings" + internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build" "gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea" ) -func declaredBuildAssets(result BuildResult) ([]gitea.ExpectedAsset, error) { +func declaredBuildAssets(result internalbuild.Result) ([]gitea.ExpectedAsset, error) { if len(result.Targets) == 0 { if len(result.Artifacts) != 0 { return nil, fmt.Errorf("source-only build result contains artifacts") diff --git a/delivery_assets_test.go b/internal/delivery/assets_test.go similarity index 70% rename from delivery_assets_test.go rename to internal/delivery/assets_test.go index d3b7061..0bf0c86 100644 --- a/delivery_assets_test.go +++ b/internal/delivery/assets_test.go @@ -1,4 +1,4 @@ -package releasekit +package delivery import ( "crypto/sha256" @@ -8,6 +8,7 @@ import ( "strings" "testing" + internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build" "gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea" ) @@ -23,12 +24,12 @@ func TestDeclaredBuildAssetsGenerateManifestFromBuildResult(t *testing.T) { if err := os.WriteFile(secondPath, second, 0o600); err != nil { t.Fatal(err) } - result, err := declaredBuildAssets(BuildResult{ + result, err := declaredBuildAssets(internalbuild.Result{ Version: "v1.2.3", BinaryName: "app", - Targets: []Target{{OS: "linux", Arch: "amd64"}, {OS: "darwin", Arch: "arm64"}}, - Artifacts: []BuildArtifact{ - {Path: firstPath, Filename: filepath.Base(firstPath), Target: Target{OS: "linux", Arch: "amd64"}, SHA256: assetDigest(first)}, - {Path: secondPath, Filename: filepath.Base(secondPath), Target: Target{OS: "darwin", Arch: "arm64"}, SHA256: assetDigest(second)}, + Targets: []internalbuild.Target{{OS: "linux", Arch: "amd64"}, {OS: "darwin", Arch: "arm64"}}, + Artifacts: []internalbuild.Artifact{ + {Path: firstPath, Filename: filepath.Base(firstPath), Target: internalbuild.Target{OS: "linux", Arch: "amd64"}, SHA256: assetDigest(first)}, + {Path: secondPath, Filename: filepath.Base(secondPath), Target: internalbuild.Target{OS: "darwin", Arch: "arm64"}, SHA256: assetDigest(second)}, }, }) if err != nil { @@ -51,9 +52,9 @@ func TestDeclaredBuildAssetsRejectsTamperedArtifact(t *testing.T) { if err := os.WriteFile(path, []byte("changed bytes"), 0o600); err != nil { t.Fatal(err) } - _, err := declaredBuildAssets(BuildResult{ - Version: "v1.2.3", BinaryName: "app", Targets: []Target{{OS: "linux", Arch: "amd64"}}, - Artifacts: []BuildArtifact{{Path: path, Filename: filepath.Base(path), Target: Target{OS: "linux", Arch: "amd64"}, SHA256: assetDigest([]byte("original bytes"))}}, + _, err := declaredBuildAssets(internalbuild.Result{ + Version: "v1.2.3", BinaryName: "app", Targets: []internalbuild.Target{{OS: "linux", Arch: "amd64"}}, + Artifacts: []internalbuild.Artifact{{Path: path, Filename: filepath.Base(path), Target: internalbuild.Target{OS: "linux", Arch: "amd64"}, SHA256: assetDigest([]byte("original bytes"))}}, }) if err == nil || !strings.Contains(err.Error(), "SHA-256") { t.Fatalf("declaredBuildAssets() error = %v, want digest mismatch", err) @@ -61,7 +62,7 @@ func TestDeclaredBuildAssetsRejectsTamperedArtifact(t *testing.T) { } func TestDeclaredBuildAssetsKeepsSourceOnlyBuildManifestFree(t *testing.T) { - assets, err := declaredBuildAssets(BuildResult{}) + assets, err := declaredBuildAssets(internalbuild.Result{}) if err != nil || len(assets) != 0 { t.Fatalf("assets = %#v, error = %v", assets, err) } diff --git a/internal/delivery/delivery.go b/internal/delivery/delivery.go index 7dc9010..a0bef49 100644 --- a/internal/delivery/delivery.go +++ b/internal/delivery/delivery.go @@ -7,6 +7,7 @@ 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" @@ -23,23 +24,12 @@ const ( Unknown State = "unknown" ) -type BuildIdentity struct { - RepositoryDir string - CandidateCommit string - SourceVerified bool - Version string - MainPackage string - BinaryName string - Targets []string -} - type Request struct { RepositoryDir string Product string Version string Destination gitea.Destination - Build *BuildIdentity - Assets []gitea.ExpectedAsset + Build *internalbuild.Result } type Result struct { @@ -76,11 +66,18 @@ func Deliver(ctx context.Context, request Request) (Result, error) { if source.Head != source.TagCommit { return result, fmt.Errorf("checkout HEAD %s does not match tag %s commit %s", source.Head, request.Version, source.TagCommit) } - if err := validateBuild(repositoryDir, source.TagCommit, request.Version, request.Build, request.Assets); err != nil { + var assets []gitea.ExpectedAsset + if request.Build != nil { + assets, err = declaredBuildAssets(*request.Build) + if err != nil { + return result, err + } + } + if err := validateBuild(repositoryDir, source.TagCommit, request.Version, request.Build, assets); err != nil { return result, err } - if len(request.Assets) > 0 { - if err := gitea.VerifyLocalAssets(request.Assets); err != nil { + if len(assets) > 0 { + if err := gitea.VerifyLocalAssets(assets); err != nil { return result, fmt.Errorf("verify build assets: %w", err) } } @@ -104,17 +101,17 @@ func Deliver(ctx context.Context, request Request) (Result, error) { return result, err } result.ReleaseURL = releaseResult.Release.URL - if len(request.Assets) == 0 { + if len(assets) == 0 { result.State = Complete return result, nil } - inspection, err := (gitea.Client{}).InspectAssets(ctx, request.Destination, releaseResult.Release.ID, request.Assets) + inspection, err := (gitea.Client{}).InspectAssets(ctx, request.Destination, releaseResult.Release.ID, assets) if err != nil { result.State = NotAttempted if releaseResult.WriteAttempted { result.State = Partial } - result.Uncertain = assetIdentities(request.Assets) + result.Uncertain = assetIdentities(assets) return result, err } uploadResult, uploadErr := (gitea.Client{}).UploadMissing(ctx, request.Destination, releaseResult.Release.ID, inspection) @@ -130,7 +127,7 @@ func Deliver(ctx context.Context, request Request) (Result, error) { return result, uploadErr } -func validateBuild(repositoryDir, commit, version string, build *BuildIdentity, assets []gitea.ExpectedAsset) error { +func validateBuild(repositoryDir, commit, version string, build *internalbuild.Result, assets []gitea.ExpectedAsset) error { if build == nil { if len(assets) != 0 { return errors.New("assets require a build identity") diff --git a/publish.go b/publish.go index 123ee32..8a1c117 100644 --- a/publish.go +++ b/publish.go @@ -3,6 +3,7 @@ package releasekit import ( "context" + internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build" "gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate" "gitea.maximumdirect.net/eric/ReleaseKit/internal/publish" ) @@ -75,7 +76,7 @@ func Publish(ctx context.Context, request ReleaseRequest) (PublicationResult, er Version: request.Version, Remote: request.Remote, Validate: validate, - AfterValidate: candidateBuildCallback(request.Build, request.Output), + AfterValidate: internalbuild.CandidateCallback(internalBuildSpec(request.Build), request.Output), Output: request.Output, }) return PublicationResult{