apify-test-tools 0.10.1-beta.6 → 0.10.1-beta.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/bin/build-from-local.ts +19 -27
  2. package/bin/build.ts +1 -1
  3. package/bin/diff-changes.ts +26 -35
  4. package/bin/dockerignore.ts +24 -17
  5. package/bin/types.ts +7 -3
  6. package/bin/utils/config/actor-json.ts +49 -8
  7. package/bin/utils/config/load-config.ts +4 -24
  8. package/bin/utils/path/repo-relative.ts +27 -4
  9. package/dist/bin/build-from-local.d.ts +3 -2
  10. package/dist/bin/build-from-local.d.ts.map +1 -1
  11. package/dist/bin/build-from-local.js +16 -23
  12. package/dist/bin/build-from-local.js.map +1 -1
  13. package/dist/bin/build.js +1 -1
  14. package/dist/bin/build.js.map +1 -1
  15. package/dist/bin/diff-changes.d.ts.map +1 -1
  16. package/dist/bin/diff-changes.js +26 -29
  17. package/dist/bin/diff-changes.js.map +1 -1
  18. package/dist/bin/dockerignore.d.ts +11 -10
  19. package/dist/bin/dockerignore.d.ts.map +1 -1
  20. package/dist/bin/dockerignore.js +24 -15
  21. package/dist/bin/dockerignore.js.map +1 -1
  22. package/dist/bin/types.d.ts +6 -3
  23. package/dist/bin/types.d.ts.map +1 -1
  24. package/dist/bin/utils/config/actor-json.d.ts.map +1 -1
  25. package/dist/bin/utils/config/actor-json.js +35 -4
  26. package/dist/bin/utils/config/actor-json.js.map +1 -1
  27. package/dist/bin/utils/config/load-config.d.ts +1 -11
  28. package/dist/bin/utils/config/load-config.d.ts.map +1 -1
  29. package/dist/bin/utils/config/load-config.js +1 -10
  30. package/dist/bin/utils/config/load-config.js.map +1 -1
  31. package/dist/bin/utils/path/repo-relative.d.ts +4 -0
  32. package/dist/bin/utils/path/repo-relative.d.ts.map +1 -1
  33. package/dist/bin/utils/path/repo-relative.js +24 -5
  34. package/dist/bin/utils/path/repo-relative.js.map +1 -1
  35. package/dist/test/unit/bin/actor-filtering.test.js +8 -2
  36. package/dist/test/unit/bin/actor-filtering.test.js.map +1 -1
  37. package/dist/test/unit/bin/build-from-local.test.js +53 -5
  38. package/dist/test/unit/bin/build-from-local.test.js.map +1 -1
  39. package/dist/test/unit/bin/diff-changes.test.js +98 -132
  40. package/dist/test/unit/bin/diff-changes.test.js.map +1 -1
  41. package/dist/test/unit/bin/dockerignore.test.js +27 -24
  42. package/dist/test/unit/bin/dockerignore.test.js.map +1 -1
  43. package/dist/test/unit/bin/utils/config/load-config.test.js +40 -4
  44. package/dist/test/unit/bin/utils/config/load-config.test.js.map +1 -1
  45. package/dist/test/unit/bin/utils/path/repo-relative.test.js +5 -0
  46. package/dist/test/unit/bin/utils/path/repo-relative.test.js.map +1 -1
  47. package/dist/test/unit/should-built-and-test.test.js +55 -116
  48. package/dist/test/unit/should-built-and-test.test.js.map +1 -1
  49. package/dist/tsconfig.tsbuildinfo +1 -1
  50. package/package.json +1 -1
  51. package/test/unit/bin/actor-filtering.test.ts +8 -2
  52. package/test/unit/bin/build-from-local.test.ts +54 -8
  53. package/test/unit/bin/diff-changes.test.ts +133 -133
  54. package/test/unit/bin/dockerignore.test.ts +28 -28
  55. package/test/unit/bin/utils/config/load-config.test.ts +54 -4
  56. package/test/unit/bin/utils/path/repo-relative.test.ts +6 -0
  57. package/test/unit/should-built-and-test.test.ts +98 -121
@@ -7,12 +7,13 @@ import type * as ApifyClientTypes from 'apify-client';
7
7
  import { ActorSourceType } from 'apify-client';
8
8
 
9
9
  import { dryRunBuildData, LOCAL_SOURCE_VERSION_NUMBER, runAndSummarizeBuilds } from './build.js';
10
- import { buildDockerIgnoreMatcher } from './dockerignore.js';
10
+ import { loadDockerIgnore } from './dockerignore.js';
11
11
  import { logger } from './logger.js';
12
12
  import { isPathWithinScope } from './path-utils.js';
13
13
  import type { ActorConfig, BuildData } from './types.js';
14
14
  import type { SourceFile } from './utils.js';
15
15
  import { getGitignoredPaths, isOutsideDir, listRepoFilePaths, readSourceFile } from './utils.js';
16
+ import type { RelativeDir } from './utils/path/repo-relative.js';
16
17
 
17
18
  // JUST IN CASE. File patterns that commonly hold credentials — never ship these into a build, regardless
18
19
  // of sourceType or of whether the repo's .gitignore happens to list them. Everything else that should be
@@ -21,30 +22,27 @@ import { getGitignoredPaths, isOutsideDir, listRepoFilePaths, readSourceFile } f
21
22
  const SKIP_FILE_PATTERNS = [/^\.env(\..+)?$/, /\.pem$/, /\.key$/, /\.pfx$/, /\.p12$/];
22
23
  const isSecretFile = (fileName: string): boolean => SKIP_FILE_PATTERNS.some((pattern) => pattern.test(fileName));
23
24
 
24
- export const collectSourceFiles = async (actorName: string, actorDir: string): Promise<SourceFile[]> => {
25
+ export const collectSourceFiles = async (actorConfig: ActorConfig): Promise<SourceFile[]> => {
25
26
  const repoRoot = process.cwd();
26
- const absActorDir = path.resolve(actorDir);
27
+ const absActorDir = path.resolve(actorConfig.folder.path);
27
28
 
28
- // Read actor.json to check if this is a monorepo actor with an external dockerContextDir.
29
- // Monorepo actors point their dockerContextDir to a parent directory (e.g. "../../.."),
30
- // which means the Docker build context is the repo root, not the actor directory itself.
31
- const actorJsonPath = path.join(absActorDir, '.actor', 'actor.json');
32
- const actorJson = JSON.parse(await fs.readFile(actorJsonPath, 'utf8')) as Record<string, unknown>;
33
- const rawContextDir = actorJson.dockerContextDir as string | undefined;
34
- const contextAbsDir = rawContextDir ? path.resolve(absActorDir, '.actor', rawContextDir) : undefined;
35
- const isMonorepoActor = !!contextAbsDir && isOutsideDir(contextAbsDir, absActorDir);
29
+ // Config loading has already resolved dockerContextDir. Keep the raw actor.json contents
30
+ // for rewriting its path fields if this actor needs a flattened build context.
31
+ const actorJson = JSON.parse(await fs.readFile(actorConfig.actorJson.file.path, 'utf8')) as Record<string, unknown>;
32
+ const isMonorepoActor = !actorConfig.folder.contains(actorConfig.dockerContextDir);
36
33
 
37
- const dockerContextDirAbs = isMonorepoActor ? contextAbsDir! : absActorDir;
38
- const keptFilePaths = await collectNonIgnoredFiles(dockerContextDirAbs, repoRoot);
34
+ const sourceRoot = isMonorepoActor ? actorConfig.dockerContextDir : actorConfig.folder;
35
+ const sourceRootAbs = path.resolve(sourceRoot.path);
36
+ const keptFilePaths = await collectNonIgnoredFiles(sourceRoot, repoRoot);
39
37
 
40
38
  if (!isMonorepoActor) {
41
- return Promise.all(keptFilePaths.map(async (filePath) => readSourceFile(filePath, dockerContextDirAbs)));
39
+ return Promise.all(keptFilePaths.map(async (filePath) => readSourceFile(filePath, sourceRootAbs)));
42
40
  }
43
41
 
44
42
  const { tempDir, filePaths } = await flattenMonorepoContext(
45
- actorName,
43
+ actorConfig.actorFullName,
46
44
  absActorDir,
47
- contextAbsDir!,
45
+ sourceRootAbs,
48
46
  actorJson,
49
47
  keptFilePaths,
50
48
  );
@@ -65,16 +63,10 @@ export const collectSourceFiles = async (actorName: string, actorDir: string): P
65
63
  // regardless of .gitignore/.dockerignore, matching Apify CLI's own behavior. Files matching the
66
64
  // hardcoded secret-pattern backstop (keys, certs, .env variants) are dropped unconditionally,
67
65
  // .actor/ included, since those should never ship regardless of what the ignore files say.
68
- export const collectNonIgnoredFiles = async (dockerContextDir: string, repoRoot: string): Promise<string[]> => {
69
- const relativePaths = await listRepoFilePaths(repoRoot, dockerContextDir);
66
+ export const collectNonIgnoredFiles = async (dockerContextDir: RelativeDir, repoRoot: string): Promise<string[]> => {
67
+ const relativePaths = await listRepoFilePaths(repoRoot, path.resolve(repoRoot, dockerContextDir.path));
70
68
  const ignoredPaths = await getGitignoredPaths(relativePaths, repoRoot);
71
- const rootRelativePaths = new Map(
72
- relativePaths.map((relPath) => [
73
- relPath,
74
- path.relative(dockerContextDir, path.join(repoRoot, relPath)).split(path.sep).join('/'),
75
- ]),
76
- );
77
- const isDockerIgnored = buildDockerIgnoreMatcher(dockerContextDir);
69
+ const isDockerIgnored = loadDockerIgnore(dockerContextDir);
78
70
 
79
71
  return relativePaths
80
72
  .filter((relPath) => {
@@ -82,7 +74,7 @@ export const collectNonIgnoredFiles = async (dockerContextDir: string, repoRoot:
82
74
  const isUnderActorDir = relPath.split('/').includes('.actor');
83
75
  if (isUnderActorDir) return true;
84
76
  if (ignoredPaths.has(relPath)) return false;
85
- return !isDockerIgnored(rootRelativePaths.get(relPath)!);
77
+ return !isDockerIgnored(relPath);
86
78
  })
87
79
  .map((relPath) => path.join(repoRoot, relPath));
88
80
  };
@@ -205,7 +197,7 @@ export const runBuildsFromLocal = async ({
205
197
  return runAndSummarizeBuilds(actorConfigs, 'LOCAL BUILDS', async (actorConfig, builder) => {
206
198
  // Zipped and uploaded like `apify push` does, since inline SOURCE_FILES are capped at ~9 MB.
207
199
  const zip = new AdmZip();
208
- for (const { name, content } of await collectSourceFiles(actorConfig.actorFullName, actorConfig.folder)) {
200
+ for (const { name, content } of await collectSourceFiles(actorConfig)) {
209
201
  zip.addFile(name, content);
210
202
  }
211
203
  const actorVersion: ApifyClientTypes.ActorVersion = {
package/bin/build.ts CHANGED
@@ -352,7 +352,7 @@ export const runBuilds = async ({
352
352
 
353
353
  // Depending on if these are miniactors or standaloneActors
354
354
  let gitRepoUrl = `${repoUrl}#${branch}`;
355
- if (actorConfig.folder) {
355
+ if (!actorConfig.folder.isRoot()) {
356
356
  gitRepoUrl = `${gitRepoUrl}:${actorConfig.folder}`;
357
357
  }
358
358
  return {
@@ -1,8 +1,8 @@
1
1
  import { isCosmeticOnlyJsonSchemaChange } from './diff-json-schema.js';
2
2
  import { type DockerIgnoreMatcher, loadDockerIgnore } from './dockerignore.js';
3
3
  import { logger } from './logger.js';
4
- import { findContainingScope, hoistPath, isPathWithinScope } from './path-utils.js';
5
4
  import type { ActorConfig, Commit } from './types.js';
5
+ import { type RelativeDir, RelativeFile } from './utils/path/repo-relative.js';
6
6
 
7
7
  interface ShouldBuildAndTestOptions {
8
8
  filepathsChanged: string[];
@@ -21,9 +21,10 @@ const IGNORED_TOP_LEVEL_FILES = [
21
21
  '.editorconfig',
22
22
  ];
23
23
 
24
- // Expects an already-hoisted path (relative to the matched context entry, see findContainingScope).
25
- const isIgnoredTopLevelFile = (hoistedLowercaseFilePath: string): boolean =>
26
- IGNORED_TOP_LEVEL_FILES.some((pattern) => hoistedLowercaseFilePath.startsWith(pattern));
24
+ const isIgnoredTopLevelFile = (filePath: RelativeFile, contextPath: RelativeDir): boolean => {
25
+ const pathWithinContext = contextPath.relativePathTo(filePath.path).toLowerCase();
26
+ return IGNORED_TOP_LEVEL_FILES.some((pattern) => pathWithinContext.startsWith(pattern));
27
+ };
27
28
 
28
29
  type FileChangeForActor =
29
30
  | { impact: 'ignored' }
@@ -46,12 +47,12 @@ type FileChangeForActor =
46
47
  * 7. Everything else → functional
47
48
  */
48
49
  const classifyFileChange = async (
49
- originalFilePath: string,
50
+ changedFile: RelativeFile,
50
51
  actorConfig: ActorConfig,
51
52
  commits: Commit[],
52
53
  dockerIgnoreMatcher: DockerIgnoreMatcher,
53
54
  ): Promise<FileChangeForActor> => {
54
- const lowercaseFilePath = originalFilePath.toLowerCase();
55
+ const lowercaseFilePath = changedFile.path.toLowerCase();
55
56
 
56
57
  // TODO: hardcodes that there's a single repo-wide changelog belonging to every actor. Should instead
57
58
  // be derived from parsing actor.json (readme, changelog, schema paths), see
@@ -60,56 +61,46 @@ const classifyFileChange = async (
60
61
  return { impact: 'cosmetic', semanticallyVerified: false };
61
62
  }
62
63
 
63
- const lowercaseContextPaths = actorConfig.contextPaths.map((contextPath) => contextPath.toLowerCase());
64
-
65
- const matchedContext = findContainingScope(lowercaseFilePath, lowercaseContextPaths);
66
- if (matchedContext === undefined) {
64
+ const matchedContext = actorConfig.contextPaths.find((contextPath) => contextPath.contains(changedFile));
65
+ if (!matchedContext) {
67
66
  return { impact: 'outside-context' };
68
67
  }
69
68
 
70
- const hoistedFilePath = hoistPath(lowercaseFilePath, matchedContext);
71
- if (isIgnoredTopLevelFile(hoistedFilePath)) {
69
+ if (isIgnoredTopLevelFile(changedFile, matchedContext)) {
72
70
  return { impact: 'ignored' };
73
71
  }
74
72
 
75
- const lowercaseFolder = actorConfig.folder.toLowerCase();
76
- const actorDotDir = lowercaseFolder ? `${lowercaseFolder}/.actor` : '.actor';
77
- const isUnderActorDotDir = isPathWithinScope(lowercaseFilePath, actorDotDir);
73
+ const isUnderActorDotDir = actorConfig.actorJson.file.parent.contains(changedFile);
78
74
 
79
75
  // .actor/ can legitimately be listed in .dockerignore (the Apify platform evaluates it before
80
76
  // the Docker build, so excluding it from the build context is a valid caching optimization) —
81
77
  // that shouldn't cause changes to .actor/ itself to be ignored here.
82
- if (!isUnderActorDotDir && dockerIgnoreMatcher(originalFilePath)) {
78
+ if (!isUnderActorDotDir && dockerIgnoreMatcher(changedFile.path)) {
83
79
  return { impact: 'ignored' };
84
80
  }
85
81
 
86
- const isInActorFolder = isPathWithinScope(lowercaseFilePath, lowercaseFolder);
82
+ const isInActorFolder = changedFile.isWithin(actorConfig.folder);
87
83
 
88
84
  if (lowercaseFilePath.endsWith('readme.md')) {
89
85
  return isInActorFolder ? { impact: 'cosmetic', semanticallyVerified: false } : { impact: 'ignored' };
90
86
  }
91
87
 
92
88
  if (lowercaseFilePath.endsWith('.json') && isUnderActorDotDir) {
93
- const isCosmetic = await isCosmeticOnlyJsonSchemaChange(commits, originalFilePath);
94
- if (isCosmetic) {
95
- return { impact: 'cosmetic', semanticallyVerified: true };
96
- }
89
+ const isCosmetic = await isCosmeticOnlyJsonSchemaChange(commits, changedFile.path);
90
+ if (isCosmetic) return { impact: 'cosmetic', semanticallyVerified: true };
97
91
  }
98
92
 
99
93
  return { impact: 'functional' };
100
94
  };
101
95
 
102
96
  /**
103
- * Check if a file falls inside another actor's folder.
104
- * Root actors (folder === "") never exclude files from siblings.
97
+ * Exclude files inside another actor's non-root folder, including for an actor at the repo root.
105
98
  */
106
- const isExcludedBySibling = (lowercaseFilePath: string, actor: ActorConfig, allActors: ActorConfig[]): boolean => {
107
- return allActors.some(
108
- (other) =>
109
- other.folder !== actor.folder &&
110
- other.folder !== '' &&
111
- isPathWithinScope(lowercaseFilePath, other.folder.toLowerCase()),
112
- );
99
+ const isExcludedBySibling = (filePath: RelativeFile, actor: ActorConfig, allActors: ActorConfig[]): boolean => {
100
+ const isSibling = (other: ActorConfig) => !other.folder.isEqualTo(actor.folder);
101
+ const isNonRoot = (other: ActorConfig) => !other.folder.isRoot();
102
+ const nonRootSiblings = allActors.filter(isSibling).filter(isNonRoot);
103
+ return nonRootSiblings.some((other) => filePath.isWithin(other.folder));
113
104
  };
114
105
 
115
106
  type ActorChangeEntry = {
@@ -179,20 +170,20 @@ export const getChangedActors = async ({
179
170
  const dockerIgnoreMatcher = loadDockerIgnore(actorConfig.dockerContextDir);
180
171
 
181
172
  for (const originalFilePath of filepathsChanged) {
182
- const lowercaseFilePath = originalFilePath.toLowerCase();
173
+ const filePath = new RelativeFile(originalFilePath);
183
174
 
184
- if (isExcludedBySibling(lowercaseFilePath, actorConfig, actorConfigs)) {
175
+ if (isExcludedBySibling(filePath, actorConfig, actorConfigs)) {
185
176
  continue;
186
177
  }
187
178
 
188
- const change = await classifyFileChange(originalFilePath, actorConfig, commits, dockerIgnoreMatcher);
179
+ const change = await classifyFileChange(filePath, actorConfig, commits, dockerIgnoreMatcher);
189
180
 
190
181
  if (change.impact === 'ignored' || change.impact === 'outside-context') continue;
191
182
  if (change.impact === 'cosmetic' && !isLatest) continue;
192
183
 
193
- const entry = actorsChangedMap.get(actorConfig.folder) ?? { actorConfig, files: [] };
184
+ const entry = actorsChangedMap.get(actorConfig.folder.path) ?? { actorConfig, files: [] };
194
185
  entry.files.push(originalFilePath);
195
- actorsChangedMap.set(actorConfig.folder, entry);
186
+ actorsChangedMap.set(actorConfig.folder.path, entry);
196
187
  }
197
188
  }
198
189
 
@@ -1,9 +1,9 @@
1
1
  import fs from 'node:fs';
2
- import path from 'node:path';
3
2
 
4
3
  import ignore from 'ignore';
5
4
 
6
- import { hoistPath, isPathWithinScope } from './path-utils.js';
5
+ import { logger } from './logger.js';
6
+ import type { RelativeDir, RelativeFile } from './utils/path/repo-relative.js';
7
7
 
8
8
  export type DockerIgnoreMatcher = (repoRelativePath: string) => boolean;
9
9
 
@@ -15,36 +15,43 @@ export type DockerIgnoreMatcher = (repoRelativePath: string) => boolean;
15
15
  const normalizeDockerignorePattern = (line: string): string => line.replace(/^(!?)(?:\.\/)+/, '$1');
16
16
 
17
17
  /**
18
- * Reads `.dockerignore` from `absoluteRootDir` and returns a matcher for paths relative to
19
- * `hoistFrom`. `hoistFrom` defaults to '', meaning callers already pass paths relative to
20
- * `absoluteRootDir` directly — isPathWithinScope/hoistPath both treat '' as "matches everything" /
21
- * identity, so the scope-check and hoist collapse to a no-op in that case, not via a branch.
18
+ * Reads a `.dockerignore` file and returns a matcher for repo-relative paths within its parent
19
+ * directory. Paths outside that directory never match. Matching is performed on paths relative to
20
+ * the `.dockerignore` file's parent, and leading `./` prefixes are removed from patterns to match
21
+ * Docker's normalization behavior.
22
22
  *
23
- * Returns a no-op matcher (always returns false) when the file is absent.
23
+ * Returns a matcher that always returns `false` if the file cannot be read.
24
24
  */
25
- export const buildDockerIgnoreMatcher = (absoluteRootDir: string, hoistFrom = ''): DockerIgnoreMatcher => {
25
+ export const buildDockerIgnoreMatcher = (dockerIgnore: RelativeFile): DockerIgnoreMatcher => {
26
26
  let content: string;
27
27
  try {
28
- content = fs.readFileSync(path.join(absoluteRootDir, '.dockerignore'), 'utf-8');
28
+ content = fs.readFileSync(dockerIgnore.path, 'utf-8');
29
29
  } catch {
30
30
  return () => false;
31
31
  }
32
32
 
33
33
  const matcher = ignore().add(content.split('\n').map(normalizeDockerignorePattern).join('\n'));
34
-
34
+ const rootDir = dockerIgnore.parent;
35
35
  return (filePath: string): boolean => {
36
- if (!isPathWithinScope(filePath.toLowerCase(), hoistFrom.toLowerCase())) {
36
+ if (!rootDir.containsPath(filePath)) {
37
37
  return false;
38
38
  }
39
39
 
40
- return matcher.ignores(hoistPath(filePath, hoistFrom));
40
+ return matcher.ignores(rootDir.relativePathTo(filePath));
41
41
  };
42
42
  };
43
43
 
44
44
  /**
45
- * Load .dockerignore from the root of an actor's dockerContextDir and return a matcher
46
- * that accepts repo-root-relative file paths. Patterns are resolved relative to
47
- * dockerContextDir, matching Docker's own behavior.
45
+ * Loads `.dockerignore` from the root of `dockerContextDir` and returns a matcher that accepts
46
+ * repo-relative file paths. Patterns are matched relative to `dockerContextDir`, consistent with
47
+ * Docker's build-context behavior. If the file cannot be read, the matcher always returns `false`.
48
48
  */
49
- export const loadDockerIgnore = (dockerContextDir: string): DockerIgnoreMatcher =>
50
- buildDockerIgnoreMatcher(path.resolve(dockerContextDir), dockerContextDir);
49
+ export const loadDockerIgnore = (dockerContextDir: RelativeDir): DockerIgnoreMatcher => {
50
+ try {
51
+ const dockerIgnorePath = dockerContextDir.joinFile('.dockerignore');
52
+ return buildDockerIgnoreMatcher(dockerIgnorePath);
53
+ } catch {
54
+ logger.warn(`[.dockerignore] not found in "${dockerContextDir}".`);
55
+ return () => false;
56
+ }
57
+ };
package/bin/types.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import type { ActorJsonPaths } from './utils/config/actor-json.js';
2
+ import type { ExistingDir, RelativeDir } from './utils/path/repo-relative.js';
3
+
1
4
  export interface Config {
2
5
  targetBranch: string;
3
6
  sourceBranch: string;
@@ -22,8 +25,9 @@ export interface BuildData {
22
25
 
23
26
  export interface ActorConfig {
24
27
  actorFullName: string;
25
- folder: string;
28
+ folder: ExistingDir;
26
29
  tokenEnvVar: string;
27
- dockerContextDir: string;
28
- contextPaths: string[];
30
+ actorJson: ActorJsonPaths;
31
+ dockerContextDir: RelativeDir;
32
+ contextPaths: RelativeDir[];
29
33
  }
@@ -1,3 +1,6 @@
1
+ import { readdirSync } from 'node:fs';
2
+ import path from 'node:path';
3
+
1
4
  import z from 'zod';
2
5
 
3
6
  import { safeReadJsonObjectFile } from '../json-file.js';
@@ -38,6 +41,31 @@ const resolveFile = (actorJsonFile: ExistingFile, field: string, value: unknown)
38
41
  }
39
42
  };
40
43
 
44
+ /**
45
+ * Looks for fallback locations of config files in the provided order, matches insensitively.
46
+ *
47
+ * Pretty inefficient way to do it, but it was either this bs or adding a matching library just for the insensitive matching of fallback files.
48
+ * So if this ever becomes a performance problem for anyone, they can explicitly state their files in the config and live happily ever after.
49
+ */
50
+ const resolveDefaultFileLocations = (
51
+ actorJsonFile: ExistingFile,
52
+ fallbackPaths: readonly string[],
53
+ ): RelativeFile | undefined => {
54
+ for (const fallbackCandidate of fallbackPaths) {
55
+ const file = actorJsonFile.joinFile(fallbackCandidate);
56
+ const expectedName = path.posix.basename(file.path);
57
+ const entries = readdirSync(file.parent.path);
58
+ const actualName =
59
+ entries.find((entry) => entry === expectedName) ??
60
+ entries.find((entry) => entry.toLowerCase() === expectedName.toLowerCase());
61
+ if (actualName) {
62
+ const actualFile = file.parent.joinFile(actualName);
63
+ if (actualFile.exists()) return actualFile;
64
+ }
65
+ }
66
+ return undefined;
67
+ };
68
+
41
69
  export function readActorJson(config: ValidatedActorConfig): ActorJsonPaths {
42
70
  const actorJsonPath = config.folder.joinFile('.actor/actor.json');
43
71
  let actorJsonFile: ExistingFile;
@@ -66,14 +94,27 @@ export function readActorJson(config: ValidatedActorConfig): ActorJsonPaths {
66
94
  return {
67
95
  file: actorJsonFile,
68
96
  dockerContextDir,
69
- dockerfile: resolveFile(actorJsonFile, 'dockerfile', fields.dockerfile),
70
- readme: resolveFile(actorJsonFile, 'readme', fields.readme),
71
- changelog: resolveFile(actorJsonFile, 'changelog', fields.changelog),
72
- inputSchema: resolveFile(
73
- actorJsonFile,
74
- fields.input != null ? 'input' : 'inputSchema',
75
- fields.input ?? fields.inputSchema,
76
- ),
97
+ // all these with `resolveDefaultFileLocations` is because the platform has lookup locations as fallback
98
+ // if not present in actor.json, this is behavior from before .actor folder was a thing
99
+ // with this we replicate exactly what the platform does for resolution of these files
100
+ // so we resolve the same way the platform does during builds.
101
+ dockerfile:
102
+ resolveFile(actorJsonFile, 'dockerfile', fields.dockerfile) ??
103
+ resolveDefaultFileLocations(actorJsonFile, ['./Dockerfile', '../Dockerfile']),
104
+ readme:
105
+ resolveFile(actorJsonFile, 'readme', fields.readme) ??
106
+ resolveDefaultFileLocations(actorJsonFile, ['./README.md', '../README.md']),
107
+ changelog:
108
+ resolveFile(actorJsonFile, 'changelog', fields.changelog) ??
109
+ resolveDefaultFileLocations(actorJsonFile, ['./CHANGELOG.md', '../CHANGELOG.md']),
110
+ inputSchema:
111
+ resolveFile(
112
+ actorJsonFile,
113
+ fields.input != null ? 'input' : 'inputSchema',
114
+ fields.input ?? fields.inputSchema,
115
+ ) ?? resolveDefaultFileLocations(actorJsonFile, ['./INPUT_SCHEMA.json', '../INPUT_SCHEMA.json']),
116
+
117
+ // these other schemas came after .actor was invented, so they have no fallbacks in platform
77
118
  outputSchema: resolveFile(
78
119
  actorJsonFile,
79
120
  fields.output != null ? 'output' : 'outputSchema',
@@ -1,8 +1,8 @@
1
1
  import { selectActors } from '../../actor-filtering.js';
2
2
  import type { ActorConfig } from '../../types.js';
3
3
  import { safeReadJsonObjectFile } from '../json-file.js';
4
- import { type ExistingDir, ExistingFile, type RelativeDir } from '../path/repo-relative.js';
5
- import { type ActorJsonPaths, readActorJson } from './actor-json.js';
4
+ import { ExistingFile, type RelativeDir } from '../path/repo-relative.js';
5
+ import { readActorJson } from './actor-json.js';
6
6
  import { parseConfigFile } from './parser.js';
7
7
  import type { ValidatedActorConfig } from './structures/base.js';
8
8
 
@@ -28,18 +28,6 @@ const findOverlappingContextPaths = (contextPaths: RelativeDir[]): [RelativeDir,
28
28
  return undefined;
29
29
  };
30
30
 
31
- // Downstream ActorConfig consumers still use "" for the repo root.
32
- const legacyPath = (dir: RelativeDir): string => (dir.path === '.' ? '' : dir.path);
33
-
34
- export interface LoadedActorConfig {
35
- actorFullName: string;
36
- folder: ExistingDir;
37
- tokenEnvVar: string;
38
- actorJson: ActorJsonPaths;
39
- dockerContextDir: RelativeDir;
40
- contextPaths: RelativeDir[];
41
- }
42
-
43
31
  // #endregion
44
32
 
45
33
  // #region stages
@@ -79,7 +67,7 @@ const readConfigFileContents = async (): Promise<Record<string, unknown>> => {
79
67
  * Stage 3 — resolves one normalized entry against the repo, reading the actor's `.actor/actor.json`
80
68
  * for its `dockerContextDir` and deciding which paths count as the actor's context.
81
69
  */
82
- export const loadActorConfig = (entry: ValidatedActorConfig): LoadedActorConfig => {
70
+ export const loadActorConfig = (entry: ValidatedActorConfig): ActorConfig => {
83
71
  const { folder } = entry;
84
72
  const actorJson = readActorJson(entry);
85
73
  const { dockerContextDir } = actorJson;
@@ -109,14 +97,6 @@ export const loadActorConfig = (entry: ValidatedActorConfig): LoadedActorConfig
109
97
  };
110
98
  };
111
99
 
112
- const toLegacyActorConfig = (entry: LoadedActorConfig): ActorConfig => ({
113
- actorFullName: entry.actorFullName,
114
- folder: legacyPath(entry.folder),
115
- tokenEnvVar: entry.tokenEnvVar,
116
- dockerContextDir: legacyPath(entry.dockerContextDir),
117
- contextPaths: entry.contextPaths.map(legacyPath),
118
- });
119
-
120
100
  // #endregion
121
101
 
122
102
  export const readConfigFile = async (selection: { actors: string[]; ignore: string[] }): Promise<ActorConfig[]> => {
@@ -126,7 +106,7 @@ export const readConfigFile = async (selection: { actors: string[]; ignore: stri
126
106
  const actorConfigs: ActorConfig[] = [];
127
107
  for (const parsed of parsedConfigs) {
128
108
  // Sequential on purpose: the first actor with a problem should be the one reported.
129
- actorConfigs.push(toLegacyActorConfig(loadActorConfig(parsed)));
109
+ actorConfigs.push(loadActorConfig(parsed));
130
110
  }
131
111
 
132
112
  return selectActors(selection, actorConfigs);
@@ -55,6 +55,10 @@ export abstract class AbstractPath {
55
55
  export class RelativeDir extends AbstractPath {
56
56
  static readonly ROOT = new RelativeDir('.');
57
57
 
58
+ isRoot(): boolean {
59
+ return this.path === RelativeDir.ROOT.path;
60
+ }
61
+
58
62
  joinDir(relativePath: string): RelativeDir {
59
63
  return new RelativeDir(this.join(relativePath));
60
64
  }
@@ -65,10 +69,24 @@ export class RelativeDir extends AbstractPath {
65
69
  return new RelativeFile(this.join(relativePath));
66
70
  }
67
71
 
72
+ relativePathTo(filePath: string): string {
73
+ // mainly for matching against .dockerignore since paths consumed by it are relative to the docker context dir
74
+ return posix.relative(this.path, filePath);
75
+ }
76
+
77
+ containsPath(filePath: string): boolean {
78
+ try {
79
+ // should be OK even if not a directory (e.g. a file)
80
+ return this.contains(new RelativeDir(filePath));
81
+ } catch {
82
+ return false;
83
+ }
84
+ }
85
+
68
86
  // True when `other` is this directory itself or lies inside it.
69
87
  contains(other: AbstractPath): boolean {
70
88
  // the root contains every path
71
- if (this.path === '.') return true;
89
+ if (this.isRoot()) return true;
72
90
  return this.isEqualTo(other) || other.path.startsWith(`${this.path}/`);
73
91
  }
74
92
 
@@ -133,14 +151,19 @@ export class RelativeFile extends AbstractPath {
133
151
  return scope.contains(this);
134
152
  }
135
153
 
154
+ exists(): boolean {
155
+ // `stat`, not `lstat`: a symlink must resolve to its target, matching what reading it does.
156
+ return statSync(this.path, { throwIfNoEntry: false })?.isFile() ?? false;
157
+ }
158
+
136
159
  // Throws unless this path exists and is a file. Paths resolve against the process working
137
160
  // directory, which is the repo root.
138
161
  assertIsFile(): this {
139
- // `stat`, not `lstat`: a symlink must resolve to its target, matching what reading it does.
162
+ if (this.exists()) return this;
163
+
140
164
  const stats = statSync(this.path, { throwIfNoEntry: false });
141
165
  if (!stats) throw new Error(`Expected file "${this}" to exist.`);
142
- if (!stats.isFile()) throw new Error(`Expected "${this}" to be a file.`);
143
- return this;
166
+ throw new Error(`Expected "${this}" to be a file.`);
144
167
  }
145
168
  }
146
169
 
@@ -1,7 +1,8 @@
1
1
  import type { ActorConfig, BuildData } from './types.js';
2
2
  import type { SourceFile } from './utils.js';
3
- export declare const collectSourceFiles: (actorName: string, actorDir: string) => Promise<SourceFile[]>;
4
- export declare const collectNonIgnoredFiles: (dockerContextDir: string, repoRoot: string) => Promise<string[]>;
3
+ import type { RelativeDir } from './utils/path/repo-relative.js';
4
+ export declare const collectSourceFiles: (actorConfig: ActorConfig) => Promise<SourceFile[]>;
5
+ export declare const collectNonIgnoredFiles: (dockerContextDir: RelativeDir, repoRoot: string) => Promise<string[]>;
5
6
  export declare const flattenMonorepoContext: (actorName: string, absActorDir: string, contextAbsDir: string, actorJson: Record<string, unknown>, keptContextFiles: string[]) => Promise<{
6
7
  tempDir: string;
7
8
  filePaths: string[];
@@ -1 +1 @@
1
- {"version":3,"file":"build-from-local.d.ts","sourceRoot":"","sources":["../../bin/build-from-local.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AACzD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAU7C,eAAO,MAAM,kBAAkB,GAAU,WAAW,MAAM,EAAE,UAAU,MAAM,KAAG,OAAO,CAAC,UAAU,EAAE,CAiClG,CAAC;AAWF,eAAO,MAAM,sBAAsB,GAAU,kBAAkB,MAAM,EAAE,UAAU,MAAM,KAAG,OAAO,CAAC,MAAM,EAAE,CAoBzG,CAAC;AAaF,eAAO,MAAM,sBAAsB,GAC/B,WAAW,MAAM,EACjB,aAAa,MAAM,EACnB,eAAe,MAAM,EACrB,WAAW,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAClC,kBAAkB,MAAM,EAAE,KAC3B,OAAO,CAAC;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,EAAE,CAAA;CAAE,CAyClD,CAAC;AAcF,eAAO,MAAM,qBAAqB,GAC9B,aAAa,MAAM,EACnB,eAAe,MAAM,EACrB,SAAS,MAAM,EACf,WAAW,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KACnC,OAAO,CAAC,IAAI,CAqBd,CAAC;AAEF,eAAO,MAAM,kBAAkB,GAAU,2BAGtC;IACC,YAAY,EAAE,WAAW,EAAE,CAAC;IAC5B,MAAM,EAAE,OAAO,CAAC;CACnB,KAAG,OAAO,CAAC,SAAS,EAAE,CAsBtB,CAAC"}
1
+ {"version":3,"file":"build-from-local.d.ts","sourceRoot":"","sources":["../../bin/build-from-local.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AACzD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAE7C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,+BAA+B,CAAC;AASjE,eAAO,MAAM,kBAAkB,GAAU,aAAa,WAAW,KAAG,OAAO,CAAC,UAAU,EAAE,CA8BvF,CAAC;AAWF,eAAO,MAAM,sBAAsB,GAAU,kBAAkB,WAAW,EAAE,UAAU,MAAM,KAAG,OAAO,CAAC,MAAM,EAAE,CAc9G,CAAC;AAaF,eAAO,MAAM,sBAAsB,GAC/B,WAAW,MAAM,EACjB,aAAa,MAAM,EACnB,eAAe,MAAM,EACrB,WAAW,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAClC,kBAAkB,MAAM,EAAE,KAC3B,OAAO,CAAC;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,EAAE,CAAA;CAAE,CAyClD,CAAC;AAcF,eAAO,MAAM,qBAAqB,GAC9B,aAAa,MAAM,EACnB,eAAe,MAAM,EACrB,SAAS,MAAM,EACf,WAAW,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KACnC,OAAO,CAAC,IAAI,CAqBd,CAAC;AAEF,eAAO,MAAM,kBAAkB,GAAU,2BAGtC;IACC,YAAY,EAAE,WAAW,EAAE,CAAC;IAC5B,MAAM,EAAE,OAAO,CAAC;CACnB,KAAG,OAAO,CAAC,SAAS,EAAE,CAsBtB,CAAC"}
@@ -4,7 +4,7 @@ import path from 'node:path';
4
4
  import AdmZip from 'adm-zip';
5
5
  import { ActorSourceType } from 'apify-client';
6
6
  import { dryRunBuildData, LOCAL_SOURCE_VERSION_NUMBER, runAndSummarizeBuilds } from './build.js';
7
- import { buildDockerIgnoreMatcher } from './dockerignore.js';
7
+ import { loadDockerIgnore } from './dockerignore.js';
8
8
  import { logger } from './logger.js';
9
9
  import { isPathWithinScope } from './path-utils.js';
10
10
  import { getGitignoredPaths, isOutsideDir, listRepoFilePaths, readSourceFile } from './utils.js';
@@ -14,23 +14,20 @@ import { getGitignoredPaths, isOutsideDir, listRepoFilePaths, readSourceFile } f
14
14
  // in the repo's .gitignore — see collectNonIgnoredFiles.
15
15
  const SKIP_FILE_PATTERNS = [/^\.env(\..+)?$/, /\.pem$/, /\.key$/, /\.pfx$/, /\.p12$/];
16
16
  const isSecretFile = (fileName) => SKIP_FILE_PATTERNS.some((pattern) => pattern.test(fileName));
17
- export const collectSourceFiles = async (actorName, actorDir) => {
17
+ export const collectSourceFiles = async (actorConfig) => {
18
18
  const repoRoot = process.cwd();
19
- const absActorDir = path.resolve(actorDir);
20
- // Read actor.json to check if this is a monorepo actor with an external dockerContextDir.
21
- // Monorepo actors point their dockerContextDir to a parent directory (e.g. "../../.."),
22
- // which means the Docker build context is the repo root, not the actor directory itself.
23
- const actorJsonPath = path.join(absActorDir, '.actor', 'actor.json');
24
- const actorJson = JSON.parse(await fs.readFile(actorJsonPath, 'utf8'));
25
- const rawContextDir = actorJson.dockerContextDir;
26
- const contextAbsDir = rawContextDir ? path.resolve(absActorDir, '.actor', rawContextDir) : undefined;
27
- const isMonorepoActor = !!contextAbsDir && isOutsideDir(contextAbsDir, absActorDir);
28
- const dockerContextDirAbs = isMonorepoActor ? contextAbsDir : absActorDir;
29
- const keptFilePaths = await collectNonIgnoredFiles(dockerContextDirAbs, repoRoot);
19
+ const absActorDir = path.resolve(actorConfig.folder.path);
20
+ // Config loading has already resolved dockerContextDir. Keep the raw actor.json contents
21
+ // for rewriting its path fields if this actor needs a flattened build context.
22
+ const actorJson = JSON.parse(await fs.readFile(actorConfig.actorJson.file.path, 'utf8'));
23
+ const isMonorepoActor = !actorConfig.folder.contains(actorConfig.dockerContextDir);
24
+ const sourceRoot = isMonorepoActor ? actorConfig.dockerContextDir : actorConfig.folder;
25
+ const sourceRootAbs = path.resolve(sourceRoot.path);
26
+ const keptFilePaths = await collectNonIgnoredFiles(sourceRoot, repoRoot);
30
27
  if (!isMonorepoActor) {
31
- return Promise.all(keptFilePaths.map(async (filePath) => readSourceFile(filePath, dockerContextDirAbs)));
28
+ return Promise.all(keptFilePaths.map(async (filePath) => readSourceFile(filePath, sourceRootAbs)));
32
29
  }
33
- const { tempDir, filePaths } = await flattenMonorepoContext(actorName, absActorDir, contextAbsDir, actorJson, keptFilePaths);
30
+ const { tempDir, filePaths } = await flattenMonorepoContext(actorConfig.actorFullName, absActorDir, sourceRootAbs, actorJson, keptFilePaths);
34
31
  try {
35
32
  return await Promise.all(filePaths.map(async (filePath) => readSourceFile(filePath, tempDir)));
36
33
  }
@@ -49,13 +46,9 @@ export const collectSourceFiles = async (actorName, actorDir) => {
49
46
  // hardcoded secret-pattern backstop (keys, certs, .env variants) are dropped unconditionally,
50
47
  // .actor/ included, since those should never ship regardless of what the ignore files say.
51
48
  export const collectNonIgnoredFiles = async (dockerContextDir, repoRoot) => {
52
- const relativePaths = await listRepoFilePaths(repoRoot, dockerContextDir);
49
+ const relativePaths = await listRepoFilePaths(repoRoot, path.resolve(repoRoot, dockerContextDir.path));
53
50
  const ignoredPaths = await getGitignoredPaths(relativePaths, repoRoot);
54
- const rootRelativePaths = new Map(relativePaths.map((relPath) => [
55
- relPath,
56
- path.relative(dockerContextDir, path.join(repoRoot, relPath)).split(path.sep).join('/'),
57
- ]));
58
- const isDockerIgnored = buildDockerIgnoreMatcher(dockerContextDir);
51
+ const isDockerIgnored = loadDockerIgnore(dockerContextDir);
59
52
  return relativePaths
60
53
  .filter((relPath) => {
61
54
  if (isSecretFile(path.basename(relPath)))
@@ -65,7 +58,7 @@ export const collectNonIgnoredFiles = async (dockerContextDir, repoRoot) => {
65
58
  return true;
66
59
  if (ignoredPaths.has(relPath))
67
60
  return false;
68
- return !isDockerIgnored(rootRelativePaths.get(relPath));
61
+ return !isDockerIgnored(relPath);
69
62
  })
70
63
  .map((relPath) => path.join(repoRoot, relPath));
71
64
  };
@@ -157,7 +150,7 @@ export const runBuildsFromLocal = async ({ actorConfigs, dryRun, }) => {
157
150
  return runAndSummarizeBuilds(actorConfigs, 'LOCAL BUILDS', async (actorConfig, builder) => {
158
151
  // Zipped and uploaded like `apify push` does, since inline SOURCE_FILES are capped at ~9 MB.
159
152
  const zip = new AdmZip();
160
- for (const { name, content } of await collectSourceFiles(actorConfig.actorFullName, actorConfig.folder)) {
153
+ for (const { name, content } of await collectSourceFiles(actorConfig)) {
161
154
  zip.addFile(name, content);
162
155
  }
163
156
  const actorVersion = {