Move release mechanics behind the public facade
This commit is contained in:
45
internal/build/candidate.go
Normal file
45
internal/build/candidate.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package build
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate"
|
||||
)
|
||||
|
||||
func CandidateCallback(spec *Spec, output io.Writer) func(context.Context, candidate.Candidate) error {
|
||||
if spec == nil || len(spec.Targets) == 0 {
|
||||
return nil
|
||||
}
|
||||
selected := *spec
|
||||
selected.Targets = append([]Target(nil), spec.Targets...)
|
||||
return func(ctx context.Context, captured candidate.Candidate) error {
|
||||
temporaryDir, err := os.MkdirTemp(filepath.Dir(captured.RepositoryDir), ".releasekit-candidate-build-")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temporary candidate build output: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(temporaryDir)
|
||||
relative, err := filepath.Rel(captured.RepositoryDir, temporaryDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("verify temporary candidate build output: %w", err)
|
||||
}
|
||||
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, Request{
|
||||
RepositoryDir: captured.RepositoryDir,
|
||||
Version: captured.Version,
|
||||
Spec: selected,
|
||||
OutputDir: temporaryDir,
|
||||
Output: output,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("build candidate: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
91
internal/delivery/assets.go
Normal file
91
internal/delivery/assets.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea"
|
||||
)
|
||||
|
||||
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")
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
if len(result.Artifacts) != len(result.Targets) {
|
||||
return nil, fmt.Errorf("build result must contain one artifact for each selected target")
|
||||
}
|
||||
wantByTarget := make(map[string]string, len(result.Targets))
|
||||
for _, target := range result.Targets {
|
||||
if target.OS == "" || target.Arch == "" || strings.ContainsAny(target.OS+target.Arch, "/\\\r\n") {
|
||||
return nil, fmt.Errorf("build result contains an invalid target")
|
||||
}
|
||||
key := target.OS + "/" + target.Arch
|
||||
if _, exists := wantByTarget[key]; exists {
|
||||
return nil, fmt.Errorf("build result repeats target %q", key)
|
||||
}
|
||||
name := result.BinaryName + "-" + result.Version + "-" + target.OS + "-" + target.Arch
|
||||
if target.OS == "windows" {
|
||||
name += ".exe"
|
||||
}
|
||||
wantByTarget[key] = name
|
||||
}
|
||||
assets := make([]gitea.ExpectedAsset, 0, len(result.Artifacts)+1)
|
||||
seenTargets := make(map[string]bool, len(result.Artifacts))
|
||||
for _, artifact := range result.Artifacts {
|
||||
key := artifact.Target.OS + "/" + artifact.Target.Arch
|
||||
wantName, ok := wantByTarget[key]
|
||||
if !ok || seenTargets[key] || artifact.Filename != wantName {
|
||||
return nil, fmt.Errorf("build artifact %q does not match a selected target and its declared name", artifact.Filename)
|
||||
}
|
||||
seenTargets[key] = 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)
|
||||
}
|
||||
info, err := os.Stat(artifact.Path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("inspect build artifact %q: %w", artifact.Filename, err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("build artifact %q is not a regular file", artifact.Filename)
|
||||
}
|
||||
file, err := os.Open(artifact.Path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open build artifact %q: %w", artifact.Filename, err)
|
||||
}
|
||||
hash := sha256.New()
|
||||
_, copyErr := io.Copy(hash, file)
|
||||
closeErr := file.Close()
|
||||
if copyErr != nil {
|
||||
return nil, fmt.Errorf("hash build artifact %q: %w", artifact.Filename, copyErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return nil, fmt.Errorf("close build artifact %q: %w", artifact.Filename, closeErr)
|
||||
}
|
||||
if artifact.SHA256 != hex.EncodeToString(hash.Sum(nil)) {
|
||||
return nil, fmt.Errorf("build artifact %q does not match its declared SHA-256 digest", artifact.Filename)
|
||||
}
|
||||
assets = append(assets, gitea.ExpectedAsset{
|
||||
Filename: artifact.Filename,
|
||||
SHA256: artifact.SHA256,
|
||||
Size: info.Size(),
|
||||
Path: artifact.Path,
|
||||
})
|
||||
}
|
||||
manifest, err := gitea.BuildManifest(assets)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build checksum manifest: %w", err)
|
||||
}
|
||||
if manifest.Filename != "" {
|
||||
assets = append(assets, manifest)
|
||||
}
|
||||
return assets, nil
|
||||
}
|
||||
74
internal/delivery/assets_test.go
Normal file
74
internal/delivery/assets_test.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea"
|
||||
)
|
||||
|
||||
func TestDeclaredBuildAssetsGenerateManifestFromBuildResult(t *testing.T) {
|
||||
output := t.TempDir()
|
||||
first := []byte("first artifact")
|
||||
second := []byte("second artifact")
|
||||
firstPath := filepath.Join(output, "app-v1.2.3-linux-amd64")
|
||||
secondPath := filepath.Join(output, "app-v1.2.3-darwin-arm64")
|
||||
if err := os.WriteFile(firstPath, first, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(secondPath, second, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := declaredBuildAssets(internalbuild.Result{
|
||||
Version: "v1.2.3", BinaryName: "app",
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result) != 3 || result[0].Filename != filepath.Base(firstPath) || result[1].Filename != filepath.Base(secondPath) || result[2].Filename != gitea.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"
|
||||
if string(result[2].Content) != want {
|
||||
t.Fatalf("manifest = %q, want %q", result[2].Content, want)
|
||||
}
|
||||
if result[0].Size != int64(len(first)) || result[1].Size != int64(len(second)) {
|
||||
t.Fatalf("sizes = %d, %d", result[0].Size, result[1].Size)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeclaredBuildAssetsRejectsTamperedArtifact(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "app-v1.2.3-linux-amd64")
|
||||
if err := os.WriteFile(path, []byte("changed bytes"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeclaredBuildAssetsKeepsSourceOnlyBuildManifestFree(t *testing.T) {
|
||||
assets, err := declaredBuildAssets(internalbuild.Result{})
|
||||
if err != nil || len(assets) != 0 {
|
||||
t.Fatalf("assets = %#v, error = %v", assets, err)
|
||||
}
|
||||
}
|
||||
|
||||
func assetDigest(content []byte) string {
|
||||
sum := sha256.Sum256(content)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user