@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,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ci init` asks once per flavor for the properties file the Secrets Gradle
|
|
3
|
+
* Plugin reads, and stores the answer against that flavor's workflows.
|
|
4
|
+
*
|
|
5
|
+
* The generator now declares this credential once per workflow when the
|
|
6
|
+
* workflows build different flavors, because two flavors are two environments
|
|
7
|
+
* and one value cannot serve both. Collection had not moved: `collectFileSecret`
|
|
8
|
+
* was called with a single group labelled *"every workflow"*, so `ci init`
|
|
9
|
+
* asked once and stored the same bytes against `primary`, `pull-request` and
|
|
10
|
+
* `release`. The declaration would then say three values are needed and the
|
|
11
|
+
* store would hold one, three times.
|
|
12
|
+
*
|
|
13
|
+
* The machinery for this was already there. `collectFileSecret` takes
|
|
14
|
+
* `workflowGroups`, prompts per group, and stores per workflow — that is how
|
|
15
|
+
* the keystore has always collected a debug key and a release key separately.
|
|
16
|
+
* The properties file was the one credential passing it a single group.
|
|
17
|
+
*
|
|
18
|
+
* What matters more than the split is that it is the *same* split: `ci init`
|
|
19
|
+
* groups by exactly the discriminator `credentialSlots` declares by, so the
|
|
20
|
+
* question asked and the slot declared cannot drift apart. One group means one
|
|
21
|
+
* slot and one question; two groups mean one slot per workflow and a question
|
|
22
|
+
* per group. That invariant is asserted directly rather than left to two
|
|
23
|
+
* copies of the same rule staying in step.
|
|
24
|
+
*/
|
|
25
|
+
import { describe, test, expect, beforeEach, afterEach } from '@jest/globals';
|
|
26
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
27
|
+
import { tmpdir } from 'node:os';
|
|
28
|
+
import { join, resolve } from 'node:path';
|
|
29
|
+
import prompts from 'prompts';
|
|
30
|
+
import { collectionGroups, credentialSlots, propertiesFileGroups, variantFlavors, } from './build.js';
|
|
31
|
+
import { collectFileSecret } from './file-secret-collector.js';
|
|
32
|
+
import { SecretsManager } from '../yaml/secrets-manager.js';
|
|
33
|
+
function variant(name) {
|
|
34
|
+
const buildType = name.charAt(0).toUpperCase() + name.slice(1);
|
|
35
|
+
return { variant: name, buildType, gradleTask: `assemble${buildType}` };
|
|
36
|
+
}
|
|
37
|
+
const FLAVORED = {
|
|
38
|
+
primary: variant('ciDynamicDebug'),
|
|
39
|
+
pullRequest: variant('ciDynamicDebug'),
|
|
40
|
+
release: variant('prodRelease'),
|
|
41
|
+
};
|
|
42
|
+
const UNFLAVORED = {
|
|
43
|
+
primary: variant('debug'),
|
|
44
|
+
pullRequest: variant('debug'),
|
|
45
|
+
release: variant('release'),
|
|
46
|
+
};
|
|
47
|
+
const FLAVORS = ['ciDynamic', 'prod'];
|
|
48
|
+
const CI_DYNAMIC = 'BASE_API_URL=https://ci-dynamic.example.test/api\n';
|
|
49
|
+
const PROD = 'BASE_API_URL=https://prod.example.test/api\n';
|
|
50
|
+
describe('the groups a credential is collected in', () => {
|
|
51
|
+
test('are one when every workflow wants the same bytes', () => {
|
|
52
|
+
const groups = collectionGroups({ primary: 'ciDynamic', 'pull-request': 'ciDynamic', release: 'ciDynamic' }, () => 'every workflow');
|
|
53
|
+
expect(groups).toEqual([
|
|
54
|
+
{ label: 'every workflow', workflows: ['primary', 'pull-request', 'release'], discriminator: 'ciDynamic' },
|
|
55
|
+
]);
|
|
56
|
+
});
|
|
57
|
+
test('are one per distinct value, keeping the workflows that agree together', () => {
|
|
58
|
+
const groups = collectionGroups({ primary: 'ciDynamic', 'pull-request': 'ciDynamic', release: 'prod' }, (flavor) => flavor);
|
|
59
|
+
expect(groups).toEqual([
|
|
60
|
+
{ label: 'ciDynamic', workflows: ['primary', 'pull-request'], discriminator: 'ciDynamic' },
|
|
61
|
+
{ label: 'prod', workflows: ['release'], discriminator: 'prod' },
|
|
62
|
+
]);
|
|
63
|
+
});
|
|
64
|
+
test('split exactly when the declaration splits', () => {
|
|
65
|
+
// Two copies of one rule is one copy too many. If these ever disagree,
|
|
66
|
+
// `ci init` asks a question the pipeline has no slot for, or declares a
|
|
67
|
+
// slot nothing on the machine was ever collected into.
|
|
68
|
+
const maps = [
|
|
69
|
+
{ primary: '', 'pull-request': '', release: '' },
|
|
70
|
+
{ primary: 'ciDynamic', 'pull-request': 'ciDynamic', release: 'prod' },
|
|
71
|
+
{ primary: 'a', 'pull-request': 'b', release: 'c' },
|
|
72
|
+
{ release: 'keystore.properties' },
|
|
73
|
+
{ primary: 'app/one.jks', 'pull-request': 'app/one.jks', release: 'app/one.jks' },
|
|
74
|
+
];
|
|
75
|
+
expect(maps.map((map) => ({
|
|
76
|
+
groups: collectionGroups(map, (d) => d).length === 1,
|
|
77
|
+
oneFlatSlot: credentialSlots('X', map).length === 1,
|
|
78
|
+
}))).toEqual([
|
|
79
|
+
{ groups: true, oneFlatSlot: true },
|
|
80
|
+
{ groups: false, oneFlatSlot: false },
|
|
81
|
+
{ groups: false, oneFlatSlot: false },
|
|
82
|
+
{ groups: true, oneFlatSlot: true },
|
|
83
|
+
{ groups: true, oneFlatSlot: true },
|
|
84
|
+
]);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
describe('the groups the properties file is collected in', () => {
|
|
88
|
+
test('are one per flavor, and say which flavor', () => {
|
|
89
|
+
const groups = propertiesFileGroups(variantFlavors(FLAVORED, FLAVORS));
|
|
90
|
+
expect(groups.map((g) => ({ label: g.label, workflows: g.workflows }))).toEqual([
|
|
91
|
+
{ label: 'the ciDynamic flavor (primary, pull-request)', workflows: ['primary', 'pull-request'] },
|
|
92
|
+
{ label: 'the prod flavor (release)', workflows: ['release'] },
|
|
93
|
+
]);
|
|
94
|
+
});
|
|
95
|
+
test('are one, labelled as they were, on a repository with no flavors', () => {
|
|
96
|
+
// The label is what the prompt says out loud, and this is the wording
|
|
97
|
+
// every unflavored repository has always seen.
|
|
98
|
+
expect(propertiesFileGroups(variantFlavors(UNFLAVORED, []))).toEqual([
|
|
99
|
+
{ label: 'every workflow', workflows: ['primary', 'pull-request', 'release'], discriminator: '' },
|
|
100
|
+
]);
|
|
101
|
+
});
|
|
102
|
+
test('are one when three workflows build three variants of one flavor', () => {
|
|
103
|
+
const oneFlavor = {
|
|
104
|
+
primary: variant('ciDynamicDebug'),
|
|
105
|
+
pullRequest: variant('ciDynamicDebug'),
|
|
106
|
+
release: variant('ciDynamicRelease'),
|
|
107
|
+
};
|
|
108
|
+
expect(propertiesFileGroups(variantFlavors(oneFlavor, FLAVORS))).toHaveLength(1);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
describe('collecting a value for each group', () => {
|
|
112
|
+
let workdir;
|
|
113
|
+
let secretsManager;
|
|
114
|
+
// `prompts.inject` *concatenates*, so an empty array clears nothing and a
|
|
115
|
+
// sentinel one test leaves queued becomes the next test's answer. The queue
|
|
116
|
+
// itself is the only reset there is.
|
|
117
|
+
const clearInjected = () => {
|
|
118
|
+
prompts._injected = undefined;
|
|
119
|
+
};
|
|
120
|
+
beforeEach(() => {
|
|
121
|
+
clearInjected();
|
|
122
|
+
workdir = mkdtempSync(join(tmpdir(), 'cibuild-flavor-properties-'));
|
|
123
|
+
workdir = resolve(workdir);
|
|
124
|
+
secretsManager = new SecretsManager(join(workdir, '.cibuild-secrets.json'));
|
|
125
|
+
});
|
|
126
|
+
afterEach(() => {
|
|
127
|
+
clearInjected();
|
|
128
|
+
rmSync(workdir, { recursive: true, force: true });
|
|
129
|
+
});
|
|
130
|
+
function put(relPath, contents) {
|
|
131
|
+
const full = join(workdir, relPath);
|
|
132
|
+
mkdirSync(join(full, '..'), { recursive: true });
|
|
133
|
+
writeFileSync(full, contents, 'utf-8');
|
|
134
|
+
return full;
|
|
135
|
+
}
|
|
136
|
+
async function collect(groups, ni = false) {
|
|
137
|
+
return collectFileSecret({
|
|
138
|
+
displayName: 'local.properties',
|
|
139
|
+
secretName: 'LOCAL_PROPERTIES',
|
|
140
|
+
fileMatch: (name) => name === 'local.properties',
|
|
141
|
+
readAs: 'text',
|
|
142
|
+
workflowGroups: groups,
|
|
143
|
+
}, workdir, secretsManager, ni);
|
|
144
|
+
}
|
|
145
|
+
test('stores a different value against the workflows of each flavor', async () => {
|
|
146
|
+
const ciDynamicFile = put('local.properties', CI_DYNAMIC);
|
|
147
|
+
const prodFile = put('envs/prod/local.properties', PROD);
|
|
148
|
+
prompts.inject([ciDynamicFile, prodFile]);
|
|
149
|
+
await collect(propertiesFileGroups(variantFlavors(FLAVORED, FLAVORS)));
|
|
150
|
+
expect(secretsManager.getSecret('LOCAL_PROPERTIES', 'primary')).toBe(CI_DYNAMIC);
|
|
151
|
+
expect(secretsManager.getSecret('LOCAL_PROPERTIES', 'pull-request')).toBe(CI_DYNAMIC);
|
|
152
|
+
expect(secretsManager.getSecret('LOCAL_PROPERTIES', 'release')).toBe(PROD);
|
|
153
|
+
});
|
|
154
|
+
test('stores them where `ci secrets upload` will encode them per workflow', async () => {
|
|
155
|
+
const ciDynamicFile = put('local.properties', CI_DYNAMIC);
|
|
156
|
+
const prodFile = put('envs/prod/local.properties', PROD);
|
|
157
|
+
prompts.inject([ciDynamicFile, prodFile]);
|
|
158
|
+
await collect(propertiesFileGroups(variantFlavors(FLAVORED, FLAVORS)));
|
|
159
|
+
// Not `global`. A global value would satisfy all three workflows and the
|
|
160
|
+
// upload would push one flat key, which is the bug this closes.
|
|
161
|
+
const stored = JSON.parse(readFileSync(join(workdir, '.cibuild-secrets.json'), 'utf-8'));
|
|
162
|
+
expect(stored.global).toEqual({});
|
|
163
|
+
expect(Object.keys(stored.workflows).sort()).toEqual(['primary', 'pull-request', 'release']);
|
|
164
|
+
});
|
|
165
|
+
test('asks nothing at all when one group meets one file', async () => {
|
|
166
|
+
// Today's behaviour on every unflavored repository, unchanged: a single
|
|
167
|
+
// group and a single candidate auto-apply. The injected answer is
|
|
168
|
+
// unreadable on purpose — if a prompt fired, nothing would be stored.
|
|
169
|
+
put('local.properties', CI_DYNAMIC);
|
|
170
|
+
prompts.inject(['/nonexistent/would-be-a-prompt']);
|
|
171
|
+
await collect(propertiesFileGroups(variantFlavors(UNFLAVORED, [])));
|
|
172
|
+
for (const workflow of ['primary', 'pull-request', 'release']) {
|
|
173
|
+
expect(secretsManager.getSecret('LOCAL_PROPERTIES', workflow)).toBe(CI_DYNAMIC);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
test('gives every group the same file when it cannot ask', async () => {
|
|
177
|
+
// `ci init --create` runs non-interactive. One file is all it can offer,
|
|
178
|
+
// and a wrong-flavored properties file that the user then corrects beats
|
|
179
|
+
// no properties file at all, which is a build that cannot configure.
|
|
180
|
+
put('local.properties', CI_DYNAMIC);
|
|
181
|
+
await collect(propertiesFileGroups(variantFlavors(FLAVORED, FLAVORS)), true);
|
|
182
|
+
expect(secretsManager.getSecret('LOCAL_PROPERTIES', 'primary')).toBe(CI_DYNAMIC);
|
|
183
|
+
expect(secretsManager.getSecret('LOCAL_PROPERTIES', 'release')).toBe(CI_DYNAMIC);
|
|
184
|
+
});
|
|
185
|
+
test('leaves a group alone once its value is already stored', async () => {
|
|
186
|
+
put('local.properties', CI_DYNAMIC);
|
|
187
|
+
put('envs/prod/local.properties', PROD);
|
|
188
|
+
secretsManager.storeSecret('LOCAL_PROPERTIES', PROD, 'release');
|
|
189
|
+
// One answer, for the one group still pending.
|
|
190
|
+
prompts.inject([join(workdir, 'local.properties')]);
|
|
191
|
+
await collect(propertiesFileGroups(variantFlavors(FLAVORED, FLAVORS)));
|
|
192
|
+
expect(secretsManager.getSecret('LOCAL_PROPERTIES', 'primary')).toBe(CI_DYNAMIC);
|
|
193
|
+
expect(secretsManager.getSecret('LOCAL_PROPERTIES', 'release')).toBe(PROD);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
//# sourceMappingURL=each-flavor-is-asked-for-its-own-properties-file.test.js.map
|
|
@@ -10,6 +10,19 @@ export interface FileSecretConfig {
|
|
|
10
10
|
secretName: string;
|
|
11
11
|
/** Returns true if a filename matches (e.g. name === "google-services.json") */
|
|
12
12
|
fileMatch: (name: string) => boolean;
|
|
13
|
+
/**
|
|
14
|
+
* Narrows the candidates by their path relative to the project root, once
|
|
15
|
+
* `fileMatch` has found them by name.
|
|
16
|
+
*
|
|
17
|
+
* A credential Android keeps one copy of per product flavor has the same
|
|
18
|
+
* basename in every source set — four `google-services.json` under
|
|
19
|
+
* `app/`, `app/src/dev/`, `app/src/prod/` and `app/src/ciDynamic/` — so a
|
|
20
|
+
* name is not enough to say which one a workflow needs. Without this the
|
|
21
|
+
* single-candidate auto-apply below never fires on a flavored repository,
|
|
22
|
+
* and the answer comes from a prompt or from whichever the walk reached
|
|
23
|
+
* first.
|
|
24
|
+
*/
|
|
25
|
+
pathMatch?: (relativePath: string) => boolean;
|
|
13
26
|
/** How to read the file into a string value. Defaults to "text". */
|
|
14
27
|
readAs?: "text" | "base64";
|
|
15
28
|
/** Workflow groups to scope the secret to */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"file-secret-collector.d.ts","sourceRoot":"","sources":["../../../src/commands/file-secret-collector.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AAE5D,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,EAAE,CAAC;CACrB;AAED,MAAM,WAAW,gBAAgB;IAC/B,oDAAoD;IACpD,WAAW,EAAE,MAAM,CAAC;IACpB,uDAAuD;IACvD,UAAU,EAAE,MAAM,CAAC;IACnB,gFAAgF;IAChF,SAAS,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC;IACrC,oEAAoE;IACpE,MAAM,CAAC,EAAE,MAAM,GAAG,QAAQ,CAAC;IAC3B,6CAA6C;IAC7C,cAAc,EAAE,aAAa,EAAE,CAAC;IAChC;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC5B;AAED;;;GAGG;AACH,MAAM,MAAM,eAAe,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;AAMrD,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,GAAG,MAAM,EAAE,CAkBxF;AAiDD;;;;;GAKG;AACH,wBAAsB,iBAAiB,CACrC,MAAM,EAAE,gBAAgB,EACxB,GAAG,EAAE,MAAM,EACX,cAAc,EAAE,cAAc,EAC9B,cAAc,UAAQ,GACrB,OAAO,CAAC,eAAe,CAAC,
|
|
1
|
+
{"version":3,"file":"file-secret-collector.d.ts","sourceRoot":"","sources":["../../../src/commands/file-secret-collector.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AAE5D,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,EAAE,CAAC;CACrB;AAED,MAAM,WAAW,gBAAgB;IAC/B,oDAAoD;IACpD,WAAW,EAAE,MAAM,CAAC;IACpB,uDAAuD;IACvD,UAAU,EAAE,MAAM,CAAC;IACnB,gFAAgF;IAChF,SAAS,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC;IACrC;;;;;;;;;;;OAWG;IACH,SAAS,CAAC,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,OAAO,CAAC;IAC9C,oEAAoE;IACpE,MAAM,CAAC,EAAE,MAAM,GAAG,QAAQ,CAAC;IAC3B,6CAA6C;IAC7C,cAAc,EAAE,aAAa,EAAE,CAAC;IAChC;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC5B;AAED;;;GAGG;AACH,MAAM,MAAM,eAAe,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;AAMrD,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,GAAG,MAAM,EAAE,CAkBxF;AAiDD;;;;;GAKG;AACH,wBAAsB,iBAAiB,CACrC,MAAM,EAAE,gBAAgB,EACxB,GAAG,EAAE,MAAM,EACX,cAAc,EAAE,cAAc,EAC9B,cAAc,UAAQ,GACrB,OAAO,CAAC,eAAe,CAAC,CA0H1B"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { resolve, relative } from "node:path";
|
|
1
|
+
import { resolve, relative, sep } from "node:path";
|
|
2
2
|
import { readdirSync, readFileSync } from "node:fs";
|
|
3
3
|
import * as readline from "node:readline";
|
|
4
4
|
import prompts from "prompts";
|
|
@@ -87,7 +87,8 @@ export async function collectFileSecret(config, cwd, secretsManager, nonInteract
|
|
|
87
87
|
const readContent = (p) => config.readAs === "base64"
|
|
88
88
|
? readFileSync(p).toString("base64")
|
|
89
89
|
: readFileSync(p, "utf-8");
|
|
90
|
-
const
|
|
90
|
+
const relativeToCwd = (p) => relative(cwd, p).split(sep).join("/");
|
|
91
|
+
const candidates = findProjectFiles(cwd, config.fileMatch).filter((p) => config.pathMatch?.(relativeToCwd(p)) ?? true);
|
|
91
92
|
if (candidates.length === 1 && config.workflowGroups.length === 1) {
|
|
92
93
|
// Single file, single-group config — auto-apply without prompting
|
|
93
94
|
const content = readContent(candidates[0]);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A properties file the Secrets Gradle Plugin reads is a credential like any
|
|
3
|
+
* other, and it has to reach every workflow that builds — not just `release`.
|
|
4
|
+
*
|
|
5
|
+
* The scanner has always found this one: it emits a `secrets-plugin` warning
|
|
6
|
+
* whenever a build file applies the plugin and the properties file it reads is
|
|
7
|
+
* gitignored, and `ci init` prints *"The file is gitignored — inject it as a
|
|
8
|
+
* file step before building"* along with the exact step to write. Then it
|
|
9
|
+
* generates a pipeline without that step. The advice was correct and nobody
|
|
10
|
+
* acted on it, so the pipeline it wrote could not configure Gradle at all.
|
|
11
|
+
*
|
|
12
|
+
* Measured, not supposed: the first build in the record to consume an uploaded
|
|
13
|
+
* credential (`bld_f5167b731b95974e61350390`, 2026-09-09) got past
|
|
14
|
+
* google-services and died four steps later on
|
|
15
|
+
*
|
|
16
|
+
* A problem occurred configuring project ':analytics'.
|
|
17
|
+
* > The file '<repo>/local.properties' could not be found
|
|
18
|
+
*
|
|
19
|
+
* on a repository whose `release` workflow had a `file@` step for it and whose
|
|
20
|
+
* `primary` and `pull-request` did not. The plugin's evaluation listener runs
|
|
21
|
+
* for every variant, so a debug build needs the file exactly as much as a
|
|
22
|
+
* release one does.
|
|
23
|
+
*
|
|
24
|
+
* Declaring the slot matters as much as the step. `unsetEnvKeys` — what
|
|
25
|
+
* `invarn setup` searches by — reads `envs`, not step inputs, so an undeclared
|
|
26
|
+
* `LOCAL_PROPERTIES` is never discovered on the developer's machine and never
|
|
27
|
+
* offered for upload, however many steps name it.
|
|
28
|
+
*/
|
|
29
|
+
export {};
|
|
30
|
+
//# sourceMappingURL=local-properties-reaches-every-workflow.test.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"local-properties-reaches-every-workflow.test.d.ts","sourceRoot":"","sources":["../../../src/commands/local-properties-reaches-every-workflow.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG"}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A properties file the Secrets Gradle Plugin reads is a credential like any
|
|
3
|
+
* other, and it has to reach every workflow that builds — not just `release`.
|
|
4
|
+
*
|
|
5
|
+
* The scanner has always found this one: it emits a `secrets-plugin` warning
|
|
6
|
+
* whenever a build file applies the plugin and the properties file it reads is
|
|
7
|
+
* gitignored, and `ci init` prints *"The file is gitignored — inject it as a
|
|
8
|
+
* file step before building"* along with the exact step to write. Then it
|
|
9
|
+
* generates a pipeline without that step. The advice was correct and nobody
|
|
10
|
+
* acted on it, so the pipeline it wrote could not configure Gradle at all.
|
|
11
|
+
*
|
|
12
|
+
* Measured, not supposed: the first build in the record to consume an uploaded
|
|
13
|
+
* credential (`bld_f5167b731b95974e61350390`, 2026-09-09) got past
|
|
14
|
+
* google-services and died four steps later on
|
|
15
|
+
*
|
|
16
|
+
* A problem occurred configuring project ':analytics'.
|
|
17
|
+
* > The file '<repo>/local.properties' could not be found
|
|
18
|
+
*
|
|
19
|
+
* on a repository whose `release` workflow had a `file@` step for it and whose
|
|
20
|
+
* `primary` and `pull-request` did not. The plugin's evaluation listener runs
|
|
21
|
+
* for every variant, so a debug build needs the file exactly as much as a
|
|
22
|
+
* release one does.
|
|
23
|
+
*
|
|
24
|
+
* Declaring the slot matters as much as the step. `unsetEnvKeys` — what
|
|
25
|
+
* `invarn setup` searches by — reads `envs`, not step inputs, so an undeclared
|
|
26
|
+
* `LOCAL_PROPERTIES` is never discovered on the developer's machine and never
|
|
27
|
+
* offered for upload, however many steps name it.
|
|
28
|
+
*/
|
|
29
|
+
import { describe, test, expect } from '@jest/globals';
|
|
30
|
+
import { load } from 'js-yaml';
|
|
31
|
+
import { generateAndroidPipeline, detectSetupNeeds } from './build.js';
|
|
32
|
+
const BASE = {
|
|
33
|
+
keystore: false,
|
|
34
|
+
keystoreProperties: false,
|
|
35
|
+
googleServices: false,
|
|
36
|
+
googlePlayDeploy: false,
|
|
37
|
+
googlePlayPackageName: '',
|
|
38
|
+
artifactType: 'apk',
|
|
39
|
+
keystorePaths: {},
|
|
40
|
+
googleServicesPaths: {},
|
|
41
|
+
workflowFlavors: {},
|
|
42
|
+
secretsProperties: false,
|
|
43
|
+
secretsPropertiesPath: 'local.properties',
|
|
44
|
+
};
|
|
45
|
+
function parse(yaml) {
|
|
46
|
+
return load(yaml);
|
|
47
|
+
}
|
|
48
|
+
function envs(parsed) {
|
|
49
|
+
return Object.assign({}, ...(parsed.app?.envs ?? []));
|
|
50
|
+
}
|
|
51
|
+
function stepNames(parsed, workflow) {
|
|
52
|
+
return (parsed.workflows?.[workflow]?.steps ?? []).map((s) => Object.keys(s)[0]);
|
|
53
|
+
}
|
|
54
|
+
function fileStepsOf(parsed, workflow) {
|
|
55
|
+
return (parsed.workflows?.[workflow]?.steps ?? [])
|
|
56
|
+
.filter((s) => Object.keys(s)[0] === 'file@1.0.0')
|
|
57
|
+
.map((s) => s['file@1.0.0']);
|
|
58
|
+
}
|
|
59
|
+
describe('a properties file the build reads at configuration time', () => {
|
|
60
|
+
test('is declared once and written in every workflow that builds', () => {
|
|
61
|
+
const parsed = parse(generateAndroidPipeline(17, { ...BASE, secretsProperties: true }));
|
|
62
|
+
expect(envs(parsed).LOCAL_PROPERTIES).toBe('');
|
|
63
|
+
// One object per workflow so a failure names the workflow that lost it.
|
|
64
|
+
const written = ['primary', 'pull-request', 'release'].map((workflow) => {
|
|
65
|
+
const step = fileStepsOf(parsed, workflow).find((s) => s.inputs.var_name === 'LOCAL_PROPERTIES');
|
|
66
|
+
return {
|
|
67
|
+
workflow,
|
|
68
|
+
target: step?.inputs.target_path,
|
|
69
|
+
// Text, not binary: re-encoding a properties file as base64 would
|
|
70
|
+
// write base64 to disk and the plugin would read gibberish.
|
|
71
|
+
base64: step?.inputs.base64_encoded,
|
|
72
|
+
};
|
|
73
|
+
});
|
|
74
|
+
expect(written).toEqual([
|
|
75
|
+
{ workflow: 'primary', target: 'local.properties', base64: undefined },
|
|
76
|
+
{ workflow: 'pull-request', target: 'local.properties', base64: undefined },
|
|
77
|
+
{ workflow: 'release', target: 'local.properties', base64: undefined },
|
|
78
|
+
]);
|
|
79
|
+
});
|
|
80
|
+
test('is written before anything that could read it', () => {
|
|
81
|
+
const parsed = parse(generateAndroidPipeline(17, { ...BASE, secretsProperties: true }));
|
|
82
|
+
const order = ['primary', 'pull-request', 'release'].map((workflow) => {
|
|
83
|
+
const names = stepNames(parsed, workflow);
|
|
84
|
+
const wrote = names.indexOf('file@1.0.0');
|
|
85
|
+
const firstBuildTool = names.findIndex((n) => /^(gradle-build|android-lint|android-unit-test)@/.test(n));
|
|
86
|
+
return { workflow, writesBeforeBuilding: wrote >= 0 && firstBuildTool > wrote };
|
|
87
|
+
});
|
|
88
|
+
expect(order).toEqual([
|
|
89
|
+
{ workflow: 'primary', writesBeforeBuilding: true },
|
|
90
|
+
{ workflow: 'pull-request', writesBeforeBuilding: true },
|
|
91
|
+
{ workflow: 'release', writesBeforeBuilding: true },
|
|
92
|
+
]);
|
|
93
|
+
});
|
|
94
|
+
test('follows the plugin to a properties file that is not local.properties', () => {
|
|
95
|
+
const parsed = parse(generateAndroidPipeline(17, {
|
|
96
|
+
...BASE,
|
|
97
|
+
secretsProperties: true,
|
|
98
|
+
secretsPropertiesPath: 'config/secrets.properties',
|
|
99
|
+
}));
|
|
100
|
+
const written = fileStepsOf(parsed, 'primary').find((s) => s.inputs.var_name === 'LOCAL_PROPERTIES');
|
|
101
|
+
expect(written.inputs.target_path).toBe('config/secrets.properties');
|
|
102
|
+
});
|
|
103
|
+
test('appears nowhere in a repository that does not use the plugin', () => {
|
|
104
|
+
// The whole point of scanning: a pipeline must not declare a slot the
|
|
105
|
+
// build never reads. After issue 03 a declared-and-empty slot fails the
|
|
106
|
+
// build, so an unnecessary one turns a working repository red.
|
|
107
|
+
const yaml = generateAndroidPipeline(17, BASE);
|
|
108
|
+
expect(yaml).not.toContain('LOCAL_PROPERTIES');
|
|
109
|
+
expect(fileStepsOf(parse(yaml), 'primary')).toEqual([]);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
describe('the scan that decides it', () => {
|
|
113
|
+
const warning = (over) => ({
|
|
114
|
+
category: 'secrets-plugin',
|
|
115
|
+
severity: 'warning',
|
|
116
|
+
message: 'Secrets plugin key: MAPS_API_KEY',
|
|
117
|
+
location: 'local.properties',
|
|
118
|
+
...over,
|
|
119
|
+
});
|
|
120
|
+
test('takes the properties file from the per-key warning', () => {
|
|
121
|
+
const needs = detectSetupNeeds([warning({})]);
|
|
122
|
+
expect(needs.secretsProperties).toBe(true);
|
|
123
|
+
expect(needs.secretsPropertiesPath).toBe('local.properties');
|
|
124
|
+
});
|
|
125
|
+
test('takes it from the message when the warning has no defaults file', () => {
|
|
126
|
+
// The two `secrets-plugin` warnings disagree about `location`: the
|
|
127
|
+
// per-key one carries the properties file, the fallback one carries the
|
|
128
|
+
// *build file* that applies the plugin. Reading the wrong one would point
|
|
129
|
+
// the step at `app/build.gradle.kts`.
|
|
130
|
+
const needs = detectSetupNeeds([
|
|
131
|
+
warning({
|
|
132
|
+
message: "Google Secrets Gradle Plugin reads 'config/secrets.properties' which is absent in CI",
|
|
133
|
+
location: 'app/build.gradle.kts',
|
|
134
|
+
}),
|
|
135
|
+
]);
|
|
136
|
+
expect(needs.secretsProperties).toBe(true);
|
|
137
|
+
expect(needs.secretsPropertiesPath).toBe('config/secrets.properties');
|
|
138
|
+
});
|
|
139
|
+
test('is off when nothing reports the plugin', () => {
|
|
140
|
+
const needs = detectSetupNeeds([
|
|
141
|
+
{ category: 'missing-file', severity: 'warning', message: 'keystore.properties not found' },
|
|
142
|
+
]);
|
|
143
|
+
expect(needs.secretsProperties).toBe(false);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
//# sourceMappingURL=local-properties-reaches-every-workflow.test.js.map
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A `cache_paths` entry must not contain the file a `file@` step writes.
|
|
3
|
+
*
|
|
4
|
+
* Everything else about a credential's life inside a build is bounded, and was
|
|
5
|
+
* checked on the three boxes after the first build that ever wrote one: the
|
|
6
|
+
* runner pushes the checkout and the guest root onto `cleanupPaths` and
|
|
7
|
+
* removes them in a `finally`, the guest gets `rm -rf`, and `tart delete`
|
|
8
|
+
* reclaims the VM. Nothing was left anywhere.
|
|
9
|
+
*
|
|
10
|
+
* The cache store is the one place a file can survive all of that.
|
|
11
|
+
* `cache-push` archives whatever `cache_paths` names and uploads it to where
|
|
12
|
+
* the *next* build pulls it from, so a credential swept into that archive
|
|
13
|
+
* outlives the build it was delivered to — and outlives the sandbox that was
|
|
14
|
+
* supposed to contain it.
|
|
15
|
+
*
|
|
16
|
+
* No pipeline in play trips this. Every one of them caches
|
|
17
|
+
* `~/.gradle/caches`, `~/.gradle/wrapper` and `.gradle`, and none of those
|
|
18
|
+
* holds a credential. It is a guard on a mistake that is easy to make and
|
|
19
|
+
* invisible once made: adding `.` to `cache_paths` is a plausible way to make
|
|
20
|
+
* a build faster, and it would publish the signing key.
|
|
21
|
+
*
|
|
22
|
+
* Refused at generation time, like issue 03's missing credential, because that
|
|
23
|
+
* is the only place that can see a whole workflow at once — a cache step can
|
|
24
|
+
* be written before the `file@` step whose target it would swallow — and
|
|
25
|
+
* because the alternative is a build that goes green while leaking.
|
|
26
|
+
*/
|
|
27
|
+
export {};
|
|
28
|
+
//# sourceMappingURL=a-cache-path-must-not-swallow-a-credential.test.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"a-cache-path-must-not-swallow-a-credential.test.d.ts","sourceRoot":"","sources":["../../../src/yaml/a-cache-path-must-not-swallow-a-credential.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG"}
|