diff --git a/action_metadata.go b/action_metadata.go index 7ec3ad479..e0c8f481d 100644 --- a/action_metadata.go +++ b/action_metadata.go @@ -40,6 +40,9 @@ type ActionMetadata struct { // Outputs is "outputs" field of action.yaml. Key is name of output. Description is omitted // since actionlint does not use it. Outputs map[string]struct{} `yaml:"outputs" json:"outputs"` + // SkipInputs is flag to specify behavior of inputs check. When it is true, inputs for this + // action will not be checked. + SkipInputs bool `json:"skip_inputs"` } // LocalActionsCache is cache for local actions' metadata. It avoids repeating to find/read/parse diff --git a/linter_test.go b/linter_test.go index 8a907a926..900a2c1ea 100644 --- a/linter_test.go +++ b/linter_test.go @@ -5,11 +5,68 @@ import ( "io/ioutil" "os" "path/filepath" + "strings" "testing" "golang.org/x/sys/execabs" ) +func TestLinterLintOK(t *testing.T) { + dir := filepath.Join("testdata", "ok") + + es, err := ioutil.ReadDir(dir) + if err != nil { + panic(err) + } + + fs := make([]string, 0, len(es)) + for _, e := range es { + if e.IsDir() { + continue + } + n := e.Name() + if strings.HasSuffix(n, ".yaml") || strings.HasSuffix(n, ".yml") { + fs = append(fs, filepath.Join(dir, n)) + } + } + + 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, + } + + linter, err := NewLinter(ioutil.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 BenchmarkLintWorkflowFiles(b *testing.B) { dir, err := os.Getwd() if err != nil { diff --git a/popular_actions.go b/popular_actions.go index 3fa2b794f..8b16bdd4a 100644 --- a/popular_actions.go +++ b/popular_actions.go @@ -2399,6 +2399,24 @@ var PopularActions = map[string]*ActionMetadata{ "deploy-url": {}, }, }, + "octokit/request-action@v1.x": { + Name: "GitHub API Request", + SkipInputs: true, + Outputs: map[string]struct{}{ + "data": {}, + "headers": {}, + "status": {}, + }, + }, + "octokit/request-action@v2.x": { + Name: "GitHub API Request", + SkipInputs: true, + Outputs: map[string]struct{}{ + "data": {}, + "headers": {}, + "status": {}, + }, + }, "peaceiris/actions-gh-pages@v2": { Name: "GitHub Pages action", Inputs: map[string]*ActionMetadataInput{ diff --git a/rule_action.go b/rule_action.go index f4e3928b4..2adc4c37c 100644 --- a/rule_action.go +++ b/rule_action.go @@ -86,6 +86,10 @@ func (rule *RuleAction) checkRepoAction(spec string, exec *ExecAction) { rule.debug("This action is not found in popular actions data set: %s", spec) return } + if meta.SkipInputs { + rule.debug("This action skips to check inputs: %s", spec) + return + } rule.checkAction(meta, exec, func(m *ActionMetadata) string { return strconv.Quote(spec) diff --git a/scripts/generate-popular-actions/main.go b/scripts/generate-popular-actions/main.go index 91cf0b1ed..5f2c8940b 100644 --- a/scripts/generate-popular-actions/main.go +++ b/scripts/generate-popular-actions/main.go @@ -106,6 +106,7 @@ var popularActions = []*action{ {"msys2/setup-msys2", []string{"v1", "v2"}, "v3", yamlExtYML}, {"ncipollo/release-action", []string{"v1"}, "v2", yamlExtYML}, {"nwtgck/actions-netlify", []string{"v1"}, "v2", yamlExtYML}, + {"octokit/request-action", []string{"v1.x", "v2.x"}, "v3.x", yamlExtYML}, {"peaceiris/actions-gh-pages", []string{"v2", "v3"}, "v4", yamlExtYML}, {"peter-evans/create-pull-request", []string{"v1", "v2", "v3"}, "v4", yamlExtYML}, {"preactjs/compressed-size-action", []string{"v1", "v2"}, "v3", yamlExtYML}, @@ -128,6 +129,12 @@ var popularActions = []*action{ {"wearerequired/lint-action", []string{"v1"}, "v2", yamlExtYML}, } +// slugs not to check inputs. Some actions allow to specify inputs which are not defined in action.yml. +// In such cases, actionlint no longer can check the inputs, but it can still check outputs. +var doNotCheckInputs = map[string]struct{}{ + "octokit/request-action": {}, +} + func buildURL(slug, tag string, ext yamlExt) string { path := "" if ss := strings.Split(slug, "/"); len(ss) > 2 { @@ -137,7 +144,7 @@ func buildURL(slug, tag string, ext yamlExt) string { return fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%saction.%s", slug, tag, path, ext.String()) } -func fetchRemote(actions []*action) (map[string]*actionlint.ActionMetadata, error) { +func fetchRemote(actions []*action, skipInputs map[string]struct{}) (map[string]*actionlint.ActionMetadata, error) { type request struct { slug string tag string @@ -183,6 +190,9 @@ func fetchRemote(actions []*action) (map[string]*actionlint.ActionMetadata, erro ret <- &fetched{err: fmt.Errorf("coult not parse metadata for %s: %w", url, err)} break } + if _, ok := skipInputs[req.slug]; ok { + meta.SkipInputs = true + } ret <- &fetched{spec: spec, meta: &meta} case <-done: return @@ -233,7 +243,7 @@ func writeJSONL(out io.Writer, actions map[string]*actionlint.ActionMetadata) er return nil } -func writeGo(out io.Writer, actions map[string]*actionlint.ActionMetadata) error { +func writeGo(out io.Writer, actions map[string]*actionlint.ActionMetadata, skipInputs map[string]struct{}) error { b := &bytes.Buffer{} fmt.Fprint(b, `// Code generated by actionlint/scripts/generate-popular-actions. DO NOT EDIT. @@ -258,7 +268,13 @@ var PopularActions = map[string]*ActionMetadata{ fmt.Fprintf(b, "%q: {\n", spec) fmt.Fprintf(b, "Name: %q,\n", meta.Name) - if len(meta.Inputs) > 0 { + slug := spec[:strings.IndexRune(spec, '@')] + _, skip := skipInputs[slug] + if skip { + fmt.Fprintf(b, "SkipInputs: true,\n") + } + + if len(meta.Inputs) > 0 && !skip { names := make([]string, 0, len(meta.Inputs)) for n := range meta.Inputs { names = append(names, n) @@ -425,7 +441,7 @@ func detectNewReleaseURLs(actions []*action) ([]string, error) { return us, nil } -func run(args []string, stdout, stderr io.Writer, knownActions []*action) int { +func run(args []string, stdout, stderr io.Writer, knownActions []*action, skipInputs map[string]struct{}) int { var source string var format string var quiet bool @@ -508,7 +524,7 @@ Flags:`) var actions map[string]*actionlint.ActionMetadata if source == "remote" { log.Println("Fetching data from https://github.com") - m, err := fetchRemote(knownActions) + m, err := fetchRemote(knownActions, skipInputs) if err != nil { fmt.Fprintln(stderr, err) return 1 @@ -527,7 +543,7 @@ Flags:`) switch format { case "go": log.Println("Generating Go source code to", where) - if err := writeGo(out, actions); err != nil { + if err := writeGo(out, actions, skipInputs); err != nil { fmt.Fprintln(stderr, err) return 1 } @@ -544,5 +560,5 @@ Flags:`) } func main() { - os.Exit(run(os.Args, os.Stdout, os.Stderr, popularActions)) + os.Exit(run(os.Args, os.Stdout, os.Stderr, popularActions, doNotCheckInputs)) } diff --git a/scripts/generate-popular-actions/main_test.go b/scripts/generate-popular-actions/main_test.go index a4fecd5f1..785ad9020 100644 --- a/scripts/generate-popular-actions/main_test.go +++ b/scripts/generate-popular-actions/main_test.go @@ -75,7 +75,36 @@ func TestReadJSONL(t *testing.T) { stdout := &bytes.Buffer{} stderr := ioutil.Discard - status := run([]string{"test", "-s", f, "-f", "jsonl"}, stdout, stderr, testDummyPopularActions) + status := run([]string{"test", "-s", f, "-f", "jsonl"}, stdout, stderr, testDummyPopularActions, nil) + if status != 0 { + t.Fatal("exit status is non-zero:", status) + } + + want := string(b) + have := stdout.String() + + if want != have { + t.Fatalf("read content and output content differ\n%s", cmp.Diff(want, have)) + } +} + +func TestReadWriteSkipInputsJSONL(t *testing.T) { + f := filepath.Join("testdata", "skip_inputs.jsonl") + b, err := ioutil.ReadFile(f) + if err != nil { + panic(err) + } + + stdout := &bytes.Buffer{} + stderr := ioutil.Discard + + slug := "rhysd/action-setup-vim" + actions := []*action{ + {slug, []string{"v1"}, "v2", yamlExtYML}, + } + skip := map[string]struct{}{slug: {}} + + status := run([]string{"test", "-s", f, "-f", "jsonl"}, stdout, stderr, actions, skip) if status != 0 { t.Fatal("exit status is non-zero:", status) } @@ -98,7 +127,37 @@ func TestWriteGoToStdout(t *testing.T) { stdout := &bytes.Buffer{} stderr := ioutil.Discard - status := run([]string{"test", "-s", f}, stdout, stderr, testDummyPopularActions) + status := run([]string{"test", "-s", f}, stdout, stderr, testDummyPopularActions, nil) + if status != 0 { + t.Fatal("exit status is non-zero:", status) + } + + want := string(b) + have := stdout.String() + + if want != have { + t.Fatalf("read content and output content differ\n%s", cmp.Diff(want, have)) + } +} + +func TestWriteSkipInputsGo(t *testing.T) { + f := filepath.Join("testdata", "skip_inputs.jsonl") + out := filepath.Join("testdata", "skip_inputs_want.go") + b, err := ioutil.ReadFile(out) + if err != nil { + panic(err) + } + + stdout := &bytes.Buffer{} + stderr := ioutil.Discard + + slug := "rhysd/action-setup-vim" + actions := []*action{ + {slug, []string{"v1"}, "v2", yamlExtYML}, + } + skip := map[string]struct{}{slug: {}} + + status := run([]string{"test", "-s", f}, stdout, stderr, actions, skip) if status != 0 { t.Fatal("exit status is non-zero:", status) } @@ -123,7 +182,7 @@ func TestWriteJSONLFile(t *testing.T) { stdout := ioutil.Discard stderr := ioutil.Discard - status := run([]string{"test", "-s", in, "-f", "jsonl", out}, stdout, stderr, testDummyPopularActions) + status := run([]string{"test", "-s", in, "-f", "jsonl", out}, stdout, stderr, testDummyPopularActions, nil) if status != 0 { t.Fatal("exit status is non-zero:", status) } @@ -147,7 +206,7 @@ func TestWriteGoFile(t *testing.T) { stdout := ioutil.Discard stderr := ioutil.Discard - status := run([]string{"test", "-s", in, out}, stdout, stderr, testDummyPopularActions) + status := run([]string{"test", "-s", in, out}, stdout, stderr, testDummyPopularActions, nil) if status != 0 { t.Fatal("exit status is non-zero:", status) } @@ -176,7 +235,7 @@ func TestFetchRemoteYAML(t *testing.T) { } stdout := &bytes.Buffer{} stderr := ioutil.Discard - status := run([]string{"test"}, stdout, stderr, data) + status := run([]string{"test"}, stdout, stderr, data, nil) if status != 0 { t.Fatal("exit status is non-zero:", status) } @@ -197,7 +256,7 @@ func TestLogOutput(t *testing.T) { f := filepath.Join("testdata", "test.jsonl") stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} - status := run([]string{"test", "-s", f, "-f", "jsonl"}, stdout, stderr, testDummyPopularActions) + status := run([]string{"test", "-s", f, "-f", "jsonl"}, stdout, stderr, testDummyPopularActions, nil) if status != 0 { t.Fatal("exit status is non-zero:", status) } @@ -213,7 +272,7 @@ func TestLogOutput(t *testing.T) { stdout = &bytes.Buffer{} stderr = &bytes.Buffer{} - status = run([]string{"test", "-s", f, "-f", "jsonl", "-q"}, stdout, stderr, testDummyPopularActions) + status = run([]string{"test", "-s", f, "-f", "jsonl", "-q"}, stdout, stderr, testDummyPopularActions, nil) if status != 0 { t.Fatal("exit status is non-zero:", status) } @@ -231,7 +290,7 @@ func TestLogOutput(t *testing.T) { func TestHelpOutput(t *testing.T) { stdout := ioutil.Discard stderr := &bytes.Buffer{} - status := run([]string{"test", "-help"}, stdout, stderr, testDummyPopularActions) + status := run([]string{"test", "-help"}, stdout, stderr, testDummyPopularActions, nil) if status != 0 { t.Fatal("exit status is non-zero:", status) } @@ -247,7 +306,7 @@ func TestDetectNewRelease(t *testing.T) { } stdout := &bytes.Buffer{} stderr := ioutil.Discard - status := run([]string{"test", "-d"}, stdout, stderr, data) + status := run([]string{"test", "-d"}, stdout, stderr, data, nil) if status != 2 { t.Fatal("exit status is not 2:", status) } @@ -274,7 +333,7 @@ func TestDetectNoRelease(t *testing.T) { } stdout := &bytes.Buffer{} stderr := ioutil.Discard - status := run([]string{"test", "-d"}, stdout, stderr, data) + status := run([]string{"test", "-d"}, stdout, stderr, data, nil) if status != 0 { t.Fatal("exit status is non-zero:", status) } @@ -303,7 +362,7 @@ func TestCouldNotReadJSONLFile(t *testing.T) { stdout := ioutil.Discard stderr := &bytes.Buffer{} - status := run([]string{"test", "-s", f}, stdout, stderr, testDummyPopularActions) + status := run([]string{"test", "-s", f}, stdout, stderr, testDummyPopularActions, nil) if status == 0 { t.Fatal("exit status is unexpectedly zero") } @@ -322,7 +381,7 @@ func TestCouldNotCreateOutputFile(t *testing.T) { stdout := ioutil.Discard stderr := &bytes.Buffer{} - status := run([]string{"test", "-s", f, "-f", "jsonl", out}, stdout, stderr, testDummyPopularActions) + status := run([]string{"test", "-s", f, "-f", "jsonl", out}, stdout, stderr, testDummyPopularActions, nil) if status == 0 { t.Fatal("exit status is unexpectedly zero") } @@ -346,7 +405,7 @@ func TestWriteError(t *testing.T) { stdout := testErrorWriter{} stderr := &bytes.Buffer{} - status := run([]string{"test", "-s", f, "-f", format}, stdout, stderr, testDummyPopularActions) + status := run([]string{"test", "-s", f, "-f", format}, stdout, stderr, testDummyPopularActions, nil) if status == 0 { t.Fatal("exit status is unexpectedly zero") } @@ -367,7 +426,7 @@ func TestCouldNotFetch(t *testing.T) { stdout := testErrorWriter{} stderr := &bytes.Buffer{} - status := run([]string{"test"}, stdout, stderr, data) + status := run([]string{"test"}, stdout, stderr, data, nil) if status == 0 { t.Fatal("exit status is unexpectedly zero") } @@ -393,7 +452,7 @@ func TestInvalidCommandArgs(t *testing.T) { stdout := testErrorWriter{} stderr := &bytes.Buffer{} - status := run(tc.args, stdout, stderr, testDummyPopularActions) + status := run(tc.args, stdout, stderr, testDummyPopularActions, nil) if status == 0 { t.Fatal("exit status is unexpectedly zero") } @@ -413,7 +472,7 @@ func TestDetectErrorBadRequest(t *testing.T) { } stdout := ioutil.Discard stderr := &bytes.Buffer{} - status := run([]string{"test", "-d"}, stdout, stderr, data) + status := run([]string{"test", "-d"}, stdout, stderr, data, nil) if status != 1 { t.Fatal("exit status is not 1:", status) } diff --git a/scripts/generate-popular-actions/testdata/skip_inputs.jsonl b/scripts/generate-popular-actions/testdata/skip_inputs.jsonl new file mode 100644 index 000000000..fc37ab010 --- /dev/null +++ b/scripts/generate-popular-actions/testdata/skip_inputs.jsonl @@ -0,0 +1 @@ +{"spec":"rhysd/action-setup-vim@v1","metadata":{"name":"Setup Vim","inputs":{"neovim":{"required":false,"default":"false"},"token":{"required":false,"default":"${{ github.token }}"},"version":{"required":false,"default":"stable"}},"outputs":{"executable":{}},"skip_inputs":true}} diff --git a/scripts/generate-popular-actions/testdata/skip_inputs_want.go b/scripts/generate-popular-actions/testdata/skip_inputs_want.go new file mode 100644 index 000000000..811f94486 --- /dev/null +++ b/scripts/generate-popular-actions/testdata/skip_inputs_want.go @@ -0,0 +1,15 @@ +// Code generated by actionlint/scripts/generate-popular-actions. DO NOT EDIT. + +package actionlint + +// PopularActions is data set of known popular actions. Keys are specs (owner/repo@ref) of actions +// and values are their metadata. +var PopularActions = map[string]*ActionMetadata{ + "rhysd/action-setup-vim@v1": { + Name: "Setup Vim", + SkipInputs: true, + Outputs: map[string]struct{}{ + "executable": {}, + }, + }, +} diff --git a/scripts/generate-popular-actions/testdata/test.jsonl b/scripts/generate-popular-actions/testdata/test.jsonl index 0b843d633..f8f0b89e1 100644 --- a/scripts/generate-popular-actions/testdata/test.jsonl +++ b/scripts/generate-popular-actions/testdata/test.jsonl @@ -1 +1 @@ -{"spec":"rhysd/action-setup-vim@v1","metadata":{"name":"Setup Vim","inputs":{"neovim":{"required":false,"default":"false"},"token":{"required":false,"default":"${{ github.token }}"},"version":{"required":false,"default":"stable"}},"outputs":{"executable":{}}}} +{"spec":"rhysd/action-setup-vim@v1","metadata":{"name":"Setup Vim","inputs":{"neovim":{"required":false,"default":"false"},"token":{"required":false,"default":"${{ github.token }}"},"version":{"required":false,"default":"stable"}},"outputs":{"executable":{}},"skip_inputs":false}} diff --git a/testdata/examples/outputs_of_action_skipping_inputs_check.out b/testdata/examples/outputs_of_action_skipping_inputs_check.out new file mode 100644 index 000000000..034ea455c --- /dev/null +++ b/testdata/examples/outputs_of_action_skipping_inputs_check.out @@ -0,0 +1 @@ +test.yaml:16:40: property "this_output_does_not_exist" is not defined in object type {data: any; headers: any; status: any} [expression] diff --git a/testdata/examples/outputs_of_action_skipping_inputs_check.yaml b/testdata/examples/outputs_of_action_skipping_inputs_check.yaml new file mode 100644 index 000000000..62ea5ff59 --- /dev/null +++ b/testdata/examples/outputs_of_action_skipping_inputs_check.yaml @@ -0,0 +1,16 @@ +on: push + +jobs: + logLatestRelease: + runs-on: ubuntu-latest + steps: + - uses: octokit/request-action@v2.x + id: get_latest_release + with: + route: GET /repos/{owner}/{repo}/releases/latest + owner: octokit + repo: request-action + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # octkit/request-action skips inputs check, but outputs are still checked + - run: "echo latest release: ${{ steps.get_latest_release.outputs.this_output_does_not_exist }}" diff --git a/testdata/ok/skip_inputs.yaml b/testdata/ok/skip_inputs.yaml new file mode 100644 index 000000000..d588490f9 --- /dev/null +++ b/testdata/ok/skip_inputs.yaml @@ -0,0 +1,19 @@ +name: Log latest release +on: + push: + branches: + - master + +jobs: + logLatestRelease: + runs-on: ubuntu-latest + steps: + - uses: octokit/request-action@v2.x + id: get_latest_release + with: + route: GET /repos/{owner}/{repo}/releases/latest + owner: octokit + repo: request-action + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - run: "echo latest release: ${{ steps.get_latest_release.outputs.data }}"