Build the mental model
A workflow file is not static YAML. Anywhere you write ${{ }}, Actions evaluates an expression before the job runs, and expressions read from contexts. The one you will use most is github: github.event_name tells you what triggered the run, github.ref the branch or tag, github.sha the commit, github.actor who started it. Other contexts include runner, job, steps for the outputs of earlier steps, and env, vars and secrets.
Expressions support comparisons (==, !=), logical operators (&&, ||, !), and functions such as contains(), hashFiles(), always() and failure(). Inside an if: key the whole value is already an expression, so you can write if: github.event_name == 'push' without the braces.
env can be declared at three levels: workflow level applies to every job, job level to every step in that job, step level to that one step. When the same name is set at more than one level, the closest declaration wins - step beats job beats workflow. That sounds obvious until you lose half an hour on a test job that misbehaves because NODE_ENV was set to production at the top of the file.
The most consequential distinction is the last one. Repository settings hold both variables and secrets, and they are not interchangeable. vars.X is plain text: it appears in logs verbatim, readable by anyone who can read a run. Use it for an API base URL, a region, a feature flag. secrets.X is encrypted at rest and masked as *** in logs. Every token, password, and private key belongs there. Do not treat masking as a guarantee: base64 a secret, print a substring of it, or pass it through a tool that reformats it, and the masker no longer recognises the value.
CONTEXTS IN, ENV PRECEDENCE DOWN
--------------------------------
WHERE VALUES COME FROM (read-only contexts)
github.* vars.* secrets.* runner.* steps.*
event_name plain encrypted os outputs of
ref, sha text masked *** temp earlier steps
actor
| | | | |
+----------+-----+------+-----------+------------+
v
${{ expression }}
== != && || ! contains() hashFiles()
ENV PRECEDENCE (the closest declaration wins)
workflow-level env: APP_ENV = base weakest
|
v overridden by
job-level env: APP_ENV = staging
|
v overridden by
step-level env: APP_ENV = test WINS in that step
a step with no env of its own sees staging, not base.Connect it to a real scenario
The workflow below puts all three env levels in one place. At the top of the file APP_ENV is base and LOG_LEVEL is info. The report job resets APP_ENV to staging, and the step named "Env precedence" sets it once more to test for itself only. So that step sees APP_ENV=test, every other step in the job sees staging, and because nothing overrides LOG_LEVEL, all of them see info. Run it once and read the log; five minutes of watching precedence in action saves a lot of guessing later.
Note that $APP_ENV inside a run: block and ${{ env.APP_ENV }} are not the same mechanism. The first is the shell reading an environment variable at runtime; the second is Actions substituting text into the YAML before the shell ever starts.
Pay closest attention to how the secret is handled. In the "Secret passed through env" step the token is not interpolated into the run: line - it is bound under env: as API_TOKEN. The reason is that ${{ }} performs literal text substitution into the script. If the value contains a quote or a $, the shell may interpret it as code, which is the classic Actions script-injection hole. Binding through env passes it as a value instead. The same rule applies to anything a human can type, such as github.event.pull_request.title.
Try the working example
name: Contexts and Variables
on:
push:
branches: [main]
workflow_dispatch:
env:
APP_ENV: base
LOG_LEVEL: info
jobs:
report:
runs-on: ubuntu-latest
env:
APP_ENV: staging
steps:
- uses: actions/checkout@v4
- name: Read the github context
run: |
echo "event: ${{ github.event_name }}"
echo "ref: ${{ github.ref }}"
echo "sha: ${{ github.sha }}"
echo "actor: ${{ github.actor }}"
echo "repo: ${{ github.repository }}"
echo "os: ${{ runner.os }}"
- name: Env precedence
env:
APP_ENV: test
run: |
echo "APP_ENV is $APP_ENV"
echo "LOG_LEVEL is $LOG_LEVEL"
- name: This step has no env of its own
run: echo "APP_ENV is $APP_ENV"
- name: Repository variable, plain text and safe to print
run: echo "API base is ${{ vars.API_BASE_URL }}"
- name: Secret bound through env, never inlined into the script
env:
API_TOKEN: ${{ secrets.API_TOKEN }}
run: ./scripts/publish.sh
- name: Only on a manual run
if: github.event_name == 'workflow_dispatch'
run: echo "Started by hand by ${{ github.actor }}"
A push to main or a press of Run workflow starts the single report job. The first step writes the trigger, ref, sha, actor, repository, and runner OS into the log. The "Env precedence" step prints test for APP_ENV and info for LOG_LEVEL. The step after it, which declares no env of its own, prints staging - the workflow-level value base is never visible inside this job at all. vars.API_BASE_URL prints verbatim if that repository variable is configured and prints as an empty string if it is not. secrets.API_TOKEN is only ever bound as an environment variable for the publish script, and any occurrence of its value in the log is replaced with ***. The final step runs only on a manual dispatch; on a push run it is marked skipped.5-minute try-it
Add this workflow, push, and compare the three APP_ENV lines in the log. Then delete the job-level env block and see what the step with no env of its own prints instead. Next, in Settings, Secrets and variables, Actions, add API_BASE_URL as a variable and API_TOKEN as a secret, and observe how each one appears in the log. As a final experiment, add a step that echoes the secret directly and confirm it is masked - then delete that step immediately, and consider what would happen if you had echoed it after piping it through base64.
One important caution
Storing a credential as a repository variable instead of a secret. vars.* is not encrypted, is not masked, and prints in full to any log anyone with read access can open.
Interpolating human-authored text such as ${{ github.event.pull_request.title }} directly into a run: script. It is substituted literally, so a crafted title can execute as shell commands. Bind it through env: instead.
GitHub Docs - Contexts reference — CI/CD with GitHub Actions