Derive module release identity and accept full Git tags
This commit is contained in:
157
internal/releaseidentity/identity.go
Normal file
157
internal/releaseidentity/identity.go
Normal 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")
|
||||
}
|
||||
Reference in New Issue
Block a user