Skip to content

Instantly share code, notes, and snippets.

@agritheory
Last active July 7, 2026 13:03
Show Gist options
  • Select an option

  • Save agritheory/28fa94da76d70df384f3bd8ef404ca4a to your computer and use it in GitHub Desktop.

Select an option

Save agritheory/28fa94da76d70df384f3bd8ef404ca4a to your computer and use it in GitHub Desktop.
# Copyright (c) 2025, AgriTheory and contributors
# For license information, please see license.txt
"""
Frappe App Version-16 Migration Script
This script automates the migration of Frappe apps from version-15 to version-16.
It uses PyGithub to create branches and update files programmatically.
For repos with pytest.yaml CI, the script converts to the reusable
_base-pytest.yml pattern (pytest-mariadb.yaml + pytest-postgres.yaml wrappers).
When ERPNext or HRMS is declared in pyproject.toml, the Postgres workflow is
created with `if: false` until ERPNext supports Postgres.
Usage:
python migrate_to_v16.py --repos repo1,repo2,repo3 --org AgriTheory --token $GITHUB_TOKEN
python migrate_to_v16.py --repos fleet --org AgriTheory --token $GITHUB_TOKEN
"""
import argparse
import base64
import json
import re
import sys
from dataclasses import dataclass, field
from typing import Optional
import toml
from github import Auth, Github, GithubException
from github.Repository import Repository
@dataclass
class MigrationConfig:
"""Configuration for the migration process."""
source_branch: str = "version-15"
target_branch: str = "version-16"
python_version: str = "3.14"
python_version_range: str = ">=3.14,<3.15"
node_version: str = "24"
frappe_dependency: str = ">=16.0.0-dev,<17.0.0"
initial_version: str = "16.0.0"
commit_message: str = "chore: migrate to version-16"
postgres_image: str = "postgres:18.0"
postgres_root_password: str = "travis"
BASE_PYTEST_INSTALL_STEP_NAMES = {
"Clone",
"Setup Python",
"Setup Node",
"Add to Hosts",
"Cache pip",
"Get yarn cache directory path",
"Install Poetry",
"Install JS Dependencies",
"Install App Dependencies",
"Install Bench Site and Apps",
"Install",
"Enable Corepack",
"Check for valid Python & Merge Conflicts",
}
BASE_PYTEST_TEMPLATE = """name: Frappe CI Base
on:
workflow_call:
inputs:
db:
required: true
type: string
job-name:
required: true
type: string
permissions:
contents: write
checks: write
issues: write
pull-requests: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
tests:
name: ${{ inputs.job-name }}
runs-on: ubuntu-latest
timeout-minutes: 60
strategy:
fail-fast: false
env:
DB: ${{ inputs.db }}
CI: 'Yes'
services:
redis-cache:
image: redis:alpine
ports:
- 13000:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 2s
--health-retries 3
redis-queue:
image: redis:alpine
ports:
- 11000:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 2s
--health-retries 3
mariadb:
image: mariadb:11.8
env:
MARIADB_ROOT_PASSWORD: root
ports:
- 3306:3306
options: >-
--health-cmd="healthcheck.sh --connect --innodb_initialized"
--health-interval=5s
--health-timeout=2s
--health-retries=3
postgres:
image: {postgres_image}
env:
POSTGRES_PASSWORD: {postgres_root_password}
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 3
steps:
- name: Clone
uses: actions/checkout@v6
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: '{python_version}'
- name: Check for valid Python & Merge Conflicts
run: |
python -m compileall -q -f "${{GITHUB_WORKSPACE}}"
if grep -lr --exclude-dir=node_modules "^<<<<<<< " "${{GITHUB_WORKSPACE}}"
then echo "Found merge conflicts"
exit 1
fi
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: {node_version}
check-latest: true
- name: Enable Corepack
run: corepack enable
- name: Add to Hosts
run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts
- name: Cache pip
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{{{ runner.os }}}}-pip-${{{{ hashFiles('**/pyproject.toml') }}}}
restore-keys: |
${{{{ runner.os }}}}-pip-
${{{{ runner.os }}}}-
- name: Cache yarn
uses: actions/cache@v4
with:
path: ~/.cache/yarn
key: ${{{{ runner.os }}}}-yarn-${{{{ hashFiles('**/yarn.lock') }}}}
restore-keys: |
${{{{ runner.os }}}}-yarn-
- name: Install
run: bash ${{{{GITHUB_WORKSPACE}}}}/.github/helper/install.sh
{custom_steps}
- name: Run Tests
working-directory: /home/runner/frappe-bench
run: |
source env/bin/activate
cd apps/{app_name}
pytest --cov={app_name} --cov-report=xml --disable-warnings -s | tee pytest-coverage.txt
env:
TYPE: server
FRAPPE_REDIS_CACHE: redis://127.0.0.1:13000
FRAPPE_REDIS_QUEUE: redis://127.0.0.1:11000
- name: Pytest coverage comment
uses: MishaKav/pytest-coverage-comment@main
with:
pytest-coverage-path: /home/runner/frappe-bench/apps/{app_name}/pytest-coverage.txt
pytest-xml-coverage-path: /home/runner/frappe-bench/apps/{app_name}/coverage.xml
"""
POSTGRES_SITE_CONFIG_OVERRIDES = {
"db_type": "postgres",
"db_port": 5432,
"root_login": "postgres",
}
POSTGRES_INSTALL_DEPS_BLOCK = """
if [ "${DB:-mariadb}" = "postgres" ]; then
sudo apt install -y postgresql-client libpq-dev
fi
"""
POSTGRES_INSTALL_DB_BLOCK = """if [ "$DB" == "postgres" ]; then
cp "${{GITHUB_WORKSPACE}}/.github/helper/site_config_postgres.json" ~/frappe-bench/sites/test_site/site_config.json
echo "{postgres_root_password}" | psql -h 127.0.0.1 -p 5432 -c "CREATE DATABASE test_site" -U postgres
echo "{postgres_root_password}" | psql -h 127.0.0.1 -p 5432 -c "CREATE USER test_site WITH PASSWORD 'test_site'" -U postgres
echo "{postgres_root_password}" | psql -h 127.0.0.1 -p 5432 -c "GRANT ALL PRIVILEGES ON DATABASE test_site TO test_site" -U postgres
else
cp "${{GITHUB_WORKSPACE}}/.github/helper/site_config.json" ~/frappe-bench/sites/test_site/site_config.json
fi
"""
@dataclass
class FileUpdate:
"""Represents a file update to be made."""
path: str
content: str
message: str
sha: Optional[str] = None
@dataclass
class FileDelete:
"""Represents a file deletion to be made."""
path: str
sha: str
message: str
@dataclass
class MigrationResult:
"""Result of a migration attempt."""
repo_name: str
success: bool
branch_created: bool = False
files_updated: list[str] = field(default_factory=list)
errors: list[str] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
class FrappeAppMigrator:
"""Handles migration of Frappe apps to version-16."""
def __init__(
self,
github_token: str,
org: str,
config: MigrationConfig,
skip_postgres_ci: bool = False,
):
self.gh = Github(auth=Auth.Token(github_token))
self.org = org
self.config = config
self.skip_postgres_ci = skip_postgres_ci
def migrate_repo(self, repo_name: str) -> MigrationResult:
"""Migrate a single repository to version-16."""
result = MigrationResult(repo_name=repo_name, success=False)
try:
repo = self.gh.get_repo(f"{self.org}/{repo_name}")
print(f"\n{'='*60}")
print(f"Migrating: {repo.full_name}")
print(f"{'='*60}")
# Check if source branch exists
if not self._branch_exists(repo, self.config.source_branch):
result.errors.append(f"Source branch '{self.config.source_branch}' not found")
return result
# Check if target branch already exists
if self._branch_exists(repo, self.config.target_branch):
result.warnings.append(
f"Target branch '{self.config.target_branch}' already exists"
)
print(f" ⚠️ Branch {self.config.target_branch} already exists, updating files...")
else:
self._create_branch(repo, self.config.source_branch, self.config.target_branch)
result.branch_created = True
print(f" ✅ Created branch: {self.config.target_branch}")
# Collect all file updates
updates, deletes = self.collect_file_changes(repo)
# Apply updates
for update in updates:
try:
self.update_file(repo, update)
print(f" ✅ Updated: {update.path}")
result.files_updated.append(update.path)
except Exception as e:
result.errors.append(f"Failed to update {update.path}: {str(e)}")
print(f" ❌ Failed to update {update.path}: {e}")
for delete in deletes:
try:
self.delete_file(repo, delete)
print(f" ✅ Deleted: {delete.path}")
result.files_updated.append(delete.path)
except Exception as e:
result.errors.append(f"Failed to delete {delete.path}: {str(e)}")
print(f" ❌ Failed to delete {delete.path}: {e}")
result.success = len(result.errors) == 0
return result
except GithubException as e:
result.errors.append(f"GitHub API error: {str(e)}")
return result
except Exception as e:
result.errors.append(f"Unexpected error: {str(e)}")
return result
def _branch_exists(self, repo: Repository, branch_name: str) -> bool:
"""Check if a branch exists in the repository."""
try:
repo.get_branch(branch_name)
return True
except GithubException:
return False
def _create_branch(self, repo: Repository, source: str, target: str) -> None:
"""Create a new branch from source."""
source_branch = repo.get_branch(source)
repo.create_git_ref(f"refs/heads/{target}", source_branch.commit.sha)
def _get_file_content(self, repo: Repository, path: str) -> tuple[str, str]:
"""Get file content and SHA from repo."""
try:
content = repo.get_contents(path, ref=self.config.target_branch)
if isinstance(content, list):
raise ValueError(f"Path {path} is a directory")
return base64.b64decode(content.content).decode("utf-8"), content.sha
except GithubException:
return None, None
def update_file(self, repo: Repository, update: FileUpdate) -> None:
"""Update or create a file in the repository."""
try:
existing = repo.get_contents(update.path, ref=self.config.target_branch)
sha = existing.sha if not isinstance(existing, list) else None
repo.update_file(
path=update.path,
message=update.message,
content=update.content,
sha=sha,
branch=self.config.target_branch,
)
except GithubException as e:
if e.status == 404:
repo.create_file(
path=update.path,
message=update.message,
content=update.content,
branch=self.config.target_branch,
)
else:
raise
def delete_file(self, repo: Repository, delete: FileDelete) -> None:
"""Delete a file from the repository."""
repo.delete_file(
path=delete.path,
message=delete.message,
sha=delete.sha,
branch=self.config.target_branch,
)
def collect_file_changes(self, repo: Repository) -> tuple[list[FileUpdate], list[FileDelete]]:
"""Collect all file updates and deletions needed for migration."""
updates: list[FileUpdate] = []
deletes: list[FileDelete] = []
pyproject_update = self._update_pyproject_toml(repo)
if pyproject_update:
updates.append(pyproject_update)
init_update = self._update_init_version(repo)
if init_update:
updates.append(init_update)
workflow_updates = self._update_github_workflows(repo)
updates.extend(workflow_updates)
install_update = self._update_install_script(repo)
if install_update:
updates.append(install_update)
readme_update = self._update_readme(repo)
if readme_update:
updates.append(readme_update)
if self.repo_has_pytest_ci(repo):
ci_updates, ci_deletes = self.collect_pytest_ci_updates(repo)
updates.extend(ci_updates)
deletes.extend(ci_deletes)
return updates, deletes
def repo_has_pytest_yaml(self, repo: Repository) -> bool:
"""Return True when the repo uses the legacy pytest.yaml CI pattern."""
content, _ = self._get_file_content(repo, ".github/workflows/pytest.yaml")
return content is not None
def repo_has_base_pytest_workflow(self, repo: Repository) -> bool:
"""Return True when the repo already uses the reusable pytest workflow pattern."""
content, _ = self._get_file_content(repo, ".github/workflows/_base-pytest.yml")
return content is not None
def repo_has_pytest_ci(self, repo: Repository) -> bool:
"""Return True when the repo has pytest CI via either workflow pattern."""
return self.repo_has_pytest_yaml(repo) or self.repo_has_base_pytest_workflow(repo)
def repo_has_erpnext_or_hrms_dependency(self, repo: Repository) -> bool:
"""Return True when pyproject.toml declares ERPNext or HRMS as a dependency."""
content, _ = self._get_file_content(repo, "pyproject.toml")
if not content:
return False
try:
data = toml.loads(content)
except Exception:
return False
deps = data.get("tool", {}).get("bench", {}).get("frappe-dependencies", {})
return "erpnext" in deps or "hrms" in deps
def app_name_from_repo(self, repo_name: str) -> str:
"""Derive the bench app module name from the repository name."""
return repo_name.replace("-", "_")
def extract_app_name_from_pytest(self, pytest_content: str, repo_name: str) -> str:
"""Extract the app module name from an existing pytest workflow."""
patterns = [
r"cd apps/([^\s/]+)",
r"apps/([^/]+)/pytest-coverage",
r"--cov=([^\s]+)",
]
for pattern in patterns:
match = re.search(pattern, pytest_content)
if match:
return match.group(1)
return self.app_name_from_repo(repo_name)
def extract_push_branches(self, workflow_content: str) -> list[str]:
"""Extract push branch triggers from a workflow file."""
branches: list[str] = []
push_block = re.search(
r"^\s*push:\s*\n(?:\s+.+\n)*",
workflow_content,
flags=re.MULTILINE,
)
if not push_block:
return branches
for match in re.finditer(r"-\s*(version-\d+)", push_block.group(0)):
branches.append(match.group(1))
inline_match = re.search(r"branches:\s*\[([^\]]+)\]", workflow_content)
if inline_match:
for branch in inline_match.group(1).split(","):
branch = branch.strip()
if branch.startswith("version-"):
branches.append(branch)
seen: set[str] = set()
unique: list[str] = []
for branch in branches:
if branch not in seen:
seen.add(branch)
unique.append(branch)
return unique
def default_concurrency_block(self) -> str:
"""Return the standard concurrency block for pytest wrapper workflows."""
return """concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: true"""
def extract_concurrency_block(self, workflow_content: str) -> str:
"""Extract an existing concurrency block or return the default."""
match = re.search(
r"^concurrency:\n(?: .+\n)+",
workflow_content,
flags=re.MULTILINE,
)
if match:
return match.group(0).rstrip()
return self.default_concurrency_block()
def extract_custom_pytest_steps(self, pytest_content: str) -> str:
"""Return app-specific steps that run between install and pytest."""
steps_match = re.search(r"^\s+steps:\n(.*)", pytest_content, re.MULTILINE | re.DOTALL)
if not steps_match:
return ""
custom_steps: list[str] = []
for chunk in re.split(r"(?= - name:)", steps_match.group(1)):
name_match = re.search(r"- name: (.+)", chunk)
if not name_match:
continue
step_name = name_match.group(1).strip()
if step_name in BASE_PYTEST_INSTALL_STEP_NAMES:
continue
if step_name in {"Run Tests", "Pytest coverage comment"}:
break
if chunk.strip():
custom_steps.append(chunk.rstrip())
if not custom_steps:
return ""
return "\n".join(custom_steps) + "\n"
def build_base_pytest_yml(self, repo: Repository, pytest_content: str) -> str:
"""Build the reusable base pytest workflow from a legacy pytest.yaml."""
app_name = self.extract_app_name_from_pytest(pytest_content, repo.name)
custom_steps = self.extract_custom_pytest_steps(pytest_content)
content = BASE_PYTEST_TEMPLATE.format(
postgres_image=self.config.postgres_image,
postgres_root_password=self.config.postgres_root_password,
python_version=self.config.python_version,
node_version=self.config.node_version,
app_name=app_name,
custom_steps=custom_steps,
)
return self.update_workflow_content(content)
def build_pytest_mariadb_yaml(self, pytest_content: str) -> str:
"""Build the MariaDB wrapper workflow for the reusable pytest pattern."""
branches = self.extract_push_branches(pytest_content)
if self.config.target_branch not in branches:
branches.append(self.config.target_branch)
branches_yaml = "\n".join(f" - {branch}" for branch in branches)
concurrency = self.extract_concurrency_block(pytest_content)
content = f"""name: Frappe CI - MariaDB
on:
push:
branches:
{branches_yaml}
pull_request:
{concurrency}
jobs:
tests:
uses: ./.github/workflows/_base-pytest.yml
permissions:
contents: write
checks: write
issues: write
pull-requests: write
with:
db: mariadb
job-name: Server
"""
return self.update_workflow_content(content)
def build_pytest_postgres_yaml(self, pytest_content: str, disabled: bool = False) -> str:
"""Build the Postgres wrapper workflow for the reusable pytest pattern."""
if_clause = ""
if disabled:
if_clause = " if: false # Disabled until ERPNext/HRMS supports Postgres\n"
concurrency = self.extract_concurrency_block(pytest_content)
content = f"""name: Frappe CI - Postgres
on:
push:
branches:
- {self.config.target_branch}
pull_request:
{concurrency}
jobs:
tests:
{if_clause} uses: ./.github/workflows/_base-pytest.yml
permissions:
contents: write
checks: write
issues: write
pull-requests: write
with:
db: postgres
job-name: Server (Postgres)
"""
return self.update_workflow_content(content)
def collect_pytest_ci_updates(self, repo: Repository) -> tuple[list[FileUpdate], list[FileDelete]]:
"""Scaffold the reusable pytest CI pattern and optional Postgres workflow."""
updates: list[FileUpdate] = []
deletes: list[FileDelete] = []
pytest_content, pytest_sha = self._get_file_content(repo, ".github/workflows/pytest.yaml")
base_content, base_sha = self._get_file_content(repo, ".github/workflows/_base-pytest.yml")
if not pytest_content and not base_content:
print(" ⚠️ No pytest CI workflow found, skipping pytest CI scaffolding")
return updates, deletes
source_content = pytest_content or base_content
disable_postgres = self.repo_has_erpnext_or_hrms_dependency(repo)
if pytest_content:
base_workflow = self.build_base_pytest_yml(repo, pytest_content)
if not base_content or base_content != base_workflow:
updates.append(
FileUpdate(
path=".github/workflows/_base-pytest.yml",
content=base_workflow,
message=f"{self.config.commit_message}\n\nAdded reusable base pytest CI workflow",
sha=base_sha,
)
)
mariadb_workflow = self.build_pytest_mariadb_yaml(pytest_content)
mariadb_workflow_path = ".github/workflows/pytest-mariadb.yaml"
existing_mariadb, mariadb_sha = self._get_file_content(repo, mariadb_workflow_path)
if not existing_mariadb or existing_mariadb != mariadb_workflow:
updates.append(
FileUpdate(
path=mariadb_workflow_path,
content=mariadb_workflow,
message=f"{self.config.commit_message}\n\nAdded MariaDB pytest CI workflow",
sha=mariadb_sha,
)
)
if pytest_sha:
deletes.append(
FileDelete(
path=".github/workflows/pytest.yaml",
sha=pytest_sha,
message=f"{self.config.commit_message}\n\nReplaced pytest.yaml with reusable pytest workflows",
)
)
elif base_content:
updated_base = self.update_workflow_content(base_content)
if updated_base != base_content:
updates.append(
FileUpdate(
path=".github/workflows/_base-pytest.yml",
content=updated_base,
message=f"{self.config.commit_message}\n\nUpdated reusable base pytest CI workflow",
sha=base_sha,
)
)
postgres_workflow = self.build_pytest_postgres_yaml(source_content, disabled=disable_postgres)
postgres_workflow_path = ".github/workflows/pytest-postgres.yaml"
if not self.skip_postgres_ci:
existing_postgres, postgres_sha = self._get_file_content(repo, postgres_workflow_path)
if not existing_postgres or existing_postgres != postgres_workflow:
message = f"{self.config.commit_message}\n\nAdded Postgres pytest CI workflow"
if disable_postgres:
message += " (disabled until ERPNext/HRMS supports Postgres)"
updates.append(
FileUpdate(
path=postgres_workflow_path,
content=postgres_workflow,
message=message,
sha=postgres_sha,
)
)
site_config_content, _ = self._get_file_content(
repo, ".github/helper/site_config.json"
)
if site_config_content:
postgres_site_config = self.build_site_config_postgres(site_config_content)
postgres_site_config_path = ".github/helper/site_config_postgres.json"
existing_site_config, postgres_site_config_sha = self._get_file_content(
repo, postgres_site_config_path
)
if not existing_site_config or existing_site_config != postgres_site_config:
updates.append(
FileUpdate(
path=postgres_site_config_path,
content=postgres_site_config,
message=f"{self.config.commit_message}\n\nAdded Postgres site config for CI",
sha=postgres_site_config_sha,
)
)
else:
print(" ⚠️ site_config.json not found, skipping site_config_postgres.json")
install_deps_path = ".github/helper/install_dependencies.sh"
install_deps_content, install_deps_sha = self._get_file_content(repo, install_deps_path)
if install_deps_content:
new_install_deps = self.patch_install_dependencies_for_postgres(install_deps_content)
if new_install_deps != install_deps_content:
updates.append(
FileUpdate(
path=install_deps_path,
content=new_install_deps,
message=f"{self.config.commit_message}\n\nUpdated install_dependencies.sh for Postgres CI",
sha=install_deps_sha,
)
)
return updates, deletes
def build_site_config_postgres(self, site_config_content: str) -> str:
"""Derive Postgres site config from the MariaDB CI site config."""
data = json.loads(site_config_content)
for key, value in POSTGRES_SITE_CONFIG_OVERRIDES.items():
data[key] = value
data["db_password"] = data.get("db_name", "test_site")
data["root_password"] = self.config.postgres_root_password
return json.dumps(data, indent=1) + "\n"
def patch_install_dependencies_for_postgres(self, content: str) -> str:
"""Add conditional Postgres client packages to install_dependencies.sh."""
if 'DB:-mariadb}" = "postgres"' in content:
return content
content = content.rstrip() + POSTGRES_INSTALL_DEPS_BLOCK
if not content.endswith("\n"):
content += "\n"
return content
def patch_install_script_branch_refs(self, content: str) -> str:
"""Update hardcoded version-15 branch references in install.sh."""
return re.sub(r"--branch\s+version-15", "--branch ${BRANCH_NAME}", content)
def normalize_mysql_setup_block(self, content: str) -> str:
"""Flatten MariaDB setup commands before re-wrapping for DB branching."""
section_pattern = (
r"(pip install frappe-bench\n\n)"
r"[\s\S]*?"
r'(\necho "BRANCH_NAME:[^\n]*\n)'
)
match = re.search(section_pattern, content)
if not match:
return content
mysql_lines = [
line.strip()
for line in re.findall(
r"^[\t ]*mysql --host 127\.0\.0\.1 --port 3306[^\n]*",
match.group(0),
flags=re.MULTILINE,
)
]
if not mysql_lines:
return content
flat_block = match.group(1) + "\n".join(mysql_lines) + "\n" + match.group(2)
return content.replace(match.group(0), flat_block, 1)
def patch_install_script_postgres_db(self, content: str) -> str:
"""Add DB=mariadb|postgres branching to install.sh."""
new_content = self.normalize_mysql_setup_block(content)
if 'DB="${DB:-mariadb}"' not in new_content:
new_content = re.sub(
r"(set -e\n)",
r'\1\nDB="${DB:-mariadb}"\n',
new_content,
count=1,
)
mysql_setup_pattern = (
r"mysql --host 127\.0\.0\.1 --port 3306[\s\S]*?"
r'mysql --host 127\.0\.0\.1 --port 3306 -u root -e "FLUSH PRIVILEGES"\n'
)
mysql_match = re.search(mysql_setup_pattern, new_content)
if mysql_match:
mysql_block = mysql_match.group(0)
indented = "".join(
f" {line}\n" for line in mysql_block.splitlines() if line.strip()
)
wrapped_block = f'if [ "$DB" == "mariadb" ]; then\n{indented}fi\n'
new_content = new_content.replace(mysql_block, wrapped_block, 1)
postgres_block = POSTGRES_INSTALL_DB_BLOCK.format(
postgres_root_password=self.config.postgres_root_password
)
site_config_copy = re.search(
r'cp -r "\$\{GITHUB_WORKSPACE\}/\.github/helper/site_config\.json" '
r"~/frappe-bench/sites/test_site/?",
new_content,
)
if site_config_copy:
new_content = new_content.replace(site_config_copy.group(0), postgres_block, 1)
elif 'if [ "$DB" == "postgres" ]' not in new_content:
mkdir_line = "mkdir ~/frappe-bench/sites/test_site\n"
if mkdir_line in new_content:
new_content = new_content.replace(
mkdir_line,
mkdir_line + postgres_block + "\n",
1,
)
return new_content
def _update_pyproject_toml(self, repo: Repository) -> Optional[FileUpdate]:
"""Update pyproject.toml for version-16."""
content, sha = self._get_file_content(repo, "pyproject.toml")
if not content:
print(f" ⚠️ pyproject.toml not found")
return None
try:
data = toml.loads(content)
except Exception as e:
print(f" ⚠️ Failed to parse pyproject.toml: {e}")
return None
modified = False
# Update project.requires-python if present
if "project" in data and "requires-python" in data["project"]:
data["project"]["requires-python"] = self.config.python_version_range
modified = True
# Update tool.poetry.version if present
if "tool" in data and "poetry" in data["tool"]:
if "version" in data["tool"]["poetry"]:
data["tool"]["poetry"]["version"] = self.config.initial_version
modified = True
if "dependencies" in data["tool"]["poetry"]:
if "python" in data["tool"]["poetry"]["dependencies"]:
data["tool"]["poetry"]["dependencies"]["python"] = self.config.python_version_range
modified = True
# Add/update tool.bench.frappe-dependencies
if "tool" not in data:
data["tool"] = {}
if "bench" not in data["tool"]:
data["tool"]["bench"] = {}
if "frappe-dependencies" not in data["tool"]["bench"]:
data["tool"]["bench"]["frappe-dependencies"] = {}
data["tool"]["bench"]["frappe-dependencies"]["frappe"] = self.config.frappe_dependency
modified = True
# Update semantic_release branches configuration
if "tool" in data and "semantic_release" in data["tool"]:
sr = data["tool"]["semantic_release"]
if "branches" in sr and "version" in sr["branches"]:
current_match = sr["branches"]["version"].get("match", "")
if "16" not in current_match:
# Update pattern to include version-16
new_match = re.sub(
r"version-\(([\d|]+)\)", lambda m: f"version-({m.group(1)}|16)", current_match
)
if new_match != current_match:
sr["branches"]["version"]["match"] = new_match
modified = True
# Update ruff target-version if present
if "tool" in data and "ruff" in data["tool"]:
if "target-version" in data["tool"]["ruff"]:
data["tool"]["ruff"]["target-version"] = "py314"
modified = True
if not modified:
return None
new_content = toml.dumps(data)
return FileUpdate(
path="pyproject.toml",
content=new_content,
message=f"{self.config.commit_message}\n\nUpdated pyproject.toml for version-16 compatibility",
sha=sha,
)
def _update_init_version(self, repo: Repository) -> Optional[FileUpdate]:
"""Update __version__ in the app's __init__.py."""
app_name = repo.name.replace("-", "_")
possible_paths = [
f"{app_name}/__init__.py",
f"{repo.name}/__init__.py",
]
for path in possible_paths:
content, sha = self._get_file_content(repo, path)
if content:
new_content = re.sub(
r'__version__\s*=\s*["\'][\d.]+["\']',
f'__version__ = "{self.config.initial_version}"',
content,
)
if new_content != content:
return FileUpdate(
path=path,
content=new_content,
message=f"{self.config.commit_message}\n\nBumped version to {self.config.initial_version}",
sha=sha,
)
return None
def _update_github_workflows(self, repo: Repository) -> list[FileUpdate]:
"""Update GitHub Actions workflow files."""
updates = []
pytest_ci_paths = {
"pytest.yaml",
"_base-pytest.yml",
"pytest-mariadb.yaml",
"pytest-postgres.yaml",
}
try:
contents = repo.get_contents(".github/workflows", ref=self.config.target_branch)
if not isinstance(contents, list):
contents = [contents]
for item in contents:
if item.type != "file" or not item.name.endswith((".yml", ".yaml")):
continue
if self.repo_has_pytest_ci(repo) and item.name in pytest_ci_paths:
continue
content, sha = self._get_file_content(repo, item.path)
if not content:
continue
new_content = self.update_workflow_content(content)
if new_content != content:
updates.append(
FileUpdate(
path=item.path,
content=new_content,
message=f"{self.config.commit_message}\n\nUpdated {item.name} for version-16",
sha=sha,
)
)
except GithubException:
print(f" ⚠️ No .github/workflows directory found")
return updates
def _update_readme(self, repo: Repository) -> Optional[FileUpdate]:
"""Update README.md branch and version references."""
content, sha = self._get_file_content(repo, "README.md")
if not content:
return None
new_content = content
# Update branch references: --branch version-15 → --branch version-16
new_content = re.sub(r"--branch\s+version-15", "--branch version-16", new_content)
# Update --frappe-branch references
new_content = re.sub(
r"--frappe-branch\s+version-15", "--frappe-branch version-16", new_content
)
# Update Python version references (e.g., "3.10 latest" or "3.10.x")
new_content = re.sub(
r"(\bpython\b.*?)3\.10(\.\d+)?",
rf"\g<1>{self.config.python_version}",
new_content,
flags=re.IGNORECASE,
)
if new_content == content:
return None
return FileUpdate(
path="README.md",
content=new_content,
message=f"{self.config.commit_message}\n\nUpdated README.md branch references",
sha=sha,
)
def dedupe_workflow_branches(self, content: str) -> str:
"""Remove duplicate version-N entries from workflow branch triggers."""
def dedupe_list_block(match: re.Match) -> str:
lines = match.group(0).splitlines()
seen: set[str] = set()
out = [lines[0]]
for line in lines[1:]:
branch_match = re.match(r"(\s*-\s*)(version-\d+)", line)
if branch_match:
branch = branch_match.group(2)
if branch in seen:
continue
seen.add(branch)
out.append(line)
trailing_newline = "\n" if match.group(0).endswith("\n") else ""
return "\n".join(out) + trailing_newline
content = re.sub(
r"^\s*branches:\s*\n(?:\s*-\s*version-\d+\s*\n)+",
dedupe_list_block,
content,
flags=re.MULTILINE,
)
def dedupe_inline_block(match: re.Match) -> str:
prefix, inner, suffix = match.groups()
branches = [item.strip() for item in inner.split(",") if item.strip()]
seen: set[str] = set()
unique = []
for branch in branches:
if branch not in seen:
seen.add(branch)
unique.append(branch)
return f"{prefix}{', '.join(unique)}{suffix}"
content = re.sub(
r"(branches:\s*\[)([^\]]+)(\])",
dedupe_inline_block,
content,
)
return content
def ensure_version_16_branch(self, content: str) -> str:
"""Add version-16 to branch triggers when missing."""
if re.search(r"-\s*version-16\b", content) or re.search(
r"branches:\s*\[[^\]]*version-16", content
):
return content
branch_patterns = [
(
r"(branches:\s*\n(?:\s*-\s*version-\d+\s*\n)*\s*-\s*version-15)(\s*\n)",
r"\1\n - version-16\2",
),
(r"(branches:\s*\[)([^\]]*version-15)(\])", r"\1\2, version-16\3"),
]
for pattern, replacement in branch_patterns:
content = re.sub(pattern, replacement, content)
return content
def normalize_node24_env(self, content: str) -> str:
"""Ensure FORCE_JAVASCRIPT_ACTIONS_TO_NODE24 is present and valid YAML."""
content = content.replace(
'FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: \\"true\\"',
"FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true",
)
content = re.sub(
r"FORCE_JAVASCRIPT_ACTIONS_TO_NODE24:\s*(?:['\"]true['\"]|true)",
"FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true",
content,
)
# Repair env blocks broken by earlier migration runs.
content = re.sub(
r"env:\n(?: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24:.*\n)?(?: )BRANCH_NAME:",
"env:\n FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true\n BRANCH_NAME:",
content,
count=1,
)
if "FORCE_JAVASCRIPT_ACTIONS_TO_NODE24" in content:
return content
env_block = re.search(r"^env:\s*\n((?:[ \t]+[^\n]+\n)+)", content, re.MULTILINE)
if env_block:
first_key = env_block.group(1).splitlines()[0]
indent_match = re.match(r"^(\s+)", first_key)
indent = indent_match.group(1) if indent_match else " "
new_line = f"{indent}FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true\n"
return re.sub(
r"^(env:\s*\n)",
rf"\1{new_line}",
content,
count=1,
flags=re.MULTILINE,
)
return re.sub(
r"^(jobs:)",
'env:\n FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true\n\n\\1',
content,
flags=re.MULTILINE,
)
def update_workflow_content(self, content: str) -> str:
"""Update workflow YAML content for version-16."""
new_content = content
new_content = self.ensure_version_16_branch(new_content)
new_content = self.dedupe_workflow_branches(new_content)
# Update Python version
new_content = re.sub(
r"(python-version:\s*['\"]?)3\.(10|11|12|13)(['\"]?)",
rf"\g<1>{self.config.python_version}\3",
new_content,
)
# Update Node version
new_content = re.sub(
r"(node-version:\s*)(\d+)", rf"\g<1>{self.config.node_version}", new_content
)
# Update actions versions
action_updates = {
"actions/checkout@v3": "actions/checkout@v6",
"actions/checkout@v2": "actions/checkout@v6",
"actions/checkout@v4": "actions/checkout@v6",
"actions/setup-python@v4": "actions/setup-python@v6",
"actions/setup-python@v3": "actions/setup-python@v6",
"actions/setup-python@v2": "actions/setup-python@v6",
"actions/setup-python@v5": "actions/setup-python@v6",
"actions/setup-node@v3": "actions/setup-node@v6",
"actions/setup-node@v2": "actions/setup-node@v6",
"actions/setup-node@v4": "actions/setup-node@v6",
"actions/cache@v3": "actions/cache@v4",
"actions/cache@v2": "actions/cache@v4",
}
for old, new in action_updates.items():
new_content = new_content.replace(old, new)
new_content = self.normalize_node24_env(new_content)
return new_content
def _update_install_script(self, repo: Repository) -> Optional[FileUpdate]:
"""Update .github/helper/install.sh for version-16 and optional Postgres CI."""
path = ".github/helper/install.sh"
content, sha = self._get_file_content(repo, path)
if not content:
return None
new_content = self.patch_install_script_branch_refs(content)
if not self.skip_postgres_ci and self.repo_has_pytest_ci(repo):
new_content = self.patch_install_script_postgres_db(new_content)
if new_content != content:
message = f"{self.config.commit_message}\n\nUpdated install.sh"
if not self.skip_postgres_ci and self.repo_has_pytest_ci(repo):
message += " for version-16 and Postgres CI"
else:
message += " branch references"
return FileUpdate(
path=path,
content=new_content,
message=message,
sha=sha,
)
return None
def parse_args():
"""Parse command line arguments."""
parser = argparse.ArgumentParser(
description="Migrate Frappe apps to version-16",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python migrate_to_v16.py --repos fleet --org AgriTheory --token $GITHUB_TOKEN
python migrate_to_v16.py --repos fleet,inventory_tools,check_run --org AgriTheory --token $GITHUB_TOKEN
python migrate_to_v16.py --repos fleet --org AgriTheory --token $GITHUB_TOKEN --dry-run
""",
)
parser.add_argument(
"--repos",
type=str,
required=True,
help="Comma-separated list of repository names",
)
parser.add_argument(
"--org",
type=str,
required=True,
help="GitHub organization name",
)
parser.add_argument(
"--token",
type=str,
required=True,
help="GitHub personal access token",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Print what would be done without making changes",
)
parser.add_argument(
"--python-version",
type=str,
default="3.14",
help="Python version for workflows (default: 3.14)",
)
parser.add_argument(
"--node-version",
type=str,
default="24",
help="Node.js version for workflows (default: 24)",
)
parser.add_argument(
"--skip-postgres-ci",
action="store_true",
help="Skip Postgres pytest workflow scaffolding and install.sh Postgres support",
)
return parser.parse_args()
def main():
"""Main entry point."""
args = parse_args()
repos = [r.strip() for r in args.repos.split(",") if r.strip()]
if not repos:
print("Error: No repositories specified")
sys.exit(1)
config = MigrationConfig(
python_version=args.python_version,
node_version=args.node_version,
)
print(f"\n🚀 Frappe Version-16 Migration Script")
print(f"{'='*60}")
print(f"Organization: {args.org}")
print(f"Repositories: {', '.join(repos)}")
print(f"Source branch: {config.source_branch}")
print(f"Target branch: {config.target_branch}")
print(f"Python version: {config.python_version}")
print(f"Node version: {config.node_version}")
print(f"Postgres CI: {'disabled' if args.skip_postgres_ci else 'enabled'}")
migrator = FrappeAppMigrator(
args.token,
args.org,
config,
skip_postgres_ci=args.skip_postgres_ci,
)
results = []
for repo in repos:
result = migrator.migrate_repo(repo)
results.append(result)
# Print summary
print(f"\n{'='*60}")
print("Migration Summary")
print(f"{'='*60}")
successful = [r for r in results if r.success]
failed = [r for r in results if not r.success]
print(f"\n✅ Successful: {len(successful)}")
for r in successful:
print(f" - {r.repo_name}: {len(r.files_updated)} files updated")
if r.warnings:
for w in r.warnings:
print(f" ⚠️ {w}")
if failed:
print(f"\n❌ Failed: {len(failed)}")
for r in failed:
print(f" - {r.repo_name}")
for e in r.errors:
print(f" Error: {e}")
return 0 if not failed else 1
if __name__ == "__main__":
sys.exit(main())
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment