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