Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
25 changes: 25 additions & 0 deletions docs/NATIVE-RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,31 @@ 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/**.{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, so a community plugin outside the first-party scopes still counts).
An unresolvable ref is an error, never an empty read. `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
251 changes: 251 additions & 0 deletions scripts/__tests__/native-fingerprint.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
const { spawnSync } = require('child_process')
const fs = require('fs')
const path = require('path')

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

// 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(...args) {
return spawnSync(process.execPath, [SCRIPT_PATH, ...args], { encoding: 'utf-8', cwd: repoRoot })
}

function fingerprint(...args) {
const result = run(...args)
expect(result.status).toBe(0)
return result.stdout.trim()
}

// Mutate one native input, read the fingerprint, always put the file back.
function withPatchedInput(relativePath, patch, assertion) {
const target = path.join(repoRoot, relativePath)
const original = fs.readFileSync(target, 'utf8')
try {
fs.writeFileSync(target, patch(original))
Comment thread
innolope-dev marked this conversation as resolved.
Outdated
assertion()
} finally {
fs.writeFileSync(target, original)
}
}

describe('native-fingerprint', () => {
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('--manifest')
const entries = 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.
const keys = Object.keys(entries)
// The two generated plugin manifests are the load-bearing inputs: they
// pin the plugin set AND their resolved versions.
expect(keys).toContain('android/capacitor.settings.gradle')
expect(keys).toContain('ios/App/CapApp-SPM/Package.swift')
expect(keys).toContain('capacitor.config.ts')
// The bridges JS actually calls, and the lockfile the OTA build
// installs from — config files alone do not move when either changes.
expect(keys).toContain('android/app/src/**.{java,kt}')
expect(keys).toContain('ios/App/**.swift')
expect(keys).toContain('native-plugin-versions')
// Native resource contracts the config files delegate to.
expect(keys).toContain('android/app/src/main/res/**.xml')
expect(keys).toContain('ios/App/**.{plist,entitlements}')
expect(keys.length).toBeGreaterThanOrEqual(10)

// Nothing is silently skipped — absence is its own sentinel, so adding
// or deleting a file moves the fingerprint.
for (const value of Object.values(entries)) {
expect(value === '<absent>' || /^[0-9a-f]{64}$/.test(value)).toBe(true)
}
})

it('reads a git ref, and differs from the working tree once native code has moved', () => {
// HEAD, never a release tag: the unit job checks out at depth 1 with no
// tags, so `--ref v1.1.0` resolves to nothing there and EVERY input
// reads <absent> — which still differs from the working tree, so a
// tag-based assertion passes for entirely the wrong reason.
expect(fingerprint('--ref', 'HEAD')).toMatch(/^[0-9a-f]{16}$/)
expect(fingerprint('--ref', 'HEAD')).toBe(fingerprint())

withPatchedInput(
'capacitor.config.ts',
(content) => `${content}\n// surface change\n`,
() => expect(fingerprint('--ref', 'HEAD')).not.toBe(fingerprint())
)
})

it('exits 0 and says so when the surface has not moved', () => {
const result = run('--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('--diff', 'HEAD')

expect(result.status).toBe(1)
expect(result.stdout).toContain('native surface changed since HEAD')
// The point of the check is that it says WHAT moved, not just
// that something did — a bare "refused" is unactionable at 2am.
expect(result.stdout).toContain('android/capacitor.settings.gradle')
}
)
})

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

withPatchedInput(
'android/capacitor.settings.gradle',
// The shape of a real plugin bump: the resolved version lives in the
// dependency path Capacitor generates.
(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',
// Exactly what scripts/native-ios-postsync.js writes on every cap
// sync. Left un-normalised this would refuse an OTA after every
// release, and the check would be turned off within a week.
(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 an Android bridge changes', () => {
const before = fingerprint()

withPatchedInput(
'android/app/src/main/java/me/peanut/wallet/MainActivity.java',
// MainActivity is where app-local plugins are registered, so a
// bundle calling a newly-registered one needs this binary. No
// config file moves when it changes.
(content) => `${content}\n// surface change\n`,
() => expect(fingerprint()).not.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 gap this closes: the OTA workflow runs `pnpm install` but
// never regenerates capacitor.settings.gradle / Package.swift, so a
// plugin bumped without a `cap sync` ships the new JS wrapper while
// both generated manifests still read unchanged.
(content) => content.split('@capgo/capacitor-updater@8.51.14').join('@capgo/capacitor-updater@9.0.0'),
() => expect(fingerprint()).not.toBe(before)
)
})

it('resolves the bridge file sets at a git ref, not just in the working tree', () => {
// Regression guard: `git ls-tree -r -- 'dir/**/*.java'` matches nothing
// and exits 0, so a glob pathspec made every ref report an empty bridge
// set — the check compared nothing against nothing and passed.
const entries = JSON.parse(run('--manifest', '--ref', 'HEAD').stdout)

expect(entries['android/app/src/**.{java,kt}']).not.toBe('<absent>')
expect(entries['ios/App/**.swift']).not.toBe('<absent>')
expect(entries['native-plugin-versions']).not.toBe('<absent>')
})

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

withPatchedInput(
// The asset statement passkeys are validated against. AndroidManifest.xml
// delegates to it and does not move when it changes.
'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(
// An extension's capabilities are part of the shell a bundle lands
// on, and none of the app-level inputs move when one changes.
'ios/App/PushProvisioningExtension/PushProvisioningExtension.entitlements',
(content) => `${content}\n`,
() => expect(fingerprint()).not.toBe(before)
)
})

it('counts a community plugin, which no first-party scope matches', () => {
const before = fingerprint()

withPatchedInput(
'package.json',
// @capacitor-community/… and @transistorsoft/capacitor-… are native
// but sit outside the first-party scopes, so a scope allowlist would
// let a bump through with both generated manifests stale.
(content) =>
content.replace(
'"@capacitor/android"',
'"@capacitor-community/in-app-review": "^7.0.0",\n "@capacitor/android"'
),
() => expect(fingerprint()).not.toBe(before)
)
})

it('refuses an unresolvable ref instead of hashing an empty tree', () => {
const result = run('--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('--diff')

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