name: 'npm依赖安装与缓存' description: '自动缓存和安装npm依赖,支持多种包管理器' branding: icon: 'package' color: 'blue' inputs: package-manager: description: '包管理器类型 (npm, pnpm, yarn)' required: false default: 'pnpm' pnpm-version: description: 'pnpm 版本(当 package-manager=pnpm 时生效)' required: false default: '10' cache-mode: description: '缓存模式:node_modules 或 store' required: false default: 'store' optimize-install-flags: description: '是否启用安装参数优化(pnpm+store时自动使用 --offline/--prefer-offline 与 --frozen-lockfile)(true/false)' required: false default: 'true' cache-prefix: description: '缓存前缀名称' required: false default: 'modules' node-modules-path: description: 'node_modules目录路径' required: false default: 'node_modules' force-install: description: '是否强制安装 (true/false)' required: false default: 'false' enable-git-stash: description: '安装后是否执行git stash (true/false)' required: false default: 'false' install-command: description: '自定义安装命令(可选,会覆盖默认命令)' required: false default: '' install-args: description: '附加到默认安装命令的参数(当未提供 install-command 时生效)' required: false default: '' clean-project-store: description: '在安装前清理项目根的 .pnpm-store 残留(true/false)' required: false default: 'false' cache-hash: description: '缓存hash值(推荐使用hashFiles计算)' required: false default: "" outputs: cache-hit: description: '是否命中缓存 (true/false)' value: ${{ steps.cache.outputs.cache-hit }} cache-key: description: '使用的缓存key' value: ${{ steps.cache-key.outputs.key }} cache-path: description: '缓存路径(用于调试与复用)' value: ${{ steps.cache-path.outputs.path }} runs: using: 'composite' steps: - name: 生成缓存key id: cache-key shell: bash env: FALLBACK_HASH: ${{ hashFiles('package.json') }} run: | # 确定使用的hash值 if [[ -n "${{ inputs.cache-hash }}" && "${{ inputs.cache-hash }}" != "" ]]; then CACHE_HASH="${{ inputs.cache-hash }}" echo "✅ 使用用户传入的hash值" elif [[ -n "${FALLBACK_HASH}" && "${FALLBACK_HASH}" != "" ]]; then CACHE_HASH="${FALLBACK_HASH}" echo "📝 未提供hash,使用package.json作为fallback" else CACHE_HASH="" echo "⚠️ 警告: 无法获取hash值,使用默认值" fi # 截取hash的前12位 if [[ -n "${CACHE_HASH}" && "${CACHE_HASH}" != "" ]]; then CACHE_HASH_SHORT=$(echo "${CACHE_HASH}" | head -c 12) echo "✅ 成功计算缓存hash: ${CACHE_HASH_SHORT}" else CACHE_HASH_SHORT="no-hash" echo "⚠️ 使用默认hash值: ${CACHE_HASH_SHORT}" fi # 生成包管理器后缀,避免不同包管理器/版本的缓存互相污染 MANAGER="${{ inputs.package-manager }}" MANAGER_SUFFIX="$MANAGER" if [[ "$MANAGER" == "pnpm" && -n "${{ inputs.pnpm-version }}" && "${{ inputs.pnpm-version }}" != "" ]]; then MANAGER_SUFFIX="${MANAGER}-v${{ inputs.pnpm-version }}" fi # 模式后缀,隔离不同缓存模式(node_modules vs store) MODE_SUFFIX="${{ inputs.cache-mode }}" if [[ -z "$MODE_SUFFIX" ]]; then MODE_SUFFIX="node_modules"; fi # 构建缓存key:---- CACHE_KEY="${{ runner.os }}-${MANAGER_SUFFIX}-${MODE_SUFFIX}-${{ inputs.cache-prefix }}-${CACHE_HASH_SHORT}" # 恢复前缀:用于 restore-keys,防止不同包管理器/模式的回退误命中 RESTORE_PREFIX="${{ runner.os }}-${MANAGER_SUFFIX}-${MODE_SUFFIX}-${{ inputs.cache-prefix }}-" echo "key=${CACHE_KEY}" >> $GITHUB_OUTPUT echo "restore-prefix=${RESTORE_PREFIX}" >> $GITHUB_OUTPUT echo "使用hash: ${CACHE_HASH}" echo "缓存key: ${CACHE_KEY}" - name: 确保 pnpm 可用 if: inputs.package-manager == 'pnpm' uses: pnpm/action-setup@v4 with: version: ${{ inputs.pnpm-version }} run_install: false - name: 确定缓存路径 id: cache-path shell: bash run: | MODE="${{ inputs.cache-mode }}" MANAGER="${{ inputs.package-manager }}" if [[ -z "$MODE" ]]; then MODE="node_modules"; fi if [[ "$MODE" == "node_modules" ]]; then CACHE_PATH="${{ inputs.node-modules-path }}" # 即使只缓存 node_modules,pnpm 也会使用 store。为避免在项目根生成 .pnpm-store,这里同样固定 PNPM_STORE_DIR if [[ "$MANAGER" == "pnpm" ]]; then DEFAULT_PNPM_STORE="${RUNNER_TEMP:-$HOME}/.pnpm-store" echo "PNPM_STORE_DIR=${DEFAULT_PNPM_STORE}" >> "$GITHUB_ENV" fi else case "$MANAGER" in "npm") # npm 的全局缓存目录 CACHE_PATH="$HOME/.npm" ;; "pnpm") # 固定 pnpm store 路径到 runner 的临时目录或 HOME,避免在项目根生成 .pnpm-store # 说明:一些仓库的 .npmrc 可能配置了 store-dir=.pnpm-store,会导致在工作目录创建 .pnpm-store # 这里通过设置 PNPM_STORE_DIR 环境变量进行覆盖,确保缓存路径稳定可控 DEFAULT_PNPM_STORE="${RUNNER_TEMP:-$HOME}/.pnpm-store" # 将目录导出到环境,供后续安装步骤使用 echo "PNPM_STORE_DIR=${DEFAULT_PNPM_STORE}" >> "$GITHUB_ENV" CACHE_PATH="${DEFAULT_PNPM_STORE}" ;; "yarn") # yarn v1 默认缓存目录(yarn berry 采用不同机制,这里聚焦 v1 常见场景) CACHE_PATH="$HOME/.cache/yarn" ;; *) echo "❌ 不支持的包管理器: $MANAGER" exit 1 ;; esac fi # 打印最终缓存目录,便于调试与确认 echo "📁 最终缓存目录: ${CACHE_PATH}" if [[ "$MANAGER" == "pnpm" ]]; then echo "📦 PNPM_STORE_DIR=${PNPM_STORE_DIR:-$DEFAULT_PNPM_STORE}" fi echo "path=${CACHE_PATH}" >> $GITHUB_OUTPUT - name: 拉取缓存依赖 id: cache uses: actions/cache@v4 with: path: ${{ steps.cache-path.outputs.path }} key: ${{ steps.cache-key.outputs.key }} restore-keys: | ${{ steps.cache-key.outputs.restore-prefix }} - name: 显示缓存状态 shell: bash run: | if [[ "${{ steps.cache.outputs.cache-hit }}" == "true" ]]; then if [[ "${{ inputs.cache-mode }}" == "store" ]]; then echo "✅ 缓存命中(store),将执行快速链接安装(不会下载包,仅链接)" else echo "✅ 缓存命中,跳过依赖安装" fi else echo "⚠️ 缓存未命中,开始安装依赖" fi - name: 安装依赖 if: (inputs.cache-mode == 'node_modules' && steps.cache.outputs.cache-hit != 'true') || (inputs.cache-mode == 'store') shell: bash run: | # 若使用 pnpm,在本步骤内始终显式设置 PNPM_STORE_DIR(覆盖可能存在的相对配置) if [[ "${{ inputs.package-manager }}" == "pnpm" ]]; then if [[ "${{ inputs.cache-mode }}" == "store" ]]; then # store 模式:cache-path 的 path 即为期望的 store 目录 export PNPM_STORE_DIR="${{ steps.cache-path.outputs.path }}" else # node_modules 模式:不要回退到 cache-path(那是 node_modules 目录),而是使用 RUNNER_TEMP/HOME export PNPM_STORE_DIR="${PNPM_STORE_DIR:-${RUNNER_TEMP:-$HOME}/.pnpm-store}" fi # 通过多通道环境变量覆盖(兼容不同版本/解析顺序) export npm_config_store_dir="${PNPM_STORE_DIR}" export PNPM_CONFIG_STORE_DIR="${PNPM_STORE_DIR}" echo "🧩 已设置 PNPM_STORE_DIR=${PNPM_STORE_DIR}" echo "🔎 pnpm 配置: store-dir=$(pnpm config get store-dir || echo '')" fi # 如果提供了自定义安装命令,使用自定义命令 if [[ -n "${{ inputs.install-command }}" ]]; then echo "🔧 使用自定义安装命令: ${{ inputs.install-command }}" ${{ inputs.install-command }} else INSTALL_ARGS="${{ inputs.install-args }}" if [[ -n "$INSTALL_ARGS" ]]; then echo "➕ 附加安装参数: $INSTALL_ARGS" fi # 根据模式与缓存命中优化安装参数(尽量离线加速) EXTRA_FLAGS="" if [[ "${{ inputs.optimize-install-flags }}" == "true" && "${{ inputs.package-manager }}" == "pnpm" && "${{ inputs.cache-mode }}" == "store" ]]; then if [[ "${{ steps.cache.outputs.cache-hit }}" == "true" ]]; then # 缓存命中:使用完全离线与锁定安装,避免网络请求 EXTRA_FLAGS="--offline --frozen-lockfile" else # 缓存未命中:尽量离线,但允许必要网络;同时锁定避免解析差异 EXTRA_FLAGS="--prefer-offline --frozen-lockfile" fi echo "⚡ pnpm安装优化参数: $EXTRA_FLAGS" fi # 根据包管理器选择安装命令 case "${{ inputs.package-manager }}" in "npm") if [[ "${{ inputs.force-install }}" == "true" ]]; then echo "🔧 使用npm强制安装" npm install --force ${INSTALL_ARGS} else echo "🔧 使用npm安装" npm install ${INSTALL_ARGS} fi ;; "pnpm") if [[ "${{ inputs.force-install }}" == "true" ]]; then echo "🔧 使用pnpm强制安装" pnpm install --force ${EXTRA_FLAGS} ${INSTALL_ARGS} else echo "🔧 使用pnpm安装" pnpm install ${EXTRA_FLAGS} ${INSTALL_ARGS} fi ;; "yarn") if [[ "${{ inputs.force-install }}" == "true" ]]; then echo "🔧 使用yarn强制安装" yarn install --force ${INSTALL_ARGS} else echo "🔧 使用yarn安装" yarn install ${INSTALL_ARGS} fi ;; *) echo "❌ 不支持的包管理器: ${{ inputs.package-manager }}" exit 1 ;; esac fi # 可选清理:如启用并发现项目根存在残留的 .pnpm-store,且与目标目录不同,则清理 if [[ "${{ inputs.package-manager }}" == "pnpm" && "${{ inputs.clean-project-store }}" == "true" ]]; then # 进一步在项目级设置覆盖一次,杜绝 .npmrc 相对路径带来的影响(仅对本 CI 工作目录生效) pnpm config set store-dir "${PNPM_STORE_DIR}" --location=project || true if [[ -d ".pnpm-store" && "${PNPM_STORE_DIR}" != "$PWD/.pnpm-store" ]]; then echo "🧹 清理项目根的残留 .pnpm-store(目标store为 ${PNPM_STORE_DIR})" rm -rf .pnpm-store || true fi fi echo "✅ 依赖安装完成" - name: 执行Git Stash if: steps.cache.outputs.cache-hit != 'true' && inputs.enable-git-stash == 'true' shell: bash run: | echo "🔄 执行git stash..." git stash echo "✅ git stash完成" - name: 安装总结 shell: bash run: | echo "📦 依赖安装总结:" echo " - 包管理器: ${{ inputs.package-manager }}" echo " - 缓存命中: ${{ steps.cache.outputs.cache-hit }}" echo " - 缓存key: ${{ steps.cache-key.outputs.key }}" if [[ "${{ steps.cache.outputs.cache-hit }}" != "true" ]]; then echo " - 强制安装: ${{ inputs.force-install }}" echo " - Git Stash: ${{ inputs.enable-git-stash }}" fi # 诊断工作目录是否存在 .pnpm-store 以及其 Git 状态 echo "\n🧪 目录状态自检:" echo " - PNPM_STORE_DIR: ${PNPM_STORE_DIR:-}" if [[ -d ".pnpm-store" ]]; then echo " - 工作目录存在 .pnpm-store 目录" if git rev-parse --git-dir >/dev/null 2>&1; then if git ls-files --error-unmatch .pnpm-store >/dev/null 2>&1; then echo " - Git 状态: 已被跟踪 (tracked)" elif git check-ignore -q .pnpm-store; then echo " - Git 状态: 被忽略 (ignored)" else echo " - Git 状态: 未跟踪 (untracked)" fi else echo " - Git 仓库: 未检测到 (非 Git 工作目录)" fi else echo " - 工作目录未发现 .pnpm-store 目录 ✅" fi