diff --git a/.github/scripts/app-store-submit.mjs b/.github/scripts/app-store-submit.mjs new file mode 100644 index 0000000..ad6c2da --- /dev/null +++ b/.github/scripts/app-store-submit.mjs @@ -0,0 +1,416 @@ +#!/usr/bin/env node +/** + * Submits an uploaded iOS build for App Store review through the App Store + * Connect API. Node 24, no dependencies (fetch + node:crypto). + * Adapted from sacadiez-app/.github/scripts/app-store-submit.mjs. + * + * 1. find the app by bundle id; + * 2. wait until the build with this CFBundleVersion is processed (VALID); + * 3. declare export compliance if Apple still asks for it; + * 4. find or create the editable App Store version for the version string, + * and attach the build; + * 5. set "What's New" on every localization from WHATSNEW_DIR/.txt + * (Apple requires it on updates); + * 6. add the version to a review submission (reusing an open one) and submit it. + * + * Idempotent: a version already waiting for or in review, or live, is left alone. + * + * Environment: + * APP_STORE_CONNECT_KEY_ID, APP_STORE_CONNECT_ISSUER_ID, + * APP_STORE_CONNECT_API_KEY (the .p8 PEM) credentials + * BUNDLE_ID online.globalemergency.emerkit + * APP_VERSION 0.1.20 (CFBundleShortVersionString) + * BUILD_NUMBER 120 (CFBundleVersion) + * WHATSNEW_DIR folder with .txt release notes + * BUILD_TIMEOUT_MINUTES optional, default 45 + * + * Logs progress and Apple's error details, never the credentials. + */ +import { createPrivateKey, sign } from 'node:crypto'; +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const API = 'https://api.appstoreconnect.apple.com'; +const EDITABLE_STATES = new Set([ + 'PREPARE_FOR_SUBMISSION', + 'DEVELOPER_REJECTED', + 'REJECTED', + 'METADATA_REJECTED', + 'INVALID_BINARY', + 'WAITING_FOR_EXPORT_COMPLIANCE', + // Added to a review submission that was never sent (e.g. a previous run + // failed on the final PATCH): resuming submits it. + 'READY_FOR_REVIEW', +]); +const ALREADY_SUBMITTED_STATES = new Set([ + 'WAITING_FOR_REVIEW', + 'IN_REVIEW', + 'PENDING_DEVELOPER_RELEASE', + 'PENDING_APPLE_RELEASE', + 'PROCESSING_FOR_APP_STORE', + 'PROCESSING_FOR_DISTRIBUTION', + 'READY_FOR_SALE', + 'READY_FOR_DISTRIBUTION', + 'ACCEPTED', +]); +const OPEN_SUBMISSION_STATES = ['READY_FOR_REVIEW', 'UNRESOLVED_ISSUES']; +const IN_FLIGHT_SUBMISSION_STATES = ['WAITING_FOR_REVIEW', 'IN_REVIEW']; + +const env = (name, fallback) => { + const value = process.env[name] ?? fallback; + if (value === undefined || value === '') fail(`${name} is not set`); + return value; +}; + +const keyId = env('APP_STORE_CONNECT_KEY_ID'); +const issuerId = env('APP_STORE_CONNECT_ISSUER_ID'); +const privateKey = createPrivateKey(env('APP_STORE_CONNECT_API_KEY')); +const bundleId = env('BUNDLE_ID'); +const versionString = env('APP_VERSION'); +const buildNumber = env('BUILD_NUMBER'); +const whatsNewDir = env('WHATSNEW_DIR'); +const timeoutMinutes = Number(env('BUILD_TIMEOUT_MINUTES', '45')); + +// ---------------------------------------------------------------- API client + +let cachedToken = { value: '', expiresAt: 0 }; + +function token() { + const now = Math.floor(Date.now() / 1000); + if (cachedToken.expiresAt - now > 60) return cachedToken.value; + const b64 = (obj) => Buffer.from(JSON.stringify(obj)).toString('base64url'); + const exp = now + 15 * 60; // Apple caps tokens at 20 minutes + const unsigned = `${b64({ alg: 'ES256', kid: keyId, typ: 'JWT' })}.${b64({ iss: issuerId, iat: now, exp, aud: 'appstoreconnect-v1' })}`; + const signature = sign('sha256', Buffer.from(unsigned), { + key: privateKey, + dsaEncoding: 'ieee-p1363', + }); + cachedToken = { + value: `${unsigned}.${signature.toString('base64url')}`, + expiresAt: exp, + }; + return cachedToken.value; +} + +class AppleApiError extends Error { + constructor(method, path, status, errors) { + const lines = []; + for (const e of errors ?? []) { + lines.push(` - [${e.code ?? e.status}] ${e.title ?? ''}: ${e.detail ?? ''}`); + // Submission blockers (e.g. unanswered age rating questions) come here. + for (const a of e.meta?.associatedErrors + ? Object.values(e.meta.associatedErrors).flat() + : []) { + lines.push(` · [${a.code ?? ''}] ${a.detail ?? a.title ?? ''}`); + } + } + super(`${method} ${path} -> HTTP ${status}${lines.length ? `\n${lines.join('\n')}` : ''}`); + this.status = status; + } +} + +async function api(method, path, body) { + for (let attempt = 1; ; attempt++) { + let res; + try { + res = await fetch(`${API}${path}`, { + method, + headers: { + Authorization: `Bearer ${token()}`, + 'Content-Type': 'application/json', + }, + body: body ? JSON.stringify(body) : undefined, + }); + } catch (err) { + // Network errors (reset, DNS) are transient like a 5xx. + if (attempt >= 4) throw err; + log(` ${method} ${path} -> ${err.message}, retrying (${attempt}/3)`); + await sleep(attempt * 10_000); + continue; + } + if (res.status === 204) return undefined; + // Rate limits and transient server errors (often HTML bodies): retry a few times. + if ((res.status === 429 || res.status >= 500) && attempt < 4) { + log(` ${method} ${path} -> HTTP ${res.status}, retrying (${attempt}/3)`); + await sleep(attempt * 10_000); + continue; + } + const text = await res.text(); + let json; + try { + json = text ? JSON.parse(text) : undefined; + } catch { + json = undefined; + } + if (res.ok) return json; + throw new AppleApiError(method, path, res.status, json?.errors); + } +} + +const rel = (type, id) => ({ data: { type, id } }); + +// ---------------------------------------------------------------- steps + +async function findApp() { + const res = await api( + 'GET', + `/v1/apps?filter[bundleId]=${encodeURIComponent(bundleId)}&limit=1`, + ); + const app = res.data?.[0]; + if (!app) fail(`No app with bundle id "${bundleId}" in App Store Connect.`); + log(`App: ${app.attributes.name} (${app.id})`); + return app; +} + +async function waitForBuild(appId) { + const deadline = Date.now() + timeoutMinutes * 60_000; + const query = + `/v1/builds?filter[app]=${appId}&filter[version]=${encodeURIComponent(buildNumber)}` + + `&filter[preReleaseVersion.version]=${encodeURIComponent(versionString)}&limit=1`; + let lastState = ''; + for (;;) { + const build = (await api('GET', query)).data?.[0]; + const state = build ? build.attributes.processingState : 'NOT_YET_VISIBLE'; + if (state !== lastState) { + log(`Build ${versionString} (${buildNumber}): ${state}`); + lastState = state; + } + if (state === 'VALID') return build; + if (state === 'FAILED' || state === 'INVALID') { + fail( + `Build ${buildNumber} is ${state} in App Store Connect: check the email Apple sent about it.`, + ); + } + if (Date.now() > deadline) { + fail( + `Build ${buildNumber} not processed after ${timeoutMinutes} min (last state ${state}).\n` + + ' The upload is done: once Apple finishes processing, re-run only the failed "Submit to App Review" job.', + ); + } + await sleep(30_000); + } +} + +async function ensureExportCompliance(build) { + // Info.plist declares ITSAppUsesNonExemptEncryption = false, which normally + // answers this. Fallback for a build where Apple still asks. + if (build.attributes.usesNonExemptEncryption == null) { + await api('PATCH', `/v1/builds/${build.id}`, { + data: { + type: 'builds', + id: build.id, + attributes: { usesNonExemptEncryption: false }, + }, + }); + log('Export compliance: declared no non-exempt encryption'); + } +} + +const stateOf = (v) => + v.attributes.appVersionState ?? v.attributes.appStoreState; + +/** Returns the version to submit, or undefined if this version is already submitted or live. */ +async function findOrCreateVersion(appId) { + const versions = + ( + await api( + 'GET', + `/v1/apps/${appId}/appStoreVersions?filter[platform]=IOS&limit=200`, + ) + ).data ?? []; + const same = versions.find( + (v) => v.attributes.versionString === versionString, + ); + if (same) { + const state = stateOf(same); + if (EDITABLE_STATES.has(state)) { + log(`App Store version ${versionString}: exists (${state})`); + return same; + } + if (ALREADY_SUBMITTED_STATES.has(state)) { + log( + `App Store version ${versionString} is already ${state}: nothing to submit.`, + ); + return undefined; + } + fail( + `App Store version ${versionString} is in state ${state}, which cannot be edited or submitted.`, + ); + } + + // Only one version can be editable at a time: reuse it under the new number. + const editable = versions.find((v) => EDITABLE_STATES.has(stateOf(v))); + if (editable) { + log( + `App Store version: renaming editable "${editable.attributes.versionString}" (${stateOf(editable)}) to ${versionString}`, + ); + const res = await api('PATCH', `/v1/appStoreVersions/${editable.id}`, { + data: { + type: 'appStoreVersions', + id: editable.id, + attributes: { versionString }, + }, + }); + return res.data; + } + + log(`App Store version ${versionString}: creating`); + const res = await api('POST', '/v1/appStoreVersions', { + data: { + type: 'appStoreVersions', + attributes: { + platform: 'IOS', + versionString, + releaseType: 'AFTER_APPROVAL', + }, + relationships: { app: rel('apps', appId) }, + }, + }); + return res.data; +} + +async function attachBuild(version, build) { + await api( + 'PATCH', + `/v1/appStoreVersions/${version.id}/relationships/build`, + rel('builds', build.id), + ); + log(`Build ${buildNumber} attached to version ${versionString}`); +} + +async function setWhatsNew(version) { + const localizations = + ( + await api( + 'GET', + `/v1/appStoreVersions/${version.id}/appStoreVersionLocalizations?limit=50`, + ) + ).data ?? []; + for (const l of localizations) { + const { locale } = l.attributes; + const file = join(whatsNewDir, `${locale}.txt`); + if (!existsSync(file)) { + fail(`No release notes for ${locale}: add ${file} (Apple requires "What's New" on updates).`); + } + await api('PATCH', `/v1/appStoreVersionLocalizations/${l.id}`, { + data: { + type: 'appStoreVersionLocalizations', + id: l.id, + attributes: { whatsNew: readFileSync(file, 'utf8').trim() }, + }, + }); + log(`What's New (${locale}): set`); + } +} + +async function submitForReview(appId, version) { + const list = async (states) => + ( + await api( + 'GET', + `/v1/reviewSubmissions?filter[app]=${appId}&filter[platform]=IOS&filter[state]=${states.join(',')}&limit=50`, + ) + ).data ?? []; + + for (const submission of await list(IN_FLIGHT_SUBMISSION_STATES)) { + if (await submissionHasVersion(submission.id, version.id)) { + log( + `Version ${versionString} is already in review submission ${submission.id} (${submission.attributes.state}).`, + ); + return; + } + } + + let submission = (await list(OPEN_SUBMISSION_STATES))[0]; + if (submission) { + log( + `Review submission: reusing ${submission.id} (${submission.attributes.state})`, + ); + } else { + submission = ( + await api('POST', '/v1/reviewSubmissions', { + data: { + type: 'reviewSubmissions', + attributes: { platform: 'IOS' }, + relationships: { app: rel('apps', appId) }, + }, + }) + ).data; + log(`Review submission: created ${submission.id}`); + } + + if (await submissionHasVersion(submission.id, version.id)) { + log(`Review submission already contains version ${versionString}`); + } else { + await api('POST', '/v1/reviewSubmissionItems', { + data: { + type: 'reviewSubmissionItems', + relationships: { + reviewSubmission: rel('reviewSubmissions', submission.id), + appStoreVersion: rel('appStoreVersions', version.id), + }, + }, + }); + log(`Review submission: version ${versionString} added`); + } + + await api('PATCH', `/v1/reviewSubmissions/${submission.id}`, { + data: { + type: 'reviewSubmissions', + id: submission.id, + attributes: { submitted: true }, + }, + }); + log( + `Submitted version ${versionString} (build ${buildNumber}) for App Store review.`, + ); +} + +async function submissionHasVersion(submissionId, versionId) { + const items = + ( + await api( + 'GET', + `/v1/reviewSubmissions/${submissionId}/items?include=appStoreVersion&limit=50`, + ) + ).data ?? []; + return items.some( + (item) => item.relationships?.appStoreVersion?.data?.id === versionId, + ); +} + +// ---------------------------------------------------------------- helpers + +function log(message) { + console.log(message); +} + +function fail(message) { + console.error(`::error::${message.split('\n')[0]}`); + console.error(message); + process.exit(1); +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// ---------------------------------------------------------------- main + +async function main() { + log( + `Submitting ${bundleId} ${versionString} (build ${buildNumber}) for App Store review`, + ); + const app = await findApp(); + const build = await waitForBuild(app.id); + await ensureExportCompliance(build); + const version = await findOrCreateVersion(app.id); + if (!version) return; + await attachBuild(version, build); + await setWhatsNew(version); + await submitForReview(app.id, version); +} + +main().catch((err) => + fail(err instanceof AppleApiError ? err.message : (err.stack ?? String(err))), +); diff --git a/.github/workflows/build-ios.yml b/.github/workflows/build-ios.yml index 6b5e871..891d993 100644 --- a/.github/workflows/build-ios.yml +++ b/.github/workflows/build-ios.yml @@ -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 @@ -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: @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b23d6a6..cd12d8f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/deploy-analytics.yml b/.github/workflows/deploy-analytics.yml new file mode 100644 index 0000000..6dd17da --- /dev/null +++ b/.github/workflows/deploy-analytics.yml @@ -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" diff --git a/.github/workflows/ensure-tags.yml b/.github/workflows/ensure-tags.yml index 74c4426..82550cf 100644 --- a/.github/workflows/ensure-tags.yml +++ b/.github/workflows/ensure-tags.yml @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 5e6cab9..8bba685 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/.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 diff --git a/README.md b/README.md index 5cf6ed8..6dd2f21 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/emerkit-analytics/01-operations.md b/backend/emerkit-analytics/01-operations.md index 43e3017..a50f29e 100644 --- a/backend/emerkit-analytics/01-operations.md +++ b/backend/emerkit-analytics/01-operations.md @@ -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 @@ -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). diff --git a/store-assets/whatsnew/en-US.txt b/store-assets/whatsnew/en-US.txt new file mode 100644 index 0000000..9f30093 --- /dev/null +++ b/store-assets/whatsnew/en-US.txt @@ -0,0 +1 @@ +Bug fixes and stability improvements. diff --git a/store-assets/whatsnew/es-ES.txt b/store-assets/whatsnew/es-ES.txt new file mode 100644 index 0000000..ac78437 --- /dev/null +++ b/store-assets/whatsnew/es-ES.txt @@ -0,0 +1 @@ +Correcciones y mejoras de estabilidad.