apify-test-tools 0.10.2-beta.0 → 0.10.2-beta.10

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 (187) hide show
  1. package/.gitattributes +2 -0
  2. package/.github/actions/checkout-restore-dependencies/action.yaml +64 -27
  3. package/.github/cli/apify-test-tools.mjs +360 -0
  4. package/.github/scripts/check-major-tag-refs.mjs +1 -1
  5. package/.github/scripts/mask-config-secrets.sh +17 -0
  6. package/.github/scripts/run-with-apify-tokens.mjs +37 -12
  7. package/.github/workflows/_check_code.yaml +25 -0
  8. package/.github/workflows/_move_major_tag.yaml +3 -81
  9. package/.github/workflows/_tests.yaml +34 -0
  10. package/.github/workflows/_update_release_metadata.yaml +0 -12
  11. package/.github/workflows/manual_move_major_tag.yaml +3 -3
  12. package/.github/workflows/manual_release_stable.yaml +3 -13
  13. package/.github/workflows/on_master.yaml +9 -5
  14. package/.github/workflows/on_pull_request.yaml +3 -48
  15. package/.github/workflows/public_platform-tests-claude-investigate-and-fix.yaml +13 -2
  16. package/.github/workflows/public_platform-tests.yaml +24 -1
  17. package/.github/workflows/public_pr-build-test.yaml +62 -49
  18. package/.github/workflows/public_push-build-latest.yaml +41 -8
  19. package/.husky/pre-commit +5 -0
  20. package/.prettierignore +3 -0
  21. package/CHANGELOG.md +17 -0
  22. package/CONTRIBUTING.md +105 -21
  23. package/README.md +218 -597
  24. package/bin/affected-actors.ts +198 -0
  25. package/bin/build-from-local.ts +65 -13
  26. package/bin/build.ts +123 -41
  27. package/bin/diff-changes.ts +55 -25
  28. package/bin/diff-json-schema.ts +5 -6
  29. package/bin/git.ts +69 -99
  30. package/bin/main.ts +96 -122
  31. package/bin/slack.ts +43 -10
  32. package/bin/stdin-input.ts +65 -0
  33. package/bin/test-report.ts +6 -9
  34. package/bin/types.ts +16 -0
  35. package/dist/bin/affected-actors.d.ts +46 -0
  36. package/dist/bin/affected-actors.d.ts.map +1 -0
  37. package/dist/bin/affected-actors.js +135 -0
  38. package/dist/bin/affected-actors.js.map +1 -0
  39. package/dist/bin/build-from-local.d.ts +12 -3
  40. package/dist/bin/build-from-local.d.ts.map +1 -1
  41. package/dist/bin/build-from-local.js +34 -12
  42. package/dist/bin/build-from-local.js.map +1 -1
  43. package/dist/bin/build.d.ts +30 -7
  44. package/dist/bin/build.d.ts.map +1 -1
  45. package/dist/bin/build.js +85 -26
  46. package/dist/bin/build.js.map +1 -1
  47. package/dist/bin/diff-changes.d.ts +12 -4
  48. package/dist/bin/diff-changes.d.ts.map +1 -1
  49. package/dist/bin/diff-changes.js +31 -20
  50. package/dist/bin/diff-changes.js.map +1 -1
  51. package/dist/bin/diff-json-schema.d.ts +2 -2
  52. package/dist/bin/diff-json-schema.d.ts.map +1 -1
  53. package/dist/bin/diff-json-schema.js +1 -4
  54. package/dist/bin/diff-json-schema.js.map +1 -1
  55. package/dist/bin/git.d.ts +23 -21
  56. package/dist/bin/git.d.ts.map +1 -1
  57. package/dist/bin/git.js +60 -83
  58. package/dist/bin/git.js.map +1 -1
  59. package/dist/bin/main.js +82 -93
  60. package/dist/bin/main.js.map +1 -1
  61. package/dist/bin/slack.d.ts +14 -4
  62. package/dist/bin/slack.d.ts.map +1 -1
  63. package/dist/bin/slack.js +39 -6
  64. package/dist/bin/slack.js.map +1 -1
  65. package/dist/bin/stdin-input.d.ts +17 -0
  66. package/dist/bin/stdin-input.d.ts.map +1 -0
  67. package/dist/bin/stdin-input.js +54 -0
  68. package/dist/bin/stdin-input.js.map +1 -0
  69. package/dist/bin/test-report.d.ts.map +1 -1
  70. package/dist/bin/test-report.js +6 -8
  71. package/dist/bin/test-report.js.map +1 -1
  72. package/dist/bin/types.d.ts +14 -0
  73. package/dist/bin/types.d.ts.map +1 -1
  74. package/dist/lib/extend-expect.d.ts.map +1 -1
  75. package/dist/lib/extend-expect.js +37 -218
  76. package/dist/lib/extend-expect.js.map +1 -1
  77. package/dist/lib/lib.d.ts +1 -2
  78. package/dist/lib/lib.d.ts.map +1 -1
  79. package/dist/lib/lib.js +15 -22
  80. package/dist/lib/lib.js.map +1 -1
  81. package/dist/lib/run-checks.d.ts +14 -0
  82. package/dist/lib/run-checks.d.ts.map +1 -0
  83. package/dist/lib/run-checks.js +82 -0
  84. package/dist/lib/run-checks.js.map +1 -0
  85. package/dist/lib/run-test-result.d.ts +3 -6
  86. package/dist/lib/run-test-result.d.ts.map +1 -1
  87. package/dist/lib/run-test-result.js +41 -54
  88. package/dist/lib/run-test-result.js.map +1 -1
  89. package/dist/test/bundle/cli-bundle.test.d.ts +2 -0
  90. package/dist/test/bundle/cli-bundle.test.d.ts.map +1 -0
  91. package/dist/test/bundle/cli-bundle.test.js +190 -0
  92. package/dist/test/bundle/cli-bundle.test.js.map +1 -0
  93. package/dist/test/e2e/cli-bundle.e2e.test.d.ts +2 -0
  94. package/dist/test/e2e/cli-bundle.e2e.test.d.ts.map +1 -0
  95. package/dist/test/e2e/cli-bundle.e2e.test.js +138 -0
  96. package/dist/test/e2e/cli-bundle.e2e.test.js.map +1 -0
  97. package/dist/test/e2e/git-build-release.e2e.test.d.ts +2 -0
  98. package/dist/test/e2e/git-build-release.e2e.test.d.ts.map +1 -0
  99. package/dist/test/e2e/git-build-release.e2e.test.js +87 -0
  100. package/dist/test/e2e/git-build-release.e2e.test.js.map +1 -0
  101. package/dist/test/e2e/helpers.d.ts +37 -0
  102. package/dist/test/e2e/helpers.d.ts.map +1 -0
  103. package/dist/test/e2e/helpers.js +57 -0
  104. package/dist/test/e2e/helpers.js.map +1 -0
  105. package/dist/test/unit/bin/affected-actors-cli.test.d.ts +2 -0
  106. package/dist/test/unit/bin/affected-actors-cli.test.d.ts.map +1 -0
  107. package/dist/test/unit/bin/affected-actors-cli.test.js +167 -0
  108. package/dist/test/unit/bin/affected-actors-cli.test.js.map +1 -0
  109. package/dist/test/unit/bin/affected-actors.test.d.ts +2 -0
  110. package/dist/test/unit/bin/affected-actors.test.d.ts.map +1 -0
  111. package/dist/test/unit/bin/affected-actors.test.js +141 -0
  112. package/dist/test/unit/bin/affected-actors.test.js.map +1 -0
  113. package/dist/test/unit/bin/build-cli.test.d.ts +2 -0
  114. package/dist/test/unit/bin/build-cli.test.d.ts.map +1 -0
  115. package/dist/test/unit/bin/build-cli.test.js +44 -0
  116. package/dist/test/unit/bin/build-cli.test.js.map +1 -0
  117. package/dist/test/unit/bin/build-from-local.test.js +65 -1
  118. package/dist/test/unit/bin/build-from-local.test.js.map +1 -1
  119. package/dist/test/unit/bin/build.test.js +223 -3
  120. package/dist/test/unit/bin/build.test.js.map +1 -1
  121. package/dist/test/unit/bin/compiled-cli.d.ts +6 -0
  122. package/dist/test/unit/bin/compiled-cli.d.ts.map +1 -0
  123. package/dist/test/unit/bin/compiled-cli.js +16 -0
  124. package/dist/test/unit/bin/compiled-cli.js.map +1 -0
  125. package/dist/test/unit/bin/diff-changes.test.js +205 -85
  126. package/dist/test/unit/bin/diff-changes.test.js.map +1 -1
  127. package/dist/test/unit/bin/diff.test.js +25 -28
  128. package/dist/test/unit/bin/diff.test.js.map +1 -1
  129. package/dist/test/unit/bin/git.test.js +69 -152
  130. package/dist/test/unit/bin/git.test.js.map +1 -1
  131. package/dist/test/unit/bin/slack.test.d.ts +2 -0
  132. package/dist/test/unit/bin/slack.test.d.ts.map +1 -0
  133. package/dist/test/unit/bin/slack.test.js +71 -0
  134. package/dist/test/unit/bin/slack.test.js.map +1 -0
  135. package/dist/test/unit/bin/stdin-input.test.d.ts +2 -0
  136. package/dist/test/unit/bin/stdin-input.test.d.ts.map +1 -0
  137. package/dist/test/unit/bin/stdin-input.test.js +50 -0
  138. package/dist/test/unit/bin/stdin-input.test.js.map +1 -0
  139. package/dist/test/unit/github-scripts/mask-config-secrets.test.d.ts +2 -0
  140. package/dist/test/unit/github-scripts/mask-config-secrets.test.d.ts.map +1 -0
  141. package/dist/test/unit/github-scripts/mask-config-secrets.test.js +50 -0
  142. package/dist/test/unit/github-scripts/mask-config-secrets.test.js.map +1 -0
  143. package/dist/test/unit/github-scripts/run-with-apify-tokens.test.d.ts +2 -0
  144. package/dist/test/unit/github-scripts/run-with-apify-tokens.test.d.ts.map +1 -0
  145. package/dist/test/unit/github-scripts/run-with-apify-tokens.test.js +102 -0
  146. package/dist/test/unit/github-scripts/run-with-apify-tokens.test.js.map +1 -0
  147. package/dist/test/unit/run-checks.test.d.ts +2 -0
  148. package/dist/test/unit/run-checks.test.d.ts.map +1 -0
  149. package/dist/test/unit/run-checks.test.js +62 -0
  150. package/dist/test/unit/run-checks.test.js.map +1 -0
  151. package/dist/test/unit/run-test-result.test.d.ts +2 -0
  152. package/dist/test/unit/run-test-result.test.d.ts.map +1 -0
  153. package/dist/test/unit/run-test-result.test.js +34 -0
  154. package/dist/test/unit/run-test-result.test.js.map +1 -0
  155. package/dist/test/unit/should-built-and-test.test.js +41 -38
  156. package/dist/test/unit/should-built-and-test.test.js.map +1 -1
  157. package/dist/tsconfig.tsbuildinfo +1 -1
  158. package/docs/cli.md +373 -0
  159. package/docs/lib.md +271 -0
  160. package/docs/workflows.md +367 -0
  161. package/lib/extend-expect.ts +48 -258
  162. package/lib/lib.ts +24 -28
  163. package/lib/run-checks.ts +101 -0
  164. package/lib/run-test-result.ts +45 -55
  165. package/package.json +15 -3
  166. package/test/bundle/cli-bundle.test.ts +235 -0
  167. package/test/e2e/cli-bundle.e2e.test.ts +207 -0
  168. package/test/e2e/git-build-release.e2e.test.ts +113 -0
  169. package/test/e2e/helpers.ts +82 -0
  170. package/test/unit/bin/affected-actors-cli.test.ts +201 -0
  171. package/test/unit/bin/affected-actors.test.ts +182 -0
  172. package/test/unit/bin/build-cli.test.ts +55 -0
  173. package/test/unit/bin/build-from-local.test.ts +71 -0
  174. package/test/unit/bin/build.test.ts +279 -3
  175. package/test/unit/bin/compiled-cli.ts +17 -0
  176. package/test/unit/bin/diff-changes.test.ts +229 -85
  177. package/test/unit/bin/diff.test.ts +25 -28
  178. package/test/unit/bin/git.test.ts +85 -176
  179. package/test/unit/bin/slack.test.ts +84 -0
  180. package/test/unit/bin/stdin-input.test.ts +61 -0
  181. package/test/unit/github-scripts/mask-config-secrets.test.ts +61 -0
  182. package/test/unit/github-scripts/run-with-apify-tokens.test.ts +115 -0
  183. package/test/unit/run-checks.test.ts +76 -0
  184. package/test/unit/run-test-result.test.ts +44 -0
  185. package/test/unit/should-built-and-test.test.ts +46 -39
  186. package/.github/scripts/check-package-version-bump.mjs +0 -74
  187. package/.github/workflows-package-version +0 -1
@@ -1,14 +1,18 @@
1
+ import picomatch from 'picomatch';
2
+
1
3
  import { isCosmeticOnlyJsonSchemaChange } from './diff-json-schema.js';
2
4
  import { type DockerIgnoreMatcher, loadDockerIgnore } from './dockerignore.js';
3
5
  import { logger } from './logger.js';
4
- import type { ActorConfig, Commit } from './types.js';
6
+ import type { ActorConfig, GitRange } from './types.js';
5
7
  import { type RelativeDir, RelativeFile } from './utils/path/repo-relative.js';
6
8
 
7
9
  interface ShouldBuildAndTestOptions {
8
10
  filepathsChanged: string[];
9
11
  actorConfigs: ActorConfig[];
10
- isLatest?: boolean;
11
- commits: Commit[];
12
+ /** The range the files changed in, to compare `.actor` JSON files across. */
13
+ range: GitRange;
14
+ // Glob relative to the repo root. Matching files count as changes even when .dockerignore lists them.
15
+ testFilesGlob?: string;
12
16
  }
13
17
 
14
18
  const IGNORED_TOP_LEVEL_FILES = [
@@ -41,7 +45,7 @@ type FileChangeForActor =
41
45
  * 2. Context matching (actorConfig.contextPaths) → outside-context if no match
42
46
  * 3. Hardcoded ignore list, checked against the path hoisted relative to the matched context entry → ignored
43
47
  * 4. .dockerignore filtering (patterns relative to dockerContextDir), skipped for the actor's own `.actor/`
44
- * dir → ignored if matched
48
+ * dir and for test files → ignored if matched
45
49
  * 5. README.md by filename → cosmetic if inside the actor's own folder, otherwise ignored
46
50
  * 6. .json inside the actor's own `.actor/` dir with only cosmetic schema diffs → cosmetic (semantically verified)
47
51
  * 7. Everything else → functional
@@ -49,8 +53,9 @@ type FileChangeForActor =
49
53
  const classifyFileChange = async (
50
54
  changedFile: RelativeFile,
51
55
  actorConfig: ActorConfig,
52
- commits: Commit[],
56
+ range: GitRange,
53
57
  dockerIgnoreMatcher: DockerIgnoreMatcher,
58
+ isTestFile: (filePath: string) => boolean,
54
59
  ): Promise<FileChangeForActor> => {
55
60
  const lowercaseFilePath = changedFile.path.toLowerCase();
56
61
 
@@ -75,7 +80,8 @@ const classifyFileChange = async (
75
80
  // .actor/ can legitimately be listed in .dockerignore (the Apify platform evaluates it before
76
81
  // the Docker build, so excluding it from the build context is a valid caching optimization) —
77
82
  // that shouldn't cause changes to .actor/ itself to be ignored here.
78
- if (!isUnderActorDotDir && dockerIgnoreMatcher(changedFile.path)) {
83
+ // Test files are often in .dockerignore too, but a change to them must still build and test.
84
+ if (!isUnderActorDotDir && !isTestFile(changedFile.path) && dockerIgnoreMatcher(changedFile.path)) {
79
85
  return { impact: 'ignored' };
80
86
  }
81
87
 
@@ -86,7 +92,7 @@ const classifyFileChange = async (
86
92
  }
87
93
 
88
94
  if (lowercaseFilePath.endsWith('.json') && isUnderActorDotDir) {
89
- const isCosmetic = await isCosmeticOnlyJsonSchemaChange(commits, changedFile.path);
95
+ const isCosmetic = await isCosmeticOnlyJsonSchemaChange(range, changedFile.path);
90
96
  if (isCosmetic) return { impact: 'cosmetic', semanticallyVerified: true };
91
97
  }
92
98
 
@@ -106,6 +112,7 @@ const isExcludedBySibling = (filePath: RelativeFile, actor: ActorConfig, allActo
106
112
  type ActorChangeEntry = {
107
113
  actorConfig: ActorConfig;
108
114
  files: string[];
115
+ hasFunctionalChange: boolean;
109
116
  };
110
117
 
111
118
  type ChangeGroup = { actors: string[]; files: string[] };
@@ -158,13 +165,23 @@ const logChangeGroups = (groups: ChangeGroup[]): void => {
158
165
  }
159
166
  };
160
167
 
168
+ export interface ChangedActors {
169
+ /** Actors with at least one functional change. They need a new build and tests. */
170
+ functional: ActorConfig[];
171
+ /** Actors whose changes are all cosmetic. They only need a new build when released. */
172
+ cosmetic: ActorConfig[];
173
+ }
174
+
175
+ /** Finds the Actors the changed files belong to, split by the most impactful change each Actor has. */
161
176
  export const getChangedActors = async ({
162
177
  filepathsChanged,
163
178
  actorConfigs,
164
- isLatest = false,
165
- commits,
166
- }: ShouldBuildAndTestOptions): Promise<ActorConfig[]> => {
179
+ range,
180
+ testFilesGlob,
181
+ }: ShouldBuildAndTestOptions): Promise<ChangedActors> => {
167
182
  const actorsChangedMap = new Map<string, ActorChangeEntry>();
183
+ const testFilesInContext = new Set<string>();
184
+ const isTestFile = testFilesGlob ? picomatch(testFilesGlob, { nocase: true, dot: true }) : () => false;
168
185
 
169
186
  for (const actorConfig of actorConfigs) {
170
187
  const dockerIgnoreMatcher = loadDockerIgnore(actorConfig.dockerContextDir);
@@ -176,31 +193,44 @@ export const getChangedActors = async ({
176
193
  continue;
177
194
  }
178
195
 
179
- const change = await classifyFileChange(filePath, actorConfig, commits, dockerIgnoreMatcher);
196
+ const change = await classifyFileChange(filePath, actorConfig, range, dockerIgnoreMatcher, isTestFile);
197
+ if (change.impact !== 'outside-context' && isTestFile(originalFilePath)) {
198
+ testFilesInContext.add(originalFilePath);
199
+ }
180
200
 
181
201
  if (change.impact === 'ignored' || change.impact === 'outside-context') continue;
182
- if (change.impact === 'cosmetic' && !isLatest) continue;
183
202
 
184
- const entry = actorsChangedMap.get(actorConfig.folder.path) ?? { actorConfig, files: [] };
203
+ const entry = actorsChangedMap.get(actorConfig.folder.path) ?? {
204
+ actorConfig,
205
+ files: [],
206
+ hasFunctionalChange: false,
207
+ };
185
208
  entry.files.push(originalFilePath);
209
+ entry.hasFunctionalChange ||= change.impact === 'functional';
186
210
  actorsChangedMap.set(actorConfig.folder.path, entry);
187
211
  }
188
212
  }
189
213
 
190
- const actorsChanged = Array.from(actorsChangedMap.values()).map((entry) => entry.actorConfig);
191
-
192
214
  // Log changes grouped by actor set, so changes shared across actors are logged once
193
215
  // instead of being repeated per actor.
194
- const fileToActors = buildFileToActorsMap(actorsChangedMap);
195
- const groups = groupFilesByActorSet(fileToActors);
196
- logChangeGroups(groups);
197
-
198
- if (actorsChanged.length > 0) {
199
- const actors = actorsChanged.map((config) => config.actorFullName);
200
- logger.info(`[DIFF]: Actors to be built and tested: ${actors.join(', ')}`);
201
- } else {
202
- logger.info(`[DIFF]: No relevant files changed, skipping builds and tests`);
216
+ logChangeGroups(groupFilesByActorSet(buildFileToActorsMap(actorsChangedMap)));
217
+
218
+ for (const testFile of filepathsChanged.filter(
219
+ (filePath) => isTestFile(filePath) && !testFilesInContext.has(filePath),
220
+ )) {
221
+ logger.warn(
222
+ `[DIFF]: Test file ${testFile} is outside every Actor's context, so changing only it builds and tests nothing. ` +
223
+ `Add its folder to "overrideActorContext" of the Actors it tests.`,
224
+ );
203
225
  }
204
226
 
205
- return actorsChanged;
227
+ const entries = Array.from(actorsChangedMap.values());
228
+ const functional = entries.filter((entry) => entry.hasFunctionalChange).map((entry) => entry.actorConfig);
229
+ const cosmetic = entries.filter((entry) => !entry.hasFunctionalChange).map((entry) => entry.actorConfig);
230
+
231
+ const names = (configs: ActorConfig[]) => configs.map((config) => config.actorFullName).join(', ') || 'none';
232
+ logger.info(`[DIFF]: Actors with functional changes: ${names(functional)}`);
233
+ logger.info(`[DIFF]: Actors with only cosmetic changes: ${names(cosmetic)}`);
234
+
235
+ return { functional, cosmetic };
206
236
  };
@@ -1,5 +1,5 @@
1
1
  import { logger } from './logger.js';
2
- import type { Commit } from './types.js';
2
+ import type { GitRange } from './types.js';
3
3
  import { runGitCommand } from './utils.js';
4
4
 
5
5
  const COSMETIC_JSON_FIELD_NAMES = new Set([
@@ -29,11 +29,10 @@ const isCosmeticObjectChange = (oldVal: unknown, newVal: unknown, currentKey?: s
29
29
  * Returns true if the two JSON strings differ only in cosmetic fields
30
30
  * (title, description, example, enumTitles, sectionCaption, sectionDescription).
31
31
  */
32
- export const isCosmeticOnlyJsonSchemaChange = async (commits: Commit[], changedFilepath: string): Promise<boolean> => {
33
- // TODO: validate this is the right commit range
34
- const oldRef = `${commits[0].sha}~`;
35
- const newRef = commits[commits.length - 1].sha;
36
-
32
+ export const isCosmeticOnlyJsonSchemaChange = async (
33
+ { fromRef: oldRef, toRef: newRef }: GitRange,
34
+ changedFilepath: string,
35
+ ): Promise<boolean> => {
37
36
  const [oldJson, newJson] = await Promise.all([
38
37
  runGitCommand(['show', `${oldRef}:${changedFilepath}`])
39
38
  .then(JSON.parse)
package/bin/git.ts CHANGED
@@ -5,80 +5,56 @@ import { GitCommandError, runGitCommand } from './utils.js';
5
5
  export const GIT_FORMAT_SEPARATOR = '»¦«';
6
6
  const GIT_LOG_FORMAT = ['%H', '%aN<%aE>', '%aD', '%s'].join(GIT_FORMAT_SEPARATOR);
7
7
 
8
+ // Lists the changed paths. With rename detection (on by default) a moved file is listed only under its new path,
9
+ // so the Actor whose context it left wouldn't count as changed. --no-renames lists it as deleted and added instead.
10
+ const LIST_CHANGED_PATHS = ['--name-only', '--no-renames'];
11
+
8
12
  /**
9
- * Gets the list of changed files between the given commits (inclusive).
13
+ * Gets the list of changed files between the given commits (inclusive), so it diffs from the parent of the oldest.
10
14
  */
11
15
  export const getChangedFiles = async (commits: Commit[]) => {
12
- // getCommits never returns an empty list (the rerun check returns all commits when the base commit
13
- // is the branch HEAD, and an empty git range throws when parsing), so this signals a programmer error
16
+ // getCommits never returns an empty list (it throws on an empty range, and the rerun check returns all
17
+ // commits when the base commit is the branch HEAD), so this signals a programmer error
14
18
  if (commits.length === 0) {
15
19
  throw new Error('Cannot get changed files: the commit list is empty. This should never happen.');
16
20
  }
17
21
 
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' },
21
- );
22
-
23
- const changedFiles = changedFilesString.split('\n').filter(Boolean);
24
- logger.info(`Changed files (up to 50): ${changedFiles.slice(0, 50).join(', ')}`);
25
- return changedFiles;
22
+ return getChangedFilesBetween(`${commits[0].sha}~`, commits[commits.length - 1].sha);
26
23
  };
27
24
 
28
25
  /**
29
- * Returns true if the branch contains a merge commit whose parent is reachable from targetBranch
30
- * (i.e. a genuine "merge from target" commit, not a merge of some unrelated branch).
31
- * Uses the full targetBranch..sourceBranch range, ignoring baseCommit.
26
+ * Files that differ between two commits, i.e. changed by the commits in the range (fromRef, toRef]. fromRef's own
27
+ * changes are not included: to include them, pass its parent, `${sha}~` (as getChangedFiles does).
28
+ * Diffing the endpoints, rather than walking a commit list, is what keeps merges right: the oldest commit of a
29
+ * merged branch can predate fromRef, and its changes are already in fromRef.
32
30
  */
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
- )
39
- .split('\n')
40
- .filter(Boolean);
31
+ export const getChangedFilesBetween = async (fromRef: string, toRef: string) => {
32
+ const changedFilesString = await runGitCommand(['diff', ...LIST_CHANGED_PATHS, `${fromRef}..${toRef}`], {
33
+ operation: 'get the list of changed files between commits',
34
+ });
41
35
 
42
- for (const sha of mergeShas) {
43
- const parents = (
44
- await runGitCommand(['log', '-1', '--pretty=format:%P', sha], {
45
- operation: 'get parents of a merge commit',
46
- })
47
- ).split(' ');
48
- for (const parent of parents) {
49
- // git merge-base A B outputs the common ancestor.
50
- // If that equals A, then A is an ancestor of B (i.e. parent is reachable from 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
- }
63
- if (mergeBase === parent) {
64
- return true;
65
- }
66
- }
67
- }
68
- return false;
36
+ const changedFiles = changedFilesString.split('\n').filter(Boolean);
37
+ logger.info(`Changed files (up to 50): ${changedFiles.slice(0, 50).join(', ')}`);
38
+ return changedFiles;
69
39
  };
70
40
 
71
41
  /**
72
- * Returns all files touched by non-merge commits on the branch (full history, ignoring baseCommit).
73
- * Used to check whether the branch itself has any functional changes, independent of what master merged in.
42
+ * The commit `git diff base...head` compares against: where `head` forked from `base`, or the last commit of `base`
43
+ * that `head` merged in. The diff from it holds only what `head` itself changed, like GitHub's "Files changed" tab.
44
+ * With criss-cross merges there are several such commits and git picks one.
74
45
  */
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
- );
80
- return output.split('\n').filter(Boolean);
81
- };
46
+ export const getMergeBase = async (base: string, head: string): Promise<string> =>
47
+ runGitCommand(['merge-base', base, head], { operation: `find where ${head} forked from ${base}` });
48
+
49
+ /** Resolves a ref to a commit, or undefined when it isn't in the local history (e.g. it was force-pushed away). */
50
+ export const findCommitSha = async (ref: string): Promise<string | undefined> =>
51
+ runGitCommand(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`], {
52
+ operation: `look up ${ref} in local Git history`,
53
+ }).catch((error: unknown) => {
54
+ // --quiet suppresses Git's error text for a missing commit, but Git still exits with code 1
55
+ if (error instanceof GitCommandError && error.exitCode === 1) return undefined;
56
+ throw error;
57
+ });
82
58
 
83
59
  const SHA_REGEX = /^[0-9a-f]{40}$/i;
84
60
 
@@ -103,13 +79,16 @@ export const parseBaseCommit = (shaOrCommit: string | undefined): string | undef
103
79
  return sha;
104
80
  };
105
81
 
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');
112
- const commits = commitsStrings.map((commitString) => parseCommit(commitString));
82
+ /**
83
+ * Commits reachable from toRef but not from fromRef, oldest first: the range (fromRef, toRef], so fromRef itself is
84
+ * not included. To include it, pass its parent, `${sha}~`.
85
+ */
86
+ export const getCommitsBetween = async (fromRef: string, toRef: string): Promise<Commit[]> => {
87
+ const output = await runGitCommand(['log', `--pretty=format:${GIT_LOG_FORMAT}`, `${fromRef}..${toRef}`], {
88
+ operation: 'list commits reachable from the source ref but absent from the target ref',
89
+ });
90
+ // An empty range (e.g. `x..x`) prints nothing
91
+ const commits = output.split('\n').filter(Boolean).map(parseCommit);
113
92
  commits.reverse();
114
93
  return commits;
115
94
  };
@@ -124,7 +103,10 @@ export const getCommits = async ({
124
103
  baseCommit,
125
104
  }: Pick<Config, 'sourceBranch' | 'targetBranch' | 'baseCommit'>): Promise<Commit[]> => {
126
105
  const baseCommitSha = parseBaseCommit(baseCommit);
127
- const commits = await fetchAllBranchCommits(sourceBranch, targetBranch);
106
+ const commits = await getCommitsBetween(targetBranch, sourceBranch);
107
+ if (commits.length === 0) {
108
+ throw new Error(`${sourceBranch} has no commits that are not on ${targetBranch}.`);
109
+ }
128
110
 
129
111
  // The last validated (base) commit being the branch HEAD means nothing new was pushed since the last
130
112
  // validation — the dev reran the workflow (or force-pushed to the same state) to trigger a clean test
@@ -169,21 +151,25 @@ export const parseCommit = (commitString: string): Commit => {
169
151
  };
170
152
 
171
153
  /**
172
- * Returns the currently checked-out branch. Release builds point the Actor version at this branch,
154
+ * Returns the currently checked-out branch. Builds point the Actor version at this branch,
173
155
  * so a detached HEAD (no branch to point at) is an error rather than a guess.
174
156
  */
175
157
  export const getCurrentBranch = async (): Promise<string> => {
176
158
  const branch = await runGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], {
177
- operation: 'determine the current branch for release',
159
+ operation: 'determine the checked-out branch',
178
160
  });
179
161
  if (branch === 'HEAD') {
180
162
  throw new Error(
181
- 'Cannot determine the branch to release: HEAD is detached. Check out the branch you want to release.',
163
+ 'Cannot determine the branch to build from: HEAD is detached. Check out the branch you want to build.',
182
164
  );
183
165
  }
184
166
  return branch;
185
167
  };
186
168
 
169
+ /** Resolves a ref (branch, remote branch, SHA, HEAD, ...) to the full SHA of the commit it points to. */
170
+ export const resolveCommitSha = async (ref: string): Promise<string> =>
171
+ runGitCommand(['rev-parse', '--verify', `${ref}^{commit}`], { operation: `resolve ${ref} to a commit` });
172
+
187
173
  /**
188
174
  * Reads the repository URL from the `origin` remote, rewritten to the SSH form the Apify platform
189
175
  * uses for Git repo sources, e.g. git@github.com:apify-store/google-maps
@@ -236,15 +222,15 @@ const ZERO_SHA_REGEX = /^0{40}$/;
236
222
  * Unlike the PR path (getCommits), there is no lenient fallback. Falling back to "everything"
237
223
  * would rebuild the latest build of every Actor and post it to Slack, so every problem is an error.
238
224
  */
239
- export const resolveReleaseBaseCommit = async (baseCommit: string): Promise<string> => {
225
+ export const resolveReleaseBaseCommit = async (baseCommit: string, head = 'HEAD'): Promise<string> => {
240
226
  const sha = parseBaseCommit(baseCommit);
241
227
  if (!sha) {
242
- throw new Error('--base-commit is required for release. See the README section "Releasing Actors".');
228
+ throw new Error('A base commit is required for release. See the "Releasing Actors" section of docs/cli.md.');
243
229
  }
244
230
  if (ZERO_SHA_REGEX.test(sha)) {
245
231
  throw new Error(
246
232
  `Base commit is ${sha}, which means the branch was just created and there is no previous release to diff against. ` +
247
- `Rerun with --base-commit set to the last commit before the changes you want to release.`,
233
+ `Rerun with the base commit set to the last commit before the changes you want to release.`,
248
234
  );
249
235
  }
250
236
  // --quiet suppresses Git's error text for a missing commit, but Git still exits with code 1.
@@ -261,16 +247,16 @@ export const resolveReleaseBaseCommit = async (baseCommit: string): Promise<stri
261
247
  );
262
248
  }
263
249
  // git merge-base A B outputs the common ancestor. If that equals A, then A is an ancestor of B.
264
- const mergeBase = await runGitCommand(['merge-base', verifiedSha, 'HEAD'], {
265
- operation: 'check whether the release base commit is an ancestor of HEAD',
250
+ const mergeBase = await runGitCommand(['merge-base', verifiedSha, head], {
251
+ operation: `check whether the release base commit is an ancestor of ${head}`,
266
252
  }).catch((error: unknown) => {
267
253
  if (error instanceof GitCommandError && error.exitCode === 1) return '';
268
254
  throw error;
269
255
  });
270
256
  if (mergeBase !== sha) {
271
257
  throw new Error(
272
- `Base commit ${sha} is not an ancestor of HEAD, most likely because the branch was force-pushed. ` +
273
- `The changed files cannot be determined reliably. Rerun with --base-commit set to an ancestor of HEAD ` +
258
+ `Base commit ${sha} is not an ancestor of ${head}, most likely because the branch was force-pushed. ` +
259
+ `The changed files cannot be determined reliably. Rerun with the base commit set to an ancestor of ${head} ` +
274
260
  `from before the changes you want to release.`,
275
261
  );
276
262
  }
@@ -279,12 +265,16 @@ export const resolveReleaseBaseCommit = async (baseCommit: string): Promise<stri
279
265
 
280
266
  const CHANGELOG_PATH = 'CHANGELOG.md';
281
267
 
282
- /** Returns the lines added to the root CHANGELOG.md between baseSha and HEAD, or null if it didn't change. */
283
- const getChangelogAdditions = async (baseSha: string, changedFiles: string[]): Promise<string | null> => {
268
+ /** Returns the lines added to the root CHANGELOG.md between fromRef and toRef, or null if it didn't change. */
269
+ export const getChangelogAdditions = async (
270
+ fromRef: string,
271
+ toRef: string,
272
+ changedFiles: string[],
273
+ ): Promise<string | null> => {
284
274
  if (!changedFiles.includes(CHANGELOG_PATH)) {
285
275
  return null;
286
276
  }
287
- const diff = await runGitCommand(['diff', baseSha, 'HEAD', '--', CHANGELOG_PATH], {
277
+ const diff = await runGitCommand(['diff', fromRef, toRef, '--', CHANGELOG_PATH], {
288
278
  operation: 'read changelog changes since the last release',
289
279
  });
290
280
 
@@ -307,23 +297,3 @@ const getChangelogAdditions = async (baseSha: string, changedFiles: string[]): P
307
297
  }
308
298
  return added.join('\n').trim();
309
299
  };
310
-
311
- /**
312
- * Everything that changed since the last release (baseSha, exclusive) up to HEAD, or null when there
313
- * are no new commits. Changed files come from diffing the range directly instead of from the commit
314
- * list: with a merge commit, the oldest commit of the merged branch can be older than baseSha, and
315
- * diffing from its parent would pull in already-released changes.
316
- */
317
- export const getReleaseChanges = async (baseSha: string) => {
318
- if (
319
- (await runGitCommand(['rev-parse', 'HEAD'], { operation: 'identify the current release commit' })) === baseSha
320
- ) {
321
- return null;
322
- }
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);
328
- return { commits, changedFiles, changelog };
329
- };