detached 37766e4

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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
258258 s.serverGitHub = expr.NewDictionary()
259259 if v, ok := root.Get("github"); ok {
260260 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+ }
261267 }
262268 if str(gh, "job") == "" && jobKey != "" {
263269 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 (
88 "strings"
99 "sync"
1010
11+ "bnema.dev/ermite-actions/expr"
1112 "bnema.dev/ermite-actions/template"
1213 "bnema.dev/ermite-actions/workflow"
1314 )
@@ -83,13 +84,81 @@ func (j *job) displayName(st *workflow.Step, env *ordered) string {
8384 default:
8485 return ""
8586 }
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+ }
87104 if err != nil {
88105 name = tokenSource(tok)
89106 }
90107 return formatStepName(prefix, name)
91108 }
92109
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+
93162 func tokenSource(t template.Token) string {
94163 if be, ok := t.(*template.BasicExpression); ok && be.Source != "" {
95164 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.Source164 return be.Source

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 11

↑↓ moveenter openesc close