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

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