Skip to content
Ayhan Sipahi Ayhan Sipahi

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).

Feature commit

push: branches

Open / update PR

pull_request

Merge to main

push: main

Enter merge queue

merge_group

Cut a release

release: published

Fast feedback: lint, unit, types

Required gate on merge commit

Integration + staging deploy

Post-merge integration proof

Build, sign, deploy behind approval

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.

TriggerPurposeTypical checksToken / secret context
push (feature branch)Fast per-commit feedbackLint, unit tests, type checkRepo token; scope to read-only by setting
pull_requestThe merge gateRequired checks on the merged resultRead-only token, no secrets for fork PRs
push (main)Post-merge integration, stagingIntegration suite, staging deployRepo token; trusted context
merge_groupIntegration proof before mergeThe full required check setRepo token; trusted context
release: publishedReproducible signed buildBuild, attest, deployElevated 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_request and pull_request_target are not interchangeable. pull_request_target runs 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 for pull_request_target. Run the untrusted build on pull_request (read-only, secret-free) and upload its output as an artifact, then process that artifact in a separate privileged workflow triggered by workflow_run.

No

Yes

PR opened or updated

Checkout merge commit refs/pull/N/merge

Run required checks

All required checks green?

Merge blocked by branch protection

PR is mergeable

Fork PR

Read-only token, no secrets

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.

No

Yes

PR A (passed alone)

Temporary branch main/pr-N: target + queued PRs

PR B (passed alone)

merge_group event dispatched

CI on the combined result

Combination green?

PR removed from queue

Merge to main, then push: main runs

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: published and push: tags: are different events that fire at different moments; prefer release: published so the job keys off the Release lifecycle rather than the raw tag push. When you name the attestation step, actions/attest-build-provenance is a wrapper over the lower-level actions/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.

No

Yes

release: published

Build at refs/tags/{tag}

Attest provenance (Sigstore)

Environment reviewer approves?

Deployment waits or fails after timeout

Deploy to production

Consumer runs gh attestation verify

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.

ConcernCacheArtifacts
PurposeReuse rarely-changing inputsKeep or pass outputs
LifetimeEvicted by least-recently-usedRetained for a set period
How sharedRestored by key across runsPassed via needs / downloaded
Typical useDependencies, build layersCompiled output, reports, the fork-safe handoff
TriggerConcurrency modeWhy
pull_requestcancel-in-progress: trueNewest commit wins; free the runner
Deploy jobqueue: 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_TOKEN default 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.

TriggerToken scopeSecrets / cloud access
push (feature)Read-onlyNone needed
pull_request (fork)Read-only (enforced)None
push (main)Read-only default, elevate per jobStaging only
release: publishedcontents: read, id-token: write, attestations: writeProduction 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

Related posts