open atlas
↑ Back to track
CI/CD pipelines CICD · 08 · 01

Events and triggers: what fires a workflow, and the pull_request_target trap

A workflow is event-driven: the `on:` block, filters, and concurrency decide what runs and what gets cancelled. pull_request runs untrusted code without secrets; pull_request_target runs trusted code with secrets against a fork — the classic exfiltration hole.

CICD Senior ◷ 16 min
Level
FoundationsJuniorMiddleSenior

The leak post-mortem read like a magic trick. A drive-by contributor opened a pull request from a fork that touched only the test config — nothing suspicious. CI ran, went green, and twenty minutes later the repo’s NPM_TOKEN and a deploy key were posted to a pastebin. Nobody had merged anything. The trick was the trigger: someone had switched the lint workflow from pull_request to pull_request_target months earlier to “fix” a secrets-not-available complaint. That one word changed the security model completely — pull_request_target runs the workflow definition from the base branch but with the fork’s code checked out, and crucially with full access to the repo’s secrets. The fork’s malicious pretest script ran with the token in the environment. The fix wasn’t a patch; it was understanding that the event you pick decides whether attacker-controlled code meets your secrets.

The on: block is the whole entry point

A workflow does nothing until an event matches its on: block. There are three broad event families, and the distinction governs both behavior and security:

on:
  push:
    branches: [main, "release/**"]
    paths-ignore: ["docs/**"]
  pull_request:
    types: [opened, synchronize, reopened]
  schedule:
    - cron: "17 3 * * 1-5"   # 03:17 UTC, Mon–Fri — offset to dodge the top-of-hour stampede
  workflow_dispatch:          # manual, with typed inputs
  workflow_call:              # invoked as a reusable workflow by another

Filters compose with AND across keys, OR within a list: branches: [main] and paths: [src/**] both must hold, but any branch in the list matches. paths and paths-ignore are mutually exclusive on one event; mixing positive and negative globs in a single paths list is the usual source of “why didn’t my workflow run.” A subtlety that bites teams using required status checks: if a paths filter skips a workflow, the required check is reported as skipped, not passed — and a branch protection rule that requires it will block the merge forever unless you make the check pass-through. schedule cron is UTC-only and is best-effort: GitHub explicitly drops scheduled runs under high load, and runs on the top of the hour are the most likely to be delayed or dropped, which is why production schedules use an offset like 17 3.

pull_request vs pull_request_target — the security fork in the road

For a PR from a fork, pull_request runs the workflow from the PR’s head ref, in a sandbox with a read-only GITHUB_TOKEN and no access to secrets. That is the safe default: untrusted code runs, but it can’t reach anything valuable. pull_request_target runs the workflow from the base branch (so the workflow file is trusted) but in the context of the base repo with full secrets and a read-write token — while the fork’s code is what would be checked out if you naively actions/checkout the PR head. That combination is the exfiltration vector from the Hook: trusted trigger, untrusted code, live secrets.

# DANGEROUS: secrets in scope, then checks out the fork's code and runs it
on: pull_request_target
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}  # ← attacker's code
      - run: npm ci && npm test   # ← runs with NPM_TOKEN in env

The rule of thumb: use pull_request_target only to do trusted work that never executes fork code (label a PR, post a comment, run a check that reads metadata). The moment you need to build or test the contributor’s actual code, use pull_request and accept that secrets are off the table — or gate the privileged step behind a manual approval environment.

Quiz

Why is a workflow triggered by pull_request_target that checks out and tests the PR's head code a security hole, while the same steps under pull_request are safe?

Why this works

Why does GitHub even offer pull_request_target? Because legitimate fork PRs need some trusted automation: auto-labeling, “needs-review” bots, posting coverage comments — work that must read or write repo state but should never run the contributor’s code. The event exists so that trusted metadata work can have credentials while the untrusted-code path (pull_request) stays locked down. The failure mode is collapsing the two: doing untrusted code execution under the trusted trigger. Keep them separate and each is safe.

Concurrency: cancel the stale run before it wastes a runner

Every busy PR branch leaks runner minutes on runs that will be overtaken before they finish — and without a concurrency policy you pay for each one. Ask yourself: does anything useful come from running a CI suite against a commit that was superseded forty seconds ago?

When a contributor pushes three commits in five minutes, three runs queue. Without concurrency control all three execute, and the first two are obsolete the moment the third starts. The concurrency block fixes this:

concurrency:
  group: ci-${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

cancel-in-progress: true cancels any in-progress run in the same group when a new one starts — the canonical PR pattern, keying on github.ref so each branch is its own group. This routinely cuts wasted minutes by a large fraction on busy branches. The inverse pattern matters too: for deploy workflows you want cancel-in-progress: false so a deploy is never killed mid-flight, and the group serializes deploys to one-at-a-time instead. When you add concurrency to a deploy workflow and a second deploy fires mid-run, decide first which failure mode you’d rather face — a half-deployed environment, or a queued deploy that waits. Concurrency is a queue gate, not a trigger — the event still fires; concurrency just decides which runs survive.

Quiz

A deploy-to-production workflow uses concurrency with cancel-in-progress: true keyed on the environment. A second deploy starts while the first is halfway through. What happens, and is it what you want?

Recall before you leave
  1. 01
    Explain the exact difference between pull_request and pull_request_target, and why one is a known exfiltration vector.
  2. 02
    How do on: filters compose, what makes schedule unreliable, and why can a path filter break a merge?
Recap

A GitHub Actions workflow is event-driven from the first line: the on: block names the events — push, pull_request, schedule, workflow_dispatch, workflow_call — and its filters compose AND across keys and OR within a list, with paths/paths-ignore mutually exclusive and a paths-skipped required check blocking merges as “skipped” rather than “passed.” schedule is UTC-only and best-effort, so GitHub drops runs under load and on-the-hour jobs are the most likely casualties; offset the minute. The security model is decided at the trigger, not in the jobs: pull_request runs untrusted fork code with a read-only token and no secrets, so executing it is safe; pull_request_target runs the trusted base-branch workflow with full secrets and a write token, which becomes an exfiltration hole the instant you check out and run the fork’s head code — reserve it for trusted metadata work and push untrusted code execution to pull_request or behind a manual-approval environment. Concurrency is a queue gate layered after the trigger: cancel-in-progress: true keyed on github.ref kills stale PR runs and reclaims minutes, while deploys flip it to false so the group serializes and no deploy dies mid-flight. Pick the event deliberately — it determines what runs, what gets cancelled, and whether attacker code ever meets your secrets.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 5 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.