This repository contains a reusable GitHub Action that checks out a repository, sets up the required toolchains, discovers runnable projects, and executes standard checks for each one.
Current support:
- Node projects detected by
package.json - Python projects detected by
pyproject.toml
Behavior:
- Single-project repo: runs checks for the one discovered project
- Multi-project repo: runs checks for every discovered project root
- Release Please pull requests are skipped when the PR only changes
.release-please-manifest.json,CHANGELOG.md,package.json, andpackage-lock.json, and both the manifest and a changelog are present in the diff - Optional changed-only mode: limits execution to project roots with changed files
- on pull requests, changed files are calculated from the git merge-base to avoid selecting projects changed only on the base branch
- on pushes, changed files are calculated from the previous pushed commit to
HEAD - changed-only requires both comparison refs to exist in the local checkout, so keep
fetch-depth: 0 - for
pull_request_target,HEADis usually the base branch unless you explicitly check out the pull request head commit
Node checks:
npm run formatnpm run lintnpm run testnpm run build
format and lint are required scripts for Node projects.
Format-script enforcement:
formatmust be a standalone Prettier command (no shell operators such as&&,||,;,|)- when format enforcement is needed, the command must invoke
prettierdirectly (wrapper commands likenpx prettier ...orcross-env ... prettier ...are rejected) - if
formatis not already in check mode, the action rewrites it to check mode by removing--writevariants and enforcing--check - rewritten format commands are executed with
npm exec -- prettier ...so local tool resolution still happens through npm
test and build remain optional. Missing optional scripts are logged and do not fail the action.
Node configuration enforcement:
- every Node project must have a
.nvmrcpinning a numeric Node version of at least22(for example22,v24, or24.1.0); nvm aliases such aslts/*,lts/jod,node, orstableare rejected because they cannot be statically guaranteed to meet the minimum - a Node version of
22or23is allowed but emits a warning: the recommended minimum is24 - every Node project must have a
.npmrcsettingmin-release-ageto at least3(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
.nvmrcand.npmrccover every package in a monorepo - a missing or invalid
.nvmrcor.npmrcfails the action - the check honors
changed-only: a project is validated when it has changed files, so adding the config counts as the change that brings the project into compliance
Python checks:
uv run ruff format --check .uv run ruff check .
Python checks only run when the action detects Ruff usage in pyproject.toml. Otherwise the action emits a warning and continues.
Python configuration enforcement:
- every Python project must configure a dependency cooldown of at least
3days, 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 frompyproject.toml([tool.uv]/[tool.poetry]/poetry-corebuild backend) and fromuv.toml,uv.lock,poetry.toml, orpoetry.lock - uv projects set
exclude-newerto a duration under[tool.uv]inpyproject.tomlor inuv.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 uv0.11.5+ - poetry projects set
min-release-ageto an integer number of days under[solver]inpoetry.toml(for examplepoetry 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-onlythe same way as the Node checks - every Python project must have a
.python-versionpinning a numeric Python version of at least3.13(for example3.14or3.14.1); aliases such aspypy3.10are rejected because they cannot be statically guaranteed to meet the minimum. A missing or invalid.python-versionfails the action - a Python version of
3.13is allowed but emits a warning: the recommended minimum is3.14 - when
requires-pythonis present in[project]ofpyproject.toml, its lower bound is validated the same way: a floor below3.13fails the action and a floor of3.13emits a warning - the
.python-versionfile 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:
- every
.claude-plugin/plugin.jsonand.claude-plugin/marketplace.jsonfound anywhere in the repository (excludingnode_modules,dist, and other build directories) is validated for proper naming - each plugin must set a human-readable
displayNamein Title Case (for example"Proposal Hub"). Without it, the Claude Code/pluginpicker, the marketplace listing, and the connector UI fall back to the kebab-casename.displayNamerequires Claude Code v2.1.143+ - a
displayNameis rejected when it is missing or empty, contains an underscore, or is not Title Case (each word must start with a capital letter or digit; common lowercase connector words such asof,the, andandare allowed after the first word) - each plugin
name(inplugin.jsonand every entry ofmarketplace.json'spluginsarray) must be a kebab-case identifier (lowercase letters, digits, and hyphens), since it is the programmatic id used for installation and tool namespacing - each key under
plugin.json'smcpServersobject must be a kebab-case identifier (lowercase letters, digits, and hyphens). MCP server entries have no display-name field, so the key is shown verbatim as the connector chip in the/pluginUI and also prefixes the MCP tool namespace (mcp__<key>__<tool>); a key with spaces, underscores, or capitals (for example"Proposal Hub") produces an ugly connector label and tool ids marketplace.json's top-levelnamemust be a kebab-case identifier: it is the public marketplace id users type in/plugin install <plugin>@<marketplace>marketplace.jsonhas no recognized top-leveldisplayNamefield, so one set there is silently ignored by Claude Code. The check flags a top-leveldisplayNameand points you to set it on eachpluginsentry instead (a missing per-entrydisplayNamefalls back to the kebab-casename, not to the plugin's ownplugin.json)- this is a repository-wide policy gate: it runs regardless of project discovery or
changed-only, so a non-compliant manifest fails the action
Minimal usage:
name: CI
on:
pull_request:
push:
branches: [main]
jobs:
checks:
runs-on: ubuntu-latest
steps:
- uses: elementx-ai/code-quality-check@main
with:
changed-only: true
base-ref: ${{ github.event.pull_request.base.sha || github.event.before }}If you use pull_request_target, do your own checkout first so HEAD points at the PR head commit:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
- uses: elementx-ai/code-quality-check@main
with:
checkout: false
changed-only: true
base-ref: ${{ github.event.pull_request.base.sha }}Depth control:
project-depth: 0means only the working directory itself is consideredproject-depth: 1means the working directory plus direct child foldersproject-depth: -1means unlimited depth and preserves the current broad discovery behavior
What the action handles internally:
actions/checkout@v6actions/setup-node@v6when apackage.jsonis detectedactions/setup-python@v6andastral-sh/setup-uv@v7when apyproject.tomlis detected- automatic Node dependency installation with
npm ciwhen a lockfile can be resolved
Node install behavior:
- if the repo root is an npm workspace with a root lockfile, it runs one root
npm ci - otherwise, it runs
npm ciinside each selected Node project that has its own lockfile - if no
package-lock.jsonornpm-shrinkwrap.jsonis available for a selected Node project, the action warns and continues
Important constraint:
- Python projects still need a usable
uvproject configuration
If you want, the next iteration can add an optional install phase with repo-specific heuristics.
Useful inputs:
checkout: defaulttruefetch-depth: default0auto-setup: defaulttrueauto-install: defaulttrueproject-depth: default-1node-version: default24node-install-command: defaultnpm cipython-version: default3.14uv-version: optionalchanged-only: defaulttruebase-ref: optionalhead-ref: defaultHEAD
repo_modeproject_countselected_project_countproject_pathsselected_project_pathsdetected_ecosystemspassed_project_pathsfailed_project_pathsexecution_results
passed_project_paths and failed_project_paths are JSON arrays, so downstream workflows can query them with fromJSON(...).
Example:
- id: quality
uses: elementx-ai/code-quality-check@main
with:
changed-only: true
base-ref: ${{ github.event.pull_request.base.sha || github.event.before }}
- name: React to evaluator failure
if: ${{ contains(fromJSON(steps.quality.outputs.failed_project_paths), 'evaluator') }}
run: echo "evaluator failed quality checks"npm install
npm test
npm run buildThis repo includes Release Please. On pushes to main, it opens or updates a release PR. When that PR is merged, Release Please creates the Git tag and GitHub release automatically.