A composite action bundles steps behind an action.yml, so that "install Node.js 2,131 from .nvmrc, restore the npm 2,036 cache, run npm ci" becomes one line in every job. It lives in its own directory, here in the repository itself:
name: Set up BookNest
description: Install Node.js from .nvmrc and BookNest's locked dependencies
inputs:
omit-dev:
description: Leave out devDependencies
default: "false"
outputs:
node-version:
description: The Node.js version that was installed
value: ${{ steps.node.outputs.node-version }}
runs:
using: composite
steps:
- id: node
uses: actions/setup-node@v7
with: {node-version-file: .nvmrc, cache: npm}
- shell: bash
run: npm ci ${{ inputs.omit-dev == 'true' && '--omit=dev' || '' }}Inputs are always strings, hence the comparison with 'true'; the && ... || ... pair is the expression language's conditional. Every run step must name its shell, because a composite action has no defaults to inherit. The log above shows its steps nested under Run ./.github/actions/setup-booknest, and the job read its output as steps.setup.outputs.node-version (v24.21.0). A local action (uses: ./path) needs the repository checked out first; one in another repository is referenced as owner/repo/path@ref and needs no checkout. Composite actions may call other actions, but they cannot declare services, runs-on or environment: those belong to jobs, and so to reusable workflows.