From ecf609b3485540f0e28c6aa4c99eb68f582896df Mon Sep 17 00:00:00 2001 From: skyflow-bharti Date: Thu, 11 Jun 2026 13:59:44 +0530 Subject: [PATCH] SK-2869 automate dependency upgrades --- .github/workflows/dependency-upgrade.yml | 136 +++++++++++++ .github/workflows/validate-deps.yml | 25 +++ package.json | 10 +- scripts/check-deps.js | 231 +++++++++++++++++++++++ scripts/validate-deps.js | 117 ++++++++++++ 5 files changed, 515 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/dependency-upgrade.yml create mode 100644 .github/workflows/validate-deps.yml create mode 100644 scripts/check-deps.js create mode 100644 scripts/validate-deps.js diff --git a/.github/workflows/dependency-upgrade.yml b/.github/workflows/dependency-upgrade.yml new file mode 100644 index 0000000..8bf9ee2 --- /dev/null +++ b/.github/workflows/dependency-upgrade.yml @@ -0,0 +1,136 @@ +name: Monthly dependency upgrade + +on: + schedule: + - cron: '0 9 1 * *' # 1st of every month at 09:00 UTC + workflow_dispatch: # manual trigger from GitHub Actions UI + +jobs: + upgrade: + name: Bump outdated dependencies and open PR + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Install current dependencies + run: npm install --ignore-scripts + + - name: Run upgrade script + id: upgrade + run: | + node scripts/check-deps.js --apply | tee /tmp/dep-report.txt + EXIT_CODE=${PIPESTATUS[0]} + + { + echo 'report<> $GITHUB_OUTPUT + + if [[ $EXIT_CODE -eq 1 ]]; then + echo "upgraded=true" >> $GITHUB_OUTPUT + else + echo "upgraded=false" >> $GITHUB_OUTPUT + fi + + - name: Write job summary + if: always() + run: | + echo "## Dependency Upgrade — $(date +'%Y-%m-%d')" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + cat /tmp/dep-report.txt >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + + # Try npm install; continue-on-error so ERESOLVE still produces a PR with a warning + - name: Install updated dependencies + id: npm_install + if: steps.upgrade.outputs.upgraded == 'true' + continue-on-error: true + run: | + npm install 2>&1 | tee /tmp/npm-install.log + EXIT=${PIPESTATUS[0]} + if [[ $EXIT -ne 0 ]]; then + echo "failed=true" >> $GITHUB_OUTPUT + else + echo "failed=false" >> $GITHUB_OUTPUT + fi + + - name: Build PR body + id: pr_body + if: steps.upgrade.outputs.upgraded == 'true' + run: | + { + cat << 'HEADER' + Automated monthly dependency upgrade. + + **Policy:** 14-day cooling period — only versions published ≥14 days ago are included. Stable releases only (no alpha/beta/rc). + + **Upgrade report:** + ``` + HEADER + cat /tmp/dep-report.txt + printf '```\n' + } > /tmp/pr-body.md + + if [[ "${{ steps.npm_install.outputs.failed }}" == "true" ]]; then + cat >> /tmp/pr-body.md << 'WARN' + + --- + + ⚠️ **`npm install` failed — resolve peer dependency conflict before merging.** + + A bumped package may conflict with another package's declared `peerDependencies`. + Common causes: + - The bumped package crossed a major version that a plugin hasn't added support for yet. + - A transitive dependency requires a lower version. + + **npm error:** + ``` + WARN + grep -A 30 "npm error" /tmp/npm-install.log >> /tmp/pr-body.md || cat /tmp/npm-install.log >> /tmp/pr-body.md + printf '\n```\n' >> /tmp/pr-body.md + cat >> /tmp/pr-body.md << 'FIX' + + **Fix options:** + 1. Remove the conflicting package if it is unused. + 2. Pin the bumped package back to the last conflict-free version and wait for the plugin to release compatibility. + 3. Last resort: add `--legacy-peer-deps` temporarily and open an issue to track removal. + FIX + fi + + cat >> /tmp/pr-body.md << 'CHECKLIST' + + --- + + **Before merging:** + - [ ] 🔴 HIGH risk items: read the changelog and test manually + - [ ] `npm run build` passes + - [ ] `npm test` passes + - [ ] For any `dependencies` changes (runtime), validate end-to-end with a consumer app + - [ ] `npm audit` clean (run `npm audit fix` if needed) + - [ ] Convert draft → ready for review + CHECKLIST + + # Read the body file into GITHUB_OUTPUT as a multiline value + { + echo 'content<> $GITHUB_OUTPUT + + - name: Open draft PR + if: steps.upgrade.outputs.upgraded == 'true' + uses: peter-evans/create-pull-request@v6 + with: + token: ${{ secrets.GITHUB_TOKEN }} + branch: chore/monthly-dep-upgrade + commit-message: 'chore: monthly dependency upgrade' + title: 'chore: monthly dependency upgrade' + draft: true + labels: dependencies + body: ${{ steps.pr_body.outputs.content }} diff --git a/.github/workflows/validate-deps.yml b/.github/workflows/validate-deps.yml new file mode 100644 index 0000000..2e6b0ea --- /dev/null +++ b/.github/workflows/validate-deps.yml @@ -0,0 +1,25 @@ +name: Validate dependency cooling period + +on: + workflow_dispatch: + pull_request: + branches: + - main + - release/* + paths: + - 'package.json' + +jobs: + validate: + name: 14-day cooling period gate + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Validate dependency versions + run: node scripts/validate-deps.js diff --git a/package.json b/package.json index 0c55b91..12bc2ff 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "analyze": false, "version": "2.5.4", "description": "Skyflow React SDK", - "homepage": "https://github.com/skyflowapi/skyflow-react", + "homepage": "https://github.com/skyflowapi/skyflow-react-js", "main": "lib/index.js", "types": "lib/index.d.ts", "files": [ @@ -14,10 +14,12 @@ "build": "tsc", "test": "jest", "test:cov": "jest --coverage", - "lint": "eslint src/**/*.{js,jsx,ts,tsx,json} tests/**/*.{js,jsx,ts,tsx,json}", - "lint:fix": "eslint --fix 'src/**/*.{js,jsx,ts,tsx,json}' 'tests/**/*.{js,jsx,ts,tsx,json}'", + "lint": "eslint src tests", + "lint:fix": "eslint --fix src tests", "format": "prettier --write 'src/**/*.{js,jsx,ts,tsx,css,md,json}' 'tests/**/*.{js,jsx,ts,tsx,json}' --config ./.prettierrc", - "spellcheck": "cspell '**/*.{ts,tsx,js,jsx,md}'" + "spellcheck": "cspell '**/*.{ts,tsx,js,jsx,md}'", + "check-deps": "node scripts/check-deps.js", + "validate-deps": "node scripts/validate-deps.js" }, "keywords": [ "client", diff --git a/scripts/check-deps.js b/scripts/check-deps.js new file mode 100644 index 0000000..d7a78d7 --- /dev/null +++ b/scripts/check-deps.js @@ -0,0 +1,231 @@ +#!/usr/bin/env node +/** + * Monthly dependency checker. + * + * Usage: + * node scripts/check-deps.js # report only, no file changes + * node scripts/check-deps.js --apply # report + write bumps to package.json + * COOLING_DAYS=30 node scripts/check-deps.js + * + * Exit codes: + * 0 — nothing outdated + * 1 — outdated found (or --apply wrote changes) + */ +'use strict'; + +const { execSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +const COOLING_DAYS = parseInt(process.env.COOLING_DAYS || '14', 10); +const APPLY = process.argv.includes('--apply'); + +// Packages whose major bumps require extra human validation +const HIGH_RISK_DEVDEPS = new Set([ + 'typescript', 'jest', 'jest-environment-jsdom', 'ts-jest', + 'eslint', 'react', 'react-dom', '@babel/core', '@babel/preset-env', + '@babel/preset-typescript', '@babel/preset-react', +]); + +function semverCompare(a, b) { + const parts = v => v.replace(/^[^0-9]*/, '').split('.').map(n => parseInt(n, 10) || 0); + const pa = parts(a), pb = parts(b); + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const diff = (pa[i] || 0) - (pb[i] || 0); + if (diff !== 0) return diff; + } + return 0; +} + +function majorOf(v) { + return parseInt(v.replace(/^[^0-9]*/, '').split('.')[0], 10) || 0; +} + +function isPreRelease(version) { + return /[.-](alpha|beta|rc|next|canary|pre|dev|experimental)/i.test(version); +} + +/** + * Risk levels: + * HIGH - major bump of a runtime dep, or major bump of core toolchain devDep + * MEDIUM - major bump of any other devDep + * LOW - minor or patch bump + */ +function riskLevel(name, currentVersion, newVersion, section) { + const isMajorBump = majorOf(newVersion) > majorOf(currentVersion); + if (!isMajorBump) return 'LOW'; + if (section === 'dependencies') return 'HIGH'; + if (HIGH_RISK_DEVDEPS.has(name)) return 'HIGH'; + return 'MEDIUM'; +} + +function getNpmTimes(pkg) { + try { + const raw = execSync(`npm info "${pkg}" time --json 2>/dev/null`, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + return JSON.parse(raw); + } catch { + return null; + } +} + +function parseSpec(spec) { + const match = spec.match(/^([~^>=<]*)(.+)$/); + return { range: match?.[1] || '', version: match?.[2] || spec }; +} + +function cutoffDate() { + const d = new Date(); + d.setDate(d.getDate() - COOLING_DAYS); + return d; +} + +function daysOld(dateStr) { + return Math.floor((Date.now() - new Date(dateStr)) / 86400000); +} + +function stableVersions(times) { + return Object.entries(times) + .filter(([v]) => v !== 'created' && v !== 'modified' && !isPreRelease(v)) + .sort((a, b) => semverCompare(b[0], a[0])); +} + +function main() { + const pkgPath = path.resolve(process.cwd(), 'package.json'); + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + + const cutoff = cutoffDate(); + const cutoffStr = cutoff.toISOString().slice(0, 10); + const today = new Date().toISOString().slice(0, 10); + + console.log(`\nDependency check — ${today}`); + console.log(`Cooling period: ${COOLING_DAYS} days | Cutoff: ${cutoffStr}`); + if (APPLY) console.log('Mode: --apply (package.json will be updated)'); + console.log(''); + + const outdated = []; + const errors = []; + + for (const section of ['dependencies', 'devDependencies']) { + if (!pkg[section]) continue; + + for (const [name, currentSpec] of Object.entries(pkg[section])) { + const { range, version: currentVersion } = parseSpec(currentSpec); + + process.stdout.write(` checking ${name} ... `); + const times = getNpmTimes(name); + + if (!times) { + process.stdout.write('⚠ registry error\n'); + errors.push(name); + continue; + } + + const stable = stableVersions(times); + const compliant = stable.filter(([, d]) => new Date(d) <= cutoff); + + if (!compliant.length) { + process.stdout.write('— skip (no stable compliant version)\n'); + continue; + } + + const [latestCompliant, compliantDate] = compliant[0]; + const [latestOverall, overallDate] = stable[0]; + + if (semverCompare(latestCompliant, currentVersion) <= 0) { + process.stdout.write(` up to date (${currentVersion})\n`); + continue; + } + + const isExcluded = semverCompare(latestOverall, latestCompliant) > 0; + const age = daysOld(compliantDate); + const risk = riskLevel(name, currentVersion, latestCompliant, section); + + process.stdout.write( + ` OUTDATED ${currentVersion} → ${latestCompliant} [${risk}]` + + (isExcluded ? ` (${latestOverall} excluded — ${daysOld(overallDate)}d old)` : '') + + '\n' + ); + + outdated.push({ + name, + section, + current: currentSpec, + latest: range + latestCompliant, + published: compliantDate.slice(0, 10), + age, + risk, + excluded: isExcluded ? latestOverall : null, + excludedAge: isExcluded ? daysOld(overallDate) : null, + }); + + if (APPLY) { + pkg[section][name] = range + latestCompliant; + } + } + } + + if (APPLY && outdated.length) { + fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n'); + } + + // ── Report ────────────────────────────────────────────────────────────────── + console.log('\n── Report ' + '─'.repeat(71)); + + if (!outdated.length) { + console.log('\n ✓ All dependencies are up to date.\n'); + if (errors.length) { + console.log(` ⚠ ${errors.length} package(s) skipped due to registry errors.\n`); + } + process.exit(0); + } + + const high = outdated.filter(r => r.risk === 'HIGH'); + const medium = outdated.filter(r => r.risk === 'MEDIUM'); + const low = outdated.filter(r => r.risk === 'LOW'); + + console.log(`\n ${outdated.length} package(s) ${APPLY ? 'bumped' : 'can be upgraded'} — ${high.length} HIGH / ${medium.length} MEDIUM / ${low.length} LOW\n`); + + const col = (s, w) => String(s).padEnd(w); + console.log(` ${col('Package', 44)} ${col('Current', 12)} ${col('Available', 12)} Published Age Risk`); + console.log(` ${'─'.repeat(100)}`); + + const RISK_ICON = { HIGH: '🔴', MEDIUM: '🟡', LOW: '🟢' }; + + for (const group of [high, medium, low]) { + for (const r of group) { + const note = r.excluded ? ` ← ${r.excluded} excluded (${r.excludedAge}d old)` : ''; + console.log( + ` ${col(r.name, 44)} ${col(r.current, 12)} ${col(r.latest, 12)} ${r.published} ${String(r.age).padStart(3)}d ${RISK_ICON[r.risk]} ${r.risk}${note}` + ); + } + } + + // ── Risk guidance ──────────────────────────────────────────────────────────── + if (high.length) { + console.log('\n── HIGH risk guidance ' + '─'.repeat(59)); + console.log('\n These packages have breaking changes between major versions.'); + console.log(' Read the changelog before merging:\n'); + for (const r of high) { + const type = r.section === 'dependencies' ? '(runtime — affects consumers)' : '(core toolchain)'; + console.log(` • ${r.name} ${r.current} → ${r.latest} ${type}`); + console.log(` Check: https://www.npmjs.com/package/${r.name}?activeTab=versions`); + } + console.log(''); + } + + if (!APPLY) { + console.log(`\n Next steps: + 1. Review HIGH risk items first and test thoroughly + 2. Bump versions manually in package.json after testing + 3. CI validate-deps gate enforces the 14-day cooling period on your PR\n`); + } else { + console.log('\n package.json updated. Run npm install to apply.\n'); + } + + process.exit(1); +} + +main(); diff --git a/scripts/validate-deps.js b/scripts/validate-deps.js new file mode 100644 index 0000000..dfe8d25 --- /dev/null +++ b/scripts/validate-deps.js @@ -0,0 +1,117 @@ +#!/usr/bin/env node +/** + * 14-day cooling period validator — PR gate. + * + * Checks every version currently pinned in package.json against its npm publish + * date. Fails if any version was published less than COOLING_DAYS days ago. + * + * Run automatically on PRs that touch package.json (see CI.yml). + * Can also be run locally before pushing: + * + * node scripts/validate-deps.js + * COOLING_DAYS=30 node scripts/validate-deps.js + * + * Exit codes: + * 0 — all versions satisfy the cooling period + * 1 — one or more versions are too recent + */ +'use strict'; + +const { execSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +const COOLING_DAYS = parseInt(process.env.COOLING_DAYS || '14', 10); + +function getPublishDate(pkg, version) { + try { + const raw = execSync(`npm info "${pkg}@${version}" time --json 2>/dev/null`, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + const times = JSON.parse(raw); + return times[version] ? new Date(times[version]) : null; + } catch { + return null; + } +} + +function parseSpec(spec) { + const match = spec.match(/^([~^>=<]*)(.+)$/); + return match?.[2] || spec; +} + +function daysOld(date) { + return Math.floor((Date.now() - date.getTime()) / 86400000); +} + +function main() { + const pkgPath = path.resolve(process.cwd(), 'package.json'); + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - COOLING_DAYS); + const cutoffStr = cutoff.toISOString().slice(0, 10); + const today = new Date().toISOString().slice(0, 10); + + console.log(`\n14-day cooling period check — ${today}`); + console.log(`Policy: versions must be published on or before ${cutoffStr}\n`); + + const violations = []; + const registryErrors = []; + + for (const section of ['dependencies', 'devDependencies']) { + if (!pkg[section]) continue; + + for (const [name, spec] of Object.entries(pkg[section])) { + const version = parseSpec(spec); + + process.stdout.write(` ${name}@${version} ... `); + const publishDate = getPublishDate(name, version); + + if (!publishDate) { + process.stdout.write('⚠ could not verify (registry error)\n'); + registryErrors.push({ name, version }); + continue; + } + + const age = daysOld(publishDate); + + if (publishDate > cutoff) { + process.stdout.write(`✗ FAIL (published ${publishDate.toISOString().slice(0, 10)}, only ${age}d old)\n`); + violations.push({ name, version, published: publishDate.toISOString().slice(0, 10), age }); + } else { + process.stdout.write(`✓ ${publishDate.toISOString().slice(0, 10)} (${age}d old)\n`); + } + } + } + + console.log('\n── Result ' + '─'.repeat(71)); + + if (registryErrors.length) { + console.log(`\n ⚠ ${registryErrors.length} package(s) could not be verified:`); + registryErrors.forEach(({ name, version }) => console.log(` - ${name}@${version}`)); + } + + if (!violations.length) { + console.log('\n ✓ All versions satisfy the 14-day cooling period.\n'); + process.exit(0); + } + + console.log(`\n ✗ ${violations.length} version(s) violate the ${COOLING_DAYS}-day cooling period:\n`); + const col = (s, w) => String(s).padEnd(w); + console.log(` ${col('Package', 44)} ${col('Version', 12)} Published Days old`); + console.log(` ${'─'.repeat(72)}`); + violations.forEach(({ name, version, published, age }) => { + console.log(` ${col(name, 44)} ${col(version, 12)} ${published} ${age}d`); + }); + + console.log(` + These versions were published less than ${COOLING_DAYS} days ago. + Wait until ${cutoffStr} has passed or pin to an older compliant version. +`); + + process.exit(1); +} + +main();