GitHub Actions Basics: How to Write Workflow YAML Files

A guide to GitHub Actions workflow YAML structure, covering triggers, jobs, steps, and runners, plus the execution model, secrets masking and leak risks, matrix builds, actions/cache design, and GITHUB_TOKEN permissions.

What is GitHub Actions?

GitHub Actions is a CI/CD (Continuous Integration/Continuous Delivery) platform that allows you to automate software development workflows directly within your GitHub repository. It automates tasks such as building, testing, and deploying code to streamline the development process.

Workflows are defined by placing YAML files in the following directory structure at the root of your repository:

.github/workflows/{filename}.yml

YAML File Structure

A GitHub Actions workflow YAML file consists of the following main elements:

name: { workflow name } # Workflow name displayed in the GitHub UI

on:
  # Specify trigger events for the workflow
  # Example 1: push event (on push to specified branches)
  push:
    branches:
      - main # or master
      - develop # Multiple branches can be specified

  # Example 2: pull_request event (on PR to specified branches)
  pull_request:
    branches:
      - main
      - develop

  # Example 3: schedule event (specify time in CRON format)
  # Specified in UTC; for JST, subtract 9 hours
  schedule:
    - cron: "45 10 * * *"

  # Other trigger events: https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows

jobs:
  # Define jobs to execute
  build: # Job ID (arbitrary name)
    name: Build and Test # Job display name
    runs-on:
      ubuntu-latest # Specify the runner environment
      # ubuntu-latest, windows-latest, macos-latest, etc.
      # Or a specific version (ubuntu-20.04)

    steps:
      # Sequence of steps executed within the job
      - uses: actions/checkout@v4 # Action to checkout repository code
        # Use GitHub-provided or community actions

      - name: Install dependencies # Step name
        run: |
          npm install # Example: Install Node.js dependencies
          pip install -r requirements.txt # Example: Install Python dependencies

      - name: Build code # Step name
        run: |
          npm run build # Example: Build the project
          make # Example: Run make command

      - name: Run tests # Step name
        run: |
          npm test # Example: Run tests
          pytest # Example: Run Python tests

Key Elements Explained

  • name: The name of the entire workflow. Displayed in GitHub’s Actions tab.
  • on: Defines when the workflow is triggered. Various events such as push, pull_request, schedule, etc. can be specified.
  • jobs: Defines a series of jobs to be executed within the workflow. Each job can run independently or sequentially with dependencies.
    • runs-on: Specifies the virtual environment (runner) for the job. You can choose the latest versions or specific versions of Ubuntu, Windows, and macOS.
    • steps: A sequence of individual tasks executed within a job.
      • uses: Uses an existing action (a reusable unit of work). actions/checkout@v4 is an official action that checks out repository code to the runner.
      • name: The display name of the step.
      • run: Executes shell commands. Multiple commands can be written using |.

That covers the minimal set of building blocks. The rest of this article digs into the practical questions every team eventually runs into: how do jobs actually get scheduled, how safe are your secrets really, how do you speed up CI, and how tightly should permissions be scoped — each backed by concrete YAML.

The Execution Model: Jobs Run in Parallel, Steps Run Sequentially

To predict how a workflow will actually behave, you need three rules:

  1. Multiple jobs within the same workflow run in parallel by default. Unless you specify a dependency with needs, jobs don’t wait for one another.
  2. The steps inside a single job always run sequentially, top to bottom. The next step doesn’t start until the previous one finishes, and if a step fails, the remaining steps in that job are skipped by default.
  3. Each job runs on its own fresh (clean) virtual machine (runner). Even within the same workflow, moving from one job to the next means the filesystem, environment variables, and running processes are not carried over at all.

That third rule is the one beginners most often miss. It’s tempting to assume that because two jobs belong to the “same workflow,” an artifact built in Job A should just be available to Job B. In reality, Job B boots on a completely separate VM and has no access whatsoever to Job A’s filesystem. To pass state (build artifacts, generated files, computed results) between jobs, you must explicitly use one of:

  • actions/upload-artifact in Job A and actions/download-artifact in Job B
  • actions/cache, keyed on content, so a later job (or a later workflow run) can restore it
  • Job outputs, for small string values only (a version number, a boolean flag, etc.)

The diagram below shows two jobs (build and lint-and-test) starting in parallel from a push/pull_request trigger, with steps inside each job executing top to bottom. The dashed line connecting the two jobs represents the fact that nothing is shared automatically — passing artifacts across jobs requires upload-artifact/download-artifact.

GitHub Actions job/step execution model. Two jobs start in parallel; steps inside each job run sequentially. Sharing state across jobs requires upload-artifact/download-artifact

Grounding it in a real workflow

This repository (web-source, which also builds this blog) has its own .github/workflows/deploy.yml, structured like this (a real excerpt):

name: Deploy to GitHub Pages

on:
  push:
    branches:
      - main
  workflow_dispatch:

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: "npm"
      # ... (omitted: npm ci, type-check, Hugo/Next.js build, deploy, E2E tests)

This workflow consists of a single job, build-and-deploy. That means the Hugo build, the Next.js build, the deploy step, and the E2E tests all run sequentially on the same runner, so there’s no need to think about passing state between jobs. Had this been split into a “build” job and a “deploy” job instead, the deploy job would have failed to find the build output (the dist/ directory) unless it was explicitly bridged across with upload-artifact/download-artifact. Keeping it as one job is a common, deliberate way to sidestep exactly this “every job gets a clean slate” constraint.

Edge Case (a): Handling Secrets and Masking

The secrets context

Sensitive values like API keys and deploy keys are registered under Settings > Secrets and variables > Actions, and referenced in a workflow via the secrets.<name> context.

- name: Deploy to yuhi-sa.github.io
  uses: peaceiris/actions-gh-pages@v4
  with:
    deploy_key: ${{ secrets.DEPLOY_KEY }}
    external_repository: yuhi-sa/yuhi-sa.github.io
    publish_branch: main
    publish_dir: ./dist

(This, too, is a real excerpt from deploy.yml — it passes an SSH deploy key as secrets.DEPLOY_KEY and pushes directly to yuhi-sa/yuhi-sa.github.io.)

Masking in the logs

If a registered secret’s value appears verbatim in a workflow run’s log, GitHub Actions automatically replaces it with ***. For example, this step:

- name: Debug output (should be avoided in practice)
  run: echo "token is ${{ secrets.DEPLOY_KEY }}"

produces this in the actual log — the value itself is never visible:

token is ***

This masking mechanism works by mechanically checking whether the log text contains a substring that exactly matches a registered secret’s value, and substituting it. The flip side is that if you emit the secret in a transformed form rather than as-is, masking does nothing.

A real incident: bypassing the masking

This isn’t a theoretical concern — it played out as a large-scale real-world incident. In March 2025, the third-party action tj-actions/changed-files, used by over 23,000 repositories, was tampered with and malicious code was injected ( CVE-2025-30066 ). The malicious code dumped the memory of the runner’s worker process, searched it for anything that looked like a secret, and base64-encoded it twice before printing it to the log — which sidestepped GitHub’s automatic masking entirely. Because masking can only recognize an exact match against a secret’s raw value, encoding it and thereby changing its appearance let it slip straight through. Since public repositories also expose their workflow logs publicly, AWS access keys, GitHub PATs, npm tokens, and private RSA keys were leaked for anyone to read.

Two practical lessons follow:

  • Pin third-party actions to a commit SHA, not a mutable tag (e.g. uses: tj-actions/changed-files@11052... rather than @v4). A tag can be repointed later; a SHA is bound to specific, immutable content.
  • Masking is not a silver bullet. Base64-encoding a secret, splitting it into pieces, concatenating it with other values, or using a secret shorter than 4 characters can all let it slip past the masking mechanism and end up in the logs.

Another easily-missed gap: artifacts and caches

Masking only applies to the log’s standard output — it does not inspect files uploaded via actions/upload-artifact or content stored via actions/cache. For example, if you accidentally upload a temporary file containing a secret (a generated .env file, a verbose build log) as an artifact, the run log may look perfectly clean while anyone who downloads that artifact sees the value in the clear. It’s not enough to avoid echo-ing a secret directly — you also need to make sure files that might contain a secret never leave the job as an artifact or cache entry.

Edge Case (b): Matrix Builds

To test across multiple OSes or language versions, strategy.matrix generates multiple jobs from a single job definition.

jobs:
  test:
    strategy:
      fail-fast: false # Default is true. Set false if one failure shouldn't cancel the rest
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]
        node-version: [18, 20, 22]
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
      - run: npm ci
      - run: npm test

With three values for os and three for node-version, this automatically generates one job per combination (3 × 3 = 9 jobs), all running in parallel. Each job receives its own matrix.os and matrix.node-version values as context.

fail-fast behavior

strategy.fail-fast defaults to true. With that default, as soon as any one job in the matrix fails, GitHub cancels every other still-running job in the matrix. This is designed for fast feedback — if Node 18 already failed, there’s no point waiting on 19 and 20.

There are situations, though, where you should disable it with fail-fast: false:

  • Checking a full compatibility matrix before a release — what you actually want to know is “it fails on Node 18 but works on 20 and 22,” and a single failure cancelling the rest would hide exactly that information.
  • Running independent checks in parallel — e.g. OS-specific E2E tests, where you want to catch a Windows-only bug and a macOS-only bug in the same run.
  • Investigating a flaky test — comparing which combinations reproduce it and which don’t.

Edge Case (c): Cache Design

Installing dependencies (npm ci, pip install, etc.) on every run when nothing has changed is wasted work. actions/cache lets you save a given path under a key and reuse it in later jobs.

- name: Cache npm dependencies
  uses: actions/cache@v4
  with:
    path: ~/.npm
    key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-npm-

The same pattern works for Python:

- name: Cache pip dependencies
  uses: actions/cache@v4
  with:
    path: ~/.cache/pip
    key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
    restore-keys: |
      ${{ runner.os }}-pip-

Designing the key

The standard practice is to include a hash of the lockfile’s contents in key, via hashFiles('**/package-lock.json'). This satisfies two requirements at once:

  • As long as the lockfile’s contents don’t change, the key stays the same, giving a cache hit and skipping the re-download/re-install of dependencies
  • The instant the lockfile changes (dependencies changed), the key changes too, forcing a cache miss so the new set of dependencies gets installed correctly

If you key the cache on a fixed string with no hash, it keeps hitting even after you update dependencies — a real footgun where you end up silently running stale versions. restore-keys is a fallback for when the key doesn’t match exactly: it partially reuses “the most recent cache that at least matches the OS” and only updates the delta, still speeding things up.

This repository’s own deploy.yml uses actions/cache@v4 for both the Next.js build cache and the Playwright browser cache.

- name: Cache Next.js build
  uses: actions/cache@v4
  with:
    path: |
      apps/calcbox/.next/cache
      apps/devtoolbox/.next/cache
      apps/pomodoro/.next/cache
    key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('apps/**/*.ts', 'apps/**/*.tsx') }}
    restore-keys: |
      ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-
      ${{ runner.os }}-nextjs-
- name: Cache Playwright browsers
  uses: actions/cache@v4
  id: playwright-cache
  with:
    path: ~/.cache/ms-playwright
    key: ${{ runner.os }}-playwright-${{ hashFiles('**/package-lock.json') }}

- name: Install Playwright Browsers
  if: steps.playwright-cache.outputs.cache-hit != 'true'
  run: npx playwright install --with-deps chromium

- name: Install Playwright system deps (cached browser)
  if: steps.playwright-cache.outputs.cache-hit == 'true'
  run: npx playwright install-deps chromium

The Next.js cache key concatenates both a hash of package-lock.json and a hash of every .ts/.tsx file under apps/. That way the key changes not just when dependencies change but also when the TypeScript source itself changes, so a stale build cache never lingers. The Playwright example gives the cache step an id: playwright-cache and branches on steps.playwright-cache.outputs.cache-hit with if: to run a different step depending on whether the cache hit or missed. On a hit, the entire browser-binary download is skipped and only the system dependency packages get installed.

Measured timing, from this repository’s own CI history

Using gh run view <run-id> --json jobs against this repository’s real workflow run history, comparing a Playwright cache hit against a miss gave these measured numbers:

CaseRelevant stepDurationWhen (Run ID)
Cache missInstall Playwright Browsers (downloads and installs the browser binary itself)~25s2026-07-02 (Run 28593370712)
Cache hitInstall Playwright system deps (cached browser) (system packages only)~15–20s2026-07-18 (Run 29636104857 and others)

In this particular case the difference is a modest 6–10 seconds, not dramatic — most of that time is spent on apt-installing system dependency packages, which is required either way regardless of cache hit/miss. Looking only at the browser binary’s own download and extraction, the cache hit shaves off several to a dozen-plus seconds.

By contrast, dependency caches covering an entire node_modules tree or a Python virtual environment — where there are many more packages, each taking longer to download and build — typically show a much bigger gap between a hit and a miss (this is a general, illustrative pattern, not a number measured in this repository). How much a cache actually helps depends heavily on what’s being cached, so rather than assuming “adding a cache always makes things dramatically faster,” it’s worth measuring it on your own workflow.

Edge Case (d): Minimizing GITHUB_TOKEN Permissions

Every workflow run is handed a short-lived token, secrets.GITHUB_TOKEN, automatically issued by GitHub. It’s used for API access to the repository (checkout, commenting on issues, creating releases, etc.), but exactly how much it can do by default depends on the repository’s settings.

  • Under Settings > Actions > General > Workflow permissions, a repository can be set to “Read and write permissions” (read/write across most scopes) or “Read repository contents permission” (read-only).
  • Repositories created after February 2023 default to the read-only side, but older repositories may still default to read/write.

Rather than relying on the repository-wide default, the best practice — the Principle of Least Privilege — is to declare an explicit permissions: block in the workflow file itself.

permissions:
  contents: read # Only allow reading code
  pull-requests: write # Additionally allow posting comments on PRs
  # Any scope not listed here is automatically set to "no access"

permissions can be set at the workflow level or per job. When different jobs need different permissions (e.g. a build job only needs contents: read, but only the deploy job needs contents: write), scoping it per job is stricter still.

Worth noting: this repository’s own deploy.yml does not declare an explicit permissions: block, so it relies on the repository’s default. The deploy step itself pushes to an external repository using an SSH deploy key (secrets.DEPLOY_KEY), and doesn’t depend on GITHUB_TOKEN permissions for anything sensitive, so there’s no actual harm here — but it’s a good real example of where applying the Principle of Least Privilege strictly would mean adding an explicit permissions: { contents: read }.

Notable Changes in 2024–2025

GitHub Actions is an actively evolving platform. Here are two relatively recent changes directly relevant to this article.

  • actions/cache backend overhaul (February 2025): On February 1, 2025, GitHub retired the legacy cache-service backend (the v1/v2 API) and moved entirely to a new cache service. The new backend is reported to cut cache upload time by up to 80% on GitHub-hosted runners. As part of this migration, actions/cache needed to be v4.2.0 or later (or v3.4.0 or later); workflows left on older pinned versions started failing to upload/download caches. If you’re still pinned to an old version (including by commit SHA), update to the latest v4 release.
  • The tj-actions/changed-files supply chain compromise (March 2025, CVE-2025-30066): As described above, a widely-used third-party action was tampered with, leaking secrets into workflow logs. In its wake, the importance of pinning third-party actions to a commit SHA rather than a tag, and periodically checking GitHub’s security advisories , has been reinforced industry-wide.

Both are real, verified changes/incidents, current as of this article’s writing (July 2026). GitHub Actions itself, its actions, and its cache service will keep changing, so in production it’s worth periodically checking the official documentation and security advisories.

Summary

On the surface, a GitHub Actions workflow is simple — on/jobs/steps — but running one in production without incident requires understanding the execution model and security surface underneath it:

  • Jobs run in parallel by default; steps run sequentially. Sharing state across jobs requires upload-artifact/download-artifact or a cache.
  • Secrets are masked as *** in logs, but a transformation (like encoding) lets them slip through untouched. Pin third-party actions to a commit SHA, not a tag.
  • A matrix build auto-generates one job per combination. fail-fast: false switches to “run everything to completion and show all results” instead of cancelling on the first failure.
  • Design a cache key around a hash of the lockfile, so it reliably misses the instant dependencies change.
  • Don’t rely on the repository’s default for GITHUB_TOKEN — declare an explicit permissions: block in the workflow file to minimize it.

GitHub Actions is highly flexible and can integrate with various tools and services to build complex workflows. Start with a small workflow and grow it, keeping each of these points in mind one at a time.