Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,12 @@ actionlint: build
@echo "Validating workflows with actionlint..."
./$(BINARY_NAME) compile --actionlint

# Run lock-file-only lint using gh aw lint
.PHONY: lint-lock
lint-lock: build
@echo "Linting committed lock files with gh aw lint..."
./$(BINARY_NAME) lint

# Format code
.PHONY: fmt
fmt: fmt-go fmt-cjs fmt-json
Expand Down Expand Up @@ -758,6 +764,11 @@ agent-finish: deps-dev fmt lint build build-wasm test-all fix recompile dependab
agent-report-progress: build fmt test-unit
@echo "Pre-PR validation passed. Safe to call report_progress."

# Extended pre-PR gate with lock-file-only linting.
.PHONY: agent-report-progress-lint
agent-report-progress-lint: agent-report-progress lint-lock
@echo "Pre-PR validation + lock-file lint passed. Safe to call report_progress."

# Help target
.PHONY: help
help:
Expand Down Expand Up @@ -813,6 +824,7 @@ help:
@echo " security-gosec - Run gosec Go security scanner"
@echo " security-govulncheck - Run govulncheck for known vulnerabilities"
@echo " actionlint - Validate workflows with actionlint (depends on build)"
@echo " lint-lock - Run lock-file-only lint with gh aw lint (depends on build)"
@echo " validate-workflows - Validate compiled workflow lock files (depends on build)"
@echo " install - Install binary locally"
@echo " sync-action-pins - Sync actions-lock.json from .github/aw to pkg/actionpins/data and pkg/workflow/data (runs automatically during build)"
Expand All @@ -832,5 +844,6 @@ help:

@echo " agent-finish - Complete validation sequence (build, test, fix, recompile, fmt, lint, security-scan)"
@echo " agent-report-progress - Lightweight pre-PR gate: build + fmt + test-unit (<30s)"
@echo " agent-report-progress-lint - Pre-PR gate + gh aw lint lock-file check"
@echo " sbom - Generate SBOM in SPDX and CycloneDX formats (requires syft)"
@echo " help - Show this help message"
3 changes: 3 additions & 0 deletions cmd/gh-aw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -765,6 +765,7 @@ Use "` + string(constants.CLIExtensionPrefix) + ` help all" to show help for all
projectCmd := cli.NewProjectCommand()
checksCmd := cli.NewChecksCommand()
validateCmd := cli.NewValidateCommand(validateEngine)
lintCmd := cli.NewLintCommand()
domainsCmd := cli.NewDomainsCommand()
experimentsCmd := cli.NewExperimentsCommand()

Expand All @@ -782,6 +783,7 @@ Use "` + string(constants.CLIExtensionPrefix) + ` help all" to show help for all
// Development Commands
compileCmd.GroupID = "development"
validateCmd.GroupID = "development"
lintCmd.GroupID = "development"
mcpCmd.GroupID = "development"
fixCmd.GroupID = "development"
domainsCmd.GroupID = "development"
Expand Down Expand Up @@ -836,6 +838,7 @@ Use "` + string(constants.CLIExtensionPrefix) + ` help all" to show help for all
rootCmd.AddCommand(secretsCmd)
rootCmd.AddCommand(fixCmd)
rootCmd.AddCommand(validateCmd)
rootCmd.AddCommand(lintCmd)
rootCmd.AddCommand(completionCmd)
rootCmd.AddCommand(hashCmd)
rootCmd.AddCommand(projectCmd)
Expand Down
15 changes: 15 additions & 0 deletions docs/src/content/docs/setup/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,21 @@ gh aw validate --engine copilot # Override AI engine

All linters (`zizmor`, `actionlint`, `poutine`), `--validate`, and `--no-emit` are always-on defaults and cannot be disabled. Accepts the same workflow ID format as `compile`.

#### `lint`

Lint existing `.lock.yml` workflow files from disk with actionlint only. This command does not recompile Markdown workflows, and skips `zizmor`/`poutine`.

```bash wrap
gh aw lint # Lint all .github/workflows/*.lock.yml
gh aw lint .github/workflows/foo.lock.yml # Lint a specific lock file
gh aw lint --dir .github/workflows # Lint all lock files in a directory
gh aw lint --shellcheck --pyflakes # Enable actionlint script integrations
```

**Options:** `--dir/-d`, `--shellcheck`, `--pyflakes`

By default, shellcheck and pyflakes integrations are disabled to reduce noise for generated `run:` scripts. Built-in actionlint ignore patterns cover gh-aw-specific extensions such as `job.workflow_*` context properties and the `copilot-requests` permission scope.

### Testing

#### `trial`
Expand Down
73 changes: 60 additions & 13 deletions pkg/cli/actionlint.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,30 @@ var actionlintLog = logger.New("cli:actionlint")
// actionlintVersion caches the actionlint version to avoid repeated Docker calls
var actionlintVersion string

// actionlintRunOptions configures optional actionlint integrations and ignores.
type actionlintRunOptions struct {
IncludeShellcheck bool
IncludePyflakes bool
// IgnorePatterns contains regular expressions passed to actionlint via
// repeated -ignore flags to suppress known false positives.
IgnorePatterns []string
}

// buildActionlintIntegrationStatus returns a human-readable description of the
// shellcheck/pyflakes integration state for actionlint execution messages.
func buildActionlintIntegrationStatus(includeShellcheck bool, includePyflakes bool) string {
switch {
case includeShellcheck && includePyflakes:
return "with shellcheck/pyflakes"
case includeShellcheck:
return "with shellcheck only"
case includePyflakes:
return "with pyflakes only"
default:
return "without shellcheck/pyflakes"
}
}

// getActionlintDocsURL returns the documentation URL for a given actionlint error kind
// Error kinds map to documentation anchors at https://github.com/rhysd/actionlint/blob/main/docs/checks.md
func getActionlintDocsURL(kind string) string {
Expand Down Expand Up @@ -144,8 +168,9 @@ func displayActionlintSummary() {
fmt.Fprintf(os.Stderr, "\n%s\n", separator)
}

// getActionlintVersion fetches and caches the actionlint version from Docker
func getActionlintVersion() (string, error) {
// getActionlintVersion fetches and caches the actionlint version from Docker.
// The provided context allows caller-driven cancellation.
func getActionlintVersion(ctx context.Context) (string, error) {
// Return cached version if already fetched
if actionlintVersion != "" {
return actionlintVersion, nil
Expand All @@ -154,11 +179,11 @@ func getActionlintVersion() (string, error) {
actionlintLog.Print("Fetching actionlint version from Docker")

// Run docker command to get version with a 30 second timeout
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
versionCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()

cmd := exec.CommandContext(
ctx,
versionCtx,
"docker",
"run",
"--rm",
Expand All @@ -185,17 +210,24 @@ func getActionlintVersion() (string, error) {
return version, nil
}

// runActionlintOnFiles runs the actionlint linter on one or more .lock.yml files using Docker
func runActionlintOnFiles(lockFiles []string, verbose bool, strict bool) error {
// runActionlintOnFiles runs the actionlint linter on one or more .lock.yml files using Docker.
// The provided context allows caller-driven cancellation.
func runActionlintOnFiles(ctx context.Context, lockFiles []string, verbose bool, strict bool) error {
return runActionlintOnFilesWithOptions(ctx, lockFiles, verbose, strict, actionlintRunOptions{
IncludeShellcheck: true,
IncludePyflakes: true,
})
}

func runActionlintOnFilesWithOptions(ctx context.Context, lockFiles []string, verbose bool, strict bool, options actionlintRunOptions) error {
if len(lockFiles) == 0 {
return nil
}

actionlintLog.Printf("Running actionlint on %d file(s): %v (verbose=%t, strict=%t)", len(lockFiles), lockFiles, verbose, strict)

// Display actionlint version on first use
if actionlintVersion == "" {
version, err := getActionlintVersion()
version, err := getActionlintVersion(ctx)
if err != nil {
// Log error but continue - version display is not critical
actionlintLog.Printf("Could not fetch actionlint version: %v", err)
Expand Down Expand Up @@ -224,7 +256,7 @@ func runActionlintOnFiles(lockFiles []string, verbose bool, strict bool) error {
// docker run --rm -v "$(pwd)":/workdir -w /workdir rhysd/actionlint:latest -format '{{json .}}' <file1> <file2> ...
// Adjust timeout based on number of files (1 minute per file, minimum 5 minutes)
timeoutDuration := time.Duration(max(5, len(lockFiles))) * time.Minute
ctx, cancel := context.WithTimeout(context.Background(), timeoutDuration)
runCtx, cancel := context.WithTimeout(ctx, timeoutDuration)
defer cancel()

// Build Docker command arguments
Expand All @@ -236,15 +268,27 @@ func runActionlintOnFiles(lockFiles []string, verbose bool, strict bool) error {
"rhysd/actionlint:latest",
"-format", "{{json .}}",
}
if !options.IncludeShellcheck {
// Empty value disables shellcheck integration in actionlint.
dockerArgs = append(dockerArgs, "-shellcheck=")
}
if !options.IncludePyflakes {
// Empty value disables pyflakes integration in actionlint.
dockerArgs = append(dockerArgs, "-pyflakes=")
}
for _, ignorePattern := range options.IgnorePatterns {
dockerArgs = append(dockerArgs, "-ignore", ignorePattern)
}
dockerArgs = append(dockerArgs, relPaths...)

cmd := exec.CommandContext(ctx, "docker", dockerArgs...)
cmd := exec.CommandContext(runCtx, "docker", dockerArgs...)

// Always show that actionlint is running (regular verbosity)
integrationStatus := buildActionlintIntegrationStatus(options.IncludeShellcheck, options.IncludePyflakes)
if len(lockFiles) == 1 {
fmt.Fprintf(os.Stderr, "%s\n", console.FormatInfoMessage("Running actionlint (includes shellcheck & pyflakes) on "+relPaths[0]))
fmt.Fprintf(os.Stderr, "%s\n", console.FormatInfoMessage("Running actionlint ("+integrationStatus+") on "+relPaths[0]))
} else {
fmt.Fprintf(os.Stderr, "%s\n", console.FormatInfoMessage(fmt.Sprintf("Running actionlint (includes shellcheck & pyflakes) on %d files", len(lockFiles))))
fmt.Fprintf(os.Stderr, "%s\n", console.FormatInfoMessage(fmt.Sprintf("Running actionlint (%s) on %d files", integrationStatus, len(lockFiles))))
}

// In verbose mode, also show the command that users can run directly
Expand All @@ -263,7 +307,7 @@ func runActionlintOnFiles(lockFiles []string, verbose bool, strict bool) error {
err = cmd.Run()

// Check for timeout
if ctx.Err() == context.DeadlineExceeded {
if runCtx.Err() == context.DeadlineExceeded {
fileList := "files"
if len(lockFiles) == 1 {
fileList = filepath.Base(lockFiles[0])
Expand All @@ -273,6 +317,9 @@ func runActionlintOnFiles(lockFiles []string, verbose bool, strict bool) error {
}
return fmt.Errorf("actionlint timed out after %d minutes on %s - this may indicate a Docker or network issue", int(timeoutDuration.Minutes()), fileList)
}
if runCtx.Err() == context.Canceled {
return fmt.Errorf("actionlint was canceled before completion (for example by Ctrl+C or caller cancellation)")
}

// Track workflows in statistics (count number of files validated)
if actionlintStats != nil {
Expand Down
44 changes: 43 additions & 1 deletion pkg/cli/actionlint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
package cli

import (
"context"
"testing"

"github.com/github/gh-aw/pkg/testutil"
Expand Down Expand Up @@ -125,7 +126,7 @@ func TestGetActionlintVersion(t *testing.T) {
defer func() { actionlintVersion = original }()

actionlintVersion = "1.7.9"
version, err := getActionlintVersion()
version, err := getActionlintVersion(context.Background())
require.NoError(t, err, "should not error when version is cached")
assert.Equal(t, "1.7.9", version, "should return cached version")
}
Expand Down Expand Up @@ -324,3 +325,44 @@ func TestGetActionlintDocsURL(t *testing.T) {
})
}
}

func TestBuildActionlintIntegrationStatus(t *testing.T) {
tests := []struct {
name string
includeShellcheck bool
includePyflakes bool
expected string
}{
{
name: "both integrations enabled",
includeShellcheck: true,
includePyflakes: true,
expected: "with shellcheck/pyflakes",
},
{
name: "only shellcheck enabled",
includeShellcheck: true,
includePyflakes: false,
expected: "with shellcheck only",
},
{
name: "only pyflakes enabled",
includeShellcheck: false,
includePyflakes: true,
expected: "with pyflakes only",
},
{
name: "both integrations disabled",
includeShellcheck: false,
includePyflakes: false,
expected: "without shellcheck/pyflakes",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := buildActionlintIntegrationStatus(tt.includeShellcheck, tt.includePyflakes)
assert.Equal(t, tt.expected, result, "integration status should match")
})
}
}
5 changes: 4 additions & 1 deletion pkg/cli/compile_external_tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
package cli

import (
"context"
"fmt"
"os"

Expand All @@ -34,7 +35,9 @@ var compileExternalToolsLog = logger.New("cli:compile_external_tools")
// RunActionlintOnFiles runs actionlint on multiple lock files in a single batch.
// This is more efficient than running actionlint once per file.
func RunActionlintOnFiles(lockFiles []string, verbose bool, strict bool) error {
return runBatchLockFileTool("actionlint", lockFiles, verbose, strict, runActionlintOnFiles)
return runBatchLockFileTool("actionlint", lockFiles, verbose, strict, func(files []string, runVerbose bool, runStrict bool) error {
return runActionlintOnFiles(context.Background(), files, runVerbose, runStrict)
})
}

// RunZizmorOnFiles runs zizmor on multiple lock files in a single batch.
Expand Down
5 changes: 3 additions & 2 deletions pkg/cli/compile_validation.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cli

import (
"context"
"errors"
"fmt"
"os"
Expand Down Expand Up @@ -90,7 +91,7 @@ func CompileWorkflowWithValidation(compiler *workflow.Compiler, filePath string,
// Run actionlint on the generated lock file if requested
// Note: For batch processing, use RunActionlintOnFiles instead
if runActionlintPerFile {
if err := runActionlintOnFiles([]string{lockFile}, verbose, strict); err != nil {
if err := runActionlintOnFiles(context.Background(), []string{lockFile}, verbose, strict); err != nil {
return fmt.Errorf("actionlint linter failed: %w", err)
}
}
Expand Down Expand Up @@ -158,7 +159,7 @@ func CompileWorkflowDataWithValidation(compiler *workflow.Compiler, workflowData
// Run actionlint on the generated lock file if requested
// Note: For batch processing, use RunActionlintOnFiles instead
if runActionlintPerFile {
if err := runActionlintOnFiles([]string{lockFile}, verbose, strict); err != nil {
if err := runActionlintOnFiles(context.Background(), []string{lockFile}, verbose, strict); err != nil {
return fmt.Errorf("actionlint linter failed: %w", err)
}
}
Expand Down
Loading