From 4fcee60ba66f4fa43bc82c9e7666e410d3092ab3 Mon Sep 17 00:00:00 2001 From: CRT Date: Thu, 22 Oct 2026 02:04:28 +0800 Subject: [PATCH] Direct store API calls in a project's scripts: androidpublisher.googleapis.com, the googleapis androidpublisher client, api.appstoreconnect.apple.com, altool / Transporter in CI files and scripts; scan rule direct-store-api, check and release status warnings with file:line and the storebound command; storebound's own checkout skipped; test, pitfall 97 --- CHANGELOG.md | 4 ++++ cli/check.ts | 4 ++-- cli/lib/fastlane-traces.ts | 29 ++++++++++++++++++++++++++--- cli/lib/scan/index.ts | 10 +++++++++- cli/release.ts | 6 +++--- skills/store-pitfalls/SKILL.md | 12 ++++++++++++ tests/fastlane-traces.test.ts | 17 ++++++++++++++++- 7 files changed, 72 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e306175..9d0b8bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- **Scripts that call the store APIs themselves are named.** `androidpublisher.googleapis.com`, the googleapis + androidpublisher client, `api.appstoreconnect.apple.com`, altool and Transporter in CI files and scripts: `check` and + `release status` warn, `scan` reports `direct-store-api`, each with file:line and the storebound command. Pitfall 97. + - **fastlane left in a project is named.** `check` and `release status` warn and `scan` reports `fastlane-upload` when a Fastfile / Appfile or a CI file or script still runs `fastlane supply`, `deliver` or `pilot`, with the file:line and the way over (`storebound import fastlane`). Pitfall 96. diff --git a/cli/check.ts b/cli/check.ts index 9147c00..2e2ebd0 100644 --- a/cli/check.ts +++ b/cli/check.ts @@ -34,7 +34,7 @@ import { creativeNote } from './shots/creative'; import { type CapProject, capacitorProject, prebuildFor, staleMessage, webState } from './lib/web-assets'; import { iphoneDuoNote } from './shots/spec'; import { hitLine } from './lib/cross-store'; -import { FASTLANE_FIX, findFastlaneTraces, traceLine } from './lib/fastlane-traces'; +import { findFastlaneTraces, traceMessage } from './lib/fastlane-traces'; import { listingFileMentions } from './lib/listing-mentions'; /** The newest .ipa in a folder (apple build's export), or null. */ @@ -97,7 +97,7 @@ export async function run(ctx: Ctx): Promise { return `${Object.keys(copy).length} locales`; }); // fastlane uploads left next to storebound (pitfall 96) - try { const tr = findFastlaneTraces(dirname(path)); if (tr.length) for (const t of tr) note(`${traceLine(t)} · ${FASTLANE_FIX}`); } catch (e) { warn('check', `fastlane traces: ${(e as Error).message}`); } + try { const tr = findFastlaneTraces(dirname(path)); if (tr.length) for (const t of tr) note(traceMessage(t)); } catch (e) { warn('check', `fastlane traces: ${(e as Error).message}`); } // other platforms named in a store's text: App Store 2.3.10 / Play Metadata, a blocker (pitfall 77) try { for (const h of listingFileMentions(ctx.loaded)) problems.push(`other platform in ${hitLine(h)}`); } catch (e) { warn('check', `platform mentions: ${(e as Error).message}`); } // listing translation drafts (storebound listing translate): fine to keep working on, not to push unreviewed diff --git a/cli/lib/fastlane-traces.ts b/cli/lib/fastlane-traces.ts index 0a666ba..ddc0668 100644 --- a/cli/lib/fastlane-traces.ts +++ b/cli/lib/fastlane-traces.ts @@ -10,16 +10,30 @@ export interface FastlaneTrace { text: string; /** play (supply, upload_to_play_store), apple (deliver, pilot, upload_to_app_store / testflight) or both (a file). */ store: 'apple' | 'play' | 'both'; + /** fastlane, or a script that calls a store API / upload tool itself (no fastlane, same bypass). */ + via?: 'api'; + /** The storebound command that replaces it (api traces). */ + fix?: string; } +/** Store APIs and upload tools called by a project's own scripts: each with its storebound replacement. */ +const DIRECT: { re: RegExp; store: 'apple' | 'play'; what: string; fix: string }[] = [ + { re: /androidpublisher\.googleapis\.com|\bandroidpublisher\s*\(|google\.androidpublisher|['"]androidpublisher['"]/, store: 'play', what: 'Google Play Developer API called directly', + fix: 'use storebound: play upload / play release (MCP play_release_testing), play images (play_images), play listing pull (play_listing_pull) (pitfall 97)' }, + { re: /api\.appstoreconnect\.apple\.com/, store: 'apple', what: 'App Store Connect API called directly', + fix: 'use storebound: apple upload / apple submit / apple listing / apple screenshots (MCP apple_* tools) (pitfall 97)' }, + { re: /\baltool\b|iTMSTransporter|\bTransporter\b/, store: 'apple', what: 'altool / Transporter upload', + fix: 'use storebound apple upload (MCP apple_upload) (pitfall 97)' }, +]; + const UPLOAD = /\b(supply|upload_to_play_store|deliver|upload_to_app_store|pilot|upload_to_testflight)\b/; /** A fastlane command line in CI / scripts: `fastlane supply`, `bundle exec fastlane run deliver`, `fastlane pilot upload`. */ const CLI_CALL = /\bfastlane\s+(?:run\s+)?(supply|upload_to_play_store|deliver|upload_to_app_store|pilot|upload_to_testflight)\b/; const PLAY = new Set(['supply', 'upload_to_play_store']); const FASTLANE_DIRS = ['', 'ios', 'android', 'ios/App', 'android/app']; const CI_FILES = ['Jenkinsfile', '.gitlab-ci.yml', 'bitrise.yml', 'codemagic.yaml', 'azure-pipelines.yml', 'Makefile', 'package.json', 'appcenter-post-build.sh']; -const CI_DIRS = ['.github/workflows', '.forgejo/workflows', '.gitea/workflows', '.circleci', 'scripts', 'ci', '.ci', 'tools']; -const SCRIPT_EXT = /\.(ya?ml|sh|bash|zsh|ps1|groovy|rb|mjs|js|ts|json)$/i; +const CI_DIRS = ['.github/workflows', '.forgejo/workflows', '.gitea/workflows', '.circleci', 'scripts', 'ci', '.ci', 'tools', 'bin', 'deploy', 'release', 'ops']; +const SCRIPT_EXT = /\.(ya?ml|sh|bash|zsh|ps1|groovy|rb|py|mjs|cjs|js|ts|json)$/i; const storeOf = (m: string): 'apple' | 'play' => (PLAY.has(m) ? 'play' : 'apple'); @@ -29,6 +43,8 @@ function lines(file: string): string[] { export function findFastlaneTraces(root: string): FastlaneTrace[] { const out: FastlaneTrace[] = []; + // storebound's own checkout calls the store APIs by design + try { if ((JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { name?: string }).name === 'storebound') return out; } catch { /* no package.json */ } const rel = (p: string) => relative(root, p).split(sep).join('/'); const seen = new Set(); // Fastfile / Appfile (in fastlane/ or next to it): the file itself, plus each upload action it calls @@ -56,6 +72,10 @@ export function findFastlaneTraces(root: string): FastlaneTrace[] { lines(f).forEach((l, i) => { const m = CLI_CALL.exec(l); if (m) out.push({ file: rel(f), line: i + 1, text: l.trim().slice(0, 200), store: storeOf(m[1]!) }); + else if (!/package\.json$/.test(f) || /"[^"]*"\s*:\s*"/.test(l)) { + const d = DIRECT.find(x => x.re.test(l)); + if (d) out.push({ file: rel(f), line: i + 1, text: l.trim().slice(0, 200), store: d.store, via: 'api', fix: d.fix }); + } }); } return out; @@ -63,4 +83,7 @@ export function findFastlaneTraces(root: string): FastlaneTrace[] { export const FASTLANE_FIX = 'uploads outside storebound skip its checks and overwrite its listing: run `storebound import fastlane` (MCP import_fastlane), then replace the call with the storebound command (play upload / apple upload) (pitfall 96)'; -export const traceLine = (t: FastlaneTrace): string => `fastlane in ${t.file}:${t.line}${t.text ? `: ${t.text}` : ''}`; +export const traceLine = (t: FastlaneTrace): string => `${t.via === 'api' ? `${DIRECT.find(d => d.fix === t.fix)?.what ?? 'store API called directly'} in` : 'fastlane in'} ${t.file}:${t.line}${t.text ? `: ${t.text}` : ''}`; + +/** The warning for one trace: where, and the storebound way. */ +export const traceMessage = (t: FastlaneTrace): string => `${traceLine(t)} · ${t.fix ?? FASTLANE_FIX}`; diff --git a/cli/lib/scan/index.ts b/cli/lib/scan/index.ts index c692d81..81e3680 100644 --- a/cli/lib/scan/index.ts +++ b/cli/lib/scan/index.ts @@ -78,11 +78,19 @@ export async function runScan(dir: string, o: { config?: string } = {}): Promise const traces = findFastlaneTraces(dir); for (const store of ['apple', 'play'] as const) { if (config && !config[store]) continue; - const ev = traces.filter(t => t.store === store || t.store === 'both'); + const ev = traces.filter(t => !t.via && (t.store === store || t.store === 'both')); if (ev.length && !(config?.scan?.ignore ?? []).some(i => i.rule === 'fastlane-upload')) findings.push({ rule: 'fastlane-upload', guideline: 'storebound workflow', store, severity: 'medium', title: `fastlane still in the project (${ev.length} place${ev.length > 1 ? 's' : ''}); uploads there bypass storebound`, evidence: ev.map(t => ({ file: t.file, line: t.line, text: t.text })), fix: FASTLANE_FIX, skill: 'migrate-from-fastlane', ref: 'https://git.crt.fyi/CRT/storebound/src/branch/main/skills/migrate-from-fastlane/SKILL.md' }); } + // the project's own scripts call a store API or upload tool (pitfall 97) + for (const store of ['apple', 'play'] as const) { + if (config && !config[store]) continue; + const ev = traces.filter(t => t.via === 'api' && t.store === store); + if (ev.length && !(config?.scan?.ignore ?? []).some(i => i.rule === 'direct-store-api')) + findings.push({ rule: 'direct-store-api', guideline: 'storebound workflow', store, severity: 'medium', title: `scripts call the ${store === 'play' ? 'Google Play' : 'App Store Connect'} API or an upload tool directly (${ev.length} place${ev.length > 1 ? 's' : ''}); they bypass storebound`, + evidence: ev.map(t => ({ file: t.file, line: t.line, text: t.text })), fix: ev[0]!.fix!, skill: 'store-api', ref: 'https://git.crt.fyi/CRT/storebound/src/branch/main/skills/store-api/SKILL.md' }); + } for (const f of findings) { const action = f.accepted ? undefined : scanFixAction(f.rule, { configPath, declarationsChanged: declarations.changed }); if (action) f.action = action; diff --git a/cli/release.ts b/cli/release.ts index c31ef2f..fc217c1 100644 --- a/cli/release.ts +++ b/cli/release.ts @@ -21,7 +21,7 @@ import { laneOf, laneText, type PlanStep, releasePlan, type ReleasePlan, sameVer import type { Finding } from './lib/validate-rules'; import { findWorkspace, loadWorkspace, resolveApps, selectApps } from './lib/workspace'; import { appDir, appEnv, workspaceApps } from './workspace'; -import { FASTLANE_FIX, findFastlaneTraces, traceLine } from './lib/fastlane-traces'; +import { findFastlaneTraces, traceMessage } from './lib/fastlane-traces'; interface Target { name: string; dir: string; virtual: boolean; env: Record; problems: string[] } @@ -95,7 +95,7 @@ async function status(args: Args): Promise { const read = await pool(list, concurrency, async t => ({ t, s: await readLive(t) })); if (args.has('json')) { printJson({ apps: read.map(({ t, s }) => ({ - app: t.name, folder: t.virtual ? null : t.dir, errors: [...t.problems, ...s.errors], warnings: [...(s.warnings ?? []), ...(t.virtual ? [] : findFastlaneTraces(t.dir).map(f => `${traceLine(f)} · ${FASTLANE_FIX}`))], storeErrors: s.storeErrors, + app: t.name, folder: t.virtual ? null : t.dir, errors: [...t.problems, ...s.errors], warnings: [...(s.warnings ?? []), ...(t.virtual ? [] : findFastlaneTraces(t.dir).map(traceMessage))], storeErrors: s.storeErrors, trains: trainsOf(s, all).map(({ which, train }) => ({ which, version: train.version, ios: train.ios, android: train.android, lanes: { ios: laneOf('ios', train.ios), android: laneOf('android', train.android) } })), })), at: new Date().toISOString() }); @@ -107,7 +107,7 @@ async function status(args: Args): Promise { if (!trains.length) rows.push([t.name, '', '', s.errors[0] ? `error: ${s.errors[0]}` : unread ? 'not read (see the warnings)' : 'no version on either store yet', '']); for (const { which, train } of trains) rows.push([t.name, train.version, which === 'other' ? '' : which, cell('ios', train, s), cell('android', train, s)]); for (const w of s.warnings ?? []) warn('release', `${t.name}: ${w}`); - if (!t.virtual) for (const f of findFastlaneTraces(t.dir)) warn('release', `${t.name}: ${traceLine(f)} · ${FASTLANE_FIX}`); + if (!t.virtual) for (const f of findFastlaneTraces(t.dir)) warn('release', `${t.name}: ${traceMessage(f)}`); for (const p of [...t.problems, ...s.errors.slice(trains.length ? 0 : 1), ...Object.entries(s.storeErrors).map(([k, v]) => `${k}: ${v}`)]) warn('release', `${t.name}: ${p}`); } console.log(textTable(['app', 'version', 'train', 'App Store', 'Google Play'], rows)); diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index 322ab96..3e3eb02 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -1177,3 +1177,15 @@ that fails without the fix (see CONTRIBUTING.md). - **Guard:** `findFastlaneTraces` in `cli/lib/fastlane-traces.ts` (Fastfile / Appfile, CI files and scripts with `fastlane supply|deliver|pilot`); `check` warns, `scan` reports `fastlane-upload` with file:line, `release status` warns per app (`tests/fastlane-traces.test.ts`). + +## 97. The project's own scripts called the store APIs (no fastlane, same bypass) +- **Symptom:** an app uploaded AABs, set the track and rollout, pushed listing images and read the listing back with + three hand-written scripts (a self-signed JWT against `androidpublisher.googleapis.com`); storebound said nothing. +- **Cause:** pitfall 96 looked only for fastlane; direct API calls and upload tools (altool, Transporter) were not read. +- **Do:** replace each script with the storebound command: `play upload` / `play release` (MCP `play_release_testing`), + `play images` (`play_images`), `play listing pull` (`play_listing_pull`); for Apple `apple upload` and the other + `apple` commands (MCP `apple_*`). Keep one on purpose only with `scan.ignore` `direct-store-api` and the reason. +- **Guard:** `findFastlaneTraces` in `cli/lib/fastlane-traces.ts` also reports `androidpublisher.googleapis.com`, the + googleapis androidpublisher client, `api.appstoreconnect.apple.com`, `altool` / `iTMSTransporter` in CI files and + scripts (not storebound's own checkout, never node_modules); `scan` rule `direct-store-api`, `check` and + `release status` warnings with file:line and the storebound command (`tests/fastlane-traces.test.ts`). diff --git a/tests/fastlane-traces.test.ts b/tests/fastlane-traces.test.ts index c0e30e9..5b7a4df 100644 --- a/tests/fastlane-traces.test.ts +++ b/tests/fastlane-traces.test.ts @@ -3,7 +3,7 @@ import { describe, expect, test } from 'bun:test'; import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { findFastlaneTraces, traceLine } from '../cli/lib/fastlane-traces'; +import { findFastlaneTraces, traceLine, traceMessage } from '../cli/lib/fastlane-traces'; import { runScan } from '../cli/lib/scan'; function project(files: Record): string { @@ -30,6 +30,21 @@ describe('fastlane traces', () => { expect(t.filter(x => x.file === 'android/fastlane/Fastfile')).toHaveLength(1); expect(traceLine(t.find(x => x.file === 'Jenkinsfile')!)).toBe('fastlane in Jenkinsfile:2: sh "bundle exec fastlane supply --aab app.aab"'); }); + test('scripts that call the store APIs or upload tools themselves, with the storebound way', () => { + const dir = project({ + 'scripts/play-upload.mjs': "const url = 'https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications/com.example.app/edits';\n", + 'tools/asc.py': "BASE = 'https://api.appstoreconnect.apple.com/v1'\n", + 'deploy/ios.sh': 'xcrun altool --upload-app -f app.ipa\n', + 'package.json': '{"name":"an-app","scripts":{"ship":"node scripts/play-upload.mjs"}}', + }); + const t = findFastlaneTraces(dir); + expect(t.map(x => `${x.file} ${x.store} ${x.via}`).sort()).toEqual(['deploy/ios.sh apple api', 'scripts/play-upload.mjs play api', 'tools/asc.py apple api']); + expect(traceMessage(t.find(x => x.store === 'play')!)).toContain('play_release_testing'); + expect(traceMessage(t.find(x => x.file === 'deploy/ios.sh')!)).toStartWith('altool / Transporter upload in deploy/ios.sh:1'); + }); + test("storebound's own checkout is not reported", () => { + expect(findFastlaneTraces(project({ 'package.json': '{"name":"storebound"}', 'scripts/x.ts': "fetch('https://api.appstoreconnect.apple.com/v1/apps')\n" }))).toEqual([]); + }); test('a project without fastlane has no traces', () => { expect(findFastlaneTraces(project({ 'package.json': '{"scripts":{"build":"vite build"}}' }))).toEqual([]); });