@invarn/cibuild 2.7.0 → 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/commands/ios-scanner.d.ts +44 -8
- package/dist/src/commands/ios-scanner.d.ts.map +1 -1
- package/dist/src/commands/ios-scanner.js +52 -10
- package/dist/src/commands/ios-scheme-ranking.test.js +109 -2
- 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
|
@@ -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"}
|
|
@@ -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 @@
|
|
|
1
|
+
{"version":3,"file":"git-fetch-retry.test.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/git-fetch-retry.test.ts"],"names":[],"mappings":"AAAA;;GAEG"}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for retrying a refused third-party dependency fetch.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, test, expect } from '@jest/globals';
|
|
5
|
+
import { REFUSED_FETCH_SIGNATURE, refusedFetchHelpers, retryOnRefusedFetch, } from './git-fetch-retry.js';
|
|
6
|
+
const helpers = () => refusedFetchHelpers().join('\n');
|
|
7
|
+
const retry = (o) => retryOnRefusedFetch(o).join('\n');
|
|
8
|
+
describe('refusedFetchHelpers', () => {
|
|
9
|
+
test('keys off the line git prints only for an HTTP 401', () => {
|
|
10
|
+
expect(REFUSED_FETCH_SIGNATURE).toBe('could not read Username for');
|
|
11
|
+
expect(helpers()).toContain(REFUSED_FETCH_SIGNATURE);
|
|
12
|
+
});
|
|
13
|
+
test('captures output to a log the diagnostic can re-read', () => {
|
|
14
|
+
const script = helpers();
|
|
15
|
+
expect(script).toContain('CIBUILD_FETCH_LOG=');
|
|
16
|
+
expect(script).toContain('mktemp');
|
|
17
|
+
});
|
|
18
|
+
test('re-probes each refused URL for its HTTP status', () => {
|
|
19
|
+
const script = helpers();
|
|
20
|
+
// The whole point: the 401 is recorded nowhere, so ask again and print it.
|
|
21
|
+
expect(script).toContain('info/refs?service=git-upload-pack');
|
|
22
|
+
expect(script).toContain("-w '%{http_code}'");
|
|
23
|
+
expect(script).toContain('cibuild_diagnose_refused_fetch()');
|
|
24
|
+
});
|
|
25
|
+
test('explains each status it can get back', () => {
|
|
26
|
+
const script = helpers();
|
|
27
|
+
expect(script).toContain('200)');
|
|
28
|
+
expect(script).toContain('401|403)');
|
|
29
|
+
expect(script).toContain('404)');
|
|
30
|
+
// An unreachable host must not be reported as a refusal.
|
|
31
|
+
expect(script).toContain('000)');
|
|
32
|
+
expect(script).toContain('unreachable from this machine');
|
|
33
|
+
});
|
|
34
|
+
test('never lets a probe failure abort the step', () => {
|
|
35
|
+
// The diagnostic runs on the failure path; it must not mask the real error.
|
|
36
|
+
expect(helpers()).toContain('|| CIBUILD_FETCH_PROBE_CODE="000"');
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
describe('retryOnRefusedFetch', () => {
|
|
40
|
+
const opts = { label: 'Package resolution', command: 'xcodebuild -resolvePackageDependencies' };
|
|
41
|
+
test('runs the command it was given', () => {
|
|
42
|
+
expect(retry(opts)).toContain('xcodebuild -resolvePackageDependencies');
|
|
43
|
+
});
|
|
44
|
+
test('streams output live while still capturing it', () => {
|
|
45
|
+
const script = retry(opts);
|
|
46
|
+
expect(script).toContain('| tee "$CIBUILD_FETCH_LOG"');
|
|
47
|
+
// tee would otherwise mask the command's own exit code.
|
|
48
|
+
expect(script).toContain('${PIPESTATUS[0]}');
|
|
49
|
+
});
|
|
50
|
+
test('retries only when the failure is a refused fetch', () => {
|
|
51
|
+
const script = retry(opts);
|
|
52
|
+
// A compile error must fail on the first attempt, not after three.
|
|
53
|
+
expect(script).toContain('if ! cibuild_fetch_was_refused; then');
|
|
54
|
+
expect(script).toContain('break');
|
|
55
|
+
});
|
|
56
|
+
test('backs off between attempts', () => {
|
|
57
|
+
const script = retry({ ...opts, attempts: 3, backoffSeconds: [15, 45] });
|
|
58
|
+
expect(script).toContain('CIBUILD_FETCH_BACKOFF=(15 45)');
|
|
59
|
+
expect(script).toContain('sleep');
|
|
60
|
+
});
|
|
61
|
+
test('defaults to three attempts', () => {
|
|
62
|
+
expect(retry(opts)).toContain('CIBUILD_FETCH_MAX_ATTEMPTS=3');
|
|
63
|
+
});
|
|
64
|
+
test('honours a custom attempt count', () => {
|
|
65
|
+
expect(retry({ ...opts, attempts: 5 })).toContain('CIBUILD_FETCH_MAX_ATTEMPTS=5');
|
|
66
|
+
});
|
|
67
|
+
test('diagnoses and then fails with the original exit code', () => {
|
|
68
|
+
const script = retry(opts);
|
|
69
|
+
expect(script).toContain('cibuild_diagnose_refused_fetch');
|
|
70
|
+
expect(script).toContain('exit "$CIBUILD_FETCH_RC"');
|
|
71
|
+
});
|
|
72
|
+
test('names the thing being fetched in its retry message', () => {
|
|
73
|
+
expect(retry(opts)).toContain('Package resolution');
|
|
74
|
+
});
|
|
75
|
+
test('does not use a bare && before break, which set -e would trip on', () => {
|
|
76
|
+
// `[ x ] && break` returns 1 when the test is false and aborts under set -e.
|
|
77
|
+
expect(retry(opts)).not.toMatch(/\]\s*&&\s*break/);
|
|
78
|
+
});
|
|
79
|
+
test('restores set -e after measuring the exit code', () => {
|
|
80
|
+
const script = retry(opts);
|
|
81
|
+
expect(script).toContain('set +e');
|
|
82
|
+
expect(script).toContain('set -e');
|
|
83
|
+
expect(script.indexOf('set +e')).toBeLessThan(script.lastIndexOf('set -e'));
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
//# sourceMappingURL=git-fetch-retry.test.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ios-deps.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/ios-deps.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;
|
|
1
|
+
{"version":3,"file":"ios-deps.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/ios-deps.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAE7C,OAAO,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AACxD,OAAO,KAAK,EAAE,qBAAqB,EAAc,MAAM,wBAAwB,CAAC;AAMhF,MAAM,WAAW,sBAAsB;IACrC,0CAA0C;IAC1C,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,4DAA4D;IAC5D,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,4DAA4D;IAC5D,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,uCAAuC;IACvC,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED;;;;GAIG;AACH,qBAAa,4BAA6B,SAAQ,gBAAgB;IAChE,yBAAyB,CACvB,OAAO,EAAE,sBAAsB,EAC/B,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC5B,OAAO,EAAE,QAAQ,GAChB,qBAAqB,EAAE;IAUpB,OAAO,CACX,MAAM,EAAE,sBAAsB,EAC9B,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC5B,OAAO,EAAE,QAAQ,GAChB,OAAO,CAAC,OAAO,CAAC;CAkFpB;AAMD,MAAM,WAAW,cAAc;IAC7B,mDAAmD;IACnD,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,kDAAkD;IAClD,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,wDAAwD;IACxD,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,6BAA6B;IAC7B,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED;;GAEG;AACH,qBAAa,oBAAqB,SAAQ,gBAAgB;IACxD,yBAAyB,CACvB,OAAO,EAAE,cAAc,EACvB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC5B,OAAO,EAAE,QAAQ,GAChB,qBAAqB,EAAE;IAUpB,OAAO,CACX,MAAM,EAAE,cAAc,EACtB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC5B,OAAO,EAAE,QAAQ,GAChB,OAAO,CAAC,OAAO,CAAC;CAyDpB"}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* iOS dependency management steps: cocoapods-install, carthage
|
|
3
3
|
*/
|
|
4
4
|
import { BaseStepExecutor } from './base.js';
|
|
5
|
+
import { refusedFetchHelpers, retryOnRefusedFetch } from './git-fetch-retry.js';
|
|
5
6
|
/**
|
|
6
7
|
* Runs `pod install` or `pod update`.
|
|
7
8
|
* Auto-detects Gemfile and uses `bundle exec pod` when appropriate.
|
|
@@ -66,13 +67,22 @@ export class CocoapodsInstallStepExecutor extends BaseStepExecutor {
|
|
|
66
67
|
const verboseFlag = verbose ? ' --verbose' : '';
|
|
67
68
|
commands.push(`POD_CMD="${this.escapeBash(command)}${verboseFlag}"`);
|
|
68
69
|
commands.push('');
|
|
70
|
+
// One invocation, chosen up front, so the retry wraps it once rather than
|
|
71
|
+
// once per branch. `pod install` clones each pod's source over https, and a
|
|
72
|
+
// refused anonymous clone there fails the step exactly as it does during a
|
|
73
|
+
// Swift package resolve — same signature, same fix.
|
|
69
74
|
commands.push('if [ "$USE_BUNDLE_EXEC" = "true" ]; then');
|
|
70
|
-
commands.push('
|
|
71
|
-
commands.push(' bundle exec pod $POD_CMD');
|
|
75
|
+
commands.push(' POD_INVOCATION="bundle exec pod"');
|
|
72
76
|
commands.push('else');
|
|
73
|
-
commands.push('
|
|
74
|
-
commands.push(' pod $POD_CMD');
|
|
77
|
+
commands.push(' POD_INVOCATION="pod"');
|
|
75
78
|
commands.push('fi');
|
|
79
|
+
commands.push('echo "Running: $POD_INVOCATION $POD_CMD"');
|
|
80
|
+
commands.push('');
|
|
81
|
+
commands.push(...refusedFetchHelpers());
|
|
82
|
+
commands.push(...retryOnRefusedFetch({
|
|
83
|
+
label: 'CocoaPods dependency fetch',
|
|
84
|
+
command: '$POD_INVOCATION $POD_CMD',
|
|
85
|
+
}));
|
|
76
86
|
commands.push('');
|
|
77
87
|
commands.push('echo "✅ CocoaPods ${POD_CMD%% *} completed"');
|
|
78
88
|
const script = this.createBashScriptFromCommands(commands, stepName);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dependency resolution is separated from the build and retried.
|
|
3
|
+
*
|
|
4
|
+
* Resolution takes seconds and the build that follows takes minutes, so a
|
|
5
|
+
* refused fetch should cost a retry of the cheap half, not a re-run of the
|
|
6
|
+
* expensive one. These assert the split actually lands in the emitted script.
|
|
7
|
+
*/
|
|
8
|
+
export {};
|
|
9
|
+
//# sourceMappingURL=xcode-resolve-retry.test.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"xcode-resolve-retry.test.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/xcode-resolve-retry.test.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG"}
|