// The pre-upload check of `apple upload` and `play upload`: a shell command the project owns (a store-readiness gate, // a policy scan of the binary) runs before any byte is sent. // --pre-check "" (config: upload.preCheck) {file} is replaced by the quoted IPA / AAB path // --evidence (config: upload.evidence) what ran, written also when the check fails // A nonzero exit refuses the upload: ShipStepError PRE_CHECK_FAILED (exit 1 under the ship-step contract), nothing // is sent. The evidence ({file, sha256, size, command, exitCode, stdoutTail, timestamp, storeboundVersion}) goes to // --evidence and into the --json / --result-file result as `preCheck`. import { createHash } from 'node:crypto'; import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; import { basename, dirname } from 'node:path'; import type { Args } from './args'; import type { ShipConfig } from './config'; import { log } from './log'; import { pump } from './runner'; import * as rt from './rt'; import { SHIP_EXIT, ShipStepError, shipExtra, shipPhase } from './ship-step'; export interface PreCheckEvidence { file: string; sha256: string; size: number; command: string; exitCode: number; stdoutTail: string[]; timestamp: string; storeboundVersion: string; } /** The command with {file} replaced by the POSIX-quoted path (sh -c); a command without {file} gets none. */ export function preCheckCommand(template: string, file: string): string { const q = `'${file.replace(/\\/g, '/').replace(/'/g, `'\\''`)}'`; return template.split('{file}').join(q); } function sha256File(path: string): string { return createHash('sha256').update(readFileSync(path)).digest('hex'); } /** * Runs the pre-check for `file` when --pre-check / upload.preCheck is set (else returns null). Throws PRE_CHECK_FAILED * on a nonzero exit, after writing the evidence. `shown` is the file name for the evidence (not the absolute path). */ export async function runPreCheck(o: { args: Args; config: ShipConfig; root: string; resolve: (p: string) => string; file: string; shown: string; tag: string }): Promise { const flag = o.args.get('pre-check'); if (flag === 'true') throw new Error('--pre-check "" needs a command'); const template = (flag ?? o.config.upload?.preCheck)?.trim(); const evFlag = o.args.get('evidence'); if (evFlag === 'true') throw new Error('--evidence needs a path'); const evPath = evFlag ?? o.config.upload?.evidence; if (!template) { if (evFlag) throw new Error('--evidence needs --pre-check (or upload.preCheck in the config)'); return null; } const command = preCheckCommand(template, o.file); shipPhase('pre-check', command); log(o.tag, `pre-check: $ ${command}`); const p = rt.spawn(['sh', '-c', command], { cwd: o.root, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe', env: process.env }); const tail: string[] = []; const each = (line: string, kind: 'stdout' | 'stderr'): void => { if (kind === 'stdout') { tail.push(line); if (tail.length > 20) tail.shift(); } console.error(` ${line}`); }; await Promise.all([pump(p.stdout, 'stdout', each), pump(p.stderr, 'stderr', each)]); const exitCode = Number(await p.exited); const version = ((await import('../../package.json')) as { version: string }).version; const ev: PreCheckEvidence = { file: o.shown.split('\\').join('/'), sha256: sha256File(o.file), size: statSync(o.file).size, command, exitCode, stdoutTail: tail, timestamp: new Date().toISOString(), storeboundVersion: version, }; if (evPath) { const abs = o.resolve(evPath); mkdirSync(dirname(abs), { recursive: true }); writeFileSync(abs, JSON.stringify(ev, null, 2) + '\n'); log(o.tag, `pre-check evidence: ${basename(abs)}`); } shipExtra({ preCheck: ev }); if (exitCode !== 0) throw new ShipStepError(`pre-check failed (exit ${exitCode}), nothing was uploaded: ${command}${tail.length ? `\n${tail.slice(-5).join('\n')}` : ''}`, SHIP_EXIT.failure, 'PRE_CHECK_FAILED'); log(o.tag, 'pre-check: ok'); return ev; }