Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/codeowners-merge.yaml
Original file line number Diff line number Diff line change
@@ -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
Comment thread
danieljimeneznz marked this conversation as resolved.
Dismissed
Comment on lines +4 to +11
1 change: 1 addition & 0 deletions CODEOWNERS
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* @danieljimeneznz
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -54,11 +55,15 @@ 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
- 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:

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
195 changes: 166 additions & 29 deletions dist/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -29428,16 +29446,14 @@ 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 MIN_NPM_VERSION = "11.10";
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())
Expand All @@ -29449,53 +29465,106 @@ 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 || "<empty>"}"`;
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 || "<empty>"}"`,
};
}
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 v${MIN_NPM_VERSION}+)`,
};
}
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 (requires npm v${MIN_NPM_VERSION}+)`,
};
}
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 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) {
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,
Expand Down Expand Up @@ -29632,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) {
Expand Down Expand Up @@ -29741,16 +29810,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 || "<empty>"}"`,
};
}
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" ||
Expand Down Expand Up @@ -30308,11 +30438,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("; ")}`)
Expand Down
37 changes: 37 additions & 0 deletions src/helpers/config-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading