Shipping push in a release build
Everything in Installation gets push working on a device you build to from your own machine. A release build is a different set of conditions, and each of the four below is silent: nothing errors, nothing logs, and the first evidence is a notification nobody receives.
On this page
All four were found in one app's first TestFlight submission, after a development build had been delivering pushes for days.
1. Release signs against a different APNs environment#
A development provisioning profile carries aps-environment: development. A
distribution profile carries production. They are not interchangeable, so one
entitlements file cannot serve both and the project needs two:
ios/Runner/Runner.entitlements aps-environment development Debug, Profile
ios/Runner/RunnerRelease.entitlements aps-environment production Release
notifications:install writes both and points each configuration at its
own, so a project installed with this version or later starts out correct.
The Release twin is a copy of the development file, because every OTHER key
has to be identical between them.
Three cases it declines, and prints what to do instead:
- A project that already names a different entitlements file. Moving some configurations and not the rest is worse than moving none, so nothing is repointed. That includes every project a previous version of this installer touched: both files are written there, and the Release build setting is yours to point.
- A project whose configurations are none of Debug, Profile or Release.
Flavours ARE handled, by base name, so
Release-productiongets the twin. A name outside that shape is left alone, since nothing here can tell which profile it signs with. - A file it cannot read. An entitlements plist with no root
<dict>or one that does not parse, and aproject.pbxprojthe editor will not rewrite byte for byte. The install itself still succeeds and exits 0; only the entitlement wiring is left to you.
None of the three ends the install. They print a warning naming what stopped them and the manual step that clears it, because by the time this runs the rest of the install has already landed.
To do it by hand, set CODE_SIGN_ENTITLEMENTS = Runner/RunnerRelease.entitlements
on the Release configuration (Xcode: select the Runner target, Build Settings,
Code Signing Entitlements, expand the row, edit the Release line).
The trap that makes this worth a section: Xcode's Signing and Capabilities tab writes to whichever configuration is selected, and that is Debug, so a capability added the obvious way reaches every build except the one that ships.
notifications:doctor reads the value each configuration signs against and
names the ones a profile cannot use:
IOS: ✗ Needs attention
the Release configuration signs against ios/Runner/Runner.entitlements,
which declares aps-environment development where a distribution
provisioning profile carries production
Flavours are read by base name, so Release-production and Release-staging
are both judged as a Release: the suffix carries no weight of its own, because
aps-environment follows the provisioning profile and every flavour of a
release build signs with a distribution one. A configuration whose name is
neither a Debug, a Profile nor a Release is named as unchecked rather than
passed over, since nothing here can tell which environment it needs, and it is
named even when its siblings were checked.
What it costs to get wrong: the export fails with an opaque signing error, or
it succeeds and the app registers a sandbox APNs token. OneSignal then marks
the subscription notification_types: -30 (an APNs error) and every send to
that device goes nowhere.
2. .env is compiled into the app#
The installer registers .env as a Flutter asset, which is what lets
flutter_dotenv read it at runtime. It also means whatever sits in that file
at BUILD time is baked into the binary, and there is no later chance to
correct it.
A development .env usually points at a local API. On a phone localhost is
the phone, so the app reaches nothing, and if your Sentry DSN lives in the same
file then the failure is also unreported.
Magic.init takes the filename, so select it by build mode:
await Magic.init(
envFileName: kReleaseMode ? '.env.production' : '.env',
configFactories: [...],
);
Register both files as assets in pubspec.yaml when you do. The alternative is
a release script that swaps the file in, builds, restores it, and then reads
the built artifact back to confirm what actually shipped; either way the point
is that no gate catches this on your behalf.
3. The APNs key has to be on the OneSignal side#
Creating the .p8 in the Apple Developer portal is step one of two. Until it
is uploaded to OneSignal (Settings, Platforms, Apple iOS), OneSignal cannot
authenticate to APNs and marks every token it tries as invalid.
One key covers Sandbox and Production, it is team-wide, and it downloads exactly once. A key created for development work is the same key a release build needs; there is nothing to redo.
No CLI can check this for you: it is dashboard state behind an authenticated
API, and notifications:doctor reads your repository.
4. On the web, the dashboard's site type decides whether your worker config is read#
notifications.push.service_worker_path and service_worker_scope are sent to
the Web SDK as serviceWorkerPath and serviceWorkerParam. OneSignal's own
documentation states that serviceWorkerPath does not apply to a Typical
Site app; it applies to Custom Code. A dashboard app left on Typical Site
ignores both, registers its worker at the root scope, and then whichever of it
and flutter_service_worker.js registers second wins.
If web push works intermittently, or the app's own service worker stops updating, check the site type before the config.
The same split decides what a click does with a tab that is already open. On
Custom Code the driver initialises the SDK with
notificationClickHandlerMatch: 'origin' and
notificationClickHandlerAction: 'focus': a click focuses any open tab of the
app and hands it the click, so Notify.onPushClicked fires there and your
deep link handler routes the push. With no tab open, the browser opens the
push's web_url. The SDK's defaults (exact, navigate) would ignore every
open tab whose url is not the launch url exactly and open a second tab, where
no Dart is running yet to receive the click. A Typical Site app reads this
from the dashboard's web settings instead; set the same pair there.
Telling your backend whether the device can be paged#
Everything above decides whether a push CAN arrive. Whether your backend knows
it is a separate question: OneSignal accepts a push for a subscription that is
denied, opted out or gone, and reports nothing back. If your backend escalates
on push alone, Notify.pushState gives it the device's own answer. Configure
both endpoints (see Laravel Backend Setup
for the server half), arm the watch once, and release before the token goes:
// In a provider's boot(), once auth is registered.
Notify.pushState.watch();
// On sign-out, BEFORE Auth.logout(): a release after it is a guaranteed 401.
if (Notify.pushState.isConfigured) await Notify.pushState.release();
await Auth.logout();
watch() reports after every identity reconcile for the signed-in person
(<external_id_prefix><Auth.id()>, user_ by default), whenever the driver's
permission or subscription moves, and once as unavailable for a build with no
push driver at all. A memo stops repeats and is forgotten on every
AuthLogout. Skip the release and the server keeps vouching for a handset the
person has left, under their name, until its own freshness horizon expires.
Before you submit#
dart run <app>:artisan notifications:doctoris clean, including the iOS configuration rows.- If your backend reads device reachability, a sign-out from the release build
posts to
notifications.push_state.release_pathbefore the token is gone. - The
.envinside the built artifact is the production one. Unzip the.ipaand readPayload/*.app/Frameworks/App.framework/flutter_assets/.env. - The signed binary carries the production entitlement:
codesign -d --entitlements :- Payload/*.appreportsaps-environment: production. - A push sent to the build's own subscription arrives on a device that installed it from TestFlight, not from Xcode.