Hardening the Workflow File Itself

Why a mutable action tag is a bet you don't know you're making, why permissions should deny everything by default, and the one gotcha where that default breaks a reusable workflow.

September 14, 20264 min read3 / 4

Everything so far has hardened where credentials come from. This post is about hardening the workflow file itself: what it's allowed to do, and what code inside it is actually allowed to change.

A workflow file nobody has looked at in months is still trusted completely, by default. That trust needs to be earned back on purpose, one setting at a time.

A version tag is a promise someone else can quietly break

uses: actions/checkout@v6 looks pinned. It isn't. v6 is a tag, and a tag can be moved to point at a different commit at any time, by the publisher, without you knowing.

A mutable tag is Russian roulette you didn't know you were playing. Nothing in the GitHub Actions UI tells you a tag moved. The workflow just quietly starts running different code than it ran yesterday, and if that new code is malicious or just broken, you find out from a failure, not a warning.

The fix is pinning to the commit hash instead:

YAML
- uses: actions/checkout@v4 # inaccurate: this can move - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4.3.0, pinned

The hash never changes what it points to, ever, no matter what the publisher does to the tag later. The comment naming the version is there for a human reading the file, not for GitHub, since the hash alone tells you nothing about what version you're looking at.

Deny everything, then grant exactly what's used

By default, a workflow's token carries a set of permissions nobody explicitly chose. A workflow you assumed could only read your code might actually be able to write back to the repo, because that's the default, not because anyone asked for it.

YAML
permissions: {}

At the top of a workflow, this wipes every default permission out. Nothing works until a job explicitly asks for something:

YAML
jobs: deploy: permissions: contents: read id-token: write

contents: read for checking out code, id-token: write for the OIDC token from the last post. Nothing else. A reader of this file can now answer "what can this actually do" just by looking at one block, instead of reading GitHub's default permissions table and hoping nothing else grants more.

The gotcha: permissions don't pass through a reusable workflow call

Set permissions: {} at the top level and call a reusable workflow from inside it, and the reusable workflow does not inherit anything the caller granted. It gets its own, separate, empty slate.

That means the permission a reusable build.yaml needs, contents: read, has to be declared inside build.yaml itself, not just in the workflow that calls it. Miss this and the call fails with a permissions error that has nothing to do with your actual build logic, it's purely about which file is allowed to ask for what.

There are two honest ways to handle it: declare contents: read inside the reusable workflow directly, or skip the reusable-workflow layer for that job and keep its steps inline where the top-level permissions block already covers it. Neither is wrong. Keeping permissions inline, visible in one place, is the less surprising of the two.

Two concurrency groups, two different intents

Concurrency control isn't one setting, it's two, doing opposite things on purpose.

YAML
# ci.yaml: cancel the old run, only the latest commit's check matters concurrency: group: ci-${{ github.event.pull_request.number }} cancel-in-progress: true
YAML
# deploy.yaml: never cancel a live deploy, queue behind it instead concurrency: group: deploy-production cancel-in-progress: false

CI groups by pull request number and cancels the old run, because only the newest commit's check result matters. Deploy uses one static group for the whole environment and never cancels, because a cancelled deploy mid-sync can leave the site half-updated. A second deploy waits its turn instead of racing the first one.

The Essentials

  1. A version tag can move without warning. A commit hash can't. Pin third-party actions to the hash, and comment the version for a human reader.
  2. permissions: {} denies everything by default, then each job grants exactly what it uses. A reader can answer "what can this do" from one block.
  3. Permissions don't inherit through a reusable workflow call. The called workflow needs its own permissions declared, or the job stays inline instead of nested.
  4. CI cancels its own old runs. Deploy never does. Same mechanism, opposite settings, because a half-finished check is harmless and a half-finished deploy isn't.

Further Reading and Watching