Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
87 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
8e02369
feat: push monorepo Actors by uploading the repository root with acto…
DaveHanns Oct 16, 2026
87c374b
test: cover the monorepo upload, the zip path, and the actorPath skew…
DaveHanns Oct 16, 2026
e330a0f
fix: upload a monorepo Actor's directory symlinks as the files under …
DaveHanns Oct 16, 2026
128e96a
test: restore the global exit code after each monorepo push case
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
bcbdd76
Merge remote-tracking branch 'origin/feat/push-validate-actor-json-pa…
DaveHanns Oct 26, 2026
6d2bdfa
fix: check the actor.json paths of a monorepo Actor from its real folder
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
72d9405
Merge remote-tracking branch 'origin/feat/push-validate-actor-json-pa…
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
138e429
Merge remote-tracking branch 'origin/feat/push-validate-actor-json-pa…
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
d523fce
Merge remote-tracking branch 'origin/feat/push-validate-actor-json-pa…
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
c735cca
Merge remote-tracking branch 'origin/feat/push-validate-actor-json-pa…
DaveHanns Oct 26, 2026
9a1a53d
fix: leave links pointing outside the repository out of the monorepo …
DaveHanns Oct 26, 2026
00755a3
test: cover schemas reached through symlinks in a monorepo push
DaveHanns Oct 26, 2026
e3edc79
test: use a forward-slash absolute path so the check reads the same o…
DaveHanns Oct 26, 2026
2df0d7b
Merge remote-tracking branch 'origin/feat/push-validate-actor-json-pa…
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
0237662
Merge remote-tracking branch 'origin/feat/push-validate-actor-json-pa…
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
1c1c13a
Merge remote-tracking branch 'origin/feat/push-validate-actor-json-pa…
DaveHanns Oct 27, 2026
04fb965
test: accept a Dockerfile in a monorepo Docker context reached throug…
DaveHanns Oct 27, 2026
a91bf73
Merge remote-tracking branch 'origin/master' into feat/push-validate-…
DaveHanns Oct 27, 2026
6fa7e87
Merge remote-tracking branch 'origin/feat/push-validate-actor-json-pa…
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
474e015
Merge remote-tracking branch 'origin/feat/push-validate-actor-json-pa…
DaveHanns Oct 30, 2026
77a4cb4
test: cover the default Dockerfile for a monorepo Actor with the Dock…
DaveHanns Oct 30, 2026
50e19e8
Merge remote-tracking branch 'origin/master' into feat/push-monorepo-…
DaveHanns Oct 30, 2026
7555cb4
Merge remote-tracking branch 'origin/master' into feat/push-monorepo-…
DaveHanns Nov 3, 2026
141295e
test: push monorepo Actors with --all from the repository root
DaveHanns Nov 3, 2026
083f895
fix: refuse a monorepo Actor whose folder is ignored, which its uploa…
DaveHanns Nov 3, 2026
fc80b89
fix: send a monorepo Actor's own folder as an ordinary push of it would
DaveHanns Nov 3, 2026
857b4e4
fix: fall back to the JS realpath where the native call fails
DaveHanns Nov 3, 2026
1408e0d
fix: list a conflicted path once in a monorepo upload
DaveHanns Nov 3, 2026
cb46750
fix: name both causes when dockerContextDir leads back into the Actor…
DaveHanns Nov 3, 2026
00ea82d
fix: resolve createActZip only once the zip file is written
DaveHanns Nov 3, 2026
bba2cd6
fix: zip a file reached through a symlink as its content
DaveHanns Nov 3, 2026
284fc13
fix: remove the temporary zip when the push fails before the upload
DaveHanns Nov 3, 2026
dc30e13
fix: write a monorepo zip under its own record key
DaveHanns Nov 3, 2026
2791c23
fix: let the actorPath field's own validation errors pass the skew guard
DaveHanns Nov 3, 2026
465aefb
fix: keep an Actor-folder link whose target spells a folder in anothe…
DaveHanns Nov 3, 2026
a28a7ba
fix: send one copy of an Actor folder that Git lists in its old case
DaveHanns Nov 3, 2026
0bea952
Merge remote-tracking branch 'origin/master' into feat/push-monorepo-…
DaveHanns Nov 4, 2026
8859c93
fix: read git's whole file list in a monorepo push, names starting wi…
DaveHanns Nov 4, 2026
f3ff68d
fix: zip a monorepo upload into one folder, which the platform takes …
DaveHanns Nov 4, 2026
ee73d34
fix: reject an absolute or backslashed dockerContextDir before apify …
DaveHanns Nov 4, 2026
06f2ec1
fix: leave out files under a folder symlink leading out of a monorepo…
DaveHanns Nov 4, 2026
de0fbfe
fix: warn about uncommitted changes in a repository with no commits yet
DaveHanns Nov 4, 2026
55d0dc6
fix: leave out a link to a file a clone lacks in a monorepo upload
DaveHanns Nov 4, 2026
b911384
fix: zip a monorepo upload with the repository root at its top level
DaveHanns Nov 4, 2026
383d518
fix: send a link elsewhere in a monorepo to a file only the Actor fol…
DaveHanns Nov 4, 2026
9bb5a56
Merge remote-tracking branch 'origin/master' into feat/push-monorepo-…
DaveHanns Nov 6, 2026
fad75aa
fix: close the monorepo upload gaps found in review
DaveHanns Nov 8, 2026
9e17d26
fix: drop the actorPath skew guard and describe monorepo pushes in th…
DaveHanns Nov 9, 2026
97d36a0
test: enable Git long paths for the long file name case on Windows
DaveHanns Nov 9, 2026
6146777
Merge remote-tracking branch 'origin/master' into feat/push-monorepo-…
DaveHanns Nov 9, 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
4 changes: 4 additions & 0 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -859,6 +859,10 @@ DESCRIPTION
as ZIP file.
Files matched by .gitignore and .actorignore are excluded. Use negation
patterns (e.g. !dist/) in .actorignore to force-include git-ignored files.
If "dockerContextDir" leads outside the Actor folder (a monorepo Actor), push
uploads the whole Git repository: the Actor folder as above, and everything
else that Git tracks or doesn't ignore (.actorignore doesn't apply there).
Outside Git, it uploads the whole Docker context as above.
Use --force to override newer remote versions.

USAGE
Expand Down
322 changes: 286 additions & 36 deletions src/commands/actors/push.ts

Large diffs are not rendered by default.

14 changes: 7 additions & 7 deletions src/lib/actor-paths.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { readdirSync, realpathSync, type Stats, statSync } from 'node:fs';
import { readdirSync, 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';
import { isOutside, realPath, 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
Expand Down Expand Up @@ -248,12 +248,12 @@ function checkDefaultDockerfile(scope: PathCheckScope & { dockerContextRoot: str
* 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.
// The Actor folder and the upload root are real paths, in their case on disk, 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),
actorDir: realPath(options.actorDir),
uploadRoot: realPath(options.uploadRoot),
uploadedFiles: new Set(options.uploadedFiles.map(toPosix)),
dockerContextRoot: options.dockerContextRoot,
};
Expand Down
89 changes: 57 additions & 32 deletions src/lib/docker-context.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,12 @@
// The Docker context of an Actor: where `dockerContextDir` in `.actor/actor.json` points and what a push of it
// would carry. `readGitState` and `getContextFilePaths` have no caller in `apify push` yet; they serve the
// stage 2 upload of the context and the `actor-runtime` branch.
// carries.
import { execFileSync } from 'node:child_process';
import { lstatSync, realpathSync, statSync } from 'node:fs';
import { isAbsolute, join, relative, resolve, sep } from 'node:path';

import { ACTOR_SPECIFICATION_FOLDER } from './consts.js';
import { getActorLocalFilePaths } from './utils.js';

/** Tracks pushing Actors with `dockerContextDir` (monorepo Actors) to the Apify platform. */
export const MONOREPO_PLATFORM_SUPPORT_ISSUE_URL = 'https://github.com/apify/apify-core/issues/28685';

export type DockerContextResolution =
/** `dockerContextDir` is absent: undefined, null, or an empty string. */
| { kind: 'none' }
Expand All @@ -27,7 +23,7 @@ export type DockerContextResolution =
contextRoot: string;
/** The Git repository's root, else the context. */
sourceRoot: string;
/** Relative to `sourceRoot`. */
/** Relative to `sourceRoot`, never empty, with the names as stored on disk. */
actorPath: string;
}
/** The field is not a string, or the directory it names is missing, not a directory, or out of reach. */
Expand All @@ -45,11 +41,27 @@ export function resolveDockerContext(actorDir: string, dockerContextDir: unknown
if (typeof dockerContextDir !== 'string') {
return { kind: 'invalid', message: `"dockerContextDir" in .actor/actor.json must be a string.` };
}
// The platform resolves the path from .actor/ on Linux, as the paths to files in actor.json.
if (isAbsolute(dockerContextDir)) {
return {
kind: 'invalid',
message: `"dockerContextDir" in .actor/actor.json is absolute (${dockerContextDir}); the path is relative to .actor/.`,
};
}
if (dockerContextDir.includes('\\')) {
return {
kind: 'invalid',
message:
`"dockerContextDir" in .actor/actor.json uses a backslash (${dockerContextDir}); ` +
'the platform resolves paths with forward slashes.',
};
}

// The platform resolves the context from the Actor folder's real path, so a symlinked `--dir` must too.
// The platform resolves the context from the Actor folder's real path, so a symlinked `--dir` must too. The native
// call also gives the names their case on disk, which a `--dir` typed in another case on macOS or Windows lacks.
let realActorDir: string;
try {
realActorDir = realpathSync(actorDir);
realActorDir = realPath(actorDir);
} catch {
return { kind: 'invalid', message: `The Actor folder ${actorDir} does not exist.` };
}
Expand All @@ -68,7 +80,7 @@ export function resolveDockerContext(actorDir: string, dockerContextDir: unknown
message: `"dockerContextDir" in .actor/actor.json points to ${contextRoot}, which is not a directory.`,
};
}
const realContextRoot = realpathSync(contextRoot);
const realContextRoot = realPath(contextRoot);

// Inside or outside the Actor folder is judged, and returned, by the path as written, not by where a symlink on it
// leads. Push follows a symlink and uploads its files under the link's own name: with `"dockerContextDir": "../sym"`
Expand All @@ -81,8 +93,9 @@ export function resolveDockerContext(actorDir: string, dockerContextDir: unknown

// The platform's clone holds nothing above the repository root.
const repoRoot = git(realActorDir, ['rev-parse', '--show-toplevel']);
let sourceRoot = realContextRoot;
if (repoRoot) {
const sourceRoot = realpathSync(repoRoot);
sourceRoot = realPath(repoRoot);
if (isOutside(relative(sourceRoot, realContextRoot))) {
return {
kind: 'invalid',
Expand All @@ -91,22 +104,34 @@ export function resolveDockerContext(actorDir: string, dockerContextDir: unknown
'The Apify platform builds from a clone of the repository, so it could not build this Actor.',
};
}
return {
kind: 'outside',
contextRoot,
sourceRoot,
actorPath: toPosix(relative(sourceRoot, realActorDir)),
};
}

const actorPath = relative(realContextRoot, realActorDir);
const actorPath = relative(sourceRoot, realActorDir);
if (isOutside(actorPath)) {
return {
kind: 'invalid',
message: `"dockerContextDir" in .actor/actor.json points to ${realContextRoot}, which does not contain the Actor's folder.`,
};
}
return { kind: 'outside', contextRoot, sourceRoot: realContextRoot, actorPath: toPosix(actorPath) };
// The path as written leaves the Actor folder, so it isn't an ordinary push, yet the upload root would be the folder.
if (actorPath === '') {
return {
kind: 'invalid',
message:
`"dockerContextDir" in .actor/actor.json leaves the Actor folder, then leads back into it at ${realContextRoot}, ` +
'through a symlink or a folder name in another letter case. Write the path without leaving the Actor folder.',
};
}
return { kind: 'outside', contextRoot, sourceRoot, actorPath: toPosix(actorPath) };
}

/** The real path with names in their case on disk; the JS implementation where the native call fails, as on Windows RAM or virtual disks. */
export function realPath(path: string): string {
try {
return realpathSync.native(path);
} catch {
return realpathSync(path);
}
}

export function isOutside(relativePath: string): boolean {
Expand All @@ -126,8 +151,15 @@ export interface GitState {

function git(cwd: string, args: string[]): string | undefined {
try {
const output = execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
return output.trim();
// A large repository lists more than the default 1 MiB of file names.
const output = execFileSync('git', args, {
cwd,
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
maxBuffer: Number.POSITIVE_INFINITY,
});
// Only the line end goes: a file or folder name can start or end with a space.
return output.replace(/\r?\n$/, '');
} catch {
return undefined;
}
Expand All @@ -148,14 +180,15 @@ function withoutCredentials(remoteUrl: string): string {

export function readGitState(dir: string): GitState {
const commit = git(dir, ['rev-parse', 'HEAD']);
if (!commit) return {};
const status = git(dir, ['status', '--porcelain', '--', '.']);
// Before the first commit, every file is uncommitted.
if (!commit) return status ? { dirty: true } : {};

const state: GitState = { commit };
const branch = git(dir, ['rev-parse', '--abbrev-ref', 'HEAD']);
if (branch && branch !== 'HEAD') state.branch = branch;
const remoteUrl = git(dir, ['remote', 'get-url', 'origin']);
if (remoteUrl) state.remoteUrl = withoutCredentials(remoteUrl);
const status = git(dir, ['status', '--porcelain', '--', '.']);
if (status !== undefined) state.dirty = status.length > 0;
return state;
}
Expand All @@ -165,7 +198,8 @@ const UNTRACKED_IGNORED_SEGMENTS = new Set(['node_modules', 'storage', 'apify_st

function gitFileList(root: string, args: string[]): string[] | undefined {
const output = git(root, ['ls-files', '-z', ...args]);
return output?.split('\0').filter(Boolean);
// During an unresolved merge, --cached lists a conflicted path once per index stage.
return output === undefined ? undefined : [...new Set(output.split('\0').filter(Boolean))];
}

/**
Expand Down Expand Up @@ -195,12 +229,3 @@ export async function getContextFilePaths(root: string): Promise<string[]> {
),
];
}

export function monorepoUnsupportedMessage(actorName: string): string {
return [
`Actor ${actorName} sets "dockerContextDir" in .actor/actor.json, so it builds from a Docker context outside its own folder (a monorepo Actor).`,
'apify push does not support such Actors on the Apify platform yet. Follow the progress at:',
` ${MONOREPO_PLATFORM_SUPPORT_ISSUE_URL}`,
`Until then, set the Actor's source in Apify Console to the Git repository with the folder (<repository URL>#<branch>:<folder>).`,
].join('\n');
}
Loading
Loading