fship 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
fship/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Flutter Ship — release orchestration CLI."""
2
+
3
+ __version__ = "0.1.0"
fship/builder.py ADDED
@@ -0,0 +1,65 @@
1
+ import subprocess
2
+ from pathlib import Path
3
+ from rich.console import Console
4
+
5
+ console = Console()
6
+
7
+
8
+ def build_apk(flavor: str, entrypoint: str) -> tuple[bool, str]:
9
+ """Build Flutter APK for flavor."""
10
+ cmd = [
11
+ "flutter",
12
+ "build",
13
+ "apk",
14
+ "--flavor",
15
+ flavor,
16
+ "-t",
17
+ entrypoint,
18
+ ]
19
+
20
+ try:
21
+ console.print(f"[dim]$ {' '.join(cmd)}[/dim]")
22
+ result = subprocess.run(cmd, capture_output=True, text=True, check=False)
23
+
24
+ if result.returncode != 0:
25
+ console.print(
26
+ f"[red]✗ Flutter build failed:[/red]\n{result.stderr[:500]}"
27
+ )
28
+ return False, ""
29
+
30
+ console.print("[green]✓[/green] APK built successfully")
31
+
32
+ apk_path = find_built_apk(flavor)
33
+ if apk_path:
34
+ console.print(f"[green]✓[/green] Found APK: {apk_path}")
35
+ return True, str(apk_path)
36
+ else:
37
+ console.print(
38
+ "[yellow]Warning: Could not locate built APK. Check build output.[/yellow]"
39
+ )
40
+ return True, ""
41
+
42
+ except FileNotFoundError:
43
+ console.print(
44
+ "[red]✗ Flutter not found. Install Flutter SDK or add to PATH.[/red]"
45
+ )
46
+ return False, ""
47
+ except Exception as e:
48
+ console.print(f"[red]✗ Build failed: {e}[/red]")
49
+ return False, ""
50
+
51
+
52
+ def find_built_apk(flavor: str) -> Path | None:
53
+ """Try to locate the built APK by checking standard paths."""
54
+ standard_paths = [
55
+ f"build/app/outputs/flutter-apk/app-{flavor}-release.apk",
56
+ f"build/app/outputs/apk/{flavor}/release/app-{flavor}-release.apk",
57
+ f"build/app/outputs/apk/release/app-release.apk",
58
+ ]
59
+
60
+ for p in standard_paths:
61
+ path = Path(p)
62
+ if path.exists():
63
+ return path
64
+
65
+ return None
fship/changelog.py ADDED
@@ -0,0 +1,119 @@
1
+ import subprocess
2
+ from pathlib import Path
3
+ from rich.console import Console
4
+
5
+ console = Console()
6
+
7
+
8
+ def get_previous_tag() -> str:
9
+ """Get the previous tag, skipping the latest one."""
10
+ try:
11
+ result = subprocess.run(
12
+ [
13
+ "bash",
14
+ "-c",
15
+ "git describe --tags --abbrev=0 $(git rev-list --tags --skip=1 --max-count=1)",
16
+ ],
17
+ capture_output=True,
18
+ text=True,
19
+ check=False,
20
+ )
21
+ if result.returncode == 0:
22
+ return result.stdout.strip()
23
+ except Exception:
24
+ pass
25
+ return ""
26
+
27
+
28
+ def generate_changelog() -> bool:
29
+ """Generate CHANGELOG.md using git-chglog. Non-fatal if config missing."""
30
+ try:
31
+ result = subprocess.run(
32
+ ["git-chglog", "-o", "CHANGELOG.md"],
33
+ capture_output=True,
34
+ text=True,
35
+ check=False,
36
+ )
37
+ if result.returncode == 0:
38
+ console.print("[green]✓[/green] CHANGELOG.md generated")
39
+ return True
40
+ else:
41
+ console.print(
42
+ "[yellow]⚠[/yellow] git-chglog skipped (missing .chglog/config.yml or other error)"
43
+ )
44
+ return True # non-fatal; continue with release
45
+
46
+ except FileNotFoundError:
47
+ console.print(
48
+ "[yellow]⚠[/yellow] git-chglog not found. Install: brew install git-chglog"
49
+ )
50
+ return True # non-fatal; continue
51
+
52
+
53
+ def generate_release_notes(flavor: str) -> bool:
54
+ """Generate release_note.txt from git log since last tag."""
55
+ prev_tag = get_previous_tag()
56
+
57
+ if not prev_tag:
58
+ console.print(
59
+ "[yellow]Warning: No previous tag found. Using all commits.[/yellow]"
60
+ )
61
+ rev_range = "HEAD"
62
+ else:
63
+ rev_range = f"{prev_tag}..HEAD"
64
+
65
+ try:
66
+ result = subprocess.run(
67
+ [
68
+ "bash",
69
+ "-c",
70
+ f'git log --pretty="- %s (%an)" {rev_range}',
71
+ ],
72
+ capture_output=True,
73
+ text=True,
74
+ check=True,
75
+ )
76
+ release_notes = result.stdout.strip()
77
+
78
+ if not release_notes:
79
+ release_notes = f"Release {flavor} - no new commits"
80
+
81
+ Path("release_note.txt").write_text(release_notes)
82
+ console.print("[green]✓[/green] release_note.txt generated")
83
+ return True
84
+ except subprocess.CalledProcessError as e:
85
+ console.print(f"[red]✗ Failed to generate release notes: {e}[/red]")
86
+ return False
87
+
88
+
89
+ def git_add_and_commit(version: str, flavor: str) -> bool:
90
+ """Stage and commit version bump + changelog. Only add files that exist."""
91
+ try:
92
+ files_to_add = ["pubspec.yaml"]
93
+ if Path("CHANGELOG.md").exists():
94
+ files_to_add.append("CHANGELOG.md")
95
+ if Path("release_note.txt").exists():
96
+ files_to_add.append("release_note.txt")
97
+
98
+ subprocess.run(["git", "add"] + files_to_add, check=True)
99
+ subprocess.run(
100
+ ["git", "commit", "-m", f"chore: release {version}-{flavor}"],
101
+ check=True,
102
+ )
103
+ console.print(f"[green]✓[/green] Committed: chore: release {version}-{flavor}")
104
+ return True
105
+ except subprocess.CalledProcessError as e:
106
+ console.print(f"[red]✗ Git commit failed: {e}[/red]")
107
+ return False
108
+
109
+
110
+ def git_tag(version: str, flavor: str) -> bool:
111
+ """Create git tag for release."""
112
+ tag = f"v{version}-{flavor}"
113
+ try:
114
+ subprocess.run(["git", "tag", tag], check=True)
115
+ console.print(f"[green]✓[/green] Tagged: {tag}")
116
+ return True
117
+ except subprocess.CalledProcessError as e:
118
+ console.print(f"[red]✗ Git tag failed: {e}[/red]")
119
+ return False
fship/config.py ADDED
@@ -0,0 +1,151 @@
1
+ import json
2
+ import os
3
+ import sys
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from rich.console import Console
7
+
8
+ console = Console()
9
+
10
+ CONFIG_DIR = Path.cwd() / ".config"
11
+ CONFIG_FILE = CONFIG_DIR / "fship.json"
12
+ ENV_FILE = Path.cwd() / ".env.dev"
13
+
14
+ DEFAULT_CFG = {
15
+ "flavors": {
16
+ "qa": {
17
+ "firebase_app_id_env": "APPIDANDROID_QA",
18
+ "entrypoint": "lib/main_qa.dart",
19
+ "apk_path": "build/app/outputs/flutter-apk/app-qa-release.apk",
20
+ "groups": "testers",
21
+ },
22
+ "uat": {
23
+ "firebase_app_id_env": "APPIDANDROID_UAT",
24
+ "entrypoint": "lib/main_uat.dart",
25
+ "apk_path": "build/app/outputs/flutter-apk/app-uat-release.apk",
26
+ "groups": "testers",
27
+ },
28
+ "prod": {
29
+ "firebase_app_id_env": "APPIDANDROID_PROD",
30
+ "entrypoint": "lib/main_prod.dart",
31
+ "apk_path": "build/app/outputs/flutter-apk/app-prod-release.apk",
32
+ "groups": "testers",
33
+ },
34
+ }
35
+ }
36
+
37
+
38
+ def load_env_file() -> None:
39
+ """Load environment variables from .env.dev if it exists."""
40
+ if not ENV_FILE.exists():
41
+ _create_env_template()
42
+ return
43
+
44
+ try:
45
+ for line in ENV_FILE.read_text().strip().split("\n"):
46
+ line = line.strip()
47
+ if not line or line.startswith("#"):
48
+ continue
49
+ if "=" in line:
50
+ key, value = line.split("=", 1)
51
+ os.environ[key.strip()] = value.strip().strip("'\"")
52
+ console.print(f"[dim]Loaded env from {ENV_FILE}[/dim]")
53
+ except Exception as e:
54
+ console.print(f"[yellow]Warning: Failed to load {ENV_FILE}: {e}[/yellow]")
55
+
56
+
57
+ def _create_env_template() -> None:
58
+ """Create .env.dev template and prompt user to fill in Android app IDs."""
59
+ template = """# Firebase Android App IDs for each flavor
60
+ # Get these from Firebase Console > App settings
61
+
62
+ APPIDANDROID_QA=
63
+ APPIDANDROID_UAT=
64
+ APPIDANDROID_PROD=
65
+ """
66
+ ENV_FILE.write_text(template)
67
+ console.print(f"[yellow]⚠ Created template: {ENV_FILE}[/yellow]")
68
+ console.print(f"[yellow]Please edit and add your Android app IDs:[/yellow]")
69
+ console.print(f"[dim] APPIDANDROID_QA=1:123456:android:abcdef...[/dim]")
70
+ console.print(f"[dim] APPIDANDROID_UAT=1:345678:android:ghijkl...[/dim]")
71
+ console.print(f"[dim] APPIDANDROID_PROD=1:789012:android:mnopqr...[/dim]")
72
+ console.print(f"[dim]Get values from: Firebase Console > App settings[/dim]")
73
+ console.print()
74
+ raise SystemExit("Configure .env.dev and run again")
75
+
76
+
77
+ @dataclass
78
+ class FlavorConfig:
79
+ firebase_app_id_env: str
80
+ entrypoint: str
81
+ apk_path: str
82
+ groups: str
83
+
84
+
85
+ @dataclass
86
+ class Config:
87
+ flavors: dict[str, FlavorConfig]
88
+
89
+
90
+ def load_config() -> Config:
91
+ """Load config from .config/fship.json or create with defaults."""
92
+ load_env_file()
93
+ CONFIG_DIR.mkdir(exist_ok=True)
94
+
95
+ if not CONFIG_FILE.exists():
96
+ CONFIG_FILE.write_text(json.dumps(DEFAULT_CFG, indent=2))
97
+ _ensure_gitignore()
98
+
99
+ try:
100
+ cfg = {**DEFAULT_CFG, **json.loads(CONFIG_FILE.read_text())}
101
+ except json.JSONDecodeError as e:
102
+ console.print(f"[red]✗ Invalid JSON in {CONFIG_FILE}: {e}[/red]")
103
+ raise
104
+
105
+ flavors = {}
106
+ for flavor_name, flavor_data in cfg.get("flavors", {}).items():
107
+ flavors[flavor_name] = FlavorConfig(
108
+ firebase_app_id_env=flavor_data["firebase_app_id_env"],
109
+ entrypoint=flavor_data["entrypoint"],
110
+ apk_path=flavor_data["apk_path"],
111
+ groups=flavor_data.get("groups", "testers"),
112
+ )
113
+
114
+ return Config(flavors=flavors)
115
+
116
+
117
+ def save_config(config: dict) -> None:
118
+ """Save config to .config/fship.json."""
119
+ CONFIG_DIR.mkdir(exist_ok=True)
120
+ CONFIG_FILE.write_text(json.dumps(config, indent=2))
121
+ _ensure_gitignore()
122
+ console.print(f"[green]✓[/green] Config saved to {CONFIG_FILE}")
123
+
124
+
125
+ def _ensure_gitignore() -> None:
126
+ """Add .config/ to .gitignore if git repo exists."""
127
+ gitignore = Path.cwd() / ".gitignore"
128
+ if not gitignore.exists():
129
+ return
130
+
131
+ content = gitignore.read_text()
132
+ if ".config/" not in content:
133
+ gitignore.write_text(content.rstrip() + "\n.config/\n")
134
+
135
+
136
+ def require_config(*keys: str) -> dict:
137
+ """Load config and exit if required keys missing."""
138
+ cfg = load_config()
139
+ missing = [k for k in keys if k not in cfg.flavors]
140
+ if missing:
141
+ console.print(f"[red]✗ Missing flavors: {', '.join(missing)}[/red]")
142
+ console.print(f"[dim]Edit: {CONFIG_FILE}[/dim]")
143
+ sys.exit(1)
144
+ return cfg.__dict__
145
+
146
+
147
+ def get_flavor(config: Config, flavor: str) -> FlavorConfig:
148
+ if flavor not in config.flavors:
149
+ available = ", ".join(config.flavors.keys())
150
+ raise ValueError(f"Unknown flavor '{flavor}'. Available: {available}")
151
+ return config.flavors[flavor]
fship/distributor.py ADDED
@@ -0,0 +1,78 @@
1
+ import os
2
+ import subprocess
3
+ from pathlib import Path
4
+ from rich.console import Console
5
+
6
+ console = Console()
7
+
8
+
9
+ def distribute_to_firebase(
10
+ apk_path: str,
11
+ firebase_app_id_env: str,
12
+ groups: str = "testers",
13
+ release_notes_file: str = "release_note.txt",
14
+ ) -> bool:
15
+ """Distribute APK to Firebase App Distribution."""
16
+ app_id = os.getenv(firebase_app_id_env)
17
+
18
+ if not app_id:
19
+ console.print(
20
+ f"[red]✗ Environment variable {firebase_app_id_env} not set.[/red]\n"
21
+ f"[dim]Add to .env.dev: {firebase_app_id_env}=<your-app-id>[/dim]\n"
22
+ f"[dim]Or export: export {firebase_app_id_env}=<your-app-id>[/dim]"
23
+ )
24
+ return False
25
+
26
+ if not Path(apk_path).exists():
27
+ console.print(f"[red]✗ APK not found: {apk_path}[/red]")
28
+ return False
29
+
30
+ if not Path(release_notes_file).exists():
31
+ console.print(
32
+ f"[yellow]Warning: {release_notes_file} not found. Distributing without notes.[/yellow]"
33
+ )
34
+ cmd = [
35
+ "firebase",
36
+ "appdistribution:distribute",
37
+ apk_path,
38
+ "--app",
39
+ app_id,
40
+ "--groups",
41
+ groups,
42
+ ]
43
+ else:
44
+ cmd = [
45
+ "firebase",
46
+ "appdistribution:distribute",
47
+ apk_path,
48
+ "--app",
49
+ app_id,
50
+ "--release-notes-file",
51
+ release_notes_file,
52
+ "--groups",
53
+ groups,
54
+ ]
55
+
56
+ try:
57
+ console.print(f"[dim]$ {' '.join(cmd)}[/dim]")
58
+ result = subprocess.run(cmd, capture_output=True, text=True, check=False)
59
+
60
+ if result.returncode != 0:
61
+ console.print(
62
+ f"[red]✗ Firebase distribution failed:[/red]\n{result.stderr[:500]}"
63
+ )
64
+ return False
65
+
66
+ console.print("[green]✓[/green] APK distributed to Firebase")
67
+ if "share-link" in result.stdout or "http" in result.stdout:
68
+ console.print(f"[dim]{result.stdout}[/dim]")
69
+ return True
70
+
71
+ except FileNotFoundError:
72
+ console.print(
73
+ "[red]✗ Firebase CLI not found. Install: npm install -g firebase-tools[/red]"
74
+ )
75
+ return False
76
+ except Exception as e:
77
+ console.print(f"[red]✗ Distribution failed: {e}[/red]")
78
+ return False
fship/main.py ADDED
@@ -0,0 +1,191 @@
1
+ import typer
2
+ import os
3
+ import json
4
+ import sys
5
+ from pathlib import Path
6
+ from rich.console import Console
7
+ from rich.prompt import Prompt
8
+ from fship.config import load_config, get_flavor, CONFIG_FILE, CONFIG_DIR, save_config, DEFAULT_CFG
9
+ from fship.runner import run_release
10
+
11
+ app = typer.Typer(
12
+ help="fship — Flutter Ship. Orchestrate release workflows to Firebase App Distribution.",
13
+ no_args_is_help=True,
14
+ )
15
+ console = Console()
16
+
17
+
18
+ @app.command()
19
+ def release(
20
+ flavor: str = typer.Argument(
21
+ ..., help="Flavor to release (qa, uat, prod, or custom)"
22
+ ),
23
+ version: str = typer.Option(
24
+ None,
25
+ "--version",
26
+ "-v",
27
+ help="Exact version to release (e.g. 1.2.4+46). Interactive if omitted.",
28
+ ),
29
+ bump: str = typer.Option(
30
+ None,
31
+ "--bump",
32
+ "-b",
33
+ help="Auto-increment version: patch, minor, or major. Resets build to 0.",
34
+ ),
35
+ skip_build: bool = typer.Option(
36
+ False, "--skip-build", help="Skip Flutter build step (for testing)"
37
+ ),
38
+ skip_distribute: bool = typer.Option(
39
+ False,
40
+ "--skip-distribute",
41
+ help="Skip Firebase distribution (for dry-run)",
42
+ ),
43
+ ):
44
+ """Release a flavor to Firebase App Distribution.
45
+
46
+ Examples:
47
+ fship release qa # Interactive version prompt
48
+ fship release qa --version 1.2.4+46 # Exact version
49
+ fship release qa --bump patch # Auto-bump patch version
50
+ fship release prod --bump minor # Bump minor, reset patch
51
+ """
52
+ try:
53
+ config = load_config()
54
+ flavor_config = get_flavor(config, flavor)
55
+ except (FileNotFoundError, ValueError) as e:
56
+ console.print(f"[red]Error: {e}[/red]")
57
+ raise typer.Exit(1)
58
+
59
+ if bump and version:
60
+ console.print("[red]Error: Cannot specify both --version and --bump[/red]")
61
+ raise typer.Exit(1)
62
+
63
+ success = run_release(
64
+ flavor,
65
+ flavor_config,
66
+ version=version,
67
+ bump=bump,
68
+ skip_build=skip_build,
69
+ skip_distribute=skip_distribute,
70
+ )
71
+
72
+ raise typer.Exit(0 if success else 1)
73
+
74
+
75
+ @app.command()
76
+ def configure():
77
+ """Interactive setup: configure flavors and app IDs."""
78
+ CONFIG_DIR.mkdir(exist_ok=True)
79
+
80
+ if CONFIG_FILE.exists():
81
+ overwrite = Prompt.ask(
82
+ f"{CONFIG_FILE} already exists. Overwrite?",
83
+ choices=["y", "n"],
84
+ default="n",
85
+ )
86
+ if overwrite == "n":
87
+ console.print("[dim]Using existing config.[/dim]")
88
+ return
89
+
90
+ console.rule("[bold cyan]fship Configure[/bold cyan]")
91
+ console.print("Configure your Flutter release flavors.\n")
92
+
93
+ cfg = {"flavors": {}}
94
+
95
+ while True:
96
+ flavor_name = Prompt.ask("Flavor name (e.g. qa, uat, prod, or done to finish)")
97
+
98
+ if flavor_name.lower() == "done":
99
+ break
100
+
101
+ console.print(f"\n[cyan]{flavor_name}:[/cyan]")
102
+ firebase_app_id_env = Prompt.ask(
103
+ " Firebase app ID env var",
104
+ default=f"FIREBASE_{flavor_name.upper()}_APP_ID",
105
+ )
106
+ entrypoint = Prompt.ask(
107
+ " Entry point", default=f"lib/main_{flavor_name}.dart"
108
+ )
109
+ apk_path = Prompt.ask(
110
+ " APK path",
111
+ default=f"build/app/outputs/flutter-apk/app-{flavor_name}-release.apk",
112
+ )
113
+ groups = Prompt.ask(" Firebase groups", default="testers")
114
+
115
+ cfg["flavors"][flavor_name] = {
116
+ "firebase_app_id_env": firebase_app_id_env,
117
+ "entrypoint": entrypoint,
118
+ "apk_path": apk_path,
119
+ "groups": groups,
120
+ }
121
+
122
+ console.print(f"[green]✓[/green] Added {flavor_name}\n")
123
+
124
+ if not cfg["flavors"]:
125
+ console.print("[red]No flavors configured. Aborting.[/red]")
126
+ raise typer.Exit(1)
127
+
128
+ save_config(cfg)
129
+ console.print(f"\n[green]✓ Config saved to {CONFIG_FILE}[/green]")
130
+
131
+
132
+ @app.command()
133
+ def validate():
134
+ """Validate config and required tools."""
135
+ try:
136
+ config = load_config()
137
+ console.print(f"[green]✓[/green] Config loaded from {CONFIG_FILE}\n")
138
+
139
+ console.print("[bold]Configured Flavors:[/bold]")
140
+ for flavor, cfg in config.flavors.items():
141
+ console.print(f" [cyan]{flavor}:[/cyan] {cfg.entrypoint}")
142
+ app_id = os.getenv(cfg.firebase_app_id_env)
143
+ if app_id:
144
+ console.print(f" [green]✓[/green] {cfg.firebase_app_id_env} set")
145
+ else:
146
+ console.print(
147
+ f" [yellow]⚠[/yellow] {cfg.firebase_app_id_env} not set"
148
+ )
149
+
150
+ except Exception as e:
151
+ console.print(f"[red]✗ Config validation failed: {e}[/red]")
152
+ raise typer.Exit(1)
153
+
154
+ tools = [
155
+ ("flutter", "flutter --version"),
156
+ ("firebase", "firebase --version"),
157
+ ("git", "git --version"),
158
+ ("git-chglog", "git-chglog --version"),
159
+ ]
160
+
161
+ console.print("\n[bold]Checking Tools:[/bold]")
162
+ import subprocess
163
+
164
+ for tool, cmd in tools:
165
+ try:
166
+ result = subprocess.run(
167
+ cmd.split(),
168
+ capture_output=True,
169
+ text=True,
170
+ timeout=5,
171
+ )
172
+ if result.returncode == 0:
173
+ console.print(f"[green]✓[/green] {tool}")
174
+ else:
175
+ console.print(f"[yellow]⚠[/yellow] {tool} (not working)")
176
+ except FileNotFoundError:
177
+ console.print(f"[red]✗[/red] {tool} (not found)")
178
+ except Exception as e:
179
+ console.print(f"[yellow]⚠[/yellow] {tool} ({e})")
180
+
181
+
182
+ @app.command()
183
+ def version():
184
+ """Show fship version."""
185
+ from fship import __version__
186
+
187
+ console.print(f"fship {__version__}")
188
+
189
+
190
+ if __name__ == "__main__":
191
+ app()
fship/runner.py ADDED
@@ -0,0 +1,132 @@
1
+ from pathlib import Path
2
+ from rich.console import Console
3
+ from rich.table import Table
4
+ from fship.config import Config, FlavorConfig
5
+ from fship.versioning import (
6
+ read_version,
7
+ write_version,
8
+ resolve_version,
9
+ )
10
+ from fship.changelog import (
11
+ generate_changelog,
12
+ generate_release_notes,
13
+ git_add_and_commit,
14
+ git_tag,
15
+ )
16
+ from fship.builder import build_apk
17
+ from fship.distributor import distribute_to_firebase
18
+
19
+ console = Console()
20
+
21
+
22
+ def run_release(
23
+ flavor: str,
24
+ flavor_config: FlavorConfig,
25
+ version: str = None,
26
+ bump: str = None,
27
+ skip_build: bool = False,
28
+ skip_distribute: bool = False,
29
+ ) -> bool:
30
+ """Orchestrate full release flow."""
31
+
32
+ console.rule(f"[bold cyan]fship release {flavor}[/bold cyan]")
33
+
34
+ try:
35
+ current_version = read_version()
36
+ new_version = resolve_version(current_version, version, bump)
37
+
38
+ steps = [
39
+ ("Update pubspec.yaml", lambda: update_pubspec(new_version)),
40
+ ("Generate CHANGELOG.md", lambda: generate_changelog()),
41
+ ("Generate release notes", lambda: generate_release_notes(flavor)),
42
+ ("Commit & tag", lambda: commit_and_tag(new_version, flavor)),
43
+ ]
44
+
45
+ if not skip_build:
46
+ steps.append(
47
+ ("Build APK", lambda: build_apk_step(flavor, flavor_config))
48
+ )
49
+
50
+ if not skip_distribute:
51
+ steps.append(
52
+ (
53
+ "Distribute to Firebase",
54
+ lambda: distribute_step(flavor_config),
55
+ )
56
+ )
57
+
58
+ for step_name, step_fn in steps:
59
+ console.print(f"\n[bold blue]→[/bold blue] {step_name}")
60
+ if not step_fn():
61
+ console.print(f"\n[bold red]Release stopped at: {step_name}[/bold red]")
62
+ return False
63
+
64
+ console.print(
65
+ f"\n[bold green]✓ Release {new_version} to {flavor} complete![/bold green]"
66
+ )
67
+ show_summary(new_version, flavor)
68
+ return True
69
+
70
+ except Exception as e:
71
+ console.print(f"\n[bold red]Error: {e}[/bold red]")
72
+ return False
73
+
74
+
75
+ def update_pubspec(version: str) -> bool:
76
+ """Update pubspec.yaml with new version."""
77
+ try:
78
+ current = read_version()
79
+ write_version(version)
80
+ console.print(f"[green]✓[/green] pubspec.yaml: {current} → {version}")
81
+ return True
82
+ except Exception as e:
83
+ console.print(f"[red]✗ Failed to update pubspec.yaml: {e}[/red]")
84
+ return False
85
+
86
+
87
+ def commit_and_tag(version: str, flavor: str) -> bool:
88
+ """Stage, commit, and tag the release."""
89
+ if not git_add_and_commit(version, flavor):
90
+ return False
91
+ if not git_tag(version, flavor):
92
+ return False
93
+ return True
94
+
95
+
96
+ def build_apk_step(flavor: str, flavor_config: FlavorConfig) -> tuple[bool, str]:
97
+ """Build APK and return (success, apk_path)."""
98
+ success, apk_path = build_apk(flavor, flavor_config.entrypoint)
99
+ if success:
100
+ console.print(f"[green]✓[/green] APK ready: {apk_path or 'built'}")
101
+ return success
102
+
103
+
104
+ def distribute_step(flavor_config: FlavorConfig) -> bool:
105
+ """Distribute APK to Firebase."""
106
+ apk_path = flavor_config.apk_path
107
+ if not Path(apk_path).exists():
108
+ console.print(f"[red]✗ APK path not found: {apk_path}[/red]")
109
+ return False
110
+
111
+ return distribute_to_firebase(
112
+ apk_path,
113
+ flavor_config.firebase_app_id_env,
114
+ flavor_config.groups,
115
+ )
116
+
117
+
118
+ def show_summary(version: str, flavor: str) -> None:
119
+ """Display summary table of what was done."""
120
+ table = Table(title=f"Release Summary: {version} → {flavor}")
121
+ table.add_column("Component", style="cyan")
122
+ table.add_column("Status", style="green")
123
+
124
+ table.add_row("Version Bumped", "pubspec.yaml updated")
125
+ table.add_row("Changelog", "CHANGELOG.md generated")
126
+ table.add_row("Release Notes", "release_note.txt generated")
127
+ table.add_row("Git Commit", f"chore: release {version}-{flavor}")
128
+ table.add_row("Git Tag", f"v{version}-{flavor}")
129
+ table.add_row("Build", "APK compiled")
130
+ table.add_row("Distribution", f"Firebase App Distribution")
131
+
132
+ console.print(table)
fship/versioning.py ADDED
@@ -0,0 +1,92 @@
1
+ from pathlib import Path
2
+ from ruamel.yaml import YAML
3
+ from rich.console import Console
4
+ from rich.prompt import Prompt
5
+
6
+ console = Console()
7
+
8
+
9
+ def read_version(pubspec_path: Path = None) -> str:
10
+ """Read version from pubspec.yaml. Format: X.Y.Z+B"""
11
+ path = pubspec_path or Path.cwd() / "pubspec.yaml"
12
+
13
+ if not path.exists():
14
+ raise FileNotFoundError(f"pubspec.yaml not found at {path}")
15
+
16
+ yaml = YAML()
17
+ yaml.preserve_quotes = True
18
+ yaml.default_flow_style = False
19
+
20
+ data = yaml.load(path)
21
+ return data.get("version", "0.0.0+0")
22
+
23
+
24
+ def write_version(new_version: str, pubspec_path: Path = None) -> None:
25
+ """Write version to pubspec.yaml, preserving formatting."""
26
+ path = pubspec_path or Path.cwd() / "pubspec.yaml"
27
+
28
+ if not path.exists():
29
+ raise FileNotFoundError(f"pubspec.yaml not found at {path}")
30
+
31
+ yaml = YAML()
32
+ yaml.preserve_quotes = True
33
+ yaml.default_flow_style = False
34
+
35
+ data = yaml.load(path)
36
+ data["version"] = new_version
37
+
38
+ with open(path, "w") as f:
39
+ yaml.dump(data, f)
40
+
41
+
42
+ def parse_version(version_str: str) -> tuple[int, int, int, int]:
43
+ """Parse 'X.Y.Z+B' into (major, minor, patch, build)."""
44
+ parts = version_str.split("+")
45
+ semantic = parts[0].split(".")
46
+ build = int(parts[1]) if len(parts) > 1 else 0
47
+
48
+ return (
49
+ int(semantic[0]),
50
+ int(semantic[1]) if len(semantic) > 1 else 0,
51
+ int(semantic[2]) if len(semantic) > 2 else 0,
52
+ build,
53
+ )
54
+
55
+
56
+ def format_version(major: int, minor: int, patch: int, build: int) -> str:
57
+ """Format (major, minor, patch, build) to 'X.Y.Z+B'."""
58
+ return f"{major}.{minor}.{patch}+{build}"
59
+
60
+
61
+ def bump_version(current: str, part: str) -> str:
62
+ """Bump version part: 'patch', 'minor', or 'major'. Resets build to 0."""
63
+ major, minor, patch, build = parse_version(current)
64
+
65
+ if part == "patch":
66
+ patch += 1
67
+ elif part == "minor":
68
+ minor += 1
69
+ patch = 0
70
+ elif part == "major":
71
+ major += 1
72
+ minor = 0
73
+ patch = 0
74
+ else:
75
+ raise ValueError(f"Unknown bump part: {part}")
76
+
77
+ return format_version(major, minor, patch, 0)
78
+
79
+
80
+ def resolve_version(current: str, version: str = None, bump: str = None) -> str:
81
+ """Resolve new version from flags or interactive prompt."""
82
+ if version:
83
+ return version
84
+
85
+ if bump:
86
+ new_version = bump_version(current, bump)
87
+ console.print(f"[cyan]{current}[/cyan] → [green]{new_version}[/green]")
88
+ return new_version
89
+
90
+ console.print(f"Current version: [cyan]{current}[/cyan]")
91
+ new_version = Prompt.ask("New version")
92
+ return new_version
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: fship
3
+ Version: 0.1.0
4
+ Summary: Flutter Ship — orchestrate release workflows to Firebase App Distribution
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: pyyaml>=6.0
7
+ Requires-Dist: rich>=13.7
8
+ Requires-Dist: ruamel-yaml>=0.18
9
+ Requires-Dist: typer[all]>=0.12
@@ -0,0 +1,12 @@
1
+ fship/__init__.py,sha256=BGg_72HFsJsn8MgBB3KgPh39wti6GdH6B-syW5AxYzQ,73
2
+ fship/builder.py,sha256=Tp5vCLaGgBZ8gpePvhYRrlQvDc7JH1tCWx9yNkIRVeg,1842
3
+ fship/changelog.py,sha256=35iEw-0ZbN6GBtDzrGdsmDF6WxZRJJ4R7SddAmq-TGw,3746
4
+ fship/config.py,sha256=roMvVK7q4rBwiUboH5qmWYhrcamBlV1k0OTU5S6xm90,4860
5
+ fship/distributor.py,sha256=y0LRYNka245kF9m7PV-8mLdzM_V8klMggmyMlA3FoIk,2322
6
+ fship/main.py,sha256=gZXySH5cEQ27lBrqFRApkcIKef91W0-7lPRZExqUlac,5765
7
+ fship/runner.py,sha256=VCqiXSu9cLrqe1v5Wx_MztTbcguwMZ44g5ACAtUE51Q,4179
8
+ fship/versioning.py,sha256=1l7gU2ac-8kmifKWvXtCjJqvqLsAur8GQk3Hzhn0Kts,2637
9
+ fship-0.1.0.dist-info/METADATA,sha256=O4kFKJd1JXPo_Bj7rAPvxJzjhRFKx-vEwBzIESLh-Qo,276
10
+ fship-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
11
+ fship-0.1.0.dist-info/entry_points.txt,sha256=5AtbeH2Pfmuc9Jnn3_3kICoL23dmO11CccerwufDBdc,41
12
+ fship-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ fship = fship.main:app