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
This commit is contained in:
parent
4f993a906a
commit
4fcee60ba6
7 changed files with 72 additions and 10 deletions
|
|
@ -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<string>();
|
||||
// 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}`;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue