Workflow Syntax
A workflow is a YAML file built on three load-bearing keys: on says when it runs, jobs says what runs, and within each job steps says how. Get those three and the indentation right, and everything else — permissions, concurrency, matrices — is refinement on a structure you already understand.
Because it is YAML, the structure is whitespace-significant and a few of YAML's quirks bite specifically here. Knowing the handful of pitfalls up front saves you the confusing class of failure where the file parses fine but does something other than what you meant.
Top-Level Keys
At the top of the file you have name (what shows in the Actions UI), on (the triggers), permissions (the GITHUB_TOKEN scopes), env (workflow-wide variables), concurrency (run serialization), and jobs (the actual work). Everything in the file hangs off these. name and the workflow filename are what you scan in a list of runs, so naming them is not cosmetic — it is how you find a failed run later.
The Job Block
Each entry under jobs: is one job with its own runner. A job declares runs-on: (the VM image), optionally needs: (jobs that must finish first), optionally strategy: (matrix expansion), and an ordered list of steps:. Steps run top to bottom on the same runner; jobs, by contrast, run in parallel unless needs: forces an order.
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build
run: |
npm ci
npm run build
Steps: run vs uses
A step is either a shell command (run:) or an action invocation (uses:) — never both in the same step. with: passes inputs to a uses: step; env: sets variables scoped to a single step. If you find yourself wanting to both call an action and run a command, that is two steps, in the order you want them to execute.
The | block scalar above keeps each line of a multi-line run: as a separate shell line. Reach for | whenever a run: has more than one command; the folded > scalar joins lines with spaces and will silently glue two commands into one.
YAML Pitfalls
The infamous one: on is a YAML boolean alias for true in the 1.1 spec, so some editors and linters render the key as true:. GitHub parses the literal key on correctly, so keep it as written and ignore tools that "helpfully" rewrite it. The other recurring trap is quoting — wrap an expression like ${{ ... }} in quotes when it sits where YAML might misread the braces, and use | not > for any multi-line script.
Run Defaults
When several steps share a shell or a directory, set them once under defaults.run instead of repeating yourself. In a monorepo, working-directory beats a cd at the top of every run:; on cross-platform jobs, pinning shell: bash makes the same script behave identically on the Windows runner, which otherwise defaults to PowerShell.
defaults:
run:
shell: bash
working-directory: ./frontend
- Letting a tool rewrite the
onkey totrue:because YAML treatsonas a boolean — keep the literal keyon, which GitHub parses correctly. - Putting both
run:anduses:in one step — a step is one or the other, so split it into two steps in the order you need. - Writing a multi-line shell script under a folded
>scalar, which joins the lines with spaces and merges separate commands into one broken line; use|instead. - Assuming a workflow-level
env:value wins everywhere, when a job-level or step-levelenv:with the same name overrides it — the most specific scope wins. - Relying on the default shell on Windows runners and getting PowerShell behavior for a bash script, instead of pinning
shell: bash.
- Give every workflow and job a
name:so the Actions run list is readable at a glance. - Use
defaults.run.working-directoryfor a monorepo subdirectory instead of acdin every step. - Pin
shell: bashon cross-platform jobs so a script behaves identically on Linux, macOS, and Windows runners. - Keep secrets and config in
env:andsecretsrather than inlining them in arun:command where they leak into logs. - Use the
|block scalar for any multi-linerun:so each command stays on its own line.
stages and script keysCircleCI jobs and stepsAzure Pipelines stages / jobs / stepsJenkins declarative pipeline / stagesKnowledge Check
Can a single step both run a shell command and invoke an action?
- No — a step is either
run:oruses:, so this takes two separate steps - Yes —
uses:runs first, then the step'srun:executes after it - Yes, as long as you also pass the command through
with:on the step - Only on self-hosted runners, where the step constraint is relaxed
The same variable name is set in workflow-level, job-level, and step-level env:. Which value does the step see?
- The step-level value, because the narrowest scope wins
- The workflow-level value, since the outermost scope is read first and wins
- The job-level value, which always overrides both step and workflow scope
- It is ambiguous, so the run errors on the duplicate name
Why use | rather than > for a multi-line run: script?
|keeps newlines so each line is its own command;>folds them into one with spaces>is invalid YAML inside arun:block and is rejected at parse time- They are identical at runtime; choosing between them is purely a style preference
|automatically escapes shell metacharacters like$and backticks for you
What does needs: change about how jobs run?
- It makes a job wait for the named jobs, ordering ones that would otherwise run in parallel
- It shares the runner filesystem between the named jobs so files written in one carry straight over
- It merges the steps of the named jobs into one combined job on one runner
- It has no effect on execution order and only nests the jobs in the UI
You got correct