307 lines
10 KiB
Go
307 lines
10 KiB
Go
// Package candidate captures and validates a committed local release candidate.
|
|
package candidate
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"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)
|
|
refreshErr := request.ToolOwner.Refresh(ctx)
|
|
if callbackErr != nil {
|
|
return captured, fmt.Errorf("after candidate validation: %w", callbackErr)
|
|
}
|
|
if refreshErr != nil {
|
|
return captured, fmt.Errorf("refresh Go settings after candidate callback: %w", refreshErr)
|
|
}
|
|
}
|
|
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 := filepath.Abs(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)
|
|
}
|
|
|
|
identitySelection, err := releaseidentity.ResolveSelection(repositoryDir, request.ModuleDir)
|
|
if err != nil {
|
|
return captured, fmt.Errorf("resolve selected module: %w", err)
|
|
}
|
|
captured.RepositoryDir = identitySelection.RepositoryDir
|
|
captured.ModuleDir = identitySelection.ModuleDir
|
|
identity, err := releaseidentity.FromCommittedSelection(ctx, identitySelection, request.Version, state.commit)
|
|
if err != nil {
|
|
return captured, fmt.Errorf("resolve selected module release identity: %w", err)
|
|
}
|
|
captured.ModulePath = identity.ModulePath
|
|
captured.Tag = identity.Tag
|
|
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)
|
|
refreshErr := owner.Refresh(ctx)
|
|
if callbackErr != nil {
|
|
return fmt.Errorf("validate candidate %s: %w", captured.Commit, callbackErr)
|
|
}
|
|
if refreshErr != nil {
|
|
return fmt.Errorf("refresh Go settings after additional checks: %w", refreshErr)
|
|
}
|
|
}
|
|
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
|
|
}
|