Which CI Steps Run on Which Trigger
Each git event deserves a different job. A GitHub Actions routing guide: what to run on push, pull_request, the merge queue, and tag/release, and why it protects lead time.
Most pipelines run the same set of checks on every event: the full test matrix, the integration suite, and the deploy logic all fire whether a developer pushed a feature commit, opened a pull request, or cut a release. That uniformity is the problem, because each git event arrives at a different point in the lifecycle and carries a different security context, so a check that belongs at one trigger is either wasteful or dangerous at another. Treat the on: block as a routing table instead: match the check to the trigger so PR feedback stays fast, main stays deployable, and releases stay reproducible. The reader who owns the CI setup decides what runs where.
The through-line is the same one in Compressing Time to Production: protecting lead time. Running everything on every push does not make delivery safer; it slows feedback and leaks privilege into code you have not reviewed yet. The fix is routing.
The Trigger Routing Table
On GitHub Actions, the on: events are not interchangeable labels. Each one fires at a distinct lifecycle moment and hands the job a distinct token and secret context. Pick the trigger first, then decide what the job does.
A push fires on commit pushes and on tag pushes, scoped by branches, tags, or paths (branch and path filters are ANDed together). A pull_request runs when a PR is opened, synchronized, or reopened, and it tests the merged result rather than the branch tip. The merge queue dispatches its own event, merge_group, when it requests checks on a batched combination. A release keys off the Release object lifecycle, which is a different moment than the raw tag push that push: tags: observes. Two more triggers round out the table: workflow_run chains one workflow off another (default-branch only, up to three levels deep per GitHub docs), and workflow_dispatch is the manual or API trigger (default-branch only, with a bounded input count per GitHub docs).
The table below is the same map in tabular form. The token and secret column is the part most teams skip, and it is the part that decides whether a trigger is safe to run privileged work on.
| Trigger | Purpose | Typical checks | Token / secret context |
|---|---|---|---|
push (feature branch) | Fast per-commit feedback | Lint, unit tests, type check | Repo token; scope to read-only by setting |
pull_request | The merge gate | Required checks on the merged result | Read-only token, no secrets for fork PRs |
push (main) | Post-merge integration, staging | Integration suite, staging deploy | Repo token; trusted context |
merge_group | Integration proof before merge | The full required check set | Repo token; trusted context |
release: published | Reproducible signed build | Build, attest, deploy | Elevated write scopes behind an approval |
The Pull-Request Gate
The pull_request trigger is where the merge decision is enforced, so it carries the required checks and nothing privileged. By default it runs on the opened, synchronize, and reopened activity types, and it checks out the merge commit (refs/pull/N/merge) rather than the PR branch tip. That detail matters: CI tests the result of merging the PR into the base, not the branch in isolation, and it does not run at all when the PR has conflicts. You are testing the thing that will actually land.
A check on this trigger is advisory until you make it required. Branch protection or a ruleset is what turns a passing job into a gate; without that, a red build can still merge. This is the most common version of “the gate is in the wrong place”: the check runs, the team trusts it, and nothing stops a merge when it fails.
The security boundary lives here too. A pull request opened from a fork gets a read-only GITHUB_TOKEN and no access to repository secrets. That is the fork-safe context: untrusted contributor code cannot read your cloud credentials or push to your repo.
Warning:
pull_requestandpull_request_targetare not interchangeable.pull_request_targetruns in the base-repo context with a read-write token and full secret access. Combine that with a checkout of the untrusted PR head and you have the “pwn request” vulnerability that GitHub Security Lab documents: attacker-controlled code executes with your secrets. If you need to build untrusted code and also use secrets, do not reach forpull_request_target. Run the untrusted build onpull_request(read-only, secret-free) and upload its output as an artifact, then process that artifact in a separate privileged workflow triggered byworkflow_run.
The Merge-to-Main Stage
A merge to main is a push to main, so on: push: branches: [main] is the natural home for post-merge integration and the staging deploy. The PR gate proved the merge commit in isolation; the push-to-main stage runs against what actually landed and promotes it to a staging environment.
At scale, the PR gate has a gap: two PRs that each pass on their own can break when combined. The merge queue closes that gap. It builds a temporary branch (the target plus the queued PRs, named like main/pr-N) and dispatches the merge_group event, so CI runs the post-merge combination rather than any single PR. The catch is wiring. A required check is matched by workflow, and if your required-check workflow does not subscribe to merge_group, the check never reports on the queued combination and the merge stalls indefinitely. The fix is one line in the on: block.
on:
pull_request:
merge_group:
That snippet is the single must-have configuration in the whole model. Everything else is routing decisions; this is the one place a missing trigger silently breaks the queue. Build concurrency throttles how many queue runs execute at once (a bounded range per GitHub docs), and the staging deploy uses a deployment environment plus a concurrency group to cap one in-progress deployment so a fast queue does not start overlapping deploys.
The Tag and Release Job
The release is the one trigger where elevating write scope is deliberate, not accidental. Subscribe to release: published and the job runs at the tag (refs/tags/<tag>), which is the fixed, reproducible point to build, sign, and deploy. Subscribing to published covers both stable releases and pre-releases, while leaving draft churn out of the pipeline.
This is the place to generate signed provenance. GitHub Artifact Attestations, generally available since June 2024, produces a Sigstore-backed attestation that links a built artifact to the workflow run that produced it; a consumer verifies it with gh attestation verify. The permissions baseline is three scopes: id-token: write to mint the signing identity, contents: read to read the source, and attestations: write to record the attestation. Add packages: write only when you are also pushing a container image; it is not part of the baseline.
Note:
release: publishedandpush: tags:are different events that fire at different moments; preferrelease: publishedso the job keys off the Release lifecycle rather than the raw tag push. When you name the attestation step,actions/attest-build-provenanceis a wrapper over the lower-levelactions/attest. Refer to these actions by name and let your pinning policy pick the version, because the upload, download, and attestation actions all move major versions on their own schedule.
Attestations map to the shape of SLSA Build Level 2: a hosted build platform produces signed provenance that a consumer can verify. Treat that as a descriptive mapping, not a certified rating; the SLSA v1.0 levels are worth quoting, but v1.2 is the current spec, so do not assert a hard “L3” claim for the out-of-the-box setup.
The Build Job Itself
The build job is orthogonal to the trigger. The same compile-and-test logic can run on a push, a PR, or a release; the trigger decides how much of it runs and how the run behaves under load. This is where you shape cost without changing what each check means.
Two storage mechanisms get confused, and they serve opposite purposes. A cache holds rarely-changing inputs so you do not refetch them every run; you key it on something like a lockfile hash and let restore-keys fall back to a near match, or you let a setup action manage it for you. The default cache budget is around 10 GB per repository per GitHub docs, with least-recently-used eviction. An artifact holds outputs you want to keep or pass to a later job through needs; it is the channel for the fork-safe pattern, where an untrusted build uploads and a privileged job downloads.
Concurrency is the other lever, and the two modes do not mix. On PRs you want cancel-in-progress: true so a new push supersedes the run still grinding on the old commit. On deploys you want the queue mode (queue: max, FIFO, with a bounded pending count per GitHub docs), which cannot combine with cancellation because you must not cancel a deploy that is already touching production. The matrix is the third lever: it runs one job per combination, so scope it by trigger. Run the full matrix on main and releases, and a reduced one on PRs.
| Concern | Cache | Artifacts |
|---|---|---|
| Purpose | Reuse rarely-changing inputs | Keep or pass outputs |
| Lifetime | Evicted by least-recently-used | Retained for a set period |
| How shared | Restored by key across runs | Passed via needs / downloaded |
| Typical use | Dependencies, build layers | Compiled output, reports, the fork-safe handoff |
| Trigger | Concurrency mode | Why |
|---|---|---|
pull_request | cancel-in-progress: true | Newest commit wins; free the runner |
| Deploy job | queue: max (FIFO) | Never cancel an in-flight production deploy |
The Gates and the Least-Privilege Token
Triggers route the work, but they only become enforceable through three mechanisms: branch protection or rulesets, deployment environments, and a least-privilege token paired with OIDC. The pattern is that scope rises with the trigger rather than being granted globally.
Branch protection and rulesets are what make a check required, as covered in the PR section. Deployment environments add the human gate: required reviewers (a bounded count per GitHub docs, with an optional prevent-self-review rule), a “Waiting” status while a run is pending approval, automatic failure after the approval window lapses, a non-billable wait timer, and branch or tag policies that restrict which refs may deploy. This is the approval that sits in front of the release deploy in the diagram above. Environment protection rules, and attestations on private or internal repositories, are plan-gated to paid tiers; on public repositories they are available on all plans.
The token is the last piece. Declaring a permissions: block sets every unlisted scope to none, write implies read, and the safe shape is a restrictive top-level block with per-job elevation only where a job needs it. OIDC removes long-lived cloud secrets entirely: permissions: id-token: write mints a per-run JWT, your cloud validates it against a trust relationship you configured once, and it returns a credential that is scoped to that single job and expires on its own. Note that id-token: write only grants the ability to request the JWT; it does not grant write access to any resource.
Warning: The
GITHUB_TOKENdefault is a repository or organization “Workflow permissions” setting, not a fixed value. Repositories created on or after 2023-02-02 default to read-only; older repositories default to read-write. Audit it and set the default to read-only, then elevate per workflow. Do not assume the token is already read-only.
| Trigger | Token scope | Secrets / cloud access |
|---|---|---|
push (feature) | Read-only | None needed |
pull_request (fork) | Read-only (enforced) | None |
push (main) | Read-only default, elevate per job | Staging only |
release: published | contents: read, id-token: write, attestations: write | Production via OIDC, behind approval |
The least-privilege and OIDC mechanics overlap with Security Without the Review Queue, which covers the gate-and-token side in depth; the focus here stays on which trigger each scope belongs to. For the client-side release angle, The Client You Cannot Roll Back covers why the release trigger carries extra weight when you cannot recall what you shipped.
Cost-to-Trigger Boundary
Match each trigger to its job and the pipeline routes itself: a feature-branch push earns fast feedback, a pull_request earns the required gate on the merged result, the merge_group earns the integration proof, and release: published earns the reproducible signed build behind a human approval. Each event runs the check that fits its lifecycle moment and its security context, which is what keeps PR feedback fast and main deployable at the same time.
The boundary is the part to hold the line on. Do not grant write scopes globally, because a permissive default leaks privilege into fork PRs and untrusted code. Do not run the full matrix on every push, because that burns minutes and slows the feedback the whole model is meant to protect. The single next step is an audit: list which workflow runs on which event, and what token scope each one holds, and move anything that is mismatched.
References
- Events that trigger workflows — GitHub Docs - The per-event reference and the source for the routing map.
- Managing a merge queue — GitHub Docs - The
merge_groupevent, the temporarymain/pr-Nbranch, and the mandatoryon: merge_group:wiring. - About protected branches — GitHub Docs - How a check becomes a required status check.
- Preventing pwn requests — GitHub Security Lab - The
pull_request_targetdanger and the safe artifact-handoff pattern. - Using artifact attestations to establish provenance — GitHub Docs - Provenance permissions and verification with
gh attestation verify. - Artifact Attestations is generally available — GitHub Changelog - The general-availability announcement.
- SLSA • Security levels (v1.0) - The Build track levels L1 through L3 (note v1.2 is the current spec).
- Managing environments for deployment — GitHub Docs - Required reviewers, the wait timer, and deploy branch policies.
- Security hardening with OpenID Connect — GitHub Docs - Short-lived, single-job cloud credentials via OIDC.
- Control workflow concurrency — GitHub Docs -
cancel-in-progressversusqueue: max. - Authentication in a workflow (GITHUB_TOKEN) — GitHub Docs - Permissions semantics and least-privilege defaults.
- Dependency caching — GitHub Docs - Cache keys, restore-keys, and the per-repository cap.
Related posts
How high-performing teams shrink the lead time from code-complete to live in production, without trading away security or code quality. A guide for tech leads.
How high-performing teams keep security review from becoming a time-to-production bottleneck: shift-left automation, risk-based gates, a paved road, and dependency cadence.
Production deploys need a real approval gate: use GitHub Environments with native protection rules and scoped secrets, not workflow if: hacks or marketplace actions.
A practical guide to building an org-level shared GitHub Actions platform: architecture decisions, security governance, adoption, and 7 costly mistakes.
A hardened, paste-ready setup for adding Anthropic's claude-code-action to a GitHub repo, with the security and cost knobs spelled out for production use.