Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
15 changes: 15 additions & 0 deletions docs/NATIVE-RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,21 @@ 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 — Capacitor's two generated plugin manifests (which pin
every plugin's resolved version in its dependency path), `capacitor.config.ts`, the
gradle files, `AndroidManifest.xml`, `project.pbxproj`, `Info.plist` and both
entitlements files. `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
128 changes: 128 additions & 0 deletions scripts/__tests__/native-fingerprint.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
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')
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', () => {
// v1.1.0 predates the 8.51.14 updater bump and the iOS 16.4 floor.
expect(fingerprint('--ref', 'v1.1.0')).toMatch(/^[0-9a-f]{16}$/)
expect(fingerprint('--ref', 'v1.1.0')).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', () => {
const result = run('--diff', 'v1.1.0')

expect(result.status).toBe(1)
expect(result.stdout).toContain('native surface changed since v1.1.0')
// 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('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')
})
})
190 changes: 190 additions & 0 deletions scripts/native-fingerprint.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
#!/usr/bin/env node
// Hashes the JS<->native contract, so an OTA can tell whether the bundle it is
// about to publish still fits the binaries that will receive it.
//
// The problem this closes: JS and native ship on two independent clocks, and a
// bundle's only identity is a version number, which says nothing about the
// native surface it needs. `1.2.1` does not encode "requires the 8.51 updater
// plugin", so CI will happily publish a bundle built against new plugins onto
// binaries built months earlier, and the mismatch first appears on a user's
// device. Capgo's own `min_update_version` only blocks *delivery*, only under
// the `metadata` channel strategy, and lives in a dashboard CI cannot see — it
// never tells you that you built an incompatible bundle.
//
// The fingerprint is a pure function of the repo tree, so it needs no storage:
// a native release's surface is exactly what its tagged commit describes. The
// OTA lane compares the tree it is publishing against the newest
// `v<major>.<build>.0` tag and refuses when they disagree, naming the file that
// moved. That is the rule docs/NATIVE-RELEASE.md already states ("bump the
// native version whenever you change plugins/native code") with something
// actually checking it.
//
// Scope is deliberately the *contract*, not every native file. Capacitor's two
// generated manifests pin the plugin set AND their exact versions in the
// dependency paths (`@capgo+capacitor-updater@8.51.14`), which is where the
// JS<->native coupling really lives; the rest are the native config surfaces a
// bundle can observe at runtime. Generated web assets (ios/App/App/public,
// android/app/src/main/assets/public) are excluded on purpose — they are OTA
// output, and including them would change the fingerprint on every commit.
//
// Usage:
// node scripts/native-fingerprint.mjs # hash of the working tree
// node scripts/native-fingerprint.mjs --ref v1.2.0 # hash at a git ref
// node scripts/native-fingerprint.mjs --manifest # per-input hashes as JSON
// node scripts/native-fingerprint.mjs --diff v1.2.0 # what moved; exit 1 if anything did

import { execFileSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'

const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')

// Every input is one line of the manifest. Adding one is a deliberate act: it
// widens what counts as "the native surface changed" and therefore how often an
// OTA is refused, so prefer the narrowest file that actually carries the
// contract over the directory that contains it.
export const NATIVE_INPUTS = [
// Capacitor's generated plugin manifests — the plugin set and their exact
// resolved versions, for each platform. These are what actually move when a
// plugin is added, removed or bumped.
'android/capacitor.settings.gradle',
Comment thread
innolope-dev marked this conversation as resolved.
Outdated
'ios/App/CapApp-SPM/Package.swift',

// Native runtime config the JS half reads through the bridge.
'capacitor.config.ts',

// Android build surface: dependencies, SDK levels, permissions.
'android/build.gradle',
'android/app/build.gradle',
'android/variables.gradle',
'android/app/src/main/AndroidManifest.xml',

// iOS build surface: targets, deployment floor, capabilities.
'ios/App/App.xcodeproj/project.pbxproj',
'ios/App/App/Info.plist',
'ios/App/App/App.entitlements',
'ios/App/App/AppRelease.entitlements',
Comment thread
innolope-dev marked this conversation as resolved.
Outdated
]

// A file the tree does not have is still a fact about the surface — adding or
// deleting one must move the fingerprint — so absence gets its own sentinel
// rather than being skipped.
const ABSENT = '<absent>'

/*
* project.pbxproj carries MARKETING_VERSION, which scripts/native-ios-postsync.js
* stamps from the release version on every `cap sync`. That is the release
* number, not the native surface: left raw it would change the fingerprint on
* every single release and make the check cry wolf forever. CURRENT_PROJECT_VERSION
* is the CI run number and is normalised for the same reason.
*/
function normalize(path, content) {
if (!path.endsWith('project.pbxproj')) return content
return content
.replace(/MARKETING_VERSION = [^;]*;/g, 'MARKETING_VERSION = <normalized>;')
.replace(/CURRENT_PROJECT_VERSION = [^;]*;/g, 'CURRENT_PROJECT_VERSION = <normalized>;')
}

function readAtRef(path, ref) {
if (!ref) {
try {
return readFileSync(resolve(repoRoot, path), 'utf8')
} catch (err) {
if (err.code === 'ENOENT') return null
throw err
}
}
try {
// `git show` writes to stderr and exits non-zero for a missing path,
// which is how absence is detected at a ref.
return execFileSync('git', ['show', `${ref}:${path}`], {
cwd: repoRoot,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
maxBuffer: 64 * 1024 * 1024,
})
} catch {
return null
}
}

function sha(text) {
return createHash('sha256').update(text).digest('hex')
}

export function manifest(ref) {
const entries = {}
for (const path of NATIVE_INPUTS) {
const raw = readAtRef(path, ref)
entries[path] = raw === null ? ABSENT : sha(normalize(path, raw))
}
return entries
}

// Hash of the manifest, not of the concatenated files: the per-input digests
// are what a diff reports, so the summary hash must be derived from exactly
// what the diff inspects or the two could disagree.
export function fingerprint(ref) {
const entries = manifest(ref)
const canonical = NATIVE_INPUTS.map((path) => `${path}:${entries[path]}`).join('\n')
return sha(canonical).slice(0, 16)
}

export function diff(baseRef, headRef) {
const base = manifest(baseRef)
const head = manifest(headRef)
return NATIVE_INPUTS.filter((path) => base[path] !== head[path]).map((path) => ({
path,
base: base[path],
head: head[path],
}))
}

function flag(argv, name) {
const index = argv.indexOf(name)
return index === -1 ? undefined : argv[index + 1]
}

function main(argv) {
const ref = flag(argv, '--ref')

if (argv.includes('--manifest')) {
return JSON.stringify(manifest(ref), null, 2)
}

// Presence of the flag decides the mode, never the value it picked up: a
// trailing `--diff` reads as "compare against nothing", and silently
// printing a fingerprint instead would let a misconfigured workflow step
// pass while checking nothing at all.
if (argv.includes('--diff')) {
const against = flag(argv, '--diff')
if (!against) throw new Error('--diff needs a git ref to compare against')
const changed = diff(against, ref)
if (changed.length === 0) {
return `native surface unchanged since ${against} (${fingerprint(against)})`
}
const lines = changed.map(({ path, base, head }) => {
const describe = (value) => (value === ABSENT ? 'absent' : value.slice(0, 12))
return ` ${path}: ${describe(base)} -> ${describe(head)}`
})
process.stdout.write(
`native surface changed since ${against}: ${fingerprint(against)} -> ${fingerprint(ref)}\n` +
`${lines.join('\n')}\n`
)
process.exit(1)
}

return fingerprint(ref)
}

// Only run as a CLI; the exports above are what the tests use.
if (process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) {
try {
process.stdout.write(`${main(process.argv.slice(2))}\n`)
} catch (err) {
console.error(`✗ native-fingerprint: ${err.message}`)
process.exit(1)
}
}
Loading