@invarn/cibuild 2.7.3 → 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.
- package/dist/cli.cjs +10 -10
- package/dist/src/commands/a-credential-target-follows-the-flavor-source-set.test.d.ts +38 -0
- package/dist/src/commands/a-credential-target-follows-the-flavor-source-set.test.d.ts.map +1 -0
- package/dist/src/commands/a-credential-target-follows-the-flavor-source-set.test.js +219 -0
- package/dist/src/commands/a-credential-two-workflows-cannot-share-is-declared-per-workflow.test.d.ts +41 -0
- package/dist/src/commands/a-credential-two-workflows-cannot-share-is-declared-per-workflow.test.d.ts.map +1 -0
- package/dist/src/commands/a-credential-two-workflows-cannot-share-is-declared-per-workflow.test.js +291 -0
- package/dist/src/commands/build.d.ts +144 -0
- package/dist/src/commands/build.d.ts.map +1 -1
- package/dist/src/commands/build.js +317 -22
- package/dist/src/commands/each-flavor-is-asked-for-its-own-properties-file.test.d.ts +26 -0
- package/dist/src/commands/each-flavor-is-asked-for-its-own-properties-file.test.d.ts.map +1 -0
- package/dist/src/commands/each-flavor-is-asked-for-its-own-properties-file.test.js +196 -0
- package/dist/src/commands/file-secret-collector.d.ts +13 -0
- package/dist/src/commands/file-secret-collector.d.ts.map +1 -1
- package/dist/src/commands/file-secret-collector.js +3 -2
- package/dist/src/commands/local-properties-reaches-every-workflow.test.d.ts +30 -0
- package/dist/src/commands/local-properties-reaches-every-workflow.test.d.ts.map +1 -0
- package/dist/src/commands/local-properties-reaches-every-workflow.test.js +146 -0
- package/dist/src/yaml/a-cache-path-must-not-swallow-a-credential.test.d.ts +28 -0
- package/dist/src/yaml/a-cache-path-must-not-swallow-a-credential.test.d.ts.map +1 -0
- package/dist/src/yaml/a-cache-path-must-not-swallow-a-credential.test.js +223 -0
- package/dist/src/yaml/a-declared-slot-may-name-the-workflow-it-belongs-to.test.d.ts +37 -0
- package/dist/src/yaml/a-declared-slot-may-name-the-workflow-it-belongs-to.test.d.ts.map +1 -0
- package/dist/src/yaml/a-declared-slot-may-name-the-workflow-it-belongs-to.test.js +255 -0
- package/dist/src/yaml/a-scoped-slot-must-name-a-workflow-the-pipeline-has.test.d.ts +34 -0
- package/dist/src/yaml/a-scoped-slot-must-name-a-workflow-the-pipeline-has.test.d.ts.map +1 -0
- package/dist/src/yaml/a-scoped-slot-must-name-a-workflow-the-pipeline-has.test.js +218 -0
- package/dist/src/yaml/a-value-this-instance-stored-is-a-value-it-has.test.d.ts +27 -0
- package/dist/src/yaml/a-value-this-instance-stored-is-a-value-it-has.test.d.ts.map +1 -0
- package/dist/src/yaml/a-value-this-instance-stored-is-a-value-it-has.test.js +126 -0
- package/dist/src/yaml/converter.d.ts +48 -0
- package/dist/src/yaml/converter.d.ts.map +1 -1
- package/dist/src/yaml/converter.js +117 -0
- package/dist/src/yaml/env-resolver.d.ts +52 -0
- package/dist/src/yaml/env-resolver.d.ts.map +1 -1
- package/dist/src/yaml/env-resolver.js +99 -1
- package/dist/src/yaml/secrets-manager.d.ts +13 -3
- package/dist/src/yaml/secrets-manager.d.ts.map +1 -1
- package/dist/src/yaml/secrets-manager.js +25 -14
- package/dist/src/yaml/steps/cocoapods-skip-if-absent.test.d.ts +17 -0
- package/dist/src/yaml/steps/cocoapods-skip-if-absent.test.d.ts.map +1 -0
- package/dist/src/yaml/steps/cocoapods-skip-if-absent.test.js +132 -0
- package/dist/src/yaml/steps/index.d.ts.map +1 -1
- package/dist/src/yaml/steps/index.js +9 -0
- package/dist/src/yaml/steps/ios-deps.d.ts +35 -0
- package/dist/src/yaml/steps/ios-deps.d.ts.map +1 -1
- package/dist/src/yaml/steps/ios-deps.js +18 -3
- package/dist/src/yaml/steps/xcode-derived-data.test.d.ts +30 -0
- package/dist/src/yaml/steps/xcode-derived-data.test.d.ts.map +1 -0
- package/dist/src/yaml/steps/xcode-derived-data.test.js +103 -0
- package/dist/src/yaml/steps/xcode.d.ts.map +1 -1
- package/dist/src/yaml/steps/xcode.js +73 -20
- 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
|
package/dist/src/commands/a-credential-two-workflows-cannot-share-is-declared-per-workflow.test.d.ts
ADDED
|
@@ -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"}
|
package/dist/src/commands/a-credential-two-workflows-cannot-share-is-declared-per-workflow.test.js
ADDED
|
@@ -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
|