Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 86 additions & 86 deletions .github/cli/apify-test-tools.mjs

Large diffs are not rendered by default.

63 changes: 62 additions & 1 deletion .github/scripts/run-with-apify-tokens.mjs

Large diffs are not rendered by default.

19 changes: 17 additions & 2 deletions bin/utils/config/load-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import type { ActorConfig } from '../../types.js';
import { safeReadJsonObjectFile } from '../json-file.js';
import { ExistingFile, type RelativeDir } from '../path/repo-relative.js';
import { readActorJson } from './actor-json.js';
import { parseConfigFile } from './parser.js';
import type { ValidatedActorConfig } from './structures/base.js';
import { parseConfigFile, parseResolvedConfigFile } from './parser.js';
import type { ResolvedActorConfig, ValidatedActorConfig } from './structures/base.js';

export const CONFIG_FILE_NAME = 'apify-test-tools.config.json';

Expand Down Expand Up @@ -111,3 +111,18 @@ export const readConfigFile = async (selection: { actors: string[]; ignore: stri

return selectActors(selection, actorConfigs);
};

type PathlessConfig = Omit<ResolvedActorConfig, 'folder' | 'overrideActorContext'>;
/** Read settings shared by all commands without initializing or validating local paths. */
export const readBaseConfigFile = async () => {
const rawFileContents = await readConfigFileContents();
return parseResolvedConfigFile(rawFileContents).map(
({ actorFullName, tokenEnvVar }) =>
// using `satisfies` here instead of return type annotation
// so its not shown as `PathlessConfig` type on output or IDE, but the expanded form
({
actorFullName,
tokenEnvVar,
}) satisfies PathlessConfig,
);
};
25 changes: 17 additions & 8 deletions bin/utils/config/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,23 +47,27 @@ const ValidatedConfigSchema = z.array(
}),
);

/** Normalize and validate paths, then check collisions using their final spelling. */
function verifyConfiguration(body: ResolvedActorConfig[]): ValidatedActorConfig[] {
/** Normalize and initialize paths, including checking that actor folders exist. */
function initializePaths(body: ResolvedActorConfig[]): ValidatedActorConfig[] {
const normalized = body.map((entry) => ({
...entry,
folder: normalizeFolder(entry.folder),
overrideActorContext: entry.overrideActorContext?.map(stripTrailingSlash),
}));
const parsed = ValidatedConfigSchema.safeParse(normalized);
if (!parsed.success) throw new Error(z.prettifyError(parsed.error));
return parsed.data;
}

/** Check rules that involve more than one Actor, using initialized paths for comparisons. */
function verifyConfiguration(body: ValidatedActorConfig[]): void {
const seenFolders = new Set<string>();
const seenActorFullNames = new Set<string>();
const errors: string[] = [];
for (const [index, entry] of parsed.data.entries()) {
for (const entry of body) {
// TODO: drop folder uniqueness (this requires several changes on other places)
if (seenFolders.has(entry.folder.path)) {
errors.push(`Duplicate folder "${body[index].folder}". Each actor must have a unique folder.`);
errors.push(`Duplicate folder "${entry.folder.path}". Each actor must have a unique folder.`);
} else {
seenFolders.add(entry.folder.path);
}
Expand All @@ -76,19 +80,24 @@ function verifyConfiguration(body: ResolvedActorConfig[]): ValidatedActorConfig[
}

if (errors.length > 0) throw new Error(errors.join('\n'));
return parsed.data;
}

// eslint-disable-next-line no-underscore-dangle
export const _privates = {
selectStrategy,
initializePaths,
verifyConfiguration,
};

export function parseConfigFile(body: Record<string, unknown>): ValidatedActorConfig[] {
export function parseResolvedConfigFile(body: Record<string, unknown>): ResolvedActorConfig[] {
const { mode, ...rest } = body;
const strategy = selectStrategy({ mode });
return strategy.parse(rest);
}

const resolved = strategy.parse(rest);
return verifyConfiguration(resolved);
export function parseConfigFile(body: Record<string, unknown>): ValidatedActorConfig[] {
const resolved = parseResolvedConfigFile(body);
const initialized = initializePaths(resolved);
verifyConfiguration(initialized);
return initialized;
}
4 changes: 2 additions & 2 deletions bin/utils/config/structures/grouped.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@ const schema = z.object({
folder: z.string(),
actorFullName: z.string().regex(ACTOR_FULL_NAME_REGEX),
// these allow overrides from stuff set at the group level
tokenEnvVar: z.string().optional(),
tokenEnvVar: z.string().min(1).optional(),
overrideActorContext: z.array(z.string()).optional(),
}),
)
.min(1),
tokenEnvVar: z.string(),
tokenEnvVar: z.string().min(1),
overrideActorContext: z.array(z.string()).optional(),
}),
)
Expand Down
2 changes: 1 addition & 1 deletion bin/utils/config/structures/legacy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const schema = z.object({
z.object({
folder: z.string(),
actorFullName: z.string().regex(ACTOR_FULL_NAME_REGEX),
tokenEnvVar: z.string(),
tokenEnvVar: z.string().min(1),
overrideActorContext: z.array(z.string()).optional(),
}),
)
Expand Down
4 changes: 4 additions & 0 deletions docs/workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,10 @@ token is not passed on. A token the config declares but the repo hasn't set is a
carry an Actor whose token isn't configured and still build fine as long as that Actor never changes. The build fails
with an error naming the Actor once it actually needs the token.

The wrapper validates the config with the CLI's parser. Each Actor must declare a nonempty `tokenEnvVar` or inherit
one from its group. An empty name or a missing required `tokenEnvVar` now fails the step during config validation;
the wrapper previously warned and continued for these entries.

The `unitTest` job runs static checks and needs `NPM_TOKEN` only. No job runs `npm ci` or `pnpm install` with Apify
or Slack credentials in scope, so a postinstall script in the dependency tree cannot read them.

Expand Down
110 changes: 110 additions & 0 deletions test/bundle/token-wrapper.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { spawnSync } from 'node:child_process';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';

import { afterAll, beforeAll, describe, expect, it } from 'vitest';

const wrapperPath = fileURLToPath(new URL('../../.github/scripts/run-with-apify-tokens.mjs', import.meta.url));
const configFileName = 'apify-test-tools.config.json';

let repoDir: string;

describe('token wrapper bundle', () => {
beforeAll(async () => {
repoDir = await mkdtemp(path.join(os.tmpdir(), 'apify-token-wrapper-'));
});

afterAll(async () => {
if (repoDir) await rm(repoDir, { recursive: true, force: true });
});

const actorSecrets = {
GROUP_TOKEN: 'group-value',
OVERRIDE_TOKEN: 'override-value',
OTHER: 'hidden',
};

const runWrapper = (workflowSecrets: Record<string, string> = actorSecrets) =>
spawnSync(process.execPath, [wrapperPath, process.execPath, '-e', 'console.log(JSON.stringify(process.env))'], {
cwd: repoDir,
env: {
PATH: process.env.PATH ?? '',
WORKFLOW_SECRETS: JSON.stringify(workflowSecrets),
},
encoding: 'utf8',
});

it.each(['inherited secrets', 'CONFIG_SECRETS'])(
'passes grouped tokens and actor overrides from %s without unrelated secrets',
async (source) => {
await writeFile(
path.join(repoDir, configFileName),
JSON.stringify({
mode: 'grouped',
groups: {
example: {
tokenEnvVar: 'GROUP_TOKEN',
actors: [
{ folder: 'actors/one', actorFullName: 'example/one' },
{ folder: 'actors/two', actorFullName: 'example/two', tokenEnvVar: 'OVERRIDE_TOKEN' },
],
},
},
}),
);

const { status, stdout, stderr } = runWrapper(
source === 'CONFIG_SECRETS' ? { CONFIG_SECRETS: JSON.stringify(actorSecrets) } : actorSecrets,
);
expect(status, stderr).toBe(0);
const childEnv: Record<string, string> = JSON.parse(stdout);
expect(childEnv).toMatchObject({ GROUP_TOKEN: 'group-value', OVERRIDE_TOKEN: 'override-value' });
expect(childEnv).not.toHaveProperty('OTHER');
expect(childEnv).not.toHaveProperty('WORKFLOW_SECRETS');
expect(childEnv).not.toHaveProperty('CONFIG_SECRETS');
expect(stderr).toContain('Passing 2 Actor token(s)');
},
);

it('does not start the child when config validation fails', async () => {
await writeFile(path.join(repoDir, configFileName), JSON.stringify({ mode: 'grouped', groups: {} }));

const { status, stdout, stderr } = runWrapper();
expect(status).toBe(1);
expect(stdout).toBe('');
expect(stderr).toContain('At least one configuration group is required');
expect(stderr).not.toContain('Cannot read');
});

it.each(['', undefined])(
'rejects an empty or missing token name (%s) before starting the child',
async (tokenEnvVar) => {
await writeFile(
path.join(repoDir, configFileName),
JSON.stringify({ actors: [{ folder: 'actors/one', actorFullName: 'example/one', tokenEnvVar }] }),
);

const { status, stdout, stderr } = runWrapper();
expect(status).toBe(1);
expect(stdout).toBe('');
expect(stderr).toContain('actors[0].tokenEnvVar');
expect(stderr).not.toContain('Warning:');
expect(stderr).not.toContain('Cannot read');
},
);

it('reports a missing config file without a duplicate error prefix', async () => {
await rm(path.join(repoDir, configFileName), { force: true });

const { status, stdout, stderr } = runWrapper();
expect(status).toBe(1);
expect(stdout).toBe('');
expect(stderr).toBe(
`Config file "${configFileName}" not found in the current directory. ` +
'Please create one with the required actor entries.\n',
);
});
});
27 changes: 26 additions & 1 deletion test/unit/bin/utils/config/load-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ import path from 'node:path';

import { afterEach, beforeEach, describe, expect, it } from 'vitest';

import { CONFIG_FILE_NAME, loadActorConfig, readConfigFile } from '../../../../../bin/utils/config/load-config.js';
import {
CONFIG_FILE_NAME,
loadActorConfig,
readBaseConfigFile,
readConfigFile,
} from '../../../../../bin/utils/config/load-config.js';
import type { ResolvedActorConfig } from '../../../../../bin/utils/config/structures/base.js';
import { ExistingDir, ExistingFile, RelativeDir, RelativeFile } from '../../../../../bin/utils/path/repo-relative.js';

Expand Down Expand Up @@ -265,3 +270,23 @@ describe('readConfigFile', () => {
await expect(readConfigFile(emptyActorSelection)).rejects.toThrow(/at actors/);
});
});

describe('readBaseConfigFile', () => {
it('returns non-file settings without checking Actor paths or reading actor.json', async () => {
await writeFiles({
[CONFIG_FILE_NAME]: validConfig([
{
folder: 'missing-actor-dir',
actorFullName: 'myteam/shopify',
tokenEnvVar: 'APIFY_TOKEN',
overrideActorContext: ['../outside'],
},
]),
});

await expect(readBaseConfigFile()).resolves.toEqual([
{ actorFullName: 'myteam/shopify', tokenEnvVar: 'APIFY_TOKEN' },
]);
await expect(readConfigFile(emptyActorSelection)).rejects.toThrow();
});
});
28 changes: 23 additions & 5 deletions test/unit/bin/utils/config/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,18 @@ import path from 'node:path';

import { afterAll, beforeAll, describe, expect, it } from 'vitest';

import { _privates, parseConfigFile } from '../../../../../bin/utils/config/parser.js';
import { CONFIG_FILE_STRATEGY } from '../../../../../bin/utils/config/structures/base.js';
import { _privates, parseConfigFile, parseResolvedConfigFile } from '../../../../../bin/utils/config/parser.js';
import { CONFIG_FILE_STRATEGY, type ResolvedActorConfig } from '../../../../../bin/utils/config/structures/base.js';
import { GROUPED_PARSER } from '../../../../../bin/utils/config/structures/grouped.js';
import { LEGACY_PARSER } from '../../../../../bin/utils/config/structures/legacy.js';
import { ExistingDir, RelativeDir } from '../../../../../bin/utils/path/repo-relative.js';

const { selectStrategy, verifyConfiguration } = _privates;
const { selectStrategy, initializePaths, verifyConfiguration: verifyInitializedConfiguration } = _privates;
const verifyConfiguration = (body: ResolvedActorConfig[]) => {
const initialized = initializePaths(body);
verifyInitializedConfiguration(initialized);
return initialized;
};

let repoDir: string;
const originalCwd = process.cwd();
Expand Down Expand Up @@ -95,13 +100,13 @@ describe('verifyConfiguration', () => {
).toThrow(/Duplicate folder/);
});

it('quotes a duplicate folder the way it was written, not the way it normalized', () => {
it('reports the normalized path for a duplicate folder', () => {
expect(() =>
verifyConfiguration([
actor({ folder: '.', actorFullName: 'myteam/actor-a' }),
actor({ folder: './', actorFullName: 'myteam/actor-b' }),
]),
).toThrow('Duplicate folder "./"');
).toThrow('Duplicate folder "."');
});

it('rejects two entries pointing at the same actor', () => {
Expand Down Expand Up @@ -150,6 +155,19 @@ describe('verifyConfiguration', () => {
});

describe('parseConfigFile', () => {
it('shares strategy resolution with parsing that does not initialize paths', () => {
const body = { actors: [actor({ folder: 'missing-actor-dir', overrideActorContext: ['../outside'] })] };
expect(parseResolvedConfigFile(body)).toEqual([
{
actorFullName: 'myteam/shopify',
folder: 'missing-actor-dir',
tokenEnvVar: 'APIFY_TOKEN',
overrideActorContext: ['../outside'],
},
]);
expect(() => parseConfigFile(body)).toThrow();
});

it('validates and normalizes paths after resolving the config', () => {
const [parsed] = parseConfigFile({ actors: [actor({ folder: './bin/', overrideActorContext: ['test/'] })] });
expect(parsed.folder).toBeInstanceOf(ExistingDir);
Expand Down
12 changes: 12 additions & 0 deletions test/unit/bin/utils/config/structures/grouped.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,18 @@ describe('GROUPED_PARSER', () => {
expect(() => GROUPED_PARSER.parse({})).toThrow();
});

it('rejects an empty group token name', () => {
expect(() => GROUPED_PARSER.parse({ groups: { myteam: group({ tokenEnvVar: '' }) } })).toThrow(
/at groups\.myteam\.tokenEnvVar/,
);
});

it('rejects an empty actor token override instead of falling back to the group token', () => {
expect(() =>
GROUPED_PARSER.parse({ groups: { myteam: group({ actors: [actor({ tokenEnvVar: '' })] }) } }),
).toThrow(/at groups\.myteam\.actors\[0\]\.tokenEnvVar/);
});

it('reports problems from every group at once instead of only the first', () => {
const message = (() => {
try {
Expand Down
1 change: 1 addition & 0 deletions test/unit/bin/utils/config/structures/legacy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ describe('LEGACY_PARSER', () => {
['folder is missing', { folder: undefined }, /at actors\[0\]\.folder/],
['folder is not a string', { folder: 123 }, /at actors\[0\]\.folder/],
['tokenEnvVar is missing', { tokenEnvVar: undefined }, /at actors\[0\]\.tokenEnvVar/],
['tokenEnvVar is empty', { tokenEnvVar: '' }, /at actors\[0\]\.tokenEnvVar/],
['overrideActorContext is not an array', { overrideActorContext: 'packages' }, /expected array/],
['overrideActorContext holds non-strings', { overrideActorContext: [123] }, /overrideActorContext\[0\]/],
])('rejects an entry where %s', (_name, fields, expected) => {
Expand Down
6 changes: 3 additions & 3 deletions test/unit/github-scripts/run-with-apify-tokens.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ const SCRIPT = fileURLToPath(new URL('../../../.github/scripts/run-with-apify-to
// Three Actors, each with its own token, so a test can leave any of them out.
const CONFIG = {
actors: [
{ actorFullName: 'team/one', tokenEnvVar: 'TOKEN_ONE' },
{ actorFullName: 'team/two', tokenEnvVar: 'TOKEN_TWO' },
{ actorFullName: 'team/three', tokenEnvVar: 'TOKEN_THREE' },
{ folder: 'actors/one', actorFullName: 'team/one', tokenEnvVar: 'TOKEN_ONE' },
{ folder: 'actors/two', actorFullName: 'team/two', tokenEnvVar: 'TOKEN_TWO' },
{ folder: 'actors/three', actorFullName: 'team/three', tokenEnvVar: 'TOKEN_THREE' },
],
};

Expand Down
Loading