@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.
- 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 +124 -1
- package/dist/src/commands/build.d.ts.map +1 -1
- package/dist/src/commands/build.js +251 -33
- 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.js +2 -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,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A pipeline may declare a credential slot under the name a *workflow-scoped*
|
|
3
|
+
* secret carries, and the workflow it names reads it under the plain name.
|
|
4
|
+
*
|
|
5
|
+
* Two workflows that build different product flavors cannot share one value.
|
|
6
|
+
* `local.properties` is the clearest case: every workflow writes the same file
|
|
7
|
+
* to the same path, and what belongs in it is the flavor's environment — a
|
|
8
|
+
* different base API URL and twenty other keys the Secrets Gradle Plugin turns
|
|
9
|
+
* into `BuildConfig`. One slot, one value, and one of the two flavors builds
|
|
10
|
+
* against the wrong environment.
|
|
11
|
+
*
|
|
12
|
+
* The value side of that has always worked. `ci secrets upload` encodes a
|
|
13
|
+
* workflow-scoped secret as `CIBUILD_SW__<WORKFLOW>__<VAR>`, `SecretsManager`
|
|
14
|
+
* decodes it back, and `EnvResolver` asks for the workflow being run — proved
|
|
15
|
+
* end to end on the dispatch path, where a fresh clone has no
|
|
16
|
+
* `.cibuild-secrets.json` and the value arrives as a bare environment
|
|
17
|
+
* variable. What did not work is *declaring* it. A pipeline declares its
|
|
18
|
+
* credentials as empty `envs` slots, and that declaration does two jobs
|
|
19
|
+
* nothing else does:
|
|
20
|
+
*
|
|
21
|
+
* - `unsetEnvKeys` — what `invarn setup` searches the developer's machine
|
|
22
|
+
* by — reads `envs`, never step inputs. An undeclared credential is never
|
|
23
|
+
* discovered and never offered for upload.
|
|
24
|
+
* - `file@1.0.0` tells "nobody supplied this" from "this workflow does not
|
|
25
|
+
* need it" by whether the key is in the env map at all, and only a
|
|
26
|
+
* declaration puts an unsupplied key there. Without one, a credential that
|
|
27
|
+
* went missing is skipped with a warning and the build fails later inside
|
|
28
|
+
* the project's own build tool (issue 03).
|
|
29
|
+
*
|
|
30
|
+
* So a per-workflow slot has to be declarable, and a declaration naming one
|
|
31
|
+
* workflow has to mean nothing in the others — otherwise `release` would
|
|
32
|
+
* demand `primary`'s value. The decoding belongs here, beside the decoding of
|
|
33
|
+
* the secret itself, and not in `file@`: the step keeps reading its plain
|
|
34
|
+
* `var_name` and never learns the convention exists.
|
|
35
|
+
*/
|
|
36
|
+
import { describe, test, expect, beforeEach, afterEach } from '@jest/globals';
|
|
37
|
+
import { spawnSync } from 'node:child_process';
|
|
38
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs';
|
|
39
|
+
import { tmpdir } from 'node:os';
|
|
40
|
+
import { join } from 'node:path';
|
|
41
|
+
import { convertYAMLToPipelineDef } from './converter.js';
|
|
42
|
+
import { EnvResolver } from './env-resolver.js';
|
|
43
|
+
import { clearRegistry, registerStep } from './steps/registry.js';
|
|
44
|
+
import { FileStepExecutor } from './steps/file.js';
|
|
45
|
+
const mockConfig = {
|
|
46
|
+
artifactsDir: '/test/artifacts',
|
|
47
|
+
maxConcurrentJobs: 1,
|
|
48
|
+
paths: {
|
|
49
|
+
buildsDir: '/test/builds',
|
|
50
|
+
cacheDir: '/test/cache',
|
|
51
|
+
derivedDataDir: '/test/derived-data',
|
|
52
|
+
},
|
|
53
|
+
interpreters: {
|
|
54
|
+
bash: '/bin/bash',
|
|
55
|
+
python: 'python3',
|
|
56
|
+
ruby: 'ruby',
|
|
57
|
+
node: 'node',
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
const TARGET = 'local.properties';
|
|
61
|
+
/** Two flavors, two environments, and nothing in common but the file name. */
|
|
62
|
+
const CI_DYNAMIC = 'BASE_API_URL=https://ci-dynamic.example.test/api\n';
|
|
63
|
+
const PROD = 'BASE_API_URL=https://prod.example.test/api\n';
|
|
64
|
+
/** Every environment variable a test here may set, cleared around each one. */
|
|
65
|
+
const TOUCHED = [
|
|
66
|
+
'LOCAL_PROPERTIES',
|
|
67
|
+
'CIBUILD_S__LOCAL_PROPERTIES',
|
|
68
|
+
'CIBUILD_SW__PRIMARY__LOCAL_PROPERTIES',
|
|
69
|
+
'CIBUILD_SW__PULL_REQUEST__LOCAL_PROPERTIES',
|
|
70
|
+
'CIBUILD_SW__RELEASE__LOCAL_PROPERTIES',
|
|
71
|
+
];
|
|
72
|
+
let workdir;
|
|
73
|
+
let previousCwd;
|
|
74
|
+
function clearTouched() {
|
|
75
|
+
for (const key of TOUCHED)
|
|
76
|
+
delete process.env[key];
|
|
77
|
+
}
|
|
78
|
+
beforeEach(() => {
|
|
79
|
+
clearRegistry();
|
|
80
|
+
registerStep('file', new FileStepExecutor());
|
|
81
|
+
previousCwd = process.cwd();
|
|
82
|
+
workdir = mkdtempSync(join(tmpdir(), 'cibuild-workflow-slot-'));
|
|
83
|
+
process.chdir(workdir);
|
|
84
|
+
// `workdir` is the pre-symlink path on macOS; the steps resolve paths from
|
|
85
|
+
// CIBUILD_SOURCE_DIR, which is cwd, so read files back the same way.
|
|
86
|
+
workdir = process.cwd();
|
|
87
|
+
clearTouched();
|
|
88
|
+
});
|
|
89
|
+
afterEach(() => {
|
|
90
|
+
process.chdir(previousCwd);
|
|
91
|
+
rmSync(workdir, { recursive: true, force: true });
|
|
92
|
+
clearRegistry();
|
|
93
|
+
clearTouched();
|
|
94
|
+
});
|
|
95
|
+
/**
|
|
96
|
+
* The shape a flavored repository's generated pipeline has: the credential
|
|
97
|
+
* declared once per workflow under the encoded name, and the same unsuffixed
|
|
98
|
+
* `var_name` in every workflow's `file@` step.
|
|
99
|
+
*/
|
|
100
|
+
function probePipeline(declared) {
|
|
101
|
+
const step = {
|
|
102
|
+
'file@1.0.0': {
|
|
103
|
+
title: 'Setup local.properties',
|
|
104
|
+
is_skippable: true,
|
|
105
|
+
inputs: { target_path: TARGET, var_name: 'LOCAL_PROPERTIES' },
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
return {
|
|
109
|
+
format_version: '1',
|
|
110
|
+
app: { envs: Object.entries(declared).map(([k, v]) => ({ [k]: v })) },
|
|
111
|
+
workflows: {
|
|
112
|
+
primary: { envs: [{ VARIANT: 'ciDynamicDebug' }], steps: [step] },
|
|
113
|
+
'pull-request': { envs: [{ VARIANT: 'ciDynamicDebug' }], steps: [step] },
|
|
114
|
+
release: { envs: [{ VARIANT: 'prodRelease' }], steps: [step] },
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Converts one workflow and runs the steps it generated the way the runner
|
|
120
|
+
* does, then reads back whatever landed at `local.properties`.
|
|
121
|
+
*/
|
|
122
|
+
async function land(pipeline, workflow) {
|
|
123
|
+
const result = await convertYAMLToPipelineDef(pipeline, mockConfig, workflow);
|
|
124
|
+
const chunks = [...result.warnings];
|
|
125
|
+
for (const step of result.pipeline.steps) {
|
|
126
|
+
const scriptPath = join(workdir, `__step_${step.id}.sh`);
|
|
127
|
+
writeFileSync(scriptPath, step.script, 'utf-8');
|
|
128
|
+
const proc = spawnSync('bash', [scriptPath], {
|
|
129
|
+
cwd: workdir,
|
|
130
|
+
encoding: 'utf-8',
|
|
131
|
+
env: { ...process.env },
|
|
132
|
+
});
|
|
133
|
+
expect(proc.status).toBe(0);
|
|
134
|
+
chunks.push(proc.stdout ?? '', proc.stderr ?? '');
|
|
135
|
+
}
|
|
136
|
+
const landedAt = join(workdir, TARGET);
|
|
137
|
+
return {
|
|
138
|
+
output: chunks.join('\n'),
|
|
139
|
+
contents: existsSync(landedAt) ? readFileSync(landedAt, 'utf-8') : undefined,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/** The conversion's outcome, without letting a throw escape the assertion. */
|
|
143
|
+
async function convert(pipeline, workflow) {
|
|
144
|
+
try {
|
|
145
|
+
const result = await convertYAMLToPipelineDef(pipeline, mockConfig, workflow);
|
|
146
|
+
return { steps: result.pipeline.steps };
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
return { error: error };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
function resolverFor(pipeline, workflow) {
|
|
153
|
+
return new EnvResolver(pipeline, pipeline.workflows[workflow], workflow, 'linux');
|
|
154
|
+
}
|
|
155
|
+
/** The three slots a flavored repository's pipeline declares. */
|
|
156
|
+
const PER_WORKFLOW_SLOTS = {
|
|
157
|
+
CIBUILD_SW__PRIMARY__LOCAL_PROPERTIES: '',
|
|
158
|
+
CIBUILD_SW__PULL_REQUEST__LOCAL_PROPERTIES: '',
|
|
159
|
+
CIBUILD_SW__RELEASE__LOCAL_PROPERTIES: '',
|
|
160
|
+
};
|
|
161
|
+
describe('a slot naming a workflow', () => {
|
|
162
|
+
test('delivers that workflow its own bytes, and the other workflow the other bytes', async () => {
|
|
163
|
+
// Exactly what a dispatch hands the build: bare environment variables
|
|
164
|
+
// under the encoded names, and no `.cibuild-secrets.json` anywhere.
|
|
165
|
+
process.env.CIBUILD_SW__PRIMARY__LOCAL_PROPERTIES = CI_DYNAMIC;
|
|
166
|
+
process.env.CIBUILD_SW__RELEASE__LOCAL_PROPERTIES = PROD;
|
|
167
|
+
expect(existsSync(join(workdir, '.cibuild-secrets.json'))).toBe(false);
|
|
168
|
+
const primary = await land(probePipeline(PER_WORKFLOW_SLOTS), 'primary');
|
|
169
|
+
expect(primary.contents).toBe(CI_DYNAMIC);
|
|
170
|
+
rmSync(join(workdir, TARGET));
|
|
171
|
+
const release = await land(probePipeline(PER_WORKFLOW_SLOTS), 'release');
|
|
172
|
+
expect(release.contents).toBe(PROD);
|
|
173
|
+
});
|
|
174
|
+
test('reaches a workflow whose name the encoding had to change', async () => {
|
|
175
|
+
// `pull-request` cannot be an environment variable name, so the encoding
|
|
176
|
+
// upper-cases it and turns the dash into an underscore. If the decoding
|
|
177
|
+
// did not turn it back the slot would belong to a workflow nobody runs.
|
|
178
|
+
process.env.CIBUILD_SW__PULL_REQUEST__LOCAL_PROPERTIES = CI_DYNAMIC;
|
|
179
|
+
const { contents } = await land(probePipeline(PER_WORKFLOW_SLOTS), 'pull-request');
|
|
180
|
+
expect(contents).toBe(CI_DYNAMIC);
|
|
181
|
+
});
|
|
182
|
+
test('is not a declaration in any other workflow', async () => {
|
|
183
|
+
// `release` is asked for nothing here. If `primary`'s slot declared
|
|
184
|
+
// `LOCAL_PROPERTIES` everywhere, `release` would refuse the build over a
|
|
185
|
+
// value it was never going to be given.
|
|
186
|
+
const pipeline = probePipeline({ CIBUILD_SW__PRIMARY__LOCAL_PROPERTIES: '' });
|
|
187
|
+
const { error, steps } = await convert(pipeline, 'release');
|
|
188
|
+
expect(error).toBeUndefined();
|
|
189
|
+
expect(steps).toBeDefined();
|
|
190
|
+
expect(resolverFor(pipeline, 'release').has('LOCAL_PROPERTIES')).toBe(false);
|
|
191
|
+
});
|
|
192
|
+
test('still refuses the build in the workflow it does name', async () => {
|
|
193
|
+
// The tripwire that makes per-workflow slots safe to emit. Replacing one
|
|
194
|
+
// flat declaration with three scoped ones must not turn issue 03's loud
|
|
195
|
+
// refusal back into a green skip.
|
|
196
|
+
const { error } = await convert(probePipeline({ CIBUILD_SW__PRIMARY__LOCAL_PROPERTIES: '' }), 'primary');
|
|
197
|
+
expect(error).toBeDefined();
|
|
198
|
+
expect(error.message).toContain('LOCAL_PROPERTIES');
|
|
199
|
+
expect(error.message).toContain(TARGET);
|
|
200
|
+
// Named as the build reads it. The encoding is ours, not the user's, and
|
|
201
|
+
// `ci secrets add CIBUILD_SW__PRIMARY__LOCAL_PROPERTIES` is not a command.
|
|
202
|
+
expect(error.message).not.toContain('CIBUILD_SW__');
|
|
203
|
+
});
|
|
204
|
+
test('does not put its own spelling into the build environment', () => {
|
|
205
|
+
process.env.CIBUILD_SW__PRIMARY__LOCAL_PROPERTIES = CI_DYNAMIC;
|
|
206
|
+
const resolver = resolverFor(probePipeline(PER_WORKFLOW_SLOTS), 'primary');
|
|
207
|
+
expect(resolver.get('LOCAL_PROPERTIES')).toBe(CI_DYNAMIC);
|
|
208
|
+
expect(resolver.has('CIBUILD_SW__PRIMARY__LOCAL_PROPERTIES')).toBe(false);
|
|
209
|
+
expect(Object.keys(resolver.getAll()).filter((k) => k.startsWith('CIBUILD_SW__'))).toEqual([]);
|
|
210
|
+
});
|
|
211
|
+
test('carries a value the pipeline itself supplies, under the plain name', () => {
|
|
212
|
+
const resolver = resolverFor(probePipeline({ CIBUILD_SW__PRIMARY__LOCAL_PROPERTIES: CI_DYNAMIC }), 'primary');
|
|
213
|
+
expect(resolver.get('LOCAL_PROPERTIES')).toBe(CI_DYNAMIC);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
describe('a value delivered the old way', () => {
|
|
217
|
+
test('still reaches a per-workflow slot under its plain name', async () => {
|
|
218
|
+
// A project whose secret was uploaded before any of this existed holds one
|
|
219
|
+
// flat `LOCAL_PROPERTIES`. Regenerating its pipeline must not strand that
|
|
220
|
+
// value: the slot declares the plain name, so step 5 imports it.
|
|
221
|
+
process.env.LOCAL_PROPERTIES = CI_DYNAMIC;
|
|
222
|
+
const { contents } = await land(probePipeline(PER_WORKFLOW_SLOTS), 'primary');
|
|
223
|
+
expect(contents).toBe(CI_DYNAMIC);
|
|
224
|
+
});
|
|
225
|
+
test('is beaten by the value scoped to the workflow being run', async () => {
|
|
226
|
+
process.env.LOCAL_PROPERTIES = CI_DYNAMIC;
|
|
227
|
+
process.env.CIBUILD_SW__RELEASE__LOCAL_PROPERTIES = PROD;
|
|
228
|
+
const { contents } = await land(probePipeline(PER_WORKFLOW_SLOTS), 'release');
|
|
229
|
+
expect(contents).toBe(PROD);
|
|
230
|
+
});
|
|
231
|
+
test('reaches a globally encoded slot too', () => {
|
|
232
|
+
// `CIBUILD_S__X` is the other half of the same convention: a secret with
|
|
233
|
+
// no workflow. A pipeline declaring it means `X`, in every workflow.
|
|
234
|
+
process.env.CIBUILD_S__LOCAL_PROPERTIES = CI_DYNAMIC;
|
|
235
|
+
const resolver = resolverFor(probePipeline({ CIBUILD_S__LOCAL_PROPERTIES: '' }), 'release');
|
|
236
|
+
expect(resolver.get('LOCAL_PROPERTIES')).toBe(CI_DYNAMIC);
|
|
237
|
+
expect(resolver.has('CIBUILD_S__LOCAL_PROPERTIES')).toBe(false);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
describe('a pipeline that declares one flat slot', () => {
|
|
241
|
+
test('is untouched — every workflow reads the one value', async () => {
|
|
242
|
+
process.env.LOCAL_PROPERTIES = CI_DYNAMIC;
|
|
243
|
+
for (const workflow of ['primary', 'pull-request', 'release']) {
|
|
244
|
+
const { contents } = await land(probePipeline({ LOCAL_PROPERTIES: '' }), workflow);
|
|
245
|
+
expect(contents).toBe(CI_DYNAMIC);
|
|
246
|
+
rmSync(join(workdir, TARGET));
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
test('keeps refusing when nobody supplied it', async () => {
|
|
250
|
+
const { error } = await convert(probePipeline({ LOCAL_PROPERTIES: '' }), 'primary');
|
|
251
|
+
expect(error).toBeDefined();
|
|
252
|
+
expect(error.message).toContain('LOCAL_PROPERTIES');
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
//# sourceMappingURL=a-declared-slot-may-name-the-workflow-it-belongs-to.test.js.map
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A credential slot scoped to a workflow the pipeline does not have refuses
|
|
3
|
+
* the build, instead of silently belonging to nobody.
|
|
4
|
+
*
|
|
5
|
+
* The declaration `CIBUILD_SW__PRIMARY__LOCAL_PROPERTIES: ""` means "this
|
|
6
|
+
* credential, for the workflow called `primary`". Nothing checked that a
|
|
7
|
+
* workflow by that name existed, and every way of getting the name wrong fails
|
|
8
|
+
* quietly:
|
|
9
|
+
*
|
|
10
|
+
* - **A typo.** `CIBUILD_SW__PRIMRAY__X` decodes to workflow `primray`. No
|
|
11
|
+
* workflow is called that, so no workflow declares `X`, so `file@1.0.0`
|
|
12
|
+
* finds no key in its map, calls the credential optional, and skips it with
|
|
13
|
+
* a warning — the green-step failure mode issue 03 exists to end. The
|
|
14
|
+
* upload half is equally quiet: `ci secrets upload` encodes a value for
|
|
15
|
+
* `primray` and the resolver looks for `primary`.
|
|
16
|
+
* - **An underscore in the workflow's own name.** The encoding upper-cases
|
|
17
|
+
* the name and turns dashes into underscores, so decoding turns every
|
|
18
|
+
* underscore back into a dash. A workflow genuinely called `my_wf` is
|
|
19
|
+
* therefore unreachable: its slot decodes to `my-wf`, which the pipeline
|
|
20
|
+
* does not have. `ci secrets upload` warns about this at upload time; the
|
|
21
|
+
* pipeline is where it can be refused before a build starts.
|
|
22
|
+
*
|
|
23
|
+
* Both are one check — does the pipeline have a workflow by the decoded
|
|
24
|
+
* name? — and both are refused at generation time, from the resolver, before
|
|
25
|
+
* any step converts. That is the same place issue 03's refusal lands and for
|
|
26
|
+
* the same reason: a build that cannot possibly deliver a credential should
|
|
27
|
+
* stop while the message can still name the credential.
|
|
28
|
+
*
|
|
29
|
+
* The check only ever fires on a name we generate. No pipeline written before
|
|
30
|
+
* this declared an encoded slot at all, and a global `CIBUILD_S__X` names no
|
|
31
|
+
* workflow, so there is nothing in existence for it to refuse.
|
|
32
|
+
*/
|
|
33
|
+
export {};
|
|
34
|
+
//# sourceMappingURL=a-scoped-slot-must-name-a-workflow-the-pipeline-has.test.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"a-scoped-slot-must-name-a-workflow-the-pipeline-has.test.d.ts","sourceRoot":"","sources":["../../../src/yaml/a-scoped-slot-must-name-a-workflow-the-pipeline-has.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG"}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A credential slot scoped to a workflow the pipeline does not have refuses
|
|
3
|
+
* the build, instead of silently belonging to nobody.
|
|
4
|
+
*
|
|
5
|
+
* The declaration `CIBUILD_SW__PRIMARY__LOCAL_PROPERTIES: ""` means "this
|
|
6
|
+
* credential, for the workflow called `primary`". Nothing checked that a
|
|
7
|
+
* workflow by that name existed, and every way of getting the name wrong fails
|
|
8
|
+
* quietly:
|
|
9
|
+
*
|
|
10
|
+
* - **A typo.** `CIBUILD_SW__PRIMRAY__X` decodes to workflow `primray`. No
|
|
11
|
+
* workflow is called that, so no workflow declares `X`, so `file@1.0.0`
|
|
12
|
+
* finds no key in its map, calls the credential optional, and skips it with
|
|
13
|
+
* a warning — the green-step failure mode issue 03 exists to end. The
|
|
14
|
+
* upload half is equally quiet: `ci secrets upload` encodes a value for
|
|
15
|
+
* `primray` and the resolver looks for `primary`.
|
|
16
|
+
* - **An underscore in the workflow's own name.** The encoding upper-cases
|
|
17
|
+
* the name and turns dashes into underscores, so decoding turns every
|
|
18
|
+
* underscore back into a dash. A workflow genuinely called `my_wf` is
|
|
19
|
+
* therefore unreachable: its slot decodes to `my-wf`, which the pipeline
|
|
20
|
+
* does not have. `ci secrets upload` warns about this at upload time; the
|
|
21
|
+
* pipeline is where it can be refused before a build starts.
|
|
22
|
+
*
|
|
23
|
+
* Both are one check — does the pipeline have a workflow by the decoded
|
|
24
|
+
* name? — and both are refused at generation time, from the resolver, before
|
|
25
|
+
* any step converts. That is the same place issue 03's refusal lands and for
|
|
26
|
+
* the same reason: a build that cannot possibly deliver a credential should
|
|
27
|
+
* stop while the message can still name the credential.
|
|
28
|
+
*
|
|
29
|
+
* The check only ever fires on a name we generate. No pipeline written before
|
|
30
|
+
* this declared an encoded slot at all, and a global `CIBUILD_S__X` names no
|
|
31
|
+
* workflow, so there is nothing in existence for it to refuse.
|
|
32
|
+
*/
|
|
33
|
+
import { describe, test, expect, beforeEach, afterEach } from '@jest/globals';
|
|
34
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
35
|
+
import { tmpdir } from 'node:os';
|
|
36
|
+
import { join } from 'node:path';
|
|
37
|
+
import { convertYAMLToPipelineDef } from './converter.js';
|
|
38
|
+
import { UnknownWorkflowSlotError } from './env-resolver.js';
|
|
39
|
+
import { clearRegistry, registerStep } from './steps/registry.js';
|
|
40
|
+
import { FileStepExecutor } from './steps/file.js';
|
|
41
|
+
const mockConfig = {
|
|
42
|
+
artifactsDir: '/test/artifacts',
|
|
43
|
+
maxConcurrentJobs: 1,
|
|
44
|
+
paths: {
|
|
45
|
+
buildsDir: '/test/builds',
|
|
46
|
+
cacheDir: '/test/cache',
|
|
47
|
+
derivedDataDir: '/test/derived-data',
|
|
48
|
+
},
|
|
49
|
+
interpreters: {
|
|
50
|
+
bash: '/bin/bash',
|
|
51
|
+
python: 'python3',
|
|
52
|
+
ruby: 'ruby',
|
|
53
|
+
node: 'node',
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
const TARGET = 'local.properties';
|
|
57
|
+
const VALUE = 'BASE_API_URL=https://example.test/api\n';
|
|
58
|
+
const TOUCHED = [
|
|
59
|
+
'LOCAL_PROPERTIES',
|
|
60
|
+
'CIBUILD_S__LOCAL_PROPERTIES',
|
|
61
|
+
'CIBUILD_SW__PRIMARY__LOCAL_PROPERTIES',
|
|
62
|
+
'CIBUILD_SW__PRIMRAY__LOCAL_PROPERTIES',
|
|
63
|
+
'CIBUILD_SW__RELEASE__LOCAL_PROPERTIES',
|
|
64
|
+
'CIBUILD_SW__MY_WF__LOCAL_PROPERTIES',
|
|
65
|
+
];
|
|
66
|
+
let workdir;
|
|
67
|
+
let previousCwd;
|
|
68
|
+
beforeEach(() => {
|
|
69
|
+
clearRegistry();
|
|
70
|
+
registerStep('file', new FileStepExecutor());
|
|
71
|
+
previousCwd = process.cwd();
|
|
72
|
+
workdir = mkdtempSync(join(tmpdir(), 'cibuild-unknown-workflow-'));
|
|
73
|
+
process.chdir(workdir);
|
|
74
|
+
workdir = process.cwd();
|
|
75
|
+
for (const key of TOUCHED)
|
|
76
|
+
delete process.env[key];
|
|
77
|
+
});
|
|
78
|
+
afterEach(() => {
|
|
79
|
+
process.chdir(previousCwd);
|
|
80
|
+
rmSync(workdir, { recursive: true, force: true });
|
|
81
|
+
clearRegistry();
|
|
82
|
+
for (const key of TOUCHED)
|
|
83
|
+
delete process.env[key];
|
|
84
|
+
});
|
|
85
|
+
/** A pipeline with the given declared slots and the given workflow names. */
|
|
86
|
+
function probePipeline(declared, workflowNames = ['primary', 'pull-request', 'release']) {
|
|
87
|
+
const step = {
|
|
88
|
+
'file@1.0.0': {
|
|
89
|
+
is_skippable: true,
|
|
90
|
+
inputs: { target_path: TARGET, var_name: 'LOCAL_PROPERTIES' },
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
const workflows = {};
|
|
94
|
+
for (const name of workflowNames)
|
|
95
|
+
workflows[name] = { steps: [step] };
|
|
96
|
+
return {
|
|
97
|
+
format_version: '1',
|
|
98
|
+
app: { envs: Object.entries(declared).map(([k, v]) => ({ [k]: v })) },
|
|
99
|
+
workflows,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/** The conversion's outcome, without letting a throw escape the assertion. */
|
|
103
|
+
async function convert(pipeline, workflow) {
|
|
104
|
+
try {
|
|
105
|
+
const result = await convertYAMLToPipelineDef(pipeline, mockConfig, workflow);
|
|
106
|
+
return { steps: result.pipeline.steps };
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
return { error: error };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
describe('a slot naming a workflow nobody has', () => {
|
|
113
|
+
test('refuses the build, naming the slot and the workflow it decodes to', async () => {
|
|
114
|
+
process.env.CIBUILD_SW__PRIMRAY__LOCAL_PROPERTIES = VALUE;
|
|
115
|
+
const { error, steps } = await convert(probePipeline({ CIBUILD_SW__PRIMRAY__LOCAL_PROPERTIES: '' }), 'primary');
|
|
116
|
+
expect(error).toBeInstanceOf(UnknownWorkflowSlotError);
|
|
117
|
+
expect(error.message).toContain('CIBUILD_SW__PRIMRAY__LOCAL_PROPERTIES');
|
|
118
|
+
expect(error.message).toContain('primray');
|
|
119
|
+
expect(steps).toBeUndefined();
|
|
120
|
+
});
|
|
121
|
+
test('says which workflows the pipeline does have', async () => {
|
|
122
|
+
const { error } = await convert(probePipeline({ CIBUILD_SW__PRIMRAY__LOCAL_PROPERTIES: '' }), 'primary');
|
|
123
|
+
expect(error.message).toContain('primary');
|
|
124
|
+
expect(error.message).toContain('pull-request');
|
|
125
|
+
expect(error.message).toContain('release');
|
|
126
|
+
});
|
|
127
|
+
test('names the credential the slot is for, since that is what has to arrive', async () => {
|
|
128
|
+
const { error } = await convert(probePipeline({ CIBUILD_SW__PRIMRAY__LOCAL_PROPERTIES: '' }), 'primary');
|
|
129
|
+
expect(error.message).toContain('LOCAL_PROPERTIES');
|
|
130
|
+
});
|
|
131
|
+
test('refuses from whichever workflow is being run', async () => {
|
|
132
|
+
// The slot is in `app.envs`, so it is a defect in the pipeline and not in
|
|
133
|
+
// one workflow of it. A build of `release` must not pass just because the
|
|
134
|
+
// bad slot happens to name something else.
|
|
135
|
+
for (const workflow of ['primary', 'pull-request', 'release']) {
|
|
136
|
+
const { error } = await convert(probePipeline({ CIBUILD_SW__PRIMRAY__LOCAL_PROPERTIES: '' }), workflow);
|
|
137
|
+
expect(error).toBeInstanceOf(UnknownWorkflowSlotError);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
test('refuses a slot declared inside a workflow, not only in app.envs', async () => {
|
|
141
|
+
const pipeline = {
|
|
142
|
+
format_version: '1',
|
|
143
|
+
workflows: {
|
|
144
|
+
primary: {
|
|
145
|
+
envs: [{ CIBUILD_SW__PRIMRAY__LOCAL_PROPERTIES: '' }],
|
|
146
|
+
steps: [
|
|
147
|
+
{
|
|
148
|
+
'file@1.0.0': {
|
|
149
|
+
is_skippable: true,
|
|
150
|
+
inputs: { target_path: TARGET, var_name: 'LOCAL_PROPERTIES' },
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
],
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
const { error } = await convert(pipeline, 'primary');
|
|
158
|
+
expect(error).toBeInstanceOf(UnknownWorkflowSlotError);
|
|
159
|
+
});
|
|
160
|
+
test('carries no secret value into the message', async () => {
|
|
161
|
+
process.env.CIBUILD_SW__PRIMRAY__LOCAL_PROPERTIES = VALUE;
|
|
162
|
+
const { error } = await convert(probePipeline({ CIBUILD_SW__PRIMRAY__LOCAL_PROPERTIES: '' }), 'primary');
|
|
163
|
+
expect(error.message).not.toContain(VALUE.trim());
|
|
164
|
+
expect(error.message).not.toContain('example.test');
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
describe('a workflow whose own name the encoding cannot survive', () => {
|
|
168
|
+
test('is refused, and the message says the underscore is why', async () => {
|
|
169
|
+
// The pipeline really does have `my_wf`. Its slot still cannot reach it:
|
|
170
|
+
// decoding turns every underscore back into a dash, so the slot belongs to
|
|
171
|
+
// `my-wf`. Saying only "no such workflow" about a workflow that is right
|
|
172
|
+
// there would read as a bug in us.
|
|
173
|
+
const { error } = await convert(probePipeline({ CIBUILD_SW__MY_WF__LOCAL_PROPERTIES: '' }, ['my_wf']), 'my_wf');
|
|
174
|
+
expect(error).toBeInstanceOf(UnknownWorkflowSlotError);
|
|
175
|
+
expect(error.message).toContain('my_wf');
|
|
176
|
+
expect(error.message).toContain('my-wf');
|
|
177
|
+
expect(error.message).toMatch(/underscore/i);
|
|
178
|
+
});
|
|
179
|
+
test('is not refused when the workflow is named with a dash instead', async () => {
|
|
180
|
+
process.env.CIBUILD_SW__MY_WF__LOCAL_PROPERTIES = VALUE;
|
|
181
|
+
const { error, steps } = await convert(probePipeline({ CIBUILD_SW__MY_WF__LOCAL_PROPERTIES: '' }, ['my-wf']), 'my-wf');
|
|
182
|
+
expect(error).toBeUndefined();
|
|
183
|
+
expect(steps).toHaveLength(1);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
describe('what the guard leaves alone', () => {
|
|
187
|
+
test('a pipeline whose scoped slots all name real workflows', async () => {
|
|
188
|
+
process.env.CIBUILD_SW__PRIMARY__LOCAL_PROPERTIES = VALUE;
|
|
189
|
+
process.env.CIBUILD_SW__RELEASE__LOCAL_PROPERTIES = VALUE;
|
|
190
|
+
const declared = {
|
|
191
|
+
CIBUILD_SW__PRIMARY__LOCAL_PROPERTIES: '',
|
|
192
|
+
CIBUILD_SW__RELEASE__LOCAL_PROPERTIES: '',
|
|
193
|
+
};
|
|
194
|
+
const { error } = await convert(probePipeline(declared, ['primary', 'release']), 'primary');
|
|
195
|
+
expect(error).toBeUndefined();
|
|
196
|
+
});
|
|
197
|
+
test('a global slot, which names no workflow at all', async () => {
|
|
198
|
+
process.env.CIBUILD_S__LOCAL_PROPERTIES = VALUE;
|
|
199
|
+
const { error, steps } = await convert(probePipeline({ CIBUILD_S__LOCAL_PROPERTIES: '' }, ['primary']), 'primary');
|
|
200
|
+
expect(error).toBeUndefined();
|
|
201
|
+
expect(steps).toHaveLength(1);
|
|
202
|
+
});
|
|
203
|
+
test('an ordinary flat slot, which is every pipeline written before this', async () => {
|
|
204
|
+
process.env.LOCAL_PROPERTIES = VALUE;
|
|
205
|
+
const { error, steps } = await convert(probePipeline({ LOCAL_PROPERTIES: '' }, ['primary']), 'primary');
|
|
206
|
+
expect(error).toBeUndefined();
|
|
207
|
+
expect(steps).toHaveLength(1);
|
|
208
|
+
});
|
|
209
|
+
test('a half-encoded name, which is not a scoped slot and never was', async () => {
|
|
210
|
+
// `decodeSecretName` returns nothing for these, so they are ordinary
|
|
211
|
+
// variable names. Refusing them would be inventing a rule.
|
|
212
|
+
for (const key of ['CIBUILD_SW__RELEASE', 'CIBUILD_SW__RELEASE__', 'CIBUILD_S__']) {
|
|
213
|
+
const { error } = await convert(probePipeline({ [key]: '' }, ['primary']), 'primary');
|
|
214
|
+
expect(error).toBeUndefined();
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
//# sourceMappingURL=a-scoped-slot-must-name-a-workflow-the-pipeline-has.test.js.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A value `SecretsManager` was just told to store is a value it can read back.
|
|
3
|
+
*
|
|
4
|
+
* It could not, on exactly the repositories that matter. `loadSecrets` sets
|
|
5
|
+
* `fileLoaded` when it finds a `.cibuild-secrets.json` or a
|
|
6
|
+
* `CIBUILD_S*`-shaped environment, and every reader begins
|
|
7
|
+
* `if (!this.fileLoaded) return process.env[...]` — a compatibility fallback
|
|
8
|
+
* to bare `CIBUILD_`-prefixed variables from before the store existed.
|
|
9
|
+
* `storeSecret` writes the file but never clears the flag, so on a repository
|
|
10
|
+
* that had no store — a first `ci init`, which is the only time `ci init`
|
|
11
|
+
* collects anything — every value collected in that run read back as absent
|
|
12
|
+
* from the instance that stored it.
|
|
13
|
+
*
|
|
14
|
+
* It is not a hypothetical. `ci init` extracts a workflow's keystore target
|
|
15
|
+
* from `storeFile=` in the `keystore.properties` it collected a moment
|
|
16
|
+
* earlier, which is how a project whose signing config names
|
|
17
|
+
* `app/customer.jks` gets its `file@` step pointed there instead of at
|
|
18
|
+
* `keystore.jks`. That read returned undefined on every fresh repository, so
|
|
19
|
+
* the resolution silently fell through to the Gradle-detected path — the half
|
|
20
|
+
* of issue 07 that shipped and never fired.
|
|
21
|
+
*
|
|
22
|
+
* The fix is additive rather than a flag flip: the stored data is consulted
|
|
23
|
+
* first, and the environment fallback stays exactly where it was for anything
|
|
24
|
+
* the store does not hold. Nothing that resolved before stops resolving.
|
|
25
|
+
*/
|
|
26
|
+
export {};
|
|
27
|
+
//# sourceMappingURL=a-value-this-instance-stored-is-a-value-it-has.test.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"a-value-this-instance-stored-is-a-value-it-has.test.d.ts","sourceRoot":"","sources":["../../../src/yaml/a-value-this-instance-stored-is-a-value-it-has.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG"}
|