// Fastlane left in a project storebound manages: a CI job or script that still uploads with `fastlane supply` // (or deliver / pilot) bypasses storebound's checks and fights its writes, and nothing said so (pitfall 96). // Offline and read-only: looks for Fastfile / Appfile, a fastlane/ folder and upload calls in CI files and scripts. import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; import { join, relative, sep } from 'node:path'; export interface FastlaneTrace { file: string; line: number; 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', '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'); function lines(file: string): string[] { try { return statSync(file).size > 2_000_000 ? [] : readFileSync(file, 'utf8').split(/\r?\n/); } catch { return []; } } 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 for (const d of FASTLANE_DIRS) { for (const f of [join(root, d, 'fastlane', 'Fastfile'), join(root, d, 'Fastfile'), join(root, d, 'fastlane', 'Appfile'), join(root, d, 'Appfile')]) { if (seen.has(f) || !existsSync(f)) continue; seen.add(f); const ls = lines(f); let calls = 0; ls.forEach((l, i) => { const m = UPLOAD.exec(l.replace(/#.*/, '')); if (m && /Fastfile$/.test(f)) { out.push({ file: rel(f), line: i + 1, text: l.trim(), store: storeOf(m[1]!) }); calls++; } }); if (!calls) out.push({ file: rel(f), line: 1, text: ls.find(l => l.trim())?.trim() ?? '', store: 'both' }); } } // CI files and scripts that run fastlane uploads const files = CI_FILES.map(f => join(root, f)).filter(f => existsSync(f)); for (const d of CI_DIRS) { const dir = join(root, d); try { for (const n of readdirSync(dir)) if (SCRIPT_EXT.test(n)) files.push(join(dir, n)); } catch { /* none */ } } try { for (const n of readdirSync(root)) if (/\.(sh|bash|ps1)$/i.test(n)) files.push(join(root, n)); } catch { /* unreadable */ } // test files quote the tools in their assertions; they upload nothing for (const f of [...new Set(files)].filter(x => !/\.(test|spec)\.[cm]?[jt]sx?$/i.test(x))) { 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]!) }); // comment lines name the tools without calling them; one line per file and tool is enough (a real project // listed the same script five times, mostly from its doc comments) else if ((!/package\.json$/.test(f) || /"[^"]*"\s*:\s*"/.test(l)) && !/^\s*(\/\/|\/?\*|#|--|REM\b)/i.test(l)) { const d = DIRECT.find(x => x.re.test(l)); if (d && !out.some(o => o.file === rel(f) && o.fix === d.fix)) out.push({ file: rel(f), line: i + 1, text: l.trim().slice(0, 200), store: d.store, via: 'api', fix: d.fix }); } }); } return out; } 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 => `${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}`;