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.
Before you start
Section titled “Before you start”- 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.
Step 1: Register your developer accounts
Section titled “Step 1: Register your developer accounts”- Join the Apple Developer Program and turn on two-factor authentication for the account. Note your 10-character team id.
- Register a Google Play developer account. Google charges a one-time registration fee.
- 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. - In the Apple Developer portal, register an App ID for that identifier with the Associated Domains capability enabled.
- 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.
Configure the shell
Section titled “Configure the shell”Edit starter.config.json. These are the only values you edit:
{ "host": "app.example.com", "appId": "com.example.app", "appName": "Example App"}hostis the bare host name your application is served on, withouthttps://, a port or a path. The app may navigate on that host only. Cleartext HTTP and mixed content are off.appIdis the identifier you chose in Step 1.appNameis the name under the icon.modeis optional and chooses where the app’s pages come from:"remote", the default whenmodeis omitted: the app loads your hosted site athttps://<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, atcapacitor://localhoston iOS andhttps://localhoston Android.hoststays 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.
siteis 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 containingindex.html, for example"../my-site/dist". It must not be or contain the starter’swww/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/:
npm cinpm testnpm run webGenerate the native projects and artwork
Section titled “Generate the native projects and artwork”The Android project, android/, is committed in the starter. Generated artwork and copied web
assets are not, so prepare them before opening the project:
npm run webnpx cap sync androidnpm run assets -- --androidnpx cap open androidThe 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:
npm run webnpx cap add iosnpx cap sync iosnpm run ios:preparenpm run assets -- --iosnpx cap open iosnpm 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.
What the shell adds
Section titled “What the shell adds”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/shareand@capacitor/hapticsplugins; - 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.
Use the shell from your site
Section titled “Use the shell from your site”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
meroshellanddata-meroshell-platform="ios"or"android"; window.MeroFoundryShelloffersshare,haptic,setStatusBarStyle,openSettingsandrefresh, and themeroshell:readyevent fires onwindow;- the attributes
data-meroshell-action="settings","share"and"haptic"work without any script of your own; give your account menu an App settings link withdata-meroshell-action="settings"; - the CSS variables
--meroshell-safe-area-inset-top,-right,-bottomand-lefthold 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.
Sign-in inside the app
Section titled “Sign-in inside the app”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-catalogsays provider sign-in from the shell is available ("mobile_shell": {"provider_sign_in": true}), which the platform is to publish only while themobile_distributioncapability 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.
Bundled mode
Section titled “Bundled mode”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
sitedirectory instarter.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 athttps://<host>, so an application built with the page builder uses remote mode. -
Allowed origins. The bundled site is served from
capacitor://localhoston iOS andhttps://localhoston Android, and callshttps://<host>cross-origin. List both origins in your application’s allowed origins withupdate_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_distributioncapability 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.bearerdoes 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>');loginsubmits your login model with"session": "bearer", reads your application’s id fromhttps://<host>/api/v1/applications/public/by-hostand saves the returned pair.fetchsendsAuthorization: Beareronly tohttps://<host>; after a401it refreshes the pair once, saves the new pair and retries the request once. Without a saved pair it sends the request unchanged.logoutrevokes 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:sessionevent onwindowwith{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.
Push notifications
Section titled “Push notifications”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.
What push needs
Section titled “What push needs”- The capability. Every push surface needs the
mobile_distributioncapability 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 withmobile_distribution_not_enabledfor 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
.p8file. 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
appIdas the package name, then download itsgoogle-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.
Give MeroFoundry your push credentials
Section titled “Give MeroFoundry your push credentials”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.
Configure the shell for push
Section titled “Configure the shell for push”- iOS.
npm run ios:preparewritesaps-environment=developmentintoApp.entitlementsand adds the twoAppDelegatemethods 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 environmentsandbox. When you archive for TestFlight or the App Store, Xcode signs the export with your distribution profile, which setsaps-environmenttoproduction: those builds register with production APNs and need a credential with the environmentproduction. An APNs credential holds one environment at a time, so set it to match the build you are testing, and toproductionbefore you submit. - Android. Put the
google-services.jsonyou downloaded atandroid/app/google-services.jsonin your shell repository and commit it, so that the release workflow builds with it.android/app/build.gradleapplies the Google services plugin only when that file is present. Without it the app still builds and runs, and push answersunavailablewith the reasonnot_configured. Android 13 and later ask the user for the notification permission the first time your site turns push on.
Turn push on from your site
Section titled “Turn push on from your site”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 failedconst 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.
Send a push notification
Section titled “Send a push notification”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.
Taps and sign-out
Section titled “Taps and sign-out”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.
Tokens stay secret
Section titled “Tokens stay secret”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.
Step 3: Publish the association files for deep links
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:
curl -si https://app.example.com/.well-known/apple-app-site-associationcurl -si https://app.example.com/.well-known/assetlinks.jsonApple 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:
fastlane match appstore --app_identifier com.example.appThis 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.
App Store Connect setup
Section titled “App Store Connect setup”- In App Store Connect, create the app record with the same bundle id.
- Under Users and Access, Integrations, create an App Store Connect API key with the
App Manager role. Download its
.p8file and note the key id and issuer id. - 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:
npm run web && npx cap sync android && npm run assets -- --androidcd androidANDROID_KEYSTORE_FILE=/path/to/upload.keystore ANDROID_KEYSTORE_PASSWORD=... \ ANDROID_KEY_ALIAS=... ANDROID_KEY_PASSWORD=... ./gradlew bundleReleaseAfter 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 the workflows
Section titled “Run the workflows”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.
Step 5: Complete the store listings
Section titled “Step 5: Complete the store listings”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.
What your application adds
Section titled “What your application adds”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.
Account deletion
Section titled “Account deletion”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.
Write the review notes
Section titled “Write the review notes”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:
- say that the app is the native client for your own service on your own host;
- 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;
- 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.
After approval
Section titled “After approval”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.
