package delivery import ( "context" "errors" "fmt" "io" "os" "path/filepath" "strings" "go.maximumdirect.net/releasekit/internal/artifact" internalbuild "go.maximumdirect.net/releasekit/internal/build" "go.maximumdirect.net/releasekit/internal/candidate" "go.maximumdirect.net/releasekit/internal/forge" "go.maximumdirect.net/releasekit/internal/git" "go.maximumdirect.net/releasekit/internal/gotool" "go.maximumdirect.net/releasekit/internal/releaseidentity" "go.maximumdirect.net/releasekit/internal/tempdir" "go.maximumdirect.net/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 Backend forge.Backend 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 []AssetIdentity Uploaded []AssetIdentity Pending []AssetIdentity Conflicting []AssetIdentity Uncertain []AssetIdentity } func Deliver(ctx context.Context, request Request) (Result, error) { return deliver(ctx, request, os.RemoveAll) } func deliver(ctx context.Context, request Request, remove func(string) error) (result Result, resultErr 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") } if request.Backend == nil { return result, errors.New("delivery backend is required") } 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) } if request.Prebuilt != nil { if err := validateBuildIdentity(identity, commit, request.Prebuilt); err != nil { return result, err } } if err := request.Backend.Preflight(ctx); err != nil { return result, 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 func() { if err := remove(temporaryDir); err != nil { resultErr = errors.Join(resultErr, fmt.Errorf("remove delivery build directory %q: %w", temporaryDir, err)) } }() 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 []artifact.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 := artifact.VerifyLocalAssets(assets); err != nil { kind := forge.FailureInvalid if errors.Is(err, artifact.ErrAssetConflict) { kind = forge.FailureConflict } return result, &forge.Failure{Kind: kind, Err: 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") } } release, pageEvidence, err := coordinateRelease(ctx, request.Backend, identity.Tag, commit, string(note)) if err != nil { result.State = stateForError(err, false) return result, err } result.ReleaseURL = release.URL if len(assets) == 0 { result.State = Complete return result, nil } inspection, err := inspectRemoteAssets(ctx, request.Backend, release.Handle, assets) if err != nil { result.State = NotAttempted if pageEvidence.Attempted { result.State = Partial } result.Uncertain = assetIdentities(assets) return result, err } uploadResult, uploadErr := uploadMissingAssets(ctx, request.Backend, release.Handle, 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 && pageEvidence.Attempted { result.State = Partial } return result, uploadErr } func validateBuild(identity releaseidentity.Identity, commit string, build *internalbuild.Result, assets []artifact.ExpectedAsset) error { if build == nil { if len(assets) != 0 { return errors.New("assets require a build identity") } return nil } if err := validateBuildIdentity(identity, commit, build); err != nil { return err } if len(build.Settings.Targets) == 0 { if len(assets) != 0 { return errors.New("page-only delivery cannot include binary assets") } return nil } if len(assets) != len(build.Settings.Targets)+1 || assets[len(assets)-1].Filename != artifact.ManifestFilename { return errors.New("build result does not contain the selected artifacts and complete checksum manifest") } return nil } func validateBuildIdentity(identity releaseidentity.Identity, commit string, build *internalbuild.Result) error { 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 { 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) == "" { 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 *forge.Failure if errors.As(err, &failure) { switch failure.Kind { case forge.FailureConflict: return Conflict case forge.FailureUnknown: return Unknown } } if pageCreated { return Partial } return NotAttempted } func assetIdentities(assets []artifact.ExpectedAsset) []AssetIdentity { result := make([]AssetIdentity, 0, len(assets)) for _, asset := range assets { result = append(result, AssetIdentity{Filename: asset.Filename, SHA256: asset.SHA256}) } return result }