From 1d9d6ea848fb8079f181ddeab49da1013dd12dfa Mon Sep 17 00:00:00 2001
From: "CanbiZ (MickLesk)" <47820557+MickLesk@users.noreply.github.com>
Date: Tue, 24 Feb 2026 11:07:24 +0100
Subject: [PATCH] ci: add weekly Node.js version drift check workflow
Scans all install scripts using setup_nodejs and compares our
NODE_VERSION with upstream Dockerfile and package.json values.
Features:
- Detects FROM node:XX, nodesource/setup_XX, FROM alpine:X.Y
- Resolves Alpine package registry for nodejs version when
upstream uses alpine base images
- Caches Alpine version lookups to minimize requests
- Creates individual GitHub issues per script with investigation
checklist when drift is detected
- Rate-limited to avoid GitHub API throttling
- Runs weekly on Monday at 06:00 UTC + manual dispatch
---
.github/workflows/check-node-versions.yml | 341 ++++++++++++++++++++++
1 file changed, 341 insertions(+)
create mode 100644 .github/workflows/check-node-versions.yml
diff --git a/.github/workflows/check-node-versions.yml b/.github/workflows/check-node-versions.yml
new file mode 100644
index 000000000..fc2a13095
--- /dev/null
+++ b/.github/workflows/check-node-versions.yml
@@ -0,0 +1,341 @@
+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: ubuntu-latest
+
+ 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
+ extract_engines_node() {
+ local content="$1"
+ ENGINES_NODE_RAW=""
+ ENGINES_MIN_MAJOR=""
+
+ ENGINES_NODE_RAW=$(echo "$content" | jq -r '.engines.node // empty' 2>/dev/null || echo "")
+ if [[ -z "$ENGINES_NODE_RAW" ]]; then
+ return
+ 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 "")
+ }
+
+ # 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)
+ source_url=$(head -20 "$script" | grep -oP '(?<=# Source: )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
+
+ # Fetch upstream Dockerfile
+ df_content=""
+ for branch in main master dev; do
+ df_content=$(curl -sf "https://raw.githubusercontent.com/${repo}/${branch}/Dockerfile" 2>/dev/null || echo "")
+ [[ -n "$df_content" ]] && break
+ done
+
+ DF_NODE_MAJOR=""
+ DF_SOURCE=""
+ if [[ -n "$df_content" ]]; then
+ extract_dockerfile_node "$df_content"
+ fi
+
+ # Fetch upstream package.json
+ pkg_content=""
+ for branch in main master dev; do
+ pkg_content=$(curl -sf "https://raw.githubusercontent.com/${repo}/${branch}/package.json" 2>/dev/null || echo "")
+ [[ -n "$pkg_content" ]] && break
+ done
+
+ ENGINES_NODE_RAW=""
+ ENGINES_MIN_MAJOR=""
+ if [[ -n "$pkg_content" ]]; then
+ extract_engines_node "$pkg_content"
+ 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"
+ 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
+ status="⚠️ NODE_VERSION not set"
+ issue_scripts+=("$slug|$our_version|$upstream_major|$upstream_hint|$repo")
+ drift_count=$((drift_count + 1))
+ elif [[ -n "$upstream_major" && "$our_version" != "$upstream_major" ]]; then
+ status="🔸 Drift → upstream=$upstream_major ($upstream_hint)"
+ issue_scripts+=("$slug|$our_version|$upstream_major|$upstream_hint|$repo")
+ drift_count=$((drift_count + 1))
+ 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
|