@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
@@ -0,0 +1,38 @@
1
+ /**
2
+ * A credential's target path and the value chosen for it both have to follow
3
+ * the product flavor the workflow builds.
4
+ *
5
+ * An Android module has one source set per flavor, and each carries its own
6
+ * copies of whatever the build needs — the manifest, the resources, and
7
+ * `google-services.json`. The google-services plugin resolves the file by
8
+ * specificity (variant, then build type, then flavor, then the module root)
9
+ * and takes the first it finds. So a workflow building `ciDynamicDebug` wants
10
+ * `app/src/ciDynamic/google-services.json`, and only falls back to the module
11
+ * root when that is absent.
12
+ *
13
+ * The generator assumed one file at the module root. Writing there on a
14
+ * flavored repository is *silently* wrong rather than loudly wrong: the plugin
15
+ * finds a file, parses it, and fails on a package name. That is what
16
+ * `bld_8e60d90ab4e55f85d2f72459` (2026-09-09) spent 1292 Gradle tasks
17
+ * discovering:
18
+ *
19
+ * Execution failed for task ':app:processCiDynamicDebugGoogleServices'.
20
+ * > No matching client found for package name '….ciDynamic'
21
+ *
22
+ * The chain worked. The file was the wrong one of four, and the error names
23
+ * Firebase rather than the credential we delivered.
24
+ *
25
+ * Two flavors cannot share one secret, and the key is how a value varies. A
26
+ * dispatched build's secrets are a flat key→value map — Invarn's scopes are
27
+ * org, connection and pipeline, with no workflow *column* — but a flat map
28
+ * carries a workflow perfectly well when the workflow is part of the key,
29
+ * which is what `ci secrets upload` has encoded as
30
+ * `CIBUILD_SW__<WORKFLOW>__<VAR>` all along. So the differing keys are
31
+ * per-workflow ones, not per-flavor ones: the flavor is a property of the
32
+ * variant the workflow builds, so keying by workflow is one indirection less,
33
+ * and it is the convention already implemented end to end. A per-flavor
34
+ * suffix (`GOOGLE_SERVICES_JSON_CIDYNAMIC`) was briefly shipped here as a
35
+ * second answer to the same question and was reverted (Alex, 2026-09-09).
36
+ */
37
+ export {};
38
+ //# sourceMappingURL=a-credential-target-follows-the-flavor-source-set.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"a-credential-target-follows-the-flavor-source-set.test.d.ts","sourceRoot":"","sources":["../../../src/commands/a-credential-target-follows-the-flavor-source-set.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG"}
@@ -0,0 +1,222 @@
1
+ /**
2
+ * A credential's target path and the value chosen for it both have to follow
3
+ * the product flavor the workflow builds.
4
+ *
5
+ * An Android module has one source set per flavor, and each carries its own
6
+ * copies of whatever the build needs — the manifest, the resources, and
7
+ * `google-services.json`. The google-services plugin resolves the file by
8
+ * specificity (variant, then build type, then flavor, then the module root)
9
+ * and takes the first it finds. So a workflow building `ciDynamicDebug` wants
10
+ * `app/src/ciDynamic/google-services.json`, and only falls back to the module
11
+ * root when that is absent.
12
+ *
13
+ * The generator assumed one file at the module root. Writing there on a
14
+ * flavored repository is *silently* wrong rather than loudly wrong: the plugin
15
+ * finds a file, parses it, and fails on a package name. That is what
16
+ * `bld_8e60d90ab4e55f85d2f72459` (2026-09-09) spent 1292 Gradle tasks
17
+ * discovering:
18
+ *
19
+ * Execution failed for task ':app:processCiDynamicDebugGoogleServices'.
20
+ * > No matching client found for package name '….ciDynamic'
21
+ *
22
+ * The chain worked. The file was the wrong one of four, and the error names
23
+ * Firebase rather than the credential we delivered.
24
+ *
25
+ * Two flavors cannot share one secret, and the key is how a value varies. A
26
+ * dispatched build's secrets are a flat key→value map — Invarn's scopes are
27
+ * org, connection and pipeline, with no workflow *column* — but a flat map
28
+ * carries a workflow perfectly well when the workflow is part of the key,
29
+ * which is what `ci secrets upload` has encoded as
30
+ * `CIBUILD_SW__<WORKFLOW>__<VAR>` all along. So the differing keys are
31
+ * per-workflow ones, not per-flavor ones: the flavor is a property of the
32
+ * variant the workflow builds, so keying by workflow is one indirection less,
33
+ * and it is the convention already implemented end to end. A per-flavor
34
+ * suffix (`GOOGLE_SERVICES_JSON_CIDYNAMIC`) was briefly shipped here as a
35
+ * second answer to the same question and was reverted (Alex, 2026-09-09).
36
+ */
37
+ import { describe, test, expect } from '@jest/globals';
38
+ import { load } from 'js-yaml';
39
+ import { generateAndroidPipeline, flavorOfVariant, googleServicesPathFor, googleServicesTargets, } from './build.js';
40
+ const BASE = {
41
+ keystore: false,
42
+ keystoreProperties: false,
43
+ googleServices: false,
44
+ googlePlayDeploy: false,
45
+ googlePlayPackageName: '',
46
+ artifactType: 'apk',
47
+ keystorePaths: {},
48
+ googleServicesPaths: {},
49
+ workflowFlavors: {},
50
+ secretsProperties: false,
51
+ secretsPropertiesPath: 'local.properties',
52
+ };
53
+ function variant(name) {
54
+ const buildType = name.charAt(0).toUpperCase() + name.slice(1);
55
+ return { variant: name, buildType, gradleTask: `assemble${buildType}` };
56
+ }
57
+ /** The real repository's shape: two debug workflows on one flavor, release on another. */
58
+ const FLAVORED = {
59
+ primary: variant('ciDynamicDebug'),
60
+ pullRequest: variant('ciDynamicDebug'),
61
+ release: variant('prodRelease'),
62
+ };
63
+ const UNFLAVORED = {
64
+ primary: variant('debug'),
65
+ pullRequest: variant('debug'),
66
+ release: variant('release'),
67
+ };
68
+ const FLAVORS = ['ciDynamic', 'dev', 'prod', 'gandalf'];
69
+ function parse(yaml) {
70
+ return load(yaml);
71
+ }
72
+ function envs(parsed) {
73
+ return Object.assign({}, ...(parsed.app?.envs ?? []));
74
+ }
75
+ /** The `file@` step of `workflow` that writes a google-services.json, if any. */
76
+ function googleServicesStep(parsed, workflow) {
77
+ return (parsed.workflows?.[workflow]?.steps ?? [])
78
+ .filter((s) => Object.keys(s)[0] === 'file@1.0.0')
79
+ .map((s) => s['file@1.0.0'].inputs)
80
+ .find((inputs) => String(inputs.target_path).endsWith('google-services.json'));
81
+ }
82
+ function generate(variants, flavors) {
83
+ return parse(generateAndroidPipeline(17, {
84
+ ...BASE,
85
+ googleServices: true,
86
+ googleServicesPaths: googleServicesTargets(variants, flavors),
87
+ }, variants));
88
+ }
89
+ describe('which flavor a variant builds', () => {
90
+ test('is the flavor the variant name starts with', () => {
91
+ expect(flavorOfVariant('ciDynamicDebug', FLAVORS)).toBe('ciDynamic');
92
+ expect(flavorOfVariant('prodRelease', FLAVORS)).toBe('prod');
93
+ expect(flavorOfVariant('devDebug', FLAVORS)).toBe('dev');
94
+ });
95
+ test('is nothing when the variant is a bare build type', () => {
96
+ expect(flavorOfVariant('debug', FLAVORS)).toBeNull();
97
+ expect(flavorOfVariant('release', FLAVORS)).toBeNull();
98
+ // A repository with no product flavors can never match one.
99
+ expect(flavorOfVariant('ciDynamicDebug', [])).toBeNull();
100
+ });
101
+ test('needs the capital that starts the build type, so a prefix cannot claim it', () => {
102
+ // Without this check `pro` claims `prodRelease` and the credential lands
103
+ // in the wrong source set — the same arbitrariness, one layer down.
104
+ expect(flavorOfVariant('prodRelease', ['pro'])).toBeNull();
105
+ expect(flavorOfVariant('prodRelease', ['pro', 'prod'])).toBe('prod');
106
+ });
107
+ test('gives the longest flavor the win when two both match', () => {
108
+ expect(flavorOfVariant('ciDynamicDebug', ['ci', 'ciDynamic'])).toBe('ciDynamic');
109
+ });
110
+ });
111
+ describe('the path a flavor implies', () => {
112
+ test('is the module root when there is no flavor', () => {
113
+ expect(googleServicesPathFor(null)).toBe('app/google-services.json');
114
+ });
115
+ test('is that flavor\'s source set when there is one', () => {
116
+ expect(googleServicesPathFor('ciDynamic')).toBe('app/src/ciDynamic/google-services.json');
117
+ expect(googleServicesPathFor('prod')).toBe('app/src/prod/google-services.json');
118
+ });
119
+ test('follows the module when the app is not in `app/`', () => {
120
+ expect(googleServicesPathFor('ciDynamic', 'androidApp')).toBe('androidApp/src/ciDynamic/google-services.json');
121
+ });
122
+ });
123
+ describe('the pipeline a flavored repository gets', () => {
124
+ test('writes each workflow into its own flavor source set', () => {
125
+ const parsed = generate(FLAVORED, FLAVORS);
126
+ // One object per workflow so a failure names the workflow that lost it.
127
+ expect(['primary', 'pull-request', 'release'].map((workflow) => ({
128
+ workflow,
129
+ target_path: googleServicesStep(parsed, workflow)?.target_path,
130
+ }))).toEqual([
131
+ { workflow: 'primary', target_path: 'app/src/ciDynamic/google-services.json' },
132
+ { workflow: 'pull-request', target_path: 'app/src/ciDynamic/google-services.json' },
133
+ { workflow: 'release', target_path: 'app/src/prod/google-services.json' },
134
+ ]);
135
+ });
136
+ test('names one secret in every workflow, whatever the flavor', () => {
137
+ // The value differs per workflow; the *name* does not. Scoping a value to
138
+ // a workflow is the secret's job — `ci secrets add GOOGLE_SERVICES_JSON
139
+ // <pipeline> -w primary` stores it per workflow, `ci secrets upload`
140
+ // encodes it as `CIBUILD_SW__PRIMARY__GOOGLE_SERVICES_JSON`, and
141
+ // `EnvResolver` resolves it for the workflow being run. A second naming
142
+ // convention in the step would be a second way to say the same thing.
143
+ const parsed = generate(FLAVORED, FLAVORS);
144
+ expect(['primary', 'pull-request', 'release'].map((workflow) => googleServicesStep(parsed, workflow)?.var_name)).toEqual(['GOOGLE_SERVICES_JSON', 'GOOGLE_SERVICES_JSON', 'GOOGLE_SERVICES_JSON']);
145
+ });
146
+ test('declares that one secret under no invented per-flavor name', () => {
147
+ // Declared, and declared empty: `unsetEnvKeys` — what `invarn setup`
148
+ // searches the developer's machine by — reads `envs`, never step inputs.
149
+ // A key no `envs` entry names is never discovered and never uploaded.
150
+ //
151
+ // *How many* slots it takes is a later ruling and lives in
152
+ // `a-credential-two-workflows-cannot-share-is-declared-per-workflow`: two
153
+ // flavors need two values, and `invarn setup` fills one slot with one
154
+ // value, so a flavored repository declares one slot per workflow under the
155
+ // `CIBUILD_SW__` name the upload already encodes. What this test still
156
+ // holds is the naming: whatever the flavor, the credential is
157
+ // `GOOGLE_SERVICES_JSON` and never `GOOGLE_SERVICES_JSON_CIDYNAMIC`.
158
+ const yaml = generateAndroidPipeline(17, {
159
+ ...BASE,
160
+ googleServices: true,
161
+ googleServicesPaths: googleServicesTargets(FLAVORED, FLAVORS),
162
+ }, FLAVORED);
163
+ // One declaration per workflow, in that workflow's own `envs`, under the
164
+ // plain credential name — the block says which workflow, so nothing is
165
+ // encoded into the key.
166
+ const parsed = parse(yaml);
167
+ const declaringWorkflows = Object.keys(parsed.workflows ?? {}).filter((workflow) => 'GOOGLE_SERVICES_JSON' in
168
+ Object.assign({}, ...(parsed.workflows?.[workflow]?.envs ?? [])));
169
+ expect(declaringWorkflows).toEqual(['primary', 'pull-request', 'release']);
170
+ expect(envs(parsed)).not.toHaveProperty('GOOGLE_SERVICES_JSON');
171
+ expect(yaml).not.toMatch(/GOOGLE_SERVICES_JSON_[A-Z]/);
172
+ expect(yaml).not.toContain('CIBUILD_SW__');
173
+ });
174
+ });
175
+ describe('a repository with no product flavors', () => {
176
+ test('is exactly what it was: one key, one path, in every workflow', () => {
177
+ const parsed = generate(UNFLAVORED, []);
178
+ expect(envs(parsed).GOOGLE_SERVICES_JSON).toBe('');
179
+ for (const workflow of ['primary', 'pull-request', 'release']) {
180
+ expect(googleServicesStep(parsed, workflow)).toMatchObject({
181
+ target_path: 'app/google-services.json',
182
+ var_name: 'GOOGLE_SERVICES_JSON',
183
+ });
184
+ }
185
+ });
186
+ test('is unchanged even when the pipeline is generated with no options at all', () => {
187
+ // The defaulted `SetupOptions` has empty maps; the step must still fall
188
+ // back to the module root rather than emitting `undefined`.
189
+ const parsed = parse(generateAndroidPipeline(17, { ...BASE, googleServices: true }, UNFLAVORED));
190
+ expect(googleServicesStep(parsed, 'primary')).toMatchObject({
191
+ target_path: 'app/google-services.json',
192
+ var_name: 'GOOGLE_SERVICES_JSON',
193
+ });
194
+ });
195
+ test('emits nothing at all when the scan found no google-services.json', () => {
196
+ const parsed = parse(generateAndroidPipeline(17, BASE, UNFLAVORED));
197
+ expect(envs(parsed)).not.toHaveProperty('GOOGLE_SERVICES_JSON');
198
+ for (const workflow of ['primary', 'pull-request', 'release']) {
199
+ expect(googleServicesStep(parsed, workflow)).toBeUndefined();
200
+ }
201
+ });
202
+ });
203
+ describe('a repository whose workflows all build one flavor', () => {
204
+ test('gets a single flavored key, written to that flavor everywhere', () => {
205
+ const allCiDynamic = {
206
+ primary: variant('ciDynamicDebug'),
207
+ pullRequest: variant('ciDynamicDebug'),
208
+ release: variant('ciDynamicRelease'),
209
+ };
210
+ const parsed = generate(allCiDynamic, FLAVORS);
211
+ expect(Object.keys(envs(parsed)).filter((k) => k.startsWith('GOOGLE_SERVICES_JSON'))).toEqual([
212
+ 'GOOGLE_SERVICES_JSON',
213
+ ]);
214
+ for (const workflow of ['primary', 'pull-request', 'release']) {
215
+ expect(googleServicesStep(parsed, workflow)).toMatchObject({
216
+ target_path: 'app/src/ciDynamic/google-services.json',
217
+ var_name: 'GOOGLE_SERVICES_JSON',
218
+ });
219
+ }
220
+ });
221
+ });
222
+ //# sourceMappingURL=a-credential-target-follows-the-flavor-source-set.test.js.map
@@ -0,0 +1,41 @@
1
+ /**
2
+ * A credential two workflows cannot share is declared once per workflow, not
3
+ * once for the pipeline.
4
+ *
5
+ * The generator declares each credential it finds as one empty `app.envs`
6
+ * slot — `- LOCAL_PROPERTIES: ""` — and emits the same unsuffixed `var_name`
7
+ * in every workflow's `file@` step. On a repository with one flavor that is
8
+ * exactly right. On a flavored one it is one value where the build needs two,
9
+ * and the pipeline says so nowhere: `invarn setup` finds one slot, uploads one
10
+ * value, and all three workflows read it.
11
+ *
12
+ * `LOCAL_PROPERTIES` is the case that matters most and the one with no target
13
+ * path to give it away. Every workflow writes the same `local.properties` to
14
+ * the same place; what belongs *in* it is the flavor's environment — a
15
+ * different base API URL plus twenty other keys the Secrets Gradle Plugin
16
+ * turns into `BuildConfig`. `ciDynamic` and `prod` are different environments,
17
+ * so `prodRelease` built against `ciDynamic`'s properties is a release
18
+ * pointing at the CI backend. Two credentials give themselves away by path
19
+ * instead, and both were measured: `google-services.json` per flavor source
20
+ * set (`bld_8e60d90ab4e55f85d2f72459`) and the keystore per declared target
21
+ * (`bld_f3cc37d81…`).
22
+ *
23
+ * The name is the whole mechanism. Invarn's secret scopes are org, connection
24
+ * and pipeline, with no workflow column for a value to vary along — but a flat
25
+ * key→value map carries a workflow perfectly well when the workflow is part of
26
+ * the key, which is what `ci secrets upload` has always encoded and
27
+ * `SecretsManager` has always decoded. So the slot names the workflow and the
28
+ * step does not: `var_name` stays unsuffixed in every workflow, the decoding
29
+ * does the mapping, and a second naming convention in the step would be a
30
+ * second way to say the same thing (`GOOGLE_SERVICES_JSON_CIDYNAMIC` was that
31
+ * second way, and it was reverted).
32
+ *
33
+ * What decides is whether the workflows disagree about the thing the
34
+ * credential follows, and each credential follows something different: a
35
+ * keystore its declared target path, `google-services.json` the flavor's
36
+ * source set, `local.properties` the flavor itself. A credential only one
37
+ * workflow reads can never disagree, which is why the two genuinely
38
+ * release-only credentials keep one slot however flavored the repository is.
39
+ */
40
+ export {};
41
+ //# sourceMappingURL=a-credential-two-workflows-cannot-share-is-declared-per-workflow.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"a-credential-two-workflows-cannot-share-is-declared-per-workflow.test.d.ts","sourceRoot":"","sources":["../../../src/commands/a-credential-two-workflows-cannot-share-is-declared-per-workflow.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG"}
@@ -0,0 +1,324 @@
1
+ /**
2
+ * A credential two workflows cannot share is declared once per workflow, not
3
+ * once for the pipeline.
4
+ *
5
+ * The generator declares each credential it finds as one empty `app.envs`
6
+ * slot — `- LOCAL_PROPERTIES: ""` — and emits the same unsuffixed `var_name`
7
+ * in every workflow's `file@` step. On a repository with one flavor that is
8
+ * exactly right. On a flavored one it is one value where the build needs two,
9
+ * and the pipeline says so nowhere: `invarn setup` finds one slot, uploads one
10
+ * value, and all three workflows read it.
11
+ *
12
+ * `LOCAL_PROPERTIES` is the case that matters most and the one with no target
13
+ * path to give it away. Every workflow writes the same `local.properties` to
14
+ * the same place; what belongs *in* it is the flavor's environment — a
15
+ * different base API URL plus twenty other keys the Secrets Gradle Plugin
16
+ * turns into `BuildConfig`. `ciDynamic` and `prod` are different environments,
17
+ * so `prodRelease` built against `ciDynamic`'s properties is a release
18
+ * pointing at the CI backend. Two credentials give themselves away by path
19
+ * instead, and both were measured: `google-services.json` per flavor source
20
+ * set (`bld_8e60d90ab4e55f85d2f72459`) and the keystore per declared target
21
+ * (`bld_f3cc37d81…`).
22
+ *
23
+ * The name is the whole mechanism. Invarn's secret scopes are org, connection
24
+ * and pipeline, with no workflow column for a value to vary along — but a flat
25
+ * key→value map carries a workflow perfectly well when the workflow is part of
26
+ * the key, which is what `ci secrets upload` has always encoded and
27
+ * `SecretsManager` has always decoded. So the slot names the workflow and the
28
+ * step does not: `var_name` stays unsuffixed in every workflow, the decoding
29
+ * does the mapping, and a second naming convention in the step would be a
30
+ * second way to say the same thing (`GOOGLE_SERVICES_JSON_CIDYNAMIC` was that
31
+ * second way, and it was reverted).
32
+ *
33
+ * What decides is whether the workflows disagree about the thing the
34
+ * credential follows, and each credential follows something different: a
35
+ * keystore its declared target path, `google-services.json` the flavor's
36
+ * source set, `local.properties` the flavor itself. A credential only one
37
+ * workflow reads can never disagree, which is why the two genuinely
38
+ * release-only credentials keep one slot however flavored the repository is.
39
+ */
40
+ import { describe, test, expect } from '@jest/globals';
41
+ import { load } from 'js-yaml';
42
+ import { generateAndroidPipeline, googleServicesTargets, variantFlavors, credentialSlots, GENERATED_WORKFLOWS, } from './build.js';
43
+ function variant(name) {
44
+ const buildType = name.charAt(0).toUpperCase() + name.slice(1);
45
+ return { variant: name, buildType, gradleTask: `assemble${buildType}` };
46
+ }
47
+ /** The real repository's shape: two debug workflows on one flavor, release on another. */
48
+ const FLAVORED = {
49
+ primary: variant('ciDynamicDebug'),
50
+ pullRequest: variant('ciDynamicDebug'),
51
+ release: variant('prodRelease'),
52
+ };
53
+ const UNFLAVORED = {
54
+ primary: variant('debug'),
55
+ pullRequest: variant('debug'),
56
+ release: variant('release'),
57
+ };
58
+ const FLAVORS = ['ciDynamic', 'dev', 'prod'];
59
+ /** The keystore targets the real repository resolves: debug key, release key. */
60
+ const SPLIT_KEYSTORES = {
61
+ primary: 'config/debug.keystore',
62
+ 'pull-request': 'config/debug.keystore',
63
+ release: 'app/release.jks',
64
+ };
65
+ const BASE = {
66
+ keystore: false,
67
+ keystoreProperties: false,
68
+ googleServices: false,
69
+ googlePlayDeploy: false,
70
+ googlePlayPackageName: '',
71
+ artifactType: 'apk',
72
+ keystorePaths: {},
73
+ googleServicesPaths: {},
74
+ workflowFlavors: {},
75
+ secretsProperties: false,
76
+ secretsPropertiesPath: 'local.properties',
77
+ };
78
+ /** Every credential the Android generator knows how to declare, all switched on. */
79
+ const ALL_CREDENTIALS = {
80
+ keystore: true,
81
+ keystoreProperties: true,
82
+ googleServices: true,
83
+ googlePlayDeploy: true,
84
+ googlePlayPackageName: 'com.example.app',
85
+ artifactType: 'aab',
86
+ secretsProperties: true,
87
+ };
88
+ function parse(yaml) {
89
+ return load(yaml);
90
+ }
91
+ function envs(parsed) {
92
+ return Object.assign({}, ...(parsed.app?.envs ?? []));
93
+ }
94
+ /** What one workflow's own `envs` block declares. */
95
+ function workflowEnvs(parsed, workflow) {
96
+ return Object.assign({}, ...(parsed.workflows?.[workflow]?.envs ?? []));
97
+ }
98
+ /**
99
+ * The workflows whose own `envs` declare `varName`, in pipeline order.
100
+ *
101
+ * The name is the plain credential now, so *where* the declaration sits is the
102
+ * whole answer to which workflow it belongs to.
103
+ */
104
+ function declaringWorkflows(parsed, varName) {
105
+ return Object.keys(parsed.workflows ?? {}).filter((workflow) => varName in workflowEnvs(parsed, workflow));
106
+ }
107
+ /** The `var_name` of every `file@` step in `workflow`, in order. */
108
+ function fileVarNames(parsed, workflow) {
109
+ return (parsed.workflows?.[workflow]?.steps ?? [])
110
+ .filter((s) => Object.keys(s)[0] === 'file@1.0.0')
111
+ .map((s) => s['file@1.0.0'].inputs.var_name);
112
+ }
113
+ /** The declared slots that carry `varName`, however they are named. */
114
+ function slotsFor(parsed, varName) {
115
+ return Object.keys(envs(parsed)).filter((key) => key === varName || key.endsWith(`__${varName}`));
116
+ }
117
+ function flavoredPipeline(over = {}) {
118
+ return parse(generateAndroidPipeline(17, {
119
+ ...BASE,
120
+ ...ALL_CREDENTIALS,
121
+ keystorePaths: SPLIT_KEYSTORES,
122
+ googleServicesPaths: googleServicesTargets(FLAVORED, FLAVORS),
123
+ workflowFlavors: variantFlavors(FLAVORED, FLAVORS),
124
+ ...over,
125
+ }, FLAVORED));
126
+ }
127
+ describe('what a credential follows', () => {
128
+ test('is the flavor each workflow builds, for the properties file', () => {
129
+ expect(variantFlavors(FLAVORED, FLAVORS)).toEqual({
130
+ primary: 'ciDynamic',
131
+ 'pull-request': 'ciDynamic',
132
+ release: 'prod',
133
+ });
134
+ });
135
+ test('is nothing at all on a repository with no product flavors', () => {
136
+ expect(variantFlavors(UNFLAVORED, [])).toEqual({
137
+ primary: null,
138
+ 'pull-request': null,
139
+ release: null,
140
+ });
141
+ });
142
+ });
143
+ describe('the slots one credential gets', () => {
144
+ test('are one, unsuffixed, when every workflow wants the same bytes', () => {
145
+ expect(credentialSlots('LOCAL_PROPERTIES', {
146
+ primary: 'ciDynamic',
147
+ 'pull-request': 'ciDynamic',
148
+ release: 'ciDynamic',
149
+ })).toEqual([{ slot: 'LOCAL_PROPERTIES', workflow: null }]);
150
+ });
151
+ test('are one per workflow, under the plain name, when they do not', () => {
152
+ // The name never changes; the block it is declared in does.
153
+ // `CIBUILD_SW__<WF>__<VAR>` is the encoding `ci secrets upload` carries a
154
+ // workflow-scoped *value* under, and a pipeline has structure instead.
155
+ expect(credentialSlots('LOCAL_PROPERTIES', {
156
+ primary: 'ciDynamic',
157
+ 'pull-request': 'ciDynamic',
158
+ release: 'prod',
159
+ })).toEqual([
160
+ { slot: 'LOCAL_PROPERTIES', workflow: 'primary' },
161
+ { slot: 'LOCAL_PROPERTIES', workflow: 'pull-request' },
162
+ { slot: 'LOCAL_PROPERTIES', workflow: 'release' },
163
+ ]);
164
+ });
165
+ test('include the workflows that agree, not only the odd one out', () => {
166
+ // `pull-request` wants the same bytes as `primary`. It still needs a slot
167
+ // of its own: the encoded name is per workflow, and a workflow with no
168
+ // slot has no value and no tripwire.
169
+ const workflows = credentialSlots('KEYSTORE_BASE64', SPLIT_KEYSTORES).map((s) => s.workflow);
170
+ expect(workflows).toEqual(['primary', 'pull-request', 'release']);
171
+ });
172
+ test('are one when only one workflow reads the credential', () => {
173
+ expect(credentialSlots('KEYSTORE_PROPERTIES', { release: 'keystore.properties' })).toEqual([
174
+ { slot: 'KEYSTORE_PROPERTIES', workflow: null },
175
+ ]);
176
+ });
177
+ });
178
+ describe('a flavored repository', () => {
179
+ test('declares the properties file in each workflow, and not globally', () => {
180
+ const parsed = flavoredPipeline();
181
+ expect(declaringWorkflows(parsed, 'LOCAL_PROPERTIES')).toEqual([
182
+ 'primary',
183
+ 'pull-request',
184
+ 'release',
185
+ ]);
186
+ // Globally would be one value for three workflows, which is the bug.
187
+ expect(envs(parsed)).not.toHaveProperty('LOCAL_PROPERTIES');
188
+ });
189
+ test('declares google-services.json and the keystore per workflow too', () => {
190
+ const parsed = flavoredPipeline();
191
+ for (const varName of ['GOOGLE_SERVICES_JSON', 'KEYSTORE_BASE64']) {
192
+ expect(declaringWorkflows(parsed, varName)).toEqual([
193
+ 'primary',
194
+ 'pull-request',
195
+ 'release',
196
+ ]);
197
+ expect(envs(parsed)).not.toHaveProperty(varName);
198
+ }
199
+ });
200
+ test('keeps one slot for the credentials only `release` reads', () => {
201
+ // Genuinely release-only, and legitimately so: nothing else signs a
202
+ // release or talks to Google Play. A second slot for a workflow that never
203
+ // reads the value is a value the user is asked for and nothing consumes.
204
+ const parsed = flavoredPipeline();
205
+ expect(slotsFor(parsed, 'KEYSTORE_PROPERTIES')).toEqual(['KEYSTORE_PROPERTIES']);
206
+ expect(slotsFor(parsed, 'GOOGLE_PLAY_SERVICE_ACCOUNT_JSON')).toEqual([
207
+ 'GOOGLE_PLAY_SERVICE_ACCOUNT_JSON',
208
+ ]);
209
+ });
210
+ test('leaves the step naming the credential, not the slot', () => {
211
+ // The `file@` step never learns the convention exists. `EnvResolver`
212
+ // resolves the plain name for the workflow being run, so one name in the
213
+ // step reads a different value in each workflow.
214
+ const parsed = flavoredPipeline();
215
+ for (const workflow of GENERATED_WORKFLOWS) {
216
+ const names = fileVarNames(parsed, workflow);
217
+ expect(names.length).toBeGreaterThan(0);
218
+ expect(names.filter((n) => n.startsWith('CIBUILD_'))).toEqual([]);
219
+ }
220
+ });
221
+ test('declares every slot empty, which is what `invarn setup` searches by', () => {
222
+ // `unsetEnvKeys` collects `envs` keys whose declared value is the empty
223
+ // string — from `app.envs` and from each workflow's own. A slot with
224
+ // anything else in it is never discovered on the developer's machine and
225
+ // never offered for upload.
226
+ const parsed = flavoredPipeline();
227
+ const isCredential = (k) => /^(KEYSTORE|GOOGLE|LOCAL)_/.test(k);
228
+ const declared = [
229
+ ...Object.entries(envs(parsed)),
230
+ ...Object.keys(parsed.workflows ?? {}).flatMap((w) => Object.entries(workflowEnvs(parsed, w))),
231
+ ].filter(([key]) => isCredential(key));
232
+ // Three per-workflow slots each for the properties file,
233
+ // google-services.json and the keystore; one flat slot each for the two
234
+ // release-only ones.
235
+ expect(declared.length).toBe(11);
236
+ for (const [, value] of declared)
237
+ expect(value).toBe('');
238
+ });
239
+ test('spends no encoding on what the block already says', () => {
240
+ // The workflow a declaration belongs to is the block it sits in. The
241
+ // `CIBUILD_SW__` prefix stays what it has always been: how `ci secrets
242
+ // upload` transports a workflow-scoped value into a flat key→value store
243
+ // that has no workflow column (Alex, 2026-09-10).
244
+ const yaml = generateAndroidPipeline(17, {
245
+ ...BASE,
246
+ ...ALL_CREDENTIALS,
247
+ keystorePaths: SPLIT_KEYSTORES,
248
+ googleServicesPaths: googleServicesTargets(FLAVORED, FLAVORS),
249
+ workflowFlavors: variantFlavors(FLAVORED, FLAVORS),
250
+ }, FLAVORED);
251
+ expect(yaml).not.toContain('CIBUILD_SW__');
252
+ expect(yaml).not.toContain('CIBUILD_S__');
253
+ });
254
+ test('puts a per-workflow declaration in a workflow the pipeline has', () => {
255
+ // Structural now, and that is the point: a declaration cannot name a
256
+ // workflow that does not exist, because it lives inside one.
257
+ const parsed = flavoredPipeline();
258
+ const present = Object.keys(parsed.workflows ?? {});
259
+ const perWorkflow = ['KEYSTORE_BASE64', 'GOOGLE_SERVICES_JSON', 'LOCAL_PROPERTIES'].flatMap((varName) => declaringWorkflows(parsed, varName));
260
+ expect(perWorkflow.length).toBe(9);
261
+ expect(perWorkflow.every((w) => present.includes(w))).toBe(true);
262
+ });
263
+ });
264
+ describe('a repository whose workflows all want the same bytes', () => {
265
+ test('is byte-identical to what the generator emitted before any of this', () => {
266
+ // The whole `app.envs` block, as it stood at cibuild d8263a1 with every
267
+ // credential switched on and no flavors anywhere. Item 02 must be inert
268
+ // here: this is what every already-onboarded project regenerates to.
269
+ const yaml = generateAndroidPipeline(17, { ...BASE, ...ALL_CREDENTIALS });
270
+ const block = yaml.slice(yaml.indexOf('app:\n'), yaml.indexOf('\nworkflows:'));
271
+ expect(block).toBe('app:\n' +
272
+ ' envs:\n' +
273
+ ' - PROJECT_LOCATION: .\n' +
274
+ ' - MODULE: app\n' +
275
+ ' - BUILD_TYPE: Debug\n' +
276
+ ' - GRADLE_OPTS: -Dorg.gradle.daemon=false -Dorg.gradle.parallel=true\n' +
277
+ ' - KEYSTORE_BASE64: "" # Set via: ci secrets add KEYSTORE_BASE64 -w primary\n' +
278
+ ' - KEYSTORE_PROPERTIES: "" # Release signing — set via: ci secrets add KEYSTORE_PROPERTIES -w release\n' +
279
+ ' - GOOGLE_SERVICES_JSON: "" # Set via: ci secrets add GOOGLE_SERVICES_JSON\n' +
280
+ ' - GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: "" # Set via: ci secrets add GOOGLE_PLAY_SERVICE_ACCOUNT_JSON -w release\n' +
281
+ ' - LOCAL_PROPERTIES: "" # Read by the Secrets Gradle Plugin — set via: ci secrets add LOCAL_PROPERTIES\n');
282
+ expect(yaml).not.toContain('CIBUILD_SW__');
283
+ });
284
+ test('is flat on one flavor as much as on none', () => {
285
+ // Three workflows, one flavor, three different build types. The build type
286
+ // is not what any of these credentials follows, so nothing splits.
287
+ const allCiDynamic = {
288
+ primary: variant('ciDynamicDebug'),
289
+ pullRequest: variant('ciDynamicDebug'),
290
+ release: variant('ciDynamicRelease'),
291
+ };
292
+ const yaml = generateAndroidPipeline(17, {
293
+ ...BASE,
294
+ ...ALL_CREDENTIALS,
295
+ googleServicesPaths: googleServicesTargets(allCiDynamic, FLAVORS),
296
+ workflowFlavors: variantFlavors(allCiDynamic, FLAVORS),
297
+ }, allCiDynamic);
298
+ expect(yaml).not.toContain('CIBUILD_SW__');
299
+ expect(slotsFor(parse(yaml), 'LOCAL_PROPERTIES')).toEqual(['LOCAL_PROPERTIES']);
300
+ });
301
+ test('is flat when one keystore serves every workflow', () => {
302
+ const yaml = generateAndroidPipeline(17, {
303
+ ...BASE,
304
+ keystore: true,
305
+ keystorePaths: { primary: 'app/one.jks', 'pull-request': 'app/one.jks', release: 'app/one.jks' },
306
+ });
307
+ expect(slotsFor(parse(yaml), 'KEYSTORE_BASE64')).toEqual(['KEYSTORE_BASE64']);
308
+ });
309
+ test('is flat when the scan resolved no keystore path at all', () => {
310
+ // Empty maps default the way the step defaults them, so a repository the
311
+ // scan could not resolve does not read as three disagreeing workflows.
312
+ const yaml = generateAndroidPipeline(17, { ...BASE, keystore: true });
313
+ expect(slotsFor(parse(yaml), 'KEYSTORE_BASE64')).toEqual(['KEYSTORE_BASE64']);
314
+ });
315
+ test('is flat when only some workflows resolved a path, and it is the default', () => {
316
+ const yaml = generateAndroidPipeline(17, {
317
+ ...BASE,
318
+ keystore: true,
319
+ keystorePaths: { release: 'keystore.jks' },
320
+ });
321
+ expect(slotsFor(parse(yaml), 'KEYSTORE_BASE64')).toEqual(['KEYSTORE_BASE64']);
322
+ });
323
+ });
324
+ //# sourceMappingURL=a-credential-two-workflows-cannot-share-is-declared-per-workflow.test.js.map
@@ -0,0 +1,29 @@
1
+ /**
2
+ * A value already in `.cibuild-secrets.json` answers "what are the bytes".
3
+ * It does not answer "where does each workflow write them", and
4
+ * `collectFileSecret` used to treat the two as one question.
5
+ *
6
+ * `pendingGroups` is empty when every group already has the secret — and a
7
+ * **global** entry satisfies `hasSecret(name, workflow)` for every workflow, so
8
+ * one `ci init` in the repository's past is enough. The call then returned an
9
+ * empty paths map, the caller assigned that to `setupOptions.keystorePaths`,
10
+ * and the fallback filled every workflow from one source: the `storeFile=`
11
+ * line of whatever `keystore.properties` was collected, or the first path
12
+ * Gradle mentioned. One path for three workflows is one discriminator, so
13
+ * `credentialSlots` declared a single flat `KEYSTORE_BASE64` and the whole
14
+ * per-workflow split was lost — silently, and on exactly the credential the
15
+ * split exists for.
16
+ *
17
+ * Measured on the real repository, 2026-09-09: the store held
18
+ * `KEYSTORE_BASE64` globally, the keystore prompt never appeared, and all
19
+ * three workflows were generated writing to `customer-android.jks` — the
20
+ * release key's path, over the two debug workflows. A fresh clone of the same
21
+ * repository prompts and splits correctly, which is why this never showed up
22
+ * in a test that builds its own fixture from nothing.
23
+ *
24
+ * The store records values and never paths, so the path has to be asked for
25
+ * whichever the value's state is. Only a caller that consumes the returned map
26
+ * is affected — `promptTargetPath` marks it — so nothing else gains a prompt.
27
+ */
28
+ export {};
29
+ //# sourceMappingURL=an-already-stored-value-does-not-answer-the-target-question.test.d.ts.map