Files
ReleaseKit/internal/releaseidentity/identity.go

227 lines
8.8 KiB
Go

// 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)
}
// Preserve components until symlinks are followed, so link/.. uses the
// linked directory's parent just as committed path traversal does.
moduleAbs, err := filepath.EvalSymlinks(repositoryAbs + string(filepath.Separator) + moduleDir)
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) {
resolver, err := committedPaths(repositoryDir, commit)
if err != nil {
return Identity{Version: version}, err
}
selection, err := selectCommittedDirectory(ctx, resolver, moduleDir)
if err != nil {
return Identity{Selection: selection, Version: version}, err
}
return fromCommittedSelection(ctx, selection, version, commit, resolver)
}
// ResolveCommittedSelection resolves directory selection without reading the
// module declaration or requiring the directory to be materialized.
func ResolveCommittedSelection(ctx context.Context, repositoryDir, moduleDir, commit string) (Selection, error) {
resolver, err := committedPaths(repositoryDir, commit)
if err != nil {
return Selection{}, err
}
return selectCommittedDirectory(ctx, resolver, moduleDir)
}
func committedPaths(repositoryDir, commit string) (git.PathResolver, error) {
repositoryAbs, err := filepath.Abs(repositoryDir)
if err != nil {
return git.PathResolver{}, err
}
repositoryAbs, err = filepath.EvalSymlinks(repositoryAbs)
if err != nil {
return git.PathResolver{}, err
}
return git.CommittedPaths(repositoryAbs, commit), nil
}
func selectCommittedDirectory(ctx context.Context, resolver git.PathResolver, moduleDir string) (Selection, error) {
selection := Selection{RepositoryDir: resolver.RepositoryDir}
if moduleDir == "" {
moduleDir = "."
}
if filepath.IsAbs(moduleDir) {
return selection, fmt.Errorf("module directory must be repository-relative: %q", moduleDir)
}
directory, err := resolver.ResolveDestination(ctx, "go.mod", filepath.ToSlash(moduleDir))
if err != nil {
return selection, fmt.Errorf("resolve committed module directory: %w", err)
}
if directory.Kind != git.DestinationDirectory {
return selection, fmt.Errorf("selected committed module directory %q resolves to %s at %q", moduleDir, directory.Kind, directory.Path)
}
selection.ModuleDir = filepath.Join(resolver.RepositoryDir, filepath.FromSlash(directory.Path))
selection.ModuleSubdir = directory.Path
return selection, nil
}
// 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) {
return fromCommittedSelection(ctx, selection, version, commit, git.CommittedPaths(selection.RepositoryDir, commit))
}
func fromCommittedSelection(ctx context.Context, selection Selection, version, commit string, resolver git.PathResolver) (Identity, error) {
identity := Identity{Selection: selection, Version: version}
if err := ctx.Err(); err != nil {
return identity, fmt.Errorf("resolve committed release identity: %w", err)
}
declarationPath, err := resolver.ResolveDestination(ctx, "go.mod", moduleFilePath(selection.ModuleSubdir))
if err != nil {
return identity, fmt.Errorf("resolve selected module declaration at %s: %w", commit, err)
}
if declarationPath.Kind != git.DestinationEntry {
return identity, fmt.Errorf("selected module declaration resolves to %s at %q", declarationPath.Kind, declarationPath.Path)
}
entry, _, err := resolver.Lookup(ctx, declarationPath.Path)
if err != nil {
return identity, err
}
declaration, err := git.ReadBlob(ctx, selection.RepositoryDir, entry.ObjectID)
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)
identity := Identity{Selection: selection, Version: version}
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) {
identity := Identity{Selection: selection, Version: version}
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")
}
identity.ModulePath = modulePath
if err := module.CheckImportPath(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")
}