diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b042dfce5..deaf4c6d5 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -50,7 +50,7 @@ jobs: - name: Dog fooding 🐶 run: | echo "::add-matcher::.github/actionlint-matcher.json" - ./actionlint -color + ./actionlint -color -input-format=workflow - uses: codecov/codecov-action@v3 with: env_vars: OS @@ -116,6 +116,6 @@ jobs: run: docker container run --mount type=bind,source="$(pwd)",target=/mnt/app --workdir /mnt/app - -- ${{ steps.image.outputs.digest }} -color -verbose + -- ${{ steps.image.outputs.digest }} -color -verbose -input-format=workflow - name: Lint Dockerfile with hadolint run: docker run --rm -i hadolint/hadolint hadolint --ignore DL3018 --strict-labels - < Dockerfile diff --git a/ast.go b/ast.go index bfd86df5f..e92cfffe4 100644 --- a/ast.go +++ b/ast.go @@ -422,9 +422,9 @@ func (e *ExecRun) Kind() ExecKind { return ExecKindRun } -// Input is an input field for running an action. +// StepInput is an input field for running an action. // https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions#jobsjob_idstepswith -type Input struct { +type StepInput struct { // Name is a name of the input. Name *String // Value is a value of the input. @@ -437,7 +437,7 @@ type ExecAction struct { // https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions#jobsjob_idstepsuses Uses *String // Inputs represents inputs to the action to execute in 'with' section. Keys are in lower case since they are case-insensitive. - Inputs map[string]*Input + Inputs map[string]*StepInput // Entrypoint represents optional 'entrypoint' field in 'with' section. Nil field means nothing specified // https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions#jobsjob_idstepswithentrypoint Entrypoint *String @@ -897,6 +897,132 @@ type Workflow struct { Jobs map[string]*Job } +// ActionInput is an input field to define the parameters that an action accepts. +// https://docs.github.com/en/actions/creating-actions/metadata-syntax-for-github-actions#inputs +// TODO This kinda duplicates `ActionMetadataInput` from `action_metadata.go`. Merge them? +type ActionInput struct { + // Description is the description of the input. + Description *String + // Required is a flag to show if the input is required or not. + Required *Bool + // Default is a default value of the input. It can be nil when no default value is specified. + Default *String + // DeprecationMessage is a message to show when the input is deprecated. + DeprecationMessage *String + // Pos is a position in source. + Pos *Pos +} + +// ActionOutput is an output field to that is set by this action. +// https://docs.github.com/en/actions/creating-actions/metadata-syntax-for-github-actions#outputs-for-docker-container-and-javascript-actions +// https://docs.github.com/en/actions/creating-actions/metadata-syntax-for-github-actions#outputs-for-composite-actions +type ActionOutput struct { + // Description is the description of the input. + Description *String + // Value contains the expression that defions the value; for composite action only (nil otherwise) + Value *String + // Pos is a position in source. + Pos *Pos +} + +// Branding defines what badges to be shown in GitHub Marketplace for actions. +// https://docs.github.com/en/actions/creating-actions/metadata-syntax-for-github-actions#branding +type Branding struct { + // Color defines the background color of the badge. + Color *String + // Icon to use + Icon *String +} + +// ActionKind is kind of how the action is executed (JavaScript, Docker or Composite) +type ActionKind uint8 + +const ( + // ActionKindJavascript is kind for actions implemented as node.js scripts + ActionKindJavascript ActionKind = iota + // ActionKindDockerContainer is kind for actions implemented as docker images + ActionKindDockerContainer + // ActionKindComposite is kind for actions implemented as composite actions (sequence of steps) + ActionKindComposite +) + +// ActionRuns is an interface how the action is executed. Action can be executed as JavaScript, Docker or composite steps. +type ActionRuns interface { + // Kind returns kind of the Action run + Kind() ActionKind +} + +// DockerContainerRuns defines how to run an action implemented as docker container +// https://docs.github.com/en/actions/creating-actions/metadata-syntax-for-github-actions#runs-for-docker-container-actions +type DockerContainerRuns struct { + // Image specifies the container image to use + Image *String + // PreEntrypoint specifices an entrypoint to run before the main script + PreEntrypoint *String + // Entrypoint specifices an entrypoint to run as main action + Entrypoint *String + // Entrypoint specifices an entrypoint to run as main action + Args []*String + // Inputs is a mapping of environment variables to pass to the container + Env *Env + // PostEntrypoint specifices an entrypoint to run at the end of the job + PostEntrypoint *String +} + +func (e *DockerContainerRuns) Kind() ActionKind { + return ActionKind(ActionKindDockerContainer) +} + +type JavaScriptRuns struct { + // https://docs.github.com/en/actions/creating-actions/metadata-syntax-for-github-actions#runs-for-javascript-actions + // Using specifies the specific node runtime (node18, node20 ..) + Using *String + // Main specifies the entrypoint of the action. + Main *String + // Pre specifices an entrypoint to run before the main script + Pre *String + // PreIf defines an expression whether the pre script should be run + PreIf *String + // Post specifices an entrypoint to run at the end of the job + Post *String + // PostIf specifies an expression whether the post script should be run + PostIf *String +} + +func (e *JavaScriptRuns) Kind() ActionKind { + return ActionKind(ActionKindJavascript) +} + +// CompositeRuns is configuration how to run composite action at the step. +// https://docs.github.com/en/actions/creating-actions/metadata-syntax-for-github-actions#runs-for-composite-actions +type CompositeRuns struct { + // Steps specifies the steps to run (as in workflow job steps) + Steps []*Step +} + +func (e *CompositeRuns) Kind() ActionKind { + return ActionKind(ActionKindComposite) +} + +// Action is root of action syntax tree, which represents one action metadata file. +// https://docs.github.com/en/actions/creating-actions/metadata-syntax-for-github-actions +type Action struct { + // Name is the name of the action. + Name *String + // Author is name of the action's author. This field can be nil when user didn't specify it. + Author *String + // Description is the description of the action. + Description *String + // Inputs is a mapping from the input ID to input attributes . This field can be nil when user didn't specify it. + Inputs map[string]*ActionInput + // Outputs is list of outputs of the action. This field can be nil when user didn't specify it. + Outputs map[string]*ActionOutput + // Branding defines what badges to be shown in GitHub Marketplace. + Branding *Branding + // Runs specifies how the action is executed (via JavaScript, Docker or composite steps) + Runs ActionRuns +} + // FindWorkflowCallEvent returns workflow_call event node if exists func (w *Workflow) FindWorkflowCallEvent() (*WorkflowCallEvent, bool) { for _, e := range w.On { diff --git a/command.go b/command.go index ffac07831..b89dbe1fa 100644 --- a/command.go +++ b/command.go @@ -4,6 +4,7 @@ import ( "flag" "fmt" "io" + "os" "runtime" "runtime/debug" ) @@ -77,6 +78,15 @@ type Command struct { Stderr io.Writer } +func isDir(path string) bool { + // use a switch to make it a bit cleaner + fi, err := os.Stat(path) + if err != nil { + return false + } + return fi.IsDir() +} + func (cmd *Command) runLinter(args []string, opts *LinterOptions, initConfig bool) ([]*Error, error) { l, err := NewLinter(cmd.Stdout, opts) if err != nil { @@ -91,6 +101,10 @@ func (cmd *Command) runLinter(args []string, opts *LinterOptions, initConfig boo return l.LintRepository(".") } + if len(args) == 1 && isDir(args[0]) { + return l.LintDirInRepository(args[0]) + } + if len(args) == 1 && args[0] == "-" { b, err := io.ReadAll(cmd.Stdin) if err != nil { @@ -142,6 +156,7 @@ func (cmd *Command) Main(args []string) int { flags.BoolVar(&opts.Debug, "debug", false, "Enable debug output (for development)") flags.BoolVar(&ver, "version", false, "Show version and how this binary was installed") flags.StringVar(&opts.StdinFileName, "stdin-filename", "", "File name when reading input from stdin") + flags.StringVar(&opts.InputFormat, "input-format", "auto-detect", "What syntax to check 'workflow', 'action' or 'auto-detect'") flags.Usage = func() { fmt.Fprintln(cmd.Stderr, commandUsageHeader) flags.PrintDefaults() diff --git a/linter.go b/linter.go index 9894a6dea..4db14a6cc 100644 --- a/linter.go +++ b/linter.go @@ -90,6 +90,9 @@ type LinterOptions struct { // function should return the modified rules. // Note that syntax errors may be reported even if this function returns nil or an empty slice. OnRulesCreated func([]Rule) []Rule + // InputFormat decides whether directories or files should be linted as workflow files or Action + // metadata + InputFormat string // More options will come here } @@ -107,6 +110,7 @@ type Linter struct { errFmt *ErrorFormatter cwd string onRulesCreated func([]Rule) []Rule + inputFormat InputFormat } // NewLinter creates a new Linter instance. @@ -171,6 +175,19 @@ func NewLinter(out io.Writer, opts *LinterOptions) (*Linter, error) { cwd = d } } + var inputFormat InputFormat + switch opts.InputFormat { + case "workflow": + inputFormat = FileWorkflow + case "action": + inputFormat = FileAction + case "": + fallthrough + case "auto-detect": + inputFormat = FileAutoDetect + default: + return nil, fmt.Errorf("unknown file syntax choose 'workflow', 'action' or 'auto-detect'") + } return &Linter{ NewProjects(), @@ -185,6 +202,7 @@ func NewLinter(out io.Writer, opts *LinterOptions) (*Linter, error) { formatter, cwd, opts.OnRulesCreated, + inputFormat, }, nil } @@ -237,11 +255,13 @@ func (l *Linter) GenerateDefaultConfig(dir string) error { return nil } -// LintRepository lints YAML workflow files and outputs the errors to given writer. It finds the nearest -// `.github/workflows` directory based on `dir` and applies lint rules to all YAML workflow files -// under the directory. +// LintRepository lints YAML workflow and action files and outputs the errors to given writer. +// It finds the nearest `.github/workflows` directory based on `dir` and applies lint rules to +// all YAML workflow files under the directory. +// Action metadata files are searched for in all other directories of the repository. +// Note, the InputFormat option can be used to filter this behavior. func (l *Linter) LintRepository(dir string) ([]*Error, error) { - l.log("Linting all workflow files in repository:", dir) + l.log("Linting all", inputFormatString(l.inputFormat), "files in repository:", dir) p, err := l.projects.At(dir) if err != nil { @@ -252,13 +272,37 @@ func (l *Linter) LintRepository(dir string) ([]*Error, error) { } l.log("Detected project:", p.RootDir()) - wd := p.WorkflowsDir() - return l.LintDir(wd, p) + var parentDir string + if l.inputFormat == FileWorkflow { + parentDir = p.WorkflowsDir() + } else { + parentDir = p.RootDir() + } + return l.LintDir(parentDir, p) } -// LintDir lints all YAML workflow files in the given directory recursively. +// LintDirInRepository lints YAML workflow or actions files and outputs the errors to given writer. +// It finds the projected based on the nearest `.github/workflows` directory based on `dir`. +// However, only files within `dir` are linted. +func (l *Linter) LintDirInRepository(dir string) ([]*Error, error) { + l.log("Linting all", inputFormatString(l.inputFormat), "files in repository:", dir) + + p, err := l.projects.At(dir) + if err != nil { + return nil, err + } + if p == nil { + return nil, fmt.Errorf("no project was found in any parent directories of %q. check workflows directory is put correctly in your Git repository", dir) + } + + l.log("Detected project:", p.RootDir()) + return l.LintDir(dir, p) +} + +// LintDir lints all YAML files in the given directory recursively. func (l *Linter) LintDir(dir string, project *Project) ([]*Error, error) { files := []string{} + workflow_dir := project.WorkflowsDir() if err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { if err != nil { return err @@ -266,7 +310,19 @@ func (l *Linter) LintDir(dir string, project *Project) ([]*Error, error) { if info.IsDir() { return nil } - if strings.HasSuffix(path, ".yml") || strings.HasSuffix(path, ".yaml") { + includeAnyYaml := false + switch l.inputFormat { + case FileAction: + includeAnyYaml = false + case FileWorkflow: + includeAnyYaml = true + case FileAutoDetect: + includeAnyYaml = strings.HasPrefix(path, workflow_dir) + } + if !includeAnyYaml && (strings.HasSuffix(path, "action.yml") || strings.HasSuffix(path, "action.yaml")) { + files = append(files, path) + } + if includeAnyYaml && (strings.HasSuffix(path, ".yml") || strings.HasSuffix(path, ".yaml")) { files = append(files, path) } return nil @@ -395,7 +451,7 @@ func (l *Linter) LintFiles(filepaths []string, project *Project) ([]*Error, erro return all, nil } -// LintFile lints one YAML workflow file and outputs the errors to given writer. The project +// LintFile lints one YAML file and outputs the errors to given writer. The project // parameter can be nil. In the case, the project is detected from the given path. func (l *Linter) LintFile(path string, project *Project) ([]*Error, error) { if project == nil { @@ -482,7 +538,7 @@ func (l *Linter) check( start = time.Now() } - l.log("Linting", path) + l.log("Linting", inputFormatString(l.inputFormat), path) if project != nil { l.log("Using project at", project.RootDir()) } @@ -500,32 +556,47 @@ func (l *Linter) check( l.debug("No config was found") } - w, all := Parse(content) + w, a, sf, all := ParseFile(path, content, l.inputFormat) if l.logLevel >= LogLevelVerbose { elapsed := time.Since(start) - l.log("Found", len(all), "parse errors in", elapsed.Milliseconds(), "ms for", path) + l.log("Found", len(all), "parse errors in", elapsed.Milliseconds(), "ms for", inputFormatString(sf), path) } - if w != nil { + if w != nil || a != nil { dbg := l.debugWriter() - rules := []Rule{ - NewRuleMatrix(), - NewRuleCredentials(), - NewRuleShellName(), - NewRuleRunnerLabel(), - NewRuleEvents(), - NewRuleJobNeeds(), - NewRuleAction(localActions), - NewRuleEnvVar(), - NewRuleID(), - NewRuleGlob(), - NewRulePermissions(), - NewRuleWorkflowCall(path, localReusableWorkflows), - NewRuleExpression(localActions, localReusableWorkflows), - NewRuleDeprecatedCommands(), - NewRuleIfCond(), + var rules []Rule + if w != nil { + rules = []Rule{ + NewRuleMatrix(), + NewRuleCredentials(), + NewRuleShellName(), + NewRuleRunnerLabel(), + NewRuleEvents(), + NewRuleJobNeeds(), + NewRuleAction(localActions), + NewRuleEnvVar(), + NewRuleID(), + NewRuleGlob(), + NewRulePermissions(), + NewRuleWorkflowCall(path, localReusableWorkflows), + NewRuleExpression(localActions, localReusableWorkflows), + NewRuleDeprecatedCommands(), + NewRuleIfCond(), + } + } else { + rules = []Rule{ + NewRuleBranding(), + NewRuleShellName(), + NewRuleAction(localActions), + NewRuleEnvVar(), + NewRuleID(), + NewRuleWorkflowCall(path, localReusableWorkflows), + NewRuleExpression(localActions, localReusableWorkflows), + NewRuleDeprecatedCommands(), + NewRuleIfCond(), + } } if l.shellcheck != "" { r, err := NewRuleShellcheck(l.shellcheck, proc) @@ -567,9 +638,17 @@ func (l *Linter) check( } } - if err := v.Visit(w); err != nil { - l.debug("error occurred while visiting workflow syntax tree: %v", err) - return nil, err + if w != nil { + if err := v.VisitWorkflow(w); err != nil { + l.debug("error occurred while visiting workflow syntax tree: %v", err) + return nil, err + } + } + if a != nil { + if err := v.VisitAction(a); err != nil { + l.debug("error occurred while visiting workflow syntax tree: %v", err) + return nil, err + } } for _, rule := range rules { @@ -607,7 +686,7 @@ func (l *Linter) check( if l.logLevel >= LogLevelVerbose { elapsed := time.Since(start) - l.log("Found total", len(all), "errors in", elapsed.Milliseconds(), "ms for", path) + l.log("Found total", len(all), "errors in", elapsed.Milliseconds(), "ms for", inputFormatString(sf), path) } return all, nil diff --git a/linter_test.go b/linter_test.go index ac990053f..cad238c3c 100644 --- a/linter_test.go +++ b/linter_test.go @@ -17,7 +17,7 @@ import ( "golang.org/x/sys/execabs" ) -func TestLinterLintOK(t *testing.T) { +func TestLinterLintWorkflowOK(t *testing.T) { dir := filepath.Join("testdata", "ok") es, err := os.ReadDir(dir) @@ -73,7 +73,52 @@ func TestLinterLintOK(t *testing.T) { } } -func testFindAllWorkflowsInDir(subdir string) (string, []string, error) { +func TestLinterLintActionOK(t *testing.T) { + dir, fs, err := testFindAllYamlInDir("actions/ok") + + if err != nil { + panic(err) + } + + proj := &Project{root: dir} + shellcheck, err := execabs.LookPath("shellcheck") + if err != nil { + t.Skip("skipped because \"shellcheck\" command does not exist in system") + } + + pyflakes, err := execabs.LookPath("pyflakes") + if err != nil { + t.Skip("skipped because \"pyflakes\" command does not exist in system") + } + + for _, f := range fs { + t.Run(filepath.Base(f), func(t *testing.T) { + opts := LinterOptions{ + Shellcheck: shellcheck, + Pyflakes: pyflakes, + InputFormat: "action", + } + + linter, err := NewLinter(io.Discard, &opts) + if err != nil { + t.Fatal(err) + } + + config := Config{} + linter.defaultConfig = &config + + errs, err := linter.LintFile(f, proj) + if err != nil { + t.Fatal(err) + } + if len(errs) > 0 { + t.Fatal(errs) + } + }) + } +} + +func testFindAllYamlInDir(subdir string) (string, []string, error) { dir := filepath.Join("testdata", subdir) entries, err := os.ReadDir(dir) @@ -137,9 +182,9 @@ func checkErrors(t *testing.T, outfile string, errs []*Error) { } } -func TestLinterLintError(t *testing.T) { +func TestLinterLintWorkflowError(t *testing.T) { for _, subdir := range []string{"examples", "err"} { - dir, infiles, err := testFindAllWorkflowsInDir(subdir) + dir, infiles, err := testFindAllYamlInDir(subdir) if err != nil { panic(err) } @@ -199,6 +244,66 @@ func TestLinterLintError(t *testing.T) { } } +func TestLinterLintActionError(t *testing.T) { + dir, infiles, err := testFindAllYamlInDir("actions/err") + if err != nil { + panic(err) + } + + proj := &Project{root: dir} + + shellcheck := "" + if p, err := execabs.LookPath("shellcheck"); err == nil { + shellcheck = p + } + + pyflakes := "" + if p, err := execabs.LookPath("pyflakes"); err == nil { + pyflakes = p + } + + for _, infile := range infiles { + base := strings.TrimSuffix(infile, filepath.Ext(infile)) + testName := filepath.Base(base) + t.Run(testName, func(t *testing.T) { + b, err := os.ReadFile(infile) + if err != nil { + panic(err) + } + + o := LinterOptions{InputFormat: "action"} + + if strings.Contains(testName, "shellcheck") { + if shellcheck == "" { + t.Skip("skipped because \"shellcheck\" command does not exist in system") + } + o.Shellcheck = shellcheck + } + + if strings.Contains(testName, "pyflakes") { + if pyflakes == "" { + t.Skip("skipped because \"pyflakes\" command does not exist in system") + } + o.Pyflakes = pyflakes + } + + l, err := NewLinter(io.Discard, &o) + if err != nil { + t.Fatal(err) + } + + l.defaultConfig = &Config{} + + errs, err := l.Lint("test.yaml", b, proj) + if err != nil { + t.Fatal(err) + } + + checkErrors(t, base+".out", errs) + }) + } +} + func TestLintFindProjectFromPath(t *testing.T) { d := filepath.Join("testdata", "find_project") f := filepath.Join(d, ".github", "workflows", "test.yaml") @@ -256,7 +361,7 @@ func TestLinterLintProject(t *testing.T) { opts := LinterOptions{ WorkingDir: repo, } - cfg := filepath.Join(repo, "actionlint.yaml") + cfg := filepath.Join(repo, ".github", "actionlint.yaml") if _, err := os.Stat(cfg); err == nil { opts.ConfigFile = cfg } @@ -266,7 +371,7 @@ func TestLinterLintProject(t *testing.T) { } proj := &Project{root: repo} - errs, err := linter.LintDir(filepath.Join(repo, "workflows"), proj) + errs, err := linter.LintDir(filepath.Join(repo, ".github", "workflows"), proj) if err != nil { t.Fatal(err) } @@ -706,7 +811,7 @@ func BenchmarkLintWorkflowContent(b *testing.B) { } func BenchmarkExamplesLintFiles(b *testing.B) { - dir, files, err := testFindAllWorkflowsInDir("examples") + dir, files, err := testFindAllYamlInDir("examples") if err != nil { panic(err) } diff --git a/parse.go b/parse.go index 989b02f0e..03aa73628 100644 --- a/parse.go +++ b/parse.go @@ -41,7 +41,7 @@ func newString(n *yaml.Node) *String { return &String{n.Value, quoted, posAt(n)} } -type workflowKeyVal struct { +type yamlKeyValue struct { // id is used for comparing keys. When the key is case insensitive, this field is in lower case. id string key *String @@ -247,7 +247,7 @@ func (p *parser) parseFloat(n *yaml.Node) *Float { } } -func (p *parser) parseMapping(what string, n *yaml.Node, allowEmpty, caseSensitive bool) []workflowKeyVal { +func (p *parser) parseMapping(what string, n *yaml.Node, allowEmpty, caseSensitive bool) []yamlKeyValue { isNull := isNull(n) if !isNull && n.Kind != yaml.MappingNode { @@ -262,7 +262,7 @@ func (p *parser) parseMapping(what string, n *yaml.Node, allowEmpty, caseSensiti l := len(n.Content) / 2 keys := make(map[string]*Pos, l) - m := make([]workflowKeyVal, 0, l) + m := make([]yamlKeyValue, 0, l) for i := 0; i < len(n.Content); i += 2 { k := p.parseString(n.Content[i], false) if k == nil { @@ -287,7 +287,7 @@ func (p *parser) parseMapping(what string, n *yaml.Node, allowEmpty, caseSensiti p.errorfAt(k.Pos, "key %q is duplicated in %s. previously defined at %s%s", k.Value, what, pos.String(), note) continue } - m = append(m, workflowKeyVal{id, k, n.Content[i+1]}) + m = append(m, yamlKeyValue{id, k, n.Content[i+1]}) keys[id] = k.Pos } @@ -298,7 +298,7 @@ func (p *parser) parseMapping(what string, n *yaml.Node, allowEmpty, caseSensiti return m } -func (p *parser) parseSectionMapping(sec string, n *yaml.Node, allowEmpty, caseSensitive bool) []workflowKeyVal { +func (p *parser) parseSectionMapping(sec string, n *yaml.Node, allowEmpty, caseSensitive bool) []yamlKeyValue { return p.parseMapping(fmt.Sprintf("%q section", sec), n, allowEmpty, caseSensitive) } @@ -980,7 +980,7 @@ func (p *parser) parseTimeoutMinutes(n *yaml.Node) *Float { } // https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions#jobsjob_idsteps -func (p *parser) parseStep(n *yaml.Node) *Step { +func (p *parser) parseStep(n *yaml.Node, requireShell bool) *Step { ret := &Step{Pos: posAt(n)} var workDir *String @@ -1013,7 +1013,7 @@ func (p *parser) parseStep(n *yaml.Node) *Step { } else { // kv.key == "with" with := p.parseSectionMapping("with", kv.val, false, false) - exec.Inputs = make(map[string]*Input, len(with)) + exec.Inputs = make(map[string]*StepInput, len(with)) for _, input := range with { switch input.id { case "entrypoint": @@ -1023,7 +1023,7 @@ func (p *parser) parseStep(n *yaml.Node) *Step { // https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions#jobsjob_idstepswithargs exec.Args = p.parseString(input.val, true) default: - exec.Inputs[input.id] = &Input{input.key, p.parseString(input.val, true)} + exec.Inputs[input.id] = &StepInput{input.key, p.parseString(input.val, true)} } } } @@ -1081,6 +1081,9 @@ func (p *parser) parseStep(n *yaml.Node) *Step { if e.Run == nil { p.error(n, "\"run\" is required to run script in step") } + if e.Shell == nil && requireShell { + p.error(n, "\"shell\" is required to run script in step as part of composite actions") + } default: p.error(n, "step must run script with \"run\" section or run action with \"uses\" section") } @@ -1089,7 +1092,7 @@ func (p *parser) parseStep(n *yaml.Node) *Step { } // https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions#jobsjob_idsteps -func (p *parser) parseSteps(n *yaml.Node) []*Step { +func (p *parser) parseSteps(n *yaml.Node, requireShell bool) []*Step { if ok := p.checkSequence("steps", n, false); !ok { return nil } @@ -1097,7 +1100,7 @@ func (p *parser) parseSteps(n *yaml.Node) []*Step { ret := make([]*Step, 0, len(n.Content)) for _, c := range n.Content { - if s := p.parseStep(c); s != nil { + if s := p.parseStep(c, requireShell); s != nil { ret = append(ret, s) } } @@ -1191,7 +1194,7 @@ func (p *parser) parseJob(id *String, n *yaml.Node) *Job { case "if": ret.If = p.parseString(v, false) case "steps": - ret.Steps = p.parseSteps(v) + ret.Steps = p.parseSteps(v, false) stepsOnlyKey = k case "timeout-minutes": ret.TimeoutMinutes = p.parseTimeoutMinutes(v) @@ -1314,7 +1317,7 @@ func (p *parser) parseJobs(n *yaml.Node) map[string]*Job { } // https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions -func (p *parser) parse(n *yaml.Node) *Workflow { +func (p *parser) parseWorkflow(n *yaml.Node) *Workflow { w := &Workflow{} if n.Line == 0 { @@ -1372,6 +1375,310 @@ func (p *parser) parse(n *yaml.Node) *Workflow { return w } +// https://docs.github.com/en/actions/creating-actions/metadata-syntax-for-github-actions#inputsinput_id +func (p *parser) parseActionInput(id *String, n *yaml.Node) *ActionInput { + ret := &ActionInput{Pos: posAt(n)} + + for _, kv := range p.parseMapping(fmt.Sprintf("%q input", id.Value), n, false, true) { + switch kv.id { + case "description": + ret.Description = p.parseString(kv.val, false) + case "required": + ret.Required = p.parseBool(kv.val) + case "default": + ret.Default = p.parseString(kv.val, true) + case "deprecationMessage": + ret.DeprecationMessage = p.parseString(kv.val, false) + default: + p.unexpectedKey(kv.key, "input", []string{ + "description", + "required", + "default", + "deprecationMessage", + }) + } + } + + if ret.Description == nil { + p.errorf(n, "\"description\" property is missing in specification of input %q", id.Value) + } + + return ret +} + +func (p *parser) parseActionInputs(pos *Pos, n *yaml.Node) map[string]*ActionInput { + inputs := p.parseSectionMapping("inputs", n, false, false) + ret := make(map[string]*ActionInput, len(inputs)) + for _, kv := range inputs { + ret[kv.id] = p.parseActionInput(kv.key, kv.val) + } + + return ret +} + +// https://docs.github.com/en/actions/creating-actions/metadata-syntax-for-github-actions#outputs-for-docker-container-and-javascript-actions +// https://docs.github.com/en/actions/creating-actions/metadata-syntax-for-github-actions#outputs-for-composite-actions +func (p *parser) parseActionOutput(id *String, n *yaml.Node) *ActionOutput { + ret := &ActionOutput{Pos: posAt(n)} + + for _, kv := range p.parseMapping(fmt.Sprintf("%q outputs", id.Value), n, false, true) { + switch kv.id { + case "description": + ret.Description = p.parseString(kv.val, false) + case "value": + ret.Value = p.parseString(kv.val, false) + default: + p.unexpectedKey(kv.key, "output", []string{ + "description", + "value", + }) + } + } + + return ret +} + +func (p *parser) parseActionOutputs(pos *Pos, n *yaml.Node) map[string]*ActionOutput { + outputs := p.parseSectionMapping("outputs", n, false, false) + ret := make(map[string]*ActionOutput, len(outputs)) + for _, kv := range outputs { + ret[kv.id] = p.parseActionOutput(kv.key, kv.val) + } + + return ret +} + +func (p *parser) parseActionRuns(n *yaml.Node) ActionRuns { + var r ActionRuns + hadUsingKey := false + for _, kv := range p.parseMapping("runs", n, false, true) { + switch kv.id { + case "using": + using := p.parseString(kv.val, false) + hadUsingKey = true + switch { + case strings.HasPrefix(using.Value, "node"): + if r == nil { + r = &JavaScriptRuns{Using: using} + } else if na, ok := r.(*JavaScriptRuns); ok { + na.Using = using + } else { + p.errorAt(kv.key.Pos, "this action declares it uses javascript but has foreign keys") + } + case using.Value == "docker": + if r == nil { + r = &DockerContainerRuns{} + } else if r.Kind() != ActionKindDockerContainer { + p.errorAt(kv.key.Pos, "this action declares it uses docker container but has foreign keys") + } + case using.Value == "composite": + if r == nil { + r = &CompositeRuns{} + } else if r.Kind() != ActionKindComposite { + p.errorAt(kv.key.Pos, "this action declares it is a composite action but has foreign keys") + } + default: + p.errorf(kv.val, "unknown action type %s, (only javascript, docker and composite are supported)", using.Value) + return nil + } + case "steps": + var def *CompositeRuns + if r == nil { + def = &CompositeRuns{} + r = def + } else if ca, ok := r.(*CompositeRuns); ok { + def = ca + } else { + p.errorfAt(kv.key.Pos, "this action defines parameter %s for composite actions, but is something else", kv.id) + continue + } + def.Steps = p.parseSteps(kv.val, true) + case "main", "pre", "pre-if", "post", "post-if": + var def *JavaScriptRuns + if r == nil { + def = &JavaScriptRuns{} + r = def + } else if na, ok := r.(*JavaScriptRuns); ok { + def = na + } else { + p.errorfAt(kv.key.Pos, "this action defines parameter %s for javascript actions, but is something else", kv.id) + continue + } + switch kv.id { + case "main": + def.Main = p.parseString(kv.val, false) + case "pre": + def.Pre = p.parseString(kv.val, false) + case "pre-if": + def.PreIf = p.parseString(kv.val, false) + case "post": + def.Post = p.parseString(kv.val, false) + case "post-if": + def.PostIf = p.parseString(kv.val, false) + } + case "image", "entrypoint", "args", "env", "pre-entrypoint", "post-entrypoint": + var def *DockerContainerRuns + if r == nil { + def = &DockerContainerRuns{} + r = def + } else if da, ok := r.(*DockerContainerRuns); ok { + def = da + } else { + p.errorfAt(kv.key.Pos, "this action defines parameter %s for javascript actions, but is something else", kv.id) + continue + } + switch kv.id { + case "image": + def.Image = p.parseString(kv.val, false) + case "args": + def.Args = p.parseStringSequence("args", kv.val, true, false) + case "env": + def.Env = p.parseEnv(kv.val) + case "pre-entrypoint": + def.PreEntrypoint = p.parseString(kv.val, false) + case "entrypoint": + def.Entrypoint = p.parseString(kv.val, false) + case "post-entrypoint": + def.PostEntrypoint = p.parseString(kv.val, false) + } + default: + p.unexpectedKey(kv.key, "runs", []string{ + "using", + "main", + "pre", + "pre-if", + "post", + "post-if", + "image", + "args", + "env", + "pre-entrypoint", + "entrypoint", + "post-entrypoint", + "steps", + }) + } + } + + if !hadUsingKey { + p.error(n, "\"using\" is required to define what to execute") + return r + } + + switch a := r.(type) { + case *JavaScriptRuns: + if a.Main == nil { + p.error(n, "\"main\" is required for a javascript action") + } + case *DockerContainerRuns: + if a.Image == nil { + p.error(n, "\"image\" is required for a docker container action") + } + case *CompositeRuns: + if a.Steps == nil { + p.error(n, "\"steps\" is required for a composite action") + } + } + return r +} + +func (p *parser) parseBranding(n *yaml.Node) *Branding { + b := Branding{} + for _, kv := range p.parseMapping("branding", n, false, false) { + switch kv.id { + case "color": + b.Color = p.parseString(kv.val, false) + case "icon": + b.Icon = p.parseString(kv.val, false) + default: + p.unexpectedKey(kv.key, "branding", []string{ + "color", + "icon", + }) + } + } + + if b.Icon == nil { + p.error(n, "\"icon\" is required for branding information") + } + if b.Color == nil { + p.error(n, "\"color\" is required for branding information") + } + return &b +} + +// https://docs.github.com/en/actions/creating-actions/metadata-syntax-for-github-actions +func (p *parser) parseAction(n *yaml.Node) *Action { + a := &Action{} + if n.Line == 0 { + n.Line = 1 + } + if n.Column == 0 { + n.Column = 1 + } + + if len(n.Content) == 0 { + p.error(n, "action is empty") + return a + } + + hasRunsBlock := false + + for _, kv := range p.parseMapping("action", n.Content[0], false, true) { + k, v := kv.key, kv.val + switch kv.id { + case "name": + a.Name = p.parseString(v, true) + case "author": + a.Author = p.parseString(v, true) + case "description": + a.Description = p.parseString(v, true) + case "inputs": + a.Inputs = p.parseActionInputs(k.Pos, v) + case "outputs": + a.Outputs = p.parseActionOutputs(k.Pos, v) + case "runs": + a.Runs = p.parseActionRuns(v) + hasRunsBlock = true // even if parseActionRuns is nil, it is still had a runs block + case "branding": + a.Branding = p.parseBranding(v) + default: + p.unexpectedKey(k, "action", []string{ + "name", + "author", + "description", + "inputs", + "outputs", + "runs", + "branding", + }) + } + } + + if a.Name == nil { + p.error(n, "\"name\" property is missing in action metadata") + } + if a.Description == nil { + p.error(n, "\"description\" property is missing in action metadata") + } + if !hasRunsBlock { + p.error(n, "\"runs\" section is missing in action metadata") + } + if a.Runs != nil { + requireValue := a.Runs.Kind() == ActionKindComposite + for _, o := range a.Outputs { + if o.Value == nil && requireValue { + p.errorAt(o.Pos, "output value is required for composite actions") + } + if o.Value != nil && !requireValue { + p.errorAt(o.Pos, "output value is only allowed for composite actions") + } + } + } + + return a +} + // func dumpYAML(n *yaml.Node, level int) { // fmt.Printf("%s%s (%s, %d,%d): %q\n", strings.Repeat(". ", level), nodeKindName(n.Kind), n.Tag, n.Line, n.Column, n.Value) // for _, c := range n.Content { @@ -1402,21 +1709,86 @@ func handleYAMLError(err error) []*Error { return []*Error{yamlErr(err.Error())} } -// Parse parses given source as byte sequence into workflow syntax tree. It returns all errors -// detected while parsing the input. It means that detecting one error does not stop parsing. Even -// if one or more errors are detected, parser will try to continue parsing and finding more errors. +// InputFormat is kind of how input files should be treated (as workflow file, action file or either of them). +type InputFormat uint8 + +const ( + // FileWorkflow ensures the file is parsed as Actions workflow + FileWorkflow InputFormat = iota + // FileAction ensures the file is parsed as Action metadata file + FileAction + // FileAutoDetect will select between workflow and action metadata file based on filename and file content. + FileAutoDetect +) + +func inputFormatString(inputFormat InputFormat) string { + switch inputFormat { + case FileWorkflow: + return "workflow" + case FileAction: + return "action" + case FileAutoDetect: + return "workflow or action" + } + return "unknown" +} + +func selectFormat(filename string, node *yaml.Node, format InputFormat) InputFormat { + if format != FileAutoDetect { // Format is already enforced + return format + } + if strings.Contains(filename, ".github/workflows/") { + // println("Detect", filename, "as workflow file based (due to its directory)") + return FileWorkflow + } + if strings.HasSuffix(filename, "/action.yaml") || strings.HasSuffix(filename, "/action.yml") { + // println("Detect", filename, "as action filename based (due to its filename)") + return FileAction + } + + // selecting Action if `runs` element is present Workflow otherwise: + if isNull(node) || len(node.Content) == 0 || node.Content[0].Kind != yaml.MappingNode { + // println("Defaulted", filename, "as workflow (file is not a yaml mapping)", nodeKindName(node.Kind)) + return FileWorkflow + } + + for i := 0; i < len(node.Content[0].Content); i += 2 { + if node.Content[0].Content[i].Kind == yaml.ScalarNode && node.Content[0].Content[i].Value == "runs" { + // println("Detected", filename, "as action workflow (it has a 'runs' key)") + return FileAction + } + } + return FileWorkflow +} + +// Parse is an alias for ParseFile with default values for API stability func Parse(b []byte) (*Workflow, []*Error) { + w, _, _, errs := ParseFile("", b, FileWorkflow) + return w, errs +} + +// ParseFile parses given source as byte sequence into action or workflow syntax tree. It returns +// all errors detected while parsing the input. It means that detecting one error does not stop parsing. +// Even if one or more errors are detected, parser will try to continue parsing and finding more errors. +func ParseFile(filename string, b []byte, format InputFormat) (*Workflow, *Action, InputFormat, []*Error) { var n yaml.Node + var a *Action + var w *Workflow if err := yaml.Unmarshal(b, &n); err != nil { - return nil, handleYAMLError(err) + return nil, nil, format, handleYAMLError(err) } // Uncomment for checking YAML tree // dumpYAML(&n, 0) p := &parser{} - w := p.parse(&n) + sf := selectFormat(filename, &n, format) + if sf == FileWorkflow { + w = p.parseWorkflow(&n) + } else { + a = p.parseAction(&n) + } - return w, p.errors + return w, a, sf, p.errors } diff --git a/parse_test.go b/parse_test.go new file mode 100644 index 000000000..0702036ce --- /dev/null +++ b/parse_test.go @@ -0,0 +1,54 @@ +package actionlint + +import ( + "fmt" + "os" + "path/filepath" + "testing" +) + +func TestDetectFilesAsWorkflow(t *testing.T) { + dir := filepath.Join("testdata", "detect_format", "workflows") + if err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + b, err := os.ReadFile(path) + if err != nil { + panic(err) + } + _, _, sf, _ := ParseFile(path, b, FileAutoDetect) + if sf != FileWorkflow { + t.Errorf("file %s is not detected as workflow", path) + } + return nil + }); err != nil { + panic(fmt.Errorf("could not read files in %q: %w", dir, err)) + } +} + +func TestDetectFilesAsAction(t *testing.T) { + dir := filepath.Join("testdata", "detect_format", "actions") + if err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + b, err := os.ReadFile(path) + if err != nil { + panic(err) + } + _, _, sf, _ := ParseFile(path, b, FileAutoDetect) + if sf != FileAction { + t.Errorf("file %s is not detected as action", path) + } + return nil + }); err != nil { + panic(fmt.Errorf("could not read files in %q: %w", dir, err)) + } +} diff --git a/pass.go b/pass.go index e626fe221..2e8da3683 100644 --- a/pass.go +++ b/pass.go @@ -18,6 +18,11 @@ type Pass interface { VisitWorkflowPre(node *Workflow) error // VisitWorkflowPost is callback when visiting Workflow node after visiting its children. It returns internal error when it cannot continue the process VisitWorkflowPost(node *Workflow) error + + // VisitActionPre is callback when visiting Action node before visiting its children. It returns internal error when it cannot continue the process + VisitActionPre(node *Action) error + // VisitActionPost is callback when visiting Action node after visiting its children. It returns internal error when it cannot continue the process + VisitActionPost(node *Action) error } // Visitor visits syntax tree from root in depth-first order @@ -46,8 +51,13 @@ func (v *Visitor) reportElapsedTime(what string, start time.Time) { fmt.Fprintf(v.dbg, "[Visitor] %s took %vms\n", what, time.Since(start).Milliseconds()) } -// Visit visits given syntax tree in depth-first order +// Visit is an alias for VisitWorkflow for API stability func (v *Visitor) Visit(n *Workflow) error { + return v.VisitWorkflow(n) +} + +// VisitWorkflow visits given syntax tree in depth-first order +func (v *Visitor) VisitWorkflow(n *Workflow) error { var t time.Time if v.dbg != nil { t = time.Now() @@ -88,6 +98,50 @@ func (v *Visitor) Visit(n *Workflow) error { return nil } +// VisitAction visits given syntax tree in depth-first order +func (v *Visitor) VisitAction(n *Action) error { + var t time.Time + if v.dbg != nil { + t = time.Now() + } + + for _, p := range v.passes { + if err := p.VisitActionPre(n); err != nil { + return err + } + } + + if v.dbg != nil { + v.reportElapsedTime("VisitActionPre", t) + t = time.Now() + } + + if c, ok := n.Runs.(*CompositeRuns); ok { + for _, s := range c.Steps { + if err := v.visitStep(s); err != nil { + return err + } + } + + if v.dbg != nil { + v.reportElapsedTime(fmt.Sprintf("Visiting %d steps of action %q", len(c.Steps), n.Name.Value), t) + t = time.Now() + } + } + + for _, p := range v.passes { + if err := p.VisitActionPost(n); err != nil { + return err + } + } + + if v.dbg != nil { + v.reportElapsedTime("VisitActionPost", t) + } + + return nil +} + func (v *Visitor) visitJob(n *Job) error { var t time.Time if v.dbg != nil { diff --git a/playground/index.html b/playground/index.html index 7c8a2181e..d2963d860 100644 --- a/playground/index.html +++ b/playground/index.html @@ -24,7 +24,7 @@
-
+
+
+ +
+
+
Loading WebAssembly binary...
diff --git a/playground/index.ts b/playground/index.ts index 25a3b0c55..04d6810e9 100644 --- a/playground/index.ts +++ b/playground/index.ts @@ -18,6 +18,13 @@ const checkUrlButton = getElementById('check-url-btn'); const checkUrlInput = getElementById('check-url-input') as HTMLInputElement; const permalinkButton = getElementById('permalink-btn'); + const toggleWorkflow = getElementById('toggle-workflow'); + const toggleAction = getElementById('toggle-action'); + + function getCurrentType() { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return document.querySelector('.is-active')!.id === 'toggle-workflow' ? 'workflow' : 'action'; + } async function getRemoteSource(url: string): Promise { function getUrlToFetch(u: string): string { @@ -52,7 +59,7 @@ return src.trim(); } - async function getDefaultSource(): Promise { + async function getDefaultSource(type: 'workflow' | 'action' = 'workflow'): Promise { const params = new URLSearchParams(window.location.search); const s = params.get('s'); @@ -72,7 +79,7 @@ return new TextDecoder().decode(decompressed); } - const src = `# Paste your workflow YAML to this code editor + const workflowSrc = `# Paste your workflow YAML to this code editor on: push: @@ -98,7 +105,22 @@ jobs: if: \${{ github.repository.permissions.admin == true }} - run: npm install && npm test`; - return src; + const actionSrc = `# Paste your action YAML to this code editor + +name: 'My Action Name' +description: 'My Action Description' +inputs: + my_input: + description: 'My input' + required: true + default: 3 + my_other_input: + required: true +runs: + using: 'node12' + main: 'index.js'`; + + return type === 'workflow' ? workflowSrc : actionSrc; } const editorConfig: CodeMirror.EditorConfiguration = { @@ -121,33 +143,38 @@ jobs: const debounceInterval = isMobile.phone ? 1000 : 300; let debounceId: number | null = null; let contentChanged = false; - editor.on('change', function (_, e) { - contentChanged = true; + + function startActionlint(): void { + debounceId = null; if (typeof window.runActionlint !== 'function') { showError('Preparing Wasm file is not completed yet. Please wait for a while and try again.'); return; } + errorMessage.style.display = 'none'; + successMessage.style.display = 'none'; + editor.clearGutter('error-marker'); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + window.runActionlint!(editor.getValue(), getCurrentType()); + } + + function startActionLintDebounced(): void { if (debounceId !== null) { window.clearTimeout(debounceId); } + debounceId = window.setTimeout(() => startActionlint(), debounceInterval); + } - function startActionlint(): void { - debounceId = null; - errorMessage.style.display = 'none'; - successMessage.style.display = 'none'; - editor.clearGutter('error-marker'); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - window.runActionlint!(editor.getValue()); - } + editor.on('change', function (_, e) { + contentChanged = true; if (e.origin === 'paste') { startActionlint(); // When pasting some code, apply actionlint instantly return; } - debounceId = window.setTimeout(() => startActionlint(), debounceInterval); + startActionLintDebounced(); }); function getSource(): string { @@ -289,6 +316,26 @@ jobs: window.location.hash = b64; }); + [toggleWorkflow, toggleAction].forEach(element => { + element.addEventListener('click', async event => { + event.preventDefault(); + const target = event.currentTarget as HTMLElement; + if (!target.classList.contains('is-active')) { + target.classList.add('is-active'); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const sibling = (target.nextElementSibling || target.previousElementSibling)!; + sibling.classList.remove('is-active'); + + if (!contentChanged) { + editor.setValue(await getDefaultSource(target.id === 'toggle-workflow' ? 'workflow' : 'action')); + contentChanged = false; + } else { + startActionLintDebounced(); + } + } + }); + }); + const go = new Go(); let result; diff --git a/playground/lib.d.ts b/playground/lib.d.ts index b6cb37863..dc850e0c3 100644 --- a/playground/lib.d.ts +++ b/playground/lib.d.ts @@ -6,7 +6,7 @@ interface ActionlintError { } interface Window { - runActionlint?(src: string): void; + runActionlint?(src: string, typ: 'workflow' | 'action'): void; getYamlSource(): string; showError(msg: string): void; onCheckCompleted(errs: ActionlintError[]): void; diff --git a/playground/main.go b/playground/main.go index 640620f0d..4ee9308f9 100644 --- a/playground/main.go +++ b/playground/main.go @@ -26,8 +26,8 @@ func encodeErrorAsMap(err *actionlint.Error) map[string]interface{} { return obj } -func lint(source string) interface{} { - opts := actionlint.LinterOptions{} +func lint(source string, typ string) interface{} { + opts := actionlint.LinterOptions{InputFormat: typ} linter, err := actionlint.NewLinter(io.Discard, &opts) if err != nil { fail(err, "creating linter instance") @@ -35,6 +35,7 @@ func lint(source string) interface{} { } errs, err := linter.Lint("test.yaml", []byte(source), nil) + if err != nil { fail(err, "applying lint rules") return nil @@ -52,12 +53,14 @@ func lint(source string) interface{} { func runActionlint(_this js.Value, args []js.Value) interface{} { source := args[0].String() - return lint(source) + typ := args[1].String() + + return lint(source, typ) } func main() { window.Set("runActionlint", js.FuncOf(runActionlint)) window.Call("dismissLoading") - lint(window.Call("getYamlSource").String()) // Show the first result + lint(window.Call("getYamlSource").String(), "workflow") // Show the first result select {} } diff --git a/playground/style.css b/playground/style.css index 878f8de65..8c56ea571 100644 --- a/playground/style.css +++ b/playground/style.css @@ -70,3 +70,12 @@ main { footer { margin-top: 32px; } + +.type-toggle { + margin-right: 1rem; +} + +.type-toggle li a { + padding: calc(.5em - 1px) 1em; + height: 2.5em; +} diff --git a/playground/test.ts b/playground/test.ts index 20b67f8cb..6d571f477 100644 --- a/playground/test.ts +++ b/playground/test.ts @@ -94,7 +94,7 @@ jobs: steps: - run: echo 'hi'`; - window.runActionlint(source); + window.runActionlint(source, 'workflow'); const errors = await results.waitCheckCompleted(); const json = JSON.stringify(errors); assert.equal(errors.length, 1, json); @@ -120,7 +120,7 @@ jobs: steps: - run: echo 'hi'`; - window.runActionlint(source); + window.runActionlint(source, 'workflow'); const errors = await results.waitCheckCompleted(); const json = JSON.stringify(errors); assert.equal(errors.length, 0, json); diff --git a/rule.go b/rule.go index ed0e169b1..81ef68185 100644 --- a/rule.go +++ b/rule.go @@ -39,6 +39,12 @@ func (r *RuleBase) VisitWorkflowPre(node *Workflow) error { return nil } // VisitWorkflowPost is callback when visiting Workflow node after visiting its children. func (r *RuleBase) VisitWorkflowPost(node *Workflow) error { return nil } +// VisitActionPre is callback when visiting Workflow node before visiting its children. +func (r *RuleBase) VisitActionPre(node *Action) error { return nil } + +// VisitActionPost is callback when visiting Workflow node after visiting its children. +func (r *RuleBase) VisitActionPost(node *Action) error { return nil } + // Error creates a new error from the source position and the error message and stores it in the // rule instance. The errors can be accessed by Errs method. func (r *RuleBase) Error(pos *Pos, msg string) { diff --git a/rule_branding.go b/rule_branding.go new file mode 100644 index 000000000..0210a0f2d --- /dev/null +++ b/rule_branding.go @@ -0,0 +1,36 @@ +package actionlint + +// RuleBranding is a rule to check branding information of actions +type RuleBranding struct { + RuleBase +} + +// NewRuleBranding creates new RuleBranding instance +func NewRuleBranding() *RuleBranding { + return &RuleBranding{ + RuleBase: RuleBase{ + name: "branding", + desc: "Checks for valid branding information of actions", + }, + } +} + +// VisitActionPre is callback when visiting Action node before visiting its children. +func (rule *RuleBranding) VisitActionPre(n *Action) error { + if n.Branding == nil { // Branding is optional + return nil + } + + // TODO auto-extract it from GitHub docs + valid_colors := []string{"white", "yellow", "blue", "green", "orange", "red", "purple", "gray-dark"} + if n.Branding.Color != nil && !contains(valid_colors, n.Branding.Color.Value) { + rule.Errorf(n.Branding.Color.Pos, "invalid color %q. valid colors are %v", n.Branding.Color.Value, valid_colors) + } + + // TODO auto-extract it from GitHub docs + valid_icons := []string{"activity", "airplay", "alert-circle", "alert-octagon", "alert-triangle", "align-center", "align-justify", "align-left", "align-right", "anchor", "aperture", "archive", "arrow-down-circle", "arrow-down-left", "arrow-down-right", "arrow-down", "arrow-left-circle", "arrow-left", "arrow-right-circle", "arrow-right", "arrow-up-circle", "arrow-up-left", "arrow-up-right", "arrow-up", "at-sign", "award", "bar-chart-2", "bar-chart", "battery-charging", "battery", "bell-off", "bell", "bluetooth", "bold", "book-open", "book", "bookmark", "box", "briefcase", "calendar", "camera-off", "camera", "cast", "check-circle", "check-square", "check", "chevron-down", "chevron-left", "chevron-right", "chevron-up", "chevrons-down", "chevrons-left", "chevrons-right", "chevrons-up", "circle", "clipboard", "clock", "cloud-drizzle", "cloud-lightning", "cloud-off", "cloud-rain", "cloud-snow", "cloud", "code", "command", "compass", "copy", "corner-down-left", "corner-down-right", "corner-left-down", "corner-left-up", "corner-right-down", "corner-right-up", "corner-up-left", "corner-up-right", "cpu", "credit-card", "crop", "crosshair", "database", "delete", "disc", "dollar-sign", "download-cloud", "download", "droplet", "edit-2", "edit-3", "edit", "external-link", "eye-off", "eye", "fast-forward", "feather", "file-minus", "file-plus", "file-text", "file", "film", "filter", "flag", "folder-minus", "folder-plus", "folder", "gift", "git-branch", "git-commit", "git-merge", "git-pull-request", "globe", "grid", "hard-drive", "hash", "headphones", "heart", "help-circle", "home", "image", "inbox", "info", "italic", "layers", "layout", "life-buoy", "link-2", "link", "list", "loader", "lock", "log-in", "log-out", "mail", "map-pin", "map", "maximize-2", "maximize", "menu", "message-circle", "message-square", "mic-off", "mic", "minimize-2", "minimize", "minus-circle", "minus-square", "minus", "monitor", "moon", "more-horizontal", "more-vertical", "move", "music", "navigation-2", "navigation", "octagon", "package", "paperclip", "pause-circle", "pause", "percent", "phone-call", "phone-forwarded", "phone-incoming", "phone-missed", "phone-off", "phone-outgoing", "phone", "pie-chart", "play-circle", "play", "plus-circle", "plus-square", "plus", "pocket", "power", "printer", "radio", "refresh-ccw", "refresh-cw", "repeat", "rewind", "rotate-ccw", "rotate-cw", "rss", "save", "scissors", "search", "send", "server", "settings", "share-2", "share", "shield-off", "shield", "shopping-bag", "shopping-cart", "shuffle", "sidebar", "skip-back", "skip-forward", "slash", "sliders", "smartphone", "speaker", "square", "star", "stop-circle", "sun", "sunrise", "sunset", "tablet", "tag", "target", "terminal", "thermometer", "thumbs-down", "thumbs-up", "toggle-left", "toggle-right", "trash-2", "trash", "trending-down", "trending-up", "triangle", "truck", "tv", "type", "umbrella", "underline", "unlock", "upload-cloud", "upload", "user-check", "user-minus", "user-plus", "user-x", "user", "users", "video-off", "video", "voicemail", "volume-1", "volume-2", "volume-x", "volume", "watch", "wifi-off", "wifi", "wind", "x-circle", "x-square", "x", "zap-off", "zap", "zoom-in", "zoom-out"} + if n.Branding.Icon != nil && !contains(valid_icons, n.Branding.Icon.Value) { + rule.Errorf(n.Branding.Icon.Pos, "invalid icon %q", n.Branding.Icon.Value) + } + return nil +} diff --git a/rule_expression.go b/rule_expression.go index e7413661a..b4d658dab 100644 --- a/rule_expression.go +++ b/rule_expression.go @@ -267,6 +267,47 @@ func (rule *RuleExpression) VisitJobPost(n *Job) error { return nil } +// VisitActionPre is callback when visiting Job node before visiting its children. +func (rule *RuleExpression) VisitActionPre(n *Action) error { + rule.needsTy = NewEmptyStrictObjectType() + rule.stepsTy = NewEmptyStrictObjectType() + + ity := NewEmptyStrictObjectType() + rule.inputsTy = ity + + for k := range n.Inputs { + ity.Props[k] = AnyType{} + } + + for _, v := range n.Inputs { + rule.checkString(v.Default, "jobs..inputs..default") + } + + switch r := n.Runs.(type) { + case *JavaScriptRuns: + rule.checkIfCondition(r.PreIf, "runs.pre-if") + rule.checkIfCondition(r.PostIf, "runs.post-if") + case *DockerContainerRuns: + rule.checkEnv(r.Env, "runs.env") + } + + return nil +} + +// VisitActionPost is callback when visiting Job node after visiting its children +func (rule *RuleExpression) VisitActionPost(n *Action) error { + // 'outputs' sections are evaluated after all steps are run + for _, output := range n.Outputs { + rule.checkString(output.Value, "jobs..outputs.") + } + + rule.matrixTy = nil + rule.stepsTy = nil + rule.needsTy = nil + + return nil +} + // VisitStep is callback when visiting Step node. func (rule *RuleExpression) VisitStep(n *Step) error { rule.checkString(n.Name, "jobs..steps.name") diff --git a/rule_id.go b/rule_id.go index b491e5e60..6d9084887 100644 --- a/rule_id.go +++ b/rule_id.go @@ -41,6 +41,19 @@ func (rule *RuleID) VisitJobPost(n *Job) error { return nil } +// VisitActionPre is callback when visiting Job node before visiting its children. +func (rule *RuleID) VisitActionPre(n *Action) error { + rule.seen = map[string]*Pos{} + + return nil +} + +// VisitActionPost is callback when visiting Job node after visiting its children. +func (rule *RuleID) VisitActionPost(n *Action) error { + rule.seen = nil + return nil +} + // VisitStep is callback when visiting Step node. func (rule *RuleID) VisitStep(n *Step) error { if n.ID == nil { diff --git a/rule_shellcheck.go b/rule_shellcheck.go index b8f5b10e3..7494f6413 100644 --- a/rule_shellcheck.go +++ b/rule_shellcheck.go @@ -107,6 +107,12 @@ func (rule *RuleShellcheck) VisitWorkflowPost(n *Workflow) error { return rule.cmd.wait() // Wait until all processes running for this rule } +// VisitActionPost is callback when visiting Action node after visiting its children. +func (rule *RuleShellcheck) VisitActionPost(n *Action) error { + rule.workflowShell = "" + return rule.cmd.wait() // Wait until all processes running for this rule +} + func (rule *RuleShellcheck) getShellName(exec *ExecRun) string { if exec.Shell != nil { return exec.Shell.Value diff --git a/testdata/actions/err/branding-unknown-missing-keys.out b/testdata/actions/err/branding-unknown-missing-keys.out new file mode 100644 index 000000000..981025760 --- /dev/null +++ b/testdata/actions/err/branding-unknown-missing-keys.out @@ -0,0 +1,3 @@ +test.yaml:7:3: unexpected key "new" for "branding" section. expected one of "color", "icon" [syntax-check] +test.yaml:7:3: "icon" is required for branding information [syntax-check] +test.yaml:7:3: "color" is required for branding information [syntax-check] diff --git a/testdata/actions/err/branding-unknown-missing-keys.yaml b/testdata/actions/err/branding-unknown-missing-keys.yaml new file mode 100644 index 000000000..c11f5e168 --- /dev/null +++ b/testdata/actions/err/branding-unknown-missing-keys.yaml @@ -0,0 +1,7 @@ +name: "Branding Test" +description: "Ensure a known color is configured" +runs: + using: "node12" + main: "index.js" +branding: + new: key diff --git a/testdata/actions/err/branding-unknown-values.out b/testdata/actions/err/branding-unknown-values.out new file mode 100644 index 000000000..8f45ad9b1 --- /dev/null +++ b/testdata/actions/err/branding-unknown-values.out @@ -0,0 +1,2 @@ +test.yaml:7:10: invalid color "navy". valid colors are [white yellow blue green orange red purple gray-dark] [branding] +test.yaml:8:9: invalid icon "github" [branding] diff --git a/testdata/actions/err/branding-unknown-values.yaml b/testdata/actions/err/branding-unknown-values.yaml new file mode 100644 index 000000000..4507cc2fd --- /dev/null +++ b/testdata/actions/err/branding-unknown-values.yaml @@ -0,0 +1,8 @@ +name: "Branding Test" +description: "Ensure a known color is configured" +runs: + using: "node12" + main: "index.js" +branding: + color: "navy" + icon: "github" diff --git a/testdata/actions/err/composite-invalid-refs.out b/testdata/actions/err/composite-invalid-refs.out new file mode 100644 index 000000000..e3fe529dd --- /dev/null +++ b/testdata/actions/err/composite-invalid-refs.out @@ -0,0 +1,4 @@ +test.yaml:8:18: undefined variable "unknown". available variables are "env", "github", "inputs", "job", "matrix", "needs", "runner", "secrets", "steps", "strategy", "vars" [expression] +test.yaml:12:5: output value is required for composite actions [syntax-check] +test.yaml:16:16: property "unknown_step" is not defined in object type {hello: {conclusion: string; outcome: string; outputs: {string => string}}} [expression] +test.yaml:21:22: property "unknown_input" is not defined in object type {test: any; token: any} [expression] diff --git a/testdata/actions/err/composite-invalid-refs.yaml b/testdata/actions/err/composite-invalid-refs.yaml new file mode 100644 index 000000000..93b89ad53 --- /dev/null +++ b/testdata/actions/err/composite-invalid-refs.yaml @@ -0,0 +1,23 @@ +name: "Composite with invalid refs" +description: "Output value is only valid for composite actions" +inputs: + token: + required: true + description: "GitHub Token to use" + test: + default: ${{ unknown.token }} + description: "Test expression evaluation of default" +outputs: + no_value: + description: "Output without value" + # ERROR no value + unknown_step: + description: "Output referencing unknown step" + value: ${{ steps.unknown_step.outputs.unknown }} + +runs: + using: composite + steps: + - run: "echo ${{ inputs.unknown_input }}" + id: hello + shell: bash diff --git a/testdata/actions/err/composite-invalid-steps.out b/testdata/actions/err/composite-invalid-steps.out new file mode 100644 index 000000000..c35551abf --- /dev/null +++ b/testdata/actions/err/composite-invalid-steps.out @@ -0,0 +1 @@ +test.yaml:7:7: "shell" is required to run script in step as part of composite actions [syntax-check] diff --git a/testdata/actions/err/composite-invalid-steps.yaml b/testdata/actions/err/composite-invalid-steps.yaml new file mode 100644 index 000000000..d2a375b53 --- /dev/null +++ b/testdata/actions/err/composite-invalid-steps.yaml @@ -0,0 +1,7 @@ +name: "Testing Composite Steps" +description: "Ensure various composite step errors are caught" +runs: + using: "composite" + steps: + # Error missing shell + - run: "echo 'Hello World!'" diff --git a/testdata/actions/err/docker-with-output-value.out b/testdata/actions/err/docker-with-output-value.out new file mode 100644 index 000000000..ca03b8664 --- /dev/null +++ b/testdata/actions/err/docker-with-output-value.out @@ -0,0 +1 @@ +test.yaml:5:5: output value is only allowed for composite actions [syntax-check] diff --git a/testdata/actions/err/docker-with-output-value.yaml b/testdata/actions/err/docker-with-output-value.yaml new file mode 100644 index 000000000..6ad36aacf --- /dev/null +++ b/testdata/actions/err/docker-with-output-value.yaml @@ -0,0 +1,10 @@ +name: "Docker With Output Value" +description: "Output value is only valid for composite actions" +outputs: + my_output: + description: "My output" + value: "my value" + +runs: + using: docker + image: true:v1.0.0 diff --git a/testdata/actions/err/empty.out b/testdata/actions/err/empty.out new file mode 100644 index 000000000..10ac2130d --- /dev/null +++ b/testdata/actions/err/empty.out @@ -0,0 +1 @@ +test.yaml:1:1: action is empty [syntax-check] diff --git a/testdata/actions/err/empty.yaml b/testdata/actions/err/empty.yaml new file mode 100644 index 000000000..e69de29bb diff --git a/testdata/actions/err/implicit-js.out b/testdata/actions/err/implicit-js.out new file mode 100644 index 000000000..4d27c8fa9 --- /dev/null +++ b/testdata/actions/err/implicit-js.out @@ -0,0 +1 @@ +test.yaml:4:3: "using" is required to define what to execute [syntax-check] diff --git a/testdata/actions/err/implicit-js.yaml b/testdata/actions/err/implicit-js.yaml new file mode 100644 index 000000000..e9dd1e564 --- /dev/null +++ b/testdata/actions/err/implicit-js.yaml @@ -0,0 +1,4 @@ +name: "Implicit Runs" +description: "Even if only JavaScript options are declared, a using value is still required" +runs: + main: "index.js" diff --git a/testdata/actions/err/js-invalid-inputs-outputs.out b/testdata/actions/err/js-invalid-inputs-outputs.out new file mode 100644 index 000000000..cbf2ed393 --- /dev/null +++ b/testdata/actions/err/js-invalid-inputs-outputs.out @@ -0,0 +1,5 @@ +test.yaml:5:5: "description" property is missing in specification of input "without_description" [syntax-check] +test.yaml:8:5: unexpected key "type" for "input" section. expected one of "default", "deprecationMessage", "description", "required" [syntax-check] +test.yaml:11:5: output value is only allowed for composite actions [syntax-check] +test.yaml:13:24: "output_without_desc" outputs should not be empty. please remove this section if it's unnecessary [syntax-check] +test.yaml:16:5: unexpected key "unknown_key" for "output" section. expected one of "description", "value" [syntax-check] diff --git a/testdata/actions/err/js-invalid-inputs-outputs.yaml b/testdata/actions/err/js-invalid-inputs-outputs.yaml new file mode 100644 index 000000000..0b57f7a25 --- /dev/null +++ b/testdata/actions/err/js-invalid-inputs-outputs.yaml @@ -0,0 +1,20 @@ +name: "JS With Output Value" +description: "Output value is only valid for composite actions" +inputs: + without_description: + required: true + with_extra_key: + description: "Warning about extra keys like type" + type: string +outputs: + output_with_value: + description: "My output" + value: "my value" + output_without_desc: {} + output_with_unknown_key: + description: "Only description is allowed as key" + unknown_key: "my value" + +runs: + using: node20 + main: index.js diff --git a/testdata/actions/err/missing-composite.out b/testdata/actions/err/missing-composite.out new file mode 100644 index 000000000..7dd273418 --- /dev/null +++ b/testdata/actions/err/missing-composite.out @@ -0,0 +1 @@ +test.yaml:4:3: "steps" is required for a composite action [syntax-check] diff --git a/testdata/actions/err/missing-composite.yaml b/testdata/actions/err/missing-composite.yaml new file mode 100644 index 000000000..1351e2b9d --- /dev/null +++ b/testdata/actions/err/missing-composite.yaml @@ -0,0 +1,4 @@ +name: "Missing Require Values" +description: "Composite action with missing required values" +runs: + using: composite diff --git a/testdata/actions/err/missing-docker.out b/testdata/actions/err/missing-docker.out new file mode 100644 index 000000000..21ad83d84 --- /dev/null +++ b/testdata/actions/err/missing-docker.out @@ -0,0 +1 @@ +test.yaml:4:3: "image" is required for a docker container action [syntax-check] diff --git a/testdata/actions/err/missing-docker.yaml b/testdata/actions/err/missing-docker.yaml new file mode 100644 index 000000000..25c3d7eb6 --- /dev/null +++ b/testdata/actions/err/missing-docker.yaml @@ -0,0 +1,4 @@ +name: "Missing Require Values" +description: "Docker action with missing required values" +runs: + using: docker diff --git a/testdata/actions/err/missing-js.out b/testdata/actions/err/missing-js.out new file mode 100644 index 000000000..ae7b18ec0 --- /dev/null +++ b/testdata/actions/err/missing-js.out @@ -0,0 +1 @@ +test.yaml:4:3: "main" is required for a javascript action [syntax-check] diff --git a/testdata/actions/err/missing-js.yaml b/testdata/actions/err/missing-js.yaml new file mode 100644 index 000000000..29ed3efcd --- /dev/null +++ b/testdata/actions/err/missing-js.yaml @@ -0,0 +1,4 @@ +name: "Missing Require Values" +description: "JavaScript action with missing required values" +runs: + using: node20 diff --git a/testdata/actions/err/missing-using.out b/testdata/actions/err/missing-using.out new file mode 100644 index 000000000..d2e163b61 --- /dev/null +++ b/testdata/actions/err/missing-using.out @@ -0,0 +1,2 @@ +test.yaml:3:7: runs should not be empty. please remove this section if it's unnecessary [syntax-check] +test.yaml:3:7: "using" is required to define what to execute [syntax-check] diff --git a/testdata/actions/err/missing-using.yaml b/testdata/actions/err/missing-using.yaml new file mode 100644 index 000000000..895f7b987 --- /dev/null +++ b/testdata/actions/err/missing-using.yaml @@ -0,0 +1,3 @@ +name: "Missing Using Value" +description: "Unknown execution type" +runs: {} diff --git a/testdata/actions/err/mixed-runs1.out b/testdata/actions/err/mixed-runs1.out new file mode 100644 index 000000000..5bb59cc96 --- /dev/null +++ b/testdata/actions/err/mixed-runs1.out @@ -0,0 +1,2 @@ +test.yaml:5:3: this action declares it uses docker container but has foreign keys [syntax-check] +test.yaml:6:3: this action defines parameter steps for composite actions, but is something else [syntax-check] diff --git a/testdata/actions/err/mixed-runs1.yaml b/testdata/actions/err/mixed-runs1.yaml new file mode 100644 index 000000000..8062b8e23 --- /dev/null +++ b/testdata/actions/err/mixed-runs1.yaml @@ -0,0 +1,7 @@ +name: "Missing Using Value" +description: "Unknown execution type" +runs: + main: index.js # appears like JavaScript action + using: docker # it is actually docker + steps: # composite key + - uses: action/checkout@v4 diff --git a/testdata/actions/err/mixed-runs2.out b/testdata/actions/err/mixed-runs2.out new file mode 100644 index 000000000..384fef295 --- /dev/null +++ b/testdata/actions/err/mixed-runs2.out @@ -0,0 +1,2 @@ +test.yaml:6:3: this action declares it uses javascript but has foreign keys [syntax-check] +test.yaml:7:3: this action defines parameter image for javascript actions, but is something else [syntax-check] diff --git a/testdata/actions/err/mixed-runs2.yaml b/testdata/actions/err/mixed-runs2.yaml new file mode 100644 index 000000000..df128a056 --- /dev/null +++ b/testdata/actions/err/mixed-runs2.yaml @@ -0,0 +1,7 @@ +name: "Missing Using Value" +description: "Unknown execution type" +runs: + steps: # composite key + - uses: action/checkout@v4 + using: node20 # it is actually JavaScript + image: true:v1.0.0 # docker specific key diff --git a/testdata/actions/err/mixed-runs3.out b/testdata/actions/err/mixed-runs3.out new file mode 100644 index 000000000..d73e9c7be --- /dev/null +++ b/testdata/actions/err/mixed-runs3.out @@ -0,0 +1,2 @@ +test.yaml:5:3: this action declares it is a composite action but has foreign keys [syntax-check] +test.yaml:6:3: this action defines parameter main for javascript actions, but is something else [syntax-check] diff --git a/testdata/actions/err/mixed-runs3.yaml b/testdata/actions/err/mixed-runs3.yaml new file mode 100644 index 000000000..447c290c4 --- /dev/null +++ b/testdata/actions/err/mixed-runs3.yaml @@ -0,0 +1,6 @@ +name: "Missing Using Value" +description: "Unknown execution type" +runs: + image: true:v1.0.0 # appears like docker runs block + using: composite # it is actually composite + main: index.js # JavaScript specfici key diff --git a/testdata/actions/err/nothing.out b/testdata/actions/err/nothing.out new file mode 100644 index 000000000..92f616634 --- /dev/null +++ b/testdata/actions/err/nothing.out @@ -0,0 +1,4 @@ +test.yaml:1:1: unexpected key "unknown" for "action" section. expected one of "author", "branding", "description", "inputs", "name", "outputs", "runs" [syntax-check] +test.yaml:1:1: "name" property is missing in action metadata [syntax-check] +test.yaml:1:1: "description" property is missing in action metadata [syntax-check] +test.yaml:1:1: "runs" section is missing in action metadata [syntax-check] diff --git a/testdata/actions/err/nothing.yaml b/testdata/actions/err/nothing.yaml new file mode 100644 index 000000000..1c720c637 --- /dev/null +++ b/testdata/actions/err/nothing.yaml @@ -0,0 +1 @@ +unknown: key diff --git a/testdata/actions/err/unknown-using.out b/testdata/actions/err/unknown-using.out new file mode 100644 index 000000000..d2ccad5fa --- /dev/null +++ b/testdata/actions/err/unknown-using.out @@ -0,0 +1,2 @@ +test.yaml:4:3: unexpected key "unknown-key" for "runs" section. expected one of "args", "entrypoint", "env", "image", "main", "post", "post-entrypoint", "post-if", "pre", "pre-entrypoint", "pre-if", "steps", "using" [syntax-check] +test.yaml:5:10: unknown action type python3.12, (only javascript, docker and composite are supported) [syntax-check] diff --git a/testdata/actions/err/unknown-using.yaml b/testdata/actions/err/unknown-using.yaml new file mode 100644 index 000000000..a10794e03 --- /dev/null +++ b/testdata/actions/err/unknown-using.yaml @@ -0,0 +1,5 @@ +name: "Unknown Using Value" +description: "Unknown execution type" +runs: + unknown-key: Value + using: python3.12 diff --git a/testdata/actions/ok/branding.yaml b/testdata/actions/ok/branding.yaml new file mode 100644 index 000000000..b2dd5e216 --- /dev/null +++ b/testdata/actions/ok/branding.yaml @@ -0,0 +1,11 @@ +name: 'My action' +author: 'rhysd ' +description: 'my action' + +runs: + using: 'node20' + main: 'index.js' + +branding: + color: red + icon: airplay diff --git a/testdata/actions/ok/complex-composite.yaml b/testdata/actions/ok/complex-composite.yaml new file mode 100644 index 000000000..2e88d0026 --- /dev/null +++ b/testdata/actions/ok/complex-composite.yaml @@ -0,0 +1,42 @@ +name: 'Complex JavaScript Action' +description: 'Try to set as many options as possible' +author: 'rhysd ' +inputs: + base_uri: + required: true + description: 'Provide the AST portal URL' + cx_tenant: + required: true + description: 'Provide the Tenant for AST portal URL' + cx_client_id: + required: true + description: 'Client ID for AST portal authentication' + cx_client_secret: + required: true + description: 'Secret key for AST portal authentication' + project_name: + required: false + default: ${{ github.repository }} # default repo name + description: 'Select a Checkmarx Project Name' + branch: + required: false + default: ${{ github.head_ref || github.ref }} # default branch name + description: 'Branch name' + github_token: + required: false + default: ${{ github.token }} + description: 'GitHub API Token' + additional_params: + required: false + default: '' + description: 'Additional parameters for AST scan' +outputs: + random-number: + description: "Random number" + value: ${{ steps.random-number-generator.outputs.random-id }} +runs: + steps: + - id: random-number-generator + run: echo "random-id=$RANDOM)" >> "$GITHUB_OUTPUT" + shell: bash + using: "composite" diff --git a/testdata/actions/ok/complex-docker.yaml b/testdata/actions/ok/complex-docker.yaml new file mode 100644 index 000000000..3ae8c1b72 --- /dev/null +++ b/testdata/actions/ok/complex-docker.yaml @@ -0,0 +1,84 @@ +name: 'Complex Docker Action' +description: 'Try to set as many options as possible' +author: 'rhysd ' +inputs: + base_uri: + required: true + description: 'Provide the AST portal URL' + cx_tenant: + required: true + description: 'Provide the Tenant for AST portal URL' + cx_client_id: + required: true + description: 'Client ID for AST portal authentication' + cx_client_secret: + required: true + description: 'Secret key for AST portal authentication' + project_name: + required: false + default: ${{ github.repository }} # default repo name + description: 'Select a Checkmarx Project Name' + branch: + required: false + default: ${{ github.head_ref || github.ref }} # default branch name + description: 'Branch name' + github_token: + required: false + default: ${{ github.token }} + description: 'GitHub API Token' + additional_params: + required: false + default: '' + description: 'Additional parameters for AST scan' + repo_name: + required: false + default: ${{ github.event.repository.name }} + description: "Repository name for PR decoration" + namespace: + required: false + default: ${{ github.repository_owner }} + description: "Organization name to create the Pr comment" + pr_number: + required: false + default: ${{ github.event.number }} + description: "Pr Number of the pull request that needs the decoration" +outputs: + cxcli: + description: output from cli + cxScanID: + description: scan ID output from cli +runs: + image: 'Dockerfile' + args: + - ${{ inputs.base_uri }} + - ${{ inputs.cx_tenant }} + - ${{ inputs.cx_client_id }} + - ${{ inputs.cx_client_secret }} + - ${{ inputs.github_token }} + - ${{ inputs.project_name }} + - ${{ inputs.additional_params }} + - ${{ inputs.repo_name }} + - ${{ inputs.namespace }} + - ${{ inputs.pr_number }} + entrypoint: '/app/entrypoint.sh' + using: 'docker' + # post-if: cancelled() + post-entrypoint: '/app/cleanup.sh' + pre-entrypoint: '/app/prepare.sh' + + env: + CX_BASE_URI: "${{ inputs.base_uri }}" + CX_TENANT: ${{ inputs.cx_tenant }} + CX_CLIENT_ID: ${{ inputs.cx_client_id }} + CX_CLIENT_SECRET: ${{ inputs.cx_client_secret }} + GITHUB_TOKEN: ${{ inputs.github_token }} + BRANCH: ${{ inputs.branch }} + PROJECT_NAME: ${{ inputs.project_name }} + ADDITIONAL_PARAMS: ${{ inputs.additional_params }} + REPO_NAME: ${{ inputs.repo_name }} + NAMESPACE: ${{ inputs.namespace }} + PR_NUMBER: ${{ inputs.pr_number }} + +branding: + icon: 'check' + color: 'green' diff --git a/testdata/actions/ok/complex-js.yaml b/testdata/actions/ok/complex-js.yaml new file mode 100644 index 000000000..41ea25b2a --- /dev/null +++ b/testdata/actions/ok/complex-js.yaml @@ -0,0 +1,33 @@ +name: 'Complex JavaScript Action' +description: 'Try to set as many options as possible' +author: 'rhysd ' +inputs: + base_uri: + description: 'Only description is required for inputs' + base_url: + description: 'Only description is required for inputs' + deprecationMessage: "Use 'base_uri' instead" + api_secret: + required: true + description: 'Secret key for the API' + github_token: + required: false + default: ${{ github.token }} + description: 'GitHub API Token' + branch: + required: false + description: 'Branch name' +outputs: + id: + description: create id of resource +runs: + main: "index.js" + pre: "prepare.js" + pre-if: runner.os == 'windows' # failure() + using: 'node20' # unusual position + post: cleanup.js + post-if: runner.os == 'linux' + +branding: + icon: 'check' + color: 'green' diff --git a/testdata/actions/ok/minimal-composite.yaml b/testdata/actions/ok/minimal-composite.yaml new file mode 100644 index 000000000..3b59811be --- /dev/null +++ b/testdata/actions/ok/minimal-composite.yaml @@ -0,0 +1,6 @@ +name: 'Minimal Docker Action' +description: 'Ensure most keys can be left out' +runs: + using: 'composite' + steps: + - uses: actions/checkout@v4 diff --git a/testdata/actions/ok/minimal-docker.yaml b/testdata/actions/ok/minimal-docker.yaml new file mode 100644 index 000000000..0273586c1 --- /dev/null +++ b/testdata/actions/ok/minimal-docker.yaml @@ -0,0 +1,5 @@ +name: 'Minimal Docker Action' +description: 'Ensure most keys can be left out' +runs: + using: 'docker' + image: 'true:v1.0.0' diff --git a/testdata/actions/ok/minimal-js.yaml b/testdata/actions/ok/minimal-js.yaml new file mode 100644 index 000000000..ec30e0dcb --- /dev/null +++ b/testdata/actions/ok/minimal-js.yaml @@ -0,0 +1,5 @@ +name: 'Minimal JavaScript Action' +description: 'Ensure most keys can be left out' +runs: + using: 'node20' + main: 'index.js' diff --git a/testdata/actions/ok/reordered-js.yaml b/testdata/actions/ok/reordered-js.yaml new file mode 100644 index 000000000..fc169fc73 --- /dev/null +++ b/testdata/actions/ok/reordered-js.yaml @@ -0,0 +1,5 @@ +runs: + main: 'index.js' + using: 'node20' +description: 'Ensure most keys can be left out' +name: 'Minimal JavaScript Action' diff --git a/testdata/detect_format/actions/action.yaml b/testdata/detect_format/actions/action.yaml new file mode 100644 index 000000000..acf5f199e --- /dev/null +++ b/testdata/detect_format/actions/action.yaml @@ -0,0 +1,5 @@ +name: "Test format" +description: "Should be detected as action due to action.yaml filename" +runs: + using: node20 + main: index.js diff --git a/testdata/detect_format/actions/action.yml b/testdata/detect_format/actions/action.yml new file mode 100644 index 000000000..d718de00f --- /dev/null +++ b/testdata/detect_format/actions/action.yml @@ -0,0 +1,5 @@ +name: "Test format" +description: "Should be detected as action due to action.yml filename" +runs: + using: node20 + main: index.js diff --git a/testdata/detect_format/actions/stdin b/testdata/detect_format/actions/stdin new file mode 100644 index 000000000..7125ed3a6 --- /dev/null +++ b/testdata/detect_format/actions/stdin @@ -0,0 +1,5 @@ +name: "Test format" +description: "Should be detected as action due to 'runs' key" +runs: + using: node20 + main: index.js diff --git a/testdata/detect_format/workflows/.github/workflows/action.yaml b/testdata/detect_format/workflows/.github/workflows/action.yaml new file mode 100644 index 000000000..efe14e23a --- /dev/null +++ b/testdata/detect_format/workflows/.github/workflows/action.yaml @@ -0,0 +1,6 @@ +on: push +jobs: + test: + runs-on: ubuntu-latest + steps: + - run: echo 'action.yaml file in .github/workflows should still be parsed as workflow' diff --git a/testdata/detect_format/workflows/.github/workflows/action.yml b/testdata/detect_format/workflows/.github/workflows/action.yml new file mode 100644 index 000000000..870817c35 --- /dev/null +++ b/testdata/detect_format/workflows/.github/workflows/action.yml @@ -0,0 +1,6 @@ +on: push +jobs: + test: + runs-on: ubuntu-latest + steps: + - run: echo 'action.yml file in .github/workflows should still be parsed as workflow' diff --git a/testdata/detect_format/workflows/empty b/testdata/detect_format/workflows/empty new file mode 100644 index 000000000..4b8d1d349 --- /dev/null +++ b/testdata/detect_format/workflows/empty @@ -0,0 +1 @@ +# default to workflow for empty files (although does not make a big difference) diff --git a/testdata/detect_format/workflows/stdin b/testdata/detect_format/workflows/stdin new file mode 100644 index 000000000..c6f5a15c5 --- /dev/null +++ b/testdata/detect_format/workflows/stdin @@ -0,0 +1,3 @@ +on: push +jobs: [] +# default to workflow diff --git a/testdata/projects/broken_local_action/workflows/test.yaml b/testdata/projects/broken_local_action/.github/workflows/test.yaml similarity index 100% rename from testdata/projects/broken_local_action/workflows/test.yaml rename to testdata/projects/broken_local_action/.github/workflows/test.yaml diff --git a/testdata/projects/broken_reusable_workflow.out b/testdata/projects/broken_reusable_workflow.out index 4f4a64016..49736e946 100644 --- a/testdata/projects/broken_reusable_workflow.out +++ b/testdata/projects/broken_reusable_workflow.out @@ -1,4 +1,4 @@ -/workflows/test\.yaml:5:11: error while parsing reusable workflow "\./reusable/broken\.yaml": yaml: .+ \[workflow-call\]/ -workflows/test.yaml:7:11: error while parsing reusable workflow "./reusable/no_hook.yaml": "workflow_call" event trigger is not found in "on:" at line:1, column:5 [workflow-call] -workflows/test.yaml:9:11: error while parsing reusable workflow "./reusable/no_on.yaml": "on:" is not found [workflow-call] -/workflows/test\.yaml:15:11: error while parsing reusable workflow "\./reusable/broken_secrets\.yaml": yaml: .+ \[workflow-call\]/ +/.github/workflows/test\.yaml:5:11: error while parsing reusable workflow "\./reusable/broken\.yaml": yaml: .+ \[workflow-call\]/ +.github/workflows/test.yaml:7:11: error while parsing reusable workflow "./reusable/no_hook.yaml": "workflow_call" event trigger is not found in "on:" at line:1, column:5 [workflow-call] +.github/workflows/test.yaml:9:11: error while parsing reusable workflow "./reusable/no_on.yaml": "on:" is not found [workflow-call] +/.github/workflows/test\.yaml:15:11: error while parsing reusable workflow "\./reusable/broken_secrets\.yaml": yaml: .+ \[workflow-call\]/ diff --git a/testdata/projects/broken_reusable_workflow/workflows/test.yaml b/testdata/projects/broken_reusable_workflow/.github/workflows/test.yaml similarity index 100% rename from testdata/projects/broken_reusable_workflow/workflows/test.yaml rename to testdata/projects/broken_reusable_workflow/.github/workflows/test.yaml diff --git a/testdata/projects/config_variables.out b/testdata/projects/config_variables.out index 8ac2f2878..ef19d3e53 100644 --- a/testdata/projects/config_variables.out +++ b/testdata/projects/config_variables.out @@ -1 +1 @@ -workflows/test.yaml:14:24: undefined configuration variable "piyo". defined configuration variables in actionlint.yaml are "FOO", "WOO" [expression] +.github/workflows/test.yaml:14:24: undefined configuration variable "piyo". defined configuration variables in actionlint.yaml are "FOO", "WOO" [expression] diff --git a/testdata/projects/config_variables/actionlint.yaml b/testdata/projects/config_variables/.github/actionlint.yaml similarity index 100% rename from testdata/projects/config_variables/actionlint.yaml rename to testdata/projects/config_variables/.github/actionlint.yaml diff --git a/testdata/projects/config_variables/workflows/test.yaml b/testdata/projects/config_variables/.github/workflows/test.yaml similarity index 100% rename from testdata/projects/config_variables/workflows/test.yaml rename to testdata/projects/config_variables/.github/workflows/test.yaml diff --git a/testdata/projects/example_inputs_secrets_in_workflow_call.out b/testdata/projects/example_inputs_secrets_in_workflow_call.out index 6af0b6160..262b7c445 100644 --- a/testdata/projects/example_inputs_secrets_in_workflow_call.out +++ b/testdata/projects/example_inputs_secrets_in_workflow_call.out @@ -1,6 +1,6 @@ -workflows/test.yaml:6:11: input "name" is required by "./.github/workflows/reusable.yaml" reusable workflow [workflow-call] -workflows/test.yaml:6:11: secret "password" is required by "./.github/workflows/reusable.yaml" reusable workflow [workflow-call] -workflows/test.yaml:9:7: input "user" is not defined in "./.github/workflows/reusable.yaml" reusable workflow. defined inputs are "id", "message", "name" [workflow-call] -workflows/test.yaml:13:7: secret "credentials" is not defined in "./.github/workflows/reusable.yaml" reusable workflow. defined secret is "password" [workflow-call] -workflows/test.yaml:22:11: input "id" is typed as number by reusable workflow "./.github/workflows/reusable.yaml". bool value cannot be assigned [expression] -workflows/test.yaml:24:16: input "message" is typed as string by reusable workflow "./.github/workflows/reusable.yaml". null value cannot be assigned [expression] +.github/workflows/test.yaml:6:11: input "name" is required by "./.github/workflows/reusable.yaml" reusable workflow [workflow-call] +.github/workflows/test.yaml:6:11: secret "password" is required by "./.github/workflows/reusable.yaml" reusable workflow [workflow-call] +.github/workflows/test.yaml:9:7: input "user" is not defined in "./.github/workflows/reusable.yaml" reusable workflow. defined inputs are "id", "message", "name" [workflow-call] +.github/workflows/test.yaml:13:7: secret "credentials" is not defined in "./.github/workflows/reusable.yaml" reusable workflow. defined secret is "password" [workflow-call] +.github/workflows/test.yaml:22:11: input "id" is typed as number by reusable workflow "./.github/workflows/reusable.yaml". bool value cannot be assigned [expression] +.github/workflows/test.yaml:24:16: input "message" is typed as string by reusable workflow "./.github/workflows/reusable.yaml". null value cannot be assigned [expression] diff --git a/testdata/projects/example_inputs_secrets_in_workflow_call/.github/workflows/reusable.yaml b/testdata/projects/example_inputs_secrets_in_workflow_call/.github/workflows/reusable.yaml index 6b4dbb2d2..a4768fdfd 100644 --- a/testdata/projects/example_inputs_secrets_in_workflow_call/.github/workflows/reusable.yaml +++ b/testdata/projects/example_inputs_secrets_in_workflow_call/.github/workflows/reusable.yaml @@ -17,4 +17,4 @@ jobs: test: runs-on: ubuntu-latest steps: - - run: echo '${{ outputs.required_input }}' + - run: echo '${{ inputs.name }}' diff --git a/testdata/projects/example_inputs_secrets_in_workflow_call/workflows/test.yaml b/testdata/projects/example_inputs_secrets_in_workflow_call/.github/workflows/test.yaml similarity index 100% rename from testdata/projects/example_inputs_secrets_in_workflow_call/workflows/test.yaml rename to testdata/projects/example_inputs_secrets_in_workflow_call/.github/workflows/test.yaml diff --git a/testdata/projects/example_workflow_call_outputs_downstream_jobs.out b/testdata/projects/example_workflow_call_outputs_downstream_jobs.out index 84f6e2a83..1c76070d3 100644 --- a/testdata/projects/example_workflow_call_outputs_downstream_jobs.out +++ b/testdata/projects/example_workflow_call_outputs_downstream_jobs.out @@ -1 +1 @@ -workflows/test.yaml:13:24: property "tag" is not defined in object type {version: string} [expression] +.github/workflows/test.yaml:13:24: property "tag" is not defined in object type {version: string} [expression] diff --git a/testdata/projects/example_workflow_call_outputs_downstream_jobs/.github/workflows/get-build-info.yaml b/testdata/projects/example_workflow_call_outputs_downstream_jobs/.github/workflows/get-build-info.yaml index 7117ee1dc..a0b2a5efa 100644 --- a/testdata/projects/example_workflow_call_outputs_downstream_jobs/.github/workflows/get-build-info.yaml +++ b/testdata/projects/example_workflow_call_outputs_downstream_jobs/.github/workflows/get-build-info.yaml @@ -3,7 +3,7 @@ on: workflow_call: outputs: version: - value: ${{ outputs.version }} + value: ${{ jobs.test.outputs.version }} description: version of software jobs: diff --git a/testdata/projects/example_workflow_call_outputs_downstream_jobs/workflows/test.yaml b/testdata/projects/example_workflow_call_outputs_downstream_jobs/.github/workflows/test.yaml similarity index 100% rename from testdata/projects/example_workflow_call_outputs_downstream_jobs/workflows/test.yaml rename to testdata/projects/example_workflow_call_outputs_downstream_jobs/.github/workflows/test.yaml diff --git a/testdata/projects/issue-136/workflows/reusable.yaml b/testdata/projects/issue-136/.github/workflows/reusable.yaml similarity index 100% rename from testdata/projects/issue-136/workflows/reusable.yaml rename to testdata/projects/issue-136/.github/workflows/reusable.yaml diff --git a/testdata/projects/issue-136/workflows/test.yaml b/testdata/projects/issue-136/.github/workflows/test.yaml similarity index 69% rename from testdata/projects/issue-136/workflows/test.yaml rename to testdata/projects/issue-136/.github/workflows/test.yaml index aaac80c71..0f777f41a 100644 --- a/testdata/projects/issue-136/workflows/test.yaml +++ b/testdata/projects/issue-136/.github/workflows/test.yaml @@ -5,4 +5,4 @@ jobs: concurrency: group: some-group cancel-in-progress: true - uses: ./workflows/reusable.yaml + uses: ./.github/workflows/reusable.yaml diff --git a/testdata/projects/issue173.out b/testdata/projects/issue173.out index 8cc83931c..5aafec301 100644 --- a/testdata/projects/issue173.out +++ b/testdata/projects/issue173.out @@ -1,4 +1,4 @@ -workflows/workflow1.yaml:9:15: missing input "hello" which is required by action "My action" defined at "./action". all required inputs are "hello" [action] -workflows/workflow1.yaml:11:11: input "goodbye" is not defined in action "My action" defined at "./action". available inputs are "hello" [action] -workflows/workflow2.yaml:9:15: missing input "hello" which is required by action "My action" defined at "./action". all required inputs are "hello" [action] -workflows/workflow2.yaml:11:11: input "goodbye" is not defined in action "My action" defined at "./action". available inputs are "hello" [action] +.github/workflows/workflow1.yaml:9:15: missing input "hello" which is required by action "My action" defined at "./action". all required inputs are "hello" [action] +.github/workflows/workflow1.yaml:11:11: input "goodbye" is not defined in action "My action" defined at "./action". available inputs are "hello" [action] +.github/workflows/workflow2.yaml:9:15: missing input "hello" which is required by action "My action" defined at "./action". all required inputs are "hello" [action] +.github/workflows/workflow2.yaml:11:11: input "goodbye" is not defined in action "My action" defined at "./action". available inputs are "hello" [action] diff --git a/testdata/projects/issue173/workflows/workflow1.yaml b/testdata/projects/issue173/.github/workflows/workflow1.yaml similarity index 100% rename from testdata/projects/issue173/workflows/workflow1.yaml rename to testdata/projects/issue173/.github/workflows/workflow1.yaml diff --git a/testdata/projects/issue173/workflows/workflow2.yaml b/testdata/projects/issue173/.github/workflows/workflow2.yaml similarity index 100% rename from testdata/projects/issue173/workflows/workflow2.yaml rename to testdata/projects/issue173/.github/workflows/workflow2.yaml diff --git a/testdata/projects/local_action_case_insensitive/workflows/test.yaml b/testdata/projects/local_action_case_insensitive/.github/workflows/test.yaml similarity index 100% rename from testdata/projects/local_action_case_insensitive/workflows/test.yaml rename to testdata/projects/local_action_case_insensitive/.github/workflows/test.yaml diff --git a/testdata/projects/local_action_empty/workflows/test.yaml b/testdata/projects/local_action_empty/.github/workflows/test.yaml similarity index 100% rename from testdata/projects/local_action_empty/workflows/test.yaml rename to testdata/projects/local_action_empty/.github/workflows/test.yaml diff --git a/testdata/projects/recursive_workflow_call/workflows/recursive.yaml b/testdata/projects/recursive_workflow_call/.github/workflows/recursive.yaml similarity index 91% rename from testdata/projects/recursive_workflow_call/workflows/recursive.yaml rename to testdata/projects/recursive_workflow_call/.github/workflows/recursive.yaml index b2168cf4e..adf66ebb1 100644 --- a/testdata/projects/recursive_workflow_call/workflows/recursive.yaml +++ b/testdata/projects/recursive_workflow_call/.github/workflows/recursive.yaml @@ -16,7 +16,7 @@ on: jobs: caller: - uses: ./workflows/recursive.yaml + uses: ./.github/workflows/recursive.yaml with: input1: hello input2: 42 diff --git a/testdata/projects/user_defined_runner_label/actionlint.yaml b/testdata/projects/user_defined_runner_label/.github/actionlint.yaml similarity index 100% rename from testdata/projects/user_defined_runner_label/actionlint.yaml rename to testdata/projects/user_defined_runner_label/.github/actionlint.yaml diff --git a/testdata/projects/user_defined_runner_label/workflows/test.yaml b/testdata/projects/user_defined_runner_label/.github/workflows/test.yaml similarity index 100% rename from testdata/projects/user_defined_runner_label/workflows/test.yaml rename to testdata/projects/user_defined_runner_label/.github/workflows/test.yaml diff --git a/testdata/projects/workflow_call_inherit_secrets/workflows/reusable.yaml b/testdata/projects/workflow_call_inherit_secrets/.github/workflows/reusable.yaml similarity index 100% rename from testdata/projects/workflow_call_inherit_secrets/workflows/reusable.yaml rename to testdata/projects/workflow_call_inherit_secrets/.github/workflows/reusable.yaml diff --git a/testdata/projects/workflow_call_inherit_secrets/workflows/test.yaml b/testdata/projects/workflow_call_inherit_secrets/.github/workflows/test.yaml similarity index 51% rename from testdata/projects/workflow_call_inherit_secrets/workflows/test.yaml rename to testdata/projects/workflow_call_inherit_secrets/.github/workflows/test.yaml index bcee02df4..06ee8c94e 100644 --- a/testdata/projects/workflow_call_inherit_secrets/workflows/test.yaml +++ b/testdata/projects/workflow_call_inherit_secrets/.github/workflows/test.yaml @@ -2,5 +2,5 @@ on: push jobs: caller: - uses: ./workflows/reusable.yaml + uses: ./.github/workflows/reusable.yaml secrets: inherit diff --git a/testdata/projects/workflow_call_input_type_check.out b/testdata/projects/workflow_call_input_type_check.out index bdc0e7e33..f12212dc4 100644 --- a/testdata/projects/workflow_call_input_type_check.out +++ b/testdata/projects/workflow_call_input_type_check.out @@ -1,6 +1,6 @@ -workflows/reusable.yaml:10:7: "type" is missing at "broken_input" input of workflow_call event [syntax-check] -workflows/test.yaml:8:18: input "str_input" is typed as string by reusable workflow "./workflows/reusable.yaml". null value cannot be assigned [expression] -workflows/test.yaml:9:18: input "num_input" is typed as number by reusable workflow "./workflows/reusable.yaml". bool value cannot be assigned [expression] -workflows/test.yaml:15:18: input "str_input" is typed as string by reusable workflow "./workflows/reusable.yaml". bool value cannot be assigned [expression] -workflows/test.yaml:16:18: input "num_input" is typed as number by reusable workflow "./workflows/reusable.yaml". string value cannot be assigned [expression] -workflows/test.yaml:22:17: input "num_input" is typed as number by reusable workflow "./workflows/reusable.yaml". string value cannot be assigned [expression] +.github/workflows/reusable.yaml:10:7: "type" is missing at "broken_input" input of workflow_call event [syntax-check] +.github/workflows/test.yaml:8:18: input "str_input" is typed as string by reusable workflow "./.github/workflows/reusable.yaml". null value cannot be assigned [expression] +.github/workflows/test.yaml:9:18: input "num_input" is typed as number by reusable workflow "./.github/workflows/reusable.yaml". bool value cannot be assigned [expression] +.github/workflows/test.yaml:15:18: input "str_input" is typed as string by reusable workflow "./.github/workflows/reusable.yaml". bool value cannot be assigned [expression] +.github/workflows/test.yaml:16:18: input "num_input" is typed as number by reusable workflow "./.github/workflows/reusable.yaml". string value cannot be assigned [expression] +.github/workflows/test.yaml:22:17: input "num_input" is typed as number by reusable workflow "./.github/workflows/reusable.yaml". string value cannot be assigned [expression] diff --git a/testdata/projects/workflow_call_input_type_check/workflows/reusable.yaml b/testdata/projects/workflow_call_input_type_check/.github/workflows/reusable.yaml similarity index 100% rename from testdata/projects/workflow_call_input_type_check/workflows/reusable.yaml rename to testdata/projects/workflow_call_input_type_check/.github/workflows/reusable.yaml diff --git a/testdata/projects/workflow_call_input_type_check/workflows/test.yaml b/testdata/projects/workflow_call_input_type_check/.github/workflows/test.yaml similarity index 73% rename from testdata/projects/workflow_call_input_type_check/workflows/test.yaml rename to testdata/projects/workflow_call_input_type_check/.github/workflows/test.yaml index e2fa31ed5..c01ac5ddd 100644 --- a/testdata/projects/workflow_call_input_type_check/workflows/test.yaml +++ b/testdata/projects/workflow_call_input_type_check/.github/workflows/test.yaml @@ -2,7 +2,7 @@ on: push jobs: caller1: - uses: ./workflows/reusable.yaml + uses: ./.github/workflows/reusable.yaml with: # Note: any value can be converted into bool str_input: null @@ -10,13 +10,13 @@ jobs: bool_input: 'foo!' broken_input: null caller2: - uses: ./workflows/reusable.yaml + uses: ./.github/workflows/reusable.yaml with: str_input: ${{ true }} num_input: ${{ 'foo' }} broken_input: 42 caller3: - uses: ./workflows/reusable.yaml + uses: ./.github/workflows/reusable.yaml with: str_input: num_input: diff --git a/testdata/projects/workflow_call_missing_required.out b/testdata/projects/workflow_call_missing_required.out index b5bcb3951..179629dd2 100644 --- a/testdata/projects/workflow_call_missing_required.out +++ b/testdata/projects/workflow_call_missing_required.out @@ -1,2 +1,2 @@ -workflows/test.yaml:5:11: input "required1" is required by "./workflows/reusable.yaml" reusable workflow [workflow-call] -workflows/test.yaml:5:11: secret "required1" is required by "./workflows/reusable.yaml" reusable workflow [workflow-call] +.github/workflows/test.yaml:5:11: input "required1" is required by "./.github/workflows/reusable.yaml" reusable workflow [workflow-call] +.github/workflows/test.yaml:5:11: secret "required1" is required by "./.github/workflows/reusable.yaml" reusable workflow [workflow-call] diff --git a/testdata/projects/workflow_call_missing_required/workflows/reusable.yaml b/testdata/projects/workflow_call_missing_required/.github/workflows/reusable.yaml similarity index 100% rename from testdata/projects/workflow_call_missing_required/workflows/reusable.yaml rename to testdata/projects/workflow_call_missing_required/.github/workflows/reusable.yaml diff --git a/testdata/projects/workflow_call_missing_required/.github/workflows/test.yaml b/testdata/projects/workflow_call_missing_required/.github/workflows/test.yaml new file mode 100644 index 000000000..a62fe9da5 --- /dev/null +++ b/testdata/projects/workflow_call_missing_required/.github/workflows/test.yaml @@ -0,0 +1,5 @@ +on: push + +jobs: + caller: + uses: ./.github/workflows/reusable.yaml diff --git a/testdata/projects/workflow_call_missing_required/workflows/test.yaml b/testdata/projects/workflow_call_missing_required/workflows/test.yaml deleted file mode 100644 index d25f6e5fd..000000000 --- a/testdata/projects/workflow_call_missing_required/workflows/test.yaml +++ /dev/null @@ -1,5 +0,0 @@ -on: push - -jobs: - caller: - uses: ./workflows/reusable.yaml diff --git a/testdata/projects/workflow_call_not_found.out b/testdata/projects/workflow_call_not_found.out index 27bc88161..58f2aee12 100644 --- a/testdata/projects/workflow_call_not_found.out +++ b/testdata/projects/workflow_call_not_found.out @@ -1 +1 @@ -/workflows/test\.yaml:5:11: could not read reusable workflow file for "\./workflows/this-workflow-does-not-exist\.yaml": .+ \[(expression|workflow-call)\]/ +/workflows/test\.yaml:5:11: could not read reusable workflow file for "\./.github/workflows/this-workflow-does-not-exist\.yaml": .+ \[(expression|workflow-call)\]/ diff --git a/testdata/projects/workflow_call_not_found/workflows/test.yaml b/testdata/projects/workflow_call_not_found/.github/workflows/test.yaml similarity index 71% rename from testdata/projects/workflow_call_not_found/workflows/test.yaml rename to testdata/projects/workflow_call_not_found/.github/workflows/test.yaml index 7f9652e32..15651e47c 100644 --- a/testdata/projects/workflow_call_not_found/workflows/test.yaml +++ b/testdata/projects/workflow_call_not_found/.github/workflows/test.yaml @@ -2,7 +2,7 @@ on: push jobs: caller: - uses: ./workflows/this-workflow-does-not-exist.yaml + uses: ./.github/workflows/this-workflow-does-not-exist.yaml other: needs: [caller] runs-on: ubuntu-latest diff --git a/testdata/projects/workflow_call_ok/workflows/empty1.yaml b/testdata/projects/workflow_call_ok/.github/workflows/empty1.yaml similarity index 100% rename from testdata/projects/workflow_call_ok/workflows/empty1.yaml rename to testdata/projects/workflow_call_ok/.github/workflows/empty1.yaml diff --git a/testdata/projects/workflow_call_ok/workflows/empty2.yaml b/testdata/projects/workflow_call_ok/.github/workflows/empty2.yaml similarity index 100% rename from testdata/projects/workflow_call_ok/workflows/empty2.yaml rename to testdata/projects/workflow_call_ok/.github/workflows/empty2.yaml diff --git a/testdata/projects/workflow_call_ok/workflows/empty3.yaml b/testdata/projects/workflow_call_ok/.github/workflows/empty3.yaml similarity index 100% rename from testdata/projects/workflow_call_ok/workflows/empty3.yaml rename to testdata/projects/workflow_call_ok/.github/workflows/empty3.yaml diff --git a/testdata/projects/workflow_call_ok/workflows/reusable_all_optional.yaml b/testdata/projects/workflow_call_ok/.github/workflows/reusable_all_optional.yaml similarity index 100% rename from testdata/projects/workflow_call_ok/workflows/reusable_all_optional.yaml rename to testdata/projects/workflow_call_ok/.github/workflows/reusable_all_optional.yaml diff --git a/testdata/projects/workflow_call_ok/workflows/reusable_all_required.yaml b/testdata/projects/workflow_call_ok/.github/workflows/reusable_all_required.yaml similarity index 100% rename from testdata/projects/workflow_call_ok/workflows/reusable_all_required.yaml rename to testdata/projects/workflow_call_ok/.github/workflows/reusable_all_required.yaml diff --git a/testdata/projects/workflow_call_ok/workflows/test.yaml b/testdata/projects/workflow_call_ok/.github/workflows/test.yaml similarity index 52% rename from testdata/projects/workflow_call_ok/workflows/test.yaml rename to testdata/projects/workflow_call_ok/.github/workflows/test.yaml index 975ed1b40..c032d8b58 100644 --- a/testdata/projects/workflow_call_ok/workflows/test.yaml +++ b/testdata/projects/workflow_call_ok/.github/workflows/test.yaml @@ -2,7 +2,7 @@ on: push jobs: caller1: - uses: ./workflows/reusable_all_required.yaml + uses: ./.github/workflows/reusable_all_required.yaml with: str: hi num: 13 @@ -10,7 +10,7 @@ jobs: secrets: foo: bar caller2: - uses: ./workflows/reusable_all_optional.yaml + uses: ./.github/workflows/reusable_all_optional.yaml with: str: hi num: 13 @@ -18,15 +18,15 @@ jobs: secrets: foo: bar caller3: - uses: ./workflows/reusable_all_optional.yaml + uses: ./.github/workflows/reusable_all_optional.yaml caller4: - uses: ./workflows/empty1.yaml + uses: ./.github/workflows/empty1.yaml caller5: - uses: ./workflows/empty2.yaml + uses: ./.github/workflows/empty2.yaml caller6: - uses: ./workflows/empty3.yaml + uses: ./.github/workflows/empty3.yaml pass-through-placeholder: - uses: ./workflows/reusable_all_required.yaml + uses: ./.github/workflows/reusable_all_required.yaml with: str: ${{ 'hi' }} num: ${{ 13 }} diff --git a/testdata/projects/workflow_call_undefined.out b/testdata/projects/workflow_call_undefined.out index bd0637a99..bf00a9ff5 100644 --- a/testdata/projects/workflow_call_undefined.out +++ b/testdata/projects/workflow_call_undefined.out @@ -1,5 +1,5 @@ -workflows/test.yaml:7:7: input "aaa" is not defined in "./workflows/reusable.yaml" reusable workflow. defined input is "foo" [workflow-call] -workflows/test.yaml:10:7: secret "bbb" is not defined in "./workflows/reusable.yaml" reusable workflow. defined secret is "piyo" [workflow-call] -workflows/test.yaml:17:24: property "ccc" is not defined in object type {bar: string} [expression] -workflows/test.yaml:21:7: input "input1" is not defined in "./workflows/empty_reusable.yaml" reusable workflow. no input is defined [workflow-call] -workflows/test.yaml:23:7: secret "secret1" is not defined in "./workflows/empty_reusable.yaml" reusable workflow. no secret is defined [workflow-call] +.github/workflows/test.yaml:7:7: input "aaa" is not defined in "./.github/workflows/reusable.yaml" reusable workflow. defined input is "foo" [workflow-call] +.github/workflows/test.yaml:10:7: secret "bbb" is not defined in "./.github/workflows/reusable.yaml" reusable workflow. defined secret is "piyo" [workflow-call] +.github/workflows/test.yaml:17:24: property "ccc" is not defined in object type {bar: string} [expression] +.github/workflows/test.yaml:21:7: input "input1" is not defined in "./.github/workflows/empty_reusable.yaml" reusable workflow. no input is defined [workflow-call] +.github/workflows/test.yaml:23:7: secret "secret1" is not defined in "./.github/workflows/empty_reusable.yaml" reusable workflow. no secret is defined [workflow-call] diff --git a/testdata/projects/workflow_call_undefined/workflows/empty_reusable.yaml b/testdata/projects/workflow_call_undefined/.github/workflows/empty_reusable.yaml similarity index 100% rename from testdata/projects/workflow_call_undefined/workflows/empty_reusable.yaml rename to testdata/projects/workflow_call_undefined/.github/workflows/empty_reusable.yaml diff --git a/testdata/projects/workflow_call_undefined/workflows/reusable.yaml b/testdata/projects/workflow_call_undefined/.github/workflows/reusable.yaml similarity index 100% rename from testdata/projects/workflow_call_undefined/workflows/reusable.yaml rename to testdata/projects/workflow_call_undefined/.github/workflows/reusable.yaml diff --git a/testdata/projects/workflow_call_undefined/workflows/test.yaml b/testdata/projects/workflow_call_undefined/.github/workflows/test.yaml similarity index 83% rename from testdata/projects/workflow_call_undefined/workflows/test.yaml rename to testdata/projects/workflow_call_undefined/.github/workflows/test.yaml index f385c99d9..6b0692324 100644 --- a/testdata/projects/workflow_call_undefined/workflows/test.yaml +++ b/testdata/projects/workflow_call_undefined/.github/workflows/test.yaml @@ -2,7 +2,7 @@ on: push jobs: caller: - uses: ./workflows/reusable.yaml + uses: ./.github/workflows/reusable.yaml with: aaa: this is not existing foo: this is existing @@ -16,7 +16,7 @@ jobs: - run: echo '${{ needs.caller.outputs.bar }} is existing' - run: echo '${{ needs.caller.outputs.ccc }} is not existing' empty: - uses: ./workflows/empty_reusable.yaml + uses: ./.github/workflows/empty_reusable.yaml with: input1: this is not existing secrets: diff --git a/testdata/projects/workflow_call_upper_case.out b/testdata/projects/workflow_call_upper_case.out index 4e56a06f0..3cac4ef4f 100644 --- a/testdata/projects/workflow_call_upper_case.out +++ b/testdata/projects/workflow_call_upper_case.out @@ -1,10 +1,10 @@ -workflows/missing.yaml:5:11: input "MY_INPUT_2" is required by "./reusable/upper.yaml" reusable workflow [workflow-call] -workflows/missing.yaml:5:11: secret "MY_SECRET_2" is required by "./reusable/upper.yaml" reusable workflow [workflow-call] -workflows/output.yaml:32:30: property "my_output_3" is not defined in object type {my_output_1: string; my_output_2: string} [expression] -workflows/output.yaml:33:30: property "my_output_3" is not defined in object type {my_output_1: string; my_output_2: string} [expression] -workflows/output.yaml:34:30: property "my_output_3" is not defined in object type {my_output_1: string; my_output_2: string} [expression] -workflows/output.yaml:35:30: property "my_output_3" is not defined in object type {my_output_1: string; my_output_2: string} [expression] -workflows/undefined.yaml:9:7: input "MY_INPUT_3" is not defined in "./reusable/upper.yaml" reusable workflow. defined inputs are "MY_INPUT_1", "MY_INPUT_2" [workflow-call] -workflows/undefined.yaml:13:7: secret "MY_SECRET_3" is not defined in "./reusable/upper.yaml" reusable workflow. defined secrets are "MY_SECRET_1", "MY_SECRET_2" [workflow-call] -workflows/undefined.yaml:19:7: input "MY_INPUT_3" is not defined in "./reusable/lower.yaml" reusable workflow. defined inputs are "my_input_1", "my_input_2" [workflow-call] -workflows/undefined.yaml:23:7: secret "MY_SECRET_3" is not defined in "./reusable/lower.yaml" reusable workflow. defined secrets are "my_secret_1", "my_secret_2" [workflow-call] +.github/workflows/missing.yaml:5:11: input "MY_INPUT_2" is required by "./reusable/upper.yaml" reusable workflow [workflow-call] +.github/workflows/missing.yaml:5:11: secret "MY_SECRET_2" is required by "./reusable/upper.yaml" reusable workflow [workflow-call] +.github/workflows/output.yaml:32:30: property "my_output_3" is not defined in object type {my_output_1: string; my_output_2: string} [expression] +.github/workflows/output.yaml:33:30: property "my_output_3" is not defined in object type {my_output_1: string; my_output_2: string} [expression] +.github/workflows/output.yaml:34:30: property "my_output_3" is not defined in object type {my_output_1: string; my_output_2: string} [expression] +.github/workflows/output.yaml:35:30: property "my_output_3" is not defined in object type {my_output_1: string; my_output_2: string} [expression] +.github/workflows/undefined.yaml:9:7: input "MY_INPUT_3" is not defined in "./reusable/upper.yaml" reusable workflow. defined inputs are "MY_INPUT_1", "MY_INPUT_2" [workflow-call] +.github/workflows/undefined.yaml:13:7: secret "MY_SECRET_3" is not defined in "./reusable/upper.yaml" reusable workflow. defined secrets are "MY_SECRET_1", "MY_SECRET_2" [workflow-call] +.github/workflows/undefined.yaml:19:7: input "MY_INPUT_3" is not defined in "./reusable/lower.yaml" reusable workflow. defined inputs are "my_input_1", "my_input_2" [workflow-call] +.github/workflows/undefined.yaml:23:7: secret "MY_SECRET_3" is not defined in "./reusable/lower.yaml" reusable workflow. defined secrets are "my_secret_1", "my_secret_2" [workflow-call] diff --git a/testdata/projects/workflow_call_upper_case/workflows/missing.yaml b/testdata/projects/workflow_call_upper_case/.github/workflows/missing.yaml similarity index 100% rename from testdata/projects/workflow_call_upper_case/workflows/missing.yaml rename to testdata/projects/workflow_call_upper_case/.github/workflows/missing.yaml diff --git a/testdata/projects/workflow_call_upper_case/workflows/ok_lower.yaml b/testdata/projects/workflow_call_upper_case/.github/workflows/ok_lower.yaml similarity index 100% rename from testdata/projects/workflow_call_upper_case/workflows/ok_lower.yaml rename to testdata/projects/workflow_call_upper_case/.github/workflows/ok_lower.yaml diff --git a/testdata/projects/workflow_call_upper_case/workflows/ok_upper.yaml b/testdata/projects/workflow_call_upper_case/.github/workflows/ok_upper.yaml similarity index 100% rename from testdata/projects/workflow_call_upper_case/workflows/ok_upper.yaml rename to testdata/projects/workflow_call_upper_case/.github/workflows/ok_upper.yaml diff --git a/testdata/projects/workflow_call_upper_case/workflows/output.yaml b/testdata/projects/workflow_call_upper_case/.github/workflows/output.yaml similarity index 100% rename from testdata/projects/workflow_call_upper_case/workflows/output.yaml rename to testdata/projects/workflow_call_upper_case/.github/workflows/output.yaml diff --git a/testdata/projects/workflow_call_upper_case/workflows/undefined.yaml b/testdata/projects/workflow_call_upper_case/.github/workflows/undefined.yaml similarity index 100% rename from testdata/projects/workflow_call_upper_case/workflows/undefined.yaml rename to testdata/projects/workflow_call_upper_case/.github/workflows/undefined.yaml