-
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 3 commits
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
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,196 @@ | ||
| 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') | ||
| // 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') | ||
| 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('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') | ||
| }) | ||
| }) | ||
Oops, something went wrong.
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.