Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 48 additions & 12 deletions .github/workflows/build-ios.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,33 @@ on:
tag:
required: true
type: string
upload_to_testflight:
required: false
default: true
type: boolean
submit_for_review:
required: false
default: false
type: boolean
workflow_dispatch:
inputs:
upload_to_testflight:
description: 'Upload to TestFlight'
required: false
default: true
default: false
type: boolean
submit_for_review:
description: 'Submit this exact version for App Review (requires upload)'
required: false
default: false
type: boolean

permissions:
contents: write

jobs:
build:
name: Build & Submit to App Store
name: Build iOS
runs-on: macos-26
timeout-minutes: 60
steps:
Expand Down Expand Up @@ -167,7 +180,7 @@ jobs:
<key>uploadSymbols</key>
<true/>
<key>destination</key>
<string>upload</string>
<string>export</string>
</dict>
</plist>
EOF
Expand All @@ -194,12 +207,33 @@ jobs:
-authenticationKeyIssuerID "$ISSUER_ID" \
-allowProvisioningUpdates

echo "✅ IPA exported and uploaded to App Store Connect"
echo "✅ IPA exported"

- name: Save signed IPA
uses: actions/upload-artifact@v4
with:
name: emerkit-ios-${{ steps.version.outputs.version }}
path: build/ios/ipa/*.ipa
if-no-files-found: error
retention-days: 7

- name: Upload to TestFlight
id: upload
if: (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')) || inputs.upload_to_testflight == true
env:
KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
run: |
xcrun altool --upload-app --type ios \
--file "$(find build/ios/ipa -name '*.ipa' -print -quit)" \
--apiKey "$KEY_ID" --apiIssuer "$ISSUER_ID"

- name: Install Python dependencies for App Store Connect API
if: inputs.submit_for_review == true && steps.upload.outcome == 'success'
run: pip3 install --break-system-packages PyJWT requests

- name: Submit to App Review
if: inputs.submit_for_review == true && steps.upload.outcome == 'success'
env:
KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
Expand Down Expand Up @@ -286,22 +320,25 @@ jobs:
r.raise_for_status()
builds = r.json()["data"]
if not builds:
print("⚠️ Build not found yet in App Store Connect, skipping auto-submit")
sys.exit(0)
raise RuntimeError("Exact build not found in App Store Connect")

build_id = builds[0]["id"]
processing = builds[0]["attributes"].get("processingState", "unknown")
if processing != "VALID":
raise RuntimeError("Build is not VALID; refusing App Review submission")
print(f"Build ID: {build_id}, processing: {processing}")

# Get or create the app store version
r = requests.get(
f"{base}/apps/{app_id}/appStoreVersions?filter[appStoreState]=READY_FOR_SALE,PREPARE_FOR_SUBMISSION,WAITING_FOR_REVIEW,IN_REVIEW,DEVELOPER_REJECTED&limit=1",
f"{base}/apps/{app_id}/appStoreVersions?filter[versionString]={version}&filter[platform]=IOS&limit=10",
headers=headers
)
r.raise_for_status()
versions = r.json()["data"]
versions = [v for v in r.json()["data"] if v["attributes"]["versionString"] == version]
if versions and versions[0]["attributes"]["appStoreState"] not in ("PREPARE_FOR_SUBMISSION", "DEVELOPER_REJECTED", "REJECTED", "METADATA_REJECTED"):
raise RuntimeError("The exact version is not editable; refusing to alter another version")

if versions and versions[0]["attributes"]["appStoreState"] == "PREPARE_FOR_SUBMISSION":
if versions:
version_id = versions[0]["id"]
print(f"Using existing version: {version_id}")
else:
Expand All @@ -324,7 +361,7 @@ jobs:
if r.ok:
print(f"✅ Build {build_id} attached to version {version_id}")
else:
print(f"⚠️ Could not attach build: {r.status_code} {r.text}")
r.raise_for_status()

# Submit for review
r = requests.post(f"{base}/appStoreVersionSubmissions", headers=headers, json={
Expand All @@ -338,8 +375,7 @@ jobs:
if r.ok:
print(f"✅ Submitted version {version} for App Review!")
else:
print(f"⚠️ Could not submit for review: {r.status_code} {r.text}")
print("You may need to submit manually from App Store Connect")
r.raise_for_status()
PYEOF

- name: Cleanup keychain
Expand Down
155 changes: 155 additions & 0 deletions .github/workflows/capture-ios-store-assets.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
name: Capture iOS Store Assets

on:
workflow_dispatch:
inputs:
device:
description: 'Device class to capture'
type: choice
required: true
default: all
options: [all, iphone, ipad]

permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
capture:
name: Capture ${{ matrix.device }}
runs-on: macos-26
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
device: ${{ fromJSON(inputs.device == 'ipad' && '["ipad"]' || inputs.device == 'iphone' && '["iphone"]' || '["iphone", "ipad"]') }}
steps:
- uses: actions/checkout@v6
- uses: subosito/flutter-action@v2
with:
flutter-version: '3.41.5'
cache: true
- name: Install dependencies
run: flutter pub get
- name: Select and boot Simulator
id: simulator
env:
DEVICE_CLASS: ${{ matrix.device }}
run: |
xcrun simctl list devices available -j > "$RUNNER_TEMP/devices.json"
python3 <<'PY'
import json, os, re
from pathlib import Path
devices = json.loads(Path(os.environ['RUNNER_TEMP'], 'devices.json').read_text())['devices']
pattern = r'iPhone .*Pro Max' if os.environ['DEVICE_CLASS'] == 'iphone' else r'iPad Pro.*13-inch'
candidates = [(runtime, d) for runtime, group in devices.items() for d in group
if d.get('isAvailable') and re.search(pattern, d['name'])]
if not candidates:
raise SystemExit(f'No supported {os.environ["DEVICE_CLASS"]} Simulator found')
runtime, device = sorted(candidates, key=lambda pair: (pair[0], pair[1]['name']), reverse=True)[0]
with open(os.environ['GITHUB_OUTPUT'], 'a') as output:
output.write(f'udid={device["udid"]}\n')
Path('store-captures').mkdir(exist_ok=True)
metadata = {'device': device['name'], 'runtime': runtime, 'udid': device['udid'],
'commit': os.environ['GITHUB_SHA'], 'version': json.loads(Path('version.json').read_text())['version'],
'source': 'iOS app running in Apple Simulator; native simctl capture'}
Path('store-captures/08-capture-metadata.json').write_text(json.dumps(metadata, indent=2))
PY
UDID=$(python3 -c 'import json; print(json.load(open("store-captures/08-capture-metadata.json"))["udid"])')
xcrun simctl boot "$UDID"
xcrun simctl bootstatus "$UDID" -b
xcrun simctl ui "$UDID" appearance light
xcrun simctl status_bar "$UDID" override --time '9:41' --dataNetwork wifi --wifiMode active --wifiBars 3 --batteryState charged --batteryLevel 100
- name: Build Simulator app
timeout-minutes: 15
run: flutter build ios --simulator --debug --no-pub --target test_driver/store_app.dart
- name: Run app and capture six screens
timeout-minutes: 5
env:
CAPTURE_UDID: ${{ steps.simulator.outputs.udid }}
CAPTURE_OUTPUT: ${{ github.workspace }}/store-captures
CAPTURE_VIDEO: ${{ matrix.device == 'iphone' }}
run: |
xcrun simctl install "$CAPTURE_UDID" build/ios/iphonesimulator/Runner.app
# Connect to a known loopback port instead of waiting for Simulator logs.
xcrun simctl launch --terminate-running-process "$CAPTURE_UDID" online.globalemergency.emerkit \
--vm-service-port=54321 --disable-service-auth-codes --disable-vm-service-publication
export VM_SERVICE_URL='http://127.0.0.1:54321/'
python3 <<'PY'
import json, time, urllib.request, urllib.error
for attempt in range(60):
try:
with urllib.request.urlopen('http://127.0.0.1:54321/getVM', timeout=2) as response:
assert json.load(response)['result']['type'] == 'VM'
print('Simulator VM Service ready')
break
except (OSError, ValueError, KeyError):
time.sleep(1)
else:
raise SystemExit('Simulator VM Service did not start within 180 seconds')
PY
dart run test_driver/store_capture.dart
- name: Export iPhone App Preview
if: matrix.device == 'iphone'
run: |
brew install ffmpeg
ffmpeg -y -i store-captures/07-preview-raw.mov \
-f lavfi -i anullsrc=channel_layout=stereo:sample_rate=48000 \
-map 0:v:0 -map 1:a:0 -t 20 -r 30 \
-vf 'scale=886:1920:force_original_aspect_ratio=decrease,pad=886:1920:(ow-iw)/2:(oh-ih)/2,setsar=1' \
-c:v libx264 -profile:v high -level:v 4.0 -pix_fmt yuv420p \
-b:v 10M -maxrate 12M -bufsize 20M \
-c:a aac -b:a 256k -ac 2 -ar 48000 -movflags +faststart \
store-captures/07-app-preview-iphone.mp4
- name: Verify captured images and video
env:
DEVICE_CLASS: ${{ matrix.device }}
run: |
python3 <<'PY'
import json, os, struct, subprocess
from pathlib import Path
folder = Path('store-captures')
expected = ['01-home', '02-glasgow', '03-rcp', '04-triage', '05-ecg', '06-home-scroll']
dimensions = set()
for name in expected:
data = (folder / f'{name}.png').read_bytes()
assert data[:8] == b'\x89PNG\r\n\x1a\n' and len(data) > 10000, name
dimensions.add(struct.unpack('>II', data[16:24]))
assert len(dimensions) == 1, dimensions
width, height = dimensions.pop()
assert width >= 1242 and height > width, (width, height)
metadata_path = folder / '08-capture-metadata.json'
metadata = json.loads(metadata_path.read_text())
metadata['screenshotSize'] = [width, height]
if os.environ['DEVICE_CLASS'] == 'iphone':
video = folder / '07-app-preview-iphone.mp4'
probe = json.loads(subprocess.check_output(['ffprobe', '-v', 'error', '-show_streams', '-show_format', '-of', 'json', str(video)]))
stream = next(s for s in probe['streams'] if s['codec_type'] == 'video')
audio = next(s for s in probe['streams'] if s['codec_type'] == 'audio')
assert stream['codec_name'] == 'h264' and [stream['width'], stream['height']] == [886, 1920]
assert stream['r_frame_rate'] == '30/1'
assert audio['codec_name'] == 'aac' and audio['channels'] == 2
assert 19.9 <= float(probe['format']['duration']) <= 20.1
assert video.stat().st_size < 500_000_000
metadata['appPreview'] = {'durationSeconds': 20, 'size': [886, 1920], 'fps': 30}
metadata_path.write_text(json.dumps(metadata, indent=2))
PY
- name: Capture diagnostics after failure
if: failure() && steps.simulator.outputs.udid != ''
run: |
xcrun simctl io '${{ steps.simulator.outputs.udid }}' screenshot store-captures/99-failure.png || true
xcrun simctl spawn '${{ steps.simulator.outputs.udid }}' log show --last 3m --style compact --predicate 'process == "Runner"' > store-captures/99-runner.log || true
- name: Save captures and original footage
if: always()
uses: actions/upload-artifact@v4
with:
name: ios-store-${{ matrix.device }}-${{ github.sha }}
path: store-captures/
if-no-files-found: error
retention-days: 14
- name: Shutdown Simulator
if: always() && steps.simulator.outputs.udid != ''
run: xcrun simctl shutdown '${{ steps.simulator.outputs.udid }}'
40 changes: 40 additions & 0 deletions .github/workflows/publish-store-listing.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: Publish EmerKit store listing

on:
workflow_dispatch:

permissions:
contents: read

concurrency:
group: emerkit-google-store-listing
cancel-in-progress: false

jobs:
publish:
# Publishing is an explicit operation, separate from code review and iOS capture.
if: github.repository == 'GlobalEmergency/EmerKit'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.sha }}
- uses: actions/setup-python@v6
with:
python-version: '3.12'
- name: Install Google Play client
run: python -m pip install google-api-python-client google-auth
- name: Upload authorized metadata and screenshots
env:
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
run: python store-assets/2026-10/16-publish-google.py
- name: Preserve before state and result
if: always()
uses: actions/upload-artifact@v4
with:
name: emerkit-google-store-result
path: |
store-assets/2026-10/18-google-*.json
store-assets/2026-10/19-google-upload-result.json
if-no-files-found: ignore
2 changes: 1 addition & 1 deletion assets/version.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"version": "0.1.17"}
{"version": "0.1.18"}
3 changes: 1 addition & 2 deletions ios/Runner.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -186,10 +186,9 @@
};
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
compatibilityVersion = "Xcode 9.3";
developmentRegion = en;
developmentRegion = es;
hasScannedForEncodings = 0;
knownRegions = (
en,
es,
Base,
);
Expand Down
6 changes: 5 additions & 1 deletion ios/Runner/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<string>es</string>
<key>CFBundleLocalizations</key>
<array>
<string>es</string>
</array>
<key>CFBundleDisplayName</key>
<string>EmerKit</string>
<key>CFBundleExecutable</key>
Expand Down
4 changes: 3 additions & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name: emerkit
description: EmerKit - Herramientas clinicas para profesionales de emergencias
publish_to: 'none'
version: 0.1.17+117
version: 0.1.18+118

environment:
sdk: ^3.5.0
Expand All @@ -21,6 +21,8 @@ dependency_overrides:
path_provider_foundation: 2.4.0

dev_dependencies:
flutter_driver:
sdk: flutter
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0
Expand Down
Loading
Loading