@invarn/cibuild 2.6.1 → 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.
@@ -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;AAMH,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;gBAGtD,QAAQ,EAAE,YAAY,EACtB,YAAY,EAAE,MAAM,EACpB,MAAM,EAAE,QAAQ,EAChB,YAAY,CAAC,EAAE,MAAM;IAwBvB;;;OAGG;IACG,gBAAgB,IAAI,OAAO,CAAC,wBAAwB,CAAC;IA8M3D;;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"}
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
- if (stepPlatform !== 'all') {
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invarn/cibuild",
3
- "version": "2.6.1",
3
+ "version": "2.6.2",
4
4
  "description": "CI Build CLI — local pipeline orchestration and validation",
5
5
  "type": "module",
6
6
  "main": "dist/cli.cjs",