1.01 GitHub Actions: Workflows, Jobs & Steps
Overview
GitHub Actions is GitHub's built-in automation and CI/CD service. Everything you build with it rests on three building blocks: workflows, jobs, and steps.
Abstract
A workflow is attached to a GitHub repository and is triggered by events. A workflow contains one or more jobs, each of which runs on a runner (an execution environment). Each job contains one or more steps, which run in order and either execute a shell command or use an action. A repository can hold as many workflows as you want, a workflow as many jobs as you want, and a job as many steps as you want.
Code Repository
└── Workflow 1 (triggered by an event)
├── Job 1 (runs on a runner)
│ ├── Step 1 (shell command or action)
│ └── Step 2
└── Job 2
└── Step 1
Why It Matters in Production
Workflows are where your release process actually lives. Build, test, scan, and deploy all run here, so the structure you choose directly controls pipeline speed, blast radius, and who can ship what.
- Jobs are the unit of parallelism and isolation — each one gets a fresh machine, so splitting work across jobs shortens wall-clock time but costs you shared state.
- Steps are the unit of sequence and failure — the first failing step stops the job, which is how you gate a deploy behind tests.
- Events are the unit of control — they decide whether something runs on every push, only on a tag, or only when a human clicks a button.
Key Concepts
| Concept | What it is |
|---|---|
| Workflow | An automated process attached to a repository, defined in a YAML file |
| Event / trigger | The condition that starts a workflow (push, pull request, schedule, manual) |
| Job | A set of steps that run together on one runner |
| Runner | The machine and OS executing a job — GitHub-hosted or self-hosted |
| Step | A single unit of work: a shell command (run) or an action (uses) |
| Action | A reusable, predefined script — official, third-party, or your own |
Note
Workflow files live in .github/workflows/ in the repository. Any .yml or .yaml file there
is picked up automatically — there is no central registry to update.
Common Use Cases
- Run automated tests on every push or pull request
- Lint and type-check code before review
- Build and publish container images to a registry
- Deploy to staging on merge to
main, to production on a version tag - Run scheduled maintenance: dependency updates, backups, cleanup jobs
- Trigger a release manually with
workflow_dispatchfor controlled deploys
Example Configuration or Commands
A minimal workflow with one job and three ordered steps — checkout, install, test:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4 # step as an action
- run: npm ci # step as a shell command
- run: npm test
Runner selection across operating systems:
Common event triggers:
on:
push:
branches: [main]
tags: ['v*']
pull_request:
schedule:
- cron: '0 3 * * *' # nightly at 03:00 UTC
workflow_dispatch: # manual button in the Actions tab
Best Practices
Best Practices
- One workflow per purpose:
ci.yml,release.yml,nightly.yml. Easier to read and to restrict than one giant file. - Split independent work into separate jobs so it runs in parallel; use
needsonly where a real dependency exists. - Set
timeout-minuteson every job so a hung step cannot burn runner minutes indefinitely. - Add
concurrencywithcancel-in-progress: trueso a new push supersedes a stale run. - Name steps (
- name: Run unit tests) — unnamed steps are hard to read in failed run logs. - Cache dependencies (
actions/cacheor the setup actions' built-in cache) instead of reinstalling from scratch. - Keep deploys behind GitHub Environments with required reviewers for production.
Security Best Practices
Security
- Never hardcode tokens, keys, or passwords in workflow YAML — the file is in the repository. Use repository/environment secrets, and prefer OIDC federation over long-lived cloud keys.
- Set the least-privilege token scope explicitly:
Grant
writescopes only on the specific job that needs them. - Pin third-party actions to a full commit SHA, not a moving tag: a tag can be repointed at malicious code.
- Treat
pull_request_targetandworkflow_runas dangerous — they run with repository secrets in scope while handling untrusted fork code. - Never interpolate untrusted input into a
runblock.${{ github.event.issue.title }}inside a shell command is a script-injection hole; pass it throughenv:instead. - Rotate credentials on a schedule and scope self-hosted runners away from public repositories, where fork PRs could execute arbitrary code on your infrastructure.
Do and Don't
| ✅ Do | ❌ Don't |
|---|---|
| Store credentials in secrets or use OIDC | Paste tokens into the workflow file |
| Pin actions to a commit SHA | Trust a floating @v1 tag from an unknown author |
Declare permissions explicitly per job |
Rely on the default broad token scope |
Use needs to order dependent jobs |
Assume jobs run top-to-bottom in file order |
| Pass data between jobs via artifacts or outputs | Expect files written in one job to exist in another |
Set timeout-minutes and concurrency |
Let runs queue up and hang forever |
| Gate production behind an Environment | Deploy straight from every push to any branch |
Common Mistakes
Common Mistakes
- Expecting jobs to be sequential. Jobs run in parallel by default; only
needscreates order. - Expecting shared filesystem or state between jobs. Each job gets a clean runner. Use
actions/upload-artifact/download-artifactor joboutputs. - Forgetting
actions/checkout. The runner does not start with your code on disk. - Using
cdin one step and expecting it to persist. Eachrunis a new shell; useworking-directoryor a multi-line script. - Broad trigger definitions.
on: pushwith no branch filter runs the whole pipeline on every branch and every tag. - Assuming a failed step stops everything. It stops that job; unrelated parallel jobs
continue unless you configure
fail-fastbehavior.
Breaks in production
A deploy job without needs: test will happily ship a build whose tests failed, because it ran
in parallel with them rather than after them.
Troubleshooting
Inspect and re-run workflows from the CLI:
# List recent runs for the repository
gh run list --limit 10
# Show a failed run with the log of the failing step
gh run view <run-id> --log-failed
# Re-run only the jobs that failed
gh run rerun <run-id> --failed
# Validate YAML syntax before pushing
yq '.' .github/workflows/ci.yml > /dev/null && echo "valid YAML"
| Symptom | Likely cause |
|---|---|
| Workflow never triggers | File not in .github/workflows/, wrong branch filter, or bad YAML |
| "No such file or directory" in step 1 | Missing actions/checkout |
| Works locally, fails on runner | Different OS or tool version — pin versions in setup actions |
| Secret is empty at runtime | Secret defined in a different environment, or the run came from a fork |
| Job stuck in "Queued" | No self-hosted runner matches the runs-on labels, or minutes exhausted |
| Permission denied pushing to the repo | permissions: contents: write not granted to the job |
Tip
Add ACTIONS_STEP_DEBUG as a repository secret set to true to get verbose step logging
without changing the workflow file.
Quick Recap
- Workflows belong to a repository, live in
.github/workflows/, and are started by events. - A workflow contains one or more jobs; a job contains one or more steps.
- Each job declares a runner — GitHub-hosted (Linux, macOS, Windows) or self-hosted.
- Jobs run in parallel by default;
needsmakes them sequential. - Steps run strictly in order, and a step is either a shell command or an action.
- Both jobs and steps can be conditional via
if.
Interview / Revision Notes
Quick revision
Q: What are the three building blocks of GitHub Actions? Workflows, jobs, and steps.
Q: Where do workflow files live?
.github/workflows/*.yml in the repository.
Q: Do jobs run in parallel or sequentially?
Parallel by default; use needs for sequential ordering.
Q: Do steps run in parallel? No — always in the order written, within a single job.
Q: What is a runner? The execution environment (machine + OS) for a job: GitHub-hosted or self-hosted.
Q: What can a step do?
Run a shell command (run) or invoke an action (uses) — official, third-party, or custom.
Q: How do you pass data between jobs?
Artifacts or job outputs — not the filesystem, since each job gets a fresh runner.
Q: How do you make a workflow manually triggerable?
Add the workflow_dispatch event.
This topic can be expanded into sub-pages: events and triggers in depth, custom actions, reusable and composite workflows, self-hosted runner setup, and artifacts/caching.