@invarn/cibuild 2.7.4 → 2.7.5

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.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 +219 -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 +291 -0
  8. package/dist/src/commands/build.d.ts +124 -1
  9. package/dist/src/commands/build.d.ts.map +1 -1
  10. package/dist/src/commands/build.js +251 -33
  11. package/dist/src/commands/each-flavor-is-asked-for-its-own-properties-file.test.d.ts +26 -0
  12. package/dist/src/commands/each-flavor-is-asked-for-its-own-properties-file.test.d.ts.map +1 -0
  13. package/dist/src/commands/each-flavor-is-asked-for-its-own-properties-file.test.js +196 -0
  14. package/dist/src/commands/file-secret-collector.d.ts +13 -0
  15. package/dist/src/commands/file-secret-collector.d.ts.map +1 -1
  16. package/dist/src/commands/file-secret-collector.js +3 -2
  17. package/dist/src/commands/local-properties-reaches-every-workflow.test.js +2 -0
  18. package/dist/src/yaml/a-cache-path-must-not-swallow-a-credential.test.d.ts +28 -0
  19. package/dist/src/yaml/a-cache-path-must-not-swallow-a-credential.test.d.ts.map +1 -0
  20. package/dist/src/yaml/a-cache-path-must-not-swallow-a-credential.test.js +223 -0
  21. package/dist/src/yaml/a-declared-slot-may-name-the-workflow-it-belongs-to.test.d.ts +37 -0
  22. package/dist/src/yaml/a-declared-slot-may-name-the-workflow-it-belongs-to.test.d.ts.map +1 -0
  23. package/dist/src/yaml/a-declared-slot-may-name-the-workflow-it-belongs-to.test.js +255 -0
  24. package/dist/src/yaml/a-scoped-slot-must-name-a-workflow-the-pipeline-has.test.d.ts +34 -0
  25. package/dist/src/yaml/a-scoped-slot-must-name-a-workflow-the-pipeline-has.test.d.ts.map +1 -0
  26. package/dist/src/yaml/a-scoped-slot-must-name-a-workflow-the-pipeline-has.test.js +218 -0
  27. package/dist/src/yaml/a-value-this-instance-stored-is-a-value-it-has.test.d.ts +27 -0
  28. package/dist/src/yaml/a-value-this-instance-stored-is-a-value-it-has.test.d.ts.map +1 -0
  29. package/dist/src/yaml/a-value-this-instance-stored-is-a-value-it-has.test.js +126 -0
  30. package/dist/src/yaml/converter.d.ts +48 -0
  31. package/dist/src/yaml/converter.d.ts.map +1 -1
  32. package/dist/src/yaml/converter.js +117 -0
  33. package/dist/src/yaml/env-resolver.d.ts +52 -0
  34. package/dist/src/yaml/env-resolver.d.ts.map +1 -1
  35. package/dist/src/yaml/env-resolver.js +99 -1
  36. package/dist/src/yaml/secrets-manager.d.ts +13 -3
  37. package/dist/src/yaml/secrets-manager.d.ts.map +1 -1
  38. package/dist/src/yaml/secrets-manager.js +25 -14
  39. package/dist/src/yaml/steps/cocoapods-skip-if-absent.test.d.ts +17 -0
  40. package/dist/src/yaml/steps/cocoapods-skip-if-absent.test.d.ts.map +1 -0
  41. package/dist/src/yaml/steps/cocoapods-skip-if-absent.test.js +132 -0
  42. package/dist/src/yaml/steps/index.d.ts.map +1 -1
  43. package/dist/src/yaml/steps/index.js +9 -0
  44. package/dist/src/yaml/steps/ios-deps.d.ts +35 -0
  45. package/dist/src/yaml/steps/ios-deps.d.ts.map +1 -1
  46. package/dist/src/yaml/steps/ios-deps.js +18 -3
  47. package/dist/src/yaml/steps/xcode-derived-data.test.d.ts +30 -0
  48. package/dist/src/yaml/steps/xcode-derived-data.test.d.ts.map +1 -0
  49. package/dist/src/yaml/steps/xcode-derived-data.test.js +103 -0
  50. package/dist/src/yaml/steps/xcode.d.ts.map +1 -1
  51. package/dist/src/yaml/steps/xcode.js +73 -20
  52. 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,219 @@
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
+ const declared = envs(parse(yaml));
164
+ expect(Object.keys(declared).filter((k) => k.includes('GOOGLE_SERVICES_JSON'))).toEqual([
165
+ 'CIBUILD_SW__PRIMARY__GOOGLE_SERVICES_JSON',
166
+ 'CIBUILD_SW__PULL_REQUEST__GOOGLE_SERVICES_JSON',
167
+ 'CIBUILD_SW__RELEASE__GOOGLE_SERVICES_JSON',
168
+ ]);
169
+ expect(yaml).not.toMatch(/GOOGLE_SERVICES_JSON_[A-Z]/);
170
+ });
171
+ });
172
+ describe('a repository with no product flavors', () => {
173
+ test('is exactly what it was: one key, one path, in every workflow', () => {
174
+ const parsed = generate(UNFLAVORED, []);
175
+ expect(envs(parsed).GOOGLE_SERVICES_JSON).toBe('');
176
+ for (const workflow of ['primary', 'pull-request', 'release']) {
177
+ expect(googleServicesStep(parsed, workflow)).toMatchObject({
178
+ target_path: 'app/google-services.json',
179
+ var_name: 'GOOGLE_SERVICES_JSON',
180
+ });
181
+ }
182
+ });
183
+ test('is unchanged even when the pipeline is generated with no options at all', () => {
184
+ // The defaulted `SetupOptions` has empty maps; the step must still fall
185
+ // back to the module root rather than emitting `undefined`.
186
+ const parsed = parse(generateAndroidPipeline(17, { ...BASE, googleServices: true }, UNFLAVORED));
187
+ expect(googleServicesStep(parsed, 'primary')).toMatchObject({
188
+ target_path: 'app/google-services.json',
189
+ var_name: 'GOOGLE_SERVICES_JSON',
190
+ });
191
+ });
192
+ test('emits nothing at all when the scan found no google-services.json', () => {
193
+ const parsed = parse(generateAndroidPipeline(17, BASE, UNFLAVORED));
194
+ expect(envs(parsed)).not.toHaveProperty('GOOGLE_SERVICES_JSON');
195
+ for (const workflow of ['primary', 'pull-request', 'release']) {
196
+ expect(googleServicesStep(parsed, workflow)).toBeUndefined();
197
+ }
198
+ });
199
+ });
200
+ describe('a repository whose workflows all build one flavor', () => {
201
+ test('gets a single flavored key, written to that flavor everywhere', () => {
202
+ const allCiDynamic = {
203
+ primary: variant('ciDynamicDebug'),
204
+ pullRequest: variant('ciDynamicDebug'),
205
+ release: variant('ciDynamicRelease'),
206
+ };
207
+ const parsed = generate(allCiDynamic, FLAVORS);
208
+ expect(Object.keys(envs(parsed)).filter((k) => k.startsWith('GOOGLE_SERVICES_JSON'))).toEqual([
209
+ 'GOOGLE_SERVICES_JSON',
210
+ ]);
211
+ for (const workflow of ['primary', 'pull-request', 'release']) {
212
+ expect(googleServicesStep(parsed, workflow)).toMatchObject({
213
+ target_path: 'app/src/ciDynamic/google-services.json',
214
+ var_name: 'GOOGLE_SERVICES_JSON',
215
+ });
216
+ }
217
+ });
218
+ });
219
+ //# 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,291 @@
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
+ import { decodeSecretName } from './secrets-upload.js';
44
+ function variant(name) {
45
+ const buildType = name.charAt(0).toUpperCase() + name.slice(1);
46
+ return { variant: name, buildType, gradleTask: `assemble${buildType}` };
47
+ }
48
+ /** The real repository's shape: two debug workflows on one flavor, release on another. */
49
+ const FLAVORED = {
50
+ primary: variant('ciDynamicDebug'),
51
+ pullRequest: variant('ciDynamicDebug'),
52
+ release: variant('prodRelease'),
53
+ };
54
+ const UNFLAVORED = {
55
+ primary: variant('debug'),
56
+ pullRequest: variant('debug'),
57
+ release: variant('release'),
58
+ };
59
+ const FLAVORS = ['ciDynamic', 'dev', 'prod'];
60
+ /** The keystore targets the real repository resolves: debug key, release key. */
61
+ const SPLIT_KEYSTORES = {
62
+ primary: 'config/debug.keystore',
63
+ 'pull-request': 'config/debug.keystore',
64
+ release: 'app/release.jks',
65
+ };
66
+ const BASE = {
67
+ keystore: false,
68
+ keystoreProperties: false,
69
+ googleServices: false,
70
+ googlePlayDeploy: false,
71
+ googlePlayPackageName: '',
72
+ artifactType: 'apk',
73
+ keystorePaths: {},
74
+ googleServicesPaths: {},
75
+ workflowFlavors: {},
76
+ secretsProperties: false,
77
+ secretsPropertiesPath: 'local.properties',
78
+ };
79
+ /** Every credential the Android generator knows how to declare, all switched on. */
80
+ const ALL_CREDENTIALS = {
81
+ keystore: true,
82
+ keystoreProperties: true,
83
+ googleServices: true,
84
+ googlePlayDeploy: true,
85
+ googlePlayPackageName: 'com.example.app',
86
+ artifactType: 'aab',
87
+ secretsProperties: true,
88
+ };
89
+ function parse(yaml) {
90
+ return load(yaml);
91
+ }
92
+ function envs(parsed) {
93
+ return Object.assign({}, ...(parsed.app?.envs ?? []));
94
+ }
95
+ /** The `var_name` of every `file@` step in `workflow`, in order. */
96
+ function fileVarNames(parsed, workflow) {
97
+ return (parsed.workflows?.[workflow]?.steps ?? [])
98
+ .filter((s) => Object.keys(s)[0] === 'file@1.0.0')
99
+ .map((s) => s['file@1.0.0'].inputs.var_name);
100
+ }
101
+ /** The declared slots that carry `varName`, however they are named. */
102
+ function slotsFor(parsed, varName) {
103
+ return Object.keys(envs(parsed)).filter((key) => key === varName || key.endsWith(`__${varName}`));
104
+ }
105
+ function flavoredPipeline(over = {}) {
106
+ return parse(generateAndroidPipeline(17, {
107
+ ...BASE,
108
+ ...ALL_CREDENTIALS,
109
+ keystorePaths: SPLIT_KEYSTORES,
110
+ googleServicesPaths: googleServicesTargets(FLAVORED, FLAVORS),
111
+ workflowFlavors: variantFlavors(FLAVORED, FLAVORS),
112
+ ...over,
113
+ }, FLAVORED));
114
+ }
115
+ describe('what a credential follows', () => {
116
+ test('is the flavor each workflow builds, for the properties file', () => {
117
+ expect(variantFlavors(FLAVORED, FLAVORS)).toEqual({
118
+ primary: 'ciDynamic',
119
+ 'pull-request': 'ciDynamic',
120
+ release: 'prod',
121
+ });
122
+ });
123
+ test('is nothing at all on a repository with no product flavors', () => {
124
+ expect(variantFlavors(UNFLAVORED, [])).toEqual({
125
+ primary: null,
126
+ 'pull-request': null,
127
+ release: null,
128
+ });
129
+ });
130
+ });
131
+ describe('the slots one credential gets', () => {
132
+ test('are one, unsuffixed, when every workflow wants the same bytes', () => {
133
+ expect(credentialSlots('LOCAL_PROPERTIES', {
134
+ primary: 'ciDynamic',
135
+ 'pull-request': 'ciDynamic',
136
+ release: 'ciDynamic',
137
+ })).toEqual([{ slot: 'LOCAL_PROPERTIES', workflow: null }]);
138
+ });
139
+ test('are one per workflow, encoded, when they do not', () => {
140
+ expect(credentialSlots('LOCAL_PROPERTIES', {
141
+ primary: 'ciDynamic',
142
+ 'pull-request': 'ciDynamic',
143
+ release: 'prod',
144
+ })).toEqual([
145
+ { slot: 'CIBUILD_SW__PRIMARY__LOCAL_PROPERTIES', workflow: 'primary' },
146
+ { slot: 'CIBUILD_SW__PULL_REQUEST__LOCAL_PROPERTIES', workflow: 'pull-request' },
147
+ { slot: 'CIBUILD_SW__RELEASE__LOCAL_PROPERTIES', workflow: 'release' },
148
+ ]);
149
+ });
150
+ test('include the workflows that agree, not only the odd one out', () => {
151
+ // `pull-request` wants the same bytes as `primary`. It still needs a slot
152
+ // of its own: the encoded name is per workflow, and a workflow with no
153
+ // slot has no value and no tripwire.
154
+ const workflows = credentialSlots('KEYSTORE_BASE64', SPLIT_KEYSTORES).map((s) => s.workflow);
155
+ expect(workflows).toEqual(['primary', 'pull-request', 'release']);
156
+ });
157
+ test('are one when only one workflow reads the credential', () => {
158
+ expect(credentialSlots('KEYSTORE_PROPERTIES', { release: 'keystore.properties' })).toEqual([
159
+ { slot: 'KEYSTORE_PROPERTIES', workflow: null },
160
+ ]);
161
+ });
162
+ });
163
+ describe('a flavored repository', () => {
164
+ test('declares the properties file once per workflow', () => {
165
+ const parsed = flavoredPipeline();
166
+ expect(slotsFor(parsed, 'LOCAL_PROPERTIES')).toEqual([
167
+ 'CIBUILD_SW__PRIMARY__LOCAL_PROPERTIES',
168
+ 'CIBUILD_SW__PULL_REQUEST__LOCAL_PROPERTIES',
169
+ 'CIBUILD_SW__RELEASE__LOCAL_PROPERTIES',
170
+ ]);
171
+ });
172
+ test('declares google-services.json and the keystore per workflow too', () => {
173
+ const parsed = flavoredPipeline();
174
+ expect(slotsFor(parsed, 'GOOGLE_SERVICES_JSON')).toEqual([
175
+ 'CIBUILD_SW__PRIMARY__GOOGLE_SERVICES_JSON',
176
+ 'CIBUILD_SW__PULL_REQUEST__GOOGLE_SERVICES_JSON',
177
+ 'CIBUILD_SW__RELEASE__GOOGLE_SERVICES_JSON',
178
+ ]);
179
+ expect(slotsFor(parsed, 'KEYSTORE_BASE64')).toEqual([
180
+ 'CIBUILD_SW__PRIMARY__KEYSTORE_BASE64',
181
+ 'CIBUILD_SW__PULL_REQUEST__KEYSTORE_BASE64',
182
+ 'CIBUILD_SW__RELEASE__KEYSTORE_BASE64',
183
+ ]);
184
+ });
185
+ test('keeps one slot for the credentials only `release` reads', () => {
186
+ // Genuinely release-only, and legitimately so: nothing else signs a
187
+ // release or talks to Google Play. A second slot for a workflow that never
188
+ // reads the value is a value the user is asked for and nothing consumes.
189
+ const parsed = flavoredPipeline();
190
+ expect(slotsFor(parsed, 'KEYSTORE_PROPERTIES')).toEqual(['KEYSTORE_PROPERTIES']);
191
+ expect(slotsFor(parsed, 'GOOGLE_PLAY_SERVICE_ACCOUNT_JSON')).toEqual([
192
+ 'GOOGLE_PLAY_SERVICE_ACCOUNT_JSON',
193
+ ]);
194
+ });
195
+ test('leaves the step naming the credential, not the slot', () => {
196
+ // The `file@` step never learns the convention exists. `EnvResolver`
197
+ // resolves the plain name for the workflow being run, so one name in the
198
+ // step reads a different value in each workflow.
199
+ const parsed = flavoredPipeline();
200
+ for (const workflow of GENERATED_WORKFLOWS) {
201
+ const names = fileVarNames(parsed, workflow);
202
+ expect(names.length).toBeGreaterThan(0);
203
+ expect(names.filter((n) => n.startsWith('CIBUILD_'))).toEqual([]);
204
+ }
205
+ });
206
+ test('declares every slot empty, which is what `invarn setup` searches by', () => {
207
+ // `unsetEnvKeys` collects `envs` keys whose declared value is the empty
208
+ // string. A slot with anything else in it is never discovered on the
209
+ // developer's machine and never offered for upload.
210
+ const declared = envs(flavoredPipeline());
211
+ const credentialSlotKeys = Object.keys(declared).filter((k) => k.startsWith('CIBUILD_SW__') || /^(KEYSTORE|GOOGLE|LOCAL)_/.test(k));
212
+ // Three scoped slots each for the properties file, google-services.json
213
+ // and the keystore; one flat slot each for the two release-only ones.
214
+ expect(credentialSlotKeys.length).toBe(11);
215
+ for (const key of credentialSlotKeys)
216
+ expect(declared[key]).toBe('');
217
+ });
218
+ test('names only workflows the pipeline actually has', () => {
219
+ // `decodeSecretName` lower-cases and turns `_` back into `-`, so the
220
+ // round trip is the only thing that proves `CIBUILD_SW__PULL_REQUEST__X`
221
+ // reaches `pull-request` and not a workflow nobody runs.
222
+ const parsed = flavoredPipeline();
223
+ const present = Object.keys(parsed.workflows ?? {});
224
+ const scoped = Object.keys(envs(parsed))
225
+ .filter((k) => k.startsWith('CIBUILD_SW__'))
226
+ .map((k) => decodeSecretName(k)?.workflow);
227
+ expect(scoped.length).toBe(9);
228
+ expect(scoped.every((w) => w !== undefined && present.includes(w))).toBe(true);
229
+ });
230
+ });
231
+ describe('a repository whose workflows all want the same bytes', () => {
232
+ test('is byte-identical to what the generator emitted before any of this', () => {
233
+ // The whole `app.envs` block, as it stood at cibuild d8263a1 with every
234
+ // credential switched on and no flavors anywhere. Item 02 must be inert
235
+ // here: this is what every already-onboarded project regenerates to.
236
+ const yaml = generateAndroidPipeline(17, { ...BASE, ...ALL_CREDENTIALS });
237
+ const block = yaml.slice(yaml.indexOf('app:\n'), yaml.indexOf('\nworkflows:'));
238
+ expect(block).toBe('app:\n' +
239
+ ' envs:\n' +
240
+ ' - PROJECT_LOCATION: .\n' +
241
+ ' - MODULE: app\n' +
242
+ ' - BUILD_TYPE: Debug\n' +
243
+ ' - GRADLE_OPTS: -Dorg.gradle.daemon=false -Dorg.gradle.parallel=true\n' +
244
+ ' - KEYSTORE_BASE64: "" # Set via: ci secrets add KEYSTORE_BASE64 -w primary\n' +
245
+ ' - KEYSTORE_PROPERTIES: "" # Release signing — set via: ci secrets add KEYSTORE_PROPERTIES -w release\n' +
246
+ ' - GOOGLE_SERVICES_JSON: "" # Set via: ci secrets add GOOGLE_SERVICES_JSON\n' +
247
+ ' - GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: "" # Set via: ci secrets add GOOGLE_PLAY_SERVICE_ACCOUNT_JSON -w release\n' +
248
+ ' - LOCAL_PROPERTIES: "" # Read by the Secrets Gradle Plugin — set via: ci secrets add LOCAL_PROPERTIES\n');
249
+ expect(yaml).not.toContain('CIBUILD_SW__');
250
+ });
251
+ test('is flat on one flavor as much as on none', () => {
252
+ // Three workflows, one flavor, three different build types. The build type
253
+ // is not what any of these credentials follows, so nothing splits.
254
+ const allCiDynamic = {
255
+ primary: variant('ciDynamicDebug'),
256
+ pullRequest: variant('ciDynamicDebug'),
257
+ release: variant('ciDynamicRelease'),
258
+ };
259
+ const yaml = generateAndroidPipeline(17, {
260
+ ...BASE,
261
+ ...ALL_CREDENTIALS,
262
+ googleServicesPaths: googleServicesTargets(allCiDynamic, FLAVORS),
263
+ workflowFlavors: variantFlavors(allCiDynamic, FLAVORS),
264
+ }, allCiDynamic);
265
+ expect(yaml).not.toContain('CIBUILD_SW__');
266
+ expect(slotsFor(parse(yaml), 'LOCAL_PROPERTIES')).toEqual(['LOCAL_PROPERTIES']);
267
+ });
268
+ test('is flat when one keystore serves every workflow', () => {
269
+ const yaml = generateAndroidPipeline(17, {
270
+ ...BASE,
271
+ keystore: true,
272
+ keystorePaths: { primary: 'app/one.jks', 'pull-request': 'app/one.jks', release: 'app/one.jks' },
273
+ });
274
+ expect(slotsFor(parse(yaml), 'KEYSTORE_BASE64')).toEqual(['KEYSTORE_BASE64']);
275
+ });
276
+ test('is flat when the scan resolved no keystore path at all', () => {
277
+ // Empty maps default the way the step defaults them, so a repository the
278
+ // scan could not resolve does not read as three disagreeing workflows.
279
+ const yaml = generateAndroidPipeline(17, { ...BASE, keystore: true });
280
+ expect(slotsFor(parse(yaml), 'KEYSTORE_BASE64')).toEqual(['KEYSTORE_BASE64']);
281
+ });
282
+ test('is flat when only some workflows resolved a path, and it is the default', () => {
283
+ const yaml = generateAndroidPipeline(17, {
284
+ ...BASE,
285
+ keystore: true,
286
+ keystorePaths: { release: 'keystore.jks' },
287
+ });
288
+ expect(slotsFor(parse(yaml), 'KEYSTORE_BASE64')).toEqual(['KEYSTORE_BASE64']);
289
+ });
290
+ });
291
+ //# sourceMappingURL=a-credential-two-workflows-cannot-share-is-declared-per-workflow.test.js.map