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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/.github/cli/apify-test-tools.mjs +140 -140
  2. package/.github/scripts/run-with-apify-tokens.mjs +1 -1
  3. package/.github/workflows/public_pr-build-test.yaml +25 -25
  4. package/.github/workflows/public_push-build-latest.yaml +20 -4
  5. package/CHANGELOG.md +2 -0
  6. package/README.md +119 -14
  7. package/bin/affected-actors.ts +157 -0
  8. package/bin/build.ts +55 -38
  9. package/bin/diff-changes.ts +25 -17
  10. package/bin/git.ts +44 -44
  11. package/bin/main.ts +132 -106
  12. package/bin/slack.ts +24 -0
  13. package/bin/stdin-input.ts +65 -0
  14. package/dist/bin/affected-actors.d.ts +45 -0
  15. package/dist/bin/affected-actors.d.ts.map +1 -0
  16. package/dist/bin/affected-actors.js +87 -0
  17. package/dist/bin/affected-actors.js.map +1 -0
  18. package/dist/bin/build.d.ts +12 -5
  19. package/dist/bin/build.d.ts.map +1 -1
  20. package/dist/bin/build.js +45 -23
  21. package/dist/bin/build.js.map +1 -1
  22. package/dist/bin/diff-changes.d.ts +8 -2
  23. package/dist/bin/diff-changes.d.ts.map +1 -1
  24. package/dist/bin/diff-changes.js +16 -16
  25. package/dist/bin/diff-changes.js.map +1 -1
  26. package/dist/bin/git.d.ts +19 -14
  27. package/dist/bin/git.d.ts.map +1 -1
  28. package/dist/bin/git.js +37 -35
  29. package/dist/bin/git.js.map +1 -1
  30. package/dist/bin/main.js +110 -86
  31. package/dist/bin/main.js.map +1 -1
  32. package/dist/bin/slack.d.ts +8 -0
  33. package/dist/bin/slack.d.ts.map +1 -1
  34. package/dist/bin/slack.js +18 -0
  35. package/dist/bin/slack.js.map +1 -1
  36. package/dist/bin/stdin-input.d.ts +17 -0
  37. package/dist/bin/stdin-input.d.ts.map +1 -0
  38. package/dist/bin/stdin-input.js +54 -0
  39. package/dist/bin/stdin-input.js.map +1 -0
  40. package/dist/test/bundle/cli-bundle.test.js +36 -6
  41. package/dist/test/bundle/cli-bundle.test.js.map +1 -1
  42. package/dist/test/e2e/cli-bundle.e2e.test.js +16 -9
  43. package/dist/test/e2e/cli-bundle.e2e.test.js.map +1 -1
  44. package/dist/test/unit/bin/affected-actors.test.d.ts +2 -0
  45. package/dist/test/unit/bin/affected-actors.test.d.ts.map +1 -0
  46. package/dist/test/unit/bin/affected-actors.test.js +154 -0
  47. package/dist/test/unit/bin/affected-actors.test.js.map +1 -0
  48. package/dist/test/unit/bin/build-cli.test.d.ts +2 -0
  49. package/dist/test/unit/bin/build-cli.test.d.ts.map +1 -0
  50. package/dist/test/unit/bin/build-cli.test.js +52 -0
  51. package/dist/test/unit/bin/build-cli.test.js.map +1 -0
  52. package/dist/test/unit/bin/build.test.js +61 -4
  53. package/dist/test/unit/bin/build.test.js.map +1 -1
  54. package/dist/test/unit/bin/diff-changes.test.js +78 -49
  55. package/dist/test/unit/bin/diff-changes.test.js.map +1 -1
  56. package/dist/test/unit/bin/git.test.js +39 -54
  57. package/dist/test/unit/bin/git.test.js.map +1 -1
  58. package/dist/test/unit/bin/slack.test.js +39 -1
  59. package/dist/test/unit/bin/slack.test.js.map +1 -1
  60. package/dist/test/unit/bin/stdin-input.test.d.ts +2 -0
  61. package/dist/test/unit/bin/stdin-input.test.d.ts.map +1 -0
  62. package/dist/test/unit/bin/stdin-input.test.js +50 -0
  63. package/dist/test/unit/bin/stdin-input.test.js.map +1 -0
  64. package/dist/test/unit/should-built-and-test.test.js +23 -17
  65. package/dist/test/unit/should-built-and-test.test.js.map +1 -1
  66. package/dist/tsconfig.tsbuildinfo +1 -1
  67. package/package.json +1 -1
  68. package/test/bundle/cli-bundle.test.ts +59 -6
  69. package/test/e2e/cli-bundle.e2e.test.ts +26 -11
  70. package/test/unit/bin/affected-actors.test.ts +184 -0
  71. package/test/unit/bin/build-cli.test.ts +61 -0
  72. package/test/unit/bin/build.test.ts +71 -3
  73. package/test/unit/bin/diff-changes.test.ts +87 -49
  74. package/test/unit/bin/git.test.ts +44 -55
  75. package/test/unit/bin/slack.test.ts +44 -1
  76. package/test/unit/bin/stdin-input.test.ts +61 -0
  77. package/test/unit/should-built-and-test.test.ts +27 -17
package/bin/build.ts CHANGED
@@ -1,22 +1,23 @@
1
1
  import type * as ApifyClientTypes from 'apify-client';
2
2
  import { ActorSourceType, ApifyClient } from 'apify-client';
3
3
 
4
- import { normalizeRepoUrl } from './git.js';
4
+ import { getCurrentBranch, normalizeRepoUrl, resolveRepoUrl } from './git.js';
5
5
  import { logger } from './logger.js';
6
6
  import type { ActorConfig, BuildData, DryRunPlan } from './types.js';
7
7
 
8
8
  type BuildPrActorOptions = {
9
- buildTag?: string;
10
9
  versionNumber: string;
11
10
  gitRepoUrl: string;
12
11
  actorConfig: ActorConfig;
13
12
  actorInfo: ApifyClientTypes.Actor;
14
- useDockerCache: boolean;
15
13
  };
16
14
 
17
15
  // Fixed version number used to build from local source files, since there is no real version to track.
18
16
  export const LOCAL_SOURCE_VERSION_NUMBER = '0.98';
19
- const DEFAULT_TEST_VERSION_NUMBER = '0.99';
17
+ // Version that build-for-test builds, so tests never touch the version users run
18
+ export const TEST_VERSION_NUMBER = '0.99';
19
+ // Stands for each Actor's default version: the one its default build tag points to
20
+ export const DEFAULT_VERSION_KEYWORD = 'default';
20
21
 
21
22
  // Shown whenever an Actor isn't set up the way CI builds need it
22
23
  const ACTOR_SETUP_REQUIREMENT =
@@ -319,20 +320,18 @@ export const runAndSummarizeBuilds = async (
319
320
  type GitBuildPlan = {
320
321
  actorFullName: string;
321
322
  versionNumber: string;
322
- buildTag?: string;
323
323
  gitRepoUrl: string;
324
- useDockerCache: boolean;
325
324
  };
326
325
 
327
326
  type RunBuildsOptions = {
328
327
  actorConfigs: ActorConfig[];
329
- isLatest?: boolean;
328
+ // A version number like "0.99", or DEFAULT_VERSION_KEYWORD for each Actor's default version
329
+ versionNumber: string;
330
330
  repoUrl: string;
331
331
  // False when the repo URL was passed explicitly, see assertRepoUrlMatchesDefaultVersion
332
332
  shouldVerifyRepoUrl: boolean;
333
333
  branch: string;
334
334
  dryRun: boolean;
335
- useDockerCache: boolean;
336
335
  };
337
336
 
338
337
  export const runBuilds = async ({
@@ -340,9 +339,8 @@ export const runBuilds = async ({
340
339
  shouldVerifyRepoUrl,
341
340
  branch,
342
341
  actorConfigs,
343
- isLatest = false,
342
+ versionNumber,
344
343
  dryRun,
345
- useDockerCache,
346
344
  }: RunBuildsOptions): Promise<BuildData[] | DryRunPlan<GitBuildPlan>> => {
347
345
  // Fetched once per Actor and reused for the checks, the version numbers and the build itself
348
346
  const buildConfigs: BuildPrActorOptions[] = await Promise.all(
@@ -352,6 +350,15 @@ export const runBuilds = async ({
352
350
  actorConfig.actorFullName,
353
351
  actorInfo,
354
352
  );
353
+ // Builds keep the tags their version gives them, so a default version without the default build tag
354
+ // would build fine but leave users on the previous build
355
+ if (versionNumber === DEFAULT_VERSION_KEYWORD && defaultVersion?.buildTag !== defaultBuildTag) {
356
+ logger.warn(
357
+ `[${actorConfig.actorFullName}] Default version ${defaultVersionNumber} has build tag ` +
358
+ `"${defaultVersion?.buildTag ?? 'none'}", not "${defaultBuildTag}", so users keep running the previous build. ` +
359
+ 'Set the build tag of the version on the platform to change that.',
360
+ );
361
+ }
355
362
  if (shouldVerifyRepoUrl) {
356
363
  assertRepoUrlMatchesDefaultVersion(actorConfig.actorFullName, defaultVersion, repoUrl);
357
364
  }
@@ -365,9 +372,7 @@ export const runBuilds = async ({
365
372
  actorConfig,
366
373
  actorInfo,
367
374
  gitRepoUrl,
368
- versionNumber: isLatest ? defaultVersionNumber : DEFAULT_TEST_VERSION_NUMBER,
369
- buildTag: isLatest ? defaultBuildTag : undefined,
370
- useDockerCache,
375
+ versionNumber: versionNumber === DEFAULT_VERSION_KEYWORD ? defaultVersionNumber : versionNumber,
371
376
  };
372
377
  }),
373
378
  );
@@ -375,21 +380,15 @@ export const runBuilds = async ({
375
380
  // Everything above only reads, so a dry run has checked what the real run would, short of writing
376
381
  if (dryRun) {
377
382
  logger.info('[DRY RUN] Would build:');
378
- for (const { actorConfig, versionNumber, gitRepoUrl } of buildConfigs) {
379
- logger.info(` ${actorConfig.actorFullName} (${versionNumber}) from ${gitRepoUrl}`);
383
+ const builds = buildConfigs.map((buildConfig) => ({
384
+ actorFullName: buildConfig.actorConfig.actorFullName,
385
+ versionNumber: buildConfig.versionNumber,
386
+ gitRepoUrl: buildConfig.gitRepoUrl,
387
+ }));
388
+ for (const build of builds) {
389
+ logger.info(` ${build.actorFullName} (${build.versionNumber}) from ${build.gitRepoUrl}`);
380
390
  }
381
- return {
382
- dryRun: true,
383
- builds: buildConfigs.map(
384
- ({ actorConfig, versionNumber, buildTag, gitRepoUrl, useDockerCache: useCache }) => ({
385
- actorFullName: actorConfig.actorFullName,
386
- versionNumber,
387
- buildTag,
388
- gitRepoUrl,
389
- useDockerCache: useCache,
390
- }),
391
- ),
392
- };
391
+ return { dryRun: true, builds };
393
392
  }
394
393
 
395
394
  const buildConfigsByActorFullName = new Map(
@@ -397,20 +396,38 @@ export const runBuilds = async ({
397
396
  );
398
397
 
399
398
  return runAndSummarizeBuilds(actorConfigs, 'BUILDS', async (actorConfig, builder) => {
400
- const {
401
- actorInfo,
402
- buildTag,
403
- versionNumber,
404
- gitRepoUrl,
405
- useDockerCache: useCache,
406
- } = buildConfigsByActorFullName.get(actorConfig.actorFullName)!;
399
+ const buildConfig = buildConfigsByActorFullName.get(actorConfig.actorFullName)!;
400
+ // No buildTag: the version keeps the one it has, so its build gets the tag the version already gives it
407
401
  const actorVersion: ApifyClientTypes.ActorVersion = {
408
- buildTag,
409
- versionNumber,
410
- gitRepoUrl,
402
+ versionNumber: buildConfig.versionNumber,
403
+ gitRepoUrl: buildConfig.gitRepoUrl,
411
404
  sourceType: ActorSourceType.GitRepo,
412
405
  };
413
- return builder.createVersionAndBuild(versionNumber, actorVersion, useCache, actorInfo);
406
+ // Always with the Docker cache: CI always asked for it, and a clean build can still be started on the platform
407
+ return builder.createVersionAndBuild(buildConfig.versionNumber, actorVersion, true, buildConfig.actorInfo);
408
+ });
409
+ };
410
+
411
+ type BuildActorsOptions = Omit<RunBuildsOptions, 'repoUrl' | 'shouldVerifyRepoUrl' | 'branch'> & {
412
+ // Defaults to the `origin` remote, see resolveRepoUrl
413
+ repoUrl?: string;
414
+ };
415
+
416
+ /**
417
+ * Builds exactly the given Actors from the checked-out Git branch, as it is on the remote. Fails on a detached HEAD,
418
+ * where there is no branch to build. Used by `build` and the commands built on top of it.
419
+ */
420
+ export const buildActors = async ({ actorConfigs, repoUrl, ...options }: BuildActorsOptions) => {
421
+ if (actorConfigs.length === 0) {
422
+ logger.info('No Actors to build');
423
+ // Still the dry-run shape in a dry run, so it can't pass for a real (empty) result
424
+ return options.dryRun ? { dryRun: true as const, builds: [] } : [];
425
+ }
426
+ return runBuilds({
427
+ ...options,
428
+ ...(await resolveRepoUrl(repoUrl)),
429
+ actorConfigs,
430
+ branch: await getCurrentBranch(),
414
431
  });
415
432
  };
416
433
 
@@ -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
@@ -169,21 +181,25 @@ export const parseCommit = (commitString: string): Commit => {
169
181
  };
170
182
 
171
183
  /**
172
- * Returns the currently checked-out branch. Release builds point the Actor version at this branch,
184
+ * Returns the currently checked-out branch. Builds point the Actor version at this branch,
173
185
  * so a detached HEAD (no branch to point at) is an error rather than a guess.
174
186
  */
175
187
  export const getCurrentBranch = async (): Promise<string> => {
176
188
  const branch = await runGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], {
177
- operation: 'determine the current branch for release',
189
+ operation: 'determine the checked-out branch',
178
190
  });
179
191
  if (branch === 'HEAD') {
180
192
  throw new Error(
181
- 'Cannot determine the branch to release: HEAD is detached. Check out the branch you want to release.',
193
+ 'Cannot determine the branch to build from: HEAD is detached. Check out the branch you want to build.',
182
194
  );
183
195
  }
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
+
187
203
  /**
188
204
  * Reads the repository URL from the `origin` remote, rewritten to the SSH form the Apify platform
189
205
  * uses for Git repo sources, e.g. git@github.com:apify-store/google-maps
@@ -236,15 +252,15 @@ const ZERO_SHA_REGEX = /^0{40}$/;
236
252
  * Unlike the PR path (getCommits), there is no lenient fallback. Falling back to "everything"
237
253
  * would rebuild the latest build of every Actor and post it to Slack, so every problem is an error.
238
254
  */
239
- export const resolveReleaseBaseCommit = async (baseCommit: string): Promise<string> => {
255
+ export const resolveReleaseBaseCommit = async (baseCommit: string, head = 'HEAD'): Promise<string> => {
240
256
  const sha = parseBaseCommit(baseCommit);
241
257
  if (!sha) {
242
- throw new Error('--base-commit is required for release. See the README section "Releasing Actors".');
258
+ throw new Error('A base commit is required for release. See the README section "Releasing Actors".');
243
259
  }
244
260
  if (ZERO_SHA_REGEX.test(sha)) {
245
261
  throw new Error(
246
262
  `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.`,
263
+ `Rerun with the base commit set to the last commit before the changes you want to release.`,
248
264
  );
249
265
  }
250
266
  // --quiet suppresses Git's error text for a missing commit, but Git still exits with code 1.
@@ -261,16 +277,16 @@ export const resolveReleaseBaseCommit = async (baseCommit: string): Promise<stri
261
277
  );
262
278
  }
263
279
  // 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',
280
+ const mergeBase = await runGitCommand(['merge-base', verifiedSha, head], {
281
+ operation: `check whether the release base commit is an ancestor of ${head}`,
266
282
  }).catch((error: unknown) => {
267
283
  if (error instanceof GitCommandError && error.exitCode === 1) return '';
268
284
  throw error;
269
285
  });
270
286
  if (mergeBase !== sha) {
271
287
  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 ` +
288
+ `Base commit ${sha} is not an ancestor of ${head}, most likely because the branch was force-pushed. ` +
289
+ `The changed files cannot be determined reliably. Rerun with the base commit set to an ancestor of ${head} ` +
274
290
  `from before the changes you want to release.`,
275
291
  );
276
292
  }
@@ -279,12 +295,16 @@ export const resolveReleaseBaseCommit = async (baseCommit: string): Promise<stri
279
295
 
280
296
  const CHANGELOG_PATH = 'CHANGELOG.md';
281
297
 
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> => {
298
+ /** Returns the lines added to the root CHANGELOG.md between fromRef and toRef, or null if it didn't change. */
299
+ export const getChangelogAdditions = async (
300
+ fromRef: string,
301
+ toRef: string,
302
+ changedFiles: string[],
303
+ ): Promise<string | null> => {
284
304
  if (!changedFiles.includes(CHANGELOG_PATH)) {
285
305
  return null;
286
306
  }
287
- const diff = await runGitCommand(['diff', baseSha, 'HEAD', '--', CHANGELOG_PATH], {
307
+ const diff = await runGitCommand(['diff', fromRef, toRef, '--', CHANGELOG_PATH], {
288
308
  operation: 'read changelog changes since the last release',
289
309
  });
290
310
 
@@ -307,23 +327,3 @@ const getChangelogAdditions = async (baseSha: string, changedFiles: string[]): P
307
327
  }
308
328
  return added.join('\n').trim();
309
329
  };
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
- };