// 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 } return FromCommittedSelection(ctx, selection, version, commit) } // FromCommittedSelection derives identity from a previously resolved module // selection and the selected module declaration at a captured commit. func FromCommittedSelection(ctx context.Context, selection Selection, version, commit string) (Identity, error) { 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") }