laneyard 0.3.0 → 0.4.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.
Files changed (44) hide show
  1. package/README.md +186 -18
  2. package/dist/src/cli/detect.js +12 -3
  3. package/dist/src/cli/prompt.js +28 -3
  4. package/dist/src/cli/secret-import.js +162 -0
  5. package/dist/src/cli/secret.js +162 -1
  6. package/dist/src/cli/setup.js +44 -7
  7. package/dist/src/credentials/kinds.js +120 -0
  8. package/dist/src/db/credentials.js +75 -0
  9. package/dist/src/db/schema.sql +39 -0
  10. package/dist/src/db/secrets.js +28 -0
  11. package/dist/src/db/sessions.js +61 -0
  12. package/dist/src/git/workspace.js +32 -6
  13. package/dist/src/heuristics/android-root.js +49 -0
  14. package/dist/src/heuristics/android-signing.js +98 -0
  15. package/dist/src/heuristics/appfile.js +60 -0
  16. package/dist/src/heuristics/blocking-actions.js +2 -0
  17. package/dist/src/heuristics/env-example.js +36 -0
  18. package/dist/src/heuristics/platforms.js +69 -10
  19. package/dist/src/heuristics/readiness.js +554 -25
  20. package/dist/src/main.js +10 -1
  21. package/dist/src/runner/gradle-properties.js +187 -0
  22. package/dist/src/runner/materialise.js +92 -0
  23. package/dist/src/runner/orchestrate.js +91 -2
  24. package/dist/src/secrets/vault.js +106 -5
  25. package/dist/src/server/app.js +30 -3
  26. package/dist/src/server/auth.js +50 -10
  27. package/dist/src/server/permissions.js +2 -0
  28. package/dist/src/server/required-secrets.js +30 -0
  29. package/dist/src/server/routes/account.js +57 -0
  30. package/dist/src/server/routes/credentials.js +108 -0
  31. package/dist/src/server/routes/readiness.js +162 -6
  32. package/dist/src/server/routes/secrets.js +101 -0
  33. package/dist/src/sidecar/bridge.js +21 -1
  34. package/dist/src/sidecar/fastlane-dir.js +23 -0
  35. package/dist/src/sidecar/lanes.js +6 -0
  36. package/dist/src/sidecar/uses.js +21 -5
  37. package/dist/web/assets/{Editor-DNFBA4gv.js → Editor-DynuBC2l.js} +1 -1
  38. package/dist/web/assets/index-CpwrNE-K.css +1 -0
  39. package/dist/web/assets/index-lyZs-Y7J.js +62 -0
  40. package/dist/web/index.html +2 -2
  41. package/package.json +1 -1
  42. package/ruby/introspect.rb +122 -9
  43. package/dist/web/assets/index-De6sxx6G.css +0 -1
  44. package/dist/web/assets/index-TWRQ1cJg.js +0 -62
@@ -1,4 +1,19 @@
1
- import { findBlockingActions } from "./blocking-actions.js";
1
+ import { argsGiven, findBlockingActions } from "./blocking-actions.js";
2
+ export const READ_EVERYTHING = { imports: false, customActions: false };
3
+ /** The sentence, once, so four checks cannot drift apart on how they say it. */
4
+ function unreadReason(unread) {
5
+ if (!unread.ok)
6
+ return null;
7
+ const { imports, customActions } = unread.value;
8
+ if (imports && customActions) {
9
+ return "this Fastfile imports lanes from elsewhere and the project defines its own actions";
10
+ }
11
+ if (imports)
12
+ return "this Fastfile imports lanes from elsewhere";
13
+ if (customActions)
14
+ return "the project defines its own actions in fastlane/actions";
15
+ return null;
16
+ }
2
17
  const ok = (detail) => ({ state: "ok", detail });
3
18
  const warn = (detail, fix, fixIn) => ({
4
19
  state: "warn",
@@ -6,10 +21,14 @@ const warn = (detail, fix, fixIn) => ({
6
21
  ...(fix === undefined ? {} : { fix }),
7
22
  ...(fixIn === undefined ? {} : { fixIn }),
8
23
  });
9
- const undetermined = (detail, fix) => ({
24
+ // `fixIn` belongs here as much as on a warning: "could not tell" and "here is
25
+ // the one setting that would settle it" are not in tension, and a link is worth
26
+ // more on the line nobody knows what to do with than on the one that says so.
27
+ const undetermined = (detail, fix, fixIn) => ({
10
28
  state: "unknown",
11
29
  detail,
12
30
  ...(fix === undefined ? {} : { fix }),
31
+ ...(fixIn === undefined ? {} : { fixIn }),
13
32
  });
14
33
  /** A rejection is not always an `Error`; a checklist saying "undefined" is worse than useless. */
15
34
  const reasonOf = (cause) => cause instanceof Error ? cause.message : String(cause);
@@ -23,6 +42,8 @@ const META = {
23
42
  androidKeystore: { id: "android-keystore", title: "keystore reachable without a prompt" },
24
43
  playStore: { id: "play-store", title: "Play Store service account" },
25
44
  platforms: { id: "platforms", title: "what this project builds for" },
45
+ environment: { id: "environment", title: "the variables the lanes read" },
46
+ releaseSigning: { id: "release-signing", title: "release signed with the release key" },
26
47
  };
27
48
  /**
28
49
  * Does the remote answer without asking anyone for credentials?
@@ -85,23 +106,180 @@ export async function checkDependencies(input) {
85
106
  }
86
107
  /** Any suffix: the key is split across several variables, and their names vary by lane. */
87
108
  const API_KEY = /^APP_STORE_CONNECT_API_KEY/;
88
- const STORE_API_KEY = "Store an App Store Connect API key — `APP_STORE_CONNECT_API_KEY_ID`, " +
89
- "`APP_STORE_CONNECT_API_KEY_ISSUER_ID` and the `.p8` contents from the secrets tab. " +
109
+ /**
110
+ * The one name on this page that never worked.
111
+ *
112
+ * `APP_STORE_CONNECT_API_KEY_P8` appears nowhere in fastlane 2.237 — no action
113
+ * declares it, so no lane can read it. Laneyard's own screen invented it and
114
+ * asked people to store their `.p8` under it, and `API_KEY` above prefix-matches
115
+ * it, so the result was a green tick for a value that reached nothing.
116
+ *
117
+ * Excluded rather than merely un-ticked: the value is still in the vault, and
118
+ * someone who put it there is entitled to be told it does nothing instead of
119
+ * being left with a checklist that has quietly gone silent about it.
120
+ */
121
+ const DEAD_API_KEY = /^APP_STORE_CONNECT_API_KEY_P8$/;
122
+ const STORE_API_KEY = "Upload the `.p8` as an App Store Connect key block from the signing tab, with its key id and " +
123
+ "issuer id. Laneyard writes the file for the length of a run and exports the three of them under " +
124
+ "the names the block carries, so nothing in the project has to change. " +
90
125
  "An API key does not expire on its own.";
91
- export function checkAppStoreConnect(secretKeys) {
92
- if (secretKeys.some((key) => API_KEY.test(key))) {
126
+ /** The action whose whole job is to load a `.p8`, under both of its names. */
127
+ const ASC_KEY_ACTIONS = new Set(["app_store_connect_api_key", "asc_api_key"]);
128
+ /**
129
+ * The actions that sign in to Apple, and therefore the ones that decide whether
130
+ * this project needs a key at all.
131
+ *
132
+ * Deliberately wider than "uploads": `match` fetches from a certificates
133
+ * repository but authenticates with App Store Connect to do it, and
134
+ * `latest_testflight_build_number` is the version-numbering call half the
135
+ * Fastfiles in existence make before they build anything. A lane that only
136
+ * builds, tests or screenshots is in none of them, and that lane's project is
137
+ * told it needs nothing — which is the whole point of asking.
138
+ */
139
+ const APPLE_AUTH_ACTIONS = new Set([
140
+ ...ASC_KEY_ACTIONS,
141
+ "upload_to_testflight",
142
+ "pilot",
143
+ "upload_to_app_store",
144
+ "deliver",
145
+ "latest_testflight_build_number",
146
+ "app_store_build_number",
147
+ "download_dsyms",
148
+ "precheck",
149
+ "check_app_store_metadata",
150
+ "produce",
151
+ "create_app_online",
152
+ "register_devices",
153
+ "match",
154
+ "sync_code_signing",
155
+ "get_certificates",
156
+ "cert",
157
+ "get_provisioning_profile",
158
+ "sigh",
159
+ ]);
160
+ /**
161
+ * Arguments that name the key somewhere other than the vault.
162
+ *
163
+ * `key_filepath` and `api_key_path` are paths to a `.p8`; `key_content` and
164
+ * `api_key` are the thing itself, or a hash built earlier in the lane.
165
+ */
166
+ const ASC_KEY_ARGS = ["key_filepath", "key_content", "api_key_path", "api_key", "key_id"];
167
+ /**
168
+ * Is there an App Store Connect credential, wherever it lives?
169
+ *
170
+ * Four places, and the vault is only the first. A project that configured
171
+ * fastlane years before it met Laneyard has its key in the Fastfile or beside
172
+ * it, and telling that project it has "no App Store Connect credential" is
173
+ * false — the run works. So the Fastfile, the repository and the Appfile are
174
+ * all read before anything is claimed.
175
+ *
176
+ * Only the vault earns a tick. Everything else earns `unknown`, and the reason
177
+ * is the same every time: a path in a Fastfile, or a `.p8` in a repository, says
178
+ * a credential was arranged — it does not say the file is on *this* machine,
179
+ * with the passphrase and permissions a run needs. A green tick that means "it
180
+ * looks arranged" is the tick nobody can trust afterwards.
181
+ *
182
+ * The vault holds two routes and both are answers: a block, which Laneyard
183
+ * writes to disk for the run, and the loose variables a project stored before
184
+ * blocks existed. Neither is preferred over the other here — a recommendation is
185
+ * only made when there is nothing at all.
186
+ */
187
+ export function checkAppStoreConnect(input) {
188
+ const { secretKeys, uses, keyFilesInRepo, appfile } = input;
189
+ const blocks = input.blocks ?? [];
190
+ if (blocks.includes("apple_asc")) {
191
+ return {
192
+ ...META.appStoreConnect,
193
+ ...ok("an App Store Connect key block is in the vault: the `.p8` is written for the length of a " +
194
+ "run, with its key id and issuer id exported beside it."),
195
+ };
196
+ }
197
+ if (secretKeys.some((key) => API_KEY.test(key) && !DEAD_API_KEY.test(key))) {
93
198
  return { ...META.appStoreConnect, ...ok("an App Store Connect API key is in the vault.") };
94
199
  }
200
+ // The one place on this checklist where somebody is asked to redo work — and
201
+ // it is work that never did anything. See `DEAD_API_KEY`.
202
+ if (secretKeys.some((key) => DEAD_API_KEY.test(key))) {
203
+ return {
204
+ ...META.appStoreConnect,
205
+ ...warn("`APP_STORE_CONNECT_API_KEY_P8` is in the vault, and no action in fastlane reads a variable " +
206
+ "of that name: the value is stored, and no lane can see it. An earlier version of this " +
207
+ "interface asked for it.", STORE_API_KEY, "signing"),
208
+ };
209
+ }
210
+ // Named in a lane: the commonest way a pre-existing project holds its key,
211
+ // and the one the vault-only check used to report as nothing at all.
212
+ const inLanes = uses.ok
213
+ ? uses.value.filter((lane) => lane.actions.some((a) => ASC_KEY_ACTIONS.has(a.name) ||
214
+ ASC_KEY_ARGS.some((arg) => argsGiven(a).includes(arg))))
215
+ : [];
216
+ if (inLanes.length > 0) {
217
+ return {
218
+ ...META.appStoreConnect,
219
+ ...undetermined(`${nameList(inLanes.map((l) => l.lane))} supplies its own API key: whether the \`.p8\` it ` +
220
+ "names is on this machine is not something a Fastfile says.", STORE_API_KEY),
221
+ };
222
+ }
223
+ // Nothing in the vault, and nothing named in a lane. Before reporting an
224
+ // absence, ask whether the absence matters here: a key is wanted by a lane
225
+ // that signs in to Apple, not by a project that happens to build for iOS.
226
+ if (!uses.ok) {
227
+ return { ...META.appStoreConnect, ...undetermined(`could not read the lanes: ${uses.reason}`) };
228
+ }
229
+ // Read once: three of the outcomes below turn on the same blindness.
230
+ const blind = unreadReason(input.unread ?? { ok: true, value: READ_EVERYTHING });
231
+ const signsIn = uses.value.some((lane) => lane.actions.some((action) => APPLE_AUTH_ACTIONS.has(action.name)));
232
+ if (!signsIn) {
233
+ if (blind) {
234
+ return {
235
+ ...META.appStoreConnect,
236
+ ...undetermined(`no lane seen to sign in to App Store Connect — but ${blind}.`, STORE_API_KEY),
237
+ };
238
+ }
239
+ return {
240
+ ...META.appStoreConnect,
241
+ ...ok("no lane signs in to App Store Connect: nothing here needs a key."),
242
+ };
243
+ }
244
+ const p8 = keyFilesInRepo.ok ? keyFilesInRepo.value : [];
245
+ if (p8.length > 0) {
246
+ return {
247
+ ...META.appStoreConnect,
248
+ ...undetermined(`the repository carries ${nameList(p8)}: a key is arranged, but nothing here says a lane ` +
249
+ "loads it, or that it is the one App Store Connect expects.", STORE_API_KEY),
250
+ };
251
+ }
95
252
  if (secretKeys.includes("FASTLANE_SESSION")) {
96
253
  return {
97
254
  ...META.appStoreConnect,
98
255
  ...warn("only a FASTLANE_SESSION: Apple sessions expire, and the run that finds it expired " +
99
- "stops and asks for a verification code.", STORE_API_KEY, "secrets"),
256
+ "stops and asks for a verification code.", STORE_API_KEY, "signing"),
257
+ };
258
+ }
259
+ // An Apple ID in the Appfile is not a credential — it is the thing that makes
260
+ // a run stop and ask for a verification code — but saying so is more use than
261
+ // "no credential", which reads as though the Appfile had not been looked at.
262
+ if (appfile.ok && appfile.value.appleId.kind !== "absent") {
263
+ return {
264
+ ...META.appStoreConnect,
265
+ ...warn("the Appfile names an Apple ID and nothing else: a lane that uploads signs in as that " +
266
+ "account, and two-factor authentication stops the run to ask for a code.", STORE_API_KEY, "signing"),
267
+ };
268
+ }
269
+ // This one is a warning rather than a tick, so being wrong costs less — but
270
+ // "in the lanes" is a claim about lanes that were read, and saying it about
271
+ // lanes that could not be is still saying something untrue.
272
+ if (blind) {
273
+ return {
274
+ ...META.appStoreConnect,
275
+ ...undetermined(`no App Store Connect credential in the vault or in the repository, and none seen in the ` +
276
+ `lanes — but ${blind}.`, STORE_API_KEY),
100
277
  };
101
278
  }
102
279
  return {
103
280
  ...META.appStoreConnect,
104
- ...warn("no App Store Connect credential in the vault: a lane that uploads will ask for an Apple ID.", STORE_API_KEY, "secrets"),
281
+ ...warn("no App Store Connect credential in the vault, in the lanes, or in the repository: " +
282
+ "a lane that uploads will ask for an Apple ID.", STORE_API_KEY, "signing"),
105
283
  };
106
284
  }
107
285
  /** The two names for the same action. `match` is the older one, still the common one. */
@@ -149,12 +327,20 @@ export function checkMatch(uses, secretKeys) {
149
327
  }
150
328
  return { ...META.match, ...ok("MATCH_PASSWORD is stored, and every match call is readonly.") };
151
329
  }
152
- export function checkBlockingActions(uses) {
330
+ export function checkBlockingActions(uses, unread = { ok: true, value: READ_EVERYTHING }) {
153
331
  if (!uses.ok) {
154
332
  return { ...META.blockingActions, ...undetermined(`could not read the lanes: ${uses.reason}`) };
155
333
  }
156
334
  const findings = uses.value.flatMap((lane) => findBlockingActions(lane.actions).map((finding) => ({ lane: lane.lane, finding })));
157
335
  if (findings.length === 0) {
336
+ const blind = unreadReason(unread);
337
+ if (blind) {
338
+ return {
339
+ ...META.blockingActions,
340
+ ...undetermined(`no lane seen to call an action known to stop and ask — but ${blind}, so a lane that ` +
341
+ "waits could be out of sight from here."),
342
+ };
343
+ }
158
344
  return { ...META.blockingActions, ...ok("no lane calls an action known to stop and ask.") };
159
345
  }
160
346
  return {
@@ -176,17 +362,55 @@ const GRADLE_ACTIONS = new Set(["gradle", "build_android_app"]);
176
362
  const KEYSTORE_ARGS = ["storeFile", "storePassword"];
177
363
  /** `ANDROID_KEYSTORE_PASSWORD`, `KEYSTORE_PASSWORD`, `STORE_PASSWORD`. */
178
364
  const KEYSTORE_PASSWORD = /(^|_)(KEYSTORE|STORE)_PASSWORD$/;
179
- const STORE_KEYSTORE_PASSWORD = "Store the keystore passphrase as `ANDROID_KEYSTORE_PASSWORD` from the secrets tab, " +
180
- "and read it in the lane with `storePassword: ENV[\"ANDROID_KEYSTORE_PASSWORD\"]`.";
181
- export function checkAndroidKeystore(uses, secretKeys) {
365
+ /**
366
+ * What to do about a keystore, said once.
367
+ *
368
+ * It used to say to store the passphrase loose and read it in the lane with
369
+ * `storePassword: ENV["ANDROID_KEYSTORE_PASSWORD"]` — which is a Fastfile edit,
370
+ * and so not Laneyard's to ask for. A block carries the file and the passphrases
371
+ * together, and a run gets both without the project knowing anything about it.
372
+ */
373
+ const STORE_KEYSTORE = "Upload the keystore as an android keystore block from the signing tab, with its alias and its " +
374
+ "two passphrases. Laneyard writes the file for the length of a run and exports the names the " +
375
+ "block carries, so no lane has to be changed to find it.";
376
+ export function checkAndroidKeystore(uses, secretKeys, unread = { ok: true, value: READ_EVERYTHING }, blocks = []) {
377
+ // Answered before the lanes are read, and on purpose: the question is whether
378
+ // anything will stop and ask for a passphrase, and a block means nothing can —
379
+ // whichever lane, and whether or not gradle is called by name.
380
+ if (blocks.includes("android_keystore")) {
381
+ return {
382
+ ...META.androidKeystore,
383
+ ...ok("an android keystore block is in the vault: the keystore is written for the length of a " +
384
+ "run and its passphrases exported with it, so nothing stops to ask for one."),
385
+ };
386
+ }
182
387
  if (!uses.ok) {
183
388
  return { ...META.androidKeystore, ...undetermined(`could not read the lanes: ${uses.reason}`) };
184
389
  }
185
390
  const calls = uses.value.flatMap((lane) => lane.actions.filter((a) => GRADLE_ACTIONS.has(a.name)).map((action) => ({ lane: lane.lane, action })));
186
391
  if (calls.length === 0) {
187
- return { ...META.androidKeystore, ...ok("no lane builds with gradle.") };
392
+ // This check only runs in the Android section, so reaching here means the
393
+ // project builds for Android and no lane was seen calling gradle. That is
394
+ // not "nothing needs a keystore" — it is a build driven by something else.
395
+ // `flutter build appbundle` and `react-native build-android` both run
396
+ // gradle underneath, and the signing configuration then lives in
397
+ // `build.gradle` or `key.properties`, where no reading of a Fastfile
398
+ // reaches it. Ticking that was the same mistake as the Play Store one: a
399
+ // confident green for a question nobody answered.
400
+ const blind = unreadReason(unread);
401
+ return {
402
+ ...META.androidKeystore,
403
+ ...undetermined("no lane seen handing gradle a keystore" +
404
+ (blind ? ` — and ${blind}` : "") +
405
+ ". This project builds for Android, so signing is configured somewhere a Fastfile does " +
406
+ "not show: `build.gradle`, `key.properties`, or a build driven through flutter or " +
407
+ "react-native.", STORE_KEYSTORE),
408
+ };
188
409
  }
189
- const signing = calls.filter((c) => KEYSTORE_ARGS.some((arg) => arg in c.action.args));
410
+ // `given`, not `args`, for the same reason as the two credential checks:
411
+ // `storePassword: ENV["KS_PASS"]` is a keystore that needs unlocking just as
412
+ // much as a literal one, and it leaves no literal behind.
413
+ const signing = calls.filter((c) => KEYSTORE_ARGS.some((arg) => argsGiven(c.action).includes(arg)));
190
414
  if (signing.length === 0) {
191
415
  // Deliberately a statement about what was read, not about the build: a
192
416
  // keystore configured in `build.gradle` or through the environment is
@@ -210,7 +434,7 @@ export function checkAndroidKeystore(uses, secretKeys) {
210
434
  return {
211
435
  ...META.androidKeystore,
212
436
  ...warn(`${nameList(withoutPassphrase.map((c) => c.lane))} hands gradle a keystore, but no keystore ` +
213
- "passphrase is in the vault: gradle asks for it and waits.", STORE_KEYSTORE_PASSWORD, "secrets"),
437
+ "passphrase is in the vault: gradle asks for it and waits.", STORE_KEYSTORE, "signing"),
214
438
  };
215
439
  }
216
440
  /** The two names for the same action. `supply` is the older one. */
@@ -219,9 +443,15 @@ const PLAY_UPLOAD_ACTIONS = new Set(["upload_to_play_store", "supply"]);
219
443
  const PLAY_JSON_KEY = /^SUPPLY_JSON_KEY/;
220
444
  /** The same credential, named in the call instead of in the vault. */
221
445
  const PLAY_KEY_ARGS = ["json_key", "json_key_data"];
222
- const STORE_PLAY_KEY = "Store the service account JSON as `SUPPLY_JSON_KEY_DATA` from the secrets tab. " +
446
+ /** Named once: three outcomes of the environment check point at the same thing. */
447
+ const DECLARE_SECRETS = "A variable read by a tool the lane shells out to — `sentry-cli` and its " +
448
+ "`SENTRY_AUTH_TOKEN`, say — is not something a Fastfile mentions. List those under " +
449
+ "`required_secrets` in laneyard.yml and they are checked like the rest.";
450
+ const STORE_PLAY_KEY = "Upload the service account JSON as a Play Store service account block from the signing tab. " +
451
+ "Laneyard writes the file for the length of a run and exports its path under the name the block " +
452
+ "carries — `SUPPLY_JSON_KEY`, which supply reads by itself, unless the block says otherwise. " +
223
453
  "A service account does not expire on its own.";
224
- export function checkPlayStore(uses, secretKeys) {
454
+ export function checkPlayStore(uses, secretKeys, appfile, unread = { ok: true, value: READ_EVERYTHING }, blocks = []) {
225
455
  if (!uses.ok) {
226
456
  return { ...META.playStore, ...undetermined(`could not read the lanes: ${uses.reason}`) };
227
457
  }
@@ -229,25 +459,75 @@ export function checkPlayStore(uses, secretKeys) {
229
459
  .filter((a) => PLAY_UPLOAD_ACTIONS.has(a.name))
230
460
  .map((action) => ({ lane: lane.lane, action })));
231
461
  if (calls.length === 0) {
462
+ // The one conclusion drawn purely from absence, and the one that was wrong
463
+ // for every project whose upload sits behind an import or a custom action.
464
+ const blind = unreadReason(unread);
465
+ if (blind) {
466
+ return {
467
+ ...META.playStore,
468
+ ...undetermined(`no lane seen to upload to the Play Store — but ${blind}, so this is not the same ` +
469
+ "as no lane uploading.", STORE_PLAY_KEY),
470
+ };
471
+ }
232
472
  return { ...META.playStore, ...ok("no lane uploads to the Play Store.") };
233
473
  }
474
+ // Either route answers: the block, or the loose variable a project stored
475
+ // before blocks existed and which fastlane still reads exactly as it did.
476
+ if (blocks.includes("play_service_account")) {
477
+ return {
478
+ ...META.playStore,
479
+ ...ok("a Play Store service account block is in the vault: the JSON is written for the length of " +
480
+ "a run and its path exported."),
481
+ };
482
+ }
234
483
  if (secretKeys.some((key) => PLAY_JSON_KEY.test(key))) {
235
484
  return { ...META.playStore, ...ok("a Play Store service account is in the vault.") };
236
485
  }
237
- const named = calls.filter((c) => PLAY_KEY_ARGS.some((arg) => arg in c.action.args));
486
+ // Named in the call, whether or not the value could be read. `json_key:
487
+ // ENV.fetch("…")` is a credential the lane supplies itself just as much as a
488
+ // literal path is — and reading only literals is what used to make this check
489
+ // and the App Store Connect one disagree about the same situation.
490
+ const named = calls.filter((c) => PLAY_KEY_ARGS.some((arg) => argsGiven(c.action).includes(arg)));
238
491
  if (named.length > 0) {
239
492
  // A path in a Fastfile says nothing about whether that file exists on this
240
493
  // machine. Neither a tick nor a warning would be honest.
241
494
  return {
242
495
  ...META.playStore,
243
- ...undetermined(`${nameList(named.map((c) => c.lane))} passes \`json_key\` in the call: whether that file is ` +
244
- "on this machine is not something a Fastfile says.", STORE_PLAY_KEY),
496
+ ...undetermined(`${nameList(named.map((c) => c.lane))} supplies its own service account: whether the file ` +
497
+ "it names is on this machine is not something a Fastfile says.", STORE_PLAY_KEY),
245
498
  };
246
499
  }
500
+ // The Appfile, which is where a project that predates Laneyard almost always
501
+ // keeps this — and which the check used to be blind to, so a project with a
502
+ // working service account was told it had none.
503
+ if (appfile.ok) {
504
+ const { jsonKeyFile, jsonKeyData } = appfile.value;
505
+ if (jsonKeyData.kind !== "absent") {
506
+ return {
507
+ ...META.playStore,
508
+ ...undetermined("the Appfile sets `json_key_data`: the credential travels with the repository, and " +
509
+ "whether it is still valid is not something a file says.", STORE_PLAY_KEY),
510
+ };
511
+ }
512
+ if (jsonKeyFile.kind === "literal") {
513
+ return {
514
+ ...META.playStore,
515
+ ...undetermined(`the Appfile points \`json_key_file\` at ${jsonKeyFile.value}: whether that file is on ` +
516
+ "this machine is not something an Appfile says.", STORE_PLAY_KEY),
517
+ };
518
+ }
519
+ if (jsonKeyFile.kind === "computed") {
520
+ return {
521
+ ...META.playStore,
522
+ ...undetermined("the Appfile sets `json_key_file` to something it computes — an environment variable, " +
523
+ "most likely — which a run only resolves once it is running.", STORE_PLAY_KEY),
524
+ };
525
+ }
526
+ }
247
527
  return {
248
528
  ...META.playStore,
249
529
  ...warn(`${nameList(calls.map((c) => c.lane))} uploads to the Play Store, but no service account is in ` +
250
- "the vault: supply stops and asks which credentials to use.", STORE_PLAY_KEY, "secrets"),
530
+ "the vault: supply stops and asks which credentials to use.", STORE_PLAY_KEY, "signing"),
251
531
  };
252
532
  }
253
533
  /**
@@ -267,6 +547,220 @@ function platformNote(platforms) {
267
547
  "and the checks for that platform appear here."),
268
548
  };
269
549
  }
550
+ /**
551
+ * The one question the block's form can settle that nothing else can. Asked
552
+ * here, answered there, and no file in the repository is touched either way.
553
+ */
554
+ const SET_PROPERTIES_PATH = "Set the properties file path on the keystore block, relative to the app, and Laneyard writes " +
555
+ "the file there for the length of a run.";
556
+ /**
557
+ * Where the build resolves the properties file, as a clause a sentence can take.
558
+ *
559
+ * A directory rather than a path: `android/` for one project and the repository
560
+ * root for another, and the parser deliberately reports the scope Gradle uses
561
+ * rather than a path it would have had to invent. An unresolved scope adds
562
+ * nothing to the sentence — naming the wrong directory sends someone to look in
563
+ * a place the build never reads, which is worse than leaving them to look.
564
+ */
565
+ function where(file) {
566
+ if (file.scope === "root")
567
+ return " in the Gradle root directory";
568
+ if (file.scope === "module")
569
+ return " in the app module directory";
570
+ return "";
571
+ }
572
+ /**
573
+ * Where Laneyard would write that file, or null when it would write nothing.
574
+ *
575
+ * The same three answers `runner/gradle-properties.ts` arrives at, in the same
576
+ * order, and that is not a coincidence to be maintained by hand: a check that
577
+ * promised a file the runner then declined to write would be the worst line on
578
+ * this page, because it would be reassuring and wrong. The configured path wins
579
+ * outright, an unresolved scope with no configured path means nothing is
580
+ * written, and everything else follows the scope the parser read.
581
+ */
582
+ function writesAt(block, file) {
583
+ if (block === null)
584
+ return null;
585
+ if (block.propertiesPath !== null && block.propertiesPath !== "") {
586
+ return ` at ${block.propertiesPath}, the path the block names`;
587
+ }
588
+ if (file.scope === "unknown")
589
+ return null;
590
+ return where(file);
591
+ }
592
+ /**
593
+ * Will a release build actually be signed with the release key?
594
+ *
595
+ * The one check here whose failure is silent. Everything else fails loudly — a
596
+ * run stops, a credential is missing, a lane waits. This one *succeeds*: the
597
+ * build finishes, produces an artifact signed with the debug key, and the error
598
+ * arrives from the store minutes later saying nothing about signing.
599
+ *
600
+ * The pattern is in the Flutter documentation, so it is everywhere: sign with
601
+ * the release config when `key.properties` exists, and with the debug config
602
+ * when it does not. The same documentation gitignores `key.properties` — so it
603
+ * is absent from every clone, including the one a build server works from.
604
+ */
605
+ export function checkReleaseSigning(input) {
606
+ const { gradle, conditionalFilePresent } = input;
607
+ const keystore = input.keystore ?? null;
608
+ if (!gradle.ok) {
609
+ return {
610
+ ...META.releaseSigning,
611
+ ...undetermined(`could not read the android build script: ${gradle.reason}`),
612
+ };
613
+ }
614
+ if (!gradle.value.releaseCanUseDebugKey) {
615
+ return {
616
+ ...META.releaseSigning,
617
+ ...ok("the release build type never takes the debug signing config."),
618
+ };
619
+ }
620
+ const { conditionalOn } = gradle.value;
621
+ if (conditionalOn) {
622
+ const { name } = conditionalOn;
623
+ const writes = writesAt(keystore, conditionalOn);
624
+ // What Laneyard will do, in the words of the file the build already asks
625
+ // for. The keys are named rather than assumed silently: they are a
626
+ // convention — the Flutter documentation's — and the one thing between a
627
+ // signed artifact and a debug-signed one that nobody read out of the script.
628
+ if (writes !== null && keystore !== null) {
629
+ const keys = nameList(keystore.propertyNames);
630
+ const supplied = `Laneyard writes ${name}${writes} for the length of a run, from the keystore block, with ` +
631
+ `the keys ${keys}. Those names come from the block's settings rather than something the ` +
632
+ "build script states — what a script reads out of that file is not written anywhere this " +
633
+ "can see — so the setting starts from the Flutter documentation's four, and a build that " +
634
+ "reads others is corrected there.";
635
+ if (!conditionalFilePresent) {
636
+ return {
637
+ ...META.releaseSigning,
638
+ ...ok(`the release build falls back to the debug signing config when ${name} is missing, and ` +
639
+ `${name} is not in the clone. ${supplied}`),
640
+ };
641
+ }
642
+ return {
643
+ ...META.releaseSigning,
644
+ ...ok(`${name} is present${where(conditionalOn)}, so the release key is used — and that file is ` +
645
+ `the project's own, which Laneyard leaves alone. Were it ever to go missing, ${supplied}`),
646
+ };
647
+ }
648
+ // A block, and nowhere to put the file: the script names it on a receiver
649
+ // the parser could not follow, so the directory is genuinely unknown. Two
650
+ // directories, one right, and writing into the wrong one would leave the
651
+ // build signing with the debug key next to a file that looked like the
652
+ // answer. The block's own form settles it — a question, not a commit.
653
+ if (keystore !== null && !conditionalFilePresent) {
654
+ return {
655
+ ...META.releaseSigning,
656
+ ...undetermined(`the release build falls back to the debug signing config when ${name} is missing, and ` +
657
+ `${name} is not in the clone. A keystore block is in the vault, but the build script ` +
658
+ `names ${name} without saying which directory it resolves against, so Laneyard will ` +
659
+ "not guess where to write it.", SET_PROPERTIES_PATH, "signing"),
660
+ };
661
+ }
662
+ if (!conditionalFilePresent) {
663
+ const looksIn = where(conditionalOn);
664
+ return {
665
+ ...META.releaseSigning,
666
+ ...warn(`the release build falls back to the debug signing config when ${name} is missing, and ` +
667
+ `${name} is not in the clone` +
668
+ (looksIn === "" ? "" : ` — the build looks for it${looksIn}`) +
669
+ ". The build will not fail: it will produce an artifact signed with the debug key, and " +
670
+ "the store will reject it.", `${name} is gitignored by the same documentation that recommends it, so it never reaches ` +
671
+ "a clone. Upload the keystore as an android keystore block from the signing tab: " +
672
+ `Laneyard then writes ${name} itself for the length of a run, and removes it ` +
673
+ "afterwards. Nothing in the build script changes.", "signing"),
674
+ };
675
+ }
676
+ return {
677
+ ...META.releaseSigning,
678
+ ...undetermined(`${conditionalOn.name} is present${where(conditionalOn)}, so the release key is ` +
679
+ "used — but the build falls back to the debug signing config if it ever goes missing, " +
680
+ "and does so without failing.", keystore === null ? STORE_KEYSTORE : SET_PROPERTIES_PATH),
681
+ };
682
+ }
683
+ return {
684
+ ...META.releaseSigning,
685
+ ...undetermined("the release build type can take the debug signing config; whether it does is decided by " +
686
+ "something this cannot read."),
687
+ };
688
+ }
689
+ /**
690
+ * Does the project have the variables its lanes need?
691
+ *
692
+ * This is the one credential question a Fastfile can actually answer. The other
693
+ * checks ask "is there a key somewhere" and end up reasoning about absence;
694
+ * this one reads `ENV.fetch("ASC_KEY_ID")` out of the lane and asks whether that
695
+ * name exists — which has a real answer.
696
+ *
697
+ * It matters most for a project that keeps its variables in `fastlane/.env`.
698
+ * That file is almost always gitignored, so it never reaches the clone a build
699
+ * runs from: everything works on the machine it was written on, nothing works
700
+ * on the build server, and nothing on screen says why. Naming the variables is
701
+ * saying why.
702
+ *
703
+ * Two things it cannot see, and one answer to both. A variable read by a tool
704
+ * the lane shells out to — `sentry-cli` wants `SENTRY_AUTH_TOKEN`, and the
705
+ * Fastfile never mentions it — is invisible to any amount of parsing. So is one
706
+ * fastlane reads for itself. `required_secrets` in laneyard.yml is where those
707
+ * are declared, and they are treated exactly like the ones that were found.
708
+ *
709
+ * A name a signing block exports counts as supplied. Laneyard writes that file
710
+ * and sets that variable itself for the length of a run, so asking someone to
711
+ * store it by hand would be asking them for the thing just uploaded.
712
+ *
713
+ * A variable present only in the server's own environment is reported rather
714
+ * than quietly ticked: it works, but it works because of how this particular
715
+ * server happened to be started, which is not something the project carries.
716
+ */
717
+ export function checkEnvironment(input) {
718
+ const { uses, secretKeys, serverEnv, declared, unread } = input;
719
+ const blockNames = input.blockNames ?? [];
720
+ if (!uses.ok) {
721
+ return { ...META.environment, ...undetermined(`could not read the lanes: ${uses.reason}`) };
722
+ }
723
+ const read = uses.value.flatMap((lane) => lane.env ?? []);
724
+ const required = [...new Set([...read, ...declared])].sort();
725
+ if (required.length === 0) {
726
+ const blind = unreadReason(unread);
727
+ if (blind) {
728
+ return {
729
+ ...META.environment,
730
+ ...undetermined(`no lane seen to read an environment variable — but ${blind}.`, DECLARE_SECRETS),
731
+ };
732
+ }
733
+ return { ...META.environment, ...ok("no lane reads an environment variable.") };
734
+ }
735
+ // A block's exported names sit with the vault keys rather than beside them: a
736
+ // signing block *is* in the vault, and a name it exports reaches the run from
737
+ // the same place, so nothing below has to distinguish the two.
738
+ const inVault = new Set([...secretKeys, ...blockNames]);
739
+ const inServer = new Set(serverEnv);
740
+ const missing = required.filter((name) => !inVault.has(name) && !inServer.has(name));
741
+ if (missing.length > 0) {
742
+ return {
743
+ ...META.environment,
744
+ ...warn(`${nameList(missing)} ${missing.length === 1 ? "is" : "are"} needed by a lane and in ` +
745
+ "neither the vault nor this server's environment: a run stops at the first one, or " +
746
+ "builds with it empty.", "Store them from the secrets tab. A `fastlane/.env` does not travel — it is almost " +
747
+ "always gitignored, so it never reaches the clone a build runs from.", "secrets"),
748
+ };
749
+ }
750
+ const borrowed = required.filter((name) => !inVault.has(name));
751
+ if (borrowed.length > 0) {
752
+ return {
753
+ ...META.environment,
754
+ ...ok(`every variable the lanes need is available — though ${nameList(borrowed)} ` +
755
+ `${borrowed.length === 1 ? "comes" : "come"} from this server's own environment rather ` +
756
+ `than the vault, so another machine would not find ${borrowed.length === 1 ? "it" : "them"}.`),
757
+ };
758
+ }
759
+ return {
760
+ ...META.environment,
761
+ ...ok(`every variable the lanes need is in the vault: ${nameList(required)}.`),
762
+ };
763
+ }
270
764
  /**
271
765
  * The checklist itself: a table, so that adding a check is adding a row.
272
766
  *
@@ -279,21 +773,56 @@ export const SECTIONS = [
279
773
  checks: [
280
774
  { ...META.repository, run: (i) => checkRepository(i.probeRepository) },
281
775
  { ...META.dependencies, run: (i) => checkDependencies(i.dependencies) },
282
- { ...META.blockingActions, run: (i) => checkBlockingActions(i.uses) },
776
+ { ...META.blockingActions, run: (i) => checkBlockingActions(i.uses, i.unread) },
777
+ {
778
+ ...META.environment,
779
+ run: (i) => checkEnvironment({
780
+ uses: i.uses,
781
+ secretKeys: i.secretKeys,
782
+ serverEnv: i.serverEnv,
783
+ blockNames: i.blockNames ?? [],
784
+ declared: i.declaredSecrets,
785
+ unread: i.unread,
786
+ }),
787
+ },
283
788
  ],
284
789
  },
285
790
  {
286
791
  platform: "ios",
287
792
  checks: [
288
- { ...META.appStoreConnect, run: (i) => checkAppStoreConnect(i.secretKeys) },
793
+ {
794
+ ...META.appStoreConnect,
795
+ run: (i) => checkAppStoreConnect({
796
+ secretKeys: i.secretKeys,
797
+ uses: i.uses,
798
+ keyFilesInRepo: i.keyFilesInRepo,
799
+ appfile: i.appfile,
800
+ unread: i.unread,
801
+ blocks: i.blocks ?? [],
802
+ }),
803
+ },
289
804
  { ...META.match, run: (i) => checkMatch(i.uses, i.secretKeys) },
290
805
  ],
291
806
  },
292
807
  {
293
808
  platform: "android",
294
809
  checks: [
295
- { ...META.androidKeystore, run: (i) => checkAndroidKeystore(i.uses, i.secretKeys) },
296
- { ...META.playStore, run: (i) => checkPlayStore(i.uses, i.secretKeys) },
810
+ {
811
+ ...META.androidKeystore,
812
+ run: (i) => checkAndroidKeystore(i.uses, i.secretKeys, i.unread, i.blocks ?? []),
813
+ },
814
+ {
815
+ ...META.playStore,
816
+ run: (i) => checkPlayStore(i.uses, i.secretKeys, i.appfile, i.unread, i.blocks ?? []),
817
+ },
818
+ {
819
+ ...META.releaseSigning,
820
+ run: (i) => checkReleaseSigning({
821
+ gradle: i.androidSigning,
822
+ conditionalFilePresent: i.signingFilePresent,
823
+ keystore: i.keystore ?? null,
824
+ }),
825
+ },
297
826
  ],
298
827
  },
299
828
  ];