Carry module identity through candidate results

This commit is contained in:
2026-10-10 02:15:13 +00:00
parent f99fb59cd4
commit 3e516d0ac1
13 changed files with 229 additions and 58 deletions

View File

@@ -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

View File

@@ -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,
})

View File

@@ -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 {

View File

@@ -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

View File

@@ -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 {

View File

@@ -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)
}