GitHub Actions Fundamentals
Topic 48

Workflow Syntax

Actions

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.

One job, two steps: checkout then build
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.

Shared shell and working directory for every run step
defaults:
  run:
    shell: bash
    working-directory: ./frontend
Common Mistakes
  • Letting a tool rewrite the on key to true: because YAML treats on as a boolean — keep the literal key on, which GitHub parses correctly.
  • Putting both run: and uses: 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-level env: 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.
Best Practices
  • Give every workflow and job a name: so the Actions run list is readable at a glance.
  • Use defaults.run.working-directory for a monorepo subdirectory instead of a cd in every step.
  • Pin shell: bash on cross-platform jobs so a script behaves identically on Linux, macOS, and Windows runners.
  • Keep secrets and config in env: and secrets rather than inlining them in a run: command where they leak into logs.
  • Use the | block scalar for any multi-line run: so each command stays on its own line.
Comparable toolsGitLab CI/CD stages and script keysCircleCI jobs and stepsAzure Pipelines stages / jobs / stepsJenkins declarative pipeline / stages

Knowledge Check

Can a single step both run a shell command and invoke an action?

  • No — a step is either run: or uses:, so this takes two separate steps
  • Yes — uses: runs first, then the step's run: 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 a run: 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