@invarn/cibuild 2.7.4 → 2.7.6

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.
Files changed (64) hide show
  1. package/dist/cli.cjs +10 -10
  2. package/dist/src/commands/a-credential-target-follows-the-flavor-source-set.test.d.ts +38 -0
  3. package/dist/src/commands/a-credential-target-follows-the-flavor-source-set.test.d.ts.map +1 -0
  4. package/dist/src/commands/a-credential-target-follows-the-flavor-source-set.test.js +222 -0
  5. package/dist/src/commands/a-credential-two-workflows-cannot-share-is-declared-per-workflow.test.d.ts +41 -0
  6. package/dist/src/commands/a-credential-two-workflows-cannot-share-is-declared-per-workflow.test.d.ts.map +1 -0
  7. package/dist/src/commands/a-credential-two-workflows-cannot-share-is-declared-per-workflow.test.js +324 -0
  8. package/dist/src/commands/an-already-stored-value-does-not-answer-the-target-question.test.d.ts +29 -0
  9. package/dist/src/commands/an-already-stored-value-does-not-answer-the-target-question.test.d.ts.map +1 -0
  10. package/dist/src/commands/an-already-stored-value-does-not-answer-the-target-question.test.js +160 -0
  11. package/dist/src/commands/android-scanner.d.ts +9 -1
  12. package/dist/src/commands/android-scanner.d.ts.map +1 -1
  13. package/dist/src/commands/android-scanner.js +103 -3
  14. package/dist/src/commands/build.d.ts +128 -1
  15. package/dist/src/commands/build.d.ts.map +1 -1
  16. package/dist/src/commands/build.js +286 -49
  17. package/dist/src/commands/each-flavor-is-asked-for-its-own-properties-file.test.d.ts +26 -0
  18. package/dist/src/commands/each-flavor-is-asked-for-its-own-properties-file.test.d.ts.map +1 -0
  19. package/dist/src/commands/each-flavor-is-asked-for-its-own-properties-file.test.js +196 -0
  20. package/dist/src/commands/file-secret-collector.d.ts +13 -0
  21. package/dist/src/commands/file-secret-collector.d.ts.map +1 -1
  22. package/dist/src/commands/file-secret-collector.js +25 -7
  23. package/dist/src/commands/flavors-a-convention-plugin-creates-are-still-found.test.d.ts +38 -0
  24. package/dist/src/commands/flavors-a-convention-plugin-creates-are-still-found.test.d.ts.map +1 -0
  25. package/dist/src/commands/flavors-a-convention-plugin-creates-are-still-found.test.js +300 -0
  26. package/dist/src/commands/local-properties-reaches-every-workflow.test.js +2 -0
  27. package/dist/src/yaml/a-cache-path-must-not-swallow-a-credential.test.d.ts +28 -0
  28. package/dist/src/yaml/a-cache-path-must-not-swallow-a-credential.test.d.ts.map +1 -0
  29. package/dist/src/yaml/a-cache-path-must-not-swallow-a-credential.test.js +223 -0
  30. package/dist/src/yaml/a-declared-slot-may-name-the-workflow-it-belongs-to.test.d.ts +37 -0
  31. package/dist/src/yaml/a-declared-slot-may-name-the-workflow-it-belongs-to.test.d.ts.map +1 -0
  32. package/dist/src/yaml/a-declared-slot-may-name-the-workflow-it-belongs-to.test.js +255 -0
  33. package/dist/src/yaml/a-scoped-slot-must-name-a-workflow-the-pipeline-has.test.d.ts +34 -0
  34. package/dist/src/yaml/a-scoped-slot-must-name-a-workflow-the-pipeline-has.test.d.ts.map +1 -0
  35. package/dist/src/yaml/a-scoped-slot-must-name-a-workflow-the-pipeline-has.test.js +218 -0
  36. package/dist/src/yaml/a-value-this-instance-stored-is-a-value-it-has.test.d.ts +27 -0
  37. package/dist/src/yaml/a-value-this-instance-stored-is-a-value-it-has.test.d.ts.map +1 -0
  38. package/dist/src/yaml/a-value-this-instance-stored-is-a-value-it-has.test.js +126 -0
  39. package/dist/src/yaml/converter.d.ts +48 -0
  40. package/dist/src/yaml/converter.d.ts.map +1 -1
  41. package/dist/src/yaml/converter.js +117 -0
  42. package/dist/src/yaml/env-resolver.d.ts +52 -0
  43. package/dist/src/yaml/env-resolver.d.ts.map +1 -1
  44. package/dist/src/yaml/env-resolver.js +99 -1
  45. package/dist/src/yaml/secrets-manager.d.ts +13 -3
  46. package/dist/src/yaml/secrets-manager.d.ts.map +1 -1
  47. package/dist/src/yaml/secrets-manager.js +25 -14
  48. package/dist/src/yaml/steps/cocoapods-install-emits-valid-bash.test.d.ts +22 -0
  49. package/dist/src/yaml/steps/cocoapods-install-emits-valid-bash.test.d.ts.map +1 -0
  50. package/dist/src/yaml/steps/cocoapods-install-emits-valid-bash.test.js +97 -0
  51. package/dist/src/yaml/steps/cocoapods-skip-if-absent.test.d.ts +17 -0
  52. package/dist/src/yaml/steps/cocoapods-skip-if-absent.test.d.ts.map +1 -0
  53. package/dist/src/yaml/steps/cocoapods-skip-if-absent.test.js +132 -0
  54. package/dist/src/yaml/steps/index.d.ts.map +1 -1
  55. package/dist/src/yaml/steps/index.js +9 -0
  56. package/dist/src/yaml/steps/ios-deps.d.ts +35 -0
  57. package/dist/src/yaml/steps/ios-deps.d.ts.map +1 -1
  58. package/dist/src/yaml/steps/ios-deps.js +19 -4
  59. package/dist/src/yaml/steps/xcode-derived-data.test.d.ts +30 -0
  60. package/dist/src/yaml/steps/xcode-derived-data.test.d.ts.map +1 -0
  61. package/dist/src/yaml/steps/xcode-derived-data.test.js +103 -0
  62. package/dist/src/yaml/steps/xcode.d.ts.map +1 -1
  63. package/dist/src/yaml/steps/xcode.js +73 -20
  64. package/package.json +1 -1
@@ -135,6 +135,158 @@ export function secretsPropertiesFileFrom(warnings) {
135
135
  }
136
136
  return "local.properties";
137
137
  }
138
+ /**
139
+ * The product flavor a variant builds, out of the flavors the scanner found —
140
+ * `ciDynamicDebug` is a build of `ciDynamic`, `prodRelease` of `prod`.
141
+ *
142
+ * An Android variant name is the flavor followed by the capitalised build
143
+ * type, so the character after the flavor has to be an upper-case one. That
144
+ * check is load-bearing: without it a flavor called `pro` would claim
145
+ * `prodRelease`. The longest match wins, so `ciDynamic` beats `ci` on a
146
+ * repository that declares both.
147
+ */
148
+ export function flavorOfVariant(variant, productFlavors) {
149
+ const matches = productFlavors
150
+ .filter((flavor) => {
151
+ if (flavor.length === 0 || !variant.startsWith(flavor))
152
+ return false;
153
+ const head = variant.slice(flavor.length)[0];
154
+ return head !== undefined && head === head.toUpperCase() && head !== head.toLowerCase();
155
+ })
156
+ .sort((a, b) => b.length - a.length);
157
+ return matches[0] ?? null;
158
+ }
159
+ /** Where a flavor's `google-services.json` belongs inside the app module. */
160
+ export function googleServicesPathFor(flavor, module = "app") {
161
+ return flavor
162
+ ? `${module}/src/${flavor}/google-services.json`
163
+ : `${module}/google-services.json`;
164
+ }
165
+ /**
166
+ * The per-workflow path and key maps for `SetupOptions`, from the variants the
167
+ * workflows build and the flavors the scanner found.
168
+ *
169
+ * `app/google-services.json` used to be hardcoded here. For a flavored build
170
+ * that is the plugin's *fallback* location, not the right one, and writing
171
+ * there is silently wrong rather than loudly wrong: the plugin finds a file,
172
+ * parses it, and fails on a package name — which reads as a Firebase
173
+ * misconfiguration rather than as the wrong file having been delivered. That
174
+ * is what `bld_8e60d90ab4e55f85d2f72459` spent 1292 Gradle tasks proving.
175
+ */
176
+ export function googleServicesTargets(variants, productFlavors, module = "app") {
177
+ const paths = {};
178
+ for (const [workflow, config] of variantByWorkflow(variants)) {
179
+ const flavor = flavorOfVariant(config.variant, productFlavors);
180
+ paths[workflow] = googleServicesPathFor(flavor, module);
181
+ }
182
+ return paths;
183
+ }
184
+ /** The three workflows every generated pipeline has, in the order they are emitted. */
185
+ export const GENERATED_WORKFLOWS = ["primary", "pull-request", "release"];
186
+ /** The variant each workflow builds, keyed by the workflow's name in the YAML. */
187
+ function variantByWorkflow(variants) {
188
+ return [
189
+ ["primary", variants.primary],
190
+ ["pull-request", variants.pullRequest],
191
+ ["release", variants.release],
192
+ ];
193
+ }
194
+ /**
195
+ * The `workflowFlavors` map for `SetupOptions`, from the variants the
196
+ * workflows build and the flavors the scanner found.
197
+ */
198
+ export function variantFlavors(variants, productFlavors) {
199
+ const flavors = {};
200
+ for (const [workflow, config] of variantByWorkflow(variants)) {
201
+ flavors[workflow] = flavorOfVariant(config.variant, productFlavors);
202
+ }
203
+ return flavors;
204
+ }
205
+ /**
206
+ * The `envs` slots one credential needs: one when every workflow that reads it
207
+ * wants the same bytes, one per workflow when they do not.
208
+ *
209
+ * What "the same bytes" means is the credential's own business — a keystore
210
+ * follows the target path the pipeline declares for it, `google-services.json`
211
+ * the flavor's source set, `local.properties` the flavor itself — so the
212
+ * caller passes the discriminator it already computed, one entry per workflow
213
+ * that reads the credential, and this decides only whether they agree. A
214
+ * credential only one workflow reads can never disagree, which is why the
215
+ * genuinely release-only ones keep one slot however flavored the repository
216
+ * is.
217
+ *
218
+ * **The name never changes — the block it sits in does.** An agreed credential
219
+ * is declared once in `app.envs`; a disagreement is declared under the plain
220
+ * credential name in **each workflow's own `envs`**, which is where the YAML
221
+ * already says which workflow something belongs to. `EnvResolver` merges
222
+ * `app.envs` and then the running workflow's own, so `release`'s declaration is
223
+ * invisible to `primary` without anything being encoded.
224
+ *
225
+ * `CIBUILD_SW__<WORKFLOW>__<VAR>` is deliberately *not* used here. That prefix
226
+ * is the transport encoding `ci secrets upload` carries a workflow-scoped
227
+ * **value** under — Invarn's secret scopes have no workflow column, so the
228
+ * workflow travels in the key. A pipeline has structure instead, and spending
229
+ * the encoding there would be a second way to say what the block already says
230
+ * (Alex, 2026-09-10).
231
+ *
232
+ * Every workflow gets a slot, not only the odd one out: a workflow left without
233
+ * one would have neither a value nor the declaration `file@1.0.0` refuses over.
234
+ *
235
+ * The `file@` step's `var_name` is the same plain name either way.
236
+ */
237
+ export function credentialSlots(varName, discriminatorByWorkflow) {
238
+ const distinct = new Set(Object.values(discriminatorByWorkflow));
239
+ if (distinct.size <= 1)
240
+ return [{ slot: varName, workflow: null }];
241
+ return Object.keys(discriminatorByWorkflow).map((workflow) => ({
242
+ slot: varName,
243
+ workflow,
244
+ }));
245
+ }
246
+ /**
247
+ * One discriminator per workflow, defaulted the way the step that writes the
248
+ * credential defaults its own input — so a repository whose scan resolved
249
+ * nothing does not read as three workflows that disagree.
250
+ */
251
+ function discriminators(map, fallback) {
252
+ const out = {};
253
+ for (const workflow of GENERATED_WORKFLOWS)
254
+ out[workflow] = map[workflow] ?? fallback;
255
+ return out;
256
+ }
257
+ /**
258
+ * The groups `ci init` collects a credential's value in: one per distinct value
259
+ * of whatever that credential follows, in workflow order.
260
+ *
261
+ * The same discriminator `credentialSlots` splits the *declaration* by, and
262
+ * deliberately the same function computing it, so what `ci init` asks for and
263
+ * what the pipeline declares cannot drift apart. One group means one slot and
264
+ * one question; two groups mean one slot per workflow and a question per group.
265
+ */
266
+ export function collectionGroups(discriminatorByWorkflow, label) {
267
+ const byDiscriminator = new Map();
268
+ for (const [workflow, value] of Object.entries(discriminatorByWorkflow)) {
269
+ byDiscriminator.set(value, [...(byDiscriminator.get(value) ?? []), workflow]);
270
+ }
271
+ return [...byDiscriminator].map(([discriminator, workflows]) => ({
272
+ label: label(discriminator, workflows),
273
+ workflows,
274
+ discriminator,
275
+ }));
276
+ }
277
+ /**
278
+ * The groups the Secrets Gradle Plugin's properties file is collected in: one
279
+ * per flavor.
280
+ *
281
+ * Unlike `google-services.json`, nothing about this credential's target says
282
+ * the bytes differ — every workflow writes the same file to the same path.
283
+ * What differs is the content: the plugin turns the file into `BuildConfig`,
284
+ * and a flavor is an environment. Asking once and storing one value gives
285
+ * `prod` the CI backend's base URL, on a green build.
286
+ */
287
+ export function propertiesFileGroups(workflowFlavors) {
288
+ return collectionGroups(discriminators(workflowFlavors, ""), (flavor, workflows) => flavor ? `the ${flavor} flavor (${workflows.join(", ")})` : "every workflow");
289
+ }
138
290
  export function detectSetupNeeds(warnings) {
139
291
  const secretsPropertiesPath = secretsPropertiesFileFrom(warnings);
140
292
  return {
@@ -174,7 +326,7 @@ trigger_map:
174
326
  workflow: pull-request
175
327
  `;
176
328
  }
177
- export function generateAndroidPipeline(javaVersion = 17, setup = { keystore: false, keystoreProperties: false, googleServices: false, googlePlayDeploy: false, googlePlayPackageName: '', artifactType: 'apk', keystorePaths: {}, secretsProperties: false, secretsPropertiesPath: 'local.properties' }, variants = DEFAULT_VARIANTS, cacheTechnology = "gradle", metaNamespace = 'cibuild.io', defaultBranch = FALLBACK_DEFAULT_BRANCH) {
329
+ export function generateAndroidPipeline(javaVersion = 17, setup = { keystore: false, keystoreProperties: false, googleServices: false, googlePlayDeploy: false, googlePlayPackageName: '', artifactType: 'apk', keystorePaths: {}, googleServicesPaths: {}, workflowFlavors: {}, secretsProperties: false, secretsPropertiesPath: 'local.properties' }, variants = DEFAULT_VARIANTS, cacheTechnology = "gradle", metaNamespace = 'cibuild.io', defaultBranch = FALLBACK_DEFAULT_BRANCH) {
178
330
  const keystoreStepFor = (wf) => {
179
331
  if (!setup.keystore)
180
332
  return "";
@@ -191,26 +343,58 @@ export function generateAndroidPipeline(javaVersion = 17, setup = { keystore: fa
191
343
  };
192
344
  // Add BUILD_TYPE override to workflow envs when it differs from the global default ("Debug")
193
345
  const buildTypeEnvOverride = (v) => v.buildType !== "Debug" ? ` - BUILD_TYPE: ${v.buildType}\n` : "";
194
- // Optional env var stubs (empty defaults — filled via ci secrets add)
195
- const keystoreEnv = setup.keystore
196
- ? ` - KEYSTORE_BASE64: "" # Set via: ci secrets add KEYSTORE_BASE64 -w primary\n`
197
- : "";
198
- const keystorePropertiesEnv = setup.keystoreProperties
199
- ? ` - KEYSTORE_PROPERTIES: "" # Release signing — set via: ci secrets add KEYSTORE_PROPERTIES -w release\n`
200
- : "";
201
- const googleServicesEnv = setup.googleServices
202
- ? ` - GOOGLE_SERVICES_JSON: "" # Set via: ci secrets add GOOGLE_SERVICES_JSON\n`
203
- : "";
204
- const googlePlayEnv = setup.googlePlayDeploy
205
- ? ` - GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: "" # Set via: ci secrets add GOOGLE_PLAY_SERVICE_ACCOUNT_JSON -w release\n`
206
- : "";
207
- // Declared globally and on purpose: `unsetEnvKeys` — what `invarn setup`
208
- // searches the developer's machine by — reads `envs`, never step inputs. An
209
- // undeclared LOCAL_PROPERTIES is never discovered and never offered for
210
- // upload, however many steps name it.
211
- const secretsPropertiesEnv = setup.secretsProperties
212
- ? ` - LOCAL_PROPERTIES: "" # Read by the Secrets Gradle Plugin — set via: ci secrets add LOCAL_PROPERTIES\n`
213
- : "";
346
+ // Optional env var stubs (empty defaults — filled via ci secrets add).
347
+ //
348
+ // Declared and not merely used by a step: `unsetEnvKeys` — what `invarn
349
+ // setup` searches the developer's machine by — reads `envs`, never step
350
+ // inputs. An undeclared credential is never discovered and never offered for
351
+ // upload, however many steps name it. The same declaration is what
352
+ // `file@1.0.0` refuses over when nobody supplied the value.
353
+ //
354
+ // One slot per credential where every workflow that reads it wants the same
355
+ // bytes, and one per workflow where they do not — see `credentialSlots`. The
356
+ // name is the plain credential either way; a per-workflow one is declared in
357
+ // that workflow's own `envs` rather than under an encoded name.
358
+ const appEnvDeclarations = [];
359
+ const workflowEnvDeclarations = {};
360
+ const declare = (varName, discriminatorByWorkflow, comment) => {
361
+ for (const { slot, workflow } of credentialSlots(varName, discriminatorByWorkflow)) {
362
+ if (workflow === null) {
363
+ appEnvDeclarations.push(` - ${slot}: ""${comment(null)}\n`);
364
+ continue;
365
+ }
366
+ // Six spaces, not four: a workflow's `envs` sits one level deeper.
367
+ (workflowEnvDeclarations[workflow] ??= []).push(` - ${slot}: ""${comment(workflow)}\n`);
368
+ }
369
+ };
370
+ /** Every credential declaration this workflow owns, after its own variables. */
371
+ const workflowEnvFor = (workflow) => (workflowEnvDeclarations[workflow] ?? []).join("");
372
+ // Declared in the order they appear in `app.envs`, which the byte-identity
373
+ // test on an unflavored pipeline pins.
374
+ if (setup.keystore) {
375
+ declare("KEYSTORE_BASE64", discriminators(setup.keystorePaths, "keystore.jks"), (wf) => wf
376
+ ? ` # Set via: ci secrets add KEYSTORE_BASE64 -w ${wf}`
377
+ : ` # Set via: ci secrets add KEYSTORE_BASE64 -w primary`);
378
+ }
379
+ // Release-only, and legitimately so: nothing but `release` signs a release
380
+ // or talks to Google Play, so there is no second workflow to disagree with.
381
+ if (setup.keystoreProperties) {
382
+ appEnvDeclarations.push(` - KEYSTORE_PROPERTIES: "" # Release signing — set via: ci secrets add KEYSTORE_PROPERTIES -w release\n`);
383
+ }
384
+ if (setup.googleServices) {
385
+ declare("GOOGLE_SERVICES_JSON", discriminators(setup.googleServicesPaths, "app/google-services.json"), (wf) => wf
386
+ ? ` # Set via: ci secrets add GOOGLE_SERVICES_JSON -w ${wf}`
387
+ : ` # Set via: ci secrets add GOOGLE_SERVICES_JSON`);
388
+ }
389
+ if (setup.googlePlayDeploy) {
390
+ appEnvDeclarations.push(` - GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: "" # Set via: ci secrets add GOOGLE_PLAY_SERVICE_ACCOUNT_JSON -w release\n`);
391
+ }
392
+ if (setup.secretsProperties) {
393
+ declare("LOCAL_PROPERTIES", discriminators(setup.workflowFlavors, ""), (wf) => wf
394
+ ? ` # Read by the Secrets Gradle Plugin — set via: ci secrets add LOCAL_PROPERTIES -w ${wf}`
395
+ : ` # Read by the Secrets Gradle Plugin — set via: ci secrets add LOCAL_PROPERTIES`);
396
+ }
397
+ const appEnv = appEnvDeclarations.join("");
214
398
  /**
215
399
  * The Secrets Gradle Plugin's properties file, written into every workflow
216
400
  * that builds. Its evaluation listener runs for every variant, so a debug
@@ -227,15 +411,28 @@ export function generateAndroidPipeline(javaVersion = 17, setup = { keystore: fa
227
411
  var_name: LOCAL_PROPERTIES
228
412
  `
229
413
  : "";
230
- const googleServicesStep = setup.googleServices
231
- ? `
414
+ /**
415
+ * Per workflow, exactly as the keystore is: the flavor a workflow builds
416
+ * decides where the file is written. A workflow whose variant names no
417
+ * flavor is unchanged — `app/google-services.json`.
418
+ *
419
+ * The `var_name` is the same in every workflow on purpose. Scoping a value
420
+ * to a workflow is the *secret's* job, not the step's: `CIBUILD_SW__<WF>__X`
421
+ * is decoded by `SecretsManager` and resolved by `EnvResolver` for the
422
+ * workflow being run, so one name here reads a different value there.
423
+ */
424
+ const googleServicesStepFor = (wf) => {
425
+ if (!setup.googleServices)
426
+ return "";
427
+ const path = setup.googleServicesPaths[wf] ?? "app/google-services.json";
428
+ return `
232
429
  - file@1.0.0:
233
430
  is_skippable: true
234
431
  inputs:
235
- target_path: app/google-services.json
432
+ target_path: ${path}
236
433
  var_name: GOOGLE_SERVICES_JSON
237
- `
238
- : "";
434
+ `;
435
+ };
239
436
  const googlePlayStep = setup.googlePlayDeploy
240
437
  ? `
241
438
  - file@1.0.0:
@@ -290,14 +487,14 @@ app:
290
487
  - MODULE: app
291
488
  - BUILD_TYPE: Debug
292
489
  - GRADLE_OPTS: -Dorg.gradle.daemon=false -Dorg.gradle.parallel=true
293
- ${keystoreEnv}${keystorePropertiesEnv}${googleServicesEnv}${googlePlayEnv}${secretsPropertiesEnv}
490
+ ${appEnv}
294
491
  workflows:
295
492
  primary:
296
493
  envs:
297
494
  - VARIANT: ${pv.variant}
298
- ${buildTypeEnvOverride(pv)}
495
+ ${buildTypeEnvOverride(pv)}${workflowEnvFor("primary")}
299
496
  steps:
300
- ${keystoreStepFor("primary")}${googleServicesStep}${secretsPropertiesStep}
497
+ ${keystoreStepFor("primary")}${googleServicesStepFor("primary")}${secretsPropertiesStep}
301
498
  - set-java-version@1.0.0:
302
499
  inputs:
303
500
  java_version: '${javaVersion}'
@@ -335,9 +532,9 @@ ${keystoreStepFor("primary")}${googleServicesStep}${secretsPropertiesStep}
335
532
  pull-request:
336
533
  envs:
337
534
  - VARIANT: ${prv.variant}
338
- ${buildTypeEnvOverride(prv)}
535
+ ${buildTypeEnvOverride(prv)}${workflowEnvFor("pull-request")}
339
536
  steps:
340
- ${keystoreStepFor("pull-request")}${googleServicesStep}${secretsPropertiesStep}
537
+ ${keystoreStepFor("pull-request")}${googleServicesStepFor("pull-request")}${secretsPropertiesStep}
341
538
  - set-java-version@1.0.0:
342
539
  inputs:
343
540
  java_version: '${javaVersion}'
@@ -374,9 +571,9 @@ ${keystoreStepFor("pull-request")}${googleServicesStep}${secretsPropertiesStep}
374
571
  release:
375
572
  envs:
376
573
  - VARIANT: ${rv.variant}
377
- ${buildTypeEnvOverride(rv)}
574
+ ${buildTypeEnvOverride(rv)}${workflowEnvFor("release")}
378
575
  steps:
379
- ${keystoreStepFor("release")}${keystorePropertiesStep}${googleServicesStep}${secretsPropertiesStep}
576
+ ${keystoreStepFor("release")}${keystorePropertiesStep}${googleServicesStepFor("release")}${secretsPropertiesStep}
380
577
  - set-java-version@1.0.0:
381
578
  inputs:
382
579
  java_version: '${javaVersion}'
@@ -943,7 +1140,13 @@ async function handleAndroidBuildCommand(cwd, options, cacheTechnology) {
943
1140
  }
944
1141
  // 4. Prompt to generate setup steps for detected file issues
945
1142
  const setupNeeds = detectSetupNeeds(scanResult.warnings);
946
- const setupOptions = { keystore: false, keystoreProperties: false, googleServices: false, googlePlayDeploy: false, googlePlayPackageName: '', artifactType: 'apk', keystorePaths: {}, secretsProperties: false, secretsPropertiesPath: setupNeeds.secretsPropertiesPath };
1143
+ // Which flavor each workflow builds decides where its google-services.json
1144
+ // is written, and — with no path to give it away — whether the properties
1145
+ // file can be one value or has to be one per workflow. Both maps are
1146
+ // derived from the variants chosen just above.
1147
+ const googleServicesPaths = googleServicesTargets(variants, productFlavors);
1148
+ const workflowFlavors = variantFlavors(variants, productFlavors);
1149
+ const setupOptions = { keystore: false, keystoreProperties: false, googleServices: false, googlePlayDeploy: false, googlePlayPackageName: '', artifactType: 'apk', keystorePaths: {}, googleServicesPaths, workflowFlavors, secretsProperties: false, secretsPropertiesPath: setupNeeds.secretsPropertiesPath };
947
1150
  const secretsManager = new SecretsManager();
948
1151
  const keystoreWorkflowGroups = [
949
1152
  { label: "debug builds (primary, pull-request)", workflows: ["primary", "pull-request"] },
@@ -1035,16 +1238,38 @@ async function handleAndroidBuildCommand(cwd, options, cacheTechnology) {
1035
1238
  setupOptions.googleServices = !!create;
1036
1239
  }
1037
1240
  if (setupOptions.googleServices) {
1038
- await collectFileSecret({
1039
- displayName: "google-services.json",
1040
- secretName: "GOOGLE_SERVICES_JSON",
1041
- fileMatch: (name) => name === "google-services.json",
1042
- readAs: "text",
1043
- workflowGroups: [
1044
- { label: "debug builds (primary, pull-request)", workflows: ["primary", "pull-request"] },
1045
- { label: "release", workflows: ["release"] },
1046
- ],
1047
- }, cwd, secretsManager, ni);
1241
+ // One collection per distinct target path. Workflows building the same
1242
+ // flavor write to the same place and are collected together; workflows
1243
+ // building different flavors need different files, so each is asked
1244
+ // separately and pinned to its own source set — which is what lets a
1245
+ // flavored repository auto-select rather than guess.
1246
+ //
1247
+ // The secret name is the same every time, and the *value* is stored per
1248
+ // workflow. That is what `SecretsManager` has always done, what
1249
+ // `ci secrets upload` encodes as `CIBUILD_SW__<WF>__<VAR>`, and what
1250
+ // `EnvResolver` resolves for the workflow being run.
1251
+ const byTarget = collectionGroups(discriminators(setupOptions.googleServicesPaths, "app/google-services.json"), (_path, workflows) => workflows.join(", "));
1252
+ for (const group of byTarget) {
1253
+ const flavorDir = /\/src\/([^/]+)\//.exec(group.discriminator)?.[1] ?? null;
1254
+ await collectFileSecret({
1255
+ displayName: flavorDir
1256
+ ? `google-services.json for the ${flavorDir} flavor`
1257
+ : "google-services.json",
1258
+ secretName: "GOOGLE_SERVICES_JSON",
1259
+ fileMatch: (name) => name === "google-services.json",
1260
+ // A flavored target takes the copy from that flavor's source set. An
1261
+ // unflavored one takes anything that is *not* a flavor's copy, so
1262
+ // the module root wins rather than an arbitrary source set.
1263
+ pathMatch: flavorDir
1264
+ ? (rel) => rel.includes(`/src/${flavorDir}/`) || rel.startsWith(`src/${flavorDir}/`)
1265
+ : (rel) => !/(?:^|\/)src\/[^/]+\//.test(rel),
1266
+ readAs: "text",
1267
+ // One group per call, not all of them: each group narrows the search
1268
+ // to its own flavor's source set, which is what lets a flavored
1269
+ // repository auto-select rather than ask.
1270
+ workflowGroups: [group],
1271
+ }, cwd, secretsManager, ni);
1272
+ }
1048
1273
  }
1049
1274
  }
1050
1275
  if (setupNeeds.secretsProperties) {
@@ -1064,14 +1289,18 @@ async function handleAndroidBuildCommand(cwd, options, cacheTechnology) {
1064
1289
  if (setupOptions.secretsProperties) {
1065
1290
  // Every workflow, not just release: the plugin's evaluation listener
1066
1291
  // runs for every variant, so a debug build cannot configure without it.
1292
+ //
1293
+ // And one group per flavor, because the plugin turns this file into
1294
+ // BuildConfig and a flavor is an environment. Every group in one call,
1295
+ // unlike google-services.json above: there is no per-flavor path
1296
+ // convention for a properties file to narrow the search by, so the
1297
+ // groups differ only in which answer they take.
1067
1298
  await collectFileSecret({
1068
1299
  displayName: propertiesFile,
1069
1300
  secretName: "LOCAL_PROPERTIES",
1070
1301
  fileMatch: (name) => name === propertiesFile.split("/").pop(),
1071
1302
  readAs: "text",
1072
- workflowGroups: [
1073
- { label: "every workflow", workflows: ["primary", "pull-request", "release"] },
1074
- ],
1303
+ workflowGroups: propertiesFileGroups(setupOptions.workflowFlavors),
1075
1304
  }, cwd, secretsManager, ni);
1076
1305
  }
1077
1306
  }
@@ -1156,8 +1385,16 @@ async function handleAndroidBuildCommand(cwd, options, cacheTechnology) {
1156
1385
  console.log(" • Setup Keystore (KEYSTORE_BASE64)");
1157
1386
  if (setupOptions.keystoreProperties)
1158
1387
  console.log(" • Setup keystore.properties (KEYSTORE_PROPERTIES) — release only");
1159
- if (setupOptions.googleServices)
1160
- console.log(" • Setup google-services.json (GOOGLE_SERVICES_JSON)");
1388
+ if (setupOptions.googleServices) {
1389
+ const seen = new Set();
1390
+ for (const wf of GENERATED_WORKFLOWS) {
1391
+ const path = setupOptions.googleServicesPaths[wf];
1392
+ if (!path || seen.has(path))
1393
+ continue;
1394
+ seen.add(path);
1395
+ console.log(` • Setup ${path} (GOOGLE_SERVICES_JSON)`);
1396
+ }
1397
+ }
1161
1398
  if (setupOptions.googlePlayDeploy)
1162
1399
  console.log(` • Google Play deploy (${setupOptions.googlePlayPackageName}) — release only`);
1163
1400
  }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * `ci init` asks once per flavor for the properties file the Secrets Gradle
3
+ * Plugin reads, and stores the answer against that flavor's workflows.
4
+ *
5
+ * The generator now declares this credential once per workflow when the
6
+ * workflows build different flavors, because two flavors are two environments
7
+ * and one value cannot serve both. Collection had not moved: `collectFileSecret`
8
+ * was called with a single group labelled *"every workflow"*, so `ci init`
9
+ * asked once and stored the same bytes against `primary`, `pull-request` and
10
+ * `release`. The declaration would then say three values are needed and the
11
+ * store would hold one, three times.
12
+ *
13
+ * The machinery for this was already there. `collectFileSecret` takes
14
+ * `workflowGroups`, prompts per group, and stores per workflow — that is how
15
+ * the keystore has always collected a debug key and a release key separately.
16
+ * The properties file was the one credential passing it a single group.
17
+ *
18
+ * What matters more than the split is that it is the *same* split: `ci init`
19
+ * groups by exactly the discriminator `credentialSlots` declares by, so the
20
+ * question asked and the slot declared cannot drift apart. One group means one
21
+ * slot and one question; two groups mean one slot per workflow and a question
22
+ * per group. That invariant is asserted directly rather than left to two
23
+ * copies of the same rule staying in step.
24
+ */
25
+ export {};
26
+ //# sourceMappingURL=each-flavor-is-asked-for-its-own-properties-file.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"each-flavor-is-asked-for-its-own-properties-file.test.d.ts","sourceRoot":"","sources":["../../../src/commands/each-flavor-is-asked-for-its-own-properties-file.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG"}
@@ -0,0 +1,196 @@
1
+ /**
2
+ * `ci init` asks once per flavor for the properties file the Secrets Gradle
3
+ * Plugin reads, and stores the answer against that flavor's workflows.
4
+ *
5
+ * The generator now declares this credential once per workflow when the
6
+ * workflows build different flavors, because two flavors are two environments
7
+ * and one value cannot serve both. Collection had not moved: `collectFileSecret`
8
+ * was called with a single group labelled *"every workflow"*, so `ci init`
9
+ * asked once and stored the same bytes against `primary`, `pull-request` and
10
+ * `release`. The declaration would then say three values are needed and the
11
+ * store would hold one, three times.
12
+ *
13
+ * The machinery for this was already there. `collectFileSecret` takes
14
+ * `workflowGroups`, prompts per group, and stores per workflow — that is how
15
+ * the keystore has always collected a debug key and a release key separately.
16
+ * The properties file was the one credential passing it a single group.
17
+ *
18
+ * What matters more than the split is that it is the *same* split: `ci init`
19
+ * groups by exactly the discriminator `credentialSlots` declares by, so the
20
+ * question asked and the slot declared cannot drift apart. One group means one
21
+ * slot and one question; two groups mean one slot per workflow and a question
22
+ * per group. That invariant is asserted directly rather than left to two
23
+ * copies of the same rule staying in step.
24
+ */
25
+ import { describe, test, expect, beforeEach, afterEach } from '@jest/globals';
26
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
27
+ import { tmpdir } from 'node:os';
28
+ import { join, resolve } from 'node:path';
29
+ import prompts from 'prompts';
30
+ import { collectionGroups, credentialSlots, propertiesFileGroups, variantFlavors, } from './build.js';
31
+ import { collectFileSecret } from './file-secret-collector.js';
32
+ import { SecretsManager } from '../yaml/secrets-manager.js';
33
+ function variant(name) {
34
+ const buildType = name.charAt(0).toUpperCase() + name.slice(1);
35
+ return { variant: name, buildType, gradleTask: `assemble${buildType}` };
36
+ }
37
+ const FLAVORED = {
38
+ primary: variant('ciDynamicDebug'),
39
+ pullRequest: variant('ciDynamicDebug'),
40
+ release: variant('prodRelease'),
41
+ };
42
+ const UNFLAVORED = {
43
+ primary: variant('debug'),
44
+ pullRequest: variant('debug'),
45
+ release: variant('release'),
46
+ };
47
+ const FLAVORS = ['ciDynamic', 'prod'];
48
+ const CI_DYNAMIC = 'BASE_API_URL=https://ci-dynamic.example.test/api\n';
49
+ const PROD = 'BASE_API_URL=https://prod.example.test/api\n';
50
+ describe('the groups a credential is collected in', () => {
51
+ test('are one when every workflow wants the same bytes', () => {
52
+ const groups = collectionGroups({ primary: 'ciDynamic', 'pull-request': 'ciDynamic', release: 'ciDynamic' }, () => 'every workflow');
53
+ expect(groups).toEqual([
54
+ { label: 'every workflow', workflows: ['primary', 'pull-request', 'release'], discriminator: 'ciDynamic' },
55
+ ]);
56
+ });
57
+ test('are one per distinct value, keeping the workflows that agree together', () => {
58
+ const groups = collectionGroups({ primary: 'ciDynamic', 'pull-request': 'ciDynamic', release: 'prod' }, (flavor) => flavor);
59
+ expect(groups).toEqual([
60
+ { label: 'ciDynamic', workflows: ['primary', 'pull-request'], discriminator: 'ciDynamic' },
61
+ { label: 'prod', workflows: ['release'], discriminator: 'prod' },
62
+ ]);
63
+ });
64
+ test('split exactly when the declaration splits', () => {
65
+ // Two copies of one rule is one copy too many. If these ever disagree,
66
+ // `ci init` asks a question the pipeline has no slot for, or declares a
67
+ // slot nothing on the machine was ever collected into.
68
+ const maps = [
69
+ { primary: '', 'pull-request': '', release: '' },
70
+ { primary: 'ciDynamic', 'pull-request': 'ciDynamic', release: 'prod' },
71
+ { primary: 'a', 'pull-request': 'b', release: 'c' },
72
+ { release: 'keystore.properties' },
73
+ { primary: 'app/one.jks', 'pull-request': 'app/one.jks', release: 'app/one.jks' },
74
+ ];
75
+ expect(maps.map((map) => ({
76
+ groups: collectionGroups(map, (d) => d).length === 1,
77
+ oneFlatSlot: credentialSlots('X', map).length === 1,
78
+ }))).toEqual([
79
+ { groups: true, oneFlatSlot: true },
80
+ { groups: false, oneFlatSlot: false },
81
+ { groups: false, oneFlatSlot: false },
82
+ { groups: true, oneFlatSlot: true },
83
+ { groups: true, oneFlatSlot: true },
84
+ ]);
85
+ });
86
+ });
87
+ describe('the groups the properties file is collected in', () => {
88
+ test('are one per flavor, and say which flavor', () => {
89
+ const groups = propertiesFileGroups(variantFlavors(FLAVORED, FLAVORS));
90
+ expect(groups.map((g) => ({ label: g.label, workflows: g.workflows }))).toEqual([
91
+ { label: 'the ciDynamic flavor (primary, pull-request)', workflows: ['primary', 'pull-request'] },
92
+ { label: 'the prod flavor (release)', workflows: ['release'] },
93
+ ]);
94
+ });
95
+ test('are one, labelled as they were, on a repository with no flavors', () => {
96
+ // The label is what the prompt says out loud, and this is the wording
97
+ // every unflavored repository has always seen.
98
+ expect(propertiesFileGroups(variantFlavors(UNFLAVORED, []))).toEqual([
99
+ { label: 'every workflow', workflows: ['primary', 'pull-request', 'release'], discriminator: '' },
100
+ ]);
101
+ });
102
+ test('are one when three workflows build three variants of one flavor', () => {
103
+ const oneFlavor = {
104
+ primary: variant('ciDynamicDebug'),
105
+ pullRequest: variant('ciDynamicDebug'),
106
+ release: variant('ciDynamicRelease'),
107
+ };
108
+ expect(propertiesFileGroups(variantFlavors(oneFlavor, FLAVORS))).toHaveLength(1);
109
+ });
110
+ });
111
+ describe('collecting a value for each group', () => {
112
+ let workdir;
113
+ let secretsManager;
114
+ // `prompts.inject` *concatenates*, so an empty array clears nothing and a
115
+ // sentinel one test leaves queued becomes the next test's answer. The queue
116
+ // itself is the only reset there is.
117
+ const clearInjected = () => {
118
+ prompts._injected = undefined;
119
+ };
120
+ beforeEach(() => {
121
+ clearInjected();
122
+ workdir = mkdtempSync(join(tmpdir(), 'cibuild-flavor-properties-'));
123
+ workdir = resolve(workdir);
124
+ secretsManager = new SecretsManager(join(workdir, '.cibuild-secrets.json'));
125
+ });
126
+ afterEach(() => {
127
+ clearInjected();
128
+ rmSync(workdir, { recursive: true, force: true });
129
+ });
130
+ function put(relPath, contents) {
131
+ const full = join(workdir, relPath);
132
+ mkdirSync(join(full, '..'), { recursive: true });
133
+ writeFileSync(full, contents, 'utf-8');
134
+ return full;
135
+ }
136
+ async function collect(groups, ni = false) {
137
+ return collectFileSecret({
138
+ displayName: 'local.properties',
139
+ secretName: 'LOCAL_PROPERTIES',
140
+ fileMatch: (name) => name === 'local.properties',
141
+ readAs: 'text',
142
+ workflowGroups: groups,
143
+ }, workdir, secretsManager, ni);
144
+ }
145
+ test('stores a different value against the workflows of each flavor', async () => {
146
+ const ciDynamicFile = put('local.properties', CI_DYNAMIC);
147
+ const prodFile = put('envs/prod/local.properties', PROD);
148
+ prompts.inject([ciDynamicFile, prodFile]);
149
+ await collect(propertiesFileGroups(variantFlavors(FLAVORED, FLAVORS)));
150
+ expect(secretsManager.getSecret('LOCAL_PROPERTIES', 'primary')).toBe(CI_DYNAMIC);
151
+ expect(secretsManager.getSecret('LOCAL_PROPERTIES', 'pull-request')).toBe(CI_DYNAMIC);
152
+ expect(secretsManager.getSecret('LOCAL_PROPERTIES', 'release')).toBe(PROD);
153
+ });
154
+ test('stores them where `ci secrets upload` will encode them per workflow', async () => {
155
+ const ciDynamicFile = put('local.properties', CI_DYNAMIC);
156
+ const prodFile = put('envs/prod/local.properties', PROD);
157
+ prompts.inject([ciDynamicFile, prodFile]);
158
+ await collect(propertiesFileGroups(variantFlavors(FLAVORED, FLAVORS)));
159
+ // Not `global`. A global value would satisfy all three workflows and the
160
+ // upload would push one flat key, which is the bug this closes.
161
+ const stored = JSON.parse(readFileSync(join(workdir, '.cibuild-secrets.json'), 'utf-8'));
162
+ expect(stored.global).toEqual({});
163
+ expect(Object.keys(stored.workflows).sort()).toEqual(['primary', 'pull-request', 'release']);
164
+ });
165
+ test('asks nothing at all when one group meets one file', async () => {
166
+ // Today's behaviour on every unflavored repository, unchanged: a single
167
+ // group and a single candidate auto-apply. The injected answer is
168
+ // unreadable on purpose — if a prompt fired, nothing would be stored.
169
+ put('local.properties', CI_DYNAMIC);
170
+ prompts.inject(['/nonexistent/would-be-a-prompt']);
171
+ await collect(propertiesFileGroups(variantFlavors(UNFLAVORED, [])));
172
+ for (const workflow of ['primary', 'pull-request', 'release']) {
173
+ expect(secretsManager.getSecret('LOCAL_PROPERTIES', workflow)).toBe(CI_DYNAMIC);
174
+ }
175
+ });
176
+ test('gives every group the same file when it cannot ask', async () => {
177
+ // `ci init --create` runs non-interactive. One file is all it can offer,
178
+ // and a wrong-flavored properties file that the user then corrects beats
179
+ // no properties file at all, which is a build that cannot configure.
180
+ put('local.properties', CI_DYNAMIC);
181
+ await collect(propertiesFileGroups(variantFlavors(FLAVORED, FLAVORS)), true);
182
+ expect(secretsManager.getSecret('LOCAL_PROPERTIES', 'primary')).toBe(CI_DYNAMIC);
183
+ expect(secretsManager.getSecret('LOCAL_PROPERTIES', 'release')).toBe(CI_DYNAMIC);
184
+ });
185
+ test('leaves a group alone once its value is already stored', async () => {
186
+ put('local.properties', CI_DYNAMIC);
187
+ put('envs/prod/local.properties', PROD);
188
+ secretsManager.storeSecret('LOCAL_PROPERTIES', PROD, 'release');
189
+ // One answer, for the one group still pending.
190
+ prompts.inject([join(workdir, 'local.properties')]);
191
+ await collect(propertiesFileGroups(variantFlavors(FLAVORED, FLAVORS)));
192
+ expect(secretsManager.getSecret('LOCAL_PROPERTIES', 'primary')).toBe(CI_DYNAMIC);
193
+ expect(secretsManager.getSecret('LOCAL_PROPERTIES', 'release')).toBe(PROD);
194
+ });
195
+ });
196
+ //# sourceMappingURL=each-flavor-is-asked-for-its-own-properties-file.test.js.map