storebound/cli/lib/pre-check.ts

72 lines
4 KiB
TypeScript

// 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 "<cmd with {file}>" (config: upload.preCheck) {file} is replaced by the quoted IPA / AAB path
// --evidence <path.json> (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<PreCheckEvidence | null> {
const flag = o.args.get('pre-check');
if (flag === 'true') throw new Error('--pre-check "<command with {file}>" needs a command');
const template = (flag ?? o.config.upload?.preCheck)?.trim();
const evFlag = o.args.get('evidence');
if (evFlag === 'true') throw new Error('--evidence <path.json> 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;
}