apify-test-tools 0.10.1-beta.3 → 0.10.1-beta.4

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 (140) hide show
  1. package/.github/workflows/public_platform-tests-claude-investigate-and-fix.yaml +7 -2
  2. package/CHANGELOG.md +6 -0
  3. package/CONTRIBUTING.md +9 -0
  4. package/bin/build-from-local.ts +8 -7
  5. package/bin/build.ts +22 -21
  6. package/bin/diff-changes.ts +11 -10
  7. package/bin/diff-json-schema.ts +14 -12
  8. package/bin/git.ts +90 -43
  9. package/bin/logger.ts +23 -0
  10. package/bin/main.ts +49 -35
  11. package/bin/output.ts +6 -0
  12. package/bin/slack.ts +8 -7
  13. package/bin/test-report.ts +22 -21
  14. package/bin/utils/config/actor-json.ts +84 -0
  15. package/bin/utils/config/load-config.ts +46 -46
  16. package/bin/utils/config/parser.ts +33 -28
  17. package/bin/utils/config/structures/base.ts +7 -0
  18. package/bin/utils/json-file.ts +11 -14
  19. package/bin/utils/path/repo-relative.ts +156 -0
  20. package/bin/utils/path/schema.ts +18 -0
  21. package/bin/utils.ts +61 -34
  22. package/dist/bin/build-from-local.d.ts +1 -1
  23. package/dist/bin/build-from-local.d.ts.map +1 -1
  24. package/dist/bin/build-from-local.js +8 -7
  25. package/dist/bin/build-from-local.js.map +1 -1
  26. package/dist/bin/build.d.ts.map +1 -1
  27. package/dist/bin/build.js +22 -21
  28. package/dist/bin/build.js.map +1 -1
  29. package/dist/bin/diff-changes.d.ts +1 -1
  30. package/dist/bin/diff-changes.d.ts.map +1 -1
  31. package/dist/bin/diff-changes.js +9 -8
  32. package/dist/bin/diff-changes.js.map +1 -1
  33. package/dist/bin/diff-json-schema.d.ts +1 -1
  34. package/dist/bin/diff-json-schema.d.ts.map +1 -1
  35. package/dist/bin/diff-json-schema.js +13 -12
  36. package/dist/bin/diff-json-schema.js.map +1 -1
  37. package/dist/bin/git.d.ts +10 -10
  38. package/dist/bin/git.d.ts.map +1 -1
  39. package/dist/bin/git.js +78 -37
  40. package/dist/bin/git.js.map +1 -1
  41. package/dist/bin/logger.d.ts +11 -0
  42. package/dist/bin/logger.d.ts.map +1 -0
  43. package/dist/bin/logger.js +20 -0
  44. package/dist/bin/logger.js.map +1 -0
  45. package/dist/bin/main.d.ts +1 -1
  46. package/dist/bin/main.js +48 -35
  47. package/dist/bin/main.js.map +1 -1
  48. package/dist/bin/output.d.ts +3 -0
  49. package/dist/bin/output.d.ts.map +1 -0
  50. package/dist/bin/output.js +6 -0
  51. package/dist/bin/output.js.map +1 -0
  52. package/dist/bin/slack.d.ts.map +1 -1
  53. package/dist/bin/slack.js +8 -7
  54. package/dist/bin/slack.js.map +1 -1
  55. package/dist/bin/test-report.d.ts.map +1 -1
  56. package/dist/bin/test-report.js +22 -21
  57. package/dist/bin/test-report.js.map +1 -1
  58. package/dist/bin/utils/config/actor-json.d.ts +14 -0
  59. package/dist/bin/utils/config/actor-json.d.ts.map +1 -0
  60. package/dist/bin/utils/config/actor-json.js +60 -0
  61. package/dist/bin/utils/config/actor-json.js.map +1 -0
  62. package/dist/bin/utils/config/load-config.d.ts +12 -2
  63. package/dist/bin/utils/config/load-config.d.ts.map +1 -1
  64. package/dist/bin/utils/config/load-config.js +32 -31
  65. package/dist/bin/utils/config/load-config.js.map +1 -1
  66. package/dist/bin/utils/config/parser.d.ts +4 -12
  67. package/dist/bin/utils/config/parser.d.ts.map +1 -1
  68. package/dist/bin/utils/config/parser.js +24 -23
  69. package/dist/bin/utils/config/parser.js.map +1 -1
  70. package/dist/bin/utils/config/structures/base.d.ts +5 -0
  71. package/dist/bin/utils/config/structures/base.d.ts.map +1 -1
  72. package/dist/bin/utils/config/structures/base.js.map +1 -1
  73. package/dist/bin/utils/json-file.d.ts +3 -2
  74. package/dist/bin/utils/json-file.d.ts.map +1 -1
  75. package/dist/bin/utils/json-file.js +8 -13
  76. package/dist/bin/utils/json-file.js.map +1 -1
  77. package/dist/bin/utils/path/repo-relative.d.ts +35 -0
  78. package/dist/bin/utils/path/repo-relative.d.ts.map +1 -0
  79. package/dist/bin/utils/path/repo-relative.js +137 -0
  80. package/dist/bin/utils/path/repo-relative.js.map +1 -0
  81. package/dist/bin/utils/path/schema.d.ts +7 -0
  82. package/dist/bin/utils/path/schema.d.ts.map +1 -0
  83. package/dist/bin/utils/path/schema.js +16 -0
  84. package/dist/bin/utils/path/schema.js.map +1 -0
  85. package/dist/bin/utils.d.ts +14 -3
  86. package/dist/bin/utils.d.ts.map +1 -1
  87. package/dist/bin/utils.js +52 -29
  88. package/dist/bin/utils.js.map +1 -1
  89. package/dist/test/unit/bin/build-from-local.test.js +3 -39
  90. package/dist/test/unit/bin/build-from-local.test.js.map +1 -1
  91. package/dist/test/unit/bin/diff-changes.test.js +97 -96
  92. package/dist/test/unit/bin/diff-changes.test.js.map +1 -1
  93. package/dist/test/unit/bin/diff.test.js +52 -48
  94. package/dist/test/unit/bin/diff.test.js.map +1 -1
  95. package/dist/test/unit/bin/git-command.test.d.ts +2 -0
  96. package/dist/test/unit/bin/git-command.test.d.ts.map +1 -0
  97. package/dist/test/unit/bin/git-command.test.js +54 -0
  98. package/dist/test/unit/bin/git-command.test.js.map +1 -0
  99. package/dist/test/unit/bin/git.test.js +211 -92
  100. package/dist/test/unit/bin/git.test.js.map +1 -1
  101. package/dist/test/unit/bin/logger.test.d.ts +2 -0
  102. package/dist/test/unit/bin/logger.test.d.ts.map +1 -0
  103. package/dist/test/unit/bin/logger.test.js +44 -0
  104. package/dist/test/unit/bin/logger.test.js.map +1 -0
  105. package/dist/test/unit/bin/logging.test.d.ts +2 -0
  106. package/dist/test/unit/bin/logging.test.d.ts.map +1 -0
  107. package/dist/test/unit/bin/logging.test.js +43 -0
  108. package/dist/test/unit/bin/logging.test.js.map +1 -0
  109. package/dist/test/unit/bin/utils/config/load-config.test.js +47 -25
  110. package/dist/test/unit/bin/utils/config/load-config.test.js.map +1 -1
  111. package/dist/test/unit/bin/utils/config/parser.test.js +64 -19
  112. package/dist/test/unit/bin/utils/config/parser.test.js.map +1 -1
  113. package/dist/test/unit/bin/utils/files.test.js +16 -27
  114. package/dist/test/unit/bin/utils/files.test.js.map +1 -1
  115. package/dist/test/unit/bin/utils/path/repo-relative.test.d.ts +2 -0
  116. package/dist/test/unit/bin/utils/path/repo-relative.test.d.ts.map +1 -0
  117. package/dist/test/unit/bin/utils/path/repo-relative.test.js +196 -0
  118. package/dist/test/unit/bin/utils/path/repo-relative.test.js.map +1 -0
  119. package/dist/test/unit/bin/utils/path/schema.test.d.ts +2 -0
  120. package/dist/test/unit/bin/utils/path/schema.test.d.ts.map +1 -0
  121. package/dist/test/unit/bin/utils/path/schema.test.js +52 -0
  122. package/dist/test/unit/bin/utils/path/schema.test.js.map +1 -0
  123. package/dist/test/unit/should-built-and-test.test.js +40 -38
  124. package/dist/test/unit/should-built-and-test.test.js.map +1 -1
  125. package/dist/tsconfig.tsbuildinfo +1 -1
  126. package/eslint.config.mjs +9 -1
  127. package/package.json +4 -4
  128. package/test/unit/bin/build-from-local.test.ts +3 -53
  129. package/test/unit/bin/diff-changes.test.ts +97 -96
  130. package/test/unit/bin/diff.test.ts +53 -48
  131. package/test/unit/bin/git-command.test.ts +71 -0
  132. package/test/unit/bin/git.test.ts +246 -89
  133. package/test/unit/bin/logger.test.ts +55 -0
  134. package/test/unit/bin/logging.test.ts +52 -0
  135. package/test/unit/bin/utils/config/load-config.test.ts +63 -26
  136. package/test/unit/bin/utils/config/parser.test.ts +81 -27
  137. package/test/unit/bin/utils/files.test.ts +19 -33
  138. package/test/unit/bin/utils/path/repo-relative.test.ts +250 -0
  139. package/test/unit/bin/utils/path/schema.test.ts +65 -0
  140. package/test/unit/should-built-and-test.test.ts +40 -38
@@ -70,6 +70,9 @@ on:
70
70
  TESTER_APIFY_TOKEN_READ_ONLY:
71
71
  required: true
72
72
  description: Apify token used only for read-only investigation — reading the failing run, its log and its storages.
73
+ NPM_TOKEN:
74
+ required: false
75
+ description: Read-only token for installing private npm packages before Claude runs.
73
76
 
74
77
  jobs:
75
78
  investigate:
@@ -166,8 +169,10 @@ jobs:
166
169
  id-token: write
167
170
  actions: read
168
171
  steps:
169
- - name: Checkout repository
170
- uses: actions/checkout@v5
172
+ - name: Setup repository and dependencies
173
+ uses: apify/apify-test-tools/.github/actions/checkout-restore-dependencies@workflows-v0
174
+ with:
175
+ npm-token: ${{ secrets.NPM_TOKEN }}
171
176
 
172
177
  - name: Run Claude Code
173
178
  id: claude
package/CHANGELOG.md CHANGED
@@ -5,10 +5,16 @@ All notable changes to this project will be documented in this file.
5
5
  <!-- git-cliff-unreleased-start -->
6
6
  ## 0.10.1 - **not yet released**
7
7
 
8
+ ### 🚀 Features
9
+
10
+ - **relative-path:** Path safety and manipulation lib ([#154](https://github.com/apify/apify-test-tools/pull/154)) ([c151a1c](https://github.com/apify/apify-test-tools/commit/c151a1c2755fef98b335d48a3461f5042b90def9)) by [@JuanGalilea](https://github.com/JuanGalilea)
11
+ - **logger:** Add logger and ban console statements ([#170](https://github.com/apify/apify-test-tools/pull/170)) ([b295d36](https://github.com/apify/apify-test-tools/commit/b295d363276c92fc6c30dd34d7a90acef5d380bf)) by [@JuanGalilea](https://github.com/JuanGalilea), closes [#156](https://github.com/apify/apify-test-tools/issues/156)
12
+
8
13
  ### 🐛 Bug Fixes
9
14
 
10
15
  - **internal-workflows:** Wait out npm&#x27;s async publish before holding the major tag ([#160](https://github.com/apify/apify-test-tools/pull/160)) ([78434f0](https://github.com/apify/apify-test-tools/commit/78434f03d288ecbd383d55d56a1610f06ded298f)) by [@metalwarrior665](https://github.com/metalwarrior665)
11
16
  - Skip builds and tests when the net diff is empty ([#171](https://github.com/apify/apify-test-tools/pull/171)) ([874d70c](https://github.com/apify/apify-test-tools/commit/874d70ca5506f7ec742a1808c9bf6c670f08552c)) by [@artogahr](https://github.com/artogahr)
17
+ - **workflows:** Add npm install step to Claude fix workflow ([#161](https://github.com/apify/apify-test-tools/pull/161)) ([d8884e0](https://github.com/apify/apify-test-tools/commit/d8884e07f778de14c8d9d7f0ec9e0a1672c224ad)) by [@foxt451](https://github.com/foxt451), closes [#39](https://github.com/apify/apify-test-tools/issues/39)
12
18
 
13
19
 
14
20
  <!-- git-cliff-unreleased-end -->
package/CONTRIBUTING.md CHANGED
@@ -14,6 +14,15 @@ The package consists of three parts:
14
14
  - `bin/slack.ts` sending notifications to slack
15
15
  - `bin/test-report.ts` processing vitest's test reports
16
16
 
17
+ CLI command results use `writeJson` from `bin/output.ts` to write newline-terminated JSON to stdout.
18
+ Use the CLI `logger` from `bin/logger.ts` for diagnostics; every level writes to stderr.
19
+ ESLint rejects `console.log` throughout the codebase and all direct `console` calls in `bin/`.
20
+ The importable library in `lib/` keeps its own diagnostics; CLI log levels apply only to `bin/`.
21
+
22
+ The CLI's global `--log-level` option accepts `debug`, `info` (default), `warn`, `error`, or `silent`.
23
+ It filters diagnostics without affecting command results. Use `logger.debug` for detailed troubleshooting,
24
+ `logger.info` for progress, `logger.warn` for recoverable problems, and `logger.error` for failures.
25
+
17
26
  ## Test library
18
27
 
19
28
  - `lib/extend-expect.ts` - custom matchers
@@ -8,6 +8,7 @@ import { ActorSourceType } from 'apify-client';
8
8
 
9
9
  import { dryRunBuildData, LOCAL_SOURCE_VERSION_NUMBER, runAndSummarizeBuilds } from './build.js';
10
10
  import { buildDockerIgnoreMatcher } from './dockerignore.js';
11
+ import { logger } from './logger.js';
11
12
  import { isPathWithinScope } from './path-utils.js';
12
13
  import type { ActorConfig, BuildData } from './types.js';
13
14
  import type { SourceFile } from './utils.js';
@@ -34,7 +35,7 @@ export const collectSourceFiles = async (actorName: string, actorDir: string): P
34
35
  const isMonorepoActor = !!contextAbsDir && isOutsideDir(contextAbsDir, absActorDir);
35
36
 
36
37
  const dockerContextDirAbs = isMonorepoActor ? contextAbsDir! : absActorDir;
37
- const keptFilePaths = collectNonIgnoredFiles(dockerContextDirAbs, repoRoot);
38
+ const keptFilePaths = await collectNonIgnoredFiles(dockerContextDirAbs, repoRoot);
38
39
 
39
40
  if (!isMonorepoActor) {
40
41
  return Promise.all(keptFilePaths.map(async (filePath) => readSourceFile(filePath, dockerContextDirAbs)));
@@ -64,9 +65,9 @@ export const collectSourceFiles = async (actorName: string, actorDir: string): P
64
65
  // regardless of .gitignore/.dockerignore, matching Apify CLI's own behavior. Files matching the
65
66
  // hardcoded secret-pattern backstop (keys, certs, .env variants) are dropped unconditionally,
66
67
  // .actor/ included, since those should never ship regardless of what the ignore files say.
67
- export const collectNonIgnoredFiles = (dockerContextDir: string, repoRoot: string): string[] => {
68
- const relativePaths = listRepoFilePaths(repoRoot, dockerContextDir);
69
- const ignoredPaths = getGitignoredPaths(relativePaths);
68
+ export const collectNonIgnoredFiles = async (dockerContextDir: string, repoRoot: string): Promise<string[]> => {
69
+ const relativePaths = await listRepoFilePaths(repoRoot, dockerContextDir);
70
+ const ignoredPaths = await getGitignoredPaths(relativePaths, repoRoot);
70
71
  const rootRelativePaths = new Map(
71
72
  relativePaths.map((relPath) => [
72
73
  relPath,
@@ -104,7 +105,7 @@ export const flattenMonorepoContext = async (
104
105
  actorJson: Record<string, unknown>,
105
106
  keptContextFiles: string[],
106
107
  ): Promise<{ tempDir: string; filePaths: string[] }> => {
107
- console.error(`[${actorName}]: monorepo actor detected — flattening from Docker context`);
108
+ logger.info(`[${actorName}]: monorepo actor detected — flattening from Docker context`);
108
109
 
109
110
  const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), `apify-build-${actorName.replace('/', '_')}-`));
110
111
  const filePaths: string[] = [];
@@ -194,9 +195,9 @@ export const runBuildsFromLocal = async ({
194
195
  dryRun: boolean;
195
196
  }): Promise<BuildData[]> => {
196
197
  if (dryRun) {
197
- console.error('[DRY RUN] Would build from local source:');
198
+ logger.info('[DRY RUN] Would build from local source:');
198
199
  for (const { actorFullName, folder } of actorConfigs) {
199
- console.error(` ${actorFullName} (${folder})`);
200
+ logger.info(` ${actorFullName} (${folder})`);
200
201
  }
201
202
  return actorConfigs.map(({ actorFullName }) => dryRunBuildData(actorFullName, LOCAL_SOURCE_VERSION_NUMBER));
202
203
  }
package/bin/build.ts CHANGED
@@ -2,6 +2,7 @@ import type * as ApifyClientTypes from 'apify-client';
2
2
  import { ActorSourceType, ApifyClient } from 'apify-client';
3
3
 
4
4
  import { normalizeRepoUrl } from './git.js';
5
+ import { logger } from './logger.js';
5
6
  import type { ActorConfig, BuildData } from './types.js';
6
7
 
7
8
  type BuildPrActorOptions = {
@@ -28,7 +29,7 @@ const ACTOR_SETUP_REQUIREMENT =
28
29
  */
29
30
  export const resolveDefaultVersion = (actorFullName: string, actorInfo: ApifyClientTypes.Actor) => {
30
31
  const defaultBuildTag = actorInfo.defaultRunOptions.build;
31
- console.error(`Default build tag for ${actorFullName} is ${defaultBuildTag}`);
32
+ logger.info(`Default build tag for ${actorFullName} is ${defaultBuildTag}`);
32
33
 
33
34
  // We could technically allow this but in most cases this is accidentally set wrongly and there is a workaround
34
35
  if (defaultBuildTag.match(/\d+\.\d+\.\d+/)) {
@@ -43,7 +44,7 @@ export const resolveDefaultVersion = (actorFullName: string, actorInfo: ApifyCli
43
44
  throw new Error(`[${actorFullName}] No build found for tag "${defaultBuildTag}". ${ACTOR_SETUP_REQUIREMENT}`);
44
45
  }
45
46
  const defaultVersionNumber = defaultBuildNumber.match(/(\d+\.\d+)\.\d+/)![1];
46
- console.error(`Default version for ${actorFullName} is ${defaultVersionNumber}`);
47
+ logger.info(`Default version for ${actorFullName} is ${defaultVersionNumber}`);
47
48
 
48
49
  const defaultVersion = actorInfo.versions.find((version) => version.versionNumber === defaultVersionNumber);
49
50
 
@@ -139,7 +140,7 @@ export class ApifyBuilder {
139
140
  // We also get back actId so the testing actor can both match by actor ID and name
140
141
  const { id, actId, buildNumber } = await actorClient.build(versionNumber, { useCache });
141
142
 
142
- console.error(`[${this.actorFullName}]: ${id} (${buildNumber})`);
143
+ logger.info(`[${this.actorFullName}]: ${id} (${buildNumber})`);
143
144
  return { buildId: id, actorRawId: actId, buildNumber, actorFullName: this.actorFullName };
144
145
  };
145
146
 
@@ -147,20 +148,20 @@ export class ApifyBuilder {
147
148
  const build = await this.apifyClient.build(buildId).waitForFinish();
148
149
  const versionNumber = build.buildNumber;
149
150
  if (build.status === 'FAILED' || build.status === 'TIMED-OUT') {
150
- console.error(`[${this.actorFullName}]: ${versionNumber}`);
151
+ logger.error(`[${this.actorFullName}]: ${versionNumber}`);
151
152
  try {
152
153
  const log = await this.apifyClient.build(buildId).log().get();
153
154
  const logTail = log?.split('\n').slice(-40).join('\n');
154
- console.error(`\n--- BUILD LOG (last 40 lines) ---\n${logTail}\n---`);
155
+ logger.error(`\n--- BUILD LOG (last 40 lines) ---\n${logTail}\n---`);
155
156
  } catch (err) {
156
- console.error(`[${this.actorFullName}]: Failed to fetch build log: ${err}`);
157
+ logger.error(`[${this.actorFullName}]: Failed to fetch build log: ${err}`);
157
158
  }
158
159
  throw new Error(
159
160
  `[BUILD][${this.actorFullName}]: Build ${buildId} (${versionNumber}) failed. ` +
160
161
  `Not continuing with other builds and tests.`,
161
162
  );
162
163
  }
163
- console.error(`[${this.actorFullName}]: ${versionNumber}`);
164
+ logger.info(`[${this.actorFullName}]: ${versionNumber}`);
164
165
  return build;
165
166
  };
166
167
 
@@ -227,7 +228,7 @@ export class ApifyBuilder {
227
228
  type CorrectBuildColletionItem = (typeof items)[0] & { buildNumber: string };
228
229
  const buildsToDelete = (items as CorrectBuildColletionItem[]).filter((build) => {
229
230
  if (build.buildNumber === defaultBuildNumber) {
230
- console.error(
231
+ logger.info(
231
232
  `[DELETE OLD BUILDS][${this.actorFullName}]: Skipping default build ${defaultBuildNumber} (${defaultBuildTag}). ` +
232
233
  `We never delete default builds`,
233
234
  );
@@ -238,7 +239,7 @@ export class ApifyBuilder {
238
239
  (protectedBuildNumber) => protectedBuildNumber.buildNumber === build.buildNumber,
239
240
  );
240
241
  if (protectedTagFound) {
241
- console.error(
242
+ logger.info(
242
243
  `[DELETE OLD BUILDS][${this.actorFullName}]: Skipping protected build ${protectedTagFound.buildNumber} (${protectedTagFound.tag}).`,
243
244
  );
244
245
  return false;
@@ -247,7 +248,7 @@ export class ApifyBuilder {
247
248
  if (taggedDevelBuildNumber && build.buildNumber === taggedDevelBuildNumber) {
248
249
  const shouldDeleteDevelBuild = build.startedAt.getTime() < daysAgoUnixDevel;
249
250
  if (shouldDeleteDevelBuild) {
250
- console.error(
251
+ logger.info(
251
252
  `[DELETE OLD BUILDS][${this.actorFullName}]: Removing olf devel build ${taggedDevelBuildNumber}.`,
252
253
  );
253
254
  }
@@ -256,7 +257,7 @@ export class ApifyBuilder {
256
257
  return build.startedAt.getTime() < daysAgoUnixProd;
257
258
  });
258
259
 
259
- console.error(
260
+ logger.info(
260
261
  `[DELETE OLD BUILDS][${this.actorFullName}]: Deleting ${buildsToDelete.length} old builds that are non-default and ` +
261
262
  `older than 30 days from total ${items.length}`,
262
263
  );
@@ -271,8 +272,8 @@ export const waitAndSummarizeBuilds = async (
271
272
  buildersMap: Map<string, ApifyBuilder>,
272
273
  label: string,
273
274
  ): Promise<BuildData[]> => {
274
- console.error('=========================================');
275
- console.error(`FINISHED ${label}:`);
275
+ logger.info('=========================================');
276
+ logger.info(`FINISHED ${label}:`);
276
277
  await Promise.all(
277
278
  startedBuilds.map(async (buildData) => {
278
279
  const builder = buildersMap.get(buildData.actorFullName)!;
@@ -280,12 +281,12 @@ export const waitAndSummarizeBuilds = async (
280
281
  }),
281
282
  );
282
283
 
283
- console.error('=========================================');
284
- console.error('SUMMARY:');
284
+ logger.info('=========================================');
285
+ logger.info('SUMMARY:');
285
286
  for (const buildData of startedBuilds.sort((a, b) => a.actorFullName.localeCompare(b.actorFullName))) {
286
- console.error(`[${buildData.actorFullName}]: ${buildData.buildNumber}`);
287
+ logger.info(`[${buildData.actorFullName}]: ${buildData.buildNumber}`);
287
288
  }
288
- console.error('=========================================');
289
+ logger.info('=========================================');
289
290
 
290
291
  return startedBuilds;
291
292
  };
@@ -298,8 +299,8 @@ export const runAndSummarizeBuilds = async (
298
299
  const buildersByActorFullName = new Map<string, ApifyBuilder>(
299
300
  actorConfigs.map((actorConfig) => [actorConfig.actorFullName, ApifyBuilder.fromActorConfig(actorConfig)]),
300
301
  );
301
- console.error('=========================================');
302
- console.error(`STARTED ${label}:`);
302
+ logger.info('=========================================');
303
+ logger.info(`STARTED ${label}:`);
303
304
  const startedBuilds = await Promise.all(
304
305
  actorConfigs.map(async (actorConfig) =>
305
306
  buildOneActor(actorConfig, buildersByActorFullName.get(actorConfig.actorFullName)!),
@@ -366,9 +367,9 @@ export const runBuilds = async ({
366
367
  );
367
368
 
368
369
  if (dryRun) {
369
- console.error('[DRY RUN] Would build:');
370
+ logger.info('[DRY RUN] Would build:');
370
371
  for (const { actorConfig, versionNumber } of buildConfigs) {
371
- console.error(` ${actorConfig.actorFullName} (${versionNumber})`);
372
+ logger.info(` ${actorConfig.actorFullName} (${versionNumber})`);
372
373
  }
373
374
  return buildConfigs.map(({ actorConfig, versionNumber }) =>
374
375
  dryRunBuildData(actorConfig.actorFullName, versionNumber),
@@ -1,5 +1,6 @@
1
1
  import { isCosmeticOnlyJsonSchemaChange } from './diff-json-schema.js';
2
2
  import { type DockerIgnoreMatcher, loadDockerIgnore } from './dockerignore.js';
3
+ import { logger } from './logger.js';
3
4
  import { findContainingScope, hoistPath, isPathWithinScope } from './path-utils.js';
4
5
  import type { ActorConfig, Commit } from './types.js';
5
6
 
@@ -44,12 +45,12 @@ type FileChangeForActor =
44
45
  * 6. .json inside the actor's own `.actor/` dir with only cosmetic schema diffs → cosmetic (semantically verified)
45
46
  * 7. Everything else → functional
46
47
  */
47
- const classifyFileChange = (
48
+ const classifyFileChange = async (
48
49
  originalFilePath: string,
49
50
  actorConfig: ActorConfig,
50
51
  commits: Commit[],
51
52
  dockerIgnoreMatcher: DockerIgnoreMatcher,
52
- ): FileChangeForActor => {
53
+ ): Promise<FileChangeForActor> => {
53
54
  const lowercaseFilePath = originalFilePath.toLowerCase();
54
55
 
55
56
  // TODO: hardcodes that there's a single repo-wide changelog belonging to every actor. Should instead
@@ -89,7 +90,7 @@ const classifyFileChange = (
89
90
  }
90
91
 
91
92
  if (lowercaseFilePath.endsWith('.json') && isUnderActorDotDir) {
92
- const isCosmetic = isCosmeticOnlyJsonSchemaChange(commits, originalFilePath);
93
+ const isCosmetic = await isCosmeticOnlyJsonSchemaChange(commits, originalFilePath);
93
94
  if (isCosmetic) {
94
95
  return { impact: 'cosmetic', semanticallyVerified: true };
95
96
  }
@@ -159,19 +160,19 @@ const groupFilesByActorSet = (fileToActors: Map<string, Set<string>>): ChangeGro
159
160
  const logChangeGroups = (groups: ChangeGroup[]): void => {
160
161
  for (const { actors, files } of groups) {
161
162
  if (actors.length > 1) {
162
- console.error(`[DIFF]: Shared changes for actors ${actors.join(', ')}: ${files.join(', ')}`);
163
+ logger.info(`[DIFF]: Shared changes for actors ${actors.join(', ')}: ${files.join(', ')}`);
163
164
  } else {
164
- console.error(`[DIFF]: Changes specific to actor ${actors[0]}: ${files.join(', ')}`);
165
+ logger.info(`[DIFF]: Changes specific to actor ${actors[0]}: ${files.join(', ')}`);
165
166
  }
166
167
  }
167
168
  };
168
169
 
169
- export const getChangedActors = ({
170
+ export const getChangedActors = async ({
170
171
  filepathsChanged,
171
172
  actorConfigs,
172
173
  isLatest = false,
173
174
  commits,
174
- }: ShouldBuildAndTestOptions): ActorConfig[] => {
175
+ }: ShouldBuildAndTestOptions): Promise<ActorConfig[]> => {
175
176
  const actorsChangedMap = new Map<string, ActorChangeEntry>();
176
177
 
177
178
  for (const actorConfig of actorConfigs) {
@@ -184,7 +185,7 @@ export const getChangedActors = ({
184
185
  continue;
185
186
  }
186
187
 
187
- const change = classifyFileChange(originalFilePath, actorConfig, commits, dockerIgnoreMatcher);
188
+ const change = await classifyFileChange(originalFilePath, actorConfig, commits, dockerIgnoreMatcher);
188
189
 
189
190
  if (change.impact === 'ignored' || change.impact === 'outside-context') continue;
190
191
  if (change.impact === 'cosmetic' && !isLatest) continue;
@@ -205,9 +206,9 @@ export const getChangedActors = ({
205
206
 
206
207
  if (actorsChanged.length > 0) {
207
208
  const actors = actorsChanged.map((config) => config.actorFullName);
208
- console.error(`[DIFF]: Actors to be built and tested: ${actors.join(', ')}`);
209
+ logger.info(`[DIFF]: Actors to be built and tested: ${actors.join(', ')}`);
209
210
  } else {
210
- console.error(`[DIFF]: No relevant files changed, skipping builds and tests`);
211
+ logger.info(`[DIFF]: No relevant files changed, skipping builds and tests`);
211
212
  }
212
213
 
213
214
  return actorsChanged;
@@ -1,5 +1,6 @@
1
+ import { logger } from './logger.js';
1
2
  import type { Commit } from './types.js';
2
- import { spawnCommand } from './utils.js';
3
+ import { runGitCommand } from './utils.js';
3
4
 
4
5
  const COSMETIC_JSON_FIELD_NAMES = new Set([
5
6
  'title',
@@ -28,21 +29,22 @@ const isCosmeticObjectChange = (oldVal: unknown, newVal: unknown, currentKey?: s
28
29
  * Returns true if the two JSON strings differ only in cosmetic fields
29
30
  * (title, description, example, enumTitles, sectionCaption, sectionDescription).
30
31
  */
31
- export const isCosmeticOnlyJsonSchemaChange = (commits: Commit[], changedFilepath: string): boolean => {
32
+ export const isCosmeticOnlyJsonSchemaChange = async (commits: Commit[], changedFilepath: string): Promise<boolean> => {
32
33
  // TODO: validate this is the right commit range
33
34
  const oldRef = `${commits[0].sha}~`;
34
35
  const newRef = commits[commits.length - 1].sha;
35
- let oldJson: unknown;
36
- let newJson: unknown;
37
- try {
38
- const oldContent = spawnCommand(`git show ${oldRef}:${changedFilepath}`);
39
- const newContent = spawnCommand(`git show ${newRef}:${changedFilepath}`);
40
36
 
41
- oldJson = JSON.parse(oldContent);
42
- newJson = JSON.parse(newContent);
43
- } catch {
44
- console.error(
45
- `Failed to get or parse JSON content for ${changedFilepath} at refs ${oldRef} and ${newRef}, maybe it is new file or deleted? Treating it as a non-cosmetic change.`,
37
+ const [oldJson, newJson] = await Promise.all([
38
+ runGitCommand(['show', `${oldRef}:${changedFilepath}`])
39
+ .then(JSON.parse)
40
+ .catch(() => undefined),
41
+ runGitCommand(['show', `${newRef}:${changedFilepath}`])
42
+ .then(JSON.parse)
43
+ .catch(() => undefined),
44
+ ]);
45
+ if (!oldJson || !newJson) {
46
+ logger.info(
47
+ `Failed to get JSON content for ${changedFilepath} at refs ${oldRef} and ${newRef}, maybe it is new file or deleted? Treating it as a non-cosmetic change.`,
46
48
  );
47
49
  return false;
48
50
  }
package/bin/git.ts CHANGED
@@ -1,5 +1,6 @@
1
+ import { logger } from './logger.js';
1
2
  import type { Commit, Config } from './types.js';
2
- import { spawnCommand } from './utils.js';
3
+ import { GitCommandError, runGitCommand } from './utils.js';
3
4
 
4
5
  export const GIT_FORMAT_SEPARATOR = '»¦«';
5
6
  const GIT_LOG_FORMAT = ['%H', '%aN<%aE>', '%aD', '%s'].join(GIT_FORMAT_SEPARATOR);
@@ -7,19 +8,20 @@ const GIT_LOG_FORMAT = ['%H', '%aN<%aE>', '%aD', '%s'].join(GIT_FORMAT_SEPARATOR
7
8
  /**
8
9
  * Gets the list of changed files between the given commits (inclusive).
9
10
  */
10
- export const getChangedFiles = (commits: Commit[]) => {
11
+ export const getChangedFiles = async (commits: Commit[]) => {
11
12
  // getCommits never returns an empty list (the rerun check returns all commits when the base commit
12
13
  // is the branch HEAD, and an empty git range throws when parsing), so this signals a programmer error
13
14
  if (commits.length === 0) {
14
15
  throw new Error('Cannot get changed files: the commit list is empty. This should never happen.');
15
16
  }
16
17
 
17
- const changedFilesString = spawnCommand(
18
- `git diff --name-only ${commits[0].sha}~..${commits[commits.length - 1].sha}`,
18
+ const changedFilesString = await runGitCommand(
19
+ ['diff', '--name-only', `${commits[0].sha}~..${commits[commits.length - 1].sha}`],
20
+ { operation: 'get the list of changed files between commits' },
19
21
  );
20
22
 
21
23
  const changedFiles = changedFilesString.split('\n').filter(Boolean);
22
- console.error(`Changed files (up to 50): ${changedFiles.slice(0, 50).join(', ')}`);
24
+ logger.info(`Changed files (up to 50): ${changedFiles.slice(0, 50).join(', ')}`);
23
25
  return changedFiles;
24
26
  };
25
27
 
@@ -28,17 +30,36 @@ export const getChangedFiles = (commits: Commit[]) => {
28
30
  * (i.e. a genuine "merge from target" commit, not a merge of some unrelated branch).
29
31
  * Uses the full targetBranch..sourceBranch range, ignoring baseCommit.
30
32
  */
31
- export const hasMergeFromTarget = (sourceBranch: string, targetBranch: string): boolean => {
32
- const mergeShas = spawnCommand(`git log --merges --pretty=format:%H ${targetBranch}..${sourceBranch}`)
33
+ export const hasMergeFromTarget = async (sourceBranch: string, targetBranch: string): Promise<boolean> => {
34
+ const mergeShas = (
35
+ await runGitCommand(['log', '--merges', '--pretty=format:%H', `${targetBranch}..${sourceBranch}`], {
36
+ operation: 'find merge commits on the source branch that are absent from the target branch',
37
+ })
38
+ )
33
39
  .split('\n')
34
40
  .filter(Boolean);
35
41
 
36
42
  for (const sha of mergeShas) {
37
- const parents = spawnCommand(`git log -1 --pretty=format:%P ${sha}`).trim().split(' ');
43
+ const parents = (
44
+ await runGitCommand(['log', '-1', '--pretty=format:%P', sha], {
45
+ operation: 'get parents of a merge commit',
46
+ })
47
+ ).split(' ');
38
48
  for (const parent of parents) {
39
49
  // git merge-base A B outputs the common ancestor.
40
50
  // If that equals A, then A is an ancestor of B (i.e. parent is reachable from targetBranch).
41
- const mergeBase = spawnCommand(`git merge-base ${parent} ${targetBranch}`);
51
+ let mergeBase: string;
52
+ try {
53
+ mergeBase = await runGitCommand(['merge-base', parent, targetBranch], {
54
+ operation: 'check whether a merge parent is reachable from the target branch',
55
+ });
56
+ } catch (error) {
57
+ // Exit code 1 on merge-base means the histories have no common ancestor.
58
+ if (error instanceof GitCommandError && error.exitCode === 1) {
59
+ continue;
60
+ }
61
+ throw error;
62
+ }
42
63
  if (mergeBase === parent) {
43
64
  return true;
44
65
  }
@@ -51,8 +72,11 @@ export const hasMergeFromTarget = (sourceBranch: string, targetBranch: string):
51
72
  * Returns all files touched by non-merge commits on the branch (full history, ignoring baseCommit).
52
73
  * Used to check whether the branch itself has any functional changes, independent of what master merged in.
53
74
  */
54
- export const getBranchOnlyChangedFiles = (sourceBranch: string, targetBranch: string): string[] => {
55
- const output = spawnCommand(`git log --no-merges --name-only --pretty=format: ${targetBranch}..${sourceBranch}`);
75
+ export const getBranchOnlyChangedFiles = async (sourceBranch: string, targetBranch: string): Promise<string[]> => {
76
+ const output = await runGitCommand(
77
+ ['log', '--no-merges', '--name-only', '--pretty=format:', `${targetBranch}..${sourceBranch}`],
78
+ { operation: 'list files changed by non-merge commits on the source branch' },
79
+ );
56
80
  return output.split('\n').filter(Boolean);
57
81
  };
58
82
 
@@ -79,10 +103,12 @@ export const parseBaseCommit = (shaOrCommit: string | undefined): string | undef
79
103
  return sha;
80
104
  };
81
105
 
82
- const fetchAllBranchCommits = (sourceBranch: string, targetBranch: string): Commit[] => {
83
- const commitsStrings = spawnCommand(
84
- `git log --pretty=format:'${GIT_LOG_FORMAT}' ${targetBranch}..${sourceBranch}`,
85
- ).split('\n');
106
+ const fetchAllBranchCommits = async (sourceBranch: string, targetBranch: string): Promise<Commit[]> => {
107
+ const output = await runGitCommand(
108
+ ['log', `--pretty=format:${GIT_LOG_FORMAT}`, `${targetBranch}..${sourceBranch}`],
109
+ { operation: 'list commits reachable from the source ref but absent from the target ref' },
110
+ );
111
+ const commitsStrings = output.split('\n');
86
112
  const commits = commitsStrings.map((commitString) => parseCommit(commitString));
87
113
  commits.reverse();
88
114
  return commits;
@@ -92,22 +118,22 @@ const fetchAllBranchCommits = (sourceBranch: string, targetBranch: string): Comm
92
118
  * Gets the commits between sourceBranch and targetBranch (exclusive).
93
119
  * - If baseCommit is provided, only returns commits after the baseCommit.
94
120
  */
95
- export const getCommits = ({
121
+ export const getCommits = async ({
96
122
  sourceBranch,
97
123
  targetBranch,
98
124
  baseCommit,
99
- }: Pick<Config, 'sourceBranch' | 'targetBranch' | 'baseCommit'>): Commit[] => {
125
+ }: Pick<Config, 'sourceBranch' | 'targetBranch' | 'baseCommit'>): Promise<Commit[]> => {
100
126
  const baseCommitSha = parseBaseCommit(baseCommit);
101
- const commits = fetchAllBranchCommits(sourceBranch, targetBranch);
127
+ const commits = await fetchAllBranchCommits(sourceBranch, targetBranch);
102
128
 
103
129
  // The last validated (base) commit being the branch HEAD means nothing new was pushed since the last
104
130
  // validation — the dev reran the workflow (or force-pushed to the same state) to trigger a clean test
105
131
  const headSha = commits[commits.length - 1]?.sha;
106
132
  if (baseCommitSha !== undefined && baseCommitSha === headSha) {
107
- console.error(
133
+ logger.info(
108
134
  `Detected rerun with the same commit that we already validated. This usually means the user wants to rerun the Action from scratch, ignoring last validated commit ${baseCommitSha} and returning all commits`,
109
135
  );
110
- console.error(`Commits being returned: ${commits.map((c) => c.sha).join(', ')}`);
136
+ logger.info(`Commits being returned: ${commits.map((c) => c.sha).join(', ')}`);
111
137
  return commits;
112
138
  }
113
139
 
@@ -116,17 +142,15 @@ export const getCommits = ({
116
142
  const hasBaseCommit = baseCommitIndex !== -1;
117
143
  if (hasBaseCommit) {
118
144
  const commitsUpToBaseCommit = commits.slice(baseCommitIndex + 1);
119
- console.error(
145
+ logger.info(
120
146
  `Found base commit ${baseCommitSha} at index ${baseCommitIndex}, returning ${commitsUpToBaseCommit.length} commits after it`,
121
147
  );
122
- console.error(`Commits being returned: ${commitsUpToBaseCommit.map((c) => c.sha).join(', ')}`);
148
+ logger.info(`Commits being returned: ${commitsUpToBaseCommit.map((c) => c.sha).join(', ')}`);
123
149
  return commitsUpToBaseCommit;
124
150
  }
125
151
 
126
- console.error(
127
- `Base commit ${baseCommitSha} not found in the commit range, returning all ${commits.length} commits`,
128
- );
129
- console.error(`Commits being returned: ${commits.map((c) => c.sha).join(', ')}`);
152
+ logger.info(`Base commit ${baseCommitSha} not found in the commit range, returning all ${commits.length} commits`);
153
+ logger.info(`Commits being returned: ${commits.map((c) => c.sha).join(', ')}`);
130
154
  return commits;
131
155
  };
132
156
 
@@ -148,8 +172,10 @@ export const parseCommit = (commitString: string): Commit => {
148
172
  * Returns the currently checked-out branch. Release builds point the Actor version at this branch,
149
173
  * so a detached HEAD (no branch to point at) is an error rather than a guess.
150
174
  */
151
- export const getCurrentBranch = (): string => {
152
- const branch = spawnCommand('git rev-parse --abbrev-ref HEAD');
175
+ export const getCurrentBranch = async (): Promise<string> => {
176
+ const branch = await runGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], {
177
+ operation: 'determine the current branch for release',
178
+ });
153
179
  if (branch === 'HEAD') {
154
180
  throw new Error(
155
181
  'Cannot determine the branch to release: HEAD is detached. Check out the branch you want to release.',
@@ -162,8 +188,11 @@ export const getCurrentBranch = (): string => {
162
188
  * Reads the repository URL from the `origin` remote, rewritten to the SSH form the Apify platform
163
189
  * uses for Git repo sources, e.g. git@github.com:apify-store/google-maps
164
190
  */
165
- const getOriginRepoUrl = (): string => {
166
- return spawnCommand('git remote get-url origin').replace(/^https:\/\/github\.com\//, 'git@github.com:');
191
+ const getOriginRepoUrl = async (): Promise<string> => {
192
+ const rawUrl = await runGitCommand(['remote', 'get-url', 'origin'], {
193
+ operation: 'read the origin repository URL',
194
+ });
195
+ return rawUrl.replace(/^https:\/\/github\.com\//, 'git@github.com:');
167
196
  };
168
197
 
169
198
  /**
@@ -171,8 +200,8 @@ const getOriginRepoUrl = (): string => {
171
200
  * each Actor's default version, so a fork or mirror remote can't repoint a published Actor. An explicit
172
201
  * --repo-url skips that check: passing it is how you move an Actor to another repository on purpose.
173
202
  */
174
- export const resolveRepoUrl = (explicitRepoUrl: string | undefined) => ({
175
- repoUrl: explicitRepoUrl ?? getOriginRepoUrl(),
203
+ export const resolveRepoUrl = async (explicitRepoUrl: string | undefined) => ({
204
+ repoUrl: explicitRepoUrl ?? (await getOriginRepoUrl()),
176
205
  shouldVerifyRepoUrl: explicitRepoUrl === undefined,
177
206
  });
178
207
 
@@ -207,7 +236,7 @@ const ZERO_SHA_REGEX = /^0{40}$/;
207
236
  * Unlike the PR path (getCommits), there is no lenient fallback. Falling back to "everything"
208
237
  * would rebuild the latest build of every Actor and post it to Slack, so every problem is an error.
209
238
  */
210
- export const resolveReleaseBaseCommit = (baseCommit: string): string => {
239
+ export const resolveReleaseBaseCommit = async (baseCommit: string): Promise<string> => {
211
240
  const sha = parseBaseCommit(baseCommit);
212
241
  if (!sha) {
213
242
  throw new Error('--base-commit is required for release. See the README section "Releasing Actors".');
@@ -218,15 +247,27 @@ export const resolveReleaseBaseCommit = (baseCommit: string): string => {
218
247
  `Rerun with --base-commit set to the last commit before the changes you want to release.`,
219
248
  );
220
249
  }
221
- // --quiet makes rev-parse print nothing (instead of an error) when the commit is missing
222
- if (!spawnCommand(`git rev-parse --verify --quiet "${sha}^{commit}"`)) {
250
+ // --quiet suppresses Git's error text for a missing commit, but Git still exits with code 1.
251
+ const verifiedSha = await runGitCommand(['rev-parse', '--verify', '--quiet', `${sha}^{commit}`], {
252
+ operation: 'verify the release base commit in local Git history',
253
+ }).catch((error: unknown) => {
254
+ if (error instanceof GitCommandError && error.exitCode === 1) return '';
255
+ throw error;
256
+ });
257
+ if (!verifiedSha) {
223
258
  throw new Error(
224
259
  `Base commit ${sha} is not in the local git history. Either the checkout is shallow ` +
225
260
  `(fetch the full history, e.g. fetch-depth: 0 in actions/checkout) or the branch was force-pushed.`,
226
261
  );
227
262
  }
228
263
  // git merge-base A B outputs the common ancestor. If that equals A, then A is an ancestor of B.
229
- if (spawnCommand(`git merge-base ${sha} HEAD`) !== sha) {
264
+ const mergeBase = await runGitCommand(['merge-base', verifiedSha, 'HEAD'], {
265
+ operation: 'check whether the release base commit is an ancestor of HEAD',
266
+ }).catch((error: unknown) => {
267
+ if (error instanceof GitCommandError && error.exitCode === 1) return '';
268
+ throw error;
269
+ });
270
+ if (mergeBase !== sha) {
230
271
  throw new Error(
231
272
  `Base commit ${sha} is not an ancestor of HEAD, most likely because the branch was force-pushed. ` +
232
273
  `The changed files cannot be determined reliably. Rerun with --base-commit set to an ancestor of HEAD ` +
@@ -239,11 +280,13 @@ export const resolveReleaseBaseCommit = (baseCommit: string): string => {
239
280
  const CHANGELOG_PATH = 'CHANGELOG.md';
240
281
 
241
282
  /** Returns the lines added to the root CHANGELOG.md between baseSha and HEAD, or null if it didn't change. */
242
- const getChangelogAdditions = (baseSha: string, changedFiles: string[]): string | null => {
283
+ const getChangelogAdditions = async (baseSha: string, changedFiles: string[]): Promise<string | null> => {
243
284
  if (!changedFiles.includes(CHANGELOG_PATH)) {
244
285
  return null;
245
286
  }
246
- const diff = spawnCommand('git', ['diff', baseSha, 'HEAD', '--', CHANGELOG_PATH]);
287
+ const diff = await runGitCommand(['diff', baseSha, 'HEAD', '--', CHANGELOG_PATH], {
288
+ operation: 'read changelog changes since the last release',
289
+ });
247
290
 
248
291
  const added: string[] = [];
249
292
  let startedChangelog = false;
@@ -271,12 +314,16 @@ const getChangelogAdditions = (baseSha: string, changedFiles: string[]): string
271
314
  * list: with a merge commit, the oldest commit of the merged branch can be older than baseSha, and
272
315
  * diffing from its parent would pull in already-released changes.
273
316
  */
274
- export const getReleaseChanges = (baseSha: string) => {
275
- if (spawnCommand('git rev-parse HEAD') === baseSha) {
317
+ export const getReleaseChanges = async (baseSha: string) => {
318
+ if (
319
+ (await runGitCommand(['rev-parse', 'HEAD'], { operation: 'identify the current release commit' })) === baseSha
320
+ ) {
276
321
  return null;
277
322
  }
278
- const commits = fetchAllBranchCommits('HEAD', baseSha);
279
- const changedFiles = spawnCommand(`git diff --name-only ${baseSha} HEAD`).split('\n').filter(Boolean);
280
- const changelog = getChangelogAdditions(baseSha, changedFiles);
323
+ const commits = await fetchAllBranchCommits('HEAD', baseSha);
324
+ const changedFiles = await runGitCommand(['diff', '--name-only', baseSha, 'HEAD'], {
325
+ operation: 'list files changed since the last release',
326
+ }).then((output) => output.split('\n').filter(Boolean));
327
+ const changelog = await getChangelogAdditions(baseSha, changedFiles);
281
328
  return { commits, changedFiles, changelog };
282
329
  };