Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/android-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ jobs:
--key-data-v2 "$CAPGO_PRIVATE_KEY" \
--path ./out \
--bundle "$VERSION" \
--auto-min-update-version \
--min-update-version "$VERSION" \
--version-exists-ok \
--comment "${GITHUB_SHA:0:7} — auto-published with the $VERSION native release"
CURRENT="$(npx @capgo/cli@8.42.4 channel currentBundle production \
Expand Down
17 changes: 16 additions & 1 deletion .github/workflows/capgo-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,20 @@ jobs:
echo "name=$VERSION" >> "$GITHUB_OUTPUT"
echo "Uploading as bundle version $VERSION"

# The bundle is built from the current tree, so it targets the newest
# native shell. --auto-min-update-version only copies the previous
# bundle's floor forward, and this checkout has no native version
# stamped on disk (package.json says 1.0.53), so the floor never rose
# past the first upload. Resolve it from the release tags instead and
# fail closed when none is visible. Keep in lockstep with the release
# lanes, which pin the floor to the binary they ship.
- name: Resolve native floor
id: native_floor
run: |
FLOOR="$(node scripts/release-version.mjs native-floor)"
echo "name=$FLOOR" >> "$GITHUB_OUTPUT"
echo "Bundle requires native $FLOOR or newer"

- name: Upload bundle to Capgo
# Commit message via env, never inline — a multi-line message (or one
# with quotes) injected into the run script breaks --comment quoting.
Expand All @@ -201,6 +215,7 @@ jobs:
COMMIT_MSG: ${{ github.event.head_commit.message }}
CHANNEL: ${{ steps.channel.outputs.name }}
VERSION: ${{ steps.version.outputs.name }}
NATIVE_FLOOR: ${{ steps.native_floor.outputs.name }}
run: |
echo "Uploading bundle $VERSION to channel $CHANNEL"
COMMENT="${GITHUB_SHA:0:7} — $(printf '%s' "${COMMIT_MSG:-Manual deploy}" | head -n1)"
Expand All @@ -210,7 +225,7 @@ jobs:
--key-data-v2 "$CAPGO_PRIVATE_KEY" \
--path ./out \
--bundle "$VERSION" \
--auto-min-update-version \
--min-update-version "$NATIVE_FLOOR" \
--version-exists-ok \
--comment "$COMMENT"

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ios-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,7 @@ jobs:
--key-data-v2 "$CAPGO_PRIVATE_KEY" \
--path ./out \
--bundle "$VERSION" \
--auto-min-update-version \
--min-update-version "$VERSION" \
--version-exists-ok \
--comment "${GITHUB_SHA:0:7} — auto-published with the $VERSION native release"
CURRENT="$(npx @capgo/cli@8.42.4 channel currentBundle production \
Expand Down
13 changes: 10 additions & 3 deletions docs/NATIVE-RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,9 +310,16 @@ own `out/` under the binary's versionName, then assert the channel serves it.
environment has no protection rules, so the run ships immediately. Adding required
reviewers under Settings → Environments → Production makes it queue for approval with no
workflow change (needs repo admin).
- **Native-version gating:** `--auto-min-update-version` (already set) keeps a JS bundle
built against new plugins off older native shells. **Bump the native version whenever
you change plugins/native code**, then ship that via Play — OTA can't.
- **Native-version gating:** every upload passes an explicit `--min-update-version`, so a
JS bundle built against new plugins stays off older native shells. The release lanes pin
it to the binary they ship; `capgo-deploy.yml` resolves it from the newest `v<major>.<build>.0`
tag (`scripts/release-version.mjs native-floor`) and fails if none is visible. It replaced
`--auto-min-update-version`, which only copies the previous bundle's floor forward — with no
native version stamped on the `dev` checkout (package.json says 1.0.53) the floor never
rose past the first upload, and the CLI refuses the two flags together. Capgo only enforces
the floor when the channel's "disable auto update" strategy is set to *version number*.
**Bump the native version whenever you change plugins/native code**, then ship that via
Play — OTA can't.
- **Staged rollout:** roll production OTA to ~10% → watch Sentry/crash + error rates →
100%. Don't 100% every merge.
- **Rollback** is configured in `capacitor.config.ts` (`appReadyTimeout: 15000` +
Expand Down
26 changes: 26 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -133,8 +133,19 @@ const RESTRICTED_SYNTAX_BASE = [
message:
'Never return a Capacitor plugin object across an await/then boundary — resolving a promise with it probes .then, which the plugin proxy turns into a native call that never settles the promise. Wrap it: `return { Plugin }` and destructure at the call site. See src/utils/crisp.ts and src/utils/auth-token.ts.',
},
{
// The --safe-* tokens (globals.css) are the only place the Android < 15
// zeroing and Capacitor's native inset injection land; a raw env() read
// paints the phantom status-bar band those exist to remove.
selector:
':matches(Literal[value=/env\\(safe-area-inset-/], TemplateElement[value.raw=/env\\(safe-area-inset-/])',
message:
"Don't read env(safe-area-inset-*) directly — use var(--safe-top|--safe-right|--safe-bottom|--safe-left) (or the pt-safe-top / pb-safe-bottom utilities) from globals.css. The tokens carry the Android < 15 zeroing and the native inset injection; env() bypasses both. The only legal raw read is the diagnostic at src/app/(mobile-ui)/dev/safe-area/page.tsx.",
},
]

const SAFE_AREA_ENV_SELECTOR = 'safe-area-inset'

module.exports = [
{
ignores: [
Expand Down Expand Up @@ -275,6 +286,21 @@ module.exports = [
],
},
},
{
// The safe-area diagnostic renders raw env() next to the tokens on
// purpose; the DS token dump is generated from globals.css.
files: [
'src/app/(mobile-ui)/dev/safe-area/page.tsx',
'src/app/(mobile-ui)/dev/ds/foundations/tokens.generated.ts',
],
rules: {
'no-restricted-syntax': [
'error',
...RESTRICTED_SYNTAX_BASE.filter((r) => !r.selector.includes(SAFE_AREA_ENV_SELECTOR)),
...QUERY_STRING_PUSH_RESTRICTIONS,
],
},
},
{
// DS 10 ratchet allowlist — do not add files; migrate to nuqs instead
// (remove entries as files migrate). These files imported useSearchParams
Expand Down
10 changes: 10 additions & 0 deletions instrumentation-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import posthog from 'posthog-js'
import { beforeSendHandler } from './sentry.utils'
import { inferSentryEnvironment } from '@/utils/sentry-env'
import { withoutBrowserTracing } from '@/utils/sentry-integrations'
import { posthogErrorMirror } from '@/utils/sentry-posthog-mirror'
import { whenIdle } from '@/utils/defer-analytics'
import { installPaymentNetworkGoogleAnalyticsGuard, isPaymentNetworkExplorerPath } from '@/utils/private-routes'

Expand Down Expand Up @@ -110,8 +111,14 @@ if (
// and that instrumentation overhead is visible jank in the WebView.
sampleRate: 1.0,
tracesSampleRate: 0,
// Synthesizes a stack for message events (captureConsole on a
// non-Error, the explicit captureMessage calls) so they attribute
// to a call site — see the web init in sentry-init.ts.
attachStacktrace: true,
beforeSend: (event) =>
isPaymentNetworkExplorerPath(window.location.pathname) ? null : beforeSendHandler(event),
beforeSendTransaction: (event) =>
isPaymentNetworkExplorerPath(window.location.pathname) ? null : event,
// A WebView that can't reach the bundler can't reach ingest either,
// so the report of the failure died with the session. The offline
// transport parks undeliverable envelopes in IndexedDB and flushes
Expand All @@ -121,6 +128,9 @@ if (
integrations: (defaults) => [
...withoutBrowserTracing(defaults),
Sentry.captureConsoleIntegration({ levels: ['error'] }),
// Same PostHog $exception mirror as the web init, so native
// errors keep their session-replay correlation.
posthogErrorMirror(),
],
})

Expand Down
8 changes: 4 additions & 4 deletions scripts/__tests__/native-build-scan.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ const Module = require('module')

const SCRIPT_PATH = path.join(__dirname, '..', 'native-build.js')

// native-build.js is a script, not a module: it calls main() at import time and
// exports nothing. Load the real source with that call stripped so the scan
// helpers can be asserted against the actual app tree.
// native-build.js only exports ITEMS_TO_DISABLE. Load the real source with the
// entrypoint stripped so the scan helpers can be asserted against the actual
// app tree.
function loadScriptInternals() {
const source = fs.readFileSync(SCRIPT_PATH, 'utf-8')
const withoutEntrypoint = source.replace(/\nmain\(\)\s*$/, '\n')
const withoutEntrypoint = source.replace(/\nif \(require\.main === module\) main\(\)\s*$/, '\n')
expect(withoutEntrypoint).not.toBe(source)

const exposed =
Expand Down
6 changes: 3 additions & 3 deletions scripts/__tests__/native-env-check.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@ const Module = require('module')

const SCRIPT_PATH = path.join(__dirname, '..', 'native-build.js')

// native-build.js is a script, not a module: it calls main() at import time and
// exports nothing. Load the real source with that call stripped so the env check
// native-build.js is a script first: it calls main() when run directly and only
// exports ITEMS_TO_DISABLE. Load the real source with that call stripped so the env check
// can be asserted against the list the build actually enforces.
function loadScriptInternals() {
const source = fs.readFileSync(SCRIPT_PATH, 'utf-8')
const withoutEntrypoint = source.replace(/\nmain\(\)\s*$/, '\n')
const withoutEntrypoint = source.replace(/\nif \(require\.main === module\) main\(\)\s*$/, '\n')
expect(withoutEntrypoint).not.toBe(source)

const exposed =
Expand Down
22 changes: 22 additions & 0 deletions scripts/__tests__/release-version.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,28 @@ describe('release version resolver', () => {
})
})

describe('native-floor', () => {
// Only v<major>.<build>.0 tags are native releases: OTA tags, another major
// and the off-scheme v2026.02.26 on main must not become a floor.
it('picks the newest native release tag', () => {
const result = run(repo('1.0.53', { tags: ['v1.4.0', 'v1.4.2', 'v1.3.0', 'v2.1.0', 'v2026.02.26'] }), [
'native-floor',
])

expect(result.status).toBe(0)
expect(result.stdout.trim()).toBe('1.4.0')
})

// A missing floor would upload a bundle every shell accepts, including the
// ones it was not built for — fail the lane instead.
it('fails when no native release tag exists', () => {
const result = run(repo('1.0.53', { tags: ['v1.0.0', 'v2026.02.26'] }), ['native-floor'])

expect(result.status).toBe(1)
expect(result.stderr).toMatch(/cut a native release/)
})
})

describe('validate', () => {
// `v*` is too loose a glob to reject this, and v2026.02.26 is a real tag on
// main — it is X.Y.Z shaped, so only the major check catches it.
Expand Down
1 change: 1 addition & 0 deletions scripts/ds-lint-counts.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ const HEX_ALLOW = [
'LandingPage/PioneerCard3D', // canvas 3d card
'receipt/[entryId]/pdf/', // @react-pdf/renderer — its StyleSheet takes no tailwind tokens
'app/layout.tsx', // next viewport themeColor — browser chrome, must be a literal
'Global/UnsupportedWebViewScreen/', // inline fallback shown when the stylesheet itself cannot parse
]

// extra allowlist for inline-style only (F-12 taxonomy). canvas/D3/mermaid
Expand Down
4 changes: 3 additions & 1 deletion scripts/native-build.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ const ITEMS_TO_DISABLE = [
{ path: '(mobile-ui)/dev/payment-graph', type: 'dir' },
]

module.exports = { ITEMS_TO_DISABLE }

const MODIFIED_FILES = []
const WRAPPER_FILES = []

Expand Down Expand Up @@ -717,4 +719,4 @@ function pruneExportedAssets() {
}
}

main()
if (require.main === module) main()
16 changes: 15 additions & 1 deletion scripts/release-version.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
// node scripts/release-version.mjs native
// node scripts/release-version.mjs ota --current <version>
// node scripts/release-version.mjs staging
// node scripts/release-version.mjs native-floor
// node scripts/release-version.mjs validate <version> --kind <native|ota>
//
// Needs full history and tags (actions/checkout with fetch-depth: 0).
Expand Down Expand Up @@ -55,10 +56,12 @@ function main(argv) {
return nextOta(major, flag(rest, '--current'))
case 'staging':
return `${major}.${latestBuild(major)}.${commitCount()}`
case 'native-floor':
return nativeFloor(major)
case 'validate':
return validate(major, rest[0], flag(rest, '--kind'))
default:
throw new Error(`unknown mode "${mode ?? ''}" — expected native, ota, staging or validate`)
throw new Error(`unknown mode "${mode ?? ''}" — expected native, ota, staging, native-floor or validate`)
}
}

Expand Down Expand Up @@ -98,6 +101,17 @@ function commitCount() {
return Number(out)
}

// The newest native release, for a bundle's --min-update-version. A bundle
// built from the current tree targets the newest shell, and Capgo's
// --auto-min-update-version only copies the previous bundle's floor forward,
// so on a checkout with no native version stamped on disk the floor never rose.
function nativeFloor(major) {
const build = latestBuild(major)
if (build === 0)
throw new Error(`no v${major}.<build>.0 tag exists — cut a native release before publishing a bundle`)
return `${major}.${build}.0`
}

function validate(major, version, kind) {
const match = PLAIN_SEMVER.exec(version ?? '')
if (!match) throw new Error(`"${version}" is not a plain X.Y.Z version`)
Expand Down
27 changes: 26 additions & 1 deletion sentry.utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ErrorEvent } from '@sentry/nextjs'
import { beforeSendHandler, shouldIgnoreError } from './sentry.utils'
import { beforeSendHandler, getEventSearchTexts, isTransientCapgoNoise, shouldIgnoreError } from './sentry.utils'
import { criticalFlowTags } from '@/utils/sentry-critical-flow'

function eventWith(partial: {
Expand Down Expand Up @@ -135,6 +135,31 @@ describe('shouldIgnoreError — Capgo updater noise', () => {
it('does not touch non-Capgo errors that mention a download', () => {
expect(shouldIgnoreError(eventWith({ type: 'Error', value: 'Download error: statement failed' }))).toBe(false)
})

// Exported for the PostHog mirror wrapper (sentry-init), whose processEvent
// hook runs before beforeSend and so cannot rely on shouldIgnoreError.
describe('isTransientCapgoNoise', () => {
const textsOf = (message: string) => getEventSearchTexts(eventWith({ message }))

it('is true for a transient updater failure', () => {
expect(isTransientCapgoNoise(textsOf('[CapgoUpdater] 🔴 Failed to send stats batch'))).toBe(true)
expect(isTransientCapgoNoise(textsOf('[capgo] update check failed: network_error'))).toBe(true)
})

it('is false for the actionable failures shouldIgnoreError keeps', () => {
expect(isTransientCapgoNoise(textsOf('[CapgoUpdater] 🔴 Checksum mismatch'))).toBe(false)
expect(
isTransientCapgoNoise(textsOf('[capgo] update check failed: disable_auto_update_under_native'))
).toBe(false)
})

it('is false for anything not from Capgo', () => {
expect(isTransientCapgoNoise(textsOf('Failed to send stats batch'))).toBe(false)
expect(isTransientCapgoNoise(getEventSearchTexts(eventWith({ type: 'TypeError', value: 'boom' })))).toBe(
false
)
})
})
})

describe('shouldIgnoreError — passkey wrapper', () => {
Expand Down
49 changes: 28 additions & 21 deletions sentry.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ function isActionableCapgoError(searchTexts: string[]): boolean {
return isFromCapgo(searchTexts) && searchTexts.some((text) => CAPGO_ACTIONABLE.some((p) => text.includes(p)))
}

function isTransientCapgoNoise(searchTexts: string[]): boolean {
export function isTransientCapgoNoise(searchTexts: string[]): boolean {
return isFromCapgo(searchTexts) && !isActionableCapgoError(searchTexts)
}

Expand Down Expand Up @@ -182,6 +182,32 @@ export function isThirdPartyScriptFrame(filename: string): boolean {
return THIRD_PARTY_SCRIPT_FRAMES.some((pattern) => filename.includes(pattern))
}

/**
* The texts every noise predicate matches against, one entry per field.
* Matching each field independently — rather than one concatenated string —
* keeps a pattern from matching across unrelated fields and suppressing a
* legitimate event. Shared with the PostHog mirror wrapper in sentry-init so
* both filters read the same event the same way.
*
* Class names come from every link in the chain. Sentry orders `exception.values`
* root-cause-first, so a wrapper carrying a `cause` lands at the END — exactly
* where fetchWithSentry's ServiceUnavailableError and useZeroDev's PasskeyError
* always sit. Reading only values[0] left `alreadyReported` inert for a month:
* PEANUT-UI-SNP kept double-counting PEANUT-UI-QEY.
*
* Deliberately types only, not messages. Class names are exact, so matching them
* chain-wide can only catch our own wrappers. Widening the fuzzy message patterns
* the same way would suppress MORE — the failure 5343f1d0 just fixed, where viem's
* "Details: Failed to fetch" ate real payment errors via `networkIssues`.
*/
export function getEventSearchTexts(event: ErrorEvent): string[] {
const message = event.message || ''
const exceptionValue = event.exception?.values?.[0]?.value || ''
const culprit = (event as any).culprit || ''
const exceptionTypes = (event.exception?.values ?? []).map((v) => v.type || '')
return [message, exceptionValue, culprit, ...exceptionTypes]
}

/**
* Check if error message matches any ignored pattern
*/
Expand All @@ -190,26 +216,7 @@ export function shouldIgnoreError(event: ErrorEvent): boolean {
// stay filtered even there — a user backing out of the passkey sheet is not
// a defect, and those would drown out the real failures.
const isCriticalFlow = Boolean(event.tags?.[CRITICAL_FLOW_TAG])
const message = event.message || ''
const exceptionValue = event.exception?.values?.[0]?.value || ''
const culprit = (event as any).culprit || ''
/*
* Class names from every link in the chain. Sentry orders `exception.values`
* root-cause-first, so a wrapper carrying a `cause` lands at the END — exactly
* where fetchWithSentry's ServiceUnavailableError and useZeroDev's PasskeyError
* always sit. Reading only values[0] left `alreadyReported` inert for a month:
* PEANUT-UI-SNP kept double-counting PEANUT-UI-QEY.
*
* Deliberately types only, not messages. Class names are exact, so matching them
* chain-wide can only catch our own wrappers. Widening the fuzzy message patterns
* the same way would suppress MORE — the failure 5343f1d0 just fixed, where viem's
* "Details: Failed to fetch" ate real payment errors via `networkIssues`.
*/
const exceptionTypes = (event.exception?.values ?? []).map((v) => v.type || '')

// Match each field independently — concatenating them would let a pattern
// match across unrelated fields and suppress a legitimate event.
const searchTexts = [message, exceptionValue, culprit, ...exceptionTypes]
const searchTexts = getEventSearchTexts(event)

/*
* Rescue actionable OTA failures BEFORE the generic patterns run. The Capgo
Expand Down
Loading
Loading