Skip to content

feat!: replace folder naming convention for actor discovery with explicit config file - #96

Open
ruocco-l wants to merge 37 commits into
masterfrom
feat/extend-support
Open

feat!: replace folder naming convention for actor discovery with explicit config file#96
ruocco-l wants to merge 37 commits into
masterfrom
feat/extend-support

Conversation

@ruocco-l

@ruocco-l ruocco-l commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Closes #84

The CI tooling used to derive actor names and ownership entirely from folder naming conventions (owner_actor-name). This PR replaces that convention with an explicit, mandatory config file (apify-test-tools.config.json) at the repo root, and reworks change detection to scope by each actor's Docker build context instead of hardcoded actors//standalone-actors/ path regexes.

The config file

Each repo declares its actors in apify-test-tools.config.json, one entry per actor:

  • folder (where the relevant .actor folder is),
  • actorFullName (owner/name, now the sole source of truth — no longer reconstructed from the folder),
  • tokenEnvVar (no fallback, no derivation from owner/repo organization fails hard if unset),
  • overrideActorContext for actors depending on paths outside their own folder.

See the README for the full schema.

Why change detection is scoped by Docker context instead of top-level directories

The old detection matched changed files against hardcoded actors//standalone-actors/ prefixes, which couldn't express "this actor also depends on packages/shared" or work for a single-actor repo with no actors/ directory at all. Scoping by each actor's dockerContextDir (with overrideActorContext as an escape hatch) ties change detection to the same boundary Docker itself uses to build the actor, so the two can't drift apart. .dockerignore filtering follows the same reasoning: if Docker wouldn't see a file, it shouldn't trigger a rebuild either.

Other design decisions

  • Actor identifier disambiguationactorName was used ambiguously for both the full owner/name string and the platform-internal ID, which produced hard-to-catch bugs when the wrong one was used. Split into actorFullName, actorRawId, and actorId throughout so each call site is unambiguous about which one it needs.
  • ApifyBuilder.fromActorConfig replaces fromActorName, which re-derived the token independently from the actor name rather than trusting the already-resolved config.

Breaking change

Repos must add apify-test-tools.config.json at the root, listing every actor with its folder, actorFullName, and tokenEnvVar (see README for the exact schema). The old folder-naming convention and BUILDER_APIFY_TOKEN fallback are both gone — there's no compatibility path for repos that don't add the config file. Special-case support for CIRC_LE accounts is also discontinued.

@metalwarrior665 metalwarrior665 left a comment

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 think this is good and obviously needed change. It won't be that big of a hassle for other teams since adding new Actors is quite rare. We need to polish this though.

Comment thread bin/diff-changes.ts Outdated
Comment on lines +36 to +35
'.actor/',
// In root .actor/ mode, .actor/ changes must trigger builds
...(isSingleActorRepo ? [] : ['.actor/']),

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.

We can remove this completely, there is no reason we should commit changes to top level .actor in multiactor

Comment thread bin/utils.ts Outdated

let cachedBuilderUsername: string | undefined;

export const resolveBuilderTokenUsername = async (): Promise<string> => {

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.

What is the use-case to need this? Just so you don't have to think about the username?

Comment thread bin/utils.ts Outdated
};

const readActorName = async (actorJsonPath: string): Promise<string> => {
const actorJson: { name?: string } = JSON.parse(await fs.readFile(actorJsonPath, 'utf-8'));

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 rather introduce a new JSON file for our own custom needs than stitch non-spec fields to actor.json.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

+1

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

that would also enable us to cover miniactors, monorepos or single actor stuff without doing the
user-name_actor-name convention.
It could all be config stuff

Comment thread bin/utils.ts Outdated
Comment thread bin/utils.ts Outdated
actorName: fullName,
folder: actorDir,
isStandalone: folderType === 'standalone-actors',
tokenEnvVar,

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'm not a big fan of passing the token around because it is more likely to leak, we should be able to just resolve it in place like we did no?

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.

This doesn't pass the token, just the name of the env variable where it's stored

@metalwarrior665

Copy link
Copy Markdown
Member

We should also sync with @gullmar about other repos. The monorepo where each Actor has its own src is also probably not ideally supported.

Comment thread bin/utils.ts Outdated
Comment on lines +62 to +66
const resolveOwner = async (folderName: string): Promise<string> => {
const ownerMatch = folderName.match(/^(.+)_[^_]+$/);
if (ownerMatch) return ownerMatch[1];
return resolveBuilderTokenUsername();
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

So the owner still has to be defined in the folder name 😅 That kind of defeats the purpose of this entire effort 🫠

I would either want the conventionally named folder names only (owner + name in folder name), or to have both the owner and actor name in actor.json, but not both the conventionally named folder names for owners and actor.json for names at once 🙏

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.

No. IF the owner is there we automatically assign and look for the appropriate token. If not we fallback to whatever is the owner of the BUILDER token. This is still useful if you have a default account where there are all the miniactors (for which is ok to fallback the BUILDER token) and have one miniactor that is build under a different account (for whatever reason), in which case you will specify it with the folder name owner_whatever-actor-name-since-it's-not-used.

But I agree that just adding the owner to the actor.json is much cleaner

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I agree that for most repos, the single BUILDER token would work fine, since we usually have everything under one account. So, for the most part, this would only have to cover edge cases. Even then it seems like there should be a better way to define the edge case then by folder name - be it actor.json or a global config file 🤔

Comment thread bin/utils.ts Outdated
Comment thread bin/utils.ts Outdated
};

const readActorName = async (actorJsonPath: string): Promise<string> => {
const actorJson: { name?: string } = JSON.parse(await fs.readFile(actorJsonPath, 'utf-8'));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

+1

Comment thread bin/utils.ts Outdated
@ruocco-l
ruocco-l marked this pull request as draft June 25, 2026 12:24
@ruocco-l

Copy link
Copy Markdown
Contributor Author

Alright alright, let's do the config

@ruocco-l
ruocco-l marked this pull request as ready for review June 25, 2026 14:59
@metalwarrior665

Copy link
Copy Markdown
Member

One more related long-term thing. When we designed this library, the core idea was "convention over configuration". We wanted everything to be at hardcoded places since that is always better than having to config and it worked well for that use-case.

Now if we want many (or arbitrary) different setups, we cannot keep patching it like this because we would have to keep adding ifs to already complex logic. We have to rewrite most of the core logic to be truly configurable, e.g. each Actor has points to its "workspace" of path dirs (that can be shared).

On the other hand, the library has to remain somewhat opinionated, like what file changes trigger build etc, otherwise it has basically no value, it would just be some configs + few API calls.

I think for now, this PR is ok because top-level .actor is the template thing, but there probably already are things in the "changed Actors" that break. But for anything more, I would wait for th full rewrite. That should also make it truly open-source usable.

@metalwarrior665 metalwarrior665 left a comment

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 gave it another thought and I think it wouldn't be too hard to go all the way for really configurable config. Otherwise, we would have to potentially do another breaking change.

We should support (let's look of we can find more) at least 3 reference repos:

  • typical Store monorepo
  • template-like repo (e.g. WCC)
  • monorepo with packages and actors importing them - e-commerce

I'm thinking it could work like this:

  1. Each Actor config points to the folder where .actor is (like now) or directly to the .actor folder.
  2. Then from actor.json, we read dockerContextDir and that contains all folders/files that can influence the changes for that Actor. We can allow config to have overrideActorContext if they want to e.g. narrow it down.
  3. Then instead of the current logic that if "code changes" we add mark all Actors as changed, we just do the changed files logic for each mini Actor independently, that cleans up the logic as well.

This way, we handle current Store monorepo (Actor folder + top-level context), template (just top-level), e-commerce (actor folder with its code, packages, shared)

What do you think?

Comment thread README.md
{
"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

Comment thread bin/main.ts
});
},
)
.command(

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 remove this, Claude will one-shot this and you will want to manually check it anyway.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think this sort of CLI initialization utility perfectly fits into a library like here 🤔

However unless we make this a completely seamless end to end migration command, then I'm also against it. Right now as far as I can tell, it doesn't really migrate the ENV vars. I will open up a separate comment for that.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@ruocco-l
ruocco-l requested a review from metalwarrior665 July 8, 2026 09:46

@metalwarrior665 metalwarrior665 left a comment

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.

Thanks for another rewrite! I think we are almost done.

Comment thread bin/utils.ts Outdated
);
}

if (!contextPaths.some((contextPath) => isPathWithinScope(folder, contextPath))) {

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.

Do we need this? We carry over actorConfig.folder so we always know where our .actor lives. So we shouldn't need it inside the context paths

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 think of the context as "I need to track the files in these paths because that's what this actor care about". With this logic it looks correct to me that the .actor folder should always be listed/in the scope.

The middle ground for this would be to always forcefully add the folder path to the overridden context list, what do you think?

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 it automatically. I take as a special type of context that is always included but if you need it add explicitly, then for sure

Comment thread bin/diff-changes.ts Outdated

if (lowercaseFilePath.endsWith('changelog.md')) {
return { impact: 'cosmetic', semanticallyVerified: false, includes: 'all-actors' };
if (dockerIgnoreMatcher(originalFilePath)) {

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.

.actor can be legit inside .dockerignore as it is evaluated by Apify platform before Docker build. That can be used to make Dockerfile more cacheable if you don't want to COPY individual folders like we d now. So either we skip it from dockerIgnore or we handle it before/special path.

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.

Comment thread bin/diff-changes.ts Outdated
}
if (lowercaseFilePath.endsWith('readme.md')) {
return { impact: 'cosmetic', semanticallyVerified: false, includes: actorConfigChanged };
const lowerFolder = actorConfig.folder.toLowerCase();

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.

Suggested change
const lowerFolder = actorConfig.folder.toLowerCase();
const lowerCaseFolder = actorConfig.folder.toLowerCase();

Comment thread bin/diff-changes.ts Outdated
* 1. Context matching (actorConfig.contextPaths) → outside-context if no match
* 2. Hardcoded ignore list, checked against the path hoisted relative to the matched context entry → ignored
* 3. .dockerignore filtering (patterns relative to dockerContextDir) → ignored if matched
* 4. README/CHANGELOG by filename → cosmetic if inside the actor's own folder, otherwise ignored

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 Changelog change is wrong, in Store Actors, there is single shared changelog so it works differently from readme.

Instead of speculating what's the right setup, let's also do this more properly for all the Actor-only files. File is specific Actor related if:

  • Is inside Actor folder (inside .actor or next to it) - We already handle this
  • Is referenced from actor.json - if the file is outside of Actor folder but is referenced from actor.json, then it belongs to this Actor (and independenctly can belong to other Actor). So top-level Changelog.md will independently trigger change for every Actor (that points to it). We should parse this at the config file read. I think we can have a hardcoded list of files supported this way (readme, changelog, all schemas)

The ignored/cosmetic/functional logic depending on being test vs release and JSON file changes stays

EDIT: I'm dragging you through this PR a bit so feel free to just hotfix this with the old semantics and make TODO + issue for next iteration

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.

Comment thread bin/diff-changes.ts
const isInActorFolder = isPathWithinScope(lowercaseFilePath, lowerFolder);

if (lowercaseFilePath.endsWith('readme.md') || lowercaseFilePath.endsWith('changelog.md')) {
return isInActorFolder ? { impact: 'cosmetic', semanticallyVerified: false } : { impact: 'ignored' };

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.

This needs fixing as mentioned above

@metalwarrior665

Copy link
Copy Markdown
Member

Also pls update the PR title :)

Comment thread bin/dockerignore.ts Outdated
*
* Returns a no-op matcher (always returns false) when the file is absent.
*/
export const loadDockerIgnore = (dockerContextDir: string): DockerIgnoreMatcher => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I found this bug. loadDockerIgnore doesn't normalize a leading ./ (e.g. ./node_modules) before passing patterns to the ignore package, so any .dockerignore written in that common style (like ./node_modules, ./test) silently matches nothing :/

@ruocco-l ruocco-l Jul 20, 2026

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.

Addressed here 48b7847

afterEach(() => vi.restoreAllMocks());

describe('loadDockerIgnore', () => {
it('returns no-op matcher when .dockerignore is absent', () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

lets add a unit test for ./-prefixed pattern

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

also edge cases like trailing slash

@ruocco-l
ruocco-l force-pushed the feat/extend-support branch from 6ab8638 to 0483a8d Compare July 20, 2026 09:39
@ruocco-l ruocco-l changed the title feat!: use actor.json as source of truth to support single actor and broader monorepo structures feat!: replace folder naming convention for actor discovery with explicit config file Jul 20, 2026
@JuanGalilea

Copy link
Copy Markdown
Contributor

Then from actor.json, we read dockerContextDir and that contains all folders/files that can influence the changes for that Actor. We can allow config to have overrideActorContext if they want to e.g. narrow it down.

@metalwarrior665 what would be the usecase of overrideActorContext?
Shouldn't actor.json have it right? what does narrowing look like?
I cant imagine a usecase where we would want to build with root as a different directory than the one specified by actor.json

@ruocco-l

Copy link
Copy Markdown
Contributor Author

Shouldn't actor.json have it right? what does narrowing look like? I cant imagine a usecase where we would want to build with root as a different directory than the one specified by actor.json

actor.json can have a docker context that for multiple reasons could be too broad (e.g. you have only one dockerfile that works for all the miniactors) but for some miniactors may actually need only a subset of shared files.

actors/
├─ actor_1/
├─ actor_2/
├─ actor_3/
Dockerfile
shared/
├─ shared_1-2/
├─ shared_1-3/
├─ shared_1-2-3/

With this folder structure you will have to have the dockercontext to look at the whole shared folder, but with overrideActorContext you tell change-detection which subset of shared/ actually matters to actor_1 (even though Docker will still build the whole thing)

@metalwarrior665

Copy link
Copy Markdown
Member

what would be the usecase of overrideActorContext?
Shouldn't actor.json have it right? what does narrowing look like?
I cant imagine a usecase where we would want to build with root as a different directory than the one specified by actor.json

This is not about building but about deciding if a change in a file should rebuilt & retest a specific Actor. In e-commerce monorepo, most folders are totally unrelated even if they are in build context

@metalwarrior665 metalwarrior665 left a comment

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.

Great, I consider it done! Thanks for going through all.

Now the release will be a bit tricky. I published a beta apify-test-tools 0.9.0-beta.0. We cannot merge this because we automatically use latest and would break everyone. So until all repos migrate to this, we will have to keep rebasing this on top of master. But the migration should be simple.

Before starting the Store-wide migration, let's wait a few days if @Patai5 @JuanGalilea @oklinov will have some remarks.

I would not release this as 1.0. I would reserve that for the final public API that will be easy to use but any publisher and we will not break that for long time to come.

Comment thread bin/utils.ts Outdated
try {
actorName = await readActorName('./.actor/actor.json');
} catch {
return [];

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

what do you think about adding some error handling here (logging or throw)? looks like it may fail silently

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 guess you forgot to remove these comments from a month ago before approving :D Thank you for your review!

Comment thread bin/build.ts Outdated
actorName: `${circleActor.username}/${circleActor.name}`,
folder: actorConfigFound.folder,
isStandalone: actorConfigFound.isStandalone,
tokenEnvVar: 'APIFY_TOKEN_CIRC_LE',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

should it be APIFY_TOKEN_CIRC_LE but not APIFY_TOKEN_CIRCLE? 🤔

Comment thread bin/build.ts Outdated
actorName: `${circleActor.username}/${circleActor.name}`,
folder: actorConfigFound.folder,
isStandalone: actorConfigFound.isStandalone,
tokenEnvVar: 'APIFY_TOKEN_CIRC_LE',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

btw 'APIFY_TOKEN_CIRC_LE' looks like it could be a const instead of a hardcoded string

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support .actor folder as source to build actor and test

8 participants