Summary
GitHub Actions compound expressions using || inside an agentic workflow's prompt markdown body never evaluate at runtime. The expression is emitted as a hash-based env var by the compiler, but the runtime evaluator only looks up env vars under deterministic per-sub-expression names — so each side resolves to undefined, and the literal ${{ … }} text reaches the agent's prompt.
The canonical slash-command pattern documented in githubnext/agentics/workflows/repo-assist.md is affected. Most agents silently mask this by self-recovering via the GitHub MCP comment fetch, so the bug isn't usually visible — but for longer / multi-line trigger comments the agent drifts into scheduled-mode behaviour instead of acting on the user's slash-command instructions.
Reproducer
Any agentic workflow whose prompt body contains a compound || expression. The reference upstream workflow at githubnext/agentics/workflows/repo-assist.md line 204:
Take heed of **instructions**: "${{ steps.sanitized.outputs.text || inputs.command }}"
Trigger it via /repo-assist <some text> on an issue comment.
Observed
The activation job's "Print prompt" step shows the prompt sent to the agent contains the literal text:
Take heed of **instructions**: "${{ inputs.command }}"
Note the steps.sanitized.outputs.text || portion is gone (or never made it into the rewrite) and the surviving ${{ inputs.command }} is unsubstituted.
The activation job logs confirm the sanitiser DID capture the comment correctly:
text: /repo-assist <user's instruction>
GH_AW_EXPR_<hash>: /repo-assist <user's instruction>
So the data is available in an env var — it just never reaches the prompt.
Expected
The compound expression should resolve to the sanitised slash-command body (or inputs.command for manual dispatch), and the agent's prompt should read e.g.:
Take heed of **instructions**: "/repo-assist <user's instruction>"
Root Cause Analysis
Two code paths in gh-aw disagree on env var naming for compound expressions:
Compile-time — pkg/workflow/expression_extraction.go, generateEnvVarName():
if simpleIdentifierRegex.MatchString(content) {
// Simple expression -> deterministic name e.g. GH_AW_STEPS_SANITIZED_OUTPUTS_TEXT
prettyName := strings.ToUpper(strings.ReplaceAll(content, ".", "_"))
return "GH_AW_" + prettyName
}
// Compound expression (anything containing `||`, function calls, etc.) -> hash
hash := sha256.Sum256([]byte(content))
return "GH_AW_EXPR_" + strings.ToUpper(hashStr[:8])
So steps.sanitized.outputs.text || inputs.command is treated as "complex" and assigned GH_AW_EXPR_<8-char-hash>. The generated lock yml emits exactly that env var, and only that env var.
Runtime — actions/setup/js/runtime_import.cjs, evaluateExpression() (around line 415):
if (trimmed.startsWith("needs.") || trimmed.startsWith("steps.") || trimmed.startsWith("inputs.")) {
const envVarName = "GH_AW_" + trimmed.toUpperCase().replace(/\./g, "_");
const envValue = process.env[envVarName];
if (envValue !== undefined && envValue !== null) {
return envValue;
}
}
The runtime evaluator handles || by recursing on each side, then for each terminal sub-expression looks up its deterministic env var name. It never tries the hash form. So:
steps.sanitized.outputs.text → looks up GH_AW_STEPS_SANITIZED_OUTPUTS_TEXT → not set → returns the original ${{ … }} form
inputs.command → looks up GH_AW_INPUTS_COMMAND → not set → returns the original ${{ … }} form
- The
|| operator sees both as unevaluated → gives up
The compiler emits GH_AW_EXPR_<hash> (which the runtime never looks for), and never emits GH_AW_STEPS_SANITIZED_OUTPUTS_TEXT or GH_AW_INPUTS_COMMAND (which the runtime does look for).
Proposed Fix
In pkg/workflow/expression_extraction.go, when an extracted expression is compound (currently routed through the hash naming branch), recursively walk the expression's operands. For every terminal sub-expression that matches the deterministic-name shape (steps.*, needs.*, inputs.*, github.*), also emit an env var under the deterministic name. The hash env var can stay (for any other consumer that depends on it), but the deterministic names are what the runtime evaluator actually uses.
In pseudocode:
func (e *ExpressionExtractor) emitEnvVars(content string) {
// Existing: emit the hash env var for the full expression
hashName := e.generateEnvVarName(content)
e.mappings[hashName] = &ExpressionMapping{Original: ..., EnvVar: hashName}
// New: for compound expressions, also emit deterministic env vars
// for each terminal sub-expression that the runtime evaluator
// recognises (steps.*, needs.*, inputs.*).
if !simpleIdentifierRegex.MatchString(content) {
for _, sub := range extractTerminalIdentifiers(content) {
if isRuntimeDeterministicShape(sub) {
detName := "GH_AW_" + strings.ToUpper(strings.ReplaceAll(sub, ".", "_"))
if _, exists := e.mappings[detName]; !exists {
e.mappings[detName] = &ExpressionMapping{Original: "${{ " + sub + " }}", EnvVar: detName}
}
}
}
}
}
extractTerminalIdentifiers would be a small tokeniser that splits on ||, &&, function-call delimiters, etc., and returns each <prefix>.<chain> terminal.
With this change:
- The lock yml's prompt-creation step env block would set both
GH_AW_EXPR_<hash> AND GH_AW_STEPS_SANITIZED_OUTPUTS_TEXT AND GH_AW_INPUTS_COMMAND
- The runtime evaluator finds the deterministic names and resolves each side of
|| correctly
- Compound expressions in markdown bodies behave as users expect
Test Coverage to Add
pkg/workflow/expression_extraction_test.go:
- Compile a workflow whose prompt body contains
${{ A || B }} where A and B are deterministic-shape (e.g. steps.sanitized.outputs.text || inputs.command). Assert that the generated lock yml's prompt-creation step env block contains env vars GH_AW_STEPS_SANITIZED_OUTPUTS_TEXT and GH_AW_INPUTS_COMMAND (in addition to any hash env var).
actions/setup/js/runtime_import.test.cjs (or equivalent):
- Given prompt content
${{ A || B }}, env var for A set, env var for B unset → resolves to A's value.
- Given env for A unset, env for B set → resolves to B's value.
- Given both set → resolves to A's value (left-biased, matches GitHub Actions semantics).
- Given neither set → returns the literal
${{ … }} form.
User-Side Workaround
Until this is fixed, workflow authors who need the equivalent of ${{ A || B }} in their prompt body can use two separate single-sub-expression references on adjacent lines. Each single-sub-expression generates a deterministic env var name that the runtime evaluator finds, so each side substitutes correctly:
Slash command instructions: "${{ steps.sanitized.outputs.text }}"
Manual dispatch instructions: "${{ inputs.command }}"
If either of the above is non-empty (not ""), you have been triggered
with explicit instructions — follow them exclusively. Slash takes
precedence if both happen to be present.
This pattern works today and is verified end-to-end (both slash-command and manual-dispatch paths resolve the corresponding env var into the prompt; the agent acts on whichever is non-empty).
Impact
The reference slash-command workflow in githubnext/agentics ships in this state. Any user adopting it gets a subtly broken Command Mode: short, simple slash invocations sometimes work (agent recovers via MCP comment fetch), but longer or multi-line invocations drift into scheduled-mode behaviour. The failure is silent — there's no error in the logs; the agent simply ignores command-mode and runs whatever the scheduled prompt says.
Summary
GitHub Actions compound expressions using
||inside an agentic workflow's prompt markdown body never evaluate at runtime. The expression is emitted as a hash-based env var by the compiler, but the runtime evaluator only looks up env vars under deterministic per-sub-expression names — so each side resolves toundefined, and the literal${{ … }}text reaches the agent's prompt.The canonical slash-command pattern documented in
githubnext/agentics/workflows/repo-assist.mdis affected. Most agents silently mask this by self-recovering via the GitHub MCP comment fetch, so the bug isn't usually visible — but for longer / multi-line trigger comments the agent drifts into scheduled-mode behaviour instead of acting on the user's slash-command instructions.Reproducer
Any agentic workflow whose prompt body contains a compound
||expression. The reference upstream workflow atgithubnext/agentics/workflows/repo-assist.mdline 204:Trigger it via
/repo-assist <some text>on an issue comment.Observed
The activation job's "Print prompt" step shows the prompt sent to the agent contains the literal text:
Note the
steps.sanitized.outputs.text ||portion is gone (or never made it into the rewrite) and the surviving${{ inputs.command }}is unsubstituted.The activation job logs confirm the sanitiser DID capture the comment correctly:
So the data is available in an env var — it just never reaches the prompt.
Expected
The compound expression should resolve to the sanitised slash-command body (or
inputs.commandfor manual dispatch), and the agent's prompt should read e.g.:Root Cause Analysis
Two code paths in
gh-awdisagree on env var naming for compound expressions:Compile-time —
pkg/workflow/expression_extraction.go,generateEnvVarName():So
steps.sanitized.outputs.text || inputs.commandis treated as "complex" and assignedGH_AW_EXPR_<8-char-hash>. The generated lock yml emits exactly that env var, and only that env var.Runtime —
actions/setup/js/runtime_import.cjs,evaluateExpression()(around line 415):The runtime evaluator handles
||by recursing on each side, then for each terminal sub-expression looks up its deterministic env var name. It never tries the hash form. So:steps.sanitized.outputs.text→ looks upGH_AW_STEPS_SANITIZED_OUTPUTS_TEXT→ not set → returns the original${{ … }}forminputs.command→ looks upGH_AW_INPUTS_COMMAND→ not set → returns the original${{ … }}form||operator sees both as unevaluated → gives upThe compiler emits
GH_AW_EXPR_<hash>(which the runtime never looks for), and never emitsGH_AW_STEPS_SANITIZED_OUTPUTS_TEXTorGH_AW_INPUTS_COMMAND(which the runtime does look for).Proposed Fix
In
pkg/workflow/expression_extraction.go, when an extracted expression is compound (currently routed through the hash naming branch), recursively walk the expression's operands. For every terminal sub-expression that matches the deterministic-name shape (steps.*,needs.*,inputs.*,github.*), also emit an env var under the deterministic name. The hash env var can stay (for any other consumer that depends on it), but the deterministic names are what the runtime evaluator actually uses.In pseudocode:
extractTerminalIdentifierswould be a small tokeniser that splits on||,&&, function-call delimiters, etc., and returns each<prefix>.<chain>terminal.With this change:
GH_AW_EXPR_<hash>ANDGH_AW_STEPS_SANITIZED_OUTPUTS_TEXTANDGH_AW_INPUTS_COMMAND||correctlyTest Coverage to Add
pkg/workflow/expression_extraction_test.go:${{ A || B }}where A and B are deterministic-shape (e.g.steps.sanitized.outputs.text || inputs.command). Assert that the generated lock yml's prompt-creation step env block contains env varsGH_AW_STEPS_SANITIZED_OUTPUTS_TEXTandGH_AW_INPUTS_COMMAND(in addition to any hash env var).actions/setup/js/runtime_import.test.cjs(or equivalent):${{ A || B }}, env var for A set, env var for B unset → resolves to A's value.${{ … }}form.User-Side Workaround
Until this is fixed, workflow authors who need the equivalent of
${{ A || B }}in their prompt body can use two separate single-sub-expression references on adjacent lines. Each single-sub-expression generates a deterministic env var name that the runtime evaluator finds, so each side substitutes correctly:Slash command instructions: "${{ steps.sanitized.outputs.text }}" Manual dispatch instructions: "${{ inputs.command }}" If either of the above is non-empty (not ""), you have been triggered with explicit instructions — follow them exclusively. Slash takes precedence if both happen to be present.This pattern works today and is verified end-to-end (both slash-command and manual-dispatch paths resolve the corresponding env var into the prompt; the agent acts on whichever is non-empty).
Impact
The reference slash-command workflow in
githubnext/agenticsships in this state. Any user adopting it gets a subtly broken Command Mode: short, simple slash invocations sometimes work (agent recovers via MCP comment fetch), but longer or multi-line invocations drift into scheduled-mode behaviour. The failure is silent — there's no error in the logs; the agent simply ignores command-mode and runs whatever the scheduled prompt says.