255 lines
8.4 KiB
Go
255 lines
8.4 KiB
Go
package delivery
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"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"
|
|
)
|
|
|
|
type State string
|
|
|
|
const (
|
|
NotAttempted State = "not_attempted"
|
|
Complete State = "complete"
|
|
Partial State = "partial"
|
|
Conflict State = "conflict"
|
|
Unknown State = "unknown"
|
|
)
|
|
|
|
type Request struct {
|
|
RepositoryDir string
|
|
ModuleDir string
|
|
Product string
|
|
Version string
|
|
ExpectedTag string
|
|
Destination gitea.Destination
|
|
Build *internalbuild.Spec
|
|
Prebuilt *internalbuild.Result
|
|
Output io.Writer
|
|
}
|
|
|
|
type Result struct {
|
|
RepositoryDir string
|
|
ModuleDir string
|
|
ModulePath string
|
|
Tag string
|
|
CandidateCommit string
|
|
ReleaseURL string
|
|
State State
|
|
Matching []gitea.AssetIdentity
|
|
Uploaded []gitea.AssetIdentity
|
|
Pending []gitea.AssetIdentity
|
|
Conflicting []gitea.AssetIdentity
|
|
Uncertain []gitea.AssetIdentity
|
|
}
|
|
|
|
func Deliver(ctx context.Context, request Request) (Result, error) {
|
|
result := Result{State: NotAttempted}
|
|
if !versioncontract.ValidStableVersion(request.Version) {
|
|
return result, candidate.ErrInvalidVersion
|
|
}
|
|
if strings.TrimSpace(request.Product) == "" {
|
|
return result, errors.New("product is required")
|
|
}
|
|
if request.Build != nil && request.Prebuilt != nil {
|
|
return result, errors.New("delivery cannot use both a managed build and prebuilt artifacts")
|
|
}
|
|
repositoryDir, err := git.RepositoryRoot(ctx, request.RepositoryDir)
|
|
if err != nil {
|
|
return result, fmt.Errorf("resolve repository directory: %w", err)
|
|
}
|
|
result.RepositoryDir = repositoryDir
|
|
commit, err := git.CaptureHead(ctx, repositoryDir)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
result.CandidateCommit = commit
|
|
identity, err := releaseidentity.FromCommitted(ctx, repositoryDir, request.ModuleDir, request.Version, commit)
|
|
result.ModuleDir = identity.ModuleDir
|
|
result.ModulePath = identity.ModulePath
|
|
result.Tag = identity.Tag
|
|
if err != nil {
|
|
return result, fmt.Errorf("resolve committed delivery identity: %w", err)
|
|
}
|
|
result.RepositoryDir = identity.RepositoryDir
|
|
result.ModuleDir = identity.ModuleDir
|
|
result.ModulePath = identity.ModulePath
|
|
result.Tag = identity.Tag
|
|
if request.ExpectedTag != "" && request.ExpectedTag != identity.Tag {
|
|
return result, fmt.Errorf("expected delivery tag %q does not match selected module tag %q", request.ExpectedTag, identity.Tag)
|
|
}
|
|
source, err := git.InspectTaggedSource(ctx, repositoryDir, identity.Tag)
|
|
if err != nil {
|
|
return result, fmt.Errorf("inspect tagged source: %w", err)
|
|
}
|
|
if source.Status != "" {
|
|
return result, fmt.Errorf("tagged checkout must be clean:\n%s", source.Status)
|
|
}
|
|
if source.Head != commit {
|
|
return result, fmt.Errorf("checkout HEAD changed from captured commit %s to %s", commit, source.Head)
|
|
}
|
|
if source.TagCommit != commit {
|
|
return result, fmt.Errorf("checkout HEAD %s does not match tag %s commit %s", source.Head, identity.Tag, source.TagCommit)
|
|
}
|
|
note, err := git.ReadCommitFile(ctx, repositoryDir, commit, identity.NotePath)
|
|
if err != nil {
|
|
return result, fmt.Errorf("read release note %q: %w", identity.NotePath, err)
|
|
}
|
|
if err := candidate.ValidateReleaseNote(note, request.Product, request.Version); err != nil {
|
|
return result, fmt.Errorf("release note %q: %w", identity.NotePath, err)
|
|
}
|
|
|
|
build := request.Prebuilt
|
|
if request.Build != nil && len(request.Build.Targets) != 0 {
|
|
temporaryDir, err := tempdir.Outside(repositoryDir, "releasekit-delivery-build-*")
|
|
if err != nil {
|
|
return result, fmt.Errorf("create temporary delivery build directory: %w", err)
|
|
}
|
|
defer os.RemoveAll(temporaryDir)
|
|
built, err := internalbuild.BuildResolved(ctx, internalbuild.Request{
|
|
Identity: identity,
|
|
Spec: *request.Build,
|
|
OutputDir: filepath.Join(temporaryDir, "assets"),
|
|
Output: request.Output,
|
|
ToolOwner: gotool.NewOwner(identity.ModuleDir),
|
|
})
|
|
if err != nil {
|
|
return result, fmt.Errorf("build delivery assets: %w", err)
|
|
}
|
|
build = &built
|
|
}
|
|
var assets []gitea.ExpectedAsset
|
|
if build != nil {
|
|
assets, err = declaredBuildAssets(*build)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
}
|
|
if err := validateBuild(identity, commit, build, assets); err != nil {
|
|
return result, err
|
|
}
|
|
if len(assets) > 0 {
|
|
if err := gitea.VerifyLocalAssets(assets); err != nil {
|
|
return result, fmt.Errorf("verify build assets: %w", err)
|
|
}
|
|
}
|
|
if build != nil && len(build.Settings.Targets) != 0 {
|
|
current, err := git.InspectTaggedSource(ctx, repositoryDir, identity.Tag)
|
|
if err != nil {
|
|
return result, fmt.Errorf("recheck tagged source after build: %w", err)
|
|
}
|
|
if current.Status != "" {
|
|
return result, fmt.Errorf("tagged checkout changed during build:\n%s", current.Status)
|
|
}
|
|
if current.Head != commit || current.TagCommit != commit {
|
|
return result, errors.New("tagged source changed during build")
|
|
}
|
|
}
|
|
|
|
releaseResult, err := (gitea.Client{}).EnsureRelease(ctx, gitea.Request{
|
|
Destination: request.Destination,
|
|
Tag: identity.Tag,
|
|
Commit: commit,
|
|
Note: string(note),
|
|
})
|
|
if err != nil {
|
|
result.State = stateForError(err, false)
|
|
return result, err
|
|
}
|
|
result.ReleaseURL = releaseResult.Release.URL
|
|
if len(assets) == 0 {
|
|
result.State = Complete
|
|
return result, nil
|
|
}
|
|
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(assets)
|
|
return result, err
|
|
}
|
|
uploadResult, uploadErr := (gitea.Client{}).UploadVerifiedMissing(ctx, request.Destination, releaseResult.Release.ID, 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 {
|
|
result.State = Partial
|
|
}
|
|
return result, uploadErr
|
|
}
|
|
|
|
func validateBuild(identity releaseidentity.Identity, commit string, build *internalbuild.Result, assets []gitea.ExpectedAsset) error {
|
|
if build == nil {
|
|
if len(assets) != 0 {
|
|
return errors.New("assets require a build identity")
|
|
}
|
|
return nil
|
|
}
|
|
buildRepository, err := filepath.Abs(build.RepositoryDir)
|
|
if err != nil {
|
|
return fmt.Errorf("resolve build repository: %w", err)
|
|
}
|
|
buildModule, err := filepath.Abs(build.ModuleDir)
|
|
if err != nil {
|
|
return fmt.Errorf("resolve build module: %w", err)
|
|
}
|
|
if buildRepository != identity.RepositoryDir || buildModule != identity.ModuleDir || build.ModulePath != identity.ModulePath || build.Tag != identity.Tag || build.CandidateCommit != commit || build.Version != identity.Version {
|
|
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 {
|
|
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
|
|
if errors.As(err, &failure) {
|
|
switch failure.Kind {
|
|
case gitea.FailureConflict:
|
|
return Conflict
|
|
case gitea.FailureUnknown:
|
|
return Unknown
|
|
}
|
|
}
|
|
if pageCreated {
|
|
return Partial
|
|
}
|
|
return NotAttempted
|
|
}
|
|
|
|
func assetIdentities(assets []gitea.ExpectedAsset) []gitea.AssetIdentity {
|
|
result := make([]gitea.AssetIdentity, 0, len(assets))
|
|
for _, asset := range assets {
|
|
result = append(result, gitea.AssetIdentity{Filename: asset.Filename, SHA256: asset.SHA256})
|
|
}
|
|
return result
|
|
}
|