Skip to content

GitHub Actions Explained: What an Action Actually Is

Lesson 7 of 11Beginner11 min readGitHub Actions & CI/CD · Actions FundamentalsVerified: actions/* releases from api.github.com, August 2026

GitHub Actions is the platform. An action is a reusable component a step calls.

The product’s name makes this ambiguous and it is worth being deliberate about, because the two are constantly confused. A workflow containing only run: steps uses no actions and is still GitHub Actions.

- uses: actions/checkout@v7

Three parts: actions is the owner, checkout is the repository, v7 is the reference.

GitHub fetches that repository at that ref, reads its action.yml, and runs it. The action is a repository, not a package — there is no registry, no publishing step, and the Marketplace is a directory rather than a distribution mechanism.

That has a consequence worth sitting with: using an action means running code from a repository you do not control, inside your job, with your token in reach.

Four sources, in decreasing order of trust.

GitHub’s own — the actions/ organisation. checkout, setup-*, cache, upload-artifact. Maintained by GitHub.

Vendor-publisheddocker/, aws-actions/, azure/, google-github-actions/, hashicorp/. Maintained by the vendor whose product they wrap.

Third-party — anything else. Quality and maintenance vary enormously.

Your own — actions in the same repository, or in one you control.

- uses: actions/checkout@v7 # GitHub
- uses: docker/build-push-action@v7 # vendor
- uses: some-person/some-action@a3f8c21 # third party, SHA-pinned
- uses: ./.github/actions/setup-project # local, same repository
- uses: my-org/shared-actions/build@v2 # another repository of yours

The local form — a path beginning ./ — needs the repository checked out first, since it reads the action from the working directory.

An action’s action.yml declares which it is. As a consumer you mostly do not care; as an author, and when debugging, it matters.

TypeImplementationRuns
JavaScriptNode.js codeDirectly on the runner
Docker containerA Dockerfile or imageIn a container
CompositeA sequence of stepsOn the runner, inside your job

JavaScript actions start fastest and run on any runner. Most actions/* are these.

Docker container actions bring their own environment, which is convenient for a tool with awkward dependencies. They are Linux-only and pay container startup on every use.

Composite actions are the lightest: a bundle of steps in YAML, with no code. Covered in Composite Actions.

Inputs go in with with, outputs come back through steps.<id>.outputs:

- id: setup
uses: actions/setup-python@v7
with:
python-version: "3.13"
cache: pip
- run: echo "Installed ${{ steps.setup.outputs.python-version }}"

Every input and output is defined by the action’s action.yml. There is no shared vocabulary between actions — path means one thing to upload-artifact and another to checkout — so reading the action’s own documentation is required rather than optional.

An action’s action.yml is the authoritative reference, and it is a file in a public repository:

Terminal window
gh api repos/actions/setup-python/contents/action.yml \
-H "Accept: application/vnd.github.raw" | head -40

What it doesFetches an action's metadata file, listing every input it accepts with descriptions and defaults.

Why we run itThe README may be out of date or incomplete; action.yml cannot be, because it is what the runner reads. This is the fastest way to answer 'what inputs does this take?'

Expected resultYAML listing name, description, inputs, outputs and the runs configuration.

The part after @ can be several things, and the choice is a security decision.

- uses: actions/checkout@v7 # major tag — moves
- uses: actions/checkout@v7.0.1 # exact tag — pinned, but a tag
- uses: actions/checkout@main # branch — moves constantly
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # commit SHA — immutable
ReferenceGets fixes automaticallyCan change under you
BranchYes, immediatelyConstantly
Major tag (v7)Yes, within the majorYes — tags are movable
Exact tag (v7.0.1)NoYes — tags are movable
Commit SHANoNo

The row that surprises people: a tag is a movable pointer. A repository owner can retag v7 at a different commit, and every workflow using @v7 picks it up on the next run with no signal.

For GitHub’s own actions that is a reasonable risk and a real convenience — you get security fixes without doing anything. For third-party actions it is the supply-chain exposure covered in Pinning Actions, and SHA pinning is the answer.

Before adding uses: someone/something@v1, the questions worth asking:

Who maintains it? An individual, a company, a community? Is the account recognisable?

Is it maintained? Recent commits and releases. An action untouched for three years may work and will not be fixed.

What permissions does it need? An action that only formats output should not need a token.

What does it do? For a small action, read it. action.yml plus a few files is often a ten-minute read, and it is the only way to know.

What are its dependencies? A JavaScript action bundles its dependencies; those are running too.

Do you need it? Many actions wrap a single command. uses: some/curl-action@v1 versus run: curl … is a dependency you did not need.

“It is on the Marketplace” is not a security assessment. Listing is not review.

When the same steps repeat across jobs in one repository, a local action is the least-ceremony fix:

.github/
├── actions/
│ └── setup-project/
│ └── action.yml
└── workflows/
└── ci.yml
- uses: actions/checkout@v7 # must come first
- uses: ./.github/actions/setup-project
with:
python-version: "3.13"

The checkout is required — a local action is read from the working directory, so it does not exist until the repository is there. This is a common first-time failure with a message that does not mention checkout.

A working vocabulary:

ActionDoes
actions/checkoutClones the repository onto the runner
actions/setup-node, setup-python, setup-go, setup-java, setup-dotnetInstalls a toolchain, usually with dependency caching
actions/cacheCaches arbitrary paths between runs
actions/upload-artifact / download-artifactMoves files out of, and between, jobs
actions/github-scriptRuns JavaScript against the GitHub API with the job’s token
actions/attest-build-provenanceProduces a build provenance attestation

The setup actions increasingly cache dependencies natively — cache: pip, cache: npm — which is simpler and less error-prone than configuring actions/cache yourself. See Caching.

Many actions call the GitHub API and therefore need a token. Most accept one as an input, defaulting to the job’s GITHUB_TOKEN:

- uses: actions/github-script@v9
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: 'Build finished.'
})

actions/github-script is worth knowing: it runs JavaScript with an authenticated Octokit client already configured, which replaces a great deal of gh api shell for anything involving several calls or conditional logic.

The permission the token needs must be declared:

permissions:
contents: read
pull-requests: write # to comment

An action failing with a 403 against your own repository is nearly always a missing permission rather than a broken action — see Least-Privilege Permissions.

For a small third-party action, reading the source is a genuinely practical ten-minute exercise.

Terminal window
# What does it declare?
gh api repos/OWNER/ACTION/contents/action.yml -H "Accept: application/vnd.github.raw"
# How big is it?
gh api repos/OWNER/ACTION/contents --jq '.[] | [.name, .size] | @tsv'
# When was it last touched?
gh api repos/OWNER/ACTION --jq '{pushed: .pushed_at, archived: .isArchived, stars: .stargazers_count}'
# Who maintains it?
gh api repos/OWNER/ACTION/contributors --jq '.[0:5] | .[] | [.contributions, .login] | @tsv'

What to look for in action.yml: whether runs.using is composite (readable YAML), node20 (bundled JavaScript, usually in dist/), or docker (an image you cannot easily inspect).

A composite action is fully readable in its metadata. A JavaScript action’s dist/index.js is bundled and minified — reading it is impractical, so trust rests on the publisher rather than on inspection. That difference is worth weighing when the action will run with write permissions.

Actions deprecate majors, and the failure mode is not always immediate.

Node-based actions are tied to a runtime the runner provides. When GitHub retires a Node version, an action still declaring it emits warnings and eventually stops working. A workflow that has run unchanged for two years can fail for a reason nothing in your repository changed.

The signals, in order of urgency:

A warning annotation on a run naming a deprecated runtime or action version. This is the notice period; it will not be repeated more loudly.

An archived action repository. No fixes are coming.

A major release with a migration note. Usually a change to input names or output shapes.

Checking periodically is cheap:

Terminal window
gh api repos/actions/checkout/releases --jq '.[0:3] | .[] | [.tag_name, .published_at[0:10]] | @tsv'

The maintainable answer is a registry plus an automated check, which is what npm run check:actions does for this site — and Dependabot for a repository whose workflows are production rather than documentation.

Confusing the platform with a component. They share a name; be precise.

Assuming a tag is immutable. Tags move. Only a SHA does not.

Using a stale major. Check releases rather than copying a version.

Trusting Marketplace listing as review. It is a directory.

A local action without checkout. It is read from the working directory.

Wrapping one command in an action. A dependency for no benefit.

Not reading action.yml. The README may be wrong; the metadata cannot be.

A local action is the cheapest way to remove repetition inside one repository, and it is under-used because people reach for a reusable workflow first.

.github/
├── actions/
│ └── setup/
│ └── action.yml
└── workflows/
├── ci.yml
└── release.yml
.github/actions/setup/action.yml
name: Setup
description: Check out, install the toolchain, and restore dependencies
inputs:
python-version:
description: Python version
default: "3.13"
runs:
using: composite
steps:
- uses: actions/setup-python@v7
with:
python-version: ${{ inputs.python-version }}
cache: pip
- shell: bash
run: pip install -r requirements.txt
# Used in both workflows
- uses: actions/checkout@v7
- uses: ./.github/actions/setup
with:
python-version: "3.12"

Two constraints worth remembering. The repository must be checked out first, since the action is read from the working directory. And every run: step inside a composite action must declare shell: — optional in a workflow, required here.

The local form has one significant advantage over a shared action in another repository: it is versioned with the code that uses it. A change to the setup and a change to what it sets up land in the same commit, and are reviewed together.

Not every repetition needs one.

A single command. uses: some/curl-action@v1 versus run: curl … — the action is a dependency, a supply-chain consideration and a version to maintain, in exchange for nothing.

Something a script does better. A script under scripts/ runs locally, is testable, and moves with you if you leave GitHub. An action does none of those.

Logic that belongs in the application. Business rules encoded in a composite action are rules nobody reading the codebase will find.

The honest test: could this be a shell script called by a run: step? If yes, it probably should be. Actions earn their place when they wrap something genuinely reusable across repositories, or when they need to interact with the runner in ways a script cannot — registering post-run cleanup, setting outputs, managing state between steps.

  1. Fetch action.yml for actions/setup-python and list its inputs.
  2. Use it with a quoted version and read the python-version output.
  3. Create a local composite action under .github/actions/ and call it — first without checkout, to see the failure, then with.
  4. Change an action reference from @v7 to its exact commit SHA and confirm the workflow still runs.
  5. Pick a third-party action you use and answer the six evaluation questions about it.

Setup actions do not usually download a language runtime. GitHub-hosted runners ship with several versions pre-installed in a tool cache, and the setup action selects one:

- uses: actions/setup-python@v7
with:
python-version: "3.13"

If that version is cached on the image, setup takes a second or two. If it is not, the action downloads and installs it, which takes considerably longer.

Two practical consequences. Pinning to a patch version3.13.2 rather than 3.13 — frequently misses the cache, because the image carries whatever patch was current when it was built. And a version that has just been released is usually not cached yet, so a matrix adding it will be slower until the runner images catch up.

RUNNER_TOOL_CACHE points at the directory, and listing it is the way to see what an image actually has:

- run: ls "$RUNNER_TOOL_CACHE/Python"
  • An action is a repository, fetched at a ref and executed inside your job.
  • Actions come in JavaScript, Docker container and composite forms.
  • Inputs and outputs are defined per action in action.yml, which is the authoritative reference.
  • Branch and tag references are movable; only a commit SHA is immutable.
  • Action majors move faster than published tutorials, including this one.
  • Marketplace listing is not a security review.
  • Local actions need actions/checkout first.

Every action is defined by one metadata file, and being able to read it makes any action comprehensible in a couple of minutes.

name: Setup Project
description: Install dependencies and configure the toolchain
author: my-org
inputs:
python-version:
description: Python version to install
required: false
default: "3.13"
install-dev:
description: Whether to install development dependencies
required: false
default: "false"
outputs:
cache-hit:
description: Whether the dependency cache was restored
value: ${{ steps.cache.outputs.cache-hit }}
runs:
using: composite
steps:
- uses: actions/setup-python@v7
with:
python-version: ${{ inputs.python-version }}
- id: cache
uses: actions/cache@v6
with:
path: ~/.cache/pip
key: pip-${{ runner.os }}-${{ hashFiles('**/requirements*.txt') }}
- shell: bash
run: pip install -r requirements.txt

The runs: block is what distinguishes the three types:

runs:
using: node20 # JavaScript
main: dist/index.js
post: dist/cleanup.js # optional cleanup phase
runs:
using: docker # Docker container
image: Dockerfile
args: ["--verbose"]
runs:
using: composite # Composite
steps: [...]

The post: entry on JavaScript actions is worth knowing about: it registers a step that runs after your job’s steps finish. That is how actions/cache saves the cache and how actions/checkout removes the credentials it configured — and it explains the “Post Run …” entries in every run’s log.

Note also that every run: step in a composite action must declare shell:. It is optional in a workflow and required here, which is the most common composite action authoring error.

Action majors move, and a repository with twenty workflows will drift.

Dependabot understands action references and will open pull requests to update them:

.github/dependabot.yml
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
groups:
actions:
patterns: ["*"]

The groups block combines all action updates into one pull request per week rather than one per action, which is the difference between a useful habit and a stream of noise.

Dependabot updates SHA-pinned references too, rewriting the SHA and updating the version comment — which removes the main practical objection to SHA pinning.

For a manual audit, the current release of any action is one call away:

Terminal window
gh api repos/actions/checkout/releases/latest --jq '.tag_name'

This site maintains that check as tooling: every action reference is registered in src/lib/action-versions.ts, and npm run check:actions compares the registry against live releases and scans all content for drift. Some equivalent is worth having anywhere workflow examples are documentation rather than code.

An action reference is a dependency, and like any dependency it needs periodic attention. The minimum viable habit is Dependabot with grouped updates, which turns twenty scattered pull requests into one weekly review.

For documentation — workflow examples in a README or on a site like this one — the equivalent is a registry plus an automated check, because nothing else will notice that an example has aged.

An action is a repository executed inside your job. Tags move, SHAs do not, and Marketplace listing is not review. Reading action.yml answers most questions faster than the README does.

Check your understanding

4 questions — each one asks you to predict what Git or GitHub will do, not to recall a flag.

Which reference to an action is immutable?
Show answer

A full commit SHA — Tags and branches can be moved by the action's maintainers. Only a commit SHA cannot change what it points to.

What is the authoritative reference for an action's inputs and outputs?
Show answer

Its `action.yml` metadata file — The README can be wrong or stale; `action.yml` is what the runner reads, so it cannot be.

Does a Marketplace listing mean an action has been security-reviewed?
Show answer

No — the Marketplace is a directory, not a review — Listing requires metadata, not scrutiny. Review the action's source, or pin to a SHA you have read.

A job uses a local action at `./.github/actions/setup` and fails with "not found". What is missing?
Show answer

`actions/checkout` before it — a local action is read from the working directory — Local actions are files in the repository. Until the repository is checked out, the working directory is empty.

GitHub Actions Security ChecklistAudit your workflows against the failure modes that actually cause incidents. Free and complete.

The CI starter template — least-privilege permissions, pinned actions, correct checkout — is in the Professional Toolkit.