@invarn/cibuild 2.7.4 → 2.7.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/dist/cli.cjs +10 -10
  2. package/dist/src/commands/a-credential-target-follows-the-flavor-source-set.test.d.ts +38 -0
  3. package/dist/src/commands/a-credential-target-follows-the-flavor-source-set.test.d.ts.map +1 -0
  4. package/dist/src/commands/a-credential-target-follows-the-flavor-source-set.test.js +222 -0
  5. package/dist/src/commands/a-credential-two-workflows-cannot-share-is-declared-per-workflow.test.d.ts +41 -0
  6. package/dist/src/commands/a-credential-two-workflows-cannot-share-is-declared-per-workflow.test.d.ts.map +1 -0
  7. package/dist/src/commands/a-credential-two-workflows-cannot-share-is-declared-per-workflow.test.js +324 -0
  8. package/dist/src/commands/an-already-stored-value-does-not-answer-the-target-question.test.d.ts +29 -0
  9. package/dist/src/commands/an-already-stored-value-does-not-answer-the-target-question.test.d.ts.map +1 -0
  10. package/dist/src/commands/an-already-stored-value-does-not-answer-the-target-question.test.js +160 -0
  11. package/dist/src/commands/android-scanner.d.ts +9 -1
  12. package/dist/src/commands/android-scanner.d.ts.map +1 -1
  13. package/dist/src/commands/android-scanner.js +103 -3
  14. package/dist/src/commands/build.d.ts +128 -1
  15. package/dist/src/commands/build.d.ts.map +1 -1
  16. package/dist/src/commands/build.js +286 -49
  17. package/dist/src/commands/each-flavor-is-asked-for-its-own-properties-file.test.d.ts +26 -0
  18. package/dist/src/commands/each-flavor-is-asked-for-its-own-properties-file.test.d.ts.map +1 -0
  19. package/dist/src/commands/each-flavor-is-asked-for-its-own-properties-file.test.js +196 -0
  20. package/dist/src/commands/file-secret-collector.d.ts +13 -0
  21. package/dist/src/commands/file-secret-collector.d.ts.map +1 -1
  22. package/dist/src/commands/file-secret-collector.js +25 -7
  23. package/dist/src/commands/flavors-a-convention-plugin-creates-are-still-found.test.d.ts +38 -0
  24. package/dist/src/commands/flavors-a-convention-plugin-creates-are-still-found.test.d.ts.map +1 -0
  25. package/dist/src/commands/flavors-a-convention-plugin-creates-are-still-found.test.js +300 -0
  26. package/dist/src/commands/local-properties-reaches-every-workflow.test.js +2 -0
  27. package/dist/src/yaml/a-cache-path-must-not-swallow-a-credential.test.d.ts +28 -0
  28. package/dist/src/yaml/a-cache-path-must-not-swallow-a-credential.test.d.ts.map +1 -0
  29. package/dist/src/yaml/a-cache-path-must-not-swallow-a-credential.test.js +223 -0
  30. package/dist/src/yaml/a-declared-slot-may-name-the-workflow-it-belongs-to.test.d.ts +37 -0
  31. package/dist/src/yaml/a-declared-slot-may-name-the-workflow-it-belongs-to.test.d.ts.map +1 -0
  32. package/dist/src/yaml/a-declared-slot-may-name-the-workflow-it-belongs-to.test.js +255 -0
  33. package/dist/src/yaml/a-scoped-slot-must-name-a-workflow-the-pipeline-has.test.d.ts +34 -0
  34. package/dist/src/yaml/a-scoped-slot-must-name-a-workflow-the-pipeline-has.test.d.ts.map +1 -0
  35. package/dist/src/yaml/a-scoped-slot-must-name-a-workflow-the-pipeline-has.test.js +218 -0
  36. package/dist/src/yaml/a-value-this-instance-stored-is-a-value-it-has.test.d.ts +27 -0
  37. package/dist/src/yaml/a-value-this-instance-stored-is-a-value-it-has.test.d.ts.map +1 -0
  38. package/dist/src/yaml/a-value-this-instance-stored-is-a-value-it-has.test.js +126 -0
  39. package/dist/src/yaml/converter.d.ts +48 -0
  40. package/dist/src/yaml/converter.d.ts.map +1 -1
  41. package/dist/src/yaml/converter.js +117 -0
  42. package/dist/src/yaml/env-resolver.d.ts +52 -0
  43. package/dist/src/yaml/env-resolver.d.ts.map +1 -1
  44. package/dist/src/yaml/env-resolver.js +99 -1
  45. package/dist/src/yaml/secrets-manager.d.ts +13 -3
  46. package/dist/src/yaml/secrets-manager.d.ts.map +1 -1
  47. package/dist/src/yaml/secrets-manager.js +25 -14
  48. package/dist/src/yaml/steps/cocoapods-install-emits-valid-bash.test.d.ts +22 -0
  49. package/dist/src/yaml/steps/cocoapods-install-emits-valid-bash.test.d.ts.map +1 -0
  50. package/dist/src/yaml/steps/cocoapods-install-emits-valid-bash.test.js +97 -0
  51. package/dist/src/yaml/steps/cocoapods-skip-if-absent.test.d.ts +17 -0
  52. package/dist/src/yaml/steps/cocoapods-skip-if-absent.test.d.ts.map +1 -0
  53. package/dist/src/yaml/steps/cocoapods-skip-if-absent.test.js +132 -0
  54. package/dist/src/yaml/steps/index.d.ts.map +1 -1
  55. package/dist/src/yaml/steps/index.js +9 -0
  56. package/dist/src/yaml/steps/ios-deps.d.ts +35 -0
  57. package/dist/src/yaml/steps/ios-deps.d.ts.map +1 -1
  58. package/dist/src/yaml/steps/ios-deps.js +19 -4
  59. package/dist/src/yaml/steps/xcode-derived-data.test.d.ts +30 -0
  60. package/dist/src/yaml/steps/xcode-derived-data.test.d.ts.map +1 -0
  61. package/dist/src/yaml/steps/xcode-derived-data.test.js +103 -0
  62. package/dist/src/yaml/steps/xcode.d.ts.map +1 -1
  63. package/dist/src/yaml/steps/xcode.js +73 -20
  64. package/package.json +1 -1
@@ -0,0 +1,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"}
@@ -0,0 +1,126 @@
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
+ import { describe, test, expect, beforeEach, afterEach } from '@jest/globals';
27
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
28
+ import { tmpdir } from 'node:os';
29
+ import { join } from 'node:path';
30
+ import { SecretsManager } from './secrets-manager.js';
31
+ const KEYSTORE_PROPERTIES = 'storeFile=app/customer.jks\nstorePassword=x\n';
32
+ let dir;
33
+ let storePath;
34
+ beforeEach(() => {
35
+ dir = mkdtempSync(join(tmpdir(), 'cibuild-store-readback-'));
36
+ storePath = join(dir, '.cibuild-secrets.json');
37
+ delete process.env.CIBUILD_KEYSTORE_PROPERTIES;
38
+ delete process.env.CIBUILD_FROM_THE_ENVIRONMENT;
39
+ });
40
+ afterEach(() => {
41
+ rmSync(dir, { recursive: true, force: true });
42
+ delete process.env.CIBUILD_KEYSTORE_PROPERTIES;
43
+ delete process.env.CIBUILD_FROM_THE_ENVIRONMENT;
44
+ });
45
+ describe('a repository that had no store yet', () => {
46
+ test('reads back a workflow-scoped value it just stored', () => {
47
+ const sm = new SecretsManager(storePath);
48
+ sm.storeSecret('KEYSTORE_PROPERTIES', KEYSTORE_PROPERTIES, 'release');
49
+ expect(sm.getSecret('KEYSTORE_PROPERTIES', 'release')).toBe(KEYSTORE_PROPERTIES);
50
+ expect(sm.hasSecret('KEYSTORE_PROPERTIES', 'release')).toBe(true);
51
+ expect(sm.getSecretIdByName('KEYSTORE_PROPERTIES', 'release')).toBe('KEYSTORE_PROPERTIES');
52
+ });
53
+ test('is why `ci init` can point a keystore step at the declared target', () => {
54
+ // The read that silently returned undefined: `ci init` takes the keystore
55
+ // target out of the properties file it collected in the same run.
56
+ const sm = new SecretsManager(storePath);
57
+ sm.storeSecret('KEYSTORE_PROPERTIES', KEYSTORE_PROPERTIES, 'release');
58
+ const storeFile = sm
59
+ .getSecret('KEYSTORE_PROPERTIES', 'release')
60
+ ?.split('\n')
61
+ .find((line) => line.trim().startsWith('storeFile='));
62
+ expect(storeFile).toBe('storeFile=app/customer.jks');
63
+ });
64
+ test('reads back a global value it just stored', () => {
65
+ const sm = new SecretsManager(storePath);
66
+ sm.storeSecret('LOCAL_PROPERTIES', 'BASE_API_URL=x\n');
67
+ expect(sm.getSecret('LOCAL_PROPERTIES')).toBe('BASE_API_URL=x\n');
68
+ expect(sm.getAll()).toHaveProperty('LOCAL_PROPERTIES', 'BASE_API_URL=x\n');
69
+ expect(sm.getAllSecretNames()).toContain('LOCAL_PROPERTIES');
70
+ });
71
+ test('keeps a value scoped to the workflow it was stored for', () => {
72
+ const sm = new SecretsManager(storePath);
73
+ sm.storeSecret('LOCAL_PROPERTIES', 'ci-dynamic', 'primary');
74
+ sm.storeSecret('LOCAL_PROPERTIES', 'prod', 'release');
75
+ expect(sm.getSecret('LOCAL_PROPERTIES', 'primary')).toBe('ci-dynamic');
76
+ expect(sm.getSecret('LOCAL_PROPERTIES', 'release')).toBe('prod');
77
+ // No global value was stored, so a workflow nobody stored for has none.
78
+ expect(sm.getSecret('LOCAL_PROPERTIES', 'pull-request')).toBeUndefined();
79
+ });
80
+ });
81
+ describe('the environment fallback', () => {
82
+ test('still answers for a key the store does not hold', () => {
83
+ process.env.CIBUILD_FROM_THE_ENVIRONMENT = 'inherited';
84
+ const sm = new SecretsManager(storePath);
85
+ sm.storeSecret('LOCAL_PROPERTIES', 'stored');
86
+ expect(sm.getSecret('FROM_THE_ENVIRONMENT')).toBe('inherited');
87
+ expect(sm.hasSecret('FROM_THE_ENVIRONMENT')).toBe(true);
88
+ expect(sm.getAll()).toMatchObject({
89
+ FROM_THE_ENVIRONMENT: 'inherited',
90
+ LOCAL_PROPERTIES: 'stored',
91
+ });
92
+ });
93
+ test('answers for everything when nothing has been stored', () => {
94
+ process.env.CIBUILD_FROM_THE_ENVIRONMENT = 'inherited';
95
+ const sm = new SecretsManager(storePath);
96
+ expect(sm.getSecret('FROM_THE_ENVIRONMENT')).toBe('inherited');
97
+ expect(sm.getAll()).toEqual({ FROM_THE_ENVIRONMENT: 'inherited' });
98
+ });
99
+ test('loses to a value that was actually stored', () => {
100
+ process.env.CIBUILD_KEYSTORE_PROPERTIES = 'from-the-environment';
101
+ const sm = new SecretsManager(storePath);
102
+ sm.storeSecret('KEYSTORE_PROPERTIES', KEYSTORE_PROPERTIES, 'release');
103
+ expect(sm.getSecret('KEYSTORE_PROPERTIES', 'release')).toBe(KEYSTORE_PROPERTIES);
104
+ });
105
+ });
106
+ describe('a repository that already had a store', () => {
107
+ test('is unchanged — the file is the answer and the environment is not consulted', () => {
108
+ process.env.CIBUILD_FROM_THE_ENVIRONMENT = 'inherited';
109
+ writeFileSync(storePath, JSON.stringify({ global: { A: 'from-the-file' }, workflows: {} }), { mode: 0o600 });
110
+ const sm = new SecretsManager(storePath);
111
+ expect(sm.getSecret('A')).toBe('from-the-file');
112
+ expect(sm.getSecret('FROM_THE_ENVIRONMENT')).toBeUndefined();
113
+ expect(sm.getAll()).toEqual({ A: 'from-the-file' });
114
+ });
115
+ test('still lets a workflow value beat the global one', () => {
116
+ writeFileSync(storePath, JSON.stringify({
117
+ global: { A: 'global' },
118
+ workflows: { release: { A: 'release' } },
119
+ }), { mode: 0o600 });
120
+ const sm = new SecretsManager(storePath);
121
+ expect(sm.getSecret('A', 'release')).toBe('release');
122
+ expect(sm.getSecret('A', 'primary')).toBe('global');
123
+ expect(sm.getAll('release')).toEqual({ A: 'release' });
124
+ });
125
+ });
126
+ //# sourceMappingURL=a-value-this-instance-stored-is-a-value-it-has.test.js.map
@@ -15,6 +15,54 @@ export interface ConversionResult {
15
15
  warnings: string[];
16
16
  skippedSteps: number;
17
17
  }
18
+ /**
19
+ * A credential a `file@` step writes, and where it goes.
20
+ */
21
+ export interface CredentialTarget {
22
+ targetPath: string;
23
+ varName: string;
24
+ }
25
+ /** A cache step's declared paths, and which step declared them. */
26
+ export interface CachePathDeclaration {
27
+ stepName: string;
28
+ cachePaths: string[];
29
+ }
30
+ export interface SwallowedCredential {
31
+ stepName: string;
32
+ cachePath: string;
33
+ targetPath: string;
34
+ varName: string;
35
+ }
36
+ /**
37
+ * True when caching `cachePath` would sweep up the file at `targetPath`.
38
+ *
39
+ * Only a project-relative entry can: `cache.ts` classifies a `~`- or
40
+ * `/`-rooted entry as absolute and stores it from the filesystem root, so it
41
+ * is somewhere the checkout is not. An entry naming the checkout itself (`.`,
42
+ * `./`) contains everything.
43
+ */
44
+ export declare function cacheEntryContains(cachePath: string, targetPath: string): boolean;
45
+ /**
46
+ * Every credential a workflow's cache steps would archive.
47
+ *
48
+ * `cache-push` uploads whatever `cache_paths` names, and that archive is
49
+ * stored where the *next* build pulls it from — so a credential swept into it
50
+ * outlives the build that received it. Everything else about a credential's
51
+ * life is bounded: the runner pushes the checkout and the guest root onto
52
+ * `cleanupPaths` and removes them in a `finally`, the guest gets
53
+ * `rm -rf`, and `tart delete` reclaims the VM. The cache store is the one
54
+ * place a file can survive all of that, which makes this the single hole in
55
+ * "nothing survives a build".
56
+ *
57
+ * No pipeline in play trips it — every one of them caches `~/.gradle/caches`,
58
+ * `~/.gradle/wrapper` and `.gradle`, and none of those contains a credential —
59
+ * so this is a guard on a mistake that is easy to make and impossible to see:
60
+ * adding `.` to `cache_paths` is a plausible way to speed up a build, and it
61
+ * would publish the keystore.
62
+ */
63
+ export declare function credentialsSwallowedByCache(credentials: readonly CredentialTarget[], caches: readonly CachePathDeclaration[]): SwallowedCredential[];
64
+ /** The refusal text for one swallowed credential. */
65
+ export declare function swallowedCredentialMessage(workflowName: string, swallowed: SwallowedCredential): string;
18
66
  /**
19
67
  * Converts a YAML pipeline workflow to PipelineDef format
20
68
  * @param pipeline Parsed YAML pipeline
@@ -1 +1 @@
1
- {"version":3,"file":"converter.d.ts","sourceRoot":"","sources":["../../../src/yaml/converter.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAW,QAAQ,EAAE,MAAM,aAAa,CAAC;AAClE,OAAO,KAAK,EAAE,YAAY,EAA4B,MAAM,YAAY,CAAC;AAMzE,qBAAa,mBAAoB,SAAQ,KAAK;gBAChC,OAAO,EAAE,MAAM;CAI5B;AAED;;GAEG;AACH,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,WAAW,CAAC;IACtB,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,YAAY,EAAE,MAAM,CAAC;CACtB;AA6CD;;;;;;;GAOG;AACH,wBAAsB,wBAAwB,CAC5C,QAAQ,EAAE,YAAY,EACtB,MAAM,EAAE,QAAQ,EAChB,YAAY,CAAC,EAAE,MAAM,EACrB,YAAY,CAAC,EAAE,MAAM,GACpB,OAAO,CAAC,gBAAgB,CAAC,CAgK3B;AAwGD;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,YAAY,GAAG,MAAM,EAAE,CAEtE"}
1
+ {"version":3,"file":"converter.d.ts","sourceRoot":"","sources":["../../../src/yaml/converter.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAW,QAAQ,EAAE,MAAM,aAAa,CAAC;AAClE,OAAO,KAAK,EAAE,YAAY,EAA4B,MAAM,YAAY,CAAC;AAMzE,qBAAa,mBAAoB,SAAQ,KAAK;gBAChC,OAAO,EAAE,MAAM;CAI5B;AAED;;GAEG;AACH,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,WAAW,CAAC;IACtB,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,YAAY,EAAE,MAAM,CAAC;CACtB;AAaD;;GAEG;AACH,MAAM,WAAW,gBAAgB;IAC/B,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,mEAAmE;AACnE,MAAM,WAAW,oBAAoB;IACnC,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,EAAE,CAAC;CACtB;AAED,MAAM,WAAW,mBAAmB;IAClC,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;CACjB;AAaD;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CASjF;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,2BAA2B,CACzC,WAAW,EAAE,SAAS,gBAAgB,EAAE,EACxC,MAAM,EAAE,SAAS,oBAAoB,EAAE,GACtC,mBAAmB,EAAE,CAgBvB;AAED,qDAAqD;AACrD,wBAAgB,0BAA0B,CACxC,YAAY,EAAE,MAAM,EACpB,SAAS,EAAE,mBAAmB,GAC7B,MAAM,CAaR;AAkCD;;;;;;;GAOG;AACH,wBAAsB,wBAAwB,CAC5C,QAAQ,EAAE,YAAY,EACtB,MAAM,EAAE,QAAQ,EAChB,YAAY,CAAC,EAAE,MAAM,EACrB,YAAY,CAAC,EAAE,MAAM,GACpB,OAAO,CAAC,gBAAgB,CAAC,CAyM3B;AAwGD;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,YAAY,GAAG,MAAM,EAAE,CAEtE"}
@@ -21,6 +21,87 @@ const CACHE_STEPS = new Set(['cache-pull', 'cache-push']);
21
21
  function isCacheStep(stepName) {
22
22
  return CACHE_STEPS.has(stepName);
23
23
  }
24
+ /**
25
+ * Trailing slashes and a leading `./` off, backslashes forward. `.` and the
26
+ * empty string both mean "the whole checkout".
27
+ */
28
+ function normalizeCheckoutPath(path) {
29
+ const forward = path.trim().replace(/\\/g, '/');
30
+ const withoutLead = forward.replace(/^\.\//, '');
31
+ const withoutTrail = withoutLead.replace(/\/+$/, '');
32
+ return withoutTrail === '.' ? '' : withoutTrail;
33
+ }
34
+ /**
35
+ * True when caching `cachePath` would sweep up the file at `targetPath`.
36
+ *
37
+ * Only a project-relative entry can: `cache.ts` classifies a `~`- or
38
+ * `/`-rooted entry as absolute and stores it from the filesystem root, so it
39
+ * is somewhere the checkout is not. An entry naming the checkout itself (`.`,
40
+ * `./`) contains everything.
41
+ */
42
+ export function cacheEntryContains(cachePath, targetPath) {
43
+ const raw = cachePath.trim();
44
+ if (raw.startsWith('~') || raw.startsWith('/'))
45
+ return false;
46
+ const cache = normalizeCheckoutPath(raw);
47
+ const target = normalizeCheckoutPath(targetPath);
48
+ if (target === '')
49
+ return false;
50
+ if (cache === '')
51
+ return true;
52
+ return target === cache || target.startsWith(`${cache}/`);
53
+ }
54
+ /**
55
+ * Every credential a workflow's cache steps would archive.
56
+ *
57
+ * `cache-push` uploads whatever `cache_paths` names, and that archive is
58
+ * stored where the *next* build pulls it from — so a credential swept into it
59
+ * outlives the build that received it. Everything else about a credential's
60
+ * life is bounded: the runner pushes the checkout and the guest root onto
61
+ * `cleanupPaths` and removes them in a `finally`, the guest gets
62
+ * `rm -rf`, and `tart delete` reclaims the VM. The cache store is the one
63
+ * place a file can survive all of that, which makes this the single hole in
64
+ * "nothing survives a build".
65
+ *
66
+ * No pipeline in play trips it — every one of them caches `~/.gradle/caches`,
67
+ * `~/.gradle/wrapper` and `.gradle`, and none of those contains a credential —
68
+ * so this is a guard on a mistake that is easy to make and impossible to see:
69
+ * adding `.` to `cache_paths` is a plausible way to speed up a build, and it
70
+ * would publish the keystore.
71
+ */
72
+ export function credentialsSwallowedByCache(credentials, caches) {
73
+ const found = [];
74
+ for (const cache of caches) {
75
+ for (const cachePath of cache.cachePaths) {
76
+ for (const credential of credentials) {
77
+ if (!cacheEntryContains(cachePath, credential.targetPath))
78
+ continue;
79
+ found.push({
80
+ stepName: cache.stepName,
81
+ cachePath: cachePath.trim(),
82
+ targetPath: credential.targetPath,
83
+ varName: credential.varName,
84
+ });
85
+ }
86
+ }
87
+ }
88
+ return found;
89
+ }
90
+ /** The refusal text for one swallowed credential. */
91
+ export function swallowedCredentialMessage(workflowName, swallowed) {
92
+ return [
93
+ `Workflow "${workflowName}" would cache a credential.`,
94
+ ``,
95
+ ` The ${swallowed.stepName} step lists "${swallowed.cachePath}" in cache_paths, and that`,
96
+ ` contains "${swallowed.targetPath}" — the file written from the ${swallowed.varName} secret.`,
97
+ ``,
98
+ ` A cached credential outlives the build. The archive is stored where the next`,
99
+ ` build pulls it from, and nothing removes it when the sandbox is destroyed.`,
100
+ ``,
101
+ ` Narrow the cache_paths entry to the directories the build needs cached, or`,
102
+ ` write the credential somewhere outside them.`,
103
+ ].join('\n');
104
+ }
24
105
  /**
25
106
  * Mapping of common unsupported steps to CI Build alternatives
26
107
  */
@@ -98,6 +179,11 @@ export async function convertYAMLToPipelineDef(pipeline, config, workflowName, y
98
179
  const steps = [];
99
180
  const stepsOrder = [];
100
181
  let stepCounter = 0;
182
+ // Collected as the steps are converted, checked once at the end: a cache
183
+ // step can be declared before the `file@` step whose target it would
184
+ // swallow, so neither one alone can answer the question.
185
+ const credentialTargets = [];
186
+ const cacheDeclarations = [];
101
187
  for (const yamlStep of workflowSteps) {
102
188
  // Parse step (sub-task 3.8)
103
189
  const parsedStep = parseStep(yamlStep, stepCounter);
@@ -154,6 +240,27 @@ export async function convertYAMLToPipelineDef(pipeline, config, workflowName, y
154
240
  }
155
241
  throw error;
156
242
  }
243
+ // Both halves of the cache-swallows-a-credential question, read off the
244
+ // interpolated inputs so `$PROJECT_LOCATION` and friends are already the
245
+ // paths they will be at runtime.
246
+ if (resolvedStepName === 'file') {
247
+ const targetPath = interpolatedInputs?.target_path;
248
+ const varName = interpolatedInputs?.var_name;
249
+ if (typeof targetPath === 'string' && typeof varName === 'string') {
250
+ credentialTargets.push({ targetPath, varName });
251
+ }
252
+ }
253
+ if (isCacheStep(resolvedStepName)) {
254
+ const declared = interpolatedInputs?.cache_paths;
255
+ const cachePaths = Array.isArray(declared)
256
+ ? declared.filter((p) => typeof p === 'string')
257
+ : typeof declared === 'string'
258
+ ? [declared]
259
+ : [];
260
+ if (cachePaths.length > 0) {
261
+ cacheDeclarations.push({ stepName: resolvedStepName, cachePaths });
262
+ }
263
+ }
157
264
  // Execute step to get script
158
265
  const stepDef = await stepExecutor.execute(interpolatedInputs, envResolver.getAll(), config);
159
266
  // Generate unique step ID
@@ -174,6 +281,16 @@ export async function convertYAMLToPipelineDef(pipeline, config, workflowName, y
174
281
  }
175
282
  }
176
283
  }
284
+ // Step 4b: refuse a pipeline that would archive a credential.
285
+ //
286
+ // Thrown at generation time for the same reason issue 03's missing-credential
287
+ // refusal is: it is the only place that can see the whole workflow, and the
288
+ // alternative is a build that succeeds while publishing a key. A pipeline
289
+ // configuration error, and the author is the one who can fix it.
290
+ const swallowed = credentialsSwallowedByCache(credentialTargets, cacheDeclarations);
291
+ if (swallowed.length > 0) {
292
+ throw new YAMLConversionError(swallowed.map((s) => swallowedCredentialMessage(selectedWorkflowName, s)).join('\n\n'));
293
+ }
177
294
  // Step 5: Create PipelineDef
178
295
  const pipelineDef = {
179
296
  name: `${selectedWorkflowName} (YAML)`,
@@ -23,6 +23,30 @@ export declare class ChecksumFileMissingError extends Error {
23
23
  readonly stepName?: string | undefined;
24
24
  constructor(filePath: string, stepName?: string | undefined);
25
25
  }
26
+ /**
27
+ * A credential slot scoped to a workflow the pipeline does not have.
28
+ *
29
+ * Thrown at generation time, before any step converts, because every way of
30
+ * getting the name wrong is otherwise silent. A slot naming a workflow nobody
31
+ * runs declares the credential for nobody: `file@1.0.0` finds no key in its
32
+ * map, calls the credential optional and skips it with a warning, and
33
+ * `ci secrets upload` cheerfully pushes a value under the same wrong name.
34
+ * Two green halves and a build that dies later inside the project's own build
35
+ * tool.
36
+ *
37
+ * The underscore case gets its own sentence because the workflow is right
38
+ * there in the pipeline. `encodeSecretName` upper-cases the workflow and turns
39
+ * dashes into underscores, so decoding turns every underscore back into a
40
+ * dash: a workflow genuinely called `my_wf` can only ever be addressed as
41
+ * `my-wf`, and "no such workflow" about a workflow that plainly exists reads
42
+ * as a defect in us rather than a name that cannot survive the round trip.
43
+ */
44
+ export declare class UnknownWorkflowSlotError extends Error {
45
+ readonly slot: string;
46
+ readonly varName: string;
47
+ readonly workflow: string;
48
+ constructor(slot: string, varName: string, workflow: string, declared: string[]);
49
+ }
26
50
  /**
27
51
  * EnvResolver handles environment variable merging and interpolation
28
52
  */
@@ -35,6 +59,10 @@ export declare class EnvResolver {
35
59
  * not it gave one a value. Read by `importDeclaredFromProcessEnv`.
36
60
  */
37
61
  private declaredVars;
62
+ /** The workflow being resolved. Decides which scoped slots apply — `slotKeyFor`. */
63
+ private workflowName;
64
+ /** Every workflow this pipeline declares. A scoped slot has to name one of them. */
65
+ private declaredWorkflows;
38
66
  constructor(pipeline: YAMLPipeline, workflow: YAMLWorkflow, workflowName: string, platform: Platform, stack?: string, yamlFilePath?: string);
39
67
  /**
40
68
  * Gets the value of an environment variable
@@ -111,6 +139,30 @@ export declare class EnvResolver {
111
139
  * Merges environment variables from YAML array format
112
140
  * @param envArray Array of environment variable objects
113
141
  */
142
+ /**
143
+ * The variable a declared `envs` key stands for, or null when the
144
+ * declaration belongs to a different workflow.
145
+ *
146
+ * A credential two workflows cannot share is declared once per workflow,
147
+ * under the same name a workflow-scoped secret carries — the pipeline says
148
+ * `CIBUILD_SW__RELEASE__LOCAL_PROPERTIES: ""` where it used to say
149
+ * `LOCAL_PROPERTIES: ""`. The build still reads the credential under its
150
+ * plain name, so the declaration has to mean the plain name: it is what puts
151
+ * an unsupplied key in the map for `file@1.0.0` to refuse over, and what
152
+ * lets `importDeclaredFromProcessEnv` accept a value delivered flat.
153
+ *
154
+ * A slot naming another workflow is dropped rather than declared. Declaring
155
+ * it would make this workflow demand a value only that one was ever going to
156
+ * be given. The decoding lives here, beside the decoding of the secret
157
+ * itself, so the steps keep reading their plain `var_name` and never learn
158
+ * the convention exists.
159
+ *
160
+ * A slot naming a workflow the pipeline does not have is refused rather than
161
+ * dropped — see `UnknownWorkflowSlotError`. Dropping it silently is the same
162
+ * outcome as never declaring the credential, which is the green skip issue 03
163
+ * exists to end.
164
+ */
165
+ private slotKeyFor;
114
166
  private mergeEnvVars;
115
167
  /**
116
168
  * Fills a declared-but-still-empty variable from the process environment.