Derive module release identity and accept full Git tags

This commit is contained in:
2026-10-10 02:10:18 +00:00
parent dbfae9ea81
commit f99fb59cd4
9 changed files with 475 additions and 79 deletions

View File

@@ -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

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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
}

View File

@@ -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")

View File

@@ -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
}

View File

@@ -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")
}

View File

@@ -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)
}