GitHub Actions runs your build, tests and deployment for you every time code changes, from a file that lives in your repository. Most of the steps in that file are code other people wrote, and they run right next to your secrets, so how you pull them in matters as much as what they do.
What GitHub Actions does
Without automation, every change goes through the same routine by hand: build it, run the tests, then ship it. It's slow, and people skip steps when they're in a hurry. That's how a broken build reaches users.
GitHub Actions is the automation built into GitHub. You describe the routine once, and GitHub runs it on its own machines whenever something happens in the repository: a push, a pull request, a schedule. This is continuous integration and delivery (CI/CD), and the CI/CD pipelines video covers the idea in general. GitHub Actions is one way to run a pipeline, and since it's already in the place your code lives, there's nothing extra to host.
The workflow file
The routine is a workflow: a YAML file in the .github/workflows/ folder of your repository. GitHub picks up every file in that folder by itself, so adding one is all it takes to switch automation on. Here's the workflow for a small Node.js shop app:
It answers two questions: when to run, and what to run.
When it runs: triggers
The on: block lists the events that start the workflow. push runs it for every commit pushed to the repository. pull_request runs it when someone opens or updates a pull request, so you see whether a change passes before you merge it. schedule takes a cron expression: 0 3 * * * means 03:00 every day, in UTC. You can also start a workflow by hand, or when another workflow finishes.
What it runs: jobs and steps
Under jobs: are one or more jobs. Each job gets its own runner, a fresh virtual machine that GitHub starts for the job and throws away afterwards. runs-on: ubuntu-24.04 picks which machine image to use. Jobs run in parallel unless one says it needs another to finish first.
Inside a job, steps run one after another on the same machine, so a file one step builds is there for the next. A step is one of two kinds:
run:executes a shell command, such asnpm test.uses:runs an action, a packaged step someone else wrote.
The env: block puts a secret into the job's environment. Secrets are stored encrypted in the repository's settings, and ${{ secrets.TUNA_VAULT }} hands the value to the job at run time. The value never appears in the file, but every step in the job can read it.
What a run looks like
Push a commit, and the repository's Actions tab shows a new run named after the commit message. Open it, and each step has its own row, its own time and its own log:
When a command exits with an error, its step gets a red cross, the job stops there, and the steps after it never run. A failing test therefore never reaches the deploy step. On a pull request, the same result shows up as a tick or a cross next to the commit, and a repository can require a green check before anyone can merge.
Actions: steps other people wrote
Most workflows lean on actions for anything beyond a few commands. actions/checkout clones your repository onto the runner. Others install Node.js or Python, cache dependencies, log in to a cloud provider, or, like tj-actions/changed-files, work out which files a commit touched. The GitHub Marketplace lists thousands of them.
A uses: line names an action as owner/repository@ref. GitHub downloads that repository at that ref and runs it as part of your job. The ref is usually a tag such as v47, and you get whatever code the tag points to at the moment the job runs.
One line instead of a page of shell script is why people reach for actions. It's also where the risk comes in.
Why a third-party action is a risk
An action isn't sandboxed from the rest of the job. It runs on the same runner, with the same files, the same environment variables and the same access token as your own steps. If your job has a deployment key or a cloud credential, the action can read it.
A tag is also just a label pointing at a commit, and whoever controls the repository can move it. If an attacker gets into an action's repository, they can point an old, trusted tag at new code, and every workflow that uses that tag runs it on the next job. Nobody has to change a line of their workflow.
That's what happened to tj-actions/changed-files in March 2025. Attackers changed the action's code and retroactively moved multiple version tags to a malicious commit. GitHub's advisory says more than 23,000 repositories used the action. The new code read secrets out of the runner's memory and printed them, encoded, into the build logs, which anyone can read on a public repository. Access keys, tokens and private keys leaked, and every exposed secret had to be rotated. The supply chain attacks video covers the wider pattern: attack something everyone depends on, and everyone downstream is hit at once.
Pin to a commit, not a tag
A Git commit is identified by its SHA, a 40-character hash of its contents. A tag can move, but a commit SHA always means the same code. GitHub's own security guide recommends pinning actions to a full-length commit SHA: uses: followed by the SHA instead of v47, usually with a # v47 comment so a human can still tell which version it is.
Pinning stops anyone swapping the code under you. It doesn't make the code safe. If the commit you pinned already has a script injection bug, or quietly calls a second, unpinned action, you've locked that problem in. You also have to update the SHA yourself when a fix comes out; Dependabot can raise those updates as pull requests.
Chainguard Actions
Chainguard Actions are hardened versions of popular GitHub Actions, published by Chainguard (the sponsor of this video) under the chainguard-actions organisation on GitHub. Each one keeps the inputs and outputs of the original, so switching usually means changing the uses: line and nothing else. The catalogue covers over 1,000 actions, from GitHub's own actions/* to cloud-provider, Docker and security-scanning actions.
According to Chainguard, each action is built from the upstream source, then checked by rule-based analysis and AI-assisted review. Fixes are made for script injection, unsafe handling of environment variables and secrets, and missing permission limits. Every uses: reference inside the action, and every container image it pulls, is pinned to an immutable SHA, which also covers the nested actions that top-level pinning misses. Each one ships with a HARDENING.md report listing what was found and fixed, and it is hardened again whenever upstream releases a new version or Chainguard adds a rule.
The names carry the original owner as a prefix, so tj-actions/changed-files becomes chainguard-actions/tj-actions-changed-files. Switching is still one line, pinned to a commit:
Read the action's HARDENING.md before you switch: in rare cases hardening changes an input or a behaviour, and the report says so. If your organisation restricts which actions may run, add chainguard-actions/* to the allowed list in the repository's Actions settings. Chainguard Actions is a paid product with a free trial, and Chainguard's tooling can scan a repository and open the pull request that swaps the lines for you.
Common mistakes
- Leaving the token with broad access. Each job gets an automatic
GITHUB_TOKEN. Setpermissions:in the workflow to the least the job needs, such ascontents: read, so a bad step can't push code or change releases. - Putting every secret in every job. Give each secret only to the job that uses it. A test job has no reason to hold the deployment key.
- Running untrusted pull requests with secrets. The
pull_request_targetevent runs with the base repository's secrets, even for a pull request from a fork. Checking out and running the fork's code there hands those secrets to a stranger. - Judging an action by its stars. Popularity says nothing about whether it's safe today. Popular actions are the ones worth attacking.
- Pinning once and forgetting. A pinned SHA never picks up security fixes by itself. Let Dependabot or a similar tool propose updates, and review them.
Key takeaways
- A workflow is a YAML file in
.github/workflows/that says when to run (on:) and what to run (jobs and steps). - Each job runs on a fresh runner, and every step in it, including third-party actions, can read any secret the job is given.
- A tag like
v47can be moved to new code; a full commit SHA can't. - Pinning fixes the version, not the flaws in it. Chainguard Actions are hardened, drop-in versions of popular actions, with their own references pinned.
- Give tokens and secrets the least access each job needs.