How do GitHub Actions workflows work — triggers, jobs, steps, and runners?
Quick Answer
GitHub Actions workflows are YAML-defined automation pipelines stored in .github/workflows/ that respond to events (triggers), execute parallel jobs on runners (GitHub-hosted or self-hosted VMs), and each job contains sequential steps that run shell commands or pre-built actions to accomplish CI/CD tasks.
Detailed Answer
Think of a GitHub Actions workflow like a restaurant kitchen during dinner service. The order ticket arriving is the trigger (a push, PR, or schedule), the kitchen has multiple stations (jobs) that can work in parallel — grill, sauté, pastry — and each station follows a recipe with sequential steps. The runners are the actual cooks at each station, and they can be the house staff (GitHub-hosted) or contract chefs you bring in (self-hosted runners). Just as the expeditor coordinates when dishes from different stations need to come together, job dependencies ensure the right ordering.
At its core, a workflow is a YAML file living in .github/workflows/ that declares one or more triggers under the 'on' key. Triggers include push, pull_request, schedule (cron), workflow_dispatch (manual), repository_dispatch (API), and dozens more. Each trigger can be filtered by branches, paths, or tags. Jobs are the top-level units of parallelism — each job runs on a fresh runner instance with its own filesystem, environment variables, and network stack. Steps within a job execute sequentially and share the runner's filesystem, meaning artifacts created in step one are available in step two.
Internally, when a trigger fires, GitHub's orchestration layer creates a workflow run, decomposes it into jobs, evaluates the 'needs' dependency graph, and queues each ready job onto the runner pool. GitHub-hosted runners spin up a fresh VM (Ubuntu, Windows, or macOS) for every job, pulling down your repository via the actions/checkout action. The runner application on the VM communicates with GitHub over HTTPS long-poll, receiving step instructions and streaming logs back. Each step either invokes a JavaScript or Docker-based action, or runs a shell script via the 'run' key. Expressions like ${{ secrets.TOKEN }} and ${{ github.sha }} are interpolated server-side before the runner sees them, which is critical for security.
In production, teams typically structure workflows with a lint job, a test job that fans out via matrix strategies, a build job that depends on tests passing, and a deploy job gated by environment protection rules. Self-hosted runners become important when you need GPU access, specific hardware, or network access to internal resources. Runner groups and labels let you route jobs to the correct machines. Organizations often set concurrency groups to prevent duplicate deployments and use workflow-level permissions to follow the principle of least privilege for the GITHUB_TOKEN.
A common gotcha is assuming jobs share state — they do not. Each job gets a clean runner, so you must use actions/upload-artifact and actions/download-artifact to pass files between jobs, or use actions/cache for dependency caching. Another pitfall is trigger misconfiguration: using 'on: push' without branch filters means every push to every branch triggers the workflow, burning through your minutes. Also, the default GITHUB_TOKEN permissions changed to read-only in newer repositories, so workflows that push tags or create releases will fail silently unless you explicitly set 'permissions: contents: write' at the job or workflow level.