fastlane traces: check and release status warn, scan reports fastlane-upload when a Fastfile / Appfile or a CI file or script still runs fastlane supply, deliver or pilot (file:line, storebound import fastlane); test, pitfall 96

This commit is contained in:
CRTkafa 2026-10-22 01:39:14 +08:00
parent e03cbbe8aa
commit 4f993a906a
7 changed files with 140 additions and 1 deletions

View file

@ -0,0 +1,66 @@
// 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';
}
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 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[] = [];
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
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 */ }
for (const f of new Set(files)) {
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]!) });
});
}
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 => `fastlane in ${t.file}:${t.line}${t.text ? `: ${t.text}` : ''}`;

View file

@ -10,6 +10,7 @@ import { AI_FIX_RULES, scanFixAction } from '../fix-actions';
import { proposeDeclarations, type Proposal } from './declare';
import { detectProject, ECO_LABEL, type ProjectFacts, type Stack, where } from './project';
import type { OriginalityReport } from '../originality';
import { FASTLANE_FIX, findFastlaneTraces } from '../fastlane-traces';
import { APPLE_GUIDELINES, applyIgnore, type ScanFinding, scanRules, type Severity, sortScan, unusedIgnores } from './rules';
import type { SdkCategory } from './sdks';
@ -73,6 +74,15 @@ export async function runScan(dir: string, o: { config?: string } = {}): Promise
fix: g.fix ?? '', skill: 'game-services', ref: 'https://developer.android.com/games/pgs/android/android-start' });
} catch { /* unreadable Android project */ }
}
// fastlane still uploading next to storebound (pitfall 96)
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');
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' });
}
for (const f of findings) {
const action = f.accepted ? undefined : scanFixAction(f.rule, { configPath, declarationsChanged: declarations.changed });
if (action) f.action = action;