-
Notifications
You must be signed in to change notification settings - Fork 14
feat(release): fingerprint the native surface so an OTA cannot outrun the binary #2977
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 1 commit
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
f43a34c
feat(release): fingerprint the native surface so an OTA cannot outrun…
innolope-dev 5fe6817
fix(release): fingerprint the native bridges and the lockfile too
innolope-dev f1f31e1
test(release): make the fingerprint tests hermetic, not tag-dependent
innolope-dev a4f2a49
fix(release): cover resource contracts, community plugins and bad refs
innolope-dev b682a6b
fix(release): close the discovery, variant and patch gaps; isolate th…
innolope-dev 7fd9462
fix(release): explicit native-dependency list; track the gated source…
innolope-dev dd00451
fix(release): fingerprint the postsync script's pinned native SDKs
innolope-dev 6d4a88a
fix(release): fingerprint the resolved iOS package graph
innolope-dev 746d877
fix(release): drop the dependency-name-set hash, which cried wolf on …
innolope-dev File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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)) | ||
| 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') | ||
| }) | ||
| }) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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', | ||
|
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', | ||
|
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) | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.