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
95 changes: 95 additions & 0 deletions .github/workflows/mobile-release-preflight.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
name: Mobile Release Preflight

"on":
pull_request:
push:
branches:
- main
workflow_dispatch:

permissions:
contents: read

env:
NODE_VERSION: 22
PEARBROWSER_ANDROID_KEYSTORE_BASE64: ${{ secrets.PEARBROWSER_ANDROID_KEYSTORE_BASE64 || '' }}
PEARBROWSER_ANDROID_STORE_PASSWORD: ${{ secrets.PEARBROWSER_ANDROID_STORE_PASSWORD || '' }}
PEARBROWSER_ANDROID_KEY_ALIAS: ${{ secrets.PEARBROWSER_ANDROID_KEY_ALIAS || '' }}
PEARBROWSER_ANDROID_KEY_PASSWORD: ${{ secrets.PEARBROWSER_ANDROID_KEY_PASSWORD || '' }}
PEARBROWSER_IOS_DEVELOPMENT_TEAM: ${{ secrets.PEARBROWSER_IOS_DEVELOPMENT_TEAM || '' }}
PEARBROWSER_TESTFLIGHT_VALIDATED: ${{ secrets.PEARBROWSER_TESTFLIGHT_VALIDATED || '' }}
PEARBROWSER_APP_STORE_CONNECT_VALIDATED: ${{ secrets.PEARBROWSER_APP_STORE_CONNECT_VALIDATED || '' }}
PEARBROWSER_PLAY_CONSOLE_VALIDATED: ${{ secrets.PEARBROWSER_PLAY_CONSOLE_VALIDATED || '' }}
PEARBROWSER_FIREBASE_APP_DISTRIBUTION_VALIDATED: ${{ secrets.PEARBROWSER_FIREBASE_APP_DISTRIBUTION_VALIDATED || '' }}

jobs:
release-preflight:
name: Mobile release preflight
runs-on: macos-latest

steps:
- name: Checkout mobile repo
uses: actions/checkout@v4

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm

- name: Install dependencies
run: npm ci

- name: Run mobile tests
run: npm test

- name: Run high-severity audit
run: npm audit --audit-level=high

- name: Build native worklet bundles
run: npm run bundle-all-native

- name: Prepare iOS BareKit frameworks
run: npm run barekit:fetch:addons

- name: Prepare Android BareKit AAR
run: npm run barekit:fetch:android

- name: Import Android release keystore when configured
if: env.PEARBROWSER_ANDROID_KEYSTORE_BASE64 != ''
run: |
set -euo pipefail
keystore="$RUNNER_TEMP/pearbrowser-release.keystore"
python3 - <<'PY'
import base64
import os
from pathlib import Path

out = Path(os.environ["RUNNER_TEMP"]) / "pearbrowser-release.keystore"
out.write_bytes(base64.b64decode(os.environ["PEARBROWSER_ANDROID_KEYSTORE_BASE64"]))
PY
echo "PEARBROWSER_ANDROID_KEYSTORE=$keystore" >> "$GITHUB_ENV"

- name: Capture soft release preflight report
run: |
set -euo pipefail
mkdir -p dist/release-preflight
npm run --silent release:preflight -- --json --soft > dist/release-preflight/mobile-release-preflight.json
npm run check:release-preflight-report -- dist/release-preflight/mobile-release-preflight.json --allow-production-blockers

- name: Run strict release preflight when distribution markers are present
if: >
env.PEARBROWSER_ANDROID_KEYSTORE_BASE64 != '' &&
env.PEARBROWSER_ANDROID_STORE_PASSWORD != '' &&
env.PEARBROWSER_ANDROID_KEY_ALIAS != '' &&
env.PEARBROWSER_IOS_DEVELOPMENT_TEAM != '' &&
(env.PEARBROWSER_TESTFLIGHT_VALIDATED == '1' || env.PEARBROWSER_APP_STORE_CONNECT_VALIDATED == '1') &&
(env.PEARBROWSER_PLAY_CONSOLE_VALIDATED == '1' || env.PEARBROWSER_FIREBASE_APP_DISTRIBUTION_VALIDATED == '1')
run: npm run --silent release:preflight -- --json

- name: Upload release preflight report
uses: actions/upload-artifact@v4
with:
name: mobile-release-preflight
path: dist/release-preflight/mobile-release-preflight.json
if-no-files-found: error
14 changes: 11 additions & 3 deletions android-native/BUILD.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,12 @@ reusing the `backend/` worklet from the RN project verbatim.
Temurin 17 is verified locally; Homebrew OpenJDK 17.0.19 hung in `jmod`
on this machine during `:app:assembleDebug`.
3. **Node.js 20+** (for bundling the backend).
4. **`bare-kit.aar` or `bare-kit.jar`** — download the latest release from
<https://github.com/holepunchto/bare-kit/releases> and drop it in
`android-native/app/libs/bare-kit.aar` (preferred) or
4. **`bare-kit.aar` or `bare-kit.jar`** — run
`npm run barekit:fetch:android` to mirror the installed
`react-native-bare-kit` Android artifact into
`android-native/app/libs/bare-kit.aar`. You can also manually download the
latest release from <https://github.com/holepunchto/bare-kit/releases> and
drop it in `android-native/app/libs/bare-kit.aar` (preferred) or
`android-native/app/libs/bare-kit.jar`.

The artifact is **not checked in** (see `.gitignore`). You must fetch it fresh.
Expand All @@ -54,7 +57,9 @@ The Kotlin shell reuses `backend/` unchanged. Produce the canonical
cd /Users/localllm/Projects/pear-ecosystem/01-browser/PearBrowser
npm install # if not already
npm run bundle-backend-native-android
npm run barekit:fetch:android
# Produces backend/dist/backend.android.bundle
# Produces android-native/app/libs/bare-kit.aar
```

Gradle's `sourceSets.main.assets.srcDirs` includes `../../backend/dist`
Expand Down Expand Up @@ -115,6 +120,9 @@ Play Console or Firebase App Distribution validation.
Before treating an Android artifact as releasable, run the root preflight:

```bash
npm run bundle-all-native
npm run barekit:fetch:addons
npm run barekit:fetch:android
npm run release:preflight
```

Expand Down
11 changes: 10 additions & 1 deletion docs/MOBILE_RELEASE_EVIDENCE_2026-06-23.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,19 @@ commit does not change runtime source.

## Automated Checks

- `npm test`: passed `136/136`.
- `npm test`: passed `139/139` after adding the release preflight report
verifier coverage.
- `git diff --check`: passed.
- `npm audit --audit-level=high`: exited `0`.
- `npm run release:preflight -- --soft`: `14 pass`, `0 warn`, `4 fail`.
- `npm run release:preflight -- --json --soft`: same counts, machine-readable
report generated at `2026-06-23T15:51:08.065Z`.
- `.github/workflows/mobile-release-preflight.yml` now reproduces the cold CI
release-preflight setup: `npm ci`, `npm test`, high-severity audit, native
worklet bundle generation, iOS BareKit/addon mirroring, Android BareKit AAR
mirroring, soft JSON preflight capture, and
`scripts/check-release-preflight-report.js` verification. The uploaded
`mobile-release-preflight` artifact is the machine-readable evidence source.

The high-severity audit gate is green. A full audit still reports 15 moderate
Expo/React Native toolchain advisories through `js-yaml` and `uuid`; npm's
Expand Down Expand Up @@ -52,3 +59,5 @@ follow-up rather than release-day force fixes.
The mobile source tree is structurally ready for release validation, but not
production-distribution-cleared. The remaining blockers require real platform
credentials and store/distribution validation evidence, not code changes.
The CI workflow intentionally allows only those four production blockers; any
new structural blocker or warning fails the release-preflight check.
4 changes: 3 additions & 1 deletion ios-native/BUILD.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,9 @@ npm run barekit:fetch:addons
```

Run this after `npm install` or `npm update react-native-bare-kit` to
pick up addon version bumps.
pick up addon version bumps. If `node_modules/react-native-bare-kit/ios/addons`
is missing, the script runs the package's `ios/link.mjs` first and then mirrors
the generated addon xcframeworks into `ios-native/PearBrowser/Frameworks/addons`.

### RPC wire format (important for anyone touching PearRPC.swift)
The worklet RPC is length-prefixed JSON, but **the length prefix is
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@
"bundle-all-native": "npm run bundle-backend-native-ios && npm run bundle-backend-native-android",
"ios:generated:release": "sh scripts/build-generated-ios-release.sh",
"release:preflight": "node scripts/release-preflight.js",
"check:release-preflight-report": "node scripts/check-release-preflight-report.js",
"barekit:fetch": "node scripts/fetch-barekit.js",
"barekit:fetch:android": "node scripts/fetch-android-barekit.js",
"barekit:fetch:addons": "node scripts/fetch-barekit-addons.js",
"test": "npx tsc --noEmit && node --check backend/index.js backend/hyper-proxy.js backend/http-bridge.js backend/swarm-bridge.js backend/swarm-grants.js backend/site-manager.js backend/relay-client.js backend/catalog-manager.js backend/app-manager.js backend/pear-bridge.js backend/rpc.js backend/xhr-streamx.js backend/navigation.js && node --test test/*.test.js"
},
Expand Down
107 changes: 107 additions & 0 deletions scripts/check-release-preflight-report.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
#!/usr/bin/env node
'use strict'

const fs = require('node:fs')
const path = require('node:path')

const PRODUCTION_BLOCKERS = new Set([
'android-release-signing',
'ios-release-signing',
'ios-store-validation',
'android-store-validation'
])

function analyzeReport (report, options = {}) {
const allowProductionBlockers = Boolean(options.allowProductionBlockers)
const allowWarnings = Boolean(options.allowWarnings)
const blockers = Array.isArray(report.blockers)
? report.blockers
: (report.checks || []).filter((check) => check.status === 'fail')
const warnings = Array.isArray(report.warnings)
? report.warnings
: (report.checks || []).filter((check) => check.status === 'warn')

const unexpectedBlockers = blockers.filter((check) => {
return !(allowProductionBlockers && PRODUCTION_BLOCKERS.has(check.id))
})
const unexpectedWarnings = allowWarnings ? [] : warnings

return {
ok: unexpectedBlockers.length === 0 && unexpectedWarnings.length === 0,
counts: {
pass: report.counts?.pass || (report.checks || []).filter((check) => check.status === 'pass').length,
warn: warnings.length,
fail: blockers.length,
unexpectedBlockers: unexpectedBlockers.length,
unexpectedWarnings: unexpectedWarnings.length
},
blockers,
warnings,
unexpectedBlockers,
unexpectedWarnings
}
}

function parseArgs (argv) {
const opts = {
file: '',
allowProductionBlockers: false,
allowWarnings: false
}

for (let i = 0; i < argv.length; i++) {
const arg = argv[i]
if (arg === '--allow-production-blockers') opts.allowProductionBlockers = true
else if (arg === '--allow-warnings') opts.allowWarnings = true
else if (arg === '-h' || arg === '--help') usage(0)
else if (!opts.file) opts.file = arg
else usage(2, `unknown argument: ${arg}`)
}

if (!opts.file) usage(2, 'missing preflight report path')
opts.file = path.resolve(opts.file)
return opts
}

function usage (code, message = '') {
if (message) console.error(`error: ${message}`)
console.error('usage: node scripts/check-release-preflight-report.js <report.json> [--allow-production-blockers] [--allow-warnings]')
process.exit(code)
}

function printSummary (file, result) {
console.log(`Mobile release preflight report: ${file}`)
console.log(` passed: ${result.counts.pass}`)
console.log(` warnings: ${result.counts.warn}`)
console.log(` blockers: ${result.counts.fail}`)
console.log(` unexpected blockers: ${result.counts.unexpectedBlockers}`)
console.log(` unexpected warnings: ${result.counts.unexpectedWarnings}`)

const printItems = (label, items) => {
if (!items.length) return
console.log('')
console.log(label)
for (const item of items) {
console.log(` - ${item.id}: ${item.detail || item.label || item.status}`)
if (item.remediation) console.log(` fix: ${item.remediation}`)
}
}

printItems('Unexpected blockers', result.unexpectedBlockers)
printItems('Unexpected warnings', result.unexpectedWarnings)
console.log('')
console.log(result.ok ? 'Mobile release preflight report is within the expected gate envelope.' : 'Mobile release preflight report has unexpected release blockers.')
}

if (require.main === module) {
const opts = parseArgs(process.argv.slice(2))
const report = JSON.parse(fs.readFileSync(opts.file, 'utf8'))
const result = analyzeReport(report, opts)
printSummary(opts.file, result)
process.exit(result.ok ? 0 : 1)
}

module.exports = {
PRODUCTION_BLOCKERS,
analyzeReport
}
60 changes: 60 additions & 0 deletions scripts/fetch-android-barekit.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
#!/usr/bin/env node
'use strict'

/**
* Mirrors react-native-bare-kit's exploded Android bare-kit artifact into the
* native Kotlin shell as an AAR. This keeps cold CI and fresh developer clones
* aligned with the release-preflight expectation without committing binaries.
*/

const fs = require('node:fs')
const path = require('node:path')
const { spawnSync } = require('node:child_process')

const ROOT = path.join(__dirname, '..')
const SRC = path.join(ROOT, 'node_modules', 'react-native-bare-kit', 'android', 'libs', 'bare-kit')
const DEST_DIR = path.join(ROOT, 'android-native', 'app', 'libs')
const DEST = path.join(DEST_DIR, 'bare-kit.aar')

function log (message) {
console.log(`[barekit:android] ${message}`)
}

function bail (message) {
console.error(`[barekit:android] ${message}`)
process.exit(1)
}

function ensureSource () {
for (const rel of ['AndroidManifest.xml', 'classes.jar']) {
if (!fs.existsSync(path.join(SRC, rel))) {
bail(`${path.join(SRC, rel)} missing — run npm ci first.`)
}
}
const jni = path.join(SRC, 'jni')
if (!fs.existsSync(jni) || !fs.readdirSync(jni).length) {
bail(`${jni} missing or empty — react-native-bare-kit did not install native libraries.`)
}
}

function runArchiver () {
const zip = spawnSync('zip', ['-qr', DEST, '.'], { cwd: SRC, stdio: 'inherit' })
if (!zip.error && zip.status === 0) return

log('zip was unavailable or failed; falling back to jar.')
const jar = spawnSync('jar', ['cf', DEST, '.'], { cwd: SRC, stdio: 'inherit' })
if (jar.error) bail(jar.error.message)
if (jar.status !== 0) bail(`jar exited with status ${jar.status}`)
}

ensureSource()
fs.mkdirSync(DEST_DIR, { recursive: true })
fs.rmSync(DEST, { force: true })
runArchiver()

const size = fs.statSync(DEST).size
if (size < 1024 * 1024) {
bail(`created ${DEST}, but it is only ${size} bytes`)
}

log(`Wrote ${DEST} (${Math.round(size / 1024 / 1024)} MiB)`)
13 changes: 11 additions & 2 deletions scripts/fetch-barekit-addons.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

const fs = require('fs')
const path = require('path')
const { execSync } = require('child_process')
const { execFileSync, execSync } = require('child_process')

const ROOT = path.join(__dirname, '..')
const SRC_ROOT = path.join(ROOT, 'node_modules', 'react-native-bare-kit', 'ios')
Expand Down Expand Up @@ -65,7 +65,16 @@ log(' patched BareKit.xcframework with module.modulemap (needed by bare-kit-swi

// ---- Addons ----
if (!fs.existsSync(SRC_ADDONS)) {
bail(`${SRC_ADDONS} missing — has react-native-bare-kit's postinstall link step run?`)
const linkScript = path.join(SRC_ROOT, 'link.mjs')
if (!fs.existsSync(linkScript)) {
bail(`${SRC_ADDONS} missing and ${linkScript} was not found.`)
}
log('Addon xcframeworks missing; running react-native-bare-kit iOS linker...')
execFileSync(process.execPath, [linkScript], { stdio: 'inherit' })
}

if (!fs.existsSync(SRC_ADDONS)) {
bail(`${SRC_ADDONS} missing after running react-native-bare-kit iOS linker.`)
}
if (fs.existsSync(DST_ADDONS)) execSync(`rm -rf "${DST_ADDONS}"`)
fs.mkdirSync(DST_ADDONS, { recursive: true })
Expand Down
Loading
Loading