From f99fb59cd48f4d2f4a669a31211d1d1c3f3cd33c Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 10 Oct 2026 02:10:18 +0000 Subject: [PATCH] Derive module release identity and accept full Git tags --- docs/roadmap/implementation.md | 6 + internal/git/prepare.go | 19 ++- internal/git/remote.go | 51 +++---- internal/git/remote_test.go | 26 ++++ internal/git/source.go | 16 +- internal/git/source_test.go | 31 ++++ internal/git/tag.go | 70 +++++---- internal/releaseidentity/identity.go | 157 +++++++++++++++++++ internal/releaseidentity/identity_test.go | 178 ++++++++++++++++++++++ 9 files changed, 475 insertions(+), 79 deletions(-) create mode 100644 internal/releaseidentity/identity.go create mode 100644 internal/releaseidentity/identity_test.go diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 387ec3f..c6fd3d4 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -177,6 +177,12 @@ or malformed declarations, and selection outside the repository. Test committed versus working-tree readers and full slash-containing Git refs. Update synthetic fixtures that need genuine module declarations. Run affected package tests. +**Completed:** Added shared committed and working-tree module identity derivation, +module-local note paths, and literal Git tag-reference validation. Verified root, +nested, physical-major, and ordinary `v2` layouts, committed versus working-tree +declarations, and slash-containing local and remote tag operations. `go test ./...` +passes. + ## Stage 2: Carry identity through facade types and candidate capture **Outcome:** Candidate capture supplies module-local notes and recoverable identity diff --git a/internal/git/prepare.go b/internal/git/prepare.go index 09d9825..3ac7a48 100644 --- a/internal/git/prepare.go +++ b/internal/git/prepare.go @@ -8,23 +8,26 @@ import ( // PrepareTaggedCheckout fetches one explicit tag ref, checks its peeled commit // against expectedCommit, and checks out that commit detached. -func PrepareTaggedCheckout(ctx context.Context, repositoryDir, version, remote, expectedCommit string) error { - if !validVersionRef(version) || !validObjectID(expectedCommit) { - return fmt.Errorf("invalid tagged checkout identity %q at %q", expectedCommit, version) +func PrepareTaggedCheckout(ctx context.Context, repositoryDir, tag, remote, expectedCommit string) error { + if err := checkTagRef(ctx, repositoryDir, tag); err != nil { + return err } - ref := "refs/tags/" + version + if !validObjectID(expectedCommit) { + return fmt.Errorf("invalid tagged checkout commit %q for tag %q", expectedCommit, tag) + } + ref := "refs/tags/" + tag if _, err := run(ctx, repositoryDir, nil, "fetch", "--no-tags", remote, ref+":"+ref); err != nil { - return fmt.Errorf("fetch tag %s from remote %s: %w", version, remote, err) + return fmt.Errorf("fetch tag %s from remote %s: %w", tag, remote, err) } - source, err := InspectTaggedSource(ctx, repositoryDir, version) + source, err := InspectTaggedSource(ctx, repositoryDir, tag) if err != nil { return err } if !strings.EqualFold(source.TagCommit, expectedCommit) { - return fmt.Errorf("tag %s resolves to commit %s, expected %s", version, source.TagCommit, expectedCommit) + return fmt.Errorf("tag %s resolves to commit %s, expected %s", tag, source.TagCommit, expectedCommit) } if _, err := run(ctx, repositoryDir, nil, "checkout", "--detach", source.TagCommit); err != nil { - return fmt.Errorf("check out tag %s at %s: %w", version, source.TagCommit, err) + return fmt.Errorf("check out tag %s at %s: %w", tag, source.TagCommit, err) } head, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", "HEAD^{commit}") if err != nil { diff --git a/internal/git/remote.go b/internal/git/remote.go index 1b0eb65..130e068 100644 --- a/internal/git/remote.go +++ b/internal/git/remote.go @@ -18,7 +18,7 @@ var ( ErrEndpointChanged = errors.New("Git publication endpoint changed") ErrRemoteMainMissing = errors.New("authoritative remote has no main branch") ErrRemoteMainConflict = errors.New("authoritative main does not identify the candidate commit") - ErrRemoteTagConflict = errors.New("version tag already exists on the authoritative remote") + ErrRemoteTagConflict = errors.New("tag already exists on the authoritative remote") ErrRemoteStateUnreadable = errors.New("authoritative remote state is unreadable") ) @@ -50,7 +50,7 @@ type RemoteRef struct { Peeled string } -// Snapshot is the result of querying refs/heads/main and one exact version tag. +// Snapshot is the result of querying refs/heads/main and one exact tag. type Snapshot struct { Main RemoteRef Tag RemoteRef @@ -97,14 +97,14 @@ func (endpoint Endpoint) Recheck(ctx context.Context, repositoryDir string) erro return endpoint.verifyScopedURLs(ctx, repositoryDir) } -// Query reads exact main and version-tag refs. A command failure is unreadable, +// Query reads exact main and tag refs. A command failure is unreadable, // never evidence that a ref is absent. -func (endpoint Endpoint) Query(ctx context.Context, repositoryDir, version string) (Snapshot, error) { - if !validVersionRef(version) { - return Snapshot{}, fmt.Errorf("invalid version tag name %q", version) +func (endpoint Endpoint) Query(ctx context.Context, repositoryDir, tag string) (Snapshot, error) { + if err := checkTagRef(ctx, repositoryDir, tag); err != nil { + return Snapshot{}, err } mainRef := "refs/heads/main" - tagRef := "refs/tags/" + version + tagRef := "refs/tags/" + tag peeledRef := tagRef + "^{}" output, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "ls-remote", endpoint.ScopedName, mainRef, tagRef, peeledRef) if err != nil { @@ -112,7 +112,7 @@ func (endpoint Endpoint) Query(ctx context.Context, repositoryDir, version strin return snapshot, fmt.Errorf("%w: query authoritative refs at %s: %w", ErrRemoteStateUnreadable, endpoint.PushURL, err) } main := RemoteRef{State: RefAbsent} - tag := RemoteRef{State: RefAbsent} + remoteTag := RemoteRef{State: RefAbsent} for _, line := range strings.Split(strings.TrimSuffix(output, "\n"), "\n") { if line == "" { continue @@ -125,19 +125,19 @@ func (endpoint Endpoint) Query(ctx context.Context, repositoryDir, version strin case mainRef: main = mergeRef(main, object, false) case tagRef: - tag = mergeRef(tag, object, false) + remoteTag = mergeRef(remoteTag, object, false) case peeledRef: - tag = mergeRef(tag, object, true) + remoteTag = mergeRef(remoteTag, object, true) } } - return Snapshot{Main: main, Tag: tag}, nil + return Snapshot{Main: main, Tag: remoteTag}, nil } -// RequireCandidate requires exact main equality and an absent remote version +// RequireCandidate requires exact main equality and an absent remote // tag. It deliberately does not accept a main branch that merely contains the // candidate as an ancestor. -func (endpoint Endpoint) RequireCandidate(ctx context.Context, repositoryDir, version, commit string) (Snapshot, error) { - snapshot, err := endpoint.Query(ctx, repositoryDir, version) +func (endpoint Endpoint) RequireCandidate(ctx context.Context, repositoryDir, tag, commit string) (Snapshot, error) { + snapshot, err := endpoint.Query(ctx, repositoryDir, tag) if err != nil { return snapshot, err } @@ -294,25 +294,14 @@ func mergeRef(ref RemoteRef, object string, peeled bool) RemoteRef { return ref } -func validVersionRef(version string) bool { - if len(version) < 6 || !strings.HasPrefix(version, "v") { - return false +func checkTagRef(ctx context.Context, repositoryDir, tag string) error { + if tag == "" { + return fmt.Errorf("invalid Git tag name %q", tag) } - parts := strings.Split(version[1:], ".") - if len(parts) != 3 { - return false + if _, err := run(ctx, repositoryDir, nil, "check-ref-format", "refs/tags/"+tag); err != nil { + return fmt.Errorf("invalid Git tag name %q: %w", tag, err) } - for _, part := range parts { - if part == "" || (len(part) > 1 && part[0] == '0') { - return false - } - for _, char := range part { - if char < '0' || char > '9' { - return false - } - } - } - return true + return nil } func validObjectID(object string) bool { diff --git a/internal/git/remote_test.go b/internal/git/remote_test.go index 1c545f7..64abffa 100644 --- a/internal/git/remote_test.go +++ b/internal/git/remote_test.go @@ -42,6 +42,32 @@ func TestResolveUsesPushURLAndQueriesWithoutMutation(t *testing.T) { } } +func TestRemoteTagOperationsAcceptSlashContainingRefs(t *testing.T) { + fixture := newRemoteFixture(t) + endpoint, err := Resolve(context.Background(), fixture.work, "origin") + if err != nil { + t.Fatal(err) + } + tag := "integrations/check/v1.2.3" + if _, err := endpoint.RequireCandidate(context.Background(), fixture.work, tag, fixture.commit); err != nil { + t.Fatalf("RequireCandidate(%q): %v", tag, err) + } + object, err := CreateAnnotatedTag(context.Background(), fixture.work, tag, fixture.commit, []byte("release note\n")) + if err != nil { + t.Fatal(err) + } + if err := endpoint.PushTag(context.Background(), fixture.work, tag, object); err != nil { + t.Fatal(err) + } + remote, err := endpoint.QueryTag(context.Background(), fixture.work, tag) + if err != nil { + t.Fatal(err) + } + if remote.State != RefPresent || remote.Object != object || remote.Peeled != fixture.commit { + t.Fatalf("remote tag = %#v", remote) + } +} + func TestResolveAppliesGitURLRewrites(t *testing.T) { for _, kind := range []string{"insteadOf", "pushInsteadOf"} { t.Run(kind, func(t *testing.T) { diff --git a/internal/git/source.go b/internal/git/source.go index ac698bd..2545d40 100644 --- a/internal/git/source.go +++ b/internal/git/source.go @@ -32,9 +32,9 @@ type TaggedSource struct { // InspectTaggedSource reads the local HEAD, worktree state, and selected tag. // Both lightweight historical tags and annotated tags are supported. -func InspectTaggedSource(ctx context.Context, repositoryDir, version string) (TaggedSource, error) { - if !validVersionRef(version) { - return TaggedSource{}, fmt.Errorf("invalid tag version %q", version) +func InspectTaggedSource(ctx context.Context, repositoryDir, tag string) (TaggedSource, error) { + if err := checkTagRef(ctx, repositoryDir, tag); err != nil { + return TaggedSource{}, err } var source TaggedSource head, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", "HEAD^{commit}") @@ -50,10 +50,10 @@ func InspectTaggedSource(ctx context.Context, repositoryDir, version string) (Ta return TaggedSource{}, fmt.Errorf("inspect tagged checkout state: %w", err) } source.Status = strings.TrimSpace(status) - ref := "refs/tags/" + version + ref := "refs/tags/" + tag object, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref) if err != nil { - return TaggedSource{}, fmt.Errorf("read local tag %s: %w", version, err) + return TaggedSource{}, fmt.Errorf("read local tag %s: %w", tag, err) } source.TagObject = strings.TrimSpace(object) if !validObjectID(source.TagObject) { @@ -61,7 +61,7 @@ func InspectTaggedSource(ctx context.Context, repositoryDir, version string) (Ta } peeled, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref+"^{commit}") if err != nil { - return TaggedSource{}, fmt.Errorf("resolve local tag %s to a commit: %w", version, err) + return TaggedSource{}, fmt.Errorf("resolve local tag %s to a commit: %w", tag, err) } source.TagCommit = strings.TrimSpace(peeled) if !validObjectID(source.TagCommit) { @@ -69,11 +69,11 @@ func InspectTaggedSource(ctx context.Context, repositoryDir, version string) (Ta } tagType, err := run(ctx, repositoryDir, nil, "cat-file", "-t", ref) if err != nil { - return TaggedSource{}, fmt.Errorf("inspect local tag %s: %w", version, err) + return TaggedSource{}, fmt.Errorf("inspect local tag %s: %w", tag, err) } source.TagObjectType = strings.TrimSpace(tagType) if source.TagObjectType != "tag" && source.TagObjectType != "commit" { - return TaggedSource{}, fmt.Errorf("local tag %s has unsupported object type %q", version, source.TagObjectType) + return TaggedSource{}, fmt.Errorf("local tag %s has unsupported object type %q", tag, source.TagObjectType) } return source, nil } diff --git a/internal/git/source_test.go b/internal/git/source_test.go index 9781ff6..0099c0d 100644 --- a/internal/git/source_test.go +++ b/internal/git/source_test.go @@ -32,6 +32,37 @@ func TestInspectTaggedSourceSupportsLightweightAndAnnotatedTags(t *testing.T) { } } +func TestTagOperationsAcceptFullSlashContainingRefs(t *testing.T) { + fixture := newRemoteFixture(t) + tag := "integrations/check/v2.3.4" + if err := checkTagRef(context.Background(), fixture.work, tag); err != nil { + t.Fatalf("checkTagRef(%q): %v", tag, err) + } + object, err := CreateAnnotatedTag(context.Background(), fixture.work, tag, fixture.commit, []byte("release note\n")) + if err != nil { + t.Fatal(err) + } + if err := InspectLocalTag(context.Background(), fixture.work, tag, object); err != nil { + t.Fatal(err) + } + source, err := InspectTaggedSource(context.Background(), fixture.work, tag) + if err != nil { + t.Fatal(err) + } + if source.TagCommit != fixture.commit || source.TagObject != object || source.TagObjectType != "tag" { + t.Fatalf("source for %q = %#v", tag, source) + } +} + +func TestTagReferenceValidationUsesGitRules(t *testing.T) { + fixture := newRemoteFixture(t) + for _, tag := range []string{"", "v1..2.3", "v1.2.3.lock", "v1.2.3/", "v1.2.3\\bad"} { + if err := checkTagRef(context.Background(), fixture.work, tag); err == nil { + t.Errorf("checkTagRef(%q) succeeded", tag) + } + } +} + func TestCandidateObjectReadsStayLocalInPartialClones(t *testing.T) { fixture := newRemoteFixture(t) gitRun(t, fixture.work, "config", "uploadpack.allowFilter", "true") diff --git a/internal/git/tag.go b/internal/git/tag.go index fd3ae7b..111ef2b 100644 --- a/internal/git/tag.go +++ b/internal/git/tag.go @@ -10,9 +10,9 @@ import ( // CreateAnnotatedTag records the captured note as an unsigned annotated tag // on the captured commit and returns the tag object's ID. -func CreateAnnotatedTag(ctx context.Context, repositoryDir, version, commit string, note []byte) (string, error) { - if !validVersionRef(version) { - return "", fmt.Errorf("invalid version tag name %q", version) +func CreateAnnotatedTag(ctx context.Context, repositoryDir, tag, commit string, note []byte) (string, error) { + if err := checkTagRef(ctx, repositoryDir, tag); err != nil { + return "", err } if !validObjectID(commit) { return "", fmt.Errorf("invalid candidate commit object ID %q", commit) @@ -30,64 +30,67 @@ func CreateAnnotatedTag(ctx context.Context, repositoryDir, version, commit stri if err := message.Close(); err != nil { return "", fmt.Errorf("close temporary release note: %w", err) } - if _, err := run(ctx, repositoryDir, []string{"tag.gpgSign=false"}, "tag", "--annotate", "--cleanup=verbatim", "--file", messagePath, version, commit); err != nil { - return "", fmt.Errorf("create annotated tag %s: %w", version, err) + if _, err := run(ctx, repositoryDir, []string{"tag.gpgSign=false"}, "tag", "--annotate", "--cleanup=verbatim", "--file", messagePath, tag, commit); err != nil { + return "", fmt.Errorf("create annotated tag %s: %w", tag, err) } - ref := "refs/tags/" + version + ref := "refs/tags/" + tag tagType, err := run(ctx, repositoryDir, nil, "cat-file", "-t", ref) if err != nil { - return "", fmt.Errorf("inspect created tag %s: %w", version, err) + return "", fmt.Errorf("inspect created tag %s: %w", tag, err) } if strings.TrimSpace(tagType) != "tag" { - return "", fmt.Errorf("created tag %s has object type %q, want tag", version, strings.TrimSpace(tagType)) + return "", fmt.Errorf("created tag %s has object type %q, want tag", tag, strings.TrimSpace(tagType)) } tagObject, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref) if err != nil { - return "", fmt.Errorf("read created tag object %s: %w", version, err) + return "", fmt.Errorf("read created tag object %s: %w", tag, err) } tagObject = strings.TrimSpace(tagObject) if !validObjectID(tagObject) { - return "", fmt.Errorf("created tag %s has invalid object ID %q", version, tagObject) + return "", fmt.Errorf("created tag %s has invalid object ID %q", tag, tagObject) } peeled, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref+"^{}") if err != nil { - return "", fmt.Errorf("read peeled commit for tag %s: %w", version, err) + return "", fmt.Errorf("read peeled commit for tag %s: %w", tag, err) } if strings.TrimSpace(peeled) != commit { - return "", fmt.Errorf("created tag %s peels to %s, want candidate %s", version, strings.TrimSpace(peeled), commit) + return "", fmt.Errorf("created tag %s peels to %s, want candidate %s", tag, strings.TrimSpace(peeled), commit) } tagContents, err := run(ctx, repositoryDir, nil, "cat-file", "tag", ref) if err != nil { - return "", fmt.Errorf("read annotated tag %s: %w", version, err) + return "", fmt.Errorf("read annotated tag %s: %w", tag, err) } separator := bytes.Index([]byte(tagContents), []byte("\n\n")) if separator < 0 || !bytes.Equal([]byte(tagContents[separator+2:]), note) { - return "", fmt.Errorf("annotated tag %s message does not match the captured release note", version) + return "", fmt.Errorf("annotated tag %s message does not match the captured release note", tag) } return tagObject, nil } // InspectLocalTag confirms the selected tag still points to the exact // annotated tag object captured before publication. -func InspectLocalTag(ctx context.Context, repositoryDir, version, expectedObject string) error { - if !validVersionRef(version) || !validObjectID(expectedObject) { - return fmt.Errorf("invalid expected tag identity %q at %q", expectedObject, version) +func InspectLocalTag(ctx context.Context, repositoryDir, tag, expectedObject string) error { + if err := checkTagRef(ctx, repositoryDir, tag); err != nil { + return err } - ref := "refs/tags/" + version + if !validObjectID(expectedObject) { + return fmt.Errorf("invalid expected tag object identity %q at %q", expectedObject, tag) + } + ref := "refs/tags/" + tag tagType, err := run(ctx, repositoryDir, nil, "cat-file", "-t", ref) if err != nil { - return fmt.Errorf("inspect local tag %s: %w", version, err) + return fmt.Errorf("inspect local tag %s: %w", tag, err) } if strings.TrimSpace(tagType) != "tag" { - return fmt.Errorf("local tag %s has type %q, want annotated tag", version, strings.TrimSpace(tagType)) + return fmt.Errorf("local tag %s has type %q, want annotated tag", tag, strings.TrimSpace(tagType)) } object, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref) if err != nil { - return fmt.Errorf("read local tag %s: %w", version, err) + return fmt.Errorf("read local tag %s: %w", tag, err) } if strings.TrimSpace(object) != expectedObject { - return fmt.Errorf("local tag %s identifies %s, want %s", version, strings.TrimSpace(object), expectedObject) + return fmt.Errorf("local tag %s identifies %s, want %s", tag, strings.TrimSpace(object), expectedObject) } return nil } @@ -95,18 +98,18 @@ func InspectLocalTag(ctx context.Context, repositoryDir, version, expectedObject // QueryTag verifies the captured endpoint still resolves to itself, then reads // one exact authoritative version tag and its peeled commit. Failed // verification or reads are unreadable and never interpreted as absence. -func (endpoint Endpoint) QueryTag(ctx context.Context, repositoryDir, version string) (RemoteRef, error) { - if !validVersionRef(version) { - return RemoteRef{}, fmt.Errorf("invalid version tag name %q", version) +func (endpoint Endpoint) QueryTag(ctx context.Context, repositoryDir, tag string) (RemoteRef, error) { + if err := checkTagRef(ctx, repositoryDir, tag); err != nil { + return RemoteRef{}, err } if err := endpoint.verifyScopedURLs(ctx, repositoryDir); err != nil { return RemoteRef{State: RefUnreadable}, fmt.Errorf("%w: verify authoritative endpoint before tag query: %w", ErrRemoteStateUnreadable, err) } - ref := "refs/tags/" + version + ref := "refs/tags/" + tag peeledRef := ref + "^{}" output, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "ls-remote", endpoint.ScopedName, ref, peeledRef) if err != nil { - return RemoteRef{State: RefUnreadable}, fmt.Errorf("%w: query authoritative tag %s at %s: %w", ErrRemoteStateUnreadable, version, endpoint.PushURL, err) + return RemoteRef{State: RefUnreadable}, fmt.Errorf("%w: query authoritative tag %s at %s: %w", ErrRemoteStateUnreadable, tag, endpoint.PushURL, err) } remoteTag := RemoteRef{State: RefAbsent} for _, line := range outputLines(output) { @@ -125,13 +128,16 @@ func (endpoint Endpoint) QueryTag(ctx context.Context, repositoryDir, version st } // PushTag publishes only the captured tag object to its exact tag ref. -func (endpoint Endpoint) PushTag(ctx context.Context, repositoryDir, version, tagObject string) error { - if !validVersionRef(version) || !validObjectID(tagObject) { - return fmt.Errorf("invalid tag publication identity %q at %q", tagObject, version) +func (endpoint Endpoint) PushTag(ctx context.Context, repositoryDir, tag, tagObject string) error { + if err := checkTagRef(ctx, repositoryDir, tag); err != nil { + return err } - refspec := tagObject + ":refs/tags/" + version + if !validObjectID(tagObject) { + return fmt.Errorf("invalid tag publication object %q at %q", tagObject, tag) + } + refspec := tagObject + ":refs/tags/" + tag if _, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "push", "--no-follow-tags", "--porcelain", endpoint.ScopedName, refspec); err != nil { - return fmt.Errorf("push annotated tag %s to %s: %w", version, endpoint.PushURL, err) + return fmt.Errorf("push annotated tag %s to %s: %w", tag, endpoint.PushURL, err) } return nil } diff --git a/internal/releaseidentity/identity.go b/internal/releaseidentity/identity.go new file mode 100644 index 0000000..3b3701e --- /dev/null +++ b/internal/releaseidentity/identity.go @@ -0,0 +1,157 @@ +// Package releaseidentity resolves and derives the identity of one Go module +// release within a repository. +package releaseidentity + +import ( + "context" + "fmt" + "os" + "path" + "path/filepath" + "strings" + + "gitea.maximumdirect.net/eric/ReleaseKit/internal/git" + "gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract" + "golang.org/x/mod/modfile" + "golang.org/x/mod/module" +) + +// Selection identifies the normalized repository and selected module +// directories. ModuleDir is an absolute path; ModuleSubdir is its canonical +// repository-relative slash path, with "." representing the root. +type Selection struct { + RepositoryDir string + ModuleDir string + ModuleSubdir string +} + +// Identity is the release identity derived from one selected module. +type Identity struct { + Selection + ModulePath string + Version string + Tag string + NotePath string +} + +// ResolveSelection normalizes a repository-relative module selection and +// confirms its final filesystem location remains inside the repository. +func ResolveSelection(repositoryDir, moduleDir string) (Selection, error) { + if moduleDir == "" { + moduleDir = "." + } + if filepath.IsAbs(moduleDir) { + return Selection{}, fmt.Errorf("module directory must be repository-relative: %q", moduleDir) + } + repositoryAbs, err := filepath.Abs(repositoryDir) + if err != nil { + return Selection{}, fmt.Errorf("resolve repository directory: %w", err) + } + repositoryAbs, err = filepath.EvalSymlinks(repositoryAbs) + if err != nil { + return Selection{}, fmt.Errorf("resolve repository directory %q: %w", repositoryDir, err) + } + moduleAbs, err := filepath.Abs(filepath.Join(repositoryAbs, moduleDir)) + if err != nil { + return Selection{}, fmt.Errorf("resolve module directory %q: %w", moduleDir, err) + } + moduleAbs, err = filepath.EvalSymlinks(moduleAbs) + if err != nil { + return Selection{}, fmt.Errorf("resolve module directory %q: %w", moduleDir, err) + } + relative, err := filepath.Rel(repositoryAbs, moduleAbs) + if err != nil { + return Selection{}, fmt.Errorf("make module directory relative to repository: %w", err) + } + if relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.IsAbs(relative) { + return Selection{}, fmt.Errorf("module directory %q resolves outside repository %q", moduleDir, repositoryDir) + } + if relative == "." { + return Selection{RepositoryDir: repositoryAbs, ModuleDir: moduleAbs, ModuleSubdir: "."}, nil + } + return Selection{RepositoryDir: repositoryAbs, ModuleDir: moduleAbs, ModuleSubdir: filepath.ToSlash(relative)}, nil +} + +// FromCommitted derives identity from the selected module declaration at a +// captured commit. The selected module must contain its own go.mod. +func FromCommitted(ctx context.Context, repositoryDir, moduleDir, version, commit string) (Identity, error) { + selection, err := ResolveSelection(repositoryDir, moduleDir) + if err != nil { + return Identity{}, err + } + if err := ctx.Err(); err != nil { + return Identity{}, fmt.Errorf("resolve committed release identity: %w", err) + } + declaration, err := git.ReadCommitFile(ctx, selection.RepositoryDir, commit, moduleFilePath(selection.ModuleSubdir)) + if err != nil { + return Identity{}, fmt.Errorf("read selected module declaration at %s: %w", commit, err) + } + return Derive(selection, declaration, version) +} + +// FromWorkingTree derives identity from the selected module declaration in the +// working tree. The selected module must contain its own go.mod. +func FromWorkingTree(ctx context.Context, repositoryDir, moduleDir, version string) (Identity, error) { + selection, err := ResolveSelection(repositoryDir, moduleDir) + if err != nil { + return Identity{}, err + } + if err := ctx.Err(); err != nil { + return Identity{}, fmt.Errorf("resolve working-tree release identity: %w", err) + } + declarationPath := filepath.Join(selection.ModuleDir, "go.mod") + declaration, err := os.ReadFile(declarationPath) + if err != nil { + return Identity{}, fmt.Errorf("read selected module declaration %q: %w", declarationPath, err) + } + return Derive(selection, declaration, version) +} + +// Derive applies Go module path-major rules and derives the standard full tag +// and module-local release-note path from a declaration and selection. +func Derive(selection Selection, declaration []byte, version string) (Identity, error) { + if selection.RepositoryDir == "" || selection.ModuleDir == "" || selection.ModuleSubdir == "" { + return Identity{}, fmt.Errorf("incomplete module selection") + } + modulePath := modfile.ModulePath(declaration) + if modulePath == "" { + return Identity{}, fmt.Errorf("selected module declaration has no valid module directive") + } + if err := module.CheckPath(modulePath); err != nil { + return Identity{}, fmt.Errorf("invalid module path %q: %w", modulePath, err) + } + _, pathMajor, ok := module.SplitPathVersion(modulePath) + if !ok { + return Identity{}, fmt.Errorf("module path %q has an invalid major-version suffix", modulePath) + } + if !versioncontract.ValidStableVersion(version) { + return Identity{}, fmt.Errorf("invalid stable release version %q", version) + } + if err := module.CheckPathMajor(version, pathMajor); err != nil { + return Identity{}, fmt.Errorf("version %q is incompatible with module path %q: %w", version, modulePath, err) + } + + tagPrefix := selection.ModuleSubdir + if strings.HasPrefix(pathMajor, "/") && tagPrefix != "." && path.Base(tagPrefix) == strings.TrimPrefix(pathMajor, "/") { + tagPrefix = path.Dir(tagPrefix) + } + tag := version + if tagPrefix != "." { + tag = path.Join(tagPrefix, version) + } + notePath := path.Join(selection.ModuleSubdir, "docs", "releases", version+".md") + return Identity{ + Selection: selection, + ModulePath: modulePath, + Version: version, + Tag: tag, + NotePath: notePath, + }, nil +} + +func moduleFilePath(moduleSubdir string) string { + if moduleSubdir == "." { + return "go.mod" + } + return path.Join(moduleSubdir, "go.mod") +} diff --git a/internal/releaseidentity/identity_test.go b/internal/releaseidentity/identity_test.go new file mode 100644 index 0000000..1855e49 --- /dev/null +++ b/internal/releaseidentity/identity_test.go @@ -0,0 +1,178 @@ +package releaseidentity + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestDeriveUsesModuleDeclarationAndPhysicalLayout(t *testing.T) { + tests := []struct { + name string + subdir string + modulePath string + version string + wantTag string + wantNote string + }{ + {name: "root v1", subdir: ".", modulePath: "example.com/root", version: "v1.2.3", wantTag: "v1.2.3", wantNote: "docs/releases/v1.2.3.md"}, + {name: "nested", subdir: "integrations/check", modulePath: "example.com/check", version: "v1.2.3", wantTag: "integrations/check/v1.2.3", wantNote: "integrations/check/docs/releases/v1.2.3.md"}, + {name: "root major path", subdir: ".", modulePath: "example.com/root/v2", version: "v2.3.4", wantTag: "v2.3.4", wantNote: "docs/releases/v2.3.4.md"}, + {name: "root physical major directory", subdir: "v2", modulePath: "example.com/root/v2", version: "v2.3.4", wantTag: "v2.3.4", wantNote: "v2/docs/releases/v2.3.4.md"}, + {name: "physical major directory", subdir: "integrations/check/v2", modulePath: "example.com/check/v2", version: "v2.3.4", wantTag: "integrations/check/v2.3.4", wantNote: "integrations/check/v2/docs/releases/v2.3.4.md"}, + {name: "logical major without physical directory", subdir: "integrations/check", modulePath: "example.com/check/v2", version: "v2.3.4", wantTag: "integrations/check/v2.3.4", wantNote: "integrations/check/docs/releases/v2.3.4.md"}, + {name: "ordinary directory named v2", subdir: "integrations/v2", modulePath: "example.com/check", version: "v1.2.3", wantTag: "integrations/v2/v1.2.3", wantNote: "integrations/v2/docs/releases/v1.2.3.md"}, + {name: "gopkg special major", subdir: "integrations/example.v2", modulePath: "gopkg.in/example.v2", version: "v2.3.4", wantTag: "integrations/example.v2/v2.3.4", wantNote: "integrations/example.v2/docs/releases/v2.3.4.md"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + selection := Selection{RepositoryDir: "/repo", ModuleDir: filepath.Join("/repo", filepath.FromSlash(tt.subdir)), ModuleSubdir: tt.subdir} + if tt.subdir == "." { + selection.ModuleDir = "/repo" + } + got, err := Derive(selection, []byte("module "+tt.modulePath+"\n"), tt.version) + if err != nil { + t.Fatal(err) + } + if got.Tag != tt.wantTag || got.NotePath != tt.wantNote { + t.Fatalf("identity tag/note = %q, %q; want %q, %q", got.Tag, got.NotePath, tt.wantTag, tt.wantNote) + } + }) + } +} + +func TestDeriveRejectsInvalidModuleAndVersionIdentity(t *testing.T) { + selection := Selection{RepositoryDir: "/repo", ModuleDir: "/repo", ModuleSubdir: "."} + for _, tt := range []struct { + name, declaration, version string + }{ + {name: "missing module directive", declaration: "go 1.26\n", version: "v1.2.3"}, + {name: "malformed module path", declaration: "module example.com/../bad\n", version: "v1.2.3"}, + {name: "incompatible major", declaration: "module example.com/service/v2\n", version: "v1.2.3"}, + {name: "unstable version", declaration: "module example.com/service\n", version: "v1.2.3-rc.1"}, + } { + t.Run(tt.name, func(t *testing.T) { + if _, err := Derive(selection, []byte(tt.declaration), tt.version); err == nil { + t.Fatal("Derive() succeeded for invalid identity") + } + }) + } +} + +func TestResolveSelectionNormalizesAndRejectsEscape(t *testing.T) { + repository := t.TempDir() + moduleDir := filepath.Join(repository, "integrations", "one") + if err := os.MkdirAll(moduleDir, 0o755); err != nil { + t.Fatal(err) + } + selection, err := ResolveSelection(repository, "integrations/one/../one") + if err != nil { + t.Fatal(err) + } + if selection.ModuleSubdir != "integrations/one" || selection.ModuleDir != moduleDir { + t.Fatalf("normalized selection = %#v", selection) + } + root, err := ResolveSelection(repository, "") + if err != nil || root.ModuleSubdir != "." || root.ModuleDir != repository { + t.Fatalf("default root selection = %#v, error = %v", root, err) + } + if _, err := ResolveSelection(repository, "../outside"); err == nil { + t.Fatal("ResolveSelection() accepted a path outside the repository") + } + if _, err := ResolveSelection(repository, moduleDir); err == nil { + t.Fatal("ResolveSelection() accepted an absolute module path") + } + outside := t.TempDir() + link := filepath.Join(repository, "linked") + if err := os.Symlink(outside, link); err != nil { + t.Fatal(err) + } + if _, err := ResolveSelection(repository, "linked"); err == nil { + t.Fatal("ResolveSelection() accepted a symlink that resolves outside the repository") + } +} + +func TestCommittedAndWorkingTreeDeclarationsUseTheirOwnSources(t *testing.T) { + repository := t.TempDir() + gitIdentityRun(t, repository, "init", "-q") + gitIdentityRun(t, repository, "config", "user.name", "ReleaseKit test") + gitIdentityRun(t, repository, "config", "user.email", "releasekit@example.invalid") + moduleDir := filepath.Join(repository, "nested", "mod") + if err := os.MkdirAll(moduleDir, 0o755); err != nil { + t.Fatal(err) + } + declaration := filepath.Join(moduleDir, "go.mod") + if err := os.WriteFile(declaration, []byte("module example.com/committed\n"), 0o644); err != nil { + t.Fatal(err) + } + gitIdentityRun(t, repository, "add", ".") + gitIdentityRun(t, repository, "commit", "-qm", "add module") + commit := strings.TrimSpace(gitIdentityOutput(t, repository, "rev-parse", "HEAD")) + if err := os.WriteFile(declaration, []byte("module example.com/working\n"), 0o644); err != nil { + t.Fatal(err) + } + committed, err := FromCommitted(context.Background(), repository, "nested/mod", "v1.2.3", commit) + if err != nil { + t.Fatal(err) + } + working, err := FromWorkingTree(context.Background(), repository, "nested/mod", "v1.2.3") + if err != nil { + t.Fatal(err) + } + if committed.ModulePath != "example.com/committed" || committed.Tag != "nested/mod/v1.2.3" { + t.Fatalf("committed identity = %#v", committed) + } + if working.ModulePath != "example.com/working" || working.Tag != "nested/mod/v1.2.3" { + t.Fatalf("working identity = %#v", working) + } +} + +func TestSelectedNestedModuleDoesNotRequireRootDeclaration(t *testing.T) { + repository := t.TempDir() + moduleDir := filepath.Join(repository, "nested") + if err := os.MkdirAll(moduleDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(moduleDir, "go.mod"), []byte("module example.com/nested\n"), 0o644); err != nil { + t.Fatal(err) + } + identity, err := FromWorkingTree(context.Background(), repository, "nested", "v1.2.3") + if err != nil || identity.ModulePath != "example.com/nested" { + t.Fatalf("nested identity = %#v, error = %v", identity, err) + } + if _, err := FromWorkingTree(context.Background(), repository, ".", "v1.2.3"); err == nil { + t.Fatal("root selection silently fell back to a nested module declaration") + } +} + +func TestReadersPreserveCancellation(t *testing.T) { + repository := t.TempDir() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := FromWorkingTree(ctx, repository, ".", "v1.2.3"); err == nil || !strings.Contains(err.Error(), context.Canceled.Error()) { + t.Fatalf("working-tree error = %v, want cancellation", err) + } +} + +func gitIdentityRun(t *testing.T, dir string, args ...string) { + t.Helper() + command := exec.Command("git", args...) + command.Dir = dir + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, output) + } +} + +func gitIdentityOutput(t *testing.T, dir string, args ...string) string { + t.Helper() + command := exec.Command("git", args...) + command.Dir = dir + output, err := command.Output() + if err != nil { + t.Fatalf("git %v: %v", args, err) + } + return string(output) +}