Skip to content

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 remote
  • pull_request — gate merges on a green build via required status checks
  • workflow_dispatch — manual deploys, one-off maintenance, re-running during an incident
  • schedule — nightly dependency audits, backups, stale-issue cleanup
  • repository_dispatch — start a workflow from an external system (CMS publish, ops tooling)
  • workflow_call — share one test or deploy pipeline across many repositories
  • issues / 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

on: push
on:
  push:
    branches:
      - main
      - 'feature/**'
    paths:
      - 'src/**'
      - 'package.json'
on:
  pull_request:
    types: [opened, reopened, synchronize]
    branches: [main]
on:
  push:
    branches: [main]
  pull_request:
  workflow_dispatch:
  schedule:
    - cron: '0 3 * * *'   # daily at 03:00 UTC

on:
  repository_dispatch:
    types: [deploy-requested]
curl -X POST \
  -H "Accept: application/vnd.github+json" \
  -H "Authorization: Bearer $GITHUB_TOKEN" \
  https://api.github.com/repos/OWNER/REPO/dispatches \
  -d '{"event_type":"deploy-requested"}'

Toolchain setup

# Works, but the version drifts as runner images are updated
- uses: actions/checkout@v4
- run: npm ci
- run: npm test
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
  with:
    node-version: 18
- run: npm ci
- run: npm test
# Single source of truth: .nvmrc or the engines field in package.json
- uses: actions/setup-node@v4
  with:
    node-version-file: '.nvmrc'
- uses: actions/setup-node@v4
  with:
    node-version: 18
    cache: 'npm'        # caches ~/.npm keyed on package-lock.json
- run: npm ci

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:

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        node-version: [18, 20, 22]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'
      - run: npm ci
      - run: npm test

Best Practices

Best Practices

  • Pick the narrowest trigger that meets the goal: push filtered to main plus pull_request covers most CI needs.
  • Use paths: filters in monorepos so unrelated changes don't run every pipeline.
  • Keep workflow_dispatch alongside automatic triggers so you can always re-run by hand.
  • Pin the toolchain with setup-node even when the runner already has Node — explicit beats inherited, and the version becomes reviewable in Git.
  • Store the version in .nvmrc and reference it with node-version-file so local and CI match.
  • Use npm ci, never npm install, in CI.
  • Turn on cache: 'npm' in setup-node — it cuts install time substantially at no configuration cost.
  • Prefer official actions for standard tasks; use run for 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 @v3 are mutable and can be repointed at new code by the maintainer or by whoever compromises the account.
    - uses: some-org/some-action@e9c2bfd66a0b3f37a1e5f1a2a51a8f69d07e3f56  # v3.1.0
    
    Major-version tags from actions/* 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: read is enough for a test pipeline.
  • Never run untrusted code on triggers that carry secrets — pull_request_target, issue_comment, and workflow_run are the dangerous ones.
  • Do not interpolate event data into shell. run: echo "${{ github.event.issue.title }}" is a script-injection hole; pass it through env: and reference "$TITLE".
  • npm ci respects the lockfile, which is also a security property: no surprise transitive upgrade lands mid-pipeline. Pair it with npm audit or 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 install in CI. Resolves versions afresh, so CI can pass on dependency versions the lockfile never pinned.
  • npm ci without a committed lockfile. It fails by design — commit package-lock.json.
  • with: indented wrong. It belongs to the step, at the same level as uses; nesting it under run or mis-indenting node-version produces "Unexpected value".
  • Quoting Node versions inconsistently. node-version: 18 is fine, but 18.0 in YAML is a float that loses the trailing zero — quote patch-level versions: '18.20.0'.
  • Assuming activity-type defaults match intent. pull_request fires on synchronize, so every new commit on the PR re-runs everything.
  • Scheduled workflows on a non-default branch. schedule only 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 via types:.
  • Repository events: push, pull_request, create, fork, issues, issue_comment, watch, discussion, and more. Other events: workflow_dispatch, repository_dispatch, schedule, workflow_call.
  • .github/ and workflows/ are fixed folder names; the workflow file name is free.
  • A step is either a run command you wrote or a uses action someone packaged.
  • Runners don't have your code — actions/checkout first.
  • ubuntu-latest ships Node.js preinstalled, but actions/setup-node pins the version explicitly.
  • npm ci installs exactly what the lockfile specifies; npm install may 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.