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

@@ -23,6 +23,9 @@ type BuildSpec struct {
// BuildRequest contains the source and output settings for Build. // BuildRequest contains the source and output settings for Build.
type BuildRequest struct { type BuildRequest struct {
RepositoryDir string // source repository whose current HEAD is recorded. 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. Version string // stable vMAJOR.MINOR.PATCH value injected at link time.
Spec BuildSpec // application package, artifact name, and targets. Spec BuildSpec // application package, artifact name, and targets.
OutputDir string // output location; empty selects <repository>/dist. OutputDir string // output location; empty selects <repository>/dist.
@@ -40,6 +43,9 @@ type BuildArtifact struct {
// BuildResult describes the source and artifacts reached by Build. // BuildResult describes the source and artifacts reached by Build.
type BuildResult struct { type BuildResult struct {
RepositoryDir string // absolute source repository path. 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. CandidateCommit string // Git HEAD observed when the build started.
// SourceVerified reports clean source and the same HEAD observed before and // SourceVerified reports clean source and the same HEAD observed before and
// after a completed build. It is required for binary delivery, not an attestation. // 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) { func Build(ctx context.Context, request BuildRequest) (BuildResult, error) {
result, err := internalbuild.Build(ctx, internalbuild.Request{ result, err := internalbuild.Build(ctx, internalbuild.Request{
RepositoryDir: request.RepositoryDir, RepositoryDir: request.RepositoryDir,
ModuleDir: request.ModuleDir,
Version: request.Version, Version: request.Version,
Spec: *internalBuildSpec(&request.Spec), Spec: *internalBuildSpec(&request.Spec),
OutputDir: request.OutputDir, OutputDir: request.OutputDir,
@@ -69,6 +76,9 @@ func Build(ctx context.Context, request BuildRequest) (BuildResult, error) {
}) })
public := BuildResult{ public := BuildResult{
RepositoryDir: result.RepositoryDir, RepositoryDir: result.RepositoryDir,
ModuleDir: result.ModuleDir,
ModulePath: result.ModulePath,
Tag: result.Tag,
CandidateCommit: result.CandidateCommit, CandidateCommit: result.CandidateCommit,
SourceVerified: result.SourceVerified, SourceVerified: result.SourceVerified,
Version: result.Version, Version: result.Version,
@@ -105,7 +115,7 @@ func internalBuildSpec(spec *BuildSpec) *internalbuild.Spec {
} }
func internalBuildResult(result BuildResult) *internalbuild.Result { 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 { for _, target := range result.Targets {
converted.Targets = append(converted.Targets, internalbuild.Target{OS: target.OS, Arch: target.Arch}) converted.Targets = append(converted.Targets, internalbuild.Target{OS: target.OS, Arch: target.Arch})
} }

View File

@@ -18,9 +18,13 @@ var ErrInvalidVersion = candidate.ErrInvalidVersion
var ErrUnexpectedPushURL = publish.ErrUnexpectedPushURL var ErrUnexpectedPushURL = publish.ErrUnexpectedPushURL
// Candidate identifies one committed local source revision selected for // 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 { type Candidate struct {
RepositoryDir string RepositoryDir string
ModuleDir string
ModulePath string
Tag string
Commit string Commit string
Version string Version string
} }
@@ -30,6 +34,9 @@ type Candidate struct {
// no remote availability claim. // no remote availability claim.
type ReleaseRequest struct { type ReleaseRequest struct {
RepositoryDir string RepositoryDir string
// ModuleDir selects a repository-relative module directory; empty selects
// the repository root.
ModuleDir string
Product string Product string
Version string Version string
Remote string Remote string
@@ -107,9 +114,12 @@ type GoValidationSpec struct {
} }
// Check validates one local, committed release candidate. It requires a clean // Check validates one local, committed release candidate. It requires a clean
// worktree and index on main, a committed note matching Product and Version, // worktree and index on main, a selected module declaration and module-local
// and an unused local tag. It always runs the offline Go test suite with the // committed note matching Product and Version, and an unused local tag. An
// available compatible local Go toolchain and Go host target, then calls // 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 // AdditionalChecks when supplied. Validation.Go.Race runs that one suite with
// the race detector, Validation.Go.FullVet runs go vet after the suite instead // 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 // 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) { func Check(ctx context.Context, request ReleaseRequest) (Candidate, error) {
checked, err := candidate.Check(ctx, candidateRequest(request)) checked, err := candidate.Check(ctx, candidateRequest(request))
if err != nil { if err != nil {
return Candidate{}, convertValidationError(err) return publicCandidate(checked), convertValidationError(err)
} }
return publicCandidate(checked), nil
}
func publicCandidate(checked candidate.Candidate) Candidate {
return Candidate{ return Candidate{
RepositoryDir: checked.RepositoryDir, RepositoryDir: checked.RepositoryDir,
ModuleDir: checked.ModuleDir,
ModulePath: checked.ModulePath,
Tag: checked.Tag,
Commit: checked.Commit, Commit: checked.Commit,
Version: checked.Version, Version: checked.Version,
}, nil }
} }
func candidateRequest(request ReleaseRequest) candidate.Request { func candidateRequest(request ReleaseRequest) candidate.Request {
fileSize := internalFileSizeSettings(request.Validation.FileSize) fileSize := internalFileSizeSettings(request.Validation.FileSize)
result := candidate.Request{ result := candidate.Request{
RepositoryDir: request.RepositoryDir, RepositoryDir: request.RepositoryDir,
ModuleDir: request.ModuleDir,
Product: request.Product, Product: request.Product,
Version: request.Version, Version: request.Version,
Validation: validation.Settings{ Validation: validation.Settings{
@@ -203,10 +221,6 @@ func candidateChecks(checks func(context.Context, Candidate) error) func(context
return nil return nil
} }
return func(ctx context.Context, captured candidate.Candidate) error { return func(ctx context.Context, captured candidate.Candidate) error {
return checks(ctx, Candidate{ return checks(ctx, publicCandidate(captured))
RepositoryDir: captured.RepositoryDir,
Commit: captured.Commit,
Version: captured.Version,
})
} }
} }

View File

@@ -46,6 +46,9 @@ type DeliveryAsset struct {
// Prebuilt result, also performs page-only delivery. // Prebuilt result, also performs page-only delivery.
type DeliveryRequest struct { type DeliveryRequest struct {
RepositoryDir string RepositoryDir string
// ModuleDir selects a repository-relative module directory; empty selects
// the repository root.
ModuleDir string
Product string Product string
Version string Version string
Destination GiteaDestination Destination GiteaDestination
@@ -62,6 +65,10 @@ type DeliveryRequest struct {
// current observations: an asset appears in at most one slice, and early // current observations: an asset appears in at most one slice, and early
// failures may leave some declared assets unreported. // failures may leave some declared assets unreported.
type DeliveryResult struct { type DeliveryResult struct {
RepositoryDir string
ModuleDir string
ModulePath string
Tag string
Version string Version string
CandidateCommit string CandidateCommit string
ReleaseURL string ReleaseURL string
@@ -95,6 +102,7 @@ func Deliver(ctx context.Context, request DeliveryRequest) (DeliveryResult, erro
} }
internalResult, err := internaldelivery.Deliver(ctx, internaldelivery.Request{ internalResult, err := internaldelivery.Deliver(ctx, internaldelivery.Request{
RepositoryDir: request.RepositoryDir, RepositoryDir: request.RepositoryDir,
ModuleDir: request.ModuleDir,
Product: request.Product, Product: request.Product,
Version: request.Version, Version: request.Version,
Destination: destination, Destination: destination,
@@ -102,6 +110,10 @@ func Deliver(ctx context.Context, request DeliveryRequest) (DeliveryResult, erro
Prebuilt: prebuilt, Prebuilt: prebuilt,
Output: request.Output, Output: request.Output,
}) })
result.RepositoryDir = internalResult.RepositoryDir
result.ModuleDir = internalResult.ModuleDir
result.ModulePath = internalResult.ModulePath
result.Tag = internalResult.Tag
result.CandidateCommit = internalResult.CandidateCommit result.CandidateCommit = internalResult.CandidateCommit
result.ReleaseURL = internalResult.ReleaseURL result.ReleaseURL = internalResult.ReleaseURL
result.State = DeliveryState(internalResult.State) result.State = DeliveryState(internalResult.State)

View File

@@ -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. evidence. An unrelated sibling worktree change still fails the repository guard.
Run candidate/facade tests and `go run ./internal/apicheck`. 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 ## Stage 3: Publish and reconcile the complete selected tag
**Outcome:** Publication addresses one module's exact tag and retains its current **Outcome:** Publication addresses one module's exact tag and retains its current

View File

@@ -29,6 +29,7 @@ type Spec struct {
type Request struct { type Request struct {
RepositoryDir string RepositoryDir string
ModuleDir string
Version string Version string
Spec Spec Spec Spec
OutputDir string OutputDir string
@@ -44,6 +45,9 @@ type Artifact struct {
type Result struct { type Result struct {
RepositoryDir string RepositoryDir string
ModuleDir string
ModulePath string
Tag string
CandidateCommit string CandidateCommit string
SourceVerified bool SourceVerified bool
Version string Version string

View File

@@ -11,7 +11,9 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/process" "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/validation"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract" "gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
) )
@@ -20,6 +22,9 @@ var ErrInvalidVersion = errors.New("invalid stable version")
type Candidate struct { type Candidate struct {
RepositoryDir string RepositoryDir string
ModuleDir string
ModulePath string
Tag string
Commit string Commit string
Version string Version string
Note string Note string
@@ -27,6 +32,7 @@ type Candidate struct {
type Request struct { type Request struct {
RepositoryDir string RepositoryDir string
ModuleDir string
Product string Product string
Version string Version string
Validation validation.Settings Validation validation.Settings
@@ -38,18 +44,18 @@ type Request struct {
func Check(ctx context.Context, request Request) (Candidate, error) { func Check(ctx context.Context, request Request) (Candidate, error) {
captured, err := Capture(ctx, request) captured, err := Capture(ctx, request)
if err != nil { if err != nil {
return Candidate{}, err return captured, err
} }
if err := Validate(ctx, request, captured); err != nil { if err := Validate(ctx, request, captured); err != nil {
return Candidate{}, err return captured, err
} }
if request.AfterValidate != nil { if request.AfterValidate != nil {
if err := request.AfterValidate(ctx, captured); err != 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 { if err := Recheck(ctx, captured, ""); err != nil {
return Candidate{}, err return captured, err
} }
return captured, nil 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, // Capture validates request identity and records one clean local candidate,
// including the note bytes from the captured commit. It does not run checks. // including the note bytes from the captured commit. It does not run checks.
func Capture(ctx context.Context, request Request) (Candidate, error) { func Capture(ctx context.Context, request Request) (Candidate, error) {
captured := Candidate{Version: request.Version}
if !validStableVersion(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) == "" { 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) repositoryDir, err := filepath.Abs(request.RepositoryDir)
if err != nil { 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) state, err := inspectLocalState(ctx, repositoryDir)
if err != nil { if err != nil {
return Candidate{}, err return captured, err
} }
captured.Commit = state.commit
if state.branch != "main" { 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 != "" { 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" identitySelection, err := releaseidentity.ResolveSelection(repositoryDir, request.ModuleDir)
note, err := gitOutput(ctx, repositoryDir, "show", state.commit+":"+path)
if err != nil { 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 { 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 { if exists, err := hasLocalTag(ctx, repositoryDir, identity.Tag); err != nil {
return Candidate{}, fmt.Errorf("inspect local tag %q: %w", request.Version, err) return captured, fmt.Errorf("inspect local tag %q: %w", identity.Tag, err)
} else if exists { } 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 // 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) return fmt.Errorf("worktree or index changed:\n%s", current.status)
} }
if expectedTagObject == "" { if expectedTagObject == "" {
if exists, err := hasLocalTag(ctx, captured.RepositoryDir, captured.Version); err != nil { if exists, err := hasLocalTag(ctx, captured.RepositoryDir, captured.Tag); err != nil {
return fmt.Errorf("recheck local tag %q: %w", captured.Version, err) return fmt.Errorf("recheck local tag %q: %w", captured.Tag, err)
} else if exists { } 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 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 { 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 { 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" { 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 return nil
} }
@@ -199,10 +220,10 @@ func gitOutput(ctx context.Context, repositoryDir string, args ...string) ([]byt
return stdout.Bytes(), nil 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 var stderr bytes.Buffer
err := process.Run(ctx, process.Invocation{ 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, Dir: repositoryDir,
Stderr: &stderr, Stderr: &stderr,
}) })

View File

@@ -71,7 +71,7 @@ func TestCheckCapturesCandidateAndCallsAdditionalChecksOnce(t *testing.T) {
if seen != candidate { if seen != candidate {
t.Fatalf("callback candidate = %#v, returned candidate = %#v", 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) t.Fatalf("Check() candidate = %#v", candidate)
} }
if tags := gitOutputForTest(t, repository, "tag", "--list"); tags != "" { 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) { func TestCheckStopsBeforeProductAndBuildCallbacksWhenGoFormatFails(t *testing.T) {
repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n") 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") 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) { func TestCheckRechecksLocalTagAfterCallback(t *testing.T) {
repository := newRepository(t, "# Example Product v1.2.3\n") repository := newRepository(t, "# Example Product v1.2.3\n")
_, err := Check(context.Background(), Request{ captured, err := Check(context.Background(), Request{
RepositoryDir: repository, RepositoryDir: repository,
Product: "Example Product", Product: "Example Product",
Version: testVersion, Version: testVersion,
@@ -435,6 +496,9 @@ func TestCheckRechecksLocalTagAfterCallback(t *testing.T) {
if err == nil || !strings.Contains(err.Error(), "appeared during validation") { if err == nil || !strings.Contains(err.Error(), "appeared during validation") {
t.Fatalf("Check() error = %v, want tag recheck error", err) 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 { func validRequest(repository string) Request {

View File

@@ -29,6 +29,7 @@ const (
type Request struct { type Request struct {
RepositoryDir string RepositoryDir string
ModuleDir string
Product string Product string
Version string Version string
Destination gitea.Destination Destination gitea.Destination
@@ -38,6 +39,10 @@ type Request struct {
} }
type Result struct { type Result struct {
RepositoryDir string
ModuleDir string
ModulePath string
Tag string
CandidateCommit string CandidateCommit string
ReleaseURL string ReleaseURL string
State State State State

View File

@@ -42,6 +42,7 @@ var ErrUnexpectedPushURL = errors.New("unexpected publication push URL")
type Request struct { type Request struct {
RepositoryDir string RepositoryDir string
ModuleDir string
Product string Product string
Version string Version string
Remote string Remote string
@@ -53,6 +54,10 @@ type Request struct {
} }
type Result struct { type Result struct {
RepositoryDir string
ModuleDir string
ModulePath string
Tag string
Version string Version string
CandidateCommit string CandidateCommit string
Endpoint string Endpoint string
@@ -76,6 +81,7 @@ func Publish(ctx context.Context, request Request) (Result, error) {
} }
checks := candidate.Request{ checks := candidate.Request{
RepositoryDir: request.RepositoryDir, RepositoryDir: request.RepositoryDir,
ModuleDir: request.ModuleDir,
Product: request.Product, Product: request.Product,
Version: request.Version, Version: request.Version,
Validation: request.Validation, Validation: request.Validation,
@@ -83,10 +89,14 @@ func Publish(ctx context.Context, request Request) (Result, error) {
Output: request.Output, Output: request.Output,
} }
captured, err := candidate.Capture(ctx, checks) 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 { if err != nil {
return result, fmt.Errorf("capture release candidate: %w", err) return result, fmt.Errorf("capture release candidate: %w", err)
} }
result.CandidateCommit = captured.Commit
endpoint, err := git.Resolve(ctx, captured.RepositoryDir, remoteName) endpoint, err := git.Resolve(ctx, captured.RepositoryDir, remoteName)
if err != nil { if err != nil {

View File

@@ -79,6 +79,12 @@ func FromCommitted(ctx context.Context, repositoryDir, moduleDir, version, commi
if err != nil { if err != nil {
return Identity{}, err 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 { if err := ctx.Err(); err != nil {
return Identity{}, fmt.Errorf("resolve committed release identity: %w", err) return Identity{}, fmt.Errorf("resolve committed release identity: %w", err)
} }

View File

@@ -40,6 +40,10 @@ const (
// PublicationResult describes the publication state reached by this call. // PublicationResult describes the publication state reached by this call.
type PublicationResult struct { 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 Version string // selected stable version
CandidateCommit string // captured main commit CandidateCommit string // captured main commit
Endpoint string // captured effective push URL Endpoint string // captured effective push URL
@@ -75,6 +79,7 @@ func Publish(ctx context.Context, request ReleaseRequest) (PublicationResult, er
candidateInput := candidateRequest(request) candidateInput := candidateRequest(request)
result, err := publish.Publish(ctx, publish.Request{ result, err := publish.Publish(ctx, publish.Request{
RepositoryDir: candidateInput.RepositoryDir, RepositoryDir: candidateInput.RepositoryDir,
ModuleDir: candidateInput.ModuleDir,
Product: candidateInput.Product, Product: candidateInput.Product,
Version: candidateInput.Version, Version: candidateInput.Version,
Remote: request.Remote, Remote: request.Remote,
@@ -85,6 +90,10 @@ func Publish(ctx context.Context, request ReleaseRequest) (PublicationResult, er
Output: candidateInput.Output, Output: candidateInput.Output,
}) })
return PublicationResult{ return PublicationResult{
RepositoryDir: result.RepositoryDir,
ModuleDir: result.ModuleDir,
ModulePath: result.ModulePath,
Tag: result.Tag,
Version: result.Version, Version: result.Version,
CandidateCommit: result.CandidateCommit, CandidateCommit: result.CandidateCommit,
Endpoint: result.Endpoint, Endpoint: result.Endpoint,

View File

@@ -37,7 +37,7 @@ func TestPublishPushesOnlyTheCapturedAnnotatedTag(t *testing.T) {
if readErr != nil || string(runs) != "ran\n" { if readErr != nil || string(runs) != "ran\n" {
return fmt.Errorf("standard suite runs before additional checks: contents=%q error=%v", runs, readErr) 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) t.Fatalf("callback candidate = %#v", candidate)
} }
return nil return nil
@@ -58,7 +58,7 @@ func TestPublishPushesOnlyTheCapturedAnnotatedTag(t *testing.T) {
if result.State != releasekit.PublicationPublished || result.Phase != releasekit.PublicationComplete { if result.State != releasekit.PublicationPublished || result.Phase != releasekit.PublicationComplete {
t.Fatalf("publication result = %#v", result) 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) t.Fatalf("publication identity = %#v", result)
} }
@@ -284,7 +284,7 @@ func requireRacePrerequisites(t *testing.T) {
func TestCheckReturnsPublicValidationError(t *testing.T) { func TestCheckReturnsPublicValidationError(t *testing.T) {
repo := newPublishFixture(t) repo := newPublishFixture(t)
t.Setenv("RELEASEKIT_FAIL_FIXTURE_SUITE", "1") 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, RepositoryDir: repo.work,
Product: "Sample App", Product: "Sample App",
Version: "v1.2.3", Version: "v1.2.3",
@@ -300,6 +300,9 @@ func TestCheckReturnsPublicValidationError(t *testing.T) {
if !errors.Is(err, exitErr) { if !errors.Is(err, exitErr) {
t.Fatal("Check() error does not preserve the Go process error identity") 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) { func TestMarkdownValidationRunsThroughCheckAndPublish(t *testing.T) {

View File

@@ -43,7 +43,7 @@ func TestCandidateOperationsPreserveCallbackValidationDiagnostics(t *testing.T)
RepositoryDir: repo.work, Product: "Sample App", Version: "v1.2.3", RepositoryDir: repo.work, Product: "Sample App", Version: "v1.2.3",
AdditionalChecks: func(context.Context, releasekit.Candidate) error { return diagnostic }, 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) result, publishError := releasekit.Publish(t.Context(), request)
for _, err := range []error{checkError, publishError} { for _, err := range []error{checkError, publishError} {
var got *releasekit.ValidationError 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 != "" { if result.State != releasekit.PublicationNotAttempted || result.TagObject != "" {
t.Fatalf("callback failure publication result = %#v", result) 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)
}
} }