Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
af1a7e2
feat: enforce using actor.json as source of truth and fallback to gen…
ruocco-l Jun 24, 2026
e4bb717
feat: support building from single actor repo
ruocco-l Jun 24, 2026
aefdb55
feat: match changed files by folder path instead of reconstructed act…
ruocco-l Jun 24, 2026
bfba4d3
add relevant tests
ruocco-l Jun 24, 2026
278ae9c
small refactor
ruocco-l Jun 24, 2026
48cd215
validate actor on user account
ruocco-l Jun 24, 2026
d89651e
Merge branch 'master' into feat/extend-support
ruocco-l Jun 25, 2026
78c0959
use config file as source of truth for actors in repo
ruocco-l Jun 25, 2026
9a6c9f6
treat .actor in root always as functional
ruocco-l Jun 25, 2026
c3f0604
add init-config command to jump start configuration
ruocco-l Jun 25, 2026
92316c0
remove access check readConfigFile
ruocco-l Jun 25, 2026
5a50e39
update relevant tests
ruocco-l Jun 25, 2026
0a8487c
update readme
ruocco-l Jun 25, 2026
f097d2e
Replace owner with config-level actorName, add dockerContextDir resol…
ruocco-l Jun 26, 2026
151e200
Rewrite change detection to use dockerContextDir-based actor scoping
ruocco-l Jun 26, 2026
6a68294
Respect dockerignore to further skip non relevant paths
ruocco-l Jun 26, 2026
a77a638
update tests
ruocco-l Jun 26, 2026
08476b2
update README
ruocco-l Jun 26, 2026
ae0d1dc
rename variable
ruocco-l Jul 1, 2026
18cf039
move context-path resolution into readConfigFile
ruocco-l Jul 1, 2026
faf4784
context aware IGNORED_TOP_LEVEL_FILES and updated readme-changelog logic
ruocco-l Jul 2, 2026
d29eb16
define shared path-utils
ruocco-l Jul 2, 2026
9a3ec87
rework json classification rule
ruocco-l Jul 3, 2026
ab37aeb
update Readme
ruocco-l Jul 3, 2026
b3df6fa
remove caching logic for cosmetic changes
ruocco-l Jul 3, 2026
6980b49
reworked logging logic
ruocco-l Jul 3, 2026
73a2d8d
enforce folder field in config json
ruocco-l Jul 3, 2026
cbed5e2
rename config file
ruocco-l Jul 3, 2026
5efdb13
disambiguate actor identifiers into actorFullName, actorRawId and act…
ruocco-l Jul 3, 2026
11eb3b2
simpler apifyBuilder
ruocco-l Jul 16, 2026
2127bfc
Merge commit '6058a395faf006ddb6ee155f850e3b8fa9e69c94' into feat/ext…
ruocco-l Jul 17, 2026
9ea9884
Merge commit 'cc145ab00c70e6fa3aba1e01f32d8116ab1b0222' into feat/ext…
ruocco-l Jul 17, 2026
48b7847
dockerignore reconciliation
ruocco-l Jul 20, 2026
98c610b
revert logic for changelog classification and add TODO
ruocco-l Jul 20, 2026
397f3ed
Bump version from 0.8.6 to 0.9.0
metalwarrior665 Jul 21, 2026
ec44cae
add actor folder path if missing in overriden context
ruocco-l Jul 27, 2026
6a5f13d
Merge branch 'master' into feat/extend-support
ruocco-l Aug 6, 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
96 changes: 83 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,99 @@

## Getting Started

1. Install the package `npm i -D apify-test-tools`
- because it uses [annotate](https://vitest.dev/guide/test-context.html#annotate), `vitest` version to be at least `3.2.0`
- make sure that `target` and `module` in your `tsconfig.json`'s `compilerOptions` are set to `ES2022`
2. create test directories: `mkdir -p test/platform/core`
- core (hourly) tests should go to `test/platform/core`
- daily tests should go to `test/platform`
3. setup github worklows TODO
### 1. Install the package

File structure:
```bash
npm i -D apify-test-tools
```

- Requires `vitest` version `3.2.0` or later (uses [annotate](https://vitest.dev/guide/test-context.html#annotate))
- Make sure `target` and `module` in your `tsconfig.json`'s `compilerOptions` are set to `ES2022`

### 2. Create the config file

Every repo that uses `apify-test-tools` must have an `apify-test-tools.config.json` file at the root. This file tells the tool which actors live in the repo, how to identify them, and which token to use.

```json
{
"actors": [
{
"folder": "actors/web-scraper",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would add actorName too rather than deriving it from the folder

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought about it, but I think can cause some confusion when you (usually when you publish the actor) play a little with the naming for SEO reasons (or similar). I think whatever is in the actor.json should be respected as the truth and not be overridden by some obscure logic from the testing package.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The testing lib will not set or change the name, it just check that it exists. The name.in actor.json is not a single source of truth so I would not use it, better to have all here

"actorFullName": "myteam/web-scraper",
"tokenEnvVar": "APIFY_TOKEN_MYTEAM"
},
{
"folder": "actors/email-sender",
"actorFullName": "myteam/email-sender",
"tokenEnvVar": "APIFY_TOKEN_MYTEAM",
"overrideActorContext": ["actors/email-sender", "packages/shared"]
}
]
}
```

Each entry has:

| Field | Required | Description |
| ---------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `folder` | yes | Relative path from repo root to the actor's own project directory — the folder that directly contains `.actor/actor.json` (i.e. `<folder>/.actor/actor.json`), the actor's README/CHANGELOG, and its source. Use `"."` for a single-actor repo where `.actor/` is at the root. |
| `actorFullName` | yes | Full actor identifier in `owner/name` format (e.g. `"apify/web-scraper"`). This is the source of truth for the actor name — the `name` field in `actor.json` is not used. |
| `tokenEnvVar` | yes | Name of the environment variable holding the Apify API token for this actor. No fallback — if the env var is not set at build time, the build fails. |
| `overrideActorContext` | no | Array of paths (relative to repo root) that define which files are relevant to this actor. When set, replaces the `dockerContextDir` from `actor.json` for change detection. Useful when an actor depends on shared packages outside its Docker build context. Entries must not be prefixes of one another (e.g. `["", "code"]` or `["actors", "actors/foo"]` are rejected). The actor's own `folder` is always part of its context — if none of the listed entries reach it, it's added automatically. |

### 3. Set up actor folders

Each actor in the config must have a `.actor/actor.json` file. The `dockerContextDir` field in `actor.json` defines the build context boundary — this is what the tool uses to determine which files can affect the actor's build.

```
google-maps
my-repo
├── apify-test-tools.config.json
├── actors
└── src
│ ├── web-scraper
│ │ ├── .actor
│ │ │ └── actor.json
│ │ └── src/
│ └── email-sender
│ ├── .actor
│ │ └── actor.json
│ └── src/
└── test
├── unit
└── platform
├── core <- Core tests need to be inside core directory
├── core <- Core (hourly) tests
│ └── core.test.ts
├── some.test.ts <- Other tests can be defined anywhere inside platform directory
├── some.test.ts <- Daily tests can be anywhere inside platform/
└── some-other.test.ts
```

For a single-actor repo, set `"folder": "."` in the config and place `.actor/actor.json` at the repo root.

### Change detection

When a PR is opened or code is pushed, the tool determines which actors need to be built and tested based on the changed files. For each changed file, for each actor:

1. **Sibling exclusion** — files inside another actor's `folder` are excluded first. This prevents an actor with broad context from being triggered by changes that belong to a sibling actor.
2. **CHANGELOG classification** — a `CHANGELOG.md` file is always `cosmetic` (only triggers a release build, not tests), for every actor, regardless of context or folder. (See [issue #106](https://github.com/apify/apify-test-tools/issues/106).)
3. **Context matching** — the file must fall within one of the actor's context paths (`dockerContextDir` from `actor.json` by default, or `overrideActorContext` from config if set). Files outside every context path are skipped.
4. **Hardcoded ignore list, context-aware** — the file path is first "hoisted" relative to the context path it matched (e.g. a standalone actor's own `.eslintrc` is checked as just `.eslintrc`, not the full repo-root-relative path), then checked against repo-level dev file patterns (`.vscode/`, `.gitignore`, `.husky/`, `.eslintrc`, `eslint.config.mjs`, `.prettierrc`, `.editorconfig`). There's no hardcoded special-casing for legacy `code/`/`shared/` layouts — repos that need those directories treated as top-level must list them explicitly in `overrideActorContext`.
5. **`.dockerignore` filtering** — if a `.dockerignore` exists at the root of the actor's `dockerContextDir`, matching files are ignored. Patterns are resolved relative to `dockerContextDir`, matching Docker's own behavior.
6. **README classification** — a `README.md` file is `cosmetic` (only triggers a release build, not tests) if it lives inside the actor's own `folder`; otherwise it's ignored entirely, since it isn't documentation for this actor.
7. **Cosmetic JSON classification** — `.json` files inside the actor's own `.actor/` directory with only cosmetic schema changes (whitespace, key ordering) only trigger a release build.
8. **Functional** — everything else triggers both build and tests.

### 4. Create test directories

```bash
mkdir -p test/platform/core
```

- Core (hourly) tests go in `test/platform/core`
- Daily tests go anywhere in `test/platform`

### 5. Set up GitHub workflows

See the [GitHub workflows](#github-worklows) section below.

## Github worklows

There should be 4 GH workflow files in `.github/workflows`.
Expand Down Expand Up @@ -343,7 +413,7 @@ GITHUB_WORKSPACE=. \
Remove `--dry-run` to actually trigger builds and update the branch names/ The command outputs a JSON array of build objects to stdout:

```json
[{ "buildId": "...", "actorId": "...", "buildNumber": "...", "actorName": "john.doe/my-actor" }]
[{ "buildId": "...", "actorRawId": "...", "buildNumber": "...", "actorFullName": "john.doe/my-actor" }]
```

#### Build from local source (no push needed)
Expand Down
69 changes: 35 additions & 34 deletions bin/build-from-local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,10 @@ import path from 'node:path';
import type { ActorVersionSourceFile } from 'apify-client';

import { ApifyBuilder, waitAndSummarizeBuilds } from './build.js';
import { buildDockerIgnoreMatcher } from './dockerignore.js';
import { isPathWithinScope } from './path-utils.js';
import type { ActorConfig, BuildData } from './types.js';
import {
getDockerignoredPaths,
getGitignoredPaths,
isOutsideDir,
listRepoFilePaths,
toActorVersionSourceFile,
} from './utils.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
Expand All @@ -34,11 +30,13 @@ export const collectSourceFiles = async (actorName: string, actorDir: string): P
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);
const dockerContextDirAbs = isMonorepoActor ? contextAbsDir! : absActorDir;
const keptFilePaths = collectNonIgnoredFiles(dockerContextDirAbs, repoRoot);

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

const { tempDir, filePaths } = await flattenMonorepoContext(
Expand All @@ -47,7 +45,6 @@ export const collectSourceFiles = async (actorName: string, actorDir: string): P
contextAbsDir!,
actorJson,
keptFilePaths,
repoRoot,
);
try {
return await Promise.all(filePaths.map(async (filePath) => toActorVersionSourceFile(filePath, tempDir)));
Expand All @@ -61,29 +58,29 @@ export const collectSourceFiles = async (actorName: string, actorDir: string): P
// 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. `.dockerignore` at
// `rootDir` (the Docker build context) is honored the same way, since those files would never
// reach a real Docker build either. `.actor/` (the Actor specification folder) is always kept
// `dockerContextDir` (the Docker build context) is honored the same way, since those files would
// never reach a real Docker build either. `.actor/` (the Actor specification folder) is always kept
// regardless of .gitignore/.dockerignore, 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 the ignore files say.
export const collectNonIgnoredFiles = (rootDir: string, repoRoot: string): string[] => {
const relativePaths = listRepoFilePaths(repoRoot, rootDir);
export const collectNonIgnoredFiles = (dockerContextDir: string, repoRoot: string): string[] => {
const relativePaths = listRepoFilePaths(repoRoot, dockerContextDir);
const ignoredPaths = getGitignoredPaths(relativePaths);
const rootRelativePaths = new Map(
relativePaths.map((relPath) => [
relPath,
path.relative(rootDir, path.join(repoRoot, relPath)).split(path.sep).join('/'),
path.relative(dockerContextDir, path.join(repoRoot, relPath)).split(path.sep).join('/'),
]),
);
const dockerIgnoredPaths = getDockerignoredPaths(rootDir, [...rootRelativePaths.values()]);
const isDockerIgnored = buildDockerIgnoreMatcher(dockerContextDir);

return relativePaths
.filter((relPath) => {
if (isSecretFile(path.basename(relPath))) return false;
const isUnderActorDir = relPath.split('/').includes('.actor');
if (isUnderActorDir) return true;
if (ignoredPaths.has(relPath)) return false;
return !dockerIgnoredPaths.has(rootRelativePaths.get(relPath)!);
return !isDockerIgnored(rootRelativePaths.get(relPath)!);
})
.map((relPath) => path.join(repoRoot, relPath));
};
Expand All @@ -92,8 +89,8 @@ export const collectNonIgnoredFiles = (rootDir: string, repoRoot: string): strin
// 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)
// - the actor's own .actor/ directory (already present within those same non-ignored files)
// is overlaid at the temp dir root instead of its original nested position
// - 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
Expand All @@ -105,7 +102,6 @@ export const flattenMonorepoContext = async (
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`);

Expand All @@ -124,11 +120,13 @@ export const flattenMonorepoContext = async (
}),
);

// 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.
// Step 2: overlay the actor's own .actor/ directory at the temp dir root. Its files are already
// present in keptContextFiles (collectNonIgnoredFiles keeps .actor/ paths unconditionally — see
// step 1 above) — pick out this actor's own subset (ignoring any sibling actors' .actor/ folders
// that might also appear in the broader context) and hoist each to its position relative to
// .actor/ itself, so it lands under tempDir/.actor/ instead of its original nested location.
const actorMetaDir = path.join(absActorDir, '.actor');
const keptActorFiles = collectNonIgnoredFiles(actorMetaDir, repoRoot);
const keptActorFiles = keptContextFiles.filter((absFilePath) => isPathWithinScope(absFilePath, actorMetaDir));
await Promise.all(
keptActorFiles.map(async (absFilePath) => {
const relPath = path.relative(actorMetaDir, absFilePath);
Expand Down Expand Up @@ -196,26 +194,29 @@ export const runBuildsFromLocal = async ({
}): Promise<BuildData[]> => {
if (dryRun) {
console.error('[DRY RUN] Would build from local source:');
for (const { actorName, folder } of actorConfigs) {
console.error(` ${actorName} (${folder})`);
for (const { actorFullName, folder } of actorConfigs) {
console.error(` ${actorFullName} (${folder})`);
}
return actorConfigs.map(({ actorName }) => ({
return actorConfigs.map(({ actorFullName }) => ({
buildId: 'dry-run',
actorId: 'dry-run',
actorRawId: 'dry-run',
buildNumber: '0.98.0',
actorName,
actorFullName,
}));
}

console.error('=========================================');
console.error('STARTED LOCAL BUILDS:');
const buildersByActorFullName = new Map<string, ApifyBuilder>(
actorConfigs.map((actorConfig) => [actorConfig.actorFullName, ApifyBuilder.fromActorConfig(actorConfig)]),
);
const startedBuilds = await Promise.all(
actorConfigs.map(async ({ actorName, folder }) => {
const builder = ApifyBuilder.fromActorName(actorName);
const sourceFiles = await collectSourceFiles(actorName, folder);
actorConfigs.map(async ({ actorFullName, folder }) => {
const builder = buildersByActorFullName.get(actorFullName)!;
const sourceFiles = await collectSourceFiles(actorFullName, folder);
return builder.startActorBuildFromSourceFiles(sourceFiles);
}),
);

return waitAndSummarizeBuilds(startedBuilds, 'LOCAL BUILDS');
return waitAndSummarizeBuilds(startedBuilds, buildersByActorFullName, 'LOCAL BUILDS');
};
Loading
Loading