Skip to content

Publish your application to the app stores

You can put an application you built on MeroFoundry in the Apple App Store and Google Play as a native iOS and Android app. The app is a Capacitor shell made from the MeroFoundry Capacitor starter. It loads your application’s own hosted site over HTTPS (https://<host>), so your pages, your login pages, the /api/ surface and the cookie session work exactly as they do in a browser.

You build, sign and submit the app. MeroFoundry never builds a binary, never holds your signing material and never submits to the App Store or Google Play for you. You hold the developer accounts, the shell repository, the signing certificates and keys, and the CI that builds and uploads each release. Apple’s guideline 4.2.6 rejects template apps that a template provider submits for its clients, so the app must come from your own developer account as the provider of its content. Nothing in the starter uses a MeroFoundry credential.

Once the app is live, activating a new site version on MeroFoundry updates every installed app on its next launch. Only native changes need a new build and a store resubmission.

The six steps below follow the order you work in. Sign-in inside the app, between Steps 2 and 3, changes what your login page shows inside the app.

  • A MeroFoundry application with a hosted site on its own host, for example app.example.com, where your application users sign in with email and password.
  • A GitHub repository you own for the shell, with GitHub Actions enabled. The starter’s release workflows run there on a macOS runner (iOS) and an Ubuntu runner (Android).
  • Node.js 22 or later on your machine.
  • A Mac with Xcode for the first iOS steps and for creating your iOS signing material. After that, the iOS workflow builds on its macOS runner.
  1. Join the Apple Developer Program and turn on two-factor authentication for the account. Note your 10-character team id.
  2. Register a Google Play developer account. Google charges a one-time registration fee.
  3. Choose your app identifier, for example com.example.app. It is your iOS bundle id and your Android package name, and it cannot change after release.
  4. In the Apple Developer portal, register an App ID for that identifier with the Associated Domains capability enabled.
  5. In the Play Console, create the app with that package name and enable Play App Signing.

Step 2: Create your shell from the starter

Section titled “Step 2: Create your shell from the starter”

Create your own repository from the MeroFoundry Capacitor starter and commit it there. From then on it is your project: you change it, you build it and you keep it up to date.

Edit starter.config.json. These are the only values you edit:

{
"host": "app.example.com",
"appId": "com.example.app",
"appName": "Example App"
}
  • host is the bare host name your application is served on, without https://, a port or a path. The app may navigate on that host only. Cleartext HTTP and mixed content are off.
  • appId is the identifier you chose in Step 1.
  • appName is the name under the icon.
  • mode is optional and chooses where the app’s pages come from:
    • "remote", the default when mode is omitted: the app loads your hosted site at https://<host>. Activating a new site version updates every installed app on its next launch.
    • "bundled": the app packages a copy of your already-built site and serves it from the device, at capacitor://localhost on iOS and https://localhost on Android. host stays the application host your site’s API calls reach, so list those two origins in your application’s allowed origins. A new site version reaches users only in a new app build.
  • site is required in bundled mode and refused in remote mode. It is the path, relative to the starter directory, of your already-built site: a readable directory containing index.html, for example "../my-site/dist". It must not be or contain the starter’s www/ directory. The starter copies the directory as it is and never builds your site, so build it first.

A bundled configuration looks like this:

{
"host": "app.example.com",
"appId": "com.example.app",
"appName": "Example App",
"mode": "bundled",
"site": "../my-site/dist"
}

npm run web assembles www/, the directory Capacitor packages. In both modes the shell’s own screens go to www/meroshell/; in bundled mode your site is copied to the www/ root. The meroshell/ path is reserved for the shell, so a site with a top-level meroshell file or directory is refused with an error that names it. www/ is generated and never committed. Run npm run web before npx cap add ios, before every npx cap sync and whenever your site changes; every npx cap sync and npx cap copy also reassembles it first.

Every other value derives from this file: the Capacitor configuration (webDir is www in both modes, server.url in remote mode only, server.allowNavigation), the Android application id, label and App Links host, and the iOS bundle id, display name and associated domain. The app’s WebView adds MeroFoundryShell/<starter version> to the end of its user agent, which is how your site recognises the app.

Replace assets/icon.png (1024 by 1024 pixels or larger, square) and assets/splash.png (2732 by 2732 pixels or larger, square, logo centred) with your own artwork. Then install, run the starter’s tests and assemble www/:

Terminal window
npm ci
npm test
npm run web

The Android project, android/, is committed in the starter. Generated artwork and copied web assets are not, so prepare them before opening the project:

Terminal window
npm run web
npx cap sync android
npm run assets -- --android
npx cap open android

The iOS project, ios/, is never committed. Generating it needs Xcode, so it is created on your Mac or on the macOS runner, and the iOS workflow creates it again on every run. Assemble www/ first; then the first iOS project step is npx cap add ios:

Terminal window
npm run web
npx cap add ios
npx cap sync ios
npm run ios:prepare
npm run assets -- --ios
npx cap open ios

npm run ios:prepare applies the starter’s iOS customizations to the generated project: the Face ID usage text, the display name and bundle id, the applinks:<host> associated domain, and a build phase that runs the release guard on every non-Debug build. It is safe to run again. Make any further iOS change in scripts/prepare-ios.cjs and lib/ios-project.cjs, never in ios/, so that every fresh generation includes it.

npm run assets generates the launch screen, app icons and splash screens for each platform from your two images.

To try a debug build against your preview host, sync with MEROSHELL_PREVIEW_HOST=preview-app.example.com npx cap sync android. A preview build can never be released: both workflows, the Android release build and the Xcode build phase refuse to package a project synced that way. They also refuse to package when www/ or the platform’s copy of it is missing or stale, that is when the configuration or any file of your site or the shell changed since npm run web (the check compares content, not timestamps). Run npm run web, then npx cap sync <platform> without the override, before a local release build.

These are the native features the starter provides. Store review looks for them (see Step 6):

  • launch screen, app icons and splash screens generated from your artwork;
  • an offline screen with Try again, bundled in the app, shown when your host cannot be reached;
  • a settings screen with the app version, shell version, host, the app lock switch and Sign out;
  • an optional app lock (Face ID, Touch ID, Android biometrics or the device passcode) on launch and whenever the app returns from the background, off by default and stored on the device only;
  • safe-area CSS variables and status-bar handling for your site;
  • the native share sheet and haptics, through the official @capacitor/share and @capacitor/haptics plugins;
  • pull to refresh at the top of your pages, built into the starter itself;
  • deep links: an https://<host>/... link opened on the device opens the app at that path once the association files from Step 3 are published;
  • provider sign-in through the system’s web-authentication session, offered to your site only where the device and your application support it (see “Sign-in inside the app”).

Sign out in the settings screen deletes every cookie in the app’s WebView, including your application’s session cookies, and reloads https://<host>/. It does not call a server-side logout, so the server forgets the session when it expires in the normal way. In bundled mode it first removes the bearer token pair saved in the device’s secure storage, then deletes the WebView’s cookies and reloads the root of your bundled site. It does not revoke the session on the server either; call MeroFoundryShell.bearer.logout from your site when you want that. The settings screen names the mode, Bundled or Remote, beside the host, and never shows a token.

The shell runs a small script in your own pages, and only in pages on your configured host. Your site needs no Capacitor dependency:

  • the root element gets the class meroshell and data-meroshell-platform="ios" or "android";
  • window.MeroFoundryShell offers share, haptic, setStatusBarStyle, openSettings and refresh, and the meroshell:ready event fires on window;
  • the attributes data-meroshell-action="settings", "share" and "haptic" work without any script of your own; give your account menu an App settings link with data-meroshell-action="settings";
  • the CSS variables --meroshell-safe-area-inset-top, -right, -bottom and -left hold the device’s insets, because the web view draws under the status bar;
  • mark maps, carousels and your own drag gestures with data-meroshell-pull-refresh="off" so a pull there does not reload the page;
  • show provider sign-in buttons only while the root element has data-meroshell-provider-sign-in="on".

The starter’s README.md documents each of these in full.

Email and password login works inside the app exactly as in a browser. Provider sign-in (Google, GitHub, Microsoft) is different: Google refuses sign-in requests from an embedded WebView, and Apple expects provider sign-in to run in the system’s web-authentication session. The shell therefore never shows a provider’s page in its WebView. When your site starts a provider sign-in, the shell loads your sign-in link in its WebView, opens the provider in the system session (ASWebAuthenticationSession on iOS, a Custom Tab on Android), and loads only the validated return to your application’s OAuth exchange route back into its WebView.

Whether your site may offer provider buttons inside the app is a capability the shell reports. It is on only when all of these hold:

  • the device supports it: iOS 17.4 or later, or an Android browser with Custom Tabs and, on Android 12 or later, a verified App Links association for your host;
  • your host’s public application metadata answers with a valid application id;
  • your application’s catalog at https://<host>/.well-known/api-catalog says provider sign-in from the shell is available ("mobile_shell": {"provider_sign_in": true}), which the platform is to publish only while the mobile_distribution capability is enabled for your application;
  • the association files from Step 3 claim your host, so that the return reaches the app.

The shell checks the metadata and the catalog once per launch and again after Sign out. It fails closed: if the catalog is not published, answers anything but 200, or does not say true, or the host cannot be reached, provider sign-in stays off and your site keeps email and password login. If a later check turns it off, the shell removes it from the page on screen.

Sign-in in a browser does not change. Inside the app, the return the shell loads into its WebView carries pending instead of code, and your application’s callback rule then runs at the exchange route, after the app’s WebView has proven that it began the flow. This requires the mobile_distribution capability to be enabled for your application. If you write the callback rule, put exchange_oauth_code first. Inside the app, the provider’s callback runs the rule only up to exchange_oauth_code, and the exchange route then runs the whole rule again in your app’s WebView: an action placed before exchange_oauth_code runs in both requests, an action after it runs once.

This example is a customer login page. Each provider button is a real link to your sign-in route, so it works in a browser, and carries a data-provider-sign-in attribute:

<form id="login-form">
<input type="email" name="email" autocomplete="username" required>
<input type="password" name="password" autocomplete="current-password" required>
<button type="submit">Sign in</button>
</form>
<div class="provider-sign-in">
<a href="/api/v1/services/<sign-in service id>/inbound?provider=google" data-provider-sign-in="google">Continue with Google</a>
<a href="/api/v1/services/<sign-in service id>/inbound?provider=github" data-provider-sign-in="github">Continue with GitHub</a>
</div>

Inside the app, hide the buttons unless the shell offers provider sign-in. While it does, the root element has data-meroshell-provider-sign-in="on", and a click on one of these links is handed to the shell instead of being followed:

.meroshell:not([data-meroshell-provider-sign-in="on"]) [data-provider-sign-in] { display: none; }

From script, MeroFoundryShell.capabilities.providerSignIn tells you the same, the meroshell:capabilities event fires on window when it changes, and MeroFoundryShell.providerSignIn(href) starts sign-in; it rejects with Provider sign-in is not available in this app while the capability is off:

function showProviderButtons() {
const inApp = document.documentElement.classList.contains('meroshell');
const offered = !inApp || window.MeroFoundryShell?.capabilities.providerSignIn === true;
for (const button of document.querySelectorAll('[data-provider-sign-in]')) {
button.hidden = !offered;
}
}
showProviderButtons();
window.addEventListener('meroshell:capabilities', showProviderButtons);

In a browser none of this applies, and every provider button still shows.

Provider sign-in from the app has one known open issue: the platform’s OAuth callback checks the browser-binding cookie set in the app’s WebView, but the provider returns to that callback inside the system session, which has its own cookies. Until the platform resolves this, do not rely on provider sign-in inside the app, and keep a password path for every user: users who registered with a provider and never set a password need one, for example through password reset. An app whose only sign-in is your own email and password account system also falls outside the third-party login rule in Apple’s guideline 4.8.

In bundled mode the app carries your site inside the binary and talks to your application over its API. Use it when your site must open without a network; otherwise remote mode is simpler, because a new site version reaches users without a new app build.

  • Bring your own static build. The starter copies an already-built static site (the site directory in starter.config.json) into the app as it is. It never builds your site, and it cannot package pages made with the MeroFoundry page builder: those pages are rendered by the platform at https://<host>, so an application built with the page builder uses remote mode.

  • Allowed origins. The bundled site is served from capacitor://localhost on iOS and https://localhost on Android, and calls https://<host> cross-origin. List both origins in your application’s allowed origins with update_application, for example {"allowedOrigins": ["capacitor://localhost", "https://localhost"]}, or the device’s WebView refuses every API answer.

  • Capability. Bearer sessions are available only while the mobile_distribution capability is enabled for your application. While it is not, a bearer login is refused and your bundled site cannot sign anyone in.

  • The bearer helper. Cookies do not work across origins, so a bundled site signs in with a bearer session that the shell keeps in the device’s secure storage. MeroFoundryShell.bearer does this for you:

    await MeroFoundryShell.bearer.login('<login model id>', { email, password });
    const response = await MeroFoundryShell.bearer.fetch(`https://${MeroFoundryShell.host}/api/v1/...`);
    if (await MeroFoundryShell.bearer.signedIn()) { /* show the signed-in view */ }
    await MeroFoundryShell.bearer.logout('<logout model id>');

    login submits your login model with "session": "bearer", reads your application’s id from https://<host>/api/v1/applications/public/by-host and saves the returned pair. fetch sends Authorization: Bearer only to https://<host>; after a 401 it refreshes the pair once, saves the new pair and retries the request once. Without a saved pair it sends the request unchanged. logout revokes the session on the server and clears the saved pair. The helper keeps no offline copy of your data and refreshes nothing in the background.

  • Provider sign-in. The provider leg runs exactly as in remote mode. At the end the shell asks the exchange route for the bearer transport, redeems it in the app’s WebView, saves the pair in secure storage and opens the returned path in your bundled site, then fires the meroshell:session event on window with {signedIn: true} and no token.

  • Offline. Your bundled site always loads from the device. The offline screen appears only when https://<host> cannot be reached during a provider sign-in; your own pages decide how to show a failed API call.

  • Sign out. Sign out in the settings screen clears the saved pair and the WebView’s cookies, then reloads the root of your bundled site.

Your application can send push notifications to the people signed in to your app: through the Apple Push Notification service (APNs) on iOS and Firebase Cloud Messaging (FCM) on Android. A push notification is a notification like an email one, with the channel push. You author it, send it and read its delivery logs the same way, and it works in remote and bundled mode alike.

  • The capability. Every push surface needs the mobile_distribution capability enabled for your application: the push credentials and their tools, the device registration routes the shell calls, creating a push notification or changing a notification to push, and delivery. While the capability is not enabled, creating a push notification is refused, and sending one writes a failed log with mobile_distribution_not_enabled for each registered device and contacts neither Apple nor Google.
  • Your own APNs key. In the Apple Developer portal (Certificates, Identifiers & Profiles, Keys), create a key with the Apple Push Notifications service enabled and download its .p8 file. Apple offers the download once, so keep the file safe. Note the key id. Turn on the Push Notifications capability for your App ID and regenerate your match provisioning profiles once (Step 4) so that they carry it.
  • Your own FCM service account. In the Firebase console, create a project and add an Android app with your appId as the package name, then download its google-services.json. In the project settings, under Service accounts, generate a new private key: a JSON key file for a service account that may send through the FCM HTTP v1 API. Note the Firebase project id.

MeroFoundry never uses a key or a service account of its own. Every application sends with the credentials you created for it, from your own Apple and Google accounts.

Set each platform’s credential once, with the set_push_credentials MCP tool or on the Push credentials page of your application in the admin UI. For APNs, pass platform: "apns", your team_id, the key_id, your bundle_id (your appId, which must match the ios entry of identity_settings.mobile_apps from Step 3), environment (sandbox or production) and the contents of the .p8 file as private_key:

{
"application_id": "<application id>",
"platform": "apns",
"team_id": "ABCDE12345",
"key_id": "KEY1234567",
"bundle_id": "com.example.app",
"environment": "sandbox",
"private_key": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
}

For FCM, pass platform: "fcm", the project_id and the service account key file as service_account_json. The key and the service account are encrypted as they are written and are never returned: get_push_credentials answers only the identifiers, whether each credential is active, and how many devices are registered on each platform. Writing a platform again replaces its credential at once, and delete_push_credentials removes it.

To check a credential, register a device (see below) and call test_push_credentials with the platform and a registration_id. It sends a silent push to that one device and reports whether the provider accepted it, with the provider’s error code when it did not. The delivery log of a push notification names each device by its registration id, so any earlier send gives you one.

  • iOS. npm run ios:prepare writes aps-environment = development into App.entitlements and adds the two AppDelegate methods that hand the APNs token to the shell. Development builds you run from Xcode register with the APNs sandbox, so they need a credential with the environment sandbox. When you archive for TestFlight or the App Store, Xcode signs the export with your distribution profile, which sets aps-environment to production: those builds register with production APNs and need a credential with the environment production. An APNs credential holds one environment at a time, so set it to match the build you are testing, and to production before you submit.
  • Android. Put the google-services.json you downloaded at android/app/google-services.json in your shell repository and commit it, so that the release workflow builds with it. android/app/build.gradle applies the Google services plugin only when that file is present. Without it the app still builds and runs, and push answers unavailable with the reason not_configured. Android 13 and later ask the user for the notification permission the first time your site turns push on.

The shell never asks for notification permission on its own: your site decides when. Call MeroFoundryShell.push.enable() after the user signs in, for example from a Turn on notifications button:

const result = await MeroFoundryShell.push.enable();
// {status: 'enabled'} registered for the signed-in user
// {status: 'denied'} the user refused, or turned notifications off
// {status: 'unavailable', reason} no APNs token, or no google-services.json
// {status: 'pending', reason} saved; registers once a session exists
// {status: 'failed', reason, httpStatus} the registration request failed
const state = await MeroFoundryShell.push.state(); // {enabled, permission, platform}
await MeroFoundryShell.push.disable(); // {status: 'disabled'}

enable() asks the operating system for permission only when the user has never answered, then registers the device for the signed-in user of your application with your site’s own session: the page’s cookies in remote mode, the saved bearer pair in bundled mode. Called before sign-in, it answers pending and the shell registers by itself once a session exists. While push is on, the shell renews the registration when the app starts and when the operating system changes the token. disable() removes the registration and the token on the device. state() tells your site whether push is on, what the user answered and which platform the device uses, so you can show the right button.

Create a notification with create_notification and "channel": "push". The subject template is the notification’s title and the body template is its message. Its settings are:

  • path (required): the in-app path a tap opens, templated like the body, for example /orders/{{order.id}}. It must be an absolute path on your application, starting with one /, at most 1024 characters, with no space, backslash or control character.
  • collapse_key (optional, at most 64 characters): a newer push with the same key replaces the older one on the device.
  • badge (optional): a whole number of 0 or more for the app icon’s badge.
{
"application_id": "<application id>",
"name": "Order update",
"channel": "push",
"subject_template": "Order {{order.id}}",
"body_template": "Your order is on its way.",
"recipient_config": {"type": "field", "field": "customer_id"},
"trigger_events": [],
"settings": {"path": "/orders/{{order.id}}", "collapse_key": "orders", "badge": 1}
}

Recipients are identity records: records of your application’s identity model, the people who sign in. Name them with {"type": "static", "record_ids": ["<record id>"]}, or with {"type": "field", "field": "customer_id"} to read one record id, or a list of them, from the send’s context. A value that is not a record of your identity model fails with recipient_not_found. The template context also offers {{recipient.id}}, the id of the record being notified.

Send it the way you send email, with send_notification or a scheduled job, and the template context:

{"context": {"customer_id": "<record id>", "order": {"id": "1042"}}}

The push goes to every device the recipient registered. Each device gets one delivery attempt and one delivery log, which names the device’s registration id, its platform and the recipient’s record id, never the token. The log is sent when the provider accepted the push and failed when it refused, with its error code. A device the provider reports as gone (BadDeviceToken or an HTTP 410 from APNs, UNREGISTERED from FCM) is logged skipped, its registration is switched off and it is never tried again. These refusals are decided before anything is sent: invalid_push_path when the rendered path breaks the rule above, push_credentials_missing when the device’s platform has no active credential, and payload_too_large when the provider’s payload would exceed 4096 bytes. Push sends use no email quota.

The title, the message and the path can appear on a locked device’s screen. They come from your templates and may include record data, so include only what you want shown there.

A tap on a notification opens its path in your app, including the tap that launches the app: in remote mode as https://<host><path>, exactly like a deep link, and in bundled mode in your bundled site. A path that breaks the rule above never navigates; the shell fires meroshell:push on window with {type: 'rejected', reason} instead. A notification without a path opens the app where it was.

Sign out in the settings screen first asks your page to turn push off, so the registration is removed with the session that made it, and only then clears the session. It waits at most five seconds, and deletes the token on the device whatever the answer, so nothing renews it. Do the same in your own sign-out: call MeroFoundryShell.push.disable() before you end the session. Registrations of a deleted identity record are deleted with it.

Device tokens never leave MeroFoundry except to APNs and FCM. They are not in delivery logs, audit events, the admin UI, MCP answers or application bundles, and your pages never see them: the shell keeps the token on the device and logs, shows and dispatches only results. Push credentials and device registrations are also left out of application bundles, so a restored application needs its credentials set again and its users’ devices register again.

Section titled “Step 3: Publish the association files for deep links”

The app claims https://<host>/... links, but iOS and Android open them in the app only once your host publishes two association files. Provider sign-in inside the app needs the same files.

When the mobile_distribution capability is enabled for your application, list your apps in the application’s identity_settings.mobile_apps: an ios entry with your team id, bundle id and the claimed paths (all paths by default), and an android entry with the package name and the SHA-256 fingerprints of the app signing certificate. The platform then serves both files from your host, ahead of any site version.

Otherwise you own these files. Put them in your site’s public/.well-known/ folder so that they are part of your site build. With the GitHub releases flow, set include-hidden-files: true on the upload step so the dot-folder is included.

public/.well-known/apple-app-site-association has no file extension and contains JSON. Use your Apple team id and your appId:

{
"applinks": {
"details": [{ "appIDs": ["ABCDE12345.com.example.app"], "components": [{ "/": "/*" }] }]
}
}

public/.well-known/assetlinks.json uses the SHA-256 fingerprint of the app signing key from the Play Console (Setup, App signing), not your upload key:

[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.example.app",
"sha256_cert_fingerprints": ["AA:BB:..."]
}
}]

After you enable Associated Domains for the App ID, regenerate your match provisioning profile once (Step 4) so that it carries the capability.

Before you rely on deep links, check that both files answer 200 over HTTPS with no redirect and with the JSON you wrote:

Terminal window
curl -si https://app.example.com/.well-known/apple-app-site-association
curl -si https://app.example.com/.well-known/assetlinks.json

Apple fetches the file through its own CDN when the app is installed, so a change can take some time to reach devices. If either file does not answer, links open in the browser instead of the app, and everything else in the app works as before. A link opened while the app is locked loads behind the lock screen. Links to other hosts, non-HTTPS links and links with credentials or a port are ignored.

Step 4: Add signing secrets and run the release workflows

Section titled “Step 4: Add signing secrets and run the release workflows”

The starter’s .github/workflows/ios.yml and .github/workflows/android.yml are your release workflows. They run in your repository on workflow_dispatch and on tags matching v*. Each one first runs the release guard, then installs dependencies, prepares the native project, generates the artwork and builds. They commit nothing back to your repository, read no MeroFoundry credential and use only the secrets you add.

iOS signing with your existing match material

Section titled “iOS signing with your existing match material”

The iOS workflow signs with fastlane match in readonly mode. It never creates or changes signing material, so the material must already exist. Once, on your Mac, create a private git repository for match and run:

Terminal window
fastlane match appstore --app_identifier com.example.app

This creates an App Store distribution certificate and an App Store provisioning profile for your appId, encrypted with a passphrase you choose, in your match repository. Run it again after enabling a capability such as Associated Domains.

  1. In App Store Connect, create the app record with the same bundle id.
  2. Under Users and Access, Integrations, create an App Store Connect API key with the App Manager role. Download its .p8 file and note the key id and issuer id.
  3. Add the secrets below to your GitHub repository.
Secret Value
APPLE_TEAM_ID Your 10-character Apple Developer team id
APP_STORE_CONNECT_API_KEY_ID Key id of the App Store Connect API key
APP_STORE_CONNECT_API_ISSUER_ID Issuer id shown with that key
APP_STORE_CONNECT_API_KEY_CONTENT The key’s .p8 file, base64-encoded (base64 -i AuthKey_XXXX.p8)
MATCH_GIT_URL URL of your existing match repository
MATCH_GIT_BASIC_AUTHORIZATION base64 of user:token with read access to that repository
MATCH_PASSWORD The passphrase that encrypts your match repository

The iOS workflow runs on macos-latest: npm ci, npm run web, npx cap add ios, npx cap sync ios, npm run ios:prepare, npm run assets -- --ios, then bundle exec fastlane ios release, which reads your signing material, builds the Release configuration and uploads the build to TestFlight. The build number is the workflow run number.

Android signing and the first manual Play upload

Section titled “Android signing and the first manual Play upload”

Create an upload keystore if you do not have one, keep it outside the repository, and add these secrets:

Secret Value
ANDROID_KEYSTORE_BASE64 Your upload keystore, base64-encoded (base64 -w0 upload.keystore)
ANDROID_KEYSTORE_PASSWORD The keystore password
ANDROID_KEY_ALIAS The upload key alias
ANDROID_KEY_PASSWORD The upload key password
PLAY_SERVICE_ACCOUNT_JSON JSON key of a Google Cloud service account invited in the Play Console with permission to release to testing tracks

Upload the first App Bundle by hand. Google Play accepts API uploads only after the first bundle for an app has been uploaded in the Play Console. Build it locally and upload it to the internal testing track:

Terminal window
npm run web && npx cap sync android && npm run assets -- --android
cd android
ANDROID_KEYSTORE_FILE=/path/to/upload.keystore ANDROID_KEYSTORE_PASSWORD=... \
ANDROID_KEY_ALIAS=... ANDROID_KEY_PASSWORD=... ./gradlew bundleRelease

After that, the Android workflow runs on ubuntu-latest: npm ci, npm run web, npx cap sync android, npm run assets -- --android, ./gradlew bundleRelease signed with your upload keystore, then an upload to the internal track. The version code is the workflow run number.

Run both workflows from the Actions tab, or push a tag such as v1.0.0. Install the TestFlight build and the internal-track build on real devices and check sign-in, the offline screen, the app lock, share, pull to refresh and a deep link before you continue.

Each store needs screenshots, a description, a privacy policy URL and a support URL for your application, plus Apple’s App Privacy answers and Google Play’s Data Safety form. The answers describe what your app collects, so they are your decisions. This step lists what MeroFoundry itself processes for every application, so that you only add what your own application does.

What MeroFoundry processes for every application

Section titled “What MeroFoundry processes for every application”
Data What the MeroFoundry baseline does What you decide
Session identifiers and cookies Signing in sets session cookies on your host, held in the app’s WebView cookie jar on the device. They identify the signed-in user’s session to your application’s API for as long as the session lives. Declare an identifier linked to the user, used for app functionality, in both forms.
Email address Your application users register and sign in with an email address, stored with their user record in your application. Declare email address as collected and linked to the user, for app functionality and account management, plus any other use you make of it.
IP address in access logs MeroFoundry’s infrastructure, access and security logs record the IP address, time and request of every call to your host. The MeroFoundry Privacy Policy states these logs are generally retained for 90 days, longer for an incident or legal requirement. These logs are retained, so they are not transient processing. Do not answer as if they were processed only in memory. Declare them under the data types and purposes that fit, such as security and fraud prevention, and say how long they are kept.
User agent Every request carries the WebView’s user agent, which names the device’s operating system and ends in MeroFoundryShell/<version>. MeroFoundry uses it to serve the request and records it in the same access logs. Decide how device or diagnostic information is declared, on the same retention terms as the access logs.

These facts hold for the baseline:

  • The MeroFoundry baseline has no advertising identifiers. The shell does not read the iOS advertising identifier or the Android advertising ID, includes no advertising or analytics SDK, and does not ask for App Tracking Transparency permission.
  • All traffic between the app and your host is HTTPS. Cleartext HTTP is off in the shell.
  • The app lock runs on the device. Face ID, Touch ID and fingerprint data never reach the shell, your site or MeroFoundry. The app lock setting is stored on the device only.

Everything your application collects beyond the baseline is yours to declare: the records your models store (names, addresses, phone numbers, photos, files, location, payment or health data), anything your users type into your forms, analytics or tag scripts your site loads, and anything you send to a third party through your rules, functions or webhooks. Retention of that data is yours too. The retention you set for your application’s records is separate from the MeroFoundry access-log retention above, and your answers must cover both.

The baseline does not track users across other companies’ apps or websites, but that does not make a “no tracking” answer true for your app. If your site adds advertising, cross-app analytics or data sharing with a data broker, you must declare tracking and, on iOS, ask for App Tracking Transparency permission before tracking. Answer for the app you actually submit.

If users can create an account in your app, Apple requires a way to start account deletion from inside the app, and Google Play requires both an in-app path and a web link for deletion requests. Provide a delete-account page on your site and link to it from your account menu.

Step 6: Submit, and resubmit only for native changes

Section titled “Step 6: Submit, and resubmit only for native changes”

Submit the TestFlight build for App Review in App Store Connect, and promote the internal-track release to production in the Play Console, under your own developer accounts.

A shell with no native features is a guideline 4.2 rejection. Apple’s guideline 4.2 (Minimum Functionality) rejects an app that is only a repackaged website. The starter’s native features are what lift your app above that, so keep them and tell the reviewer about them. In the review notes:

  1. say that the app is the native client for your own service on your own host;
  2. list the native features: the offline screen, biometric app lock, settings screen, native share sheet, haptics, pull to refresh, safe-area and status-bar handling, and deep links;
  3. give the reviewer a working test account that signs in with email and password, and say that the app lock is off until the user turns it on in the app’s settings.

For example:

Example App is the native client for Example, our service at app.example.com.
Native features: biometric app lock (Settings > App lock), an offline screen with retry,
an in-app settings screen, the native share sheet, haptic feedback, pull to refresh,
safe-area and status-bar handling, and universal links into the app.
Test account: reviewer@example.com / <password>. The app lock is off by default.

Google Play reviews against its own policies. Give its reviewer the same test account under App access in the Play Console.

Your web app updates without the stores. Activating a new site version on MeroFoundry, for example through GitHub releases, reaches every installed app on its next launch, and a release that changes only the web bundle needs no store action.

Native changes need a new build and a resubmission: a change to starter.config.json, icons or splash artwork, plugins, permissions, Capacitor upgrades, or an update you take from the starter. Run the release workflows again, or push a new v* tag, and submit the new build.