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