diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bed5bc..e306175 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- **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. + ## 0.8.24 · 2026-10-21 - **Windows: the MCP server starts.** `storebound install` writes `cmd /c npx -y storebound@ mcp` (or diff --git a/cli/check.ts b/cli/check.ts index 8b9368e..9147c00 100644 --- a/cli/check.ts +++ b/cli/check.ts @@ -34,6 +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 { listingFileMentions } from './lib/listing-mentions'; /** The newest .ipa in a folder (apple build's export), or null. */ @@ -95,6 +96,8 @@ 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}`); } // 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 new file mode 100644 index 0000000..0a666ba --- /dev/null +++ b/cli/lib/fastlane-traces.ts @@ -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(); + // 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}` : ''}`; diff --git a/cli/lib/scan/index.ts b/cli/lib/scan/index.ts index 5b88cff..c692d81 100644 --- a/cli/lib/scan/index.ts +++ b/cli/lib/scan/index.ts @@ -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; diff --git a/cli/release.ts b/cli/release.ts index dfaf404..c31ef2f 100644 --- a/cli/release.ts +++ b/cli/release.ts @@ -21,6 +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'; interface Target { name: string; dir: string; virtual: boolean; env: Record; problems: string[] } @@ -94,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 ?? [], 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(f => `${traceLine(f)} · ${FASTLANE_FIX}`))], 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() }); @@ -106,6 +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}`); 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 68af0d1..322ab96 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -1166,3 +1166,14 @@ that fails without the fix (see CONTRIBUTING.md). - **Do:** run the command again later; after a release, warm the CDN cache by fetching every installer once. - **Guard:** `download` in `cli/desktop.ts` reads in chunks, prints progress every 10 % and fails after 60 s without data (`tests/desktop-dist.test.ts`). + +## 96. A project still uploaded with fastlane and storebound never said so +- **Symptom:** the Play listing or build changed behind storebound's back; CI ran `fastlane supply` (or `deliver` / + `pilot`) next to the storebound config, and `check`, `scan` and `release status` were all quiet. +- **Cause:** storebound looked only at its own config; fastlane files and CI upload calls were never read. +- **Do:** run `storebound import fastlane` (MCP `import_fastlane`) to bring the metadata over, then replace each + upload call with the storebound command and remove the lane. Keep a lane only on purpose (`scan.ignore` + `fastlane-upload` with the reason). +- **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`). diff --git a/tests/fastlane-traces.test.ts b/tests/fastlane-traces.test.ts new file mode 100644 index 0000000..c0e30e9 --- /dev/null +++ b/tests/fastlane-traces.test.ts @@ -0,0 +1,43 @@ +// fastlane left in a project (Fastfile, CI calls to supply / deliver / pilot) is found and named (pitfall 96). +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 { runScan } from '../cli/lib/scan'; + +function project(files: Record): string { + const dir = mkdtempSync(join(tmpdir(), 'sb-fl-')); + for (const [p, text] of Object.entries(files)) { mkdirSync(join(dir, p, '..'), { recursive: true }); writeFileSync(join(dir, p), text); } + return dir; +} + +describe('fastlane traces', () => { + test('Fastfile actions, Appfile, and CI / script calls with file:line', () => { + const dir = project({ + 'android/fastlane/Fastfile': 'lane :ship do\n # supply in a comment is not a call\n supply(track: "internal")\nend\n', + 'fastlane/Appfile': 'app_identifier("com.example.game")\n', + 'Jenkinsfile': 'stage("upload") {\n sh "bundle exec fastlane supply --aab app.aab"\n}\n', + '.github/workflows/ios.yml': 'steps:\n - run: fastlane pilot upload\n', + 'scripts/release.sh': 'echo fastlane is fine to mention\n', + }); + const t = findFastlaneTraces(dir); + expect(t).toContainEqual({ file: 'android/fastlane/Fastfile', line: 3, text: 'supply(track: "internal")', store: 'play' }); + expect(t).toContainEqual({ file: 'fastlane/Appfile', line: 1, text: 'app_identifier("com.example.game")', store: 'both' }); + expect(t).toContainEqual({ file: 'Jenkinsfile', line: 2, text: 'sh "bundle exec fastlane supply --aab app.aab"', store: 'play' }); + expect(t).toContainEqual({ file: '.github/workflows/ios.yml', line: 2, text: '- run: fastlane pilot upload', store: 'apple' }); + expect(t.some(x => x.file === 'scripts/release.sh')).toBe(false); + 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('a project without fastlane has no traces', () => { + expect(findFastlaneTraces(project({ 'package.json': '{"scripts":{"build":"vite build"}}' }))).toEqual([]); + }); + test('scan reports fastlane-upload with the evidence lines', async () => { + const dir = project({ 'Jenkinsfile': 'sh "fastlane supply"\n' }); + const { report } = await runScan(dir, {}); + const f = report.findings.find(x => x.rule === 'fastlane-upload' && x.store === 'play'); + expect(f?.evidence[0]).toMatchObject({ file: 'Jenkinsfile', line: 1 }); + expect(f?.fix).toContain('storebound import fastlane'); + }); +});