mirror of
https://github.com/ivuorinen/gh-action-readme.git
synced 2026-01-26 11:14:04 +00:00
* 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
723 lines
22 KiB
Go
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
|
|
}
|