Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 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
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
191 changes: 143 additions & 48 deletions src/commands/actors/push.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { readFileSync, statSync, unlinkSync } from 'node:fs';
import { basename, join, resolve } from 'node:path';
import { randomUUID } from 'node:crypto';
import { lstatSync, readFileSync, realpathSync, type Stats, statSync, unlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { basename, join, relative, resolve } from 'node:path';
import process from 'node:process';

import type { Actor, ActorCollectionCreateOptions, ActorDefaultRunOptions, Build } from 'apify-client';
import type { Actor, ActorCollectionCreateOptions, ActorDefaultRunOptions, ApifyApiError, Build } from 'apify-client';
import open from 'open';

import { fetchManifest } from '@apify/actor-templates';
Expand All @@ -16,7 +18,14 @@ import { Flags } from '../../lib/command-framework/flags.js';
import { isTerminalStatus } from '../../lib/commands/agent-output.js';
import { getConsoleUrl } from '../../lib/console-url.js';
import { CommandExitCodes, DEPRECATED_LOCAL_CONFIG_NAME, LOCAL_CONFIG_PATH } from '../../lib/consts.js';
import { monorepoUnsupportedMessage, resolveDockerContext } from '../../lib/docker-context.js';
import {
getContextFilePaths,
isOutside,
monorepoUnsupportedMessage,
readGitState,
resolveDockerContext,
toPosix,
} from '../../lib/docker-context.js';
import { sumFilesSizeInBytes } from '../../lib/files.js';
import { useAbortJobOnSignal } from '../../lib/hooks/useAbortJobOnSignal.js';
import { useActorConfig } from '../../lib/hooks/useActorConfig.js';
Expand All @@ -34,7 +43,6 @@ import {
printJsonToStdout,
} from '../../lib/utils.js';

const TEMP_ZIP_FILE_NAME = 'temp_file.zip';
const DEFAULT_RUN_OPTIONS = {
build: 'latest',
memoryMbytes: 4096,
Expand Down Expand Up @@ -151,6 +159,47 @@ const confirmGitSourceSwitch = async ({
});
};

/** A platform without the `actorPath` version field rejects the key with a schema error. */
const rejectsActorPath = (err: unknown) => {
const { statusCode, message } = err as ApifyApiError;
return statusCode === 400 && typeof message === 'string' && message.includes('actorPath');
};

/**
* The platform's multi-file and zip sources carry files only: a link to a file inside the upload goes as that file
* at the link's path, a link to a directory inside the upload as copies of the uploaded files under its target, and
* a link to nothing or to a target outside the upload is left out, as a clone of the repository could not follow it
* either. The `actor.json` path check then reports a config that refers to a left-out link as not uploaded.
*/
const resolveLinksForUpload = (root: string, files: string[]) => {
const realRoot = realpathSync(root);
// Where an entry's content lies, relative to the root, and what it is; nothing for a link the upload drops.
const locate = (file: string): { target: string; stats: Stats } | undefined => {
const filePath = join(root, file);
const stats = lstatSync(filePath, { throwIfNoEntry: false });
if (!stats) return undefined;
if (!stats.isSymbolicLink()) return { target: file, stats };
try {
const realPath = realpathSync(filePath);
const target = relative(realRoot, realPath);
return isOutside(target) ? undefined : { target: toPosix(target), stats: statSync(realPath) };
} catch {
return undefined;
}
};
const located = new Map(files.map((file) => [file, locate(file)]));
return files.flatMap((file) => {
const entry = located.get(file);
if (!entry) return [];
if (entry.stats.isFile()) return [file];
if (!entry.stats.isDirectory()) return [];
const prefix = `${entry.target}/`;
return files
.filter((other) => other.startsWith(prefix) && located.get(other)?.stats.isFile())
.map((other) => `${file}/${other.slice(prefix.length)}`);
});
};

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

Expand Down Expand Up @@ -285,27 +334,35 @@ export class ActorsPushCommand extends ApifyCommand<typeof ActorsPushCommand> {

const { config: actorConfig } = actorConfigResult.unwrap();

// A monorepo Actor builds from a Docker context above its folder, which a push of that folder cannot carry.
// A monorepo Actor builds from a Docker context above its folder, so the push uploads its repository
// (or, outside Git, the context) and tells the platform where the Actor lies in it.
const dockerContext = resolveDockerContext(cwd, actorConfig?.dockerContextDir);
if (dockerContext.kind === 'invalid') {
error({ message: dockerContext.message });
process.exitCode = CommandExitCodes.InvalidActorJson;
return;
}
// A context inside the Actor folder ('none' or 'within') is an ordinary push of the folder.
let actorDir = cwd;
let uploadRoot = cwd;
let uploadedFiles = filePathsToPush;
let actorPath: string | undefined;
if (dockerContext.kind === 'outside') {
// Pushing by ID, actor.json may have no name; the folder then names the Actor.
error({ message: monorepoUnsupportedMessage((actorConfig?.name as string | undefined) ?? basename(cwd)) });
process.exitCode = CommandExitCodes.NotImplemented;
return;
uploadRoot = dockerContext.sourceRoot;
uploadedFiles = resolveLinksForUpload(uploadRoot, await getContextFilePaths(uploadRoot));
actorPath = dockerContext.actorPath;
// The source root is a real path, so the Actor folder has to be one too for its paths to fall inside it.
actorDir = join(uploadRoot, actorPath);
}

// 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,
actorDir,
uploadRoot,
uploadedFiles,
config: actorConfig,
dockerContextRoot: dockerContext.kind === 'within' ? dockerContext.contextRoot : undefined,
// BuildKit reads the Dockerfile from the context, wherever the context lies.
dockerContextRoot: dockerContext.kind === 'none' ? undefined : dockerContext.contextRoot,
});
// A readme or changelog the platform cannot find only falls back to its default, so push goes on.
const pathWarnings = formatActorPathProblems(pathProblems, 'warning');
Expand All @@ -317,6 +374,18 @@ export class ActorsPushCommand extends ApifyCommand<typeof ActorsPushCommand> {
return;
}

if (actorPath && readGitState(uploadRoot).dirty) {
warning({
message: `The repository ${uploadRoot} has uncommitted changes; the push uploads the files as they are.`,
});
}

const refuseMonorepoPush = () => {
// Pushing by ID, actor.json may have no name; the folder then names the Actor.
error({ message: monorepoUnsupportedMessage((actorConfig?.name as string | undefined) ?? basename(cwd)) });
process.exitCode = CommandExitCodes.NotImplemented;
};

const apifyClient = await getLoggedClientOrThrow();
const userInfo = await getCurrentUserInfo();
const isOrganizationLoggedIn = !!userInfo.organizationOwnerUserId;
Expand Down Expand Up @@ -370,6 +439,7 @@ export class ActorsPushCommand extends ApifyCommand<typeof ActorsPushCommand> {
// TODO: export enum from apify-client
sourceType: ACTOR_SOURCE_TYPES.SOURCE_FILES as never,
sourceFiles: [],
...(actorPath ? { actorPath } : {}),
},
],
};
Expand All @@ -379,7 +449,13 @@ export class ActorsPushCommand extends ApifyCommand<typeof ActorsPushCommand> {
newActor.actorStandby = { isEnabled: true };
}

actor = await apifyClient.actors().create(newActor);
try {
actor = await apifyClient.actors().create(newActor);
} catch (err) {
if (!rejectsActorPath(err)) throw err;
refuseMonorepoPush();
return;
}
actorId = actor.id;
isActorCreatedNow = true;
info({ message: `Created Actor with name ${actorConfig!.name} on Apify.` });
Expand All @@ -395,14 +471,14 @@ export class ActorsPushCommand extends ApifyCommand<typeof ActorsPushCommand> {

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

const filesSize = await sumFilesSizeInBytes(filePathsToPush, cwd);
const filesSize = await sumFilesSizeInBytes(uploadedFiles, uploadRoot);

if (filesSize < MAX_MULTIFILE_BYTES && !isActorCreatedNow) {
if ((filesSize < MAX_MULTIFILE_BYTES || actorPath) && !isActorCreatedNow) {
const client = await actorClient.get();

// Check when was files modified last
const mostRecentModifiedFileMs = filePathsToPush.reduce((modifiedMs, filePath) => {
const { mtimeMs, ctimeMs } = statSync(join(cwd, filePath));
const mostRecentModifiedFileMs = uploadedFiles.reduce((modifiedMs, filePath) => {
const { mtimeMs, ctimeMs } = statSync(join(uploadRoot, filePath));

// Sometimes it's possible mtimeMs is some messed up value (like 2000/01/01 midnight), then we want to check created if it's newer
const fileModifiedMs = mtimeMs > ctimeMs ? mtimeMs : ctimeMs;
Expand Down Expand Up @@ -445,24 +521,28 @@ Skipping push. Use --force to override.`,
let sourceFiles;
let tarballUrl;
if (filesSize < MAX_MULTIFILE_BYTES) {
sourceFiles = await createSourceFiles(filePathsToPush, cwd);
sourceFiles = await createSourceFiles(uploadedFiles, uploadRoot);
sourceType = ACTOR_SOURCE_TYPES.SOURCE_FILES;
} else {
// Create zip
run({ message: 'Zipping Actor files' });
await createActZip(TEMP_ZIP_FILE_NAME, filePathsToPush, cwd);
const zipPath = join(tmpdir(), `apify-push-${randomUUID()}.zip`);
await createActZip(zipPath, uploadedFiles, uploadRoot);

// Upload it to Apify.keyValueStores
const store = await apifyClient.keyValueStores().getOrCreate(`actor-${actorId}-source`);
const key = `version-${version}.zip`;
const buffer = readFileSync(TEMP_ZIP_FILE_NAME);
await apifyClient.keyValueStore(store.id).setRecord({
key,
// TODO: fix this type too
value: buffer as never,
contentType: 'application/zip',
});
unlinkSync(TEMP_ZIP_FILE_NAME);
try {
const buffer = readFileSync(zipPath);
await apifyClient.keyValueStore(store.id).setRecord({
key,
// TODO: fix this type too
value: buffer as never,
contentType: 'application/zip',
});
} finally {
unlinkSync(zipPath);
}
const tempTarballUrl = new URL(
`${apifyClient.baseUrl}/key-value-stores/${store.id}/records/${key}?disableRedirect=true`,
);
Expand Down Expand Up @@ -495,27 +575,42 @@ Skipping push. Use --force to override.`,
const applyEnvVarsToBuild =
this.flags.applyEnvVarsToBuild ?? (actorConfig!.applyEnvVarsToBuild as boolean | undefined);

if (actorCurrentVersion) {
const actorVersionModifier = { tarballUrl, sourceFiles, buildTag, sourceType, envVars, applyEnvVarsToBuild };
// TODO: fix this type too -.-
await actorClient.version(version).update(actorVersionModifier as never);
run({ message: `Updated version ${version} for Actor ${actor.name}.` });
} else {
const actorNewVersion = {
versionNumber: version,
tarballUrl,
sourceFiles,
buildTag,
sourceType,
envVars,
applyEnvVarsToBuild,
};
const actorVersionSource = { tarballUrl, sourceFiles, sourceType, ...(actorPath ? { actorPath } : {}) };
try {
if (actorCurrentVersion) {
const actorVersionModifier = { ...actorVersionSource, buildTag, envVars, applyEnvVarsToBuild };
// TODO: fix this type too -.-
await actorClient.version(version).update(actorVersionModifier as never);
run({ message: `Updated version ${version} for Actor ${actor.name}.` });
} else {
const actorNewVersion = {
versionNumber: version,
...actorVersionSource,
buildTag,
envVars,
applyEnvVarsToBuild,
};

await actorClient.versions().create({
...actorNewVersion,
} as never);
await actorClient.versions().create({
...actorNewVersion,
} as never);

run({ message: `Created version ${version} for Actor ${actor.name}.` });
run({ message: `Created version ${version} for Actor ${actor.name}.` });
}
} catch (err) {
if (!rejectsActorPath(err)) throw err;
if (isActorCreatedNow) {
try {
await actorClient.delete();
info({ message: `Deleted the just created Actor ${actor.name} again.` });
} catch (deleteErr) {
warning({
message: `Could not delete the just created Actor ${actor.name}: ${(deleteErr as Error).message}`,
});
}
}
refuseMonorepoPush();
return;
}

// Sync standby mode on existing actors with actor.json
Expand Down
3 changes: 1 addition & 2 deletions src/lib/docker-context.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
// 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';
Expand Down
Loading
Loading