apify-test-tools 0.10.2-beta.17 → 0.10.2-beta.19
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.
- package/.gitattributes +1 -0
- package/.github/cli/apify-test-tools.mjs +89 -89
- package/.github/scripts/run-with-apify-tokens.mjs +63 -128
- package/.github/workflows/_check_code.yaml +5 -5
- package/.husky/pre-commit +4 -4
- package/.prettierignore +1 -0
- package/CHANGELOG.md +1 -0
- package/bin/utils/config/load-config.ts +17 -2
- package/bin/utils/config/parser.ts +17 -8
- package/bin/utils/config/structures/grouped.ts +2 -2
- package/bin/utils/config/structures/legacy.ts +1 -1
- package/dist/bin/utils/config/load-config.d.ts +5 -0
- package/dist/bin/utils/config/load-config.d.ts.map +1 -1
- package/dist/bin/utils/config/load-config.js +12 -1
- package/dist/bin/utils/config/load-config.js.map +1 -1
- package/dist/bin/utils/config/parser.d.ts +6 -2
- package/dist/bin/utils/config/parser.d.ts.map +1 -1
- package/dist/bin/utils/config/parser.js +17 -8
- package/dist/bin/utils/config/parser.js.map +1 -1
- package/dist/bin/utils/config/structures/grouped.js +2 -2
- package/dist/bin/utils/config/structures/grouped.js.map +1 -1
- package/dist/bin/utils/config/structures/legacy.js +1 -1
- package/dist/bin/utils/config/structures/legacy.js.map +1 -1
- package/dist/test/bundle/token-wrapper.test.d.ts +2 -0
- package/dist/test/bundle/token-wrapper.test.d.ts.map +1 -0
- package/dist/test/bundle/token-wrapper.test.js +80 -0
- package/dist/test/bundle/token-wrapper.test.js.map +1 -0
- package/dist/test/unit/bin/utils/config/load-config.test.js +19 -1
- package/dist/test/unit/bin/utils/config/load-config.test.js.map +1 -1
- package/dist/test/unit/bin/utils/config/parser.test.js +21 -4
- package/dist/test/unit/bin/utils/config/parser.test.js.map +1 -1
- package/dist/test/unit/bin/utils/config/structures/grouped.test.js +6 -0
- package/dist/test/unit/bin/utils/config/structures/grouped.test.js.map +1 -1
- package/dist/test/unit/bin/utils/config/structures/legacy.test.js +1 -0
- package/dist/test/unit/bin/utils/config/structures/legacy.test.js.map +1 -1
- package/dist/test/unit/github-scripts/run-with-apify-tokens.test.js +3 -3
- package/dist/test/unit/github-scripts/run-with-apify-tokens.test.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/workflow-scripts-src/run-with-apify-tokens.d.ts +3 -0
- package/dist/workflow-scripts-src/run-with-apify-tokens.d.ts.map +1 -0
- package/dist/workflow-scripts-src/run-with-apify-tokens.js +100 -0
- package/dist/workflow-scripts-src/run-with-apify-tokens.js.map +1 -0
- package/docs/workflows.md +3 -0
- package/eslint.config.mjs +9 -0
- package/package.json +4 -2
- package/test/bundle/token-wrapper.test.ts +110 -0
- package/test/unit/bin/utils/config/load-config.test.ts +26 -1
- package/test/unit/bin/utils/config/parser.test.ts +23 -5
- package/test/unit/bin/utils/config/structures/grouped.test.ts +12 -0
- package/test/unit/bin/utils/config/structures/legacy.test.ts +1 -0
- package/test/unit/github-scripts/run-with-apify-tokens.test.ts +3 -3
- package/tsconfig.json +1 -1
- package/workflow-scripts-src/run-with-apify-tokens.ts +112 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"run-with-apify-tokens.d.ts","sourceRoot":"","sources":["../../workflow-scripts-src/run-with-apify-tokens.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Runs apify-test-tools and passes it only secrets needed for the Actors, no other secrets are leaked.
|
|
3
|
+
//
|
|
4
|
+
// jq ... | node .github/scripts/run-with-apify-tokens.mjs apify-test-tools build --version-number 0.99
|
|
5
|
+
//
|
|
6
|
+
// The step passes the reusable workflow's whole secrets map as WORKFLOW_SECRETS. The token names
|
|
7
|
+
// come from `tokenEnvVar` in the repo's apify-test-tools.config.json, which is the same file
|
|
8
|
+
// apify-test-tools itself reads, so the two can't drift. Everything else in the secrets map (npm,
|
|
9
|
+
// Slack, GitHub, anything else the repo holds) is left out, and WORKFLOW_SECRETS itself is dropped,
|
|
10
|
+
// so the blob never reaches the CLI or anything under node_modules.
|
|
11
|
+
//
|
|
12
|
+
// Each token is looked up in the CONFIG_SECRETS secret first, a JSON object of secret name -> value,
|
|
13
|
+
// and then among the top-level secrets, which is where `secrets: inherit` puts them. The secrets the
|
|
14
|
+
// workflows read by name (NPM_TOKEN, TESTER_APIFY_TOKEN, ...) never come from CONFIG_SECRETS.
|
|
15
|
+
//
|
|
16
|
+
// The command runs without a shell, so branch names and other interpolated arguments are passed
|
|
17
|
+
// through as literal argv entries.
|
|
18
|
+
import { spawnSync } from 'node:child_process';
|
|
19
|
+
import { CONFIG_FILE_NAME, readBaseConfigFile } from '../bin/utils/config/load-config.js';
|
|
20
|
+
function fail(message) {
|
|
21
|
+
console.error(message);
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
function isRecord(value) {
|
|
25
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
26
|
+
}
|
|
27
|
+
const [command, ...args] = process.argv.slice(2);
|
|
28
|
+
if (!command)
|
|
29
|
+
fail('Usage: run-with-apify-tokens.mjs <command> [args...]');
|
|
30
|
+
const { WORKFLOW_SECRETS, ...baseEnv } = process.env;
|
|
31
|
+
if (!WORKFLOW_SECRETS) {
|
|
32
|
+
fail('WORKFLOW_SECRETS is not set. Add `WORKFLOW_SECRETS: ${{ toJSON(secrets) }}` to the step env.');
|
|
33
|
+
}
|
|
34
|
+
let workflowSecrets;
|
|
35
|
+
try {
|
|
36
|
+
workflowSecrets = JSON.parse(WORKFLOW_SECRETS);
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
fail(`WORKFLOW_SECRETS is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
40
|
+
}
|
|
41
|
+
const workflowSecretsMap = workflowSecrets;
|
|
42
|
+
if (!isRecord(workflowSecretsMap)) {
|
|
43
|
+
fail('WORKFLOW_SECRETS must be a JSON object of secret names and values.');
|
|
44
|
+
}
|
|
45
|
+
// Never includes the parse error: Node quotes the input in it, and the input is a secret.
|
|
46
|
+
const unpackConfigSecrets = () => {
|
|
47
|
+
const packed = workflowSecretsMap.CONFIG_SECRETS;
|
|
48
|
+
if (!packed)
|
|
49
|
+
return {};
|
|
50
|
+
if (typeof packed !== 'string')
|
|
51
|
+
fail('The CONFIG_SECRETS secret is not valid JSON.');
|
|
52
|
+
let value;
|
|
53
|
+
try {
|
|
54
|
+
value = JSON.parse(packed);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
fail('The CONFIG_SECRETS secret is not valid JSON.');
|
|
58
|
+
}
|
|
59
|
+
if (!isRecord(value)) {
|
|
60
|
+
fail('The CONFIG_SECRETS secret must be a JSON object.');
|
|
61
|
+
}
|
|
62
|
+
return value;
|
|
63
|
+
};
|
|
64
|
+
const secrets = { ...workflowSecretsMap, ...unpackConfigSecrets() };
|
|
65
|
+
let actorConfigs;
|
|
66
|
+
try {
|
|
67
|
+
actorConfigs = await readBaseConfigFile();
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
fail(error instanceof Error ? error.message : String(error));
|
|
71
|
+
}
|
|
72
|
+
const tokenNames = [...new Set(actorConfigs.map(({ tokenEnvVar }) => tokenEnvVar))].sort();
|
|
73
|
+
// Declared but absent is a warning, not an error. A repo can carry an Actor whose token isn't
|
|
74
|
+
// configured and still build fine as long as that Actor never changes, and apify-test-tools raises
|
|
75
|
+
// a precise error naming the Actor at the point it actually needs the token.
|
|
76
|
+
const missing = tokenNames.filter((name) => !(name in secrets));
|
|
77
|
+
if (missing.length > 0) {
|
|
78
|
+
console.error(`Warning: ${CONFIG_FILE_NAME} declares ${missing.join(', ')}, but no such secret was passed ` +
|
|
79
|
+
`to this workflow, neither inside CONFIG_SECRETS nor inherited. Check the repository secrets ` +
|
|
80
|
+
`and the workflow's \`secrets:\` if a build fails on a missing token.`);
|
|
81
|
+
}
|
|
82
|
+
const present = tokenNames.filter((name) => name in secrets);
|
|
83
|
+
// Names only. The values are secrets and must never be printed, even though the runner masks
|
|
84
|
+
// registered secrets in logs.
|
|
85
|
+
console.error(`Passing ${present.length} Actor token(s) to \`${command}\`: ${present.join(', ') || '(none)'}`);
|
|
86
|
+
const env = { ...baseEnv };
|
|
87
|
+
for (const name of present) {
|
|
88
|
+
const value = secrets[name];
|
|
89
|
+
if (typeof value !== 'string')
|
|
90
|
+
fail(`Secret "${name}" must be a string.`);
|
|
91
|
+
env[name] = value;
|
|
92
|
+
}
|
|
93
|
+
const result = spawnSync(command, args, { stdio: 'inherit', env });
|
|
94
|
+
if (result.error) {
|
|
95
|
+
fail(`Failed to run \`${command}\`: ${result.error.message}`);
|
|
96
|
+
}
|
|
97
|
+
// Preserve the child's exit code so the step fails exactly when the command does. A child killed
|
|
98
|
+
// by a signal reports a null status, which would otherwise be read as success.
|
|
99
|
+
process.exit(result.status ?? 1);
|
|
100
|
+
//# sourceMappingURL=run-with-apify-tokens.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"run-with-apify-tokens.js","sourceRoot":"","sources":["../../workflow-scripts-src/run-with-apify-tokens.ts"],"names":[],"mappings":"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"}
|
package/docs/workflows.md
CHANGED
|
@@ -396,6 +396,9 @@ token is not passed on. A token the config declares but the repo hasn't set is a
|
|
|
396
396
|
carry an Actor whose token isn't configured and still build fine as long as that Actor never changes. The build fails
|
|
397
397
|
with an error naming the Actor once it actually needs the token.
|
|
398
398
|
|
|
399
|
+
The wrapper validates the config with the CLI's parser. Each Actor must declare a nonempty `tokenEnvVar` or inherit
|
|
400
|
+
one from its group. An empty name or a missing required `tokenEnvVar` fails during config validation.
|
|
401
|
+
|
|
399
402
|
The `unitTest` job runs static checks and needs `NPM_TOKEN` only. No job runs `npm ci` or `pnpm install` with Apify
|
|
400
403
|
or Slack credentials in scope, so a postinstall script in the dependency tree cannot read them.
|
|
401
404
|
|
package/eslint.config.mjs
CHANGED
|
@@ -37,4 +37,13 @@ export default [
|
|
|
37
37
|
'no-console': ['error', { allow: ['warn', 'error'] }],
|
|
38
38
|
},
|
|
39
39
|
},
|
|
40
|
+
{
|
|
41
|
+
files: ['workflow-scripts-src/**/*.ts'],
|
|
42
|
+
rules: {
|
|
43
|
+
// Workflow helpers preserve the existing standalone scripts' console output and GitHub expression text.
|
|
44
|
+
'no-console': 'off',
|
|
45
|
+
'no-template-curly-in-string': 'off',
|
|
46
|
+
'prefer-destructuring': 'off',
|
|
47
|
+
},
|
|
48
|
+
},
|
|
40
49
|
];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "apify-test-tools",
|
|
3
|
-
"version": "0.10.2-beta.
|
|
3
|
+
"version": "0.10.2-beta.19",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "TBD",
|
|
6
6
|
"repository": {
|
|
@@ -59,7 +59,9 @@
|
|
|
59
59
|
"start": "npm run dist/bin/main.js",
|
|
60
60
|
"start:dev": "cd local-clone && tsx ../bin/main.ts",
|
|
61
61
|
"build": "tsc && chmod +x ./dist/bin/main.js",
|
|
62
|
-
"bundle": "
|
|
62
|
+
"bundle": "npm run bundle:cli && npm run bundle:workflow-scripts",
|
|
63
|
+
"bundle:cli": "esbuild bin/main.ts --bundle --minify --platform=node --format=esm --target=node24 --log-level=warning --banner:js=\"import{createRequire as __createRequire}from'node:module';const require=__createRequire(import.meta.url);\" --outfile=.github/cli/apify-test-tools.mjs",
|
|
64
|
+
"bundle:workflow-scripts": "esbuild workflow-scripts-src/run-with-apify-tokens.ts --bundle --minify --platform=node --format=esm --target=node24 --log-level=warning --banner:js=\"import{createRequire as __createRequire}from'node:module';const require=__createRequire(import.meta.url);\" --outfile=.github/scripts/run-with-apify-tokens.mjs",
|
|
63
65
|
"lint": "eslint",
|
|
64
66
|
"lint:fix": "eslint --fix",
|
|
65
67
|
"format": "prettier --write .",
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import process from 'node:process';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
9
|
+
|
|
10
|
+
const wrapperPath = fileURLToPath(new URL('../../.github/scripts/run-with-apify-tokens.mjs', import.meta.url));
|
|
11
|
+
const configFileName = 'apify-test-tools.config.json';
|
|
12
|
+
|
|
13
|
+
let repoDir: string;
|
|
14
|
+
|
|
15
|
+
describe('token wrapper bundle', () => {
|
|
16
|
+
beforeAll(async () => {
|
|
17
|
+
repoDir = await mkdtemp(path.join(os.tmpdir(), 'apify-token-wrapper-'));
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterAll(async () => {
|
|
21
|
+
if (repoDir) await rm(repoDir, { recursive: true, force: true });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const actorSecrets = {
|
|
25
|
+
GROUP_TOKEN: 'group-value',
|
|
26
|
+
OVERRIDE_TOKEN: 'override-value',
|
|
27
|
+
OTHER: 'hidden',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const runWrapper = (workflowSecrets: Record<string, string> = actorSecrets) =>
|
|
31
|
+
spawnSync(process.execPath, [wrapperPath, process.execPath, '-e', 'console.log(JSON.stringify(process.env))'], {
|
|
32
|
+
cwd: repoDir,
|
|
33
|
+
env: {
|
|
34
|
+
PATH: process.env.PATH ?? '',
|
|
35
|
+
WORKFLOW_SECRETS: JSON.stringify(workflowSecrets),
|
|
36
|
+
},
|
|
37
|
+
encoding: 'utf8',
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it.each(['inherited secrets', 'CONFIG_SECRETS'])(
|
|
41
|
+
'passes grouped tokens and actor overrides from %s without unrelated secrets',
|
|
42
|
+
async (source) => {
|
|
43
|
+
await writeFile(
|
|
44
|
+
path.join(repoDir, configFileName),
|
|
45
|
+
JSON.stringify({
|
|
46
|
+
mode: 'grouped',
|
|
47
|
+
groups: {
|
|
48
|
+
example: {
|
|
49
|
+
tokenEnvVar: 'GROUP_TOKEN',
|
|
50
|
+
actors: [
|
|
51
|
+
{ folder: 'actors/one', actorFullName: 'example/one' },
|
|
52
|
+
{ folder: 'actors/two', actorFullName: 'example/two', tokenEnvVar: 'OVERRIDE_TOKEN' },
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
}),
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const { status, stdout, stderr } = runWrapper(
|
|
60
|
+
source === 'CONFIG_SECRETS' ? { CONFIG_SECRETS: JSON.stringify(actorSecrets) } : actorSecrets,
|
|
61
|
+
);
|
|
62
|
+
expect(status, stderr).toBe(0);
|
|
63
|
+
const childEnv: Record<string, string> = JSON.parse(stdout);
|
|
64
|
+
expect(childEnv).toMatchObject({ GROUP_TOKEN: 'group-value', OVERRIDE_TOKEN: 'override-value' });
|
|
65
|
+
expect(childEnv).not.toHaveProperty('OTHER');
|
|
66
|
+
expect(childEnv).not.toHaveProperty('WORKFLOW_SECRETS');
|
|
67
|
+
expect(childEnv).not.toHaveProperty('CONFIG_SECRETS');
|
|
68
|
+
expect(stderr).toContain('Passing 2 Actor token(s)');
|
|
69
|
+
},
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
it('does not start the child when config validation fails', async () => {
|
|
73
|
+
await writeFile(path.join(repoDir, configFileName), JSON.stringify({ mode: 'grouped', groups: {} }));
|
|
74
|
+
|
|
75
|
+
const { status, stdout, stderr } = runWrapper();
|
|
76
|
+
expect(status).toBe(1);
|
|
77
|
+
expect(stdout).toBe('');
|
|
78
|
+
expect(stderr).toContain('At least one configuration group is required');
|
|
79
|
+
expect(stderr).not.toContain('Cannot read');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it.each(['', undefined])(
|
|
83
|
+
'rejects an empty or missing token name (%s) before starting the child',
|
|
84
|
+
async (tokenEnvVar) => {
|
|
85
|
+
await writeFile(
|
|
86
|
+
path.join(repoDir, configFileName),
|
|
87
|
+
JSON.stringify({ actors: [{ folder: 'actors/one', actorFullName: 'example/one', tokenEnvVar }] }),
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const { status, stdout, stderr } = runWrapper();
|
|
91
|
+
expect(status).toBe(1);
|
|
92
|
+
expect(stdout).toBe('');
|
|
93
|
+
expect(stderr).toContain('actors[0].tokenEnvVar');
|
|
94
|
+
expect(stderr).not.toContain('Warning:');
|
|
95
|
+
expect(stderr).not.toContain('Cannot read');
|
|
96
|
+
},
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
it('reports a missing config file without a duplicate error prefix', async () => {
|
|
100
|
+
await rm(path.join(repoDir, configFileName), { force: true });
|
|
101
|
+
|
|
102
|
+
const { status, stdout, stderr } = runWrapper();
|
|
103
|
+
expect(status).toBe(1);
|
|
104
|
+
expect(stdout).toBe('');
|
|
105
|
+
expect(stderr).toBe(
|
|
106
|
+
`Config file "${configFileName}" not found in the current directory. ` +
|
|
107
|
+
'Please create one with the required actor entries.\n',
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -4,7 +4,12 @@ import path from 'node:path';
|
|
|
4
4
|
|
|
5
5
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
CONFIG_FILE_NAME,
|
|
9
|
+
loadActorConfig,
|
|
10
|
+
readBaseConfigFile,
|
|
11
|
+
readConfigFile,
|
|
12
|
+
} from '../../../../../bin/utils/config/load-config.js';
|
|
8
13
|
import type { ResolvedActorConfig } from '../../../../../bin/utils/config/structures/base.js';
|
|
9
14
|
import { ExistingDir, ExistingFile, RelativeDir, RelativeFile } from '../../../../../bin/utils/path/repo-relative.js';
|
|
10
15
|
|
|
@@ -265,3 +270,23 @@ describe('readConfigFile', () => {
|
|
|
265
270
|
await expect(readConfigFile(emptyActorSelection)).rejects.toThrow(/at actors/);
|
|
266
271
|
});
|
|
267
272
|
});
|
|
273
|
+
|
|
274
|
+
describe('readBaseConfigFile', () => {
|
|
275
|
+
it('returns non-file settings without checking Actor paths or reading actor.json', async () => {
|
|
276
|
+
await writeFiles({
|
|
277
|
+
[CONFIG_FILE_NAME]: validConfig([
|
|
278
|
+
{
|
|
279
|
+
folder: 'missing-actor-dir',
|
|
280
|
+
actorFullName: 'myteam/shopify',
|
|
281
|
+
tokenEnvVar: 'APIFY_TOKEN',
|
|
282
|
+
overrideActorContext: ['../outside'],
|
|
283
|
+
},
|
|
284
|
+
]),
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
await expect(readBaseConfigFile()).resolves.toEqual([
|
|
288
|
+
{ actorFullName: 'myteam/shopify', tokenEnvVar: 'APIFY_TOKEN' },
|
|
289
|
+
]);
|
|
290
|
+
await expect(readConfigFile(emptyActorSelection)).rejects.toThrow();
|
|
291
|
+
});
|
|
292
|
+
});
|
|
@@ -4,13 +4,18 @@ import path from 'node:path';
|
|
|
4
4
|
|
|
5
5
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
6
6
|
|
|
7
|
-
import { _privates, parseConfigFile } from '../../../../../bin/utils/config/parser.js';
|
|
8
|
-
import { CONFIG_FILE_STRATEGY } from '../../../../../bin/utils/config/structures/base.js';
|
|
7
|
+
import { _privates, parseConfigFile, parseResolvedConfigFile } from '../../../../../bin/utils/config/parser.js';
|
|
8
|
+
import { CONFIG_FILE_STRATEGY, type ResolvedActorConfig } from '../../../../../bin/utils/config/structures/base.js';
|
|
9
9
|
import { GROUPED_PARSER } from '../../../../../bin/utils/config/structures/grouped.js';
|
|
10
10
|
import { LEGACY_PARSER } from '../../../../../bin/utils/config/structures/legacy.js';
|
|
11
11
|
import { ExistingDir, RelativeDir } from '../../../../../bin/utils/path/repo-relative.js';
|
|
12
12
|
|
|
13
|
-
const { selectStrategy, verifyConfiguration } = _privates;
|
|
13
|
+
const { selectStrategy, initializePaths, verifyConfiguration: verifyInitializedConfiguration } = _privates;
|
|
14
|
+
const verifyConfiguration = (body: ResolvedActorConfig[]) => {
|
|
15
|
+
const initialized = initializePaths(body);
|
|
16
|
+
verifyInitializedConfiguration(initialized);
|
|
17
|
+
return initialized;
|
|
18
|
+
};
|
|
14
19
|
|
|
15
20
|
let repoDir: string;
|
|
16
21
|
const originalCwd = process.cwd();
|
|
@@ -95,13 +100,13 @@ describe('verifyConfiguration', () => {
|
|
|
95
100
|
).toThrow(/Duplicate folder/);
|
|
96
101
|
});
|
|
97
102
|
|
|
98
|
-
it('
|
|
103
|
+
it('reports the normalized path for a duplicate folder', () => {
|
|
99
104
|
expect(() =>
|
|
100
105
|
verifyConfiguration([
|
|
101
106
|
actor({ folder: '.', actorFullName: 'myteam/actor-a' }),
|
|
102
107
|
actor({ folder: './', actorFullName: 'myteam/actor-b' }),
|
|
103
108
|
]),
|
|
104
|
-
).toThrow('Duplicate folder "
|
|
109
|
+
).toThrow('Duplicate folder "."');
|
|
105
110
|
});
|
|
106
111
|
|
|
107
112
|
it('rejects two entries pointing at the same actor', () => {
|
|
@@ -150,6 +155,19 @@ describe('verifyConfiguration', () => {
|
|
|
150
155
|
});
|
|
151
156
|
|
|
152
157
|
describe('parseConfigFile', () => {
|
|
158
|
+
it('shares strategy resolution with parsing that does not initialize paths', () => {
|
|
159
|
+
const body = { actors: [actor({ folder: 'missing-actor-dir', overrideActorContext: ['../outside'] })] };
|
|
160
|
+
expect(parseResolvedConfigFile(body)).toEqual([
|
|
161
|
+
{
|
|
162
|
+
actorFullName: 'myteam/shopify',
|
|
163
|
+
folder: 'missing-actor-dir',
|
|
164
|
+
tokenEnvVar: 'APIFY_TOKEN',
|
|
165
|
+
overrideActorContext: ['../outside'],
|
|
166
|
+
},
|
|
167
|
+
]);
|
|
168
|
+
expect(() => parseConfigFile(body)).toThrow();
|
|
169
|
+
});
|
|
170
|
+
|
|
153
171
|
it('validates and normalizes paths after resolving the config', () => {
|
|
154
172
|
const [parsed] = parseConfigFile({ actors: [actor({ folder: './bin/', overrideActorContext: ['test/'] })] });
|
|
155
173
|
expect(parsed.folder).toBeInstanceOf(ExistingDir);
|
|
@@ -68,6 +68,18 @@ describe('GROUPED_PARSER', () => {
|
|
|
68
68
|
expect(() => GROUPED_PARSER.parse({})).toThrow();
|
|
69
69
|
});
|
|
70
70
|
|
|
71
|
+
it('rejects an empty group token name', () => {
|
|
72
|
+
expect(() => GROUPED_PARSER.parse({ groups: { myteam: group({ tokenEnvVar: '' }) } })).toThrow(
|
|
73
|
+
/at groups\.myteam\.tokenEnvVar/,
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('rejects an empty actor token override instead of falling back to the group token', () => {
|
|
78
|
+
expect(() =>
|
|
79
|
+
GROUPED_PARSER.parse({ groups: { myteam: group({ actors: [actor({ tokenEnvVar: '' })] }) } }),
|
|
80
|
+
).toThrow(/at groups\.myteam\.actors\[0\]\.tokenEnvVar/);
|
|
81
|
+
});
|
|
82
|
+
|
|
71
83
|
it('reports problems from every group at once instead of only the first', () => {
|
|
72
84
|
const message = (() => {
|
|
73
85
|
try {
|
|
@@ -32,6 +32,7 @@ describe('LEGACY_PARSER', () => {
|
|
|
32
32
|
['folder is missing', { folder: undefined }, /at actors\[0\]\.folder/],
|
|
33
33
|
['folder is not a string', { folder: 123 }, /at actors\[0\]\.folder/],
|
|
34
34
|
['tokenEnvVar is missing', { tokenEnvVar: undefined }, /at actors\[0\]\.tokenEnvVar/],
|
|
35
|
+
['tokenEnvVar is empty', { tokenEnvVar: '' }, /at actors\[0\]\.tokenEnvVar/],
|
|
35
36
|
['overrideActorContext is not an array', { overrideActorContext: 'packages' }, /expected array/],
|
|
36
37
|
['overrideActorContext holds non-strings', { overrideActorContext: [123] }, /overrideActorContext\[0\]/],
|
|
37
38
|
])('rejects an entry where %s', (_name, fields, expected) => {
|
|
@@ -12,9 +12,9 @@ const SCRIPT = fileURLToPath(new URL('../../../.github/scripts/run-with-apify-to
|
|
|
12
12
|
// Three Actors, each with its own token, so a test can leave any of them out.
|
|
13
13
|
const CONFIG = {
|
|
14
14
|
actors: [
|
|
15
|
-
{ actorFullName: 'team/one', tokenEnvVar: 'TOKEN_ONE' },
|
|
16
|
-
{ actorFullName: 'team/two', tokenEnvVar: 'TOKEN_TWO' },
|
|
17
|
-
{ actorFullName: 'team/three', tokenEnvVar: 'TOKEN_THREE' },
|
|
15
|
+
{ folder: 'actors/one', actorFullName: 'team/one', tokenEnvVar: 'TOKEN_ONE' },
|
|
16
|
+
{ folder: 'actors/two', actorFullName: 'team/two', tokenEnvVar: 'TOKEN_TWO' },
|
|
17
|
+
{ folder: 'actors/three', actorFullName: 'team/three', tokenEnvVar: 'TOKEN_THREE' },
|
|
18
18
|
],
|
|
19
19
|
};
|
|
20
20
|
|
package/tsconfig.json
CHANGED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Runs apify-test-tools and passes it only secrets needed for the Actors, no other secrets are leaked.
|
|
3
|
+
//
|
|
4
|
+
// jq ... | node .github/scripts/run-with-apify-tokens.mjs apify-test-tools build --version-number 0.99
|
|
5
|
+
//
|
|
6
|
+
// The step passes the reusable workflow's whole secrets map as WORKFLOW_SECRETS. The token names
|
|
7
|
+
// come from `tokenEnvVar` in the repo's apify-test-tools.config.json, which is the same file
|
|
8
|
+
// apify-test-tools itself reads, so the two can't drift. Everything else in the secrets map (npm,
|
|
9
|
+
// Slack, GitHub, anything else the repo holds) is left out, and WORKFLOW_SECRETS itself is dropped,
|
|
10
|
+
// so the blob never reaches the CLI or anything under node_modules.
|
|
11
|
+
//
|
|
12
|
+
// Each token is looked up in the CONFIG_SECRETS secret first, a JSON object of secret name -> value,
|
|
13
|
+
// and then among the top-level secrets, which is where `secrets: inherit` puts them. The secrets the
|
|
14
|
+
// workflows read by name (NPM_TOKEN, TESTER_APIFY_TOKEN, ...) never come from CONFIG_SECRETS.
|
|
15
|
+
//
|
|
16
|
+
// The command runs without a shell, so branch names and other interpolated arguments are passed
|
|
17
|
+
// through as literal argv entries.
|
|
18
|
+
|
|
19
|
+
import { spawnSync } from 'node:child_process';
|
|
20
|
+
|
|
21
|
+
import { CONFIG_FILE_NAME, readBaseConfigFile } from '../bin/utils/config/load-config.js';
|
|
22
|
+
|
|
23
|
+
function fail(message: string): never {
|
|
24
|
+
console.error(message);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
29
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const [command, ...args] = process.argv.slice(2);
|
|
33
|
+
if (!command) fail('Usage: run-with-apify-tokens.mjs <command> [args...]');
|
|
34
|
+
|
|
35
|
+
const { WORKFLOW_SECRETS, ...baseEnv } = process.env;
|
|
36
|
+
if (!WORKFLOW_SECRETS) {
|
|
37
|
+
fail('WORKFLOW_SECRETS is not set. Add `WORKFLOW_SECRETS: ${{ toJSON(secrets) }}` to the step env.');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let workflowSecrets: unknown;
|
|
41
|
+
try {
|
|
42
|
+
workflowSecrets = JSON.parse(WORKFLOW_SECRETS);
|
|
43
|
+
} catch (error) {
|
|
44
|
+
fail(`WORKFLOW_SECRETS is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
45
|
+
}
|
|
46
|
+
const workflowSecretsMap = workflowSecrets;
|
|
47
|
+
if (!isRecord(workflowSecretsMap)) {
|
|
48
|
+
fail('WORKFLOW_SECRETS must be a JSON object of secret names and values.');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Never includes the parse error: Node quotes the input in it, and the input is a secret.
|
|
52
|
+
const unpackConfigSecrets = (): Record<string, unknown> => {
|
|
53
|
+
const packed = workflowSecretsMap.CONFIG_SECRETS;
|
|
54
|
+
if (!packed) return {};
|
|
55
|
+
if (typeof packed !== 'string') fail('The CONFIG_SECRETS secret is not valid JSON.');
|
|
56
|
+
let value: unknown;
|
|
57
|
+
try {
|
|
58
|
+
value = JSON.parse(packed);
|
|
59
|
+
} catch {
|
|
60
|
+
fail('The CONFIG_SECRETS secret is not valid JSON.');
|
|
61
|
+
}
|
|
62
|
+
if (!isRecord(value)) {
|
|
63
|
+
fail('The CONFIG_SECRETS secret must be a JSON object.');
|
|
64
|
+
}
|
|
65
|
+
return value;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const secrets = { ...workflowSecretsMap, ...unpackConfigSecrets() };
|
|
69
|
+
|
|
70
|
+
let actorConfigs: Awaited<ReturnType<typeof readBaseConfigFile>>;
|
|
71
|
+
try {
|
|
72
|
+
actorConfigs = await readBaseConfigFile();
|
|
73
|
+
} catch (error) {
|
|
74
|
+
fail(error instanceof Error ? error.message : String(error));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const tokenNames = [...new Set(actorConfigs.map(({ tokenEnvVar }) => tokenEnvVar))].sort();
|
|
78
|
+
|
|
79
|
+
// Declared but absent is a warning, not an error. A repo can carry an Actor whose token isn't
|
|
80
|
+
// configured and still build fine as long as that Actor never changes, and apify-test-tools raises
|
|
81
|
+
// a precise error naming the Actor at the point it actually needs the token.
|
|
82
|
+
const missing = tokenNames.filter((name) => !(name in secrets));
|
|
83
|
+
if (missing.length > 0) {
|
|
84
|
+
console.error(
|
|
85
|
+
`Warning: ${CONFIG_FILE_NAME} declares ${missing.join(', ')}, but no such secret was passed ` +
|
|
86
|
+
`to this workflow, neither inside CONFIG_SECRETS nor inherited. Check the repository secrets ` +
|
|
87
|
+
`and the workflow's \`secrets:\` if a build fails on a missing token.`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const present = tokenNames.filter((name) => name in secrets);
|
|
92
|
+
|
|
93
|
+
// Names only. The values are secrets and must never be printed, even though the runner masks
|
|
94
|
+
// registered secrets in logs.
|
|
95
|
+
console.error(`Passing ${present.length} Actor token(s) to \`${command}\`: ${present.join(', ') || '(none)'}`);
|
|
96
|
+
|
|
97
|
+
const env = { ...baseEnv };
|
|
98
|
+
for (const name of present) {
|
|
99
|
+
const value = secrets[name];
|
|
100
|
+
if (typeof value !== 'string') fail(`Secret "${name}" must be a string.`);
|
|
101
|
+
env[name] = value;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const result = spawnSync(command, args, { stdio: 'inherit', env });
|
|
105
|
+
|
|
106
|
+
if (result.error) {
|
|
107
|
+
fail(`Failed to run \`${command}\`: ${result.error.message}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Preserve the child's exit code so the step fails exactly when the command does. A child killed
|
|
111
|
+
// by a signal reports a null status, which would otherwise be read as success.
|
|
112
|
+
process.exit(result.status ?? 1);
|