mirror of
https://github.com/ivuorinen/gh-action-readme.git
synced 2026-03-14 02:00:37 +00:00
Initial commit
This commit is contained in:
539
internal/dependencies/analyzer.go
Normal file
539
internal/dependencies/analyzer.go
Normal file
@@ -0,0 +1,539 @@
|
||||
// Package dependencies provides GitHub Actions dependency analysis functionality.
|
||||
package dependencies
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-github/v57/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"
|
||||
defaultBranch = "main"
|
||||
)
|
||||
|
||||
// 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) {
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Only analyze composite actions
|
||||
if action.Runs.Using != compositeUsing {
|
||||
return []Dependency{}, nil // No dependencies for non-composite actions
|
||||
}
|
||||
|
||||
var dependencies []Dependency
|
||||
|
||||
// Analyze each step
|
||||
for i, step := range action.Runs.Steps {
|
||||
if step.Uses != "" {
|
||||
// This is an action dependency
|
||||
dep, err := a.analyzeActionDependency(step, i+1)
|
||||
if err != nil {
|
||||
// Log error but continue processing
|
||||
continue
|
||||
}
|
||||
dependencies = append(dependencies, *dep)
|
||||
} else if step.Run != "" {
|
||||
// This is a shell script step
|
||||
dep := a.analyzeShellScript(step, i+1)
|
||||
dependencies = append(dependencies, *dep)
|
||||
}
|
||||
}
|
||||
|
||||
return dependencies, nil
|
||||
}
|
||||
|
||||
// parseCompositeAction is implemented in parser.go
|
||||
|
||||
// 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: step.Name,
|
||||
Uses: step.Uses,
|
||||
Version: version,
|
||||
VersionType: versionType,
|
||||
IsPinned: versionType == CommitSHA || (versionType == SemanticVersion && a.isVersionPinned(version)),
|
||||
Author: owner,
|
||||
SourceURL: fmt.Sprintf("https://github.com/%s/%s", owner, repo),
|
||||
IsLocalAction: isLocal,
|
||||
IsShellScript: false,
|
||||
WithParams: a.convertWithParams(step.With),
|
||||
}
|
||||
|
||||
// Add marketplace URL for public actions
|
||||
if !isLocal {
|
||||
dep.MarketplaceURL = fmt.Sprintf("https://github.com/marketplace/actions/%s", 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("https://github.com/%s/%s/blob/%s/action.yml#L%d",
|
||||
a.RepoInfo.Organization, a.RepoInfo.Repository, a.RepoInfo.DefaultBranch, stepNumber*10) // 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, "./") || strings.HasPrefix(uses, "../") {
|
||||
return "", "", uses, LocalPath
|
||||
}
|
||||
|
||||
if strings.HasPrefix(uses, "docker://") {
|
||||
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) >= 7 && 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
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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 "", "", fmt.Errorf("GitHub client not available")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Check cache first
|
||||
cacheKey := fmt.Sprintf("latest:%s/%s", owner, repo)
|
||||
if cached, exists := a.Cache.Get(cacheKey); exists {
|
||||
if versionInfo, ok := cached.(map[string]string); ok {
|
||||
return versionInfo["version"], versionInfo["sha"], nil
|
||||
}
|
||||
}
|
||||
|
||||
// Try to get latest release first
|
||||
release, _, err := a.GitHubClient.Repositories.GetLatestRelease(ctx, owner, repo)
|
||||
if err == nil && release.GetTagName() != "" {
|
||||
// Get the commit SHA for this tag
|
||||
tag, _, tagErr := a.GitHubClient.Git.GetRef(ctx, owner, repo, "tags/"+release.GetTagName())
|
||||
sha := ""
|
||||
if tagErr == nil && tag.GetObject() != nil {
|
||||
sha = tag.GetObject().GetSHA()
|
||||
}
|
||||
|
||||
version := release.GetTagName()
|
||||
// Cache the result
|
||||
versionInfo := map[string]string{"version": version, "sha": sha}
|
||||
_ = a.Cache.SetWithTTL(cacheKey, versionInfo, 1*time.Hour)
|
||||
|
||||
return version, sha, nil
|
||||
}
|
||||
|
||||
// If no releases, try to get latest tags
|
||||
tags, _, err := a.GitHubClient.Repositories.ListTags(ctx, owner, repo, &github.ListOptions{
|
||||
PerPage: 10,
|
||||
})
|
||||
if err != nil || len(tags) == 0 {
|
||||
return "", "", fmt.Errorf("no releases or tags found")
|
||||
}
|
||||
|
||||
// Get the most recent tag
|
||||
latestTag := tags[0]
|
||||
version = latestTag.GetName()
|
||||
sha = latestTag.GetCommit().GetSHA()
|
||||
|
||||
// Cache the result
|
||||
versionInfo := map[string]string{"version": version, "sha": sha}
|
||||
_ = a.Cache.SetWithTTL(cacheKey, versionInfo, 1*time.Hour)
|
||||
|
||||
return version, sha, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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 len(parts) < 3 {
|
||||
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 "minor"
|
||||
}
|
||||
if currentParts[2] != latestParts[2] {
|
||||
return updateTypePatch
|
||||
}
|
||||
return updateTypePatch
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read file: %w", err)
|
||||
}
|
||||
|
||||
// Create backup
|
||||
backupPath := filePath + ".backup"
|
||||
if err := os.WriteFile(backupPath, content, 0644); err != nil {
|
||||
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, " ")))
|
||||
usesPrefix := "uses: "
|
||||
lines[i] = indent + usesPrefix + 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), 0644); err != nil {
|
||||
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: %v (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(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Check cache first
|
||||
cacheKey := fmt.Sprintf("repo:%s/%s", owner, repo)
|
||||
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
|
||||
_ = a.Cache.SetWithTTL(cacheKey, repository, 1*time.Hour) // Ignore cache errors
|
||||
|
||||
// Enrich dependency with API data
|
||||
dep.Description = repository.GetDescription()
|
||||
|
||||
return nil
|
||||
}
|
||||
547
internal/dependencies/analyzer_test.go
Normal file
547
internal/dependencies/analyzer_test.go
Normal file
@@ -0,0 +1,547 @@
|
||||
package dependencies
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-github/v57/github"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/internal/cache"
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
func TestAnalyzer_AnalyzeActionFile(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
actionYML string
|
||||
expectError bool
|
||||
expectDeps bool
|
||||
expectedLen int
|
||||
expectedDeps []string
|
||||
}{
|
||||
{
|
||||
name: "simple action - no dependencies",
|
||||
actionYML: testutil.SimpleActionYML,
|
||||
expectError: false,
|
||||
expectDeps: false,
|
||||
expectedLen: 0,
|
||||
},
|
||||
{
|
||||
name: "composite action with dependencies",
|
||||
actionYML: testutil.CompositeActionYML,
|
||||
expectError: false,
|
||||
expectDeps: true,
|
||||
expectedLen: 2,
|
||||
expectedDeps: []string{"actions/checkout@v4", "actions/setup-node@v3"},
|
||||
},
|
||||
{
|
||||
name: "docker action - no step dependencies",
|
||||
actionYML: testutil.DockerActionYML,
|
||||
expectError: false,
|
||||
expectDeps: false,
|
||||
expectedLen: 0,
|
||||
},
|
||||
{
|
||||
name: "invalid action file",
|
||||
actionYML: testutil.InvalidActionYML,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "minimal action - no dependencies",
|
||||
actionYML: testutil.MinimalActionYML,
|
||||
expectError: false,
|
||||
expectDeps: false,
|
||||
expectedLen: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create temporary action file
|
||||
tmpDir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
actionPath := filepath.Join(tmpDir, "action.yml")
|
||||
testutil.WriteTestFile(t, actionPath, tt.actionYML)
|
||||
|
||||
// Create analyzer with mock GitHub client
|
||||
mockResponses := testutil.MockGitHubResponses()
|
||||
githubClient := testutil.MockGitHubClient(mockResponses)
|
||||
cacheInstance, _ := cache.NewCache(cache.DefaultConfig())
|
||||
|
||||
analyzer := &Analyzer{
|
||||
GitHubClient: githubClient,
|
||||
Cache: cacheInstance,
|
||||
}
|
||||
|
||||
// Analyze the action file
|
||||
deps, err := analyzer.AnalyzeActionFile(actionPath)
|
||||
|
||||
// Check error expectation
|
||||
if tt.expectError {
|
||||
testutil.AssertError(t, err)
|
||||
return
|
||||
}
|
||||
testutil.AssertNoError(t, err)
|
||||
|
||||
// Check dependencies
|
||||
if tt.expectDeps {
|
||||
if len(deps) != tt.expectedLen {
|
||||
t.Errorf("expected %d dependencies, got %d", tt.expectedLen, len(deps))
|
||||
}
|
||||
|
||||
// Check specific dependencies if provided
|
||||
if tt.expectedDeps != nil {
|
||||
for i, expectedDep := range tt.expectedDeps {
|
||||
if i >= len(deps) {
|
||||
t.Errorf("expected dependency %s but got fewer dependencies", expectedDep)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(deps[i].Name+"@"+deps[i].Version, expectedDep) {
|
||||
t.Errorf("expected dependency %s, got %s@%s", expectedDep, deps[i].Name, deps[i].Version)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if len(deps) != 0 {
|
||||
t.Errorf("expected no dependencies, got %d", len(deps))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzer_ParseUsesStatement(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
uses string
|
||||
expectedOwner string
|
||||
expectedRepo string
|
||||
expectedVersion string
|
||||
expectedType VersionType
|
||||
}{
|
||||
{
|
||||
name: "semantic version",
|
||||
uses: "actions/checkout@v4",
|
||||
expectedOwner: "actions",
|
||||
expectedRepo: "checkout",
|
||||
expectedVersion: "v4",
|
||||
expectedType: SemanticVersion,
|
||||
},
|
||||
{
|
||||
name: "semantic version with patch",
|
||||
uses: "actions/setup-node@v3.8.1",
|
||||
expectedOwner: "actions",
|
||||
expectedRepo: "setup-node",
|
||||
expectedVersion: "v3.8.1",
|
||||
expectedType: SemanticVersion,
|
||||
},
|
||||
{
|
||||
name: "commit SHA",
|
||||
uses: "actions/checkout@8f4b7f84bd579b95d7f0b90f8d8b6e5d9b8a7f6e",
|
||||
expectedOwner: "actions",
|
||||
expectedRepo: "checkout",
|
||||
expectedVersion: "8f4b7f84bd579b95d7f0b90f8d8b6e5d9b8a7f6e",
|
||||
expectedType: CommitSHA,
|
||||
},
|
||||
{
|
||||
name: "branch reference",
|
||||
uses: "octocat/hello-world@main",
|
||||
expectedOwner: "octocat",
|
||||
expectedRepo: "hello-world",
|
||||
expectedVersion: "main",
|
||||
expectedType: BranchName,
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := &Analyzer{}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
owner, repo, version, versionType := analyzer.parseUsesStatement(tt.uses)
|
||||
|
||||
testutil.AssertEqual(t, tt.expectedOwner, owner)
|
||||
testutil.AssertEqual(t, tt.expectedRepo, repo)
|
||||
testutil.AssertEqual(t, tt.expectedVersion, version)
|
||||
testutil.AssertEqual(t, tt.expectedType, versionType)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzer_VersionChecking(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
version string
|
||||
isPinned bool
|
||||
isCommitSHA bool
|
||||
isSemantic bool
|
||||
}{
|
||||
{
|
||||
name: "semantic version major",
|
||||
version: "v4",
|
||||
isPinned: false,
|
||||
isCommitSHA: false,
|
||||
isSemantic: true,
|
||||
},
|
||||
{
|
||||
name: "semantic version full",
|
||||
version: "v3.8.1",
|
||||
isPinned: true,
|
||||
isCommitSHA: false,
|
||||
isSemantic: true,
|
||||
},
|
||||
{
|
||||
name: "commit SHA full",
|
||||
version: "8f4b7f84bd579b95d7f0b90f8d8b6e5d9b8a7f6e",
|
||||
isPinned: true,
|
||||
isCommitSHA: true,
|
||||
isSemantic: false,
|
||||
},
|
||||
{
|
||||
name: "commit SHA short",
|
||||
version: "8f4b7f8",
|
||||
isPinned: false,
|
||||
isCommitSHA: true,
|
||||
isSemantic: false,
|
||||
},
|
||||
{
|
||||
name: "branch reference",
|
||||
version: "main",
|
||||
isPinned: false,
|
||||
isCommitSHA: false,
|
||||
isSemantic: false,
|
||||
},
|
||||
{
|
||||
name: "numeric version",
|
||||
version: "1.2.3",
|
||||
isPinned: true,
|
||||
isCommitSHA: false,
|
||||
isSemantic: true,
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := &Analyzer{}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
isPinned := analyzer.isVersionPinned(tt.version)
|
||||
isCommitSHA := analyzer.isCommitSHA(tt.version)
|
||||
isSemantic := analyzer.isSemanticVersion(tt.version)
|
||||
|
||||
testutil.AssertEqual(t, tt.isPinned, isPinned)
|
||||
testutil.AssertEqual(t, tt.isCommitSHA, isCommitSHA)
|
||||
testutil.AssertEqual(t, tt.isSemantic, isSemantic)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzer_GetLatestVersion(t *testing.T) {
|
||||
// Create mock GitHub client with test responses
|
||||
mockResponses := testutil.MockGitHubResponses()
|
||||
githubClient := testutil.MockGitHubClient(mockResponses)
|
||||
cacheInstance, _ := cache.NewCache(cache.DefaultConfig())
|
||||
|
||||
analyzer := &Analyzer{
|
||||
GitHubClient: githubClient,
|
||||
Cache: cacheInstance,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
owner string
|
||||
repo string
|
||||
expectedVersion string
|
||||
expectedSHA string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "valid repository",
|
||||
owner: "actions",
|
||||
repo: "checkout",
|
||||
expectedVersion: "v4.1.1",
|
||||
expectedSHA: "8f4b7f84bd579b95d7f0b90f8d8b6e5d9b8a7f6e",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "another valid repository",
|
||||
owner: "actions",
|
||||
repo: "setup-node",
|
||||
expectedVersion: "v4.0.0",
|
||||
expectedSHA: "1a4e6d7c9f8e5b2a3c4d5e6f7a8b9c0d1e2f3a4b",
|
||||
expectError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
version, sha, err := analyzer.getLatestVersion(tt.owner, tt.repo)
|
||||
|
||||
if tt.expectError {
|
||||
testutil.AssertError(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
testutil.AssertNoError(t, err)
|
||||
testutil.AssertEqual(t, tt.expectedVersion, version)
|
||||
testutil.AssertEqual(t, tt.expectedSHA, sha)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzer_CheckOutdated(t *testing.T) {
|
||||
// Create mock GitHub client
|
||||
mockResponses := testutil.MockGitHubResponses()
|
||||
githubClient := testutil.MockGitHubClient(mockResponses)
|
||||
cacheInstance, _ := cache.NewCache(cache.DefaultConfig())
|
||||
|
||||
analyzer := &Analyzer{
|
||||
GitHubClient: githubClient,
|
||||
Cache: cacheInstance,
|
||||
}
|
||||
|
||||
// Create test dependencies
|
||||
dependencies := []Dependency{
|
||||
{
|
||||
Name: "actions/checkout",
|
||||
Version: "v3",
|
||||
IsPinned: false,
|
||||
VersionType: SemanticVersion,
|
||||
Description: "Action for checking out a repo",
|
||||
},
|
||||
{
|
||||
Name: "actions/setup-node",
|
||||
Version: "v4.0.0",
|
||||
IsPinned: true,
|
||||
VersionType: SemanticVersion,
|
||||
Description: "Setup Node.js",
|
||||
},
|
||||
}
|
||||
|
||||
outdated, err := analyzer.CheckOutdated(dependencies)
|
||||
testutil.AssertNoError(t, err)
|
||||
|
||||
// Should detect that actions/checkout v3 is outdated (latest is v4.1.1)
|
||||
if len(outdated) == 0 {
|
||||
t.Error("expected to find outdated dependencies")
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, dep := range outdated {
|
||||
if dep.Current.Name == "actions/checkout" && dep.Current.Version == "v3" {
|
||||
found = true
|
||||
if dep.LatestVersion != "v4.1.1" {
|
||||
t.Errorf("expected latest version v4.1.1, got %s", dep.LatestVersion)
|
||||
}
|
||||
if dep.UpdateType != "major" {
|
||||
t.Errorf("expected major update, got %s", dep.UpdateType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
t.Error("expected to find actions/checkout v3 as outdated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzer_CompareVersions(t *testing.T) {
|
||||
analyzer := &Analyzer{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
current string
|
||||
latest string
|
||||
expectedType string
|
||||
}{
|
||||
{
|
||||
name: "major version difference",
|
||||
current: "v3.0.0",
|
||||
latest: "v4.0.0",
|
||||
expectedType: "major",
|
||||
},
|
||||
{
|
||||
name: "minor version difference",
|
||||
current: "v4.0.0",
|
||||
latest: "v4.1.0",
|
||||
expectedType: "minor",
|
||||
},
|
||||
{
|
||||
name: "patch version difference",
|
||||
current: "v4.1.0",
|
||||
latest: "v4.1.1",
|
||||
expectedType: "patch",
|
||||
},
|
||||
{
|
||||
name: "no difference",
|
||||
current: "v4.1.1",
|
||||
latest: "v4.1.1",
|
||||
expectedType: "none",
|
||||
},
|
||||
{
|
||||
name: "floating to specific",
|
||||
current: "v4",
|
||||
latest: "v4.1.1",
|
||||
expectedType: "patch",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
updateType := analyzer.compareVersions(tt.current, tt.latest)
|
||||
testutil.AssertEqual(t, tt.expectedType, updateType)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzer_GeneratePinnedUpdate(t *testing.T) {
|
||||
tmpDir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
// Create a test action file with composite steps
|
||||
actionContent := `name: 'Test Composite Action'
|
||||
description: 'Test action for update testing'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v3.8.0
|
||||
with:
|
||||
node-version: '18'
|
||||
`
|
||||
|
||||
actionPath := filepath.Join(tmpDir, "action.yml")
|
||||
testutil.WriteTestFile(t, actionPath, actionContent)
|
||||
|
||||
// Create analyzer
|
||||
mockResponses := testutil.MockGitHubResponses()
|
||||
githubClient := testutil.MockGitHubClient(mockResponses)
|
||||
cacheInstance, _ := cache.NewCache(cache.DefaultConfig())
|
||||
|
||||
analyzer := &Analyzer{
|
||||
GitHubClient: githubClient,
|
||||
Cache: cacheInstance,
|
||||
}
|
||||
|
||||
// Create test dependency
|
||||
dep := Dependency{
|
||||
Name: "actions/checkout",
|
||||
Version: "v3",
|
||||
IsPinned: false,
|
||||
VersionType: SemanticVersion,
|
||||
Description: "Action for checking out a repo",
|
||||
}
|
||||
|
||||
// Generate pinned update
|
||||
update, err := analyzer.GeneratePinnedUpdate(
|
||||
actionPath,
|
||||
dep,
|
||||
"v4.1.1",
|
||||
"8f4b7f84bd579b95d7f0b90f8d8b6e5d9b8a7f6e",
|
||||
)
|
||||
|
||||
testutil.AssertNoError(t, err)
|
||||
|
||||
// Verify update details
|
||||
testutil.AssertEqual(t, actionPath, update.FilePath)
|
||||
testutil.AssertEqual(t, "actions/checkout@v3", update.OldUses)
|
||||
testutil.AssertStringContains(t, update.NewUses, "actions/checkout@8f4b7f84bd579b95d7f0b90f8d8b6e5d9b8a7f6e")
|
||||
testutil.AssertStringContains(t, update.NewUses, "# v4.1.1")
|
||||
testutil.AssertEqual(t, "major", update.UpdateType)
|
||||
}
|
||||
|
||||
func TestAnalyzer_WithCache(t *testing.T) {
|
||||
// Test that caching works properly
|
||||
mockResponses := testutil.MockGitHubResponses()
|
||||
githubClient := testutil.MockGitHubClient(mockResponses)
|
||||
cacheInstance, _ := cache.NewCache(cache.DefaultConfig())
|
||||
|
||||
analyzer := &Analyzer{
|
||||
GitHubClient: githubClient,
|
||||
Cache: cacheInstance,
|
||||
}
|
||||
|
||||
// First call should hit the API
|
||||
version1, sha1, err1 := analyzer.getLatestVersion("actions", "checkout")
|
||||
testutil.AssertNoError(t, err1)
|
||||
|
||||
// Second call should hit the cache
|
||||
version2, sha2, err2 := analyzer.getLatestVersion("actions", "checkout")
|
||||
testutil.AssertNoError(t, err2)
|
||||
|
||||
// Results should be identical
|
||||
testutil.AssertEqual(t, version1, version2)
|
||||
testutil.AssertEqual(t, sha1, sha2)
|
||||
}
|
||||
|
||||
func TestAnalyzer_RateLimitHandling(t *testing.T) {
|
||||
// Create mock client that returns rate limit error
|
||||
rateLimitResponse := &http.Response{
|
||||
StatusCode: 403,
|
||||
Header: http.Header{
|
||||
"X-RateLimit-Remaining": []string{"0"},
|
||||
"X-RateLimit-Reset": []string{fmt.Sprintf("%d", time.Now().Add(time.Hour).Unix())},
|
||||
},
|
||||
Body: testutil.NewStringReader(`{"message": "API rate limit exceeded"}`),
|
||||
}
|
||||
|
||||
mockClient := &testutil.MockHTTPClient{
|
||||
Responses: map[string]*http.Response{
|
||||
"GET https://api.github.com/repos/actions/checkout/releases/latest": rateLimitResponse,
|
||||
},
|
||||
}
|
||||
|
||||
client := github.NewClient(&http.Client{Transport: &mockTransport{client: mockClient}})
|
||||
cacheInstance, _ := cache.NewCache(cache.DefaultConfig())
|
||||
|
||||
analyzer := &Analyzer{
|
||||
GitHubClient: client,
|
||||
Cache: cacheInstance,
|
||||
}
|
||||
|
||||
// This should handle the rate limit gracefully
|
||||
_, _, err := analyzer.getLatestVersion("actions", "checkout")
|
||||
if err == nil {
|
||||
t.Error("expected rate limit error to be returned")
|
||||
}
|
||||
|
||||
testutil.AssertStringContains(t, err.Error(), "rate limit")
|
||||
}
|
||||
|
||||
func TestAnalyzer_WithoutGitHubClient(t *testing.T) {
|
||||
// Test graceful degradation when GitHub client is not available
|
||||
analyzer := &Analyzer{
|
||||
GitHubClient: nil,
|
||||
Cache: nil,
|
||||
}
|
||||
|
||||
tmpDir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
actionPath := filepath.Join(tmpDir, "action.yml")
|
||||
testutil.WriteTestFile(t, actionPath, testutil.CompositeActionYML)
|
||||
|
||||
deps, err := analyzer.AnalyzeActionFile(actionPath)
|
||||
|
||||
// Should still parse dependencies but without GitHub API data
|
||||
testutil.AssertNoError(t, err)
|
||||
if len(deps) > 0 {
|
||||
// Dependencies should have basic info but no GitHub API data
|
||||
for _, dep := range deps {
|
||||
if dep.Description != "" {
|
||||
t.Error("expected empty description when GitHub client is not available")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// mockTransport wraps our mock HTTP client for GitHub client.
|
||||
type mockTransport struct {
|
||||
client *testutil.MockHTTPClient
|
||||
}
|
||||
|
||||
func (t *mockTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return t.client.Do(req)
|
||||
}
|
||||
55
internal/dependencies/cache_adapter.go
Normal file
55
internal/dependencies/cache_adapter.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package dependencies
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/internal/cache"
|
||||
)
|
||||
|
||||
// CacheAdapter adapts the cache.Cache to implement DependencyCache interface.
|
||||
type CacheAdapter struct {
|
||||
cache *cache.Cache
|
||||
}
|
||||
|
||||
// NewCacheAdapter creates a new cache adapter.
|
||||
func NewCacheAdapter(c *cache.Cache) *CacheAdapter {
|
||||
return &CacheAdapter{cache: c}
|
||||
}
|
||||
|
||||
// Get retrieves a value from the cache.
|
||||
func (ca *CacheAdapter) Get(key string) (any, bool) {
|
||||
return ca.cache.Get(key)
|
||||
}
|
||||
|
||||
// Set stores a value in the cache with default TTL.
|
||||
func (ca *CacheAdapter) Set(key string, value any) error {
|
||||
return ca.cache.Set(key, value)
|
||||
}
|
||||
|
||||
// SetWithTTL stores a value in the cache with custom TTL.
|
||||
func (ca *CacheAdapter) SetWithTTL(key string, value any, ttl time.Duration) error {
|
||||
return ca.cache.SetWithTTL(key, value, ttl)
|
||||
}
|
||||
|
||||
// NoOpCache implements DependencyCache with no-op operations for when caching is disabled.
|
||||
type NoOpCache struct{}
|
||||
|
||||
// NewNoOpCache creates a new no-op cache.
|
||||
func NewNoOpCache() *NoOpCache {
|
||||
return &NoOpCache{}
|
||||
}
|
||||
|
||||
// Get always returns false (cache miss).
|
||||
func (noc *NoOpCache) Get(_ string) (any, bool) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Set does nothing.
|
||||
func (noc *NoOpCache) Set(_ string, _ any) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetWithTTL does nothing.
|
||||
func (noc *NoOpCache) SetWithTTL(_ string, _ any, _ time.Duration) error {
|
||||
return nil
|
||||
}
|
||||
51
internal/dependencies/parser.go
Normal file
51
internal/dependencies/parser.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package dependencies
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// parseCompositeActionFromFile reads and parses a composite action file.
|
||||
func (a *Analyzer) parseCompositeActionFromFile(actionPath string) (*ActionWithComposite, error) {
|
||||
// Read the file
|
||||
data, err := os.ReadFile(actionPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read action file %s: %w", actionPath, err)
|
||||
}
|
||||
|
||||
// Parse YAML
|
||||
var action ActionWithComposite
|
||||
if err := yaml.Unmarshal(data, &action); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse YAML: %w", err)
|
||||
}
|
||||
|
||||
return &action, nil
|
||||
}
|
||||
|
||||
// parseCompositeAction parses an action.yml file with composite action support.
|
||||
func (a *Analyzer) parseCompositeAction(actionPath string) (*ActionWithComposite, error) {
|
||||
// Use the real file parser
|
||||
action, err := a.parseCompositeActionFromFile(actionPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If this is not a composite action, return empty steps
|
||||
if action.Runs.Using != compositeUsing {
|
||||
action.Runs.Steps = []CompositeStep{}
|
||||
}
|
||||
|
||||
return action, nil
|
||||
}
|
||||
|
||||
// IsCompositeAction checks if an action file defines a composite action.
|
||||
func IsCompositeAction(actionPath string) (bool, error) {
|
||||
action, err := (&Analyzer{}).parseCompositeActionFromFile(actionPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return action.Runs.Using == compositeUsing, nil
|
||||
}
|
||||
27
internal/dependencies/types.go
Normal file
27
internal/dependencies/types.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package dependencies
|
||||
|
||||
// CompositeStep represents a step in a composite action.
|
||||
type CompositeStep struct {
|
||||
Name string `yaml:"name,omitempty"`
|
||||
Uses string `yaml:"uses,omitempty"`
|
||||
With map[string]any `yaml:"with,omitempty"`
|
||||
Run string `yaml:"run,omitempty"`
|
||||
Shell string `yaml:"shell,omitempty"`
|
||||
Env map[string]string `yaml:"env,omitempty"`
|
||||
}
|
||||
|
||||
// CompositeRuns represents the runs section of a composite action.
|
||||
type CompositeRuns struct {
|
||||
Using string `yaml:"using"`
|
||||
Steps []CompositeStep `yaml:"steps"`
|
||||
}
|
||||
|
||||
// ActionWithComposite represents an action.yml with composite steps support.
|
||||
type ActionWithComposite struct {
|
||||
Name string `yaml:"name"`
|
||||
Description string `yaml:"description"`
|
||||
Inputs map[string]any `yaml:"inputs"`
|
||||
Outputs map[string]any `yaml:"outputs"`
|
||||
Runs CompositeRuns `yaml:"runs"`
|
||||
Branding any `yaml:"branding,omitempty"`
|
||||
}
|
||||
Reference in New Issue
Block a user