Online Tool Store Online Tool Store
⚙️ Developer Tools

· 4 min read

How to Write a Safe GitHub Actions Workflow

Manesh Jayawardhana

CIO & Co-founder

Manesh Jayawardhana is the CIO and Co-Founder of Ceyentra Technologies, where he has spent over nine years leading the design and delivery of software solutions for clients across the globe, spanning web, mobile, AI, and capital market systems. He has grown Online Tool Store's engineering team from the ground up while steering the company's technical direction. His writing draws on this breadth of experience building and shipping software across a wide range of industries and markets. View on LinkedIn

Share

How to Write a Safe GitHub Actions Workflow

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. @v4 can 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.

SettingWeak DefaultBetter
PermissionsRepository defaultcontents: read, widen per job
Cache keyBranch or fixed stringHash of the lockfile
Action versionFloating @v4Commit SHA for sensitive steps
TriggerEvery push, all branchesPush plus pull_request

Common mistakes to avoid

  • Running secrets-bearing jobs on pull_request_target without 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.

  1. Choose when it runs — push and pull_request covers most repositories.
  2. Pick the runtime and whether you need a version matrix.
  3. Enable dependency caching; the generated key hashes your lockfile rather than a branch name.
  4. Copy the YAML into .github/workflows/ and commit it on a branch, so the first run happens as a pull request.
  5. 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.

Try the free GitHub Actions Workflow Builder

#github-actions-workflow#ci-yaml#workflow-permissions#dependency-caching#online-tools#free-tools