/ Back to notes

July 8, 2026

7 min read

ci-cd / devops / github-actions

GitHub Actions Beyond Basic CI: The Trigger Decides Whose Code Runs With Your Secrets

GitHub Actions is a repository-native automation platform, and that makes every workflow a trust decision. How events, token permissions, OIDC and pinned actions shape what a workflow can do, and how to tell which automation belongs in a workflow at all.

GitHub Actions Beyond Basic CI: The Trigger Decides Whose Code Runs With Your Secrets

Two workflows can be almost identical. Both run when a pull request opens, check out the pull request's code and run npm install && npm test. One uses on: pull_request. The other uses on: pull_request_target.

For a pull request from a fork, the first runs the contributor's code with a read-only token and no repository secrets. The second runs in the base repository's context: a token with the repository's default permissions, often including write access, plus access to secrets. If it checks out and builds the contributor's code, it has handed an untrusted package.json the keys to your repository. GitHub's security team calls this a "pwn request". GitHub Security Lab: Preventing pwn requests

Most introductions present GitHub Actions as a CI runner. I find it more useful to treat it as an event-driven automation platform attached to the repository, where each workflow answers two questions: what triggers it and with which privileges, and whether this automation belongs in the repository at all. This post is a synthesis of GitHub's documentation and public material, not an account of a specific production setup.

The model in one paragraph

A workflow is a YAML file in .github/workflows. An event (push, pull_request, release, schedule, workflow_dispatch, and many more) starts it. The workflow contains jobs that run on runners, either GitHub-hosted or your own machines. Jobs run in parallel unless needs orders them. Each job is a list of steps: shell commands or packaged actions. That is all it takes to run tests on a pull request. It is also enough to publish packages, deploy to production, label issues or rotate dependencies, which is why the trust questions matter.

The event decides whose code runs, and with what

EventWorkflow and default checkoutGITHUB_TOKENSecrets
pull_request from a forkThe pull request's merge commitRead-onlyNot available
pull_request_target from a forkThe base repository's default branchRepository defaults, often writeAvailable
push to your branchesCode already in the repositoryAs configuredAvailable
workflow_dispatchThe branch a person with write access choseAs configuredAvailable

The design behind pull_request_target is reasonable: it lets a workflow label or comment on fork pull requests, which needs a write token. By default, actions/checkout in that workflow checks out the base branch, which is trusted code. The danger starts when the workflow explicitly checks out the pull request's head and executes it in that privileged context. Build scripts, test hooks and install steps are all code the contributor controls. GitHub's own documentation says to avoid this event if you need to build or run code from the pull request. GitHub: Events that trigger workflows

The safe pattern separates the two:

  • validate untrusted code under pull_request, without secrets;
  • run privileged steps only on trusted code, for example after merge, on a release, or in a separate workflow triggered by workflow_run that consumes the unprivileged run's results as data, never as code to execute.

Give the token only what the job needs

Every job receives a GITHUB_TOKEN. Its default permissions depend on repository and organization settings, so the workflow should state what it needs explicitly:

permissions:
  contents: read

jobs:
  release:
    permissions:
      contents: write   # create the release
      id-token: write   # request an OIDC token for the cloud

Declaring read-only at the top and elevating per job turns the YAML into a readable list of what each job can touch. It also limits the damage if a step is compromised.

Credentials that expire on their own

Deploying to a cloud provider used to mean storing long-lived access keys as repository secrets. With OpenID Connect, the workflow asks GitHub for a signed token describing the run: repository, branch, environment. The cloud provider exchanges that token for short-lived credentials, under a trust policy you write. GitHub: OIDC with AWS

The trust policy is where the security actually lives. A policy that accepts any branch of the repository lets any branch deploy. Restricting it to a specific environment or branch, such as main or production, ties cloud access to the same review process as the code.

Third-party actions are dependencies that execute

uses: some-org/some-action@v3 runs code from another repository with your job's token and secrets in reach. A tag like v3 is mutable, so its owner, or anyone who compromises the owner's account, can move it to different code. Pinning to a full commit SHA makes the executed code fixed and reviewable, at the cost of updating pins deliberately, which tools such as Dependabot can automate. GitHub: Secure use reference

Cache and artifacts solve different problems

Two features are often confused:

  • Cache reuses inputs across runs, such as dependency directories, keyed by something like the lockfile hash. A miss only costs time. GitHub: Dependency caching
  • Artifacts preserve outputs of a run, such as binaries, reports or coverage, so later jobs or people can download them.

Using the cache to pass a build result between jobs, or artifacts as a dependency cache, works until it does not. A cache can be evicted, and artifacts add upload and download time to every run. The cache also has a trust angle: entries saved by one workflow run can be restored by others, so a cache written by untrusted code should not feed a privileged job.

What belongs in a workflow

The second question is about placement. Actions fits automation whose trigger is a repository event and whose result benefits from being visible in pull requests, releases or issues: validation before merge, packaging, deploy orchestration, dependency and security checks, changelog generation, policy enforcement.

It fits poorly with work that belongs to the running product: queue consumers, business schedulers, recurring jobs on production data. A schedule trigger can run those, but then your product's behavior depends on your CI platform's availability, permissions and logs. Workers, queues, Kubernetes CronJobs or a cloud scheduler keep that work with the system it serves.

When an organization scales it

With dozens of repositories, copying YAML stops working. Each copy drifts, and a security fix has to be applied everywhere. The tools for this are reusable workflows (workflow_call), composite actions for shared steps, workflow templates for new repositories, and organization-level policies on allowed actions and default token permissions. GitHub Blog: organization-wide governance and reuse

GitHub's customer stories describe this transition from the vendor's perspective. Blue Yonder describes moving away from Jenkins bottlenecks, AstraZeneca reusable patterns for security scanning and testing, Elanco composite actions and guardrails, and Grupo Boticário less context switching between tools. These are vendor-published accounts, not independent measurements. The common pattern is still useful: at scale, the work shifts from writing workflows to governing how they are reused.

Two questions before every workflow

Before merging a new workflow, I would ask:

  1. What triggers it, whose code runs, and with which token permissions and secrets? If untrusted code and privileged credentials meet in the same job, redesign the workflow before adding features to it.
  2. Does this belong to the repository's lifecycle or to the running product? If it is the latter, it probably belongs in the product's infrastructure.

Answering those two questions explicitly keeps workflows from slowly becoming an unreviewed part of the production platform.

References