apify-test-tools 0.9.1-beta.9 → 0.10.0

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 (86) hide show
  1. package/.github/actions/checkout-restore-dependencies/action.yaml +2 -28
  2. package/.github/workflows/public_platform-tests.yaml +0 -12
  3. package/.github/workflows/public_pr-build-test.yaml +2 -24
  4. package/.github/workflows/public_push-build-latest.yaml +3 -14
  5. package/.github/workflows/public_review.yaml +0 -7
  6. package/.github/workflows-package-version +1 -1
  7. package/CHANGELOG.md +4 -3
  8. package/CONTRIBUTING.md +2 -4
  9. package/README.md +147 -36
  10. package/bin/build-from-local.ts +15 -11
  11. package/bin/build.ts +127 -67
  12. package/bin/git.ts +140 -0
  13. package/bin/main.ts +128 -91
  14. package/bin/slack.ts +5 -2
  15. package/bin/types.ts +0 -73
  16. package/bin/utils/config/parser.ts +6 -5
  17. package/bin/utils/config/structures/base.ts +5 -1
  18. package/bin/utils/config/structures/grouped.ts +40 -0
  19. package/bin/utils/config/structures/legacy.ts +3 -1
  20. package/bin/utils.ts +7 -15
  21. package/dist/bin/build-from-local.d.ts +2 -2
  22. package/dist/bin/build-from-local.d.ts.map +1 -1
  23. package/dist/bin/build-from-local.js +11 -6
  24. package/dist/bin/build-from-local.js.map +1 -1
  25. package/dist/bin/build.d.ts +26 -4
  26. package/dist/bin/build.d.ts.map +1 -1
  27. package/dist/bin/build.js +90 -47
  28. package/dist/bin/build.js.map +1 -1
  29. package/dist/bin/git.d.ts +40 -0
  30. package/dist/bin/git.d.ts.map +1 -1
  31. package/dist/bin/git.js +118 -0
  32. package/dist/bin/git.js.map +1 -1
  33. package/dist/bin/main.d.ts +1 -20
  34. package/dist/bin/main.d.ts.map +1 -1
  35. package/dist/bin/main.js +65 -62
  36. package/dist/bin/main.js.map +1 -1
  37. package/dist/bin/slack.d.ts.map +1 -1
  38. package/dist/bin/slack.js +4 -2
  39. package/dist/bin/slack.js.map +1 -1
  40. package/dist/bin/types.d.ts +0 -64
  41. package/dist/bin/types.d.ts.map +1 -1
  42. package/dist/bin/utils/config/parser.d.ts.map +1 -1
  43. package/dist/bin/utils/config/parser.js +6 -5
  44. package/dist/bin/utils/config/parser.js.map +1 -1
  45. package/dist/bin/utils/config/structures/base.d.ts +3 -2
  46. package/dist/bin/utils/config/structures/base.d.ts.map +1 -1
  47. package/dist/bin/utils/config/structures/base.js +1 -0
  48. package/dist/bin/utils/config/structures/base.js.map +1 -1
  49. package/dist/bin/utils/config/structures/grouped.d.ts +6 -0
  50. package/dist/bin/utils/config/structures/grouped.d.ts.map +1 -0
  51. package/dist/bin/utils/config/structures/grouped.js +29 -0
  52. package/dist/bin/utils/config/structures/grouped.js.map +1 -0
  53. package/dist/bin/utils/config/structures/legacy.d.ts +1 -1
  54. package/dist/bin/utils/config/structures/legacy.d.ts.map +1 -1
  55. package/dist/bin/utils/config/structures/legacy.js +1 -1
  56. package/dist/bin/utils/config/structures/legacy.js.map +1 -1
  57. package/dist/bin/utils.d.ts +5 -2
  58. package/dist/bin/utils.d.ts.map +1 -1
  59. package/dist/bin/utils.js +6 -12
  60. package/dist/bin/utils.js.map +1 -1
  61. package/dist/test/unit/bin/build.test.d.ts +2 -0
  62. package/dist/test/unit/bin/build.test.d.ts.map +1 -0
  63. package/dist/test/unit/bin/build.test.js +46 -0
  64. package/dist/test/unit/bin/build.test.js.map +1 -0
  65. package/dist/test/unit/bin/git.test.js +104 -1
  66. package/dist/test/unit/bin/git.test.js.map +1 -1
  67. package/dist/test/unit/bin/utils/config/load-config.test.js +1 -0
  68. package/dist/test/unit/bin/utils/config/load-config.test.js.map +1 -1
  69. package/dist/test/unit/bin/utils/config/parser.test.js +23 -5
  70. package/dist/test/unit/bin/utils/config/parser.test.js.map +1 -1
  71. package/dist/test/unit/bin/utils/config/structures/grouped.test.d.ts +2 -0
  72. package/dist/test/unit/bin/utils/config/structures/grouped.test.d.ts.map +1 -0
  73. package/dist/test/unit/bin/utils/config/structures/grouped.test.js +75 -0
  74. package/dist/test/unit/bin/utils/config/structures/grouped.test.js.map +1 -0
  75. package/dist/tsconfig.tsbuildinfo +1 -1
  76. package/package.json +65 -63
  77. package/test/unit/bin/build.test.ts +68 -0
  78. package/test/unit/bin/git.test.ts +122 -0
  79. package/test/unit/bin/utils/config/load-config.test.ts +1 -0
  80. package/test/unit/bin/utils/config/parser.test.ts +26 -5
  81. package/test/unit/bin/utils/config/structures/grouped.test.ts +90 -0
  82. package/bin/github.ts +0 -69
  83. package/dist/bin/github.d.ts +0 -11
  84. package/dist/bin/github.d.ts.map +0 -1
  85. package/dist/bin/github.js +0 -59
  86. package/dist/bin/github.js.map +0 -1
package/bin/build.ts CHANGED
@@ -1,6 +1,7 @@
1
- import type { ActorVersion, Build } from 'apify-client';
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
5
  import type { ActorConfig, BuildData } from './types.js';
5
6
 
6
7
  type BuildPrActorOptions = {
@@ -8,6 +9,7 @@ type BuildPrActorOptions = {
8
9
  versionNumber: string;
9
10
  gitRepoUrl: string;
10
11
  actorConfig: ActorConfig;
12
+ actorInfo: ApifyClientTypes.Actor;
11
13
  useDockerCache: boolean;
12
14
  };
13
15
 
@@ -15,69 +17,118 @@ type BuildPrActorOptions = {
15
17
  export const LOCAL_SOURCE_VERSION_NUMBER = '0.98';
16
18
  const DEFAULT_TEST_VERSION_NUMBER = '0.99';
17
19
 
20
+ // Shown whenever an Actor isn't set up the way CI builds need it
21
+ const ACTOR_SETUP_REQUIREMENT =
22
+ 'Before using apify-test-tools, every Actor needs at least one build under its default build tag (usually "latest"), ' +
23
+ 'built from a Git repository version whose URL points to this repository. Build it once manually on the platform.';
24
+
25
+ /**
26
+ * Finds the Actor's default version: the one whose build the default build tag points to.
27
+ * Usually tagged 'latest' but not necessarily (can be e.g. 'version-0').
28
+ */
29
+ export const resolveDefaultVersion = (actorFullName: string, actorInfo: ApifyClientTypes.Actor) => {
30
+ const defaultBuildTag = actorInfo.defaultRunOptions.build;
31
+ console.error(`Default build tag for ${actorFullName} is ${defaultBuildTag}`);
32
+
33
+ // We could technically allow this but in most cases this is accidentally set wrongly and there is a workaround
34
+ if (defaultBuildTag.match(/\d+\.\d+\.\d+/)) {
35
+ throw new Error(
36
+ `[${actorFullName}] Default build is a build number, not a tag. While this could work, ` +
37
+ `we want to have a default as tag so this is often an accidental misconfiguration from the dev`,
38
+ );
39
+ }
40
+ // I reported that buildNumber should probably not be optional
41
+ const defaultBuildNumber = actorInfo.taggedBuilds?.[defaultBuildTag]?.buildNumber;
42
+ if (!defaultBuildNumber) {
43
+ throw new Error(`[${actorFullName}] No build found for tag "${defaultBuildTag}". ${ACTOR_SETUP_REQUIREMENT}`);
44
+ }
45
+ const defaultVersionNumber = defaultBuildNumber.match(/(\d+\.\d+)\.\d+/)![1];
46
+ console.error(`Default version for ${actorFullName} is ${defaultVersionNumber}`);
47
+
48
+ const defaultVersion = actorInfo.versions.find((version) => version.versionNumber === defaultVersionNumber);
49
+
50
+ return { defaultBuildNumber, defaultVersionNumber, defaultBuildTag, defaultVersion };
51
+ };
52
+
53
+ /**
54
+ * Guards against building an Actor from a different repository than it is published from, e.g. when the
55
+ * local `origin` remote is a fork or a mirror. Every build overwrites the version's Git URL, so building
56
+ * from the wrong remote would silently repoint the Actor. The default version is the source of truth
57
+ * since it is what users run. Skipped when the repo URL is passed explicitly, which is how you move an
58
+ * Actor to a new repository on purpose.
59
+ */
60
+ export const assertRepoUrlMatchesDefaultVersion = (
61
+ actorFullName: string,
62
+ defaultVersion: ApifyClientTypes.ActorVersion | undefined,
63
+ repoUrl: string,
64
+ ) => {
65
+ if (!defaultVersion) {
66
+ throw new Error(
67
+ `[${actorFullName}] The version of the default build no longer exists. ${ACTOR_SETUP_REQUIREMENT}`,
68
+ );
69
+ }
70
+ if (defaultVersion.sourceType !== ActorSourceType.GitRepo) {
71
+ throw new Error(
72
+ `[${actorFullName}] Default version ${defaultVersion.versionNumber} has source type "${defaultVersion.sourceType}", ` +
73
+ `not "${ActorSourceType.GitRepo}". ${ACTOR_SETUP_REQUIREMENT}`,
74
+ );
75
+ }
76
+ if (normalizeRepoUrl(defaultVersion.gitRepoUrl) !== normalizeRepoUrl(repoUrl)) {
77
+ throw new Error(
78
+ `[${actorFullName}] Repository mismatch: the git remote "origin" is ${repoUrl} but default version ` +
79
+ `${defaultVersion.versionNumber} is built from ${defaultVersion.gitRepoUrl}. Fix the remote or the Actor's source, ` +
80
+ `or pass --repo-url to build from a different repository on purpose.`,
81
+ );
82
+ }
83
+ };
84
+
18
85
  export class ApifyBuilder {
19
86
  private constructor(
20
87
  private readonly apifyClient: ApifyClient,
21
88
  private readonly actorFullName: string,
22
89
  ) {}
23
90
 
24
- // Usually 'latest' but not necessarily (can be e.g. 'version-0')
25
- getDefaultVersionAndTag = async (): Promise<{
26
- defaultBuildNumber: string;
27
- defaultVersionNumber: string;
28
- defaultBuildTag: string;
29
- }> => {
30
- const actorClient = this.apifyClient.actor(this.actorFullName);
31
- const actorInfo = await actorClient.get();
32
-
91
+ getActorInfo = async (): Promise<ApifyClientTypes.Actor> => {
92
+ const actorInfo = await this.apifyClient.actor(this.actorFullName).get();
33
93
  if (!actorInfo) {
34
94
  throw new Error(
35
95
  `[${this.actorFullName}] not found. It is not published or we are missing token to access it privately or its name is misspelled`,
36
96
  );
37
97
  }
98
+ return actorInfo;
99
+ };
38
100
 
39
- const defaultBuildTag = actorInfo.defaultRunOptions.build;
40
- console.error(`Default build tag for ${this.actorFullName} is ${defaultBuildTag}`);
101
+ getDefaultVersionAndTag = async () => resolveDefaultVersion(this.actorFullName, await this.getActorInfo());
41
102
 
42
- // We could technically allow this but in most cases this is accidentally set wrongly and there is a workaround
43
- if (defaultBuildTag.match(/\d+\.\d+\.\d+/)) {
44
- throw new Error(
45
- `[${this.actorFullName}] Default build is a build number, not a tag. While this could work, ` +
46
- `we want to have a default as tag so this is often an accidental misconfiguration from the dev`,
47
- );
48
- }
49
- // I reported that buildNumber should probably not be optional
50
- if (!actorInfo.taggedBuilds?.[defaultBuildTag]?.buildNumber) {
51
- throw new Error(
52
- `[${this.actorFullName}] No build found for tag "${defaultBuildTag}". ` +
53
- `The first build must be triggered manually on the platform before CI can take over.`,
54
- );
55
- }
56
- const defaultBuildNumber = actorInfo.taggedBuilds![defaultBuildTag].buildNumber!;
57
- const defaultVersionNumber = defaultBuildNumber.match(/(\d+\.\d+)\.\d+/)![1];
58
- console.error(`Default version for ${this.actorFullName} is ${defaultVersionNumber}`);
103
+ // Mirrors `apify push`: stores the zip in the Actor's `actor-<id>-source` KV store and returns a signed
104
+ // record URL the builder can download without a token.
105
+ uploadSourceZip = async (versionNumber: string, zip: Buffer): Promise<string> => {
106
+ const actorInfo = await this.apifyClient.actor(this.actorFullName).get();
107
+ if (!actorInfo) throw new Error(`No actor named '${this.actorFullName}' was found on the platform.`);
108
+
109
+ const store = await this.apifyClient.keyValueStores().getOrCreate(`actor-${actorInfo.id}-source`);
110
+ const storeClient = this.apifyClient.keyValueStore(store.id);
111
+ const key = `version-${versionNumber}.zip`;
112
+ await storeClient.setRecord({ key, value: zip as never, contentType: 'application/zip' });
59
113
 
60
- return { defaultBuildNumber, defaultVersionNumber, defaultBuildTag };
114
+ const url = new URL(await storeClient.getRecordPublicUrl(key));
115
+ url.searchParams.set('disableRedirect', 'true');
116
+ return url.toString();
61
117
  };
62
118
 
119
+ // Pass actorInfo when the caller already fetched it, to save an API call
63
120
  createVersionAndBuild = async (
64
121
  versionNumber: string,
65
- actorVersion: ActorVersion,
122
+ actorVersion: ApifyClientTypes.ActorVersion,
66
123
  useCache: boolean,
124
+ actorInfo?: ApifyClientTypes.Actor,
67
125
  ): Promise<BuildData> => {
68
126
  const actorClient = this.apifyClient.actor(this.actorFullName);
69
- const actorInfo = await actorClient.get();
70
- if (!actorInfo) {
71
- throw new Error(
72
- `No actor named '${this.actorFullName}' was found on the platform. If this` +
73
- ' is unexpected, make sure the actor you are targeting is spelled the' +
74
- ' same as the folder in the repository.',
75
- );
76
- }
127
+ const { versions } = actorInfo ?? (await this.getActorInfo());
77
128
 
78
129
  // Prepare version
79
- const versionExists = !actorInfo.versions.find((version) => version.versionNumber === versionNumber);
80
- if (versionExists) {
130
+ const versionMissing = !versions.find((version) => version.versionNumber === versionNumber);
131
+ if (versionMissing) {
81
132
  // create new version
82
133
  await actorClient.versions().create(actorVersion);
83
134
  } else {
@@ -92,7 +143,7 @@ export class ApifyBuilder {
92
143
  return { buildId: id, actorRawId: actId, buildNumber, actorFullName: this.actorFullName };
93
144
  };
94
145
 
95
- waitForBuildToFinish = async (buildId: string): Promise<Build> => {
146
+ waitForBuildToFinish = async (buildId: string): Promise<ApifyClientTypes.Build> => {
96
147
  const build = await this.apifyClient.build(buildId).waitForFinish();
97
148
  const versionNumber = build.buildNumber;
98
149
  if (build.status === 'FAILED' || build.status === 'TIMED-OUT') {
@@ -152,7 +203,7 @@ export class ApifyBuilder {
152
203
  const DEFAULT_DAYS_BACK_PROD_VERSIONS = 30;
153
204
  const DEFAULT_DAYS_BACK_DEVEL = 7;
154
205
 
155
- const actorInfo = (await this.apifyClient.actor(this.actorFullName).get())!;
206
+ const actorInfo = await this.getActorInfo();
156
207
 
157
208
  // 'devel' used to be hardcoded for testing version 0.99, once we get rid of this tag everywhere, we can remove this code
158
209
  const taggedDevelBuildNumber: string | undefined = actorInfo.taggedBuilds!.devel?.buildNumber;
@@ -167,7 +218,7 @@ export class ApifyBuilder {
167
218
  const { items } = await this.apifyClient.actor(this.actorFullName).builds().list();
168
219
 
169
220
  // Deleting default build throws an error, so we skip it
170
- const { defaultBuildNumber, defaultBuildTag } = await this.getDefaultVersionAndTag();
221
+ const { defaultBuildNumber, defaultBuildTag } = resolveDefaultVersion(this.actorFullName, actorInfo);
171
222
 
172
223
  const daysAgoUnixProd = Date.now() - DEFAULT_DAYS_BACK_PROD_VERSIONS * 24 * 60 * 60 * 1000;
173
224
  const daysAgoUnixDevel = Date.now() - DEFAULT_DAYS_BACK_DEVEL * 24 * 60 * 60 * 1000;
@@ -270,6 +321,8 @@ type RunBuildsOptions = {
270
321
  actorConfigs: ActorConfig[];
271
322
  isLatest?: boolean;
272
323
  repoUrl: string;
324
+ // False when the repo URL was passed explicitly, see assertRepoUrlMatchesDefaultVersion
325
+ shouldVerifyRepoUrl: boolean;
273
326
  branch: string;
274
327
  dryRun: boolean;
275
328
  useDockerCache: boolean;
@@ -277,34 +330,40 @@ type RunBuildsOptions = {
277
330
 
278
331
  export const runBuilds = async ({
279
332
  repoUrl,
333
+ shouldVerifyRepoUrl,
280
334
  branch,
281
335
  actorConfigs,
282
336
  isLatest = false,
283
337
  dryRun,
284
338
  useDockerCache,
285
339
  }: RunBuildsOptions): Promise<BuildData[]> => {
286
- const buildConfigs: BuildPrActorOptions[] = [];
287
-
288
- for (const actorConfig of actorConfigs) {
289
- let versionNumber: string;
290
- let buildTag: string | undefined;
291
-
292
- if (isLatest) {
293
- const { defaultVersionNumber, defaultBuildTag } =
294
- await ApifyBuilder.fromActorConfig(actorConfig).getDefaultVersionAndTag();
295
- versionNumber = defaultVersionNumber;
296
- buildTag = defaultBuildTag;
297
- } else {
298
- versionNumber = DEFAULT_TEST_VERSION_NUMBER;
299
- }
340
+ // Fetched once per Actor and reused for the checks, the version numbers and the build itself
341
+ const buildConfigs: BuildPrActorOptions[] = await Promise.all(
342
+ actorConfigs.map(async (actorConfig) => {
343
+ const actorInfo = await ApifyBuilder.fromActorConfig(actorConfig).getActorInfo();
344
+ const { defaultVersionNumber, defaultBuildTag, defaultVersion } = resolveDefaultVersion(
345
+ actorConfig.actorFullName,
346
+ actorInfo,
347
+ );
348
+ if (shouldVerifyRepoUrl) {
349
+ assertRepoUrlMatchesDefaultVersion(actorConfig.actorFullName, defaultVersion, repoUrl);
350
+ }
300
351
 
301
- // Depending on if these are miniactors or standaloneActors
302
- let gitRepoUrl = `${repoUrl}#${branch}`;
303
- if (actorConfig.folder) {
304
- gitRepoUrl = `${gitRepoUrl}:${actorConfig.folder}`;
305
- }
306
- buildConfigs.push({ actorConfig, gitRepoUrl, versionNumber, buildTag, useDockerCache });
307
- }
352
+ // Depending on if these are miniactors or standaloneActors
353
+ let gitRepoUrl = `${repoUrl}#${branch}`;
354
+ if (actorConfig.folder) {
355
+ gitRepoUrl = `${gitRepoUrl}:${actorConfig.folder}`;
356
+ }
357
+ return {
358
+ actorConfig,
359
+ actorInfo,
360
+ gitRepoUrl,
361
+ versionNumber: isLatest ? defaultVersionNumber : DEFAULT_TEST_VERSION_NUMBER,
362
+ buildTag: isLatest ? defaultBuildTag : undefined,
363
+ useDockerCache,
364
+ };
365
+ }),
366
+ );
308
367
 
309
368
  if (dryRun) {
310
369
  console.error('[DRY RUN] Would build:');
@@ -322,18 +381,19 @@ export const runBuilds = async ({
322
381
 
323
382
  return runAndSummarizeBuilds(actorConfigs, 'BUILDS', async (actorConfig, builder) => {
324
383
  const {
384
+ actorInfo,
325
385
  buildTag,
326
386
  versionNumber,
327
387
  gitRepoUrl,
328
388
  useDockerCache: useCache,
329
389
  } = buildConfigsByActorFullName.get(actorConfig.actorFullName)!;
330
- const actorVersion: ActorVersion = {
390
+ const actorVersion: ApifyClientTypes.ActorVersion = {
331
391
  buildTag,
332
392
  versionNumber,
333
393
  gitRepoUrl,
334
394
  sourceType: ActorSourceType.GitRepo,
335
395
  };
336
- return builder.createVersionAndBuild(versionNumber, actorVersion, useCache);
396
+ return builder.createVersionAndBuild(versionNumber, actorVersion, useCache, actorInfo);
337
397
  });
338
398
  };
339
399
 
package/bin/git.ts CHANGED
@@ -145,3 +145,143 @@ export const parseCommit = (commitString: string): Commit => {
145
145
  message,
146
146
  };
147
147
  };
148
+
149
+ /**
150
+ * Returns the currently checked-out branch. Release builds point the Actor version at this branch,
151
+ * so a detached HEAD (no branch to point at) is an error rather than a guess.
152
+ */
153
+ export const getCurrentBranch = (): string => {
154
+ const branch = spawnCommandInGhWorkspace('git rev-parse --abbrev-ref HEAD');
155
+ if (branch === 'HEAD') {
156
+ throw new Error(
157
+ 'Cannot determine the branch to release: HEAD is detached. Check out the branch you want to release.',
158
+ );
159
+ }
160
+ return branch;
161
+ };
162
+
163
+ /**
164
+ * Reads the repository URL from the `origin` remote, rewritten to the SSH form the Apify platform
165
+ * uses for Git repo sources, e.g. git@github.com:apify-store/google-maps
166
+ */
167
+ const getOriginRepoUrl = (): string => {
168
+ return spawnCommandInGhWorkspace('git remote get-url origin').replace(
169
+ /^https:\/\/github\.com\//,
170
+ 'git@github.com:',
171
+ );
172
+ };
173
+
174
+ /**
175
+ * Picks the repo URL to build from. By default it's the `origin` remote, and runBuilds then checks it against
176
+ * each Actor's default version, so a fork or mirror remote can't repoint a published Actor. An explicit
177
+ * --repo-url skips that check: passing it is how you move an Actor to another repository on purpose.
178
+ */
179
+ export const resolveRepoUrl = (explicitRepoUrl: string | undefined) => ({
180
+ repoUrl: explicitRepoUrl ?? getOriginRepoUrl(),
181
+ shouldVerifyRepoUrl: explicitRepoUrl === undefined,
182
+ });
183
+
184
+ /**
185
+ * Makes repo URLs comparable regardless of their form. All of these normalize to `github.com/org/repo`:
186
+ * - git@github.com:org/repo.git
187
+ * - https://github.com/org/repo
188
+ * - ssh://git@github.com/org/repo#master:actors/foo (Actor versions carry a `#branch:folder` fragment)
189
+ */
190
+ export const normalizeRepoUrl = (repoUrl: string): string => {
191
+ return repoUrl
192
+ .trim()
193
+ .split('#')[0]
194
+ .replace(/^[a-z+]+:\/\//i, '')
195
+ .replace(/^[^@/]+@/, '')
196
+ .replace(':', '/')
197
+ .replace(/\/+$/, '')
198
+ .replace(/\.git$/, '')
199
+ .toLowerCase();
200
+ };
201
+
202
+ /** Repository name from its URL, e.g. `google-maps` for git@github.com:apify-store/google-maps.git */
203
+ export const getRepoName = (repoUrl: string): string => {
204
+ return normalizeRepoUrl(repoUrl).split('/').pop()!;
205
+ };
206
+
207
+ // Sent by e.g. GitHub as the "before" commit of a push that created the branch
208
+ const ZERO_SHA_REGEX = /^0{40}$/;
209
+
210
+ /**
211
+ * Validates the base commit of a release: the last commit whose changes are already released.
212
+ * Unlike the PR path (getCommits), there is no lenient fallback. Falling back to "everything"
213
+ * would rebuild the latest build of every Actor and post it to Slack, so every problem is an error.
214
+ */
215
+ export const resolveReleaseBaseCommit = (baseCommit: string): string => {
216
+ const sha = parseBaseCommit(baseCommit);
217
+ if (!sha) {
218
+ throw new Error('--base-commit is required for release. See the README section "Releasing Actors".');
219
+ }
220
+ if (ZERO_SHA_REGEX.test(sha)) {
221
+ throw new Error(
222
+ `Base commit is ${sha}, which means the branch was just created and there is no previous release to diff against. ` +
223
+ `Rerun with --base-commit set to the last commit before the changes you want to release.`,
224
+ );
225
+ }
226
+ // --quiet makes rev-parse print nothing (instead of an error) when the commit is missing
227
+ if (!spawnCommandInGhWorkspace(`git rev-parse --verify --quiet "${sha}^{commit}"`)) {
228
+ throw new Error(
229
+ `Base commit ${sha} is not in the local git history. Either the checkout is shallow ` +
230
+ `(fetch the full history, e.g. fetch-depth: 0 in actions/checkout) or the branch was force-pushed.`,
231
+ );
232
+ }
233
+ // git merge-base A B outputs the common ancestor. If that equals A, then A is an ancestor of B.
234
+ if (spawnCommandInGhWorkspace(`git merge-base ${sha} HEAD`) !== sha) {
235
+ throw new Error(
236
+ `Base commit ${sha} is not an ancestor of HEAD, most likely because the branch was force-pushed. ` +
237
+ `The changed files cannot be determined reliably. Rerun with --base-commit set to an ancestor of HEAD ` +
238
+ `from before the changes you want to release.`,
239
+ );
240
+ }
241
+ return sha;
242
+ };
243
+
244
+ const CHANGELOG_PATH = 'CHANGELOG.md';
245
+
246
+ /** Returns the lines added to the root CHANGELOG.md between baseSha and HEAD, or null if it didn't change. */
247
+ const getChangelogAdditions = (baseSha: string, changedFiles: string[]): string | null => {
248
+ if (!changedFiles.includes(CHANGELOG_PATH)) {
249
+ return null;
250
+ }
251
+ const diff = spawnCommandInGhWorkspace('git', ['diff', baseSha, 'HEAD', '--', CHANGELOG_PATH]);
252
+
253
+ const added: string[] = [];
254
+ let startedChangelog = false;
255
+ for (const line of diff.split('\n')) {
256
+ // The diff is already limited to the changelog but better to double check
257
+ if (line.startsWith('+++') && line.toLowerCase().includes(CHANGELOG_PATH.toLowerCase())) {
258
+ startedChangelog = true;
259
+ continue;
260
+ }
261
+ if (startedChangelog) {
262
+ if (line.startsWith('diff')) {
263
+ break;
264
+ }
265
+ if (line.startsWith('+')) {
266
+ added.push(line.slice(1).trim());
267
+ }
268
+ }
269
+ }
270
+ return added.join('\n').trim();
271
+ };
272
+
273
+ /**
274
+ * Everything that changed since the last release (baseSha, exclusive) up to HEAD, or null when there
275
+ * are no new commits. Changed files come from diffing the range directly instead of from the commit
276
+ * list: with a merge commit, the oldest commit of the merged branch can be older than baseSha, and
277
+ * diffing from its parent would pull in already-released changes.
278
+ */
279
+ export const getReleaseChanges = (baseSha: string) => {
280
+ if (spawnCommandInGhWorkspace('git rev-parse HEAD') === baseSha) {
281
+ return null;
282
+ }
283
+ const commits = fetchAllBranchCommits('HEAD', baseSha);
284
+ const changedFiles = spawnCommandInGhWorkspace(`git diff --name-only ${baseSha} HEAD`).split('\n').filter(Boolean);
285
+ const changelog = getChangelogAdditions(baseSha, changedFiles);
286
+ return { commits, changedFiles, changelog };
287
+ };