GitHub Actions in Depth

Expressions, Contexts & Conditionals

18 min Lesson 3 of 30

Expressions, Contexts & Conditionals

By the time a workflow file is saved, you have already written YAML — but GitHub Actions evaluates a second language embedded inside that YAML: the expression language. Understanding it is the difference between copy-pasting snippets and authoring workflows that behave exactly the way you intend under every condition. This lesson covers that language completely: its syntax, the built-in contexts it reads from, and the if: key that gates job and step execution.

Expression Syntax

Expressions are delimited by ${{ }}. They may appear in any value position in a workflow file — job-level fields like runs-on, step fields like run, env, with, and the if: key. A bare if: value is itself an implicit expression (the delimiters are optional there).

The language supports:

  • Literals — boolean (true/false), null, numbers, and single-quoted strings: 'main'. Double quotes are not valid string delimiters.
  • Operators==, !=, <, <=, >, >=, &&, ||, !. Comparison is case-insensitive for strings.
  • Property access — dot notation (github.event_name) or bracket notation for dynamic keys (fromJson(inputs.matrix)[0]).
  • Functions — a small stdlib: contains(), startsWith(), endsWith(), format(), join(), toJson(), fromJson(), hashFiles(), always(), success(), failure(), cancelled().
Type coercion: GitHub Actions silently coerces types in comparisons. An empty string equals false; any non-empty string equals true. This is intentional and useful — if: inputs.flag is truthy whenever the input was provided — but it catches engineers who expect strict equality.

Contexts

A context is a named object that Actions populates before your workflow runs. You access context properties with expressions. The seven contexts you will use most often are:

  • github — metadata about the triggering event: github.event_name, github.ref, github.sha, github.actor, github.repository, and the full event payload at github.event.
  • env — environment variables set at the workflow, job, or step level. Within a step's run you can also read them as shell variables ($VAR), but ${{ env.VAR }} is available anywhere, including if: conditions.
  • secrets — values stored in the repository or organization secret store. Accessing them in expressions (${{ secrets.TOKEN }}) is the only safe way to pass them to steps. Actions redacts secret values from logs automatically.
  • vars — non-sensitive configuration variables (repository or org level). Use these for things like AWS_REGION or NODE_VERSION that are environment-specific but not secret.
  • jobs — output values from other jobs in the same workflow. Available only in jobs that declare needs:.
  • steps — output values and outcome (success/failure/skipped/cancelled) of previous steps within the same job.
  • runner — runtime info: runner.os, runner.arch, runner.temp, runner.tool_cache.
At big-tech scale, use vars for everything that varies between environments but is not sensitive — base Docker registries, cluster names, feature flags, notification channels. This keeps secrets small and auditable, and you can change non-secret configuration without rotating credentials.
GitHub Actions contexts and where they are populated Context Sources & Availability github.* Event payload, ref, sha secrets.* Encrypted secret store vars.* Non-secret config vars env.* Workflow / job / step env steps.* Outputs & outcomes jobs.* Cross-job outputs (needs) runner.* OS, arch, temp path Expression Evaluator ${{ ... }} if: conditions env: values with: inputs run: commands
All contexts feed into the expression evaluator, which resolves values at workflow runtime.

The if: Key — Conditional Execution

Every job and every step accepts an if: key. When the expression evaluates to falsy, GitHub skips that job or step entirely — it appears as "skipped" in the UI. The default implicit condition is success(), meaning jobs and steps only run when all previous steps in the same job (or all needed jobs) succeeded.

The four status-check functions are critical to understand:

  • success() — true when every prior step/job finished without error (default).
  • failure() — true when at least one prior step/job failed. Use this for notification or cleanup steps.
  • always() — true regardless of prior outcome, even if the workflow was cancelled. Use for teardown steps that must run no matter what.
  • cancelled() — true only when the workflow run was explicitly cancelled.
A common pitfall: writing if: failure() on a step that also needs an output from a previous step. If the previous step failed, its output is empty or undefined — and your failure handler may itself error. Always scope failure handlers to the minimal data they actually need.

Practical Patterns

The following workflow demonstrates all three concepts together — expressions, multiple contexts, and conditional steps — in a realistic production scenario: a deployment that only runs on main, notifies Slack on failure, and always uploads logs as artifacts.

name: Production Deploy on: push: branches: [main] pull_request: branches: [main] env: AWS_REGION: ${{ vars.AWS_REGION }} # non-secret config var IMAGE_TAG: ${{ github.sha }} jobs: build-and-push: runs-on: ubuntu-latest outputs: image-uri: ${{ steps.push.outputs.uri }} steps: - uses: actions/checkout@v4 - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }} aws-region: ${{ env.AWS_REGION }} - name: Build & push image id: push run: | IMAGE="${{ vars.ECR_REGISTRY }}/myapp:${{ env.IMAGE_TAG }}" docker build -t "$IMAGE" . docker push "$IMAGE" echo "uri=$IMAGE" >> "$GITHUB_OUTPUT" deploy: needs: build-and-push runs-on: ubuntu-latest # Only deploy on a real push to main, not on PR builds if: github.event_name == 'push' && github.ref == 'refs/heads/main' environment: production # triggers required reviewers if configured steps: - name: Deploy to ECS run: | aws ecs update-service \ --cluster ${{ vars.ECS_CLUSTER }} \ --service myapp \ --force-new-deployment \ --region ${{ env.AWS_REGION }} - name: Collect deploy logs if: always() # upload logs even when deploy fails run: aws ecs describe-services --cluster ${{ vars.ECS_CLUSTER }} --services myapp > deploy-logs.json - name: Upload logs artifact if: always() uses: actions/upload-artifact@v4 with: name: deploy-logs-${{ github.sha }} path: deploy-logs.json - name: Notify Slack on failure if: failure() uses: slackapi/slack-github-action@v1 with: payload: | { "text": "Deploy FAILED for ${{ github.repository }} @ ${{ github.sha }} by ${{ github.actor }}" } env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_DEPLOY_WEBHOOK }}

Notice the interplay: vars.* holds environment-specific but non-sensitive config; secrets.* holds the IAM role ARN and webhook URL; github.* drives the conditional logic; and steps.* outputs thread the image URI between jobs.

Checking Step Outcomes

Each step has an outcome property: success, failure, cancelled, or skipped. You can branch on it explicitly when you need fine-grained control:

- name: Run tests id: tests run: npm test continue-on-error: true # don't fail the job yet - name: Post coverage comment if: steps.tests.outcome == 'success' run: gh pr comment --body "$(cat coverage-summary.txt)" - name: Fail the job now if: steps.tests.outcome == 'failure' run: exit 1

The continue-on-error: true flag lets a step fail without immediately killing the job — you capture the outcome and decide what to do next. This is useful for flaky test suites where you want to upload results before halting.

Expression Functions in Practice

Two functions deserve special attention at scale. hashFiles('**/package-lock.json') returns a deterministic hash of the matched files — ideal as a cache key that invalidates only when dependencies actually change. fromJson() and toJson() let you pass structured data between steps and jobs, bridging the string-only boundary of GITHUB_OUTPUT.

Use contains(github.event.pull_request.labels.*.name, 'skip-ci') to let engineers label a PR and bypass expensive jobs. The *.name wildcard flattens an array of label objects to their names — a clean idiom you will see in production workflows at scale.