From 3aece7ca2929a09014ff26b3b418fb2949669f40 Mon Sep 17 00:00:00 2001 From: Daniel Jimenez Date: Fri, 19 Jun 2026 16:48:49 +1200 Subject: [PATCH 1/3] fix: Enforce node 22, python 3.13 minimums, warn below node 24, python 3.14 --- README.md | 9 +- action.yml | 2 +- dist/index.mjs | 185 +++++++++++++++++++++++++----- src/helpers/config-files.ts | 37 ++++++ src/helpers/node-config.ts | 76 ++++++++----- src/helpers/python-config.ts | 162 +++++++++++++++++++++++--- src/index.ts | 12 +- test/node-config.test.js | 122 ++++++++++++++++---- test/python-config.test.js | 212 +++++++++++++++++++++++++++++++---- 9 files changed, 705 insertions(+), 112 deletions(-) diff --git a/README.md b/README.md index ba462ba..2be7021 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,8 @@ Format-script enforcement: Node configuration enforcement: -- every Node project must have a `.nvmrc` pinning a numeric Node version of at least `24` (for example `24`, `v24`, or `24.1.0`); nvm aliases such as `lts/*`, `lts/jod`, `node`, or `stable` are rejected because they cannot be statically guaranteed to meet the minimum +- every Node project must have a `.nvmrc` pinning a numeric Node version of at least `22` (for example `22`, `v24`, or `24.1.0`); nvm aliases such as `lts/*`, `lts/jod`, `node`, or `stable` are rejected because they cannot be statically guaranteed to meet the minimum +- a Node version of `22` or `23` is allowed but emits a warning: the recommended minimum is `24` - every Node project must have a `.npmrc` setting `min-release-age` to at least `3` (days), which delays installing newly published package versions as a supply-chain safeguard (requires npm v11.10+) - both files are resolved from the project directory upward to the repository root, so a single root `.nvmrc` and `.npmrc` cover every package in a monorepo - a missing or invalid `.nvmrc` or `.npmrc` fails the action @@ -59,6 +60,10 @@ Python configuration enforcement: - when a project uses both managers, configuring either cooldown satisfies the check - the setting is resolved from the project directory upward to the repository root so a workspace root config covers every member - a missing, too-short, or invalid cooldown fails the action, and the check honors `changed-only` the same way as the Node checks +- every Python project must have a `.python-version` pinning a numeric Python version of at least `3.13` (for example `3.14` or `3.14.1`); aliases such as `pypy3.10` are rejected because they cannot be statically guaranteed to meet the minimum. A missing or invalid `.python-version` fails the action +- a Python version of `3.13` is allowed but emits a warning: the recommended minimum is `3.14` +- when `requires-python` is present in `[project]` of `pyproject.toml`, its lower bound is validated the same way: a floor below `3.13` fails the action and a floor of `3.13` emits a warning +- the `.python-version` file is resolved from the project directory upward to the repository root, so a single root pin covers every package in a monorepo Claude plugin naming enforcement: @@ -142,7 +147,7 @@ Useful inputs: - `project-depth`: default `-1` - `node-version`: default `24` - `node-install-command`: default `npm ci` -- `python-version`: default `3.12` +- `python-version`: default `3.14` - `uv-version`: optional - `changed-only`: default `true` - `base-ref`: optional diff --git a/action.yml b/action.yml index 3364f49..3227a71 100644 --- a/action.yml +++ b/action.yml @@ -28,7 +28,7 @@ inputs: python-version: description: Python version to install when Python projects are detected. required: false - default: "3.12" + default: "3.14" uv-version: description: Optional uv version to install when Python projects are detected. required: false diff --git a/dist/index.mjs b/dist/index.mjs index 6e1e3fb..53935bd 100644 --- a/dist/index.mjs +++ b/dist/index.mjs @@ -29093,6 +29093,24 @@ const normalizeRelativePath = (from, to) => { }; const MIN_DEPENDENCY_AGE_DAYS = 3; +const firstNonEmptyLine = (content) => content + .split(/\r?\n/) + .map((line) => line.trim()) + .find((line) => line.length > 0) ?? ""; +const collectFindings = (entries) => { + const bySeverity = (severity) => entries + .map(({ relativePath, findings }) => ({ + relativePath, + reasons: findings + .filter((finding) => finding.severity === severity) + .map((finding) => finding.reason), + })) + .filter((entry) => entry.reasons.length > 0); + return { + violations: bySeverity("error"), + warnings: bySeverity("warning"), + }; +}; const ancestorChain = (startDir, boundaryDir) => { const boundary = path$1.resolve(boundaryDir); const start = path$1.resolve(startDir); @@ -29428,16 +29446,13 @@ const resolveFirstParent = async (gitRoot, ref, commandExecutor) => { return firstParent.trim() || undefined; }; -const MIN_NODE_MAJOR_VERSION = 24; +const MIN_NODE_MAJOR_VERSION = 22; +const RECOMMENDED_NODE_MAJOR_VERSION = 24; const nodeVersionPattern = /^v?(\d+)(?:\.\d+){0,2}$/; const parseNodeMajorVersion = (value) => { const match = nodeVersionPattern.exec(value); return match ? Number.parseInt(match[1], 10) : undefined; }; -const firstNonEmptyLine = (content) => content - .split(/\r?\n/) - .map((line) => line.trim()) - .find((line) => line.length > 0) ?? ""; const parseMinReleaseAge = (content) => content .split(/\r?\n/) .map((line) => line.trim()) @@ -29449,53 +29464,101 @@ const parseMinReleaseAge = (content) => content const validateNvmrc = async (rootPath, boundaryDirectory) => { const resolved = await readFileUpwards(rootPath, boundaryDirectory, ".nvmrc"); if (!resolved) { - return `missing a .nvmrc file pinning the Node version to at least ${MIN_NODE_MAJOR_VERSION} (for example "${MIN_NODE_MAJOR_VERSION}")`; + return { + severity: "error", + reason: `missing a .nvmrc file pinning the Node version to at least ${MIN_NODE_MAJOR_VERSION} (for example "${RECOMMENDED_NODE_MAJOR_VERSION}")`, + }; } const version = firstNonEmptyLine(resolved.content); const major = parseNodeMajorVersion(version); if (major === undefined) { - return `${resolved.relativePath} must pin a numeric Node version of at least ${MIN_NODE_MAJOR_VERSION} (nvm aliases such as "lts/*" or "node" are not allowed), found: "${version || ""}"`; + return { + severity: "error", + reason: `${resolved.relativePath} must pin a numeric Node version of at least ${MIN_NODE_MAJOR_VERSION} (nvm aliases such as "lts/*" or "node" are not allowed), found: "${version || ""}"`, + }; } if (major < MIN_NODE_MAJOR_VERSION) { - return `${resolved.relativePath} pins Node ${version} but the minimum is ${MIN_NODE_MAJOR_VERSION}`; + return { + severity: "error", + reason: `${resolved.relativePath} pins Node ${version} but the minimum is ${MIN_NODE_MAJOR_VERSION}`, + }; + } + if (major < RECOMMENDED_NODE_MAJOR_VERSION) { + return { + severity: "warning", + reason: `${resolved.relativePath} pins Node ${version}; the recommended minimum is ${RECOMMENDED_NODE_MAJOR_VERSION}`, + }; } return undefined; }; const validateNpmrc = async (rootPath, boundaryDirectory) => { const resolved = await readFileUpwards(rootPath, boundaryDirectory, ".npmrc"); if (!resolved) { - return `missing a .npmrc file with "min-release-age=${MIN_DEPENDENCY_AGE_DAYS}" (requires npm v11.10+)`; + return { + severity: "error", + reason: `missing a .npmrc file with "min-release-age=${MIN_DEPENDENCY_AGE_DAYS}" (requires npm v11.10+)`, + }; } const rawValue = parseMinReleaseAge(resolved.content); if (rawValue === undefined) { - return `${resolved.relativePath} must set "min-release-age" to at least ${MIN_DEPENDENCY_AGE_DAYS}, but the setting is not present`; + return { + severity: "error", + reason: `${resolved.relativePath} must set "min-release-age" to at least ${MIN_DEPENDENCY_AGE_DAYS}, but the setting is not present`, + }; } const days = Number.parseInt(rawValue, 10); if (Number.isNaN(days) || String(days) !== rawValue) { - return `${resolved.relativePath} has an invalid "min-release-age" value: "${rawValue}" (expected an integer number of days)`; + return { + severity: "error", + reason: `${resolved.relativePath} has an invalid "min-release-age" value: "${rawValue}" (expected an integer number of days)`, + }; } if (days < MIN_DEPENDENCY_AGE_DAYS) { - return `${resolved.relativePath} sets "min-release-age=${days}" but the minimum is ${MIN_DEPENDENCY_AGE_DAYS} days`; + return { + severity: "error", + reason: `${resolved.relativePath} sets "min-release-age=${days}" but the minimum is ${MIN_DEPENDENCY_AGE_DAYS} days`, + }; } return undefined; }; const projectHasNodeTarget = (project) => project.targets.some((target) => target.ecosystem === "node"); const findNodeConfigViolations = async (projects, boundaryDirectory) => { const nodeProjects = projects.filter(projectHasNodeTarget); - const violations = await Promise.all(nodeProjects.map(async (project) => { - const reasons = (await Promise.all([ + const entries = await Promise.all(nodeProjects.map(async (project) => { + const findings = (await Promise.all([ validateNvmrc(project.rootPath, boundaryDirectory), validateNpmrc(project.rootPath, boundaryDirectory), - ])).filter((reason) => reason !== undefined); - return reasons.length > 0 - ? { reasons, relativePath: project.relativePath } - : undefined; + ])).filter((finding) => finding !== undefined); + return { relativePath: project.relativePath, findings }; })); - return violations.filter((violation) => violation !== undefined); + return collectFindings(entries); }; const SECONDS_PER_DAY = 86400; const MIN_COOLDOWN_SECONDS = MIN_DEPENDENCY_AGE_DAYS * SECONDS_PER_DAY; +const MIN_PYTHON_VERSION = "3.13"; +const RECOMMENDED_PYTHON_VERSION = "3.14"; +const minPythonRank = 3 * 1000 + 13; +const recommendedPythonRank = 3 * 1000 + 14; +const pythonVersionPattern = /^(\d+)\.(\d+)(?:\.\d+)?$/; +const requiresPythonLowerBoundPattern = /(>=|~=|==|>)\s*(\d+)\.(\d+)/; +const versionRank = (major, minor) => major * 1000 + minor; +const classifyPythonVersion = (major, minor, subject) => { + const rank = versionRank(major, minor); + if (rank < minPythonRank) { + return { + severity: "error", + reason: `${subject} but the minimum is ${MIN_PYTHON_VERSION}`, + }; + } + if (rank < recommendedPythonRank) { + return { + severity: "warning", + reason: `${subject}; the recommended minimum is ${RECOMMENDED_PYTHON_VERSION}`, + }; + } + return undefined; +}; const unitSeconds = { s: 1, sec: 1, @@ -29741,16 +29804,77 @@ const validateCooldown = async (rootPath, boundaryDirectory) => { } return validateUvCooldown(rootPath, boundaryDirectory); }; +const validatePythonVersionFile = async (rootPath, boundaryDirectory) => { + const resolved = await readFileUpwards(rootPath, boundaryDirectory, ".python-version"); + if (!resolved) { + return { + severity: "error", + reason: `missing a .python-version file pinning the Python version to at least ${MIN_PYTHON_VERSION} (for example "${RECOMMENDED_PYTHON_VERSION}")`, + }; + } + const version = firstNonEmptyLine(resolved.content); + const match = pythonVersionPattern.exec(version); + if (!match) { + return { + severity: "error", + reason: `${resolved.relativePath} must pin a numeric Python version of at least ${MIN_PYTHON_VERSION} (aliases such as "pypy3.10" are not allowed), found: "${version || ""}"`, + }; + } + return classifyPythonVersion(Number.parseInt(match[1], 10), Number.parseInt(match[2], 10), `${resolved.relativePath} pins Python ${version}`); +}; +const requiresPythonPattern = /^\s*requires-python\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s#]+))/m; +const readRequiresPython = (body) => { + if (body === undefined) { + return undefined; + } + const match = requiresPythonPattern.exec(body); + if (!match) { + return undefined; + } + return (match[1] ?? match[2] ?? match[3])?.trim(); +}; +const validateRequiresPython = async (rootPath, boundaryDirectory) => { + const directories = ancestorChain(rootPath, boundaryDirectory); + for (const directory of directories) { + const pyproject = await readFileIfExists(path$1.join(directory, "pyproject.toml")); + if (pyproject === undefined) { + continue; + } + const value = readRequiresPython(tableBody(pyproject, "project")); + if (value === undefined) { + continue; + } + const relativePath = path$1.relative(boundaryDirectory, path$1.join(directory, "pyproject.toml")) || "pyproject.toml"; + const match = requiresPythonLowerBoundPattern.exec(value); + if (!match) { + return { + severity: "error", + reason: `${relativePath} has an unparseable "requires-python": "${value}"`, + }; + } + return classifyPythonVersion(Number.parseInt(match[2], 10), Number.parseInt(match[3], 10), `${relativePath} sets requires-python "${value}"`); + } + return undefined; +}; const projectHasPythonTarget = (project) => project.targets.some((target) => target.ecosystem === "python"); const findPythonConfigViolations = async (projects, boundaryDirectory) => { const pythonProjects = projects.filter(projectHasPythonTarget); - const violations = await Promise.all(pythonProjects.map(async (project) => { - const reason = await validateCooldown(project.rootPath, boundaryDirectory); - return reason === undefined - ? undefined - : { reasons: [reason], relativePath: project.relativePath }; + const entries = await Promise.all(pythonProjects.map(async (project) => { + const [cooldownReason, versionFileFinding, requiresPythonFinding] = await Promise.all([ + validateCooldown(project.rootPath, boundaryDirectory), + validatePythonVersionFile(project.rootPath, boundaryDirectory), + validateRequiresPython(project.rootPath, boundaryDirectory), + ]); + const findings = [ + cooldownReason === undefined + ? undefined + : { severity: "error", reason: cooldownReason }, + versionFileFinding, + requiresPythonFinding, + ].filter((finding) => finding !== undefined); + return { relativePath: project.relativePath, findings }; })); - return violations.filter((violation) => violation !== undefined); + return collectFindings(entries); }; const isReleasePleaseMetadataFile = (filename) => filename === ".release-please-manifest.json" || @@ -30308,11 +30432,18 @@ const main = async () => { return; } const configBoundary = await resolveGitRoot(workingDirectory, execCommand$1).catch(() => workingDirectory); - const [nodeConfigViolations, pythonConfigViolations] = await Promise.all([ + const [nodeConfig, pythonConfig] = await Promise.all([ findNodeConfigViolations(selectedProjects, configBoundary), findPythonConfigViolations(selectedProjects, configBoundary), ]); - const configViolations = [...nodeConfigViolations, ...pythonConfigViolations]; + const configWarnings = [...nodeConfig.warnings, ...pythonConfig.warnings]; + for (const warning$1 of configWarnings) { + warning(`${warning$1.relativePath}: ${warning$1.reasons.join("; ")}`); + } + const configViolations = [ + ...nodeConfig.violations, + ...pythonConfig.violations, + ]; if (configViolations.length > 0) { const detail = configViolations .map((violation) => `${violation.relativePath}: ${violation.reasons.join("; ")}`) diff --git a/src/helpers/config-files.ts b/src/helpers/config-files.ts index 6a575b3..92e07df 100644 --- a/src/helpers/config-files.ts +++ b/src/helpers/config-files.ts @@ -8,11 +8,48 @@ export interface ConfigViolation { relativePath: string; } +export interface ConfigCheckResult { + violations: ConfigViolation[]; + warnings: ConfigViolation[]; +} + +export type CheckSeverity = "error" | "warning"; + +export interface CheckFinding { + severity: CheckSeverity; + reason: string; +} + export interface ResolvedConfigFile { content: string; relativePath: string; } +export const firstNonEmptyLine = (content: string): string => + content + .split(/\r?\n/) + .map((line) => line.trim()) + .find((line) => line.length > 0) ?? ""; + +export const collectFindings = ( + entries: Array<{ relativePath: string; findings: CheckFinding[] }>, +): ConfigCheckResult => { + const bySeverity = (severity: CheckSeverity): ConfigViolation[] => + entries + .map(({ relativePath, findings }) => ({ + relativePath, + reasons: findings + .filter((finding) => finding.severity === severity) + .map((finding) => finding.reason), + })) + .filter((entry) => entry.reasons.length > 0); + + return { + violations: bySeverity("error"), + warnings: bySeverity("warning"), + }; +}; + export const ancestorChain = ( startDir: string, boundaryDir: string, diff --git a/src/helpers/node-config.ts b/src/helpers/node-config.ts index df4d206..b9385cf 100644 --- a/src/helpers/node-config.ts +++ b/src/helpers/node-config.ts @@ -1,12 +1,16 @@ import { - ConfigViolation, + CheckFinding, + ConfigCheckResult, MIN_DEPENDENCY_AGE_DAYS, + collectFindings, + firstNonEmptyLine, readFileUpwards, } from "./config-files.js"; import { Project } from "../types.js"; -export const MIN_NODE_MAJOR_VERSION = 24; +export const MIN_NODE_MAJOR_VERSION = 22; +export const RECOMMENDED_NODE_MAJOR_VERSION = 24; const nodeVersionPattern = /^v?(\d+)(?:\.\d+){0,2}$/; @@ -15,12 +19,6 @@ const parseNodeMajorVersion = (value: string): number | undefined => { return match ? Number.parseInt(match[1], 10) : undefined; }; -const firstNonEmptyLine = (content: string): string => - content - .split(/\r?\n/) - .map((line) => line.trim()) - .find((line) => line.length > 0) ?? ""; - const parseMinReleaseAge = (content: string): string | undefined => content .split(/\r?\n/) @@ -34,20 +32,36 @@ const parseMinReleaseAge = (content: string): string | undefined => const validateNvmrc = async ( rootPath: string, boundaryDirectory: string, -): Promise => { +): Promise => { const resolved = await readFileUpwards(rootPath, boundaryDirectory, ".nvmrc"); if (!resolved) { - return `missing a .nvmrc file pinning the Node version to at least ${MIN_NODE_MAJOR_VERSION} (for example "${MIN_NODE_MAJOR_VERSION}")`; + return { + severity: "error", + reason: `missing a .nvmrc file pinning the Node version to at least ${MIN_NODE_MAJOR_VERSION} (for example "${RECOMMENDED_NODE_MAJOR_VERSION}")`, + }; } const version = firstNonEmptyLine(resolved.content); const major = parseNodeMajorVersion(version); if (major === undefined) { - return `${resolved.relativePath} must pin a numeric Node version of at least ${MIN_NODE_MAJOR_VERSION} (nvm aliases such as "lts/*" or "node" are not allowed), found: "${version || ""}"`; + return { + severity: "error", + reason: `${resolved.relativePath} must pin a numeric Node version of at least ${MIN_NODE_MAJOR_VERSION} (nvm aliases such as "lts/*" or "node" are not allowed), found: "${version || ""}"`, + }; } if (major < MIN_NODE_MAJOR_VERSION) { - return `${resolved.relativePath} pins Node ${version} but the minimum is ${MIN_NODE_MAJOR_VERSION}`; + return { + severity: "error", + reason: `${resolved.relativePath} pins Node ${version} but the minimum is ${MIN_NODE_MAJOR_VERSION}`, + }; + } + + if (major < RECOMMENDED_NODE_MAJOR_VERSION) { + return { + severity: "warning", + reason: `${resolved.relativePath} pins Node ${version}; the recommended minimum is ${RECOMMENDED_NODE_MAJOR_VERSION}`, + }; } return undefined; @@ -56,24 +70,36 @@ const validateNvmrc = async ( const validateNpmrc = async ( rootPath: string, boundaryDirectory: string, -): Promise => { +): Promise => { const resolved = await readFileUpwards(rootPath, boundaryDirectory, ".npmrc"); if (!resolved) { - return `missing a .npmrc file with "min-release-age=${MIN_DEPENDENCY_AGE_DAYS}" (requires npm v11.10+)`; + return { + severity: "error", + reason: `missing a .npmrc file with "min-release-age=${MIN_DEPENDENCY_AGE_DAYS}" (requires npm v11.10+)`, + }; } const rawValue = parseMinReleaseAge(resolved.content); if (rawValue === undefined) { - return `${resolved.relativePath} must set "min-release-age" to at least ${MIN_DEPENDENCY_AGE_DAYS}, but the setting is not present`; + return { + severity: "error", + reason: `${resolved.relativePath} must set "min-release-age" to at least ${MIN_DEPENDENCY_AGE_DAYS}, but the setting is not present`, + }; } const days = Number.parseInt(rawValue, 10); if (Number.isNaN(days) || String(days) !== rawValue) { - return `${resolved.relativePath} has an invalid "min-release-age" value: "${rawValue}" (expected an integer number of days)`; + return { + severity: "error", + reason: `${resolved.relativePath} has an invalid "min-release-age" value: "${rawValue}" (expected an integer number of days)`, + }; } if (days < MIN_DEPENDENCY_AGE_DAYS) { - return `${resolved.relativePath} sets "min-release-age=${days}" but the minimum is ${MIN_DEPENDENCY_AGE_DAYS} days`; + return { + severity: "error", + reason: `${resolved.relativePath} sets "min-release-age=${days}" but the minimum is ${MIN_DEPENDENCY_AGE_DAYS} days`, + }; } return undefined; @@ -85,25 +111,21 @@ const projectHasNodeTarget = (project: Project): boolean => export const findNodeConfigViolations = async ( projects: Project[], boundaryDirectory: string, -): Promise => { +): Promise => { const nodeProjects = projects.filter(projectHasNodeTarget); - const violations = await Promise.all( + const entries = await Promise.all( nodeProjects.map(async (project) => { - const reasons = ( + const findings = ( await Promise.all([ validateNvmrc(project.rootPath, boundaryDirectory), validateNpmrc(project.rootPath, boundaryDirectory), ]) - ).filter((reason): reason is string => reason !== undefined); + ).filter((finding): finding is CheckFinding => finding !== undefined); - return reasons.length > 0 - ? { reasons, relativePath: project.relativePath } - : undefined; + return { relativePath: project.relativePath, findings }; }), ); - return violations.filter( - (violation): violation is ConfigViolation => violation !== undefined, - ); + return collectFindings(entries); }; diff --git a/src/helpers/python-config.ts b/src/helpers/python-config.ts index 7a6d390..6198761 100644 --- a/src/helpers/python-config.ts +++ b/src/helpers/python-config.ts @@ -1,11 +1,15 @@ import path from "node:path"; import { - ConfigViolation, + CheckFinding, + ConfigCheckResult, MIN_DEPENDENCY_AGE_DAYS, ancestorChain, + collectFindings, fileExists, + firstNonEmptyLine, readFileIfExists, + readFileUpwards, } from "./config-files.js"; import { Project } from "../types.js"; @@ -13,6 +17,41 @@ import { Project } from "../types.js"; const SECONDS_PER_DAY = 86400; const MIN_COOLDOWN_SECONDS = MIN_DEPENDENCY_AGE_DAYS * SECONDS_PER_DAY; +export const MIN_PYTHON_VERSION = "3.13"; +export const RECOMMENDED_PYTHON_VERSION = "3.14"; + +const minPythonRank = 3 * 1000 + 13; +const recommendedPythonRank = 3 * 1000 + 14; + +const pythonVersionPattern = /^(\d+)\.(\d+)(?:\.\d+)?$/; +const requiresPythonLowerBoundPattern = /(>=|~=|==|>)\s*(\d+)\.(\d+)/; + +const versionRank = (major: number, minor: number): number => + major * 1000 + minor; + +const classifyPythonVersion = ( + major: number, + minor: number, + subject: string, +): CheckFinding | undefined => { + const rank = versionRank(major, minor); + if (rank < minPythonRank) { + return { + severity: "error", + reason: `${subject} but the minimum is ${MIN_PYTHON_VERSION}`, + }; + } + + if (rank < recommendedPythonRank) { + return { + severity: "warning", + reason: `${subject}; the recommended minimum is ${RECOMMENDED_PYTHON_VERSION}`, + }; + } + + return undefined; +}; + const unitSeconds: Record = { s: 1, sec: 1, @@ -379,28 +418,125 @@ const validateCooldown = async ( return validateUvCooldown(rootPath, boundaryDirectory); }; +const validatePythonVersionFile = async ( + rootPath: string, + boundaryDirectory: string, +): Promise => { + const resolved = await readFileUpwards( + rootPath, + boundaryDirectory, + ".python-version", + ); + if (!resolved) { + return { + severity: "error", + reason: `missing a .python-version file pinning the Python version to at least ${MIN_PYTHON_VERSION} (for example "${RECOMMENDED_PYTHON_VERSION}")`, + }; + } + + const version = firstNonEmptyLine(resolved.content); + const match = pythonVersionPattern.exec(version); + if (!match) { + return { + severity: "error", + reason: `${resolved.relativePath} must pin a numeric Python version of at least ${MIN_PYTHON_VERSION} (aliases such as "pypy3.10" are not allowed), found: "${version || ""}"`, + }; + } + + return classifyPythonVersion( + Number.parseInt(match[1], 10), + Number.parseInt(match[2], 10), + `${resolved.relativePath} pins Python ${version}`, + ); +}; + +const requiresPythonPattern = + /^\s*requires-python\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s#]+))/m; + +const readRequiresPython = (body: string | undefined): string | undefined => { + if (body === undefined) { + return undefined; + } + + const match = requiresPythonPattern.exec(body); + if (!match) { + return undefined; + } + + return (match[1] ?? match[2] ?? match[3])?.trim(); +}; + +const validateRequiresPython = async ( + rootPath: string, + boundaryDirectory: string, +): Promise => { + const directories = ancestorChain(rootPath, boundaryDirectory); + + for (const directory of directories) { + const pyproject = await readFileIfExists( + path.join(directory, "pyproject.toml"), + ); + if (pyproject === undefined) { + continue; + } + + const value = readRequiresPython(tableBody(pyproject, "project")); + if (value === undefined) { + continue; + } + + const relativePath = + path.relative( + boundaryDirectory, + path.join(directory, "pyproject.toml"), + ) || "pyproject.toml"; + const match = requiresPythonLowerBoundPattern.exec(value); + if (!match) { + return { + severity: "error", + reason: `${relativePath} has an unparseable "requires-python": "${value}"`, + }; + } + + return classifyPythonVersion( + Number.parseInt(match[2], 10), + Number.parseInt(match[3], 10), + `${relativePath} sets requires-python "${value}"`, + ); + } + + return undefined; +}; + const projectHasPythonTarget = (project: Project): boolean => project.targets.some((target) => target.ecosystem === "python"); export const findPythonConfigViolations = async ( projects: Project[], boundaryDirectory: string, -): Promise => { +): Promise => { const pythonProjects = projects.filter(projectHasPythonTarget); - const violations = await Promise.all( + const entries = await Promise.all( pythonProjects.map(async (project) => { - const reason = await validateCooldown( - project.rootPath, - boundaryDirectory, - ); - return reason === undefined - ? undefined - : { reasons: [reason], relativePath: project.relativePath }; + const [cooldownReason, versionFileFinding, requiresPythonFinding] = + await Promise.all([ + validateCooldown(project.rootPath, boundaryDirectory), + validatePythonVersionFile(project.rootPath, boundaryDirectory), + validateRequiresPython(project.rootPath, boundaryDirectory), + ]); + + const findings = [ + cooldownReason === undefined + ? undefined + : ({ severity: "error", reason: cooldownReason } as CheckFinding), + versionFileFinding, + requiresPythonFinding, + ].filter((finding): finding is CheckFinding => finding !== undefined); + + return { relativePath: project.relativePath, findings }; }), ); - return violations.filter( - (violation): violation is ConfigViolation => violation !== undefined, - ); + return collectFindings(entries); }; diff --git a/src/index.ts b/src/index.ts index 39f2c06..77cb568 100644 --- a/src/index.ts +++ b/src/index.ts @@ -185,11 +185,19 @@ const main = async (): Promise => { workingDirectory, execCommand, ).catch(() => workingDirectory); - const [nodeConfigViolations, pythonConfigViolations] = await Promise.all([ + const [nodeConfig, pythonConfig] = await Promise.all([ findNodeConfigViolations(selectedProjects, configBoundary), findPythonConfigViolations(selectedProjects, configBoundary), ]); - const configViolations = [...nodeConfigViolations, ...pythonConfigViolations]; + const configWarnings = [...nodeConfig.warnings, ...pythonConfig.warnings]; + for (const warning of configWarnings) { + core.warning(`${warning.relativePath}: ${warning.reasons.join("; ")}`); + } + + const configViolations = [ + ...nodeConfig.violations, + ...pythonConfig.violations, + ]; if (configViolations.length > 0) { const detail = configViolations .map( diff --git a/test/node-config.test.js b/test/node-config.test.js index 9c11744..87ee797 100644 --- a/test/node-config.test.js +++ b/test/node-config.test.js @@ -8,6 +8,7 @@ import { MIN_DEPENDENCY_AGE_DAYS } from "../src/helpers/config-files.js"; import { findNodeConfigViolations, MIN_NODE_MAJOR_VERSION, + RECOMMENDED_NODE_MAJOR_VERSION, } from "../src/helpers/node-config.js"; const withTempDir = async (run) => { @@ -37,12 +38,19 @@ const validNpmrc = `min-release-age=${MIN_DEPENDENCY_AGE_DAYS}\n`; test("passes when .nvmrc and .npmrc satisfy the policy", async () => { await withTempDir(async (dir) => { - await fs.writeFile(path.join(dir, ".nvmrc"), "24\n"); + await fs.writeFile( + path.join(dir, ".nvmrc"), + `${RECOMMENDED_NODE_MAJOR_VERSION}\n`, + ); await fs.writeFile(path.join(dir, ".npmrc"), validNpmrc); - const violations = await findNodeConfigViolations([nodeProject(dir)], dir); + const { violations, warnings } = await findNodeConfigViolations( + [nodeProject(dir)], + dir, + ); assert.deepEqual(violations, []); + assert.deepEqual(warnings, []); }); }); @@ -51,7 +59,10 @@ test("rejects nvm aliases in .nvmrc", async () => { await fs.writeFile(path.join(dir, ".nvmrc"), "lts/iron\n"); await fs.writeFile(path.join(dir, ".npmrc"), "min-release-age=7\n"); - const violations = await findNodeConfigViolations([nodeProject(dir)], dir); + const { violations } = await findNodeConfigViolations( + [nodeProject(dir)], + dir, + ); assert.equal(violations.length, 1); assert.ok( @@ -62,21 +73,34 @@ test("rejects nvm aliases in .nvmrc", async () => { test("accepts v-prefixed versions in .nvmrc", async () => { await withTempDir(async (dir) => { - await fs.writeFile(path.join(dir, ".nvmrc"), "v24\n"); + await fs.writeFile( + path.join(dir, ".nvmrc"), + `v${RECOMMENDED_NODE_MAJOR_VERSION}\n`, + ); await fs.writeFile(path.join(dir, ".npmrc"), "min-release-age=7\n"); - const violations = await findNodeConfigViolations([nodeProject(dir)], dir); + const { violations, warnings } = await findNodeConfigViolations( + [nodeProject(dir)], + dir, + ); assert.deepEqual(violations, []); + assert.deepEqual(warnings, []); }); }); test("flags a Node version below the minimum", async () => { await withTempDir(async (dir) => { - await fs.writeFile(path.join(dir, ".nvmrc"), "22\n"); + await fs.writeFile( + path.join(dir, ".nvmrc"), + `${MIN_NODE_MAJOR_VERSION - 1}\n`, + ); await fs.writeFile(path.join(dir, ".npmrc"), validNpmrc); - const violations = await findNodeConfigViolations([nodeProject(dir)], dir); + const { violations } = await findNodeConfigViolations( + [nodeProject(dir)], + dir, + ); assert.equal(violations.length, 1); assert.ok( @@ -87,11 +111,34 @@ test("flags a Node version below the minimum", async () => { }); }); +test("warns when the Node version is at the minimum but below the recommended", async () => { + await withTempDir(async (dir) => { + await fs.writeFile(path.join(dir, ".nvmrc"), `${MIN_NODE_MAJOR_VERSION}\n`); + await fs.writeFile(path.join(dir, ".npmrc"), validNpmrc); + + const { violations, warnings } = await findNodeConfigViolations( + [nodeProject(dir)], + dir, + ); + + assert.deepEqual(violations, []); + assert.equal(warnings.length, 1); + assert.ok( + warnings[0].reasons.some((r) => + r.includes(`recommended minimum is ${RECOMMENDED_NODE_MAJOR_VERSION}`), + ), + ); + }); +}); + test("flags a missing .nvmrc", async () => { await withTempDir(async (dir) => { await fs.writeFile(path.join(dir, ".npmrc"), validNpmrc); - const violations = await findNodeConfigViolations([nodeProject(dir)], dir); + const { violations } = await findNodeConfigViolations( + [nodeProject(dir)], + dir, + ); assert.equal(violations.length, 1); assert.ok(violations[0].reasons.some((r) => r.includes(".nvmrc"))); @@ -103,7 +150,10 @@ test("flags an empty or invalid .nvmrc version", async () => { await fs.writeFile(path.join(dir, ".nvmrc"), "not-a-version\n"); await fs.writeFile(path.join(dir, ".npmrc"), validNpmrc); - const violations = await findNodeConfigViolations([nodeProject(dir)], dir); + const { violations } = await findNodeConfigViolations( + [nodeProject(dir)], + dir, + ); assert.equal(violations.length, 1); assert.ok( @@ -114,9 +164,15 @@ test("flags an empty or invalid .nvmrc version", async () => { test("flags a missing .npmrc", async () => { await withTempDir(async (dir) => { - await fs.writeFile(path.join(dir, ".nvmrc"), "24\n"); + await fs.writeFile( + path.join(dir, ".nvmrc"), + `${RECOMMENDED_NODE_MAJOR_VERSION}\n`, + ); - const violations = await findNodeConfigViolations([nodeProject(dir)], dir); + const { violations } = await findNodeConfigViolations( + [nodeProject(dir)], + dir, + ); assert.equal(violations.length, 1); assert.ok(violations[0].reasons.some((r) => r.includes("min-release-age"))); @@ -125,10 +181,16 @@ test("flags a missing .npmrc", async () => { test("flags a missing min-release-age setting", async () => { await withTempDir(async (dir) => { - await fs.writeFile(path.join(dir, ".nvmrc"), "24\n"); + await fs.writeFile( + path.join(dir, ".nvmrc"), + `${RECOMMENDED_NODE_MAJOR_VERSION}\n`, + ); await fs.writeFile(path.join(dir, ".npmrc"), "save-exact=true\n"); - const violations = await findNodeConfigViolations([nodeProject(dir)], dir); + const { violations } = await findNodeConfigViolations( + [nodeProject(dir)], + dir, + ); assert.equal(violations.length, 1); assert.ok(violations[0].reasons.some((r) => r.includes("not present"))); @@ -137,10 +199,16 @@ test("flags a missing min-release-age setting", async () => { test("flags a min-release-age below the minimum", async () => { await withTempDir(async (dir) => { - await fs.writeFile(path.join(dir, ".nvmrc"), "24\n"); + await fs.writeFile( + path.join(dir, ".nvmrc"), + `${RECOMMENDED_NODE_MAJOR_VERSION}\n`, + ); await fs.writeFile(path.join(dir, ".npmrc"), "min-release-age=2\n"); - const violations = await findNodeConfigViolations([nodeProject(dir)], dir); + const { violations } = await findNodeConfigViolations( + [nodeProject(dir)], + dir, + ); assert.equal(violations.length, 1); assert.ok( @@ -153,10 +221,16 @@ test("flags a min-release-age below the minimum", async () => { test("flags a non-integer min-release-age value", async () => { await withTempDir(async (dir) => { - await fs.writeFile(path.join(dir, ".nvmrc"), "24\n"); + await fs.writeFile( + path.join(dir, ".nvmrc"), + `${RECOMMENDED_NODE_MAJOR_VERSION}\n`, + ); await fs.writeFile(path.join(dir, ".npmrc"), "min-release-age=3days\n"); - const violations = await findNodeConfigViolations([nodeProject(dir)], dir); + const { violations } = await findNodeConfigViolations( + [nodeProject(dir)], + dir, + ); assert.equal(violations.length, 1); assert.ok(violations[0].reasons.some((r) => r.includes("invalid"))); @@ -165,12 +239,15 @@ test("flags a non-integer min-release-age value", async () => { test("resolves config files from an ancestor directory in a monorepo", async () => { await withTempDir(async (dir) => { - await fs.writeFile(path.join(dir, ".nvmrc"), "24\n"); + await fs.writeFile( + path.join(dir, ".nvmrc"), + `${RECOMMENDED_NODE_MAJOR_VERSION}\n`, + ); await fs.writeFile(path.join(dir, ".npmrc"), validNpmrc); const packageDir = path.join(dir, "packages", "app"); await fs.mkdir(packageDir, { recursive: true }); - const violations = await findNodeConfigViolations( + const { violations } = await findNodeConfigViolations( [nodeProject(packageDir, "packages/app")], dir, ); @@ -181,7 +258,7 @@ test("resolves config files from an ancestor directory in a monorepo", async () test("ignores non-Node projects", async () => { await withTempDir(async (dir) => { - const violations = await findNodeConfigViolations( + const { violations } = await findNodeConfigViolations( [ { rootPath: dir, @@ -204,7 +281,10 @@ test("ignores non-Node projects", async () => { test("reports both missing files in a single violation entry", async () => { await withTempDir(async (dir) => { - const violations = await findNodeConfigViolations([nodeProject(dir)], dir); + const { violations } = await findNodeConfigViolations( + [nodeProject(dir)], + dir, + ); assert.equal(violations.length, 1); assert.equal(violations[0].reasons.length, 2); diff --git a/test/python-config.test.js b/test/python-config.test.js index 1890233..7824db1 100644 --- a/test/python-config.test.js +++ b/test/python-config.test.js @@ -5,7 +5,11 @@ import path from "node:path"; import test from "node:test"; import { MIN_DEPENDENCY_AGE_DAYS } from "../src/helpers/config-files.js"; -import { findPythonConfigViolations } from "../src/helpers/python-config.js"; +import { + findPythonConfigViolations, + MIN_PYTHON_VERSION, + RECOMMENDED_PYTHON_VERSION, +} from "../src/helpers/python-config.js"; const withTempDir = async (run) => { const tempDirectory = await fs.mkdtemp( @@ -30,30 +34,39 @@ const pythonProject = (rootPath, relativePath = ".") => ({ ], }); +const writeVersionPin = (dir) => + fs.writeFile( + path.join(dir, ".python-version"), + `${RECOMMENDED_PYTHON_VERSION}\n`, + ); + const pyprojectWith = (excludeNewer) => `[project]\nname = "demo"\n\n[tool.uv]\nexclude-newer = "${excludeNewer}"\n`; test("passes with a friendly-duration cooldown at the minimum", async () => { await withTempDir(async (dir) => { + await writeVersionPin(dir); await fs.writeFile( path.join(dir, "pyproject.toml"), pyprojectWith(`${MIN_DEPENDENCY_AGE_DAYS} days`), ); - const violations = await findPythonConfigViolations( + const { violations, warnings } = await findPythonConfigViolations( [pythonProject(dir)], dir, ); assert.deepEqual(violations, []); + assert.deepEqual(warnings, []); }); }); test("passes with an ISO 8601 duration above the minimum", async () => { await withTempDir(async (dir) => { + await writeVersionPin(dir); await fs.writeFile(path.join(dir, "pyproject.toml"), pyprojectWith("P1W")); - const violations = await findPythonConfigViolations( + const { violations } = await findPythonConfigViolations( [pythonProject(dir)], dir, ); @@ -64,6 +77,7 @@ test("passes with an ISO 8601 duration above the minimum", async () => { test("passes when the cooldown is set in uv.toml", async () => { await withTempDir(async (dir) => { + await writeVersionPin(dir); await fs.writeFile( path.join(dir, "pyproject.toml"), '[project]\nname = "demo"\n', @@ -73,7 +87,7 @@ test("passes when the cooldown is set in uv.toml", async () => { 'exclude-newer = "72 hours"\n', ); - const violations = await findPythonConfigViolations( + const { violations } = await findPythonConfigViolations( [pythonProject(dir)], dir, ); @@ -84,12 +98,13 @@ test("passes when the cooldown is set in uv.toml", async () => { test("flags a missing cooldown", async () => { await withTempDir(async (dir) => { + await writeVersionPin(dir); await fs.writeFile( path.join(dir, "pyproject.toml"), '[project]\nname = "demo"\n', ); - const violations = await findPythonConfigViolations( + const { violations } = await findPythonConfigViolations( [pythonProject(dir)], dir, ); @@ -101,12 +116,13 @@ test("flags a missing cooldown", async () => { test("flags a cooldown below the minimum", async () => { await withTempDir(async (dir) => { + await writeVersionPin(dir); await fs.writeFile( path.join(dir, "pyproject.toml"), pyprojectWith("2 days"), ); - const violations = await findPythonConfigViolations( + const { violations } = await findPythonConfigViolations( [pythonProject(dir)], dir, ); @@ -122,12 +138,13 @@ test("flags a cooldown below the minimum", async () => { test("flags an absolute exclude-newer date as not a rolling cooldown", async () => { await withTempDir(async (dir) => { + await writeVersionPin(dir); await fs.writeFile( path.join(dir, "pyproject.toml"), pyprojectWith("2024-01-01"), ); - const violations = await findPythonConfigViolations( + const { violations } = await findPythonConfigViolations( [pythonProject(dir)], dir, ); @@ -139,12 +156,13 @@ test("flags an absolute exclude-newer date as not a rolling cooldown", async () test("flags an unparseable duration", async () => { await withTempDir(async (dir) => { + await writeVersionPin(dir); await fs.writeFile( path.join(dir, "pyproject.toml"), pyprojectWith("soon-ish"), ); - const violations = await findPythonConfigViolations( + const { violations } = await findPythonConfigViolations( [pythonProject(dir)], dir, ); @@ -156,12 +174,13 @@ test("flags an unparseable duration", async () => { test("ignores exclude-newer outside the [tool.uv] table", async () => { await withTempDir(async (dir) => { + await writeVersionPin(dir); await fs.writeFile( path.join(dir, "pyproject.toml"), '[project]\nname = "demo"\n\n[tool.other]\nexclude-newer = "1 week"\n', ); - const violations = await findPythonConfigViolations( + const { violations } = await findPythonConfigViolations( [pythonProject(dir)], dir, ); @@ -173,6 +192,7 @@ test("ignores exclude-newer outside the [tool.uv] table", async () => { test("resolves the cooldown from an ancestor uv.toml in a workspace", async () => { await withTempDir(async (dir) => { + await writeVersionPin(dir); await fs.writeFile(path.join(dir, "uv.toml"), 'exclude-newer = "1 week"\n'); const packageDir = path.join(dir, "packages", "api"); await fs.mkdir(packageDir, { recursive: true }); @@ -181,7 +201,7 @@ test("resolves the cooldown from an ancestor uv.toml in a workspace", async () = '[project]\nname = "api"\n', ); - const violations = await findPythonConfigViolations( + const { violations } = await findPythonConfigViolations( [pythonProject(packageDir, "packages/api")], dir, ); @@ -195,13 +215,14 @@ const poetryPyproject = (extra = "") => test("passes when a poetry project sets min-release-age at the minimum", async () => { await withTempDir(async (dir) => { + await writeVersionPin(dir); await fs.writeFile(path.join(dir, "pyproject.toml"), poetryPyproject()); await fs.writeFile( path.join(dir, "poetry.toml"), `[solver]\nmin-release-age = ${MIN_DEPENDENCY_AGE_DAYS}\n`, ); - const violations = await findPythonConfigViolations( + const { violations } = await findPythonConfigViolations( [pythonProject(dir)], dir, ); @@ -212,10 +233,11 @@ test("passes when a poetry project sets min-release-age at the minimum", async ( test("flags a poetry project missing min-release-age", async () => { await withTempDir(async (dir) => { + await writeVersionPin(dir); await fs.writeFile(path.join(dir, "pyproject.toml"), poetryPyproject()); await fs.writeFile(path.join(dir, "poetry.lock"), ""); - const violations = await findPythonConfigViolations( + const { violations } = await findPythonConfigViolations( [pythonProject(dir)], dir, ); @@ -228,13 +250,14 @@ test("flags a poetry project missing min-release-age", async () => { test("flags a poetry min-release-age below the minimum", async () => { await withTempDir(async (dir) => { + await writeVersionPin(dir); await fs.writeFile(path.join(dir, "pyproject.toml"), poetryPyproject()); await fs.writeFile( path.join(dir, "poetry.toml"), "[solver]\nmin-release-age = 1\n", ); - const violations = await findPythonConfigViolations( + const { violations } = await findPythonConfigViolations( [pythonProject(dir)], dir, ); @@ -250,13 +273,14 @@ test("flags a poetry min-release-age below the minimum", async () => { test("flags a non-integer poetry min-release-age", async () => { await withTempDir(async (dir) => { + await writeVersionPin(dir); await fs.writeFile(path.join(dir, "pyproject.toml"), poetryPyproject()); await fs.writeFile( path.join(dir, "poetry.toml"), '[solver]\nmin-release-age = "3 days"\n', ); - const violations = await findPythonConfigViolations( + const { violations } = await findPythonConfigViolations( [pythonProject(dir)], dir, ); @@ -268,12 +292,13 @@ test("flags a non-integer poetry min-release-age", async () => { test("detects poetry via poetry-core build backend", async () => { await withTempDir(async (dir) => { + await writeVersionPin(dir); await fs.writeFile( path.join(dir, "pyproject.toml"), '[build-system]\nrequires = ["poetry-core>=2.0.0"]\nbuild-backend = "poetry.core.masonry.api"\n', ); - const violations = await findPythonConfigViolations( + const { violations } = await findPythonConfigViolations( [pythonProject(dir)], dir, ); @@ -285,6 +310,7 @@ test("detects poetry via poetry-core build backend", async () => { test("resolves poetry min-release-age from an ancestor poetry.toml", async () => { await withTempDir(async (dir) => { + await writeVersionPin(dir); await fs.writeFile( path.join(dir, "poetry.toml"), "[solver]\nmin-release-age = 7\n", @@ -296,7 +322,7 @@ test("resolves poetry min-release-age from an ancestor poetry.toml", async () => poetryPyproject(), ); - const violations = await findPythonConfigViolations( + const { violations } = await findPythonConfigViolations( [pythonProject(packageDir, "packages/api")], dir, ); @@ -307,12 +333,13 @@ test("resolves poetry min-release-age from an ancestor poetry.toml", async () => test("accepts a uv cooldown when both managers are configured", async () => { await withTempDir(async (dir) => { + await writeVersionPin(dir); await fs.writeFile( path.join(dir, "pyproject.toml"), poetryPyproject(`\n[tool.uv]\nexclude-newer = "1 week"\n`), ); - const violations = await findPythonConfigViolations( + const { violations } = await findPythonConfigViolations( [pythonProject(dir)], dir, ); @@ -323,12 +350,13 @@ test("accepts a uv cooldown when both managers are configured", async () => { test("treats a [tool.poetry] pyproject without poetry.toml as poetry", async () => { await withTempDir(async (dir) => { + await writeVersionPin(dir); await fs.writeFile( path.join(dir, "pyproject.toml"), '[tool.poetry]\nname = "demo"\nversion = "1.0.0"\n\n[build-system]\nrequires = ["poetry-core"]\nbuild-backend = "poetry.core.masonry.api"\n', ); - const violations = await findPythonConfigViolations( + const { violations } = await findPythonConfigViolations( [pythonProject(dir)], dir, ); @@ -339,9 +367,155 @@ test("treats a [tool.poetry] pyproject without poetry.toml as poetry", async () }); }); +test("flags a missing .python-version", async () => { + await withTempDir(async (dir) => { + await fs.writeFile( + path.join(dir, "pyproject.toml"), + pyprojectWith("1 week"), + ); + + const { violations } = await findPythonConfigViolations( + [pythonProject(dir)], + dir, + ); + + assert.equal(violations.length, 1); + assert.ok(violations[0].reasons.some((r) => r.includes(".python-version"))); + }); +}); + +test("flags a .python-version below the minimum", async () => { + await withTempDir(async (dir) => { + await fs.writeFile(path.join(dir, ".python-version"), "3.12\n"); + await fs.writeFile( + path.join(dir, "pyproject.toml"), + pyprojectWith("1 week"), + ); + + const { violations } = await findPythonConfigViolations( + [pythonProject(dir)], + dir, + ); + + assert.equal(violations.length, 1); + assert.ok( + violations[0].reasons.some((r) => + r.includes(`minimum is ${MIN_PYTHON_VERSION}`), + ), + ); + }); +}); + +test("rejects a non-numeric .python-version", async () => { + await withTempDir(async (dir) => { + await fs.writeFile(path.join(dir, ".python-version"), "pypy3.10\n"); + await fs.writeFile( + path.join(dir, "pyproject.toml"), + pyprojectWith("1 week"), + ); + + const { violations } = await findPythonConfigViolations( + [pythonProject(dir)], + dir, + ); + + assert.equal(violations.length, 1); + assert.ok( + violations[0].reasons.some((r) => r.includes("numeric Python version")), + ); + }); +}); + +test("warns when .python-version is at the minimum but below the recommended", async () => { + await withTempDir(async (dir) => { + await fs.writeFile( + path.join(dir, ".python-version"), + `${MIN_PYTHON_VERSION}\n`, + ); + await fs.writeFile( + path.join(dir, "pyproject.toml"), + pyprojectWith("1 week"), + ); + + const { violations, warnings } = await findPythonConfigViolations( + [pythonProject(dir)], + dir, + ); + + assert.deepEqual(violations, []); + assert.equal(warnings.length, 1); + assert.ok( + warnings[0].reasons.some((r) => + r.includes(`recommended minimum is ${RECOMMENDED_PYTHON_VERSION}`), + ), + ); + }); +}); + +test("accepts a patch-level .python-version above the recommended", async () => { + await withTempDir(async (dir) => { + await fs.writeFile(path.join(dir, ".python-version"), "3.14.1\n"); + await fs.writeFile( + path.join(dir, "pyproject.toml"), + pyprojectWith("1 week"), + ); + + const { violations, warnings } = await findPythonConfigViolations( + [pythonProject(dir)], + dir, + ); + + assert.deepEqual(violations, []); + assert.deepEqual(warnings, []); + }); +}); + +test("flags requires-python below the minimum", async () => { + await withTempDir(async (dir) => { + await writeVersionPin(dir); + await fs.writeFile( + path.join(dir, "pyproject.toml"), + '[project]\nname = "demo"\nrequires-python = ">=3.11"\n\n[tool.uv]\nexclude-newer = "1 week"\n', + ); + + const { violations } = await findPythonConfigViolations( + [pythonProject(dir)], + dir, + ); + + assert.equal(violations.length, 1); + assert.ok( + violations[0].reasons.some( + (r) => + r.includes("requires-python") && + r.includes(`minimum is ${MIN_PYTHON_VERSION}`), + ), + ); + }); +}); + +test("warns when requires-python floor is at the minimum but below the recommended", async () => { + await withTempDir(async (dir) => { + await writeVersionPin(dir); + await fs.writeFile( + path.join(dir, "pyproject.toml"), + `[project]\nname = "demo"\nrequires-python = ">=${MIN_PYTHON_VERSION}"\n\n[tool.uv]\nexclude-newer = "1 week"\n`, + ); + + const { violations, warnings } = await findPythonConfigViolations( + [pythonProject(dir)], + dir, + ); + + assert.deepEqual(violations, []); + assert.equal(warnings.length, 1); + assert.ok(warnings[0].reasons.some((r) => r.includes("requires-python"))); + }); +}); + test("ignores non-Python projects", async () => { await withTempDir(async (dir) => { - const violations = await findPythonConfigViolations( + const { violations } = await findPythonConfigViolations( [ { rootPath: dir, From a9aab30ac7621c7c584ec23b718b15d8880593a7 Mon Sep 17 00:00:00 2001 From: Daniel Jimenez Date: Fri, 19 Jun 2026 17:04:49 +1200 Subject: [PATCH 2/3] fix: Add uv version requirement, copilot suggestions --- README.md | 2 +- dist/index.mjs | 18 ++++++++++++------ src/helpers/node-config.ts | 5 +++-- src/helpers/python-config.ts | 16 +++++++++++----- test/node-config.test.js | 4 ++++ test/python-config.test.js | 24 ++++++++++++++++++++++++ 6 files changed, 55 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 2be7021..477d4cb 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ Python checks only run when the action detects Ruff usage in `pyproject.toml`. O Python configuration enforcement: - every Python project must configure a dependency cooldown of at least `3` days, which delays resolving newly published package versions as a supply-chain safeguard. The required setting depends on the project's package manager, which the action detects from `pyproject.toml` (`[tool.uv]` / `[tool.poetry]` / `poetry-core` build backend) and from `uv.toml`, `uv.lock`, `poetry.toml`, or `poetry.lock` -- uv projects set [`exclude-newer`](https://docs.astral.sh/uv/concepts/resolution/#dependency-cooldowns) to a duration under `[tool.uv]` in `pyproject.toml` or in `uv.toml`. The duration may be a friendly value (`"3 days"`, `"72 hours"`, `"1 week"`) or an ISO 8601 duration (`"P3D"`, `"PT72H"`); an absolute date is rejected because it is a fixed pin rather than a rolling cooldown +- uv projects set [`exclude-newer`](https://docs.astral.sh/uv/concepts/resolution/#dependency-cooldowns) to a duration under `[tool.uv]` in `pyproject.toml` or in `uv.toml`. The duration may be a friendly value (`"3 days"`, `"72 hours"`, `"1 week"`) or an ISO 8601 duration (`"P3D"`, `"PT72H"`); an absolute date is rejected because it is a fixed pin rather than a rolling cooldown. Duration-based cooldowns require uv `0.11.5`+ - poetry projects set [`min-release-age`](https://python-poetry.org/docs/configuration/#solvermin-release-age) to an integer number of days under `[solver]` in `poetry.toml` (for example `poetry config --local solver.min-release-age 3`) - when a project uses both managers, configuring either cooldown satisfies the check - the setting is resolved from the project directory upward to the repository root so a workspace root config covers every member diff --git a/dist/index.mjs b/dist/index.mjs index 53935bd..6ba1180 100644 --- a/dist/index.mjs +++ b/dist/index.mjs @@ -29448,6 +29448,7 @@ const resolveFirstParent = async (gitRoot, ref, commandExecutor) => { const MIN_NODE_MAJOR_VERSION = 22; const RECOMMENDED_NODE_MAJOR_VERSION = 24; +const MIN_NPM_VERSION = "11.10"; const nodeVersionPattern = /^v?(\d+)(?:\.\d+){0,2}$/; const parseNodeMajorVersion = (value) => { const match = nodeVersionPattern.exec(value); @@ -29496,14 +29497,14 @@ const validateNpmrc = async (rootPath, boundaryDirectory) => { if (!resolved) { return { severity: "error", - reason: `missing a .npmrc file with "min-release-age=${MIN_DEPENDENCY_AGE_DAYS}" (requires npm v11.10+)`, + reason: `missing a .npmrc file with "min-release-age=${MIN_DEPENDENCY_AGE_DAYS}" (requires npm v${MIN_NPM_VERSION}+)`, }; } const rawValue = parseMinReleaseAge(resolved.content); if (rawValue === undefined) { return { severity: "error", - reason: `${resolved.relativePath} must set "min-release-age" to at least ${MIN_DEPENDENCY_AGE_DAYS}, but the setting is not present`, + reason: `${resolved.relativePath} must set "min-release-age" to at least ${MIN_DEPENDENCY_AGE_DAYS}, but the setting is not present (requires npm v${MIN_NPM_VERSION}+)`, }; } const days = Number.parseInt(rawValue, 10); @@ -29538,11 +29539,16 @@ const SECONDS_PER_DAY = 86400; const MIN_COOLDOWN_SECONDS = MIN_DEPENDENCY_AGE_DAYS * SECONDS_PER_DAY; const MIN_PYTHON_VERSION = "3.13"; const RECOMMENDED_PYTHON_VERSION = "3.14"; -const minPythonRank = 3 * 1000 + 13; -const recommendedPythonRank = 3 * 1000 + 14; +const MIN_UV_VERSION = "0.11.5"; const pythonVersionPattern = /^(\d+)\.(\d+)(?:\.\d+)?$/; const requiresPythonLowerBoundPattern = /(>=|~=|==|>)\s*(\d+)\.(\d+)/; const versionRank = (major, minor) => major * 1000 + minor; +const rankFromVersion = (value) => { + const [major, minor] = value.split("."); + return versionRank(Number.parseInt(major, 10), Number.parseInt(minor, 10)); +}; +const minPythonRank = rankFromVersion(MIN_PYTHON_VERSION); +const recommendedPythonRank = rankFromVersion(RECOMMENDED_PYTHON_VERSION); const classifyPythonVersion = (major, minor, subject) => { const rank = versionRank(major, minor); if (rank < minPythonRank) { @@ -29695,10 +29701,10 @@ const resolveExcludeNewer = async (rootPath, boundaryDirectory) => { const validateUvCooldown = async (rootPath, boundaryDirectory) => { const resolved = await resolveExcludeNewer(rootPath, boundaryDirectory); if (!resolved) { - return `missing a uv dependency cooldown: set "exclude-newer" to at least "${MIN_DEPENDENCY_AGE_DAYS} days" under [tool.uv] in pyproject.toml or in uv.toml`; + return `missing a uv dependency cooldown: set "exclude-newer" to at least "${MIN_DEPENDENCY_AGE_DAYS} days" under [tool.uv] in pyproject.toml or in uv.toml (duration cooldowns require uv ${MIN_UV_VERSION}+)`; } if (/^\d{4}-\d{2}-\d{2}/.test(resolved.value)) { - return `${resolved.relativePath} sets "exclude-newer" to a fixed date ("${resolved.value}"); use a duration such as "${MIN_DEPENDENCY_AGE_DAYS} days" for a rolling cooldown`; + return `${resolved.relativePath} sets "exclude-newer" to a fixed date ("${resolved.value}"); use a duration such as "${MIN_DEPENDENCY_AGE_DAYS} days" for a rolling cooldown (requires uv ${MIN_UV_VERSION}+)`; } const seconds = durationToSeconds(resolved.value); if (seconds === undefined) { diff --git a/src/helpers/node-config.ts b/src/helpers/node-config.ts index b9385cf..1fe9c54 100644 --- a/src/helpers/node-config.ts +++ b/src/helpers/node-config.ts @@ -11,6 +11,7 @@ import { Project } from "../types.js"; export const MIN_NODE_MAJOR_VERSION = 22; export const RECOMMENDED_NODE_MAJOR_VERSION = 24; +export const MIN_NPM_VERSION = "11.10"; const nodeVersionPattern = /^v?(\d+)(?:\.\d+){0,2}$/; @@ -75,7 +76,7 @@ const validateNpmrc = async ( if (!resolved) { return { severity: "error", - reason: `missing a .npmrc file with "min-release-age=${MIN_DEPENDENCY_AGE_DAYS}" (requires npm v11.10+)`, + reason: `missing a .npmrc file with "min-release-age=${MIN_DEPENDENCY_AGE_DAYS}" (requires npm v${MIN_NPM_VERSION}+)`, }; } @@ -83,7 +84,7 @@ const validateNpmrc = async ( if (rawValue === undefined) { return { severity: "error", - reason: `${resolved.relativePath} must set "min-release-age" to at least ${MIN_DEPENDENCY_AGE_DAYS}, but the setting is not present`, + reason: `${resolved.relativePath} must set "min-release-age" to at least ${MIN_DEPENDENCY_AGE_DAYS}, but the setting is not present (requires npm v${MIN_NPM_VERSION}+)`, }; } diff --git a/src/helpers/python-config.ts b/src/helpers/python-config.ts index 6198761..a154210 100644 --- a/src/helpers/python-config.ts +++ b/src/helpers/python-config.ts @@ -19,9 +19,7 @@ const MIN_COOLDOWN_SECONDS = MIN_DEPENDENCY_AGE_DAYS * SECONDS_PER_DAY; export const MIN_PYTHON_VERSION = "3.13"; export const RECOMMENDED_PYTHON_VERSION = "3.14"; - -const minPythonRank = 3 * 1000 + 13; -const recommendedPythonRank = 3 * 1000 + 14; +export const MIN_UV_VERSION = "0.11.5"; const pythonVersionPattern = /^(\d+)\.(\d+)(?:\.\d+)?$/; const requiresPythonLowerBoundPattern = /(>=|~=|==|>)\s*(\d+)\.(\d+)/; @@ -29,6 +27,14 @@ const requiresPythonLowerBoundPattern = /(>=|~=|==|>)\s*(\d+)\.(\d+)/; const versionRank = (major: number, minor: number): number => major * 1000 + minor; +const rankFromVersion = (value: string): number => { + const [major, minor] = value.split("."); + return versionRank(Number.parseInt(major, 10), Number.parseInt(minor, 10)); +}; + +const minPythonRank = rankFromVersion(MIN_PYTHON_VERSION); +const recommendedPythonRank = rankFromVersion(RECOMMENDED_PYTHON_VERSION); + const classifyPythonVersion = ( major: number, minor: number, @@ -250,11 +256,11 @@ const validateUvCooldown = async ( ): Promise => { const resolved = await resolveExcludeNewer(rootPath, boundaryDirectory); if (!resolved) { - return `missing a uv dependency cooldown: set "exclude-newer" to at least "${MIN_DEPENDENCY_AGE_DAYS} days" under [tool.uv] in pyproject.toml or in uv.toml`; + return `missing a uv dependency cooldown: set "exclude-newer" to at least "${MIN_DEPENDENCY_AGE_DAYS} days" under [tool.uv] in pyproject.toml or in uv.toml (duration cooldowns require uv ${MIN_UV_VERSION}+)`; } if (/^\d{4}-\d{2}-\d{2}/.test(resolved.value)) { - return `${resolved.relativePath} sets "exclude-newer" to a fixed date ("${resolved.value}"); use a duration such as "${MIN_DEPENDENCY_AGE_DAYS} days" for a rolling cooldown`; + return `${resolved.relativePath} sets "exclude-newer" to a fixed date ("${resolved.value}"); use a duration such as "${MIN_DEPENDENCY_AGE_DAYS} days" for a rolling cooldown (requires uv ${MIN_UV_VERSION}+)`; } const seconds = durationToSeconds(resolved.value); diff --git a/test/node-config.test.js b/test/node-config.test.js index 87ee797..29d1768 100644 --- a/test/node-config.test.js +++ b/test/node-config.test.js @@ -8,6 +8,7 @@ import { MIN_DEPENDENCY_AGE_DAYS } from "../src/helpers/config-files.js"; import { findNodeConfigViolations, MIN_NODE_MAJOR_VERSION, + MIN_NPM_VERSION, RECOMMENDED_NODE_MAJOR_VERSION, } from "../src/helpers/node-config.js"; @@ -176,6 +177,9 @@ test("flags a missing .npmrc", async () => { assert.equal(violations.length, 1); assert.ok(violations[0].reasons.some((r) => r.includes("min-release-age"))); + assert.ok( + violations[0].reasons.some((r) => r.includes(`npm v${MIN_NPM_VERSION}`)), + ); }); }); diff --git a/test/python-config.test.js b/test/python-config.test.js index 7824db1..90a2453 100644 --- a/test/python-config.test.js +++ b/test/python-config.test.js @@ -8,6 +8,7 @@ import { MIN_DEPENDENCY_AGE_DAYS } from "../src/helpers/config-files.js"; import { findPythonConfigViolations, MIN_PYTHON_VERSION, + MIN_UV_VERSION, RECOMMENDED_PYTHON_VERSION, } from "../src/helpers/python-config.js"; @@ -111,6 +112,9 @@ test("flags a missing cooldown", async () => { assert.equal(violations.length, 1); assert.ok(violations[0].reasons.some((r) => r.includes("exclude-newer"))); + assert.ok( + violations[0].reasons.some((r) => r.includes(`uv ${MIN_UV_VERSION}`)), + ); }); }); @@ -470,6 +474,26 @@ test("accepts a patch-level .python-version above the recommended", async () => }); }); +test("resolves .python-version from an ancestor directory in a monorepo", async () => { + await withTempDir(async (dir) => { + await writeVersionPin(dir); + const packageDir = path.join(dir, "packages", "api"); + await fs.mkdir(packageDir, { recursive: true }); + await fs.writeFile( + path.join(packageDir, "pyproject.toml"), + pyprojectWith("1 week"), + ); + + const { violations, warnings } = await findPythonConfigViolations( + [pythonProject(packageDir, "packages/api")], + dir, + ); + + assert.deepEqual(violations, []); + assert.deepEqual(warnings, []); + }); +}); + test("flags requires-python below the minimum", async () => { await withTempDir(async (dir) => { await writeVersionPin(dir); From 29e9df0ae31e3d25caec3fb0a0921b602c0db08a Mon Sep 17 00:00:00 2001 From: Daniel Jimenez Date: Fri, 19 Jun 2026 17:14:21 +1200 Subject: [PATCH 3/3] fix: Add codeowners --- .github/workflows/codeowners-merge.yaml | 11 +++++++++++ CODEOWNERS | 1 + 2 files changed, 12 insertions(+) create mode 100644 .github/workflows/codeowners-merge.yaml create mode 100644 CODEOWNERS diff --git a/.github/workflows/codeowners-merge.yaml b/.github/workflows/codeowners-merge.yaml new file mode 100644 index 0000000..7a22682 --- /dev/null +++ b/.github/workflows/codeowners-merge.yaml @@ -0,0 +1,11 @@ +--- +name: Codeowners Merge +on: + pull_request_target: { types: [opened] } + issue_comment: { types: [created] } + pull_request_review: { types: [submitted] } + +jobs: + merge-check: + uses: elementx-ai/workflows/.github/workflows/codeowners-merge.yaml@main + secrets: inherit diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000..08de39a --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1 @@ +* @danieljimeneznz