@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.
@@ -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;gBAGrC,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;IA0BvB;;;;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;IAkBpB;;;;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"}
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, overrides everything
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"}