@invarn/cibuild 2.7.1 → 2.7.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.
- package/dist/cli.cjs +10 -10
- package/dist/src/yaml/a-declared-secret-reaches-the-file-step.test.d.ts +20 -0
- package/dist/src/yaml/a-declared-secret-reaches-the-file-step.test.d.ts.map +1 -0
- package/dist/src/yaml/a-declared-secret-reaches-the-file-step.test.js +201 -0
- package/dist/src/yaml/a-missing-credential-is-loud.test.d.ts +32 -0
- package/dist/src/yaml/a-missing-credential-is-loud.test.d.ts.map +1 -0
- package/dist/src/yaml/a-missing-credential-is-loud.test.js +301 -0
- package/dist/src/yaml/env-resolver.d.ts +33 -0
- package/dist/src/yaml/env-resolver.d.ts.map +1 -1
- package/dist/src/yaml/env-resolver.js +54 -2
- package/dist/src/yaml/steps/file.d.ts +25 -0
- package/dist/src/yaml/steps/file.d.ts.map +1 -1
- package/dist/src/yaml/steps/file.js +75 -2
- package/dist/src/yaml/steps/git-fetch-retry-exec.test.d.ts +10 -0
- package/dist/src/yaml/steps/git-fetch-retry-exec.test.d.ts.map +1 -0
- package/dist/src/yaml/steps/git-fetch-retry-exec.test.js +162 -0
- package/dist/src/yaml/steps/git-fetch-retry.d.ts +62 -0
- package/dist/src/yaml/steps/git-fetch-retry.d.ts.map +1 -0
- package/dist/src/yaml/steps/git-fetch-retry.js +155 -0
- package/dist/src/yaml/steps/git-fetch-retry.test.d.ts +5 -0
- package/dist/src/yaml/steps/git-fetch-retry.test.d.ts.map +1 -0
- package/dist/src/yaml/steps/git-fetch-retry.test.js +86 -0
- package/dist/src/yaml/steps/ios-deps.d.ts.map +1 -1
- package/dist/src/yaml/steps/ios-deps.js +14 -4
- package/dist/src/yaml/steps/xcode-resolve-retry.test.d.ts +9 -0
- package/dist/src/yaml/steps/xcode-resolve-retry.test.d.ts.map +1 -0
- package/dist/src/yaml/steps/xcode-resolve-retry.test.js +146 -0
- package/dist/src/yaml/steps/xcode.d.ts.map +1 -1
- package/dist/src/yaml/steps/xcode.js +41 -0
- package/package.json +1 -1
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A credential the pipeline declares and the dispatch delivers reaches the
|
|
3
|
+
* step that writes it.
|
|
4
|
+
*
|
|
5
|
+
* `file@1.0.0` decides at generation time, from the env map `EnvResolver`
|
|
6
|
+
* hands it: a value means a write script, no value means
|
|
7
|
+
* `echo "⚠️ X not set — skipping <file>"` and a green step. That map had
|
|
8
|
+
* four sources — built-ins, `.cibuild-secrets.json`, `app.envs`,
|
|
9
|
+
* `workflow.envs` — and a dispatched build supplies none of them. The runner
|
|
10
|
+
* merges the dispatch's secrets into the environment of the `ci` invocation
|
|
11
|
+
* and writes no secrets file, so every credential declared as an empty slot
|
|
12
|
+
* was dropped, on every dispatched build, and the step reported success.
|
|
13
|
+
*
|
|
14
|
+
* The two runs that make it unambiguous are here as the first two tests: the
|
|
15
|
+
* same pipeline, the same step, the same value, delivered once through the
|
|
16
|
+
* process environment and once through the local store. Before the fifth
|
|
17
|
+
* resolution step the first wrote nothing and the second wrote the file.
|
|
18
|
+
*/
|
|
19
|
+
export {};
|
|
20
|
+
//# sourceMappingURL=a-declared-secret-reaches-the-file-step.test.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"a-declared-secret-reaches-the-file-step.test.d.ts","sourceRoot":"","sources":["../../../src/yaml/a-declared-secret-reaches-the-file-step.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG"}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A credential the pipeline declares and the dispatch delivers reaches the
|
|
3
|
+
* step that writes it.
|
|
4
|
+
*
|
|
5
|
+
* `file@1.0.0` decides at generation time, from the env map `EnvResolver`
|
|
6
|
+
* hands it: a value means a write script, no value means
|
|
7
|
+
* `echo "⚠️ X not set — skipping <file>"` and a green step. That map had
|
|
8
|
+
* four sources — built-ins, `.cibuild-secrets.json`, `app.envs`,
|
|
9
|
+
* `workflow.envs` — and a dispatched build supplies none of them. The runner
|
|
10
|
+
* merges the dispatch's secrets into the environment of the `ci` invocation
|
|
11
|
+
* and writes no secrets file, so every credential declared as an empty slot
|
|
12
|
+
* was dropped, on every dispatched build, and the step reported success.
|
|
13
|
+
*
|
|
14
|
+
* The two runs that make it unambiguous are here as the first two tests: the
|
|
15
|
+
* same pipeline, the same step, the same value, delivered once through the
|
|
16
|
+
* process environment and once through the local store. Before the fifth
|
|
17
|
+
* resolution step the first wrote nothing and the second wrote the file.
|
|
18
|
+
*/
|
|
19
|
+
import { describe, test, expect, beforeEach, afterEach } from '@jest/globals';
|
|
20
|
+
import { spawnSync } from 'node:child_process';
|
|
21
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs';
|
|
22
|
+
import { tmpdir } from 'node:os';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
import { convertYAMLToPipelineDef } from './converter.js';
|
|
25
|
+
import { EnvResolver } from './env-resolver.js';
|
|
26
|
+
import { clearRegistry, registerStep } from './steps/registry.js';
|
|
27
|
+
import { FileStepExecutor } from './steps/file.js';
|
|
28
|
+
const mockConfig = {
|
|
29
|
+
artifactsDir: '/test/artifacts',
|
|
30
|
+
maxConcurrentJobs: 1,
|
|
31
|
+
paths: {
|
|
32
|
+
buildsDir: '/test/builds',
|
|
33
|
+
cacheDir: '/test/cache',
|
|
34
|
+
derivedDataDir: '/test/derived-data',
|
|
35
|
+
},
|
|
36
|
+
interpreters: {
|
|
37
|
+
bash: '/bin/bash',
|
|
38
|
+
python: 'python3',
|
|
39
|
+
ruby: 'ruby',
|
|
40
|
+
node: 'node',
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
/** The 61-byte document from the PRD's reproduction. Synthetic. */
|
|
44
|
+
const JSON_CREDENTIAL = '{"project_info":{"project_id":"synthetic-probe"},"client":[]}';
|
|
45
|
+
const TARGET = 'app/google-services.json';
|
|
46
|
+
let workdir;
|
|
47
|
+
let previousCwd;
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
clearRegistry();
|
|
50
|
+
registerStep('file', new FileStepExecutor());
|
|
51
|
+
previousCwd = process.cwd();
|
|
52
|
+
workdir = mkdtempSync(join(tmpdir(), 'cibuild-declared-secret-'));
|
|
53
|
+
process.chdir(workdir);
|
|
54
|
+
// `workdir` is the pre-symlink path on macOS; the steps resolve paths from
|
|
55
|
+
// CIBUILD_SOURCE_DIR, which is cwd, so read files back the same way.
|
|
56
|
+
workdir = process.cwd();
|
|
57
|
+
delete process.env.PLAIN_SECRET;
|
|
58
|
+
delete process.env.B64_SECRET;
|
|
59
|
+
});
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
process.chdir(previousCwd);
|
|
62
|
+
rmSync(workdir, { recursive: true, force: true });
|
|
63
|
+
clearRegistry();
|
|
64
|
+
delete process.env.PLAIN_SECRET;
|
|
65
|
+
delete process.env.B64_SECRET;
|
|
66
|
+
});
|
|
67
|
+
/**
|
|
68
|
+
* The shape the Android generator emits: an empty slot under `app.envs` and a
|
|
69
|
+
* skippable `file@1.0.0` step naming it.
|
|
70
|
+
*/
|
|
71
|
+
function probePipeline(declared, fileInputs) {
|
|
72
|
+
const app = declared
|
|
73
|
+
? { app: { envs: Object.entries(declared).map(([k, v]) => ({ [k]: v })) } }
|
|
74
|
+
: {};
|
|
75
|
+
return {
|
|
76
|
+
format_version: '1',
|
|
77
|
+
...app,
|
|
78
|
+
workflows: {
|
|
79
|
+
probe: {
|
|
80
|
+
steps: [
|
|
81
|
+
{
|
|
82
|
+
'file@1.0.0': {
|
|
83
|
+
title: 'Setup credential',
|
|
84
|
+
is_skippable: true,
|
|
85
|
+
inputs: fileInputs,
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
function writeSecretStore(global) {
|
|
94
|
+
writeFileSync(join(workdir, '.cibuild-secrets.json'), JSON.stringify({ global, workflows: {} }), { mode: 0o600 });
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Converts the pipeline and runs the generated steps the way the runner does,
|
|
98
|
+
* then reads back whatever landed at `target`.
|
|
99
|
+
*/
|
|
100
|
+
async function land(pipeline, target = TARGET) {
|
|
101
|
+
const result = await convertYAMLToPipelineDef(pipeline, mockConfig, 'probe');
|
|
102
|
+
const chunks = [...result.warnings];
|
|
103
|
+
for (const step of result.pipeline.steps) {
|
|
104
|
+
const scriptPath = join(workdir, `__step_${step.id}.sh`);
|
|
105
|
+
writeFileSync(scriptPath, step.script, 'utf-8');
|
|
106
|
+
const proc = spawnSync('bash', [scriptPath], {
|
|
107
|
+
cwd: workdir,
|
|
108
|
+
encoding: 'utf-8',
|
|
109
|
+
env: { ...process.env },
|
|
110
|
+
});
|
|
111
|
+
// A step that cannot even run would make every other assertion here
|
|
112
|
+
// vacuous, so fail on it directly rather than through the file contents.
|
|
113
|
+
expect(proc.status).toBe(0);
|
|
114
|
+
chunks.push(proc.stdout ?? '', proc.stderr ?? '');
|
|
115
|
+
}
|
|
116
|
+
const landedAt = join(workdir, target);
|
|
117
|
+
return {
|
|
118
|
+
output: chunks.join('\n'),
|
|
119
|
+
bytes: existsSync(landedAt) ? readFileSync(landedAt) : undefined,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
describe('a declared credential delivered through the process environment', () => {
|
|
123
|
+
test('reaches the file step and lands byte-exact — the dispatch path', async () => {
|
|
124
|
+
process.env.PLAIN_SECRET = JSON_CREDENTIAL;
|
|
125
|
+
const { output, bytes } = await land(probePipeline({ PLAIN_SECRET: '' }, { target_path: TARGET, var_name: 'PLAIN_SECRET' }));
|
|
126
|
+
expect(output).toContain('✓ Written google-services.json');
|
|
127
|
+
expect(output).not.toContain('not set');
|
|
128
|
+
expect(bytes?.toString('utf-8')).toBe(JSON_CREDENTIAL);
|
|
129
|
+
expect(bytes).toHaveLength(61);
|
|
130
|
+
});
|
|
131
|
+
test('still lands when the value comes from the local store instead', async () => {
|
|
132
|
+
writeSecretStore({ PLAIN_SECRET: JSON_CREDENTIAL });
|
|
133
|
+
const { output, bytes } = await land(probePipeline({ PLAIN_SECRET: '' }, { target_path: TARGET, var_name: 'PLAIN_SECRET' }));
|
|
134
|
+
expect(output).toContain('✓ Written google-services.json');
|
|
135
|
+
expect(bytes?.toString('utf-8')).toBe(JSON_CREDENTIAL);
|
|
136
|
+
});
|
|
137
|
+
test('the local store wins when both carry the key', async () => {
|
|
138
|
+
writeSecretStore({ PLAIN_SECRET: 'from-the-local-store' });
|
|
139
|
+
process.env.PLAIN_SECRET = 'from-the-inherited-environment';
|
|
140
|
+
const { bytes } = await land(probePipeline({ PLAIN_SECRET: '' }, { target_path: TARGET, var_name: 'PLAIN_SECRET' }));
|
|
141
|
+
expect(bytes?.toString('utf-8')).toBe('from-the-local-store');
|
|
142
|
+
});
|
|
143
|
+
test('a value the YAML itself supplies is not replaced by the environment', async () => {
|
|
144
|
+
process.env.PLAIN_SECRET = 'from-the-inherited-environment';
|
|
145
|
+
const { bytes } = await land(probePipeline({ PLAIN_SECRET: 'from-the-pipeline' }, { target_path: TARGET, var_name: 'PLAIN_SECRET' }));
|
|
146
|
+
expect(bytes?.toString('utf-8')).toBe('from-the-pipeline');
|
|
147
|
+
});
|
|
148
|
+
test('arrives verbatim — a $ in a credential is not a variable reference', async () => {
|
|
149
|
+
// A PEM passphrase, a Play service-account key, any credential at all may
|
|
150
|
+
// hold a `$`. Interpolating one would either paste a pipeline value into
|
|
151
|
+
// the middle of a secret or refuse the build over a name nothing declares.
|
|
152
|
+
const withDollars = 'prefix $NOT_A_DECLARED_VARIABLE ${ALSO_NOT_ONE} suffix';
|
|
153
|
+
process.env.PLAIN_SECRET = withDollars;
|
|
154
|
+
const { bytes } = await land(probePipeline({ PLAIN_SECRET: '' }, { target_path: TARGET, var_name: 'PLAIN_SECRET' }));
|
|
155
|
+
expect(bytes?.toString('utf-8')).toBe(withDollars);
|
|
156
|
+
});
|
|
157
|
+
test('base64_encoded round-trips bytes a text channel cannot carry', async () => {
|
|
158
|
+
// NUL and a newline together: the two bytes a keystore has and an
|
|
159
|
+
// environment variable cannot hold, which is why the slot carries base64.
|
|
160
|
+
const payload = Buffer.from([
|
|
161
|
+
0x50, 0x4b, 0x03, 0x04, 0x00, 0x0a, 0x00, 0xff, 0x00, 0x0d, 0x0a, 0x7f,
|
|
162
|
+
]);
|
|
163
|
+
process.env.B64_SECRET = payload.toString('base64');
|
|
164
|
+
const { output, bytes } = await land(probePipeline({ B64_SECRET: '' }, { target_path: 'release.keystore', var_name: 'B64_SECRET', base64_encoded: true }), 'release.keystore');
|
|
165
|
+
expect(output).toContain('✓ Written release.keystore');
|
|
166
|
+
expect(bytes?.equals(payload)).toBe(true);
|
|
167
|
+
});
|
|
168
|
+
test('the value never appears in what ci prints', async () => {
|
|
169
|
+
process.env.PLAIN_SECRET = JSON_CREDENTIAL;
|
|
170
|
+
const { output, bytes } = await land(probePipeline({ PLAIN_SECRET: '' }, { target_path: TARGET, var_name: 'PLAIN_SECRET' }));
|
|
171
|
+
// Asserted on the captured output, not read off a transcript.
|
|
172
|
+
expect(bytes?.toString('utf-8')).toBe(JSON_CREDENTIAL);
|
|
173
|
+
expect(output).not.toContain(JSON_CREDENTIAL);
|
|
174
|
+
expect(output).not.toContain('synthetic-probe');
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
describe('the host environment is not a source of pipeline variables', () => {
|
|
178
|
+
test('an undeclared key is not imported, even where a step reads it', async () => {
|
|
179
|
+
// HOME exists in every environment this runs in. If step 5 copied
|
|
180
|
+
// `process.env` in bulk this step would write the user's home directory
|
|
181
|
+
// to a file instead of skipping.
|
|
182
|
+
expect(process.env.HOME).toBeTruthy();
|
|
183
|
+
process.env.PLAIN_SECRET = JSON_CREDENTIAL;
|
|
184
|
+
// PLAIN_SECRET is declared, so the import runs; HOME is not.
|
|
185
|
+
const { output, bytes } = await land(probePipeline({ PLAIN_SECRET: '' }, { target_path: 'home.txt', var_name: 'HOME' }), 'home.txt');
|
|
186
|
+
expect(output).toContain('HOME not set — skipping home.txt');
|
|
187
|
+
expect(bytes).toBeUndefined();
|
|
188
|
+
});
|
|
189
|
+
test('the resolver does not hold an undeclared host variable', () => {
|
|
190
|
+
const pipeline = probePipeline({ PLAIN_SECRET: '' }, {
|
|
191
|
+
target_path: TARGET,
|
|
192
|
+
var_name: 'PLAIN_SECRET',
|
|
193
|
+
});
|
|
194
|
+
process.env.PLAIN_SECRET = JSON_CREDENTIAL;
|
|
195
|
+
const resolver = new EnvResolver(pipeline, pipeline.workflows.probe, 'probe', 'linux');
|
|
196
|
+
expect(resolver.get('PLAIN_SECRET')).toBe(JSON_CREDENTIAL);
|
|
197
|
+
expect(resolver.has('HOME')).toBe(false);
|
|
198
|
+
expect(resolver.getAll()).not.toHaveProperty('HOME');
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
//# sourceMappingURL=a-declared-secret-reaches-the-file-step.test.js.map
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A credential the pipeline declares and nobody supplied fails the build,
|
|
3
|
+
* naming itself, instead of being skipped with a warning.
|
|
4
|
+
*
|
|
5
|
+
* `file@1.0.0` carries `is_skippable: true` in every pipeline the Android
|
|
6
|
+
* generator emits, so an absent value printed `⚠️ X not set — skipping <file>`
|
|
7
|
+
* and returned success. The build then failed several steps later inside the
|
|
8
|
+
* project's own build tool, with an error naming the project rather than the
|
|
9
|
+
* value we dropped. Both failure modes in this defect were green, which is why
|
|
10
|
+
* it survived.
|
|
11
|
+
*
|
|
12
|
+
* The two absences are not the same absence, and the map the step already
|
|
13
|
+
* receives tells them apart by presence:
|
|
14
|
+
*
|
|
15
|
+
* - a `var_name` the pipeline **declares** and leaves empty is a key of the
|
|
16
|
+
* map with an empty value — the value was supposed to arrive as a secret
|
|
17
|
+
* and did not. After issue 01 that means it is in none of the four places
|
|
18
|
+
* a value can come from, so the diagnosis is not a guess.
|
|
19
|
+
* - a `var_name` **nothing declares** is not a key of the map at all — a
|
|
20
|
+
* release keystore on a debug workflow. Skipping is correct there.
|
|
21
|
+
*
|
|
22
|
+
* One case sits between them, and it decides the rule (issue 03 §2.3, Alex,
|
|
23
|
+
* 2026-09-08): the generator declares `KEYSTORE_BASE64` globally but emits its
|
|
24
|
+
* `file@` step in all three workflows, so a project that ran `ci init` with a
|
|
25
|
+
* keystore and never uploaded one would go from three green skips to three
|
|
26
|
+
* hard failures — including on `pull-request`, where nothing signs anything.
|
|
27
|
+
* So the question the step asks is the one that actually matters: **will the
|
|
28
|
+
* file be there when the build reads it?** A repository that carries the file
|
|
29
|
+
* keeps warning and building; only an absence with nothing behind it fails.
|
|
30
|
+
*/
|
|
31
|
+
export {};
|
|
32
|
+
//# sourceMappingURL=a-missing-credential-is-loud.test.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"a-missing-credential-is-loud.test.d.ts","sourceRoot":"","sources":["../../../src/yaml/a-missing-credential-is-loud.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG"}
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A credential the pipeline declares and nobody supplied fails the build,
|
|
3
|
+
* naming itself, instead of being skipped with a warning.
|
|
4
|
+
*
|
|
5
|
+
* `file@1.0.0` carries `is_skippable: true` in every pipeline the Android
|
|
6
|
+
* generator emits, so an absent value printed `⚠️ X not set — skipping <file>`
|
|
7
|
+
* and returned success. The build then failed several steps later inside the
|
|
8
|
+
* project's own build tool, with an error naming the project rather than the
|
|
9
|
+
* value we dropped. Both failure modes in this defect were green, which is why
|
|
10
|
+
* it survived.
|
|
11
|
+
*
|
|
12
|
+
* The two absences are not the same absence, and the map the step already
|
|
13
|
+
* receives tells them apart by presence:
|
|
14
|
+
*
|
|
15
|
+
* - a `var_name` the pipeline **declares** and leaves empty is a key of the
|
|
16
|
+
* map with an empty value — the value was supposed to arrive as a secret
|
|
17
|
+
* and did not. After issue 01 that means it is in none of the four places
|
|
18
|
+
* a value can come from, so the diagnosis is not a guess.
|
|
19
|
+
* - a `var_name` **nothing declares** is not a key of the map at all — a
|
|
20
|
+
* release keystore on a debug workflow. Skipping is correct there.
|
|
21
|
+
*
|
|
22
|
+
* One case sits between them, and it decides the rule (issue 03 §2.3, Alex,
|
|
23
|
+
* 2026-09-08): the generator declares `KEYSTORE_BASE64` globally but emits its
|
|
24
|
+
* `file@` step in all three workflows, so a project that ran `ci init` with a
|
|
25
|
+
* keystore and never uploaded one would go from three green skips to three
|
|
26
|
+
* hard failures — including on `pull-request`, where nothing signs anything.
|
|
27
|
+
* So the question the step asks is the one that actually matters: **will the
|
|
28
|
+
* file be there when the build reads it?** A repository that carries the file
|
|
29
|
+
* keeps warning and building; only an absence with nothing behind it fails.
|
|
30
|
+
*/
|
|
31
|
+
import { describe, test, expect, beforeEach, afterEach } from '@jest/globals';
|
|
32
|
+
import { spawnSync } from 'node:child_process';
|
|
33
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync, } from 'node:fs';
|
|
34
|
+
import { tmpdir } from 'node:os';
|
|
35
|
+
import { dirname, join } from 'node:path';
|
|
36
|
+
import { convertYAMLToPipelineDef } from './converter.js';
|
|
37
|
+
import { StepValidator } from './step-validator.js';
|
|
38
|
+
import { clearRegistry, registerStep } from './steps/registry.js';
|
|
39
|
+
import { FileStepExecutor } from './steps/file.js';
|
|
40
|
+
const mockConfig = {
|
|
41
|
+
artifactsDir: '/test/artifacts',
|
|
42
|
+
maxConcurrentJobs: 1,
|
|
43
|
+
paths: {
|
|
44
|
+
buildsDir: '/test/builds',
|
|
45
|
+
cacheDir: '/test/cache',
|
|
46
|
+
derivedDataDir: '/test/derived-data',
|
|
47
|
+
},
|
|
48
|
+
interpreters: {
|
|
49
|
+
bash: '/bin/bash',
|
|
50
|
+
python: 'python3',
|
|
51
|
+
ruby: 'ruby',
|
|
52
|
+
node: 'node',
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
/** The 61-byte document from the PRD's reproduction. Synthetic. */
|
|
56
|
+
const JSON_CREDENTIAL = '{"project_info":{"project_id":"synthetic-probe"},"client":[]}';
|
|
57
|
+
const TARGET = 'app/google-services.json';
|
|
58
|
+
let workdir;
|
|
59
|
+
let previousCwd;
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
clearRegistry();
|
|
62
|
+
registerStep('file', new FileStepExecutor());
|
|
63
|
+
previousCwd = process.cwd();
|
|
64
|
+
workdir = mkdtempSync(join(tmpdir(), 'cibuild-missing-credential-'));
|
|
65
|
+
process.chdir(workdir);
|
|
66
|
+
// `workdir` is the pre-symlink path on macOS; the step resolves paths from
|
|
67
|
+
// CIBUILD_SOURCE_DIR, which is cwd, so read and write files the same way.
|
|
68
|
+
workdir = process.cwd();
|
|
69
|
+
delete process.env.GOOGLE_SERVICES_JSON;
|
|
70
|
+
delete process.env.KEYSTORE_BASE64;
|
|
71
|
+
delete process.env.OTHER_SECRET;
|
|
72
|
+
});
|
|
73
|
+
afterEach(() => {
|
|
74
|
+
process.chdir(previousCwd);
|
|
75
|
+
rmSync(workdir, { recursive: true, force: true });
|
|
76
|
+
clearRegistry();
|
|
77
|
+
delete process.env.GOOGLE_SERVICES_JSON;
|
|
78
|
+
delete process.env.KEYSTORE_BASE64;
|
|
79
|
+
delete process.env.OTHER_SECRET;
|
|
80
|
+
});
|
|
81
|
+
/**
|
|
82
|
+
* The shape the Android generator emits: empty slots under `app.envs` and one
|
|
83
|
+
* skippable `file@1.0.0` step per credential, in declaration order.
|
|
84
|
+
*/
|
|
85
|
+
function probePipeline(declared, fileSteps, options = {}) {
|
|
86
|
+
const skippable = options.skippable ?? true;
|
|
87
|
+
const app = declared
|
|
88
|
+
? { app: { envs: Object.entries(declared).map(([k, v]) => ({ [k]: v })) } }
|
|
89
|
+
: {};
|
|
90
|
+
return {
|
|
91
|
+
format_version: '1',
|
|
92
|
+
...app,
|
|
93
|
+
workflows: {
|
|
94
|
+
probe: {
|
|
95
|
+
steps: fileSteps.map((inputs) => ({
|
|
96
|
+
'file@1.0.0': {
|
|
97
|
+
title: `Setup ${String(inputs.var_name)}`,
|
|
98
|
+
is_skippable: skippable,
|
|
99
|
+
inputs,
|
|
100
|
+
},
|
|
101
|
+
})),
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
function writeSecretStore(global) {
|
|
107
|
+
writeFileSync(join(workdir, '.cibuild-secrets.json'), JSON.stringify({ global, workflows: {} }), { mode: 0o600 });
|
|
108
|
+
}
|
|
109
|
+
/** Puts a file in the checkout, the way a repository that carries one does. */
|
|
110
|
+
function commitToCheckout(relPath, contents) {
|
|
111
|
+
const target = join(workdir, relPath);
|
|
112
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
113
|
+
writeFileSync(target, contents, 'utf-8');
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Converts the pipeline and runs the generated steps the way the runner does,
|
|
117
|
+
* then reads back whatever landed at `target`.
|
|
118
|
+
*/
|
|
119
|
+
async function land(pipeline, target = TARGET) {
|
|
120
|
+
const result = await convertYAMLToPipelineDef(pipeline, mockConfig, 'probe');
|
|
121
|
+
const chunks = [...result.warnings];
|
|
122
|
+
for (const step of result.pipeline.steps) {
|
|
123
|
+
const scriptPath = join(workdir, `__step_${step.id}.sh`);
|
|
124
|
+
writeFileSync(scriptPath, step.script, 'utf-8');
|
|
125
|
+
const proc = spawnSync('bash', [scriptPath], {
|
|
126
|
+
cwd: workdir,
|
|
127
|
+
encoding: 'utf-8',
|
|
128
|
+
env: { ...process.env },
|
|
129
|
+
});
|
|
130
|
+
expect(proc.status).toBe(0);
|
|
131
|
+
chunks.push(proc.stdout ?? '', proc.stderr ?? '');
|
|
132
|
+
}
|
|
133
|
+
const landedAt = join(workdir, target);
|
|
134
|
+
return {
|
|
135
|
+
output: chunks.join('\n'),
|
|
136
|
+
bytes: existsSync(landedAt) ? readFileSync(landedAt) : undefined,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
/** The conversion's outcome, without letting a throw escape the assertion. */
|
|
140
|
+
async function convert(pipeline) {
|
|
141
|
+
try {
|
|
142
|
+
const result = await convertYAMLToPipelineDef(pipeline, mockConfig, 'probe');
|
|
143
|
+
return { steps: result.pipeline.steps };
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
return { error: error };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
describe('a declared credential nothing supplied', () => {
|
|
150
|
+
test('fails, naming the key and the target path', async () => {
|
|
151
|
+
const { error, steps } = await convert(probePipeline({ GOOGLE_SERVICES_JSON: '' }, [
|
|
152
|
+
{ target_path: TARGET, var_name: 'GOOGLE_SERVICES_JSON' },
|
|
153
|
+
]));
|
|
154
|
+
expect(error).toBeDefined();
|
|
155
|
+
expect(error.message).toContain('GOOGLE_SERVICES_JSON');
|
|
156
|
+
expect(error.message).toContain(TARGET);
|
|
157
|
+
// Not a stack trace and not a step that runs: conversion is what builds
|
|
158
|
+
// the step list, so nothing at all is handed to the runner.
|
|
159
|
+
expect(steps).toBeUndefined();
|
|
160
|
+
});
|
|
161
|
+
test('says both places the value can come from', async () => {
|
|
162
|
+
const { error } = await convert(probePipeline({ GOOGLE_SERVICES_JSON: '' }, [
|
|
163
|
+
{ target_path: TARGET, var_name: 'GOOGLE_SERVICES_JSON' },
|
|
164
|
+
]));
|
|
165
|
+
// A user reading this should not have to know that a slot, an upload and
|
|
166
|
+
// a `file@` step are three separate things.
|
|
167
|
+
expect(error.message).toContain('ci secrets add GOOGLE_SERVICES_JSON');
|
|
168
|
+
expect(error.message).toMatch(/organization or pipeline secret/);
|
|
169
|
+
});
|
|
170
|
+
test('is_skippable does not turn it into a pass', async () => {
|
|
171
|
+
// Every `file@` step the Android generator emits carries this, which is
|
|
172
|
+
// the whole reason the defect was invisible.
|
|
173
|
+
const skippable = await convert(probePipeline({ GOOGLE_SERVICES_JSON: '' }, [{ target_path: TARGET, var_name: 'GOOGLE_SERVICES_JSON' }], { skippable: true }));
|
|
174
|
+
const notSkippable = await convert(probePipeline({ GOOGLE_SERVICES_JSON: '' }, [{ target_path: TARGET, var_name: 'GOOGLE_SERVICES_JSON' }], { skippable: false }));
|
|
175
|
+
expect(skippable.error).toBeDefined();
|
|
176
|
+
expect(notSkippable.error).toBeDefined();
|
|
177
|
+
});
|
|
178
|
+
test('fails on the first unfilled slot, not the last and not all three', async () => {
|
|
179
|
+
// What a real Android pipeline carries: three slots, none filled.
|
|
180
|
+
const { error } = await convert(probePipeline({
|
|
181
|
+
KEYSTORE_BASE64: '',
|
|
182
|
+
KEYSTORE_PROPERTIES: '',
|
|
183
|
+
GOOGLE_SERVICES_JSON: '',
|
|
184
|
+
}, [
|
|
185
|
+
{
|
|
186
|
+
target_path: 'release.keystore',
|
|
187
|
+
var_name: 'KEYSTORE_BASE64',
|
|
188
|
+
base64_encoded: true,
|
|
189
|
+
},
|
|
190
|
+
{ target_path: 'keystore.properties', var_name: 'KEYSTORE_PROPERTIES' },
|
|
191
|
+
{ target_path: TARGET, var_name: 'GOOGLE_SERVICES_JSON' },
|
|
192
|
+
]));
|
|
193
|
+
expect(error).toBeDefined();
|
|
194
|
+
expect(error.message).toContain('KEYSTORE_BASE64');
|
|
195
|
+
expect(error.message).not.toContain('KEYSTORE_PROPERTIES');
|
|
196
|
+
expect(error.message).not.toContain('GOOGLE_SERVICES_JSON');
|
|
197
|
+
});
|
|
198
|
+
test('no secret value appears in the message', async () => {
|
|
199
|
+
// The failing slot has no value by definition, so the assertion that
|
|
200
|
+
// means anything is about the values the build *does* hold.
|
|
201
|
+
process.env.OTHER_SECRET = JSON_CREDENTIAL;
|
|
202
|
+
const { error } = await convert(probePipeline({ OTHER_SECRET: '', GOOGLE_SERVICES_JSON: '' }, [
|
|
203
|
+
{ target_path: TARGET, var_name: 'GOOGLE_SERVICES_JSON' },
|
|
204
|
+
]));
|
|
205
|
+
expect(error).toBeDefined();
|
|
206
|
+
expect(error.message).not.toContain(JSON_CREDENTIAL);
|
|
207
|
+
expect(error.message).not.toContain('synthetic-probe');
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
describe('a credential the repository already carries', () => {
|
|
211
|
+
test('warns and proceeds — the file will be there when the build reads it', async () => {
|
|
212
|
+
// The `pull-request` collision: the generator declares KEYSTORE_BASE64
|
|
213
|
+
// globally and emits its step on every workflow, so a project whose
|
|
214
|
+
// keystore is in the repository must not be refused a build over a value
|
|
215
|
+
// it never needed.
|
|
216
|
+
commitToCheckout('release.keystore', 'a keystore the repository carries');
|
|
217
|
+
const { output } = await land(probePipeline({ KEYSTORE_BASE64: '' }, [
|
|
218
|
+
{
|
|
219
|
+
target_path: 'release.keystore',
|
|
220
|
+
var_name: 'KEYSTORE_BASE64',
|
|
221
|
+
base64_encoded: true,
|
|
222
|
+
},
|
|
223
|
+
]), 'release.keystore');
|
|
224
|
+
expect(output).toContain('KEYSTORE_BASE64');
|
|
225
|
+
expect(output).toContain('release.keystore');
|
|
226
|
+
// The escape hatch has to be visible in the log, or a build that passes
|
|
227
|
+
// this way is unexplainable to whoever reads it later.
|
|
228
|
+
expect(output).toMatch(/already in the checkout/);
|
|
229
|
+
});
|
|
230
|
+
test('leaves the file it found exactly as it was', async () => {
|
|
231
|
+
const carried = 'the repository\'s own google-services.json';
|
|
232
|
+
commitToCheckout(TARGET, carried);
|
|
233
|
+
const { bytes } = await land(probePipeline({ GOOGLE_SERVICES_JSON: '' }, [
|
|
234
|
+
{ target_path: TARGET, var_name: 'GOOGLE_SERVICES_JSON' },
|
|
235
|
+
]));
|
|
236
|
+
expect(bytes?.toString('utf-8')).toBe(carried);
|
|
237
|
+
});
|
|
238
|
+
test('an uploaded value still wins over the one in the checkout', async () => {
|
|
239
|
+
// A repository carrying a placeholder is the ordinary case for
|
|
240
|
+
// google-services.json, and the point of the upload is to replace it.
|
|
241
|
+
commitToCheckout(TARGET, 'placeholder committed for local development');
|
|
242
|
+
process.env.GOOGLE_SERVICES_JSON = JSON_CREDENTIAL;
|
|
243
|
+
const { output, bytes } = await land(probePipeline({ GOOGLE_SERVICES_JSON: '' }, [
|
|
244
|
+
{ target_path: TARGET, var_name: 'GOOGLE_SERVICES_JSON' },
|
|
245
|
+
]));
|
|
246
|
+
expect(output).toContain('✓ Written google-services.json');
|
|
247
|
+
expect(bytes?.toString('utf-8')).toBe(JSON_CREDENTIAL);
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
describe('what issue 03 must not change', () => {
|
|
251
|
+
test('an undeclared key still warns and passes, is_skippable and all', async () => {
|
|
252
|
+
// Issue 01's class-A case, and the one this change is most likely to
|
|
253
|
+
// break: HOME exists in every environment this runs in, and nothing
|
|
254
|
+
// declares it.
|
|
255
|
+
expect(process.env.HOME).toBeTruthy();
|
|
256
|
+
const { output, bytes } = await land(probePipeline({ GOOGLE_SERVICES_JSON: '' }, [
|
|
257
|
+
{ target_path: 'home.txt', var_name: 'HOME' },
|
|
258
|
+
]), 'home.txt');
|
|
259
|
+
expect(output).toContain('HOME not set — skipping home.txt');
|
|
260
|
+
expect(bytes).toBeUndefined();
|
|
261
|
+
});
|
|
262
|
+
test('a declared value from the local store still writes', async () => {
|
|
263
|
+
writeSecretStore({ GOOGLE_SERVICES_JSON: JSON_CREDENTIAL });
|
|
264
|
+
const { output, bytes } = await land(probePipeline({ GOOGLE_SERVICES_JSON: '' }, [
|
|
265
|
+
{ target_path: TARGET, var_name: 'GOOGLE_SERVICES_JSON' },
|
|
266
|
+
]));
|
|
267
|
+
expect(output).toContain('✓ Written google-services.json');
|
|
268
|
+
expect(bytes?.toString('utf-8')).toBe(JSON_CREDENTIAL);
|
|
269
|
+
});
|
|
270
|
+
test('a declared value from the dispatched environment still writes', async () => {
|
|
271
|
+
process.env.GOOGLE_SERVICES_JSON = JSON_CREDENTIAL;
|
|
272
|
+
const { output, bytes } = await land(probePipeline({ GOOGLE_SERVICES_JSON: '' }, [
|
|
273
|
+
{ target_path: TARGET, var_name: 'GOOGLE_SERVICES_JSON' },
|
|
274
|
+
]));
|
|
275
|
+
expect(output).toContain('✓ Written google-services.json');
|
|
276
|
+
expect(bytes?.toString('utf-8')).toBe(JSON_CREDENTIAL);
|
|
277
|
+
});
|
|
278
|
+
test('a value the YAML itself supplies still writes', async () => {
|
|
279
|
+
const { output, bytes } = await land(probePipeline({ GOOGLE_SERVICES_JSON: JSON_CREDENTIAL }, [
|
|
280
|
+
{ target_path: TARGET, var_name: 'GOOGLE_SERVICES_JSON' },
|
|
281
|
+
]));
|
|
282
|
+
expect(output).toContain('✓ Written google-services.json');
|
|
283
|
+
expect(bytes?.toString('utf-8')).toBe(JSON_CREDENTIAL);
|
|
284
|
+
});
|
|
285
|
+
test('ci validate still passes on a pipeline with empty slots', async () => {
|
|
286
|
+
// Option 3 was rejected so validation does not start refusing builds
|
|
287
|
+
// that are green today. This is the line that holds it.
|
|
288
|
+
const pipeline = probePipeline({ KEYSTORE_BASE64: '', GOOGLE_SERVICES_JSON: '' }, [
|
|
289
|
+
{
|
|
290
|
+
target_path: 'release.keystore',
|
|
291
|
+
var_name: 'KEYSTORE_BASE64',
|
|
292
|
+
base64_encoded: true,
|
|
293
|
+
},
|
|
294
|
+
{ target_path: TARGET, var_name: 'GOOGLE_SERVICES_JSON' },
|
|
295
|
+
]);
|
|
296
|
+
const validator = new StepValidator(pipeline, 'probe', mockConfig);
|
|
297
|
+
const result = await validator.validateWorkflow();
|
|
298
|
+
expect(result.valid).toBe(true);
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
//# sourceMappingURL=a-missing-credential-is-loud.test.js.map
|
|
@@ -30,6 +30,11 @@ export declare class EnvResolver {
|
|
|
30
30
|
private envVars;
|
|
31
31
|
private referencedVars;
|
|
32
32
|
private secretsManager;
|
|
33
|
+
/**
|
|
34
|
+
* Every key the pipeline names in `app.envs` or `workflow.envs`, whether or
|
|
35
|
+
* not it gave one a value. Read by `importDeclaredFromProcessEnv`.
|
|
36
|
+
*/
|
|
37
|
+
private declaredVars;
|
|
33
38
|
constructor(pipeline: YAMLPipeline, workflow: YAMLWorkflow, workflowName: string, platform: Platform, stack?: string, yamlFilePath?: string);
|
|
34
39
|
/**
|
|
35
40
|
* Gets the value of an environment variable
|
|
@@ -107,6 +112,34 @@ export declare class EnvResolver {
|
|
|
107
112
|
* @param envArray Array of environment variable objects
|
|
108
113
|
*/
|
|
109
114
|
private mergeEnvVars;
|
|
115
|
+
/**
|
|
116
|
+
* Fills a declared-but-still-empty variable from the process environment.
|
|
117
|
+
*
|
|
118
|
+
* This is how a credential dispatched by a build service arrives. The
|
|
119
|
+
* dispatch hands the value to the runner, the runner merges it into the
|
|
120
|
+
* environment of the `ci` invocation, and nothing else copies it anywhere —
|
|
121
|
+
* it writes no `.cibuild-secrets.json`, and that file is gitignored so the
|
|
122
|
+
* clone has none either. Without this step the value sits in the shell and
|
|
123
|
+
* is invisible to the steps that read this map: `file@1.0.0` reads
|
|
124
|
+
* `env[var_name]`, finds nothing, prints "not set — skipping" and reports
|
|
125
|
+
* success. Every credential a pipeline declared as an empty slot was
|
|
126
|
+
* dropped that way, on every dispatched build, with a green step.
|
|
127
|
+
*
|
|
128
|
+
* Scoped to keys the pipeline declares, deliberately. Copying `process.env`
|
|
129
|
+
* wholesale would turn arbitrary host environment into pipeline values and
|
|
130
|
+
* would change what every existing pipeline resolves. The declaration is
|
|
131
|
+
* the opt-in: `- GOOGLE_SERVICES_JSON: ""` is the pipeline saying "I read
|
|
132
|
+
* this key and I have no value for it", which is exactly the invitation the
|
|
133
|
+
* dispatched environment needs. A key nothing declares stays out, so `HOME`
|
|
134
|
+
* and the rest of the host's environment are not pipeline variables.
|
|
135
|
+
*
|
|
136
|
+
* Last, so anything an earlier source did supply wins — a developer's
|
|
137
|
+
* `.cibuild-secrets.json` still beats an inherited environment. The value
|
|
138
|
+
* is taken verbatim, with no interpolation, for the same reason step 2
|
|
139
|
+
* takes secrets verbatim: a credential is bytes, and a `$` inside one is
|
|
140
|
+
* not a variable reference.
|
|
141
|
+
*/
|
|
142
|
+
private importDeclaredFromProcessEnv;
|
|
110
143
|
/**
|
|
111
144
|
* Resolves $SECRET{secret_id} references to actual secret values
|
|
112
145
|
* @param value Value that may contain a secret reference
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"env-resolver.d.ts","sourceRoot":"","sources":["../../../src/yaml/env-resolver.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAMH,OAAO,KAAK,EAAE,YAAY,EAAE,YAAY,EAAc,QAAQ,EAAE,MAAM,YAAY,CAAC;AAiEnF,qBAAa,+BAAgC,SAAQ,KAAK;aAEtC,YAAY,EAAE,MAAM;aACpB,QAAQ,CAAC,EAAE,MAAM;aACjB,IAAI,CAAC,EAAE,MAAM;gBAFb,YAAY,EAAE,MAAM,EACpB,QAAQ,CAAC,EAAE,MAAM,YAAA,EACjB,IAAI,CAAC,EAAE,MAAM,YAAA;CAUhC;AAED;;;;;;;GAOG;AACH,qBAAa,wBAAyB,SAAQ,KAAK;aAE/B,QAAQ,EAAE,MAAM;aAChB,QAAQ,CAAC,EAAE,MAAM;gBADjB,QAAQ,EAAE,MAAM,EAChB,QAAQ,CAAC,EAAE,MAAM,YAAA;CAKpC;AAED;;GAEG;AACH,qBAAa,WAAW;IACtB,OAAO,CAAC,OAAO,CAAsB;IACrC,OAAO,CAAC,cAAc,CAA0B;IAChD,OAAO,CAAC,cAAc,CAAiB;
|
|
1
|
+
{"version":3,"file":"env-resolver.d.ts","sourceRoot":"","sources":["../../../src/yaml/env-resolver.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAMH,OAAO,KAAK,EAAE,YAAY,EAAE,YAAY,EAAc,QAAQ,EAAE,MAAM,YAAY,CAAC;AAiEnF,qBAAa,+BAAgC,SAAQ,KAAK;aAEtC,YAAY,EAAE,MAAM;aACpB,QAAQ,CAAC,EAAE,MAAM;aACjB,IAAI,CAAC,EAAE,MAAM;gBAFb,YAAY,EAAE,MAAM,EACpB,QAAQ,CAAC,EAAE,MAAM,YAAA,EACjB,IAAI,CAAC,EAAE,MAAM,YAAA;CAUhC;AAED;;;;;;;GAOG;AACH,qBAAa,wBAAyB,SAAQ,KAAK;aAE/B,QAAQ,EAAE,MAAM;aAChB,QAAQ,CAAC,EAAE,MAAM;gBADjB,QAAQ,EAAE,MAAM,EAChB,QAAQ,CAAC,EAAE,MAAM,YAAA;CAKpC;AAED;;GAEG;AACH,qBAAa,WAAW;IACtB,OAAO,CAAC,OAAO,CAAsB;IACrC,OAAO,CAAC,cAAc,CAA0B;IAChD,OAAO,CAAC,cAAc,CAAiB;IACvC;;;OAGG;IACH,OAAO,CAAC,YAAY,CAA0B;gBAG5C,QAAQ,EAAE,YAAY,EACtB,QAAQ,EAAE,YAAY,EACtB,YAAY,EAAE,MAAM,EACpB,QAAQ,EAAE,QAAQ,EAClB,KAAK,CAAC,EAAE,MAAM,EACd,YAAY,CAAC,EAAE,MAAM;IAkCvB;;;;OAIG;IACH,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS;IAKrC;;;;OAIG;IACH,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO;IAI1B;;;;OAIG;IACH,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAMtC;;;OAGG;IACH,MAAM,IAAI,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC;IAQhC;;;;;;;;;;;;OAYG;IACH,WAAW,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,MAAM;IAqCnD;;;;;OAKG;IACH,iBAAiB,CAAC,GAAG,EAAE,GAAG,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,GAAG;IAoBnD;;;OAGG;IACH,sBAAsB,IAAI,IAAI;IAQ9B;;;;;;OAMG;IACH,OAAO,CAAC,UAAU;IAalB;;;;;OAKG;IACH,OAAO,CAAC,eAAe;IA0BvB;;;;;OAKG;IACH,OAAO,CAAC,cAAc;IA4FtB;;;OAGG;IACH,OAAO,CAAC,YAAY;IAoBpB;;;;;;;;;;;;;;;;;;;;;;;;;;OA0BG;IACH,OAAO,CAAC,4BAA4B;IAUpC;;;;OAIG;IACH,OAAO,CAAC,sBAAsB;IAuB9B;;;;;OAKG;IACH,OAAO,CAAC,mBAAmB;CAS5B;AAED;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAC/B,QAAQ,EAAE,YAAY,EACtB,YAAY,EAAE,MAAM,EACpB,QAAQ,EAAE,QAAQ,EAClB,KAAK,CAAC,EAAE,MAAM,GACb,WAAW,CAOb"}
|