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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,27 @@ Remove `--dry-run` to actually trigger builds and update the branch names/ The c
[{ "buildId": "...", "actorId": "...", "buildNumber": "...", "actorName": "john.doe/my-actor" }]
```

#### Build from local source (no push needed)

If you don't want to push a dummy branch just to test a change and wait for all the tests to finish, `build-from-local` builds Actors directly from your local files (zipped and uploaded as `SOURCE_FILES`), skipping steps 1-4 above.

```bash
APIFY_TOKEN_JOHN_DOE=<token> \
GITHUB_WORKSPACE=. \
npx apify-test-tools build-from-local --actors john.doe/my-actor
```

Pass a hardcoded actor name via `--actors` to build only that Actor (comma-separate multiple names). Omit `--actors` to build all Actors in the repo, or add `--dry-run` to preview without building. It outputs the same JSON build array as `build`, so you run tests against it the same way as in step 5 below:

```bash
# Build from local source and capture output
BUILDS=$(APIFY_TOKEN_JOHN_DOE=apify_api_xxx \
GITHUB_WORKSPACE=. \
npx apify-test-tools build-from-local --actors apify/my-actor)
```

Since you already scoped the build to just the Actor(s) you care about, point vitest at a specific test file (or a `-t` name filter) instead of the whole `test/platform` directory — you get feedback on that one test without waiting for the full suite to run.

#### 5. Run tests against the builds

Pass the build output as `ACTOR_BUILDS` and provide `TESTER_APIFY_TOKEN`. The token can point to your own account (if you have enough memory) or you can use the testing account (xRGg9iAfJSymqartk).
Expand Down
204 changes: 204 additions & 0 deletions bin/build-from-local.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';

import type { ActorVersionSourceFile } from 'apify-client';

import { ApifyBuilder, waitAndSummarizeBuilds } from './build.js';
import type { ActorConfig, BuildData } from './types.js';
import { getGitignoredPaths, isOutsideDir, listRepoFilePaths, toActorVersionSourceFile } from './utils.js';

// JUST IN CASE. File patterns that commonly hold credentials — never ship these into a build, regardless
// of sourceType or of whether the repo's .gitignore happens to list them. Everything else that should be
// excluded (build output, local overrides, project-specific secret files, ...) is expected to already be
// in the repo's .gitignore — see collectNonIgnoredFiles.
const SKIP_FILE_PATTERNS = [/^\.env(\..+)?$/, /\.pem$/, /\.key$/, /\.pfx$/, /\.p12$/];
const isSecretFile = (fileName: string): boolean => SKIP_FILE_PATTERNS.some((pattern) => pattern.test(fileName));

export const collectSourceFiles = async (actorName: string, actorDir: string): Promise<ActorVersionSourceFile[]> => {
const repoRoot = process.cwd();
const absActorDir = path.resolve(actorDir);

// Read actor.json to check if this is a monorepo actor with an external dockerContextDir.
// Monorepo actors point their dockerContextDir to a parent directory (e.g. "../../.."),
// which means the Docker build context is the repo root, not the actor directory itself.
const actorJsonPath = path.join(absActorDir, '.actor', 'actor.json');
const actorJson = JSON.parse(await fs.readFile(actorJsonPath, 'utf8')) as Record<string, unknown>;
const rawContextDir = actorJson.dockerContextDir as string | undefined;
const contextAbsDir = rawContextDir ? path.resolve(absActorDir, '.actor', rawContextDir) : undefined;
const isMonorepoActor = !!contextAbsDir && isOutsideDir(contextAbsDir, absActorDir);

const collectRootDir = isMonorepoActor ? contextAbsDir! : absActorDir;
const keptFilePaths = collectNonIgnoredFiles(collectRootDir, repoRoot);

if (!isMonorepoActor) {
return Promise.all(keptFilePaths.map(async (filePath) => toActorVersionSourceFile(filePath, collectRootDir)));
}

const { tempDir, filePaths } = await flattenMonorepoContext(
actorName,
absActorDir,
contextAbsDir!,
actorJson,
keptFilePaths,
repoRoot,
);
try {
return await Promise.all(filePaths.map(async (filePath) => toActorVersionSourceFile(filePath, tempDir)));
} finally {
// Only the flattened copy is temporary — never delete the actor's own directory.
await fs.rm(tempDir, { recursive: true, force: true });
}
};

// Candidates come from `git ls-files` (tracked + untracked, gitignored included) rather than a
// manual directory walk — nested .gitignore files, `.git/info/exclude`, and global excludes are
// all honored since this delegates to git itself instead of re-implementing gitignore matching,
// and .git/ is never walked because git never lists its own internals here. `.actor/` (the Actor
// specification folder) is always kept regardless of .gitignore, matching Apify CLI's own behavior.
// Files matching the hardcoded secret-pattern backstop (keys, certs, .env variants) are dropped
// unconditionally, .actor/ included, since those should never ship regardless of what .gitignore says.
export const collectNonIgnoredFiles = (rootDir: string, repoRoot: string): string[] => {
const relativePaths = listRepoFilePaths(repoRoot, rootDir);
const ignoredPaths = getGitignoredPaths(relativePaths);

return relativePaths
.filter((relPath) => {
if (isSecretFile(path.basename(relPath))) return false;
const isUnderActorDir = relPath.split('/').includes('.actor');
return isUnderActorDir || !ignoredPaths.has(relPath);
})
.map((relPath) => path.join(repoRoot, relPath));
};

// SOURCE_FILES always treats the collected root as the actor root, so we cannot simply
// collect the actor directory of a monorepo actor — the platform would reject any path
// escaping it. Fix: create a temporary "flattened" directory where:
// - the Docker context's non-ignored files (repo root) are copied to the temp dir root
// - the actor's .actor/ directory is overlaid at the temp dir root (through the same
// gitignore/secret-pattern filter as the rest of the context — see collectNonIgnoredFiles)
// - actor.json path fields are rewritten to be relative to the new location
//
// Result: the collected root IS the Docker context, .actor/ is at that root, and
// all relative paths (dockerfile, dockerContextDir, changelog) are exactly one
// level up ("..") instead of three ("../../..").
export const flattenMonorepoContext = async (
actorName: string,
absActorDir: string,
contextAbsDir: string,
actorJson: Record<string, unknown>,
keptContextFiles: string[],
repoRoot: string,
): Promise<{ tempDir: string; filePaths: string[] }> => {
console.error(`[${actorName}]: monorepo actor detected — flattening from Docker context`);

const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), `apify-build-${actorName.replace('/', '_')}-`));
const filePaths: string[] = [];

// Step 1: copy only the files that survived gitignore/secret filtering, preserving their
// position relative to the Docker context root.
await Promise.all(
keptContextFiles.map(async (absFilePath) => {
const relPath = path.relative(contextAbsDir, absFilePath);
const destPath = path.join(tempDir, relPath);
await fs.mkdir(path.dirname(destPath), { recursive: true });
await fs.copyFile(absFilePath, destPath);
filePaths.push(destPath);
}),
);

// Step 2: overlay the actor's .actor/ directory at the temp dir root. collectNonIgnoredFiles
// always keeps .actor/ paths regardless of .gitignore, but still drops the hardcoded secret
// patterns — so this isn't a raw copy, a stray secret file living inside .actor/ is still dropped.
const actorMetaDir = path.join(absActorDir, '.actor');
const keptActorFiles = collectNonIgnoredFiles(actorMetaDir, repoRoot);
await Promise.all(
keptActorFiles.map(async (absFilePath) => {
const relPath = path.relative(actorMetaDir, absFilePath);
const destPath = path.join(tempDir, '.actor', relPath);
await fs.mkdir(path.dirname(destPath), { recursive: true });
await fs.copyFile(absFilePath, destPath);
filePaths.push(destPath);
}),
);

// Step 3: rewrite actor.json path fields so they resolve correctly from the new location.
// This overwrites the actor.json already copied in step 2 in place, so its path is already
// accounted for in filePaths — no need to add it again.
await rewriteActorJsonPaths(absActorDir, contextAbsDir, tempDir, actorJson);

return { tempDir, filePaths };
};

// Rewrites actor.json path fields so they resolve correctly from the new .actor/ location
// (one level below the root) instead of the original three-levels-deep location.
//
// Algorithm for each path field:
// 1. Resolve the original value to an absolute path on disk.
// 2. Compute its position relative to the Docker context root (e.g. repo root).
// That relative position is exactly where the file landed inside tempDir,
// because we copied contextAbsDir → tempDir in flattenMonorepoContext's step 1.
// 3. Build the new path from newActorDir to that file in tempDir.
//
// Local paths (e.g. "./dataset_schema.json") point inside .actor/ and are left
// unchanged — .actor/ was copied intact so those paths still resolve correctly.
export const rewriteActorJsonPaths = async (
absActorDir: string,
contextAbsDir: string,
tempDir: string,
actorJson: Record<string, unknown>,
): Promise<void> => {
const originalActorDir = path.join(absActorDir, '.actor');
const newActorDir = path.join(tempDir, '.actor');
const pathFields = ['dockerfile', 'dockerContextDir', 'changelog', 'readme'] as const;
const rewritten = { ...actorJson };
for (const field of pathFields) {
const value = rewritten[field];
if (typeof value !== 'string') continue;

const absPath = path.resolve(originalActorDir, value);

// Skip paths that stay inside .actor/ — they don't need rewriting.
if (!isOutsideDir(absPath, originalActorDir)) continue;

// Where does this file live inside the Docker context? That's also where
// it lives inside tempDir after the copy in flattenMonorepoContext's step 1.
const relativeToContext = path.relative(contextAbsDir, absPath);
const newAbsPath = path.join(tempDir, relativeToContext);
rewritten[field] = path.relative(newActorDir, newAbsPath);
}
await fs.writeFile(path.join(newActorDir, 'actor.json'), JSON.stringify(rewritten, null, 4));
};

export const runBuildsFromLocal = async ({
actorConfigs,
dryRun,
}: {
actorConfigs: ActorConfig[];
dryRun: boolean;
}): Promise<BuildData[]> => {
if (dryRun) {
console.error('[DRY RUN] Would build from local source:');
for (const { actorName, folder } of actorConfigs) {
console.error(` ${actorName} (${folder})`);
}
return actorConfigs.map(({ actorName }) => ({
buildId: 'dry-run',
actorId: 'dry-run',
buildNumber: '0.98.0',
actorName,
}));
}

console.error('=========================================');
console.error('STARTED LOCAL BUILDS:');
const startedBuilds = await Promise.all(
actorConfigs.map(async ({ actorName, folder }) => {
const builder = ApifyBuilder.fromActorName(actorName);
const sourceFiles = await collectSourceFiles(actorName, folder);
return builder.startActorBuildFromSourceFiles(sourceFiles);
}),
);

return waitAndSummarizeBuilds(startedBuilds, 'LOCAL BUILDS');
};
88 changes: 67 additions & 21 deletions bin/build.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Build } from 'apify-client';
import type { ActorVersionSourceFile, Build } from 'apify-client';
import { ApifyClient } from 'apify-client';

import { ACTOR_SOURCE_TYPES } from '@apify/consts';
Expand All @@ -12,7 +12,7 @@ type BuildPrActorOptions = {
actorName: string;
useDockerCache: boolean;
};
class ApifyBuilder {
export class ApifyBuilder {
private constructor(
private readonly apifyClient: ApifyClient,
private readonly actorName: string,
Expand Down Expand Up @@ -101,15 +101,55 @@ class ApifyBuilder {
return { buildId: id, actorId: actId, buildNumber, actorName: this.actorName };
};

startActorBuildFromSourceFiles = async (sourceFiles: ActorVersionSourceFile[]): Promise<BuildData> => {
const ZIP_VERSION = '0.98';
const actorClient = this.apifyClient.actor(this.actorName);
const actorInfo = await actorClient.get();
if (!actorInfo) {
throw new Error(
`No actor named '${this.actorName}' was found on the platform. If this` +
' is unexpected, make sure the actor you are targeting is spelled the' +
' same as the folder in the repository.',
);
}

type ActorVersion = Parameters<ReturnType<typeof actorClient.version>['update']>[0];
const actorVersion: ActorVersion = {
versionNumber: ZIP_VERSION,
sourceFiles,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: couldn't find this type :(
sourceType: ACTOR_SOURCE_TYPES.SOURCE_FILES,
};

const versionExists = !actorInfo.versions.find((v) => v.versionNumber === ZIP_VERSION);
if (versionExists) {
await actorClient.versions().create(actorVersion);
} else {
await actorClient.version(ZIP_VERSION).update(actorVersion);
}

const { id, actId, buildNumber } = await actorClient.build(ZIP_VERSION, { useCache: false });
console.error(`[${this.actorName}]: ${id} (${buildNumber})`);
return { buildId: id, actorId: actId, buildNumber, actorName: this.actorName };
};

waitForBuildToFinish = async (buildId: string, actorName: string): Promise<Build> => {
const build = await this.apifyClient.build(buildId).waitForFinish();
const versionNumber = build.buildNumber;
if (build.status === 'FAILED' || build.status === 'TIMED-OUT') {
const message =
`[BUILD][${actorName}]: Build ${buildId} (${versionNumber}) failed. ` +
`Not continuing with other builds and tests.`;
console.error(`[${this.actorName}]: ${versionNumber}`);
throw new Error(message);
try {
const log = await this.apifyClient.build(buildId).log().get();
const logTail = log?.split('\n').slice(-40).join('\n');
console.error(`\n--- BUILD LOG (last 40 lines) ---\n${logTail}\n---`);
} catch (err) {
console.error(`[${this.actorName}]: Failed to fetch build log: ${err}`);
}
throw new Error(
`[BUILD][${actorName}]: Build ${buildId} (${versionNumber}) failed. ` +
`Not continuing with other builds and tests.`,
);
}
console.error(`[${this.actorName}]: ${versionNumber}`);
return build;
Expand Down Expand Up @@ -229,6 +269,26 @@ class ApifyBuilder {
}
}

export const waitAndSummarizeBuilds = async (startedBuilds: BuildData[], label: string): Promise<BuildData[]> => {
console.error('=========================================');
console.error(`FINISHED ${label}:`);
await Promise.all(
startedBuilds.map(async (buildData) => {
const builder = ApifyBuilder.fromActorName(buildData.actorName);
await builder.waitForBuildToFinish(buildData.buildId, buildData.actorName);
}),
);

console.error('=========================================');
console.error('SUMMARY:');
for (const buildData of startedBuilds.sort((a, b) => a.actorName.localeCompare(b.actorName))) {
console.error(`[${buildData.actorName}]: ${buildData.buildNumber}`);
}
console.error('=========================================');

return startedBuilds;
};

type RunBuildsOptions = {
actorConfigs: ActorConfig[];
isLatest?: boolean;
Expand Down Expand Up @@ -281,22 +341,8 @@ export const runBuilds = async ({
return buildData;
}),
);
console.error('=========================================');
console.error('FINISHED BUILDS:');
await Promise.all(
startedBuilds.map(async (buildData) => {
const builder = ApifyBuilder.fromActorName(buildData.actorName);
await builder.waitForBuildToFinish(buildData.buildId, buildData.actorName);
}),
);
console.error('=========================================');
console.error('SUMMARY:');
for (const buildData of startedBuilds.sort((a, b) => a.actorName.localeCompare(b.actorName))) {
console.error(`[${buildData.actorName}]: ${buildData.buildNumber} `);
}
console.error('=========================================');

return startedBuilds;
return waitAndSummarizeBuilds(startedBuilds, 'BUILDS');
};

export const deleteOldBuilds = async (actorConfigs: ActorConfig[]) => {
Expand Down
26 changes: 26 additions & 0 deletions bin/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import yargs, { type Argv } from 'yargs';
import { hideBin } from 'yargs/helpers';

import { deleteOldBuilds, runBuilds } from './build.js';
import { runBuildsFromLocal } from './build-from-local.js';
import { getChangedActors } from './diff-changes.js';
import { getBranchOnlyChangedFiles, getChangedFiles, getCommits, hasMergeFromTarget } from './git.js';
import { getPushData } from './github.js';
Expand Down Expand Up @@ -202,6 +203,31 @@ await yargs()
});
},
)
.command(
'build-from-local',
'',
(args) =>
args
.option('actors', {
type: 'string',
description:
'Comma-separated actor names (owner/name) to build. Defaults to all actors in the repo.',
})
.option('dry-run', { type: 'boolean', default: false }),
async ({ actors, dryRun }) => {
const allActorConfigs = await getRepoActors();
const actorConfigs = actors
? actors.split(',').map((name) => {
const trimmed = name.trim();
const config = allActorConfigs.find((c) => c.actorName === trimmed);
if (!config) throw new Error(`Actor "${trimmed}" not found in repo`);
return config;
})
: allActorConfigs;
const builds = await runBuildsFromLocal({ actorConfigs, dryRun });
console.log(JSON.stringify(builds));
},
)
.command(
'delete-old-builds',
'',
Expand Down
Loading
Loading