Skip to content

Commit abc2dd6

Browse files
✨ Add fastapi cloud setup-ci (fastapilabs#157)
* Add tests * Add tests * Update test * Improve annotation * Clean up tags * Cleanup * Simplify test * Clean up dry run * Address PR comments
1 parent e30b63f commit abc2dd6

File tree

3 files changed

+1062
-0
lines changed

3 files changed

+1062
-0
lines changed

src/fastapi_cloud_cli/cli.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from .commands.login import login
77
from .commands.logout import logout
88
from .commands.logs import logs
9+
from .commands.setup_ci import setup_ci
910
from .commands.unlink import unlink
1011
from .commands.whoami import whoami
1112
from .logging import setup_logging
@@ -32,6 +33,7 @@
3233
cloud_app.command()(logout)
3334
cloud_app.command()(whoami)
3435
cloud_app.command()(unlink)
36+
cloud_app.command()(setup_ci)
3537

3638
cloud_app.add_typer(env_app, name="env")
3739

Lines changed: 360 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,360 @@
1+
import logging
2+
import re
3+
import shutil
4+
import subprocess
5+
from pathlib import Path
6+
from typing import Annotated
7+
8+
import typer
9+
10+
from fastapi_cloud_cli.utils.api import APIClient
11+
from fastapi_cloud_cli.utils.apps import get_app_config
12+
from fastapi_cloud_cli.utils.auth import Identity
13+
from fastapi_cloud_cli.utils.cli import get_rich_toolkit, handle_http_errors
14+
15+
logger = logging.getLogger(__name__)
16+
17+
TOKEN_EXPIRES_DAYS = 365
18+
DEFAULT_WORKFLOW_PATH = Path(".github/workflows/deploy.yml")
19+
20+
21+
class GitHubSecretError(Exception):
22+
"""Raised when setting a GitHub Actions secret fails."""
23+
24+
pass
25+
26+
27+
def _get_github_host(origin: str) -> str:
28+
"""Extract the GitHub host from a git remote URL.
29+
30+
Supports both github.com and GitHub Enterprise hosts.
31+
Examples:
32+
git@github.com:owner/repo.git -> github.com
33+
https://github.com/owner/repo.git -> github.com
34+
git@enterprise.github.com:owner/repo.git -> enterprise.github.com
35+
"""
36+
# Match git@HOST:owner/repo or https://HOST/owner/repo
37+
match = re.search(r"(?:git@|https://)([^:/]+)", origin)
38+
return match.group(1) if match else "github.com"
39+
40+
41+
def _repo_slug_from_origin(origin: str) -> str | None:
42+
"""Extract 'owner/repo' from a GitHub remote URL."""
43+
# Handles URLs like: git@github.com:owner/repo.git or https://github.com/owner/repo.git
44+
# Also supports GitHub Enterprise hosts like git@github.enterprise.com:owner/repo.git
45+
# Match the part after the last : or / (which is owner/repo)
46+
match = re.search(r"[:/]([^:/]+/[^/]+?)(?:\.git)?$", origin)
47+
return match.group(1) if match else None
48+
49+
50+
def _check_git_installed() -> bool:
51+
"""Check if git is installed and available."""
52+
return shutil.which("git") is not None
53+
54+
55+
def _check_gh_cli_installed() -> bool:
56+
"""Check if the GitHub CLI (gh) is installed and available."""
57+
return shutil.which("gh") is not None
58+
59+
60+
def _get_remote_origin() -> str:
61+
"""Get the remote origin URL of the Git repository."""
62+
try:
63+
# Try gh first (to respect gh repo set-default)
64+
result = subprocess.run(
65+
["gh", "repo", "view", "--json", "url", "-q", ".url"],
66+
capture_output=True,
67+
text=True,
68+
check=True,
69+
)
70+
return result.stdout.strip()
71+
# CalledProcessError if gh command fails, FileNotFoundError if gh is not installed
72+
except (subprocess.CalledProcessError, FileNotFoundError):
73+
# Fallback to git command
74+
result = subprocess.run(
75+
["git", "config", "--get", "remote.origin.url"],
76+
capture_output=True,
77+
text=True,
78+
check=True,
79+
)
80+
return result.stdout.strip()
81+
82+
83+
def _set_github_secret(name: str, value: str) -> None:
84+
"""Set a GitHub Actions secret via the gh CLI.
85+
86+
Raises:
87+
GitHubSecretError: If setting the secret fails.
88+
"""
89+
try:
90+
subprocess.run(
91+
["gh", "secret", "set", name, "--body", value],
92+
capture_output=True,
93+
check=True,
94+
)
95+
except (subprocess.CalledProcessError, FileNotFoundError) as e:
96+
raise GitHubSecretError(f"Failed to set GitHub secret '{name}'") from e
97+
98+
99+
def _create_token(app_id: str, token_name: str) -> dict[str, str]:
100+
"""Create a new deploy token.
101+
102+
Returns token_data dict with 'value' and 'expired_at' keys.
103+
"""
104+
with APIClient() as client:
105+
response = client.post(
106+
f"/apps/{app_id}/tokens",
107+
json={"name": token_name, "expires_in_days": TOKEN_EXPIRES_DAYS},
108+
)
109+
response.raise_for_status()
110+
data = response.json()
111+
return {"value": data["value"], "expired_at": data["expired_at"]}
112+
113+
114+
def _get_default_branch() -> str:
115+
"""Get the default branch of the Git repository."""
116+
try:
117+
result = subprocess.run(
118+
[
119+
"gh",
120+
"repo",
121+
"view",
122+
"--json",
123+
"defaultBranchRef",
124+
"-q",
125+
".defaultBranchRef.name",
126+
],
127+
capture_output=True,
128+
text=True,
129+
check=True,
130+
)
131+
return result.stdout.strip()
132+
except (subprocess.CalledProcessError, FileNotFoundError):
133+
return "main"
134+
135+
136+
def _write_workflow_file(branch: str, workflow_path: Path) -> None:
137+
workflow_content = f"""\
138+
name: Deploy to FastAPI Cloud
139+
on:
140+
push:
141+
branches: [{branch}]
142+
jobs:
143+
deploy:
144+
runs-on: ubuntu-latest
145+
steps:
146+
- uses: actions/checkout@v5
147+
- uses: astral-sh/setup-uv@v7
148+
- run: uv run fastapi deploy
149+
env:
150+
FASTAPI_CLOUD_TOKEN: ${{{{ secrets.FASTAPI_CLOUD_TOKEN }}}}
151+
FASTAPI_CLOUD_APP_ID: ${{{{ secrets.FASTAPI_CLOUD_APP_ID }}}}
152+
"""
153+
workflow_path.parent.mkdir(parents=True, exist_ok=True)
154+
workflow_path.write_text(workflow_content)
155+
156+
157+
def setup_ci(
158+
path: Annotated[
159+
Path | None,
160+
typer.Argument(
161+
help="Path to the folder containing the app (defaults to current directory)"
162+
),
163+
] = None,
164+
branch: str | None = typer.Option(
165+
None,
166+
"--branch",
167+
"-b",
168+
help="Branch that triggers deploys (defaults to the repo's default branch)",
169+
),
170+
secrets_only: bool = typer.Option(
171+
False,
172+
"--secrets-only",
173+
"-s",
174+
help="Provisions token and sets secrets, skips writing the workflow file",
175+
show_default=True,
176+
),
177+
dry_run: bool = typer.Option(
178+
False,
179+
"--dry-run",
180+
"-d",
181+
help="Prints steps that would be taken without actually performing them",
182+
show_default=True,
183+
),
184+
file: str | None = typer.Option(
185+
None,
186+
"--file",
187+
"-f",
188+
help="Custom workflow filename (written to .github/workflows/)",
189+
),
190+
) -> None:
191+
"""Configures a GitHub Actions workflow for deploying the app on push to the specified branch.
192+
193+
Examples:
194+
fastapi cloud setup-ci # Provisions token, sets secrets, and writes workflow file for the 'main' branch
195+
fastapi cloud setup-ci --branch develop # Same as above but for the 'develop' branch
196+
fastapi cloud setup-ci --secrets-only # Only provisions token and sets secrets, does not write workflow file
197+
fastapi cloud setup-ci --dry-run # Prints the steps that would be taken without performing them
198+
fastapi cloud setup-ci --file ci.yml # Writes workflow to .github/workflows/ci.yml
199+
"""
200+
201+
identity = Identity()
202+
203+
with get_rich_toolkit() as toolkit:
204+
if not identity.is_logged_in():
205+
toolkit.print(
206+
"No credentials found. Use [blue]`fastapi login`[/] to login.",
207+
tag="auth",
208+
)
209+
raise typer.Exit(1)
210+
211+
app_path = path or Path.cwd()
212+
app_config = get_app_config(app_path)
213+
214+
if not app_config:
215+
toolkit.print(
216+
"No app linked to this directory. Run [blue]`fastapi deploy`[/] first.",
217+
tag="error",
218+
)
219+
raise typer.Exit(1)
220+
221+
if not _check_git_installed():
222+
toolkit.print(
223+
"git is not installed. Please install git to use this command.",
224+
tag="error",
225+
)
226+
raise typer.Exit(1)
227+
228+
try:
229+
origin = _get_remote_origin()
230+
except subprocess.CalledProcessError:
231+
toolkit.print(
232+
"Error retrieving git remote origin URL. Make sure you're in a git repository with a remote origin set.",
233+
tag="error",
234+
)
235+
raise typer.Exit(1) from None
236+
237+
# Check if it's a GitHub host (github.com or GitHub Enterprise)
238+
if "github" not in origin.lower():
239+
toolkit.print(
240+
"Remote origin is not a GitHub repository. Please set up a GitHub repo and add it as the remote origin.",
241+
tag="error",
242+
)
243+
raise typer.Exit(1)
244+
245+
repo_slug = _repo_slug_from_origin(origin) or origin
246+
github_host = _get_github_host(origin)
247+
has_gh = _check_gh_cli_installed()
248+
249+
if not branch:
250+
branch = _get_default_branch()
251+
252+
if dry_run:
253+
toolkit.print(
254+
"[yellow]This is a dry run — no changes will be made[/yellow]"
255+
)
256+
toolkit.print_line()
257+
258+
toolkit.print_title("Configuring CI", tag="FastAPI")
259+
toolkit.print_line()
260+
261+
toolkit.print(f"Setting up CI for [bold]{repo_slug}[/bold] (branch: {branch})")
262+
toolkit.print_line()
263+
264+
msg_token = "Created deploy token"
265+
msg_secrets = (
266+
"Set [bold]FASTAPI_CLOUD_TOKEN[/bold] and [bold]FASTAPI_CLOUD_APP_ID[/bold]"
267+
)
268+
workflow_file = file or DEFAULT_WORKFLOW_PATH.name
269+
msg_workflow = (
270+
f"Wrote [bold].github/workflows/{workflow_file}[/bold] (branch: {branch})"
271+
)
272+
msg_done = "Done — commit and push to start deploying."
273+
274+
if dry_run:
275+
toolkit.print(msg_token)
276+
toolkit.print(msg_secrets)
277+
if not secrets_only:
278+
toolkit.print(msg_workflow)
279+
return
280+
281+
from datetime import datetime, timezone
282+
283+
# Create unique token name with timestamp to avoid duplicates
284+
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
285+
token_name = f"GitHub Actions — {repo_slug} ({timestamp})"
286+
287+
with (
288+
toolkit.progress(title="Generating deploy token...") as progress,
289+
handle_http_errors(
290+
progress, default_message="Error creating deploy token."
291+
),
292+
):
293+
token_data = _create_token(app_config.app_id, token_name)
294+
progress.log(msg_token)
295+
296+
toolkit.print_line()
297+
298+
if has_gh:
299+
with toolkit.progress(title="Setting repo secrets...") as progress:
300+
try:
301+
_set_github_secret("FASTAPI_CLOUD_TOKEN", token_data["value"])
302+
_set_github_secret("FASTAPI_CLOUD_APP_ID", app_config.app_id)
303+
except GitHubSecretError:
304+
progress.set_error("Failed to set GitHub secrets via gh CLI.")
305+
raise typer.Exit(1) from None
306+
progress.log(msg_secrets)
307+
else:
308+
secrets_url = f"https://{github_host}/{repo_slug}/settings/secrets/actions"
309+
toolkit.print(
310+
"[yellow]gh CLI not found. Set these secrets manually:[/yellow]",
311+
tag="info",
312+
)
313+
toolkit.print_line()
314+
toolkit.print(f" Repository: [blue]{secrets_url}[/]")
315+
toolkit.print_line()
316+
toolkit.print(f" [bold]FASTAPI_CLOUD_TOKEN[/bold] = {token_data['value']}")
317+
toolkit.print(f" [bold]FASTAPI_CLOUD_APP_ID[/bold] = {app_config.app_id}")
318+
319+
toolkit.print_line()
320+
321+
if not secrets_only:
322+
if file:
323+
workflow_path = Path(f".github/workflows/{file}")
324+
else:
325+
workflow_path = DEFAULT_WORKFLOW_PATH
326+
327+
write_workflow = True
328+
if not file and workflow_path.exists():
329+
overwrite = toolkit.confirm(
330+
f"Workflow file [bold]{workflow_path}[/bold] already exists. Overwrite?",
331+
tag="workflow",
332+
default=False,
333+
)
334+
if not overwrite:
335+
new_name = toolkit.input(
336+
"Enter a new filename (without path) or leave blank to skip writing the workflow file:",
337+
tag="workflow",
338+
).strip()
339+
if new_name:
340+
workflow_path = Path(f".github/workflows/{new_name}")
341+
else:
342+
toolkit.print("Skipped writing workflow file.")
343+
toolkit.print_line()
344+
write_workflow = False
345+
toolkit.print_line()
346+
if write_workflow:
347+
msg_workflow = f"Wrote [bold]{workflow_path}[/bold] (branch: {branch})"
348+
with toolkit.progress(title="Writing workflow file...") as progress:
349+
_write_workflow_file(branch, workflow_path)
350+
progress.log(msg_workflow)
351+
352+
toolkit.print_line()
353+
354+
toolkit.print(msg_done)
355+
toolkit.print_line()
356+
# Token expiration date is in ISO 8601 format (YYYY-MM-DDTHH:MM:SSZ), extract date portion
357+
toolkit.print(
358+
f"Your deploy token expires on [bold]{token_data['expired_at'][:10]}[/bold]. "
359+
"Regenerate it from the dashboard or re-run this command before then.",
360+
)

0 commit comments

Comments
 (0)