@invarn/cibuild 2.5.9 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,27 @@
1
+ /**
2
+ * A build whose HEAD commit has a multi-line message completes every step.
3
+ *
4
+ * This is the regression that started the whole thread. On 2026-08-30 the first
5
+ * real `invarn setup` proof died of it: `git-clone` publishes the commit body,
6
+ * and every step after it failed loading the envstore —
7
+ *
8
+ * [step_2] Running: cache-pull
9
+ * …: line 59: export: `value nothing provides.': not a valid identifier
10
+ * ✗ Pipeline failed: Step "Install CocoaPods" failed with exit code 1
11
+ *
12
+ * `src/yaml/steps/envstore-loader.test.ts` drives the *reader* with hostile
13
+ * content written by hand, which is the right shape for the defect but stops
14
+ * short of the thing that actually happened: the value came out of a real
15
+ * repository, through a real producer. This closes that gap — a real
16
+ * `git commit`, the real `git-clone` step reading it with `git log -1
17
+ * --format=%b`, the real `envman` writer (`envmanShellFunction`, the same
18
+ * bytes the runner prepends to every script step), and the real reader in the
19
+ * next step's preamble.
20
+ *
21
+ * The fixture's commit message carries both consequences the PRD names, because
22
+ * they need different bytes to surface: a line that is *not* a valid assignment
23
+ * (which killed the step) and a line that *is* one (which let the commit's
24
+ * author set `PATH` for every step after it).
25
+ */
26
+ export {};
27
+ //# sourceMappingURL=envstore-round-trip.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"envstore-round-trip.test.d.ts","sourceRoot":"","sources":["../../../src/envman/envstore-round-trip.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG"}
@@ -0,0 +1,130 @@
1
+ /**
2
+ * A build whose HEAD commit has a multi-line message completes every step.
3
+ *
4
+ * This is the regression that started the whole thread. On 2026-08-30 the first
5
+ * real `invarn setup` proof died of it: `git-clone` publishes the commit body,
6
+ * and every step after it failed loading the envstore —
7
+ *
8
+ * [step_2] Running: cache-pull
9
+ * …: line 59: export: `value nothing provides.': not a valid identifier
10
+ * ✗ Pipeline failed: Step "Install CocoaPods" failed with exit code 1
11
+ *
12
+ * `src/yaml/steps/envstore-loader.test.ts` drives the *reader* with hostile
13
+ * content written by hand, which is the right shape for the defect but stops
14
+ * short of the thing that actually happened: the value came out of a real
15
+ * repository, through a real producer. This closes that gap — a real
16
+ * `git commit`, the real `git-clone` step reading it with `git log -1
17
+ * --format=%b`, the real `envman` writer (`envmanShellFunction`, the same
18
+ * bytes the runner prepends to every script step), and the real reader in the
19
+ * next step's preamble.
20
+ *
21
+ * The fixture's commit message carries both consequences the PRD names, because
22
+ * they need different bytes to surface: a line that is *not* a valid assignment
23
+ * (which killed the step) and a line that *is* one (which let the commit's
24
+ * author set `PATH` for every step after it).
25
+ */
26
+ import { describe, test, expect, beforeAll, afterAll } from '@jest/globals';
27
+ import { execFileSync, spawnSync } from 'child_process';
28
+ import { mkdtempSync, readFileSync, mkdirSync, rmSync, writeFileSync, existsSync } from 'fs';
29
+ import { join } from 'path';
30
+ import { tmpdir } from 'os';
31
+ import { envmanShellFunction } from './shim.js';
32
+ import { GitCloneStepExecutor } from '../yaml/steps/git-clone.js';
33
+ import { ScriptStepExecutor } from '../yaml/steps/script.js';
34
+ import { testConfigNoPeer } from '../yaml/steps/test-config.js';
35
+ const SUBJECT = 'Add Invarn pipeline';
36
+ /**
37
+ * Three lines and a blank one. Line 2 is not a valid assignment; line 4 is.
38
+ * `git log --format=%b` emits a trailing newline that `$( )` strips, so the
39
+ * expected value below carries none.
40
+ */
41
+ const BODY = [
42
+ 'cibuild sets CIBUILD_GIT_BRANCH, so pre-execution validation asked for a',
43
+ 'value nothing provides.',
44
+ '',
45
+ 'PATH=/tmp/attacker-controlled',
46
+ ].join('\n');
47
+ describe('a multi-line commit message, from the repository to the next step', () => {
48
+ let repoDir;
49
+ let envstorePath;
50
+ let captureDir;
51
+ beforeAll(() => {
52
+ repoDir = mkdtempSync(join(tmpdir(), 'envstore-round-trip-'));
53
+ envstorePath = join(repoDir, '.ci', '.envstore.json');
54
+ captureDir = join(repoDir, 'captured');
55
+ mkdirSync(captureDir, { recursive: true });
56
+ writeFileSync(join(repoDir, 'README.md'), 'fixture\n', 'utf-8');
57
+ const git = (...args) => {
58
+ execFileSync('git', args, { cwd: repoDir, stdio: 'ignore' });
59
+ };
60
+ git('init', '-b', 'main');
61
+ git('config', 'user.name', 'CI Build Tests');
62
+ git('config', 'user.email', 'cibuild-tests@example.com');
63
+ git('config', 'commit.gpgsign', 'false');
64
+ git('add', '.');
65
+ git('commit', '-m', `${SUBJECT}\n\n${BODY}`);
66
+ });
67
+ afterAll(() => {
68
+ rmSync(repoDir, { recursive: true, force: true });
69
+ });
70
+ /**
71
+ * Runs one generated step the way the runner does: the envman shell function
72
+ * prepended, bash, the envstore path in the environment so it survives the
73
+ * `cd` the step does.
74
+ */
75
+ function runStep(script) {
76
+ const scriptPath = join(repoDir, '__step.sh');
77
+ writeFileSync(scriptPath, envmanShellFunction() + script, 'utf-8');
78
+ const proc = spawnSync('bash', [scriptPath], {
79
+ cwd: repoDir,
80
+ encoding: 'utf-8',
81
+ env: {
82
+ ...process.env,
83
+ ENVMAN_ENVSTORE_PATH: envstorePath,
84
+ CAPTURE_DIR: captureDir,
85
+ },
86
+ });
87
+ return { status: proc.status, stdout: proc.stdout ?? '', stderr: proc.stderr ?? '' };
88
+ }
89
+ function captured(name) {
90
+ const file = join(captureDir, name);
91
+ return existsSync(file) ? readFileSync(file, 'utf-8') : undefined;
92
+ }
93
+ test('git-clone publishes the commit body without dying on it', async () => {
94
+ const { script } = await new GitCloneStepExecutor().execute({}, {}, testConfigNoPeer);
95
+ const run = runStep(script);
96
+ expect(run.status).toBe(0);
97
+ expect(run.stdout).toContain(`Message: ${SUBJECT}`);
98
+ // The envstore is what the next step reads, so it has to exist and to hold
99
+ // the body as data rather than as three more records.
100
+ const store = JSON.parse(readFileSync(envstorePath, 'utf-8'));
101
+ expect(store.envs.find(e => e.key === 'GIT_CLONE_COMMIT_MESSAGE_BODY')?.value).toBe(BODY);
102
+ expect(store.envs.map(e => e.key)).not.toContain('PATH');
103
+ });
104
+ test('the next step loads it, completes, and is not reconfigured by it', async () => {
105
+ // Runs after git-clone above: the envstore on disk is the real one that
106
+ // step wrote.
107
+ const { script } = await new ScriptStepExecutor().execute({
108
+ content: [
109
+ `printf '%s' "\${GIT_CLONE_COMMIT_MESSAGE_BODY-}" > "$CAPTURE_DIR/body"`,
110
+ `printf '%s' "\${GIT_CLONE_COMMIT_MESSAGE_SUBJECT-}" > "$CAPTURE_DIR/subject"`,
111
+ `printf '%s' "$PATH" > "$CAPTURE_DIR/path"`,
112
+ 'echo step-completed',
113
+ ].join('\n'),
114
+ }, {}, testConfigNoPeer);
115
+ const run = runStep(script);
116
+ // "Completes every step" — the whole acceptance criterion, and what the
117
+ // production build could not do.
118
+ expect(run.status).toBe(0);
119
+ expect(run.stdout).toContain('step-completed');
120
+ expect(run.stderr).not.toContain('not a valid identifier');
121
+ // Correctness: byte for byte, internal blank line and all.
122
+ expect(captured('body')).toBe(BODY);
123
+ expect(captured('subject')).toBe(SUBJECT);
124
+ // Security: the commit's author does not get to choose PATH. Compared
125
+ // against what the parent process held, which is what "unchanged" means.
126
+ expect(captured('path')).toBe(process.env.PATH ?? '');
127
+ expect(captured('path')).not.toContain('/tmp/attacker-controlled');
128
+ });
129
+ });
130
+ //# sourceMappingURL=envstore-round-trip.test.js.map
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The `envman` shell function prepended to every script step.
3
+ *
4
+ * Steps call `envman add --key K --value V` to publish a value to the steps
5
+ * after them. This is a bash function rather than a call to the `ci` binary
6
+ * because pkg-built binaries mishandle subcommands — so the writer half of the
7
+ * envstore contract is generated bash, exactly like the reader half in
8
+ * `createBashScript`.
9
+ *
10
+ * Lives in its own module so a test can run the *same bytes production runs*
11
+ * against a real producer, rather than a copy that can drift from it. The
12
+ * reader is driven directly in `src/yaml/steps/envstore-loader.test.ts`; the
13
+ * pair is exercised end to end in `src/envman/envstore-round-trip.test.ts`.
14
+ */
15
+ export declare function envmanShellFunction(): string;
16
+ //# sourceMappingURL=shim.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"shim.d.ts","sourceRoot":"","sources":["../../../src/envman/shim.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AACH,wBAAgB,mBAAmB,IAAI,MAAM,CAkD5C"}
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The `envman` shell function prepended to every script step.
3
+ *
4
+ * Steps call `envman add --key K --value V` to publish a value to the steps
5
+ * after them. This is a bash function rather than a call to the `ci` binary
6
+ * because pkg-built binaries mishandle subcommands — so the writer half of the
7
+ * envstore contract is generated bash, exactly like the reader half in
8
+ * `createBashScript`.
9
+ *
10
+ * Lives in its own module so a test can run the *same bytes production runs*
11
+ * against a real producer, rather than a copy that can drift from it. The
12
+ * reader is driven directly in `src/yaml/steps/envstore-loader.test.ts`; the
13
+ * pair is exercised end to end in `src/envman/envstore-round-trip.test.ts`.
14
+ */
15
+ export function envmanShellFunction() {
16
+ return `envman() {
17
+ local cmd="$1"; shift
18
+ local store="\${ENVMAN_ENVSTORE_PATH:-.ci/.envstore.json}"
19
+ case "$cmd" in
20
+ add)
21
+ local key="" value="" valuefile="" sensitive=false skipifempty=false append=false
22
+ while [ $# -gt 0 ]; do
23
+ case "$1" in
24
+ --key|-k) key="$2"; shift 2 ;;
25
+ --value|-v) value="$2"; shift 2 ;;
26
+ --valuefile|-f) valuefile="$2"; shift 2 ;;
27
+ --sensitive|-s) sensitive=true; shift ;;
28
+ --skip-if-empty) skipifempty=true; shift ;;
29
+ --append|-a) append=true; shift ;;
30
+ *) shift ;;
31
+ esac
32
+ done
33
+ [ -z "$key" ] && { echo "envman add: --key required" >&2; return 1; }
34
+ if [ -n "$valuefile" ]; then
35
+ value="$(cat "$valuefile")"
36
+ elif [ -z "$value" ] && ! [ -t 0 ]; then
37
+ value="$(cat)"
38
+ fi
39
+ [ "$skipifempty" = true ] && [ -z "$value" ] && return 0
40
+ node -e '
41
+ const fs=require("fs");
42
+ const f=process.argv[1], k=process.argv[2], v=process.argv[3];
43
+ const s=process.argv[4]==="true", a=process.argv[5]==="true";
44
+ let store={envs:[]};
45
+ try{store=JSON.parse(fs.readFileSync(f,"utf-8"))}catch{}
46
+ const idx=store.envs.findIndex(e=>e.key===k);
47
+ if(a && idx>=0){store.envs[idx].value+=v}
48
+ else if(idx>=0){store.envs[idx].value=v;store.envs[idx].sensitive=s}
49
+ else{store.envs.push({key:k,value:v,sensitive:s})}
50
+ fs.mkdirSync(require("path").dirname(f),{recursive:true});
51
+ fs.writeFileSync(f,JSON.stringify(store,null,2));
52
+ ' "$store" "$key" "$value" "$sensitive" "$append"
53
+ echo "envman: $key set"
54
+ ;;
55
+ init)
56
+ node -e 'const fs=require("fs");const f=process.argv[1];fs.mkdirSync(require("path").dirname(f),{recursive:true});fs.writeFileSync(f,JSON.stringify({envs:[]},null,2))' "$store"
57
+ ;;
58
+ *)
59
+ echo "envman: unknown command: $cmd" >&2; return 1
60
+ ;;
61
+ esac
62
+ }
63
+ export -f envman
64
+ `;
65
+ }
66
+ //# sourceMappingURL=shim.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"runner.d.ts","sourceRoot":"","sources":["../../src/runner.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,OAAO,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAMjE,qBAAa,kBAAmB,SAAQ,KAAK;IAElC,MAAM,EAAE,MAAM;IACd,QAAQ,EAAE,MAAM;IAChB,QAAQ,EAAE,MAAM;gBAFhB,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,MAAM;CAK1B;AAED,qBAAa,cAAc;IACb,OAAO,CAAC,MAAM;gBAAN,MAAM,EAAE,QAAQ;IAE9B,OAAO,CAAC,IAAI,EAAE,OAAO,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAkKhE,OAAO,CAAC,cAAc;IAYtB,gBAAgB,CAAC,QAAQ,EAAE,WAAW,GAAG,IAAI;IAwB7C,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,EAAE,YAAY,EAAE,MAAM,GAAG,IAAI;IAczD,WAAW,CAAC,QAAQ,EAAE,WAAW,EAAE,QAAQ,CAAC,EAAE,MAAM,EAAE,EAAE,YAAY,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CA2CpG"}
1
+ {"version":3,"file":"runner.d.ts","sourceRoot":"","sources":["../../src/runner.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,OAAO,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAOjE,qBAAa,kBAAmB,SAAQ,KAAK;IAElC,MAAM,EAAE,MAAM;IACd,QAAQ,EAAE,MAAM;IAChB,QAAQ,EAAE,MAAM;gBAFhB,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,MAAM;CAK1B;AAED,qBAAa,cAAc;IACb,OAAO,CAAC,MAAM;gBAAN,MAAM,EAAE,QAAQ;IAE9B,OAAO,CAAC,IAAI,EAAE,OAAO,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAiHhE,OAAO,CAAC,cAAc;IAYtB,gBAAgB,CAAC,QAAQ,EAAE,WAAW,GAAG,IAAI;IAwB7C,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,EAAE,YAAY,EAAE,MAAM,GAAG,IAAI;IAczD,WAAW,CAAC,QAAQ,EAAE,WAAW,EAAE,QAAQ,CAAC,EAAE,MAAM,EAAE,EAAE,YAAY,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CA2CpG"}
@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
2
2
  import { dirname, join, resolve } from "node:path";
3
3
  import { unlinkSync, existsSync, writeFileSync, chmodSync, mkdtempSync, rmSync, mkdirSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
+ import { envmanShellFunction } from "./envman/shim.js";
5
6
  import { formatStepMarker, shouldEmitStepMarker } from "./step-marker.js";
6
7
  // Use absolute path for envstore so it works regardless of cd in scripts
7
8
  const ENVMAN_ENVSTORE_PATH = resolve(process.cwd(), ".ci", ".envstore.json");
@@ -54,56 +55,7 @@ export class PipelineRunner {
54
55
  tmpScriptPath = join(tmpDir, `step-${step.id || 'script'}.sh`);
55
56
  // Prepend envman shell function that writes directly to the envstore JSON file.
56
57
  // This avoids calling the ci binary (pkg binaries have issues with subcommands).
57
- const envmanShim = `envman() {
58
- local cmd="\$1"; shift
59
- local store="\${ENVMAN_ENVSTORE_PATH:-.ci/.envstore.json}"
60
- case "\$cmd" in
61
- add)
62
- local key="" value="" valuefile="" sensitive=false skipifempty=false append=false
63
- while [ \$# -gt 0 ]; do
64
- case "\$1" in
65
- --key|-k) key="\$2"; shift 2 ;;
66
- --value|-v) value="\$2"; shift 2 ;;
67
- --valuefile|-f) valuefile="\$2"; shift 2 ;;
68
- --sensitive|-s) sensitive=true; shift ;;
69
- --skip-if-empty) skipifempty=true; shift ;;
70
- --append|-a) append=true; shift ;;
71
- *) shift ;;
72
- esac
73
- done
74
- [ -z "\$key" ] && { echo "envman add: --key required" >&2; return 1; }
75
- if [ -n "\$valuefile" ]; then
76
- value="\$(cat "\$valuefile")"
77
- elif [ -z "\$value" ] && ! [ -t 0 ]; then
78
- value="\$(cat)"
79
- fi
80
- [ "\$skipifempty" = true ] && [ -z "\$value" ] && return 0
81
- node -e '
82
- const fs=require("fs");
83
- const f=process.argv[1], k=process.argv[2], v=process.argv[3];
84
- const s=process.argv[4]==="true", a=process.argv[5]==="true";
85
- let store={envs:[]};
86
- try{store=JSON.parse(fs.readFileSync(f,"utf-8"))}catch{}
87
- const idx=store.envs.findIndex(e=>e.key===k);
88
- if(a && idx>=0){store.envs[idx].value+=v}
89
- else if(idx>=0){store.envs[idx].value=v;store.envs[idx].sensitive=s}
90
- else{store.envs.push({key:k,value:v,sensitive:s})}
91
- fs.mkdirSync(require("path").dirname(f),{recursive:true});
92
- fs.writeFileSync(f,JSON.stringify(store,null,2));
93
- ' "\$store" "\$key" "\$value" "\$sensitive" "\$append"
94
- echo "envman: \$key set"
95
- ;;
96
- init)
97
- node -e 'const fs=require("fs");const f=process.argv[1];fs.mkdirSync(require("path").dirname(f),{recursive:true});fs.writeFileSync(f,JSON.stringify({envs:[]},null,2))' "\$store"
98
- ;;
99
- *)
100
- echo "envman: unknown command: \$cmd" >&2; return 1
101
- ;;
102
- esac
103
- }
104
- export -f envman
105
- `;
106
- writeFileSync(tmpScriptPath, envmanShim + step.script, 'utf-8');
58
+ writeFileSync(tmpScriptPath, envmanShellFunction() + step.script, 'utf-8');
107
59
  chmodSync(tmpScriptPath, 0o755);
108
60
  args = ["-l", tmpScriptPath];
109
61
  }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * `cache-pull` over the daemon: a miss and a broken daemon are different things.
3
+ *
4
+ * `{ curl | zstd | tar; } 2>/dev/null` swallowed curl's message entirely, so a
5
+ * 401, a 400, a 503, a 500 and a daemon that was not listening all printed the
6
+ * same line as an empty cache:
7
+ *
8
+ * No cache found for key: pods-0fc6c3e8f03403bc5fb869d95e505969-HEAD
9
+ *
10
+ * Same class as the push-side defect in `27e05e5`, where `curl -f` threw the
11
+ * response body away so `invalid_cache_key` never reached the log. A cold cache
12
+ * is the normal case and must stay quiet; the point is only that a broken
13
+ * daemon stops looking like one.
14
+ *
15
+ * These tests RUN the generated script against a **real listener with the real
16
+ * curl**. Stubbing curl onto PATH cannot see this: the whole question is what
17
+ * the real curl reports and where it writes it, so a stub that emits the status
18
+ * itself would pass against a step that never asked for one.
19
+ *
20
+ * `zstd` and `tar` *are* stubbed, for two different reasons. zstd is not on
21
+ * every host, and without it the pipeline fails before curl is reached — which
22
+ * would make these pass vacuously. tar is stubbed because the generated script
23
+ * extracts with `-C /`: nothing under test here is worth writing to the root of
24
+ * the machine running the suite.
25
+ */
26
+ export {};
27
+ //# sourceMappingURL=cache-pull-daemon.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cache-pull-daemon.test.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/cache-pull-daemon.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG"}
@@ -0,0 +1,183 @@
1
+ /**
2
+ * `cache-pull` over the daemon: a miss and a broken daemon are different things.
3
+ *
4
+ * `{ curl | zstd | tar; } 2>/dev/null` swallowed curl's message entirely, so a
5
+ * 401, a 400, a 503, a 500 and a daemon that was not listening all printed the
6
+ * same line as an empty cache:
7
+ *
8
+ * No cache found for key: pods-0fc6c3e8f03403bc5fb869d95e505969-HEAD
9
+ *
10
+ * Same class as the push-side defect in `27e05e5`, where `curl -f` threw the
11
+ * response body away so `invalid_cache_key` never reached the log. A cold cache
12
+ * is the normal case and must stay quiet; the point is only that a broken
13
+ * daemon stops looking like one.
14
+ *
15
+ * These tests RUN the generated script against a **real listener with the real
16
+ * curl**. Stubbing curl onto PATH cannot see this: the whole question is what
17
+ * the real curl reports and where it writes it, so a stub that emits the status
18
+ * itself would pass against a step that never asked for one.
19
+ *
20
+ * `zstd` and `tar` *are* stubbed, for two different reasons. zstd is not on
21
+ * every host, and without it the pipeline fails before curl is reached — which
22
+ * would make these pass vacuously. tar is stubbed because the generated script
23
+ * extracts with `-C /`: nothing under test here is worth writing to the root of
24
+ * the machine running the suite.
25
+ */
26
+ import { describe, test, expect } from '@jest/globals';
27
+ import { execFile } from 'child_process';
28
+ import { mkdtempSync, writeFileSync, rmSync, mkdirSync, chmodSync } from 'fs';
29
+ import { createServer } from 'http';
30
+ import { join } from 'path';
31
+ import { tmpdir } from 'os';
32
+ import { promisify } from 'util';
33
+ import { CachePullStepExecutor } from './cache.js';
34
+ import { testConfigNoPeer } from './test-config.js';
35
+ /**
36
+ * Async on purpose. The listener below lives in this process, and
37
+ * `execFileSync` blocks the event loop — so it would never answer curl, every
38
+ * request would time out, and every case would look like a dead daemon
39
+ * whatever the test asked for.
40
+ */
41
+ const run = promisify(execFile);
42
+ const CACHE_KEY = 'pods-abc123def456-release-1.4';
43
+ async function listen(handler, paths) {
44
+ const server = createServer((req, res) => {
45
+ const path = req.url ?? '/';
46
+ paths.push(path);
47
+ const { status, body } = handler(path);
48
+ res.writeHead(status, { 'content-type': 'application/octet-stream' });
49
+ res.end(body);
50
+ });
51
+ const port = await new Promise((resolve, reject) => {
52
+ server.once('error', reject);
53
+ server.listen(0, '127.0.0.1', () => {
54
+ const address = server.address();
55
+ resolve(typeof address === 'object' && address !== null ? address.port : 0);
56
+ });
57
+ });
58
+ return { server, port };
59
+ }
60
+ async function runPull(options) {
61
+ const dir = mkdtempSync(join(tmpdir(), 'cibuild-pull-'));
62
+ const paths = [];
63
+ const { server, port } = await listen((path) => {
64
+ if (path.includes('/scope/')) {
65
+ return { status: options.scopeStatus ?? 404, body: '{"error":"no_cache_for_scope"}' };
66
+ }
67
+ const status = options.keyStatus === 'dead' ? 404 : options.keyStatus;
68
+ return {
69
+ status,
70
+ body: status === 200 ? 'a-tarball-shaped-payload' : '{"error":"cache_miss"}',
71
+ };
72
+ }, paths);
73
+ // A dead daemon is a port nothing answers on — the one the listener just
74
+ // released. Anything else and curl would be diagnosing the wrong failure.
75
+ if (options.keyStatus === 'dead') {
76
+ await new Promise((resolve) => server.close(() => resolve()));
77
+ }
78
+ try {
79
+ const bin = join(dir, 'bin');
80
+ mkdirSync(bin, { recursive: true });
81
+ writeFileSync(join(bin, 'zstd'), '#!/bin/bash\ncat\n', 'utf-8');
82
+ chmodSync(join(bin, 'zstd'), 0o755);
83
+ // Consumes the stream and writes nothing: see the file header on `-C /`.
84
+ writeFileSync(join(bin, 'tar'), '#!/bin/bash\ncat > /dev/null\n', 'utf-8');
85
+ chmodSync(join(bin, 'tar'), 0o755);
86
+ // The preset form derives its key from a lockfile, so it needs one.
87
+ if (options.technology) {
88
+ writeFileSync(join(dir, 'package-lock.json'), '{"lockfileVersion":3}\n', 'utf-8');
89
+ }
90
+ const { script } = await new CachePullStepExecutor().execute(options.technology
91
+ ? { technology: options.technology }
92
+ : { cache_key: CACHE_KEY, cache_paths: ['Pods'] }, {}, testConfigNoPeer);
93
+ const scriptPath = join(dir, '__cache_pull.sh');
94
+ writeFileSync(scriptPath, script, 'utf-8');
95
+ let stdout = '';
96
+ try {
97
+ const result = await run('bash', [scriptPath], {
98
+ cwd: dir,
99
+ encoding: 'utf-8',
100
+ env: {
101
+ ...process.env,
102
+ PATH: `${bin}:${process.env.PATH}`,
103
+ CIBUILD_CACHE_DAEMON: `http://127.0.0.1:${port}`,
104
+ CIBUILD_CACHE_TOKEN: 'tok',
105
+ },
106
+ timeout: 20000,
107
+ });
108
+ stdout = String(result.stdout) + String(result.stderr);
109
+ }
110
+ catch (e) {
111
+ const err = e;
112
+ stdout = String(err.stdout ?? '') + String(err.stderr ?? '');
113
+ }
114
+ return { stdout, paths };
115
+ }
116
+ finally {
117
+ if (options.keyStatus !== 'dead') {
118
+ await new Promise((resolve) => server.close(() => resolve()));
119
+ }
120
+ rmSync(dir, { recursive: true, force: true });
121
+ }
122
+ }
123
+ describe('cache-pull with an explicit key, over the daemon', () => {
124
+ test('an empty cache is reported, and nothing else', async () => {
125
+ // 404 is the daemon's answer for `cache_miss`. The normal case on any
126
+ // first build: it must not start printing warnings.
127
+ const { stdout, paths } = await runPull({ keyStatus: 404 });
128
+ expect(paths).toEqual([`/cache/${CACHE_KEY}.tar.zst`]);
129
+ expect(stdout).toContain(`No cache found for key: ${CACHE_KEY}`);
130
+ expect(stdout).toContain('CACHE_SOURCE=cold');
131
+ expect(stdout).not.toContain('Warning');
132
+ });
133
+ test('an auth failure no longer looks like an empty cache', async () => {
134
+ const { stdout } = await runPull({ keyStatus: 401 });
135
+ expect(stdout).toContain('Warning: cache-pull could not restore');
136
+ expect(stdout).toContain('401');
137
+ // Still not fatal, and still cold: a build that cannot read a cache builds
138
+ // anyway. Only the silence is gone.
139
+ expect(stdout).toContain('CACHE_SOURCE=cold');
140
+ });
141
+ test.each([
142
+ ['a rejected key', 400],
143
+ ['a busy cache', 503],
144
+ ['a daemon that broke', 500],
145
+ ])('%s says so, with the status', async (_label, status) => {
146
+ const { stdout } = await runPull({ keyStatus: status });
147
+ expect(stdout).toContain('Warning: cache-pull could not restore');
148
+ expect(stdout).toContain(String(status));
149
+ });
150
+ test('a daemon that is not listening says so, rather than reporting a miss', async () => {
151
+ const { stdout } = await runPull({ keyStatus: 'dead' });
152
+ expect(stdout).toContain('Warning: cache-pull could not restore');
153
+ // curl's own words for it. There is no HTTP status to report — which is
154
+ // itself the distinction from every case above.
155
+ expect(stdout).toMatch(/Failed to connect|Couldn't connect|Connection refused/u);
156
+ });
157
+ test('a hit is still a hit', async () => {
158
+ const { stdout } = await runPull({ keyStatus: 200 });
159
+ expect(stdout).toContain('Cache found (daemon), extracting...');
160
+ expect(stdout).toContain('CACHE_SOURCE=daemon');
161
+ expect(stdout).not.toContain('Warning');
162
+ });
163
+ });
164
+ describe('cache-pull from a preset, over the daemon', () => {
165
+ test('an empty cache is quiet through the scope fallback too', async () => {
166
+ const { stdout, paths } = await runPull({ keyStatus: 404, technology: 'npm' });
167
+ // Exact key first, then the scope's newest — the shape the fallback needs.
168
+ expect(paths).toHaveLength(2);
169
+ expect(paths[1]).toContain('/cache/scope/');
170
+ expect(stdout).toContain('No cache found for key: npm-');
171
+ expect(stdout).not.toContain('Warning');
172
+ });
173
+ test('a broken daemon is named on the preset path as well', async () => {
174
+ // The two forms emit the same diagnosis from one definition. They used to
175
+ // carry byte-identical copies of the upload block, and that is exactly how
176
+ // the push-side defect got in.
177
+ const { stdout } = await runPull({ keyStatus: 401, technology: 'npm' });
178
+ expect(stdout).toContain('Warning: cache-pull could not restore');
179
+ expect(stdout).toContain('401');
180
+ expect(stdout).toContain('CACHE_SOURCE=cold');
181
+ });
182
+ });
183
+ //# sourceMappingURL=cache-pull-daemon.test.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"cache.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/cache.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAC7C,OAAO,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAkBxD;;;;;;GAMG;AACH,MAAM,WAAW,WAAW;IAC1B,4HAA4H;IAC5H,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,sHAAsH;IACtH,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,yGAAyG;IACzG,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,eAAO,MAAM,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAUrD,CAAC;AAgLF;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB;AAED;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,qBAAqB,CAAC,EAAE,OAAO,CAAC;CACjC;AAED;;;GAGG;AACH,qBAAa,qBAAsB,SAAQ,gBAAgB;IACnD,OAAO,CAAC,MAAM,EAAE,eAAe,EAAE,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,EAAE,QAAQ,GAAG,OAAO,CAAC,OAAO,CAAC;YA0HzF,iBAAiB;CA0MhC;AAED;;;GAGG;AACH,qBAAa,qBAAsB,SAAQ,gBAAgB;IACnD,OAAO,CAAC,MAAM,EAAE,eAAe,EAAE,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,EAAE,QAAQ,GAAG,OAAO,CAAC,OAAO,CAAC;YAkLzF,iBAAiB;CAgJhC"}
1
+ {"version":3,"file":"cache.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/cache.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAC7C,OAAO,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAkBxD;;;;;;GAMG;AACH,MAAM,WAAW,WAAW;IAC1B,4HAA4H;IAC5H,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,sHAAsH;IACtH,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,yGAAyG;IACzG,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,eAAO,MAAM,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAUrD,CAAC;AAuPF;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB;AAED;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,qBAAqB,CAAC,EAAE,OAAO,CAAC;CACjC;AAED;;;GAGG;AACH,qBAAa,qBAAsB,SAAQ,gBAAgB;IACnD,OAAO,CAAC,MAAM,EAAE,eAAe,EAAE,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,EAAE,QAAQ,GAAG,OAAO,CAAC,OAAO,CAAC;YA6HzF,iBAAiB;CA0MhC;AAED;;;GAGG;AACH,qBAAa,qBAAsB,SAAQ,gBAAgB;IACnD,OAAO,CAAC,MAAM,EAAE,eAAe,EAAE,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,EAAE,QAAQ,GAAG,OAAO,CAAC,OAAO,CAAC;YAkLzF,iBAAiB;CAgJhC"}