@invarn/cibuild 2.7.5 → 2.7.7

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 (52) hide show
  1. package/dist/cli.cjs +10 -10
  2. package/dist/src/commands/a-credential-target-follows-the-flavor-source-set.test.js +9 -6
  3. package/dist/src/commands/a-credential-two-workflows-cannot-share-is-declared-per-workflow.test.js +71 -38
  4. package/dist/src/commands/an-already-stored-value-does-not-answer-the-target-question.test.d.ts +29 -0
  5. package/dist/src/commands/an-already-stored-value-does-not-answer-the-target-question.test.d.ts.map +1 -0
  6. package/dist/src/commands/an-already-stored-value-does-not-answer-the-target-question.test.js +160 -0
  7. package/dist/src/commands/android-scanner.d.ts +9 -1
  8. package/dist/src/commands/android-scanner.d.ts.map +1 -1
  9. package/dist/src/commands/android-scanner.js +103 -3
  10. package/dist/src/commands/build.d.ts +16 -12
  11. package/dist/src/commands/build.d.ts.map +1 -1
  12. package/dist/src/commands/build.js +64 -45
  13. package/dist/src/commands/file-secret-collector.d.ts.map +1 -1
  14. package/dist/src/commands/file-secret-collector.js +24 -7
  15. package/dist/src/commands/flavors-a-convention-plugin-creates-are-still-found.test.d.ts +38 -0
  16. package/dist/src/commands/flavors-a-convention-plugin-creates-are-still-found.test.d.ts.map +1 -0
  17. package/dist/src/commands/flavors-a-convention-plugin-creates-are-still-found.test.js +300 -0
  18. package/dist/src/commands/run.d.ts.map +1 -1
  19. package/dist/src/commands/run.js +1 -1
  20. package/dist/src/runner.d.ts +15 -1
  21. package/dist/src/runner.d.ts.map +1 -1
  22. package/dist/src/runner.js +24 -1
  23. package/dist/src/yaml/a-runtime-requirement-names-a-real-provider.test.d.ts +18 -0
  24. package/dist/src/yaml/a-runtime-requirement-names-a-real-provider.test.d.ts.map +1 -0
  25. package/dist/src/yaml/a-runtime-requirement-names-a-real-provider.test.js +66 -0
  26. package/dist/src/yaml/converter.d.ts +22 -0
  27. package/dist/src/yaml/converter.d.ts.map +1 -1
  28. package/dist/src/yaml/converter.js +26 -0
  29. package/dist/src/yaml/every-build-log-witnesses-the-commit-on-disk.test.d.ts +16 -0
  30. package/dist/src/yaml/every-build-log-witnesses-the-commit-on-disk.test.d.ts.map +1 -0
  31. package/dist/src/yaml/every-build-log-witnesses-the-commit-on-disk.test.js +126 -0
  32. package/dist/src/yaml/step-validator.d.ts.map +1 -1
  33. package/dist/src/yaml/step-validator.js +6 -2
  34. package/dist/src/yaml/steps/android.js +3 -3
  35. package/dist/src/yaml/steps/base.d.ts +6 -1
  36. package/dist/src/yaml/steps/base.d.ts.map +1 -1
  37. package/dist/src/yaml/steps/base.js +6 -1
  38. package/dist/src/yaml/steps/bitrise-android-tools.js +1 -1
  39. package/dist/src/yaml/steps/cocoapods-install-emits-valid-bash.test.d.ts +22 -0
  40. package/dist/src/yaml/steps/cocoapods-install-emits-valid-bash.test.d.ts.map +1 -0
  41. package/dist/src/yaml/steps/cocoapods-install-emits-valid-bash.test.js +97 -0
  42. package/dist/src/yaml/steps/git-clone-names-the-inputs-it-ignores.test.d.ts +16 -0
  43. package/dist/src/yaml/steps/git-clone-names-the-inputs-it-ignores.test.d.ts.map +1 -0
  44. package/dist/src/yaml/steps/git-clone-names-the-inputs-it-ignores.test.js +127 -0
  45. package/dist/src/yaml/steps/git-clone.d.ts +1 -1
  46. package/dist/src/yaml/steps/git-clone.d.ts.map +1 -1
  47. package/dist/src/yaml/steps/git-clone.js +56 -2
  48. package/dist/src/yaml/steps/ios-deps.js +1 -1
  49. package/dist/src/yaml/steps/xcode.js +5 -5
  50. package/dist/src/yaml/validation-types.d.ts +7 -1
  51. package/dist/src/yaml/validation-types.d.ts.map +1 -1
  52. package/package.json +1 -1
@@ -4,7 +4,6 @@ import prompts from "prompts";
4
4
  import { scanAndroidProject, formatScanResult, runnableJavaVersion, javaVersionProblem, } from "./android-scanner.js";
5
5
  import { scanIosProject, formatIosScanResult } from "./ios-scanner.js";
6
6
  import { SecretsManager } from "../yaml/secrets-manager.js";
7
- import { encodeSecretName } from "./secrets-upload.js";
8
7
  import { collectFileSecret } from "./file-secret-collector.js";
9
8
  import { generateGitHubActionsWorkflow } from "./github-workflow.js";
10
9
  import { detectDefaultBranch, FALLBACK_DEFAULT_BRANCH } from "../shared/git.js";
@@ -216,27 +215,31 @@ export function variantFlavors(variants, productFlavors) {
216
215
  * genuinely release-only ones keep one slot however flavored the repository
217
216
  * is.
218
217
  *
219
- * A disagreement is declared under `CIBUILD_SW__<WORKFLOW>__<VAR>`: the name
220
- * `ci secrets upload` already encodes a workflow-scoped value under, that
221
- * `SecretsManager` already decodes, and that `EnvResolver` already resolves
222
- * for the workflow being run. Invarn's secret scopes have no workflow column,
223
- * but a flat key→value map carries a workflow perfectly well when the workflow
224
- * is part of the key.
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.
225
224
  *
226
- * Every workflow gets a slot, not only the odd one out. The encoded name is
227
- * per workflow, so a workflow left without one would have neither a value nor
228
- * the declaration `file@1.0.0` refuses over.
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).
229
231
  *
230
- * The `file@` step's `var_name` stays unsuffixed either way. The decoding does
231
- * the mapping; a second naming convention in the step would be a second way to
232
- * say the same thing.
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.
233
236
  */
234
237
  export function credentialSlots(varName, discriminatorByWorkflow) {
235
238
  const distinct = new Set(Object.values(discriminatorByWorkflow));
236
239
  if (distinct.size <= 1)
237
240
  return [{ slot: varName, workflow: null }];
238
241
  return Object.keys(discriminatorByWorkflow).map((workflow) => ({
239
- slot: encodeSecretName(varName, workflow),
242
+ slot: varName,
240
243
  workflow,
241
244
  }));
242
245
  }
@@ -342,40 +345,56 @@ export function generateAndroidPipeline(javaVersion = 17, setup = { keystore: fa
342
345
  const buildTypeEnvOverride = (v) => v.buildType !== "Debug" ? ` - BUILD_TYPE: ${v.buildType}\n` : "";
343
346
  // Optional env var stubs (empty defaults — filled via ci secrets add).
344
347
  //
345
- // Declared globally and on purpose: `unsetEnvKeys` — what `invarn setup`
346
- // searches the developer's machine by — reads `envs`, never step inputs. An
347
- // undeclared credential is never discovered and never offered for upload,
348
- // however many steps name it.
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.
349
353
  //
350
354
  // One slot per credential where every workflow that reads it wants the same
351
- // bytes, and one per workflow where they do not — see `credentialSlots`.
352
- // The `var_name` in the step below stays unsuffixed either way.
353
- const declare = (varName, discriminatorByWorkflow, comment) => credentialSlots(varName, discriminatorByWorkflow)
354
- .map(({ slot, workflow }) => ` - ${slot}: ""${comment(workflow)}\n`)
355
- .join("");
356
- const keystoreEnv = setup.keystore
357
- ? declare("KEYSTORE_BASE64", discriminators(setup.keystorePaths, "keystore.jks"), (wf) => wf
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
358
376
  ? ` # Set via: ci secrets add KEYSTORE_BASE64 -w ${wf}`
359
- : ` # Set via: ci secrets add KEYSTORE_BASE64 -w primary`)
360
- : "";
377
+ : ` # Set via: ci secrets add KEYSTORE_BASE64 -w primary`);
378
+ }
361
379
  // Release-only, and legitimately so: nothing but `release` signs a release
362
380
  // or talks to Google Play, so there is no second workflow to disagree with.
363
- const keystorePropertiesEnv = setup.keystoreProperties
364
- ? ` - KEYSTORE_PROPERTIES: "" # Release signing — set via: ci secrets add KEYSTORE_PROPERTIES -w release\n`
365
- : "";
366
- const googleServicesEnv = setup.googleServices
367
- ? declare("GOOGLE_SERVICES_JSON", discriminators(setup.googleServicesPaths, "app/google-services.json"), (wf) => wf
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
368
386
  ? ` # Set via: ci secrets add GOOGLE_SERVICES_JSON -w ${wf}`
369
- : ` # Set via: ci secrets add GOOGLE_SERVICES_JSON`)
370
- : "";
371
- const googlePlayEnv = setup.googlePlayDeploy
372
- ? ` - GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: "" # Set via: ci secrets add GOOGLE_PLAY_SERVICE_ACCOUNT_JSON -w release\n`
373
- : "";
374
- const secretsPropertiesEnv = setup.secretsProperties
375
- ? declare("LOCAL_PROPERTIES", discriminators(setup.workflowFlavors, ""), (wf) => 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
376
394
  ? ` # Read by the Secrets Gradle Plugin — set via: ci secrets add LOCAL_PROPERTIES -w ${wf}`
377
- : ` # Read by the Secrets Gradle Plugin — set via: ci secrets add LOCAL_PROPERTIES`)
378
- : "";
395
+ : ` # Read by the Secrets Gradle Plugin — set via: ci secrets add LOCAL_PROPERTIES`);
396
+ }
397
+ const appEnv = appEnvDeclarations.join("");
379
398
  /**
380
399
  * The Secrets Gradle Plugin's properties file, written into every workflow
381
400
  * that builds. Its evaluation listener runs for every variant, so a debug
@@ -468,12 +487,12 @@ app:
468
487
  - MODULE: app
469
488
  - BUILD_TYPE: Debug
470
489
  - GRADLE_OPTS: -Dorg.gradle.daemon=false -Dorg.gradle.parallel=true
471
- ${keystoreEnv}${keystorePropertiesEnv}${googleServicesEnv}${googlePlayEnv}${secretsPropertiesEnv}
490
+ ${appEnv}
472
491
  workflows:
473
492
  primary:
474
493
  envs:
475
494
  - VARIANT: ${pv.variant}
476
- ${buildTypeEnvOverride(pv)}
495
+ ${buildTypeEnvOverride(pv)}${workflowEnvFor("primary")}
477
496
  steps:
478
497
  ${keystoreStepFor("primary")}${googleServicesStepFor("primary")}${secretsPropertiesStep}
479
498
  - set-java-version@1.0.0:
@@ -513,7 +532,7 @@ ${keystoreStepFor("primary")}${googleServicesStepFor("primary")}${secretsPropert
513
532
  pull-request:
514
533
  envs:
515
534
  - VARIANT: ${prv.variant}
516
- ${buildTypeEnvOverride(prv)}
535
+ ${buildTypeEnvOverride(prv)}${workflowEnvFor("pull-request")}
517
536
  steps:
518
537
  ${keystoreStepFor("pull-request")}${googleServicesStepFor("pull-request")}${secretsPropertiesStep}
519
538
  - set-java-version@1.0.0:
@@ -552,7 +571,7 @@ ${keystoreStepFor("pull-request")}${googleServicesStepFor("pull-request")}${secr
552
571
  release:
553
572
  envs:
554
573
  - VARIANT: ${rv.variant}
555
- ${buildTypeEnvOverride(rv)}
574
+ ${buildTypeEnvOverride(rv)}${workflowEnvFor("release")}
556
575
  steps:
557
576
  ${keystoreStepFor("release")}${keystorePropertiesStep}${googleServicesStepFor("release")}${secretsPropertiesStep}
558
577
  - set-java-version@1.0.0:
@@ -1 +1 @@
1
- {"version":3,"file":"file-secret-collector.d.ts","sourceRoot":"","sources":["../../../src/commands/file-secret-collector.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AAE5D,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,EAAE,CAAC;CACrB;AAED,MAAM,WAAW,gBAAgB;IAC/B,oDAAoD;IACpD,WAAW,EAAE,MAAM,CAAC;IACpB,uDAAuD;IACvD,UAAU,EAAE,MAAM,CAAC;IACnB,gFAAgF;IAChF,SAAS,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC;IACrC;;;;;;;;;;;OAWG;IACH,SAAS,CAAC,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,OAAO,CAAC;IAC9C,oEAAoE;IACpE,MAAM,CAAC,EAAE,MAAM,GAAG,QAAQ,CAAC;IAC3B,6CAA6C;IAC7C,cAAc,EAAE,aAAa,EAAE,CAAC;IAChC;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC5B;AAED;;;GAGG;AACH,MAAM,MAAM,eAAe,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;AAMrD,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,GAAG,MAAM,EAAE,CAkBxF;AAiDD;;;;;GAKG;AACH,wBAAsB,iBAAiB,CACrC,MAAM,EAAE,gBAAgB,EACxB,GAAG,EAAE,MAAM,EACX,cAAc,EAAE,cAAc,EAC9B,cAAc,UAAQ,GACrB,OAAO,CAAC,eAAe,CAAC,CA0H1B"}
1
+ {"version":3,"file":"file-secret-collector.d.ts","sourceRoot":"","sources":["../../../src/commands/file-secret-collector.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AAE5D,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,EAAE,CAAC;CACrB;AAED,MAAM,WAAW,gBAAgB;IAC/B,oDAAoD;IACpD,WAAW,EAAE,MAAM,CAAC;IACpB,uDAAuD;IACvD,UAAU,EAAE,MAAM,CAAC;IACnB,gFAAgF;IAChF,SAAS,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC;IACrC;;;;;;;;;;;OAWG;IACH,SAAS,CAAC,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,OAAO,CAAC;IAC9C,oEAAoE;IACpE,MAAM,CAAC,EAAE,MAAM,GAAG,QAAQ,CAAC;IAC3B,6CAA6C;IAC7C,cAAc,EAAE,aAAa,EAAE,CAAC;IAChC;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC5B;AAED;;;GAGG;AACH,MAAM,MAAM,eAAe,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;AAMrD,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,GAAG,MAAM,EAAE,CAkBxF;AAiDD;;;;;GAKG;AACH,wBAAsB,iBAAiB,CACrC,MAAM,EAAE,gBAAgB,EACxB,GAAG,EAAE,MAAM,EACX,cAAc,EAAE,cAAc,EAC9B,cAAc,UAAQ,GACrB,OAAO,CAAC,eAAe,CAAC,CA6I1B"}
@@ -77,18 +77,35 @@ async function promptTargetPathInProject(displayName) {
77
77
  */
78
78
  export async function collectFileSecret(config, cwd, secretsManager, nonInteractive = false) {
79
79
  const paths = {};
80
- // Only process groups where at least one workflow is missing the secret
81
- const pendingGroups = config.workflowGroups.filter((g) => !g.workflows.every((wf) => secretsManager.hasSecret(config.secretName, wf)));
82
- if (pendingGroups.length === 0) {
83
- console.log(` ✅ ${config.secretName} already in .cibuild-secrets.json — will be used at runtime.`);
84
- return paths;
85
- }
86
- const pendingWorkflows = pendingGroups.flatMap((g) => g.workflows);
87
80
  const readContent = (p) => config.readAs === "base64"
88
81
  ? readFileSync(p).toString("base64")
89
82
  : readFileSync(p, "utf-8");
90
83
  const relativeToCwd = (p) => relative(cwd, p).split(sep).join("/");
91
84
  const candidates = findProjectFiles(cwd, config.fileMatch).filter((p) => config.pathMatch?.(relativeToCwd(p)) ?? true);
85
+ // A stored value answers "what are the bytes". It does not answer "where does
86
+ // each workflow write them", and `.cibuild-secrets.json` cannot: the store
87
+ // records values and never targets. So a caller that consumes the returned
88
+ // map has to be asked whichever state the value is in.
89
+ //
90
+ // `promptTargetPath` marks that caller — the keystore, the only one whose
91
+ // return value the generator assigns. Conflating the two questions lost the
92
+ // keystore split on every repository that had ever run `ci init`: a *global*
93
+ // entry satisfies `hasSecret` for every workflow, so nothing was pending, an
94
+ // empty map went back, and the generator's fallback handed all three
95
+ // workflows one path. A fresh clone asked and split correctly, which is why
96
+ // no fixture-built test saw it.
97
+ //
98
+ // Only when there is something on disk to choose between. With no candidate
99
+ // the question has no answer worth interrupting for, and the short-circuit is
100
+ // still the kinder branch.
101
+ const callerNeedsTargets = config.promptTargetPath === true && candidates.length > 0;
102
+ const pendingGroups = config.workflowGroups.filter((g) => callerNeedsTargets ||
103
+ !g.workflows.every((wf) => secretsManager.hasSecret(config.secretName, wf)));
104
+ if (pendingGroups.length === 0) {
105
+ console.log(` ✅ ${config.secretName} already in .cibuild-secrets.json — will be used at runtime.`);
106
+ return paths;
107
+ }
108
+ const pendingWorkflows = pendingGroups.flatMap((g) => g.workflows);
92
109
  if (candidates.length === 1 && config.workflowGroups.length === 1) {
93
110
  // Single file, single-group config — auto-apply without prompting
94
111
  const content = readContent(candidates[0]);
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Product flavors a convention plugin creates are still the project's flavors.
3
+ *
4
+ * `detectBuildVariants` looked for a literal `productFlavors { … }` block in a
5
+ * Gradle file. Modern Android does not write one: the flavors are an enum in a
6
+ * convention plugin's Kotlin source and are created programmatically —
7
+ * `AppFlavor.values().forEach { create(flavor.name) }` — so the block the
8
+ * scan looks for does not exist anywhere in the repository.
9
+ *
10
+ * `android-variant-resolution.test.ts` already records the *runtime* half of
11
+ * this: nowinandroid declares its flavors the same way, Gradle refused
12
+ * `:app:lintDebug` as ambiguous, and the step now reads the candidates out of
13
+ * Gradle's own error. That recovers a task name during a build. It cannot
14
+ * recover anything decided *before* the build:
15
+ *
16
+ * - `googleServicesTargets` writes every workflow's `google-services.json`
17
+ * to the module root instead of its flavor's source set, which is the
18
+ * silent wrong-file failure `bld_8e60d90ab4e55f85d2f72459` spent 1292
19
+ * Gradle tasks discovering;
20
+ * - `variantFlavors` reports no flavor for any workflow, so `credentialSlots`
21
+ * sees no disagreement and declares one flat slot where the workflows need
22
+ * one value each;
23
+ * - the variant prompt offers `debug, release` and nothing else.
24
+ *
25
+ * Gradle is not available to the scan — it is a static read, and running a
26
+ * configuration phase to list flavors would cost minutes. The evidence that
27
+ * *is* on disk is the one AGP itself relies on: a flavor has a source set.
28
+ * `app/src/ciDynamic/`, `app/src/prod/` — the same directories
29
+ * `findGoogleServices` in `invarn setup` has always read the flavor from,
30
+ * which is why setup resolves this repository correctly while the generator
31
+ * does not.
32
+ *
33
+ * It is a **fallback**, not a merge: a project whose Gradle files declare
34
+ * flavors literally is decided by those files exactly as before, so this can
35
+ * only change the answer for a project that currently detects none.
36
+ */
37
+ export {};
38
+ //# sourceMappingURL=flavors-a-convention-plugin-creates-are-still-found.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"flavors-a-convention-plugin-creates-are-still-found.test.d.ts","sourceRoot":"","sources":["../../../src/commands/flavors-a-convention-plugin-creates-are-still-found.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG"}
@@ -0,0 +1,300 @@
1
+ /**
2
+ * Product flavors a convention plugin creates are still the project's flavors.
3
+ *
4
+ * `detectBuildVariants` looked for a literal `productFlavors { … }` block in a
5
+ * Gradle file. Modern Android does not write one: the flavors are an enum in a
6
+ * convention plugin's Kotlin source and are created programmatically —
7
+ * `AppFlavor.values().forEach { create(flavor.name) }` — so the block the
8
+ * scan looks for does not exist anywhere in the repository.
9
+ *
10
+ * `android-variant-resolution.test.ts` already records the *runtime* half of
11
+ * this: nowinandroid declares its flavors the same way, Gradle refused
12
+ * `:app:lintDebug` as ambiguous, and the step now reads the candidates out of
13
+ * Gradle's own error. That recovers a task name during a build. It cannot
14
+ * recover anything decided *before* the build:
15
+ *
16
+ * - `googleServicesTargets` writes every workflow's `google-services.json`
17
+ * to the module root instead of its flavor's source set, which is the
18
+ * silent wrong-file failure `bld_8e60d90ab4e55f85d2f72459` spent 1292
19
+ * Gradle tasks discovering;
20
+ * - `variantFlavors` reports no flavor for any workflow, so `credentialSlots`
21
+ * sees no disagreement and declares one flat slot where the workflows need
22
+ * one value each;
23
+ * - the variant prompt offers `debug, release` and nothing else.
24
+ *
25
+ * Gradle is not available to the scan — it is a static read, and running a
26
+ * configuration phase to list flavors would cost minutes. The evidence that
27
+ * *is* on disk is the one AGP itself relies on: a flavor has a source set.
28
+ * `app/src/ciDynamic/`, `app/src/prod/` — the same directories
29
+ * `findGoogleServices` in `invarn setup` has always read the flavor from,
30
+ * which is why setup resolves this repository correctly while the generator
31
+ * does not.
32
+ *
33
+ * It is a **fallback**, not a merge: a project whose Gradle files declare
34
+ * flavors literally is decided by those files exactly as before, so this can
35
+ * only change the answer for a project that currently detects none.
36
+ */
37
+ import { describe, test, expect, beforeEach, afterEach } from '@jest/globals';
38
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
39
+ import { tmpdir } from 'node:os';
40
+ import { join, resolve } from 'node:path';
41
+ import { load } from 'js-yaml';
42
+ import { detectBuildVariants, scanAndroidProject } from './android-scanner.js';
43
+ import { generateAndroidPipeline, googleServicesTargets, variantFlavors, } from './build.js';
44
+ let root;
45
+ beforeEach(() => {
46
+ root = resolve(mkdtempSync(join(tmpdir(), 'cibuild-convention-flavors-')));
47
+ });
48
+ afterEach(() => {
49
+ rmSync(root, { recursive: true, force: true });
50
+ });
51
+ /** Writes `app/build.gradle.kts` with the given body and returns its path. */
52
+ function appModule(body) {
53
+ mkdirSync(join(root, 'app'), { recursive: true });
54
+ const path = join(root, 'app', 'build.gradle.kts');
55
+ writeFileSync(path, body, 'utf-8');
56
+ return path;
57
+ }
58
+ /** Creates `app/src/<name>/` holding each of `children`. */
59
+ function sourceSet(name, children = ['res']) {
60
+ const dir = join(root, 'app', 'src', name);
61
+ mkdirSync(dir, { recursive: true });
62
+ for (const child of children) {
63
+ if (child.includes('.'))
64
+ writeFileSync(join(dir, child), '{}', 'utf-8');
65
+ else
66
+ mkdirSync(join(dir, child), { recursive: true });
67
+ }
68
+ }
69
+ /** An `android { buildTypes { … } }` block with no `productFlavors` anywhere. */
70
+ const CONVENTION_PLUGIN_MODULE = `
71
+ plugins {
72
+ id("acme.android.application")
73
+ id("acme.android.application.flavors")
74
+ }
75
+
76
+ android {
77
+ namespace = "com.example.app"
78
+ buildTypes {
79
+ debug { isMinifyEnabled = false }
80
+ release { isMinifyEnabled = true }
81
+ }
82
+ }
83
+
84
+ androidComponents {
85
+ onVariants { variant ->
86
+ if (variant.buildType == "release" &&
87
+ variant.productFlavors.any { it.second == "prod" }
88
+ ) {
89
+ variant.signingConfig.setConfig(android.signingConfigs.getByName("release"))
90
+ }
91
+ }
92
+ }
93
+ `;
94
+ describe('flavors declared by a convention plugin', () => {
95
+ test('are read from the source sets the flavors have on disk', () => {
96
+ const gradle = appModule(CONVENTION_PLUGIN_MODULE);
97
+ sourceSet('main', ['AndroidManifest.xml', 'java', 'res']);
98
+ sourceSet('ciDynamic', ['google-services.json', 'res']);
99
+ sourceSet('dev', ['google-services.json', 'res']);
100
+ sourceSet('prod', ['google-services.json', 'res']);
101
+ expect(detectBuildVariants([gradle]).productFlavors).toEqual(['ciDynamic', 'dev', 'prod']);
102
+ });
103
+ test('become the variants the prompt offers', () => {
104
+ const gradle = appModule(CONVENTION_PLUGIN_MODULE);
105
+ sourceSet('main', ['AndroidManifest.xml']);
106
+ sourceSet('ciDynamic');
107
+ sourceSet('prod');
108
+ // Every flavor crossed with every build type, which is what the literal
109
+ // path has always produced.
110
+ expect(detectBuildVariants([gradle]).variants).toEqual([
111
+ 'ciDynamicDebug',
112
+ 'ciDynamicRelease',
113
+ 'prodDebug',
114
+ 'prodRelease',
115
+ ]);
116
+ });
117
+ test('are found in whichever module has them, not only `app`', () => {
118
+ const app = appModule(CONVENTION_PLUGIN_MODULE);
119
+ mkdirSync(join(root, 'core'), { recursive: true });
120
+ const core = join(root, 'core', 'build.gradle.kts');
121
+ writeFileSync(core, 'plugins { id("acme.android.library.flavors") }', 'utf-8');
122
+ mkdirSync(join(root, 'core', 'src', 'staging', 'res'), { recursive: true });
123
+ mkdirSync(join(root, 'core', 'src', 'main', 'java'), { recursive: true });
124
+ sourceSet('main', ['AndroidManifest.xml']);
125
+ sourceSet('prod');
126
+ expect(detectBuildVariants([app, core]).productFlavors).toEqual(['prod', 'staging']);
127
+ });
128
+ test('are ordered the same way whatever order the filesystem lists them in', () => {
129
+ const gradle = appModule(CONVENTION_PLUGIN_MODULE);
130
+ sourceSet('main', ['AndroidManifest.xml']);
131
+ sourceSet('prod');
132
+ sourceSet('ciDynamic');
133
+ sourceSet('dev');
134
+ expect(detectBuildVariants([gradle]).productFlavors).toEqual(['ciDynamic', 'dev', 'prod']);
135
+ });
136
+ });
137
+ describe('what a source set directory is not', () => {
138
+ test('the shared source set, the test source sets, and the build types', () => {
139
+ const gradle = appModule(`
140
+ android {
141
+ buildTypes {
142
+ debug { }
143
+ release { }
144
+ staging { }
145
+ }
146
+ }
147
+ `);
148
+ sourceSet('main', ['AndroidManifest.xml', 'java']);
149
+ sourceSet('test', ['java']);
150
+ sourceSet('androidTest', ['java']);
151
+ sourceSet('testFixtures', ['java']);
152
+ sourceSet('debug', ['res']);
153
+ sourceSet('release', ['res']);
154
+ sourceSet('staging', ['res']);
155
+ expect(detectBuildVariants([gradle]).productFlavors).toEqual([]);
156
+ });
157
+ test('a variant directory, which is a flavor and a build type already joined', () => {
158
+ const gradle = appModule(CONVENTION_PLUGIN_MODULE);
159
+ sourceSet('main', ['AndroidManifest.xml']);
160
+ sourceSet('ciDynamic');
161
+ sourceSet('ciDynamicDebug', ['res']);
162
+ sourceSet('prodRelease', ['res']);
163
+ // Taking `ciDynamicDebug` for a flavor would cross it with the build types
164
+ // again and offer `ciDynamicDebugDebug`.
165
+ expect(detectBuildVariants([gradle]).productFlavors).toEqual(['ciDynamic']);
166
+ });
167
+ test('a test source set for one flavor', () => {
168
+ const gradle = appModule(CONVENTION_PLUGIN_MODULE);
169
+ sourceSet('main', ['AndroidManifest.xml']);
170
+ sourceSet('prod');
171
+ sourceSet('testProd', ['java']);
172
+ sourceSet('androidTestProd', ['java']);
173
+ expect(detectBuildVariants([gradle]).productFlavors).toEqual(['prod']);
174
+ });
175
+ test('a flavor whose name merely starts with `test`', () => {
176
+ // `testing` is a legitimate flavor name; `testProd` is AGP's test source
177
+ // set for `prod`. The capital is the whole difference.
178
+ const gradle = appModule(CONVENTION_PLUGIN_MODULE);
179
+ sourceSet('main', ['AndroidManifest.xml']);
180
+ sourceSet('testing');
181
+ expect(detectBuildVariants([gradle]).productFlavors).toEqual(['testing']);
182
+ });
183
+ test('a directory under `src/` that holds nothing a source set holds', () => {
184
+ const gradle = appModule(CONVENTION_PLUGIN_MODULE);
185
+ sourceSet('main', ['AndroidManifest.xml']);
186
+ sourceSet('prod');
187
+ mkdirSync(join(root, 'app', 'src', 'notes'), { recursive: true });
188
+ writeFileSync(join(root, 'app', 'src', 'notes', 'README.md'), '# scratch', 'utf-8');
189
+ expect(detectBuildVariants([gradle]).productFlavors).toEqual(['prod']);
190
+ });
191
+ });
192
+ describe('a project the literal scan already reads', () => {
193
+ test('is decided by its Gradle files, and the source sets change nothing', () => {
194
+ // The block declares two; the disk carries a third. A merge would invent a
195
+ // flavor the build does not have, so the literal answer wins outright.
196
+ const gradle = appModule(`
197
+ android {
198
+ flavorDimensions += "contentType"
199
+ productFlavors {
200
+ create("demo") { dimension = "contentType" }
201
+ create("prod") { dimension = "contentType" }
202
+ }
203
+ }
204
+ `);
205
+ sourceSet('main', ['AndroidManifest.xml']);
206
+ sourceSet('demo');
207
+ sourceSet('prod');
208
+ sourceSet('leftover', ['res']);
209
+ expect(detectBuildVariants([gradle]).productFlavors).toEqual(['demo', 'prod']);
210
+ });
211
+ });
212
+ describe('a project with no flavors at all', () => {
213
+ test('is exactly what it was', () => {
214
+ const gradle = appModule(`
215
+ android {
216
+ buildTypes {
217
+ debug { }
218
+ release { }
219
+ }
220
+ }
221
+ `);
222
+ sourceSet('main', ['AndroidManifest.xml', 'java', 'res']);
223
+ sourceSet('debug', ['res']);
224
+ const detected = detectBuildVariants([gradle]);
225
+ expect(detected.productFlavors).toEqual([]);
226
+ expect(detected.variants).toEqual(['debug', 'release']);
227
+ });
228
+ test('is unchanged when the module has no `src/` directory at all', () => {
229
+ const gradle = appModule('android { }');
230
+ expect(detectBuildVariants([gradle]).productFlavors).toEqual([]);
231
+ });
232
+ });
233
+ const BASE_SETUP = {
234
+ keystore: false,
235
+ keystoreProperties: false,
236
+ googleServices: false,
237
+ googlePlayDeploy: false,
238
+ googlePlayPackageName: '',
239
+ artifactType: 'apk',
240
+ keystorePaths: {},
241
+ googleServicesPaths: {},
242
+ workflowFlavors: {},
243
+ secretsProperties: false,
244
+ secretsPropertiesPath: 'local.properties',
245
+ };
246
+ describe('what finding them buys the generator', () => {
247
+ /** The real repository's shape, as a project on disk. */
248
+ function conventionPluginRepository() {
249
+ appModule(CONVENTION_PLUGIN_MODULE);
250
+ writeFileSync(join(root, 'settings.gradle.kts'), 'include(":app")\n', 'utf-8');
251
+ sourceSet('main', ['AndroidManifest.xml', 'java', 'res']);
252
+ sourceSet('ciDynamic', ['google-services.json', 'res']);
253
+ sourceSet('dev', ['google-services.json', 'res']);
254
+ sourceSet('prod', ['google-services.json', 'res']);
255
+ }
256
+ const variant = (name) => ({
257
+ variant: name,
258
+ buildType: name.charAt(0).toUpperCase() + name.slice(1),
259
+ gradleTask: `assemble${name.charAt(0).toUpperCase()}${name.slice(1)}`,
260
+ });
261
+ const VARIANTS = {
262
+ primary: variant('ciDynamicDebug'),
263
+ pullRequest: variant('ciDynamicDebug'),
264
+ release: variant('prodRelease'),
265
+ };
266
+ test('each workflow writes google-services.json into its own source set', async () => {
267
+ conventionPluginRepository();
268
+ const { productFlavors } = (await scanAndroidProject(root)).buildVariants;
269
+ expect(googleServicesTargets(VARIANTS, productFlavors)).toEqual({
270
+ primary: 'app/src/ciDynamic/google-services.json',
271
+ 'pull-request': 'app/src/ciDynamic/google-services.json',
272
+ release: 'app/src/prod/google-services.json',
273
+ });
274
+ });
275
+ test('the credential the two flavors cannot share is declared per workflow', async () => {
276
+ conventionPluginRepository();
277
+ const { productFlavors } = (await scanAndroidProject(root)).buildVariants;
278
+ const yaml = generateAndroidPipeline(17, {
279
+ ...BASE_SETUP,
280
+ googleServices: true,
281
+ googleServicesPaths: googleServicesTargets(VARIANTS, productFlavors),
282
+ }, VARIANTS);
283
+ const parsed = load(yaml);
284
+ const declaringWorkflows = Object.keys(parsed.workflows ?? {}).filter((workflow) => 'GOOGLE_SERVICES_JSON' in Object.assign({}, ...(parsed.workflows?.[workflow]?.envs ?? [])));
285
+ // One declaration per workflow, in that workflow's own `envs`, under the
286
+ // plain name — the split the flavors bought, without the transport prefix.
287
+ expect(declaringWorkflows).toEqual(['primary', 'pull-request', 'release']);
288
+ expect(Object.assign({}, ...(parsed.app?.envs ?? []))).not.toHaveProperty('GOOGLE_SERVICES_JSON');
289
+ });
290
+ test('local.properties is split by the flavor each workflow builds', async () => {
291
+ conventionPluginRepository();
292
+ const { productFlavors } = (await scanAndroidProject(root)).buildVariants;
293
+ expect(variantFlavors(VARIANTS, productFlavors)).toEqual({
294
+ primary: 'ciDynamic',
295
+ 'pull-request': 'ciDynamic',
296
+ release: 'prod',
297
+ });
298
+ });
299
+ });
300
+ //# sourceMappingURL=flavors-a-convention-plugin-creates-are-still-found.test.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"run.d.ts","sourceRoot":"","sources":["../../../src/commands/run.ts"],"names":[],"mappings":"AAeA,MAAM,WAAW,UAAU;IACzB;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED;;;;;;;GAOG;AACH,wBAAsB,gBAAgB,CAAC,IAAI,GAAE,UAAe,GAAG,OAAO,CAAC,IAAI,CAAC,CAiJ3E"}
1
+ {"version":3,"file":"run.d.ts","sourceRoot":"","sources":["../../../src/commands/run.ts"],"names":[],"mappings":"AAeA,MAAM,WAAW,UAAU;IACzB;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED;;;;;;;GAOG;AACH,wBAAsB,gBAAgB,CAAC,IAAI,GAAE,UAAe,GAAG,OAAO,CAAC,IAAI,CAAC,CAkJ3E"}
@@ -106,7 +106,7 @@ export async function handleRunCommand(opts = {}) {
106
106
  interactive: isInteractiveTTY(),
107
107
  });
108
108
  pipeline = conversionResult.pipeline;
109
- await runner.runPipeline(pipeline, conversionResult.warnings, conversionResult.skippedSteps);
109
+ await runner.runPipeline(pipeline, conversionResult.warnings, conversionResult.skippedSteps, conversionResult.checkout);
110
110
  }
111
111
  else if (isJavaScriptModule) {
112
112
  // The one dynamic import that has to stay dynamic: the specifier is a
@@ -1,4 +1,5 @@
1
1
  import type { StepDef, PipelineDef, CIConfig } from "./types.js";
2
+ import type { CheckoutWitness } from "./yaml/converter.js";
2
3
  export declare class StepExecutionError extends Error {
3
4
  stepId: string;
4
5
  stepName: string;
@@ -12,6 +13,19 @@ export declare class PipelineRunner {
12
13
  private formatDuration;
13
14
  validatePipeline(pipeline: PipelineDef): void;
14
15
  displayWarnings(warnings: string[], skippedSteps: number): void;
15
- runPipeline(pipeline: PipelineDef, warnings?: string[], skippedSteps?: number): Promise<void>;
16
+ /**
17
+ * The commit this build compiled, printed under the banner.
18
+ *
19
+ * Every build carries it, whatever steps it declares. The dashboard's sha is
20
+ * the one that was *dispatched*; this one was read off the disk the compiler
21
+ * is about to read, and it is what a later argument about which code shipped
22
+ * has to be settled against. It used to live only in `git-clone`'s echo,
23
+ * which is optional and no longer in the curated templates.
24
+ *
25
+ * Labels match that echo's on purpose — where both appear they are the same
26
+ * three facts, and they have to agree.
27
+ */
28
+ displayCheckout(checkout: CheckoutWitness): void;
29
+ runPipeline(pipeline: PipelineDef, warnings?: string[], skippedSteps?: number, checkout?: CheckoutWitness): Promise<void>;
16
30
  }
17
31
  //# sourceMappingURL=runner.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"runner.d.ts","sourceRoot":"","sources":["../../src/runner.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,OAAO,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAOjE,qBAAa,kBAAmB,SAAQ,KAAK;IAElC,MAAM,EAAE,MAAM;IACd,QAAQ,EAAE,MAAM;IAChB,QAAQ,EAAE,MAAM;gBAFhB,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,MAAM;CAK1B;AAED,qBAAa,cAAc;IACb,OAAO,CAAC,MAAM;gBAAN,MAAM,EAAE,QAAQ;IAE9B,OAAO,CAAC,IAAI,EAAE,OAAO,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAiHhE,OAAO,CAAC,cAAc;IAYtB,gBAAgB,CAAC,QAAQ,EAAE,WAAW,GAAG,IAAI;IAwB7C,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,EAAE,YAAY,EAAE,MAAM,GAAG,IAAI;IAczD,WAAW,CAAC,QAAQ,EAAE,WAAW,EAAE,QAAQ,CAAC,EAAE,MAAM,EAAE,EAAE,YAAY,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CA2CpG"}
1
+ {"version":3,"file":"runner.d.ts","sourceRoot":"","sources":["../../src/runner.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,OAAO,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AACjE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AAO3D,qBAAa,kBAAmB,SAAQ,KAAK;IAElC,MAAM,EAAE,MAAM;IACd,QAAQ,EAAE,MAAM;IAChB,QAAQ,EAAE,MAAM;gBAFhB,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,MAAM;CAK1B;AAED,qBAAa,cAAc;IACb,OAAO,CAAC,MAAM;gBAAN,MAAM,EAAE,QAAQ;IAE9B,OAAO,CAAC,IAAI,EAAE,OAAO,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAiHhE,OAAO,CAAC,cAAc;IAYtB,gBAAgB,CAAC,QAAQ,EAAE,WAAW,GAAG,IAAI;IAwB7C,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,EAAE,YAAY,EAAE,MAAM,GAAG,IAAI;IAc/D;;;;;;;;;;;OAWG;IACH,eAAe,CAAC,QAAQ,EAAE,eAAe,GAAG,IAAI;IAO1C,WAAW,CACf,QAAQ,EAAE,WAAW,EACrB,QAAQ,CAAC,EAAE,MAAM,EAAE,EACnB,YAAY,CAAC,EAAE,MAAM,EACrB,QAAQ,CAAC,EAAE,eAAe,GACzB,OAAO,CAAC,IAAI,CAAC;CA+CjB"}