Files
gh-action-readme/internal/dependencies/analyzer.go
Ismo Vuorinen 3fbb608f9f feat: update go version, renovate config, tooling, fixes (#28)
* feat(deps): update go version, renovate config, tooling

* chore(deps): update google/go-github to v74

* feat(deps): migrate from yaml.v3 to goccy/go-yaml

* chore(deps): update goccy/go-yaml to v1.18.0 and address security concerns

* feat: improve issue templates and project configuration

- Update GitHub issue templates with CLI-specific fields for better bug reports
- Add specialized templates for documentation, theme, and performance issues
- Update pre-commit config to include comprehensive documentation linting
- Remove outdated Snyk configuration and security references
- Update Go version from 1.23+ to 1.24+ across project
- Streamline README.md organization and improve clarity
- Update CHANGELOG.md and CLAUDE.md formatting
- Create comprehensive CONTRIBUTING.md with development guidelines
- Remove TODO.md (replaced by docs/roadmap.md)
- Move SECURITY.md to docs/security.md

* docs: fix markdown linting violations across documentation

* fix: resolve template placeholder issues and improve uses statement generation

* fix: remove trailing whitespace from GitHub issue template
2025-08-07 05:22:44 +03:00

723 lines
22 KiB
Go

// Package dependencies provides GitHub Actions dependency analysis functionality.
package dependencies
import (
"context"
"errors"
"fmt"
"os"
"regexp"
"strings"
"time"
"github.com/google/go-github/v74/github"
"github.com/ivuorinen/gh-action-readme/internal/git"
)
// VersionType represents the type of version specification used.
type VersionType string
const (
// SemanticVersion represents semantic versioning format (v1.2.3).
SemanticVersion VersionType = "semantic"
// CommitSHA represents a git commit SHA.
CommitSHA VersionType = "commit"
// BranchName represents a git branch reference.
BranchName VersionType = "branch"
// LocalPath represents a local file path reference.
LocalPath VersionType = "local"
// Common string constants.
compositeUsing = "composite"
updateTypeNone = "none"
updateTypeMajor = "major"
updateTypePatch = "patch"
updateTypeMinor = "minor"
defaultBranch = "main"
// Timeout constants.
apiCallTimeout = 10 * time.Second
cacheDefaultTTL = 1 * time.Hour
// File permission constants.
backupFilePerms = 0600
updatedFilePerms = 0600
// GitHub URL patterns.
githubBaseURL = "https://github.com"
marketplaceBaseURL = "https://github.com/marketplace/actions/"
// Version parsing constants.
fullSHALength = 40
minSHALength = 7
versionPartsCount = 3
// File path patterns.
dockerPrefix = "docker://"
localPathPrefix = "./"
localPathUpPrefix = "../"
// File extensions.
backupExtension = ".backup"
// Cache key prefixes.
cacheKeyLatest = "latest:"
cacheKeyRepo = "repo:"
// YAML structure constants.
usesFieldPrefix = "uses: "
// Special line estimation for script URLs.
scriptLineEstimate = 10
)
// Dependency represents a GitHub Action dependency with detailed information.
type Dependency struct {
Name string `json:"name"`
Uses string `json:"uses"` // Full uses statement
Version string `json:"version"` // Readable version
VersionType VersionType `json:"version_type"` // semantic, commit, branch
IsPinned bool `json:"is_pinned"` // Whether locked to specific version
Description string `json:"description"` // From GitHub API
Author string `json:"author"` // Action owner
MarketplaceURL string `json:"marketplace_url,omitempty"`
SourceURL string `json:"source_url"`
WithParams map[string]string `json:"with_params,omitempty"`
IsLocalAction bool `json:"is_local_action"` // Same repo dependency
IsShellScript bool `json:"is_shell_script"`
ScriptURL string `json:"script_url,omitempty"` // Link to script line
}
// OutdatedDependency represents a dependency that has newer versions available.
type OutdatedDependency struct {
Current Dependency `json:"current"`
LatestVersion string `json:"latest_version"`
LatestSHA string `json:"latest_sha"`
UpdateType string `json:"update_type"` // "major", "minor", "patch"
Changelog string `json:"changelog,omitempty"`
IsSecurityUpdate bool `json:"is_security_update"`
}
// PinnedUpdate represents an update that pins to a specific commit SHA.
type PinnedUpdate struct {
FilePath string `json:"file_path"`
OldUses string `json:"old_uses"` // "actions/checkout@v4"
NewUses string `json:"new_uses"` // "actions/checkout@8f4b7f84...# v4.1.1"
CommitSHA string `json:"commit_sha"`
Version string `json:"version"`
UpdateType string `json:"update_type"` // "major", "minor", "patch"
LineNumber int `json:"line_number"`
}
// Analyzer analyzes GitHub Action dependencies.
type Analyzer struct {
GitHubClient *github.Client
Cache DependencyCache // High-performance cache interface
RepoInfo git.RepoInfo
}
// DependencyCache defines the caching interface for dependency data.
type DependencyCache interface {
Get(key string) (any, bool)
Set(key string, value any) error
SetWithTTL(key string, value any, ttl time.Duration) error
}
// Note: Using git.RepoInfo instead of local GitInfo to avoid duplication
// NewAnalyzer creates a new dependency analyzer.
func NewAnalyzer(client *github.Client, repoInfo git.RepoInfo, cache DependencyCache) *Analyzer {
return &Analyzer{
GitHubClient: client,
Cache: cache,
RepoInfo: repoInfo,
}
}
// AnalyzeActionFile analyzes dependencies from an action.yml file.
func (a *Analyzer) AnalyzeActionFile(actionPath string) ([]Dependency, error) {
return a.AnalyzeActionFileWithProgress(actionPath, nil)
}
// AnalyzeActionFileWithProgress analyzes dependencies with optional progress tracking.
func (a *Analyzer) AnalyzeActionFileWithProgress(
actionPath string,
progressCallback func(current, total int, message string),
) ([]Dependency, error) {
if progressCallback != nil {
progressCallback(0, 1, "Parsing "+actionPath)
}
// Read and parse the action.yml file
action, err := a.parseCompositeAction(actionPath)
if err != nil {
return nil, fmt.Errorf("failed to parse action file: %w", err)
}
// Validate and check if it's a composite action
deps, isComposite, err := a.validateAndCheckComposite(action, progressCallback)
if err != nil {
return nil, err
}
if !isComposite {
return deps, nil
}
// Process composite action steps
return a.processCompositeSteps(action.Runs.Steps, progressCallback)
}
// CheckOutdated analyzes dependencies and finds those with newer versions available.
func (a *Analyzer) CheckOutdated(deps []Dependency) ([]OutdatedDependency, error) {
var outdated []OutdatedDependency
for _, dep := range deps {
if dep.IsShellScript || dep.IsLocalAction {
continue // Skip shell scripts and local actions
}
owner, repo, currentVersion, _ := a.parseUsesStatement(dep.Uses)
if owner == "" || repo == "" {
continue
}
latestVersion, latestSHA, err := a.getLatestVersion(owner, repo)
if err != nil {
continue // Skip on error, don't fail the whole operation
}
updateType := a.compareVersions(currentVersion, latestVersion)
if updateType != updateTypeNone {
outdated = append(outdated, OutdatedDependency{
Current: dep,
LatestVersion: latestVersion,
LatestSHA: latestSHA,
UpdateType: updateType,
IsSecurityUpdate: updateType == updateTypeMajor, // Assume major updates might be security
})
}
}
return outdated, nil
}
// GeneratePinnedUpdate creates a pinned update for a dependency.
func (a *Analyzer) GeneratePinnedUpdate(
actionPath string,
dep Dependency,
latestVersion, latestSHA string,
) (*PinnedUpdate, error) {
if latestSHA == "" {
return nil, fmt.Errorf("no commit SHA available for %s", dep.Uses)
}
// Create the new pinned uses string: "owner/repo@sha # version"
owner, repo, currentVersion, _ := a.parseUsesStatement(dep.Uses)
newUses := fmt.Sprintf("%s/%s@%s # %s", owner, repo, latestSHA, latestVersion)
updateType := a.compareVersions(currentVersion, latestVersion)
return &PinnedUpdate{
FilePath: actionPath,
OldUses: dep.Uses,
NewUses: newUses,
CommitSHA: latestSHA,
Version: latestVersion,
UpdateType: updateType,
LineNumber: 0, // Will be determined during file update
}, nil
}
// ApplyPinnedUpdates applies pinned updates to action files.
func (a *Analyzer) ApplyPinnedUpdates(updates []PinnedUpdate) error {
// Group updates by file path
updatesByFile := make(map[string][]PinnedUpdate)
for _, update := range updates {
updatesByFile[update.FilePath] = append(updatesByFile[update.FilePath], update)
}
// Apply updates to each file
for filePath, fileUpdates := range updatesByFile {
if err := a.updateActionFile(filePath, fileUpdates); err != nil {
return fmt.Errorf("failed to update %s: %w", filePath, err)
}
}
return nil
}
// validateAndCheckComposite validates action type and checks if it's composite.
func (a *Analyzer) validateAndCheckComposite(
action *ActionWithComposite,
progressCallback func(current, total int, message string),
) ([]Dependency, bool, error) {
if action.Runs.Using != compositeUsing {
if err := a.validateActionType(action.Runs.Using); err != nil {
return nil, false, err
}
if progressCallback != nil {
progressCallback(1, 1, "No dependencies (non-composite action)")
}
return []Dependency{}, false, nil
}
return nil, true, nil
}
// validateActionType checks if the action type is valid.
func (a *Analyzer) validateActionType(usingType string) error {
validTypes := []string{"node20", "node16", "node12", "docker", "composite"}
for _, validType := range validTypes {
if usingType == validType {
return nil
}
}
return fmt.Errorf("invalid action runtime: %s", usingType)
}
// processCompositeSteps processes steps in a composite action.
func (a *Analyzer) processCompositeSteps(
steps []CompositeStep,
progressCallback func(current, total int, message string),
) ([]Dependency, error) {
var dependencies []Dependency
totalSteps := len(steps)
for i, step := range steps {
if progressCallback != nil {
progressCallback(i, totalSteps, fmt.Sprintf("Analyzing step %d/%d", i+1, totalSteps))
}
dep := a.processStep(step, i+1)
if dep != nil {
dependencies = append(dependencies, *dep)
}
}
if progressCallback != nil {
progressCallback(totalSteps, totalSteps, fmt.Sprintf("Found %d dependencies", len(dependencies)))
}
return dependencies, nil
}
// processStep processes a single step and returns dependency if found.
func (a *Analyzer) processStep(step CompositeStep, stepNumber int) *Dependency {
if step.Uses != "" {
// This is an action dependency
dep, err := a.analyzeActionDependency(step, stepNumber)
if err != nil {
// Log error but continue processing
return nil
}
return dep
} else if step.Run != "" {
// This is a shell script step
return a.analyzeShellScript(step, stepNumber)
}
return nil
}
// analyzeActionDependency analyzes a single action dependency.
func (a *Analyzer) analyzeActionDependency(step CompositeStep, _ int) (*Dependency, error) {
// Parse the uses statement
owner, repo, version, versionType := a.parseUsesStatement(step.Uses)
if owner == "" || repo == "" {
return nil, fmt.Errorf("invalid uses statement: %s", step.Uses)
}
// Check if it's a local action (same repository)
isLocal := (owner == a.RepoInfo.Organization && repo == a.RepoInfo.Repository)
// Build dependency
dep := &Dependency{
Name: fmt.Sprintf("%s/%s", owner, repo),
Uses: step.Uses,
Version: version,
VersionType: versionType,
IsPinned: versionType == CommitSHA || (versionType == SemanticVersion && a.isVersionPinned(version)),
Author: owner,
SourceURL: fmt.Sprintf("%s/%s/%s", githubBaseURL, owner, repo),
IsLocalAction: isLocal,
IsShellScript: false,
WithParams: a.convertWithParams(step.With),
}
// Add marketplace URL for public actions
if !isLocal {
dep.MarketplaceURL = marketplaceBaseURL + repo
}
// Fetch additional metadata from GitHub API if available
if a.GitHubClient != nil && !isLocal {
_ = a.enrichWithGitHubData(dep, owner, repo) // Ignore error - we have basic info
}
return dep, nil
}
// analyzeShellScript analyzes a shell script step.
func (a *Analyzer) analyzeShellScript(step CompositeStep, stepNumber int) *Dependency {
// Create a shell script dependency
name := step.Name
if name == "" {
name = fmt.Sprintf("Shell Script #%d", stepNumber)
}
// Try to create a link to the script in the repository
scriptURL := ""
if a.RepoInfo.Organization != "" && a.RepoInfo.Repository != "" {
// This would ideally link to the specific line in the action.yml file
scriptURL = fmt.Sprintf(
"%s/%s/%s/blob/%s/action.yml#L%d",
githubBaseURL,
a.RepoInfo.Organization,
a.RepoInfo.Repository,
a.RepoInfo.DefaultBranch,
stepNumber*scriptLineEstimate,
) // Rough estimate
}
return &Dependency{
Name: name,
Uses: "", // No uses for shell scripts
Version: "",
VersionType: LocalPath,
IsPinned: true, // Shell scripts are always "pinned"
Description: "Shell script execution",
Author: a.RepoInfo.Organization,
SourceURL: scriptURL,
WithParams: map[string]string{},
IsLocalAction: true,
IsShellScript: true,
ScriptURL: scriptURL,
}
}
// parseUsesStatement parses a GitHub Action uses statement.
func (a *Analyzer) parseUsesStatement(uses string) (owner, repo, version string, versionType VersionType) {
// Handle different uses statement formats:
// - actions/checkout@v4
// - actions/checkout@main
// - actions/checkout@8f4b7f84bd579b95d7f0b90f8d8b6e5d9b8a7f6e
// - ./local-action
// - docker://alpine:3.14
if strings.HasPrefix(uses, localPathPrefix) || strings.HasPrefix(uses, localPathUpPrefix) {
return "", "", uses, LocalPath
}
if strings.HasPrefix(uses, dockerPrefix) {
return "", "", uses, LocalPath
}
// Standard GitHub action format: owner/repo@version
re := regexp.MustCompile(`^([^/]+)/([^@]+)@(.+)$`)
matches := re.FindStringSubmatch(uses)
if len(matches) != 4 {
return "", "", "", LocalPath
}
owner = matches[1]
repo = matches[2]
version = matches[3]
// Determine version type
switch {
case a.isCommitSHA(version):
versionType = CommitSHA
case a.isSemanticVersion(version):
versionType = SemanticVersion
default:
versionType = BranchName
}
return owner, repo, version, versionType
}
// isCommitSHA checks if a version string is a commit SHA.
func (a *Analyzer) isCommitSHA(version string) bool {
// Check if it's a 40-character hex string (full SHA) or 7+ character hex (short SHA)
re := regexp.MustCompile(`^[a-f0-9]{7,40}$`)
return len(version) >= minSHALength && re.MatchString(version)
}
// isSemanticVersion checks if a version string follows semantic versioning.
func (a *Analyzer) isSemanticVersion(version string) bool {
// Check for vX, vX.Y, vX.Y.Z format
re := regexp.MustCompile(`^v?\d+(\.\d+)*(\.\d+)?(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$`)
return re.MatchString(version)
}
// isVersionPinned checks if a semantic version is pinned to a specific version.
func (a *Analyzer) isVersionPinned(version string) bool {
// Consider it pinned if it specifies patch version (v1.2.3) or is a commit SHA
// Also check for full commit SHAs (40 chars)
if len(version) == fullSHALength {
return true
}
re := regexp.MustCompile(`^v?\d+\.\d+\.\d+`)
return re.MatchString(version)
}
// convertWithParams converts with parameters to string map.
func (a *Analyzer) convertWithParams(with map[string]any) map[string]string {
params := make(map[string]string)
for k, v := range with {
if str, ok := v.(string); ok {
params[k] = str
} else {
params[k] = fmt.Sprintf("%v", v)
}
}
return params
}
// getLatestVersion fetches the latest release/tag for a repository.
func (a *Analyzer) getLatestVersion(owner, repo string) (version, sha string, err error) {
if a.GitHubClient == nil {
return "", "", errors.New("GitHub client not available")
}
ctx, cancel := context.WithTimeout(context.Background(), apiCallTimeout)
defer cancel()
// Check cache first
cacheKey := cacheKeyLatest + fmt.Sprintf("%s/%s", owner, repo)
if version, sha, found := a.getCachedVersion(cacheKey); found {
return version, sha, nil
}
// Try to get latest release first
if version, sha, err := a.getLatestRelease(ctx, owner, repo); err == nil {
a.cacheVersion(cacheKey, version, sha)
return version, sha, nil
}
// Fallback to latest tag
version, sha, err = a.getLatestTag(ctx, owner, repo)
if err != nil {
return "", "", err
}
a.cacheVersion(cacheKey, version, sha)
return version, sha, nil
}
// getCachedVersion retrieves version info from cache if available.
func (a *Analyzer) getCachedVersion(cacheKey string) (version, sha string, found bool) {
if a.Cache == nil {
return "", "", false
}
cached, exists := a.Cache.Get(cacheKey)
if !exists {
return "", "", false
}
versionInfo, ok := cached.(map[string]string)
if !ok {
return "", "", false
}
return versionInfo["version"], versionInfo["sha"], true
}
// getLatestRelease fetches the latest release and its commit SHA.
func (a *Analyzer) getLatestRelease(ctx context.Context, owner, repo string) (version, sha string, err error) {
release, _, err := a.GitHubClient.Repositories.GetLatestRelease(ctx, owner, repo)
if err != nil || release.GetTagName() == "" {
return "", "", errors.New("no release found")
}
version = release.GetTagName()
sha = a.getCommitSHAForTag(ctx, owner, repo, version)
return version, sha, nil
}
// getCommitSHAForTag retrieves the commit SHA for a given tag.
func (a *Analyzer) getCommitSHAForTag(ctx context.Context, owner, repo, tagName string) string {
tag, _, err := a.GitHubClient.Git.GetRef(ctx, owner, repo, "tags/"+tagName)
if err != nil || tag.GetObject() == nil {
return ""
}
return tag.GetObject().GetSHA()
}
// getLatestTag fetches the most recent tag and its commit SHA.
func (a *Analyzer) getLatestTag(ctx context.Context, owner, repo string) (version, sha string, err error) {
tags, _, err := a.GitHubClient.Repositories.ListTags(ctx, owner, repo, &github.ListOptions{
PerPage: 10,
})
if err != nil || len(tags) == 0 {
return "", "", errors.New("no releases or tags found")
}
latestTag := tags[0]
return latestTag.GetName(), latestTag.GetCommit().GetSHA(), nil
}
// cacheVersion stores version information in cache with TTL.
func (a *Analyzer) cacheVersion(cacheKey, version, sha string) {
if a.Cache == nil {
return
}
versionInfo := map[string]string{"version": version, "sha": sha}
_ = a.Cache.SetWithTTL(cacheKey, versionInfo, cacheDefaultTTL)
}
// compareVersions compares two version strings and returns the update type.
func (a *Analyzer) compareVersions(current, latest string) string {
currentClean := strings.TrimPrefix(current, "v")
latestClean := strings.TrimPrefix(latest, "v")
if currentClean == latestClean {
return updateTypeNone
}
// Special case: floating major version (e.g., "4" -> "4.1.1") should be patch
if !strings.Contains(currentClean, ".") && strings.HasPrefix(latestClean, currentClean+".") {
return updateTypePatch
}
currentParts := a.parseVersionParts(currentClean)
latestParts := a.parseVersionParts(latestClean)
return a.determineUpdateType(currentParts, latestParts)
}
// parseVersionParts normalizes version string to 3-part semantic version.
func (a *Analyzer) parseVersionParts(version string) []string {
parts := strings.Split(version, ".")
// For floating versions like "v4", treat as "v4.0.0" for comparison
for len(parts) < versionPartsCount {
parts = append(parts, "0")
}
return parts
}
// determineUpdateType compares version parts and returns update type.
func (a *Analyzer) determineUpdateType(currentParts, latestParts []string) string {
if currentParts[0] != latestParts[0] {
return updateTypeMajor
}
if currentParts[1] != latestParts[1] {
return updateTypeMinor
}
if currentParts[2] != latestParts[2] {
return updateTypePatch
}
return updateTypeNone
}
// updateActionFile applies updates to a single action file.
func (a *Analyzer) updateActionFile(filePath string, updates []PinnedUpdate) error {
// Read the file
content, err := os.ReadFile(filePath) // #nosec G304 -- file path from function parameter
if err != nil {
return fmt.Errorf("failed to read file: %w", err)
}
// Create backup
backupPath := filePath + backupExtension
if err := os.WriteFile(backupPath, content, backupFilePerms); err != nil { // #nosec G306 -- backup file permissions
return fmt.Errorf("failed to create backup: %w", err)
}
// Apply updates to content
lines := strings.Split(string(content), "\n")
for _, update := range updates {
// Find and replace the uses line
for i, line := range lines {
if strings.Contains(line, update.OldUses) {
// Replace the uses statement while preserving indentation
indent := strings.Repeat(" ", len(line)-len(strings.TrimLeft(line, " ")))
lines[i] = indent + usesFieldPrefix + update.NewUses
update.LineNumber = i + 1 // Store line number for reference
break
}
}
}
// Write updated content
updatedContent := strings.Join(lines, "\n")
if err := os.WriteFile(filePath, []byte(updatedContent), updatedFilePerms); err != nil {
// #nosec G306 -- updated file permissions
return fmt.Errorf("failed to write updated file: %w", err)
}
// Validate the updated file by trying to parse it
if err := a.validateActionFile(filePath); err != nil {
// Rollback on validation failure
if rollbackErr := os.Rename(backupPath, filePath); rollbackErr != nil {
return fmt.Errorf("validation failed and rollback failed: %w (original error: %w)", rollbackErr, err)
}
return fmt.Errorf("validation failed, rolled back changes: %w", err)
}
// Remove backup on success
_ = os.Remove(backupPath)
return nil
}
// validateActionFile validates that an action.yml file is still valid after updates.
func (a *Analyzer) validateActionFile(filePath string) error {
_, err := a.parseCompositeAction(filePath)
return err
}
// enrichWithGitHubData fetches additional information from GitHub API.
func (a *Analyzer) enrichWithGitHubData(dep *Dependency, owner, repo string) error {
ctx, cancel := context.WithTimeout(context.Background(), apiCallTimeout)
defer cancel()
// Check cache first
cacheKey := cacheKeyRepo + fmt.Sprintf("%s/%s", owner, repo)
if a.Cache != nil {
if cached, exists := a.Cache.Get(cacheKey); exists {
if repository, ok := cached.(*github.Repository); ok {
dep.Description = repository.GetDescription()
return nil
}
}
}
// Fetch from API
repository, _, err := a.GitHubClient.Repositories.Get(ctx, owner, repo)
if err != nil {
return fmt.Errorf("failed to fetch repository info: %w", err)
}
// Cache the result with 1 hour TTL
if a.Cache != nil {
_ = a.Cache.SetWithTTL(cacheKey, repository, cacheDefaultTTL) // Ignore cache errors
}
// Enrich dependency with API data
dep.Description = repository.GetDescription()
return nil
}