name: Check Node.js Version Drift on: workflow_dispatch: schedule: # Runs weekly on Monday at 06:00 UTC - cron: "0 6 * * 1" permissions: contents: write issues: write pull-requests: write jobs: check-node-versions: if: github.repository == 'community-scripts/ProxmoxVE' runs-on: coolify-runner steps: - name: Checkout Repository uses: actions/checkout@v7 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 full_ver="" local arch_label url_arch url page for arch_pair in "amd64:x86_64" "arm64:aarch64"; do arch_label="${arch_pair%%:*}" url_arch="${arch_pair#*:}" url="https://pkgs.alpinelinux.org/package/v${alpine_ver}/main/${url_arch}/nodejs" page=$(curl -sf "$url" 2>/dev/null || echo "") 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 if [[ -n "$full_ver" ]]; then echo "Resolved Alpine ${alpine_ver} nodejs via ${arch_label}" break fi done 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=\K"?\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 if (( 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 else status="✅ Ahead of upstream ($upstream_major via $upstream_hint)" 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 # One PR per script, not one for all of them: the bumps are judged # individually (an app can be deliberately held back while the next is a # safe follow), and a combined PR is blocked by its worst member. - name: Open a pull request per drifting script if: steps.check.outputs.drift_count != '0' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" : >/tmp/drift_prs.txt while IFS='|' read -r slug our upstream hint repo; do [[ -z "$slug" ]] && continue # "dynamic" and "unset" have no literal to rewrite, and a missing # upstream major is nothing to rewrite it to. if [[ ! "$our" =~ ^[0-9]+$ || ! "$upstream" =~ ^[0-9]+$ ]]; then printf '%s|manual||\n' "$slug" >>/tmp/drift_prs.txt continue fi # The branch name carries the exact bump, so the PR for it answers # the question on its own: open means it is waiting for a review, # closed means someone said no to this bump. A later upstream # release changes the name and gets its own PR. branch="node-drift/${slug}-${our}-to-${upstream}" existing=$(gh pr list --head "$branch" --state all \ --json number,state,url,labels --jq '.[0] // empty' 2>/dev/null || echo "") if [[ -n "$existing" ]]; then state=$(jq -r '.state' <<<"$existing") url=$(jq -r '.url' <<<"$existing") stale=$(jq -r '[.labels[]?.name] | index("stale") // empty' <<<"$existing") case "$state" in OPEN) printf '%s|open|%s\n' "$slug" "$url" >>/tmp/drift_prs.txt continue ;; MERGED) printf '%s|merged|%s\n' "$slug" "$url" >>/tmp/drift_prs.txt continue ;; *) # The stale bot only closes what a human labelled "stale", so # that close carries no verdict on the bump — reopen the case. # A close without it is a decision, and it stands. if [[ -z "$stale" ]]; then printf '%s|declined|%s\n' "$slug" "$url" >>/tmp/drift_prs.txt continue fi git push origin --delete "$branch" >/dev/null 2>&1 || true ;; esac fi git checkout -q -B "$branch" changed=() for f in "install/${slug}-install.sh" "ct/${slug}.sh"; do [[ -f "$f" ]] || continue if grep -qE "NODE_VERSION=\"?${our}\"?" "$f"; then sed -i -E "s/(NODE_VERSION=)\"?${our}\"?/\1\"${upstream}\"/g" "$f" changed+=("$f") fi done if [[ ${#changed[@]} -eq 0 ]]; then printf '%s|manual||\n' "$slug" >>/tmp/drift_prs.txt git checkout -q main continue fi git add "${changed[@]}" git commit -q -m "${slug}: bump Node.js from ${our} to ${upstream}" if ! git push -q -u origin "$branch" 2>/dev/null; then printf '%s|failed||\n' "$slug" >>/tmp/drift_prs.txt git checkout -q main continue fi # The test command is written here rather than left to # pr-test-command.yml: a PR opened with GITHUB_TOKEN does not start # another workflow, so that comment would never arrive. body=$(cat </dev/null); then printf '%s|open|%s\n' "$slug" "$url" >>/tmp/drift_prs.txt else printf '%s|failed||\n' "$slug" >>/tmp/drift_prs.txt fi git checkout -q main 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