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

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 (144) hide show
  1. package/.claude-plugin/marketplace.json +12 -0
  2. package/.github/workflows/public_platform-tests-claude-investigate-and-fix.yaml +22 -10
  3. package/CHANGELOG.md +7 -0
  4. package/CONTRIBUTING.md +9 -0
  5. package/README.md +6 -7
  6. package/bin/build-from-local.ts +8 -7
  7. package/bin/build.ts +22 -21
  8. package/bin/diff-changes.ts +11 -10
  9. package/bin/diff-json-schema.ts +14 -12
  10. package/bin/git.ts +90 -43
  11. package/bin/logger.ts +23 -0
  12. package/bin/main.ts +49 -35
  13. package/bin/output.ts +6 -0
  14. package/bin/slack.ts +8 -7
  15. package/bin/test-report.ts +22 -21
  16. package/bin/utils/config/actor-json.ts +84 -0
  17. package/bin/utils/config/load-config.ts +46 -46
  18. package/bin/utils/config/parser.ts +33 -28
  19. package/bin/utils/config/structures/base.ts +7 -0
  20. package/bin/utils/json-file.ts +11 -14
  21. package/bin/utils/path/repo-relative.ts +156 -0
  22. package/bin/utils/path/schema.ts +18 -0
  23. package/bin/utils.ts +61 -34
  24. package/dist/bin/build-from-local.d.ts +1 -1
  25. package/dist/bin/build-from-local.d.ts.map +1 -1
  26. package/dist/bin/build-from-local.js +8 -7
  27. package/dist/bin/build-from-local.js.map +1 -1
  28. package/dist/bin/build.d.ts.map +1 -1
  29. package/dist/bin/build.js +22 -21
  30. package/dist/bin/build.js.map +1 -1
  31. package/dist/bin/diff-changes.d.ts +1 -1
  32. package/dist/bin/diff-changes.d.ts.map +1 -1
  33. package/dist/bin/diff-changes.js +9 -8
  34. package/dist/bin/diff-changes.js.map +1 -1
  35. package/dist/bin/diff-json-schema.d.ts +1 -1
  36. package/dist/bin/diff-json-schema.d.ts.map +1 -1
  37. package/dist/bin/diff-json-schema.js +13 -12
  38. package/dist/bin/diff-json-schema.js.map +1 -1
  39. package/dist/bin/git.d.ts +10 -10
  40. package/dist/bin/git.d.ts.map +1 -1
  41. package/dist/bin/git.js +78 -37
  42. package/dist/bin/git.js.map +1 -1
  43. package/dist/bin/logger.d.ts +11 -0
  44. package/dist/bin/logger.d.ts.map +1 -0
  45. package/dist/bin/logger.js +20 -0
  46. package/dist/bin/logger.js.map +1 -0
  47. package/dist/bin/main.d.ts +1 -1
  48. package/dist/bin/main.js +48 -35
  49. package/dist/bin/main.js.map +1 -1
  50. package/dist/bin/output.d.ts +3 -0
  51. package/dist/bin/output.d.ts.map +1 -0
  52. package/dist/bin/output.js +6 -0
  53. package/dist/bin/output.js.map +1 -0
  54. package/dist/bin/slack.d.ts.map +1 -1
  55. package/dist/bin/slack.js +8 -7
  56. package/dist/bin/slack.js.map +1 -1
  57. package/dist/bin/test-report.d.ts.map +1 -1
  58. package/dist/bin/test-report.js +22 -21
  59. package/dist/bin/test-report.js.map +1 -1
  60. package/dist/bin/utils/config/actor-json.d.ts +14 -0
  61. package/dist/bin/utils/config/actor-json.d.ts.map +1 -0
  62. package/dist/bin/utils/config/actor-json.js +60 -0
  63. package/dist/bin/utils/config/actor-json.js.map +1 -0
  64. package/dist/bin/utils/config/load-config.d.ts +12 -2
  65. package/dist/bin/utils/config/load-config.d.ts.map +1 -1
  66. package/dist/bin/utils/config/load-config.js +32 -31
  67. package/dist/bin/utils/config/load-config.js.map +1 -1
  68. package/dist/bin/utils/config/parser.d.ts +4 -12
  69. package/dist/bin/utils/config/parser.d.ts.map +1 -1
  70. package/dist/bin/utils/config/parser.js +24 -23
  71. package/dist/bin/utils/config/parser.js.map +1 -1
  72. package/dist/bin/utils/config/structures/base.d.ts +5 -0
  73. package/dist/bin/utils/config/structures/base.d.ts.map +1 -1
  74. package/dist/bin/utils/config/structures/base.js.map +1 -1
  75. package/dist/bin/utils/json-file.d.ts +3 -2
  76. package/dist/bin/utils/json-file.d.ts.map +1 -1
  77. package/dist/bin/utils/json-file.js +8 -13
  78. package/dist/bin/utils/json-file.js.map +1 -1
  79. package/dist/bin/utils/path/repo-relative.d.ts +35 -0
  80. package/dist/bin/utils/path/repo-relative.d.ts.map +1 -0
  81. package/dist/bin/utils/path/repo-relative.js +137 -0
  82. package/dist/bin/utils/path/repo-relative.js.map +1 -0
  83. package/dist/bin/utils/path/schema.d.ts +7 -0
  84. package/dist/bin/utils/path/schema.d.ts.map +1 -0
  85. package/dist/bin/utils/path/schema.js +16 -0
  86. package/dist/bin/utils/path/schema.js.map +1 -0
  87. package/dist/bin/utils.d.ts +14 -3
  88. package/dist/bin/utils.d.ts.map +1 -1
  89. package/dist/bin/utils.js +52 -29
  90. package/dist/bin/utils.js.map +1 -1
  91. package/dist/test/unit/bin/build-from-local.test.js +3 -39
  92. package/dist/test/unit/bin/build-from-local.test.js.map +1 -1
  93. package/dist/test/unit/bin/diff-changes.test.js +97 -96
  94. package/dist/test/unit/bin/diff-changes.test.js.map +1 -1
  95. package/dist/test/unit/bin/diff.test.js +52 -48
  96. package/dist/test/unit/bin/diff.test.js.map +1 -1
  97. package/dist/test/unit/bin/git-command.test.d.ts +2 -0
  98. package/dist/test/unit/bin/git-command.test.d.ts.map +1 -0
  99. package/dist/test/unit/bin/git-command.test.js +54 -0
  100. package/dist/test/unit/bin/git-command.test.js.map +1 -0
  101. package/dist/test/unit/bin/git.test.js +211 -92
  102. package/dist/test/unit/bin/git.test.js.map +1 -1
  103. package/dist/test/unit/bin/logger.test.d.ts +2 -0
  104. package/dist/test/unit/bin/logger.test.d.ts.map +1 -0
  105. package/dist/test/unit/bin/logger.test.js +44 -0
  106. package/dist/test/unit/bin/logger.test.js.map +1 -0
  107. package/dist/test/unit/bin/logging.test.d.ts +2 -0
  108. package/dist/test/unit/bin/logging.test.d.ts.map +1 -0
  109. package/dist/test/unit/bin/logging.test.js +43 -0
  110. package/dist/test/unit/bin/logging.test.js.map +1 -0
  111. package/dist/test/unit/bin/utils/config/load-config.test.js +47 -25
  112. package/dist/test/unit/bin/utils/config/load-config.test.js.map +1 -1
  113. package/dist/test/unit/bin/utils/config/parser.test.js +64 -19
  114. package/dist/test/unit/bin/utils/config/parser.test.js.map +1 -1
  115. package/dist/test/unit/bin/utils/files.test.js +16 -27
  116. package/dist/test/unit/bin/utils/files.test.js.map +1 -1
  117. package/dist/test/unit/bin/utils/path/repo-relative.test.d.ts +2 -0
  118. package/dist/test/unit/bin/utils/path/repo-relative.test.d.ts.map +1 -0
  119. package/dist/test/unit/bin/utils/path/repo-relative.test.js +196 -0
  120. package/dist/test/unit/bin/utils/path/repo-relative.test.js.map +1 -0
  121. package/dist/test/unit/bin/utils/path/schema.test.d.ts +2 -0
  122. package/dist/test/unit/bin/utils/path/schema.test.d.ts.map +1 -0
  123. package/dist/test/unit/bin/utils/path/schema.test.js +52 -0
  124. package/dist/test/unit/bin/utils/path/schema.test.js.map +1 -0
  125. package/dist/test/unit/should-built-and-test.test.js +40 -38
  126. package/dist/test/unit/should-built-and-test.test.js.map +1 -1
  127. package/dist/tsconfig.tsbuildinfo +1 -1
  128. package/eslint.config.mjs +9 -1
  129. package/package.json +4 -4
  130. package/platform-tests-investigate-and-fix/.claude-plugin/plugin.json +4 -0
  131. package/platform-tests-investigate-and-fix/skills/measure-flakiness/SKILL.md +28 -0
  132. package/test/unit/bin/build-from-local.test.ts +3 -53
  133. package/test/unit/bin/diff-changes.test.ts +97 -96
  134. package/test/unit/bin/diff.test.ts +53 -48
  135. package/test/unit/bin/git-command.test.ts +71 -0
  136. package/test/unit/bin/git.test.ts +246 -89
  137. package/test/unit/bin/logger.test.ts +55 -0
  138. package/test/unit/bin/logging.test.ts +52 -0
  139. package/test/unit/bin/utils/config/load-config.test.ts +63 -26
  140. package/test/unit/bin/utils/config/parser.test.ts +81 -27
  141. package/test/unit/bin/utils/files.test.ts +19 -33
  142. package/test/unit/bin/utils/path/repo-relative.test.ts +250 -0
  143. package/test/unit/bin/utils/path/schema.test.ts +65 -0
  144. package/test/unit/should-built-and-test.test.ts +40 -38
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
  };
package/bin/logger.ts ADDED
@@ -0,0 +1,23 @@
1
+ import process from 'node:process';
2
+ import { formatWithOptions } from 'node:util';
3
+
4
+ export const logLevels = ['debug', 'info', 'warn', 'error', 'silent'] as const;
5
+ export type LogLevel = (typeof logLevels)[number];
6
+
7
+ let currentLevel: LogLevel = 'info';
8
+
9
+ const write = (level: Exclude<LogLevel, 'silent'>, args: unknown[]) => {
10
+ if (logLevels.indexOf(level) < logLevels.indexOf(currentLevel)) return;
11
+ process.stderr.write(`${formatWithOptions({ colors: false }, ...args)}\n`);
12
+ };
13
+
14
+ /** All diagnostics go to stderr, leaving stdout available for machine-readable results. */
15
+ export const logger = {
16
+ setLevel(level: LogLevel): void {
17
+ currentLevel = level;
18
+ },
19
+ debug: (...args: unknown[]): void => write('debug', args),
20
+ info: (...args: unknown[]): void => write('info', args),
21
+ warn: (...args: unknown[]): void => write('warn', args),
22
+ error: (...args: unknown[]): void => write('error', args),
23
+ };
package/bin/main.ts CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env -S node --no-warnings
2
2
 
3
3
  import process from 'node:process';
4
4
 
@@ -20,11 +20,18 @@ import {
20
20
  resolveReleaseBaseCommit,
21
21
  resolveRepoUrl,
22
22
  } from './git.js';
23
+ import { logger, logLevels } from './logger.js';
24
+ import { writeJson } from './output.js';
23
25
  import { notifyToSlack } from './slack.js';
24
26
  import { reportTestResults } from './test-report.js';
25
27
  import type { Config } from './types.js';
26
28
  import { readConfigFile } from './utils/config/load-config.js';
27
29
 
30
+ /**
31
+ * Middlewares to be run before every command execution
32
+ */
33
+ const middlewares = [(args: { logLevel: (typeof logLevels)[number] }) => logger.setLevel(args.logLevel)];
34
+
28
35
  /*
29
36
  * Option groups shared across commands. Each is a plain object passed to `.options()`, so a command's
30
37
  * builder composes them by chaining (`y.options(gitRangeOptions).options(actorSelectionOptions)`)
@@ -64,6 +71,12 @@ const buildOptions = {
64
71
 
65
72
  /** Global flags, available to every command. */
66
73
  const globalOptions = {
74
+ 'log-level': {
75
+ type: 'string',
76
+ choices: logLevels,
77
+ default: 'info',
78
+ describe: 'Minimum diagnostic level written to stderr; silent disables logging',
79
+ },
67
80
  'dry-run': { type: 'boolean', default: false },
68
81
  } as const satisfies Record<string, Options>;
69
82
 
@@ -75,53 +88,54 @@ const resolveChangedActors = async (config: Config, { isLatest }: { isLatest: bo
75
88
  // since the branch has no functional changes of its own, there is nothing new to validate.
76
89
  // Exception: if the branch has any functional changes alongside the merge, we must re-test — even
77
90
  // individually validated changes can have novel interactions when combined.
78
- if (hasMergeFromTarget(config.sourceBranch, config.targetBranch)) {
79
- console.error(
91
+ if (await hasMergeFromTarget(config.sourceBranch, config.targetBranch)) {
92
+ logger.info(
80
93
  '[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',
81
94
  );
82
- const branchOnlyFiles = getBranchOnlyChangedFiles(config.sourceBranch, config.targetBranch);
95
+ const branchOnlyFiles = await getBranchOnlyChangedFiles(config.sourceBranch, config.targetBranch);
83
96
  // Omit baseCommit to get full branch history. Validated functional commits can still interact with merged ones
84
- const allBranchCommits = getCommits({ ...config, baseCommit: undefined });
85
- const branchOnlyActorsChanged = getChangedActors({
97
+ const allBranchCommits = await getCommits({ ...config, baseCommit: undefined });
98
+ const branchOnlyActorsChanged = await getChangedActors({
86
99
  filepathsChanged: branchOnlyFiles,
87
100
  actorConfigs,
88
101
  commits: allBranchCommits,
89
102
  });
90
103
  if (branchOnlyActorsChanged.length === 0) {
91
- console.error('[MERGE-FROM-TARGET-OPTIMIZATION]: Branch itself has no functional changes, skipping tests');
104
+ logger.info('[MERGE-FROM-TARGET-OPTIMIZATION]: Branch itself has no functional changes, skipping tests');
92
105
  return [];
93
106
  }
94
- console.error(
107
+ logger.info(
95
108
  `[MERGE-FROM-TARGET-OPTIMIZATION]: Branch has ${branchOnlyActorsChanged.length} functional changes, cannot optimize, we continue with full check`,
96
109
  );
97
110
  }
98
111
 
99
112
  // 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)
100
- const commits = getCommits(config);
101
- const changedFiles = getChangedFiles(commits);
113
+ const commits = await getCommits(config);
114
+ const changedFiles = await getChangedFiles(commits);
102
115
  return getChangedActors({ filepathsChanged: changedFiles, actorConfigs, isLatest, commits });
103
116
  };
104
117
 
105
118
  await yargs()
106
119
  .scriptName('public-actors-utils')
107
120
  .options(globalOptions)
121
+ .middleware(middlewares)
108
122
  .command(
109
123
  'get-commits',
110
124
  '',
111
125
  (y) => y.options(gitRangeOptions),
112
- (args) => {
113
- const commits = getCommits(args);
114
- console.log(JSON.stringify(commits));
126
+ async (args) => {
127
+ const commits = await getCommits(args);
128
+ writeJson(commits);
115
129
  },
116
130
  )
117
131
  .command(
118
132
  'get-latest-commit',
119
133
  '',
120
134
  (y) => y.options(gitRangeOptions),
121
- (args) => {
122
- const commits = getCommits(args);
135
+ async (args) => {
136
+ const commits = await getCommits(args);
123
137
  if (commits.length > 0) {
124
- console.log(JSON.stringify(commits[commits.length - 1]));
138
+ writeJson(commits[commits.length - 1]);
125
139
  }
126
140
  },
127
141
  )
@@ -129,10 +143,10 @@ await yargs()
129
143
  'get-changed-files',
130
144
  '',
131
145
  (y) => y.options(gitRangeOptions),
132
- (args) => {
133
- const commits = getCommits(args);
134
- const changedFiles = getChangedFiles(commits);
135
- console.log(JSON.stringify(changedFiles));
146
+ async (args) => {
147
+ const commits = await getCommits(args);
148
+ const changedFiles = await getChangedFiles(commits);
149
+ writeJson(changedFiles);
136
150
  },
137
151
  )
138
152
  .command(
@@ -141,7 +155,7 @@ await yargs()
141
155
  (y) => y.options(actorSelectionOptions),
142
156
  async ({ actors, ignore }) => {
143
157
  const actorConfigs = await readConfigFile({ actors, ignore });
144
- console.log(JSON.stringify(actorConfigs));
158
+ writeJson(actorConfigs);
145
159
  },
146
160
  )
147
161
  .command(
@@ -150,7 +164,7 @@ await yargs()
150
164
  (y) => y.options(gitRangeOptions).options(actorSelectionOptions),
151
165
  async (config) => {
152
166
  const actorsChanged = await resolveChangedActors(config, { isLatest: false });
153
- console.log(JSON.stringify(actorsChanged));
167
+ writeJson(actorsChanged);
154
168
  },
155
169
  )
156
170
  .command(
@@ -173,13 +187,13 @@ await yargs()
173
187
  async (config) => {
174
188
  const actorsChanged = await resolveChangedActors(config, { isLatest: false });
175
189
  const builds = await runBuilds({
176
- ...resolveRepoUrl(config.repoUrl),
190
+ ...(await resolveRepoUrl(config.repoUrl)),
177
191
  actorConfigs: actorsChanged,
178
192
  branch: config.sourceBranch.replace('origin/', ''),
179
193
  dryRun: config.dryRun,
180
194
  useDockerCache: config.useDockerCache,
181
195
  });
182
- console.log(JSON.stringify(builds));
196
+ writeJson(builds);
183
197
  },
184
198
  )
185
199
  .command(
@@ -196,19 +210,19 @@ await yargs()
196
210
  .option('report-slack-channel', { type: 'string' })
197
211
  .option('release-slack-channel', { type: 'string' }),
198
212
  async (args) => {
199
- const baseSha = resolveReleaseBaseCommit(args.baseCommit);
200
- const branch = getCurrentBranch();
201
- const changes = getReleaseChanges(baseSha);
213
+ const baseSha = await resolveReleaseBaseCommit(args.baseCommit);
214
+ const branch = await getCurrentBranch();
215
+ const changes = await getReleaseChanges(baseSha);
202
216
  if (!changes) {
203
- console.error(`HEAD is the base commit ${baseSha}, there is nothing new to release`);
217
+ logger.info(`HEAD is the base commit ${baseSha}, there is nothing new to release`);
204
218
  return;
205
219
  }
206
220
  const { commits, changedFiles, changelog } = changes;
207
- const { repoUrl, shouldVerifyRepoUrl } = resolveRepoUrl(args.repoUrl);
221
+ const { repoUrl, shouldVerifyRepoUrl } = await resolveRepoUrl(args.repoUrl);
208
222
 
209
223
  const isLatest = true;
210
224
  const actorConfigs = await readConfigFile(args);
211
- const actorsChanged = getChangedActors({
225
+ const actorsChanged = await getChangedActors({
212
226
  filepathsChanged: changedFiles,
213
227
  actorConfigs,
214
228
  isLatest,
@@ -224,7 +238,7 @@ await yargs()
224
238
  dryRun,
225
239
  useDockerCache: args.useDockerCache,
226
240
  });
227
- console.error(JSON.stringify(builds));
241
+ logger.info(JSON.stringify(builds));
228
242
 
229
243
  await notifyToSlack({
230
244
  changedFiles,
@@ -246,7 +260,7 @@ await yargs()
246
260
  async ({ actors, ignore, dryRun }) => {
247
261
  const actorConfigs = await readConfigFile({ actors, ignore });
248
262
  const builds = await runBuildsFromLocal({ actorConfigs, dryRun });
249
- console.log(JSON.stringify(builds));
263
+ writeJson(builds);
250
264
  },
251
265
  )
252
266
  .command(
@@ -265,11 +279,11 @@ await yargs()
265
279
  // or a missing config file) arrive here as `err`. A malformed selection must fail loudly
266
280
  // rather than silently operate on the wrong set of actors — print the message, no stack.
267
281
  if (err) {
268
- console.error(`[ERROR]: ${err.message}`);
282
+ logger.error(`[ERROR]: ${err.message}`);
269
283
  } else {
270
284
  // Argument-parsing/validation failure — keep yargs' usage output.
271
- console.error(yargsInstance.help());
272
- console.error(`\n${msg}`);
285
+ logger.error(yargsInstance.help());
286
+ logger.error(`\n${msg}`);
273
287
  }
274
288
  process.exit(1);
275
289
  })
package/bin/output.ts ADDED
@@ -0,0 +1,6 @@
1
+ import process from 'node:process';
2
+
3
+ /** CLI results always go to stdout, independently of the diagnostic log level. */
4
+ export const writeJson = (value: unknown): void => {
5
+ process.stdout.write(`${JSON.stringify(value)}\n`);
6
+ };
package/bin/slack.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { WebClient } from '@slack/web-api';
2
2
 
3
+ import { logger } from './logger.js';
3
4
  import type { Commit } from './types.js';
4
5
  import { getEnvVar } from './utils.js';
5
6
 
@@ -30,7 +31,7 @@ export const notifyToSlack = async ({
30
31
  const slack = new WebClient(getEnvVar('SLACK_TOKEN_RELEASES_BOT'));
31
32
 
32
33
  if (!changelog) {
33
- console.warn('No new changelog entries found, did you forget to update it?');
34
+ logger.warn('No new changelog entries found, did you forget to update it?');
34
35
  }
35
36
 
36
37
  let shortMessage = `*${repository}* – New release (by ${displayName(author)}):\n\n`;
@@ -38,9 +39,9 @@ export const notifyToSlack = async ({
38
39
  // This one is just for broader public that only cares about public facing changes
39
40
  if (changelog && releaseSlackChannel) {
40
41
  shortMessage += `*Additions to the changelog*:\n\n${changelog}\n`;
41
- console.error(`=========================================`);
42
- console.error(`**Sending slack message to channel**: ${releaseSlackChannel}.\n\n${shortMessage}`);
43
- console.error(`=========================================`);
42
+ logger.info(`=========================================`);
43
+ logger.info(`**Sending slack message to channel**: ${releaseSlackChannel}.\n\n${shortMessage}`);
44
+ logger.info(`=========================================`);
44
45
  if (!dryRun) {
45
46
  await slack.chat.postMessage({
46
47
  channel: releaseSlackChannel,
@@ -60,9 +61,9 @@ export const notifyToSlack = async ({
60
61
 
61
62
  // This one is for devs and project managers that need to know more details
62
63
  if (reportSlackChannel) {
63
- console.error(`=========================================`);
64
- console.error(`Sending slack message to channel: ${reportSlackChannel}.\n\n${longMessage}`);
65
- console.error(`=========================================`);
64
+ logger.info(`=========================================`);
65
+ logger.info(`Sending slack message to channel: ${reportSlackChannel}.\n\n${longMessage}`);
66
+ logger.info(`=========================================`);
66
67
  if (!dryRun) {
67
68
  await slack.chat.postMessage({
68
69
  text: longMessage,
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs/promises';
2
2
 
3
+ import { logger } from './logger.js';
3
4
  import { sendSlackMessage } from './slack.js';
4
5
  import { getEnvVar } from './utils.js';
5
6
 
@@ -38,23 +39,23 @@ export const reportTestResults = async ({
38
39
 
39
40
  const failedAssertions: { message: string; runLink: string; actorId: string }[] = [];
40
41
 
41
- console.error();
42
- console.error(`PASSED: ${passed.length}, FAILED: ${failed.length}`);
43
- console.error();
44
- console.error('**************************************************');
45
- console.error('* Successes *');
46
- console.error('**************************************************');
47
- console.error();
42
+ logger.info();
43
+ logger.info(`PASSED: ${passed.length}, FAILED: ${failed.length}`);
44
+ logger.info();
45
+ logger.info('**************************************************');
46
+ logger.info('* Successes *');
47
+ logger.info('**************************************************');
48
+ logger.info();
48
49
  for (const [i, aResult] of passed.entries()) {
49
50
  const { fullName } = aResult;
50
- console.error(`${i + 1}) ${fullName} ... ${aResult.meta.runLink}`);
51
- console.error();
51
+ logger.info(`${i + 1}) ${fullName} ... ${aResult.meta.runLink}`);
52
+ logger.info();
52
53
  }
53
54
 
54
- console.error('**************************************************');
55
- console.error('* Failures *');
56
- console.error('**************************************************');
57
- console.error();
55
+ logger.info('**************************************************');
56
+ logger.info('* Failures *');
57
+ logger.info('**************************************************');
58
+ logger.info();
58
59
  for (const [i, aResult] of failed.entries()) {
59
60
  const { failureMessages, fullName, meta } = aResult;
60
61
  if (failureMessages) {
@@ -66,15 +67,15 @@ export const reportTestResults = async ({
66
67
  })),
67
68
  );
68
69
  }
69
- console.error(`${i + 1}) ${fullName} ... ${meta.runLink}`);
70
- console.error();
70
+ logger.info(`${i + 1}) ${fullName} ... ${meta.runLink}`);
71
+ logger.info();
71
72
  }
72
- console.error();
73
- console.error(`PASSED: ${passed.length}, FAILED: ${failed.length}`);
74
- console.error();
73
+ logger.info();
74
+ logger.info(`PASSED: ${passed.length}, FAILED: ${failed.length}`);
75
+ logger.info();
75
76
 
76
77
  if (!reportSlackChannel) {
77
- console.error(
78
+ logger.info(
78
79
  `Skipping slack notification. If you want to enable it, add --report-slack-channel flag and make sure SLACK_TOKEN_TESTS_BOT env variable is set.`,
79
80
  );
80
81
  return;
@@ -94,8 +95,8 @@ export const reportTestResults = async ({
94
95
  .slice(1)
95
96
  .map(({ message, runLink, actorId }) => `• ${message} --- <${runLink}|${actorId}>`);
96
97
 
97
- console.error('SLACK:', slackMessage);
98
- console.error('\tblocks:', blocks.join('\n\t\t'));
98
+ logger.info('SLACK:', slackMessage);
99
+ logger.info('\tblocks:', blocks.join('\n\t\t'));
99
100
 
100
101
  if (!reportSlackChannel) {
101
102
  return;