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

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 (78) hide show
  1. package/.gitattributes +2 -0
  2. package/.github/actions/checkout-restore-dependencies/action.yaml +13 -29
  3. package/.github/cli/apify-test-tools.mjs +362 -0
  4. package/.github/scripts/run-with-apify-tokens.mjs +2 -2
  5. package/.github/workflows/_check_code.yaml +25 -0
  6. package/.github/workflows/_move_major_tag.yaml +3 -81
  7. package/.github/workflows/_tests.yaml +34 -0
  8. package/.github/workflows/_update_release_metadata.yaml +0 -12
  9. package/.github/workflows/manual_move_major_tag.yaml +3 -3
  10. package/.github/workflows/manual_release_stable.yaml +3 -13
  11. package/.github/workflows/on_master.yaml +9 -5
  12. package/.github/workflows/on_pull_request.yaml +3 -48
  13. package/.github/workflows/public_platform-tests.yaml +1 -1
  14. package/.github/workflows/public_pr-build-test.yaml +5 -28
  15. package/.github/workflows/public_push-build-latest.yaml +2 -2
  16. package/.husky/pre-commit +5 -0
  17. package/.prettierignore +3 -0
  18. package/CHANGELOG.md +5 -0
  19. package/CONTRIBUTING.md +8 -18
  20. package/README.md +40 -42
  21. package/bin/build-from-local.ts +47 -13
  22. package/bin/build.ts +35 -19
  23. package/bin/diff-changes.ts +24 -3
  24. package/bin/main.ts +35 -7
  25. package/bin/slack.ts +28 -6
  26. package/bin/test-report.ts +6 -9
  27. package/bin/types.ts +10 -0
  28. package/dist/bin/build-from-local.d.ts +10 -2
  29. package/dist/bin/build-from-local.d.ts.map +1 -1
  30. package/dist/bin/build-from-local.js +29 -11
  31. package/dist/bin/build-from-local.js.map +1 -1
  32. package/dist/bin/build.d.ts +15 -5
  33. package/dist/bin/build.d.ts.map +1 -1
  34. package/dist/bin/build.js +24 -15
  35. package/dist/bin/build.js.map +1 -1
  36. package/dist/bin/diff-changes.d.ts +2 -1
  37. package/dist/bin/diff-changes.d.ts.map +1 -1
  38. package/dist/bin/diff-changes.js +16 -5
  39. package/dist/bin/diff-changes.js.map +1 -1
  40. package/dist/bin/main.js +32 -6
  41. package/dist/bin/main.js.map +1 -1
  42. package/dist/bin/slack.d.ts +8 -1
  43. package/dist/bin/slack.d.ts.map +1 -1
  44. package/dist/bin/slack.js +26 -5
  45. package/dist/bin/slack.js.map +1 -1
  46. package/dist/bin/test-report.d.ts.map +1 -1
  47. package/dist/bin/test-report.js +6 -8
  48. package/dist/bin/test-report.js.map +1 -1
  49. package/dist/bin/types.d.ts +9 -0
  50. package/dist/bin/types.d.ts.map +1 -1
  51. package/dist/test/bundle/cli-bundle.test.d.ts +2 -0
  52. package/dist/test/bundle/cli-bundle.test.d.ts.map +1 -0
  53. package/dist/test/bundle/cli-bundle.test.js +162 -0
  54. package/dist/test/bundle/cli-bundle.test.js.map +1 -0
  55. package/dist/test/e2e/cli-bundle.e2e.test.d.ts +2 -0
  56. package/dist/test/e2e/cli-bundle.e2e.test.d.ts.map +1 -0
  57. package/dist/test/e2e/cli-bundle.e2e.test.js +172 -0
  58. package/dist/test/e2e/cli-bundle.e2e.test.js.map +1 -0
  59. package/dist/test/unit/bin/build-from-local.test.js +53 -1
  60. package/dist/test/unit/bin/build-from-local.test.js.map +1 -1
  61. package/dist/test/unit/bin/build.test.js +113 -3
  62. package/dist/test/unit/bin/build.test.js.map +1 -1
  63. package/dist/test/unit/bin/diff-changes.test.js +91 -0
  64. package/dist/test/unit/bin/diff-changes.test.js.map +1 -1
  65. package/dist/test/unit/bin/slack.test.d.ts +2 -0
  66. package/dist/test/unit/bin/slack.test.d.ts.map +1 -0
  67. package/dist/test/unit/bin/slack.test.js +54 -0
  68. package/dist/test/unit/bin/slack.test.js.map +1 -0
  69. package/dist/tsconfig.tsbuildinfo +1 -1
  70. package/package.json +15 -3
  71. package/test/bundle/cli-bundle.test.ts +192 -0
  72. package/test/e2e/cli-bundle.e2e.test.ts +237 -0
  73. package/test/unit/bin/build-from-local.test.ts +55 -0
  74. package/test/unit/bin/build.test.ts +135 -3
  75. package/test/unit/bin/diff-changes.test.ts +106 -0
  76. package/test/unit/bin/slack.test.ts +65 -0
  77. package/.github/scripts/check-package-version-bump.mjs +0 -74
  78. package/.github/workflows-package-version +0 -1
package/README.md CHANGED
@@ -57,7 +57,7 @@ When a PR is opened or code is pushed, the tool determines which actors need to
57
57
  2. **CHANGELOG classification** — a `CHANGELOG.md` file is always `cosmetic` (only triggers a release build, not tests), for every actor, regardless of context or folder. (See [issue #106](https://github.com/apify/apify-test-tools/issues/106).)
58
58
  3. **Context matching** — the file must fall within one of the actor's context paths (`dockerContextDir` from `actor.json` by default, or `overrideActorContext` from config if set). Files outside every context path are skipped.
59
59
  4. **Hardcoded ignore list, context-aware** — the file path is first "hoisted" relative to the context path it matched (e.g. a standalone actor's own `.eslintrc` is checked as just `.eslintrc`, not the full repo-root-relative path), then checked against repo-level dev file patterns (`.vscode/`, `.gitignore`, `.husky/`, `.eslintrc`, `eslint.config.mjs`, `.prettierrc`, `.editorconfig`). There's no hardcoded special-casing for legacy `code/`/`shared/` layouts — repos that need those directories treated as top-level must list them explicitly in `overrideActorContext`.
60
- 5. **`.dockerignore` filtering** — if a `.dockerignore` exists at the root of the actor's `dockerContextDir`, matching files are ignored. Patterns are resolved relative to `dockerContextDir`, matching Docker's own behavior.
60
+ 5. **`.dockerignore` filtering** — if a `.dockerignore` exists at the root of the actor's `dockerContextDir`, matching files are ignored. Patterns are resolved relative to `dockerContextDir`, matching Docker's own behavior. Test files are the exception: `build --test-files-glob <glob>` (relative to the repo root) skips this check for matching files, so a changed test triggers a build, and with it its tests, even when `.dockerignore` lists it. The PR workflow passes `test/platform/**`. Context matching still applies, so tests outside an actor's `dockerContextDir` (e.g. a root `test/` with standalone actors) need to be listed in its `overrideActorContext`; `build` logs a warning for changed test files outside every actor's context.
61
61
  6. **README classification** — a `README.md` file is `cosmetic` (only triggers a release build, not tests) if it lives inside the actor's own `folder`; otherwise it's ignored entirely, since it isn't documentation for this actor.
62
62
  7. **Cosmetic JSON classification** — `.json` files inside the actor's own `.actor/` directory with only cosmetic schema changes (whitespace, key ordering) only trigger a release build.
63
63
  8. **Functional** — everything else triggers both build and tests.
@@ -308,48 +308,35 @@ Slack credentials in scope, so a postinstall script in the dependency tree canno
308
308
 
309
309
  ## Versioning and releases
310
310
 
311
- The workflows and the npm package live in one repo but ship on their own schedules. Two pointers
312
- decide what a consumer repo actually runs:
311
+ The workflows and the npm package live in one repo but ship on their own schedules:
313
312
 
314
- | Pointer | What it selects | Moves when |
315
- | ----------------------------------- | ---------------------------------------------- | -------------------------------------------------------- |
316
- | the `@workflows-v0` tag in `uses:` | which workflows run | a master push, once the version below is published |
317
- | `.github/workflows-package-version` | which `apify-test-tools` the workflows install | a stable release writes it; you may set it ahead of time |
313
+ | What | Consumers get it through | Ships when |
314
+ | -------------------------------------------- | ------------------------------------------ | ----------------------- |
315
+ | the workflows and the CLI they call | the `@workflows-v0` tag in `uses:` | every master push |
316
+ | the lib (`testActor`, `describe`, matchers…) | `apify-test-tools` in their `package.json` | a stable release on npm |
318
317
 
319
- The setup action installs that **exact** version — not a range. A tag is therefore a complete
320
- statement: these workflows _and_ this library. The stable release writes the file into the same
321
- commit that bumps `package.json` and `CHANGELOG.md`, so a released commit always names the version
322
- it published, and rollout latency is unchanged: the release publishes and moves the tag in one run.
318
+ The CLI is bundled with its dependencies into `.github/cli/apify-test-tools.mjs` (`npm run bundle`,
319
+ run by the pre-commit hook and checked in CI), and the setup action runs it from there. Nothing is
320
+ installed for it, so a tag is a complete statement: these workflows and this CLI, with the exact
321
+ dependency versions from `package-lock.json` at that commit. The consumer's own `node_modules` is
322
+ left alone, so their tests keep the lib version they locked.
323
323
 
324
- Prereleases are excluded. Master pushes publish a `-beta` that consumer repos must never install, so
325
- the pin only moves on a stable release; betas stay reachable through the lockfile path in the setup
326
- action, which is how branch testing works.
324
+ - **Workflow or CLI change** — merge it. `workflows-v0` moves once checks and tests pass, and it goes
325
+ live. No release needed, even when a workflow starts using a new CLI feature: both are in the
326
+ same commit.
327
+ - **Lib change** — merge it, then cut a release when you want it out. Consumers pick it up when
328
+ they update `apify-test-tools`.
327
329
 
328
- Nothing is coupled that doesn't need to be:
329
-
330
- - **Workflow-only change** — merge it. `workflows-v0` moves, it goes live, no release needed.
331
- - **Package-only change** — merge it, then cut a release when you want it out. The workflows are
332
- unchanged, so consumers see nothing until the release lands.
333
- - **A workflow that calls a new CLI feature** — the one case that can break consumers, and the only
334
- one with any ceremony. Put the package change, the workflow change, and the new pin in one PR. On
335
- merge the tag is **held**: the CI job reports that the pinned version isn't on npm and leaves `workflows-v0`
336
- where it is, so consumers keep running the previous workflows. Cut a stable release, and the tag
337
- moves on its own. Run **Move major version tag** if you don't want to wait for the next master
338
- push.
330
+ The CLI and the lib in one job can be different versions now, so the formats between them are a
331
+ contract: the `ACTOR_BUILDS` JSON that `build` writes and the lib reads, and the vitest JSON report
332
+ `report-tests` reads. Add fields freely; renaming or removing one needs a new major tag.
339
333
 
340
334
  `workflows-v0` moving on every master push means `@workflows-v0` is as live as `@master` was —
341
- there's no staging step, just a gate on the package version. What the tag buys you is a
342
- `workflows-v1` for breaking workflow changes, so repos migrate one at a time instead of all at once,
343
- and a way to roll back by pointing the tag at an earlier commit. Bump `MAJOR_TAG` in
335
+ there's no staging step. What the tag buys you is a `workflows-v1` for breaking workflow changes,
336
+ so repos migrate one at a time instead of all at once, and a way to roll back by pointing the tag at
337
+ an earlier commit (**Move major version tag** workflow). Bump `MAJOR_TAG` in
344
338
  `.github/workflows/_move_major_tag.yaml` to cut the next major; the old tag then freezes where it is
345
- and keeps working.
346
-
347
- Freezing is real, which is the whole reason the version is pinned rather than a floor. A frozen `workflows-v0`
348
- points at a commit whose pinned version never changes, so it keeps installing the library it was
349
- tested against no matter how far the package moves on. Had the workflows installed `>=<floor>`, a
350
- frozen `workflows-v0` would still resolve to whatever is newest — and since the release that enables
351
- `workflows-v1` is usually the same release that breaks `workflows-v0`, the migration window would
352
- have been zero.
339
+ and keeps working, CLI included, since the bundle is part of the frozen commit.
353
340
 
354
341
  The tag tracks the **workflows'** contract, not the npm package version. They move independently on
355
342
  purpose, so `@workflows-v0` is expected to stay `@workflows-v0` after the package reaches 1.0 —
@@ -360,9 +347,8 @@ and those refs disagree, which is the mistake that would otherwise ship silently
360
347
  ### Testing workflow changes
361
348
 
362
349
  - Point [testing-repo-for-github-actions](https://github.com/apify-store/testing-repo-for-github-actions)
363
- at your branch (`uses: ...@your-branch`). It has real attached Actors and tests. Because the
364
- package lives here too, a master push publishes a `beta`, and the lockfile-beta path in the setup
365
- action installs that exact version — so one branch tests both halves of a change together.
350
+ at your branch (`uses: ...@your-branch`). It has real attached Actors and tests. The branch carries
351
+ its own CLI bundle, so one branch tests the workflows and the CLI together.
366
352
  - To change the composite action itself, repoint the `uses:` refs inside the reusable workflows at
367
353
  your branch as well, and change them back before merging.
368
354
  - Make sure the shell code actually works on your laptop first.
@@ -580,6 +566,15 @@ describe('standby tests', () => {
580
566
 
581
567
  The package includes a CLI binary used by CI workflows to build Actors, detect changes, and report test results. You can also run it locally.
582
568
 
569
+ ### Dry runs (`--dry-run`)
570
+
571
+ Every command that changes something (`build`, `build-from-local`, `release`, `delete-old-builds`, `report-tests`) takes `--dry-run`. A dry run:
572
+
573
+ - **Changes nothing.** No versions or builds are created, nothing is uploaded or deleted, and no Slack message is posted.
574
+ - **Does every read and check the real run does**, so it fails wherever the real run would: change detection, the Apify token and Actor lookup, the [repository check](#repository-check), collecting and zipping local sources, and the Slack token (verified with Slack's read-only `auth.test`).
575
+ - **Needs the same tokens as the real run.** Read-only tokens are enough, since nothing is written. A dry run can't tell whether a token is also allowed to write, because the Apify API has no dry-run mode of its own.
576
+ - **Prints a plan, not results.** Logs start with `[DRY RUN] Would …`. `build` and `build-from-local` write `{ "dryRun": true, "builds": [...] }` to stdout instead of the build array, so the plan can't be passed to tests as `ACTOR_BUILDS` by mistake.
577
+
583
578
  ### Running locally
584
579
 
585
580
  Running the testing library locally is useful when you only want to update the testing code in /test because you can iterate on it without pushing new code to the remote.
@@ -610,7 +605,7 @@ APIFY_TOKEN_JOHN_DOE=<token> \
610
605
  --dry-run
611
606
  ```
612
607
 
613
- Remove `--dry-run` to actually trigger builds and update the branch names/ The command outputs a JSON array of build objects to stdout:
608
+ With `--dry-run`, it prints the plan (the version and Git URL each Actor would be built from). Remove `--dry-run` to actually trigger builds and update the branch names. The command outputs a JSON array of build objects to stdout:
614
609
 
615
610
  ```json
616
611
  [{ "buildId": "...", "actorRawId": "...", "buildNumber": "...", "actorFullName": "john.doe/my-actor" }]
@@ -625,7 +620,7 @@ APIFY_TOKEN_JOHN_DOE=<token> \
625
620
  npx apify-test-tools build-from-local --actors john.doe/my-actor
626
621
  ```
627
622
 
628
- Pass a hardcoded actor name via `--actors` to build only that Actor (comma-separate multiple names). Omit `--actors` to build all Actors in the repo, or add `--dry-run` to preview without building. It outputs the same JSON build array as `build`, so you run tests against it the same way as in step 5 below:
623
+ Pass a hardcoded actor name via `--actors` to build only that Actor (comma-separate multiple names). Omit `--actors` to build all Actors in the repo, or add `--dry-run` to check the token, the Actor and the zipped sources without uploading or building. It outputs the same JSON build array as `build`, so you run tests against it the same way as in step 5 below:
629
624
 
630
625
  ```bash
631
626
  # Build from local source and capture output
@@ -649,12 +644,15 @@ TESTER_APIFY_TOKEN=<token> \
649
644
 
650
645
  #### Full example
651
646
 
647
+ Tests only run for Actors in `ACTOR_BUILDS`, so a custom workflow has to build (and pass) the Actors even when only tests changed. Pass `--test-files-glob` so test changes count even when `.dockerignore` lists them.
648
+
652
649
  ```bash
653
650
  # Build and capture output
654
651
  BUILDS=$(APIFY_TOKEN_JOHN_DOE=apify_api_xxx \
655
652
  npx apify-test-tools build \
656
653
  --target-branch origin/master \
657
- --source-branch origin/my-dummy-branch)
654
+ --source-branch origin/my-dummy-branch \
655
+ --test-files-glob 'test/platform/**')
658
656
 
659
657
  # Run tests with the builds
660
658
  ACTOR_BUILDS="$BUILDS" \
@@ -6,11 +6,11 @@ import AdmZip from 'adm-zip';
6
6
  import type * as ApifyClientTypes from 'apify-client';
7
7
  import { ActorSourceType } from 'apify-client';
8
8
 
9
- import { dryRunBuildData, LOCAL_SOURCE_VERSION_NUMBER, runAndSummarizeBuilds } from './build.js';
9
+ import { ApifyBuilder, LOCAL_SOURCE_VERSION_NUMBER, runAndSummarizeBuilds } from './build.js';
10
10
  import { loadDockerIgnore } from './dockerignore.js';
11
11
  import { logger } from './logger.js';
12
12
  import { isPathWithinScope } from './path-utils.js';
13
- import type { ActorConfig, BuildData } from './types.js';
13
+ import type { ActorConfig, BuildData, DryRunPlan } from './types.js';
14
14
  import type { SourceFile } from './utils.js';
15
15
  import { getGitignoredPaths, isOutsideDir, listRepoFilePaths, readSourceFile } from './utils.js';
16
16
  import type { RelativeDir } from './utils/path/repo-relative.js';
@@ -179,32 +179,66 @@ export const rewriteActorJsonPaths = async (
179
179
  await fs.writeFile(path.join(newActorDir, 'actor.json'), JSON.stringify(rewritten, null, 4));
180
180
  };
181
181
 
182
+ type LocalBuildPlan = {
183
+ actorFullName: string;
184
+ versionNumber: string;
185
+ folder: string;
186
+ fileCount: number;
187
+ zipSizeBytes: number;
188
+ };
189
+
182
190
  export const runBuildsFromLocal = async ({
183
191
  actorConfigs,
184
192
  dryRun,
185
193
  }: {
186
194
  actorConfigs: ActorConfig[];
187
195
  dryRun: boolean;
188
- }): Promise<BuildData[]> => {
196
+ }): Promise<BuildData[] | DryRunPlan<LocalBuildPlan>> => {
197
+ // Read-only preparation shared with the real build, so a dry run fails on a missing token, an unknown
198
+ // Actor or unreadable sources just like the build would
199
+ const preparedBuilds = await Promise.all(
200
+ actorConfigs.map(async (actorConfig) => {
201
+ const actorInfo = await ApifyBuilder.fromActorConfig(actorConfig).getActorInfo();
202
+ // Zipped and uploaded like `apify push` does, since inline SOURCE_FILES are capped at ~9 MB.
203
+ const zip = new AdmZip();
204
+ const sourceFiles = await collectSourceFiles(actorConfig);
205
+ for (const { name, content } of sourceFiles) {
206
+ zip.addFile(name, content);
207
+ }
208
+ return { actorConfig, actorInfo, fileCount: sourceFiles.length, zipBuffer: zip.toBuffer() };
209
+ }),
210
+ );
211
+
189
212
  if (dryRun) {
190
213
  logger.info('[DRY RUN] Would build from local source:');
191
- for (const { actorFullName, folder } of actorConfigs) {
192
- logger.info(` ${actorFullName} (${folder})`);
214
+ for (const { actorConfig, fileCount, zipBuffer } of preparedBuilds) {
215
+ logger.info(
216
+ ` ${actorConfig.actorFullName} (${actorConfig.folder}): ${fileCount} files, ${zipBuffer.length} bytes zipped`,
217
+ );
193
218
  }
194
- return actorConfigs.map(({ actorFullName }) => dryRunBuildData(actorFullName, LOCAL_SOURCE_VERSION_NUMBER));
219
+ return {
220
+ dryRun: true,
221
+ builds: preparedBuilds.map(({ actorConfig, fileCount, zipBuffer }) => ({
222
+ actorFullName: actorConfig.actorFullName,
223
+ versionNumber: LOCAL_SOURCE_VERSION_NUMBER,
224
+ folder: actorConfig.folder.path,
225
+ fileCount,
226
+ zipSizeBytes: zipBuffer.length,
227
+ })),
228
+ };
195
229
  }
196
230
 
231
+ const preparedByActorFullName = new Map(
232
+ preparedBuilds.map((prepared) => [prepared.actorConfig.actorFullName, prepared]),
233
+ );
234
+
197
235
  return runAndSummarizeBuilds(actorConfigs, 'LOCAL BUILDS', async (actorConfig, builder) => {
198
- // Zipped and uploaded like `apify push` does, since inline SOURCE_FILES are capped at ~9 MB.
199
- const zip = new AdmZip();
200
- for (const { name, content } of await collectSourceFiles(actorConfig)) {
201
- zip.addFile(name, content);
202
- }
236
+ const { actorInfo, zipBuffer } = preparedByActorFullName.get(actorConfig.actorFullName)!;
203
237
  const actorVersion: ApifyClientTypes.ActorVersion = {
204
238
  versionNumber: LOCAL_SOURCE_VERSION_NUMBER,
205
- tarballUrl: await builder.uploadSourceZip(LOCAL_SOURCE_VERSION_NUMBER, zip.toBuffer()),
239
+ tarballUrl: await builder.uploadSourceZip(LOCAL_SOURCE_VERSION_NUMBER, zipBuffer),
206
240
  sourceType: ActorSourceType.Tarball,
207
241
  };
208
- return builder.createVersionAndBuild(LOCAL_SOURCE_VERSION_NUMBER, actorVersion, false);
242
+ return builder.createVersionAndBuild(LOCAL_SOURCE_VERSION_NUMBER, actorVersion, false, actorInfo);
209
243
  });
210
244
  };
package/bin/build.ts CHANGED
@@ -3,7 +3,7 @@ import { ActorSourceType, ApifyClient } from 'apify-client';
3
3
 
4
4
  import { normalizeRepoUrl } from './git.js';
5
5
  import { logger } from './logger.js';
6
- import type { ActorConfig, BuildData } from './types.js';
6
+ import type { ActorConfig, BuildData, DryRunPlan } from './types.js';
7
7
 
8
8
  type BuildPrActorOptions = {
9
9
  buildTag?: string;
@@ -179,7 +179,7 @@ export class ApifyBuilder {
179
179
  * Deletes build of all versions. Apify API doesn't allow to delete default build and we explicitly skip it
180
180
  * We delete devel builds faster because we used the for every PR until recently so just to get rid of them faster
181
181
  */
182
- async deleteOldBuilds(): Promise<void> {
182
+ async deleteOldBuilds({ dryRun }: { dryRun: boolean }): Promise<void> {
183
183
  // Even though we don't version our current Actors, if we ever such Actors to GitHub CI, we would accidentally delete old supported versions
184
184
  // This hardcoded solution is not ideal, but it should prevent most imaginable cases
185
185
  // All currently popular versioned Actors use `version-${number}` format
@@ -258,9 +258,15 @@ export class ApifyBuilder {
258
258
  });
259
259
 
260
260
  logger.info(
261
- `[DELETE OLD BUILDS][${this.actorFullName}]: Deleting ${buildsToDelete.length} old builds that are non-default and ` +
262
- `older than 30 days from total ${items.length}`,
261
+ `[DELETE OLD BUILDS][${this.actorFullName}]: ${dryRun ? '[DRY RUN] Would delete' : 'Deleting'} ${buildsToDelete.length} ` +
262
+ `old builds that are non-default and older than 30 days from total ${items.length}`,
263
263
  );
264
+ if (dryRun) {
265
+ for (const build of buildsToDelete) {
266
+ logger.info(` ${build.buildNumber} (${build.id}, started ${build.startedAt.toISOString()})`);
267
+ }
268
+ return;
269
+ }
264
270
  for (const build of buildsToDelete) {
265
271
  await this.apifyClient.build(build.id).delete();
266
272
  }
@@ -310,13 +316,13 @@ export const runAndSummarizeBuilds = async (
310
316
  return waitAndSummarizeBuilds(startedBuilds, buildersByActorFullName, label);
311
317
  };
312
318
 
313
- // Placeholder BuildData for dry runs, since no real build was triggered.
314
- export const dryRunBuildData = (actorFullName: string, versionNumber: string): BuildData => ({
315
- buildId: 'dry-run',
316
- actorRawId: 'dry-run',
317
- buildNumber: versionNumber,
318
- actorFullName,
319
- });
319
+ type GitBuildPlan = {
320
+ actorFullName: string;
321
+ versionNumber: string;
322
+ buildTag?: string;
323
+ gitRepoUrl: string;
324
+ useDockerCache: boolean;
325
+ };
320
326
 
321
327
  type RunBuildsOptions = {
322
328
  actorConfigs: ActorConfig[];
@@ -337,7 +343,7 @@ export const runBuilds = async ({
337
343
  isLatest = false,
338
344
  dryRun,
339
345
  useDockerCache,
340
- }: RunBuildsOptions): Promise<BuildData[]> => {
346
+ }: RunBuildsOptions): Promise<BuildData[] | DryRunPlan<GitBuildPlan>> => {
341
347
  // Fetched once per Actor and reused for the checks, the version numbers and the build itself
342
348
  const buildConfigs: BuildPrActorOptions[] = await Promise.all(
343
349
  actorConfigs.map(async (actorConfig) => {
@@ -366,14 +372,24 @@ export const runBuilds = async ({
366
372
  }),
367
373
  );
368
374
 
375
+ // Everything above only reads, so a dry run has checked what the real run would, short of writing
369
376
  if (dryRun) {
370
377
  logger.info('[DRY RUN] Would build:');
371
- for (const { actorConfig, versionNumber } of buildConfigs) {
372
- logger.info(` ${actorConfig.actorFullName} (${versionNumber})`);
378
+ for (const { actorConfig, versionNumber, gitRepoUrl } of buildConfigs) {
379
+ logger.info(` ${actorConfig.actorFullName} (${versionNumber}) from ${gitRepoUrl}`);
373
380
  }
374
- return buildConfigs.map(({ actorConfig, versionNumber }) =>
375
- dryRunBuildData(actorConfig.actorFullName, versionNumber),
376
- );
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
+ };
377
393
  }
378
394
 
379
395
  const buildConfigsByActorFullName = new Map(
@@ -398,8 +414,8 @@ export const runBuilds = async ({
398
414
  });
399
415
  };
400
416
 
401
- export const deleteOldBuilds = async (actorConfigs: ActorConfig[]) => {
417
+ export const deleteOldBuilds = async (actorConfigs: ActorConfig[], { dryRun }: { dryRun: boolean }) => {
402
418
  for (const actorConfig of actorConfigs) {
403
- await ApifyBuilder.fromActorConfig(actorConfig).deleteOldBuilds();
419
+ await ApifyBuilder.fromActorConfig(actorConfig).deleteOldBuilds({ dryRun });
404
420
  }
405
421
  };
@@ -1,3 +1,5 @@
1
+ import picomatch from 'picomatch';
2
+
1
3
  import { isCosmeticOnlyJsonSchemaChange } from './diff-json-schema.js';
2
4
  import { type DockerIgnoreMatcher, loadDockerIgnore } from './dockerignore.js';
3
5
  import { logger } from './logger.js';
@@ -9,6 +11,8 @@ interface ShouldBuildAndTestOptions {
9
11
  actorConfigs: ActorConfig[];
10
12
  isLatest?: boolean;
11
13
  commits: Commit[];
14
+ // Glob relative to the repo root. Matching files count as changes even when .dockerignore lists them.
15
+ testFilesGlob?: string;
12
16
  }
13
17
 
14
18
  const IGNORED_TOP_LEVEL_FILES = [
@@ -41,7 +45,7 @@ type FileChangeForActor =
41
45
  * 2. Context matching (actorConfig.contextPaths) → outside-context if no match
42
46
  * 3. Hardcoded ignore list, checked against the path hoisted relative to the matched context entry → ignored
43
47
  * 4. .dockerignore filtering (patterns relative to dockerContextDir), skipped for the actor's own `.actor/`
44
- * dir → ignored if matched
48
+ * dir and for test files → ignored if matched
45
49
  * 5. README.md by filename → cosmetic if inside the actor's own folder, otherwise ignored
46
50
  * 6. .json inside the actor's own `.actor/` dir with only cosmetic schema diffs → cosmetic (semantically verified)
47
51
  * 7. Everything else → functional
@@ -51,6 +55,7 @@ const classifyFileChange = async (
51
55
  actorConfig: ActorConfig,
52
56
  commits: Commit[],
53
57
  dockerIgnoreMatcher: DockerIgnoreMatcher,
58
+ isTestFile: (filePath: string) => boolean,
54
59
  ): Promise<FileChangeForActor> => {
55
60
  const lowercaseFilePath = changedFile.path.toLowerCase();
56
61
 
@@ -75,7 +80,8 @@ const classifyFileChange = async (
75
80
  // .actor/ can legitimately be listed in .dockerignore (the Apify platform evaluates it before
76
81
  // the Docker build, so excluding it from the build context is a valid caching optimization) —
77
82
  // that shouldn't cause changes to .actor/ itself to be ignored here.
78
- if (!isUnderActorDotDir && dockerIgnoreMatcher(changedFile.path)) {
83
+ // Test files are often in .dockerignore too, but a change to them must still build and test.
84
+ if (!isUnderActorDotDir && !isTestFile(changedFile.path) && dockerIgnoreMatcher(changedFile.path)) {
79
85
  return { impact: 'ignored' };
80
86
  }
81
87
 
@@ -163,8 +169,11 @@ export const getChangedActors = async ({
163
169
  actorConfigs,
164
170
  isLatest = false,
165
171
  commits,
172
+ testFilesGlob,
166
173
  }: ShouldBuildAndTestOptions): Promise<ActorConfig[]> => {
167
174
  const actorsChangedMap = new Map<string, ActorChangeEntry>();
175
+ const testFilesInContext = new Set<string>();
176
+ const isTestFile = testFilesGlob ? picomatch(testFilesGlob, { nocase: true, dot: true }) : () => false;
168
177
 
169
178
  for (const actorConfig of actorConfigs) {
170
179
  const dockerIgnoreMatcher = loadDockerIgnore(actorConfig.dockerContextDir);
@@ -176,7 +185,10 @@ export const getChangedActors = async ({
176
185
  continue;
177
186
  }
178
187
 
179
- const change = await classifyFileChange(filePath, actorConfig, commits, dockerIgnoreMatcher);
188
+ const change = await classifyFileChange(filePath, actorConfig, commits, dockerIgnoreMatcher, isTestFile);
189
+ if (change.impact !== 'outside-context' && isTestFile(originalFilePath)) {
190
+ testFilesInContext.add(originalFilePath);
191
+ }
180
192
 
181
193
  if (change.impact === 'ignored' || change.impact === 'outside-context') continue;
182
194
  if (change.impact === 'cosmetic' && !isLatest) continue;
@@ -195,6 +207,15 @@ export const getChangedActors = async ({
195
207
  const groups = groupFilesByActorSet(fileToActors);
196
208
  logChangeGroups(groups);
197
209
 
210
+ for (const testFile of filepathsChanged.filter(
211
+ (filePath) => isTestFile(filePath) && !testFilesInContext.has(filePath),
212
+ )) {
213
+ logger.warn(
214
+ `[DIFF]: Test file ${testFile} is outside every Actor's context, so changing only it builds and tests nothing. ` +
215
+ `Add its folder to "overrideActorContext" of the Actors it tests.`,
216
+ );
217
+ }
218
+
198
219
  if (actorsChanged.length > 0) {
199
220
  const actors = actorsChanged.map((config) => config.actorFullName);
200
221
  logger.info(`[DIFF]: Actors to be built and tested: ${actors.join(', ')}`);
package/bin/main.ts CHANGED
@@ -64,6 +64,19 @@ const actorSelectionOptions = {
64
64
  ignore: { type: 'string', array: true, default: [] as string[] },
65
65
  } as const satisfies Record<string, Options>;
66
66
 
67
+ /**
68
+ * For commands that change something (builds, deletes, Slack posts). A dry run still does every read and check
69
+ * the real run does, credentials included, and only skips the writes. So it needs the same tokens, though
70
+ * read-only ones are enough. Read-only commands don't take it since they have nothing to skip.
71
+ */
72
+ const dryRunOptions = {
73
+ 'dry-run': {
74
+ type: 'boolean',
75
+ default: false,
76
+ describe: 'Do every read and check (tokens included) but change nothing; print what would be done',
77
+ },
78
+ } as const satisfies Record<string, Options>;
79
+
67
80
  /** Flags for commands that trigger Apify builds. */
68
81
  const buildOptions = {
69
82
  'use-docker-cache': { type: 'boolean', default: false },
@@ -77,7 +90,6 @@ const globalOptions = {
77
90
  default: 'info',
78
91
  describe: 'Minimum diagnostic level written to stderr; silent disables logging',
79
92
  },
80
- 'dry-run': { type: 'boolean', default: false },
81
93
  } as const satisfies Record<string, Options>;
82
94
 
83
95
  const resolveChangedActors = async (config: Config, { isLatest }: { isLatest: boolean }) => {
@@ -99,6 +111,7 @@ const resolveChangedActors = async (config: Config, { isLatest }: { isLatest: bo
99
111
  filepathsChanged: branchOnlyFiles,
100
112
  actorConfigs,
101
113
  commits: allBranchCommits,
114
+ testFilesGlob: config.testFilesGlob,
102
115
  });
103
116
  if (branchOnlyActorsChanged.length === 0) {
104
117
  logger.info('[MERGE-FROM-TARGET-OPTIMIZATION]: Branch itself has no functional changes, skipping tests');
@@ -112,7 +125,13 @@ const resolveChangedActors = async (config: Config, { isLatest }: { isLatest: bo
112
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)
113
126
  const commits = await getCommits(config);
114
127
  const changedFiles = await getChangedFiles(commits);
115
- return getChangedActors({ filepathsChanged: changedFiles, actorConfigs, isLatest, commits });
128
+ return getChangedActors({
129
+ filepathsChanged: changedFiles,
130
+ actorConfigs,
131
+ isLatest,
132
+ commits,
133
+ testFilesGlob: config.testFilesGlob,
134
+ });
116
135
  };
117
136
 
118
137
  await yargs()
@@ -172,6 +191,7 @@ await yargs()
172
191
  '',
173
192
  (y) =>
174
193
  y
194
+ .options(dryRunOptions)
175
195
  .option('report-file', { type: 'string', demandOption: true })
176
196
  .option('report-slack-channel', { type: 'string' })
177
197
  .option('job-url', { type: 'string' })
@@ -183,7 +203,14 @@ await yargs()
183
203
  .command(
184
204
  'build',
185
205
  '',
186
- (y) => y.options(gitRangeOptions).options(actorSelectionOptions).options(buildOptions).options(repoUrlOptions),
206
+ (y) =>
207
+ y
208
+ .options(gitRangeOptions)
209
+ .options(actorSelectionOptions)
210
+ .options(buildOptions)
211
+ .options(repoUrlOptions)
212
+ .options(dryRunOptions)
213
+ .option('test-files-glob', { type: 'string' }),
187
214
  async (config) => {
188
215
  const actorsChanged = await resolveChangedActors(config, { isLatest: false });
189
216
  const builds = await runBuilds({
@@ -204,6 +231,7 @@ await yargs()
204
231
  .options(actorSelectionOptions)
205
232
  .options(buildOptions)
206
233
  .options(repoUrlOptions)
234
+ .options(dryRunOptions)
207
235
  // The last commit that is already released, everything after it up to HEAD gets released.
208
236
  // Required, see the README section "Releasing Actors" for how to set it for each merge strategy.
209
237
  .option('base-commit', { type: 'string', demandOption: true })
@@ -256,7 +284,7 @@ await yargs()
256
284
  .command(
257
285
  'build-from-local',
258
286
  '',
259
- (y) => y.options(actorSelectionOptions),
287
+ (y) => y.options(actorSelectionOptions).options(dryRunOptions),
260
288
  async ({ actors, ignore, dryRun }) => {
261
289
  const actorConfigs = await readConfigFile({ actors, ignore });
262
290
  const builds = await runBuildsFromLocal({ actorConfigs, dryRun });
@@ -266,10 +294,10 @@ await yargs()
266
294
  .command(
267
295
  'delete-old-builds',
268
296
  '',
269
- (y) => y.options(actorSelectionOptions),
270
- async ({ actors, ignore }) => {
297
+ (y) => y.options(actorSelectionOptions).options(dryRunOptions),
298
+ async ({ actors, ignore, dryRun }) => {
271
299
  const actorConfigs = await readConfigFile({ actors, ignore });
272
- await deleteOldBuilds(actorConfigs);
300
+ await deleteOldBuilds(actorConfigs, { dryRun });
273
301
  },
274
302
  )
275
303
  .strictCommands()
package/bin/slack.ts CHANGED
@@ -15,6 +15,25 @@ type NotifyToSlackOptions = {
15
15
  reportSlackChannel?: string;
16
16
  };
17
17
 
18
+ /**
19
+ * Creates the client the real run posts with. A dry run posts nothing but still requires the token and checks
20
+ * Slack accepts it (`auth.test` is read-only), so it fails wherever the real run would.
21
+ */
22
+ export const getSlackClient = async (tokenEnvVar: string, dryRun: boolean): Promise<WebClient> => {
23
+ const slack = new WebClient(getEnvVar(tokenEnvVar));
24
+ if (dryRun) {
25
+ try {
26
+ await slack.auth.test();
27
+ } catch (err) {
28
+ throw new Error(`${tokenEnvVar} was rejected by Slack: ${(err as Error).message}`);
29
+ }
30
+ logger.info(`[DRY RUN] ${tokenEnvVar} is valid`);
31
+ }
32
+ return slack;
33
+ };
34
+
35
+ export const sendingLabel = (dryRun: boolean) => (dryRun ? '[DRY RUN] Would send' : 'Sending');
36
+
18
37
  // Git authors come as "Name<email>", Slack only needs the name
19
38
  const displayName = (author: string) => author.replace(/\s*<[^>]*>$/, '');
20
39
 
@@ -28,19 +47,23 @@ export const notifyToSlack = async ({
28
47
  releaseSlackChannel,
29
48
  reportSlackChannel,
30
49
  }: NotifyToSlackOptions) => {
31
- const slack = new WebClient(getEnvVar('SLACK_TOKEN_RELEASES_BOT'));
32
-
33
50
  if (!changelog) {
34
51
  logger.warn('No new changelog entries found, did you forget to update it?');
35
52
  }
36
53
 
54
+ if (!releaseSlackChannel && !reportSlackChannel) {
55
+ logger.info('Skipping slack notification, no --release-slack-channel or --report-slack-channel passed');
56
+ return;
57
+ }
58
+ const slack = await getSlackClient('SLACK_TOKEN_RELEASES_BOT', dryRun);
59
+
37
60
  let shortMessage = `*${repository}* – New release (by ${displayName(author)}):\n\n`;
38
61
 
39
62
  // This one is just for broader public that only cares about public facing changes
40
63
  if (changelog && releaseSlackChannel) {
41
64
  shortMessage += `*Additions to the changelog*:\n\n${changelog}\n`;
42
65
  logger.info(`=========================================`);
43
- logger.info(`**Sending slack message to channel**: ${releaseSlackChannel}.\n\n${shortMessage}`);
66
+ logger.info(`**${sendingLabel(dryRun)} slack message to channel**: ${releaseSlackChannel}.\n\n${shortMessage}`);
44
67
  logger.info(`=========================================`);
45
68
  if (!dryRun) {
46
69
  await slack.chat.postMessage({
@@ -62,7 +85,7 @@ export const notifyToSlack = async ({
62
85
  // This one is for devs and project managers that need to know more details
63
86
  if (reportSlackChannel) {
64
87
  logger.info(`=========================================`);
65
- logger.info(`Sending slack message to channel: ${reportSlackChannel}.\n\n${longMessage}`);
88
+ logger.info(`${sendingLabel(dryRun)} slack message to channel: ${reportSlackChannel}.\n\n${longMessage}`);
66
89
  logger.info(`=========================================`);
67
90
  if (!dryRun) {
68
91
  await slack.chat.postMessage({
@@ -73,8 +96,7 @@ export const notifyToSlack = async ({
73
96
  }
74
97
  };
75
98
 
76
- export const sendSlackMessage = async (channel: string, text: string, blocks: string[], token: string) => {
77
- const slack = new WebClient(token);
99
+ export const sendSlackMessage = async (slack: WebClient, channel: string, text: string, blocks: string[]) => {
78
100
  const { ts } = await slack.chat.postMessage({ text, channel });
79
101
  if (blocks.length > 0 && ts) {
80
102
  await slack.chat.postMessage({
@@ -1,8 +1,7 @@
1
1
  import fs from 'node:fs/promises';
2
2
 
3
3
  import { logger } from './logger.js';
4
- import { sendSlackMessage } from './slack.js';
5
- import { getEnvVar } from './utils.js';
4
+ import { getSlackClient, sendingLabel, sendSlackMessage } from './slack.js';
6
5
 
7
6
  interface ReportTestResultsOptions {
8
7
  reportFile: string;
@@ -80,6 +79,9 @@ export const reportTestResults = async ({
80
79
  );
81
80
  return;
82
81
  }
82
+ // Checked before knowing if there's anything to send, so a broken token shows up on a green run
83
+ // (and in dry runs) rather than on the day tests fail
84
+ const slack = await getSlackClient('SLACK_TOKEN_TESTS_BOT', dryRun);
83
85
 
84
86
  if (failedAssertions.length === 0) {
85
87
  return;
@@ -95,16 +97,11 @@ export const reportTestResults = async ({
95
97
  .slice(1)
96
98
  .map(({ message, runLink, actorId }) => `• ${message} --- <${runLink}|${actorId}>`);
97
99
 
98
- logger.info('SLACK:', slackMessage);
100
+ logger.info(`${sendingLabel(dryRun)} slack message to channel ${reportSlackChannel}:`, slackMessage);
99
101
  logger.info('\tblocks:', blocks.join('\n\t\t'));
100
102
 
101
- if (!reportSlackChannel) {
102
- return;
103
- }
104
-
105
103
  if (!dryRun) {
106
- const slackToken = getEnvVar('SLACK_TOKEN_TESTS_BOT');
107
- await sendSlackMessage(reportSlackChannel, slackMessage, blocks, slackToken);
104
+ await sendSlackMessage(slack, reportSlackChannel, slackMessage, blocks);
108
105
  }
109
106
  };
110
107