From 8e0236979d314cb3c7ba1d66883e78ce781446fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 16 Oct 2026 10:10:31 +0200 Subject: [PATCH 1/8] feat: push monorepo Actors by uploading the repository root with actorPath --- src/commands/actors/push.ts | 141 +++++++++++++++++++++++++----------- 1 file changed, 98 insertions(+), 43 deletions(-) diff --git a/src/commands/actors/push.ts b/src/commands/actors/push.ts index 4d50dbb1d..b6ba318ee 100644 --- a/src/commands/actors/push.ts +++ b/src/commands/actors/push.ts @@ -1,8 +1,10 @@ +import { randomUUID } from 'node:crypto'; import { readFileSync, statSync, unlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import process from 'node:process'; -import type { Actor, ActorCollectionCreateOptions, ActorDefaultRunOptions } from 'apify-client'; +import type { Actor, ActorCollectionCreateOptions, ActorDefaultRunOptions, ApifyApiError } from 'apify-client'; import open from 'open'; import { fetchManifest } from '@apify/actor-templates'; @@ -20,7 +22,13 @@ import { Args } from '../../lib/command-framework/args.js'; import { Flags } from '../../lib/command-framework/flags.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, + monorepoUnsupportedMessage, + readGitProvenance, + 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'; @@ -38,7 +46,6 @@ import { printJsonToStdout, } from '../../lib/utils.js'; -const TEMP_ZIP_FILE_NAME = 'temp_file.zip'; const DEFAULT_RUN_OPTIONS = { build: 'latest', memoryMbytes: 4096, @@ -155,6 +162,12 @@ 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'); +}; + export class ActorsPushCommand extends ApifyCommand { static override name = 'push' as const; @@ -290,25 +303,30 @@ export class ActorsPushCommand extends ApifyCommand { 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; } + let uploadRoot = cwd; + let uploadedFiles = filePathsToPush; + let actorPath: string | undefined; if (dockerContext.kind === 'context') { - error({ message: monorepoUnsupportedMessage(actorConfig!.name as string) }); - process.exitCode = CommandExitCodes.NotImplemented; - return; + uploadRoot = dockerContext.sourceRoot; + uploadedFiles = (await getContextFilePaths(uploadRoot)).map(toPosix); + actorPath = dockerContext.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, + uploadRoot, + uploadedFiles, config: actorConfig, + dockerContextRoot: dockerContext.kind === 'context' ? dockerContext.contextRoot : undefined, }); if (pathProblems.length > 0) { error({ message: formatActorPathProblems(pathProblems) }); @@ -316,6 +334,17 @@ export class ActorsPushCommand extends ApifyCommand { return; } + if (actorPath && readGitProvenance(uploadRoot).dirty) { + warning({ + message: `The repository ${uploadRoot} has uncommitted changes; the push uploads the files as they are.`, + }); + } + + const refuseMonorepoPush = () => { + error({ message: monorepoUnsupportedMessage(actorConfig!.name as string) }); + process.exitCode = CommandExitCodes.NotImplemented; + }; + const userInfo = await getCurrentUserInfo(); const isOrganizationLoggedIn = !!userInfo.organizationOwnerUserId; const redirectUrlPart = isOrganizationLoggedIn ? `/organization/${userInfo.id}` : ''; @@ -368,6 +397,7 @@ export class ActorsPushCommand extends ApifyCommand { // TODO: export enum from apify-client sourceType: ACTOR_SOURCE_TYPES.SOURCE_FILES as never, sourceFiles: [], + ...(actorPath ? { actorPath } : {}), }, ], }; @@ -377,7 +407,13 @@ export class ActorsPushCommand extends ApifyCommand { 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.` }); @@ -393,14 +429,14 @@ export class ActorsPushCommand extends ApifyCommand { 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; @@ -443,24 +479,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`, ); @@ -493,27 +533,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 From 87c374b485f64dfb8489a548812598b23aeb654e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 16 Oct 2026 10:10:32 +0200 Subject: [PATCH 2/8] test: cover the monorepo upload, the zip path, and the actorPath skew guard --- test/local/commands/push-monorepo.test.ts | 247 ++++++++++++++++++++-- 1 file changed, 224 insertions(+), 23 deletions(-) diff --git a/test/local/commands/push-monorepo.test.ts b/test/local/commands/push-monorepo.test.ts index 6d3eab22d..b92a59100 100644 --- a/test/local/commands/push-monorepo.test.ts +++ b/test/local/commands/push-monorepo.test.ts @@ -1,8 +1,9 @@ import { execFileSync } from 'node:child_process'; -import { mkdir, writeFile } from 'node:fs/promises'; -import { dirname } from 'node:path'; +import { existsSync } from 'node:fs'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; -import { ACTOR_SOURCE_TYPES } from '@apify/consts'; +import { ACTOR_SOURCE_TYPES, MAX_MULTIFILE_BYTES } from '@apify/consts'; import { testRunCommand } from '../../../src/lib/command-framework/apify-command.js'; import { CommandExitCodes } from '../../../src/lib/consts.js'; @@ -25,10 +26,27 @@ const actor = { const build = { id: 'build1', actId: ACTOR_ID, buildNumber: '0.0.1', status: 'SUCCEEDED' }; const currentVersion = { versionNumber: '0.0', sourceType: ACTOR_SOURCE_TYPES.SOURCE_FILES }; -const actorGet = vitest.fn(async () => actor); +interface VersionPayload { + sourceType: string; + sourceFiles?: { name: string }[]; + tarballUrl?: string; + actorPath?: string; +} + +const actorGet = vitest.fn(async (): Promise => actor); +const actorDelete = vitest.fn(async () => {}); +const actorsCreate = vitest.fn(async (_created: unknown) => actor); const versionUpdate = vitest.fn(async (_modifier: unknown) => ({})); const versionCreate = vitest.fn(async (created: unknown) => created); -const keyValueStoreGetOrCreate = vitest.fn(async () => ({ id: 'store1' })); +const keyValueStoreGetOrCreate = vitest.fn(async () => ({ id: 'store1', urlSigningSecretKey: 'secret' })); +const setRecord = vitest.fn(async (_record: unknown) => {}); + +// What apify-client throws when a platform without the `actorPath` version field rejects the key. +const actorPathRejected = () => + Object.assign(new Error('Invalid actor version: actorPath is not allowed by the schema'), { + statusCode: 400, + type: 'invalid-request', + }); // Stands in for the platform: the Actor and its version exist, and every write is recorded. vitest.mock('../../../src/lib/utils.js', async (importOriginal) => ({ @@ -40,20 +58,43 @@ vitest.mock('../../../src/lib/utils.js', async (importOriginal) => ({ token: 'my-token', actor: () => ({ get: actorGet, + delete: actorDelete, version: () => ({ get: async () => currentVersion, update: versionUpdate }), versions: () => ({ create: versionCreate }), build: async () => build, }), + actors: () => ({ create: actorsCreate }), keyValueStores: () => ({ getOrCreate: keyValueStoreGetOrCreate }), + keyValueStore: () => ({ setRecord }), build: () => ({ get: async () => build }), })), })); +// Creating an Actor looks its template up in the manifest; nothing here needs the network. +vitest.mock('@apify/actor-templates', () => ({ fetchManifest: async () => ({ templates: [] }) })); + // The mocked `node:process` copy has no signal handlers; nothing here needs them. vitest.mock('../../../src/lib/hooks/useAbortJobOnSignal.js', () => ({ useAbortJobOnSignal: () => ({ [Symbol.dispose]() {} }), })); +// With `gitAvailable` off, every git call fails as it does without git, so the fixture counts as outside Git +// even though it lives inside this repository's checkout. +const gitState = vitest.hoisted(() => ({ gitAvailable: true })); +vitest.mock('node:child_process', async (importOriginal) => { + const original = await importOriginal(); + const unavailable = () => { + throw new Error('not a git repository'); + }; + return { + ...original, + execSync: ((...args: Parameters) => + gitState.gitAvailable ? original.execSync(...args) : unavailable()) as typeof original.execSync, + execFileSync: ((...args: Parameters) => + gitState.gitAvailable ? original.execFileSync(...args) : unavailable()) as typeof original.execFileSync, + }; +}); + const { beforeAllCalls, afterAllCalls, joinPath, tmpPath, forceNewCwd } = useTempPath(actName, { create: true, remove: true, @@ -83,14 +124,31 @@ function git(...args: string[]) { execFileSync('git', args, { cwd: tmpPath, stdio: 'ignore' }); } +function versionPayload(mock: typeof versionUpdate | typeof actorsCreate) { + expect(mock).toHaveBeenCalledTimes(1); + return mock.mock.calls[0][0] as VersionPayload; +} + +function expectRefusal() { + expect(commandProcess.exitCode).toBe(CommandExitCodes.NotImplemented); + const stderr = logMessages.error.join('\n'); + expect(stderr).toContain('apify push does not support such Actors on the Apify platform yet'); + expect(stderr).toContain(MONOREPO_PLATFORM_SUPPORT_ISSUE_URL); + expect(logMessages.log.join('\n')).not.toContain('Apify push result'); +} + let originalExitCode: typeof process.exitCode; beforeEach(async () => { originalExitCode = commandProcess.exitCode; + gitState.gitAvailable = true; actorGet.mockClear(); + actorDelete.mockClear(); + actorsCreate.mockClear(); versionUpdate.mockClear(); versionCreate.mockClear(); keyValueStoreGetOrCreate.mockClear(); + setRecord.mockClear(); cwdCache.clear(); await beforeAllCalls(); @@ -115,30 +173,173 @@ afterEach(async () => { }); describe('apify push of a monorepo Actor', () => { - it('stops before touching the platform, pointing at the tracking issue', async () => { + it('uploads the repository root as source files with the Actor folder as actorPath', async () => { await testRunCommand(ActorsPushCommand, {}); - expect(commandProcess.exitCode).toBe(CommandExitCodes.NotImplemented); - const stderr = logMessages.error.join('\n'); - expect(stderr).toContain( - `Actor ${actName} sets "dockerContextDir" in .actor/actor.json, so it builds from a Docker context outside its own folder (a monorepo Actor).`, + expect(commandProcess.exitCode).toBeFalsy(); + const modifier = versionPayload(versionUpdate); + expect(modifier.sourceType).toBe(ACTOR_SOURCE_TYPES.SOURCE_FILES); + expect(modifier.actorPath).toBe(ACTOR_PATH); + expect(modifier.tarballUrl).toBeUndefined(); + const names = modifier.sourceFiles!.map((file) => file.name); + expect(names).toEqual( + expect.arrayContaining([ + `${ACTOR_PATH}/.actor/actor.json`, + `${ACTOR_PATH}/src/index.ts`, + 'package.json', + 'shared/TypeScript_Dockerfile', + 'packages/typescript-utils/src/index.ts', + ]), ); - expect(stderr).toContain('apify push does not support such Actors on the Apify platform yet'); - expect(stderr).toContain(MONOREPO_PLATFORM_SUPPORT_ISSUE_URL); - expect(stderr).toContain("set the Actor's source in Apify Console to the Git repository with the folder"); - expect(actorGet).not.toHaveBeenCalled(); - expect(versionCreate).not.toHaveBeenCalled(); - expect(versionUpdate).not.toHaveBeenCalled(); + expect(names.some((name) => name.startsWith('node_modules'))).toBe(false); + expect(names.some((name) => name.includes('\\'))).toBe(false); expect(keyValueStoreGetOrCreate).not.toHaveBeenCalled(); + expect(logMessages.error.join('\n')).not.toContain('uncommitted changes'); + expect(logMessages.log.join('\n')).toContain('Apify push result: SUCCEEDED'); }); - it('keeps stdout clean with --json', async () => { + it('keeps the --json envelope on stdout', async () => { await testRunCommand(ActorsPushCommand, { flags_json: true }); - expect(commandProcess.exitCode).toBe(CommandExitCodes.NotImplemented); - expect(logMessages.log).toEqual([]); - expect(logMessages.error.join('\n')).toContain(MONOREPO_PLATFORM_SUPPORT_ISSUE_URL); + expect(commandProcess.exitCode).toBeFalsy(); + expect(versionPayload(versionUpdate).actorPath).toBe(ACTOR_PATH); + expect(logMessages.log).toHaveLength(1); + expect(JSON.parse(logMessages.log[0])).toMatchObject({ ok: true, operation: 'push', actor: { id: ACTOR_ID } }); + }); + + it('zips a repository over the multi-file limit into the OS temp directory and pushes it as a tarball', async () => { + await write(`${ACTOR_PATH}/big.bin`, 'x'.repeat(MAX_MULTIFILE_BYTES + 1)); + + await testRunCommand(ActorsPushCommand, {}); + + expect(commandProcess.exitCode).toBeFalsy(); + expect(keyValueStoreGetOrCreate).toHaveBeenCalledWith(`actor-${ACTOR_ID}-source`); + expect(setRecord).toHaveBeenCalledTimes(1); + const record = setRecord.mock.calls[0][0] as { key: string; value: Buffer; contentType: string }; + expect(record.key).toBe('version-0.0.zip'); + expect(record.contentType).toBe('application/zip'); + expect(Buffer.isBuffer(record.value)).toBe(true); + expect(record.value.length).toBeGreaterThan(0); + + const modifier = versionPayload(versionUpdate); + expect(modifier.sourceType).toBe(ACTOR_SOURCE_TYPES.TARBALL); + expect(modifier.sourceFiles).toBeUndefined(); + expect(modifier.actorPath).toBe(ACTOR_PATH); + const tarballUrl = new URL(modifier.tarballUrl!); + expect(tarballUrl.pathname).toBe('/v2/key-value-stores/store1/records/version-0.0.zip'); + expect(tarballUrl.searchParams.get('disableRedirect')).toBe('true'); + expect(tarballUrl.searchParams.get('signature')).toBeTruthy(); + + expect(existsSync(join(process.cwd(), 'temp_file.zip'))).toBe(false); + expect(existsSync(joinPath('temp_file.zip'))).toBe(false); + }); + + it('warns about uncommitted changes, which the upload carries as they are', async () => { + await write(`${ACTOR_PATH}/src/index.ts`, 'console.log(2);\n'); + + await testRunCommand(ActorsPushCommand, {}); + + expect(commandProcess.exitCode).toBeFalsy(); + expect(versionUpdate).toHaveBeenCalledTimes(1); + const warningLine = logMessages.error.find((line) => line.includes('uncommitted changes')); + expect(warningLine).toBeDefined(); + expect(warningLine!.split('\n')).toHaveLength(1); + }); + + describe('on a platform without the actorPath field', () => { + it('turns the 400 into the refusal with the tracking issue', async () => { + versionUpdate.mockRejectedValueOnce(actorPathRejected()); + + await testRunCommand(ActorsPushCommand, {}); + + expectRefusal(); + expect(actorDelete).not.toHaveBeenCalled(); + }); + + it('keeps stdout clean with --json', async () => { + versionUpdate.mockRejectedValueOnce(actorPathRejected()); + + await testRunCommand(ActorsPushCommand, { flags_json: true }); + + expectRefusal(); + expect(logMessages.log).toEqual([]); + }); + + it('deletes the Actor it has just created', async () => { + actorGet.mockResolvedValueOnce(null); + versionUpdate.mockRejectedValueOnce(actorPathRejected()); + + await testRunCommand(ActorsPushCommand, {}); + + expectRefusal(); + expect(versionPayload(actorsCreate)).toMatchObject({ versions: [{ actorPath: ACTOR_PATH }] }); + expect(actorDelete).toHaveBeenCalledTimes(1); + }); + + it('still reports the refusal when deleting the just created Actor fails', async () => { + actorGet.mockResolvedValueOnce(null); + versionUpdate.mockRejectedValueOnce(actorPathRejected()); + actorDelete.mockRejectedValueOnce(new Error('delete failed')); + + await testRunCommand(ActorsPushCommand, {}); + + expectRefusal(); + expect(actorDelete).toHaveBeenCalledTimes(1); + expect(logMessages.error.join('\n')).toContain('delete failed'); + }); + + it('refuses when the Actor create itself rejects actorPath, leaving nothing to delete', async () => { + actorGet.mockResolvedValueOnce(null); + actorsCreate.mockRejectedValueOnce(actorPathRejected()); + + await testRunCommand(ActorsPushCommand, {}); + + expectRefusal(); + expect(versionUpdate).not.toHaveBeenCalled(); + expect(actorDelete).not.toHaveBeenCalled(); + }); + + it('passes other API errors through', async () => { + versionUpdate.mockRejectedValueOnce(Object.assign(new Error('Version is locked'), { statusCode: 400 })); + + await testRunCommand(ActorsPushCommand, {}); + + expect(commandProcess.exitCode).toBeFalsy(); + expect(process.exitCode).toBe(1); + expect(logMessages.error.join('\n')).toContain('Version is locked'); + expect(logMessages.error.join('\n')).not.toContain(MONOREPO_PLATFORM_SUPPORT_ISSUE_URL); + }); + }); + + it('uploads the Docker context with .actorignore applied outside Git', async () => { + await rm(joinPath('.git'), { recursive: true, force: true }); + await write('.actorignore', 'packages/\n'); + gitState.gitAvailable = false; + + await testRunCommand(ActorsPushCommand, {}); + + expect(commandProcess.exitCode).toBeFalsy(); + const modifier = versionPayload(versionUpdate); + expect(modifier.sourceType).toBe(ACTOR_SOURCE_TYPES.SOURCE_FILES); + expect(modifier.actorPath).toBe(ACTOR_PATH); + const names = modifier.sourceFiles!.map((file) => file.name); + expect(names).toEqual( + expect.arrayContaining([`${ACTOR_PATH}/.actor/actor.json`, 'package.json', 'shared/TypeScript_Dockerfile']), + ); + expect(names.some((name) => name.startsWith('packages/'))).toBe(false); + expect(names.some((name) => name.startsWith('node_modules'))).toBe(false); + }); + + it('rejects a dockerfile outside the Docker context before touching the platform', async () => { + await writeActorConfig({ dockerContextDir: '../..', dockerfile: '../../../shared/TypeScript_Dockerfile' }); + + await testRunCommand(ActorsPushCommand, {}); + + expect(commandProcess.exitCode).toBe(CommandExitCodes.InvalidActorJson); + expect(logMessages.error.join('\n')).toContain('lies outside the Docker context'); + expect(actorGet).not.toHaveBeenCalled(); expect(versionUpdate).not.toHaveBeenCalled(); + expect(keyValueStoreGetOrCreate).not.toHaveBeenCalled(); }); it('rejects a dockerContextDir outside the Git repository, which the platform could not build', async () => { @@ -158,10 +359,10 @@ describe('apify push of a monorepo Actor', () => { await testRunCommand(ActorsPushCommand, {}); expect(commandProcess.exitCode).toBeFalsy(); - expect(versionUpdate).toHaveBeenCalledTimes(1); - const modifier = versionUpdate.mock.calls[0][0] as { sourceType: string; sourceFiles: { name: string }[] }; + const modifier = versionPayload(versionUpdate); expect(modifier.sourceType).toBe(ACTOR_SOURCE_TYPES.SOURCE_FILES); - expect(modifier.sourceFiles.map((file) => file.name).sort()).toEqual(['.actor/actor.json', 'src/index.ts']); + expect(modifier.sourceFiles!.map((file) => file.name).sort()).toEqual(['.actor/actor.json', 'src/index.ts']); + expect(modifier).not.toHaveProperty('actorPath'); expect(logMessages.log.join('\n')).toContain('Apify push result: SUCCEEDED'); }); }); From e330a0f2c6feaac21070dd5287f10cf85eb9648e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 16 Oct 2026 10:18:55 +0200 Subject: [PATCH 3/8] fix: upload a monorepo Actor's directory symlinks as the files under them --- src/commands/actors/push.ts | 26 ++++++++++++++++++++--- test/local/commands/push-monorepo.test.ts | 17 ++++++++++++++- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/commands/actors/push.ts b/src/commands/actors/push.ts index b6ba318ee..cd5d800a4 100644 --- a/src/commands/actors/push.ts +++ b/src/commands/actors/push.ts @@ -1,7 +1,7 @@ import { randomUUID } from 'node:crypto'; -import { readFileSync, statSync, unlinkSync } from 'node:fs'; +import { readFileSync, realpathSync, statSync, unlinkSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join, resolve } from 'node:path'; +import { join, relative, resolve } from 'node:path'; import process from 'node:process'; import type { Actor, ActorCollectionCreateOptions, ActorDefaultRunOptions, ApifyApiError } from 'apify-client'; @@ -24,6 +24,7 @@ import { getConsoleUrl } from '../../lib/console-url.js'; import { CommandExitCodes, DEPRECATED_LOCAL_CONFIG_NAME, LOCAL_CONFIG_PATH } from '../../lib/consts.js'; import { getContextFilePaths, + isOutside, monorepoUnsupportedMessage, readGitProvenance, resolveDockerContext, @@ -168,6 +169,25 @@ const rejectsActorPath = (err: unknown) => { 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 goes as that file, a link to a + * directory inside the upload as copies of the uploaded files under its target, and a link to nothing is left out. + */ +const resolveLinksForUpload = (root: string, files: string[]) => { + const stats = new Map(files.map((file) => [file, statSync(join(root, file), { throwIfNoEntry: false })])); + return files.flatMap((file) => { + const fileStats = stats.get(file); + if (fileStats?.isFile()) return [file]; + if (!fileStats?.isDirectory()) return []; + const target = relative(realpathSync(root), realpathSync(join(root, file))); + if (isOutside(target)) return []; + const prefix = `${toPosix(target)}/`; + return files + .filter((other) => other.startsWith(prefix) && stats.get(other)?.isFile()) + .map((other) => `${file}/${other.slice(prefix.length)}`); + }); +}; + export class ActorsPushCommand extends ApifyCommand { static override name = 'push' as const; @@ -316,7 +336,7 @@ export class ActorsPushCommand extends ApifyCommand { let actorPath: string | undefined; if (dockerContext.kind === 'context') { uploadRoot = dockerContext.sourceRoot; - uploadedFiles = (await getContextFilePaths(uploadRoot)).map(toPosix); + uploadedFiles = resolveLinksForUpload(uploadRoot, (await getContextFilePaths(uploadRoot)).map(toPosix)); actorPath = dockerContext.actorPath; } diff --git a/test/local/commands/push-monorepo.test.ts b/test/local/commands/push-monorepo.test.ts index b92a59100..701b528aa 100644 --- a/test/local/commands/push-monorepo.test.ts +++ b/test/local/commands/push-monorepo.test.ts @@ -1,5 +1,5 @@ import { execFileSync } from 'node:child_process'; -import { existsSync } from 'node:fs'; +import { existsSync, symlinkSync } from 'node:fs'; import { mkdir, rm, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; @@ -234,6 +234,21 @@ describe('apify push of a monorepo Actor', () => { expect(existsSync(joinPath('temp_file.zip'))).toBe(false); }); + it('uploads the files under a directory symlink as copies and leaves a dangling link out', async () => { + symlinkSync('../../shared', joinPath(`${ACTOR_PATH}/shared`)); + symlinkSync('missing', joinPath('dangling')); + git('add', '.'); + + await testRunCommand(ActorsPushCommand, {}); + + expect(commandProcess.exitCode).toBeFalsy(); + const names = versionPayload(versionUpdate).sourceFiles!.map((file) => file.name); + expect(names).toContain(`${ACTOR_PATH}/shared/TypeScript_Dockerfile`); + expect(names).toContain('shared/TypeScript_Dockerfile'); + expect(names).not.toContain(`${ACTOR_PATH}/shared`); + expect(names).not.toContain('dangling'); + }); + it('warns about uncommitted changes, which the upload carries as they are', async () => { await write(`${ACTOR_PATH}/src/index.ts`, 'console.log(2);\n'); From 128e96abbde8b37f929a53f814f979a5abb25d1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 16 Oct 2026 10:18:56 +0200 Subject: [PATCH 4/8] test: restore the global exit code after each monorepo push case --- test/local/commands/push-monorepo.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/local/commands/push-monorepo.test.ts b/test/local/commands/push-monorepo.test.ts index 701b528aa..2772298b3 100644 --- a/test/local/commands/push-monorepo.test.ts +++ b/test/local/commands/push-monorepo.test.ts @@ -138,9 +138,11 @@ function expectRefusal() { } let originalExitCode: typeof process.exitCode; +let originalGlobalExitCode: typeof process.exitCode; beforeEach(async () => { originalExitCode = commandProcess.exitCode; + originalGlobalExitCode = process.exitCode; gitState.gitAvailable = true; actorGet.mockClear(); actorDelete.mockClear(); @@ -169,6 +171,7 @@ beforeEach(async () => { afterEach(async () => { commandProcess.exitCode = originalExitCode; + process.exitCode = originalGlobalExitCode; await afterAllCalls(); }); From 6d2bdfa91ffc22da5b686926b8bcacaa81085e10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Mon, 26 Oct 2026 12:40:42 +0100 Subject: [PATCH 5/8] fix: check the actor.json paths of a monorepo Actor from its real folder --- src/commands/actors/push.ts | 5 ++++- test/local/commands/push-monorepo.test.ts | 12 ++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/commands/actors/push.ts b/src/commands/actors/push.ts index aa27ce398..424d0e1f6 100644 --- a/src/commands/actors/push.ts +++ b/src/commands/actors/push.ts @@ -331,6 +331,7 @@ export class ActorsPushCommand extends ApifyCommand { process.exitCode = CommandExitCodes.InvalidActorJson; return; } + let actorDir = cwd; let uploadRoot = cwd; let uploadedFiles = filePathsToPush; let actorPath: string | undefined; @@ -338,11 +339,13 @@ export class ActorsPushCommand extends ApifyCommand { 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, + actorDir, uploadRoot, uploadedFiles, config: actorConfig, diff --git a/test/local/commands/push-monorepo.test.ts b/test/local/commands/push-monorepo.test.ts index 067d73870..a0949cd48 100644 --- a/test/local/commands/push-monorepo.test.ts +++ b/test/local/commands/push-monorepo.test.ts @@ -370,6 +370,18 @@ describe('apify push of a monorepo Actor', () => { expect(logMessages.error.join('\n')).toContain('Actor typescript-actor sets "dockerContextDir"'); }); + it('checks the actor.json paths from the real Actor folder when --dir goes through a symlink', async () => { + symlinkSync(ACTOR_PATH, joinPath('linked-actor')); + forceNewCwd('linked-actor'); + + await testRunCommand(ActorsPushCommand, {}); + + expect(commandProcess.exitCode).toBeFalsy(); + const modifier = versionPayload(versionUpdate); + expect(modifier.actorPath).toBe(ACTOR_PATH); + expect(modifier.sourceFiles!.map((file) => file.name)).toContain(`${ACTOR_PATH}/.actor/actor.json`); + }); + it.each([ ['outside the Git repository, which the platform could not build', '../../../..', 'outside the Git repository'], ['that does not exist', '../../../missing', 'which does not exist'], From 9a1a53dde812462588b385743b49650fe04b55b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Mon, 26 Oct 2026 16:07:40 +0100 Subject: [PATCH 6/8] fix: leave links pointing outside the repository out of the monorepo upload --- src/commands/actors/push.ts | 38 ++++++++++++++++------- test/local/commands/push-monorepo.test.ts | 10 +++++- 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/src/commands/actors/push.ts b/src/commands/actors/push.ts index 22d19a229..d1db4b0c0 100644 --- a/src/commands/actors/push.ts +++ b/src/commands/actors/push.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'node:crypto'; -import { readFileSync, realpathSync, statSync, unlinkSync } from 'node:fs'; +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'; @@ -170,20 +170,36 @@ const rejectsActorPath = (err: unknown) => { }; /** - * The platform's multi-file and zip sources carry files only: a link to a file goes as that file, a link to a - * directory inside the upload as copies of the uploaded files under its target, and a link to nothing is left out. + * 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 stats = new Map(files.map((file) => [file, statSync(join(root, file), { throwIfNoEntry: false })])); + 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 fileStats = stats.get(file); - if (fileStats?.isFile()) return [file]; - if (!fileStats?.isDirectory()) return []; - const target = relative(realpathSync(root), realpathSync(join(root, file))); - if (isOutside(target)) return []; - const prefix = `${toPosix(target)}/`; + 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) && stats.get(other)?.isFile()) + .filter((other) => other.startsWith(prefix) && located.get(other)?.stats.isFile()) .map((other) => `${file}/${other.slice(prefix.length)}`); }); }; diff --git a/test/local/commands/push-monorepo.test.ts b/test/local/commands/push-monorepo.test.ts index d9677e2b9..a6e7f804f 100644 --- a/test/local/commands/push-monorepo.test.ts +++ b/test/local/commands/push-monorepo.test.ts @@ -102,6 +102,9 @@ const { beforeAllCalls, afterAllCalls, joinPath, tmpPath, forceNewCwd } = useTem cwdParent: false, }); +// A sibling folder of the repository: the target of links that leave it. +const outside = useTempPath('push-monorepo-outside', { create: true, remove: true, cwd: false, cwdParent: false }); + const { logMessages } = useConsoleSpy(); const { ActorsPushCommand } = await import('../../../src/commands/actors/push.js'); @@ -154,6 +157,7 @@ beforeEach(async () => { setRecord.mockClear(); cwdCache.clear(); await beforeAllCalls(); + await outside.beforeAllCalls(); // The layout of apify/actor-monorepo-example, as its own Git repository. await write('package.json', '{"workspaces":["actors/**","packages/**"]}'); @@ -173,6 +177,7 @@ afterEach(async () => { commandProcess.exitCode = originalExitCode; process.exitCode = originalRealExitCode; await afterAllCalls(); + await outside.afterAllCalls(); }); describe('apify push of a monorepo Actor', () => { @@ -237,9 +242,11 @@ describe('apify push of a monorepo Actor', () => { expect(existsSync(joinPath('temp_file.zip'))).toBe(false); }); - it('uploads the files under a directory symlink as copies and leaves a dangling link out', async () => { + it('uploads the files under a directory symlink as copies and leaves dangling and outside links out', async () => { symlinkSync('../../shared', joinPath(`${ACTOR_PATH}/shared`)); symlinkSync('missing', joinPath('dangling')); + await writeFile(outside.joinPath('outside.txt'), 'outside\n'); + symlinkSync(outside.joinPath('outside.txt'), joinPath('outside.txt')); git('add', '.'); await testRunCommand(ActorsPushCommand, {}); @@ -250,6 +257,7 @@ describe('apify push of a monorepo Actor', () => { expect(names).toContain('shared/TypeScript_Dockerfile'); expect(names).not.toContain(`${ACTOR_PATH}/shared`); expect(names).not.toContain('dangling'); + expect(names).not.toContain('outside.txt'); }); it('warns about uncommitted changes, which the upload carries as they are', async () => { From 00755a39126e9ded15c0af62d7fad97508f182d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Mon, 26 Oct 2026 16:07:40 +0100 Subject: [PATCH 7/8] test: cover schemas reached through symlinks in a monorepo push --- test/local/commands/push-monorepo.test.ts | 40 ++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/test/local/commands/push-monorepo.test.ts b/test/local/commands/push-monorepo.test.ts index a6e7f804f..87e367c95 100644 --- a/test/local/commands/push-monorepo.test.ts +++ b/test/local/commands/push-monorepo.test.ts @@ -28,7 +28,7 @@ const currentVersion = { versionNumber: '0.0', sourceType: ACTOR_SOURCE_TYPES.SO interface VersionPayload { sourceType: string; - sourceFiles?: { name: string }[]; + sourceFiles?: { name: string; content?: string }[]; tarballUrl?: string; actorPath?: string; } @@ -260,6 +260,44 @@ describe('apify push of a monorepo Actor', () => { expect(names).not.toContain('outside.txt'); }); + it('accepts a schema reached through a symlink inside the repository and uploads it at the link path', async () => { + await write('shared/input_schema.json', '{"title":"Input","type":"object","properties":{}}'); + symlinkSync('../../../shared/input_schema.json', joinPath(`${ACTOR_PATH}/.actor/input_schema.json`)); + await writeActorConfig({ + dockerContextDir: '../../..', + dockerfile: '../../../shared/TypeScript_Dockerfile', + input: './input_schema.json', + }); + git('add', '.'); + + await testRunCommand(ActorsPushCommand, {}); + + expect(commandProcess.exitCode).toBeFalsy(); + const files = versionPayload(versionUpdate).sourceFiles!; + const linked = files.find((file) => file.name === `${ACTOR_PATH}/.actor/input_schema.json`); + expect(linked?.content).toContain('"title":"Input"'); + }); + + it('rejects a schema reached through a symlink leaving the repository, which the upload leaves out', async () => { + await writeFile(outside.joinPath('input_schema.json'), '{}'); + symlinkSync(outside.joinPath('input_schema.json'), joinPath(`${ACTOR_PATH}/.actor/input_schema.json`)); + await writeActorConfig({ + dockerContextDir: '../../..', + dockerfile: '../../../shared/TypeScript_Dockerfile', + input: './input_schema.json', + }); + git('add', '.'); + + await testRunCommand(ActorsPushCommand, {}); + + expect(commandProcess.exitCode).toBe(CommandExitCodes.InvalidActorJson); + const stderr = logMessages.error.join('\n'); + expect(stderr).toContain('"input": "./input_schema.json"'); + expect(stderr).toContain('is not among the files apify push uploads'); + expect(actorGet).not.toHaveBeenCalled(); + expect(versionUpdate).not.toHaveBeenCalled(); + }); + it('warns about uncommitted changes, which the upload carries as they are', async () => { await write(`${ACTOR_PATH}/src/index.ts`, 'console.log(2);\n'); From 04fb9654b31ef09072446a9a6db3479b8d5b14e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Tue, 27 Oct 2026 15:39:53 +0100 Subject: [PATCH 8/8] test: accept a Dockerfile in a monorepo Docker context reached through a symlink --- test/local/commands/push-monorepo.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test/local/commands/push-monorepo.test.ts b/test/local/commands/push-monorepo.test.ts index 87e367c95..ac9646411 100644 --- a/test/local/commands/push-monorepo.test.ts +++ b/test/local/commands/push-monorepo.test.ts @@ -278,6 +278,22 @@ describe('apify push of a monorepo Actor', () => { expect(linked?.content).toContain('"title":"Input"'); }); + it('accepts a Dockerfile in a Docker context reached through a symlink inside the repository', async () => { + // `context-link` links to the shared folder; the upload carries it as a normal folder under the link's name. + symlinkSync('shared', joinPath('context-link')); + await writeActorConfig({ + dockerContextDir: '../../../context-link', + dockerfile: '../../../context-link/TypeScript_Dockerfile', + }); + git('add', '.'); + + await testRunCommand(ActorsPushCommand, {}); + + expect(commandProcess.exitCode).toBeFalsy(); + const names = versionPayload(versionUpdate).sourceFiles!.map((file) => file.name); + expect(names).toContain('context-link/TypeScript_Dockerfile'); + }); + it('rejects a schema reached through a symlink leaving the repository, which the upload leaves out', async () => { await writeFile(outside.joinPath('input_schema.json'), '{}'); symlinkSync(outside.joinPath('input_schema.json'), joinPath(`${ACTOR_PATH}/.actor/input_schema.json`));