Last active
July 7, 2026 13:03
-
-
Save agritheory/28fa94da76d70df384f3bd8ef404ca4a to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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