Resolve source operations from the Git checkout root

This commit is contained in:
2026-10-10 14:03:57 +00:00
parent 750632f80e
commit 7555d3e6c9
11 changed files with 164 additions and 13 deletions

View File

@@ -6,6 +6,7 @@ import (
"gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact" "gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact"
internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build" internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity" "gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity"
) )
@@ -24,7 +25,7 @@ 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 // directory within the source Git checkout whose HEAD is recorded.
// ModuleDir selects a repository-relative module directory; empty selects // ModuleDir selects a repository-relative module directory; empty selects
// the repository root. // the repository root.
ModuleDir string ModuleDir string
@@ -71,7 +72,11 @@ type BuildResult struct {
// Errors return partial build facts in the result. // Errors return partial build facts in the result.
func Build(ctx context.Context, request BuildRequest) (BuildResult, error) { func Build(ctx context.Context, request BuildRequest) (BuildResult, error) {
public := BuildResult{Version: request.Version} public := BuildResult{Version: request.Version}
identity, err := releaseidentity.FromWorkingTree(ctx, request.RepositoryDir, request.ModuleDir, request.Version) repositoryDir, err := git.RepositoryRoot(ctx, request.RepositoryDir)
if err != nil {
return public, err
}
identity, err := releaseidentity.FromWorkingTree(ctx, repositoryDir, request.ModuleDir, request.Version)
if err != nil { if err != nil {
return public, err return public, err
} }

View File

@@ -33,12 +33,14 @@ type Candidate struct {
// local candidate. Remote is retained for publication operations; Check makes // local candidate. Remote is retained for publication operations; Check makes
// no remote availability claim. // no remote availability claim.
type ReleaseRequest struct { type ReleaseRequest struct {
// RepositoryDir names a directory within the source Git checkout. Operations
// resolve its root before applying repository-relative selections.
RepositoryDir string RepositoryDir string
// ModuleDir selects a repository-relative module directory; empty selects // ModuleDir selects a repository-relative module directory; empty selects
// the repository root. // the repository root.
ModuleDir string ModuleDir string
Product string Product string
Version string // stable semantic version, independent of the derived Git tag. Version string // stable semantic version, independent of the derived Git tag.
Remote string Remote string
// ExpectedPushURL optionally requires Publish's resolved push URL to match // ExpectedPushURL optionally requires Publish's resolved push URL to match
// this string exactly before validation or tag creation. Check ignores this // this string exactly before validation or tag creation. Check ignores this

View File

@@ -14,6 +14,7 @@ import (
"gitea.maximumdirect.net/eric/ReleaseKit" "gitea.maximumdirect.net/eric/ReleaseKit"
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo" "gitea.maximumdirect.net/eric/ReleaseKit/buildinfo"
"gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli" "gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity" "gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity"
) )
@@ -30,6 +31,13 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
fmt.Fprintf(stderr, "%v\n%s\n", err, cli.Usage) fmt.Fprintf(stderr, "%v\n%s\n", err, cli.Usage)
return 2 return 2
} }
if invocation.Command != cli.VersionCommand {
invocation.Repository, err = git.RepositoryRoot(ctx, invocation.Repository)
if err != nil {
fmt.Fprintf(stderr, "%v\n", err)
return 1
}
}
switch invocation.Command { switch invocation.Command {
case cli.VersionCommand: case cli.VersionCommand:

View File

@@ -0,0 +1,25 @@
package main
import (
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestNativeCheckFromChildUsesRootProfileAndSuite(t *testing.T) {
binary := buildWorkflowCLI(t, cliRepositoryRoot(t))
repository := newCLIRepository(t)
writeRepositoryFile(t, repository, "candidate_test.go", "package candidate\nimport \"testing\"\nfunc TestFixture(t *testing.T) { t.Fatal(\"root suite failure\") }\n")
writeRepositoryFile(t, repository, "releasekit.json", `{"product":"Example Product"}`)
writeRepositoryFile(t, repository, "apps/widget/go.mod", "module example.com/widget\n\ngo 1.26.0\n")
writeRepositoryFile(t, repository, "apps/widget/widget_test.go", "package widget\nimport \"testing\"\nfunc TestWidget(t *testing.T) {}\n")
cliGitRun(t, repository, "add", ".")
cliGitRun(t, repository, "commit", "-m", "Add failing root and passing child")
command := exec.Command(binary, "check", "v1.2.3")
command.Dir = filepath.Join(repository, "apps", "widget")
output, err := command.CombinedOutput()
if exit, ok := err.(*exec.ExitError); !ok || exit.ExitCode() != 1 || !strings.Contains(string(output), "root suite failure") || !strings.Contains(string(output), "module=example.com/cli-candidate") {
t.Fatalf("child invocation error = %v, output = %s", err, output)
}
}

View File

@@ -21,8 +21,9 @@ go run ./cmd/releasekit check [--repo DIR] [--config FILE] VERSION
``` ```
`VERSION` must use stable `vMAJOR.MINOR.PATCH` syntax. Options must precede the `VERSION` must use stable `vMAJOR.MINOR.PATCH` syntax. Options must precede the
version. The repository defaults to the current directory, and the profile version. The repository argument defaults to the current directory. ReleaseKit
defaults to `<repository>/releasekit.json`. A successful command prints the resolves its containing Git checkout root before selecting the module or profile;
the profile defaults to `<repository-root>/releasekit.json`. A successful command prints the
captured module, version, full tag, and commit. It checks the committed release note and captured module, version, full tag, and commit. It checks the committed release note and
local Git state, runs the standard offline Go test suite (with `-race` when local Git state, runs the standard offline Go test suite (with `-race` when
`validation.go.race` is true), and runs a separate `go vet -mod=readonly ./...` `validation.go.race` is true), and runs a separate `go vet -mod=readonly ./...`

View File

@@ -1,8 +1,9 @@
# Configuration # Configuration
ReleaseKit reads a JSON profile from `<repository>/releasekit.json` by default. ReleaseKit reads a JSON profile from `<repository-root>/releasekit.json` by default.
The repository directory defaults to the current directory. A relative profile The repository argument defaults to the current directory; the CLI resolves its
path is resolved from that repository directory; an absolute path is used as-is. containing Git checkout root. A relative profile path is resolved from that root;
an absolute path is used as-is.
The current profile fields are: The current profile fields are:

View File

@@ -24,7 +24,14 @@ review gates. Add focused completion markers as issues are resolved.
## Issue 1: Resolve the actual Git repository root before release identity (P1) ## Issue 1: Resolve the actual Git repository root before release identity (P1)
Status: open. Category: architecture gap and incorrect publication. Status: complete. Category: architecture gap and incorrect publication.
**Completion:** Source operations and CLI profile selection resolve the containing
Git checkout root before module selection. Regression tests verify that a child
directory cannot bypass a failing root suite or publish its tag, explicit nested
selection binds its own source and tag, aliases and independent worktrees retain
their identity, and dependency preparation works without Git. The focused public
API tests and candidate, delivery, CLI, and profile suites pass offline.
**Evidence:** [candidate capture](../../internal/candidate/check.go) treats **Evidence:** [candidate capture](../../internal/candidate/check.go) treats
`filepath.Abs(request.RepositoryDir)` as the repository root, and `filepath.Abs(request.RepositoryDir)` as the repository root, and

View File

@@ -8,7 +8,6 @@ import (
"fmt" "fmt"
"io" "io"
"os/exec" "os/exec"
"path/filepath"
"strings" "strings"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git" "gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
@@ -79,7 +78,7 @@ func Capture(ctx context.Context, request Request) (Candidate, error) {
return captured, errors.New("product is required") return captured, errors.New("product is required")
} }
repositoryDir, err := filepath.Abs(request.RepositoryDir) repositoryDir, err := git.RepositoryRoot(ctx, request.RepositoryDir)
if err != nil { if err != nil {
return captured, fmt.Errorf("resolve repository directory: %w", err) return captured, fmt.Errorf("resolve repository directory: %w", err)
} }

View File

@@ -67,7 +67,7 @@ func Deliver(ctx context.Context, request Request) (Result, error) {
if request.Build != nil && request.Prebuilt != nil { if request.Build != nil && request.Prebuilt != nil {
return result, errors.New("delivery cannot use both a managed build and prebuilt artifacts") return result, errors.New("delivery cannot use both a managed build and prebuilt artifacts")
} }
repositoryDir, err := filepath.Abs(request.RepositoryDir) repositoryDir, err := git.RepositoryRoot(ctx, request.RepositoryDir)
if err != nil { if err != nil {
return result, fmt.Errorf("resolve repository directory: %w", err) return result, fmt.Errorf("resolve repository directory: %w", err)
} }

View File

@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"io" "io"
"path" "path"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
@@ -30,6 +31,24 @@ type TaggedSource struct {
TagObjectType string TagObjectType string
} }
// RepositoryRoot resolves the containing checkout, including independent
// worktrees and directories reached through filesystem aliases.
func RepositoryRoot(ctx context.Context, directory string) (string, error) {
if directory == "" {
directory = "."
}
root, err := run(ctx, directory, nil, "rev-parse", "--show-toplevel")
if err != nil {
return "", fmt.Errorf("resolve Git repository root: %w", err)
}
root = strings.TrimSuffix(root, "\n")
resolved, err := filepath.EvalSymlinks(root)
if err != nil {
return "", fmt.Errorf("resolve Git repository directory: %w", err)
}
return resolved, nil
}
// CaptureHead records the checkout commit before identity derived from files // CaptureHead records the checkout commit before identity derived from files
// in that commit is inspected. // in that commit is inspected.
func CaptureHead(ctx context.Context, repositoryDir string) (string, error) { func CaptureHead(ctx context.Context, repositoryDir string) (string, error) {

84
repository_test.go Normal file
View File

@@ -0,0 +1,84 @@
package releasekit_test
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
releasekit "gitea.maximumdirect.net/eric/ReleaseKit"
)
func TestSourceOperationsResolveContainingRepository(t *testing.T) {
repo := newPublishFixture(t)
const module = "apps/widget"
addNestedReleaseModule(t, repo.work, module, "example.com/widget", "v1.2.3")
gitRun(t, repo.work, "add", ".")
gitRun(t, repo.work, "commit", "-m", "Add nested module")
gitRun(t, repo.work, "-c", "remote.origin.mirror=false", "push", "origin", "HEAD:refs/heads/main")
child := filepath.Join(repo.work, filepath.FromSlash(module))
t.Setenv("RELEASEKIT_FAIL_FIXTURE_SUITE", "1")
request := releasekit.ReleaseRequest{RepositoryDir: child, Product: "Sample App", Version: "v1.2.3"}
captured, err := releasekit.Check(context.Background(), request)
if err == nil || captured.RepositoryDir != repo.work || captured.ModuleDir != repo.work || captured.ModulePath != "example.com/releasekit-publish-fixture" || captured.Tag != request.Version {
t.Fatalf("default check = %#v, error = %v; want failing root module", captured, err)
}
published, err := releasekit.Publish(context.Background(), request)
if err == nil || published.State != releasekit.PublicationNotAttempted || published.TagObject != "" || published.Tag != request.Version {
t.Fatalf("default publication = %#v, error = %v", published, err)
}
if refs := gitText(t, repo.bare, "tag", "--list", request.Version); refs != "" {
t.Fatalf("failing root tag was published: %s", refs)
}
for _, selected := range []string{"", module} {
wantModule, wantPath, wantTag := repo.work, "example.com/releasekit-publish-fixture", request.Version
if selected != "" {
wantModule, wantPath, wantTag = child, "example.com/widget", module+"/"+request.Version
}
built, err := releasekit.Build(context.Background(), releasekit.BuildRequest{RepositoryDir: child, ModuleDir: selected, Version: request.Version})
if err != nil || built.RepositoryDir != repo.work || built.ModuleDir != wantModule || built.ModulePath != wantPath || built.Tag != wantTag {
t.Fatalf("build %q = %#v, error = %v", selected, built, err)
}
delivered, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{RepositoryDir: child, ModuleDir: selected, Product: request.Product, Version: request.Version, ExpectedTag: "mismatch"})
if err == nil || !strings.Contains(err.Error(), "expected delivery tag") || delivered.RepositoryDir != repo.work || delivered.ModuleDir != wantModule || delivered.ModulePath != wantPath || delivered.Tag != wantTag {
t.Fatalf("delivery %q = %#v, error = %v", selected, delivered, err)
}
}
request.ModuleDir = module
captured, err = releasekit.Check(context.Background(), request)
if err != nil || captured.ModuleDir != child || captured.ModulePath != "example.com/widget" || captured.Tag != module+"/"+request.Version {
t.Fatalf("nested check = %#v, error = %v", captured, err)
}
published, err = releasekit.Publish(context.Background(), request)
if err != nil || published.State != releasekit.PublicationPublished || published.Tag != captured.Tag {
t.Fatalf("nested publication = %#v, error = %v", published, err)
}
if got := gitText(t, repo.bare, "rev-parse", "refs/tags/"+captured.Tag+"^{commit}"); got != captured.Commit {
t.Fatalf("published commit = %s; want %s", got, captured.Commit)
}
}
func TestRepositoryResolutionSupportsAliasesAndIndependentWorktrees(t *testing.T) {
repo := newPublishFixture(t)
alias := filepath.Join(t.TempDir(), "alias")
if err := os.Symlink(repo.work, alias); err != nil {
t.Fatal(err)
}
worktree := filepath.Join(t.TempDir(), "worktree")
gitRun(t, repo.work, "worktree", "add", "--detach", worktree, "HEAD")
for _, tt := range []struct{ directory, wantRoot string }{{alias, repo.work}, {filepath.Join(worktree, "docs"), worktree}} {
result, err := releasekit.Build(context.Background(), releasekit.BuildRequest{RepositoryDir: tt.directory, Version: "v1.2.3"})
if err != nil || result.RepositoryDir != tt.wantRoot || result.ModuleDir != tt.wantRoot || result.CandidateCommit != repo.commit {
t.Fatalf("build through %q = %#v, error = %v", tt.directory, result, err)
}
}
}
func TestDependencyPreparationDoesNotRequireGitCheckout(t *testing.T) {
directory := t.TempDir()
writeFile(t, directory, "go.mod", "module example.com/preparation\n\ngo 1.26.0\n")
if err := releasekit.PrepareBuildDependencies(context.Background(), releasekit.DependencyPreparationRequest{RepositoryDir: directory}); err != nil {
t.Fatalf("prepare module outside Git checkout: %v", err)
}
}