Expressions, Contexts and Variables in GitHub Actions: if, outputs and GITHUB_ENV
Expressions inside ${{ }} read values from contexts (github, env, vars, steps, needs, inputs…), use loose comparison and drive if: conditions. With act output we show why text outside ${{ }} in if is always true, the contains/fromJSON/hashFiles/case functions, moving data with GITHUB_OUTPUT and GITHUB_ENV, a test table with GITHUB_STEP_SUMMARY, and always versus !cancelled.
Version note: The examples in this post were tested with act 0.2.97, actionlint 1.7.12, Docker 29.2, .NET 10 (SDK 10.0.403), xUnit v3 4.0 and actions/setup-dotnet@v6. The workflows were run locally with act; they were not run on GitHub.
Guess First
Bookstore's CI (Continuous Integration) has a "Publish" step that must run only on main. Alan wrote the condition like this:
jobs:
guess:
runs-on: ubuntu-latest
steps:
- name: Publish (broken condition)
if: ${{ github.ref }} == 'refs/heads/main'
run: echo "publishing from ${{ github.ref }}"
- name: Publish (correct condition)
if: github.ref == 'refs/heads/main'
run: echo "publishing from ${{ github.ref }}"
Ada opens a pull request from the feature/coupon-codes branch. Which step runs: one, both, neither?
$ act pull_request -e pr-opened.json
[expressions/guess] ⭐ Run Main Publish (broken condition)
[expressions/guess] | publishing from refs/pull/42/merge
[expressions/guess] ✅ Success - Main Publish (broken condition) [72.855417ms]
[expressions/guess] 🏁 Job succeeded
The "broken" one ran and the correct one was skipped. In the first condition the ${{ github.ref }} part is evaluated and substituted, leaving a string: refs/pull/42/merge == 'refs/heads/main'. That is no longer a comparison but a non-empty piece of text, and a non-empty string counts as true. actionlint says so in one line:
.github/workflows/expressions.yml:12:13: if: condition "${{ github.ref }} == 'refs/heads/main'" is always evaluated to true because extra characters are around ${{ }} [if-cond]
According to the January 29, 2026 changelog, GitHub now flags this too, in the Visual Studio Code (VS Code) extension and the web editor, and adds an annotation to the run. Still, the cheapest place is before the push: either put the whole expression inside ${{ }}, or don't use it in if: at all. In this post we open up the language behind that syntax: literals, operators, contexts, functions and the ways of moving data between steps.
The ${{ }} Syntax and Type Conversions
Everything inside ${{ }} is GitHub's expression language; it is evaluated before the key is used (for a step, right before the step runs) and the result replaces the value in the YAML (YAML Ain't Markup Language). The literal types are boolean, null, number and string. Strings use single quotes; double quotes are an error, and you escape a single quote by writing two ('It''s'). In conditions false, 0, -0, "", '' and null count as false, everything else as true. The root of the bug in the guess section is that sentence.
Comparisons are "loose": if the types differ, GitHub tries to convert both sides to numbers. String comparisons are also case-insensitive. We tried the documented rules in act:
echo "equality : ${{ 'Main' == 'main' }} ${{ '1' == 1 }} ${{ '' == 0 }} ${{ 'abc' == 0 }}"
[expressions/version] | equality : true true true false
'Main' == 'main' is true because case does not matter. '1' == 1 is true because '1' converts to the number 1. The empty string converts to 0, so '' == 0 is also true. 'abc' becomes NaN (Not a Number) and equals nothing. The practical consequence: if you compare an output with '0' and the output came back empty, the comparison is still true.
Writing ${{ }} inside if: is optional, because GitHub always evaluates if: as an expression. There is one exception: if the expression starts with !, ${{ }} is mandatory, because ! is a special character in YAML. That is why if: ${{ !cancelled() }} is written this way.
Contexts: github, env, vars, secrets, job, steps, runner, matrix, needs, inputs
Expressions read their values from contexts. The ones we will use most in this series:
| Context | What it carries | Example |
|---|---|---|
github | Event, repository, ref, SHA (Secure Hash Algorithm), run number | github.ref, github.event_name, github.run_number |
env | Environment variables defined at workflow, job and step level | env.BOOKSTORE_VERSION |
vars | Configuration variables at repository, organization and environment level | vars.API_BASE_URL |
secrets | Secrets and GITHUB_TOKEN | secrets.GITHUB_TOKEN |
job | Status of the running job, its service containers | job.status, job.services.postgres.ports |
steps | Outputs and results of earlier steps in the same job | steps.tests.outcome |
runner | The machine running the job | runner.os, runner.arch, runner.temp |
matrix | Values of this combination in a matrix job | matrix.dotnet |
needs | Outputs and results of the jobs this job depends on | needs.version.outputs.version |
inputs | workflow_dispatch and workflow_call inputs | inputs.dry-run |
Not every context can be used everywhere. For example, runner only exists once the job has been assigned to a machine, so it cannot be used in a job-level if:; steps only makes sense inside the same job. The documentation has a table showing which contexts are valid for each workflow key, and actionlint catches a context used in the wrong place. The difference between vars and secrets, and why secrets cannot be used directly in if:, is the topic of the eighth post.
Functions: contains, startsWith, format, fromJSON, hashFiles and case
The expression language has a small but useful set of functions. The step we ran in act:
- name: Functions
run: |
echo "startsWith : ${{ startsWith(github.ref, 'refs/heads/') }}"
echo "contains : ${{ contains(fromJSON('["push", "pull_request"]'), github.event_name) }}"
echo "format : ${{ format('bookstore-{0}-{1}', runner.os, runner.arch) }}"
echo "equality : ${{ 'Main' == 'main' }} ${{ '1' == 1 }} ${{ '' == 0 }} ${{ 'abc' == 0 }}"
[expressions/version] | startsWith : true
[expressions/version] | contains : true
[expressions/version] | format : bookstore-Linux-ARM64
[expressions/version] | equality : true true true false
startsWith, endsWith and contains are case-insensitive and cast their arguments to strings. The contains(fromJSON('[...]'), x) idiom replaces long || chains. fromJSON turns a string into a real value: it is used to build a matrix from a job output, or to turn the string 'true' into a boolean and '30' into a number. format builds a string with placeholders like {0}. runner.arch came out as ARM64 here because we ran act on an Apple Silicon machine with an arm64 container; on GitHub's standard ubuntu-latest you will see X64.
hashFiles returns the SHA-256 digest of the files matching a glob and is the cornerstone of cache keys: hashFiles('api/**/packages.lock.json') stays the same as long as the lock files do not change. We will use caching in the .NET CI post.
On January 29, 2026 the language gained case(pred1, val1, pred2, val2, ..., default); it tries the predicates in order and returns the value of the first true one, or the last argument if none match: ${{ case(github.ref == 'refs/heads/main', 'production', 'development') }}. Previously people used the cond && 'a' || 'b' trick, which gave the wrong result when 'a' was an empty string. A warning: actionlint 1.7.12 knows case, but act 0.2.97 does not yet. When we gave the same workflow to act, it stopped with this error:
Error: workflow is not valid. 'expressions.yml': Line: 9 Column 5: Failed to match job-factory: Line: 26 Column 9: Failed to match run-step: Line: 27 Column 14: Unknown Function Call case
So we removed the case line from the version we ran in act; since we did not run it on GitHub, it was not tried there either, and its behavior here comes from the documentation.
Moving Data Between Steps: GITHUB_OUTPUT and GITHUB_ENV
To carry a value produced by one step to later steps and other jobs, the runner gives you two files:
version:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.ver.outputs.version }}
steps:
- name: Compute version
id: ver
run: |
version="1.4.${{ github.run_number }}"
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "BOOKSTORE_VERSION=$version" >> "$GITHUB_ENV"
- name: Read it back
run: |
echo "step output : ${{ steps.ver.outputs.version }}"
echo "env variable: $BOOKSTORE_VERSION"
echo "env context : ${{ env.BOOKSTORE_VERSION }}"
[expressions/version] ⭐ Run Main Compute version
[expressions/version] ⚙ ::set-env:: BOOKSTORE_VERSION=1.4.1
[expressions/version] ⚙ ::set-output:: version=1.4.1
[expressions/version] ⭐ Run Main Read it back
[expressions/version] | step output : 1.4.1
[expressions/version] | env variable: 1.4.1
[expressions/version] | env context : 1.4.1
A name=value line written to $GITHUB_OUTPUT becomes the step's output and is read with steps.<id>.outputs.<name>; for that the step must have an id. The job's outputs: section exposes the value outside the job, so other jobs can read it as needs.version.outputs.version. According to the documentation, outputs can total at most 1 MB per job and 50 MB across all outputs of a workflow run. Outputs are always strings.
A variable written to $GITHUB_ENV shows up as a normal environment variable in all later steps of the job and can also be read from the env context. An important detail: the step that writes it does not see the new value; only later steps do. You cannot overwrite the default variables starting with GITHUB_* and RUNNER_*. If you need a multi-line value there is the name<<EOF ... EOF syntax; as the documentation warns, the delimiter must not appear on a line of its own inside the value.
If you see echo "::set-output name=version::1.4.1" in old workflows, that is the deprecated old way; the ::set-output:: line in the act log is just act's own display. We will talk about updating old workflows in the last post of the series.
Job Summaries: A Test Results Table with GITHUB_STEP_SUMMARY
Instead of hunting for test results in hundreds of log lines, you can see them as a table on the run's summary page. Each step gets its own GITHUB_STEP_SUMMARY file; the GitHub Flavored Markdown you write into it is combined and shown on the run's summary page when the job finishes. We had xUnit v3 produce a TRX (Visual Studio Test Results) report and wrote a small script that turns it into Markdown:
# Writes a Markdown summary table from one or more TRX files
import glob
import sys
import xml.etree.ElementTree as ET
ns = {"t": "http://microsoft.com/schemas/VisualStudio/TeamTest/2010"}
print("| Project | Total | Passed | Failed | Skipped |")
print("|---|---:|---:|---:|---:|")
for path in sorted(glob.glob(sys.argv[1], recursive=True)):
root = ET.parse(path).getroot()
c = root.find("t:ResultSummary/t:Counters", ns).attrib
name = root.find("t:TestDefinitions/t:UnitTest", ns).attrib["storage"].split("/")[-1]
skipped = int(c["total"]) - int(c["executed"])
print(f"| {name} | {c['total']} | {c['passed']} | {c['failed']} | {skipped} |")
for path in sorted(glob.glob(sys.argv[1], recursive=True)):
root = ET.parse(path).getroot()
for r in root.findall("t:Results/t:UnitTestResult[@outcome='Failed']", ns):
print(f"\n:x: `{r.attrib['testName']}`")
test:
needs: version
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-dotnet@v6
with:
global-json-file: global.json
- name: Unit tests
id: tests
run: >
dotnet test --project api/tests/Bookstore.Api.Tests -c Release
--results-directory TestResults --report-xunit-trx
- name: Test summary
if: ${{ !cancelled() }}
run: |
{
echo "### Bookstore ${{ needs.version.outputs.version }} unit tests"
python3 .github/scripts/trx-summary.py 'TestResults/**/*.trx'
} | tee -a "$GITHUB_STEP_SUMMARY"
With tee -a the same content goes to both the log and the summary file, so we can see it in act too. The summary act captured on the branch where Ada changed the shipping fee and forgot the tests:
[expressions/test ] ⚙ Summary - ### Bookstore 1.4.1 unit tests
| Project | Total | Passed | Failed | Skipped |
|---|---:|---:|---:|---:|
| Bookstore.Api.Tests.dll | 6 | 4 | 2 | 0 |
:x: `Bookstore.Api.Tests.OrderPricingTests.Small_order_pays_shipping`
:x: `Bookstore.Api.Tests.OrderPricingTests.Discount_can_bring_order_below_free_shipping`
On GitHub this Markdown shows up as a real table on the run page. Whoever looks at the pull request sees which test broke without opening a log. Ready-made actions (test reporters) write to the same file; this is the mechanism.
Status Functions: success, failure, always and cancelled
When a step fails, the following steps are skipped by default, because every step has an invisible default condition of success(). Our summary step must run when tests break too, which is why its condition is !cancelled(). We added two more steps at the end of the workflow:
- name: Only when tests failed
if: ${{ failure() && steps.tests.outcome == 'failure' }}
run: echo "tests failed, outcome=${{ steps.tests.outcome }}"
- name: Always (even after cancel)
if: ${{ always() }}
run: echo "job status so far = ${{ job.status }}"
[expressions/test ] ⭐ Run Main Only when tests failed
[expressions/test ] | tests failed, outcome=failure
[expressions/test ] ⭐ Run Main Always (even after cancel)
[expressions/test ] | job status so far = failure
[expressions/test ] 🏁 Job failed
On the green run, "Only when tests failed" was skipped and "Always" printed job status so far = success. failure() returns true if any earlier step failed; with steps.tests.outcome you can narrow it to a specific step. The difference between outcome and conclusion matters here: if a step with continue-on-error: true fails, its outcome is failure but its conclusion is success.
The difference between always() and !cancelled() only shows up when someone cancels the run: always() runs even after a cancel, !cancelled() does not. For work that could hit a critical failure (fetching sources, for example), the documentation recommends !cancelled() over always(); otherwise a cancelled run can hang until it times out. Our rule: !cancelled() for cleanup and reporting, always() only for very light work that truly must happen "no matter what". We did not simulate cancellation in act; this difference comes from the documentation.
Production Notes
Expression mistakes don't fail the build, they produce wrong behavior: a publish that runs on every pull request, as in the guess section, or a cleanup that never runs. That is why putting actionlint in a pre-commit hook or in the first job of CI catches nearly this whole class of mistakes for a few seconds of cost.
Keep outputs small. To move files from one job to another, use an artifact, not an output; outputs are for short values such as a version number, an image tag or a "did it change" flag. The job summary has a limit too; instead of dumping a test report thousands of lines long there, write only the failed tests and the counts, and upload the full report as an artifact.
Understanding skipped jobs also got easier: with the same changelog, when a job is skipped because of if:, the job log shows the condition both as written and with context values substituted. In act you get the same information with -v.
And visibility: the job summary page is the first thing someone sees after clicking the red cross on a pull request. Putting the answers to "what broke, where, and how do I reproduce it locally" there directly increases the team's trust in CI.
Common Pitfalls
Leaving text outside ${{ }} in if:. The condition becomes a string and always counts as true. actionlint catches it with the [if-cond] rule.
Running on cancelled runs too with always(). You cancelled a long deploy, but the "notify" or "upload" steps guarded by always() ran anyway. Most of the time what you want is !cancelled().
Treating an output as a number. steps.x.outputs.count > 10 seems to work most of the time thanks to loose comparison, but an empty output turns into 0. For numeric comparisons use fromJSON(steps.x.outputs.count) and check separately for the empty case.
Embedding an expression directly in run:. A user-controlled value such as ${{ github.event.pull_request.title }} is pasted into the script's text before the step runs; a "; curl ... written in the title runs in your shell. Passing the value through env: and reading it as "$TITLE" closes that risk. We will look at script injection in detail in the security post.
Reading GITHUB_ENV in the same step. The value you write is only visible in later steps.
A Small Experiment
★☆☆ Add a short-sha output to the version job containing the first 7 characters of github.sha (hint: ${GITHUB_SHA::7} in the shell). Add it to the summary heading in the test job.
★★☆ Write a deliberately broken condition like if: ${{ steps.tests.outputs.failed }} != '0', read actionlint's warning, then build the correct version with fromJSON.
★★★ Try trx-summary.py with more than one test project (unit and integration) on separate rows, and print the first line of the error message for each failed test. Add a limit so the summary never exceeds 50 lines.
In this post we carried an output between two jobs with needs. In the next post we turn jobs into a real graph: dependencies, multiple combinations with a matrix, cancelling old runs with concurrency, timeouts, and an integration test job that brings up PostgreSQL as a service container.