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