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.
What uses: does
Section titled “What uses: does”- uses: actions/checkout@v7Three 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.
Where actions come from
Section titled “Where actions come from”Four sources, in decreasing order of trust.
GitHub’s own — the actions/ organisation. checkout, setup-*, cache, upload-artifact.
Maintained by GitHub.
Vendor-published — docker/, 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 yoursThe local form — a path beginning ./ — needs the repository checked out first, since it reads the
action from the working directory.
The three implementation types
Section titled “The three implementation types”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.
| Type | Implementation | Runs |
|---|---|---|
| JavaScript | Node.js code | Directly on the runner |
| Docker container | A Dockerfile or image | In a container |
| Composite | A sequence of steps | On 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 and outputs
Section titled “Inputs and outputs”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:
gh api repos/actions/setup-python/contents/action.yml \ -H "Accept: application/vnd.github.raw" | head -40What 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.
Version references
Section titled “Version references”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| Reference | Gets fixes automatically | Can change under you |
|---|---|---|
| Branch | Yes, immediately | Constantly |
Major tag (v7) | Yes, within the major | Yes — tags are movable |
Exact tag (v7.0.1) | No | Yes — tags are movable |
| Commit SHA | No | No |
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.
Evaluating a third-party action
Section titled “Evaluating a third-party action”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.
Local actions
Section titled “Local actions”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.
Actions you will use constantly
Section titled “Actions you will use constantly”A working vocabulary:
| Action | Does |
|---|---|
actions/checkout | Clones the repository onto the runner |
actions/setup-node, setup-python, setup-go, setup-java, setup-dotnet | Installs a toolchain, usually with dependency caching |
actions/cache | Caches arbitrary paths between runs |
actions/upload-artifact / download-artifact | Moves files out of, and between, jobs |
actions/github-script | Runs JavaScript against the GitHub API with the job’s token |
actions/attest-build-provenance | Produces 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.
Actions that need a token
Section titled “Actions that need a token”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 commentAn action failing with a 403 against your own repository is nearly always a missing permission rather than a broken action — see Least-Privilege Permissions.
Reading an action before you trust it
Section titled “Reading an action before you trust it”For a small third-party action, reading the source is a genuinely practical ten-minute exercise.
# 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.
Deprecation and breaking changes
Section titled “Deprecation and breaking changes”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:
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.
Common mistakes
Section titled “Common mistakes”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.
Local actions in practice
Section titled “Local actions in practice”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.ymlname: Setupdescription: 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.
When an action is the wrong answer
Section titled “When an action is the wrong answer”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.
Exercise
Section titled “Exercise”- Fetch
action.ymlforactions/setup-pythonand list its inputs. - Use it with a quoted version and read the
python-versionoutput. - Create a local composite action under
.github/actions/and call it — first without checkout, to see the failure, then with. - Change an action reference from
@v7to its exact commit SHA and confirm the workflow still runs. - Pick a third-party action you use and answer the six evaluation questions about it.
Actions and the runner’s tool cache
Section titled “Actions and the runner’s tool cache”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 version — 3.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"What you learned
Section titled “What you learned”- 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/checkoutfirst.
Anatomy of action.yml
Section titled “Anatomy of action.yml”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 Projectdescription: Install dependencies and configure the toolchainauthor: 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.txtThe 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.
Keeping actions up to date
Section titled “Keeping actions up to date”Action majors move, and a repository with twenty workflows will drift.
Dependabot understands action references and will open pull requests to update them:
version: 2updates: - 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:
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.
A note on maintenance
Section titled “A note on maintenance”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.
Summary
Section titled “Summary”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.
Related lessons
Section titled “Related lessons”Check your understanding
4 questions — each one asks you to predict what Git or GitHub will do, not to recall a flag.