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 +3 -0
- fship/builder.py +65 -0
- fship/changelog.py +119 -0
- fship/config.py +151 -0
- fship/distributor.py +78 -0
- fship/main.py +191 -0
- fship/runner.py +132 -0
- fship/versioning.py +92 -0
- fship-0.1.0.dist-info/METADATA +9 -0
- fship-0.1.0.dist-info/RECORD +12 -0
- fship-0.1.0.dist-info/WHEEL +4 -0
- fship-0.1.0.dist-info/entry_points.txt +2 -0
fship/__init__.py
ADDED
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,,
|