diff --git a/.github/workflows/mobile-release-preflight.yml b/.github/workflows/mobile-release-preflight.yml new file mode 100644 index 0000000..05e4b63 --- /dev/null +++ b/.github/workflows/mobile-release-preflight.yml @@ -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 diff --git a/android-native/BUILD.md b/android-native/BUILD.md index 4ab5c08..79f557c 100644 --- a/android-native/BUILD.md +++ b/android-native/BUILD.md @@ -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 - 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 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. @@ -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` @@ -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 ``` diff --git a/docs/MOBILE_RELEASE_EVIDENCE_2026-06-23.md b/docs/MOBILE_RELEASE_EVIDENCE_2026-06-23.md index cb86451..ba3efde 100644 --- a/docs/MOBILE_RELEASE_EVIDENCE_2026-06-23.md +++ b/docs/MOBILE_RELEASE_EVIDENCE_2026-06-23.md @@ -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 @@ -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. diff --git a/ios-native/BUILD.md b/ios-native/BUILD.md index a789df8..0e1ebf2 100644 --- a/ios-native/BUILD.md +++ b/ios-native/BUILD.md @@ -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 diff --git a/package.json b/package.json index 625f7f3..4a2a62b 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/scripts/check-release-preflight-report.js b/scripts/check-release-preflight-report.js new file mode 100644 index 0000000..4938af6 --- /dev/null +++ b/scripts/check-release-preflight-report.js @@ -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 [--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 +} diff --git a/scripts/fetch-android-barekit.js b/scripts/fetch-android-barekit.js new file mode 100644 index 0000000..e2200b7 --- /dev/null +++ b/scripts/fetch-android-barekit.js @@ -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)`) diff --git a/scripts/fetch-barekit-addons.js b/scripts/fetch-barekit-addons.js index 9c65738..7a2ca18 100644 --- a/scripts/fetch-barekit-addons.js +++ b/scripts/fetch-barekit-addons.js @@ -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') @@ -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 }) diff --git a/test/release-preflight-report.test.js b/test/release-preflight-report.test.js new file mode 100644 index 0000000..2751743 --- /dev/null +++ b/test/release-preflight-report.test.js @@ -0,0 +1,55 @@ +const { test } = require('node:test') +const assert = require('node:assert/strict') + +const { analyzeReport } = require('../scripts/check-release-preflight-report') + +function report (checks) { + const blockers = checks.filter((check) => check.status === 'fail') + const warnings = checks.filter((check) => check.status === 'warn') + return { + counts: { + pass: checks.filter((check) => check.status === 'pass').length, + warn: warnings.length, + fail: blockers.length + }, + checks, + blockers, + warnings + } +} + +test('preflight report checker accepts only documented production blockers in soft mode', () => { + const result = analyzeReport(report([ + { status: 'pass', id: 'version-lock' }, + { status: 'fail', id: 'android-release-signing' }, + { status: 'fail', id: 'ios-release-signing' }, + { status: 'fail', id: 'ios-store-validation' }, + { status: 'fail', id: 'android-store-validation' } + ]), { allowProductionBlockers: true }) + + assert.equal(result.ok, true) + assert.equal(result.counts.unexpectedBlockers, 0) +}) + +test('preflight report checker fails on structural blockers', () => { + const result = analyzeReport(report([ + { status: 'pass', id: 'version-lock' }, + { status: 'fail', id: 'android-ids', detail: 'wrong app id' }, + { status: 'fail', id: 'android-release-signing' } + ]), { allowProductionBlockers: true }) + + assert.equal(result.ok, false) + assert.deepEqual(result.unexpectedBlockers.map((check) => check.id), ['android-ids']) +}) + +test('preflight report checker fails on warnings unless explicitly allowed', () => { + const blocked = analyzeReport(report([ + { status: 'warn', id: 'eas-project' } + ]), { allowProductionBlockers: true }) + const allowed = analyzeReport(report([ + { status: 'warn', id: 'eas-project' } + ]), { allowProductionBlockers: true, allowWarnings: true }) + + assert.equal(blocked.ok, false) + assert.equal(allowed.ok, true) +})