From b9fdd530c0f370f2fccb6251d700aa6f9bdf4124 Mon Sep 17 00:00:00 2001 From: Elias Schneider Date: Tue, 19 May 2026 17:02:05 +0200 Subject: [PATCH] ci/cd: use dependabot for automatic dependency upgrades --- .github/dependabot.yml | 115 +++- scripts/development/update-dependencies.mjs | 583 -------------------- 2 files changed, 105 insertions(+), 593 deletions(-) delete mode 100755 scripts/development/update-dependencies.mjs diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f33a02cd..a06da956 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,12 +1,107 @@ -# To get started with Dependabot version updates, you'll need to specify which -# package ecosystems to update and where the package manifests are located. -# Please see the documentation for more information: -# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates -# https://containers.dev/guide/dependabot - version: 2 + +pull-request-branch-name: + separator: "-" + +multi-ecosystem-groups: + all-dependencies: + schedule: + interval: "weekly" + day: "friday" + time: "17:00" + timezone: "Europe/Zurich" + labels: + - "dependencies" + updates: - - package-ecosystem: "devcontainers" - directory: "/" - schedule: - interval: weekly + - package-ecosystem: "github-actions" + directory: "/" + patterns: ["*"] + multi-ecosystem-group: "all-dependencies" + open-pull-requests-limit: 2 + commit-message: + prefix: "chore" + include: "scope" + ignore: + - dependency-name: "*" + update-types: ["version-update:semver-major"] + cooldown: + default-days: 7 + + - package-ecosystem: "gomod" + directory: "/backend" + patterns: ["*"] + multi-ecosystem-group: "all-dependencies" + open-pull-requests-limit: 2 + commit-message: + prefix: "chore" + include: "scope" + ignore: + - dependency-name: "*" + update-types: ["version-update:semver-major"] + cooldown: + default-days: 7 + + - package-ecosystem: "npm" + directories: + - "/frontend" + - "/tests" + - "/email-templates" + patterns: ["*"] + multi-ecosystem-group: "all-dependencies" + versioning-strategy: "increase-if-necessary" + open-pull-requests-limit: 2 + commit-message: + prefix: "chore" + include: "scope" + ignore: + - dependency-name: "*" + update-types: ["version-update:semver-major"] + cooldown: + default-days: 7 + + - package-ecosystem: "docker" + directories: + - "/docker" + - "/tests/setup" + patterns: ["*"] + multi-ecosystem-group: "all-dependencies" + open-pull-requests-limit: 2 + commit-message: + prefix: "chore" + include: "scope" + ignore: + - dependency-name: "*" + update-types: ["version-update:semver-major"] + cooldown: + default-days: 7 + + - package-ecosystem: "docker-compose" + directories: + - "/" + - "/tests/setup" + patterns: ["*"] + multi-ecosystem-group: "all-dependencies" + open-pull-requests-limit: 2 + commit-message: + prefix: "chore" + include: "scope" + ignore: + - dependency-name: "*" + update-types: ["version-update:semver-major"] + cooldown: + default-days: 7 + + - package-ecosystem: "devcontainers" + directory: "/" + patterns: ["*"] + multi-ecosystem-group: "all-dependencies" + open-pull-requests-limit: 2 + commit-message: + prefix: "chore" + include: "scope" + ignore: + - dependency-name: "*" + update-types: ["version-update:semver-major"] + cooldown: + default-days: 7 diff --git a/scripts/development/update-dependencies.mjs b/scripts/development/update-dependencies.mjs deleted file mode 100755 index 0803e9b4..00000000 --- a/scripts/development/update-dependencies.mjs +++ /dev/null @@ -1,583 +0,0 @@ -#!/usr/bin/env node - -import { spawnSync } from 'node:child_process'; -import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; -import { exit, stdin as input, stdout as output } from 'node:process'; -import readline from 'node:readline/promises'; - -const ROOT_DIR = path.resolve(import.meta.dirname, '..', '..'); -const BACKEND_DIR = path.join(ROOT_DIR, 'backend'); -const TEMP_DIR = mkdtempSync(path.join(tmpdir(), 'pocket-id-deps-')); - -const CONFIG = { - minimumReleaseAgeDays: 7, - snykArgs: [ - 'test', - '--all-projects', - '--detection-depth=1', - '--severity-threshold=medium', - ], - pnpmProjects: [ - { dir: '.', label: 'root workspace' }, - { dir: 'frontend', label: 'frontend' }, - { dir: 'tests', label: 'tests' }, - { dir: 'email-templates', label: 'email-templates' }, - ], -}; - -const ASSUME_YES = process.argv.includes('--yes') || process.argv.includes('-y'); -const RELEASE_CUTOFF_MS = Date.now() - CONFIG.minimumReleaseAgeDays * 24 * 60 * 60 * 1000; -const COLOR = { - red: '\x1b[31m', - reset: '\x1b[0m', -}; - -const packagePublishTimelineCache = new Map(); -const packagePublishTimeCache = new Map(); -const goModuleVersionTimeCache = new Map(); -const goModuleVersionsCache = new Map(); - -process.on('exit', () => { - rmSync(TEMP_DIR, { recursive: true, force: true }); -}); - -function printSection(title) { - console.log(`\n== ${title} ==`); -} - -function formatDate(value) { - return new Date(value).toISOString(); -} - -function withColor(text, color) { - return output.isTTY ? `${color}${text}${COLOR.reset}` : text; -} - -function parseMajor(version) { - const match = String(version).trim().replace(/^[^\d]*/, '').match(/^(\d+)/); - return match ? Number(match[1]) : null; -} - -function isMajorUpgrade(currentVersion, nextVersion) { - const currentMajor = parseMajor(currentVersion); - const nextMajor = parseMajor(nextVersion); - return currentMajor !== null && nextMajor !== null && nextMajor > currentMajor; -} - -function isSameMajorLine(currentVersion, candidateVersion) { - const currentMajor = parseMajor(currentVersion); - const candidateMajor = parseMajor(candidateVersion); - return currentMajor !== null && currentMajor === candidateMajor; -} - -function highlightUpgrade(text, currentVersion, nextVersion) { - return isMajorUpgrade(currentVersion, nextVersion) ? withColor(text, COLOR.red) : text; -} - -function isOlderThanMinimumAge(releaseTime) { - return new Date(releaseTime).getTime() <= RELEASE_CUTOFF_MS; -} - -function run(command, args, options = {}) { - const result = spawnSync(command, args, { - cwd: options.cwd ?? ROOT_DIR, - encoding: 'utf8', - stdio: options.stdio ?? ['ignore', 'pipe', 'pipe'], - }); - - if (result.error) { - throw new Error(`Failed to run '${command}': ${result.error.message}`); - } - - return result; -} - -function requireCommand(command) { - const result = run('bash', ['-lc', `command -v ${command}`]); - if (result.status !== 0) { - throw new Error(`Required command '${command}' is not installed.`); - } -} - -function parseJson(stdout, errorContext) { - try { - return JSON.parse(stdout); - } catch { - throw new Error(`Failed to parse JSON for ${errorContext}.`); - } -} - -function partitionRows(rows, predicate) { - const eligibleRows = []; - const heldBackRows = []; - - for (const row of rows) { - if (predicate(row)) { - eligibleRows.push(row); - } else { - heldBackRows.push(row); - } - } - - return { eligibleRows, heldBackRows }; -} - -function parsePnpmOutdated(rawText) { - if (!rawText.trim()) { - return []; - } - - const raw = parseJson(rawText, 'pnpm outdated output'); - - const collectRows = (value) => { - if (!value || typeof value !== 'object') return []; - if (Array.isArray(value)) return value.flatMap(collectRows); - if (Array.isArray(value.results)) return collectRows(value.results); - if ('current' in value || 'latest' in value || 'wanted' in value) return [value]; - - return Object.entries(value).flatMap(([name, info]) => { - if (Array.isArray(info)) { - return info.flatMap((entry) => collectRows({ name, ...entry })); - } - if (info && typeof info === 'object') { - return collectRows({ name, ...info }); - } - return []; - }); - }; - - return collectRows(raw) - .map((row) => ({ - name: row.name || row.package || row.packageName || 'unknown', - current: row.current || 'unknown', - latest: row.latest || row.wanted || 'unknown', - type: row.dependencyType || row.type || '', - })) - .filter((row) => row.current !== row.latest) - .sort((left, right) => left.name.localeCompare(right.name)); -} - -function getPnpmPackagePublishTime(name, version) { - const cacheKey = `${name}@${version}`; - const cachedPublishTime = packagePublishTimeCache.get(cacheKey); - if (cachedPublishTime) { - return cachedPublishTime; - } - - let timeline = packagePublishTimelineCache.get(name); - if (!timeline) { - const result = run('pnpm', ['view', name, 'time', '--json']); - if (result.status !== 0 || !result.stdout.trim()) { - throw new Error(`Failed to get publish timeline for ${name}\n${result.stderr}`.trim()); - } - - timeline = parseJson(result.stdout, `pnpm publish timeline for ${name}`); - if (!timeline || typeof timeline !== 'object') { - throw new Error(`Publish timeline for ${name} is unavailable.`); - } - - packagePublishTimelineCache.set(name, timeline); - } - - const publishTime = timeline[version]; - if (!publishTime) { - throw new Error(`Publish time for ${cacheKey} is unavailable.`); - } - - packagePublishTimeCache.set(cacheKey, publishTime); - return publishTime; -} - -function getPnpmUpgradeSummary(project) { - const result = run('pnpm', ['outdated', '--format', 'json'], { - cwd: path.join(ROOT_DIR, project.dir), - }); - - if (result.status !== 0 && !result.stdout.trim()) { - throw new Error(`${project.label}: failed to collect pnpm updates\n${result.stderr}`.trim()); - } - - const rows = parsePnpmOutdated(result.stdout).map((row) => ({ - ...row, - publishTime: getPnpmPackagePublishTime(row.name, row.latest), - })); - - return { - project, - ...partitionRows(rows, (row) => isOlderThanMinimumAge(row.publishTime)), - }; -} - -function parseGoListJsonStream(rawText) { - const objects = []; - let depth = 0; - let start = -1; - let inString = false; - let escaped = false; - - for (let index = 0; index < rawText.length; index += 1) { - const char = rawText[index]; - - if (escaped) { - escaped = false; - continue; - } - - if (char === '\\') { - escaped = true; - continue; - } - - if (char === '"') { - inString = !inString; - continue; - } - - if (inString) { - continue; - } - - if (char === '{') { - if (depth === 0) { - start = index; - } - depth += 1; - continue; - } - - if (char === '}') { - depth -= 1; - if (depth === 0 && start !== -1) { - objects.push(parseJson(rawText.slice(start, index + 1), 'go module JSON stream')); - start = -1; - } - } - } - - return objects; -} - -function getGoModuleVersionTime(name, version) { - const cacheKey = `${name}@${version}`; - const cachedTime = goModuleVersionTimeCache.get(cacheKey); - if (cachedTime) { - return cachedTime; - } - - const result = run('go', ['list', '-m', '-json', `${name}@${version}`], { - cwd: BACKEND_DIR, - }); - - if (result.status !== 0 || !result.stdout.trim()) { - throw new Error(`Failed to get release time for Go module ${cacheKey}\n${result.stderr}`.trim()); - } - - const moduleInfo = parseJson(result.stdout, `Go module ${cacheKey}`); - if (!moduleInfo.Time) { - throw new Error(`Release time for Go module ${cacheKey} is unavailable.`); - } - - goModuleVersionTimeCache.set(cacheKey, moduleInfo.Time); - return moduleInfo.Time; -} - -function getGoModuleVersions(name) { - const cachedVersions = goModuleVersionsCache.get(name); - if (cachedVersions) { - return cachedVersions; - } - - const result = run('go', ['list', '-m', '-versions', '-json', name], { - cwd: BACKEND_DIR, - }); - - if (result.status !== 0 || !result.stdout.trim()) { - throw new Error(`Failed to get versions for Go module ${name}\n${result.stderr}`.trim()); - } - - const moduleInfo = parseJson(result.stdout, `Go module versions for ${name}`); - const versions = Array.isArray(moduleInfo.Versions) ? moduleInfo.Versions : []; - goModuleVersionsCache.set(name, versions); - return versions; -} - -function resolveGoUpgradeTarget(name, currentVersion) { - const versions = getGoModuleVersions(name); - const currentVersionIndex = versions.indexOf(currentVersion); - - const candidateVersions = (currentVersionIndex === -1 ? versions : versions.slice(currentVersionIndex + 1)) - .filter((version) => isSameMajorLine(currentVersion, version)); - - if (candidateVersions.length === 0) { - return null; - } - - const latest = candidateVersions.at(-1); - const latestPublishTime = getGoModuleVersionTime(name, latest); - - for (let index = candidateVersions.length - 1; index >= 0; index -= 1) { - const target = candidateVersions[index]; - const targetPublishTime = getGoModuleVersionTime(name, target); - - if (isOlderThanMinimumAge(targetPublishTime)) { - return { - latest, - latestPublishTime, - target, - targetPublishTime, - }; - } - } - - return { - latest, - latestPublishTime, - target: null, - targetPublishTime: null, - }; -} - -function getGoUpgradeSummary() { - const result = run('go', ['list', '-m', '-u', '-json', 'all'], { - cwd: BACKEND_DIR, - }); - - if (result.status !== 0) { - throw new Error(`backend/go.mod: failed to collect Go updates\n${result.stderr}`.trim()); - } - - const rows = parseGoListJsonStream(result.stdout) - .filter((moduleInfo) => !moduleInfo.Main && moduleInfo.Update?.Version && moduleInfo.Version && !moduleInfo.Indirect) - .map((moduleInfo) => { - const resolution = resolveGoUpgradeTarget(moduleInfo.Path, moduleInfo.Version); - if (!resolution) { - return null; - } - - return { - name: moduleInfo.Path, - current: moduleInfo.Version, - ...resolution, - }; - }) - .filter(Boolean) - .sort((left, right) => left.name.localeCompare(right.name)); - - return partitionRows(rows, (row) => Boolean(row.target)); -} - -function formatPnpmRow(row, statusLabel) { - const suffix = row.type ? ` (${row.type})` : ''; - return highlightUpgrade( - ` - ${row.name}: ${row.current} -> ${row.latest}${suffix} [${statusLabel}, published ${formatDate(row.publishTime)}]`, - row.current, - row.latest - ); -} - -function printPnpmSummaries(summaries) { - printSection(`Planned pnpm Upgrades (minimum age: ${CONFIG.minimumReleaseAgeDays} days)`); - - for (const { project, eligibleRows, heldBackRows } of summaries) { - if (eligibleRows.length === 0 && heldBackRows.length === 0) { - console.log(`${project.label}: no pnpm upgrades available`); - continue; - } - - console.log( - `${project.label}: ${eligibleRows.length} eligible pnpm upgrade(s), ${heldBackRows.length} held back` - ); - - for (const row of eligibleRows) { - console.log(formatPnpmRow(row, 'eligible')); - } - - for (const row of heldBackRows) { - console.log(formatPnpmRow(row, 'held back')); - } - } -} - -function formatGoRow(row) { - const details = row.target === row.latest - ? `[eligible, published ${formatDate(row.targetPublishTime)}]` - : `[eligible fallback, latest ${row.latest} published ${formatDate(row.latestPublishTime)}, selected ${row.target} published ${formatDate(row.targetPublishTime)}]`; - - return highlightUpgrade( - ` - ${row.name}: ${row.current} -> ${row.target} ${details}`, - row.current, - row.target - ); -} - -function formatHeldBackGoRow(row) { - return highlightUpgrade( - ` - ${row.name}: ${row.current} -> ${row.latest} [held back, latest published ${formatDate(row.latestPublishTime)}]`, - row.current, - row.latest - ); -} - -function printGoSummary(summary) { - printSection(`Planned Go Upgrades (minimum age: ${CONFIG.minimumReleaseAgeDays} days)`); - - if (summary.eligibleRows.length === 0 && summary.heldBackRows.length === 0) { - console.log('backend/go.mod: no Go upgrades available'); - return; - } - - console.log( - `backend/go.mod: ${summary.eligibleRows.length} eligible Go upgrade(s), ${summary.heldBackRows.length} held back` - ); - - for (const row of summary.eligibleRows) { - console.log(formatGoRow(row)); - } - - for (const row of summary.heldBackRows) { - console.log(formatHeldBackGoRow(row)); - } -} - -function parseSnykResults(rawText) { - const raw = parseJson(rawText, 'Snyk results'); - const results = Array.isArray(raw) ? raw : Array.isArray(raw.results) ? raw.results : [raw]; - - const totals = { critical: 0, high: 0, medium: 0 }; - let projects = 0; - let affectedProjects = 0; - - for (const result of results) { - if (!result || typeof result !== 'object') { - continue; - } - - projects += 1; - - const vulnerabilities = Array.isArray(result.vulnerabilities) - ? result.vulnerabilities - : Array.isArray(result.issues?.vulnerabilities) - ? result.issues.vulnerabilities - : []; - - if (vulnerabilities.length > 0) { - affectedProjects += 1; - } - - for (const vulnerability of vulnerabilities) { - const severity = String(vulnerability.severity || '').toLowerCase(); - if (severity in totals) { - totals[severity] += 1; - } - } - } - - return { totals, projects, affectedProjects }; -} - -function printSnykStatus(label) { - console.log(`\nCollecting Snyk vulnerability status for ${label.toLowerCase()}...`); - - const outputFile = path.join(TEMP_DIR, `${label.toLowerCase().replaceAll(' ', '-')}.json`); - const result = run('snyk', [...CONFIG.snykArgs, `--json-file-output=${outputFile}`]); - - if (![0, 1].includes(result.status)) { - throw new Error(`${label}: failed to collect Snyk status\n${result.stderr}`.trim()); - } - - const summary = parseSnykResults(readFileSync(outputFile, 'utf8')); - - printSection(`${label} Vulnerability Status`); - console.log(`${label}: ${summary.projects} project scan(s), ${summary.affectedProjects} with vulnerabilities`); - console.log(` - critical: ${summary.totals.critical}`); - console.log(` - high: ${summary.totals.high}`); - console.log(` - medium: ${summary.totals.medium}`); - console.log(` - total: ${Object.values(summary.totals).reduce((sum, count) => sum + count, 0)}`); - - return result.status; -} - -async function confirmUpgrade() { - if (ASSUME_YES) { - return; - } - - const rl = readline.createInterface({ input, output }); - const answer = await rl.question('\nProceed with dependency upgrades? (y/n) '); - rl.close(); - - if (answer !== 'y') { - throw new Error('Dependency upgrade canceled.'); - } -} - -function applyPnpmUpgrades() { - printSection('Applying pnpm Upgrades'); - - const result = run('pnpm', ['update', '-r', '--latest'], { stdio: 'inherit' }); - if (result.status !== 0) { - throw new Error('pnpm workspace upgrade failed.'); - } -} - -function applyGoUpgrades(goSummary) { - printSection('Applying Go Upgrades'); - - if (goSummary.eligibleRows.length === 0) { - console.log(`No Go upgrades met the ${CONFIG.minimumReleaseAgeDays}-day minimum age.`); - } else { - const moduleSpecs = goSummary.eligibleRows.map((row) => `${row.name}@${row.target}`); - const result = run('go', ['get', '-t', ...moduleSpecs], { - cwd: BACKEND_DIR, - stdio: 'inherit', - }); - - if (result.status !== 0) { - throw new Error('Go dependency upgrade failed.'); - } - } - - const tidyResult = run('go', ['mod', 'tidy'], { - cwd: BACKEND_DIR, - stdio: 'inherit', - }); - - if (tidyResult.status !== 0) { - throw new Error('go mod tidy failed.'); - } -} - -async function main() { - requireCommand('pnpm'); - requireCommand('go'); - requireCommand('snyk'); - - const pnpmSummaries = CONFIG.pnpmProjects.map(getPnpmUpgradeSummary); - const goSummary = getGoUpgradeSummary(); - - printPnpmSummaries(pnpmSummaries); - printGoSummary(goSummary); - printSnykStatus('Before Upgrade'); - - const hasEligibleUpgrades = - pnpmSummaries.some((summary) => summary.eligibleRows.length > 0) || - goSummary.eligibleRows.length > 0; - - if (!hasEligibleUpgrades) { - console.log(`\nNo dependency upgrades met the ${CONFIG.minimumReleaseAgeDays}-day minimum age.`); - exit(printSnykStatus('After Upgrade')); - } - - await confirmUpgrade(); - applyPnpmUpgrades(); - applyGoUpgrades(goSummary); - - exit(printSnykStatus('After Upgrade')); -} - -main().catch((error) => { - console.error(error.message); - exit(1); -});