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"]
Repository events and filters
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] | Filter | Applies to | Rule to remember |
|---|---|---|
branches / branches-ignore | push, pull_request, pull_request_target, workflow_run | Use one or the other for the same event, not both; use !pattern inside branches to exclude |
tags / tags-ignore | push | Pushing more than three tags at once creates no event |
paths / paths-ignore | push, pull_request | With branches, both must match; order matters for ! patterns |
types | Events with activity types | pull_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@dailyis not supported. - UTC by default; an optional
timezonekey 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.
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 key | Meaning |
|---|---|
type | string, boolean, number, choice (with options) or environment |
required | The form and the API refuse a run without it |
default | Used when the input is omitted; must be one of options for a choice |
description | Shown 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.
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" 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.ymlin the same repository (same commit, no@ref). - Secrets are not passed implicitly. Map each one under
secrets:, or passsecrets: inheritto hand over every secret the caller can see (same organisation or enterprise only). - The caller's
envcontext is not propagated; use inputs. - A calling job can only use
with,secrets,needs,if,permissions,concurrencyandstrategy— nostepsand noruns-on. - The called workflow can keep or reduce the
GITHUB_TOKENpermissions 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.
gh api repos/octo-org/web/dispatches \
-f event_type=deploy-requested \
-F 'client_payload[env]=staging' \
-F 'client_payload[sha]=1f2e3d4' 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
| Situation | Choose | Why |
|---|---|---|
| Test code from forks | pull_request | Runs the fork's code with a read-only token and no secrets |
| Label or comment on a fork's PR | pull_request_target, without checking out the PR | Runs the default branch's workflow with base-repository secrets — never execute the PR's code there |
| Run privileged work after untrusted CI | workflow_run on completed | Runs in the base repository; check github.event.workflow_run.conclusion |
| Let a workflow start another | workflow_dispatch or repository_dispatch | Events created with GITHUB_TOKEN do not start new runs, except these two |
| Only one job needs to write | Top-level permissions: contents: read, raise in that job only | Least 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
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 function | True 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 getssuccess() &&prepended — soif: github.ref == 'refs/heads/main'does not run after a failure. ${{ }}is optional inif:, except when the expression starts with!— YAML would read that as a tag, so writeif: ${{ !cancelled() }}.steps.<id>.outcomeis the result beforecontinue-on-error;conclusionis after it. A failed step withcontinue-on-error: truehas outcomefailure, conclusionsuccess.timeout-minutesdefaults to 360 for a job; set it lower to stop hung jobs burning minutes. Steps take it too.defaults.runsetsshellandworking-directoryforrunsteps at workflow or job level — not forusessteps.
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
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.
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" 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:
Install dependencies
Run unit tests
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:
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 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 at | Ports |
|---|---|---|
| Directly on the runner | localhost:<mapped port> | Map them with ports: |
In a container: | The service label as hostname, e.g. postgres:5432 | No 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
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
- The matrix is the cartesian product of its axes;
excludeis applied first, theninclude. includeadds 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(defaulttrue) cancels in-progress and queued legs when one fails;max-parallelcaps how many run at once.continue-on-errorat 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.
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 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
| Context | Holds | Typical use |
|---|---|---|
github | The run and its event: event_name, ref, ref_name, sha, actor, repository, run_id, run_number, run_attempt, workflow, token | Conditions on branch or event; github.event is the full webhook payload |
env | Variables set with env: | Reading a variable in an expression |
vars | Configuration variables at organisation, repository and environment level | Non-secret settings, such as URLs and feature flags |
secrets | Secrets available to the run, plus GITHUB_TOKEN | Passing a secret into a step's env or with |
inputs | Inputs of workflow_dispatch or workflow_call | Typed parameters |
matrix · strategy | This leg's matrix values · fail-fast, job-index, job-total, max-parallel | Per-variant configuration |
needs | outputs and result of the jobs this job needs | Passing data between jobs |
job · steps | This job's status, container and services · earlier steps' outputs, outcome, conclusion | Reacting to earlier steps |
runner | os, 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
- 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 (
'' == 0andnull == 0are true). - Falsy values are
false,0,-0,''andnull; everything else, including'false', is truthy. hashFiles()returns one SHA-256 for every file matching the patterns, relative toGITHUB_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 inuses:.
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.
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:orwith:, 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
secretscontext cannot be referenced directly inif:; map it to anenvvalue 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-pronerun: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
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 eachrestore-keysprefix in order, newest match first.cache-hitis'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.
| Cache | Artifact | |
|---|---|---|
| Purpose | Speed up a later run by reusing files that rarely change | Keep the output of a run — builds, reports, logs |
| Across | Runs (by key and branch scope) | Jobs in the same run, and download after the run |
| Lifetime | Evicted after 7 days unused, or for space | Retention days (default 90) |
| Mutability | Immutable per key | Immutable once uploaded (v4 and later) |
Artifacts
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@v7anddownload-artifact@v8are the current majors. Since v4, artifacts are immutable and an artifact name must be unique in a run — upload once per name, or passoverwrite: true.- For a matrix, give each leg its own name (
coverage-${{ matrix.os }}) and download withpattern: coverage-*andmerge-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-dayscan 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.
# 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"] 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 }}" | Mechanism | Scope | Use for |
|---|---|---|
$GITHUB_ENV | Later steps, same job | Environment variables computed at runtime |
$GITHUB_OUTPUT → steps.<id>.outputs | Later steps, same job (the step needs an id) | Small strings between steps |
Job outputs → needs.<job>.outputs | Jobs that need it | Small strings across jobs (1 MB per job, 50 MB per run) |
on.workflow_call.outputs | The caller of a reusable workflow | Returning values from a called workflow |
| Artifacts | Any job in the run, and afterwards | Files 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.
- 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.


 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.
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 | Rule | Effect |
|---|---|
| Required reviewers | Up to six people or teams; one approval releases the job. Prevent self-review stops the person who triggered the run approving it. |
| Wait timer | Delay the job by 1 to 43,200 minutes (30 days) |
| Deployment branches and tags | All branches, protected branches only, or selected name patterns |
| Custom deployment protection rules | A GitHub App approves or rejects, e.g. after a change-ticket or monitoring check |
| Allow administrators to bypass | On 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>/mergeforpull_request, the default branch forpull_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.