name: Check Node.js Version Drift on: workflow_dispatch: schedule: # Runs weekly on Monday at 06:00 UTC - cron: "0 6 * * 1" permissions: contents: read issues: write jobs: check-node-versions: if: github.repository == 'community-scripts/ProxmoxVE' runs-on: coolify-runner steps: - name: Checkout Repository uses: actions/checkout@v4 with: ref: main - name: Install dependencies run: | sudo apt-get update -qq sudo apt-get install -y -qq jq curl > /dev/null 2>&1 - name: Check upstream Node.js versions id: check env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail echo "================================================" echo " Checking Node.js version drift in install scripts" echo "================================================" # Alpine version -> Node major cache (populated on demand) declare -A ALPINE_NODE_CACHE # Resolve Node.js major version from Alpine package registry # Usage: resolve_alpine_node "3.21" => sets REPLY to major version (e.g. "22") resolve_alpine_node() { local alpine_ver="$1" if [[ -n "${ALPINE_NODE_CACHE[$alpine_ver]+x}" ]]; then REPLY="${ALPINE_NODE_CACHE[$alpine_ver]}" return fi local url="https://pkgs.alpinelinux.org/package/v${alpine_ver}/main/x86_64/nodejs" local page page=$(curl -sf "$url" 2>/dev/null || echo "") local full_ver="" if [[ -n "$page" ]]; then # Parse: "Version | 24.13.0-r1" or similar table row full_ver=$(echo "$page" | grep -oP 'Version\s*\|\s*\K[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo "") if [[ -z "$full_ver" ]]; then # Fallback: look for version pattern after "Version" full_ver=$(echo "$page" | grep -oP '(?<=Version)[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo "") fi fi local major="" if [[ -n "$full_ver" ]]; then major="${full_ver%%.*}" fi ALPINE_NODE_CACHE[$alpine_ver]="$major" REPLY="$major" } # Extract Node major from a Dockerfile content # Sets: DF_NODE_MAJOR, DF_SOURCE (description of where we found it) extract_dockerfile_node() { local content="$1" DF_NODE_MAJOR="" DF_SOURCE="" # 1) FROM node:XX (e.g. node:24-alpine, node:22.9.0-bookworm-slim, node:20) local node_from node_from=$(echo "$content" | grep -oP '(?i)FROM\s+(--platform=[^\s]+\s+)?node:\K[0-9]+' | head -1 || echo "") if [[ -n "$node_from" ]]; then DF_NODE_MAJOR="$node_from" DF_SOURCE="FROM node:${node_from}" return fi # 2) nodesource/setup_XX.x local nodesource nodesource=$(echo "$content" | grep -oP 'nodesource/setup_\K[0-9]+' | head -1 || echo "") if [[ -n "$nodesource" ]]; then DF_NODE_MAJOR="$nodesource" DF_SOURCE="nodesource/setup_${nodesource}.x" return fi # 3) FROM alpine:X.Y — resolve via Alpine packages local alpine_ver alpine_ver=$(echo "$content" | grep -oP '(?i)FROM\s+(--platform=[^\s]+\s+)?alpine:\K[0-9]+\.[0-9]+' | head -1 || echo "") if [[ -n "$alpine_ver" ]]; then resolve_alpine_node "$alpine_ver" if [[ -n "$REPLY" ]]; then DF_NODE_MAJOR="$REPLY" DF_SOURCE="alpine:${alpine_ver} (pkg: nodejs ${DF_NODE_MAJOR})" return fi fi } # Extract Node major from engines.node in package.json # Sets: ENGINES_NODE_RAW (raw string), ENGINES_MIN_MAJOR, ENGINES_IS_MINIMUM extract_engines_node() { local content="$1" ENGINES_NODE_RAW="" ENGINES_MIN_MAJOR="" ENGINES_IS_MINIMUM="false" ENGINES_NODE_RAW=$(echo "$content" | jq -r '.engines.node // empty' 2>/dev/null || echo "") if [[ -z "$ENGINES_NODE_RAW" ]]; then return fi # Detect if constraint is a minimum (>=, ^) vs exact pinning if [[ "$ENGINES_NODE_RAW" =~ ^(\>=|\^|\~) ]]; then ENGINES_IS_MINIMUM="true" fi # Extract the first number (major) from the constraint # Handles: ">=24.13.1", "^22", ">=18.0.0", ">=18.15.0 <19 || ^20", etc. ENGINES_MIN_MAJOR=$(echo "$ENGINES_NODE_RAW" | grep -oP '\d+' | head -1 || echo "") } # Check if our_version satisfies an engines.node constraint # Returns 0 if satisfied, 1 if not # Usage: version_satisfies_engines "22" ">=18.0.0" "true" version_satisfies_engines() { local our="$1" local min_major="$2" local is_minimum="$3" if [[ -z "$min_major" || -z "$our" ]]; then return 1 fi if [[ "$is_minimum" == "true" ]]; then # >= or ^ constraint: our version must be >= min_major if [[ "$our" -ge "$min_major" ]]; then return 0 fi fi return 1 } # Search for files in subdirectories via GitHub API tree # Usage: find_repo_file "owner/repo" "branch" "filename" => sets REPLY to raw URL or empty find_repo_file() { local repo="$1" local branch="$2" local filename="$3" REPLY="" # Try root first (fast) local root_url="https://raw.githubusercontent.com/${repo}/${branch}/${filename}" if curl -sfI "$root_url" >/dev/null 2>&1; then REPLY="$root_url" return fi # Search via GitHub API tree (recursive) local tree_url="https://api.github.com/repos/${repo}/git/trees/${branch}?recursive=1" local tree_json tree_json=$(curl -sf -H "Authorization: token $GH_TOKEN" "$tree_url" 2>/dev/null || echo "") if [[ -z "$tree_json" ]]; then return fi # Find first matching path (prefer shorter/root-level paths) local match_path match_path=$(echo "$tree_json" | jq -r --arg fn "$filename" \ '.tree[]? | select(.path | endswith("/" + $fn) or . == $fn) | .path' 2>/dev/null \ | sort | head -1 || echo "") if [[ -n "$match_path" ]]; then REPLY="https://raw.githubusercontent.com/${repo}/${branch}/${match_path}" fi } # Extract Node major from .nvmrc or .node-version # Sets: NVMRC_NODE_MAJOR extract_nvmrc_node() { local content="$1" NVMRC_NODE_MAJOR="" # .nvmrc/.node-version typically has: "v22.9.0", "22", "lts/iron", etc. local ver ver=$(echo "$content" | tr -d '[:space:]' | grep -oP '^v?\K[0-9]+' | head -1 || echo "") NVMRC_NODE_MAJOR="$ver" } # Collect results declare -a issue_scripts=() declare -a report_lines=() total=0 checked=0 drift_count=0 for script in install/*-install.sh; do [[ ! -f "$script" ]] && continue if ! grep -q 'setup_nodejs' "$script"; then continue fi total=$((total + 1)) slug=$(basename "$script" | sed 's/-install\.sh$//') # Extract Source URL (GitHub only) from the "# Source:" line # Supports both: # # Source: https://github.com/owner/repo # # Source: https://example.com | Github: https://github.com/owner/repo # NOTE: Must filter for "# Source:" line first to avoid matching the License URL source_url=$(head -20 "$script" | grep -i '# Source:' | grep -oP 'https://github\.com/[^\s|]+' | head -1 || echo "") if [[ -z "$source_url" ]]; then report_lines+=("| \`$slug\` | — | — | — | — | ⏭️ No GitHub source |") continue fi repo=$(echo "$source_url" | sed -E 's|https://github\.com/||; s|/$||; s|\.git$||') if [[ -z "$repo" || "$repo" != */* ]]; then report_lines+=("| \`$slug\` | — | — | — | — | ⏭️ Invalid repo |") continue fi checked=$((checked + 1)) # Extract our NODE_VERSION our_version=$(grep -oP 'NODE_VERSION="(\d+)"' "$script" | head -1 | grep -oP '\d+' || echo "") if [[ -z "$our_version" ]]; then if grep -q 'NODE_VERSION=\$(' "$script"; then our_version="dynamic" else our_version="unset" fi fi # Determine default branch via GitHub API (fast, single call) detected_branch="" api_default=$(curl -sf -H "Authorization: token $GH_TOKEN" \ "https://api.github.com/repos/${repo}" 2>/dev/null \ | jq -r '.default_branch // empty' 2>/dev/null || echo "") if [[ -n "$api_default" ]]; then detected_branch="$api_default" else detected_branch="main" fi # Fetch upstream Dockerfile (root + subdirectories) df_content="" find_repo_file "$repo" "$detected_branch" "Dockerfile" if [[ -n "$REPLY" ]]; then df_content=$(curl -sf "$REPLY" 2>/dev/null || echo "") fi DF_NODE_MAJOR="" DF_SOURCE="" if [[ -n "$df_content" ]]; then extract_dockerfile_node "$df_content" fi # Fetch upstream package.json (root + subdirectories) pkg_content="" find_repo_file "$repo" "$detected_branch" "package.json" if [[ -n "$REPLY" ]]; then pkg_content=$(curl -sf "$REPLY" 2>/dev/null || echo "") fi ENGINES_NODE_RAW="" ENGINES_MIN_MAJOR="" ENGINES_IS_MINIMUM="false" if [[ -n "$pkg_content" ]]; then extract_engines_node "$pkg_content" fi # Fallback: check .nvmrc or .node-version NVMRC_NODE_MAJOR="" if [[ -z "$DF_NODE_MAJOR" && -z "$ENGINES_MIN_MAJOR" ]]; then for nvmfile in .nvmrc .node-version; do find_repo_file "$repo" "$detected_branch" "$nvmfile" if [[ -n "$REPLY" ]]; then nvmrc_content=$(curl -sf "$REPLY" 2>/dev/null || echo "") if [[ -n "$nvmrc_content" ]]; then extract_nvmrc_node "$nvmrc_content" [[ -n "$NVMRC_NODE_MAJOR" ]] && break fi fi done fi # Determine upstream recommended major version upstream_major="" upstream_hint="" if [[ -n "$DF_NODE_MAJOR" ]]; then upstream_major="$DF_NODE_MAJOR" upstream_hint="$DF_SOURCE" elif [[ -n "$ENGINES_MIN_MAJOR" ]]; then upstream_major="$ENGINES_MIN_MAJOR" upstream_hint="engines: $ENGINES_NODE_RAW" elif [[ -n "$NVMRC_NODE_MAJOR" ]]; then upstream_major="$NVMRC_NODE_MAJOR" upstream_hint=".nvmrc/.node-version" fi # Build display values engines_display="${ENGINES_NODE_RAW:-—}" dockerfile_display="${DF_SOURCE:-—}" # Compare status="✅" if [[ "$our_version" == "dynamic" ]]; then status="🔄 Dynamic" elif [[ "$our_version" == "unset" ]]; then if [[ -n "$upstream_major" ]]; then status="⚠️ NODE_VERSION not set (upstream=$upstream_major via $upstream_hint)" else status="⚠️ NODE_VERSION not set (no upstream info found)" fi issue_scripts+=("$slug|$our_version|$upstream_major|$upstream_hint|$repo") drift_count=$((drift_count + 1)) elif [[ -n "$upstream_major" && "$our_version" != "$upstream_major" ]]; then # Check if engines.node is a minimum constraint that our version satisfies if [[ -z "$DF_NODE_MAJOR" && "$ENGINES_IS_MINIMUM" == "true" ]] && \ version_satisfies_engines "$our_version" "$ENGINES_MIN_MAJOR" "$ENGINES_IS_MINIMUM"; then status="✅ (engines: $ENGINES_NODE_RAW — ours: $our_version satisfies)" else status="🔸 Drift → upstream=$upstream_major ($upstream_hint)" issue_scripts+=("$slug|$our_version|$upstream_major|$upstream_hint|$repo") drift_count=$((drift_count + 1)) fi fi report_lines+=("| \`$slug\` | $our_version | $engines_display | $dockerfile_display | [$repo](https://github.com/$repo) | $status |") # Rate-limit to avoid GitHub secondary rate limits sleep 0.3 done # Print summary echo "" echo "=========================================" echo " Total scripts with setup_nodejs: $total" echo " Checked (with GitHub source): $checked" echo " Version drift detected: $drift_count" echo "=========================================" # Export { echo "drift_count=$drift_count" echo "total=$total" echo "checked=$checked" } >> "$GITHUB_OUTPUT" # Save issue details for next step printf '%s\n' "${issue_scripts[@]}" > /tmp/drift_scripts.txt 2>/dev/null || touch /tmp/drift_scripts.txt # Save full report { echo "## Node.js Version Drift Report" echo "" echo "**Generated:** $(date -u +%Y-%m-%dT%H:%M:%SZ)" echo "**Scripts checked:** $total | **With GitHub source:** $checked | **Drift detected:** $drift_count" echo "" echo "| Script | Our Version | engines.node | Dockerfile | Upstream Repo | Status |" echo "|--------|-------------|-------------|------------|---------------|--------|" printf '%s\n' "${report_lines[@]}" | sort } > /tmp/drift_report.md cat /tmp/drift_report.md - name: Create or update summary issue if: steps.check.outputs.drift_count != '0' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail TITLE="[Automated] Node.js Version Drift Report" DATE=$(date -u +%Y-%m-%d) DRIFT_COUNT="${{ steps.check.outputs.drift_count }}" TOTAL="${{ steps.check.outputs.total }}" CHECKED="${{ steps.check.outputs.checked }}" # Build checklist from drift data CHECKLIST="" while IFS='|' read -r slug our_version upstream_major upstream_hint repo; do [[ -z "$slug" ]] && continue CHECKLIST+="- [ ] **\`${slug}\`** — ours: \`${our_version}\` → upstream: \`${upstream_major}\` (${upstream_hint}) — [repo](https://github.com/${repo})"$'\n' done < /tmp/drift_scripts.txt # Build full report table REPORT=$(cat /tmp/drift_report.md) BODY=$(cat <-install.sh\` 4. Update \`NODE_VERSION\` in \`ct/.sh\` (update section) if applicable 5. Check off the item above once done
Full report ${REPORT}
--- *This issue is automatically created/updated weekly by the Node.js version drift check workflow.* *Last updated: ${DATE}* ISSUE_EOF ) # Check if a matching open issue already exists EXISTING=$(gh issue list --state open --label "automated,dependencies" --search "\"[Automated] Node.js Version Drift Report\"" --json number --jq '.[0].number // empty' 2>/dev/null || echo "") if [[ -n "$EXISTING" ]]; then gh issue edit "$EXISTING" --body "$BODY" echo "Updated existing issue #$EXISTING" else gh issue create \ --title "$TITLE" \ --body "$BODY" \ --label "automated,dependencies" echo "Created new summary issue" fi - name: Close issue if no drift if: steps.check.outputs.drift_count == '0' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | EXISTING=$(gh issue list --state open --label "automated,dependencies" --search "\"[Automated] Node.js Version Drift Report\"" --json number --jq '.[0].number // empty' 2>/dev/null || echo "") if [[ -n "$EXISTING" ]]; then gh issue close "$EXISTING" --comment "All Node.js versions are in sync with upstream. Closing automatically." echo "Closed issue #$EXISTING" fi