@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.
Files changed (54) hide show
  1. package/dist/cli.cjs +10 -10
  2. package/dist/src/commands/a-credential-target-follows-the-flavor-source-set.test.d.ts +38 -0
  3. package/dist/src/commands/a-credential-target-follows-the-flavor-source-set.test.d.ts.map +1 -0
  4. package/dist/src/commands/a-credential-target-follows-the-flavor-source-set.test.js +219 -0
  5. package/dist/src/commands/a-credential-two-workflows-cannot-share-is-declared-per-workflow.test.d.ts +41 -0
  6. package/dist/src/commands/a-credential-two-workflows-cannot-share-is-declared-per-workflow.test.d.ts.map +1 -0
  7. package/dist/src/commands/a-credential-two-workflows-cannot-share-is-declared-per-workflow.test.js +291 -0
  8. package/dist/src/commands/build.d.ts +144 -0
  9. package/dist/src/commands/build.d.ts.map +1 -1
  10. package/dist/src/commands/build.js +317 -22
  11. package/dist/src/commands/each-flavor-is-asked-for-its-own-properties-file.test.d.ts +26 -0
  12. package/dist/src/commands/each-flavor-is-asked-for-its-own-properties-file.test.d.ts.map +1 -0
  13. package/dist/src/commands/each-flavor-is-asked-for-its-own-properties-file.test.js +196 -0
  14. package/dist/src/commands/file-secret-collector.d.ts +13 -0
  15. package/dist/src/commands/file-secret-collector.d.ts.map +1 -1
  16. package/dist/src/commands/file-secret-collector.js +3 -2
  17. package/dist/src/commands/local-properties-reaches-every-workflow.test.d.ts +30 -0
  18. package/dist/src/commands/local-properties-reaches-every-workflow.test.d.ts.map +1 -0
  19. package/dist/src/commands/local-properties-reaches-every-workflow.test.js +146 -0
  20. package/dist/src/yaml/a-cache-path-must-not-swallow-a-credential.test.d.ts +28 -0
  21. package/dist/src/yaml/a-cache-path-must-not-swallow-a-credential.test.d.ts.map +1 -0
  22. package/dist/src/yaml/a-cache-path-must-not-swallow-a-credential.test.js +223 -0
  23. package/dist/src/yaml/a-declared-slot-may-name-the-workflow-it-belongs-to.test.d.ts +37 -0
  24. package/dist/src/yaml/a-declared-slot-may-name-the-workflow-it-belongs-to.test.d.ts.map +1 -0
  25. package/dist/src/yaml/a-declared-slot-may-name-the-workflow-it-belongs-to.test.js +255 -0
  26. package/dist/src/yaml/a-scoped-slot-must-name-a-workflow-the-pipeline-has.test.d.ts +34 -0
  27. package/dist/src/yaml/a-scoped-slot-must-name-a-workflow-the-pipeline-has.test.d.ts.map +1 -0
  28. package/dist/src/yaml/a-scoped-slot-must-name-a-workflow-the-pipeline-has.test.js +218 -0
  29. package/dist/src/yaml/a-value-this-instance-stored-is-a-value-it-has.test.d.ts +27 -0
  30. package/dist/src/yaml/a-value-this-instance-stored-is-a-value-it-has.test.d.ts.map +1 -0
  31. package/dist/src/yaml/a-value-this-instance-stored-is-a-value-it-has.test.js +126 -0
  32. package/dist/src/yaml/converter.d.ts +48 -0
  33. package/dist/src/yaml/converter.d.ts.map +1 -1
  34. package/dist/src/yaml/converter.js +117 -0
  35. package/dist/src/yaml/env-resolver.d.ts +52 -0
  36. package/dist/src/yaml/env-resolver.d.ts.map +1 -1
  37. package/dist/src/yaml/env-resolver.js +99 -1
  38. package/dist/src/yaml/secrets-manager.d.ts +13 -3
  39. package/dist/src/yaml/secrets-manager.d.ts.map +1 -1
  40. package/dist/src/yaml/secrets-manager.js +25 -14
  41. package/dist/src/yaml/steps/cocoapods-skip-if-absent.test.d.ts +17 -0
  42. package/dist/src/yaml/steps/cocoapods-skip-if-absent.test.d.ts.map +1 -0
  43. package/dist/src/yaml/steps/cocoapods-skip-if-absent.test.js +132 -0
  44. package/dist/src/yaml/steps/index.d.ts.map +1 -1
  45. package/dist/src/yaml/steps/index.js +9 -0
  46. package/dist/src/yaml/steps/ios-deps.d.ts +35 -0
  47. package/dist/src/yaml/steps/ios-deps.d.ts.map +1 -1
  48. package/dist/src/yaml/steps/ios-deps.js +18 -3
  49. package/dist/src/yaml/steps/xcode-derived-data.test.d.ts +30 -0
  50. package/dist/src/yaml/steps/xcode-derived-data.test.d.ts.map +1 -0
  51. package/dist/src/yaml/steps/xcode-derived-data.test.js +103 -0
  52. package/dist/src/yaml/steps/xcode.d.ts.map +1 -1
  53. package/dist/src/yaml/steps/xcode.js +73 -20
  54. package/package.json +1 -1
@@ -0,0 +1,223 @@
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
+ import { describe, test, expect, beforeEach, afterEach } from '@jest/globals';
28
+ import { mkdtempSync, rmSync } from 'node:fs';
29
+ import { tmpdir } from 'node:os';
30
+ import { join } from 'node:path';
31
+ import { cacheEntryContains, credentialsSwallowedByCache, swallowedCredentialMessage, convertYAMLToPipelineDef, YAMLConversionError, } from './converter.js';
32
+ import { clearRegistry, registerStep } from './steps/registry.js';
33
+ import { FileStepExecutor } from './steps/file.js';
34
+ import { CachePullStepExecutor, CachePushStepExecutor } from './steps/cache.js';
35
+ const KEYSTORE = {
36
+ targetPath: 'app/customer-android.jks',
37
+ varName: 'KEYSTORE_BASE64',
38
+ };
39
+ const PROPERTIES = {
40
+ targetPath: 'local.properties',
41
+ varName: 'LOCAL_PROPERTIES',
42
+ };
43
+ function cache(...cachePaths) {
44
+ return [{ stepName: 'cache-push', cachePaths }];
45
+ }
46
+ describe('which cache entries reach a credential', () => {
47
+ test('the checkout root reaches everything in it', () => {
48
+ for (const root of ['.', './', './/']) {
49
+ expect(cacheEntryContains(root, 'app/customer-android.jks')).toBe(true);
50
+ expect(cacheEntryContains(root, 'local.properties')).toBe(true);
51
+ }
52
+ });
53
+ test('a directory reaches what is under it, and nothing beside it', () => {
54
+ expect(cacheEntryContains('app', 'app/customer-android.jks')).toBe(true);
55
+ expect(cacheEntryContains('app/', 'app/customer-android.jks')).toBe(true);
56
+ expect(cacheEntryContains('./app', 'app/customer-android.jks')).toBe(true);
57
+ // `application/` is a different directory that merely shares a prefix.
58
+ expect(cacheEntryContains('app', 'application/customer-android.jks')).toBe(false);
59
+ expect(cacheEntryContains('.gradle', 'local.properties')).toBe(false);
60
+ });
61
+ test('an entry that is the credential itself reaches it', () => {
62
+ expect(cacheEntryContains('local.properties', 'local.properties')).toBe(true);
63
+ });
64
+ test('an absolute or home-rooted entry reaches nothing in the checkout', () => {
65
+ // `cache.ts` classifies these as absolute and stores them from the
66
+ // filesystem root, so they are somewhere the checkout is not — which is
67
+ // why every real pipeline's Gradle caches are safe.
68
+ for (const outside of ['~/.gradle/caches', '~/.gradle/wrapper', '/var/tmp/x']) {
69
+ expect(cacheEntryContains(outside, 'app/customer-android.jks')).toBe(false);
70
+ expect(cacheEntryContains(outside, 'local.properties')).toBe(false);
71
+ }
72
+ });
73
+ });
74
+ describe('the credentials a workflow would archive', () => {
75
+ test('are none for the paths every real pipeline caches', () => {
76
+ expect(credentialsSwallowedByCache([KEYSTORE, PROPERTIES], cache('~/.gradle/caches', '~/.gradle/wrapper', '.gradle'))).toEqual([]);
77
+ });
78
+ test('are every one of them when the entry is the checkout root', () => {
79
+ const found = credentialsSwallowedByCache([KEYSTORE, PROPERTIES], cache('.'));
80
+ expect(found.map((f) => f.varName)).toEqual(['KEYSTORE_BASE64', 'LOCAL_PROPERTIES']);
81
+ expect(found[0]).toEqual({
82
+ stepName: 'cache-push',
83
+ cachePath: '.',
84
+ targetPath: 'app/customer-android.jks',
85
+ varName: 'KEYSTORE_BASE64',
86
+ });
87
+ });
88
+ test('are only the ones the entry actually reaches', () => {
89
+ const found = credentialsSwallowedByCache([KEYSTORE, PROPERTIES], cache('app'));
90
+ expect(found.map((f) => f.varName)).toEqual(['KEYSTORE_BASE64']);
91
+ });
92
+ test('are found on a pull step as readily as a push step', () => {
93
+ // `cache-pull` names the same paths, and a pipeline that pulls a directory
94
+ // it also pushes is the normal shape — so naming the step that declared it
95
+ // is what makes the message actionable.
96
+ const found = credentialsSwallowedByCache([PROPERTIES], [
97
+ { stepName: 'cache-pull', cachePaths: ['.'] },
98
+ ]);
99
+ expect(found[0].stepName).toBe('cache-pull');
100
+ });
101
+ test('are none when the workflow writes no credential at all', () => {
102
+ expect(credentialsSwallowedByCache([], cache('.'))).toEqual([]);
103
+ });
104
+ });
105
+ describe('what the refusal tells the author', () => {
106
+ test('names the workflow, the entry, the file and the secret', () => {
107
+ const [swallowed] = credentialsSwallowedByCache([KEYSTORE], cache('.'));
108
+ const message = swallowedCredentialMessage('primary', swallowed);
109
+ expect(message).toContain('primary');
110
+ expect(message).toContain('cache-push');
111
+ expect(message).toContain('cache_paths');
112
+ expect(message).toContain('app/customer-android.jks');
113
+ expect(message).toContain('KEYSTORE_BASE64');
114
+ // And why it matters, not only that it is refused.
115
+ expect(message).toContain('outlives the build');
116
+ });
117
+ });
118
+ // ─── Through the real converter ───────────────────────────────────────────
119
+ const mockConfig = {
120
+ artifactsDir: '/test/artifacts',
121
+ maxConcurrentJobs: 1,
122
+ paths: {
123
+ buildsDir: '/test/builds',
124
+ cacheDir: '/test/cache',
125
+ derivedDataDir: '/test/derived-data',
126
+ },
127
+ interpreters: {
128
+ bash: '/bin/bash',
129
+ python: 'python3',
130
+ ruby: 'ruby',
131
+ node: 'node',
132
+ },
133
+ };
134
+ let workdir;
135
+ let previousCwd;
136
+ beforeEach(() => {
137
+ clearRegistry();
138
+ registerStep('file', new FileStepExecutor());
139
+ registerStep('cache-pull', new CachePullStepExecutor());
140
+ registerStep('cache-push', new CachePushStepExecutor());
141
+ previousCwd = process.cwd();
142
+ workdir = mkdtempSync(join(tmpdir(), 'cibuild-cache-credential-'));
143
+ process.chdir(workdir);
144
+ workdir = process.cwd();
145
+ process.env.LOCAL_PROPERTIES = 'sdk.dir=/opt/android-sdk\n';
146
+ });
147
+ afterEach(() => {
148
+ process.chdir(previousCwd);
149
+ rmSync(workdir, { recursive: true, force: true });
150
+ clearRegistry();
151
+ delete process.env.LOCAL_PROPERTIES;
152
+ });
153
+ /** A workflow that writes a credential and then caches `cachePaths`. */
154
+ function pipelineCaching(cachePaths) {
155
+ return {
156
+ format_version: '1',
157
+ app: { envs: [{ LOCAL_PROPERTIES: '' }] },
158
+ workflows: {
159
+ primary: {
160
+ steps: [
161
+ {
162
+ 'file@1.0.0': {
163
+ title: 'Setup local.properties',
164
+ is_skippable: true,
165
+ inputs: { target_path: 'local.properties', var_name: 'LOCAL_PROPERTIES' },
166
+ },
167
+ },
168
+ {
169
+ 'cache-push@1.0.0': {
170
+ inputs: { cache_key: 'gradle-app', cache_paths: cachePaths },
171
+ },
172
+ },
173
+ ],
174
+ },
175
+ },
176
+ };
177
+ }
178
+ describe('converting a workflow whose cache would archive a credential', () => {
179
+ test('refuses, rather than building green and publishing the file', async () => {
180
+ await expect(convertYAMLToPipelineDef(pipelineCaching(['.']), mockConfig, 'primary')).rejects.toBeInstanceOf(YAMLConversionError);
181
+ await expect(convertYAMLToPipelineDef(pipelineCaching(['.']), mockConfig, 'primary')).rejects.toThrow(/local\.properties/);
182
+ });
183
+ test('refuses when the cache step is written before the file step', async () => {
184
+ // Order cannot be the test: an author is free to pull a cache first, and
185
+ // the cache still ends up holding whatever the later step wrote.
186
+ const reordered = {
187
+ format_version: '1',
188
+ app: { envs: [{ LOCAL_PROPERTIES: '' }] },
189
+ workflows: {
190
+ primary: {
191
+ steps: [
192
+ { 'cache-pull@1.0.0': { inputs: { cache_key: 'k', cache_paths: ['.'] } } },
193
+ {
194
+ 'file@1.0.0': {
195
+ is_skippable: true,
196
+ inputs: { target_path: 'local.properties', var_name: 'LOCAL_PROPERTIES' },
197
+ },
198
+ },
199
+ ],
200
+ },
201
+ },
202
+ };
203
+ await expect(convertYAMLToPipelineDef(reordered, mockConfig, 'primary')).rejects.toThrow(/LOCAL_PROPERTIES/);
204
+ });
205
+ test('converts the paths every real pipeline caches, untouched', async () => {
206
+ const result = await convertYAMLToPipelineDef(pipelineCaching(['~/.gradle/caches', '~/.gradle/wrapper', '.gradle']), mockConfig, 'primary');
207
+ expect(result.pipeline.steps).toHaveLength(2);
208
+ expect(result.warnings).toEqual([]);
209
+ });
210
+ test('leaves a workflow with no credential alone, whatever it caches', async () => {
211
+ const noCredential = {
212
+ format_version: '1',
213
+ workflows: {
214
+ primary: {
215
+ steps: [{ 'cache-push@1.0.0': { inputs: { cache_key: 'k', cache_paths: ['.'] } } }],
216
+ },
217
+ },
218
+ };
219
+ const result = await convertYAMLToPipelineDef(noCredential, mockConfig, 'primary');
220
+ expect(result.pipeline.steps).toHaveLength(1);
221
+ });
222
+ });
223
+ //# sourceMappingURL=a-cache-path-must-not-swallow-a-credential.test.js.map
@@ -0,0 +1,37 @@
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
+ export {};
37
+ //# sourceMappingURL=a-declared-slot-may-name-the-workflow-it-belongs-to.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"a-declared-slot-may-name-the-workflow-it-belongs-to.test.d.ts","sourceRoot":"","sources":["../../../src/yaml/a-declared-slot-may-name-the-workflow-it-belongs-to.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG"}
@@ -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"}