Skip to content

Commit 99506f6

Browse files
authored
feat(only-and-omit): pick or exclude actors (#113)
added `--omit-actors` and `--only-actors` to ignore or focus on certain actors. Purpose is to enable the 2 step deployment process of POD E. pod e needs: 1. deploy everything but the main actor 2. deploy the main actor Even if not needed for this case, added both as arrays. So it can be used as: `--omit-actors actor1 actor2 actor3 ...` Also the filtering function asserts all being found on the config. Since there is probably something wrong if your options don't match your config. This is the most versatile thing i cooked up and then the responsibility of the ordering and such falls on the deployment action workflow. Which in this case would be: 1. deploy with omit main actor 2. deploy only main actor
1 parent ba4ccb4 commit 99506f6

7 files changed

Lines changed: 210 additions & 88 deletions

File tree

bin/actor-filtering.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import type { ActorConfig } from './types.js';
2+
3+
/**
4+
* Restricts a set of actors to those selected via `--actors` and not excluded via `--ignore`.
5+
* Both filters match on `actorFullName` (`owner/name`). `--actors` is applied first (empty means
6+
* "all"), then `--ignore` removes from the result. A name that doesn't exist in the config throws —
7+
* a malformed selection must never silently build/release/delete the wrong set. The caller is
8+
* responsible for turning that into a non-zero exit.
9+
*/
10+
export function selectActors({ actors, ignore }: { actors: string[]; ignore: string[] }, actorConfigs: ActorConfig[]) {
11+
const fullNames = actorConfigs.map((actor) => actor.actorFullName);
12+
const missing = [...actors, ...ignore].filter((name) => !fullNames.includes(name));
13+
if (missing.length > 0) {
14+
throw new Error(`The following actors from the filter config do not exist: ${missing.join(', ')}`);
15+
}
16+
17+
const afterOnly = actors.length
18+
? actorConfigs.filter((actor) => actors.includes(actor.actorFullName))
19+
: actorConfigs;
20+
return afterOnly.filter((actor) => !ignore.includes(actor.actorFullName));
21+
}

bin/git.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,11 @@ const fetchAllBranchCommits = (sourceBranch: string, targetBranch: string): Comm
9494
* Gets the commits between sourceBranch and targetBranch (exclusive).
9595
* - If baseCommit is provided, only returns commits after the baseCommit.
9696
*/
97-
export const getCommits = ({ sourceBranch, targetBranch, baseCommit }: Config): Commit[] => {
97+
export const getCommits = ({
98+
sourceBranch,
99+
targetBranch,
100+
baseCommit,
101+
}: Pick<Config, 'sourceBranch' | 'targetBranch' | 'baseCommit'>): Commit[] => {
98102
const baseCommitSha = parseBaseCommit(baseCommit);
99103
const commits = fetchAllBranchCommits(sourceBranch, targetBranch);
100104

bin/main.ts

Lines changed: 65 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { readConfigFile, setCwd, spawnCommandInGhWorkspace } from './utils.js';
2121
*/
2222
const middlewares = [setCwd];
2323

24-
const buildOptions = (y: Argv) => {
24+
export const buildOptions = <T>(y: Argv<T>) => {
2525
return y
2626
.option('target-branch', {
2727
type: 'string',
@@ -37,27 +37,44 @@ const buildOptions = (y: Argv) => {
3737
})
3838
.option('base-commit', {
3939
type: 'string',
40+
demandOption: false,
4041
});
4142
};
4243

43-
const resolveChangedActors = async (
44-
{ targetBranch, sourceBranch, baseCommit }: Config,
45-
{ isLatest }: { isLatest: boolean },
46-
) => {
47-
const actorConfigs = await readConfigFile();
44+
/**
45+
* Actor-selection flags, applied to every command that reads the actor config so a caller can
46+
* narrow the set it operates on (e.g. two-stage releases: `--ignore X`, then `--actors X`).
47+
* Kept separate from `buildOptions` so the read-only git commands don't advertise flags they ignore.
48+
*/
49+
export const actorSelectionOptions = <T>(y: Argv<T>) => {
50+
return y
51+
.option('actors', {
52+
type: 'string',
53+
array: true,
54+
default: [] as string[],
55+
})
56+
.option('ignore', {
57+
type: 'string',
58+
array: true,
59+
default: [] as string[],
60+
});
61+
};
62+
63+
const resolveChangedActors = async (config: Config, { isLatest }: { isLatest: boolean }) => {
64+
const actorConfigs = await readConfigFile(config);
4865

49-
// This is an optimization for the common case where a branch only has cosmetic changes but had to merge in
66+
// This is an optimization for the common case where a branch only has cosmetic changes but had to smerge in
5067
// functional changes from master (being up-to-date is a CI requirement). Master is already validated, and
5168
// since the branch has no functional changes of its own, there is nothing new to validate.
5269
// Exception: if the branch has any functional changes alongside the merge, we must re-test — even
5370
// individually validated changes can have novel interactions when combined.
54-
if (hasMergeFromTarget(sourceBranch, targetBranch)) {
71+
if (hasMergeFromTarget(config.sourceBranch, config.targetBranch)) {
5572
console.error(
5673
'[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',
5774
);
58-
const branchOnlyFiles = getBranchOnlyChangedFiles(sourceBranch, targetBranch);
75+
const branchOnlyFiles = getBranchOnlyChangedFiles(config.sourceBranch, config.targetBranch);
5976
// Omit baseCommit to get full branch history. Validated functional commits can still interact with merged ones
60-
const allBranchCommits = getCommits({ sourceBranch, targetBranch, baseCommit: undefined });
77+
const allBranchCommits = getCommits({ ...config, baseCommit: undefined });
6178
const branchOnlyActorsChanged = getChangedActors({
6279
filepathsChanged: branchOnlyFiles,
6380
actorConfigs,
@@ -73,7 +90,7 @@ const resolveChangedActors = async (
7390
}
7491

7592
// 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)
76-
const commits = getCommits({ targetBranch, sourceBranch, baseCommit });
93+
const commits = getCommits(config);
7794
const changedFiles = getChangedFiles(commits);
7895
return getChangedActors({ filepathsChanged: changedFiles, actorConfigs, isLatest, commits });
7996
};
@@ -103,22 +120,19 @@ await yargs()
103120
const changedFiles = getChangedFiles(commits);
104121
console.log(JSON.stringify(changedFiles));
105122
})
123+
.command('get-actor-configs', '', actorSelectionOptions, async ({ actors, ignore }) => {
124+
const actorConfigs = await readConfigFile({ actors, ignore });
125+
console.log(JSON.stringify(actorConfigs));
126+
})
106127
.command(
107-
'get-actor-configs',
128+
'get-affected-actors',
108129
'',
109-
(_) => _,
110-
async () => {
111-
const actorConfigs = await readConfigFile();
112-
console.log(JSON.stringify(actorConfigs));
130+
(args) => actorSelectionOptions(buildOptions(args)),
131+
async (config) => {
132+
const actorsChanged = await resolveChangedActors(config, { isLatest: false });
133+
console.log(JSON.stringify(actorsChanged));
113134
},
114135
)
115-
.command('get-affected-actors', '', buildOptions, async ({ targetBranch, sourceBranch, baseCommit }) => {
116-
const actorsChanged = await resolveChangedActors(
117-
{ targetBranch, sourceBranch, baseCommit },
118-
{ isLatest: false },
119-
);
120-
console.log(JSON.stringify(actorsChanged));
121-
})
122136
.command(
123137
'report-tests',
124138
'',
@@ -135,12 +149,9 @@ await yargs()
135149
.command(
136150
'build',
137151
'',
138-
(args) => buildOptions(args).option('dry-run', { type: 'boolean', default: false }),
139-
async ({ targetBranch, sourceBranch, baseCommit, dryRun, useDockerCache }) => {
140-
const actorsChanged = await resolveChangedActors(
141-
{ targetBranch, sourceBranch, baseCommit },
142-
{ isLatest: false },
143-
);
152+
(args) => actorSelectionOptions(buildOptions(args)).option('dry-run', { type: 'boolean', default: false }),
153+
async (config) => {
154+
const actorsChanged = await resolveChangedActors(config, { isLatest: false });
144155
// https://github.com/apify-store/google-maps#:actors/lukaskrivka_google-maps-with-contact-details
145156
// git@github.com:apify-store/google-maps#:actors/lukaskrivka_google-maps-with-contact-details
146157
const repoUrl = spawnCommandInGhWorkspace(`git remote get-url origin`).replace(
@@ -151,9 +162,9 @@ await yargs()
151162
const builds = await runBuilds({
152163
repoUrl,
153164
actorConfigs: actorsChanged,
154-
branch: sourceBranch.replace('origin/', ''),
155-
dryRun,
156-
useDockerCache,
165+
branch: config.sourceBranch.replace('origin/', ''),
166+
dryRun: config.dryRun,
167+
useDockerCache: config.useDockerCache,
157168
});
158169
console.log(JSON.stringify(builds));
159170
},
@@ -162,7 +173,7 @@ await yargs()
162173
'release',
163174
'',
164175
(args) =>
165-
args
176+
actorSelectionOptions(args)
166177
.option('push-event-path', { type: 'string', demandOption: true })
167178
.option('dry-run', { type: 'boolean', default: false })
168179
.option('report-slack-channel', { type: 'string' })
@@ -173,7 +184,7 @@ await yargs()
173184
args.pushEventPath,
174185
);
175186
const isLatest = true;
176-
const actorConfigs = await readConfigFile();
187+
const actorConfigs = await readConfigFile(args);
177188
const actorsChanged = getChangedActors({
178189
filepathsChanged: changedFiles,
179190
actorConfigs,
@@ -206,37 +217,30 @@ await yargs()
206217
.command(
207218
'build-from-local',
208219
'',
209-
(args) =>
210-
args
211-
.option('actors', {
212-
type: 'string',
213-
description:
214-
'Comma-separated actor names (owner/name) to build. Defaults to all actors in the repo.',
215-
})
216-
.option('dry-run', { type: 'boolean', default: false }),
217-
async ({ actors, dryRun }) => {
218-
const allActorConfigs = await readConfigFile();
219-
const actorConfigs = actors
220-
? actors.split(',').map((name) => {
221-
const trimmed = name.trim();
222-
const config = allActorConfigs.find((c) => c.actorFullName === trimmed);
223-
if (!config) throw new Error(`Actor "${trimmed}" not found in repo`);
224-
return config;
225-
})
226-
: allActorConfigs;
220+
(args) => actorSelectionOptions(args).option('dry-run', { type: 'boolean', default: false }),
221+
async ({ actors, ignore, dryRun }) => {
222+
const actorConfigs = await readConfigFile({ actors, ignore });
227223
const builds = await runBuildsFromLocal({ actorConfigs, dryRun });
228224
console.log(JSON.stringify(builds));
229225
},
230226
)
231-
.command(
232-
'delete-old-builds',
233-
'',
234-
(_) => _,
235-
async () => {
236-
const actorConfigs = await readConfigFile();
237-
await deleteOldBuilds(actorConfigs);
238-
},
239-
)
227+
.command('delete-old-builds', '', actorSelectionOptions, async ({ actors, ignore }) => {
228+
const actorConfigs = await readConfigFile({ actors, ignore });
229+
await deleteOldBuilds(actorConfigs);
230+
})
240231
.strictCommands()
241232
.demandCommand(1, 'Command is required')
233+
.fail((msg, err, yargsInstance) => {
234+
// Errors thrown from a command handler (e.g. an unknown actor passed to --actors/--ignore,
235+
// or a missing config file) arrive here as `err`. A malformed selection must fail loudly
236+
// rather than silently operate on the wrong set of actors — print the message, no stack.
237+
if (err) {
238+
console.error(`[ERROR]: ${err.message}`);
239+
} else {
240+
// Argument-parsing/validation failure — keep yargs' usage output.
241+
console.error(yargsInstance.help());
242+
console.error(`\n${msg}`);
243+
}
244+
process.exit(1);
245+
})
242246
.parse(hideBin(process.argv));

bin/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ export interface Config {
33
sourceBranch: string;
44
baseCommit?: string;
55
workspace?: string;
6+
actors: string[];
7+
ignore: string[];
68
}
79

810
export type Commit = {

bin/utils.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { ActorVersionSourceFile } from 'apify-client';
66

77
import { SOURCE_FILE_FORMATS } from '@apify/consts';
88

9+
import { selectActors } from './actor-filtering.js';
910
import { isPathWithinScope } from './path-utils.js';
1011
import type { ActorConfig, ActorConfigFile } from './types.js';
1112

@@ -113,7 +114,7 @@ const findOverlappingContextPaths = (contextPaths: string[]): [string, string] |
113114
return undefined;
114115
};
115116

116-
export const readConfigFile = async (): Promise<ActorConfig[]> => {
117+
export const readConfigFile = async (selection: { actors: string[]; ignore: string[] }): Promise<ActorConfig[]> => {
117118
let raw: string;
118119
try {
119120
raw = await fs.readFile(CONFIG_FILE_NAME, 'utf-8');
@@ -225,7 +226,7 @@ export const readConfigFile = async (): Promise<ActorConfig[]> => {
225226
});
226227
}
227228

228-
return actorConfigs;
229+
return selectActors(selection, actorConfigs);
229230
};
230231

231232
export const setCwd = ({ workspace }: { workspace: string | undefined }) => {
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { selectActors } from '../../../bin/actor-filtering.js';
4+
import type { ActorConfig } from '../../../bin/types.js';
5+
6+
const actor = (actorFullName: string): ActorConfig => ({
7+
actorFullName,
8+
folder: actorFullName.split('/')[1],
9+
tokenEnvVar: 'TOKEN',
10+
dockerContextDir: '.',
11+
contextPaths: [],
12+
});
13+
14+
const configs = [actor('owner/a'), actor('owner/b'), actor('owner/c')];
15+
const names = (result: ActorConfig[]) => result.map((c) => c.actorFullName);
16+
17+
describe('selectActors', () => {
18+
it('returns all actors when neither filter is set', () => {
19+
expect(names(selectActors({ actors: [], ignore: [] }, configs))).toStrictEqual([
20+
'owner/a',
21+
'owner/b',
22+
'owner/c',
23+
]);
24+
});
25+
26+
it('keeps only the actors listed in --actors', () => {
27+
expect(names(selectActors({ actors: ['owner/a', 'owner/c'], ignore: [] }, configs))).toStrictEqual([
28+
'owner/a',
29+
'owner/c',
30+
]);
31+
});
32+
33+
it('drops the actors listed in --ignore', () => {
34+
expect(names(selectActors({ actors: [], ignore: ['owner/b'] }, configs))).toStrictEqual(['owner/a', 'owner/c']);
35+
});
36+
37+
it('applies --actors first, then removes --ignore from that subset', () => {
38+
expect(names(selectActors({ actors: ['owner/a', 'owner/b'], ignore: ['owner/b'] }, configs))).toStrictEqual([
39+
'owner/a',
40+
]);
41+
});
42+
43+
it('can select down to nothing when --actors and --ignore overlap fully', () => {
44+
expect(selectActors({ actors: ['owner/a'], ignore: ['owner/a'] }, configs)).toStrictEqual([]);
45+
});
46+
47+
it('throws listing every unknown name across both filters', () => {
48+
expect(() => selectActors({ actors: ['owner/x'], ignore: ['owner/y'] }, configs)).toThrow(
49+
'The following actors from the filter config do not exist: owner/x, owner/y',
50+
);
51+
});
52+
});

0 commit comments

Comments
 (0)