
Frappe Testing Cicd
- 59 installs
- 159 repo stars
- Updated July 8, 2026
- openaec-foundation/erpnext_anthropic_claude_development_skill_package
Set up Frappe CI/CD with GitHub Actions, test matrix, MariaDB/Redis services, semgrep, pre-commit, ruff, and eslint.
About
Guides setting up CI/CD pipelines for Frappe apps with GitHub Actions, linting, and security scanning. A developer uses it when configuring automated tests and code quality gates for a Frappe app.
- Set up GitHub Actions test workflows for Frappe apps
- Covers test matrix, semgrep, pre-commit, ruff, and eslint
Frappe Testing Cicd by the numbers
- 59 all-time installs (skills.sh)
- Ranked #660 of 1,435 DevOps & CI/CD skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/openaec-foundation/erpnext_anthropic_claude_development_skill_package --skill frappe-testing-cicdAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 59 |
|---|---|
| repo stars | ★ 159 |
| Last updated | July 8, 2026 |
| Repository | openaec-foundation/erpnext_anthropic_claude_development_skill_package ↗ |
What it does
Set up Frappe CI/CD with GitHub Actions, test matrix, MariaDB/Redis services, semgrep, pre-commit, ruff, and eslint.
Files
CI/CD Pipelines
Quick Reference
| Task | Tool / File |
|---|---|
| Install pre-commit hooks | pre-commit install --hook-type pre-commit --hook-type commit-msg |
| Run all pre-commit checks | pre-commit run --all-files |
| Run linter | ruff check . |
| Run formatter | ruff format . |
| Run ESLint | npx eslint "**/*.js" --quiet |
| Run tests in CI | bench --site test_site run-tests --app myapp |
| Run parallel tests | bench --site test_site run-parallel-tests --total-builds 2 --build-number 0 |
| Generate coverage | coverage run -m pytest && coverage xml |
| Generate JUnit XML | bench --site test_site run-tests --junit-xml-output report.xml |
Decision Tree: CI/CD Setup
Setting up CI for a Frappe app?
├─ Start with GitHub Actions workflow
│ ├─ ALWAYS include MariaDB + Redis services
│ ├─ ALWAYS use test matrix for Python versions
│ └─ Optionally add PostgreSQL for dual-DB support
├─ Add pre-commit hooks
│ ├─ ALWAYS include ruff (Python linting + formatting)
│ ├─ ALWAYS include eslint + prettier (JS/Vue)
│ └─ Add commitlint for conventional commits
├─ Add security scanning?
│ └─ YES → Add semgrep with Frappe-specific rules
└─ Need release automation?
└─ YES → Add tag-based release workflowGitHub Actions Workflow for Frappe Apps
Standard Server Test Workflow
name: Server Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
concurrency:
group: server-tests-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
python-version: ["3.11", "3.12"]
db: ["mariadb"]
services:
mariadb:
image: mariadb:11.4
ports:
- 3306:3306
env:
MARIADB_ROOT_PASSWORD: db_root
options: >-
--health-cmd="healthcheck.sh --connect --innodb_initialized"
--health-interval=5s
--health-timeout=5s
--health-retries=10
redis-cache:
image: redis:alpine
ports:
- 13000:6379
redis-queue:
image: redis:alpine
ports:
- 11000:6379
steps:
- name: Checkout frappe
uses: actions/checkout@v4
with:
repository: frappe/frappe
path: frappe-bench/apps/frappe
- name: Checkout app
uses: actions/checkout@v4
with:
path: frappe-bench/apps/myapp
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install bench
run: pip install frappe-bench
- name: Init bench
working-directory: frappe-bench
run: |
bench init --skip-assets --skip-redis-config-generation .
bench set-config -g db_root_password db_root
bench set-config -g redis_cache redis://localhost:13000
bench set-config -g redis_queue redis://localhost:11000
- name: Install app
working-directory: frappe-bench
run: |
bench get-app --skip-assets myapp ./apps/myapp
bench setup requirements --dev
bench new-site test_site \
--db-root-password db_root \
--admin-password admin \
--no-mariadb-socket
bench --site test_site install-app myapp
bench build --apps myapp
- name: Run tests
working-directory: frappe-bench
run: bench --site test_site run-tests --app myapp --failfast
- name: Upload coverage
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage-${{ matrix.python-version }}-${{ matrix.db }}
path: frappe-bench/sites/coverage.xmlPostgreSQL Support (Additional Matrix Entry)
strategy:
matrix:
include:
- python-version: "3.12"
db: "postgres"
services:
postgres:
image: postgres:16
ports:
- 5432:5432
env:
POSTGRES_PASSWORD: db_root
options: >-
--health-cmd pg_isready
--health-interval=10s
--health-timeout=5s
--health-retries=5When using PostgreSQL, change the bench new-site command:
bench new-site test_site \
--db-type postgres \
--db-root-password db_root \
--admin-password adminPre-Commit Configuration
Minimal .pre-commit-config.yaml for Frappe Apps
exclude: "node_modules|.git"
default_stages: [pre-commit]
fail_fast: false
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
files: "myapp.*"
exclude: ".*json$|.*txt$|.*csv$|.*md$|.*svg$"
- id: check-merge-conflict
- id: check-ast
- id: check-json
- id: check-toml
- id: check-yaml
- id: debug-statements
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.0
hooks:
- id: ruff
args: [--select=I, --fix]
name: ruff (import sorter)
- id: ruff
name: ruff (linter)
- id: ruff-format
name: ruff (formatter)
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v2.7.1
hooks:
- id: prettier
types_or: [javascript, vue, scss]
exclude: ".*dist.*|node_modules"
- repo: https://github.com/pre-commit/mirrors-eslint
rev: v8.44.0
hooks:
- id: eslint
types: [javascript]
args: [--quiet]
exclude: ".*dist.*|node_modules"
- repo: https://github.com/alessandrojcm/commitlint-pre-commit-hook
rev: v9.16.0
hooks:
- id: commitlint
stages: [commit-msg]
additional_dependencies:
- conventional-changelog-conventionalcommitsALWAYS run pre-commit install --hook-type pre-commit --hook-type commit-msg after cloning.
Ruff Configuration (pyproject.toml)
[tool.ruff]
line-length = 110
target-version = "py311"
[tool.ruff.lint]
select = [
"F", # Pyflakes
"E", # pycodestyle errors
"W", # pycodestyle warnings
"I", # isort
"UP", # pyupgrade
"B", # flake8-bugbear
"RUF", # Ruff-specific rules
]
ignore = [
"E501", # line too long (handled by formatter)
"F401", # unused import (common in __init__.py)
"F403", # wildcard import (Frappe convention)
"F405", # undefined from wildcard (Frappe convention)
"E402", # module-level import order (Frappe convention)
]
[tool.ruff.format]
quote-style = "double"
indent-style = "tab"
docstring-code-format = true
[tool.ruff.lint.per-file-ignores]
# Ignore in auto-generated boilerplate
"**/doctype/**/boilerplate/**" = ["ALL"]Rules:
- ALWAYS use
indent-style = "tab"for Frappe projects — this is the framework convention - ALWAYS ignore F401/F403/F405 — Frappe uses wildcard imports by convention
- NEVER set
line-lengthbelow 110 — Frappe standard is 110 characters
ESLint Configuration
{
"env": {
"browser": true,
"node": true,
"es2021": true
},
"extends": "eslint:recommended",
"globals": {
"frappe": "readonly",
"cur_frm": "readonly",
"__": "readonly",
"cur_dialog": "readonly",
"cur_page": "readonly",
"cur_list": "readonly"
},
"rules": {
"no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }],
"no-console": "warn"
}
}ALWAYS declare Frappe globals (frappe, cur_frm, __, etc.) — otherwise ESLint reports false positives.
Semgrep Security Rules
# .semgrep/frappe-security.yml
rules:
- id: frappe-sql-injection
pattern: frappe.db.sql($X.format(...))
message: "NEVER use .format() in SQL — use parameterized queries"
severity: ERROR
languages: [python]
- id: frappe-raw-sql-concat
pattern: frappe.db.sql($X + ...)
message: "NEVER concatenate strings in SQL — use parameterized queries"
severity: ERROR
languages: [python]
- id: frappe-eval-usage
pattern: eval(...)
message: "NEVER use eval() — use frappe.safe_eval() instead"
severity: ERROR
languages: [python]Add to CI:
- name: Run Semgrep
uses: returntocorp/semgrep-action@v1
with:
config: .semgrep/Test Coverage
Setup coverage.py
# .coveragerc
[run]
source = myapp
omit =
*/test_*.py
*/tests/*
*/setup.py
[report]
exclude_lines =
pragma: no cover
if frappe.flags.in_test:
if TYPE_CHECKING:CI Coverage Step
- name: Run tests with coverage
working-directory: frappe-bench
run: |
cd apps/myapp
coverage run -m pytest
coverage xml -o ../../sites/coverage.xml
- name: Upload to Codecov
uses: codecov/codecov-action@v4
with:
file: frappe-bench/sites/coverage.xml
token: ${{ secrets.CODECOV_TOKEN }}Release Workflow
name: Release
on:
push:
tags:
- "v*"
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Generate changelog
id: changelog
uses: mikepenz/release-changelog-builder-action@v4
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
body: ${{ steps.changelog.outputs.changelog }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}Branch Protection Rules
ALWAYS configure these branch protection rules for main and develop:
1. Require status checks: Server Tests must pass before merging 2. Require pull request reviews: At least 1 approval 3. Require up-to-date branches: Force rebase before merge 4. Require linear history: Enforce squash or rebase merging
Configure via GitHub CLI:
gh api repos/{owner}/{repo}/branches/main/protection -X PUT \
-f "required_status_checks[strict]=true" \
-f "required_status_checks[contexts][]=Server Tests" \
-f "required_pull_request_reviews[required_approving_review_count]=1"Common CI Failures and Fixes
| Failure | Cause | Fix |
|---|---|---|
MariaDB not ready | Health check too short | Increase --health-retries to 10+ |
Redis connection refused | Wrong port mapping | Verify port mapping matches bench set-config |
ModuleNotFoundError | Missing app install | Ensure bench get-app and bench install-app both run |
Site not found | Missing bench new-site | ALWAYS create site before running tests |
Permission denied on bench | pip install location | Use pip install frappe-bench without sudo |
Assets build failed | Node version mismatch | Use Node 18 or 20 (NEVER Node 16) |
frappe.exceptions.DoesNotExistError | Missing test fixtures | Ensure test_records.json exists for dependent DocTypes |
Timeout in parallel tests | Too many parallel builds | Reduce --total-builds or increase timeout-minutes |
See Also
- references/examples.md — Complete workflow examples
- references/anti-patterns.md — CI/CD mistakes to avoid
- references/github-actions.md — Full GitHub Actions reference
- references/linting.md — Linting and formatting deep dive
- frappe-testing-unit — Unit and integration testing
CI/CD Anti-Patterns
Anti-Pattern 1: Missing Service Health Checks
# WRONG — tests start before MariaDB is ready
services:
mariadb:
image: mariadb:11.4
ports:
- 3306:3306
env:
MARIADB_ROOT_PASSWORD: db_root
# No health check — bench new-site will fail with "Can't connect to MySQL server"# CORRECT — ALWAYS add health checks with sufficient retries
services:
mariadb:
image: mariadb:11.4
ports:
- 3306:3306
env:
MARIADB_ROOT_PASSWORD: db_root
options: >-
--health-cmd="healthcheck.sh --connect --innodb_initialized"
--health-interval=5s
--health-timeout=5s
--health-retries=10Anti-Pattern 2: Using sudo pip install
# WRONG — permission issues and global pip pollution
- name: Install bench
run: sudo pip install frappe-bench# CORRECT — install in user space
- name: Install bench
run: pip install frappe-benchAnti-Pattern 3: Hardcoded Redis Ports
# WRONG — default Redis port 6379 conflicts when running multiple Redis services
services:
redis-cache:
image: redis:alpine
ports:
- 6379:6379 # Will conflict with redis-queue
redis-queue:
image: redis:alpine
ports:
- 6379:6379 # Port conflict!# CORRECT — ALWAYS use unique host ports for each Redis service
services:
redis-cache:
image: redis:alpine
ports:
- 13000:6379
redis-queue:
image: redis:alpine
ports:
- 11000:6379And ALWAYS configure bench to match:
- run: |
bench set-config -g redis_cache redis://localhost:13000
bench set-config -g redis_queue redis://localhost:11000Anti-Pattern 4: Missing --skip-assets on bench init
# WRONG — builds all assets during init (slow, unnecessary before app install)
- name: Init bench
run: bench init .# CORRECT — skip assets until after app installation
- name: Init bench
run: bench init --skip-assets --skip-redis-config-generation .Anti-Pattern 5: Not Using Concurrency Groups
# WRONG — multiple CI runs stack up for same PR
name: Tests
on: [push, pull_request]
# No concurrency control — wastes resources# CORRECT — cancel outdated runs
name: Tests
on: [push, pull_request]
concurrency:
group: tests-${{ github.ref }}
cancel-in-progress: trueAnti-Pattern 6: fail-fast: true in Matrix
# WRONG — one Python version failing cancels all other matrix entries
strategy:
fail-fast: true # Default is true!
matrix:
python-version: ["3.11", "3.12"]# CORRECT — let all matrix entries complete
strategy:
fail-fast: false
matrix:
python-version: ["3.11", "3.12"]Anti-Pattern 7: No Timeout on Jobs
# WRONG — stuck test can run for 6 hours (GitHub Actions default)
jobs:
test:
runs-on: ubuntu-latest
# No timeout — a hanging test blocks the runner indefinitely# CORRECT — ALWAYS set a reasonable timeout
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 60Anti-Pattern 8: Skipping Node Setup
# WRONG — system Node version may be incompatible
- name: Build assets
run: bench build --apps myapp
# Fails because Node 16 is too old for Frappe v15# CORRECT — ALWAYS pin Node version
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20 # NEVER use Node 16 for Frappe v15+Anti-Pattern 9: Running All App Tests Instead of Just Your App
# WRONG — runs ALL tests including frappe core (slow, noisy)
- name: Run tests
run: bench --site test_site run-tests# CORRECT — ALWAYS scope to your app
- name: Run tests
run: bench --site test_site run-tests --app myappAnti-Pattern 10: Not Checking Ruff Format in CI
# WRONG — only checks linting, not formatting
- name: Lint
run: ruff check .
# Formatting issues slip through# CORRECT — check both linting AND formatting
- name: Lint
run: |
ruff check .
ruff format --check .Anti-Pattern 11: Committing Directly to Main
# WRONG — no branch protection, no CI validation before merge
on:
push:
branches: [main]
# Tests run AFTER code is already on main# CORRECT — require PR with passing checks
on:
pull_request:
branches: [main, develop]
push:
branches: [main, develop]
# Combined with branch protection rules requiring status checksAnti-Pattern 12: Missing --no-mariadb-socket
# WRONG — bench tries to use Unix socket (not available in CI container)
- run: bench new-site test_site --db-root-password db_root --admin-password admin# CORRECT — force TCP connection
- run: bench new-site test_site --db-root-password db_root --admin-password admin --no-mariadb-socketCI/CD Examples
Complete GitHub Actions Workflow: MariaDB Only
name: Server Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
concurrency:
group: server-tests-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
python-version: ["3.11", "3.12"]
services:
mariadb:
image: mariadb:11.4
ports:
- 3306:3306
env:
MARIADB_ROOT_PASSWORD: db_root
options: >-
--health-cmd="healthcheck.sh --connect --innodb_initialized"
--health-interval=5s
--health-timeout=5s
--health-retries=10
redis-cache:
image: redis:alpine
ports:
- 13000:6379
redis-queue:
image: redis:alpine
ports:
- 11000:6379
steps:
- name: Checkout frappe
uses: actions/checkout@v4
with:
repository: frappe/frappe
path: frappe-bench/apps/frappe
- name: Checkout app
uses: actions/checkout@v4
with:
path: frappe-bench/apps/myapp
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install bench
run: pip install frappe-bench
- name: Init bench
working-directory: frappe-bench
run: |
bench init --skip-assets --skip-redis-config-generation .
bench set-config -g db_root_password db_root
bench set-config -g redis_cache redis://localhost:13000
bench set-config -g redis_queue redis://localhost:11000
- name: Create site and install app
working-directory: frappe-bench
run: |
bench get-app --skip-assets myapp ./apps/myapp
bench setup requirements --dev
bench new-site test_site \
--db-root-password db_root \
--admin-password admin \
--no-mariadb-socket
bench --site test_site install-app myapp
bench build --apps myapp
- name: Run tests
working-directory: frappe-bench
run: bench --site test_site run-tests --app myapp --failfastDual Database Workflow (MariaDB + PostgreSQL)
strategy:
fail-fast: false
matrix:
include:
- python-version: "3.11"
db: mariadb
- python-version: "3.12"
db: mariadb
- python-version: "3.12"
db: postgres
services:
mariadb:
image: mariadb:11.4
ports:
- 3306:3306
env:
MARIADB_ROOT_PASSWORD: db_root
options: >-
--health-cmd="healthcheck.sh --connect --innodb_initialized"
--health-interval=5s
--health-timeout=5s
--health-retries=10
postgres:
image: postgres:16
ports:
- 5432:5432
env:
POSTGRES_PASSWORD: db_root
options: >-
--health-cmd pg_isready
--health-interval=10s
--health-timeout=5s
--health-retries=5
redis-cache:
image: redis:alpine
ports:
- 13000:6379
redis-queue:
image: redis:alpine
ports:
- 11000:6379
steps:
# ... (checkout, setup Python/Node, install bench, init bench same as above)
- name: Create site (MariaDB)
if: matrix.db == 'mariadb'
working-directory: frappe-bench
run: |
bench new-site test_site \
--db-root-password db_root \
--admin-password admin \
--no-mariadb-socket
- name: Create site (PostgreSQL)
if: matrix.db == 'postgres'
working-directory: frappe-bench
run: |
bench new-site test_site \
--db-type postgres \
--db-root-password db_root \
--admin-password adminParallel Test Workflow
strategy:
matrix:
build-number: [0, 1]
steps:
# ... (setup steps same as above)
- name: Run parallel tests
working-directory: frappe-bench
run: |
bench --site test_site run-parallel-tests \
--app myapp \
--total-builds 2 \
--build-number ${{ matrix.build-number }}Linting Workflow (Separate Job)
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install ruff
run: pip install ruff
- name: Ruff check
run: ruff check .
- name: Ruff format check
run: ruff format --check .Pre-Commit CI Check
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
- name: Run pre-commit
uses: pre-commit/action@v3.0.1Release Workflow with Changelog
name: Release
on:
push:
tags:
- "v*"
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Generate changelog
id: changelog
uses: mikepenz/release-changelog-builder-action@v4
with:
configuration: ".github/changelog-config.json"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
body: ${{ steps.changelog.outputs.changelog }}
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}Coverage Upload with Codecov
- name: Run tests with coverage
working-directory: frappe-bench
run: |
cd apps/myapp
coverage run -m pytest
coverage xml -o ../../sites/coverage.xml
coverage report --show-missing
- name: Upload coverage to Codecov
if: always()
uses: codecov/codecov-action@v4
with:
file: frappe-bench/sites/coverage.xml
token: ${{ secrets.CODECOV_TOKEN }}
flags: serverSemgrep Security Scan Job
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Semgrep
uses: returntocorp/semgrep-action@v1
with:
config: .semgrep/
env:
SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }}GitHub Actions Reference for Frappe Apps
Required Services
Every Frappe CI workflow MUST include these services:
MariaDB (Primary Database)
services:
mariadb:
image: mariadb:11.4 # v15+: use 11.x; v14: use 10.x
ports:
- 3306:3306
env:
MARIADB_ROOT_PASSWORD: db_root
options: >-
--health-cmd="healthcheck.sh --connect --innodb_initialized"
--health-interval=5s
--health-timeout=5s
--health-retries=10PostgreSQL (Optional, Dual-DB)
postgres:
image: postgres:16
ports:
- 5432:5432
env:
POSTGRES_PASSWORD: db_root
options: >-
--health-cmd pg_isready
--health-interval=10s
--health-timeout=5s
--health-retries=5Redis (Cache + Queue)
redis-cache:
image: redis:alpine
ports:
- 13000:6379
redis-queue:
image: redis:alpine
ports:
- 11000:6379ALWAYS use separate Redis instances for cache and queue. NEVER share a single Redis for both.
Bench Setup Steps
Step 1: Initialize Bench
- name: Init bench
working-directory: frappe-bench
run: |
bench init --skip-assets --skip-redis-config-generation .
bench set-config -g db_root_password db_root
bench set-config -g redis_cache redis://localhost:13000
bench set-config -g redis_queue redis://localhost:11000Step 2: Install App
- name: Install app
working-directory: frappe-bench
run: |
bench get-app --skip-assets myapp ./apps/myapp
bench setup requirements --devStep 3: Create Site
# MariaDB site
- name: Create site (MariaDB)
working-directory: frappe-bench
run: |
bench new-site test_site \
--db-root-password db_root \
--admin-password admin \
--no-mariadb-socket
# PostgreSQL site
- name: Create site (PostgreSQL)
working-directory: frappe-bench
run: |
bench new-site test_site \
--db-type postgres \
--db-root-password db_root \
--admin-password adminStep 4: Install App on Site
- name: Install app on site
working-directory: frappe-bench
run: |
bench --site test_site install-app myapp
bench build --apps myappTest Matrix Configuration
Python Version Matrix
strategy:
fail-fast: false
matrix:
python-version: ["3.11", "3.12"]Version compatibility:
- Frappe v14: Python 3.10, 3.11
- Frappe v15: Python 3.11, 3.12
- Frappe v16: Python 3.12, 3.13+
Database Matrix
strategy:
fail-fast: false
matrix:
include:
- python-version: "3.11"
db: mariadb
- python-version: "3.12"
db: mariadb
- python-version: "3.12"
db: postgresEnvironment Variables
env:
NODE_ENV: production
CI: trueUseful Actions
| Action | Version | Purpose |
|---|---|---|
actions/checkout@v4 | v4 | Clone repository |
actions/setup-python@v5 | v5 | Install Python version |
actions/setup-node@v4 | v4 | Install Node version |
actions/upload-artifact@v4 | v4 | Upload test artifacts |
actions/download-artifact@v4 | v4 | Download artifacts between jobs |
codecov/codecov-action@v4 | v4 | Upload coverage to Codecov |
pre-commit/action@v3.0.1 | v3 | Run pre-commit checks |
returntocorp/semgrep-action@v1 | v1 | Run Semgrep security scans |
softprops/action-gh-release@v2 | v2 | Create GitHub releases |
Caching (Optional Performance Boost)
- name: Cache pip
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements*.txt', '**/pyproject.toml') }}
- name: Cache node_modules
uses: actions/cache@v4
with:
path: ~/.cache/yarn
key: ${{ runner.os }}-node-${{ hashFiles('**/yarn.lock') }}Debugging Failed CI
Enable SSH Debug Session
- name: Debug via SSH
if: failure()
uses: mxschmitt/action-tmate@v3
timeout-minutes: 15Upload Logs on Failure
- name: Upload bench logs
if: failure()
uses: actions/upload-artifact@v4
with:
name: bench-logs
path: |
frappe-bench/logs/
frappe-bench/sites/test_site/logs/Branch Protection via GitHub CLI
# Require status checks before merging
gh api repos/{owner}/{repo}/branches/main/protection -X PUT \
--input - <<'EOF'
{
"required_status_checks": {
"strict": true,
"contexts": ["test (3.11, mariadb)", "test (3.12, mariadb)", "lint"]
},
"enforce_admins": false,
"required_pull_request_reviews": {
"required_approving_review_count": 1
},
"restrictions": null
}
EOFLinting and Formatting Reference
Ruff (Python Linting + Formatting)
Ruff replaces flake8, isort, pyupgrade, and black for Frappe projects.
Installation
pip install ruffComplete pyproject.toml Configuration
[tool.ruff]
line-length = 110
target-version = "py311"
[tool.ruff.lint]
select = [
"F", # Pyflakes — undefined names, unused imports
"E", # pycodestyle errors — syntax and style errors
"W", # pycodestyle warnings — style warnings
"I", # isort — import sorting
"UP", # pyupgrade — modernize Python syntax
"B", # flake8-bugbear — common bug patterns
"RUF", # Ruff-specific — Ruff's own rules
]
ignore = [
"E501", # Line too long — handled by ruff format
"F401", # Unused import — Frappe uses __init__.py re-exports
"F403", # Wildcard import — Frappe convention in __init__.py
"F405", # Undefined from wildcard — consequence of F403
"E402", # Module-level import not at top — Frappe bootstrap pattern
]
[tool.ruff.format]
quote-style = "double"
indent-style = "tab"
docstring-code-format = true
[tool.ruff.lint.isort]
known-first-party = ["frappe", "erpnext", "myapp"]
[tool.ruff.lint.per-file-ignores]
"**/doctype/**/boilerplate/**" = ["ALL"]
"**/__init__.py" = ["F401", "F403"]CLI Commands
| Command | Purpose |
|---|---|
ruff check . | Run linting checks |
ruff check . --fix | Auto-fix linting issues |
ruff check . --select=I --fix | Fix import sorting only |
ruff format . | Format all files |
ruff format --check . | Check formatting without changing files |
ruff format --diff . | Show formatting diff |
Frappe-Specific Rules
ALWAYS ignore these rules for Frappe projects:
| Rule | Why Ignore |
|---|---|
F401 | Frappe re-exports in __init__.py |
F403 | Frappe wildcard imports are conventional |
F405 | Side effect of F403 |
E402 | Frappe requires bootstrap imports before module imports |
E501 | Handled by formatter; Frappe uses 110 char lines |
ALWAYS use these settings:
indent-style = "tab"— Frappe convention (NOT spaces)line-length = 110— Frappe standardquote-style = "double"— Frappe convention
ESLint (JavaScript Linting)
Installation
npm install --save-dev eslint.eslintrc.json for Frappe Apps
{
"env": {
"browser": true,
"node": true,
"es2021": true
},
"extends": "eslint:recommended",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"globals": {
"frappe": "readonly",
"cur_frm": "readonly",
"cur_dialog": "readonly",
"cur_page": "readonly",
"cur_list": "readonly",
"__": "readonly",
"cint": "readonly",
"cstr": "readonly",
"flt": "readonly",
"strip": "readonly"
},
"rules": {
"no-unused-vars": ["warn", {
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_"
}],
"no-console": "warn",
"no-undef": "error",
"prefer-const": "warn"
},
"ignorePatterns": [
"dist/",
"node_modules/",
"*.bundle.*",
"*.min.js"
]
}ALWAYS declare ALL Frappe globals — frappe, cur_frm, __, cint, flt, cstr, cur_dialog, cur_page, cur_list. Missing globals cause false-positive no-undef errors.
CLI Commands
| Command | Purpose |
|---|---|
npx eslint "**/*.js" | Lint all JS files |
npx eslint "**/*.js" --quiet | Lint, suppress warnings |
npx eslint "**/*.js" --fix | Auto-fix issues |
Prettier (JavaScript/Vue/SCSS Formatting)
.prettierrc
{
"semi": true,
"singleQuote": false,
"tabWidth": 4,
"useTabs": true,
"trailingComma": "es5"
}.prettierignore
dist/
node_modules/
*.min.js
*.bundle.*Semgrep (Security Scanning)
Frappe-Specific Rules
Create .semgrep/frappe-security.yml:
rules:
- id: frappe-sql-injection-format
pattern: frappe.db.sql($X.format(...))
message: >
SQL injection risk: NEVER use .format() in SQL queries.
Use parameterized queries: frappe.db.sql("SELECT * FROM t WHERE x=%s", [value])
severity: ERROR
languages: [python]
- id: frappe-sql-injection-concat
pattern: frappe.db.sql($X + ...)
message: >
SQL injection risk: NEVER concatenate strings in SQL.
Use parameterized queries: frappe.db.sql("SELECT * FROM t WHERE x=%s", [value])
severity: ERROR
languages: [python]
- id: frappe-sql-injection-fstring
pattern: frappe.db.sql(f"...")
message: >
SQL injection risk: NEVER use f-strings in SQL.
Use parameterized queries: frappe.db.sql("SELECT * FROM t WHERE x=%s", [value])
severity: ERROR
languages: [python]
- id: frappe-eval-usage
pattern: eval(...)
message: >
Security risk: NEVER use eval(). Use frappe.safe_eval() for trusted expressions.
severity: ERROR
languages: [python]
- id: frappe-exec-usage
pattern: exec(...)
message: >
Security risk: NEVER use exec(). Use frappe.safe_eval() or Server Scripts.
severity: ERROR
languages: [python]
- id: frappe-db-commit-in-test
patterns:
- pattern: frappe.db.commit()
- pattern-inside: |
class $CLASS(...):
...
pattern-where-python: "'Test' in str(vars().get('$CLASS', ''))"
message: >
NEVER call frappe.db.commit() in test classes. The test framework handles transactions.
severity: WARNING
languages: [python]Running Semgrep Locally
# Install
pip install semgrep
# Run with local rules
semgrep --config .semgrep/ .
# Run with community rules
semgrep --config "p/python" --config .semgrep/ .Pre-Commit Hook Setup
Installation
pip install pre-commit
pre-commit install --hook-type pre-commit --hook-type commit-msgRunning Manually
# Run all hooks on all files
pre-commit run --all-files
# Run specific hook
pre-commit run ruff --all-files
# Update hook versions
pre-commit autoupdateCommitlint Configuration
Create commitlint.config.js:
module.exports = {
extends: ["@commitlint/config-conventional"],
rules: {
"type-enum": [2, "always", [
"feat", "fix", "docs", "style", "refactor",
"perf", "test", "chore", "revert", "ci"
]],
"subject-case": [0], // Allow any case
"body-max-line-length": [0], // No body line limit
},
};Test Coverage Configuration
.coveragerc
[run]
source = myapp
omit =
*/test_*.py
*/tests/*
*/setup.py
*/patches/*
*/migrations/*
[report]
exclude_lines =
pragma: no cover
if frappe.flags.in_test:
if TYPE_CHECKING:
raise NotImplementedError
pass
show_missing = true
fail_under = 60
[html]
directory = coverage_htmlRunning Coverage Locally
cd apps/myapp
coverage run -m pytest
coverage report --show-missing
coverage html # Generate HTML report
coverage xml # Generate XML for CI upload