desktop-release: never run the x86_64 slice on the build Mac (Rosetta makes macOS warn the owner that the app will not open in the next macOS); every Mach-O in the bundle checked with lipo (arm64 + x86_64), self-test and sidecar version run natively only; pitfall 34

This commit is contained in:
CRTkafa 2026-10-13 01:59:23 +08:00
parent 7e09947e62
commit 3952716ef8
5 changed files with 64 additions and 22 deletions

View file

@ -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.

View file

@ -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') ?: <current>) as Integer)`, `versionName project.findProperty('versionName') ?: '<current>'`; Kotlin `versionCode = (project.findProperty("versionCode") as String?)?.toInt() ?: <current>`, `versionName = project.findProperty("versionName") as String? ?: "<current>"`), 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`. |

View file

@ -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`.

View file

@ -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\}`/);
}
});
});

View file

@ -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 <dir> <files...> [--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<string, unknown> }
/** 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<string, unknown>;
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<string, unknown>;
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<void> {
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