Skip to main content

Version Signature System

Status: Production-ready version tracking infrastructure Last Updated: 2025-11-21 Aligned with: CLAUDE.md Project Values, Best-in-class versioning practices

Table of Contents


Overview

What is a Version Signature?

A Version Signature is a unique identifier that captures the exact state of your app at any given time. It consolidates:
  • App Version (1.0.3) - Semantic version from app.json
  • Build Number (36) - iOS build number or Android version code
  • Git Commit (a3f2c1b) - The commit the binary was built from
  • OTA Update (→OTA:def) - If running an over-the-air update
  • Runtime Metadata - Branch, commit message, download time for OTA

Why We Built This

Problem:
  • Users report bugs, but we don’t know which version they’re on
  • OTA updates change the code without changing version numbers
  • Multiple sources of truth (app.json, git, EAS, Expo Updates)
  • Difficult to correlate bug reports with specific code states
Solution:
  • Single consolidated “Signature” that shows everything
  • User-friendly display in Settings and optional footer
  • Agent-friendly JSON structure in console logs
  • Automatic Sentry tagging for error correlation

Versioning Strategy

Semantic Versioning

We use standard semantic versioning with clear delivery tracking:
VERSION (BUILD:OTA)
  │      │     │
  │      │     └─ OTA commit hash (what's running)
  │      └─────── Build number (native binary)
  └────────────── Semantic version (what the app is)

MAJOR.MINOR.PATCH
  │     │     │
  │     │     └─ Bug fixes, minor changes
  │     └─────── New features, backwards-compatible
  └───────────── Breaking changes, major milestones

Version Components

Runtime Version

The runtimeVersion in app.json defines OTA compatibility groups:
  • Native builds with the same runtimeVersion can receive the same OTA updates
  • Only change when native code (dependencies, modules) changes

App Version

The version in app.json and package.json is the display version:
  • Follows the MAJOR.MINOR.PATCH.OTA format
  • Shows hierarchy: version tells you which runtime it belongs to

Version Bumping Rules

For OTA Updates (JS/TS changes only)

Increment the 4th digit:
1.0.3.1 → 1.0.3.2 → 1.0.3.3
Keep runtimeVersion unchanged:
{
  "version": "1.0.3.2",
  "runtimeVersion": "1.0.3"
}
When to use:
  • UI changes
  • Business logic updates
  • Bug fixes in JavaScript/TypeScript
  • Design system changes
  • Flow engine updates

For Native Builds (Native code changes)

Increment MAJOR/MINOR/PATCH and reset OTA to 0:
1.0.3.3 → 1.0.4.0 (patch bump)
1.0.3.3 → 1.1.0.0 (minor bump)
1.0.3.3 → 2.0.0.0 (major bump)
Update runtimeVersion to match the base version:
{
  "version": "1.0.4.0",
  "runtimeVersion": "1.0.4"
}
When to use:
  • New native dependencies
  • Expo SDK upgrades
  • Native module changes
  • New App Store/TestFlight submission required

Examples

Scenario 1: JavaScript Bug Fix

Current: 1.0.3.0 (runtime: 1.0.3, build 36)
Action:  Fix emotion picker state bug
Result:  1.0.3.1 (runtime: 1.0.3, build 36)
Deploy:  OTA update only

Scenario 2: Multiple OTA Updates

1.0.3.0 → Initial build 36 release
1.0.3.1 → Fix emotion picker (OTA)
1.0.3.2 → Update design system (OTA)
1.0.3.3 → Add debug features (OTA)

Scenario 3: Native Dependency Change

Current: 1.0.3.3 (runtime: 1.0.3, build 36)
Action:  Upgrade Sentry SDK (native module)
Result:  1.0.4.0 (runtime: 1.0.4, build 37)
Deploy:  New TestFlight/App Store build required

Benefits

Clear hierarchy - Version tells you which runtime it belongs to
OTA tracking - Easy to see how many OTAs on each build
Predictable - Simple rules for bumping versions
Compatible - Works seamlessly with Expo OTA system
Debuggable - Version signature shows full picture

Version Signature Display

With this strategy, signatures are self-documenting:
1.0.3.1-36-a3f2c1b→OTA:def
   ↑   ↑
   │   └─ Build 36 is on runtime 1.0.3
   └───── This is the 1st OTA update on 1.0.3

Quick Start

View Your Signature

  1. In Settings (Always visible):
    • Open Settings
    • Scroll to bottom
    • See: Signature: 1.0.3-36-a3f2c1b
  2. Enable Footer Display:
    • Settings → Debug Mode → Enable Debug Mode
    • Toggle “Show Version Signature” ON
    • Small text appears at bottom of all screens
  3. Enable Verbose Mode:
    • After enabling footer
    • Toggle “Verbose Signature” ON
    • See 2-line display with OTA details

Check Signature in Code

import { getVersionSignature } from "@/lib/versionSignature";

const sig = await getVersionSignature();
console.log(sig.compact); // "1.0.3-36-a3f2c1b→OTA:def"

Understanding the Signature

Compact Format

1.0.3-36-a3f2c1b→OTA:def
  ↓    ↓    ↓       ↓
  |    |    |       OTA Update ID (first 3 chars)
  |    |    Binary git commit (7 chars)
  |    Build number
  App version

Color Coding (Visual)

When displayed in the app, each component has a distinct color:
  • Version (1.0.3) - Blue
  • Build (36) - Gray
  • Commit (a3f2c1b) - Purple
  • OTA (→OTA:def) - Orange
  • Branch (production) - Green
  • Message ("Fix bug") - Light gray
  • Time (2h ago) - Lighter gray

Three States

1. Embedded Bundle (Fresh Install)
1.0.3-36-a3f2c1b
Running the original binary, no OTA updates applied. 2. OTA Update Applied
1.0.3-36-a3f2c1b→OTA:def
Binary built from a3f2c1b, but JavaScript running from OTA update def. 3. Development Mode
1.0.3-dev-local
Running on Metro bundler, live reloading.

Signature Formats

Line 1:
1.0.3-36-a3f2c1b→OTA:def
Line 2 (Verbose mode):
production • xyz789 • "Fix emotion picker" • 2h ago

Detailed (Settings Modal)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Binary Information
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Version: 1.0.3 (Build 36)
Commit: a3f2c1b
Built: Jan 21, 2025 10:43 AM
Channel: production

━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Runtime Status
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Running: OTA Update
Branch: production
Commit: xyz789
Message: "Fix emotion picker bug"
Published: Jan 21, 2025 2:30 PM
Downloaded: Jan 21, 2025 3:15 PM

🔄 Newer update available
   → Restart to apply

Console (Debug Mode)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🔐 Version Signature
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Compact: 1.0.3-36-a3f2c1bOTA:def
OTA: productionxyz789"Fix bug" • 2h ago
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Full Details: {
  "compact": "1.0.3-36-a3f2c1b→OTA:def",
  "binary": {
    "version": "1.0.3",
    "build": "36",
    "commit": "a3f2c1b",
    "builtAt": "2025-01-21T10:43:00Z",
    "channel": "production"
  },
  "runtime": {
    "type": "ota",
    "ota": {
      "updateId": "def456abc...",
      "commit": "xyz789",
      "branch": "production",
      "message": "Fix emotion picker bug",
      "publishedAt": "2025-01-21T14:30:00Z",
      "downloadedAt": "2025-01-21T15:15:00Z",
      "timeAgo": "2 hours ago"
    }
  },
  "updateAvailable": {
    "available": false
  },
  "timestamp": "2025-01-21T17:43:00Z"
}
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Architecture

Timing Complexity

Version components are set at different times: Build Time (Static):
  • App version, build number, git commit
  • Baked into binary, never changes for this build
Runtime (Dynamic):
  • OTA update detection
  • Download time tracking
  • Update availability checking

Data Flow

┌──────────────────────────────────────────────────────────────┐
│  EAS Build (Binary)                                           │
│  ├─ App Version: app.json → 1.0.3                            │
│  ├─ Build Number: app.json → 36                              │
│  └─ Git Commit: EAS_BUILD_GIT_COMMIT_HASH → a3f2c1b          │
└──────────────────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────────────────┐
│  User Installs Binary                                         │
│  Signature: 1.0.3-36-a3f2c1b                                 │
└──────────────────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────────────────┐
│  OTA Update Published                                         │
│  ├─ Update ID: EAS generates → def456abc                     │
│  ├─ Commit: scripts/publish-ota.sh → xyz789                  │
│  ├─ Message: git log → "Fix emotion picker"                  │
│  └─ Branch: production                                        │
└──────────────────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────────────────┐
│  User Receives OTA (App restart)                             │
│  ├─ Download tracked in AsyncStorage                         │
│  └─ Signature: 1.0.3-36-a3f2c1b→OTA:def                     │
└──────────────────────────────────────────────────────────────┘

Key Insight: Two Commits

When running an OTA update, you have TWO commits:
  1. Binary Commit (a3f2c1b) - Native code
  2. OTA Commit (xyz789) - JavaScript code
The signature shows both:
  • Base: 1.0.3-36-a3f2c1b (binary)
  • Arrow: →OTA:def (runtime JavaScript from commit xyz789)

Metadata Injection (How Signatures Get Populated)

📦 To publish app updates, use the /publish skill. Publishing Workflow: See docs/skills/publish/publish.md for complete publishing instructions.

What Metadata Gets Injected

When you publish using /publish (which uses scripts/publish-ota.sh), the following metadata is automatically injected into the update:
  • Current git commit (short & full hash)
  • Commit message from git log
  • Branch name (e.g., main, production)
  • Publish timestamp (when the update was published)
  • Author name (git commit author)
  • Version from package.json
  • Optional audience tag (for targeted updates)
This metadata appears in the app’s version signature (visible in Settings → Debug Mode → Version Signature) for debugging and tracking which code version is running. Example: If you see →OTA:def in a signature, you can look up the full metadata to see:
  • Which commit the OTA update was built from
  • When it was published
  • What branch it came from
  • The commit message
This helps correlate bug reports with specific code states.

Usage Guide

Enable Signature Display

Settings → Debug Mode:
  1. Toggle “Enable Debug Mode” ON
  2. Toggle “Show Version Signature” ON
  3. (Optional) Toggle “Verbose Signature” ON for 2-line display

Read a Signature

Example: 1.0.3-36-a3f2c1b→OTA:def What it tells you:
  • App version 1.0.3 (semantic version)
  • Build 36 (iOS build number from TestFlight)
  • Binary from commit a3f2c1b (git commit)
  • Running OTA update def (update ID prefix)
To investigate:
# Look up binary commit
git show a3f2c1b

# Look up OTA commit (from verbose mode or Settings)
git show xyz789

View Full Details

  1. Settings → Debug Mode → “Show Version Signature” (ON)
  2. Tap “Full Signature Details”
  3. See complete breakdown with copy options

Using in Bug Reports

User workflow:
  1. User enables Debug Mode (Settings)
  2. Toggles “Show Version Signature”
  3. Takes screenshot
  4. Sends to support
Support workflow:
  1. Read signature from screenshot
  2. Look up commits in git
  3. Check if bug is fixed in newer version
  4. Guide user to update if needed

Integration Examples

In Components

import { getVersionSignature } from "@/lib/versionSignature";

function MyComponent() {
  useEffect(() => {
    const logVersion = async () => {
      const sig = await getVersionSignature();
      console.log("Component mounted with signature:", sig.compact);
    };
    logVersion();
  }, []);
}

In API Calls

import { getCompactSignature } from "@/lib/versionSignature";

async function apiCall() {
  const response = await fetch("/api/endpoint", {
    headers: {
      "X-App-Signature": getCompactSignature(), // Sync call
    }
  });
}

In Error Handling

try {
  await riskyOperation();
} catch (error) {
  logger.error("operation", "Failed", {
    error: error.message,
    signature: getCompactSignature() // Automatically included in Sentry
  });
}

In Sentry (Automatic)

Version signature is automatically added to:
  • All Sentry events (as context)
  • All breadcrumbs (from logger)
  • All error reports
Query in Sentry:
signature:"1.0.3-36-a3f2c1b→OTA:def"

Troubleshooting

Issue: Signature shows “unknown” for commit

Cause: Binary built without git commit injection Solution:
  • Ensure eas.json has EXPO_PUBLIC_GIT_COMMIT_HASH env var
  • Rebuild with EAS Build
  • For local builds, commit hash won’t be available (shows “local”)

Issue: OTA commit not showing

Cause: OTA published without metadata Solution: Use the provided script:
bun run publish:ota:prod "Your message"
Don’t use raw eas update command without metadata.

Issue: Download time not tracking

Cause: AsyncStorage permission or first-time launch Solution:
  • Check AsyncStorage permissions
  • Download time tracked on first launch with OTA
  • May show current time if storage fails
Cause: SafeArea insets not properly applied Solution:
  • Footer uses useSafeAreaInsets().bottom
  • Should appear below tab bar
  • If blocking UI, toggle footer OFF

Issue: Signature not in Sentry events

Cause: Sentry context not set on app start Solution:
// In App.tsx, check this code exists:
const sig = await getVersionSignature();
setCustomContext("version_signature", sig.json);

Best Practices

1. Always Use the Script for OTA

# ✅ Good
bun run publish:ota:prod "Fix emotion picker"

# ❌ Bad (no metadata)
eas update --branch production --message "Fix"

2. Write Descriptive OTA Messages

# ✅ Good
bun run publish:ota:prod "Fix emotion picker state persistence bug"

# ❌ Bad
bun run publish:ota:prod "fix"

3. Check Signature Before Debugging

Always ask users for their signature first:
"What's your Signature? (Settings → scroll to bottom)"

4. Include Signature in Bug Reports

Create GitHub issues with:
## Bug Report

**Signature:** `1.0.3-36-a3f2c1b→OTA:def`

**Description:**
...

5. Log Signature on Critical Operations

logger.info("critical-operation", "Starting", {
  signature: getCompactSignature()
});

Advanced Features

Update Detection

The signature system automatically checks for new OTA updates:
const sig = await getVersionSignature();

if (sig.updateAvailable.available) {
  console.log("New update available:", sig.updateAvailable.message);
  // Show indicator to user
}

Cache Invalidation

Signature is cached for 5 seconds. To force refresh:
import { invalidateCache, getVersionSignature } from "@/lib/versionSignature";

invalidateCache();
const freshSig = await getVersionSignature();

Custom Audience Targeting

Publish OTA to specific user groups:
bash scripts/publish-ota.sh production "Beta feature" "beta-testers"
Users in the beta-testers audience will see:
production • xyz789 • "Beta feature" • 1h ago • (beta-testers)

File Reference

Core Files

FilePurposeLines
src/lib/versionSignature.tsSignature generation logic350
src/components/VersionFooter.tsxVisual display component140
src/state/debugStore.tsDebug toggles (modified)230
src/screens/SettingsScreen.tsxSettings UI (modified)580
App.tsxIntegration point (modified)171
scripts/publish-ota.shOTA publishing workflow70

Configuration Files

FilePurpose
eas.jsonGit commit injection (env vars)
app.jsonBinary metadata (extra field)
package.jsonOTA publish scripts

Quick Reference

Common Commands

# Check what signature current code will have
git rev-parse --short HEAD

# Publish OTA with metadata
bun run publish:ota:prod "Your message"
bun run publish:ota:preview "Preview update"
bun run publish:ota:dev "Dev update"

# Build new binary (increments build number)
eas build --profile production --platform ios

# Check current OTA status
eas update:list --branch production

Support Checklist

When user reports a bug:
  1. ✅ Ask for Signature
  2. ✅ Look up commits in git
  3. ✅ Check if bug is fixed in newer commit
  4. ✅ Guide user to update if needed
  5. ✅ Check Sentry for related errors with same signature


Maintained by: Development Team
Questions? Check CLAUDE.md or Slack #dev-support