diff --git a/docs/desktop-release.md b/docs/desktop-release.md index fa54644..5706843 100644 --- a/docs/desktop-release.md +++ b/docs/desktop-release.md @@ -91,8 +91,11 @@ What it does, in order: - `codesign --verify --deep --strict`; - the Gatekeeper expectation (signed: `spctl` accepts it as Notarized Developer ID and `stapler validate` passes; unsigned: ad-hoc, and `spctl` rejects it); - - the self-test, run from the mounted DMG and from the unpacked updater bundle, for both CPU types (x86_64 through - Rosetta). `storebound-desktop --self-test` starts the bundled sidecar, sends `session.hello`, prints one JSON + - `lipo -archs` on every executable in the bundle (each must carry arm64 and x86_64). The x86_64 slice is never + run on the build Mac: that starts Rosetta, and macOS then warns the owner that the app "includes a component that + will not open" in the next macOS; + - the self-test (arm64, native), run from the mounted DMG and from the unpacked updater bundle. + `storebound-desktop --self-test` starts the bundled sidecar, sends `session.hello`, prints one JSON line and exits, before Tauri starts, so no window opens; - the privacy scan of the app, the DMG and the updater bundle. 6. Writes minisign `.sig` files with the updater key and checks them right away against tauri.conf.json's key. diff --git a/docs/pitfalls.md b/docs/pitfalls.md index 2a88a6f..ba3bc8a 100644 --- a/docs/pitfalls.md +++ b/docs/pitfalls.md @@ -9,6 +9,7 @@ fails without the fix. | # | date | lesson | guard | |---|---|---|---| +| 34 | 2026-10 | The macOS desktop release ran the universal app's x86_64 slice on the Apple silicon build Mac (`arch -x86_64 storebound-desktop --self-test`, and the x64 sidecar's `version`) to cover both CPU types. That starts Rosetta, and macOS showed the owner "App Update Required: this version of storebound includes a component that will not open in macOS 28". | `tools/desktop-release.ts` never runs an x86_64 slice: `sliceProblems` checks every Mach-O in the bundle with `lipo -archs` (each must carry arm64 and x86_64), the self-test and the sidecar `version` run natively (arm64) only; `tests/desktop-release.test.ts` (slice check, and no `arch -x86_64` / Rosetta launch in the release tools); docs/desktop-release.md. | | 33 | 2026-10 | Play Console: pressing "Submit N changes for review" in Publishing overview while a review was running opened a dialog "Do you want to restart your review?" with "Restart review". Pressing it restarts the running review with the new changes; the plan did not know the dialog. | `play-production-release` plan: the dialog is a known step and UI fact, "Restart review" is in neverPress (the owner's button), Cancel is allowed (closes it, nothing changes); play-first-release and release-management skills explain it. `tests/console-play-plans.test.ts`. | | 32 | 2026-10 | `play build` passed `-PversionCode` / `-PversionName` and `android.injected.version.*`, but a Capacitor `app/build.gradle` read versionCode from an env var, so the AAB stayed at versionCode 1 (the post-build check caught it). Checked with AGP 8.13: `-Pandroid.injected.version.code=42` on `versionCode 1` gives an AAB with versionCode 1, so the injected property does not override `defaultConfig` for `bundleRelease`. The Groovy line `versionCode (project.findProperty('versionCode') ?: 1) as Integer` fails ("Value is null": it parses as `versionCode(...) as Integer`); the cast belongs inside the parentheses. | `versionSourceIssues` (cli/lib/gradle.ts) reads `defaultConfig` of the app module's build.gradle(.kts): a literal, an env var or anything but the project property is a warning in `play build` (before Gradle, also in the dry run and `--json` as `versionWarnings`) and in `check`, with the exact line (Groovy `versionCode((project.findProperty('versionCode') ?: ) as Integer)`, `versionName project.findProperty('versionName') ?: ''`; Kotlin `versionCode = (project.findProperty("versionCode") as String?)?.toInt() ?: `, `versionName = project.findProperty("versionName") as String? ?: ""`), both lines built with AGP 8.13; the post-build versionCode check is now a hard stop (the AAB is not copied). `tests/build-sign.test.ts`, `tests/web-assets.test.ts`. | | 31 | 2026-10 | After the owner canceled a WAITING_FOR_REVIEW submission, the app version went DEVELOPER_REJECTED, then PREPARE_FOR_SUBMISSION, and every IAP version stayed DEVELOPER_REJECTED. `apple iap-submit --with-version --dry` skipped all of them ("latest version is DEVELOPER_REJECTED: change it in App Store Connect (or --new-version)") and said "nothing to submit". Apple takes the same version again: `POST /v1/reviewSubmissionItems` with that inAppPurchaseVersion → 201 READY_FOR_REVIEW (all 7), then `PATCH submitted: true` → 200 WAITING_FOR_REVIEW; an empty READY_FOR_REVIEW draft was reused for it. | DEVELOPER_REJECTED IAP, subscription and subscription group versions are submittable as they are (`planIapSubmission`; the app version already was): the plan says "add (developer rejected, re-submitted as is)" and `apple resubmit`'s table the same; `--new-version` only for APPROVED / REJECTED; an empty draft is reused (`chooseSubmission`). The fake ASC leaves the versions of a canceled submission DEVELOPER_REJECTED, accepts them as items and refuses APPROVED ones. `tests/resubmit.test.ts`. | diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index 6c2d6bd..14a3a6c 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -459,3 +459,13 @@ that fails without the fix (see CONTRIBUTING.md). the owner decides (Cancel keeps the running review). - **Guard:** the `play-production-release` console plan lists the dialog, "Restart review" is in neverPress; `tests/console-play-plans.test.ts`. + +## 34. Desktop release: never run the x86_64 slice on the build Mac +- **Symptom:** while building the storebound desktop app, macOS tells the owner "App Update Required: this version + of storebound includes a component that will not open in macOS 28". +- **Cause:** a check ran the universal app's Intel slice (`arch -x86_64 ... --self-test`) on Apple silicon, which + starts Rosetta; macOS warns about every app that does. +- **Do:** check the x86_64 slice only by structure (`lipo -archs`, `file`, `codesign`) and run self-tests natively + (arm64). The same holds for any universal app or game you build: never launch the Intel slice to test it. +- **Guard:** `tools/desktop-release.ts` (`sliceProblems`: every Mach-O in the bundle must carry arm64 and x86_64, + nothing x86_64 is run); `tests/desktop-release.test.ts`. diff --git a/tests/desktop-release.test.ts b/tests/desktop-release.test.ts index 4f664e7..1778165 100644 --- a/tests/desktop-release.test.ts +++ b/tests/desktop-release.test.ts @@ -3,7 +3,7 @@ import { describe, expect, test } from 'bun:test'; import { cpSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { artifactName, developerIdIdentity, mergeManifest, pathFreeEnv, releaseUrl, remoteSessionReason, sumsText, uploadable } from '../tools/desktop-release'; +import { artifactName, developerIdIdentity, mergeManifest, pathFreeEnv, releaseUrl, remoteSessionReason, sliceProblems, sumsText, uploadable } from '../tools/desktop-release'; import { COPIES, setVersion, versions } from '../tools/desktop-version'; describe('desktop version authority', () => { @@ -86,3 +86,16 @@ describe('desktop release', () => { for (const n of ['evidence-macos-0.1.0.json', '.DS_Store', 'latest.json.part', 'a b.dmg']) expect(uploadable(n)).toBe(false); }); }); + +describe('universal slices (never run under Rosetta)', () => { + test('every executable must carry arm64 and x86_64', () => { + expect(sliceProblems([{ file: 'Contents/MacOS/storebound-desktop', archs: ['x86_64', 'arm64'] }, { file: 'Contents/MacOS/storebound', archs: ['arm64', 'x86_64'] }])).toEqual([]); + expect(sliceProblems([{ file: 'a', archs: ['x86_64'] }, { file: 'b', archs: ['arm64'] }, { file: 'c', archs: [] }])).toEqual(['a (x86_64)', 'b (arm64)', 'c (no slices)']); + }); + test('the release tools never launch an x86_64 slice (Rosetta warns the owner about the app)', () => { + for (const f of ['tools/desktop-release.ts', 'tools/desktop-boot-check.ts', 'tools/build-sidecars.ts']) { + const src = readFileSync(join(import.meta.dir, '..', f), 'utf8'); + expect(src).not.toMatch(/['"]arch['"]\s*,|arch -x86_64|`-\$\{arch\}`/); + } + }); +}); diff --git a/tools/desktop-release.ts b/tools/desktop-release.ts index 4f53625..f9de4d8 100644 --- a/tools/desktop-release.ts +++ b/tools/desktop-release.ts @@ -8,8 +8,8 @@ // identity in the keychain: hardened runtime, notarization (notarytool, App Store Connect API key) and stapling. // Without one: an ad-hoc signed build named `-unsigned`, and what the owner has to do. Must run in the owner's // local GUI session: codesign over SSH fails (the keychain is locked there), so SSH sessions are refused. -// The built window is booted headless first (tools/desktop-boot-check.ts). Then, on the exact artifacts: codesign / Gatekeeper expectations, the self-test (sidecar handshake, both CPU -// types) from the mounted DMG and from the unpacked updater bundle, the privacy scan, minisign signatures with +// The built window is booted headless first (tools/desktop-boot-check.ts). Then, on the exact artifacts: codesign / Gatekeeper expectations, lipo (every executable universal; the x86_64 slice is +// never run, Rosetta), the native self-test (sidecar handshake) from the mounted DMG and from the unpacked updater bundle, the privacy scan, minisign signatures with // the updater key (~/.storebound-keys/updater.key), SHA256SUMS, latest.json and evidence.json. // bun tools/desktop-release.ts add --out [--code-signed] // Adds Windows / Linux installers built by the Forgejo workflow (.forgejo/workflows/desktop.yml) to the version @@ -217,23 +217,36 @@ function args(argv: string[]) { interface SelfTest { arch: string; result: Record } +/** The x86_64 slice is never run on this Mac: launching it starts Rosetta, and macOS then tells the owner that + * "storebound" includes a component that will not open in the next major release. It is checked by structure only + * (`lipo -archs` on every Mach-O in the bundle, each must carry arm64 and x86_64); the self-test runs natively. */ +export function sliceProblems(files: { file: string; archs: string[] }[]): string[] { + return files.filter(f => !(f.archs.includes('arm64') && f.archs.includes('x86_64'))).map(f => `${f.file} (${f.archs.join(' ') || 'no slices'})`); +} + +function machOFiles(dir: string): string[] { + const out: string[] = []; + for (const e of readdirSync(dir, { withFileTypes: true })) { + const f = join(dir, e.name); + if (e.isDirectory()) out.push(...machOFiles(f)); + else if (e.isFile() && /Mach-O/.test(run(['file', '-b', f]).out)) out.push(f); + } + return out; +} + function selfTest(appPath: string, label: string): SelfTest[] { const bin = join(appPath, 'Contents/MacOS', MAIN_BIN); - const out: SelfTest[] = []; - const arches = run(['lipo', '-archs', bin]).out.trim().split(/\s+/).sort(); - const sideArches = run(['lipo', '-archs', join(appPath, 'Contents/MacOS', PRODUCT)]).out.trim().split(/\s+/).sort(); - if (arches.join(' ') !== 'arm64 x86_64' || sideArches.join(' ') !== 'arm64 x86_64') fail(`${label}: not universal (app ${arches}, sidecar ${sideArches})`); - const rosetta = run(['arch', '-x86_64', '/usr/bin/true'], { allowFail: true }).code === 0; - for (const arch of rosetta ? ['arm64', 'x86_64'] : [process.arch === 'arm64' ? 'arm64' : 'x86_64']) { - const r = run(['arch', `-${arch}`, bin, '--self-test'], { allowFail: true, timeout: 90_000 }); - if (r.code !== 0) fail(`${label}: self-test (${arch}) failed: ${r.err.trim() || r.out.trim()}`); - const result = JSON.parse(r.out.trim().split('\n').at(-1)!) as Record; - if (result.ok !== true || result.protocol !== 'storebound-desktop/1') fail(`${label}: self-test (${arch}) answered ${r.out}`); - say(`self-test ${label} (${arch}): sidecar ${result.sidecar}, ${result.methods} methods, ${result.ms} ms`); - out.push({ arch, result }); - } - if (!rosetta) say('self-test: Rosetta is not installed, x86_64 slice not run (softwareupdate --install-rosetta to cover it)'); - return out; + const slices = machOFiles(appPath).map(f => ({ file: f.slice(appPath.length + 1), archs: run(['lipo', '-archs', f]).out.trim().split(/\s+/).filter(Boolean) })); + const bad = sliceProblems(slices); + if (!slices.length || bad.length) fail(`${label}: not universal: ${bad.join(', ') || 'no executables'}`); + say(`${label}: every executable is universal (${slices.map(s => s.file.replace('Contents/MacOS/', '')).join(', ')}: arm64 x86_64); x86_64 checked by lipo, not run (no Rosetta)`); + if (process.arch !== 'arm64') fail('the self-test runs the arm64 slice natively: build on Apple silicon'); + const r = run([bin, '--self-test'], { allowFail: true, timeout: 90_000 }); + if (r.code !== 0) fail(`${label}: self-test (arm64) failed: ${r.err.trim() || r.out.trim()}`); + const result = JSON.parse(r.out.trim().split('\n').at(-1)!) as Record; + if (result.ok !== true || result.protocol !== 'storebound-desktop/1') fail(`${label}: self-test (arm64) answered ${r.out}`); + say(`self-test ${label} (arm64): sidecar ${result.sidecar}, ${result.methods} methods, ${result.ms} ms`); + return [{ arch: 'arm64', result }]; } export interface Gate { codesign: string; spctl: string; stapler: string; binaries: string[] } @@ -344,9 +357,11 @@ async function mac(argv: string[]): Promise { rmSync(uni, { force: true }); run(['lipo', '-create', '-output', uni, arm, x64]); run(['codesign', '--force', '--sign', '-', uni]); - for (const f of [arm, x64, uni]) { + // native slices only: running the x64 sidecar would start Rosetta (see sliceProblems) + if (run(['lipo', '-archs', uni]).out.trim().split(/\s+/).sort().join(' ') !== 'arm64 x86_64') fail(`${basename(uni)} is not universal`); + for (const f of [arm, uni]) { const v = run([f, 'version'], { allowFail: true, timeout: 60_000 }); - if (f !== x64 && v.code !== 0) fail(`${basename(f)} does not run: ${v.err}`); + if (v.code !== 0) fail(`${basename(f)} does not run: ${v.err}`); } // the app: tauri build with the path-free environment, CARGO_TARGET_DIR outside home