Author and manage workflows

The heaviest skill on the exam. Choosing the right trigger and scope, structuring jobs and steps with conditions, dependencies, services and matrices, reading contexts and expressions correctly, and moving data and results through a run with caches, artifacts, outputs, environment files and job summaries.

Skill
1
Exam weight
20–25%
Skills measured
January 2026
Sections
3

Configure workflow triggers and events

The on: key decides when a workflow runs. It accepts a single event, a list, or a map of events with activity types and filters. A workflow runs once per matching event — two matching events mean two runs.

flowchart TD
  Q["What should start the run?"]
  Q --> A1["Code or PR activity"] --> T1["push · pull_request<br/>with branches, paths, types"]
  Q --> A2["A clock"] --> T2["schedule<br/>cron, UTC unless timezone is set"]
  Q --> A3["A person, on demand"] --> T3["workflow_dispatch<br/>typed inputs, Run workflow button"]
  Q --> A4["Another system"] --> T4["repository_dispatch<br/>event_type and client_payload"]
  Q --> A5["Another workflow, as a job"] --> T5["workflow_call<br/>inputs, secrets, outputs"]
  Q --> A6["Another workflow finishing"] --> T6["workflow_run<br/>types: completed"]
Pick the trigger by what starts the work. workflow_call is used by another workflow at job level; workflow_run fires after another workflow finishes and runs with this repository's privileges.

Repository events and filters

.github/workflows/triggers.yml excerpt
on:
  push:
    branches: [main, 'release/**']
    tags: ['v*']
    paths-ignore: ['docs/**', '**.md']
  pull_request:
    types: [opened, synchronize, reopened, ready_for_review]
    branches: [main]
  schedule:
    - cron: '30 5 * * 1-5'          # 05:30 on weekdays
      timezone: 'Europe/Madrid'     # optional; UTC without it
  issues:
    types: [labeled]
  release:
    types: [published]
FilterApplies toRule to remember
branches / branches-ignorepush, pull_request, pull_request_target, workflow_runUse one or the other for the same event, not both; use !pattern inside branches to exclude
tags / tags-ignorepushPushing more than three tags at once creates no event
paths / paths-ignorepush, pull_requestWith branches, both must match; order matters for ! patterns
typesEvents with activity typespull_request defaults to opened, synchronize, reopened — add ready_for_review or labeled explicitly

Glob patterns use * (no /), ** (any depth), ?, + and []. A branch filter on pull_request matches the base branch of the pull request, not the head.

Scheduled workflows

  • POSIX cron with five fields — minute, hour, day of month, month, day of week — and *, ,, -, /. Shorthand such as @daily is not supported.
  • UTC by default; an optional timezone key takes an IANA zone name.
  • The shortest interval is every 5 minutes, and runs can be delayed — or dropped — under load, especially at the top of the hour. Pick an odd minute.
  • Runs on the latest commit of the default branch only.
  • In public repositories, scheduled workflows are disabled after 60 days without repository activity.

Manual runs with workflow_dispatch

workflow_dispatch adds a Run workflow button to the Actions tab — but only once the file is on the default branch. It can also be started with gh workflow run or the REST API, against any branch or tag.

.github/workflows/deploy.yml
name: Deploy

on:
  workflow_dispatch:
    inputs:
      target:
        description: Where to deploy
        type: environment          # a dropdown of the repository's environments
        required: true
      version:
        description: Release tag, e.g. v2.4.0
        type: string
        required: true
      replicas:
        type: number
        default: 2
      dry-run:
        type: boolean
        default: true
      region:
        type: choice
        options: [eu-west, us-east, ap-south]
        default: eu-west

permissions:
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: ${{ inputs.target }}
    steps:
      - name: Show the request
        env:
          VERSION: ${{ inputs.version }}
          DRY_RUN: ${{ inputs.dry-run }}
        run: echo "Deploying $VERSION, dry run $DRY_RUN"
Input keyMeaning
typestring, boolean, number, choice (with options) or environment
requiredThe form and the API refuse a run without it
defaultUsed when the input is omitted; must be one of options for a choice
descriptionShown in the form

inputs versus github.event.inputs

Read inputs from the inputs context. A boolean input is a real boolean there, but a string in github.event.inputs — so if: github.event.inputs.dry-run is always truthy ('false' is a non-empty string). A workflow can declare up to 25 inputs, with a 65,535-character payload limit.

Reusable workflows with workflow_call

A workflow with on: workflow_call is called as a job, with jobs.<id>.uses. The called workflow declares its inputs (types boolean, number or string — no choice), its secrets and its outputs; the caller maps values into them.

octo-org/platform: .github/workflows/build.yml
name: Reusable build

on:
  workflow_call:
    inputs:
      node-version:
        type: string               # boolean, number or string only
        default: '24'
      run-e2e:
        type: boolean
        default: false
    secrets:
      npm_token:
        required: true
    outputs:
      image-tag:
        description: Tag of the image that was built
        value: ${{ jobs.build.outputs.tag }}

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      tag: ${{ steps.meta.outputs.tag }}
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: ${{ inputs.node-version }}
      - run: npm ci
        env:
          NODE_AUTH_TOKEN: ${{ secrets.npm_token }}
      - id: meta
        run: echo "tag=sha-${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
.github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  build:
    uses: octo-org/platform/.github/workflows/build.yml@v3
    with:
      node-version: '24'
      run-e2e: true
    secrets:
      npm_token: ${{ secrets.NPM_TOKEN }}   # or: secrets: inherit

  report:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - run: echo "Built ${{ needs.build.outputs.image-tag }}"
  • Reference a reusable workflow as owner/repo/.github/workflows/file.yml@ref, or ./.github/workflows/file.yml in the same repository (same commit, no @ref).
  • Secrets are not passed implicitly. Map each one under secrets:, or pass secrets: inherit to hand over every secret the caller can see (same organisation or enterprise only).
  • The caller's env context is not propagated; use inputs.
  • A calling job can only use with, secrets, needs, if, permissions, concurrency and strategy — no steps and no runs-on.
  • The called workflow can keep or reduce the GITHUB_TOKEN permissions it receives, never raise them.
  • Up to ten levels of nesting, and at most 50 unique reusable workflows from one workflow file (both raised in November 2025 from four and 20).

External events with repository_dispatch

Another system starts a workflow by calling the REST API with an event_type (up to 100 characters) and an optional client_payload (up to 10 top-level properties). The workflow must be on the default branch.

shell
gh api repos/octo-org/web/dispatches \
  -f event_type=deploy-requested \
  -F 'client_payload[env]=staging' \
  -F 'client_payload[sha]=1f2e3d4'
.github/workflows/deploy-requested.yml excerpt
on:
  repository_dispatch:
    types: [deploy-requested]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - env:
          TARGET: ${{ github.event.client_payload.env }}
        run: echo "Deploying to $TARGET"

Choose scope, permissions and events

SituationChooseWhy
Test code from forkspull_requestRuns the fork's code with a read-only token and no secrets
Label or comment on a fork's PRpull_request_target, without checking out the PRRuns the default branch's workflow with base-repository secrets — never execute the PR's code there
Run privileged work after untrusted CIworkflow_run on completedRuns in the base repository; check github.event.workflow_run.conclusion
Let a workflow start anotherworkflow_dispatch or repository_dispatchEvents created with GITHUB_TOKEN do not start new runs, except these two
Only one job needs to writeTop-level permissions: contents: read, raise in that job onlyLeast privilege per job

GITHUB_TOKEN does not trigger workflows

A push, tag or release created with GITHUB_TOKEN starts no new workflow run — that is what stops a workflow triggering itself forever. Only workflow_dispatch and repository_dispatch are exempt. If a release workflow must trigger a publish workflow, use a GitHub App token or call workflow_dispatch. Pull requests opened by GITHUB_TOKEN do get runs, but they wait for approval from someone with write access.

Design and implement workflow structure

Jobs, steps and conditional logic

.github/workflows/test.yml excerpt
jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 20
    defaults:
      run:
        shell: bash
        working-directory: app
    steps:
      - uses: actions/checkout@v7
      - id: unit
        run: npm test
        continue-on-error: true          # the step "fails" but the job carries on

      - name: Only on main pushes
        if: github.event_name == 'push' && github.ref == 'refs/heads/main'
        run: ./scripts/publish-preview.sh

      - name: Tell us when unit tests failed
        if: steps.unit.outcome == 'failure'   # outcome: before continue-on-error
        run: echo "::warning::Unit tests failed"

      - name: Always clean up
        if: always()
        run: ./scripts/cleanup.sh

      - name: Only when an earlier step failed
        if: failure()
        run: ./scripts/collect-diagnostics.sh
Status functionTrue when
success()Nothing before it failed or was cancelled — the implicit default of every if:
failure()A previous step (or, at job level, an ancestor job) failed
cancelled()The run was cancelled
always()Always — even after cancellation; prefer !cancelled() for clean-up that should not delay a cancel
  • An if: without a status function gets success() && prepended — so if: github.ref == 'refs/heads/main' does not run after a failure.
  • ${{ }} is optional in if:, except when the expression starts with ! — YAML would read that as a tag, so write if: ${{ !cancelled() }}.
  • steps.<id>.outcome is the result before continue-on-error; conclusion is after it. A failed step with continue-on-error: true has outcome failure, conclusion success.
  • timeout-minutes defaults to 360 for a job; set it lower to stop hung jobs burning minutes. Steps take it too.
  • defaults.run sets shell and working-directory for run steps at workflow or job level — not for uses steps.

Dependencies between jobs

flowchart LR
  LINT["lint"]:::success --> BUILD["build"]:::success
  TEST["test"]:::failure --> DEPLOY["deploy<br/>needs: build, test"]:::skipped
  BUILD --> DEPLOY
  DEPLOY --> NOTIFY["notify<br/>if: always()"]:::success
lint → build succeeded, test failed, so deploy (needs: build, test) was skipped — shown dashed. notify needs deploy but has if: always(), so it still ran. needs.deploy.result reads 'skipped'.
.github/workflows/pipeline.yml excerpt
jobs:
  lint:
    runs-on: ubuntu-latest
    steps: [{ run: npm run lint }]
  build:
    needs: lint
    runs-on: ubuntu-latest
    steps: [{ run: npm run build }]
  test:
    runs-on: ubuntu-latest
    steps: [{ run: npm test }]
  deploy:
    needs: [build, test]               # skipped if either does not succeed
    runs-on: ubuntu-latest
    steps: [{ run: ./deploy.sh }]
  notify:
    needs: deploy
    if: ${{ always() }}                 # runs even though deploy was skipped
    runs-on: ubuntu-latest
    steps:
      - run: echo "deploy result was ${{ needs.deploy.result }}"

A job runs only when every job in its needs succeeded, unless its if: uses a status function. A skipped job counts as not-succeeded for its dependants, so skips propagate down the chain. needs.<job>.result is success, failure, cancelled or skipped.

Workflow commands and environment variables

Variables set with env: at workflow, job or step level are visible to steps; the most specific wins (step over job over workflow). The runner also sets default variables — GITHUB_SHA, GITHUB_REF, GITHUB_REPOSITORY, GITHUB_WORKSPACE, RUNNER_OS, RUNNER_TEMP and more — which you cannot overwrite (names starting GITHUB_ are reserved). The full list is in the Syntax reference.

Steps talk to the runner in two ways: environment files, written with >>, and workflow commands, printed to standard output as ::command parameters::value.

.github/workflows/env.yml excerpt
steps:
  - name: Set values for later steps
    run: |
      echo "BUILD_ID=$(date +%s)" >> "$GITHUB_ENV"       # env var for later steps
      echo "$HOME/.local/bin" >> "$GITHUB_PATH"            # prepend to PATH
      {
        echo 'NOTES<<EOF'                                  # multiline value
        git log -3 --oneline
        echo 'EOF'
      } >> "$GITHUB_ENV"

  - name: Use them
    run: |
      echo "Build $BUILD_ID"
      echo "$NOTES"
workflow commands
echo "::group::Install dependencies"
npm ci
echo "::endgroup::"

echo "::error file=src/checkout.ts,line=42,col=7::Expected total to be 30, received 3"
echo "::warning::Coverage dropped below 80% (78.4%)"
echo "::notice title=Preview::Deployed to $PREVIEW_URL"

TOKEN=$(./scripts/fetch-token.sh)
echo "::add-mask::$TOKEN"          # mask a value that is not a secret
echo "::debug::only shown when step debug logging is on"

And what those commands look like in the job log:

Run npm test
Install dependencies
added 812 packages in 14s
Run unit tests
PASS src/cart.test.ts
FAIL src/checkout.test.ts
Error: Expected total to be 30, received 3 src/checkout.ts#L42:7
Warning: Coverage dropped below 80% (78.4%)
Notice: Deployed to https://pr-1287.preview.example.com Preview
Using token *** for the registry
Error: Process completed with exit code 1.

Environment files are per step

A value written to $GITHUB_ENV is visible to the following steps of the same job, not to the step that wrote it, and not to other jobs. Use $GITHUB_OUTPUT plus job outputs to cross a job boundary. Multiline values use the NAME<<DELIMITER form; pick a delimiter that cannot appear in the value.

Service containers

services: starts Docker containers — databases, caches, queues — alongside a job, and tears them down when it ends. They need a Linux runner (GitHub-hosted Ubuntu, or a self-hosted Linux runner with Docker). How you reach them depends on where the job runs:

.github/workflows/integration.yml excerpt
jobs:
  integration:
    runs-on: ubuntu-latest           # service containers need a Linux runner
    services:
      postgres:
        image: postgres:17
        env:
          POSTGRES_PASSWORD: postgres
        ports:
          - 5432:5432                # host:container — needed when the job is on the runner
        options: >-
          --health-cmd "pg_isready -U postgres"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
      redis:
        image: redis:8
        ports:
          - 6379:6379
    steps:
      - uses: actions/checkout@v7
      - run: npm ci && npm run test:integration
        env:
          DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres
          REDIS_URL: redis://localhost:6379
.github/workflows/integration-container.yml excerpt
jobs:
  integration:
    runs-on: ubuntu-latest
    container: node:24               # the job itself runs in a container…
    services:
      postgres:
        image: postgres:17
        env:
          POSTGRES_PASSWORD: postgres
    steps:
      - uses: actions/checkout@v7
      - run: npm ci && npm run test:integration
        env:
          # …so services are reached by their label on the job's network
          DATABASE_URL: postgres://postgres:postgres@postgres:5432/postgres
Job runs…Reach the service atPorts
Directly on the runnerlocalhost:<mapped port>Map them with ports:
In a container:The service label as hostname, e.g. postgres:5432No mapping needed — they share a Docker network

options: passes Docker create flags; health checks (--health-cmd, --health-interval, --health-retries) make the runner wait until the service is healthy before the first step. credentials: pulls from a private registry, and the mapped host port is available as ${{ job.services.postgres.ports[5432] }} when you map to a random port.

Strategy and matrix

.github/workflows/matrix.yml excerpt
jobs:
  test:
    name: test (${{ matrix.os }}, node ${{ matrix.node }})
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false               # default true: one failure cancels the rest
      max-parallel: 4
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]
        node: [22, 24]
        exclude:
          - os: macos-latest
            node: 22                   # drop one combination
        include:
          - os: ubuntu-latest
            node: 26
            experimental: true         # add a combination with an extra key
          - os: windows-latest
            node: 24
            npm-flags: --no-audit      # add a key to an existing combination
    continue-on-error: ${{ matrix.experimental == true }}
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: ${{ matrix.node }}
      - run: npm ci ${{ matrix.npm-flags }}
      - run: npm test
flowchart LR
  A["test (ubuntu-latest, node 22)"]:::success --> P["publish<br/>needs: test"]:::skipped
  B["test (windows-latest, node 24)"]:::failure --> P
  C["test (macos-latest, node 24)"]:::success --> P
  D["test (ubuntu-latest, node 26)"]:::success --> P
Four of the matrix's six jobs are drawn; the other two follow the same pattern. fail-fast: false let the other legs finish after windows/node 24 failed; publish was skipped because one leg of the matrix it needs failed. With fail-fast: true, the running legs would have been cancelled.
  • The matrix is the cartesian product of its axes; exclude is applied first, then include.
  • include adds keys to every existing combination it fully matches without overwriting an original value; if it matches none, it adds a new combination.
  • A matrix can generate up to 256 jobs per workflow run.
  • fail-fast (default true) cancels in-progress and queued legs when one fails; max-parallel caps how many run at once.
  • continue-on-error at job level lets an experimental leg fail without failing the run.
  • A matrix can be built at runtime: output JSON from an earlier job and use ${{ fromJSON(needs.plan.outputs.matrix) }}.

Size the matrix for cost and signal

Every leg is a billed job. Test the full product on the default branch, and a smaller matrix on pull requests; exclude combinations you do not ship; put the slowest legs on Linux, which bills at the lowest rate; and use max-parallel to stay inside your concurrency limits rather than queue.

Runner labels move — checked 5 November 2026

ubuntu-20.04 was retired in April 2025 and windows-2019 in June 2025; jobs that ask for them never find a runner. windows-latest moved to Windows Server 2025 in September 2025, and ubuntu-latest is moving to Ubuntu 26.04 between 19 October and 19 November 2026. A matrix on -latest labels can change tool versions under you overnight: pin a version label (ubuntu-24.04) where reproducibility matters, and read the image release notes when a -latest label moves.

YAML anchors and aliases

Since September 2025 workflows accept YAML anchors (&name, which labels a value) and aliases (*name, which repeats it) to reuse a mapping, a sequence or a whole step within one file. Anchors do not cross files — reuse across files is what reusable workflows and composite actions are for.

.github/workflows/anchors.yml excerpt
jobs:
  test:
    runs-on: ubuntu-latest
    env: &shared-env                 # anchor: name this mapping
      NODE_ENV: test
      TZ: UTC
    steps:
      - &checkout                    # anchor a whole step
        uses: actions/checkout@v7
        with:
          fetch-depth: 0
      - run: npm test

  lint:
    runs-on: ubuntu-latest
    env: *shared-env                 # alias: the same mapping again
    steps:
      - *checkout                    # the same step again
      - run: npm run lint

The study guide also lists the YAML merge key, <<, which copies every key of an aliased mapping into the current one and lets local keys override:

YAML merge key
# YAML merge keys: valid YAML 1.1, not documented for GitHub Actions
base: &base
  runs-on: ubuntu-latest
  timeout-minutes: 10

lint:
  <<: *base                # copy every key of the base mapping…
  timeout-minutes: 5       # …then override one (local keys win)

Merge keys: know how to read them

GitHub's documentation describes only & anchors and * aliases, and community reports say << is still rejected by Actions. The study guide expects you to expand merged mappings when reading a configuration, so know the rule — the merged keys are copied in, and any key written locally wins — but do not rely on << in a real workflow. This is listed with the other study-guide discrepancies.

Predefined contexts

ContextHoldsTypical use
githubThe run and its event: event_name, ref, ref_name, sha, actor, repository, run_id, run_number, run_attempt, workflow, tokenConditions on branch or event; github.event is the full webhook payload
envVariables set with env:Reading a variable in an expression
varsConfiguration variables at organisation, repository and environment levelNon-secret settings, such as URLs and feature flags
secretsSecrets available to the run, plus GITHUB_TOKENPassing a secret into a step's env or with
inputsInputs of workflow_dispatch or workflow_callTyped parameters
matrix · strategyThis leg's matrix values · fail-fast, job-index, job-total, max-parallelPer-variant configuration
needsoutputs and result of the jobs this job needsPassing data between jobs
job · stepsThis job's status, container and services · earlier steps' outputs, outcome, conclusionReacting to earlier steps
runneros, arch, name, temp, tool_cache, environment (github-hosted or self-hosted)Cross-platform scripts, cache keys

github.ref is the full ref (refs/heads/main, refs/tags/v1.2.0, refs/pull/42/merge); github.ref_name is the short name. In a pull_request run, github.head_ref and github.base_ref hold the branch names, and github.sha is the merge commit, not the head of the branch.

Contexts and version pinning

The study guide pairs contexts with immutable actions and version pinning because an action reference is resolved when the job starts: @v7 means whatever commit the v7 tag points to at that moment. Expressions cannot be used in uses:, so a reference cannot be chosen dynamically. Pinning to a full SHA, workflow lockfiles and what happened to immutable actions are covered on the security page and in Skill 3.

Expressions

step (excerpt)
- name: Expressions, one per variable
  if: ${{ github.event_name == 'pull_request' && !github.event.pull_request.draft }}
  env:
    # Literals: numbers, booleans, null, and single-quoted strings ('' escapes ')
    ANSWER: ${{ 42 }}
    QUOTE: ${{ 'it''s' }}
    # Operators
    RELEASE: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') }}
    # Functions — the object filter * collects a property from every item
    WANTS_DEPLOY: ${{ contains(github.event.pull_request.labels.*.name, 'deploy') }}
    CACHE_KEY: ${{ format('{0}-{1}', runner.os, hashFiles('**/package-lock.json')) }}
    TARGETS: ${{ join(github.event.pull_request.labels.*.name, ', ') }}
    COMMIT_JSON: ${{ toJSON(github.event.pull_request.head) }}
    # A "ternary": cond && a || b — b wins whenever a is falsy
    STAGE: ${{ github.ref == 'refs/heads/main' && 'production' || 'staging' }}
  run: echo "$STAGE $CACHE_KEY"
  • Comparisons are loose: strings compare case-insensitively, and mismatched types are coerced to numbers ('' == 0 and null == 0 are true).
  • Falsy values are false, 0, -0, '' and null; everything else, including 'false', is truthy.
  • hashFiles() returns one SHA-256 for every file matching the patterns, relative to GITHUB_WORKSPACE — empty if nothing matches.
  • fromJSON() turns a JSON string — typically a job output — back into an object, array, number or boolean, which is how a matrix is built at runtime.
  • Expressions are not allowed in on: or in uses:.

Static and runtime evaluation

Two things are easy to blur. When an expression is evaluated depends on its key: workflow-level keys when the run is queued, job-level keys when the job is scheduled, step keys just before the step — which is why a job's if: cannot see steps and runs-on cannot use env. How it reaches a script matters too: a ${{ }} inside run: is substituted into the script text before the shell starts, while $VAR is read by the shell at runtime. The first lets attacker-controlled text become code; the second keeps it data.

.github/workflows/secrets.yml excerpt
steps:
  - name: Wrong — secret pasted into the script, and leaked by a transform
    run: |
      curl -H "Authorization: Bearer ${{ secrets.API_TOKEN }}" https://api.example.com
      echo "${{ secrets.API_TOKEN }}" | base64      # the base64 form is NOT masked

  - name: Right — secret passed as an environment variable
    env:
      API_TOKEN: ${{ secrets.API_TOKEN }}
    run: |
      curl -H "Authorization: Bearer $API_TOKEN" https://api.example.com

  - name: Gate on a secret being present (secrets cannot be used in if:)
    if: env.HAS_TOKEN == 'true'
    env:
      HAS_TOKEN: ${{ secrets.API_TOKEN != '' }}
    run: ./scripts/publish.sh
  • Secrets are masked in logs as *** — but only their exact value. Anything derived from a secret (base64, a JSON field, a substring) is not masked unless you register it with ::add-mask::.
  • Pass secrets through env: or with:, not on a command line, where they can show up in process lists.
  • Do not store structured data (JSON, YAML) as a single secret: masking works on exact strings.
  • The secrets context cannot be referenced directly in if:; map it to an env value first, as in the last step above.

Editor tooling

The GitHub Actions extension for VS Code (github.vscode-github-actions) understands workflow files: syntax highlighting, IntelliSense for keys, contexts and expressions, action metadata completion (it reads the inputs of the action you uses:), validation errors as you type, and a panel to browse workflows and runs, read logs, re-run jobs and trigger workflow_dispatch runs without leaving the editor.

  • YAML schema completion: any editor with a YAML language server picks up the SchemaStore schemas for workflows and action.yml, giving key completion and type checking.
  • actionlint is an open-source linter that catches what schemas cannot: unknown contexts, type errors in expressions, invalid needs, shell issues and injection-prone run: lines. Run it locally and in CI.
  • The workflow editor on GitHub.com offers the same completion and a Marketplace side panel for searching actions.

Manage workflow execution and outputs

Caching dependencies

.github/workflows/cache.yml excerpt
steps:
  - uses: actions/checkout@v7
  - uses: actions/cache@v6
    id: cache
    with:
      path: ~/.npm
      key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
      restore-keys: |
        npm-${{ runner.os }}-
  - if: steps.cache.outputs.cache-hit != 'true'
    run: echo "No exact match — restored from a prefix, or nothing"
  - run: npm ci

  # Or let the setup action do it:
  - uses: actions/setup-node@v7
    with:
      node-version: 24
      cache: npm                       # keys on the lockfile automatically
  • Lookup order: exact key, then each restore-keys prefix in order, newest match first. cache-hit is 'true' only for an exact key match.
  • A cache is immutable: an existing key is never overwritten. Change the key (usually via hashFiles) to save a new one.
  • Scope: a run can restore caches from its own branch or the default branch; a pull request also from its base branch. Sibling branches never share.
  • Caches not accessed for 7 days are evicted; the repository limit is 10 GB by default (admins can raise it, billed beyond), oldest-accessed evicted first.
  • The setup-* actions have built-in caching (cache: npm, pip, gradle…), keyed on the lockfile.
CacheArtifact
PurposeSpeed up a later run by reusing files that rarely changeKeep the output of a run — builds, reports, logs
AcrossRuns (by key and branch scope)Jobs in the same run, and download after the run
LifetimeEvicted after 7 days unused, or for spaceRetention days (default 90)
MutabilityImmutable per keyImmutable once uploaded (v4 and later)

Artifacts

.github/workflows/build.yml excerpt
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - run: npm ci && npm run build
      - uses: actions/upload-artifact@v7
        with:
          name: web-dist
          path: |
            dist/
            !dist/**/*.map
          retention-days: 5            # within the repository's maximum
          if-no-files-found: error

  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v8
        with:
          name: web-dist
          path: dist
  • upload-artifact@v7 and download-artifact@v8 are the current majors. Since v4, artifacts are immutable and an artifact name must be unique in a run — upload once per name, or pass overwrite: true.
  • For a matrix, give each leg its own name (coverage-${{ matrix.os }}) and download with pattern: coverage-* and merge-multiple: true.
  • v7 can upload a single file unzipped with archive: false; v8 checks digests and fails on a mismatch by default.
  • Default retention is 90 days. Public repositories allow 1–90 days; private and internal up to 400. retention-days can shorten, never exceed, the configured maximum.

Retention policies via the REST API

The artifact and log retention setting covers artifacts, logs and workflow-run data. Set it in Settings → Actions → General, or programmatically — at enterprise, organisation or repository level, where each level can only be the same or shorter than the one above.

gh api
# Read, then set, the default retention (days) for artifacts and logs
gh api repos/octo-org/web/actions/permissions/artifact-and-log-retention
gh api -X PUT repos/octo-org/web/actions/permissions/artifact-and-log-retention -F days=30

# The same at organisation level (repositories cannot exceed it)
gh api -X PUT orgs/octo-org/actions/permissions/artifact-and-log-retention -F days=60

# Clean up ahead of retention: one artifact, one run's logs, one whole run
gh api -X DELETE repos/octo-org/web/actions/artifacts/1234567
gh api -X DELETE repos/octo-org/web/actions/runs/987654321/logs
gh api -X DELETE repos/octo-org/web/actions/runs/987654321

There is no separate "keep runs for N days" switch: runs age out with the retention period, and you can delete a run, its logs or an artifact early through the API, the UI or gh run delete.

Passing data between jobs and steps

flowchart LR
  subgraph J1["Job: plan"]
    direction TB
    S1["Step writes<br/>GITHUB_OUTPUT"] --> S2["steps.id.outputs"]
  end
  S2 --> JO["jobs.plan.outputs"]
  JO --> N["needs.plan.outputs<br/>in later jobs"]
  N --> WO["on.workflow_call.outputs<br/>back to a caller"]
A step writes key=value to $GITHUB_OUTPUT; the job maps it into jobs.<id>.outputs; later jobs read it through needs; a reusable workflow re-exports job outputs through on.workflow_call.outputs. Files travel as artifacts instead.
.github/workflows/release.yml excerpt
jobs:
  plan:
    runs-on: ubuntu-latest
    outputs:                                     # 2 · publish as job outputs
      version: ${{ steps.ver.outputs.version }}
      targets: ${{ steps.ver.outputs.targets }}
    steps:
      - id: ver                                  # 1 · a step writes outputs
        run: |
          echo "version=2.4.0" >> "$GITHUB_OUTPUT"
          echo 'targets=["eu","us"]' >> "$GITHUB_OUTPUT"

  release:
    needs: plan                                  # 3 · read them through needs
    runs-on: ubuntu-latest
    strategy:
      matrix:
        region: ${{ fromJSON(needs.plan.outputs.targets) }}
    steps:
      - run: echo "Releasing ${{ needs.plan.outputs.version }} to ${{ matrix.region }}"
MechanismScopeUse for
$GITHUB_ENVLater steps, same jobEnvironment variables computed at runtime
$GITHUB_OUTPUT → steps.<id>.outputsLater steps, same job (the step needs an id)Small strings between steps
Job outputs → needs.<job>.outputsJobs that need itSmall strings across jobs (1 MB per job, 50 MB per run)
on.workflow_call.outputsThe caller of a reusable workflowReturning values from a called workflow
ArtifactsAny job in the run, and afterwardsFiles and directories

Outputs containing secrets are dropped

A job output whose value contains a secret is not passed on — the runner redacts it and logs a warning. Pass a reference (an artifact name, a key) instead, or let the downstream job fetch the secret itself.

Job summaries

Markdown appended to $GITHUB_STEP_SUMMARY is rendered on the run's summary page, under the job — tables, links, emoji, collapsed sections and all. Each step has its own file, limited to 1 MiB, and up to 20 step summaries are shown per job. Secrets are masked there too.

step
- name: Publish a job summary
  if: always()
  run: |
    {
      echo "## Test results :white_check_mark:"
      echo ""
      echo "| Suite | Passed | Failed |"
      echo "| ----- | -----: | -----: |"
      echo "| unit  | 412    | 0      |"
      echo "| e2e   | 37     | 1      |"
      echo ""
      echo "Coverage: **84.1%** · [Full report]($REPORT_URL)"
    } >> "$GITHUB_STEP_SUMMARY"

Status badges

A badge shows the status of the latest run of a workflow, by default for the default branch. Copy it from the workflow's ⋯ → Create status badge menu, or build the URL from the workflow file name; branch and event query parameters narrow it.

README.md
![CI](https://github.com/octo-org/web/actions/workflows/ci.yml/badge.svg)
![CI on main](https://github.com/octo-org/web/actions/workflows/ci.yml/badge.svg?branch=main)
![CI for pushes](https://github.com/octo-org/web/actions/workflows/ci.yml/badge.svg?event=push)

Environment protections

An environment is a deployment target with its own secrets, variables and protection rules. A job that names it waits at the start until every rule passes, then gets the environment's secrets.

.github/workflows/deploy.yml excerpt
jobs:
  deploy:
    runs-on: ubuntu-latest
    environment:
      name: production
      url: ${{ steps.deploy.outputs.url }}   # shown on the run graph and in Deployments
    concurrency:
      group: production
      queue: max                             # deployments wait in line, none are dropped
    steps:
      - id: deploy
        env:
          DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}   # an environment secret
          API_URL: ${{ vars.API_URL }}           # an environment variable
        run: ./deploy.sh
RuleEffect
Required reviewersUp to six people or teams; one approval releases the job. Prevent self-review stops the person who triggered the run approving it.
Wait timerDelay the job by 1 to 43,200 minutes (30 days)
Deployment branches and tagsAll branches, protected branches only, or selected name patterns
Custom deployment protection rulesA GitHub App approves or rejects, e.g. after a change-ticket or monitoring check
Allow administrators to bypassOn by default; turn off to bind admins to the rules too
  • Referencing an environment that does not exist creates it, with no protection rules.
  • A job waits at most 30 days for approval before it fails.
  • Since December 2025, branch rules match the executing ref for pull request events: refs/pull/<n>/merge for pull_request, the default branch for pull_request_target.
  • Plans: environments, their secrets and branch rules are available for public repositories on every plan, and for private ones on Pro, Team and Enterprise; required reviewers and wait timers in private repositories need Enterprise.