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.
Portal hosting (Vercel)
The portal is hosted on Vercel through its Git integration. The project settings live in the Vercel dashboard; the repo holds no deploy config.
| Host | Deployment |
|---|---|
https://portal.enode.ai | production, built from the release branch (main is merged into release to ship) |
https://portaldev.enode.ai | development, behind Vercel deployment protection |
TODO: confirm which branch
portaldev.enode.aibuilds from. It sits behind deployment protection and is configured only in the Vercel dashboard.
Vercel builds from a shallow clone without tags. A build step that needs git history or tags cannot rely on them there. That is why the portal's version stamp comes from the built commit alone (see "Versioning" below).
TODO: the tracking web app has no hosted web deployment documented. It ships inside the native shells (see below).
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 comes from the git tag (see "Versioning" below). 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.
Versioning
No file in the tree carries a version number. Every build resolves its version from git, so Android, iOS and the web bundles cannot drift apart, and no release needs a "bump the version" commit.
| Version | Build number | Resolved by | |
|---|---|---|---|
| Android | CI_COMMIT_TAG (v3.0.3 → 3.0.3) | 3200 + CI_PIPELINE_IID | the pipeline |
| iOS | nearest v* tag | git rev-list --count HEAD | ios/scripts/set-version.sh, a Run Script build phase |
| Tracking web bundle | nearest v* tag | commits since it | scripts/version.mjs, at next build |
| Portal | date of the built commit (2026.09.22) | short hash of the built commit (e42a1a2) | scripts/version.mjs, at next build |
The tracking app displays 3.0.3 (147), the portal 2026.09.22 (e42a1a2).
Why tracking uses git tags
The tracking app is a store app. Play and the App Store need a real release
version and a build number that rises monotonically over the app's whole
lifetime. The v* tag declares that release version.
Why the portal uses the built commit
The portal is a website. Every user always runs the latest deploy, so a release number would answer no question. A support report only needs to identify the exact code that was running. The commit hash does that, and the commit date makes it readable at a glance.
The commit stamp needs no tag and no git history, only the checked-out commit.
Vercel builds from a shallow clone without tags, where the former portal-v*
tag lookup could only fail: every deploy showed dev. The existing portal-v*
tags are no longer read.
The date is the committer date. For a merge it is when the change landed,
and a rebased commit keeps its old author date. The hash is always cut to seven
characters: git rev-parse --short lengthens it when a prefix is ambiguous, so
a full clone and Vercel's shallow clone would otherwise stamp the same commit
differently.
Version and build are separate values
NEXT_PUBLIC_APP_VERSION and NEXT_PUBLIC_APP_BUILD stay apart outside the
UI, and only useAppVersion joins them for display. The native app's
app-version request header must carry the bare 3.0.3. A display string with
spaces and brackets has no business in a header.
The portal sends no app-version header. A browser must not send it: the header
is not in the API's CORS allow-list (see packages/core/src/api/client.ts). The
portal's version reaches only the UI and the support report.
Fallback when no version can be resolved
Without a reachable tag (tracking) or without a git checkout (portal), the
version is dev (web) or 0.0.0-dev (Android). These values are deliberately
implausible, because such a build is never published. scripts/version.mjs
prints a [version] warning to the build log explaining the fallback.
Native builds show the installed version
On a device the app shows the installed native version, read from the
platform by useAppVersion (@enode/core/use-app-version), not the version
compiled into the web bundle. Those differ whenever a bundle outlives its shell,
and only the native one identifies an actual upload. When no version can be
determined the footer omits the line rather than inventing one.
The iOS build phase runs
"${SRCROOT}/../scripts/set-version.sh"— the../becauseApp.xcodeprojlives inios/Appwhile the script sits inios/. It requiresENABLE_USER_SCRIPT_SANDBOXING = NOand must sit after "Copy Bundle Resources". It fails the build unlessHEADis exactly on av*tag — archive from the tag (git checkout v3.0.3), not frommain, which may have moved past it.
Release flow
- Merge to
main. - Tag that commit
vX.Y.Zand push the tag. The push creates the pipeline —publish_android_internalruns on tag pipelines only. - Start
publish_android_internalvia the ▶ button in the pipeline. It stays manual on purpose: the tag declares the version, uploading it to Play is a separate decision, and Play burns theversionCodepermanently. - 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).
- For iOS, check out the same tag and archive in Xcode — the build phase stamps the version from it.
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.