A GitHub Actions workflow is a deployment boundary, not a YAML file

I compared two synthetic GitHub Actions workflows in a locked-down Docker container. Both could build and publish a container image. Only one made the trust boundary obvious in review.

GitHub's 2026 Actions security roadmap is pushing in a useful direction: reproducible action dependencies, policy controls over who can trigger a workflow, and an evaluate mode before enforcement. The common theme is that workflow YAML is security-sensitive infrastructure.

That sounds obvious until a pull request changes three lines and turns a test workflow into a release path.

I wanted a small way to describe that change without pretending to run a full GitHub Actions security scanner. The question was narrower:

Which event can reach which credentialed capability?

That is an execution contract. It is more useful in review than a list of uses: lines.

The two workflows

I wrote two synthetic workflows. They both use docker/build-push-action and both can publish an image to GHCR.

The first one is deliberately unsafe. It runs on pull_request_target, checks out the pull request head, has write-all permissions, logs in to GHCR with GITHUB_TOKEN, pushes an image, and sends a deployment webhook.

on:
  pull_request_target:
permissions: write-all

jobs:
  release:
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}
      - uses: docker/login-action@v3
      - uses: docker/build-push-action@v6
        with:
          push: true
      - run: curl -fsS -X POST "$DEPLOY_WEBHOOK"
        env:
          DEPLOY_WEBHOOK: ${{ secrets.DEPLOY_WEBHOOK }}

The second workflow separates verification from publication. Pull requests can run npm ci && npm test. The publish job runs only on a push to main and declares the one extra permission it needs: packages: write.

on:
  pull_request:
  push:
    branches: [main]
permissions:
  contents: read

jobs:
  verify:
    if: github.event_name == 'pull_request'
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm test

  publish:
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4
      - uses: docker/login-action@v3
      - uses: docker/build-push-action@v6
        with:
          push: true

Neither example proves a real repository is safe. They are small enough to make the difference visible.

A contract, not a verdict

I wrote a short parser that reads workflow events, job conditions, permissions, secret references, image publishing, and a simple deployment-webhook signal. Its output is a table-shaped JSON document. It does not execute workflow code and it does not contact GitHub.

The hardened workflow produced two separate contracts:

{
  "workflow": "hardened.yml",
  "job": "verify",
  "events": ["pull_request", "push"],
  "condition": "github.event_name == 'pull_request'",
  "capabilities": {
    "packages_write": false,
    "publishes_image": false,
    "uses_secrets": false
  },
  "findings": []
}

The publish job had image-publish capability, but only behind an explicit trusted-main condition and packages: write permission. The unsafe workflow showed the opposite shape:

{
  "workflow": "unsafe-pr-release.yml",
  "job": "release",
  "events": ["pull_request_target"],
  "condition": null,
  "capabilities": {
    "deploy_webhook": true,
    "publishes_image": true,
    "uses_secrets": true
  },
  "findings": [
    "untrusted PR event reaches credentialed publish/deploy capability",
    "image publish lacks an explicit trusted-main push guard"
  ]
}

The parser did not need to know whether the webhook URL was real or whether the registry existed. It only needed to show a reviewer that a pull request event, credentialed steps, and a publish/deploy capability were in the same execution path.

That is the part I do not want buried in YAML.

SHA pinning answers a different question

Pinning actions/checkout or docker/build-push-action to immutable commits is worthwhile. GitHub's roadmap makes the case for locking direct and transitive action dependencies so a workflow executes the code that reviewers actually approved.

But a pinned action can still run in the wrong trust context.

A review has at least two questions:

Review question Evidence to inspect
What action code will run? Immutable action reference or dependency lock
Who can cause that code to run with credentials? Event, condition, permissions, secrets, and publish/deploy steps

The first is dependency integrity. The second is an execution boundary. Both matter, and neither substitutes for the other.

Why an evaluate mode matters

GitHub describes an evaluate mode for workflow execution rules: show runs that a policy would block before enforcing the policy. That is the right rollout shape.

CI policy changes can stop releases. A rule that bans a trigger or requires a particular actor may catch a real problem, but it can also catch a legitimate release workflow nobody remembered existed. An evidence-first review gives teams somewhere to start:

workflow event
  -> job condition
  -> permissions and secrets
  -> image publish or deployment capability
  -> review-required decision

The goal is not to auto-declare every pull_request_target workflow malicious, or every image publish wrong. There are repositories with carefully designed reasons to use sensitive events. The goal is to make that design legible before someone merges it.

Where this experiment stops

This was a smoke test, not a GitHub Actions emulator or a security assessment. The parser does not resolve reusable workflows, composite-action internals, organization rulesets, environment protection, branch protection, OIDC claims, runner network policy, or runtime expression values. It does not prove that a job can actually obtain a secret.

The test ran in a disposable Docker image with no network, no mounted host directory, a read-only filesystem, a 16 MiB temporary filesystem, all Linux capabilities dropped, no-new-privileges, one CPU, 128 MiB memory, and a PID limit of 64. The image ran as UID 65532 against two synthetic workflow files. No GitHub credential, repository, workflow run, registry, deployment endpoint, or customer source code was used.

The output JSON SHA-256 was:

665061732b6ec86dca49c8fbfef2dbeff6d809a9e2c53195ae252ca4b5d169b7

The useful next step is not another all-purpose CI scanner. It is a small review companion that can turn a workflow diff into an execution-contract diff: which event, actor, permission, secret, registry, or deployment surface changed, and which part still needs a human decision.

That would extend the same review habit I want from GitOps manifests and Gateway migrations. Treat the artifact as evidence. Make the operational consequence visible. Leave the decision with the person who owns the risk.

Sources: GitHub Actions 2026 security roadmap · GitHub Actions security hardening guidance · I built a security-impact diff for GitOps · A Gateway migration is a routing change, not a YAML conversion