@invarn/cibuild 2.6.0 → 2.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.cjs +10 -10
- package/dist/src/yaml/meta-helpers.d.ts +9 -0
- package/dist/src/yaml/meta-helpers.d.ts.map +1 -1
- package/dist/src/yaml/meta-helpers.js +26 -0
- package/dist/src/yaml/step-validator.d.ts +1 -0
- package/dist/src/yaml/step-validator.d.ts.map +1 -1
- package/dist/src/yaml/step-validator.js +11 -1
- package/dist/src/yaml/step-validator.test.d.ts +18 -0
- package/dist/src/yaml/step-validator.test.d.ts.map +1 -0
- package/dist/src/yaml/step-validator.test.js +140 -0
- package/dist/src/yaml/steps/cache-pull-daemon.test.js +22 -2
- package/dist/src/yaml/steps/cache-pull-restore-roots.test.d.ts +42 -0
- package/dist/src/yaml/steps/cache-pull-restore-roots.test.d.ts.map +1 -0
- package/dist/src/yaml/steps/cache-pull-restore-roots.test.js +200 -0
- package/dist/src/yaml/steps/cache-push-daemon.test.js +32 -2
- package/dist/src/yaml/steps/cache.d.ts.map +1 -1
- package/dist/src/yaml/steps/cache.js +185 -27
- package/package.json +1 -1
|
@@ -16,4 +16,13 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import type { YAMLCIBuildMeta, YAMLPipeline } from './types.js';
|
|
18
18
|
export declare function getInvarnMeta(pipeline: YAMLPipeline): YAMLCIBuildMeta | undefined;
|
|
19
|
+
/**
|
|
20
|
+
* Whether the pipeline *declares* an app that spans both mobile platforms.
|
|
21
|
+
*
|
|
22
|
+
* Read only from the explicit declaration, never inferred from the steps.
|
|
23
|
+
* Inference would defeat the point: a workflow that mixes platforms by mistake
|
|
24
|
+
* is exactly what the gate exists to catch, and it looks identical to one that
|
|
25
|
+
* does so on purpose.
|
|
26
|
+
*/
|
|
27
|
+
export declare function declaresMultiplatformApp(pipeline: YAMLPipeline): boolean;
|
|
19
28
|
//# sourceMappingURL=meta-helpers.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"meta-helpers.d.ts","sourceRoot":"","sources":["../../../src/yaml/meta-helpers.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAEhE,wBAAgB,aAAa,CAAC,QAAQ,EAAE,YAAY,GAAG,eAAe,GAAG,SAAS,CAEjF"}
|
|
1
|
+
{"version":3,"file":"meta-helpers.d.ts","sourceRoot":"","sources":["../../../src/yaml/meta-helpers.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAEhE,wBAAgB,aAAa,CAAC,QAAQ,EAAE,YAAY,GAAG,eAAe,GAAG,SAAS,CAEjF;AAiBD;;;;;;;GAOG;AACH,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,YAAY,GAAG,OAAO,CAGxE"}
|
|
@@ -17,4 +17,30 @@
|
|
|
17
17
|
export function getInvarnMeta(pipeline) {
|
|
18
18
|
return pipeline.meta?.invarn ?? pipeline.meta?.['cibuild.io'];
|
|
19
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* App types whose build is inherently both platforms in a single pass, and so
|
|
22
|
+
* whose one workflow legitimately holds both android and ios steps.
|
|
23
|
+
*
|
|
24
|
+
* A Kotlin Multiplatform build is the case that forced this: gradle assembles
|
|
25
|
+
* the XCFramework that `xcodebuild` then consumes, in that order, on one
|
|
26
|
+
* machine. It cannot be split — a workflow is one dispatch and two workflows
|
|
27
|
+
* share no filesystem, so the artifact would never reach the second half.
|
|
28
|
+
*
|
|
29
|
+
* `ios` and `android` are single-platform by definition. `other` means the app
|
|
30
|
+
* type was never classified, and an unclassified app has not declared anything
|
|
31
|
+
* — silence is not permission.
|
|
32
|
+
*/
|
|
33
|
+
const MULTIPLATFORM_APP_TYPES = new Set(['kmm']);
|
|
34
|
+
/**
|
|
35
|
+
* Whether the pipeline *declares* an app that spans both mobile platforms.
|
|
36
|
+
*
|
|
37
|
+
* Read only from the explicit declaration, never inferred from the steps.
|
|
38
|
+
* Inference would defeat the point: a workflow that mixes platforms by mistake
|
|
39
|
+
* is exactly what the gate exists to catch, and it looks identical to one that
|
|
40
|
+
* does so on purpose.
|
|
41
|
+
*/
|
|
42
|
+
export function declaresMultiplatformApp(pipeline) {
|
|
43
|
+
const platform = getInvarnMeta(pipeline)?.platform;
|
|
44
|
+
return platform !== undefined && MULTIPLATFORM_APP_TYPES.has(platform);
|
|
45
|
+
}
|
|
20
46
|
//# sourceMappingURL=meta-helpers.js.map
|
|
@@ -22,6 +22,7 @@ export declare class StepValidator {
|
|
|
22
22
|
private yamlFilePath?;
|
|
23
23
|
private availableOutputs;
|
|
24
24
|
private establishedPlatform;
|
|
25
|
+
private readonly spansPlatforms;
|
|
25
26
|
constructor(pipeline: YAMLPipeline, workflowName: string, config: CIConfig, yamlFilePath?: string);
|
|
26
27
|
/**
|
|
27
28
|
* Validates all steps in the workflow.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"step-validator.d.ts","sourceRoot":"","sources":["../../../src/yaml/step-validator.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;
|
|
1
|
+
{"version":3,"file":"step-validator.d.ts","sourceRoot":"","sources":["../../../src/yaml/step-validator.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAOH,OAAO,KAAK,EAAE,YAAY,EAA4B,MAAM,YAAY,CAAC;AACzE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAC5C,OAAO,KAAK,EAGV,wBAAwB,EAGzB,MAAM,uBAAuB,CAAC;AAwB/B;;;;GAIG;AACH,qBAAa,aAAa;IACxB,OAAO,CAAC,QAAQ,CAAe;IAC/B,OAAO,CAAC,QAAQ,CAAe;IAC/B,OAAO,CAAC,YAAY,CAAS;IAC7B,OAAO,CAAC,MAAM,CAAW;IACzB,OAAO,CAAC,WAAW,CAAc;IACjC,OAAO,CAAC,YAAY,CAAC,CAAS;IAG9B,OAAO,CAAC,gBAAgB,CAAoE;IAG5F,OAAO,CAAC,mBAAmB,CAA6B;IAKxD,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAU;gBAGvC,QAAQ,EAAE,YAAY,EACtB,YAAY,EAAE,MAAM,EACpB,MAAM,EAAE,QAAQ,EAChB,YAAY,CAAC,EAAE,MAAM;IA0BvB;;;OAGG;IACG,gBAAgB,IAAI,OAAO,CAAC,wBAAwB,CAAC;IAkN3D;;OAEG;YACW,mBAAmB;IAsDjC;;;;;;;;;OASG;IACH,OAAO,CAAC,yBAAyB;IAsBjC;;;;;;;;;OASG;IACH,OAAO,CAAC,mBAAmB;IAoB3B;;OAEG;IACH,OAAO,CAAC,SAAS;CA8BlB;AAED;;GAEG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,wBAAwB,GAAG,MAAM,CA4D/E"}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { getStepExecutor, hasStep, getStepMetadata } from './steps/registry.js';
|
|
9
9
|
import { EnvResolver } from './env-resolver.js';
|
|
10
10
|
import { detectPlatformInfo } from './platform-detector.js';
|
|
11
|
+
import { declaresMultiplatformApp } from './meta-helpers.js';
|
|
11
12
|
// POSIX env vars the shell always provides — don't flag these as undeclared.
|
|
12
13
|
// The runner's guest shell seeds HOME, TMPDIR, PATH, etc.; requiring
|
|
13
14
|
// pipelines to redeclare them forces literal-path-only scripts.
|
|
@@ -45,6 +46,10 @@ export class StepValidator {
|
|
|
45
46
|
availableOutputs = new Map();
|
|
46
47
|
// Platform established by the first platform-specific step ('ios' or 'android')
|
|
47
48
|
establishedPlatform = null;
|
|
49
|
+
// True when the pipeline declares an app that is inherently both platforms
|
|
50
|
+
// (KMM today), so one workflow may legitimately hold android and ios steps.
|
|
51
|
+
// Read from the declaration only — see `declaresMultiplatformApp`.
|
|
52
|
+
spansPlatforms;
|
|
48
53
|
constructor(pipeline, workflowName, config, yamlFilePath) {
|
|
49
54
|
this.pipeline = pipeline;
|
|
50
55
|
this.workflowName = workflowName;
|
|
@@ -54,6 +59,7 @@ export class StepValidator {
|
|
|
54
59
|
if (!this.workflow) {
|
|
55
60
|
throw new Error(`Workflow '${workflowName}' not found in pipeline`);
|
|
56
61
|
}
|
|
62
|
+
this.spansPlatforms = declaresMultiplatformApp(pipeline);
|
|
57
63
|
// Create env resolver for variable interpolation
|
|
58
64
|
const platformInfo = detectPlatformInfo(pipeline, workflowName);
|
|
59
65
|
this.envResolver = new EnvResolver(pipeline, this.workflow, workflowName, platformInfo.platform, platformInfo.stack, yamlFilePath);
|
|
@@ -78,7 +84,11 @@ export class StepValidator {
|
|
|
78
84
|
// Platform compatibility check
|
|
79
85
|
const stepMetadata = getStepMetadata(parsedStep.name);
|
|
80
86
|
const stepPlatform = stepMetadata?.platform ?? 'all';
|
|
81
|
-
|
|
87
|
+
// A declared multiplatform app is exempt: its build spans both platforms
|
|
88
|
+
// by construction, so there is no single platform for a step to conflict
|
|
89
|
+
// with. Everything else — including an app that declares nothing, or
|
|
90
|
+
// declares one platform and then mixes — is still refused below.
|
|
91
|
+
if (stepPlatform !== 'all' && !this.spansPlatforms) {
|
|
82
92
|
if (this.establishedPlatform === null) {
|
|
83
93
|
// First platform-specific step establishes the workflow platform
|
|
84
94
|
this.establishedPlatform = stepPlatform;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the pre-execution platform gate.
|
|
3
|
+
*
|
|
4
|
+
* A workflow's platform is established by its first platform-specific step,
|
|
5
|
+
* and any later step of a different platform is refused. That rule is right
|
|
6
|
+
* for an app that targets one platform and wrong for one that targets both:
|
|
7
|
+
* a Kotlin Multiplatform build is *inherently* android-then-ios — gradle
|
|
8
|
+
* assembles the XCFramework that `xcodebuild` then consumes — and there is no
|
|
9
|
+
* way to split it, because a workflow is one dispatch on one machine and two
|
|
10
|
+
* workflows share no filesystem.
|
|
11
|
+
*
|
|
12
|
+
* On 2026-09-01 that cost a real build: a generated KMM pipeline was refused
|
|
13
|
+
* before a single step ran. These tests pin the narrow relaxation that fixes
|
|
14
|
+
* it — permitted only where the pipeline *declares* a multiplatform app,
|
|
15
|
+
* never inferred from the steps themselves.
|
|
16
|
+
*/
|
|
17
|
+
import '../yaml/steps/index.js';
|
|
18
|
+
//# sourceMappingURL=step-validator.test.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"step-validator.test.d.ts","sourceRoot":"","sources":["../../../src/yaml/step-validator.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAGH,OAAO,wBAAwB,CAAC"}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the pre-execution platform gate.
|
|
3
|
+
*
|
|
4
|
+
* A workflow's platform is established by its first platform-specific step,
|
|
5
|
+
* and any later step of a different platform is refused. That rule is right
|
|
6
|
+
* for an app that targets one platform and wrong for one that targets both:
|
|
7
|
+
* a Kotlin Multiplatform build is *inherently* android-then-ios — gradle
|
|
8
|
+
* assembles the XCFramework that `xcodebuild` then consumes — and there is no
|
|
9
|
+
* way to split it, because a workflow is one dispatch on one machine and two
|
|
10
|
+
* workflows share no filesystem.
|
|
11
|
+
*
|
|
12
|
+
* On 2026-09-01 that cost a real build: a generated KMM pipeline was refused
|
|
13
|
+
* before a single step ran. These tests pin the narrow relaxation that fixes
|
|
14
|
+
* it — permitted only where the pipeline *declares* a multiplatform app,
|
|
15
|
+
* never inferred from the steps themselves.
|
|
16
|
+
*/
|
|
17
|
+
import { describe, test, expect } from '@jest/globals';
|
|
18
|
+
import '../yaml/steps/index.js';
|
|
19
|
+
import { StepValidator } from './step-validator.js';
|
|
20
|
+
import { PlatformDetectionError } from './platform-detector.js';
|
|
21
|
+
import { loadConfig } from '../config.js';
|
|
22
|
+
const config = loadConfig();
|
|
23
|
+
/** What every curated template declares, and what the detector reads first. */
|
|
24
|
+
const STACK = 'macos-xcode-26.4';
|
|
25
|
+
/**
|
|
26
|
+
* A KMM build in one workflow: gradle establishes android, xcodebuild follows.
|
|
27
|
+
*
|
|
28
|
+
* The stack is not decoration. `PlatformDetector` resolves the host OS from it
|
|
29
|
+
* first and only falls back to reading the steps — where a mixed workflow
|
|
30
|
+
* throws before this validator ever runs. Every curated template declares a
|
|
31
|
+
* stack, so this is the gate a real pipeline actually meets.
|
|
32
|
+
*
|
|
33
|
+
* `getInvarnMeta` takes `meta.invarn` as a whole when it is present, so a test
|
|
34
|
+
* about the legacy block has to omit `meta.invarn` entirely.
|
|
35
|
+
*/
|
|
36
|
+
function kmmShapedPipeline({ invarn, legacy } = {}, { stack = true } = {}) {
|
|
37
|
+
const base = stack ? { stack: STACK } : {};
|
|
38
|
+
const meta = legacy && !invarn
|
|
39
|
+
? { 'cibuild.io': { ...base, ...legacy } }
|
|
40
|
+
: {
|
|
41
|
+
invarn: { ...base, ...invarn },
|
|
42
|
+
...(legacy ? { 'cibuild.io': { ...base, ...legacy } } : {}),
|
|
43
|
+
};
|
|
44
|
+
return {
|
|
45
|
+
format_version: '1',
|
|
46
|
+
meta,
|
|
47
|
+
workflows: {
|
|
48
|
+
primary: {
|
|
49
|
+
steps: [
|
|
50
|
+
{ 'gradle-build@1.0.0': { inputs: { project_location: '.', gradle_task: 'assembleDebug' } } },
|
|
51
|
+
{
|
|
52
|
+
'xcodebuild@1.0.0': {
|
|
53
|
+
inputs: {
|
|
54
|
+
project_path: 'iosApp/iosApp.xcodeproj',
|
|
55
|
+
scheme: 'iosApp',
|
|
56
|
+
destination: 'generic/platform=iOS Simulator',
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
async function platformIssues(pipeline) {
|
|
66
|
+
const validator = new StepValidator(pipeline, 'primary', config);
|
|
67
|
+
const result = await validator.validateWorkflow();
|
|
68
|
+
return (result.issuesByCategory.get('platform') ?? []).map((i) => i.requirement.name);
|
|
69
|
+
}
|
|
70
|
+
describe('StepValidator platform gate', () => {
|
|
71
|
+
test('refuses a mixed workflow that declares no app platform', async () => {
|
|
72
|
+
expect(await platformIssues(kmmShapedPipeline())).toEqual(['xcodebuild']);
|
|
73
|
+
});
|
|
74
|
+
test('permits a mixed workflow that declares a KMM app', async () => {
|
|
75
|
+
expect(await platformIssues(kmmShapedPipeline({ invarn: { platform: 'kmm' } }))).toEqual([]);
|
|
76
|
+
});
|
|
77
|
+
test('permits it through the legacy meta.cibuild.io block too', async () => {
|
|
78
|
+
expect(await platformIssues(kmmShapedPipeline({ legacy: { platform: 'kmm' } }))).toEqual([]);
|
|
79
|
+
});
|
|
80
|
+
// meta.invarn wins as a whole when both blocks are present, so a stale
|
|
81
|
+
// `kmm` under the legacy key must not grant permission the current block
|
|
82
|
+
// withholds.
|
|
83
|
+
test('lets meta.invarn withhold what a stale meta.cibuild.io granted', async () => {
|
|
84
|
+
expect(await platformIssues(kmmShapedPipeline({ invarn: { platform: 'android' }, legacy: { platform: 'kmm' } }))).toEqual(['xcodebuild']);
|
|
85
|
+
});
|
|
86
|
+
// The regression this relaxation could introduce. A declared platform, never
|
|
87
|
+
// inference: an ios step in a workflow that says it is android stays refused.
|
|
88
|
+
test('still refuses an ios step in a declared-android workflow', async () => {
|
|
89
|
+
expect(await platformIssues(kmmShapedPipeline({ invarn: { platform: 'android' } }))).toEqual([
|
|
90
|
+
'xcodebuild',
|
|
91
|
+
]);
|
|
92
|
+
});
|
|
93
|
+
test('still refuses an android step in a declared-ios workflow', async () => {
|
|
94
|
+
const iosFirst = {
|
|
95
|
+
format_version: '1',
|
|
96
|
+
meta: { invarn: { stack: STACK, platform: 'ios' } },
|
|
97
|
+
workflows: {
|
|
98
|
+
primary: {
|
|
99
|
+
steps: [
|
|
100
|
+
{ 'xcodebuild@1.0.0': { inputs: { project_path: 'App.xcodeproj', scheme: 'App' } } },
|
|
101
|
+
{ 'gradle-build@1.0.0': { inputs: { project_location: '.', gradle_task: 'assembleDebug' } } },
|
|
102
|
+
],
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
expect(await platformIssues(iosFirst)).toEqual(['gradle-build']);
|
|
107
|
+
});
|
|
108
|
+
// 'other' means the app type was not classified. An unclassified app has not
|
|
109
|
+
// declared that it spans platforms; silence is not permission.
|
|
110
|
+
test('still refuses when the declared platform is other', async () => {
|
|
111
|
+
expect(await platformIssues(kmmShapedPipeline({ invarn: { platform: 'other' } }))).toEqual([
|
|
112
|
+
'xcodebuild',
|
|
113
|
+
]);
|
|
114
|
+
});
|
|
115
|
+
// The declaration is about mixing, not about skipping the gate: a
|
|
116
|
+
// single-platform workflow was never at risk either way.
|
|
117
|
+
test('leaves a single-platform workflow alone whatever it declares', async () => {
|
|
118
|
+
const androidOnly = {
|
|
119
|
+
format_version: '1',
|
|
120
|
+
meta: { invarn: { stack: STACK, platform: 'kmm' } },
|
|
121
|
+
workflows: {
|
|
122
|
+
primary: {
|
|
123
|
+
steps: [
|
|
124
|
+
{ 'gradle-build@1.0.0': { inputs: { project_location: '.', gradle_task: 'assembleDebug' } } },
|
|
125
|
+
],
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
expect(await platformIssues(androidOnly)).toEqual([]);
|
|
130
|
+
});
|
|
131
|
+
// Declaring the app's platform does not tell the detector which host OS to
|
|
132
|
+
// ask for, and a mixed workflow cannot be read off its steps. That refusal
|
|
133
|
+
// is `PlatformDetector`'s and comes first — it is not relaxed here, and its
|
|
134
|
+
// own message already says what to add.
|
|
135
|
+
test('does not substitute for the stack a mixed workflow still needs', async () => {
|
|
136
|
+
const noStack = kmmShapedPipeline({ invarn: { platform: 'kmm' } }, { stack: false });
|
|
137
|
+
expect(() => new StepValidator(noStack, 'primary', config)).toThrow(PlatformDetectionError);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
//# sourceMappingURL=step-validator.test.js.map
|
|
@@ -80,8 +80,28 @@ async function runPull(options) {
|
|
|
80
80
|
mkdirSync(bin, { recursive: true });
|
|
81
81
|
writeFileSync(join(bin, 'zstd'), '#!/bin/bash\ncat\n', 'utf-8');
|
|
82
82
|
chmodSync(join(bin, 'zstd'), 0o755);
|
|
83
|
-
//
|
|
84
|
-
|
|
83
|
+
// Writes nothing: see the file header on `-C /`.
|
|
84
|
+
//
|
|
85
|
+
// It has to answer `-tf` as well as extract. A restore now stages the
|
|
86
|
+
// archive and reads its top-level names to decide which of them were
|
|
87
|
+
// recorded against `/` and which against the checkout, so a stub that only
|
|
88
|
+
// drained the stream made every hit look like a miss. Both project-relative
|
|
89
|
+
// names this suite can ask about are listed, so the split runs whichever
|
|
90
|
+
// form is under test. Where the bytes actually land is asserted against a
|
|
91
|
+
// real tar in `cache-pull-restore-roots.test.ts`.
|
|
92
|
+
writeFileSync(join(bin, 'tar'), [
|
|
93
|
+
'#!/bin/bash',
|
|
94
|
+
"case \"$1\" in",
|
|
95
|
+
" -tf) printf 'Pods/\\nPods/marker\\nnode_modules/\\n'; exit 0 ;;",
|
|
96
|
+
'esac',
|
|
97
|
+
'# `-xf -` must drain the stream, or the writer upstream dies of SIGPIPE',
|
|
98
|
+
'# and the pipeline reports a failure that never happened.',
|
|
99
|
+
'for a in "$@"; do',
|
|
100
|
+
' if [ "$a" = "-" ]; then cat > /dev/null; exit 0; fi',
|
|
101
|
+
'done',
|
|
102
|
+
'exit 0',
|
|
103
|
+
'',
|
|
104
|
+
].join('\n'), 'utf-8');
|
|
85
105
|
chmodSync(join(bin, 'tar'), 0o755);
|
|
86
106
|
// The preset form derives its key from a lockfile, so it needs one.
|
|
87
107
|
if (options.technology) {
|
|
@@ -0,0 +1,42 @@
|
|
|
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
|
+
export {};
|
|
42
|
+
//# sourceMappingURL=cache-pull-restore-roots.test.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cache-pull-restore-roots.test.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/cache-pull-restore-roots.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG"}
|
|
@@ -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
|
-
|
|
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;
|
|
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"}
|