From 1dc7a4d26970cdb513f85a1b8d82b4af82e05c19 Mon Sep 17 00:00:00 2001 From: ChristopherHX Date: Sun, 18 May 2025 11:35:05 +0200 Subject: [PATCH] feat: allow ctx overlay + case sensitive env ctx (#99) * switch to fork of actionlint --- go.mod | 2 + go.sum | 4 +- pkg/exprparser/interpreter.go | 46 ++++++++++++++++- pkg/exprparser/interpreter_test.go | 83 ++++++++++++++++++------------ pkg/runner/expression.go | 1 + 5 files changed, 99 insertions(+), 37 deletions(-) diff --git a/go.mod b/go.mod index 7d208b08..197dd9e4 100644 --- a/go.mod +++ b/go.mod @@ -110,3 +110,5 @@ require ( google.golang.org/grpc v1.66.3 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect ) + +replace github.com/rhysd/actionlint => github.com/actions-oss/act-cli-actionlint v0.0.0-20250517100532-8f847f29ba36 diff --git a/go.sum b/go.sum index 23e81ee2..7c97e2bc 100644 --- a/go.sum +++ b/go.sum @@ -15,6 +15,8 @@ github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63n github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +github.com/actions-oss/act-cli-actionlint v0.0.0-20250517100532-8f847f29ba36 h1:QnIPcWM4eVfqRUB3B6sLOwEJrMrTa64qrVqzxF5A21U= +github.com/actions-oss/act-cli-actionlint v0.0.0-20250517100532-8f847f29ba36/go.mod h1:AE6I6vJEkNaIfWqC2GNE5spIJNhxf8NCtLEKU4NnUXg= github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= github.com/andreaskoch/go-fswatch v1.0.0 h1:la8nP/HiaFCxP2IM6NZNUCoxgLWuyNFgH0RligBbnJU= @@ -164,8 +166,6 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rhysd/actionlint v1.7.7 h1:0KgkoNTrYY7vmOCs9BW2AHxLvvpoY9nEUzgBHiPUr0k= -github.com/rhysd/actionlint v1.7.7/go.mod h1:AE6I6vJEkNaIfWqC2GNE5spIJNhxf8NCtLEKU4NnUXg= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= diff --git a/pkg/exprparser/interpreter.go b/pkg/exprparser/interpreter.go index 083c8f17..c99fccaf 100644 --- a/pkg/exprparser/interpreter.go +++ b/pkg/exprparser/interpreter.go @@ -2,6 +2,7 @@ package exprparser import ( "encoding" + "encoding/json" "fmt" "math" "reflect" @@ -25,8 +26,12 @@ type EvaluationEnvironment struct { Needs map[string]Needs Inputs map[string]interface{} HashFiles func([]reflect.Value) (interface{}, error) + EnvCS bool + CtxData map[string]interface{} } +type CaseSensitiveDict map[string]string + type Needs struct { Outputs map[string]string `json:"outputs"` Result string `json:"result"` @@ -151,10 +156,17 @@ func (impl *interperterImpl) evaluateNode(exprNode actionlint.ExprNode) (interfa } func (impl *interperterImpl) evaluateVariable(variableNode *actionlint.VariableNode) (interface{}, error) { - switch strings.ToLower(variableNode.Name) { + lowerName := strings.ToLower(variableNode.Name) + if result, err := impl.evaluateOverriddenVariable(lowerName); result != nil || err != nil { + return result, err + } + switch lowerName { case "github": return impl.env.Github, nil case "env": + if impl.env.EnvCS { + return CaseSensitiveDict(impl.env.Env), nil + } return impl.env.Env, nil case "job": return impl.env.Job, nil @@ -188,6 +200,33 @@ func (impl *interperterImpl) evaluateVariable(variableNode *actionlint.VariableN } } +func (impl *interperterImpl) evaluateOverriddenVariable(lowerName string) (interface{}, error) { + if cd, ok := impl.env.CtxData[lowerName]; ok { + if serverPayload, ok := cd.(map[string]interface{}); ok { + if lowerName == "github" { + var out map[string]interface{} + content, err := json.Marshal(impl.env.Github) + if err != nil { + return nil, err + } + err = json.Unmarshal(content, &out) + if err != nil { + return nil, err + } + for k, v := range serverPayload { + // skip empty values, because github.workspace was set by Gitea Actions to an empty string + if _, ok := out[k]; !ok || v != "" && v != nil { + out[k] = v + } + } + return out, nil + } + } + return cd, nil + } + return nil, nil +} + func (impl *interperterImpl) evaluateIndexAccess(indexAccessNode *actionlint.IndexAccessNode) (interface{}, error) { left, err := impl.evaluateNode(indexAccessNode.Operand) if err != nil { @@ -280,6 +319,11 @@ func (impl *interperterImpl) getPropertyValue(left reflect.Value, property strin return i, nil case reflect.Map: + cd, ok := left.Interface().(CaseSensitiveDict) + if ok { + return cd[property], nil + } + iter := left.MapRange() for iter.Next() { diff --git a/pkg/exprparser/interpreter_test.go b/pkg/exprparser/interpreter_test.go index 9db1713a..64525f28 100644 --- a/pkg/exprparser/interpreter_test.go +++ b/pkg/exprparser/interpreter_test.go @@ -528,46 +528,58 @@ func TestOperatorsBooleanEvaluation(t *testing.T) { func TestContexts(t *testing.T) { table := []struct { - input string - expected interface{} - name string + input string + expected interface{} + name string + caseSensitiveEnv bool + ctxdata map[string]interface{} }{ - {"github.action", "push", "github-context"}, - {"github.event.commits[0].message", nil, "github-context-noexist-prop"}, - {"fromjson('{\"commits\":[]}').commits[0].message", nil, "github-context-noexist-prop"}, - {"github.event.pull_request.labels.*.name", nil, "github-context-noexist-prop"}, - {"env.TEST", "value", "env-context"}, - {"job.status", "success", "job-context"}, - {"steps.step-id.outputs.name", "value", "steps-context"}, - {"steps.step-id.conclusion", "success", "steps-context-conclusion"}, - {"steps.step-id.conclusion && true", true, "steps-context-conclusion"}, - {"steps.step-id2.conclusion", "skipped", "steps-context-conclusion"}, - {"steps.step-id2.conclusion && true", true, "steps-context-conclusion"}, - {"steps.step-id.outcome", "success", "steps-context-outcome"}, - {"steps.step-id['outcome']", "success", "steps-context-outcome"}, - {"steps.step-id.outcome == 'success'", true, "steps-context-outcome"}, - {"steps.step-id['outcome'] == 'success'", true, "steps-context-outcome"}, - {"steps.step-id.outcome && true", true, "steps-context-outcome"}, - {"steps['step-id']['outcome'] && true", true, "steps-context-outcome"}, - {"steps.step-id2.outcome", "failure", "steps-context-outcome"}, - {"steps.step-id2.outcome && true", true, "steps-context-outcome"}, + {input: "github.action", expected: "push", name: "github-context"}, + {input: "github.action", expected: "push", name: "github-context", ctxdata: map[string]interface{}{"github": map[string]interface{}{"ref": "refs/heads/test-data"}}}, + {input: "github.ref", expected: "refs/heads/test-data", name: "github-context", ctxdata: map[string]interface{}{"github": map[string]interface{}{"ref": "refs/heads/test-data"}}}, + {input: "github.custom-field", expected: "custom-value", name: "github-context", ctxdata: map[string]interface{}{"github": map[string]interface{}{"custom-field": "custom-value"}}}, + {input: "github.event.commits[0].message", expected: nil, name: "github-context-noexist-prop"}, + {input: "fromjson('{\"commits\":[]}').commits[0].message", expected: nil, name: "github-context-noexist-prop"}, + {input: "github.event.pull_request.labels.*.name", expected: nil, name: "github-context-noexist-prop"}, + {input: "env.TEST", expected: "value", name: "env-context"}, + {input: "env.TEST", expected: "value", name: "env-context", caseSensitiveEnv: true}, + {input: "env.test", expected: "", name: "env-context", caseSensitiveEnv: true}, + {input: "env['TEST']", expected: "value", name: "env-context", caseSensitiveEnv: true}, + {input: "env['test']", expected: "", name: "env-context", caseSensitiveEnv: true}, + {input: "env.test", expected: "value", name: "env-context"}, + {input: "job.status", expected: "success", name: "job-context"}, + {input: "steps.step-id.outputs.name", expected: "value", name: "steps-context"}, + {input: "steps.step-id.conclusion", expected: "success", name: "steps-context-conclusion"}, + {input: "steps.step-id.conclusion && true", expected: true, name: "steps-context-conclusion"}, + {input: "steps.step-id2.conclusion", expected: "skipped", name: "steps-context-conclusion"}, + {input: "steps.step-id2.conclusion && true", expected: true, name: "steps-context-conclusion"}, + {input: "steps.step-id.outcome", expected: "success", name: "steps-context-outcome"}, + {input: "steps.step-id['outcome']", expected: "success", name: "steps-context-outcome"}, + {input: "steps.step-id.outcome == 'success'", expected: true, name: "steps-context-outcome"}, + {input: "steps.step-id['outcome'] == 'success'", expected: true, name: "steps-context-outcome"}, + {input: "steps.step-id.outcome && true", expected: true, name: "steps-context-outcome"}, + {input: "steps['step-id']['outcome'] && true", expected: true, name: "steps-context-outcome"}, + {input: "steps.step-id2.outcome", expected: "failure", name: "steps-context-outcome"}, + {input: "steps.step-id2.outcome && true", expected: true, name: "steps-context-outcome"}, // Disabled, since the interpreter is still too broken // {"contains(steps.*.outcome, 'success')", true, "steps-context-array-outcome"}, // {"contains(steps.*.outcome, 'failure')", true, "steps-context-array-outcome"}, // {"contains(steps.*.outputs.name, 'value')", true, "steps-context-array-outputs"}, - {"runner.os", "Linux", "runner-context"}, - {"secrets.name", "value", "secrets-context"}, - {"vars.name", "value", "vars-context"}, - {"strategy.fail-fast", true, "strategy-context"}, - {"matrix.os", "Linux", "matrix-context"}, - {"needs.job-id.outputs.output-name", "value", "needs-context"}, - {"needs.job-id.result", "success", "needs-context"}, - {"contains(needs.*.result, 'success')", true, "needs-wildcard-context-contains-success"}, - {"contains(needs.*.result, 'failure')", false, "needs-wildcard-context-contains-failure"}, - {"inputs.name", "value", "inputs-context"}, + {input: "runner.os", expected: "Linux", name: "runner-context"}, + {input: "secrets.name", expected: "value", name: "secrets-context"}, + {input: "vars.name", expected: "value", name: "vars-context"}, + {input: "strategy.fail-fast", expected: true, name: "strategy-context"}, + {input: "matrix.os", expected: "Linux", name: "matrix-context"}, + {input: "needs.job-id.outputs.output-name", expected: "value", name: "needs-context"}, + {input: "needs.job-id.result", expected: "success", name: "needs-context"}, + {input: "contains(needs.*.result, 'success')", expected: true, name: "needs-wildcard-context-contains-success"}, + {input: "contains(needs.*.result, 'failure')", expected: false, name: "needs-wildcard-context-contains-failure"}, + {input: "inputs.name", expected: "value", name: "inputs-context"}, + {input: "vars.MY_VAR", expected: "refs/heads/test-data", name: "vars-context", ctxdata: map[string]interface{}{"vars": map[string]interface{}{"MY_VAR": "refs/heads/test-data"}}}, + {input: "vars.MY_VAR", expected: "refs/heads/test-data", name: "vars-context", ctxdata: map[string]interface{}{"vars": map[string]interface{}{"my_var": "refs/heads/test-data"}}}, } - env := &EvaluationEnvironment{ + env := EvaluationEnvironment{ Github: &model.GithubContext{ Action: "push", }, @@ -626,7 +638,10 @@ func TestContexts(t *testing.T) { for _, tt := range table { t.Run(tt.name, func(t *testing.T) { - output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone) + tenv := env + tenv.EnvCS = tt.caseSensitiveEnv + tenv.CtxData = tt.ctxdata + output, err := NewInterpeter(&tenv, Config{}).Evaluate(tt.input, DefaultStatusCheckNone) assert.Nil(t, err) assert.Equal(t, tt.expected, output) diff --git a/pkg/runner/expression.go b/pkg/runner/expression.go index 2cbfe518..703b0ea0 100644 --- a/pkg/runner/expression.go +++ b/pkg/runner/expression.go @@ -93,6 +93,7 @@ func (rc *RunContext) NewExpressionEvaluatorWithEnv(ctx context.Context, env map } if rc.JobContainer != nil { ee.Runner = rc.JobContainer.GetRunnerContext(ctx) + ee.EnvCS = !rc.JobContainer.IsEnvironmentCaseInsensitive() } return expressionEvaluator{ interpreter: exprparser.NewInterpeter(ee, exprparser.Config{