@invarn/cibuild 2.7.0 → 2.7.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.
@@ -44,18 +44,54 @@ export interface IosScanResult {
44
44
  *
45
45
  * 1. named after the project or workspace — the commonest case, and
46
46
  * unambiguous;
47
- * 2. launches the product named after the project, and is not named like a
48
- * companion. `thebaselab/codeapp` is why this outranks the next one: its
49
- * `Code App` scheme launches `Code.app` and declares no tests at all,
50
- * while `Code UI` has two test bundles and launches `CodeUI.app`;
51
- * 3. launches an `.app`, declares tests (the template runs them), and is not
52
- * named like a companion; among several, the iOS or Debug one, then the
53
- * shortest;
54
- * 4. launches an `.app` and is not named like a companion;
47
+ * 2. launches the product named after the project, and is not a companion.
48
+ * `thebaselab/codeapp` is why this outranks the next one: its `Code App`
49
+ * scheme launches `Code.app` and declares no tests at all, while `Code UI`
50
+ * has two test bundles and launches `CodeUI.app`;
51
+ * 3. launches an `.app`, declares tests (the template runs them), and is not a
52
+ * companion; among several, the iOS or Debug one, then the shortest;
53
+ * 4. launches an `.app` and is not a companion;
55
54
  * 5. whatever order it came in.
56
55
  *
57
56
  * Every scheme stays in the list — this reorders, it does not filter, so a
58
57
  * caller wanting the extension can still find it.
58
+ *
59
+ * **What "companion" has to mean, and why a name is not enough.**
60
+ * `mozilla-mobile/firefox-ios` broke tiers 1-4 without touching the directory
61
+ * order this function was written for. Its project is `Client.xcodeproj`, so
62
+ * `projectName` is `Client`. An app extension is launched *through its host
63
+ * app*, so the `Today` widget scheme's LaunchAction runnable is `Client.app` —
64
+ * identical to `Fennec`'s. Both landed in tier 1, neither name carries an `iOS`
65
+ * or `Debug` token, and the tie fell to name length:
66
+ *
67
+ * "Today" -> 5 characters <- won
68
+ * "Fennec" -> 6 characters
69
+ *
70
+ * The build then failed compiling `ActionExtension` rather than the browser
71
+ * (`bld_b1b38dda58f4111080415283`).
72
+ *
73
+ * Launching the app binary is therefore not sufficient, and the answer is not a
74
+ * longer name blocklist either — `Today` names nothing. Xcode states the fact
75
+ * outright: it writes `wasCreatedForAppExtension = "YES"` onto the `<Scheme>`
76
+ * element of a scheme it created for an extension target, so that is what is
77
+ * read.
78
+ *
79
+ * Measured over all 125 shared schemes in the thirty-app study's clones: 29
80
+ * carry the attribute, and **all 29 are extension schemes** — no false
81
+ * positive. It is also the only signal that would have worked. Two that were
82
+ * considered and are wrong on this same evidence:
83
+ *
84
+ * - *require an `.app` among the BuildAction entries.* 27 of the 29 extension
85
+ * schemes build the host app too — `Today` builds
86
+ * `["Today.appex", "Client.app"]` — so this demotes 2 of 29 and leaves
87
+ * firefox-ios picking `Today`.
88
+ * - *demote anything building an `.appex`.* `chenqi92/primuse`'s `Primuse`
89
+ * scheme is the real app scheme and builds two widget extensions alongside
90
+ * `Primuse.app`.
91
+ *
92
+ * The attribute is a sufficient signal, not a necessary one: a hand-written or
93
+ * pre-Xcode-10 scheme may omit it, and its absence changes nothing, so nothing
94
+ * that ranked before can regress on a scheme that does not carry it.
59
95
  */
60
96
  export declare function rankSchemes(root: string, projectPath: string, schemes: string[]): string[];
61
97
  /** The ranking, plus the sentence explaining its first entry. */
@@ -1 +1 @@
1
- {"version":3,"file":"ios-scanner.d.ts","sourceRoot":"","sources":["../../../src/commands/ios-scanner.ts"],"names":[],"mappings":"AAGA,MAAM,MAAM,kBAAkB,GAAG,cAAc,GAAG,SAAS,GAAG,gBAAgB,GAAG,WAAW,CAAC;AAE7F,MAAM,MAAM,kBAAkB,GAAG,SAAS,GAAG,MAAM,CAAC;AAEpD,MAAM,WAAW,UAAU;IACzB,QAAQ,EAAE,kBAAkB,CAAC;IAC7B,QAAQ,EAAE,kBAAkB,CAAC;IAC7B,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,UAAU,EAAE,CAAC;IACvB;;;OAGG;IACH,eAAe,EAAE,MAAM,EAAE,CAAC;IAC1B,uEAAuE;IACvE,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,oDAAoD;IACpD,YAAY,EAAE,OAAO,CAAC;IACtB,0DAA0D;IAC1D,MAAM,EAAE,OAAO,CAAC;IAChB,+DAA+D;IAC/D,WAAW,EAAE,MAAM,CAAC;IACpB,6DAA6D;IAC7D,gBAAgB,EAAE,OAAO,CAAC;IAC1B,iFAAiF;IACjF,eAAe,EAAE,MAAM,CAAC;CACzB;AA8ED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,wBAAgB,WAAW,CACzB,IAAI,EAAE,MAAM,EACZ,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE,MAAM,EAAE,GAChB,MAAM,EAAE,CAEV;AAED,iEAAiE;AACjE,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,MAAM,EACZ,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE,MAAM,EAAE,GAChB;IAAE,OAAO,EAAE,MAAM,EAAE,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAA;CAAE,CA4CxC;AAmLD,wBAAsB,cAAc,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC,CAgGhF;AAaD,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,aAAa,GAAG,MAAM,CAkDjE"}
1
+ {"version":3,"file":"ios-scanner.d.ts","sourceRoot":"","sources":["../../../src/commands/ios-scanner.ts"],"names":[],"mappings":"AAGA,MAAM,MAAM,kBAAkB,GAAG,cAAc,GAAG,SAAS,GAAG,gBAAgB,GAAG,WAAW,CAAC;AAE7F,MAAM,MAAM,kBAAkB,GAAG,SAAS,GAAG,MAAM,CAAC;AAEpD,MAAM,WAAW,UAAU;IACzB,QAAQ,EAAE,kBAAkB,CAAC;IAC7B,QAAQ,EAAE,kBAAkB,CAAC;IAC7B,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,UAAU,EAAE,CAAC;IACvB;;;OAGG;IACH,eAAe,EAAE,MAAM,EAAE,CAAC;IAC1B,uEAAuE;IACvE,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,oDAAoD;IACpD,YAAY,EAAE,OAAO,CAAC;IACtB,0DAA0D;IAC1D,MAAM,EAAE,OAAO,CAAC;IAChB,+DAA+D;IAC/D,WAAW,EAAE,MAAM,CAAC;IACpB,6DAA6D;IAC7D,gBAAgB,EAAE,OAAO,CAAC;IAC1B,iFAAiF;IACjF,eAAe,EAAE,MAAM,CAAC;CACzB;AAuFD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiEG;AACH,wBAAgB,WAAW,CACzB,IAAI,EAAE,MAAM,EACZ,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE,MAAM,EAAE,GAChB,MAAM,EAAE,CAEV;AAED,iEAAiE;AACjE,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,MAAM,EACZ,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE,MAAM,EAAE,GAChB;IAAE,OAAO,EAAE,MAAM,EAAE,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAA;CAAE,CA8CxC;AAmLD,wBAAsB,cAAc,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC,CAgGhF;AAaD,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,aAAa,GAAG,MAAM,CAkDjE"}
@@ -60,9 +60,13 @@ function readSchemeFacts(root, name) {
60
60
  if (!xml)
61
61
  continue;
62
62
  const launch = /<LaunchAction[\s\S]*?<\/LaunchAction>/.exec(xml)?.[0] ?? "";
63
+ // Attribute values carry no `>`, so the opening tag alone is a safe scope
64
+ // and no attribute deeper in the document can be mistaken for it.
65
+ const schemeTag = /<Scheme\b[^>]*>/.exec(xml)?.[0] ?? "";
63
66
  return {
64
67
  runnable: /BuildableName\s*=\s*"([^"]+)"/.exec(launch)?.[1],
65
68
  testable: /<TestableReference/.test(xml),
69
+ appExtension: /wasCreatedForAppExtension\s*=\s*"YES"/.test(schemeTag),
66
70
  };
67
71
  }
68
72
  return undefined;
@@ -84,18 +88,54 @@ function readSchemeFacts(root, name) {
84
88
  *
85
89
  * 1. named after the project or workspace — the commonest case, and
86
90
  * unambiguous;
87
- * 2. launches the product named after the project, and is not named like a
88
- * companion. `thebaselab/codeapp` is why this outranks the next one: its
89
- * `Code App` scheme launches `Code.app` and declares no tests at all,
90
- * while `Code UI` has two test bundles and launches `CodeUI.app`;
91
- * 3. launches an `.app`, declares tests (the template runs them), and is not
92
- * named like a companion; among several, the iOS or Debug one, then the
93
- * shortest;
94
- * 4. launches an `.app` and is not named like a companion;
91
+ * 2. launches the product named after the project, and is not a companion.
92
+ * `thebaselab/codeapp` is why this outranks the next one: its `Code App`
93
+ * scheme launches `Code.app` and declares no tests at all, while `Code UI`
94
+ * has two test bundles and launches `CodeUI.app`;
95
+ * 3. launches an `.app`, declares tests (the template runs them), and is not a
96
+ * companion; among several, the iOS or Debug one, then the shortest;
97
+ * 4. launches an `.app` and is not a companion;
95
98
  * 5. whatever order it came in.
96
99
  *
97
100
  * Every scheme stays in the list — this reorders, it does not filter, so a
98
101
  * caller wanting the extension can still find it.
102
+ *
103
+ * **What "companion" has to mean, and why a name is not enough.**
104
+ * `mozilla-mobile/firefox-ios` broke tiers 1-4 without touching the directory
105
+ * order this function was written for. Its project is `Client.xcodeproj`, so
106
+ * `projectName` is `Client`. An app extension is launched *through its host
107
+ * app*, so the `Today` widget scheme's LaunchAction runnable is `Client.app` —
108
+ * identical to `Fennec`'s. Both landed in tier 1, neither name carries an `iOS`
109
+ * or `Debug` token, and the tie fell to name length:
110
+ *
111
+ * "Today" -> 5 characters <- won
112
+ * "Fennec" -> 6 characters
113
+ *
114
+ * The build then failed compiling `ActionExtension` rather than the browser
115
+ * (`bld_b1b38dda58f4111080415283`).
116
+ *
117
+ * Launching the app binary is therefore not sufficient, and the answer is not a
118
+ * longer name blocklist either — `Today` names nothing. Xcode states the fact
119
+ * outright: it writes `wasCreatedForAppExtension = "YES"` onto the `<Scheme>`
120
+ * element of a scheme it created for an extension target, so that is what is
121
+ * read.
122
+ *
123
+ * Measured over all 125 shared schemes in the thirty-app study's clones: 29
124
+ * carry the attribute, and **all 29 are extension schemes** — no false
125
+ * positive. It is also the only signal that would have worked. Two that were
126
+ * considered and are wrong on this same evidence:
127
+ *
128
+ * - *require an `.app` among the BuildAction entries.* 27 of the 29 extension
129
+ * schemes build the host app too — `Today` builds
130
+ * `["Today.appex", "Client.app"]` — so this demotes 2 of 29 and leaves
131
+ * firefox-ios picking `Today`.
132
+ * - *demote anything building an `.appex`.* `chenqi92/primuse`'s `Primuse`
133
+ * scheme is the real app scheme and builds two widget extensions alongside
134
+ * `Primuse.app`.
135
+ *
136
+ * The attribute is a sufficient signal, not a necessary one: a hand-written or
137
+ * pre-Xcode-10 scheme may omit it, and its absence changes nothing, so nothing
138
+ * that ranked before can regress on a scheme that does not carry it.
99
139
  */
100
140
  export function rankSchemes(root, projectPath, schemes) {
101
141
  return rankSchemesWithReason(root, projectPath, schemes).schemes;
@@ -107,10 +147,12 @@ export function rankSchemesWithReason(root, projectPath, schemes) {
107
147
  const projectName = projectPath.replace(/\.(xcodeproj|xcworkspace)$/, "").split("/").pop();
108
148
  const scored = schemes.map((name, index) => {
109
149
  const facts = readSchemeFacts(root, name);
110
- const companion = COMPANION_SCHEME.test(name);
150
+ // Two ways to be a companion: the name says so, or Xcode does. Xcode's
151
+ // word outranks a name match, so it disqualifies tier 0 as well.
152
+ const companion = COMPANION_SCHEME.test(name) || (facts?.appExtension ?? false);
111
153
  const app = facts?.runnable?.endsWith(".app") ?? false;
112
154
  let tier = 4;
113
- if (name === projectName)
155
+ if (name === projectName && !facts?.appExtension)
114
156
  tier = 0;
115
157
  else if (facts?.runnable === `${projectName}.app` && !companion)
116
158
  tier = 1;
@@ -27,11 +27,11 @@ afterEach(() => {
27
27
  rmSync(root, { recursive: true, force: true });
28
28
  });
29
29
  /** An `.xcscheme` with the parts the ranking reads, in Xcode's own shape. */
30
- function scheme(project, name, { runnable, testable }) {
30
+ function scheme(project, name, { runnable, testable, appExtension }) {
31
31
  const dir = join(root, `${project}.xcodeproj`, "xcshareddata", "xcschemes");
32
32
  mkdirSync(dir, { recursive: true });
33
33
  writeFileSync(join(dir, `${name}.xcscheme`), `<?xml version="1.0" encoding="UTF-8"?>
34
- <Scheme LastUpgradeVersion = "1600" version = "1.7">
34
+ <Scheme LastUpgradeVersion = "1600"${appExtension ? `\n wasCreatedForAppExtension = "YES"` : ""} version = "1.7">
35
35
  <TestAction buildConfiguration = "Debug">
36
36
  <Testables>${testable
37
37
  ? `
@@ -149,4 +149,111 @@ describe("rankSchemes", () => {
149
149
  expect(pick("Solo.xcodeproj", ["Solo", "Other"])).toBe("Solo");
150
150
  });
151
151
  });
152
+ /**
153
+ * An app extension is launched *through its host app*, so an extension scheme's
154
+ * LaunchAction runnable is the host `.app` — the same value the app's own
155
+ * scheme carries. Launching the app binary is therefore not sufficient to be
156
+ * the app scheme, and no name blocklist reaches the case: `Today` names nothing.
157
+ *
158
+ * Xcode states the fact itself, on the `<Scheme>` element of any scheme it
159
+ * created for an extension target. Measured across all 125 shared schemes in
160
+ * the thirty-app study's clones: 29 carry `wasCreatedForAppExtension`, all 29
161
+ * are extension schemes, and 27 of them build the host `.app` too — which is
162
+ * why "require an `.app` among the BuildAction entries" does not separate them.
163
+ */
164
+ describe("rankSchemes and app-extension schemes", () => {
165
+ /**
166
+ * `mozilla-mobile/firefox-ios`, all thirteen schemes of
167
+ * `firefox-ios/Client.xcodeproj`, read off the clone. The project is
168
+ * `Client.xcodeproj`, so five schemes launch `Client.app` and land in the
169
+ * same tier; none carries an `iOS` or `Debug` token, so the tie fell to name
170
+ * length and `Today` (5) beat `Fennec` (6). The build then failed compiling
171
+ * `ActionExtension` — `bld_b1b38dda58f4111080415283`.
172
+ */
173
+ const FIREFOX_IOS = [
174
+ ["Account", { runnable: "libAccount.a", testable: true }],
175
+ ["Fennec", { runnable: "Client.app", testable: true }],
176
+ ["Fennec_Enterprise", { runnable: "Client.app", testable: true }],
177
+ ["Firefox", { runnable: "Client.app", testable: true }],
178
+ ["FirefoxBeta", { runnable: "Client.app", testable: true }],
179
+ ["FirefoxStaging", { runnable: "Client.app", testable: true }],
180
+ ["L10nSnapshotTests", { runnable: "L10nSnapshotTests.xctest", testable: true }],
181
+ ["Periphery", {}],
182
+ ["ShareTo", { runnable: "ShareTo.appex", appExtension: true }],
183
+ ["Storage", { runnable: "libStorage.a", testable: true }],
184
+ ["Sync", { runnable: "Sync.framework", testable: true }],
185
+ ["Telemetry", { runnable: "libSyncTelemetry.a", testable: true }],
186
+ ["Today", { runnable: "Client.app", testable: true, appExtension: true }],
187
+ ];
188
+ function firefoxIos() {
189
+ for (const [name, shape] of FIREFOX_IOS)
190
+ scheme("Client", name, shape);
191
+ return FIREFOX_IOS.map(([name]) => name);
192
+ }
193
+ test("picks the browser, not its widget", () => {
194
+ const names = firefoxIos();
195
+ expect(pick("Client.xcodeproj", names)).toBe("Fennec");
196
+ });
197
+ // The defect only became visible because a five-character extension name won
198
+ // a length tiebreak. The answer must not depend on the order either.
199
+ test("picks the browser whatever order the filesystem lists", () => {
200
+ const names = firefoxIos();
201
+ const orders = [
202
+ names,
203
+ [...names].reverse(),
204
+ ["Today", ...names.filter((n) => n !== "Today")],
205
+ ["ShareTo", "Today", ...names.filter((n) => n !== "Today" && n !== "ShareTo")],
206
+ ];
207
+ for (const order of orders) {
208
+ expect([order[0], pick("Client.xcodeproj", order)]).toEqual([order[0], "Fennec"]);
209
+ }
210
+ });
211
+ test("still keeps every scheme in the list", () => {
212
+ const names = firefoxIos();
213
+ expect([...rankSchemes(root, "Client.xcodeproj", names)].sort()).toEqual([...names].sort());
214
+ });
215
+ // The narrow statement, without the other twelve schemes around it.
216
+ test("an extension scheme launching the host app is not the app scheme", () => {
217
+ scheme("Client", "Fennec", { runnable: "Client.app", testable: true });
218
+ scheme("Client", "Today", { runnable: "Client.app", testable: true, appExtension: true });
219
+ expect(pick("Client.xcodeproj", ["Today", "Fennec"])).toBe("Fennec");
220
+ });
221
+ // Xcode's own word about the target outranks a name coincidence, so it
222
+ // disqualifies the name-match tier too.
223
+ test("outranks a scheme named exactly after the project", () => {
224
+ scheme("Widget", "Widget", { runnable: "Host.app", appExtension: true });
225
+ scheme("Widget", "HostApp", { runnable: "Host.app", testable: true });
226
+ expect(pick("Widget.xcodeproj", ["Widget", "HostApp"])).toBe("HostApp");
227
+ });
228
+ /**
229
+ * The attribute is a sufficient signal, not a necessary one — a hand-written
230
+ * or pre-Xcode-10 scheme may omit it. Its absence must change nothing, or
231
+ * every scheme that ranked correctly before this could regress.
232
+ *
233
+ * `chenqi92/primuse` is the row that makes the point concrete: its `Primuse`
234
+ * scheme is the real app scheme and builds two widget `.appex` bundles
235
+ * alongside `Primuse.app`, so "demote anything that builds an extension"
236
+ * would have taken the wrong one.
237
+ */
238
+ test("an untagged app scheme ranks exactly as it did before", () => {
239
+ scheme("Primuse", "Primuse", { runnable: "Primuse.app", testable: true });
240
+ scheme("Primuse", "PrimuseWidgets", { runnable: "Primuse.app" });
241
+ expect(pick("Primuse.xcodeproj", ["PrimuseWidgets", "Primuse"])).toBe("Primuse");
242
+ });
243
+ // When every candidate is an extension scheme there is no app scheme to
244
+ // find. Both land in the bottom tier, nothing is filtered out, and the
245
+ // pre-existing within-tier rule decides — so the caller still gets a
246
+ // deterministic answer rather than one that depends on the directory order.
247
+ test("still answers deterministically when every scheme is an extension", () => {
248
+ scheme("Client", "ShareTo", { runnable: "ShareTo.appex", appExtension: true });
249
+ scheme("Client", "Today", { runnable: "Client.app", appExtension: true });
250
+ for (const order of [
251
+ ["Today", "ShareTo"],
252
+ ["ShareTo", "Today"],
253
+ ]) {
254
+ expect([order[0], pick("Client.xcodeproj", order)]).toEqual([order[0], "Today"]);
255
+ }
256
+ expect(rankSchemes(root, "Client.xcodeproj", ["ShareTo", "Today"])).toHaveLength(2);
257
+ });
258
+ });
152
259
  //# sourceMappingURL=ios-scheme-ranking.test.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invarn/cibuild",
3
- "version": "2.7.0",
3
+ "version": "2.7.1",
4
4
  "description": "CI Build CLI — local pipeline orchestration and validation",
5
5
  "type": "module",
6
6
  "main": "dist/cli.cjs",