GitHub Actions Workflow Syntax

A GitHub Actions workflow is a YAML file in .github/workflows/ that says when to run (triggers), where (runners), and what (jobs made of steps). Pushing the file is all it takes to turn it on. The syntax is compact, but a handful of concepts cause most confusion: how triggers filter events, how jobs run in parallel unless told otherwise, how data passes between steps and jobs, and how expressions and contexts work.

This page covers the anatomy of a workflow and the syntax you'll use daily. Related pages cover matrix builds, caching, reusable workflows, and security.

TL;DR

Quick Example

A CI workflow that tests on pull requests and deploys main after tests pass:

Core Concepts

Triggers (on)

paths and paths-ignore skip runs when only irrelevant files change. That's essential in monorepos. Note that a workflow skipped by path filters doesn't report a status, which matters for required checks.

Jobs

Each job runs on a fresh runner: a GitHub-hosted VM (ubuntu-latest, windows-latest, macos-latest, larger or ARM runners) or a self-hosted machine. Jobs run in parallel unless linked with needs. Useful job keys:

Steps

A step either uses an action (uses: owner/repo@ref with with: inputs) or runs a script (run:, with an optional shell: and working-directory:). Steps share the runner's filesystem, so files written in one step are visible to the next. Set id: to reference a step's outputs, and env: for step-level environment variables.

Expressions and Contexts

${{ expression }} is evaluated before the step runs. Common contexts:

Functions include contains(), startsWith(), format(), toJSON(), fromJSON(), hashFiles(), and the status checks success(), failure(), cancelled(), and always().

Passing Data

Within a job, use step outputs or files. Across jobs, declare job outputs: and read them via needs.<job>.outputs. Use artifacts (actions/upload-artifact / download-artifact) for files. $GITHUB_ENV sets environment variables for later steps, and $GITHUB_STEP_SUMMARY writes Markdown to the run summary page.

Environments and Concurrency

Environments (environment: production) add protection rules (required reviewers, wait timers, branch restrictions) and environment-scoped secrets. That's how you gate deployments. concurrency groups runs so only one runs at a time per group, and can cancel in-progress runs superseded by newer commits.

Best Practices

Set Least-Privilege Permissions

Declare permissions: contents: read at the top of every workflow and grant more per job only when needed (packages: write, id-token: write). See GitHub Actions security.

Always Set Timeouts

A hung test or deploy otherwise burns up to six hours of runner minutes. Set timeout-minutes on every job, sized to a few times its normal duration.

Cancel Superseded PR Runs

concurrency with cancel-in-progress for pull requests saves minutes and gives faster feedback when developers push several commits in a row. Don't cancel in-progress deployments to main.

Keep Logic in Scripts

Long inline run: blocks full of shell logic are hard to test and review. Move non-trivial logic into scripts in the repo (./scripts/release.sh) that also run locally, and keep workflows as orchestration.

Common Mistakes

Expecting Jobs to Share Files

Upload dist/ as an artifact in build and download it in deploy, or do both in one job.

Using set-output or save-state Commands

The old ::set-output name=x::value workflow commands are deprecated and disabled. Write to $GITHUB_OUTPUT and $GITHUB_STATE instead.

Interpolating Untrusted Input Into run:

run: echo "${{ github.event.pull_request.title }}" injects attacker-controlled text directly into a shell script. Pass it through an environment variable instead. This is a classic script-injection vulnerability covered in GitHub Actions security.

FAQ

What's the difference between pull_request and pull_request_target?

pull_request runs in the context of the PR's merge commit, and for PRs from forks it gets a read-only token and no secrets. pull_request_target runs in the context of the base branch with a write-capable token and secrets. It's intended for labeling and commenting, and it's dangerous if it checks out and runs the PR's code.

How do I run a workflow manually?

Add workflow_dispatch: (optionally with typed inputs) to on. You can then trigger it from the Actions tab, with gh workflow run ci.yml -f env=staging, or through the REST API.

How do I skip CI for a commit?

Include [skip ci], [ci skip], [no ci], [skip actions], or [actions skip] in the commit message for push and pull_request events. Path filters are better for skipping systematically, such as for docs-only changes.

Why didn't my scheduled workflow run?

Schedules only run on the default branch, can be delayed during high load, and are disabled automatically in public repositories after 60 days without repository activity. Check that the cron expression is in UTC.

Related Topics

References