Resolve source operations from the Git checkout root
This commit is contained in:
9
build.go
9
build.go
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
6
check.go
6
check.go
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
25
cmd/releasekit/repository_test.go
Normal file
25
cmd/releasekit/repository_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 ./...`
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
84
repository_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user