Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
32 changes: 32 additions & 0 deletions .github/workflows/capgo-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,38 @@ jobs:
echo "name=$FLOOR" >> "$GITHUB_OUTPUT"
echo "Bundle requires native $FLOOR or newer"

# The floor above says which binary this bundle CLAIMS to need. This
# step checks the claim: if the native surface moved since that
# binary was cut, the JS being published was built against plugins,
# permissions or entitlements no shipped binary has, and the floor is
# a number rather than a fact.
#
# A bundle's version says nothing about the native surface it needs,
# so nothing else in this pipeline can tell. Capgo's
# min_update_version only blocks DELIVERY, only under the `metadata`
# channel strategy, and lives in a dashboard CI cannot read — it
# never reports that an incompatible bundle was built. The
# fingerprint is a pure function of the tree, so the shipped binary's
# surface is exactly what its tag describes and no state has to be
# stored anywhere.
#
# Failing here is the intended outcome, not an obstacle: it is
# docs/NATIVE-RELEASE.md's "bump the native version whenever you
# change plugins/native code" with something actually checking it.
# The fix is to cut a native release, never to weaken this step.
- name: Check the native surface still matches that binary
env:
FLOOR: ${{ steps.native_floor.outputs.name }}
run: |
if ! git rev-parse -q --verify "refs/tags/v$FLOOR" >/dev/null; then
echo "::error::no v$FLOOR tag in this checkout — cannot prove the bundle fits the binary it targets (needs fetch-depth: 0 and tags)" >&2
exit 1
fi
if ! node scripts/native-fingerprint.mjs --diff "v$FLOOR"; then
echo "::error::this tree's native surface differs from the v$FLOOR binary, so the JS about to ship was built against native code no released binary has. Cut a native release (Actions -> Release Native) before OTA'ing this tree." >&2
exit 1
fi

- 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 Down
30 changes: 30 additions & 0 deletions docs/NATIVE-RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,36 @@ own `out/` under the binary's versionName, then assert the channel serves it.
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.
- **Native fingerprint (the check behind that rule):** `scripts/native-fingerprint.mjs`
hashes the JS↔native contract in three parts: the **config** (Capacitor's two generated
plugin manifests, `capacitor.config.ts`, the gradle files, `AndroidManifest.xml`,
`project.pbxproj`, `Info.plist`, both entitlements files), the **bridges** JS actually
calls (`android/app/src/main/**.{java,kt}`, `ios/App/**.swift` — a bundle calling a new
method on `PushProvisioningPlugin` needs the binary that has it, and no config file
moves when that changes), the **resource contracts** those config files delegate to
(`android/app/src/main/res/**.xml`, including the `capacitor-passkey.xml` asset
statement, and every `Info.plist`/`.entitlements` under `ios/App` — the extensions'
as well as the app's), and the **resolved plugin versions from `pnpm-lock.yaml`**
(the OTA workflow runs `pnpm install` but never regenerates the committed manifests, so
a plugin bumped without a `cap sync` would ship the new JS wrapper against unchanged
manifest bytes; the plugin set is the union of the declared dependencies and the names
Capacitor generated, plus the full dependency NAME set so a plugin named after neither
still counts, plus `patches/` and the `patchedDependencies` map, since a pnpm patch
rewrites both halves of a package with no version change). Only `src/main` is hashed on
Android: `src/meawallet` is added to the build by build.gradle only when the Nexus
credentials are present, so claiming it would let a tag assert a bridge the binary may
never have registered. An unresolvable ref is an error, never an empty read, and
`--root` points the CLI at another checkout so its tests never mutate this one. `capgo-deploy.yml` recomputes it and compares against the
`v<major>.<build>.0` tag the bundle's floor targets; a mismatch **fails the OTA** and
names the file that moved. It is a pure function of the tree, so nothing is stored and
any tag can be fingerprinted retroactively (`--ref v1.2.0`). `MARKETING_VERSION` and
`CURRENT_PROJECT_VERSION` are normalised out — `native-ios-postsync.js` stamps them on
every sync, and leaving them in would refuse an OTA after every release.
**Why it exists:** `min_update_version` only blocks *delivery*, only under the
`metadata` channel strategy, and lives in a dashboard CI cannot read, so nothing
previously reported that an incompatible bundle had been *built* — the mismatch first
appeared on a user's device. The remedy for a failure is always to cut a native
release, never to widen or skip the check.
- **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
309 changes: 309 additions & 0 deletions scripts/__tests__/native-fingerprint.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,309 @@
const { spawnSync } = require('child_process')
const fs = require('fs')
const os = require('os')
const path = require('path')

const SCRIPT_PATH = path.join(__dirname, '..', 'native-fingerprint.mjs')
const repoRoot = path.join(__dirname, '..', '..')

// The real files the manifest reads, copied once into the fixture. Anything
// absent is skipped, so this list can lag the manifest without breaking.
const FIXTURE_FILES = [
'package.json',
'pnpm-lock.yaml',
'capacitor.config.ts',
'android/capacitor.settings.gradle',
'android/build.gradle',
'android/variables.gradle',
'android/app/build.gradle',
'android/app/src/main/AndroidManifest.xml',
'android/app/src/main/res/values/capacitor-passkey.xml',
'android/app/src/main/java/me/peanut/wallet/MainActivity.java',
'android/app/src/meawallet/java/me/peanut/wallet/PushProvisioningPlugin.java',
'ios/App/CapApp-SPM/Package.swift',
'ios/App/App.xcodeproj/project.pbxproj',
'ios/App/App/Info.plist',
'ios/App/App/App.entitlements',
'ios/App/App/AppRelease.entitlements',
'ios/App/App/ClipboardDetectPlugin.swift',
'ios/App/PushProvisioningExtension/PushProvisioningExtension.entitlements',
'patches/@zerodev__webauthn-key.patch',
]

/*
* Every mutation happens in a throwaway git repo, never in this checkout.
*
* The first version of this suite rewrote tracked files around a spawned CLI
* and restored them in a `finally`. That is not isolation: Jest runs suites in
* parallel, and marketing-version.test.js reads the same project.pbxproj — so
* holding it at MARKETING_VERSION 9.9.9 for the length of a subprocess made an
* unrelated suite fail on timing. `--root` exists so the CLI can be pointed at
* a fixture instead.
*/
function makeFixture() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'native-fp-'))
const git = (...args) => {
const result = spawnSync('git', args, { cwd: dir, encoding: 'utf-8' })
if (result.status !== 0) throw new Error(`git ${args.join(' ')} failed: ${result.stderr}`)
return result.stdout
}

for (const relative of FIXTURE_FILES) {
const source = path.join(repoRoot, relative)
if (!fs.existsSync(source)) continue
fs.mkdirSync(path.join(dir, path.dirname(relative)), { recursive: true })
fs.copyFileSync(source, path.join(dir, relative))
}

git('init', '-q')
git('config', 'user.email', 'test@example.com')
git('config', 'user.name', 'test')
git('config', 'commit.gpgsign', 'false')
git('add', '-A')
git('commit', '-qm', 'fixture')
return { dir, cleanup: () => fs.rmSync(dir, { recursive: true, force: true }) }
}

// The script is a CI entrypoint: its contract is stdout + exit code, so run it
// the way capgo-deploy.yml does instead of reaching into its internals. (Jest
// runs CJS here, so a dynamic import of the .mjs would not load anyway — same
// reason semver-newer.test.js and release-version.test.js spawn it.)
function run(root, ...args) {
return spawnSync(process.execPath, [SCRIPT_PATH, '--root', root, ...args], { encoding: 'utf-8' })
}

describe('native-fingerprint', () => {
let fixture

beforeAll(() => {
fixture = makeFixture()
})

afterAll(() => fixture.cleanup())

const fingerprint = (...args) => {
const result = run(fixture.dir, ...args)
expect(result.status).toBe(0)
return result.stdout.trim()
}

// Mutate a file in the FIXTURE, assert, restore. Nothing here touches the
// checkout other suites are reading.
function withPatchedInput(relativePath, patch, assertion) {
const target = path.join(fixture.dir, relativePath)
const original = fs.readFileSync(target, 'utf8')
try {
fs.writeFileSync(target, patch(original))
assertion()
} finally {
fs.writeFileSync(target, original)
}
}

it('is a stable 16-hex digest across repeated runs of one tree', () => {
const first = fingerprint()

expect(first).toMatch(/^[0-9a-f]{16}$/)
expect(fingerprint()).toBe(first)
})

it('covers every native input, hashing it or recording its absence', () => {
const result = run(fixture.dir, '--manifest')
const keys = Object.keys(JSON.parse(result.stdout))

expect(result.status).toBe(0)
// toContain on the key list, not toHaveProperty: these keys contain
// dots, which toHaveProperty would read as a nested property path.
expect(keys).toContain('android/capacitor.settings.gradle')
expect(keys).toContain('ios/App/CapApp-SPM/Package.swift')
expect(keys).toContain('capacitor.config.ts')
expect(keys).toContain('android/app/src/main/**.{java,kt}')
expect(keys).toContain('ios/App/**.swift')
expect(keys).toContain('native-plugin-versions')
expect(keys).toContain('android/app/src/main/res/**.xml')
expect(keys).toContain('ios/App/**.{plist,entitlements}')
expect(keys).toContain('patches/**')
})

it('matches its own committed tree, which proves the ref path reads content', () => {
// An unresolvable read would make every input <absent> — a well-formed
// fingerprint of nothing. Equality with HEAD is what rules that out.
expect(fingerprint('--ref', 'HEAD')).toBe(fingerprint())
})

it('exits 0 and says so when the surface has not moved', () => {
const result = run(fixture.dir, '--diff', 'HEAD')

expect(result.status).toBe(0)
expect(result.stdout).toContain('native surface unchanged')
})

it('exits 1 naming the culprit when the surface moved', () => {
withPatchedInput(
'android/capacitor.settings.gradle',
(content) => content.replace('capacitor-updater@8.51.14', 'capacitor-updater@9.0.0'),
() => {
const result = run(fixture.dir, '--diff', 'HEAD')

expect(result.status).toBe(1)
expect(result.stdout).toContain('native surface changed since HEAD')
// It must say WHAT moved — a bare "refused" is unactionable.
expect(result.stdout).toContain('android/capacitor.settings.gradle')
}
)
})

it('moves when a plugin version changes', () => {
const before = fingerprint()

withPatchedInput(
'android/capacitor.settings.gradle',
(content) => content.replace('capacitor-updater@8.51.14', 'capacitor-updater@9.0.0'),
() => expect(fingerprint()).not.toBe(before)
)
})

it('ignores the MARKETING_VERSION stamp, which is the release number not the surface', () => {
const before = fingerprint()

withPatchedInput(
'ios/App/App.xcodeproj/project.pbxproj',
// What native-ios-postsync.js writes on every cap sync. Left raw
// this would refuse an OTA after every release.
(content) => content.replace(/MARKETING_VERSION = [^;]*;/g, 'MARKETING_VERSION = 9.9.9;'),
() => expect(fingerprint()).toBe(before)
)
})

it('still notices a real pbxproj change, e.g. the deployment floor', () => {
const before = fingerprint()

withPatchedInput(
'ios/App/App.xcodeproj/project.pbxproj',
(content) => content.replace(/IPHONEOS_DEPLOYMENT_TARGET = [^;]*;/g, 'IPHONEOS_DEPLOYMENT_TARGET = 18.0;'),
() => expect(fingerprint()).not.toBe(before)
)
})

it('moves when a compiled Android bridge changes', () => {
const before = fingerprint()

withPatchedInput(
// Where the app-local plugins are registered. No config file moves
// when it changes.
'android/app/src/main/java/me/peanut/wallet/MainActivity.java',
(content) => `${content}\n// surface change\n`,
() => expect(fingerprint()).not.toBe(before)
)
})

it('does NOT claim a bridge whose source set is gated on CI secrets', () => {
const before = fingerprint()

withPatchedInput(
// build.gradle adds src/meawallet only when the Nexus credentials
// are present, so whether this reaches the binary depends on
// secrets rather than on the tree. Claiming it let a release tag
// assert a bridge the binary may never have registered.
'android/app/src/meawallet/java/me/peanut/wallet/PushProvisioningPlugin.java',
(content) => `${content}\n// not compiled without credentials\n`,
() => expect(fingerprint()).toBe(before)
)
})

it('moves when an iOS bridge changes', () => {
const before = fingerprint()

withPatchedInput(
'ios/App/App/ClipboardDetectPlugin.swift',
(content) => `${content}\n// surface change\n`,
() => expect(fingerprint()).not.toBe(before)
)
})

it('moves on a lockfile-only plugin bump, which the generated manifests miss', () => {
const before = fingerprint()

withPatchedInput(
'pnpm-lock.yaml',
// The OTA workflow runs `pnpm install` but never regenerates the
// committed Capacitor manifests, so a bump without a `cap sync`
// ships the new JS wrapper against unchanged manifest bytes.
(content) => content.split('@capgo/capacitor-updater@8.51.14').join('@capgo/capacitor-updater@9.0.0'),
() => expect(fingerprint()).not.toBe(before)
)
})

it('moves when the passkey asset statement changes', () => {
const before = fingerprint()

withPatchedInput(
// AndroidManifest.xml delegates to it and does not move with it.
'android/app/src/main/res/values/capacitor-passkey.xml',
(content) => `${content}\n<!-- surface change -->\n`,
() => expect(fingerprint()).not.toBe(before)
)
})

it('moves when an extension entitlement changes', () => {
const before = fingerprint()

withPatchedInput(
'ios/App/PushProvisioningExtension/PushProvisioningExtension.entitlements',
(content) => `${content}\n`,
() => expect(fingerprint()).not.toBe(before)
)
})

it('moves on a pnpm patch, which changes both halves with no version bump', () => {
const before = fingerprint()

withPatchedInput(
// A patch rewrites the JS wrapper AND the native sources while the
// resolved version and the generated manifests stay identical.
'patches/@zerodev__webauthn-key.patch',
(content) => `${content}\n`,
() => expect(fingerprint()).not.toBe(before)
)
})

it('moves when a dependency is added, whatever it is called', () => {
const before = fingerprint()

withPatchedInput(
'package.json',
// The name-heuristic gap: a native plugin called neither
// capacitor/cordova nor a first-party scope, whose generated
// manifests are also stale. Hashing the dependency NAME SET catches
// it; hashing their versions too would refuse an OTA on every JS
// bump, and a check that cries wolf gets switched off.
(content) => content.replace('"dependencies": {', '"dependencies": {\n "some-native-thing": "^1.0.0",'),
() => expect(fingerprint()).not.toBe(before)
)
})

it('refuses an unresolvable ref instead of hashing an empty tree', () => {
const result = run(fixture.dir, '--ref', 'v99.99.99-does-not-exist')

// Both git reads fail quietly for an unknown ref, which would produce a
// well-formed fingerprint of nothing that differs from any real tree —
// so a --diff against a missing tag would report "changed" and look
// like the check had run.
expect(result.status).toBe(1)
expect(result.stderr).toContain('does not resolve')
})

it('rejects --diff without a ref rather than comparing against nothing', () => {
const result = run(fixture.dir, '--diff')

expect(result.status).toBe(1)
expect(result.stderr).toContain('needs a git ref')
})

it('rejects --root without a directory', () => {
const result = spawnSync(process.execPath, [SCRIPT_PATH, '--root'], { encoding: 'utf-8' })

expect(result.status).toBe(1)
expect(result.stderr).toContain('needs a directory')
})
})
Loading
Loading