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
416 changes: 416 additions & 0 deletions .github/scripts/app-store-submit.mjs

Large diffs are not rendered by default.

183 changes: 32 additions & 151 deletions .github/workflows/build-ios.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ on:
default: false
type: boolean
submit_for_review:
description: 'Submit this exact version for App Review (requires upload)'
description: 'Submit this exact version for App Review (requires "Upload to TestFlight")'
required: false
default: false
type: boolean
Expand All @@ -41,6 +41,10 @@ jobs:
name: Build iOS
runs-on: macos-26
timeout-minutes: 60
outputs:
version: ${{ steps.version.outputs.version }}
build_number: ${{ steps.version.outputs.build_number }}
uploaded: ${{ steps.upload.outcome == 'success' }}
steps:
- uses: actions/checkout@v6
with:
Expand Down Expand Up @@ -228,159 +232,36 @@ jobs:
--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 }}
API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }}
VERSION: ${{ steps.version.outputs.version }}
BUILD: ${{ steps.version.outputs.build_number }}
run: |
# Install API key for altool/notarytool
mkdir -p ~/private_keys
echo "$API_KEY" > ~/private_keys/AuthKey_${KEY_ID}.p8

VERSION="${{ steps.version.outputs.version }}"
BUILD="${{ steps.version.outputs.build_number }}"

echo "⏳ Waiting for build $VERSION ($BUILD) to finish processing..."
# App Store Connect needs time to process the uploaded build
for i in $(seq 1 30); do
sleep 30
# Use xcrun to check build processing status
BUILD_STATUS=$(xcrun altool --list-builds \
--apiKey "$KEY_ID" \
--apiIssuer "$ISSUER_ID" \
--output-format json 2>/dev/null | \
python3 -c "
import json, sys
try:
data = json.load(sys.stdin)
builds = data.get('builds', [])
for b in builds:
if b.get('buildVersion') == '$BUILD':
print(b.get('processingState', 'unknown'))
sys.exit(0)
print('not_found')
except:
print('error')
" 2>/dev/null || echo "error")

echo " Attempt $i/30: status=$BUILD_STATUS"
if [ "$BUILD_STATUS" = "VALID" ] || [ "$BUILD_STATUS" = "valid" ]; then
echo "✅ Build processing complete"
break
fi
if [ "$i" -eq 30 ]; then
echo "⚠️ Build still processing after 15 minutes, submitting anyway..."
fi
done

# Submit for review using App Store Connect API via Python
python3 << 'PYEOF'
import json, time, jwt, requests, os, sys

KEY_ID = os.environ["KEY_ID"]
ISSUER_ID = os.environ["ISSUER_ID"]
key_path = os.path.expanduser(f"~/private_keys/AuthKey_{KEY_ID}.p8")

with open(key_path, "r") as f:
private_key = f.read()

# Generate JWT token
now = int(time.time())
payload = {
"iss": ISSUER_ID,
"iat": now,
"exp": now + 1200,
"aud": "appstoreconnect-v1"
}
token = jwt.encode(payload, private_key, algorithm="ES256", headers={"kid": KEY_ID})
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
base = "https://api.appstoreconnect.apple.com/v1"

# Find the app
r = requests.get(f"{base}/apps?filter[bundleId]=online.globalemergency.emerkit", headers=headers)
r.raise_for_status()
app_id = r.json()["data"][0]["id"]
print(f"App ID: {app_id}")

# Get the latest build (just uploaded)
version = os.environ.get("VERSION", "")
build_num = os.environ.get("BUILD", "")
r = requests.get(
f"{base}/builds?filter[app]={app_id}&filter[version]={build_num}&sort=-uploadedDate&limit=1",
headers=headers
)
r.raise_for_status()
builds = r.json()["data"]
if not builds:
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[versionString]={version}&filter[platform]=IOS&limit=10",
headers=headers
)
r.raise_for_status()
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:
version_id = versions[0]["id"]
print(f"Using existing version: {version_id}")
else:
# Create new version
r = requests.post(f"{base}/appStoreVersions", headers=headers, json={
"data": {
"type": "appStoreVersions",
"attributes": {"platform": "IOS", "versionString": version},
"relationships": {"app": {"data": {"type": "apps", "id": app_id}}}
}
})
r.raise_for_status()
version_id = r.json()["data"]["id"]
print(f"Created version: {version_id}")

# Attach build to version
r = requests.patch(f"{base}/appStoreVersions/{version_id}/relationships/build", headers=headers, json={
"data": {"type": "builds", "id": build_id}
})
if r.ok:
print(f"✅ Build {build_id} attached to version {version_id}")
else:
r.raise_for_status()

# Submit for review
r = requests.post(f"{base}/appStoreVersionSubmissions", headers=headers, json={
"data": {
"type": "appStoreVersionSubmissions",
"relationships": {
"appStoreVersion": {"data": {"type": "appStoreVersions", "id": version_id}}
}
}
})
if r.ok:
print(f"✅ Submitted version {version} for App Review!")
else:
r.raise_for_status()
PYEOF

- name: Cleanup keychain
if: always()
run: |
if [ -n "$KEYCHAIN_PATH" ] && [ -f "$KEYCHAIN_PATH" ]; then
security delete-keychain "$KEYCHAIN_PATH" || true
fi

# Separate Linux job: waiting for Apple's processing would burn macOS minutes.
submit:
name: Submit to App Review
needs: build
if: inputs.submit_for_review == true && needs.build.outputs.uploaded == 'true'
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v6
with:
ref: ${{ inputs.tag || github.ref }}

- uses: actions/setup-node@v5
with:
node-version: 24

- name: Submit build for App Review
env:
APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }}
BUNDLE_ID: online.globalemergency.emerkit
APP_VERSION: ${{ needs.build.outputs.version }}
BUILD_NUMBER: ${{ needs.build.outputs.build_number }}
WHATSNEW_DIR: store-assets/whatsnew
run: node .github/scripts/app-store-submit.mjs
44 changes: 44 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,3 +93,47 @@ jobs:
- name: Build debug APK
if: github.event_name == 'push'
run: flutter build apk --debug

analytics-plugin:
name: Analytics plugin (PHP)
runs-on: ubuntu-latest
services:
# Disposable database for store-test.php (it only touches test_emerkit_* tables).
mariadb:
image: mariadb:11.8
env:
MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: '1'
MARIADB_DATABASE: analytics
ports: ['3306:3306']
options: >-
--health-cmd="healthcheck.sh --connect --innodb_initialized"
--health-interval=5s --health-timeout=5s --health-retries=20
defaults:
run:
working-directory: backend/emerkit-analytics
steps:
- uses: actions/checkout@v6

# The plugin declares PHP >= 7.4; production runs 8.4.
- uses: shivammathur/setup-php@v2
with:
php-version: '7.4'
coverage: none
- name: Lint plugin on PHP 7.4
run: find . -name '*.php' -not -path './tests/*' -print0 | xargs -0 -n1 php -l

- uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
extensions: pdo_mysql
coverage: none
- name: Lint on PHP 8.4
run: find . -name '*.php' -print0 | xargs -0 -n1 php -l
- name: Run tests
env:
DB_HOST: 127.0.0.1
run: |
for t in contract wp host-alias store; do
echo "== $t"
php "tests/$t-test.php"
done
71 changes: 71 additions & 0 deletions .github/workflows/deploy-analytics.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
name: Deploy Analytics Plugin (srv07)

# Publishes backend/emerkit-analytics to the GlobalEmergency WordPress on srv07
# (served at emerkit.globalemergency.online) on every push to main that touches it.
# The plugin is already active there: copying the files is the whole update
# (see backend/emerkit-analytics/01-operations.md). CI lints and tests it on the PR.
on:
push:
branches: [main]
paths:
- 'backend/emerkit-analytics/**'
- '.github/workflows/deploy-analytics.yml'
workflow_dispatch:

concurrency:
group: deploy-emerkit-analytics-srv07
cancel-in-progress: false

permissions:
contents: read

jobs:
deploy:
name: Deploy to srv07
runs-on: ubuntu-latest
timeout-minutes: 10
env:
WP_PATH: /var/www/vhosts/globalemergency.online/httpdocs
TARGET: ${{ secrets.SRV07_USER }}@${{ secrets.SRV07_HOST }}
# srv07 public host key, pinned instead of trusting the first connection
# (update it here if the server key is ever rotated).
SRV07_HOST_KEY: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBcznWLBEBj0x0Y/w6mhRzYzCsunu5jUwMC8Pd/aQd10
steps:
- uses: actions/checkout@v6

- name: Configure SSH
env:
SRV07_HOST: ${{ secrets.SRV07_HOST }}
SRV07_DEPLOY_KEY: ${{ secrets.SRV07_DEPLOY_KEY }}
run: |
install -d -m 700 ~/.ssh
install -m 600 /dev/null ~/.ssh/deploy_key
printf '%s\n' "$SRV07_DEPLOY_KEY" > ~/.ssh/deploy_key
echo "$SRV07_HOST $SRV07_HOST_KEY" > ~/.ssh/known_hosts

- name: Sync plugin files
# --delay-updates swaps every file in at the end, so PHP never loads a
# half-copied plugin. -p + --chmod also reset the world-writable bits of
# the manual copy. --delete removes server-side files not in the repo.
run: |
rsync -rlpz --delete --delay-updates --chmod=D755,F644 \
--exclude=tests/ --exclude='*.md' \
-e "ssh -i $HOME/.ssh/deploy_key -o IdentitiesOnly=yes" \
backend/emerkit-analytics/ \
"$TARGET:$WP_PATH/wp-content/plugins/emerkit-analytics/"

- name: Check plugin is active
run: |
ssh -i ~/.ssh/deploy_key -o IdentitiesOnly=yes "$TARGET" \
"/opt/plesk/php/8.4/bin/php -d memory_limit=512M /usr/local/bin/wp --path=$WP_PATH plugin is-active emerkit-analytics"

- name: Smoke test public contract
run: |
# Validation rejects an empty payload before touching the tables: 400 proves the route is live.
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/json' \
-d '{}' https://emerkit.globalemergency.online/wp-json/emerkit/v1/events)
[ "$code" = 400 ] || { echo "::error::events endpoint returned $code, expected 400"; exit 1; }
# HEAD is not counted as a store click.
code=$(curl -s -o /dev/null -w '%{http_code}' -I https://emerkit.globalemergency.online/emerkit/descargar/android/)
[ "$code" = 302 ] || { echo "::error::store redirect returned $code, expected 302"; exit 1; }
echo "Plugin deployed and responding"
1 change: 1 addition & 0 deletions .github/workflows/ensure-tags.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,4 +87,5 @@ jobs:
with:
version: ${{ needs.tag-and-release.outputs.version }}
tag: ${{ needs.tag-and-release.outputs.tag }}
submit_for_review: true
secrets: inherit
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,11 @@ All tools are registered in `lib/features/home/presentation/tool_registry.dart`
### Release (on version.json change)
- `ensure-tags.yml` → creates tag + GitHub Release
- Calls `build-android.yml` → signed APK/AAB → Google Play (internal track)
- Calls `build-ios.yml` → IPA → TestFlight
- Calls `build-ios.yml` → IPA → TestFlight → App Review (`.github/scripts/app-store-submit.mjs`; "What's New" from `store-assets/whatsnew/<locale>.txt`)

### Analytics plugin (`backend/emerkit-analytics`)
- PR/push: `ci.yml` job `analytics-plugin` lints on PHP 7.4/8.4 and runs its tests (MariaDB service)
- Push to main touching it: `deploy-analytics.yml` rsyncs it to srv07 and smoke-tests it

## Commit Convention

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ La version se gestiona desde `version.json`. Al hacer push a main con cambios en

1. Crea un tag `vX.Y.Z`
2. Crea un GitHub Release
3. Dispara los builds de Android (Google Play) e iOS (TestFlight)
3. Dispara los builds de Android (Google Play) e iOS (TestFlight + envío a revisión de App Store)

```bash
# Propagar version a pubspec.yaml y assets
Expand Down
4 changes: 3 additions & 1 deletion backend/emerkit-analytics/01-operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ Plugin propio para el WordPress de GlobalEmergency. Base real del colector: `htt

## Instalación y comprobación

**Despliegue continuo:** cada push a `main` que toque `backend/emerkit-analytics/` (o al lanzarlo a mano con *Run workflow*) ejecuta `.github/workflows/deploy-analytics.yml`: rsync (sin `tests/` ni `.md`, permisos 644/755) a `/var/www/vhosts/globalemergency.online/httpdocs/wp-content/plugins/emerkit-analytics/` con la clave de despliegue del usuario del vhost (secretos `SRV07_*`, copia en el vault: *Hosting → srv07 deploy key EmerKit Analytics*), comprobación de que el plugin sigue activo y smoke test de `/events` (400 con payload vacío) y de la redirección Android (302 por HEAD, que no cuenta clic). `--delete` borra del servidor lo que no esté en el repo: no hacer hotfixes a mano allí. La CI de la PR hace lint en PHP 7.4/8.4 y ejecuta las cuatro pruebas, incluida la de persistencia con MariaDB desechable. El esquema se actualiza solo: en `plugins_loaded`, si `emerkit_analytics_schema` no coincide con `Plugin::VERSION`, se ejecuta `install()`. El procedimiento siguiente queda para la primera instalación o una reparación manual.

Copiar esta carpeta como `wp-content/plugins/emerkit-analytics/` (excluir `tests/` en producción si se desea). El plugin no se activa al copiarlo. Ejecutar WP-CLI como el usuario del vhost:

```sh
Expand Down Expand Up @@ -92,7 +94,7 @@ La imagen PHP del ejemplo ya estaba disponible en el equipo; no es una dependenc

## Reversión

`wp plugin deactivate emerkit-analytics` desactiva colector/panel/redirects y elimina su cron. Las tablas se conservan para evitar pérdida involuntaria. No se incluye uninstall destructivo. Desactivar exige restaurar enlaces directos a stores si los enlaces públicos ya se han cambiado. La nueva app no se publica hasta actualizar las declaraciones de privacidad de ambas stores.
`wp plugin deactivate emerkit-analytics` desactiva colector/panel/redirects y elimina su cron. Las tablas se conservan para evitar pérdida involuntaria. No se incluye uninstall destructivo. Desactivar exige restaurar enlaces directos a stores si los enlaces públicos ya se han cambiado. Cada subida de `version.json` en `main` publica sola en ambas stores (iOS se envía a revisión automáticamente): actualizar antes las declaraciones de privacidad de las stores si cambian los datos recogidos.


En srv07 se añadió además una tarea real horaria del usuario globalemergency.online (minuto 17), limitada a `do_action("emerkit_analytics_cleanup")`, con flock y sin salida de datos. Conserva las demás tareas y asegura limpieza aunque WP-Cron tenga poco tráfico. Plesk gestiona el certificado independiente de emerkit.globalemergency.online (emitido 24/10/2026, vencimiento inicial 22/01/2027).
1 change: 1 addition & 0 deletions store-assets/whatsnew/en-US.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Bug fixes and stability improvements.
1 change: 1 addition & 0 deletions store-assets/whatsnew/es-ES.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Correcciones y mejoras de estabilidad.
Loading