fix(executor): evaluate display names with github.job empty like GitHub
GitHub evaluates job and step display names when the job starts, with the contexts of the job message, where github.job is empty. A step name that only reads those contexts and well-known functions keeps that value (recorded: ctx-github-names, 'Run echo "job="'), while the script runs with the real job key. Other names are still evaluated when the step starts.
- commit
- 37766e4c57306ec2c9b4698864e609e17331eeb1
- author
- brice · · 2026-10-13 03:27 UTC
- parent
- 3418cfa
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +117 -1
M internal/executor/contexts.go +6 -0
| @@ -258,6 +258,12 @@ func NewState(contextsJSON, matrixJSON, jobKey string, r RunnerInfo, repoFallbac | |||
| 258 | 258 | s.serverGitHub = expr.NewDictionary() | |
| 259 | 259 | if v, ok := root.Get("github"); ok { | |
| 260 | 260 | s.serverGitHub = v.(*expr.Dictionary) | |
| 261 | + | // GitHub evaluates names with github.job empty (recorded: | |
| 262 | + | // ctx-github-names): the job key is only added to the contexts the | |
| 263 | + | // job runs with. | |
| 264 | + | if _, has := s.serverGitHub.Get("job"); has { | |
| 265 | + | s.serverGitHub = setKey(s.serverGitHub, "job", expr.String("")) | |
| 266 | + | } | |
| 261 | 267 | } | |
| 262 | 268 | if str(gh, "job") == "" && jobKey != "" { | |
| 263 | 269 | gh = setKeySorted(gh, "job", expr.String(jobKey)) | |
| @@ -258,6 +258,12 @@ func NewState(contextsJSON, matrixJSON, jobKey string, r RunnerInfo, repoFallbac | |||||
| 258 | s.serverGitHub = expr.NewDictionary() | 258 | s.serverGitHub = expr.NewDictionary() | ||
| 259 | if v, ok := root.Get("github"); ok { | 259 | if v, ok := root.Get("github"); ok { | ||
| 260 | s.serverGitHub = v.(*expr.Dictionary) | 260 | s.serverGitHub = v.(*expr.Dictionary) | ||
| 261 | + | // GitHub evaluates names with github.job empty (recorded: | |||
| 262 | + | // ctx-github-names): the job key is only added to the contexts the | |||
| 263 | + | // job runs with. | |||
| 264 | + | if _, has := s.serverGitHub.Get("job"); has { | |||
| 265 | + | s.serverGitHub = setKey(s.serverGitHub, "job", expr.String("")) | |||
| 266 | + | } | |||
| 261 | } | 267 | } | ||
| 262 | if str(gh, "job") == "" && jobKey != "" { | 268 | if str(gh, "job") == "" && jobKey != "" { | ||
| 263 | gh = setKeySorted(gh, "job", expr.String(jobKey)) | 269 | gh = setKeySorted(gh, "job", expr.String(jobKey)) | ||
A internal/executor/names_test.go +41 -0
| @@ -0,0 +1,41 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package executor | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // Recorded on github.com (ctx-github-names): names that only read the | |
| 11 | + | // contexts of the job message are evaluated when the job starts, with | |
| 12 | + | // github.job empty, while the script of the step runs with the real job key. | |
| 13 | + | func TestNamesSeeAnEmptyGithubJob(t *testing.T) { | |
| 14 | + | needTools(t, "bash") | |
| 15 | + | yaml := `on: push | |
| 16 | + | jobs: | |
| 17 | + | first-job: | |
| 18 | + | name: Display name of ${{ github.job }} | |
| 19 | + | runs-on: ubuntu-24.04 | |
| 20 | + | steps: | |
| 21 | + | - run: echo "job=${{ github.job }}" | |
| 22 | + | - name: late ${{ github.job }} ${{ env.X }} | |
| 23 | + | env: | |
| 24 | + | X: x | |
| 25 | + | run: echo hi | |
| 26 | + | ` | |
| 27 | + | rec, res := runLocal(t, yaml, "first-job", nil) | |
| 28 | + | if res.Conclusion != ConclusionSuccess { | |
| 29 | + | t.Fatalf("%+v %v", res, rec.names()) | |
| 30 | + | } | |
| 31 | + | if got := rec.steps[0].Log; got[len(got)-1] != "Complete job name: Display name of" { | |
| 32 | + | t.Errorf("job name: %v", got) | |
| 33 | + | } | |
| 34 | + | s := rec.step(t, `Run echo "job="`) | |
| 35 | + | if !strings.Contains(logOf(s), `##[group]Run echo "job=first-job"`) || !strings.Contains(logOf(s), "\njob=first-job") { | |
| 36 | + | t.Errorf("the script must see the real job key:\n%s", logOf(s)) | |
| 37 | + | } | |
| 38 | + | // A name that reads env is not available at job start: evaluated with the | |
| 39 | + | // step's contexts, where github.job is the key. | |
| 40 | + | rec.step(t, "late first-job x") | |
| 41 | + | } | |
| @@ -0,0 +1,41 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package executor | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // Recorded on github.com (ctx-github-names): names that only read the | |||
| 11 | + | // contexts of the job message are evaluated when the job starts, with | |||
| 12 | + | // github.job empty, while the script of the step runs with the real job key. | |||
| 13 | + | func TestNamesSeeAnEmptyGithubJob(t *testing.T) { | |||
| 14 | + | needTools(t, "bash") | |||
| 15 | + | yaml := `on: push | |||
| 16 | + | jobs: | |||
| 17 | + | first-job: | |||
| 18 | + | name: Display name of ${{ github.job }} | |||
| 19 | + | runs-on: ubuntu-24.04 | |||
| 20 | + | steps: | |||
| 21 | + | - run: echo "job=${{ github.job }}" | |||
| 22 | + | - name: late ${{ github.job }} ${{ env.X }} | |||
| 23 | + | env: | |||
| 24 | + | X: x | |||
| 25 | + | run: echo hi | |||
| 26 | + | ` | |||
| 27 | + | rec, res := runLocal(t, yaml, "first-job", nil) | |||
| 28 | + | if res.Conclusion != ConclusionSuccess { | |||
| 29 | + | t.Fatalf("%+v %v", res, rec.names()) | |||
| 30 | + | } | |||
| 31 | + | if got := rec.steps[0].Log; got[len(got)-1] != "Complete job name: Display name of" { | |||
| 32 | + | t.Errorf("job name: %v", got) | |||
| 33 | + | } | |||
| 34 | + | s := rec.step(t, `Run echo "job="`) | |||
| 35 | + | if !strings.Contains(logOf(s), `##[group]Run echo "job=first-job"`) || !strings.Contains(logOf(s), "\njob=first-job") { | |||
| 36 | + | t.Errorf("the script must see the real job key:\n%s", logOf(s)) | |||
| 37 | + | } | |||
| 38 | + | // A name that reads env is not available at job start: evaluated with the | |||
| 39 | + | // step's contexts, where github.job is the key. | |||
| 40 | + | rec.step(t, "late first-job x") | |||
| 41 | + | } | |||
M internal/executor/step.go +70 -1
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "strings" | |
| 9 | 9 | "sync" | |
| 10 | 10 | ||
| 11 | + | "bnema.dev/ermite-actions/expr" | |
| 11 | 12 | "bnema.dev/ermite-actions/template" | |
| 12 | 13 | "bnema.dev/ermite-actions/workflow" | |
| 13 | 14 | ) | |
| @@ -83,13 +84,81 @@ func (j *job) displayName(st *workflow.Step, env *ordered) string { | |||
| 83 | 84 | default: | |
| 84 | 85 | return "" | |
| 85 | 86 | } | |
| 86 | - | name, err := j.evalString(tok, env) | |
| 87 | + | // JobExtension evaluates the display names once, when the job starts, with | |
| 88 | + | // the contexts the server sent (github.job is empty there) and without the | |
| 89 | + | // step functions; ActionRunner.GenerateDisplayName only does it when every | |
| 90 | + | // context and function the name uses is available (CheckHasRequiredContext). | |
| 91 | + | // A name that is fully evaluated then stays as it is (recorded: | |
| 92 | + | // ctx-github-names, "Run echo \"job=\""); the others are evaluated again, | |
| 93 | + | // with the contexts of the step, when the step starts. | |
| 94 | + | var name string | |
| 95 | + | var err error | |
| 96 | + | if serverEvaluable(tok) { | |
| 97 | + | j.mu.Lock() | |
| 98 | + | sc := j.state.ServerContexts() | |
| 99 | + | j.mu.Unlock() | |
| 100 | + | name, err = template.EvaluateString(context.Background(), tok, template.Env{Contexts: sc}) | |
| 101 | + | } else { | |
| 102 | + | name, err = j.evalString(tok, env) | |
| 103 | + | } | |
| 87 | 104 | if err != nil { | |
| 88 | 105 | name = tokenSource(tok) | |
| 89 | 106 | } | |
| 90 | 107 | return formatStepName(prefix, name) | |
| 91 | 108 | } | |
| 92 | 109 | ||
| 110 | + | // serverContextNames are the contexts of the job message (ContextData) when | |
| 111 | + | // display names are first evaluated. | |
| 112 | + | var serverContextNames = map[string]bool{"github": true, "needs": true, "strategy": true, "matrix": true, "inputs": true, "vars": true} | |
| 113 | + | ||
| 114 | + | // wellKnownFuncs are the functions available without the step functions. | |
| 115 | + | var wellKnownFuncs = map[string]bool{"case": true, "contains": true, "endswith": true, "format": true, "join": true, "startswith": true, "tojson": true, "fromjson": true} | |
| 116 | + | ||
| 117 | + | // serverEvaluable is TemplateTokenExtensions.CheckHasRequiredContext for the | |
| 118 | + | // first evaluation of a display name: every expression of the token only | |
| 119 | + | // reads contexts of the job message and calls well-known functions. | |
| 120 | + | func serverEvaluable(tok template.Token) bool { | |
| 121 | + | ok := true | |
| 122 | + | template.Walk(tok, func(t template.Token) bool { | |
| 123 | + | if be, isExpr := t.(*template.BasicExpression); isExpr { | |
| 124 | + | ok = ok && nodeServerEvaluable(be.Node()) | |
| 125 | + | } | |
| 126 | + | return ok | |
| 127 | + | }) | |
| 128 | + | return ok | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | func nodeServerEvaluable(n expr.Node) bool { | |
| 132 | + | switch n := n.(type) { | |
| 133 | + | case *expr.ContextAccess: | |
| 134 | + | return serverContextNames[strings.ToLower(n.Name.Lexeme)] | |
| 135 | + | case *expr.Unary: | |
| 136 | + | return nodeServerEvaluable(n.Expr) | |
| 137 | + | case *expr.Binary: | |
| 138 | + | return nodeServerEvaluable(n.Left) && nodeServerEvaluable(n.Right) | |
| 139 | + | case *expr.Grouping: | |
| 140 | + | return nodeServerEvaluable(n.Group) | |
| 141 | + | case *expr.IndexAccess: | |
| 142 | + | return nodeServerEvaluable(n.Expr) && nodeServerEvaluable(n.Index) | |
| 143 | + | case *expr.Logical: | |
| 144 | + | for _, a := range n.Args { | |
| 145 | + | if !nodeServerEvaluable(a) { | |
| 146 | + | return false | |
| 147 | + | } | |
| 148 | + | } | |
| 149 | + | case *expr.FunctionCall: | |
| 150 | + | if !wellKnownFuncs[strings.ToLower(n.Name.Lexeme)] { | |
| 151 | + | return false | |
| 152 | + | } | |
| 153 | + | for _, a := range n.Args { | |
| 154 | + | if !nodeServerEvaluable(a) { | |
| 155 | + | return false | |
| 156 | + | } | |
| 157 | + | } | |
| 158 | + | } | |
| 159 | + | return true | |
| 160 | + | } | |
| 161 | + | ||
| 93 | 162 | func tokenSource(t template.Token) string { | |
| 94 | 163 | if be, ok := t.(*template.BasicExpression); ok && be.Source != "" { | |
| 95 | 164 | return be.Source | |
| @@ -8,6 +8,7 @@ import ( | |||||
| 8 | "strings" | 8 | "strings" | ||
| 9 | "sync" | 9 | "sync" | ||
| 10 | 10 | ||||
| 11 | + | "bnema.dev/ermite-actions/expr" | |||
| 11 | "bnema.dev/ermite-actions/template" | 12 | "bnema.dev/ermite-actions/template" | ||
| 12 | "bnema.dev/ermite-actions/workflow" | 13 | "bnema.dev/ermite-actions/workflow" | ||
| 13 | ) | 14 | ) | ||
| @@ -83,13 +84,81 @@ func (j *job) displayName(st *workflow.Step, env *ordered) string { | |||||
| 83 | default: | 84 | default: | ||
| 84 | return "" | 85 | return "" | ||
| 85 | } | 86 | } | ||
| 86 | - | name, err := j.evalString(tok, env) | 87 | + | // JobExtension evaluates the display names once, when the job starts, with |
| 88 | + | // the contexts the server sent (github.job is empty there) and without the | |||
| 89 | + | // step functions; ActionRunner.GenerateDisplayName only does it when every | |||
| 90 | + | // context and function the name uses is available (CheckHasRequiredContext). | |||
| 91 | + | // A name that is fully evaluated then stays as it is (recorded: | |||
| 92 | + | // ctx-github-names, "Run echo \"job=\""); the others are evaluated again, | |||
| 93 | + | // with the contexts of the step, when the step starts. | |||
| 94 | + | var name string | |||
| 95 | + | var err error | |||
| 96 | + | if serverEvaluable(tok) { | |||
| 97 | + | j.mu.Lock() | |||
| 98 | + | sc := j.state.ServerContexts() | |||
| 99 | + | j.mu.Unlock() | |||
| 100 | + | name, err = template.EvaluateString(context.Background(), tok, template.Env{Contexts: sc}) | |||
| 101 | + | } else { | |||
| 102 | + | name, err = j.evalString(tok, env) | |||
| 103 | + | } | |||
| 87 | if err != nil { | 104 | if err != nil { | ||
| 88 | name = tokenSource(tok) | 105 | name = tokenSource(tok) | ||
| 89 | } | 106 | } | ||
| 90 | return formatStepName(prefix, name) | 107 | return formatStepName(prefix, name) | ||
| 91 | } | 108 | } | ||
| 92 | 109 | ||||
| 110 | + | // serverContextNames are the contexts of the job message (ContextData) when | |||
| 111 | + | // display names are first evaluated. | |||
| 112 | + | var serverContextNames = map[string]bool{"github": true, "needs": true, "strategy": true, "matrix": true, "inputs": true, "vars": true} | |||
| 113 | + | ||||
| 114 | + | // wellKnownFuncs are the functions available without the step functions. | |||
| 115 | + | var wellKnownFuncs = map[string]bool{"case": true, "contains": true, "endswith": true, "format": true, "join": true, "startswith": true, "tojson": true, "fromjson": true} | |||
| 116 | + | ||||
| 117 | + | // serverEvaluable is TemplateTokenExtensions.CheckHasRequiredContext for the | |||
| 118 | + | // first evaluation of a display name: every expression of the token only | |||
| 119 | + | // reads contexts of the job message and calls well-known functions. | |||
| 120 | + | func serverEvaluable(tok template.Token) bool { | |||
| 121 | + | ok := true | |||
| 122 | + | template.Walk(tok, func(t template.Token) bool { | |||
| 123 | + | if be, isExpr := t.(*template.BasicExpression); isExpr { | |||
| 124 | + | ok = ok && nodeServerEvaluable(be.Node()) | |||
| 125 | + | } | |||
| 126 | + | return ok | |||
| 127 | + | }) | |||
| 128 | + | return ok | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | func nodeServerEvaluable(n expr.Node) bool { | |||
| 132 | + | switch n := n.(type) { | |||
| 133 | + | case *expr.ContextAccess: | |||
| 134 | + | return serverContextNames[strings.ToLower(n.Name.Lexeme)] | |||
| 135 | + | case *expr.Unary: | |||
| 136 | + | return nodeServerEvaluable(n.Expr) | |||
| 137 | + | case *expr.Binary: | |||
| 138 | + | return nodeServerEvaluable(n.Left) && nodeServerEvaluable(n.Right) | |||
| 139 | + | case *expr.Grouping: | |||
| 140 | + | return nodeServerEvaluable(n.Group) | |||
| 141 | + | case *expr.IndexAccess: | |||
| 142 | + | return nodeServerEvaluable(n.Expr) && nodeServerEvaluable(n.Index) | |||
| 143 | + | case *expr.Logical: | |||
| 144 | + | for _, a := range n.Args { | |||
| 145 | + | if !nodeServerEvaluable(a) { | |||
| 146 | + | return false | |||
| 147 | + | } | |||
| 148 | + | } | |||
| 149 | + | case *expr.FunctionCall: | |||
| 150 | + | if !wellKnownFuncs[strings.ToLower(n.Name.Lexeme)] { | |||
| 151 | + | return false | |||
| 152 | + | } | |||
| 153 | + | for _, a := range n.Args { | |||
| 154 | + | if !nodeServerEvaluable(a) { | |||
| 155 | + | return false | |||
| 156 | + | } | |||
| 157 | + | } | |||
| 158 | + | } | |||
| 159 | + | return true | |||
| 160 | + | } | |||
| 161 | + | ||||
| 93 | func tokenSource(t template.Token) string { | 162 | func tokenSource(t template.Token) string { | ||
| 94 | if be, ok := t.(*template.BasicExpression); ok && be.Source != "" { | 163 | if be, ok := t.(*template.BasicExpression); ok && be.Source != "" { | ||
| 95 | return be.Source | 164 | return be.Source | ||