package delivery import ( "context" "errors" "fmt" "path/filepath" "strings" "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/versioncontract" ) type State string const ( NotAttempted State = "not_attempted" Complete State = "complete" Partial State = "partial" Conflict State = "conflict" 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 } type Result struct { 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") } repositoryDir, err := filepath.Abs(request.RepositoryDir) if err != nil { return result, fmt.Errorf("resolve repository directory: %w", err) } source, err := git.InspectTaggedSource(ctx, repositoryDir, request.Version) if err != nil { return result, fmt.Errorf("inspect tagged source: %w", err) } result.CandidateCommit = source.TagCommit if source.Status != "" { return result, fmt.Errorf("tagged checkout must be clean:\n%s", source.Status) } 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 { return result, err } if len(request.Assets) > 0 { if err := gitea.VerifyLocalAssets(request.Assets); err != nil { return result, fmt.Errorf("verify build assets: %w", err) } } notePath := "docs/releases/" + request.Version + ".md" note, err := git.ReadCommitFile(ctx, repositoryDir, source.TagCommit, notePath) if err != nil { return result, err } if err := candidate.ValidateReleaseNote(note, request.Product, request.Version); err != nil { return result, fmt.Errorf("release note %q: %w", notePath, err) } releaseResult, err := (gitea.Client{}).EnsureRelease(ctx, gitea.Request{ Destination: request.Destination, Version: request.Version, Commit: source.TagCommit, Note: string(note), }) if err != nil { result.State = stateForError(err, false) return result, err } result.ReleaseURL = releaseResult.Release.URL if len(request.Assets) == 0 { result.State = Complete return result, nil } inspection, err := (gitea.Client{}).InspectAssets(ctx, request.Destination, releaseResult.Release.ID, request.Assets) if err != nil { result.State = NotAttempted if releaseResult.WriteAttempted { result.State = Partial } result.Uncertain = assetIdentities(request.Assets) return result, err } uploadResult, uploadErr := (gitea.Client{}).UploadMissing(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(repositoryDir, commit, version string, build *BuildIdentity, assets []gitea.ExpectedAsset) error { if build == nil { if len(assets) != 0 { return errors.New("assets require a build identity") } return nil } if len(build.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") } buildRepository, err := filepath.Abs(build.RepositoryDir) if err != nil { return fmt.Errorf("resolve build repository: %w", err) } if buildRepository != repositoryDir || build.CandidateCommit != commit || build.Version != version { return errors.New("build result does not identify the selected tagged source") } if strings.TrimSpace(build.MainPackage) == "" || strings.TrimSpace(build.BinaryName) == "" || len(assets) != len(build.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 }