@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
@@ -10,6 +10,19 @@ export interface FileSecretConfig {
10
10
  secretName: string;
11
11
  /** Returns true if a filename matches (e.g. name === "google-services.json") */
12
12
  fileMatch: (name: string) => boolean;
13
+ /**
14
+ * Narrows the candidates by their path relative to the project root, once
15
+ * `fileMatch` has found them by name.
16
+ *
17
+ * A credential Android keeps one copy of per product flavor has the same
18
+ * basename in every source set — four `google-services.json` under
19
+ * `app/`, `app/src/dev/`, `app/src/prod/` and `app/src/ciDynamic/` — so a
20
+ * name is not enough to say which one a workflow needs. Without this the
21
+ * single-candidate auto-apply below never fires on a flavored repository,
22
+ * and the answer comes from a prompt or from whichever the walk reached
23
+ * first.
24
+ */
25
+ pathMatch?: (relativePath: string) => boolean;
13
26
  /** How to read the file into a string value. Defaults to "text". */
14
27
  readAs?: "text" | "base64";
15
28
  /** Workflow groups to scope the secret to */
@@ -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,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,CAuH1B"}
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"}
@@ -1,4 +1,4 @@
1
- import { resolve, relative } from "node:path";
1
+ import { resolve, relative, sep } from "node:path";
2
2
  import { readdirSync, readFileSync } from "node:fs";
3
3
  import * as readline from "node:readline";
4
4
  import prompts from "prompts";
@@ -77,17 +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)));
80
+ const readContent = (p) => config.readAs === "base64"
81
+ ? readFileSync(p).toString("base64")
82
+ : readFileSync(p, "utf-8");
83
+ const relativeToCwd = (p) => relative(cwd, p).split(sep).join("/");
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)));
82
104
  if (pendingGroups.length === 0) {
83
105
  console.log(` ✅ ${config.secretName} already in .cibuild-secrets.json — will be used at runtime.`);
84
106
  return paths;
85
107
  }
86
108
  const pendingWorkflows = pendingGroups.flatMap((g) => g.workflows);
87
- const readContent = (p) => config.readAs === "base64"
88
- ? readFileSync(p).toString("base64")
89
- : readFileSync(p, "utf-8");
90
- const candidates = findProjectFiles(cwd, config.fileMatch);
91
109
  if (candidates.length === 1 && config.workflowGroups.length === 1) {
92
110
  // Single file, single-group config — auto-apply without prompting
93
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
@@ -37,6 +37,8 @@ const BASE = {
37
37
  googlePlayPackageName: '',
38
38
  artifactType: 'apk',
39
39
  keystorePaths: {},
40
+ googleServicesPaths: {},
41
+ workflowFlavors: {},
40
42
  secretsProperties: false,
41
43
  secretsPropertiesPath: 'local.properties',
42
44
  };
@@ -0,0 +1,28 @@
1
+ /**
2
+ * A `cache_paths` entry must not contain the file a `file@` step writes.
3
+ *
4
+ * Everything else about a credential's life inside a build is bounded, and was
5
+ * checked on the three boxes after the first build that ever wrote one: the
6
+ * runner pushes the checkout and the guest root onto `cleanupPaths` and
7
+ * removes them in a `finally`, the guest gets `rm -rf`, and `tart delete`
8
+ * reclaims the VM. Nothing was left anywhere.
9
+ *
10
+ * The cache store is the one place a file can survive all of that.
11
+ * `cache-push` archives whatever `cache_paths` names and uploads it to where
12
+ * the *next* build pulls it from, so a credential swept into that archive
13
+ * outlives the build it was delivered to — and outlives the sandbox that was
14
+ * supposed to contain it.
15
+ *
16
+ * No pipeline in play trips this. Every one of them caches
17
+ * `~/.gradle/caches`, `~/.gradle/wrapper` and `.gradle`, and none of those
18
+ * holds a credential. It is a guard on a mistake that is easy to make and
19
+ * invisible once made: adding `.` to `cache_paths` is a plausible way to make
20
+ * a build faster, and it would publish the signing key.
21
+ *
22
+ * Refused at generation time, like issue 03's missing credential, because that
23
+ * is the only place that can see a whole workflow at once — a cache step can
24
+ * be written before the `file@` step whose target it would swallow — and
25
+ * because the alternative is a build that goes green while leaking.
26
+ */
27
+ export {};
28
+ //# sourceMappingURL=a-cache-path-must-not-swallow-a-credential.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"a-cache-path-must-not-swallow-a-credential.test.d.ts","sourceRoot":"","sources":["../../../src/yaml/a-cache-path-must-not-swallow-a-credential.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG"}