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{}).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(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 }