// Package candidate captures and validates a committed local release candidate. package candidate import ( "bytes" "context" "errors" "fmt" "io" "os/exec" "strings" "gitea.maximumdirect.net/eric/ReleaseKit/internal/git" "gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool" "gitea.maximumdirect.net/eric/ReleaseKit/internal/process" "gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity" "gitea.maximumdirect.net/eric/ReleaseKit/internal/validation" "gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract" ) var ErrInvalidVersion = errors.New("invalid stable version") type Candidate struct { RepositoryDir string ModuleDir string ModulePath string Tag string Commit string Version string Note string } type Request struct { RepositoryDir string ModuleDir string Product string Version string Validation validation.Settings AdditionalChecks func(context.Context, Candidate) error AfterValidate func(context.Context, Candidate, *gotool.Owner) error Output io.Writer ToolOwner *gotool.Owner } func Check(ctx context.Context, request Request) (Candidate, error) { captured, err := Capture(ctx, request) if err != nil { return captured, err } request.ToolOwner = gotool.NewOwner(captured.ModuleDir) if err := Validate(ctx, request, captured); err != nil { return captured, err } if request.AfterValidate != nil { callbackErr := request.AfterValidate(ctx, captured, request.ToolOwner) if callbackErr != nil { return captured, fmt.Errorf("after candidate validation: %w", callbackErr) } } if err := Recheck(ctx, captured, ""); err != nil { return captured, err } return captured, nil } // Capture validates request identity and records one clean local candidate, // including the note bytes from the captured commit. It does not run checks. func Capture(ctx context.Context, request Request) (Candidate, error) { captured := Candidate{Version: request.Version} if !validStableVersion(request.Version) { return captured, fmt.Errorf("%w: %q", ErrInvalidVersion, request.Version) } if strings.TrimSpace(request.Product) == "" { return captured, errors.New("product is required") } repositoryDir, err := git.RepositoryRoot(ctx, request.RepositoryDir) if err != nil { return captured, fmt.Errorf("resolve repository directory: %w", err) } captured.RepositoryDir = repositoryDir state, err := inspectLocalState(ctx, repositoryDir) if err != nil { return captured, err } captured.Commit = state.commit if state.branch != "main" { return captured, fmt.Errorf("release candidate must be on main; current branch is %q", state.branch) } if state.status != "" { return captured, fmt.Errorf("release candidate worktree and index must be clean:\n%s", state.status) } identity, err := releaseidentity.FromCommitted(ctx, repositoryDir, request.ModuleDir, request.Version, state.commit) captured.ModuleDir = identity.ModuleDir captured.ModulePath = identity.ModulePath captured.Tag = identity.Tag if err != nil { return captured, fmt.Errorf("resolve selected module release identity: %w", err) } note, err := git.ReadCommitFile(ctx, identity.RepositoryDir, state.commit, identity.NotePath) if err != nil { return captured, fmt.Errorf("read committed release note %q: %w", identity.NotePath, err) } if err := validateNote(note, request.Product, request.Version); err != nil { return captured, fmt.Errorf("release note %q: %w", identity.NotePath, err) } if exists, err := hasLocalTag(ctx, repositoryDir, identity.Tag); err != nil { return captured, fmt.Errorf("inspect local tag %q: %w", identity.Tag, err) } else if exists { return captured, fmt.Errorf("local tag %q already exists", identity.Tag) } captured.Note = string(note) return captured, nil } // Validate runs the selected offline Go suite, followed by AdditionalChecks // when supplied, for the captured candidate. func Validate(ctx context.Context, request Request, captured Candidate) error { owner := request.ToolOwner if owner == nil { owner = gotool.NewOwner(captured.ModuleDir) } if err := validation.Run(ctx, validation.Request{ RepositoryDir: captured.RepositoryDir, ModuleDir: captured.ModuleDir, Commit: captured.Commit, Output: request.Output, Settings: request.Validation, ToolOwner: owner, }); err != nil { return fmt.Errorf("validate candidate %s: %w", captured.Commit, err) } if request.AdditionalChecks != nil { callbackErr := request.AdditionalChecks(ctx, captured) if callbackErr != nil { return fmt.Errorf("validate candidate %s: %w", captured.Commit, callbackErr) } } return nil } // Recheck verifies that the captured local source is unchanged. When // expectedTagObject is empty, the selected local version tag must still be // absent; otherwise the existing annotated tag must still have that object ID. func Recheck(ctx context.Context, captured Candidate, expectedTagObject string) error { current, err := inspectLocalState(ctx, captured.RepositoryDir) if err != nil { return fmt.Errorf("recheck local candidate state: %w", err) } if current.branch != "main" { return fmt.Errorf("release candidate branch changed to %q", current.branch) } if current.commit != captured.Commit { return fmt.Errorf("HEAD changed from %s to %s", captured.Commit, current.commit) } if current.status != "" { return fmt.Errorf("worktree or index changed:\n%s", current.status) } if expectedTagObject == "" { if exists, err := hasLocalTag(ctx, captured.RepositoryDir, captured.Tag); err != nil { return fmt.Errorf("recheck local tag %q: %w", captured.Tag, err) } else if exists { return fmt.Errorf("local tag %q appeared during validation", captured.Tag) } return nil } object, err := gitOutput(ctx, captured.RepositoryDir, "rev-parse", "--verify", "refs/tags/"+captured.Tag) if err != nil { return fmt.Errorf("read local tag %q: %w", captured.Tag, err) } tagType, err := gitOutput(ctx, captured.RepositoryDir, "cat-file", "-t", "refs/tags/"+captured.Tag) if err != nil { return fmt.Errorf("inspect local tag %q: %w", captured.Tag, err) } if strings.TrimSpace(string(object)) != expectedTagObject || strings.TrimSpace(string(tagType)) != "tag" { return fmt.Errorf("local tag %q no longer identifies annotated tag object %s", captured.Tag, expectedTagObject) } return nil } type localState struct { branch string commit string status string } func inspectLocalState(ctx context.Context, repositoryDir string) (localState, error) { branchOutput, err := gitOutput(ctx, repositoryDir, "branch", "--show-current") if err != nil { return localState{}, fmt.Errorf("inspect current branch: %w", err) } commitOutput, err := gitOutput(ctx, repositoryDir, "rev-parse", "--verify", "HEAD^{commit}") if err != nil { return localState{}, fmt.Errorf("capture HEAD commit: %w", err) } statusOutput, err := gitOutput(ctx, repositoryDir, "status", "--porcelain=v1", "--untracked-files=all") if err != nil { return localState{}, fmt.Errorf("inspect worktree and index: %w", err) } return localState{ branch: strings.TrimSpace(string(branchOutput)), commit: strings.TrimSpace(string(commitOutput)), status: strings.TrimSpace(string(statusOutput)), }, nil } func gitOutput(ctx context.Context, repositoryDir string, args ...string) ([]byte, error) { var stdout, stderr bytes.Buffer commandArgs := append([]string{"git"}, args...) err := process.Run(ctx, process.Invocation{ Args: commandArgs, Dir: repositoryDir, Stdout: &stdout, Stderr: &stderr, }) if err != nil { message := strings.TrimSpace(stderr.String()) if message != "" { return nil, fmt.Errorf("git %s: %s: %w", strings.Join(args, " "), message, err) } return nil, fmt.Errorf("git %s: %w", strings.Join(args, " "), err) } return stdout.Bytes(), nil } func hasLocalTag(ctx context.Context, repositoryDir, tag string) (bool, error) { var stderr bytes.Buffer err := process.Run(ctx, process.Invocation{ Args: []string{"git", "show-ref", "--verify", "--quiet", "refs/tags/" + tag}, Dir: repositoryDir, Stderr: &stderr, }) if err == nil { return true, nil } var exitErr *exec.ExitError if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 { return false, nil } message := strings.TrimSpace(stderr.String()) if message != "" { return false, fmt.Errorf("%s: %w", message, err) } return false, err } func validStableVersion(version string) bool { return versioncontract.ValidStableVersion(version) } func validateNote(note []byte, product, version string) error { line := string(note) if firstLine, _, ok := strings.Cut(line, "\n"); ok { line = firstLine } line = strings.TrimSuffix(line, "\r") if !strings.HasPrefix(line, "# ") { return errors.New("first line must be a level-one Markdown heading") } elements := strings.Fields(strings.TrimPrefix(line, "# ")) productElements := strings.Fields(product) if !containsSequence(elements, productElements) || !containsSequence(elements, []string{version}) { return fmt.Errorf("first heading must contain product %q and exact version %q as title elements", product, version) } return nil } // ValidateReleaseNote checks the title contract for a committed release note. func ValidateReleaseNote(note []byte, product, version string) error { return validateNote(note, product, version) } func containsSequence(elements, sequence []string) bool { if len(sequence) == 0 || len(sequence) > len(elements) { return false } for start := 0; start+len(sequence) <= len(elements); start++ { match := true for i, element := range sequence { if elements[start+i] != element { match = false break } } if match { return true } } return false }