apify-test-tools 0.10.2-beta.2 → 0.10.2-beta.3

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 (40) hide show
  1. package/.github/cli/apify-test-tools.mjs +124 -125
  2. package/CHANGELOG.md +1 -0
  3. package/README.md +55 -0
  4. package/bin/affected-actors.ts +166 -0
  5. package/bin/diff-changes.ts +25 -17
  6. package/bin/git.ts +52 -41
  7. package/bin/main.ts +43 -73
  8. package/dist/bin/affected-actors.d.ts +50 -0
  9. package/dist/bin/affected-actors.d.ts.map +1 -0
  10. package/dist/bin/affected-actors.js +89 -0
  11. package/dist/bin/affected-actors.js.map +1 -0
  12. package/dist/bin/diff-changes.d.ts +8 -2
  13. package/dist/bin/diff-changes.d.ts.map +1 -1
  14. package/dist/bin/diff-changes.js +16 -16
  15. package/dist/bin/diff-changes.js.map +1 -1
  16. package/dist/bin/git.d.ts +23 -13
  17. package/dist/bin/git.d.ts.map +1 -1
  18. package/dist/bin/git.js +44 -32
  19. package/dist/bin/git.js.map +1 -1
  20. package/dist/bin/main.js +39 -53
  21. package/dist/bin/main.js.map +1 -1
  22. package/dist/test/bundle/cli-bundle.test.js +9 -2
  23. package/dist/test/bundle/cli-bundle.test.js.map +1 -1
  24. package/dist/test/unit/bin/affected-actors.test.d.ts +2 -0
  25. package/dist/test/unit/bin/affected-actors.test.d.ts.map +1 -0
  26. package/dist/test/unit/bin/affected-actors.test.js +160 -0
  27. package/dist/test/unit/bin/affected-actors.test.js.map +1 -0
  28. package/dist/test/unit/bin/diff-changes.test.js +78 -49
  29. package/dist/test/unit/bin/diff-changes.test.js.map +1 -1
  30. package/dist/test/unit/bin/git.test.js +50 -53
  31. package/dist/test/unit/bin/git.test.js.map +1 -1
  32. package/dist/test/unit/should-built-and-test.test.js +23 -17
  33. package/dist/test/unit/should-built-and-test.test.js.map +1 -1
  34. package/dist/tsconfig.tsbuildinfo +1 -1
  35. package/package.json +1 -1
  36. package/test/bundle/cli-bundle.test.ts +9 -2
  37. package/test/unit/bin/affected-actors.test.ts +190 -0
  38. package/test/unit/bin/diff-changes.test.ts +87 -49
  39. package/test/unit/bin/git.test.ts +56 -53
  40. package/test/unit/should-built-and-test.test.ts +27 -17
package/CHANGELOG.md CHANGED
@@ -8,6 +8,7 @@ All notable changes to this project will be documented in this file.
8
8
  ### 🚀 Features
9
9
 
10
10
  - **ci:** Ship the workflows' CLI as a bundle in the action instead of installing it ([#200](https://github.com/apify/apify-test-tools/pull/200)) ([a0ef975](https://github.com/apify/apify-test-tools/commit/a0ef9756123a30a8210590b080a2a289c6a9c17c)) by [@metalwarrior665](https://github.com/metalwarrior665)
11
+ - [**breaking**] Revamp get-affected-actors to be a core logic command ([#190](https://github.com/apify/apify-test-tools/pull/190)) ([ea5f244](https://github.com/apify/apify-test-tools/commit/ea5f244cfd42bf3fa9ffd38cd2ad499dde2e83f8)) by [@metalwarrior665](https://github.com/metalwarrior665)
11
12
 
12
13
  ### 🐛 Bug Fixes
13
14
 
package/README.md CHANGED
@@ -660,6 +660,61 @@ TESTER_APIFY_TOKEN=apify_api_yyy \
660
660
  npx vitest --run --maxConcurrency 20 --fileParallelism=true --maxWorkers 100 test/platform
661
661
  ```
662
662
 
663
+ ### Finding affected Actors (`get-affected-actors`)
664
+
665
+ `get-affected-actors` lists the Actors whose context changed in a commit range, using the rules in
666
+ [Change detection](#change-detection). It only reads git and the config file, so it needs no tokens.
667
+ The range always ends at the checked-out `HEAD`, so check out the branch you want to inspect first.
668
+
669
+ ```bash
670
+ # A PR: the branch's own commits, after its last validated commit
671
+ npx apify-test-tools get-affected-actors --base origin/master --since <sha>
672
+
673
+ # A release: everything after the last released commit
674
+ npx apify-test-tools get-affected-actors --since <sha>
675
+ ```
676
+
677
+ | Flag | Default | Meaning |
678
+ | ------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
679
+ | `--base` | | Only commits on `HEAD` that are not on this branch count |
680
+ | `--since` | | Only commits after this one count (see below) |
681
+ | `--test-files-glob` | | Test files (glob from the repo root) that count as changes even when `.dockerignore` lists them, see [Change detection](#change-detection) |
682
+ | `--actors` | all | Only report these Actors |
683
+ | `--ignore` | none | Never report these Actors |
684
+
685
+ Pass `--base`, `--since`, or both:
686
+
687
+ - **`--base` only** inspects the whole branch. If the branch merged `--base` in and has no functional changes
688
+ of its own, no Actor is reported as functional, since `--base` is already validated (and none as cosmetic
689
+ either, if the branch has no changes of its own at all).
690
+ - **`--base` and `--since`** inspects the branch after its last validated commit. If `--since` is not on the
691
+ branch (force-push), or it is the head (a rerun), the whole branch is inspected instead.
692
+ - **`--since` only** inspects everything after it up to `HEAD`. It must be an ancestor of `HEAD` and fails
693
+ otherwise, exactly like [`--base-commit` of `release`](#--base-commit). If `HEAD` is `--since`, nothing is
694
+ reported.
695
+
696
+ The output is a JSON document:
697
+
698
+ ```json
699
+ {
700
+ "version": 1,
701
+ "ref": "my-branch",
702
+ "head": "<sha of HEAD>",
703
+ "commits": [{ "sha": "…", "author": "…", "date": "…", "message": "…" }],
704
+ "changedFiles": ["src/main.ts", "CHANGELOG.md"],
705
+ "changelog": "Lines added to CHANGELOG.md, or null",
706
+ "functionallyChangedActors": [{ "actorFullName": "john.doe/my-actor" }],
707
+ "cosmeticallyChangedActors": [{ "actorFullName": "john.doe/other-actor" }]
708
+ }
709
+ ```
710
+
711
+ Each changed Actor is in exactly one of the lists. `functionallyChangedActors` have at least one functional change, so
712
+ they need a new build and tests. `cosmeticallyChangedActors` only have cosmetic changes (see
713
+ [Change detection](#change-detection)), so they need a new build only when released: `build` builds
714
+ `functionallyChangedActors`, `release` builds both.
715
+
716
+ `ref` is the checked-out branch, or `null` for a detached HEAD. `build` and `release` use the same detection internally.
717
+
663
718
  ### Releasing Actors (`release`)
664
719
 
665
720
  `release` builds the `latest` (default) version of every Actor changed since the last release and
@@ -0,0 +1,166 @@
1
+ import { type ChangedActors, getChangedActors } from './diff-changes.js';
2
+ import {
3
+ getBranchOnlyChangedFiles,
4
+ getChangedFilesBetween,
5
+ getChangelogAdditions,
6
+ getCommits,
7
+ getCommitsBetween,
8
+ hasMergeFromTarget,
9
+ resolveBranchName,
10
+ resolveCommitSha,
11
+ resolveReleaseBaseCommit,
12
+ } from './git.js';
13
+ import { logger } from './logger.js';
14
+ import type { ActorConfig, Commit } from './types.js';
15
+ import { readConfigFile } from './utils/config/load-config.js';
16
+
17
+ interface AffectedActorsOptions {
18
+ /** End of the range. */
19
+ head: string;
20
+ /** Only the commits on head that are not on base count, i.e. the branch's own changes. */
21
+ base?: string;
22
+ /**
23
+ * Only changes after this commit count. With `base` it's the last validated commit of the branch and is
24
+ * ignored when it isn't on the branch. Without `base` it must be an ancestor of head (e.g. the last release).
25
+ */
26
+ since?: string;
27
+ /** Glob relative to the repo root. Matching files count as changes even when .dockerignore lists them. */
28
+ testFilesGlob?: string;
29
+ actors: string[];
30
+ ignore: string[];
31
+ }
32
+
33
+ interface AffectedActors {
34
+ headSha: string;
35
+ /** Branch head points to, which is what git builds are made from. Null for a SHA or a detached HEAD. */
36
+ ref: string | null;
37
+ commits: Commit[];
38
+ changedFiles: string[];
39
+ changelog: string | null;
40
+ /** Actors with at least one functional change. They need a new build and tests. */
41
+ functionallyChangedActors: ActorConfig[];
42
+ /** Actors whose changes are all cosmetic (README, CHANGELOG, cosmetic .actor JSON). They only need a release build. */
43
+ cosmeticallyChangedActors: ActorConfig[];
44
+ }
45
+
46
+ interface CommitRange {
47
+ commits: Commit[];
48
+ fromRef: string;
49
+ toRef: string;
50
+ }
51
+
52
+ /** The branch's own commits, after `since` when it is on the branch (see getCommits). */
53
+ const getBranchRange = async (head: string, base: string, since: string | undefined): Promise<CommitRange> => {
54
+ const commits = await getCommits({ sourceBranch: head, targetBranch: base, baseCommit: since });
55
+ return { commits, fromRef: `${commits[0].sha}~`, toRef: commits[commits.length - 1].sha };
56
+ };
57
+
58
+ /** Everything after `since` up to head. Unlike on a branch, `since` must be an ancestor of head. */
59
+ const getRangeSince = async (head: string, headSha: string, since: string): Promise<CommitRange> => {
60
+ const sinceSha = await resolveReleaseBaseCommit(since, head);
61
+ const commits = sinceSha === headSha ? [] : await getCommitsBetween(sinceSha, headSha);
62
+ return { commits, fromRef: sinceSha, toRef: headSha };
63
+ };
64
+
65
+ /**
66
+ * This is an optimization for the common case where a branch only has cosmetic changes but had to merge in
67
+ * functional changes from base (being up-to-date is a CI requirement). Base is already validated, and
68
+ * since the branch has no functional changes of its own, there is nothing new to validate.
69
+ * Exception: if the branch has any functional changes alongside the merge, we must re-test — even
70
+ * individually validated changes can have novel interactions when combined.
71
+ * Returns the Actors the branch's own commits changed, or undefined when it didn't merge base in.
72
+ *
73
+ * TODO: Apply this per Actor instead of to the whole branch. Changes merged in from base only interact with the
74
+ * branch's own changes in Actors whose context both touch, so an Actor that only base changed is already validated
75
+ * and can be skipped even when the branch changes other Actors functionally. Today any functional change of the
76
+ * branch's own keeps every Actor, and the log messages below describe the whole branch.
77
+ */
78
+ const getOwnChangesIfMergedFromBase = async (
79
+ head: string,
80
+ base: string,
81
+ actorConfigs: ActorConfig[],
82
+ testFilesGlob: string | undefined,
83
+ ): Promise<ChangedActors | undefined> => {
84
+ if (!(await hasMergeFromTarget(head, base))) return undefined;
85
+
86
+ logger.info(
87
+ '[MERGE-FROM-TARGET-OPTIMIZATION]: There is merge from target branch, checking if there are no functional changes in our own branch. If so, we can skip tests',
88
+ );
89
+ const own = await getChangedActors({
90
+ filepathsChanged: await getBranchOnlyChangedFiles(head, base),
91
+ actorConfigs,
92
+ // Omit since to get full branch history. Validated functional commits can still interact with merged ones
93
+ commits: await getCommits({ sourceBranch: head, targetBranch: base }),
94
+ testFilesGlob,
95
+ });
96
+ logger.info(
97
+ own.functional.length > 0
98
+ ? `[MERGE-FROM-TARGET-OPTIMIZATION]: Branch has ${own.functional.length} functional changes, cannot optimize, we continue with full check`
99
+ : '[MERGE-FROM-TARGET-OPTIMIZATION]: Branch itself has no functional changes, skipping tests',
100
+ );
101
+ return own;
102
+ };
103
+
104
+ /**
105
+ * Finds the Actors whose context changed in the given range. `base` selects the branch's own commits
106
+ * (a PR), `since` the commits after a known one (a release, or the last validated commit of a PR).
107
+ */
108
+ export const getAffectedActors = async ({
109
+ head,
110
+ base,
111
+ since: sinceOption,
112
+ testFilesGlob,
113
+ actors,
114
+ ignore,
115
+ }: AffectedActorsOptions): Promise<AffectedActors> => {
116
+ // An empty --since (e.g. an empty cache file) means the same as not passing it
117
+ const since = sinceOption || undefined;
118
+ if (!base && !since) {
119
+ throw new Error('Pass --base, --since, or both to select the commits to inspect.');
120
+ }
121
+
122
+ const headSha = await resolveCommitSha(head);
123
+ const { commits, fromRef, toRef } = base
124
+ ? await getBranchRange(head, base, since)
125
+ : await getRangeSince(head, headSha, since!);
126
+ const actorConfigs = await readConfigFile({ actors, ignore });
127
+
128
+ // What the branch only merged in from base is already validated. Without functional changes of its own
129
+ // the branch has nothing to test, and without any changes of its own nothing to release either.
130
+ const own = base ? await getOwnChangesIfMergedFromBase(head, base, actorConfigs, testFilesGlob) : undefined;
131
+ const changedFiles = await getChangedFilesBetween(fromRef, toRef);
132
+ const changed = await getChangedActors({ filepathsChanged: changedFiles, actorConfigs, commits, testFilesGlob });
133
+
134
+ return {
135
+ headSha,
136
+ ref: await resolveBranchName(head),
137
+ commits,
138
+ changedFiles,
139
+ changelog: await getChangelogAdditions(fromRef, toRef, changedFiles),
140
+ functionallyChangedActors: own && own.functional.length === 0 ? [] : changed.functional,
141
+ cosmeticallyChangedActors: own && own.functional.length + own.cosmetic.length === 0 ? [] : changed.cosmetic,
142
+ };
143
+ };
144
+
145
+ /** The JSON document `get-affected-actors` prints. Versioned, since other commands and workflows consume it. */
146
+ export const toAffectedActorsDocument = ({
147
+ headSha,
148
+ ref,
149
+ commits,
150
+ changedFiles,
151
+ changelog,
152
+ functionallyChangedActors,
153
+ cosmeticallyChangedActors,
154
+ }: AffectedActors) => {
155
+ const names = (configs: ActorConfig[]) => configs.map(({ actorFullName }) => ({ actorFullName }));
156
+ return {
157
+ version: 1,
158
+ ref,
159
+ head: headSha,
160
+ commits,
161
+ changedFiles,
162
+ changelog,
163
+ functionallyChangedActors: names(functionallyChangedActors),
164
+ cosmeticallyChangedActors: names(cosmeticallyChangedActors),
165
+ };
166
+ };
@@ -9,7 +9,6 @@ import { type RelativeDir, RelativeFile } from './utils/path/repo-relative.js';
9
9
  interface ShouldBuildAndTestOptions {
10
10
  filepathsChanged: string[];
11
11
  actorConfigs: ActorConfig[];
12
- isLatest?: boolean;
13
12
  commits: Commit[];
14
13
  // Glob relative to the repo root. Matching files count as changes even when .dockerignore lists them.
15
14
  testFilesGlob?: string;
@@ -112,6 +111,7 @@ const isExcludedBySibling = (filePath: RelativeFile, actor: ActorConfig, allActo
112
111
  type ActorChangeEntry = {
113
112
  actorConfig: ActorConfig;
114
113
  files: string[];
114
+ hasFunctionalChange: boolean;
115
115
  };
116
116
 
117
117
  type ChangeGroup = { actors: string[]; files: string[] };
@@ -164,13 +164,20 @@ const logChangeGroups = (groups: ChangeGroup[]): void => {
164
164
  }
165
165
  };
166
166
 
167
+ export interface ChangedActors {
168
+ /** Actors with at least one functional change. They need a new build and tests. */
169
+ functional: ActorConfig[];
170
+ /** Actors whose changes are all cosmetic. They only need a new build when released. */
171
+ cosmetic: ActorConfig[];
172
+ }
173
+
174
+ /** Finds the Actors the changed files belong to, split by the most impactful change each Actor has. */
167
175
  export const getChangedActors = async ({
168
176
  filepathsChanged,
169
177
  actorConfigs,
170
- isLatest = false,
171
178
  commits,
172
179
  testFilesGlob,
173
- }: ShouldBuildAndTestOptions): Promise<ActorConfig[]> => {
180
+ }: ShouldBuildAndTestOptions): Promise<ChangedActors> => {
174
181
  const actorsChangedMap = new Map<string, ActorChangeEntry>();
175
182
  const testFilesInContext = new Set<string>();
176
183
  const isTestFile = testFilesGlob ? picomatch(testFilesGlob, { nocase: true, dot: true }) : () => false;
@@ -191,21 +198,21 @@ export const getChangedActors = async ({
191
198
  }
192
199
 
193
200
  if (change.impact === 'ignored' || change.impact === 'outside-context') continue;
194
- if (change.impact === 'cosmetic' && !isLatest) continue;
195
201
 
196
- const entry = actorsChangedMap.get(actorConfig.folder.path) ?? { actorConfig, files: [] };
202
+ const entry = actorsChangedMap.get(actorConfig.folder.path) ?? {
203
+ actorConfig,
204
+ files: [],
205
+ hasFunctionalChange: false,
206
+ };
197
207
  entry.files.push(originalFilePath);
208
+ entry.hasFunctionalChange ||= change.impact === 'functional';
198
209
  actorsChangedMap.set(actorConfig.folder.path, entry);
199
210
  }
200
211
  }
201
212
 
202
- const actorsChanged = Array.from(actorsChangedMap.values()).map((entry) => entry.actorConfig);
203
-
204
213
  // Log changes grouped by actor set, so changes shared across actors are logged once
205
214
  // instead of being repeated per actor.
206
- const fileToActors = buildFileToActorsMap(actorsChangedMap);
207
- const groups = groupFilesByActorSet(fileToActors);
208
- logChangeGroups(groups);
215
+ logChangeGroups(groupFilesByActorSet(buildFileToActorsMap(actorsChangedMap)));
209
216
 
210
217
  for (const testFile of filepathsChanged.filter(
211
218
  (filePath) => isTestFile(filePath) && !testFilesInContext.has(filePath),
@@ -216,12 +223,13 @@ export const getChangedActors = async ({
216
223
  );
217
224
  }
218
225
 
219
- if (actorsChanged.length > 0) {
220
- const actors = actorsChanged.map((config) => config.actorFullName);
221
- logger.info(`[DIFF]: Actors to be built and tested: ${actors.join(', ')}`);
222
- } else {
223
- logger.info(`[DIFF]: No relevant files changed, skipping builds and tests`);
224
- }
226
+ const entries = Array.from(actorsChangedMap.values());
227
+ const functional = entries.filter((entry) => entry.hasFunctionalChange).map((entry) => entry.actorConfig);
228
+ const cosmetic = entries.filter((entry) => !entry.hasFunctionalChange).map((entry) => entry.actorConfig);
229
+
230
+ const names = (configs: ActorConfig[]) => configs.map((config) => config.actorFullName).join(', ') || 'none';
231
+ logger.info(`[DIFF]: Actors with functional changes: ${names(functional)}`);
232
+ logger.info(`[DIFF]: Actors with only cosmetic changes: ${names(cosmetic)}`);
225
233
 
226
- return actorsChanged;
234
+ return { functional, cosmetic };
227
235
  };
package/bin/git.ts CHANGED
@@ -6,7 +6,7 @@ export const GIT_FORMAT_SEPARATOR = '»¦«';
6
6
  const GIT_LOG_FORMAT = ['%H', '%aN<%aE>', '%aD', '%s'].join(GIT_FORMAT_SEPARATOR);
7
7
 
8
8
  /**
9
- * Gets the list of changed files between the given commits (inclusive).
9
+ * Gets the list of changed files between the given commits (inclusive), so it diffs from the parent of the oldest.
10
10
  */
11
11
  export const getChangedFiles = async (commits: Commit[]) => {
12
12
  // getCommits never returns an empty list (the rerun check returns all commits when the base commit
@@ -15,10 +15,19 @@ export const getChangedFiles = async (commits: Commit[]) => {
15
15
  throw new Error('Cannot get changed files: the commit list is empty. This should never happen.');
16
16
  }
17
17
 
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
- );
18
+ return getChangedFilesBetween(`${commits[0].sha}~`, commits[commits.length - 1].sha);
19
+ };
20
+
21
+ /**
22
+ * Files that differ between two commits, i.e. changed by the commits in the range (fromRef, toRef]. fromRef's own
23
+ * changes are not included: to include them, pass its parent, `${sha}~` (as getChangedFiles does).
24
+ * Diffing the endpoints, rather than walking a commit list, is what keeps merges right: the oldest commit of a
25
+ * merged branch can predate fromRef, and its changes are already in fromRef.
26
+ */
27
+ export const getChangedFilesBetween = async (fromRef: string, toRef: string) => {
28
+ const changedFilesString = await runGitCommand(['diff', '--name-only', `${fromRef}..${toRef}`], {
29
+ operation: 'get the list of changed files between commits',
30
+ });
22
31
 
23
32
  const changedFiles = changedFilesString.split('\n').filter(Boolean);
24
33
  logger.info(`Changed files (up to 50): ${changedFiles.slice(0, 50).join(', ')}`);
@@ -103,11 +112,14 @@ export const parseBaseCommit = (shaOrCommit: string | undefined): string | undef
103
112
  return sha;
104
113
  };
105
114
 
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
- );
115
+ /**
116
+ * Commits reachable from toRef but not from fromRef, oldest first: the range (fromRef, toRef], so fromRef itself is
117
+ * not included. To include it, pass its parent, `${sha}~`.
118
+ */
119
+ export const getCommitsBetween = async (fromRef: string, toRef: string): Promise<Commit[]> => {
120
+ const output = await runGitCommand(['log', `--pretty=format:${GIT_LOG_FORMAT}`, `${fromRef}..${toRef}`], {
121
+ operation: 'list commits reachable from the source ref but absent from the target ref',
122
+ });
111
123
  const commitsStrings = output.split('\n');
112
124
  const commits = commitsStrings.map((commitString) => parseCommit(commitString));
113
125
  commits.reverse();
@@ -124,7 +136,7 @@ export const getCommits = async ({
124
136
  baseCommit,
125
137
  }: Pick<Config, 'sourceBranch' | 'targetBranch' | 'baseCommit'>): Promise<Commit[]> => {
126
138
  const baseCommitSha = parseBaseCommit(baseCommit);
127
- const commits = await fetchAllBranchCommits(sourceBranch, targetBranch);
139
+ const commits = await getCommitsBetween(targetBranch, sourceBranch);
128
140
 
129
141
  // The last validated (base) commit being the branch HEAD means nothing new was pushed since the last
130
142
  // validation — the dev reran the workflow (or force-pushed to the same state) to trigger a clean test
@@ -184,6 +196,21 @@ export const getCurrentBranch = async (): Promise<string> => {
184
196
  return branch;
185
197
  };
186
198
 
199
+ /** Resolves a ref (branch, remote branch, SHA, HEAD, ...) to the full SHA of the commit it points to. */
200
+ export const resolveCommitSha = async (ref: string): Promise<string> =>
201
+ runGitCommand(['rev-parse', '--verify', `${ref}^{commit}`], { operation: `resolve ${ref} to a commit` });
202
+
203
+ /**
204
+ * Branch name a ref points to, without the remote: `origin/foo` and `foo` both give `foo`, and `HEAD`
205
+ * gives the checked-out branch. Null when there is no branch to point at (a SHA, a tag, a detached HEAD).
206
+ */
207
+ export const resolveBranchName = async (ref: string): Promise<string | null> => {
208
+ const fullName = await runGitCommand(['rev-parse', '--symbolic-full-name', ref], {
209
+ operation: `resolve the branch name of ${ref}`,
210
+ });
211
+ return fullName.match(/^refs\/(?:heads|remotes\/[^/]+)\/(.+)$/)?.[1] ?? null;
212
+ };
213
+
187
214
  /**
188
215
  * Reads the repository URL from the `origin` remote, rewritten to the SSH form the Apify platform
189
216
  * uses for Git repo sources, e.g. git@github.com:apify-store/google-maps
@@ -236,15 +263,15 @@ const ZERO_SHA_REGEX = /^0{40}$/;
236
263
  * Unlike the PR path (getCommits), there is no lenient fallback. Falling back to "everything"
237
264
  * would rebuild the latest build of every Actor and post it to Slack, so every problem is an error.
238
265
  */
239
- export const resolveReleaseBaseCommit = async (baseCommit: string): Promise<string> => {
266
+ export const resolveReleaseBaseCommit = async (baseCommit: string, head = 'HEAD'): Promise<string> => {
240
267
  const sha = parseBaseCommit(baseCommit);
241
268
  if (!sha) {
242
- throw new Error('--base-commit is required for release. See the README section "Releasing Actors".');
269
+ throw new Error('A base commit is required for release. See the README section "Releasing Actors".');
243
270
  }
244
271
  if (ZERO_SHA_REGEX.test(sha)) {
245
272
  throw new Error(
246
273
  `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.`,
274
+ `Rerun with the base commit set to the last commit before the changes you want to release.`,
248
275
  );
249
276
  }
250
277
  // --quiet suppresses Git's error text for a missing commit, but Git still exits with code 1.
@@ -261,16 +288,16 @@ export const resolveReleaseBaseCommit = async (baseCommit: string): Promise<stri
261
288
  );
262
289
  }
263
290
  // 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',
291
+ const mergeBase = await runGitCommand(['merge-base', verifiedSha, head], {
292
+ operation: `check whether the release base commit is an ancestor of ${head}`,
266
293
  }).catch((error: unknown) => {
267
294
  if (error instanceof GitCommandError && error.exitCode === 1) return '';
268
295
  throw error;
269
296
  });
270
297
  if (mergeBase !== sha) {
271
298
  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 ` +
299
+ `Base commit ${sha} is not an ancestor of ${head}, most likely because the branch was force-pushed. ` +
300
+ `The changed files cannot be determined reliably. Rerun with the base commit set to an ancestor of ${head} ` +
274
301
  `from before the changes you want to release.`,
275
302
  );
276
303
  }
@@ -279,12 +306,16 @@ export const resolveReleaseBaseCommit = async (baseCommit: string): Promise<stri
279
306
 
280
307
  const CHANGELOG_PATH = 'CHANGELOG.md';
281
308
 
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> => {
309
+ /** Returns the lines added to the root CHANGELOG.md between fromRef and toRef, or null if it didn't change. */
310
+ export const getChangelogAdditions = async (
311
+ fromRef: string,
312
+ toRef: string,
313
+ changedFiles: string[],
314
+ ): Promise<string | null> => {
284
315
  if (!changedFiles.includes(CHANGELOG_PATH)) {
285
316
  return null;
286
317
  }
287
- const diff = await runGitCommand(['diff', baseSha, 'HEAD', '--', CHANGELOG_PATH], {
318
+ const diff = await runGitCommand(['diff', fromRef, toRef, '--', CHANGELOG_PATH], {
288
319
  operation: 'read changelog changes since the last release',
289
320
  });
290
321
 
@@ -307,23 +338,3 @@ const getChangelogAdditions = async (baseSha: string, changedFiles: string[]): P
307
338
  }
308
339
  return added.join('\n').trim();
309
340
  };
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
- };
package/bin/main.ts CHANGED
@@ -6,25 +6,14 @@ import yargs, { type Options } from 'yargs';
6
6
  // eslint-disable-next-line import/extensions --- With .js, it cannot find types
7
7
  import { hideBin } from 'yargs/helpers';
8
8
 
9
+ import { getAffectedActors, toAffectedActorsDocument } from './affected-actors.js';
9
10
  import { deleteOldBuilds, runBuilds } from './build.js';
10
11
  import { runBuildsFromLocal } from './build-from-local.js';
11
- import { getChangedActors } from './diff-changes.js';
12
- import {
13
- getBranchOnlyChangedFiles,
14
- getChangedFiles,
15
- getCommits,
16
- getCurrentBranch,
17
- getReleaseChanges,
18
- getRepoName,
19
- hasMergeFromTarget,
20
- resolveReleaseBaseCommit,
21
- resolveRepoUrl,
22
- } from './git.js';
12
+ import { getChangedFiles, getCommits, getCurrentBranch, getRepoName, resolveRepoUrl } from './git.js';
23
13
  import { logger, logLevels } from './logger.js';
24
14
  import { writeJson } from './output.js';
25
15
  import { notifyToSlack } from './slack.js';
26
16
  import { reportTestResults } from './test-report.js';
27
- import type { Config } from './types.js';
28
17
  import { readConfigFile } from './utils/config/load-config.js';
29
18
 
30
19
  /**
@@ -64,6 +53,23 @@ const actorSelectionOptions = {
64
53
  ignore: { type: 'string', array: true, default: [] as string[] },
65
54
  } as const satisfies Record<string, Options>;
66
55
 
56
+ /**
57
+ * Commit range for get-affected-actors, which always ends at the checked-out HEAD. `base` and `since` combine:
58
+ * `base` alone is a branch's own commits (a PR), `since` alone is everything after a commit (a release), both is
59
+ * a branch after its last validated commit.
60
+ */
61
+ const affectedRangeOptions = {
62
+ base: { type: 'string', describe: 'Only count commits on HEAD that are not on this branch' },
63
+ since: {
64
+ type: 'string',
65
+ describe: 'Only count commits after this one. Ignored with --base if it is not on the branch',
66
+ },
67
+ 'test-files-glob': {
68
+ type: 'string',
69
+ describe: 'Glob (from the repo root) of test files that count as changes even when .dockerignore lists them',
70
+ },
71
+ } as const satisfies Record<string, Options>;
72
+
67
73
  /**
68
74
  * For commands that change something (builds, deletes, Slack posts). A dry run still does every read and check
69
75
  * the real run does, credentials included, and only skips the writes. So it needs the same tokens, though
@@ -92,48 +98,6 @@ const globalOptions = {
92
98
  },
93
99
  } as const satisfies Record<string, Options>;
94
100
 
95
- const resolveChangedActors = async (config: Config, { isLatest }: { isLatest: boolean }) => {
96
- const actorConfigs = await readConfigFile(config);
97
-
98
- // This is an optimization for the common case where a branch only has cosmetic changes but had to smerge in
99
- // functional changes from master (being up-to-date is a CI requirement). Master is already validated, and
100
- // since the branch has no functional changes of its own, there is nothing new to validate.
101
- // Exception: if the branch has any functional changes alongside the merge, we must re-test — even
102
- // individually validated changes can have novel interactions when combined.
103
- if (await hasMergeFromTarget(config.sourceBranch, config.targetBranch)) {
104
- logger.info(
105
- '[MERGE-FROM-TARGET-OPTIMIZATION]: There is merge from target branch, checking if there are no functional changes in our own branch. If so, we can skip tests',
106
- );
107
- const branchOnlyFiles = await getBranchOnlyChangedFiles(config.sourceBranch, config.targetBranch);
108
- // Omit baseCommit to get full branch history. Validated functional commits can still interact with merged ones
109
- const allBranchCommits = await getCommits({ ...config, baseCommit: undefined });
110
- const branchOnlyActorsChanged = await getChangedActors({
111
- filepathsChanged: branchOnlyFiles,
112
- actorConfigs,
113
- commits: allBranchCommits,
114
- testFilesGlob: config.testFilesGlob,
115
- });
116
- if (branchOnlyActorsChanged.length === 0) {
117
- logger.info('[MERGE-FROM-TARGET-OPTIMIZATION]: Branch itself has no functional changes, skipping tests');
118
- return [];
119
- }
120
- logger.info(
121
- `[MERGE-FROM-TARGET-OPTIMIZATION]: Branch has ${branchOnlyActorsChanged.length} functional changes, cannot optimize, we continue with full check`,
122
- );
123
- }
124
-
125
- // If the optimization doesn't apply, we check all branch commits including merges for full coverage. We don't reuse the merge optimization results because here we can apply baseCommit and check merge commits (they might be functional or just cosmetic)
126
- const commits = await getCommits(config);
127
- const changedFiles = await getChangedFiles(commits);
128
- return getChangedActors({
129
- filepathsChanged: changedFiles,
130
- actorConfigs,
131
- isLatest,
132
- commits,
133
- testFilesGlob: config.testFilesGlob,
134
- });
135
- };
136
-
137
101
  await yargs()
138
102
  .scriptName('public-actors-utils')
139
103
  .options(globalOptions)
@@ -179,11 +143,10 @@ await yargs()
179
143
  )
180
144
  .command(
181
145
  'get-affected-actors',
182
- '',
183
- (y) => y.options(gitRangeOptions).options(actorSelectionOptions),
184
- async (config) => {
185
- const actorsChanged = await resolveChangedActors(config, { isLatest: false });
186
- writeJson(actorsChanged);
146
+ 'Find the Actors whose context changed in a commit range ending at the checked-out HEAD',
147
+ (y) => y.options(affectedRangeOptions).options(actorSelectionOptions),
148
+ async (args) => {
149
+ writeJson(toAffectedActorsDocument(await getAffectedActors({ ...args, head: 'HEAD' })));
187
150
  },
188
151
  )
189
152
  .command(
@@ -212,7 +175,15 @@ await yargs()
212
175
  .options(dryRunOptions)
213
176
  .option('test-files-glob', { type: 'string' }),
214
177
  async (config) => {
215
- const actorsChanged = await resolveChangedActors(config, { isLatest: false });
178
+ // Cosmetic-only changes don't need testing, so they don't need a build either
179
+ const { functionallyChangedActors: actorsChanged } = await getAffectedActors({
180
+ head: config.sourceBranch,
181
+ base: config.targetBranch,
182
+ since: config.baseCommit,
183
+ testFilesGlob: config.testFilesGlob,
184
+ actors: config.actors,
185
+ ignore: config.ignore,
186
+ });
216
187
  const builds = await runBuilds({
217
188
  ...(await resolveRepoUrl(config.repoUrl)),
218
189
  actorConfigs: actorsChanged,
@@ -238,24 +209,23 @@ await yargs()
238
209
  .option('report-slack-channel', { type: 'string' })
239
210
  .option('release-slack-channel', { type: 'string' }),
240
211
  async (args) => {
241
- const baseSha = await resolveReleaseBaseCommit(args.baseCommit);
242
212
  const branch = await getCurrentBranch();
243
- const changes = await getReleaseChanges(baseSha);
244
- if (!changes) {
245
- logger.info(`HEAD is the base commit ${baseSha}, there is nothing new to release`);
213
+ const { commits, changedFiles, changelog, functionallyChangedActors, cosmeticallyChangedActors } =
214
+ await getAffectedActors({
215
+ head: 'HEAD',
216
+ since: args.baseCommit,
217
+ actors: args.actors,
218
+ ignore: args.ignore,
219
+ });
220
+ // A release ships cosmetic changes (README, input schema descriptions, ...) too
221
+ const actorsChanged = [...functionallyChangedActors, ...cosmeticallyChangedActors];
222
+ if (commits.length === 0) {
223
+ logger.info(`HEAD is the base commit ${args.baseCommit}, there is nothing new to release`);
246
224
  return;
247
225
  }
248
- const { commits, changedFiles, changelog } = changes;
249
226
  const { repoUrl, shouldVerifyRepoUrl } = await resolveRepoUrl(args.repoUrl);
250
227
 
251
228
  const isLatest = true;
252
- const actorConfigs = await readConfigFile(args);
253
- const actorsChanged = await getChangedActors({
254
- filepathsChanged: changedFiles,
255
- actorConfigs,
256
- isLatest,
257
- commits,
258
- });
259
229
  const { dryRun, reportSlackChannel, releaseSlackChannel } = args;
260
230
  const builds = await runBuilds({
261
231
  isLatest,