Deployment
Apps (static export)
npm run build tracking # or: npm run build portal
Each app's next.config.ts sets output: "export" in production, emitting a
fully static site to the app's out/. Any static host can serve it — no Node
server required.
TODO: there is no committed app hosting/CI deploy config yet. Document the chosen host and pipeline here once it exists.
Native (Capacitor)
The tracking app's static export is wrapped by Capacitor for iOS/Android
(apps/tracking/capacitor.config.ts, apps/tracking/ios, apps/tracking/android).
Build output is copied into the native shell via cap copy / Xcode; native
build artefacts are git-ignored, the shells are committed.
TODO: document the exact
capcommand sequence and signing steps for an iOS release build. The Android release flow is documented below.
Android → Play Store (GitLab CI)
The Capacitor app ships to the existing Play listing of the former native
app — applicationId is com.enode.enodeone (immutable on Play; the code
namespace stays ai.enode.tracking, see
apps/tracking/android/app/build.gradle).
The pipeline (.gitlab-ci.yml) has two manual jobs, both running the same
chain npm run build tracking → source-map guard → npx cap sync android →
Gradle:
| Job | Branches | Effect |
|---|---|---|
build_android | any | Signed AAB as CI artifact — no Play access. Dry run for the whole build chain. |
publish_android_internal | main only | Builds and uploads the AAB to the Play internal-testing track. |
Backend selection (which API server a native build talks to)
The backend base URL is frozen into the static web export at build time
(NEXT_PUBLIC_API_SERVER / NEXT_PUBLIC_API_BASE_URL, default develop —
single source: packages/core/src/api/servers.ts). Since the native shells
only package the pre-built out/, each shell injects
window.__ENODE_API_SERVER__ before any bundled JS runs, which overrides the
baked default:
- Android —
BuildConfig.ENODE_API_SERVER, set per build type inapps/tracking/android/app/build.gradleand injected byMainActivityviaWebViewCompat.addDocumentStartJavaScript. Release builds (incl. the CI jobs below) always point atproduction; debug builds use the baked default (develop) unless repointed with-PenodeApiServer=<name>or theENODE_API_SERVERenv var at Gradle time — no web rebuild needed. - iOS —
ENODE_API_SERVERfrom the Xcode run scheme (dev runs) or the per-configuration Info.plist value (Release =production), injected as aWKUserScript(ios/App/App/AppViewController.swift).
Publishing uses the
Gradle Play Publisher
plugin. versionCode is 3200 + CI_PIPELINE_IID, set in app/build.gradle;
versionName is maintained by hand. The internal track needs no Google review;
promotion to beta/production happens manually in the Play Console and goes
through review from the closed track onward.
Why the versionCode is not resolved from Play
The plugin's resolutionStrategy = AUTO (highest code on Play + 1) was removed
because it deadlocks against the DRAFT release status used below. Play burns a
versionCode permanently the moment an artifact is uploaded with it — deleting
the draft does not release it — but AUTO only sees codes currently held in
a track. So once a publish fails after upload, every rerun resolves that same
burned code and Play rejects it with:
Concurrent uploads for app com.enode.enodeone (version code already used).
Despite the wording, this is not a concurrency problem, and the failure repeats
forever because AUTO cannot observe the code it needs to skip. Deriving the
code from the pipeline counter instead makes it monotonic and independent of
Play's state. If a draft is still open, clicking Start rollout also resolves
it — deleting the draft does not.
The 3200 base clears every code used on the listing so far. Raise it only:
lowering it would collide with an already-burned code.
Release flow
- Merge to
main. - Start
publish_android_internalvia the ▶ button in the pipeline. - The upload lands as a draft on the internal track (
releaseStatus = DRAFTinapp/build.gradle) — nothing reaches the internal testers yet. Review it in the Play Console and click Start rollout to release it to the internal-testing track. - Test via the internal-testing track, then promote the release in the Play Console (Releases → promote).
- Bump
versionNamewhen the next marketing version begins.
Signing & credentials
The AAB must be signed with the upload key of the existing listing. The
keystore is not committed: CI decodes it from UPLOAD_KEYSTORE_BASE64 into
apps/tracking/android/upload-keystore.jks (git-ignored); the passwords come
from masked CI variables (UPLOAD_KEYSTORE_PASSWORD, UPLOAD_KEY_ALIAS,
UPLOAD_KEY_PASSWORD). The Play service account JSON arrives base64-encoded in
PLAY_SERVICE_ACCOUNT_JSON_BASE64 and is passed to the plugin via the
ANDROID_PUBLISHER_CREDENTIALS env var. All five variables are protected
(GitLab → Settings → CI/CD → Variables); main is a protected branch, so
feature-branch pipelines never see them.
Local signed builds work the same way: copy the upload keystore to
apps/tracking/android/upload-keystore.jks and set the three UPLOAD_*
password/alias env vars before ./gradlew :app:bundleRelease. Without the
file, release builds are unsigned (debug builds are unaffected).
Source maps
The CI aborts if apps/tracking/out contains *.map files: source maps in
the APK would expose the readable app source. Next.js has
productionBrowserSourceMaps off by default — the guard only protects against
a future config change.
Documentation site → Cloudflare Pages
The docs website is built separately from the apps:
npm run docs:build # → docs-site/build
npm run docs:serve # preview at http://localhost:4000
The built site is a single-page app and must be served over HTTP — opening
docs-site/build/index.htmlas afile://URL will not work.
It deploys to Cloudflare Pages from main via the dashboard Git
integration — Cloudflare builds the site itself on every push, with no secrets
stored in the repo. Build settings come from wrangler.toml (project
enode-tracking-docs, output docs-site/build) and .nvmrc (Node 22).
One-time setup
The deploy config (wrangler.toml, .nvmrc, the docs build) must be on main
first — Cloudflare builds the branch you connect.
- Merge the documentation setup into
main. - Cloudflare dashboard → Workers & Pages → Create → Pages → Connect to Git, select this repository.
- Configure the build:
- Production branch:
main - Build command:
npm run docs:build - Build output directory:
docs-site/build - Root directory:
/(repo root) - Environment variable:
NODE_VERSION=22(or rely on.nvmrc). If the build can't find the dev dependencies, also setNPM_FLAGS=--include=dev.
- Production branch:
- Save and Deploy. Every push to
mainnow builds and deploys automatically; pull requests get preview URLs.
After the first deploy
- The site is live at
https://enode-tracking-docs.pages.dev. - For a custom domain, attach it in Cloudflare and set the
DOCS_URLenv var to the final URL (baseUrlstays/).
Cloudflare builds TypeDoc + Storybook + Docusaurus in one
npm run docs:buildrun (a few minutes). The build is not coupled to GitHub Actions —docs-checkstill runs on PRs as the quality gate, but it does not deploy.