laneyard 0.7.0 → 0.9.0

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.
@@ -514,6 +514,17 @@ function platformNote(platforms) {
514
514
  * here, answered there, and no file in the repository is touched either way.
515
515
  */
516
516
  const SET_PROPERTIES_PATH = "Set the properties file path on the keystore block, relative to the app.";
517
+ /**
518
+ * Do the configured path and the one the build reads point at the same file?
519
+ *
520
+ * Compared as text after normalising, which is all that can be done here: the
521
+ * clone is not this function's to stat, and `./android/key.properties` and
522
+ * `android/key.properties` are the same answer typed twice.
523
+ */
524
+ function samePath(configured, readAt) {
525
+ const clean = (p) => p.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+/g, "/").replace(/\/$/, "");
526
+ return clean(configured) === clean(readAt);
527
+ }
517
528
  /**
518
529
  * Where the build resolves the properties file, as a clause a sentence can take.
519
530
  *
@@ -579,6 +590,31 @@ export function checkReleaseSigning(input) {
579
590
  if (conditionalOn) {
580
591
  const { name } = conditionalOn;
581
592
  const writes = writesAt(keystore, conditionalOn);
593
+ /**
594
+ * The configured path, pointing somewhere the build does not read.
595
+ *
596
+ * The one way this check can be wrong while every part of it is right: the
597
+ * setting wins outright in `runner/gradle-properties.ts` — it exists
598
+ * precisely because detection cannot always tell — so a path off by a
599
+ * directory is written, found by nobody, and the release build falls back
600
+ * to the debug config. That is this check's own failure mode, arrived at
601
+ * through the field meant to prevent it.
602
+ *
603
+ * Said rather than refused. The parser can be wrong about the directory too,
604
+ * and a correct path overruled by a bad reading would be a build that cannot
605
+ * run at all — worse than one that says what it is about to do.
606
+ */
607
+ const configured = keystore?.propertiesPath ?? null;
608
+ const readAt = input.conditionalFileAt ?? null;
609
+ if (configured !== null && readAt !== null && !samePath(configured, readAt)) {
610
+ return {
611
+ ...META.releaseSigning,
612
+ ...warn(`the keystore block names ${configured}, and the build reads ${name}${where(conditionalOn)} — ` +
613
+ `at ${readAt}. Laneyard writes the file where the block says, so the build finds none and ` +
614
+ "signs with the debug key: it does not fail, and the store rejects the artifact.", `Set the properties file path to ${readAt}, or clear it and Laneyard writes it where the ` +
615
+ "build reads it.", "signing"),
616
+ };
617
+ }
582
618
  // What Laneyard will do, in the words of the file the build already asks
583
619
  // for. The keys are named rather than assumed silently: they are a
584
620
  // convention — the Flutter documentation's — and the one thing between a
@@ -765,6 +801,7 @@ export const SECTIONS = [
765
801
  run: (i) => checkReleaseSigning({
766
802
  gradle: i.androidSigning,
767
803
  conditionalFilePresent: i.signingFilePresent,
804
+ conditionalFileAt: i.signingFileAt ?? null,
768
805
  keystore: i.keystore ?? null,
769
806
  }),
770
807
  },
package/dist/src/main.js CHANGED
@@ -14,6 +14,7 @@ import { runUserCommand } from "./cli/user.js";
14
14
  import { ConfigStore } from "./config/store.js";
15
15
  import { CacheStore } from "./db/cache.js";
16
16
  import { openDatabase } from "./db/open.js";
17
+ import { migrateGlobalScope } from "./db/migrate-global-scope.js";
17
18
  import { RunStore } from "./db/runs.js";
18
19
  import { CredentialStore } from "./db/credentials.js";
19
20
  import { SecretStore } from "./db/secrets.js";
@@ -51,6 +52,10 @@ export async function createServerFromConfig(root) {
51
52
  if (!loaded.ok)
52
53
  throw new Error(`Unreadable configuration: ${loaded.error}`);
53
54
  const db = openDatabase(join(root, "laneyard.db"));
55
+ // Before anything reads the vault: the two scopes are gone, and every query
56
+ // below names a project. Reported rather than returned quietly — see
57
+ // `migrate-global-scope.ts` for why a user has to be told.
58
+ const migration = migrateGlobalScope(db, config.projects().map((p) => p.slug));
54
59
  // No run that had begun can survive the shutdown of the process that carried
55
60
  // it. Queued runs never began, so they stay queued for the next start.
56
61
  new RunStore(db).interruptInFlight();
@@ -76,12 +81,35 @@ export async function createServerFromConfig(root) {
76
81
  // this, `wake()` would only ever be called from the trigger route, and three
77
82
  // runs queued before a restart would wait for someone to trigger a fourth.
78
83
  app.queue.wake();
79
- return { app, db, config };
84
+ return { app, db, config, migration };
85
+ }
86
+ /**
87
+ * The upgrade notice for a vault that had a global scope.
88
+ *
89
+ * Empty for every start but the one that migrates, so it costs nothing to leave
90
+ * in. It matters on that one start: someone who stored a signing key once now
91
+ * has a copy per project, and rotating it means replacing each of them. Finding
92
+ * that out from a build that uploaded with a key they thought they had replaced
93
+ * would be far worse than a paragraph on a terminal.
94
+ */
95
+ function migrationNotice(migration) {
96
+ const { copied, dropped } = migration;
97
+ if (copied.length === 0 && dropped.length === 0)
98
+ return "";
99
+ const lines = [
100
+ ...copied.map(({ what, name, slugs }) => ` ${bold(name)} — ${what}, now in ${slugs.join(", ")}`),
101
+ ...dropped.map(({ what, name }) => ` ${bold(name)} — ${what}, ${dim("removed: no project read it")}`),
102
+ ];
103
+ return ("\n" +
104
+ dim(" A secret and a signing block now belong to one project. What was shared\n") +
105
+ dim(" has been copied into each project that read it — delete any you do not want.\n\n") +
106
+ lines.join("\n") +
107
+ "\n");
80
108
  }
81
109
  /** Real startup, outside tests. */
82
110
  async function main() {
83
111
  const root = process.env["LANEYARD_HOME"] ?? join(homedir(), ".laneyard");
84
- const { app, config } = await createServerFromConfig(root);
112
+ const { app, config, migration } = await createServerFromConfig(root);
85
113
  config.watch((ok) => {
86
114
  if (!ok)
87
115
  console.error(`Invalid configuration, the previous one stays active: ${config.lastError()}`);
@@ -105,6 +133,7 @@ async function main() {
105
133
  dim(" No project yet. From a folder that already uses fastlane:\n") +
106
134
  ` ${bold("laneyard setup")}\n`
107
135
  : "") +
136
+ migrationNotice(migration) +
108
137
  "\n");
109
138
  }
110
139
  /**
@@ -0,0 +1,144 @@
1
+ /**
2
+ * The environment file a build reads from disk.
3
+ *
4
+ * A project's own `.env` — the one its app reads, not the one fastlane reads —
5
+ * is gitignored, and Laneyard builds from a clone, so it is never there. The
6
+ * vault does not answer that on its own: a stored secret becomes an environment
7
+ * variable of the run, which is enough for fastlane and enough for a Fastfile
8
+ * that forwards values itself, and no use at all to anything that reads a
9
+ * *file*. `flutter_dotenv` bundles `.env` as an asset,
10
+ * `--dart-define-from-file=config.json` reads a path at compile time, an
11
+ * `.xcconfig` is a file by definition. None of them looks at the environment,
12
+ * and none of them fails loudly — they produce an app configured with nothing.
13
+ *
14
+ * So the variables a project ticks are rendered into a file at the path its
15
+ * `laneyard.yml` names, for the length of one run, and removed afterwards. The
16
+ * ticking decides membership of this file and nothing else: a ticked secret
17
+ * still reaches the run as an environment variable like every other one.
18
+ */
19
+ import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
20
+ import { dirname, join } from "node:path";
21
+ import { LANEYARD_MARKER } from "./gradle-properties.js";
22
+ /**
23
+ * Whether a value can be written bare.
24
+ *
25
+ * Anything outside this set is quoted. The set is deliberately narrow — a bare
26
+ * line is a convenience, and being wrong about it costs a value silently read
27
+ * back short. `#` starts a comment, whitespace at either end is trimmed by most
28
+ * readers, and a quote or backslash is an escape somebody has to interpret.
29
+ */
30
+ const BARE = /^[A-Za-z0-9_./:@+-]*$/;
31
+ /**
32
+ * Escapes a value for a double-quoted dotenv line.
33
+ *
34
+ * Backslash first, or every escape this function then writes would be escaped
35
+ * again by its own output. Newlines become `\n` rather than being written
36
+ * literally: a line break inside a value ends the line, and the parser reads a
37
+ * truncated value and a second variable that does not exist. A private key
38
+ * pasted into a variable is where that happens for real.
39
+ */
40
+ function escape(value) {
41
+ return value
42
+ .replace(/\\/g, "\\\\")
43
+ .replace(/"/g, '\\"')
44
+ .replace(/\r/g, "\\r")
45
+ .replace(/\n/g, "\\n");
46
+ }
47
+ /**
48
+ * Renders variables as dotenv text.
49
+ *
50
+ * Sorted by name, so a diff between two runs shows what changed rather than
51
+ * what moved. Pure, and tested against a parser rather than against itself:
52
+ * the property worth having is "a reader gets back what went in", and a test
53
+ * written against this function's own idea of the format would pass however
54
+ * wrong that idea was.
55
+ */
56
+ export function renderDotenv(values) {
57
+ return Object.keys(values)
58
+ .sort()
59
+ .map((key) => dotenvLine(key, values[key]))
60
+ .join("");
61
+ }
62
+ /**
63
+ * One `KEY=value` line, quoted if the value needs it.
64
+ *
65
+ * Exported for the preview the secrets screen shows, which renders a stand-in
66
+ * for every masked value rather than the value. It has to compose its own lines
67
+ * — a row of dots is not ASCII, so it would come back quoted, and the quotes
68
+ * would be a claim about the real value that nobody can check. It still goes
69
+ * through this function for everything it does show, so the preview and the
70
+ * file cannot drift apart in how they write a line.
71
+ */
72
+ export function dotenvLine(key, value) {
73
+ return `${key}=${BARE.test(value) ? value : `"${escape(value)}"`}\n`;
74
+ }
75
+ /** The first line of a file, or null when there is no file to read. */
76
+ async function firstLine(path) {
77
+ const text = await readFile(path, "utf8").catch(() => null);
78
+ return text === null ? null : (text.split("\n")[0] ?? "");
79
+ }
80
+ /** Whether this file is one Laneyard wrote, and may therefore replace or remove. */
81
+ async function ours(path) {
82
+ const line = await firstLine(path);
83
+ return line === null || line.trimEnd() === LANEYARD_MARKER;
84
+ }
85
+ /**
86
+ * Writes the environment file, and returns its path — or null, having written
87
+ * nothing.
88
+ *
89
+ * Null has two causes and neither is an error: the project names no `env_file`,
90
+ * or a file is already there that Laneyard did not write. That second one is
91
+ * the important guard. The path points into a clone the user can work in by
92
+ * hand, and a `.env` they put there themselves is their build's real
93
+ * configuration — overwriting it would replace something that works with
94
+ * something assembled from a checklist. It is left exactly as it is.
95
+ *
96
+ * The marker is the first line, so the file says what it is to anyone who opens
97
+ * it, and so this function can tell its own output from a person's on the next
98
+ * run. `#` is a comment in dotenv, which is the reason dotenv is the only
99
+ * format for now: JSON has nowhere to put a line like this.
100
+ */
101
+ export async function writeEnvFile(appRoot, envFile, values) {
102
+ if (envFile === undefined)
103
+ return null;
104
+ const path = join(appRoot, envFile);
105
+ if (!(await ours(path)))
106
+ return null;
107
+ await mkdir(dirname(path), { recursive: true });
108
+ // The mode is set twice, as in `materialise.ts` and `gradle-properties.ts`:
109
+ // `writeFile`'s mode is masked by the process umask, and ignored outright for
110
+ // a file that already exists. This one holds whatever the vault held.
111
+ await writeFile(path, `${LANEYARD_MARKER}\n${renderDotenv(values)}`, { mode: 0o600 });
112
+ await chmod(path, 0o600);
113
+ return path;
114
+ }
115
+ /**
116
+ * Removes a file this run wrote, and only such a file.
117
+ *
118
+ * Called from the run's `finally`, so the values do not outlive the build that
119
+ * needed them. Takes null so the caller can hand it whatever `writeEnvFile`
120
+ * returned without a branch of its own.
121
+ */
122
+ export async function removeEnvFile(path) {
123
+ if (path === null)
124
+ return;
125
+ if ((await firstLine(path))?.trimEnd() !== LANEYARD_MARKER)
126
+ return;
127
+ await rm(path, { force: true }).catch(() => { });
128
+ }
129
+ /**
130
+ * Removes a marked file left where this run would write one.
131
+ *
132
+ * A run killed between writing the file and reaching its `finally` — an
133
+ * unplugged server, a `kill -9` — leaves values in a working tree that is kept
134
+ * between runs. Nothing at that moment can clean up after itself, so the next
135
+ * run does it first.
136
+ *
137
+ * Silent about everything: a workspace that was never cloned has nothing to
138
+ * sweep, and a sweep that failed must not be the reason a build did not start.
139
+ */
140
+ export async function sweepEnvFile(appRoot, envFile) {
141
+ if (envFile === undefined)
142
+ return;
143
+ await removeEnvFile(join(appRoot, envFile)).catch(() => { });
144
+ }
@@ -7,6 +7,7 @@ import { Redactor, scrub } from "../logs/redact.js";
7
7
  import { assertFastlaneDir } from "../sidecar/fastlane-dir.js";
8
8
  import { collectArtifacts } from "./artifacts.js";
9
9
  import { removeGradleProperties, sweepGradleProperties, writeGradleProperties, } from "./gradle-properties.js";
10
+ import { removeEnvFile, sweepEnvFile, writeEnvFile } from "./env-file.js";
10
11
  import { LiveStepTracker } from "./live-steps.js";
11
12
  import { startPty } from "./pty.js";
12
13
  import { readReport } from "./report.js";
@@ -31,13 +32,14 @@ import { readReport } from "./report.js";
31
32
  * inside the run and has to be visible to a `finally` wrapped around all of it.
32
33
  */
33
34
  export async function executeRun(opts) {
34
- const wrote = { properties: null };
35
+ const wrote = { properties: null, envFile: null };
35
36
  try {
36
37
  return await execute(opts, wrote);
37
38
  }
38
39
  finally {
39
40
  // Guarded by the marker, so a file the user replaced mid-run survives.
40
41
  await removeGradleProperties(wrote.properties).catch(() => { });
42
+ await removeEnvFile(wrote.envFile).catch(() => { });
41
43
  // Deliberately swallowed: by now the log writer is closed and the run's
42
44
  // verdict is recorded, so there is nowhere left to report this without
43
45
  // rewriting a finished run's outcome as a failure it did not have. A
@@ -139,6 +141,7 @@ async function execute(opts, wrote) {
139
141
  return fail(cause.message);
140
142
  }
141
143
  await sweepGradleProperties(appRoot, opts.androidKeystore).catch(() => { });
144
+ await sweepEnvFile(appRoot, settings.env_file).catch(() => { });
142
145
  try {
143
146
  wrote.properties = await writeGradleProperties(appRoot, opts.androidKeystore);
144
147
  }
@@ -148,6 +151,16 @@ async function execute(opts, wrote) {
148
151
  // succeeds, signed with the debug key, rejected by the store days later.
149
152
  return fail(`Could not write the signing properties file: ${cause.message}`);
150
153
  }
154
+ try {
155
+ wrote.envFile = await writeEnvFile(appRoot, settings.env_file, opts.envFileValues ?? {});
156
+ }
157
+ catch (cause) {
158
+ // Failing for the same reason, and it is the same shape of failure. A build
159
+ // that carries on without the file it was configured to read does not stop:
160
+ // it produces an app pointed at nothing, which is discovered by a person
161
+ // opening it rather than by this run.
162
+ return fail(`Could not write the environment file: ${cause.message}`);
163
+ }
151
164
  // Cancelled during preparation: fastlane never gets to start.
152
165
  if (opts.signal?.aborted) {
153
166
  return finishAs("cancelled", "Cancelled");
@@ -21,8 +21,8 @@ export class Vault {
21
21
  static async open(home, store, credentials) {
22
22
  return new Vault(await loadOrCreateKey(home), store, credentials);
23
23
  }
24
- async set(projectSlug, key, value, masked) {
25
- this.store.set(projectSlug, key, encrypt(value, this.key), masked);
24
+ async set(projectSlug, key, value, masked, inEnvFile = false) {
25
+ this.store.set(projectSlug, key, encrypt(value, this.key), masked, inEnvFile);
26
26
  }
27
27
  remove(projectSlug, key) {
28
28
  return this.store.remove(projectSlug, key);
@@ -66,24 +66,6 @@ export class Vault {
66
66
  }
67
67
  });
68
68
  }
69
- listGlobal() {
70
- return this.store.listGlobal();
71
- }
72
- /**
73
- * What the vault holds under one project's own name — secrets and signing
74
- * blocks — and nothing global.
75
- *
76
- * Its own method rather than a filter over `list`, because the question is a
77
- * different one: `list` is "what would a run of this project see", and this is
78
- * "what would be left behind if the project went away". A global secret is in
79
- * the first answer and must never be in the second.
80
- */
81
- ownedBy(projectSlug) {
82
- return {
83
- secrets: this.store.listOwn(projectSlug),
84
- credentials: this.credentials.listOwn(projectSlug),
85
- };
86
- }
87
69
  /**
88
70
  * Forgets everything stored under one project's name.
89
71
  *
@@ -92,13 +74,15 @@ export class Vault {
92
74
  * covers all of it. What it forgets is Laneyard's own encrypted copy — the
93
75
  * `.p8` and the keystore that went in are still wherever the user keeps them.
94
76
  *
95
- * Scoped by slug, so a global secret or a global signing block survives it:
96
- * those are read by every project and are never one project's to remove.
77
+ * There is nothing it has to leave behind. Everything a project sees is a row
78
+ * under its own slug, so "what would a run see" and "what would be left if the
79
+ * project went away" are now the same question — which is most of the point of
80
+ * having one scope.
97
81
  */
98
82
  forget(projectSlug) {
99
83
  return {
100
- secrets: this.store.removeAllOwn(projectSlug),
101
- credentials: this.credentials.removeAllOwn(projectSlug),
84
+ secrets: this.store.removeAll(projectSlug),
85
+ credentials: this.credentials.removeAll(projectSlug),
102
86
  };
103
87
  }
104
88
  /**
@@ -111,7 +95,7 @@ export class Vault {
111
95
  return scrub(text, this.maskedValues(projectSlug));
112
96
  }
113
97
  /**
114
- * Every secret that applies to a project, ready to become environment variables.
98
+ * Every secret a project holds, ready to become environment variables.
115
99
  *
116
100
  * A row that will not decrypt is skipped rather than thrown: a key that was
117
101
  * rotated or a corrupted row should cost one variable, not the whole build.
@@ -160,6 +144,23 @@ export class Vault {
160
144
  setMasked(projectSlug, key, masked) {
161
145
  return this.store.setMasked(projectSlug, key, masked);
162
146
  }
147
+ /** Flips whether a value is written into the environment file. */
148
+ setInEnvFile(projectSlug, key, inEnvFile) {
149
+ return this.store.setInEnvFile(projectSlug, key, inEnvFile);
150
+ }
151
+ /**
152
+ * The variables that go in the environment file, in the clear, ready to be
153
+ * rendered.
154
+ *
155
+ * Decrypted here rather than in the writer, so plaintext stays inside this one
156
+ * file — the property the whole module exists to make checkable by reading.
157
+ * A row that will not decrypt is skipped, the same leniency `resolve` takes:
158
+ * the build then fails on its own terms rather than on a rotated key.
159
+ */
160
+ envFileValues(projectSlug) {
161
+ const wanted = new Set(this.store.envFileKeys(projectSlug));
162
+ return Object.fromEntries(Object.entries(this.resolve(projectSlug)).filter(([key]) => wanted.has(key)));
163
+ }
163
164
  /**
164
165
  * Stores a signing block: the file, and the fields that make it usable.
165
166
  *
@@ -177,7 +178,7 @@ export class Vault {
177
178
  });
178
179
  }
179
180
  /**
180
- * One block that applies to a project, in the clear, or undefined if there is none.
181
+ * One of a project's blocks, in the clear, or undefined if there is none.
181
182
  *
182
183
  * Unlike `resolve`, an unreadable row throws. The leniency there is earned: a
183
184
  * missing variable makes fastlane stop and say which one. A block that quietly
@@ -205,8 +206,96 @@ export class Vault {
205
206
  listCredentials(projectSlug) {
206
207
  return this.credentials.list(projectSlug);
207
208
  }
208
- listGlobalCredentials() {
209
- return this.credentials.listGlobal();
209
+ /**
210
+ * The same listing, with the fields that are not secret attached.
211
+ *
212
+ * The exact trade `listWithValues` makes for secrets, made again here and for
213
+ * a reason this project learned the hard way: a block whose every field reads
214
+ * `stored` cannot be checked. An alias with a character missing, a key id off
215
+ * by a digit, and the screen says the same word it says when they are right —
216
+ * so the mistake is found by a build that fails an hour later, if it fails at
217
+ * all rather than shipping a debug-signed artifact.
218
+ *
219
+ * A field marked `secret` is never here. Those come one at a time through
220
+ * `revealCredentialField`, so opening the screen puts no password in a
221
+ * browser.
222
+ *
223
+ * An unanswered optional field is absent rather than empty: the block never
224
+ * held it, and `""` would read as an answer somebody gave. A block that will
225
+ * not decrypt keeps its summary and loses its fields — the same leniency the
226
+ * rest of this file takes, and the screen still says what file is in place.
227
+ */
228
+ listCredentialsWithFields(projectSlug) {
229
+ return this.credentials.list(projectSlug).map((summary) => {
230
+ try {
231
+ const block = this.resolveCredential(projectSlug, summary.kind);
232
+ const fields = {};
233
+ for (const field of fieldsOf(summary.kind)) {
234
+ const value = block?.fields[field.name];
235
+ if (!field.secret && value)
236
+ fields[field.name] = value;
237
+ }
238
+ return { ...summary, fields };
239
+ }
240
+ catch {
241
+ return { ...summary, fields: {} };
242
+ }
243
+ });
244
+ }
245
+ /**
246
+ * One field of one block, in the clear — a password included, on request.
247
+ *
248
+ * Deliberately as narrow as `reveal`: one named field of one named block, by
249
+ * an admin, one request at a time. That is what keeps a keystore password a
250
+ * secret while still letting somebody check the one they suspect of a typo,
251
+ * which is the whole reason this exists — a password can only be replaced by
252
+ * uploading the whole block again, and replacing a value you cannot read is
253
+ * how the wrong one gets stored twice.
254
+ *
255
+ * Null for a block that is not stored or a field it does not hold.
256
+ */
257
+ revealCredentialField(projectSlug, kind, field) {
258
+ const block = this.resolveCredential(projectSlug, kind);
259
+ return block?.fields[field] ?? null;
260
+ }
261
+ /**
262
+ * Corrects fields of a stored block, leaving its file alone.
263
+ *
264
+ * A block is still taken whole on the way in: `setCredential` is what "taken
265
+ * whole or refused" means, and it is right for a block arriving for the first
266
+ * time — a keystore with no alias is not a partial success. It was wrong for a
267
+ * block already in place. A password typed with one character missing could
268
+ * only be fixed by uploading the `.jks` again, which is asking someone to
269
+ * re-supply the one part that was never in doubt, and every re-supply is
270
+ * another chance to get the part that was in doubt wrong a second time.
271
+ *
272
+ * So: the fields given are the fields changed, and nothing else moves. An
273
+ * empty value removes an optional field, which is how an answer is taken back
274
+ * — the reader then falls back to what it would have used anyway. The caller
275
+ * decides what may be emptied; this stores what it is told.
276
+ *
277
+ * Returns false when there is no such block. Throws, through
278
+ * `resolveCredential`, on a block that will not decrypt: rewriting one of its
279
+ * fields would mean re-encrypting a file read as garbage.
280
+ */
281
+ async updateCredential(projectSlug, kind, changes) {
282
+ const block = this.resolveCredential(projectSlug, kind);
283
+ if (!block)
284
+ return false;
285
+ const fields = { ...block.fields };
286
+ for (const [name, value] of Object.entries(changes.fields ?? {})) {
287
+ if (value === "")
288
+ delete fields[name];
289
+ else
290
+ fields[name] = value;
291
+ }
292
+ await this.setCredential(projectSlug, kind, {
293
+ fileName: changes.file?.fileName ?? block.fileName,
294
+ fileBytes: changes.file?.fileBytes ?? block.fileBytes,
295
+ fields,
296
+ varNames: { ...block.varNames, ...(changes.varNames ?? {}) },
297
+ });
298
+ return true;
210
299
  }
211
300
  removeCredential(projectSlug, kind) {
212
301
  return this.credentials.remove(projectSlug, kind);
@@ -98,6 +98,10 @@ export async function buildApp(deps) {
98
98
  },
99
99
  env: process.env,
100
100
  secrets: ctx.vault.resolve(slug),
101
+ // A subset of the line above, not a second source. The tick decides which
102
+ // variables are also written into the file the build reads from disk;
103
+ // every one of them still reaches the run through the environment.
104
+ envFileValues: ctx.vault.envFileValues(slug),
101
105
  credentialEnv: credentials.env,
102
106
  androidKeystore: credentials.keystore,
103
107
  cleanup: credentials.cleanup,