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.
- fship-0.1.0/.claude/settings.json +8 -0
- fship-0.1.0/.github/workflows/publish.yml +27 -0
- fship-0.1.0/CHANGELOG.md +23 -0
- fship-0.1.0/PKG-INFO +9 -0
- fship-0.1.0/README.md +176 -0
- fship-0.1.0/fship.yaml +21 -0
- fship-0.1.0/pyproject.toml +21 -0
- fship-0.1.0/src/fship/__init__.py +3 -0
- fship-0.1.0/src/fship/builder.py +65 -0
- fship-0.1.0/src/fship/changelog.py +119 -0
- fship-0.1.0/src/fship/config.py +151 -0
- fship-0.1.0/src/fship/distributor.py +78 -0
- fship-0.1.0/src/fship/main.py +191 -0
- fship-0.1.0/src/fship/runner.py +132 -0
- fship-0.1.0/src/fship/versioning.py +92 -0
|
@@ -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 }}
|
fship-0.1.0/CHANGELOG.md
ADDED
|
@@ -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,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
|