detached d706e56

test(expr): build the runner's newer expression engine and compare it with the legacy one

gen/runner-new compiles src/Sdk/Expressions (GitHub.Actions.Expressions), the
engine WorkflowTemplateEvaluator runs next to the legacy one. engines.mjs runs
46,148 expressions through both: the only difference is negative zero.
commit
d706e56f12ef6e237c5d9d272b03996d9b45f78b
author
brice · · 2026-10-12 18:34 UTC
committer
brice · · 2026-10-18 14:59 UTC
parent
a970bd2
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +231 -0
A expr/gen/runner-new/.gitignore +2 -0
@@ -0,0 +1,2 @@
1+bin/
2+obj/
@@ -0,0 +1,2 @@
1+bin/
2+obj/
A expr/gen/runner-new/Program.cs +132 -0
@@ -0,0 +1,132 @@
1+// SPDX-License-Identifier: MIT
2+
3+using System;
4+using System.Collections.Generic;
5+using System.IO;
6+using System.Linq;
7+using System.Text;
8+using GitHub.Actions.Expressions;
9+using GitHub.Actions.Expressions.Data;
10+using GitHub.Actions.Expressions.Sdk;
11+using GitHub.Actions.Expressions.Sdk.Functions;
12+using Newtonsoft.Json;
13+using Newtonsoft.Json.Linq;
14+
15+// Differential harness: runs expressions through the runner's own
16+// Expressions2 code (compiled from the runner repository) and prints one JSON
17+// line per case.
18+//
19+// Input (argv[0]): JSON {"contexts": {name: json}, "cases": [{"e": expr, "ctx": {name: json} | null}]}
20+// Output: JSON lines {"k": kind, "s": string form, "j": toJson, "err": {"t": "parse"|"eval", "m": message}}
21+
22+internal sealed class CtxNode : NamedValue
23+{
24+ protected override Object EvaluateCore(EvaluationContext context, out ResultMemory resultMemory)
25+ {
26+ resultMemory = null;
27+ return (context.State as DictionaryExpressionData)[Name];
28+ }
29+}
30+
31+internal sealed class Success : Function { protected override Object EvaluateCore(EvaluationContext c, out ResultMemory m) { m = null; return true; } }
32+internal sealed class Always : Function { protected override Object EvaluateCore(EvaluationContext c, out ResultMemory m) { m = null; return true; } }
33+internal sealed class HashFiles : Function
34+{
35+ protected override Object EvaluateCore(EvaluationContext c, out ResultMemory m)
36+ {
37+ m = null;
38+ var s = "hash:";
39+ for (var i = 0; i < Parameters.Count; i++)
40+ {
41+ if (i > 0) s += "|";
42+ s += Parameters[i].Evaluate(c).ConvertToString();
43+ }
44+ return s;
45+ }
46+}
47+
48+internal static class P
49+{
50+ static DictionaryExpressionData Load(JObject ctx)
51+ {
52+ var d = new DictionaryExpressionData();
53+ foreach (var p in ctx.Properties())
54+ {
55+ var token = p.Value;
56+ d[p.Name] = token.ToExpressionData();
57+ }
58+ return d;
59+ }
60+
61+ static int Main(string[] args)
62+ {
63+ JObject root; using (var jr0 = new JsonTextReader(new StringReader(File.ReadAllText(args[0]))) { DateParseHandling = DateParseHandling.None }) root = JObject.Load(jr0);
64+ var defaultCtx = (JObject)root["contexts"];
65+ var funcs = new IFunctionInfo[]
66+ {
67+ new FunctionInfo<Success>("success", 0, 0),
68+ new FunctionInfo<Always>("always", 0, 0),
69+ new FunctionInfo<HashFiles>("hashFiles", 1, 255),
70+ };
71+ var output = new StreamWriter(Console.OpenStandardOutput(), new UTF8Encoding(false));
72+ var defaultData = Load(defaultCtx);
73+ foreach (var c in (JArray)root["cases"])
74+ {
75+ var e = (string)c["e"];
76+ var custom = c["ctx"] as JObject;
77+ var data = custom != null ? Load(custom) : defaultData;
78+ var names = (custom ?? defaultCtx).Properties().Select(p => p.Name);
79+ var res = new JObject();
80+ IExpressionNode tree = null;
81+ try
82+ {
83+ tree = new ExpressionParser().CreateTree(e, null, names.Select(n => (INamedValueInfo)new NamedValueInfo<CtxNode>(n)).ToList(), funcs);
84+ }
85+ catch (Exception ex)
86+ {
87+ res["err"] = new JObject { ["t"] = "parse", ["m"] = ex.Message, ["x"] = ex.GetType().Name };
88+ }
89+ if (res["err"] == null)
90+ {
91+ if (tree == null)
92+ {
93+ res["empty"] = true;
94+ }
95+ else
96+ {
97+ try
98+ {
99+ var r = tree.Evaluate(null, null, data, null);
100+ res["k"] = r.Kind.ToString();
101+ res["s"] = r.ConvertToString();
102+ // toJson of the result
103+ var tj = new ToJson();
104+ tj.AddParameter(new Literal(r.Value));
105+ try
106+ {
107+ var jr = ((IExpressionNode)tj).Evaluate(null, null, data, null);
108+ res["j"] = jr.ConvertToString();
109+ }
110+ catch (Exception ex2)
111+ {
112+ res["jerr"] = ex2.Message;
113+ }
114+ }
115+ catch (Exception ex)
116+ {
117+ res["err"] = new JObject { ["t"] = "eval", ["m"] = ex.Message, ["x"] = ex.GetType().Name };
118+ }
119+ }
120+ }
121+ // ASCII-escaped, so that a lone surrogate (which UTF-8 cannot carry) survives.
122+ var sw = new StringWriter();
123+ using (var jw = new JsonTextWriter(sw) { StringEscapeHandling = StringEscapeHandling.EscapeNonAscii, Formatting = Formatting.None })
124+ {
125+ res.WriteTo(jw);
126+ }
127+ output.WriteLine(sw.ToString());
128+ }
129+ output.Flush();
130+ return 0;
131+ }
132+}
@@ -0,0 +1,132 @@
1+// SPDX-License-Identifier: MIT
2+
3+using System;
4+using System.Collections.Generic;
5+using System.IO;
6+using System.Linq;
7+using System.Text;
8+using GitHub.Actions.Expressions;
9+using GitHub.Actions.Expressions.Data;
10+using GitHub.Actions.Expressions.Sdk;
11+using GitHub.Actions.Expressions.Sdk.Functions;
12+using Newtonsoft.Json;
13+using Newtonsoft.Json.Linq;
14+
15+// Differential harness: runs expressions through the runner's own
16+// Expressions2 code (compiled from the runner repository) and prints one JSON
17+// line per case.
18+//
19+// Input (argv[0]): JSON {"contexts": {name: json}, "cases": [{"e": expr, "ctx": {name: json} | null}]}
20+// Output: JSON lines {"k": kind, "s": string form, "j": toJson, "err": {"t": "parse"|"eval", "m": message}}
21+
22+internal sealed class CtxNode : NamedValue
23+{
24+ protected override Object EvaluateCore(EvaluationContext context, out ResultMemory resultMemory)
25+ {
26+ resultMemory = null;
27+ return (context.State as DictionaryExpressionData)[Name];
28+ }
29+}
30+
31+internal sealed class Success : Function { protected override Object EvaluateCore(EvaluationContext c, out ResultMemory m) { m = null; return true; } }
32+internal sealed class Always : Function { protected override Object EvaluateCore(EvaluationContext c, out ResultMemory m) { m = null; return true; } }
33+internal sealed class HashFiles : Function
34+{
35+ protected override Object EvaluateCore(EvaluationContext c, out ResultMemory m)
36+ {
37+ m = null;
38+ var s = "hash:";
39+ for (var i = 0; i < Parameters.Count; i++)
40+ {
41+ if (i > 0) s += "|";
42+ s += Parameters[i].Evaluate(c).ConvertToString();
43+ }
44+ return s;
45+ }
46+}
47+
48+internal static class P
49+{
50+ static DictionaryExpressionData Load(JObject ctx)
51+ {
52+ var d = new DictionaryExpressionData();
53+ foreach (var p in ctx.Properties())
54+ {
55+ var token = p.Value;
56+ d[p.Name] = token.ToExpressionData();
57+ }
58+ return d;
59+ }
60+
61+ static int Main(string[] args)
62+ {
63+ JObject root; using (var jr0 = new JsonTextReader(new StringReader(File.ReadAllText(args[0]))) { DateParseHandling = DateParseHandling.None }) root = JObject.Load(jr0);
64+ var defaultCtx = (JObject)root["contexts"];
65+ var funcs = new IFunctionInfo[]
66+ {
67+ new FunctionInfo<Success>("success", 0, 0),
68+ new FunctionInfo<Always>("always", 0, 0),
69+ new FunctionInfo<HashFiles>("hashFiles", 1, 255),
70+ };
71+ var output = new StreamWriter(Console.OpenStandardOutput(), new UTF8Encoding(false));
72+ var defaultData = Load(defaultCtx);
73+ foreach (var c in (JArray)root["cases"])
74+ {
75+ var e = (string)c["e"];
76+ var custom = c["ctx"] as JObject;
77+ var data = custom != null ? Load(custom) : defaultData;
78+ var names = (custom ?? defaultCtx).Properties().Select(p => p.Name);
79+ var res = new JObject();
80+ IExpressionNode tree = null;
81+ try
82+ {
83+ tree = new ExpressionParser().CreateTree(e, null, names.Select(n => (INamedValueInfo)new NamedValueInfo<CtxNode>(n)).ToList(), funcs);
84+ }
85+ catch (Exception ex)
86+ {
87+ res["err"] = new JObject { ["t"] = "parse", ["m"] = ex.Message, ["x"] = ex.GetType().Name };
88+ }
89+ if (res["err"] == null)
90+ {
91+ if (tree == null)
92+ {
93+ res["empty"] = true;
94+ }
95+ else
96+ {
97+ try
98+ {
99+ var r = tree.Evaluate(null, null, data, null);
100+ res["k"] = r.Kind.ToString();
101+ res["s"] = r.ConvertToString();
102+ // toJson of the result
103+ var tj = new ToJson();
104+ tj.AddParameter(new Literal(r.Value));
105+ try
106+ {
107+ var jr = ((IExpressionNode)tj).Evaluate(null, null, data, null);
108+ res["j"] = jr.ConvertToString();
109+ }
110+ catch (Exception ex2)
111+ {
112+ res["jerr"] = ex2.Message;
113+ }
114+ }
115+ catch (Exception ex)
116+ {
117+ res["err"] = new JObject { ["t"] = "eval", ["m"] = ex.Message, ["x"] = ex.GetType().Name };
118+ }
119+ }
120+ }
121+ // ASCII-escaped, so that a lone surrogate (which UTF-8 cannot carry) survives.
122+ var sw = new StringWriter();
123+ using (var jw = new JsonTextWriter(sw) { StringEscapeHandling = StringEscapeHandling.EscapeNonAscii, Formatting = Formatting.None })
124+ {
125+ res.WriteTo(jw);
126+ }
127+ output.WriteLine(sw.ToString());
128+ }
129+ output.Flush();
130+ return 0;
131+ }
132+}
A expr/gen/runner-new/engines.mjs +73 -0
@@ -0,0 +1,73 @@
1+// SPDX-License-Identifier: MIT
2+//
3+// Runs the oracle corpus, the fromJSON corpus, the number corpus and the string
4+// comparison corpus through both expression engines of actions/runner and lists
5+// where they differ:
6+// legacy src/Sdk/DTExpressions2 (driver ../runner)
7+// newer src/Sdk/Expressions (driver ./runner-new)
8+//
9+// dotnet build -c Release expr/gen/runner -p:RunnerSrc=$RUNNER_CLONE/src/Sdk
10+// dotnet build -c Release expr/gen/runner-new -p:RunnerSrc=$RUNNER_CLONE/src/Sdk
11+// RUNNER_CLONE=<clone> node expr/gen/runner-new/engines.mjs
12+//
13+// The exception message of a fromJSON failure is not compared (the engines word
14+// it differently); everything else must be identical. Exit code 1 if anything
15+// differs beyond the known difference: negative zero (the newer engine prints 0).
16+
17+import { execFileSync } from "node:child_process";
18+import { readFileSync, writeFileSync, mkdtempSync, rmSync } from "node:fs";
19+import { tmpdir } from "node:os";
20+import { join, dirname } from "node:path";
21+import { fileURLToPath } from "node:url";
22+import { gunzipSync } from "node:zlib";
23+import { checkEnvironment } from "../runner/env.mjs";
24+
25+const here = dirname(fileURLToPath(import.meta.url));
26+const testdata = join(here, "../../testdata");
27+checkEnvironment(process.argv[2]);
28+
29+const dec = h => (h === "-" ? "" : h.split(".").map(u => String.fromCharCode(parseInt(u, 16))).join(""));
30+const lit = s => "'" + s.replaceAll("'", "''") + "'";
31+const hasLone = s => /[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/.test(s);
32+
33+const oracle = JSON.parse(gunzipSync(readFileSync(join(testdata, "oracle.json.gz"))));
34+const corpora = { oracle: { contexts: oracle.contexts, cases: oracle.cases.map(c => c.e) } };
35+const exprs = [];
36+for (const c of JSON.parse(readFileSync(join(testdata, "fromjson_cases.json"), "utf8"))) exprs.push(`toJSON(fromJSON(${lit(c.in)}))`);
37+for (const l of readFileSync(join(testdata, "number_cases.txt"), "utf8").trim().split("\n")) {
38+ const s = dec(l.split(" ")[0]);
39+ if (hasLone(s)) continue;
40+ for (const op of ["== 0", "== 1", "> 255", "< 0"]) exprs.push(`${lit(s)} ${op}`);
41+}
42+for (const l of readFileSync(join(testdata, "ordinal_cases.txt"), "utf8").trim().split("\n")) {
43+ const [a, b] = l.split(" ");
44+ const A = dec(a), B = dec(b);
45+ if (hasLone(A + B)) continue;
46+ exprs.push(`${lit(A)} == ${lit(B)}`, `${lit(A)} < ${lit(B)}`, `contains(${lit(A)}, ${lit(B)})`, `startsWith(${lit(A)}, ${lit(B)})`, `endsWith(${lit(A)}, ${lit(B)})`);
47+}
48+corpora.strings = { contexts: {}, cases: exprs };
49+
50+const run = (dll, input) => execFileSync("dotnet", [dll, input], { maxBuffer: 1 << 30 }).toString("utf8").split("\n").filter(Boolean).map(l => JSON.parse(l));
51+const sig = r => (r.err ? JSON.stringify(["err", r.err.t, r.err.x === "JsonReaderException" ? r.err.x : r.err.m]) : JSON.stringify(r));
52+
53+const tmp = mkdtempSync(join(tmpdir(), "engines-"));
54+let unexpected = 0, total = 0;
55+for (const [name, c] of Object.entries(corpora)) {
56+ const input = join(tmp, name + ".json");
57+ writeFileSync(input, JSON.stringify({ contexts: c.contexts, cases: c.cases.map(e => ({ e })) }));
58+ const a = run(join(here, "../runner/bin/Release/net9.0/runner.dll"), input);
59+ const b = run(join(here, "bin/Release/net9.0/runner-new.dll"), input);
60+ let diffs = 0;
61+ a.forEach((r, i) => {
62+ total++;
63+ if (sig(r) === sig(b[i])) return;
64+ diffs++;
65+ const known = /^(toJSON\(-0\)|-0|toJSON\(fromJSON\('-\.0'\)\))$/.test(c.cases[i]);
66+ if (!known) unexpected++;
67+ console.log(`${known ? "known " : "DIFF "} ${name}: ${JSON.stringify(c.cases[i].slice(0, 80))}\n legacy ${sig(r).slice(0, 100)}\n newer ${sig(b[i]).slice(0, 100)}`);
68+ });
69+ console.log(`${name}: ${a.length} expressions, ${diffs} differences`);
70+}
71+rmSync(tmp, { recursive: true });
72+console.log(`${total} expressions, ${unexpected} unexpected differences`);
73+process.exit(unexpected ? 1 : 0);
@@ -0,0 +1,73 @@
1+// SPDX-License-Identifier: MIT
2+//
3+// Runs the oracle corpus, the fromJSON corpus, the number corpus and the string
4+// comparison corpus through both expression engines of actions/runner and lists
5+// where they differ:
6+// legacy src/Sdk/DTExpressions2 (driver ../runner)
7+// newer src/Sdk/Expressions (driver ./runner-new)
8+//
9+// dotnet build -c Release expr/gen/runner -p:RunnerSrc=$RUNNER_CLONE/src/Sdk
10+// dotnet build -c Release expr/gen/runner-new -p:RunnerSrc=$RUNNER_CLONE/src/Sdk
11+// RUNNER_CLONE=<clone> node expr/gen/runner-new/engines.mjs
12+//
13+// The exception message of a fromJSON failure is not compared (the engines word
14+// it differently); everything else must be identical. Exit code 1 if anything
15+// differs beyond the known difference: negative zero (the newer engine prints 0).
16+
17+import { execFileSync } from "node:child_process";
18+import { readFileSync, writeFileSync, mkdtempSync, rmSync } from "node:fs";
19+import { tmpdir } from "node:os";
20+import { join, dirname } from "node:path";
21+import { fileURLToPath } from "node:url";
22+import { gunzipSync } from "node:zlib";
23+import { checkEnvironment } from "../runner/env.mjs";
24+
25+const here = dirname(fileURLToPath(import.meta.url));
26+const testdata = join(here, "../../testdata");
27+checkEnvironment(process.argv[2]);
28+
29+const dec = h => (h === "-" ? "" : h.split(".").map(u => String.fromCharCode(parseInt(u, 16))).join(""));
30+const lit = s => "'" + s.replaceAll("'", "''") + "'";
31+const hasLone = s => /[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/.test(s);
32+
33+const oracle = JSON.parse(gunzipSync(readFileSync(join(testdata, "oracle.json.gz"))));
34+const corpora = { oracle: { contexts: oracle.contexts, cases: oracle.cases.map(c => c.e) } };
35+const exprs = [];
36+for (const c of JSON.parse(readFileSync(join(testdata, "fromjson_cases.json"), "utf8"))) exprs.push(`toJSON(fromJSON(${lit(c.in)}))`);
37+for (const l of readFileSync(join(testdata, "number_cases.txt"), "utf8").trim().split("\n")) {
38+ const s = dec(l.split(" ")[0]);
39+ if (hasLone(s)) continue;
40+ for (const op of ["== 0", "== 1", "> 255", "< 0"]) exprs.push(`${lit(s)} ${op}`);
41+}
42+for (const l of readFileSync(join(testdata, "ordinal_cases.txt"), "utf8").trim().split("\n")) {
43+ const [a, b] = l.split(" ");
44+ const A = dec(a), B = dec(b);
45+ if (hasLone(A + B)) continue;
46+ exprs.push(`${lit(A)} == ${lit(B)}`, `${lit(A)} < ${lit(B)}`, `contains(${lit(A)}, ${lit(B)})`, `startsWith(${lit(A)}, ${lit(B)})`, `endsWith(${lit(A)}, ${lit(B)})`);
47+}
48+corpora.strings = { contexts: {}, cases: exprs };
49+
50+const run = (dll, input) => execFileSync("dotnet", [dll, input], { maxBuffer: 1 << 30 }).toString("utf8").split("\n").filter(Boolean).map(l => JSON.parse(l));
51+const sig = r => (r.err ? JSON.stringify(["err", r.err.t, r.err.x === "JsonReaderException" ? r.err.x : r.err.m]) : JSON.stringify(r));
52+
53+const tmp = mkdtempSync(join(tmpdir(), "engines-"));
54+let unexpected = 0, total = 0;
55+for (const [name, c] of Object.entries(corpora)) {
56+ const input = join(tmp, name + ".json");
57+ writeFileSync(input, JSON.stringify({ contexts: c.contexts, cases: c.cases.map(e => ({ e })) }));
58+ const a = run(join(here, "../runner/bin/Release/net9.0/runner.dll"), input);
59+ const b = run(join(here, "bin/Release/net9.0/runner-new.dll"), input);
60+ let diffs = 0;
61+ a.forEach((r, i) => {
62+ total++;
63+ if (sig(r) === sig(b[i])) return;
64+ diffs++;
65+ const known = /^(toJSON\(-0\)|-0|toJSON\(fromJSON\('-\.0'\)\))$/.test(c.cases[i]);
66+ if (!known) unexpected++;
67+ console.log(`${known ? "known " : "DIFF "} ${name}: ${JSON.stringify(c.cases[i].slice(0, 80))}\n legacy ${sig(r).slice(0, 100)}\n newer ${sig(b[i]).slice(0, 100)}`);
68+ });
69+ console.log(`${name}: ${a.length} expressions, ${diffs} differences`);
70+}
71+rmSync(tmp, { recursive: true });
72+console.log(`${total} expressions, ${unexpected} unexpected differences`);
73+process.exit(unexpected ? 1 : 0);
A expr/gen/runner-new/runner-new.csproj +24 -0
@@ -0,0 +1,24 @@
1+<Project Sdk="Microsoft.NET.Sdk">
2+ <!-- Same driver as ../runner, built on the newer engine of actions/runner,
3+ src/Sdk/Expressions (namespace GitHub.Actions.Expressions), the one that
4+ WorkflowTemplateEvaluator runs next to the legacy engine to compare.
5+ Used by engines.mjs to find out where the two engines differ. -->
6+ <PropertyGroup>
7+ <OutputType>Exe</OutputType>
8+ <TargetFramework>net9.0</TargetFramework>
9+ <Nullable>disable</Nullable>
10+ <LangVersion>11.0</LangVersion>
11+ <NoWarn>NU1701;CS8632;CS0108;CS0114;CS1591;CS0618;SYSLIB0050</NoWarn>
12+ <EnableDefaultCompileItems>false</EnableDefaultCompileItems>
13+ <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
14+ </PropertyGroup>
15+ <Target Name="RequireRunnerSrc" BeforeTargets="BeforeBuild;CoreCompile">
16+ <Error Condition="'$(RunnerSrc)' == '' Or !Exists('$(RunnerSrc)/Expressions')" Text="Set RunnerSrc to the src/Sdk directory of a clone of actions/runner: dotnet build -c Release expr/gen/runner-new -p:RunnerSrc=&lt;clone&gt;/src/Sdk" />
17+ </Target>
18+ <ItemGroup>
19+ <Compile Include="$(RunnerSrc)/Expressions/**/*.cs" />
20+ <Compile Include="Program.cs" />
21+ <EmbeddedResource Include="$(RunnerSrc)/Expressions/Resources/ExpressionResources.resx" LogicalName="GitHub.Actions.Expressions.ExpressionResources.resources" />
22+ <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
23+ </ItemGroup>
24+</Project>
@@ -0,0 +1,24 @@
1+<Project Sdk="Microsoft.NET.Sdk">
2+ <!-- Same driver as ../runner, built on the newer engine of actions/runner,
3+ src/Sdk/Expressions (namespace GitHub.Actions.Expressions), the one that
4+ WorkflowTemplateEvaluator runs next to the legacy engine to compare.
5+ Used by engines.mjs to find out where the two engines differ. -->
6+ <PropertyGroup>
7+ <OutputType>Exe</OutputType>
8+ <TargetFramework>net9.0</TargetFramework>
9+ <Nullable>disable</Nullable>
10+ <LangVersion>11.0</LangVersion>
11+ <NoWarn>NU1701;CS8632;CS0108;CS0114;CS1591;CS0618;SYSLIB0050</NoWarn>
12+ <EnableDefaultCompileItems>false</EnableDefaultCompileItems>
13+ <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
14+ </PropertyGroup>
15+ <Target Name="RequireRunnerSrc" BeforeTargets="BeforeBuild;CoreCompile">
16+ <Error Condition="'$(RunnerSrc)' == '' Or !Exists('$(RunnerSrc)/Expressions')" Text="Set RunnerSrc to the src/Sdk directory of a clone of actions/runner: dotnet build -c Release expr/gen/runner-new -p:RunnerSrc=&lt;clone&gt;/src/Sdk" />
17+ </Target>
18+ <ItemGroup>
19+ <Compile Include="$(RunnerSrc)/Expressions/**/*.cs" />
20+ <Compile Include="Program.cs" />
21+ <EmbeddedResource Include="$(RunnerSrc)/Expressions/Resources/ExpressionResources.resx" LogicalName="GitHub.Actions.Expressions.ExpressionResources.resources" />
22+ <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
23+ </ItemGroup>
24+</Project>

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 1

Tags 3

↑↓ moveenter openesc close