diff --git a/CHANGELOG.md b/CHANGELOG.md index e980e05..85300fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- **`apple build` stops at once over SSH** (codesign fails there with errSecInternalComponent) and names how to run it + in the logged-in session; relative `ASC_P8` / `PLAY_SERVICE_ACCOUNT` paths are read from the project folder and + xcodebuild gets an absolute key path. Pitfall 101. + - **Bypass mode (owner opt-in, default off).** `storebound settings bypass on` (or "y" in `storebound init`) lets the AI agent press the owner-only buttons too: MCP `owner_step` (submit, release, rollout, testers, replies, prices, ...; dry run first), owner-only `storebound api` operations with `--yes --confirm `, console plans' diff --git a/cli/apple/build.ts b/cli/apple/build.ts index dcb71dc..eccc64f 100644 --- a/cli/apple/build.ts +++ b/cli/apple/build.ts @@ -23,7 +23,7 @@ import { parseMobileprovision, toXmlPlist } from '../lib/plist'; import { nextBuildNumber } from '../lib/release'; import { exportOptions, profileDirs } from '../lib/signing'; import { capacitorProject, webStep } from '../lib/web-assets'; -import { archiveArgs, type AscAuth, distributionErrors, exportArgs, foreignRsync, xcodePath, flutterConfigArgs, ipaInfo, pickXcode, scanXcode, showCmd } from '../lib/xcode'; +import { archiveArgs, type AscAuth, distributionErrors, exportArgs, foreignRsync, remoteBuildSession, xcodePath, flutterConfigArgs, ipaInfo, pickXcode, scanXcode, showCmd } from '../lib/xcode'; import { bundleTrackingIssues, ipaManifests } from '../lib/privacy-files'; import { ascClient } from './asc'; import { findBundleId } from './signing'; @@ -168,6 +168,10 @@ export async function run(ctx: Ctx): Promise { return; } if (process.platform !== 'darwin' || !rt.which('xcodebuild')) throw new Error('apple build needs macOS with Xcode (xcode-select --install is not enough: the full Xcode app)'); + // codesign in an SSH session cannot use the login keychain: the archive fails late with errSecInternalComponent + // (Embed Pods Frameworks). Stop before the web build and the archive (pitfall 101). + const remote = remoteBuildSession(process.env, rt.spawnSync(['launchctl', 'managername'], { timeout: 5000 }).stdout.toString().trim()); + if (remote && !a.has('allow-ssh')) throw new Error(`apple build: ${remote}; codesign there fails with errSecInternalComponent (locked keychain). Run it in the Mac's logged-in session: a Jenkins / Forgejo agent started in that session, or from SSH: write the command into a .command file and \`open -a Terminal \` (it runs in the GUI session; unset SSH_CONNECTION SSH_CLIENT SSH_TTY inside). --allow-ssh skips this check after \`security unlock-keychain\`.`); const web = await webStep({ ...webOpts, dry: false }); for (const n of target.notes) log(TAG, n); const rsync = foreignRsync(process.env.PATH); diff --git a/cli/data/flags.json b/cli/data/flags.json index a93f66b..a24b5e2 100644 --- a/cli/data/flags.json +++ b/cli/data/flags.json @@ -62,6 +62,7 @@ ], "apple appinfo": [], "apple build": [ + "allow-ssh", "allow-stale-web", "build-number", "configuration", @@ -941,6 +942,7 @@ "all-pages", "allow-existing", "allow-price-changes", + "allow-ssh", "allow-stale-web", "alpha", "apk", diff --git a/cli/lib/credentials.ts b/cli/lib/credentials.ts index 3b36e9d..f027d57 100644 --- a/cli/lib/credentials.ts +++ b/cli/lib/credentials.ts @@ -189,7 +189,13 @@ function value(root: string, name: string): string | undefined { } /** A path or the inline content itself. */ -export const inline = (v: string, marker: RegExp): string => (marker.test(v) ? v : readFileSync(v.replace(/^~(?=\/)/, homedir()), 'utf8')); +export const inline = (v: string, marker: RegExp, root?: string): string => (marker.test(v) ? v : readFileSync(credPath(v, root), 'utf8')); + +/** A credential path: ~ expanded; a relative one is taken from the project folder, not the working directory. */ +export function credPath(v: string, root?: string): string { + const h = v.replace(/^~(?=[/\\])/, homedir()); + return isAbsolute(h) || !root ? h : resolve(root, h); +} export function parseServiceAccount(text: string): ServiceAccount { const j = JSON.parse(text) as Partial; @@ -201,7 +207,7 @@ export function parseServiceAccount(text: string): ServiceAccount { export function playServiceAccount(root: string): ServiceAccount { const v = value(root, 'PLAY_SERVICE_ACCOUNT'); if (!v) throw new Error('PLAY_SERVICE_ACCOUNT is not set (env, the credential profile in ~/.storebound/credentials, or .storebound/credentials.json): path to the service account JSON key; `storebound accounts` lists the profiles'); - return parseServiceAccount(inline(v, /^\s*\{/)); + return parseServiceAccount(inline(v, /^\s*\{/, root)); } export function ascKey(root: string): AscKey { @@ -209,8 +215,8 @@ export function ascKey(root: string): AscKey { const issuerId = value(root, 'ASC_ISSUER_ID'); if (!keyId || !issuerId) throw new Error('ASC_KEY_ID and ASC_ISSUER_ID are not set (env, the credential profile in ~/.storebound/credentials, or .storebound/credentials.json); `storebound accounts` lists the profiles'); const p8 = value(root, 'ASC_P8') ?? join(homedir(), '.appstoreconnect/private_keys', `AuthKey_${keyId}.p8`); - if (!/BEGIN PRIVATE KEY/.test(p8) && !existsSync(p8.replace(/^~(?=\/)/, homedir()))) throw new Error(`App Store Connect key not found: ${p8} (set ASC_P8)`); - return { keyId, issuerId, pem: inline(p8, /BEGIN PRIVATE KEY/) }; + if (!/BEGIN PRIVATE KEY/.test(p8) && !existsSync(credPath(p8, root))) throw new Error(`App Store Connect key not found: ${p8} (set ASC_P8)`); + return { keyId, issuerId, pem: inline(p8, /BEGIN PRIVATE KEY/, root) }; } /** The keys and nestings a review contact is read from (first hit): the documented one, then shapes seen in real files. */ @@ -306,7 +312,9 @@ export const maskSecret = (s: string): string => (s ? '********' : '(empty)'); export function ascKeyFile(root: string): { keyPath: string; keyId: string; issuerId: string; cleanup: () => void } { const k = ascKey(root); const raw = value(root, 'ASC_P8') ?? join(homedir(), '.appstoreconnect/private_keys', `AuthKey_${k.keyId}.p8`); - if (!/BEGIN PRIVATE KEY/.test(raw)) return { keyPath: raw.replace(/^~(?=\/)/, homedir()), keyId: k.keyId, issuerId: k.issuerId, cleanup: () => {} }; + // xcodebuild refuses a relative -authenticationKeyPath ("must be an absolute path"): resolve it from the project + const asPath = (p: string) => { const h = p.replace(/^~(?=[/\\])/, homedir()); return isAbsolute(h) ? h : resolve(root, h); }; + if (!/BEGIN PRIVATE KEY/.test(raw)) return { keyPath: asPath(raw), keyId: k.keyId, issuerId: k.issuerId, cleanup: () => {} }; const dir = mkdtempSync(join(tmpdir(), 'storebound-key-')); chmodSync(dir, 0o700); const keyPath = join(dir, `AuthKey_${k.keyId}.p8`); diff --git a/cli/lib/xcode.ts b/cli/lib/xcode.ts index 0969ac8..3ff1171 100644 --- a/cli/lib/xcode.ts +++ b/cli/lib/xcode.ts @@ -242,3 +242,11 @@ export function distributionErrors(output: string, tmp: string = process.env.TMP } return logs[0] ? { log: logs[0], lines: [] } : null; } + +/** Why this is not the Mac's logged-in GUI session (SSH, or a launchd manager other than Aqua), else null: codesign + * cannot reach the login keychain there (errSecInternalComponent). */ +export function remoteBuildSession(env: Record, managerName: string): string | null { + if (env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY) return 'this is an SSH session'; + if (managerName && managerName !== 'Aqua') return `this is not the logged-in GUI session (launchctl manager: ${managerName})`; + return null; +} diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index 4de9ad2..d74c179 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -1220,3 +1220,16 @@ that fails without the fix (see CONTRIBUTING.md). --from-text `. - **Guard:** `textToFile` in `cli/mcp.ts` sends multi-line text as `--from-text `; `clip` keeps the start of long JSON output (the end of plain text) and names a file with the whole output (`tests/mcp-text-file.test.ts`). + +## 101. `apple build` over SSH: errSecInternalComponent; a relative ASC_P8 path: "must be an absolute path" +- **Symptom:** `storebound apple build` run over SSH on a Mac failed late in the archive (Embed Pods Frameworks, + codesign errSecInternalComponent). With `ASC_P8` set to a project-relative path, xcodebuild stopped with + "-authenticationKeyPath must be an absolute path". +- **Cause:** codesign in an SSH session cannot use the login keychain. The key path was handed to xcodebuild as written + (and read relative to the working directory, not the project). +- **Do:** run the build in the Mac's logged-in session: a CI agent started there, or from SSH a `.command` file opened + with `open -a Terminal ` (unset SSH_CONNECTION SSH_CLIENT SSH_TTY inside). `--allow-ssh` after + `security unlock-keychain` skips the check. +- **Guard:** `remoteBuildSession` in `cli/lib/xcode.ts` stops `apple build` before the archive; `credPath` in + `cli/lib/credentials.ts` resolves relative credential paths from the project and `ascKeyFile` hands xcodebuild an + absolute path (`tests/build-sign.test.ts`). diff --git a/tests/build-sign.test.ts b/tests/build-sign.test.ts index 83f1f1d..843df9f 100644 --- a/tests/build-sign.test.ts +++ b/tests/build-sign.test.ts @@ -488,3 +488,30 @@ describe('MCP', () => { for (const t of TOOLS.filter(t => ['apple_signing_status', 'originality'].includes(t.name))) expect(t.write).toBe(false); }); }); + +describe('apple build outside the GUI session (pitfall 101)', () => { + test('SSH or a non-Aqua launchd manager is named; the logged-in session passes', async () => { + const { remoteBuildSession } = await import('../cli/lib/xcode'); + expect(remoteBuildSession({ SSH_CONNECTION: '10.0.0.2 5000 10.0.0.3 22' }, 'Aqua')).toBe('this is an SSH session'); + expect(remoteBuildSession({}, 'Background')).toContain('launchctl manager: Background'); + expect(remoteBuildSession({}, 'Aqua')).toBeNull(); + }); + test('a project-relative ASC_P8 path becomes absolute (xcodebuild refuses a relative -authenticationKeyPath)', async () => { + const { mkdtempSync, mkdirSync, writeFileSync } = await import('node:fs'); + const { tmpdir } = await import('node:os'); + const { join, isAbsolute } = await import('node:path'); + const d = mkdtempSync(join(tmpdir(), 'sb-p8-')); + mkdirSync(join(d, '.storebound')); + const { generateKeyPairSync } = await import('node:crypto'); + writeFileSync(join(d, '.storebound/AuthKey_KEYID00002.p8'), generateKeyPairSync('ec', { namedCurve: 'P-256' }).privateKey.export({ type: 'pkcs8', format: 'pem' }).toString()); + writeFileSync(join(d, '.storebound/credentials.json'), JSON.stringify({ ASC_KEY_ID: 'KEYID00002', ASC_ISSUER_ID: '00000000-0000-0000-0000-000000000000', ASC_P8: '.storebound/AuthKey_KEYID00002.p8' })); + const saved = { ...process.env }; + try { + for (const k of ['ASC_KEY_ID', 'ASC_ISSUER_ID', 'ASC_P8']) delete process.env[k]; + const { ascKeyFile } = await import('../cli/lib/credentials'); + const path = ascKeyFile(d).keyPath; + expect(isAbsolute(path)).toBe(true); + expect(path).toBe(join(d, '.storebound/AuthKey_KEYID00002.p8')); + } finally { process.env = saved; } + }); +});