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/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 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 := 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) } 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) } 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.Build(ctx, internalbuild.Request{ RepositoryDir: repositoryDir, Version: request.Version, Spec: *request.Build, OutputDir: filepath.Join(temporaryDir, "assets"), Output: request.Output, }) 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(repositoryDir, source.TagCommit, request.Version, 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.Targets) != 0 { current, err := git.InspectTaggedSource(ctx, repositoryDir, request.Version) 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 != source.TagCommit || current.TagCommit != source.TagCommit { return result, errors.New("tagged source changed during build") } } 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(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{}).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 *internalbuild.Result, 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 }