GitHub Actions has maybe twenty different event types that can trigger a workflow. Most people learn two — push and pull_request — and discover the rest the hard way, usually during an incident at 11pm when they’re staring at a workflow that should have fired but didn’t, or did fire but with the wrong SHA.
This is the reference I wish I had when I started wiring up CI for a hundred-package monorepo. Every trigger type, when it fires, what context it carries, and all the edge cases that the docs mention in one sentence and then move on like they didn’t just drop a grenade.
The on: block — what makes a workflow run
Every workflow starts with on:. It’s a map of event types to optional filters.
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
This says: run on pushes to main, on pull requests (all types), and on manual dispatch. Simple enough.
But “simple enough” hides a lot. Each event type carries different context, different git refs, different permissions, and different behavior around concurrency and cancellation. The same workflow can behave completely differently depending on which event triggered it — and that’s before you add if: conditions.
Let me walk through each one.
push — the obvious one (with surprises)
on:
push:
branches: [main]
paths:
- 'src/**'
Fires when commits are pushed to matching branches. If you specify paths, only fires when those paths have changes. Straightforward.
Context:
github.sha— the HEAD commit of the pushgithub.ref—refs/heads/main(or whatever branch)github.event.commits— array of commits in the push
Surprises:
- A merge to main is a
pushevent. When a PR merges, the PR workflow (pull_request: closed) fires and the push workflow fires. They’re independent events. pathsfiltering applies to the diff between the push and the previous HEAD. Force-pushes can produce unexpected path diffs because the base has shifted.- If you push 10 commits at once, you get one
pushevent, not ten.github.shapoints to the tip.
In our monorepo, the main-CI workflow triggers on push to main:
name: "Main Required CI"
on:
workflow_dispatch:
push:
branches:
- main
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: false # Every commit to main gets verified
Note cancel-in-progress: false. For main, you want every push to complete — you need to know exactly which commit broke the build. On PRs, you want the opposite.
pull_request — the workhorse with a weird SHA
on:
pull_request:
types: [opened, synchronize, reopened]
branches: [main]
paths:
- '**.py'
Fires on PR events. The types filter is important — the default is [opened, synchronize, reopened], which means “when a PR is created, when new commits are pushed to it, or when it’s reopened.” There are many more types — labeled, unlabeled, closed, review_requested, etc.
Context:
github.sha— this is a merge commit SHA. Not the HEAD of your branch. GitHub creates a temporary merge of your branch into the base branch and checks out that commit. This is how it tests “what would happen if this merged right now.”github.ref—refs/pull/123/mergegithub.event.pull_request— the full PR object (number, title, labels, head SHA, base SHA, etc.)github.event.pull_request.head.sha— the actual tip of the PR branch
This SHA thing trips everyone up. Your workflow runs at a merge commit that doesn’t exist in your branch history. If you’re trying to git log or compare commits, use github.event.pull_request.head.sha for the real branch tip. The merge commit SHA (github.sha) is ephemeral — it only exists in the Actions runner’s checkout.
Default types caveat: If you add types: [labeled] so your workflow responds to label events, you lose the default opened and synchronize triggers. You have to explicitly list everything you want:
on:
pull_request:
types: [opened, synchronize, reopened, labeled]
Forget this once and spend an hour wondering why CI doesn’t run on new pushes anymore. Ask me how I know.
merge_group — the merge queue middleman
on:
merge_group:
branches: [main]
This one’s newer and less understood. It fires when a PR enters a merge queue. The merge queue is GitHub’s answer to “two PRs pass CI individually but break when merged together” — it tests PRs in combination before actually merging.
Context:
github.sha— the merge group head SHA (a temporary merge commit combining the PR with other queued PRs)github.ref—refs/heads/gh-readonly-queue/main/pr-123-<hash>— a temporary branch GitHub createsgithub.event.merge_group— contains.head_sha,.base_sha, etc.github.event.pull_request— does not exist. This is the big one.
The timing flow:
- PR passes
pull_request-triggered CI - Someone clicks “Merge when ready” (or auto-merge is enabled)
- PR enters the merge queue
- GitHub creates a temporary merge branch combining this PR with others already in the queue
merge_groupevent fires — your merge queue workflow runs against this combined branch- If CI passes, the PR merges to main (which triggers a
pushevent) - If CI fails, the PR is kicked out of the queue
Three events, one merge: A PR going through the merge queue triggers pull_request (step 1), merge_group (step 4), and push (step 6). Three separate workflow runs, potentially three different workflow files.
In our monorepo, the merge queue workflow is deliberately lighter than the PR workflow:
name: "Merge Queue Required CI"
on:
workflow_dispatch:
merge_group:
branches:
- main
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: false
jobs:
cpu-tests:
uses: ./.github/workflows/run_cpu_tests.yml
with:
caller: mq-ci
secrets: inherit
# NOTE: merge queue does NOT run GPU tests — only lint + CPU
GPU tests are expensive and slow. Running them again in the merge queue — after they already passed in PR CI — would make the queue unbearably slow. The team decided the marginal safety wasn’t worth the cost. Lint and CPU tests catch integration conflicts; GPU tests are unlikely to fail from a merge that passed both PRs individually.
workflow_call — reusable workflows (the “called” pattern)
on:
workflow_call:
inputs:
caller:
description: "Who called this workflow"
required: true
type: string
run_all_gpu_tests:
required: false
type: boolean
default: false
workflow_call makes a workflow reusable — another workflow can invoke it like a function. The calling workflow uses uses: at the job level:
jobs:
gpu-tests:
uses: ./.github/workflows/run_gpu_tests.yml
with:
caller: pr-ci
secrets: inherit
Key things:
- The called workflow inherits the event context of the caller. If PR CI calls
run_gpu_tests.yml, then inside that called workflow,github.event_nameis stillpull_request. Theworkflow_callevent type doesn’t replace the original event. - Inputs are the interface. The caller passes data through
with:. The called workflow accesses them viainputs.*. secrets: inheritpasses all secrets from the caller. Without it, the called workflow gets no secrets — a fun discovery at runtime.- Called workflows can define their own
outputs:that the caller can consume.
The caller pattern
This is an elegant solution we use for “same workflow, different behavior depending on trigger context.”
We have three entry-point workflows — PR CI, Main CI, and Merge Queue CI — that all call the same reusable workflow files. The reusable workflows need to know who called them to adjust behavior. Since github.event_name is the original event (not workflow_call), you could branch on that. But there’s a cleaner way: pass a caller input.
# In the called workflow:
on:
workflow_call:
inputs:
caller:
description: "The caller of this workflow"
required: true
type: string
jobs:
wait-for-label:
if: inputs.caller == 'pr-ci'
# Only poll for GPU label on PR runs — main and merge queue don't need it
The wait-for-label job only makes sense in PR context — it polls for a human to add a run-gpu-tests label before starting expensive GPU tests. On main pushes and merge queue runs, GPU tests should just run (or not run at all, in the merge queue case). A single if: inputs.caller == 'pr-ci' handles this.
This is cleaner than if: github.event_name == 'pull_request' because:
- It’s explicit about the semantic context, not just the event type
- You can have multiple callers with the same event type but different behavior
- It’s self-documenting — reading
caller: pr-citells you exactly what’s happening
Dual-mode: workflow_call + workflow_dispatch
A common pattern: the same workflow is both reusable (called by other workflows) AND manually triggerable.
on:
workflow_call:
inputs:
caller:
required: true
type: string
run_all_gpu_tests:
required: false
type: boolean
default: false
workflow_dispatch:
inputs:
run_all_gpu_tests:
description: "Run all GPU tests"
required: false
type: boolean
default: false
caller:
description: "The caller of this workflow"
type: choice
options:
- main-ci
- pr-ci
default: "main-ci"
Notice: workflow_dispatch inputs support choice type (dropdown in the Actions UI), but workflow_call only supports string, boolean, and number. You define the same logical inputs twice with different type constraints. Inside the workflow, you access both the same way via inputs.*.
workflow_dispatch — the manual trigger
on:
workflow_dispatch:
inputs:
environment:
description: "Target environment"
type: choice
options: [dev, staging, prod]
default: dev
dry_run:
description: "Dry run mode"
type: boolean
default: true
version:
description: "Version to deploy"
type: string
required: true
The “Run workflow” button in the GitHub Actions UI. Supports input types: string, boolean, choice, environment.
Context:
github.sha— HEAD of the selected branchgithub.ref— the branch you picked in the UIgithub.event.inputs— the inputs you provided (all as strings, regardless of declared type)inputs.*— the inputs with proper type coercion
The type coercion gotcha: github.event.inputs.dry_run is the string "true", not the boolean true. Use inputs.dry_run for the actual boolean. This matters in if: conditions:
# Wrong — comparing string to boolean
if: github.event.inputs.dry_run == true # Always false!
# Right — use the typed accessor
if: inputs.dry_run == true
# Also right — string comparison
if: github.event.inputs.dry_run == 'true'
Why every workflow should have workflow_dispatch: Even if you never plan to trigger manually, add it. When CI is broken and you need to re-run a specific workflow against a specific branch with specific inputs, you’ll be glad it’s there. It costs nothing and saves you from “I need to push an empty commit to trigger CI.”
schedule — cron, but GitHub’s version
on:
schedule:
- cron: "0 */2 * * *" # Every 2 hours
- cron: "0 10 * * 1-5" # Weekdays at 10:00 UTC
Runs on a cron schedule. Uses standard cron syntax, always in UTC.
Context:
github.sha— HEAD of the default branch (usuallymain)github.ref—refs/heads/maingithub.event_name—schedulegithub.event.schedule— the specific cron string that triggered this run (useful if you have multiple schedules)
The gotchas:
-
GitHub might delay your cron. During high-load periods, scheduled runs can be delayed by minutes or even hours. Don’t rely on precise timing.
-
Scheduled workflows only run on the default branch. Even if the workflow file exists on a feature branch,
scheduleonly triggers against the default branch’s version of the file. This means you can’t test schedule changes on a branch — you have to merge to main to see them work. -
If the repo is inactive, scheduled runs stop. After 60 days of no repository activity (pushes, issues, PRs), GitHub disables scheduled workflows. You’ll get a notification email, but it’s easy to miss.
-
Multiple cron entries fire independently. Each
cron:line is a separate trigger. If both happen to fire at the same minute, you get two separate workflow runs.
Our image build runs every 2 hours on a schedule, plus on-demand via workflow_call and workflow_dispatch:
on:
workflow_dispatch:
workflow_call:
inputs:
caller:
description: "The caller of this workflow"
required: true
type: string
schedule:
- cron: "0 */2 * * *"
The scheduled runs are our “keep the base image fresh” mechanism. If a dependency updates or a base image changes upstream, the next scheduled run picks it up even if no code changed.
if: conditions — the expression language
if: can appear at three levels: workflow, job, and step. It controls whether that unit runs.
on:
push:
branches: [main, develop]
jobs:
deploy:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Deploy to production
if: github.event_name != 'schedule'
run: ./deploy.sh
The ${{ }} bracket rules
This is where it gets confusing. Sometimes you need ${{ }}, sometimes you don’t.
In if: conditions: GitHub automatically wraps the expression in ${{ }} for you. So these are equivalent:
if: github.ref == 'refs/heads/main'
if: ${{ github.ref == 'refs/heads/main' }}
Both work. The bare form is cleaner.
But there’s a catch with !: The YAML parser can interpret ! as a tag indicator. This fails:
if: !contains(github.event.pull_request.labels.*.name, 'skip-ci')
Because YAML sees !contains(...) as a type tag. You need the brackets:
if: ${{ !contains(github.event.pull_request.labels.*.name, 'skip-ci') }}
Rule of thumb: Use bare expressions for simple conditions. Use ${{ }} when your expression starts with ! or when you’re composing expressions with other strings (like in run: or env:).
Outside if: — always use ${{ }}:
env:
IS_MAIN: ${{ github.ref == 'refs/heads/main' }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: echo "Deploying SHA ${{ github.sha }}"
In run:, env:, with:, and everywhere else, ${{ }} is required. The auto-wrapping is an if:-only convenience.
Multiline if: with >-
For complex conditions, use YAML’s folded block scalar:
if: >-
inputs.caller == 'pr-ci'
&& needs.find_changed.outputs.has_tests == 'true'
&& needs.calculate_shards.outputs.shard_count > 0
The >- folds the lines into a single line with spaces, and strips the trailing newline. This is how our GPU test jobs express “only run if we’re in PR context AND there are changed tests AND there are shards to run.”
Watch the types. Job outputs are always strings. needs.calculate_shards.outputs.shard_count > 0 works because GitHub coerces the string "3" to a number for comparison. But needs.find_changed.outputs.has_tests == 'true' needs the string comparison — == true (boolean) would fail because the output is the string "true", not the boolean true.
Truthy/falsy values
GitHub Actions expressions treat these as falsy: false, 0, -0, "" (empty string), null. Everything else is truthy, including the string "false" — which is a non-empty string, so it’s truthy.
# If some-output is the string "false":
if: needs.check.outputs.some-output # TRUE! (non-empty string)
if: needs.check.outputs.some-output == 'true' # false (string comparison)
Always use explicit string comparisons for outputs. Never rely on truthy evaluation for strings that might be "false".
concurrency — preventing double runs
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: true
Concurrency groups are how you prevent the same workflow from running twice for the same context. When a new run starts with the same group key, it either queues (if cancel-in-progress: false) or cancels the existing run (if cancel-in-progress: true).
Group key patterns
The group key determines “what counts as the same thing.”
| Pattern | Group key | What it does |
|---|---|---|
| Per-PR | ${{ github.workflow }}-${{ github.ref }} |
One run per PR branch. New push cancels old run. |
| Per-PR, per-event | ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} |
Separates PR events from push events on the same ref. |
| Per-job | ${{ github.workflow }}-${{ github.ref }}-gpu-tests |
Lets lint and GPU tests have independent concurrency. |
| Global | deploy-production |
Only one deploy at a time, regardless of branch. |
Our standard pattern is per-workflow-per-event-per-ref:
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: true
The github.event_name in the key is subtle but important. Without it, a workflow_dispatch run and a push run on the same branch would collide — one would cancel the other. With it, they’re in different groups.
cancel-in-progress — when yes, when no
| Workflow | cancel-in-progress |
Why |
|---|---|---|
| PR CI | true |
New push means old run is stale. Save resources. |
| Main CI | false |
Every commit to main must be verified. Canceling a main build means you might miss the commit that broke things. |
| Merge Queue CI | false |
The merge queue needs a definitive pass/fail. Canceling would kick the PR out of the queue. |
| Deploy | false |
Half-finished deploys are worse than queued deploys. |
Workflow-level vs job-level concurrency
You can set concurrency at both the workflow level and the job level. Job-level concurrency lets different jobs within the same workflow have independent concurrency groups:
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
# No job-level concurrency — uses workflow-level
runs-on: ubuntu-latest
steps: ...
gpu-tests:
uses: ./.github/workflows/run_gpu_tests.yml
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-gpu-tests
cancel-in-progress: true
With this setup, if you push twice quickly: the lint job from the first run gets cancelled (workflow-level concurrency). The GPU test from the first run also gets cancelled, but independently (job-level concurrency). This matters when lint finishes fast and GPU tests are slow — you might want lint from the second push to start immediately without waiting for GPU tests from the first push to finish cancelling.
The needs: dependency graph
needs: creates hard dependencies between jobs. A job won’t start until all its needs have completed.
jobs:
lint:
runs-on: ubuntu-latest
steps: ...
tests:
needs: [lint]
runs-on: ubuntu-latest
steps: ...
deploy:
needs: [lint, tests]
runs-on: ubuntu-latest
steps: ...
deploy waits for both lint and tests. If either fails, deploy is skipped (not failed — skipped).
Status functions
By default, a job only runs if all needs succeeded. You can override this with status functions in if::
| Function | Meaning |
|---|---|
success() |
All needs succeeded (default if no if: specified) |
failure() |
At least one need failed |
always() |
Run regardless of needs status — even if cancelled |
cancelled() |
The workflow was cancelled |
cleanup:
needs: [build, test, deploy]
if: always()
runs-on: ubuntu-latest
steps:
- run: echo "Cleaning up regardless of what happened above"
always() is powerful and dangerous. It runs even if the workflow is cancelled. If you have a cleanup job with if: always() that takes 10 minutes, cancelling the workflow doesn’t cancel that job — it will run to completion.
The status aggregation pattern
This is how you report a single pass/fail for a set of required jobs. Essential for merge queue workflows where GitHub needs one “check” to pass or fail:
all-done:
runs-on: ubuntu-latest
if: always()
needs:
- lint
- cpu-tests
steps:
- name: Check for failures
if: >-
contains(needs.*.result, 'failure') ||
contains(needs.*.result, 'cancelled')
run: exit 1
- name: All required CI jobs passed
run: echo "All done."
needs.*.result is an array of result strings: "success", "failure", "cancelled", or "skipped". The contains() function checks if any element matches.
The if: always() on the job is critical. Without it, if lint fails, all-done would be skipped — which reads as “skipped,” not “failed.” With always(), the job runs and explicitly fails with exit 1, giving the merge queue a clear signal.
Why not just make the merge queue require lint and cpu-tests directly? You can. But with reusable workflows and dynamic job matrices, the job names can be unpredictable. A single aggregation job with a stable name is easier to configure as a required check.
Context objects across trigger types
This is the table I actually keep bookmarked. What’s available where:
| Context | pull_request |
push |
merge_group |
workflow_dispatch |
schedule |
workflow_call |
|---|---|---|---|---|---|---|
github.sha |
Merge commit | Push HEAD | MQ head SHA | Branch HEAD | Default branch HEAD | Inherited |
github.ref |
refs/pull/N/merge |
refs/heads/X |
refs/heads/gh-readonly-queue/... |
Branch ref | refs/heads/main |
Inherited |
github.event_name |
pull_request |
push |
merge_group |
workflow_dispatch |
schedule |
Caller’s event |
github.event.pull_request |
PR object | – | – | – | – | If caller is PR |
github.event.merge_group |
– | – | MQ object | – | – | If caller is MQ |
github.event.inputs |
– | – | – | Input strings | – | – |
inputs.* |
– | – | – | Typed inputs | – | workflow_call inputs |
The workflow_call column is the tricky one. It inherits the caller’s context. github.sha, github.ref, github.event_name — all come from the caller. But inputs.* comes from the workflow_call definition, not the original event. This is why the caller pattern works: inputs.caller is always available in the called workflow regardless of what event triggered the caller.
Getting the PR number from different contexts
A common need: you want the PR number for a status comment or artifact naming.
# In pull_request context — easy
pr-number: ${{ github.event.pull_request.number }}
# In merge_group context — not directly available
# The ref looks like: refs/heads/gh-readonly-queue/main/pr-123-<hash>
# You'd need to parse it or use the API
# In push context (after merge) — not available at all
# The PR is already merged; you'd need to search via the API
This inconsistency is annoying but by design — push and merge_group aren’t “about” a PR, even though they might be caused by one.
The merge queue flow, end to end
Let me trace a PR through the entire CI pipeline to show how these triggers compose:
1. Developer pushes to PR branch
- Event:
pull_request(type:synchronize) - Triggers: PR CI workflow
- Runs: lint, CPU tests, GPU tests (after label gate)
- Context:
github.sha= merge commit,github.ref=refs/pull/123/merge
2. All checks pass, developer clicks “Merge when ready”
- PR enters the merge queue
- GitHub creates a temporary branch combining this PR with others in the queue
3. Merge queue tests the combined branch
- Event:
merge_group - Triggers: Merge Queue CI workflow
- Runs: lint, CPU tests only (no GPU tests — too expensive)
- Context:
github.sha= combined merge SHA,github.ref=refs/heads/gh-readonly-queue/main/pr-123-...
4. Merge queue checks pass
- GitHub fast-forward merges the PR into main
- Event:
pushto main - Triggers: Main CI workflow
- Runs: full CI suite (lint, CPU, GPU)
- Context:
github.sha= new main HEAD,github.ref=refs/heads/main
Three workflow files. Three trigger events. Same reusable workflow files underneath, differentiated by the caller input. The light merge queue workflow is the key design decision — it keeps the queue fast while the main-CI workflow provides the full verification post-merge.
Timing subtleties that will bite you
pull_request fires before the merge commit exists on remote
The merge commit SHA in github.sha is ephemeral. It exists in the runner’s checkout but not in the remote repository. If you try to git fetch origin ${{ github.sha }}, it won’t work. It’s a synthetic merge that GitHub creates just for the CI run.
push to main fires after merge, not during
The push event fires after the commits are on the branch. By the time your workflow starts, main has already moved. If two PRs merge in quick succession, your main-CI for the first merge might check out a SHA that already has the second merge on top of it — unless you pin the checkout to the specific SHA.
- uses: actions/checkout@v4
with:
ref: ${{ github.sha }} # Pin to the specific push, not HEAD
merge_group can fire multiple times for the same PR
If a PR is kicked out of the merge queue (CI failure) and re-enqueued, it gets a new merge_group event with a potentially different base — other PRs may have merged in the meantime. The SHA is different, the combined diff is different. Don’t cache based on PR number alone.
schedule doesn’t guarantee exact timing
The cron fires “around” the specified time. During high-load periods (top of the hour, Monday mornings), delays of 10-30 minutes are normal. Don’t build alerting that assumes your 0 */2 * * * runs at exactly :00.
Cheat sheet
Trigger quick reference
| Trigger | Fires when | github.sha |
github.ref |
github.event_name |
|---|---|---|---|---|
push |
Commits pushed to branch | Push HEAD | refs/heads/X |
push |
pull_request |
PR opened/updated/etc. | Merge commit | refs/pull/N/merge |
pull_request |
merge_group |
PR enters merge queue | MQ head SHA | refs/heads/gh-readonly-queue/... |
merge_group |
workflow_dispatch |
Manual “Run workflow” | Branch HEAD | Branch ref | workflow_dispatch |
schedule |
Cron fires | Default branch HEAD | refs/heads/main |
schedule |
workflow_call |
Called by another workflow | Inherited | Inherited | Caller’s event |
Expression syntax quick reference
| Context | Needs ${{ }}? |
Example |
|---|---|---|
if: |
No (auto-wrapped) | if: github.ref == 'refs/heads/main' |
if: with ! |
Yes | if: ${{ !cancelled() }} |
run: |
Yes | run: echo ${{ github.sha }} |
with: / env: |
Yes | env: { IS_MAIN: ${{ github.ref == 'refs/heads/main' }} } |
Concurrency patterns
| Workflow type | cancel-in-progress |
Group key includes |
|---|---|---|
| PR CI | true |
workflow + event + ref |
| Main CI | false |
workflow + event + ref |
| Merge Queue CI | false |
workflow + event + ref |
| Deploy | false |
static name (e.g., deploy-prod) |
Status functions
| Function | Runs when | Common use |
|---|---|---|
success() |
All needs passed | Default (implicit) |
failure() |
Any need failed | Notification, cleanup |
always() |
Always, even if cancelled | Status aggregation, cleanup |
cancelled() |
Workflow cancelled | Graceful shutdown |
The one-sentence summaries
push— fires on commits to a branch. The SHA is real.pull_request— fires on PR events. The SHA is a synthetic merge commit. Use.head.shafor the real branch tip.merge_group— fires when a PR enters the merge queue. No PR object in context. Lighter CI is the right call.workflow_call— makes a workflow reusable. Inherits the caller’s context. Use thecallerinput pattern to differentiate behavior.workflow_dispatch— manual trigger. Add it to every workflow. Input types are different fromworkflow_calltypes.schedule— cron in UTC. GitHub might delay it. Only runs on the default branch.if:— auto-wraps in${{ }}, except when you use!. Job outputs are strings."false"is truthy.concurrency— group key determines what counts as duplicate.cancel-in-progress: truefor PRs,falsefor main.needs:+always()— the status aggregation pattern. One job to rule them all.
That’s the full picture. Bookmark this, come back when your workflow fires at the wrong time or with the wrong SHA, and ctrl-F your way to the answer.