Mastering the two main workflow keywords: run for executing shell commands and uses for using Marketplace actions, complete with a Node.js CI workflow example and how to choose a safe action version.

In episode 3 you could already write workflows and choose the right event trigger. Now we enter the heart of every job: steps. The two keywords that will grace almost all of your workflows are run and uses — they're the two different ways to fill a step.
run executes shell commands directly, while uses calls a ready-made action from GitHub Marketplace. Understanding when to use which, how to compose multi-line commands, set the working directory, and pick the right action version, is the skill that separates fragile workflows from professional ones.
run is the most direct way to execute commands on the runner. The simplest form — a single line:
steps:
- name: Jalankan unit test
run: npm testNote that the default shell on Linux runners is bash with -e and pipefail mode — if a command returns a non-zero exit code, the step is considered failed and the pipeline stops.
For several sequential commands, use a literal block with |:
steps:
- name: Install, lint, dan test
run: |
npm ci
npm run lint
npm testEach line under run: | is executed as a separate command in the same shell session. This is important: variables set on the first line can still be used on the following lines.
Sometimes the code you want to build isn't at the repository root — for example, a monorepo with frontend and backend folders. Use working-directory:
jobs:
frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build frontend
working-directory: ./frontend
run: |
npm ci
npm run buildworking-directory applies per step. If almost all steps in a job use the same directory, you can set it once as a default for the whole job via defaults:
jobs:
frontend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./frontend
shell: bash
steps:
- uses: actions/checkout@v4
- run: npm ciNow every run step in the frontend job automatically runs in ./frontend using the bash shell — without having to write it over and over.
run executes raw commands, but many jobs are better handed over to an action — ready-made code tested millions of times. The basic syntax is: uses: <owner>/<repository>@<version>.
The four most important actions you should know:
| Action | Function |
|---|---|
actions/checkout@v4 | Clones the repository to the runner — always the first step |
actions/setup-node@v4 | Installs the Node.js runtime, supports automatic npm cache |
actions/setup-python@v5 | Installs Python and pip, supports pip cache |
actions/setup-go@v5 | Installs the Go toolchain, supports module cache |
Example usage of setup-node with inputs:
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npmNotice the with section — that's how you send inputs to an action. The action receives parameters via with, runs its logic, and (if it has any) produces outputs that other steps can use. cache: npm makes this action automatically handle dependency caching — we'll dive deeper into caching in episode 8.
The part after the @ sign determines which version of the action is used. There are three ways:
| Method | Example | Characteristics |
|---|---|---|
| Tag | actions/checkout@v4 | Easy to read, stable major version — the default choice |
| Commit SHA | actions/checkout@1a2b3c4... | Immutable and safest for supply chain |
| Branch | actions/checkout@main | Uses the latest code — not for production |
The golden rule: tags for readability, SHA for extreme security. A tag like v4 could be moved by the action owner to point to a different commit — rare, but possible. For teams serious about supply chain security, pin actions to a full commit SHA and let a renovate bot update them.
Warning
Avoid referencing actions by branch like @main. Action code can change at any time without your knowledge, and the change could break workflows running in production. Always reference a major tag (@v4) or a full commit SHA.
Now let's assemble all the material into one real CI workflow for a Node.js project:
name: Node.js CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout kode
uses: actions/checkout@v4
- name: Setup Node.js 20
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install dependensi
run: npm ci
- name: Lint
run: npm run lint
- name: Unit test
run: npm testYou can see a consistent pattern that you'll meet in almost every professional repository: checkout → setup runtime → install dependencies → lint → test. Every change on push or pull request automatically goes through this process, and its status is directly visible in the Checks tab of every PR.
Tip
Use npm ci (not npm install) in workflows. npm ci performs a deterministic installation based on package-lock.json — the result is faster, more stable, and avoids accidental lockfile changes.
setup-node action need repository contents? No — in fact setup-node does not clone code. Without actions/checkout@v4 as the first step, the runner is empty and npm ci will fail.setup-node@v4 is the version of the action; node-version: 20 is the version of Node.js installed.run without a correct exit code. If your script does exit 0 even when there's an error, the workflow still counts as successful. Make sure the script truly returns the appropriate exit code.In episode 4 you mastered the contents of every job:
run for shell commands: single-line, multi-line with |, working-directory, and defaults.run.shell for job-wide defaults.uses for Marketplace actions: checkout, setup-node, setup-python, and setup-go, complete with inputs via with.Your steps are now truly well-structured. In episode 5 we'll add the next most important capability: variables, contexts, and environment variables — how your workflow can use context data like who sent the commit, store configuration in vars, and pass values between steps with GITHUB_ENV and GITHUB_OUTPUT. See you in episode 5!