@invarn/cibuild 2.7.1 → 2.7.2
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/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/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 +156 -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 +111 -0
- package/dist/src/yaml/steps/xcode.d.ts.map +1 -1
- package/dist/src/yaml/steps/xcode.js +28 -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
|
|
@@ -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"}
|
|
@@ -102,6 +102,11 @@ export class EnvResolver {
|
|
|
102
102
|
envVars;
|
|
103
103
|
referencedVars = new Set();
|
|
104
104
|
secretsManager;
|
|
105
|
+
/**
|
|
106
|
+
* Every key the pipeline names in `app.envs` or `workflow.envs`, whether or
|
|
107
|
+
* not it gave one a value. Read by `importDeclaredFromProcessEnv`.
|
|
108
|
+
*/
|
|
109
|
+
declaredVars = new Set();
|
|
105
110
|
constructor(pipeline, workflow, workflowName, platform, stack, yamlFilePath) {
|
|
106
111
|
this.envVars = new Map();
|
|
107
112
|
this.secretsManager = new SecretsManager();
|
|
@@ -113,14 +118,21 @@ export class EnvResolver {
|
|
|
113
118
|
for (const [key, value] of Object.entries(allSecrets)) {
|
|
114
119
|
this.envVars.set(key, value);
|
|
115
120
|
}
|
|
116
|
-
// Step 3: Add global environment variables (app.envs) — overrides secrets
|
|
121
|
+
// Step 3: Add global environment variables (app.envs) — overrides secrets,
|
|
122
|
+
// except with an empty placeholder: `mergeEnvVars` refuses to write `""`
|
|
123
|
+
// over a value that is already there, so `- KEYSTORE_BASE64: ""` declares
|
|
124
|
+
// the key without erasing a loaded secret of the same name.
|
|
117
125
|
if (pipeline.app?.envs) {
|
|
118
126
|
this.mergeEnvVars(pipeline.app.envs);
|
|
119
127
|
}
|
|
120
|
-
// Step 4: Add workflow-level environment variables — highest priority
|
|
128
|
+
// Step 4: Add workflow-level environment variables — highest priority over
|
|
129
|
+
// steps 1-3, with the same empty-placeholder exception as step 3
|
|
121
130
|
if (workflow.envs) {
|
|
122
131
|
this.mergeEnvVars(workflow.envs);
|
|
123
132
|
}
|
|
133
|
+
// Step 5: Last resort for a declared key that is still empty — the
|
|
134
|
+
// process environment. See importDeclaredFromProcessEnv.
|
|
135
|
+
this.importDeclaredFromProcessEnv();
|
|
124
136
|
}
|
|
125
137
|
/**
|
|
126
138
|
* Gets the value of an environment variable
|
|
@@ -369,6 +381,8 @@ export class EnvResolver {
|
|
|
369
381
|
mergeEnvVars(envArray) {
|
|
370
382
|
for (const envObj of envArray) {
|
|
371
383
|
for (const [key, value] of Object.entries(envObj)) {
|
|
384
|
+
// Naming the key is the declaration, whatever value follows it
|
|
385
|
+
this.declaredVars.add(key);
|
|
372
386
|
// First resolve any secret references
|
|
373
387
|
const secretResolved = this.resolveSecretReference(value);
|
|
374
388
|
// Then interpolate in case it references other env vars
|
|
@@ -383,6 +397,44 @@ export class EnvResolver {
|
|
|
383
397
|
}
|
|
384
398
|
}
|
|
385
399
|
}
|
|
400
|
+
/**
|
|
401
|
+
* Fills a declared-but-still-empty variable from the process environment.
|
|
402
|
+
*
|
|
403
|
+
* This is how a credential dispatched by a build service arrives. The
|
|
404
|
+
* dispatch hands the value to the runner, the runner merges it into the
|
|
405
|
+
* environment of the `ci` invocation, and nothing else copies it anywhere —
|
|
406
|
+
* it writes no `.cibuild-secrets.json`, and that file is gitignored so the
|
|
407
|
+
* clone has none either. Without this step the value sits in the shell and
|
|
408
|
+
* is invisible to the steps that read this map: `file@1.0.0` reads
|
|
409
|
+
* `env[var_name]`, finds nothing, prints "not set — skipping" and reports
|
|
410
|
+
* success. Every credential a pipeline declared as an empty slot was
|
|
411
|
+
* dropped that way, on every dispatched build, with a green step.
|
|
412
|
+
*
|
|
413
|
+
* Scoped to keys the pipeline declares, deliberately. Copying `process.env`
|
|
414
|
+
* wholesale would turn arbitrary host environment into pipeline values and
|
|
415
|
+
* would change what every existing pipeline resolves. The declaration is
|
|
416
|
+
* the opt-in: `- GOOGLE_SERVICES_JSON: ""` is the pipeline saying "I read
|
|
417
|
+
* this key and I have no value for it", which is exactly the invitation the
|
|
418
|
+
* dispatched environment needs. A key nothing declares stays out, so `HOME`
|
|
419
|
+
* and the rest of the host's environment are not pipeline variables.
|
|
420
|
+
*
|
|
421
|
+
* Last, so anything an earlier source did supply wins — a developer's
|
|
422
|
+
* `.cibuild-secrets.json` still beats an inherited environment. The value
|
|
423
|
+
* is taken verbatim, with no interpolation, for the same reason step 2
|
|
424
|
+
* takes secrets verbatim: a credential is bytes, and a `$` inside one is
|
|
425
|
+
* not a variable reference.
|
|
426
|
+
*/
|
|
427
|
+
importDeclaredFromProcessEnv() {
|
|
428
|
+
for (const key of this.declaredVars) {
|
|
429
|
+
// Any non-empty value from an earlier source stays. Only empty is open.
|
|
430
|
+
if (this.envVars.get(key))
|
|
431
|
+
continue;
|
|
432
|
+
const fromProcess = process.env[key];
|
|
433
|
+
if (!fromProcess)
|
|
434
|
+
continue;
|
|
435
|
+
this.envVars.set(key, fromProcess);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
386
438
|
/**
|
|
387
439
|
* Resolves $SECRET{secret_id} references to actual secret values
|
|
388
440
|
* @param value Value that may contain a secret reference
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Executes the generated shell rather than grepping it.
|
|
3
|
+
*
|
|
4
|
+
* The retry lives entirely in emitted bash, so string assertions prove only
|
|
5
|
+
* that the text was written. These run it: a first-attempt refusal followed by
|
|
6
|
+
* a second-attempt success has to actually pass, and a non-refusal has to fail
|
|
7
|
+
* without burning the retry budget.
|
|
8
|
+
*/
|
|
9
|
+
export {};
|
|
10
|
+
//# sourceMappingURL=git-fetch-retry-exec.test.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"git-fetch-retry-exec.test.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/git-fetch-retry-exec.test.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG"}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Executes the generated shell rather than grepping it.
|
|
3
|
+
*
|
|
4
|
+
* The retry lives entirely in emitted bash, so string assertions prove only
|
|
5
|
+
* that the text was written. These run it: a first-attempt refusal followed by
|
|
6
|
+
* a second-attempt success has to actually pass, and a non-refusal has to fail
|
|
7
|
+
* without burning the retry budget.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, test, expect } from '@jest/globals';
|
|
10
|
+
import { execFileSync } from 'child_process';
|
|
11
|
+
import { mkdtempSync, writeFileSync, rmSync, readFileSync, existsSync } from 'fs';
|
|
12
|
+
import { join } from 'path';
|
|
13
|
+
import { tmpdir } from 'os';
|
|
14
|
+
import { refusedFetchHelpers, retryOnRefusedFetch } from './git-fetch-retry.js';
|
|
15
|
+
const REFUSAL = "fatal: could not read Username for 'https://github.com': terminal prompts disabled";
|
|
16
|
+
/** Wraps the emitted block in the same preamble the real step scripts use. */
|
|
17
|
+
function buildScript(command, attempts = 3) {
|
|
18
|
+
return [
|
|
19
|
+
'#!/bin/bash',
|
|
20
|
+
'set -e',
|
|
21
|
+
'set -o pipefail',
|
|
22
|
+
'',
|
|
23
|
+
...refusedFetchHelpers(),
|
|
24
|
+
...retryOnRefusedFetch({
|
|
25
|
+
label: 'Package resolution',
|
|
26
|
+
command,
|
|
27
|
+
attempts,
|
|
28
|
+
backoffSeconds: [0, 0, 0, 0],
|
|
29
|
+
}),
|
|
30
|
+
'echo "REACHED_THE_END"',
|
|
31
|
+
].join('\n');
|
|
32
|
+
}
|
|
33
|
+
function run(command, attempts = 3) {
|
|
34
|
+
const dir = mkdtempSync(join(tmpdir(), 'cibuild-fetch-retry-'));
|
|
35
|
+
const scriptPath = join(dir, 'step.sh');
|
|
36
|
+
writeFileSync(scriptPath, buildScript(command, attempts), { mode: 0o755 });
|
|
37
|
+
try {
|
|
38
|
+
const output = execFileSync('bash', [scriptPath], {
|
|
39
|
+
encoding: 'utf-8',
|
|
40
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
41
|
+
cwd: dir,
|
|
42
|
+
env: { ...process.env, COUNTER: join(dir, 'attempts') },
|
|
43
|
+
});
|
|
44
|
+
return { status: 0, output };
|
|
45
|
+
}
|
|
46
|
+
catch (e) {
|
|
47
|
+
return {
|
|
48
|
+
status: e.status ?? -1,
|
|
49
|
+
output: `${e.stdout ?? ''}${e.stderr ?? ''}`,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
rmSync(dir, { recursive: true, force: true });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* A command that records each invocation and refuses the first `n` of them the
|
|
58
|
+
* way git does, then succeeds.
|
|
59
|
+
*/
|
|
60
|
+
function refuseFirst(n) {
|
|
61
|
+
return (`bash -c 'echo x >> "$COUNTER"; ` +
|
|
62
|
+
`if [ "$(wc -l < "$COUNTER")" -le ${n} ]; then echo "${REFUSAL}" >&2; exit 128; fi; ` +
|
|
63
|
+
`echo RESOLVED_OK'`);
|
|
64
|
+
}
|
|
65
|
+
describe('retryOnRefusedFetch, executed', () => {
|
|
66
|
+
test('a first-attempt refusal and a second-attempt success still passes', () => {
|
|
67
|
+
const r = run(refuseFirst(1));
|
|
68
|
+
expect(r.status).toBe(0);
|
|
69
|
+
expect(r.output).toContain('RESOLVED_OK');
|
|
70
|
+
expect(r.output).toContain('REACHED_THE_END');
|
|
71
|
+
expect(r.output).toContain('was refused by the server (attempt 1 of 3)');
|
|
72
|
+
});
|
|
73
|
+
test('a command that succeeds first time is run exactly once', () => {
|
|
74
|
+
const r = run(refuseFirst(0));
|
|
75
|
+
expect(r.status).toBe(0);
|
|
76
|
+
expect(r.output).toContain('RESOLVED_OK');
|
|
77
|
+
expect(r.output).not.toContain('was refused by the server');
|
|
78
|
+
});
|
|
79
|
+
test('a refusal that never clears fails with the original exit code', () => {
|
|
80
|
+
const r = run(refuseFirst(99));
|
|
81
|
+
expect(r.status).toBe(128);
|
|
82
|
+
expect(r.output).not.toContain('REACHED_THE_END');
|
|
83
|
+
// and it explains itself rather than blaming a package
|
|
84
|
+
expect(r.output).toContain('A dependency fetch was refused.');
|
|
85
|
+
expect(r.output).toContain('HTTP 401');
|
|
86
|
+
});
|
|
87
|
+
test('it gives up after exactly the configured number of attempts', () => {
|
|
88
|
+
const r = run(refuseFirst(99), 3);
|
|
89
|
+
expect(r.output).toContain('(attempt 1 of 3)');
|
|
90
|
+
expect(r.output).toContain('(attempt 2 of 3)');
|
|
91
|
+
// the third attempt runs but is not announced as a retry
|
|
92
|
+
expect(r.output).not.toContain('(attempt 3 of 3)');
|
|
93
|
+
});
|
|
94
|
+
test('a failure that is NOT a refused fetch fails immediately', () => {
|
|
95
|
+
const r = run(`bash -c 'echo x >> "$COUNTER"; echo "error: no such module" >&2; exit 65'`);
|
|
96
|
+
expect(r.status).toBe(65);
|
|
97
|
+
// no retry, and no misleading refused-fetch diagnostic
|
|
98
|
+
expect(r.output).not.toContain('was refused by the server');
|
|
99
|
+
expect(r.output).not.toContain('A dependency fetch was refused.');
|
|
100
|
+
});
|
|
101
|
+
test('a non-refusal failure is attempted once, not three times', () => {
|
|
102
|
+
const dir = mkdtempSync(join(tmpdir(), 'cibuild-fetch-count-'));
|
|
103
|
+
const counter = join(dir, 'attempts');
|
|
104
|
+
const scriptPath = join(dir, 'step.sh');
|
|
105
|
+
writeFileSync(scriptPath, buildScript(`bash -c 'echo x >> "${counter}"; exit 65'`), { mode: 0o755 });
|
|
106
|
+
try {
|
|
107
|
+
execFileSync('bash', [scriptPath], { stdio: 'ignore', cwd: dir });
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
/* expected */
|
|
111
|
+
}
|
|
112
|
+
const attempts = existsSync(counter)
|
|
113
|
+
? readFileSync(counter, 'utf-8').trim().split('\n').length
|
|
114
|
+
: 0;
|
|
115
|
+
expect(attempts).toBe(1);
|
|
116
|
+
rmSync(dir, { recursive: true, force: true });
|
|
117
|
+
});
|
|
118
|
+
test('the command output still reaches the log while being captured', () => {
|
|
119
|
+
const r = run(`bash -c 'echo x >> "$COUNTER"; echo VISIBLE_TO_THE_USER'`);
|
|
120
|
+
expect(r.output).toContain('VISIBLE_TO_THE_USER');
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
describe('the steps that embed it emit syntactically valid bash', () => {
|
|
124
|
+
// A syntax error in generated shell is not caught by any string assertion
|
|
125
|
+
// and would break every build of that type on the first run.
|
|
126
|
+
const parses = async (script) => {
|
|
127
|
+
const dir = mkdtempSync(join(tmpdir(), 'cibuild-syntax-'));
|
|
128
|
+
const p = join(dir, 'step.sh');
|
|
129
|
+
writeFileSync(p, script);
|
|
130
|
+
try {
|
|
131
|
+
execFileSync('bash', ['-n', p], { stdio: 'pipe' });
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
rmSync(dir, { recursive: true, force: true });
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
test('xcode-build-for-test', async () => {
|
|
138
|
+
const { XcodeBuildForTestStepExecutor } = await import('./xcode.js');
|
|
139
|
+
const { testConfig } = await import('./test-config.js');
|
|
140
|
+
const r = await new XcodeBuildForTestStepExecutor().execute({ project_path: "My App.xcworkspace", scheme: "My Scheme", xcconfig_content: 'ARCHS = arm64' }, {}, testConfig);
|
|
141
|
+
await parses(r.script);
|
|
142
|
+
});
|
|
143
|
+
test('xcodebuild', async () => {
|
|
144
|
+
const { XcodeBuildStepExecutor } = await import('./xcode.js');
|
|
145
|
+
const { testConfig } = await import('./test-config.js');
|
|
146
|
+
const r = await new XcodeBuildStepExecutor().execute({ project_path: 'MyApp.xcodeproj', scheme: 'MyApp', is_clean_build: true }, {}, testConfig);
|
|
147
|
+
await parses(r.script);
|
|
148
|
+
});
|
|
149
|
+
test('cocoapods-install', async () => {
|
|
150
|
+
const { CocoapodsInstallStepExecutor } = await import('./ios-deps.js');
|
|
151
|
+
const { testConfig } = await import('./test-config.js');
|
|
152
|
+
const r = await new CocoapodsInstallStepExecutor().execute({ verbose: true }, {}, testConfig);
|
|
153
|
+
await parses(r.script);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
//# sourceMappingURL=git-fetch-retry-exec.test.js.map
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retrying a refused third-party dependency fetch, and naming the reason when
|
|
3
|
+
* the retry does not save it.
|
|
4
|
+
*
|
|
5
|
+
* A build that resolves its dependencies from public git repositories fetches
|
|
6
|
+
* them anonymously. When a host answers one of those fetches with HTTP 401,
|
|
7
|
+
* git has no credential to fall back on and prints
|
|
8
|
+
*
|
|
9
|
+
* fatal: could not read Username for 'https://<host>': terminal prompts disabled
|
|
10
|
+
* fatal: expected flush after ref listing
|
|
11
|
+
*
|
|
12
|
+
* Those two lines are one failure, not two: the transport child asks for a
|
|
13
|
+
* username, cannot prompt, and dies; the parent then finds the ref listing
|
|
14
|
+
* truncated. git asks for a username *only* on a 401, which makes the first
|
|
15
|
+
* line an exact signature for "the server refused this fetch".
|
|
16
|
+
*
|
|
17
|
+
* Such a refusal has been observed to be transient — the same repository
|
|
18
|
+
* readable again a minute or two later, from the same machine and the same
|
|
19
|
+
* address — while landing in the middle of a dependency resolution and taking
|
|
20
|
+
* a multi-minute build down with it.
|
|
21
|
+
*
|
|
22
|
+
* Two things are emitted here, and they are deliberately separate:
|
|
23
|
+
*
|
|
24
|
+
* - a retry around the fetch, gated on that signature, so a compile error
|
|
25
|
+
* still fails on the first attempt instead of three attempts later; and
|
|
26
|
+
* - a diagnostic that re-probes every refused URL and prints the HTTP status
|
|
27
|
+
* it gets back. Nothing else in the pipeline records that status, so a
|
|
28
|
+
* build failing this way otherwise reports a missing package rather than a
|
|
29
|
+
* refused fetch, and a recurrence is unattributable after the fact. The
|
|
30
|
+
* probe is the whole reason the second occurrence will be cheaper to
|
|
31
|
+
* explain than the first.
|
|
32
|
+
*
|
|
33
|
+
* The emitted shell is bash and runs under `set -e` / `set -o pipefail`, which
|
|
34
|
+
* is why the exit code is measured explicitly rather than inferred.
|
|
35
|
+
*/
|
|
36
|
+
/**
|
|
37
|
+
* The line git prints when, and only when, a server answers a fetch with
|
|
38
|
+
* HTTP 401 and no credential is available.
|
|
39
|
+
*/
|
|
40
|
+
export declare const REFUSED_FETCH_SIGNATURE = "could not read Username for";
|
|
41
|
+
export interface RefusedFetchRetryOptions {
|
|
42
|
+
/** What is being fetched, as it should read in a log line. */
|
|
43
|
+
label: string;
|
|
44
|
+
/** The command to run, exactly as it would appear on one shell line. */
|
|
45
|
+
command: string;
|
|
46
|
+
/** Total attempts, including the first. Defaults to 3. */
|
|
47
|
+
attempts?: number;
|
|
48
|
+
/** Seconds to wait before each retry. Defaults to 15s then 45s. */
|
|
49
|
+
backoffSeconds?: number[];
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Shell functions and state the retry depends on. Emit once per script, before
|
|
53
|
+
* the first `retryOnRefusedFetch` block.
|
|
54
|
+
*/
|
|
55
|
+
export declare function refusedFetchHelpers(): string[];
|
|
56
|
+
/**
|
|
57
|
+
* Runs `command`, retrying it while the failure looks like a refused fetch.
|
|
58
|
+
* On the last failure the diagnostic runs and the step exits with the
|
|
59
|
+
* command's own status, so the reported exit code is still the real one.
|
|
60
|
+
*/
|
|
61
|
+
export declare function retryOnRefusedFetch(options: RefusedFetchRetryOptions): string[];
|
|
62
|
+
//# sourceMappingURL=git-fetch-retry.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"git-fetch-retry.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/git-fetch-retry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AAEH;;;GAGG;AACH,eAAO,MAAM,uBAAuB,gCAAgC,CAAC;AAErE,MAAM,WAAW,wBAAwB;IACvC,8DAA8D;IAC9D,KAAK,EAAE,MAAM,CAAC;IACd,wEAAwE;IACxE,OAAO,EAAE,MAAM,CAAC;IAChB,0DAA0D;IAC1D,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,mEAAmE;IACnE,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;CAC3B;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,IAAI,MAAM,EAAE,CAiE9C;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,wBAAwB,GAAG,MAAM,EAAE,CAuC/E"}
|