Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 73 additions & 1 deletion src/commands/actors/push.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { readFileSync, statSync, unlinkSync } from 'node:fs';
import { existsSync, readFileSync, statSync, unlinkSync } from 'node:fs';
import { join, resolve } from 'node:path';
import process from 'node:process';

Expand Down Expand Up @@ -121,6 +121,70 @@ export function resolvePushOutcome(buildStatus: string): PushOutcome {
}
}

// Best-effort pre-upload check for a common TypeScript packaging trap: a
// single-stage Dockerfile that runs `npm install --omit=dev` (or
// `--production`) and a package.json build script that shells out to `tsc`,
// while `typescript` sits in devDependencies. The platform-side Docker build
// will fail because tsc is dropped before `npm run build` runs. This is a
// warning only — the source of truth is the platform build.
export function detectOmitDevTscTrap(cwd: string): string | null {
const dockerfileCandidates = [join(cwd, '.actor', 'Dockerfile'), join(cwd, 'Dockerfile')];
let dockerfileContent: string | null = null;
for (const candidate of dockerfileCandidates) {
if (existsSync(candidate)) {
try {
dockerfileContent = readFileSync(candidate, 'utf8');
break;
} catch {
// unreadable — ignore, this check is best-effort
}
}
}
if (!dockerfileContent) return null;

// Match `npm ci|install|i` followed anywhere on the same command by
// `--omit=dev` or `--production` (with optional `=true`). Line-based to
// avoid crossing RUN boundaries. Backslash continuations are handled by
// checking each logical `\` -joined chunk.
const commandChunks = dockerfileContent
.replace(/\\\n/g, ' ') // fold backslash-continuations onto one line
.split('\n');
const dropsDevDeps = commandChunks.some(
(line) => /\bnpm\s+(ci|install|i)\b/.test(line) && /(--omit[= ]dev|--production(?:[= ]true)?)/.test(line),
);
if (!dropsDevDeps) return null;

// package.json lookup
const packageJsonPath = join(cwd, 'package.json');
if (!existsSync(packageJsonPath)) return null;
let pkg: {
scripts?: Record<string, string>;
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
};
try {
pkg = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
} catch {
// malformed package.json — not our problem to diagnose here
return null;
}

const buildScript = pkg.scripts?.build ?? '';
// Match a standalone `tsc` invocation (not `tsc-alias`, `tsconfig`, etc.).
const buildUsesTsc = /(^|[\s&|;])tsc($|[\s&|;])/.test(buildScript);
if (!buildUsesTsc) return null;

const tsInDev = !!pkg.devDependencies?.typescript;
const tsInProd = !!pkg.dependencies?.typescript;
if (!tsInDev || tsInProd) return null;

return [
'npm --omit=dev drops typescript (in devDependencies); the build stage needs tsc.',
'Either (a) use a multi-stage Dockerfile where the build stage installs devDeps then',
'copies dist/ to the runtime stage, or (b) drop --omit=dev.',
].join('\n ');
}

export class ActorsPushCommand extends ApifyCommand<typeof ActorsPushCommand> {
static override name = 'push' as const;

Expand Down Expand Up @@ -326,6 +390,14 @@ export class ActorsPushCommand extends ApifyCommand<typeof ActorsPushCommand> {

info({ message: `Deploying Actor '${actorConfig!.name}' to Apify.` });

// Best-effort local sanity check: warn (never block) if the Dockerfile
// drops devDependencies before running a tsc-based build script. See
// detectOmitDevTscTrap for the full heuristic.
const trapWarning = detectOmitDevTscTrap(cwd);
if (trapWarning) {
warning({ message: trapWarning });
}

const filesSize = await sumFilesSizeInBytes(filePathsToPush, cwd);

let sourceType;
Expand Down
114 changes: 113 additions & 1 deletion test/local/commands/push.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

import { ACTOR_JOB_STATUSES } from '@apify/consts';

import { resolvePushOutcome } from '../../../src/commands/actors/push.js';
import { detectOmitDevTscTrap, resolvePushOutcome } from '../../../src/commands/actors/push.js';
import { CommandExitCodes } from '../../../src/lib/consts.js';

describe('resolvePushOutcome', () => {
Expand Down Expand Up @@ -37,3 +41,111 @@ describe('resolvePushOutcome', () => {
expect(resolvePushOutcome(ACTOR_JOB_STATUSES.FAILED).errorMessage).toBe('Build failed');
});
});

describe('detectOmitDevTscTrap', () => {
function makeProject(files: Record<string, string>): string {
const root = mkdtempSync(join(tmpdir(), 'apify-cli-push-trap-'));
for (const [rel, content] of Object.entries(files)) {
const abs = join(root, rel);
mkdirSync(join(abs, '..'), { recursive: true });
writeFileSync(abs, content);
}
return root;
}

test('warns when Dockerfile omits dev deps and build uses tsc with typescript only in devDependencies', () => {
const root = makeProject({
Dockerfile: 'FROM node:20\nRUN npm install --omit=dev\nCMD ["node", "dist/main.js"]\n',
'package.json': JSON.stringify({
scripts: { build: 'tsc' },
devDependencies: { typescript: '^5.0.0' },
}),
});
expect(detectOmitDevTscTrap(root)).toMatch(/npm --omit=dev/);
});

test('warns for .actor/Dockerfile as well', () => {
const root = makeProject({
'.actor/Dockerfile': 'FROM node:20\nRUN npm ci --omit=dev\n',
'package.json': JSON.stringify({
scripts: { build: 'tsc -p tsconfig.build.json' },
devDependencies: { typescript: '^5.0.0' },
}),
});
expect(detectOmitDevTscTrap(root)).not.toBeNull();
});

test('also recognizes --production as dev-drop flag', () => {
const root = makeProject({
Dockerfile: 'FROM node:20\nRUN npm install --production\n',
'package.json': JSON.stringify({
scripts: { build: 'tsc' },
devDependencies: { typescript: '^5.0.0' },
}),
});
expect(detectOmitDevTscTrap(root)).not.toBeNull();
});

test('does not warn when typescript is also in dependencies (already available at build time)', () => {
const root = makeProject({
Dockerfile: 'FROM node:20\nRUN npm install --omit=dev\n',
'package.json': JSON.stringify({
scripts: { build: 'tsc' },
dependencies: { typescript: '^5.0.0' },
devDependencies: { typescript: '^5.0.0' },
}),
});
expect(detectOmitDevTscTrap(root)).toBeNull();
});

test('does not warn when Dockerfile does not drop dev deps', () => {
const root = makeProject({
Dockerfile: 'FROM node:20\nRUN npm install\n',
'package.json': JSON.stringify({
scripts: { build: 'tsc' },
devDependencies: { typescript: '^5.0.0' },
}),
});
expect(detectOmitDevTscTrap(root)).toBeNull();
});

test('does not warn when build script does not use tsc', () => {
const root = makeProject({
Dockerfile: 'FROM node:20\nRUN npm install --omit=dev\n',
'package.json': JSON.stringify({
scripts: { build: 'tsdown' },
devDependencies: { typescript: '^5.0.0' },
}),
});
expect(detectOmitDevTscTrap(root)).toBeNull();
});

test('does not confuse tsc-alias / tsconfig-paths for a tsc invocation', () => {
const root = makeProject({
Dockerfile: 'FROM node:20\nRUN npm install --omit=dev\n',
'package.json': JSON.stringify({
scripts: { build: 'tsc-alias' },
devDependencies: { typescript: '^5.0.0' },
}),
});
expect(detectOmitDevTscTrap(root)).toBeNull();
});

test('returns null when there is no Dockerfile', () => {
const root = makeProject({
'package.json': JSON.stringify({
scripts: { build: 'tsc' },
devDependencies: { typescript: '^5.0.0' },
}),
});
expect(detectOmitDevTscTrap(root)).toBeNull();
});

test('returns null when package.json is missing or malformed', () => {
const root = makeProject({
Dockerfile: 'FROM node:20\nRUN npm install --omit=dev\n',
'package.json': '{ this is not valid json',
});
expect(detectOmitDevTscTrap(root)).toBeNull();
});
});
Loading