mirror of
https://github.com/ivuorinen/gh-action-readme.git
synced 2026-03-12 00:59:53 +00:00
feat: detect permissions from actions (#137)
* feat: detect permissions from actions * refactor(test): fix 25 SonarCloud issues by extracting test constants Resolved all SonarCloud code quality issues for PR #137: - Fixed 12 string duplication issues (S1192) - Fixed 13 naming convention issues (S100) Changes: - Centralized test constants in appconstants/test_constants.go * Added 9 parser test constants for YAML templates * Added 3 template test constants for paths and versions - Updated parser_test.go to use shared constants - Updated template_test.go to use shared constants - Renamed 13 test functions to camelCase (removed underscores) * chore: reduce code duplication * fix: implement cr fixes * chore: deduplication
This commit is contained in:
552
CLAUDE.md
552
CLAUDE.md
@@ -33,219 +33,447 @@ The embedded filesystem is used by default, with fallback to filesystem for deve
|
|||||||
**For testing generation commands:**
|
**For testing generation commands:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# New enhanced targeting (recommended)
|
# Safe testing approaches
|
||||||
gh-action-readme gen testdata/example-action/
|
gh-action-readme gen testdata/example-action/
|
||||||
gh-action-readme gen testdata/composite-action/action.yml
|
gh-action-readme gen testdata/composite-action/action.yml
|
||||||
|
gh-action-readme gen testdata/ --output /tmp/test-output.md
|
||||||
# Traditional method (still supported)
|
|
||||||
cd testdata/
|
|
||||||
../gh-action-readme gen [options]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🏗️ Architecture
|
## 🏗️ Architecture Overview
|
||||||
|
|
||||||
**Core Components:**
|
### Template Rendering Pipeline
|
||||||
|
|
||||||
- `main.go` - CLI with Cobra framework, enhanced gen command
|
1. **Parser** (`internal/parser.go`):
|
||||||
- `internal/generator.go` - Core generation logic with custom output paths
|
|
||||||
- `internal/config.go` - Viper configuration (XDG compliant)
|
|
||||||
- `internal/output.go` - Colored terminal output with progress bars
|
|
||||||
- `internal/json_writer.go` - JSON format support
|
|
||||||
- `internal/errors/` - Contextual error handling with suggestions
|
|
||||||
- `internal/wizard/` - Interactive configuration wizard
|
|
||||||
- `internal/progress.go` - Progress indicators for batch operations
|
|
||||||
|
|
||||||
**Templates:**
|
- Parses `action.yml` files using `goccy/go-yaml`
|
||||||
|
- Extracts permissions from header comments via `parsePermissionsFromComments()`
|
||||||
|
- Merges comment and YAML permissions (YAML takes precedence)
|
||||||
|
- Returns `*ActionYML` struct with all parsed data
|
||||||
|
|
||||||
- `templates/readme.tmpl` - Default template
|
1. **Template Data Builder** (`internal/template.go`):
|
||||||
- `templates/themes/` - Theme-specific templates
|
|
||||||
- `github/` - GitHub-style with badges
|
|
||||||
- `gitlab/` - GitLab CI/CD focused
|
|
||||||
- `minimal/` - Clean, concise
|
|
||||||
- `professional/` - Comprehensive with ToC
|
|
||||||
- `asciidoc/` - AsciiDoc format
|
|
||||||
|
|
||||||
## 🛠️ Commands & Usage
|
- `BuildTemplateData()` creates comprehensive `TemplateData` struct
|
||||||
|
- Embeds `*ActionYML` (all action fields accessible via `.Name`, `.Inputs`, `.Permissions`, etc.)
|
||||||
|
- Detects git repository info (org, repo, default branch)
|
||||||
|
- Extracts action subdirectory for monorepo support
|
||||||
|
- Builds `uses:` statement with proper path/version
|
||||||
|
|
||||||
**Available Commands:**
|
1. **Template Functions** (`internal/template.go:templateFuncs()`):
|
||||||
|
|
||||||
```bash
|
- `gitUsesString` - Generates complete `org/repo/path@version` string
|
||||||
gh-action-readme gen [directory_or_file] [flags] # Generate documentation
|
- `actionVersion` - Determines version (config override → default branch → "v1")
|
||||||
gh-action-readme validate # Validate action.yml files
|
- `gitOrg`, `gitRepo` - Extract git repository information
|
||||||
gh-action-readme config {init|show|themes|wizard} # Configuration management
|
- Standard Go template functions: `lower`, `upper`, `replace`, `join`
|
||||||
gh-action-readme version # Show version
|
|
||||||
gh-action-readme about # About tool
|
1. **Renderer** (`internal/template.go:RenderReadme()`):
|
||||||
|
|
||||||
|
- Reads template from embedded filesystem via `templates_embed.ReadTemplate()`
|
||||||
|
- Executes template with `TemplateData`
|
||||||
|
- Supports multiple formats (md, html, json, asciidoc)
|
||||||
|
|
||||||
|
### Key Data Structures
|
||||||
|
|
||||||
|
**ActionYML** - Parsed action.yml data:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ActionYML struct {
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
Inputs map[string]ActionInput
|
||||||
|
Outputs map[string]ActionOutput
|
||||||
|
Runs map[string]any
|
||||||
|
Branding *Branding
|
||||||
|
Permissions map[string]string // From comments or YAML field
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Key Flags:**
|
**TemplateData** - Complete data for rendering:
|
||||||
|
|
||||||
- `--theme` - Select template theme
|
```go
|
||||||
- `--output-format` - Choose format (md, html, json, asciidoc)
|
type TemplateData struct {
|
||||||
- `--output` - Custom output filename
|
*ActionYML // Embedded - all fields accessible directly
|
||||||
- `--recursive` - Process directories recursively
|
Git git.RepoInfo
|
||||||
- `--verbose` - Detailed output
|
Config *AppConfig
|
||||||
- `--quiet` - Suppress output
|
UsesStatement string // Pre-built "org/repo/path@version"
|
||||||
|
ActionPath string // For subdirectory extraction
|
||||||
## 🔧 Development Workflow
|
RepoRoot string
|
||||||
|
Dependencies []dependencies.Dependency
|
||||||
**Build:** `go build .`
|
}
|
||||||
**Test:** `go test ./internal`
|
|
||||||
**Lint:** `golangci-lint run`
|
|
||||||
**Dependencies:** `make deps-check` / `make deps-update`
|
|
||||||
|
|
||||||
**Testing Generation (SAFE):**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Enhanced targeting (recommended)
|
|
||||||
gh-action-readme gen testdata/example-action/ --theme github --output test-output.md
|
|
||||||
gh-action-readme gen testdata/composite-action/action.yml --theme professional
|
|
||||||
|
|
||||||
# Traditional method (still works)
|
|
||||||
cd testdata/example-action/
|
|
||||||
../../gh-action-readme gen --theme github
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📊 Feature Matrix
|
### Monorepo Action Path Resolution
|
||||||
|
|
||||||
| Feature | Status | Files |
|
The tool automatically detects and handles monorepo actions:
|
||||||
| --------- | -------- | ------- |
|
|
||||||
| CLI Framework | ✅ | `main.go` |
|
|
||||||
| Enhanced Gen Command | ✅ | `main.go:168-180` |
|
|
||||||
| File Discovery | ✅ | `generator.go:304-324` |
|
|
||||||
| Template Themes | ✅ | `templates/themes/` |
|
|
||||||
| Output Formats | ✅ | `generator.go:168-182` |
|
|
||||||
| Custom Output Paths | ✅ | `generator.go:157-166` |
|
|
||||||
| Validation | ✅ | `internal/validation/` |
|
|
||||||
| Configuration | ✅ | `config.go`, `configuration_loader.go` |
|
|
||||||
| Interactive Wizard | ✅ | `internal/wizard/` |
|
|
||||||
| Progress Indicators | ✅ | `progress.go` |
|
|
||||||
| Contextual Errors | ✅ | `internal/errors/` |
|
|
||||||
| Colored Output | ✅ | `output.go` |
|
|
||||||
|
|
||||||
## 🎨 Themes
|
```text
|
||||||
|
Input: /repo/actions/csharp-build/action.yml
|
||||||
|
Root: /repo
|
||||||
|
Output: org/repo/actions/csharp-build@main
|
||||||
|
```
|
||||||
|
|
||||||
**Available Themes:**
|
Implementation in `internal/template.go:extractActionSubdirectory()`:
|
||||||
|
|
||||||
1. **default** - Original simple template
|
- Calculates relative path from repo root to action directory
|
||||||
2. **github** - Badges, tables, collapsible sections
|
- Returns empty string for root-level actions
|
||||||
3. **gitlab** - GitLab CI/CD examples
|
- Returns subdirectory path for monorepo actions
|
||||||
4. **minimal** - Clean, concise documentation
|
- Used by `buildUsesString()` to construct proper `uses:` statements
|
||||||
5. **professional** - Comprehensive with troubleshooting
|
|
||||||
|
|
||||||
## 📄 Output Formats
|
### Permissions Parsing
|
||||||
|
|
||||||
**Supported Formats:**
|
Supports three sources (merged with priority):
|
||||||
|
|
||||||
- **md** - Markdown (default)
|
1. **Header comments** (lowest priority):
|
||||||
- **html** - HTML with styling
|
|
||||||
- **json** - Structured data for APIs
|
|
||||||
- **asciidoc** - Technical documentation format
|
|
||||||
|
|
||||||
## 🧪 Testing Strategy
|
```yaml
|
||||||
|
# permissions:
|
||||||
|
# - contents: read
|
||||||
|
# - issues: write
|
||||||
|
```
|
||||||
|
|
||||||
**Unit Tests:** `internal/*_test.go` (26.2% coverage)
|
1. **YAML field** (highest priority):
|
||||||
**Integration:** Manual CLI testing
|
|
||||||
**Templates:** Test with `testdata/example-action/`
|
|
||||||
|
|
||||||
**Test Commands:**
|
```yaml
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **Merged result**: YAML overrides comment values for duplicate keys, all unique keys included
|
||||||
|
|
||||||
|
## 🛠️ Development Commands
|
||||||
|
|
||||||
|
### Building and Running
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Core functionality (enhanced)
|
# Build binary
|
||||||
gh-action-readme gen testdata/example-action/
|
go build .
|
||||||
gh-action-readme gen testdata/composite-action/action.yml
|
|
||||||
|
|
||||||
# All themes with custom outputs
|
# Run without installing
|
||||||
for theme in github gitlab minimal professional; do
|
go run . gen testdata/example-action/
|
||||||
gh-action-readme gen testdata/example-action/ --theme $theme --output "test-${theme}.md"
|
go run . validate
|
||||||
|
go run . config show
|
||||||
|
|
||||||
|
# Build and run tests
|
||||||
|
make build
|
||||||
|
make test
|
||||||
|
make test-coverage # With coverage report
|
||||||
|
make test-coverage-html # HTML coverage + open in browser
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
# Run specific test package
|
||||||
|
go test ./internal
|
||||||
|
go test ./internal/wizard
|
||||||
|
|
||||||
|
# Run specific test by name
|
||||||
|
go test ./internal -run TestParsePermissions
|
||||||
|
go test ./internal -v -run "TestParseActionYML_.*Permissions"
|
||||||
|
|
||||||
|
# Run tests with race detection
|
||||||
|
go test -race ./...
|
||||||
|
|
||||||
|
# Test all themes
|
||||||
|
for theme in default github gitlab minimal professional; do
|
||||||
|
./gh-action-readme gen testdata/example-action/ --theme $theme --output /tmp/test-$theme.md
|
||||||
done
|
done
|
||||||
|
|
||||||
# All formats with custom outputs
|
|
||||||
for format in md html json asciidoc; do
|
|
||||||
gh-action-readme gen testdata/example-action/ --output-format $format --output "test.${format}"
|
|
||||||
done
|
|
||||||
|
|
||||||
# Recursive processing
|
|
||||||
gh-action-readme gen testdata/ --recursive --theme professional
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🚀 Production Features
|
### Linting and Quality
|
||||||
|
|
||||||
**Configuration:**
|
```bash
|
||||||
|
# Run all linters via pre-commit
|
||||||
|
make lint
|
||||||
|
|
||||||
- XDG Base Directory compliant
|
# Run golangci-lint directly
|
||||||
- Environment variable support
|
golangci-lint run
|
||||||
- Theme persistence
|
golangci-lint run --timeout=5m
|
||||||
- Multiple search paths
|
|
||||||
|
|
||||||
**Error Handling:**
|
# Check editor config compliance
|
||||||
|
make editorconfig
|
||||||
|
|
||||||
- Colored error messages
|
# Auto-fix editorconfig issues
|
||||||
- Actionable suggestions
|
make editorconfig-fix
|
||||||
- Context-aware validation
|
|
||||||
- Graceful fallbacks
|
|
||||||
|
|
||||||
**Performance:**
|
# Format code
|
||||||
|
make format
|
||||||
|
```
|
||||||
|
|
||||||
- Progress bars for batch operations
|
### Security Scanning
|
||||||
- Thread-safe fixture caching with RWMutex
|
|
||||||
- Binary-relative template paths
|
```bash
|
||||||
- Efficient file discovery
|
# Run all security checks
|
||||||
- Custom output path resolution
|
make security
|
||||||
- Race condition protection
|
|
||||||
- Minimal dependencies
|
# Individual security tools
|
||||||
|
make vulncheck # Go vulnerability check
|
||||||
|
make audit # Nancy dependency audit
|
||||||
|
make trivy # Container security scanner
|
||||||
|
make gitleaks # Secret detection
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check for outdated dependencies
|
||||||
|
make deps-check
|
||||||
|
|
||||||
|
# Update dependencies interactively
|
||||||
|
make deps-update
|
||||||
|
|
||||||
|
# Update all dependencies to latest
|
||||||
|
make deps-update-all
|
||||||
|
|
||||||
|
# Install development tools
|
||||||
|
make devtools
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pre-commit Hooks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install hooks (run once per clone)
|
||||||
|
make pre-commit-install
|
||||||
|
|
||||||
|
# Update hooks to latest versions
|
||||||
|
make pre-commit-update
|
||||||
|
|
||||||
|
# Run hooks manually
|
||||||
|
pre-commit run --all-files
|
||||||
|
```
|
||||||
|
|
||||||
|
## ⚙️ Configuration System
|
||||||
|
|
||||||
|
### Configuration Hierarchy (highest to lowest priority)
|
||||||
|
|
||||||
|
1. **Command-line flags** - Override everything
|
||||||
|
2. **Action-specific config** - `.ghreadme.yaml` in action directory
|
||||||
|
3. **Repository config** - `.ghreadme.yaml` in repo root
|
||||||
|
4. **Global config** - `~/.config/gh-action-readme/config.yaml`
|
||||||
|
5. **Environment variables** - `GH_README_GITHUB_TOKEN`, `GITHUB_TOKEN`
|
||||||
|
6. **Defaults** - Built-in fallbacks
|
||||||
|
|
||||||
|
### Version Resolution for Usage Examples
|
||||||
|
|
||||||
|
Priority order for `uses: org/repo@VERSION`:
|
||||||
|
|
||||||
|
1. `Config.Version` - Explicit override (e.g., `version: "v2.0.0"`)
|
||||||
|
2. `Config.UseDefaultBranch` + `Git.DefaultBranch` - Detected branch (e.g., `@main`)
|
||||||
|
3. Fallback - `"v1"`
|
||||||
|
|
||||||
|
Implemented in `internal/template.go:getActionVersion()`.
|
||||||
|
|
||||||
|
### Permissions Parsing
|
||||||
|
|
||||||
|
**Comment Format Support:**
|
||||||
|
|
||||||
|
- List format: `# permissions:\n# - key: value`
|
||||||
|
- Object format: `# permissions:\n# key: value`
|
||||||
|
- Mixed format: Both styles in same block
|
||||||
|
- Inline comments: `# key: value # explanation`
|
||||||
|
|
||||||
|
**Merge Behavior:**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// internal/parser.go:ParseActionYML()
|
||||||
|
if a.Permissions == nil && commentPermissions != nil {
|
||||||
|
a.Permissions = commentPermissions // Use comments
|
||||||
|
} else if a.Permissions != nil && commentPermissions != nil {
|
||||||
|
// Merge: YAML overrides, add missing from comments
|
||||||
|
for key, value := range commentPermissions {
|
||||||
|
if _, exists := a.Permissions[key]; !exists {
|
||||||
|
a.Permissions[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## 🔄 Adding New Features
|
## 🔄 Adding New Features
|
||||||
|
|
||||||
**New Theme:**
|
### New Theme
|
||||||
|
|
||||||
1. Create `templates/themes/THEME_NAME/readme.tmpl`
|
1. Create template file:
|
||||||
2. Add to `resolveThemeTemplate()` in `config.go:67`
|
|
||||||
3. Update `configThemesHandler()` in `main.go:284`
|
|
||||||
|
|
||||||
**New Output Format:**
|
```bash
|
||||||
|
touch templates_embed/templates/themes/THEME_NAME/readme.tmpl
|
||||||
|
```
|
||||||
|
|
||||||
1. Add constant to `generator.go:14`
|
1. Add theme constant to `appconstants/constants.go`:
|
||||||
2. Add case to `GenerateFromFile()` switch `generator.go:67`
|
|
||||||
3. Implement `generate[FORMAT]()` method
|
|
||||||
4. Update CLI help in `main.go:84`
|
|
||||||
|
|
||||||
**New Template Functions:**
|
```go
|
||||||
Add to `templateFuncs()` in `internal_template.go:19`
|
ThemeTHEMENAME = "theme-name"
|
||||||
|
TemplatePathTHEMENAME = "templates/themes/theme-name/readme.tmpl"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** The template path constant still uses `templates/` prefix (not
|
||||||
|
`templates_embed/templates/`) as this is the logical path used by the code.
|
||||||
|
The physical file lives in `templates_embed/templates/` but is referenced as
|
||||||
|
`templates/` in the code.
|
||||||
|
|
||||||
|
1. Add to theme resolver in `internal/config.go:resolveThemeTemplate()`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
case appconstants.ThemeTHEMENAME:
|
||||||
|
templatePath = appconstants.TemplatePathTHEMENAME
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Update `main.go:configThemesHandler()` to list the new theme
|
||||||
|
|
||||||
|
2. Rebuild: `go build .`
|
||||||
|
|
||||||
|
### New Output Format
|
||||||
|
|
||||||
|
1. Add format constant to `appconstants/constants.go`
|
||||||
|
|
||||||
|
2. Add case in `internal/generator.go:GenerateFromFile()`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
case appconstants.OutputFormatNEW:
|
||||||
|
return g.generateNEW(action, outputDir, actionPath)
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Implement generator method:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (g *Generator) generateNEW(action *ActionYML, outputDir, actionPath string) error {
|
||||||
|
opts := TemplateOptions{
|
||||||
|
TemplatePath: g.resolveTemplatePathForFormat(),
|
||||||
|
Format: "new",
|
||||||
|
}
|
||||||
|
// ... implementation
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Update CLI help text in `main.go`
|
||||||
|
|
||||||
|
### New Template Function
|
||||||
|
|
||||||
|
Add to `internal/template.go:templateFuncs()`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func templateFuncs() template.FuncMap {
|
||||||
|
return template.FuncMap{
|
||||||
|
"myFunc": myFuncImplementation,
|
||||||
|
// ... existing functions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### New Parser Field
|
||||||
|
|
||||||
|
When adding fields to `ActionYML`:
|
||||||
|
|
||||||
|
1. Update struct in `internal/parser.go`
|
||||||
|
2. Update `ActionYMLForJSON` in `internal/json_writer.go` (for JSON output)
|
||||||
|
3. Add field to JSON struct initialization
|
||||||
|
4. Add tests in `internal/parser_test.go`
|
||||||
|
5. Update templates if field should be displayed
|
||||||
|
|
||||||
|
## 📊 Package Structure
|
||||||
|
|
||||||
|
- **`main.go`** - CLI entry point (Cobra commands)
|
||||||
|
- **`internal/generator.go`** - Core generation orchestration
|
||||||
|
- **`internal/parser.go`** - Action.yml parsing (including permissions from comments)
|
||||||
|
- **`internal/template.go`** - Template data building and rendering
|
||||||
|
- **`internal/config.go`** - Configuration management (Viper)
|
||||||
|
- **`internal/json_writer.go`** - JSON output format
|
||||||
|
- **`internal/output.go`** - Colored CLI output
|
||||||
|
- **`internal/progress.go`** - Progress bars for batch operations
|
||||||
|
- **`internal/git/`** - Git repository detection
|
||||||
|
- **`internal/validation/`** - Action.yml validation
|
||||||
|
- **`internal/wizard/`** - Interactive configuration wizard
|
||||||
|
- **`internal/dependencies/`** - Dependency analysis for actions
|
||||||
|
- **`internal/errors/`** - Contextual error handling
|
||||||
|
- **`appconstants/`** - Application constants
|
||||||
|
- **`testutil/`** - Testing utilities
|
||||||
|
- **`templates_embed/`** - Embedded template filesystem
|
||||||
|
|
||||||
|
## 🧪 Testing Guidelines
|
||||||
|
|
||||||
|
### Test File Locations
|
||||||
|
|
||||||
|
- Unit tests: `internal/*_test.go` alongside source files
|
||||||
|
- Test fixtures: `testdata/example-action/`, `testdata/composite-action/`
|
||||||
|
- Integration tests: Manual CLI testing with testdata
|
||||||
|
|
||||||
|
### Running Specific Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Parser tests
|
||||||
|
go test ./internal -v -run TestParse
|
||||||
|
|
||||||
|
# Permissions tests
|
||||||
|
go test ./internal -run ".*Permissions"
|
||||||
|
|
||||||
|
# Template tests
|
||||||
|
go test ./internal -run ".*Template|.*Uses"
|
||||||
|
|
||||||
|
# Generator tests
|
||||||
|
go test ./internal -run "TestGenerator"
|
||||||
|
|
||||||
|
# Wizard tests
|
||||||
|
go test ./internal/wizard -v
|
||||||
|
```
|
||||||
|
|
||||||
|
### Template Testing After Updates
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Rebuild with updated templates
|
||||||
|
go build .
|
||||||
|
|
||||||
|
# 2. Test all themes
|
||||||
|
for theme in default github gitlab minimal professional; do
|
||||||
|
./gh-action-readme gen testdata/example-action/ --theme $theme --output /tmp/test-$theme.md
|
||||||
|
echo "=== $theme theme ==="
|
||||||
|
grep -i "permissions" /tmp/test-$theme.md && echo "✅ Found" || echo "❌ Missing"
|
||||||
|
done
|
||||||
|
|
||||||
|
# 4. Test JSON output
|
||||||
|
./gh-action-readme gen testdata/example-action/ -f json -o /tmp/test.json
|
||||||
|
cat /tmp/test.json | python3 -m json.tool | grep -A 3 permissions
|
||||||
|
```
|
||||||
|
|
||||||
## 📦 Dependency Management
|
## 📦 Dependency Management
|
||||||
|
|
||||||
**Check for updates:**
|
**Automated Updates:**
|
||||||
|
|
||||||
|
- Renovate bot runs weekly (Mondays 4am UTC)
|
||||||
|
- Auto-merges minor/patch updates
|
||||||
|
- Major updates require manual review
|
||||||
|
- Groups `golang.org/x` packages together
|
||||||
|
|
||||||
|
**Manual Updates:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make deps-check # Show outdated dependencies
|
make deps-check # Show outdated
|
||||||
|
make deps-update # Interactive with go-mod-upgrade
|
||||||
|
make deps-update-all # Update all to latest
|
||||||
```
|
```
|
||||||
|
|
||||||
**Update dependencies:**
|
## 🔐 Security
|
||||||
|
|
||||||
```bash
|
**Pre-commit Hooks:**
|
||||||
make deps-update # Interactive updates with go-mod-upgrade
|
|
||||||
make deps-update-all # Update all to latest versions
|
- `gitleaks` - Secret detection
|
||||||
|
- `golangci-lint` - Static analysis including security checks
|
||||||
|
- `editorconfig-checker` - File format validation
|
||||||
|
|
||||||
|
**Security Scanning:**
|
||||||
|
|
||||||
|
- CodeQL analysis on push/PR
|
||||||
|
- Go vulnerability check (govulncheck)
|
||||||
|
- Trivy container scanning
|
||||||
|
- Nancy dependency audit
|
||||||
|
|
||||||
|
**Path Validation:**
|
||||||
|
All file reads use validated paths to prevent path traversal:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// templates_embed/embed.go:ReadTemplate()
|
||||||
|
cleanPath := filepath.Clean(templatePath)
|
||||||
|
if cleanPath != templatePath || strings.Contains(cleanPath, "..") {
|
||||||
|
return nil, filepath.ErrBadPattern
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Automated updates:**
|
|
||||||
|
|
||||||
- Renovate bot runs weekly on Mondays at 4am UTC
|
|
||||||
- Creates PRs for minor/patch updates (auto-merge enabled)
|
|
||||||
- Major updates disabled (require manual review)
|
|
||||||
- Groups golang.org/x packages together
|
|
||||||
- Runs `go mod tidy` after updates
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Status: ENTERPRISE READY ✅**
|
|
||||||
*Enhanced gen command, thread-safety, comprehensive testing, and enterprise features fully implemented.*
|
|
||||||
|
|
||||||
**Latest Updates (August 6, 2025):**
|
|
||||||
|
|
||||||
- ✅ Enhanced gen command with directory/file targeting
|
|
||||||
- ✅ Custom output filename support (`--output` flag)
|
|
||||||
- ✅ Thread-safe fixture management with race condition protection
|
|
||||||
- ✅ GitHub Actions workflow integration with new capabilities
|
|
||||||
- ✅ Complete linting and code quality compliance
|
|
||||||
- ✅ Zero known race conditions or threading issues
|
|
||||||
- ✅ Dependency management automation with Renovate and go-mod-upgrade
|
|
||||||
|
|||||||
@@ -80,3 +80,23 @@ const (
|
|||||||
TestYAMLNested = "name: nested"
|
TestYAMLNested = "name: nested"
|
||||||
TestYAMLSub = "name: sub"
|
TestYAMLSub = "name: sub"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Test YAML template strings for parser tests.
|
||||||
|
const (
|
||||||
|
TestActionFilePattern = "action-*.yml"
|
||||||
|
TestPermissionsHeader = "# permissions:\n"
|
||||||
|
TestActionNameLine = "name: Test Action\n"
|
||||||
|
TestDescriptionLine = "description: Test\n"
|
||||||
|
TestRunsLine = "runs:\n"
|
||||||
|
TestCompositeUsing = " using: composite\n"
|
||||||
|
TestStepsEmpty = " steps: []\n"
|
||||||
|
TestErrorFormat = "ParseActionYML() error = %v"
|
||||||
|
TestContentsRead = "# contents: read\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Test path constants for template tests.
|
||||||
|
const (
|
||||||
|
TestRepoActionPath = "/repo/action.yml"
|
||||||
|
TestRepoBuildActionPath = "/repo/build/action.yml"
|
||||||
|
TestVersionV123 = "@v1.2.3"
|
||||||
|
)
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ type ActionYMLForJSON struct {
|
|||||||
Outputs map[string]ActionOutputForJSON `json:"outputs,omitempty"`
|
Outputs map[string]ActionOutputForJSON `json:"outputs,omitempty"`
|
||||||
Runs map[string]any `json:"runs"`
|
Runs map[string]any `json:"runs"`
|
||||||
Branding *BrandingForJSON `json:"branding,omitempty"`
|
Branding *BrandingForJSON `json:"branding,omitempty"`
|
||||||
|
Permissions map[string]string `json:"permissions,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ActionInputForJSON represents an input parameter in JSON format.
|
// ActionInputForJSON represents an input parameter in JSON format.
|
||||||
@@ -218,6 +219,7 @@ func (jw *JSONWriter) convertToJSONOutput(action *ActionYML) *JSONOutput {
|
|||||||
Outputs: outputs,
|
Outputs: outputs,
|
||||||
Runs: action.Runs,
|
Runs: action.Runs,
|
||||||
Branding: branding,
|
Branding: branding,
|
||||||
|
Permissions: action.Permissions,
|
||||||
},
|
},
|
||||||
Documentation: DocumentationInfo{
|
Documentation: DocumentationInfo{
|
||||||
Title: action.Name,
|
Title: action.Name,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package internal
|
package internal
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -19,6 +20,7 @@ type ActionYML struct {
|
|||||||
Outputs map[string]ActionOutput `yaml:"outputs"`
|
Outputs map[string]ActionOutput `yaml:"outputs"`
|
||||||
Runs map[string]any `yaml:"runs"`
|
Runs map[string]any `yaml:"runs"`
|
||||||
Branding *Branding `yaml:"branding,omitempty"`
|
Branding *Branding `yaml:"branding,omitempty"`
|
||||||
|
Permissions map[string]string `yaml:"permissions,omitempty"`
|
||||||
// Add more fields as the schema evolves
|
// Add more fields as the schema evolves
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,6 +44,14 @@ type Branding struct {
|
|||||||
|
|
||||||
// ParseActionYML reads and parses action.yml from given path.
|
// ParseActionYML reads and parses action.yml from given path.
|
||||||
func ParseActionYML(path string) (*ActionYML, error) {
|
func ParseActionYML(path string) (*ActionYML, error) {
|
||||||
|
// Parse permissions from header comments FIRST
|
||||||
|
commentPermissions, err := parsePermissionsFromComments(path)
|
||||||
|
if err != nil {
|
||||||
|
// Don't fail if comment parsing fails, just log and continue
|
||||||
|
commentPermissions = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard YAML parsing
|
||||||
f, err := os.Open(path) // #nosec G304 -- path from function parameter
|
f, err := os.Open(path) // #nosec G304 -- path from function parameter
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -55,9 +65,139 @@ func ParseActionYML(path string) (*ActionYML, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Merge permissions: YAML permissions override comment permissions
|
||||||
|
mergePermissions(&a, commentPermissions)
|
||||||
|
|
||||||
return &a, nil
|
return &a, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mergePermissions combines comment and YAML permissions.
|
||||||
|
// YAML permissions take precedence when both exist.
|
||||||
|
func mergePermissions(action *ActionYML, commentPerms map[string]string) {
|
||||||
|
if action.Permissions == nil && commentPerms != nil && len(commentPerms) > 0 {
|
||||||
|
action.Permissions = commentPerms
|
||||||
|
} else if action.Permissions != nil && commentPerms != nil && len(commentPerms) > 0 {
|
||||||
|
// Merge: YAML takes precedence, add missing from comments
|
||||||
|
for key, value := range commentPerms {
|
||||||
|
if _, exists := action.Permissions[key]; !exists {
|
||||||
|
action.Permissions[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePermissionsFromComments extracts permissions from header comments.
|
||||||
|
// Looks for lines like:
|
||||||
|
//
|
||||||
|
// # permissions:
|
||||||
|
// # - contents: read # Required for checking out repository
|
||||||
|
// # contents: read # Alternative format without dash
|
||||||
|
func parsePermissionsFromComments(path string) (map[string]string, error) {
|
||||||
|
file, err := os.Open(path) // #nosec G304 -- path from function parameter
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = file.Close() // Ignore close error in defer
|
||||||
|
}()
|
||||||
|
|
||||||
|
permissions := make(map[string]string)
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
|
||||||
|
inPermissionsBlock := false
|
||||||
|
var expectedItemIndent int
|
||||||
|
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
|
||||||
|
// Stop parsing at first non-comment line
|
||||||
|
if !strings.HasPrefix(trimmed, "#") {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove leading # and spaces
|
||||||
|
content := strings.TrimPrefix(trimmed, "#")
|
||||||
|
content = strings.TrimSpace(content)
|
||||||
|
|
||||||
|
// Check for permissions block start
|
||||||
|
if content == "permissions:" {
|
||||||
|
inPermissionsBlock = true
|
||||||
|
// Calculate expected indent for permission items (after the # and any spaces)
|
||||||
|
// We expect items to be indented relative to the content
|
||||||
|
expectedItemIndent = -1 // Will be set on first item
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse permission entries
|
||||||
|
if inPermissionsBlock && content != "" {
|
||||||
|
shouldBreak := processPermissionEntry(line, content, &expectedItemIndent, permissions)
|
||||||
|
if shouldBreak {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return permissions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePermissionLine extracts key-value from a permission line.
|
||||||
|
// Supports formats:
|
||||||
|
// - "- contents: read # comment"
|
||||||
|
// - "contents: read # comment"
|
||||||
|
func parsePermissionLine(content string) (key, value string, ok bool) {
|
||||||
|
// Remove leading dash if present
|
||||||
|
content = strings.TrimPrefix(content, "-")
|
||||||
|
content = strings.TrimSpace(content)
|
||||||
|
|
||||||
|
// Remove inline comments
|
||||||
|
if idx := strings.Index(content, "#"); idx > 0 {
|
||||||
|
content = strings.TrimSpace(content[:idx])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse key: value
|
||||||
|
parts := strings.SplitN(content, ":", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
key = strings.TrimSpace(parts[0])
|
||||||
|
value = strings.TrimSpace(parts[1])
|
||||||
|
if key != "" && value != "" {
|
||||||
|
return key, value, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// processPermissionEntry processes a single line in the permissions block.
|
||||||
|
// Returns true if parsing should break (dedented out of block), false to continue.
|
||||||
|
func processPermissionEntry(line, content string, expectedItemIndent *int, permissions map[string]string) bool {
|
||||||
|
// Get the indent of the content (after removing #)
|
||||||
|
lineAfterHash := strings.TrimPrefix(line, "#")
|
||||||
|
contentIndent := len(lineAfterHash) - len(strings.TrimLeft(lineAfterHash, " "))
|
||||||
|
|
||||||
|
// Set expected indent on first item
|
||||||
|
if *expectedItemIndent == -1 {
|
||||||
|
*expectedItemIndent = contentIndent
|
||||||
|
}
|
||||||
|
|
||||||
|
// If dedented relative to expected item indent, we've left the permissions block
|
||||||
|
if contentIndent < *expectedItemIndent {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse permission line and add to map if valid
|
||||||
|
if key, value, ok := parsePermissionLine(content); ok {
|
||||||
|
permissions[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// shouldIgnoreDirectory checks if a directory name matches the ignore list.
|
// shouldIgnoreDirectory checks if a directory name matches the ignore list.
|
||||||
func shouldIgnoreDirectory(dirName string, ignoredDirs []string) bool {
|
func shouldIgnoreDirectory(dirName string, ignoredDirs []string) bool {
|
||||||
for _, ignored := range ignoredDirs {
|
for _, ignored := range ignoredDirs {
|
||||||
|
|||||||
@@ -3,23 +3,45 @@ package internal
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const testPermissionWrite = "write"
|
||||||
|
|
||||||
|
// parseActionFromContent creates a temporary action.yml file with the given content and parses it.
|
||||||
|
func parseActionFromContent(t *testing.T, content string) (*ActionYML, error) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
tmpFile, err := os.CreateTemp(t.TempDir(), appconstants.TestActionFilePattern)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Remove(tmpFile.Name()) }()
|
||||||
|
|
||||||
|
if _, err := tmpFile.WriteString(content); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = tmpFile.Close()
|
||||||
|
|
||||||
|
return ParseActionYML(tmpFile.Name())
|
||||||
|
}
|
||||||
|
|
||||||
// createTestDirWithAction creates a directory with an action.yml file and returns both paths.
|
// createTestDirWithAction creates a directory with an action.yml file and returns both paths.
|
||||||
func createTestDirWithAction(t *testing.T, baseDir, dirName, yamlContent string) (string, string) {
|
func createTestDirWithAction(t *testing.T, baseDir, dirName, yamlContent string) (string, string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
dirPath := filepath.Join(baseDir, dirName)
|
dirPath := filepath.Join(baseDir, dirName)
|
||||||
if err := os.Mkdir(dirPath, appconstants.FilePermDir); err != nil { // nolint:gosec
|
if err := os.Mkdir(dirPath, appconstants.FilePermDir); err != nil {
|
||||||
t.Fatalf(testutil.ErrCreateDir(dirName), err)
|
t.Fatalf(testutil.ErrCreateDir(dirName), err)
|
||||||
}
|
}
|
||||||
actionPath := filepath.Join(dirPath, appconstants.ActionFileNameYML)
|
actionPath := filepath.Join(dirPath, appconstants.ActionFileNameYML)
|
||||||
if err := os.WriteFile(
|
if err := os.WriteFile(
|
||||||
actionPath, []byte(yamlContent), appconstants.FilePermDefault,
|
actionPath, []byte(yamlContent), appconstants.FilePermDefault,
|
||||||
); err != nil { // nolint:gosec
|
); err != nil {
|
||||||
t.Fatalf(testutil.ErrCreateFile(dirName+"/action.yml"), err)
|
t.Fatalf(testutil.ErrCreateFile(dirName+"/action.yml"), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,7 +54,7 @@ func createTestFile(t *testing.T, baseDir, fileName, content string) string {
|
|||||||
filePath := filepath.Join(baseDir, fileName)
|
filePath := filepath.Join(baseDir, fileName)
|
||||||
if err := os.WriteFile(
|
if err := os.WriteFile(
|
||||||
filePath, []byte(content), appconstants.FilePermDefault,
|
filePath, []byte(content), appconstants.FilePermDefault,
|
||||||
); err != nil { // nolint:gosec
|
); err != nil {
|
||||||
t.Fatalf(testutil.ErrCreateFile(fileName), err)
|
t.Fatalf(testutil.ErrCreateFile(fileName), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,14 +246,14 @@ func TestDiscoverActionFilesNestedIgnoredDirs(t *testing.T) {
|
|||||||
// action.yml (should be ignored)
|
// action.yml (should be ignored)
|
||||||
|
|
||||||
nodeModulesDir := filepath.Join(tmpDir, appconstants.DirNodeModules, "deep", "nested")
|
nodeModulesDir := filepath.Join(tmpDir, appconstants.DirNodeModules, "deep", "nested")
|
||||||
if err := os.MkdirAll(nodeModulesDir, appconstants.FilePermDir); err != nil { // nolint:gosec
|
if err := os.MkdirAll(nodeModulesDir, appconstants.FilePermDir); err != nil {
|
||||||
t.Fatalf(testutil.ErrCreateDir("nested"), err)
|
t.Fatalf(testutil.ErrCreateDir("nested"), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
nestedAction := filepath.Join(nodeModulesDir, appconstants.ActionFileNameYML)
|
nestedAction := filepath.Join(nodeModulesDir, appconstants.ActionFileNameYML)
|
||||||
if err := os.WriteFile(
|
if err := os.WriteFile(
|
||||||
nestedAction, []byte(appconstants.TestYAMLNested), appconstants.FilePermDefault,
|
nestedAction, []byte(appconstants.TestYAMLNested), appconstants.FilePermDefault,
|
||||||
); err != nil { // nolint:gosec
|
); err != nil {
|
||||||
t.Fatalf(testutil.ErrCreateFile("nested action.yml"), err)
|
t.Fatalf(testutil.ErrCreateFile("nested action.yml"), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,19 +276,19 @@ func TestDiscoverActionFilesNonRecursive(t *testing.T) {
|
|||||||
rootAction := filepath.Join(tmpDir, appconstants.ActionFileNameYML)
|
rootAction := filepath.Join(tmpDir, appconstants.ActionFileNameYML)
|
||||||
if err := os.WriteFile(
|
if err := os.WriteFile(
|
||||||
rootAction, []byte(appconstants.TestYAMLRoot), appconstants.FilePermDefault,
|
rootAction, []byte(appconstants.TestYAMLRoot), appconstants.FilePermDefault,
|
||||||
); err != nil { // nolint:gosec
|
); err != nil {
|
||||||
t.Fatalf(testutil.ErrCreateFile("action.yml"), err)
|
t.Fatalf(testutil.ErrCreateFile("action.yml"), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create subdirectory (should not be searched in non-recursive mode)
|
// Create subdirectory (should not be searched in non-recursive mode)
|
||||||
subDir := filepath.Join(tmpDir, "sub")
|
subDir := filepath.Join(tmpDir, "sub")
|
||||||
if err := os.Mkdir(subDir, appconstants.FilePermDir); err != nil { // nolint:gosec
|
if err := os.Mkdir(subDir, appconstants.FilePermDir); err != nil {
|
||||||
t.Fatalf(testutil.ErrCreateDir("sub"), err)
|
t.Fatalf(testutil.ErrCreateDir("sub"), err)
|
||||||
}
|
}
|
||||||
subAction := filepath.Join(subDir, appconstants.ActionFileNameYML)
|
subAction := filepath.Join(subDir, appconstants.ActionFileNameYML)
|
||||||
if err := os.WriteFile(
|
if err := os.WriteFile(
|
||||||
subAction, []byte(appconstants.TestYAMLSub), appconstants.FilePermDefault,
|
subAction, []byte(appconstants.TestYAMLSub), appconstants.FilePermDefault,
|
||||||
); err != nil { // nolint:gosec
|
); err != nil {
|
||||||
t.Fatalf(testutil.ErrCreateFile("sub/action.yml"), err)
|
t.Fatalf(testutil.ErrCreateFile("sub/action.yml"), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,3 +305,586 @@ func TestDiscoverActionFilesNonRecursive(t *testing.T) {
|
|||||||
t.Errorf("DiscoverActionFiles() = %v, want %v", files[0], rootAction)
|
t.Errorf("DiscoverActionFiles() = %v, want %v", files[0], rootAction)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestParsePermissionsFromComments tests parsing permissions from header comments.
|
||||||
|
func TestParsePermissionsFromComments(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
content string
|
||||||
|
want map[string]string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "single permission with dash format",
|
||||||
|
content: `# yaml-language-server: $schema=https://json.schemastore.org/github-action.json
|
||||||
|
# permissions:
|
||||||
|
# - contents: read # Required for checking out repository
|
||||||
|
name: Test Action`,
|
||||||
|
want: map[string]string{
|
||||||
|
"contents": "read",
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple permissions",
|
||||||
|
content: `# permissions:
|
||||||
|
# - contents: read
|
||||||
|
# - issues: write
|
||||||
|
# - pull-requests: write
|
||||||
|
name: Test Action`,
|
||||||
|
want: map[string]string{
|
||||||
|
"contents": "read",
|
||||||
|
"issues": "write",
|
||||||
|
"pull-requests": "write",
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "permissions without dash",
|
||||||
|
content: `# permissions:
|
||||||
|
# contents: read
|
||||||
|
# issues: write
|
||||||
|
name: Test Action`,
|
||||||
|
want: map[string]string{
|
||||||
|
"contents": "read",
|
||||||
|
"issues": "write",
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no permissions block",
|
||||||
|
content: `# Just a comment
|
||||||
|
name: Test Action`,
|
||||||
|
want: map[string]string{},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "permissions with inline comments",
|
||||||
|
content: `# permissions:
|
||||||
|
# - contents: read # Needed for checkout
|
||||||
|
# - issues: write # To create issues
|
||||||
|
name: Test Action`,
|
||||||
|
want: map[string]string{
|
||||||
|
"contents": "read",
|
||||||
|
"issues": "write",
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty permissions block",
|
||||||
|
content: `# permissions:
|
||||||
|
name: Test Action`,
|
||||||
|
want: map[string]string{},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "permissions with mixed formats",
|
||||||
|
content: `# permissions:
|
||||||
|
# - contents: read
|
||||||
|
# issues: write
|
||||||
|
name: Test Action`,
|
||||||
|
want: map[string]string{
|
||||||
|
"contents": "read",
|
||||||
|
"issues": "write",
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Create temp file
|
||||||
|
tmpFile, err := os.CreateTemp(t.TempDir(), appconstants.TestActionFilePattern)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Remove(tmpFile.Name()) }()
|
||||||
|
|
||||||
|
if _, err := tmpFile.WriteString(tt.content); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = tmpFile.Close()
|
||||||
|
|
||||||
|
got, err := parsePermissionsFromComments(tmpFile.Name())
|
||||||
|
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("parsePermissionsFromComments() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !reflect.DeepEqual(got, tt.want) {
|
||||||
|
t.Errorf("parsePermissionsFromComments() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseActionYMLWithCommentPermissions tests that ParseActionYML includes comment permissions.
|
||||||
|
func TestParseActionYMLWithCommentPermissions(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
content := appconstants.TestPermissionsHeader +
|
||||||
|
"# - contents: read\n" +
|
||||||
|
appconstants.TestActionNameLine +
|
||||||
|
appconstants.TestDescriptionLine +
|
||||||
|
appconstants.TestRunsLine +
|
||||||
|
appconstants.TestCompositeUsing +
|
||||||
|
appconstants.TestStepsEmpty
|
||||||
|
|
||||||
|
action, err := parseActionFromContent(t, content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf(appconstants.TestErrorFormat, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if action.Permissions == nil {
|
||||||
|
t.Fatal("Expected permissions to be parsed from comments")
|
||||||
|
}
|
||||||
|
|
||||||
|
if action.Permissions["contents"] != "read" {
|
||||||
|
t.Errorf("Expected contents: read, got %v", action.Permissions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseActionYMLYAMLPermissionsOverrideComments tests that YAML permissions override comments.
|
||||||
|
func TestParseActionYMLYAMLPermissionsOverrideComments(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
content := appconstants.TestPermissionsHeader +
|
||||||
|
"# - contents: read\n" +
|
||||||
|
"# - issues: write\n" +
|
||||||
|
appconstants.TestActionNameLine +
|
||||||
|
appconstants.TestDescriptionLine +
|
||||||
|
"permissions:\n" +
|
||||||
|
" contents: write # YAML override\n" +
|
||||||
|
appconstants.TestRunsLine +
|
||||||
|
appconstants.TestCompositeUsing +
|
||||||
|
appconstants.TestStepsEmpty
|
||||||
|
|
||||||
|
action, err := parseActionFromContent(t, content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf(appconstants.TestErrorFormat, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// YAML should override comment
|
||||||
|
if action.Permissions["contents"] != testPermissionWrite {
|
||||||
|
t.Errorf(
|
||||||
|
"Expected YAML permissions to override comment permissions, got contents: %v",
|
||||||
|
action.Permissions["contents"],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comment permission should be merged in
|
||||||
|
if action.Permissions["issues"] != testPermissionWrite {
|
||||||
|
t.Errorf(
|
||||||
|
"Expected comment permissions to be merged with YAML permissions, got issues: %v",
|
||||||
|
action.Permissions["issues"],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseActionYMLOnlyYAMLPermissions tests parsing when only YAML permissions exist.
|
||||||
|
func TestParseActionYMLOnlyYAMLPermissions(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
content := appconstants.TestActionNameLine +
|
||||||
|
appconstants.TestDescriptionLine +
|
||||||
|
"permissions:\n" +
|
||||||
|
" contents: read\n" +
|
||||||
|
" issues: write\n" +
|
||||||
|
appconstants.TestRunsLine +
|
||||||
|
appconstants.TestCompositeUsing +
|
||||||
|
appconstants.TestStepsEmpty
|
||||||
|
|
||||||
|
action, err := parseActionFromContent(t, content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf(appconstants.TestErrorFormat, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if action.Permissions == nil {
|
||||||
|
t.Fatal("Expected permissions to be parsed from YAML")
|
||||||
|
}
|
||||||
|
|
||||||
|
if action.Permissions["contents"] != "read" {
|
||||||
|
t.Errorf("Expected contents: read, got %v", action.Permissions)
|
||||||
|
}
|
||||||
|
|
||||||
|
if action.Permissions["issues"] != testPermissionWrite {
|
||||||
|
t.Errorf("Expected issues: write, got %v", action.Permissions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseActionYMLNoPermissions tests parsing when no permissions exist.
|
||||||
|
func TestParseActionYMLNoPermissions(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
content := appconstants.TestActionNameLine +
|
||||||
|
appconstants.TestDescriptionLine +
|
||||||
|
appconstants.TestRunsLine +
|
||||||
|
appconstants.TestCompositeUsing +
|
||||||
|
appconstants.TestStepsEmpty
|
||||||
|
|
||||||
|
action, err := parseActionFromContent(t, content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf(appconstants.TestErrorFormat, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if action.Permissions != nil {
|
||||||
|
t.Errorf("Expected no permissions, got %v", action.Permissions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseActionYMLMalformedYAML tests parsing with malformed YAML.
|
||||||
|
func TestParseActionYMLMalformedYAML(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
content := appconstants.TestActionNameLine +
|
||||||
|
appconstants.TestDescriptionLine +
|
||||||
|
"invalid-yaml: [\n" + // Unclosed bracket
|
||||||
|
" - item"
|
||||||
|
|
||||||
|
_, err := parseActionFromContent(t, content)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Expected error for malformed YAML, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseActionYMLEmptyFile tests parsing an empty file.
|
||||||
|
func TestParseActionYMLEmptyFile(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tmpFile, err := os.CreateTemp(t.TempDir(), appconstants.TestActionFilePattern)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Remove(tmpFile.Name()) }()
|
||||||
|
|
||||||
|
_ = tmpFile.Close()
|
||||||
|
|
||||||
|
_, err = ParseActionYML(tmpFile.Name())
|
||||||
|
// Empty file should return EOF error from YAML parser
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Expected EOF error for empty file, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParsePermissionLineEdgeCases tests edge cases in permission line parsing.
|
||||||
|
func TestParsePermissionLineEdgeCases(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
wantKey string
|
||||||
|
wantValue string
|
||||||
|
wantOK bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "comment at start is parsed",
|
||||||
|
input: "#contents: read",
|
||||||
|
wantKey: "#contents",
|
||||||
|
wantValue: "read",
|
||||||
|
wantOK: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty value after colon",
|
||||||
|
input: "contents:",
|
||||||
|
wantKey: "",
|
||||||
|
wantValue: "",
|
||||||
|
wantOK: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "only spaces after colon",
|
||||||
|
input: "contents: ",
|
||||||
|
wantKey: "",
|
||||||
|
wantValue: "",
|
||||||
|
wantOK: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid with inline comment",
|
||||||
|
input: "contents: read # required",
|
||||||
|
wantKey: "contents",
|
||||||
|
wantValue: "read",
|
||||||
|
wantOK: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid with leading dash",
|
||||||
|
input: "- issues: write",
|
||||||
|
wantKey: "issues",
|
||||||
|
wantValue: "write",
|
||||||
|
wantOK: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no colon",
|
||||||
|
input: "invalid permission line",
|
||||||
|
wantKey: "",
|
||||||
|
wantValue: "",
|
||||||
|
wantOK: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
key, value, ok := parsePermissionLine(tt.input)
|
||||||
|
|
||||||
|
if ok != tt.wantOK {
|
||||||
|
t.Errorf("parsePermissionLine() ok = %v, want %v", ok, tt.wantOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
if key != tt.wantKey {
|
||||||
|
t.Errorf("parsePermissionLine() key = %q, want %q", key, tt.wantKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
if value != tt.wantValue {
|
||||||
|
t.Errorf("parsePermissionLine() value = %q, want %q", value, tt.wantValue)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProcessPermissionEntryIndentationEdgeCases tests indentation scenarios.
|
||||||
|
func TestProcessPermissionEntryIndentationEdgeCases(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
line string
|
||||||
|
content string
|
||||||
|
initialIndent int
|
||||||
|
wantBreak bool
|
||||||
|
wantPermissionsLen int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "first item sets indent",
|
||||||
|
line: appconstants.TestContentsRead,
|
||||||
|
content: "contents: read",
|
||||||
|
initialIndent: -1,
|
||||||
|
wantBreak: false,
|
||||||
|
wantPermissionsLen: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "dedented breaks",
|
||||||
|
line: "# contents: read",
|
||||||
|
content: "contents: read",
|
||||||
|
initialIndent: 2,
|
||||||
|
wantBreak: true,
|
||||||
|
wantPermissionsLen: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "same indent continues",
|
||||||
|
line: "# issues: write",
|
||||||
|
content: "issues: write",
|
||||||
|
initialIndent: 3,
|
||||||
|
wantBreak: false,
|
||||||
|
wantPermissionsLen: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid format skipped",
|
||||||
|
line: "# invalid-line-no-colon",
|
||||||
|
content: "invalid-line-no-colon",
|
||||||
|
initialIndent: 3,
|
||||||
|
wantBreak: false,
|
||||||
|
wantPermissionsLen: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
permissions := make(map[string]string)
|
||||||
|
indent := tt.initialIndent
|
||||||
|
|
||||||
|
shouldBreak := processPermissionEntry(tt.line, tt.content, &indent, permissions)
|
||||||
|
|
||||||
|
if shouldBreak != tt.wantBreak {
|
||||||
|
t.Errorf("processPermissionEntry() shouldBreak = %v, want %v", shouldBreak, tt.wantBreak)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(permissions) != tt.wantPermissionsLen {
|
||||||
|
t.Errorf(
|
||||||
|
"processPermissionEntry() permissions length = %d, want %d",
|
||||||
|
len(permissions),
|
||||||
|
tt.wantPermissionsLen,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParsePermissionsFromCommentsEdgeCases tests edge cases in comment parsing.
|
||||||
|
func TestParsePermissionsFromCommentsEdgeCases(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
content string
|
||||||
|
wantPerms map[string]string
|
||||||
|
wantErr bool
|
||||||
|
description string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "duplicate permissions",
|
||||||
|
content: appconstants.TestPermissionsHeader +
|
||||||
|
appconstants.TestContentsRead +
|
||||||
|
"# contents: write\n",
|
||||||
|
wantPerms: map[string]string{"contents": "write"},
|
||||||
|
wantErr: false,
|
||||||
|
description: "last value wins",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mixed valid and invalid lines",
|
||||||
|
content: appconstants.TestPermissionsHeader +
|
||||||
|
appconstants.TestContentsRead +
|
||||||
|
"# invalid-line-no-value\n" +
|
||||||
|
"# issues: write\n",
|
||||||
|
wantPerms: map[string]string{"contents": "read", "issues": "write"},
|
||||||
|
wantErr: false,
|
||||||
|
description: "invalid lines skipped",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "permissions block ends at non-comment",
|
||||||
|
content: appconstants.TestPermissionsHeader +
|
||||||
|
appconstants.TestContentsRead +
|
||||||
|
appconstants.TestActionNameLine +
|
||||||
|
"# issues: write\n",
|
||||||
|
wantPerms: map[string]string{"contents": "read"},
|
||||||
|
wantErr: false,
|
||||||
|
description: "stops at first non-comment",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "only permissions header",
|
||||||
|
content: appconstants.TestPermissionsHeader +
|
||||||
|
appconstants.TestActionNameLine,
|
||||||
|
wantPerms: map[string]string{},
|
||||||
|
wantErr: false,
|
||||||
|
description: "empty permissions block",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
tmpFile, err := os.CreateTemp(t.TempDir(), "test-*.yml")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Remove(tmpFile.Name()) }()
|
||||||
|
|
||||||
|
if _, err := tmpFile.WriteString(tt.content); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = tmpFile.Close()
|
||||||
|
|
||||||
|
perms, err := parsePermissionsFromComments(tmpFile.Name())
|
||||||
|
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("parsePermissionsFromComments() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !reflect.DeepEqual(perms, tt.wantPerms) {
|
||||||
|
t.Errorf("parsePermissionsFromComments() = %v, want %v (%s)", perms, tt.wantPerms, tt.description)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMergePermissionsEdgeCases tests permission merging edge cases.
|
||||||
|
func TestMergePermissionsEdgeCases(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
yamlPerms map[string]string
|
||||||
|
commentPerms map[string]string
|
||||||
|
wantPerms map[string]string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "both nil",
|
||||||
|
yamlPerms: nil,
|
||||||
|
commentPerms: nil,
|
||||||
|
wantPerms: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "yaml nil, comments empty",
|
||||||
|
yamlPerms: nil,
|
||||||
|
commentPerms: map[string]string{},
|
||||||
|
wantPerms: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "yaml empty, comments nil",
|
||||||
|
yamlPerms: map[string]string{},
|
||||||
|
commentPerms: nil,
|
||||||
|
wantPerms: map[string]string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "yaml has value, comments override",
|
||||||
|
yamlPerms: map[string]string{"contents": "read"},
|
||||||
|
commentPerms: map[string]string{"issues": "write"},
|
||||||
|
wantPerms: map[string]string{"contents": "read", "issues": "write"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
action := &ActionYML{Permissions: tt.yamlPerms}
|
||||||
|
mergePermissions(action, tt.commentPerms)
|
||||||
|
|
||||||
|
if !reflect.DeepEqual(action.Permissions, tt.wantPerms) {
|
||||||
|
t.Errorf("mergePermissions() = %v, want %v", action.Permissions, tt.wantPerms)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDiscoverActionFilesWalkErrors tests error handling during directory walk.
|
||||||
|
func TestDiscoverActionFilesWalkErrors(t *testing.T) {
|
||||||
|
// Test with a path that doesn't exist
|
||||||
|
_, err := DiscoverActionFiles("/nonexistent/path/that/does/not/exist", true, []string{})
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Expected error for nonexistent directory, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test that error message mentions the path
|
||||||
|
if err != nil && !strings.Contains(err.Error(), "/nonexistent/path/that/does/not/exist") {
|
||||||
|
t.Errorf("Expected error to mention path, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWalkFuncErrorHandling tests walkFunc error propagation.
|
||||||
|
func TestWalkFuncErrorHandling(t *testing.T) {
|
||||||
|
walker := &actionFileWalker{
|
||||||
|
ignoredDirs: []string{},
|
||||||
|
actionFiles: []string{},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a valid FileInfo for testing
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
info, err := os.Stat(tmpDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to stat temp dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test with valid directory - should return nil
|
||||||
|
err = walker.walkFunc(tmpDir, info, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("walkFunc() with valid directory should return nil, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test with pre-existing error - should propagate
|
||||||
|
testErr := filepath.SkipDir
|
||||||
|
err = walker.walkFunc(tmpDir, info, testErr)
|
||||||
|
if err != testErr {
|
||||||
|
t.Errorf("walkFunc() should propagate error, got %v, want %v", err, testErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseActionYMLOnlyComments tests file with only comments.
|
||||||
|
func TestParseActionYMLOnlyComments(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
content := "# This is a comment\n" +
|
||||||
|
"# Another comment\n" +
|
||||||
|
appconstants.TestPermissionsHeader +
|
||||||
|
appconstants.TestContentsRead
|
||||||
|
|
||||||
|
_, err := parseActionFromContent(t, content)
|
||||||
|
// File with only comments should return EOF error from YAML parser
|
||||||
|
// (comments are parsed separately, but YAML decoder still needs valid YAML)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Expected EOF error for comment-only file, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
533
internal/template_test.go
Normal file
533
internal/template_test.go
Normal file
@@ -0,0 +1,533 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||||
|
"github.com/ivuorinen/gh-action-readme/internal/git"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newTemplateData creates a TemplateData with common test values.
|
||||||
|
// Pass nil for any field to use defaults or zero values.
|
||||||
|
func newTemplateData(
|
||||||
|
actionName string,
|
||||||
|
version string,
|
||||||
|
useDefaultBranch bool,
|
||||||
|
defaultBranch string,
|
||||||
|
org string,
|
||||||
|
repo string,
|
||||||
|
actionPath string,
|
||||||
|
repoRoot string,
|
||||||
|
) *TemplateData {
|
||||||
|
var actionYML *ActionYML
|
||||||
|
if actionName != "" {
|
||||||
|
actionYML = &ActionYML{Name: actionName}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &TemplateData{
|
||||||
|
ActionYML: actionYML,
|
||||||
|
Config: &AppConfig{
|
||||||
|
Version: version,
|
||||||
|
UseDefaultBranch: useDefaultBranch,
|
||||||
|
},
|
||||||
|
Git: git.RepoInfo{
|
||||||
|
Organization: org,
|
||||||
|
Repository: repo,
|
||||||
|
DefaultBranch: defaultBranch,
|
||||||
|
},
|
||||||
|
ActionPath: actionPath,
|
||||||
|
RepoRoot: repoRoot,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExtractActionSubdirectory tests the extractActionSubdirectory function.
|
||||||
|
func TestExtractActionSubdirectory(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
actionPath string
|
||||||
|
repoRoot string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "subdirectory action",
|
||||||
|
actionPath: "/repo/actions/csharp-build/action.yml",
|
||||||
|
repoRoot: "/repo",
|
||||||
|
want: "actions/csharp-build",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single level subdirectory",
|
||||||
|
actionPath: appconstants.TestRepoBuildActionPath,
|
||||||
|
repoRoot: "/repo",
|
||||||
|
want: "build",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "deeply nested subdirectory",
|
||||||
|
actionPath: "/repo/a/b/c/d/action.yml",
|
||||||
|
repoRoot: "/repo",
|
||||||
|
want: "a/b/c/d",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "root action",
|
||||||
|
actionPath: appconstants.TestRepoActionPath,
|
||||||
|
repoRoot: "/repo",
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty action path",
|
||||||
|
actionPath: "",
|
||||||
|
repoRoot: "/repo",
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty repo root",
|
||||||
|
actionPath: appconstants.TestRepoActionPath,
|
||||||
|
repoRoot: "",
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "both empty",
|
||||||
|
actionPath: "",
|
||||||
|
repoRoot: "",
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got := extractActionSubdirectory(tt.actionPath, tt.repoRoot)
|
||||||
|
|
||||||
|
// Normalize paths for cross-platform compatibility
|
||||||
|
want := filepath.ToSlash(tt.want)
|
||||||
|
got = filepath.ToSlash(got)
|
||||||
|
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("extractActionSubdirectory() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildUsesString tests the buildUsesString function with subdirectory extraction.
|
||||||
|
func TestBuildUsesString(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
td *TemplateData
|
||||||
|
org string
|
||||||
|
repo string
|
||||||
|
version string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "monorepo with subdirectory",
|
||||||
|
td: &TemplateData{
|
||||||
|
ActionPath: "/repo/actions/csharp-build/action.yml",
|
||||||
|
RepoRoot: "/repo",
|
||||||
|
},
|
||||||
|
org: "ivuorinen",
|
||||||
|
repo: "actions",
|
||||||
|
version: "@main",
|
||||||
|
want: "ivuorinen/actions/actions/csharp-build@main",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "root action",
|
||||||
|
td: &TemplateData{
|
||||||
|
ActionPath: appconstants.TestRepoActionPath,
|
||||||
|
RepoRoot: "/repo",
|
||||||
|
},
|
||||||
|
org: "ivuorinen",
|
||||||
|
repo: "my-action",
|
||||||
|
version: "@main",
|
||||||
|
want: "ivuorinen/my-action@main",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty org",
|
||||||
|
td: &TemplateData{
|
||||||
|
ActionPath: appconstants.TestRepoBuildActionPath,
|
||||||
|
RepoRoot: "/repo",
|
||||||
|
},
|
||||||
|
org: "",
|
||||||
|
repo: "actions",
|
||||||
|
version: "@main",
|
||||||
|
want: "your-org/your-action@v1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty repo",
|
||||||
|
td: &TemplateData{
|
||||||
|
ActionPath: appconstants.TestRepoBuildActionPath,
|
||||||
|
RepoRoot: "/repo",
|
||||||
|
},
|
||||||
|
org: "ivuorinen",
|
||||||
|
repo: "",
|
||||||
|
version: "@main",
|
||||||
|
want: "your-org/your-action@v1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing paths in template data",
|
||||||
|
td: &TemplateData{
|
||||||
|
ActionPath: "",
|
||||||
|
RepoRoot: "",
|
||||||
|
},
|
||||||
|
org: "ivuorinen",
|
||||||
|
repo: "actions",
|
||||||
|
version: "@v1",
|
||||||
|
want: "ivuorinen/actions@v1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got := buildUsesString(tt.td, tt.org, tt.repo, tt.version)
|
||||||
|
|
||||||
|
// Normalize paths for cross-platform compatibility
|
||||||
|
want := filepath.ToSlash(tt.want)
|
||||||
|
got = filepath.ToSlash(got)
|
||||||
|
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("buildUsesString() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetActionVersion tests the getActionVersion function with priority logic.
|
||||||
|
func TestGetActionVersion(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
data any
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "config version override",
|
||||||
|
data: newTemplateData("", "v2.0.0", true, "main", "", "", "", ""),
|
||||||
|
want: "v2.0.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "use default branch when enabled",
|
||||||
|
data: newTemplateData("", "", true, "main", "", "", "", ""),
|
||||||
|
want: "main",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "use default branch master",
|
||||||
|
data: newTemplateData("", "", true, "master", "", "", "", ""),
|
||||||
|
want: "master",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fallback to v1 when default branch disabled",
|
||||||
|
data: newTemplateData("", "", false, "main", "", "", "", ""),
|
||||||
|
want: "v1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fallback to v1 when default branch not detected",
|
||||||
|
data: newTemplateData("", "", true, "", "", "", "", ""),
|
||||||
|
want: "v1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fallback to v1 when data is invalid",
|
||||||
|
data: "invalid",
|
||||||
|
want: "v1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fallback to v1 when data is nil",
|
||||||
|
data: nil,
|
||||||
|
want: "v1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got := getActionVersion(tt.data)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("getActionVersion() = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetGitUsesString tests the complete integration of gitUsesString template function.
|
||||||
|
func TestGetGitUsesString(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
data *TemplateData
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "monorepo action with default branch",
|
||||||
|
data: newTemplateData("C# Build", "", true, "main", "ivuorinen", "actions",
|
||||||
|
"/repo/csharp-build/action.yml", "/repo"),
|
||||||
|
want: "ivuorinen/actions/csharp-build@main",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "monorepo action with explicit version",
|
||||||
|
data: newTemplateData("Build Action", "v1.0.0", true, "main", "org", "actions",
|
||||||
|
appconstants.TestRepoBuildActionPath, "/repo"),
|
||||||
|
want: "org/actions/build@v1.0.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "root level action with default branch",
|
||||||
|
data: newTemplateData("My Action", "", true, "develop", "user", "my-action",
|
||||||
|
appconstants.TestRepoActionPath, "/repo"),
|
||||||
|
want: "user/my-action@develop",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "action with use_default_branch disabled",
|
||||||
|
data: newTemplateData("Test Action", "", false, "main", "org", "test",
|
||||||
|
appconstants.TestRepoActionPath, "/repo"),
|
||||||
|
want: "org/test@v1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got := getGitUsesString(tt.data)
|
||||||
|
|
||||||
|
// Normalize paths for cross-platform compatibility
|
||||||
|
want := filepath.ToSlash(tt.want)
|
||||||
|
got = filepath.ToSlash(got)
|
||||||
|
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("getGitUsesString() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFormatVersion tests the formatVersion function.
|
||||||
|
func TestFormatVersion(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
version string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty version",
|
||||||
|
version: "",
|
||||||
|
want: "@v1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "whitespace only version",
|
||||||
|
version: " ",
|
||||||
|
want: "@v1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "version without @",
|
||||||
|
version: "v1.2.3",
|
||||||
|
want: appconstants.TestVersionV123,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "version with @",
|
||||||
|
version: appconstants.TestVersionV123,
|
||||||
|
want: appconstants.TestVersionV123,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "main branch",
|
||||||
|
version: "main",
|
||||||
|
want: "@main",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "version with @ and spaces",
|
||||||
|
version: " @v2.0.0 ",
|
||||||
|
want: "@v2.0.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sha version",
|
||||||
|
version: "abc123",
|
||||||
|
want: "@abc123",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got := formatVersion(tt.version)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("formatVersion(%q) = %q, want %q", tt.version, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildTemplateData tests the BuildTemplateData function.
|
||||||
|
func TestBuildTemplateData(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
action *ActionYML
|
||||||
|
config *AppConfig
|
||||||
|
repoRoot string
|
||||||
|
actionPath string
|
||||||
|
wantOrg string
|
||||||
|
wantRepo string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "basic action with config overrides",
|
||||||
|
action: &ActionYML{
|
||||||
|
Name: "Test Action",
|
||||||
|
Description: "Test description",
|
||||||
|
},
|
||||||
|
config: &AppConfig{
|
||||||
|
Organization: "testorg",
|
||||||
|
Repository: "testrepo",
|
||||||
|
},
|
||||||
|
repoRoot: ".",
|
||||||
|
actionPath: "action.yml",
|
||||||
|
wantOrg: "testorg",
|
||||||
|
wantRepo: "testrepo",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "action without config overrides",
|
||||||
|
action: &ActionYML{
|
||||||
|
Name: "Another Action",
|
||||||
|
Description: "Another description",
|
||||||
|
},
|
||||||
|
config: &AppConfig{},
|
||||||
|
repoRoot: ".",
|
||||||
|
actionPath: "action.yml",
|
||||||
|
wantOrg: "",
|
||||||
|
wantRepo: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "action with dependency analysis enabled",
|
||||||
|
action: &ActionYML{
|
||||||
|
Name: "Dep Action",
|
||||||
|
Description: "Action with deps",
|
||||||
|
},
|
||||||
|
config: &AppConfig{
|
||||||
|
Organization: "deporg",
|
||||||
|
Repository: "deprepo",
|
||||||
|
AnalyzeDependencies: true,
|
||||||
|
},
|
||||||
|
repoRoot: ".",
|
||||||
|
actionPath: "../testdata/composite-action/action.yml",
|
||||||
|
wantOrg: "deporg",
|
||||||
|
wantRepo: "deprepo",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
data := BuildTemplateData(tt.action, tt.config, tt.repoRoot, tt.actionPath)
|
||||||
|
assertTemplateData(t, data, tt.action, tt.config, tt.wantOrg, tt.wantRepo)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertTemplateData(
|
||||||
|
t *testing.T,
|
||||||
|
data *TemplateData,
|
||||||
|
action *ActionYML,
|
||||||
|
config *AppConfig,
|
||||||
|
wantOrg, wantRepo string,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if data == nil {
|
||||||
|
t.Fatal("BuildTemplateData() returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if data.ActionYML != action {
|
||||||
|
t.Error("BuildTemplateData() did not preserve ActionYML")
|
||||||
|
}
|
||||||
|
|
||||||
|
if data.Config != config {
|
||||||
|
t.Error("BuildTemplateData() did not preserve Config")
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.Organization != "" && data.Git.Organization != wantOrg {
|
||||||
|
t.Errorf("BuildTemplateData() Git.Organization = %q, want %q", data.Git.Organization, wantOrg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.Repository != "" && data.Git.Repository != wantRepo {
|
||||||
|
t.Errorf("BuildTemplateData() Git.Repository = %q, want %q", data.Git.Repository, wantRepo)
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.AnalyzeDependencies && data.Dependencies == nil {
|
||||||
|
t.Error("BuildTemplateData() expected Dependencies to be set when AnalyzeDependencies is true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAnalyzeDependencies tests the analyzeDependencies function.
|
||||||
|
func TestAnalyzeDependencies(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
actionPath string
|
||||||
|
config *AppConfig
|
||||||
|
expectNil bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid composite action without GitHub token",
|
||||||
|
actionPath: "../../testdata/analyzer/composite-action.yml",
|
||||||
|
config: &AppConfig{},
|
||||||
|
expectNil: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nonexistent action file",
|
||||||
|
actionPath: "../../testdata/analyzer/nonexistent.yml",
|
||||||
|
config: &AppConfig{},
|
||||||
|
expectNil: false, // Should return empty slice, not nil
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "docker action without token",
|
||||||
|
actionPath: "../../testdata/analyzer/docker-action.yml",
|
||||||
|
config: &AppConfig{},
|
||||||
|
expectNil: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "javascript action without token",
|
||||||
|
actionPath: "../../testdata/analyzer/javascript-action.yml",
|
||||||
|
config: &AppConfig{},
|
||||||
|
expectNil: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid yaml file",
|
||||||
|
actionPath: "../../testdata/analyzer/invalid.yml",
|
||||||
|
config: &AppConfig{},
|
||||||
|
expectNil: false, // Should gracefully handle errors and return empty slice
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
gitInfo := git.RepoInfo{
|
||||||
|
Organization: "testorg",
|
||||||
|
Repository: "testrepo",
|
||||||
|
}
|
||||||
|
|
||||||
|
result := analyzeDependencies(tt.actionPath, tt.config, gitInfo)
|
||||||
|
|
||||||
|
if tt.expectNil && result != nil {
|
||||||
|
t.Errorf("analyzeDependencies() expected nil, got %v", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !tt.expectNil && result == nil {
|
||||||
|
t.Error("analyzeDependencies() returned nil, expected non-nil slice")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,24 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{if and .Permissions (gt (len .Permissions) 0)}}
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
This action requires the following permissions:
|
||||||
|
|
||||||
|
{{range $key, $value := .Permissions}}
|
||||||
|
- **`{{$key}}`**: `{{$value}}`
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
**Usage in workflow:**
|
||||||
|
```yaml
|
||||||
|
permissions:
|
||||||
|
{{- range $key, $value := .Permissions}}
|
||||||
|
{{$key}}: {{$value}}
|
||||||
|
{{- end}}
|
||||||
|
```
|
||||||
|
{{end}}
|
||||||
|
|
||||||
## Example
|
## Example
|
||||||
|
|
||||||
See the [action.yml](./action.yml) for a full reference.
|
See the [action.yml](./action.yml) for a full reference.
|
||||||
|
|||||||
@@ -45,6 +45,26 @@ jobs:
|
|||||||
{{- end}}
|
{{- end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Permissions}}
|
||||||
|
## 🔐 Permissions
|
||||||
|
|
||||||
|
This action requires the following permissions:
|
||||||
|
|
||||||
|
| Permission | Access Level |
|
||||||
|
|------------|--------------|
|
||||||
|
{{- range $key, $value := .Permissions}}
|
||||||
|
| `{{$key}}` | `{{$value}}` |
|
||||||
|
{{- end}}
|
||||||
|
|
||||||
|
**Usage in workflow:**
|
||||||
|
```yaml
|
||||||
|
permissions:
|
||||||
|
{{- range $key, $value := .Permissions}}
|
||||||
|
{{$key}}: {{$value}}
|
||||||
|
{{- end}}
|
||||||
|
```
|
||||||
|
{{end}}
|
||||||
|
|
||||||
## 💡 Examples
|
## 💡 Examples
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
|
|||||||
@@ -60,6 +60,24 @@ steps:
|
|||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{if and .Permissions (gt (len .Permissions) 0)}}
|
||||||
|
### Required Permissions
|
||||||
|
|
||||||
|
{{range $key, $value := .Permissions}}
|
||||||
|
#### `{{$key}}`
|
||||||
|
- **Access Level**: `{{$value}}`
|
||||||
|
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
**GitHub Actions Workflow:**
|
||||||
|
```yaml
|
||||||
|
permissions:
|
||||||
|
{{- range $key, $value := .Permissions}}
|
||||||
|
{{$key}}: {{$value}}
|
||||||
|
{{- end}}
|
||||||
|
```
|
||||||
|
{{end}}
|
||||||
|
|
||||||
## Usage Examples
|
## Usage Examples
|
||||||
|
|
||||||
### Basic Example
|
### Basic Example
|
||||||
|
|||||||
@@ -28,6 +28,14 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{if and .Permissions (gt (len .Permissions) 0)}}
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
{{range $key, $value := .Permissions}}
|
||||||
|
- `{{$key}}`: `{{$value}}`
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|||||||
@@ -112,6 +112,38 @@ This action provides the following outputs that can be used in subsequent workfl
|
|||||||
```
|
```
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Permissions}}
|
||||||
|
## 🔐 Required Permissions
|
||||||
|
|
||||||
|
This action requires specific GitHub permissions to function correctly. Ensure your workflow includes these permissions:
|
||||||
|
|
||||||
|
| Permission | Access Level | Description |
|
||||||
|
|------------|--------------|-------------|
|
||||||
|
{{- range $key, $value := .Permissions}}
|
||||||
|
| `{{$key}}` | `{{$value}}` | Required for action operation |
|
||||||
|
{{- end}}
|
||||||
|
|
||||||
|
### How to Set Permissions
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: My Workflow
|
||||||
|
on: [push]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
{{- range $key, $value := .Permissions}}
|
||||||
|
{{$key}}: {{$value}}
|
||||||
|
{{- end}}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: {{gitUsesString .}}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** If your workflow doesn't specify permissions, GitHub uses default permissions which may not include all required permissions above.
|
||||||
|
{{end}}
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
### Basic Usage
|
### Basic Usage
|
||||||
|
|||||||
3
testdata/example-action/action.yml
vendored
3
testdata/example-action/action.yml
vendored
@@ -1,3 +1,6 @@
|
|||||||
|
# yaml-language-server: $schema=https://json.schemastore.org/github-action.json
|
||||||
|
# permissions:
|
||||||
|
# - contents: read # Required for checking out repository
|
||||||
---
|
---
|
||||||
name: Example Action
|
name: Example Action
|
||||||
description: 'Test Action for gh-action-readme'
|
description: 'Test Action for gh-action-readme'
|
||||||
|
|||||||
Reference in New Issue
Block a user