From 87e1957a6dbd24d20de2237e25983dedb4724468 Mon Sep 17 00:00:00 2001 From: Tyler Matteson Date: Wed, 24 Jun 2026 11:12:35 -0400 Subject: [PATCH] wip: deploy app to frappe cloud --- README.md | 2 + actions/frappe_cloud_publish/action.yml | 76 +++ .../publish_marketplace_release.py | 510 ++++++++++++++++++ docs/frappe_cloud_publish.md | 157 ++++++ docs/index.md | 1 + 5 files changed, 746 insertions(+) create mode 100644 actions/frappe_cloud_publish/action.yml create mode 100644 actions/frappe_cloud_publish/publish_marketplace_release.py create mode 100644 docs/frappe_cloud_publish.md diff --git a/README.md b/README.md index 2ed93ee..79245bf 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ Development tools for [Frappe](https://frappeframework.com) apps: test fixtures, See [docs/index.md](docs/index.md) for a full overview of every tool. +For Frappe Cloud Marketplace auto-publish from GitHub Actions, see [docs/frappe_cloud_publish.md](docs/frappe_cloud_publish.md). + --- ## Installation diff --git a/actions/frappe_cloud_publish/action.yml b/actions/frappe_cloud_publish/action.yml new file mode 100644 index 0000000..e53288b --- /dev/null +++ b/actions/frappe_cloud_publish/action.yml @@ -0,0 +1,76 @@ +name: Frappe Cloud Marketplace Publish +description: Register and submit a Frappe Cloud Marketplace app release (update-only, no bench deploy) +author: AgriTheory + +inputs: + repository: + description: GitHub repository slug (owner/repo). Defaults to the workflow repository. + required: false + default: '' + branch: + description: Publish branch; must match App Source.branch on Frappe Cloud + required: false + default: version-15 + app-name: + description: Frappe app name override when multiple App Sources match or repo basename differs + required: false + default: '' + fc-base-url: + description: Frappe Cloud base URL + required: false + default: https://cloud.frappe.io + fc-api-key: + description: Frappe Cloud API key (org secret FC_API_KEY) + required: true + fc-api-secret: + description: Frappe Cloud API secret (org secret FC_API_SECRET) + required: true + fc-team: + description: Frappe Cloud team name (optional org secret FC_TEAM; resolved via account.me) + required: false + default: '' + commit-hash: + description: Optional git commit hash to pin the App Release + required: false + default: '' + dry-run: + description: Resolve FC config only; do not create release or submit approval + required: false + default: 'false' + +runs: + using: composite + steps: + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + shell: bash + run: python -m pip install --upgrade pip requests + + - name: Publish to Frappe Cloud Marketplace + shell: bash + env: + FC_API_KEY: ${{ inputs.fc-api-key }} + FC_API_SECRET: ${{ inputs.fc-api-secret }} + FC_TEAM: ${{ inputs.fc-team }} + run: | + REPO="${{ inputs.repository }}" + if [ -z "$REPO" ]; then + REPO="${{ github.repository }}" + fi + + DRY_RUN_FLAG="" + if [ "${{ inputs.dry-run }}" = "true" ]; then + DRY_RUN_FLAG="--dry-run" + fi + + python "${{ github.action_path }}/publish_marketplace_release.py" \ + --repository "$REPO" \ + --branch "${{ inputs.branch }}" \ + --app-name "${{ inputs.app-name }}" \ + --fc-base-url "${{ inputs.fc-base-url }}" \ + --commit-hash "${{ inputs.commit-hash }}" \ + $DRY_RUN_FLAG diff --git a/actions/frappe_cloud_publish/publish_marketplace_release.py b/actions/frappe_cloud_publish/publish_marketplace_release.py new file mode 100644 index 0000000..40030e5 --- /dev/null +++ b/actions/frappe_cloud_publish/publish_marketplace_release.py @@ -0,0 +1,510 @@ +#!/usr/bin/env python3 +"""Register and submit Frappe Cloud Marketplace app releases (update-only, no bench deploy).""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from dataclasses import dataclass +from typing import Any + +import requests + + +class FrappeCloudError(Exception): + pass + + +@dataclass(frozen=True) +class AppSourceMatch: + name: str + app: str + branch: str + repository_owner: str + repository: str + + +@dataclass(frozen=True) +class PublishResult: + skipped: bool + reason: str + app: str | None = None + source: str | None = None + release: str | None = None + release_status: str | None = None + approval_submitted: bool = False + + +def parse_repository(repository: str) -> tuple[str, str]: + repository = repository.strip().strip("/") + if "/" not in repository: + raise FrappeCloudError( + f"Invalid repository slug: {repository!r} (expected owner/repo)" + ) + owner, repo = repository.split("/", 1) + if not owner or not repo: + raise FrappeCloudError(f"Invalid repository slug: {repository!r}") + return owner, repo.removesuffix(".git") + + +def pick_app_source( + sources: list[dict[str, Any]], + app_name: str | None, +) -> AppSourceMatch: + if not sources: + raise FrappeCloudError("No App Source records provided") + + if app_name: + matches = [s for s in sources if s.get("app") == app_name] + if not matches: + available = ", ".join(sorted({s.get("app", "") for s in sources})) + raise FrappeCloudError( + f"No App Source for app {app_name!r} (available: {available})" + ) + if len(matches) > 1: + raise FrappeCloudError( + f"Multiple App Sources for app {app_name!r}; set app-name explicitly" + ) + source = matches[0] + elif len(sources) == 1: + source = sources[0] + else: + apps = ", ".join(sorted({s.get("app", "") for s in sources})) + raise FrappeCloudError( + f"Multiple App Sources match this repo/branch ({apps}); set app-name input" + ) + + return AppSourceMatch( + name=source["name"], + app=source["app"], + branch=source.get("branch") or "", + repository_owner=source.get("repository_owner") or "", + repository=source.get("repository") or "", + ) + + +class FrappeCloudClient: + def __init__( + self, + base_url: str, + api_key: str, + api_secret: str, + team: str | None = None, + ) -> None: + self.base_url = base_url.rstrip("/") + if self.base_url.rstrip("/") == "https://frappecloud.com": + raise FrappeCloudError( + "Use https://cloud.frappe.io as fc-base-url. https://frappecloud.com " + "redirects and strips Authorization headers, which breaks API token auth." + ) + self.team = team.strip() if team else None + self.session = requests.Session() + self.session.headers.update( + { + "Authorization": f"token {api_key}:{api_secret}", + "Content-Type": "application/json", + "Accept": "application/json", + } + ) + if self.team: + self.session.headers["X-Press-Team"] = self.team + + def list_teams(self) -> list[dict[str, str]]: + account = self.call_method("press.api.account.get") + if not isinstance(account, dict): + return [] + + teams: list[dict[str, str]] = [] + for team in account.get("teams") or []: + if isinstance(team, dict): + teams.append( + { + "name": team.get("name") or "", + "title": team.get("team_title") or team.get("name") or "", + } + ) + elif isinstance(team, str): + teams.append({"name": team, "title": team}) + return [team for team in teams if team["name"]] + + def ensure_team(self) -> str: + if self.team: + return self.team + + teams = self.list_teams() + if len(teams) == 1: + self.team = teams[0]["name"] + self.session.headers["X-Press-Team"] = self.team + return self.team + + if len(teams) > 1: + options = ", ".join( + f"{team['name']} ({team['title']})" + if team["title"] != team["name"] + else team["name"] + for team in teams + ) + raise FrappeCloudError( + "Multiple Frappe Cloud teams available. Set FC_TEAM or pass --fc-team. " + f"Options: {options}. Run with --list-teams for details." + ) + + me = self.call_method("press.api.account.me") + if not isinstance(me, dict) or not me.get("team"): + raise FrappeCloudError( + "Could not resolve Frappe Cloud team; set FC_TEAM or pass --fc-team" + ) + + self.team = me["team"] + self.session.headers["X-Press-Team"] = self.team + return self.team + + def call_method(self, method: str, data: dict[str, Any] | None = None) -> Any: + url = f"{self.base_url}/api/method/{method}" + try: + response = self.session.post( + url, json=data or {}, timeout=120, allow_redirects=False + ) + except requests.RequestException as exc: + raise FrappeCloudError(f"Network error calling {method}: {exc}") from exc + + if response.status_code in (301, 302, 303, 307, 308): + location = response.headers.get("Location", "") + raise FrappeCloudError( + f"Unexpected redirect from {url} to {location}. " + "Use https://cloud.frappe.io as fc-base-url." + ) + + if not response.ok: + raise FrappeCloudError( + f"HTTP {response.status_code} calling {method}: {response.text[:500]}" + ) + + try: + payload = response.json() + except json.JSONDecodeError as exc: + raise FrappeCloudError(f"Invalid JSON from {method}: {response.text[:500]}") from exc + + if payload.get("exc_type") or payload.get("exception"): + message = payload.get("message") + if not message and payload.get("_server_messages"): + try: + message = json.loads(payload["_server_messages"])[0] + if isinstance(message, str): + message = json.loads(message).get("message", message) + except (json.JSONDecodeError, IndexError, TypeError): + message = payload["_server_messages"] + raise FrappeCloudError(message or str(payload)) + if "message" in payload: + return payload["message"] + return payload + + def verify_auth(self) -> dict[str, Any]: + me = self.call_method("press.api.account.me") + if not isinstance(me, dict) or not me.get("user") or me.get("user") == "Guest": + raise FrappeCloudError( + "Frappe Cloud API authentication failed. Regenerate API keys from " + "Settings → Profile & Team → API Access." + ) + team = self.ensure_team() + return {"user": me["user"], "team": team} + + def find_app_sources( + self, + repository_owner: str, + repository: str, + branch: str, + ) -> list[dict[str, Any]]: + self.ensure_team() + apps = self.call_method("press.api.marketplace.get_apps") + if not isinstance(apps, list): + return [] + + matches: list[dict[str, Any]] = [] + for app_summary in apps: + marketplace_app_name = app_summary.get("name") + if not marketplace_app_name: + continue + + app_detail = self.call_method( + "press.api.marketplace.get_app", + {"name": marketplace_app_name}, + ) + if not isinstance(app_detail, dict): + continue + + for source_row in app_detail.get("sources") or []: + source_info = source_row.get("source_information") or {} + if ( + source_info.get("repository_owner") == repository_owner + and source_info.get("repository") == repository + and source_info.get("branch") == branch + and source_info.get("enabled") + ): + matches.append( + { + "name": source_info.get("name"), + "app": source_info.get("app"), + "branch": source_info.get("branch"), + "repository_owner": source_info.get("repository_owner"), + "repository": source_info.get("repository"), + "marketplace_app": marketplace_app_name, + "marketplace_app_status": app_detail.get("status"), + } + ) + + return [match for match in matches if match.get("name") and match.get("app")] + + def find_marketplace_app( + self, app: str, source_match: dict[str, Any] + ) -> dict[str, Any] | None: + marketplace_app_name = source_match.get("marketplace_app") + if not marketplace_app_name: + return None + + return { + "name": marketplace_app_name, + "app": app, + "status": source_match.get("marketplace_app_status"), + } + + def create_release(self, source_name: str, commit_hash: str | None = None) -> Any: + self.ensure_team() + args: dict[str, Any] = {"force": True} + if commit_hash: + args["commit_hash"] = commit_hash + return self.call_method( + "press.api.client.run_doc_method", + { + "dt": "App Source", + "dn": source_name, + "method": "create_release", + "args": args, + }, + ) + + def latest_release(self, source_name: str) -> dict[str, Any] | None: + releases = self.call_method( + "press.api.marketplace.releases", + { + "filters": {"source": source_name}, + "limit_page_length": 1, + "order_by": "creation desc", + }, + ) + if isinstance(releases, list) and releases: + return releases[0] + return None + + def submit_for_approval(self, marketplace_app: str, app_release: str) -> None: + self.call_method( + "press.api.marketplace.create_approval_request", + {"name": marketplace_app, "app_release": app_release}, + ) + + +def publish_marketplace_release( + client: FrappeCloudClient, + repository: str, + branch: str, + app_name: str | None = None, + commit_hash: str | None = None, + dry_run: bool = False, +) -> PublishResult: + owner, repo = parse_repository(repository) + sources = client.find_app_sources(owner, repo, branch) + if not sources: + return PublishResult( + skipped=True, + reason=f"No enabled App Source on Frappe Cloud for {owner}/{repo} @ {branch}", + ) + + source = pick_app_source(sources, app_name) + source_row = next(match for match in sources if match["name"] == source.name) + marketplace_app = client.find_marketplace_app(source.app, source_row) + if not marketplace_app: + return PublishResult( + skipped=True, + reason=f"No Marketplace App on Frappe Cloud for app {source.app!r}", + app=source.app, + source=source.name, + ) + + if dry_run: + return PublishResult( + skipped=False, + reason="dry-run", + app=source.app, + source=source.name, + release_status="(dry-run)", + ) + + try: + client.create_release(source.name, commit_hash=commit_hash) + except FrappeCloudError as exc: + print( + f"Note: could not force-poll App Source ({exc}); " + "relying on GitHub webhook to create the App Release", + file=sys.stderr, + ) + + release = client.latest_release(source.name) + if not release: + raise FrappeCloudError( + f"No App Release found for source {source.name}. " + "Confirm the GitHub App/webhook is connected on Frappe Cloud and retry." + ) + + release_name = release.get("name") + release_status = release.get("status") + approval_submitted = False + + if release_status == "Draft": + try: + client.submit_for_approval(marketplace_app["name"], release_name) + approval_submitted = True + release = client.latest_release(source.name) or release + release_status = release.get("status", release_status) + except FrappeCloudError as exc: + message = str(exc).lower() + if "active request" in message or "already exists" in message: + approval_submitted = False + else: + raise + + return PublishResult( + skipped=False, + reason="published", + app=source.app, + source=source.name, + release=release_name, + release_status=release_status, + approval_submitted=approval_submitted, + ) + + +def write_step_summary(result: PublishResult, repository: str, branch: str) -> None: + summary_path = os.environ.get("GITHUB_STEP_SUMMARY") + if not summary_path: + return + + lines = [ + "## Frappe Cloud Marketplace Publish", + "", + f"- **Repository:** `{repository}`", + f"- **Branch:** `{branch}`", + ] + if result.skipped: + lines.append(f"- **Result:** skipped — {result.reason}") + else: + lines.extend( + [ + f"- **App:** `{result.app}`", + f"- **App Source:** `{result.source}`", + f"- **Release:** `{result.release}`", + f"- **Status:** `{result.release_status}`", + f"- **Approval submitted:** `{result.approval_submitted}`", + ] + ) + lines.append("") + + with open(summary_path, "a", encoding="utf-8") as handle: + handle.write("\n".join(lines)) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repository", help="GitHub owner/repo") + parser.add_argument("--branch", help="Publish branch (App Source.branch)") + parser.add_argument("--app-name", default="", help="FC app name override") + parser.add_argument("--fc-base-url", default="https://cloud.frappe.io") + parser.add_argument("--fc-api-key", default=os.environ.get("FC_API_KEY", "")) + parser.add_argument("--fc-api-secret", default=os.environ.get("FC_API_SECRET", "")) + parser.add_argument( + "--fc-team", + default=os.environ.get("FC_TEAM", ""), + help="Frappe Cloud team name (required when user belongs to multiple teams)", + ) + parser.add_argument( + "--list-teams", + action="store_true", + help="List Frappe Cloud teams for this API key and exit", + ) + parser.add_argument("--commit-hash", default="", help="Optional commit hash pin") + parser.add_argument( + "--dry-run", + action="store_true", + help="Resolve FC config only; do not create release or submit approval", + ) + args = parser.parse_args(argv) + + if not args.list_teams and (not args.repository or not args.branch): + parser.error("--repository and --branch are required unless using --list-teams") + + if not args.fc_api_key or not args.fc_api_secret: + print("FC_API_KEY and FC_API_SECRET are required", file=sys.stderr) + return 1 + + client = FrappeCloudClient( + args.fc_base_url, + args.fc_api_key, + args.fc_api_secret, + team=args.fc_team.strip() or None, + ) + app_name = args.app_name.strip() or None + commit_hash = args.commit_hash.strip() or None + + try: + if args.list_teams: + me = client.call_method("press.api.account.me") + user = me.get("user") if isinstance(me, dict) else me + print(f"Authenticated as {user}") + for team in client.list_teams(): + default = "" + if isinstance(me, dict) and me.get("team") == team["name"]: + default = " (current default)" + title = team["title"] + if title and title != team["name"]: + print(f"- {team['name']} — {title}{default}") + else: + print(f"- {team['name']}{default}") + return 0 + + auth = client.verify_auth() + print(f"Authenticated as {auth['user']} (team {auth['team']})") + result = publish_marketplace_release( + client, + repository=args.repository, + branch=args.branch, + app_name=app_name, + commit_hash=commit_hash, + dry_run=args.dry_run, + ) + except FrappeCloudError as exc: + print(f"::error::{exc}", file=sys.stderr) + return 1 + + write_step_summary(result, args.repository, args.branch) + + if result.skipped: + print(result.reason) + return 0 + + if args.dry_run: + print( + f"dry-run: would publish {result.app} from source {result.source} " + f"({args.repository} @ {args.branch})" + ) + return 0 + + print( + f"Published {result.app}: release {result.release} ({result.release_status})" + + ("; approval submitted" if result.approval_submitted else "") + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/docs/frappe_cloud_publish.md b/docs/frappe_cloud_publish.md new file mode 100644 index 0000000..744bc25 --- /dev/null +++ b/docs/frappe_cloud_publish.md @@ -0,0 +1,157 @@ +# Frappe Cloud Marketplace Publish + +Register and submit [Frappe Cloud Marketplace](https://docs.frappe.io/cloud/marketplace/manage-marketplace-app) app releases after a **merged PR** to your publish branch. This is **update-only**: it finds the `App Release` (created by the FC GitHub webhook) and submits it for marketplace publishing. It does **not** deploy or update benches. + +## Workflow + +```mermaid +sequenceDiagram + participant GHA as AppRepo_GHA + participant Action as test_utils_action + participant FC as FrappeCloud + + GHA->>Action: frappe_cloud_publish + Action->>FC: find App Source by repo + branch + alt no App Source + Action-->>GHA: skip + else configured + Action->>FC: AppSource.create_release + Action->>FC: create_approval_request if Draft + end +``` + +Frappe Cloud is the source of truth for which apps are publishable. **test_utils ships only the composite action** — it does not run publish workflows and must not store `FC_API_KEY` / `FC_API_SECRET`. Each marketplace app repo adds the workflow below and holds (or inherits org) FC credentials. + +## Usage + +Add `.github/workflows/frappe-cloud-publish.yaml` to a marketplace app repo: + +```yaml +name: Publish to Frappe Cloud Marketplace + +on: + pull_request: + types: + - closed + branches: [version-15] # must match App Source.branch on FC + workflow_dispatch: + inputs: + dry_run: + description: Resolve FC config only; do not submit approval + required: false + default: true + type: boolean + +jobs: + publish: + if: github.event_name == 'workflow_dispatch' || github.event.pull_request.merged == true + runs-on: ubuntu-latest + concurrency: fc-publish-${{ github.repository }} + steps: + - uses: agritheory/test_utils/actions/frappe_cloud_publish@ + with: + fc-api-key: ${{ secrets.FC_API_KEY }} + fc-api-secret: ${{ secrets.FC_API_SECRET }} + branch: version-15 + dry-run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run && 'true' || 'false' }} + # app-name: my_app # only if repo basename ≠ FC app name +``` + +The workflow runs when a PR into the publish branch is **merged**, not on every push. Direct pushes to the publish branch do not trigger it (use `workflow_dispatch` to publish manually). Frappe Cloud still creates `App Release` records via the GitHub webhook on push; this workflow only submits them for marketplace approval. + +### GitHub secrets (app repos only) + +Store `FC_API_KEY` and `FC_API_SECRET` as **organization secrets** (recommended, shared across marketplace repos) or **repository secrets** on each app repo. **Do not** add these to `test_utils`. + +| Secret | Description | +|--------|-------------| +| `FC_API_KEY` | Frappe Cloud API key (publisher team user) | +| `FC_API_SECRET` | Frappe Cloud API secret | +| `FC_TEAM` | Optional when you belong to one team. **Required** when you belong to multiple teams. Use `--list-teams` locally to see values. | + +Generate keys from Frappe Cloud dashboard → **Settings → Profile & Team → API Access → Create New API Key**. Copy the **API Secret** immediately — it is shown only once. + +Frappe Cloud only allows token-authenticated calls under `press.api.*` (not generic `frappe.*` endpoints). The publish action uses marketplace APIs plus optional release polling via the GitHub webhook. + +### Verify credentials locally + +```bash +export FC_API_KEY="..." +export FC_API_SECRET="..." + +# List teams (pick the one that owns your marketplace apps) +python actions/frappe_cloud_publish/publish_marketplace_release.py --list-teams + +# Set the publisher team, then dry-run +export FC_TEAM="your-team-name@example.com" +python actions/frappe_cloud_publish/publish_marketplace_release.py \ + --repository agritheory/check_run \ + --branch version-15 \ + --dry-run +``` + +The API key must belong to a user with a **Marketplace developer account** ([Become a Publisher](https://docs.frappe.io/cloud/marketplace/publishing-an-app-to-marketplace) on the FC Profile tab). + +### Frappe Cloud prerequisites + +Before enabling the workflow for an app: + +1. [Publish the app to the marketplace](https://docs.frappe.io/cloud/marketplace/publishing-an-app-to-marketplace) — `Marketplace App` exists +2. `App Source` is enabled with correct `repository_owner`, `repository`, and `branch` +3. GitHub App is connected on FC for the repo (webhook backup; action force-polls via `create_release`) + +### Deploy guard checklist + +Confirm these are **not** set for marketplace publish branches, or `App Release.after_insert` may trigger bench deploys: + +- [ ] No `Release Group App.enable_auto_deploy` for the app's `App Source` +- [ ] No `auto-deploy` resource tag on publisher release groups +- [ ] No `deploy_marker` string in commit messages on the publish branch + +### Action inputs + +| Input | Default | Description | +|-------|---------|-------------| +| `repository` | workflow repo | `owner/repo` slug | +| `branch` | `version-15` | Must match `App Source.branch` | +| `app-name` | (derived) | Override when multiple sources match | +| `fc-base-url` | `https://cloud.frappe.io` | FC site URL. Do **not** use `https://frappecloud.com` — it redirects and strips API auth headers. | +| `commit-hash` | HEAD | Optional pin for `create_release` | +| `dry-run` | `false` | Resolve FC config only | + +### Onboarding a new marketplace app + +1. Configure `Marketplace App` + `App Source` on Frappe Cloud +2. Add the workflow file to the GitHub repo (PR target branch must match `App Source.branch`) +3. Ensure `FC_API_KEY` / `FC_API_SECRET` are available to the **app repo** (org or repo secrets) +4. Merge a PR into the publish branch and verify the Releases tab on FC + +Manual test (after the workflow file is on the repo default branch): + +```bash +gh workflow run "Publish to Frappe Cloud Marketplace" \ + --repo agritheory/check_run \ + --ref version-15 \ + -f dry_run=true +``` + +### Marketplace apps (rollout) + +Add the workflow to each repo when ready: + +- agritheory/approvals +- agritheory/check_run +- agritheory/cloud_storage +- agritheory/beam +- agritheory/communications +- agritheory/inventory_tools +- agritheory/electronic_payments +- agritheory/forecast +- agritheory/autoreader +- agritheory/saml +- agritheory/fleet +- agritheory/shipstation_integration +- agritheory/taxjar_erpnext +- agritheory/frappe_vault + +Adjust `branch` in the workflow to match each app's FC `App Source` branch (most use `version-15`). diff --git a/docs/index.md b/docs/index.md index 72f970a..e04dcdd 100644 --- a/docs/index.md +++ b/docs/index.md @@ -98,3 +98,4 @@ Reusable GitHub Action workflows are in the `actions/` directory: - Python semantic release - Synchronization report with this library - Translated documentation pull requests +- [Frappe Cloud Marketplace publish](frappe_cloud_publish.md) — register and submit marketplace app releases