@invarn/cibuild 2.5.9 → 2.6.1

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,200 @@
1
+ /**
2
+ * `cache-pull` restores every cached path to the root it was recorded against.
3
+ *
4
+ * `cache-push` archives two kinds of entry in one tarball. A `~`- or `/`-rooted
5
+ * path is expanded and tar stores it with the leading `/` stripped
6
+ * (`/home/builder/.gradle/caches` -> `home/builder/.gradle/caches`). A
7
+ * project-relative path (`Pods`, `.gradle`, `node_modules`) is stored as-is,
8
+ * relative to the step's working directory. Every restore then used a single
9
+ * `tar -xf - -C /`, which is right for the first kind and impossible for the
10
+ * second: the build user cannot create `/Pods`.
11
+ *
12
+ * Three shapes, three different failures, all measured before this was fixed:
13
+ *
14
+ * | `cache_paths` | before |
15
+ * |------------------------------------------|---------------------------------|
16
+ * | all `~`-rooted | correct |
17
+ * | mixed (every Gradle/Android pipeline) | `~` half landed, relative half never did, tar exited non-zero, reported **cold** on a build that was warm |
18
+ * | all project-relative (Pods, node_modules)| **nothing** landed — those caches were written on every build and had never restored |
19
+ *
20
+ * So the mixed shape lied and the all-relative shape silently did nothing. Both
21
+ * are asserted here, and the all-`~` shape is asserted too because it already
22
+ * worked and the fix must not cost it anything.
23
+ *
24
+ * ### Why this runs the real `tar`
25
+ *
26
+ * The sibling daemon suite stubs `tar` to `cat > /dev/null`, because the
27
+ * generated script extracts with `-C /` and nothing there is worth writing to
28
+ * the root of the machine running the suite. This suite cannot: *where the
29
+ * bytes land* is the entire question, and a stub answers it by construction.
30
+ *
31
+ * It stays out of the real root by pointing `CIBUILD_USER_HOME` at a temporary
32
+ * directory. `~/.gradle/caches` then expands to an absolute path *inside* that
33
+ * directory, so `-C /` reassembles it exactly where it belongs and writes
34
+ * nothing outside the temp tree. The path is resolved through `realpath` first:
35
+ * on macOS `/var` is a symlink to `/private/var`, and tar refuses to extract
36
+ * through a symlink.
37
+ *
38
+ * `zstd` is stubbed to an identity copy, as in the sibling suite — it is not on
39
+ * every host, and compression is not what is under test.
40
+ */
41
+ import { describe, test, expect } from '@jest/globals';
42
+ import { execFile } from 'child_process';
43
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, realpathSync, rmSync, chmodSync, } from 'fs';
44
+ import { join } from 'path';
45
+ import { tmpdir } from 'os';
46
+ import { promisify } from 'util';
47
+ import { CachePullStepExecutor } from './cache.js';
48
+ import { testConfigNoPeer } from './test-config.js';
49
+ const run = promisify(execFile);
50
+ const CACHE_KEY = 'gradle-abc123def456-fingerprint';
51
+ async function restore(shape) {
52
+ // Resolved: see the header — tar will not extract through a symlink, and on
53
+ // macOS the temp root sits behind one.
54
+ const root = realpathSync(mkdtempSync(join(tmpdir(), 'cibuild-restore-roots-')));
55
+ const home = join(root, 'home');
56
+ const checkout = join(root, 'checkout');
57
+ const bin = join(root, 'bin');
58
+ mkdirSync(home, { recursive: true });
59
+ mkdirSync(checkout, { recursive: true });
60
+ mkdirSync(bin, { recursive: true });
61
+ try {
62
+ // Identity stand-in for zstd: copies a named file, or stdin when the
63
+ // daemon path pipes into it.
64
+ writeFileSync(join(bin, 'zstd'), '#!/bin/bash\nfor a in "$@"; do case "$a" in -*) ;; *) exec cat "$a";; esac; done\nexec cat\n', 'utf-8');
65
+ chmodSync(join(bin, 'zstd'), 0o755);
66
+ // Create the content, remembering where each piece has to return to.
67
+ const landings = [];
68
+ const operands = [];
69
+ for (const f of shape.files) {
70
+ const [where, rel] = f.at.split(':', 2);
71
+ const base = where === 'home' ? home : checkout;
72
+ const abs = join(base, rel);
73
+ mkdirSync(join(abs, '..'), { recursive: true });
74
+ writeFileSync(abs, f.body, 'utf-8');
75
+ landings.push({ path: abs, body: f.body });
76
+ }
77
+ // The tar operands are what cache-push would have built: `~` paths
78
+ // expanded to absolute, project-relative paths left alone.
79
+ for (const p of shape.cachePaths) {
80
+ operands.push(p.startsWith('~') ? join(home, p.slice(2)) : p);
81
+ }
82
+ // One archive, mixed operands, created from the checkout — exactly what
83
+ // `tar -cf - "${PATHS_TO_CACHE[@]}"` produces.
84
+ const cacheDir = join(checkout, '.ci-cache');
85
+ mkdirSync(cacheDir, { recursive: true });
86
+ const tarball = join(cacheDir, `${CACHE_KEY}.tar.zst`);
87
+ await run('tar', ['-cf', tarball, ...operands], { cwd: checkout });
88
+ // Remove the originals: "it landed" is only meaningful if it was gone.
89
+ for (const l of landings)
90
+ rmSync(l.path, { force: true });
91
+ const { script } = await new CachePullStepExecutor().execute({ cache_key: CACHE_KEY, cache_paths: shape.cachePaths }, {}, testConfigNoPeer);
92
+ const scriptPath = join(root, '__cache_pull.sh');
93
+ writeFileSync(scriptPath, script, 'utf-8');
94
+ const env = {
95
+ ...process.env,
96
+ PATH: `${bin}:${process.env.PATH}`,
97
+ CIBUILD_USER_HOME: home,
98
+ };
99
+ // The filesystem transport is the one under test here; the daemon URL
100
+ // would switch the script to the HTTP path.
101
+ delete env.CIBUILD_CACHE_DAEMON;
102
+ let stdout = '';
103
+ let exitCode = 0;
104
+ try {
105
+ const r = await run('bash', [scriptPath], {
106
+ cwd: checkout,
107
+ encoding: 'utf-8',
108
+ env,
109
+ timeout: 20000,
110
+ });
111
+ stdout = String(r.stdout) + String(r.stderr);
112
+ }
113
+ catch (e) {
114
+ const err = e;
115
+ stdout = String(err.stdout ?? '') + String(err.stderr ?? '');
116
+ exitCode = err.code ?? 1;
117
+ }
118
+ // Read the landings back while the tree is still alive.
119
+ const observed = landings.map((l) => ({
120
+ path: l.path,
121
+ body: existsSync(l.path) ? readFileSync(l.path, 'utf-8') : '',
122
+ }));
123
+ return { stdout, exitCode, landings: observed };
124
+ }
125
+ finally {
126
+ rmSync(root, { recursive: true, force: true });
127
+ }
128
+ }
129
+ describe('cache-pull restores each class of path to its own root', () => {
130
+ test('all `~`-rooted: keeps working, and still streams', async () => {
131
+ // The shape that was already correct. It is here so the fix cannot buy the
132
+ // other two at its expense.
133
+ const { stdout, exitCode, landings } = await restore({
134
+ cachePaths: ['~/.gradle/caches'],
135
+ files: [{ at: 'home:.gradle/caches/modules/marker', body: 'gradle-cache\n' }],
136
+ });
137
+ expect(exitCode).toBe(0);
138
+ expect(stdout).toContain('CACHE_SOURCE=local');
139
+ expect(stdout).not.toContain('CACHE_SOURCE=cold');
140
+ expect(landings).toEqual([
141
+ { path: landings[0].path, body: 'gradle-cache\n' },
142
+ ]);
143
+ });
144
+ test('mixed: reports warm AND the project-relative half lands in the checkout', async () => {
145
+ // Every Gradle/Android pipeline, including the one the README ships. Before
146
+ // the fix: the `~` half restored (that was the real warmth), `.gradle`
147
+ // could not become `/.gradle`, tar exited non-zero, and the step reported
148
+ // CACHE_SOURCE=cold — on the filesystem transport it ended the step outright.
149
+ const { stdout, exitCode, landings } = await restore({
150
+ cachePaths: ['~/.gradle/caches', '.gradle'],
151
+ files: [
152
+ { at: 'home:.gradle/caches/modules/marker', body: 'gradle-cache\n' },
153
+ { at: 'checkout:.gradle/configuration-cache/marker', body: 'project-gradle\n' },
154
+ ],
155
+ });
156
+ expect(exitCode).toBe(0);
157
+ expect(stdout).toContain('CACHE_SOURCE=local');
158
+ expect(stdout).not.toContain('CACHE_SOURCE=cold');
159
+ // Both halves, at their own roots.
160
+ expect(landings.map((l) => l.body)).toEqual(['gradle-cache\n', 'project-gradle\n']);
161
+ });
162
+ test('all project-relative: restores into the checkout instead of nothing', async () => {
163
+ // The iOS/Pods and node shape. Before the fix every member was extracted to
164
+ // `/` and every one failed, so these caches were pushed on every build and
165
+ // had never restored anything — a silent miss that costs a `pod install`
166
+ // rather than failing a build, which is why it went unnoticed.
167
+ const { stdout, exitCode, landings } = await restore({
168
+ cachePaths: ['Pods', 'Podfile.lock'],
169
+ files: [
170
+ { at: 'checkout:Pods/Alamofire/marker', body: 'pods\n' },
171
+ { at: 'checkout:Podfile.lock', body: 'lock\n' },
172
+ ],
173
+ });
174
+ expect(exitCode).toBe(0);
175
+ expect(stdout).toContain('CACHE_SOURCE=local');
176
+ expect(stdout).not.toContain('CACHE_SOURCE=cold');
177
+ expect(landings.map((l) => l.body)).toEqual(['pods\n', 'lock\n']);
178
+ });
179
+ test('a project-relative path is never written to the filesystem root', async () => {
180
+ // The direct statement of the bug: `Pods` must not be attempted at `/Pods`.
181
+ // Asserted on the generated script rather than by running it, because the
182
+ // only honest runtime check would be whether `/Pods` appeared on the
183
+ // machine running the suite.
184
+ const { script } = await new CachePullStepExecutor().execute({ cache_key: CACHE_KEY, cache_paths: ['~/.gradle/caches', 'Pods'] }, {}, testConfigNoPeer);
185
+ // No restore pipes into `tar -C /` any more; they all go through the
186
+ // helper, which picks the root per class. The helper keeps a single
187
+ // streaming `tar -xf - -C /` for the all-absolute shape, so the assertion
188
+ // is about the call sites, not about the string appearing anywhere.
189
+ expect(script).not.toContain('| tar -xf - -C /');
190
+ expect(script).toContain('| __ci_cache_extract');
191
+ // `Pods` is declared project-relative, so it can only be extracted
192
+ // relative to the checkout ...
193
+ expect(script).toContain('__ci_cache_rel+=(\'Pods\')');
194
+ // ... and the tilde path is expanded at runtime, then matched against the
195
+ // archive's own top-level names.
196
+ expect(script).toContain('__ci_cache_abs+=("${__ci_p#/}")');
197
+ expect(script).toContain('tar -xf "$__t" -C "$__ci_cache_root"');
198
+ });
199
+ });
200
+ //# sourceMappingURL=cache-pull-restore-roots.test.js.map
@@ -37,8 +37,11 @@ const CACHE_KEY = 'pods-abc123def456-main';
37
37
  *
38
38
  * @param curlExit exit status the stub returns, and `curlBody` is what it
39
39
  * prints — so a rejection can be exercised without a daemon.
40
+ * @param zstdExit exit status the `zstd` stub returns, which is how the
41
+ * *middle* stage is made to fail. 127 is what a shell that
42
+ * cannot find the binary produces, i.e. an image without it.
40
43
  */
41
- async function runPush({ curlExit = 0, curlBody = '' } = {}) {
44
+ async function runPush({ curlExit = 0, curlBody = '', zstdExit = 0, } = {}) {
42
45
  const dir = mkdtempSync(join(tmpdir(), 'cibuild-push-'));
43
46
  try {
44
47
  // Something real to cache, so the step does not take its empty branch.
@@ -63,7 +66,11 @@ async function runPush({ curlExit = 0, curlBody = '' } = {}) {
63
66
  chmodSync(join(bin, 'curl'), 0o755);
64
67
  // zstd is not on every host, and without it the pipeline fails before
65
68
  // curl is ever reached — which would make these tests pass vacuously.
66
- writeFileSync(join(bin, 'zstd'), '#!/bin/bash\ncat\n', 'utf-8');
69
+ // A failing stub drains stdin before exiting, so tar completes normally
70
+ // and the only non-zero status in the pipeline is the one under test.
71
+ writeFileSync(join(bin, 'zstd'), zstdExit === 0
72
+ ? '#!/bin/bash\ncat\n'
73
+ : `#!/bin/bash\ncat > /dev/null\nexit ${zstdExit}\n`, 'utf-8');
67
74
  chmodSync(join(bin, 'zstd'), 0o755);
68
75
  const { script } = await new CachePushStepExecutor().execute({ cache_key: CACHE_KEY, cache_paths: ['Pods'] }, {}, testConfigNoPeer);
69
76
  const scriptPath = join(dir, '__cache_push.sh');
@@ -128,5 +135,28 @@ describe('cache-push with an explicit key, over the daemon', () => {
128
135
  });
129
136
  expect(stdout).toContain('invalid_cache_key');
130
137
  });
138
+ test('a failed compressor is not reported as the daemon saying yes', async () => {
139
+ // What a worker image with no zstd binary actually logged:
140
+ //
141
+ // Warning: failed to upload cache to the daemon — {"ok":true}
142
+ //
143
+ // `--fail-with-body` writes the daemon's response body to curl's *stdout*
144
+ // on success as well as on refusal, and the old code captured that as the
145
+ // failure reason. curl is perfectly happy to upload nothing, so the
146
+ // success body is precisely what a broken middle stage produces — the
147
+ // warning blamed the one stage that worked.
148
+ const { stdout } = await runPush({ zstdExit: 127, curlBody: '{"ok":true}' });
149
+ expect(stdout).toContain('Warning: failed to upload cache to the daemon');
150
+ expect(stdout).not.toContain('{"ok":true}');
151
+ expect(stdout).toContain('zstd is not installed');
152
+ });
153
+ test('a middle stage failing for any other reason still names the stage', async () => {
154
+ // 127 is the case that bit us, but the fix is not about 127: the reason is
155
+ // only the daemon's answer when the daemon is what refused.
156
+ const { stdout } = await runPush({ zstdExit: 1, curlBody: '{"ok":true}' });
157
+ expect(stdout).toContain('Warning: failed to upload cache to the daemon');
158
+ expect(stdout).not.toContain('{"ok":true}');
159
+ expect(stdout).toContain('zstd exited 1');
160
+ });
131
161
  });
132
162
  //# 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;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;AAubF;;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;YAmIzF,iBAAiB;CAkNhC;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;YAqKzF,iBAAiB;CAgJhC"}