@invarn/cibuild 2.7.1 → 2.7.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.cjs +10 -10
- package/dist/src/yaml/a-declared-secret-reaches-the-file-step.test.d.ts +20 -0
- package/dist/src/yaml/a-declared-secret-reaches-the-file-step.test.d.ts.map +1 -0
- package/dist/src/yaml/a-declared-secret-reaches-the-file-step.test.js +201 -0
- package/dist/src/yaml/a-missing-credential-is-loud.test.d.ts +32 -0
- package/dist/src/yaml/a-missing-credential-is-loud.test.d.ts.map +1 -0
- package/dist/src/yaml/a-missing-credential-is-loud.test.js +301 -0
- package/dist/src/yaml/env-resolver.d.ts +33 -0
- package/dist/src/yaml/env-resolver.d.ts.map +1 -1
- package/dist/src/yaml/env-resolver.js +54 -2
- package/dist/src/yaml/steps/file.d.ts +25 -0
- package/dist/src/yaml/steps/file.d.ts.map +1 -1
- package/dist/src/yaml/steps/file.js +75 -2
- package/dist/src/yaml/steps/git-fetch-retry-exec.test.d.ts +10 -0
- package/dist/src/yaml/steps/git-fetch-retry-exec.test.d.ts.map +1 -0
- package/dist/src/yaml/steps/git-fetch-retry-exec.test.js +162 -0
- package/dist/src/yaml/steps/git-fetch-retry.d.ts +62 -0
- package/dist/src/yaml/steps/git-fetch-retry.d.ts.map +1 -0
- package/dist/src/yaml/steps/git-fetch-retry.js +155 -0
- package/dist/src/yaml/steps/git-fetch-retry.test.d.ts +5 -0
- package/dist/src/yaml/steps/git-fetch-retry.test.d.ts.map +1 -0
- package/dist/src/yaml/steps/git-fetch-retry.test.js +86 -0
- package/dist/src/yaml/steps/ios-deps.d.ts.map +1 -1
- package/dist/src/yaml/steps/ios-deps.js +14 -4
- package/dist/src/yaml/steps/xcode-resolve-retry.test.d.ts +9 -0
- package/dist/src/yaml/steps/xcode-resolve-retry.test.d.ts.map +1 -0
- package/dist/src/yaml/steps/xcode-resolve-retry.test.js +146 -0
- package/dist/src/yaml/steps/xcode.d.ts.map +1 -1
- package/dist/src/yaml/steps/xcode.js +41 -0
- package/package.json +1 -1
|
@@ -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
|
|
@@ -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":"
|
|
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 {
|
|
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
|
-
|
|
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 @@
|
|
|
1
|
+
{"version":3,"file":"git-fetch-retry.test.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/git-fetch-retry.test.ts"],"names":[],"mappings":"AAAA;;GAEG"}
|