Move release mechanics behind the public facade

This commit is contained in:
2026-10-06 00:58:22 +00:00
parent b4d348dd1f
commit 4dee2841e0
10 changed files with 78 additions and 80 deletions

View File

@@ -59,20 +59,12 @@ type BuildResult struct {
// empty target set records source identity without running the Go toolchain. // empty target set records source identity without running the Go toolchain.
// Errors return partial build facts in the result. // Errors return partial build facts in the result.
func Build(ctx context.Context, request BuildRequest) (BuildResult, error) { 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{ result, err := internalbuild.Build(ctx, internalbuild.Request{
RepositoryDir: request.RepositoryDir, RepositoryDir: request.RepositoryDir,
Version: request.Version, Version: request.Version,
Spec: internalbuild.Spec{ Spec: *internalBuildSpec(&request.Spec),
MainPackage: request.Spec.MainPackage, OutputDir: request.OutputDir,
BinaryName: request.Spec.BinaryName, Output: request.Output,
Targets: targets,
},
OutputDir: request.OutputDir,
Output: request.Output,
}) })
public := BuildResult{ public := BuildResult{
RepositoryDir: result.RepositoryDir, RepositoryDir: result.RepositoryDir,
@@ -99,3 +91,25 @@ func Build(ctx context.Context, request BuildRequest) (BuildResult, error) {
} }
return public, err 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
}

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"io" "io"
internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate" "gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate"
) )
@@ -54,7 +55,7 @@ func Check(ctx context.Context, request ReleaseRequest) (Candidate, error) {
Product: request.Product, Product: request.Product,
Version: request.Version, Version: request.Version,
Validate: validate, Validate: validate,
AfterValidate: candidateBuildCallback(request.Build, request.Output), AfterValidate: internalbuild.CandidateCallback(internalBuildSpec(request.Build), request.Output),
Output: request.Output, Output: request.Output,
}) })
if err != nil { if err != nil {

View File

@@ -2,8 +2,8 @@ package releasekit
import ( import (
"context" "context"
"fmt"
internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build"
internaldelivery "gitea.maximumdirect.net/eric/ReleaseKit/internal/delivery" internaldelivery "gitea.maximumdirect.net/eric/ReleaseKit/internal/delivery"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea" "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. // finds its release page and adds any missing declared build assets.
func Deliver(ctx context.Context, request DeliveryRequest) (DeliveryResult, error) { func Deliver(ctx context.Context, request DeliveryRequest) (DeliveryResult, error) {
result := DeliveryResult{Version: request.Version, State: DeliveryNotAttempted} result := DeliveryResult{Version: request.Version, State: DeliveryNotAttempted}
var build *internaldelivery.BuildIdentity var build *internalbuild.Result
var assets []gitea.ExpectedAsset
if request.Build != nil { if request.Build != nil {
if len(request.Build.Targets) == 0 { build = internalBuildResult(*request.Build)
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,
}
}
} }
destination := gitea.Destination{ destination := gitea.Destination{
BaseURL: request.Destination.BaseURL, Owner: request.Destination.Owner, BaseURL: request.Destination.BaseURL, Owner: request.Destination.Owner,
@@ -104,7 +80,6 @@ func Deliver(ctx context.Context, request DeliveryRequest) (DeliveryResult, erro
Version: request.Version, Version: request.Version,
Destination: destination, Destination: destination,
Build: build, Build: build,
Assets: assets,
}) })
result.CandidateCommit = internalResult.CandidateCommit result.CandidateCommit = internalResult.CandidateCommit
result.ReleaseURL = internalResult.ReleaseURL result.ReleaseURL = internalResult.ReleaseURL

View File

@@ -34,9 +34,9 @@ these implemented packages:
| `cmd/releasekit` | Executable entry point, command routing, output, and exit status. | | `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. | | `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/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/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/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/gitea` | Gitea API transport, hosted release pages, asset inspection, manifests, and uploads. |
| `internal/process` | Cancellable child-process execution with per-command environment overlays. | | `internal/process` | Cancellable child-process execution with per-command environment overlays. |

View File

@@ -198,11 +198,13 @@ removes the previous receipt. Affected suites pass.
### R5 — Medium: The root facade still owns release mechanics ### R5 — Medium: The root facade still owns release mechanics
Status: Resolved.
Category: Architectural ownership gap and maintainability. 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 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, implements artifact inventory validation, file opening/stat/hash checks,
filename derivation, and manifest assembly there. These are substantial filename derivation, and manifest assembly there. These are substantial
implementation responsibilities beyond forwarding and type conversion. 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 checks remain valid after the move. Confirm filesystem and artifact algorithms
are owned internally without introducing inward imports of public facades. 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 ### R6 — Medium: Builds require a writable parent of the repository
Category: Inadvertently strengthened filesystem prerequisite. 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, and [CLI delivery builds](../../cmd/releasekit/main.go), around line 194,
allocate temporary storage with `os.MkdirTemp(filepath.Dir(repositoryDir), ...)`. allocate temporary storage with `os.MkdirTemp(filepath.Dir(repositoryDir), ...)`.
The requirement is disposable output outside the worktree, not sibling output. The requirement is disposable output outside the worktree, not sibling output.

View File

@@ -1,4 +1,4 @@
package releasekit package build
import ( import (
"context" "context"
@@ -11,7 +11,7 @@ import (
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate" "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 { if spec == nil || len(spec.Targets) == 0 {
return nil 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)) { if relative == "." || relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
return fmt.Errorf("temporary candidate build output %q is inside the repository", temporaryDir) return fmt.Errorf("temporary candidate build output %q is inside the repository", temporaryDir)
} }
_, err = Build(ctx, BuildRequest{ _, err = Build(ctx, Request{
RepositoryDir: captured.RepositoryDir, RepositoryDir: captured.RepositoryDir,
Version: captured.Version, Version: captured.Version,
Spec: selected, Spec: selected,

View File

@@ -1,4 +1,4 @@
package releasekit package delivery
import ( import (
"crypto/sha256" "crypto/sha256"
@@ -9,10 +9,11 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea" "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.Targets) == 0 {
if len(result.Artifacts) != 0 { if len(result.Artifacts) != 0 {
return nil, fmt.Errorf("source-only build result contains artifacts") return nil, fmt.Errorf("source-only build result contains artifacts")

View File

@@ -1,4 +1,4 @@
package releasekit package delivery
import ( import (
"crypto/sha256" "crypto/sha256"
@@ -8,6 +8,7 @@ import (
"strings" "strings"
"testing" "testing"
internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea" "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 { if err := os.WriteFile(secondPath, second, 0o600); err != nil {
t.Fatal(err) t.Fatal(err)
} }
result, err := declaredBuildAssets(BuildResult{ result, err := declaredBuildAssets(internalbuild.Result{
Version: "v1.2.3", BinaryName: "app", Version: "v1.2.3", BinaryName: "app",
Targets: []Target{{OS: "linux", Arch: "amd64"}, {OS: "darwin", Arch: "arm64"}}, Targets: []internalbuild.Target{{OS: "linux", Arch: "amd64"}, {OS: "darwin", Arch: "arm64"}},
Artifacts: []BuildArtifact{ Artifacts: []internalbuild.Artifact{
{Path: firstPath, Filename: filepath.Base(firstPath), Target: Target{OS: "linux", Arch: "amd64"}, SHA256: assetDigest(first)}, {Path: firstPath, Filename: filepath.Base(firstPath), Target: internalbuild.Target{OS: "linux", Arch: "amd64"}, SHA256: assetDigest(first)},
{Path: secondPath, Filename: filepath.Base(secondPath), Target: Target{OS: "darwin", Arch: "arm64"}, SHA256: assetDigest(second)}, {Path: secondPath, Filename: filepath.Base(secondPath), Target: internalbuild.Target{OS: "darwin", Arch: "arm64"}, SHA256: assetDigest(second)},
}, },
}) })
if err != nil { if err != nil {
@@ -51,9 +52,9 @@ func TestDeclaredBuildAssetsRejectsTamperedArtifact(t *testing.T) {
if err := os.WriteFile(path, []byte("changed bytes"), 0o600); err != nil { if err := os.WriteFile(path, []byte("changed bytes"), 0o600); err != nil {
t.Fatal(err) t.Fatal(err)
} }
_, err := declaredBuildAssets(BuildResult{ _, err := declaredBuildAssets(internalbuild.Result{
Version: "v1.2.3", BinaryName: "app", Targets: []Target{{OS: "linux", Arch: "amd64"}}, Version: "v1.2.3", BinaryName: "app", Targets: []internalbuild.Target{{OS: "linux", Arch: "amd64"}},
Artifacts: []BuildArtifact{{Path: path, Filename: filepath.Base(path), Target: Target{OS: "linux", Arch: "amd64"}, SHA256: assetDigest([]byte("original bytes"))}}, 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") { if err == nil || !strings.Contains(err.Error(), "SHA-256") {
t.Fatalf("declaredBuildAssets() error = %v, want digest mismatch", err) t.Fatalf("declaredBuildAssets() error = %v, want digest mismatch", err)
@@ -61,7 +62,7 @@ func TestDeclaredBuildAssetsRejectsTamperedArtifact(t *testing.T) {
} }
func TestDeclaredBuildAssetsKeepsSourceOnlyBuildManifestFree(t *testing.T) { func TestDeclaredBuildAssetsKeepsSourceOnlyBuildManifestFree(t *testing.T) {
assets, err := declaredBuildAssets(BuildResult{}) assets, err := declaredBuildAssets(internalbuild.Result{})
if err != nil || len(assets) != 0 { if err != nil || len(assets) != 0 {
t.Fatalf("assets = %#v, error = %v", assets, err) t.Fatalf("assets = %#v, error = %v", assets, err)
} }

View File

@@ -7,6 +7,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate" "gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git" "gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea" "gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea"
@@ -23,23 +24,12 @@ const (
Unknown State = "unknown" Unknown State = "unknown"
) )
type BuildIdentity struct {
RepositoryDir string
CandidateCommit string
SourceVerified bool
Version string
MainPackage string
BinaryName string
Targets []string
}
type Request struct { type Request struct {
RepositoryDir string RepositoryDir string
Product string Product string
Version string Version string
Destination gitea.Destination Destination gitea.Destination
Build *BuildIdentity Build *internalbuild.Result
Assets []gitea.ExpectedAsset
} }
type Result struct { type Result struct {
@@ -76,11 +66,18 @@ func Deliver(ctx context.Context, request Request) (Result, error) {
if source.Head != source.TagCommit { 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) 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 return result, err
} }
if len(request.Assets) > 0 { if len(assets) > 0 {
if err := gitea.VerifyLocalAssets(request.Assets); err != nil { if err := gitea.VerifyLocalAssets(assets); err != nil {
return result, fmt.Errorf("verify build assets: %w", err) 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 return result, err
} }
result.ReleaseURL = releaseResult.Release.URL result.ReleaseURL = releaseResult.Release.URL
if len(request.Assets) == 0 { if len(assets) == 0 {
result.State = Complete result.State = Complete
return result, nil return result, nil
} }
inspection, err := (gitea.Client{}).InspectAssets(ctx, request.Destination, releaseResult.Release.ID, request.Assets) inspection, err := (gitea.Client{}).InspectAssets(ctx, request.Destination, releaseResult.Release.ID, assets)
if err != nil { if err != nil {
result.State = NotAttempted result.State = NotAttempted
if releaseResult.WriteAttempted { if releaseResult.WriteAttempted {
result.State = Partial result.State = Partial
} }
result.Uncertain = assetIdentities(request.Assets) result.Uncertain = assetIdentities(assets)
return result, err return result, err
} }
uploadResult, uploadErr := (gitea.Client{}).UploadMissing(ctx, request.Destination, releaseResult.Release.ID, inspection) 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 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 build == nil {
if len(assets) != 0 { if len(assets) != 0 {
return errors.New("assets require a build identity") return errors.New("assets require a build identity")

View File

@@ -3,6 +3,7 @@ package releasekit
import ( import (
"context" "context"
internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate" "gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/publish" "gitea.maximumdirect.net/eric/ReleaseKit/internal/publish"
) )
@@ -75,7 +76,7 @@ func Publish(ctx context.Context, request ReleaseRequest) (PublicationResult, er
Version: request.Version, Version: request.Version,
Remote: request.Remote, Remote: request.Remote,
Validate: validate, Validate: validate,
AfterValidate: candidateBuildCallback(request.Build, request.Output), AfterValidate: internalbuild.CandidateCallback(internalBuildSpec(request.Build), request.Output),
Output: request.Output, Output: request.Output,
}) })
return PublicationResult{ return PublicationResult{