mirror of
https://github.com/n8n-io/n8n.git
synced 2026-07-28 19:45:09 +02:00
277 lines
14 KiB
YAML
277 lines
14 KiB
YAML
# This action works transparently on both Blacksmith and GitHub-hosted runners.
|
|
# Blacksmith runners benefit from transparent caching and optional Docker layer caching.
|
|
# GitHub-hosted runners use standard GitHub Actions caching.
|
|
|
|
name: 'Node.js Build Setup'
|
|
description: 'Configures Node.js with pnpm, installs Aikido SafeChain for supply chain protection, installs dependencies, enables Turborepo caching, (optional) sets up Docker layer caching, and builds the project or an optional command.'
|
|
|
|
inputs:
|
|
node-version:
|
|
description: 'Node.js version to use. Pinned to 24.16.0 by default for reproducible builds.'
|
|
required: false
|
|
default: '24.16.0'
|
|
enable-docker-cache:
|
|
description: 'Whether to set up Blacksmith Buildx for Docker layer caching (Blacksmith runners only).'
|
|
required: false
|
|
default: 'false'
|
|
build-command:
|
|
description: 'Command to execute for building the project or an optional command. Leave empty to skip build step.'
|
|
required: false
|
|
default: 'pnpm build'
|
|
install-command:
|
|
description: 'Command to execute for installing project dependencies. Leave empty to skip install step.'
|
|
required: false
|
|
default: 'pnpm install --frozen-lockfile'
|
|
cache-dependency-path:
|
|
description: 'Path(s) to the lockfile(s) used to compute the pnpm-store cache key. Scope this down (e.g. to `.github/scripts/pnpm-lock.yaml`) when only installing a subset, to avoid restoring the full workspace store.'
|
|
required: false
|
|
default: 'pnpm-lock.yaml'
|
|
|
|
runs:
|
|
using: 'composite'
|
|
steps:
|
|
- name: Setup pnpm
|
|
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
|
|
|
# Cache the Node toolcache so setup-node skips the ~33s nodejs.org download
|
|
# on every subsequent job. First fresh runner pays the download; later jobs
|
|
# (including parallel E2E shards) hit the cache. Blacksmith transparently
|
|
# routes actions/cache through its S3 backend.
|
|
#
|
|
# The `x64.complete` sibling marker file must be cached alongside the
|
|
# toolchain — without it, setup-node's `tc.find` returns empty and the
|
|
# action re-downloads from nodejs.org. Re-downloads also keep the silent
|
|
# fall-through window open: errors during download/extract are swallowed,
|
|
# PATH is left untouched, and the runner image's baked-in Node 20.20.0
|
|
# quietly takes over. Caching the parent version dir captures the marker
|
|
# alongside the toolchain. Key bumped to v2 to invalidate stale entries.
|
|
- name: Restore Node.js Toolcache
|
|
if: runner.os == 'Linux' && runner.arch == 'X64'
|
|
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
|
with:
|
|
path: /opt/hostedtoolcache/node/${{ inputs.node-version }}
|
|
key: node-toolcache-v2-${{ runner.os }}-${{ runner.arch }}-${{ inputs.node-version }}
|
|
|
|
- name: Setup Node.js
|
|
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
|
with:
|
|
node-version: ${{ inputs.node-version }}
|
|
cache: 'pnpm'
|
|
cache-dependency-path: ${{ inputs.cache-dependency-path }}
|
|
|
|
# Fail fast if setup-node silently fell through to the runner's baked-in
|
|
# Node instead of activating the requested version.
|
|
# see: https://github.com/actions/setup-node/issues/1137
|
|
- name: Verify Node.js Version
|
|
shell: bash
|
|
env:
|
|
EXPECTED_NODE_VERSION: ${{ inputs.node-version }}
|
|
run: |
|
|
ACTUAL_NODE_VERSION="$(node --version)"
|
|
if [ "${ACTUAL_NODE_VERSION#v}" != "${EXPECTED_NODE_VERSION#v}" ]; then
|
|
echo "::error::setup-node did not activate Node ${EXPECTED_NODE_VERSION} (got ${ACTUAL_NODE_VERSION})"
|
|
exit 1
|
|
fi
|
|
|
|
# To avoid setup-node cache failure.
|
|
# see: https://github.com/actions/setup-node/issues/1137
|
|
- name: Verify PNPM Cache Directory
|
|
shell: bash
|
|
run: |
|
|
PNPM_STORE_PATH="$( pnpm store path --silent )"
|
|
if [ ! -d "$PNPM_STORE_PATH" ]; then
|
|
mkdir -p "$PNPM_STORE_PATH"
|
|
fi
|
|
|
|
- name: Configure SafeChain
|
|
shell: bash
|
|
run: |
|
|
# SafeChain only reads configs from this directory https://github.com/AikidoSec/safe-chain#configuration-options-1
|
|
mkdir -p "$HOME/.safe-chain"
|
|
cp "${{ github.action_path }}/safe-chain.config.json" "$HOME/.safe-chain/config.json"
|
|
|
|
# Cache the SafeChain binary keyed on version + platform. The binary path
|
|
# is deterministic, so subsequent jobs across the CI fanout (E2E shards,
|
|
# docker-cluster, unit, lint, typecheck, ...) hit the cache instead of
|
|
# the GH release CDN. Layered with retry below — cache reduces blast
|
|
# radius across jobs, retry covers the first-job-per-key case where the
|
|
# CDN must be hit. Keep the version in sync with the download step below.
|
|
- name: Restore Aikido SafeChain Binary
|
|
id: cache-safe-chain
|
|
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
|
with:
|
|
path: ~/.safe-chain/bin
|
|
key: safe-chain-1.5.7-${{ runner.os }}-${{ runner.arch }}
|
|
|
|
# Download + install the SafeChain binary. This is the expensive,
|
|
# CDN-backed part and the only part worth gating on the cache. The install
|
|
# script also runs `setup-ci`, so on a cache miss this both downloads AND
|
|
# activates; the Activate step below then re-runs `setup-ci` idempotently.
|
|
- name: Download Aikido SafeChain
|
|
if: steps.cache-safe-chain.outputs.cache-hit != 'true'
|
|
run: |
|
|
VERSION="1.5.7"
|
|
EXPECTED_SHA256="07ab512fd8795ce41b2275be369aced4c9a93cc7bca9b397951507891a955239"
|
|
node .github/scripts/retry.mjs --attempts 3 --delay 10 -- \
|
|
curl -fsSL -o install-safe-chain.sh "https://github.com/AikidoSec/safe-chain/releases/download/${VERSION}/install-safe-chain.sh"
|
|
echo "${EXPECTED_SHA256} install-safe-chain.sh" | sha256sum -c -
|
|
# Wrap the install in retry too — it internally fetches the
|
|
# safe-chain binary from the GH release CDN, which has hit
|
|
# transient 404s.
|
|
node .github/scripts/retry.mjs --attempts 3 --delay 10 -- \
|
|
sh install-safe-chain.sh --ci
|
|
rm install-safe-chain.sh
|
|
shell: bash
|
|
|
|
# ALWAYS runs, even on a cache hit. `setup-ci` installs SafeChain's
|
|
# executable shims and exposes them to subsequent steps via $GITHUB_PATH.
|
|
# That PATH wiring is per-job runtime state — it is NOT captured by the
|
|
# binary cache above. On a cache hit the download step is skipped, so this
|
|
# is the only thing that puts SafeChain on PATH for the install/build steps;
|
|
# without it the package managers run unwrapped and protection is inert.
|
|
# Runs the cached binary directly to avoid a redundant CDN round-trip.
|
|
- name: Activate Aikido SafeChain
|
|
run: |
|
|
"$HOME/.safe-chain/bin/safe-chain" setup-ci
|
|
shell: bash
|
|
|
|
# Fail fast if activation did not take effect, mirroring the Node/pnpm
|
|
# verify guards above. We use SafeChain's own `safe-chain-verify` command,
|
|
# which runs *through* the wrapped package manager and prints
|
|
# "OK: Safe-chain works!" only when the shim is active.
|
|
# Kept as a step after Activate: `setup-ci` exposes the shims via
|
|
# $GITHUB_PATH, which only reaches PATH between steps.
|
|
- name: Verify SafeChain is active
|
|
shell: bash
|
|
run: node .github/scripts/verify-safechain.mjs
|
|
|
|
# `--loglevel` CLI flag does NOT override `.npmrc` for that path, but
|
|
# `--config.loglevel` does — so the failing script's real output reaches us.
|
|
# (Confirmed on CI across Blacksmith + GitHub-hosted, through the SafeChain
|
|
# shim and the real pnpm binary)
|
|
#
|
|
# Layered so the cause survives even failure modes pnpm can't report itself:
|
|
# 1. `--config.loglevel=info` un-suppresses lifecycle output (the actual fix).
|
|
# `--reporter=append-only` is pinned so we don't drift if pnpm changes its
|
|
# CI default; combined stdout+stderr is streamed live AND persisted to
|
|
# `$INSTALL_LOG` via `tee`; `${PIPESTATUS[0]}` preserves pnpm's exit code.
|
|
# 2. `--config.logs-dir` routes pnpm's own `ERR_PNPM_*` diagnostic logs into
|
|
# a dir we always collect.
|
|
# 3. On failure we dump an environment snapshot written by *this* shell —
|
|
# node/pnpm versions, the resolved pnpm binary, SafeChain wiring, free
|
|
# memory, disk, and kernel OOM lines — so OOM / wrong-binary deaths that
|
|
# produce no pnpm output at all are still diagnosable.
|
|
# 4. Everything is uploaded as an artifact by the next step, so the full log
|
|
# survives even when the live console stream is truncated by the runner.
|
|
#
|
|
# `SAFE_CHAIN_LOGGING=verbose` surfaces safe-chain's proxy decisions, which
|
|
# are otherwise buffered (and lost on failure) while pnpm is in flight.
|
|
- name: Install Dependencies
|
|
if: ${{ inputs.install-command != '' }}
|
|
id: install-deps
|
|
env:
|
|
INSTALL_COMMAND: ${{ inputs.install-command }}
|
|
INSTALL_LOG: ${{ runner.temp }}/pnpm-install.log
|
|
PNPM_DIAG_DIR: ${{ runner.temp }}/pnpm-diagnostics
|
|
SAFE_CHAIN_LOGGING: verbose
|
|
GITHUB_TOKEN: ${{ github.token }}
|
|
run: |
|
|
# Stable, artifact-name-safe id for the upload step (written first so it
|
|
# exists even if the install dies immediately).
|
|
DIAG_ID="$(printf '%s' "${GITHUB_JOB}-${RUNNER_NAME}-${GITHUB_RUN_ATTEMPT}-${RANDOM}" | tr -c 'A-Za-z0-9._-' '-')"
|
|
echo "diag-id=$DIAG_ID" >> "$GITHUB_OUTPUT"
|
|
mkdir -p "$PNPM_DIAG_DIR/pnpm-logs"
|
|
|
|
set +o pipefail
|
|
timeout --kill-after=30s 300s $INSTALL_COMMAND \
|
|
--config.logs-dir="$PNPM_DIAG_DIR/pnpm-logs" \
|
|
--reporter=append-only --config.loglevel=info 2>&1 | tee "$INSTALL_LOG"
|
|
rc=${PIPESTATUS[0]}
|
|
set -o pipefail
|
|
|
|
if [ "$rc" -ne 0 ]; then
|
|
# Self-emitted environment snapshot — cannot be swallowed by pnpm.
|
|
{
|
|
echo "exit_code=$rc"
|
|
echo "date=$(date -u +%FT%TZ)"
|
|
echo "install_command=$INSTALL_COMMAND"
|
|
echo "node=$(node --version 2>&1)"
|
|
echo "pnpm=$(pnpm --version 2>&1)"
|
|
echo "pnpm_resolved=$(command -v pnpm 2>&1) -> $(readlink -f "$(command -v pnpm 2>/dev/null)" 2>&1)"
|
|
echo "pnpm_on_path=$(which -a pnpm 2>&1 | tr '\n' ' ')"
|
|
echo "safe_chain_bin=$(ls -la "$HOME/.safe-chain/bin" 2>&1 | tr '\n' ' ')"
|
|
echo "mem_mb=$(free -m 2>/dev/null | tr '\n' ' ' || echo 'free unavailable')"
|
|
echo "disk=$(df -h "$PWD" 2>/dev/null | tail -1)"
|
|
echo "oom=$(dmesg 2>/dev/null | grep -iE 'killed process|out of memory' | tail -5 || true)"
|
|
} > "$PNPM_DIAG_DIR/environment.txt" 2>&1
|
|
cp "$INSTALL_LOG" "$PNPM_DIAG_DIR/" 2>/dev/null || true
|
|
|
|
echo "::error::pnpm install failed (exit $rc). Full output + diagnostics below; also uploaded as artifact 'pnpm-install-logs-${DIAG_ID}'."
|
|
echo "::group::Environment diagnostics"
|
|
cat "$PNPM_DIAG_DIR/environment.txt"
|
|
echo "::endgroup::"
|
|
echo "::group::pnpm install combined output ($INSTALL_LOG)"
|
|
cat "$INSTALL_LOG" 2>/dev/null || echo "(combined log empty — pnpm produced no capturable output; see environment diagnostics and the uploaded artifact)"
|
|
echo "::endgroup::"
|
|
echo "::group::pnpm diagnostic logs ($PNPM_DIAG_DIR/pnpm-logs)"
|
|
find "$PNPM_DIAG_DIR/pnpm-logs" -type f -exec sh -c 'echo "----- $1 -----"; cat "$1"' _ {} \; 2>/dev/null || echo "(none)"
|
|
echo "::endgroup::"
|
|
case "$rc" in
|
|
124) echo "::error::pnpm install timed out after 300s (exit 124)" ;;
|
|
137) echo "::error::pnpm install received SIGKILL (exit 137 — likely OOM or kill-after timeout)" ;;
|
|
esac
|
|
fi
|
|
exit "$rc"
|
|
shell: bash
|
|
|
|
# Persist the full combined log + diagnostics so the real cause survives even
|
|
# when the runner truncates the live console stream. Scoped to the install
|
|
# step's own failure so unrelated step failures don't trigger an empty upload.
|
|
- name: Upload pnpm install diagnostics
|
|
if: ${{ failure() && steps.install-deps.outcome == 'failure' }}
|
|
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
|
with:
|
|
name: pnpm-install-logs-${{ steps.install-deps.outputs.diag-id }}
|
|
path: |
|
|
${{ runner.temp }}/pnpm-install.log
|
|
${{ runner.temp }}/pnpm-diagnostics/**
|
|
if-no-files-found: ignore
|
|
retention-days: 7
|
|
|
|
# When setup-nodejs is nested inside another setup-nodejs invocation
|
|
# (e.g. load-n8n-docker's rebuild fallback → build-n8n-docker), a turbo
|
|
# cache server is already running and its TURBO_API env is already
|
|
# exported, so the nested builds can just use it. Starting a second
|
|
# server overwrites TURBOGHA_PORT, which both post-runs then read: the
|
|
# first post shuts the second server down, and the other post fails the
|
|
# job with a bare `fetch failed` — and the first server's caches are
|
|
# never saved.
|
|
- name: Check for running Turborepo cache server
|
|
id: turbo-server
|
|
shell: bash
|
|
run: echo "running=${TURBOGHA_PORT:+true}" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Configure Turborepo Cache
|
|
if: steps.turbo-server.outputs.running != 'true'
|
|
uses: rharkor/caching-for-turbo@5d14fba18e450c09393333cfd4242e8b3cb455a6 # v2.4.2
|
|
with:
|
|
server-port: 0
|
|
|
|
- name: Setup Docker Builder for Docker Cache (Blacksmith)
|
|
if: ${{ inputs.enable-docker-cache == 'true' && contains(runner.name, 'blacksmith') }}
|
|
uses: useblacksmith/setup-docker-builder@ef12d5b165b596e3aa44ea8198d8fde563eab402 # v1.4.0
|
|
|
|
- name: Setup Docker Builder (GitHub fallback)
|
|
if: ${{ inputs.enable-docker-cache == 'true' && !contains(runner.name, 'blacksmith') }}
|
|
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
|
|
|
- name: Build Project
|
|
if: ${{ inputs.build-command != '' }}
|
|
env:
|
|
BUILD_COMMAND: ${{ inputs.build-command }}
|
|
run: |
|
|
$BUILD_COMMAND --summarize
|
|
node .github/scripts/send-build-stats.mjs || true
|
|
node .github/scripts/send-docker-stats.mjs || true
|
|
shell: bash
|