# yaml-language-server: $schema=https://json.schemastore.org/github-action.json # permissions: # - (none required) # Setup action, no repository writes --- name: Node Setup description: 'Sets up Node.js env with advanced version management, caching, and tooling.' author: 'Ismo Vuorinen' branding: icon: server color: green inputs: default-version: description: 'Default Node.js version to use if no configuration file is found.' required: false default: '22' package-manager: description: 'Node.js package manager to use (npm, yarn, pnpm, bun, auto)' required: false default: 'auto' registry-url: description: 'Custom NPM registry URL' required: false default: 'https://registry.npmjs.org' token: description: 'Auth token for private registry' required: false cache: description: 'Enable dependency caching' required: false default: 'true' install: description: 'Automatically install dependencies' required: false default: 'true' node-mirror: description: 'Custom Node.js binary mirror' required: false force-version: description: 'Force specific Node.js version regardless of config files' required: false max-retries: description: 'Maximum number of retry attempts for package manager operations' required: false default: '3' outputs: node-version: description: 'Installed Node.js version' value: ${{ steps.setup.outputs.node-version }} package-manager: description: 'Selected package manager' value: ${{ steps.package-manager-resolution.outputs.final-package-manager }} cache-hit: description: 'Indicates if there was a cache hit' value: ${{ steps.deps-cache.outputs.cache-hit }} node-path: description: 'Path to Node.js installation' value: ${{ steps.setup.outputs.node-path }} esm-support: description: 'Whether ESM modules are supported' value: ${{ steps.package-manager-resolution.outputs.esm-support }} typescript-support: description: 'Whether TypeScript is configured' value: ${{ steps.package-manager-resolution.outputs.typescript-support }} detected-frameworks: description: 'Comma-separated list of detected frameworks' value: ${{ steps.package-manager-resolution.outputs.detected-frameworks }} runs: using: composite steps: - name: Validate Inputs id: validate shell: bash env: DEFAULT_VERSION: ${{ inputs.default-version }} FORCE_VERSION: ${{ inputs.force-version }} PACKAGE_MANAGER: ${{ inputs.package-manager }} REGISTRY_URL: ${{ inputs.registry-url }} NODE_MIRROR: ${{ inputs.node-mirror }} MAX_RETRIES: ${{ inputs.max-retries }} CACHE: ${{ inputs.cache }} INSTALL: ${{ inputs.install }} AUTH_TOKEN: ${{ inputs.token }} run: | set -euo pipefail # Validate default-version format if [[ -n "$DEFAULT_VERSION" ]]; then if ! [[ "$DEFAULT_VERSION" =~ ^[0-9]+(\.[0-9]+(\.[0-9]+)?)?$ ]]; then echo "::error::Invalid default-version format: '$DEFAULT_VERSION'. Expected format: X or X.Y or X.Y.Z (e.g., 22, 20.9, 18.17.1)" exit 1 fi # Check for reasonable version range (prevent malicious inputs) major_version=$(echo "$DEFAULT_VERSION" | cut -d'.' -f1) if [ "$major_version" -lt 14 ] || [ "$major_version" -gt 30 ]; then echo "::error::Invalid default-version: '$DEFAULT_VERSION'. Node.js major version should be between 14 and 30" exit 1 fi fi # Validate force-version format if provided if [[ -n "$FORCE_VERSION" ]]; then if ! [[ "$FORCE_VERSION" =~ ^[0-9]+(\.[0-9]+(\.[0-9]+)?)?$ ]]; then echo "::error::Invalid force-version format: '$FORCE_VERSION'. Expected format: X or X.Y or X.Y.Z (e.g., 22, 20.9, 18.17.1)" exit 1 fi # Check for reasonable version range major_version=$(echo "$FORCE_VERSION" | cut -d'.' -f1) if [ "$major_version" -lt 14 ] || [ "$major_version" -gt 30 ]; then echo "::error::Invalid force-version: '$FORCE_VERSION'. Node.js major version should be between 14 and 30" exit 1 fi fi # Validate package-manager case "$PACKAGE_MANAGER" in "npm"|"yarn"|"pnpm"|"bun"|"auto") # Valid package managers ;; *) echo "::error::Invalid package-manager: '$PACKAGE_MANAGER'. Must be one of: npm, yarn, pnpm, bun, auto" exit 1 ;; esac # Validate registry-url format (basic URL validation) if [[ "$REGISTRY_URL" != "https://"* ]] && [[ "$REGISTRY_URL" != "http://"* ]]; then echo "::error::Invalid registry-url: '$REGISTRY_URL'. Must be a valid HTTP/HTTPS URL" exit 1 fi # Validate node-mirror format if provided if [[ -n "$NODE_MIRROR" ]]; then if [[ "$NODE_MIRROR" != "https://"* ]] && [[ "$NODE_MIRROR" != "http://"* ]]; then echo "::error::Invalid node-mirror: '$NODE_MIRROR'. Must be a valid HTTP/HTTPS URL" exit 1 fi fi # Validate max retries (positive integer with reasonable upper limit) if ! [[ "$MAX_RETRIES" =~ ^[0-9]+$ ]] || [ "$MAX_RETRIES" -le 0 ] || [ "$MAX_RETRIES" -gt 10 ]; then echo "::error::Invalid max-retries: '$MAX_RETRIES'. Must be a positive integer between 1 and 10" exit 1 fi # Validate boolean inputs if [[ "$CACHE" != "true" ]] && [[ "$CACHE" != "false" ]]; then echo "::error::Invalid cache value: '$CACHE'. Must be 'true' or 'false'" exit 1 fi if [[ "$INSTALL" != "true" ]] && [[ "$INSTALL" != "false" ]]; then echo "::error::Invalid install value: '$INSTALL'. Must be 'true' or 'false'" exit 1 fi # Validate auth token format if provided (basic check for NPM tokens) if [[ -n "$AUTH_TOKEN" ]]; then if [[ "$AUTH_TOKEN" == *";"* ]] || [[ "$AUTH_TOKEN" == *"&&"* ]] || [[ "$AUTH_TOKEN" == *"|"* ]]; then echo "::error::Invalid token format: command injection patterns not allowed" exit 1 fi fi echo "Input validation completed successfully" - name: Checkout Repository uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: token: ${{ inputs.token || github.token }} - name: Parse Node.js Version id: version uses: ./version-file-parser with: language: 'node' tool-versions-key: 'nodejs' dockerfile-image: 'node' version-file: '.nvmrc' validation-regex: '^[0-9]+(\.[0-9]+)*$' default-version: ${{ inputs.force-version != '' && inputs.force-version || inputs.default-version }} - name: Resolve Package Manager id: package-manager-resolution shell: bash env: INPUT_PM: ${{ inputs.package-manager }} DETECTED_PM: ${{ steps.version.outputs.package-manager }} run: | set -euo pipefail input_pm="$INPUT_PM" detected_pm="$DETECTED_PM" final_pm="" if [ "$input_pm" = "auto" ]; then if [ -n "$detected_pm" ]; then final_pm="$detected_pm" echo "Auto-detected package manager: $final_pm" else final_pm="npm" echo "No package manager detected, using default: $final_pm" fi else final_pm="$input_pm" echo "Using specified package manager: $final_pm" fi echo "final-package-manager=$final_pm" >> $GITHUB_OUTPUT echo "Final package manager: $final_pm" # Node.js feature detection echo "Detecting Node.js features..." # Detect ESM support esm_support="false" if [ -f package.json ] && command -v jq >/dev/null 2>&1; then pkg_type=$(jq -r '.type // "commonjs"' package.json 2>/dev/null) if [ "$pkg_type" = "module" ]; then esm_support="true" fi fi echo "esm-support=$esm_support" >> $GITHUB_OUTPUT echo "ESM support: $esm_support" # Detect TypeScript typescript_support="false" if [ -f tsconfig.json ] || [ -f package.json ]; then if [ -f tsconfig.json ]; then typescript_support="true" elif command -v jq >/dev/null 2>&1; then if jq -e '.devDependencies.typescript or .dependencies.typescript' package.json >/dev/null 2>&1; then typescript_support="true" fi fi fi echo "typescript-support=$typescript_support" >> $GITHUB_OUTPUT echo "TypeScript support: $typescript_support" # Detect frameworks frameworks="" if [ -f package.json ] && command -v jq >/dev/null 2>&1; then detected_frameworks=() if jq -e '.dependencies.next or .devDependencies.next' package.json >/dev/null 2>&1; then detected_frameworks+=("next") fi if jq -e '.dependencies.react or .devDependencies.react' package.json >/dev/null 2>&1; then detected_frameworks+=("react") fi if jq -e '.dependencies.vue or .devDependencies.vue' package.json >/dev/null 2>&1; then detected_frameworks+=("vue") fi if jq -e '.dependencies.svelte or .devDependencies.svelte' package.json >/dev/null 2>&1; then detected_frameworks+=("svelte") fi if jq -e '.dependencies."@angular/core" or .devDependencies."@angular/core"' package.json >/dev/null 2>&1; then detected_frameworks+=("angular") fi if [ ${#detected_frameworks[@]} -gt 0 ]; then frameworks=$(IFS=','; echo "${detected_frameworks[*]}") fi fi echo "detected-frameworks=$frameworks" >> $GITHUB_OUTPUT echo "Detected frameworks: $frameworks" - name: Setup Node.js id: setup uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 with: node-version: ${{ steps.version.outputs.detected-version }} registry-url: ${{ inputs.registry-url }} cache: false - name: Enable Corepack id: corepack shell: bash run: | set -euo pipefail echo "Enabling Corepack for package manager management..." corepack enable echo "✅ Corepack enabled successfully" - name: Set Auth Token if: inputs.token != '' shell: bash env: TOKEN: ${{ inputs.token }} run: | # Sanitize token by removing newlines to prevent env var injection sanitized_token="$(echo "$TOKEN" | tr -d '\n\r')" printf 'NODE_AUTH_TOKEN=%s\n' "$sanitized_token" >> "$GITHUB_ENV" - name: Cache Dependencies if: inputs.cache == 'true' id: deps-cache uses: ./common-cache with: type: 'npm' paths: '~/.npm,~/.yarn/cache,~/.pnpm-store,~/.bun/install/cache,node_modules' key-prefix: 'node-${{ steps.version.outputs.detected-version }}-${{ steps.package-manager-resolution.outputs.final-package-manager }}' key-files: 'package-lock.json,yarn.lock,pnpm-lock.yaml,bun.lockb,.yarnrc.yml' restore-keys: '${{ runner.os }}-node-${{ steps.version.outputs.detected-version }}-${{ steps.package-manager-resolution.outputs.final-package-manager }}-' - name: Install Package Managers if: inputs.install == 'true' && steps.deps-cache.outputs.cache-hit != 'true' shell: bash env: PACKAGE_MANAGER: ${{ steps.package-manager-resolution.outputs.final-package-manager }} run: | set -euo pipefail package_manager="$PACKAGE_MANAGER" echo "Setting up package manager: $package_manager" case "$package_manager" in "pnpm") echo "Installing PNPM via Corepack..." corepack prepare pnpm@latest --activate echo "✅ PNPM installed successfully" ;; "yarn") echo "Installing Yarn via Corepack..." corepack prepare yarn@stable --activate echo "✅ Yarn installed successfully" ;; "bun") # Bun installation handled by separate step below echo "Bun will be installed via official setup-bun action" ;; "npm") echo "Using built-in NPM" ;; *) echo "::warning::Unknown package manager: $package_manager, using NPM" ;; esac - name: Setup Bun if: inputs.install == 'true' && steps.package-manager-resolution.outputs.final-package-manager == 'bun' uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 with: bun-version: latest - name: Export Package Manager to Environment if: inputs.install == 'true' && steps.deps-cache.outputs.cache-hit != 'true' shell: bash env: PACKAGE_MANAGER: ${{ steps.package-manager-resolution.outputs.final-package-manager }} run: | # Sanitize package manager by removing newlines to prevent env var injection sanitized_pm="$(echo "$PACKAGE_MANAGER" | tr -d '\n\r')" printf 'PACKAGE_MANAGER=%s\n' "$sanitized_pm" >> "$GITHUB_ENV" - name: Install Dependencies if: inputs.install == 'true' && steps.deps-cache.outputs.cache-hit != 'true' uses: ./common-retry with: command: | package_manager="$PACKAGE_MANAGER" echo "Installing dependencies using $package_manager..." case "$package_manager" in "pnpm") pnpm install --frozen-lockfile ;; "yarn") # Check for Yarn Berry/PnP configuration if [ -f ".yarnrc.yml" ]; then echo "Detected Yarn Berry configuration" yarn install --immutable else echo "Using Yarn Classic" yarn install --frozen-lockfile fi ;; "bun") bun install ;; "npm"|*) npm ci ;; esac echo "✅ Dependencies installed successfully" max-retries: ${{ inputs.max-retries }} description: 'Installing Node.js dependencies' - name: Set Final Outputs shell: bash env: NODE_VERSION: ${{ steps.version.outputs.detected-version }} PACKAGE_MANAGER: ${{ steps.package-manager-resolution.outputs.final-package-manager }} run: |- { echo "node-version=$NODE_VERSION" echo "package-manager=$PACKAGE_MANAGER" echo "node-path=$(which node)" } >> $GITHUB_OUTPUT