Carry module identity through candidate results
This commit is contained in:
@@ -29,6 +29,7 @@ type Spec struct {
|
||||
|
||||
type Request struct {
|
||||
RepositoryDir string
|
||||
ModuleDir string
|
||||
Version string
|
||||
Spec Spec
|
||||
OutputDir string
|
||||
@@ -44,6 +45,9 @@ type Artifact struct {
|
||||
|
||||
type Result struct {
|
||||
RepositoryDir string
|
||||
ModuleDir string
|
||||
ModulePath string
|
||||
Tag string
|
||||
CandidateCommit string
|
||||
SourceVerified bool
|
||||
Version string
|
||||
|
||||
@@ -11,7 +11,9 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
|
||||
"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"
|
||||
)
|
||||
@@ -20,6 +22,9 @@ var ErrInvalidVersion = errors.New("invalid stable version")
|
||||
|
||||
type Candidate struct {
|
||||
RepositoryDir string
|
||||
ModuleDir string
|
||||
ModulePath string
|
||||
Tag string
|
||||
Commit string
|
||||
Version string
|
||||
Note string
|
||||
@@ -27,6 +32,7 @@ type Candidate struct {
|
||||
|
||||
type Request struct {
|
||||
RepositoryDir string
|
||||
ModuleDir string
|
||||
Product string
|
||||
Version string
|
||||
Validation validation.Settings
|
||||
@@ -38,18 +44,18 @@ type Request struct {
|
||||
func Check(ctx context.Context, request Request) (Candidate, error) {
|
||||
captured, err := Capture(ctx, request)
|
||||
if err != nil {
|
||||
return Candidate{}, err
|
||||
return captured, err
|
||||
}
|
||||
if err := Validate(ctx, request, captured); err != nil {
|
||||
return Candidate{}, err
|
||||
return captured, err
|
||||
}
|
||||
if request.AfterValidate != nil {
|
||||
if err := request.AfterValidate(ctx, captured); err != nil {
|
||||
return Candidate{}, fmt.Errorf("after candidate validation: %w", err)
|
||||
return captured, fmt.Errorf("after candidate validation: %w", err)
|
||||
}
|
||||
}
|
||||
if err := Recheck(ctx, captured, ""); err != nil {
|
||||
return Candidate{}, err
|
||||
return captured, err
|
||||
}
|
||||
return captured, nil
|
||||
}
|
||||
@@ -57,43 +63,58 @@ func Check(ctx context.Context, request Request) (Candidate, error) {
|
||||
// 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 Candidate{}, fmt.Errorf("%w: %q", ErrInvalidVersion, request.Version)
|
||||
return captured, fmt.Errorf("%w: %q", ErrInvalidVersion, request.Version)
|
||||
}
|
||||
if strings.TrimSpace(request.Product) == "" {
|
||||
return Candidate{}, errors.New("product is required")
|
||||
return captured, errors.New("product is required")
|
||||
}
|
||||
|
||||
repositoryDir, err := filepath.Abs(request.RepositoryDir)
|
||||
if err != nil {
|
||||
return Candidate{}, fmt.Errorf("resolve repository directory: %w", err)
|
||||
return captured, fmt.Errorf("resolve repository directory: %w", err)
|
||||
}
|
||||
captured.RepositoryDir = repositoryDir
|
||||
state, err := inspectLocalState(ctx, repositoryDir)
|
||||
if err != nil {
|
||||
return Candidate{}, err
|
||||
return captured, err
|
||||
}
|
||||
captured.Commit = state.commit
|
||||
if state.branch != "main" {
|
||||
return Candidate{}, fmt.Errorf("release candidate must be on main; current branch is %q", state.branch)
|
||||
return captured, fmt.Errorf("release candidate must be on main; current branch is %q", state.branch)
|
||||
}
|
||||
if state.status != "" {
|
||||
return Candidate{}, fmt.Errorf("release candidate worktree and index must be clean:\n%s", state.status)
|
||||
return captured, fmt.Errorf("release candidate worktree and index must be clean:\n%s", state.status)
|
||||
}
|
||||
|
||||
path := "docs/releases/" + request.Version + ".md"
|
||||
note, err := gitOutput(ctx, repositoryDir, "show", state.commit+":"+path)
|
||||
identitySelection, err := releaseidentity.ResolveSelection(repositoryDir, request.ModuleDir)
|
||||
if err != nil {
|
||||
return Candidate{}, fmt.Errorf("read committed release note %q: %w", path, err)
|
||||
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 Candidate{}, fmt.Errorf("release note %q: %w", path, err)
|
||||
return captured, fmt.Errorf("release note %q: %w", identity.NotePath, err)
|
||||
}
|
||||
if exists, err := hasLocalTag(ctx, repositoryDir, request.Version); err != nil {
|
||||
return Candidate{}, fmt.Errorf("inspect local tag %q: %w", request.Version, 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 Candidate{}, fmt.Errorf("local tag %q already exists", request.Version)
|
||||
return captured, fmt.Errorf("local tag %q already exists", identity.Tag)
|
||||
}
|
||||
|
||||
return Candidate{RepositoryDir: repositoryDir, Commit: state.commit, Version: request.Version, Note: string(note)}, nil
|
||||
captured.Note = string(note)
|
||||
return captured, nil
|
||||
}
|
||||
|
||||
// Validate runs the selected offline Go suite, followed by AdditionalChecks
|
||||
@@ -133,23 +154,23 @@ func Recheck(ctx context.Context, captured Candidate, expectedTagObject string)
|
||||
return fmt.Errorf("worktree or index changed:\n%s", current.status)
|
||||
}
|
||||
if expectedTagObject == "" {
|
||||
if exists, err := hasLocalTag(ctx, captured.RepositoryDir, captured.Version); err != nil {
|
||||
return fmt.Errorf("recheck local tag %q: %w", captured.Version, err)
|
||||
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.Version)
|
||||
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.Version)
|
||||
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.Version, err)
|
||||
return fmt.Errorf("read local tag %q: %w", captured.Tag, err)
|
||||
}
|
||||
tagType, err := gitOutput(ctx, captured.RepositoryDir, "cat-file", "-t", "refs/tags/"+captured.Version)
|
||||
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.Version, err)
|
||||
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.Version, expectedTagObject)
|
||||
return fmt.Errorf("local tag %q no longer identifies annotated tag object %s", captured.Tag, expectedTagObject)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -199,10 +220,10 @@ func gitOutput(ctx context.Context, repositoryDir string, args ...string) ([]byt
|
||||
return stdout.Bytes(), nil
|
||||
}
|
||||
|
||||
func hasLocalTag(ctx context.Context, repositoryDir, version string) (bool, error) {
|
||||
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/" + version},
|
||||
Args: []string{"git", "show-ref", "--verify", "--quiet", "refs/tags/" + tag},
|
||||
Dir: repositoryDir,
|
||||
Stderr: &stderr,
|
||||
})
|
||||
|
||||
@@ -71,7 +71,7 @@ func TestCheckCapturesCandidateAndCallsAdditionalChecksOnce(t *testing.T) {
|
||||
if seen != candidate {
|
||||
t.Fatalf("callback candidate = %#v, returned candidate = %#v", seen, candidate)
|
||||
}
|
||||
if candidate.RepositoryDir != repository || candidate.Commit != commit || candidate.Version != testVersion {
|
||||
if candidate.RepositoryDir != repository || candidate.ModuleDir != repository || candidate.ModulePath != "example.com/candidate" || candidate.Tag != testVersion || candidate.Commit != commit || candidate.Version != testVersion {
|
||||
t.Fatalf("Check() candidate = %#v", candidate)
|
||||
}
|
||||
if tags := gitOutputForTest(t, repository, "tag", "--list"); tags != "" {
|
||||
@@ -82,6 +82,67 @@ func TestCheckCapturesCandidateAndCallsAdditionalChecksOnce(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureUsesNestedMajorModuleNoteAndTag(t *testing.T) {
|
||||
repository := newRepository(t, "# Example Product v1.2.3\n\nRoot release.\n")
|
||||
moduleDir := "integrations/ckconfig/v2"
|
||||
writeTestFile(t, repository, moduleDir+"/go.mod", "module example.com/ckconfig/v2\n\ngo 1.25.0\n")
|
||||
writeTestFile(t, repository, moduleDir+"/docs/releases/v2.3.4.md", "# Example Product v2.3.4\n\nNested release.\n")
|
||||
gitRun(t, repository, "add", moduleDir)
|
||||
gitRun(t, repository, "commit", "-m", "add nested major module")
|
||||
|
||||
captured, err := Capture(context.Background(), Request{
|
||||
RepositoryDir: repository,
|
||||
ModuleDir: moduleDir,
|
||||
Product: "Example Product",
|
||||
Version: "v2.3.4",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if captured.RepositoryDir != repository || captured.ModuleDir != filepath.Join(repository, moduleDir) || captured.ModulePath != "example.com/ckconfig/v2" || captured.Tag != "integrations/ckconfig/v2.3.4" {
|
||||
t.Fatalf("nested candidate identity = %#v", captured)
|
||||
}
|
||||
if !strings.Contains(captured.Note, "Nested release.") || strings.Contains(captured.Note, "Root release.") {
|
||||
t.Fatalf("captured note = %q, want nested module note", captured.Note)
|
||||
}
|
||||
|
||||
gitRun(t, repository, "tag", captured.Tag)
|
||||
conflicted, err := Capture(context.Background(), Request{
|
||||
RepositoryDir: repository,
|
||||
ModuleDir: moduleDir,
|
||||
Product: "Example Product",
|
||||
Version: "v2.3.4",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), captured.Tag) {
|
||||
t.Fatalf("Capture() with existing nested tag returned %#v, %v", conflicted, err)
|
||||
}
|
||||
if conflicted.ModulePath != captured.ModulePath || conflicted.Tag != captured.Tag || conflicted.Commit != captured.Commit {
|
||||
t.Fatalf("capture failure lost identity evidence: %#v", conflicted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureKeepsRepositoryWideCleanlinessGuardForNestedSelection(t *testing.T) {
|
||||
repository := newRepository(t, "# Example Product v1.2.3\n")
|
||||
moduleDir := "integrations/ckconfig"
|
||||
writeTestFile(t, repository, moduleDir+"/go.mod", "module example.com/ckconfig\n")
|
||||
writeTestFile(t, repository, moduleDir+"/docs/releases/"+testVersion+".md", "# Example Product v1.2.3\n")
|
||||
gitRun(t, repository, "add", moduleDir)
|
||||
gitRun(t, repository, "commit", "-m", "add nested module")
|
||||
writeTestFile(t, repository, "sibling/untracked.txt", "dirty\n")
|
||||
captured, err := Capture(context.Background(), Request{
|
||||
RepositoryDir: repository,
|
||||
ModuleDir: moduleDir,
|
||||
Product: "Example Product",
|
||||
Version: testVersion,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "must be clean") {
|
||||
t.Fatalf("Capture() = %#v, %v; want repository-wide dirty-state rejection", captured, err)
|
||||
}
|
||||
if captured.Commit == "" {
|
||||
t.Fatalf("dirty-state error lost captured commit: %#v", captured)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckStopsBeforeProductAndBuildCallbacksWhenGoFormatFails(t *testing.T) {
|
||||
repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n")
|
||||
writeTestFile(t, repository, "unformatted.go", "package candidate\nfunc Value( ) int{return 1}\n")
|
||||
@@ -423,7 +484,7 @@ func TestCheckRechecksHeadAndWorktreeAfterCallback(t *testing.T) {
|
||||
|
||||
func TestCheckRechecksLocalTagAfterCallback(t *testing.T) {
|
||||
repository := newRepository(t, "# Example Product v1.2.3\n")
|
||||
_, err := Check(context.Background(), Request{
|
||||
captured, err := Check(context.Background(), Request{
|
||||
RepositoryDir: repository,
|
||||
Product: "Example Product",
|
||||
Version: testVersion,
|
||||
@@ -435,6 +496,9 @@ func TestCheckRechecksLocalTagAfterCallback(t *testing.T) {
|
||||
if err == nil || !strings.Contains(err.Error(), "appeared during validation") {
|
||||
t.Fatalf("Check() error = %v, want tag recheck error", err)
|
||||
}
|
||||
if captured.RepositoryDir != repository || captured.ModuleDir != repository || captured.ModulePath != "example.com/candidate" || captured.Tag != testVersion || captured.Commit == "" {
|
||||
t.Fatalf("tag recheck failure lost candidate evidence: %#v", captured)
|
||||
}
|
||||
}
|
||||
|
||||
func validRequest(repository string) Request {
|
||||
|
||||
@@ -29,6 +29,7 @@ const (
|
||||
|
||||
type Request struct {
|
||||
RepositoryDir string
|
||||
ModuleDir string
|
||||
Product string
|
||||
Version string
|
||||
Destination gitea.Destination
|
||||
@@ -38,6 +39,10 @@ type Request struct {
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
RepositoryDir string
|
||||
ModuleDir string
|
||||
ModulePath string
|
||||
Tag string
|
||||
CandidateCommit string
|
||||
ReleaseURL string
|
||||
State State
|
||||
|
||||
@@ -42,6 +42,7 @@ var ErrUnexpectedPushURL = errors.New("unexpected publication push URL")
|
||||
|
||||
type Request struct {
|
||||
RepositoryDir string
|
||||
ModuleDir string
|
||||
Product string
|
||||
Version string
|
||||
Remote string
|
||||
@@ -53,6 +54,10 @@ type Request struct {
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
RepositoryDir string
|
||||
ModuleDir string
|
||||
ModulePath string
|
||||
Tag string
|
||||
Version string
|
||||
CandidateCommit string
|
||||
Endpoint string
|
||||
@@ -76,6 +81,7 @@ func Publish(ctx context.Context, request Request) (Result, error) {
|
||||
}
|
||||
checks := candidate.Request{
|
||||
RepositoryDir: request.RepositoryDir,
|
||||
ModuleDir: request.ModuleDir,
|
||||
Product: request.Product,
|
||||
Version: request.Version,
|
||||
Validation: request.Validation,
|
||||
@@ -83,10 +89,14 @@ func Publish(ctx context.Context, request Request) (Result, error) {
|
||||
Output: request.Output,
|
||||
}
|
||||
captured, err := candidate.Capture(ctx, checks)
|
||||
result.RepositoryDir = captured.RepositoryDir
|
||||
result.ModuleDir = captured.ModuleDir
|
||||
result.ModulePath = captured.ModulePath
|
||||
result.Tag = captured.Tag
|
||||
result.CandidateCommit = captured.Commit
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("capture release candidate: %w", err)
|
||||
}
|
||||
result.CandidateCommit = captured.Commit
|
||||
|
||||
endpoint, err := git.Resolve(ctx, captured.RepositoryDir, remoteName)
|
||||
if err != nil {
|
||||
|
||||
@@ -79,6 +79,12 @@ func FromCommitted(ctx context.Context, repositoryDir, moduleDir, version, commi
|
||||
if err != nil {
|
||||
return Identity{}, err
|
||||
}
|
||||
return FromCommittedSelection(ctx, selection, version, commit)
|
||||
}
|
||||
|
||||
// FromCommittedSelection derives identity from a previously resolved module
|
||||
// selection and the selected module declaration at a captured commit.
|
||||
func FromCommittedSelection(ctx context.Context, selection Selection, version, commit string) (Identity, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Identity{}, fmt.Errorf("resolve committed release identity: %w", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user