@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.
Files changed (30) hide show
  1. package/dist/cli.cjs +10 -10
  2. package/dist/src/yaml/a-declared-secret-reaches-the-file-step.test.d.ts +20 -0
  3. package/dist/src/yaml/a-declared-secret-reaches-the-file-step.test.d.ts.map +1 -0
  4. package/dist/src/yaml/a-declared-secret-reaches-the-file-step.test.js +201 -0
  5. package/dist/src/yaml/a-missing-credential-is-loud.test.d.ts +32 -0
  6. package/dist/src/yaml/a-missing-credential-is-loud.test.d.ts.map +1 -0
  7. package/dist/src/yaml/a-missing-credential-is-loud.test.js +301 -0
  8. package/dist/src/yaml/env-resolver.d.ts +33 -0
  9. package/dist/src/yaml/env-resolver.d.ts.map +1 -1
  10. package/dist/src/yaml/env-resolver.js +54 -2
  11. package/dist/src/yaml/steps/file.d.ts +25 -0
  12. package/dist/src/yaml/steps/file.d.ts.map +1 -1
  13. package/dist/src/yaml/steps/file.js +75 -2
  14. package/dist/src/yaml/steps/git-fetch-retry-exec.test.d.ts +10 -0
  15. package/dist/src/yaml/steps/git-fetch-retry-exec.test.d.ts.map +1 -0
  16. package/dist/src/yaml/steps/git-fetch-retry-exec.test.js +162 -0
  17. package/dist/src/yaml/steps/git-fetch-retry.d.ts +62 -0
  18. package/dist/src/yaml/steps/git-fetch-retry.d.ts.map +1 -0
  19. package/dist/src/yaml/steps/git-fetch-retry.js +155 -0
  20. package/dist/src/yaml/steps/git-fetch-retry.test.d.ts +5 -0
  21. package/dist/src/yaml/steps/git-fetch-retry.test.d.ts.map +1 -0
  22. package/dist/src/yaml/steps/git-fetch-retry.test.js +86 -0
  23. package/dist/src/yaml/steps/ios-deps.d.ts.map +1 -1
  24. package/dist/src/yaml/steps/ios-deps.js +14 -4
  25. package/dist/src/yaml/steps/xcode-resolve-retry.test.d.ts +9 -0
  26. package/dist/src/yaml/steps/xcode-resolve-retry.test.d.ts.map +1 -0
  27. package/dist/src/yaml/steps/xcode-resolve-retry.test.js +146 -0
  28. package/dist/src/yaml/steps/xcode.d.ts.map +1 -1
  29. package/dist/src/yaml/steps/xcode.js +41 -0
  30. package/package.json +1 -1
@@ -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
@@ -1,5 +1,20 @@
1
1
  import { BaseStepExecutor } from './base.js';
2
2
  import type { StepDef, CIConfig } from '../../types.js';
3
+ /**
4
+ * A credential the pipeline declares, the build reads, and nobody supplied.
5
+ *
6
+ * Thrown at generation time, from the step that would have written the file,
7
+ * so the build stops before its own build tool fails for a reason that names
8
+ * the project instead of the value we dropped. Generation time is also the
9
+ * only place `is_skippable` cannot swallow it: the converter sets that flag on
10
+ * a StepDef *after* `execute()` returns, so a step which merely exited
11
+ * non-zero would be reported as "failed but marked skippable — continuing".
12
+ */
13
+ export declare class MissingFileSecretError extends Error {
14
+ readonly varName: string;
15
+ readonly targetPath: string;
16
+ constructor(varName: string, targetPath: string);
17
+ }
3
18
  export interface FileInputs {
4
19
  /** Path where the file should be written, relative to the project root. */
5
20
  target_path: string;
@@ -23,5 +38,15 @@ export interface FileInputs {
23
38
  */
24
39
  export declare class FileStepExecutor extends BaseStepExecutor {
25
40
  execute(inputs: FileInputs, env: Record<string, string>, _config: CIConfig): Promise<StepDef>;
41
+ /**
42
+ * Whether the build will find the file at `target_path` without this step.
43
+ *
44
+ * Asked at generation time, which is when the step decides, and answerable
45
+ * because generation happens inside the checkout — on the host path locally
46
+ * and on the sandbox path inside the guest. The path is composed exactly the
47
+ * way the generated script composes the one it writes to, so the two cannot
48
+ * disagree about which file is in question.
49
+ */
50
+ private fileAlreadyInCheckout;
26
51
  }
27
52
  //# sourceMappingURL=file.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"file.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/file.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAC7C,OAAO,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAExD,MAAM,WAAW,UAAU;IACzB,2EAA2E;IAC3E,WAAW,EAAE,MAAM,CAAC;IACpB,+EAA+E;IAC/E,QAAQ,EAAE,MAAM,CAAC;IACjB;;;;OAIG;IACH,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B;AAED;;;;;;;;GAQG;AACH,qBAAa,gBAAiB,SAAQ,gBAAgB;IAC9C,OAAO,CACX,MAAM,EAAE,UAAU,EAClB,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC3B,OAAO,EAAE,QAAQ,GAChB,OAAO,CAAC,OAAO,CAAC;CA4BpB"}
1
+ {"version":3,"file":"file.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/file.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAC7C,OAAO,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAExD;;;;;;;;;GASG;AACH,qBAAa,sBAAuB,SAAQ,KAAK;IAC/C,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;gBAEhB,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM;CAyBhD;AAED,MAAM,WAAW,UAAU;IACzB,2EAA2E;IAC3E,WAAW,EAAE,MAAM,CAAC;IACpB,+EAA+E;IAC/E,QAAQ,EAAE,MAAM,CAAC;IACjB;;;;OAIG;IACH,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B;AAED;;;;;;;;GAQG;AACH,qBAAa,gBAAiB,SAAQ,gBAAgB;IAC9C,OAAO,CACX,MAAM,EAAE,UAAU,EAClB,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC3B,OAAO,EAAE,QAAQ,GAChB,OAAO,CAAC,OAAO,CAAC;IAiDnB;;;;;;;;OAQG;IACH,OAAO,CAAC,qBAAqB;CAI9B"}
@@ -1,5 +1,43 @@
1
- import { basename } from 'node:path';
1
+ import { existsSync } from 'node:fs';
2
+ import { basename, join } from 'node:path';
2
3
  import { BaseStepExecutor } from './base.js';
4
+ /**
5
+ * A credential the pipeline declares, the build reads, and nobody supplied.
6
+ *
7
+ * Thrown at generation time, from the step that would have written the file,
8
+ * so the build stops before its own build tool fails for a reason that names
9
+ * the project instead of the value we dropped. Generation time is also the
10
+ * only place `is_skippable` cannot swallow it: the converter sets that flag on
11
+ * a StepDef *after* `execute()` returns, so a step which merely exited
12
+ * non-zero would be reported as "failed but marked skippable — continuing".
13
+ */
14
+ export class MissingFileSecretError extends Error {
15
+ varName;
16
+ targetPath;
17
+ constructor(varName, targetPath) {
18
+ super(`file@1.0.0 cannot write ${targetPath}: ${varName} is declared in this ` +
19
+ `pipeline but no value reached the build.\n` +
20
+ `\n` +
21
+ `A slot declared and left empty means the value arrives as a secret. ` +
22
+ `None did:\n` +
23
+ `not the pipeline itself, not .cibuild-secrets.json, and not this ` +
24
+ `build's environment.\n` +
25
+ `\n` +
26
+ `Supply it in one of two places:\n` +
27
+ `\n` +
28
+ ` locally ci secrets add ${varName}\n` +
29
+ ` remote build add ${varName} as an organization or pipeline ` +
30
+ `secret, then\n` +
31
+ ` run the build again\n` +
32
+ `\n` +
33
+ `If ${basename(targetPath)} belongs to the repository instead, commit ` +
34
+ `it at\n` +
35
+ `${targetPath} — a file already in the checkout is used as it is.`);
36
+ this.name = 'MissingFileSecretError';
37
+ this.varName = varName;
38
+ this.targetPath = targetPath;
39
+ }
40
+ }
3
41
  /**
4
42
  * Writes a file to a target path at runtime.
5
43
  *
@@ -16,7 +54,29 @@ export class FileStepExecutor extends BaseStepExecutor {
16
54
  const filename = basename(targetPath);
17
55
  const content = env[varName];
18
56
  if (!content) {
19
- return this.createScriptStep(`echo "⚠️ ${varName} not set — skipping ${filename}"`, 'file');
57
+ // Two absences, and they mean opposite things. A `var_name` nothing
58
+ // declares is a genuinely optional credential — a release keystore on a
59
+ // debug workflow — and skipping it is correct. A `var_name` the pipeline
60
+ // declares and leaves empty is a value that was supposed to arrive as a
61
+ // secret and did not: after issue 01 it is in none of the four places a
62
+ // value can come from, so the build is guaranteed red later, for a
63
+ // reason nothing in the log connects to this step. `mergeEnvVars` puts a
64
+ // declared key in the map even when its value is the empty placeholder,
65
+ // and nothing else puts a key there, so presence is the whole test.
66
+ //
67
+ // Except when the repository already carries the file. The Android
68
+ // generator declares KEYSTORE_BASE64 once, globally, but emits its
69
+ // `file@` step on every workflow including `pull-request`, where nothing
70
+ // signs anything — so "declared and empty" alone would refuse builds
71
+ // that are green today and rightly so. The question worth asking is the
72
+ // one the build will ask: will the file be there when it is read?
73
+ if (varName in env && !this.fileAlreadyInCheckout(targetPath, env)) {
74
+ throw new MissingFileSecretError(varName, targetPath);
75
+ }
76
+ const reason = varName in env
77
+ ? `${filename} is already in the checkout, using it`
78
+ : `skipping ${filename}`;
79
+ return this.createScriptStep(`echo "⚠️ ${varName} not set — ${reason}"`, 'file');
20
80
  }
21
81
  // Base64-encode the content so it can be embedded safely in the script
22
82
  // regardless of quotes, newlines, dollar signs, or any other special characters.
@@ -31,5 +91,18 @@ printf '%s' '${encoded}' | base64 -d > "\${CIBUILD_SOURCE_DIR:-$PWD}/${targetPat
31
91
  echo "✓ Written ${filename}"`;
32
92
  return this.createScriptStep(this.createBashScript(scriptContent, 'file'), 'file');
33
93
  }
94
+ /**
95
+ * Whether the build will find the file at `target_path` without this step.
96
+ *
97
+ * Asked at generation time, which is when the step decides, and answerable
98
+ * because generation happens inside the checkout — on the host path locally
99
+ * and on the sandbox path inside the guest. The path is composed exactly the
100
+ * way the generated script composes the one it writes to, so the two cannot
101
+ * disagree about which file is in question.
102
+ */
103
+ fileAlreadyInCheckout(targetPath, env) {
104
+ const sourceDir = env.CIBUILD_SOURCE_DIR || process.cwd();
105
+ return existsSync(join(sourceDir, targetPath));
106
+ }
34
107
  }
35
108
  //# sourceMappingURL=file.js.map
@@ -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,162 @@
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('xcode-build-for-simulator', async () => {
150
+ const { XcodeBuildForSimulatorStepExecutor } = await import('./xcode.js');
151
+ const { testConfig } = await import('./test-config.js');
152
+ const r = await new XcodeBuildForSimulatorStepExecutor().execute({ project_path: "My App.xcworkspace", scheme: "My Scheme", perform_clean_action: 'yes' }, {}, testConfig);
153
+ await parses(r.script);
154
+ });
155
+ test('cocoapods-install', async () => {
156
+ const { CocoapodsInstallStepExecutor } = await import('./ios-deps.js');
157
+ const { testConfig } = await import('./test-config.js');
158
+ const r = await new CocoapodsInstallStepExecutor().execute({ verbose: true }, {}, testConfig);
159
+ await parses(r.script);
160
+ });
161
+ });
162
+ //# 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"}
@@ -0,0 +1,155 @@
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 const REFUSED_FETCH_SIGNATURE = 'could not read Username for';
41
+ /**
42
+ * Shell functions and state the retry depends on. Emit once per script, before
43
+ * the first `retryOnRefusedFetch` block.
44
+ */
45
+ export function refusedFetchHelpers() {
46
+ return [
47
+ '# --- refused dependency fetch: retry, then explain -------------------------',
48
+ 'CIBUILD_FETCH_LOG="$(mktemp -t cibuild-fetch)"',
49
+ '',
50
+ '# git asks for a username only when a server answers with HTTP 401, so this',
51
+ '# line means the fetch was refused, not that a package is missing.',
52
+ 'cibuild_fetch_was_refused() {',
53
+ ` grep -q '${REFUSED_FETCH_SIGNATURE}' "$CIBUILD_FETCH_LOG" 2>/dev/null`,
54
+ '}',
55
+ '',
56
+ '# The status that caused the refusal is recorded nowhere, so ask again and',
57
+ '# put it in the build log where the next person will find it.',
58
+ 'cibuild_diagnose_refused_fetch() {',
59
+ ' echo ""',
60
+ ' echo "──────────────────────────────────────────────────────────────"',
61
+ ' echo "A dependency fetch was refused."',
62
+ ' echo ""',
63
+ ' echo "git asked for a username, which it does only when the server answered"',
64
+ ' echo "with HTTP 401. That is an authentication refusal, not a missing"',
65
+ ' echo "package. Re-probing each repository named above:"',
66
+ ' echo ""',
67
+ // Every stage here can legitimately match nothing, and under `set -e` with
68
+ // `pipefail` an empty grep would abort the step and replace the command's
69
+ // real exit code with 1. The diagnostic must never do that: it runs on the
70
+ // failure path, and its whole job is to explain a failure it did not cause.
71
+ ` grep -oE 'https://[^ "'"'"']+' "$CIBUILD_FETCH_LOG" 2>/dev/null \\`,
72
+ ` | sed -e "s/[.,:;')]*$//" -e 's/\\.git$//' \\`,
73
+ ` | grep -E '^https://[^/]+/[^/]+/[^/]+$' \\`,
74
+ ' | sort -u | head -20 \\',
75
+ ' | while IFS= read -r CIBUILD_FETCH_PROBE_URL; do',
76
+ ' CIBUILD_FETCH_PROBE_CODE="$(curl -sS -o /dev/null -m 20 \\',
77
+ ` -w '%{http_code}' \\`,
78
+ ' "$CIBUILD_FETCH_PROBE_URL/info/refs?service=git-upload-pack" 2>/dev/null)" \\',
79
+ ' || CIBUILD_FETCH_PROBE_CODE="000"',
80
+ ' case "$CIBUILD_FETCH_PROBE_CODE" in',
81
+ ' 200)',
82
+ ' echo " 200 $CIBUILD_FETCH_PROBE_URL"',
83
+ ' echo " readable anonymously now — the refusal was transient"',
84
+ ' ;;',
85
+ ' 401|403)',
86
+ ' echo " $CIBUILD_FETCH_PROBE_CODE $CIBUILD_FETCH_PROBE_URL"',
87
+ ' echo " still refused — this repository needs a credential, or the"',
88
+ ' echo " host is refusing anonymous reads from this machine"',
89
+ ' ;;',
90
+ ' 404)',
91
+ ' echo " 404 $CIBUILD_FETCH_PROBE_URL"',
92
+ ' echo " no such repository — check the dependency URL"',
93
+ ' ;;',
94
+ ' 000)',
95
+ ' echo " --- $CIBUILD_FETCH_PROBE_URL"',
96
+ ' echo " unreachable from this machine — network, DNS or TLS"',
97
+ ' ;;',
98
+ ' *)',
99
+ ' echo " $CIBUILD_FETCH_PROBE_CODE $CIBUILD_FETCH_PROBE_URL"',
100
+ ' ;;',
101
+ ' esac',
102
+ ' done || true',
103
+ ' echo ""',
104
+ ' echo "A 200 above means the fetch would succeed if it ran again now."',
105
+ ' echo "──────────────────────────────────────────────────────────────"',
106
+ ' echo ""',
107
+ '}',
108
+ '',
109
+ ];
110
+ }
111
+ /**
112
+ * Runs `command`, retrying it while the failure looks like a refused fetch.
113
+ * On the last failure the diagnostic runs and the step exits with the
114
+ * command's own status, so the reported exit code is still the real one.
115
+ */
116
+ export function retryOnRefusedFetch(options) {
117
+ const attempts = options.attempts ?? 3;
118
+ const backoff = options.backoffSeconds ?? [15, 45];
119
+ const label = options.label.replace(/"/g, '\\"');
120
+ return [
121
+ `CIBUILD_FETCH_MAX_ATTEMPTS=${attempts}`,
122
+ `CIBUILD_FETCH_BACKOFF=(${backoff.join(' ')})`,
123
+ 'CIBUILD_FETCH_ATTEMPT=1',
124
+ 'while :; do',
125
+ ' set +e',
126
+ ` ${options.command} 2>&1 | tee "$CIBUILD_FETCH_LOG"`,
127
+ ' CIBUILD_FETCH_RC=${PIPESTATUS[0]}',
128
+ ' set -e',
129
+ ' if [ "$CIBUILD_FETCH_RC" -eq 0 ]; then',
130
+ ' break',
131
+ ' fi',
132
+ // Anything that is not a refused fetch is a real failure: fail now.
133
+ ' if ! cibuild_fetch_was_refused; then',
134
+ ' break',
135
+ ' fi',
136
+ ' if [ "$CIBUILD_FETCH_ATTEMPT" -ge "$CIBUILD_FETCH_MAX_ATTEMPTS" ]; then',
137
+ ' break',
138
+ ' fi',
139
+ ' CIBUILD_FETCH_WAIT="${CIBUILD_FETCH_BACKOFF[$((CIBUILD_FETCH_ATTEMPT - 1))]:-30}"',
140
+ ` echo "⚠️ ${label} was refused by the server (attempt $CIBUILD_FETCH_ATTEMPT of $CIBUILD_FETCH_MAX_ATTEMPTS)."`,
141
+ ' echo " A refused anonymous fetch is usually transient. Retrying in ${CIBUILD_FETCH_WAIT}s..."',
142
+ ' sleep "$CIBUILD_FETCH_WAIT"',
143
+ ' CIBUILD_FETCH_ATTEMPT=$((CIBUILD_FETCH_ATTEMPT + 1))',
144
+ 'done',
145
+ '',
146
+ 'if [ "$CIBUILD_FETCH_RC" -ne 0 ]; then',
147
+ ' if cibuild_fetch_was_refused; then',
148
+ ' cibuild_diagnose_refused_fetch',
149
+ ' fi',
150
+ ' exit "$CIBUILD_FETCH_RC"',
151
+ 'fi',
152
+ '',
153
+ ];
154
+ }
155
+ //# sourceMappingURL=git-fetch-retry.js.map
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Tests for retrying a refused third-party dependency fetch.
3
+ */
4
+ export {};
5
+ //# sourceMappingURL=git-fetch-retry.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"git-fetch-retry.test.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/git-fetch-retry.test.ts"],"names":[],"mappings":"AAAA;;GAEG"}