@invarn/cibuild 2.5.8 → 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.
- package/dist/cli.cjs +10 -10
- package/dist/src/envman/envstore-round-trip.test.d.ts +27 -0
- package/dist/src/envman/envstore-round-trip.test.d.ts.map +1 -0
- package/dist/src/envman/envstore-round-trip.test.js +130 -0
- package/dist/src/envman/shim.d.ts +16 -0
- package/dist/src/envman/shim.d.ts.map +1 -0
- package/dist/src/envman/shim.js +66 -0
- package/dist/src/runner.d.ts.map +1 -1
- package/dist/src/runner.js +2 -50
- package/dist/src/yaml/steps/base.d.ts.map +1 -1
- package/dist/src/yaml/steps/base.js +11 -4
- package/dist/src/yaml/steps/cache-pull-daemon.test.d.ts +27 -0
- package/dist/src/yaml/steps/cache-pull-daemon.test.d.ts.map +1 -0
- package/dist/src/yaml/steps/cache-pull-daemon.test.js +183 -0
- package/dist/src/yaml/steps/cache-push-daemon.test.d.ts +27 -0
- package/dist/src/yaml/steps/cache-push-daemon.test.d.ts.map +1 -0
- package/dist/src/yaml/steps/cache-push-daemon.test.js +132 -0
- package/dist/src/yaml/steps/cache.d.ts.map +1 -1
- package/dist/src/yaml/steps/cache.js +131 -19
- package/dist/src/yaml/steps/envstore-loader.test.d.ts +15 -0
- package/dist/src/yaml/steps/envstore-loader.test.d.ts.map +1 -0
- package/dist/src/yaml/steps/envstore-loader.test.js +143 -0
- package/dist/src/yaml/steps/steps.test.js +20 -9
- package/package.json +1 -1
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cache-push` with an explicit `cache_key`, over the daemon.
|
|
3
|
+
*
|
|
4
|
+
* Every curated Invarn template uses the explicit-key form — `cache_key:
|
|
5
|
+
* pods-{{checksum "Podfile.lock"}}-$CIBUILD_GIT_BRANCH` and its Gradle/KMM
|
|
6
|
+
* siblings — for both halves of the pair. The pull side assigns the shell
|
|
7
|
+
* variable the daemon URL interpolates; the push side did not, and there is
|
|
8
|
+
* no `set -u`, so `$CACHE_KEY` expanded to nothing and the upload went to
|
|
9
|
+
* `<daemon>/cache/.tar.zst`. The daemon requires at least one character
|
|
10
|
+
* before `.tar.zst` and answers 400, which is exactly what the first real
|
|
11
|
+
* `invarn setup` proof logged on 2026-08-30:
|
|
12
|
+
*
|
|
13
|
+
* curl: (22) The requested URL returned error: 400
|
|
14
|
+
* Warning: failed to upload cache to the daemon
|
|
15
|
+
* [step_6] ✓ Success (109ms)
|
|
16
|
+
*
|
|
17
|
+
* Nothing was ever stored, so the pull could never hit, and every iOS build
|
|
18
|
+
* paid a cold `pod install`.
|
|
19
|
+
*
|
|
20
|
+
* These tests RUN the generated script. A containment check on the script
|
|
21
|
+
* text cannot see this defect: the text contains the literal `$CACHE_KEY`
|
|
22
|
+
* either way, which is why the existing assertion passed throughout. `curl`
|
|
23
|
+
* and `zstd` are stubbed onto PATH so the pipeline completes on any host and
|
|
24
|
+
* the URL actually requested can be read back.
|
|
25
|
+
*/
|
|
26
|
+
import { describe, test, expect } from '@jest/globals';
|
|
27
|
+
import { execFileSync } from 'child_process';
|
|
28
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync, chmodSync } from 'fs';
|
|
29
|
+
import { join } from 'path';
|
|
30
|
+
import { tmpdir } from 'os';
|
|
31
|
+
import { CachePushStepExecutor } from './cache.js';
|
|
32
|
+
import { testConfigNoPeer } from './test-config.js';
|
|
33
|
+
const CACHE_KEY = 'pods-abc123def456-main';
|
|
34
|
+
/**
|
|
35
|
+
* Generates the explicit-key push step, runs it, and reports what `curl` was
|
|
36
|
+
* actually asked to do.
|
|
37
|
+
*
|
|
38
|
+
* @param curlExit exit status the stub returns, and `curlBody` is what it
|
|
39
|
+
* prints — so a rejection can be exercised without a daemon.
|
|
40
|
+
*/
|
|
41
|
+
async function runPush({ curlExit = 0, curlBody = '' } = {}) {
|
|
42
|
+
const dir = mkdtempSync(join(tmpdir(), 'cibuild-push-'));
|
|
43
|
+
try {
|
|
44
|
+
// Something real to cache, so the step does not take its empty branch.
|
|
45
|
+
const payload = join(dir, 'Pods');
|
|
46
|
+
mkdirSync(payload, { recursive: true });
|
|
47
|
+
writeFileSync(join(payload, 'thing.txt'), 'contents\n', 'utf-8');
|
|
48
|
+
const bin = join(dir, 'bin');
|
|
49
|
+
mkdirSync(bin, { recursive: true });
|
|
50
|
+
const urlLog = join(dir, 'curl-urls.txt');
|
|
51
|
+
// Records the URL, drains stdin so the upstream pipe does not break.
|
|
52
|
+
writeFileSync(join(bin, 'curl'), [
|
|
53
|
+
'#!/bin/bash',
|
|
54
|
+
'for arg in "$@"; do',
|
|
55
|
+
' case "$arg" in',
|
|
56
|
+
` http*) printf '%s\\n' "$arg" >> "${urlLog}" ;;`,
|
|
57
|
+
' esac',
|
|
58
|
+
'done',
|
|
59
|
+
'cat > /dev/null',
|
|
60
|
+
curlBody ? `printf '%s' ${JSON.stringify(curlBody)}` : ':',
|
|
61
|
+
`exit ${curlExit}`,
|
|
62
|
+
].join('\n'), 'utf-8');
|
|
63
|
+
chmodSync(join(bin, 'curl'), 0o755);
|
|
64
|
+
// zstd is not on every host, and without it the pipeline fails before
|
|
65
|
+
// curl is ever reached — which would make these tests pass vacuously.
|
|
66
|
+
writeFileSync(join(bin, 'zstd'), '#!/bin/bash\ncat\n', 'utf-8');
|
|
67
|
+
chmodSync(join(bin, 'zstd'), 0o755);
|
|
68
|
+
const { script } = await new CachePushStepExecutor().execute({ cache_key: CACHE_KEY, cache_paths: ['Pods'] }, {}, testConfigNoPeer);
|
|
69
|
+
const scriptPath = join(dir, '__cache_push.sh');
|
|
70
|
+
writeFileSync(scriptPath, script, 'utf-8');
|
|
71
|
+
let stdout = '';
|
|
72
|
+
try {
|
|
73
|
+
stdout = execFileSync('bash', [scriptPath], {
|
|
74
|
+
cwd: dir,
|
|
75
|
+
encoding: 'utf-8',
|
|
76
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
77
|
+
env: {
|
|
78
|
+
...process.env,
|
|
79
|
+
PATH: `${bin}:${process.env.PATH}`,
|
|
80
|
+
CIBUILD_CACHE_DAEMON: 'http://daemon.invalid',
|
|
81
|
+
CIBUILD_CACHE_TOKEN: 'tok',
|
|
82
|
+
},
|
|
83
|
+
timeout: 20000,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
catch (e) {
|
|
87
|
+
const err = e;
|
|
88
|
+
stdout = String(err.stdout ?? '') + String(err.stderr ?? '');
|
|
89
|
+
}
|
|
90
|
+
const urls = existsSync(urlLog)
|
|
91
|
+
? readFileSync(urlLog, 'utf-8').split('\n').filter(Boolean)
|
|
92
|
+
: [];
|
|
93
|
+
return { stdout, urls };
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
rmSync(dir, { recursive: true, force: true });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
describe('cache-push with an explicit key, over the daemon', () => {
|
|
100
|
+
test('uploads under the key it was given', async () => {
|
|
101
|
+
const { urls } = await runPush();
|
|
102
|
+
expect(urls).toHaveLength(1);
|
|
103
|
+
expect(urls[0]).toBe(`http://daemon.invalid/cache/${CACHE_KEY}.tar.zst`);
|
|
104
|
+
});
|
|
105
|
+
test('never uploads to an empty key', async () => {
|
|
106
|
+
// The defect, named. `<daemon>/cache/.tar.zst` is what an unassigned
|
|
107
|
+
// `$CACHE_KEY` produces, and what the daemon answers 400 to.
|
|
108
|
+
const { urls } = await runPush();
|
|
109
|
+
for (const url of urls) {
|
|
110
|
+
expect(url).not.toMatch(/\/cache\/\.tar\.zst$/);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
test('the step survives a rejection rather than failing the build', async () => {
|
|
114
|
+
// Deliberate: a build that produced good output should not be failed by a
|
|
115
|
+
// cache upload. Whether the STEP should still report success having
|
|
116
|
+
// stored nothing is a separate, open question.
|
|
117
|
+
const { stdout } = await runPush({ curlExit: 22 });
|
|
118
|
+
expect(stdout).toContain('Warning: failed to upload cache to the daemon');
|
|
119
|
+
});
|
|
120
|
+
test('a rejection says why, not just that it happened', async () => {
|
|
121
|
+
// `curl -f` throws the response body away, so the daemon's own reason —
|
|
122
|
+
// `invalid_cache_key`, `cache_busy`, an auth failure — never reached the
|
|
123
|
+
// log. All the reader got was an exit code, which is how a one-line bug
|
|
124
|
+
// stayed open behind a warning that named nothing.
|
|
125
|
+
const { stdout } = await runPush({
|
|
126
|
+
curlExit: 22,
|
|
127
|
+
curlBody: '{"error":"invalid_cache_key"}',
|
|
128
|
+
});
|
|
129
|
+
expect(stdout).toContain('invalid_cache_key');
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
//# sourceMappingURL=cache-push-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;
|
|
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"}
|
|
@@ -86,13 +86,120 @@ function resolvePresetChain(technology) {
|
|
|
86
86
|
}
|
|
87
87
|
return chain;
|
|
88
88
|
}
|
|
89
|
+
/** The token `%{http_code}` is reported under; see `daemonCurl`. */
|
|
90
|
+
const DAEMON_STATUS_TOKEN = 'ci_http_status';
|
|
89
91
|
/**
|
|
90
92
|
* Emits the `curl` invocation the cache steps talk to the daemon with. The
|
|
91
93
|
* bearer token is per-build and only present on the Invarn runner, so it is
|
|
92
94
|
* expanded conditionally and the same script works without one.
|
|
95
|
+
*
|
|
96
|
+
* Every request also reports the HTTP status it got, on **stderr** — the
|
|
97
|
+
* response body is the tarball and goes to stdout, so a status written there
|
|
98
|
+
* would corrupt it. `%{stderr}` is curl 7.63+ (2018); an older curl prints the
|
|
99
|
+
* token literally instead of a number, which the reader below tolerates.
|
|
100
|
+
*
|
|
101
|
+
* `-f`, not `--fail-with-body`: on the pull side the error body would be piped
|
|
102
|
+
* straight into `zstd`. The status is what distinguishes the answers anyway,
|
|
103
|
+
* and it now arrives without the body.
|
|
93
104
|
*/
|
|
94
105
|
function daemonCurl(args) {
|
|
95
|
-
return `curl -fsS \${CIBUILD_CACHE_TOKEN:+-H "Authorization: Bearer $CIBUILD_CACHE_TOKEN"} ${args}`;
|
|
106
|
+
return `curl -fsS -w '%{stderr}${DAEMON_STATUS_TOKEN}=%{http_code}\\n' \${CIBUILD_CACHE_TOKEN:+-H "Authorization: Bearer $CIBUILD_CACHE_TOKEN"} ${args}`;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Emitted once inside a daemon pull branch, before the first fetch: a file for
|
|
110
|
+
* curl's stderr, and the reader that turns it into one line — or into nothing
|
|
111
|
+
* at all when the daemon simply had no such entry.
|
|
112
|
+
*
|
|
113
|
+
* `{ curl | zstd | tar; } 2>/dev/null` used to swallow curl's message
|
|
114
|
+
* entirely, so a 401, a 400, a 503, a 500 and a daemon that was not listening
|
|
115
|
+
* all printed the same `No cache found for key: …` as an empty cache. That is
|
|
116
|
+
* the same class as the push-side defect: the instrument reported the normal
|
|
117
|
+
* case for every abnormal one.
|
|
118
|
+
*
|
|
119
|
+
* **A miss stays quiet and a failure gets loud.** A cold cache is the normal
|
|
120
|
+
* case and must not start printing errors; the point is only that a broken
|
|
121
|
+
* daemon stops looking like one.
|
|
122
|
+
*/
|
|
123
|
+
function emitDaemonPullDiagnostics(indent = '') {
|
|
124
|
+
return [
|
|
125
|
+
`${indent}__ci_cache_diag="\${TMPDIR:-/tmp}/cibuild-cache-pull.$$.diag"`,
|
|
126
|
+
`${indent}__ci_cache_daemon_said() {`,
|
|
127
|
+
`${indent} local __said=''`,
|
|
128
|
+
`${indent} if [ -s "$__ci_cache_diag" ]; then`,
|
|
129
|
+
`${indent} __said=$(tr '\\n' ' ' < "$__ci_cache_diag")`,
|
|
130
|
+
`${indent} fi`,
|
|
131
|
+
`${indent} case "$__said" in`,
|
|
132
|
+
// 404 is the daemon's answer for `cache_miss` and `no_cache_for_scope` —
|
|
133
|
+
// an empty cache, which is normal. The second pattern reads curl's own
|
|
134
|
+
// English for a curl too old to honour `%{stderr}`, so neither reader
|
|
135
|
+
// failing alone can turn a cold cache noisy.
|
|
136
|
+
`${indent} *${DAEMON_STATUS_TOKEN}=404*|*'error: 404'*) __said='' ;;`,
|
|
137
|
+
`${indent} esac`,
|
|
138
|
+
// Always exits 0. A non-zero return would be the assignment's status at
|
|
139
|
+
// every call site, and under errexit that ends the step on a cache miss.
|
|
140
|
+
`${indent} printf '%s' "\${__said:0:300}"`,
|
|
141
|
+
`${indent}}`,
|
|
142
|
+
];
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* The `else` arm of a daemon fetch: says why it did not restore, unless the
|
|
146
|
+
* daemon simply had no such entry. Never fatal — a build that cannot read a
|
|
147
|
+
* cache still builds, it just builds cold.
|
|
148
|
+
*/
|
|
149
|
+
function emitDaemonPullFailureDiagnosis(indent, what) {
|
|
150
|
+
return [
|
|
151
|
+
`${indent}__ci_cache_why=$(__ci_cache_daemon_said)`,
|
|
152
|
+
`${indent}if [ -n "$__ci_cache_why" ]; then`,
|
|
153
|
+
`${indent} echo "Warning: cache-pull could not restore ${what} from the daemon — $__ci_cache_why"`,
|
|
154
|
+
`${indent}fi`,
|
|
155
|
+
];
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The same request, keeping the response body when the daemon refuses.
|
|
159
|
+
*
|
|
160
|
+
* `-f` throws the body away, so a rejection reached the log as an exit code
|
|
161
|
+
* and nothing else. The daemon distinguishes `invalid_cache_key` (400), a
|
|
162
|
+
* retryable `cache_busy` (503) and an auth failure (401); all three arrived
|
|
163
|
+
* as the same `curl: (22)` line under the same warning, which is how a
|
|
164
|
+
* one-line bug stayed open behind it. Push only — on the pull side the body
|
|
165
|
+
* would be piped into `zstd`.
|
|
166
|
+
*
|
|
167
|
+
* `--fail-with-body` is curl 7.76+ (2021); every runner image is well past
|
|
168
|
+
* it. An older curl rejects the option and the step still degrades to
|
|
169
|
+
* today's behaviour, with the reason in the message.
|
|
170
|
+
*/
|
|
171
|
+
function daemonCurlKeepingErrorBody(args) {
|
|
172
|
+
return `curl --fail-with-body -sS \${CIBUILD_CACHE_TOKEN:+-H "Authorization: Bearer $CIBUILD_CACHE_TOKEN"} ${args}`;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* The daemon half of `cache-push`, shared by both step forms.
|
|
176
|
+
*
|
|
177
|
+
* Shared because it did not used to be. The two forms carried byte-identical
|
|
178
|
+
* copies of this block, and the explicit-key one reached it without ever
|
|
179
|
+
* assigning the `CACHE_KEY` it interpolates — the preset form assigned it,
|
|
180
|
+
* the other only assigned `CACHE_FILE`. There is no `set -u`, so the PUT went
|
|
181
|
+
* to `<daemon>/cache/.tar.zst`, the daemon answered 400, and the step
|
|
182
|
+
* reported success. Every curated Invarn template uses the explicit-key form,
|
|
183
|
+
* so nothing was ever stored by any of them. One definition cannot diverge
|
|
184
|
+
* from itself.
|
|
185
|
+
*/
|
|
186
|
+
function emitDaemonPushCommands() {
|
|
187
|
+
return [
|
|
188
|
+
` if __ci_push_err=$(tar -cf - "\${PATHS_TO_CACHE[@]}" 2>/dev/null | zstd -3 | ${daemonCurlKeepingErrorBody('-T - "$CIBUILD_CACHE_DAEMON/cache/$CACHE_KEY.tar.zst"')} 2>&1); then`,
|
|
189
|
+
' echo "Cache created successfully"',
|
|
190
|
+
' else',
|
|
191
|
+
// Never fatal: a build that produced good output should not be failed by
|
|
192
|
+
// a cache upload, and the next build simply misses. Saying *why* costs
|
|
193
|
+
// nothing and is the difference between a warning and a diagnosis.
|
|
194
|
+
" __ci_push_msg=$(printf '%s' \"$__ci_push_err\" | tr '\\n' ' ')",
|
|
195
|
+
' __ci_push_msg=${__ci_push_msg:0:300}',
|
|
196
|
+
' if [ -n "$__ci_push_msg" ]; then',
|
|
197
|
+
' echo "Warning: failed to upload cache to the daemon — $__ci_push_msg"',
|
|
198
|
+
' else',
|
|
199
|
+
' echo "Warning: failed to upload cache to the daemon"',
|
|
200
|
+
' fi',
|
|
201
|
+
' fi',
|
|
202
|
+
];
|
|
96
203
|
}
|
|
97
204
|
/**
|
|
98
205
|
* Restores a cache through the daemon rather than a shared directory.
|
|
@@ -109,12 +216,18 @@ function daemonCurl(args) {
|
|
|
109
216
|
function emitDaemonPullCommands() {
|
|
110
217
|
return [
|
|
111
218
|
'echo "Cache daemon: $CIBUILD_CACHE_DAEMON"',
|
|
112
|
-
|
|
113
|
-
//
|
|
114
|
-
|
|
219
|
+
...emitDaemonPullDiagnostics(),
|
|
220
|
+
// Exact key. zstd's and tar's stderr is silenced — a miss is the normal
|
|
221
|
+
// case and they both editorialise about it; pipefail still carries the
|
|
222
|
+
// exit status. curl's own stderr is kept, in the file the diagnostics
|
|
223
|
+
// above read: it is the only thing that knows *why*.
|
|
224
|
+
`if { ${daemonCurl('"$CIBUILD_CACHE_DAEMON/cache/$CACHE_KEY.tar.zst" 2>"$__ci_cache_diag"')} | zstd -dc | tar -xf - -C /; } 2>/dev/null; then`,
|
|
115
225
|
' echo "Cache found (daemon), extracting..."',
|
|
116
226
|
' echo "CACHE_SOURCE=daemon CACHE_KEY=$CACHE_KEY"',
|
|
117
227
|
'else',
|
|
228
|
+
// Said before the fallback is attempted, so the reason names the request
|
|
229
|
+
// it belongs to rather than whichever request happened to fail last.
|
|
230
|
+
...emitDaemonPullFailureDiagnosis(' ', '$CACHE_KEY'),
|
|
118
231
|
// Scope fallback: the newest tarball for this <keyPrefix>-<projectId>.
|
|
119
232
|
// Gradle/Xcode re-hash their inputs on top of the warm directory, hitting
|
|
120
233
|
// the unchanged work and rebuilding only what actually moved.
|
|
@@ -130,10 +243,11 @@ function emitDaemonPullCommands() {
|
|
|
130
243
|
// than seed from ancient state. Exact-key hits are unaffected.
|
|
131
244
|
' if [ "$__ci_fb_age_days" -le 30 ]; then',
|
|
132
245
|
' echo "Cache fallback (daemon): using prior tarball $__ci_fb_key (${__ci_fb_age_days}d old)"',
|
|
133
|
-
` if { ${daemonCurl('"$CIBUILD_CACHE_DAEMON/cache/$__ci_fb_key.tar.zst"')} | zstd -dc | tar -xf - -C /; } 2>/dev/null; then`,
|
|
246
|
+
` if { ${daemonCurl('"$CIBUILD_CACHE_DAEMON/cache/$__ci_fb_key.tar.zst" 2>"$__ci_cache_diag"')} | zstd -dc | tar -xf - -C /; } 2>/dev/null; then`,
|
|
134
247
|
' echo "CACHE_SOURCE=fallback_daemon CACHE_KEY=$CACHE_KEY FALLBACK_KEY=$__ci_fb_key"',
|
|
135
248
|
' else',
|
|
136
249
|
' echo "Cache fallback $__ci_fb_key could not be fetched — going cold"',
|
|
250
|
+
...emitDaemonPullFailureDiagnosis(' ', '$__ci_fb_key'),
|
|
137
251
|
' echo "CACHE_SOURCE=cold CACHE_KEY=$CACHE_KEY"',
|
|
138
252
|
' fi',
|
|
139
253
|
' else',
|
|
@@ -145,6 +259,7 @@ function emitDaemonPullCommands() {
|
|
|
145
259
|
' echo "CACHE_SOURCE=cold CACHE_KEY=$CACHE_KEY"',
|
|
146
260
|
' fi',
|
|
147
261
|
'fi',
|
|
262
|
+
'rm -f "$__ci_cache_diag"',
|
|
148
263
|
];
|
|
149
264
|
}
|
|
150
265
|
/**
|
|
@@ -217,13 +332,16 @@ export class CachePullStepExecutor extends BaseStepExecutor {
|
|
|
217
332
|
// Transport split — see the preset path. An explicit-key step has no scope
|
|
218
333
|
// to fall back to, so the daemon side is the exact key or nothing.
|
|
219
334
|
commands.push('if [ -n "$CIBUILD_CACHE_DAEMON" ]; then');
|
|
220
|
-
commands.push(
|
|
335
|
+
commands.push(...emitDaemonPullDiagnostics(' '));
|
|
336
|
+
commands.push(` if { ${daemonCurl('"$CIBUILD_CACHE_DAEMON/cache/$CACHE_KEY.tar.zst" 2>"$__ci_cache_diag"')} | zstd -dc | tar -xf - -C /; } 2>/dev/null; then`);
|
|
221
337
|
commands.push(' echo "Cache found (daemon), extracting..."');
|
|
222
338
|
commands.push(' echo "CACHE_SOURCE=daemon"');
|
|
223
339
|
commands.push(' else');
|
|
340
|
+
commands.push(...emitDaemonPullFailureDiagnosis(' ', '$CACHE_KEY'));
|
|
224
341
|
commands.push(` echo "No cache found for key: ${this.escapeBash(cacheKey)}"`);
|
|
225
342
|
commands.push(' echo "CACHE_SOURCE=cold"');
|
|
226
343
|
commands.push(' fi');
|
|
344
|
+
commands.push(' rm -f "$__ci_cache_diag"');
|
|
227
345
|
commands.push('else');
|
|
228
346
|
commands.push('if [ -f "$CACHE_FILE" ]; then');
|
|
229
347
|
commands.push(' echo "Cache found (local), extracting..."');
|
|
@@ -491,7 +609,11 @@ export class CachePushStepExecutor extends BaseStepExecutor {
|
|
|
491
609
|
// Generate cache file name based on cache key
|
|
492
610
|
commands.push('');
|
|
493
611
|
commands.push('# Generate cache file path');
|
|
494
|
-
|
|
612
|
+
// CACHE_KEY first, CACHE_FILE derived from it — exactly as the pull side
|
|
613
|
+
// does. This step used to set only CACHE_FILE while the daemon URL below
|
|
614
|
+
// interpolated `$CACHE_KEY`; see emitDaemonPushCommands.
|
|
615
|
+
commands.push(`CACHE_KEY="${this.escapeBash(cacheKey)}"`);
|
|
616
|
+
commands.push('CACHE_FILE="$CACHE_DIR/$CACHE_KEY.tar.zst"');
|
|
495
617
|
if (isDebugMode) {
|
|
496
618
|
commands.push('echo "Debug mode enabled"');
|
|
497
619
|
commands.push('echo "Cache file: $CACHE_FILE"');
|
|
@@ -561,11 +683,7 @@ export class CachePushStepExecutor extends BaseStepExecutor {
|
|
|
561
683
|
}
|
|
562
684
|
// Transport split — see the preset path.
|
|
563
685
|
commands.push(' if [ -n "$CIBUILD_CACHE_DAEMON" ]; then');
|
|
564
|
-
commands.push(
|
|
565
|
-
commands.push(' echo "Cache created successfully"');
|
|
566
|
-
commands.push(' else');
|
|
567
|
-
commands.push(' echo "Warning: failed to upload cache to the daemon"');
|
|
568
|
-
commands.push(' fi');
|
|
686
|
+
commands.push(...emitDaemonPushCommands());
|
|
569
687
|
commands.push(' else');
|
|
570
688
|
// Atomic write into a per-process-unique temp (CBR-12). The "runs serialized
|
|
571
689
|
// per runner" assumption behind a fixed "$CACHE_FILE.tmp" is false with two VM
|
|
@@ -692,13 +810,7 @@ export class CachePushStepExecutor extends BaseStepExecutor {
|
|
|
692
810
|
// so there is no temp file to collide or orphan, and the daemon does the
|
|
693
811
|
// atomic temp+rename on the host side where the cache actually lives.
|
|
694
812
|
commands.push(' if [ -n "$CIBUILD_CACHE_DAEMON" ]; then');
|
|
695
|
-
commands.push(
|
|
696
|
-
commands.push(' echo "Cache created successfully"');
|
|
697
|
-
commands.push(' else');
|
|
698
|
-
// Never fatal: a build that produced good output should not be failed by a
|
|
699
|
-
// cache upload, and the next build simply misses.
|
|
700
|
-
commands.push(' echo "Warning: failed to upload cache to the daemon"');
|
|
701
|
-
commands.push(' fi');
|
|
813
|
+
commands.push(...emitDaemonPushCommands());
|
|
702
814
|
commands.push(' else');
|
|
703
815
|
// Per-process-unique temp (CBR-12). The old fixed "$CACHE_FILE.tmp" assumed
|
|
704
816
|
// a single serialized writer — false with two VM slots, and it collides with
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The envstore loader carried by every generated step.
|
|
3
|
+
*
|
|
4
|
+
* These tests drive the *reader* — the preamble `createBashScript` prepends —
|
|
5
|
+
* directly, with hostile envstore content written by hand. Going through a
|
|
6
|
+
* producer such as git-clone would only prove the one value that producer
|
|
7
|
+
* happens to write; the defect lives in the reader, so the reader is what gets
|
|
8
|
+
* the hostile input.
|
|
9
|
+
*
|
|
10
|
+
* Each case actually runs bash. Assertions are made on files the script body
|
|
11
|
+
* writes with `printf '%s'`, not on stdout, so a value's own newlines and
|
|
12
|
+
* quoting cannot be confused with the test's framing.
|
|
13
|
+
*/
|
|
14
|
+
export {};
|
|
15
|
+
//# sourceMappingURL=envstore-loader.test.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"envstore-loader.test.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/envstore-loader.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG"}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The envstore loader carried by every generated step.
|
|
3
|
+
*
|
|
4
|
+
* These tests drive the *reader* — the preamble `createBashScript` prepends —
|
|
5
|
+
* directly, with hostile envstore content written by hand. Going through a
|
|
6
|
+
* producer such as git-clone would only prove the one value that producer
|
|
7
|
+
* happens to write; the defect lives in the reader, so the reader is what gets
|
|
8
|
+
* the hostile input.
|
|
9
|
+
*
|
|
10
|
+
* Each case actually runs bash. Assertions are made on files the script body
|
|
11
|
+
* writes with `printf '%s'`, not on stdout, so a value's own newlines and
|
|
12
|
+
* quoting cannot be confused with the test's framing.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, test, expect, beforeEach, afterEach } from '@jest/globals';
|
|
15
|
+
import { spawnSync } from 'child_process';
|
|
16
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'fs';
|
|
17
|
+
import { join } from 'path';
|
|
18
|
+
import { tmpdir } from 'os';
|
|
19
|
+
import { BaseStepExecutor } from './base.js';
|
|
20
|
+
/** Exposes the protected preamble builder so the reader can be tested on its own. */
|
|
21
|
+
class PreambleProbe extends BaseStepExecutor {
|
|
22
|
+
async execute(_inputs, _env, _config) {
|
|
23
|
+
throw new Error('not used');
|
|
24
|
+
}
|
|
25
|
+
build(scriptContent) {
|
|
26
|
+
return this.createBashScript(scriptContent, 'envstore-loader-probe');
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
describe('envstore loader', () => {
|
|
30
|
+
let workDir;
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
workDir = mkdtempSync(join(tmpdir(), 'envstore-loader-'));
|
|
33
|
+
});
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
rmSync(workDir, { recursive: true, force: true });
|
|
36
|
+
});
|
|
37
|
+
/**
|
|
38
|
+
* Writes an envstore, runs the generated preamble over it, and captures the
|
|
39
|
+
* named variables as the step body sees them.
|
|
40
|
+
*/
|
|
41
|
+
function run(envs, capture, extraBody = '') {
|
|
42
|
+
const storePath = join(workDir, 'envstore.json');
|
|
43
|
+
const outDir = join(workDir, 'captured');
|
|
44
|
+
mkdirSync(outDir, { recursive: true });
|
|
45
|
+
writeFileSync(storePath, JSON.stringify({ envs: envs.map(e => ({ ...e, sensitive: false })) }), 'utf-8');
|
|
46
|
+
const body = [
|
|
47
|
+
...capture.map(name => `printf '%s' "\${${name}-}" > "$CAPTURE_DIR/${name}"`),
|
|
48
|
+
extraBody,
|
|
49
|
+
].join('\n');
|
|
50
|
+
const scriptPath = join(workDir, 'step.sh');
|
|
51
|
+
writeFileSync(scriptPath, new PreambleProbe().build(body), 'utf-8');
|
|
52
|
+
const proc = spawnSync('bash', [scriptPath], {
|
|
53
|
+
cwd: workDir,
|
|
54
|
+
encoding: 'utf-8',
|
|
55
|
+
env: {
|
|
56
|
+
...process.env,
|
|
57
|
+
ENVMAN_ENVSTORE_PATH: storePath,
|
|
58
|
+
CAPTURE_DIR: outDir,
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
return {
|
|
62
|
+
status: proc.status,
|
|
63
|
+
stderr: proc.stderr ?? '',
|
|
64
|
+
captured(name) {
|
|
65
|
+
const file = join(outDir, name);
|
|
66
|
+
return existsSync(file) ? readFileSync(file, 'utf-8') : undefined;
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
test('a value containing newlines round-trips byte for byte', () => {
|
|
71
|
+
const body = 'first line\nsecond line\nthird line';
|
|
72
|
+
const result = run([{ key: 'GIT_CLONE_COMMIT_MESSAGE_BODY', value: body }], ['GIT_CLONE_COMMIT_MESSAGE_BODY']);
|
|
73
|
+
expect(result.status).toBe(0);
|
|
74
|
+
expect(result.captured('GIT_CLONE_COMMIT_MESSAGE_BODY')).toBe(body);
|
|
75
|
+
});
|
|
76
|
+
test('security: a value whose lines look like assignments sets nothing', () => {
|
|
77
|
+
const hostile = 'looks harmless\nPATH=/tmp/attacker-controlled\nNODE_OPTIONS=--require=/tmp/evil.js';
|
|
78
|
+
const result = run([{ key: 'GIT_CLONE_COMMIT_MESSAGE_BODY', value: hostile }], ['GIT_CLONE_COMMIT_MESSAGE_BODY', 'PATH', 'NODE_OPTIONS']);
|
|
79
|
+
expect(result.status).toBe(0);
|
|
80
|
+
// The value arrives intact...
|
|
81
|
+
expect(result.captured('GIT_CLONE_COMMIT_MESSAGE_BODY')).toBe(hostile);
|
|
82
|
+
// ...and none of its lines became a variable. Both are compared against
|
|
83
|
+
// what the parent environment held, which is what "unchanged" means here:
|
|
84
|
+
// the test runner itself sets NODE_OPTIONS.
|
|
85
|
+
expect(result.captured('PATH')).toBe(process.env.PATH ?? '');
|
|
86
|
+
expect(result.captured('NODE_OPTIONS')).toBe(process.env.NODE_OPTIONS ?? '');
|
|
87
|
+
expect(result.captured('NODE_OPTIONS')).not.toContain('/tmp/evil.js');
|
|
88
|
+
});
|
|
89
|
+
test('awkward bytes in a value all survive unchanged', () => {
|
|
90
|
+
const cases = [
|
|
91
|
+
{ key: 'HAS_EQUALS', value: 'a=b=c' },
|
|
92
|
+
{ key: 'HAS_QUOTES', value: `single ' double " both '"` },
|
|
93
|
+
{ key: 'HAS_BACKTICKS', value: 'a `whoami` b' },
|
|
94
|
+
{ key: 'HAS_SUBSHELL', value: 'a $(whoami) b ${HOME} c' },
|
|
95
|
+
{ key: 'HAS_TRAILING_NEWLINE', value: 'ends with a newline\n' },
|
|
96
|
+
{ key: 'HAS_LONE_CR', value: 'before\rafter' },
|
|
97
|
+
{ key: 'HAS_BACKSLASHES', value: 'C:\\path\\to\\thing and \\n literal' },
|
|
98
|
+
{ key: 'HAS_LEADING_SPACE', value: ' padded ' },
|
|
99
|
+
];
|
|
100
|
+
const result = run(cases, cases.map(c => c.key));
|
|
101
|
+
expect(result.status).toBe(0);
|
|
102
|
+
for (const { key, value } of cases) {
|
|
103
|
+
expect({ key, value: result.captured(key) }).toEqual({ key, value });
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
test('a non-identifier key is skipped without failing the step under set -e', () => {
|
|
107
|
+
const result = run([
|
|
108
|
+
{ key: 'not a valid identifier', value: 'x' },
|
|
109
|
+
{ key: '2_LEADING_DIGIT', value: 'x' },
|
|
110
|
+
{ key: '', value: 'x' },
|
|
111
|
+
{ key: 'GOOD_KEY', value: 'kept' },
|
|
112
|
+
], ['GOOD_KEY'], 'echo reached-the-end');
|
|
113
|
+
expect(result.status).toBe(0);
|
|
114
|
+
expect(result.captured('GOOD_KEY')).toBe('kept');
|
|
115
|
+
expect(result.stderr).not.toContain('not a valid identifier');
|
|
116
|
+
});
|
|
117
|
+
test('regression: a multi-line commit message does not end the step', () => {
|
|
118
|
+
// The shape that killed the first real proof on 2026-08-30: git-clone
|
|
119
|
+
// publishes the commit body, and every later step died loading it.
|
|
120
|
+
const result = run([
|
|
121
|
+
{ key: 'GIT_CLONE_COMMIT_MESSAGE_SUBJECT', value: 'Add Invarn pipeline' },
|
|
122
|
+
{
|
|
123
|
+
key: 'GIT_CLONE_COMMIT_MESSAGE_BODY',
|
|
124
|
+
value: 'cibuild sets CIBUILD_GIT_BRANCH, so pre-execution validation asked for a\nvalue nothing provides.\n\nSecond paragraph.',
|
|
125
|
+
},
|
|
126
|
+
], ['GIT_CLONE_COMMIT_MESSAGE_SUBJECT'], 'echo step-completed');
|
|
127
|
+
expect(result.status).toBe(0);
|
|
128
|
+
expect(result.captured('GIT_CLONE_COMMIT_MESSAGE_SUBJECT')).toBe('Add Invarn pipeline');
|
|
129
|
+
expect(result.stderr).not.toContain('not a valid identifier');
|
|
130
|
+
});
|
|
131
|
+
test('an absent envstore leaves the step to run normally', () => {
|
|
132
|
+
const scriptPath = join(workDir, 'no-store.sh');
|
|
133
|
+
writeFileSync(scriptPath, new PreambleProbe().build('echo ran'), 'utf-8');
|
|
134
|
+
const proc = spawnSync('bash', [scriptPath], {
|
|
135
|
+
cwd: workDir,
|
|
136
|
+
encoding: 'utf-8',
|
|
137
|
+
env: { ...process.env, ENVMAN_ENVSTORE_PATH: join(workDir, 'missing.json') },
|
|
138
|
+
});
|
|
139
|
+
expect(proc.status).toBe(0);
|
|
140
|
+
expect(proc.stdout).toContain('ran');
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
//# sourceMappingURL=envstore-loader.test.js.map
|
|
@@ -218,17 +218,28 @@ describe('Step Implementations', () => {
|
|
|
218
218
|
mkdirSync(swiftpm, { recursive: true });
|
|
219
219
|
writeFileSync(join(swiftpm, 'Package.resolved'), '{"pins":[],"version":3}');
|
|
220
220
|
}
|
|
221
|
-
test('a
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
|
|
221
|
+
test('a daemon that is not listening is named once, and leaks no raw noise', async () => {
|
|
222
|
+
// A closed port: curl errors instantly with no server handle to leak.
|
|
223
|
+
//
|
|
224
|
+
// This used to assert that the output contained no `curl:` at all —
|
|
225
|
+
// curl's stderr was silenced whatever the failure mode was, which is
|
|
226
|
+
// how a 401, a 500 and a dead daemon all came to print the same
|
|
227
|
+
// `No cache found` as an empty cache. The contract is now narrower: a
|
|
228
|
+
// *miss* is silent (the 404 case, covered against a real listener in
|
|
229
|
+
// cache-pull-daemon.test.ts), a *failure* is named exactly once, and
|
|
230
|
+
// neither leaks the raw pipeline chatter that made the old blanket
|
|
231
|
+
// silence attractive in the first place.
|
|
232
|
+
const { stdout, stderr, combined } = await runIosPull(writeSpmFixture, {
|
|
229
233
|
CIBUILD_CACHE_DAEMON: 'http://127.0.0.1:1',
|
|
230
234
|
});
|
|
231
|
-
expect(
|
|
235
|
+
expect(stdout).toContain('Warning: cache-pull could not restore');
|
|
236
|
+
expect(stdout).toMatch(/Failed to connect|Couldn't connect|Connection refused/);
|
|
237
|
+
// Once, not once per request the step happens to make.
|
|
238
|
+
expect(stdout.match(/Warning: cache-pull could not restore/gu)).toHaveLength(1);
|
|
239
|
+
// Whatever curl said arrives inside that one line, on stdout. Nothing
|
|
240
|
+
// reaches the build log as loose interleaved stderr, and zstd/tar still
|
|
241
|
+
// do not editorialise about a stream that never arrived.
|
|
242
|
+
expect(stderr).not.toContain('curl:');
|
|
232
243
|
expect(combined).not.toContain('unexpected end of file');
|
|
233
244
|
// Still resolves the SPM key and reports a cold miss.
|
|
234
245
|
expect(stdout).toContain('CACHE_SOURCE=cold');
|