Skip to content

Commit 919b72a

Browse files
committed
Merge branch 'fromjson' (fix #464)
2 parents 5aaa4ce + cae9c3e commit 919b72a

20 files changed

+333
-60
lines changed

CONTRIBUTING.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ ronn ./man/actionlint.1.ronn
130130
or
131131

132132
```sh
133-
make ./man/actionlint.1
133+
make man
134134
```
135135

136136
## How to develop playground

Makefile

+1-1
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ all: build test lint
2323

2424
t test: .testtimestamp
2525

26-
.linttimestamp: $(TESTS) $(SRCS) $(TOOL)
26+
.linttimestamp: $(TESTS) $(SRCS) $(TOOL) docs/checks.md
2727
go vet ./...
2828
staticcheck ./...
2929
GOOS=js GOARCH=wasm staticcheck ./playground

README.md

+3-14
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ Features:
1717
- **Other several useful checks**; [glob syntax][filter-pattern-doc] validation, dependencies check for `needs:`,
1818
runner label validation, cron syntax validation, ...
1919

20-
See [the full list][checks] of checks done by actionlint.
20+
See the [full list][checks] of checks done by actionlint.
2121

2222
<img src="https://github.com/rhysd/ss/blob/master/actionlint/main.gif?raw=true" alt="actionlint reports 7 errors" width="806" height="492"/>
2323

@@ -82,18 +82,6 @@ test.yaml:22:17: receiver of object dereference "permissions" must be type of ob
8282
| ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
8383
```
8484
85-
## Why?
86-
87-
- **Running a workflow is time consuming.** You need to push the changes and wait until the workflow runs on GitHub even if
88-
it contains some trivial mistakes. [act][] is useful to debug the workflow locally. But it is not suitable for CI and still
89-
time consuming when your workflow gets larger.
90-
- **Checks of workflow files by GitHub are very loose.** It reports no error even if unexpected keys are in mappings
91-
(meant that some typos in keys). And also it reports no error when accessing to property which is actually not existing.
92-
For example `matrix.foo` when no `foo` is defined in `matrix:` section, it is evaluated to `null` and causes no error.
93-
- **Some mistakes silently break a workflow.** Most common case I saw is specifying missing property to cache key. In the
94-
case cache silently does not work properly but a workflow itself runs without error. So you might not notice the mistake
95-
forever.
96-
9785
## Quick start
9886
9987
Install `actionlint` command by downloading [the released binary][releases] or by Homebrew or by `go install`. See
@@ -133,6 +121,8 @@ See [the usage document][usage] for more details.
133121
When you see some bugs or false positives, it is helpful to [file a new issue][issue-form] with a minimal example
134122
of input. Giving me some feedbacks like feature requests or ideas of additional checks is also welcome.
135123

124+
See the [contribution guide](./CONTRIBUTING.md) for more details.
125+
136126
## License
137127

138128
actionlint is distributed under [the MIT license](./LICENSE.txt).
@@ -145,7 +135,6 @@ actionlint is distributed under [the MIT license](./LICENSE.txt).
145135
[playground]: https://rhysd.github.io/actionlint/
146136
[shellcheck]: https://github.com/koalaman/shellcheck
147137
[pyflakes]: https://github.com/PyCQA/pyflakes
148-
[act]: https://github.com/nektos/act
149138
[syntax-doc]: https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions
150139
[filter-pattern-doc]: https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#filter-pattern-cheat-sheet
151140
[script-injection-doc]: https://docs.github.com/en/actions/learn-github-actions/security-hardening-for-github-actions#understanding-the-risk-of-script-injections

docs/checks.md

+53-12
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ List of checks:
3737
- [Permissions](#permissions)
3838
- [Reusable workflows](#check-reusable-workflows)
3939
- [ID naming convention](#id-naming-convention)
40-
- [Contexts and special functions availability](#ctx-spfunc-availability)
40+
- [Availability of contexts and special functions](#ctx-spfunc-availability)
4141
- [Deprecated workflow commands](#check-deprecated-workflow-commands)
4242
- [Conditions always evaluated to true at `if:`](#if-cond-always-true)
4343
- [Action metadata syntax validation](#action-metadata-syntax)
@@ -395,8 +395,6 @@ jobs:
395395
# Function overloads can be handled properly. contains() has string version and array version
396396
- run: echo "${{ contains('hello, world', 'lo,') }}"
397397
- run: echo "${{ contains(github.event.labels.*.name, 'enhancement') }}"
398-
# format() has a special check for formatting string
399-
- run: echo "${{ format('{0}{1}', 1, 2, 3) }}"
400398
```
401399
402400
Output:
@@ -422,13 +420,9 @@ test.yaml:15:51: 2nd argument of function call is not assignable. "object" canno
422420
|
423421
15 | - run: echo "${{ startsWith('hello, world', github.event) }}"
424422
| ^~~~~~~~~~~~~
425-
test.yaml:20:24: format string "{0}{1}" does not contain placeholder {2}. remove argument which is unused in the format string [expression]
426-
|
427-
20 | - run: echo "${{ format('{0}{1}', 1, 2, 3) }}"
428-
| ^~~~~~~~~~~~~~~~
429423
```
430424
431-
[Playground](https://rhysd.github.io/actionlint/#eNqckMFOwzAQRO/9ilGF5IKciMItP8IROWHBAWe3yq4pUuR/Ry4SAqnNoScf5r3xaIU7HLLGzbv02m0AI7X6AnNmbWqe+8yWmxRqdorU6KA/FNBUsgMNUeBulgWZP1iO/DwIG30ZSnGX0LfRYu5b+iQ2vQBuK6gWZnsaLe5cpJTE4yhzenEeLol3tyhlu+rqGfk6y/9bvdpRLxBG1itG/6p/P2tT6Clpe9dymMjDEcfAA03Etl73KvMUbOeW+7Lsi/PYezx4PJ6k7wAAAP//nfWd6A==)
425+
[Playground](https://rhysd.github.io/actionlint/#eNqckEFKxjAQhfc9xVCEqKQ5QC/iUpI6mGo6UzoTK5TcXWJB/OFvF11l8b7v5TFMPcxZYvPBQfoGQFG0vgBLJulqnkMmzV3yNfuNRHGWnQLoKtkDDpHBPGwbZPokXul1YFL8VijFHKHvo8YcHH4hqRyAbQVF/aIvo8ZHEzEltrDykt6MBZPYmicopT115Y58zbI3q0876gX8SHJh9J/6/zOXfMAk7tmRn9CCQYqeBpyQdK/7CQAA//9h6o/Y)
432426
433427
[Contexts][contexts-doc] and [built-in functions][funcs-doc] are strongly typed. Typos in property access of contexts and
434428
function names can be checked. And invalid function calls like wrong number of arguments or type mismatch at parameter also
@@ -440,11 +434,58 @@ The semantics checker can properly handle that
440434
- some parameters are optional (e.g. `join(strings, sep)` and `join(strings)`)
441435
- some parameters are repeatable (e.g. `hashFiles(file1, file2, ...)`)
442436

443-
In addition, `format()` function has a special check for placeholders in the first parameter which represents the formatting
444-
string.
445-
446437
Note that context names and function names are case-insensitive. For example, `toJSON` and `toJson` are the same function.
447438

439+
In addition, actionlint performs special checks on some built-in functions.
440+
441+
- `format()`: Checks placeholders in the first parameter which represents the format string.
442+
- `fromJSON()`: Checks the JSON string is valid and the return value is strongly typed.
443+
444+
Example input:
445+
446+
```yaml
447+
on: push
448+
449+
jobs:
450+
test:
451+
# ERROR: Key 'mac' does not exist in the object returned by the fromJSON()
452+
runs-on: ${{ fromJSON('{"win":"windows-latest","linux":"ubuntul-latest"}')['mac'] }}
453+
steps:
454+
# ERROR: {2} is missing in the first argument of format()
455+
- run: echo "${{ format('{0}{1}', 1, 2, 3) }}"
456+
# ERROR: Argument for {2} is missing in the arguments of format()
457+
- run: echo "${{ format('{0}{1}{2}', 1, 2) }}"
458+
- run: echo This is a special branch!
459+
# ERROR: Broken JSON string. Special check for fromJSON()
460+
if: contains(fromJson('["main","release","dev"'), github.ref_name)
461+
```
462+
463+
Output:
464+
465+
```
466+
test.yaml:6:18: property "mac" is not defined in object type {linux: string; win: string} [expression]
467+
|
468+
6 | runs-on: ${{ fromJSON('{"win":"windows-latest","linux":"ubuntul-latest"}')['mac'] }}
469+
| ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
470+
test.yaml:9:24: format string "{0}{1}" does not contain placeholder {2}. remove argument which is unused in the format string [expression]
471+
|
472+
9 | - run: echo "${{ format('{0}{1}', 1, 2, 3) }}"
473+
| ^~~~~~~~~~~~~~~~
474+
test.yaml:11:24: format string "{0}{1}{2}" contains placeholder {2} but only 2 arguments are given to format [expression]
475+
|
476+
11 | - run: echo "${{ format('{0}{1}{2}', 1, 2) }}"
477+
| ^~~~~~~~~~~~~~~~~~~
478+
test.yaml:14:31: broken JSON string is passed to fromJSON() at offset 23: unexpected end of JSON input [expression]
479+
|
480+
14 | if: contains(fromJson('["main","release","dev"'), github.ref_name)
481+
| ^~~~~~~~~~~~~~~~~~~~~~~~~~~
482+
```
483+
484+
[Playground](https://rhysd.github.io/actionlint/#eNqMj0FL9DAQhu/7K95v+CAtZMVdb/kJHvSgt0Uk7aY20k5KJnGFkP8ure5R8DJzmPeZhzewwZJl3O3eQydmByQnad1AzCz7NfC/FAwxzPdPjw+NKnTxTGad53CR/WRXhDRNnvMnGcpd5pSn66Gq9qRm26sX1Lo9luQW+XYA+9Vj4PoxgDZTiLNNjSq3tRyq0jhoHDXuWtRKf4PK8cr9Bj2PXuAFFrK43tsJXbTcj/9+soAfDPrAyXqWZmsvgRt1otl6Jk3RTc6KI01n90Gq1XjzaczdTXTDK9vZtV8BAAD//8ITaRA=)
485+
486+
GitHub Actions does not provide the syntax to create an array or object constant. It [is popular](https://github.com/search?q=fromJSON%28%27+lang%3Ayaml&type=code)
487+
to create such constants via `fromJSON()`.
488+
448489
<a id="check-contextual-step-object"></a>
449490
## Contextual typing for `steps.<step_id>` objects
450491

@@ -2578,7 +2619,7 @@ IDs must start with a letter or `_` and contain only alphanumeric characters, `-
25782619
convention, and reports invalid IDs as errors.
25792620

25802621
<a id="ctx-spfunc-availability"></a>
2581-
## Contexts and special functions availability
2622+
## Availability of contexts and special functions
25822623

25832624
Example input:
25842625

expr_sema.go

+19-4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package actionlint
22

33
import (
4+
"encoding/json"
45
"fmt"
56
"strconv"
67
"strings"
@@ -794,15 +795,15 @@ func checkFuncSignature(n *FuncCallNode, sig *FuncSignature, args []ExprType) *E
794795
return nil
795796
}
796797

797-
func (sema *ExprSemanticsChecker) checkBuiltinFunctionCall(n *FuncCallNode, _ *FuncSignature) {
798+
func (sema *ExprSemanticsChecker) checkBuiltinFuncCall(n *FuncCallNode, sig *FuncSignature) ExprType {
798799
sema.checkSpecialFunctionAvailability(n)
799800

800801
// Special checks for specific built-in functions
801802
switch strings.ToLower(n.Callee) {
802803
case "format":
803804
lit, ok := n.Args[0].(*StringNode)
804805
if !ok {
805-
return
806+
return sig.Ret
806807
}
807808
l := len(n.Args) - 1 // -1 means removing first format string argument
808809

@@ -819,7 +820,22 @@ func (sema *ExprSemanticsChecker) checkBuiltinFunctionCall(n *FuncCallNode, _ *F
819820
for i := range holders {
820821
sema.errorf(n, "format string %q contains placeholder {%d} but only %d arguments are given to format", lit.Value, i, l)
821822
}
823+
case "fromjson":
824+
lit, ok := n.Args[0].(*StringNode)
825+
if !ok {
826+
return sig.Ret
827+
}
828+
var v any
829+
err := json.Unmarshal([]byte(lit.Value), &v)
830+
if err == nil {
831+
return typeOfJSONValue(v)
832+
}
833+
if s, ok := err.(*json.SyntaxError); ok {
834+
sema.errorf(lit, "broken JSON string is passed to fromJSON() at offset %d: %s", s.Offset, s)
835+
}
822836
}
837+
838+
return sig.Ret
823839
}
824840

825841
func (sema *ExprSemanticsChecker) checkFuncCall(n *FuncCallNode) ExprType {
@@ -846,8 +862,7 @@ func (sema *ExprSemanticsChecker) checkFuncCall(n *FuncCallNode) ExprType {
846862
err := checkFuncSignature(n, sig, tys)
847863
if err == nil {
848864
// When one of overload pass type check, overload was resolved correctly
849-
sema.checkBuiltinFunctionCall(n, sig)
850-
return sig.Ret
865+
return sema.checkBuiltinFuncCall(n, sig)
851866
}
852867
errs = append(errs, err)
853868
}

expr_sema_test.go

+18-2
Original file line numberDiff line numberDiff line change
@@ -637,8 +637,8 @@ func TestExprSemanticsCheckOK(t *testing.T) {
637637
},
638638
{
639639
what: "non-special function",
640-
input: "fromJSON('{}')",
641-
expected: AnyType{},
640+
input: "contains('hello, world', 'o, w')",
641+
expected: BoolType{},
642642
availSPFuncs: []string{"always"},
643643
},
644644
{
@@ -729,6 +729,15 @@ func TestExprSemanticsCheckOK(t *testing.T) {
729729
input: "format('hello {{{0}', 'world')", // First {{ is escaped. {0} is not escaped
730730
expected: StringType{},
731731
},
732+
{
733+
what: "fromJSON with JSON constant value",
734+
input: `fromJSON('{"foo":true,"bar":["foo", 12.3],"piyo":null}')`,
735+
expected: NewStrictObjectType(map[string]ExprType{
736+
"foo": BoolType{},
737+
"bar": &ArrayType{Elem: StringType{}}, // Element type was merged
738+
"piyo": NullType{},
739+
}),
740+
},
732741
}
733742

734743
allSPFuncs := []string{}
@@ -1255,6 +1264,13 @@ func TestExprSemanticsCheckError(t *testing.T) {
12551264
"must not start with the GITHUB_ prefix",
12561265
},
12571266
},
1267+
{
1268+
what: "broken JSON value at fromJSON argument",
1269+
input: `fromJSON('{"foo": true')`,
1270+
expected: []string{
1271+
"broken JSON string is passed to fromJSON() at offset 12",
1272+
},
1273+
},
12581274
}
12591275

12601276
allSP := []string{}

expr_type.go

+45
Original file line numberDiff line numberDiff line change
@@ -427,3 +427,48 @@ func (ty *ArrayType) DeepCopy() ExprType {
427427
func EqualTypes(l, r ExprType) bool {
428428
return l.Assignable(r) && r.Assignable(l)
429429
}
430+
431+
// typeOfJSONValue returns the type of the given JSON value. The JSON value is an any value decoded by json.Unmarshal.
432+
// https://pkg.go.dev/encoding/json#Unmarshal
433+
//
434+
// To unmarshal JSON into an interface value, Unmarshal stores one of these in the interface value:
435+
// - bool, for JSON booleans
436+
// - float64, for JSON numbers
437+
// - string, for JSON strings
438+
// - []interface{}, for JSON arrays
439+
// - map[string]interface{}, for JSON objects
440+
// - nil for JSON null
441+
func typeOfJSONValue(v any) ExprType {
442+
switch v := v.(type) {
443+
case bool:
444+
return BoolType{}
445+
case float64:
446+
return NumberType{}
447+
case string:
448+
return StringType{}
449+
case []any:
450+
var elem ExprType
451+
for _, e := range v {
452+
t := typeOfJSONValue(e)
453+
if elem == nil {
454+
elem = t
455+
} else {
456+
elem = elem.Merge(t)
457+
}
458+
}
459+
if elem == nil {
460+
elem = AnyType{}
461+
}
462+
return &ArrayType{Elem: elem}
463+
case map[string]any:
464+
props := make(map[string]ExprType, len(v))
465+
for k, v := range v {
466+
props[k] = typeOfJSONValue(v)
467+
}
468+
return NewStrictObjectType(props)
469+
case nil:
470+
return NullType{}
471+
default:
472+
panic(v) // Unreachable
473+
}
474+
}

0 commit comments

Comments
 (0)