From 3e516d0ac189c2d6891f7e0ec6e5bfbe3a7fb2ff Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 10 Oct 2026 02:15:13 +0000 Subject: [PATCH] Carry module identity through candidate results --- build.go | 22 +++++--- check.go | 42 ++++++++++----- delivery.go | 18 +++++-- docs/roadmap/implementation.md | 7 +++ internal/build/build.go | 4 ++ internal/candidate/check.go | 77 ++++++++++++++++++---------- internal/candidate/check_test.go | 68 +++++++++++++++++++++++- internal/delivery/delivery.go | 5 ++ internal/publish/publish.go | 12 ++++- internal/releaseidentity/identity.go | 6 +++ publish.go | 9 ++++ publish_test.go | 9 ++-- validation_error_test.go | 8 ++- 13 files changed, 229 insertions(+), 58 deletions(-) diff --git a/build.go b/build.go index 58192df..dbfeba5 100644 --- a/build.go +++ b/build.go @@ -22,11 +22,14 @@ type BuildSpec struct { // BuildRequest contains the source and output settings for Build. type BuildRequest struct { - RepositoryDir string // source repository whose current HEAD is recorded. - Version string // stable vMAJOR.MINOR.PATCH value injected at link time. - Spec BuildSpec // application package, artifact name, and targets. - OutputDir string // output location; empty selects /dist. - Output io.Writer // optional build progress destination. + RepositoryDir string // source repository whose current HEAD is recorded. + // ModuleDir selects a repository-relative module directory; empty selects + // the repository root. + ModuleDir string + Version string // stable vMAJOR.MINOR.PATCH value injected at link time. + Spec BuildSpec // application package, artifact name, and targets. + OutputDir string // output location; empty selects /dist. + Output io.Writer // optional build progress destination. } // BuildArtifact identifies one generated binary and its SHA-256 digest. @@ -40,6 +43,9 @@ type BuildArtifact struct { // BuildResult describes the source and artifacts reached by Build. type BuildResult struct { RepositoryDir string // absolute source repository path. + ModuleDir string // absolute selected module directory. + ModulePath string // selected module's declared Go path. + Tag string // complete tag derived for this version. CandidateCommit string // Git HEAD observed when the build started. // SourceVerified reports clean source and the same HEAD observed before and // after a completed build. It is required for binary delivery, not an attestation. @@ -62,6 +68,7 @@ type BuildResult struct { func Build(ctx context.Context, request BuildRequest) (BuildResult, error) { result, err := internalbuild.Build(ctx, internalbuild.Request{ RepositoryDir: request.RepositoryDir, + ModuleDir: request.ModuleDir, Version: request.Version, Spec: *internalBuildSpec(&request.Spec), OutputDir: request.OutputDir, @@ -69,6 +76,9 @@ func Build(ctx context.Context, request BuildRequest) (BuildResult, error) { }) public := BuildResult{ RepositoryDir: result.RepositoryDir, + ModuleDir: result.ModuleDir, + ModulePath: result.ModulePath, + Tag: result.Tag, CandidateCommit: result.CandidateCommit, SourceVerified: result.SourceVerified, Version: result.Version, @@ -105,7 +115,7 @@ func internalBuildSpec(spec *BuildSpec) *internalbuild.Spec { } func internalBuildResult(result BuildResult) *internalbuild.Result { - converted := &internalbuild.Result{RepositoryDir: result.RepositoryDir, CandidateCommit: result.CandidateCommit, SourceVerified: result.SourceVerified, Version: result.Version, MainPackage: result.MainPackage, BinaryName: result.BinaryName, OutputDir: result.OutputDir, Compiler: result.Compiler} + converted := &internalbuild.Result{RepositoryDir: result.RepositoryDir, ModuleDir: result.ModuleDir, ModulePath: result.ModulePath, Tag: result.Tag, CandidateCommit: result.CandidateCommit, SourceVerified: result.SourceVerified, Version: result.Version, MainPackage: result.MainPackage, BinaryName: result.BinaryName, OutputDir: result.OutputDir, Compiler: result.Compiler} for _, target := range result.Targets { converted.Targets = append(converted.Targets, internalbuild.Target{OS: target.OS, Arch: target.Arch}) } diff --git a/check.go b/check.go index 8eae41a..be02dad 100644 --- a/check.go +++ b/check.go @@ -18,9 +18,13 @@ var ErrInvalidVersion = candidate.ErrInvalidVersion var ErrUnexpectedPushURL = publish.ErrUnexpectedPushURL // Candidate identifies one committed local source revision selected for -// validation. +// validation. ModuleDir is the absolute selected module directory, ModulePath +// is its declared Go import path, and Tag is the complete derived Git tag. type Candidate struct { RepositoryDir string + ModuleDir string + ModulePath string + Tag string Commit string Version string } @@ -30,9 +34,12 @@ type Candidate struct { // no remote availability claim. type ReleaseRequest struct { RepositoryDir string - Product string - Version string - Remote string + // ModuleDir selects a repository-relative module directory; empty selects + // the repository root. + ModuleDir string + Product string + Version string + Remote string // ExpectedPushURL optionally requires Publish's resolved push URL to match // this string exactly before validation or tag creation. Check ignores this // publication-only expectation. A mismatch returns ErrUnexpectedPushURL. @@ -107,9 +114,12 @@ type GoValidationSpec struct { } // Check validates one local, committed release candidate. It requires a clean -// worktree and index on main, a committed note matching Product and Version, -// and an unused local tag. It always runs the offline Go test suite with the -// available compatible local Go toolchain and Go host target, then calls +// worktree and index on main, a selected module declaration and module-local +// committed note matching Product and Version, and an unused local tag. An +// empty ModuleDir selects the repository root. The module declaration +// determines the full Git tag for the semantic Version. It always runs the +// offline Go test suite with the available compatible local Go toolchain and +// Go host target, then calls // AdditionalChecks when supplied. Validation.Go.Race runs that one suite with // the race detector, Validation.Go.FullVet runs go vet after the suite instead // of test-time vet, and Validation.Go.CompileTargets compiles all packages for @@ -139,19 +149,27 @@ type GoValidationSpec struct { func Check(ctx context.Context, request ReleaseRequest) (Candidate, error) { checked, err := candidate.Check(ctx, candidateRequest(request)) if err != nil { - return Candidate{}, convertValidationError(err) + return publicCandidate(checked), convertValidationError(err) } + return publicCandidate(checked), nil +} + +func publicCandidate(checked candidate.Candidate) Candidate { return Candidate{ RepositoryDir: checked.RepositoryDir, + ModuleDir: checked.ModuleDir, + ModulePath: checked.ModulePath, + Tag: checked.Tag, Commit: checked.Commit, Version: checked.Version, - }, nil + } } func candidateRequest(request ReleaseRequest) candidate.Request { fileSize := internalFileSizeSettings(request.Validation.FileSize) result := candidate.Request{ RepositoryDir: request.RepositoryDir, + ModuleDir: request.ModuleDir, Product: request.Product, Version: request.Version, Validation: validation.Settings{ @@ -203,10 +221,6 @@ func candidateChecks(checks func(context.Context, Candidate) error) func(context return nil } return func(ctx context.Context, captured candidate.Candidate) error { - return checks(ctx, Candidate{ - RepositoryDir: captured.RepositoryDir, - Commit: captured.Commit, - Version: captured.Version, - }) + return checks(ctx, publicCandidate(captured)) } } diff --git a/delivery.go b/delivery.go index 3a039c0..bb2472a 100644 --- a/delivery.go +++ b/delivery.go @@ -46,9 +46,12 @@ type DeliveryAsset struct { // Prebuilt result, also performs page-only delivery. type DeliveryRequest struct { RepositoryDir string - Product string - Version string - Destination GiteaDestination + // ModuleDir selects a repository-relative module directory; empty selects + // the repository root. + ModuleDir string + Product string + Version string + Destination GiteaDestination // Build selects binaries to build in temporary storage outside the checkout. Build *BuildSpec // Prebuilt supplies an existing build whose files remain caller-owned. @@ -62,6 +65,10 @@ type DeliveryRequest struct { // current observations: an asset appears in at most one slice, and early // failures may leave some declared assets unreported. type DeliveryResult struct { + RepositoryDir string + ModuleDir string + ModulePath string + Tag string Version string CandidateCommit string ReleaseURL string @@ -95,6 +102,7 @@ func Deliver(ctx context.Context, request DeliveryRequest) (DeliveryResult, erro } internalResult, err := internaldelivery.Deliver(ctx, internaldelivery.Request{ RepositoryDir: request.RepositoryDir, + ModuleDir: request.ModuleDir, Product: request.Product, Version: request.Version, Destination: destination, @@ -102,6 +110,10 @@ func Deliver(ctx context.Context, request DeliveryRequest) (DeliveryResult, erro Prebuilt: prebuilt, Output: request.Output, }) + result.RepositoryDir = internalResult.RepositoryDir + result.ModuleDir = internalResult.ModuleDir + result.ModulePath = internalResult.ModulePath + result.Tag = internalResult.Tag result.CandidateCommit = internalResult.CandidateCommit result.ReleaseURL = internalResult.ReleaseURL result.State = DeliveryState(internalResult.State) diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index c6fd3d4..4206247 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -215,6 +215,13 @@ later validation failures retain available module, version, tag, and commit evidence. An unrelated sibling worktree change still fails the repository guard. Run candidate/facade tests and `go run ./internal/apicheck`. +**Completed:** Added module selection and identity evidence to the facade and +internal request/result types. Candidate capture now reads the selected +committed declaration and module-local note, guards the complete local tag, and +preserves available evidence through callbacks and later failures. Verified +nested physical-major capture, sibling cleanliness, facade error evidence, +`go test . ./internal/candidate`, and `go run ./internal/apicheck`. + ## Stage 3: Publish and reconcile the complete selected tag **Outcome:** Publication addresses one module's exact tag and retains its current diff --git a/internal/build/build.go b/internal/build/build.go index 9b36488..6808c77 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -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 diff --git a/internal/candidate/check.go b/internal/candidate/check.go index 263a514..6bc89d3 100644 --- a/internal/candidate/check.go +++ b/internal/candidate/check.go @@ -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, }) diff --git a/internal/candidate/check_test.go b/internal/candidate/check_test.go index 18fda3e..2067b97 100644 --- a/internal/candidate/check_test.go +++ b/internal/candidate/check_test.go @@ -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 { diff --git a/internal/delivery/delivery.go b/internal/delivery/delivery.go index 75bb666..9234766 100644 --- a/internal/delivery/delivery.go +++ b/internal/delivery/delivery.go @@ -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 diff --git a/internal/publish/publish.go b/internal/publish/publish.go index 623e5da..3de36b5 100644 --- a/internal/publish/publish.go +++ b/internal/publish/publish.go @@ -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 { diff --git a/internal/releaseidentity/identity.go b/internal/releaseidentity/identity.go index 3b3701e..c3a158f 100644 --- a/internal/releaseidentity/identity.go +++ b/internal/releaseidentity/identity.go @@ -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) } diff --git a/publish.go b/publish.go index 39fa56a..32ac7b5 100644 --- a/publish.go +++ b/publish.go @@ -40,6 +40,10 @@ const ( // PublicationResult describes the publication state reached by this call. type PublicationResult struct { + RepositoryDir string // absolute source repository path. + ModuleDir string // absolute selected module directory. + ModulePath string // selected module's declared Go path. + Tag string // complete selected Git tag. Version string // selected stable version CandidateCommit string // captured main commit Endpoint string // captured effective push URL @@ -75,6 +79,7 @@ func Publish(ctx context.Context, request ReleaseRequest) (PublicationResult, er candidateInput := candidateRequest(request) result, err := publish.Publish(ctx, publish.Request{ RepositoryDir: candidateInput.RepositoryDir, + ModuleDir: candidateInput.ModuleDir, Product: candidateInput.Product, Version: candidateInput.Version, Remote: request.Remote, @@ -85,6 +90,10 @@ func Publish(ctx context.Context, request ReleaseRequest) (PublicationResult, er Output: candidateInput.Output, }) return PublicationResult{ + RepositoryDir: result.RepositoryDir, + ModuleDir: result.ModuleDir, + ModulePath: result.ModulePath, + Tag: result.Tag, Version: result.Version, CandidateCommit: result.CandidateCommit, Endpoint: result.Endpoint, diff --git a/publish_test.go b/publish_test.go index fec8245..9425a17 100644 --- a/publish_test.go +++ b/publish_test.go @@ -37,7 +37,7 @@ func TestPublishPushesOnlyTheCapturedAnnotatedTag(t *testing.T) { if readErr != nil || string(runs) != "ran\n" { return fmt.Errorf("standard suite runs before additional checks: contents=%q error=%v", runs, readErr) } - if candidate.RepositoryDir != repo.work || candidate.Commit != commit || candidate.Version != "v1.2.3" { + if candidate.RepositoryDir != repo.work || candidate.ModuleDir != repo.work || candidate.ModulePath != "example.com/releasekit-publish-fixture" || candidate.Tag != "v1.2.3" || candidate.Commit != commit || candidate.Version != "v1.2.3" { t.Fatalf("callback candidate = %#v", candidate) } return nil @@ -58,7 +58,7 @@ func TestPublishPushesOnlyTheCapturedAnnotatedTag(t *testing.T) { if result.State != releasekit.PublicationPublished || result.Phase != releasekit.PublicationComplete { t.Fatalf("publication result = %#v", result) } - if result.CandidateCommit != commit || result.Endpoint != repo.bare || result.TagObject == "" { + if result.RepositoryDir != repo.work || result.ModuleDir != repo.work || result.ModulePath != "example.com/releasekit-publish-fixture" || result.Tag != "v1.2.3" || result.CandidateCommit != commit || result.Endpoint != repo.bare || result.TagObject == "" { t.Fatalf("publication identity = %#v", result) } @@ -284,7 +284,7 @@ func requireRacePrerequisites(t *testing.T) { func TestCheckReturnsPublicValidationError(t *testing.T) { repo := newPublishFixture(t) t.Setenv("RELEASEKIT_FAIL_FIXTURE_SUITE", "1") - _, err := releasekit.Check(context.Background(), releasekit.ReleaseRequest{ + captured, err := releasekit.Check(context.Background(), releasekit.ReleaseRequest{ RepositoryDir: repo.work, Product: "Sample App", Version: "v1.2.3", @@ -300,6 +300,9 @@ func TestCheckReturnsPublicValidationError(t *testing.T) { if !errors.Is(err, exitErr) { t.Fatal("Check() error does not preserve the Go process error identity") } + if captured.RepositoryDir != repo.work || captured.ModuleDir != repo.work || captured.ModulePath != "example.com/releasekit-publish-fixture" || captured.Tag != "v1.2.3" || captured.Commit != repo.commit || captured.Version != "v1.2.3" { + t.Fatalf("Check() failure candidate = %#v, want captured identity alongside validation error", captured) + } } func TestMarkdownValidationRunsThroughCheckAndPublish(t *testing.T) { diff --git a/validation_error_test.go b/validation_error_test.go index 9deeff3..157a28e 100644 --- a/validation_error_test.go +++ b/validation_error_test.go @@ -43,7 +43,7 @@ func TestCandidateOperationsPreserveCallbackValidationDiagnostics(t *testing.T) RepositoryDir: repo.work, Product: "Sample App", Version: "v1.2.3", AdditionalChecks: func(context.Context, releasekit.Candidate) error { return diagnostic }, } - _, checkError := releasekit.Check(t.Context(), request) + candidate, checkError := releasekit.Check(t.Context(), request) result, publishError := releasekit.Publish(t.Context(), request) for _, err := range []error{checkError, publishError} { var got *releasekit.ValidationError @@ -56,7 +56,13 @@ func TestCandidateOperationsPreserveCallbackValidationDiagnostics(t *testing.T) } } } + if candidate.ModuleDir != repo.work || candidate.ModulePath != "example.com/releasekit-publish-fixture" || candidate.Tag != "v1.2.3" || candidate.Commit != repo.commit { + t.Fatalf("Check() callback failure candidate = %#v, want captured identity", candidate) + } if result.State != releasekit.PublicationNotAttempted || result.TagObject != "" { t.Fatalf("callback failure publication result = %#v", result) } + if result.RepositoryDir != repo.work || result.ModuleDir != repo.work || result.ModulePath != candidate.ModulePath || result.Tag != candidate.Tag || result.CandidateCommit != candidate.Commit { + t.Fatalf("Publish() callback failure result = %#v, want captured identity", result) + } }