fship 0.1.0__tar.gz

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.
@@ -0,0 +1,8 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(pipx install *)",
5
+ "Bash(~/.local/bin/pyproject-build)"
6
+ ]
7
+ }
8
+ }
@@ -0,0 +1,27 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ jobs:
8
+ publish:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v4
12
+
13
+ - uses: actions/setup-python@v5
14
+ with:
15
+ python-version: '3.11'
16
+
17
+ - name: Install build tools
18
+ run: python -m pip install build twine
19
+
20
+ - name: Build distribution
21
+ run: python -m build
22
+
23
+ - name: Upload to PyPI
24
+ run: twine upload dist/* --non-interactive
25
+ env:
26
+ TWINE_USERNAME: __token__
27
+ TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
@@ -0,0 +1,23 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.0] - 2026-05-08
9
+
10
+ ### Added
11
+
12
+ - Initial release of fship
13
+ - CLI for orchestrating Flutter release workflows to Firebase App Distribution
14
+ - Interactive version bumping (interactive, auto-increment, or exact version)
15
+ - CHANGELOG generation via git-chglog
16
+ - Release notes generation from git log
17
+ - Git tagging and commit management
18
+ - APK building for Flutter flavors
19
+ - Firebase App Distribution integration
20
+ - `fship init` command for project setup
21
+ - `fship validate` command for configuration validation
22
+ - `fship release` command with flavor support (e.g., `fship release qa`)
23
+ - PyPI publish workflow
fship-0.1.0/PKG-INFO ADDED
@@ -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
fship-0.1.0/README.md ADDED
@@ -0,0 +1,176 @@
1
+ # fship — Flutter Ship
2
+
3
+ Memorable, easy CLI for orchestrating Flutter release workflows to Firebase App Distribution.
4
+
5
+ ```bash
6
+ fship release qa # Interactive version bump + full release
7
+ fship release qa --version 1.2.4+46 # Exact version
8
+ fship release qa --bump patch # Auto-increment patch
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ 1. **Install**: `pip install fship`
14
+ 2. **Initialize**: `cd /path/to/flutter/project && fship init`
15
+ 3. **Configure**: Edit `fship.yaml` with your Firebase app IDs
16
+ 4. **Validate**: `fship validate`
17
+ 5. **Release**: `fship release qa` (or your flavor name)
18
+
19
+ ## What It Does (Full Flow)
20
+
21
+ 1. **Bump version** in `pubspec.yaml` (interactive or auto)
22
+ 2. **Generate CHANGELOG.md** via `git-chglog`
23
+ 3. **Generate release_note.txt** from git log since last tag
24
+ 4. **Git commit** version changes
25
+ 5. **Git tag** the release
26
+ 6. **Build APK** for the flavor
27
+ 7. **Distribute to Firebase App Distribution**
28
+
29
+ ## Installation
30
+
31
+ **Via PyPI (Recommended)**
32
+ ```bash
33
+ pip install fship
34
+ ```
35
+
36
+ **From Source (Development)**
37
+ ```bash
38
+ git clone https://github.com/MrShakila/F-ship.git
39
+ cd F-ship
40
+ pip install -e .
41
+ ```
42
+
43
+ ## Setup (One Time)
44
+
45
+ ```bash
46
+ cd /path/to/your/flutter/project
47
+
48
+ # Copy default config
49
+ fship init
50
+
51
+ # Edit fship.yaml — set your Firebase app IDs, entry points, APK paths
52
+ vi fship.yaml
53
+
54
+ # Validate setup
55
+ fship validate
56
+ ```
57
+
58
+ ### fship.yaml Example
59
+
60
+ ```yaml
61
+ flavors:
62
+ qa:
63
+ firebase_app_id_env: APPIDANDROID_QA
64
+ entrypoint: lib/main_qa.dart
65
+ apk_path: build/app/outputs/flutter-apk/app-qa-release.apk
66
+ groups: testers
67
+
68
+ prod:
69
+ firebase_app_id_env: APPIDANDROID_PROD
70
+ entrypoint: lib/main_prod.dart
71
+ apk_path: build/app/outputs/flutter-apk/app-prod-release.apk
72
+ groups: testers
73
+ ```
74
+
75
+ ## Usage
76
+
77
+ ### Interactive Version Bump
78
+
79
+ ```bash
80
+ fship release qa
81
+ # Current version: 1.2.3+45
82
+ # New version: 1.2.4+46
83
+ # [shows full release workflow with progress]
84
+ ```
85
+
86
+ ### Exact Version (Non-Interactive)
87
+
88
+ ```bash
89
+ fship release qa --version 1.2.4+46
90
+ ```
91
+
92
+ ### Auto-Increment
93
+
94
+ ```bash
95
+ fship release qa --bump patch # 1.2.3+45 → 1.2.4+0
96
+ fship release qa --bump minor # 1.2.3+45 → 1.3.0+0
97
+ fship release qa --bump major # 1.2.3+45 → 2.0.0+0
98
+ ```
99
+
100
+ ### Dry Run (Skip Build & Distribution)
101
+
102
+ ```bash
103
+ fship release qa --skip-build --skip-distribute
104
+ # Only bumps version, generates changelog, commits, tags
105
+ ```
106
+
107
+ ## Prerequisites
108
+
109
+ - Python 3.11+
110
+ - Flutter SDK
111
+ - Firebase CLI: `npm install -g firebase-tools`
112
+ - git-chglog: `brew install git-chglog` (macOS) or `npm install -g git-chglog`
113
+
114
+ ## Environment Setup
115
+
116
+ **First run creates `.env.dev` template:**
117
+ ```bash
118
+ fship release qa
119
+ # Creates .env.dev with placeholders, prompts you to fill in Android app IDs
120
+ ```
121
+
122
+ Edit `.env.dev` with your Firebase Android app IDs:
123
+ ```bash
124
+ # .env.dev (add to .gitignore)
125
+ APPIDANDROID_QA=1:123456:android:abcdef...
126
+ APPIDANDROID_UAT=1:345678:android:ghijkl...
127
+ APPIDANDROID_PROD=1:789012:android:mnopqr...
128
+ ```
129
+
130
+ Get app IDs from Firebase Console > Project Settings > Your apps (Android).
131
+
132
+ fship automatically loads from `.env.dev` when you run `fship release`.
133
+
134
+ ## Commands
135
+
136
+ ```bash
137
+ fship release <flavor> [--version X.Y.Z+B] [--bump patch|minor|major] [--skip-build] [--skip-distribute]
138
+ fship init # Copy default fship.yaml
139
+ fship validate # Check tools and config
140
+ fship version # Show fship version
141
+ fship --help # Full help
142
+ ```
143
+
144
+ ## Troubleshooting
145
+
146
+ **"fship.yaml not found"**
147
+ ```bash
148
+ fship init
149
+ vi fship.yaml # customize
150
+ ```
151
+
152
+ **"Firebase CLI not found"**
153
+ ```bash
154
+ npm install -g firebase-tools
155
+ firebase login
156
+ ```
157
+
158
+ **"git-chglog not found"**
159
+ ```bash
160
+ brew install git-chglog
161
+ # or
162
+ npm install -g git-chglog
163
+ ```
164
+
165
+ **"Commits/tags not created, but version was bumped"**
166
+ - Ensure you're in a git repo and have uncommitted changes allowed
167
+ - Check `git status`
168
+
169
+ ## Development
170
+
171
+ ```bash
172
+ git clone https://github.com/MrShakila/F-ship.git
173
+ cd F-ship
174
+ pip install -e .
175
+ fship --help
176
+ ```
fship-0.1.0/fship.yaml ADDED
@@ -0,0 +1,21 @@
1
+ # fship — Flutter Ship release configuration
2
+ # Copy this to your Flutter project root and customize app IDs, paths, and groups
3
+
4
+ flavors:
5
+ qa:
6
+ firebase_app_id_env: FIREBASE_QA_APP_ID
7
+ entrypoint: lib/main_qa.dart
8
+ apk_path: build/app/outputs/flutter-apk/app-qa-release.apk
9
+ groups: testers
10
+
11
+ uat:
12
+ firebase_app_id_env: FIREBASE_UAT_APP_ID
13
+ entrypoint: lib/main_uat.dart
14
+ apk_path: build/app/outputs/flutter-apk/app-uat-release.apk
15
+ groups: testers
16
+
17
+ prod:
18
+ firebase_app_id_env: FIREBASE_PROD_APP_ID
19
+ entrypoint: lib/main_prod.dart
20
+ apk_path: build/app/outputs/flutter-apk/app-prod-release.apk
21
+ groups: testers
@@ -0,0 +1,21 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "fship"
7
+ version = "0.1.0"
8
+ description = "Flutter Ship — orchestrate release workflows to Firebase App Distribution"
9
+ requires-python = ">=3.11"
10
+ dependencies = [
11
+ "typer[all]>=0.12",
12
+ "rich>=13.7",
13
+ "pyyaml>=6.0",
14
+ "ruamel.yaml>=0.18",
15
+ ]
16
+
17
+ [project.scripts]
18
+ fship = "fship.main:app"
19
+
20
+ [tool.hatch.build.targets.wheel]
21
+ packages = ["src/fship"]
@@ -0,0 +1,3 @@
1
+ """Flutter Ship — release orchestration CLI."""
2
+
3
+ __version__ = "0.1.0"
@@ -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
@@ -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
@@ -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]
@@ -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
@@ -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()
@@ -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)
@@ -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