diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 87b0206..a6855f9 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,9 +1,18 @@ version: 2 updates: + # Supply-chain defense: minimum 7-day cooldown before Dependabot opens + # a version-bump PR. Matches `.npmrc` `min-release-age=7` and the + # cargo audit `min-age` policy. Security updates bypass cooldown by + # default (Dependabot's `security-updates`-first behavior). + # When RFC 3923 (`-Zmin-publish-age`) stabilizes on Rust stable + # (~1.98+), the cargo ecosystem can drop to native enforcement + # and `cooldown` can be relaxed. Tracking: see sqlpilot issue. - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" + cooldown: + default-days: 7 labels: - "dependencies" - "github-actions" @@ -12,6 +21,8 @@ updates: directory: "/src-tauri" schedule: interval: "weekly" + cooldown: + default-days: 7 labels: - "dependencies" - "rust" @@ -25,6 +36,8 @@ updates: directory: "/" schedule: interval: "weekly" + cooldown: + default-days: 7 labels: - "dependencies" - "javascript" @@ -33,7 +46,3 @@ updates: npm: patterns: - "*" - -# Minimum package age is enforced by the CI `deps-age` job -# (scripts/check-deps.mjs). Dependabot PRs that bump packages younger -# than 7 days will fail CI and cannot be merged. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5713f79..2aaf3d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -133,23 +133,6 @@ jobs: fi echo "All versions match: $jsver" - deps-age: - name: Dependency Age Gate - needs: detect-changes - if: needs.detect-changes.outputs.npm == 'true' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # v1.0.0 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: '20' - cache: 'npm' - - name: Install npm dependencies (for lockfile resolution) - run: npm ci - - name: Check dependency ages - run: npm run deps:check - lint-ts: name: Lint (TypeScript) needs: detect-changes diff --git a/.npmrc b/.npmrc index 7e06b6a..865191f 100644 --- a/.npmrc +++ b/.npmrc @@ -1,4 +1,4 @@ # Security audit-level=critical -min-release-age=2 +min-release-age=7 package-lock=true diff --git a/package.json b/package.json index ad9ed81..e056c3c 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,7 @@ "type-check": "tsc --noEmit", "test:unit": "vitest run", "test:unit:watch": "vitest", - "test:e2e": "playwright test", - "deps:check": "node scripts/check-deps.mjs" + "test:e2e": "playwright test" }, "dependencies": { "@monaco-editor/react": "^4.7.0", diff --git a/scripts/check-deps.mjs b/scripts/check-deps.mjs deleted file mode 100755 index 5273b92..0000000 --- a/scripts/check-deps.mjs +++ /dev/null @@ -1,212 +0,0 @@ -#!/usr/bin/env node -import { readFileSync } from "node:fs"; -import { resolve, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; -import { execFileSync } from "node:child_process"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const ROOT = resolve(__dirname, ".."); - -const MIN_AGE_DAYS = parseInt(process.env.MIN_AGE_DAYS || "7", 10); -const MIN_AGE_MS = MIN_AGE_DAYS * 24 * 60 * 60 * 1000; -const REGISTRY_CONCURRENCY = 8; - -let failures = 0; - -async function fetchWithRetry(url, opts = {}, retries = 3) { - for (let i = 0; i < retries; i++) { - try { - const res = await fetch(url, opts); - if (res.ok) return res; - if (res.status === 404 || res.status === 405) return null; - if (i < retries - 1) await new Promise((r) => setTimeout(r, 1000 * (i + 1))); - } catch { - if (i < retries - 1) await new Promise((r) => setTimeout(r, 1000 * (i + 1))); - } - } - return null; -} - -async function checkAll(checks) { - const results = []; - for (let i = 0; i < checks.length; i += REGISTRY_CONCURRENCY) { - const batch = checks.slice(i, i + REGISTRY_CONCURRENCY).map(async (check) => { - const age = await checkEntry(check); - if (age !== null) results.push({ ...check, age }); - }); - await Promise.all(batch); - } - return results; -} - -async function checkEntry({ name, version, ecosystem, url }) { - const opts = ecosystem === "cargo" - ? { headers: { "User-Agent": "sqlpilot-dep-check/1.0" } } - : {}; - const res = await fetchWithRetry(url, opts); - if (!res) { - console.warn(` [${ecosystem}] ${name}@${version} — registry unavailable`); - return null; - } - try { - const data = await res.json(); - const created = ecosystem === "npm" - ? data.time?.[version] - : data.version?.created_at; - if (!created) { - console.warn(` [${ecosystem}] ${name}@${version} — no publish time`); - return null; - } - return Date.now() - new Date(created).getTime(); - } catch { - console.warn(` [${ecosystem}] ${name}@${version} — parse error`); - return null; - } -} - -// ── npm: direct dependencies only ──────────────────────────────────── - -const pkgJson = JSON.parse(readFileSync(resolve(ROOT, "package.json"), "utf-8")); -const npmDeps = { ...(pkgJson.dependencies || {}), ...(pkgJson.devDependencies || {}) }; - -let pkgLock; -try { - pkgLock = JSON.parse(readFileSync(resolve(ROOT, "package-lock.json"), "utf-8")); -} catch { - pkgLock = null; -} - -const npmChecks = []; -for (const [name] of Object.entries(npmDeps)) { - const entry = pkgLock?.packages?.[`node_modules/${name}`]; - const version = entry?.version; - if (!version) continue; - npmChecks.push({ - name, version, ecosystem: "npm", - url: `https://registry.npmjs.org/${encodeURIComponent(name)}`, - }); -} - -console.log(`[npm] Checking ${npmChecks.length} direct dependencies (min age: ${MIN_AGE_DAYS}d)...`); -const npmResults = await checkAll(npmChecks); -for (const r of npmResults) { - if (r.age < MIN_AGE_MS) { - const days = (r.age / (24 * 60 * 60 * 1000)).toFixed(1); - console.error(` [npm] ${r.name}@${r.version} too new (${days}d, need >=${MIN_AGE_DAYS}d)`); - failures++; - } -} - -// ── Cargo: resolve workspace crates against Cargo.lock ─────────────── - -const CARGO_TOML = resolve(ROOT, "src-tauri", "Cargo.toml"); -const LOCK_PATH = resolve(ROOT, "src-tauri", "Cargo.lock"); - -function parseCargoLock(path) { - const text = readFileSync(path, "utf-8"); - const packages = []; - let inPackage = false; - let current = {}; - for (const line of text.split("\n")) { - const trimmed = line.trim(); - if (trimmed === "[[package]]") { - if (inPackage) packages.push(current); - inPackage = true; - current = {}; - } else if (inPackage && trimmed.startsWith("name = ")) { - current.name = trimmed.match(/name = "(.+)"/)?.[1]; - } else if (inPackage && trimmed.startsWith("version = ")) { - current.version = trimmed.match(/version = "(.+)"/)?.[1]; - } else if (inPackage && trimmed.startsWith("source = ")) { - current.source = trimmed.match(/source = "(.+)"/)?.[1]; - } else if (inPackage && trimmed === "") { - // empty line within package block is fine, skip - } else if (inPackage && !trimmed.startsWith("[")) { - // skip other fields - } else if (inPackage && trimmed.startsWith("[") && trimmed !== "[[package]]") { - packages.push(current); - inPackage = false; - current = {}; - } - } - if (inPackage) packages.push(current); - return packages.filter((p) => p.name && p.version); -} - -let lockPkgs; -try { - lockPkgs = parseCargoLock(LOCK_PATH); -} catch { - console.warn("[cargo] failed to parse Cargo.lock — skipping"); - lockPkgs = null; -} - -function lookupInLock(name) { - return lockPkgs?.find((p) => p.name === name && p.source?.startsWith("registry+")); -} - -function resolveCargoVersion(crateName) { - const pkg = lookupInLock(crateName); - return pkg ? { version: pkg.version, source: pkg.source } : null; -} - -// Get workspace crate dependency names from Cargo.toml manifests -function parseWorkspaceDepNames() { - const workspaceDir = resolve(ROOT, "src-tauri", "crates"); - const crates = ["mas-core", "mas-export", "mas-admin"]; - const manifests = [ - resolve(ROOT, "src-tauri", "Cargo.toml"), - ...crates.map((c) => resolve(workspaceDir, c, "Cargo.toml")), - ]; - const names = new Set(); - for (const manifest of manifests) { - try { - const toml = readFileSync(manifest, "utf-8"); - let inDeps = false; - for (const line of toml.split("\n")) { - const trimmed = line.trim(); - if (trimmed === "[dependencies]") { inDeps = true; continue; } - if (inDeps && trimmed.startsWith("[")) break; - if (inDeps) { - const m = trimmed.match(/^(\S+)\s*=/); - if (m) names.add(m[1]); - } - } - } catch { /* skip */ } - } - // Exclude path-only deps (workspace members) - for (const local of ["mas-core", "mas-export", "mas-admin", "mas-ai"]) { - names.delete(local); - } - return names; -} - -const cargoChecks = []; -if (lockPkgs) { - const depNames = parseWorkspaceDepNames(); - for (const name of depNames) { - const resolved = resolveCargoVersion(name); - if (!resolved) continue; - cargoChecks.push({ - name, version: resolved.version, ecosystem: "cargo", - url: `https://crates.io/api/v1/crates/${encodeURIComponent(name)}/${encodeURIComponent(resolved.version)}`, - }); - } -} - -console.log(`[cargo] Checking ${cargoChecks.length} direct dependencies (min age: ${MIN_AGE_DAYS}d)...`); -const cargoResults = await checkAll(cargoChecks); -for (const r of cargoResults) { - if (r.age < MIN_AGE_MS) { - const days = (r.age / (24 * 60 * 60 * 1000)).toFixed(1); - console.error(` [cargo] ${r.name}@${r.version} too new (${days}d, need >=${MIN_AGE_DAYS}d)`); - failures++; - } -} - -if (failures > 0) { - console.error(`\n${failures} dependency(s) younger than ${MIN_AGE_DAYS} days. Blocked.`); - process.exit(1); -} - -console.log(`All dependencies are >=${MIN_AGE_DAYS} days old.`);