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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) 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 +361 -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 +2 -2
  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 +94 -44
  21. package/bin/affected-actors.ts +166 -0
  22. package/bin/build-from-local.ts +47 -13
  23. package/bin/build.ts +35 -19
  24. package/bin/diff-changes.ts +25 -17
  25. package/bin/git.ts +52 -41
  26. package/bin/main.ts +63 -78
  27. package/bin/slack.ts +28 -6
  28. package/bin/test-report.ts +6 -9
  29. package/bin/types.ts +9 -0
  30. package/dist/bin/affected-actors.d.ts +50 -0
  31. package/dist/bin/affected-actors.d.ts.map +1 -0
  32. package/dist/bin/affected-actors.js +89 -0
  33. package/dist/bin/affected-actors.js.map +1 -0
  34. package/dist/bin/build-from-local.d.ts +10 -2
  35. package/dist/bin/build-from-local.d.ts.map +1 -1
  36. package/dist/bin/build-from-local.js +29 -11
  37. package/dist/bin/build-from-local.js.map +1 -1
  38. package/dist/bin/build.d.ts +15 -5
  39. package/dist/bin/build.d.ts.map +1 -1
  40. package/dist/bin/build.js +24 -15
  41. package/dist/bin/build.js.map +1 -1
  42. package/dist/bin/diff-changes.d.ts +8 -2
  43. package/dist/bin/diff-changes.d.ts.map +1 -1
  44. package/dist/bin/diff-changes.js +16 -16
  45. package/dist/bin/diff-changes.js.map +1 -1
  46. package/dist/bin/git.d.ts +23 -13
  47. package/dist/bin/git.d.ts.map +1 -1
  48. package/dist/bin/git.js +44 -32
  49. package/dist/bin/git.js.map +1 -1
  50. package/dist/bin/main.js +57 -57
  51. package/dist/bin/main.js.map +1 -1
  52. package/dist/bin/slack.d.ts +8 -1
  53. package/dist/bin/slack.d.ts.map +1 -1
  54. package/dist/bin/slack.js +26 -5
  55. package/dist/bin/slack.js.map +1 -1
  56. package/dist/bin/test-report.d.ts.map +1 -1
  57. package/dist/bin/test-report.js +6 -8
  58. package/dist/bin/test-report.js.map +1 -1
  59. package/dist/bin/types.d.ts +8 -0
  60. package/dist/bin/types.d.ts.map +1 -1
  61. package/dist/test/bundle/cli-bundle.test.d.ts +2 -0
  62. package/dist/test/bundle/cli-bundle.test.d.ts.map +1 -0
  63. package/dist/test/bundle/cli-bundle.test.js +169 -0
  64. package/dist/test/bundle/cli-bundle.test.js.map +1 -0
  65. package/dist/test/e2e/cli-bundle.e2e.test.d.ts +2 -0
  66. package/dist/test/e2e/cli-bundle.e2e.test.d.ts.map +1 -0
  67. package/dist/test/e2e/cli-bundle.e2e.test.js +172 -0
  68. package/dist/test/e2e/cli-bundle.e2e.test.js.map +1 -0
  69. package/dist/test/unit/bin/affected-actors.test.d.ts +2 -0
  70. package/dist/test/unit/bin/affected-actors.test.d.ts.map +1 -0
  71. package/dist/test/unit/bin/affected-actors.test.js +160 -0
  72. package/dist/test/unit/bin/affected-actors.test.js.map +1 -0
  73. package/dist/test/unit/bin/build-from-local.test.js +53 -1
  74. package/dist/test/unit/bin/build-from-local.test.js.map +1 -1
  75. package/dist/test/unit/bin/build.test.js +113 -3
  76. package/dist/test/unit/bin/build.test.js.map +1 -1
  77. package/dist/test/unit/bin/diff-changes.test.js +78 -49
  78. package/dist/test/unit/bin/diff-changes.test.js.map +1 -1
  79. package/dist/test/unit/bin/git.test.js +50 -53
  80. package/dist/test/unit/bin/git.test.js.map +1 -1
  81. package/dist/test/unit/bin/slack.test.d.ts +2 -0
  82. package/dist/test/unit/bin/slack.test.d.ts.map +1 -0
  83. package/dist/test/unit/bin/slack.test.js +54 -0
  84. package/dist/test/unit/bin/slack.test.js.map +1 -0
  85. package/dist/test/unit/should-built-and-test.test.js +23 -17
  86. package/dist/test/unit/should-built-and-test.test.js.map +1 -1
  87. package/dist/tsconfig.tsbuildinfo +1 -1
  88. package/package.json +13 -3
  89. package/test/bundle/cli-bundle.test.ts +199 -0
  90. package/test/e2e/cli-bundle.e2e.test.ts +237 -0
  91. package/test/unit/bin/affected-actors.test.ts +190 -0
  92. package/test/unit/bin/build-from-local.test.ts +55 -0
  93. package/test/unit/bin/build.test.ts +135 -3
  94. package/test/unit/bin/diff-changes.test.ts +87 -49
  95. package/test/unit/bin/git.test.ts +56 -53
  96. package/test/unit/bin/slack.test.ts +65 -0
  97. package/test/unit/should-built-and-test.test.ts +27 -17
  98. package/.github/scripts/check-package-version-bump.mjs +0 -74
  99. package/.github/workflows-package-version +0 -1
package/README.md CHANGED
@@ -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:
313
-
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 |
318
-
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.
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.
327
-
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.
311
+ The workflows and the npm package live in one repo but ship on their own schedules:
312
+
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 |
317
+
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
+
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`.
329
+
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
@@ -665,6 +660,61 @@ TESTER_APIFY_TOKEN=apify_api_yyy \
665
660
  npx vitest --run --maxConcurrency 20 --fileParallelism=true --maxWorkers 100 test/platform
666
661
  ```
667
662
 
663
+ ### Finding affected Actors (`get-affected-actors`)
664
+
665
+ `get-affected-actors` lists the Actors whose context changed in a commit range, using the rules in
666
+ [Change detection](#change-detection). It only reads git and the config file, so it needs no tokens.
667
+ The range always ends at the checked-out `HEAD`, so check out the branch you want to inspect first.
668
+
669
+ ```bash
670
+ # A PR: the branch's own commits, after its last validated commit
671
+ npx apify-test-tools get-affected-actors --base origin/master --since <sha>
672
+
673
+ # A release: everything after the last released commit
674
+ npx apify-test-tools get-affected-actors --since <sha>
675
+ ```
676
+
677
+ | Flag | Default | Meaning |
678
+ | ------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
679
+ | `--base` | | Only commits on `HEAD` that are not on this branch count |
680
+ | `--since` | | Only commits after this one count (see below) |
681
+ | `--test-files-glob` | | Test files (glob from the repo root) that count as changes even when `.dockerignore` lists them, see [Change detection](#change-detection) |
682
+ | `--actors` | all | Only report these Actors |
683
+ | `--ignore` | none | Never report these Actors |
684
+
685
+ Pass `--base`, `--since`, or both:
686
+
687
+ - **`--base` only** inspects the whole branch. If the branch merged `--base` in and has no functional changes
688
+ of its own, no Actor is reported as functional, since `--base` is already validated (and none as cosmetic
689
+ either, if the branch has no changes of its own at all).
690
+ - **`--base` and `--since`** inspects the branch after its last validated commit. If `--since` is not on the
691
+ branch (force-push), or it is the head (a rerun), the whole branch is inspected instead.
692
+ - **`--since` only** inspects everything after it up to `HEAD`. It must be an ancestor of `HEAD` and fails
693
+ otherwise, exactly like [`--base-commit` of `release`](#--base-commit). If `HEAD` is `--since`, nothing is
694
+ reported.
695
+
696
+ The output is a JSON document:
697
+
698
+ ```json
699
+ {
700
+ "version": 1,
701
+ "ref": "my-branch",
702
+ "head": "<sha of HEAD>",
703
+ "commits": [{ "sha": "…", "author": "…", "date": "…", "message": "…" }],
704
+ "changedFiles": ["src/main.ts", "CHANGELOG.md"],
705
+ "changelog": "Lines added to CHANGELOG.md, or null",
706
+ "functionallyChangedActors": [{ "actorFullName": "john.doe/my-actor" }],
707
+ "cosmeticallyChangedActors": [{ "actorFullName": "john.doe/other-actor" }]
708
+ }
709
+ ```
710
+
711
+ Each changed Actor is in exactly one of the lists. `functionallyChangedActors` have at least one functional change, so
712
+ they need a new build and tests. `cosmeticallyChangedActors` only have cosmetic changes (see
713
+ [Change detection](#change-detection)), so they need a new build only when released: `build` builds
714
+ `functionallyChangedActors`, `release` builds both.
715
+
716
+ `ref` is the checked-out branch, or `null` for a detached HEAD. `build` and `release` use the same detection internally.
717
+
668
718
  ### Releasing Actors (`release`)
669
719
 
670
720
  `release` builds the `latest` (default) version of every Actor changed since the last release and
@@ -0,0 +1,166 @@
1
+ import { type ChangedActors, getChangedActors } from './diff-changes.js';
2
+ import {
3
+ getBranchOnlyChangedFiles,
4
+ getChangedFilesBetween,
5
+ getChangelogAdditions,
6
+ getCommits,
7
+ getCommitsBetween,
8
+ hasMergeFromTarget,
9
+ resolveBranchName,
10
+ resolveCommitSha,
11
+ resolveReleaseBaseCommit,
12
+ } from './git.js';
13
+ import { logger } from './logger.js';
14
+ import type { ActorConfig, Commit } from './types.js';
15
+ import { readConfigFile } from './utils/config/load-config.js';
16
+
17
+ interface AffectedActorsOptions {
18
+ /** End of the range. */
19
+ head: string;
20
+ /** Only the commits on head that are not on base count, i.e. the branch's own changes. */
21
+ base?: string;
22
+ /**
23
+ * Only changes after this commit count. With `base` it's the last validated commit of the branch and is
24
+ * ignored when it isn't on the branch. Without `base` it must be an ancestor of head (e.g. the last release).
25
+ */
26
+ since?: string;
27
+ /** Glob relative to the repo root. Matching files count as changes even when .dockerignore lists them. */
28
+ testFilesGlob?: string;
29
+ actors: string[];
30
+ ignore: string[];
31
+ }
32
+
33
+ interface AffectedActors {
34
+ headSha: string;
35
+ /** Branch head points to, which is what git builds are made from. Null for a SHA or a detached HEAD. */
36
+ ref: string | null;
37
+ commits: Commit[];
38
+ changedFiles: string[];
39
+ changelog: string | null;
40
+ /** Actors with at least one functional change. They need a new build and tests. */
41
+ functionallyChangedActors: ActorConfig[];
42
+ /** Actors whose changes are all cosmetic (README, CHANGELOG, cosmetic .actor JSON). They only need a release build. */
43
+ cosmeticallyChangedActors: ActorConfig[];
44
+ }
45
+
46
+ interface CommitRange {
47
+ commits: Commit[];
48
+ fromRef: string;
49
+ toRef: string;
50
+ }
51
+
52
+ /** The branch's own commits, after `since` when it is on the branch (see getCommits). */
53
+ const getBranchRange = async (head: string, base: string, since: string | undefined): Promise<CommitRange> => {
54
+ const commits = await getCommits({ sourceBranch: head, targetBranch: base, baseCommit: since });
55
+ return { commits, fromRef: `${commits[0].sha}~`, toRef: commits[commits.length - 1].sha };
56
+ };
57
+
58
+ /** Everything after `since` up to head. Unlike on a branch, `since` must be an ancestor of head. */
59
+ const getRangeSince = async (head: string, headSha: string, since: string): Promise<CommitRange> => {
60
+ const sinceSha = await resolveReleaseBaseCommit(since, head);
61
+ const commits = sinceSha === headSha ? [] : await getCommitsBetween(sinceSha, headSha);
62
+ return { commits, fromRef: sinceSha, toRef: headSha };
63
+ };
64
+
65
+ /**
66
+ * This is an optimization for the common case where a branch only has cosmetic changes but had to merge in
67
+ * functional changes from base (being up-to-date is a CI requirement). Base is already validated, and
68
+ * since the branch has no functional changes of its own, there is nothing new to validate.
69
+ * Exception: if the branch has any functional changes alongside the merge, we must re-test — even
70
+ * individually validated changes can have novel interactions when combined.
71
+ * Returns the Actors the branch's own commits changed, or undefined when it didn't merge base in.
72
+ *
73
+ * TODO: Apply this per Actor instead of to the whole branch. Changes merged in from base only interact with the
74
+ * branch's own changes in Actors whose context both touch, so an Actor that only base changed is already validated
75
+ * and can be skipped even when the branch changes other Actors functionally. Today any functional change of the
76
+ * branch's own keeps every Actor, and the log messages below describe the whole branch.
77
+ */
78
+ const getOwnChangesIfMergedFromBase = async (
79
+ head: string,
80
+ base: string,
81
+ actorConfigs: ActorConfig[],
82
+ testFilesGlob: string | undefined,
83
+ ): Promise<ChangedActors | undefined> => {
84
+ if (!(await hasMergeFromTarget(head, base))) return undefined;
85
+
86
+ logger.info(
87
+ '[MERGE-FROM-TARGET-OPTIMIZATION]: There is merge from target branch, checking if there are no functional changes in our own branch. If so, we can skip tests',
88
+ );
89
+ const own = await getChangedActors({
90
+ filepathsChanged: await getBranchOnlyChangedFiles(head, base),
91
+ actorConfigs,
92
+ // Omit since to get full branch history. Validated functional commits can still interact with merged ones
93
+ commits: await getCommits({ sourceBranch: head, targetBranch: base }),
94
+ testFilesGlob,
95
+ });
96
+ logger.info(
97
+ own.functional.length > 0
98
+ ? `[MERGE-FROM-TARGET-OPTIMIZATION]: Branch has ${own.functional.length} functional changes, cannot optimize, we continue with full check`
99
+ : '[MERGE-FROM-TARGET-OPTIMIZATION]: Branch itself has no functional changes, skipping tests',
100
+ );
101
+ return own;
102
+ };
103
+
104
+ /**
105
+ * Finds the Actors whose context changed in the given range. `base` selects the branch's own commits
106
+ * (a PR), `since` the commits after a known one (a release, or the last validated commit of a PR).
107
+ */
108
+ export const getAffectedActors = async ({
109
+ head,
110
+ base,
111
+ since: sinceOption,
112
+ testFilesGlob,
113
+ actors,
114
+ ignore,
115
+ }: AffectedActorsOptions): Promise<AffectedActors> => {
116
+ // An empty --since (e.g. an empty cache file) means the same as not passing it
117
+ const since = sinceOption || undefined;
118
+ if (!base && !since) {
119
+ throw new Error('Pass --base, --since, or both to select the commits to inspect.');
120
+ }
121
+
122
+ const headSha = await resolveCommitSha(head);
123
+ const { commits, fromRef, toRef } = base
124
+ ? await getBranchRange(head, base, since)
125
+ : await getRangeSince(head, headSha, since!);
126
+ const actorConfigs = await readConfigFile({ actors, ignore });
127
+
128
+ // What the branch only merged in from base is already validated. Without functional changes of its own
129
+ // the branch has nothing to test, and without any changes of its own nothing to release either.
130
+ const own = base ? await getOwnChangesIfMergedFromBase(head, base, actorConfigs, testFilesGlob) : undefined;
131
+ const changedFiles = await getChangedFilesBetween(fromRef, toRef);
132
+ const changed = await getChangedActors({ filepathsChanged: changedFiles, actorConfigs, commits, testFilesGlob });
133
+
134
+ return {
135
+ headSha,
136
+ ref: await resolveBranchName(head),
137
+ commits,
138
+ changedFiles,
139
+ changelog: await getChangelogAdditions(fromRef, toRef, changedFiles),
140
+ functionallyChangedActors: own && own.functional.length === 0 ? [] : changed.functional,
141
+ cosmeticallyChangedActors: own && own.functional.length + own.cosmetic.length === 0 ? [] : changed.cosmetic,
142
+ };
143
+ };
144
+
145
+ /** The JSON document `get-affected-actors` prints. Versioned, since other commands and workflows consume it. */
146
+ export const toAffectedActorsDocument = ({
147
+ headSha,
148
+ ref,
149
+ commits,
150
+ changedFiles,
151
+ changelog,
152
+ functionallyChangedActors,
153
+ cosmeticallyChangedActors,
154
+ }: AffectedActors) => {
155
+ const names = (configs: ActorConfig[]) => configs.map(({ actorFullName }) => ({ actorFullName }));
156
+ return {
157
+ version: 1,
158
+ ref,
159
+ head: headSha,
160
+ commits,
161
+ changedFiles,
162
+ changelog,
163
+ functionallyChangedActors: names(functionallyChangedActors),
164
+ cosmeticallyChangedActors: names(cosmeticallyChangedActors),
165
+ };
166
+ };
@@ -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
  };
@@ -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
  };