Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
d71b158
feat: add the dockerContextDir resolver and Docker context file colle…
DaveHanns Oct 16, 2026
d49e15e
feat: refuse monorepo Actors in apify push before any platform write
DaveHanns Oct 16, 2026
3fa653b
feat: validate actor.json paths before apify push uploads anything
DaveHanns Oct 16, 2026
afa1cb7
test: cover the actor.json path checks and the push refusal
DaveHanns Oct 16, 2026
2ac0be9
test: pass the monorepo file list to the Docker context case instead …
DaveHanns Oct 16, 2026
8312f66
fix: resolve the Actor directory's real path and require a directory …
DaveHanns Oct 26, 2026
feb2cec
fix: return forward-slash paths from the Docker context file collector
DaveHanns Oct 26, 2026
b2ee951
fix: name the Actor folder in the monorepo refusal when actor.json ha…
DaveHanns Oct 26, 2026
d1fec8d
test: cover readGitProvenance and tidy the monorepo push test
DaveHanns Oct 26, 2026
86d8822
Merge remote-tracking branch 'origin/feat/push-monorepo-preflight' in…
DaveHanns Oct 26, 2026
db2cb8f
refactor: split the Docker context resolution into none, within, outs…
DaveHanns Oct 26, 2026
f728886
test: cover the none and within Docker context kinds
DaveHanns Oct 26, 2026
92b2679
Merge remote-tracking branch 'origin/feat/push-monorepo-preflight' in…
DaveHanns Oct 26, 2026
9300a01
fix: require the Dockerfile inside a Docker context within the Actor …
DaveHanns Oct 26, 2026
a9f2d19
refactor: rename readGitProvenance to readGitState
DaveHanns Oct 26, 2026
d9c0cb2
Merge remote-tracking branch 'origin/feat/push-monorepo-preflight' in…
DaveHanns Oct 26, 2026
a31364e
fix: judge actor.json paths lexically, as push uploads them
DaveHanns Oct 26, 2026
0b495cf
fix: check the inputSchema and outputSchema aliases
DaveHanns Oct 26, 2026
3e14b8b
fix: skip empty path fields and reject backslashes
DaveHanns Oct 26, 2026
775db15
test: cover the case mismatch reason and use the temp path hook
DaveHanns Oct 26, 2026
703159b
refactor: compute the path check invariants once
DaveHanns Oct 26, 2026
cc9e1a8
docs: state the BuildKit basis of the Docker context rule
DaveHanns Oct 26, 2026
39be1cb
docs: list the rules checkPathField applies, in order
DaveHanns Oct 26, 2026
f42dd5b
docs: name the dangling symlink and case rules in the checkPathField …
DaveHanns Oct 26, 2026
40a603c
test: cover dangling and symlinked Dockerfile paths and a symlinked A…
DaveHanns Oct 26, 2026
e3edc79
test: use a forward-slash absolute path so the check reads the same o…
DaveHanns Oct 26, 2026
50fb1b8
docs: explain why the Docker context is judged by its path as written
DaveHanns Oct 27, 2026
1af9abf
test: pin the Docker context classification through symlinks
DaveHanns Oct 27, 2026
c1cce18
Merge remote-tracking branch 'origin/feat/push-monorepo-preflight' in…
DaveHanns Oct 27, 2026
944b6ae
fix: return the Docker context path as the upload lays it out
DaveHanns Oct 27, 2026
5b807db
Merge remote-tracking branch 'origin/feat/push-monorepo-preflight' in…
DaveHanns Oct 27, 2026
ee0eda8
fix: compare the Dockerfile with the Docker context as the upload lay…
DaveHanns Oct 27, 2026
d56fdf7
test: accept a Dockerfile in a Docker context reached through a symlink
DaveHanns Oct 27, 2026
a91bf73
Merge remote-tracking branch 'origin/master' into feat/push-validate-…
DaveHanns Oct 27, 2026
cd7d2b5
fix: check the default Dockerfile against the Docker context
DaveHanns Oct 29, 2026
9b8ef37
fix: warn instead of failing when the platform falls back to its defa…
DaveHanns Oct 29, 2026
7f0a49c
test: cover the default Dockerfile and the README and CHANGELOG warni…
DaveHanns Oct 30, 2026
89b7811
fix: read actor.json paths and the default Dockerfile as the platform…
DaveHanns Oct 30, 2026
baadeb1
fix: check the paths in actor.json before asking for a login
DaveHanns Oct 30, 2026
539f218
Merge branch 'master' into feat/push-validate-actor-json-paths
l2ysho Oct 30, 2026
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
23 changes: 21 additions & 2 deletions src/commands/actors/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { fetchManifest } from '@apify/actor-templates';
import { ACTOR_JOB_STATUSES, ACTOR_JOB_TYPES, ACTOR_SOURCE_TYPES, MAX_MULTIFILE_BYTES } from '@apify/consts';
import { createHmacSignature } from '@apify/utilities';

import { checkActorPaths, formatActorPathProblems } from '../../lib/actor-paths.js';
import { ApifyCommand } from '../../lib/command-framework/apify-command.js';
import { Args } from '../../lib/command-framework/args.js';
import { Flags } from '../../lib/command-framework/flags.js';
Expand Down Expand Up @@ -273,8 +274,7 @@ export class ActorsPushCommand extends ApifyCommand<typeof ActorsPushCommand> {
return;
}

const apifyClient = await getLoggedClientOrThrow();

// Everything up to the path checks is local, so a broken Actor fails the same way with or without a login.
const actorConfigResult = await useActorConfig({ cwd });

if (actorConfigResult.isErr()) {
Expand All @@ -299,6 +299,25 @@ export class ActorsPushCommand extends ApifyCommand<typeof ActorsPushCommand> {
return;
}

// The platform resolves these paths after the upload; a wrong one builds the wrong image or fails the build.
const pathProblems = checkActorPaths({
actorDir: cwd,
uploadRoot: cwd,
uploadedFiles: filePathsToPush,
config: actorConfig,
dockerContextRoot: dockerContext.kind === 'within' ? dockerContext.contextRoot : undefined,
});
// A readme or changelog the platform cannot find only falls back to its default, so push goes on.
const pathWarnings = formatActorPathProblems(pathProblems, 'warning');
if (pathWarnings) warning({ message: pathWarnings });
const pathErrors = formatActorPathProblems(pathProblems, 'error');
if (pathErrors) {
error({ message: pathErrors });
process.exitCode = CommandExitCodes.InvalidActorJson;
return;
}

const apifyClient = await getLoggedClientOrThrow();
const userInfo = await getCurrentUserInfo();
const isOrganizationLoggedIn = !!userInfo.organizationOwnerUserId;
const redirectUrlPart = isOrganizationLoggedIn ? `/organization/${userInfo.id}` : '';
Expand Down
289 changes: 289 additions & 0 deletions src/lib/actor-paths.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,289 @@
import { readdirSync, realpathSync, type Stats, statSync } from 'node:fs';
import { basename, dirname, isAbsolute, join, posix, relative, resolve } from 'node:path';

import { ACTOR_SPECIFICATION_FOLDER } from './consts.js';
import { isOutside, toPosix } from './docker-context.js';

/**
* An `error` would fail the build or build the wrong thing, so push stops. A `warning` is a path the platform cannot
* find but recovers from with its default lookup, so the build succeeds and push continues.
*/
export type ActorPathSeverity = 'error' | 'warning';

export interface ActorPathProblem {
field: string;
/** Empty when the field is not set and the problem lies with what the platform uses by default. */
value: string;
resolvedPath: string;
reason: string;
severity: ActorPathSeverity;
}

export interface ActorPathsOptions {
actorDir: string;
/** What push sends: the Actor folder, or the source root of a monorepo Actor. */
uploadRoot: string;
/** Relative to `uploadRoot`, as the file collectors return them. */
uploadedFiles: string[];
config: Record<string, unknown>;
/**
* When given, `dockerfile` must lie inside it. The worker runs BuildKit with `--local dockerfile=<context dir>`
* and `filename=<Dockerfile relative to the context>`, so a Dockerfile outside the context cannot be read by the
* build. The same holds for a context within the Actor folder, which today's push already sends. Without
* `dockerfile`, the Dockerfile the platform would find by default is held to the same rules. Pass it as
* `resolveDockerContext` returns it: the path as written, from the real Actor folder.
*/
dockerContextRoot?: string;
}

/** The platform finds these by a directory listing, so their names match case-insensitively. */
const LISTED_FILE_FIELDS = ['dockerfile', 'readme', 'changelog'];
/** The worker copies a set alias over its base key before resolving, so the alias wins and the base key is ignored. */
const SCHEMA_FIELDS: { field: string; alias?: string }[] = [
{ field: 'input', alias: 'inputSchema' },
{ field: 'output', alias: 'outputSchema' },
{ field: 'webServerSchema' },
];
const STORAGE_SCHEMA_FIELDS = ['keyValueStore', 'dataset'];
/**
* When the worker cannot find the file these name, it logs a warning and falls back to its default lookup
* (`.actor/ACTOR.md`, `.actor/README.md`, then `README.md` or `README` in the Actor folder; likewise for the
* changelog), so the build succeeds. A missing `dockerfile` falls back too, but to a Dockerfile the user did not choose.
*/
const FALLBACK_FIELDS = new Set(['readme', 'changelog']);
/**
* Without `dockerfile`, the worker takes the first of these that exists, relative to `.actor/`; with none, it copies
* its built-in Node.js Dockerfile into the Docker context.
*/
const DEFAULT_DOCKERFILE_PATHS = ['./Dockerfile', '../Dockerfile'];

interface PathField {
field: string;
value: string;
caseInsensitive: boolean;
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}

/** Every file-path field the platform resolves relative to `.actor/`; an inline schema object is not a path. */
function collectPathFields(config: Record<string, unknown>): PathField[] {
const fields: PathField[] = [];
const add = (field: string, value: unknown, caseInsensitive = false) => {
// The worker treats an empty string as an unset field.
if (typeof value === 'string' && value !== '') fields.push({ field, value, caseInsensitive });
};

for (const field of LISTED_FILE_FIELDS) add(field, config[field], true);
Comment thread
l2ysho marked this conversation as resolved.
Resolved
for (const { field, alias } of SCHEMA_FIELDS) {
if (alias && config[alias]) add(alias, config[alias]);
else add(field, config[field]);
}
if (isRecord(config.storages)) {
for (const field of STORAGE_SCHEMA_FIELDS) add(`storages.${field}`, config.storages[field]);
if (isRecord(config.storages.datasets)) {
for (const [name, value] of Object.entries(config.storages.datasets)) {
add(`storages.datasets.${name}`, value);
}
}
}
return fields;
}

function listDirectory(dir: string): string[] {
try {
return readdirSync(dir);
} catch {
return [];
}
}

/** The entry for `filePath` in a listing of its directory, by the exact name and else by any case. */
function findEntry(filePath: string): { sameCase?: string; anyCase?: string } {
const wanted = basename(filePath);
const entries = listDirectory(dirname(filePath));
return {
sameCase: entries.find((entry) => entry === wanted),
anyCase: entries.find((entry) => entry.toLowerCase() === wanted.toLowerCase()),
};
}

/** The options as every field sees them: real roots and the uploaded files as posix paths. */
interface PathCheckScope {
actorDir: string;
uploadRoot: string;
uploadedFiles: Set<string>;
dockerContextRoot?: string;
}

/**
* Applies the platform's rules to one path field of `actor.json`, in order; the first failing rule wins.
* 1. Not absolute, and resolved from `.actor/`, inside the upload root; the platform never sees anything else. The
* platform resolves the value on Linux, where a backslash is part of a name, so this rule reads it that way first.
* 2. No backslashes: on Linux, such a name matches no file a push from another system uploads.
* 3. The file exists in its directory: by any case for `dockerfile`, `readme` and `changelog`, which the
* platform finds by listing the directory, and by the exact name for schema references. A dangling symlink
* does not exist.
* 4. It is a file, not a directory.
* 5. `dockerfile`, set or found by default, lies inside the Docker context, as BuildKit reads it from there.
* 6. It is among the files push uploads, in the same case: a file `.gitignore` or `.actorignore` drops never
* reaches the platform.
*
* Every problem is an error, except for `readme` and `changelog` failing rules 2 (a backslash), 3, or 6: the worker
* does not find such a file and falls back to its default, so these are warnings. An absolute path, a path outside
* the upload root, or a directory fails the build for those fields too.
*/
function checkPathField(
{ field, value, caseInsensitive }: PathField,
{ actorDir, uploadRoot, uploadedFiles, dockerContextRoot }: PathCheckScope,
): ActorPathProblem | undefined {
const problem = (resolvedPath: string, reason: string, platformFallsBack = false): ActorPathProblem => ({
field,
value,
resolvedPath,
reason,
severity: platformFallsBack && FALLBACK_FIELDS.has(field) ? 'warning' : 'error',
});
const absolute = () => problem(value, `is absolute; the path is relative to ${ACTOR_SPECIFICATION_FOLDER}/`);
const resolvedPath = resolve(actorDir, ACTOR_SPECIFICATION_FOLDER, value);
const outside = () => problem(resolvedPath, `lies outside the uploaded folder ${uploadRoot}`);

// As the platform reads the value: an absolute or escaping path fails the build, even with a backslash in it.
if (value.startsWith('/')) return absolute();
const uploadedPath = posix.join(toPosix(relative(uploadRoot, actorDir)), ACTOR_SPECIFICATION_FOLDER, value);
if (uploadedPath === '..' || uploadedPath.startsWith('../')) return outside();
if (value.includes('\\')) {
return problem(value, 'uses a backslash; the platform resolves paths with forward slashes', true);
}
// As this system reads it, for a Windows drive path.
if (isAbsolute(value)) return absolute();
if (isOutside(relative(uploadRoot, resolvedPath))) return outside();

const { sameCase, anyCase } = findEntry(resolvedPath);
const name = sameCase ?? (caseInsensitive ? anyCase : undefined);
if (!name) {
return problem(
resolvedPath,
anyCase ? `does not exist (the name must match ${anyCase} exactly)` : 'does not exist',
true,
);
}

// push reads through a symlink and uploads the content at this path, so the path is judged as it is, not by its
// target; a dangling symlink has no content to upload.
const filePath = join(dirname(resolvedPath), name);
let stats: Stats;
try {
stats = statSync(filePath);
} catch {
return problem(resolvedPath, 'does not exist', true);
}
if (stats.isDirectory()) return problem(resolvedPath, 'is a directory, not a file');
if (dockerContextRoot && field === 'dockerfile' && isOutside(relative(dockerContextRoot, filePath))) {
return problem(resolvedPath, `lies outside the Docker context ${dockerContextRoot}`);
}

const uploadedFilePath = toPosix(relative(uploadRoot, filePath));
if (!uploadedFiles.has(uploadedFilePath)) {
const differentCase = [...uploadedFiles].find((file) => file.toLowerCase() === uploadedFilePath.toLowerCase());
return problem(
resolvedPath,
differentCase
? `does not match the case of the uploaded file ${differentCase}`
: 'is not among the files apify push uploads (excluded by .gitignore or .actorignore?)',
true,
);
}
return undefined;
}

/** Whether push sends this file, or files inside this directory, so the platform finds it among the uploaded files. */
function isUploaded(filePath: string, { uploadRoot, uploadedFiles }: PathCheckScope): boolean {
const uploadedPath = toPosix(relative(uploadRoot, filePath));
return uploadedFiles.has(uploadedPath) || [...uploadedFiles].some((file) => file.startsWith(`${uploadedPath}/`));
}

/**
* Without `dockerfile`, the platform takes the first default Dockerfile among the uploaded files, else copies its
* built-in one into the Docker context. A default that `.gitignore` or `.actorignore` drops never reaches the
* platform, so the lookup moves on to the next one. The found default must pass the same rules as a set `dockerfile`.
* The built-in one is the documented default for a context that is the Actor folder itself; in any other context it
* would build files the built-in Dockerfile was never meant for, and silently ignore a Dockerfile the context may hold.
*/
function checkDefaultDockerfile(scope: PathCheckScope & { dockerContextRoot: string }): ActorPathProblem | undefined {
const { actorDir, dockerContextRoot } = scope;
const value = DEFAULT_DOCKERFILE_PATHS.find((candidate) => {
const candidatePath = resolve(actorDir, ACTOR_SPECIFICATION_FOLDER, candidate);
const { sameCase, anyCase } = findEntry(candidatePath);
const name = sameCase ?? anyCase;
return name !== undefined && isUploaded(join(dirname(candidatePath), name), scope);
});
if (value) {
const problem = checkPathField({ field: 'dockerfile', value, caseInsensitive: true }, scope);
return (
problem && {
...problem,
value: '',
reason: `is not set, so the platform would use the default ${problem.resolvedPath}, which ${problem.reason}`,
}
);
}
if (relative(actorDir, dockerContextRoot) === '') return undefined;
return {
field: 'dockerfile',
value: '',
resolvedPath: join(dockerContextRoot, 'Dockerfile'),
reason:
`is not set and push uploads no ${ACTOR_SPECIFICATION_FOLDER}/Dockerfile or Dockerfile in the Actor folder, ` +
`so the platform would build the Docker context ${dockerContextRoot} with its built-in Node.js Dockerfile; ` +
'add a Dockerfile, or set "dockerfile" to one inside the Docker context',
severity: 'error',
};
}

/**
* Applies the platform's rules to every file path in `actor.json` before anything is uploaded: relative to
* `.actor/`, inside the upload root, an existing file, and one that push sends. A symlink counts by its own path,
* as push uploads its content there. With a Docker context and no `dockerfile`, the default Dockerfile is checked too.
*/
export function checkActorPaths(options: ActorPathsOptions): ActorPathProblem[] {
// The Actor folder and the upload root are real paths, as the Docker context resolver works from them, so a
// symlinked Actor folder does not count as an escape. A referenced path, like the Docker context the resolver
// returns, is judged as written below the real Actor folder, the way push lays out the upload.
const scope: PathCheckScope = {
actorDir: realpathSync(options.actorDir),
uploadRoot: realpathSync(options.uploadRoot),
uploadedFiles: new Set(options.uploadedFiles.map(toPosix)),
dockerContextRoot: options.dockerContextRoot,
};
const pathFields = collectPathFields(options.config);
const problems = pathFields.flatMap((pathField) => checkPathField(pathField, scope) ?? []);

const { dockerContextRoot } = scope;
if (dockerContextRoot && !pathFields.some(({ field }) => field === 'dockerfile')) {
const problem = checkDefaultDockerfile({ ...scope, dockerContextRoot });
if (problem) problems.push(problem);
}
return problems;
}

const PROBLEM_HEADERS: Record<ActorPathSeverity, string> = {
error: '.actor/actor.json refers to files the Apify platform could not use:',
warning:
'.actor/actor.json refers to files the Apify platform will not find. ' +
'The push continues, and the platform falls back to its default README or CHANGELOG:',
};

/** The message for the problems of one severity, with a header that says what happens; empty when there are none. */
export function formatActorPathProblems(problems: ActorPathProblem[], severity: ActorPathSeverity): string {
const selected = problems.filter((problem) => problem.severity === severity);
if (selected.length === 0) return '';
return [
PROBLEM_HEADERS[severity],
...selected.map(({ field, value, resolvedPath, reason }) =>
value ? ` "${field}": "${value}" (${resolvedPath}) ${reason}.` : ` "${field}" ${reason}.`,
),
`Paths are relative to ${ACTOR_SPECIFICATION_FOLDER}/ and must point to files inside the uploaded folder.`,
].join('\n');
}
4 changes: 2 additions & 2 deletions src/lib/docker-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,11 +109,11 @@ export function resolveDockerContext(actorDir: string, dockerContextDir: unknown
return { kind: 'outside', contextRoot, sourceRoot: realContextRoot, actorPath: toPosix(actorPath) };
}

function isOutside(relativePath: string): boolean {
export function isOutside(relativePath: string): boolean {
return isAbsolute(relativePath) || relativePath === '..' || relativePath.startsWith(`..${sep}`);
}

function toPosix(filePath: string): string {
export function toPosix(filePath: string): string {
return filePath.split(sep).join('/');
}

Expand Down
Loading
Loading