· 4 min read
How to Write a Safe GitHub Actions Workflow
Manesh Jayawardhana
CIO & Co-founder
Most CI files start life copied from another repository. That’s efficient right up to the moment you inherit a permissions block that grants write access to everything, a cache key based on the branch name, and a trigger that runs the whole matrix on every push to every branch.
None of that is dramatic on day one. It becomes a problem when the repository gets a new dependency, a new contributor, or a third-party action that turns out to have been compromised.
What a workflow file is actually declaring
A GitHub Actions workflow declares three things worth thinking about carefully.
When it runs. Push, pull request, schedule, manual dispatch. Most repositories want push plus pull_request and nothing else. A schedule on a repository nobody watches produces failing runs nobody reads.
What it can do. Every job gets a token, and the token’s default scope may be broader than the job needs. Declaring permissions: contents: read at the top of the file and widening only where a job genuinely publishes something is the highest-value line you can add.
What it reuses. Caching turns a three-minute install into twenty seconds — but only if the cache key is right. Keyed on a lockfile hash, the cache invalidates exactly when dependencies change. Keyed on anything else, you either miss constantly or serve stale dependencies.
Why people get stuck here
- YAML indentation. The syntax is unforgiving and the errors are unhelpful.
- Permissions left implicit. If you don’t declare them, you get whatever the repository default is, which may be write.
- Cache keys that don’t invalidate. A branch-name key means one stale cache serves every build on that branch.
- Actions pinned to a moving tag.
@v4can be repointed by the action’s owner at any time, including to something you didn’t review.
What a good workflow looks like
Narrow permissions by default
Start the file with read-only permissions and grant more per job. GitHub’s own hardening guidance recommends exactly this, and it costs one line.
A cache key from the lockfile
Hash package-lock.json, poetry.lock, go.sum — whatever pins your versions. The cache then survives every build where dependencies are unchanged and rebuilds the moment they aren’t.
Actions pinned appropriately
For steps that touch secrets or publish artifacts, pin to a full commit SHA. A tag can be moved; a SHA can’t. For routine build steps, a major version tag is a reasonable trade-off.
| Setting | Weak Default | Better |
|---|---|---|
| Permissions | Repository default | contents: read, widen per job |
| Cache key | Branch or fixed string | Hash of the lockfile |
| Action version | Floating @v4 | Commit SHA for sensitive steps |
| Trigger | Every push, all branches | Push plus pull_request |
Common mistakes to avoid
- Running secrets-bearing jobs on
pull_request_targetwithout understanding what that trigger exposes to fork PRs. - Echoing environment variables during debugging and printing a secret into a public log.
- Building a matrix across six versions when two would catch the same failures, then wondering why CI is slow.
- Committing the workflow directly to the default branch, so the first run is also the first test of the file.
- Caching the wrong directory — the build output rather than the dependency store — so nothing is actually reused.
How to do it with GitHub Actions Workflow Builder
The GitHub Actions Workflow Builder assembles the YAML with the fiddly parts already right.
- Choose when it runs — push and pull_request covers most repositories.
- Pick the runtime and whether you need a version matrix.
- Enable dependency caching; the generated key hashes your lockfile rather than a branch name.
- Copy the YAML into
.github/workflows/and commit it on a branch, so the first run happens as a pull request. - Read the run log once end to end before trusting it.
Other developer utilities that run in the browser are in the tools directory.
Frequently asked questions
Why declare permissions if the default already works?
Because “works” and “minimal” aren’t the same thing. A compromised dependency running in a job with write permissions can push commits. The same dependency in a read-only job can’t. It’s one line for a meaningful reduction in blast radius.
Should every action be pinned to a SHA?
For anything handling secrets, publishing packages, or deploying — yes. For a checkout or a language setup step in a private repository, a major version tag is a defensible compromise between safety and maintenance overhead.
What should the cache key include?
A hash of the lockfile, plus the runner OS and the language version. That combination invalidates precisely when it should and reuses aggressively when nothing has changed.
Final thought
Write the permissions line first, before the steps. A workflow that does slightly less than it could is a much better starting point than one that quietly does more.