name: Node Pull Request # Checks for a Node, Vue or Astro project: install without touching the lockfile, # then run lint, typecheck, test and build. A script that does not exist is skipped. on: workflow_call: inputs: node-version: description: "Node.js version to use" required: false type: string default: "22" working-directory: description: "Directory that holds package.json" required: false type: string default: "." workspace: description: "The project is a pnpm workspace. working-directory then holds pnpm-workspace.yaml and pnpm-lock.yaml, and needs no package.json. Dependencies install once there, and scripts run in each of packages." required: false type: boolean default: false packages: description: "With workspace, comma-separated workspace package directories, relative to working-directory." required: false type: string default: "" scripts: description: "Comma-separated package.json scripts to run, in order. A script that does not exist is skipped." required: false type: string default: "lint,typecheck,test,build" pnpm-version: description: "pnpm version to use, for a repo whose package.json has no packageManager field. Empty uses the packageManager field, or the newest pnpm." required: false type: string default: "" require-tests: description: "Fail when package.json has no test script" required: false type: boolean default: false lfs: description: "Enable Git LFS checkout (for repos with large files)" required: false type: boolean default: false npm-registry-scope: description: "Scope served by a private npm registry, for example '@fortawesome'. Used only when npm-registry-url and the npm-registry-token secret are also set." required: false type: string default: "" npm-registry-url: description: "URL of the private npm registry, for example 'https://npm.fontawesome.com/'. Used only when npm-registry-scope and the npm-registry-token secret are also set." required: false type: string default: "" runner: description: "Runner label, or JSON object such as {\"group\":\"ci\",\"labels\":[\"linux\"]} to select a runner group. Falls back to vars.SHARED_ACTIONS_RUNNER, then ubuntu-latest." required: false type: string default: "" secrets: npm-registry-token: description: "Auth token for the private npm registry" required: false concurrency: group: node-pr-${{ github.repository }}-${{ github.event_name }}-${{ github.head_ref || github.ref_name }}-${{ inputs.working-directory }} cancel-in-progress: true jobs: pre: name: Detect duplicate run runs-on: ${{ fromJSON(startsWith(inputs.runner || vars.SHARED_ACTIONS_RUNNER || 'ubuntu-latest', '{') && (inputs.runner || vars.SHARED_ACTIONS_RUNNER || 'ubuntu-latest') || format('"{0}"', inputs.runner || vars.SHARED_ACTIONS_RUNNER || 'ubuntu-latest')) }} timeout-minutes: 5 permissions: pull-requests: read outputs: skip: ${{ steps.dup.outputs.skip }} steps: # A push to a branch with an open PR also fires pull_request; run only the PR one. - name: Skip push run when a PR is open id: dup uses: actions/github-script@v9 with: script: | let skip = false; if (context.eventName === 'push') { try { const { data } = await github.rest.pulls.list({ owner: context.repo.owner, repo: context.repo.repo, head: `${context.repo.owner}:${context.ref.replace('refs/heads/', '')}`, state: 'open', }); skip = data.length > 0; } catch (e) { core.warning(`PR lookup failed, running checks: ${e.message}`); } } core.setOutput('skip', String(skip)); if (skip) core.notice('Open PR found for this branch; checks run on the pull_request event.'); checks: name: Checks (${{ inputs.working-directory }}) needs: pre if: needs.pre.outputs.skip != 'true' runs-on: ${{ fromJSON(startsWith(inputs.runner || vars.SHARED_ACTIONS_RUNNER || 'ubuntu-latest', '{') && (inputs.runner || vars.SHARED_ACTIONS_RUNNER || 'ubuntu-latest') || format('"{0}"', inputs.runner || vars.SHARED_ACTIONS_RUNNER || 'ubuntu-latest')) }} timeout-minutes: 30 permissions: contents: read steps: - name: Checkout uses: actions/checkout@v7 with: lfs: ${{ inputs.lfs }} - name: Run workflow prepare script shell: bash run: | if [ -f "./workflow-prepare.sh" ]; then chmod +x ./workflow-prepare.sh ./workflow-prepare.sh fi - name: Detect the package manager id: detect working-directory: ${{ inputs.working-directory }} env: WORKSPACE: ${{ inputs.workspace }} PACKAGES: ${{ inputs.packages }} run: | if [ "$WORKSPACE" = true ]; then if [ ! -f pnpm-workspace.yaml ] || [ ! -f pnpm-lock.yaml ]; then echo "::error::workspace needs pnpm-workspace.yaml and pnpm-lock.yaml in ${{ inputs.working-directory }}." exit 1 fi count=0 IFS=',' read -r -a packages <<<"$PACKAGES" for package in "${packages[@]}"; do package=$(echo "$package" | xargs) [ -n "$package" ] || continue if [ ! -f "$package/package.json" ]; then echo "::error::packages lists $package, but ${{ inputs.working-directory }}/$package/package.json does not exist." exit 1 fi count=$((count + 1)) done if [ "$count" -eq 0 ]; then echo "::error::workspace needs packages: the packages to check." exit 1 fi echo "pm=pnpm" >> "$GITHUB_OUTPUT" exit 0 fi if [ ! -f package.json ]; then echo "::error::No package.json in ${{ inputs.working-directory }}." exit 1 fi pm=npm if [ -f pnpm-lock.yaml ]; then pm=pnpm elif [ -f yarn.lock ]; then pm=yarn fi echo "pm=$pm" >> "$GITHUB_OUTPUT" - name: Setup Node.js uses: actions/setup-node@v7 with: node-version: ${{ inputs.node-version }} # The npmrc lives in RUNNER_TEMP, not the checkout, so no git command can pick the token up. - name: Configure the private npm registry env: NPM_SCOPE: ${{ inputs.npm-registry-scope }} NPM_URL: ${{ inputs.npm-registry-url }} NPM_TOKEN: ${{ secrets.npm-registry-token }} run: | [ -n "$NPM_SCOPE" ] && [ -n "$NPM_URL" ] && [ -n "$NPM_TOKEN" ] || exit 0 echo "::add-mask::$NPM_TOKEN" url="${NPM_URL%/}/" npmrc="$RUNNER_TEMP/private-registry.npmrc" (umask 077 && printf '%s:registry=%s\n//%s:_authToken=%s\n' "$NPM_SCOPE" "$url" "${url#*://}" "$NPM_TOKEN" > "$npmrc") echo "NPM_CONFIG_USERCONFIG=$npmrc" >> "$GITHUB_ENV" echo "Using a private npm registry for $NPM_SCOPE." - name: Install dependencies working-directory: ${{ inputs.working-directory }} env: PM: ${{ steps.detect.outputs.pm }} PNPM_VERSION: ${{ inputs.pnpm-version }} run: | # Install without updating the lockfile, so a lockfile that does not match package.json fails. case "$PM" in pnpm | yarn) command -v corepack >/dev/null || npm install --global corepack corepack enable "$PM" ;; esac if [ "$PM" = pnpm ] && [ -n "$PNPM_VERSION" ]; then corepack prepare "pnpm@$PNPM_VERSION" --activate fi case "$PM" in pnpm) pnpm install --frozen-lockfile ;; yarn) yarn install --immutable ;; npm) if [ -f package-lock.json ]; then npm ci; else npm install; fi ;; esac - name: Run scripts working-directory: ${{ inputs.working-directory }} env: PM: ${{ steps.detect.outputs.pm }} SCRIPTS: ${{ inputs.scripts }} REQUIRE_TESTS: ${{ inputs.require-tests }} WORKSPACE: ${{ inputs.workspace }} PACKAGES: ${{ inputs.packages }} run: | # A single project is the one package ".". A workspace is each listed package. packages=(.) if [ "$WORKSPACE" = true ]; then IFS=',' read -r -a packages <<<"$PACKAGES" fi if [ "$REQUIRE_TESTS" = true ]; then for package in "${packages[@]}"; do package=$(echo "$package" | xargs) [ -n "$package" ] || continue if ! jq -e '.scripts.test' "$package/package.json" >/dev/null; then echo "::error::$package/package.json has no test script." exit 1 fi done fi ran=() IFS=',' read -r -a scripts <<<"$SCRIPTS" for script in "${scripts[@]}"; do script=$(echo "$script" | xargs) [ -n "$script" ] || continue for package in "${packages[@]}"; do package=$(echo "$package" | xargs) [ -n "$package" ] || continue where="" [ "$package" = . ] || where=" ($package)" if jq -e --arg s "$script" '.scripts[$s]' "$package/package.json" >/dev/null; then echo "::group::$PM run $script$where" (cd "$package" && "$PM" run "$script") echo "::endgroup::" ran+=("$script$where") else echo "::notice::No '$script' script$where, skipping." fi done done if [ "${#ran[@]}" -eq 0 ]; then echo "::warning::None of the scripts ($SCRIPTS) exist in package.json, so nothing was checked." fi echo "Ran: ${ran[*]:-none} (${PM})" >> "$GITHUB_STEP_SUMMARY"