1.03 Events, Actions, and a Complete Test Workflow
Overview
Two things define every workflow: the event that triggers it, and what each step uses to do
its work — a run command you write, or an action someone packaged.
Abstract
Events (triggers) are set under on:. Most are repository-related (push, pull_request,
create, issues), the rest are not (workflow_dispatch, repository_dispatch, schedule,
workflow_call). Inside a job, a step is either a shell command via run or a prebuilt
application via uses. Because a runner is a clean machine that does not contain your
repository, a real CI job starts with actions/checkout, optionally pins a toolchain with
actions/setup-node, installs dependencies with npm ci, then runs the tests.
Why It Matters in Production
The trigger is your cost and access boundary. on: push unfiltered runs the whole pipeline on every
branch; pull_request on a public repo exposes runners to fork code; schedule spends minutes
whether anything changed or not.
The run vs action choice is a supply-chain decision. Hand-written shell is transparent but yours
to maintain; an action is a third-party dependency executing inside CI with full access to the
workspace and any secrets in scope.
Pinning toolchain versions matters too. Runner images are updated continuously, so a pipeline that relies on whatever Node.js happens to be preinstalled will change behavior without a single commit on your side.
Key Concepts
Events
| Event | Fires when | Category |
|---|---|---|
push |
A commit is pushed to the repository | Repository |
pull_request |
A PR is opened, closed, synchronized, … | Repository |
create |
A branch or tag is created | Repository |
fork |
The repository is forked | Repository |
issues |
An issue is opened, closed, deleted, … | Repository |
issue_comment |
A comment on an issue or PR is created/edited | Repository |
watch |
The repository is starred | Repository |
discussion |
A discussion is created, deleted, answered | Repository |
workflow_dispatch |
A human triggers the workflow manually | Other |
repository_dispatch |
A REST API request triggers the workflow | Other |
schedule |
A cron expression matches | Other |
workflow_call |
Another workflow calls this one as reusable | Other |
Most events have activity types — issues covers opened, closed, labeled, deleted;
pull_request covers opened, closed, reopened, synchronize. Narrow them with types:.
Note
The authoritative list is GitHub's Events that trigger workflows reference. An unrecognized event key does not error — the workflow simply never fires.
Commands vs Actions
Command (run) |
Action (uses) |
|
|---|---|---|
| What it is | A shell command defined by you | An application performing a complex, repeated task |
| Typical complexity | Simple, though it can call scripts | Complex, packaged and versioned |
| Where it lives | Your YAML file | A repository, browsable via the Marketplace |
| Source options | Only yours | Official (actions/*), community, or your own |
| Example | run: npm ci |
uses: actions/checkout@v4 |
Runners do not contain your code
Jobs run on machines owned by GitHub — or by you, for self-hosted runners. They are related to the repository but do not run inside it; the repository is a folder on a disk somewhere else. Any step that needs your source must be preceded by a checkout step.
The two actions used in almost every Node.js pipeline
| Action | Purpose | Key inputs |
|---|---|---|
actions/checkout |
Clones the repository onto the runner | fetch-depth, ref, submodules |
actions/setup-node |
Installs and activates a specific Node.js version | node-version, cache |
The GitHub Marketplace is the central catalogue for actions. It lists both Apps (a separate
GitHub feature) and Actions; the actions are free. A verified creator badge means GitHub
confirmed the publisher's identity — actions/checkout and actions/setup-node are maintained by
the GitHub team itself.
Check what is already on the runner
GitHub publishes the preinstalled software for each runner image — see the Supported software
/ Preinstalled software pages, or the
runner-images repository. ubuntu-latest ships with
Node.js, npm, Git, Docker, Python, and much more, so setup-node is only strictly required when
you need a version other than the preinstalled one.
Common Use Cases
push— run unit tests and linting on every commit pushed to the remotepull_request— gate merges on a green build via required status checksworkflow_dispatch— manual deploys, one-off maintenance, re-running during an incidentschedule— nightly dependency audits, backups, stale-issue cleanuprepository_dispatch— start a workflow from an external system (CMS publish, ops tooling)workflow_call— share one test or deploy pipeline across many repositoriesissues/issue_comment— triage automation: auto-label, greet first-time contributors
Example Configuration or Commands
The complete four-step test workflow for a React (Vite) project:
# .github/workflows/test.yml
name: Test Project
on: push
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Get code
uses: actions/checkout@v4 # action — clones the repo onto the runner
- name: Install NodeJS
uses: actions/setup-node@v4 # action — pins the toolchain version
with:
node-version: 18
- name: Install dependencies
run: npm ci # command — installs from package-lock.json
- name: Run tests
run: npm test # command — the actual goal of the workflow
Creating it locally — .github and workflows are mandatory names, the file name is yours:
# Structure is identical whether authored in the browser or locally
mkdir -p .github/workflows
touch .github/workflows/test.yml
git add .github/workflows/test.yml
git commit -m "Add test workflow"
git push origin main # the push itself triggers the workflow
Trigger syntax
Toolchain setup
Dependency install: npm ci vs npm install
npm ci |
npm install |
|
|---|---|---|
| Source of truth | package-lock.json only |
package.json, may update the lockfile |
| Version drift | None — exact locked versions | Possible, within semver ranges |
| Missing/stale lockfile | Fails loudly | Silently proceeds |
node_modules |
Deleted and reinstalled clean | Reconciled in place |
| Use in CI | Yes | Avoid |
Multiple runtime versions in one job definition
A matrix expands one job into several parallel runs — useful for verifying a library across supported Node.js versions:
Best Practices
Best Practices
- Pick the narrowest trigger that meets the goal:
pushfiltered tomainpluspull_requestcovers most CI needs. - Use
paths:filters in monorepos so unrelated changes don't run every pipeline. - Keep
workflow_dispatchalongside automatic triggers so you can always re-run by hand. - Pin the toolchain with
setup-nodeeven when the runner already has Node — explicit beats inherited, and the version becomes reviewable in Git. - Store the version in
.nvmrcand reference it withnode-version-fileso local and CI match. - Use
npm ci, nevernpm install, in CI. - Turn on
cache: 'npm'insetup-node— it cuts install time substantially at no configuration cost. - Prefer official actions for standard tasks; use
runfor logic specific to your project. - Name every step so the run log reads as a sequence of intentions.
- Keep checkout first in any job that touches source code.
Security Best Practices
Security
- Pin third-party actions to a full commit SHA. Tags like
@v3are mutable and can be repointed at new code by the maintainer or by whoever compromises the account. Major-version tags fromactions/*are a reasonable exception; anything else, pin the SHA. - The verified badge confirms publisher identity, not code safety. Read the source of any action you give secrets to.
- Actions execute with full access to the workspace, environment, and secrets in scope — adding one is exactly as significant as adding a production dependency.
- Declare least-privilege
permissions:;contents: readis enough for a test pipeline. - Never run untrusted code on triggers that carry secrets —
pull_request_target,issue_comment, andworkflow_runare the dangerous ones. - Do not interpolate event data into shell.
run: echo "${{ github.event.issue.title }}"is a script-injection hole; pass it throughenv:and reference"$TITLE". npm cirespects the lockfile, which is also a security property: no surprise transitive upgrade lands mid-pipeline. Pair it withnpm auditor Dependabot.- Restrict which actions may run at all under Settings → Actions → General.
Do and Don't
| ✅ Do | ❌ Don't |
|---|---|
| Check the events reference for exact names and types | Invent an event name and hope it fires |
Filter push by branch and path |
Leave on: push unfiltered on a busy repo |
Start code-touching jobs with actions/checkout |
Assume the runner already has your files |
Pin Node with setup-node |
Depend on whatever the runner image ships today |
Run npm ci |
Run npm install in CI |
| Pin third-party actions to a SHA | Trust a floating @v1 from an unknown author |
| Use official actions for standard tasks | Re-implement git clone by hand |
Enable cache: 'npm' |
Re-download the dependency tree on every run |
Declare permissions explicitly |
Rely on the default token scope |
Common Mistakes
Common Mistakes
- Misnamed folders.
.github/workflows/is mandatory and exact;.github/workflow/is ignored with no error. - Skipping checkout. Steps fail with "no such file or directory" or a missing
package.json, because the runner is empty. npm installin CI. Resolves versions afresh, so CI can pass on dependency versions the lockfile never pinned.npm ciwithout a committed lockfile. It fails by design — commitpackage-lock.json.with:indented wrong. It belongs to the step, at the same level asuses; nesting it underrunor mis-indentingnode-versionproduces "Unexpected value".- Quoting Node versions inconsistently.
node-version: 18is fine, but18.0in YAML is a float that loses the trailing zero — quote patch-level versions:'18.20.0'. - Assuming activity-type defaults match intent.
pull_requestfires onsynchronize, so every new commit on the PR re-runs everything. - Scheduled workflows on a non-default branch.
scheduleonly runs the default branch's copy of the file, and cron runs can be delayed under load.
Breaks in production
A pipeline with no pinned Node version and no lockfile install passes for months, then fails — or worse, ships — the day the runner image bumps its default major version. Nothing in your Git history explains it.
Troubleshooting
# Which workflows has GitHub registered, and are they active?
gh workflow list --all
# What event triggered each recent run?
gh run list --limit 10 --json displayTitle,event,status,conclusion
# Inspect only the failing step's log
gh run view --log-failed
# Confirm which Node and npm the runner actually used — add as a temporary step
node --version && npm --version && npm config get cache
| Symptom | Likely cause |
|---|---|
| Workflow never runs on push | Branch/path filter excludes the commit, or file not on that branch |
| Runs twice per PR commit | Both push and pull_request match — filter push to main |
| "No such file or directory" early on | Missing actions/checkout |
npm ci fails: lockfile out of sync |
package.json and package-lock.json diverged — regenerate and commit |
| Tests pass locally, fail in CI | Different Node version — pin it with setup-node |
Unable to resolve action |
Wrong owner/repo@ref, private action, or deleted tag |
| Cache never hits | No package-lock.json at the expected path, or lockfile changes every run |
| Action blocked from running | Organization/repository actions allowlist policy |
Tip
Set ACTIONS_STEP_DEBUG=true as a repository secret for verbose step logs, and dump the trigger
payload once with run: echo '${{ toJSON(github.event) }}' in a scratch workflow when you need
to filter on event data.
Quick Recap
on:defines the triggering event(s); most events also have activity types filtered viatypes:.- Repository events:
push,pull_request,create,fork,issues,issue_comment,watch,discussion, and more. Other events:workflow_dispatch,repository_dispatch,schedule,workflow_call. .github/andworkflows/are fixed folder names; the workflow file name is free.- A step is either a
runcommand you wrote or ausesaction someone packaged. - Runners don't have your code —
actions/checkoutfirst. ubuntu-latestships Node.js preinstalled, butactions/setup-nodepins the version explicitly.npm ciinstalls exactly what the lockfile specifies;npm installmay drift.- The Marketplace catalogues free official, community, and custom actions; verified means the publisher is identified, not audited.
Interview / Revision Notes
Quick revision
Q: Event vs trigger?
Same thing — defined under on:.
Q: Name the non-repository events.
workflow_dispatch, repository_dispatch, schedule, workflow_call.
Q: run vs uses?
run executes a shell command you wrote; uses invokes a packaged action.
Q: Why is actions/checkout needed?
The runner is a clean machine separate from the repository and has no code until you fetch it.
Q: Is setup-node always required?
No — ubuntu-latest has Node preinstalled. Use it to pin a specific version and to enable
dependency caching.
Q: How do you know what's preinstalled on a runner?
GitHub's preinstalled-software pages per runner image (the actions/runner-images repo).
Q: npm ci vs npm install?
ci installs strictly from package-lock.json and fails if it's missing or out of sync;
install may resolve new versions. Use ci in CI.
Q: How do you pass configuration to an action?
The with: block, e.g. node-version: 18 for setup-node.
Q: What does the verified badge on the Marketplace mean? GitHub verified the publisher's identity — not that the code is safe.
Q: How do you test across several Node versions?
A strategy.matrix on node-version, referenced as ${{ matrix.node-version }}.
This topic can be expanded into sub-pages: advanced event filters, activity types per event, building custom actions (composite, JavaScript, Docker), caching strategies, matrix builds, and supply-chain security for third-party actions.